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 <boudra.moha@gmail.com>
This commit is contained in:
nllptrx
2026-08-25 23:37:50 +02:00
committed by Mohamed Boudra
co-authored by Mohamed Boudra
parent e22ad117fd
commit 4a5716360b
75 changed files with 3099 additions and 1040 deletions
+33 -3
View File
@@ -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.
+3 -3
View File
@@ -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");
});
});
+4 -3
View File
@@ -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<CheckPresentationCounts, "success" | "failure" | "pending">,
): Promise<void> {
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);
}
+3 -1
View File
@@ -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";
@@ -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 (
<Svg width={size} height={size} viewBox="-0.5 -0.5 15 15">
<Circle cx={7} cy={7} r={6.25} fill="none" stroke={color} strokeWidth={1.25} />
<Path d={MANUAL_STATUS_GEAR_PATH} fill={color} />
</Svg>
);
}
@@ -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<PrHint["checks"]>) {
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 (
<View style={styles.checksSummaryPill}>
<CheckPresentationIcon presentation={presentation} size={12} />
<Text style={checksSummaryTextStyle(presentation)}>{count}</Text>
</View>
);
}
function ChecksSummaryPill({
count,
kind,
}: {
count: number;
kind: "passed" | "failed" | "pending";
}) {
if (count === 0) return null;
if (kind === "passed") {
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleCheck size={12} uniProps={successColorMapping} />
<Text style={styles.checksStatusTextPassed}>{count}</Text>
</View>
);
}
if (kind === "failed") {
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleX size={12} uniProps={dangerColorMapping} />
<Text style={styles.checksStatusTextFailed}>{count}</Text>
</View>
);
}
return (
<View style={styles.checksSummaryPill}>
<ThemedCircleDot size={12} uniProps={warningColorMapping} />
<Text style={styles.checksStatusTextPending}>{count}</Text>
</View>
);
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({
)}
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checksSummaryCounts}>
<ChecksSummaryPill count={passed} kind="passed" />
<ChecksSummaryPill count={failed} kind="failed" />
<ChecksSummaryPill count={pending} kind="pending" />
{COUNTED_CHECK_PRESENTATIONS.map((presentation) => (
<ChecksSummaryPill
key={presentation}
count={counts[presentation]}
presentation={presentation}
/>
))}
</View>
</>
);
@@ -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 (
<Pressable style={checksSummaryPressableStyle} onPress={handlePress}>
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="link"
style={checksSummaryPressableStyle}
onPress={handlePress}
>
{renderChildren}
</Pressable>
);
@@ -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,
},
}));
+25 -25
View File
@@ -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);
});
});
+11 -11
View File
@@ -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;
@@ -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
@@ -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<UserComposerAttachment[]>(
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);
});
@@ -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<SetStateAction<UserComposerAttachment[]>>;
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<string>());
const presentPullRequestKeysRef = useRef(new Set<string>());
const activeLookupsRef = useRef(new Set<ActiveGithubLookup>());
const presentChangeRequestKeysRef = useRef(new Set<string>());
const activeLookupsRef = useRef(new Set<ActiveForgeLookup>());
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
() => 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>,
): string {
return getLookupCandidateRefs(params, removedRefKeys).map(githubRefKey).join("|");
return getLookupCandidateRefs(params, removedRefKeys).map(forgeRefKey).join("|");
}
function getLookupCandidateRefs(
params: ComposerGithubAutoAttachInput,
params: ComposerForgeAutoAttachInput,
removedRefKeys: ReadonlySet<string>,
): 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<string>,
): 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>,
): 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<string>;
}): 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<string>;
}): 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<Set<string>>;
params: ComposerForgeAutoAttachInput;
presentChangeRequestKeysRef: RefObject<Set<string>>;
}): 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<ComposerGithubAutoAttachInput>;
latestRef: RefObject<ComposerForgeAutoAttachInput>;
removedRefKeys: Set<string>;
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<ComposerGithubAutoAttachInput>;
latestRef: RefObject<ComposerForgeAutoAttachInput>;
removedRefKeys: Set<string>;
}): Promise<ForgeSearchItem | null> {
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<string>;
activeLookups: ReadonlySet<ActiveGithubLookup>;
}): GithubRef[] {
activeLookups: ReadonlySet<ActiveForgeLookup>;
}): 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;
}
+22 -22
View File
@@ -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<number>(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 <View key={option.id} />;
}
const selected = isAttachmentSelectedForGithubItem(selectedAttachments, item);
const selected = isAttachmentSelectedForForgeItem(selectedAttachments, item);
return (
<GithubPickerOption
key={option.id}
@@ -2220,7 +2220,7 @@ function ComposerContentImpl({
const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 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
+9 -6
View File
@@ -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<CheckoutGitActionsStoreState>()
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<CheckoutGitActionsStoreState>()
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 })
@@ -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");
});
});
@@ -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<CountedCheckPresentation, string>;
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<CountedCheckPresentation, string>;
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");
}
@@ -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,
});
});
});
@@ -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<CountedCheckPresentation, number>;
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;
}
@@ -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<CheckPresentation, CheckPresentationViewPolicy> = {
success: {
tone: "success",
renderIcon(size) {
return <ThemedCircleCheck size={size} uniProps={successColorMapping} />;
},
},
failure: {
tone: "danger",
renderIcon(size) {
return <ThemedCircleX size={size} uniProps={dangerColorMapping} />;
},
},
warning: {
tone: "warning",
renderIcon(size) {
return <ThemedCircleX size={size} uniProps={warningColorMapping} />;
},
},
actionRequired: {
tone: "warning",
renderIcon(size) {
return <ThemedManualStatusIcon size={size} uniProps={warningColorMapping} />;
},
},
manual: {
tone: "muted",
renderIcon(size) {
return <ThemedManualStatusIcon size={size} uniProps={foregroundMutedColorMapping} />;
},
},
pending: {
tone: "warning",
renderIcon(size) {
return <ThemedCircleDot size={size} uniProps={warningColorMapping} />;
},
},
ignored: {
tone: "muted",
renderIcon(size) {
return <ThemedCircleSlash size={size} uniProps={foregroundMutedColorMapping} />;
},
},
};
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);
}
@@ -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<string, unknown>;
+104
View File
@@ -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([]);
});
});
+145
View File
@@ -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<string>;
repoPath: string;
/** Known cloud forge ids, or null when a self-hosted domain is unresolved. */
forgeIds: ReadonlySet<string> | 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<string>();
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<string>();
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<string> | 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}`;
}
+26 -1
View File
@@ -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", () => {
+17
View File
@@ -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. */
+4 -14
View File
@@ -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<typeof GiteaMergeFactsSchema>;
// 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,
};
},
+5
View File
@@ -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",
+49 -26
View File
@@ -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<string>(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<CheckoutPipelineJob, "status" | "allowFailure">;
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",
+31 -48
View File
@@ -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 = (
<>
<SummaryPill
count={displayCounts.passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-pipeline-passed"
/>
<SummaryPill
count={displayCounts.failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-pipeline-failed"
/>
<SummaryPill
count={displayCounts.pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-pipeline-pending"
/>
{showBreakdown ? null : <CheckStatusIcon status={summary.status} />}
{COUNTED_CHECK_PRESENTATIONS.map((presentation) => (
<CheckPresentationSummaryPill
key={presentation}
count={displayCounts[presentation]}
presentation={presentation}
testID={`pr-pane-pipeline-${presentation}`}
/>
))}
{showBreakdown ? null : <GitLabPipelineStatusIcon status={summary.rawStatus} />}
</>
);
@@ -195,7 +167,7 @@ function GitLabPipelineSection({
disabled={!summary.url}
testID="pr-pane-pipeline-link"
>
<CheckStatusIcon status={summary.status} />
<GitLabPipelineStatusIcon status={summary.rawStatus} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{`Pipeline #${summary.id}`}
</Text>
@@ -234,7 +206,7 @@ function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) {
return (
<View>
<View style={styles.pipelineStageHeader}>
<CheckStatusIcon status={mapPipelineStatus(stage.status)} />
<GitLabPipelineStatusIcon status={stage.status} />
<Text style={styles.pipelineStageName} numberOfLines={1}>
{stage.name}
</Text>
@@ -256,7 +228,7 @@ function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
const duration = formatPipelineDuration(job.durationSeconds);
return (
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
<CheckStatusIcon status={mapPipelineStatus(job.status)} />
<GitLabPipelineStatusIcon status={job.status} allowFailure={job.allowFailure} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{job.name}
</Text>
@@ -272,6 +244,17 @@ function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
);
}
function GitLabPipelineStatusIcon({
status,
allowFailure = false,
}: {
status: string;
allowFailure?: boolean;
}) {
const presentation = classifyGitlabPipelineJob({ status, allowFailure });
return <CheckPresentationIcon presentation={presentation} size={14} />;
}
export const gitlabForgeView = {
id: "gitlab",
icon: GitLabIcon,
+4 -5
View File
@@ -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,
+8 -2
View File
@@ -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;
+3 -2
View File
@@ -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"),
@@ -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<ReadonlySet<CheckStatus>>(() => new Set());
const toggle = useCallback((status: CheckStatus) => {
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckPresentation>>(
() => new Set(),
);
const toggle = useCallback((status: CheckPresentation) => {
setCollapsedGroups((current) => {
const next = new Set(current);
if (!next.delete(status)) next.add(status);
@@ -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";
}
@@ -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<CheckStatus, string> & { track: string };
type ChecksPalette = Record<CheckPresentation, string> & { 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. */
@@ -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<Record<CheckStatus, string>> = {
const PART_TEST_ID: Partial<Record<CheckPresentation, string>> = {
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<string>;
onAddLogsToChat: (check: PrPaneCheck) => void;
@@ -194,7 +198,7 @@ function CheckRow({
);
return (
<Pressable onPress={handlePress} style={rowPressableStyle} testID="pr-pane-check-row">
<CheckStatusIcon status={check.status} />
<CheckPresentationIcon presentation={classifyCheck(check)} size={14} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{check.name}
</Text>
@@ -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<CheckStatus, string> = {
const PRESENTATION_NOUN: Record<CheckPresentation, string> = {
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<ChecksOutcome, string> = {
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<ChecksOutcome, string> = {
};
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")) {
@@ -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);
+24 -25
View File
@@ -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<CheckoutPrStatus>): PrState {
}
function mapChecks(status: NonNullable<CheckoutPrStatus>, 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<CheckoutPrStatus>, forge: Forge): PrPaneC
function mapCheck(
check: NonNullable<CheckoutPrStatus>["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 } : {}),
},
}
: {}),
};
}
/**
@@ -135,6 +135,7 @@ describe("getNativeFallbackChecks", () => {
provider: "gitea",
name: "CI",
status: "failure",
traits: ["warning"],
url: "https://gitea.com/group/repo/pulls/7",
},
]);
@@ -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<PrState, PrStatePresentation> = {
const SUMMARY_COMMENT_ICON = (
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
);
const SUMMARY_APPROVAL_ICON = <CheckPresentationIcon presentation="success" size={12} />;
const SUMMARY_CHANGES_REQUESTED_ICON = <CheckPresentationIcon presentation="failure" size={12} />;
const ADD_TO_CHAT_MENU_ICON = (
<ThemedMessageSquarePlus size={14} uniProps={foregroundMutedColorMapping} />
);
@@ -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={
<>
<SummaryPill count={approvals} icon={SUMMARY_SUCCESS_ICON} variant="success" />
<SummaryPill count={changesRequested} icon={SUMMARY_DANGER_ICON} variant="danger" />
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
<SummaryPill
count={changesRequested}
icon={SUMMARY_CHANGES_REQUESTED_ICON}
variant="danger"
/>
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
</>
}
@@ -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 = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
export const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
export const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
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 (
<View>
<Pressable style={sectionKitStyles.sectionHeader} onPress={onToggle}>
<Pressable
accessibilityLabel={accessibilityLabel}
style={sectionKitStyles.sectionHeader}
onPress={onToggle}
>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
@@ -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 <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
export function CheckPresentationSummaryPill({
count,
presentation,
testID,
}: {
count: number;
presentation: CountedCheckPresentation;
testID?: string;
}) {
if (count === 0) return null;
return (
<View style={sectionKitStyles.summaryPill} testID={testID}>
<CheckPresentationIcon presentation={presentation} size={12} />
<Text style={summaryPillTextStyle(getCheckPresentationTone(presentation))}>{count}</Text>
</View>
);
}
export const sectionKitStyles = StyleSheet.create((theme) => ({
@@ -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) => {
+18
View File
@@ -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(
+18
View File
@@ -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: "مساحة عمل جديدة",
+18
View File
@@ -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",
+18
View File
@@ -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",
+18
View File
@@ -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",
+18
View File
@@ -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: "新しいワークスペース",
+18
View File
@@ -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: "새 워크스페이스",
+18
View File
@@ -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",
+18
View File
@@ -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: "Новое рабочее пространство",
+18
View File
@@ -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",
@@ -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,
}
: {}),
@@ -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
-130
View File
@@ -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([]);
});
});
-117
View File
@@ -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<string>();
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()
);
}
+14
View File
@@ -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;
+22
View File
@@ -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";
}
}
+19
View File
@@ -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<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
@@ -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",
+81 -40
View File
@@ -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,
@@ -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", () => {
@@ -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",
@@ -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;
@@ -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;
}
@@ -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 }
+17 -6
View File
@@ -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,
@@ -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;
@@ -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
+14 -3
View File
@@ -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;
}
@@ -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])));
+374 -148
View File
@@ -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<typeof GiteaPullRequestViewSchema>;
type GiteaCurrentPullRequestApi = z.infer<typeof GiteaCurrentPullRequestApiSchema>;
type GiteaCommitStatus = z.infer<typeof GiteaCommitStatusSchema>;
type GiteaCombinedCommitStatus = z.infer<typeof GiteaCombinedCommitStatusSchema>;
type GiteaActionsRun = z.infer<typeof GiteaActionsRunSchema>;
type GiteaActionTask = z.infer<typeof GiteaActionTaskSchema>;
type GiteaIssueComment = z.infer<typeof GiteaIssueCommentSchema>;
type GiteaReview = z.infer<typeof GiteaReviewSchema>;
type GiteaReviewComment = z.infer<typeof GiteaReviewCommentSchema>;
@@ -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<number>,
): 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<typeof GiteaActionRunMetadataSchema>): 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<number> {
const runIds = new Set<number>();
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<string, GiteaActionsRun>();
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<string, GiteaActionTask>();
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<string>,
nativeRunIds: ReadonlySet<number>,
): 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
// "<run link>/jobs/<job id>" (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 "<workflow> / <job> (<event>)" - is kept.
function isGiteaActionShadowStatus(
status: GiteaCommitStatus,
nativeOrigins: ReadonlySet<string>,
nativeRunIds: ReadonlySet<number>,
): boolean {
return isNativeActionJobUrl(status.target_url, nativeOrigins, nativeRunIds);
}
function combineGiteaChecks(
commitStatuses: GiteaCommitStatus[],
actionsRuns: GiteaActionsRun[],
actionTasks: GiteaActionTask[],
actionRequiredRunIds: ReadonlySet<number> = 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<number>();
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<number> = 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<ReadonlySet<number>> {
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<GiteaActionsRun[]> {
}): Promise<GiteaActionTask[]> {
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<GiteaActionTask[]> {
if (!tasks.some((task) => task.status.toLowerCase() === "blocked")) return tasks;
try {
const sha = encodeURIComponent(input.sha);
const approvalRunNumbers = new Set<number>();
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<unknown[]> {
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<GiteaActionsRun[]> {
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<GiteaActionTask[]> {
return parseGiteaActionTasks(await loadGiteaActionPages(input, "tasks"));
}
async function resolveCurrentPullRequestHeadSha(cwd: string): Promise<string> {
@@ -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<SearchResult> {
@@ -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<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
export type GitLabForgeSpecificStatusFacts = ForgeSpecificStatusFacts & {
forge: "gitlab";
} & GitLabStatusFacts;
@@ -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) => {
+131 -22
View File
@@ -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<PullRequestCheck, "traits"> {
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<PipelineJobStatus, PullRequestCheck["status"]>;
function toPullRequestCheck(job: z.infer<typeof GitLabPipelineJobSchema>): 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<typeof GitLabPipelineDetailsSchema>,
): 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<typeof GitLabPipelineDetailsSchema>):
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<PipelineChecksResult> {
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<PullRequestSummary[]> {
@@ -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;
+2 -7
View File
@@ -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";