Files
paseo/packages/app/src/git/pull-request-panel/data.ts
T
nllptrxandMohamed Boudra 4a5716360b 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>
2026-08-25 23:37:50 +02:00

334 lines
9.8 KiB
TypeScript

import type {
CheckoutPrStatusResponse,
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";
import { type CheckStatus, mapCheckStatus } from "./check-status";
import { getNativeFallbackChecks } from "./native-data";
export type { CheckStatus } from "./check-status";
export type PrState = "open" | "draft" | "merged" | "closed";
export type ReviewState = "approved" | "changes_requested" | "commented";
export type ActivityKind = "review" | "comment";
export type PullRequestProvider = Forge;
export interface PullRequestProviderMetadata {
id: PullRequestProvider;
label: string;
url?: string | null;
}
export interface PrPaneCheck extends PresentableCheck {
provider: PullRequestProvider;
name: string;
workflow?: string;
status: CheckStatus;
/**
* What the row says about time: how long a finished check took ("2m"), or how long a
* 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
* forge that exposes a check-run id populates it; the daemon resolves the logs
* through the neutral check-details RPC.
*/
detailRef?: {
checkRunId?: number;
workflowRunId?: number;
};
}
export interface PrPaneActivity {
provider: PullRequestProvider;
id: string;
kind: ActivityKind;
author: string;
authorUrl?: string | null;
avatarColor: string;
avatarUrl?: string | null;
reviewState?: ReviewState;
body: string;
age: string;
url: string;
/** For inline review comments: the review this comment was submitted with. */
reviewId?: string;
/**
* Forge-neutral discussion id, independent of a file position. Groups general
* (non-file) reply chains into one thread; file threads also carry it.
*/
threadId?: string;
/**
* Resolution state for a thread with no file position (e.g. a GitLab general
* discussion). File threads carry resolution under `location.isResolved`.
*/
threadIsResolved?: boolean;
location?: {
path: string;
line?: number;
startLine?: number;
threadId?: string;
isResolved?: boolean;
isOutdated?: boolean;
};
}
export interface PrPaneData {
provider: PullRequestProviderMetadata;
/** The forge hosting this change request. */
forge: Forge;
number: number;
repoOwner?: string;
repoName?: string;
/** Neutral project identity (GitLab namespaces nest beyond owner/name). */
projectPath?: string;
title: string;
state: PrState;
url: string;
reviewDecision: "approved" | "changes_requested" | "pending";
awaitingReviewers: string[];
checks: PrPaneCheck[];
/**
* The forge's already-validated native facts, passed through so pane native
* contributions (e.g. GitLab pipeline/approvals) derive their surfaces without
* the neutral data type carrying forge-specific fields.
*/
forgeSpecific?: ForgeSpecificStatusFacts;
activity: PrPaneActivity[];
}
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
type PullRequestTimelineItem = PullRequestTimeline["items"][number];
export function mapPrPaneData(
status: CheckoutPrStatus,
timeline: PullRequestTimeline | null | undefined,
nowMs = Date.now(),
forge: Forge = "github",
): PrPaneData | null {
if (!status) {
return null;
}
const number = status.number ?? parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
const timelineMatchesStatus = timeline?.prNumber === number;
const provider = toProviderMetadata(forge);
const forgeSpecific = parseClientForgeFacts(status.forgeSpecific);
return {
provider,
forge,
number,
repoOwner: status.repoOwner,
repoName: status.repoName,
projectPath: status.projectPath,
title: status.title,
state: derivePrState(status),
url: status.url,
reviewDecision: mapReviewDecision(status.reviewDecision),
// Requested reviewers are intentionally unwired until the server exposes them.
awaitingReviewers: [],
checks: mapChecks(status, forge),
...(forgeSpecific ? { forgeSpecific } : {}),
activity: timelineMatchesStatus
? timeline.items.flatMap((item) => mapActivity(item, nowMs, forge))
: [],
};
}
function toProviderMetadata(forge: Forge): PullRequestProviderMetadata {
return { id: forge, label: getForgePresentation(forge).brandLabel };
}
// Avatars are identity, not status: they draw from the shared identity table so a PR
// participant square sits at the same weight as a project icon. Logins are matched
// case-insensitively, so the same person keeps one color across forges.
export function deriveAvatarColor(login: string): string {
return identityColor(deriveIdentityColorName(login.toLowerCase()));
}
export function formatAge(createdAtMs: number, nowMs = Date.now()): string {
const elapsedMs = Math.max(0, nowMs - createdAtMs);
const elapsedSeconds = Math.floor(elapsedMs / 1000);
if (elapsedSeconds < 60) {
return "just now";
}
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
if (elapsedMinutes < 60) {
return `${elapsedMinutes}m ago`;
}
const elapsedHours = Math.floor(elapsedMinutes / 60);
if (elapsedHours < 24) {
return `${elapsedHours}h ago`;
}
const elapsedDays = Math.floor(elapsedHours / 24);
if (elapsedDays < 30) {
return `${elapsedDays}d ago`;
}
if (elapsedDays < 365) {
return `${Math.floor(elapsedDays / 30)}mo ago`;
}
return `${Math.floor(elapsedDays / 365)}y ago`;
}
function derivePrState(status: NonNullable<CheckoutPrStatus>): PrState {
if (status.isMerged || status.state === "merged") {
return "merged";
}
if (status.state !== "open") {
return "closed";
}
if (status.isDraft) {
return "draft";
}
return "open";
}
function mapChecks(status: NonNullable<CheckoutPrStatus>, forge: Forge): PrPaneCheck[] {
const checks = (status.checks ?? []).map((check) => mapCheck(check, forge, status.url));
if (checks.length > 0) {
return checks;
}
return getNativeFallbackChecks(status, forge);
}
function mapCheck(
check: NonNullable<CheckoutPrStatus>["checks"][number],
forge: Forge,
fallbackUrl: string,
): PrPaneCheck {
const status = mapCheckStatus(check.status);
const timing = formatCheckTiming(check.duration, status);
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 } : {}),
},
}
: {}),
};
}
/**
* The forge measures how long a check ran, whether or not it has finished. Which one it
* is comes from the status: on a running check a bare "2m" would read as "took 2m", so
* the row says the run is still going.
*/
function formatCheckTiming(duration: string | undefined, status: CheckStatus): string | null {
if (!duration) {
return null;
}
return status === "pending" ? `running ${duration}` : duration;
}
function mapActivity(item: PullRequestTimelineItem, nowMs: number, forge: Forge): PrPaneActivity[] {
if (item.kind === "comment") {
if (item.body.trim() === "") {
return [];
}
return [
{
id: item.id,
provider: forge,
kind: "comment",
author: item.author,
authorUrl: item.authorUrl,
avatarColor: deriveAvatarColor(item.author),
avatarUrl: item.avatarUrl,
body: item.body,
age: formatAge(item.createdAt, nowMs),
url: item.url,
reviewId: item.reviewId,
threadId: item.threadId,
threadIsResolved: item.threadIsResolved,
location: item.location,
},
];
}
if (item.reviewState === "commented" && item.body.trim() === "") {
return [];
}
return [
{
id: item.id,
provider: forge,
kind: "review",
author: item.author,
authorUrl: item.authorUrl,
avatarColor: deriveAvatarColor(item.author),
avatarUrl: item.avatarUrl,
reviewState: item.reviewState,
body: item.body,
age: formatAge(item.createdAt, nowMs),
url: item.url,
},
];
}
function mapReviewDecision(
reviewDecision: NonNullable<CheckoutPrStatus>["reviewDecision"],
): PrPaneData["reviewDecision"] {
if (reviewDecision === "approved" || reviewDecision === "changes_requested") {
return reviewDecision;
}
return "pending";
}
function parsePullRequestNumber(url: string): number | null {
try {
const match = new URL(url).pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
export function getStateLabel(state: PrState): string {
if (state === "draft") return "Draft";
if (state === "merged") return "Merged";
if (state === "closed") return "Closed";
return "Open";
}
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
if (item.kind === "comment") return "Commented";
if (item.reviewState === "approved") return "Approved";
if (item.reviewState === "changes_requested") return "Requested changes";
return "Reviewed";
}