diff --git a/.claude/skills/obs-org-coordinator/SKILL.md b/.claude/skills/obs-org-coordinator/SKILL.md index 81dcf12..b96a8dc 100644 --- a/.claude/skills/obs-org-coordinator/SKILL.md +++ b/.claude/skills/obs-org-coordinator/SKILL.md @@ -34,6 +34,13 @@ would not find the lease `take` made. ## 1. Get the list +First, apply claims across the org. Most repos have no claim-bot workflow, +and GitHub sometimes skips scheduled runs: + +```sh +uv run -m workflow.claim_bot sweep --org obs-rust +``` + ```sh uv run -m workflow.coordinator # grouped, with instructions per kind uv run -m workflow.coordinator --json # every task diff --git a/WORKFLOW.md b/WORKFLOW.md index 476acbc..419abb5 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -65,6 +65,12 @@ comment from the claimant and no commit, comment or review on their open PR that references the issue. The hourly sweep unassigns it. Refused commands get one reply that mentions only the person refused. +Every coordinator tick also runs `uv run -m workflow.claim_bot sweep --org +obs-rust`. It handles claims in repos that have no caller workflow, within +one tick, and covers for scheduled runs that GitHub skips or delays: in one +case obs-studio's hourly sweep went 4 hours without running. A repo with a +caller sees only no-op replays. + A repo turns claims on with this workflow (`.github/workflows/claim-bot.yaml`): ```yaml diff --git a/tests/test_flows.py b/tests/test_flows.py index c3eeae9..7c6fc73 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -302,6 +302,21 @@ def test_sweep_sync_and_coordinator_against_a_saved_world(self): self.assertIn("added 1", self.run_cli("workflow.board_sync", db=db)) self.assertIn("triage", self.run_cli("workflow.coordinator", db=db) + "triage") + def test_org_sweep_handles_claims_in_repos_without_a_caller(self): + with tempfile.TemporaryDirectory() as d: + db = str(Path(d) / "world.db") + w = World(db) + a = w.open_issue("obs-rust/obs-browser", labels={"ready"}) + b = w.open_issue("obs-rust/obs-deps", labels={"ready"}) + w.comment("obs-rust/obs-browser", a, "alice", "Claiming: it", deliver=False) + w.comment("obs-rust/obs-deps", b, "bob", "Claiming: it", deliver=False) + w.shutdown() + self.run_cli("workflow.claim_bot", "sweep", "--org", "obs-rust", db=db) + w = World(db) + self.addCleanup(w.shutdown) + self.assertEqual(w.assignees("obs-rust/obs-browser", a), ["alice"]) + self.assertEqual(w.assignees("obs-rust/obs-deps", b), ["bob"]) + if __name__ == "__main__": unittest.main() diff --git a/workflow/claim_bot.py b/workflow/claim_bot.py index 2da70eb..1da479b 100644 --- a/workflow/claim_bot.py +++ b/workflow/claim_bot.py @@ -4,6 +4,7 @@ uv run -m workflow.claim_bot comment # an issue_comment event uv run -m workflow.claim_bot sweep # the hourly expiry sweep uv run -m workflow.claim_bot sweep --sim w.db # against a simulated GitHub + uv run -m workflow.claim_bot sweep --org obs-rust # every repo (coordinator tick) The repo comes from --repo or GITHUB_REPOSITORY, the comment event from GITHUB_EVENT_PATH. The comment body is only ever read from the event file. @@ -88,6 +89,7 @@ def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("mode", choices=["auto", "comment", "sweep"]) ap.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "")) + ap.add_argument("--org", help="sweep every repo of this org (repos with no caller)") ap.add_argument("--sim", help="SQLite world to run against instead of GitHub") ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() @@ -98,6 +100,11 @@ def main() -> int: if args.mode == "comment": with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as f: on_comment(gh, args.repo, json.load(f), now, args.dry_run) + elif args.org: + # Repos without a claim-bot caller get claims handled here, on the + # coordinator's tick; repos with one see only no-op replays. + for repo in gh.repos(args.org): + sweep(gh, repo, now, args.dry_run) else: sweep(gh, args.repo, now, args.dry_run) return 0 diff --git a/workflow/gh.py b/workflow/gh.py index 28719aa..a478f85 100644 --- a/workflow/gh.py +++ b/workflow/gh.py @@ -238,6 +238,10 @@ def items(self, org: str) -> list[Item]: i for chunk in pool.map(lambda r: self._repo_items(org, r), repos) for i in chunk ] + def repos(self, org: str) -> list[str]: + self.queued = set() + return [f"{org}/{r}" for r in self._repos(org)] + def _repos(self, org: str) -> list[str]: repos, cursor = [], None while True: diff --git a/workflow/sim.py b/workflow/sim.py index eff7697..8238be1 100644 --- a/workflow/sim.py +++ b/workflow/sim.py @@ -206,6 +206,9 @@ def apply_issue(self, repo: str, number: int, action: tuple) -> None: ) self.log(BOT, f"{repo}#{number} {kind} {str(arg)[:60]}") + def repos(self, org: str) -> list[str]: + return [r for (r,) in self.q("SELECT DISTINCT repo FROM items ORDER BY repo")] + # ---- board (same interface as GhBackend) -------------------------- def items(self, org: str) -> list[Item]: