From e14581f213c3b2370e04829de7fd9e847c3330c4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 13 Sep 2026 11:23:23 +0900 Subject: [PATCH 1/2] Link manually published issues to suggestions (#5350) --- DEVELOPER_GUIDE.md | 8 +- TESTING_GUIDE.md | 4 + USER_GUIDE.md | 8 +- changelog.d/unreleased/5350.added.md | 18 ++ docs/suggestion-issue-linking.md | 109 +++++++++ src/CodeIndex/Cli/CliCommandMetadata.cs | 2 +- src/CodeIndex/Cli/CliFlagSchema.cs | 19 +- src/CodeIndex/Cli/ConsoleUi.cs | 10 +- .../Cli/SuggestionStore.Association.cs | 129 +++++++++++ src/CodeIndex/Cli/SuggestionStore.cs | 23 +- .../Cli/SuggestionsCommandRunner.Link.cs | 39 ++++ src/CodeIndex/Cli/SuggestionsCommandRunner.cs | 90 ++++++-- src/CodeIndex/Models/SuggestionRecord.cs | 17 ++ tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 9 + .../SuggestionLinkCommandTests.cs | 118 ++++++++++ .../SuggestionStoreTests.Association.cs | 214 ++++++++++++++++++ tests/CodeIndex.Tests/SuggestionStoreTests.cs | 2 +- 17 files changed, 789 insertions(+), 30 deletions(-) create mode 100644 changelog.d/unreleased/5350.added.md create mode 100644 docs/suggestion-issue-linking.md create mode 100644 src/CodeIndex/Cli/SuggestionStore.Association.cs create mode 100644 src/CodeIndex/Cli/SuggestionsCommandRunner.Link.cs create mode 100644 tests/CodeIndex.Tests/SuggestionLinkCommandTests.cs create mode 100644 tests/CodeIndex.Tests/SuggestionStoreTests.Association.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 8ee58f4b4..d64ba19c1 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -3036,9 +3036,11 @@ Local suggestion retention is bounded by `CDIDX_SUGGESTION_MAX_AGE_DAYS` and `CD ### Local lifecycle fields +`SuggestionStore.TryLinkIssue` associates an operator-supplied existing issue under the same lock and atomic writer, preserving submission history and recording independent manual association evidence. See [issue-link identity, offline behavior, lifecycle and export contracts](docs/suggestion-issue-linking.md#english). + Local suggestion records use the `status` lifecycle field instead of a binary submitted flag. New records start as `draft`; successful GitHub submission moves them to `submitted_pending_triage` and stamps `upstream_url`, `upstream_issue_number`, and `last_synced_at` when known. Every GitHub submission attempt also stamps `last_submit_attempt`, increments `submit_attempt_count`, and records `last_submit_error` on failure; success clears the last error. GitHub rate-limit responses also stamp `next_retry_at`, and duplicate unsubmitted suggestions are not retried until that timestamp has passed. Older records containing `submitted_to_github` / `github_issue_url` are normalized on read to the new lifecycle fields. -`SuggestionStore.TryTransitionStatus` is the atomic manual-transition boundary used by `suggestions update --status `. `submitted_pending_triage` is automatic-only. `open_in_upstream` and `resolved_in_upstream` require existing upstream evidence; `draft` requires the absence of upstream evidence; and `wont_fix`, `duplicate`, or `superseded` are local maintainer dispositions. Local dispositions suppress automatic duplicate resubmission without setting `AlreadySubmitted` or an upstream-submission response flag. Same-state transitions and transitions during an active submission reservation fail closed. The store rechecks the expected revision under its file lock, stamps the latest `previous_status`, UTC `status_changed_at`, bounded/redacted `status_changed_by`, and optional bounded/redacted `status_change_reason`, updates `resolved_at` for `resolved_in_upstream`, and recomputes `revision_hash`. Full audit values are redacted before a surrogate-safe final cap so a credential crossing the cap boundary cannot evade redaction. Content edits and lifecycle transitions are separate CLI operations so one audit event has one unambiguous meaning. +`SuggestionStore.TryTransitionStatus` is the atomic manual-transition boundary used by `suggestions update --status `. `submitted_pending_triage` is set by GitHub submission or explicit issue linking, and is not a manual status-transition target. `open_in_upstream` and `resolved_in_upstream` require existing upstream evidence; `draft` requires the absence of upstream evidence; and `wont_fix`, `duplicate`, or `superseded` are local maintainer dispositions. Local dispositions suppress automatic duplicate resubmission without setting `AlreadySubmitted` or an upstream-submission response flag. Same-state transitions and transitions during an active submission reservation fail closed. The store rechecks the expected revision under its file lock, stamps the latest `previous_status`, UTC `status_changed_at`, bounded/redacted `status_changed_by`, and optional bounded/redacted `status_change_reason`, updates `resolved_at` for `resolved_in_upstream`, and recomputes `revision_hash`. Full audit values are redacted before a surrogate-safe final cap so a credential crossing the cap boundary cannot evade redaction. Content edits and lifecycle transitions are separate CLI operations so one audit event has one unambiguous meaning. `suggestions list|export --query ` matches the NFKC-normalized query as an ordinal, case-insensitive substring against the redacted stable ID, sampled title, description, context, evidence paths, category, and language. Applying `SuggestionStore.RedactSensitiveText` before matching is a confidentiality contract: a caller cannot use zero-result/count differences to probe a value removed by redaction. Status, time, category, language, and agent filters run first; the text query follows; records are then ordered by descending `CreatedAt` and ordinal stable ID before offset/limit pagination. @@ -7537,9 +7539,11 @@ suggestion sidecar は `DataDirectorySecurity.ResolveSensitiveSidecarDirectoryFo ### ローカルライフサイクルフィールド +`SuggestionStore.TryLinkIssue` は同じロックと原子的な書き込み処理で、操作者が指定した既存 Issue を関連付けます。投稿履歴を維持し、独立した手動関連付けの証跡を記録します。[識別情報・オフライン動作・状態・エクスポートの契約](docs/suggestion-issue-linking.md#日本語)を参照してください。 + ローカルの提案レコードは、送信済みかどうかの二値フラグではなく `status` ライフサイクルフィールドを使う。新規レコードは `draft` で始まり、GitHub への送信が成功すると `submitted_pending_triage` へ移行し、判明している範囲で `upstream_url`、`upstream_issue_number`、`last_synced_at` を記録する。GitHub 送信を試みるたびに `last_submit_attempt` を stamp し、`submit_attempt_count` を増やし、失敗時は `last_submit_error` を記録します。成功時は最後の error を clear します。GitHub の rate-limit 応答では `next_retry_at` も記録し、未送信の重複提案はその時刻を過ぎるまで再送しない。`submitted_to_github` / `github_issue_url` を含む古いレコードは、読み取り時に新しいライフサイクルフィールドへ正規化される。 -`SuggestionStore.TryTransitionStatus` は `suggestions update --status ` が使う原子的な手動遷移境界です。`submitted_pending_triage` は自動設定専用です。`open_in_upstream` と `resolved_in_upstream` には既存の upstream 根拠が必要で、`draft` には upstream 根拠がないことが必要です。`wont_fix`、`duplicate`、`superseded` はメンテナーによるローカルの判断です。ローカルの判断は重複提案の自動再送を抑止しますが、`AlreadySubmitted` や upstream 送信済み response flag は設定しません。同じ状態への遷移、および送信 reservation が active な間の遷移は fail closed になります。store は file lock 内で expected revision を再確認し、最新の `previous_status`、UTC の `status_changed_at`、上限・redaction 付きの `status_changed_by`、任意の上限・redaction 付き `status_change_reason` を stamp し、`resolved_in_upstream` では `resolved_at` を更新して、`revision_hash` を再計算します。監査値全体を redaction してから surrogate-safe な最終上限を適用するため、上限境界をまたぐ credential も redaction を回避できません。1件の監査 event の意味を曖昧にしないため、content 編集と lifecycle 遷移は別々の CLI 操作です。 +`SuggestionStore.TryTransitionStatus` は `suggestions update --status ` が使う原子的な手動遷移境界です。`submitted_pending_triage` は GitHub 投稿または明示的な Issue 関連付けで設定され、手動の状態遷移先には指定できません。`open_in_upstream` と `resolved_in_upstream` には既存の upstream 根拠が必要で、`draft` には upstream 根拠がないことが必要です。`wont_fix`、`duplicate`、`superseded` はメンテナーによるローカルの判断です。ローカルの判断は重複提案の自動再送を抑止しますが、`AlreadySubmitted` や upstream 送信済み response flag は設定しません。同じ状態への遷移、および送信 reservation が active な間の遷移は fail closed になります。store は file lock 内で expected revision を再確認し、最新の `previous_status`、UTC の `status_changed_at`、上限・redaction 付きの `status_changed_by`、任意の上限・redaction 付き `status_change_reason` を stamp し、`resolved_in_upstream` では `resolved_at` を更新して、`revision_hash` を再計算します。監査値全体を redaction してから surrogate-safe な最終上限を適用するため、上限境界をまたぐ credential も redaction を回避できません。1件の監査 event の意味を曖昧にしないため、content 編集と lifecycle 遷移は別々の CLI 操作です。 `suggestions list|export --query ` は、NFKC 正規化した query を、redaction 済みの stable ID、sampled title、description、context、evidence path、category、language に対して ordinal・大文字小文字を区別しない部分一致で照合します。照合前に `SuggestionStore.RedactSensitiveText` を適用することは confidentiality contract です。caller は 0 件結果や count の差を使って redaction により除去された値を探索できません。status、時刻、category、language、agent の filter を最初に適用し、次に text query、続いて `CreatedAt` 降順と ordinal stable ID の順で並べてから offset/limit pagination を行います。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ff8f3742b..7f586a818 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -1119,6 +1119,8 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result ## Conventions +Issue #5350 uses `SuggestionStoreTests.Association.cs` and `SuggestionLinkCommandTests` for same-issue consolidation, immutable manual provenance, idempotency, identity/revision conflicts, offline operation, legacy records, no resubmission callbacks, and list/show/export parity. The instance-scoped pre-publication failure seam verifies previous-store preservation and temporary-file cleanup. Run these with suggestion, CLI schema/help and GitHub/MCP submission tests on net8/net9. + - Keep test names descriptive. The current suite mostly uses `Method_Scenario_ExpectedBehavior`. - Keep tests deterministic. Do not depend on machine-global git config, locale-specific output, or ambient files. - Prefer `ManualTimeProvider` for fake clocks and `TestDeterminism.CreateRandom` for randomized fixture input so repeated test runs replay the same timeline and data. Use `TestDeterminism.WaitUntilAsync` or the synchronous `WaitUntil` for bounded polling/eventual assertions instead of local `Task.Delay` loops or fixed sleeps. Use `AssertConditionRemainsTrue` for short absence/stability observations, and `TestDeterminism.RunConcurrentlyAsync` when a test needs workers to start from the same gate. @@ -2482,6 +2484,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## 規約 +Issue #5350 は `SuggestionStoreTests.Association.cs` と `SuggestionLinkCommandTests` で、同一 Issue への統合、手動登録の証跡保持、冪等性、識別情報・revision の競合、オフライン動作、旧レコード、再投稿コールバックの抑止、一覧・詳細・エクスポートの一致を検証します。インスタンス単位の公開直前失敗フックで、以前のストアの保持と一時ファイルの片付けを確認します。提案・CLI スキーマ/ヘルプ・GitHub/MCP 投稿の既存テストとともに net8/net9 で実行してください。 + - テスト名は説明的にする。現在のスイートは `Method_Scenario_ExpectedBehavior` 形式が中心です。 - テストは決定的に保つ。マシン全体の git 設定、ロケール依存出力、外部の残存ファイルに依存しないこと。 - 本番コードが `TimeProvider` を受け取れる場合は `ManualTimeProvider` を使い、fixture data の時刻は wall clock ではなく明示的に進めてください。ランダム入力は `TestDeterminism.CreateRandom` を使い、同じ timeline とデータを再実行できるようにします。境界付きポーリング / 最終的な条件成立のアサーションには、ローカルの `Task.Delay` loop や固定 sleep ではなく `TestDeterminism.WaitUntilAsync` または同期版の `WaitUntil` を使い、短い不在・安定性の観測には `AssertConditionRemainsTrue` を使ってください。ワーカーを同じ gate から開始したい場合は `TestDeterminism.RunConcurrentlyAsync` を使ってください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index b56599954..7a308a862 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -4055,6 +4055,8 @@ requests therefore observe one complete generation, and an older in-flight ### AI Feedback +For an issue already published manually, run `cdidx suggestions link --repo Widthdom/CodeIndex --issue 5350 --db .cdidx/codeindex.db --json`. Repeat for each suggestion consolidated into that issue. This local operation records manual publication without creating another issue or fabricating submission attempts. See [link validation, offline limitations, and state transitions](docs/suggestion-issue-linking.md#english). + Each stored suggestion has an immutable `id` and a mutable `revision_hash`. Editing a draft changes its revision hash without changing the ID, so saved links, abbreviated IDs, `show`, `delete`, and exports keep addressing the same record. When an older store has only `hash`, cdidx adopts that value as the stable ID, computes the current revision, and retains `hash` as a compatibility alias; CLI and MCP JSON expose `id` and `revision_hash` explicitly. Ordinary repository evidence links such as `artifacts/full-dogfood-20260912/FINDINGS.md#d01` survive `suggestions add`, `show`, and JSON/Markdown/issue-draft export. Use a relative path with forward slashes and ordinary word/date components. The recognizer checks the spelling, not file existence; absolute paths, URLs, traversal, control characters, and secret-shaped components are outside this exception. Other path spellings may still be redacted; see the [exact limits](DEVELOPER_GUIDE.md#suggestion-identity-and-revisions). Existing export text-size limits still apply. @@ -4075,7 +4077,7 @@ For full-text triage, add `--query ` to `suggestions list` or `suggestions Large-history automation can select a bounded JSON projection. `--count` returns the authoritative filtered count (a scalar for human `suggestions list`, or a JSON envelope with `--json`); `--summary-only` returns bounded `by_status`, `by_category`, and `by_language` counts without record bodies; and `--compact` returns only `id`, bounded redacted `title`, `status`, and redacted `evidence_paths`. `--summary-only` and `--compact` imply JSON. Add `--max-json-bytes ` to cap the complete UTF-8 JSON document, including its final newline. When the limit removes rows, cdidx removes only whole trailing rows and reports authoritative `total_count`, `returned_count`, `byte_limit_omitted_count`, `truncated`, `next_offset`, and `recovery_guidance`; resume with the reported offset or increase the byte limit. A limit too small for the metadata-only envelope fails without emitting partial JSON. Count, summary, and compact are mutually exclusive, and structured projection flags on `suggestions export` require `--format json`. Row-producing compact and byte-bounded projections require a positive `--limit`; aggregate count and summary modes continue to ignore pagination. -Maintainers can make an explicit audited transition with `cdidx suggestions update --status wont_fix --actor --reason ""`. Manual targets are `draft`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, and `superseded`; `submitted_pending_triage` is reserved for successful GitHub submission. The two upstream states require an existing upstream URL or issue number, while returning to `draft` is allowed only when no upstream reference exists. Local `wont_fix`, `duplicate`, and `superseded` dispositions suppress automatic resubmission of the same suggestion but remain distinguishable from an actual upstream submission. A status transition cannot be combined with content edits, refuses a no-op transition, changes `revision_hash`, and records the latest `previous_status`, `status_changed_at`, `status_changed_by`, and optional `status_change_reason`. Audit text is redacted before its final length cap, and the actor defaults to `cdidx-cli` when omitted. +Maintainers can make an explicit audited transition with `cdidx suggestions update --status wont_fix --actor --reason ""`. Manual targets are `draft`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, and `superseded`; `submitted_pending_triage` is set by successful GitHub submission or explicit issue linking. The two upstream states require an existing upstream URL or issue number, while returning to `draft` is allowed only when no upstream reference exists. Local `wont_fix`, `duplicate`, and `superseded` dispositions suppress automatic resubmission of the same suggestion but remain distinguishable from an actual upstream submission. A status transition cannot be combined with content edits, refuses a no-op transition, changes `revision_hash`, and records the latest `previous_status`, `status_changed_at`, `status_changed_by`, and optional `status_change_reason`. Audit text is redacted before its final length cap, and the actor defaults to `cdidx-cli` when omitted. Markdown and issue-draft exports can be published directly with `--output `, for example `cdidx suggestions export --format markdown --output suggestions.md`. File output is UTF-8 without a BOM, creates missing parent directories, and is capped at 16 MiB; use `--limit` and `--offset` to split larger exports. cdidx refuses an existing destination by default, rejects the selected database and suggestion-store paths including equivalent filesystem aliases, and uses a sibling temporary file plus a same-filesystem publish so a partial payload is never exposed. Pass `--overwrite` to replace an existing destination atomically. JSON-format suggestion exports remain stdout-only; issue-draft output files contain the same JSON object that would otherwise be printed. With `--json`, a successful file export writes a structured summary containing `status`, `format`, `count`, `output_path`, and `bytes` to stdout. @@ -8063,6 +8065,8 @@ handshake を上書きすることもありません(#4540)。 ### AIフィードバック +手動で公開済みの Issue には `cdidx suggestions link --repo Widthdom/CodeIndex --issue 5350 --db .cdidx/codeindex.db --json` で関連付けます。同じ Issue に統合した各提案に対して繰り返してください。このローカル操作は、別の Issue を作成したり投稿試行を捏造したりせず、手動での公開を記録します。[リンクの検証、オフライン時の制約、状態遷移](docs/suggestion-issue-linking.md#日本語)を参照してください。 + 保存済みの各提案は、不変の `id` と可変の `revision_hash` を持ちます。draft を編集すると revision hash だけが変わり、ID は変わらないため、保存済み link、短縮 ID、`show`、`delete`、export は同じ record を参照し続けます。旧 store に `hash` しかない場合、cdidx はその値を stable ID として採用し、現在の revision を計算したうえで、`hash` を互換 alias として維持します。CLI と MCP の JSON は `id` と `revision_hash` を明示します。 `artifacts/full-dogfood-20260912/FINDINGS.md#d01` のような通常のリポジトリ内の証拠リンクは、`suggestions add`・`show`・JSON/Markdown/Issue draft のエクスポートで保持されます。区切りに `/` を使い、通常の単語や日付で構成した相対パスを指定してください。検証するのは文字列の構成であり、ファイルの実在ではありません。絶対パス、URL、親ディレクトリへの移動、制御文字、秘密情報の形式を持つ構成要素は例外対象外です。ほかの表記は引き続き伏字になる場合があります。[具体的な条件](DEVELOPER_GUIDE.md#提案-id-と-revision)を参照してください。エクスポート本文の既存の長さ制限も適用されます。 @@ -8083,7 +8087,7 @@ cdidx には、AI エージェントがギャップや不具合に気づいた 大きな履歴を扱う automation では、上限付き JSON projection を選べます。`--count` は filter 後の authoritative な件数を返します(人間向け `suggestions list` では scalar、`--json` 併用時は JSON envelope)。`--summary-only` は record 本文を含めず、上限付きの `by_status`、`by_category`、`by_language` 件数を返します。`--compact` は `id`、上限・redaction 済み `title`、`status`、redaction 済み `evidence_paths` だけを返します。`--summary-only` と `--compact` は JSON を暗黙に有効化します。`--max-json-bytes ` は末尾改行を含む UTF-8 JSON document 全体を制限します。上限によって row が省略される場合、cdidx は末尾の完全な row だけを取り除き、authoritative な `total_count`、`returned_count`、`byte_limit_omitted_count`、`truncated`、`next_offset`、`recovery_guidance` を返します。報告された offset から再開するか byte 上限を増やしてください。metadata-only envelope にも足りない上限では、partial JSON を出さず失敗します。count、summary、compact は互いに排他的で、`suggestions export` の structured projection flag は `--format json` を必要とします。row を返す compact / byte 上限付き projection の `--limit` は正数でなければならず、aggregate の count / summary mode は pagination を引き続き無視します。 -メンテナーは `cdidx suggestions update --status wont_fix --actor --reason ""` で、監査情報付きの明示的な状態遷移を実行できます。手動で指定できる遷移先は `draft`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` です。`submitted_pending_triage` は GitHub 送信成功時だけ自動設定されます。upstream の2状態には既存の upstream URL または Issue 番号が必要で、`draft` に戻せるのは upstream 参照がない場合だけです。ローカルの `wont_fix`、`duplicate`、`superseded` は同じ提案の自動再送を抑止しますが、実際の upstream 送信済み状態とは区別されます。状態遷移は content 編集と同時指定できず、同じ状態への遷移を拒否し、`revision_hash` を更新して、最新の `previous_status`、`status_changed_at`、`status_changed_by`、任意の `status_change_reason` を記録します。監査テキストは最終的な長さ制限より前に redaction され、`--actor` を省略した場合は `cdidx-cli` です。 +メンテナーは `cdidx suggestions update --status wont_fix --actor --reason ""` で、監査情報付きの明示的な状態遷移を実行できます。手動で指定できる遷移先は `draft`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` です。`submitted_pending_triage` は GitHub 送信成功時または明示的な Issue 関連付け時に設定されます。upstream の2状態には既存の upstream URL または Issue 番号が必要で、`draft` に戻せるのは upstream 参照がない場合だけです。ローカルの `wont_fix`、`duplicate`、`superseded` は同じ提案の自動再送を抑止しますが、実際の upstream 送信済み状態とは区別されます。状態遷移は content 編集と同時指定できず、同じ状態への遷移を拒否し、`revision_hash` を更新して、最新の `previous_status`、`status_changed_at`、`status_changed_by`、任意の `status_change_reason` を記録します。監査テキストは最終的な長さ制限より前に redaction され、`--actor` を省略した場合は `cdidx-cli` です。 Markdown と issue draft は `--output ` で直接ファイルへ公開できます。たとえば `cdidx suggestions export --format markdown --output suggestions.md` です。ファイル出力は BOM なし UTF-8 で、不足している親ディレクトリを作成し、16 MiB に制限されます。より大きい export は `--limit` と `--offset` で分割してください。既存の出力先は既定で拒否し、同じファイルを指す filesystem alias を含め、選択中の database または suggestion store への出力も拒否します。兄弟一時ファイルから同一 filesystem 上で公開するため、不完全な payload は見えません。既存ファイルを原子的に置換する場合だけ `--overwrite` を指定します。JSON 形式の suggestion export は従来どおり stdout 専用で、issue-draft 出力ファイルには stdout に出す場合と同じ JSON object が入ります。`--json` を指定したファイル出力の成功時は、`status`、`format`、`count`、`output_path`、`bytes` を含む構造化 summary を stdout に出します。 diff --git a/changelog.d/unreleased/5350.added.md b/changelog.d/unreleased/5350.added.md new file mode 100644 index 000000000..6d07adc18 --- /dev/null +++ b/changelog.d/unreleased/5350.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 5350 +affected: + - src/CodeIndex/Cli/SuggestionsCommandRunner.Link.cs + - src/CodeIndex/Cli/SuggestionStore.Association.cs + - src/CodeIndex/Models/SuggestionRecord.cs + - docs/suggestion-issue-linking.md +--- + +## English + +- **Link manually published issues to local suggestions (#5350)** — `suggestions link --repo --issue ` atomically records an existing GitHub issue with manual provenance and a UTC audit timestamp, without network verification or issue creation. Multiple suggestions can share one issue; identical repeats preserve history and conflicting links are rejected. List/show/export and duplicate submission recognize the association without fabricating submission attempts or implementation completion. + +## 日本語 + +- **手動公開した Issue をローカル提案に関連付けられるようになりました (#5350)** — `suggestions link --repo --issue ` は、リモート検証や Issue 作成を行わず、既存 GitHub Issue、手動登録の出所、UTC の監査日時を原子的に保存します。複数提案を1つの Issue に関連付けられ、同一の再実行は履歴を維持し、競合するリンクは拒否します。一覧・詳細・エクスポート・重複投稿は関連付けを認識し、投稿試行や実装完了を捏造しません。 diff --git a/docs/suggestion-issue-linking.md b/docs/suggestion-issue-linking.md new file mode 100644 index 000000000..e38ee97a1 --- /dev/null +++ b/docs/suggestion-issue-linking.md @@ -0,0 +1,109 @@ +# Linking existing issues to suggestions + +## English + +After publishing a finding manually, associate it with its existing GitHub issue: + +```sh +cdidx suggestions link --repo Widthdom/CodeIndex --issue 5350 --actor maintainer --db .cdidx/codeindex.db --json +cdidx suggestions link --repo Widthdom/CodeIndex --issue https://github.com/Widthdom/CodeIndex/issues/5350 --db .cdidx/codeindex.db --json +``` + +Each command atomically links one record. Repeat it for findings consolidated into +one issue. A full ID or unambiguous prefix is accepted. `--actor` defaults to +`cdidx-cli`; `--reason` supplies optional audit context. Both are redacted and +bounded before persistence (256 and 2,048 characters respectively). + +The operation is local and works offline without GitHub credentials. It never +creates an issue or verifies one remotely. It accepts an ASCII `owner/name` +repository and an integer from 1 through 2,147,483,647, or the matching canonical +`https://github.com/owner/name/issues/number` URL. Repository casing is normalized. +Other hosts, PR URLs, credentials, ports, escaped/traversal paths, leading-zero +numbers, query strings, fragments and trailing slashes are rejected. Existence, +issue-versus-PR identity (including for numeric input), and current open/closed +state remain unverified; check those independently before linking. GitHub +Enterprise hosts and remote verification are not supported by this operation. + +| Evidence | Meaning after a new link | +|---|---| +| `upstream_url`, `upstream_issue_number` | The operator-supplied existing issue identity. | +| `upstream_association` | Canonical `repository`, UTC `linked_at`, `linked_by`, optional `reason`, `provenance: manual_external`, and `verification: not_performed`. This audit survives later status transitions. | +| `status` | A draft becomes `submitted_pending_triage`: published according to the operator, awaiting triage. Other states are preserved; linking never marks implementation complete. | +| `submitted_to_github`, `--status submitted` | Compatibility indicators of upstream publication, including external publication. They do not prove a cdidx submission attempt. | +| Submission/sync/resolution fields | Actual attempt count, last attempt/error, `last_synced_at` and `resolved_at` are preserved. No attempt, sync or resolution is invented; a scheduled retry is cleared. | + +Repeating the same complete identity succeeds without rewriting history, changing +the original audit timestamp, or relabelling a historical cdidx submission as +external. Different or incomplete existing identities produce +`upstream_association_conflict`; reassociation is not supported. Submission +reservations and stale revisions are checked under the existing store lock. +Failed atomic publication leaves the previous store intact. + +Full list/show/JSON exports include the association; human show and Markdown +export also display its provenance. Compact lists retain the lifecycle status; +use `show --json` for the full identity and audit. `--status unsubmitted` excludes +linked records. Issue-draft exports retain the records, expose the identity in +`source`, set `duplicate_preflight.already_published: true` with the upstream +identity, and explicitly say not to file again. This local evidence does not +turn an unchecked remote duplicate preflight into a checked one. Subsequent +duplicate submissions through CLI/MCP store callers return the existing issue +without invoking the issue-creation callback, even for multiple suggestions +sharing one issue. Existing retention limits still apply to local history. + +Older stores remain readable. Linking touches only the requested suggestion's +association; context text is never scraped to infer publication. Use the separate +`suggestions update --status open_in_upstream|resolved_in_upstream` operation +after independently checking the upstream state. + +## 日本語 + +手動で公開した指摘は、既存の GitHub Issue に明示的に関連付けます。 + +```sh +cdidx suggestions link --repo Widthdom/CodeIndex --issue 5350 --actor maintainer --db .cdidx/codeindex.db --json +cdidx suggestions link --repo Widthdom/CodeIndex --issue https://github.com/Widthdom/CodeIndex/issues/5350 --db .cdidx/codeindex.db --json +``` + +1回のコマンドで1件を原子的に関連付けます。複数の指摘を1つの Issue に統合した場合は、 +各 ID に対して繰り返してください。完全な ID または一意な接頭辞を指定できます。 +`--actor` の既定値は `cdidx-cli`、`--reason` は任意の監査理由です。 +両者は保存前に伏字化され、それぞれ256文字、2,048文字に制限されます。 + +この操作はローカルで完結し、GitHub の認証情報がないオフライン環境でも使えます。 +Issue の新規作成やリモート検証は行いません。ASCII の `owner/name` と +1〜2,147,483,647の整数、または同じリポジトリの標準形式 +`https://github.com/owner/name/issues/number` URL を受け付けます。 +リポジトリの大文字・小文字は正規化します。他のホスト、PR URL、認証情報、ポート、 +エスケープや親ディレクトリ参照を含むパス、先頭が0の番号、クエリ文字列、フラグメント、 +末尾のスラッシュは拒否します。存在確認、Issue と PR の区別(番号指定の場合も含む)、 +現在のオープン・クローズ状態は未検証です。関連付け前に別途確認してください。 +この操作は GitHub Enterprise のホストとリモート検証には対応していません。 + +| 証跡 | 新規関連付け後の意味 | +|---|---| +| `upstream_url`、`upstream_issue_number` | 操作者が指定した既存 Issue の識別情報。 | +| `upstream_association` | 正規化した `repository`、UTC の `linked_at`、`linked_by`、任意の `reason`、`provenance: manual_external`、`verification: not_performed`。後の状態変更でもこの監査証跡を保持します。 | +| `status` | 下書きは `submitted_pending_triage` になります。操作者が公開済みと申告し、トリアージを待つ状態です。他の状態は維持し、関連付けによって実装完了にはしません。 | +| `submitted_to_github`、`--status submitted` | 外部での公開を含む互換用の公開済み表示です。cdidx による投稿試行を証明しません。 | +| 投稿・同期・解決フィールド | 実際の試行回数、最終試行・エラー、`last_synced_at`、`resolved_at` を維持します。試行・同期・解決を捏造せず、再試行予約だけを解除します。 | + +同じ完全な識別情報で再実行した場合は成功し、履歴や最初の監査日時を更新しません。 +過去の cdidx 投稿を外部投稿として記録し直すこともありません。異なる、または不完全な +既存識別情報には `upstream_association_conflict` を返します。付け替えには対応しません。 +既存のストアロック内で投稿中の予約と古い revision を検査します。 +原子的な公開処理が失敗した場合、以前のストアを保持します。 + +通常の一覧・詳細・JSON エクスポートには関連付け情報が含まれ、人間向け詳細表示と +Markdown エクスポートにも出所を表示します。簡略一覧は状態を保持します。 +完全な識別情報と監査証跡には `show --json` を使ってください。 +`--status unsubmitted` は関連付け済みレコードを除外します。Issue draft のエクスポートは +レコードを保持し、`source` に識別情報を含め、`duplicate_preflight.already_published: true` +と upstream 識別情報を出力して、再起票しないよう明記します。このローカル証跡によって +未実行のリモート重複確認を確認済みにはしません。CLI/MCP のストア利用側が同じ提案を +再送しても、Issue 作成コールバックを呼ばず既存 Issue を返します。複数の提案を +同じ Issue に関連付けた場合も同様です。ローカル履歴の既存の保持制限は適用されます。 + +旧ストアも引き続き読み取れます。関連付け対象は指定した提案だけで、文脈テキストから +公開を推測して変更することはありません。upstream の状態を別途確認した後に、 +独立した `suggestions update --status open_in_upstream|resolved_in_upstream` +操作で状態を変更してください。 diff --git a/src/CodeIndex/Cli/CliCommandMetadata.cs b/src/CodeIndex/Cli/CliCommandMetadata.cs index 15a303798..af78fa54c 100644 --- a/src/CodeIndex/Cli/CliCommandMetadata.cs +++ b/src/CodeIndex/Cli/CliCommandMetadata.cs @@ -35,7 +35,7 @@ internal static class CliCommandMetadata ("db", ReadOnly("integrity", "schema", "prune", "checkpoint", "checkpoints", "restore", "restore-backups")), ("recipes", ReadOnly("list")), ("audit", ReadOnly("baseline-export", "baseline-compare", "baseline-review")), - ("suggestions", ReadOnly("list", "show", "export", "add", "update", "delete")), + ("suggestions", ReadOnly("list", "show", "export", "add", "update", "link", "delete")), ("export", ReadOnly("ctags")), ]); diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index d314e8c49..f9c4151df 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -631,6 +631,16 @@ private static IReadOnlyList BuildAll() new() { Name = "--open-issues", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, SupplementalCompletionValues = ["github"], Description = "Preflight issue drafts against issue JSON or GitHub issues", PrimaryCommands = Set("search", "map", "suggestions") }, new() { Name = "--repo", ValuePlaceholder = "", ValueKind = CliOptionValueKind.Repository, Description = "Issue-drafts: GitHub repository for --open-issues github", PrimaryCommands = Set("search", "map", "suggestions") }, new() { Name = "--issue-state", ValueDomain = Values(["open", "closed", "all"]), Description = "Issue-drafts: GitHub issue history state to inspect", PrimaryCommands = Set("search", "map", "suggestions") }, + new() + { + Name = "--issue", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FreeText, + Description = "Suggestions link: existing GitHub issue number or URL; never creates an issue", + PrimaryCommands = Set("suggestions"), + CompletionSubcommands = new Dictionary>(StringComparer.Ordinal) + { + ["suggestions"] = Set("link"), + }, + }, new() { Name = "--duplicate-confidence", ValueDomain = Values(["low", "medium", "high"]), Description = "Issue-drafts: preset duplicate-preflight match threshold", PrimaryCommands = Set("search", "suggestions") }, new() { Name = "--duplicate-threshold", ValuePlaceholder = "", Description = "Issue-drafts: explicit duplicate-preflight minimum score from 0 to 1", PrimaryCommands = Set("search", "suggestions") }, new() { Name = "--issue-title", ValuePlaceholder = "", Description = "Search issue-drafts: override the title for an ad hoc search draft", PrimaryCommands = Set("search") }, @@ -652,8 +662,8 @@ private static IReadOnlyList<CliFlag> BuildAll() }, new() { Name = "--category", ValueDomain = Values(SuggestionRecord.ValidCategories), Description = "Suggestions: filter by category", PrimaryCommands = Set("suggestions") }, new() { Name = "--agent", ValuePlaceholder = "<agent>", Description = "Suggestions: filter by agent", PrimaryCommands = Set("suggestions") }, - new() { Name = "--actor", ValuePlaceholder = "<name>", Description = "Suggestions update: actor recorded for a manual status transition", PrimaryCommands = Set("suggestions") }, - new() { Name = "--reason", ValuePlaceholder = "<text>", Description = "Suggestions update: optional reason recorded for a manual status transition", PrimaryCommands = Set("suggestions") }, + new() { Name = "--actor", ValuePlaceholder = "<name>", Description = "Suggestions update/link: actor recorded for a manual operation", PrimaryCommands = Set("suggestions") }, + new() { Name = "--reason", ValuePlaceholder = "<text>", Description = "Suggestions update/link: optional reason recorded for a manual operation", PrimaryCommands = Set("suggestions") }, new() { Name = "--description", ValuePlaceholder = "<text>", Description = "Suggestions add: local suggestion description", PrimaryCommands = Set("suggestions") }, new() { Name = "--title", ValuePlaceholder = "<title>", Description = "Suggestions add: optional issue-draft title source", PrimaryCommands = Set("suggestions") }, new() { Name = "--evidence-path", ValuePlaceholder = "<path>", ValueKind = CliOptionValueKind.FilePath, Description = "Suggestions add: repository-relative evidence path; repeat for multiple paths", PrimaryCommands = Set("suggestions") }, @@ -906,6 +916,11 @@ public static IReadOnlySet<string> GetAcceptedFlagNamesForCommand(string command /// </summary> public static IReadOnlyList<CliFlag> GetCompletionFlagsForCommand(string command, string? subcommand = null) { + if (command == "suggestions" && subcommand == "link") + { + var names = Set("--repo", "--issue", "--actor", "--reason", "--db", "--json"); + return All.Where(flag => flag.AppliesToCompletionContext(command, subcommand) && names.Contains(flag.Name)).ToList(); + } if (command == "audit" && subcommand is "baseline-export" or "baseline-compare" or "baseline-review") { var names = subcommand == "baseline-review" diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 09e181b64..a41c73248 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -137,7 +137,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("deps", "cdidx deps [--db <path>] [--json] [--format <dot|graphml|json-graph|edgelist>] [--summary-only] [--max-json-bytes <n>] [--verbose] [--limit <n>|--top <n>] [--cursor <cursor>] [--graph-budget <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--reverse] [--cycles] [--group-partial-types] [--node-mappings] [--cycle-node <id>] [--node-generation <token>] [--mapping-cursor <token>] [--suppress-noise] [--resolution-state <state[,state]>] [--reference-kind <kind[,kind]>] [--symbol <name>] [--symbol-family <prefix>]"), ("unused", "cdidx unused [--analysis-timeout-ms <1..600000>] [--progress] [--db <path>] [--json] [--compact] [--summary-only] [--max-json-bytes <n>] [--verbose] [--limit <n>|--top <n>] [--cursor <next_cursor>] [--audit-scope <source|production-and-tooling|all>] [--kind <kind>] [--bucket <bucket>] [--min-confidence <medium|low>|--confidence <medium|low>] [--actionable] [--all] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--by-bucket]"), ("hotspots", "cdidx hotspots [--db <path>] [--json] [--format <text|json|count|compact>] [--compact] [--fields <csv>] [--cursor <next_cursor>] [--summary-only] [--max-json-bytes <n>] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--group-by <symbol|file|statement>] [--group-by-name]"), - ("suggestions", "cdidx suggestions [list|show|export|add|update|delete] [id|description] [--db <path>] [--json] [--description <text>] [--context <text>] [--title <text>] [--evidence-path <path>] [--status <all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>] [--actor <name>] [--reason <text>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--query <text>] [--count|--summary-only|--compact] [--max-json-bytes <n>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--output <path>] [--overwrite] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-state <open|closed|all>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>]"), + ("suggestions", "cdidx suggestions [list|show|export|add|update|link|delete] [id|description] [--db <path>] [--json] [--description <text>] [--context <text>] [--title <text>] [--evidence-path <path>] [--status <all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>] [--actor <name>] [--reason <text>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--query <text>] [--count|--summary-only|--compact] [--max-json-bytes <n>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--output <path>] [--overwrite] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue <number-or-url>] [--issue-state <open|closed|all>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>]"), ("suggestions-list", "cdidx suggestions list [--status <state>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--query <text>] [--count|--summary-only|--compact] [--max-json-bytes <n>] [--limit <n>] [--offset <n>] [--db <path>] [--json]"), ("suggestions-show", "cdidx suggestions show <id> [--status <state>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--db <path>] [--json]"), ("suggestions-export", "cdidx suggestions export [--format <json|markdown|issue-drafts>] [--status <state>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--query <text>] [--count|--summary-only|--compact] [--max-json-bytes <n>] [--limit <n>] [--offset <n>] [--output <path> [--overwrite]] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-state <open|closed|all>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--db <path>] [--json]"), @@ -145,6 +145,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("suggestions-update", "cdidx suggestions update <id> [--description <text>] [--context <text>] [--title <text>] [--evidence-path <path>] [--category <value>] [--language <lang>] [--agent <name>] [--db <path>] [--json]"), ("suggestions-update", "cdidx suggestions update <id> --status <draft|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded> [--actor <name>] [--reason <text>] [--db <path>] [--json]"), ("suggestions-delete", "cdidx suggestions delete <id> [--db <path>] [--json]"), + ("suggestions-link", "cdidx suggestions link <id> --repo <owner/name> --issue <number-or-url> [--actor <name>] [--reason <text>] [--db <path>] [--json]"), ("export", "cdidx export <archive> [--db <path>] [--json] [--overwrite] [--redact-paths] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--project <name|path>] [--solution <path>] [--exclude-tests]"), ("export", "cdidx export ctags [--output <path>] [--db <path>] [--json] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--include-generated]"), ("export-ctags", "cdidx export ctags [--output <path>] [--db <path>] [--json] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--include-generated]"), @@ -263,10 +264,14 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = ("suggestions-export", "Example: `cdidx suggestions export --format issue-drafts --open-issues github:owner/repo --issue-state open --output drafts.json`."), ("suggestions-add", "Writes one local draft to the selected suggestion store; normalized category, language, and description duplicates succeed without adding another record."), ("suggestions-add", "Example: `cdidx suggestions add \"Improve macro handling\" --category language_support --language rust --json`."), - ("suggestions-update", "Content edits require at least one content-edit flag and mutate only an editable local draft. Status transitions are a separate form, may update records with upstream references according to lifecycle rules, and cannot be combined with content edits; submitted_pending_triage is managed by GitHub submission."), + ("suggestions-update", "Content edits require at least one content-edit flag and mutate only an editable local draft. Status transitions are a separate form, may update records with upstream references according to lifecycle rules, and cannot be combined with content edits; submitted_pending_triage is managed by GitHub submission or explicit issue linking."), ("suggestions-update", "Example: `cdidx suggestions update <id> --status wont_fix --reason \"Not actionable\" --json`."), ("suggestions-delete", "Deletes only an editable local draft and accepts no query, export, or content-edit options."), ("suggestions-delete", "Example: `cdidx suggestions delete <id> --json`."), + ("suggestions-link", "Locally associates an existing github.com issue; repeat for multiple suggestions consolidated into one issue. No network access or remote issue creation occurs, even offline; existence, issue-vs-PR identity for numeric input, and remote state remain unverified."), + ("suggestions-link", "Requires a matching owner/name repository and a positive issue number or https://github.com/owner/name/issues/number URL. Other hosts, PR URLs, query strings and fragments are rejected. Same-identity repeats preserve audit timestamps; conflicting reassociation is rejected."), + ("suggestions-link", "Stores manual_external provenance and an audit timestamp atomically. Drafts become submitted_pending_triage; other lifecycle states and real submission/sync/resolution evidence are preserved. Linked records are excluded by --status unsubmitted and cannot be submitted again."), + ("suggestions-link", "Example: `cdidx suggestions link <id> --repo Widthdom/CodeIndex --issue 5350 --actor maintainer --json`."), ("export-ctags", "Writes a ctags file without changing the index, defaulting to `tags` in the current directory when --output is omitted and replacing an existing destination; generated files are excluded unless --include-generated is set."), ("export-ctags", "Example: `cdidx export ctags --output tags --exclude-tests`."), ]; @@ -296,6 +301,7 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = "suggestions-export", "suggestions-add", "suggestions-update", + "suggestions-link", "suggestions-delete", "export-ctags", }; diff --git a/src/CodeIndex/Cli/SuggestionStore.Association.cs b/src/CodeIndex/Cli/SuggestionStore.Association.cs new file mode 100644 index 000000000..bf8fb9b47 --- /dev/null +++ b/src/CodeIndex/Cli/SuggestionStore.Association.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public partial class SuggestionStore +{ + internal Action<string>? ValidateAssociationWriteForTesting { get; set; } + + /// <summary> + /// Atomically associates an existing issue without any network access or submission attempt. + /// Existing associations cannot be replaced; repeating the same identity is a no-op. + /// ネットワークアクセスや投稿試行を行わず、既存 Issue を原子的に関連付ける。 + /// 既存の関連付けは置換できず、同一の識別情報による再実行は何も変更しない。 + /// </summary> + public MutationResult TryLinkIssue( + string id, + string expectedRevisionHash, + string repository, + string issue, + string? linkedBy, + string? reason, + out SuggestionRecord? updated) + { + updated = null; + if (!TryParseIssueIdentity(repository, issue, out var identity)) + return MutationResult.InvalidAssociation; + + SuggestionRecord? result = null; + var mutationResult = WithFileLock(() => + { + var records = ReadUnlocked(); + var index = records.FindIndex(record => string.Equals(record.Id, id, StringComparison.Ordinal)); + if (index < 0) + return MutationResult.NotFound; + var record = records[index]; + if (IsSubmissionInFlight(record)) + return MutationResult.SubmissionInFlight; + + if (record.UpstreamAssociation != null || record.UpstreamIssueNumber != null || !string.IsNullOrWhiteSpace(record.UpstreamUrl)) + { + if (!TryParseIssueIdentity(identity!.Repository, record.UpstreamUrl, out var existing) + || existing != identity + || (record.UpstreamIssueNumber != null && record.UpstreamIssueNumber != identity.Number) + || (record.UpstreamAssociation != null + && !string.Equals(record.UpstreamAssociation.Repository, identity.Repository, StringComparison.OrdinalIgnoreCase))) + return MutationResult.AssociationConflict; + + result = record; + return MutationResult.Success; + } + if (!string.Equals(record.RevisionHash, expectedRevisionHash, StringComparison.Ordinal)) + return MutationResult.RevisionConflict; + + var actor = RedactAndBoundAuditValue(linkedBy, MaxStatusChangedByLength, out var actorTypes) ?? "cdidx-cli"; + var boundedReason = RedactAndBoundAuditValue(reason, MaxStatusChangeReasonLength, out var reasonTypes); + var redactedTypes = actorTypes.Concat(reasonTypes).Distinct(StringComparer.Ordinal).ToArray(); + if (redactedTypes.Length > 0) + WriteRedactionWarning(redactedTypes); + + var linkedAt = GetUtcNow(); + record.UpstreamUrl = identity!.Url; + record.UpstreamIssueNumber = identity.Number; + record.UpstreamAssociation = new(identity.Repository, linkedAt, actor, boundedReason); + if (record.Status == SuggestionStatus.Draft) + { + record.PreviousStatus = record.Status; + record.Status = SuggestionStatus.SubmittedPendingTriage; + record.StatusChangedAt = linkedAt; + record.StatusChangedBy = actor; + record.StatusChangeReason = "Existing GitHub issue linked manually; remote state was not verified."; + } + // Preserve actual attempt/error/sync/resolution evidence. Only cancel future retries. + // 実際の試行・エラー・同期・解決の証跡は維持し、今後の再試行予約だけを解除する。 + record.NextRetryAt = null; + SaveUnlocked(records, ValidateAssociationWriteForTesting); + result = record; + return MutationResult.Success; + }); + updated = result; + return mutationResult; + } + + internal static bool IsValidIssueAssociation(string? repository, string? issue) + => TryParseIssueIdentity(repository, issue, out _); + + private sealed record IssueIdentity(string Repository, int Number) + { + public string Url => $"https://github.com/{Repository}/issues/{Number.ToString(CultureInfo.InvariantCulture)}"; + } + + private static bool TryParseIssueIdentity(string? repository, string? issue, out IssueIdentity? identity) + { + identity = null; + if (repository == null || repository.Length > 140 || issue == null || issue.Length > 2048) + return false; + var parts = repository.Split('/'); + if (parts.Length != 2 + || parts[0].Length is < 1 or > 39 + || !char.IsAsciiLetterOrDigit(parts[0][0]) + || !char.IsAsciiLetterOrDigit(parts[0][^1]) + || parts[0].Contains("--", StringComparison.Ordinal) + || !parts[0].All(c => char.IsAsciiLetterOrDigit(c) || c == '-') + || parts[1].Length is < 1 or > 100 + || parts[1] is "." or ".." + || !parts[1].All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-')) + return false; + + var normalizedRepository = repository.ToLowerInvariant(); + var numberText = issue; + if (!issue.All(char.IsAsciiDigit)) + { + // Validate the original spelling so URI normalization cannot hide + // dot segments, escaped paths, credentials, ports, queries or PR URLs. + // URI 正規化で相対要素・エスケープ・認証情報・ポート・クエリ・PR URL を受理しない。 + var prefix = $"https://github.com/{repository}/issues/"; + if (!issue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + numberText = issue[prefix.Length..]; + } + if (numberText.Length == 0 || numberText.Length > 10 || numberText[0] == '0' + || !numberText.All(char.IsAsciiDigit) + || !int.TryParse(numberText, NumberStyles.None, CultureInfo.InvariantCulture, out var number) + || number <= 0) + return false; + identity = new(normalizedRepository, number); + return true; + } +} diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 4e08bc6fc..da7230c2d 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -19,7 +19,7 @@ namespace CodeIndex.Cli; /// 全ての read-modify-write 操作はファイルロックでシリアライズされ、 /// 並行書き込み者が互いの変更をサイレントに上書きすることを防ぐ。 /// </summary> -public class SuggestionStore +public partial class SuggestionStore { internal const int MaxStatusChangedByLength = 256; internal const int MaxStatusChangeReasonLength = 2048; @@ -166,6 +166,15 @@ public static string ComputeRevisionHash(SuggestionRecord record) AppendRevisionValue(normalized, record.StatusChangedAt?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); AppendRevisionValue(normalized, record.StatusChangedBy); AppendRevisionValue(normalized, record.StatusChangeReason); + if (record.UpstreamAssociation is { } association) + { + AppendRevisionValue(normalized, record.UpstreamUrl); + AppendRevisionValue(normalized, record.UpstreamIssueNumber?.ToString(CultureInfo.InvariantCulture)); + AppendRevisionValue(normalized, association.Repository); + AppendRevisionValue(normalized, association.LinkedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); + AppendRevisionValue(normalized, association.LinkedBy); + AppendRevisionValue(normalized, association.Reason); + } var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized.ToString())); return HexEncoding.ToLowerHexString(hashBytes); } @@ -231,6 +240,8 @@ public enum MutationResult SubmissionInFlight, RevisionConflict, InvalidTransition, + InvalidAssociation, + AssociationConflict, } /// <summary> @@ -1067,14 +1078,19 @@ private static DateTimeOffset ToUtcOffset(DateTime value) /// write または rename が失敗した場合、一時ファイルをベストエフォートで削除して /// <c>.cdidx/</c> に孤児 <c>.tmp</c> が蓄積するのを防ぐ。 /// </summary> - private void SaveUnlocked(List<SuggestionRecord> records) + private void SaveUnlocked(List<SuggestionRecord> records, Action<string>? validateBeforePublish = null) { var dir = Path.GetDirectoryName(_filePath); if (!string.IsNullOrEmpty(dir)) DataDirectorySecurity.CreateSensitiveDirectory(dir); NormalizeRecordDefaults(records); - AtomicFileWriter.WriteJson(_filePath, records, s_jsonOptions, AtomicFileWriter.WriteProfile.Sensitive); + if (validateBeforePublish == null) + AtomicFileWriter.WriteJson(_filePath, records, s_jsonOptions, AtomicFileWriter.WriteProfile.Sensitive); + else + AtomicFileWriter.WriteWithPrePublishValidation(_filePath, + stream => JsonSerializer.Serialize(stream, records, s_jsonOptions), + AtomicFileWriter.WriteProfile.Sensitive, overwrite: true, validateBeforePublish); } private static bool HasUpstreamSubmission(SuggestionRecord record) => @@ -1395,6 +1411,7 @@ private static string BuildChangedDuringSubmissionError( EvidencePaths = record.EvidencePaths?.ToArray(), UpstreamIssueNumber = record.UpstreamIssueNumber, UpstreamUrl = record.UpstreamUrl, + UpstreamAssociation = record.UpstreamAssociation, LastSyncedAt = record.LastSyncedAt, LastSubmitError = record.LastSubmitError, LastSubmitAttempt = record.LastSubmitAttempt, diff --git a/src/CodeIndex/Cli/SuggestionsCommandRunner.Link.cs b/src/CodeIndex/Cli/SuggestionsCommandRunner.Link.cs new file mode 100644 index 000000000..3955cacde --- /dev/null +++ b/src/CodeIndex/Cli/SuggestionsCommandRunner.Link.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +namespace CodeIndex.Cli; + +internal static partial class SuggestionsCommandRunner +{ + private static int RunLink(SuggestionStore store, Options options, JsonSerializerOptions jsonOptions) + { + if (string.IsNullOrWhiteSpace(options.Id)) + return WriteUsageError("suggestions link requires an id.", options.Json, jsonOptions); + if (options.HasContentEditableFields || options.HasQueryOnlyOptionsExceptStatusAndRepository || options.StatusSpecified) + return WriteUsageError("suggestions link accepts only an id, --repo, --issue, --actor, --reason, --db, and --json.", options.Json, jsonOptions); + if (!SuggestionStore.IsValidIssueAssociation(options.OpenIssuesRepository, options.Issue)) + return WriteUsageError( + "suggestions link requires --repo <owner/name> and --issue <positive-number|https://github.com/owner/name/issues/number>. The URL must match --repo; PR URLs, other hosts, query strings and fragments are not accepted.", + options.Json, jsonOptions); + if (options.ActorSpecified && string.IsNullOrWhiteSpace(options.Actor)) + return WriteUsageError("--actor must not be empty.", options.Json, jsonOptions); + if (options.ReasonSpecified && string.IsNullOrWhiteSpace(options.Reason)) + return WriteUsageError("--reason must not be empty.", options.Json, jsonOptions); + + var record = ResolveById(store.LoadAll(), options.Id); + if (record == null) + return WriteMutationNotFound(options, jsonOptions); + var result = store.TryLinkIssue(record.Id, record.RevisionHash, options.OpenIssuesRepository!, options.Issue!, + options.Actor, options.Reason, out var updated); + return result switch + { + SuggestionStore.MutationResult.Success => WriteMutationSuccess("linked", updated!, options, jsonOptions), + SuggestionStore.MutationResult.SubmissionInFlight => WriteMutationSubmissionInFlight(options, jsonOptions), + SuggestionStore.MutationResult.RevisionConflict => WriteMutationRevisionConflict(options, jsonOptions), + SuggestionStore.MutationResult.AssociationConflict => CommandErrorWriter.WriteJsonOrHuman( + options.Json, jsonOptions, "Suggestion already has a different or incomplete upstream identity; the existing association was preserved.", + CommandExitCodes.UsageError, "Inspect the existing identity with `cdidx suggestions show <id> --json`. Reassociation is not supported.", + category: "upstream_association_conflict"), + _ => WriteMutationNotFound(options, jsonOptions), + }; + } +} diff --git a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs index 826520dd7..2420229bc 100644 --- a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs +++ b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs @@ -11,7 +11,7 @@ namespace CodeIndex.Cli; internal static partial class SuggestionsCommandRunner { - private const string Usage = "Usage: cdidx suggestions [list|show|export|add|update|delete] [id|description] [--db <path>] [--json] [--description <text>] [--context <text>] [--title <text>] [--evidence-path <path>] [--status <all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>] [--actor <name>] [--reason <text>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--query <text>] [--count|--summary-only|--compact] [--max-json-bytes <n>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--output <path>] [--overwrite] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-state <open|closed|all>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>]"; + private const string Usage = "Usage: cdidx suggestions [list|show|export|add|update|link|delete] [id|description] [--db <path>] [--json] [--description <text>] [--context <text>] [--title <text>] [--evidence-path <path>] [--status <all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>] [--actor <name>] [--reason <text>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--query <text>] [--count|--summary-only|--compact] [--max-json-bytes <n>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--output <path>] [--overwrite] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue <number-or-url>] [--issue-state <open|closed|all>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>]"; internal const int MaxOpenIssuesJsonBytes = IssueDuplicatePreflight.MaxOpenIssuesJsonBytes; internal const int MaxOpenIssuesJsonDepth = IssueDuplicatePreflight.MaxOpenIssuesJsonDepth; internal const int MaxSuggestionExportTextFieldLength = 4096; @@ -126,8 +126,10 @@ public static int Run( return WriteUsageError("--duplicate-confidence and --duplicate-threshold can only be used with `suggestions export --format issue-drafts`.", options.Json, jsonOptions); if (options.OpenIssuesPath != null && (verb != "export" || options.ExportFormat != "issue-drafts")) return WriteUsageError("--open-issues can only be used with `suggestions export --format issue-drafts`.", options.Json, jsonOptions); - if (options.OpenIssuesRepository != null && (verb != "export" || options.ExportFormat != "issue-drafts")) - return WriteUsageError("--repo can only be used with `suggestions export --format issue-drafts --open-issues github`.", options.Json, jsonOptions); + if (options.OpenIssuesRepository != null && verb != "link" && (verb != "export" || options.ExportFormat != "issue-drafts")) + return WriteUsageError("--repo can only be used with `suggestions link` or `suggestions export --format issue-drafts --open-issues github`.", options.Json, jsonOptions); + if (options.Issue != null && verb != "link") + return WriteUsageError("--issue can only be used with `suggestions link`.", options.Json, jsonOptions); if (options.IssueStateSpecified && (verb != "export" || options.ExportFormat != "issue-drafts" || !IssueDuplicatePreflight.IsGitHubOpenIssuesSource(options.OpenIssuesPath))) return WriteUsageError("--issue-state can only be used with `suggestions export --format issue-drafts --open-issues github`.", options.Json, jsonOptions); @@ -139,8 +141,8 @@ public static int Run( return WriteUsageError("--output can only be used with `suggestions export --format markdown` or `suggestions export --format issue-drafts`.", options.Json, jsonOptions); if (options.Overwrite && options.OutputPath == null) return WriteUsageError("--overwrite requires --output <path>.", options.Json, jsonOptions); - if ((options.ActorSpecified || options.ReasonSpecified) && (verb != "update" || !options.StatusSpecified)) - return WriteUsageError("--actor and --reason can only be used with `suggestions update <id> --status <state>`.", options.Json, jsonOptions); + if ((options.ActorSpecified || options.ReasonSpecified) && verb != "link" && (verb != "update" || !options.StatusSpecified)) + return WriteUsageError("--actor and --reason can only be used with `suggestions link` or `suggestions update <id> --status <state>`.", options.Json, jsonOptions); if (verb == "export" && options.Json && options.ExportFormat == "markdown" @@ -162,6 +164,8 @@ public static int Run( databasePath = storeContext.DatabasePath; if (verb == "add") return RunAdd(store, options, jsonOptions); + if (verb == "link") + return RunLink(store, options, jsonOptions); if (verb == "update") return RunUpdate(store, store.LoadAll(), options, jsonOptions); if (verb == "delete") @@ -275,7 +279,7 @@ private static int RunStatusTransition( return WriteUsageError("A status transition cannot be combined with query or export options.", options.Json, jsonOptions); if (!TryParseLifecycleStatus(options.Status, out var targetStatus)) return WriteUsageError( - "--status for `suggestions update` must be one of draft, open_in_upstream, resolved_in_upstream, wont_fix, duplicate, or superseded; submitted_pending_triage is managed by GitHub submission.", + "--status for `suggestions update` must be one of draft, open_in_upstream, resolved_in_upstream, wont_fix, duplicate, or superseded; submitted_pending_triage is managed by GitHub submission or explicit issue linking.", options.Json, jsonOptions); @@ -377,7 +381,7 @@ private static int WriteInvalidStatusTransition( $"Invalid suggestion status transition from {GetStatus(record)} to {ToSnakeCase(targetStatus)}.", CommandExitCodes.UsageError, targetStatus is SuggestionStatus.OpenInUpstream or SuggestionStatus.ResolvedInUpstream - ? "Upstream lifecycle states require a stored upstream issue URL or number; submitted_pending_triage is set only by successful GitHub submission." + ? "Upstream lifecycle states require a stored upstream issue URL or number; submitted_pending_triage is set by successful GitHub submission or explicit issue linking." : "Choose a different lifecycle state; repeating the current state is not a transition.", category: "invalid_status_transition"); @@ -557,6 +561,13 @@ private static int RunShow(List<SuggestionRecord> records, Options options, Json Console.WriteLine($"upstream_url: {record.UpstreamUrl}"); if (record.UpstreamIssueNumber != null) Console.WriteLine($"upstream_issue_number: {record.UpstreamIssueNumber}"); + if (record.UpstreamAssociation is { } association) + { + Console.WriteLine($"upstream_repository: {association.Repository}"); + Console.WriteLine($"upstream_association: {association.Provenance} ({association.Verification})"); + Console.WriteLine($"upstream_linked_at: {association.LinkedAt:O}"); + Console.WriteLine($"upstream_linked_by: {association.LinkedBy}"); + } var evidencePaths = NormalizeEvidencePaths(record); if (evidencePaths.Count > 0) { @@ -972,7 +983,8 @@ private static string FormatTitle(string description, int maxLength) record.PreviousStatus == null ? null : ToSnakeCase(record.PreviousStatus.Value), record.StatusChangedAt, record.StatusChangedBy, - record.StatusChangeReason); + record.StatusChangeReason, + record.UpstreamAssociation); private static SuggestionDetailJsonResult ToDetail(SuggestionRecord record) => ToDetail(record, capTextFields: false); @@ -1011,7 +1023,8 @@ private static string FormatTitle(string description, int maxLength) record.PreviousStatus == null ? null : ToSnakeCase(record.PreviousStatus.Value), record.StatusChangedAt, record.StatusChangedBy, - BoundSuggestionOutputValue(record.StatusChangeReason, capTextFields)); + BoundSuggestionOutputValue(record.StatusChangeReason, capTextFields), + record.UpstreamAssociation); private static SuggestionIssueDraftJsonResult ToIssueDraft(SuggestionRecord record, IssueDuplicatePreflight preflight, Options options) { @@ -1045,11 +1058,19 @@ private static SuggestionIssueDraftJsonResult ToIssueDraft(SuggestionRecord reco record.PreviousStatus == null ? null : ToSnakeCase(record.PreviousStatus.Value), record.StatusChangedAt, record.StatusChangedBy, - BoundSuggestionOutputValue(record.StatusChangeReason, capTextFields: true)), + BoundSuggestionOutputValue(record.StatusChangeReason, capTextFields: true), + record.UpstreamUrl, + record.UpstreamIssueNumber, + record.UpstreamAssociation), new SuggestionIssueDraftDuplicatePreflightJsonResult( preflight.Checked, duplicateMatches.Count, - duplicateMatches)); + duplicateMatches) + { + AlreadyPublished = IsSubmitted(record), + UpstreamUrl = record.UpstreamUrl, + UpstreamIssueNumber = record.UpstreamIssueNumber, + }); } private static IssueDraftTriageMetadataJsonResult BuildSuggestionIssueDraftTriage( @@ -1073,7 +1094,9 @@ private static IssueDraftTriageMetadataJsonResult BuildSuggestionIssueDraftTriag severity, confidence, evidencePaths.Count, - BuildSuggestionIssueDraftDuplicateGuidance(duplicatePreflightChecked, duplicateMatchCount)); + IsSubmitted(record) + ? $"Already published{(record.UpstreamUrl == null ? string.Empty : " at " + record.UpstreamUrl)}; use the existing issue and do not file again. Remote state is not implied by the local association." + : BuildSuggestionIssueDraftDuplicateGuidance(duplicatePreflightChecked, duplicateMatchCount)); } private static string BuildSuggestionIssueDraftDuplicateGuidance(bool duplicatePreflightChecked, int duplicateMatchCount) @@ -1233,6 +1256,13 @@ private static string FormatMarkdown(List<SuggestionRecord> records) sb.AppendLine($"- upstream_url: {record.UpstreamUrl}"); if (record.UpstreamIssueNumber != null) sb.AppendLine($"- upstream_issue_number: `{record.UpstreamIssueNumber}`"); + if (record.UpstreamAssociation is { } association) + { + sb.AppendLine($"- upstream_repository: `{association.Repository}`"); + sb.AppendLine($"- upstream_association: `{association.Provenance}` (`{association.Verification}`)"); + sb.AppendLine($"- upstream_linked_at: `{association.LinkedAt:O}`"); + sb.AppendLine($"- upstream_linked_by: {association.LinkedBy}"); + } if (record.LastSubmitAttempt != null) sb.AppendLine($"- last_submit_attempt: `{record.LastSubmitAttempt:O}`"); if (record.SubmitAttemptCount > 0) @@ -1581,6 +1611,14 @@ bool TryReadSchemaValue(string option, out string value, out string? error) } options.OpenIssuesRepository = repository; break; + case "--issue": + if (!TryReadSchemaValue("--issue", out var issue, out var issueError)) + { + options.Error = issueError; + return options; + } + options.Issue = issue; + break; case "--issue-state": if (!TryReadSchemaValue("--issue-state", out var issueState, out var issueStateError)) { @@ -1753,6 +1791,7 @@ private sealed class Options public bool FormatSpecified { get; set; } public string? OpenIssuesPath { get; set; } public string? OpenIssuesRepository { get; set; } + public string? Issue { get; set; } public string IssueState { get; set; } = IssueDuplicatePreflight.DefaultIssueState; public bool IssueStateSpecified { get; set; } public string DuplicateConfidence { get; set; } = IssueDuplicatePreflight.DefaultDuplicateConfidence; @@ -1769,7 +1808,8 @@ private sealed class Options public bool HasPagination => Limit.HasValue || OffsetSpecified; public bool HasContentEditableFields => LanguageSpecified || CategorySpecified || DescriptionSpecified || ContextSpecified || TitleSpecified || EvidencePathsSpecified || AgentSpecified; public bool HasQueryOnlyOptions => HasQueryOnlyOptionsExceptStatus || StatusSpecified; - public bool HasQueryOnlyOptionsExceptStatus => HasPagination || Since != null || HasHistoryQueryProjectionOptions || FormatSpecified || OutputPath != null || Overwrite || OpenIssuesPath != null || OpenIssuesRepository != null || IssueStateSpecified || DuplicateConfidenceSpecified || DuplicateThresholdSpecified; + public bool HasQueryOnlyOptionsExceptStatus => HasQueryOnlyOptionsExceptStatusAndRepository || OpenIssuesRepository != null; + public bool HasQueryOnlyOptionsExceptStatusAndRepository => HasPagination || Since != null || HasHistoryQueryProjectionOptions || FormatSpecified || OutputPath != null || Overwrite || OpenIssuesPath != null || IssueStateSpecified || DuplicateConfidenceSpecified || DuplicateThresholdSpecified; public bool HasHistoryQueryProjectionOptions => Query != null || HasStructuredProjectionOptions; public bool HasStructuredProjectionOptions => Count || SummaryOnly || Compact || MaxJsonBytes != null; } @@ -1810,7 +1850,8 @@ internal sealed record SuggestionListItemJsonResult( [property: JsonPropertyName("previous_status")] string? PreviousStatus, [property: JsonPropertyName("status_changed_at")] DateTime? StatusChangedAt, [property: JsonPropertyName("status_changed_by")] string? StatusChangedBy, - [property: JsonPropertyName("status_change_reason")] string? StatusChangeReason); + [property: JsonPropertyName("status_change_reason")] string? StatusChangeReason, + [property: JsonPropertyName("upstream_association")] SuggestionUpstreamAssociation? UpstreamAssociation = null); internal sealed record SuggestionDetailJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, @@ -1845,7 +1886,8 @@ internal sealed record SuggestionDetailJsonResult( [property: JsonPropertyName("previous_status")] string? PreviousStatus, [property: JsonPropertyName("status_changed_at")] DateTime? StatusChangedAt, [property: JsonPropertyName("status_changed_by")] string? StatusChangedBy, - [property: JsonPropertyName("status_change_reason")] string? StatusChangeReason); + [property: JsonPropertyName("status_change_reason")] string? StatusChangeReason, + [property: JsonPropertyName("upstream_association")] SuggestionUpstreamAssociation? UpstreamAssociation = null); internal sealed record SuggestionExportJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, @@ -1900,12 +1942,26 @@ internal sealed record SuggestionIssueDraftSourceJsonResult( [property: JsonPropertyName("previous_status")] string? PreviousStatus, [property: JsonPropertyName("status_changed_at")] DateTime? StatusChangedAt, [property: JsonPropertyName("status_changed_by")] string? StatusChangedBy, - [property: JsonPropertyName("status_change_reason")] string? StatusChangeReason); + [property: JsonPropertyName("status_change_reason")] string? StatusChangeReason, + [property: JsonPropertyName("upstream_url")] string? UpstreamUrl = null, + [property: JsonPropertyName("upstream_issue_number")] int? UpstreamIssueNumber = null, + [property: JsonPropertyName("upstream_association")] SuggestionUpstreamAssociation? UpstreamAssociation = null); internal sealed record SuggestionIssueDraftDuplicatePreflightJsonResult( [property: JsonPropertyName("checked")] bool Checked, [property: JsonPropertyName("match_count")] int MatchCount, - [property: JsonPropertyName("matches")] List<SuggestionIssueDraftDuplicateMatchJsonResult> Matches); + [property: JsonPropertyName("matches")] List<SuggestionIssueDraftDuplicateMatchJsonResult> Matches) +{ + [JsonPropertyName("already_published")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool AlreadyPublished { get; init; } + [JsonPropertyName("upstream_url")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? UpstreamUrl { get; init; } + [JsonPropertyName("upstream_issue_number")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? UpstreamIssueNumber { get; init; } +} internal sealed record SuggestionIssueDraftDuplicateMatchJsonResult( [property: JsonPropertyName("number")] int? Number, diff --git a/src/CodeIndex/Models/SuggestionRecord.cs b/src/CodeIndex/Models/SuggestionRecord.cs index e2b7019d2..3d714c32b 100644 --- a/src/CodeIndex/Models/SuggestionRecord.cs +++ b/src/CodeIndex/Models/SuggestionRecord.cs @@ -134,6 +134,9 @@ public class SuggestionRecord /// <summary>Upstream GitHub Issue URL when known / 判明している場合の upstream GitHub Issue URL</summary> public string? UpstreamUrl { get; set; } + /// <summary>Audit evidence for an explicitly linked external issue / 明示的に関連付けた外部 Issue の監査証跡</summary> + public SuggestionUpstreamAssociation? UpstreamAssociation { get; set; } + /// <summary>UTC timestamp of the last upstream sync / 最後に upstream と同期したUTCタイムスタンプ</summary> public DateTime? LastSyncedAt { get; set; } @@ -188,3 +191,17 @@ public class SuggestionRecord "other" }; } + +/// <summary>Local association evidence, independent of submission and resolution / 投稿・解決とは独立したローカル関連付けの証跡</summary> +public sealed record SuggestionUpstreamAssociation( + [property: JsonPropertyName("repository")] string Repository, + [property: JsonPropertyName("linked_at")] DateTime LinkedAt, + [property: JsonPropertyName("linked_by")] string LinkedBy, + [property: JsonPropertyName("reason")] string? Reason) +{ + [JsonPropertyName("provenance")] + public string Provenance => "manual_external"; + + [JsonPropertyName("verification")] + public string Verification => "not_performed"; +} diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index fcfc220f6..eb60bde0e 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -874,6 +874,15 @@ public void SuggestionCategoryRegistry_DrivesHelpRuntimeAndCompletions_Issue5235 [Fact] public void NestedValueDomainsAndValidateConfigJsonMatchAcceptedCliContracts_Issue5163() { + Assert.Equal( + ["--actor", "--db", "--issue", "--json", "--reason", "--repo"], + CliFlagSchema.GetCompletionFlagsForCommand("suggestions", "link").Select(flag => flag.Name).Order(StringComparer.Ordinal)); + Assert.Contains("--issue <number-or-url>", ConsoleUi.GetUsageLine("suggestions-link")); + foreach (var sibling in new[] { "list", "show", "export", "add", "update", "delete" }) + Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("suggestions", sibling), flag => flag.Name == "--issue"); + foreach (var shell in new[] { "bash", "zsh", "fish", "powershell" }) + Assert.Contains("issue", ConsoleCompletionRenderer.GetCompletionScript(shell)); + Assert.Equal( SuggestionsCommandRunner.StatusFilterValues, CliFlagSchema.GetCanonicalValuesForCommand("suggestions", "--status")); diff --git a/tests/CodeIndex.Tests/SuggestionLinkCommandTests.cs b/tests/CodeIndex.Tests/SuggestionLinkCommandTests.cs new file mode 100644 index 000000000..73af6fa5f --- /dev/null +++ b/tests/CodeIndex.Tests/SuggestionLinkCommandTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Models; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public sealed class SuggestionLinkCommandTests : IDisposable +{ + private readonly string _root = TestProjectHelper.CreateTempProject("suggestion_link"); + private string DbPath => Path.Combine(_root, "codeindex.db"); + + [Fact] + public void Link_ExposesAssociationAcrossReadAndExportSurfaces_Issue5350() + { + var store = new SuggestionStore(_root); + foreach (var (category, description) in new[] { ("bug", "First manual finding"), ("performance", "Second consolidated observation") }) + Assert.True(store.TryAdd(new SuggestionRecord { Category = category, Description = description })); + var records = store.LoadAll(); + for (var index = 0; index < records.Count; index++) + { + var record = records[index]; + var issue = index == 0 ? "5350" : "https://github.com/Widthdom/CodeIndex/issues/5350"; + using var linked = RunJson("link", record.Id[..12], "--repo=Widthdom/CodeIndex", "--issue", issue, "--actor", "maintainer", "--reason", "Published with GitHub CLI"); + Assert.Equal("linked", linked.RootElement.GetProperty("action").GetString()); + AssertLinked(linked.RootElement.GetProperty("suggestion")); + using var shown = RunJson("show", record.Id); + AssertLinked(shown.RootElement); + } + using var listed = RunJson("list", "--status", "submitted"); + Assert.Equal(2, listed.RootElement.GetProperty("total_count").GetInt32()); + foreach (var item in listed.RootElement.GetProperty("results").EnumerateArray()) + AssertLinked(item); + using var unsubmitted = RunJson("list", "--status", "unsubmitted"); + Assert.Equal(0, unsubmitted.RootElement.GetProperty("total_count").GetInt32()); + using var exported = RunJson("export", "--format", "json"); + foreach (var item in exported.RootElement.GetProperty("suggestions").EnumerateArray()) + AssertLinked(item); + using var drafts = RunJson("export", "--format", "issue-drafts"); + Assert.False(drafts.RootElement.GetProperty("duplicate_preflight").GetProperty("checked").GetBoolean()); + foreach (var draft in drafts.RootElement.GetProperty("drafts").EnumerateArray()) + { + var preflight = draft.GetProperty("duplicate_preflight"); + Assert.True(preflight.GetProperty("already_published").GetBoolean()); + Assert.Equal(5350, preflight.GetProperty("upstream_issue_number").GetInt32()); + Assert.Contains("do not file again", draft.GetProperty("triage").GetProperty("duplicate_guidance").GetString()); + Assert.Equal("manual_external", draft.GetProperty("source").GetProperty("upstream_association").GetProperty("provenance").GetString()); + } + foreach (var args in new string[][] { ["show", records[0].Id], ["export", "--format", "markdown"] }) + { + var (exit, stdout, stderr) = Run(args); + Assert.Equal(CommandExitCodes.Success, exit); + Assert.Empty(stderr); + Assert.Contains("manual_external", stdout); + Assert.Contains("not_performed", stdout); + Assert.Contains("https://github.com/widthdom/codeindex/issues/5350", stdout); + } + using var conflict = RunJsonFailure("link", records[0].Id, "--repo", "Widthdom/CodeIndex", "--issue", "5351"); + Assert.Equal("upstream_association_conflict", conflict.RootElement.GetProperty("category").GetString()); + using var update = RunJsonFailure("update", records[0].Id, "--description", "Do not rewrite published history"); + using var delete = RunJsonFailure("delete", records[0].Id); + Assert.Equal(2, store.LoadAll().Count); + } + + [Fact] + public void Link_RejectsMissingIdentityAndUnrelatedFlags_Issue5350() + { + foreach (var args in new string[][] + { + ["link"], + ["link", "id", "--repo", "owner/repo"], + ["link", "id", "--issue", "1"], + ["link", "id", "--repo", "owner/repo", "--issue", "https://github.com/wrong/repo/issues/1"], + ["link", "id", "--repo", "owner/repo", "--issue", "1", "--actor", ""], + ["link", "id", "--repo", "owner/repo", "--issue", "1", "--status", "resolved_in_upstream"], + ["link", "id", "--repo", "owner/repo", "--issue", "1", "--description", "edit"], + ["link", "id", "--repo", "owner/repo", "--issue", "1", "--open-issues", "github"], + ["link", "id", "--repo", "owner/repo", "--issue", "1", "--limit", "1"], + ["list", "--issue", "1"], + ["export", "--format", "issue-drafts", "--issue", "1"], + }) + { + using var error = RunJsonFailure(args); + Assert.Equal("error", error.RootElement.GetProperty("status").GetString()); + } + Assert.False(File.Exists(new SuggestionStore(_root).FilePath)); + } + + private static void AssertLinked(JsonElement record) + { + Assert.Equal("submitted_pending_triage", record.GetProperty("status").GetString()); + Assert.True(record.GetProperty("submitted_to_github").GetBoolean()); + Assert.Equal(0, record.GetProperty("submit_attempt_count").GetInt32()); + Assert.False(record.TryGetProperty("last_submit_attempt", out _)); + Assert.Equal(5350, record.GetProperty("upstream_issue_number").GetInt32()); + var association = record.GetProperty("upstream_association"); + Assert.Equal("widthdom/codeindex", association.GetProperty("repository").GetString()); + Assert.Equal("manual_external", association.GetProperty("provenance").GetString()); + Assert.Equal("not_performed", association.GetProperty("verification").GetString()); + Assert.Equal("maintainer", association.GetProperty("linked_by").GetString()); + } + + private JsonDocument RunJson(params string[] args) => RunJsonExpected(CommandExitCodes.Success, args); + private JsonDocument RunJsonFailure(params string[] args) => RunJsonExpected(CommandExitCodes.UsageError, args); + + private JsonDocument RunJsonExpected(int expectedExit, string[] args) + { + var (exit, stdout, stderr) = Run([.. args, "--json"]); + Assert.Equal(expectedExit, exit); + Assert.Empty(stderr); + return JsonDocument.Parse(stdout); + } + + private (int ExitCode, string Stdout, string Stderr) Run(params string[] args) + => ConsoleCapture.Capture(() => SuggestionsCommandRunner.Run([.. args, "--db", DbPath], ProgramRunner.CreateDefaultJsonOptions())); + + public void Dispose() => TestProjectHelper.DeleteDirectory(_root); +} diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.Association.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.Association.cs new file mode 100644 index 000000000..a7a38d7e1 --- /dev/null +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.Association.cs @@ -0,0 +1,214 @@ +using CodeIndex.Cli; +using CodeIndex.Models; + +namespace CodeIndex.Tests; + +public partial class SuggestionStoreTests +{ + [Fact] + public void TryLinkIssue_RoundTripsMultipleSuggestionsWithoutSubmitting_Issue5350() + { + var clock = new ManualTimeProvider(new DateTimeOffset(2035, 6, 7, 8, 9, 10, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, null, clock); + var first = MakeRecord("bug", "csharp", "Manual publication after a failed request"); + store.TryAddAndSubmit(first, _ => SuggestionStore.SubmitAttemptResult.Failure("Offline")); + var second = MakeRecord("performance", "rust", "Several observations share one upstream issue"); + Assert.True(store.TryAdd(second)); + var unrelated = MakeRecord("other", null, "Unrelated historical draft"); + unrelated.Context = "Published as #5350; context alone must not associate this record."; + Assert.True(store.TryAdd(unrelated)); + first = store.LoadAll().Single(record => record.Id == first.Id); + var originalAttempt = first.LastSubmitAttempt; + + Assert.Equal(SuggestionStore.MutationResult.Success, + store.TryLinkIssue(first.Id, first.RevisionHash, "Widthdom/CodeIndex", "5350", "maintainer", "Manual publication", out var linked)); + Assert.NotNull(linked); + Assert.Equal("https://github.com/widthdom/codeindex/issues/5350", linked.UpstreamUrl); + Assert.Equal(5350, linked.UpstreamIssueNumber); + Assert.Equal(SuggestionStatus.SubmittedPendingTriage, linked.Status); + Assert.Equal(SuggestionStatus.Draft, linked.PreviousStatus); + Assert.Equal(1, linked.SubmitAttemptCount); + Assert.Equal(originalAttempt, linked.LastSubmitAttempt); + Assert.Equal("Offline", linked.LastSubmitError); + Assert.Null(linked.LastSyncedAt); + Assert.Null(linked.ResolvedAt); + Assert.Null(linked.NextRetryAt); + Assert.NotEqual(first.RevisionHash, linked.RevisionHash); + var association = Assert.IsType<SuggestionUpstreamAssociation>(linked.UpstreamAssociation); + Assert.Equal("widthdom/codeindex", association.Repository); + Assert.Equal("manual_external", association.Provenance); + Assert.Equal("not_performed", association.Verification); + Assert.Equal(clock.GetUtcNow().UtcDateTime, association.LinkedAt); + Assert.Equal("maintainer", association.LinkedBy); + Assert.Equal("Manual publication", association.Reason); + + Assert.Equal(SuggestionStore.MutationResult.Success, + store.TryLinkIssue(second.Id, second.RevisionHash, "widthdom/codeindex", + "https://github.com/Widthdom/CodeIndex/issues/5350", null, null, out var secondLinked)); + Assert.Equal(0, secondLinked!.SubmitAttemptCount); + Assert.Null(secondLinked.LastSubmitAttempt); + Assert.Null(secondLinked.ResolvedAt); + var beforeRepeat = File.ReadAllBytes(store.FilePath); + clock.Advance(TimeSpan.FromDays(1)); + Assert.Equal(SuggestionStore.MutationResult.Success, + store.TryLinkIssue(first.Id, first.RevisionHash, "WIDTHDOM/CODEINDEX", "5350", "different actor", "different reason", out var repeated)); + Assert.Equal(linked.UpstreamAssociation, repeated!.UpstreamAssociation); + Assert.Equal(beforeRepeat, File.ReadAllBytes(store.FilePath)); + + var reopened = new SuggestionStore(_tempDir); + var records = reopened.LoadAll(); + Assert.Equal(2, records.Count(record => record.UpstreamIssueNumber == 5350)); + Assert.Equal(association, records.Single(record => record.Id == first.Id).UpstreamAssociation); + var historical = records.Single(record => record.Id == unrelated.Id); + Assert.Equal(SuggestionStatus.Draft, historical.Status); + Assert.Null(historical.UpstreamAssociation); + Assert.Null(historical.UpstreamUrl); + Assert.Equal(unrelated.Context, historical.Context); + var createCalls = 0; + foreach (var record in new[] { first, second }) + { + var result = store.TryAddAndSubmit(MakeRecord(record.Category, record.Language, record.Description), _ => + { + createCalls++; + return SuggestionStore.SubmitAttemptResult.Success("https://github.com/widthdom/codeindex/issues/9999"); + }); + Assert.True(result.AlreadySubmitted); + Assert.Equal(linked.UpstreamUrl, result.UpstreamUrl); + } + Assert.Equal(0, createCalls); + + Assert.Equal(SuggestionStore.MutationResult.Success, + store.TryTransitionStatus(first.Id, linked.RevisionHash, SuggestionStatus.ResolvedInUpstream, "maintainer", "Verified separately", out var resolved)); + Assert.Equal(SuggestionStore.MutationResult.Success, + store.TryLinkIssue(first.Id, resolved!.RevisionHash, "widthdom/codeindex", "5350", null, null, out var stillResolved)); + Assert.Equal(SuggestionStatus.ResolvedInUpstream, stillResolved!.Status); + Assert.Equal(resolved.ResolvedAt, stillResolved.ResolvedAt); + Assert.Equal(association, stillResolved.UpstreamAssociation); + } + + [Theory] + [InlineData("owner/repo", "0")] + [InlineData("owner/repo", "01")] + [InlineData("owner/repo", "2147483648")] + [InlineData("owner/repo", "https://github.com/other/repo/issues/1")] + [InlineData("owner/repo", "https://github.com/owner/repo/pull/1")] + [InlineData("owner/repo", "https://example.com/owner/repo/issues/1")] + [InlineData("owner/repo", "http://github.com/owner/repo/issues/1")] + [InlineData("owner/repo", "https://github.com:443/owner/repo/issues/1")] + [InlineData("owner/repo", "https://user@github.com/owner/repo/issues/1")] + [InlineData("owner/repo", "https://github.com/owner/repo/issues/1#issuecomment-2")] + [InlineData("owner/repo", "https://github.com/owner/repo/issues/1?x=1")] + [InlineData("owner/repo", "https://github.com/owner/else/../repo/issues/1")] + [InlineData("owner/repo", "https://github.com/owner/repo/issues/%31")] + [InlineData("owner/repo", "https://github.com/owner/repo/issues/1/")] + [InlineData("owner/repo", "1\n")] + [InlineData("owner/repo", "1")] + [InlineData("-owner/repo", "1")] + [InlineData("owner/..", "1")] + [InlineData("owner/other/repo", "1")] + [InlineData("owner/repo ", "1")] + [InlineData("öwner/repo", "1")] + public void TryLinkIssue_RejectsInvalidIdentityWithoutChangingStore_Issue5350(string repository, string issue) + { + var record = MakeRecord("bug", null, "Validation must precede mutation"); + Assert.True(_store.TryAdd(record)); + var before = File.ReadAllBytes(_store.FilePath); + Assert.Equal(SuggestionStore.MutationResult.InvalidAssociation, + _store.TryLinkIssue(record.Id, record.RevisionHash, repository, issue, null, null, out var rejected)); + Assert.Null(rejected); + Assert.Equal(before, File.ReadAllBytes(_store.FilePath)); + } + + [Fact] + public void TryLinkIssue_RejectsConflictsAndPreservesLegacyHistory_Issue5350() + { + TestProjectHelper.WriteTextFile(_tempDir, "suggestions-codeindex.json", """ + [{"hash":"legacy-draft","category":"bug","description":"Legacy unsubmitted record"}, + {"hash":"legacy-published","category":"other","description":"Legacy publication", + "submitted_to_github":true,"github_issue_url":"https://github.com/owner/repo/issues/1"}] + """); + var records = _store.LoadAll(); + var draft = records.Single(record => record.Id == "legacy-draft"); + var published = records.Single(record => record.Id == "legacy-published"); + Assert.Null(published.UpstreamAssociation); + Assert.Equal(SuggestionStore.MutationResult.Success, + _store.TryLinkIssue(draft.Id, draft.RevisionHash, "owner/repo", "1", "api_key=actor-secret-5350", "token=reason-secret-5350", out var linked)); + Assert.NotNull(linked!.UpstreamAssociation); + Assert.DoesNotContain("actor-secret-5350", File.ReadAllText(_store.FilePath)); + Assert.DoesNotContain("reason-secret-5350", File.ReadAllText(_store.FilePath)); + var before = File.ReadAllBytes(_store.FilePath); + foreach (var (repository, issue) in new[] { ("owner/repo", "2"), ("other/repo", "1") }) + { + Assert.Equal(SuggestionStore.MutationResult.AssociationConflict, + _store.TryLinkIssue(draft.Id, linked.RevisionHash, repository, issue, null, null, out var conflict)); + Assert.Null(conflict); + } + Assert.Equal(SuggestionStore.MutationResult.Success, + _store.TryLinkIssue(published.Id, published.RevisionHash, "owner/repo", "1", "external", null, out var unchanged)); + Assert.Null(unchanged!.UpstreamAssociation); // Do not relabel historical cdidx submissions as external. + Assert.Equal(before, File.ReadAllBytes(_store.FilePath)); + } + + [Fact] + public void TryLinkIssue_FailedAtomicPublicationPreservesHistory_Issue5350() + { + var record = MakeRecord("bug", null, "Existing history survives a failed association write"); + Assert.True(_store.TryAdd(record)); + var before = File.ReadAllBytes(_store.FilePath); + _store.ValidateAssociationWriteForTesting = stagedPath => + { + Assert.Contains("manual_external", File.ReadAllText(stagedPath)); + Assert.Equal(before, File.ReadAllBytes(_store.FilePath)); + throw new IOException("Simulated failure before atomic replacement"); + }; + try + { + Assert.Throws<IOException>(() => _store.TryLinkIssue(record.Id, record.RevisionHash, "owner/repo", "1", null, null, out _)); + Assert.Equal(before, File.ReadAllBytes(_store.FilePath)); + Assert.Null(Assert.Single(_store.LoadAll()).UpstreamAssociation); + Assert.DoesNotContain(Directory.EnumerateFiles(_tempDir), path => path.EndsWith(".tmp", StringComparison.Ordinal)); + } + finally + { + _store.ValidateAssociationWriteForTesting = null; + } + Assert.Equal(SuggestionStore.MutationResult.Success, + _store.TryLinkIssue(record.Id, record.RevisionHash, "owner/repo", "1", null, null, out _)); + } + + [Fact] + public async Task TryLinkIssue_RejectsInFlightSubmissionAndStaleRevision_Issue5350() + { + var record = MakeRecord("bug", null, "Submission and association serialize on the same store lock"); + Assert.True(_store.TryAdd(record)); + var replacement = Assert.Single(_store.LoadAll()); + replacement.Context = "A newer edit"; + Assert.Equal(SuggestionStore.MutationResult.Success, _store.TryUpdate(record.Id, record.RevisionHash, replacement, out _)); + Assert.Equal(SuggestionStore.MutationResult.RevisionConflict, + _store.TryLinkIssue(record.Id, record.RevisionHash, "owner/repo", "1", null, null, out _)); + + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var submission = _store.TryAddAndSubmitAsync(record, async _ => + { + entered.SetResult(); + await release.Task; + return SuggestionStore.SubmitAttemptResult.Failure("Offline"); + }); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var current = Assert.Single(_store.LoadAll()); + Assert.Equal(SuggestionStore.MutationResult.SubmissionInFlight, + _store.TryLinkIssue(current.Id, current.RevisionHash, "owner/repo", "1", null, null, out _)); + } + finally + { + release.TrySetResult(); + await submission.WaitAsync(TimeSpan.FromSeconds(10)); + } + var retry = Assert.Single(_store.LoadAll()); + Assert.Equal(SuggestionStore.MutationResult.Success, + _store.TryLinkIssue(retry.Id, retry.RevisionHash, "owner/repo", "1", null, null, out _)); + } +} diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 5c5eb0efe..4e960b324 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -11,7 +11,7 @@ namespace CodeIndex.Tests; /// SuggestionStoreのテスト(ローカルJSON蓄積 + 重複排除)。 /// </summary> [Collection("Console sensitive")] -public class SuggestionStoreTests : IDisposable +public partial class SuggestionStoreTests : IDisposable { private readonly string _tempDir; private readonly SuggestionStore _store; From b517ec3a19873aeff8d9b80234d0486d3186fe99 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Sun, 13 Sep 2026 11:45:12 +0900 Subject: [PATCH 2/2] Preserve suggestion history at association size limits (#5350) --- TESTING_GUIDE.md | 4 ++-- changelog.d/unreleased/5350.added.md | 3 +++ docs/suggestion-issue-linking.md | 8 ++++++-- .../Cli/SuggestionStore.Association.cs | 9 ++++++++- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 6 ++++-- .../SuggestionStoreTests.Association.cs | 17 +++++++++++++++++ 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 59fc403f1..2a1868d9b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -1130,7 +1130,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result ## Conventions -Issue #5350 uses `SuggestionStoreTests.Association.cs` and `SuggestionLinkCommandTests` for same-issue consolidation, immutable manual provenance, idempotency, identity/revision conflicts, offline operation, legacy records, no resubmission callbacks, and list/show/export parity. The instance-scoped pre-publication failure seam verifies previous-store preservation and temporary-file cleanup. Run these with suggestion, CLI schema/help and GitHub/MCP submission tests on net8/net9. +Issue #5350 uses `SuggestionStoreTests.Association.cs` and `SuggestionLinkCommandTests` for same-issue consolidation, immutable manual provenance, idempotency, identity/revision conflicts, offline operation, legacy records, the 8 MiB store boundary, no resubmission callbacks, and list/show/export parity. The instance-scoped pre-publication failure seam verifies previous-store preservation and temporary-file cleanup. Run these with suggestion, CLI schema/help and GitHub/MCP submission tests on net8/net9. - Keep test names descriptive. The current suite mostly uses `Method_Scenario_ExpectedBehavior`. - Keep tests deterministic. Do not depend on machine-global git config, locale-specific output, or ambient files. @@ -2506,7 +2506,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## 規約 -Issue #5350 は `SuggestionStoreTests.Association.cs` と `SuggestionLinkCommandTests` で、同一 Issue への統合、手動登録の証跡保持、冪等性、識別情報・revision の競合、オフライン動作、旧レコード、再投稿コールバックの抑止、一覧・詳細・エクスポートの一致を検証します。インスタンス単位の公開直前失敗フックで、以前のストアの保持と一時ファイルの片付けを確認します。提案・CLI スキーマ/ヘルプ・GitHub/MCP 投稿の既存テストとともに net8/net9 で実行してください。 +Issue #5350 は `SuggestionStoreTests.Association.cs` と `SuggestionLinkCommandTests` で、同一 Issue への統合、手動登録の証跡保持、冪等性、識別情報・revision の競合、オフライン動作、旧レコード、ストアの8 MiB境界、再投稿コールバックの抑止、一覧・詳細・エクスポートの一致を検証します。インスタンス単位の公開直前失敗フックで、以前のストアの保持と一時ファイルの片付けを確認します。提案・CLI スキーマ/ヘルプ・GitHub/MCP 投稿の既存テストとともに net8/net9 で実行してください。 - テスト名は説明的にする。現在のスイートは `Method_Scenario_ExpectedBehavior` 形式が中心です。 - テストは決定的に保つ。マシン全体の git 設定、ロケール依存出力、外部の残存ファイルに依存しないこと。 diff --git a/changelog.d/unreleased/5350.added.md b/changelog.d/unreleased/5350.added.md index 6d07adc18..5745f6b00 100644 --- a/changelog.d/unreleased/5350.added.md +++ b/changelog.d/unreleased/5350.added.md @@ -13,6 +13,9 @@ affected: - **Link manually published issues to local suggestions (#5350)** — `suggestions link <id> --repo <owner/name> --issue <number-or-url>` atomically records an existing GitHub issue with manual provenance and a UTC audit timestamp, without network verification or issue creation. Multiple suggestions can share one issue; identical repeats preserve history and conflicting links are rejected. List/show/export and duplicate submission recognize the association without fabricating submission attempts or implementation completion. +- Association writes that would exceed the store's 8 MiB read limit fail before replacement, preserving the original history. + ## 日本語 - **手動公開した Issue をローカル提案に関連付けられるようになりました (#5350)** — `suggestions link <id> --repo <owner/name> --issue <number-or-url>` は、リモート検証や Issue 作成を行わず、既存 GitHub Issue、手動登録の出所、UTC の監査日時を原子的に保存します。複数提案を1つの Issue に関連付けられ、同一の再実行は履歴を維持し、競合するリンクは拒否します。一覧・詳細・エクスポート・重複投稿は関連付けを認識し、投稿試行や実装完了を捏造しません。 +- 関連付け後の JSON がストアの読み取り上限8 MiBを超える場合は置換前に失敗し、元の履歴を保持します。 diff --git a/docs/suggestion-issue-linking.md b/docs/suggestion-issue-linking.md index e38ee97a1..a7de09637 100644 --- a/docs/suggestion-issue-linking.md +++ b/docs/suggestion-issue-linking.md @@ -37,7 +37,9 @@ the original audit timestamp, or relabelling a historical cdidx submission as external. Different or incomplete existing identities produce `upstream_association_conflict`; reassociation is not supported. Submission reservations and stale revisions are checked under the existing store lock. -Failed atomic publication leaves the previous store intact. +Failed atomic publication leaves the previous store intact. The completed staged +JSON must fit the 8 MiB store read limit; oversized results fail with a storage +error before replacement, preserving the readable original history. Full list/show/JSON exports include the association; human show and Markdown export also display its provenance. Compact lists retain the lifecycle status; @@ -91,7 +93,9 @@ Issue の新規作成やリモート検証は行いません。ASCII の `owner/ 過去の cdidx 投稿を外部投稿として記録し直すこともありません。異なる、または不完全な 既存識別情報には `upstream_association_conflict` を返します。付け替えには対応しません。 既存のストアロック内で投稿中の予約と古い revision を検査します。 -原子的な公開処理が失敗した場合、以前のストアを保持します。 +原子的な公開処理が失敗した場合、以前のストアを保持します。完成した一時 JSON は +ストアの読み取り上限8 MiBに収まる必要があります。超過時は置換前にストレージエラーとし、 +読み取り可能な元の履歴を維持します。 通常の一覧・詳細・JSON エクスポートには関連付け情報が含まれ、人間向け詳細表示と Markdown エクスポートにも出所を表示します。簡略一覧は状態を保持します。 diff --git a/src/CodeIndex/Cli/SuggestionStore.Association.cs b/src/CodeIndex/Cli/SuggestionStore.Association.cs index bf8fb9b47..2aa211d00 100644 --- a/src/CodeIndex/Cli/SuggestionStore.Association.cs +++ b/src/CodeIndex/Cli/SuggestionStore.Association.cs @@ -73,7 +73,7 @@ public MutationResult TryLinkIssue( // Preserve actual attempt/error/sync/resolution evidence. Only cancel future retries. // 実際の試行・エラー・同期・解決の証跡は維持し、今後の再試行予約だけを解除する。 record.NextRetryAt = null; - SaveUnlocked(records, ValidateAssociationWriteForTesting); + SaveUnlocked(records, ValidateAssociationWrite); result = record; return MutationResult.Success; }); @@ -81,6 +81,13 @@ public MutationResult TryLinkIssue( return mutationResult; } + private void ValidateAssociationWrite(string stagedPath) + { + if (new FileInfo(stagedPath).Length > MaxSuggestionStoreBytes) + throw new IOException($"Linked suggestion store would exceed the {MaxSuggestionStoreBytes}-byte read limit; the original history was preserved."); + ValidateAssociationWriteForTesting?.Invoke(stagedPath); + } + internal static bool IsValidIssueAssociation(string? repository, string? issue) => TryParseIssueIdentity(repository, issue, out _); diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index eb60bde0e..145b61338 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -880,8 +880,10 @@ public void NestedValueDomainsAndValidateConfigJsonMatchAcceptedCliContracts_Iss Assert.Contains("--issue <number-or-url>", ConsoleUi.GetUsageLine("suggestions-link")); foreach (var sibling in new[] { "list", "show", "export", "add", "update", "delete" }) Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("suggestions", sibling), flag => flag.Name == "--issue"); - foreach (var shell in new[] { "bash", "zsh", "fish", "powershell" }) - Assert.Contains("issue", ConsoleCompletionRenderer.GetCompletionScript(shell)); + Assert.Contains("[ \"$cmd\" = \"suggestions\" ] && [ \"$nested\" = \"link\" ]", ConsoleCompletionRenderer.GetCompletionScript("bash")); + Assert.Contains("[[ $subcmd == suggestions && $nested == link ]]", ConsoleCompletionRenderer.GetCompletionScript("zsh")); + Assert.Contains("'__fish_cdidx_using_context suggestions link' -l issue -r", ConsoleCompletionRenderer.GetCompletionScript("fish")); + Assert.Contains("($subcmd -eq 'suggestions' -and $nested -eq 'link')", ConsoleCompletionRenderer.GetCompletionScript("powershell")); Assert.Equal( SuggestionsCommandRunner.StatusFilterValues, diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.Association.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.Association.cs index a7a38d7e1..9083f1800 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.Association.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.Association.cs @@ -149,6 +149,23 @@ public void TryLinkIssue_RejectsConflictsAndPreservesLegacyHistory_Issue5350() Assert.Equal(before, File.ReadAllBytes(_store.FilePath)); } + [Fact] + public void TryLinkIssue_RejectsOversizedResultWithoutQuarantiningOldStore_Issue5350() + { + const string prefix = "[{\"hash\":\"near-capacity\",\"category\":\"bug\",\"description\":\"Capacity boundary fixture\",\"context\":\""; + const string suffix = "\"}]"; + var original = prefix + new string('x', SuggestionStore.MaxSuggestionStoreBytes - prefix.Length - suffix.Length - 16) + suffix; + File.WriteAllText(_store.FilePath, original); + var record = Assert.Single(_store.LoadAll()); + + Assert.Throws<IOException>(() => _store.TryLinkIssue(record.Id, record.RevisionHash, "owner/repo", "1", null, null, out _)); + + Assert.Equal(original, File.ReadAllText(_store.FilePath)); + Assert.Equal(record.Id, Assert.Single(_store.LoadAll()).Id); + Assert.False(File.Exists(_store.FilePath + ".bak")); + Assert.DoesNotContain(Directory.EnumerateFiles(_tempDir), path => path.EndsWith(".tmp", StringComparison.Ordinal)); + } + [Fact] public void TryLinkIssue_FailedAtomicPublicationPreservesHistory_Issue5350() {