From 925c266b7ac2710072d781458b956ab844199424 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 16 Jul 2026 15:02:52 +0100 Subject: [PATCH] =?UTF-8?q?feat(community):=20v2=20=E2=80=94=20the=20Ears?= =?UTF-8?q?=20hear=20external=20PRs=20+=20review=20requests=20(#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The community scan and triage surfaces now cover pull requests, closing the staged v1 follow-up (#119 / #122): - scan: two new legs per qualifier group — open PRs authored by non-self humans (bot-filtered, same awaiting-response detection, merged into the ranked waiting list) and open PRs with review requested from a self login (any author). Surface gains open_external_prs + awaiting_review + counts; degraded labels now distinguish the review-requested pass. - triage: accepts PR refs (incl. /pull/ URLs); a PR carries the change-shape block (draft, changed files, +/-, requested reviewers, mergeable state, head -> base) and routes to *human review with session-drafted comments* — never the ship-gate review faculty. - rate-limit hardening: COMMUNITY_SEARCH_PAUSE (default 2s) between the up-to six search calls — GitHub's secondary limit trips on bursts (found live). - docs: conductor AGENTS.md (modes, capability audit, v1-scope note retired, review-thread-comments limit recorded), /community skill body, dispatcher description; command surface regenerated. - tests: 8 hermetic contract tests (2 new: PR scan + review-requested leg, PR triage block); no-mutation proof covers the new endpoints. Tests: 77 passed. Live-verified: issue + external-PR legs pass against real GitHub; review-requested legs degraded honestly under the account throttle. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 +- agents/conductors/community/AGENTS.md | 38 +++-- agents/conductors/community/_community.py | 184 +++++++++++++++------- agents/conductors/community/community.sh | 10 +- bin/pyauto-brain | 4 +- skills/community/community.md | 21 ++- tests/test_community_conductor.py | 128 +++++++++++---- 7 files changed, 271 insertions(+), 116 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index da80e06..89b3b1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,7 +242,7 @@ the `/` slash commands. | Verb | Purpose | Entrypoint | |------|---------|------------| | `intake` | Conceive a task: turn raw input into a formal, headed PyAutoMind prompt (files it; never starts dev) | `bin/pyauto-brain intake` | -| `community` | The ears — the organism's receptive function: scan/triage user-filed GitHub issues across the repos; drafts stay human-gated, dev work routes via start_dev_for_user (never posts) | `bin/pyauto-brain community` | +| `community` | The ears — the organism's receptive function: scan/triage user-filed GitHub issues + PRs and review requests across the repos; drafts stay human-gated, dev work routes via start_dev_for_user (never posts) | `bin/pyauto-brain community` | | `feature` | Reason over PyAutoMind feature tasks: select, size, phase, plan for start_dev | `bin/pyauto-brain feature` | | `bug` | The immune system: classify a bug/regression/Heart finding, locate the fix, plan the repair | `bin/pyauto-brain bug` | | `refactor` | The renewal function: plan behaviour-preserving restructuring — RefactorDecision; default-safe under --auto | `bin/pyauto-brain refactor` | diff --git a/agents/conductors/community/AGENTS.md b/agents/conductors/community/AGENTS.md index 4b113e5..42ad763 100644 --- a/agents/conductors/community/AGENTS.md +++ b/agents/conductors/community/AGENTS.md @@ -2,7 +2,8 @@ > **Tier: conductor** — a front-door agent you *drive*. The *Ears* — the > organism's receptive language function: it hears the community (user-filed -> GitHub issues across every repo) and drafts what the organism says back; the +> GitHub issues and pull requests across every repo) and drafts what the +> organism says back; the > human remains the mouth. Wernicke to the Workspace Agent's Broca: that > *Voice* speaks to users through examples and tutorials, this agent > *comprehends and converses* — it reads an outsider's issue, judges whether @@ -27,20 +28,22 @@ gates every outward message. | Mode | Surface | Consumed by | |------|---------|-------------| -| `scan` *(default)* | every `PyAutoMind/repos.yaml` repo → open issues authored by non-self humans (bots filtered), with **awaiting-response** detection (the conversation's last word is not ours) ranked by waiting time | `/community` step 1; the `/wake_up` community sensory leg | -| `triage ` | one issue → context-sufficiency signals (code block, traceback, versions, expected-vs-actual, data pointer), missing-signal clarifying-question seeds, comment tail, route | `/community` steps 2–3 | +| `scan` *(default)* | every `PyAutoMind/repos.yaml` repo → open issues **and PRs** authored by non-self humans (bots filtered), with **awaiting-response** detection (the conversation's last word is not ours) ranked by waiting time, plus open PRs with **review requested** from a self login (any author) | `/community` step 1; the `/wake_up` community sensory leg | +| `triage ` | one issue or PR → context-sufficiency signals (code block, traceback, versions, expected-vs-actual, data pointer), missing-signal clarifying-question seeds, comment tail, route; a PR ref adds the **change-shape block** (draft, files, +/-, requested reviewers, mergeable state, head→base) | `/community` steps 2–3 | ``` pyauto-brain community # scan: who is waiting on us? pyauto-brain community scan --json -pyauto-brain community triage [--json] +pyauto-brain community triage [--json] ``` Repo enumeration comes from `PyAutoMind/repos.yaml` (the body map) under `PYAUTO_ROOT`; the org is searched wholesale, non-org homes individually. GitHub access is the `gh` CLI — `COMMUNITY_GH` overrides the binary (hermetic -tests), `COMMUNITY_SELF` the self logins (default `Jammy2211`). A failed -search degrades honestly (`degraded:` in the surface), never silently. +tests), `COMMUNITY_SELF` the self logins (default `Jammy2211`), +`COMMUNITY_SEARCH_PAUSE` the inter-search sleep (default 2s — the scan makes +up to six search calls and GitHub's secondary rate limit trips on bursts). A +failed search degrades honestly (`degraded:` in the surface), never silently. ## Fundamental principles @@ -79,17 +82,24 @@ search degrades honestly (`degraded:` in the surface), never silently. reporter-facing updates after routing. - **vs bug / feature** — they classify and plan the *work*; community manages the *relationship* with the person who reported it. -- **vs health / hygiene / release** — no verdicts, no upkeep, no releases; - external PRs and review requests are out of scope for v1 (a staged - follow-up recorded here). +- **vs health / hygiene / release** — no verdicts, no upkeep, no releases. +- **vs the review faculty** — an external PR surfaced here routes to a + *human review with session-drafted comments*; the review faculty judges + only our own feature branches for the autonomous-ship gate, never + community PRs. Known v2 limit: awaiting-response reads PR *conversation* + comments — review-thread comments don't count as our reply yet. ## Capability audit — what the modes read - **GitHub search** (`gh api search/issues`): `org:PyAutoLabs` plus the - non-org homes from `repos.yaml`, `is:issue is:open -author:`, bot - authors post-filtered. -- **Issue comments** (`gh api repos///issues//comments`): last-actor - detection for awaiting-response (capped at 30 issues per scan) and the - triage comment tail. + non-org homes from `repos.yaml`; three passes per qualifier group — + `is:issue is:open -author:`, `is:pr is:open -author:`, and + `is:pr is:open review-requested:` — bot authors post-filtered on the + external passes. +- **Issue/PR conversation comments** (`gh api repos///issues//comments`): + last-actor detection for awaiting-response (capped at 30 items per scan) + and the triage comment tail. +- **Pull detail** (`gh api repos///pulls/`): the triage change-shape + block for PR refs. - **PyAutoMind `repos.yaml`**: the body map, parsed for `github:` homes (regex, stdlib-only — the Brain takes no yaml dependency). diff --git a/agents/conductors/community/_community.py b/agents/conductors/community/_community.py index 0411a02..f4929d9 100644 --- a/agents/conductors/community/_community.py +++ b/agents/conductors/community/_community.py @@ -6,10 +6,13 @@ hears the community). It reads the outside world's GitHub issues and emits deterministic surfaces the /community skill reasons over: - scan every repos.yaml repo -> open issues raised by non-self humans, - with awaiting-response detection and waiting-time ranking + scan every repos.yaml repo -> open issues AND pull requests raised by + non-self humans (awaiting-response detection, waiting-time + ranking) + open PRs with review requested from a self login (the /wake_up community sensory leg) - triage one issue -> context-sufficiency signals + routing surface + triage one issue or PR -> context-sufficiency signals + routing surface; + PR refs additionally carry the change-shape block (draft, files, + additions/deletions, requested reviewers, mergeable state) (the judgment — actionable vs ask-for-more — stays in the session) The conductor NEVER posts, labels or edits anything on GitHub and never writes @@ -29,6 +32,7 @@ import re import subprocess import sys +import time from datetime import datetime, timezone from pathlib import Path @@ -41,6 +45,9 @@ ] PRIMARY_ORG = "PyAutoLabs" SCAN_DETAIL_CAP = 30 # issues that get a per-issue last-commenter lookup +# Pause between search-API calls — the scan makes up to six, and GitHub's +# secondary rate limit trips on rapid bursts (hermetic tests set it to 0). +SEARCH_PAUSE_S = float(os.environ.get("COMMUNITY_SEARCH_PAUSE", "2")) # Context signals a well-formed report tends to carry. Each is (key, ask) — # the ask is the clarifying-question seed the skill session redrafts in its @@ -107,16 +114,25 @@ def days_since(iso): return round((datetime.now(timezone.utc) - then).total_seconds() / 86400, 1) -def search_external_issues(qualifier): - q = f"{qualifier} is:issue is:open " + " ".join( - f"-author:{login}" for login in SELF_LOGINS - ) +def search_open(qualifier, kind, extra_quals=""): + """Open issues or PRs (`kind` = issue|pr) matching the qualifier; None on + a failed search.""" + q = f"{qualifier} is:{kind} is:open" + if extra_quals: + q += f" {extra_quals}" + if SEARCH_PAUSE_S: + time.sleep(SEARCH_PAUSE_S) data = gh_json(["-X", "GET", "search/issues", "-f", f"q={q}", "-f", "per_page=50"]) if data is None: return None return data.get("items", []) +def search_external(qualifier, kind): + not_self = " ".join(f"-author:{login}" for login in SELF_LOGINS) + return search_open(qualifier, kind, not_self) + + def last_commenter(owner_repo, number): """Login of the newest comment's author; None when uncommented/unreadable.""" comments = gh_json([f"repos/{owner_repo}/issues/{number}/comments", "-f", "per_page=100"]) @@ -130,48 +146,62 @@ def issue_repo(item): return "/".join(item.get("repository_url", "").split("/")[-2:]) +def _entry(item, kind): + return { + "type": kind, + "repo": issue_repo(item), + "number": item.get("number"), + "title": item.get("title", ""), + "author": (item.get("user") or {}).get("login"), + "url": item.get("html_url"), + "labels": [l.get("name") for l in item.get("labels", [])], + "comments": item.get("comments", 0), + "updated_at": item.get("updated_at"), + "waiting_days": days_since(item.get("updated_at")), + "last_actor": None, + "awaiting_response": None, + } + + +def _searched(qualifiers, kind, degraded, external=True): + """Run one search per qualifier group, folding failures into `degraded`.""" + entries = [] + tag = kind if external else "review-requested" + for label, qualifier in qualifiers: + items = ( + search_external(qualifier, kind) + if external + else search_open(qualifier, kind, "review-requested:" + SELF_LOGINS[0]) + ) + if items is None: + degraded.append(f"{label} {tag} search failed (gh auth? rate limit?)") + continue + entries += [_entry(i, kind) for i in items if not is_bot(i.get("user"))] + return entries + + def build_scan(): homes = repo_homes() extra = [h for h in homes if not h.startswith(f"{PRIMARY_ORG}/")] - - items, degraded = [], [] - org_items = search_external_issues(f"org:{PRIMARY_ORG}") - if org_items is None: - degraded.append(f"org:{PRIMARY_ORG} search failed (gh auth? rate limit?)") - else: - items += org_items + qualifiers = [(f"org:{PRIMARY_ORG}", f"org:{PRIMARY_ORG}")] if extra: - extra_items = search_external_issues(" ".join(f"repo:{h}" for h in extra)) - if extra_items is None: - degraded.append("non-org repo search failed") - else: - items += extra_items - - issues = [] - for item in items: - if is_bot(item.get("user")): - continue - repo = issue_repo(item) - entry = { - "repo": repo, - "number": item.get("number"), - "title": item.get("title", ""), - "author": (item.get("user") or {}).get("login"), - "url": item.get("html_url"), - "labels": [l.get("name") for l in item.get("labels", [])], - "comments": item.get("comments", 0), - "updated_at": item.get("updated_at"), - "waiting_days": days_since(item.get("updated_at")), - "last_actor": None, - "awaiting_response": None, - } - issues.append(entry) + qualifiers.append(("non-org", " ".join(f"repo:{h}" for h in extra))) + + degraded = [] + issues = _searched(qualifiers, "issue", degraded) + prs = _searched(qualifiers, "pr", degraded) + # Review requests target a self login regardless of author (a bot's PR + # asking for review is still ours to answer, so no external filter). + review_requested = _searched(qualifiers, "pr", degraded, external=False) # Awaiting-response = the conversation's last word is not ours. Cap the - # per-issue lookups; uncapped entries keep awaiting_response=None (unknown). - for entry in sorted(issues, key=lambda e: e["updated_at"] or "", reverse=True)[ - :SCAN_DETAIL_CAP - ]: + # per-item lookups; uncapped entries keep awaiting_response=None (unknown). + # Uses issue-conversation comments (PR review-thread comments are a known + # v2 limit, recorded in AGENTS.md). + conversations = issues + prs + for entry in sorted( + conversations, key=lambda e: e["updated_at"] or "", reverse=True + )[:SCAN_DETAIL_CAP]: actor = ( entry["author"] if entry["comments"] == 0 @@ -180,21 +210,25 @@ def build_scan(): entry["last_actor"] = actor entry["awaiting_response"] = actor is not None and actor not in SELF_LOGINS - awaiting = [e for e in issues if e["awaiting_response"]] + awaiting = [e for e in conversations if e["awaiting_response"]] awaiting.sort(key=lambda e: e["waiting_days"] or 0, reverse=True) return { "self_logins": SELF_LOGINS, "org": PRIMARY_ORG, "extra_repos": extra, "open_external_issues": issues, + "open_external_prs": prs, + "awaiting_review": review_requested, "awaiting_response": awaiting, "counts": { "open_external": len(issues), + "open_external_prs": len(prs), + "awaiting_review": len(review_requested), "awaiting_response": len(awaiting), }, "degraded": degraded, "next_action": ( - "pick an issue -> `community triage ` -> the /community session " + "pick an item -> `community triage ` -> the /community session " "assesses context, drafts the reply for human approval, and routes " "actionable work via /start_dev_for_user; this surface posts nothing" ), @@ -209,25 +243,52 @@ def print_scan(s): for d in s["degraded"]: print(f"DEGRADED: {d}") c = s["counts"] - print(f"Open external issues: {c['open_external']} (awaiting our response: {c['awaiting_response']})") + print(f"Open external: {c['open_external']} issue(s), {c['open_external_prs']} PR(s)" + f" (awaiting our response: {c['awaiting_response']})") for e in s["awaiting_response"]: days = f"{e['waiting_days']:.0f}d" if e["waiting_days"] is not None else "?" - print(f" ! {e['repo']}#{e['number']} [{days} waiting] @{e['author']}: {e['title'][:70]}") - for e in s["open_external_issues"]: + kind = "PR " if e["type"] == "pr" else "" + print(f" ! {kind}{e['repo']}#{e['number']} [{days} waiting] @{e['author']}: {e['title'][:70]}") + for e in s["open_external_issues"] + s["open_external_prs"]: if not e["awaiting_response"]: state = "ours-to-watch" if e["awaiting_response"] is False else "unchecked" - print(f" - {e['repo']}#{e['number']} ({state}) @{e['author']}: {e['title'][:70]}") + kind = "PR " if e["type"] == "pr" else "" + print(f" - {kind}{e['repo']}#{e['number']} ({state}) @{e['author']}: {e['title'][:70]}") + if s["awaiting_review"]: + print(f"Review requested: {c['awaiting_review']}") + for e in s["awaiting_review"]: + print(f" * {e['repo']}#{e['number']} @{e['author']}: {e['title'][:70]}") print(f"Next action: {s['next_action']}") def parse_issue_ref(ref): - m = re.match(r"https?://github\.com/([^/]+/[^/]+)/issues/(\d+)", ref) + m = re.match(r"https?://github\.com/([^/]+/[^/]+)/(?:issues|pull)/(\d+)", ref) if m: return m.group(1), int(m.group(2)) m = re.match(r"([^/#\s]+/[^/#\s]+)#(\d+)$", ref) if m: return m.group(1), int(m.group(2)) - fail(5, f"cannot parse issue ref '{ref}' — use a full URL or owner/repo#N") + fail(5, f"cannot parse ref '{ref}' — use a full issue/PR URL or owner/repo#N") + + +def pr_block(owner_repo, number): + """The change-shape block for a PR ref; None when the pulls endpoint is + unreadable (surface degrades, never invents).""" + pr = gh_json([f"repos/{owner_repo}/pulls/{number}"]) + if pr is None: + return None + return { + "draft": pr.get("draft"), + "changed_files": pr.get("changed_files"), + "additions": pr.get("additions"), + "deletions": pr.get("deletions"), + "mergeable_state": pr.get("mergeable_state"), + "requested_reviewers": [ + (u or {}).get("login") for u in pr.get("requested_reviewers", []) + ], + "base": (pr.get("base") or {}).get("ref"), + "head": (pr.get("head") or {}).get("ref"), + } def build_triage(ref): @@ -262,7 +323,10 @@ def build_triage(ref): ] last = tail[-1]["author"] if tail else (issue.get("user") or {}).get("login") + is_pr = "pull_request" in issue return { + "type": "pr" if is_pr else "issue", + "pr": pr_block(owner_repo, number) if is_pr else None, "repo": owner_repo, "number": number, "url": issue.get("html_url"), @@ -276,7 +340,12 @@ def build_triage(ref): "signals_missing": missing, "comment_tail": tail, "awaiting_response": last not in SELF_LOGINS, - "route": f"/start_dev_for_user {issue.get('html_url')}", + "route": ( + f"human review of PR {issue.get('html_url')} — session drafts the " + f"review comments (this is NOT the ship-gate review faculty)" + if is_pr + else f"/start_dev_for_user {issue.get('html_url')}" + ), "reminders": [ "the session judges sufficiency — these signals are heuristics, not a verdict", "every outward comment is drafted and shown to the human before posting", @@ -288,11 +357,18 @@ def build_triage(ref): def print_triage(t): - print(f"== CommunityTriage — {t['repo']}#{t['number']} ==") + kind = "PR" if t["type"] == "pr" else "issue" + print(f"== CommunityTriage — {t['repo']}#{t['number']} ({kind}) ==") print(f"Title: {t['title']}") print(f"Author: @{t['author']}" + (" (external)" if t["author_is_external"] else " (self)")) print(f"State: {t['state']} Labels: {', '.join(t['labels']) or '(none)'}") + if t["pr"]: + p = t["pr"] + reviewers = ", ".join(p["requested_reviewers"]) or "(none)" + print(f"PR shape: {p['changed_files']} file(s), +{p['additions']}/-{p['deletions']}" + f" draft={p['draft']} mergeable={p['mergeable_state']} {p['head']} -> {p['base']}") + print(f"Review requested: {reviewers}") print(f"Awaiting response: {t['awaiting_response']}") print("Context signals:") for key, ok in t["signals_present"].items(): @@ -316,7 +392,7 @@ def main(): parser.add_argument("mode", nargs="?", default="scan", help="scan (default) | triage ") parser.add_argument("ref", nargs="?", default=None, - help="triage only: issue URL or owner/repo#N") + help="triage only: issue/PR URL or owner/repo#N") parser.add_argument("--json", action="store_true") args = parser.parse_args(argv) diff --git a/agents/conductors/community/community.sh b/agents/conductors/community/community.sh index eae63a8..b1695ed 100644 --- a/agents/conductors/community/community.sh +++ b/agents/conductors/community/community.sh @@ -12,11 +12,13 @@ # labels, edits or writes anything. # # Usage: -# community.sh # scan (default): open external issues, -# # awaiting-response ranking +# community.sh # scan (default): open external issues +# # + PRs, awaiting-response ranking, +# # review requests # community.sh scan # same, explicit -# community.sh triage # one issue -> context-sufficiency surface -# # ( = URL or owner/repo#N) +# community.sh triage # one issue/PR -> context-sufficiency +# # surface (+ PR change-shape block); +# # = issue/PR URL or owner/repo#N # community.sh ... --json # machine-readable output set -uo pipefail diff --git a/bin/pyauto-brain b/bin/pyauto-brain index 91288f8..098ca74 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -12,7 +12,7 @@ # CONSULT (they only opine; never dispatch or # mutate). Runnable directly for a quick read. # -# pyauto-brain community [args] (conductor) the Ears: scan/triage user-filed issues across the repos — read-only surfaces, replies stay human-gated +# pyauto-brain community [args] (conductor) the Ears: scan/triage user-filed issues + PRs across the repos — read-only surfaces, replies stay human-gated # pyauto-brain feature [args] (conductor) reason over PyAutoMind tasks, plan growth # pyauto-brain refactor [args] (conductor) plan behaviour-preserving restructuring (default-safe) # pyauto-brain workspace [args] (conductor) the Voice: plan/survey workspace + HowTo example authorship — WorkspaceDecision (writes nothing) @@ -66,7 +66,7 @@ declare -A AGENT_SCRIPT=( ) declare -A AGENT_DESC=( [intake]="Conceive a task: turn raw input into a formal, headed PyAutoMind prompt (files it; never starts dev)" - [community]="The ears — the organism's receptive function: scan/triage user-filed GitHub issues across the repos; drafts stay human-gated, dev work routes via start_dev_for_user (never posts)" + [community]="The ears — the organism's receptive function: scan/triage user-filed GitHub issues + PRs and review requests across the repos; drafts stay human-gated, dev work routes via start_dev_for_user (never posts)" [feature]="Reason over PyAutoMind feature tasks: select, size, phase, plan for start_dev" [bug]="The immune system: classify a bug/regression/Heart finding, locate the fix, plan the repair" [refactor]="The renewal function: plan behaviour-preserving restructuring — RefactorDecision; default-safe under --auto" diff --git a/skills/community/community.md b/skills/community/community.md index 3343c38..7bf1a8c 100644 --- a/skills/community/community.md +++ b/skills/community/community.md @@ -16,21 +16,23 @@ docs: `bin/pyauto-brain help community`. bin/pyauto-brain community # default mode ``` - Emits a **CommunityScan**: open issues authored by non-self humans across - every `repos.yaml` repo, with awaiting-response detection (the - conversation's last word is not ours) ranked by waiting time. `/wake_up` - runs this same scan as its community step. + Emits a **CommunityScan**: open issues **and PRs** authored by non-self + humans across every `repos.yaml` repo, with awaiting-response detection + (the conversation's last word is not ours) ranked by waiting time, plus + open PRs with review requested from you. `/wake_up` runs this same scan + as its community step. -2. **Triage** the issue the human picks: +2. **Triage** the item the human picks: ```bash - bin/pyauto-brain community triage + bin/pyauto-brain community triage ``` Emits context-sufficiency signals (code block, traceback, versions, expected-vs-actual, data pointer), clarifying-question seeds for whatever - is missing, and the comment tail. The signals are heuristics — **you** read - the actual issue and judge. + is missing, and the comment tail; a PR ref adds the change-shape block + (draft, files, +/-, requested reviewers, mergeable state). The signals are + heuristics — **you** read the actual issue or PR and judge. 3. **Converse — drafts only.** Based on your judgment: - **Actionable** → route into `/start_dev_for_user ` — it owns the @@ -43,6 +45,9 @@ docs: `bin/pyauto-brain help community`. - **In-flight follow-ups** (reporter replied, milestone reached) → draft the update the same way; `/update_issue` posts progress from a dev session. Cadence: ~5 milestones for bugs, ~4 for features. + - **External PR / review request** → the review is yours with the human: + read the diff, draft the review comments for approval. Never route a + community PR through the ship-gate review faculty. ## Boundary diff --git a/tests/test_community_conductor.py b/tests/test_community_conductor.py index 6c3bdc0..8825a7e 100644 --- a/tests/test_community_conductor.py +++ b/tests/test_community_conductor.py @@ -18,12 +18,14 @@ SCAN_KEYS = { "self_logins", "org", "extra_repos", "open_external_issues", - "awaiting_response", "counts", "degraded", "next_action", + "open_external_prs", "awaiting_review", "awaiting_response", "counts", + "degraded", "next_action", } TRIAGE_KEYS = { - "repo", "number", "url", "title", "author", "author_is_external", "state", - "labels", "body", "signals_present", "signals_missing", "comment_tail", - "awaiting_response", "route", "reminders", + "type", "pr", "repo", "number", "url", "title", "author", + "author_is_external", "state", "labels", "body", "signals_present", + "signals_missing", "comment_tail", "awaiting_response", "route", + "reminders", } REPOS_YAML = """\ @@ -36,8 +38,16 @@ category: admin """ +EMPTY_SEARCHES = { + "search_org_issues.json": {"items": []}, + "search_org_prs.json": {"items": []}, + "search_org_review.json": {"items": []}, + "search_extra.json": {"items": []}, + "comments.json": [], +} + -def _issue(repo, number, login, title, comments=0, user_type="User"): +def _item(repo, number, login, title, comments=0, user_type="User"): return { "number": number, "title": title, @@ -67,10 +77,13 @@ def _fabricate(tmp_path, fixtures): echo "$@" >> "{tmp_path}/gh_calls.log" for arg in "$@"; do case "$arg" in - q=org:*) cat "{fixture_dir}/search_org.json"; exit 0 ;; - q=repo:*) cat "{fixture_dir}/search_extra.json"; exit 0 ;; - */comments) cat "{fixture_dir}/comments.json"; exit 0 ;; - repos/*/issues/*) cat "{fixture_dir}/issue.json"; exit 0 ;; + q=org:*review-requested*) cat "{fixture_dir}/search_org_review.json"; exit 0 ;; + q=org:*is:issue*) cat "{fixture_dir}/search_org_issues.json"; exit 0 ;; + q=org:*is:pr*) cat "{fixture_dir}/search_org_prs.json"; exit 0 ;; + q=repo:*) cat "{fixture_dir}/search_extra.json"; exit 0 ;; + */comments) cat "{fixture_dir}/comments.json"; exit 0 ;; + repos/*/pulls/*) cat "{fixture_dir}/pull.json"; exit 0 ;; + repos/*/issues/*) cat "{fixture_dir}/issue.json"; exit 0 ;; esac done exit 1 @@ -80,7 +93,12 @@ def _fabricate(tmp_path, fixtures): def _run(args, tmp_path, stub): - env = {**os.environ, "PYAUTO_ROOT": str(tmp_path), "COMMUNITY_GH": str(stub)} + env = { + **os.environ, + "PYAUTO_ROOT": str(tmp_path), + "COMMUNITY_GH": str(stub), + "COMMUNITY_SEARCH_PAUSE": "0", + } return subprocess.run( [str(BRAIN), "community", *args], capture_output=True, text=True, env=env, cwd=tmp_path, @@ -89,12 +107,11 @@ def _run(args, tmp_path, stub): def test_scan_json_is_a_complete_surface_and_filters_bots(tmp_path): stub = _fabricate(tmp_path, { - "search_org.json": {"items": [ - _issue("PyAutoLabs/PyAutoLens", 1, "some_user", "lens model crashes"), - _issue("PyAutoLabs/PyAutoLens", 2, "dependabot[bot]", "bump", user_type="Bot"), + **EMPTY_SEARCHES, + "search_org_issues.json": {"items": [ + _item("PyAutoLabs/PyAutoLens", 1, "some_user", "lens model crashes"), + _item("PyAutoLabs/PyAutoLens", 2, "dependabot[bot]", "bump", user_type="Bot"), ]}, - "search_extra.json": {"items": []}, - "comments.json": [], }) r = _run(["scan", "--json"], tmp_path, stub) assert r.returncode == 0, r.stderr @@ -103,17 +120,21 @@ def test_scan_json_is_a_complete_surface_and_filters_bots(tmp_path): assert s["org"] == "PyAutoLabs" assert s["extra_repos"] == ["Jammy2211/admin_jammy"] assert [e["author"] for e in s["open_external_issues"]] == ["some_user"] - assert s["counts"] == {"open_external": 1, "awaiting_response": 1} + assert s["open_external_issues"][0]["type"] == "issue" + assert s["counts"] == { + "open_external": 1, "open_external_prs": 0, + "awaiting_review": 0, "awaiting_response": 1, + } # Uncommented external issue: the author had the last word -> awaiting us. assert s["awaiting_response"][0]["last_actor"] == "some_user" def test_scan_last_word_ours_is_not_awaiting(tmp_path): stub = _fabricate(tmp_path, { - "search_org.json": {"items": [ - _issue("PyAutoLabs/PyAutoLens", 3, "some_user", "feature request", comments=2), + **EMPTY_SEARCHES, + "search_org_issues.json": {"items": [ + _item("PyAutoLabs/PyAutoLens", 3, "some_user", "feature request", comments=2), ]}, - "search_extra.json": {"items": []}, "comments.json": [ {"user": {"login": "some_user"}, "created_at": "t1", "body": "ping"}, {"user": {"login": "Jammy2211"}, "created_at": "t2", "body": "on it"}, @@ -125,23 +146,42 @@ def test_scan_last_word_ours_is_not_awaiting(tmp_path): assert s["open_external_issues"][0]["last_actor"] == "Jammy2211" -def test_triage_surface_signals_and_missing_asks(tmp_path): +def test_scan_hears_external_prs_and_review_requests(tmp_path): + stub = _fabricate(tmp_path, { + **EMPTY_SEARCHES, + "search_org_prs.json": {"items": [ + _item("PyAutoLabs/PyAutoLens", 40, "contributor", "add cored profile"), + ]}, + "search_org_review.json": {"items": [ + _item("PyAutoLabs/PyAutoFit", 41, "rhayes777", "please review: validation"), + ]}, + }) + s = json.loads(_run(["scan", "--json"], tmp_path, stub).stdout) + assert s["counts"]["open_external_prs"] == 1 + assert s["counts"]["awaiting_review"] == 1 + pr = s["open_external_prs"][0] + assert pr["type"] == "pr" and pr["author"] == "contributor" + # An uncommented external PR is a conversation awaiting our response too. + assert any(e["type"] == "pr" and e["number"] == 40 for e in s["awaiting_response"]) + assert s["awaiting_review"][0]["repo"] == "PyAutoLabs/PyAutoFit" + + +def test_triage_issue_signals_and_missing_asks(tmp_path): body = "```python\nfit = al.FitImaging(...)\n```\nTraceback (most recent call last):\nboom" stub = _fabricate(tmp_path, { - "search_org.json": {"items": []}, - "search_extra.json": {"items": []}, + **EMPTY_SEARCHES, "issue.json": { "number": 7, "title": "crash", "state": "open", "body": body, "user": {"login": "some_user", "type": "User"}, "html_url": "https://github.com/PyAutoLabs/PyAutoLens/issues/7", "labels": [{"name": "bug"}], }, - "comments.json": [], }) r = _run(["triage", "PyAutoLabs/PyAutoLens#7", "--json"], tmp_path, stub) assert r.returncode == 0, r.stderr t = json.loads(r.stdout) assert set(t) == TRIAGE_KEYS + assert t["type"] == "issue" and t["pr"] is None assert t["author_is_external"] is True assert t["signals_present"]["code_block"] is True assert t["signals_present"]["traceback"] is True @@ -150,12 +190,37 @@ def test_triage_surface_signals_and_missing_asks(tmp_path): assert t["route"].startswith("/start_dev_for_user ") -def test_never_writes_and_never_mutates_github(tmp_path): +def test_triage_pr_ref_carries_the_change_shape_block(tmp_path): stub = _fabricate(tmp_path, { - "search_org.json": {"items": []}, - "search_extra.json": {"items": []}, - "comments.json": [], + **EMPTY_SEARCHES, + "issue.json": { + "number": 40, "title": "add cored profile", "state": "open", + "body": "adds a cored isothermal profile", + "user": {"login": "contributor", "type": "User"}, + "html_url": "https://github.com/PyAutoLabs/PyAutoLens/pull/40", + "labels": [], + "pull_request": {"url": "https://api.github.com/repos/PyAutoLabs/PyAutoLens/pulls/40"}, + }, + "pull.json": { + "draft": False, "changed_files": 3, "additions": 120, "deletions": 4, + "mergeable_state": "clean", + "requested_reviewers": [{"login": "Jammy2211"}], + "base": {"ref": "main"}, "head": {"ref": "feature/cored-profile"}, + }, }) + r = _run(["triage", "https://github.com/PyAutoLabs/PyAutoLens/pull/40", "--json"], + tmp_path, stub) + assert r.returncode == 0, r.stderr + t = json.loads(r.stdout) + assert t["type"] == "pr" + assert t["pr"]["changed_files"] == 3 + assert t["pr"]["requested_reviewers"] == ["Jammy2211"] + assert t["pr"]["head"] == "feature/cored-profile" + assert "human review" in t["route"] and "/start_dev_for_user" not in t["route"] + + +def test_never_writes_and_never_mutates_github(tmp_path): + stub = _fabricate(tmp_path, EMPTY_SEARCHES) before = {p for p in tmp_path.rglob("*")} r = _run(["scan", "--json"], tmp_path, stub) assert r.returncode == 0, r.stderr @@ -168,11 +233,7 @@ def test_never_writes_and_never_mutates_github(tmp_path): def test_bad_ref_and_unknown_mode_fail_loudly(tmp_path): - stub = _fabricate(tmp_path, { - "search_org.json": {"items": []}, - "search_extra.json": {"items": []}, - "comments.json": [], - }) + stub = _fabricate(tmp_path, EMPTY_SEARCHES) assert _run(["triage", "not-a-ref"], tmp_path, stub).returncode == 5 assert _run(["triage"], tmp_path, stub).returncode == 5 assert _run(["gossip"], tmp_path, stub).returncode == 5 @@ -184,4 +245,5 @@ def test_scan_degrades_honestly_when_search_fails(tmp_path): assert r.returncode == 0, r.stderr s = json.loads(r.stdout) assert s["counts"]["open_external"] == 0 - assert len(s["degraded"]) == 2 + # 2 qualifier groups (org + non-org) x 3 searches (issue, pr, review). + assert len(s["degraded"]) == 6