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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1802.fixed.md
Original file line number Diff line number Diff line change
@@ -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 し、リクエストキャンセルも維持します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2006.fixed.md
Original file line number Diff line number Diff line change
@@ -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 送信前に除去します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2007.fixed.md
Original file line number Diff line number Diff line change
@@ -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 を防ぎます。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2008.fixed.md
Original file line number Diff line number Diff line change
@@ -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 が作られないようにしました。
115 changes: 103 additions & 12 deletions src/CodeIndex/Cli/GitHubIssueReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 の再利用ベストプラクティス。
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
@"(?<!`)`[^`\r\n]+`(?!`)",
"[code example removed]");
return ScrubSingleBacktickSpans(scrubbed);
}

internal static string BuildIssueTitle(string category, string description)
{
var prefix = $"[AI Suggestion] {category}: ";
if (prefix.Length >= 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.";

Expand Down
29 changes: 21 additions & 8 deletions src/CodeIndex/Cli/SuggestionStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ namespace CodeIndex.Cli;

/// <summary>
/// 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 操作はファイルロックでシリアライズされ、
/// 並行書き込み者が互いの変更をサイレントに上書きすることを防ぐ。
/// </summary>
Expand Down Expand Up @@ -92,15 +92,17 @@ public SuggestionStore(string cdidxDir, string? dbName = null)

/// <summary>
/// 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 + 小文字化)から導出する
/// </summary>
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();
}
Expand Down Expand Up @@ -171,6 +173,17 @@ public record SubmitAttemptResult(string? IssueUrl, string? Error, DateTime? Nex
/// 未送信の重複)にのみロック外で呼ばれる。成功時は Issue URL を返す。
/// </param>
public AddAndSubmitResult TryAddAndSubmit(SuggestionRecord record, Func<SuggestionRecord, SubmitAttemptResult>? submitToGitHub)
{
return TryAddAndSubmitAsync(
record,
submitToGitHub == null
? null
: r => Task.FromResult(submitToGitHub(r))).GetAwaiter().GetResult();
}

public async Task<AddAndSubmitResult> TryAddAndSubmitAsync(
SuggestionRecord record,
Func<SuggestionRecord, Task<SubmitAttemptResult>>? submitToGitHub)
{
var reservation = WithFileLock(() =>
{
Expand Down Expand Up @@ -232,7 +245,7 @@ public AddAndSubmitResult TryAddAndSubmit(SuggestionRecord record, Func<Suggesti
SubmitAttemptResult submitResult;
try
{
submitResult = submitToGitHub(reservation.RecordToSubmit);
submitResult = await submitToGitHub(reservation.RecordToSubmit).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException)
{
Expand Down
Loading
Loading