feat(ui): external services tab with Azure DevOps PAT, draft PR button and link in company repos
This commit is contained in:
@@ -16,7 +16,7 @@
|
|||||||
"categoryFiles": "Dateien & Prompts",
|
"categoryFiles": "Dateien & Prompts",
|
||||||
"categorySkills": "Session Skills",
|
"categorySkills": "Session Skills",
|
||||||
"categoryReports": "Berichte",
|
"categoryReports": "Berichte",
|
||||||
"categoryTickets": "Ticketsystem"
|
"categoryTickets": "Externe Dienste"
|
||||||
},
|
},
|
||||||
"repoHint": {
|
"repoHint": {
|
||||||
"text": "Wirkt erst mit verknüpftem Repo.",
|
"text": "Wirkt erst mit verknüpftem Repo.",
|
||||||
@@ -148,6 +148,10 @@
|
|||||||
"saveButton": "Konfiguration speichern"
|
"saveButton": "Konfiguration speichern"
|
||||||
},
|
},
|
||||||
"tickets": {
|
"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",
|
"baseUrl": "API-Base-URL",
|
||||||
"baseUrlHint": "Leer lassen, um die Ticket-Anbindung komplett zu deaktivieren.",
|
"baseUrlHint": "Leer lassen, um die Ticket-Anbindung komplett zu deaktivieren.",
|
||||||
"token": "Personal Access Token",
|
"token": "Personal Access Token",
|
||||||
@@ -335,6 +339,10 @@
|
|||||||
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
|
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
|
||||||
"targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.",
|
"targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.",
|
||||||
"approveMerge": "Genehmigen & Mergen",
|
"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",
|
"park": "Parken",
|
||||||
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
|
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -885,7 +893,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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "error": "Fehler: {0}" },
|
||||||
"usageMonitor": {
|
"usageMonitor": {
|
||||||
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
||||||
@@ -912,7 +920,8 @@
|
|||||||
"stalledHint": "Läuft weiter — Worker antwortet noch",
|
"stalledHint": "Läuft weiter — Worker antwortet noch",
|
||||||
"merge": {
|
"merge": {
|
||||||
"merging": "Wird zusammengeführt…",
|
"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": {
|
"diff": {
|
||||||
"parsing": "Diff wird geparst…"
|
"parsing": "Diff wird geparst…"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"categoryFiles": "Files & Prompts",
|
"categoryFiles": "Files & Prompts",
|
||||||
"categorySkills": "Session Skills",
|
"categorySkills": "Session Skills",
|
||||||
"categoryReports": "Reports",
|
"categoryReports": "Reports",
|
||||||
"categoryTickets": "Ticket system"
|
"categoryTickets": "External services"
|
||||||
},
|
},
|
||||||
"repoHint": {
|
"repoHint": {
|
||||||
"text": "Only takes effect once a repo is linked.",
|
"text": "Only takes effect once a repo is linked.",
|
||||||
@@ -148,6 +148,10 @@
|
|||||||
"saveButton": "Save config"
|
"saveButton": "Save config"
|
||||||
},
|
},
|
||||||
"tickets": {
|
"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",
|
"baseUrl": "API base URL",
|
||||||
"baseUrlHint": "Leave empty to disable the ticket integration entirely.",
|
"baseUrlHint": "Leave empty to disable the ticket integration entirely.",
|
||||||
"token": "Personal access token",
|
"token": "Personal access token",
|
||||||
@@ -335,6 +339,10 @@
|
|||||||
"openDiffHint": "Open the diff to enable merge",
|
"openDiffHint": "Open the diff to enable merge",
|
||||||
"targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.",
|
"targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.",
|
||||||
"approveMerge": "Approve & Merge",
|
"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",
|
"park": "Park",
|
||||||
"parkTip": "Set aside — back to Idle, keeps the worktree",
|
"parkTip": "Set aside — back to Idle, keeps the worktree",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -885,7 +893,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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"weeklyReport": { "invalidRange": "Invalid date range.", "error": "Error: {0}" },
|
||||||
"usageMonitor": {
|
"usageMonitor": {
|
||||||
"loadFailed": "Couldn't load usage data: {0}",
|
"loadFailed": "Couldn't load usage data: {0}",
|
||||||
@@ -912,7 +920,8 @@
|
|||||||
"stalledHint": "Still running — worker hasn't responded in a while",
|
"stalledHint": "Still running — worker hasn't responded in a while",
|
||||||
"merge": {
|
"merge": {
|
||||||
"merging": "Merging…",
|
"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": {
|
"diff": {
|
||||||
"parsing": "Parsing diff…"
|
"parsing": "Parsing diff…"
|
||||||
|
|||||||
@@ -676,6 +676,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
|
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
|
||||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||||
WorktreeStateLabel, _listWorkingDir);
|
WorktreeStateLabel, _listWorkingDir);
|
||||||
|
Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl;
|
||||||
|
|
||||||
await AgentSettings.LoadForTaskAsync(entity, ct);
|
await AgentSettings.LoadForTaskAsync(entity, ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
@@ -728,6 +729,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
|
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
|
||||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||||
Merge.TargetDirty = targets.TargetDirty;
|
Merge.TargetDirty = targets.TargetDirty;
|
||||||
|
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Merge.RefreshMergePreviewAsync();
|
await Merge.RefreshMergePreviewAsync();
|
||||||
@@ -779,6 +781,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Merge.MergeTargetBranches.Add(b);
|
Merge.MergeTargetBranches.Add(b);
|
||||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||||
Merge.TargetDirty = targets.TargetDirty;
|
Merge.TargetDirty = targets.TargetDirty;
|
||||||
|
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -826,6 +829,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Merge.MergeTargetBranches.Add(b);
|
Merge.MergeTargetBranches.Add(b);
|
||||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||||
Merge.TargetDirty = targets.TargetDirty;
|
Merge.TargetDirty = targets.TargetDirty;
|
||||||
|
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -939,6 +943,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||||
WorktreeStateLabel, _listWorkingDir);
|
WorktreeStateLabel, _listWorkingDir);
|
||||||
|
Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl;
|
||||||
}
|
}
|
||||||
catch { /* best-effort refresh */ }
|
catch { /* best-effort refresh */ }
|
||||||
}
|
}
|
||||||
@@ -1261,7 +1266,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
var taskId = Task.Id;
|
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
|
// 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.
|
// 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)
|
void OnOperationProgress(string opKey, string phase, int current, int total)
|
||||||
@@ -1276,7 +1283,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
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
|
// 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
|
// 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)
|
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);
|
await Dialogs.ShowErrorAsync(ex.Message);
|
||||||
}
|
}
|
||||||
finally
|
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.
|
// dir is visible before anyone clicks, not just after the merge modal refuses it.
|
||||||
[ObservableProperty] private bool _targetDirty;
|
[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,
|
// 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.
|
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
|
||||||
public OperationStatus PreviewOp { get; } = new();
|
public OperationStatus PreviewOp { get; } = new();
|
||||||
@@ -146,11 +162,20 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
|||||||
MergeIsClean = false;
|
MergeIsClean = false;
|
||||||
MergeIsConflict = false;
|
MergeIsConflict = false;
|
||||||
TargetDirty = false;
|
TargetDirty = false;
|
||||||
|
IsCompanyRepo = false;
|
||||||
|
PullRequestUrl = null;
|
||||||
SyncWorktree(null, null, null, null, null);
|
SyncWorktree(null, null, null, null, null);
|
||||||
SyncTaskContext(null, null, false);
|
SyncTaskContext(null, null, false);
|
||||||
SyncChildOutcomes(false, 0);
|
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))]
|
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
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.
|
// Never shows the stored token — TicketSettingsDto only ever carries the bool.
|
||||||
public string TokenPlaceholder => TokenSet ? Loc.T("settings.tickets.tokenSet") : "";
|
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();
|
public ObservableCollection<FallbackListOption> FallbackLists { get; } = new();
|
||||||
[ObservableProperty] private FallbackListOption? _selectedFallbackList;
|
[ObservableProperty] private FallbackListOption? _selectedFallbackList;
|
||||||
|
|
||||||
@@ -54,6 +63,7 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
|
|||||||
|
|
||||||
public async Task LoadAsync()
|
public async Task LoadAsync()
|
||||||
{
|
{
|
||||||
|
AzdoTokenSet = (await _worker.GetAzureDevOpsSettingsAsync())?.TokenSet ?? false;
|
||||||
var dto = await _worker.GetTicketSettingsAsync();
|
var dto = await _worker.GetTicketSettingsAsync();
|
||||||
if (dto is null)
|
if (dto is null)
|
||||||
{
|
{
|
||||||
@@ -86,6 +96,40 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
if (_fallbackLoaded)
|
if (_fallbackLoaded)
|
||||||
await _worker.SetTicketFallbackListAsync(SelectedFallbackList?.Id);
|
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]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -321,8 +321,18 @@
|
|||||||
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
|
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
|
||||||
</StackPanel>
|
</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 -->
|
<!-- 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}" />
|
<TextBlock Classes="field-label" Text="{loc:Tr details.workConsole.targetBranch}" />
|
||||||
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
|
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
|
||||||
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
|
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
|
||||||
@@ -333,7 +343,8 @@
|
|||||||
IsVisible="{Binding Merge.TargetDirty}" />
|
IsVisible="{Binding Merge.TargetDirty}" />
|
||||||
</StackPanel>
|
</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"
|
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
|
||||||
Foreground="{DynamicResource MossBrush}"
|
Foreground="{DynamicResource MossBrush}"
|
||||||
IsVisible="{Binding Merge.MergeIsClean}" />
|
IsVisible="{Binding Merge.MergeIsClean}" />
|
||||||
@@ -400,8 +411,13 @@
|
|||||||
|
|
||||||
<WrapPanel Orientation="Horizontal">
|
<WrapPanel Orientation="Horizontal">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||||
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.approveMerge}"
|
<Button Classes="btn accent" Command="{Binding ApproveReviewCommand}">
|
||||||
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}" />
|
<controls:OperationIndicator Status="{Binding ApproveOp}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||||
|
|||||||
@@ -738,12 +738,14 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<!-- Ticketsystem: Base-URL + PAT for the Bandel ticket integration. Empty base URL
|
<!-- External services: Bandel ticket system (base URL + PAT; empty base URL disables it, so
|
||||||
disables the feature entirely, so this tab intentionally never forces a value. -->
|
this tab never forces a value) and the Azure DevOps PAT for company-repo push + draft PR. -->
|
||||||
<TabItem Header="{loc:Tr settings.sidebar.categoryTickets}">
|
<TabItem Header="{loc:Tr settings.sidebar.categoryTickets}">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Spacing="14" Margin="0,8,0,0">
|
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||||||
|
|
||||||
|
<TextBlock Classes="section-label" Text="{loc:Tr settings.tickets.ticketSection}"/>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.baseUrl}"/>
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.baseUrl}"/>
|
||||||
<TextBox Text="{Binding Tickets.ApiBaseUrl, Mode=TwoWay}"/>
|
<TextBox Text="{Binding Tickets.ApiBaseUrl, Mode=TwoWay}"/>
|
||||||
@@ -785,6 +787,33 @@
|
|||||||
<TextBlock Classes="meta" Text="{loc:Tr settings.tickets.fallbackListHint}"
|
<TextBlock Classes="meta" Text="{loc:Tr settings.tickets.fallbackListHint}"
|
||||||
TextWrapping="Wrap" Opacity="0.6"/>
|
TextWrapping="Wrap" Opacity="0.6"/>
|
||||||
</StackPanel>
|
</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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>Detail pane in a company repo: approve becomes "create draft PR" / "update PR", and the PR link only exists once there is a PR.</summary>
|
||||||
|
public sealed class MergeSectionCompanyRepoTests
|
||||||
|
{
|
||||||
|
private sealed class Worker : StubWorkerClient { }
|
||||||
|
|
||||||
|
private static MergeSectionViewModel Vm() => new(new Worker(), services: null!);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrivateRepo_ShowsOnlyApproveAndMerge()
|
||||||
|
{
|
||||||
|
var vm = Vm();
|
||||||
|
|
||||||
|
Assert.True(vm.ShowApproveMerge);
|
||||||
|
Assert.False(vm.ShowCreatePr);
|
||||||
|
Assert.False(vm.ShowUpdatePr);
|
||||||
|
Assert.False(vm.OpenPullRequestCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompanyRepo_WithoutPr_OffersCreateDraftPr()
|
||||||
|
{
|
||||||
|
var vm = Vm();
|
||||||
|
vm.IsCompanyRepo = true;
|
||||||
|
|
||||||
|
Assert.False(vm.ShowApproveMerge);
|
||||||
|
Assert.True(vm.ShowCreatePr);
|
||||||
|
Assert.False(vm.ShowUpdatePr);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompanyRepo_WithPr_OffersUpdateAndLink()
|
||||||
|
{
|
||||||
|
var vm = Vm();
|
||||||
|
vm.IsCompanyRepo = true;
|
||||||
|
vm.PullRequestUrl = "https://srv/Coll/Proj/_git/Repo/pullrequest/5";
|
||||||
|
|
||||||
|
Assert.False(vm.ShowCreatePr);
|
||||||
|
Assert.True(vm.ShowUpdatePr);
|
||||||
|
Assert.True(vm.OpenPullRequestCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,44 @@ public sealed class TicketSettingsTabViewModelTests : IDisposable
|
|||||||
public List<string?> SavedFallbacks { get; } = new();
|
public List<string?> SavedFallbacks { get; } = new();
|
||||||
public override Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult(Settings);
|
public override Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult(Settings);
|
||||||
public override Task SetTicketFallbackListAsync(string? listId) { SavedFallbacks.Add(listId); return Task.CompletedTask; }
|
public override Task SetTicketFallbackListAsync(string? listId) { SavedFallbacks.Add(listId); return Task.CompletedTask; }
|
||||||
|
public List<string> SavedAzdoTokens { get; } = new();
|
||||||
|
public AzureDevOpsConnectionDto? AzdoConnection { get; set; }
|
||||||
|
public override Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync() => Task.FromResult<AzureDevOpsSettingsDto?>(new(false));
|
||||||
|
public override Task SetAzureDevOpsTokenAsync(string token) { SavedAzdoTokens.Add(token); return Task.CompletedTask; }
|
||||||
|
public override Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync() => Task.FromResult(AzdoConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Save_writes_a_typed_azdo_token_once_and_never_keeps_it_in_the_field()
|
||||||
|
{
|
||||||
|
var worker = new Worker { Settings = new TicketSettingsDto("http://api", true, null) };
|
||||||
|
var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext));
|
||||||
|
await vm.LoadAsync();
|
||||||
|
Assert.False(vm.AzdoTokenSet);
|
||||||
|
|
||||||
|
vm.AzdoToken = "azdo-pat";
|
||||||
|
await vm.SaveAsync();
|
||||||
|
await vm.SaveAsync();
|
||||||
|
|
||||||
|
Assert.Equal(["azdo-pat"], worker.SavedAzdoTokens);
|
||||||
|
Assert.Equal("", vm.AzdoToken);
|
||||||
|
Assert.True(vm.AzdoTokenSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Azdo_test_reports_the_server_error_verbatim()
|
||||||
|
{
|
||||||
|
var worker = new Worker
|
||||||
|
{
|
||||||
|
Settings = new TicketSettingsDto("http://api", true, null),
|
||||||
|
AzdoConnection = new AzureDevOpsConnectionDto(false, null, null, "Azure DevOps 401: PAT abgelaufen"),
|
||||||
|
};
|
||||||
|
var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext));
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
await vm.TestAzdoConnectionCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("Azure DevOps 401: PAT abgelaufen", vm.AzdoStatusMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user