From f6836600b4b9d791c31be0cbc14afc1f78bd8083 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 18 May 2026 17:12:24 +0800 Subject: [PATCH] feat(miniapp): refine built-in PR review workflow --- .../builtin/assets/pr-review/style.css | 547 ++++- .../miniapp/builtin/assets/pr-review/ui.js | 1776 +++++++++++++++-- src/crates/core/src/miniapp/builtin/mod.rs | 213 +- 3 files changed, 2320 insertions(+), 216 deletions(-) diff --git a/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css b/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css index 32d66f3cb..88111bbbf 100644 --- a/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css +++ b/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css @@ -84,7 +84,11 @@ button { cursor: pointer; } -button:disabled { +button:disabled, +.pr-btn[aria-disabled="true"], +.pr-icon-btn[aria-disabled="true"], +.pr-text-btn[aria-disabled="true"], +.pr-sync-tile[aria-disabled="true"] { cursor: not-allowed; opacity: 0.55; } @@ -104,6 +108,15 @@ button:disabled { overflow: hidden; } +.pr-shell--with-status { + grid-template-rows: auto auto minmax(0, 1fr); + row-gap: 8px; +} + +.pr-shell--with-status .pr-main-layout { + grid-row: 3; +} + .pr-command-bar { display: grid; grid-template-columns: minmax(180px, 0.45fr) minmax(520px, 1.55fr) minmax(300px, 0.75fr); @@ -119,6 +132,22 @@ button:disabled { box-shadow: 0 10px 26px var(--pr-shadow); } +.pr-command-bar--simple { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "brand actions"; + align-items: center; + padding: 10px 12px; +} + +.pr-command-actions { + grid-area: actions; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + .pr-brand { grid-area: brand; display: flex; @@ -129,6 +158,7 @@ button:disabled { } .pr-brand-mark { + flex: 0 0 30px; width: 30px; height: 30px; display: grid; @@ -217,6 +247,17 @@ button:disabled { 100% { background-position: 0% 50%; } } +@keyframes pr-pulse-dot { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 0 5px color-mix(in srgb, var(--pr-info) 14%, transparent); + } + 50% { + opacity: 0.58; + box-shadow: 0 0 0 8px color-mix(in srgb, var(--pr-info) 6%, transparent); + } +} + .pr-repo-first::before { opacity: 0.15; } @@ -408,17 +449,59 @@ button:disabled { border-color: color-mix(in srgb, var(--pr-accent) 36%, var(--pr-line)); } +.pr-btn[aria-disabled="true"]:hover, +.pr-icon-btn[aria-disabled="true"]:hover { + background: var(--pr-surface-2); + border-color: var(--pr-line); +} + .pr-btn:active, .pr-icon-btn:active { transform: translateY(1px); } +.pr-btn[aria-disabled="true"]:active, +.pr-icon-btn[aria-disabled="true"]:active { + transform: none; +} + .pr-btn--primary { border-color: color-mix(in srgb, var(--pr-accent) 48%, var(--pr-line)); background: color-mix(in srgb, var(--pr-accent) 17%, var(--pr-surface-2)); color: var(--pr-text); } +.pr-btn--review { + border-color: color-mix(in srgb, var(--pr-accent) 62%, var(--pr-line)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 42%, var(--pr-surface-2)), color-mix(in srgb, var(--pr-info) 18%, var(--pr-surface-2))); + color: var(--pr-text); + box-shadow: 0 8px 20px color-mix(in srgb, var(--pr-accent) 15%, transparent); +} + +.pr-btn--review:hover { + border-color: color-mix(in srgb, var(--pr-accent) 76%, var(--pr-line)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 50%, var(--pr-surface-2)), color-mix(in srgb, var(--pr-info) 23%, var(--pr-surface-2))); +} + +.pr-btn--secondary { + color: var(--pr-text-2); + background: color-mix(in srgb, var(--pr-surface-2) 84%, transparent); +} + +.pr-btn--merge { + border-color: color-mix(in srgb, var(--pr-green) 38%, var(--pr-line)); + background: color-mix(in srgb, var(--pr-green) 13%, var(--pr-surface-2)); + color: color-mix(in srgb, var(--pr-green) 72%, var(--pr-text)); +} + +.pr-btn--danger { + border-color: color-mix(in srgb, var(--pr-red) 42%, var(--pr-line)); + background: color-mix(in srgb, var(--pr-red) 14%, var(--pr-surface-2)); + color: color-mix(in srgb, var(--pr-red) 78%, var(--pr-text)); +} + .pr-btn--compact { min-height: 30px; padding: 5px 9px; @@ -450,6 +533,12 @@ button:disabled { background: var(--pr-surface-2); } +.pr-text-btn[aria-disabled="true"]:hover { + color: var(--pr-muted); + border-color: transparent; + background: transparent; +} + .pr-token-badge, .pr-chip, .pr-count { @@ -497,6 +586,14 @@ button:disabled { min-height: 0; } +.pr-main-layout--no-composer { + grid-template-columns: minmax(260px, 0.5fr) minmax(0, 1.5fr); +} + +.pr-main-layout--reviewing { + grid-template-columns: minmax(300px, 0.78fr) minmax(460px, 1.35fr) minmax(330px, 0.92fr); +} + .pr-sidebar, .pr-review-workspace, .pr-composer { @@ -581,6 +678,10 @@ button:disabled { padding: 12px 12px 0; } +.pr-sync-panel--settings { + padding: 0; +} + .pr-sync-tile { display: grid; gap: 4px; @@ -609,6 +710,17 @@ button:disabled { background: linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 12%, transparent), color-mix(in srgb, var(--pr-info) 7%, transparent)); } +.pr-sync-tile[aria-disabled="true"]:hover { + border-color: var(--pr-line-soft); + background: color-mix(in srgb, var(--pr-surface) 88%, var(--pr-bg)); +} + +.pr-sync-tile.is-active[aria-disabled="true"], +.pr-sync-tile.is-active[aria-disabled="true"]:hover { + border-color: color-mix(in srgb, var(--pr-accent) 56%, var(--pr-line)); + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 12%, transparent), color-mix(in srgb, var(--pr-info) 7%, transparent)); +} + .pr-queue-actions { align-items: center; justify-content: space-between; @@ -633,6 +745,11 @@ button:disabled { padding: 4px 7px; } +.pr-queue-actions--settings { + padding: 0; + justify-content: space-between; +} + .pr-list, .pr-source-list, .pr-review-list, @@ -657,6 +774,15 @@ button:disabled { padding: 11px; } +.pr-queue-item--compact { + gap: 6px; + padding: 10px; +} + +.pr-main-layout--reviewing .pr-queue-item--compact { + padding: 9px; +} + .pr-queue-item:hover, .pr-queue-item.is-active { border-color: color-mix(in srgb, var(--pr-accent) 56%, var(--pr-line)); @@ -673,6 +799,10 @@ button:disabled { overflow: hidden; } +.pr-queue-item--compact .pr-queue-title { + -webkit-line-clamp: 2; +} + .pr-queue-meta, .pr-queue-signals, .pr-meta-row { @@ -854,11 +984,32 @@ button:disabled { margin: 0; } +.pr-shell-status { + margin: 0; +} + +.pr-shell-status .pr-status { + display: flex; + align-items: center; + min-height: 34px; + font-size: 13px; + font-weight: 720; +} + .pr-status--error { color: var(--pr-red); border-color: color-mix(in srgb, var(--pr-red) 35%, var(--pr-line)); } +.pr-status--busy { + color: var(--pr-text); + border-color: color-mix(in srgb, var(--pr-accent) 42%, var(--pr-line)); + background: + linear-gradient(90deg, color-mix(in srgb, var(--pr-accent) 18%, var(--pr-surface)), color-mix(in srgb, var(--pr-info) 10%, var(--pr-bg))), + var(--pr-surface); + box-shadow: 0 8px 22px color-mix(in srgb, var(--pr-accent) 12%, transparent); +} + .pr-empty { display: grid; place-items: center; @@ -905,10 +1056,16 @@ button:disabled { .pr-pr-actions { align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; + flex: 0 0 auto; justify-content: flex-end; } +.pr-pr-actions .pr-btn { + min-height: 32px; + padding-inline: 9px; +} + .pr-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -946,6 +1103,52 @@ button:disabled { font-weight: 740; } +.pr-lifecycle-panel { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-surface) 92%, var(--pr-bg)), color-mix(in srgb, var(--pr-accent) 5%, var(--pr-bg))); +} + +.pr-lifecycle-panel .pr-section-head { + margin-bottom: 0; +} + +.pr-lifecycle-panel .pr-muted { + margin: 4px 0 0; +} + +.pr-lifecycle-tags { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.pr-lifecycle-tag { + min-height: 24px; + max-width: 100%; + border-color: color-mix(in srgb, var(--pr-accent) 22%, var(--pr-line-soft)); + background: color-mix(in srgb, var(--pr-bg) 74%, var(--pr-surface)); +} + +.pr-lifecycle-warnings { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--pr-amber); + font-size: 11px; + line-height: 1.4; +} + +.pr-lifecycle-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + .pr-section-head { display: flex; justify-content: space-between; @@ -1014,18 +1217,24 @@ button:disabled { .pr-diff-panel { min-width: 0; + max-height: min(560px, 56vh); + overflow: auto; + overscroll-behavior: contain; border: 1px solid var(--pr-line-soft); border-radius: 8px; - overflow: hidden; background: var(--pr-diff-bg); } .pr-diff-head { + position: sticky; + top: 0; + z-index: 1; display: flex; justify-content: space-between; gap: 10px; padding: 9px 10px; border-bottom: 1px solid var(--pr-line-soft); + background: var(--pr-diff-bg); } .pr-diff-head strong { @@ -1078,31 +1287,62 @@ button:disabled { } .pr-fold summary { + list-style: none; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; cursor: pointer; color: var(--pr-text-2); font-size: 13px; font-weight: 720; } -.pr-fold summary span { - margin-left: 8px; - color: var(--pr-muted); - font-size: 11px; - font-weight: 400; +.pr-fold summary::-webkit-details-marker { + display: none; } -.pr-overview-fold summary { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; +.pr-fold summary::before { + content: ""; + width: 0; + height: 0; + flex: 0 0 auto; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid var(--pr-muted); + transition: transform 140ms ease, border-left-color 140ms ease; +} + +.pr-fold[open] > summary::before { + transform: rotate(90deg); + border-left-color: var(--pr-accent); +} + +.pr-fold-title { + flex: 0 0 auto; + min-width: max-content; + color: var(--pr-text-2); + font-size: 13px; + font-weight: 720; + white-space: nowrap; } -.pr-overview-fold summary span { +.pr-fold-meta { + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--pr-muted); + font-size: 11px; + font-weight: 400; +} + +.pr-overview-fold summary { + justify-content: flex-start; +} + +.pr-overview-fold .pr-fold-meta { margin-left: 0; } @@ -1110,6 +1350,19 @@ button:disabled { margin-top: 10px; } +.pr-files-fold summary { + justify-content: flex-start; +} + +.pr-files-fold .pr-fold-meta { + margin-left: auto; + text-align: right; +} + +.pr-files-fold .pr-files-layout { + margin-top: 10px; +} + .pr-review-row { display: grid; gap: 7px; @@ -1120,6 +1373,69 @@ button:disabled { font-size: 12px; } +.pr-ci-list { + display: grid; + gap: 7px; +} + +.pr-ci-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 82%, var(--pr-surface)); +} + +.pr-ci-row strong, +.pr-ci-row span { + display: block; +} + +.pr-ci-row strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--pr-text); + font-size: 12px; +} + +.pr-ci-row span { + color: var(--pr-muted); + font-size: 11px; +} + +.pr-ci-row .pr-text-btn { + white-space: nowrap; +} + +.pr-ci-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--pr-muted); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-muted) 10%, transparent); +} + +.pr-ci-dot.is-ok { + background: var(--pr-green); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-green) 12%, transparent); +} + +.pr-ci-dot.is-warn { + background: var(--pr-amber); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-amber) 14%, transparent); +} + +.pr-ci-dot.is-bad { + background: var(--pr-red); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-red) 13%, transparent); +} + .pr-review-row p { margin: 0; color: var(--pr-text-2); @@ -1128,15 +1444,33 @@ button:disabled { } .pr-manual-comment { - margin-top: 10px; display: grid; gap: 8px; - padding: 10px; + padding: 12px; border: 1px solid var(--pr-line-soft); border-radius: 8px; background: color-mix(in srgb, var(--pr-bg) 76%, var(--pr-surface)); } +.pr-manual-comment-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.pr-manual-comment .pr-textarea { + min-height: 56px; +} + +.pr-manual-comment.is-expanded .pr-textarea { + min-height: 260px; +} + +.pr-manual-comment .pr-btn { + justify-self: flex-start; +} + .pr-composer { padding-bottom: 12px; } @@ -1150,6 +1484,39 @@ button:disabled { margin: 0 12px 12px; } +.pr-language-control { + display: grid; + grid-template-columns: minmax(0, 1fr) 124px; + gap: 10px; + align-items: center; + margin: 0 12px 12px; + padding: 10px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 80%, var(--pr-surface)); +} + +.pr-language-control span { + min-width: 0; +} + +.pr-language-control strong, +.pr-language-control small { + display: block; +} + +.pr-language-control strong { + color: var(--pr-text); + font-size: 12px; +} + +.pr-language-control small { + margin-top: 3px; + color: var(--pr-muted); + font-size: 11px; + line-height: 1.35; +} + .pr-live-status { margin: 0 12px 12px; display: grid; @@ -1164,6 +1531,11 @@ button:disabled { box-shadow: 0 8px 22px color-mix(in srgb, var(--pr-accent) 12%, transparent); } +.pr-composer.is-reviewing .pr-live-status { + border-color: color-mix(in srgb, var(--pr-info) 48%, var(--pr-line)); + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-info) 17%, var(--pr-surface)), color-mix(in srgb, var(--pr-accent) 11%, var(--pr-bg))); +} + .pr-live-status-dot { width: 9px; height: 9px; @@ -1172,6 +1544,10 @@ button:disabled { box-shadow: 0 0 0 5px color-mix(in srgb, var(--pr-accent) 15%, transparent); } +.pr-composer.is-reviewing .pr-live-status-dot { + animation: pr-pulse-dot 1.1s ease-in-out infinite; +} + .pr-live-status strong, .pr-live-status span { display: block; @@ -1189,6 +1565,37 @@ button:disabled { line-height: 1.45; } +.pr-reviewing-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--pr-info) 42%, var(--pr-line)); + border-left-width: 3px; + border-radius: 8px; + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-info) 16%, var(--pr-surface)), color-mix(in srgb, var(--pr-accent) 9%, var(--pr-bg))); + box-shadow: 0 10px 26px color-mix(in srgb, var(--pr-info) 12%, transparent); +} + +.pr-reviewing-banner strong, +.pr-reviewing-banner span { + display: block; +} + +.pr-reviewing-banner strong { + color: var(--pr-text); + font-size: 13px; +} + +.pr-reviewing-banner span { + margin-top: 3px; + color: var(--pr-text-2); + font-size: 11px; + line-height: 1.45; +} + .pr-draft-list { padding: 0 12px; } @@ -1269,6 +1676,14 @@ button:disabled { min-width: 0; } +.pr-draft-path-row { + min-width: 0; +} + +.pr-draft-path-row .pr-file-link { + max-width: 100%; +} + .pr-file-link { display: inline-flex; align-items: center; @@ -1360,6 +1775,15 @@ button:disabled { box-shadow: 0 30px 80px rgba(0, 0, 0, 0.46); } +.pr-modal--lifecycle { + width: min(620px, calc(100vw - 40px)); +} + +.pr-settings-modal { + width: min(980px, calc(100vw - 40px)); + overflow: hidden; +} + .pr-modal-head, .pr-modal-body, .pr-modal-foot { @@ -1367,6 +1791,10 @@ button:disabled { } .pr-modal-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; border-bottom: 1px solid var(--pr-line-soft); } @@ -1375,11 +1803,79 @@ button:disabled { font-size: 16px; } +.pr-modal-head p { + margin: 4px 0 0; + color: var(--pr-muted); + font-size: 12px; + line-height: 1.45; +} + .pr-modal-body p { color: var(--pr-text-2); line-height: 1.55; } +.pr-settings-body { + max-height: calc(100vh - 170px); + overflow: auto; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +.pr-settings-body .pr-card--sources, +.pr-settings-section, +.pr-settings-body .pr-url-card, +.pr-settings-body .pr-access { + min-width: 0; + grid-area: auto; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 80%, var(--pr-surface)); +} + +.pr-settings-section { + display: grid; + gap: 10px; + padding: 11px; +} + +.pr-confirm-summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 12px 0; +} + +.pr-confirm-summary div { + min-width: 0; + display: grid; + gap: 4px; + padding: 9px; + border: 1px solid var(--pr-line-soft); + border-radius: 7px; + background: color-mix(in srgb, var(--pr-bg) 74%, var(--pr-surface)); +} + +.pr-confirm-summary span { + color: var(--pr-muted); + font-size: 11px; + font-weight: 650; +} + +.pr-confirm-summary strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--pr-text); + font-size: 12px; + white-space: nowrap; +} + +.pr-lifecycle-warnings--modal { + margin-top: 12px; +} + .pr-modal-foot { display: flex; justify-content: flex-end; @@ -1420,6 +1916,12 @@ button:disabled { "access"; } + .pr-command-bar--simple { + grid-template-areas: + "brand" + "actions"; + } + .pr-sidebar, .pr-composer, .pr-review-workspace { @@ -1435,7 +1937,8 @@ button:disabled { .pr-open-strip, .pr-pr-header, - .pr-pr-actions { + .pr-pr-actions, + .pr-command-actions { align-items: stretch; flex-direction: column; } @@ -1446,10 +1949,18 @@ button:disabled { .pr-top-watch-form, .pr-access-row, .pr-source-row, - .pr-sync-panel { + .pr-sync-panel, + .pr-language-control, + .pr-settings-body, + .pr-confirm-summary { grid-template-columns: 1fr; } + .pr-reviewing-banner { + align-items: stretch; + flex-direction: column; + } + .pr-access-row .pr-btn, .pr-access-row .pr-token-badge, .pr-refresh-now { diff --git a/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js b/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js index eef7b9f00..5a8ec6c67 100644 --- a/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js +++ b/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js @@ -21,6 +21,11 @@ const I18N = { 'en-US': { title: 'PR Review Inbox', subtitle: 'Watch repositories, open PRs, review diffs, compose feedback, and publish with confirmation.', + manageSources: 'Manage sources', + sourceSettingsTitle: 'Sources and queue settings', + sourceSettingsSubtitle: 'Adjust watched repositories, queue mode, private access, or open a single PR.', + queueSettingsTitle: 'Queue settings', + queueSettingsHint: 'Choose which PRs appear in the review queue. The main workspace stays focused on review.', queueModeAll: 'Repository PRs', queueModeMine: 'Needs my review', queueModeAllHint: 'Sync open PRs from watched repositories.', @@ -70,7 +75,7 @@ const I18N = { manualToken: 'Manual token', repoAddedSyncing: 'Repository added. Syncing queue...', advancedProviders: 'Advanced provider settings', - errorRepoNotFound: 'Repository not found or not accessible. Check the repository path or authorize GitHub CLI.', + errorSourceUnavailable: 'Could not refresh {repo}. The repository or PR may be unavailable, the provider API may be incompatible, or your account may not have access. Check Manage sources or authorize the provider.', workspaceDiscovering: 'Detecting repositories from the current workspace...', workspaceDiscovered: 'Added {count} repository from the current workspace.', workspaceDiscoveredMany: 'Added {count} repositories from the current workspace.', @@ -81,6 +86,17 @@ const I18N = { repositoryFirstHint: 'Add the repo you care about, then sync its review queue.', singlePrFallback: 'Inspect one PR', singlePrFallbackHint: 'Use this when the repository is not watched yet.', + directOpenBusySync: 'Queue sync is running. Wait a moment before opening a single PR.', + directOpenBusyReview: 'AI review is running. Stop or finish it before opening another PR.', + directOpenBusyPublish: 'Review publishing is in progress. Finish it before opening another PR.', + directOpenBusyLifecycle: 'A PR lifecycle action is in progress. Finish it before opening another PR.', + directOpenBusyGeneric: 'Another PR action is in progress. Finish it before opening a single PR.', + busyActionSync: 'Queue sync is already running. Wait a moment before starting another action.', + busyActionReview: 'AI review is running. Stop or finish it before using this action.', + busyActionPublish: 'Review publishing is in progress. Finish it before using this action.', + busyActionLifecycle: 'A PR lifecycle action is in progress. Finish it before using this action.', + busyActionGeneric: 'Another PR action is in progress. Finish it before using this action.', + publishedItemLocked: 'Published review items cannot be deleted from BitFun.', owner: 'Owner', repo: 'Repository', providerName: 'Display name', @@ -104,6 +120,8 @@ const I18N = { ciDetails: 'CI details', existingReview: 'Existing discussion', ciFolded: 'CI is folded by default. Open it when status needs attention.', + ciFreshnessHint: 'CI is fetched when this PR is opened, refreshed, or auto-synced.', + ciOpenDetails: 'Open details', noCi: 'No CI status returned.', noBody: 'No description.', noFiles: 'No changed files returned by the provider.', @@ -111,14 +129,20 @@ const I18N = { manualComment: 'Manual comment', manualCommentPlaceholder: 'Write a PR-level comment or paste a finding here.', addManualComment: 'Add to review', + expandComment: 'Expand', + collapseComment: 'Collapse', composer: 'Review composer', composerHint: 'Draft, edit, select, then publish. Nothing is sent without confirmation.', modeFast: 'Fast scan', modeFocused: 'Focused', modeDeep: 'Deep', + reviewLanguage: 'Review language', + reviewLanguageEn: 'English', + reviewLanguageZh: 'Chinese', + reviewLanguageHint: 'Used for AI review comments.', summaryComment: 'Summary comment', inlineComment: 'Inline comment', - reviewDecision: 'Review decision', + reviewDecision: 'Overall review', decisionComment: 'Comment', decisionApprove: 'Approve', decisionRequestChanges: 'Request changes', @@ -126,15 +150,18 @@ const I18N = { publishSelected: 'Publish selected', publishConfirmTitle: 'Publish selected review items?', publishConfirmBody: 'These comments will be posted to the provider. This action cannot be undone from BitFun.', + overwriteDraftTitle: 'Replace current review draft?', + overwriteDraftBody: '{count} unpublished review items will be deleted before a new AI review starts. Published comments are kept as context for the next review.', + confirmStartReview: 'Start new AI review', publishStaleTitle: 'PR head changed', publishStaleBody: 'The draft was created for an older head. Refresh or confirm that you want to publish against the latest head.', staleConfirm: 'I understand the PR head changed', publishNow: 'Publish now', cancel: 'Cancel', - markReviewed: 'Mark current head reviewed', - audit: 'Publish audit', + audit: 'Action audit', statusReady: 'Ready', statusRefreshing: 'Syncing queue...', + statusPartialSync: 'Refreshed what BitFun could access. {count} source could not be refreshed.', statusAssignedNeedsToken: 'Assigned-review sync needs a session token for the selected provider.', statusNoSubscriptions: 'Add a watched repository or paste a PR URL first.', statusNoActiveSubscriptions: 'All watched repositories are paused. Re-enable one or paste a PR URL.', @@ -150,22 +177,106 @@ const I18N = { reviewDetailAi: 'AI is analyzing the diff and existing discussion.', reviewDetailAiWait: 'Still analyzing; large diffs can take a little while.', reviewDetailBuild: 'Building an editable review draft.', + reviewingBannerTitle: 'AI review is running', + reviewingBannerBody: 'BitFun is reading the diff and preparing editable findings. You can keep browsing the PR or stop this run.', cancelReview: 'Cancel review', reviewCancelled: 'Review cancelled', statusPublishing: 'Publishing review...', statusSaved: 'Saved', statusPublished: 'Review published', - statusReviewed: 'Current head marked reviewed', + statusPublishFailed: 'No review item was published. Check the action audit and try again.', errorParse: 'Could not identify a PR from this URL.', errorNetwork: 'Provider request failed', newPrTitle: 'New reviewable PR', newHeadTitle: 'New commits on reviewed PR', + newPrBatchTitle: 'Review queue updated', + newPrBatchBody: '{count} PRs need attention.', + newHeadBatchBody: '{count} reviewed PRs have new commits.', publicRead: 'Public read', privateAction: 'Private and write actions', draftStatus: 'Draft', readyStatus: 'Ready', + prDraftStatus: 'Draft PR', + readyForReviewStatus: 'Ready for review', + lifecyclePanel: 'PR lifecycle', + lifecyclePanelHint: 'Status is checked before actions.', + lifecycleUnsupported: 'This provider does not support this action from BitFun.', + refreshLifecycle: 'Refresh status', + refreshingLifecycle: 'Refreshing PR lifecycle status...', + lifecycleUpdating: 'Updating PR lifecycle...', + lifecycleState: 'PR state', + lifecyclePermission: 'Permission', + lifecycleFreshness: 'Head', + lifecycleChecks: 'Checks', + lifecycleReviews: 'Reviews', + lifecycleMergeability: 'Mergeability', + lifecycleTokenReady: 'Authorized', + lifecycleTokenMissing: 'Authorization needed', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: 'Head unavailable', + lifecycleOpen: 'Open', + lifecycleClosed: 'Closed', + lifecycleMerged: 'Merged', + lifecycleMergeable: 'Mergeable', + lifecycleBlocked: 'Blocked', + lifecycleUnknown: 'Unknown', + lifecycleChecking: 'Checking', + lifecycleChecksPassing: 'Passing', + lifecycleChecksPending: 'Pending', + lifecycleChecksFailing: 'Failing', + lifecycleChecksMissing: 'No checks', + lifecycleReviewApproved: 'Approved', + lifecycleReviewChanges: 'Changes requested', + lifecycleReviewCommented: 'Comments only', + lifecycleReviewMissing: 'No review signal', + lifecycleBlockDraft: 'Draft PRs cannot be merged.', + lifecycleBlockClosed: 'Only open PRs can be updated.', + lifecycleBlockMerged: 'This PR is already merged.', + lifecycleBlockHead: 'The provider did not return a head SHA.', + lifecycleBlockToken: 'Authorize this provider before lifecycle actions.', + lifecycleBlockMergeable: 'The provider does not report this PR as mergeable.', + lifecycleWarnChecks: 'Checks are not passing.', + lifecycleWarnReviews: 'Review state needs attention.', + lifecycleGuideToken: 'Use GitHub CLI authorization or paste a token before changing PR state.', + lifecycleGuideDraft: 'Mark the PR ready before merging.', + lifecycleGuideClosed: 'Open the provider page to reopen or inspect the closed PR.', + lifecycleGuideHead: 'Refresh the PR so BitFun can verify the latest head commit.', + lifecycleGuideMergeable: 'Open the provider page to resolve conflicts or branch-protection blocks.', + lifecycleGuideChecking: 'Refresh again after the provider finishes mergeability checks.', + lifecycleGuideChecks: 'Open CI details, fix failing checks, or wait for pending checks.', + lifecycleGuideReviews: 'Resolve requested changes or collect an approval before merging.', + lifecycleAutoAuthHint: 'BitFun will try to authorize with GitHub CLI before this action.', + lifecycleAuthFailed: 'Authorization is required. Use GitHub CLI authorization or paste a token in Manage sources.', + markPrReady: 'Mark ready', + convertPrDraft: 'Convert to draft', + mergePr: 'Merge PR', + mergeMethod: 'Merge method', + mergeMethodMerge: 'Merge commit', + mergeMethodSquash: 'Squash merge', + mergeMethodRebase: 'Rebase merge', + lifecycleConfirmReadyTitle: 'Mark this PR ready for review?', + lifecycleConfirmDraftTitle: 'Convert this PR to a draft?', + lifecycleConfirmMergeTitle: 'Merge this PR?', + lifecycleConfirmReadyBody: 'The provider-side PR state will become ready for review.', + lifecycleConfirmDraftBody: 'The provider-side PR state will become draft. Reviewers may stop seeing it as ready.', + lifecycleConfirmMergeBody: 'This will merge the selected PR on the provider. BitFun will use the expected head SHA shown below.', + lifecycleConfirmWarning: 'Warnings', + expectedHeadSha: 'Expected head', + confirmLifecycle: 'Confirm action', + lifecycleActionReady: 'Ready state', + lifecycleActionDraft: 'Draft state', + lifecycleActionMerge: 'Merge', + lifecycleReadyDone: 'PR marked ready for review', + lifecycleDraftDone: 'PR converted to draft', + lifecycleMergeDone: 'PR merged', + lifecycleHeadChanged: 'The PR head changed. Refresh the PR before taking this action.', + lifecycleMergeBlocked: 'Merge is blocked: {reason}', + lifecycleActionUnsupported: 'This lifecycle action is not supported for the selected provider.', overviewHint: 'Expand for full description.', noActionableFindings: 'No actionable findings were generated. Add a manual comment or edit this review decision before publishing.', + publishNoDraftItems: 'Generate or add a review item before publishing.', + publishSelectItemFirst: 'Select at least one draft review item before publishing.', + suggestedFix: 'Suggested fix', binary: 'binary', large: 'large', stale: 'stale', @@ -177,6 +288,11 @@ const I18N = { 'zh-CN': { title: 'PR 审核台', subtitle: '监听仓库、打开 PR、查看变更、组织意见,并在二次确认后发布 Review。', + manageSources: '管理来源', + sourceSettingsTitle: '来源与队列设置', + sourceSettingsSubtitle: '调整监听仓库、队列范围、私有授权,或单独打开某个 PR。', + queueSettingsTitle: '队列设置', + queueSettingsHint: '选择哪些 PR 进入审核队列。主界面只保留审核工作流。', queueModeAll: '仓库全部 PR', queueModeMine: '待我审核', queueModeAllHint: '从已监听仓库同步打开状态的 PR。', @@ -226,7 +342,7 @@ const I18N = { manualToken: '手动 Token', repoAddedSyncing: '已添加监听仓库,正在同步队列...', advancedProviders: '高级平台设置', - errorRepoNotFound: '仓库不存在或当前无权访问,请检查仓库路径或授权 GitHub CLI。', + errorSourceUnavailable: '无法刷新 {repo}。仓库或 PR 可能不可用、接口可能不兼容,或当前账号无权访问。请在“管理来源”中检查仓库地址;如为私有仓库,请先完成授权。', workspaceDiscovering: '正在从当前工作区识别仓库...', workspaceDiscovered: '已从当前工作区添加 {count} 个仓库。', workspaceDiscoveredMany: '已从当前工作区添加 {count} 个仓库。', @@ -237,6 +353,17 @@ const I18N = { repositoryFirstHint: '先添加要关注的仓库,再同步它的审核队列。', singlePrFallback: '单独检视一个 PR', singlePrFallbackHint: '当这个仓库暂时不需要监听时使用。', + directOpenBusySync: '正在同步队列,请稍后再单独打开 PR。', + directOpenBusyReview: 'AI 审核正在进行,请先完成或中止后再打开其他 PR。', + directOpenBusyPublish: 'Review 正在发布,请完成后再打开其他 PR。', + directOpenBusyLifecycle: 'PR 状态操作正在执行,请完成后再打开其他 PR。', + directOpenBusyGeneric: '当前有 PR 操作正在进行,请完成后再单独打开 PR。', + busyActionSync: '队列正在同步,请稍后再执行其他操作。', + busyActionReview: 'AI 审核正在进行,请先完成或中止后再执行此操作。', + busyActionPublish: 'Review 正在发布,请完成后再执行此操作。', + busyActionLifecycle: 'PR 状态操作正在执行,请完成后再执行此操作。', + busyActionGeneric: '当前有 PR 操作正在进行,请完成后再执行此操作。', + publishedItemLocked: '已发布的 Review 项不能在 BitFun 中删除。', owner: 'Owner', repo: '仓库', providerName: '显示名称', @@ -260,6 +387,8 @@ const I18N = { ciDetails: 'CI 详情', existingReview: '已有讨论', ciFolded: 'CI 默认折叠,只有需要定位状态时再展开。', + ciFreshnessHint: 'CI 会在打开 PR、刷新或自动同步时重新获取。', + ciOpenDetails: '打开详情', noCi: '代码平台没有返回 CI 状态。', noBody: '没有描述。', noFiles: '代码平台没有返回变更文件。', @@ -267,14 +396,20 @@ const I18N = { manualComment: '手写评论', manualCommentPlaceholder: '在这里写 PR 级评论,或粘贴你已经发现的问题。', addManualComment: '加入 Review', + expandComment: '展开', + collapseComment: '收起', composer: 'Review 编辑器', composerHint: '生成、编辑、选择,再发布。未经确认不会提交到代码平台。', modeFast: '快速扫读', modeFocused: '重点审核', modeDeep: '深度审核', + reviewLanguage: '审核语言', + reviewLanguageEn: '英文', + reviewLanguageZh: '中文', + reviewLanguageHint: '用于 AI 生成的审核意见。', summaryComment: '总结评论', inlineComment: '行内评论', - reviewDecision: 'Review 结论', + reviewDecision: '整体 Review', decisionComment: '评论', decisionApprove: '通过', decisionRequestChanges: '要求修改', @@ -282,15 +417,18 @@ const I18N = { publishSelected: '发布选中项', publishConfirmTitle: '确认发布选中的 Review 内容?', publishConfirmBody: '这些评论会提交到代码平台。BitFun 无法替你撤回这个操作。', + overwriteDraftTitle: '替换当前审核草稿?', + overwriteDraftBody: '新的 AI 审核开始前,会删除 {count} 条未发布审核意见。已发布的意见会作为下一轮审核上下文保留。', + confirmStartReview: '开始新的 AI 审核', publishStaleTitle: 'PR head 已变化', publishStaleBody: '草稿基于旧 head 生成。请刷新,或明确确认要基于最新 head 继续发布。', staleConfirm: '我确认 PR head 已变化', publishNow: '立即发布', cancel: '取消', - markReviewed: '标记当前 head 已审', - audit: '发布审计', + audit: '操作记录', statusReady: '就绪', statusRefreshing: '正在同步队列...', + statusPartialSync: '已刷新 BitFun 可访问的内容。{count} 个来源暂时无法刷新。', statusAssignedNeedsToken: '同步待我审核需要当前代码平台的会话 Token。', statusNoSubscriptions: '请先添加监听仓库,或粘贴一个 PR 链接。', statusNoActiveSubscriptions: '已添加的监听仓库都处于暂停状态,请重新开启一个仓库或粘贴 PR 链接。', @@ -306,22 +444,106 @@ const I18N = { reviewDetailAi: 'AI 正在分析 diff 和已有讨论。', reviewDetailAiWait: '仍在分析中,大型 diff 可能需要稍等。', reviewDetailBuild: '正在整理可编辑的审核草稿。', + reviewingBannerTitle: 'AI 正在审核', + reviewingBannerBody: 'BitFun 正在读取 diff 并整理可编辑问题。你可以继续浏览 PR,也可以中止本次审核。', cancelReview: '中止审核', reviewCancelled: '审核已中止', statusPublishing: '正在发布 Review...', statusSaved: '已保存', statusPublished: 'Review 已发布', - statusReviewed: '已标记当前 head 已审', + statusPublishFailed: '没有 Review 内容发布成功。请查看操作记录后重试。', errorParse: '无法从这个链接识别 PR。', errorNetwork: '代码平台请求失败', newPrTitle: '新的可审核 PR', newHeadTitle: '已审 PR 有新提交', + newPrBatchTitle: '审核队列已更新', + newPrBatchBody: '{count} 个 PR 需要关注。', + newHeadBatchBody: '{count} 个已审核 PR 有新提交。', publicRead: '公开读取', privateAction: '私有与写入操作', draftStatus: '草稿', readyStatus: '可审', + prDraftStatus: '草稿 PR', + readyForReviewStatus: '可审 PR', + lifecyclePanel: 'PR 生命周期', + lifecyclePanelHint: '操作前会先校验状态。', + lifecycleUnsupported: '当前代码平台不支持在 BitFun 中执行这个操作。', + refreshLifecycle: '刷新状态', + refreshingLifecycle: '正在刷新 PR 生命周期状态...', + lifecycleUpdating: '正在更新 PR 生命周期...', + lifecycleState: 'PR 状态', + lifecyclePermission: '权限', + lifecycleFreshness: 'Head', + lifecycleChecks: '检查', + lifecycleReviews: 'Review', + lifecycleMergeability: '可合并性', + lifecycleTokenReady: '已授权', + lifecycleTokenMissing: '需要授权', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: '未返回 Head', + lifecycleOpen: '打开', + lifecycleClosed: '已关闭', + lifecycleMerged: '已合并', + lifecycleMergeable: '可合并', + lifecycleBlocked: '受阻', + lifecycleUnknown: '未知', + lifecycleChecking: '检查中', + lifecycleChecksPassing: '通过', + lifecycleChecksPending: '等待中', + lifecycleChecksFailing: '失败', + lifecycleChecksMissing: '无检查', + lifecycleReviewApproved: '已批准', + lifecycleReviewChanges: '要求修改', + lifecycleReviewCommented: '仅评论', + lifecycleReviewMissing: '无 Review 信号', + lifecycleBlockDraft: '草稿 PR 不能合并。', + lifecycleBlockClosed: '只有打开状态的 PR 可以更新。', + lifecycleBlockMerged: '这个 PR 已经合并。', + lifecycleBlockHead: '代码平台未返回 head SHA。', + lifecycleBlockToken: '执行生命周期操作前需要先授权。', + lifecycleBlockMergeable: '代码平台未确认这个 PR 可合并。', + lifecycleWarnChecks: '检查尚未全部通过。', + lifecycleWarnReviews: 'Review 状态需要关注。', + lifecycleGuideToken: '先使用 GitHub CLI 授权,或粘贴 Token,再变更 PR 状态。', + lifecycleGuideDraft: '先将 PR 标记为可审,再执行合并。', + lifecycleGuideClosed: '打开代码平台页面,重新打开或检查这个已关闭 PR。', + lifecycleGuideHead: '刷新 PR,让 BitFun 确认最新 head 提交。', + lifecycleGuideMergeable: '打开代码平台页面处理冲突或分支保护阻塞。', + lifecycleGuideChecking: '等待代码平台完成可合并性检查后再刷新。', + lifecycleGuideChecks: '展开 CI 详情,修复失败检查或等待检查完成。', + lifecycleGuideReviews: '处理要求修改的意见,或先获得批准后再合并。', + lifecycleAutoAuthHint: 'BitFun 会先尝试通过 GitHub CLI 自动授权。', + lifecycleAuthFailed: '需要授权。请在管理来源中使用 GitHub CLI 授权,或粘贴 Token。', + markPrReady: '标记可审', + convertPrDraft: '转为草稿', + mergePr: '合并 PR', + mergeMethod: '合并方式', + mergeMethodMerge: '创建合并提交', + mergeMethodSquash: 'Squash 合并', + mergeMethodRebase: 'Rebase 合并', + lifecycleConfirmReadyTitle: '将这个 PR 标记为可审?', + lifecycleConfirmDraftTitle: '将这个 PR 转为草稿?', + lifecycleConfirmMergeTitle: '合并这个 PR?', + lifecycleConfirmReadyBody: '代码平台上的 PR 状态会变为可审。', + lifecycleConfirmDraftBody: '代码平台上的 PR 状态会变为草稿,审核者可能不再把它视为待审。', + lifecycleConfirmMergeBody: '这会在代码平台合并当前选中的 PR。BitFun 会使用下方显示的预期 head SHA。', + lifecycleConfirmWarning: '风险提示', + expectedHeadSha: '预期 Head', + confirmLifecycle: '确认执行', + lifecycleActionReady: '可审状态', + lifecycleActionDraft: '草稿状态', + lifecycleActionMerge: '合并', + lifecycleReadyDone: '已标记为可审 PR', + lifecycleDraftDone: '已转为草稿 PR', + lifecycleMergeDone: 'PR 已合并', + lifecycleHeadChanged: 'PR head 已变化,请刷新后再执行该操作。', + lifecycleMergeBlocked: '合并被阻止:{reason}', + lifecycleActionUnsupported: '当前代码平台不支持这个生命周期操作。', overviewHint: '展开查看完整描述。', noActionableFindings: '没有生成可操作问题。发布前可以添加手写评论,或编辑这条 Review 结论。', + publishNoDraftItems: '请先生成或添加一条 Review 内容,再发布。', + publishSelectItemFirst: '请至少选中一条 Review 草稿后再发布。', + suggestedFix: '建议处理', binary: '二进制', large: '过大', stale: '已过期', @@ -333,6 +555,11 @@ const I18N = { 'zh-TW': { title: 'PR 審核台', subtitle: '監聽倉庫、開啟 PR、檢視變更、組織意見,並在二次確認後發布 Review。', + manageSources: '管理來源', + sourceSettingsTitle: '來源與佇列設定', + sourceSettingsSubtitle: '調整監聽倉庫、佇列範圍、私有授權,或單獨開啟某個 PR。', + queueSettingsTitle: '佇列設定', + queueSettingsHint: '選擇哪些 PR 進入審核佇列。主介面只保留審核工作流。', queueModeAll: '倉庫全部 PR', queueModeMine: '待我審核', queueModeAllHint: '從已監聽倉庫同步開啟狀態的 PR。', @@ -382,7 +609,7 @@ const I18N = { manualToken: '手動 Token', repoAddedSyncing: '已新增監聽倉庫,正在同步佇列...', advancedProviders: '進階平台設定', - errorRepoNotFound: '倉庫不存在或目前無權存取,請檢查倉庫路徑或授權 GitHub CLI。', + errorSourceUnavailable: '無法重新整理 {repo}。倉庫或 PR 可能無法使用、介面可能不相容,或目前帳號無權存取。請在「管理來源」中檢查倉庫地址;如為私有倉庫,請先完成授權。', workspaceDiscovering: '正在從目前工作區識別倉庫...', workspaceDiscovered: '已從目前工作區新增 {count} 個倉庫。', workspaceDiscoveredMany: '已從目前工作區新增 {count} 個倉庫。', @@ -393,6 +620,17 @@ const I18N = { repositoryFirstHint: '先新增要關注的倉庫,再同步它的審核佇列。', singlePrFallback: '單獨檢視一個 PR', singlePrFallbackHint: '當這個倉庫暫時不需要監聽時使用。', + directOpenBusySync: '正在同步佇列,請稍後再單獨開啟 PR。', + directOpenBusyReview: 'AI 審核正在進行,請先完成或中止後再開啟其他 PR。', + directOpenBusyPublish: 'Review 正在發布,請完成後再開啟其他 PR。', + directOpenBusyLifecycle: 'PR 狀態操作正在執行,請完成後再開啟其他 PR。', + directOpenBusyGeneric: '目前有 PR 操作正在進行,請完成後再單獨開啟 PR。', + busyActionSync: '佇列正在同步,請稍後再執行其他操作。', + busyActionReview: 'AI 審核正在進行,請先完成或中止後再執行此操作。', + busyActionPublish: 'Review 正在發布,請完成後再執行此操作。', + busyActionLifecycle: 'PR 狀態操作正在執行,請完成後再執行此操作。', + busyActionGeneric: '目前有 PR 操作正在進行,請完成後再執行此操作。', + publishedItemLocked: '已發布的 Review 項不能在 BitFun 中刪除。', owner: 'Owner', repo: '倉庫', providerName: '顯示名稱', @@ -416,6 +654,8 @@ const I18N = { ciDetails: 'CI 詳情', existingReview: '既有討論', ciFolded: 'CI 預設摺疊,只有需要定位狀態時再展開。', + ciFreshnessHint: 'CI 會在開啟 PR、重新整理或自動同步時重新取得。', + ciOpenDetails: '開啟詳情', noCi: '程式碼平台沒有回傳 CI 狀態。', noBody: '沒有描述。', noFiles: '程式碼平台沒有回傳變更檔案。', @@ -423,14 +663,20 @@ const I18N = { manualComment: '手寫評論', manualCommentPlaceholder: '在這裡寫 PR 級評論,或貼上你已經發現的問題。', addManualComment: '加入 Review', + expandComment: '展開', + collapseComment: '收起', composer: 'Review 編輯器', composerHint: '產生、編輯、選擇,再發布。未經確認不會提交到程式碼平台。', modeFast: '快速掃讀', modeFocused: '重點審核', modeDeep: '深度審核', + reviewLanguage: '審核語言', + reviewLanguageEn: '英文', + reviewLanguageZh: '中文', + reviewLanguageHint: '用於 AI 產生的審核意見。', summaryComment: '總結評論', inlineComment: '行內評論', - reviewDecision: 'Review 結論', + reviewDecision: '整體 Review', decisionComment: '評論', decisionApprove: '通過', decisionRequestChanges: '要求修改', @@ -438,15 +684,18 @@ const I18N = { publishSelected: '發布選取項', publishConfirmTitle: '確認發布選取的 Review 內容?', publishConfirmBody: '這些評論會提交到程式碼平台。BitFun 無法替你撤回這個操作。', + overwriteDraftTitle: '取代目前審核草稿?', + overwriteDraftBody: '新的 AI 審核開始前,會刪除 {count} 條未發布審核意見。已發布的意見會作為下一輪審核上下文保留。', + confirmStartReview: '開始新的 AI 審核', publishStaleTitle: 'PR head 已變更', publishStaleBody: '草稿基於舊 head 產生。請重新整理,或明確確認要基於最新 head 繼續發布。', staleConfirm: '我確認 PR head 已變更', publishNow: '立即發布', cancel: '取消', - markReviewed: '標記目前 head 已審', - audit: '發布審計', + audit: '操作記錄', statusReady: '就緒', statusRefreshing: '正在同步佇列...', + statusPartialSync: '已重新整理 BitFun 可存取的內容。{count} 個來源暫時無法重新整理。', statusAssignedNeedsToken: '同步待我審核需要目前程式碼平台的工作階段 Token。', statusNoSubscriptions: '請先新增監聽倉庫,或貼上一個 PR 連結。', statusNoActiveSubscriptions: '已新增的監聽倉庫都處於暫停狀態,請重新開啟一個倉庫或貼上 PR 連結。', @@ -462,22 +711,106 @@ const I18N = { reviewDetailAi: 'AI 正在分析 diff 和既有討論。', reviewDetailAiWait: '仍在分析中,大型 diff 可能需要稍等。', reviewDetailBuild: '正在整理可編輯的審核草稿。', + reviewingBannerTitle: 'AI 正在審核', + reviewingBannerBody: 'BitFun 正在讀取 diff 並整理可編輯問題。你可以繼續瀏覽 PR,也可以中止本次審核。', cancelReview: '中止審核', reviewCancelled: '審核已中止', statusPublishing: '正在發布 Review...', statusSaved: '已儲存', statusPublished: 'Review 已發布', - statusReviewed: '已標記目前 head 已審', + statusPublishFailed: '沒有 Review 內容發布成功。請查看操作記錄後重試。', errorParse: '無法從這個連結識別 PR。', errorNetwork: '程式碼平台請求失敗', newPrTitle: '新的可審核 PR', newHeadTitle: '已審 PR 有新提交', + newPrBatchTitle: '審核佇列已更新', + newPrBatchBody: '{count} 個 PR 需要關注。', + newHeadBatchBody: '{count} 個已審核 PR 有新提交。', publicRead: '公開讀取', privateAction: '私有與寫入操作', draftStatus: '草稿', readyStatus: '可審', + prDraftStatus: '草稿 PR', + readyForReviewStatus: '可審 PR', + lifecyclePanel: 'PR 生命週期', + lifecyclePanelHint: '操作前會先校驗狀態。', + lifecycleUnsupported: '目前程式碼平台不支援在 BitFun 中執行此操作。', + refreshLifecycle: '重新整理狀態', + refreshingLifecycle: '正在重新整理 PR 生命週期狀態...', + lifecycleUpdating: '正在更新 PR 生命週期...', + lifecycleState: 'PR 狀態', + lifecyclePermission: '權限', + lifecycleFreshness: 'Head', + lifecycleChecks: '檢查', + lifecycleReviews: 'Review', + lifecycleMergeability: '可合併性', + lifecycleTokenReady: '已授權', + lifecycleTokenMissing: '需要授權', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: '未返回 Head', + lifecycleOpen: '開啟', + lifecycleClosed: '已關閉', + lifecycleMerged: '已合併', + lifecycleMergeable: '可合併', + lifecycleBlocked: '受阻', + lifecycleUnknown: '未知', + lifecycleChecking: '檢查中', + lifecycleChecksPassing: '通過', + lifecycleChecksPending: '等待中', + lifecycleChecksFailing: '失敗', + lifecycleChecksMissing: '無檢查', + lifecycleReviewApproved: '已核准', + lifecycleReviewChanges: '要求修改', + lifecycleReviewCommented: '僅評論', + lifecycleReviewMissing: '無 Review 訊號', + lifecycleBlockDraft: '草稿 PR 不能合併。', + lifecycleBlockClosed: '只有開啟狀態的 PR 可以更新。', + lifecycleBlockMerged: '這個 PR 已經合併。', + lifecycleBlockHead: '程式碼平台未返回 head SHA。', + lifecycleBlockToken: '執行生命週期操作前需要先授權。', + lifecycleBlockMergeable: '程式碼平台未確認這個 PR 可合併。', + lifecycleWarnChecks: '檢查尚未全部通過。', + lifecycleWarnReviews: 'Review 狀態需要關注。', + lifecycleGuideToken: '先使用 GitHub CLI 授權,或貼上 Token,再變更 PR 狀態。', + lifecycleGuideDraft: '先將 PR 標記為可審,再執行合併。', + lifecycleGuideClosed: '開啟程式碼平台頁面,重新開啟或檢查這個已關閉 PR。', + lifecycleGuideHead: '重新整理 PR,讓 BitFun 確認最新 head 提交。', + lifecycleGuideMergeable: '開啟程式碼平台頁面處理衝突或分支保護阻塞。', + lifecycleGuideChecking: '等待程式碼平台完成可合併性檢查後再重新整理。', + lifecycleGuideChecks: '展開 CI 詳情,修復失敗檢查或等待檢查完成。', + lifecycleGuideReviews: '處理要求修改的意見,或先取得核准後再合併。', + lifecycleAutoAuthHint: 'BitFun 會先嘗試透過 GitHub CLI 自動授權。', + lifecycleAuthFailed: '需要授權。請在管理來源中使用 GitHub CLI 授權,或貼上 Token。', + markPrReady: '標記可審', + convertPrDraft: '轉為草稿', + mergePr: '合併 PR', + mergeMethod: '合併方式', + mergeMethodMerge: '建立合併提交', + mergeMethodSquash: 'Squash 合併', + mergeMethodRebase: 'Rebase 合併', + lifecycleConfirmReadyTitle: '將這個 PR 標記為可審?', + lifecycleConfirmDraftTitle: '將這個 PR 轉為草稿?', + lifecycleConfirmMergeTitle: '合併這個 PR?', + lifecycleConfirmReadyBody: '程式碼平台上的 PR 狀態會變為可審。', + lifecycleConfirmDraftBody: '程式碼平台上的 PR 狀態會變為草稿,審核者可能不再把它視為待審。', + lifecycleConfirmMergeBody: '這會在程式碼平台合併目前選中的 PR。BitFun 會使用下方顯示的預期 head SHA。', + lifecycleConfirmWarning: '風險提示', + expectedHeadSha: '預期 Head', + confirmLifecycle: '確認執行', + lifecycleActionReady: '可審狀態', + lifecycleActionDraft: '草稿狀態', + lifecycleActionMerge: '合併', + lifecycleReadyDone: '已標記為可審 PR', + lifecycleDraftDone: '已轉為草稿 PR', + lifecycleMergeDone: 'PR 已合併', + lifecycleHeadChanged: 'PR head 已變化,請重新整理後再執行該操作。', + lifecycleMergeBlocked: '合併被阻止:{reason}', + lifecycleActionUnsupported: '目前程式碼平台不支援這個生命週期操作。', overviewHint: '展開查看完整描述。', noActionableFindings: '沒有生成可操作問題。發布前可以新增手寫評論,或編輯這條 Review 結論。', + publishNoDraftItems: '請先產生或新增一條 Review 內容,再發布。', + publishSelectItemFirst: '請至少選取一條 Review 草稿後再發布。', + suggestedFix: '建議處理', binary: '二進位', large: '過大', stale: '已過期', @@ -519,8 +852,10 @@ const state = { selectedFilePath: null, directUrl: '', mode: 'focused_review', + reviewLanguage: 'en', queueMode: 'all', drafts: {}, + publishedReviewContext: {}, audit: [], lastReviewedHeads: {}, notifiedKeys: [], @@ -533,10 +868,17 @@ const state = { busy: null, status: null, error: null, + startupSyncing: false, reviewProgress: null, cancelReviewRequested: false, + filesExpanded: false, + manualCommentExpanded: false, + manualCommentDraft: '', activeProviderId: 'github', confirm: null, + draftOverwriteConfirm: null, + lifecycleConfirm: null, + settingsOpen: false, }, volatile: { sessionTokens: {}, @@ -565,6 +907,30 @@ function restoreReviewWorkspaceScroll(position) { }); } +function readPaneScrolls() { + return ['.pr-sidebar', '.pr-review-workspace', '.pr-composer'].map((selector) => { + const node = document.querySelector(selector); + if (!(node instanceof HTMLElement)) return null; + return { + selector, + top: node.scrollTop, + left: node.scrollLeft, + }; + }).filter(Boolean); +} + +function restorePaneScrolls(positions) { + if (!positions?.length) return; + window.requestAnimationFrame(() => { + for (const position of positions) { + const node = document.querySelector(position.selector); + if (!(node instanceof HTMLElement)) continue; + node.scrollTop = position.top; + node.scrollLeft = position.left; + } + }); +} + function t(key, params = {}) { const table = I18N[state.locale] || I18N['en-US']; const fallback = I18N['en-US'][key] || key; @@ -607,6 +973,28 @@ function profileById(id) { return state.data.profiles.find((profile) => profile.id === id) || state.data.profiles[0]; } +function sourceLabel(source) { + const profileId = source?.providerId || source?.id || ''; + const profile = profileId ? profileById(profileId) : null; + const providerName = source?.displayName || profile?.displayName || source?.id || profile?.id || t('provider'); + if (!(source?.owner && source?.repo)) return providerName; + const repoName = `${source.owner}/${source.repo}`; + const number = Number.isFinite(Number(source?.number)) ? `#${source.number}` : ''; + return `${providerName} ${repoName}${number}`; +} + +function sourceAccessError(source, error) { + const friendly = new Error(t('errorSourceUnavailable', { repo: sourceLabel(source) })); + friendly.status = Number(error?.status || 0); + return friendly; +} + +function preferAccessError(firstError, secondError) { + return [401, 403, 404].includes(Number(secondError?.status || 0)) + ? secondError + : firstError; +} + function activeProfile() { return profileById(state.ui.activeProviderId); } @@ -622,7 +1010,7 @@ function setReviewProgress(stageKey, detail = '', progressPct = 8) { progressPct: Math.max(0, Math.min(100, Number(progressPct) || 0)), cancelled: state.ui.cancelReviewRequested, }; - render(); + render({ preservePaneScroll: true }); } function modeLabel(mode) { @@ -732,7 +1120,6 @@ async function netJson(url, options = {}) { : String(body || `${status}`); const error = new Error(`${t('errorNetwork')}: ${message}`); error.status = status; - error.body = body; throw error; } return body; @@ -773,12 +1160,14 @@ async function loadStorage() { subscriptions, items: Array.isArray(parsed.items) ? parsed.items : [], drafts: parsed.drafts && typeof parsed.drafts === 'object' ? parsed.drafts : {}, + publishedReviewContext: parsed.publishedReviewContext && typeof parsed.publishedReviewContext === 'object' ? parsed.publishedReviewContext : {}, audit: Array.isArray(parsed.audit) ? parsed.audit : [], lastReviewedHeads: parsed.lastReviewedHeads || {}, notifiedKeys: Array.isArray(parsed.notifiedKeys) ? parsed.notifiedKeys : [], dismissedWorkspaceRepos: Array.isArray(parsed.dismissedWorkspaceRepos) ? parsed.dismissedWorkspaceRepos : [], workspaceAutoListenDoneFor: parsed.workspaceAutoListenDoneFor || '', queueMode: parsed.queueMode || 'all', + reviewLanguage: ['en', 'zh'].includes(parsed.reviewLanguage) ? parsed.reviewLanguage : 'en', }; state.ui.activeProviderId = state.data.profiles[0]?.id || 'github'; } catch (error) { @@ -814,13 +1203,13 @@ function setBusy(key, statusKey) { render(); } -async function finish(statusKey) { +async function finish(statusKey, renderOptions = {}) { state.ui.busy = null; state.ui.status = statusKey ? t(statusKey) : null; state.ui.reviewProgress = null; state.ui.cancelReviewRequested = false; await saveStorage(); - render(); + render(renderOptions); } function setError(error) { @@ -1112,9 +1501,19 @@ async function applyWorkspaceDiscoveredRepositories({ force = false, sync = true } async function refreshQueueOnOpen() { - await applyWorkspaceDiscoveredRepositories({ sync: false }); - if (activeSubscriptions().length) { - void syncQueue('all'); + state.ui.startupSyncing = true; + state.ui.status = t('statusRefreshing'); + state.ui.error = null; + render({ preservePaneScroll: true }); + try { + await applyWorkspaceDiscoveredRepositories({ sync: false }); + if (activeSubscriptions().length) { + await syncQueue('all'); + } + } finally { + state.ui.startupSyncing = false; + if (state.ui.busy === 'refresh') state.ui.busy = null; + render({ preservePaneScroll: true }); } } @@ -1172,6 +1571,86 @@ function normalizeChecks(statusBody, checksBody) { return [...statusChecks, ...checkRuns]; } +function summarizeCheckState(checks = []) { + if (!checks.length) return 'missing'; + const states = checks.map((check) => String(check.conclusion || check.status || '').toLowerCase()); + if (states.some((stateValue) => ['failure', 'failed', 'error', 'timed_out', 'cancelled', 'action_required'].includes(stateValue))) { + return 'failing'; + } + if (states.some((stateValue) => ['pending', 'queued', 'in_progress', 'requested', 'waiting', 'expected'].includes(stateValue))) { + return 'pending'; + } + if (states.every((stateValue) => ['success', 'neutral', 'skipped', 'completed'].includes(stateValue))) { + return 'passing'; + } + return 'unknown'; +} + +function summarizeReviewState(reviewSummary = {}) { + if (reviewSummary.changesRequested > 0) return 'changes_requested'; + if (reviewSummary.approvals > 0) return 'approved'; + if (reviewSummary.comments > 0) return 'commented'; + return 'missing'; +} + +function shortSha(value) { + const sha = String(value || '').trim(); + return sha ? sha.slice(0, 12) : ''; +} + +function buildMergeReadiness({ pr, checks, reviewSummary, profile }) { + const stateName = String(pr.state || pr.status || '').toLowerCase(); + const merged = Boolean(pr.merged || pr.merged_at); + const isOpen = stateName === 'open' || stateName === 'opened'; + const isDraft = Boolean(pr.draft || pr.work_in_progress); + const headSha = pr.head?.sha || pr.head_sha || pr.sha || pr.diff_refs?.head_sha || ''; + const mergeableRaw = pr.mergeable; + const mergeableStateRaw = String(pr.mergeable_state || pr.detailed_merge_status || pr.merge_status || '').toLowerCase(); + const checkState = summarizeCheckState(checks); + const reviewState = summarizeReviewState(reviewSummary); + const blockers = []; + const warnings = []; + + if (merged) blockers.push('lifecycleBlockMerged'); + if (!merged && !isOpen) blockers.push('lifecycleBlockClosed'); + if (isDraft) blockers.push('lifecycleBlockDraft'); + if (!headSha) blockers.push('lifecycleBlockHead'); + if (!hasToken(profile)) blockers.push('lifecycleBlockToken'); + if (mergeableRaw === false || ['dirty', 'blocked', 'cannot_be_merged', 'conflicts'].includes(mergeableStateRaw)) { + blockers.push('lifecycleBlockMergeable'); + } + if ((mergeableRaw == null && !['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) || mergeableStateRaw === 'checking' || mergeableStateRaw === 'unknown') { + blockers.push('lifecycleChecking'); + } + if (['failing', 'pending', 'unknown', 'missing'].includes(checkState)) warnings.push('lifecycleWarnChecks'); + if (['changes_requested', 'missing'].includes(reviewState)) warnings.push('lifecycleWarnReviews'); + + let mergeability = 'unknown'; + if (mergeableRaw === true || ['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) { + mergeability = 'mergeable'; + } else if (mergeableRaw === false || ['dirty', 'blocked', 'cannot_be_merged', 'conflicts'].includes(mergeableStateRaw)) { + mergeability = 'blocked'; + } else if ((mergeableRaw == null && !['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) || mergeableStateRaw === 'checking') { + mergeability = 'checking'; + } + + return { + state: merged ? 'merged' : isOpen ? 'open' : 'closed', + isDraft, + headSha, + expectedHeadSha: headSha, + mergeability, + mergeableState: mergeableStateRaw || String(mergeableRaw ?? 'unknown'), + checkState, + reviewState, + permissionState: hasToken(profile) ? 'ready' : 'missing', + blockers, + warnings, + canMerge: blockers.length === 0, + refreshedAt: new Date().toISOString(), + }; +} + async function fetchGithubSnapshot(identity) { const profile = profileById(identity.providerId); const base = normalizeBaseUrl(profile.apiBaseUrl); @@ -1198,6 +1677,8 @@ async function fetchGithubSnapshot(identity) { statusResult.status === 'fulfilled' ? statusResult.value : null, checksResult.status === 'fulfilled' ? checksResult.value : null, ); + const reviewSummary = summarizeReviews(reviews); + const mergeReadiness = buildMergeReadiness({ pr, checks, reviewSummary, profile }); return { identity: { providerId: profile.id, @@ -1206,11 +1687,16 @@ async function fetchGithubSnapshot(identity) { repo: identity.repo, number: identity.number, }, + queueOrigin: identity.queueOrigin || 'direct', + nodeId: pr.node_id || '', url: pr.html_url || identity.url || `${normalizeBaseUrl(profile.webBaseUrl)}/${identity.owner}/${identity.repo}/pull/${identity.number}`, title: pr.title || `#${identity.number}`, body: pr.body || '', author: pr.user?.login || '', state: pr.state || '', + merged: Boolean(pr.merged || pr.merged_at), + closedAt: pr.closed_at || '', + mergedAt: pr.merged_at || '', isDraft: Boolean(pr.draft), baseBranch: pr.base?.ref || '', headBranch: pr.head?.ref || '', @@ -1220,11 +1706,17 @@ async function fetchGithubSnapshot(identity) { files, checks, reviews, - reviewSummary: summarizeReviews(reviews), + reviewSummary, + mergeReadiness, providerCapabilities: { publishSummaryComment: true, publishInlineComment: true, publishReviewDecision: true, + refreshMergeReadiness: true, + transitionDraftState: true, + mergePullRequest: true, + closePullRequest: false, + reopenPullRequest: false, }, }; } @@ -1238,10 +1730,14 @@ async function fetchCompatibleSnapshot(identity) { pr = await requestWithAuthRetry(profile, () => netJson(`${base}/repos/${ownerRepo}/pulls/${identity.number}`, { headers: providerHeaders(profile), })); - } catch { - pr = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${identity.owner}/${identity.repo}`)}/merge_requests/${identity.number}`, { - headers: providerHeaders(profile), - })); + } catch (firstError) { + try { + pr = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${identity.owner}/${identity.repo}`)}/merge_requests/${identity.number}`, { + headers: providerHeaders(profile), + })); + } catch (secondError) { + throw preferAccessError(firstError, secondError); + } } const headers = providerHeaders(profile); @@ -1264,6 +1760,7 @@ async function fetchCompatibleSnapshot(identity) { const files = rawFiles.map(normalizeFile); const reviews = rawReviews.map((item) => normalizeReview(item)); const headSha = pr.head?.sha || pr.head_sha || pr.sha || pr.diff_refs?.head_sha || ''; + const reviewSummary = summarizeReviews(reviews); return { identity: { providerId: profile.id, @@ -1272,6 +1769,7 @@ async function fetchCompatibleSnapshot(identity) { repo: identity.repo, number: identity.number, }, + queueOrigin: identity.queueOrigin || 'direct', url: pr.html_url || pr.web_url || identity.url || `${normalizeBaseUrl(profile.webBaseUrl)}/${identity.owner}/${identity.repo}/pull/${identity.number}`, title: pr.title || `#${identity.number}`, body: pr.body || pr.description || '', @@ -1286,20 +1784,46 @@ async function fetchCompatibleSnapshot(identity) { files, checks: [], reviews, - reviewSummary: summarizeReviews(reviews), + reviewSummary, + mergeReadiness: { + state: pr.merged || pr.merged_at ? 'merged' : (String(pr.state || pr.status || '').toLowerCase() === 'closed' ? 'closed' : 'open'), + isDraft: Boolean(pr.draft || pr.work_in_progress), + headSha, + expectedHeadSha: headSha, + mergeability: 'unknown', + checkState: 'missing', + reviewState: summarizeReviewState(reviewSummary), + permissionState: hasToken(profile) ? 'ready' : 'missing', + blockers: ['lifecycleActionUnsupported'], + warnings: [], + canMerge: false, + refreshedAt: new Date().toISOString(), + }, providerCapabilities: { publishSummaryComment: true, publishInlineComment: true, publishReviewDecision: false, + refreshMergeReadiness: false, + transitionDraftState: false, + mergePullRequest: false, + closePullRequest: false, + reopenPullRequest: false, }, }; } async function fetchSnapshot(identity) { const profile = profileById(identity.providerId); - return profile.kind === 'github' - ? fetchGithubSnapshot(identity) - : fetchCompatibleSnapshot(identity); + try { + return profile.kind === 'github' + ? fetchGithubSnapshot(identity) + : fetchCompatibleSnapshot(identity); + } catch (error) { + if ([401, 403, 404].includes(Number(error?.status || 0))) { + throw sourceAccessError(identity, error); + } + throw error; + } } async function listRepositoryPullRequests(subscription) { @@ -1314,8 +1838,8 @@ async function listRepositoryPullRequests(subscription) { headers: providerHeaders(profile), })); } catch (error) { - if (Number(error?.status || 0) === 404) { - throw new Error(`${t('errorRepoNotFound')} (${subscription.owner}/${subscription.repo})`); + if ([401, 403, 404].includes(Number(error?.status || 0))) { + throw sourceAccessError(subscription, error); } throw error; } @@ -1326,16 +1850,21 @@ async function listRepositoryPullRequests(subscription) { repo: subscription.repo, number: Number(row.number || row.iid || row.id), url: row.html_url || row.web_url || '', + queueOrigin: 'watch', })).filter((row) => Number.isFinite(row.number)); } try { raw = await requestWithAuthRetry(profile, () => netJson(`${base}/repos/${ownerRepo}/pulls?state=open&per_page=20`, { headers: providerHeaders(profile), })); - } catch { - raw = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${subscription.owner}/${subscription.repo}`)}/merge_requests?state=opened&per_page=20`, { - headers: providerHeaders(profile), - })); + } catch (firstError) { + try { + raw = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${subscription.owner}/${subscription.repo}`)}/merge_requests?state=opened&per_page=20`, { + headers: providerHeaders(profile), + })); + } catch (secondError) { + throw sourceAccessError(subscription, preferAccessError(firstError, secondError)); + } } const rows = Array.isArray(raw) ? raw : raw?.values || raw?.items || []; return rows.slice(0, 20).map((row) => ({ @@ -1345,6 +1874,7 @@ async function listRepositoryPullRequests(subscription) { repo: subscription.repo, number: Number(row.number || row.iid || row.id), url: row.html_url || row.web_url || '', + queueOrigin: 'watch', })).filter((row) => Number.isFinite(row.number)); } @@ -1355,31 +1885,57 @@ async function listReviewRequested(profile) { const headers = providerHeaders(profile); const query = encodeURIComponent('is:pr is:open review-requested:@me archived:false'); const result = await netJson(`${base}/search/issues?q=${query}&per_page=20`, { headers }); - return (result.items || []).map((item) => parsePrUrl(item.html_url)).filter(Boolean); + return (result.items || []) + .map((item) => parsePrUrl(item.html_url)) + .filter(Boolean) + .map((item) => ({ ...item, queueOrigin: 'assigned' })); } try { const result = await netJson(`${normalizeBaseUrl(profile.apiBaseUrl)}/user/review_requests`, { headers: providerHeaders(profile), }); - return (Array.isArray(result) ? result : result?.items || []).map((item) => parsePrUrl(item.html_url || item.web_url || item.url)).filter(Boolean); + return (Array.isArray(result) ? result : result?.items || []) + .map((item) => parsePrUrl(item.html_url || item.web_url || item.url)) + .filter(Boolean) + .map((item) => ({ ...item, queueOrigin: 'assigned' })); } catch { return []; } } -function mergeItems(nextItems) { +function isOpenReviewItem(item) { + const stateName = String(item?.state || '').toLowerCase(); + return !item?.merged && (stateName === 'open' || stateName === 'opened' || !stateName); +} + +function mergeItems(nextItems, { dropMissing = false } = {}) { const previous = new Map(state.data.items.map((item) => [itemKey(item), item])); const merged = new Map(); for (const item of nextItems) { - merged.set(itemKey(item), { ...previous.get(itemKey(item)), ...item, stale: false }); + const existing = previous.get(itemKey(item)); + merged.set(itemKey(item), { + ...existing, + ...item, + queueOrigin: item.queueOrigin || existing?.queueOrigin || 'direct', + stale: false, + }); } for (const item of state.data.items) { - if (!merged.has(itemKey(item))) merged.set(itemKey(item), { ...item, stale: true }); + if (merged.has(itemKey(item))) continue; + if (!dropMissing || item.queueOrigin === 'direct') { + merged.set(itemKey(item), { ...item, stale: dropMissing }); + } } state.data.items = Array.from(merged.values()).sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))); + if (state.data.selectedKey && !state.data.items.some((item) => itemKey(item) === state.data.selectedKey)) { + state.data.selectedKey = null; + state.data.selectedFilePath = null; + resetSelectedPrTransientUi(); + } if (!state.data.selectedKey && state.data.items[0]) { state.data.selectedKey = itemKey(state.data.items[0]); state.data.selectedFilePath = state.data.items[0].files?.[0]?.path || null; + resetSelectedPrTransientUi(); } } @@ -1412,15 +1968,49 @@ async function notifyNewWork(previousItems, nextItems) { } } } - for (const notice of notifications.slice(0, 4)) { + if (!notifications.length) return; + for (const notice of notifications) { + notified.add(notice.key); + } + state.data.notifiedKeys = Array.from(notified).slice(-200); + if (shouldSuppressSystemNotification()) { + return; + } + const digest = buildNotificationDigest(notifications); + for (const notice of digest) { try { await app.notifications.system(notice.title, notice.body); - notified.add(notice.key); } catch { break; } } - state.data.notifiedKeys = Array.from(notified).slice(-200); +} + +function shouldSuppressSystemNotification() { + return document.visibilityState === 'visible' || document.hasFocus(); +} + +function buildNotificationDigest(notifications) { + const newItems = notifications.filter((notice) => notice.key.startsWith('new:')); + const newHeads = notifications.filter((notice) => notice.key.startsWith('head:')); + const digest = []; + if (newItems.length === 1) digest.push(newItems[0]); + if (newItems.length > 1) { + digest.push({ + key: 'new:digest', + title: t('newPrBatchTitle'), + body: t('newPrBatchBody', { count: newItems.length }), + }); + } + if (newHeads.length === 1) digest.push(newHeads[0]); + if (newHeads.length > 1) { + digest.push({ + key: 'head:digest', + title: t('newHeadTitle'), + body: t('newHeadBatchBody', { count: newHeads.length }), + }); + } + return digest.slice(0, 2); } async function syncQueue(mode = state.data.queueMode) { @@ -1444,20 +2034,24 @@ async function syncQueue(mode = state.data.queueMode) { try { const previousItems = [...state.data.items]; const identities = []; + const sourceErrors = []; + let refreshedSourceCount = 0; if (mode === 'all') { for (const subscription of subscriptions) { try { identities.push(...await listRepositoryPullRequests(subscription)); + refreshedSourceCount += 1; } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(error); } } } else { for (const item of state.data.profiles.filter((profileItem) => profileItem.enabled && hasToken(profileItem))) { try { identities.push(...await listReviewRequested(item)); + refreshedSourceCount += 1; } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(sourceAccessError(item, error)); } } } @@ -1465,14 +2059,31 @@ async function syncQueue(mode = state.data.queueMode) { const snapshots = []; for (const identity of Array.from(unique.values()).slice(0, 30)) { try { - snapshots.push(await fetchSnapshot(identity)); + const snapshot = await fetchSnapshot(identity); + if (isOpenReviewItem(snapshot)) snapshots.push(snapshot); } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(error); } } - mergeItems(snapshots); + const hadSourceError = sourceErrors.length > 0; + mergeItems(snapshots, { dropMissing: !hadSourceError }); await notifyNewWork(previousItems, snapshots); - await finish('statusReady'); + if (hadSourceError) { + state.ui.busy = null; + if (refreshedSourceCount > 0) { + state.ui.error = null; + state.ui.status = t('statusPartialSync', { count: sourceErrors.length }); + } else { + state.ui.error = String(sourceErrors[0]?.message || sourceErrors[0]); + state.ui.status = null; + } + state.ui.reviewProgress = null; + state.ui.cancelReviewRequested = false; + await saveStorage(); + render({ preservePaneScroll: true }); + } else { + await finish('statusReady'); + } resetPollTimer(); } catch (error) { setError(error); @@ -1494,6 +2105,7 @@ async function openDirectUrl() { const byKey = new Map(state.data.items.map((item) => [itemKey(item), item])); byKey.set(itemKey(snapshot), snapshot); state.data.items = Array.from(byKey.values()); + if (state.data.selectedKey !== itemKey(snapshot)) resetSelectedPrTransientUi(); state.data.selectedKey = itemKey(snapshot); state.data.selectedFilePath = snapshot.files?.[0]?.path || null; state.ui.activeProviderId = identity.providerId; @@ -1512,6 +2124,50 @@ function selectedDraft() { return snapshot ? state.data.drafts[snapshotKey(snapshot)] || null : null; } +function unpublishedDraftOperations(draft) { + return (draft?.operations || []).filter((operation) => !operation.published); +} + +function publishedReviewContextFor(snapshot) { + const key = snapshotKey(snapshot); + const context = state.data.publishedReviewContext?.[key]; + return Array.isArray(context) ? context.slice(0, 20) : []; +} + +function recordPublishedReviewContext(snapshot, draft, results) { + const key = snapshotKey(snapshot); + const byOperationId = new Map((results || []).map((result) => [result.operationId, result])); + const publishedItems = (draft?.operations || []) + .filter((operation) => { + const result = byOperationId.get(operation.id); + return result?.status === 'success' && result.providerOperationId !== 'skipped'; + }) + .map((operation) => ({ + id: operation.id, + kind: operation.kind, + path: operation.path || '', + position: operation.position || null, + body: String(operation.body || '').slice(0, 1200), + decision: operation.decision || '', + headSha: draft.headSha || snapshot.headSha || '', + publishedAt: new Date().toISOString(), + })); + if (!publishedItems.length) return; + const previous = publishedReviewContextFor(snapshot); + state.data.publishedReviewContext = { + ...(state.data.publishedReviewContext || {}), + [key]: [...publishedItems, ...previous].slice(0, 30), + }; +} + +function resetSelectedPrTransientUi() { + state.ui.filesExpanded = false; + state.ui.focusedDiffPath = null; + state.ui.focusedDiffPosition = null; + state.ui.manualCommentDraft = ''; + state.ui.manualCommentExpanded = false; +} + function recommendMode(snapshot) { const lines = snapshot.files.reduce((sum, file) => sum + file.additions + file.deletions, 0); const security = snapshot.files.some((file) => /auth|permission|crypto|secret|token|security/i.test(file.path)); @@ -1521,9 +2177,26 @@ function recommendMode(snapshot) { return 'fast_check'; } +function reviewLanguageName() { + return state.data.reviewLanguage === 'zh' ? 'Simplified Chinese' : 'English'; +} + function localSummary(snapshot) { const lines = snapshot.files.reduce((sum, file) => sum + file.additions + file.deletions, 0); const topFiles = snapshot.files.slice(0, 8).map((file) => `- ${file.path} (+${file.additions}/-${file.deletions})`).join('\n'); + if (state.data.reviewLanguage === 'zh') { + return [ + `Review 草稿:"${snapshot.title}"。`, + '', + `变更文件:${snapshot.files.length}。变更行数:${lines}。`, + snapshot.checks.length ? `CI:${snapshot.checks.map((check) => `${check.name}:${check.conclusion || check.status}`).join(', ')}` : 'CI:代码平台未返回状态。', + '', + '建议关注:', + topFiles || '- 代码平台未返回可审核文件。', + '', + '发布前请编辑确认。', + ].join('\n'); + } return [ `Review draft for "${snapshot.title}".`, '', @@ -1545,12 +2218,23 @@ function reviewPrompt(snapshot, mode) { deletions: file.deletions, patch: (file.patch || '').slice(0, mode === 'deep_review' ? 12000 : 5000), })); + const previousPublishedFindings = publishedReviewContextFor(snapshot).map((item) => ({ + kind: item.kind, + path: item.path, + position: item.position, + body: item.body, + decision: item.decision, + headSha: item.headSha, + })); return [ 'You are reviewing a pull request. Return JSON only with actionable review items.', + `Write all review comments in ${reviewLanguageName()}.`, 'Do not create a general summary comment. The reviewed author does not need a recap unless there is a principle-level concern about the PR direction.', 'Use summaryComment only when the issue is a principle-level PR direction concern that cannot be tied to a specific file or diff line.', - `Depth: ${modeLabel(mode)}. Prefer concrete functionality direction, implementation risks, and missing tests.`, - 'Schema: {"findings":[{"path":"src/file.ts","position":12,"body":"specific issue"}],"summaryComment":"","decision":"comment","decisionBody":""}.', + `Depth: ${modeLabel(mode)}. Prefer concrete functionality direction, implementation risks, missing tests, and a practical suggested fix for each issue.`, + 'Previous published review comments are provided as context. Do not repeat previous published review comments as new findings.', + 'You may disagree with or refine those earlier comments when the current diff or discussion shows they were wrong, outdated, resolved, or need follow-up.', + 'Schema: {"findings":[{"path":"src/file.ts","position":12,"body":"specific issue","suggestion":"how to fix it"}],"summaryComment":"","decision":"comment","decisionBody":""}.', 'Use a 1-based diff position only when you can identify it from the patch. Omit findings that are not supported by the diff.', '', JSON.stringify({ @@ -1561,6 +2245,7 @@ function reviewPrompt(snapshot, mode) { head: snapshot.headBranch, ci: snapshot.checks, existingReviews: snapshot.reviews.slice(0, 12), + previousPublishedFindings, files, }, null, 2), ].join('\n'); @@ -1583,6 +2268,13 @@ function normalizeDecision(value) { return ['approve', 'request_changes', 'comment'].includes(value) ? value : 'comment'; } +function formatFindingBody(body, suggestion) { + const issue = String(body || '').trim(); + const fix = String(suggestion || '').trim(); + if (!fix) return issue; + return `${issue}\n\n${state.data.reviewLanguage === 'zh' ? '建议处理' : 'Suggested fix'}: ${fix}`; +} + function buildReviewOperations(snapshot, aiText, mode) { const parsed = extractJsonObject(aiText); const timestamp = snapshot.headSha || Date.now(); @@ -1606,6 +2298,7 @@ function buildReviewOperations(snapshot, aiText, mode) { for (const finding of findings.slice(0, 12)) { const path = String(finding?.path || '').trim(); const body = String(finding?.body || '').trim(); + const suggestion = String(finding?.suggestion || finding?.suggestedFix || '').trim(); const position = Number(finding?.position || 0); if (!path || !body || !validPaths.has(path) || !Number.isFinite(position) || position <= 0) { continue; @@ -1615,15 +2308,16 @@ function buildReviewOperations(snapshot, aiText, mode) { kind: 'inline_comment', path, position, - body, + body: formatFindingBody(body, suggestion), selected: true, stale: false, published: false, }); } + const hasInlineFindings = operations.some((operation) => operation.kind === 'inline_comment'); const decisionBody = String(parsed.decisionBody || '').trim(); - if (decisionBody) { + if (decisionBody && !hasInlineFindings) { operations.push({ id: `decision-${timestamp}`, kind: 'review_decision', @@ -1652,9 +2346,26 @@ function buildReviewOperations(snapshot, aiText, mode) { return operations; } +function requestGenerateDraft() { + const snapshot = selectedSnapshot(); + if (!snapshot) return; + const draft = selectedDraft(); + const unpublished = unpublishedDraftOperations(draft); + if (unpublished.length) { + state.ui.draftOverwriteConfirm = { + key: snapshotKey(snapshot), + count: unpublished.length, + }; + render(); + return; + } + void generateDraft(); +} + async function generateDraft() { const snapshot = selectedSnapshot(); if (!snapshot) return; + state.ui.draftOverwriteConfirm = null; state.ui.cancelReviewRequested = false; setBusy('draft', 'statusGenerating'); try { @@ -1696,7 +2407,7 @@ async function generateDraft() { operations: buildReviewOperations(snapshot, reviewText, mode), }; state.data.drafts[snapshotKey(snapshot)] = draft; - await finish('statusReady'); + await finish('statusReady', { preservePaneScroll: true }); } catch (error) { setError(error); } @@ -1725,6 +2436,7 @@ async function addManualComment() { published: false, }); state.data.drafts[key] = draft; + state.ui.manualCommentDraft = ''; input.value = ''; await finish('statusSaved'); } @@ -1879,17 +2591,274 @@ async function confirmPublish() { message: result.message || null, timestamp: new Date().toISOString(), })); + recordPublishedReviewContext(snapshot, draft, results); state.data.audit = [...auditEntries, ...state.data.audit].slice(0, 50); - state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; + const publishedCount = results.filter((result) => result.status === 'success').length; + if (publishedCount > 0) { + state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; + } state.ui.confirm = null; - await finish('statusPublished'); + if (publishedCount > 0) { + await finish('statusPublished'); + } else { + await saveStorage(); + setError(t('statusPublishFailed')); + } +} + +function upsertSnapshot(snapshot) { + if (!snapshot) return; + const byKey = new Map(state.data.items.map((item) => [itemKey(item), item])); + byKey.set(snapshotKey(snapshot), { + ...byKey.get(snapshotKey(snapshot)), + ...snapshot, + stale: false, + }); + state.data.items = Array.from(byKey.values()).sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))); + state.data.selectedKey = snapshotKey(snapshot); + state.data.selectedFilePath = state.data.selectedFilePath || snapshot.files?.[0]?.path || null; } -async function markReviewed() { +function githubGraphqlUrl(profile) { + const base = normalizeBaseUrl(profile.apiBaseUrl); + if (base === 'https://api.github.com') return `${base}/graphql`; + if (base.endsWith('/api/v3')) return `${base.slice(0, -'/api/v3'.length)}/api/graphql`; + return `${base}/graphql`; +} + +async function githubGraphql(profile, query, variables = {}) { + const result = await netJson(githubGraphqlUrl(profile), { + method: 'POST', + headers: providerHeaders(profile, true), + body: JSON.stringify({ query, variables }), + }); + if (Array.isArray(result?.errors) && result.errors.length) { + const message = result.errors.map((error) => error.message).filter(Boolean).join('; '); + throw new Error(`${t('errorNetwork')}: ${message || 'GraphQL error'}`); + } + return result?.data || {}; +} + +async function refreshSelectedLifecycleStatus() { const snapshot = selectedSnapshot(); if (!snapshot) return; - state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; - await finish('statusReviewed'); + setBusy('lifecycle-refresh', 'refreshingLifecycle'); + try { + const fresh = await fetchSnapshot(snapshot.identity); + upsertSnapshot(fresh); + await finish('statusReady'); + } catch (error) { + setError(error); + } +} + +function lifecycleActionBlockers(action, snapshot) { + const caps = snapshot?.providerCapabilities || {}; + const readiness = snapshot?.mergeReadiness || {}; + if (action === 'merge') { + if (!caps.mergePullRequest) return ['lifecycleActionUnsupported']; + const blockers = [...(readiness.blockers || ['lifecycleUnknown'])]; + if (!hasToken(profileById(snapshot?.identity?.providerId)) && !blockers.includes('lifecycleBlockToken')) { + blockers.push('lifecycleBlockToken'); + } + return blockers; + } + if (!caps.transitionDraftState) return ['lifecycleActionUnsupported']; + const blockers = []; + if (readiness.state === 'merged') blockers.push('lifecycleBlockMerged'); + if (readiness.state === 'closed') blockers.push('lifecycleBlockClosed'); + if (!readiness.headSha && !snapshot?.headSha) blockers.push('lifecycleBlockHead'); + if (!snapshot?.nodeId) blockers.push('lifecycleUnknown'); + if (!hasToken(profileById(snapshot.identity.providerId))) blockers.push('lifecycleBlockToken'); + return blockers; +} + +function hardLifecycleBlockers(blockers) { + return blockers.filter((key) => key !== 'lifecycleBlockToken'); +} + +function lifecycleButtonTitle(action, blockers, profile) { + const unique = Array.from(new Set(blockers || [])); + if (unique.includes('lifecycleBlockToken')) { + const authHint = profile?.kind === 'github' ? t('lifecycleAutoAuthHint') : t('lifecycleGuideToken'); + const other = unique.filter((key) => key !== 'lifecycleBlockToken').map((key) => t(key)); + return [authHint, ...other].filter(Boolean).join(' · '); + } + if (unique.length) return unique.map((key) => t(key)).join(' · '); + if (action === 'ready') return t('lifecycleConfirmReadyBody'); + if (action === 'draft') return t('lifecycleConfirmDraftBody'); + return t('lifecycleConfirmMergeBody'); +} + +function lifecycleActionLabel(action) { + if (action === 'ready') return t('lifecycleActionReady'); + if (action === 'draft') return t('lifecycleActionDraft'); + if (action === 'merge') return t('lifecycleActionMerge'); + return action; +} + +function lifecycleConfirmTitleKey(action) { + if (action === 'ready') return 'lifecycleConfirmReadyTitle'; + if (action === 'draft') return 'lifecycleConfirmDraftTitle'; + return 'lifecycleConfirmMergeTitle'; +} + +function lifecycleConfirmBodyKey(action) { + if (action === 'ready') return 'lifecycleConfirmReadyBody'; + if (action === 'draft') return 'lifecycleConfirmDraftBody'; + return 'lifecycleConfirmMergeBody'; +} + +async function requestLifecycleAction(action) { + const snapshot = selectedSnapshot(); + if (!snapshot) return; + const profile = profileById(snapshot.identity.providerId); + if (!profile || !await ensureProfileToken(profile)) { + state.ui.settingsOpen = true; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleAuthFailed'); + render(); + return; + } + setBusy('lifecycle-check', 'refreshingLifecycle'); + try { + const fresh = await fetchSnapshot(snapshot.identity); + upsertSnapshot(fresh); + if (snapshot.headSha && fresh.headSha && snapshot.headSha !== fresh.headSha) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleHeadChanged'); + render(); + return; + } + const blockers = lifecycleActionBlockers(action, fresh); + if (blockers.length) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = action === 'merge' + ? t('lifecycleMergeBlocked', { reason: blockers.map((key) => t(key)).join(', ') }) + : blockers.map((key) => t(key)).join(', '); + render(); + return; + } + state.ui.busy = null; + state.ui.status = null; + state.ui.error = null; + state.ui.lifecycleConfirm = { + action, + identity: fresh.identity, + title: fresh.title, + owner: fresh.identity.owner, + repo: fresh.identity.repo, + number: fresh.identity.number, + baseBranch: fresh.baseBranch, + headBranch: fresh.headBranch, + expectedHeadSha: fresh.headSha, + warnings: action === 'merge' ? (fresh.mergeReadiness?.warnings || []) : [], + defaultMergeMethod: 'merge', + }; + render(); + } catch (error) { + setError(error); + } +} + +async function transitionGithubDraftState(profile, snapshot, action) { + const mutation = action === 'ready' + ? `mutation MarkPullRequestReady($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { + pullRequest { isDraft } + } + }` + : `mutation ConvertPullRequestToDraft($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { + pullRequest { isDraft } + } + }`; + await githubGraphql(profile, mutation, { pullRequestId: snapshot.nodeId }); +} + +async function mergeGithubPullRequest(profile, snapshot, mergeMethod, expectedHeadSha) { + const base = normalizeBaseUrl(profile.apiBaseUrl); + const ownerRepo = `${encodeURIComponent(snapshot.identity.owner)}/${encodeURIComponent(snapshot.identity.repo)}`; + return netJson(`${base}/repos/${ownerRepo}/pulls/${snapshot.identity.number}/merge`, { + method: 'PUT', + headers: providerHeaders(profile, true), + body: JSON.stringify({ + sha: expectedHeadSha, + merge_method: mergeMethod, + }), + }); +} + +async function confirmLifecycleAction() { + const confirm = state.ui.lifecycleConfirm; + if (!confirm) return; + const profile = profileById(confirm.identity.providerId); + const mergeMethod = document.getElementById('merge-method')?.value || confirm.defaultMergeMethod || 'merge'; + setBusy('lifecycle', 'lifecycleUpdating'); + try { + const fresh = await fetchSnapshot(confirm.identity); + upsertSnapshot(fresh); + if (confirm.expectedHeadSha && fresh.headSha && confirm.expectedHeadSha !== fresh.headSha) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleHeadChanged'); + render(); + return; + } + const blockers = lifecycleActionBlockers(confirm.action, fresh); + if (blockers.length) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = confirm.action === 'merge' + ? t('lifecycleMergeBlocked', { reason: blockers.map((key) => t(key)).join(', ') }) + : blockers.map((key) => t(key)).join(', '); + render(); + return; + } + if (confirm.action === 'merge') { + await mergeGithubPullRequest(profile, fresh, mergeMethod, confirm.expectedHeadSha); + } else { + await transitionGithubDraftState(profile, fresh, confirm.action); + } + const auditEntry = { + id: `${snapshotKey(fresh)}:lifecycle:${confirm.action}:${Date.now()}`, + providerId: profile.id, + owner: fresh.identity.owner, + repo: fresh.identity.repo, + number: fresh.identity.number, + actionType: `lifecycle:${confirm.action}`, + actionLabel: lifecycleActionLabel(confirm.action), + status: 'success', + headSha: confirm.expectedHeadSha, + timestamp: new Date().toISOString(), + }; + state.data.audit = [auditEntry, ...state.data.audit].slice(0, 50); + state.ui.lifecycleConfirm = null; + try { + upsertSnapshot(await fetchSnapshot(fresh.identity)); + } catch { + upsertSnapshot({ + ...fresh, + merged: confirm.action === 'merge' ? true : fresh.merged, + isDraft: confirm.action === 'draft' ? true : confirm.action === 'ready' ? false : fresh.isDraft, + }); + } + await finish(confirm.action === 'merge' + ? 'lifecycleMergeDone' + : confirm.action === 'draft' + ? 'lifecycleDraftDone' + : 'lifecycleReadyDone'); + } catch (error) { + state.ui.lifecycleConfirm = null; + setError(error); + } } function resetPollTimer() { @@ -1904,77 +2873,203 @@ function resetPollTimer() { function renderStatus() { if (state.ui.error) return `
${esc(state.ui.error)}
`; - if (state.ui.status) return `
${esc(state.ui.status)}
`; + if (state.ui.status) { + const busyClass = state.ui.startupSyncing || state.ui.busy === 'refresh' ? ' pr-status--busy' : ''; + return `
${esc(state.ui.status)}
`; + } return ''; } +function shouldShowShellStatus() { + return Boolean(state.ui.status && (state.ui.startupSyncing || state.ui.busy === 'refresh')); +} + +function directOpenBusyReason() { + if (!state.ui.busy) return ''; + if (state.ui.busy === 'refresh') return t('directOpenBusySync'); + if (state.ui.busy === 'draft') return t('directOpenBusyReview'); + if (state.ui.busy === 'publish' || state.ui.busy === 'stale-check') return t('directOpenBusyPublish'); + if (String(state.ui.busy).startsWith('lifecycle')) return t('directOpenBusyLifecycle'); + return t('directOpenBusyGeneric'); +} + +function busyActionReason(action) { + if (!state.ui.busy) return ''; + if (action === 'open-direct') return directOpenBusyReason(); + if (state.ui.busy === 'refresh') return t('busyActionSync'); + if (state.ui.busy === 'draft') return t('busyActionReview'); + if (state.ui.busy === 'publish' || state.ui.busy === 'stale-check') return t('busyActionPublish'); + if (String(state.ui.busy).startsWith('lifecycle')) return t('busyActionLifecycle'); + return t('busyActionGeneric'); +} + +function actionAvailabilityAttrs(disabledReason = '', enabledTitle = '') { + const title = disabledReason || enabledTitle; + const attrs = [`aria-disabled="${disabledReason ? 'true' : 'false'}"`]; + if (title) attrs.push(`title="${esc(title)}"`); + if (disabledReason) attrs.push(`data-disabled-reason="${esc(disabledReason)}"`); + return attrs.join(' '); +} + +function disabledActionAttrs(disabledReason = '') { + return actionAvailabilityAttrs(disabledReason); +} + +function busyActionAttrs(action) { + return disabledActionAttrs(busyActionReason(action)); +} + +function renderShellStatus() { + if (!shouldShowShellStatus()) return ''; + return `
${renderStatus()}
`; +} + function renderCommandBar() { - const profile = activeProfile(); + const activeRepoCount = activeSubscriptions().length; + const modeLabelText = state.data.queueMode === 'mine' ? t('syncMineTitle') : t('syncAllTitle'); return ` -
+
PR

${esc(t('title'))}

-

${esc(t('subtitle'))}

+

${esc(modeLabelText)} · ${esc(activeRepoCount ? t('autoSync') : t('queueEmpty'))}

-
-
- ${esc(t('repositoryFirst'))} - ${esc(t('repositoryFirstHint'))} -
-
- - - -
+
+ +
-
-
- ${esc(t('singlePrFallback'))} - ${esc(t('singlePrFallbackHint'))} -
-
- - -
+
+ `; +} + +function renderWatchRepositoryCard() { + return ` +
+
+ ${esc(t('repositoryFirst'))} + ${esc(t('repositoryFirstHint'))} +
+
+ + + +
+
+ `; +} + +function renderDirectOpenCard() { + const disabledAttrs = busyActionAttrs('open-direct'); + return ` +
+
+ ${esc(t('singlePrFallback'))} + ${esc(t('singlePrFallbackHint'))} +
+
+ + +
+
+ `; +} + +function renderAccessCard() { + const profile = activeProfile(); + return ` +
+
+ ${esc(t('privateAction'))} + ${esc(t('authAutoHint'))}
-
-
- ${esc(t('privateAction'))} - ${esc(t('authAutoHint'))} +
+ + ${profile?.kind === 'github' ? `` : ''} + ${esc(hasToken(profile) ? t('tokenReady') : t('tokenMissing'))} +
+
+ ${esc(t('manualToken'))} + +
+
+ `; +} + +function renderQueueSettingsCard() { + const mode = state.data.queueMode; + return ` +
+
+ ${esc(t('queueSettingsTitle'))} + ${esc(t('queueSettingsHint'))} +
+
+ + +
+
+ + +
+
+ `; +} + +function renderSettingsModal() { + if (!state.ui.settingsOpen) return ''; + return ` +
+
+
+ +
+ + `; } @@ -1983,8 +3078,7 @@ function renderQueuePanel() { const activeRepoCount = activeSubscriptions().length; return `