diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a20e189ff8..e7786a79b3 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1084,6 +1084,7 @@ The `suggest_improvement` MCP tool allows AI agents to report gaps or errors. | [`src/CodeIndex/Cli/GitHubIssueReporter.cs`](src/CodeIndex/Cli/GitHubIssueReporter.cs) | GitHub Issues API client (best-effort) | | [`src/CodeIndex/Mcp/McpToolHandlers.cs`](src/CodeIndex/Mcp/McpToolHandlers.cs) | `ExecuteSuggestImprovement` handler | | [`src/CodeIndex/Mcp/McpToolDefinitions.cs`](src/CodeIndex/Mcp/McpToolDefinitions.cs) | Tool schema definition | +| [`src/CodeIndex/Cli/SuggestionsCommandRunner.cs`](src/CodeIndex/Cli/SuggestionsCommandRunner.cs) | Local suggestion listing, export, issue-draft generation, and open-issue duplicate preflight | ### What is sent (when GitHub token is configured) @@ -1091,6 +1092,7 @@ The `suggest_improvement` MCP tool allows AI agents to report gaps or errors. - Language name (e.g. `typescript`) - Description text (natural language, validated by SourceCodeDetector) - Context text (natural language, validated by SourceCodeDetector) +- Optional repository-relative evidence paths supplied by the caller. These are path strings only; file contents are never read for the payload. - cdidx version string - Attribution metadata: `created_by_agent`, `session_id`, `client_version`, `mcp_client_name`, `mcp_client_version`, and optional `tool_invocation_context` - SHA256 suggestion hash (for deduplication) @@ -1107,13 +1109,13 @@ Local suggestion records use the `status` lifecycle field instead of a binary su `SuggestionStore.TryAddAndSubmit` keeps local read/write and submission reservation under the suggestion-store file lock, but invokes the GitHub callback after releasing the lock. The reservation stamps `last_submit_attempt`, increments `submit_attempt_count`, and sets a short `next_retry_at` guard so another writer will not submit the same duplicate while the first remote call is still in flight. The callback result is persisted by re-taking the lock briefly after the remote call completes. -Before creating an upstream Issue, `GitHubIssueReporter` checks whether an Issue with the same SHA256 suggestion hash already exists. It first queries GitHub Search for the hash in issue bodies, then falls back to listing `ai-suggestion` Issues directly and matching the hash in each body. The fallback avoids GitHub Search indexing latency, so a retry immediately after a lost create response can still find the just-created Issue and avoid a duplicate POST. Lookup failures remain best-effort: if both checks fail because GitHub is unavailable, the reporter proceeds to the normal create path instead of blocking a legitimate first submission. +Before creating an upstream Issue, `GitHubIssueReporter` checks whether an Issue with the same SHA256 suggestion hash already exists. It first queries GitHub Search for the hash in issue bodies, then falls back to listing Issues with the existing repository labels cdidx applies (`enhancement` for ordinary suggestions, `bug` for crash/error reports) and matching the hash in each body. The fallback avoids GitHub Search indexing latency, so a retry immediately after a lost create response can still find the just-created Issue and avoid a duplicate POST. Lookup failures remain best-effort: if both checks fail because GitHub is unavailable, the reporter proceeds to the normal create path instead of blocking a legitimate first submission. The shared GitHub HTTP client uses an explicit 10-second submission timeout by default, configurable with `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS`, and the platform default proxy (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, and `NO_PROXY` through .NET default proxy handling). Create failures mention proxy environment variables in their diagnostic hint. `429` responses and `403` responses with `x-ratelimit-remaining: 0` are treated as rate limits; `Retry-After` wins, then `x-ratelimit-reset`, then a one-minute fallback retry window. ### What is NOT included in the payload by design -- File paths from the user's project +- Source file contents from the user's project - Any data from the indexed SQLite database - Any data from `.cdidx/codeindex.db` - Operating system or environment information diff --git a/README.md b/README.md index 3208e3dc05..42de280fdd 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ downgrading `cdidx`. | Diagnostics | `doctor` prints a redacted environment summary for bug reports. `status --config` prints effective configuration with source attribution, and `status --explain ` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms `, and --trace=stderr|file|none; file traces write daily `query-trace-YYYYMMDD.jsonl` files next to the lifecycle log and retain the newest 30 trace files. | | Query exit codes | Valid zero-result query commands exit `0` by default. Pass `--strict-not-found` when scripts should treat zero rows as exit code `2`. | | Drift checks | `cdidx diff ` compares schema, file, symbol, and reference deltas with stable exit codes: `0` identical, `1` drift, `2` schema mismatch, `3` unreadable DB. | -| Extensibility and feedback | Post-extraction hooks from `~/.config/cdidx/hooks/*.dll` or `CDIDX_HOOKS_DIR` can enrich symbols and references. `cdidx suggestions` lists, inspects, and exports local suggestion history, with fuzzy MCP suggestion deduplication controlled by CLI, env, or `.cdidxrc.json`. | +| Extensibility and feedback | Post-extraction hooks from `~/.config/cdidx/hooks/*.dll` or `CDIDX_HOOKS_DIR` can enrich symbols and references. `cdidx suggestions` lists, inspects, and exports local suggestion history, including issue-draft JSON with optional open-issue duplicate preflight, with fuzzy MCP suggestion deduplication controlled by CLI, env, or `.cdidxrc.json`. | | Language coverage | 78 detected languages, with symbol and graph support where available. | | Updates | `cdidx --version` checks GitHub releases at most once per day and appends a newer-release hint when one is available. Use `cdidx --check-updates` or `cdidx status --check-updates` for an explicit freshness check, and `cdidx upgrade` to reinstall the latest GitHub release via `install.sh`. Set `CDIDX_DISABLE_UPDATE_CHECK=1` to suppress checks. | @@ -433,7 +433,7 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し | security defaults | POSIX では `.cdidx` を `0700` 権限で作成し、lifecycle log、metrics log、MCP audit log、query trace log は作成時点から owner read/write のみで作成します。metrics log と audit log は bounded slot へ rotation し、query trace log は bounded な保持件数へ pruning します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 | | diagnostics | `status --config` は source attribution 付きの effective configuration を出力し、`status --explain ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書き、最新30件の trace file を保持します。 | | drift checks | `cdidx diff ` は schema、file、symbol、reference の差分を比較します。exit code は `0` identical、`1` drift、`2` schema mismatch、`3` unreadable DB です。 | -| extensibility / feedback | `~/.config/cdidx/hooks/*.dll` または `CDIDX_HOOKS_DIR` の post-extraction hook で永続化前のシンボルと参照を拡張できます。`cdidx suggestions` はローカル提案履歴の一覧表示、詳細表示、エクスポートに対応し、MCP 提案の近似重複排除しきい値は CLI、env、`.cdidxrc.json` で調整できます。 | +| extensibility / feedback | `~/.config/cdidx/hooks/*.dll` または `CDIDX_HOOKS_DIR` の post-extraction hook で永続化前のシンボルと参照を拡張できます。`cdidx suggestions` はローカル提案履歴の一覧表示、詳細表示、open issue 重複 preflight 付き issue draft JSON エクスポートに対応し、MCP 提案の近似重複排除しきい値は CLI、env、`.cdidxrc.json` で調整できます。 | | language coverage | 78 言語を検出し、対応言語ではシンボルとグラフも利用可能です。 | | updates | `cdidx --version` は GitHub releases を 1 日 1 回まで確認し、新しいリリースがある場合にヒントを追記します。確認を抑止するには `CDIDX_DISABLE_UPDATE_CHECK=1` を設定します。 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4c8730e4a6..13f34f1937 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1122,7 +1122,8 @@ same source location. | `--language ` / `--lang ` | `suggestions` | Filter local suggestion history by recorded target language. | | `--category ` | `suggestions` | Filter local suggestion history by suggestion category. | | `--agent ` | `suggestions` | Filter local suggestion history by recorded agent/tool name when present. | -| `--format ` | `suggestions export` | Choose export format. JSON is the default; markdown is intended for human triage. | +| `--format ` | `suggestions export` | Choose export format. JSON is the default, markdown is intended for human triage, and issue-drafts emits issue-ready draft objects. | +| `--open-issues ` | `suggestions export --format issue-drafts` | Preflight drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`. | | `--check` | `status` | Verify that `.cdidx/codeindex.db` exactly matches the current indexable workspace by comparing DB file paths/checksums against a fresh filesystem scan. Matching indexes exit `0`; stale indexes exit `5`. | | `--dry-run` | `index` | Scan files and report what would change without writing to the database | | `--limit ` | Query commands | Max results (default: 20, max: 10000; `map` uses it per section) | @@ -2016,9 +2017,9 @@ When both are set, the allowlist wins. `tools/list` only advertises enabled tool ### AI Feedback -cdidx includes a `suggest_improvement` MCP tool for AI agents that hit gaps or bugs. Suggestions are saved locally beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), and are sent to GitHub only when the user explicitly provides `CDIDX_GITHUB_TOKEN`. GitHub submission runs outside the suggestion-store file lock and uses a 10-second timeout by default; set `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=` to tune that deadline. Local records include lifecycle metadata: `draft`, `submitted_pending_triage`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, or `superseded`, plus upstream issue URL/number fields when known. They also persist GitHub submission diagnostics (`last_submit_attempt`, `submit_attempt_count`, `last_submit_error`, and rate-limit `next_retry_at`) so operators can tell whether a suggestion was never attempted, failed transiently, is waiting for a rate-limit window, or was rejected by the API. New records also store attribution metadata: the MCP `initialize.clientInfo` name/version when available, an opaque cdidx session id, the cdidx version that recorded the suggestion, and optional natural-language `toolInvocationContext` supplied by the caller. Payload details and source-code leak guardrails are documented in the [Developer Guide](DEVELOPER_GUIDE.md#ai-feedback-implementation). +cdidx includes a `suggest_improvement` MCP tool for AI agents that hit gaps or bugs. Suggestions are saved locally beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), and are sent to GitHub only when the user explicitly provides `CDIDX_GITHUB_TOKEN`. GitHub submission runs outside the suggestion-store file lock and uses a 10-second timeout by default; set `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=` to tune that deadline. Local records include lifecycle metadata: `draft`, `submitted_pending_triage`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, or `superseded`, plus upstream issue URL/number fields when known. They also persist GitHub submission diagnostics (`last_submit_attempt`, `submit_attempt_count`, `last_submit_error`, and rate-limit `next_retry_at`) so operators can tell whether a suggestion was never attempted, failed transiently, is waiting for a rate-limit window, or was rejected by the API. New records also store attribution metadata: the MCP `initialize.clientInfo` name/version when available, an opaque cdidx session id, the cdidx version that recorded the suggestion, optional natural-language `toolInvocationContext`, and optional repository-relative `evidencePaths` supplied by the caller. Payload details and source-code leak guardrails are documented in the [Developer Guide](DEVELOPER_GUIDE.md#ai-feedback-implementation). -Use `cdidx suggestions list` to review recorded suggestions, `cdidx suggestions show ` to inspect one entry, and `cdidx suggestions export --format markdown` to share a filtered triage bundle with a team. The command reads the suggestion store beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), supports filters such as `--status`, `--language`, `--category`, `--since`, and `--agent`, and prints JSON with `--json` for scripts. +Use `cdidx suggestions list` to review recorded suggestions, `cdidx suggestions show ` to inspect one entry, and `cdidx suggestions export --format markdown` to share a filtered triage bundle with a team. Use `cdidx suggestions export --format issue-drafts --open-issues open-issues.json` to emit issue-ready drafts with title, labels, evidence paths, body text, and duplicate matches from an open-issues JSON preflight. The command reads the suggestion store beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), supports filters such as `--status`, `--language`, `--category`, `--since`, and `--agent`, and prints JSON with `--json` for scripts. Suggestion history readers can query the local store by lifecycle status, created-at threshold, category, language, or stored-order pages. These query APIs stream records from disk so tools that only need a narrow slice do not have to deserialize the whole suggestions file first. @@ -3228,7 +3229,8 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--language ` / `--lang ` | `suggestions` | ローカル提案履歴を記録済み対象言語で絞り込みます。 | | `--category ` | `suggestions` | ローカル提案履歴を提案カテゴリで絞り込みます。 | | `--agent ` | `suggestions` | 記録されている場合、ローカル提案履歴をエージェント / ツール名で絞り込みます。 | -| `--format ` | `suggestions export` | エクスポート形式を選びます。既定は JSON、markdown は人間の triage 共有向けです。 | +| `--format ` | `suggestions export` | エクスポート形式を選びます。既定は JSON、markdown は人間の triage 共有向け、issue-drafts は Issue 作成用の draft object を出力します。 | +| `--open-issues ` | `suggestions export --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` などの open issue JSON と照合して draft を事前重複確認します。 | | `--check` | `status` | DB のファイル path/checksum と現在の index 対象 workspace を比較し、`.cdidx/codeindex.db` が完全一致するか確認。完全一致なら終了コード `0`、stale なら `5` | | `--dry-run` | `index` | DB に書き込まず、どの変更が発生するかだけを走査して報告 | | `--limit ` | クエリ系 | 最大結果数(デフォルト: 20、最大: 10000。`map` では各セクションごとの件数) | @@ -4101,9 +4103,9 @@ stdio トランスポートはバイト単位で挙動が変わらないため ### AIフィードバック -cdidx には、AI エージェントがギャップや不具合に気づいたときに使える `suggest_improvement` MCP ツールがあります。提案は選択した DB の隣(既定は `.cdidx/suggestions-codeindex.json`)にローカル保存され、`CDIDX_GITHUB_TOKEN` を明示設定した場合に限って GitHub へ送信されます。GitHub 送信は suggestion-store のファイルロック外で実行され、既定では 10 秒で timeout します。この deadline は `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=<秒>` で調整できます。ローカルレコードには lifecycle metadata として `draft`、`submitted_pending_triage`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` と、判明している upstream issue URL/番号が保存されます。さらに GitHub 送信診断として `last_submit_attempt`、`submit_attempt_count`、`last_submit_error`、rate-limit 時の `next_retry_at` も永続化されるため、提案が未試行なのか、一時的に失敗したのか、rate-limit window 待ちなのか、API に拒否されたのかを運用者が判断できます。新規レコードには attribution metadata も保存されます。取得可能な場合は MCP `initialize.clientInfo` の name/version、不透明な cdidx セッション ID、提案を記録した cdidx バージョン、呼び出し元が任意で渡す自然言語の `toolInvocationContext` が含まれます。ペイロード詳細とソースコード漏えいガードは [DEVELOPER_GUIDE.md#aiフィードバックの実装](DEVELOPER_GUIDE.md#aiフィードバックの実装) にまとめています。 +cdidx には、AI エージェントがギャップや不具合に気づいたときに使える `suggest_improvement` MCP ツールがあります。提案は選択した DB の隣(既定は `.cdidx/suggestions-codeindex.json`)にローカル保存され、`CDIDX_GITHUB_TOKEN` を明示設定した場合に限って GitHub へ送信されます。GitHub 送信は suggestion-store のファイルロック外で実行され、既定では 10 秒で timeout します。この deadline は `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=<秒>` で調整できます。ローカルレコードには lifecycle metadata として `draft`、`submitted_pending_triage`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` と、判明している upstream issue URL/番号が保存されます。さらに GitHub 送信診断として `last_submit_attempt`、`submit_attempt_count`、`last_submit_error`、rate-limit 時の `next_retry_at` も永続化されるため、提案が未試行なのか、一時的に失敗したのか、rate-limit window 待ちなのか、API に拒否されたのかを運用者が判断できます。新規レコードには attribution metadata も保存されます。取得可能な場合は MCP `initialize.clientInfo` の name/version、不透明な cdidx セッション ID、提案を記録した cdidx バージョン、呼び出し元が任意で渡す自然言語の `toolInvocationContext`、任意のリポジトリ相対 `evidencePaths` が含まれます。ペイロード詳細とソースコード漏えいガードは [DEVELOPER_GUIDE.md#aiフィードバックの実装](DEVELOPER_GUIDE.md#aiフィードバックの実装) にまとめています。 -記録済みの提案は `cdidx suggestions list` で確認し、`cdidx suggestions show ` で1件を詳細表示し、`cdidx suggestions export --format markdown` でチーム triage 用に共有できます。このコマンドは選択した DB の隣にある提案ストア(既定は `.cdidx/suggestions-codeindex.json`)を読み、`--status`、`--language`、`--category`、`--since`、`--agent` で絞り込めます。スクリプト向けには `--json` を使います。 +記録済みの提案は `cdidx suggestions list` で確認し、`cdidx suggestions show ` で1件を詳細表示し、`cdidx suggestions export --format markdown` でチーム triage 用に共有できます。`cdidx suggestions export --format issue-drafts --open-issues open-issues.json` は、title、labels、evidence paths、body text、open issue JSON との重複候補を含む Issue 作成用 draft を出力します。このコマンドは選択した DB の隣にある提案ストア(既定は `.cdidx/suggestions-codeindex.json`)を読み、`--status`、`--language`、`--category`、`--since`、`--agent` で絞り込めます。スクリプト向けには `--json` を使います。 提案履歴を読む側は、ライフサイクル状態、作成日時のしきい値、カテゴリ、言語、保存順ページでローカルストアを絞り込めます。これらのクエリ API はディスクからレコードをストリーミングするため、必要な範囲が小さいツールでも suggestions ファイル全体を先にデシリアライズする必要がありません。 diff --git a/changelog.d/unreleased/2878.added.md b/changelog.d/unreleased/2878.added.md new file mode 100644 index 0000000000..0bd89465cf --- /dev/null +++ b/changelog.d/unreleased/2878.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 2878 +affected: + - src/CodeIndex/Cli/SuggestionsCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Models/SuggestionRecord.cs + - src/CodeIndex/Models/SuggestionEvidencePaths.cs + - USER_GUIDE.md +--- + +## English + +- **Suggestion exports can now produce issue drafts with duplicate preflight (#2878)** - `cdidx suggestions export --format issue-drafts` emits title, labels, evidence paths, body text, and optional open-issue duplicate matches from `--open-issues`. + +## 日本語 + +- **suggestion export が重複 preflight 付き Issue draft を出力できるようになりました (#2878)** - `cdidx suggestions export --format issue-drafts` は title、labels、evidence paths、body text と、`--open-issues` による open issue 重複候補を出力します。 diff --git a/changelog.d/unreleased/2931.fixed.md b/changelog.d/unreleased/2931.fixed.md new file mode 100644 index 0000000000..3a792c1375 --- /dev/null +++ b/changelog.d/unreleased/2931.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2931 +affected: + - src/CodeIndex/Cli/GitHubIssueReporter.cs + - tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +--- + +## English + +- **GitHub suggestion submission now uses existing repository labels (#2931)** - suggestion issues use `enhancement` for ordinary suggestions and `bug` for crash/error reports instead of the missing `ai-suggestion` label. + +## 日本語 + +- **GitHub suggestion 送信が既存リポジトリ label を使うようになりました (#2931)** - suggestion 由来の Issue は、存在しない `ai-suggestion` label ではなく、通常の提案では `enhancement`、crash/error 報告では `bug` を使います。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index c1751f6617..04a75a469a 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -104,7 +104,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("deps", "cdidx deps [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--reverse] [--cycles]"), ("unused", "cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]"), ("hotspots", "cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"), - ("suggestions", "cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ]"), + ("suggestions", "cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ] [--open-issues ]"), ("export", "cdidx export [--db ] [--json]"), ("export", "cdidx export ctags [--output ] [--db ]"), ("import", "cdidx import [--db ] [--prune-paths] [--json]"), diff --git a/src/CodeIndex/Cli/GitHubIssueReporter.cs b/src/CodeIndex/Cli/GitHubIssueReporter.cs index 5277ff3f12..76bcf8dcb3 100644 --- a/src/CodeIndex/Cli/GitHubIssueReporter.cs +++ b/src/CodeIndex/Cli/GitHubIssueReporter.cs @@ -88,6 +88,7 @@ private static HttpClient CreateDefaultHttpClient() private const string RepoOwner = "widthdom"; private const string RepoName = "CodeIndex"; private const string ApiBase = "https://api.github.com"; + private static readonly string[] ExistingSuggestionLookupLabels = ["enhancement", "bug"]; /// /// Try to create a GitHub Issue for the given suggestion. @@ -132,7 +133,7 @@ private static HttpClient CreateDefaultHttpClient() // レスポンスが消失した場合、ローカルレコードでは SubmittedToGitHub=false の // ままになる。再試行で重複 Issue を作らないよう、新規 POST 前に // 当該提案ハッシュを含む既存 Issue を探す。 - var existingUrl = await FindExistingIssueByHashAsync(record.Hash, token, linkedCts.Token); + var existingUrl = await FindExistingIssueByHashAsync(record.Hash, token, BuildIssueLabels(record), linkedCts.Token); if (existingUrl != null) return SuggestionStore.SubmitAttemptResult.Success(existingUrl); @@ -197,20 +198,27 @@ private static bool IsTimeoutCancellation(Exception ex) /// /// Search the target GitHub repository for an existing Issue whose body /// contains the suggestion hash. The primary check uses GitHub Search; - /// the backstop lists ai-suggestion issues directly so same-second retries + /// the backstop lists issues by the labels cdidx would apply so same-second retries /// are not exposed to Search indexing latency. Returns the html_url of the /// first match, or null if no match is found or the hash looks unsafe to /// search with. On API failure this returns null — the caller falls through /// to the normal create path so a GitHub-side lookup outage never blocks a /// legitimate first submission. /// 当該提案ハッシュを含む既存 Issue を対象リポジトリから検索する。 - /// 主経路は GitHub Search を使い、backstop として ai-suggestion Issue を + /// 主経路は GitHub Search を使い、backstop として cdidx が付ける label の Issue を /// 直接一覧取得することで、同秒の再試行が Search の index 遅延に影響されない /// ようにする。一致した最初の Issue の html_url を返す。一致なし、またはハッシュが /// 検索に使えない形の場合は null。API 失敗時も null を返し、GitHub 側 lookup の /// 障害によって新規送信がブロックされないようにする。 /// internal static async Task FindExistingIssueByHashAsync(string hash, string token, CancellationToken cancellationToken = default) + => await FindExistingIssueByHashAsync(hash, token, ExistingSuggestionLookupLabels, cancellationToken); + + private static async Task FindExistingIssueByHashAsync( + string hash, + string token, + IReadOnlyList lookupLabels, + CancellationToken cancellationToken) { // Defensive: only search with hex-shaped hashes to avoid accidentally // injecting search operators if the field ever held arbitrary text. @@ -222,7 +230,7 @@ private static bool IsTimeoutCancellation(Exception ex) if (searchUrl != null) return searchUrl; - return await ListExistingSuggestionIssueByHashAsync(hash, token, cancellationToken); + return await ListExistingSuggestionIssueByHashAsync(hash, token, lookupLabels, cancellationToken); } private static async Task SearchExistingIssueByHashAsync(string hash, string token, CancellationToken cancellationToken) @@ -246,35 +254,44 @@ private static bool IsTimeoutCancellation(Exception ex) return items[0]?["html_url"]?.GetValue(); } - private static async Task ListExistingSuggestionIssueByHashAsync(string hash, string token, CancellationToken cancellationToken) + private static async Task ListExistingSuggestionIssueByHashAsync( + string hash, + string token, + IReadOnlyList lookupLabels, + CancellationToken cancellationToken) { - for (var page = 1; ; page++) + foreach (var label in lookupLabels.Distinct(StringComparer.OrdinalIgnoreCase)) { - var labels = Uri.EscapeDataString("ai-suggestion"); - var url = $"{ApiBase}/repos/{RepoOwner}/{RepoName}/issues?labels={labels}&state=all&per_page=100&page={page}"; - - using var requestMessage = new HttpRequestMessage(HttpMethod.Get, url); - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - - var response = await HttpClient.SendAsync(requestMessage, cancellationToken); - if (!response.IsSuccessStatusCode) - return null; - - var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); - var items = JsonNode.Parse(responseJson) as JsonArray; - if (items == null || items.Count == 0) - return null; - - foreach (var item in items) + for (var page = 1; ; page++) { - var body = item?["body"]?.GetValue(); - if (body != null && body.Contains(hash, StringComparison.Ordinal)) - return item?["html_url"]?.GetValue(); + var labels = Uri.EscapeDataString(label); + var url = $"{ApiBase}/repos/{RepoOwner}/{RepoName}/issues?labels={labels}&state=all&per_page=100&page={page}"; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, url); + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await HttpClient.SendAsync(requestMessage, cancellationToken); + if (!response.IsSuccessStatusCode) + return null; + + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + var items = JsonNode.Parse(responseJson) as JsonArray; + if (items == null || items.Count == 0) + break; + + foreach (var item in items) + { + var body = item?["body"]?.GetValue(); + if (body != null && body.Contains(hash, StringComparison.Ordinal)) + return item?["html_url"]?.GetValue(); + } + + if (items.Count < 100) + break; } - - if (items.Count < 100) - return null; } + + return null; } private static bool IsHexHash(string value) @@ -346,6 +363,18 @@ private static bool IsHexHash(string value) body.AppendLine("## Language"); body.AppendLine(record.Language ?? "N/A"); body.AppendLine(); + body.AppendLine("## Evidence paths"); + var evidencePaths = NormalizeEvidencePaths(record.EvidencePaths); + if (evidencePaths.Count == 0) + { + body.AppendLine("N/A"); + } + else + { + foreach (var path in evidencePaths) + body.AppendLine($"- {path}"); + } + body.AppendLine(); body.AppendLine("## Description"); body.AppendLine(scrubbedDescription); body.AppendLine(); @@ -367,7 +396,7 @@ private static bool IsHexHash(string value) { ["title"] = title, ["body"] = body.ToString(), - ["labels"] = new JsonArray { "ai-suggestion" }, + ["labels"] = new JsonArray(BuildIssueLabels(record).Select(label => JsonValue.Create(label)).ToArray()), }; var content = new StringContent( @@ -454,6 +483,16 @@ internal static string BuildIssueTitle(string category, string description) : title[..MaxGitHubIssueTitleLength]; } + internal static string[] BuildIssueLabels(SuggestionRecord record) + { + return record.Category is "crash_report" or "unexpected_error" + ? ["bug"] + : ["enhancement"]; + } + + private static List NormalizeEvidencePaths(string[]? paths) + => SuggestionEvidencePaths.Normalize(paths); + internal static string SanitizeIssueTitleText(string value) { if (string.IsNullOrEmpty(value)) diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 9745644871..7f4ccc2801 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -441,6 +441,12 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(StatusProcessMetrics))] [JsonSerializable(typeof(SuggestionDetailJsonResult))] [JsonSerializable(typeof(SuggestionExportJsonResult))] +[JsonSerializable(typeof(SuggestionIssueDraftDuplicateMatchJsonResult))] +[JsonSerializable(typeof(SuggestionIssueDraftDuplicatePreflightJsonResult))] +[JsonSerializable(typeof(SuggestionIssueDraftExportJsonResult))] +[JsonSerializable(typeof(SuggestionIssueDraftJsonResult))] +[JsonSerializable(typeof(SuggestionIssueDraftPreflightSummaryJsonResult))] +[JsonSerializable(typeof(SuggestionIssueDraftSourceJsonResult))] [JsonSerializable(typeof(SuggestionListItemJsonResult))] [JsonSerializable(typeof(SymbolAnalysisResult))] [JsonSerializable(typeof(SymbolHotspotJsonResult))] diff --git a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs index fec5a67a07..c8d6105379 100644 --- a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs +++ b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CodeIndex.Database; using CodeIndex.Models; @@ -9,7 +10,7 @@ namespace CodeIndex.Cli; internal static class SuggestionsCommandRunner { - private const string Usage = "Usage: cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ]"; + private const string Usage = "Usage: cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ] [--open-issues ]"; public static int Run(string[] args, JsonSerializerOptions jsonOptions) { @@ -27,6 +28,8 @@ public static int Run(string[] args, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(Usage); return CommandExitCodes.UsageError; } + if (options.OpenIssuesPath != null && (verb != "export" || options.ExportFormat != "issue-drafts")) + return WriteUsageError("--open-issues can only be used with `suggestions export --format issue-drafts`."); var store = CreateStore(options.DbPath); var records = ApplyFilters(store.LoadAll(), options) @@ -112,6 +115,13 @@ private static int RunShow(List records, Options options, Json Console.WriteLine($"upstream_url: {record.UpstreamUrl}"); if (record.UpstreamIssueNumber != null) Console.WriteLine($"upstream_issue_number: {record.UpstreamIssueNumber}"); + var evidencePaths = NormalizeEvidencePaths(record); + if (evidencePaths.Count > 0) + { + Console.WriteLine("evidence_paths:"); + foreach (var path in evidencePaths) + Console.WriteLine($"- {path}"); + } Console.WriteLine(); Console.WriteLine(record.Description); if (!string.IsNullOrWhiteSpace(record.Context)) @@ -131,6 +141,8 @@ private static int RunExport(List records, Options options, Js Console.WriteLine(FormatMarkdown(records)); return CommandExitCodes.Success; } + if (options.ExportFormat == "issue-drafts") + return RunIssueDraftExport(records, options, jsonOptions); var payload = new SuggestionExportJsonResult( JsonOutputContract.ApiVersion, @@ -142,6 +154,26 @@ private static int RunExport(List records, Options options, Js return CommandExitCodes.Success; } + private static int RunIssueDraftExport(List records, Options options, JsonSerializerOptions jsonOptions) + { + if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, out var preflight, out var error)) + return WriteUsageError(error!); + + var drafts = records.Select(record => ToIssueDraft(record, preflight)).ToList(); + var payload = new SuggestionIssueDraftExportJsonResult( + JsonOutputContract.ApiVersion, + drafts.Count, + new SuggestionIssueDraftPreflightSummaryJsonResult( + preflight.Checked, + preflight.Source, + preflight.OpenIssueCount), + drafts); + Console.WriteLine(JsonSerializer.Serialize( + payload, + CliJsonSerializerContextFactory.Create(jsonOptions).SuggestionIssueDraftExportJsonResult)); + return CommandExitCodes.Success; + } + private static SuggestionStore CreateStore(string? dbPath) { var normalizedDbPath = string.IsNullOrWhiteSpace(dbPath) @@ -271,6 +303,9 @@ private static string FormatTitle(string description, int maxLength) record.McpClientName, record.McpClientVersion, record.ToolInvocationContext, + record.SampledTitle, + NormalizeNullableArray(record.SampledTags), + NormalizeEvidencePaths(record), record.Description, record.Context, IsSubmitted(record), @@ -284,6 +319,99 @@ private static string FormatTitle(string description, int maxLength) record.SubmitAttemptCount, record.LastSubmitError); + private static SuggestionIssueDraftJsonResult ToIssueDraft(SuggestionRecord record, IssueDuplicatePreflight preflight) + { + var title = BuildIssueDraftTitle(record); + var labels = GitHubIssueReporter.BuildIssueLabels(record).ToList(); + var evidencePaths = NormalizeEvidencePaths(record); + var duplicateMatches = preflight.FindMatches(title, labels); + return new SuggestionIssueDraftJsonResult( + record.Hash, + ShortId(record.Hash), + title, + labels, + evidencePaths, + BuildIssueDraftBody(record, evidencePaths), + new SuggestionIssueDraftSourceJsonResult( + record.Category, + record.Language, + GetStatus(record), + GetAgent(record), + record.CreatedAt), + new SuggestionIssueDraftDuplicatePreflightJsonResult( + preflight.Checked, + duplicateMatches.Count, + duplicateMatches)); + } + + private static string BuildIssueDraftTitle(SuggestionRecord record) + { + var titleSource = !string.IsNullOrWhiteSpace(record.SampledTitle) + ? record.SampledTitle + : record.Description; + return GitHubIssueReporter.BuildIssueTitle(record.Category, titleSource); + } + + private static string BuildIssueDraftBody(SuggestionRecord record, IReadOnlyList evidencePaths) + { + var sb = new StringBuilder(); + sb.AppendLine("## Summary"); + sb.AppendLine(GitHubIssueReporter.ScrubInlineCode(record.Description)); + sb.AppendLine(); + sb.AppendLine("## Category"); + sb.AppendLine(record.Category); + sb.AppendLine(); + sb.AppendLine("## Language"); + sb.AppendLine(record.Language ?? "N/A"); + sb.AppendLine(); + sb.AppendLine("## Evidence paths"); + if (evidencePaths.Count == 0) + { + sb.AppendLine("N/A"); + } + else + { + foreach (var path in evidencePaths) + sb.AppendLine($"- {path}"); + } + sb.AppendLine(); + sb.AppendLine("## Context"); + sb.AppendLine(record.Context != null ? GitHubIssueReporter.ScrubInlineCode(record.Context) : "N/A"); + if (!string.IsNullOrWhiteSpace(record.ToolInvocationContext)) + { + sb.AppendLine(); + sb.AppendLine("## Tool invocation context"); + sb.AppendLine(GitHubIssueReporter.ScrubInlineCode(record.ToolInvocationContext)); + } + sb.AppendLine(); + sb.AppendLine("## Suggestion metadata"); + sb.AppendLine($"- suggestion_id: `{record.Hash}`"); + sb.AppendLine($"- status: `{GetStatus(record)}`"); + sb.AppendLine($"- created_at: `{record.CreatedAt:O}`"); + var agent = GetAgent(record); + if (!string.IsNullOrWhiteSpace(agent)) + sb.AppendLine($"- agent: `{agent}`"); + if (!string.IsNullOrWhiteSpace(record.ClientVersion) && record.ClientVersion != "unknown") + sb.AppendLine($"- cdidx_version: `{record.ClientVersion}`"); + return sb.ToString().TrimEnd(); + } + + private static List NormalizeEvidencePaths(SuggestionRecord record) + => SuggestionEvidencePaths.Normalize(record.EvidencePaths); + + private static List NormalizeNullableArray(string[]? values) + { + if (values == null || values.Length == 0) + return []; + + return values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Where(value => value.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + private static string FormatMarkdown(List records) { var sb = new StringBuilder(); @@ -309,6 +437,13 @@ private static string FormatMarkdown(List records) sb.AppendLine($"- mcp_client: `{record.McpClientName}{(string.IsNullOrWhiteSpace(record.McpClientVersion) ? string.Empty : " " + record.McpClientVersion)}`"); if (!string.IsNullOrWhiteSpace(record.SessionId) && record.SessionId != "unknown") sb.AppendLine($"- session_id: `{record.SessionId}`"); + var evidencePaths = NormalizeEvidencePaths(record); + if (evidencePaths.Count > 0) + { + sb.AppendLine("- evidence_paths:"); + foreach (var path in evidencePaths) + sb.AppendLine($" - `{path}`"); + } if (!string.IsNullOrWhiteSpace(record.UpstreamUrl)) sb.AppendLine($"- upstream_url: {record.UpstreamUrl}"); if (record.UpstreamIssueNumber != null) @@ -418,8 +553,16 @@ private static Options Parse(string[] args) return options; } options.ExportFormat = format; - if (options.ExportFormat is not ("json" or "markdown")) - options.Error = "Error: --format must be one of json, markdown."; + if (!IsValidExportFormat(options.ExportFormat)) + options.Error = "Error: --format must be one of json, markdown, issue-drafts."; + break; + case "--open-issues": + if (!TryReadValue(args, ref i, "--open-issues", out var openIssuesPath, out var openIssuesError)) + { + options.Error = openIssuesError; + return options; + } + options.OpenIssuesPath = openIssuesPath; break; default: if (arg.StartsWith("--db=", StringComparison.Ordinal)) @@ -436,6 +579,8 @@ private static Options Parse(string[] args) options.Agent = arg["--agent=".Length..]; else if (arg.StartsWith("--format=", StringComparison.Ordinal)) options.ExportFormat = arg["--format=".Length..]; + else if (arg.StartsWith("--open-issues=", StringComparison.Ordinal)) + options.OpenIssuesPath = arg["--open-issues=".Length..]; else if (arg.StartsWith("--since=", StringComparison.Ordinal)) { var inlineSince = arg["--since=".Length..]; @@ -461,11 +606,13 @@ private static Options Parse(string[] args) options.ExportFormat = options.ExportFormat.ToLowerInvariant(); if (!IsValidStatusFilter(options.Status)) options.Error = "Error: --status must be one of all, draft, submitted_pending_triage, open_in_upstream, resolved_in_upstream, wont_fix, duplicate, superseded, submitted, unsubmitted."; - if (options.ExportFormat is not ("json" or "markdown")) - options.Error = "Error: --format must be one of json, markdown."; + if (!IsValidExportFormat(options.ExportFormat)) + options.Error = "Error: --format must be one of json, markdown, issue-drafts."; return options; } + private static bool IsValidExportFormat(string format) => format is "json" or "markdown" or "issue-drafts"; + private static bool TryReadValue(string[] args, ref int i, string option, out string value, out string? error) { value = string.Empty; @@ -480,6 +627,261 @@ private static bool TryReadValue(string[] args, ref int i, string option, out st return true; } + private sealed class IssueDuplicatePreflight + { + private static readonly HashSet StopTitleTokens = new(StringComparer.OrdinalIgnoreCase) + { + "ai", + "suggestion", + "suggestions", + "cdidx", + "the", + "and", + "or", + "for", + "with", + "from", + "into", + "that", + "this", + }; + + private readonly List _issues; + + private IssueDuplicatePreflight(bool isChecked, string? source, List issues) + { + Checked = isChecked; + Source = source; + _issues = issues; + } + + public bool Checked { get; } + public string? Source { get; } + public int OpenIssueCount => _issues.Count; + + public static bool TryLoad(string? path, out IssueDuplicatePreflight preflight, out string? error) + { + error = null; + if (string.IsNullOrWhiteSpace(path)) + { + preflight = new IssueDuplicatePreflight(false, null, []); + return true; + } + + try + { + var fullPath = Path.GetFullPath(path); + var root = JsonNode.Parse(File.ReadAllText(fullPath)); + preflight = new IssueDuplicatePreflight(true, fullPath, ParseOpenIssues(root)); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + preflight = new IssueDuplicatePreflight(false, null, []); + error = $"could not read --open-issues file '{path}': {ex.Message}"; + return false; + } + } + + public List FindMatches(string draftTitle, IReadOnlyList draftLabels) + { + if (!Checked || _issues.Count == 0) + return []; + + var draftLabelSet = draftLabels.ToHashSet(StringComparer.OrdinalIgnoreCase); + var normalizedDraftTitle = NormalizeTitleText(draftTitle); + var draftTokens = TokenizeTitle(draftTitle); + var matches = new List(); + foreach (var issue in _issues) + { + var issueLabels = issue.Labels + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var overlappingLabels = issueLabels + .Where(draftLabelSet.Contains) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var normalizedIssueTitle = NormalizeTitleText(issue.Title); + var score = 0.0; + string? reason = null; + if (normalizedIssueTitle.Length > 0 && normalizedIssueTitle == normalizedDraftTitle) + { + reason = "title_exact"; + score = 1.0; + } + else if (overlappingLabels.Count > 0) + { + score = ScoreTitleSimilarity(draftTokens, TokenizeTitle(issue.Title)); + if (score >= 0.45) + { + reason = "title_label_similarity"; + } + else if (normalizedIssueTitle.Length > 16 + && normalizedDraftTitle.Length > 16 + && (normalizedIssueTitle.Contains(normalizedDraftTitle, StringComparison.Ordinal) + || normalizedDraftTitle.Contains(normalizedIssueTitle, StringComparison.Ordinal))) + { + reason = "title_label_contains"; + score = Math.Max(score, 0.45); + } + } + + if (reason == null) + continue; + + matches.Add(new SuggestionIssueDraftDuplicateMatchJsonResult( + issue.Number, + issue.Title, + issue.Url, + issueLabels, + overlappingLabels, + reason, + Math.Round(score, 3))); + } + + return matches + .OrderByDescending(match => match.Score) + .ThenBy(match => match.Number ?? int.MaxValue) + .Take(5) + .ToList(); + } + + private static List ParseOpenIssues(JsonNode? root) + { + var array = root as JsonArray + ?? root?["issues"] as JsonArray + ?? root?["items"] as JsonArray; + if (array == null) + return []; + + var issues = new List(); + foreach (var item in array) + { + var title = TryReadString(item?["title"]); + if (string.IsNullOrWhiteSpace(title)) + continue; + issues.Add(new OpenIssue( + TryReadInt(item?["number"]), + title, + TryReadString(item?["url"]) ?? TryReadString(item?["html_url"]), + ReadLabels(item?["labels"]))); + } + + return issues; + } + + private static List ReadLabels(JsonNode? labelsNode) + { + if (labelsNode is not JsonArray labels) + return []; + + var result = new List(); + foreach (var labelNode in labels) + { + var label = TryReadString(labelNode) ?? TryReadString(labelNode?["name"]); + if (!string.IsNullOrWhiteSpace(label)) + result.Add(label.Trim()); + } + + return result.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static string? TryReadString(JsonNode? node) + { + if (node == null) + return null; + try + { + return node.GetValue(); + } + catch (InvalidOperationException) + { + return null; + } + } + + private static int? TryReadInt(JsonNode? node) + { + if (node == null) + return null; + try + { + return node.GetValue(); + } + catch (InvalidOperationException) + { + var value = TryReadString(node); + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + } + + private static string NormalizeTitleText(string title) + { + var builder = new StringBuilder(title.Length); + var previousWasSpace = true; + foreach (var c in title) + { + if (char.IsLetterOrDigit(c)) + { + builder.Append(char.ToLowerInvariant(c)); + previousWasSpace = false; + } + else if (!previousWasSpace) + { + builder.Append(' '); + previousWasSpace = true; + } + } + + return builder.ToString().Trim(); + } + + private static HashSet TokenizeTitle(string title) + { + var tokens = new HashSet(StringComparer.OrdinalIgnoreCase); + var current = new StringBuilder(); + foreach (var c in title) + { + if (char.IsLetterOrDigit(c)) + { + current.Append(char.ToLowerInvariant(c)); + continue; + } + + AddToken(tokens, current); + } + + AddToken(tokens, current); + return tokens; + } + + private static void AddToken(HashSet tokens, StringBuilder current) + { + if (current.Length == 0) + return; + var token = current.ToString(); + current.Clear(); + if (token.Length < 3 || StopTitleTokens.Contains(token)) + return; + tokens.Add(token); + } + + private static double ScoreTitleSimilarity(HashSet left, HashSet right) + { + if (left.Count == 0 || right.Count == 0) + return 0.0; + + var intersection = left.Count(right.Contains); + var union = left.Count + right.Count - intersection; + return union == 0 ? 0.0 : intersection / (double)union; + } + + private sealed record OpenIssue(int? Number, string Title, string? Url, List Labels); + } + private sealed class Options { public string? Id { get; set; } @@ -490,6 +892,7 @@ private sealed class Options public string? Language { get; set; } public string? Category { get; set; } public string? Agent { get; set; } + public string? OpenIssuesPath { get; set; } public DateTimeOffset? Since { get; set; } public string? Error { get; set; } } @@ -530,6 +933,9 @@ internal sealed record SuggestionDetailJsonResult( [property: JsonPropertyName("mcp_client_name")] string? McpClientName, [property: JsonPropertyName("mcp_client_version")] string? McpClientVersion, [property: JsonPropertyName("tool_invocation_context")] string? ToolInvocationContext, + [property: JsonPropertyName("sampled_title")] string? SampledTitle, + [property: JsonPropertyName("sampled_tags")] List SampledTags, + [property: JsonPropertyName("evidence_paths")] List EvidencePaths, [property: JsonPropertyName("description")] string Description, [property: JsonPropertyName("context")] string? Context, [property: JsonPropertyName("submitted_to_github")] bool SubmittedToGitHub, @@ -547,3 +953,45 @@ internal sealed record SuggestionExportJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, [property: JsonPropertyName("count")] int Count, [property: JsonPropertyName("suggestions")] List Suggestions); + +internal sealed record SuggestionIssueDraftExportJsonResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("duplicate_preflight")] SuggestionIssueDraftPreflightSummaryJsonResult DuplicatePreflight, + [property: JsonPropertyName("drafts")] List Drafts); + +internal sealed record SuggestionIssueDraftPreflightSummaryJsonResult( + [property: JsonPropertyName("checked")] bool Checked, + [property: JsonPropertyName("source")] string? Source, + [property: JsonPropertyName("open_issue_count")] int OpenIssueCount); + +internal sealed record SuggestionIssueDraftJsonResult( + [property: JsonPropertyName("suggestion_id")] string SuggestionId, + [property: JsonPropertyName("short_id")] string ShortId, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("labels")] List Labels, + [property: JsonPropertyName("evidence_paths")] List EvidencePaths, + [property: JsonPropertyName("body")] string Body, + [property: JsonPropertyName("source")] SuggestionIssueDraftSourceJsonResult Source, + [property: JsonPropertyName("duplicate_preflight")] SuggestionIssueDraftDuplicatePreflightJsonResult DuplicatePreflight); + +internal sealed record SuggestionIssueDraftSourceJsonResult( + [property: JsonPropertyName("category")] string Category, + [property: JsonPropertyName("language")] string? Language, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("agent")] string? Agent, + [property: JsonPropertyName("created_at")] DateTime CreatedAt); + +internal sealed record SuggestionIssueDraftDuplicatePreflightJsonResult( + [property: JsonPropertyName("checked")] bool Checked, + [property: JsonPropertyName("match_count")] int MatchCount, + [property: JsonPropertyName("matches")] List Matches); + +internal sealed record SuggestionIssueDraftDuplicateMatchJsonResult( + [property: JsonPropertyName("number")] int? Number, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("url")] string? Url, + [property: JsonPropertyName("labels")] List Labels, + [property: JsonPropertyName("overlapping_labels")] List OverlappingLabels, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("score")] double Score); diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index d19e057117..d2a9965b53 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -3157,6 +3157,7 @@ private static JsonArray BuildToolExamples(string name) { ["category"] = "output_format", ["description"] = "The tool response should make truncation easier to detect.", + ["evidencePaths"] = new JsonArray { "src/CodeIndex/Mcp/McpToolHandlers.cs" }, }, _ => new JsonObject(), }; diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 553478cffe..f4a3e1eb14 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -506,7 +506,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["language"] = new JsonObject { ["type"] = "string", ["description"] = "Programming language this applies to (optional)" }, ["description"] = new JsonObject { ["type"] = "string", ["description"] = "What gap or improvement you observed, or what error occurred (NOT source code)" }, ["context"] = new JsonObject { ["type"] = "string", ["description"] = "What you were trying to do when you noticed the gap (NOT source code)" }, - ["toolInvocationContext"] = new JsonObject { ["type"] = "string", ["description"] = "Natural-language context for the current tool invocation or workflow (optional, NOT source code)" } + ["toolInvocationContext"] = new JsonObject { ["type"] = "string", ["description"] = "Natural-language context for the current tool invocation or workflow (optional, NOT source code)" }, + ["evidencePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Repository-relative paths that support the suggestion (optional, no source code)" } }, ["required"] = new JsonArray { "category", "description" } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index dbf4f4cabb..08eb5d10eb 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -487,7 +487,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "path" or "project" or "solution" or "symbol" or "direction" or "groupBy" or "category" or "language" or "description" or "context" or "toolInvocationContext" or "db" => "string", - "queries" => "array", + "queries" or "evidencePaths" or "evidence_paths" => "array", _ => string.Empty, }; @@ -553,7 +553,7 @@ private static string DescribeJsonType(JsonNode? node) "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, "index" => new HashSet(StringComparer.Ordinal) { "path", "db", "rebuild", "parallelism", "maxFileBytes", "files", "commits", "changedBetween", "dryRun", "optimize" }, "backfill_fold" => new HashSet(StringComparer.Ordinal) { "dry_run", "dryRun", "force" }, - "suggest_improvement" => new HashSet(StringComparer.Ordinal) { "category", "language", "description", "context", "toolInvocationContext" }, + "suggest_improvement" => new HashSet(StringComparer.Ordinal) { "category", "language", "description", "context", "toolInvocationContext", "evidencePaths", "evidence_paths" }, _ => new HashSet(StringComparer.Ordinal), }; @@ -4010,6 +4010,9 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo var language = args?["language"]?.GetValue(); var context = args?["context"]?.GetValue(); var toolInvocationContext = args?["toolInvocationContext"]?.GetValue(); + var evidencePaths = ReadEvidencePaths(args?["evidencePaths"] ?? args?["evidence_paths"], out var evidencePathsError); + if (evidencePathsError != null) + return CreateToolErrorResponse(id, evidencePathsError); if (context != null && context.Length > MaxContextLength) return CreateToolErrorResponse(id, $"Context too long ({context.Length} chars, max {MaxContextLength})"); @@ -4068,6 +4071,7 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo ToolInvocationContext = toolInvocationContext, SampledTitle = sampling?.Title, SampledTags = sampling?.Tags, + EvidencePaths = evidencePaths, }; // Build GitHub submission callback (null if no token configured). @@ -4137,9 +4141,55 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo payload["sampled_title"] = sampling.Title; if (sampling?.Tags is { Length: > 0 }) payload["sampled_tags"] = new JsonArray(sampling.Tags.Select(tag => JsonValue.Create(tag)).ToArray()); + if (evidencePaths is { Length: > 0 }) + payload["evidence_paths"] = new JsonArray(evidencePaths.Select(path => JsonValue.Create(path)).ToArray()); return CreateToolResult(id, "Suggestion recorded. Thank you for the feedback.", payload); } + private static string[]? ReadEvidencePaths(JsonNode? node, out string? error) + { + error = null; + if (node == null) + return null; + if (node is not JsonArray array) + { + error = "evidencePaths must be an array of path strings."; + return null; + } + if (array.Count > SuggestionEvidencePaths.MaxCount) + { + error = $"evidencePaths has too many entries ({array.Count}, max {SuggestionEvidencePaths.MaxCount})."; + return null; + } + + var paths = new List(); + foreach (var item in array) + { + string? path; + try + { + path = item?.GetValue(); + } + catch (InvalidOperationException) + { + error = "evidencePaths must contain only path strings."; + return null; + } + + if (string.IsNullOrWhiteSpace(path)) + continue; + if (!SuggestionEvidencePaths.TryNormalize(path, out var normalizedPath, out var pathError)) + { + error = pathError; + return null; + } + if (normalizedPath.Length > 0 && !paths.Contains(normalizedPath, StringComparer.Ordinal)) + paths.Add(normalizedPath); + } + + return paths.Count == 0 ? null : paths.ToArray(); + } + private static string ResolveGitHubSubmissionReason(SuggestionStore.AddAndSubmitResult result, bool githubTokenConfigured) { if (result.AlreadySubmitted || result.UpstreamUrl != null) diff --git a/src/CodeIndex/Models/SuggestionEvidencePaths.cs b/src/CodeIndex/Models/SuggestionEvidencePaths.cs new file mode 100644 index 0000000000..d5a218a43d --- /dev/null +++ b/src/CodeIndex/Models/SuggestionEvidencePaths.cs @@ -0,0 +1,80 @@ +namespace CodeIndex.Models; + +internal static class SuggestionEvidencePaths +{ + public const int MaxCount = 20; + public const int MaxLength = 260; + + public static List Normalize(string[]? values) + { + if (values == null || values.Length == 0) + return []; + + var result = new List(); + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + continue; + if (!TryNormalize(value, out var normalized, out _)) + continue; + if (normalized.Length > 0 && !result.Contains(normalized, StringComparer.Ordinal)) + result.Add(normalized); + } + + return result; + } + + public static bool TryNormalize(string value, out string normalized, out string? error) + { + normalized = string.Empty; + error = null; + + var path = value.Trim(); + if (path.Length == 0) + return true; + if (path.Length > MaxLength) + { + error = $"evidencePaths contains a path longer than {MaxLength} characters."; + return false; + } + if (path.Any(char.IsControl)) + { + error = "evidencePaths entries must not contain control characters."; + return false; + } + + path = path.Replace('\\', '/'); + while (path.StartsWith("./", StringComparison.Ordinal)) + path = path[2..]; + + if (path.Length == 0) + return true; + if (IsRootedOrHomePath(path) || path.Contains("://", StringComparison.Ordinal)) + { + error = "evidencePaths entries must be repository-relative paths."; + return false; + } + + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + return true; + if (segments.Any(segment => segment is "." or "..")) + { + error = "evidencePaths entries must not contain . or .. path segments."; + return false; + } + + normalized = string.Join('/', segments); + return true; + } + + private static bool IsRootedOrHomePath(string path) + { + return path.StartsWith("/", StringComparison.Ordinal) + || path.StartsWith("~", StringComparison.Ordinal) + || (path.Length >= 2 && IsAsciiLetter(path[0]) && path[1] == ':'); + } + + private static bool IsAsciiLetter(char c) + => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; +} diff --git a/src/CodeIndex/Models/SuggestionRecord.cs b/src/CodeIndex/Models/SuggestionRecord.cs index c1bb7103c1..620343d362 100644 --- a/src/CodeIndex/Models/SuggestionRecord.cs +++ b/src/CodeIndex/Models/SuggestionRecord.cs @@ -95,6 +95,9 @@ public class SuggestionRecord /// Structured tags extracted by MCP sampling, when available / MCP sampling で抽出された構造化タグ(取得可能な場合) public string[]? SampledTags { get; set; } + /// Repository-relative paths that support the suggestion / 提案の根拠となるリポジトリ相対パス + public string[]? EvidencePaths { get; set; } + /// Upstream GitHub Issue number when known / 判明している場合の upstream GitHub Issue 番号 public int? UpstreamIssueNumber { get; set; } diff --git a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs index 3becaf52dc..00725eb15c 100644 --- a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +++ b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs @@ -279,6 +279,14 @@ public void BuildApiFailureMessage_IsActionable() Assert.Contains("retry `suggest_improvement`", message); } + [Fact] + public void BuildIssueLabels_MapsSuggestionCategoriesToExistingRepositoryLabels() + { + Assert.Equal(["enhancement"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "output_format" })); + Assert.Equal(["bug"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "crash_report" })); + Assert.Equal(["bug"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "unexpected_error" })); + } + [Fact] public void BuildApiErrorDetail_UsesStatusAndSingleLineBodyExcerpt() { @@ -470,7 +478,7 @@ public async Task TryCreateIssueAsync_SearchMissesButLabelListFindsExistingIssue Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); Assert.Equal("/search/issues", handler.Requests[0].RequestUri!.AbsolutePath); Assert.Equal("/repos/widthdom/CodeIndex/issues", handler.Requests[1].RequestUri!.AbsolutePath); - Assert.Contains("labels=ai-suggestion", handler.Requests[1].RequestUri!.Query); + Assert.Contains("labels=enhancement", handler.Requests[1].RequestUri!.Query); Assert.Contains("state=all", handler.Requests[1].RequestUri!.Query); Assert.DoesNotContain(handler.Requests, r => r.Method == HttpMethod.Post); } @@ -523,6 +531,8 @@ public async Task TryCreateIssueAsync_NoExistingIssue_CreatesNew() Assert.Contains("session-123", postedJson); Assert.Contains("MCP client: codex", postedJson); Assert.Contains("Tool invocation context: Investigating suggestion triage", postedJson); + var payload = JsonNode.Parse(postedJson)!; + Assert.Equal("enhancement", payload["labels"]![0]!.GetValue()); } finally { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index af8243bf6d..d897fffff1 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -9456,7 +9456,8 @@ public void SuggestImprovement_RecordsClientAttributionFromInitialize() { ["category"] = "other", ["description"] = uniqueDesc, - ["toolInvocationContext"] = "Investigating suggestion triage" + ["toolInvocationContext"] = "Investigating suggestion triage", + ["evidencePaths"] = new JsonArray { "src/CodeIndex/Mcp/McpToolHandlers.cs" } } } }; @@ -9473,6 +9474,35 @@ public void SuggestImprovement_RecordsClientAttributionFromInitialize() Assert.Equal("codex", stored.McpClientName); Assert.Equal("5.0", stored.McpClientVersion); Assert.Equal("Investigating suggestion triage", stored.ToolInvocationContext); + Assert.Equal(["src/CodeIndex/Mcp/McpToolHandlers.cs"], stored.EvidencePaths); + } + + [Fact] + public void SuggestImprovement_RejectsNonRelativeEvidencePath() + { + var uniqueDesc = $"Evidence path validation regression {Guid.NewGuid():N}"; + var json = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "suggest_improvement", + ["arguments"] = new JsonObject + { + ["category"] = "other", + ["description"] = uniqueDesc, + ["evidencePaths"] = new JsonArray { "/Users/example/project/src/File.cs" } + } + } + }; + + var response = _server.HandleMessage((JsonNode)json)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var message = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("repository-relative", message); } [Fact] diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index df1d8dfe4d..ce367c1023 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -628,6 +628,49 @@ public void Suggestions_ExportMarkdownIncludesFilteredSuggestions() Assert.DoesNotContain("Add parser support", stdout); } + [Fact] + public void Suggestions_ExportIssueDraftsIncludesEvidenceAndDuplicatePreflight() + { + using var fixture = SuggestionFixture.Create(); + var record = fixture.Add( + "output_format", + "csharp", + "Issue draft export should preserve structured triage evidence", + submitted: false, + sampledTitle: "Add issue draft export", + evidencePaths: ["src/CodeIndex/Cli/SuggestionsCommandRunner.cs", "tests/CodeIndex.Tests/ProgramCliTests.cs"]); + var openIssuesPath = fixture.WriteOpenIssuesJson($$""" + [ + { + "number": 2878, + "title": "[AI Suggestion] output_format: Add issue draft export", + "url": "https://github.com/Widthdom/CodeIndex/issues/2878", + "labels": [{ "name": "enhancement" }] + } + ] + """); + + var (exitCode, stdout, stderr) = RunCliInSubprocess([ + "suggestions", "export", "--db", fixture.DbPath, "--format", "issue-drafts", "--open-issues", openIssuesPath + ]); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, stderr); + using var doc = JsonDocument.Parse(stdout); + var root = doc.RootElement; + Assert.True(root.GetProperty("duplicate_preflight").GetProperty("checked").GetBoolean()); + Assert.Equal(1, root.GetProperty("duplicate_preflight").GetProperty("open_issue_count").GetInt32()); + var draft = root.GetProperty("drafts")[0]; + Assert.Equal(record.Hash, draft.GetProperty("suggestion_id").GetString()); + Assert.Equal("enhancement", draft.GetProperty("labels")[0].GetString()); + Assert.Equal("src/CodeIndex/Cli/SuggestionsCommandRunner.cs", draft.GetProperty("evidence_paths")[0].GetString()); + Assert.Contains("## Evidence paths", draft.GetProperty("body").GetString()); + var preflight = draft.GetProperty("duplicate_preflight"); + Assert.Equal(1, preflight.GetProperty("match_count").GetInt32()); + Assert.Equal(2878, preflight.GetProperty("matches")[0].GetProperty("number").GetInt32()); + Assert.Equal("title_exact", preflight.GetProperty("matches")[0].GetProperty("reason").GetString()); + } + private static (int ExitCode, string StdOut, string StdErr) RunCliInSubprocess(string[] args, IReadOnlyDictionary? environment = null) { var psi = new System.Diagnostics.ProcessStartInfo @@ -731,7 +774,9 @@ public SuggestionRecord Add( bool submitted, DateTime? lastSubmitAttempt = null, int submitAttemptCount = 0, - string? lastSubmitError = null) + string? lastSubmitError = null, + string? sampledTitle = null, + string[]? evidencePaths = null) { var record = new SuggestionRecord { @@ -746,12 +791,21 @@ public SuggestionRecord Add( LastSubmitAttempt = lastSubmitAttempt, SubmitAttemptCount = submitAttemptCount, LastSubmitError = lastSubmitError, + SampledTitle = sampledTitle, + EvidencePaths = evidencePaths, }; _records.Add(record); Write(); return record; } + public string WriteOpenIssuesJson(string json) + { + var path = Path.Combine(_root, "open-issues.json"); + File.WriteAllText(path, json); + return path; + } + private void Write() { var path = Path.Combine(_root, ".cdidx", "suggestions-codeindex.json");