Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude/skills/obs-org-coordinator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/test_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
7 changes: 7 additions & 0 deletions workflow/claim_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions workflow/gh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions workflow/sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Loading