mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
co-authored by
Mohamed Boudra
parent
e22ad117fd
commit
4a5716360b
+33
-3
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
+112
-37
@@ -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);
|
||||
});
|
||||
|
||||
+102
-104
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: "مساحة عمل جديدة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "新しいワークスペース",
|
||||
|
||||
@@ -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: "새 워크스페이스",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Новое рабочее пространство",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])));
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user