diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index c7d20b2a..c454dc78 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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", @@ -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}" }, "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}", @@ -912,7 +920,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…" diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 8753c160..53a60d5e 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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", @@ -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}" }, "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}", @@ -912,7 +920,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…" diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 23154686..9eadff47 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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 diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs index cf3f6d16..3750cdd1 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs @@ -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; + /// Mirrors MergeTargetsDto.IsCompanyRepo: approve pushes + opens a draft PR, there is no target to pick. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowApproveMerge), nameof(ShowCreatePr), nameof(ShowUpdatePr))] + private bool _isCompanyRepo; + + /// The worktree's draft PR (company repo), once an approve created it. + [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() { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs index 785a5c1e..ba8821f5 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs @@ -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") : ""; + /// Azure DevOps PAT being typed; never shows the stored one. + [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 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] diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml index 91918844..362af2fb 100644 --- a/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml @@ -321,8 +321,18 @@ + + + + diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index c1b734d2..7d28cf1a 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -738,12 +738,14 @@ - + + + @@ -785,6 +787,33 @@ + + + + + + + + + + + + +