mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
* 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>
334 lines
9.8 KiB
TypeScript
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";
|
|
}
|