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
20 changes: 20 additions & 0 deletions changelog.d/unreleased/3700.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
category: changed
issues:
- 3700
affected:
- src/CodeIndex/Cli/IndexCommandRunner.cs
- src/CodeIndex/Cli/IndexLock.cs
- src/CodeIndex/Cli/GitHubIssueReporter.cs
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
- tests/CodeIndex.Tests/GitHubIssueReporterTests.cs
---

## English

- **Runtime timestamps now use injectable clocks across CLI, MCP, and GitHub flows (#3700)** — index metadata, lock-holder metadata, MCP index start times, and GitHub rate-limit retry diagnostics now use testable UTC clock sources instead of direct wall-clock reads.

## 日本語

- **CLI / MCP / GitHub フローのランタイム時刻が注入可能な clock を使うようになりました (#3700)** — index metadata、lock-holder metadata、MCP index の開始時刻、GitHub rate-limit retry diagnostics は、直接 wall-clock を読む代わりにテスト可能な UTC clock source を使います。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3708.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 3708
affected:
- src/CodeIndex/Cli/UpdateChecker.cs
- tests/CodeIndex.Tests/ProgramRunnerTests.cs
---

## English

- **Update-check cache failures now have gated diagnostics (#3708)** — malformed cache reads and cache write failures remain best-effort, but `CDIDX_UPDATE_CHECK_DIAGNOSTICS=1` emits low-noise stable diagnostics for troubleshooting.

## 日本語

- **update-check cache failure に gated diagnostics が追加されました (#3708)** — malformed cache read と cache write failure は best-effort のまま維持しつつ、`CDIDX_UPDATE_CHECK_DIAGNOSTICS=1` で troubleshooting 向けの low-noise stable diagnostics を出力します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/3750.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: changed
issues:
- 3750
affected:
- src/CodeIndex/Cli/GitHubHttpClientFactory.cs
- src/CodeIndex/Cli/UpdateChecker.cs
- tests/CodeIndex.Tests/ProgramRunnerTests.cs
---

## English

- **Update checks now reuse the shared GitHub HTTP policy (#3750)** — release and prerelease probes apply the same proxy-aware client defaults and GitHub API headers as other GitHub REST calls.

## 日本語

- **update-check が共有 GitHub HTTP policy を再利用するようになりました (#3750)** — release / prerelease probe は、他の GitHub REST 呼び出しと同じ proxy-aware client defaults と GitHub API headers を適用します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3822.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 3822
affected:
- src/CodeIndex/Cli/UpdateChecker.cs
- tests/CodeIndex.Tests/ProgramRunnerTests.cs
---

## English

- **Update-check cache hardening was tightened (#3822)** — failed or null release probes no longer refresh the cache TTL, cache roots are validated before use, and GitHub rate-limit responses now report bounded retry metadata.

## 日本語

- **update-check cache hardening を強化しました (#3822)** — 失敗または null の release probe では cache TTL を更新せず、cache root を使用前に検証し、GitHub rate-limit response では bounded retry metadata を報告するようにしました。
21 changes: 21 additions & 0 deletions changelog.d/unreleased/3823.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
category: fixed
issues:
- 3823
affected:
- src/CodeIndex/Cli/GitHubIssueReporter.cs
- src/CodeIndex/Cli/IssueDuplicatePreflight.cs
- src/CodeIndex/Cli/ProgramRunner.Dispatch.cs
- src/CodeIndex/Cli/QueryCommandRunner.cs
- src/CodeIndex/Cli/SuggestionsCommandRunner.cs
- tests/CodeIndex.Tests/GitHubIssueReporterTests.cs
- tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs
---

## English

- **GitHub duplicate preflight diagnostics were hardened (#3823)** — GitHub open-issue preflight now uses cancellable async requests, reports bounded rate-limit retry metadata, sanitizes exception diagnostics, and adds duplicate confidence/signals from safe body and evidence-path matching.

## 日本語

- **GitHub duplicate preflight diagnostics を強化しました (#3823)** — GitHub open-issue preflight は cancellable async request を使い、bounded rate-limit retry metadata を報告し、exception diagnostics を sanitize し、安全な body / evidence-path matching から duplicate confidence/signals を追加します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/3831.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 3831
affected:
- src/CodeIndex/Cli/JsonOutputContracts.cs
- src/CodeIndex/Cli/ProgramRunner.cs
- tests/CodeIndex.Tests/ProgramRunnerTests.cs
---

## English

- **Upgrade hardening and diagnostics were improved (#3831)** — explicit release tags are constrained to semver-shaped tags, JSON upgrade failures include bounded suppressed installer output tails, install-directory probe failures report sanitized diagnostics, and upgrade JSON now states the installer checksum trust boundary.

## 日本語

- **upgrade hardening と diagnostics を改善しました (#3831)** — explicit release tag を semver-shaped tag に制限し、JSON upgrade failure に bounded suppressed installer output tail を含め、install-directory probe failure は sanitized diagnostic を報告し、upgrade JSON で installer checksum trust boundary を明示します。
33 changes: 27 additions & 6 deletions src/CodeIndex/Cli/GitHubHttpClientFactory.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
using System.Net;
using System.Net.Http.Headers;

namespace CodeIndex.Cli;

internal static class GitHubHttpClientFactory
{
internal const string ProxyDefaultCredentialsEnvironmentVariable = "CDIDX_GITHUB_PROXY_USE_DEFAULT_CREDENTIALS";
private const string GitHubApiVersionHeader = "X-GitHub-Api-Version";
private const string GitHubApiVersion = "2022-11-28";
private const string GitHubAcceptMediaType = "application/vnd.github+json";

internal static HttpClient CreateDefaultHttpClient(TimeSpan timeout)
{
Expand All @@ -19,16 +23,33 @@ internal static HttpClient CreateDefaultHttpClient(TimeSpan timeout)
var client = new HttpClient(handler)
{
Timeout = timeout,
DefaultRequestHeaders =
{
{ "User-Agent", "cdidx" },
{ "Accept", "application/vnd.github+json" },
{ "X-GitHub-Api-Version", "2022-11-28" },
},
};
ApplyDefaultHeaders(client.DefaultRequestHeaders);
return client;
}

internal static void ApplyDefaultHeaders(HttpRequestHeaders headers)
{
if (headers.UserAgent.Count == 0)
headers.UserAgent.Add(new ProductInfoHeaderValue(new ProductHeaderValue("cdidx")));

var hasGitHubAccept = false;
foreach (var accept in headers.Accept)
{
if (string.Equals(accept.MediaType, GitHubAcceptMediaType, StringComparison.OrdinalIgnoreCase))
{
hasGitHubAccept = true;
break;
}
}

if (!hasGitHubAccept)
headers.Accept.Add(new MediaTypeWithQualityHeaderValue(GitHubAcceptMediaType));

if (!headers.Contains(GitHubApiVersionHeader))
headers.Add(GitHubApiVersionHeader, GitHubApiVersion);
}

internal static bool ShouldUseDefaultProxyCredentials()
{
var raw = Environment.GetEnvironmentVariable(ProxyDefaultCredentialsEnvironmentVariable)?.Trim();
Expand Down
68 changes: 55 additions & 13 deletions src/CodeIndex/Cli/GitHubIssueReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ namespace CodeIndex.Cli;
/// </summary>
internal static class GitHubIssueReporter
{
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;

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";
Expand Down Expand Up @@ -131,7 +133,11 @@ private static HttpClient CreateDefaultHttpClient()
// レスポンスが消失した場合、ローカルレコードでは SubmittedToGitHub=false の
// ままになる。再試行で重複 Issue を作らないよう、新規 POST 前に
// 当該提案ハッシュを含む既存 Issue を探す。
var existingLookup = await FindExistingIssueByHashDetailedAsync(record.Hash, token, BuildIssueLabels(record), linkedCts.Token);
var existingLookup = await FindExistingIssueByHashDetailedAsync(
record.Hash,
token,
BuildExistingSuggestionLookupLabels(record),
linkedCts.Token);
if (existingLookup.Error != null)
{
Console.Error.WriteLine(BuildSubmissionFailureMessage(existingLookup.Error));
Expand All @@ -153,8 +159,9 @@ private static HttpClient CreateDefaultHttpClient()
{
// Best-effort: log to stderr but do not propagate.
// ベストエフォート: stderr にログ出力するが伝播しない。
Console.Error.WriteLine(BuildSubmissionFailureMessage(ex.Message));
return SuggestionStore.SubmitAttemptResult.Failure($"{ex.GetType().Name}: {ex.Message}");
var detail = CommandErrorWriter.FormatSanitizedException(ex);
Console.Error.WriteLine(BuildSubmissionFailureMessage(detail));
return SuggestionStore.SubmitAttemptResult.Failure(detail);
}
}

Expand Down Expand Up @@ -250,9 +257,10 @@ private static async Task<ExistingIssueLookupResult> SearchExistingIssueByHashAs
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await ReadBoundedApiErrorBodyAsync(response.Content, cancellationToken);
return ExistingIssueLookupResult.Failure(
BuildExistingSuggestionLookupFailure("search", BuildApiErrorDetail((int)response.StatusCode, errorBody)));
BuildExistingSuggestionLookupFailure(
"search",
await BuildGitHubApiErrorDetailAsync(response, cancellationToken).ConfigureAwait(false)));
}

JsonNode? node;
Expand All @@ -263,7 +271,7 @@ private static async Task<ExistingIssueLookupResult> SearchExistingIssueByHashAs
catch (Exception ex) when (IsRecoverableGitHubApiResponseException(ex))
{
return ExistingIssueLookupResult.Failure(
BuildExistingSuggestionLookupFailure("search", $"{ex.GetType().Name}: {ex.Message}"));
BuildExistingSuggestionLookupFailure("search", CommandErrorWriter.FormatSanitizedException(ex)));
}

var items = node?["items"] as JsonArray;
Expand All @@ -288,7 +296,7 @@ private static async Task<ExistingIssueLookupResult> SearchExistingIssueByHashAs
catch (Exception ex) when (IsRecoverableGitHubApiResponseException(ex))
{
return ExistingIssueLookupResult.Failure(
BuildExistingSuggestionLookupFailure("search", $"{ex.GetType().Name}: {ex.Message}"));
BuildExistingSuggestionLookupFailure("search", CommandErrorWriter.FormatSanitizedException(ex)));
}

return ExistingIssueLookupResult.NotFound;
Expand All @@ -300,8 +308,11 @@ private static async Task<ExistingIssueLookupResult> ListExistingSuggestionIssue
IReadOnlyList<string> lookupLabels,
CancellationToken cancellationToken)
{
foreach (var label in lookupLabels.Distinct(StringComparer.OrdinalIgnoreCase))
var labelsToQuery = lookupLabels.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
for (var labelIndex = 0; labelIndex < labelsToQuery.Count; labelIndex++)
{
var label = labelsToQuery[labelIndex];
var sawCandidateIssueForLabel = false;
for (var page = 1; page <= MaxExistingSuggestionLookupPagesPerLabel; page++)
{
var labels = Uri.EscapeDataString(label);
Expand All @@ -316,11 +327,10 @@ private static async Task<ExistingIssueLookupResult> ListExistingSuggestionIssue
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await ReadBoundedApiErrorBodyAsync(response.Content, cancellationToken);
return ExistingIssueLookupResult.Failure(
BuildExistingSuggestionLookupFailure(
$"label list '{label}' page {page}",
BuildApiErrorDetail((int)response.StatusCode, errorBody)));
await BuildGitHubApiErrorDetailAsync(response, cancellationToken).ConfigureAwait(false)));
}

JsonNode? node;
Expand All @@ -333,7 +343,7 @@ private static async Task<ExistingIssueLookupResult> ListExistingSuggestionIssue
return ExistingIssueLookupResult.Failure(
BuildExistingSuggestionLookupFailure(
$"label list '{label}' page {page}",
$"{ex.GetType().Name}: {ex.Message}"));
CommandErrorWriter.FormatSanitizedException(ex)));
}

var items = node as JsonArray;
Expand All @@ -354,6 +364,8 @@ private static async Task<ExistingIssueLookupResult> ListExistingSuggestionIssue
{
var body = item?["body"]?.GetValue<string>();
var itemUrl = TryGetOpenIssueUrl(item);
if (itemUrl != null)
sawCandidateIssueForLabel = true;
if (itemUrl != null && body != null && body.Contains(hash, StringComparison.Ordinal))
return ExistingIssueLookupResult.Found(itemUrl);
}
Expand All @@ -363,15 +375,28 @@ private static async Task<ExistingIssueLookupResult> ListExistingSuggestionIssue
return ExistingIssueLookupResult.Failure(
BuildExistingSuggestionLookupFailure(
$"label list '{label}' page {page}",
$"{ex.GetType().Name}: {ex.Message}"));
CommandErrorWriter.FormatSanitizedException(ex)));
}

if (items.Count < 100)
break;

if (page == MaxExistingSuggestionLookupPagesPerLabel)
{
WriteExistingSuggestionLookupPageCapWarning(label);
return ExistingIssueLookupResult.NotFound;
}
}

// Fan out to supplemental labels only when the primary label returned
// plausible open issues. An empty primary list is treated as a bounded
// "no local backstop candidates" result to avoid scanning every cdidx
// label on the common no-duplicate path.
// primary label が open issue 候補を返した場合だけ補助 label に広げる。
// 空の primary list は bounded な候補なしとして扱い、通常の重複なし経路で
// cdidx label 全体を走査しない。
if (labelIndex == 0 && !sawCandidateIssueForLabel)
return ExistingIssueLookupResult.NotFound;
}

return ExistingIssueLookupResult.NotFound;
Expand All @@ -389,6 +414,17 @@ private sealed record ExistingIssueLookupResult(string? IssueUrl, string? Error)
private static string BuildExistingSuggestionLookupFailure(string phase, string detail)
=> $"GitHub existing-suggestion lookup failed during {phase}: {detail}";

private static async Task<string> BuildGitHubApiErrorDetailAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
var errorBody = await ReadBoundedApiErrorBodyAsync(response.Content, cancellationToken).ConfigureAwait(false);
var rateLimitRetryAt = GetRateLimitRetryAt(response, TimeProvider.GetUtcNow().UtcDateTime);
return rateLimitRetryAt is null
? BuildApiErrorDetail((int)response.StatusCode, errorBody)
: BuildRateLimitErrorDetail((int)response.StatusCode, errorBody, rateLimitRetryAt.Value);
}

private static void WriteExistingSuggestionLookupPageCapWarning(string label)
{
var boundedLabel = SanitizeExistingSuggestionLookupLabelForWarning(label);
Expand Down Expand Up @@ -542,7 +578,7 @@ private static bool IsHexHash(string value)
if (!response.IsSuccessStatusCode)
{
var errorBody = await ReadBoundedApiErrorBodyAsync(response.Content, cancellationToken);
var rateLimitRetryAt = GetRateLimitRetryAt(response, DateTime.UtcNow);
var rateLimitRetryAt = GetRateLimitRetryAt(response, TimeProvider.GetUtcNow().UtcDateTime);
if (rateLimitRetryAt != null)
{
Console.Error.WriteLine(BuildRateLimitFailureMessage((int)response.StatusCode, errorBody, rateLimitRetryAt.Value));
Expand Down Expand Up @@ -657,6 +693,12 @@ internal static string[] BuildIssueLabels(SuggestionRecord record)
: ["enhancement"];
}

private static string[] BuildExistingSuggestionLookupLabels(SuggestionRecord record)
=> BuildIssueLabels(record)
.Concat(ExistingSuggestionLookupLabels)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

private static List<string> NormalizeEvidencePaths(string[]? paths)
=> SuggestionEvidencePaths.Normalize(paths);

Expand Down
7 changes: 5 additions & 2 deletions src/CodeIndex/Cli/IndexCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ internal sealed record ScanCheckpointLoadResult(
internal static Action<string>? DeleteScanCheckpointForTesting { get; set; }
internal static Func<bool> IsInputRedirectedForTesting { get; set; } = () => Console.IsInputRedirected;
internal static Func<string?> ReadLineForTesting { get; set; } = Console.ReadLine;
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;

private static DateTime GetUtcNow() => TimeProvider.GetUtcNow().UtcDateTime;

public static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions) =>
Run(indexArgs, jsonOptions, cancellationForTesting: null);
Expand Down Expand Up @@ -107,7 +110,7 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C
var dbResolution = DbPathResolver.ResolveForIndex(options.ProjectPath, options.DbPath, options.DataDir);
var dbPath = dbResolution.DbPath;
var stopwatch = Stopwatch.StartNew();
var runStartedAtUtc = DateTime.UtcNow;
var runStartedAtUtc = GetUtcNow();
var isUpdateMode = IsUpdateMode(options);
var mode = options.Rebuild ? "rebuild" : isUpdateMode ? "update" : "incremental";

Expand Down Expand Up @@ -927,7 +930,7 @@ private static void StampIndexedHeadMetadata(DbWriter writer, string projectRoot
var headSha = GitHelper.TryGetHeadCommit(projectRoot, cancellationToken);
var headBranch = GitHelper.TryGetHeadBranch(projectRoot, cancellationToken);
var timestamp = headSha != null
? DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture)
? GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture)
: null;
writer.SetMeta(DbContext.IndexedHeadShaMetaKey, headSha);
writer.SetMeta(DbContext.IndexedHeadBranchMetaKey, headBranch);
Expand Down
5 changes: 4 additions & 1 deletion src/CodeIndex/Cli/IndexLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ internal sealed class IndexLock : IDisposable

internal static Action<string> DeleteFileForTesting { get; set; } = File.Delete;
internal static Action<LockCleanupDiagnostic>? CleanupDiagnosticSinkForTesting { get; set; }
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;

private static DateTime GetUtcNow() => TimeProvider.GetUtcNow().UtcDateTime;

private IndexLock(FileStream stream, string lockPath, string infoPath)
{
Expand Down Expand Up @@ -102,7 +105,7 @@ public static IndexLock Acquire(string lockPath, string projectPath)
{
var info = new IndexLockInfo(
Pid: Environment.ProcessId,
StartedAt: DateTime.UtcNow);
StartedAt: GetUtcNow());
DataDirectorySecurity.WritePrivateText(infoPath, SerializeInfo(info), Encoding.UTF8);
}
catch (Exception)
Expand Down
Loading
Loading