From 4a5716360b9eff23b65ee037e203be121cd0ecc4 Mon Sep 17 00:00:00 2001 From: nllptrx <48022579+nllptrx@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:42:54 +0000 Subject: [PATCH] Feat/forge check semantics and followup fixes (#2337) * feat(protocol): add forge check presentation metadata Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible. * fix(gitea): model Actions jobs as tasks Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata. * feat(app): distinguish forge check presentation states * fix(gitlab): preserve pipeline job semantics * refactor(app): centralize forge check presentation policy Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces. * refactor: consolidate forge check handling * feat(forge): auto-attach pasted forge references * docs(compat): correct forge rollout metadata * refactor(forge): model check nuances as traits * fix(forge): harden checks from branch review Structural Gitea Actions shadow dedup by run id (verified upstream), rerun grouping without workflow_id, shared trait/status constants and Gitea state mapping in the protocol package, all cloud host aliases in pasted-link matching, logged glab job-detail failures, concurrent approvals/pipeline fetch, checks aggregate from the same pipeline as the job list, and removal of the unread check-level rawStatus. The released pipeline-level rawStatus is staged for removal under COMPAT(pipelineRawStatus). * fix(gitea): detect fork-PR Actions approval hold as action_required gitea.com represents a fork PR awaiting Actions approval as a run in status "waiting" with no tasks and pending shadow commit statuses; it does not expose need_approval. The previous mapping only set action_required from a task with blocked+need_approval, so the gate never surfaced (0 tasks -> early return; need_approval absent). Detect the hold from /actions/runs: a waiting/blocked run whose PR is cross-repo (head.repo.id != base.repo.id), or one that does expose need_approval (self-hosted). Tag the run's pending shadow statuses action_required. Runs are only fetched when a pending shadow has no covering task, keeping the common path at two requests. Verified live against gitea.com fork PR (run 715170, waiting, cross-repo). * fix(gitea): resolve cross-fork PRs by head sha The current-PR matcher gated candidates on the checkout owner (giteaHeadOwner === expectedHeadOwner), which excludes every fork PR: its head carries the fork owner while the checkout is the base repo, so a fork PR - the only shape that can be approval-held on Gitea - never resolved and its checks never showed in-app. GitLab has no such gate (it resolves fork MRs from the target project by source branch). Accept a recent PR whose head sha equals the workspace head sha regardless of owner; the sha is an unambiguous identity, so the anti-collision owner gate still applies when no sha match is available. Verified live against gitea.com fork PR #6 (resolves + renders action_required). * fix: resolve forge rebase drift * fix: type forge auto-attach boundary * fix(i18n): add Korean check status copy --------- Co-authored-by: Mohamed Boudra --- docs/forge-providers.md | 36 +- packages/app/e2e/browser/pr-pane.spec.ts | 6 +- packages/app/e2e/support/helpers/pr-pane.ts | 7 +- packages/app/src/attachments/types.ts | 4 +- .../components/icons/manual-status-icon.tsx | 16 + .../src/components/workspace-hover-card.tsx | 113 ++- packages/app/src/composer/actions.test.ts | 50 +- packages/app/src/composer/actions.ts | 22 +- .../app/src/composer/attachments/submit.ts | 8 +- ...ch.test.tsx => forge-auto-attach.test.tsx} | 149 +++- .../auto-attach.ts => forge-auto-attach.ts} | 206 +++--- packages/app/src/composer/index.tsx | 44 +- packages/app/src/git/actions-store.ts | 15 +- .../src/git/check-presentation-copy.test.ts | 36 + .../app/src/git/check-presentation-copy.ts | 55 ++ .../app/src/git/check-presentation.test.ts | 42 ++ packages/app/src/git/check-presentation.ts | 59 ++ .../app/src/git/check-presentation.view.tsx | 83 +++ packages/app/src/git/client-forge-module.ts | 18 + packages/app/src/git/forge-refs.test.ts | 104 +++ packages/app/src/git/forge-refs.ts | 145 ++++ packages/app/src/git/forge-url.test.ts | 27 +- packages/app/src/git/forge-url.ts | 17 + packages/app/src/git/forges/gitea.ts | 18 +- packages/app/src/git/forges/github.ts | 5 + packages/app/src/git/forges/gitlab.ts | 75 +- packages/app/src/git/forges/gitlab.view.tsx | 79 +-- packages/app/src/git/merge-capability.ts | 9 +- packages/app/src/git/pr-hint.ts | 10 +- packages/app/src/git/pr-status.ts | 5 +- .../pull-request-panel/check-group-state.ts | 8 +- .../git/pull-request-panel/check-status.ts | 8 +- .../git/pull-request-panel/checks-ring.tsx | 11 +- .../git/pull-request-panel/checks-section.tsx | 14 +- .../git/pull-request-panel/checks-summary.ts | 43 +- .../src/git/pull-request-panel/data.test.ts | 87 ++- .../app/src/git/pull-request-panel/data.ts | 49 +- .../pull-request-panel/native-data.test.ts | 1 + .../app/src/git/pull-request-panel/pane.tsx | 23 +- .../git/pull-request-panel/section-kit.tsx | 63 +- .../app/src/git/use-forge-search-query.ts | 8 +- packages/app/src/i18n/resources.test.ts | 18 + packages/app/src/i18n/resources/ar.ts | 18 + packages/app/src/i18n/resources/en.ts | 18 + packages/app/src/i18n/resources/es.ts | 18 + packages/app/src/i18n/resources/fr.ts | 18 + packages/app/src/i18n/resources/ja.ts | 18 + packages/app/src/i18n/resources/ko.ts | 18 + packages/app/src/i18n/resources/pt-BR.ts | 18 + packages/app/src/i18n/resources/ru.ts | 18 + packages/app/src/i18n/resources/zh-CN.ts | 18 + .../src/screens/new-workspace-picker-item.ts | 5 +- .../app/src/screens/new-workspace-screen.tsx | 10 +- packages/app/src/utils/github-refs.test.ts | 130 ---- packages/app/src/utils/github-refs.ts | 117 --- packages/protocol/src/check-traits.ts | 14 + packages/protocol/src/gitea-status.ts | 22 + packages/protocol/src/gitlab-pipeline.ts | 19 + .../src/messages.checkout-pr-schema.test.ts | 53 +- packages/protocol/src/messages.ts | 121 ++-- .../protocol/src/messages.workspaces.test.ts | 11 + .../src/server/agent/prompt-attachments.ts | 4 + .../src/server/checkout/status-projection.ts | 7 +- .../resolve-worktree-creation-intent.ts | 4 +- .../session/checkout/checkout-session.ts | 7 +- .../server/src/server/websocket-server.ts | 23 +- .../src/server/workspace-git-service.ts | 9 +- .../server/src/server/worktree-session.ts | 5 +- packages/server/src/services/forge-service.ts | 17 +- .../server/src/services/gitea-service.test.ts | 668 +++++++++++++++++- packages/server/src/services/gitea-service.ts | 522 ++++++++++---- packages/server/src/services/gitlab-facts.ts | 17 - .../src/services/gitlab-service.test.ts | 234 ++++-- .../server/src/services/gitlab-service.ts | 153 +++- packages/server/src/utils/checkout-git.ts | 9 +- 75 files changed, 3099 insertions(+), 1040 deletions(-) create mode 100644 packages/app/src/components/icons/manual-status-icon.tsx rename packages/app/src/composer/{github/auto-attach.test.tsx => forge-auto-attach.test.tsx} (80%) rename packages/app/src/composer/{github/auto-attach.ts => forge-auto-attach.ts} (66%) create mode 100644 packages/app/src/git/check-presentation-copy.test.ts create mode 100644 packages/app/src/git/check-presentation-copy.ts create mode 100644 packages/app/src/git/check-presentation.test.ts create mode 100644 packages/app/src/git/check-presentation.ts create mode 100644 packages/app/src/git/check-presentation.view.tsx create mode 100644 packages/app/src/git/forge-refs.test.ts create mode 100644 packages/app/src/git/forge-refs.ts delete mode 100644 packages/app/src/utils/github-refs.test.ts delete mode 100644 packages/app/src/utils/github-refs.ts create mode 100644 packages/protocol/src/check-traits.ts create mode 100644 packages/protocol/src/gitea-status.ts create mode 100644 packages/protocol/src/gitlab-pipeline.ts diff --git a/docs/forge-providers.md b/docs/forge-providers.md index 6cc4c518ee..128e0f81da 100644 --- a/docs/forge-providers.md +++ b/docs/forge-providers.md @@ -26,8 +26,9 @@ For forge `acme`, the expected end state is: 3. **App modules** - a forge splits into a pure logic half and a view half so logic consumers (URL builders, merge-capability, native checks, and the Node-based e2e harness) never pull the client rendering stack: - - `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`, - optional `facts` (schema, merge-capability, native-check fallbacks). No + - `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar` + (source links and pasted issue/change-request reference paths), optional + `facts` (schema, merge-capability, native-check fallbacks). No React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in `packages/app/src/git/forges/index.ts`. - `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component @@ -57,8 +58,20 @@ schema-mismatched facts render neutrally instead of failing the whole message parse. This is the version-skew win: an old client can receive facts from a newer forge and still show the PR/MR in a neutral state. +Check results keep a stable normalized `status` for aggregation and an optional +open `traits: string[]` for composable forge-neutral refinements such as +`manual`, `action_required`, and `warning`. Consumers ignore unknown traits and +fall back to `status`; `rawStatus` preserves provider-native detail. Do not add +provider vocabulary to the normalized status union. Producers and the +presentation layer share the trait constants in +`packages/protocol/src/check-traits.ts` — use those instead of string literals +so classification and emission cannot drift apart. + Shipped GitHub compatibility stays separate: +- The forge compatibility layer first shipped in `v0.2.0-beta.1`. Its initial + six-month cleanup target is `2027-01-17`, after supported client and daemon + floors reach stable `v0.2.0`. - `status.github` remains accepted for released peers. - The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts into `status.github` for older clients. @@ -112,7 +125,7 @@ rendering stack: `acme.ts` exports a `ClientForgeLogicModule`: - `id` -- optional `urlGrammar` +- optional `urlGrammar` (including `referencePaths` for composer auto-attachment) - optional `facts` registration (schema, merge-capability, native-check fallbacks) `acme.view.tsx` exports a `ClientForgeViewModule`: @@ -181,10 +194,27 @@ are stale, run `npm run build:server`. real shims tagged with `COMPAT(name)`. - Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the top-level brand is Forgejo or Codeberg. +- Gitea's legacy Actions task endpoint cannot filter by commit, may enforce a + page size below the requested limit, and omits rerun-attempt numbers. Walk a + bounded number of pages using `total_count` and filter locally. Preserve + ambiguous same-run tasks: display names are not stable job identities, so + name-based deduplication can hide a real failure. +- A blocked Gitea Actions task is not necessarily manual: only add the + `action_required` trait when the matching run metadata reports + `need_approval`. - Brand icons are bundled React components, so they cannot come from protocol manifest data. - Source URL grammars are app-side because blob/tree path syntax is forge-specific. If a forge has no grammar, omit the "Open on ..." source link rather than constructing a wrong URL. +- Pasted issue/change-request URL recognition is also app-side and registry + driven. Put the forge's route infixes in `urlGrammar.referencePaths`; the + neutral extractor matches the pasted host and full repository path against + the current remote before it invokes cwd-routed forge search. Do not add a + central forge switch or guess a self-hosted Gitea-family brand from URL shape. - GitLab pipeline status constants belong to the GitLab adapter/client module, not protocol. +- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs + (`manual`) from blocking jobs (`manual` plus `action_required`). A failed + allowed-failure job carries the `warning` trait, and `canceling` remains active + until GitLab reports a terminal status. diff --git a/packages/app/e2e/browser/pr-pane.spec.ts b/packages/app/e2e/browser/pr-pane.spec.ts index a3b623ea1f..1dd94b6737 100644 --- a/packages/app/e2e/browser/pr-pane.spec.ts +++ b/packages/app/e2e/browser/pr-pane.spec.ts @@ -102,11 +102,11 @@ test.describe("PR pane", () => { await expectPrPaneTitle(page, "Work in progress"); }); - test("renders check pills with correct passed/failed/pending counts", async ({ page }) => { + test("renders check pills with correct success/failure/pending counts", async ({ page }) => { await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!); await openPrPane(page); - await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 }); + await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 }); }); test("renders activity rows with correct count", async ({ page }) => { @@ -120,7 +120,7 @@ test.describe("PR pane", () => { await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!); await openPrPane(page); - await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 }); + await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 }); await expectPrPaneTitle(page, "PR with no checks"); }); }); diff --git a/packages/app/e2e/support/helpers/pr-pane.ts b/packages/app/e2e/support/helpers/pr-pane.ts index bf13a77021..42aa200fdd 100644 --- a/packages/app/e2e/support/helpers/pr-pane.ts +++ b/packages/app/e2e/support/helpers/pr-pane.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test"; +import type { CheckPresentationCounts } from "@/git/check-presentation"; import { getStateLabel } from "@/git/pull-request-panel/data"; import { openPullRequestPanel } from "./workspace-tabs"; @@ -29,10 +30,10 @@ async function assertCheckPill(page: Page, testId: string, count: number): Promi export async function expectPrPaneCheckSummary( page: Page, - counts: { passed: number; failed: number; pending: number }, + counts: Pick, ): Promise { - await assertCheckPill(page, "pr-pane-check-passed", counts.passed); - await assertCheckPill(page, "pr-pane-check-failed", counts.failed); + await assertCheckPill(page, "pr-pane-check-success", counts.success); + await assertCheckPill(page, "pr-pane-check-failure", counts.failure); await assertCheckPill(page, "pr-pane-check-pending", counts.pending); } diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 1d953c3906..75ae6ae152 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -110,7 +110,9 @@ export type UserComposerAttachment = | PluginResourceComposerAttachment | { kind: "forge_issue"; item: ForgeSearchItem } | { kind: "forge_change_request"; item: ForgeSearchItem } - // COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106 + // COMPAT(githubAttachmentKinds): legacy persisted attachment kinds retained + // when forge-neutral kinds shipped in v0.2.0-beta.1. Remove after 2027-01-17 + // once supported floors are >= v0.2.0 and old drafts no longer need them. | { kind: "github_issue"; item: ForgeSearchItem } | { kind: "github_pr"; diff --git a/packages/app/src/components/icons/manual-status-icon.tsx b/packages/app/src/components/icons/manual-status-icon.tsx new file mode 100644 index 0000000000..fcf6f6d547 --- /dev/null +++ b/packages/app/src/components/icons/manual-status-icon.tsx @@ -0,0 +1,16 @@ +import Svg, { Circle, Path } from "react-native-svg"; +import type { SvgPathIconProps } from "./svg-path-icon"; + +// Forge-neutral rendering of GitLab's manual-status gear, aligned with the +// proportions of the other pipeline status icons. +const MANUAL_STATUS_GEAR_PATH = + "M10.5 7.63V6.37l-.787-.13c-.044-.175-.132-.349-.263-.61l.481-.652-.918-.913-.657.478a2.346 2.346 0 0 0-.612-.26L7.656 3.5H6.388l-.132.783c-.219.043-.394.13-.612.26l-.657-.478-.918.913.437.652c-.131.218-.175.392-.262.61l-.744.086v1.261l.787.13c.044.218.132.392.263.61l-.438.651.92.913.655-.434c.175.086.394.173.613.26l.131.783h1.313l.131-.783c.219-.043.394-.13.613-.26l.656.478.918-.913-.48-.652c.13-.218.218-.435.262-.61l.656-.13zM7 8.283a1.285 1.285 0 0 1-1.313-1.305c0-.739.57-1.304 1.313-1.304.744 0 1.313.565 1.313 1.304 0 .74-.57 1.305-1.313 1.305z"; + +export function ManualStatusIcon({ size = 16, color = "currentColor" }: SvgPathIconProps) { + return ( + + + + + ); +} diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index c1c71274f7..a865bfe2fa 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -14,9 +14,6 @@ import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Check, - CircleCheck, - CircleDot, - CircleX, Copy, ExternalLink, FileDiff, @@ -42,6 +39,14 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { FloatingSurface } from "@/components/ui/floating"; import { isWeb } from "@/constants/platform"; import { useHosts } from "@/runtime/host-runtime"; +import { + COUNTED_CHECK_PRESENTATIONS, + countCheckPresentations, + type CountedCheckPresentation, +} from "@/git/check-presentation"; +import { formatCheckPresentationCountsLabel } from "@/git/check-presentation-copy"; +import { CheckPresentationIcon, getCheckPresentationTone } from "@/git/check-presentation.view"; +import { buildForgeChecksUrl } from "@/git/forge-url"; interface Rect { x: number; @@ -355,17 +360,11 @@ function HostRow({ serverId }: { serverId: string }): ReactElement | null { } const ThemedExternalLink = withUnistyles(ExternalLink); -const ThemedCircleCheck = withUnistyles(CircleCheck); -const ThemedCircleDot = withUnistyles(CircleDot); -const ThemedCircleX = withUnistyles(CircleX); const ThemedCopy = withUnistyles(Copy); const ThemedCheck = withUnistyles(Check); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess }); -const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning }); -const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); function InfoRow({ icon: Icon, @@ -455,51 +454,28 @@ function CopyableInfoRow({ ); } -function getChecksSummaryCounts(checks: NonNullable) { - return checks.reduce( - (counts, check) => { - if (check.status === "success") counts.passed += 1; - else if (check.status === "failure") counts.failed += 1; - else if (check.status !== "skipped" && check.status !== "cancelled") counts.pending += 1; - return counts; - }, - { passed: 0, failed: 0, pending: 0 }, +function ChecksSummaryPill({ + count, + presentation, +}: { + count: number; + presentation: CountedCheckPresentation; +}) { + if (count === 0) return null; + return ( + + + {count} + ); } -function ChecksSummaryPill({ - count, - kind, -}: { - count: number; - kind: "passed" | "failed" | "pending"; -}) { - if (count === 0) return null; - - if (kind === "passed") { - return ( - - - {count} - - ); - } - - if (kind === "failed") { - return ( - - - {count} - - ); - } - - return ( - - - {count} - - ); +function checksSummaryTextStyle(presentation: CountedCheckPresentation) { + const tone = getCheckPresentationTone(presentation); + if (tone === "success") return styles.checksStatusTextPassed; + if (tone === "danger") return styles.checksStatusTextFailed; + if (tone === "warning") return styles.checksStatusTextPending; + return styles.checksStatusTextMuted; } function ChecksSummaryContent({ @@ -512,7 +488,7 @@ function ChecksSummaryContent({ hovered: boolean; }) { const { t } = useTranslation(); - const { passed, failed, pending } = getChecksSummaryCounts(checks); + const counts = countCheckPresentations(checks); const labelStyle = hovered ? [styles.checksSummaryLabel, styles.checksSummaryLabelHovered] @@ -529,9 +505,13 @@ function ChecksSummaryContent({ )} {t("workspace.git.pr.sections.checks")} - - - + {COUNTED_CHECK_PRESENTATIONS.map((presentation) => ( + + ))} ); @@ -546,9 +526,16 @@ function ChecksSummaryPressable({ forge: PrHint["forge"]; url: string; }) { + const { t } = useTranslation(); + const counts = countCheckPresentations(checks); + const accessibilityLabel = formatCheckPresentationCountsLabel( + counts, + t("workspace.git.pr.sections.checks"), + t, + ); const handlePress = useCallback(() => { - void openExternalUrl(`${url}/checks`); - }, [url]); + void openExternalUrl(buildForgeChecksUrl(forge, url) ?? url); + }, [forge, url]); const renderChildren = useCallback( ({ hovered }: { pressed: boolean; hovered?: boolean }) => ( @@ -558,7 +545,12 @@ function ChecksSummaryPressable({ ); return ( - + {renderChildren} ); @@ -672,4 +664,9 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, color: theme.colors.statusSuccess, }, + checksStatusTextMuted: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, })); diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 9727d73e1f..688ef99925 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -21,15 +21,15 @@ import { cancelComposerAgent, dispatchComposerAgentMessage, editQueuedComposerMessage, - findGithubItemByOption, - isAttachmentSelectedForGithubItem, + findForgeItemByOption, + isAttachmentSelectedForForgeItem, openComposerAttachment, pickAndPersistImages, queueComposerMessage, removeComposerAttachmentAtIndex, sendQueuedComposerMessageNow, - toggleGithubAttachment, - toggleGithubAttachmentFromPicker, + toggleForgeAttachment, + toggleForgeAttachmentFromPicker, type MessageSubmissionWriter, type AttachmentPersister, type ComposerCancelClient, @@ -961,19 +961,19 @@ describe("openComposerAttachment", () => { }); }); -describe("toggleGithubAttachment", () => { +describe("toggleForgeAttachment", () => { it("appends a GitHub issue when not already attached", () => { - const next = toggleGithubAttachment([], issueItem); + const next = toggleForgeAttachment([], issueItem); expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]); }); it("appends a GitHub PR when not already attached", () => { - const next = toggleGithubAttachment([], prItem); + const next = toggleForgeAttachment([], prItem); expect(next).toEqual([{ kind: "forge_change_request", item: prItem }]); }); it("removes an existing GitHub item with the same kind+number", () => { - const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem); + const next = toggleForgeAttachment([{ kind: "github_issue", item: issueItem }], issueItem); expect(next).toEqual([]); }); @@ -983,7 +983,7 @@ describe("toggleGithubAttachment", () => { { kind: "github_pr", item: prItem }, ]; const otherIssue: ForgeSearchItem = { ...issueItem, number: 999 }; - const next = toggleGithubAttachment(start, otherIssue); + const next = toggleForgeAttachment(start, otherIssue); expect(next).toEqual([ { kind: "github_issue", item: issueItem }, { kind: "github_pr", item: prItem }, @@ -992,41 +992,41 @@ describe("toggleGithubAttachment", () => { }); }); -describe("toggleGithubAttachmentFromPicker", () => { +describe("toggleForgeAttachmentFromPicker", () => { it("marks an existing GitHub item as removed when picker toggle removes it", () => { - const markGithubAttachmentRemoved = vi.fn(); + const markForgeAttachmentRemoved = vi.fn(); const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem }; - const next = toggleGithubAttachmentFromPicker({ + const next = toggleForgeAttachmentFromPicker({ current: [attachment], item: prItem, - markGithubAttachmentRemoved, + markForgeAttachmentRemoved, }); expect(next).toEqual([]); - expect(markGithubAttachmentRemoved).toHaveBeenCalledTimes(1); - expect(markGithubAttachmentRemoved).toHaveBeenCalledWith(attachment); + expect(markForgeAttachmentRemoved).toHaveBeenCalledTimes(1); + expect(markForgeAttachmentRemoved).toHaveBeenCalledWith(attachment); }); it("does not mark a GitHub item removed when picker toggle adds it", () => { - const markGithubAttachmentRemoved = vi.fn(); + const markForgeAttachmentRemoved = vi.fn(); - const next = toggleGithubAttachmentFromPicker({ + const next = toggleForgeAttachmentFromPicker({ current: [], item: issueItem, - markGithubAttachmentRemoved, + markForgeAttachmentRemoved, }); expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]); - expect(markGithubAttachmentRemoved).not.toHaveBeenCalled(); + expect(markForgeAttachmentRemoved).not.toHaveBeenCalled(); }); }); -describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => { +describe("findForgeItemByOption / isAttachmentSelectedForForgeItem", () => { it("locates items via their composite kind:number id", () => { - expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem); - expect(findGithubItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem); - expect(findGithubItemByOption([issueItem], "change_request:404")).toBeUndefined(); + expect(findForgeItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem); + expect(findForgeItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem); + expect(findForgeItemByOption([issueItem], "change_request:404")).toBeUndefined(); }); it("recognizes when an attachment list already contains a matching GitHub item", () => { @@ -1035,7 +1035,7 @@ describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => { { kind: "github_issue", item: issueItem }, reviewWorkspaceAttachment("ignored"), ]; - expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true); - expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false); + expect(isAttachmentSelectedForForgeItem(attachments, issueItem)).toBe(true); + expect(isAttachmentSelectedForForgeItem(attachments, prItem)).toBe(false); }); }); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index adf331dfe9..d020e7b6b0 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -356,7 +356,9 @@ function isForgeAttachment( return ( attachment.kind === "forge_issue" || attachment.kind === "forge_change_request" || - // COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106 + // COMPAT(githubAttachmentKinds): accept legacy persisted attachment kinds + // until 2027-01-17, when supported floors are >= v0.2.0 and old drafts no + // longer require them. attachment.kind === "github_issue" || attachment.kind === "github_pr" ); @@ -376,17 +378,17 @@ export function toggleForgeAttachment( return [...current, buildForgeAttachment(item)]; } -interface ToggleGithubAttachmentFromPickerInput { +interface ToggleForgeAttachmentFromPickerInput { current: UserComposerAttachment[]; item: ForgeSearchItem; - markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void; + markForgeAttachmentRemoved: (attachment: UserComposerAttachment) => void; } -export function toggleGithubAttachmentFromPicker({ +export function toggleForgeAttachmentFromPicker({ current, item, - markGithubAttachmentRemoved, -}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] { + markForgeAttachmentRemoved, +}: ToggleForgeAttachmentFromPickerInput): UserComposerAttachment[] { const existingAttachment = current.find( (attachment) => isForgeAttachment(attachment) && @@ -394,19 +396,19 @@ export function toggleGithubAttachmentFromPicker({ attachment.item.number === item.number, ); if (existingAttachment) { - markGithubAttachmentRemoved(existingAttachment); + markForgeAttachmentRemoved(existingAttachment); } return toggleForgeAttachment(current, item); } -export function findGithubItemByOption( +export function findForgeItemByOption( items: readonly ForgeSearchItem[], optionId: string, ): ForgeSearchItem | undefined { return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId); } -export function isAttachmentSelectedForGithubItem( +export function isAttachmentSelectedForForgeItem( current: readonly ComposerAttachment[], item: ForgeSearchItem, ): boolean { @@ -417,5 +419,3 @@ export function isAttachmentSelectedForGithubItem( attachment.item.number === item.number, ); } - -export const toggleGithubAttachment = toggleForgeAttachment; diff --git a/packages/app/src/composer/attachments/submit.ts b/packages/app/src/composer/attachments/submit.ts index cedce553bd..d2eb008d43 100644 --- a/packages/app/src/composer/attachments/submit.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -21,7 +21,9 @@ interface SplitComposerAttachmentsOptions { export function resolveComposerAttachmentSubmitFormat(input: { supportsForgeAttachments?: boolean; }): ComposerAttachmentSubmitFormat { - // COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28. + // COMPAT(githubAttachmentKinds): emit legacy GitHub attachments for daemons + // predating forge-neutral attachments. Remove after 2027-01-17 once the + // supported daemon floor is >= v0.2.0. return input.supportsForgeAttachments === false ? "legacy-github" : "forge"; } @@ -34,7 +36,9 @@ export function splitComposerAttachmentsForSubmit( } { const images: ImageAttachment[] = []; const agentAttachments: AgentAttachment[] = []; - // COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28. + // COMPAT(githubAttachmentKinds): emit legacy GitHub attachments for daemons + // predating forge-neutral attachments. Remove after 2027-01-17 once the + // supported daemon floor is >= v0.2.0. const buildSearchAttachment = options.format === "legacy-github" ? buildLegacyGitHubAttachmentFromSearchItem diff --git a/packages/app/src/composer/github/auto-attach.test.tsx b/packages/app/src/composer/forge-auto-attach.test.tsx similarity index 80% rename from packages/app/src/composer/github/auto-attach.test.tsx rename to packages/app/src/composer/forge-auto-attach.test.tsx index 2abb55ba36..6027ca6d82 100644 --- a/packages/app/src/composer/github/auto-attach.test.tsx +++ b/packages/app/src/composer/forge-auto-attach.test.tsx @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from "vitest"; import type { UserComposerAttachment } from "@/attachments/types"; import type { ForgeSearchClient } from "@/git/use-forge-search-query"; import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages"; -import { useComposerGithubAutoAttach } from "./auto-attach"; +import { useComposerForgeAutoAttach } from "./forge-auto-attach"; type ForgeSearchPayload = ForgeSearchResponse["payload"]; @@ -48,6 +48,31 @@ const issue202: ForgeSearchItem = { headRefName: null, }; +const gitlabMr73: ForgeSearchItem = { + forge: "gitlab", + kind: "change_request", + number: 73, + title: "Attach MR", + url: "https://gitlab.com/acme/paseo/-/merge_requests/73", + state: "opened", + body: null, + labels: [], + projectPath: "acme/paseo", + baseRefName: "main", + headRefName: "feature", +}; + +const giteaIssue27: ForgeSearchItem = { + forge: "gitea", + kind: "issue", + number: 27, + title: "Attach Gitea issue", + url: "https://gitea.example.com/acme/paseo/issues/27", + state: "open", + body: null, + labels: [], +}; + interface SearchCall { cwd: string; query: string; @@ -58,8 +83,8 @@ interface HarnessInput { initialAttachments?: UserComposerAttachment[]; initialCwd?: string; initialText?: string; - onPullRequestDetected?: () => void; - onPullRequestAdded?: (item: ForgeSearchItem) => void; + onChangeRequestDetected?: () => void; + onChangeRequestAdded?: (item: ForgeSearchItem) => void; remote?: string | null; } @@ -111,7 +136,7 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { const [attachments, setAttachments] = useState( input.initialAttachments ?? [], ); - const autoAttach = useComposerGithubAutoAttach({ + const autoAttach = useComposerForgeAutoAttach({ text, remoteUrl: input.remote ?? remoteUrl, attachments, @@ -120,8 +145,8 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { serverId: "server-1", cwd: workingDirectory, setAttachments, - onPullRequestDetected: input.onPullRequestDetected, - onPullRequestAdded: input.onPullRequestAdded, + onChangeRequestDetected: input.onChangeRequestDetected, + onChangeRequestAdded: input.onChangeRequestAdded, }); return { @@ -132,7 +157,7 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { attachments, setAttachments, isResolving: autoAttach.isResolving, - markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved, + markForgeAttachmentRemoved: autoAttach.markForgeAttachmentRemoved, }; } @@ -143,12 +168,12 @@ async function flushDebounce() { }); } -describe("useComposerGithubAutoAttach", () => { +describe("useComposerForgeAutoAttach", () => { it("adds a matching pasted GitHub PR URL as a composer attachment", async () => { vi.useFakeTimers(); const client = createSearchClient([pr101]); - const onPullRequestDetected = vi.fn(); - const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), { + const onChangeRequestDetected = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onChangeRequestDetected }), { wrapper: createWrapper(), }); @@ -156,7 +181,7 @@ describe("useComposerGithubAutoAttach", () => { result.current.setText("Please review https://github.com/acme/paseo/pull/101"); }); expect(result.current.isResolving).toBe(true); - expect(onPullRequestDetected).toHaveBeenCalledTimes(1); + expect(onChangeRequestDetected).toHaveBeenCalledTimes(1); await flushDebounce(); expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); @@ -165,6 +190,56 @@ describe("useComposerGithubAutoAttach", () => { vi.useRealTimers(); }); + it("adds a matching pasted GitLab MR URL as a composer attachment", async () => { + vi.useFakeTimers(); + const client = createSearchClient([gitlabMr73]); + const onChangeRequestDetected = vi.fn(); + const onChangeRequestAdded = vi.fn(); + const { result } = renderHook( + () => + useHarness(client, { + onChangeRequestDetected, + onChangeRequestAdded, + remote: "git@gitlab.com:acme/paseo.git", + }), + { wrapper: createWrapper() }, + ); + + act(() => { + result.current.setText("Review https://gitlab.com/acme/paseo/-/merge_requests/73/diffs"); + }); + await flushDebounce(); + + expect(result.current.attachments).toEqual([ + { kind: "forge_change_request", item: gitlabMr73 }, + ]); + expect(onChangeRequestDetected).toHaveBeenCalledTimes(1); + expect(onChangeRequestAdded).toHaveBeenCalledWith(gitlabMr73); + expect(client.calls).toEqual([{ cwd, query: "73", limit: 20 }]); + vi.useRealTimers(); + }); + + it("adds a matching pasted self-hosted Gitea issue URL", async () => { + vi.useFakeTimers(); + const client = createSearchClient([giteaIssue27]); + const { result } = renderHook( + () => + useHarness(client, { + remote: "git@gitea.example.com:acme/paseo.git", + }), + { wrapper: createWrapper() }, + ); + + act(() => { + result.current.setText("See https://gitea.example.com/acme/paseo/issues/27"); + }); + await flushDebounce(); + + expect(result.current.attachments).toEqual([{ kind: "forge_issue", item: giteaIssue27 }]); + expect(client.calls).toEqual([{ cwd, query: "27", limit: 20 }]); + vi.useRealTimers(); + }); + it("ignores URLs that do not match the current remote", async () => { vi.useFakeTimers(); const client = createSearchClient([pr101]); @@ -207,7 +282,7 @@ describe("useComposerGithubAutoAttach", () => { }); act(() => { - result.current.markGithubAttachmentRemoved(initialAttachments[0]); + result.current.markForgeAttachmentRemoved(initialAttachments[0]); result.current.setAttachments([]); result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101"); }); @@ -251,8 +326,8 @@ describe("useComposerGithubAutoAttach", () => { .mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(secondLookup.promise), }; - const onPullRequestAdded = vi.fn(); - const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), { + const onChangeRequestAdded = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), { wrapper: createWrapper(), }); @@ -267,7 +342,7 @@ describe("useComposerGithubAutoAttach", () => { secondLookup.resolve(githubPayload([pr202], "search-202")); await Promise.resolve(); }); - expect(onPullRequestAdded).not.toHaveBeenCalled(); + expect(onChangeRequestAdded).not.toHaveBeenCalled(); expect(result.current.attachments).toEqual([]); await act(async () => { @@ -275,7 +350,7 @@ describe("useComposerGithubAutoAttach", () => { await Promise.resolve(); }); await vi.waitFor(() => { - expect(onPullRequestAdded.mock.calls).toEqual([[pr101], [pr202]]); + expect(onChangeRequestAdded.mock.calls).toEqual([[pr101], [pr202]]); expect(result.current.attachments).toEqual([ { kind: "forge_change_request", item: pr101 }, { kind: "forge_change_request", item: pr202 }, @@ -294,8 +369,8 @@ describe("useComposerGithubAutoAttach", () => { .mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(secondLookup.promise), }; - const onPullRequestAdded = vi.fn(); - const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), { + const onChangeRequestAdded = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), { wrapper: createWrapper(), }); @@ -315,7 +390,7 @@ describe("useComposerGithubAutoAttach", () => { secondLookup.resolve(githubPayload([pr202], "search-202")); await Promise.resolve(); }); - expect(onPullRequestAdded).not.toHaveBeenCalled(); + expect(onChangeRequestAdded).not.toHaveBeenCalled(); expect(result.current.attachments).toEqual([]); expect(result.current.isResolving).toBe(true); @@ -324,7 +399,7 @@ describe("useComposerGithubAutoAttach", () => { await Promise.resolve(); }); await vi.waitFor(() => { - expect(onPullRequestAdded.mock.calls).toEqual([[pr101], [pr202]]); + expect(onChangeRequestAdded.mock.calls).toEqual([[pr101], [pr202]]); expect(result.current.attachments).toEqual([ { kind: "forge_change_request", item: pr101 }, { kind: "forge_change_request", item: pr202 }, @@ -338,8 +413,8 @@ describe("useComposerGithubAutoAttach", () => { it("uses the latest pull request order when URLs are reordered during debounce", async () => { vi.useFakeTimers(); const client = createSearchClient([pr101, pr202]); - const onPullRequestAdded = vi.fn(); - const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), { + const onChangeRequestAdded = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), { wrapper: createWrapper(), }); @@ -355,7 +430,7 @@ describe("useComposerGithubAutoAttach", () => { }); await flushDebounce(); - expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]); + expect(onChangeRequestAdded.mock.calls).toEqual([[pr202], [pr101]]); expect(client.calls).toEqual([ { cwd, query: "202", limit: 20 }, { cwd, query: "101", limit: 20 }, @@ -373,8 +448,8 @@ describe("useComposerGithubAutoAttach", () => { .mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(secondLookup.promise), }; - const onPullRequestAdded = vi.fn(); - const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), { + const onChangeRequestAdded = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), { wrapper: createWrapper(), }); @@ -395,7 +470,7 @@ describe("useComposerGithubAutoAttach", () => { firstLookup.resolve(githubPayload([pr101], "search-101")); await Promise.resolve(); }); - expect(onPullRequestAdded).not.toHaveBeenCalled(); + expect(onChangeRequestAdded).not.toHaveBeenCalled(); expect(result.current.attachments).toEqual([]); expect(result.current.isResolving).toBe(true); @@ -404,7 +479,7 @@ describe("useComposerGithubAutoAttach", () => { await Promise.resolve(); }); await vi.waitFor(() => { - expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]); + expect(onChangeRequestAdded.mock.calls).toEqual([[pr202], [pr101]]); expect(result.current.attachments).toEqual([ { kind: "forge_change_request", item: pr202 }, { kind: "forge_change_request", item: pr101 }, @@ -425,8 +500,8 @@ describe("useComposerGithubAutoAttach", () => { .mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(secondLookup.promise), }; - const onPullRequestAdded = vi.fn(); - const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), { + const onChangeRequestAdded = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), { wrapper: createWrapper(), }); @@ -451,7 +526,7 @@ describe("useComposerGithubAutoAttach", () => { await Promise.resolve(); }); await vi.waitFor(() => { - expect(onPullRequestAdded.mock.calls).toEqual([[pr101]]); + expect(onChangeRequestAdded.mock.calls).toEqual([[pr101]]); }); vi.useRealTimers(); }); @@ -462,8 +537,8 @@ describe("useComposerGithubAutoAttach", () => { const client: ForgeSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; - const onPullRequestAdded = vi.fn(); - const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), { + const onChangeRequestAdded = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), { wrapper: createWrapper(), }); @@ -491,7 +566,7 @@ describe("useComposerGithubAutoAttach", () => { await vi.waitFor(() => { expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); expect(result.current.isResolving).toBe(false); - expect(onPullRequestAdded.mock.calls).toEqual([[pr101]]); + expect(onChangeRequestAdded.mock.calls).toEqual([[pr101]]); }); vi.useRealTimers(); }); @@ -506,8 +581,8 @@ describe("useComposerGithubAutoAttach", () => { .mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(secondLookup.promise), }; - const onPullRequestAdded = vi.fn(); - const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), { + const onChangeRequestAdded = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), { wrapper: createWrapper(), }); @@ -522,7 +597,7 @@ describe("useComposerGithubAutoAttach", () => { secondLookup.resolve(githubPayload([pr202], "search-202")); await Promise.resolve(); }); - expect(onPullRequestAdded).not.toHaveBeenCalled(); + expect(onChangeRequestAdded).not.toHaveBeenCalled(); expect(result.current.isResolving).toBe(true); act(() => { @@ -531,7 +606,7 @@ describe("useComposerGithubAutoAttach", () => { await flushDebounce(); await vi.waitFor(() => { - expect(onPullRequestAdded.mock.calls).toEqual([[pr202]]); + expect(onChangeRequestAdded.mock.calls).toEqual([[pr202]]); expect(result.current.isResolving).toBe(false); }); diff --git a/packages/app/src/composer/github/auto-attach.ts b/packages/app/src/composer/forge-auto-attach.ts similarity index 66% rename from packages/app/src/composer/github/auto-attach.ts rename to packages/app/src/composer/forge-auto-attach.ts index 5cb19e47f2..1d1ddd206d 100644 --- a/packages/app/src/composer/github/auto-attach.ts +++ b/packages/app/src/composer/forge-auto-attach.ts @@ -11,13 +11,13 @@ import { } from "react"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query"; -import { extractGithubRefs, type GithubRef } from "@/utils/github-refs"; +import { extractForgeRefs, type ForgeRef } from "@/git/forge-refs"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; -import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions"; +import { isAttachmentSelectedForForgeItem, toggleForgeAttachment } from "./actions"; const AUTO_ATTACH_DEBOUNCE_MS = 300; -interface ComposerGithubAutoAttachInput { +interface ComposerForgeAutoAttachInput { text: string; remoteUrl: string | null | undefined; attachments: UserComposerAttachment[]; @@ -27,29 +27,29 @@ interface ComposerGithubAutoAttachInput { cwd: string; supportsForgeSearch?: boolean; setAttachments: Dispatch>; - onPullRequestDetected?: () => void; - onPullRequestAdded?: (item: ForgeSearchItem) => void; + onChangeRequestDetected?: () => void; + onChangeRequestAdded?: (item: ForgeSearchItem) => void; } -interface ComposerGithubAutoAttachResult { +interface ComposerForgeAutoAttachResult { isResolving: boolean; - markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void; + markForgeAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void; } -interface ActiveGithubLookup { +interface ActiveForgeLookup { invalidate: (keys: readonly string[]) => void; - invalidateIrrelevant: (current: ComposerGithubAutoAttachInput) => void; + invalidateIrrelevant: (current: ComposerForgeAutoAttachInput) => void; hasPending: (key: string) => boolean; } -export function useComposerGithubAutoAttach( - params: ComposerGithubAutoAttachInput, -): ComposerGithubAutoAttachResult { +export function useComposerForgeAutoAttach( + params: ComposerForgeAutoAttachInput, +): ComposerForgeAutoAttachResult { const queryClient = useQueryClient(); const latestRef = useRef(params); const removedRefKeysRef = useRef(new Set()); - const presentPullRequestKeysRef = useRef(new Set()); - const activeLookupsRef = useRef(new Set()); + const presentChangeRequestKeysRef = useRef(new Set()); + const activeLookupsRef = useRef(new Set()); const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd }); const [resolvingRefCounts, setResolvingRefCounts] = useState>( () => new Map(), @@ -58,15 +58,15 @@ export function useComposerGithubAutoAttach( latestRef.current = params; const lookupCandidateKey = getLookupCandidateKey(params, removedRefKeysRef.current); const lookupRelevanceKey = getLookupRelevanceKey(params, removedRefKeysRef.current); - const presentPullRequestKey = getPresentPullRequestKey(params); + const presentChangeRequestKey = getPresentChangeRequestKey(params); const hasClient = params.client !== null; useEffect(() => { - notifyNewPullRequestRefs({ + notifyNewChangeRequestRefs({ params: latestRef.current, - presentPullRequestKeysRef, + presentChangeRequestKeysRef, }); - }, [presentPullRequestKey]); + }, [presentChangeRequestKey]); useEffect(() => { const current = latestRef.current; @@ -99,11 +99,11 @@ export function useComposerGithubAutoAttach( return; } - const refKeys = refs.map(githubRefKey); + const refKeys = refs.map(forgeRefKey); setResolvingRefCounts((current) => addKeys(current, refKeys)); const unreleasedRefKeys = new Set(refKeys); let lookupStarted = false; - let lookup: ActiveGithubLookup | null = null; + let lookup: ActiveForgeLookup | null = null; const releaseResolving = (keys: readonly string[]) => { const keysToRelease = keys.filter((key) => unreleasedRefKeys.delete(key)); if (keysToRelease.length === 0) return; @@ -147,7 +147,7 @@ export function useComposerGithubAutoAttach( queryClient, ]); - const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => { + const markForgeAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => { const key = attachmentKey(attachment); if (key) { removedRefKeysRef.current.add(key); @@ -157,55 +157,55 @@ export function useComposerGithubAutoAttach( return useMemo( () => ({ isResolving: resolvingRefCounts.size > 0, - markGithubAttachmentRemoved, + markForgeAttachmentRemoved, }), - [markGithubAttachmentRemoved, resolvingRefCounts.size], + [markForgeAttachmentRemoved, resolvingRefCounts.size], ); } function getLookupCandidateKey( - params: ComposerGithubAutoAttachInput, + params: ComposerForgeAutoAttachInput, removedRefKeys: ReadonlySet, ): string { - return getLookupCandidateRefs(params, removedRefKeys).map(githubRefKey).join("|"); + return getLookupCandidateRefs(params, removedRefKeys).map(forgeRefKey).join("|"); } function getLookupCandidateRefs( - params: ComposerGithubAutoAttachInput, + params: ComposerForgeAutoAttachInput, removedRefKeys: ReadonlySet, -): GithubRef[] { - return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => { - const key = githubRefKey(ref); - return !removedRefKeys.has(key) && !hasGithubAttachment(params.attachments, ref); +): ForgeRef[] { + return extractForgeRefs(params.text, params.remoteUrl).filter((ref) => { + const key = forgeRefKey(ref); + return !removedRefKeys.has(key) && !hasForgeAttachment(params.attachments, ref); }); } function didLookupSourceOrderChange( originalKeys: readonly string[], - current: ComposerGithubAutoAttachInput, + current: ComposerForgeAutoAttachInput, removedRefKeys: ReadonlySet, ): boolean { - const currentKeys = getLookupCandidateRefs(current, removedRefKeys).map(githubRefKey); + const currentKeys = getLookupCandidateRefs(current, removedRefKeys).map(forgeRefKey); const currentKeySet = new Set(currentKeys); const retainedOriginalKeys = originalKeys.filter((key) => currentKeySet.has(key)); return currentKeys.join("|") !== retainedOriginalKeys.join("|"); } function getLookupRelevanceKey( - params: ComposerGithubAutoAttachInput, + params: ComposerForgeAutoAttachInput, removedRefKeys: ReadonlySet, ): string { - return extractGithubRefs(params.text, params.remoteUrl) - .map(githubRefKey) + return extractForgeRefs(params.text, params.remoteUrl) + .map(forgeRefKey) .filter((key) => !removedRefKeys.has(key)) .sort() .join("|"); } -function getPresentPullRequestKey(params: ComposerGithubAutoAttachInput): string { - return extractGithubRefs(params.text, params.remoteUrl) - .filter((ref) => ref.kind === "pull") - .map(githubRefKey) +function getPresentChangeRequestKey(params: ComposerForgeAutoAttachInput): string { + return extractForgeRefs(params.text, params.remoteUrl) + .filter((ref) => ref.kind === "change_request") + .map(forgeRefKey) .sort() .join("|"); } @@ -216,15 +216,15 @@ function isLookupContextStillRelevant({ current, removedRefKeys, }: { - ref: GithubRef; - initial: ComposerGithubAutoAttachInput; - current: ComposerGithubAutoAttachInput; + ref: ForgeRef; + initial: ComposerForgeAutoAttachInput; + current: ComposerForgeAutoAttachInput; removedRefKeys: ReadonlySet; }): boolean { return ( current.client !== null && current.isConnected && - !removedRefKeys.has(githubRefKey(ref)) && + !removedRefKeys.has(forgeRefKey(ref)) && isSameLookupTarget(initial, current) && isRefStillPresent(ref, current) ); @@ -235,7 +235,7 @@ function suppressRefsCarriedAcrossTargets({ previousTargetRef, removedRefKeys, }: { - params: ComposerGithubAutoAttachInput; + params: ComposerForgeAutoAttachInput; previousTargetRef: RefObject<{ serverId: string; cwd: string }>; removedRefKeys: Set; }): void { @@ -247,29 +247,29 @@ function suppressRefsCarriedAcrossTargets({ previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd }; if (!targetChanged) return; - for (const ref of extractGithubRefs(params.text, params.remoteUrl)) { - removedRefKeys.add(githubRefKey(ref)); + for (const ref of extractForgeRefs(params.text, params.remoteUrl)) { + removedRefKeys.add(forgeRefKey(ref)); } } -function notifyNewPullRequestRefs({ +function notifyNewChangeRequestRefs({ params, - presentPullRequestKeysRef, + presentChangeRequestKeysRef, }: { - params: ComposerGithubAutoAttachInput; - presentPullRequestKeysRef: RefObject>; + params: ComposerForgeAutoAttachInput; + presentChangeRequestKeysRef: RefObject>; }): void { const currentKeys = new Set( - extractGithubRefs(params.text, params.remoteUrl) - .filter((ref) => ref.kind === "pull") - .map(githubRefKey), + extractForgeRefs(params.text, params.remoteUrl) + .filter((ref) => ref.kind === "change_request") + .map(forgeRefKey), ); for (const key of currentKeys) { - if (!presentPullRequestKeysRef.current.has(key)) { - params.onPullRequestDetected?.(); + if (!presentChangeRequestKeysRef.current.has(key)) { + params.onChangeRequestDetected?.(); } } - presentPullRequestKeysRef.current = currentKeys; + presentChangeRequestKeysRef.current = currentKeys; } function addKeys( @@ -310,14 +310,14 @@ function attachRefs({ onSettled, onComplete, }: { - refs: GithubRef[]; - initial: ComposerGithubAutoAttachInput; + refs: ForgeRef[]; + initial: ComposerForgeAutoAttachInput; queryClient: QueryClient; - latestRef: RefObject; + latestRef: RefObject; removedRefKeys: Set; onSettled: (key: string) => void; - onComplete: (lookup: ActiveGithubLookup) => void; -}): ActiveGithubLookup { + onComplete: (lookup: ActiveForgeLookup) => void; +}): ActiveForgeLookup { const outcomes = refs.map(() => ({ settled: false, item: null as ForgeSearchItem | null, @@ -332,16 +332,16 @@ function attachRefs({ if (item) { latestRef.current.setAttachments((attachments) => { if ( - removedRefKeys.has(githubRefKey(ref)) || - isAttachmentSelectedForGithubItem(attachments, item) + removedRefKeys.has(forgeRefKey(ref)) || + isAttachmentSelectedForForgeItem(attachments, item) ) { return attachments; } - return toggleGithubAttachment(attachments, item); + return toggleForgeAttachment(attachments, item); }); } if (item?.kind === "change_request") { - latestRef.current.onPullRequestAdded?.(item); + latestRef.current.onChangeRequestAdded?.(item); } } if (!didComplete && nextOutcomeIndex === outcomes.length) { @@ -357,7 +357,7 @@ function attachRefs({ }; refs.forEach((ref, index) => { - const key = githubRefKey(ref); + const key = forgeRefKey(ref); void attachRef({ ref, key, queryClient, latestRef, removedRefKeys }) .then((item) => settleOutcome(index, item)) .finally(() => { @@ -365,12 +365,12 @@ function attachRefs({ }); }); - const lookup: ActiveGithubLookup = { + const lookup: ActiveForgeLookup = { invalidate(keys) { const invalidKeys = new Set(keys); const newlySettledKeys: string[] = []; refs.forEach((ref, index) => { - const key = githubRefKey(ref); + const key = forgeRefKey(ref); if (!invalidKeys.has(key) || index < nextOutcomeIndex) return; if (!outcomes[index].settled) newlySettledKeys.push(key); outcomes[index] = { settled: true, item: null }; @@ -382,14 +382,14 @@ function attachRefs({ lookup.invalidate( refs.flatMap((ref, index) => isLookupContextStillRelevant({ ref, initial, current, removedRefKeys }) && - (outcomes[index].settled || !hasGithubAttachment(current.attachments, ref)) + (outcomes[index].settled || !hasForgeAttachment(current.attachments, ref)) ? [] - : [githubRefKey(ref)], + : [forgeRefKey(ref)], ), ); }, hasPending(key) { - const index = refs.findIndex((ref) => githubRefKey(ref) === key); + const index = refs.findIndex((ref) => forgeRefKey(ref) === key); return index >= 0 && !outcomes[index].settled; }, }; @@ -403,10 +403,10 @@ async function attachRef({ latestRef, removedRefKeys, }: { - ref: GithubRef; + ref: ForgeRef; key: string; queryClient: QueryClient; - latestRef: RefObject; + latestRef: RefObject; removedRefKeys: Set; }): Promise { const snapshot = latestRef.current; @@ -414,11 +414,11 @@ async function attachRef({ return null; } - const search = await fetchGithubRefSearch({ ref, snapshot, queryClient }); + const search = await fetchForgeRefSearch({ ref, snapshot, queryClient }); if (!search) { return null; } - const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref)); + const item = search.items.find((candidate) => forgeItemMatchesRef(candidate, ref)); const current = latestRef.current; if ( !item || @@ -429,7 +429,7 @@ async function attachRef({ return null; } - if (isAttachmentSelectedForGithubItem(current.attachments, item)) { + if (isAttachmentSelectedForForgeItem(current.attachments, item)) { return null; } return item; @@ -440,31 +440,31 @@ function refsReadyForLookup({ removedRefKeys, activeLookups, }: { - params: ComposerGithubAutoAttachInput; + params: ComposerForgeAutoAttachInput; removedRefKeys: Set; - activeLookups: ReadonlySet; -}): GithubRef[] { + activeLookups: ReadonlySet; +}): ForgeRef[] { if (!params.client || !params.isConnected || params.cwd.trim().length === 0) { return []; } - return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => { - const key = githubRefKey(ref); + return extractForgeRefs(params.text, params.remoteUrl).filter((ref) => { + const key = forgeRefKey(ref); return ( !removedRefKeys.has(key) && ![...activeLookups].some((lookup) => lookup.hasPending(key)) && - !hasGithubAttachment(params.attachments, ref) + !hasForgeAttachment(params.attachments, ref) ); }); } -async function fetchGithubRefSearch({ +async function fetchForgeRefSearch({ ref, snapshot, queryClient, }: { - ref: GithubRef; - snapshot: ComposerGithubAutoAttachInput; + ref: ForgeRef; + snapshot: ComposerForgeAutoAttachInput; queryClient: QueryClient; }) { if (!snapshot.client) { @@ -487,15 +487,15 @@ async function fetchGithubRefSearch({ } } -function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput): boolean { - return extractGithubRefs(params.text, params.remoteUrl).some( - (candidate) => githubRefKey(candidate) === githubRefKey(ref), +function isRefStillPresent(ref: ForgeRef, params: ComposerForgeAutoAttachInput): boolean { + return extractForgeRefs(params.text, params.remoteUrl).some( + (candidate) => forgeRefKey(candidate) === forgeRefKey(ref), ); } function isSameLookupTarget( - initial: ComposerGithubAutoAttachInput, - current: ComposerGithubAutoAttachInput, + initial: ComposerForgeAutoAttachInput, + current: ComposerForgeAutoAttachInput, ): boolean { return ( initial.serverId === current.serverId && @@ -504,30 +504,28 @@ function isSameLookupTarget( ); } -function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean { - return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref)); +function hasForgeAttachment(attachments: UserComposerAttachment[], ref: ForgeRef): boolean { + return attachments.some((attachment) => attachmentKey(attachment) === forgeRefKey(ref)); } -function githubItemMatchesRef(item: ForgeSearchItem, ref: GithubRef): boolean { - return item.kind === githubItemKind(ref) && item.number === ref.number; +function forgeItemMatchesRef(item: ForgeSearchItem, ref: ForgeRef): boolean { + return item.kind === forgeItemKind(ref) && item.number === ref.number; } -function githubItemKind(ref: GithubRef): ForgeSearchItem["kind"] { - return ref.kind === "pull" ? "change_request" : "issue"; +function forgeItemKind(ref: ForgeRef): ForgeSearchItem["kind"] { + return ref.kind === "change_request" ? "change_request" : "issue"; } -function githubRefKey(ref: GithubRef): string { - return `${githubItemKind(ref)}:${ref.number}`; +function forgeRefKey(ref: ForgeRef): string { + return `${forgeItemKind(ref)}:${ref.number}`; } function attachmentKey(attachment: ComposerAttachment | undefined): string | null { if ( - !attachment || - attachment.kind === "image" || - (attachment.kind !== "forge_change_request" && - attachment.kind !== "forge_issue" && - attachment.kind !== "github_pr" && - attachment.kind !== "github_issue") + attachment?.kind !== "forge_change_request" && + attachment?.kind !== "forge_issue" && + attachment?.kind !== "github_pr" && + attachment?.kind !== "github_issue" ) { return null; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a8f43a3e81..b20ffc9a9e 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -62,14 +62,14 @@ import { cancelComposerAgent, dispatchComposerAgentMessage, editQueuedComposerMessage, - findGithubItemByOption, - isAttachmentSelectedForGithubItem, + findForgeItemByOption, + isAttachmentSelectedForForgeItem, openComposerAttachment, pickAndPersistImages, queueComposerMessage, removeComposerAttachmentAtIndex, sendQueuedComposerMessageNow, - toggleGithubAttachmentFromPicker, + toggleForgeAttachmentFromPicker, uploadFileAttachments, type AttachmentPersister, type QueueWriter, @@ -132,7 +132,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { getForgePresentation } from "@/git/forge"; import { ForgeBrandIcon } from "@/git/forge-icon"; -import { useComposerGithubAutoAttach } from "./github/auto-attach"; +import { useComposerForgeAutoAttach } from "./forge-auto-attach"; import { readClipboardImage } from "./clipboard-image"; import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image"; import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins"; @@ -924,8 +924,8 @@ interface ComposerProps { submitIcon?: "arrow" | "return"; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; - /** When true, waits for pasted GitHub links to resolve before enabling submit. */ - waitForGithubAutoAttachOnSubmit?: boolean; + /** When true, waits for pasted forge links to resolve before enabling submit. */ + waitForForgeAutoAttachOnSubmit?: boolean; submitBehavior?: "clear" | "preserve-and-lock"; /** When true, blurs the input immediately when submitting. */ blurOnSubmit?: boolean; @@ -936,8 +936,8 @@ interface ComposerProps { attachmentScopeKeys?: readonly string[]; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; onChangeAttachments: (updater: AttachmentListUpdater) => void; - onGithubPrDetected?: () => void; - onGithubPrAutoAttach?: (item: ForgeSearchItem) => void; + onForgeChangeRequestDetected?: () => void; + onForgeChangeRequestAutoAttach?: (item: ForgeSearchItem) => void; cwd: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; /** When true, auto-focuses the text input on web. */ @@ -1148,7 +1148,7 @@ function ComposerContentImpl({ submitButtonTestID, submitIcon = "arrow", isSubmitLoading = false, - waitForGithubAutoAttachOnSubmit = false, + waitForForgeAutoAttachOnSubmit = false, submitBehavior = "clear", blurOnSubmit = false, value, @@ -1158,8 +1158,8 @@ function ComposerContentImpl({ attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS, onOpenWorkspaceAttachment, onChangeAttachments, - onGithubPrDetected, - onGithubPrAutoAttach, + onForgeChangeRequestDetected, + onForgeChangeRequestAutoAttach, cwd, clearDraft, autoFocus = false, @@ -1235,7 +1235,7 @@ function ComposerContentImpl({ const supportsForgeSearch = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, ); - const githubAutoAttach = useComposerGithubAutoAttach({ + const forgeAutoAttach = useComposerForgeAutoAttach({ text: userInput, remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), attachments, @@ -1245,8 +1245,8 @@ function ComposerContentImpl({ cwd, supportsForgeSearch, setAttachments: setSelectedAttachments, - onPullRequestDetected: onGithubPrDetected, - onPullRequestAdded: onGithubPrAutoAttach, + onChangeRequestDetected: onForgeChangeRequestDetected, + onChangeRequestAdded: onForgeChangeRequestAutoAttach, }); const [cursorIndex, setCursorIndex] = useState(0); const cursorPublication = useMemo(() => new AfterPaintPublication(setCursorIndex), []); @@ -1734,7 +1734,7 @@ function ComposerContentImpl({ const handleRemoveAttachment = useCallback( (index: number) => { - githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]); + forgeAutoAttach.markForgeAttachmentRemoved(selectedAttachments[index]); const didRemoveWorkspaceAttachment = removeAttachment({ selectedAttachments, index, @@ -1746,7 +1746,7 @@ function ComposerContentImpl({ removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }), ); }, - [githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments], + [forgeAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments], ); const handleOpenAttachment = useCallback( @@ -2082,10 +2082,10 @@ function ComposerContentImpl({ const handleToggleGithubItem = useCallback( (item: ForgeSearchItem) => { - const nextAttachments = toggleGithubAttachmentFromPicker({ + const nextAttachments = toggleForgeAttachmentFromPicker({ current: attachments, item, - markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved, + markForgeAttachmentRemoved: forgeAutoAttach.markForgeAttachmentRemoved, }); setSelectedAttachments(nextAttachments); setIsGithubPickerOpen(false); @@ -2093,7 +2093,7 @@ function ComposerContentImpl({ }, [ attachments, - githubAutoAttach, + forgeAutoAttach, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen, @@ -2154,11 +2154,11 @@ function ComposerContentImpl({ const renderGithubPickerOption = useCallback( ({ option, active }: { option: ComboboxOption; selected: boolean; active: boolean }) => { - const item = findGithubItemByOption(githubSearchItems, option.id); + const item = findForgeItemByOption(githubSearchItems, option.id); if (!item) { return ; } - const selected = isAttachmentSelectedForGithubItem(selectedAttachments, item); + const selected = isAttachmentSelectedForForgeItem(selectedAttachments, item); return ( 0; const isSubmitDisabled = - isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving); + isSubmitLoadingVisible || (waitForForgeAutoAttachOnSubmit && forgeAutoAttach.isResolving); // Disable drops while submitting/uploading: the submit path clears and restores attachments, // so a drop in that window would be lost or land on a locked draft. `disabled` hides the diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 853cd2ff83..0e38f04811 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -50,8 +50,9 @@ function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc { if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) { return "forge"; } - // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once - // all supported clients use checkout.forge.set_auto_merge.*. + // COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons that + // predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17 once the + // supported daemon floor is >= v0.2.0. if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) { return "github"; } @@ -298,8 +299,9 @@ export const useCheckoutGitActionsStore = create() actionId: `enable-pr-auto-merge-${method}`, run: async () => { const client = resolveClient(serverId); - // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once - // all supported clients use checkout.forge.set_auto_merge.*. + // COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons + // that predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17 + // once the supported daemon floor is >= v0.2.0. const payload = rpc === "forge" ? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method }) @@ -319,8 +321,9 @@ export const useCheckoutGitActionsStore = create() actionId: "disable-pr-auto-merge", run: async () => { const client = resolveClient(serverId); - // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once - // all supported clients use checkout.forge.set_auto_merge.*. + // COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons + // that predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17 + // once the supported daemon floor is >= v0.2.0. const payload = rpc === "forge" ? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false }) diff --git a/packages/app/src/git/check-presentation-copy.test.ts b/packages/app/src/git/check-presentation-copy.test.ts new file mode 100644 index 0000000000..7f9a0c6fbb --- /dev/null +++ b/packages/app/src/git/check-presentation-copy.test.ts @@ -0,0 +1,36 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { + formatCheckPresentationCountsLabel, + getCheckPresentationStatusLabel, +} from "./check-presentation-copy"; + +describe("check presentation copy", () => { + beforeAll(async () => { + await i18n.changeLanguage("en"); + }); + + it("formats counts in canonical presentation order and omits empty categories", () => { + expect( + formatCheckPresentationCountsLabel( + { + success: 2, + failure: 0, + warning: 1, + actionRequired: 1, + manual: 1, + pending: 0, + }, + "Checks", + i18n.t, + ), + ).toBe("Checks, Passed: 2, Warnings: 1, Action required: 1, Manual: 1"); + }); + + it("uses presentation policy for row status labels", () => { + expect( + getCheckPresentationStatusLabel({ status: "success", traits: ["warning"] }, i18n.t), + ).toBe("Warning"); + expect(getCheckPresentationStatusLabel({ status: "cancelled" }, i18n.t)).toBe("Cancelled"); + }); +}); diff --git a/packages/app/src/git/check-presentation-copy.ts b/packages/app/src/git/check-presentation-copy.ts new file mode 100644 index 0000000000..581d3a1ebf --- /dev/null +++ b/packages/app/src/git/check-presentation-copy.ts @@ -0,0 +1,55 @@ +import type { TFunction } from "i18next"; +import { + COUNTED_CHECK_PRESENTATIONS, + classifyCheck, + type CheckPresentationCounts, + type CountedCheckPresentation, + type PresentableCheck, +} from "./check-presentation"; + +const CHECK_PRESENTATION_COUNT_KEYS = { + success: "sidebar.workspace.checks.passed", + failure: "sidebar.workspace.checks.failed", + warning: "sidebar.workspace.checks.warning", + actionRequired: "sidebar.workspace.checks.actionRequired", + manual: "sidebar.workspace.checks.manual", + pending: "sidebar.workspace.checks.pending", +} as const satisfies Record; + +const CHECK_PRESENTATION_STATUS_KEYS = { + success: "workspace.git.pr.accessibility.checkStatus.passed", + failure: "workspace.git.pr.accessibility.checkStatus.failed", + warning: "workspace.git.pr.accessibility.checkStatus.warning", + actionRequired: "workspace.git.pr.accessibility.checkStatus.actionRequired", + manual: "workspace.git.pr.accessibility.checkStatus.manual", + pending: "workspace.git.pr.accessibility.checkStatus.pending", +} as const satisfies Record; + +export function getCheckPresentationCountLabel( + presentation: CountedCheckPresentation, + count: number, + t: TFunction, +): string { + return t(CHECK_PRESENTATION_COUNT_KEYS[presentation], { count }); +} + +export function formatCheckPresentationCountsLabel( + counts: CheckPresentationCounts, + heading: string, + t: TFunction, +): string { + const labels = COUNTED_CHECK_PRESENTATIONS.flatMap((presentation) => { + const count = counts[presentation]; + return count > 0 ? [getCheckPresentationCountLabel(presentation, count, t)] : []; + }); + return [heading, ...labels].join(", "); +} + +export function getCheckPresentationStatusLabel(check: PresentableCheck, t: TFunction): string { + const presentation = classifyCheck(check); + if (presentation !== "ignored") return t(CHECK_PRESENTATION_STATUS_KEYS[presentation]); + if (check.status === "cancelled") { + return t("workspace.git.pr.accessibility.checkStatus.cancelled"); + } + return t("workspace.git.pr.accessibility.checkStatus.skipped"); +} diff --git a/packages/app/src/git/check-presentation.test.ts b/packages/app/src/git/check-presentation.test.ts new file mode 100644 index 0000000000..5bf4dc981f --- /dev/null +++ b/packages/app/src/git/check-presentation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { classifyCheck, countCheckPresentations } from "./check-presentation"; + +describe("check presentation", () => { + it.each([ + [{ status: "failure", traits: ["warning", "action_required"] }, "actionRequired"], + [{ status: "pending", traits: ["manual", "action_required"] }, "actionRequired"], + [{ status: "success", traits: ["action_required"] }, "actionRequired"], + [{ status: "success", traits: ["warning"] }, "warning"], + [{ status: "skipped", traits: ["manual"] }, "manual"], + [{ status: "success" }, "success"], + [{ status: "failure" }, "failure"], + [{ status: "skipped" }, "ignored"], + [{ status: "cancelled" }, "ignored"], + [{ status: "pending" }, "pending"], + [{ status: "success", traits: ["future-forge-trait"] }, "success"], + [{ status: "future-provider-state" }, "pending"], + ] as const)("classifies %o as %s", (check, expected) => { + expect(classifyCheck(check)).toBe(expected); + }); + + it("counts mutually exclusive presentation categories", () => { + expect( + countCheckPresentations([ + { status: "success" }, + { status: "failure" }, + { status: "success", traits: ["warning"] }, + { status: "pending", traits: ["action_required"] }, + { status: "pending" }, + { status: "skipped", traits: ["manual"] }, + { status: "skipped" }, + ]), + ).toEqual({ + success: 1, + failure: 1, + warning: 1, + actionRequired: 1, + manual: 1, + pending: 1, + }); + }); +}); diff --git a/packages/app/src/git/check-presentation.ts b/packages/app/src/git/check-presentation.ts new file mode 100644 index 0000000000..aed6ef1ecb --- /dev/null +++ b/packages/app/src/git/check-presentation.ts @@ -0,0 +1,59 @@ +import { + CHECK_TRAIT_ACTION_REQUIRED, + CHECK_TRAIT_MANUAL, + CHECK_TRAIT_WARNING, +} from "@getpaseo/protocol/check-traits"; + +export interface PresentableCheck { + status: string; + traits?: readonly string[]; +} + +export const COUNTED_CHECK_PRESENTATIONS = [ + "success", + "failure", + "warning", + "actionRequired", + "manual", + "pending", +] as const; + +export type CountedCheckPresentation = (typeof COUNTED_CHECK_PRESENTATIONS)[number]; +export type CheckPresentation = CountedCheckPresentation | "ignored"; + +export const ATTENTION_CHECK_PRESENTATIONS = [ + "failure", + "warning", + "actionRequired", +] as const satisfies readonly CountedCheckPresentation[]; + +export type CheckPresentationCounts = Record; + +export function classifyCheck(check: PresentableCheck): CheckPresentation { + const traits = check.traits ?? []; + if (traits.includes(CHECK_TRAIT_ACTION_REQUIRED)) return "actionRequired"; + if (traits.includes(CHECK_TRAIT_WARNING)) return "warning"; + if (traits.includes(CHECK_TRAIT_MANUAL)) return "manual"; + if (check.status === "success") return "success"; + if (check.status === "failure") return "failure"; + if (check.status === "skipped" || check.status === "cancelled") return "ignored"; + return "pending"; +} + +export function countCheckPresentations( + checks: readonly PresentableCheck[], +): CheckPresentationCounts { + const counts: CheckPresentationCounts = { + success: 0, + failure: 0, + warning: 0, + actionRequired: 0, + manual: 0, + pending: 0, + }; + for (const check of checks) { + const presentation = classifyCheck(check); + if (presentation !== "ignored") counts[presentation] += 1; + } + return counts; +} diff --git a/packages/app/src/git/check-presentation.view.tsx b/packages/app/src/git/check-presentation.view.tsx new file mode 100644 index 0000000000..f9f2c9dfe8 --- /dev/null +++ b/packages/app/src/git/check-presentation.view.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from "react"; +import { withUnistyles } from "react-native-unistyles"; +import { CircleCheck, CircleDot, CircleSlash, CircleX } from "lucide-react-native"; +import { ManualStatusIcon } from "@/components/icons/manual-status-icon"; +import type { Theme } from "@/styles/theme"; +import type { CheckPresentation } from "./check-presentation"; + +const ThemedCircleCheck = withUnistyles(CircleCheck); +const ThemedCircleDot = withUnistyles(CircleDot); +const ThemedCircleSlash = withUnistyles(CircleSlash); +const ThemedCircleX = withUnistyles(CircleX); +const ThemedManualStatusIcon = withUnistyles(ManualStatusIcon); + +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess }); +const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); +const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning }); + +export type CheckPresentationTone = "success" | "danger" | "warning" | "muted"; + +interface CheckPresentationViewPolicy { + tone: CheckPresentationTone; + renderIcon: (size: number) => ReactNode; +} + +const CHECK_PRESENTATION_VIEW_POLICY: Record = { + success: { + tone: "success", + renderIcon(size) { + return ; + }, + }, + failure: { + tone: "danger", + renderIcon(size) { + return ; + }, + }, + warning: { + tone: "warning", + renderIcon(size) { + return ; + }, + }, + actionRequired: { + tone: "warning", + renderIcon(size) { + return ; + }, + }, + manual: { + tone: "muted", + renderIcon(size) { + return ; + }, + }, + pending: { + tone: "warning", + renderIcon(size) { + return ; + }, + }, + ignored: { + tone: "muted", + renderIcon(size) { + return ; + }, + }, +}; + +export function getCheckPresentationTone(presentation: CheckPresentation): CheckPresentationTone { + return CHECK_PRESENTATION_VIEW_POLICY[presentation].tone; +} + +export function CheckPresentationIcon({ + presentation, + size, +}: { + presentation: CheckPresentation; + size: number; +}): ReactNode { + return CHECK_PRESENTATION_VIEW_POLICY[presentation].renderIcon(size); +} diff --git a/packages/app/src/git/client-forge-module.ts b/packages/app/src/git/client-forge-module.ts index cbc71da571..d8293fc26f 100644 --- a/packages/app/src/git/client-forge-module.ts +++ b/packages/app/src/git/client-forge-module.ts @@ -27,6 +27,20 @@ export interface ForgeUrlGrammar { blobInfix: string; /** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */ lineAnchor: (start: number, end?: number) => string; + /** Suffix for the change request's checks/pipelines overview. */ + changeRequestChecksSuffix?: string; + /** + * Web-path infixes used when recognizing pasted links for this forge. The + * parser anchors each infix after the current remote's full repository path, + * so links for another repository are never auto-attached. + */ + referencePaths?: readonly ForgeReferencePath[]; +} + +export interface ForgeReferencePath { + kind: "change_request" | "issue"; + /** Path between the repository path and the numeric local id. */ + infix: string; } /** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */ @@ -39,6 +53,10 @@ export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = { treeInfix: "/src/branch/", blobInfix: "/src/branch/", lineAnchor: GITHUB_LINE_ANCHOR, + referencePaths: [ + { kind: "change_request", infix: "/pulls/" }, + { kind: "issue", infix: "/issues/" }, + ], }; export type ForgeSpecificEnvelope = { forge: string } & Record; diff --git a/packages/app/src/git/forge-refs.test.ts b/packages/app/src/git/forge-refs.test.ts new file mode 100644 index 0000000000..e39c03bdeb --- /dev/null +++ b/packages/app/src/git/forge-refs.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { extractForgeRefs, parseForgeRef } from "./forge-refs"; + +describe("parseForgeRef", () => { + it.each([ + [ + "GitHub pull request", + "git@github.com:getpaseo/paseo.git", + "https://github.com/getpaseo/paseo/pull/994/files?diff=split#discussion_r123", + { kind: "change_request", number: 994 }, + ], + [ + "GitHub issue", + "https://github.com/getpaseo/paseo.git", + "https://github.com/getpaseo/paseo/issues/456", + { kind: "issue", number: 456 }, + ], + [ + "nested GitLab merge request", + "git@gitlab.com:group/subgroup/project.git", + "https://gitlab.com/group/subgroup/project/-/merge_requests/73/diffs", + { kind: "change_request", number: 73 }, + ], + [ + "GitLab issue", + "https://gitlab.com/group/project.git", + "https://gitlab.com/group/project/-/issues/19#note_1", + { kind: "issue", number: 19 }, + ], + [ + "self-hosted Gitea pull request", + "ssh://git@git.example.com/acme/project.git", + "https://git.example.com/acme/project/pulls/31/files", + { kind: "change_request", number: 31 }, + ], + [ + "Forgejo issue", + "git@forgejo.example.com:acme/project.git", + "https://forgejo.example.com/acme/project/issues/27", + { kind: "issue", number: 27 }, + ], + [ + "Codeberg pull request", + "git@codeberg.org:acme/project.git", + "https://codeberg.org/acme/project/pulls/8", + { kind: "change_request", number: 8 }, + ], + ])("parses a matching %s URL", (_label, remote, text, expected) => { + expect(parseForgeRef(text, remote)).toEqual(expected); + }); + + it("canonicalizes a cloud SSH alias to its web host", () => { + expect( + parseForgeRef( + "https://github.com/getpaseo/paseo/pull/994", + "ssh://git@ssh.github.com/getpaseo/paseo.git", + ), + ).toEqual({ kind: "change_request", number: 994 }); + }); + + it("ignores another host, repository, and malformed local id", () => { + const remote = "git@gitlab.com:group/project.git"; + expect( + parseForgeRef("https://other.example/group/project/-/merge_requests/1", remote), + ).toBeNull(); + expect(parseForgeRef("https://gitlab.com/group/other/-/merge_requests/1", remote)).toBeNull(); + expect( + parseForgeRef("https://gitlab.com/group/project/-/merge_requests/1oops", remote), + ).toBeNull(); + }); + + it("does not apply another forge's route grammar to a known cloud host", () => { + expect( + parseForgeRef( + "https://github.com/getpaseo/paseo/pulls/31", + "git@github.com:getpaseo/paseo.git", + ), + ).toBeNull(); + expect( + parseForgeRef("https://gitlab.com/group/project/pull/31", "git@gitlab.com:group/project.git"), + ).toBeNull(); + }); +}); + +describe("extractForgeRefs", () => { + it("extracts matching references in text order and deduplicates them", () => { + const text = [ + "[MR](https://gitlab.com/group/project/-/merge_requests/12/diffs).", + "https://gitlab.com/group/project/-/issues/34#note_1,", + "https://gitlab.com/group/project/-/merge_requests/12", + "https://gitlab.com/group/other/-/issues/99", + ].join("\n"); + + expect(extractForgeRefs(text, "git@gitlab.com:group/project.git")).toEqual([ + { kind: "change_request", number: 12 }, + { kind: "issue", number: 34 }, + ]); + }); + + it("returns no references without text or a valid remote", () => { + expect(extractForgeRefs("", "git@github.com:getpaseo/paseo.git")).toEqual([]); + expect(extractForgeRefs("https://github.com/getpaseo/paseo/pull/1", null)).toEqual([]); + }); +}); diff --git a/packages/app/src/git/forge-refs.ts b/packages/app/src/git/forge-refs.ts new file mode 100644 index 0000000000..dcafce4cd6 --- /dev/null +++ b/packages/app/src/git/forge-refs.ts @@ -0,0 +1,145 @@ +import { FORGE_DEFINITIONS } from "@getpaseo/protocol/forge-manifest"; +import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; +import type { ForgeReferencePath } from "@/git/client-forge-module"; +import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges"; + +export interface ForgeRef { + kind: ForgeReferencePath["kind"]; + number: number; +} + +const WEB_URL_PATTERN = /https?:\/\/[^\s<>"'`)\]]+/giu; +const TRAILING_SENTENCE_PUNCTUATION = /[.,;:!]+$/u; + +interface RemoteReferenceTarget { + webHosts: ReadonlySet; + repoPath: string; + /** Known cloud forge ids, or null when a self-hosted domain is unresolved. */ + forgeIds: ReadonlySet | null; +} + +/** + * Finds issue and change-request URLs belonging to the current repository. + * Route syntax comes entirely from the app forge registry: adding a forge's + * `urlGrammar.referencePaths` makes composer auto-attachment work without a + * central forge switch. + */ +export function extractForgeRefs( + text: string | null | undefined, + remoteUrl: string | null | undefined, +): ForgeRef[] { + const target = resolveRemoteReferenceTarget(remoteUrl); + const body = text?.trim(); + if (!target || !body) { + return []; + } + + const refs: ForgeRef[] = []; + const seen = new Set(); + for (const match of body.matchAll(WEB_URL_PATTERN)) { + const candidate = parsePastedWebUrl(match[0]); + if (!candidate || !target.webHosts.has(normalizeHost(candidate.hostname))) { + continue; + } + const pathname = decodePathname(candidate.pathname); + if (!pathname) { + continue; + } + for (const referencePath of registeredReferencePaths(target.forgeIds)) { + const ref = parseReferencePath(pathname, target.repoPath, referencePath); + if (!ref) { + continue; + } + const key = forgeRefKey(ref); + if (!seen.has(key)) { + seen.add(key); + refs.push(ref); + } + break; + } + } + return refs; +} + +export function parseForgeRef( + text: string | null | undefined, + remoteUrl: string | null | undefined, +): ForgeRef | null { + return extractForgeRefs(text, remoteUrl)[0] ?? null; +} + +function resolveRemoteReferenceTarget( + remoteUrl: string | null | undefined, +): RemoteReferenceTarget | null { + if (!remoteUrl) { + return null; + } + const remote = parseGitRemoteLocation(remoteUrl); + if (!remote) { + return null; + } + + const webHosts = new Set([remote.host]); + const forgeIds = new Set(); + for (const definition of FORGE_DEFINITIONS) { + const cloudHosts = definition.cloudHosts?.map(normalizeHost) ?? []; + if (cloudHosts.includes(remote.host)) { + forgeIds.add(definition.id); + for (const host of cloudHosts) { + webHosts.add(host); + } + } + } + return { + webHosts, + repoPath: remote.path, + forgeIds: forgeIds.size > 0 ? forgeIds : null, + }; +} + +function parsePastedWebUrl(raw: string): URL | null { + try { + const parsed = new URL(raw.replace(TRAILING_SENTENCE_PUNCTUATION, "")); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : null; + } catch { + return null; + } +} + +function decodePathname(pathname: string): string | null { + try { + return decodeURIComponent(pathname); + } catch { + return null; + } +} + +function registeredReferencePaths( + forgeIds: ReadonlySet | null, +): readonly ForgeReferencePath[] { + return CLIENT_FORGE_LOGIC_MODULES.flatMap((module) => + !forgeIds || forgeIds.has(module.id) ? (module.urlGrammar?.referencePaths ?? []) : [], + ); +} + +function parseReferencePath( + pathname: string, + repoPath: string, + referencePath: ForgeReferencePath, +): ForgeRef | null { + const prefix = `/${repoPath}${referencePath.infix}`; + if (!pathname.toLowerCase().startsWith(prefix.toLowerCase())) { + return null; + } + const suffix = pathname.slice(prefix.length); + const numberText = suffix.match(/^(\d+)(?:\/|$)/u)?.[1]; + if (!numberText) { + return null; + } + const number = Number.parseInt(numberText, 10); + return Number.isSafeInteger(number) && number > 0 ? { kind: referencePath.kind, number } : null; +} + +function forgeRefKey(ref: ForgeRef): string { + return `${ref.kind}:${ref.number}`; +} diff --git a/packages/app/src/git/forge-url.test.ts b/packages/app/src/git/forge-url.test.ts index 46a2dfe6cf..c7a250f9a4 100644 --- a/packages/app/src/git/forge-url.test.ts +++ b/packages/app/src/git/forge-url.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it } from "vitest"; -import { buildForgeBlobUrl, buildForgeBranchTreeUrl, hasForgeWebUrls } from "./forge-url"; +import { + buildForgeBlobUrl, + buildForgeBranchTreeUrl, + buildForgeChecksUrl, + hasForgeWebUrls, +} from "./forge-url"; + +describe("buildForgeChecksUrl", () => { + it.each([ + [ + "github", + "https://github.com/acme/repo/pull/12", + "https://github.com/acme/repo/pull/12/checks", + ], + [ + "gitlab", + "https://gitlab.com/acme/repo/-/merge_requests/12", + "https://gitlab.com/acme/repo/-/merge_requests/12/pipelines", + ], + ["codeberg", "https://codeberg.org/acme/repo/pulls/12", null], + ["bitbucket", "https://bitbucket.org/acme/repo/pull-requests/12", null], + ["gitlab", "not a url", null], + ] as const)("maps the %s checks URL", (forge, url, expected) => { + expect(buildForgeChecksUrl(forge, url)).toBe(expected); + }); +}); describe("buildForgeBranchTreeUrl", () => { it("builds a branch-specific GitHub tree URL", () => { diff --git a/packages/app/src/git/forge-url.ts b/packages/app/src/git/forge-url.ts index a19190d82c..61bf6bdb8d 100644 --- a/packages/app/src/git/forge-url.ts +++ b/packages/app/src/git/forge-url.ts @@ -27,6 +27,23 @@ export interface ForgeBranchTreeUrlInput { branch: string | null | undefined; } +export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): string | null { + const suffix = getClientForgeLogicModule(forge)?.urlGrammar?.changeRequestChecksSuffix; + if (!suffix) { + return null; + } + try { + const url = new URL(changeRequestUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + url.pathname = `${url.pathname.replace(/\/$/, "")}${suffix}`; + return url.toString(); + } catch { + return null; + } +} + interface ForgeWebLocation { host: string; /** Non-default port for a self-hosted http(s) origin, or undefined. */ diff --git a/packages/app/src/git/forges/gitea.ts b/packages/app/src/git/forges/gitea.ts index c49af40dc1..bfd10c134f 100644 --- a/packages/app/src/git/forges/gitea.ts +++ b/packages/app/src/git/forges/gitea.ts @@ -7,7 +7,8 @@ import { type MergeCapability, } from "@/git/client-forge-module"; import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; -import { mapCheckStatus, type CheckStatus } from "@/git/pull-request-panel/check-status"; +import { CHECK_TRAIT_WARNING } from "@getpaseo/protocol/check-traits"; +import { mapGiteaCommitState } from "@getpaseo/protocol/gitea-status"; const GiteaMergeFactsSchema = z .object({ @@ -20,18 +21,6 @@ const GiteaMergeFactsSchema = z type GiteaMergeFacts = z.infer; -// forgeSpecific.ciStatus carries Gitea's raw aggregate CI string. Server twin: -// packages/server/src/services/gitea-service.ts (mapGiteaCommitStatus) — "warning" -// and "error" are terminal, non-passing states, but the generic mapCheckStatus -// would show them as pending, so interpret them here where the module owns Gitea -// facts. -function mapGiteaCiStatus(ciStatus: string): CheckStatus { - if (ciStatus === "warning" || ciStatus === "error") { - return "failure"; - } - return mapCheckStatus(ciStatus); -} - const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"]; function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability { @@ -62,7 +51,8 @@ export const giteaForgeLogic = { return { provider: forge, name: "CI", - status: mapGiteaCiStatus(facts.ciStatus), + status: mapGiteaCommitState(facts.ciStatus), + ...(facts.ciStatus === "warning" ? { traits: [CHECK_TRAIT_WARNING] } : {}), url: status.url, }; }, diff --git a/packages/app/src/git/forges/github.ts b/packages/app/src/git/forges/github.ts index 1cae04dd94..10290a2849 100644 --- a/packages/app/src/git/forges/github.ts +++ b/packages/app/src/git/forges/github.ts @@ -86,6 +86,11 @@ export const githubForgeLogic = { treeInfix: "/tree/", blobInfix: "/blob/", lineAnchor: GITHUB_LINE_ANCHOR, + changeRequestChecksSuffix: "/checks", + referencePaths: [ + { kind: "change_request", infix: "/pull/" }, + { kind: "issue", infix: "/issues/" }, + ], }, facts: defineForgeFacts({ family: "github", diff --git a/packages/app/src/git/forges/gitlab.ts b/packages/app/src/git/forges/gitlab.ts index 7758d2c0ab..15a67aeedf 100644 --- a/packages/app/src/git/forges/gitlab.ts +++ b/packages/app/src/git/forges/gitlab.ts @@ -4,29 +4,25 @@ import { type ClientForgeLogicModule, type MergeCapability, } from "@/git/client-forge-module"; -import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; +import type { CheckoutPipelineJob, CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; +import { + CHECK_TRAIT_ACTION_REQUIRED, + CHECK_TRAIT_MANUAL, + CHECK_TRAIT_WARNING, +} from "@getpaseo/protocol/check-traits"; +import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline"; import type { CheckStatus } from "@/git/pull-request-panel/check-status"; +import { + classifyCheck, + countCheckPresentations, + type CheckPresentation, + type CheckPresentationCounts, + type PresentableCheck, +} from "@/git/check-presentation"; const gitlabLineAnchor = (start: number, end?: number): string => end && end > start ? `#L${start}-${end}` : `#L${start}`; -/** - * Canonical set of GitLab pipeline statuses that count as "still active" (a - * pipeline that has not reached a terminal state). Server-side twin: - * packages/server/src/services/gitlab-facts.ts (GITLAB_ACTIVE_PIPELINE_STATUSES), - * duplicated because the app can't depend on the server package. - */ -const GITLAB_ACTIVE_PIPELINE_STATUSES = [ - "created", - "waiting_for_resource", - "preparing", - "pending", - "running", - "scheduled", -] as const; - -const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set(GITLAB_ACTIVE_PIPELINE_STATUSES); - export function isPipelineActiveStatus(status: string): boolean { return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status); } @@ -38,23 +34,45 @@ export function mapPipelineStatus(status: string): CheckStatus { return "success"; case "failed": return "failure"; - case "running": - case "pending": - case "created": - case "waiting_for_resource": - case "preparing": - case "scheduled": - case "manual": - return "pending"; case "canceled": case "cancelled": case "skipped": return "skipped"; default: + // "manual" and every active transitional status (see + // GITLAB_ACTIVE_PIPELINE_STATUSES) render as pending. return "pending"; } } +type GitlabPipelineJobPresentationInput = Pick; + +function toPresentableGitlabPipelineJob(job: GitlabPipelineJobPresentationInput): PresentableCheck { + const status = mapPipelineStatus(job.status); + if (job.status === "manual") { + return { + status, + traits: job.allowFailure + ? [CHECK_TRAIT_MANUAL] + : [CHECK_TRAIT_MANUAL, CHECK_TRAIT_ACTION_REQUIRED], + }; + } + if (status === "failure" && job.allowFailure) return { status, traits: [CHECK_TRAIT_WARNING] }; + return { status }; +} + +export function classifyGitlabPipelineJob( + job: GitlabPipelineJobPresentationInput, +): CheckPresentation { + return classifyCheck(toPresentableGitlabPipelineJob(job)); +} + +export function countGitlabPipelineJobs( + jobs: readonly CheckoutPipelineJob[], +): CheckPresentationCounts { + return countCheckPresentations(jobs.map(toPresentableGitlabPipelineJob)); +} + const GitlabMergeFactsSchema = z .object({ forge: z.literal("gitlab"), @@ -146,6 +164,11 @@ export const gitlabForgeLogic = { treeInfix: "/-/tree/", blobInfix: "/-/blob/", lineAnchor: gitlabLineAnchor, + changeRequestChecksSuffix: "/pipelines", + referencePaths: [ + { kind: "change_request", infix: "/-/merge_requests/" }, + { kind: "issue", infix: "/-/issues/" }, + ], }, facts: defineForgeFacts({ family: "gitlab", diff --git a/packages/app/src/git/forges/gitlab.view.tsx b/packages/app/src/git/forges/gitlab.view.tsx index acbc02e0ec..39bf349248 100644 --- a/packages/app/src/git/forges/gitlab.view.tsx +++ b/packages/app/src/git/forges/gitlab.view.tsx @@ -12,24 +12,23 @@ import { } from "@/git/client-forge-module"; import { openExternalUrl } from "@/utils/open-external-url"; import { formatDuration } from "@/utils/time"; +import { COUNTED_CHECK_PRESENTATIONS } from "@/git/check-presentation"; +import { CheckPresentationIcon } from "@/git/check-presentation.view"; import { - CheckStatusIcon, + CheckPresentationSummaryPill, Section, - SUMMARY_DANGER_ICON, - SUMMARY_SUCCESS_ICON, - SUMMARY_WARNING_ICON, - SummaryPill, foregroundMutedColorMapping, sectionKitStyles, successColorMapping, } from "@/git/pull-request-panel/section-kit"; import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline"; import { + classifyGitlabPipelineJob, deriveGitlabApprovals, deriveGitlabPipelineSummary, + countGitlabPipelineJobs, GitlabMergeFactsSchema, isPipelineActiveStatus, - mapPipelineStatus, type GitlabApprovals, type GitlabMergeFacts, type GitlabPipelineSummary, @@ -99,23 +98,6 @@ function formatPipelineDuration(seconds: number | null): string { return formatDuration(seconds * 1000); } -interface PipelineJobCounts { - passed: number; - failed: number; - pending: number; -} - -function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts { - const counts: PipelineJobCounts = { passed: 0, failed: 0, pending: 0 }; - for (const job of jobs) { - const status = mapPipelineStatus(job.status); - if (status === "success") counts.passed += 1; - else if (status === "failure") counts.failed += 1; - else if (status === "pending") counts.pending += 1; - } - return counts; -} - function GitLabPipelineSection({ serverId, cwd, @@ -144,13 +126,13 @@ function GitLabPipelineSection({ }); const counts = useMemo( - () => countPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)), + () => countGitlabPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)), [pipeline], ); - const totalCounted = counts.passed + counts.failed + counts.pending; + const totalCounted = Object.values(counts).reduce((total, count) => total + count, 0); const showBreakdown = !isPlaceholderData && totalCounted > 0; - const displayCounts = showBreakdown ? counts : { passed: 0, failed: 0, pending: 0 }; + const displayCounts = showBreakdown ? counts : countGitlabPipelineJobs([]); const handleOpenPipeline = useCallback(() => { if (summary.url) { @@ -160,25 +142,15 @@ function GitLabPipelineSection({ const sectionSummary = ( <> - - - - {showBreakdown ? null : } + {COUNTED_CHECK_PRESENTATIONS.map((presentation) => ( + + ))} + {showBreakdown ? null : } ); @@ -195,7 +167,7 @@ function GitLabPipelineSection({ disabled={!summary.url} testID="pr-pane-pipeline-link" > - + {`Pipeline #${summary.id}`} @@ -234,7 +206,7 @@ function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) { return ( - + {stage.name} @@ -256,7 +228,7 @@ function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) { const duration = formatPipelineDuration(job.durationSeconds); return ( - + {job.name} @@ -272,6 +244,17 @@ function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) { ); } +function GitLabPipelineStatusIcon({ + status, + allowFailure = false, +}: { + status: string; + allowFailure?: boolean; +}) { + const presentation = classifyGitlabPipelineJob({ status, allowFailure }); + return ; +} + export const gitlabForgeView = { id: "gitlab", icon: GitLabIcon, diff --git a/packages/app/src/git/merge-capability.ts b/packages/app/src/git/merge-capability.ts index ec294ac516..4889d19173 100644 --- a/packages/app/src/git/merge-capability.ts +++ b/packages/app/src/git/merge-capability.ts @@ -31,11 +31,10 @@ function deriveForgeMergeCapability(facts: unknown): MergeCapability | null { * Per-forge derivation lives on client forge modules; this stays the single * neutral entry point the action policy reads. * - * COMPAT(forgeSpecific): added in v0.1.106, remove after 2026-12-27. A daemon - * predating forgeSpecific emits only the legacy `status.github` field. When - * forgeSpecific is absent we synthesize the github arm from those legacy facts - * so GitHub merge-method restrictions and auto-merge state are not lost against - * an old daemon. + * COMPAT(forgeSpecific): forgeSpecific shipped in v0.2.0-beta.1. A daemon + * predating it emits only the legacy `status.github` field. When forgeSpecific + * is absent we synthesize the GitHub arm from those legacy facts. Remove after + * 2027-01-17 once the supported daemon floor is >= v0.2.0. */ export function deriveMergeCapability( forgeSpecific: unknown, diff --git a/packages/app/src/git/pr-hint.ts b/packages/app/src/git/pr-hint.ts index 9fbd1949a1..7e47ccde43 100644 --- a/packages/app/src/git/pr-hint.ts +++ b/packages/app/src/git/pr-hint.ts @@ -1,4 +1,5 @@ import { normalizeForge, type Forge } from "@/git/forge"; +import type { PresentableCheck } from "@/git/check-presentation"; export interface PrHint { url: string; @@ -6,16 +7,21 @@ export interface PrHint { state: "open" | "merged" | "closed"; /** Forge backing this change request, so badges render the right brand mark. */ forge: Forge; - checks?: Array<{ name: string; status: string; url: string | null }>; + checks?: PrHintCheck[]; checksStatus?: "none" | "pending" | "success" | "failure"; reviewDecision?: "approved" | "changes_requested" | "pending" | null; } +export interface PrHintCheck extends PresentableCheck { + name: string; + url: string | null; +} + interface PrStatusLike { url: string; state: string; isMerged: boolean; - checks?: Array<{ name: string; status: string; url: string | null }>; + checks?: PrHintCheck[]; checksStatus?: string; reviewDecision?: string | null; forge?: string; diff --git a/packages/app/src/git/pr-status.ts b/packages/app/src/git/pr-status.ts index e296d5820d..245d29dcaf 100644 --- a/packages/app/src/git/pr-status.ts +++ b/packages/app/src/git/pr-status.ts @@ -12,8 +12,9 @@ export function normalizeCheckoutPrStatusPayload( ): CheckoutPrStatusPayload { return { ...payload, - // COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27 once - // all supported daemons send authState. + // COMPAT(forgeAuthState): normalize the legacy GitHub boolean for daemons + // predating v0.2.0-beta.1. Remove after 2027-01-17 once the supported daemon + // floor is >= v0.2.0. authState: parseForgeAuthState(payload.authState) ?? (payload.githubFeaturesEnabled ? "authenticated" : "unauthenticated"), diff --git a/packages/app/src/git/pull-request-panel/check-group-state.ts b/packages/app/src/git/pull-request-panel/check-group-state.ts index 53c7e9a69d..d0e1875d0e 100644 --- a/packages/app/src/git/pull-request-panel/check-group-state.ts +++ b/packages/app/src/git/pull-request-panel/check-group-state.ts @@ -1,10 +1,12 @@ import { useCallback, useState } from "react"; -import type { CheckStatus } from "./check-status"; +import type { CheckPresentation } from "@/git/check-presentation"; /** Per-mounted PR panel state; never shared with another tab instance. */ export function useCheckGroupState() { - const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()); - const toggle = useCallback((status: CheckStatus) => { + const [collapsedGroups, setCollapsedGroups] = useState>( + () => new Set(), + ); + const toggle = useCallback((status: CheckPresentation) => { setCollapsedGroups((current) => { const next = new Set(current); if (!next.delete(status)) next.add(status); diff --git a/packages/app/src/git/pull-request-panel/check-status.ts b/packages/app/src/git/pull-request-panel/check-status.ts index 055d1693c1..84a7f49b98 100644 --- a/packages/app/src/git/pull-request-panel/check-status.ts +++ b/packages/app/src/git/pull-request-panel/check-status.ts @@ -4,19 +4,17 @@ * maps its raw CI strings onto this frozen union. Forge-specific vocabularies * (e.g. GitLab pipeline statuses) live in the owning forge module. */ -export type CheckStatus = "success" | "failure" | "pending" | "skipped"; +export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"; export function mapCheckStatus(status: string): CheckStatus { if ( status === "success" || status === "failure" || status === "pending" || - status === "skipped" + status === "skipped" || + status === "cancelled" ) { return status; } - if (status === "cancelled") { - return "skipped"; - } return "pending"; } diff --git a/packages/app/src/git/pull-request-panel/checks-ring.tsx b/packages/app/src/git/pull-request-panel/checks-ring.tsx index 65d69e1398..d5ae3e6226 100644 --- a/packages/app/src/git/pull-request-panel/checks-ring.tsx +++ b/packages/app/src/git/pull-request-panel/checks-ring.tsx @@ -1,7 +1,7 @@ import Svg, { Circle } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; -import type { CheckStatus } from "./check-status"; +import type { CheckPresentation } from "@/git/check-presentation"; import type { ChecksSummary } from "./checks-summary"; /** @@ -18,20 +18,23 @@ const SEGMENT_GAP = 2; /** Floor for a segment, so one failure among twenty checks is still a visible mark. */ const MIN_SEGMENT = 1.5; -type ChecksPalette = Record & { track: string }; +type ChecksPalette = Record & { track: string }; const paletteMapping = (theme: Theme): { palette: ChecksPalette } => ({ palette: { + actionRequired: theme.colors.statusWarning, + warning: theme.colors.statusWarning, failure: theme.colors.statusDanger, pending: theme.colors.statusWarning, + manual: theme.colors.foregroundMuted, success: theme.colors.statusSuccess, - skipped: theme.colors.foregroundExtraMuted, + ignored: theme.colors.foregroundExtraMuted, track: theme.colors.surface3, }, }); interface RingSegment { - status: CheckStatus; + status: CheckPresentation; /** Painted arc length. */ dash: number; /** Distance from twelve o'clock to where this segment starts. */ diff --git a/packages/app/src/git/pull-request-panel/checks-section.tsx b/packages/app/src/git/pull-request-panel/checks-section.tsx index fddb0e292b..39ad9a0b96 100644 --- a/packages/app/src/git/pull-request-panel/checks-section.tsx +++ b/packages/app/src/git/pull-request-panel/checks-section.tsx @@ -5,12 +5,13 @@ import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-nativ import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; import { ICON_SIZE } from "@/styles/theme"; -import type { CheckStatus } from "./check-status"; +import { classifyCheck, type CheckPresentation } from "@/git/check-presentation"; +import { CheckPresentationIcon } from "@/git/check-presentation.view"; import { ChecksRing } from "./checks-ring"; import { summarizeChecks, type ChecksGroup } from "./checks-summary"; import { canAddPullRequestCheckLogsToChat } from "./context-attachment"; import type { PrPaneCheck } from "./data"; -import { CheckStatusIcon, foregroundMutedColorMapping, sectionKitStyles } from "./section-kit"; +import { foregroundMutedColorMapping, sectionKitStyles } from "./section-kit"; import { useCheckGroupState } from "./check-group-state"; const ThemedChevronDown = withUnistyles(ChevronDown); @@ -26,10 +27,13 @@ const LIST_MAX_HEIGHT = 268; * The three statuses the pane has always labelled for tests. Skipped has no id because * nothing asserts on it. */ -const PART_TEST_ID: Partial> = { +const PART_TEST_ID: Partial> = { + actionRequired: "pr-pane-check-action-required", + warning: "pr-pane-check-warning", success: "pr-pane-check-passed", failure: "pr-pane-check-failed", pending: "pr-pane-check-pending", + manual: "pr-pane-check-manual", }; /** @@ -137,7 +141,7 @@ function CheckGroup({ }: { group: ChecksGroup; collapsed: boolean; - onToggle: (status: CheckStatus) => void; + onToggle: (status: CheckPresentation) => void; attachEnabled: boolean; loadingCheckKeys: ReadonlySet; onAddLogsToChat: (check: PrPaneCheck) => void; @@ -194,7 +198,7 @@ function CheckRow({ ); return ( - + {check.name} diff --git a/packages/app/src/git/pull-request-panel/checks-summary.ts b/packages/app/src/git/pull-request-panel/checks-summary.ts index 3351cfb19c..f2f9b30a38 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.ts @@ -1,4 +1,4 @@ -import type { CheckStatus } from "./check-status"; +import { classifyCheck, type CheckPresentation } from "@/git/check-presentation"; import type { PrPaneCheck } from "./data"; /** @@ -6,25 +6,32 @@ import type { PrPaneCheck } from "./data"; * waiting on, then what needs no attention. Skipped trails everything because it is the * only status that says nothing about whether the run is going well. */ -const STATUS_ORDER = [ +const PRESENTATION_ORDER = [ + "actionRequired", + "warning", "failure", "pending", + "manual", "success", - "skipped", -] as const satisfies readonly CheckStatus[]; + "ignored", +] as const satisfies readonly CheckPresentation[]; /** How each status reads inside a count phrase: "2 failing", "4 in progress". */ -const STATUS_NOUN: Record = { +const PRESENTATION_NOUN: Record = { + actionRequired: "needs action", + warning: "warning", failure: "failing", pending: "in progress", + manual: "manual", success: "successful", - skipped: "skipped", + ignored: "skipped", }; /** The worst thing happening in the run, which is what the headline and the ring report. */ -export type ChecksOutcome = "failure" | "pending" | "success" | "none"; +export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none"; const OUTCOME_HEADLINE: Record = { + actionRequired: "Some checks need your attention", failure: "Some checks were not successful", pending: "Some checks haven't completed yet", success: "All checks have passed", @@ -32,14 +39,14 @@ const OUTCOME_HEADLINE: Record = { }; export interface ChecksCountPart { - status: CheckStatus; + status: CheckPresentation; count: number; /** e.g. "2 failing" */ text: string; } export interface ChecksGroup { - status: CheckStatus; + status: CheckPresentation; /** e.g. "2 failing checks" */ label: string; checks: readonly PrPaneCheck[]; @@ -69,20 +76,20 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { const groups: ChecksGroup[] = []; const parts: ChecksCountPart[] = []; - for (const status of STATUS_ORDER) { - const matching = checks.filter((check) => check.status === status); + for (const status of PRESENTATION_ORDER) { + const matching = checks.filter((check) => classifyCheck(check) === status); if (matching.length === 0) { continue; } groups.push({ status, - label: `${matching.length} ${STATUS_NOUN[status]} ${countNoun(matching.length)}`, + label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`, checks: matching, }); parts.push({ status, count: matching.length, - text: `${matching.length} ${STATUS_NOUN[status]}`, + text: `${matching.length} ${PRESENTATION_NOUN[status]}`, }); } @@ -107,7 +114,15 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome { if (checks.length === 0) { return "none"; } - if (checks.some((check) => check.status === "failure")) { + if (checks.some((check) => classifyCheck(check) === "actionRequired")) { + return "actionRequired"; + } + if ( + checks.some((check) => { + const presentation = classifyCheck(check); + return presentation === "failure" || presentation === "warning"; + }) + ) { return "failure"; } if (checks.some((check) => check.status === "pending")) { diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index b28081651d..21b145bedc 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from "vitest"; import type { + CheckoutPipelineJob, CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; -import { isPipelineActiveStatus, mapPipelineStatus } from "@/git/forges/gitlab"; +import { + countGitlabPipelineJobs, + isPipelineActiveStatus, + mapPipelineStatus, +} from "@/git/forges/gitlab"; import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors"; import { deriveAvatarColor, @@ -108,7 +113,7 @@ describe("mapPrPaneData", () => { ); }); - it("drops checks with null URLs to preserve the pressable check contract", () => { + it("keeps checks with null URLs by linking them to the pull request", () => { const data = mapPrPaneData( status({ checks: [ @@ -126,6 +131,12 @@ describe("mapPrPaneData", () => { status: "success", url: "https://example.com/checks/1", }, + { + provider: "github", + name: "legacy status", + status: "pending", + url: "https://github.com/getpaseo/paseo/pull/42", + }, ]); }); @@ -141,9 +152,20 @@ describe("mapPrPaneData", () => { duration: "1m", }, { name: "failure", status: "failure", url: "https://example.com/2" }, - { name: "pending", status: "pending", url: "https://example.com/3" }, + { + name: "approval", + status: "pending", + traits: ["action_required"], + url: "https://example.com/3", + }, { name: "skipped", status: "skipped", url: "https://example.com/4" }, { name: "cancelled", status: "cancelled", url: "https://example.com/5" }, + { + name: "manual", + status: "skipped", + traits: ["manual"], + url: "https://example.com/6", + }, ], }), baseTimeline, @@ -159,9 +181,27 @@ describe("mapPrPaneData", () => { url: "https://example.com/1", }, { provider: "github", name: "failure", status: "failure", url: "https://example.com/2" }, - { provider: "github", name: "pending", status: "pending", url: "https://example.com/3" }, + { + provider: "github", + name: "approval", + status: "pending", + traits: ["action_required"], + url: "https://example.com/3", + }, { provider: "github", name: "skipped", status: "skipped", url: "https://example.com/4" }, - { provider: "github", name: "cancelled", status: "skipped", url: "https://example.com/5" }, + { + provider: "github", + name: "cancelled", + status: "cancelled", + url: "https://example.com/5", + }, + { + provider: "github", + name: "manual", + status: "skipped", + traits: ["manual"], + url: "https://example.com/6", + }, ]); }); @@ -588,7 +628,7 @@ describe("mapPrPaneData", () => { ]); }); - it("keeps Forgejo branding for aggregate Gitea-family CI status", () => { + it("keeps Forgejo branding and warning presentation for aggregate CI status", () => { const data = mapPrPaneData( status({ forge: "forgejo", @@ -598,7 +638,7 @@ describe("mapPrPaneData", () => { forge: "gitea", mergeable: true, hasMerged: false, - ciStatus: "failure", + ciStatus: "warning", }, }), baseTimeline, @@ -611,6 +651,7 @@ describe("mapPrPaneData", () => { provider: "forgejo", name: "CI", status: "failure", + traits: ["warning"], url: "https://forgejo.example.com/group/repo/pulls/7", }, ]); @@ -667,16 +708,48 @@ describe("mapPipelineStatus", () => { expect(mapPipelineStatus("created")).toBe("pending"); expect(mapPipelineStatus("waiting_for_resource")).toBe("pending"); expect(mapPipelineStatus("preparing")).toBe("pending"); + expect(mapPipelineStatus("canceling")).toBe("pending"); expect(mapPipelineStatus("scheduled")).toBe("pending"); expect(mapPipelineStatus("anything-else")).toBe("pending"); }); + it("separates blocking outcomes from allowed failures and optional manual jobs", () => { + const job = (id: number, jobStatus: string, allowFailure: boolean): CheckoutPipelineJob => ({ + id, + name: `job-${id}`, + stage: "test", + status: jobStatus, + rawStatus: jobStatus, + url: null, + allowFailure, + durationSeconds: null, + }); + expect( + countGitlabPipelineJobs([ + job(1, "success", false), + job(2, "failed", false), + job(3, "failed", true), + job(4, "pending", false), + job(5, "manual", true), + job(6, "manual", false), + ]), + ).toEqual({ + success: 1, + failure: 1, + warning: 1, + actionRequired: 1, + manual: 1, + pending: 1, + }); + }); + it("marks running and queued pipeline statuses as live for polling", () => { expect(isPipelineActiveStatus("running")).toBe(true); expect(isPipelineActiveStatus("pending")).toBe(true); expect(isPipelineActiveStatus("created")).toBe(true); expect(isPipelineActiveStatus("waiting_for_resource")).toBe(true); expect(isPipelineActiveStatus("preparing")).toBe(true); + expect(isPipelineActiveStatus("canceling")).toBe(true); expect(isPipelineActiveStatus("scheduled")).toBe(true); expect(isPipelineActiveStatus("success")).toBe(false); expect(isPipelineActiveStatus("failed")).toBe(false); diff --git a/packages/app/src/git/pull-request-panel/data.ts b/packages/app/src/git/pull-request-panel/data.ts index 84019ea013..0f1e2014e9 100644 --- a/packages/app/src/git/pull-request-panel/data.ts +++ b/packages/app/src/git/pull-request-panel/data.ts @@ -3,6 +3,7 @@ import type { PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; import { type Forge, getForgePresentation } from "@/git/forge"; +import type { PresentableCheck } from "@/git/check-presentation"; import { parseClientForgeFacts } from "@/git/forges"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors"; @@ -22,7 +23,7 @@ export interface PullRequestProviderMetadata { url?: string | null; } -export interface PrPaneCheck { +export interface PrPaneCheck extends PresentableCheck { provider: PullRequestProvider; name: string; workflow?: string; @@ -32,6 +33,8 @@ export interface PrPaneCheck { * running one has been going ("running 2m"). Absent when the forge reports neither. */ timing?: string; + isManual?: boolean; + requiresAction?: boolean; url: string; /** * Forge-neutral reference for fetching this check's detail/logs on demand. Any @@ -201,7 +204,7 @@ function derivePrState(status: NonNullable): PrState { } function mapChecks(status: NonNullable, forge: Forge): PrPaneCheck[] { - const checks = (status.checks ?? []).flatMap((check) => mapCheck(check, forge)); + const checks = (status.checks ?? []).map((check) => mapCheck(check, forge, status.url)); if (checks.length > 0) { return checks; } @@ -211,31 +214,27 @@ function mapChecks(status: NonNullable, forge: Forge): PrPaneC function mapCheck( check: NonNullable["checks"][number], forge: Forge, -): PrPaneCheck[] { - if (check.url === null) { - return []; - } - + fallbackUrl: string, +): PrPaneCheck { const status = mapCheckStatus(check.status); const timing = formatCheckTiming(check.duration, status); - return [ - { - provider: forge, - name: check.name, - status, - url: check.url, - ...(check.workflow ? { workflow: check.workflow } : {}), - ...(timing ? { timing } : {}), - ...(check.checkRunId !== undefined || check.workflowRunId !== undefined - ? { - detailRef: { - ...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}), - ...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}), - }, - } - : {}), - }, - ]; + return { + provider: forge, + name: check.name, + status, + ...(check.traits ? { traits: check.traits } : {}), + url: check.url ?? fallbackUrl, + ...(check.workflow ? { workflow: check.workflow } : {}), + ...(timing ? { timing } : {}), + ...(check.checkRunId !== undefined || check.workflowRunId !== undefined + ? { + detailRef: { + ...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}), + ...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}), + }, + } + : {}), + }; } /** diff --git a/packages/app/src/git/pull-request-panel/native-data.test.ts b/packages/app/src/git/pull-request-panel/native-data.test.ts index 7c5d2b2525..c2c98fc8ba 100644 --- a/packages/app/src/git/pull-request-panel/native-data.test.ts +++ b/packages/app/src/git/pull-request-panel/native-data.test.ts @@ -135,6 +135,7 @@ describe("getNativeFallbackChecks", () => { provider: "gitea", name: "CI", status: "failure", + traits: ["warning"], url: "https://gitea.com/group/repo/pulls/7", }, ]); diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index e4faa833fd..b7c0890c3a 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -66,6 +66,7 @@ import { ChecksSection, getCheckIdentity } from "./checks-section"; import { getActivityVerb, getStateLabel } from "./data"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; +import { CheckPresentationIcon } from "@/git/check-presentation.view"; import { buildPrTimeline, type PrReviewEntry, @@ -74,8 +75,6 @@ import { } from "./timeline"; import { Section, - SUMMARY_DANGER_ICON, - SUMMARY_SUCCESS_ICON, SummaryPill, dangerColorMapping, foregroundMutedColorMapping, @@ -129,6 +128,8 @@ const PR_STATE_PRESENTATION: Record = { const SUMMARY_COMMENT_ICON = ( ); +const SUMMARY_APPROVAL_ICON = ; +const SUMMARY_CHANGES_REQUESTED_ICON = ; const ADD_TO_CHAT_MENU_ICON = ( ); @@ -205,8 +206,9 @@ export function PullRequestPane({ ); const toast = useToast(); const daemonClient = useHostRuntimeClient(serverId); - // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once - // all supported clients use checkout.forge.get_check_details.*. + // COMPAT(githubCheckDetailsRpc): recognize the legacy capability on daemons + // predating checkout.forge.get_check_details.*. Remove after 2027-01-17 once + // the supported daemon floor is >= v0.2.0. const canFetchGitHubCheckDetails = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true, ); @@ -395,8 +397,9 @@ export function PullRequestPane({ workflowRunId: ref.workflowRunId, changeRequestNumber: data.number, }; - // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once - // all supported clients use checkout.forge.get_check_details.*. + // COMPAT(githubCheckDetailsRpc): use the legacy GitHub RPC with + // daemons predating checkout.forge.get_check_details.*. Remove after + // 2027-01-17 once the supported daemon floor is >= v0.2.0. const payload = canFetchForgeCheckDetails ? await daemonClient.checkoutForgeGetCheckDetails(request) : await daemonClient.checkoutGithubGetCheckDetails(request); @@ -570,8 +573,12 @@ export function PullRequestPane({ onToggle={handleToggleActivity} summary={ <> - - + + } diff --git a/packages/app/src/git/pull-request-panel/section-kit.tsx b/packages/app/src/git/pull-request-panel/section-kit.tsx index 9cd394054d..ab7b07ee9f 100644 --- a/packages/app/src/git/pull-request-panel/section-kit.tsx +++ b/packages/app/src/git/pull-request-panel/section-kit.tsx @@ -1,24 +1,18 @@ import React, { type ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { - ChevronDown, - ChevronRight, - CircleCheck, - CircleDot, - CircleSlash, - CircleX, -} from "lucide-react-native"; +import type { CountedCheckPresentation } from "@/git/check-presentation"; import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry"; +import { + CheckPresentationIcon, + getCheckPresentationTone, + type CheckPresentationTone, +} from "@/git/check-presentation.view"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; import type { Theme } from "@/styles/theme"; -import type { CheckStatus } from "./check-status"; const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); -const ThemedCircleCheck = withUnistyles(CircleCheck); -const ThemedCircleDot = withUnistyles(CircleDot); -const ThemedCircleSlash = withUnistyles(CircleSlash); -const ThemedCircleX = withUnistyles(CircleX); export const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, @@ -27,22 +21,30 @@ export const successColorMapping = (theme: Theme) => ({ color: theme.colors.stat export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning }); -export const SUMMARY_SUCCESS_ICON = ; -export const SUMMARY_DANGER_ICON = ; -export const SUMMARY_WARNING_ICON = ; - interface SectionProps { title: string; open: boolean; onToggle: () => void; summary: ReactNode; children: ReactNode; + accessibilityLabel?: string; } -export function Section({ title, open, onToggle, summary, children }: SectionProps) { +export function Section({ + title, + open, + onToggle, + summary, + children, + accessibilityLabel, +}: SectionProps) { return ( - + {open ? ( ) : ( @@ -56,7 +58,7 @@ export function Section({ title, open, onToggle, summary, children }: SectionPro ); } -export type SummaryPillVariant = "success" | "danger" | "warning" | "muted"; +export type SummaryPillVariant = CheckPresentationTone; export function SummaryPill({ count, @@ -85,11 +87,22 @@ function summaryPillTextStyle(variant: SummaryPillVariant) { return sectionKitStyles.summaryPillMutedText; } -export function CheckStatusIcon({ status }: { status: CheckStatus }) { - if (status === "success") return ; - if (status === "failure") return ; - if (status === "pending") return ; - return ; +export function CheckPresentationSummaryPill({ + count, + presentation, + testID, +}: { + count: number; + presentation: CountedCheckPresentation; + testID?: string; +}) { + if (count === 0) return null; + return ( + + + {count} + + ); } export const sectionKitStyles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/git/use-forge-search-query.ts b/packages/app/src/git/use-forge-search-query.ts index 747dd033f8..9839828bf8 100644 --- a/packages/app/src/git/use-forge-search-query.ts +++ b/packages/app/src/git/use-forge-search-query.ts @@ -95,8 +95,9 @@ export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) { const request = input.kinds ? { cwd: input.cwd, query, limit: 20, kinds: input.kinds } : { cwd: input.cwd, query, limit: 20 }; - // COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once - // clients use forge.search.*. + // COMPAT(githubSearchRpc): use the legacy GitHub RPC with daemons that + // predate forge.search.*. Remove after 2027-01-17 once the supported + // daemon floor is >= v0.2.0. if (transport === "github" && input.client.searchGitHub) { return normalizeLegacyGitHubSearchPayload( await input.client.searchGitHub(toLegacyGitHubSearchRequest(request)), @@ -125,7 +126,8 @@ function normalizeForgeSearchPayload(payload: ForgeSearchResponse["payload"]): F function normalizeLegacyGitHubSearchPayload( payload: GitHubSearchResponse["payload"], ): ForgeSearchPayload { - // COMPAT(githubSearchAuthState): added in v0.1.106, remove after 2026-12-28. + // COMPAT(githubSearchAuthState): normalize the legacy GitHub search response + // until 2027-01-17, when the supported daemon floor is >= v0.2.0. const featuresEnabled = payload.featuresEnabled ?? payload.githubFeaturesEnabled ?? true; return { items: payload.items.flatMap((item) => { diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 95e8bc69f7..59664bfeea 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -310,6 +310,16 @@ describe("translation resources", () => { expect(en.workspace.git.pr.empty.pipelineJobsLoadFailed).toBe("Could not load pipeline jobs"); expect(en.workspace.git.pr.empty.allowedToFail).toBe("allowed to fail"); expect(en.workspace.git.pr.approvals).toBe("{{given}} of {{required}} approvals"); + expect(en.workspace.git.pr.accessibility.checkStatus).toEqual({ + passed: "Passed", + failed: "Failed", + warning: "Warning", + actionRequired: "Action required", + manual: "Manual", + pending: "Pending", + skipped: "Skipped", + cancelled: "Cancelled", + }); expect(en.review.comment.placeholder).toBe("Leave a comment"); }); @@ -438,6 +448,14 @@ describe("translation resources", () => { expect(en.sidebar.workspace.actions.createWorkspaceFor).toBe( "Create a new workspace for {{projectName}}", ); + expect(en.sidebar.workspace.checks).toEqual({ + passed: "Passed: {{count}}", + failed: "Failed: {{count}}", + warning: "Warnings: {{count}}", + actionRequired: "Action required: {{count}}", + manual: "Manual: {{count}}", + pending: "Pending: {{count}}", + }); expect(en.sidebar.project.empty.title).toBe("No projects yet"); expect(en.sidebar.project.empty.description).toBe("Add a project to get started"); expect(en.settings.projectList.hostLoadFailed).toBe( diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 96ea2c6f57..5a5af66b47 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -940,6 +940,16 @@ export const ar: TranslationResources = { accessibility: { pullRequest: "سحب الطلب #{{number}}", pullRequest_mr: "طلب دمج !{{number}}", + checkStatus: { + passed: "ناجح", + failed: "فاشل", + warning: "تحذير", + actionRequired: "إجراء مطلوب", + manual: "يدوي", + pending: "قيد الانتظار", + skipped: "تم التخطي", + cancelled: "تم الإلغاء", + }, }, states: { draft: "مسودة", @@ -1130,6 +1140,14 @@ export const ar: TranslationResources = { serviceUnhealthy: "الخدمة {{name}} غير سليمة", creating: "جارٍ الإنشاء...", }, + checks: { + passed: "ناجحة: {{count}}", + failed: "فاشلة: {{count}}", + warning: "تحذيرات: {{count}}", + actionRequired: "إجراء مطلوب: {{count}}", + manual: "يدوية: {{count}}", + pending: "قيد الانتظار: {{count}}", + }, actions: { menu: "إجراءات Workspace", newWorkspace: "مساحة عمل جديدة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 30a225eaa9..c0b3fa82d4 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -949,6 +949,16 @@ export const en = { accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + checkStatus: { + passed: "Passed", + failed: "Failed", + warning: "Warning", + actionRequired: "Action required", + manual: "Manual", + pending: "Pending", + skipped: "Skipped", + cancelled: "Cancelled", + }, }, states: { draft: "Draft", @@ -1139,6 +1149,14 @@ export const en = { serviceUnhealthy: "Service {{name}} unhealthy", creating: "Creating...", }, + checks: { + passed: "Passed: {{count}}", + failed: "Failed: {{count}}", + warning: "Warnings: {{count}}", + actionRequired: "Action required: {{count}}", + manual: "Manual: {{count}}", + pending: "Pending: {{count}}", + }, actions: { menu: "Workspace actions", newWorkspace: "New workspace", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index e17e3fab3a..4a6c4a61a0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -971,6 +971,16 @@ export const es: TranslationResources = { accessibility: { pullRequest: "Solicitud de extracción n.°{{number}}", pullRequest_mr: "Solicitud de fusión !{{number}}", + checkStatus: { + passed: "Superado", + failed: "Fallido", + warning: "Advertencia", + actionRequired: "Acción requerida", + manual: "Manual", + pending: "Pendiente", + skipped: "Omitido", + cancelled: "Cancelado", + }, }, states: { draft: "Borrador", @@ -1164,6 +1174,14 @@ export const es: TranslationResources = { serviceUnhealthy: "Servicio {{name}} con fallos", creating: "Creando...", }, + checks: { + passed: "Superados: {{count}}", + failed: "Fallidos: {{count}}", + warning: "Advertencias: {{count}}", + actionRequired: "Acción requerida: {{count}}", + manual: "Manuales: {{count}}", + pending: "Pendientes: {{count}}", + }, actions: { menu: "AccionesWorkspace", newWorkspace: "Nuevo espacio de trabajo", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 86d6d17841..35c9b3c42e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -970,6 +970,16 @@ export const fr: TranslationResources = { accessibility: { pullRequest: "Demande de tirage #{{number}}", pullRequest_mr: "Demande de fusion !{{number}}", + checkStatus: { + passed: "Réussi", + failed: "Échec", + warning: "Avertissement", + actionRequired: "Action requise", + manual: "Manuel", + pending: "En attente", + skipped: "Ignoré", + cancelled: "Annulé", + }, }, states: { draft: "Brouillon", @@ -1164,6 +1174,14 @@ export const fr: TranslationResources = { serviceUnhealthy: "Service {{name}} en échec", creating: "Création...", }, + checks: { + passed: "Réussis : {{count}}", + failed: "Échecs : {{count}}", + warning: "Avertissements : {{count}}", + actionRequired: "Action requise : {{count}}", + manual: "Manuels : {{count}}", + pending: "En attente : {{count}}", + }, actions: { menu: "ActionsWorkspace", newWorkspace: "Nouvel espace de travail", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 75bf79bc00..ad99106e28 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -951,6 +951,16 @@ export const ja: TranslationResources = { accessibility: { pullRequest: "プルリクエスト#{{number}}", pullRequest_mr: "マージリクエスト !{{number}}", + checkStatus: { + passed: "成功", + failed: "失敗", + warning: "警告", + actionRequired: "操作が必要", + manual: "手動", + pending: "保留中", + skipped: "スキップ済み", + cancelled: "キャンセル済み", + }, }, states: { draft: "ドラフト", @@ -1142,6 +1152,14 @@ export const ja: TranslationResources = { serviceUnhealthy: "サービス {{name}} 異常", creating: "作成中...", }, + checks: { + passed: "成功: {{count}}", + failed: "失敗: {{count}}", + warning: "警告: {{count}}", + actionRequired: "操作が必要: {{count}}", + manual: "手動: {{count}}", + pending: "保留中: {{count}}", + }, actions: { menu: "ワークスペースアクション", newWorkspace: "新しいワークスペース", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index c7757649df..21540ec348 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -947,6 +947,16 @@ export const ko: TranslationResources = { accessibility: { pullRequest: "풀 리퀘스트 #{{number}}", pullRequest_mr: "병합 요청 !{{number}}", + checkStatus: { + passed: "통과", + failed: "실패", + warning: "경고", + actionRequired: "조치 필요", + manual: "수동", + pending: "대기 중", + skipped: "건너뜀", + cancelled: "취소됨", + }, }, states: { draft: "초안", @@ -1137,6 +1147,14 @@ export const ko: TranslationResources = { serviceUnhealthy: "서비스 {{name}} 비정상", creating: "생성하는 중...", }, + checks: { + passed: "통과: {{count}}개", + failed: "실패: {{count}}개", + warning: "경고: {{count}}개", + actionRequired: "조치 필요: {{count}}개", + manual: "수동: {{count}}개", + pending: "대기 중: {{count}}개", + }, actions: { menu: "워크스페이스 작업", newWorkspace: "새 워크스페이스", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 90ff716a76..576ca298e3 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -962,6 +962,16 @@ export const ptBR: TranslationResources = { accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + checkStatus: { + passed: "Aprovado", + failed: "Falhou", + warning: "Aviso", + actionRequired: "Ação necessária", + manual: "Manual", + pending: "Pendente", + skipped: "Ignorado", + cancelled: "Cancelado", + }, }, states: { draft: "Rascunho", @@ -1155,6 +1165,14 @@ export const ptBR: TranslationResources = { serviceUnhealthy: "Serviço {{name}} com falha", creating: "Criando...", }, + checks: { + passed: "Aprovados: {{count}}", + failed: "Falhas: {{count}}", + warning: "Avisos: {{count}}", + actionRequired: "Ação necessária: {{count}}", + manual: "Manuais: {{count}}", + pending: "Pendentes: {{count}}", + }, actions: { menu: "Ações do workspace", newWorkspace: "Novo workspace", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 86f12175e9..7aa31e9f53 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -955,6 +955,16 @@ export const ru: TranslationResources = { accessibility: { pullRequest: "PR #{{number}}", pullRequest_mr: "MR !{{number}}", + checkStatus: { + passed: "Успешно", + failed: "Ошибка", + warning: "Предупреждение", + actionRequired: "Требуется действие", + manual: "Вручную", + pending: "Ожидание", + skipped: "Пропущено", + cancelled: "Отменено", + }, }, states: { draft: "Черновик", @@ -1146,6 +1156,14 @@ export const ru: TranslationResources = { serviceUnhealthy: "Сервис {{name}} работает некорректно", creating: "Создание...", }, + checks: { + passed: "Успешные: {{count}}", + failed: "Ошибки: {{count}}", + warning: "Предупреждения: {{count}}", + actionRequired: "Требуется действие: {{count}}", + manual: "Вручную: {{count}}", + pending: "Ожидают: {{count}}", + }, actions: { menu: "Действия рабочего пространства", newWorkspace: "Новое рабочее пространство", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 94a00cec8c..240a1c5a8b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -932,6 +932,16 @@ export const zhCN: TranslationResources = { accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "合并请求 !{{number}}", + checkStatus: { + passed: "成功", + failed: "失败", + warning: "警告", + actionRequired: "需要操作", + manual: "手动", + pending: "等待中", + skipped: "已跳过", + cancelled: "已取消", + }, }, states: { draft: "Draft", @@ -1120,6 +1130,14 @@ export const zhCN: TranslationResources = { serviceUnhealthy: "服务 {{name}} 异常", creating: "正在创建...", }, + checks: { + passed: "成功: {{count}}", + failed: "失败: {{count}}", + warning: "警告: {{count}}", + actionRequired: "需要操作: {{count}}", + manual: "手动: {{count}}", + pending: "等待中: {{count}}", + }, actions: { menu: "Workspace 操作", newWorkspace: "新建 workspace", diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index 9f04d35e4d..0cb0680605 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -199,8 +199,9 @@ export function pickerItemToCheckoutRequest( }, ...(forge === "github" ? { - // COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once - // daemon floor parses checkoutSource. + // COMPAT(githubPrNumber): send the legacy GitHub checkout input + // to daemons predating checkoutSource. Remove after 2027-01-17 + // once the supported daemon floor is >= v0.2.0. githubPrNumber: item.item.number, } : {}), diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 288a763fed..5ccb5fcd9a 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1678,11 +1678,11 @@ export function NewWorkspaceScreen({ ); const selectedItem = pickerSelection.selectedItem; - const handleGithubPrDetected = useCallback(() => { + const handleForgeChangeRequestDetected = useCallback(() => { dispatchPickerSelection({ type: "pr-detected" }); }, []); - const handleGithubPrAutoAttach = useCallback((item: ForgeSearchItem) => { + const handleForgeChangeRequestAutoAttach = useCallback((item: ForgeSearchItem) => { dispatchPickerSelection({ type: "pr-added", item: { kind: "github-pr", item }, @@ -2312,7 +2312,7 @@ export function NewWorkspaceScreen({ submitButtonTestID="workspace-create-submit" submitIcon="return" isSubmitLoading={isPending} - waitForGithubAutoAttachOnSubmit + waitForForgeAutoAttachOnSubmit submitBehavior="preserve-and-lock" blurOnSubmit={true} value={chatDraft.text} @@ -2321,8 +2321,8 @@ export function NewWorkspaceScreen({ attachments={chatDraft.attachments} attachmentScopeKeys={visibleDraftContextScopeKeys} onChangeAttachments={chatDraft.setAttachments} - onGithubPrDetected={handleGithubPrDetected} - onGithubPrAutoAttach={handleGithubPrAutoAttach} + onForgeChangeRequestDetected={handleForgeChangeRequestDetected} + onForgeChangeRequestAutoAttach={handleForgeChangeRequestAutoAttach} cwd={selectedSourceDirectory ?? ""} clearDraft={handleClearDraft} autoFocus diff --git a/packages/app/src/utils/github-refs.test.ts b/packages/app/src/utils/github-refs.test.ts deleted file mode 100644 index b8c69d1087..0000000000 --- a/packages/app/src/utils/github-refs.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { extractGithubRefs, normalizeGithubRemote, parseGithubRef } from "./github-refs"; - -const httpsRemote = "https://github.com/getpaseo/paseo.git"; -const sshRemote = "git@github.com:getpaseo/paseo.git"; - -describe("normalizeGithubRemote", () => { - it.each([ - ["https://github.com/getpaseo/paseo", { owner: "getpaseo", repo: "paseo", host: "github.com" }], - [ - "https://github.com/getpaseo/paseo.git", - { owner: "getpaseo", repo: "paseo", host: "github.com" }, - ], - ["git@github.com:getpaseo/paseo.git", { owner: "getpaseo", repo: "paseo", host: "github.com" }], - [ - "ssh://git@github.com/getpaseo/paseo.git", - { owner: "getpaseo", repo: "paseo", host: "github.com" }, - ], - ])("extracts GitHub identity from %s", (remoteUrl, expected) => { - expect(normalizeGithubRemote(remoteUrl)).toEqual(expected); - }); - - it("returns null for non-GitHub remotes and empty input", () => { - expect(normalizeGithubRemote("git@gitlab.com:getpaseo/paseo.git")).toBeNull(); - expect(normalizeGithubRemote(null)).toBeNull(); - }); -}); - -describe("parseGithubRef", () => { - it.each([ - "https://github.com/getpaseo/paseo/pull/994", - "https://github.com/getpaseo/paseo/pull/994/", - "https://github.com/getpaseo/paseo/pull/994/files", - "https://github.com/getpaseo/paseo/pull/994?diff=split", - "https://github.com/getpaseo/paseo/pull/994#discussion_r123", - ])("parses a matching pull request URL: %s", (text) => { - expect(parseGithubRef(text, httpsRemote)).toEqual({ - kind: "pull", - number: 994, - owner: "getpaseo", - repo: "paseo", - url: "https://github.com/getpaseo/paseo/pull/994", - }); - }); - - it("parses a matching issue URL", () => { - expect(parseGithubRef("https://github.com/getpaseo/paseo/issues/456", httpsRemote)).toEqual({ - kind: "issues", - number: 456, - owner: "getpaseo", - repo: "paseo", - url: "https://github.com/getpaseo/paseo/issues/456", - }); - }); - - it("matches HTTPS pasted URLs against an SSH remote", () => { - expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", sshRemote)).toEqual({ - kind: "pull", - number: 994, - owner: "getpaseo", - repo: "paseo", - url: "https://github.com/getpaseo/paseo/pull/994", - }); - }); - - it("ignores URLs for another owner or repo", () => { - expect(parseGithubRef("https://github.com/other/paseo/pull/994", httpsRemote)).toBeNull(); - expect(parseGithubRef("https://github.com/getpaseo/other/pull/994", httpsRemote)).toBeNull(); - }); - - it("returns null for non-GitHub remotes and empty text", () => { - expect( - parseGithubRef( - "https://github.com/getpaseo/paseo/pull/994", - "git@gitlab.com:getpaseo/paseo.git", - ), - ).toBeNull(); - expect(parseGithubRef("", httpsRemote)).toBeNull(); - expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", null)).toBeNull(); - }); - - it("finds URLs embedded in text and markdown links", () => { - expect( - parseGithubRef( - "See:\n[the PR](https://github.com/getpaseo/paseo/pull/994/files).", - httpsRemote, - ), - ).toEqual({ - kind: "pull", - number: 994, - owner: "getpaseo", - repo: "paseo", - url: "https://github.com/getpaseo/paseo/pull/994", - }); - }); -}); - -describe("extractGithubRefs", () => { - it("returns every matching ref deduped by kind and number", () => { - const text = [ - "https://github.com/getpaseo/paseo/pull/994", - "https://github.com/getpaseo/paseo/issues/456#issuecomment-1", - "https://github.com/getpaseo/paseo/pull/994/files", - "https://github.com/other/paseo/issues/1", - ].join("\n"); - - expect(extractGithubRefs(text, httpsRemote)).toEqual([ - { - kind: "pull", - number: 994, - owner: "getpaseo", - repo: "paseo", - url: "https://github.com/getpaseo/paseo/pull/994", - }, - { - kind: "issues", - number: 456, - owner: "getpaseo", - repo: "paseo", - url: "https://github.com/getpaseo/paseo/issues/456", - }, - ]); - }); - - it("returns an empty array for empty text or null remote", () => { - expect(extractGithubRefs("", httpsRemote)).toEqual([]); - expect(extractGithubRefs("https://github.com/getpaseo/paseo/pull/994", null)).toEqual([]); - }); -}); diff --git a/packages/app/src/utils/github-refs.ts b/packages/app/src/utils/github-refs.ts deleted file mode 100644 index 5eb48bae31..0000000000 --- a/packages/app/src/utils/github-refs.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote"; - -export type GithubRefKind = "pull" | "issues"; - -export interface GithubRemote { - owner: string; - repo: string; - host: "github.com"; -} - -export interface GithubRef { - kind: GithubRefKind; - number: number; - owner: string; - repo: string; - url: string; -} - -interface ParsedGithubUrl { - kind: GithubRefKind; - number: number; - owner: string; - repo: string; -} - -const GITHUB_REF_URL_PATTERN = - /https?:\/\/github\.com\/([^/\s<>)\]]+)\/([^/\s<>)\]]+)\/(pull|issues)\/(\d+)(?:[/?#][^\s<>)\]]*)?/giu; - -export function normalizeGithubRemote(remoteUrl: string | null | undefined): GithubRemote | null { - const trimmed = remoteUrl?.trim(); - if (!trimmed) { - return null; - } - - const identity = parseGitHubRemoteUrl(trimmed); - if (!identity) { - return null; - } - - return { - owner: identity.owner, - repo: identity.name, - host: "github.com", - }; -} - -export function parseGithubRef( - text: string | null | undefined, - remoteUrl: string | null | undefined, -): GithubRef | null { - return extractGithubRefs(text, remoteUrl)[0] ?? null; -} - -export function extractGithubRefs( - text: string | null | undefined, - remoteUrl: string | null | undefined, -): GithubRef[] { - const remote = normalizeGithubRemote(remoteUrl); - const body = text?.trim(); - if (!remote || !body) { - return []; - } - - const refs: GithubRef[] = []; - const seen = new Set(); - - for (const match of body.matchAll(GITHUB_REF_URL_PATTERN)) { - const parsed = parseGithubUrlMatch(match); - if (!parsed || !matchesRemote(parsed, remote)) { - continue; - } - - const dedupeKey = `${parsed.kind}:${parsed.number}`; - if (seen.has(dedupeKey)) { - continue; - } - seen.add(dedupeKey); - - refs.push({ - kind: parsed.kind, - number: parsed.number, - owner: remote.owner, - repo: remote.repo, - url: `https://github.com/${remote.owner}/${remote.repo}/${parsed.kind}/${parsed.number}`, - }); - } - - return refs; -} - -function parseGithubUrlMatch(match: RegExpMatchArray): ParsedGithubUrl | null { - const owner = match[1]; - const repo = match[2]; - const kind = match[3]; - const numberText = match[4]; - if (!owner || !repo || !isGithubRefKind(kind) || !numberText) { - return null; - } - - const number = Number.parseInt(numberText, 10); - if (!Number.isSafeInteger(number) || number <= 0) { - return null; - } - - return { owner, repo, kind, number }; -} - -function isGithubRefKind(value: string): value is GithubRefKind { - return value === "pull" || value === "issues"; -} - -function matchesRemote(parsed: ParsedGithubUrl, remote: GithubRemote): boolean { - return ( - parsed.owner.toLowerCase() === remote.owner.toLowerCase() && - parsed.repo.toLowerCase() === remote.repo.toLowerCase() - ); -} diff --git a/packages/protocol/src/check-traits.ts b/packages/protocol/src/check-traits.ts new file mode 100644 index 0000000000..70bdb5f4db --- /dev/null +++ b/packages/protocol/src/check-traits.ts @@ -0,0 +1,14 @@ +/** + * Forge-neutral check traits carried on the wire alongside the stable check + * status union. Traits are open-ended by design (unknown values must be + * ignored by consumers); these constants cover the traits the shipped forges + * emit today so producers and the presentation layer cannot drift apart. + */ +export const CHECK_TRAIT_MANUAL = "manual"; +export const CHECK_TRAIT_ACTION_REQUIRED = "action_required"; +export const CHECK_TRAIT_WARNING = "warning"; + +export type KnownCheckTrait = + | typeof CHECK_TRAIT_MANUAL + | typeof CHECK_TRAIT_ACTION_REQUIRED + | typeof CHECK_TRAIT_WARNING; diff --git a/packages/protocol/src/gitea-status.ts b/packages/protocol/src/gitea-status.ts new file mode 100644 index 0000000000..fee02e2288 --- /dev/null +++ b/packages/protocol/src/gitea-status.ts @@ -0,0 +1,22 @@ +/** + * Maps a Gitea/Forgejo commit-status state (individual status or the combined + * aggregate, e.g. a forge fact's ciStatus) onto the neutral check status. + * Shared by the daemon's Gitea adapter and the app's Gitea forge module so the + * "warning is terminal and non-passing" rule lives in one place: upstream + * treats warning as IsSuccess() == false, so it blocks merges and must not + * render as a plain pass or hang as pending. + */ +export function mapGiteaCommitState(state: string): "success" | "failure" | "pending" | "skipped" { + switch (state.toLowerCase()) { + case "success": + return "success"; + case "failure": + case "error": + case "warning": + return "failure"; + case "skipped": + return "skipped"; + default: + return "pending"; + } +} diff --git a/packages/protocol/src/gitlab-pipeline.ts b/packages/protocol/src/gitlab-pipeline.ts new file mode 100644 index 0000000000..61b4718cdd --- /dev/null +++ b/packages/protocol/src/gitlab-pipeline.ts @@ -0,0 +1,19 @@ +/** + * Canonical set of GitLab pipeline/job statuses that count as "still active" + * (not yet terminal). Shared by the daemon (merge-capability facts, checks + * aggregation) and the app (pipeline presentation) so a new GitLab transitional + * status only needs to be added in one place. + */ +export const GITLAB_ACTIVE_PIPELINE_STATUSES = [ + "created", + "waiting_for_resource", + "preparing", + "pending", + "running", + "canceling", + "scheduled", +] as const; + +export type GitLabActivePipelineStatus = (typeof GITLAB_ACTIVE_PIPELINE_STATUSES)[number]; + +export const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set(GITLAB_ACTIVE_PIPELINE_STATUSES); diff --git a/packages/protocol/src/messages.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index 7136cbcc76..cf15e47962 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -26,7 +26,16 @@ describe("checkout PR schemas", () => { expect(parsed.forge).toBe("github"); }); - test("round-trips forge and neutral project identity", () => { + test("round-trips forge, project identity, and optional check presentation metadata", () => { + const checks = [ + { name: "legacy", status: "success", url: null }, + { + name: "deploy", + status: "pending", + url: null, + traits: ["manual", "action_required", "future-forge-trait"], + }, + ]; const payload = { forge: "github", projectPath: "getpaseo/paseo", @@ -38,7 +47,7 @@ describe("checkout PR schemas", () => { isMerged: false, isDraft: false, mergeable: "UNKNOWN" as const, - checks: [], + checks, }; expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload); @@ -635,6 +644,46 @@ describe("checkout PR schemas", () => { }); }); + // COMPAT(pipelineRawStatus): released peers <= v0.2.0-rc.1 require rawStatus, + // so daemons keep emitting it; this proves newer clients accept its absence + // once emission stops. + test("parses pipeline details without the compat rawStatus field", () => { + const details = CheckoutGithubGetCheckDetailsResponseSchema.parse({ + type: "checkout.github.get_check_details.response", + payload: { + cwd: "/tmp/repo", + success: true, + details: { + checkRunId: 307, + name: "Pipeline (feat/y)", + annotations: [], + failedJobs: [], + truncated: false, + pipeline: { + id: 307, + status: "running", + stages: [ + { + name: "test", + status: "running", + jobs: [{ id: 930, name: "unit", stage: "test", status: "running" }], + }, + ], + }, + }, + error: null, + requestId: "request-pipeline-no-raw", + }, + }).payload.details; + + expect(details?.pipeline).toMatchObject({ + id: 307, + status: "running", + stages: [{ jobs: [{ id: 930, status: "running" }] }], + }); + expect(details?.pipeline?.rawStatus).toBeUndefined(); + }); + test("keeps pipeline absent for legacy and GitHub check-details responses", () => { const details = CheckoutGithubGetCheckDetailsResponseSchema.parse({ type: "checkout.github.get_check_details.response", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 71ff5e522b..a9b46a6fc9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1050,6 +1050,9 @@ export const SetVoiceModeMessageSchema = z.object({ requestId: z.string().optional(), }); +// COMPAT(githubAttachmentKinds): legacy wire attachment retained when +// forge-neutral attachments shipped in v0.2.0-beta.1. Stop emitting it after +// 2027-01-17 once supported client and daemon floors are >= v0.2.0. export const GitHubPrAttachmentSchema = z.object({ type: z.literal("github_pr"), mimeType: z.literal("application/github-pr"), @@ -1074,6 +1077,9 @@ export const ForgeChangeRequestAttachmentSchema = z.object({ headRefName: z.string().nullable().optional(), }); +// COMPAT(githubAttachmentKinds): legacy wire attachment retained when +// forge-neutral attachments shipped in v0.2.0-beta.1. Stop emitting it after +// 2027-01-17 once supported client and daemon floors are >= v0.2.0. export const GitHubIssueAttachmentSchema = z.object({ type: z.literal("github_issue"), mimeType: z.literal("application/github-issue"), @@ -1565,8 +1571,9 @@ const GitSetupOptionsSchema = z.object({ refName: z.string().min(1).optional(), action: z.enum(["branch-off", "checkout"]).optional(), checkoutSource: ChangeRequestCheckoutSourceSchema.optional(), - // COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once - // clients send checkoutSource. + // COMPAT(githubPrNumber): legacy GitHub checkout input retained when + // checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once the + // supported client floor is >= v0.2.0. githubPrNumber: z.number().int().positive().optional(), }); @@ -2111,8 +2118,9 @@ export const CheckoutForgeSetAutoMergeRequestSchema = z.object({ requestId: z.string(), }); -// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once -// all supported clients use checkout.forge.set_auto_merge.*. +// COMPAT(githubAutoMergeRpc): legacy RPC retained when +// checkout.forge.set_auto_merge.* shipped in v0.2.0-beta.1. Stop serving and +// consuming it after 2027-01-17 once client and daemon floors are >= v0.2.0. export const CheckoutGithubSetAutoMergeRequestSchema = z.object({ type: z.literal("checkout.github.set_auto_merge.request"), cwd: z.string(), @@ -2181,8 +2189,9 @@ export const CheckoutForgeGetCheckDetailsRequestSchema = type: z.literal("checkout.forge.get_check_details.request"), }); -// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once -// all supported clients use checkout.forge.get_check_details.*. +// COMPAT(githubCheckDetailsRpc): legacy RPC retained when +// checkout.forge.get_check_details.* shipped in v0.2.0-beta.1. Stop serving +// and consuming it after 2027-01-17 once client and daemon floors are >= v0.2.0. export const CheckoutGithubGetCheckDetailsRequestSchema = CheckoutCheckDetailsRequestPayloadSchema.extend({ type: z.literal("checkout.github.get_check_details.request"), @@ -2275,8 +2284,9 @@ export const ForgeSearchItemSchema = GitHubSearchItemSchema.extend({ kind: z.enum(["issue", "change_request"]), }); -// COMPAT(githubSearchKind): added in v0.1.106, remove with the legacy -// github_search_request RPC after 2026-12-28. +// COMPAT(githubSearchKind): legacy GitHub kind aliases retained when neutral +// forge search shipped in v0.2.0-beta.1. Remove after 2027-01-17 together with +// the legacy github_search_request RPC. export const ForgeSearchKindSchema = z.enum([ "issue", "change_request", @@ -2296,8 +2306,9 @@ export const ForgeSearchRequestSchema = z.object({ requestId: z.string(), }); -// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once -// clients use forge.search.*. +// COMPAT(githubSearchRpc): legacy RPC retained when forge.search.* shipped in +// v0.2.0-beta.1. Stop serving and consuming it after 2027-01-17 once client +// and daemon floors are >= v0.2.0. export const GitHubSearchRequestSchema = z.object({ type: z.literal("github_search_request"), cwd: z.string(), @@ -2365,8 +2376,9 @@ export const CreatePaseoWorktreeRequestSchema = z.object({ refName: z.string().min(1).optional(), action: z.enum(["branch-off", "checkout"]).optional(), checkoutSource: ChangeRequestCheckoutSourceSchema.optional(), - // COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once - // clients send checkoutSource: { kind: "change_request", forge, number }. + // COMPAT(githubPrNumber): legacy GitHub checkout input retained when + // checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once the + // supported client floor is >= v0.2.0. githubPrNumber: z.number().int().positive().optional(), requestId: z.string(), }); @@ -2477,8 +2489,9 @@ export const WorkspaceCreateRequestSchema = z.object({ // New branch name for branch-off. The worktree path may use a different slug. branchName: z.string().min(1).optional(), checkoutSource: ChangeRequestCheckoutSourceSchema.optional(), - // COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once - // clients send checkoutSource. + // COMPAT(githubPrNumber): legacy GitHub checkout input retained when + // checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once + // the supported client floor is >= v0.2.0. githubPrNumber: z.number().int().positive().optional(), worktreeSlug: z.string().optional(), }), @@ -3301,15 +3314,25 @@ export const ServerInfoStatusPayloadSchema = z directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), - // COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old - // checkoutGithubSetAutoMerge fallback after 2026-12-28. + // COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the + // feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17 + // once the supported daemon floor is >= v0.2.0. checkoutForgeSetAutoMerge: z.boolean().optional(), + // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75 and retained as + // the fallback for checkoutForgeSetAutoMerge. Stop advertising and + // consuming it after 2027-01-17 once supported floors are >= v0.2.0. checkoutGithubSetAutoMerge: z.boolean().optional(), - // COMPAT(githubCheckDetails): added in v0.1.92, remove gate after 2026-12-08. + // COMPAT(githubCheckDetails): added in v0.1.92 and retained as the + // fallback for forgeCheckDetails. Stop advertising and consuming it + // after 2027-01-17 once supported floors are >= v0.2.0. githubCheckDetails: z.boolean().optional(), - // COMPAT(forgeCheckDetails): added in v0.1.106, remove githubCheckDetails fallback after 2026-12-28. + // COMPAT(forgeCheckDetails): added in v0.2.0-beta.1. Remove the feature + // gate and githubCheckDetails fallback after 2027-01-17 once the + // supported daemon floor is >= v0.2.0. forgeCheckDetails: z.boolean().optional(), - // COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28. + // COMPAT(forgeSearch): added in v0.2.0-beta.1. Remove the feature gate + // and github_search fallback after 2027-01-17 once the supported daemon + // floor is >= v0.2.0. forgeSearch: z.boolean().optional(), // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. daemonStatusRpc: z.boolean().optional(), @@ -3393,7 +3416,8 @@ export const ServerInfoStatusPayloadSchema = z providerRemoval: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), - // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. + // COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after + // 2027-01-17 once the supported daemon floor is >= v0.2.0. // Daemon advertises pluggable non-GitHub forge support (the forge registry); // the client gates non-GitHub setup UI on it. forgeProviders: z.boolean().optional(), @@ -3662,6 +3686,8 @@ export const WorkspaceGitHubRuntimePayloadSchema = z url: z.string().nullable(), workflow: z.string().optional(), duration: z.string().optional(), + // Open so future forge-neutral refinements remain parse-compatible. + traits: z.array(z.string()).optional(), }), ) .optional(), @@ -3736,10 +3762,14 @@ export const WorkspaceDescriptorPayloadSchema = z .optional(), scripts: z.array(WorkspaceScriptPayloadSchema).default([]), gitRuntime: WorkspaceGitRuntimePayloadSchema, + // COMPAT(githubRuntimeName): legacy wire-field name now carries + // forge-neutral runtime data. Introduce and migrate to a neutral + // forgeRuntime field before consumers stop using this name. Target cleanup + // after 2027-01-17 once the supported client floor is >= v0.2.0. githubRuntime: WorkspaceGitHubRuntimePayloadSchema, - // COMPAT(forge): added in v0.1.106, remove after 2026-12-27. The forge resolved - // for this workspace, so the sidebar/hover-card render the right brand mark. - // Old daemons omit it; absent means the client falls back to GitHub. + // COMPAT(forge): added in v0.2.0-beta.1. Treat an absent forge as GitHub + // until 2027-01-17; remove the consumer fallback once the supported daemon + // floor is >= v0.2.0. forge: z.string().optional(), project: ProjectPlacementPayloadSchema.optional(), // COMPAT(directorySync): sequence of this latest directory projection. @@ -4830,7 +4860,8 @@ const CheckoutPrGithubStatusSchema = CheckoutPrGithubStatusObjectSchema.optional const CheckoutPrForgeSpecificSchema = z.unknown().optional(); export const CheckoutPrStatusSchema = z.object({ - // COMPAT(forge): added in v0.1.106, remove the default after 2026-12-27 once daemon floor >= v0.1.106. + // COMPAT(forge): added in v0.2.0-beta.1. Remove the GitHub default after + // 2027-01-17 once the supported daemon floor is >= v0.2.0. forge: z.string().optional().default("github"), projectPath: z.string().optional(), number: z.number().optional(), @@ -4860,6 +4891,8 @@ export const CheckoutPrStatusSchema = z.object({ duration: z.string().optional(), checkRunId: z.number().optional(), workflowRunId: z.number().optional(), + // Open so future forge-neutral refinements remain parse-compatible. + traits: z.array(z.string()).optional(), }), ) .optional() @@ -4888,12 +4921,12 @@ const CheckoutPrStatusPayloadSchema = z.object({ cwd: z.string(), status: CheckoutPrStatusSchema.nullable(), githubFeaturesEnabled: z.boolean(), - // COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27. Optional richer - // signal that supersedes githubFeaturesEnabled. The legacy boolean stays for old clients - // and may remain true for non-auth error payloads so old clients still show the error. - // Drop the boolean once the daemon floor >= v0.1.106. + // COMPAT(forgeAuthState): added in v0.2.0-beta.1. Remove the legacy + // githubFeaturesEnabled normalization after 2027-01-17 once the supported + // daemon floor is >= v0.2.0. authState: ForgeAuthStateSchema, - // COMPAT(forge): added in v0.1.106, remove the default after 2026-12-27 once daemon floor >= v0.1.106. + // COMPAT(forge): added in v0.2.0-beta.1. Remove the GitHub default after + // 2027-01-17 once the supported daemon floor is >= v0.2.0. forge: z.string().optional().default("github"), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -5027,8 +5060,9 @@ export const CheckoutForgeSetAutoMergeResponseSchema = z.object({ }), }); -// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once -// all supported clients use checkout.forge.set_auto_merge.*. +// COMPAT(githubAutoMergeRpc): legacy RPC retained when +// checkout.forge.set_auto_merge.* shipped in v0.2.0-beta.1. Stop serving and +// consuming it after 2027-01-17 once client and daemon floors are >= v0.2.0. export const CheckoutGithubSetAutoMergeResponseSchema = z.object({ type: z.literal("checkout.github.set_auto_merge.response"), payload: z.object({ @@ -5101,7 +5135,11 @@ const CheckoutPipelineJobSchema = z.object({ name: z.string(), stage: z.string(), status: z.string(), - rawStatus: z.string(), + // COMPAT(pipelineRawStatus): no client reads this, but peers <= v0.2.0-rc.1 + // validate it as required, so daemons must keep emitting it. Optional since + // this schema so future daemons may omit it; delete the field and its + // emission after 2027-01-17 once the supported client floor is >= v0.2.0. + rawStatus: z.string().optional(), url: z.string().nullable().optional().default(null), allowFailure: z.boolean().optional().default(false), durationSeconds: z.number().nullable().optional().default(null), @@ -5116,7 +5154,8 @@ const CheckoutPipelineStageSchema = z.object({ const CheckoutPipelineSchema = z.object({ id: z.number(), status: z.string(), - rawStatus: z.string(), + // COMPAT(pipelineRawStatus): see CheckoutPipelineJobSchema.rawStatus. + rawStatus: z.string().optional(), url: z.string().nullable().optional().default(null), ref: z.string().nullable().optional().default(null), sha: z.string().nullable().optional().default(null), @@ -5159,8 +5198,9 @@ export const CheckoutForgeGetCheckDetailsResponseSchema = z.object({ }), }); -// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once -// all supported clients use checkout.forge.get_check_details.*. +// COMPAT(githubCheckDetailsRpc): legacy RPC retained when +// checkout.forge.get_check_details.* shipped in v0.2.0-beta.1. Stop serving +// and consuming it after 2027-01-17 once client and daemon floors are >= v0.2.0. export const CheckoutGithubGetCheckDetailsResponseSchema = z.object({ type: z.literal("checkout.github.get_check_details.response"), payload: z.object({ @@ -5281,9 +5321,9 @@ export const PullRequestTimelineResponseSchema = z.object({ error: PullRequestTimelineErrorSchema.nullable().optional().default(null), requestId: z.string().optional().default(""), githubFeaturesEnabled: z.boolean().optional().default(true), - // COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27. Optional richer - // signal that supersedes githubFeaturesEnabled, mirroring CheckoutPrStatusPayloadSchema. - // Drop the boolean once the daemon floor >= v0.1.106. + // COMPAT(forgeAuthState): added in v0.2.0-beta.1. Remove the legacy + // githubFeaturesEnabled normalization after 2027-01-17 once the supported + // daemon floor is >= v0.2.0. authState: ForgeAuthStateSchema, }) .optional() @@ -5403,8 +5443,9 @@ export const ForgeSearchResponseSchema = z.object({ payload: ForgeSearchResponsePayloadSchema, }); -// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once -// clients use forge.search.*. +// COMPAT(githubSearchRpc): legacy RPC retained when forge.search.* shipped in +// v0.2.0-beta.1. Stop serving and consuming it after 2027-01-17 once client +// and daemon floors are >= v0.2.0. export const GitHubSearchResponseSchema = z.object({ type: z.literal("github_search_response"), payload: GitHubSearchResponsePayloadSchema, diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index bdf985152d..6d39d59571 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -868,6 +868,15 @@ describe("workspace message schemas", () => { }); test("parses fetch_workspaces_response with optional runtime fields", () => { + const checks = [ + { name: "legacy", status: "success", url: null }, + { + name: "deploy", + status: "pending", + url: null, + traits: ["manual", "action_required", "future-forge-trait"], + }, + ]; const parsed = SessionOutboundMessageSchema.parse({ type: "fetch_workspaces_response", payload: { @@ -909,6 +918,7 @@ describe("workspace message schemas", () => { baseRefName: "main", headRefName: "workspace-git-service", isMerged: false, + checks, }, error: null, refreshedAt: "2026-04-12T00:00:00.000Z", @@ -930,6 +940,7 @@ describe("workspace message schemas", () => { aheadOfOrigin: 2, }); expect(parsed.payload.entries[0]?.githubRuntime?.pullRequest?.title).toBe("Runtime payloads"); + expect(parsed.payload.entries[0]?.githubRuntime?.pullRequest?.checks).toEqual(checks); }); test("older workspace parsers ignore additive runtime fields", () => { diff --git a/packages/server/src/server/agent/prompt-attachments.ts b/packages/server/src/server/agent/prompt-attachments.ts index 3803f24312..5817dc6113 100644 --- a/packages/server/src/server/agent/prompt-attachments.ts +++ b/packages/server/src/server/agent/prompt-attachments.ts @@ -51,6 +51,8 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin headRefName: attachment.headRefName, }); } + // COMPAT(githubAttachmentKinds): render legacy wire attachments until + // 2027-01-17, when supported client and daemon floors are >= v0.2.0. case "github_pr": { return renderChangeRequestAttachment({ forge: "github", @@ -72,6 +74,8 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin projectPath: attachment.projectPath, }); } + // COMPAT(githubAttachmentKinds): render legacy wire attachments until + // 2027-01-17, when supported client and daemon floors are >= v0.2.0. case "github_issue": { return renderIssueAttachment({ forge: "github", diff --git a/packages/server/src/server/checkout/status-projection.ts b/packages/server/src/server/checkout/status-projection.ts index b9405be3ea..7f7365efca 100644 --- a/packages/server/src/server/checkout/status-projection.ts +++ b/packages/server/src/server/checkout/status-projection.ts @@ -153,9 +153,10 @@ export function normalizeCheckoutPrStatusPayload( } if (status.forgeSpecific) { payload.forgeSpecific = status.forgeSpecific; - // COMPAT(forgeSpecific): added in v0.1.106, remove after 2026-12-27. Keep - // mirroring GitHub facts onto `github` for clients that predate forgeSpecific; - // drop once the daemon floor >= v0.1.106. + // COMPAT(forgeSpecific): forgeSpecific shipped in v0.2.0-beta.1. Keep + // mirroring GitHub facts onto `github` for clients that predate it. Stop + // emitting the mirror after 2027-01-17 once the supported client floor + // is >= v0.2.0. if (isGitHubPullRequestStatusFacts(status.forgeSpecific)) { const { forge: _forge, ...githubFacts } = status.forgeSpecific; payload.github = githubFacts; diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index 91611b0f8e..aa9409a5c8 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -15,7 +15,9 @@ export interface ResolveWorktreeCreationIntentInput { projectPath?: string; }; /** - * COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28. + * COMPAT(githubPrNumber): legacy GitHub checkout input retained when + * checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once the + * supported client floor is >= v0.2.0. */ githubPrNumber?: number; } diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 7545b9f510..789f1f0449 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -1313,9 +1313,10 @@ export class CheckoutSession { try { const resolvedCwd = expandTilde(cwd); - // COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 — - // the legacy github_search RPC is GitHub by definition; the modern - // forge.search RPC resolves the cwd's forge. + // COMPAT(githubSearchRpc): the legacy github_search RPC is GitHub by + // definition; forge.search.* shipped in v0.2.0-beta.1 and resolves the + // cwd's forge. Remove after 2027-01-17 once the supported client floor + // is >= v0.2.0. const resolvedForge = msg.type === "github_search_request" ? { forge: "github", service: this.github } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cad568d335..b3e910f341 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1654,15 +1654,25 @@ export class VoiceAssistantWebSocketServer { providersSnapshot: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: true, - // COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old - // checkoutGithubSetAutoMerge fallback after 2026-12-28. + // COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the + // feature gate and legacy fallback after 2027-01-17 once the supported + // daemon floor is >= v0.2.0. checkoutForgeSetAutoMerge: true, - // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13. + // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75 and retained as + // the fallback for checkoutForgeSetAutoMerge. Stop advertising it after + // 2027-01-17 once supported floors are >= v0.2.0. checkoutGithubSetAutoMerge: true, + // COMPAT(githubCheckDetails): added in v0.1.92 and retained as the + // fallback for forgeCheckDetails. Stop advertising it after 2027-01-17 + // once supported floors are >= v0.2.0. githubCheckDetails: true, - // COMPAT(forgeCheckDetails): added in v0.1.106, remove githubCheckDetails fallback after 2026-12-28. + // COMPAT(forgeCheckDetails): added in v0.2.0-beta.1. Remove the feature + // gate and legacy fallback after 2027-01-17 once the supported daemon + // floor is >= v0.2.0. forgeCheckDetails: true, - // COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28. + // COMPAT(forgeSearch): added in v0.2.0-beta.1. Remove the feature gate + // and legacy fallback after 2027-01-17 once the supported daemon floor + // is >= v0.2.0. forgeSearch: true, // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. ...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), @@ -1742,7 +1752,8 @@ export class VoiceAssistantWebSocketServer { providerRemoval: true, // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, - // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. + // COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after + // 2027-01-17 once the supported daemon floor is >= v0.2.0. forgeProviders: true, // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: true, diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 52fa6633ff..bcc550d64a 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -31,6 +31,7 @@ import type { ForgeAuthState, ForgeService, ForgeSpecificStatusFacts, + PullRequestCheck, PullRequestMergeable, } from "../services/forge-service.js"; import { createForgeService } from "../services/forge-registry.js"; @@ -159,13 +160,7 @@ export interface WorkspaceGitRuntimeSnapshot { isMerged: boolean; isDraft?: boolean; mergeable?: PullRequestMergeable; - checks?: Array<{ - name: string; - status: "success" | "failure" | "pending" | "skipped" | "cancelled"; - url: string | null; - workflow?: string; - duration?: string; - }>; + checks?: PullRequestCheck[]; checksStatus?: "none" | "pending" | "success" | "failure"; reviewDecision?: "approved" | "changes_requested" | "pending" | null; forgeSpecific?: ForgeSpecificStatusFacts; diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index cbf9a7f6ee..0dffe569e3 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -334,8 +334,9 @@ export function normalizeGitOptions( const normalizedWorktreeSlug = requestedWorktreeSlug ?? normalizedBranchName; const refName = merged.refName?.trim() || undefined; const action = merged.action; - // COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once - // clients send checkoutSource. + // COMPAT(githubPrNumber): legacy GitHub checkout input retained when + // checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once the + // supported client floor is >= v0.2.0. const checkoutSource = merged.checkoutSource ?? (merged.githubPrNumber diff --git a/packages/server/src/services/forge-service.ts b/packages/server/src/services/forge-service.ts index 4c50bc4956..fba9833cd8 100644 --- a/packages/server/src/services/forge-service.ts +++ b/packages/server/src/services/forge-service.ts @@ -8,8 +8,9 @@ export function normalizeForgeSearchKinds( if (!kinds) return ["issue", "change_request"]; return kinds.map((kind) => { - // COMPAT(githubSearchKind): added in v0.1.106, remove with the legacy - // github_search_request RPC after 2026-12-28. + // COMPAT(githubSearchKind): legacy GitHub kind aliases retained when + // neutral forge search shipped in v0.2.0-beta.1. Remove after 2027-01-17 + // together with the legacy github_search_request RPC. if (kind === "github-issue") return "issue"; if (kind === "github-pr" || kind === "pr") return "change_request"; return kind; @@ -67,6 +68,8 @@ export interface PullRequestCheck { duration?: string; checkRunId?: number; workflowRunId?: number; + /** Open forge-neutral refinements such as manual, action_required, or warning. */ + traits?: string[]; } export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure"; @@ -322,6 +325,11 @@ export interface PipelineJob { name: string; stage: string; status: PipelineJobStatus; + /** + * COMPAT(pipelineRawStatus): unread by clients but required by peers + * <= v0.2.0-rc.1, so adapters must keep populating it. Remove together with + * the wire field after 2027-01-17 once the client floor is >= v0.2.0. + */ rawStatus: string; url: string | null; allowFailure: boolean; @@ -342,6 +350,7 @@ export interface PipelineStage { export interface PipelineDetails { id: number; status: PipelineJobStatus; + /** COMPAT(pipelineRawStatus): see {@link PipelineJob.rawStatus}. */ rawStatus: string; url: string | null; ref: string | null; @@ -391,7 +400,9 @@ export interface SearchResult { featuresEnabled: boolean; authState: ForgeAuthState; /** - * COMPAT(githubFeaturesEnabled): added in v0.1.106, remove after 2026-12-28. + * COMPAT(githubFeaturesEnabled): legacy search result flag retained when + * neutral authState shipped in v0.2.0-beta.1. Remove after 2027-01-17 once + * supported client and daemon floors are >= v0.2.0. */ githubFeaturesEnabled?: boolean; } diff --git a/packages/server/src/services/gitea-service.test.ts b/packages/server/src/services/gitea-service.test.ts index 6b2028e667..768f1a7f05 100644 --- a/packages/server/src/services/gitea-service.test.ts +++ b/packages/server/src/services/gitea-service.test.ts @@ -556,7 +556,7 @@ describe("createGiteaService", () => { ]); }); - it("keeps only the latest Gitea Actions rerun for the same workflow", async () => { + it("keeps the latest Gitea Actions run without collapsing same-name jobs", async () => { const headSha = STATUS_PR_VIEW.headSha; const { service } = makeService((args) => { if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); @@ -578,6 +578,16 @@ describe("createGiteaService", () => { url: "https://example.invalid/actions/runs/4", created_at: "2026-06-28T18:00:00+02:00", }, + { + id: 40, + name: "verify", + head_sha: headSha, + run_number: 5, + status: "failure", + workflow_id: "ci.yml", + url: "https://example.invalid/actions/runs/5", + created_at: "2026-06-28T18:10:00+02:00", + }, { id: 42, name: "verify", @@ -588,8 +598,18 @@ describe("createGiteaService", () => { url: "https://example.invalid/actions/runs/5", created_at: "2026-06-28T18:10:00+02:00", }, + { + id: 43, + name: "lint", + head_sha: headSha, + run_number: 5, + status: "success", + workflow_id: "ci.yml", + url: "https://example.invalid/actions/runs/5", + created_at: "2026-06-28T18:10:01+02:00", + }, ], - total_count: 2, + total_count: 4, }), ); } @@ -601,17 +621,65 @@ describe("createGiteaService", () => { headRef: "feat/sample-change", }); - expect(status?.checksStatus).toBe("success"); + expect(status?.checksStatus).toBe("failure"); expect(status?.checks).toEqual([ + { + name: "verify", + status: "failure", + url: "https://example.invalid/actions/runs/5", + workflowRunId: 40, + }, { name: "verify", status: "success", url: "https://example.invalid/actions/runs/5", workflowRunId: 42, }, + { + name: "lint", + status: "success", + url: "https://example.invalid/actions/runs/5", + workflowRunId: 43, + }, ]); }); + it("keeps sibling tasks when a nonmatching SHA separates their pages", async () => { + const headSha = STATUS_PR_VIEW.headSha; + const task = (id: number, name: string, sha = headSha) => ({ + id, + name, + head_sha: sha, + run_number: 6, + status: "success", + workflow_id: "ci.yml", + url: "https://example.invalid/actions/runs/6", + }); + const pages = [[task(61, "first")], [task(50, "other", "other-sha")], [task(62, "second")]]; + const { service, calls } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); + if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 })); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + const page = Number(new URL(`https://gitea.invalid/${args[1]}`).searchParams.get("page")); + return ok(JSON.stringify({ workflow_runs: pages[page - 1] ?? [], total_count: 3 })); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/sample-change", + }); + + expect(status?.checks.map((check) => check.name)).toEqual(["first", "second"]); + expect( + calls.filter((args) => args[0] === "api" && args[1].includes("/actions/tasks")), + ).toHaveLength(3); + }); + it("reports failure when Actions fail even if the commit-status aggregate is success", async () => { const headSha = STATUS_PR_VIEW.headSha; const { service } = makeService((args) => { @@ -719,14 +787,26 @@ describe("createGiteaService", () => { ]); }); - it("maps Gitea Actions run status values", async () => { + it("maps Gitea Actions task statuses and approval requirements", async () => { const headSha = STATUS_PR_VIEW.headSha; - const { service } = makeService((args) => { + const { service, calls } = makeService((args) => { if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); if (args[0] === "api" && args[1].includes("/commits/")) { return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 })); } + if (args[0] === "api" && args[1].includes("/actions/runs?")) { + const page = Number(new URL(`https://gitea.invalid/${args[1]}`).searchParams.get("page")); + return ok( + JSON.stringify({ + workflow_runs: + page === 1 + ? [{ index_in_repo: 18, commit_sha: headSha, need_approval: false }] + : [{ index_in_repo: 17, commit_sha: headSha, need_approval: true }], + total_count: 2, + }), + ); + } if (args[0] === "api" && args[1].includes("/actions/tasks")) { return ok( JSON.stringify({ @@ -779,8 +859,26 @@ describe("createGiteaService", () => { workflow_id: "skipped.yml", url: "https://example.invalid/actions/runs/16", }, + { + id: 17, + name: "approval-required", + head_sha: headSha, + run_number: 17, + status: "blocked", + workflow_id: "approval.yml", + url: "https://example.invalid/actions/runs/17", + }, + { + id: 18, + name: "dependency-blocked", + head_sha: headSha, + run_number: 18, + status: "blocked", + workflow_id: "dependency.yml", + url: "https://example.invalid/actions/runs/18", + }, ], - total_count: 6, + total_count: 8, }), ); } @@ -800,7 +898,22 @@ describe("createGiteaService", () => { "pending", "pending", "skipped", + "pending", + "pending", ]); + expect(status?.checks.find((check) => check.name === "approval-required")).toMatchObject({ + status: "pending", + traits: ["action_required"], + }); + expect(status?.checks.find((check) => check.name === "dependency-blocked")).toMatchObject({ + status: "pending", + }); + expect( + status?.checks.find((check) => check.name === "dependency-blocked")?.traits, + ).toBeUndefined(); + expect( + calls.filter((args) => args[0] === "api" && args[1].includes("/actions/runs?")), + ).toHaveLength(2); }); it("combines Gitea commit statuses with matching Actions runs", async () => { @@ -859,11 +972,452 @@ describe("createGiteaService", () => { ]); }); + it("deduplicates native Actions shadows without hiding external statuses", async () => { + const headSha = STATUS_PR_VIEW.headSha; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); + if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok( + JSON.stringify({ + state: "success", + statuses: [ + { + id: 81, + status: "success", + context: "build / verify (push)", + target_url: "/example-user/sample-repo/actions/runs/3/jobs/10", + creator: null, + }, + { + id: 82, + status: "success", + context: "build / verify (pull_request)", + target_url: "https://codeberg.org/example-user/sample-repo/actions/runs/3/jobs/11", + creator: STATUS_CREATOR, + }, + { + id: 83, + status: "success", + context: "quality / verify (external)", + target_url: "https://ci.example.invalid/actions/runs/3/jobs/12", + creator: STATUS_CREATOR, + }, + { + id: 84, + status: "success", + context: "external/quality", + target_url: "https://example.invalid/quality", + creator: STATUS_CREATOR, + }, + ], + total_count: 4, + }), + ); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + return ok( + JSON.stringify({ + workflow_runs: [ + { + id: 6979709, + name: "verify", + head_sha: headSha, + status: "success", + workflow_id: "ci.yml", + url: "https://codeberg.org/example-user/sample-repo/actions/runs/3", + }, + ], + total_count: 1, + }), + ); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/sample-change", + }); + + expect(status?.checks.map((check) => check.name)).toEqual([ + "quality / verify (external)", + "external/quality", + "verify", + ]); + }); + + it("deduplicates shadows when the run id differs from the run number", async () => { + const headSha = STATUS_PR_VIEW.headSha; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); + if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok( + JSON.stringify({ + state: "success", + statuses: [ + { + id: 95, + status: "success", + context: "build / verify (push)", + // target_url embeds run.ID (12345), unrelated to run_number + // (run.Index, 7) — upstream run.Link() uses the database id. + target_url: + "https://codeberg.org/example-user/sample-repo/actions/runs/12345/jobs/1", + creator: null, + }, + ], + total_count: 1, + }), + ); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + return ok( + JSON.stringify({ + workflow_runs: [ + { + id: 6979712, + name: "verify", + head_sha: headSha, + run_number: 7, + status: "success", + workflow_id: "ci.yml", + url: "https://codeberg.org/example-user/sample-repo/actions/runs/12345", + }, + ], + total_count: 1, + }), + ); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/sample-change", + }); + + expect(status?.checks.map((check) => check.name)).toEqual(["verify"]); + }); + + it("marks a fork PR awaiting Actions approval as action_required", async () => { + // Real gitea.com shape for a fork PR held for approval: the run is + // "waiting" (not "blocked"), exposes no need_approval, and produces NO + // tasks — only pending shadow commit statuses carrying the run id in their + // target_url. The cross-repo PR (head.repo.id != base.repo.id) is the + // approval-pending signal. + const headSha = STATUS_PR_VIEW.headSha; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); + if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok( + JSON.stringify({ + state: "pending", + statuses: [ + { + id: 201, + status: "pending", + context: "build / db-breaking (pull_request)", + target_url: "https://gitea.com/example-user/sample-repo/actions/runs/715170/jobs/1", + creator: null, + }, + { + id: 202, + status: "pending", + context: "build / backend-external-tests (pull_request)", + target_url: "https://gitea.com/example-user/sample-repo/actions/runs/715170/jobs/2", + creator: null, + }, + ], + total_count: 2, + }), + ); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + return ok(JSON.stringify({ workflow_runs: [], total_count: 0 })); + } + if (args[0] === "api" && args[1].includes("/actions/runs")) { + return ok( + JSON.stringify({ + workflow_runs: [ + { + id: 715170, + status: "waiting", + head_sha: headSha, + run_number: 7, + pull_requests: [ + { + number: 6, + head: { ref: "action-required-demo", repo: { id: 2 } }, + base: { ref: "main", repo: { id: 1 } }, + }, + ], + }, + ], + total_count: 1, + }), + ); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/sample-change", + }); + + expect(status?.checks).toEqual([ + { + name: "build / db-breaking (pull_request)", + status: "pending", + traits: ["action_required"], + url: "https://gitea.com/example-user/sample-repo/actions/runs/715170/jobs/1", + checkRunId: 201, + }, + { + name: "build / backend-external-tests (pull_request)", + status: "pending", + traits: ["action_required"], + url: "https://gitea.com/example-user/sample-repo/actions/runs/715170/jobs/2", + checkRunId: 202, + }, + ]); + expect(status?.checksStatus).toBe("pending"); + }); + + it("does not mark a same-repo waiting run as action_required", async () => { + // A same-repo run waiting for a runner (head.repo.id == base.repo.id) must + // stay plain pending — approval gating only applies to cross-repo forks. + const headSha = STATUS_PR_VIEW.headSha; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); + if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok( + JSON.stringify({ + state: "pending", + statuses: [ + { + id: 211, + status: "pending", + context: "build / db-breaking (push)", + target_url: "https://gitea.com/example-user/sample-repo/actions/runs/715171/jobs/1", + creator: null, + }, + ], + total_count: 1, + }), + ); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + return ok(JSON.stringify({ workflow_runs: [], total_count: 0 })); + } + if (args[0] === "api" && args[1].includes("/actions/runs")) { + return ok( + JSON.stringify({ + workflow_runs: [ + { + id: 715171, + status: "waiting", + head_sha: headSha, + run_number: 8, + pull_requests: [ + { number: 7, head: { repo: { id: 1 } }, base: { repo: { id: 1 } } }, + ], + }, + ], + total_count: 1, + }), + ); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/sample-change", + }); + + expect(status?.checks.map((c) => c.traits ?? [])).toEqual([[]]); + expect(status?.checks[0]?.status).toBe("pending"); + }); + + it("keeps external statuses that only look like Actions shadows", async () => { + const headSha = STATUS_PR_VIEW.headSha; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); + if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok( + JSON.stringify({ + state: "failure", + statuses: [ + { + id: 91, + status: "failure", + context: "ext / verify (push)", + target_url: "https://ci.example.invalid/builds/77", + creator: null, + }, + { + id: 92, + status: "failure", + context: "other / verify (push)", + target_url: "https://codeberg.org/example-user/sample-repo/actions/runs/999/jobs/1", + creator: null, + }, + ], + total_count: 2, + }), + ); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + return ok( + JSON.stringify({ + workflow_runs: [ + { + id: 6979710, + name: "verify", + head_sha: headSha, + status: "success", + workflow_id: "ci.yml", + url: "https://codeberg.org/example-user/sample-repo/actions/runs/3", + }, + ], + total_count: 1, + }), + ); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/sample-change", + }); + + // Neither status mirrors a fetched Actions run: 91 lives on an external CI + // origin, 92 points at a run number no fetched task belongs to. Both must + // survive alongside the real Actions task. + expect(status?.checks.map((check) => check.name)).toEqual([ + "ext / verify (push)", + "other / verify (push)", + "verify", + ]); + expect(status?.checksStatus).toBe("failure"); + }); + + it("keeps URL-less statuses even when their context matches a task name", async () => { + const headSha = STATUS_PR_VIEW.headSha; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); + if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok( + JSON.stringify({ + state: "success", + statuses: [ + { + id: 93, + status: "success", + context: "build / verify (push)", + target_url: null, + }, + ], + total_count: 1, + }), + ); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + return ok( + JSON.stringify({ + workflow_runs: [ + { + id: 6979711, + name: "verify", + head_sha: headSha, + status: "success", + workflow_id: "ci.yml", + url: "https://codeberg.org/example-user/sample-repo/actions/runs/3", + }, + ], + total_count: 1, + }), + ); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/sample-change", + }); + + // Gitea always gives its own Actions shadow statuses a job target_url, so a + // URL-less status is external by construction and must stay visible. + expect(status?.checks.map((check) => check.name)).toEqual(["build / verify (push)", "verify"]); + }); + + it("collapses reruns to the latest execution when workflow_id is absent", async () => { + const headSha = STATUS_PR_VIEW.headSha; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); + if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 })); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + return ok( + JSON.stringify({ + workflow_runs: [ + { + id: 50, + name: "verify", + head_sha: headSha, + run_number: 8, + status: "failure", + url: "https://example.invalid/actions/runs/8", + }, + { + id: 51, + name: "verify", + head_sha: headSha, + run_number: 9, + status: "success", + url: "https://example.invalid/actions/runs/9", + }, + ], + total_count: 2, + }), + ); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/sample-change", + }); + + expect(status?.checks).toEqual([ + { + name: "verify", + status: "success", + url: "https://example.invalid/actions/runs/9", + workflowRunId: 51, + }, + ]); + expect(status?.checksStatus).toBe("success"); + }); + it.each([ - ["failure", "failure", "failure"], - ["error", "error", "failure"], - ["success", "success", "success"], - ] as const)("maps Gitea commit status state %s", async (aggregate, state, expected) => { + ["failure", "failure", "failure", "failure"], + ["error", "error", "failure", "failure"], + ["success", "success", "success", "success"], + ["success", "skipped", "skipped", "success"], + ] as const)("maps Gitea commit status state %s", async (aggregate, state, check, summary) => { const { service } = makeService((args) => { if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR])); if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW)); @@ -884,8 +1438,8 @@ describe("createGiteaService", () => { headRef: "feat/sample-change", }); - expect(status?.checksStatus).toBe(expected); - expect(status?.checks[0]?.status).toBe(expected); + expect(status?.checksStatus).toBe(summary); + expect(status?.checks[0]?.status).toBe(check); }); it("maps a Gitea 'warning' commit status to failure (terminal, non-passing), not stuck pending", async () => { @@ -913,6 +1467,7 @@ describe("createGiteaService", () => { // merge). Our enum has no yellow bucket, so it surfaces as failure — never // green success, never a never-resolving pending. expect(status?.checks[0]?.status).toBe("failure"); + expect(status?.checks[0]?.traits).toEqual(["warning"]); expect(status?.checksStatus).toBe("failure"); }); @@ -1555,6 +2110,95 @@ describe("createGiteaService", () => { ]); }); + it("resolves an open cross-fork PR by head sha despite the owner gate", async () => { + // The PR lives in the base repo but its head is on a fork + // (contributor:feat/fork-change), so the checkout-owner gate excludes it. + // The head sha uniquely identifies it, so resolution must accept it — this + // mirrors GitLab, which resolves fork MRs from the base checkout. + const forkHeadSha = "c4ce035f493f2c50f656e3e5bdba29b321a74042"; + const forkPr = { + number: 6, + html_url: "https://gitea.com/example-user/sample-repo/pulls/6", + title: "Fork contribution", + body: "", + state: "open", + merged: false, + mergeable: true, + updated_at: "2026-07-22T10:00:00Z", + labels: [], + head: { + ref: "feat/fork-change", + sha: forkHeadSha, + repo: { id: 2, owner: { login: "contributor" } }, + }, + base: { + ref: "main", + repo: { id: 1, owner: { login: "example-user" } }, + }, + }; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok("[]"); + if (args[0] === "api" && args[1].includes("/pulls?state=all")) { + return ok(JSON.stringify([forkPr])); + } + if (args[0] === "pr" && args[1] === "6") { + return ok(JSON.stringify({ ...STATUS_PR_VIEW, index: 6, headSha: forkHeadSha })); + } + if (args[0] === "api" && args[1].includes("/commits/")) { + return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 })); + } + if (args[0] === "api" && args[1].includes("/actions/tasks")) { + return ok(JSON.stringify({ workflow_runs: [], total_count: 0 })); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/fork-change", + headSha: forkHeadSha, + }); + + expect(status?.number).toBe(6); + }); + + it("does not resolve a cross-fork PR when the head sha does not match", async () => { + // Without a sha match the owner gate still protects against blindly picking + // a colliding cross-fork branch name. + const forkPr = { + number: 6, + html_url: "https://gitea.com/example-user/sample-repo/pulls/6", + title: "Fork contribution", + body: "", + state: "open", + merged: false, + mergeable: true, + updated_at: "2026-07-22T10:00:00Z", + labels: [], + head: { + ref: "feat/fork-change", + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + repo: { id: 2, owner: { login: "contributor" } }, + }, + base: { ref: "main", repo: { id: 1, owner: { login: "example-user" } } }, + }; + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok("[]"); + if (args[0] === "api" && args[1].includes("/pulls?state=all")) { + return ok(JSON.stringify([forkPr])); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/fork-change", + headSha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + ).resolves.toBeNull(); + }); + it("lists open pull requests", async () => { const { service, calls } = makeService(() => ok(JSON.stringify([OPEN_PR]))); diff --git a/packages/server/src/services/gitea-service.ts b/packages/server/src/services/gitea-service.ts index 2d467a26ec..88b5caa004 100644 --- a/packages/server/src/services/gitea-service.ts +++ b/packages/server/src/services/gitea-service.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { CHECK_TRAIT_ACTION_REQUIRED, CHECK_TRAIT_WARNING } from "@getpaseo/protocol/check-traits"; +import { mapGiteaCommitState } from "@getpaseo/protocol/gitea-status"; import pLimit from "p-limit"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { findExecutable } from "../executable-resolution/executable-resolution.js"; @@ -71,8 +73,8 @@ const reviewCommentsLimit = pLimit(REVIEW_COMMENTS_FAN_OUT_CONCURRENCY); const CURRENT_PR_HEAD_SHA_CACHE_TTL_MS = 30_000; // 50 is the page-size cap Gitea enforces on list endpoints; five pages bounds // the sweep to the 250 most recent Actions tasks. -const GITEA_ACTIONS_RUNS_PAGE_LIMIT = 50; -const GITEA_ACTIONS_RUNS_MAX_PAGES = 5; +const GITEA_ACTION_PAGE_LIMIT = 50; +const GITEA_ACTION_MAX_PAGES = 5; /** * Fields requested from `tea pr list -o json`. tea's default field set omits the @@ -272,6 +274,7 @@ const GiteaCommitStatusSchema = z description: z.string().nullable().optional(), url: z.string().nullable().optional(), context: z.string().optional().default(""), + creator: z.unknown().nullable().optional(), }) .passthrough(); @@ -284,7 +287,7 @@ const GiteaCombinedCommitStatusSchema = z }) .passthrough(); -const GiteaActionsRunSchema = z +const GiteaActionTaskSchema = z .object({ id: z.number(), name: z.string().nullable().optional(), @@ -294,6 +297,7 @@ const GiteaActionsRunSchema = z event: z.string().nullable().optional(), display_title: z.string().nullable().optional(), status: z.string(), + need_approval: z.boolean().optional().default(false), workflow_id: z.string().nullable().optional(), url: z.string().nullable().optional(), created_at: z.string().nullable().optional(), @@ -301,16 +305,45 @@ const GiteaActionsRunSchema = z }) .passthrough(); -// The item shape is validated per-run rather than as `z.array(GiteaActionsRunSchema)` -// so one malformed run (missing id/status/head_sha) drops only itself instead of +// The item shape is validated per-task rather than as `z.array(GiteaActionTaskSchema)` +// so one malformed task (missing id/status/head_sha) drops only itself instead of // throwing out the whole page and zeroing every Actions check for the PR. -const GiteaActionsRunsSchema = z +const GiteaActionsListResponseSchema = z .object({ workflow_runs: z.array(z.unknown()).optional().default([]), total_count: z.number().optional(), }) .passthrough(); +const GiteaActionRunPullRequestRefSchema = z + .object({ + repo: z.object({ id: z.number().optional() }).passthrough().nullable().optional(), + }) + .passthrough(); + +const GiteaActionRunMetadataSchema = z + .object({ + id: z.number().optional(), + run_number: z.number().optional(), + index_in_repo: z.number().optional(), + head_sha: z.string().optional(), + commit_sha: z.string().optional(), + status: z.string().optional(), + need_approval: z.boolean().optional().default(false), + pull_requests: z + .array( + z + .object({ + head: GiteaActionRunPullRequestRefSchema.optional(), + base: GiteaActionRunPullRequestRefSchema.optional(), + }) + .passthrough(), + ) + .nullable() + .optional(), + }) + .passthrough(); + const GiteaIssueCommentSchema = z .object({ id: z.number(), @@ -357,7 +390,7 @@ type GiteaPullRequestView = z.infer; type GiteaCurrentPullRequestApi = z.infer; type GiteaCommitStatus = z.infer; type GiteaCombinedCommitStatus = z.infer; -type GiteaActionsRun = z.infer; +type GiteaActionTask = z.infer; type GiteaIssueComment = z.infer; type GiteaReview = z.infer; type GiteaReviewComment = z.infer; @@ -659,23 +692,7 @@ function mapGiteaCiStatus(ci: string | undefined): PullRequestChecksStatus { } } -function mapGiteaCommitStatus(state: string): PullRequestCheck["status"] { - switch (state.toLowerCase()) { - case "success": - return "success"; - case "failure": - case "error": - // See mapGiteaCiStatus: "warning" is terminal and non-passing (blocks merge). - case "warning": - return "failure"; - case "pending": - return "pending"; - default: - return "pending"; - } -} - -function mapGiteaActionsRunStatus(status: string): PullRequestCheck["status"] { +function mapGiteaActionTaskStatus(status: string): PullRequestCheck["status"] { switch (status.toLowerCase()) { case "success": return "success"; @@ -701,39 +718,104 @@ function mapGiteaActionsRunStatus(status: string): PullRequestCheck["status"] { } } -function toGiteaPullRequestCheck(status: GiteaCommitStatus): PullRequestCheck { +function toGiteaPullRequestCheck( + status: GiteaCommitStatus, + actionRequiredRunIds: ReadonlySet, +): PullRequestCheck { + const rawStatus = status.status.toLowerCase(); + const runId = getGiteaActionRunIdFromUrl(status.target_url); + if (runId !== null && actionRequiredRunIds.has(runId)) { + // The Actions run behind this shadow status is held awaiting approval; the + // raw shadow reads plain pending, so surface the gate as a neutral trait. + return { + name: status.context || `status-${status.id}`, + status: "pending", + traits: [CHECK_TRAIT_ACTION_REQUIRED], + url: status.target_url ?? null, + checkRunId: status.id, + }; + } return { name: status.context || `status-${status.id}`, - status: mapGiteaCommitStatus(status.status), + status: mapGiteaCommitState(rawStatus), + ...(rawStatus === "warning" ? { traits: [CHECK_TRAIT_WARNING] } : {}), url: status.target_url ?? null, checkRunId: status.id, }; } -function getGiteaActionsRunName(workflowRun: GiteaActionsRun): string { - return ( - workflowRun.name || - workflowRun.display_title || - workflowRun.workflow_id || - `actions-${workflowRun.id}` - ); +/** + * A Gitea Actions run is awaiting approval when it explicitly reports + * need_approval (self-hosted instances that expose it) OR when it is held in a + * non-terminal `waiting`/`blocked` state for a cross-repo fork PR. gitea.com + * does not set need_approval and represents the approval gate as a `waiting` + * run with no tasks, so the cross-repo signal is what disambiguates an approval + * hold from a run merely queued for a runner. + */ +function isGiteaRunAwaitingApproval(run: z.infer): boolean { + if (run.need_approval) return true; + const status = (run.status ?? "").toLowerCase(); + if (status !== "waiting" && status !== "blocked") return false; + const pr = run.pull_requests?.[0]; + const headRepoId = pr?.head?.repo?.id; + const baseRepoId = pr?.base?.repo?.id; + return headRepoId !== undefined && baseRepoId !== undefined && headRepoId !== baseRepoId; } -function toGiteaActionsPullRequestCheck(workflowRun: GiteaActionsRun): PullRequestCheck { +function deriveGiteaActionRequiredRunIds(runs: unknown[], sha: string): Set { + const runIds = new Set(); + for (const raw of runs) { + const parsed = GiteaActionRunMetadataSchema.safeParse(raw); + if (!parsed.success) continue; + const run = parsed.data; + if (run.id === undefined) continue; + if ((run.head_sha ?? run.commit_sha) !== sha) continue; + if (isGiteaRunAwaitingApproval(run)) runIds.add(run.id); + } + return runIds; +} + +function getGiteaActionTaskName(task: GiteaActionTask): string { + return task.name || task.display_title || task.workflow_id || `actions-${task.id}`; +} + +function toGiteaActionTaskCheck(task: GiteaActionTask): PullRequestCheck { + const rawStatus = task.status.toLowerCase(); return { - name: getGiteaActionsRunName(workflowRun), - status: mapGiteaActionsRunStatus(workflowRun.status), - url: workflowRun.url ?? null, - workflowRunId: workflowRun.id, + name: getGiteaActionTaskName(task), + status: mapGiteaActionTaskStatus(rawStatus), + ...(rawStatus === "blocked" && task.need_approval + ? { traits: [CHECK_TRAIT_ACTION_REQUIRED] } + : {}), + url: task.url ?? null, + workflowRunId: task.id, }; } -function getGiteaActionsWorkflowIdentity(workflowRun: GiteaActionsRun): string { - return workflowRun.workflow_id || workflowRun.name || `actions-${workflowRun.id}`; +function getGiteaActionWorkflowIdentity(task: GiteaActionTask): string { + if (task.workflow_id) { + return `workflow\u0000${task.workflow_id}`; + } + // Instances that omit workflow_id (older Gitea/Forgejo payloads) still need + // reruns of the same job collapsed to the latest execution, so fall back to + // the job name before treating the task as its own group. + const name = task.name || task.display_title; + if (name) { + return `name\u0000${name}`; + } + return `task\u0000${getGiteaActionExecutionIdentity(task)}`; } -function parseGiteaActionsRunTime(workflowRun: GiteaActionsRun): number { - const timestamp = workflowRun.created_at ?? workflowRun.run_started_at ?? null; +function getGiteaActionExecutionIdentity(task: GiteaActionTask): string { + if (task.run_number !== undefined) return `run-number\u0000${task.run_number}`; + const runId = getGiteaActionRunIdFromUrl(task.url); + if (runId !== null) return `run-id\u0000${runId}`; + if (task.url) return `url\u0000${task.url}`; + return `task\u0000${task.id}`; +} + +function parseGiteaActionTaskTime(task: GiteaActionTask): number { + const timestamp = task.created_at ?? task.run_started_at ?? null; if (!timestamp) { return 0; } @@ -741,15 +823,15 @@ function parseGiteaActionsRunTime(workflowRun: GiteaActionsRun): number { return Number.isNaN(time) ? 0 : time; } -function compareGiteaActionsRunRecency(left: GiteaActionsRun, right: GiteaActionsRun): number { +function compareGiteaActionTaskRecency(left: GiteaActionTask, right: GiteaActionTask): number { const leftRunNumber = left.run_number ?? 0; const rightRunNumber = right.run_number ?? 0; if (leftRunNumber !== rightRunNumber) { return leftRunNumber - rightRunNumber; } - const leftTime = parseGiteaActionsRunTime(left); - const rightTime = parseGiteaActionsRunTime(right); + const leftTime = parseGiteaActionTaskTime(left); + const rightTime = parseGiteaActionTaskTime(right); if (leftTime !== rightTime) { return leftTime - rightTime; } @@ -757,44 +839,115 @@ function compareGiteaActionsRunRecency(left: GiteaActionsRun, right: GiteaAction return left.id - right.id; } -function parseGiteaActionsRuns(rawRuns: unknown[]): GiteaActionsRun[] { - const runs: GiteaActionsRun[] = []; - for (const raw of rawRuns) { - const parsed = GiteaActionsRunSchema.safeParse(raw); +function parseGiteaActionTasks(rawTasks: unknown[]): GiteaActionTask[] { + const tasks: GiteaActionTask[] = []; + for (const raw of rawTasks) { + const parsed = GiteaActionTaskSchema.safeParse(raw); if (parsed.success) { - runs.push(parsed.data); + tasks.push(parsed.data); } } - return runs; + return tasks; } -function latestGiteaActionsRunsByWorkflow(actionsRuns: GiteaActionsRun[]): GiteaActionsRun[] { - const latestRuns = new Map(); - for (const workflowRun of actionsRuns) { - const identity = getGiteaActionsWorkflowIdentity(workflowRun); - const current = latestRuns.get(identity); - if (!current || compareGiteaActionsRunRecency(current, workflowRun) < 0) { - latestRuns.set(identity, workflowRun); +function latestGiteaActionTasksByWorkflow(tasks: GiteaActionTask[]): GiteaActionTask[] { + const latestExecutions = new Map(); + for (const task of tasks) { + const identity = getGiteaActionWorkflowIdentity(task); + const current = latestExecutions.get(identity); + if (!current || compareGiteaActionTaskRecency(current, task) < 0) { + latestExecutions.set(identity, task); } } - return [...latestRuns.values()]; + return tasks.filter((task) => { + const latest = latestExecutions.get(getGiteaActionWorkflowIdentity(task)); + return ( + latest !== undefined && + getGiteaActionExecutionIdentity(task) === getGiteaActionExecutionIdentity(latest) + ); + }); +} + +function getUrlOrigin(value: string | null | undefined): string | null { + if (!value) return null; + try { + return new URL(value).origin; + } catch { + return null; + } +} + +const GITEA_RELATIVE_URL_ORIGIN = "https://gitea.invalid"; + +function getGiteaActionRunIdFromUrl(value: string | null | undefined): number | null { + if (!value) return null; + try { + const url = new URL(value, GITEA_RELATIVE_URL_ORIGIN); + const match = url.pathname.match(/\/actions\/runs\/(\d+)(?:\/jobs\/\d+)?\/?$/); + return match?.[1] ? Number.parseInt(match[1], 10) : null; + } catch { + return null; + } +} + +function isNativeActionJobUrl( + targetUrl: string | null | undefined, + nativeOrigins: ReadonlySet, + nativeRunIds: ReadonlySet, +): boolean { + if (!targetUrl) return false; + try { + const url = new URL(targetUrl, GITEA_RELATIVE_URL_ORIGIN); + if (!/\/actions\/runs\/\d+\/jobs\/\d+\/?$/.test(url.pathname)) return false; + if (url.origin !== GITEA_RELATIVE_URL_ORIGIN && !nativeOrigins.has(url.origin)) return false; + const runId = getGiteaActionRunIdFromUrl(targetUrl); + return runId !== null && nativeRunIds.has(runId); + } catch { + return false; + } +} + +// A "shadow" is the commit status Gitea synthesizes to mirror one of its own +// Actions jobs; keeping both would double-report every job. Detection is purely +// structural: Gitea always creates these statuses with a target_url of the form +// "/jobs/" (services/actions/commit_status.go upstream), so a +// status is a shadow exactly when its URL points at a job of a run we actually +// fetched from the Actions API. Anything else - external CI, URL-less statuses, +// contexts that merely look like " / ()" - is kept. +function isGiteaActionShadowStatus( + status: GiteaCommitStatus, + nativeOrigins: ReadonlySet, + nativeRunIds: ReadonlySet, +): boolean { + return isNativeActionJobUrl(status.target_url, nativeOrigins, nativeRunIds); } function combineGiteaChecks( commitStatuses: GiteaCommitStatus[], - actionsRuns: GiteaActionsRun[], + actionTasks: GiteaActionTask[], + actionRequiredRunIds: ReadonlySet = new Set(), ): PullRequestCheck[] { - const checks = commitStatuses.map(toGiteaPullRequestCheck); - const seen = new Set( - checks.map((check) => `${check.name}\u0000${check.url ?? ""}\u0000${check.status}`), + const nativeOrigins = new Set( + actionTasks + .map((task) => getUrlOrigin(task.url)) + .filter((origin): origin is string => !!origin), ); - for (const workflowRun of actionsRuns) { - const check = toGiteaActionsPullRequestCheck(workflowRun); - const key = `${check.name}\u0000${check.url ?? ""}\u0000${check.status}`; - if (!seen.has(key)) { - checks.push(check); - seen.add(key); - } + // Shadow target_urls and task urls both embed run.ID (run.Link() upstream); + // run_number is the unrelated per-repo run.Index, so only URL-derived ids can + // match here. + const nativeRunIds = new Set( + actionTasks + .map((task) => getGiteaActionRunIdFromUrl(task.url)) + .filter((runId): runId is number => runId !== null), + ); + const checks = commitStatuses + .filter((status) => !isGiteaActionShadowStatus(status, nativeOrigins, nativeRunIds)) + .map((status) => toGiteaPullRequestCheck(status, actionRequiredRunIds)); + const seenTaskIds = new Set(); + for (const task of actionTasks) { + if (seenTaskIds.has(task.id)) continue; + checks.push(toGiteaActionTaskCheck(task)); + seenTaskIds.add(task.id); } return checks; } @@ -802,9 +955,10 @@ function combineGiteaChecks( function applyGiteaChecks( status: CurrentPullRequestStatus, combined: GiteaCombinedCommitStatus, - actionsRuns: GiteaActionsRun[], + actionTasks: GiteaActionTask[], + actionRequiredRunIds: ReadonlySet = new Set(), ): CurrentPullRequestStatus { - const checks = combineGiteaChecks(combined.statuses, actionsRuns); + const checks = combineGiteaChecks(combined.statuses, actionTasks, actionRequiredRunIds); if (checks.length === 0) { return status; } @@ -1448,13 +1602,23 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For { cwd: input.cwd }, GiteaCurrentPullRequestApiSchema, ); - const candidates = recentItems.filter((item) => - matchesCurrentHeadRef( - currentPullRequestApiToListItem(item), - input.headRef, - expectedHeadOwner, - ), - ); + const candidates = recentItems.filter((item) => { + if ( + matchesCurrentHeadRef( + currentPullRequestApiToListItem(item), + input.headRef, + expectedHeadOwner, + ) + ) { + return true; + } + // A cross-fork PR carries the fork owner in its head, so the checkout-owner + // gate excludes it even though the PR lives in this base repo. The head sha + // is an unambiguous identity, so accept a match on it regardless of owner — + // this mirrors the GitLab adapter, which resolves fork MRs without an + // owner gate. + return input.headSha !== undefined && item.head.sha === input.headSha; + }); const match = candidates.find((item) => mapGiteaState(item.state) === "open") ?? candidates.find( @@ -1513,21 +1677,22 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For if (!pr.headSha) { return status; } - const [combined, actionsRuns] = await Promise.all([ - loadCombinedCommitStatusBestEffort({ - cwd, - repoOwner: status.repoOwner, - repoName: status.repoName, - sha: pr.headSha, - }), - loadActionsRunsBestEffort({ - cwd, - repoOwner: status.repoOwner, - repoName: status.repoName, - sha: pr.headSha, - }), + const checksInput = { + cwd, + repoOwner: status.repoOwner, + repoName: status.repoName, + sha: pr.headSha, + }; + const [combined, actionTasks] = await Promise.all([ + loadCombinedCommitStatusBestEffort(checksInput), + loadActionTasksBestEffort(checksInput), ]); - return applyGiteaChecks(status, combined, actionsRuns); + const actionRequiredRunIds = await loadActionRequiredRunIdsBestEffort( + checksInput, + combined, + actionTasks, + ); + return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds); } catch { return status; } @@ -1563,64 +1728,125 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For ); } - async function loadActionsRunsBestEffort(input: { + // Only fetch runs when there is a pending Actions shadow status whose run has + // no corresponding task: that is the approval-hold signature on gitea.com + // (waiting run, no tasks, pending shadows). Skipping the fetch otherwise keeps + // the common running/finished path at two requests. + async function loadActionRequiredRunIdsBestEffort( + input: { cwd: string; repoOwner: string; repoName: string; sha: string }, + combined: GiteaCombinedCommitStatus, + actionTasks: GiteaActionTask[], + ): Promise> { + const taskRunIds = new Set( + actionTasks + .map((task) => getGiteaActionRunIdFromUrl(task.url)) + .filter((runId): runId is number => runId !== null), + ); + const hasUncoveredPendingShadow = combined.statuses.some((status) => { + const runId = getGiteaActionRunIdFromUrl(status.target_url); + return ( + runId !== null && + !taskRunIds.has(runId) && + mapGiteaCommitState(status.status.toLowerCase()) === "pending" + ); + }); + if (!hasUncoveredPendingShadow) return new Set(); + try { + const sha = encodeURIComponent(input.sha); + const runs = await loadGiteaActionPages(input, "runs", `head_sha=${sha}`); + return deriveGiteaActionRequiredRunIds(runs, input.sha); + } catch (error) { + rethrowTeaAuthFailure(error); + return new Set(); + } + } + + async function loadActionTasksBestEffort(input: { cwd: string; repoOwner: string; repoName: string; sha: string; - }): Promise { + }): Promise { try { - const runs = await loadActionsRuns(input); - const matchingRuns = runs.filter((workflowRun) => workflowRun.head_sha === input.sha); - return latestGiteaActionsRunsByWorkflow(matchingRuns); + const tasks = await loadActionTasks(input); + const matchingTasks = tasks.filter((task) => task.head_sha === input.sha); + const latestTasks = latestGiteaActionTasksByWorkflow(matchingTasks); + return await enrichActionTaskApprovalsBestEffort(input, latestTasks); } catch (error) { rethrowTeaAuthFailure(error); return []; } } + async function enrichActionTaskApprovalsBestEffort( + input: { cwd: string; repoOwner: string; repoName: string; sha: string }, + tasks: GiteaActionTask[], + ): Promise { + if (!tasks.some((task) => task.status.toLowerCase() === "blocked")) return tasks; + try { + const sha = encodeURIComponent(input.sha); + const approvalRunNumbers = new Set(); + const runs = await loadGiteaActionPages(input, "runs", `head_sha=${sha}`); + for (const rawRun of runs) { + const parsed = GiteaActionRunMetadataSchema.safeParse(rawRun); + if (!parsed.success || !parsed.data.need_approval) continue; + const metadata = parsed.data; + const runSha = metadata.head_sha ?? metadata.commit_sha; + const runNumber = metadata.run_number ?? metadata.index_in_repo; + if (runSha === input.sha && runNumber !== undefined) approvalRunNumbers.add(runNumber); + } + return tasks.map((task) => + task.status.toLowerCase() === "blocked" && + task.run_number !== undefined && + approvalRunNumbers.has(task.run_number) + ? { ...task, need_approval: true } + : task, + ); + } catch (error) { + rethrowTeaAuthFailure(error); + return tasks; + } + } + // The tasks endpoint pages at a small server default (10 on gitea.com) and a // self-hosted cap can sit below our requested 50; it also cannot filter by ref // or sha server-side. A short page therefore does NOT mean the last page — // conflating the two drops the current PR's run on a capped instance. Page on - // until the target sha is found, a page comes back empty, or the bounded - // max-pages cap is hit. - async function loadActionsRuns(input: { + // until total_count is exhausted, a page comes back empty, or the bounded + // max-pages cap is hit. SHA matches need not be contiguous across pages. + async function loadGiteaActionPages( + input: { cwd: string; repoOwner: string; repoName: string }, + endpoint: "tasks" | "runs", + query?: string, + ): Promise { + const owner = encodeURIComponent(input.repoOwner); + const repo = encodeURIComponent(input.repoName); + const items: unknown[] = []; + let fetched = 0; + for (let page = 1; page <= GITEA_ACTION_MAX_PAGES; page += 1) { + const queryPrefix = query ? `${query}&` : ""; + const response = await runJson( + [ + "api", + `repos/${owner}/${repo}/actions/${endpoint}?${queryPrefix}limit=${GITEA_ACTION_PAGE_LIMIT}&page=${page}`, + ], + { cwd: input.cwd }, + GiteaActionsListResponseSchema, + ); + if (response.workflow_runs.length === 0) break; + fetched += response.workflow_runs.length; + items.push(...response.workflow_runs); + if (response.total_count !== undefined && fetched >= response.total_count) break; + } + return items; + } + + async function loadActionTasks(input: { cwd: string; repoOwner: string; repoName: string; - sha: string; - }): Promise { - const owner = encodeURIComponent(input.repoOwner); - const repo = encodeURIComponent(input.repoName); - const runs: GiteaActionsRun[] = []; - let fetched = 0; - for (let page = 1; page <= GITEA_ACTIONS_RUNS_MAX_PAGES; page += 1) { - const batch = await runJson( - [ - "api", - `repos/${owner}/${repo}/actions/tasks?limit=${GITEA_ACTIONS_RUNS_PAGE_LIMIT}&page=${page}`, - ], - { cwd: input.cwd }, - GiteaActionsRunsSchema, - ); - if (batch.workflow_runs.length === 0) { - break; - } - fetched += batch.workflow_runs.length; - runs.push(...parseGiteaActionsRuns(batch.workflow_runs)); - if (runs.some((workflowRun) => workflowRun.head_sha === input.sha)) { - break; - } - // Stop once the whole result set is fetched: relying on a short page as - // the terminator is unsafe on instances whose page cap is below the - // requested limit, but `total_count` bounds the walk exactly, so a sha - // with no run does not pay the full max-page fan-out. - if (batch.total_count !== undefined && fetched >= batch.total_count) { - break; - } - } - return runs; + }): Promise { + return parseGiteaActionTasks(await loadGiteaActionPages(input, "tasks")); } async function resolveCurrentPullRequestHeadSha(cwd: string): Promise { @@ -1685,7 +1911,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For checkRunId: status.id, name: status.context || `status-${status.id}`, status: status.status, - conclusion: mapGiteaCommitStatus(status.status), + conclusion: mapGiteaCommitState(status.status), url: status.target_url ?? null, detailsUrl: status.url ?? null, output: @@ -1702,19 +1928,19 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For }; } - function toGiteaActionsCheckDetails(workflowRun: GiteaActionsRun): CheckDetails { - const conclusion = mapGiteaActionsRunStatus(workflowRun.status); + function toGiteaActionTaskDetails(task: GiteaActionTask): CheckDetails { + const conclusion = mapGiteaActionTaskStatus(task.status); return { - checkRunId: workflowRun.id, - workflowRunId: workflowRun.id, - name: getGiteaActionsRunName(workflowRun), - status: workflowRun.status, + checkRunId: task.id, + workflowRunId: task.id, + name: getGiteaActionTaskName(task), + status: task.status, conclusion, - url: workflowRun.url ?? null, - detailsUrl: workflowRun.url ?? null, + url: task.url ?? null, + detailsUrl: task.url ?? null, output: { - title: workflowRun.display_title ?? workflowRun.name ?? workflowRun.workflow_id ?? null, - summary: workflowRun.workflow_id ?? null, + title: task.display_title ?? task.name ?? task.workflow_id ?? null, + summary: task.workflow_id ?? null, text: null, }, annotations: [], @@ -1958,18 +2184,18 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For if (status) { return toGiteaCheckDetails(status); } - const runs = await loadActionsRunsBestEffort({ + const tasks = await loadActionTasksBestEffort({ cwd: input.cwd, repoOwner: input.repoOwner, repoName: input.repoName, sha, }); const workflowRunId = input.workflowRunId ?? input.checkRunId; - const workflowRun = runs.find((entry) => entry.id === workflowRunId); - if (!workflowRun) { + const task = tasks.find((entry) => entry.id === workflowRunId); + if (!task) { throw new Error(`Gitea check ${workflowRunId} was not found`); } - return toGiteaActionsCheckDetails(workflowRun); + return toGiteaActionTaskDetails(task); }, async searchIssuesAndPrs(input: SearchIssuesAndPrsOptions): Promise { diff --git a/packages/server/src/services/gitlab-facts.ts b/packages/server/src/services/gitlab-facts.ts index 6d550efe20..dbe594306d 100644 --- a/packages/server/src/services/gitlab-facts.ts +++ b/packages/server/src/services/gitlab-facts.ts @@ -21,23 +21,6 @@ export interface GitLabStatusFacts { mergeWhenPipelineSucceeds: boolean; } -// Client-side twin: packages/app/src/git/forges/gitlab.ts -// (GITLAB_ACTIVE_PIPELINE_STATUSES / isPipelineActiveStatus). Duplicated because -// the app can't depend on the server package; keep the two lists in sync by hand -// if GitLab adds a new active pipeline status. -export const GITLAB_ACTIVE_PIPELINE_STATUSES = [ - "created", - "waiting_for_resource", - "preparing", - "pending", - "running", - "scheduled", -] as const; - -export type GitLabActivePipelineStatus = (typeof GITLAB_ACTIVE_PIPELINE_STATUSES)[number]; - -export const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set(GITLAB_ACTIVE_PIPELINE_STATUSES); - export type GitLabForgeSpecificStatusFacts = ForgeSpecificStatusFacts & { forge: "gitlab"; } & GitLabStatusFacts; diff --git a/packages/server/src/services/gitlab-service.test.ts b/packages/server/src/services/gitlab-service.test.ts index 3f930a7207..c74224e444 100644 --- a/packages/server/src/services/gitlab-service.test.ts +++ b/packages/server/src/services/gitlab-service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { PullRequestCommandStatus } from "./forge-service.js"; +import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline"; import type { GitLabStatusFacts } from "./gitlab-facts.js"; import { type CreateGitLabServiceOptions, @@ -93,6 +94,17 @@ const OPEN_MR = { head_pipeline: { status: "success" }, }; +function mergeRequestWithPipeline(status = "success") { + return { + ...OPEN_MR, + head_pipeline: { + id: 306, + status, + web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", + }, + }; +} + const OPEN_ISSUE = { iid: 7, title: "Login button misaligned", @@ -629,32 +641,147 @@ describe("createGitLabService", () => { }); it("surfaces the head pipeline id and url on the gitlab status facts", async () => { - const pipelineMr = { - ...OPEN_MR, - head_pipeline: { - id: 306, - status: "running", - web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", - }, - }; - const { service } = makeService((args) => - ok(JSON.stringify(args[1] === "list" ? [pipelineMr] : pipelineMr)), - ); + const pipelineMr = mergeRequestWithPipeline("canceling"); + const { service } = makeService((args) => { + if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([pipelineMr])); + if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(pipelineMr)); + if (args[0] === "ci" && args[1] === "get") return ok(JSON.stringify(PIPELINE_WITH_JOBS)); + return ok("{}"); + }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0", }); - expect(status?.checksStatus).toBe("pending"); + // checksStatus follows the pipeline whose jobs are listed (the fetched + // one, status "failed"), while the facts keep head_pipeline's raw status. + expect(status?.checksStatus).toBe("failure"); expect(status?.forgeSpecific).toMatchObject({ forge: "gitlab", - pipelineStatus: "running", + pipelineStatus: "canceling", pipelineId: 306, pipelineUrl: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", }); }); + it("populates sidebar checks from the merge request head pipeline", async () => { + const pipelineMr = mergeRequestWithPipeline(); + const pipeline = { + ...PIPELINE_WITH_JOBS, + status: "success", + jobs: [ + PIPELINE_WITH_JOBS.jobs[0], + PIPELINE_WITH_JOBS.jobs[2], + { + id: 934, + name: "optional-deploy", + stage: "deploy", + status: "manual", + allow_failure: true, + web_url: "https://gitlab.example.com/example-group/example-project/-/jobs/934", + }, + { + id: 935, + name: "release", + stage: "deploy", + status: "manual", + allow_failure: false, + web_url: "https://gitlab.example.com/example-group/example-project/-/jobs/935", + }, + ], + }; + const { service, calls } = makeService((args) => { + if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([pipelineMr])); + if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(pipelineMr)); + if (args[0] === "api" && args[1].endsWith("/approvals")) return ok("{}"); + if (args[0] === "ci" && args[1] === "get") return ok(JSON.stringify(pipeline)); + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "release/v0.4.0", + }); + + expect(calls[3]).toEqual([ + "ci", + "get", + "--merge-request", + "14", + "--with-job-details", + "-F", + "json", + ]); + expect(status?.checksStatus).toBe("success"); + expect(status?.checks).toEqual([ + { + name: "lint", + status: "success", + url: "https://gitlab.example.com/example-group/example-project/-/jobs/929", + workflow: "test", + checkRunId: 929, + }, + { + name: "flaky", + status: "success", + traits: ["warning"], + url: "https://gitlab.example.com/example-group/example-project/-/jobs/932", + workflow: "test", + checkRunId: 932, + }, + { + name: "optional-deploy", + status: "skipped", + traits: ["manual"], + url: "https://gitlab.example.com/example-group/example-project/-/jobs/934", + workflow: "deploy", + checkRunId: 934, + }, + { + name: "release", + status: "pending", + traits: ["manual", "action_required"], + url: "https://gitlab.example.com/example-group/example-project/-/jobs/935", + workflow: "deploy", + checkRunId: 935, + }, + ]); + }); + + it.each([ + [ + "unavailable", + () => { + throw { code: 1, stderr: "pipeline details unavailable" }; + }, + ], + ["malformed", () => ok(JSON.stringify({ jobs: "invalid" }))], + ] as const)( + "keeps the merge request status when pipeline job details are %s", + async (_, load) => { + const pipelineMr = mergeRequestWithPipeline(); + const { service } = makeService((args) => { + if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([pipelineMr])); + if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(pipelineMr)); + if (args[0] === "api" && args[1].endsWith("/approvals")) return ok("{}"); + if (args[0] === "ci" && args[1] === "get") return load(); + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "release/v0.4.0", + }); + + expect(status).toMatchObject({ number: 14, checks: [], checksStatus: "success" }); + }, + ); + + it("keeps cancellation transitions active", () => { + expect(GITLAB_ACTIVE_PIPELINE_STATUS_SET.has("canceling")).toBe(true); + }); + it("fetches a pipeline's stages and jobs as neutral check details", async () => { const { service, calls } = makeService(() => ok(JSON.stringify(PIPELINE_WITH_JOBS))); @@ -729,35 +856,60 @@ describe("createGitLabService", () => { ]); }); - it("does not fail a stage when only allow_failure jobs failed", async () => { - const { service } = makeService(() => - ok( - JSON.stringify({ - ...PIPELINE_WITH_JOBS, - status: "success", - jobs: [ - { - id: 940, - name: "lint", - stage: "test", - status: "success", - allow_failure: false, - }, - { - id: 941, - name: "optional", - stage: "test", - status: "failed", - allow_failure: true, - }, - ], - }), - ), - ); + it.each([ + ["allowed failure", "success", "failed", true, "success", "success", "failed"], + ["optional manual", "success", "manual", true, "success", "success", "manual"], + ["blocking manual", "manual", "manual", false, "manual", "manual", "manual"], + ["cancellation transition", "canceling", "canceling", false, "pending", "pending", "pending"], + ] as const)( + "maps %s without distorting the pipeline result", + async ( + _case, + pipelineStatus, + jobStatus, + allowFailure, + expectedPipeline, + expectedStage, + expectedJob, + ) => { + const { service } = makeService(() => + ok( + JSON.stringify({ + ...PIPELINE_WITH_JOBS, + status: pipelineStatus, + jobs: [ + { + id: 940, + name: "build", + stage: "test", + status: "success", + allow_failure: false, + }, + { + id: 941, + name: "subject", + stage: "test", + status: jobStatus, + allow_failure: allowFailure, + }, + ], + }), + ), + ); - const details = await service.getCheckDetails({ cwd: "/repo", checkRunId: 306 }); - expect(details.pipeline?.stages[0]?.status).toBe("success"); - }); + const details = await service.getCheckDetails({ cwd: "/repo", checkRunId: 306 }); + expect(details.pipeline).toMatchObject({ + status: expectedPipeline, + rawStatus: pipelineStatus, + stages: [{ status: expectedStage }], + }); + expect(details.pipeline?.stages[0]?.jobs[1]).toMatchObject({ + status: expectedJob, + rawStatus: jobStatus, + allowFailure, + }); + }, + ); it("populates approval counts from the approvals endpoint", async () => { const { service, calls } = makeService((args) => { diff --git a/packages/server/src/services/gitlab-service.ts b/packages/server/src/services/gitlab-service.ts index dae4cb2b46..f0b166cdda 100644 --- a/packages/server/src/services/gitlab-service.ts +++ b/packages/server/src/services/gitlab-service.ts @@ -38,6 +38,7 @@ import type { PipelineJobStatus, PipelineStage, PullRequestAutoMergeResult, + PullRequestCheck, PullRequestChecksStatus, PullRequestCheckoutTarget, PullRequestCreateResult, @@ -53,10 +54,12 @@ import type { SearchResult, } from "./forge-service.js"; import { - GITLAB_ACTIVE_PIPELINE_STATUS_SET, - isGitLabStatusFacts, - type GitLabStatusFacts, -} from "./gitlab-facts.js"; + CHECK_TRAIT_ACTION_REQUIRED, + CHECK_TRAIT_MANUAL, + CHECK_TRAIT_WARNING, +} from "@getpaseo/protocol/check-traits"; +import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline"; +import { isGitLabStatusFacts, type GitLabStatusFacts } from "./gitlab-facts.js"; const GLAB_ENV = { GIT_TERMINAL_PROMPT: "0", @@ -336,16 +339,10 @@ function mapPipelineChecksStatus(status: string | undefined): PullRequestChecksS case "canceled": case "cancelled": return "none"; - case "running": - case "pending": - case "created": - case "scheduled": - case "preparing": - case "waiting_for_resource": case "manual": return "pending"; default: - return "none"; + return status && GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status) ? "pending" : "none"; } } @@ -555,11 +552,6 @@ function normalizePipelineJobStatus(raw: string): PipelineJobStatus { return "failed"; case "running": return "running"; - case "pending": - case "waiting_for_resource": - case "preparing": - case "scheduled": - return "pending"; case "created": return "created"; case "canceled": @@ -570,10 +562,69 @@ function normalizePipelineJobStatus(raw: string): PipelineJobStatus { case "manual": return "manual"; default: - return "unknown"; + return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(raw) ? "pending" : "unknown"; } } +function getPullRequestCheckMetadata( + rawStatus: string, + allowFailure: boolean, +): Pick { + if (rawStatus === "manual") { + return { + traits: allowFailure + ? [CHECK_TRAIT_MANUAL] + : [CHECK_TRAIT_MANUAL, CHECK_TRAIT_ACTION_REQUIRED], + }; + } + return rawStatus === "failed" && allowFailure ? { traits: [CHECK_TRAIT_WARNING] } : {}; +} + +const PULL_REQUEST_CHECK_STATUS_BY_PIPELINE_JOB_STATUS = { + success: "success", + failed: "failure", + running: "pending", + pending: "pending", + canceled: "cancelled", + skipped: "skipped", + manual: "pending", + created: "pending", + unknown: "pending", +} as const satisfies Record; + +function toPullRequestCheck(job: z.infer): PullRequestCheck { + const rawStatus = job.status.toLowerCase(); + const allowFailure = job.allow_failure ?? false; + + let status: PullRequestCheck["status"]; + if (rawStatus === "failed" && allowFailure) { + // GitLab treats this as passed-with-warning: retain a successful aggregate + // while giving the client the presentation distinction it cannot express + // through the neutral status enum alone. + status = "success"; + } else if (rawStatus === "manual" && allowFailure) { + status = "skipped"; + } else { + status = + PULL_REQUEST_CHECK_STATUS_BY_PIPELINE_JOB_STATUS[normalizePipelineJobStatus(rawStatus)]; + } + + return { + name: job.name, + status, + ...getPullRequestCheckMetadata(rawStatus, allowFailure), + url: job.web_url ?? null, + workflow: job.stage, + checkRunId: job.id, + }; +} + +function toPullRequestChecks( + pipeline: z.infer, +): PullRequestCheck[] { + return [...pipeline.jobs].sort((a, b) => a.id - b.id).map(toPullRequestCheck); +} + const STAGE_STATUS_PRIORITY: PipelineJobStatus[] = [ "running", "failed", @@ -587,7 +638,11 @@ const STAGE_STATUS_PRIORITY: PipelineJobStatus[] = [ function aggregateStageStatus(jobs: PipelineJob[]): PipelineJobStatus { const present = new Set( - jobs.map((job) => (job.status === "failed" && job.allowFailure ? "success" : job.status)), + jobs.map((job) => + job.allowFailure && (job.status === "failed" || job.status === "manual") + ? "success" + : job.status, + ), ); for (const status of STAGE_STATUS_PRIORITY) { if (present.has(status)) { @@ -658,6 +713,8 @@ function toCheckDetails(pipeline: z.infer): function toCurrentPullRequestStatus( mr: GitLabMergeRequest, approvals?: GitLabApprovals | null, + checks: PullRequestCheck[] = [], + pipelineStatus?: string | null, ): CurrentPullRequestStatus { const { owner, name } = splitProjectPath(mr.references?.full); const projectPath = extractProjectPath(mr.references?.full); @@ -674,8 +731,13 @@ function toCurrentPullRequestStatus( isMerged: mr.state === "merged" || mr.merged_at != null, isDraft: mr.draft ?? mr.work_in_progress ?? false, mergeable: mapMergeable(mr), - checks: [], - checksStatus: mapPipelineChecksStatus(mr.head_pipeline?.status), + checks, + // Aggregate and job list must come from the same pipeline: glab resolves + // the MR's latest pipeline, which can differ from head_pipeline (detached + // vs branch pipelines, or a newer run). GitLab's own pipeline status stays + // authoritative for the aggregate - deriving it from the mapped jobs would + // hold MRs at "pending" forever on post-success manual deploy jobs. + checksStatus: mapPipelineChecksStatus(pipelineStatus ?? mr.head_pipeline?.status), reviewDecision: null, forgeSpecific: { forge: "gitlab", ...toGitLabStatusFacts(mr, approvals) }, }; @@ -956,6 +1018,45 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F } } + /** + * Populates the neutral checks used by the sidebar and hover card. Pipeline + * drill-down remains independently available, so any glab command or output + * failure while loading optional job details must not make the merge request + * itself disappear. Authentication and missing-CLI failures use separate + * error classes and still propagate. + */ + interface PipelineChecksResult { + checks: PullRequestCheck[]; + pipelineStatus: string | null; + } + + async function fetchPipelineChecks( + cwd: string, + mr: GitLabMergeRequest, + ): Promise { + if (mr.head_pipeline?.id === undefined) { + return { checks: [], pipelineStatus: null }; + } + try { + const pipeline = await runJson( + ["ci", "get", "--merge-request", String(mr.iid), "--with-job-details", "-F", "json"], + { cwd }, + GitLabPipelineDetailsSchema, + ); + return { checks: toPullRequestChecks(pipeline), pipelineStatus: pipeline.status }; + } catch (error) { + if (error instanceof GlabCommandError) { + console.warn( + `Failed to load GitLab pipeline jobs for MR !${mr.iid}: ${ + error.stderr?.trim() || error.message + }`, + ); + return { checks: [], pipelineStatus: null }; + } + throw error; + } + } + async function runMergeRequestList( input: ListPullRequestsOptions, ): Promise { @@ -1020,8 +1121,16 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F if (!mr) { return null; } - const approvals = await fetchApprovals(input.cwd, mr); - return toCurrentPullRequestStatus(mr, approvals); + const [approvals, pipelineChecks] = await Promise.all([ + fetchApprovals(input.cwd, mr), + fetchPipelineChecks(input.cwd, mr), + ]); + return toCurrentPullRequestStatus( + mr, + approvals, + pipelineChecks.checks, + pipelineChecks.pipelineStatus, + ); } catch (error) { if (error instanceof GlabCommandError && isNoMergeRequestText(error.stderr)) { return null; diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index febf226c8f..b65e5ca5e4 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -19,6 +19,7 @@ import type { ForgeAuthState, ForgeService, ForgeSpecificStatusFacts, + PullRequestCheck as ForgePullRequestCheck, PullRequestMergeable, } from "../services/forge-service.js"; import { @@ -3874,13 +3875,7 @@ export function forgeAuthStateFromError(error: unknown): ForgeAuthState { return "unauthenticated"; } -export interface PullRequestCheck { - name: string; - status: "success" | "failure" | "pending" | "skipped" | "cancelled"; - url: string | null; - workflow?: string; - duration?: string; -} +export type PullRequestCheck = ForgePullRequestCheck; export type ChecksStatus = "none" | "pending" | "success" | "failure";