mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 09:12:44 +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>
540 lines
16 KiB
TypeScript
540 lines
16 KiB
TypeScript
import type { ForgeSearchKind } from "@getpaseo/protocol/messages";
|
|
|
|
export type ForgeSearchRequestKind = ForgeSearchKind | "github-issue" | "github-pr" | "pr";
|
|
|
|
export function normalizeForgeSearchKinds(
|
|
kinds: readonly ForgeSearchRequestKind[] | undefined,
|
|
): ForgeSearchKind[] {
|
|
if (!kinds) return ["issue", "change_request"];
|
|
|
|
return kinds.map((kind) => {
|
|
// 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;
|
|
});
|
|
}
|
|
|
|
export interface PullRequestSummary {
|
|
number: number;
|
|
title: string;
|
|
url: string;
|
|
state: string;
|
|
body: string | null;
|
|
projectPath?: string;
|
|
baseRefName: string;
|
|
headRefName: string;
|
|
labels: string[];
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface PullRequestCheckoutTarget {
|
|
number: number;
|
|
baseRefName: string;
|
|
headRefName: string;
|
|
checkoutRefs?: PullRequestCheckoutRef[];
|
|
headOwnerLogin: string | null;
|
|
headRepositorySshUrl: string | null;
|
|
headRepositoryUrl: string | null;
|
|
isCrossRepository: boolean;
|
|
}
|
|
|
|
export interface PullRequestCheckoutRef {
|
|
remoteName?: string;
|
|
remoteRef: string;
|
|
}
|
|
|
|
export interface IssueSummary {
|
|
number: number;
|
|
title: string;
|
|
url: string;
|
|
state: string;
|
|
body: string | null;
|
|
projectPath?: string;
|
|
labels: string[];
|
|
updatedAt: string;
|
|
}
|
|
|
|
export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped";
|
|
|
|
export interface PullRequestCheck {
|
|
name: string;
|
|
status: PullRequestCheckStatus;
|
|
url: string | null;
|
|
workflow?: string;
|
|
/** How long the check took, or how long it has been running. Formatted, never raw. */
|
|
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";
|
|
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
|
|
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
|
|
export function computeChecksStatus(checks: PullRequestCheck[]): PullRequestChecksStatus {
|
|
if (checks.length === 0) {
|
|
return "none";
|
|
}
|
|
if (checks.some((check) => check.status === "failure")) {
|
|
return "failure";
|
|
}
|
|
if (checks.some((check) => check.status === "pending")) {
|
|
return "pending";
|
|
}
|
|
return "success";
|
|
}
|
|
|
|
/**
|
|
* Why a forge's PR/MR features are (un)available for a workspace. Replaces the
|
|
* lossy "authenticated yes/no" boolean so the UI can offer the precise next step
|
|
* (install the CLI vs sign in) instead of a single generic dead-end. "no_remote"
|
|
* covers anything where the feature simply does not apply (no resolvable forge
|
|
* remote, or no branch to look up). "error" is reserved for non-auth failures
|
|
* where the UI should show the actual error instead of setup guidance.
|
|
*/
|
|
export type ForgeAuthState =
|
|
| "authenticated"
|
|
| "unauthenticated"
|
|
| "cli_missing"
|
|
| "no_remote"
|
|
| "error";
|
|
|
|
/**
|
|
* Open envelope for a forge's native merge facts on the neutral PR status.
|
|
* Per-adapter modules own their typed fact interfaces and guards; shared server
|
|
* code only requires the runtime facts-family tag.
|
|
*/
|
|
export type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
|
|
|
|
export interface CurrentPullRequestStatus {
|
|
number?: number;
|
|
repoOwner?: string;
|
|
repoName?: string;
|
|
/**
|
|
* The forge's full project path (e.g. nested GitLab namespaces like
|
|
* `group/subgroup/repo`). Adapters that can report it precisely set it here;
|
|
* otherwise consumers fall back to deriving it from owner/name.
|
|
*/
|
|
projectPath?: string;
|
|
url: string;
|
|
title: string;
|
|
state: string;
|
|
baseRefName: string;
|
|
headRefName: string;
|
|
isMerged: boolean;
|
|
isDraft?: boolean;
|
|
mergeable: PullRequestMergeable;
|
|
checks: PullRequestCheck[];
|
|
checksStatus: PullRequestChecksStatus;
|
|
reviewDecision: PullRequestReviewDecision;
|
|
forgeSpecific?: ForgeSpecificStatusFacts;
|
|
}
|
|
|
|
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
|
|
|
|
interface PullRequestTimelineItemBase {
|
|
id: string;
|
|
author: string;
|
|
authorUrl: string | null;
|
|
avatarUrl: string | null;
|
|
body: string;
|
|
createdAt: number;
|
|
url: string;
|
|
}
|
|
|
|
export type PullRequestTimelineItem =
|
|
| (PullRequestTimelineItemBase & {
|
|
kind: "review";
|
|
reviewState: PullRequestTimelineReviewState;
|
|
})
|
|
| (PullRequestTimelineItemBase & {
|
|
kind: "comment";
|
|
reviewId?: string;
|
|
threadId?: string;
|
|
threadIsResolved?: boolean;
|
|
location?: PullRequestTimelineCommentLocation;
|
|
});
|
|
|
|
export interface PullRequestTimelineCommentLocation {
|
|
path: string;
|
|
line?: number;
|
|
startLine?: number;
|
|
threadId?: string;
|
|
isResolved?: boolean;
|
|
isOutdated?: boolean;
|
|
}
|
|
|
|
export type PullRequestTimelineErrorKind = "not_found" | "forbidden" | "unknown";
|
|
|
|
export interface PullRequestTimelineError {
|
|
kind: PullRequestTimelineErrorKind;
|
|
message: string;
|
|
}
|
|
|
|
export interface PullRequestTimeline {
|
|
prNumber: number;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
items: PullRequestTimelineItem[];
|
|
truncated: boolean;
|
|
error: PullRequestTimelineError | null;
|
|
}
|
|
|
|
export interface PullRequestCreateResult {
|
|
url: string;
|
|
number: number;
|
|
}
|
|
|
|
export type PullRequestMergeMethod = "merge" | "squash" | "rebase";
|
|
|
|
export interface PullRequestCommandStatus {
|
|
mergeable?: PullRequestMergeable;
|
|
forgeSpecific?: ForgeSpecificStatusFacts;
|
|
}
|
|
|
|
export interface MergePullRequestOptions {
|
|
cwd: string;
|
|
prNumber: number;
|
|
mergeMethod: PullRequestMergeMethod;
|
|
status?: PullRequestCommandStatus | null;
|
|
}
|
|
|
|
export interface EnablePullRequestAutoMergeOptions {
|
|
cwd: string;
|
|
prNumber: number;
|
|
mergeMethod: PullRequestMergeMethod;
|
|
status?: PullRequestCommandStatus | null;
|
|
}
|
|
|
|
export interface DisablePullRequestAutoMergeOptions {
|
|
cwd: string;
|
|
prNumber: number;
|
|
status?: PullRequestCommandStatus | null;
|
|
}
|
|
|
|
export interface PullRequestMergeResult {
|
|
success: true;
|
|
}
|
|
|
|
export interface PullRequestAutoMergeResult {
|
|
success: true;
|
|
}
|
|
|
|
export type ForgeReadOptions =
|
|
| {
|
|
force?: false;
|
|
reason?: string;
|
|
}
|
|
| {
|
|
force: true;
|
|
reason: string;
|
|
};
|
|
|
|
export type ListPullRequestsOptions = {
|
|
cwd: string;
|
|
query?: string;
|
|
limit?: number;
|
|
} & ForgeReadOptions;
|
|
|
|
export type ListIssuesOptions = {
|
|
cwd: string;
|
|
query?: string;
|
|
limit?: number;
|
|
} & ForgeReadOptions;
|
|
|
|
export type GetPullRequestOptions = {
|
|
cwd: string;
|
|
number: number;
|
|
} & ForgeReadOptions;
|
|
|
|
export type GetPullRequestTimelineOptions = {
|
|
cwd: string;
|
|
prNumber: number;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
} & ForgeReadOptions;
|
|
|
|
export type GetCheckDetailsOptions = {
|
|
cwd: string;
|
|
/**
|
|
* GitHub-only: the GitHub adapter addresses the check run by owner/name. The
|
|
* GitLab adapter resolves the project from the cwd's remote and ignores these.
|
|
*/
|
|
repoOwner?: string;
|
|
repoName?: string;
|
|
/**
|
|
* Check-run id. Optional because some checks are addressed only by
|
|
* workflowRunId (Gitea Actions runs carry no check-run id). Callers pass at
|
|
* least one of checkRunId/workflowRunId.
|
|
*/
|
|
checkRunId?: number;
|
|
workflowRunId?: number;
|
|
/**
|
|
* Change request number used when check details must resolve against a
|
|
* specific request rather than the current branch. GitLab routes the fetch to
|
|
* the MR's head pipeline; Gitea-family adapters resolve the PR head SHA by
|
|
* number, including for terminal PRs. GitHub ignores it.
|
|
*/
|
|
changeRequestNumber?: number;
|
|
} & ForgeReadOptions;
|
|
|
|
export interface CheckAnnotation {
|
|
path?: string;
|
|
startLine?: number;
|
|
endLine?: number;
|
|
annotationLevel?: string;
|
|
message?: string;
|
|
title?: string;
|
|
rawDetails?: string;
|
|
}
|
|
|
|
export interface CheckFailedJob {
|
|
jobId: number;
|
|
name: string;
|
|
status?: string | null;
|
|
conclusion?: string | null;
|
|
url?: string | null;
|
|
completedAt?: string;
|
|
logTail?: string;
|
|
logTruncated?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Normalized lifecycle of a CI job/pipeline, neutral across forges. Adapters
|
|
* map their forge's raw status strings onto this; readers keep the raw string
|
|
* too (see {@link PipelineJob.rawStatus}) for display fidelity.
|
|
*/
|
|
export type PipelineJobStatus =
|
|
| "success"
|
|
| "failed"
|
|
| "running"
|
|
| "pending"
|
|
| "canceled"
|
|
| "skipped"
|
|
| "manual"
|
|
| "created"
|
|
| "unknown";
|
|
|
|
export interface PipelineJob {
|
|
id: number;
|
|
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;
|
|
durationSeconds: number | null;
|
|
}
|
|
|
|
export interface PipelineStage {
|
|
name: string;
|
|
status: PipelineJobStatus;
|
|
jobs: PipelineJob[];
|
|
}
|
|
|
|
/**
|
|
* A CI pipeline as a stage → job tree. Forges that model CI as a pipeline
|
|
* (GitLab) populate this; forges that model it as flat check runs (GitHub)
|
|
* leave {@link CheckDetails.pipeline} undefined and use the check-run fields.
|
|
*/
|
|
export interface PipelineDetails {
|
|
id: number;
|
|
status: PipelineJobStatus;
|
|
/** COMPAT(pipelineRawStatus): see {@link PipelineJob.rawStatus}. */
|
|
rawStatus: string;
|
|
url: string | null;
|
|
ref: string | null;
|
|
sha: string | null;
|
|
stages: PipelineStage[];
|
|
}
|
|
|
|
export interface CheckDetails {
|
|
checkRunId: number;
|
|
workflowRunId?: number | null;
|
|
name: string;
|
|
status?: string | null;
|
|
conclusion?: string | null;
|
|
url?: string | null;
|
|
detailsUrl?: string | null;
|
|
output?: {
|
|
title?: string | null;
|
|
summary?: string | null;
|
|
text?: string | null;
|
|
} | null;
|
|
annotations: CheckAnnotation[];
|
|
failedJobs: CheckFailedJob[];
|
|
truncated: boolean;
|
|
/**
|
|
* Structured pipeline (stages → jobs) for forges that model CI as a pipeline.
|
|
* GitLab populates it; GitHub leaves it undefined and keeps using the flat
|
|
* check-run fields above.
|
|
*/
|
|
pipeline?: PipelineDetails | null;
|
|
}
|
|
|
|
export interface SearchResult {
|
|
items: Array<{
|
|
kind: "issue" | "change_request";
|
|
forge?: string;
|
|
number: number;
|
|
title: string;
|
|
url: string;
|
|
state: string;
|
|
body: string | null;
|
|
labels: string[];
|
|
projectPath?: string;
|
|
baseRefName?: string | null;
|
|
headRefName?: string | null;
|
|
updatedAt?: string;
|
|
}>;
|
|
featuresEnabled: boolean;
|
|
authState: ForgeAuthState;
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
export function createUnavailableSearchResult(
|
|
authState: Exclude<ForgeAuthState, "authenticated">,
|
|
): SearchResult {
|
|
return {
|
|
items: [],
|
|
featuresEnabled: false,
|
|
authState,
|
|
githubFeaturesEnabled: false,
|
|
};
|
|
}
|
|
|
|
export type SearchIssuesAndPrsOptions = {
|
|
cwd: string;
|
|
query: string;
|
|
limit?: number;
|
|
kinds?: ForgeSearchRequestKind[];
|
|
} & ForgeReadOptions;
|
|
|
|
export interface CreatePullRequestOptions {
|
|
cwd: string;
|
|
title: string;
|
|
head: string;
|
|
base: string;
|
|
body?: string;
|
|
}
|
|
|
|
export interface ForgeService {
|
|
listPullRequests(options: ListPullRequestsOptions): Promise<PullRequestSummary[]>;
|
|
listIssues(options: ListIssuesOptions): Promise<IssueSummary[]>;
|
|
getPullRequest(options: GetPullRequestOptions): Promise<PullRequestSummary>;
|
|
getPullRequestHeadRef(options: GetPullRequestOptions): Promise<string>;
|
|
getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>;
|
|
/**
|
|
* Refs to fetch for a change-request checkout when the resolved checkout
|
|
* target carries none. Adapters that expose a universal change-request head
|
|
* ref (GitHub's refs/pull/N/head) return it here; others let the shell fall
|
|
* back to the head branch.
|
|
*/
|
|
defaultCheckoutRefs?(params: {
|
|
changeRequestNumber: number;
|
|
headRef: string;
|
|
}): PullRequestCheckoutRef[];
|
|
/**
|
|
* Local branch name for a checked-out change request when the adapter
|
|
* disambiguates cross-repository heads (GitHub prefixes the fork owner).
|
|
* Returns undefined to keep the head ref name as-is.
|
|
*/
|
|
buildPrLocalBranchName?(params: {
|
|
headRef: string;
|
|
checkoutTarget: PullRequestCheckoutTarget;
|
|
}): string | undefined;
|
|
/**
|
|
* True when the adapter can fetch a cross-repository change-request head with
|
|
* no explicit refs on the checkout target (GitHub via refs/pull/N/head).
|
|
* Others reject a cross-repo checkout that carries no refs.
|
|
*/
|
|
supportsCrossRepoCheckoutWithoutRefs?: boolean;
|
|
getCurrentPullRequestStatus(
|
|
options: {
|
|
cwd: string;
|
|
headRef: string;
|
|
headSha?: string;
|
|
headRepositoryOwner?: string;
|
|
} & ForgeReadOptions,
|
|
): Promise<CurrentPullRequestStatus | null>;
|
|
getPullRequestTimeline(options: GetPullRequestTimelineOptions): Promise<PullRequestTimeline>;
|
|
getCheckDetails(options: GetCheckDetailsOptions): Promise<CheckDetails>;
|
|
searchIssuesAndPrs(options: SearchIssuesAndPrsOptions): Promise<SearchResult>;
|
|
createPullRequest(options: CreatePullRequestOptions): Promise<PullRequestCreateResult>;
|
|
mergePullRequest(options: MergePullRequestOptions): Promise<PullRequestMergeResult>;
|
|
enablePullRequestAutoMerge(
|
|
options: EnablePullRequestAutoMergeOptions,
|
|
): Promise<PullRequestAutoMergeResult>;
|
|
disablePullRequestAutoMerge(
|
|
options: DisablePullRequestAutoMergeOptions,
|
|
): Promise<PullRequestAutoMergeResult>;
|
|
/**
|
|
* Check whether the adapter can authenticate against the forge for `cwd`.
|
|
* An adapter picks exactly one of two contracts:
|
|
*
|
|
* - Resolve to `false` on any auth failure (missing CLI, expired token,
|
|
* unreachable host, ...) and never reject. This is the default contract;
|
|
* most adapters should implement it this way.
|
|
* - Reject with a classified error distinguishing the failure kind (e.g.
|
|
* "CLI not installed" vs "not logged in") instead of collapsing
|
|
* everything to `false`. An adapter that does this MUST also set
|
|
* {@link authProbeCanThrow} to `true`, or callers have no way to know the
|
|
* rejection is meaningful and will treat it as an unhandled error.
|
|
*/
|
|
isAuthenticated(options: { cwd: string } & ForgeReadOptions): Promise<boolean>;
|
|
/**
|
|
* True when isAuthenticated() throws a classified error (missing CLI /
|
|
* auth failure) on failure instead of just resolving to false. Callers that
|
|
* want the precise failure kind before falling back to a PR-status lookup
|
|
* (which can't distinguish "unauthenticated" from "no PR found") should
|
|
* only probe isAuthenticated() when this is set — otherwise the call
|
|
* cannot change the outcome and is pure overhead.
|
|
*/
|
|
authProbeCanThrow?: boolean;
|
|
retainCurrentPullRequestStatusPoll?(options: {
|
|
cwd: string;
|
|
headRef: string;
|
|
headSha?: string;
|
|
headRepositoryOwner?: string;
|
|
onStatus?: (status: CurrentPullRequestStatus | null) => void;
|
|
onError?: (error: unknown) => void;
|
|
}): { unsubscribe: () => void };
|
|
invalidate(options: { cwd: string }): void;
|
|
dispose?(): void;
|
|
}
|
|
|
|
/** Parse an optional ISO timestamp to epoch ms, 0 when absent or unparseable. */
|
|
export function parseOptionalTime(value: string | null | undefined): number {
|
|
if (!value) {
|
|
return 0;
|
|
}
|
|
const parsed = Date.parse(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
/** Stable timeline ordering shared by every adapter: by createdAt, then id. */
|
|
export function compareTimelineItems(
|
|
left: PullRequestTimelineItem,
|
|
right: PullRequestTimelineItem,
|
|
): number {
|
|
if (left.createdAt !== right.createdAt) {
|
|
return left.createdAt - right.createdAt;
|
|
}
|
|
return left.id.localeCompare(right.id);
|
|
}
|