Merge branch 'claudedo/387-unit-branch-teil-3-firmen-modus-fetch-pu'
This commit is contained in:
@@ -36,6 +36,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEnti
|
||||
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
|
||||
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
|
||||
builder.Property(w => w.MergeCommit).HasColumnName("merge_commit");
|
||||
builder.Property(w => w.PullRequestUrl).HasColumnName("pull_request_url");
|
||||
builder.Property(w => w.State).HasColumnName("state").IsRequired()
|
||||
.HasDefaultValue(WorktreeState.Active)
|
||||
.HasConversion(StateConverter);
|
||||
|
||||
@@ -450,6 +450,45 @@ public sealed class GitService
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>git fetch origin</c> for one branch into <c>refs/remotes/origin/<branch></c>. The explicit
|
||||
/// refspec works without a configured fetch refspec. <paramref name="env"/> carries the auth
|
||||
/// header (never the command line) and must disable the terminal prompt.
|
||||
/// </summary>
|
||||
public async Task<(int ExitCode, string Stderr)> FetchBranchAsync(
|
||||
string repoDir, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["fetch", "origin", $"+refs/heads/{branch}:refs/remotes/origin/{branch}"], ct, env: env);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
/// <summary>Moves the local, not checked-out <paramref name="branch"/> to <c>origin/<branch></c>; git itself refuses a non-fast-forward. No network.</summary>
|
||||
public async Task<(int ExitCode, string Stderr)> FastForwardLocalBranchAsync(
|
||||
string repoDir, string branch, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["fetch", ".", $"refs/remotes/origin/{branch}:refs/heads/{branch}"], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
/// <summary><c>git push -u origin <branch></c> from <paramref name="worktreePath"/>; auth via <paramref name="env"/>.</summary>
|
||||
public async Task<(int ExitCode, string Stderr)> PushBranchAsync(
|
||||
string worktreePath, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["push", "-u", "origin", branch], ct, env: env);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
/// <summary>Branch that <c>refs/remotes/origin/HEAD</c> points to (without <c>origin/</c>), or null.</summary>
|
||||
public async Task<string?> GetRemoteHeadBranchAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], ct);
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) return null;
|
||||
var head = stdout.Trim();
|
||||
return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head;
|
||||
}
|
||||
|
||||
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
|
||||
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
|
||||
string worktreePath, string sourceRef, CancellationToken ct = default)
|
||||
@@ -721,7 +760,8 @@ public sealed class GitService
|
||||
|
||||
|
||||
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true,
|
||||
IReadOnlyDictionary<string, string>? env = null)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
@@ -742,6 +782,8 @@ public sealed class GitService
|
||||
// of the OS locale git for Windows picks up.
|
||||
psi.Environment["LC_ALL"] = "C";
|
||||
psi.Environment["LANGUAGE"] = "C";
|
||||
if (env is not null)
|
||||
foreach (var (key, value) in env) psi.Environment[key] = value;
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workDir);
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
+1172
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWorktreePullRequestUrl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "pull_request_url",
|
||||
table: "worktrees",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "pull_request_url",
|
||||
table: "worktrees");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1019,6 +1019,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("PullRequestUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pull_request_url");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class WorktreeEntity
|
||||
public string? HeadCommit { get; set; }
|
||||
public string? DiffStat { get; set; }
|
||||
public string? MergeCommit { get; set; }
|
||||
/// <summary>Draft-PR auf Azure DevOps, gesetzt vom Approve im Firmen-Repo.</summary>
|
||||
public string? PullRequestUrl { get; set; }
|
||||
public WorktreeState State { get; set; } = WorktreeState.Active;
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
|
||||
|
||||
@@ -552,6 +552,10 @@ public static class PromptFiles
|
||||
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
||||
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
|
||||
|
||||
## Company repos (Azure DevOps)
|
||||
|
||||
If the list's repo is a company repo (origin on the Azure DevOps Server), approve does not merge anything locally: review_task approve runs the verify command in the task's own worktree, pushes its branch and opens a draft PR (or reuses the one already open for that branch). mergeStatus is "pr_created" or "pr_updated" and mergeMessage is the PR URL; the task deliberately stays WaitingForReview until the user merges the PR on Azure DevOps and ticks it Done. There are no merge conflicts to resolve and main does not move, so skip preview_merge_set's merge-order reasoning, do NOT call verify_merges (the verify already ran inside approve, skipVerify is ignored there), and treat "erst mergen, dann neu starten" as harmless. A blocked result names what failed (no Azure DevOps PAT, push rejected, or "gepusht, PR fehlgeschlagen: …" when the push landed but the PR call did not) — report it, do not retry blindly. In the Phase 5 summary, list every PR link.
|
||||
|
||||
## Verify once — after the last merge
|
||||
|
||||
When every merge above is done (including conflict resolutions), call verify_merges with the ids of ALL tasks that reported merged_verify_pending. It runs the list's verify command a single time and promotes them all to Done on success — that is the per-merge gate you skipped, paid once.
|
||||
@@ -600,7 +604,7 @@ public static class PromptFiles
|
||||
|
||||
## Phase 5 — Summary
|
||||
Print one line per task from the brief:
|
||||
title — final status — merge commit (if any) — conflicts resolved (if any) — verify-failed (yes/no).
|
||||
title — final status — merge commit or PR link (if any) — conflicts resolved (if any) — verify-failed (yes/no).
|
||||
|
||||
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
|
||||
""";
|
||||
|
||||
@@ -45,6 +45,14 @@ public sealed class WorktreeRepository
|
||||
.SetProperty(w => w.MergeCommit, mergeCommit), ct);
|
||||
}
|
||||
|
||||
/// <summary>Stores the draft PR an approve in a company repo created or reused.</summary>
|
||||
public async Task SetPullRequestUrlAsync(string taskId, string url, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Worktrees
|
||||
.Where(w => w.TaskId == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(w => w.PullRequestUrl, url), ct);
|
||||
}
|
||||
|
||||
/// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto
|
||||
/// commit and its head moves to the new tip <c>git rebase</c> produced.</summary>
|
||||
public async Task SetRebasedAsync(string taskId, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||
|
||||
@@ -96,7 +96,7 @@ public record PlanningMergeConflictStateDto(string PlanningTaskId, string Subtas
|
||||
|
||||
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage, string? PullRequestUrl = null);
|
||||
|
||||
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
|
||||
|
||||
@@ -108,7 +108,7 @@ public record MergePreviewDto(
|
||||
|
||||
public record MergeTargetsDto(
|
||||
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
|
||||
bool TargetDirty = false);
|
||||
bool TargetDirty = false, bool IsCompanyRepo = false);
|
||||
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
|
||||
@@ -240,5 +240,8 @@ public record HandlerOpenTaskDto(string Id, string Title);
|
||||
|
||||
public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet, string? FallbackListId = null);
|
||||
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
|
||||
public record AzureDevOpsSettingsDto(bool TokenSet);
|
||||
// Repo = the company repo the test ran against (first list whose origin matches), DefaultBranch = its ADO default.
|
||||
public record AzureDevOpsConnectionDto(bool Ok, string? Repo, string? DefaultBranch, string? Error);
|
||||
public record TicketProjectDto(int Id, string Title, string DepartmentName);
|
||||
public record TicketImportResultDto(int Examined, int Created);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"categoryFiles": "Dateien & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
"categoryReports": "Berichte",
|
||||
"categoryTickets": "Ticketsystem"
|
||||
"categoryTickets": "Externe Dienste"
|
||||
},
|
||||
"repoHint": {
|
||||
"text": "Wirkt erst mit verknüpftem Repo.",
|
||||
@@ -148,6 +148,10 @@
|
||||
"saveButton": "Konfiguration speichern"
|
||||
},
|
||||
"tickets": {
|
||||
"ticketSection": "TICKETSYSTEM",
|
||||
"azdoSection": "AZURE DEVOPS",
|
||||
"azdoToken": "Personal Access Token (Scope: Code Read & Write)",
|
||||
"azdoHint": "Gilt in Firmen-Repos (origin auf dem Azure DevOps Server): Fetch vor dem Task-Start, Push und Draft-PR beim Genehmigen.",
|
||||
"baseUrl": "API-Base-URL",
|
||||
"baseUrlHint": "Leer lassen, um die Ticket-Anbindung komplett zu deaktivieren.",
|
||||
"token": "Personal Access Token",
|
||||
@@ -335,6 +339,10 @@
|
||||
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
|
||||
"targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.",
|
||||
"approveMerge": "Genehmigen & Mergen",
|
||||
"createDraftPr": "Draft-PR erstellen",
|
||||
"updatePr": "PR aktualisieren",
|
||||
"openPr": "Pull Request öffnen",
|
||||
"companyTargetHint": "Firmen-Repo — Genehmigen pusht den Branch und erstellt einen Draft-PR auf Azure DevOps.",
|
||||
"park": "Parken",
|
||||
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -892,7 +900,7 @@
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
|
||||
"tickets": { "azdoConnectionOk": "Verbunden — Repo {0}, Default-Branch {1}.", "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
|
||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "error": "Fehler: {0}" },
|
||||
"usageMonitor": {
|
||||
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
||||
@@ -919,7 +927,8 @@
|
||||
"stalledHint": "Läuft weiter — Worker antwortet noch",
|
||||
"merge": {
|
||||
"merging": "Wird zusammengeführt…",
|
||||
"verifying": "Verify-Kommando der Liste läuft… ({0})"
|
||||
"verifying": "Verify-Kommando der Liste läuft… ({0})",
|
||||
"publishing": "Push und Draft-PR laufen…"
|
||||
},
|
||||
"diff": {
|
||||
"parsing": "Diff wird geparst…"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"categoryFiles": "Files & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
"categoryReports": "Reports",
|
||||
"categoryTickets": "Ticket system"
|
||||
"categoryTickets": "External services"
|
||||
},
|
||||
"repoHint": {
|
||||
"text": "Only takes effect once a repo is linked.",
|
||||
@@ -148,6 +148,10 @@
|
||||
"saveButton": "Save config"
|
||||
},
|
||||
"tickets": {
|
||||
"ticketSection": "TICKET SYSTEM",
|
||||
"azdoSection": "AZURE DEVOPS",
|
||||
"azdoToken": "Personal access token (scope: Code read & write)",
|
||||
"azdoHint": "Used in company repos (origin on the Azure DevOps Server): fetch before a task starts, push and draft PR on approve.",
|
||||
"baseUrl": "API base URL",
|
||||
"baseUrlHint": "Leave empty to disable the ticket integration entirely.",
|
||||
"token": "Personal access token",
|
||||
@@ -335,6 +339,10 @@
|
||||
"openDiffHint": "Open the diff to enable merge",
|
||||
"targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.",
|
||||
"approveMerge": "Approve & Merge",
|
||||
"createDraftPr": "Create draft PR",
|
||||
"updatePr": "Update PR",
|
||||
"openPr": "Open pull request",
|
||||
"companyTargetHint": "Company repo — approve pushes the branch and opens a draft PR on Azure DevOps.",
|
||||
"park": "Park",
|
||||
"parkTip": "Set aside — back to Idle, keeps the worktree",
|
||||
"cancel": "Cancel",
|
||||
@@ -892,7 +900,7 @@
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
|
||||
"tickets": { "azdoConnectionOk": "Connected — repo {0}, default branch {1}.", "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
|
||||
"weeklyReport": { "invalidRange": "Invalid date range.", "error": "Error: {0}" },
|
||||
"usageMonitor": {
|
||||
"loadFailed": "Couldn't load usage data: {0}",
|
||||
@@ -919,7 +927,8 @@
|
||||
"stalledHint": "Still running — worker hasn't responded in a while",
|
||||
"merge": {
|
||||
"merging": "Merging…",
|
||||
"verifying": "Running the list's verify command… ({0})"
|
||||
"verifying": "Running the list's verify command… ({0})",
|
||||
"publishing": "Pushing and opening the draft PR…"
|
||||
},
|
||||
"diff": {
|
||||
"parsing": "Parsing diff…"
|
||||
|
||||
@@ -260,6 +260,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task SetTicketTokenAsync(string token);
|
||||
Task ClearTicketTokenAsync();
|
||||
Task<TicketConnectionDto?> TestTicketConnectionAsync();
|
||||
Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync();
|
||||
Task SetAzureDevOpsTokenAsync(string token);
|
||||
Task ClearAzureDevOpsTokenAsync();
|
||||
Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync();
|
||||
Task<List<TicketProjectDto>> GetTicketProjectsAsync();
|
||||
/// <summary>Imports open tickets assigned to the token owner into the given list. Throws
|
||||
/// HubException (with a user-readable message) on failure so the caller can surface it via
|
||||
|
||||
@@ -791,6 +791,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<TicketConnectionDto?> TestTicketConnectionAsync()
|
||||
=> TryInvokeAsync<TicketConnectionDto>("TestTicketConnection");
|
||||
|
||||
public Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync()
|
||||
=> TryInvokeAsync<AzureDevOpsSettingsDto>("GetAzureDevOpsSettings");
|
||||
|
||||
public Task SetAzureDevOpsTokenAsync(string token)
|
||||
=> InvokeTimedAsync("SetAzureDevOpsToken", () => _hub.InvokeAsync("SetAzureDevOpsToken", token));
|
||||
|
||||
public Task ClearAzureDevOpsTokenAsync()
|
||||
=> InvokeTimedAsync("ClearAzureDevOpsToken", () => _hub.InvokeAsync("ClearAzureDevOpsToken"));
|
||||
|
||||
public Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync()
|
||||
=> TryInvokeAsync<AzureDevOpsConnectionDto>("TestAzureDevOpsConnection");
|
||||
|
||||
public async Task<List<TicketProjectDto>> GetTicketProjectsAsync()
|
||||
=> await TryInvokeAsync<List<TicketProjectDto>>("GetTicketProjects") ?? [];
|
||||
|
||||
|
||||
@@ -676,6 +676,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
|
||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||
WorktreeStateLabel, _listWorkingDir);
|
||||
Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl;
|
||||
|
||||
await AgentSettings.LoadForTaskAsync(entity, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -728,6 +729,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||
}
|
||||
}
|
||||
await Merge.RefreshMergePreviewAsync();
|
||||
@@ -779,6 +781,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -826,6 +829,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,6 +943,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||
WorktreeStateLabel, _listWorkingDir);
|
||||
Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl;
|
||||
}
|
||||
catch { /* best-effort refresh */ }
|
||||
}
|
||||
@@ -1261,7 +1266,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected) return;
|
||||
var taskId = Task.Id;
|
||||
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
|
||||
// Company repo: approve = verify + push + draft PR; nothing merges locally.
|
||||
var company = Merge.IsCompanyRepo;
|
||||
using var op = ApproveOp.Begin(Loc.T(company ? "ops.merge.publishing" : "ops.merge.merging"));
|
||||
// Subscribed only for the duration of the call: the worker broadcasts to every
|
||||
// client, and a VM left on this event would keep reacting to merges for other tasks.
|
||||
void OnOperationProgress(string opKey, string phase, int current, int total)
|
||||
@@ -1276,7 +1283,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
try
|
||||
{
|
||||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
||||
var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||
var result = await _worker.ApproveReviewAsync(taskId, company ? "" : Merge.SelectedMergeTarget ?? "");
|
||||
|
||||
if (company)
|
||||
{
|
||||
if (result?.PullRequestUrl is { } url) Merge.PullRequestUrl = url;
|
||||
else if (result?.Status == "verify_failed") ErrorReported?.Invoke(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing landed -- the target working dir has uncommitted changes (often another
|
||||
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
|
||||
@@ -1304,7 +1318,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Dialogs != null)
|
||||
// Company-repo failures (no PAT, push rejected, "gepusht, PR fehlgeschlagen") go to the footer strip.
|
||||
if (company) ErrorReported?.Invoke(ex.Message);
|
||||
else if (Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(ex.Message);
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -32,6 +32,22 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
// dir is visible before anyone clicks, not just after the merge modal refuses it.
|
||||
[ObservableProperty] private bool _targetDirty;
|
||||
|
||||
/// <summary>Mirrors MergeTargetsDto.IsCompanyRepo: approve pushes + opens a draft PR, there is no target to pick.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowApproveMerge), nameof(ShowCreatePr), nameof(ShowUpdatePr))]
|
||||
private bool _isCompanyRepo;
|
||||
|
||||
/// <summary>The worktree's draft PR (company repo), once an approve created it.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowCreatePr), nameof(ShowUpdatePr), nameof(HasPullRequest))]
|
||||
[NotifyCanExecuteChangedFor(nameof(OpenPullRequestCommand))]
|
||||
private string? _pullRequestUrl;
|
||||
|
||||
public bool ShowApproveMerge => !IsCompanyRepo;
|
||||
public bool ShowCreatePr => IsCompanyRepo && PullRequestUrl is null;
|
||||
public bool ShowUpdatePr => IsCompanyRepo && PullRequestUrl is not null;
|
||||
public bool HasPullRequest => PullRequestUrl is not null;
|
||||
|
||||
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
|
||||
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
|
||||
public OperationStatus PreviewOp { get; } = new();
|
||||
@@ -146,11 +162,20 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
MergeIsClean = false;
|
||||
MergeIsConflict = false;
|
||||
TargetDirty = false;
|
||||
IsCompanyRepo = false;
|
||||
PullRequestUrl = null;
|
||||
SyncWorktree(null, null, null, null, null);
|
||||
SyncTaskContext(null, null, false);
|
||||
SyncChildOutcomes(false, 0);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasPullRequest))]
|
||||
private void OpenPullRequest()
|
||||
{
|
||||
var (ok, error) = ShellOpen.Url(PullRequestUrl);
|
||||
if (!ok && error is not null) ErrorReported?.Invoke(error);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,15 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
|
||||
// Never shows the stored token — TicketSettingsDto only ever carries the bool.
|
||||
public string TokenPlaceholder => TokenSet ? Loc.T("settings.tickets.tokenSet") : "";
|
||||
|
||||
/// <summary>Azure DevOps PAT being typed; never shows the stored one.</summary>
|
||||
[ObservableProperty] private string _azdoToken = "";
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AzdoTokenPlaceholder))]
|
||||
private bool _azdoTokenSet;
|
||||
[ObservableProperty] private string _azdoStatusMessage = "";
|
||||
|
||||
public string AzdoTokenPlaceholder => AzdoTokenSet ? Loc.T("settings.tickets.tokenSet") : "";
|
||||
|
||||
public ObservableCollection<FallbackListOption> FallbackLists { get; } = new();
|
||||
[ObservableProperty] private FallbackListOption? _selectedFallbackList;
|
||||
|
||||
@@ -54,6 +63,7 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
AzdoTokenSet = (await _worker.GetAzureDevOpsSettingsAsync())?.TokenSet ?? false;
|
||||
var dto = await _worker.GetTicketSettingsAsync();
|
||||
if (dto is null)
|
||||
{
|
||||
@@ -86,6 +96,40 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
|
||||
}
|
||||
if (_fallbackLoaded)
|
||||
await _worker.SetTicketFallbackListAsync(SelectedFallbackList?.Id);
|
||||
if (!string.IsNullOrWhiteSpace(AzdoToken))
|
||||
{
|
||||
await _worker.SetAzureDevOpsTokenAsync(AzdoToken);
|
||||
AzdoToken = "";
|
||||
AzdoTokenSet = true;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestAzdoConnection()
|
||||
{
|
||||
IsBusy = true;
|
||||
AzdoStatusMessage = "";
|
||||
try
|
||||
{
|
||||
await SaveAsync();
|
||||
var result = await _worker.TestAzureDevOpsConnectionAsync();
|
||||
AzdoStatusMessage = result is { Ok: true }
|
||||
? Loc.T("vm.tickets.azdoConnectionOk", result.Repo!, result.DefaultBranch!)
|
||||
: result?.Error ?? Loc.T("vm.tickets.workerOffline");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClearAzdoToken()
|
||||
{
|
||||
await _worker.ClearAzureDevOpsTokenAsync();
|
||||
AzdoToken = "";
|
||||
AzdoTokenSet = false;
|
||||
AzdoStatusMessage = Loc.T("vm.tickets.tokenCleared");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -321,8 +321,18 @@
|
||||
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Company repo: no local target — approve pushes and opens a draft PR. -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding Merge.IsCompanyRepo}">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr details.workConsole.companyTargetHint}" TextWrapping="Wrap" />
|
||||
<Button Classes="btn" HorizontalAlignment="Left"
|
||||
Content="{loc:Tr details.workConsole.openPr}"
|
||||
ToolTip.Tip="{Binding Merge.PullRequestUrl}"
|
||||
Command="{Binding Merge.OpenPullRequestCommand}"
|
||||
IsVisible="{Binding Merge.HasPullRequest}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Target branch + pre-flight mergeability -->
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Spacing="4" IsVisible="{Binding Merge.ShowApproveMerge}">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr details.workConsole.targetBranch}" />
|
||||
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
|
||||
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
|
||||
@@ -333,7 +343,8 @@
|
||||
IsVisible="{Binding Merge.TargetDirty}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="0">
|
||||
<!-- The preview is against the local target, meaningless when the PR targets origin. -->
|
||||
<StackPanel Spacing="0" IsVisible="{Binding Merge.ShowApproveMerge}">
|
||||
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource MossBrush}"
|
||||
IsVisible="{Binding Merge.MergeIsClean}" />
|
||||
@@ -400,8 +411,13 @@
|
||||
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.approveMerge}"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
<Button Classes="btn accent" Command="{Binding ApproveReviewCommand}">
|
||||
<Panel>
|
||||
<TextBlock Text="{loc:Tr details.workConsole.approveMerge}" IsVisible="{Binding Merge.ShowApproveMerge}" />
|
||||
<TextBlock Text="{loc:Tr details.workConsole.createDraftPr}" IsVisible="{Binding Merge.ShowCreatePr}" />
|
||||
<TextBlock Text="{loc:Tr details.workConsole.updatePr}" IsVisible="{Binding Merge.ShowUpdatePr}" />
|
||||
</Panel>
|
||||
</Button>
|
||||
<controls:OperationIndicator Status="{Binding ApproveOp}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||
|
||||
@@ -738,12 +738,14 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- Ticketsystem: Base-URL + PAT for the Bandel ticket integration. Empty base URL
|
||||
disables the feature entirely, so this tab intentionally never forces a value. -->
|
||||
<!-- External services: Bandel ticket system (base URL + PAT; empty base URL disables it, so
|
||||
this tab never forces a value) and the Azure DevOps PAT for company-repo push + draft PR. -->
|
||||
<TabItem Header="{loc:Tr settings.sidebar.categoryTickets}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||||
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.tickets.ticketSection}"/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.baseUrl}"/>
|
||||
<TextBox Text="{Binding Tickets.ApiBaseUrl, Mode=TwoWay}"/>
|
||||
@@ -785,6 +787,33 @@
|
||||
<TextBlock Classes="meta" Text="{loc:Tr settings.tickets.fallbackListHint}"
|
||||
TextWrapping="Wrap" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,8,0,0"/>
|
||||
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.tickets.azdoSection}"/>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.azdoToken}"/>
|
||||
<TextBox Text="{Binding Tickets.AzdoToken, Mode=TwoWay}"
|
||||
PasswordChar="•"
|
||||
PlaceholderText="{Binding Tickets.AzdoTokenPlaceholder}"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr settings.tickets.azdoHint}"
|
||||
TextWrapping="Wrap" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr settings.tickets.testConnection}"
|
||||
Command="{Binding Tickets.TestAzdoConnectionCommand}"
|
||||
IsEnabled="{Binding !Tickets.IsBusy}"/>
|
||||
<Button Classes="btn danger"
|
||||
Content="{loc:Tr settings.tickets.clearToken}"
|
||||
Command="{Binding Tickets.ClearAzdoTokenCommand}"
|
||||
IsEnabled="{Binding !Tickets.IsBusy}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="meta" Text="{Binding Tickets.AzdoStatusMessage}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Tickets.AzdoStatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeDo.Worker.AzureDevOps;
|
||||
|
||||
/// <summary>An Azure DevOps Server repo derived from its remote URL; <see cref="ApiBase"/> is collection + project.</summary>
|
||||
public sealed record AzdoRepo(string ApiBase, string Repo, string WebUrl);
|
||||
|
||||
/// <summary>A REST or configuration failure, with a message that can go straight to the user.</summary>
|
||||
public sealed class AzureDevOpsException : Exception
|
||||
{
|
||||
public AzureDevOpsException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one HTTP surface to the on-prem Azure DevOps Server, ported from work-conventions' <c>pr-create.ps1</c>.
|
||||
/// REST, not the az CLI (cloud-only) and not the pullrequestcreate URL (on-prem ignores title/description).
|
||||
/// </summary>
|
||||
public sealed class AzureDevOpsClient
|
||||
{
|
||||
private const string DefaultApiVersion = "6.0";
|
||||
// ADO caps a PR description at 4000 characters and rejects anything longer.
|
||||
private const int MaxDescriptionLength = 4000;
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly AzureDevOpsPatStore _pat;
|
||||
private string _apiVersion = DefaultApiVersion;
|
||||
|
||||
public AzureDevOpsClient(IHttpClientFactory httpFactory, AzureDevOpsPatStore pat)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_pat = pat;
|
||||
}
|
||||
|
||||
/// <summary>The stored PAT, or null.</summary>
|
||||
public string? Pat => _pat.Store.Read();
|
||||
|
||||
/// <summary>
|
||||
/// Splits an ADO remote at <c>/_git/</c>: everything before it already is the API prefix, so collection
|
||||
/// and project never need parsing (holds for virtual directories like <c>/tfs/</c> too). Embedded
|
||||
/// credentials are dropped. Null for anything that is not an http(s) ADO remote.
|
||||
/// </summary>
|
||||
public static AzdoRepo? ParseRemote(string? remote)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(remote)) return null;
|
||||
var clean = Regex.Replace(remote.Trim(), @"\.git$", "");
|
||||
clean = Regex.Replace(clean, @"^(https?://)[^/@]+@", "$1");
|
||||
var m = Regex.Match(clean, @"^(?<base>https?://.+)/_git/(?<repo>[^/]+)$");
|
||||
return m.Success ? new AzdoRepo(m.Groups["base"].Value, Uri.UnescapeDataString(m.Groups["repo"].Value), clean) : null;
|
||||
}
|
||||
|
||||
/// <summary>Basic auth with an empty user, as ADO expects for a PAT.</summary>
|
||||
public static string AuthHeaderValue(string pat) => "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(":" + pat));
|
||||
|
||||
/// <summary>
|
||||
/// Environment for a git network call: never prompt — neither the terminal nor Git Credential Manager's GUI
|
||||
/// (a background process would hang on either) — and hand the PAT
|
||||
/// over as <c>http.extraHeader</c> via <c>GIT_CONFIG_*</c> so it never shows up in a command line.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> GitAuthEnv(string? pat)
|
||||
{
|
||||
var env = new Dictionary<string, string> { ["GIT_TERMINAL_PROMPT"] = "0", ["GCM_INTERACTIVE"] = "never" };
|
||||
if (!string.IsNullOrEmpty(pat))
|
||||
{
|
||||
env["GIT_CONFIG_COUNT"] = "1";
|
||||
env["GIT_CONFIG_KEY_0"] = "http.extraHeader";
|
||||
env["GIT_CONFIG_VALUE_0"] = "Authorization: " + AuthHeaderValue(pat);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/// <summary>The API version an out-of-range error names (<c>… supports is 6.0</c>), else null.</summary>
|
||||
public static string? SupportedVersion(string? error)
|
||||
{
|
||||
if (error is null) return null;
|
||||
var m = Regex.Match(error, @"supports is (?<v>\d+\.\d+)");
|
||||
return m.Success ? m.Groups["v"].Value : null;
|
||||
}
|
||||
|
||||
/// <summary>The repo's default branch without <c>refs/heads/</c>.</summary>
|
||||
public async Task<string> GetDefaultBranchAsync(AzdoRepo repo, CancellationToken ct)
|
||||
{
|
||||
var json = await SendAsync(repo, HttpMethod.Get, "", null, null, ct);
|
||||
var branch = json["defaultBranch"]?.GetValue<string>()
|
||||
?? throw new AzureDevOpsException($"Azure DevOps repo '{repo.Repo}' has no default branch.");
|
||||
return StripHeads(branch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reuses the active PR for <paramref name="source"/> if there is one (idempotent — the work-conventions
|
||||
/// skill may already have opened it), else opens a draft PR into <paramref name="target"/>.
|
||||
/// </summary>
|
||||
public async Task<(string Url, bool Created)> EnsureDraftPullRequestAsync(
|
||||
AzdoRepo repo, string source, string target, string title, string description, CancellationToken ct)
|
||||
{
|
||||
var sourceRef = "refs/heads/" + source;
|
||||
var existing = await SendAsync(repo, HttpMethod.Get, "pullrequests",
|
||||
$"searchCriteria.sourceRefName={Uri.EscapeDataString(sourceRef)}&searchCriteria.status=active&", null, ct);
|
||||
if (existing["value"] is JsonArray { Count: > 0 } prs && prs[0]?["pullRequestId"]?.GetValue<int>() is { } existingId)
|
||||
return (PullRequestUrl(repo, existingId), false);
|
||||
|
||||
var body = new JsonObject
|
||||
{
|
||||
["sourceRefName"] = sourceRef,
|
||||
["targetRefName"] = "refs/heads/" + target,
|
||||
["title"] = title,
|
||||
["description"] = description.Length <= MaxDescriptionLength ? description : description[..MaxDescriptionLength],
|
||||
["isDraft"] = true,
|
||||
};
|
||||
var created = await SendAsync(repo, HttpMethod.Post, "pullrequests", null, body.ToJsonString(), ct);
|
||||
var id = created["pullRequestId"]?.GetValue<int>()
|
||||
?? throw new AzureDevOpsException("Azure DevOps answered the PR request without a pullRequestId.");
|
||||
return (PullRequestUrl(repo, id), true);
|
||||
}
|
||||
|
||||
private static string PullRequestUrl(AzdoRepo repo, int id) => $"{repo.WebUrl}/pullrequest/{id}";
|
||||
|
||||
private static string StripHeads(string branch) =>
|
||||
branch.StartsWith("refs/heads/", StringComparison.Ordinal) ? branch["refs/heads/".Length..] : branch;
|
||||
|
||||
private async Task<JsonObject> SendAsync(
|
||||
AzdoRepo repo, HttpMethod method, string path, string? query, string? body, CancellationToken ct)
|
||||
{
|
||||
var pat = Pat;
|
||||
if (string.IsNullOrEmpty(pat))
|
||||
throw new AzureDevOpsException("Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste).");
|
||||
|
||||
var baseUrl = $"{repo.ApiBase}/_apis/git/repositories/{Uri.EscapeDataString(repo.Repo)}";
|
||||
if (path.Length > 0) baseUrl += "/" + path;
|
||||
|
||||
// The server names the version it supports in its error; retry once with it instead of making the user configure one.
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, $"{baseUrl}?{query}api-version={_apiVersion}");
|
||||
request.Headers.Authorization = AuthenticationHeaderValue.Parse(AuthHeaderValue(pat));
|
||||
if (body is not null) request.Content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage response;
|
||||
try { response = await _httpFactory.CreateClient("azdo").SendAsync(request, ct); }
|
||||
catch (Exception ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new AzureDevOpsException($"Azure DevOps nicht erreichbar ({repo.ApiBase}): {ex.Message}");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(ct);
|
||||
// A rejected PAT comes back as 203 + an HTML login page, which IsSuccessStatusCode counts as success.
|
||||
if ((int)response.StatusCode == 203)
|
||||
throw new AzureDevOpsException("Azure DevOps 203: PAT abgelaufen oder ohne Scope Code (Read & Write).");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(payload) as JsonObject
|
||||
?? throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps bei {method} {path}.");
|
||||
}
|
||||
catch (JsonException ex) { throw new AzureDevOpsException($"Unerwartete Antwort von Azure DevOps: {ex.Message}"); }
|
||||
}
|
||||
|
||||
var detail = ErrorMessage(payload);
|
||||
var better = SupportedVersion(detail);
|
||||
if (attempt == 1 && better is not null && better != _apiVersion)
|
||||
{
|
||||
_apiVersion = better;
|
||||
continue;
|
||||
}
|
||||
var hint = (int)response.StatusCode == 401 ?" — PAT abgelaufen oder ohne Scope Code (Read & Write)" : "";
|
||||
throw new AzureDevOpsException($"Azure DevOps {(int)response.StatusCode} bei {method} {path}{hint}: {detail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorMessage(string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (JsonNode.Parse(payload) is JsonObject o && o["message"]?.GetValue<string>() is { Length: > 0 } msg) return msg;
|
||||
}
|
||||
// InvalidOperationException: "message" exists but is not a string.
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException) { }
|
||||
return payload.Length <= 500 ? payload : payload[..500];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Text;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tickets;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.AzureDevOps;
|
||||
|
||||
/// <summary>
|
||||
/// The company-repo end of a review: push the unit branch and find-or-create its draft PR on Azure DevOps.
|
||||
/// Never touches task status or worktree state — the task stays WaitingForReview until the ADO merge.
|
||||
/// </summary>
|
||||
public sealed class PullRequestPublisher
|
||||
{
|
||||
/// <summary>Web front of the Bandel ticket system; the API base URL (an IP) is not browsable.</summary>
|
||||
public const string TicketWebBase = "https://ticket.fb-tuning.local";
|
||||
|
||||
private readonly GitService _git;
|
||||
private readonly AzureDevOpsClient _azdo;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly ILogger<PullRequestPublisher> _logger;
|
||||
private readonly TicketClientFactory? _tickets;
|
||||
|
||||
public PullRequestPublisher(
|
||||
GitService git, AzureDevOpsClient azdo, IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
HubBroadcaster broadcaster, WorkerConfig cfg, ILogger<PullRequestPublisher> logger,
|
||||
TicketClientFactory? tickets = null)
|
||||
{
|
||||
_git = git;
|
||||
_azdo = azdo;
|
||||
_dbFactory = dbFactory;
|
||||
_broadcaster = broadcaster;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
_tickets = tickets;
|
||||
}
|
||||
|
||||
/// <summary>Live check of origin against <c>company_remote_pattern</c>.</summary>
|
||||
public Task<bool> IsCompanyRepoAsync(string workingDir, CancellationToken ct) =>
|
||||
CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
|
||||
|
||||
/// <summary>Why a PR can't be published from <paramref name="workingDir"/>, or null — checked before the verify run so a missing PAT doesn't cost a build.</summary>
|
||||
public async Task<string?> CheckReadyAsync(string workingDir, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_azdo.Pat))
|
||||
return "Kein Azure-DevOps-PAT hinterlegt (Einstellungen → Externe Dienste) — nichts gepusht.";
|
||||
var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct);
|
||||
return AzureDevOpsClient.ParseRemote(remote) is null
|
||||
? $"origin ist kein https-Remote eines Azure DevOps Servers: {remote}"
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Push + draft PR; <paramref name="verifyCommand"/> is the command that passed (null = none ran).</summary>
|
||||
public async Task<MergeResult> PublishAsync(
|
||||
TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct)
|
||||
{
|
||||
var workingDir = list.WorkingDir!;
|
||||
if (await CheckReadyAsync(workingDir, ct) is { } notReady) return Blocked(notReady);
|
||||
var pat = _azdo.Pat!;
|
||||
var repo = AzureDevOpsClient.ParseRemote(await _git.GetRemoteUrlAsync(workingDir, "origin", ct))!;
|
||||
|
||||
var (pushExit, pushErr) = await _git.PushBranchAsync(wt.Path, wt.BranchName, AzureDevOpsClient.GitAuthEnv(pat), ct);
|
||||
if (pushExit != 0) return Blocked($"push rejected: {pushErr}");
|
||||
|
||||
string url;
|
||||
bool created;
|
||||
try
|
||||
{
|
||||
var target = await ResolveTargetAsync(task.ListId, repo, ct);
|
||||
if (target == wt.BranchName)
|
||||
return Blocked($"gepusht, PR fehlgeschlagen: Quell- und Ziel-Branch sind beide '{target}'.");
|
||||
var ticketId = TicketStatusMap.ParseBandelTicketId(task.TicketRef);
|
||||
var title = BuildTitle(task, list, ticketId);
|
||||
var description = BuildDescription(task, ticketId, await ResolveProjectIdAsync(ticketId, ct), verifyCommand);
|
||||
(url, created) = await _azdo.EnsureDraftPullRequestAsync(repo, wt.BranchName, target, title, description, ct);
|
||||
}
|
||||
// InvalidOperationException: a field of the ADO answer has an unexpected JSON type.
|
||||
catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException)
|
||||
{
|
||||
return Blocked($"gepusht, PR fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
|
||||
using (var ctx = _dbFactory.CreateDbContext())
|
||||
await new WorktreeRepository(ctx).SetPullRequestUrlAsync(task.Id, url, ct);
|
||||
await _broadcaster.WorktreeUpdated(task.Id);
|
||||
_logger.LogInformation("Task {task_id}: branch {branch} pushed, draft PR {pr_url} ({pr_state})",
|
||||
task.Id, wt.BranchName, url, created ? "created" : "reused");
|
||||
|
||||
return new MergeResult(created ? TaskMergeService.StatusPrCreated : TaskMergeService.StatusPrUpdated,
|
||||
Array.Empty<string>(), null, url);
|
||||
}
|
||||
|
||||
/// <summary><c><type>(<list-slug>): <title>, Resolves #<ticket></c>.</summary>
|
||||
public static string BuildTitle(TaskEntity task, ListEntity list, int? ticketId)
|
||||
{
|
||||
var type = string.IsNullOrWhiteSpace(task.CommitType) ? CommitTypeRegistry.DefaultType : task.CommitType.Trim();
|
||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||
var scope = slug.Length == 0 ? "" : $"({slug})";
|
||||
var title = $"{type}{scope}: {task.Title.Trim()}";
|
||||
return ticketId is null ? title : $"{title}, Resolves #{ticketId}";
|
||||
}
|
||||
|
||||
/// <summary>Ticket link (plain <c>Resolves #id</c> without a project id), task description, and only what really ran.</summary>
|
||||
public static string BuildDescription(TaskEntity task, int? ticketId, int? projectId, string? verifyCommand)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (ticketId is { } id)
|
||||
{
|
||||
sb.Append(projectId is { } pid
|
||||
? $"Resolves [#{id}]({TicketWebBase}/board/project/{pid}/ticket/{id})"
|
||||
: $"Resolves #{id}");
|
||||
sb.Append("\n\n");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(task.Description))
|
||||
sb.Append(task.Description.Trim()).Append("\n\n");
|
||||
sb.Append(verifyCommand is null ? "Geprüft: nichts automatisch" : $"Geprüft: `{verifyCommand}` grün");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private async Task<string> ResolveTargetAsync(string listId, AzdoRepo repo, CancellationToken ct)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var baseBranch = await ctx.ListConfigs.AsNoTracking()
|
||||
.Where(c => c.ListId == listId)
|
||||
.Select(c => c.BaseBranch)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return baseBranch.NullIfBlank() ?? await _azdo.GetDefaultBranchAsync(repo, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The project an item lives in: a ticket's parent is its project, a task's parent is a ticket — so walk
|
||||
/// up until <c>parentTicketID</c> is null (live-checked 2026-10-01). Null when unknown or unreachable.
|
||||
/// </summary>
|
||||
private async Task<int?> ResolveProjectIdAsync(int? ticketId, CancellationToken ct)
|
||||
{
|
||||
if (ticketId is null || _tickets?.Create() is not { } client) return null;
|
||||
try
|
||||
{
|
||||
var id = ticketId.Value;
|
||||
for (var hop = 0; hop < 3; hop++)
|
||||
{
|
||||
var detail = await client.GetTicketAsync(id, ct);
|
||||
if (detail.ParentTicketId is not { } parent) return hop == 0 ? null : id;
|
||||
id = parent;
|
||||
}
|
||||
}
|
||||
catch (TicketApiException ex)
|
||||
{
|
||||
_logger.LogWarning("Project of ticket {ticket_id} unknown, PR links no ticket: {reason}", ticketId, ex.Message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MergeResult Blocked(string reason) =>
|
||||
new(TaskMergeService.StatusBlocked, Array.Empty<string>(), reason);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ Worker/
|
||||
Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
|
||||
DailyPrepPrompt, PrimeScheduleValidation, NextDueCalculator, PrimeScheduleSignal
|
||||
Online/ — optional Online Inbox sync (off by default; zero network when disabled)
|
||||
AzureDevOps/ — company-repo push + draft PR: AzureDevOpsClient (REST, port of pr-create.ps1),
|
||||
PullRequestPublisher (push, find-or-create draft PR, pull_request_url)
|
||||
Tickets/ — Bandel ticket-system integration (per-list import + status write-back),
|
||||
inactive without ticket_api_base_url: TicketSystemConfig/TicketClientFactory,
|
||||
BandelTicketClient, TicketDtos, TicketStatusMap, TicketStatusSync,
|
||||
@@ -151,8 +153,8 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
|
||||
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
|
||||
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
||||
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
|
||||
- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = `rev-parse <unit branch>` for a child whose parent has an Active worktree, else `list_config.base_branch`, else the checked-out HEAD. Commits with semantic messages, updates DB with head commit + diff stats
|
||||
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
|
||||
- **WorktreeManager** — worktrees on `<typ>/<nummer>-<slug>` branches, same leaf as the folder (`NameFor`, slug capped at 40 chars, umlauts transliterated). Typ from `BranchTypeFor` (`feat`→`feature`, `fix/refactor/docs/test/chore` as-is, anything else `chore`). Nummer = ticket id (own `TicketRef` or the parent's), else the task number — in a company repo (`CompanyRepo`, origin matches `company_remote_pattern`) without a ticket no number at all. A taken folder/branch name gets an 8-char task-id suffix, so the self-heal never force-deletes a foreign branch. Base = `rev-parse <unit branch>` for a child whose parent has an Active worktree, else `list_config.base_branch`, else the checked-out HEAD — in a company repo a unit root first fetches its basis (see Azure DevOps below). Commits with semantic messages, updates DB with head commit + diff stats
|
||||
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId` (no trailer in company repos — the convention forbids signature trailers and nothing reads it); `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
|
||||
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
|
||||
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
|
||||
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
|
||||
@@ -273,10 +275,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
|
||||
- `db_path`, `sandbox_root`, `log_root`
|
||||
- `worktree_root_strategy` (`sibling` | `central`), `central_worktree_root`
|
||||
- `queue_backstop_interval_ms` (30000) — also the gate/throttle recovery timer
|
||||
- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar.
|
||||
- `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar. Schaltet Fetch vor dem Unit-Root, Approve = Push + Draft-PR und Commits ohne Trailer (siehe Azure DevOps).
|
||||
- `signalr_port` (47821), `claude_bin`, `ticket_api_base_url` — the two UI-writable fields
|
||||
(`claude_bin`: Settings → Dateien → CLAUDE CLI, hub `GetClaudeBin`/`SetClaudeBin`;
|
||||
`ticket_api_base_url`: Settings → Ticketsystem, hub `GetTicketSettings`/`SetTicketApiBaseUrl`).
|
||||
`ticket_api_base_url`: Settings → Externe Dienste, hub `GetTicketSettings`/`SetTicketApiBaseUrl`).
|
||||
Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call
|
||||
(`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field
|
||||
here is hand-edit only.
|
||||
@@ -341,6 +343,39 @@ distinguish the two singletons). The token never touches `worker.config.json` or
|
||||
`TicketStatusMap.NameOf` supplies the canonical name.
|
||||
⚠️ The base URL must be the **IP** — `bandelapis.fb-tuning.local` still refuses the connection.
|
||||
|
||||
## Azure DevOps (company repos)
|
||||
|
||||
Active when origin matches `company_remote_pattern` (live `git remote get-url`, no cache). The PAT
|
||||
(scope Code Read & Write) is DPAPI-encrypted at `~/.claudeDo/azdo.pat` (DI marker
|
||||
`AzureDevOpsPatStore`), set via Settings → Externe Dienste (hub `GetAzureDevOpsSettings` /
|
||||
`SetAzureDevOpsToken` / `ClearAzureDevOpsToken` / `TestAzureDevOpsConnection` — the test reads the
|
||||
first list whose origin matches and parses). Deliberately independent of the work-conventions
|
||||
plugin's PAT file.
|
||||
|
||||
- **`AzureDevOpsClient`** — the only HTTP surface. `ParseRemote` splits at `/_git/` (API base =
|
||||
everything before it, embedded credentials stripped, http(s) only). `GitAuthEnv(pat)` is the
|
||||
env for every git network call: `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`, PAT as
|
||||
`http.extraHeader` via `GIT_CONFIG_COUNT/KEY_0/VALUE_0` — never on a command line. API version
|
||||
starts at 6.0 and retries once with the version an out-of-range error names. ⚠️ A rejected PAT
|
||||
is HTTP **203** + an HTML login page, which `IsSuccessStatusCode` counts as success — handled
|
||||
explicitly.
|
||||
- **Fetch before a unit root** (`WorktreeManager.FetchCompanyBaseAsync`, once per unit root, never
|
||||
per child): basis = `base_branch` → ADO `defaultBranch` → `origin/HEAD`; `git fetch origin
|
||||
+refs/heads/<b>:refs/remotes/origin/<b>`; the worktree starts from `origin/<b>`. Local `<b>` is
|
||||
pulled along best-effort: not checked out → `git fetch . origin/<b>:<b>` (no network, git
|
||||
refuses a non-ff), checked out + clean → `merge --ff-only`, dirty/diverged → warning only. A
|
||||
failed fetch warns and continues from the local branch — it never fails the task.
|
||||
- **Approve** → `TaskMergeService.ApproveCompanyAsync` → `PullRequestPublisher`; details in
|
||||
[review-merge](../../docs/explore-notes/review-merge.md). PR title
|
||||
`<type>(<list-slug>): <title>, Resolves #<ticket>`; description
|
||||
`Resolves [#id](https://ticket.fb-tuning.local/board/project/<p>/ticket/<id>)` (project id = walk
|
||||
`GET /api/ticket/{id}` up until `parentTicketID` is null — a ticket's parent is its project, a
|
||||
task's parent is a ticket, live-checked 2026-10-01; unknown → plain `Resolves #id`), the task
|
||||
description, `Geprüft: …` with only what actually ran. Target = `base_branch`, else the ADO
|
||||
default branch.
|
||||
- Not done on purpose: PR status polling / auto-`Done`, other PR providers, worktree cleanup on
|
||||
`Done`, fetch in private repos, a UI for `company_remote_pattern`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ClaudeDo.Worker;
|
||||
/// <summary>
|
||||
/// Persists a single secret encrypted with DPAPI (CurrentUser scope), one instance per file.
|
||||
/// Windows-only. Used for the Online-Inbox refresh token (~/.claudeDo/online-inbox.token) and
|
||||
/// the ticket-system PAT (~/.claudeDo/ticket.pat).
|
||||
/// the ticket-system PAT (~/.claudeDo/ticket.pat) and the Azure DevOps PAT (~/.claudeDo/azdo.pat).
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class DpapiTokenStore
|
||||
@@ -55,3 +55,6 @@ public sealed record OnlineRefreshTokenStore(DpapiTokenStore Store);
|
||||
|
||||
/// DI-Marker: der Personal Access Token des Ticketsystems.
|
||||
public sealed record TicketPatStore(DpapiTokenStore Store);
|
||||
|
||||
/// DI-Marker: der Personal Access Token für Azure DevOps (Push + Draft-PR im Firmen-Repo).
|
||||
public sealed record AzureDevOpsPatStore(DpapiTokenStore Store);
|
||||
|
||||
+8
-1
@@ -636,7 +636,10 @@ public sealed class ExternalMcpService
|
||||
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
|
||||
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
|
||||
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
|
||||
"delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
|
||||
"delivered something. In a company repo (origin on the Azure DevOps Server) approve does not merge: it runs " +
|
||||
"the verify command in the task's worktree (skipVerify is ignored), pushes the branch and opens or reuses a " +
|
||||
"draft PR — mergeStatus 'pr_created'/'pr_updated', mergeMessage = the PR URL, the task stays WaitingForReview " +
|
||||
"and needs no verify_merges." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
|
||||
public async Task<ReviewTaskResult> ReviewTask(
|
||||
string taskId,
|
||||
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
|
||||
@@ -736,6 +739,10 @@ public sealed class ExternalMcpService
|
||||
mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " +
|
||||
"verify_merges (with its id) passes";
|
||||
}
|
||||
else if (r.Status is TaskMergeService.StatusPrCreated or TaskMergeService.StatusPrUpdated)
|
||||
{
|
||||
mergeMessage = r.PullRequestUrl;
|
||||
}
|
||||
else if (r.Status == TaskMergeService.StatusConflict)
|
||||
{
|
||||
if (leaveConflictsInTree)
|
||||
|
||||
@@ -18,6 +18,7 @@ using ClaudeDo.Worker.Report;
|
||||
using ClaudeDo.Worker.Report.Interfaces;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.AzureDevOps;
|
||||
using ClaudeDo.Worker.Tickets;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using ClaudeDo.Worker.Usage.Interfaces;
|
||||
@@ -76,6 +77,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly HandlerRoundState? _roundState;
|
||||
private readonly ConnectedClientTracker? _clientTracker;
|
||||
private readonly RunningOperationRegistry? _operations;
|
||||
private readonly AzureDevOpsPatStore? _azdoPat;
|
||||
private readonly AzureDevOpsClient? _azdo;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -118,7 +121,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
HandlerRestartCoordinator? handlerRestart = null,
|
||||
HandlerRoundState? roundState = null,
|
||||
ConnectedClientTracker? clientTracker = null,
|
||||
RunningOperationRegistry? operations = null)
|
||||
RunningOperationRegistry? operations = null,
|
||||
AzureDevOpsPatStore? azdoPat = null,
|
||||
AzureDevOpsClient? azdo = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -161,6 +166,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_roundState = roundState;
|
||||
_clientTracker = clientTracker;
|
||||
_operations = operations;
|
||||
_azdoPat = azdoPat;
|
||||
_azdo = azdo;
|
||||
}
|
||||
|
||||
// Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler)
|
||||
@@ -509,7 +516,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty);
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty, t.IsCompanyRepo);
|
||||
});
|
||||
|
||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
||||
@@ -721,7 +728,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage, r.PullRequestUrl);
|
||||
});
|
||||
|
||||
public async Task RejectReviewToQueue(string taskId, string feedback)
|
||||
@@ -1428,6 +1435,45 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
}
|
||||
}
|
||||
|
||||
public AzureDevOpsSettingsDto GetAzureDevOpsSettings() => new(_azdoPat?.Store.Exists() == true);
|
||||
|
||||
public void SetAzureDevOpsToken(string token)
|
||||
{
|
||||
if (_azdoPat is null) return;
|
||||
if (string.IsNullOrWhiteSpace(token)) _azdoPat.Store.Clear();
|
||||
else _azdoPat.Store.Save(token.Trim());
|
||||
}
|
||||
|
||||
public void ClearAzureDevOpsToken() => _azdoPat?.Store.Clear();
|
||||
|
||||
/// <summary>Reads the first company repo's ADO repository (default branch). Errors are answers, not throws — same reason as TestTicketConnection.</summary>
|
||||
public async Task<AzureDevOpsConnectionDto> TestAzureDevOpsConnection()
|
||||
{
|
||||
if (_azdo is null) return new AzureDevOpsConnectionDto(false, null, null, "Azure DevOps ist im Worker nicht registriert.");
|
||||
try
|
||||
{
|
||||
var git = _git ?? new Data.Git.GitService();
|
||||
List<string> dirs;
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted))
|
||||
dirs = await ctx.Lists.AsNoTracking().Where(l => l.WorkingDir != null).Select(l => l.WorkingDir!).ToListAsync(Context.ConnectionAborted);
|
||||
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
var remote = await git.GetRemoteUrlAsync(dir, "origin", Context.ConnectionAborted);
|
||||
if (!Git.CompanyRepo.Matches(remote, _cfg.CompanyRemotePattern) || AzureDevOpsClient.ParseRemote(remote) is not { } repo)
|
||||
continue;
|
||||
var branch = await _azdo.GetDefaultBranchAsync(repo, Context.ConnectionAborted);
|
||||
return new AzureDevOpsConnectionDto(true, repo.Repo, branch, null);
|
||||
}
|
||||
return new AzureDevOpsConnectionDto(false, null, null, "Keine Liste zeigt auf ein Firmen-Repo (origin mit " + _cfg.CompanyRemotePattern + ").");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AzureDevOpsConnectionDto(false, null, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TicketProjectDto>> GetTicketProjects()
|
||||
{
|
||||
var client = _ticketClients.Create();
|
||||
|
||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.AzureDevOps;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
@@ -15,13 +16,15 @@ namespace ClaudeDo.Worker.Lifecycle;
|
||||
public sealed record MergeResult(
|
||||
string Status,
|
||||
IReadOnlyList<string> ConflictFiles,
|
||||
string? ErrorMessage);
|
||||
string? ErrorMessage,
|
||||
string? PullRequestUrl = null);
|
||||
|
||||
public sealed record MergeTargets(
|
||||
string DefaultBranch,
|
||||
IReadOnlyList<string> LocalBranches,
|
||||
string DefaultCommitMessage,
|
||||
bool TargetDirty = false);
|
||||
bool TargetDirty = false,
|
||||
bool IsCompanyRepo = false);
|
||||
|
||||
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
|
||||
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
|
||||
@@ -76,6 +79,11 @@ public sealed class TaskMergeService
|
||||
// autostash: true) without matching on prose.
|
||||
public const string StatusDirtyTarget = "dirty_target";
|
||||
|
||||
// Company repo: approve pushed the unit branch and opened (or reused) its draft PR; the task stays
|
||||
// WaitingForReview, nothing landed locally.
|
||||
public const string StatusPrCreated = "pr_created";
|
||||
public const string StatusPrUpdated = "pr_updated";
|
||||
|
||||
public const string StatusReverted = "reverted";
|
||||
public const string StatusConflictAborted = "conflict_aborted";
|
||||
|
||||
@@ -119,6 +127,7 @@ public sealed class TaskMergeService
|
||||
private readonly ILogger<TaskMergeService> _logger;
|
||||
private readonly UnitBranchService? _unitBranch;
|
||||
private readonly RunningOperationRegistry _operations;
|
||||
private readonly PullRequestPublisher? _pullRequests;
|
||||
|
||||
public TaskMergeService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
@@ -128,7 +137,8 @@ public sealed class TaskMergeService
|
||||
IVerifyCommandRunner verify,
|
||||
ILogger<TaskMergeService> logger,
|
||||
UnitBranchService? unitBranch = null,
|
||||
RunningOperationRegistry? operations = null)
|
||||
RunningOperationRegistry? operations = null,
|
||||
PullRequestPublisher? pullRequests = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_git = git;
|
||||
@@ -138,6 +148,7 @@ public sealed class TaskMergeService
|
||||
_logger = logger;
|
||||
_unitBranch = unitBranch;
|
||||
_operations = operations ?? new RunningOperationRegistry();
|
||||
_pullRequests = pullRequests;
|
||||
}
|
||||
|
||||
private IDisposable TrackTask(string kind, TaskEntity task) =>
|
||||
@@ -893,7 +904,8 @@ public sealed class TaskMergeService
|
||||
var current = await ResolveTargetAsync(list, null, ct);
|
||||
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
||||
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
|
||||
return new MergeTargets(current, branches, defaultMessage, dirty);
|
||||
var company = _pullRequests is not null && await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct);
|
||||
return new MergeTargets(current, branches, defaultMessage, dirty, company);
|
||||
}
|
||||
|
||||
/// <summary>Leeres Ziel → <c>list_config.base_branch</c>, sonst der ausgecheckte Branch des Main-Checkouts.</summary>
|
||||
@@ -1054,6 +1066,12 @@ public sealed class TaskMergeService
|
||||
}
|
||||
}
|
||||
|
||||
// Kept (parked or reverted) goes the PR way too: a local merge has no place in a company repo.
|
||||
if (wt is { State: WorktreeState.Active or WorktreeState.Kept } && _pullRequests is not null
|
||||
&& !string.IsNullOrWhiteSpace(list.WorkingDir)
|
||||
&& await _pullRequests.IsCompanyRepoAsync(list.WorkingDir, ct))
|
||||
return await ApproveCompanyAsync(task, list, wt, verifyCommand, ct, progress);
|
||||
|
||||
if (wt is not null && wt.State == WorktreeState.Kept)
|
||||
{
|
||||
// Kept means either RevertMergeAsync undid a previous merge, or the user manually
|
||||
@@ -1137,6 +1155,42 @@ public sealed class TaskMergeService
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify, autostash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Company repo: verify in the unit root's worktree (nothing lands on a local target, so there is no
|
||||
/// merge to defer the verify to — <c>skipVerify</c> does not apply), then push + draft PR. Red verify
|
||||
/// pushes nothing. Status, worktree state and ticket stay as they are.
|
||||
/// </summary>
|
||||
private async Task<MergeResult> ApproveCompanyAsync(
|
||||
TaskEntity task, ListEntity list, WorktreeEntity wt, string? verifyCommand, CancellationToken ct,
|
||||
IProgress<ProgressNotificationValue>? progress)
|
||||
{
|
||||
if (await _pullRequests!.CheckReadyAsync(list.WorkingDir!, ct) is { } notReady)
|
||||
return Blocked(notReady);
|
||||
|
||||
var ranVerify = !string.IsNullOrWhiteSpace(verifyCommand);
|
||||
if (ranVerify)
|
||||
{
|
||||
await _broadcaster.OperationProgress(task.Id, PhaseVerifying, 0, 0);
|
||||
var failed = await RunVerifyGateAsync(
|
||||
verifyCommand, wt.Path, () => TrackTask(OperationKinds.VerifyMerge, task), ct, progress,
|
||||
elapsed => _ = _broadcaster.OperationProgress(task.Id, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
|
||||
if (failed is not null) return failed;
|
||||
}
|
||||
|
||||
var result = await ProgressReporter.RunAsync(
|
||||
_pullRequests.PublishAsync(task, list, wt, ranVerify ? verifyCommand : null, ct),
|
||||
ProgressReportInterval, progress, "push + draft PR running");
|
||||
|
||||
// A branch with an open PR is under review again, whatever parked it before.
|
||||
if (wt.State == WorktreeState.Kept && result.PullRequestUrl is not null)
|
||||
{
|
||||
using (var ctx = _dbFactory.CreateDbContext())
|
||||
await new WorktreeRepository(ctx).SetStateAsync(task.Id, WorktreeState.Active, ct);
|
||||
await _broadcaster.WorktreeUpdated(task.Id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
|
||||
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
|
||||
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ClaudeDo.Worker.AzureDevOps;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Findings;
|
||||
@@ -248,12 +249,16 @@ builder.Services.AddSingleton(cfg.OnlineInbox);
|
||||
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
|
||||
builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppData("online-inbox.token")));
|
||||
builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat")));
|
||||
builder.Services.AddSingleton(new AzureDevOpsPatStore(DpapiTokenStore.InAppData("azdo.pat")));
|
||||
#pragma warning restore CA1416
|
||||
builder.Services.AddSingleton<TicketSystemConfig>();
|
||||
builder.Services.AddHttpClient("tickets");
|
||||
builder.Services.AddSingleton<TicketClientFactory>();
|
||||
builder.Services.AddSingleton<TicketStatusSync>();
|
||||
builder.Services.AddSingleton<TicketImportService>();
|
||||
builder.Services.AddHttpClient("azdo", c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||
builder.Services.AddSingleton<AzureDevOpsClient>();
|
||||
builder.Services.AddSingleton<PullRequestPublisher>();
|
||||
|
||||
if (cfg.OnlineInbox.Enabled)
|
||||
{
|
||||
|
||||
@@ -6,7 +6,9 @@ namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
public static class CommitMessageBuilder
|
||||
{
|
||||
public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId)
|
||||
/// <summary>Task commit message; <paramref name="includeTrailer"/> is false in company repos, whose convention forbids signature trailers.</summary>
|
||||
public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId,
|
||||
bool includeTrailer = true)
|
||||
{
|
||||
var slug = ToSlug(listName);
|
||||
var title = Truncate(taskTitle, 60);
|
||||
@@ -22,9 +24,11 @@ public static class CommitMessageBuilder
|
||||
sb.Append(Truncate(taskDescription!.Trim(), 400));
|
||||
}
|
||||
|
||||
// Trailer is always included.
|
||||
sb.Append("\n\n");
|
||||
sb.Append($"ClaudeDo-Task: {taskId}");
|
||||
if (includeTrailer)
|
||||
{
|
||||
sb.Append("\n\n");
|
||||
sb.Append($"ClaudeDo-Task: {taskId}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.AzureDevOps;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
@@ -26,11 +27,12 @@ public sealed class WorktreeManager
|
||||
private readonly ILogger<WorktreeManager> _logger;
|
||||
private readonly HubBroadcaster? _broadcaster;
|
||||
private readonly RunningOperationRegistry _operations;
|
||||
private readonly AzureDevOpsClient? _azdo;
|
||||
|
||||
public WorktreeManager(
|
||||
GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg,
|
||||
ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null,
|
||||
RunningOperationRegistry? operations = null)
|
||||
RunningOperationRegistry? operations = null, AzureDevOpsClient? azdo = null)
|
||||
{
|
||||
_git = git;
|
||||
_dbFactory = dbFactory;
|
||||
@@ -38,6 +40,7 @@ public sealed class WorktreeManager
|
||||
_logger = logger;
|
||||
_broadcaster = broadcaster;
|
||||
_operations = operations ?? new RunningOperationRegistry();
|
||||
_azdo = azdo;
|
||||
}
|
||||
|
||||
private Task BroadcastCreating(string taskId) =>
|
||||
@@ -80,8 +83,8 @@ public sealed class WorktreeManager
|
||||
if (await _git.EnsureHeadCommitAsync(workingDir, ct))
|
||||
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
|
||||
|
||||
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
|
||||
var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
|
||||
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, isCompany, ct);
|
||||
var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany);
|
||||
var branchType = BranchTypeFor(task.CommitType);
|
||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||
@@ -204,8 +207,10 @@ public sealed class WorktreeManager
|
||||
|
||||
await _git.AddAllAsync(ctx.WorktreePath, ct);
|
||||
|
||||
var isCompany = list.WorkingDir is not null
|
||||
&& await CompanyRepo.IsCompanyAsync(_git, list.WorkingDir, _cfg.CompanyRemotePattern, ct);
|
||||
var message = CommitMessageBuilder.Build(
|
||||
task.CommitType, list.Name, task.Title, task.Description, task.Id);
|
||||
task.CommitType, list.Name, task.Title, task.Description, task.Id, includeTrailer: !isCompany);
|
||||
|
||||
await _git.CommitAsync(ctx.WorktreePath, message, ct);
|
||||
|
||||
@@ -264,9 +269,10 @@ public sealed class WorktreeManager
|
||||
/// <summary>
|
||||
/// A child of a parent with an Active worktree starts from the unit branch's current head, so it
|
||||
/// sees every sibling absorbed before it. Unit roots (and legacy children) base off
|
||||
/// <c>list_config.base_branch</c>, else the current HEAD.
|
||||
/// <c>list_config.base_branch</c>, else the current HEAD — in a company repo off a freshly fetched
|
||||
/// <c>origin/<basis></c> (<see cref="FetchCompanyBaseAsync"/>).
|
||||
/// </summary>
|
||||
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
|
||||
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, bool isCompany, CancellationToken ct)
|
||||
{
|
||||
if (task.ParentTaskId is not null)
|
||||
{
|
||||
@@ -276,12 +282,92 @@ public sealed class WorktreeManager
|
||||
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
|
||||
}
|
||||
using var cfgCtx = _dbFactory.CreateDbContext();
|
||||
var baseBranch = await cfgCtx.ListConfigs.AsNoTracking()
|
||||
var baseBranch = (await cfgCtx.ListConfigs.AsNoTracking()
|
||||
.Where(c => c.ListId == task.ListId)
|
||||
.Select(c => c.BaseBranch)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return string.IsNullOrWhiteSpace(baseBranch)
|
||||
.FirstOrDefaultAsync(ct)).NullIfBlank();
|
||||
|
||||
if (isCompany)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched) return fetched;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Fetching the company base in {working_dir} failed, continuing with the local branch", workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
return baseBranch is null
|
||||
? await _git.RevParseHeadAsync(workingDir, ct)
|
||||
: await _git.RevParseAsync(workingDir, baseBranch, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the basis (<c>base_branch</c>, else the ADO repo's default branch, else <c>origin/HEAD</c>) and
|
||||
/// returns <c>origin/<basis></c>'s commit; the local basis is fast-forwarded best-effort. Null when no
|
||||
/// basis is known or the fetch failed (offline, no credentials) — the caller then uses the local basis,
|
||||
/// a fetch problem never fails the task.
|
||||
/// </summary>
|
||||
private async Task<string?> FetchCompanyBaseAsync(string workingDir, string? baseBranch, CancellationToken ct)
|
||||
{
|
||||
var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct);
|
||||
var pat = _azdo?.Pat;
|
||||
var basis = baseBranch;
|
||||
if (basis is null && _azdo is not null && pat is not null && AzureDevOpsClient.ParseRemote(remote) is { } repo)
|
||||
{
|
||||
try { basis = await _azdo.GetDefaultBranchAsync(repo, ct); }
|
||||
catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException) { _logger.LogWarning("Default branch from Azure DevOps unavailable: {reason}", ex.Message); }
|
||||
}
|
||||
basis ??= await _git.GetRemoteHeadBranchAsync(workingDir, ct);
|
||||
if (basis is null)
|
||||
{
|
||||
_logger.LogWarning("Company repo {working_dir}: no base branch known, worktree starts from HEAD without fetch", workingDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
var (fetchExit, fetchErr) = await _git.FetchBranchAsync(workingDir, basis, AzureDevOpsClient.GitAuthEnv(pat), ct);
|
||||
if (fetchExit != 0)
|
||||
{
|
||||
_logger.LogWarning("git fetch origin {branch} failed in {working_dir}, continuing with the local branch: {reason}",
|
||||
basis, workingDir, fetchErr);
|
||||
return baseBranch is null ? await TryRevParseAsync(workingDir, basis, ct) : null;
|
||||
}
|
||||
|
||||
await FastForwardLocalBaseAsync(workingDir, basis, ct);
|
||||
return await _git.RevParseAsync(workingDir, $"refs/remotes/origin/{basis}", ct);
|
||||
}
|
||||
|
||||
/// <summary>Pulls the local basis up to <c>origin/<basis></c> without ever touching a dirty or diverged checkout.</summary>
|
||||
private async Task FastForwardLocalBaseAsync(string workingDir, string basis, CancellationToken ct)
|
||||
{
|
||||
string? current;
|
||||
try { current = await _git.GetCurrentBranchAsync(workingDir, ct); }
|
||||
catch (InvalidOperationException) { current = null; } // detached HEAD
|
||||
|
||||
(int ExitCode, string Stderr) result;
|
||||
if (current == basis)
|
||||
{
|
||||
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
|
||||
{
|
||||
_logger.LogWarning("Local {branch} in {working_dir} has uncommitted changes, not fast-forwarded to origin", basis, workingDir);
|
||||
return;
|
||||
}
|
||||
result = await _git.MergeFastForwardOnlyAsync(workingDir, $"refs/remotes/origin/{basis}", ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!(await _git.ListLocalBranchesAsync(workingDir, ct)).Contains(basis)) return;
|
||||
result = await _git.FastForwardLocalBranchAsync(workingDir, basis, ct);
|
||||
}
|
||||
if (result.ExitCode != 0)
|
||||
_logger.LogWarning("Local {branch} in {working_dir} not fast-forwarded to origin: {reason}", basis, workingDir, result.Stderr);
|
||||
}
|
||||
|
||||
private async Task<string?> TryRevParseAsync(string workingDir, string revision, CancellationToken ct)
|
||||
{
|
||||
try { return await _git.RevParseAsync(workingDir, revision, ct); }
|
||||
catch (InvalidOperationException) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user