mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +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>
137 lines
4.1 KiB
TypeScript
137 lines
4.1 KiB
TypeScript
import { classifyCheck, type CheckPresentation } from "@/git/check-presentation";
|
|
import type { PrPaneCheck } from "./data";
|
|
|
|
/**
|
|
* The order the checks section reads in: what the user can act on, then what they are
|
|
* 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 PRESENTATION_ORDER = [
|
|
"actionRequired",
|
|
"warning",
|
|
"failure",
|
|
"pending",
|
|
"manual",
|
|
"success",
|
|
"ignored",
|
|
] as const satisfies readonly CheckPresentation[];
|
|
|
|
/** How each status reads inside a count phrase: "2 failing", "4 in progress". */
|
|
const PRESENTATION_NOUN: Record<CheckPresentation, string> = {
|
|
actionRequired: "needs action",
|
|
warning: "warning",
|
|
failure: "failing",
|
|
pending: "in progress",
|
|
manual: "manual",
|
|
success: "successful",
|
|
ignored: "skipped",
|
|
};
|
|
|
|
/** The worst thing happening in the run, which is what the headline and the ring report. */
|
|
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",
|
|
none: "No checks",
|
|
};
|
|
|
|
export interface ChecksCountPart {
|
|
status: CheckPresentation;
|
|
count: number;
|
|
/** e.g. "2 failing" */
|
|
text: string;
|
|
}
|
|
|
|
export interface ChecksGroup {
|
|
status: CheckPresentation;
|
|
/** e.g. "2 failing checks" */
|
|
label: string;
|
|
checks: readonly PrPaneCheck[];
|
|
}
|
|
|
|
export interface ChecksSummary {
|
|
outcome: ChecksOutcome;
|
|
/** e.g. "Some checks were not successful" */
|
|
headline: string;
|
|
/** The count phrases behind `detail`, kept apart so the header can label each one. */
|
|
parts: readonly ChecksCountPart[];
|
|
/** The noun closing the detail line — "checks", or "check" for a run of one. */
|
|
countNoun: string;
|
|
/** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */
|
|
detail: string;
|
|
total: number;
|
|
/** Non-empty groups only, in `STATUS_ORDER`. */
|
|
groups: readonly ChecksGroup[];
|
|
}
|
|
|
|
/**
|
|
* Reduces a change request's checks to everything the checks section renders, so the
|
|
* header, the ring, and the grouped list all read from one derivation instead of each
|
|
* filtering the array again with its own idea of what counts.
|
|
*/
|
|
export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary {
|
|
const groups: ChecksGroup[] = [];
|
|
const parts: ChecksCountPart[] = [];
|
|
|
|
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} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`,
|
|
checks: matching,
|
|
});
|
|
parts.push({
|
|
status,
|
|
count: matching.length,
|
|
text: `${matching.length} ${PRESENTATION_NOUN[status]}`,
|
|
});
|
|
}
|
|
|
|
const outcome = selectOutcome(checks);
|
|
const noun = countNoun(checks.length);
|
|
return {
|
|
outcome,
|
|
headline: OUTCOME_HEADLINE[outcome],
|
|
parts,
|
|
countNoun: noun,
|
|
detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`,
|
|
total: checks.length,
|
|
groups,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A failure outranks anything still running: a run that is half done with one failure
|
|
* already needs the user, and reporting it as in progress buries that.
|
|
*/
|
|
function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome {
|
|
if (checks.length === 0) {
|
|
return "none";
|
|
}
|
|
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")) {
|
|
return "pending";
|
|
}
|
|
return "success";
|
|
}
|
|
|
|
function countNoun(count: number): string {
|
|
return count === 1 ? "check" : "checks";
|
|
}
|