diff --git a/README.md b/README.md index 7efa095..b36b663 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ on `gh`, `pr-fix` stops at its start and `pr-review` posts only a pull request's | Server | Skills | | --- | --- | -| GitHub | `work`, `pr-create`, `pr-review`, `pr-fix`, `ticket-draft`, `ticket-audit`, `revise`, `skill-feedback`; `work`'s ticket status too where the standard's tracker is GitHub's; `implement-change` when its source is a GitHub issue; `downstream`, for `ci `; `onboard`, when connected, and its first rules; `rebase`, when connected, for the pull request's base | +| GitHub | `work`, `pr-create`, `pr-review`, `pr-fix`, `ticket-draft`, `ticket-audit`, `revise`, `skill-feedback`; `work`'s ticket status too where the standard's tracker is GitHub's; `implement-change` when its source is a GitHub issue; `downstream`, for `ci `; `onboard`, when connected, the repository's issues and their labels for its tracker included, and its first rules; `rebase`, when connected, for the pull request's base | | Jira | `ticket-draft`, `design-doc`, `architecture-doc` and `process-doc` (only the tickets you or a page name), `ticket-audit`, `revise` (with `measures`, for an epic it adds or a context entry reads, or for an issue `correct` cites); `pr-review`, for the ticket when no context page holds it; `work`, for a ticket's status when it has no context page here; `implement-change` when its source is a ticket; `onboard`, when connected, and its first rules | | Confluence | `design-doc` (your notes, the area's pages, and the publish), `architecture-doc` (the system's page, the area's pages, and the publish), `process-doc` (your notes, the pages you point at, and the publish), `ticket-draft` (the year plan, and `investigate`), `revise` (the context refresh, or a page `correct` cites), `ticket-audit` (the year plan); `onboard`, when connected, and its first rules | | Neither | `commit`, `rebase` with no GitHub server connected, `drawio`, `architecture-doc` and `process-doc` until they publish, `implement-change` from a file or a description, `test-suite-review`, `writing`, `quickstart`, `onboard` with no server connected, the repository alone | @@ -772,7 +772,7 @@ session-start line or `work` says something unexpected, these files are what it | `onboard/downstream.json` | `onboard`, from its read-only code search when GitHub is connected: the repositories that import or depend on this one's package, each with its count of files | `scripts/survey.py --downstream-from`, for the candidate's proposed `downstream` | Replaced at each survey | | `onboard/profile.yaml` | `onboard`, through `scripts/survey.py --profile`: the candidate profile, every key it cannot fill reading `not yet recorded`, with downstream repositories and depth globs proposed for the person to trim, and `profile.sources.json` beside it, where each value came from | `sandbox/onboard_roundtrip.py`; the person | Replaced at each survey | | `onboard/answers.json` | `onboard`, from the person's answers: each key they confirmed, trimmed or typed, in the form `scripts/survey.py --answers` takes | `scripts/survey.py --drop-in`; `sandbox/onboard_roundtrip.py`; the person | Replaced at each run that asks | -| `onboard/project.yaml` | `onboard`, for a new standard: the project's settings the person gave, the tracker's workflow and the internal domains, in the profile's form | `onboard`, at step 5, into the standard's `profile.yaml` on the person's yes | Replaced at each run that asks | +| `onboard/project.yaml` | `onboard`, for a new standard: the project's settings the person gave, the tracker's workflow (a Jira workflow as `jira:`, or GitHub Issues' labels as `issue_tracker:`, with a ticket pattern when one is recorded) and the internal domains, in the profile's form | `onboard`, at step 5, into the standard's `profile.yaml` on the person's yes | Replaced at each run that asks | | `onboard/profile.dropin.yaml` | `onboard`, through `scripts/survey.py --drop-in --answers`: only the keys the survey filled and the person settled, so the team's values stand for the rest | `onboard`, at step 5, on the person's yes; the person, to place it; a sandbox variant in place of the hand-written profile | Replaced at each run that asks | | `onboard/clone/__` | `onboard`, for a standard in another repository, in the main working tree's `.review/` whichever working tree it runs in: a clone of it, read-only until its step 5 writes this repository's settings there on a branch | `git config lasp-dev.standard`, which names it until the standard's pull request merges; `onboard`'s next run, which uses it as it stands | Kept; removed by the person once the pull request merges and the config is unset | | `design//` | `design-doc`, under the page ID or a topic slug: `notes.md`, `frame.md` (confirmed with `Confirmed:`), a `.drawio` file per diagram, `draft.md`, a `tickets/.md` prompt per ticket to create; `page.md` in `revise` | `design-doc`, resuming from the frame and publishing `draft.md`; `ticket-draft`, given a prompt; the person, placing the diagrams | Not cleared by the tools | diff --git a/ROADMAP.md b/ROADMAP.md index 32d2e47..b9d5e32 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 `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 | +| 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:` for a Jira tracker. 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), what is left | GitHub Issues is the first tracker adapter after Jira; reading, routing, drafting and filing, the audit sweeps and onboard's detection have landed. Left: no Projects fields, estimates or sub-issues | plugin maintainer | a project that needs them | deferred | | 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/docs/contracts.md b/docs/contracts.md index 000226e..8b7f063 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -792,7 +792,9 @@ page as a ticket and counts no gate, and `standard_file.sh` exits 1 (version 3). and the shadow read are gone, with `.review/profile-shadow.log` and exit 3 (version 15). Writers: `onboard`, on the person's yes at its step 5: the drop-in's settled keys and their derived gates, `public` -and, for a new standard, `.review/onboard/project.yaml` with a `decisions.md` holding none yet, into the standard's `profile.yaml` or, +and, for a new standard, `.review/onboard/project.yaml` (a Jira tracker as `jira:`, GitHub Issues as +`issue_tracker:` with `kind: github`, a `location` only for another repository's issues, and a +`ticket.pattern` only when one is recorded) with a `decisions.md` holding none yet, into the standard's `profile.yaml` or, for a standard covering several repositories, its `profiles/.yaml`, with the pointer here; people, by hand from `standards/profile.example.yaml`; the plugin, `standards/default-profile.yaml`; each repository's maintainers, its `standards/profile.yaml`; and `profile.py`, through `survey.py --drop-in`, the @@ -1325,7 +1327,7 @@ Pinned by: tests/test_check_static.py::test_the_update_writes_the_baseline ## The survey -Version: 10 +Version: 11 Fields: `scripts/survey.py`'s printout, from the committed files of `--ref` (default `HEAD`) only; a candidate file the checkout holds but git does not track is named on the `not committed` @@ -1416,7 +1418,15 @@ as `where.sh` reads it or at a commit subject's start, a number followed by `-` is GitHub's issue form and names no project. The `issue key` row, after `ticket +project`, reads GitHub issue keys in the same places (version 11): a branch name's `issue-`, +`GH-`, or `` alone, each before `-`, `_` or the name's end, so `2026-10-29-notes` is none, +and a subject opening with `#` or `GH-`, with the same date guard. Its value is +`branches:
, … of ; subjects: of ; pattern

`, `

` the ticket pattern the +branch forms imply as `where.sh` reads a GitHub tracker's branch: `(issue-)?\d+`, its default, for +`issue-` and `-words`; `GH-\d+` for `GH-` alone; `mixed` for `GH-` beside another +form; `none` with no branch form. No key holds it (`-`), and the candidate takes nothing from it, +since a GitHub tracker's pattern is only ever the one its standard records; `onboard` proposes it. `--json` prints the same rows, their values whole, as a list of objects with `family`, `found_in`, `value`, `key`, `plugin_today`, `plugin_source`, `match` and `conflict`. With `--profile `, it also writes a candidate profile there, in the plugin's profile format diff --git a/sandbox/README.md b/sandbox/README.md index 1bdaaf3..c522267 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -87,6 +87,12 @@ such a call then runs only when `hooks/issue-gate.sh` allows it, and the stub re file, which each scenario's setup removes and its verify reads; nothing reaches GitHub. The stub's tools carry github-mcp-server's names, arguments and output shapes. +The same pack's `tracker-gh-onboard` and `tracker-gh-onboard-mixed` run `onboard` with no +standard, each setup making its own history: one with no Jira key, where onboard reads the +issues and writes their labels as `issue_tracker:` under `.review/onboard/`, and one whose branch +names a Jira project beside the issues, where onboard asks which tracker holds the tickets. The +stub also answers onboard's `search_repositories` and `search_code`. + A project's own pack, which replays its pull requests and tickets against its GitHub and Jira, read-only, is a file of the same form in a directory beside this file, given with `--scenarios`, with a README that lists its scenarios and diff --git a/sandbox/run.py b/sandbox/run.py index 21beab5..cc60f7c 100755 --- a/sandbox/run.py +++ b/sandbox/run.py @@ -348,9 +348,9 @@ def holding(sandbox: Path, who: str, wait: bool): def reset(sandbox: Path) -> None: """Both clones back to their pinned commits, out of any rebase or merge a run left paused, with untracked and ignored files gone but .venv kept, - origin fetching from the URL `sandbox.sh` gave it and kept as `sandbox.fetch`, and `main` and `origin/main` back at the `main` - of the bare repository under remotes/, the source's `main` when the sandbox was built, which a - scenario's setup may have moved.""" + origin fetching from the URL `sandbox.sh` gave it and kept as `sandbox.fetch`, every `origin/*` back at the branches of the bare + repository under remotes/ and `main` at its `main`, the source's when the sandbox was built, and every other local branch + deleted, since a scenario's setup may have moved, made or deleted any of them.""" for name, (sha, branch) in pins(sandbox).items(): repo = sandbox / "repos" / name fetch = subprocess.run(["git", "-C", str(repo), "config", "--get", "sandbox.fetch"], capture_output=True, text=True).stdout.strip() @@ -361,8 +361,12 @@ def reset(sandbox: Path) -> None: git(repo, "checkout", "-q", "-f", "-B", branch, sha) git(repo, "reset", "-q", "--hard", sha) git(repo, "clean", "-q", "-fdx", "-e", ".venv") - main = git(sandbox / "remotes" / f"{name}.git", "rev-parse", "--verify", "refs/heads/main") - git(repo, "update-ref", "refs/remotes/origin/main", main) + bare = sandbox / "remotes" / f"{name}.git" + git(repo, "fetch", "-q", "--prune", str(bare), "+refs/heads/*:refs/remotes/origin/*") + for other in git(repo, "for-each-ref", "refs/heads", "--format=%(refname:lstrip=2)").split(): + if other not in (branch, "main"): + git(repo, "branch", "-q", "-D", other) + main = git(bare, "rev-parse", "--verify", "refs/heads/main") if branch != "main": git(repo, "update-ref", "refs/heads/main", main) diff --git a/sandbox/tracker-github/fixture/github-mcp-server-stub.py b/sandbox/tracker-github/fixture/github-mcp-server-stub.py index 6759d2e..1dbcafc 100644 --- a/sandbox/tracker-github/fixture/github-mcp-server-stub.py +++ b/sandbox/tracker-github/fixture/github-mcp-server-stub.py @@ -279,9 +279,36 @@ def call(name: str, args: dict): "updated_at": p["updated_at"], "pull_request": {"html_url": url(args, "pull", p["number"]), "diff_url": url(args, "pull", p["number"]) + ".diff"}}) return {"incomplete_results": False, "items": [chosen(h, args.get("fields")) for h in hits], "total_count": len(hits)} + if name in SEARCHES: + return searched(name, args) raise ValueError(f"unknown tool {name}") +# The two searches onboarding makes before it reads the issues: `search_repositories`, which +# returns the repository its query's `repo:` names, public, on `main`, as the server's minimal +# output gives it, and `search_code`, which finds nothing, so no standard elsewhere covers the +# repository. +SEARCHES = { + "search_repositories": ("Find GitHub repositories by name, description, readme, topics, or other metadata.", + {"query": TEXT, "sort": TEXT, "order": TEXT, "page": NUMBER, "perPage": NUMBER, "minimal_output": {"type": "boolean"}}, + ["query"]), + "search_code": ("Fast and precise code search across ALL GitHub repositories using GitHub's native search engine.", + {"query": TEXT, "sort": TEXT, "order": TEXT, "page": NUMBER, "perPage": NUMBER}, ["query"]), +} +TOOLS.update(SEARCHES) + + +def searched(name: str, args: dict) -> dict: + named = re.search(r"\brepo:([^/\s]+)/(\S+)", str(args.get("query", ""))) + if name == "search_code" or not named: + return {"total_count": 0, "incomplete_results": False, "items": []} + owner, repo = named.groups() + return {"total_count": 1, "incomplete_results": False, + "items": [{"id": 2001, "name": repo, "full_name": f"{owner}/{repo}", "html_url": f"https://github.com/{owner}/{repo}", + "private": False, "fork": False, "archived": False, "default_branch": "main", "open_issues_count": 2, + "created_at": "2026-01-05T10:00:00Z", "updated_at": "2026-02-11T08:00:00Z"}]} + + def schema(props: dict, required: list) -> dict: return {"type": "object", "properties": props, "required": required} diff --git a/sandbox/tracker-github/scenarios.yaml b/sandbox/tracker-github/scenarios.yaml index a59d66d..3291ee3 100644 --- a/sandbox/tracker-github/scenarios.yaml +++ b/sandbox/tracker-github/scenarios.yaml @@ -323,6 +323,72 @@ scenarios: verify: - test ! -e .git/github-writes.jsonl - test ! -e .review/github-approval.json + # onboard on a repository with no standard, reading its GitHub issues. The stub also answers + # onboard's search_repositories (public, on main) and search_code (nothing), so no standard + # elsewhere covers widgets. Each setup makes its own history, and a run's reset restores the branches. + - id: tracker-gh-onboard + repo: widgets + setup: + - rm -f .git/github-writes.jsonl + - git branch -D PROJ-42-csv-loader 2>/dev/null || true + - git update-ref -d refs/remotes/origin/PROJ-42-csv-loader + - git branch -f 9-by-part main && git branch -f issue-12-empty-inventory main + origin: "#59: onboard detects GitHub Issues as the tracker and maps the labels the repository uses" + why: >- + Onboards widgets with no Jira key in its history and issues on GitHub, so onboard reads the + issues, proposes GitHub Issues as the tracker with the labels mapped to the workflow, and + writes the project settings as issue_tracker, never jira, and only under .review/onboard/. + turns: + - /lasp-dev:onboard + - Here. + - Take your recommended answers to those questions. + must_not_run: ["Bash(git push*)", "Bash(git commit*)", "mcp__github__issue_write", "Write(*/repos/widgets/[!.]*)"] + look_at: >- + The second turn lists the issues, finds no Jira project and proposes GitHub Issues: `ready` + mapped to approved and `in progress` to implementation, both to implement-change, with `in + progress` the pull request label; `enhancement` passed over as a kind of issue; no label + proposed that no issue carries, such as needs-refinement; filed, finished and abandoned left + to the issue's state; the issues this repository's, though one pull request names o/r#90; + and the branches' pattern the default, not asked. The third turn writes + .review/onboard/project.yaml with issue_tracker kind github and no location, then asks step + 5's question; nothing is written to standards/. + expect: + ends: success + mcp: [mcp__github__list_issues] + files: [".review/onboard/project.yaml"] + files_absent: ["standards/*"] + verify: + - grep -Eq '^issue_tracker:' .review/onboard/project.yaml + - grep -Eq '^[[:space:]]+kind:[[:space:]]*github' .review/onboard/project.yaml + - grep -Eq "name:[[:space:]]*['\"]?ready" .review/onboard/project.yaml + - "! grep -Eq '^jira:|^[[:space:]]+location:' .review/onboard/project.yaml" + - "! grep -Eq \"name:[[:space:]]*['\\\"]?(enhancement|needs-refinement)\" .review/onboard/project.yaml" + - |- + python3 -c 'import re, sys; m = re.search(r"^\s+pr_statuses:(.*(?:\n\s+- .*)*)", open(".review/onboard/project.yaml").read(), re.M); sys.exit(not (m and re.search(r"\bin progress\b", m.group(1))))' + + - id: tracker-gh-onboard-mixed + repo: widgets + setup: + - rm -f .git/github-writes.jsonl + - git branch -f PROJ-42-csv-loader main + origin: "#59: onboard asks which tracker when the history names a Jira project and GitHub holds issues" + why: >- + Onboards widgets, whose branch PROJ-42-csv-loader names a Jira project while GitHub holds its + issues, so the evidence is mixed and onboard asks which tracker holds the project's tickets + before any question about the workflow, writing no project settings. + turns: + - /lasp-dev:onboard + - Here. + must_not_run: ["Bash(git push*)", "Bash(git commit*)", "mcp__github__issue_write", "Write(*/repos/widgets/[!.]*)"] + look_at: >- + The second turn runs the survey, whose ticket project row names PROJ, lists the issues, and + ends asking whether the project's tickets are in Jira or GitHub Issues, with the evidence for + each, before mapping a label or a status. No project.yaml is written. + expect: + ends: success + mcp: [mcp__github__list_issues] + says: ["(?i)\\bjira\\b", "(?i)\\bgithub issues\\b", "(?is)\\bwhich tracker\\b|(?:\\bwhich one\\b|\\b(?:jira|github issues)\\b[^?]{0,120}\\bor\\b[^?]{0,120}\\b(?:jira|github issues)\\b)[^?]{0,200}\\?"] + files_absent: ["standards/*", ".review/onboard/project.yaml"] - id: tracker-gh-audit-duplicates repo: widgets diff --git a/scripts/survey.py b/scripts/survey.py index 5029cb7..4c75125 100644 --- a/scripts/survey.py +++ b/scripts/survey.py @@ -74,7 +74,13 @@ `doc/` or `docs/`). A ticket key is taken only where convention puts one: in a branch name, where `where.sh` reads it, or opening a commit subject of `--ref`'s history, a number followed by `--` being a date and no key; it names the project when seen with two numbers or more, or in a -branch name, and with at least twice as many numbers as any other key so seen. With `--json`, the same rows as a JSON list of +branch name, and with at least twice as many numbers as any other key so seen; `GH-` is GitHub's +form and names no project. The `issue key` row reads GitHub issue keys in the same places: a branch +name's `issue-`, `GH-` or `` before `-`, `_` or the name's end, each form with its count, +and a subject opening with `#` or `GH-`, then the ticket pattern the branch forms imply, +`(issue-)?\d+` (`where.sh`'s default for a GitHub tracker) for `issue-` and `-words`, +`GH-\d+` for `GH-` alone, `mixed` for `GH-` beside another form, or `none` with no branch +form; no key holds it, since a GitHub tracker's pattern is only ever the standard's. With `--json`, the same rows as a JSON list of objects with the keys `family`, `found_in`, `value`, `key`, `plugin_today`, `plugin_source`, `match` and `conflict` (true on every row of a family whose sources disagree), each value whole, where the table cuts one past 60 characters. @@ -156,6 +162,13 @@ # (`onboard/WISER-2026-10-29`). BRANCH_KEY = re.compile(r"^(?:[A-Za-z]+/)?([A-Z][A-Z0-9]+)-(\d+)(?!\d|-\d\d-\d\d)") SUBJECT_KEY = re.compile(r"^\[?([A-Z][A-Z0-9]+)-(\d+)\b(?!-\d\d-\d\d)") +# A GitHub issue's number in the same places: a branch name's start, after the same optional +# `/`, as `issue-`, `GH-` or ``, each before `-`, `_` or the name's end, so a date +# such as `2026-10-29-notes` is no key; a subject's start as `#` or `GH-`, with the date +# guard above. `GH` is GitHub's form, never a Jira project. +ISSUE_BRANCH = re.compile(r"^(?:[A-Za-z]+/)?(issue-|GH-)?(\d+)(?=-[A-Za-z]|_|$)", re.I) +ISSUE_SUBJECT = re.compile(r"^\[?(#|GH-)(\d+)\b(?!-\d\d-\d\d)", re.I) +ISSUE_FORMS = {"": "-words", "issue-": "issue-", "gh-": "GH-"} HEADINGS = {"commit style": r"(? int: def project_key(names: list[str], subjects: list[str]) -> str: """The ticket project the history names where convention puts a key, at a branch name's start or a subject's: seen with two numbers or more, or in a branch name, and with at least twice as - many numbers as any other key so seen; otherwise the history names none.""" + many numbers as any other key so seen; otherwise the history names none. `GH` is GitHub's + issue form and names no project.""" numbers: dict[str, set] = {} in_branch = set() for text, is_branch in [(n, True) for n in names] + [(s, False) for s in subjects]: m = (BRANCH_KEY if is_branch else SUBJECT_KEY).match(text) - if m: + if m and m.group(1) != "GH": numbers.setdefault(m.group(1), set()).add(m.group(2)) if is_branch: in_branch.add(m.group(1)) @@ -838,6 +852,27 @@ def project_key(names: list[str], subjects: list[str]) -> str: return ranked[0][0] +def issue_key(names: list[str], subjects: list[str]) -> str: + """The GitHub issue keys the history carries where convention puts one, as the `issue key` row + gives them: each branch form with how many branch names carry it, how many subjects open with + one, and the ticket pattern the branch forms imply as `where.sh` reads a GitHub tracker's + branch: `(issue-)?\\d+`, its default, for `issue-` and `-words`, `GH-\\d+` for `GH-` + alone, `mixed` for `GH-` beside another form, and `none` when no branch name carries one. + Empty when neither a branch name nor a subject does.""" + forms = Counter(ISSUE_FORMS[(m.group(1) or "").lower()] for m in map(ISSUE_BRANCH.match, names) if m) + led = sum(1 for s in subjects if ISSUE_SUBJECT.match(s)) + if not forms and not led: + return "" + if not forms: + pattern = "none" + elif "GH-" not in forms: + pattern = r"(issue-)?\d+" + else: + pattern = r"GH-\d+" if len(forms) == 1 else "mixed" + held = ", ".join(f"{form} {n}" for form, n in sorted(forms.items(), key=lambda kv: (-kv[1], kv[0]))) + return f"branches: {held or 'none'} of {len(names)}; subjects: {led} of {len(subjects)}; pattern {pattern}" + + def survey(top: Path, root: Path, ref: str = "HEAD", raws: list | None = None) -> tuple[str, list[dict]]: """The repository and its rows; `raws`, when given, gets each row's value as the repository writes it, a command whole rather than as the table shows it.""" @@ -956,6 +991,8 @@ def docs_named(rx) -> list[str]: f"{key}-; branches: {exact} of {len(names)} as where.sh, {loose} any case" if key else "", "ticket.pattern", {"pattern": pattern, "names": names} if key else None) add("ticket project", "history" if key else "none", key, "ticket.project", key or None) + issue_keys = issue_key(names, subjects) + add("issue key", "history" if issue_keys else "none", issue_keys) led = sum(1 for s in subjects if key and re.match(rf"\[?{re.escape(key)}-\d+\b", s)) conventional = sum(1 for s in subjects if re.match(r"(feat|fix|docs|chore|refactor|test|build|ci)(\(.+\))?!?:", s)) habit = (f"{led} of {len(subjects)} subjects open with a ticket key" if led else diff --git a/skills/onboard/SKILL.md b/skills/onboard/SKILL.md index b0a3834..c649d91 100644 --- a/skills/onboard/SKILL.md +++ b/skills/onboard/SKILL.md @@ -15,7 +15,7 @@ description: > argument-hint: "[rules [Confluence page or Jira epic to add]]" metadata: author: lasp-dev - version: 0.6.7 + version: 0.7.0 --- # Onboard @@ -160,8 +160,9 @@ never a page's body. Find each server's tools with `ToolSearch`; a server that i or a call that fails, is a line in step 3's "could not detect" group, and the rest goes on. Say so once per server, at the first step that needs it, as `${CLAUDE_PLUGIN_ROOT}/skills/work/servers.md` sets out, naming what is lost: for GitHub, step 1's -search for the project's standard, the repository's visibility and default branch and the search -for the repositories that use it; for Jira, the statuses and fields; for Confluence, the space keys. +search for the project's standard, the repository's visibility and default branch, the search +for the repositories that use it and its issues' labels; for Jira, the statuses and fields; for +Confluence, the space keys. - **GitHub**, as `${CLAUDE_PLUGIN_ROOT}/skills/using-github/SKILL.md` sets out: whether the repository is private and its default branch, from the `search_repositories` call step 1 made @@ -194,8 +195,34 @@ for the repositories that use it; for Jira, the statuses and fields; for Conflue incomplete, which the table says. Write them to `.review/onboard/downstream.json` with the Write tool, as a JSON list of `{"repo": "", "evidence": }`, the name bare with no owner, most first, or skip the file when none is found. -- **Jira**, for the project the survey's `ticket project` row names, or the profile's - `ticket.project` when the history names none: +- **Which tracker**, for a new standard; a standard the repository joins already names it. The + survey's `ticket project` row is the Jira history, and its `issue key` row the GitHub issue keys + in branch names and subjects. With GitHub connected, read this repository's issues and pull + requests too: `list_issues`, with no `state`, so open and closed alike, `orderBy` `UPDATED_AT`, + `direction` `DESC` and `perPage` 50; and one `search_pull_requests` for `repo:/ + is:pr is:merged`, `perPage` 30, reading of each body only its issue references, in the forms + `docs/contracts.md`'s "The record" reads on a `Ticket:` line, an issue's `github.com` URL among + them, there or after `Closes`, `Fixes` or `Resolves`. Then: + - a project on the `ticket project` row, and no issue listed: Jira, read as below; + - no project there, and an issue listed, an issue reference in a body, or an `issue key` row + found in `history`: GitHub Issues, read as below; + - a project there, and an issue listed: the evidence is mixed. Read both, and step 4 asks which + tracker the project uses before any question about its workflow; + - neither: no tracker is found, and step 4 says so and asks nothing about a workflow. +- **GitHub Issues**, when the tracker is or may be GitHub's: + - where the issues live: this repository, unless it lists no issues or has them turned off, + and at least two of the bodies' references name the same other repository, as + `/#` or its issue URL. That repository is then proposed as the issues' location, and its issues + are listed as above in place of this one's; otherwise another repository's references are + shown as evidence only; + - the labels the listed issues carry, each with how many open and how many closed issues carry + it. Only these labels, which the repository already uses, are proposed, and none is created. + GitHub's server lists no labels on their own, so a label the person names at step 4 that the + sample missed is checked with `get_label` (`owner`, `repo`, `name`); one it does not find is + said and not recorded; + - the ticket pattern the survey's `issue key` row implies. +- **Jira**, when the tracker is or may be Jira's, for the project the survey's `ticket project` + row names, or the profile's `ticket.project` when the history names none: - the statuses, sampled: `jira_search` with `project = ORDER BY updated DESC`, `fields` `status`, `limit` 50, each distinct status with its category (To Do, In Progress, Done); - the fields a skill uses by role, found by `jira_search_fields`: an epic link and an estimate @@ -213,7 +240,8 @@ Group the families, in this order: 1. **In the repository**: the survey's rows with a path, `git` or `history`, each with its `match`. 2. **Elsewhere**: what only the plugin or a server holds, with where: the standard's file or the default profile the - survey names, or the server and the call. + survey names, or the server and the call; among them the tracker step 2 found, Jira, GitHub + Issues, mixed or none, with its evidence. 3. **Missing**: a family neither holds. 4. **Could not detect**: a server not connected, a call that failed, or a family no tool lists. A server's row names its case and the fix, as `servers.md` sets out. @@ -303,9 +331,19 @@ file leaves out is asked as for a new repository. downstream check off. Either way, say the search covered only ``, so a user in another organization is one to add by name. - **For a new standard only, the project's settings**, which the default profile states for no - project, asked only when step 2 read the tracker's statuses (otherwise say the question was + project, asked only when step 2 read the tracker's statuses, or GitHub's issues (otherwise say the question was skipped, so no workflow is recorded and no skill moves a ticket's status or routes by it, and - the standard names no internal host of its own, until its `profile.yaml` records them): the tracker's workflow, each + the standard names no internal host of its own, until its `profile.yaml` records them). Where + step 2 found the evidence mixed, first ask alone **which tracker holds the project's tickets**, + since every other question here follows from it: what each has, the Jira project's key with the + branches and subjects that carry it, and the issues listed with the newest one's last update and + the merged pull requests whose bodies name an issue; recommend the one the most recent branches + and pull requests follow, and when neither leads, recommend neither and say so. **Jira**, + **GitHub Issues**, or **Let's discuss**. The rest of this bullet is then asked only when step 2 + read the chosen tracker's statuses or issues; otherwise say the question was skipped, as above, + and write no `.review/onboard/project.yaml`. For GitHub Issues, read + `${CLAUDE_SKILL_DIR}/github-issues.md` for these settings and follow it in place of the rest of + this bullet. For Jira: the tracker's workflow, each status with the phase a ticket in it goes to, `ticket-draft`, `implement-change` or `nothing`, and, for five of them, the role a generic skill names it by, `filed`, `approved`, `implementation`, `finished` and `abandoned`, each on exactly one status, a status the sample @@ -504,6 +542,7 @@ and a clone of the standard's repository under `.review/onboard/clone/` where st the step 5 writes the person says yes to, each by name, `git config lasp-dev.standard` among them, and on step 6's yes what `first-rules.md`'s "Writes" names under `.review/harvest/` and `.review/backtest/`. No -ticket is read beyond its status, and no page is fetched, but the pages and epics the person names +ticket is read beyond its status, a GitHub issue beyond its state and labels, or a merged pull +request's body beyond its issue references, no label is created, and no page is fetched, but the pages and epics the person names at step 6; a Confluence search's excerpts are read only for a warning, and nothing of them is kept. diff --git a/skills/onboard/github-issues.md b/skills/onboard/github-issues.md new file mode 100644 index 0000000..6485f24 --- /dev/null +++ b/skills/onboard/github-issues.md @@ -0,0 +1,59 @@ +# GitHub Issues' settings + +Read by onboard's step 4 for a new standard whose tickets are GitHub issues: step 2 found GitHub +Issues, or the person chose it where the evidence was mixed. These questions go in step 4's widget +beside its others, each with what bears on it and a recommendation, and none writes outside +`.review/onboard/`. + +- **The workflow, from the labels.** An issue's own state already says most of where it stands: + an open one is filed, one closed as completed is finished, and one closed as not planned is + abandoned. A label marks a step between. For each label step 2 found that marks a step of the + work, rather than a kind of issue such as `bug` or `enhancement`, ask the phase an open issue + carrying it goes to, `ticket-draft`, `implement-change` or `nothing`, and, where one fits, the + role a generic skill names it by, `filed`, `approved`, `implementation`, `finished` or + `abandoned`, each role on at most one label. A role with no fitting label stays unmapped, and + the issue's state stands for it. A label that sorts issues by kind is not mapped, so an open + issue carrying only such labels goes to `ticket-draft`, as one with none does. Recommend from + the label names and their open and closed counts, such as `ready` for approved and `in progress` + for implementation, both going to `implement-change`. Name each listed open issue that carries + two or more of the labels recommended, and say `work` refuses it until all but one are removed. + A label the person names that the sample missed is checked first, as step 2 says. Nothing + creates or renames a label. +- **The labels at which a pull request may be open**, each one of the mapped labels: recommend + the implementation label when one is mapped, and none otherwise. +- **Where the issues live**, asked only when step 2 proposed another repository, which it does + only when this one lists no issues or has them turned off: recommend it, with the references that name it as the + evidence. Otherwise the issues are this repository's and nothing is asked. +- **How a branch names its issue**, which `work` and the session-start line read to tell which + issue a branch is for, from the `issue key` row's pattern: + - `(issue-)?\d+` or `none`: with no pattern recorded, the plugin reads `59-words`, + `issue-59-words` and `feature/59-words` as issue 59, so this is not asked, and the table says + the branches follow the default; + - `GH-\d+`: ask, recommending recording it, since with none recorded no `GH-` branch names + its issue; + - `mixed`: the branch names follow more than one form, so the history leaves the pattern open: + ask, with each form's count from the row. Offer a pattern covering every form seen, such as + `(GH-|issue-)?\d+` (Recommended: no branch then goes unread), the most used form's own + pattern, or **Let's discuss**. Whichever the person picks, the default included, is recorded. + +Write the answers to `.review/onboard/project.yaml` with the Write tool, never into +`answers.json`, whose keys are the survey's, and never as `jira:`: + +```yaml +issue_tracker: + kind: github + statuses: + - {name: ready, next: implement-change, role: approved} + - {name: in progress, next: implement-change, role: implementation} + pr_statuses: [in progress] +internal: + domains: [] + private_repositories: [] +``` + +Each status is `{name, next, role}`, with the role only where one was given, and `statuses: []` +when no label is mapped. `location: /` goes under `kind` only when the issues are +another repository's; with none, the plugin reads the issues as this repository's, and `none` is +never written, since the standard refuses it. A recorded pattern adds `ticket:` with `pattern:`, +in single quotes. `internal.domains` holds the hosts no posted text may name, from the hosts the +servers use, as for Jira, and is `[]` when they name none of the project's own. diff --git a/tests/test_onboard_roundtrip.py b/tests/test_onboard_roundtrip.py index 8458ae9..70f9c50 100644 --- a/tests/test_onboard_roundtrip.py +++ b/tests/test_onboard_roundtrip.py @@ -366,6 +366,16 @@ def test_a_package_or_marker_named_like_a_checker_is_none(self): data, keys = values_of(make_repo(stubs, REMOTE), self.root) self.assertEqual((data["types"]["regex"], keys["types.regex"]["source"]), (NR, None)) + def test_a_github_keyed_history_fills_no_ticket_key(self): + repo = make_repo({}, REMOTE, branches=("GH-12-guard", "14-low-stock", "issue-9-by-part"), subjects=("GH-12: guard", "GH-14: low")) + data, keys = values_of(repo, self.root) + self.assertEqual(data["ticket"], {"pattern": NR, "project": NR}) + self.assertIsNone(keys["ticket.pattern"]["source"]) + drop_in = scratch() / "onboard" / "profile.dropin.yaml" + out = survey_in(repo, self.root, "--profile", str(drop_in.with_name("profile.yaml")), "--drop-in", str(drop_in)) + self.assertEqual(out.returncode, 0, out.stderr) + self.assertNotIn("ticket", profile.parse(drop_in.read_text(), "drop-in")) + def test_no_signal_leaves_every_detected_key_unrecorded(self): quiet = {"README.txt": "x\n", "pyproject.toml": "[project]\nname = 'p'\n# no mypy here\n", "tests/test_manifest.py": "x\n", "docs/calibration.txt": "x\n"} diff --git a/tests/test_profile.py b/tests/test_profile.py index 4f98ed5..38ca6a8 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -350,6 +350,7 @@ def test_a_github_tracker_maps_at_most_one_label_to_each_role(self): ("{name: triage, next: ticket-draft}", "{name: triage, next: ticket-draft, role: reviewed}", "gives triage the role 'reviewed', not one of"), ("location: someone/issues", "location: issues", "issue_tracker.location is owner/repo"), + ("location: someone/issues", "location: none", "issue_tracker.location is owner/repo"), ("location: someone/issues", "location: not yet recorded", "issue_tracker.location reads not yet recorded"), (" kind: github\n", "", "leaves out issue_tracker.kind"), ("{name: triage, next: ticket-draft}", "{name: None, next: ticket-draft}", "has a label named none"), diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index ed4ab1c..d167bdd 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"} + ONBOARD = {"tracker-gh-onboard", "tracker-gh-onboard-mixed"} AUDIT = {"tracker-gh-audit-duplicates", "tracker-gh-audit-pulls", "tracker-gh-audit-declined"} def setUp(self): @@ -262,7 +263,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.AUDIT) + self.assertEqual(set(self.known), self.WORK | self.DRAFT | self.ONBOARD | self.AUDIT) well_formed(self, self.known, {"widgets"}) for s in self.known.values(): if s["id"] in self.WORK: @@ -308,7 +309,7 @@ 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", - "list_pull_requests", "pull_request_read"}) + "list_pull_requests", "pull_request_read", "search_repositories", "search_code"}) 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"]) @@ -463,6 +464,38 @@ def test_the_pack_plants_no_credentials(self): self.assertTrue(files) self.assertEqual(scan.planted(files), {}) + def test_the_onboard_scenarios_read_the_issues_and_write_no_standard(self): + for name in self.ONBOARD: + s = self.known[name] + self.assertEqual(s["expect"]["mcp"], ["mcp__github__list_issues"], name) + self.assertIn("standards/*", s["expect"]["files_absent"], name) + self.assertIn("mcp__github__issue_write", s["must_not_run"], name) + self.assertIn("rm -f .git/github-writes.jsonl", s["setup"], name) + self.assertIn(".review/onboard/project.yaml", self.known["tracker-gh-onboard-mixed"]["expect"]["files_absent"]) + self.assertTrue(any("kind:" in v and "github" in v for v in self.known["tracker-gh-onboard"]["verify"])) + + def test_the_onboard_history_is_github_keyed_or_mixed_as_each_scenario_needs(self): + widgets = Path(tempfile.mkdtemp()) / "widgets" + self.addCleanup(shutil.rmtree, widgets.parent) + subprocess.run(["bash", str(ROOT / "sandbox" / "fixture" / "make_widgets.sh"), str(widgets)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(widgets), "update-ref", "refs/remotes/origin/PROJ-42-csv-loader", "PROJ-42-csv-loader"], check=True) + survey = ROOT / "scripts" / "survey.py" + for name, project, issues in (("tracker-gh-onboard", "-", r"pattern (issue-)?\d+"), ("tracker-gh-onboard-mixed", "PROJ", None)): + for line in self.known[name]["setup"]: + subprocess.run(["bash", "-c", line], cwd=widgets, check=True, capture_output=True) + out = subprocess.run([sys.executable, str(survey), "--repo", str(widgets), "--json"], capture_output=True, text=True, check=True) + found = {r["family"]: r["value"] for r in json.loads(out.stdout)} + self.assertEqual(found["ticket project"], project, name) + if issues: + self.assertTrue(found["issue key"].endswith(issues), found["issue key"]) + + def test_the_stub_answers_onboards_searches(self): + repos = self.read("search_repositories", query="repo:example/widgets") + self.assertEqual([(r["full_name"], r["private"], r["default_branch"]) for r in repos["items"]], [("example/widgets", False, "main")]) + self.assertEqual(self.read("search_code", query="path:standards/profiles org:example")["total_count"], 0) + listed = self.read("list_issues", orderBy="UPDATED_AT", direction="DESC", perPage=50) + self.assertEqual(sorted(label for i in listed["issues"] for label in i["labels"]), ["enhancement", "in progress", "ready"]) + @unittest.skipIf(run is None, "sandbox/run.py needs PyYAML") class Sandbox(unittest.TestCase): @@ -1686,6 +1719,26 @@ def test_a_reset_points_origin_back_at_the_url_the_sandbox_was_built_with(self): run.reset(self.sandbox) self.assertEqual(git(repo, "remote", "get-url", "origin"), built) + def test_a_reset_restores_the_remotes_branches_and_drops_the_branches_a_setup_made(self): + remote, repo = self.sandbox / "remotes" / "gizmos.git", self.sandbox / "repos" / "gizmos" + tree = git(remote, "rev-parse", "main^{tree}") + pinned = subprocess.run(["git", "-C", str(remote), "-c", "user.name=t", "-c", "user.email=t@t", "commit-tree", tree, + "-p", "main", "-m", "csv"], check=True, capture_output=True, text=True).stdout.strip() + git(remote, "update-ref", "refs/heads/PROJ-42-csv-loader", pinned) + git(repo, "fetch", "-q", "origin") + pins = self.sandbox / "pins" + pins.write_text("".join(f"{n} {s} {'live/abc1234' if n == 'gizmos' else b}\n" for n, (s, b) in run.pins(self.sandbox).items())) + for line in ("git update-ref -d refs/remotes/origin/PROJ-42-csv-loader", + "git branch -f 9-by-part main && git branch -f issue-12-empty-inventory main", + "git branch -f PROJ-42-csv-loader main", "git config branch.9-by-part.remote origin", + "git update-ref refs/remotes/origin/gone main"): + subprocess.run(["bash", "-c", line], cwd=repo, check=True, capture_output=True) + run.reset(self.sandbox) + self.assertEqual(git(repo, "rev-parse", "origin/PROJ-42-csv-loader"), pinned) + self.assertEqual(git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads").split(), ["live/abc1234", "main"]) + self.assertEqual(subprocess.run(["git", "-C", str(repo), "config", "--get", "branch.9-by-part.remote"]).returncode, 1) + self.assertNotIn("origin/gone", git(repo, "for-each-ref", "--format=%(refname:short)", "refs/remotes").split()) + def conflicting_branches(self) -> Path: """The pinned clone with `main` and `side` each changing f.txt's one line, `side` checked out.""" repo = self.sandbox / "repos" / "gizmos" diff --git a/tests/test_survey.py b/tests/test_survey.py index 482c460..bac3c37 100644 --- a/tests/test_survey.py +++ b/tests/test_survey.py @@ -266,6 +266,42 @@ def test_branches_the_pattern_matches_agree(self): self.assertEqual(rows(repo, "ticket:\n pattern: 'PROJ-\\d+'\n project: PROJ\n")["ticket key"]["match"], "same") +class IssueKeys(unittest.TestCase): + """The `issue key` row: GitHub issue keys where convention puts one, and the pattern they imply.""" + + def issue_row(self, **history) -> dict: + return rows(make_repo({}, "git@github.com:example-org/k.git", **history))["issue key"] + + def test_numbered_branches_imply_where_shs_default(self): + row = self.issue_row(branches=("12-empty-inventory", "feature/14-low-stock", "issue-9-by-part", "issue-15")) + self.assertEqual((row["found_in"], row["key"], row["match"]), ("history", None, "-")) + self.assertEqual(row["value"], r"branches: -words 2, issue- 2 of 5; subjects: 0 of 1; pattern (issue-)?\d+") + + def test_gh_branches_alone_imply_their_own_pattern_and_name_no_jira_project(self): + found = rows(make_repo({}, "git@github.com:example-org/k.git", branches=("GH-12-x", "gh-14-y"), subjects=("GH-12: a", "GH-14: b"))) + self.assertEqual(found["issue key"]["value"], r"branches: GH- 2 of 3; subjects: 2 of 2; pattern GH-\d+") + self.assertEqual((found["ticket project"]["value"], found["ticket key"]["found_in"]), ("-", "none")) + + def test_a_number_before_an_underscore_is_an_issue_key_as_in_where_sh(self): + self.assertEqual(self.issue_row(branches=("12_empty-inventory",))["found_in"], "history") + + def test_gh_beside_another_form_is_mixed(self): + self.assertTrue(self.issue_row(branches=("GH-12-x", "14-y")).get("value").endswith("pattern mixed")) + + def test_subjects_alone_imply_no_pattern(self): + row = self.issue_row(subjects=("#12: guard an empty inventory", "[#14] low stock", "Count widgets by part (#15)")) + self.assertEqual(row["value"], "branches: none of 1; subjects: 2 of 3; pattern none") + + def test_dates_and_versions_are_no_issue_keys(self): + row = self.issue_row(branches=("2026-10-29-notes", "onboard/widgets-2026-11-01", "release/1.2", "v3-fix", "release/1-2"), + subjects=("#2026-10-29 notes", "Bump to 1.2")) + self.assertEqual((row["found_in"], row["value"]), ("none", "-")) + + def test_jira_keys_are_no_issue_keys(self): + found = rows(make_repo({}, "git@github.com:example-org/k.git", branches=("PROJ-42-csv",), subjects=("PROJ-42: csv",))) + self.assertEqual((found["issue key"]["found_in"], found["ticket project"]["value"]), ("none", "PROJ")) + + class Guides(unittest.TestCase): def test_headings_are_read_by_word_and_never_from_code(self): guide = ("# Guide\n\n## Pre-commit hooks\n\n## Emerging practices\n\n```bash\n# create a branch for your work\n```\n\n"