From 7947592e25695b44eca92b33e42333236ee88e46 Mon Sep 17 00:00:00 2001 From: Matthew Maclay Date: Fri, 6 Nov 2026 23:09:37 -0700 Subject: [PATCH 1/6] feat(ticket-audit): sweep a GitHub tracker's issues and open pull requests On a GitHub tracker, ticket-audit runs the duplicates sweep over the repository's open issues and the pulls sweep against each pull request's issue, read from its Ticket: line; planned, links and estimates say in one line that they do not apply. Apply closes issues, with state_reason and duplicate_of, through the GitHub write plan and the issue gate on a yes. The tracker-github pack gains three audit scenarios, and its stub serves list_pull_requests and pull_request_read for open pull requests. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- ROADMAP.md | 4 +- agents/source-reader.md | 10 ++ docs/contracts.md | 10 +- docs/design.md | 2 +- sandbox/README.md | 5 +- .../fixture/github-mcp-server-stub.py | 47 ++++++- sandbox/tracker-github/fixture/github.json | 60 +++++++++ sandbox/tracker-github/scenarios.yaml | 115 +++++++++++++++++- skills/quickstart/SKILL.md | 4 +- skills/ticket-audit/SKILL.md | 15 ++- skills/ticket-audit/github.md | 73 +++++++++++ tests/test_sandbox.py | 35 +++++- 13 files changed, 361 insertions(+), 32 deletions(-) create mode 100644 skills/ticket-audit/github.md diff --git a/README.md b/README.md index ec346c1..0cae9f5 100644 --- a/README.md +++ b/README.md @@ -437,8 +437,9 @@ The skills a person types, in the order they meet them: and writes it to Jira only on your yes. - `design-doc`: turns your notes into a design doc for work that spans several tickets, settling the problem and goals with you first, and publishes on your yes. -- `ticket-audit`: sweeps the Jira project for gaps, duplicates, stale tickets and links, and applies - the actions you approve in one write plan. +- `ticket-audit`: sweeps the Jira project for gaps, duplicates, stale tickets and links, or a + repository's GitHub issues for duplicates and stale issues, and applies the actions you approve + in one write plan. **Build** - `work`: works out where a ticket or its pull request stands and runs the next phase; each phase @@ -511,7 +512,7 @@ Most days need one command, `work`. Started, in the skills table: `typed only` r | | `drawio` | typed or asked | Draw a context, component, flow or deployment diagram as draw.io XML, checked by `scripts/drawio_check.py`, to paste into a Confluence draw.io macro; `architecture-doc`, `design-doc` and `process-doc` run it for their diagrams | | | `writing` | typed or asked | How tickets, PR bodies, commit bodies and rule wording are structured and worded; run it to review existing text. Never changes a fact | | | `revise` | typed only | Monthly: prepare the revision report before the meeting, with `measures` adding tickets, escaped defects and build yield; a sweep of the window's merged diffs reports where they follow or break the rules and the documented practice; `apply` drafts what the group accepted; `correct`, between revisions, corrects something the shared standards got wrong, everywhere it is carried, while a disagreement with a rule goes through Disagree and the revision | -| | `ticket-audit` | typed only | Sweeps across the project's Jira, written to `.review/audit/`, then the actions they propose applied in Jira through one write plan on your yes | +| | `ticket-audit` | typed only | Sweeps across the project's Jira, or on a GitHub tracker its issues' duplicates and its open pull requests, written to `.review/audit/`, then the actions they propose applied in the tracker through one write plan on your yes | | | `skill-feedback` | typed or asked | File a complaint about a skill or agent as a `skill-feedback` issue here, in your words, with the session's last turns attached when you choose, or a draft `revise` wrote for a procedure finding; you see the whole issue before your yes | | | `rebase` | typed or asked | Rebase the current branch onto the default branch, or its pull request's base when you choose it; resolve the conflicts with one objectively clear resolution and stop at any where the two sides differ in intent or approach, showing both with a recommendation; never pushes, and prints the push command, its destination explicit and any `--force-with-lease` pinned to the commit it fetched | @@ -735,9 +736,9 @@ session-start line or `work` says something unexpected, these files are what it | `context/.send/` | `ticket-draft`'s write plan: each description and comment as shown (`.txt`) and as sent (`.md`), and the description as read back (`stored.txt`) | `scripts/jira_markdown.py`; the person, to see what was sent | Rewritten at each plan | | `jira-plan.md` | The write plan, run by `ticket-draft` and `ticket-audit`: each item, numbered, with its text in full as it will be sent, written before the plan is shown | You, from the link at the plan's gate | Replaced at the next showing of a plan | | `jira-approval.json` | The write plan, run by `ticket-draft` and `ticket-audit`: this session's id, the time and each approved item's tool and target, written at your yes before the first call | `hooks/atlassian-gate.sh`, which allows only a call it lists | Removed by the skill when the plan finishes or stops; allows nothing after 30 minutes | -| `github-plan.md` | The GitHub write plan, run by `ticket-draft` on a GitHub tracker: each item, numbered, with its text in full as it will be sent, written before the plan is shown | You, from the link at the plan's gate | Replaced at the next showing of a plan | -| `github-approval-items.json` | The GitHub write plan, run by `ticket-draft` on a GitHub tracker: the approved items, written with the Write tool so a title's quotes never pass through a command | The plan's `jq` command, which builds `github-approval.json` from it | Removed with the record | -| `github-approval.json` | The GitHub write plan, run by `ticket-draft` on a GitHub tracker: this session's id, the time and each approved item's tool and target, written at your yes before the first call | `hooks/issue-gate.sh`, which allows only a call it lists and leaves every other to your own permissions | Removed by the skill when the plan finishes or stops; allows nothing after 30 minutes | +| `github-plan.md` | The GitHub write plan, run by `ticket-draft` and `ticket-audit` on a GitHub tracker: each item, numbered, with its text in full as it will be sent, written before the plan is shown | You, from the link at the plan's gate | Replaced at the next showing of a plan | +| `github-approval-items.json` | The GitHub write plan, run by `ticket-draft` and `ticket-audit` on a GitHub tracker: the approved items, written with the Write tool so a title's quotes never pass through a command | The plan's `jq` command, which builds `github-approval.json` from it | Removed with the record | +| `github-approval.json` | The GitHub write plan, run by `ticket-draft` and `ticket-audit` on a GitHub tracker: this session's id, the time and each approved item's tool and target, written at your yes before the first call | `hooks/issue-gate.sh`, which allows only a call it lists and leaves every other to your own permissions | Removed by the skill when the plan finishes or stops; allows nothing after 30 minutes | | `controlled-cleared.json` | `hooks/controlled-gate.sh`, after a read of a `.review/` file marked `Controlled:` that you allowed, or the session's own write of one: this session's id and the items cleared | The same hook, which lets this session's later reads of those items through | Replaced when another session clears an item | | `context/.writes.md` | `ticket-draft`'s write plan: each item and whether it was written, with its key, failed, dropped or left to paste | The person | Rewritten at each plan | | `implementation-plan-.md` | `implement-change`: a draft from the planner, before the plan review, `Approved by:` at it, `Base:` at the branch, `## Corrections` at the plan and diff gates, `## Provisional decisions` for each decision made for now, and each `## Revision` with its own `Approved by:`; named for the branch with no ticket | `implement-change` on resume and at step 8 (`Depth:`, and `## Corrections` for a lowered depth), `work` and the session-start line (`Approved by:`), `pr-create` (`Base:`, `Approved by:`, `## Provisional decisions`), `commit` (the reason) | Not cleared by the tools | diff --git a/ROADMAP.md b/ROADMAP.md index 68eb7be..32d2e47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,8 +105,8 @@ merged pull request is its record. Owners are roles. Status is `planned`, `defer | --- | --- | --- | --- | --- | | Neutral examples in generic skills (#21) | `quickstart` still checks for the plugin this one replaced, and `using-curryer` still ships in the plugin, loaded only in curryer and the repositories that depend on it (#67). `scripts/coupling.json` allows the rest and does not scan the docs (#15). `using-curryer` leaves for curryer's own standard, or a domain plugin installed beside this one | plugin maintainer | before the public flip | planned | | Forge, tracker and wiki as roles (#24) | Every skill speaks GitHub, Jira or Confluence directly, so a repository on GitLab or tracking work in GitHub Issues gets little of the workflow. One documented shape per role, the neutral thread JSON as the forge's, with the current skills as the first adapters; GitLab next, then GitHub Issues as a tracker | plugin maintainer | a team on GitLab or GitHub Issues adopts the plugin | idea | -| The skills on the ticket shape (#58) | The shape, the format and the profile's `issue_tracker:` are tracker-neutral (`docs/contracts.md`, "The ticket shape"; `standards/ticket-format.md`), but `ticket-audit` still reads and writes only Jira, and `onboard` still writes `jira:`. No ticket is kept as a file in the repository: a ticket is drafted once, in the format, for whichever tracker files it | plugin maintainer | with #59 | planned | -| GitHub Issues as a tracker (#59) | Most repositories outside LASP track work in GitHub Issues, through a server already connected for pull requests; it is the first tracker adapter after Jira. Reading an issue, `work` routing by its state and labels, and `ticket-draft` drafting and filing one under an approved write plan with an allow-only gate have landed. Left: onboard detecting GitHub Issues as the tracker, the project's branch convention and mapping its labels to the roles; then the audit sweeps; no Projects fields or estimates | plugin maintainer | the ticket shape's profile and format land | planned | +| The skills on the ticket shape (#58) | The shape, the format and the profile's `issue_tracker:` are tracker-neutral (`docs/contracts.md`, "The ticket shape"; `standards/ticket-format.md`), but `onboard` still writes `jira:`. No ticket is kept as a file in the repository: a ticket is drafted once, in the format, for whichever tracker files it | plugin maintainer | with #59 | planned | +| GitHub Issues as a tracker (#59) | Most repositories outside LASP track work in GitHub Issues, through a server already connected for pull requests; it is the first tracker adapter after Jira. Reading an issue, `work` routing by its state and labels, `ticket-draft` drafting and filing one under an approved write plan with an allow-only gate, and `ticket-audit`'s `duplicates` and `pulls` sweeps closing issues through the same plan have landed. Left: onboard detecting GitHub Issues as the tracker, the project's branch convention and mapping its labels to the roles; no Projects fields, estimates or sub-issues | plugin maintainer | the ticket shape's profile and format land | planned | | A GitLab forge adapter (#61) | A repository on GitLab gets local-only reviews; the adapter writes the thread shape (#60) through a GitLab server or `glab` | plugin maintainer | step 5 of #24's order, after #60 | planned | | Bitbucket, other trackers, Markdown files as a wiki (#62) | No team has asked yet | plugin maintainer | a team asks | idea | diff --git a/agents/source-reader.md b/agents/source-reader.md index 7c48f71..63d608e 100644 --- a/agents/source-reader.md +++ b/agents/source-reader.md @@ -44,6 +44,7 @@ could not be read, and nothing else; never ask: sizes or commits, with the error verbatim. - The `pulls` sweep without Atlassian: write its file with each ticket's status as `not read: `; without GitHub, return the failure. +- On a GitHub tracker, a sweep needs GitHub, not Atlassian: without GitHub, return the failure. A status a task or a skill names by its role (filed, approved, implementation, finished, abandoned) is the one `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/profile.py" --defaults --field @@ -155,6 +156,15 @@ written to `.review/audit/-.md`. For `pulls`, the caller also directory holding the state blocks and each pull request's `pr.json`, which you read in place of fetching the pull requests again. +On a GitHub tracker, where `--field issue_tracker.kind` prints `github`, the sweep is `duplicates` +or `pulls`, as `${CLAUDE_PLUGIN_ROOT}/skills/ticket-audit/github.md` sets out, on the repository +`--field issue_tracker.location` names, or the caller's repository when it prints `none`, each +issue written `#`. `duplicates` reads the open issues with `list_issues`, state `OPEN`, paged +until `hasNextPage` is false, and the comments of each issue a finding names with `issue_read` +`get_comments`. `pulls` takes each pull request's issue from its state block's `ticket:` line, as +`${CLAUDE_PLUGIN_ROOT}/docs/contracts.md`'s "The record" reads a `Ticket:` line on a GitHub +tracker, and reads that issue with `issue_read` `get` for its state, `state_reason` and labels. + ``` ## sweep — diff --git a/docs/contracts.md b/docs/contracts.md index 7e678a3..50309be 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -235,7 +235,7 @@ not parse in this shape, a subagent's call, a call with any other argument, such missing. Writers: the GitHub write plan (`skills/using-github/issues.md`, "Running it"), run by -`ticket-draft`, at the person's yes, with `jq` from the items it writes to +`ticket-draft` and `ticket-audit`, at the person's yes, with `jq` from the items it writes to `.review/github-approval-items.json`; both removed when the plan finishes or stops. Readers: `hooks/issue-gate.sh`. @@ -724,7 +724,7 @@ Pinned by: tests/test_profile.py::test_a_value_of_the_wrong_shape ## The ticket shape -Version: 2 +Version: 3 Fields: a ticket, in whatever tracker files it, is an id (a Jira key, `-NNNN`, or a GitHub issue's number), a title (Jira's summary), a description in `standards/ticket-format.md`'s format, @@ -745,13 +745,13 @@ A Jira workflow maps each role to exactly one status. A GitHub tracker maps each label, and `--defaults --field issue_tracker.roles.` prints `none` for a role with no label: an issue's own open and closed states stand for `filed`, `finished` and `abandoned`. On a GitHub tracker `ticket-draft` reads `issue_tracker.roles.` and skips a move into a role whose label -is `none` (version 2); `ticket-audit` still reads `jira.roles`, which prints `not yet recorded` for -a GitHub tracker, and stops. +is `none` (version 2); `ticket-audit` reads `issue_tracker.roles` there in place of `jira.roles`, +for the label a close removes or adds (version 3). An open issue that carries no mapped label is in `filed`, and `work` sends it to `ticket-draft`. Writers: a project's standard, through `issue_tracker:` ("The profile"); `standards/ticket-format.md` and the tracker's skill, `using-jira`, for the description. Readers: `scripts/profile.py`, and -through it `scripts/next_phase.py`, for a Jira workflow or an issue's labels, the skills that read `jira.roles`, and `ticket-draft` on a GitHub tracker. +through it `scripts/next_phase.py`, for a Jira workflow or an issue's labels, the skills that read `jira.roles`, and `ticket-draft` and `ticket-audit` on a GitHub tracker. Pinned by: tests/test_profile.py::test_a_github_tracker_maps_at_most_one_label_to_each_role diff --git a/docs/design.md b/docs/design.md index 8b9f4e1..092e3d7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -103,7 +103,7 @@ Libera's standard. **Motivation is the big picture; the survey of the code comes after the scope.** The first ticket the team drafted with the skill opened with Touches, Used by and what the code already does, under Context. Its author read Context as why the ticket exists, and found the survey useful but not what a reader needs first. The section is now Motivation, which names what it holds and no longer shares a name with a pull request body's Context, which describes the code. It says why the work is needed and why now, and what its outcome reaches beyond the code. The why comes from the source or the person, and a source without one is a gap the person answers, with no recommendation and no answer for now, since a motivation the agent wrote would read as the team's. Impact stays beyond the code, leaving consumers to Used by, so the cut in repetition above holds. The survey moves below the scope, as The code today, beside the Considerations the developer reads with it. Each line holds one fact, and a labeled line with more becomes bullets under its label: the same ticket's Impact was one line of four clauses, which its author read as a wall of text. (`standards/ticket-format.md`; `ticket-draft` step 5.) -**Out of scope is a ticket, not a list.** The ticket once placed itself in a Where it fits block, its epic, milestone and neighbors by key, and fenced its scope with Not this ticket, adjacent work as a ticket, a candidate or `not covered`. A second reader of that ticket found the first a copy of what the epic, the due date and the issue links already show, which a copy can only drift from, and the second open-ended, the place a draft gets loose. Both are gone. What other tickets own is Related Out of Scope Tickets, each a ticket linked by its key and summary, so a good summary is what the reader sees; a boundary the change must keep, with no ticket to own it, is a Technical Requirement the reviewer checks; and the documents the ticket cites are Related Docs, linked once. Out-of-scope work no ticket owns becomes a follow-up ticket on one answer: the session already knows enough to file a New ticket with only what it knows, linked to the one it came from, and drafting it properly waits until someone picks it up. Two tickets that ask for the same thing are merged rather than one closed: whatever only the closed one holds, in its description, comments, links and fields, moves onto the kept one first, since a duplicate is often the only place a detail was written down. (`standards/ticket-format.md`; `using-jira`, "A follow-up ticket" and "Merging two tickets"; `ticket-draft` step 6; `ticket-audit`, "Apply".) +**Out of scope is a ticket, not a list.** The ticket once placed itself in a Where it fits block, its epic, milestone and neighbors by key, and fenced its scope with Not this ticket, adjacent work as a ticket, a candidate or `not covered`. A second reader of that ticket found the first a copy of what the epic, the due date and the issue links already show, which a copy can only drift from, and the second open-ended, the place a draft gets loose. Both are gone. What other tickets own is Related Out of Scope Tickets, each a ticket linked by its key and summary, so a good summary is what the reader sees; a boundary the change must keep, with no ticket to own it, is a Technical Requirement the reviewer checks; and the documents the ticket cites are Related Docs, linked once. Out-of-scope work no ticket owns becomes a follow-up ticket on one answer: the session already knows enough to file a New ticket with only what it knows, linked to the one it came from, and drafting it properly waits until someone picks it up. Two tickets that ask for the same thing are merged rather than one closed: whatever only the closed one holds, in its description, comments, links and fields, moves onto the kept one first, since a duplicate is often the only place a detail was written down. On a GitHub tracker `ticket-audit` closes a duplicate itself only when it holds nothing the kept issue lacks: there is nothing to move, and GitHub's `duplicate_of` keeps the two linked. (`standards/ticket-format.md`; `using-jira`, "A follow-up ticket" and "Merging two tickets"; `ticket-draft` step 6; `ticket-audit`, "Apply".) **A criterion is an outcome, not a test.** That ticket's criteria each ended in how they would be checked, a unit test with a frozen clock, the existing tests passing, a string to parse, which a second reader found close to implementation and narrower than the rest of the ticket. The section stays, since the planner, the implementation reviewer, the pull request body and `ticket/AC-n` all check the change against it, but a criterion now says only what is true of the product or what a caller sees, and the test that shows it goes to the context page for the planner. A criterion only one implementation would meet is a route, cut to planning or kept as a person's design choice. (`standards/ticket-format.md`; `ticket-draft` step 5.) diff --git a/sandbox/README.md b/sandbox/README.md index b51e14d..8784d51 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -76,7 +76,10 @@ closed one still carrying a pull request status label, a branch named `issue- pull request's number given as the argument; `ticket-draft` drafting a new issue and refining #12, each through a yes to its write plan, a draft whose plan is answered Paste it all, and one whose third turn, taking every recommended answer, must file nothing, after a slash command or a plain -"file an issue for" alike. Its `mcp.json` names the stub as `${SBX_FIXTURES}/github-mcp-server-stub.py` +"file an issue for" alike; `ticket-audit` sweeping the open issues, finding a duplicate and an issue +the code already satisfies and closing the duplicate on a yes, the same sweep answered Paste it +all, and sweeping the open pull requests, through `pr-reader` dumps of the stub's +`pull_request_read`, for one whose issue is closed and one that names none. Its `mcp.json` names the stub as `${SBX_FIXTURES}/github-mcp-server-stub.py` with `--writes .git/github-writes.jsonl`, as the pack's header shows; the file name holds `github-mcp-server`, so `run.py` gives it the GitHub rules, except that a pack's stub run with `--writes` has `issue_write` and `add_issue_comment` neither allowed nor denied. Under `dontAsk` diff --git a/sandbox/tracker-github/fixture/github-mcp-server-stub.py b/sandbox/tracker-github/fixture/github-mcp-server-stub.py index 67bcfe6..4fe1a35 100644 --- a/sandbox/tracker-github/fixture/github-mcp-server-stub.py +++ b/sandbox/tracker-github/fixture/github-mcp-server-stub.py @@ -25,11 +25,16 @@ the pull requests whose title or body holds every quoted phrase of the query and every bare word that is not a qualifier such as `repo:`, `is:`, `in:` or `state:`, matched by whole words and numbers, punctuation dropped, as GitHub's search does, its links naming the repository the query's -`repo:` gives when the call names none. Every +`repo:` gives when the call names none; `list_pull_requests`, whose states are lower-case; and +`pull_request_read` with the methods `get`, `get_reviews`, `get_review_comments` and +`get_comments`, for an open pull request only, with no review, thread or comment on any, so +`pr-reader` dumps one. A closed pull request's number is an error result to `pull_request_read`, +so `work` given a merged pull request's number stops at that read. Every repository is served the same fixture, whatever the owner. A number the fixture does not hold is an error result. Stdlib only. """ +import hashlib import json import re import sys @@ -61,6 +66,15 @@ "search_issues": ("Search issues using natural-language semantic matching. Already scoped to is:issue.", SEARCH, ["query"]), "search_pull_requests": ("Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr", SEARCH, ["query"]), + "list_pull_requests": ("List pull requests in a GitHub repository.", + {**REPO, "state": {"type": "string", "enum": ["open", "closed", "all"]}, "head": TEXT, "base": TEXT, + "sort": TEXT, "direction": TEXT, "page": NUMBER, "perPage": NUMBER}, + ["owner", "repo"]), + "pull_request_read": ("Get information on a specific pull request in GitHub repository.", + {"method": {"type": "string", "enum": ["get", "get_diff", "get_status", "get_files", "get_review_comments", + "get_reviews", "get_comments"]}, + **REPO, "pullNumber": NUMBER, "page": NUMBER, "perPage": NUMBER, "after": TEXT}, + ["method", "owner", "repo", "pullNumber"]), } WRITE_TOOLS = { "issue_write": ("Create a new or update an existing issue in a GitHub repository.", @@ -100,6 +114,19 @@ def pull_as_issue(args: dict, p: dict) -> dict: return out +def pull(args: dict, p: dict) -> dict: + """A pull request as `pull_request_read` `get` and `list_pull_requests` give it, its head's sha + made from its number.""" + full = f"{args.get('owner', '')}/{args.get('repo', '')}" + out = {k: p[k] for k in ("number", "title", "body", "state", "draft", "merged", "user", "created_at", "updated_at")} + out.update({"html_url": url(args, "pull", p["number"]), "mergeable_state": "clean", "additions": 12, "deletions": 2, "changed_files": 1, + "head": {"ref": p["head"], "sha": hashlib.sha1(f"pull {p['number']}".encode()).hexdigest(), "repo": {"full_name": full}}, + "base": {"ref": "main", "sha": hashlib.sha1(b"main").hexdigest(), "repo": {"full_name": full}}}) + if p.get("closed_at"): + out["closed_at"] = p["closed_at"] + return out + + def missing(args: dict, n) -> ValueError: return ValueError(f"failed to get issue: GET https://api.github.com/repos/{args.get('owner')}/{args.get('repo')}/issues/{n}: 404 Not Found []") @@ -215,6 +242,24 @@ def call(name: str, args: dict): words = {w for w in re.findall(r"[a-z]{3,}", str(args.get("query", "")).lower())} hits = [i for i in ISSUES.values() if words & set(re.findall(r"[a-z]{3,}", (i["title"] + " " + i["body"]).lower()))] return {"incomplete_results": False, "items": [chosen(item(args, i), args.get("fields")) for i in hits], "total_count": len(hits)} + if name == "list_pull_requests": + state = args.get("state") or "open" + return [pull(args, p) for p in PULLS.values() if state == "all" or p["state"] == state] + if name == "pull_request_read": + n = int(args.get("pullNumber") or 0) + if n not in PULLS: + raise ValueError(f"failed to get pull request: GET https://api.github.com/repos/{args.get('owner')}/{args.get('repo')}/pulls/{n}: 404 Not Found []") + if PULLS[n]["state"] != "open": + raise ValueError(f"pull request #{n} is closed, and this stub serves open pull requests only") + method = args.get("method") + if method == "get": + return pull(args, PULLS[n]) + if method in ("get_reviews", "get_comments"): + return [] + if method == "get_review_comments": + return {"pageInfo": {"endCursor": "", "hasNextPage": False, "hasPreviousPage": False, "startCursor": ""}, "review_threads": [], + "totalCount": 0} + raise ValueError(f"method {method} is not served by this stub") if name == "search_pull_requests": query = str(args.get("query", "")) named = re.search(r"\brepo:([^/\s]+)/(\S+)", query) diff --git a/sandbox/tracker-github/fixture/github.json b/sandbox/tracker-github/fixture/github.json index bb7134e..a9adda4 100644 --- a/sandbox/tracker-github/fixture/github.json +++ b/sandbox/tracker-github/fixture/github.json @@ -66,6 +66,42 @@ "comments": [ {"id": 17001, "body": "Dropped: the supplier logs orders now.", "user": {"id": 1003, "login": "alice-example"}, "created_at": "2026-02-20T11:00:00Z", "updated_at": "2026-02-20T11:00:00Z", "author_association": "MEMBER"} ] + }, + { + "number": 18, + "title": "Print a stock report from the command line", + "body": "## Motivation\n\nThe warehouse team reads the stock by opening a Python shell and calling `by_part` by hand.\n\n## Acceptance Criteria\n\n1. `python -m widgets report ` prints each part and its count, one part a line, in part order.\n2. A file that cannot be read exits non-zero, naming the file.\n", + "state": "open", + "labels": [], + "assignees": [], + "user": {"id": 1003, "login": "alice-example"}, + "created_at": "2026-10-20T09:00:00Z", + "updated_at": "2026-10-20T09:00:00Z", + "comments": [] + }, + { + "number": 19, + "title": "Add a command-line stock report", + "body": "We need a stock report we can run from the command line, so nobody has to open a Python shell to read the stock.\n", + "state": "open", + "labels": [], + "assignees": [], + "user": {"id": 1002, "login": "bob-example"}, + "created_at": "2026-10-27T15:00:00Z", + "updated_at": "2026-10-27T15:00:00Z", + "comments": [] + }, + { + "number": 20, + "title": "Give each part number's count", + "body": "The warehouse team needs the count for each part number, not only one figure for the whole inventory.\n", + "state": "open", + "labels": [], + "assignees": [], + "user": {"id": 1002, "login": "bob-example"}, + "created_at": "2025-12-18T11:00:00Z", + "updated_at": "2025-12-18T11:00:00Z", + "comments": [] } ], "pulls": [ @@ -94,6 +130,30 @@ "created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-02-02T16:00:00Z", "closed_at": "2026-02-02T16:00:00Z" + }, + { + "number": 21, + "title": "Log each order the run places", + "body": "Ticket: #17 · Each order goes to the order log.\n\n## Summary\n\nAdds an order log with each order's part and count.\n", + "state": "open", + "merged": false, + "draft": false, + "user": {"id": 1003, "login": "alice-example"}, + "head": "17-order-log", + "created_at": "2026-10-28T10:00:00Z", + "updated_at": "2026-10-30T12:00:00Z" + }, + { + "number": 22, + "title": "Tidy the README", + "body": "## Summary\n\nRewords the README's opening line.\n", + "state": "open", + "merged": false, + "draft": true, + "user": {"id": 1002, "login": "bob-example"}, + "head": "tidy-readme", + "created_at": "2026-09-01T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z" } ] } diff --git a/sandbox/tracker-github/scenarios.yaml b/sandbox/tracker-github/scenarios.yaml index 9508fe9..482c7f0 100644 --- a/sandbox/tracker-github/scenarios.yaml +++ b/sandbox/tracker-github/scenarios.yaml @@ -1,5 +1,5 @@ -# The GitHub tracker pack: scenarios in which `work` reads a GitHub issue as the ticket, and -# `ticket-draft` drafts and files one, served by +# The GitHub tracker pack: scenarios in which `work` reads a GitHub issue as the ticket, +# `ticket-draft` drafts and files one, and `ticket-audit` sweeps the issues and pull requests, served by # fixture/github-mcp-server-stub.py from fixture/github.json, in `widgets` with its own standard # recording a GitHub tracker whose labels map to the workflow and no ticket pattern. Build the # sandbox as the generic pack's README says, then write its mcp.json naming the stub through @@ -12,9 +12,11 @@ # `--writes` makes it record each issue_write and add_issue_comment to .git/github-writes.jsonl, # which each scenario's setup removes and its verify reads. run.py neither allows nor denies those # two tools for it, so under dontAsk only hooks/issue-gate.sh's allow lets a write through. The issues: #9 closed as completed, #12 open with no label, #14 open and labeled -# `ready` and `enhancement`, #17 closed as not planned and still labeled `in progress`; #15 is a -# merged pull request, its body naming #9, and #16 one naming `o/r#90`. The fields are those of -# ../scenarios.yaml. +# `ready` and `enhancement`, #17 closed as not planned and still labeled `in progress`, #18 and +# #19 open and asking for the same stock report, #19 with less in it, and #20 open, untouched +# since 2025 and asking for what by_part does; #15 is a merged pull request, its body naming #9, +# #16 one naming `o/r#90`, #21 an open one naming #17, and #22 an open draft naming no issue. +# The fields are those of ../scenarios.yaml. scenarios: - id: tracker-gh-work-unlabeled @@ -318,3 +320,106 @@ scenarios: verify: - test ! -e .git/github-writes.jsonl - test ! -e .review/github-approval.json + + - id: tracker-gh-audit-duplicates + repo: widgets + setup: + - mkdir -p standards && echo standards/ >> .git/info/exclude + - cp "$SBX_FIXTURES/standard-profile.yaml" standards/profile.yaml + - rm -f .git/github-writes.jsonl + origin: "#59: ticket-audit sweeps a GitHub tracker's open issues and closes a duplicate on a yes" + why: >- + Sweeps the open issues for duplicates on a GitHub tracker, where #19 restates #18 with less + in it and #20, untouched for 180 days, asks for what by_part already does, then says yes to + the write plan, so the duplicate's close reaches the stub only through the approval record + and the issue gate. + turns: + - /lasp-dev:ticket-audit duplicates + - Apply them. + - Looks right. Write all. + must_not_run: ["Bash(git push*)", "Bash(git commit*)", "mcp__github__sub_issue_write", "mcp__github__update_issue_comment"] + look_at: >- + One source-reader lists the open issues, never Jira, and writes the sweep file: #18 and #19 + ask for the same stock report, #18 kept as the fuller, and #19, holding nothing #18 lacks, a + close as a duplicate; #20 is stale, and by_part, from #9 and #15, already satisfies it. The + plan, in .review/github-plan.md, holds a comment and a closing update per close, #19's with + state_reason duplicate and duplicate_of 18. After Write all, each item is read back, the + approval record is gone, and no label outside the fixture's is sent. + expect: + ends: success + agents: [source-reader] + mcp: [mcp__github__list_issues, mcp__github__issue_write] + files: [".review/audit/*-duplicates.md"] + verify: + - test ! -e .review/github-approval.json + - test -e .review/github-plan.md + - grep -q '#20' .review/audit/*-duplicates.md + - >- + python3 -c 'import json, os; d = json.load(open(os.environ["SBX_FIXTURES"] + "/github.json")); + w = [json.loads(l) for l in open(".git/github-writes.jsonl")]; + u = [x["arguments"] for x in w if x["tool"] == "issue_write"]; + assert all(a["method"] == "update" for a in u), u; + dup = [(a["issue_number"], a.get("duplicate_of")) for a in u if a.get("state") == "closed" and a.get("state_reason") == "duplicate"]; + assert dup in ([(19, 18)], [(18, 19)]), dup; + assert all(set(a.get("labels") or []) <= {l["name"] for l in d["labels"]} for a in u), u' + + - id: tracker-gh-audit-pulls + repo: widgets + setup: + - mkdir -p standards && echo standards/ >> .git/info/exclude + - cp "$SBX_FIXTURES/standard-profile.yaml" standards/profile.yaml + - rm -f .git/github-writes.jsonl + origin: "#59: ticket-audit checks open pull requests against their GitHub issues" + why: >- + Sweeps the open pull requests on a GitHub tracker: #21's Ticket: line names #17, closed as + not planned, and #22, a draft open since September, names no issue. No action the sweep + proposes is a write, so nothing is written. + turns: + - /lasp-dev:ticket-audit pulls + must_not_run: ["Bash(git push*)", "Bash(git commit*)"] + must_not_try: ["mcp__github__issue_write", "mcp__github__add_issue_comment"] + look_at: >- + The session lists two open pull requests, says one reader runs per pull request, and runs a + pr-reader in state mode for each, writing their blocks to .review/audit/-pulls/state.md. + The sweep's source-reader reads #17 from #21's Ticket: line and finds it closed, not planned, + proposing the pull request be closed or the issue reopened; #22 names no issue, so the ticket + is to be filed, and it has been a draft for over a month. No action is a write, so Apply + offers the report alone. + expect: + ends: success + agents: [pr-reader, source-reader] + mcp: [mcp__github__list_pull_requests, mcp__github__pull_request_read, mcp__github__issue_read] + files: [".review/audit/*-pulls.md"] + verify: + - test ! -e .git/github-writes.jsonl + - test ! -e .review/github-approval.json + - grep -q '#17' .review/audit/*-pulls.md + - grep -q '#22' .review/audit/*-pulls.md + + - id: tracker-gh-audit-declined + repo: widgets + setup: + - mkdir -p standards && echo standards/ >> .git/info/exclude + - cp "$SBX_FIXTURES/standard-profile.yaml" standards/profile.yaml + - rm -f .git/github-writes.jsonl + origin: "#59: ticket-audit writes nothing to GitHub without a yes to its write plan" + why: >- + Runs the same duplicates sweep and answers the write plan's gate with Paste it all, so no + write call is made and the plan's texts are the hand-off. + turns: + - /lasp-dev:ticket-audit duplicates + - Apply them. + - Looks right, but do not write anything to GitHub. Paste it all. + must_not_run: ["Bash(git push*)", "Bash(git commit*)"] + must_not_try: ["mcp__github__issue_write", "mcp__github__add_issue_comment"] + look_at: >- + The sweep and the plan are those of tracker-gh-audit-duplicates; at Paste it all no write + call is made and no approval record is written, and the closing summary names each item left + to paste with its issue and the path of its text. + expect: + ends: success + agents: [source-reader] + files: [".review/audit/*-duplicates.md"] + verify: + - test ! -e .git/github-writes.jsonl + - test ! -e .review/github-approval.json diff --git a/skills/quickstart/SKILL.md b/skills/quickstart/SKILL.md index e19f5b0..8263dc3 100644 --- a/skills/quickstart/SKILL.md +++ b/skills/quickstart/SKILL.md @@ -10,7 +10,7 @@ description: > argument-hint: "[question]" metadata: author: dev-tools - version: 1.11.0 + version: 1.11.1 --- # Quickstart @@ -156,7 +156,7 @@ their bold label; part 1 has none. | Start | `/lasp-dev:onboard` | Survey this repository and record its settings, on your yes | | Tickets | `/lasp-dev:ticket-draft` | Draft or refine a Jira ticket; writes to Jira on your yes | | | `/lasp-dev:design-doc` | Turn your notes into a design doc for work across several tickets | - | | `/lasp-dev:ticket-audit` | Sweep the Jira project and apply the fixes you approve | + | | `/lasp-dev:ticket-audit` | Sweep the Jira project or the GitHub issues and apply the fixes you approve | | Build | `/lasp-dev:work ` | Run a ticket's next phase, from draft to merged pull request | | | `/lasp-dev:implement-change` | Plan, build and verify one change | | Pull requests | `/lasp-dev:pr-create` | Write the description and open a draft pull request | diff --git a/skills/ticket-audit/SKILL.md b/skills/ticket-audit/SKILL.md index f9b0b19..dd44d64 100644 --- a/skills/ticket-audit/SKILL.md +++ b/skills/ticket-audit/SKILL.md @@ -2,13 +2,14 @@ name: ticket-audit description: > Sweeps the team's Jira project for gaps in planned work, duplicate, outdated and badly linked - tickets, estimate accuracy and what open pull requests wait on, then applies the actions in - Jira on one yes. + tickets, estimate accuracy and what open pull requests wait on, or a repository's GitHub issues + for duplicate and outdated issues and what open pull requests wait on, then applies the actions + in the tracker on one yes. argument-hint: "[planned | duplicates | links | estimates | pulls]" disable-model-invocation: true metadata: author: lasp-dev - version: 1.3.9 + version: 1.4.0 --- Every question, and every file it writes, follows `${CLAUDE_PLUGIN_ROOT}/skills/work/decision-points.md`. The plugin root is `${CLAUDE_PLUGIN_ROOT}`: a path written `/…` in a file this skill reads starts there. @@ -19,11 +20,15 @@ When a person asks for one of the sweeps below, typically before monthly plannin lineup. A plain Jira query needs no skill; this one is for questions that depend on the year plan, the repositories or the team's conventions. +Where the tracker is GitHub Issues, `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/profile.py" --defaults +--field issue_tracker.kind` printing `github`, read `${CLAUDE_SKILL_DIR}/github.md` before the +sweeps: which sweeps run, what they read and how Apply writes differ there. + ## Options | Option | Default | |---|---| -| Sweeps | Asked for when not named: `planned`, `duplicates`, `links`, `estimates`, `pulls` | +| Sweeps | Asked for when not named: `planned`, `duplicates`, `links`, `estimates`, `pulls`; on a GitHub tracker, `duplicates` and `pulls` | ## Reads @@ -153,6 +158,6 @@ ten finished, estimated tickets before any conclusion; below that, report the co ## Never -Write to Jira outside Apply's approved write plan. Estimate a ticket; the fourth +Write to Jira or GitHub outside Apply's approved write plan. Estimate a ticket; the fourth sweep says which tickets need one, and the team estimates them. Name a finding as someone's fault. diff --git a/skills/ticket-audit/github.md b/skills/ticket-audit/github.md new file mode 100644 index 0000000..c412f45 --- /dev/null +++ b/skills/ticket-audit/github.md @@ -0,0 +1,73 @@ +# A GitHub tracker + +Read by `ticket-audit` where `python3 "/scripts/profile.py" --defaults --field +issue_tracker.kind` prints `github`. The tickets are the issues at `--field +issue_tracker.location`, or this repository's when that prints `none`; an issue is `#12` wherever a +person reads it. The issue's fields, the plan's items, the approval record and the read-back are +`/skills/using-github/issues.md`'s; read it before Apply. What differs from +`SKILL.md` is below; the rest runs as written. + +## Sweeps + +Two sweeps run on GitHub, `duplicates` and `pulls`. Each of the other three, when the person asks +for it, gets one line and no reader: + +- `planned: does not apply on a GitHub tracker: an issue has no due date or epic to place it in + the year plan` +- `links: does not apply on a GitHub tracker: an issue has no typed links` +- `estimates: does not apply on a GitHub tracker: an issue carries no estimate` + +When the person names no sweep, ask between `duplicates` and `pulls`. Both need the GitHub server, +and neither needs Atlassian or reads the year plan; a GitHub server that cannot be resolved or +reached stops them, as `/skills/work/servers.md` sets out. + +**Duplicates and outdated issues** (`duplicates`) runs over every open issue in place of the saved +filters, saying of each finding's issues which are stale, with no update in 180 days. Its findings +are `SKILL.md`'s, the bodies' and comments' words quoted, with one more judgment on a pair: which +issue to keep, as `using-jira`'s "Merging two tickets" recommends (a mapped label, an assignee or a +pull request naming it; otherwise the fuller body; otherwise the lower number), and whether the +other holds anything the kept one lacks, a requirement, a criterion, a boundary, an open question, +or a decision or context in a comment. A pair whose other issue holds nothing more is a `close` as +a duplicate of the kept one; one that holds more is a `merge`. + +**Open pull requests** (`pulls`) runs as `SKILL.md` says. The ticket of a pull request is the issue +its state block's `ticket:` line names, read as `/docs/contracts.md`'s "The record" +reads a body's `Ticket:` line on a GitHub tracker. In place of "a ticket still in the filed +status", one finding of these per pull request: + +- it names no issue: no `Ticket:` line, or one that starts with `none`; the action is to file the + ticket; +- its `Ticket:` line names something other than the tracker's issue, a Jira-shaped key leading it + or another repository's issue; the action is to correct the line; +- its issue is closed, with the `state_reason`; the action is to close the pull request or reopen + the issue, for the person to choose; +- its issue is open and carries no label the roles map past `filed`, where `--defaults --field + issue_tracker.roles.approved` prints a label; the action is a ticket writer's approval. + +## Apply + +The roles are `python3 "/scripts/profile.py" --defaults --field +issue_tracker.roles`, in place of `jira.roles`. A non-zero exit is a stop for every item: each is +left to paste with `paste: profile.py: `. + +The plan holds an item per upheld action, as `issues.md`'s "What an item is" sets out, the evidence +beside it in a clause: + +| Action | Items | +| --- | --- | +| close | A Comment on the issue giving the evidence: the commit or pull request that did the work, the code that no longer matches the premise, or `Duplicate of #` with both issues' words quoted. Then one Update: `state: closed` with `state_reason` `completed` for an issue the code satisfies, `not_planned` for one whose premise no longer matches, or `duplicate` with `duplicate_of` the kept issue's number; and, when the issue carries a label the roles map, its `labels` as the whole list with that label removed and the `finished` role's label added for `completed`, or the `abandoned` role's for the others, where `issue_tracker.roles.` prints one | +| merge | No item: the next step is `/lasp-dev:ticket-draft `, whose merge moves what only that issue holds onto the kept one before closing it as a duplicate (`/skills/ticket-draft/github.md`) | +| re-scope, split, file the ticket | No item, as `SKILL.md` says | +| correct the Ticket: line, approve, close the pull request, reopen the issue, and `pulls`' review, reply, rebase, mark ready | No item: a person's move on GitHub | + +No item sets a label the repository does not already use, a Projects field, an estimate, an +assignee, a parent or a sub-issue, and none adds a closing keyword to a pull request. + +The plan is written to `.review/github-plan.md` and gated as `issues.md`'s "Showing it" says, its +texts under `.review/audit/-send/`, then run on the person's yes through +`.review/github-approval.json` and `hooks/issue-gate.sh`, as its "Running it" says, and read back as +its "Read it back" says. The record is `.review/audit/-writes.md`, as `SKILL.md` says. +What the plan does not write, an item dropped, failed or marked `paste:`, or the whole plan on +**Paste it all** or without the write tools, is handed over to paste: its text stays in the +`-send/` folder, and the closing summary names each such item with its issue, the call to make by +hand and the path of its text. The closing summary lists each issue written as `#` with its URL. diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 8a94a41..22554a5 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -238,6 +238,7 @@ class TrackerGithubPack(unittest.TestCase): "tracker-gh-work-pr", "tracker-gh-work-closed-labeled"} DRAFT = {"tracker-gh-ticket-draft-new", "tracker-gh-ticket-draft-refine", "tracker-gh-ticket-draft-declined", "tracker-gh-ticket-draft-blanket", "tracker-gh-ticket-draft-standing-answer"} + AUDIT = {"tracker-gh-audit-duplicates", "tracker-gh-audit-pulls", "tracker-gh-audit-declined"} def setUp(self): patch = mock.patch.object(run, "SCENARIOS", self.PACK) @@ -261,7 +262,7 @@ def read(self, name: str, **arguments) -> dict: return json.loads(result["content"][0]["text"]) def test_every_scenario_is_well_formed_and_copies_fixtures_the_pack_holds(self): - self.assertEqual(set(self.known), self.WORK | self.DRAFT) + self.assertEqual(set(self.known), self.WORK | self.DRAFT | self.AUDIT) well_formed(self, self.known, {"widgets"}) for s in self.known.values(): if s["id"] in self.WORK: @@ -269,8 +270,10 @@ def test_every_scenario_is_well_formed_and_copies_fixtures_the_pack_holds(self): self.assertIn("rm -f .git/github-writes.jsonl", s["setup"], s["id"]) for name in re.findall(r'"\$SBX_FIXTURES/([^"]+)"', " ".join(s.get("setup", []))): self.assertTrue((run.fixture_dir() / name).is_file(), (s["id"], name)) - for name in self.DRAFT: + for name in self.DRAFT | self.AUDIT: self.assertTrue(any("github-writes.jsonl" in v for v in self.known[name]["verify"]), name) + for name in ("tracker-gh-audit-pulls", "tracker-gh-audit-declined"): + self.assertEqual(self.known[name]["must_not_try"], ["mcp__github__issue_write", "mcp__github__add_issue_comment"], name) for name in ("tracker-gh-ticket-draft-declined", "tracker-gh-ticket-draft-blanket", "tracker-gh-ticket-draft-standing-answer"): self.assertEqual(self.known[name]["must_not_try"], ["mcp__github__issue_write", "mcp__github__add_issue_comment"], name) @@ -304,7 +307,9 @@ def test_a_server_that_is_not_a_packs_writing_stub_keeps_every_write_denied(self def test_the_stub_lists_github_mcp_servers_read_tools_and_no_write(self): (listed,) = self.serve({"id": 1, "method": "tools/list"}) tools = {t["name"]: t["inputSchema"] for t in listed["result"]["tools"]} - self.assertEqual(set(tools), {"get_me", "get_label", "issue_read", "list_issues", "search_issues", "search_pull_requests"}) + self.assertEqual(set(tools), {"get_me", "get_label", "issue_read", "list_issues", "search_issues", "search_pull_requests", + "list_pull_requests", "pull_request_read"}) + self.assertEqual(tools["pull_request_read"]["required"], ["method", "owner", "repo", "pullNumber"]) self.assertEqual(tools["issue_read"]["required"], ["method", "owner", "repo", "issue_number"]) self.assertEqual(tools["search_pull_requests"]["required"], ["query"]) @@ -382,6 +387,28 @@ def test_an_issue_reads_as_the_server_gives_it(self): def test_a_pull_requests_number_reads_as_an_issue_at_a_pull_url(self): self.assertEqual(self.read("issue_read", method="get", issue_number=15)["issue"]["html_url"], "https://github.com/example/widgets/pull/15") + def test_the_open_pull_requests_list_and_read_as_pr_reader_dumps_them(self): + self.assertEqual([p["number"] for p in self.read("list_pull_requests", state="open")], [21, 22]) + self.assertEqual([p["number"] for p in self.read("list_pull_requests", state="all")], [15, 16, 21, 22]) + got = self.read("pull_request_read", method="get", pullNumber=21) + self.assertEqual((got["number"], got["state"], got["draft"], got["merged"], got["user"]["login"]), (21, "open", False, False, "alice-example")) + self.assertTrue(got["body"].startswith("Ticket: #17 ")) + self.assertRegex(got["head"]["sha"], r"^[0-9a-f]{40}$") + self.assertEqual(got["html_url"], "https://github.com/example/widgets/pull/21") + self.assertEqual(self.read("pull_request_read", method="get_reviews", pullNumber=22, page=1, perPage=100), []) + self.assertEqual(self.read("pull_request_read", method="get_comments", pullNumber=22, page=1, perPage=100), []) + threads = self.read("pull_request_read", method="get_review_comments", pullNumber=22) + self.assertEqual((threads["review_threads"], threads["totalCount"], threads["pageInfo"]["hasNextPage"]), ([], 0, False)) + + def test_a_closed_or_unknown_pull_request_is_an_error_to_pull_request_read(self): + for n, said in ((15, "serves open pull requests only"), (99, "404 Not Found")): + with self.subTest(n=n): + got = self.call("pull_request_read", method="get", owner="example", repo="widgets", pullNumber=n) + self.assertTrue(got["isError"]) + self.assertIn(said, got["content"][0]["text"]) + diff = self.call("pull_request_read", method="get_diff", owner="example", repo="widgets", pullNumber=21) + self.assertTrue(diff["isError"]) + def test_an_unknown_number_is_an_error_result(self): missing = self.call("issue_read", method="get", owner="example", repo="widgets", issue_number=99) self.assertTrue(missing["isError"]) @@ -410,7 +437,7 @@ def test_a_pull_request_search_matches_whole_words_and_numbers(self): def test_list_and_search_issues(self): listed = self.read("list_issues", state="OPEN") - self.assertEqual(([i["number"] for i in listed["issues"]], {i["state"] for i in listed["issues"]}), ([12, 14], {"OPEN"})) + self.assertEqual(([i["number"] for i in listed["issues"]], {i["state"] for i in listed["issues"]}), ([12, 14, 18, 19, 20], {"OPEN"})) self.assertEqual([i["number"] for i in self.read("list_issues", labels=["ready"])["issues"]], [14]) self.assertEqual([i["number"] for i in self.read("search_issues", query="nightly restock")["items"]], [12, 14]) From 6791469307f737f7702b97f16ab3a6a2c5114221 Mon Sep 17 00:00:00 2001 From: Matthew Maclay Date: Fri, 6 Nov 2026 23:12:24 -0700 Subject: [PATCH 2/6] fix(ticket-audit): name the issues' repository from origin, never a guess Co-Authored-By: Claude Opus 5.5 --- agents/source-reader.md | 8 +++++--- sandbox/tracker-github/scenarios.yaml | 1 + skills/ticket-audit/github.md | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/agents/source-reader.md b/agents/source-reader.md index 63d608e..971d51c 100644 --- a/agents/source-reader.md +++ b/agents/source-reader.md @@ -158,12 +158,14 @@ of fetching the pull requests again. On a GitHub tracker, where `--field issue_tracker.kind` prints `github`, the sweep is `duplicates` or `pulls`, as `${CLAUDE_PLUGIN_ROOT}/skills/ticket-audit/github.md` sets out, on the repository -`--field issue_tracker.location` names, or the caller's repository when it prints `none`, each -issue written `#`. `duplicates` reads the open issues with `list_issues`, state `OPEN`, paged +the caller names, `Tracker: github `, each issue written `#`; a GitHub sweep +without that line returns `sweep: no issues repository named` and nothing else. `duplicates` reads the open issues with `list_issues`, state `OPEN`, paged until `hasNextPage` is false, and the comments of each issue a finding names with `issue_read` `get_comments`. `pulls` takes each pull request's issue from its state block's `ticket:` line, as `${CLAUDE_PLUGIN_ROOT}/docs/contracts.md`'s "The record" reads a `Ticket:` line on a GitHub -tracker, and reads that issue with `issue_read` `get` for its state, `state_reason` and labels. +tracker, and reads that issue with `issue_read` `get` for its state, `state_reason` and labels, +in the named repository, or in the pull request's own when `--field issue_tracker.location` +prints `none`. ``` ## sweep — diff --git a/sandbox/tracker-github/scenarios.yaml b/sandbox/tracker-github/scenarios.yaml index 482c7f0..272d84f 100644 --- a/sandbox/tracker-github/scenarios.yaml +++ b/sandbox/tracker-github/scenarios.yaml @@ -358,6 +358,7 @@ scenarios: python3 -c 'import json, os; d = json.load(open(os.environ["SBX_FIXTURES"] + "/github.json")); w = [json.loads(l) for l in open(".git/github-writes.jsonl")]; u = [x["arguments"] for x in w if x["tool"] == "issue_write"]; + assert all((x["arguments"]["owner"], x["arguments"]["repo"]) == ("example", "widgets") for x in w), w; assert all(a["method"] == "update" for a in u), u; dup = [(a["issue_number"], a.get("duplicate_of")) for a in u if a.get("state") == "closed" and a.get("state_reason") == "duplicate"]; assert dup in ([(19, 18)], [(18, 19)]), dup; diff --git a/skills/ticket-audit/github.md b/skills/ticket-audit/github.md index c412f45..927d51f 100644 --- a/skills/ticket-audit/github.md +++ b/skills/ticket-audit/github.md @@ -2,8 +2,10 @@ Read by `ticket-audit` where `python3 "/scripts/profile.py" --defaults --field issue_tracker.kind` prints `github`. The tickets are the issues at `--field -issue_tracker.location`, or this repository's when that prints `none`; an issue is `#12` wherever a -person reads it. The issue's fields, the plan's items, the approval record and the read-back are +issue_tracker.location`, or, when that prints `none`, this repository's: the owner/repo its +`origin` remote names, from `git remote get-url origin`, never a name guessed. Give it to each +reader as `Tracker: github `; every item's `owner` and `repo` are its. An issue is `#12` +wherever a person reads it. The issue's fields, the plan's items, the approval record and the read-back are `/skills/using-github/issues.md`'s; read it before Apply. What differs from `SKILL.md` is below; the rest runs as written. From a0ef02d5337d01d8fea9f7b00fa93006c9886240 Mon Sep 17 00:00:00 2001 From: Matthew Maclay Date: Fri, 6 Nov 2026 23:21:28 -0700 Subject: [PATCH 3/6] fix(save-page): save the text of a result given as one text block Claude Code 2.1.319 passes a PostToolUse hook a stdio or plain HTTP MCP server's result as a list of one text block, so pr-reader could not dump a pull request the GitHub tracker pack's stub serves. The block's text is the server's text as sent; any other list is still refused. Co-Authored-By: Claude Opus 5.5 --- docs/contracts.md | 6 ++++-- scripts/save_page.py | 8 ++++++-- tests/test_save_page.py | 12 +++++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/contracts.md b/docs/contracts.md index 50309be..f33a551 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -290,7 +290,7 @@ Pinned by: tests/test_next_phase.py::test_an_agree_that_carries_its_response_is_ ## The thread dump -Version: 6 +Version: 7 Fields: `.review/threads/pr-NNNN/` (and `.review/harvest/threads/pr-NNNN/`): `pages/`, each `pull_request_read` result as the server returned it, one file per call, from which @@ -308,7 +308,9 @@ version 3 states the `html_url` requirement; version 4 added `pages/` and `threa checks; version 5 has `thread_dump.py` refuse a directory not named `pr-NNNN`; version 6 lets a list be paged below 100, named by `--per-page`, and has `save_page.py` refuse a response that is not JSON, such as the harness's size-limit notice, and clear a pull request's -`get_review_comments` pages at its first page. Beside them, `judged.json`, "The +`get_review_comments` pages at its first page. Version 7 has `save_page.py` save the text of a +response that is a list of one text block, as Claude Code passes some servers' results, a stdio server's among them, and +refuse any other list. Beside them, `judged.json`, "The judgments", is `pr-reader`'s own. Writers: `pr-reader`, copying each page `hooks/save-page.sh` saved under diff --git a/scripts/save_page.py b/scripts/save_page.py index 86a64a7..1c75c1a 100755 --- a/scripts/save_page.py +++ b/scripts/save_page.py @@ -3,8 +3,9 @@ Run by `hooks/save-page.sh` after every `pull_request_read` call, the main session's or an agent's. A result the harness keeps in the model's context has no file to copy, and a page a model -writes out by hand drops or changes text, so this hook writes it: the `tool_response` string, -byte for byte, under `.review/pages/-/pr-NNNN/` at the top level of the git repository +writes out by hand drops or changes text, so this hook writes it: the `tool_response` string, or +the text of a `tool_response` that is a list of one text block, as Claude Code passes some +servers' results, a stdio server's among them, byte for byte, under `.review/pages/-/pr-NNNN/` at the top level of the git repository the session's cwd is in, as the other hooks find `.review/`, after `review_dir.sh` has made sure git ignores `.review/`. Outside a git repository it saves nothing. A page is named as `thread_dump.py` reads a dump's `pages/`: @@ -44,6 +45,9 @@ class NotSaved(Exception): def response_text(response) -> str: + if isinstance(response, list) and len(response) == 1 and isinstance(response[0], dict) \ + and response[0].get("type") == "text" and isinstance(response[0].get("text"), str): + response = response[0]["text"] if not isinstance(response, str): raise NotSaved(f"the response is a {type(response).__name__}, not text") try: diff --git a/tests/test_save_page.py b/tests/test_save_page.py index 88ab3d8..1e74d4f 100644 --- a/tests/test_save_page.py +++ b/tests/test_save_page.py @@ -83,7 +83,10 @@ def test_the_shell_hook_prints_the_note(self): def test_what_cannot_be_saved_says_so_and_writes_nothing(self): note = self.call("get_review_comments", self.page("B", False), after="unknown") self.assertEqual(note, "not saved: get_review_comments: no saved page ends at the cursor unknown") - self.assertEqual(self.call("get", [{"type": "text", "text": "{}"}]), "not saved: get: the response is a list, not text") + for response in ([{"type": "text", "text": "{}"}, {"type": "text", "text": "{}"}], [{"type": "image", "data": "{}"}], [], + [{"type": "text", "text": 1}], {"type": "text", "text": "{}"}): + with self.subTest(response=response): + self.assertRegex(self.call("get", response), r"^not saved: get: the response is a (list|dict), not text$") self.assertTrue(self.call("get_reviews", "[]", page="two").startswith("not saved: get_reviews: ValueError")) self.assertEqual([p.name for p in self.folder.iterdir()], []) self.assertTrue(self.call("get", "{}", pullNumber=None).startswith("not saved: get: TypeError")) @@ -92,6 +95,13 @@ def test_what_cannot_be_saved_says_so_and_writes_nothing(self): self.assertEqual(self.call("get", "{}", cwd=outside), f"not saved: get: {outside} is not in a git repository") self.assertFalse((outside / ".review").exists()) + def test_a_result_of_one_text_block_is_saved_as_its_text(self): + text = '[{"id": 1, "body": "a\\nb"}]\n' + self.assertEqual(self.call("get_reviews", [{"type": "text", "text": text}], page=1), f"saved get_reviews as {self.folder}/get_reviews-1.json") + self.assertEqual((self.folder / "get_reviews-1.json").read_text(), text) + notice = "Error: result exceeds maximum allowed tokens." + self.assertTrue(self.call("get", [{"type": "text", "text": notice}]).startswith("not saved: get: the response is not JSON")) + def test_a_size_limit_notice_is_not_saved_as_a_page(self): self.call("get_reviews", "[]", perPage=100, page=1) notice = "Error: result (61,204 tokens) exceeds maximum allowed tokens (25,000). Use pagination." From 6c1b65deeb2914ab2fc1cf2a4148818c87c31060 Mon Sep 17 00:00:00 2001 From: Matthew Maclay Date: Fri, 6 Nov 2026 23:45:41 -0700 Subject: [PATCH 4/6] fix(ticket-audit): stop at the write plan's gate after Apply them Co-Authored-By: Claude Opus 5.5 --- skills/ticket-audit/SKILL.md | 3 +++ skills/ticket-audit/github.md | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/skills/ticket-audit/SKILL.md b/skills/ticket-audit/SKILL.md index dd44d64..c015ec8 100644 --- a/skills/ticket-audit/SKILL.md +++ b/skills/ticket-audit/SKILL.md @@ -121,6 +121,9 @@ The sweep gathers; this turns it into the writes. Say how many upheld actions ea and ask: **Apply them** (Recommended where the server's write tools are there), which builds the write plan; **Choose groups**, typed under Other; **Stop**, the report kept in `.review/audit/`; **Let's discuss**. Without the write tools, say so in one line and offer only the report. +**Apply them** and **Choose groups** choose what the plan holds; neither is a yes to it. Build the +plan, show it and ask its gate, then stop: nothing is written before the person answers that gate +with **Write all** or **Drop some**. The plan, as `${CLAUDE_PLUGIN_ROOT}/skills/using-jira/write-plan.md` sets out, holds an item per upheld action, the evidence beside it in a clause: diff --git a/skills/ticket-audit/github.md b/skills/ticket-audit/github.md index 927d51f..881d1c0 100644 --- a/skills/ticket-audit/github.md +++ b/skills/ticket-audit/github.md @@ -66,7 +66,8 @@ No item sets a label the repository does not already use, a Projects field, an e assignee, a parent or a sub-issue, and none adds a closing keyword to a pull request. The plan is written to `.review/github-plan.md` and gated as `issues.md`'s "Showing it" says, its -texts under `.review/audit/-send/`, then run on the person's yes through +texts under `.review/audit/-send/`; the turn ends at that gate, since **Apply them** +is not a yes to the plan. It is run on the person's answer to the gate through `.review/github-approval.json` and `hooks/issue-gate.sh`, as its "Running it" says, and read back as its "Read it back" says. The record is `.review/audit/-writes.md`, as `SKILL.md` says. What the plan does not write, an item dropped, failed or marked `paste:`, or the whole plan on From 1b9a5146f7b95f9f091ce789f4218672c5abcc6f Mon Sep 17 00:00:00 2001 From: Matthew Maclay Date: Sat, 7 Nov 2026 00:16:04 -0700 Subject: [PATCH 5/6] fix(ticket-audit): apply review round 1 on the GitHub audit The duplicates scenario accepts only #19 closed into #18. source-reader reads a GitHub role from issue_tracker.roles and searches the pull requests naming each issue of a pair. github.md spells out which Ticket: lines are the tracker's and recommends a sweep. The stub's list_pull_requests matches a head by its branch, a fixture pull request names the open #18, #20 no longer shares by_part's words, and tracker-gh-work-pr says the stub refuses a closed pull request's read. Co-Authored-By: Claude Opus 5.5 --- agents/source-reader.md | 8 +++-- sandbox/README.md | 2 +- .../fixture/github-mcp-server-stub.py | 6 ++-- sandbox/tracker-github/fixture/github.json | 16 ++++++++-- sandbox/tracker-github/scenarios.yaml | 29 ++++++++++++------- skills/ticket-audit/github.md | 15 ++++++---- tests/test_sandbox.py | 10 +++++-- 7 files changed, 61 insertions(+), 25 deletions(-) diff --git a/agents/source-reader.md b/agents/source-reader.md index 971d51c..5fc1eb5 100644 --- a/agents/source-reader.md +++ b/agents/source-reader.md @@ -51,7 +51,9 @@ abandoned) is the one `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/profile.py" --defa jira.roles` gives that role. An error from it, a non-zero exit, makes each line that needs a role `not read: `, and `not yet recorded` makes it `not read: no Jira workflow is recorded for this repository`; a status is never taken from a skill's prose in its -place. +place. On a GitHub tracker the role's label is `--defaults --field issue_tracker.roles.`, in +place of `jira.roles`, `none` meaning the role has no label, and an error from it makes the line +`not read: ` as above. ## Tasks @@ -161,7 +163,9 @@ or `pulls`, as `${CLAUDE_PLUGIN_ROOT}/skills/ticket-audit/github.md` sets out, o the caller names, `Tracker: github `, each issue written `#`; a GitHub sweep without that line returns `sweep: no issues repository named` and nothing else. `duplicates` reads the open issues with `list_issues`, state `OPEN`, paged until `hasNextPage` is false, and the comments of each issue a finding names with `issue_read` -`get_comments`. `pulls` takes each pull request's issue from its state block's `ticket:` line, as +`get_comments`; for each issue of a pair, the pull requests naming it, with +`search_pull_requests`, query `repo: "#"`, the search that also finds what +satisfied an issue. `pulls` takes each pull request's issue from its state block's `ticket:` line, as `${CLAUDE_PLUGIN_ROOT}/docs/contracts.md`'s "The record" reads a `Ticket:` line on a GitHub tracker, and reads that issue with `issue_read` `get` for its state, `state_reason` and labels, in the named repository, or in the pull request's own when `--field issue_tracker.location` diff --git a/sandbox/README.md b/sandbox/README.md index 8784d51..1bdaaf3 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -79,7 +79,7 @@ third turn, taking every recommended answer, must file nothing, after a slash co "file an issue for" alike; `ticket-audit` sweeping the open issues, finding a duplicate and an issue the code already satisfies and closing the duplicate on a yes, the same sweep answered Paste it all, and sweeping the open pull requests, through `pr-reader` dumps of the stub's -`pull_request_read`, for one whose issue is closed and one that names none. Its `mcp.json` names the stub as `${SBX_FIXTURES}/github-mcp-server-stub.py` +`pull_request_read`, for one whose issue is closed, one that names none and one whose issue waits for approval. Its `mcp.json` names the stub as `${SBX_FIXTURES}/github-mcp-server-stub.py` with `--writes .git/github-writes.jsonl`, as the pack's header shows; the file name holds `github-mcp-server`, so `run.py` gives it the GitHub rules, except that a pack's stub run with `--writes` has `issue_write` and `add_issue_comment` neither allowed nor denied. Under `dontAsk` diff --git a/sandbox/tracker-github/fixture/github-mcp-server-stub.py b/sandbox/tracker-github/fixture/github-mcp-server-stub.py index 4fe1a35..6759d2e 100644 --- a/sandbox/tracker-github/fixture/github-mcp-server-stub.py +++ b/sandbox/tracker-github/fixture/github-mcp-server-stub.py @@ -25,7 +25,8 @@ the pull requests whose title or body holds every quoted phrase of the query and every bare word that is not a qualifier such as `repo:`, `is:`, `in:` or `state:`, matched by whole words and numbers, punctuation dropped, as GitHub's search does, its links naming the repository the query's -`repo:` gives when the call names none; `list_pull_requests`, whose states are lower-case; and +`repo:` gives when the call names none; `list_pull_requests`, whose states are lower-case and whose `head`, `:`, is +matched by its branch; and `pull_request_read` with the methods `get`, `get_reviews`, `get_review_comments` and `get_comments`, for an open pull request only, with no review, thread or comment on any, so `pr-reader` dumps one. A closed pull request's number is an error result to `pull_request_read`, @@ -244,7 +245,8 @@ def call(name: str, args: dict): return {"incomplete_results": False, "items": [chosen(item(args, i), args.get("fields")) for i in hits], "total_count": len(hits)} if name == "list_pull_requests": state = args.get("state") or "open" - return [pull(args, p) for p in PULLS.values() if state == "all" or p["state"] == state] + head = str(args.get("head") or "").split(":")[-1] + return [pull(args, p) for p in PULLS.values() if (state == "all" or p["state"] == state) and (not head or p["head"] == head)] if name == "pull_request_read": n = int(args.get("pullNumber") or 0) if n not in PULLS: diff --git a/sandbox/tracker-github/fixture/github.json b/sandbox/tracker-github/fixture/github.json index a9adda4..1332011 100644 --- a/sandbox/tracker-github/fixture/github.json +++ b/sandbox/tracker-github/fixture/github.json @@ -93,8 +93,8 @@ }, { "number": 20, - "title": "Give each part number's count", - "body": "The warehouse team needs the count for each part number, not only one figure for the whole inventory.\n", + "title": "Break the inventory down by item", + "body": "The warehouse team needs a figure for every item code, not only one figure for the whole inventory.\n", "state": "open", "labels": [], "assignees": [], @@ -154,6 +154,18 @@ "head": "tidy-readme", "created_at": "2026-09-01T10:00:00Z", "updated_at": "2026-09-01T10:00:00Z" + }, + { + "number": 23, + "title": "Print a stock report from the command line", + "body": "Ticket: #18 · The warehouse team reads the stock without a Python shell.\n\n## Summary\n\nAdds `python -m widgets report`.\n", + "state": "open", + "merged": false, + "draft": false, + "user": {"id": 1003, "login": "alice-example"}, + "head": "18-stock-report", + "created_at": "2026-11-02T10:00:00Z", + "updated_at": "2026-11-03T12:00:00Z" } ] } diff --git a/sandbox/tracker-github/scenarios.yaml b/sandbox/tracker-github/scenarios.yaml index 272d84f..0a81245 100644 --- a/sandbox/tracker-github/scenarios.yaml +++ b/sandbox/tracker-github/scenarios.yaml @@ -15,7 +15,8 @@ # `ready` and `enhancement`, #17 closed as not planned and still labeled `in progress`, #18 and # #19 open and asking for the same stock report, #19 with less in it, and #20 open, untouched # since 2025 and asking for what by_part does; #15 is a merged pull request, its body naming #9, -# #16 one naming `o/r#90`, #21 an open one naming #17, and #22 an open draft naming no issue. +# #16 one naming `o/r#90`, #21 an open one naming #17, #22 an open draft naming no issue, and #23 +# an open one naming #18. # The fields are those of ../scenarios.yaml. scenarios: @@ -145,15 +146,17 @@ scenarios: origin: "#59 (review of #113): a pull request's number on a GitHub tracker is not an issue" why: >- Works 15, the merged pull request for issue 9, so work's one issue_read finds a pull - request's /pull/ link and reads it as that pull request, never as a ticket. The stub serves - no pull_request_read, so the pull request read itself fails and work stops there. + request's /pull/ link and reads it as that pull request, never as a ticket. The stub refuses + pull_request_read on a closed pull request, so the pull request read itself fails and work + stops there. turns: - /lasp-dev:work 15 must_not_run: ["Bash(git push*)", "Bash(git commit*)", "Edit", "Write(*/repos/widgets/[!.]*)", "Bash(*where.sh*--ticket 15*)"] look_at: >- work reads 15 with issue_read, sees /pull/ in its html_url, and takes it as pull request #15: it never gives where.sh --ticket 15, never routes 15 to ticket-draft or - implement-change, and stops at the pull request read the stub cannot serve, saying so. + implement-change, and stops at the pull request read the stub refuses for a closed pull + request, saying so. expect: ends: success mcp: [mcp__github__issue_read] @@ -330,7 +333,8 @@ scenarios: origin: "#59: ticket-audit sweeps a GitHub tracker's open issues and closes a duplicate on a yes" why: >- Sweeps the open issues for duplicates on a GitHub tracker, where #19 restates #18 with less - in it and #20, untouched for 180 days, asks for what by_part already does, then says yes to + in it and has no pull request where #18 has #23, and #20, untouched for 180 days, asks for + what by_part already does, then says yes to the write plan, so the duplicate's close reaches the stub only through the approval record and the issue gate. turns: @@ -361,7 +365,7 @@ scenarios: assert all((x["arguments"]["owner"], x["arguments"]["repo"]) == ("example", "widgets") for x in w), w; assert all(a["method"] == "update" for a in u), u; dup = [(a["issue_number"], a.get("duplicate_of")) for a in u if a.get("state") == "closed" and a.get("state_reason") == "duplicate"]; - assert dup in ([(19, 18)], [(18, 19)]), dup; + assert dup == [(19, 18)], dup; assert all(set(a.get("labels") or []) <= {l["name"] for l in d["labels"]} for a in u), u' - id: tracker-gh-audit-pulls @@ -373,19 +377,21 @@ scenarios: origin: "#59: ticket-audit checks open pull requests against their GitHub issues" why: >- Sweeps the open pull requests on a GitHub tracker: #21's Ticket: line names #17, closed as - not planned, and #22, a draft open since September, names no issue. No action the sweep - proposes is a write, so nothing is written. + not planned; #22, a draft open since September, names no issue; and #23 names #18, open with + no label though the standard maps `ready` to approved. No action the sweep proposes is a + write, so nothing is written. turns: - /lasp-dev:ticket-audit pulls must_not_run: ["Bash(git push*)", "Bash(git commit*)"] must_not_try: ["mcp__github__issue_write", "mcp__github__add_issue_comment"] look_at: >- - The session lists two open pull requests, says one reader runs per pull request, and runs a + The session lists three open pull requests, says one reader runs per pull request, and runs a pr-reader in state mode for each, writing their blocks to .review/audit/-pulls/state.md. The sweep's source-reader reads #17 from #21's Ticket: line and finds it closed, not planned, proposing the pull request be closed or the issue reopened; #22 names no issue, so the ticket - is to be filed, and it has been a draft for over a month. No action is a write, so Apply - offers the report alone. + is to be filed, and it has been a draft for over a month; #18, #23's issue, waits for a + ticket writer's approval, the `ready` label. No action is a write, so Apply offers the report + alone. expect: ends: success agents: [pr-reader, source-reader] @@ -396,6 +402,7 @@ scenarios: - test ! -e .review/github-approval.json - grep -q '#17' .review/audit/*-pulls.md - grep -q '#22' .review/audit/*-pulls.md + - grep -q '#18' .review/audit/*-pulls.md - id: tracker-gh-audit-declined repo: widgets diff --git a/skills/ticket-audit/github.md b/skills/ticket-audit/github.md index 881d1c0..192744e 100644 --- a/skills/ticket-audit/github.md +++ b/skills/ticket-audit/github.md @@ -19,7 +19,8 @@ for it, gets one line and no reader: - `links: does not apply on a GitHub tracker: an issue has no typed links` - `estimates: does not apply on a GitHub tracker: an issue carries no estimate` -When the person names no sweep, ask between `duplicates` and `pulls`. Both need the GitHub server, +When the person names no sweep, ask between `duplicates` and `pulls`, recommending the one their +words point to, or `duplicates` when nothing does. Both need the GitHub server, and neither needs Atlassian or reads the year plan; a GitHub server that cannot be resolved or reached stops them, as `/skills/work/servers.md` sets out. @@ -39,12 +40,16 @@ status", one finding of these per pull request: - it names no issue: no `Ticket:` line, or one that starts with `none`; the action is to file the ticket; -- its `Ticket:` line names something other than the tracker's issue, a Jira-shaped key leading it - or another repository's issue; the action is to correct the line; +- its `Ticket:` line names something other than the tracker's issue; the action is to correct the + line. "The record" sets which: a line leading with a Jira-shaped key is another tracker's; a bare + `#` is the tracker's only when `issue_tracker.location` prints `none` or names the pull + request's own repository, and an `#` or issue URL only when it names the + location, or with `none` the pull request's repository; - its issue is closed, with the `state_reason`; the action is to close the pull request or reopen the issue, for the person to choose; -- its issue is open and carries no label the roles map past `filed`, where `--defaults --field - issue_tracker.roles.approved` prints a label; the action is a ticket writer's approval. +- its issue is open and carries neither the `approved` nor the `implementation` role's label, + where `--defaults --field issue_tracker.roles.approved` prints a label; the action is a ticket + writer's approval. ## Apply diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 8b3d63d..3356f12 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -388,8 +388,8 @@ def test_a_pull_requests_number_reads_as_an_issue_at_a_pull_url(self): self.assertEqual(self.read("issue_read", method="get", issue_number=15)["issue"]["html_url"], "https://github.com/example/widgets/pull/15") def test_the_open_pull_requests_list_and_read_as_pr_reader_dumps_them(self): - self.assertEqual([p["number"] for p in self.read("list_pull_requests", state="open")], [21, 22]) - self.assertEqual([p["number"] for p in self.read("list_pull_requests", state="all")], [15, 16, 21, 22]) + self.assertEqual([p["number"] for p in self.read("list_pull_requests", state="open")], [21, 22, 23]) + self.assertEqual([p["number"] for p in self.read("list_pull_requests", state="all")], [15, 16, 21, 22, 23]) got = self.read("pull_request_read", method="get", pullNumber=21) self.assertEqual((got["number"], got["state"], got["draft"], got["merged"], got["user"]["login"]), (21, "open", False, False, "alice-example")) self.assertTrue(got["body"].startswith("Ticket: #17 ")) @@ -400,6 +400,12 @@ def test_the_open_pull_requests_list_and_read_as_pr_reader_dumps_them(self): threads = self.read("pull_request_read", method="get_review_comments", pullNumber=22) self.assertEqual((threads["review_threads"], threads["totalCount"], threads["pageInfo"]["hasNextPage"]), ([], 0, False)) + def test_list_pull_requests_matches_a_head_by_its_branch(self): + for head, found in (("example:9-by-part", [15]), ("9-by-part", [15]), ("example:no-such-branch", []), ("example:9-by", [])): + with self.subTest(head=head): + self.assertEqual([p["number"] for p in self.read("list_pull_requests", state="all", head=head)], found) + self.assertEqual(self.read("list_pull_requests", state="open", head="example:9-by-part"), []) + def test_a_closed_or_unknown_pull_request_is_an_error_to_pull_request_read(self): for n, said in ((15, "serves open pull requests only"), (99, "404 Not Found")): with self.subTest(n=n): From 92829e658e9e5f2624e8edce679ea0e36d606c40 Mon Sep 17 00:00:00 2001 From: Matthew Maclay Date: Sat, 7 Nov 2026 10:16:38 -0700 Subject: [PATCH 6/6] fix(ticket-audit): apply review rounds 2 and 3 on the GitHub audit The duplicates sweep searches origin's pull requests for an issue, in the form origin's bodies write it, URL form included. The pulls sweep's "names no issue" finding covers a Ticket: line from which no issue is read, and leaves another tracker's link to the correct-the-line finding. The scenarios require the pull request search and tie #18 to its approval finding. Co-Authored-By: Claude Opus 5.5 --- agents/source-reader.md | 7 +++++-- docs/contracts.md | 3 ++- sandbox/tracker-github/scenarios.yaml | 4 ++-- skills/ticket-audit/github.md | 8 +++++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/agents/source-reader.md b/agents/source-reader.md index 5fc1eb5..d9a2318 100644 --- a/agents/source-reader.md +++ b/agents/source-reader.md @@ -164,8 +164,11 @@ the caller names, `Tracker: github `, each issue written `#`; a G without that line returns `sweep: no issues repository named` and nothing else. `duplicates` reads the open issues with `list_issues`, state `OPEN`, paged until `hasNextPage` is false, and the comments of each issue a finding names with `issue_read` `get_comments`; for each issue of a pair, the pull requests naming it, with -`search_pull_requests`, query `repo: "#"`, the search that also finds what -satisfied an issue. `pulls` takes each pull request's issue from its state block's `ticket:` line, as +`search_pull_requests` on this repository, the owner/repo `git remote get-url origin` names, since +pull requests live there and not in the tracker's: query `repo: "#"` when +`--field issue_tracker.location` prints `none` or names that repository, and otherwise +`repo: "#"` and again `repo: "/issues/"` for a body +linking the issue, the search that also finds what satisfied an issue. `pulls` takes each pull request's issue from its state block's `ticket:` line, as `${CLAUDE_PLUGIN_ROOT}/docs/contracts.md`'s "The record" reads a `Ticket:` line on a GitHub tracker, and reads that issue with `issue_read` `get` for its state, `state_reason` and labels, in the named repository, or in the pull request's own when `--field issue_tracker.location` diff --git a/docs/contracts.md b/docs/contracts.md index c118b8a..91b88b8 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -762,7 +762,8 @@ label, and `--defaults --field issue_tracker.roles.` prints `none` for a r an issue's own open and closed states stand for `filed`, `finished` and `abandoned`. On a GitHub tracker `ticket-draft` reads `issue_tracker.roles.` and skips a move into a role whose label is `none` (version 2); `ticket-audit` reads `issue_tracker.roles` there in place of `jira.roles`, -for the label a close removes or adds (version 3). +for the label a close removes or adds and, in `source-reader`'s `pulls` sweep, for the `approved` +and `implementation` labels an open issue lacks when its pull request waits on approval (version 3). An open issue that carries no mapped label is in `filed`, and `work` sends it to `ticket-draft`. Writers: a project's standard, through `issue_tracker:` ("The profile"); `standards/ticket-format.md` diff --git a/sandbox/tracker-github/scenarios.yaml b/sandbox/tracker-github/scenarios.yaml index 0a81245..a59d66d 100644 --- a/sandbox/tracker-github/scenarios.yaml +++ b/sandbox/tracker-github/scenarios.yaml @@ -352,7 +352,7 @@ scenarios: expect: ends: success agents: [source-reader] - mcp: [mcp__github__list_issues, mcp__github__issue_write] + mcp: [mcp__github__list_issues, mcp__github__search_pull_requests, mcp__github__issue_write] files: [".review/audit/*-duplicates.md"] verify: - test ! -e .review/github-approval.json @@ -402,7 +402,7 @@ scenarios: - test ! -e .review/github-approval.json - grep -q '#17' .review/audit/*-pulls.md - grep -q '#22' .review/audit/*-pulls.md - - grep -q '#18' .review/audit/*-pulls.md + - grep -iE -B8 -A3 '#18' .review/audit/*-pulls.md | grep -qiE 'ready|approv' - id: tracker-gh-audit-declined repo: widgets diff --git a/skills/ticket-audit/github.md b/skills/ticket-audit/github.md index 192744e..2d7a8aa 100644 --- a/skills/ticket-audit/github.md +++ b/skills/ticket-audit/github.md @@ -38,10 +38,12 @@ its state block's `ticket:` line names, read as `/docs/contracts.md reads a body's `Ticket:` line on a GitHub tracker. In place of "a ticket still in the filed status", one finding of these per pull request: -- it names no issue: no `Ticket:` line, or one that starts with `none`; the action is to file the - ticket; +- it names no issue: no `Ticket:` line, one that starts with `none`, or one from which `profile.py`'s + `ticket_named` reads no issue without refusing and that links no other tracker, such as + `Ticket: TBD`; the rest are the next finding's; the action is to file the ticket; - its `Ticket:` line names something other than the tracker's issue; the action is to correct the - line. "The record" sets which: a line leading with a Jira-shaped key is another tracker's; a bare + line. "The record" sets which: a line leading with a Jira-shaped key, or linking another tracker + such as a Jira URL, is another tracker's; a bare `#` is the tracker's only when `issue_tracker.location` prints `none` or names the pull request's own repository, and an `#` or issue URL only when it names the location, or with `none` the pull request's repository;