Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/close-linked-issues.yml
Original file line number Diff line number Diff line change
@@ -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: |
# <Service>/<type>/Issue-<sub-issue>/<description>, 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
5 changes: 5 additions & 0 deletions openspec/changes/platform-chore-backlog-dedup/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
schema: spec-driven
created: 2026-09-30
author: Norair Arutshyan
priority: medium
skip_specs: true
156 changes: 156 additions & 0 deletions openspec/changes/platform-chore-backlog-dedup/design.md
Original file line number Diff line number Diff line change
@@ -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:<key>` 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 <name> --force`, which creates or updates the label and so is
idempotent.

*Alternative:* `area:<key>`. 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:<change.service>`.
- `issue` passes `svc:<--service>`.
- `defer` passes `svc:<service>` 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 "<kw…> 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 <name>`.

*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 #<pr>[ — <comment URL>]

**Location:** `<path:line>` (omitted if not given)

**Finding:** <body arg or "TODO: describe">

**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.
65 changes: 65 additions & 0 deletions openspec/changes/platform-chore-backlog-dedup/proposal.md
Original file line number Diff line number Diff line change
@@ -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:<service>` 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 "<text>"` 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 "<title>" --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
35 changes: 35 additions & 0 deletions openspec/changes/platform-chore-backlog-dedup/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
Loading