diff --git a/.github/workflows/proposal-shared.yml b/.github/workflows/proposal-shared.yml index a4ab710..ce86017 100644 --- a/.github/workflows/proposal-shared.yml +++ b/.github/workflows/proposal-shared.yml @@ -71,8 +71,8 @@ permissions: contents: write pull-requests: write # actions/proposal is pinned to @main below, not a version tag, while v0.9.0 -# is still unreleased -- switch back to @v0.9 (matching route-issue-shared.yml's -# own pinned-tag pattern for its sibling action) once that tag exists. +# is still unreleased -- switch back to @v0.9 (matching this repo's other +# reusable workflows' pinned-tag pattern) once that tag exists. jobs: validate: runs-on: ubuntu-latest diff --git a/.github/workflows/route-issue-shared.yml b/.github/workflows/route-issue-shared.yml deleted file mode 100644 index 66e39fb..0000000 --- a/.github/workflows/route-issue-shared.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Route Issue -on: - workflow_call: - inputs: - routes: - description: 'JSON object mapping exact label names to destination "owner/repo" (e.g. {"Household": "some-org/some-repo"})' - required: true - type: string - label_name: - description: 'Label that triggered this run (e.g. github.event.label.name)' - required: true - type: string - issue_number: - description: 'Number of the issue to route (e.g. github.event.issue.number)' - required: true - type: number - create_labels_if_missing: - description: 'Create the triggering label at the destination if it does not already exist there' - required: false - type: boolean - default: false - secrets: - token: - description: 'Token with write access to the source and destination repositories' - required: true -jobs: - route: - runs-on: ubuntu-latest - steps: - - uses: rubykatzen/starcast/actions/route-issue@v0.2 - with: - routes: ${{ inputs.routes }} - label_name: ${{ inputs.label_name }} - issue_number: ${{ inputs.issue_number }} - create_labels_if_missing: ${{ inputs.create_labels_if_missing }} - github_token: ${{ secrets.token }} diff --git a/AGENTS.md b/AGENTS.md index ea683a6..1688e06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,6 @@ commit SHA. Do not recommend `@main` for stable consumers. ## Workflow behavior -- `route-issue-shared.yml` transfers an issue according to an explicit label - routing map. - `collect-issues-shared.yml` expands configured organizations to repositories, combines them with explicit repositories, and processes each unique repository independently. It paginates open issues directly and does not use diff --git a/CHANGELOG.md b/CHANGELOG.md index d369bd6..580c9c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,15 @@ `issue_number`/`comment_id` to `type: string`, whose unset default is genuinely empty. +### Removed + +- `route-issue-shared.yml` reusable workflow and its `actions/route-issue` + composite action: label-based issue transfer had no proposal or review + step, so it never fit the human-agent loop this repo builds for, and its + only consumer (`dupmachine/ground-control`) vendored the logic locally + instead of depending on it. Breaking for any other consumer still pinned + to it (#110). + ## [v0.8.0] - 2026-08-05 ### Added diff --git a/README.md b/README.md index 48fb1c1..faae25b 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ editorial pipeline implementation has been removed and is not supported. Its history remains available in Git. The current reusable workflows cover centralized Project intake for issues and -pull requests, plus explicit label-based issue routing. The proposal-as-issue +pull requests. The proposal-as-issue lifecycle described above (`Propose`/`Apply`/`Reject`/`Distill`/`Rework`) has a working contract, `proposal-shared.yml`: `Apply`/`Reject` are fully implemented, `Propose` dequeues and creates a proposal with mocked field @@ -53,46 +53,6 @@ placeholders (tracked in #11). ## Reusable workflows -### `route-issue-shared.yml` - -Transfers an issue to another repository when a configured label is -applied, idempotently. - -```yaml -jobs: - route: - uses: rubykatzen/starcast/.github/workflows/route-issue-shared.yml@v0.9 - with: - routes: >- - { - "Household": "some-org/some-repo", - "Meds": "another-org/another-repo" - } - label_name: ${{ github.event.label.name }} - issue_number: ${{ github.event.issue.number }} - create_labels_if_missing: false - secrets: - token: ${{ secrets.ROUTE_TOKEN }} -``` - -Caller triggers on `issues: labeled`. - -- **Exact match only**: `routes` maps exact label names to `owner/repo`. - A label with no configured route is a clean no-op, not an error — - StarCast never derives a destination from untrusted label text. -- **Idempotent, verified against real transfers**: after a transfer, the - issue's old id and its `owner/repo#number` address both stop resolving - on the source side. A retry that can't find the issue there anymore is - treated as an already-completed transfer, not an error. -- **Source equal to destination** is a clean no-op. -- **Label carry-over** is off by default (`create_labels_if_missing: - false`) — GitHub's own transfer behavior otherwise silently drops a - label with no same-named counterpart at the destination, which is - usually what you want for a routing label. Set it to `true` to have - GitHub create the label at the destination instead. -- `token` needs write access to both the source and destination - repositories; StarCast stores no consumer secrets. - ### `collect-issues-shared.yml` Collects open issues from a configured set of organizations and/or individual diff --git a/actions/route-issue/action.yml b/actions/route-issue/action.yml deleted file mode 100644 index f9661ba..0000000 --- a/actions/route-issue/action.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: 'Route Issue' -description: 'Transfer an issue to another repository based on a configured label, idempotently' -inputs: - routes: - description: 'JSON object mapping exact label names to destination "owner/repo"' - required: true - label_name: - description: 'Label that triggered this run (e.g. github.event.label.name)' - required: true - issue_number: - description: 'Number of the issue to route (e.g. github.event.issue.number)' - required: true - create_labels_if_missing: - description: 'Create the triggering label at the destination if it does not already exist there' - required: false - default: 'false' - github_token: - description: 'Token with write access to the source and destination repositories' - required: true -runs: - using: composite - steps: - - shell: bash - env: - GH_TOKEN: ${{ inputs.github_token }} - REPOSITORY: ${{ github.repository }} - ISSUE_NUMBER: ${{ inputs.issue_number }} - ROUTES: ${{ inputs.routes }} - LABEL_NAME: ${{ inputs.label_name }} - CREATE_LABELS_IF_MISSING: ${{ inputs.create_labels_if_missing }} - run: python3 "$GITHUB_ACTION_PATH/route_issue.py" diff --git a/actions/route-issue/route_issue.py b/actions/route-issue/route_issue.py deleted file mode 100644 index 1007aec..0000000 --- a/actions/route-issue/route_issue.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -"""Transfer an issue to another repository based on a configured label. - -Idempotency was verified directly against disposable scratch repos, not -assumed: after a real transfer, the issue's old node id stops resolving -(NOT_FOUND), and querying the source repo by number also returns null — -the issue is simply gone from the source repo. So a retry that can't find -the issue there anymore is a completed transfer, not an error: this -script resolves the issue by owner/repo/number right before mutating and -treats "not found" as a successful no-op. - -createLabelsIfMissing was also verified directly: without it, a label -with no same-named counterpart at the destination is silently dropped -(not an error). With it, the label is created and attached — but, like -other GitHub Projects/Issues mutations, the response from the transfer -call itself doesn't reliably reflect that immediately; this script -reports what the mutation returned rather than re-querying to confirm. -""" - -import json -import os -import subprocess -import sys - - -def gh_graphql(query: str, **variables: str | int | bool | None) -> dict: - """Run a GraphQL query/mutation via `gh api graphql`. - - Values are typed by their Python type: `int`/`bool`/`None` go through - `-F` (gh's typed flag, which converts numbers, true/false, and the - literal "null" to real JSON types); everything else goes through `-f` - (always a raw JSON string), so a string value is never accidentally - re-interpreted as a number/bool/null even if it looks like one. - """ - args = ["gh", "api", "graphql", "-f", f"query={query}"] - for key, value in variables.items(): - if value is None: - args += ["-F", f"{key}=null"] - elif isinstance(value, (int, bool)): - args += ["-F", f"{key}={str(value).lower() if isinstance(value, bool) else value}"] - else: - args += ["-f", f"{key}={value}"] - result = subprocess.run(args, capture_output=True, text=True, check=False) - if result.returncode != 0: - print(result.stdout, file=sys.stderr) - print(result.stderr, file=sys.stderr) - sys.exit(1) - return json.loads(result.stdout)["data"] - - -def resolve_repo_id(owner: str, name: str) -> str | None: - data = gh_graphql( - """ - query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { id } - } - """, - owner=owner, - name=name, - ) - repo = data["repository"] - return repo["id"] if repo else None - - -def resolve_issue_id(owner: str, name: str, number: int) -> str | None: - """Return the issue's node id, or None if the repository has no such issue. - - Runs the query directly rather than through gh_graphql: `gh api - graphql` exits non-zero whenever the response carries a GraphQL - `errors` array, and a nonexistent issue number produces exactly that - (a NOT_FOUND error alongside `"issue": null`) — verified this is what - a completed transfer looks like on retry, so it must not be treated - as fatal here the way every other query/mutation in this script is. - The repository itself failing to resolve is still treated as fatal: - that's a real misconfiguration, not an idempotent no-op. - """ - args = [ - "gh", - "api", - "graphql", - "-f", - """query= - query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - issue(number: $number) { id } - } - } - """, - "-f", - f"owner={owner}", - "-f", - f"name={name}", - "-F", - f"number={number}", - ] - result = subprocess.run(args, capture_output=True, text=True, check=False) - try: - payload = json.loads(result.stdout) - except json.JSONDecodeError: - print(result.stdout, file=sys.stderr) - print(result.stderr, file=sys.stderr) - sys.exit(1) - repository = (payload.get("data") or {}).get("repository") - if repository is None: - print(result.stdout, file=sys.stderr) - print(result.stderr, file=sys.stderr) - sys.exit(1) - issue = repository.get("issue") - return issue["id"] if issue else None - - -def transfer_issue(issue_id: str, repository_id: str, create_labels_if_missing: bool) -> dict: - data = gh_graphql( - """ - mutation($issueId: ID!, $repositoryId: ID!, $createLabels: Boolean!) { - transferIssue(input: { - issueId: $issueId - repositoryId: $repositoryId - createLabelsIfMissing: $createLabels - }) { issue { number url } } - } - """, - issueId=issue_id, - repositoryId=repository_id, - createLabels=create_labels_if_missing, - ) - return data["transferIssue"]["issue"] - - -def summarize(**fields: str) -> None: - body = "### route-issue\n\n" + "".join(f"- {k}: {v}\n" for k, v in fields.items()) - print(body) - step_summary = os.environ.get("GITHUB_STEP_SUMMARY") - if step_summary: - with open(step_summary, "a", encoding="utf-8") as f: - f.write(body) - - -def main() -> None: - routes = json.loads(os.environ["ROUTES"]) - label_name = os.environ["LABEL_NAME"] - create_labels_if_missing = os.environ.get("CREATE_LABELS_IF_MISSING", "false") == "true" - issue_number = int(os.environ["ISSUE_NUMBER"]) - repo_owner, repo_name = os.environ["REPOSITORY"].split("/") - - destination = routes.get(label_name) - if destination is None: - summarize(result="no-op", reason=f"no route configured for label '{label_name}'") - return - - dest_owner, dest_name = destination.split("/") - if (dest_owner.lower(), dest_name.lower()) == (repo_owner.lower(), repo_name.lower()): - summarize(result="no-op", reason=f"destination '{destination}' is the source repository") - return - - destination_id = resolve_repo_id(dest_owner, dest_name) - if destination_id is None: - print(f"ERROR: destination repository '{destination}' not found or inaccessible", file=sys.stderr) - sys.exit(1) - - issue_id = resolve_issue_id(repo_owner, repo_name, issue_number) - if issue_id is None: - # No issue at this owner/repo/number anymore — verified this is - # exactly what a completed transfer looks like on retry, not a - # real error. - summarize( - result="no-op", - reason=f"issue #{issue_number} not found in {repo_owner}/{repo_name} — already transferred", - ) - return - - result = transfer_issue(issue_id, destination_id, create_labels_if_missing) - summarize( - result="transferred", - source=f"{repo_owner}/{repo_name}#{issue_number}", - destination=destination, - new_issue=f"#{result['number']} ({result['url']})", - ) - - -if __name__ == "__main__": - main()