From 7461d4da1ee9b1eb0566bb407a49c0205a3fd93d Mon Sep 17 00:00:00 2001 From: Norair Arutshyan Date: Wed, 30 Sep 2026 14:29:23 +0100 Subject: [PATCH] feat(workflow): add GitHub Actions to close linked issues on PR merge --- .github/workflows/close-linked-issues.yml | 48 ++++++ .../.openspec.yaml | 5 + .../platform-chore-backlog-dedup/design.md | 156 ++++++++++++++++++ .../platform-chore-backlog-dedup/proposal.md | 65 ++++++++ .../platform-chore-backlog-dedup/tasks.md | 35 ++++ 5 files changed, 309 insertions(+) create mode 100644 .github/workflows/close-linked-issues.yml create mode 100644 openspec/changes/platform-chore-backlog-dedup/.openspec.yaml create mode 100644 openspec/changes/platform-chore-backlog-dedup/design.md create mode 100644 openspec/changes/platform-chore-backlog-dedup/proposal.md create mode 100644 openspec/changes/platform-chore-backlog-dedup/tasks.md diff --git a/.github/workflows/close-linked-issues.yml b/.github/workflows/close-linked-issues.yml new file mode 100644 index 0000000..7497f04 --- /dev/null +++ b/.github/workflows/close-linked-issues.yml @@ -0,0 +1,48 @@ +name: Close Linked Issues + +on: + pull_request: + types: [closed] + +jobs: + close-issues: + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + permissions: + issues: write + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.event.pull_request.head.ref }} + steps: + - name: Extract sub-issue number from branch name + id: branch + run: | + # //Issue-/, see docs/development/WORKFLOW.md §2 + if [[ ! "$BRANCH" =~ ^[A-Z][A-Za-z]*/(feat|fix|chore|refactor)/Issue-([0-9]+)/.+$ ]]; then + echo "'$BRANCH' isn't a flow.py branch; nothing to close" + echo "issue=" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "issue=${BASH_REMATCH[2]}" >> "$GITHUB_OUTPUT" + + - name: Close the sub-issue, and the parent if it was the last one open + if: steps.branch.outputs.issue != '' + run: | + issue="${{ steps.branch.outputs.issue }}" + gh issue close "$issue" --repo "$REPO" --reason completed + + # A GitHub sub-issue relationship, not this repo's tasks.md — survives archiving. + parent=$(gh api "repos/$REPO/issues/$issue/parent" --jq '.number' 2>/dev/null || echo "") + if [[ -z "$parent" ]]; then + echo "issue #$issue has no parent" + exit 0 + fi + + summary=$(gh api "repos/$REPO/issues/$parent" --jq '.sub_issues_summary') + total=$(jq -r '.total' <<<"$summary") + completed=$(jq -r '.completed' <<<"$summary") + echo "parent #$parent: $completed/$total sub-issues closed" + if [[ "$completed" == "$total" ]]; then + gh issue close "$parent" --repo "$REPO" --reason completed + fi diff --git a/openspec/changes/platform-chore-backlog-dedup/.openspec.yaml b/openspec/changes/platform-chore-backlog-dedup/.openspec.yaml new file mode 100644 index 0000000..51ac9d7 --- /dev/null +++ b/openspec/changes/platform-chore-backlog-dedup/.openspec.yaml @@ -0,0 +1,5 @@ +schema: spec-driven +created: 2026-09-30 +author: Norair Arutshyan +priority: medium +skip_specs: true diff --git a/openspec/changes/platform-chore-backlog-dedup/design.md b/openspec/changes/platform-chore-backlog-dedup/design.md new file mode 100644 index 0000000..3c39e05 --- /dev/null +++ b/openspec/changes/platform-chore-backlog-dedup/design.md @@ -0,0 +1,156 @@ +# Design + +## Context + +`scripts/flow.py` already owns every GitHub write in the workflow. All shell +calls go through `run()`, which skips mutating calls on `--dry-run`, and +`gh_json()`. Issues are created in one place, `create_issue(title, body)`, which +`init`, `sync` (via `reconcile`) and `issue` share. Groups are parsed by +`parse_groups()`, and `cmd_pr()` builds the `Closes` trailer. Tests in +`scripts/test_flow.py` monkeypatch `run`/`gh_json` and use `tmp_path` change +fixtures. The repo has only GitHub's default labels. + +See proposal.md for motivation. + +## Goals / Non-Goals + +**Goals:** +- A duplicate check at the point where each new ticket is created: proposal, + `init`, `issue`, `defer`. +- A single path for deferred review findings, from the PR to the board. +- Every new change keeps working with the existing `init`/`sync`/`start`/`pr` + flow unchanged. + +**Non-Goals:** +- Exact or semantic duplicate detection. The search is keyword-based and + advisory; a human or agent decides. +- Searching closed issues or archived changes. The check is about open work. +- Severity labels. Priority stays on the board and in `.openspec.yaml`. +- Automating the one-off label backfill or the migration of memory backlogs. + +## Decisions + +### D1. Label names: `svc:` from `SERVICES` + +The labels are derived from the `SERVICES` map, so one list drives change names, +branch names and labels. The `svc:` prefix keeps them apart from GitHub's +default `bug`/`enhancement` labels. `flow.py labels` runs +`gh label create --force`, which creates or updates the label and so is +idempotent. + +*Alternative:* `area:`. Equivalent; `svc` matches the "service" vocabulary +the workflow docs already use. + +### D2. `create_issue(title, body, labels)` is the only place labels are applied + +It passes one `--label` per entry. +- `init` and `reconcile` pass `svc:`. +- `issue` passes `svc:<--service>`. +- `defer` passes `svc:` plus `review-followup`. + +Putting it in the shared function means no path can create an unlabeled +ticket. + +`--service` on `issue` is required and validated against `SERVICES`. An +optional flag would let unlabeled issues back in, and search by service would +quietly miss them. + +### D3. `find_related(text)`: keyword search over two sources + +- **Keywords:** lowercase the words of `text`, drop a small stopword set and + words shorter than 4 characters, de-duplicate, and keep at most 6. +- **Issues:** `gh issue list --state open --search " in:title,body" + --json number,title,labels --limit 15`. GitHub's search treats the terms as + AND, so this runs as one query per keyword pair (at most 3 queries), merged by + issue number. That keeps recall reasonable without spamming the API. +- **Changes:** scan `openspec/changes/*/proposal.md`, excluding `archive/`. + Score = keyword hits in the change name plus hits in the proposal text. Keep + changes with score ≥ 2. +- **Output:** issues and changes ranked by hits, capped at 10, each printed as + `#n title [labels]` or `change `. + +*Alternative:* embeddings or LLM similarity. Rejected, because it adds a +dependency and a network cost to a CLI tool, and keyword recall is enough for a +single-maintainer backlog. + +### D4. Gate behavior + +`issue` and `defer` call `confirm_no_duplicate(text, force)`: +- no matches → continue +- matches and `--force` → print them and continue +- matches and a terminal → print them and ask y/N (default No) +- matches and no terminal → print them and `die()` telling the caller to + re-run with `--force`. Agents run without a terminal, so an agent has to act + on the list rather than skip past it. +- `--dry-run` → print the matches and stop before any mutating call + +`init` only warns. The proposal's Related section has already done the real +check, and `init` must stay non-interactive. + +`related` is read-only and always exits 0. + +### D5. `Absorbs:` line in tasks.md + +The format is `Absorbs: #12, #34` on the line directly under a +`## N.` heading. `parse_groups` reads it into `Group.absorbs: list[int]` and +ignores it anywhere else. `cmd_pr` adds `Closes #n` for each absorbed issue of +the current group, before the parent's `Closes`. `sync` leaves the line alone. + +The data lives in tasks.md, not on GitHub, because tasks.md is already what +`pr` reads, and it keeps the link reviewable in the change itself. + +### D6. `defer` creates related issues, not sub-issues + +A sub-issue would keep the parent open until the deferred finding is fixed, +which defeats the purpose of deferring it. Linking is done by mentioning the PR +in the issue body ("From review of #N"), which GitHub turns into a +cross-reference on both sides. + +The service comes from the current branch's first segment (matched +case-insensitively against `SERVICES`). If the branch doesn't conform, +`--service` is required. + +The issue goes on the board as **Backlog**, so it doesn't compete with Todo work. + +The body template: + +``` +From review of #[ — ] + +**Location:** `` (omitted if not given) + +**Finding:** + +**Why deferred:** TODO +``` + +### D7. Proposal and tasks rules live in `openspec/config.yaml` + +The rules are injected into every artifact generation, which makes them the +one enforcement point that covers AI-authored proposals. The rules refer to +`flow.py related` rather than raw `gh` commands, so the search logic lives in +one place. + +## Risks / Trade-offs + +- [Keyword search misses a rephrased duplicate] → It's advisory; the monthly + grooming pass still catches the rest. Tune the stopwords and thresholds only + if misses show up in practice. +- [Noisy matches train users to always pass `--force`] → The change-score + threshold (≥ 2) and the 10-result cap keep the list short. Revisit if + `--force` becomes a reflex. +- [Extra `gh` calls slow `issue`/`defer`] → At most 3 search calls, only + on commands that create tickets. +- [Existing open issues have no `svc:` label, so `related` misses nothing but + label filtering does] → A one-off backfill as part of group 1. + +## Migration Plan + +1. Group 1 merges → run `flow.py labels` once, then backfill labels on open + issues. +2. Group 3 merges → move the review backlogs held only in agent memory into + issues with `flow.py defer --force`, then trim those memory entries to issue + pointers. + +Rollback: revert the commits. The labels and issues created along the way are +harmless to leave. diff --git a/openspec/changes/platform-chore-backlog-dedup/proposal.md b/openspec/changes/platform-chore-backlog-dedup/proposal.md new file mode 100644 index 0000000..0763836 --- /dev/null +++ b/openspec/changes/platform-chore-backlog-dedup/proposal.md @@ -0,0 +1,65 @@ +# Proposal + +## Why + +Work gets tracked, but nothing checks for overlap before new work is filed. +New proposals and issues are written without a required look at open changes +and issues, so the same problem can be raised twice. Deferred code-review +findings currently live outside GitHub (in agent memory), where only one tool +can see them and nothing links them to the PR that produced them. + +## What Changes + +- **Triage rule** documented in `docs/development/WORKFLOW.md`: an OpenSpec + change only when work alters required behavior, spans services, or needs a + design decision; otherwise a plain issue. Every piece of work has an issue; + changes are the heavier path, not the only path. +- **Service labels**: `svc:` for each key in `flow.py`'s `SERVICES`, + plus `review-followup`, created by a new idempotent `flow.py labels` + subcommand and applied automatically by `init`, `sync`, `issue` and `defer`. + `issue` gains a required `--service`. +- **Related-work search**: new read-only `flow.py related ""` lists open + issues and in-flight changes that match the text. + - `issue` and `defer` run it first. If there are matches, they ask y/N; when + no terminal is attached, they refuse unless `--force` is passed. + - `init` runs it as a warning only. +- **Proposal rule** in `openspec/config.yaml`: before drafting, run + `flow.py related`, and end every proposal with a `## Related` section that + classifies each overlap (absorbs / depends on / supersedes / independent). +- **Absorbed issues**: tasks.md groups may carry an `Absorbs: #n, #m` line. + `flow.py pr` emits `Closes #n` for each one. A tasks rule links this to the + proposal's Related section. +- **Deferred review findings**: new + `flow.py defer "" --pr N [--file path:line] [--comment URL]` creates a + `review-followup` issue in Backlog. It is deliberately *not* a sub-issue, so it + never blocks the parent's `Closes`. It prints a `Deferred: #n` line for the PR + body. Fixed findings stay as PR review comments. + +## Related + +- `openspec list`: no in-flight change touches `scripts/flow.py` or the + workflow docs. **Independent.** +- Issue #320 (flow workflow rework): built the current `init`/`sync`/`start`/`pr` + flow. This change **depends on** it (it extends that tool) and does not reopen + its scope. + +## Capabilities + +### New Capabilities + +None. This is developer tooling and process; no product behavior changes, so +the change sets `skip_specs: true`. + +### Modified Capabilities + +None. + +## Impact + +- `scripts/flow.py`, `scripts/test_flow.py` +- `openspec/config.yaml` (proposal and tasks rules) +- `docs/development/WORKFLOW.md`, `CLAUDE.md` (flow section pointer) +- GitHub repo labels (new `svc:*` and `review-followup`); a one-off backfill + labels the existing open issues +- After merge, review backlogs held only in agent memory move to + `review-followup` issues diff --git a/openspec/changes/platform-chore-backlog-dedup/tasks.md b/openspec/changes/platform-chore-backlog-dedup/tasks.md new file mode 100644 index 0000000..e55b2bb --- /dev/null +++ b/openspec/changes/platform-chore-backlog-dedup/tasks.md @@ -0,0 +1,35 @@ +# Tasks + +Workflow rule: one task group = one GitHub sub-issue (of this change's parent issue) = one PR, merged before the next group starts. + +## 1. Service labels on every created issue, and the proposal rule + +- [ ] 1.0 Run `scripts/flow.py start platform-chore-backlog-dedup 1` to move this group to In Progress and create its branch before starting any other work in this group. +- [ ] 1.1 Add a `labels` subcommand that runs `gh label create <name> --force` for `svc:<key>` (each `SERVICES` key) and `review-followup` (D1). Verify with a test asserting the exact `gh` argument lists, and with `--dry-run labels` printing one command per label. +- [ ] 1.2 Change `create_issue(title, body)` to `create_issue(title, body, labels)` and pass one `--label` per entry. Pass `svc:<change.service>` from `init` and `reconcile` (D2). Verify with tests that `init` and `reconcile` pass the label arguments to `gh issue create`. +- [ ] 1.3 Add a required `--service` to `issue`, validated against `SERVICES`, and pass `svc:<service>`. Verify with tests: an unknown service exits non-zero, and a valid one reaches `gh issue create` with the label. +- [ ] 1.4 Add the proposal rule to `rules.proposal` in `openspec/config.yaml`: run `openspec list` and `gh issue list --state open --search` before drafting, and end with a `## Related` section classifying each overlap as absorbs / depends on / supersedes / independent. If an existing change covers the same scope, update it instead. (Group 2 switches the command to `flow.py related`.) Verify that `openspec instructions proposal --change platform-chore-backlog-dedup --json` shows the rule under `rules`. +- [ ] 1.5 Update `docs/development/WORKFLOW.md`: add the triage section (issue vs change), `labels` and `issue --service` in the command table, and the `svc:` labels. Update the `flow.py issue` line in `CLAUDE.md` to include `--service`. Verify every documented command runs under `--dry-run`. +- [ ] 1.6 Run `scripts/flow.py labels` once, then add `svc:` labels to the existing open issues by hand. Verify that `gh issue list --state open --json labels` shows no issue without an `svc:` label. +- [ ] 1.7 Run `python3 -m pytest scripts/test_flow.py -q`, `black --check scripts/` and `flake8 --max-line-length=100 scripts/` clean; PR merged. + +## 2. Related-work search and the duplicate gate — ticket depends on 1 + +- [ ] 2.0 Run `scripts/flow.py start platform-chore-backlog-dedup 2` to move this group to In Progress and create its branch before starting any other work in this group. +- [ ] 2.1 Implement keyword extraction (D3: lowercase, stopwords removed, words of 4+ characters, de-duplicated, at most 6). Verify with a parametrized test covering stopword removal, the length cutoff, de-duplication and the cap. +- [ ] 2.2 Implement `find_related(text)` (D3): issue search as one query per keyword pair, merged by number; a `proposal.md` scan of `openspec/changes` excluding `archive/`, with score ≥ 2; ranked and capped at 10. Verify with tests using a `tmp_path` changes fixture and a monkeypatched `gh_json`, covering the archived-change exclusion, the score threshold, merging by issue number and the cap. +- [ ] 2.3 Add a read-only `related "<text>"` subcommand that prints the matches and always exits 0. Verify with a test that it makes no mutating `run` call. +- [ ] 2.4 Add `confirm_no_duplicate(text, force)` and a `--force` flag to `issue`, gating it as in D4 (terminal → y/N defaulting to No; no terminal → die; `--force` → continue; `--dry-run` → print and stop). Verify with one test per D4 branch, monkeypatching `sys.stdin.isatty` and `input`. +- [ ] 2.5 Make `init` warn with the `find_related` matches for the change description and the proposal's first heading, without prompting. Verify with a test that `init` continues when there are matches and prints them to stderr. +- [ ] 2.6 Point the `openspec/config.yaml` proposal rule at `scripts/flow.py related "<keywords>"`. Document `related`, the gate and `--force` in `docs/development/WORKFLOW.md`. Verify that `openspec instructions proposal ... --json` shows the updated rule. +- [ ] 2.7 Run `python3 -m pytest scripts/test_flow.py -q`, `black --check scripts/` and `flake8 --max-line-length=100 scripts/` clean, plus a manual `scripts/flow.py related "provider ssrf"` that lists the in-flight `ai-fix-361-provider-ssrf` change; PR merged. + +## 3. Deferred review findings and absorbed issues — ticket depends on 1, 2 + +- [ ] 3.0 Run `scripts/flow.py start platform-chore-backlog-dedup 3` to move this group to In Progress and create its branch before starting any other work in this group. +- [ ] 3.1 Parse an `Absorbs: #n, #m` line directly under a `## N.` heading into `Group.absorbs` (D5) and ignore it anywhere else. Verify with `parse_groups` tests: present, absent, placed elsewhere (ignored), and a Tracking block written by `write_tasks` leaving the line intact. +- [ ] 3.2 Make `cmd_pr` print `Closes #n` for each absorbed issue of the current group, before the parent's trailer. Verify by extending the existing `test_pr_closes_parent_only_when_all_groups_are_linked_and_finished` fixtures with an absorbed issue. +- [ ] 3.3 Add `defer "<title>" --pr N [--file path:line] [--comment URL] [--service S] [--force] [body]` (D6): service taken from the branch or `--service`; the D4 gate; the D6 body template; labels `svc:<service>` and `review-followup`; board status **Backlog**; no sub-issue link; prints `Deferred: #n`. Verify with tests for body rendering (with and without `--file`/`--comment`), deriving the service from the branch, failing on a non-conforming branch without `--service`, the label list, and that no sub-issue call is made. +- [ ] 3.4 Add the tasks rule to `openspec/config.yaml`: when the proposal's Related section says a change absorbs #n, put `Absorbs: #n` under the group that fixes it. Document `defer`, `Absorbs:` and the review-findings policy (fixed findings stay as PR comments, deferred ones go through `defer`) in `docs/development/WORKFLOW.md`, and add a one-line pointer in `CLAUDE.md`. Verify with `openspec instructions tasks ... --json` and a read-through of the docs. +- [ ] 3.5 Run `python3 -m pytest scripts/test_flow.py -q`, `black --check scripts/` and `flake8 --max-line-length=100 scripts/` clean, plus a manual `--dry-run defer` from a group branch showing the expected `gh` calls; PR merged. +- [ ] 3.6 After merge: move the review backlogs held only in agent memory (chat-migration, donor-dashboard, donor-grantee) into issues with `flow.py defer --force`. Verify each appears on the board as Backlog with `review-followup`, then trim those memory entries to issue pointers.