diff --git a/changelog.d/unreleased/1802.fixed.md b/changelog.d/unreleased/1802.fixed.md new file mode 100644 index 0000000000..d64018045b --- /dev/null +++ b/changelog.d/unreleased/1802.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1802 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs +--- + +## English + +- **MCP suggestion submission no longer blocks on sync-over-async GitHub calls (#1802)** — `suggest_improvement` now awaits GitHub issue creation through the MCP dispatch path and preserves request cancellation. + +## 日本語 + +- **MCP 提案送信が sync-over-async の GitHub 呼び出しでブロックしないようになりました (#1802)** — `suggest_improvement` は MCP dispatch 経路で GitHub Issue 作成を await し、リクエストキャンセルも維持します。 diff --git a/changelog.d/unreleased/2006.fixed.md b/changelog.d/unreleased/2006.fixed.md new file mode 100644 index 0000000000..5a1244d130 --- /dev/null +++ b/changelog.d/unreleased/2006.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2006 +affected: + - src/CodeIndex/Cli/GitHubIssueReporter.cs + - tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +--- + +## English + +- **GitHub suggestion code scrubbing now handles nested and escaped backticks (#2006)** — inline code spans containing template-style or escaped backticks are removed before outbound issue submission. + +## 日本語 + +- **GitHub 提案のコード除去がネスト・エスケープされたバッククォートに対応しました (#2006)** — template 風またはエスケープされたバッククォートを含む inline code span を、Issue 送信前に除去します。 diff --git a/changelog.d/unreleased/2007.fixed.md b/changelog.d/unreleased/2007.fixed.md new file mode 100644 index 0000000000..2a44a0005e --- /dev/null +++ b/changelog.d/unreleased/2007.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2007 +affected: + - src/CodeIndex/Cli/GitHubIssueReporter.cs + - tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +--- + +## English + +- **GitHub suggestion titles are now clamped to the REST API title limit (#2007)** — generated issue titles are bounded before `POST /issues`, avoiding avoidable validation failures for long categories or descriptions. + +## 日本語 + +- **GitHub 提案タイトルを REST API のタイトル上限内に収めるようにしました (#2007)** — 長い category や description でも `POST /issues` 前に Issue title を制限し、回避可能な validation failure を防ぎます。 diff --git a/changelog.d/unreleased/2008.fixed.md b/changelog.d/unreleased/2008.fixed.md new file mode 100644 index 0000000000..c297489d1a --- /dev/null +++ b/changelog.d/unreleased/2008.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2008 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **Suggestion dedup hashes now use the GitHub-visible title and scrubbed description (#2008)** — inline-code-only differences no longer create a different local dedup identity than the issue title and body users would see on GitHub. + +## 日本語 + +- **提案の重複排除ハッシュが GitHub 表示用の title と除去済みの description を使うようになりました (#2008)** — inline code だけの違いで、GitHub 上の Issue 件名と本文と異なるローカル重複排除 identity が作られないようにしました。 diff --git a/src/CodeIndex/Cli/GitHubIssueReporter.cs b/src/CodeIndex/Cli/GitHubIssueReporter.cs index e79e1bf9a0..21aeeed697 100644 --- a/src/CodeIndex/Cli/GitHubIssueReporter.cs +++ b/src/CodeIndex/Cli/GitHubIssueReporter.cs @@ -48,6 +48,7 @@ internal static class GitHubIssueReporter internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan DefaultRateLimitRetryDelay = TimeSpan.FromMinutes(1); private const string TimeoutEnvironmentVariable = "CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS"; + internal const int MaxGitHubIssueTitleLength = 255; // Static HttpClient singleton — .NET best practice for reuse. // 静的 HttpClient シングルトン — .NET の再利用ベストプラクティス。 @@ -321,11 +322,7 @@ private static bool IsHexHash(string value) // Build the issue title — scrub and truncate for readability. // Issue タイトルを構築 — 除去・切り詰めて可読性を確保。 - var scrubbedForTitle = ScrubInlineCode(record.Description); - var shortDesc = scrubbedForTitle.Length > 60 - ? scrubbedForTitle[..60] + "..." - : scrubbedForTitle; - var title = $"[AI Suggestion] {record.Category}: {shortDesc}"; + var title = BuildIssueTitle(record.Category, record.Description); // Scrub inline code from description and context before external submission. // SourceCodeDetector intentionally allows short inline code examples for local @@ -435,15 +432,109 @@ internal static string ScrubInlineCode(string text) @"(?s)```.*?```", "[code example removed]"); - // Replace single-backtick inline spans after fenced blocks so triple - // fences cannot leave stray backticks around a placeholder. - // fenced block を先に置換し、triple fence が placeholder 周辺に残らないようにする。 - return Regex.Replace( - scrubbed, - @"(?= MaxGitHubIssueTitleLength) + return prefix[..MaxGitHubIssueTitleLength]; + + var scrubbedForTitle = ScrubInlineCode(description).Replace("\r", " ").Replace("\n", " ").Trim(); + var maxDescriptionLength = MaxGitHubIssueTitleLength - prefix.Length; + var shortDesc = TruncateWithEllipsis(scrubbedForTitle, Math.Min(63, maxDescriptionLength)); + var title = prefix + shortDesc; + return title.Length <= MaxGitHubIssueTitleLength + ? title + : title[..MaxGitHubIssueTitleLength]; + } + + private static string TruncateWithEllipsis(string value, int maxLength) + { + if (value.Length <= maxLength) + return value; + if (maxLength <= 3) + return value[..maxLength]; + return value[..(maxLength - 3)] + "..."; + } + + private static string ScrubSingleBacktickSpans(string text) + { + var builder = new StringBuilder(text.Length); + var index = 0; + while (index < text.Length) + { + if (text[index] != '`' || IsEscaped(text, index) || IsTripleBacktickAt(text, index)) + { + builder.Append(text[index]); + index++; + continue; + } + + var close = FindInlineCodeClose(text, index + 1); + if (close < 0) + { + builder.Append(text[index]); + index++; + continue; + } + + builder.Append("[code example removed]"); + index = close + 1; + } + + return builder.ToString(); } + private static int FindInlineCodeClose(string text, int start) + { + for (var i = start; i < text.Length; i++) + { + if (text[i] == '\r' || text[i] == '\n') + return -1; + if (text[i] != '`' || IsEscaped(text, i) || IsTripleBacktickAt(text, i)) + continue; + + var next = i + 1 < text.Length ? text[i + 1] : '\0'; + var previous = i > start ? text[i - 1] : '\0'; + if ((char.IsWhiteSpace(previous) || previous == '=') && + char.IsLetterOrDigit(next) && + HasLaterInlineBacktick(text, i + 1)) + continue; + + while (i + 1 < text.Length && text[i + 1] == '`') + i++; + return i; + } + + return -1; + } + + private static bool IsEscaped(string text, int index) + { + var slashCount = 0; + for (var i = index - 1; i >= 0 && text[i] == '\\'; i--) + slashCount++; + return slashCount % 2 == 1; + } + + private static bool HasLaterInlineBacktick(string text, int start) + { + for (var i = start; i < text.Length; i++) + { + if (text[i] == '\r' || text[i] == '\n') + return false; + if (text[i] == '`' && !IsEscaped(text, i) && !IsTripleBacktickAt(text, i)) + return true; + } + + return false; + } + + private static bool IsTripleBacktickAt(string text, int index) + => index + 2 < text.Length && text[index + 1] == '`' && text[index + 2] == '`'; + internal static string BuildSubmissionFailureMessage(string detail) => $"[cdidx] GitHub issue creation failed: {detail}. The suggestion stays recorded locally; check `CDIDX_GITHUB_TOKEN`, network access, and proxy environment variables (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, `NO_PROXY`), then retry `suggest_improvement` when ready."; diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index ceaf7739cb..d99b461c0f 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -9,11 +9,11 @@ namespace CodeIndex.Cli; /// /// Reads and writes improvement suggestions to .cdidx/suggestions-*.json. -/// Provides deduplication via SHA256 hash of (category + language + normalized description). +/// Provides deduplication via SHA256 hash of (category + language + externally visible title and description). /// All read-modify-write operations are serialized with a file lock to prevent /// concurrent writers from silently overwriting each other's changes. /// 改善提案を .cdidx/suggestions-*.json に読み書きする。 -/// (category + language + 正規化済み description) のSHA256ハッシュで重複排除する。 +/// (category + language + 外部表示用 title と description) のSHA256ハッシュで重複排除する。 /// 全ての read-modify-write 操作はファイルロックでシリアライズされ、 /// 並行書き込み者が互いの変更をサイレントに上書きすることを防ぐ。 /// @@ -92,15 +92,17 @@ public SuggestionStore(string cdidxDir, string? dbName = null) /// /// Compute the dedup hash for a suggestion. - /// The hash is derived from category, language (lowered), and description (trimmed + lowered). - /// This ensures that trivially different phrasings (e.g. different casing) produce the same hash. + /// The hash is derived from category, language (lowered), GitHub-visible title, and + /// GitHub-visible description after outbound code scrubbing, trimming, and lowercasing. /// 提案の重複排除用ハッシュを計算する。 - /// category、language(小文字化)、description(trim + 小文字化)から導出する。 - /// 些細な表現差(大小文字等)で同じハッシュが生成されるようにする。 + /// category、language(小文字化)、GitHub 表示用 title、GitHub 表示用にコード除去された + /// description(trim + 小文字化)から導出する。 /// public static string ComputeHash(string category, string? language, string description) { - var normalized = $"{category}|{(language ?? "").ToLowerInvariant()}|{description.Trim().ToLowerInvariant()}"; + var externallyVisibleDescription = GitHubIssueReporter.ScrubInlineCode(description); + var externallyVisibleTitle = GitHubIssueReporter.BuildIssueTitle(category, description); + var normalized = $"{category}|{(language ?? "").ToLowerInvariant()}|{externallyVisibleTitle.ToLowerInvariant()}|{externallyVisibleDescription.Trim().ToLowerInvariant()}"; var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); return Convert.ToHexString(hashBytes).ToLowerInvariant(); } @@ -171,6 +173,17 @@ public record SubmitAttemptResult(string? IssueUrl, string? Error, DateTime? Nex /// 未送信の重複)にのみロック外で呼ばれる。成功時は Issue URL を返す。 /// public AddAndSubmitResult TryAddAndSubmit(SuggestionRecord record, Func? submitToGitHub) + { + return TryAddAndSubmitAsync( + record, + submitToGitHub == null + ? null + : r => Task.FromResult(submitToGitHub(r))).GetAwaiter().GetResult(); + } + + public async Task TryAddAndSubmitAsync( + SuggestionRecord record, + Func>? submitToGitHub) { var reservation = WithFileLock(() => { @@ -232,7 +245,7 @@ public AddAndSubmitResult TryAddAndSubmit(SuggestionRecord record, Func outOfBandTransport.WriteOutOfBandFrameAsync(frameToWrite, loopToken).GetAwaiter().GetResult() : null; BeginDeferredFrameLogs(); - response = ProcessFrame(frame); + response = await ProcessFrameAsync(frame).ConfigureAwait(false); } finally { @@ -519,7 +519,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella if (IsCancellationFrame(frame)) { BeginDeferredFrameLogs(); - var response = ProcessFrame(frame); + var response = await ProcessFrameAsync(frame).ConfigureAwait(false); await writeGate.WaitAsync(loopToken).ConfigureAwait(false); try { @@ -556,7 +556,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella } }; BeginDeferredFrameLogs(); - response = ProcessFrame(frame); + response = await ProcessFrameAsync(frame).ConfigureAwait(false); } finally { @@ -598,7 +598,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella internal async Task ProcessLineAsync(string line, TextWriter writer) { BeginDeferredFrameLogs(); - var response = ProcessFrame(line); + var response = await ProcessFrameAsync(line).ConfigureAwait(false); if (response != null) { try @@ -658,6 +658,9 @@ internal static string BuildInvalidUtf8ErrorLog(string detail) /// 実装が共有するトランスポート非依存の合流点 (issue #1558)。 /// internal string? ProcessFrame(string line) + => ProcessFrameAsync(line).GetAwaiter().GetResult(); + + internal async Task ProcessFrameAsync(string line) { if (string.IsNullOrWhiteSpace(line)) return null; @@ -684,7 +687,7 @@ internal static string BuildInvalidUtf8ErrorLog(string detail) return null; ExtractResponseId(request, out responseHasId, out responseId); - var response = HandleMessage(request); + var response = await HandleMessageAsync(request).ConfigureAwait(false); return response != null ? SerializeResponseOrFallback(response, responseHasId, responseId) : null; } catch (JsonException ex) @@ -796,6 +799,9 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id /// JSON-RPCメッセージを適切なハンドラにルーティング。 /// internal JsonNode? HandleMessage(JsonNode request) + => HandleMessageAsync(request).GetAwaiter().GetResult(); + + internal async Task HandleMessageAsync(JsonNode request) { if (request is not JsonObject obj) return CreateErrorResponse(hasId: false, id: null, code: -32600, message: "Invalid request: expected JSON object", @@ -891,28 +897,31 @@ private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id retrySafe: false); } - return DispatchWithRequestCancellation(id, () => method switch + return await DispatchWithRequestCancellationAsync(id, () => method switch { - "initialize" => HandleInitialize(id, request["params"]), - "tools/list" => HandleToolsList(id), - "tools/call" => HandleToolsCall(id, request["params"]), - "resources/list" => HandleResourcesList(id, request["params"]), - "resources/read" => HandleResourcesRead(id, request["params"]), - "prompts/list" => HandlePromptsList(id), - "prompts/get" => HandlePromptsGet(id, request["params"]), - "ping" => CreateSuccessResponse(hasId, id, new JsonObject()), - _ => CreateErrorResponse(hasId: true, id: id, code: -32601, message: $"Method not found: {method}", + "initialize" => Task.FromResult(HandleInitialize(id, request["params"])), + "tools/list" => Task.FromResult(HandleToolsList(id)), + "tools/call" => HandleToolsCallAsync(id, request["params"]), + "resources/list" => Task.FromResult(HandleResourcesList(id, request["params"])), + "resources/read" => Task.FromResult(HandleResourcesRead(id, request["params"])), + "prompts/list" => Task.FromResult(HandlePromptsList(id)), + "prompts/get" => Task.FromResult(HandlePromptsGet(id, request["params"])), + "ping" => Task.FromResult(CreateSuccessResponse(hasId, id, new JsonObject())), + _ => Task.FromResult(CreateErrorResponse(hasId: true, id: id, code: -32601, message: $"Method not found: {method}", category: McpErrorEnvelope.CategoryMethodNotFound, suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get, ping, notifications/initialized, notifications/cancelled, notifications/shutdown.", - retrySafe: false), - }); + retrySafe: false)), + }).ConfigureAwait(false); } private JsonNode DispatchWithRequestCancellation(JsonNode? id, Func action) + => DispatchWithRequestCancellationAsync(id, () => Task.FromResult(action())).GetAwaiter().GetResult(); + + private async Task DispatchWithRequestCancellationAsync(JsonNode? id, Func> action) { var requestKey = SerializeRequestId(id); if (requestKey == null) - return action(); + return await action().ConfigureAwait(false); using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(_currentRequestToken.Value, _shutdownCts.Token); if (!_activeRequests.TryAdd(requestKey, requestCts)) @@ -929,7 +938,7 @@ private JsonNode DispatchWithRequestCancellation(JsonNode? id, Func ac { _currentRequestToken.Value = requestCts.Token; requestCts.Token.ThrowIfCancellationRequested(); - return action(); + return await action().ConfigureAwait(false); } catch (OperationCanceledException) when (requestCts.IsCancellationRequested) { @@ -1517,6 +1526,9 @@ internal static JsonObject CreateRateLimitedErrorResponse(JsonNode? id, string t /// ツール呼び出しを実行。 /// private JsonNode HandleToolsCall(JsonNode? id, JsonNode? callParams) + => HandleToolsCallAsync(id, callParams).GetAwaiter().GetResult(); + + private async Task HandleToolsCallAsync(JsonNode? id, JsonNode? callParams) { var toolName = callParams?["name"]?.GetValue(); var args = callParams?["arguments"]; @@ -1599,7 +1611,6 @@ private JsonNode HandleToolsCall(JsonNode? id, JsonNode? callParams) else { response = toolName switch - { "search" => ExecuteSearch(id, args), "definition" => ExecuteDefinition(id, args), @@ -1624,7 +1635,7 @@ private JsonNode HandleToolsCall(JsonNode? id, JsonNode? callParams) "ping" => ExecutePing(id), "index" => ExecuteIndex(id, args, progressToken), "backfill_fold" => ExecuteBackfillFold(id, progressToken), - "suggest_improvement" => ExecuteSuggestImprovement(id, args), + "suggest_improvement" => await ExecuteSuggestImprovementAsync(id, args).ConfigureAwait(false), _ => CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"Unknown tool: {toolName}", category: McpErrorEnvelope.CategoryToolUnknown, suggestion: "Call tools/list to enumerate the available tool names for this server. Tool name match is case-sensitive.", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 1f78ec9f25..7a306ae81f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2896,6 +2896,9 @@ private JsonNode ExecuteBackfillFold(JsonNode? id, JsonNode? progressToken = nul /// description と context にソースコードが含まれていないことを検証する。 /// private JsonNode ExecuteSuggestImprovement(JsonNode? id, JsonNode? args) + => ExecuteSuggestImprovementAsync(id, args).GetAwaiter().GetResult(); + + private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNode? args) { // 1. Validate required parameters / 必須パラメータのバリデーション if (!TryReadRequiredStringParameter(args, "category", out var category, out var requiredError)) @@ -2974,14 +2977,15 @@ private JsonNode ExecuteSuggestImprovement(JsonNode? id, JsonNode? args) // Build GitHub submission callback (null if no token configured). // GitHub 送信コールバックを構築(トークン未設定なら null)。 - Func? githubCallback = null; + Func>? githubCallback = null; if (GitHubIssueReporter.ResolveToken() != null) { var version = _version; - githubCallback = r => GitHubIssueReporter.TryCreateIssueDetailedAsync(r, version).GetAwaiter().GetResult(); + var cancellationToken = _currentRequestToken.Value; + githubCallback = r => GitHubIssueReporter.TryCreateIssueDetailedAsync(r, version, cancellationToken); } - var result = store.TryAddAndSubmit(record, githubCallback); + var result = await store.TryAddAndSubmitAsync(record, githubCallback).ConfigureAwait(false); if (!result.IsNew) { diff --git a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs index a71c4211a0..fda157a009 100644 --- a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +++ b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text; +using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Models; @@ -115,6 +116,42 @@ public void ScrubInlineCode_RemovesMultipleSpans() Assert.Equal("Both [code example removed] and [code example removed] are missed", result); } + [Fact] + public void ScrubInlineCode_RemovesInlineSpanContainingNestedBackticks() + { + var input = "Template examples like `const x = `template`` should not leak"; + var result = GitHubIssueReporter.ScrubInlineCode(input); + Assert.Equal("Template examples like [code example removed] should not leak", result); + Assert.DoesNotContain("template", result); + } + + [Fact] + public void ScrubInlineCode_IgnoresEscapedBackticksInsideInlineSpan() + { + var input = "Escaped examples like `const x = \\`secret\\`` should not leak"; + var result = GitHubIssueReporter.ScrubInlineCode(input); + Assert.Equal("Escaped examples like [code example removed] should not leak", result); + Assert.DoesNotContain("secret", result); + } + + [Fact] + public void ScrubInlineCode_RemovesInlineSpanFollowedByLetter() + { + var input = "Call `secret()`when submitting"; + var result = GitHubIssueReporter.ScrubInlineCode(input); + Assert.Equal("Call [code example removed]when submitting", result); + Assert.DoesNotContain("secret", result); + } + + [Fact] + public void ScrubInlineCode_RemovesInlineSpanWithTrailingSpaceBeforeAdjacentText() + { + var input = "Use `secret `and retry"; + var result = GitHubIssueReporter.ScrubInlineCode(input); + Assert.Equal("Use [code example removed]and retry", result); + Assert.DoesNotContain("secret", result); + } + [Fact] public void ScrubInlineCode_PreservesPlainText() { @@ -250,6 +287,59 @@ public void BuildApiErrorDetail_UsesStatusAndSingleLineBodyExcerpt() Assert.Equal("422: { \"message\":\"validation failed\" }", detail); } + [Fact] + public void BuildIssueTitle_ClampsFinalTitleToGitHubLimit() + { + var category = new string('c', 240); + var title = GitHubIssueReporter.BuildIssueTitle(category, new string('d', 200)); + + Assert.True(title.Length <= GitHubIssueReporter.MaxGitHubIssueTitleLength); + } + + [Fact] + public async Task TryCreateIssueAsync_PostPayloadTitleDoesNotExceedGitHubLimit() + { + _env.Set("CDIDX_GITHUB_TOKEN", "ghp_title_length_test"); + + var handler = new RecordingHandler(); + handler.AddResponse(req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/search/issues", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = MakeJsonContent("""{ "total_count": 0, "items": [] }"""), + }); + handler.AddResponse(req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/repos/widthdom/CodeIndex/issues", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = MakeJsonContent("[]"), + }); + handler.AddResponse(req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/issues"), + new HttpResponseMessage(HttpStatusCode.Created) + { + Content = MakeJsonContent("""{ "html_url": "https://github.com/widthdom/CodeIndex/issues/4242" }"""), + }); + using var mockClient = new HttpClient(handler); + GitHubIssueReporter.s_httpClientOverride = mockClient; + try + { + var description = new string('d', 500); + var record = MakeRecordWithKnownHash(); + record.Category = new string('c', 240); + record.Description = description; + record.Hash = SuggestionStore.ComputeHash(record.Category, record.Language, description); + + await GitHubIssueReporter.TryCreateIssueAsync(record, "1.0.0-test"); + + var postedJson = Assert.Single(handler.RequestBodies); + var payload = JsonNode.Parse(postedJson)!.AsObject(); + var title = payload["title"]!.GetValue(); + Assert.True(title.Length <= GitHubIssueReporter.MaxGitHubIssueTitleLength); + } + finally + { + GitHubIssueReporter.s_httpClientOverride = null; + } + } + [Fact] public void GetRateLimitRetryAt_UsesRetryAfterDeltaFor429() { diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 70a53ce764..f76be33596 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -55,6 +55,15 @@ public void ComputeHash_TrimsWhitespace() Assert.Equal(hash1, hash2); } + [Fact] + public void ComputeHash_UsesExternallyVisibleScrubbedDescription() + { + var hash1 = SuggestionStore.ComputeHash("other", null, "Code sample `secret()` is missing"); + var hash2 = SuggestionStore.ComputeHash("other", null, "Code sample `otherSecret()` is missing"); + + Assert.Equal(hash1, hash2); + } + [Fact] public void ComputeHash_NullLanguage_TreatedAsEmpty() {