diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f1b1f1f..e3140f9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -15,7 +15,7 @@ "plugins": [ { "name": "issue-driven-dev", - "version": "3.0.0", + "version": "3.0.1", "description": "v3.0.0 (BREAKING): the closing-summary helper may VETO and may never PERMIT. After twelve verify rounds failing in one direction — a real summary the recogniser could not follow classified `missing`, and `missing` being the sole authorisation for `/idd-close --retroactive` to post a duplicate — the power was split along the direction that is sound. \"A marker IS here\" is an observation; \"a marker is NOT here\" is an inference from a failure to recognise, and no matcher over source bytes can answer a question about rendered output in the negative. Gate exit codes are now 1 (recognised) / 2 (undeterminable) / 10 (nothing recognised — NOT permission); there is no exit 0 in gate mode, deliberately, so a caller still reading `rc == 0 means go` breaks loudly. Gate class `missing` → `unrecognised`, every reply carries authorises:false, and a fifth class `mentioned` names the state the tool can actually observe. `--retroactive` loses its unattended path: the skill must read the comment set itself and obtain human confirmation that cannot be disabled. Classification now asks who wrote the comment, so a commenter can no longer move an issue between classes. Also: three more exit-0 parser paths, markup counted as content three layers deep, a quotation reaching `compliant`, the mention gate passing on zero iterations by three routes, untrusted prose reaching a shell command line, and #317 criterion (c) answered correctly for the first time in five attempts. Ten guards were mutation-proven vacuous and rebuilt.", "author": { "name": "Che Cheng" diff --git a/.claude/.idd/attachments/issue-353/_manifest.json b/.claude/.idd/attachments/issue-353/_manifest.json new file mode 100644 index 0000000..afb96ae --- /dev/null +++ b/.claude/.idd/attachments/issue-353/_manifest.json @@ -0,0 +1,6 @@ +{ + "issue": 353, + "fetched_at": "2026-10-02T07:00:33Z", + "fetched_by": "idd-diagnose", + "files": [] +} diff --git a/.claude/.idd/routing-stats.jsonl b/.claude/.idd/routing-stats.jsonl index 102309b..bb89db3 100644 --- a/.claude/.idd/routing-stats.jsonl +++ b/.claude/.idd/routing-stats.jsonl @@ -1,3 +1,4 @@ {"agent":"claude-fable-5","complexity":"Spectra","followups_spawned":2,"issue_number":209,"issue_repo":"PsychQuant\/issue-driven-development","outcome":"in_review","recorded_by":"idd-verify-2.89.0","round_trips":2,"scope_files":15,"scope_loc":359,"scope_signals":["design_negotiation","public_api","breaking_change","requires_changelog"],"ts":"2026-07-02T14:38:05Z","verify_blocking":1,"verify_low":11,"verify_medium":9} {"agent":"claude-fable-5","complexity":"Spectra","followups_spawned":2,"issue_number":209,"issue_repo":"PsychQuant\/issue-driven-development","outcome":"merged","outcome_ts":"2026-07-02T20:11:52Z","recorded_by":"update-outcome-cli","round_trips":2,"scope_files":15,"scope_loc":359,"scope_signals":["design_negotiation","public_api","breaking_change","requires_changelog"],"ts":"2026-07-02T20:11:52Z","verify_blocking":1,"verify_low":11,"verify_medium":9} {"agent":"claude-fable-5","complexity":"Spectra","followups_spawned":2,"issue_number":214,"issue_repo":"PsychQuant\/issue-driven-development","outcome":"merged","outcome_ts":"2026-07-03T02:38:11Z","recorded_by":"idd-close-2.89.0","round_trips":2,"scope_files":19,"scope_loc":1100,"scope_signals":["design_negotiation","public_api","requires_changelog"],"ts":"2026-07-03T02:38:11Z","verify_blocking":1,"verify_low":16,"verify_medium":12} +{"agent":"claude-opus-5.5","complexity":"Simple","followups_spawned":4,"issue_number":353,"issue_repo":"PsychQuant\/issue-driven-development","outcome":"in_review","recorded_by":"idd-verify-3.0.0","round_trips":2,"scope_files":9,"scope_loc":484,"scope_signals":["explicit_acceptance","single_handler","multi_file","new_test_infrastructure"],"ts":"2026-10-02T08:10:43Z","verify_blocking":0,"verify_low":16,"verify_medium":2} diff --git a/.github/workflows/live-schema.yml b/.github/workflows/live-schema.yml new file mode 100644 index 0000000..04cf112 --- /dev/null +++ b/.github/workflows/live-schema.yml @@ -0,0 +1,30 @@ +# live-schema.yml — check GitHub's real GraphQL schema for the names idd-issue uses (#353). +# +# `idd-issue --blocked-by` calls the `addBlockedBy` mutation. If GitHub renames it, nothing +# in this repo changes, so the PR suite (tests.yml, offline by design) cannot notice. The +# wrong name used from v2.52.0 to 3.0.0 could only fail against today's schema, and the +# failure was hidden. This job runs the blocked-by-mutation suite with IDD_LIVE_GH=1, which +# introspects the live schema, on a schedule and on demand. +# +# GitHub disables scheduled workflows in a public repository after 60 days without +# repository activity. If that happens, re-enable this workflow from the Actions tab. +name: live-schema +on: + schedule: + - cron: '0 1 * * 1' # Mondays 01:00 UTC = Mondays 09:00 Taipei (UTC+8) + workflow_dispatch: +permissions: + contents: read +jobs: + blocked-by-mutation: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false # the suite only reads; gh gets GH_TOKEN below + - name: Introspect GitHub's schema for addBlockedBy + env: + IDD_LIVE_GH: '1' + GH_TOKEN: ${{ github.token }} + run: bash plugins/issue-driven-dev/scripts/tests/blocked-by-mutation/test.sh diff --git a/docs/commands.md b/docs/commands.md index b087711..e3cf447 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -150,7 +150,7 @@ | _positional_ description / path | Raw text, file path (docx/pdf/md/txt), or chat reference; skill auto-detects type and routes to the matching reader (`che-word-mcp`, `che-pdf-mcp`, Telegram MCP, Apple Mail/Notes MCP). Missing MCP plugin → **fail-fast** with install instructions (per #27 / #32). | | `--target ` | Per-invocation target override; does **not** write to config | | `--parent N` | Link the new issue under issue #N (idempotent PATCH of #N's body task-list, see [`bundle-flags.md`](../plugins/issue-driven-dev/references/bundle-flags.md)) | -| `--blocked-by M[,M2,...]` | Three-layer chain: body blockquote (unconditional) + `addBlockedByDependency` GraphQL (best-effort) + parent annotation (if `--parent` co-used) | +| `--blocked-by M[,M2,...]` | Three-layer chain: body blockquote (unconditional) + `addBlockedBy` GraphQL (best-effort; GitHub's error is printed verbatim, an existing dependency counts as success) + parent annotation (if `--parent` co-used) | | `--bundle-mode ordered \| unordered` | Create an epic + N children; `ordered` also wires a Blocked-by chain. Mutually exclusive with group mode. | | `--mention login[,login2,...]` | Force the 5-step collaborator-tagging protocol ([`rules/tagging-collaborators.md`](../plugins/issue-driven-dev/rules/tagging-collaborators.md)); cannot fail open. | diff --git a/openspec/specs/idd-issue-bundle/spec.md b/openspec/specs/idd-issue-bundle/spec.md index b8a8467..7f915f4 100644 --- a/openspec/specs/idd-issue-bundle/spec.md +++ b/openspec/specs/idd-issue-bundle/spec.md @@ -54,21 +54,27 @@ The PATCH operation SHALL preserve existing parent body content: it SHALL NOT re The `idd-issue` skill SHALL accept a `--blocked-by [,...]` flag where each value is a positive integer issue number. After creating the child issue, the skill SHALL apply the dependency annotation through three layers: 1. The skill SHALL prepend a blockquote `> Blocked by #M` (one line per `M`) to the child issue body, regardless of subsequent layer outcomes. -2. The skill SHALL attempt the GitHub GraphQL `addBlockedByDependency` mutation for each `M`. Failure SHALL NOT abort the operation;the skill SHALL emit a warning naming the failed `M` and continue. +2. The skill SHALL attempt the GitHub GraphQL `addBlockedBy` mutation (input fields `issueId` and `blockingIssueId`) for each `M`. Failure SHALL NOT abort the operation;the skill SHALL emit a warning naming the failed `M` that includes the error text GitHub returned, SHALL NOT attribute the failure to a cause GitHub did not report, and continue. A failure whose GitHub error text contains `Target issue has already been taken` means the dependency already exists and SHALL be treated as success; any other error, including other "has already been taken" validation failures, SHALL produce the warning. The warning and any other message from this layer SHALL be written to stderr, because bundle mode captures the child number from stdout. 3. When `--parent ` is also provided, the skill SHALL annotate the corresponding parent task list entry as `- [ ] #child (blocked by #M)` to surface dependency at parent view level. #### Scenario: Native dependency mutation succeeds -- **WHEN** `idd-issue --blocked-by 50` is invoked and the GraphQL `addBlockedByDependency` mutation returns success +- **WHEN** `idd-issue --blocked-by 50` is invoked and the GraphQL `addBlockedBy` mutation returns success - **THEN** child body contains `> Blocked by #50` blockquote - **AND** GitHub UI displays the native "Blocked by" dependency on the child issue - **AND** no warning is emitted #### Scenario: Native dependency mutation fails, body annotation persists -- **WHEN** `idd-issue --blocked-by 50` is invoked and the GraphQL mutation fails (repo not enabled / permission / API error) +- **WHEN** `idd-issue --blocked-by 50` is invoked and the GraphQL mutation fails for a reason other than an existing dependency - **THEN** child body still contains `> Blocked by #50` blockquote -- **AND** the skill SHALL emit a warning naming the mutation failure and the blocked-by target +- **AND** the skill SHALL emit a warning naming the blocked-by target and including the error text GitHub returned +- **AND** the child issue creation SHALL NOT be aborted + +#### Scenario: Dependency already exists + +- **WHEN** the dependency on `50` already exists — for example `idd-issue --blocked-by 50,50`, or the relationship was created elsewhere — and GitHub's error text contains `Target issue has already been taken` +- **THEN** the skill SHALL NOT emit a warning for target `50` - **AND** the child issue creation SHALL NOT be aborted #### Scenario: Multiple blocked-by targets @@ -83,8 +89,8 @@ The `idd-issue` skill SHALL accept a `--blocked-by [,...]` flag where eac | GraphQL result | Body blockquote | Parent annotation (when --parent used) | Final state | | ----- | ----- | ----- | ----- | | Success | Present | Present | All three layers active | -| API failure | Present | Present | UI lacks native warning, but markdown still readable | -| Repo not enabled | Present | Present | Same as API failure, plus one-time warning | +| Dependency already exists (`Target issue has already been taken`) | Present | Present | Same as Success; no warning | +| Any other failure | Present | Present | UI lacks native warning, but markdown still readable; warning on stderr carries GitHub's error text | | Both --blocked-by and --parent absent | N/A | N/A | Child created normally without dependency annotation | ### Requirement: idd-issue SHALL accept --bundle-mode flag for batch bundle creation diff --git a/plugins/issue-driven-dev/.claude-plugin/plugin.json b/plugins/issue-driven-dev/.claude-plugin/plugin.json index 3d36f06..dbee408 100644 --- a/plugins/issue-driven-dev/.claude-plugin/plugin.json +++ b/plugins/issue-driven-dev/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "issue-driven-dev", "description": "v3.0.0 (BREAKING): the closing-summary helper may VETO and may never PERMIT. After twelve verify rounds failing in one direction — a real summary the recogniser could not follow classified `missing`, and `missing` being the sole authorisation for `/idd-close --retroactive` to post a duplicate — the power was split along the direction that is sound. \"A marker IS here\" is an observation; \"a marker is NOT here\" is an inference from a failure to recognise, and no matcher over source bytes can answer a question about rendered output in the negative. Gate exit codes are now 1 (recognised) / 2 (undeterminable) / 10 (nothing recognised — NOT permission); there is no exit 0 in gate mode, deliberately, so a caller still reading `rc == 0 means go` breaks loudly. Gate class `missing` → `unrecognised`, every reply carries authorises:false, and a fifth class `mentioned` names the state the tool can actually observe. `--retroactive` loses its unattended path: the skill must read the comment set itself and obtain human confirmation that cannot be disabled. Classification now asks who wrote the comment, so a commenter can no longer move an issue between classes. Also: three more exit-0 parser paths, markup counted as content three layers deep, a quotation reaching `compliant`, the mention gate passing on zero iterations by three routes, untrusted prose reaching a shell command line, and #317 criterion (c) answered correctly for the first time in five attempts. Ten guards were mutation-proven vacuous and rebuilt.", - "version": "3.0.0", + "version": "3.0.1", "author": { "name": "Che Cheng" }, diff --git a/plugins/issue-driven-dev/CHANGELOG.md b/plugins/issue-driven-dev/CHANGELOG.md index 4a9124e..f98db01 100644 --- a/plugins/issue-driven-dev/CHANGELOG.md +++ b/plugins/issue-driven-dev/CHANGELOG.md @@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.1] - 2026-10-02 + +### Fixed — `--blocked-by` could not create a native dependency (#353) + +- **Layer 1 called a mutation GitHub does not have.** Since 2.52.0 (#21) the native-dependency layer sent + `addBlockedByDependency(input:{issueId, blockedByIssueId})`. GitHub's schema has `addBlockedBy(input:{issueId, + blockingIssueId})` and nothing by the other name. Against today's schema that call can only fail, so as far as + we can tell every `--blocked-by` and every `--bundle-mode ordered` run fell back to the body blockquote alone. The normative spec named the same mutation, which is why reviewing + against the spec agreed with the bug. +- **The failure was invisible.** The call sent stderr to `/dev/null` and the warning hard-coded three causes + (repo not enabled / API error / permission), none of them the real one. Layer 1 now captures GitHub's output + and prints it verbatim on failure, and no longer guesses a cause. +- **An existing dependency is not a failure.** When the dependency is already there — the same target listed + twice (`--blocked-by 50,50`), or the relationship created elsewhere — GitHub returns rc=1 with `Target issue + has already been taken` and changes nothing (measured 2026-10-02). Layer 1 now reports that as already + linked, without a warning, and prints GitHub's sentence as the evidence. Only that exact sentence counts; any + other "has already been taken" still warns. +- **Layer 1 messages go to stderr.** The documented bundle orchestration runs the `--blocked-by` handler inside + `CHILD_NUM=$(…)`, so a message Layer 1 printed on stdout would end up in `CHILD_NUM` and be passed to the next + child as its `--blocked-by` value. Layer 2 and the `--parent` handler on the same path still print to stdout; + that, and the other remaining defects of the handler, are tracked in #359. +- **The reference example no longer prints GitHub's reply.** `references/bundle-flags.md` shows the request + captured into `GQL_OUT` and points to `SKILL.md` for the branches; the suite checks the example's shape. +- **Node IDs are bound with `-f`, not `-F`.** `-F` reads a local file for a value starting with `@`, and the + failure branch now prints GitHub's output verbatim. +- **New suite `blocked-by-mutation`** runs the Layer 1 snippet from `SKILL.md` against a stub `gh` in four + modes (success / already exists / another uniqueness failure / other error), plus two targets where the first + fails and the second must still be attempted. It checks the request GitHub receives (mutation, field names, + `-f` bindings, child → `issueId`), that every message stays off stdout, and + that no live file names the old mutation outside a closed list of historical records. Each check has a + positive control that breaks the snippet again and requires the check to fail. +- **Weekly live schema check.** `.github/workflows/live-schema.yml` runs the suite with `IDD_LIVE_GH=1` every + Monday 01:00 UTC (09:00 Taipei) and on demand, introspecting GitHub's real `addBlockedBy` mutation, its input + fields and its payload. A rename on GitHub's side does not come with a PR, so the PR suite cannot catch it; + without `IDD_LIVE_GH=1` the suite prints SKIP rather than claiming the schema was checked. Introspection + leaves out deprecated fields by default, so a deprecation fails the check too. GitHub disables scheduled + workflows in a public repository after 60 days without repository activity; if that happens, re-enable the + workflow from the Actions tab. The first run after merge is started by hand: `gh workflow run live-schema.yml`. +- **Issues created by 2.52.0–3.0.0 have only the body blockquote.** To add the native dependency to one of + them, call `addBlockedBy` once per pair as shown in `references/bundle-flags.md` Layer 1. Re-running + `idd-issue` would create a new child instead. + ## [3.0.0] - 2026-09-01 23 commits since 2.112.0, across four `/idd-verify` ensembles. **The major bump is for one diff --git a/plugins/issue-driven-dev/references/bundle-flags.md b/plugins/issue-driven-dev/references/bundle-flags.md index 1db4a17..54bf4ba 100644 --- a/plugins/issue-driven-dev/references/bundle-flags.md +++ b/plugins/issue-driven-dev/references/bundle-flags.md @@ -2,7 +2,7 @@ `idd-issue` 對 ordered/unordered issue bundle 的 first-class 支援(v2.52.0+)。本文件是 `--parent` / `--blocked-by` / `--bundle-mode` 三個 flag 的 canonical reference。 -> **TL;DR**:GitHub 提供三個原生 primitive:**parent body task list**(自動渲染 sub-issues + 進度條)、**Blocked-by dependency**(GraphQL `addBlockedByDependency`)、**milestone**(分組無依賴)。本機制把前兩個包進 `idd-issue` 的 flag 介面,讓常見的 ordered/unordered bundle 一個指令完成,並保證 idempotent + graceful degradation。 +> **TL;DR**:GitHub 提供三個原生 primitive:**parent body task list**(自動渲染 sub-issues + 進度條)、**Blocked-by dependency**(GraphQL `addBlockedBy`)、**milestone**(分組無依賴)。本機制把前兩個包進 `idd-issue` 的 flag 介面,讓常見的 ordered/unordered bundle 一個指令完成,並保證 idempotent + graceful degradation。 ## Overview — 三個正交軸 @@ -43,7 +43,7 @@ idd-issue --parent 100 "Step 4: 加 email 通知" |------|------| | 取值 | 逗號分隔的正整數 issue number list | | 多值 | ✅ `--blocked-by 50,51,52` | -| 副作用 | 1. Body prepend `> Blocked by #M` blockquote(每個 M 一行)
2. 嘗試 GraphQL `addBlockedByDependency` mutation
3. 若 `--parent` 同時 used:在 parent task list entry 加 `(blocked by #M)` 註解 | +| 副作用 | 1. Body prepend `> Blocked by #M` blockquote(每個 M 一行)
2. 嘗試 GraphQL `addBlockedBy` mutation
3. 若 `--parent` 同時 used:在 parent task list entry 加 `(blocked by #M)` 註解 | | Graceful degradation | ✅ GraphQL 失敗 → warning + 繼續,不 abort | | Cross-repo blocked-by | ❌ 同 repo only(跨 repo 用 cross-reference link 即可) | @@ -129,32 +129,32 @@ PATCH parent body 加 child entry 時,演算法保證 idempotency: ### Layer 1 — GitHub GraphQL native dependency(嘗試) -呼叫 `addBlockedByDependency` GraphQL mutation,把 child issue 跟 #M 綁成原生 Blocked-by 關係。 +呼叫 `addBlockedBy` GraphQL mutation,把 child issue 跟 #M 綁成原生 Blocked-by 關係。名稱與欄位以 GitHub schema 為準(`AddBlockedByInput` = `issueId` + `blockingIssueId`);v2.52.0 起此處寫的名稱不在現行 schema 裡,據此判斷原生依賴應從未建立過(#353)。 ```bash -gh api graphql -f query=' -mutation($issueId:ID!, $blockedById:ID!) { - addBlockedByDependency(input: { +GQL_OUT=$(gh api graphql -f query=' +mutation($issueId:ID!, $blockingId:ID!) { + addBlockedBy(input: { issueId: $issueId, - blockedByIssueId: $blockedById + blockingIssueId: $blockingId }) { - issue { id } + issue { number } } -}' -F issueId="$CHILD_NODE_ID" -F blockedById="$M_NODE_ID" +}' -f issueId="$CHILD_NODE_ID" -f blockingId="$M_NODE_ID" 2>&1) ``` +這裡只示範 request 的形狀:輸出一律先擷取進 `GQL_OUT`,不直接印到 stdout。接下來的三個分支(成功不印、依賴已存在、其他失敗)以 `skills/idd-issue/SKILL.md` Step 3.B 的 Layer 1 為準;可執行的版本只有那一份,由 `scripts/tests/blocked-by-mutation` suite 實際執行。 + +ID 用 `-f`(字串)不用 `-F`:`-F` 對 `@` 開頭的值會去讀本機檔案,而失敗時這段輸出會原樣印出。這一層的訊息都寫到 stderr:`--bundle-mode` 用 `CHILD_NUM=$(…)` 擷取 stdout,訊息若走 stdout 會被吃掉,還會被當成下一個 child 的 `--blocked-by` 值。同一個 handler 的 Layer 2 與 `--parent` 仍會寫 stdout,見 #359。 + 成功效果: - GitHub UI 顯示 「Blocked by #M」 紅色 warning - Issue side panel 顯示原生 dependency - task list 自動連動(parent 看 #M close 才解 child block) -失敗情境: -- Repo / org 未 enable native dependency feature -- API rate limit -- 權限不足 -- Issue 跨 repo(GraphQL mutation 限同 repo) +已知情境:依賴已存在,例如同一個目標重複出現(`--blocked-by 50,50`),或關係已由他處建立。GitHub 回 rc=1 與 `Validation failed: Target issue has already been taken`,狀態不變(2026-10-02 實測)。**視為成功**、不印警告,但把 GitHub 的那一句印到 stderr,作為判讀依據。只比對這一整句;其他 `has already been taken` 是別的驗證失敗,照常警告。 -**失敗處理**:emit warning 名指 `M` 和 failure reason,**不 abort** child issue 建立。 +**失敗處理**:捕捉 GraphQL 的輸出,失敗時**原樣印出 GitHub 回傳的錯誤**,名指 `M`,**不 abort** child issue 建立。文件不預設失敗原因——舊版在這裡列了四個猜測原因,而當時真正的原因(名稱不存在)不在其中,猜測只會把人引去查環境(#353)。 ### Layer 2 — Body blockquote 標註(無條件) @@ -200,14 +200,14 @@ Bundle 或 multi-target 操作的失敗情境分類處理: | `--bundle-mode` 中第 N 個 child 建立失敗(N>1) | 不 abort 已建的 children;**continue** 後續 children;最後報告 partial success(N-1 成功 / total)。使用者可以重跑 invocation 並用 `--parent ` 補建 | | `--blocked-by 50,51,52` 中某個 mutation 失敗 | 該 target 的 Layer 1 失敗 → warning + 繼續嘗試下個 target;Layer 2 body blockquote 一律加(包括失敗的 target);Layer 3 parent annotation 一律加 | | Parent body PATCH 失敗(權限 / API error) | child 仍建立成功;parent body 未更新 → warning + 退出非零 code,使用者可 `gh issue edit` 手動補 | -| GraphQL `addBlockedByDependency` 對全部 target 都失敗 | child 仍建立成功;child body 仍含 `> Blocked by #M` blockquote;每個 target 各自一條 warning | +| GraphQL `addBlockedBy` 對全部 target 都失敗 | child 仍建立成功;child body 仍含 `> Blocked by #M` blockquote;每個 target 各自一條 warning | ## Idempotency Contract | 操作 | Idempotent? | 機制 | |------|------------|------| | `--parent ` 重複呼叫(同一 child) | ✅ | Edit algorithm Step 2 掃 `#N` reference;already-exists → no-op skip | -| `--blocked-by ` 重複呼叫(同一 child) | ✅(body)/ ⚠(GraphQL) | Body blockquote 重複 prepend 會產生重複行 → 演算法掃 `> Blocked by #M` 字串先 dedup;GraphQL mutation 重複呼叫由 GitHub 端 dedup(no-op for already-blocking pair) | +| `--blocked-by` 對同一 child 列出重複目標(如 `50,50`) | ❌(body)/ ✅(GraphQL) | Layer 2 目前不去重,會寫兩行 `> Blocked by #50`,Layer 3 的 entry 也會重複(#359)。GraphQL 第二次呼叫回 rc=1 與 `Target issue has already been taken`、狀態不變,skill 視為已連結。重跑 `idd-issue` 會建新的 child,不會碰到同一個 child | | `--bundle-mode` 重複呼叫(同樣 input) | ❌ | bundle 必然建新 epic + 新 children;使用者責任避免重複呼叫 | **為什麼 `--bundle-mode` 不 idempotent**:bundle 把 N 個 child 視為一次性 transaction,沒有 stable identifier 可掃。idempotency 只在「個別 child 的 parent / blocked-by 標註」層級保證。 @@ -297,5 +297,5 @@ Bundle flags **不修改**既有 `idd-issue` 機制: - `plugins/issue-driven-dev/skills/idd-issue/SKILL.md § Ordered Bundle Pattern` — 使用者導向的 pattern 介紹 - `plugins/issue-driven-dev/CLAUDE.md § Configuration § Groups` — cross-repo 場景的正確機制 -- GitHub Docs: [Issue dependencies](https://docs.github.com/en/issues/managing-your-work-with-issues/managing-dependencies-and-blockers) — `addBlockedByDependency` 原生功能 +- GitHub Docs: [Issue dependencies](https://docs.github.com/en/issues/managing-your-work-with-issues/managing-dependencies-and-blockers) — `addBlockedBy` / `removeBlockedBy` 原生功能 - GitHub Docs: [Issue task lists / sub-issues](https://docs.github.com/en/issues/managing-your-work-with-issues/about-sub-issues) — parent task list 渲染慣例 diff --git a/plugins/issue-driven-dev/scripts/tests/blocked-by-mutation/test.sh b/plugins/issue-driven-dev/scripts/tests/blocked-by-mutation/test.sh new file mode 100644 index 0000000..8974e6c --- /dev/null +++ b/plugins/issue-driven-dev/scripts/tests/blocked-by-mutation/test.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +# Test: `idd-issue --blocked-by` calls the GraphQL mutation that actually exists, +# and shows GitHub's error when it fails (#353). +# +# WHY THIS TEST EXISTS +# +# Since v2.52.0 (#21) Layer 1 of the --blocked-by fallback chain called +# `addBlockedByDependency(input:{issueId, blockedByIssueId})`. Neither name is +# in GitHub's current schema — the mutation is `addBlockedBy(input:{issueId, +# blockingIssueId})`. Nobody noticed because three things hid it: +# +# 1. `2>/dev/null` swallowed GitHub's error; +# 2. the warning hard-coded three causes ("repo not enabled / API error / +# permission"), none of which was the real one; +# 3. the normative spec named the same wrong mutation, so a spec-driven +# review agreed with the bug. +# +# So the checks below RUN the Layer 1 snippet out of SKILL.md against a stub +# `gh` instead of grepping for the right words: the defect was never a missing +# string, it was behaviour nobody could see. Every check has a positive control +# that breaks the snippet (or the input) and requires the check to fail. +# +# LIVE CHECK: IDD_LIVE_GH=1 also introspects GitHub's real schema. It is off in +# the default suite on purpose — every other suite here is offline and +# deterministic, and a GitHub API hiccup should not turn an unrelated PR red. +# A schema rename happens on GitHub's side, independently of any PR, so it is +# detected by `.github/workflows/live-schema.yml`, which runs this suite with +# IDD_LIVE_GH=1 on a weekly schedule against main. When the live check does not +# run, the suite prints SKIP — a skipped live check is not evidence that the +# schema was verified. +# +# Usage: bash test.sh (exit 0 = pass, 1 = fail) + +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN="$(cd "$HERE/../../.." && pwd)" +REPO="$(cd "$PLUGIN/../.." && pwd)" +SKILL="$PLUGIN/skills/idd-issue/SKILL.md" +REFDOC="$PLUGIN/references/bundle-flags.md" +. "$(cd "$HERE/../../lib" && pwd)/assert-helpers.sh" + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/blocked-by-mutation-XXXXXX") || exit 1 +trap 'rm -rf "$TMP"' EXIT +trap 'exit 130' HUP INT TERM # the EXIT trap still cleans up + +BANNED='addBlockedByDependency|blockedByIssueId' +OLD_CAUSES='not enabled|API error|permission|rate limit' # the causes the old warning guessed + +# ── Harness: extract the Layer 1 snippet and run it against a stub `gh` ── + +extract_layer1() { # $1 = SKILL.md path → the Layer 1 lines, stopping at Layer 3 or a fence + awk '/^# Layer 1[::]/{f=1} f && /^```/{f=0} /^# Layer 3[::]/{f=0} f' "$1" +} + +marker_count() { # $1 = ERE, $2 = file → number of lines matching + grep -cE -- "$1" "$2" +} + +mkdir -p "$TMP/bin" "$TMP/run" +cat > "$TMP/bin/gh" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$GH_LOG" +case "$1 $2" in + "issue view") echo "NODE_$3"; exit 0 ;; + "api graphql") + mode="$GH_MODE" # GH_MODE_ overrides it for one target + for a in "$@"; do + case "$a" in b=NODE_*) v="GH_MODE_${a#b=}"; mode="${!v:-$GH_MODE}" ;; esac + done + case "$mode" in + ok) echo '{"data":{"addBlockedBy":{"issue":{"number":9}}}}'; exit 0 ;; + taken) echo '{"data":{"addBlockedBy":null},"errors":[{"type":"VALIDATION","message":"An error occurred while adding the blocking issue to the issue. Validation failed: Target issue has already been taken"}]}' + echo "gh: An error occurred while adding the blocking issue to the issue. Validation failed: Target issue has already been taken" >&2 + exit 1 ;; + other-unique) # a different uniqueness failure must NOT be read as "already linked" + echo '{"errors":[{"type":"VALIDATION","message":"Validation failed: Name has already been taken"}]}' + echo "gh: Validation failed: Name has already been taken" >&2 + exit 1 ;; + other) echo '{"errors":[{"type":"NOT_FOUND"}]}' + echo "gh: stub-specific failure text 4711" >&2 + exit 1 ;; + esac ;; +esac +exit 0 +STUB +chmod +x "$TMP/bin/gh" + +run_layer1() { # $1 = snippet file, $2 = mode, [$3 = target list] → $TMP/out.$2, $TMP/err.$2, $TMP/rc.$2 + : > "$TMP/gh.log" + ( + cd "$TMP/run" || exit 99 # anything the snippet writes by accident lands here, not in the repo + export PATH="$TMP/bin:$PATH" GH_MODE="$2" GH_LOG="$TMP/gh.log" + CHILD_NUM=9 BLOCKED_BY_LIST="${3:-7}" GITHUB_REPO=owner/repo + . "$1" + ) > "$TMP/out.$2" 2> "$TMP/err.$2" + echo $? > "$TMP/rc.$2" +} + +# Each check returns 0 when the snippet behaves correctly, 1 otherwise, so the +# positive controls can call the same function on a broken snippet. + +check_request() { # the request GitHub receives: real names, -f bindings, child → issueId + run_layer1 "$1" ok + local log; log=$(cat "$TMP/gh.log") + printf '%s\n' "$log" | grep -qF -- 'addBlockedBy(input:{issueId:$i,blockingIssueId:$b}){issue{number}}' && + printf '%s\n' "$log" | grep -qF -- '-f i=NODE_9' && + printf '%s\n' "$log" | grep -qF -- '-f b=NODE_7' && + ! printf '%s\n' "$log" | grep -qE -- "$BANNED" && + ! printf '%s\n' "$log" | grep -qE -- '-F (i|b)=' +} + +check_error_surfaced() { # a real failure: GitHub's own text, the target named, no guessed cause + run_layer1 "$1" other + [ "$(cat "$TMP/rc.other")" = 0 ] && + grep -q 'stub-specific failure text 4711' "$TMP/err.other" && + grep '⚠' "$TMP/err.other" | grep -q '#7' && + ! grep -qiE -- "$OLD_CAUSES" "$TMP/err.other" +} + +check_existing_is_success() { # the exact "already linked" message is not a failure, + run_layer1 "$1" taken # and GitHub's sentence is kept as the evidence for saying so + [ "$(cat "$TMP/rc.taken")" = 0 ] && + ! grep -q '⚠' "$TMP/err.taken" && + grep -q '#7' "$TMP/err.taken" && + grep -q 'Target issue has already been taken' "$TMP/err.taken" +} + +check_other_uniqueness_warns() { # any OTHER "has already been taken" is a real failure + run_layer1 "$1" other-unique + [ "$(cat "$TMP/rc.other-unique")" = 0 ] && + grep -q '⚠' "$TMP/err.other-unique" && + grep -q 'Name has already been taken' "$TMP/err.other-unique" +} + +check_success_is_quiet() { # success prints nothing at all + run_layer1 "$1" ok + [ "$(cat "$TMP/rc.ok")" = 0 ] && + [ ! -s "$TMP/out.ok" ] && [ ! -s "$TMP/err.ok" ] +} + +check_continues_after_failure() { # spec: one target failing SHALL NOT stop the others + ( export GH_MODE_NODE_7=other GH_MODE_NODE_8=ok; run_layer1 "$1" multi 7,8 ) # #7 fails, #8 succeeds + [ "$(cat "$TMP/rc.multi")" = 0 ] && + grep '⚠' "$TMP/err.multi" | grep -q '#7' && + grep -qF -- '-f b=NODE_8' "$TMP/gh.log" +} + +check_reference_captures() { # $1 = bundle-flags.md: its Layer 1 example must capture the output, + local block # never end in a bare 2>&1 that sends GitHub's reply to stdout + block=$(awk '/^### Layer 1/{f=1; next} f && /^### /{f=0} f' "$1") + printf '%s\n' "$block" | grep -qF 'GQL_OUT=$(gh api graphql' && + ! printf '%s\n' "$block" | grep -qE '2>&1[[:space:]]*$' +} + +check_stdout_silent() { # messages go to stderr in every mode: bundle-mode captures stdout + local m # with CHILD_NUM=$(…), so anything printed there is swallowed and + for m in ok taken other-unique other; do # then fed to the next child as --blocked-by + run_layer1 "$1" "$m" + [ ! -s "$TMP/out.$m" ] || return 1 + done +} + +# ── Rule: banned names appear nowhere except a CLOSED list of historical records ── +# +# Exactly these, and no others by analogy: +# 1. plugins/issue-driven-dev/CHANGELOG.md — release history is not rewritten +# 2. README.md rows of the version table — lines starting `| v` +# 3. openspec/changes/archive/ — archived changes are frozen +# 4. this test's own directory — it has to name what it bans +# +# The scan covers every tracked file on purpose: a wrong mutation name copied +# into a new skill, rule or doc should fail here too. A future file that needs +# to mention the old name (e.g. to explain history) is added to this list +# explicitly, not exempted by a broader pattern. +banned_hits() { # $1 = root; NUL-delimited root-relative paths on stdin → path:line:text + local root="$1" f + while IFS= read -r -d '' f; do + case "$f" in + plugins/issue-driven-dev/CHANGELOG.md) continue ;; + openspec/changes/archive/*) continue ;; + plugins/issue-driven-dev/scripts/tests/blocked-by-mutation/*) continue ;; + esac + [ -f "$root/$f" ] || continue # tracked but deleted in the working tree + # grep prints the path itself (-H): the path is never spliced into a program. + if [ "$f" = "plugins/issue-driven-dev/README.md" ]; then + (cd "$root" && grep -HInE -- "$BANNED" "$f") | grep -vE '^[^:]+:[0-9]+:\| v[0-9]' + else + (cd "$root" && grep -HInE -- "$BANNED" "$f") + fi + done + return 0 +} + +list_files() { # NUL-delimited; non-ASCII paths unquoted; tracked files when this is a checkout + if git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$REPO" -c core.quotePath=false ls-files -z + else + (cd "$REPO" && find . -type f -not -path './.git/*' -not -path './.claude/worktrees/*' -print0 \ + | while IFS= read -r -d '' p; do printf '%s\0' "${p#./}"; done) + fi +} + +# ── Real runs ── + +require "exactly one '# Layer 1:' marker in idd-issue/SKILL.md" \ + test "$(marker_count '^# Layer 1[::]' "$SKILL")" = 1 +require "exactly one '# Layer 3:' marker in idd-issue/SKILL.md" \ + test "$(marker_count '^# Layer 3[::]' "$SKILL")" = 1 + +LAYER1="$TMP/layer1.sh" +extract_layer1 "$SKILL" > "$LAYER1" +require "the Layer 1 snippet can be found in idd-issue/SKILL.md" test -s "$LAYER1" + +require "Layer 1 sends addBlockedBy(issueId=child, blockingIssueId=target) with -f bindings" check_request "$LAYER1" +require "Layer 1 shows GitHub's error text, names the target, guesses no cause" check_error_surfaced "$LAYER1" +require "Layer 1 treats the exact 'Target issue has already been taken' as already linked" check_existing_is_success "$LAYER1" +require "Layer 1 still warns on a different 'has already been taken' failure" check_other_uniqueness_warns "$LAYER1" +require "Layer 1 prints nothing on success" check_success_is_quiet "$LAYER1" +require "Layer 1 never writes to stdout" check_stdout_silent "$LAYER1" +require "Layer 1 still tries #8 after #7 fails" check_continues_after_failure "$LAYER1" +require "bundle-flags.md's Layer 1 example captures the output instead of printing it" check_reference_captures "$REFDOC" + +require "the file list sees idd-issue/SKILL.md (an empty list would pass the scan vacuously)" \ + sh -c 'tr "\0" "\n" | grep -qx "plugins/issue-driven-dev/skills/idd-issue/SKILL.md"' < <(list_files) +HITS=$(list_files | banned_hits "$REPO") +assert_eq "no live file names addBlockedByDependency / blockedByIssueId" "" "$HITS" + +# ── Positive controls: each check must FAIL on a snippet that is broken again ── + +mutate() { # $1 = sed expression, $2 = output file; fails if nothing changed + sed -E "$1" "$LAYER1" > "$2" + ! cmp -s "$LAYER1" "$2" +} + +require "control: the old mutation name can be put back" \ + mutate 's/addBlockedBy\(/addBlockedByDependency(/; s/blockingIssueId/blockedByIssueId/' "$TMP/m1.sh" +refute "control: the request check catches the old mutation" check_request "$TMP/m1.sh" + +require "control: the bindings can be swapped" \ + mutate 's/-f i="\$CHILD_NODE_ID" -f b="\$M_NODE_ID"/-f i="$M_NODE_ID" -f b="$CHILD_NODE_ID"/' "$TMP/m1b.sh" +refute "control: the request check catches a reversed dependency" check_request "$TMP/m1b.sh" + +require "control: the bindings can go back to -F" \ + mutate 's/-f i=/-F i=/; s/-f b=/-F b=/' "$TMP/m1c.sh" +refute "control: the request check catches -F bindings" check_request "$TMP/m1c.sh" + +require "control: GitHub's error can be swallowed again" \ + mutate 's/2>&1\)/2>\/dev\/null)/' "$TMP/m2.sh" +refute "control: the error check catches a swallowed error" check_error_surfaced "$TMP/m2.sh" + +require "control: a guessed cause can be put back into the warning" \ + mutate 's/失敗。GitHub 回傳/失敗 (permission)。GitHub 回傳/' "$TMP/m2b.sh" +refute "control: the error check catches a guessed cause" check_error_surfaced "$TMP/m2b.sh" + +require "control: the already-exists branch can be disabled" \ + mutate 's/Target issue has already been taken/zzz-never-matches/' "$TMP/m3.sh" +refute "control: the already-exists check catches its removal" check_existing_is_success "$TMP/m3.sh" + +require "control: the already-exists match can be broadened" \ + mutate "s/'Target issue has already been taken'/'already been taken'/" "$TMP/m3b.sh" +refute "control: the other-uniqueness check catches a broadened match" check_other_uniqueness_warns "$TMP/m3b.sh" + +require "control: success can be made noisy" \ + mutate 's/^([[:space:]]*):[[:space:]]+# 原生依賴已建立.*/\1printf "%s\\n" "$GQL_OUT" >\&2/' "$TMP/m4.sh" +refute "control: the quiet-success check catches output on success" check_success_is_quiet "$TMP/m4.sh" + +require "control: messages can be sent back to stdout" \ + mutate 's/ >&2$//; s/^([[:space:]]*\}) >&2$/\1/' "$TMP/m5.sh" +refute "control: the stdout check catches a message on stdout" check_stdout_silent "$TMP/m5.sh" + +require "control: the loop can be cut short after a failure" \ + mutate 's/\} >&2$/} >\&2; break/' "$TMP/m6.sh" +refute "control: the continuation check catches a break after a failure" check_continues_after_failure "$TMP/m6.sh" + +require "control: the already-exists evidence can be dropped" \ + mutate "s/grep -F 'Target issue has already been taken' \\| head -n 1/grep -F zzz-none | head -n 1/" "$TMP/m7.sh" +refute "control: the already-exists check catches missing evidence" check_existing_is_success "$TMP/m7.sh" + +cat > "$TMP/ref-old.md" <<'OLD' +### Layer 1 — old shape +```bash +gh api graphql -f query='mutation{addBlockedBy(input:{issueId:$i,blockingIssueId:$b}){issue{number}}}' -f i="$C" -f b="$M" 2>&1 +``` +### Layer 2 +OLD +refute "control: the reference check catches an uncaptured example ending in 2>&1" check_reference_captures "$TMP/ref-old.md" + +mkdir -p "$TMP/tree/plugins/issue-driven-dev" "$TMP/tree/docs" "$TMP/tree/openspec/changes/archive/x" +echo 'old: addBlockedByDependency' > "$TMP/tree/plugins/issue-driven-dev/CHANGELOG.md" +echo 'old: addBlockedByDependency' > "$TMP/tree/openspec/changes/archive/x/design.md" +printf '| v2.52.0 | addBlockedByDependency |\nnow: addBlockedByDependency\n' > "$TMP/tree/plugins/issue-driven-dev/README.md" +echo 'blockedByIssueId: $b' > "$TMP/tree/docs/commands.md" +echo 'blockedByIssueId: $b' > "$TMP/tree/docs/文件.md" +CTRL=$(printf '%s\0' plugins/issue-driven-dev/CHANGELOG.md openspec/changes/archive/x/design.md \ + plugins/issue-driven-dev/README.md docs/commands.md "docs/文件.md" | banned_hits "$TMP/tree") +assert_grep "control: a live doc with a banned name is reported" "docs/commands.md:1:" "$CTRL" +assert_grep "control: a non-ASCII path with a banned name is reported" "docs/文件.md:1:" "$CTRL" +assert_grep "control: a README line outside the version table is reported" "README.md:2:" "$CTRL" +refute_grep "control: a README version-table row is exempt" "README.md:1:" "$CTRL" +refute_grep "control: CHANGELOG is exempt" "CHANGELOG.md" "$CTRL" +refute_grep "control: archived changes are exempt" "archive/" "$CTRL" + +# ── Live: GitHub's real schema (IDD_LIVE_GH=1; weekly in .github/workflows/live-schema.yml) ── + +if [ "${IDD_LIVE_GH:-}" = 1 ]; then + MUTS=$(gh api graphql -f query='{__type(name:"Mutation"){fields{name}}}' --jq '.data.__type.fields[].name' 2>&1) + INPUT=$(gh api graphql -f query='{__type(name:"AddBlockedByInput"){inputFields{name}}}' --jq '.data.__type.inputFields[].name' 2>&1) + PAYLOAD=$(gh api graphql -f query='{__type(name:"AddBlockedByPayload"){fields{name}}}' --jq '.data.__type.fields[].name' 2>&1) + # `fields` / `inputFields` leave out deprecated entries unless includeDeprecated:true is + # passed (measured 2026-10-02: Mutation has 261 fields by default, 277 with deprecated ones), + # so a deprecation fails these checks as well as a removal. + # Whole-line matches: a substring test would let `blockingIssueId` satisfy + # "has issueId", and any longer mutation name satisfy "has addBlockedBy". + assert_grep_re "live: the schema has an addBlockedBy mutation" '^addBlockedBy$' "$MUTS" + assert_grep_re "live: AddBlockedByInput has issueId" '^issueId$' "$INPUT" + assert_grep_re "live: AddBlockedByInput has blockingIssueId" '^blockingIssueId$' "$INPUT" + assert_grep_re "live: AddBlockedByPayload has issue (the snippet selects issue{number})" '^issue$' "$PAYLOAD" +else + echo "SKIP live schema check (set IDD_LIVE_GH=1 to introspect GitHub's real schema)" +fi + +print_summary "blocked-by-mutation" +exit $? diff --git a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md index daf6905..673ad39 100644 --- a/plugins/issue-driven-dev/skills/idd-issue/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-issue/SKILL.md @@ -107,7 +107,7 @@ TaskCreate(name="resolve_mentions", description="若有 --mention 或 descriptio TaskCreate(name="privacy_scrub_gate", description="Step 0.6: 依 repo visibility 解析 $SCRUB_LEVEL (third-party=enforce / own-public=warn / private=light);每次 egress 前對 drafted body 跑 rules/privacy-scrubbing.md 的 LLM 語意自審,並一律經 scripts/gh-egress.sh --scrub-attested 派送(不直接 gh issue,#202)") TaskCreate(name="create_issue", description="Step 3: gh issue create — Single mode / Group mode / Bundle mode(--parent / --blocked-by / --bundle-mode,見 Step 3.B),body 含已驗證的 @login;經 scripts/gh-egress.sh 派送(#202),有 mention 時帶 --mention-attested (#117 mention net;未帶會被 refuse)") TaskCreate(name="resolve_parent_link", description="Step 3.B: 若 --parent set,驗證 #N 在 target repo + idempotent PATCH parent body task list(見 references/bundle-flags.md § Edit Algorithm)") -TaskCreate(name="apply_blocked_by", description="Step 3.B: 若 --blocked-by [,...] set,三層 fallback chain — body blockquote(unconditional)+ GraphQL addBlockedByDependency(嘗試)+ parent annotation(若 --parent co-used)") +TaskCreate(name="apply_blocked_by", description="Step 3.B: 若 --blocked-by [,...] set,三層 fallback chain — body blockquote(unconditional)+ GraphQL addBlockedBy(嘗試,錯誤原樣印出)+ parent annotation(若 --parent co-used)") TaskCreate(name="orchestrate_bundle_mode", description="Step 3.B: 若 --bundle-mode set,建 epic + N children + 自動套用 --parent + (ordered 時)Blocked-by 鏈;與 group 模式互斥") TaskCreate(name="attach_images", description="上傳圖片到 attachments release 並編輯 issue body 嵌入(若有)") TaskCreate(name="create_milestone", description="來源為文件時自動建立 milestone 並指派(見 Step 4.5)") @@ -765,13 +765,29 @@ NEW_CHILD_BODY="${BLOCKED_BLOCKQUOTE}\n${ORIGINAL_BODY}" bash "$CLAUDE_PLUGIN_ROOT/scripts/gh-egress.sh" edit "$CHILD_NUM" --repo "$GITHUB_REPO" --body "$NEW_CHILD_BODY" --scrub-attested "$SCRUB_LEVEL" ${MENTION_ATTESTED:+--mention-attested="$MENTION_ATTESTED"} # Layer 1:GraphQL native dependency(嘗試,失敗不 abort) +# Mutation 是 addBlockedBy(input:{issueId, blockingIssueId})——GitHub 現行 schema 裡的名稱(#353)。 +# GitHub 的錯誤原樣印出,不猜原因。v2.52.0 起這裡用的名稱不在現行 schema 裡,錯誤又被丟進 +# /dev/null、改印三個寫死的猜測原因;據此判斷,原生依賴應從未建立過。 +# 這一層的訊息一律走 stderr:bundle-mode 用 CHILD_NUM=$(…) 擷取 stdout,訊息若走 stdout 會被吃掉, +# 還會被當成下一個 child 的 --blocked-by 值。同一個 handler 的 Layer 2 與 --parent 仍會寫 stdout,見 #359。 CHILD_NODE_ID=$(gh issue view "$CHILD_NUM" --repo "$GITHUB_REPO" --json id --jq '.id') for M in $(echo "$BLOCKED_BY_LIST" | tr ',' '\n'); do M_NODE_ID=$(gh issue view "$M" --repo "$GITHUB_REPO" --json id --jq '.id') - if ! gh api graphql -f query=' - mutation($i:ID!,$b:ID!){addBlockedByDependency(input:{issueId:$i,blockedByIssueId:$b}){issue{id}}} - ' -F i="$CHILD_NODE_ID" -F b="$M_NODE_ID" 2>/dev/null; then - echo "⚠ GraphQL addBlockedByDependency #${CHILD_NUM} ← #${M} failed (repo not enabled / API error / permission); body blockquote already in place" + # ID 用 -f(字串)不用 -F:-F 對 @ 開頭的值會去讀本機檔案,而失敗時這段輸出會原樣印出。 + if GQL_OUT=$(gh api graphql -f query=' + mutation($i:ID!,$b:ID!){addBlockedBy(input:{issueId:$i,blockingIssueId:$b}){issue{number}}} + ' -f i="$CHILD_NODE_ID" -f b="$M_NODE_ID" 2>&1); then + : # 原生依賴已建立 + elif printf '%s' "$GQL_OUT" | grep -qF 'Target issue has already been taken'; then + # 依賴已存在:同一個目標重複出現(例如 --blocked-by 50,50),或已由他處建立。 + # GitHub 回 rc=1 與這句訊息,狀態不變(2026-10-02 實測),所以不算失敗、不印警告。 + # 只比對這一整句:其他 "has already been taken" 是別的驗證失敗,照常警告。 + # 判讀依據是 GitHub 的原句,所以把那一句一起印出來。 + { echo "→ #${CHILD_NUM} ← #${M}:視為原生依賴已存在。GitHub 回傳:" + printf '%s\n' "$GQL_OUT" | grep -F 'Target issue has already been taken' | head -n 1 | sed 's/^/ /'; } >&2 + else + { echo "⚠ GraphQL addBlockedBy #${CHILD_NUM} ← #${M} 失敗。GitHub 回傳:" + printf '%s\n' "$GQL_OUT" | sed 's/^/ /'; } >&2 fi done @@ -1005,7 +1021,7 @@ AskUserQuestion: #### (d) Native relationship suggestion (deferred — lean-v1 不實作,disclosed) -- [~] **Native relationship suggestion (deferred)** — body 內 `#N` 用 GitHub 2024+ GraphQL(`addBlockedByDependency` 等)建 structured relationship,比 body text reference 多 sidebar backlink。**本 cluster 刻意不做**,理由:(1) 複雜度/風險最高(需 relationship-type picker UI);(2) #141 spec 自身將其框為「reuse v2.52.0+ `--blocked-by` code path」的後續工作,而該 path 尚未一般化成可獨立呼叫的 primitive;(3) design Q4 裁定 body text reference 本來就保留(與 native backlink 互補不重複),所以延後不損失現有 inline context。點亮條件:`--blocked-by` 的 GraphQL mutation 抽成可重用 helper 後,再接本 (d)。 +- [~] **Native relationship suggestion (deferred)** — body 內 `#N` 用 GitHub 2024+ GraphQL(`addBlockedBy` 等)建 structured relationship,比 body text reference 多 sidebar backlink。**本 cluster 刻意不做**,理由:(1) 複雜度/風險最高(需 relationship-type picker UI);(2) #141 spec 自身將其框為「reuse v2.52.0+ `--blocked-by` code path」的後續工作,而該 path 尚未一般化成可獨立呼叫的 primitive;(3) design Q4 裁定 body text reference 本來就保留(與 native backlink 互補不重複),所以延後不損失現有 inline context。點亮條件:`--blocked-by` 的 GraphQL mutation 抽成可重用 helper 後,再接本 (d)。 ### Step 3.6: Baseline auto-tag(rollback anchor,v2.94.0+,#85)