mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +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>
2298 lines
76 KiB
TypeScript
2298 lines
76 KiB
TypeScript
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";
|
|
import { runGitCommand } from "../utils/run-git-command.js";
|
|
import { execCommand } from "../utils/spawn.js";
|
|
import {
|
|
createCachedCliPathResolver,
|
|
createForgeCliRunner,
|
|
ForgeAuthenticationError,
|
|
ForgeCliMissingError,
|
|
CLI_AUTH_PROBE_TIMEOUT_MS,
|
|
defaultResolveRemoteUrl,
|
|
parseCliJsonOutput,
|
|
ForgeCommandError,
|
|
type ForgeCommandFailureParams,
|
|
} from "./forge-cli-command.js";
|
|
import {
|
|
computeChecksStatus,
|
|
compareTimelineItems,
|
|
createUnavailableSearchResult,
|
|
normalizeForgeSearchKinds,
|
|
parseOptionalTime,
|
|
} from "./forge-service.js";
|
|
import type {
|
|
CheckDetails,
|
|
CreatePullRequestOptions,
|
|
CurrentPullRequestStatus,
|
|
DisablePullRequestAutoMergeOptions,
|
|
EnablePullRequestAutoMergeOptions,
|
|
ForgeReadOptions,
|
|
ForgeService,
|
|
GetCheckDetailsOptions,
|
|
GetPullRequestOptions,
|
|
GetPullRequestTimelineOptions,
|
|
IssueSummary,
|
|
ListIssuesOptions,
|
|
ListPullRequestsOptions,
|
|
MergePullRequestOptions,
|
|
PullRequestChecksStatus,
|
|
PullRequestCreateResult,
|
|
PullRequestMergeable,
|
|
PullRequestMergeResult,
|
|
PullRequestSummary,
|
|
PullRequestTimeline,
|
|
PullRequestTimelineCommentLocation,
|
|
PullRequestTimelineError,
|
|
PullRequestTimelineErrorKind,
|
|
PullRequestTimelineItem,
|
|
PullRequestTimelineReviewState,
|
|
PullRequestCheck,
|
|
PullRequestCheckoutTarget,
|
|
SearchIssuesAndPrsOptions,
|
|
SearchResult,
|
|
} from "./forge-service.js";
|
|
import { isGiteaStatusFacts, type GiteaStatusFacts } from "./gitea-facts.js";
|
|
|
|
const TEA_ENV = {
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
} as const;
|
|
|
|
const TEA_COMMAND_TIMEOUT_MS = 30_000;
|
|
// The timeline's per-review comment fan-out spawns one `tea api` process per
|
|
// review that has comments; an unbounded Promise.allSettled over a PR with
|
|
// dozens of reviews would launch that many processes at once.
|
|
const REVIEW_COMMENTS_FAN_OUT_CONCURRENCY = 5;
|
|
const reviewCommentsLimit = pLimit(REVIEW_COMMENTS_FAN_OUT_CONCURRENCY);
|
|
// The client polls getCheckDetails every ~15s while a pipeline runs; without a
|
|
// short cache, each poll re-derives the PR head SHA from scratch (branch name +
|
|
// PR lookup + PR view). The TTL keeps a new push visible within one poll cycle.
|
|
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_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
|
|
* ones the neutral mapping needs (url, mergeable, base, head, ci, body), so they
|
|
* must be requested explicitly — otherwise tea silently emits the short default
|
|
* set and the mapping loses data.
|
|
*/
|
|
const PR_LIST_FIELDS =
|
|
"index,state,author,url,title,body,mergeable,base,head,created,updated,labels,comments,ci";
|
|
|
|
const ISSUE_LIST_FIELDS = "index,state,author,url,title,body,labels,comments,created,updated";
|
|
|
|
const CURRENT_PR_LOOKUP_PAGE_SIZE = 50;
|
|
const CURRENT_PR_OPEN_LOOKUP_MAX_PAGES = 4;
|
|
const TIMELINE_PAGE_SIZE = 50;
|
|
const TIMELINE_MAX_PAGES_PER_BUCKET = 4;
|
|
|
|
export class TeaCliMissingError extends ForgeCliMissingError {
|
|
constructor() {
|
|
super("Gitea CLI (tea) is not installed or not in PATH");
|
|
this.name = "TeaCliMissingError";
|
|
}
|
|
}
|
|
|
|
export class TeaAuthenticationError extends ForgeAuthenticationError {
|
|
constructor(params: { stderr: string }) {
|
|
super("Gitea CLI authentication failed", params);
|
|
this.name = "TeaAuthenticationError";
|
|
}
|
|
}
|
|
|
|
export class TeaCommandError extends ForgeCommandError {
|
|
constructor(params: ForgeCommandFailureParams) {
|
|
super({ brand: "Gitea", binary: "tea" }, params);
|
|
this.name = "TeaCommandError";
|
|
}
|
|
}
|
|
|
|
export interface TeaCommandRunnerOptions {
|
|
cwd: string;
|
|
envOverlay?: Record<string, string>;
|
|
}
|
|
|
|
export interface TeaCommandResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export type TeaCommandRunner = (
|
|
args: string[],
|
|
options: TeaCommandRunnerOptions,
|
|
) => Promise<TeaCommandResult>;
|
|
|
|
export interface CreateGiteaServiceOptions {
|
|
runner?: TeaCommandRunner;
|
|
resolveTeaPath?: () => Promise<string | null>;
|
|
resolveRemoteUrl?: (cwd: string) => Promise<string | null>;
|
|
resolveCurrentBranch?: (cwd: string) => Promise<string | null>;
|
|
now?: () => number;
|
|
}
|
|
|
|
/**
|
|
* `tea pr list -o json` flattens every value to a string (e.g. `index:"5"`,
|
|
* `mergeable:"false"`, `comments:"0"`), unlike the single-PR view which is
|
|
* natively typed. The list schema therefore parses strings and the mapping
|
|
* coerces; `.passthrough()` tolerates the extra fields tea always includes.
|
|
*/
|
|
const GiteaPrListItemSchema = z
|
|
.object({
|
|
index: z.string(),
|
|
state: z.string(),
|
|
url: z.string(),
|
|
title: z.string(),
|
|
body: z.string().optional().default(""),
|
|
mergeable: z.string().optional(),
|
|
base: z.string().optional(),
|
|
head: z.string().optional(),
|
|
created: z.string().optional(),
|
|
updated: z.string().optional(),
|
|
labels: z.string().optional(),
|
|
ci: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaIssueListItemSchema = z
|
|
.object({
|
|
index: z.string(),
|
|
state: z.string(),
|
|
url: z.string(),
|
|
title: z.string(),
|
|
body: z.string().optional().default(""),
|
|
labels: z.string().optional(),
|
|
updated: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaLoginSchema = z
|
|
.object({
|
|
name: z.string().optional(),
|
|
url: z.string().optional(),
|
|
ssh_host: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaUserSchema = z
|
|
.object({
|
|
login: z.string().optional(),
|
|
full_name: z.string().optional(),
|
|
avatar_url: z.string().nullable().optional(),
|
|
html_url: z.string().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaPullRequestViewSchema = z
|
|
.object({
|
|
index: z.number(),
|
|
url: z.string(),
|
|
headSha: z.string().optional(),
|
|
title: z.string().optional().default(""),
|
|
state: z.string().optional().default("open"),
|
|
body: z.string().nullable().optional(),
|
|
base: z.string().optional(),
|
|
head: z.string().optional(),
|
|
mergeable: z.boolean().optional(),
|
|
hasMerged: z.boolean().optional(),
|
|
updated: z.string().optional(),
|
|
// tea emits labels as an array of label objects (or strings on older builds);
|
|
// tolerate both and extract the display name.
|
|
labels: z.array(z.union([z.string(), z.object({ name: z.string() }).passthrough()])).optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
/**
|
|
* Subset of the Gitea/Forgejo REST PR object (`GET repos/{owner}/{repo}/pulls/{index}`)
|
|
* that `tea pr -o json` flattens away: the head/base repositories. Comparing their
|
|
* ids tells a fork PR (cross-repo) from a same-repo PR, and the head repo carries
|
|
* the contributor remote we push back to. `head.repo` is null when the fork was
|
|
* deleted, which we treat as same-repo.
|
|
*/
|
|
const GiteaPullRequestRepoSchema = z
|
|
.object({
|
|
id: z.number().optional(),
|
|
ssh_url: z.string().nullable().optional(),
|
|
html_url: z.string().nullable().optional(),
|
|
owner: z.object({ login: z.string().optional() }).passthrough().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaPullRequestApiSchema = z
|
|
.object({
|
|
head: z
|
|
.object({ repo: GiteaPullRequestRepoSchema.nullable().optional() })
|
|
.passthrough()
|
|
.optional(),
|
|
base: z
|
|
.object({ repo: GiteaPullRequestRepoSchema.nullable().optional() })
|
|
.passthrough()
|
|
.optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaCurrentPullRequestApiSchema = z
|
|
.object({
|
|
number: z.number(),
|
|
html_url: z.string(),
|
|
title: z.string(),
|
|
body: z.string().nullable().optional(),
|
|
state: z.string(),
|
|
merged: z.boolean().optional(),
|
|
mergeable: z.boolean().nullable().optional(),
|
|
updated_at: z.string().optional(),
|
|
labels: z
|
|
.array(z.object({ name: z.string() }).passthrough())
|
|
.optional()
|
|
.default([]),
|
|
head: z
|
|
.object({
|
|
ref: z.string(),
|
|
sha: z.string(),
|
|
repo: GiteaPullRequestRepoSchema.nullable().optional(),
|
|
})
|
|
.passthrough(),
|
|
base: z
|
|
.object({
|
|
ref: z.string(),
|
|
repo: GiteaPullRequestRepoSchema.nullable().optional(),
|
|
})
|
|
.passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaCommitStatusSchema = z
|
|
.object({
|
|
id: z.number(),
|
|
status: z.string(),
|
|
target_url: z.string().nullable().optional(),
|
|
description: z.string().nullable().optional(),
|
|
url: z.string().nullable().optional(),
|
|
context: z.string().optional().default(""),
|
|
creator: z.unknown().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaCombinedCommitStatusSchema = z
|
|
.object({
|
|
state: z.string().optional().default(""),
|
|
sha: z.string().optional(),
|
|
total_count: z.number().optional(),
|
|
statuses: z.array(GiteaCommitStatusSchema).optional().default([]),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaActionTaskSchema = z
|
|
.object({
|
|
id: z.number(),
|
|
name: z.string().nullable().optional(),
|
|
head_branch: z.string().nullable().optional(),
|
|
head_sha: z.string(),
|
|
run_number: z.number().optional(),
|
|
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(),
|
|
run_started_at: z.string().nullable().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
// 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 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(),
|
|
html_url: z.string().optional(),
|
|
user: GiteaUserSchema.nullable().optional(),
|
|
body: z.string().optional().default(""),
|
|
created_at: z.string().optional(),
|
|
type: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaReviewSchema = z
|
|
.object({
|
|
id: z.number(),
|
|
user: GiteaUserSchema.nullable().optional(),
|
|
state: z.string(),
|
|
body: z.string().optional().default(""),
|
|
comments_count: z.number().optional().default(0),
|
|
submitted_at: z.string().optional(),
|
|
updated_at: z.string().optional(),
|
|
html_url: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const GiteaReviewCommentSchema = z
|
|
.object({
|
|
id: z.number(),
|
|
body: z.string().optional().default(""),
|
|
user: GiteaUserSchema.nullable().optional(),
|
|
pull_request_review_id: z.number().optional(),
|
|
created_at: z.string().optional(),
|
|
updated_at: z.string().optional(),
|
|
path: z.string().optional(),
|
|
position: z.number().optional(),
|
|
original_position: z.number().optional(),
|
|
resolver: GiteaUserSchema.nullable().optional(),
|
|
html_url: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
type GiteaPrListItem = z.infer<typeof GiteaPrListItemSchema>;
|
|
type GiteaIssueListItem = z.infer<typeof GiteaIssueListItemSchema>;
|
|
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 GiteaActionTask = z.infer<typeof GiteaActionTaskSchema>;
|
|
type GiteaIssueComment = z.infer<typeof GiteaIssueCommentSchema>;
|
|
type GiteaReview = z.infer<typeof GiteaReviewSchema>;
|
|
type GiteaReviewComment = z.infer<typeof GiteaReviewCommentSchema>;
|
|
|
|
interface GiteaTimelineBucket<T> {
|
|
items: T[];
|
|
truncated: boolean;
|
|
}
|
|
|
|
async function resolveTeaPath(): Promise<string | null> {
|
|
return findExecutable("tea");
|
|
}
|
|
|
|
const teaCliRunner = createForgeCliRunner({
|
|
binary: "tea",
|
|
envOverlay: TEA_ENV,
|
|
timeoutMs: TEA_COMMAND_TIMEOUT_MS,
|
|
isAuthFailureText,
|
|
errorClasses: {
|
|
isAlreadyClassified: (candidate) =>
|
|
candidate instanceof TeaAuthenticationError || candidate instanceof TeaCliMissingError,
|
|
isCommandError: (candidate): candidate is TeaCommandError =>
|
|
candidate instanceof TeaCommandError,
|
|
createAuthError: (stderr) => new TeaAuthenticationError({ stderr }),
|
|
createMissingError: () => new TeaCliMissingError(),
|
|
createCommandError: (params) => new TeaCommandError(params),
|
|
},
|
|
});
|
|
|
|
async function runTeaCommand(
|
|
args: string[],
|
|
options: TeaCommandRunnerOptions,
|
|
): Promise<TeaCommandResult> {
|
|
return teaCliRunner.run(args, options);
|
|
}
|
|
|
|
async function defaultResolveCurrentBranch(cwd: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await runGitCommand(["branch", "--show-current"], { cwd });
|
|
const branch = stdout.trim();
|
|
return branch.length > 0 ? branch : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function parseGiteaHostFromRemoteUrl(url: string): string | null {
|
|
return parseGitRemoteLocation(url)?.host ?? null;
|
|
}
|
|
|
|
/**
|
|
* tea numeric fields arrive as strings in list output. Returns null for a
|
|
* missing/non-numeric value so callers can decide on a fallback.
|
|
*/
|
|
function parseGiteaInt(value: string | undefined): number | null {
|
|
if (value === undefined || value.trim() === "") {
|
|
return null;
|
|
}
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function parseGiteaBool(value: string | undefined): boolean {
|
|
return value?.trim().toLowerCase() === "true";
|
|
}
|
|
|
|
function timelineApiPath(input: GetPullRequestTimelineOptions, suffix: string): string {
|
|
const owner = encodeURIComponent(input.repoOwner);
|
|
const repo = encodeURIComponent(input.repoName);
|
|
return `repos/${owner}/${repo}/${suffix}`;
|
|
}
|
|
|
|
function timelinePageQuery(page: number): string {
|
|
return `page=${page}&limit=${TIMELINE_PAGE_SIZE}`;
|
|
}
|
|
|
|
function giteaUserLogin(user: z.infer<typeof GiteaUserSchema> | null | undefined): string {
|
|
return user?.login ?? user?.full_name ?? "unknown";
|
|
}
|
|
|
|
function giteaUserUrl(user: z.infer<typeof GiteaUserSchema> | null | undefined): string | null {
|
|
return user?.html_url ?? null;
|
|
}
|
|
|
|
function giteaUserAvatar(user: z.infer<typeof GiteaUserSchema> | null | undefined): string | null {
|
|
return user?.avatar_url ?? null;
|
|
}
|
|
|
|
function isGiteaSystemComment(comment: GiteaIssueComment): boolean {
|
|
return comment.type !== undefined && comment.type !== "comment";
|
|
}
|
|
|
|
function toGiteaReviewState(state: string): PullRequestTimelineReviewState {
|
|
switch (state.trim().toUpperCase()) {
|
|
case "APPROVED":
|
|
case "APPROVE":
|
|
return "approved";
|
|
case "REQUEST_CHANGES":
|
|
case "REQUESTED_CHANGES":
|
|
case "CHANGES_REQUESTED":
|
|
return "changes_requested";
|
|
default:
|
|
return "commented";
|
|
}
|
|
}
|
|
|
|
function toGiteaTimelineComment(
|
|
comment: GiteaIssueComment,
|
|
pr: GiteaPullRequestView,
|
|
): PullRequestTimelineItem | null {
|
|
if (isGiteaSystemComment(comment)) {
|
|
return null;
|
|
}
|
|
return {
|
|
kind: "comment",
|
|
id: String(comment.id),
|
|
author: giteaUserLogin(comment.user),
|
|
authorUrl: giteaUserUrl(comment.user),
|
|
avatarUrl: giteaUserAvatar(comment.user),
|
|
body: comment.body,
|
|
createdAt: parseOptionalTime(comment.created_at),
|
|
url: comment.html_url ?? `${pr.url}#issuecomment-${comment.id}`,
|
|
};
|
|
}
|
|
|
|
function toGiteaTimelineReview(review: GiteaReview): PullRequestTimelineItem {
|
|
return {
|
|
kind: "review",
|
|
id: String(review.id),
|
|
author: giteaUserLogin(review.user),
|
|
authorUrl: giteaUserUrl(review.user),
|
|
avatarUrl: giteaUserAvatar(review.user),
|
|
body: review.body,
|
|
createdAt: parseOptionalTime(review.submitted_at ?? review.updated_at),
|
|
url: review.html_url ?? "",
|
|
reviewState: toGiteaReviewState(review.state),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gitea/Forgejo expose no stable review-thread id, so distinct inline locations
|
|
* within one review all share a single `pull_request_review_id`. Keying threads
|
|
* on that id collapses unrelated file/line comments into one card. Instead key
|
|
* on the diff position (path + position), which a reply to the same inline
|
|
* thread reuses, so distinct locations stay separate while same-location replies
|
|
* still group, matching GitHub's review-thread grouping as closely as the API
|
|
* allows. Without a diff position (comment scrolled out of the current diff),
|
|
* fall back to the comment's own id so unrelated comments never merge.
|
|
*/
|
|
function giteaReviewCommentThreadId(comment: GiteaReviewComment): string {
|
|
const position = comment.position ?? comment.original_position ?? null;
|
|
const anchor = position != null ? `pos-${position}` : `comment-${comment.id}`;
|
|
return `${comment.path}#${anchor}`;
|
|
}
|
|
|
|
/**
|
|
* Gitea/Forgejo set `resolver` to the user who resolved an inline review thread,
|
|
* or null when it is still open. Older servers omit the field entirely; treat
|
|
* absence as "unknown" and leave `isResolved` off rather than guessing unresolved.
|
|
*/
|
|
function giteaReviewCommentResolved(comment: GiteaReviewComment): boolean | undefined {
|
|
if (!("resolver" in comment)) {
|
|
return undefined;
|
|
}
|
|
return comment.resolver != null;
|
|
}
|
|
|
|
function toGiteaReviewCommentLocation(
|
|
comment: GiteaReviewComment,
|
|
): PullRequestTimelineCommentLocation | undefined {
|
|
if (!comment.path) {
|
|
return undefined;
|
|
}
|
|
const isResolved = giteaReviewCommentResolved(comment);
|
|
return {
|
|
path: comment.path,
|
|
...(comment.position != null ? { line: comment.position } : {}),
|
|
threadId: giteaReviewCommentThreadId(comment),
|
|
...(isResolved !== undefined ? { isResolved } : {}),
|
|
};
|
|
}
|
|
|
|
function toGiteaTimelineReviewComment(comment: GiteaReviewComment): PullRequestTimelineItem {
|
|
const location = toGiteaReviewCommentLocation(comment);
|
|
return {
|
|
kind: "comment",
|
|
id: String(comment.id),
|
|
author: giteaUserLogin(comment.user),
|
|
authorUrl: giteaUserUrl(comment.user),
|
|
avatarUrl: giteaUserAvatar(comment.user),
|
|
body: comment.body,
|
|
createdAt: parseOptionalTime(comment.created_at ?? comment.updated_at),
|
|
url: comment.html_url ?? "",
|
|
...(comment.pull_request_review_id != null
|
|
? { reviewId: String(comment.pull_request_review_id) }
|
|
: {}),
|
|
...(location ? { location } : {}),
|
|
};
|
|
}
|
|
|
|
function splitGiteaLabels(value: string | undefined): string[] {
|
|
if (!value) {
|
|
return [];
|
|
}
|
|
return value
|
|
.split(",")
|
|
.map((label) => label.trim())
|
|
.filter((label) => label.length > 0);
|
|
}
|
|
|
|
/**
|
|
* tea reports a PR head as `owner:branch` for cross-repo PRs and a bare branch
|
|
* for same-repo ones. The neutral head ref is always just the branch name.
|
|
*/
|
|
function stripHeadOwner(head: string | undefined): string {
|
|
if (!head) {
|
|
return "";
|
|
}
|
|
const colon = head.indexOf(":");
|
|
return colon >= 0 ? head.slice(colon + 1) : head;
|
|
}
|
|
|
|
function headHasOwner(head: string | undefined): boolean {
|
|
return head !== undefined && head.indexOf(":") >= 0;
|
|
}
|
|
|
|
/**
|
|
* The head repo owner for a PR list item: the `owner:` prefix for a cross-repo
|
|
* (fork) head, otherwise the base repo owner parsed from the item URL, since a
|
|
* same-repo head arrives as a bare branch. Returns null when neither is known.
|
|
*/
|
|
function giteaHeadOwner(item: GiteaPrListItem): string | null {
|
|
const head = item.head;
|
|
if (head) {
|
|
const colon = head.indexOf(":");
|
|
if (colon >= 0) {
|
|
return head.slice(0, colon);
|
|
}
|
|
}
|
|
return parseGiteaRepoFromUrl(item.url).owner ?? null;
|
|
}
|
|
|
|
/**
|
|
* Match a PR list item to the checkout's current head. Two open PRs from
|
|
* different forks can share a branch name (`alice:fix` vs `bob:fix`), so the
|
|
* branch alone is ambiguous. When the checkout's head owner is known, require
|
|
* the head repo owner to match it too; when it cannot be determined, accept only
|
|
* a same-repo (bare) head so a colliding cross-fork PR is never picked blindly.
|
|
*/
|
|
function matchesCurrentHeadRef(
|
|
item: GiteaPrListItem,
|
|
headRef: string,
|
|
expectedHeadOwner: string | null,
|
|
): boolean {
|
|
if (stripHeadOwner(item.head) !== headRef) {
|
|
return false;
|
|
}
|
|
if (expectedHeadOwner === null) {
|
|
return !headHasOwner(item.head);
|
|
}
|
|
return giteaHeadOwner(item) === expectedHeadOwner;
|
|
}
|
|
|
|
function mapGiteaState(state: string): string {
|
|
const normalized = state.toLowerCase();
|
|
if (normalized === "open") {
|
|
return "open";
|
|
}
|
|
if (normalized === "merged") {
|
|
return "merged";
|
|
}
|
|
return "closed";
|
|
}
|
|
|
|
function mapGiteaMergeable(item: GiteaPrListItem): PullRequestMergeable {
|
|
if (item.mergeable === undefined) {
|
|
return "UNKNOWN";
|
|
}
|
|
return parseGiteaBool(item.mergeable) ? "MERGEABLE" : "CONFLICTING";
|
|
}
|
|
|
|
function mapGiteaCiStatus(ci: string | undefined): PullRequestChecksStatus {
|
|
switch (ci?.toLowerCase()) {
|
|
case "success":
|
|
return "success";
|
|
case "failure":
|
|
case "error":
|
|
// Gitea's "warning" is a terminal, NON-passing state: IsSuccess() is false,
|
|
// it fails required-status checks, and Gitea itself blocks the merge on it.
|
|
// Our neutral enum has no yellow/neutral bucket, so surface it as failure —
|
|
// never success (misleading green) or pending (never-resolving poll).
|
|
case "warning":
|
|
return "failure";
|
|
case "pending":
|
|
case "running":
|
|
return "pending";
|
|
default:
|
|
return "none";
|
|
}
|
|
}
|
|
|
|
function mapGiteaActionTaskStatus(status: string): PullRequestCheck["status"] {
|
|
switch (status.toLowerCase()) {
|
|
case "success":
|
|
return "success";
|
|
case "failure":
|
|
case "failed":
|
|
case "error":
|
|
return "failure";
|
|
case "cancelled":
|
|
case "canceled":
|
|
return "cancelled";
|
|
case "skipped":
|
|
return "skipped";
|
|
case "pending":
|
|
case "queued":
|
|
case "waiting":
|
|
case "requested":
|
|
case "running":
|
|
case "in_progress":
|
|
case "blocked":
|
|
return "pending";
|
|
default:
|
|
return "pending";
|
|
}
|
|
}
|
|
|
|
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: mapGiteaCommitState(rawStatus),
|
|
...(rawStatus === "warning" ? { traits: [CHECK_TRAIT_WARNING] } : {}),
|
|
url: status.target_url ?? null,
|
|
checkRunId: status.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 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: getGiteaActionTaskName(task),
|
|
status: mapGiteaActionTaskStatus(rawStatus),
|
|
...(rawStatus === "blocked" && task.need_approval
|
|
? { traits: [CHECK_TRAIT_ACTION_REQUIRED] }
|
|
: {}),
|
|
url: task.url ?? null,
|
|
workflowRunId: task.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 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;
|
|
}
|
|
const time = Date.parse(timestamp);
|
|
return Number.isNaN(time) ? 0 : time;
|
|
}
|
|
|
|
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 = parseGiteaActionTaskTime(left);
|
|
const rightTime = parseGiteaActionTaskTime(right);
|
|
if (leftTime !== rightTime) {
|
|
return leftTime - rightTime;
|
|
}
|
|
|
|
return left.id - right.id;
|
|
}
|
|
|
|
function parseGiteaActionTasks(rawTasks: unknown[]): GiteaActionTask[] {
|
|
const tasks: GiteaActionTask[] = [];
|
|
for (const raw of rawTasks) {
|
|
const parsed = GiteaActionTaskSchema.safeParse(raw);
|
|
if (parsed.success) {
|
|
tasks.push(parsed.data);
|
|
}
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
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 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[],
|
|
actionTasks: GiteaActionTask[],
|
|
actionRequiredRunIds: ReadonlySet<number> = new Set(),
|
|
): PullRequestCheck[] {
|
|
const nativeOrigins = new Set(
|
|
actionTasks
|
|
.map((task) => getUrlOrigin(task.url))
|
|
.filter((origin): origin is string => !!origin),
|
|
);
|
|
// 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;
|
|
}
|
|
|
|
function applyGiteaChecks(
|
|
status: CurrentPullRequestStatus,
|
|
combined: GiteaCombinedCommitStatus,
|
|
actionTasks: GiteaActionTask[],
|
|
actionRequiredRunIds: ReadonlySet<number> = new Set(),
|
|
): CurrentPullRequestStatus {
|
|
const checks = combineGiteaChecks(combined.statuses, actionTasks, actionRequiredRunIds);
|
|
if (checks.length === 0) {
|
|
return status;
|
|
}
|
|
// Aggregate over the union of commit statuses AND Actions runs. Gitea's
|
|
// combined.state summarizes commit statuses only, so trusting it would let a
|
|
// failing/pending Actions run be reported as success. computeChecksStatus
|
|
// applies failure > pending > success precedence across every check.
|
|
return {
|
|
...status,
|
|
checks,
|
|
checksStatus: computeChecksStatus(checks),
|
|
};
|
|
}
|
|
|
|
/** Parse owner/name from a Gitea PR/issue URL (`https://host/owner/repo/pulls/N`). */
|
|
function parseGiteaRepoFromUrl(url: string): { owner?: string; name?: string } {
|
|
try {
|
|
const segments = new URL(url).pathname.split("/").filter((segment) => segment.length > 0);
|
|
if (segments.length >= 2) {
|
|
return { owner: segments[0], name: segments[1] };
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): GiteaPrListItem {
|
|
const headOwner = item.head.repo?.owner?.login;
|
|
const baseOwner = item.base.repo?.owner?.login;
|
|
const head =
|
|
headOwner && headOwner !== baseOwner ? `${headOwner}:${item.head.ref}` : item.head.ref;
|
|
return {
|
|
index: String(item.number),
|
|
state: item.merged ? "merged" : item.state,
|
|
url: item.html_url,
|
|
title: item.title,
|
|
body: item.body ?? "",
|
|
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
|
|
base: item.base.ref,
|
|
head,
|
|
updated: item.updated_at,
|
|
labels: item.labels.map((label) => label.name).join(","),
|
|
};
|
|
}
|
|
|
|
function toPullRequestSummary(item: GiteaPrListItem): PullRequestSummary {
|
|
const { owner, name } = parseGiteaRepoFromUrl(item.url);
|
|
return {
|
|
number: parseGiteaInt(item.index) ?? 0,
|
|
title: item.title,
|
|
url: item.url,
|
|
state: mapGiteaState(item.state),
|
|
body: item.body || null,
|
|
...(owner && name ? { projectPath: `${owner}/${name}` } : {}),
|
|
baseRefName: item.base ?? "",
|
|
headRefName: stripHeadOwner(item.head),
|
|
labels: splitGiteaLabels(item.labels),
|
|
updatedAt: item.updated ?? "",
|
|
};
|
|
}
|
|
|
|
function viewToPullRequestSummary(view: GiteaPullRequestView): PullRequestSummary {
|
|
const { owner, name } = parseGiteaRepoFromUrl(view.url);
|
|
return {
|
|
number: view.index,
|
|
title: view.title,
|
|
url: view.url,
|
|
state: view.hasMerged ? "merged" : mapGiteaState(view.state),
|
|
body: view.body || null,
|
|
...(owner && name ? { projectPath: `${owner}/${name}` } : {}),
|
|
baseRefName: view.base ?? "",
|
|
headRefName: stripHeadOwner(view.head),
|
|
labels: (view.labels ?? []).map((label) => (typeof label === "string" ? label : label.name)),
|
|
updatedAt: view.updated ?? "",
|
|
};
|
|
}
|
|
|
|
function toIssueSummary(item: GiteaIssueListItem): IssueSummary {
|
|
const { owner, name } = parseGiteaRepoFromUrl(item.url);
|
|
return {
|
|
number: parseGiteaInt(item.index) ?? 0,
|
|
title: item.title,
|
|
url: item.url,
|
|
state: mapGiteaState(item.state),
|
|
body: item.body || null,
|
|
...(owner && name ? { projectPath: `${owner}/${name}` } : {}),
|
|
labels: splitGiteaLabels(item.labels),
|
|
updatedAt: item.updated ?? "",
|
|
};
|
|
}
|
|
|
|
function toGiteaStatusFacts(item: GiteaPrListItem): GiteaStatusFacts {
|
|
return {
|
|
mergeable: parseGiteaBool(item.mergeable),
|
|
hasMerged: mapGiteaState(item.state) === "merged",
|
|
ciStatus: item.ci && item.ci.length > 0 ? item.ci : null,
|
|
};
|
|
}
|
|
|
|
function toCurrentPullRequestStatus(item: GiteaPrListItem): CurrentPullRequestStatus {
|
|
const { owner, name } = parseGiteaRepoFromUrl(item.url);
|
|
const state = mapGiteaState(item.state);
|
|
return {
|
|
number: parseGiteaInt(item.index) ?? undefined,
|
|
...(owner ? { repoOwner: owner } : {}),
|
|
...(name ? { repoName: name } : {}),
|
|
...(owner && name ? { projectPath: `${owner}/${name}` } : {}),
|
|
url: item.url,
|
|
title: item.title,
|
|
state,
|
|
baseRefName: item.base ?? "",
|
|
headRefName: stripHeadOwner(item.head),
|
|
isMerged: state === "merged",
|
|
mergeable: mapGiteaMergeable(item),
|
|
checks: [],
|
|
checksStatus: mapGiteaCiStatus(item.ci),
|
|
reviewDecision: null,
|
|
// The whole Gitea family (gitea, forgejo, codeberg) tags its facts with the
|
|
// family id "gitea" — they share one facts shape and one guard. This is the
|
|
// facts-family tag, distinct from the workspace's brand-specific forge id.
|
|
forgeSpecific: { forge: "gitea", ...toGiteaStatusFacts(item) },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Server-side guard for a Gitea direct merge, mirroring the gh/glab adapters:
|
|
* refuse the merge unless Gitea reports the PR as mergeable. The resolved status
|
|
* can go stale between the client check and execution, and the RPC can be called
|
|
* directly, so the guard lives here rather than only in the UI policy.
|
|
*/
|
|
function assertGiteaDirectMergeReady(input: Pick<MergePullRequestOptions, "status">): void {
|
|
const facts = input.status?.forgeSpecific;
|
|
if (!isGiteaStatusFacts(facts)) {
|
|
throw new Error("Gitea merge facts are unavailable for this pull request");
|
|
}
|
|
if (facts.hasMerged) {
|
|
throw new Error("This pull request is already merged");
|
|
}
|
|
if (!facts.mergeable) {
|
|
throw new Error("Gitea does not report this pull request as ready for direct merge");
|
|
}
|
|
}
|
|
|
|
function isAuthFailureText(text: string): boolean {
|
|
return /\b(401|unauthorized|not logged in|authentication failed|no token|invalid token|no logins?)\b/i.test(
|
|
text,
|
|
);
|
|
}
|
|
|
|
function classifyGiteaTimelineErrorKind(stderr: string): PullRequestTimelineErrorKind {
|
|
const normalized = stderr.toLowerCase();
|
|
if (normalized.includes("404") || normalized.includes("not found")) {
|
|
return "not_found";
|
|
}
|
|
if (normalized.includes("403") || normalized.includes("forbidden") || isAuthFailureText(stderr)) {
|
|
return "forbidden";
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
/**
|
|
* Re-throw only tea auth / CLI-missing failures, letting every other error be
|
|
* swallowed by a best-effort caller. Background status polling wraps its checks
|
|
* load in its own catch, so this stays invisible there; on the explicit,
|
|
* user-triggered check-details path it lets an expired token surface the real
|
|
* remediation instead of a misleading "check not found".
|
|
*/
|
|
function rethrowTeaAuthFailure(error: unknown): void {
|
|
if (error instanceof TeaAuthenticationError || error instanceof TeaCliMissingError) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function mapGiteaTimelineError(error: unknown): PullRequestTimelineError {
|
|
if (error instanceof TeaAuthenticationError || error instanceof TeaCliMissingError) {
|
|
return { kind: "forbidden", message: error.message };
|
|
}
|
|
if (error instanceof TeaCommandError) {
|
|
return {
|
|
kind: classifyGiteaTimelineErrorKind(error.stderr),
|
|
message: error.stderr || error.message,
|
|
};
|
|
}
|
|
return { kind: "unknown", message: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
|
|
function extractPullRequestUrl(stdout: string): string | null {
|
|
const match = stdout.match(/https?:\/\/\S+\/pulls\/\d+/);
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
function parseIndexFromUrl(url: string): number | null {
|
|
const match = url.match(/\/pulls\/(\d+)/);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
// Flags whose values carry user/generated content (a PR title or body may
|
|
// contain a secret). The value that follows any of these in argv is replaced
|
|
// before argv is embedded in a client-facing error.
|
|
const TEA_SENSITIVE_FLAGS = new Set(["--title", "--description", "--body"]);
|
|
const TEA_REDACTED_VALUE = "<redacted>";
|
|
|
|
/**
|
|
* Redact the values of sensitive flags in a tea argv so a command failure that
|
|
* surfaces to the remote/mobile client never echoes a PR title or body. The
|
|
* real argv still runs; only the copy stored on the error is scrubbed.
|
|
*/
|
|
function redactTeaArgs(args: string[]): string[] {
|
|
return args.map((arg, index) =>
|
|
index > 0 && TEA_SENSITIVE_FLAGS.has(args[index - 1]) ? TEA_REDACTED_VALUE : arg,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Probe whether a host is a Gitea instance Paseo can talk to. tea has no
|
|
* per-repo auth check (it keeps per-instance logins), so a configured tea login
|
|
* for the host is the signal: it means tea both recognizes the host as Gitea and
|
|
* holds a usable token for it. Mirrors the role of `glab auth status` for GitLab.
|
|
*/
|
|
export async function probeGiteaHost(host: string): Promise<boolean> {
|
|
const teaPath = await findExecutable("tea");
|
|
if (!teaPath) {
|
|
return false;
|
|
}
|
|
try {
|
|
const { stdout } = await execCommand("tea", ["login", "list", "-o", "json"], {
|
|
envOverlay: TEA_ENV,
|
|
timeout: CLI_AUTH_PROBE_TIMEOUT_MS,
|
|
});
|
|
return hostHasLogin(stdout, host);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function findTeaLoginNameForHost(stdout: string, host: string): string | null {
|
|
let data: unknown;
|
|
try {
|
|
data = JSON.parse(stdout);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const parsed = z.array(GiteaLoginSchema).safeParse(data);
|
|
if (!parsed.success) {
|
|
return null;
|
|
}
|
|
const target = host.toLowerCase();
|
|
const match = parsed.data.find((login) => {
|
|
const candidates = [login.ssh_host, login.name];
|
|
if (login.url) {
|
|
try {
|
|
candidates.push(new URL(login.url).hostname);
|
|
} catch {
|
|
// ignore malformed login url
|
|
}
|
|
}
|
|
return candidates.some((candidate) => candidate?.toLowerCase() === target);
|
|
});
|
|
return match?.name ?? null;
|
|
}
|
|
|
|
function hostHasLogin(stdout: string, host: string): boolean {
|
|
return findTeaLoginNameForHost(stdout, host) !== null;
|
|
}
|
|
|
|
export type GiteaFamilySoftware = "gitea" | "forgejo";
|
|
|
|
/**
|
|
* The Forgejo REST namespace exists only on Forgejo; Gitea's router returns 404
|
|
* for it. We check it only through authenticated tea so the resolver never makes
|
|
* anonymous HTTP requests to a remote-derived host.
|
|
*/
|
|
const FORGEJO_VERSION_PATH = "/api/forgejo/v1/version";
|
|
const GITEA_SOFTWARE_PROBE_TIMEOUT_MS = 5_000;
|
|
|
|
type TeaApiRunner = (args: string[]) => Promise<{ stdout: string; stderr: string }>;
|
|
|
|
export interface DetectGiteaFamilyOptions {
|
|
/** Authenticated tier transport; defaults to spawning `tea`. */
|
|
runTea?: TeaApiRunner;
|
|
}
|
|
|
|
async function defaultRunTea(args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
const { stdout, stderr } = await execCommand("tea", args, {
|
|
envOverlay: TEA_ENV,
|
|
timeout: GITEA_SOFTWARE_PROBE_TIMEOUT_MS,
|
|
});
|
|
return { stdout, stderr };
|
|
}
|
|
|
|
const HTTP_STATUS_LINE = /^HTTP\/\d(?:\.\d)?\s+(\d{3})/m;
|
|
|
|
function parseHttpStatus(headerDump: string): number | null {
|
|
const match = headerDump.match(HTTP_STATUS_LINE);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
/**
|
|
* Probe the Forgejo namespace through an authenticated `tea` request. `tea api`
|
|
* exits 0 regardless of HTTP status, so the verdict is read from the status line
|
|
* `tea api -i` emits. Different tea builds print that line on stdout or stderr,
|
|
* so both streams are parsed. When `-i` is unsupported no status line appears
|
|
* (or the call errors) and the probe is inconclusive (null), leaving the Gitea
|
|
* default in place — the branch is always non-fatal.
|
|
*
|
|
* Not verified against a live self-hosted Forgejo instance; QA follow-up.
|
|
*/
|
|
async function probeForgejoNamespaceByTea(
|
|
host: string,
|
|
runTea: TeaApiRunner,
|
|
): Promise<boolean | null> {
|
|
let loginName: string | null;
|
|
try {
|
|
const { stdout } = await runTea(["login", "list", "-o", "json"]);
|
|
loginName = findTeaLoginNameForHost(stdout, host);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!loginName) {
|
|
return null;
|
|
}
|
|
try {
|
|
const { stdout, stderr } = await runTea([
|
|
"api",
|
|
"--login",
|
|
loginName,
|
|
"-i",
|
|
FORGEJO_VERSION_PATH,
|
|
]);
|
|
const status = parseHttpStatus(stderr) ?? parseHttpStatus(stdout);
|
|
if (status === 404) {
|
|
return false;
|
|
}
|
|
if (status !== null && status >= 200 && status < 300) {
|
|
return true;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decide whether a Gitea-family host runs Forgejo or Gitea through authenticated
|
|
* tea only. Inconclusive detection keeps the historical Gitea default.
|
|
*/
|
|
export async function detectGiteaFamilySoftware(
|
|
host: string,
|
|
options: DetectGiteaFamilyOptions = {},
|
|
): Promise<GiteaFamilySoftware> {
|
|
const runTea = options.runTea ?? defaultRunTea;
|
|
|
|
const viaTea = await probeForgejoNamespaceByTea(host, runTea);
|
|
if (viaTea !== null) {
|
|
return viaTea ? "forgejo" : "gitea";
|
|
}
|
|
return "gitea";
|
|
}
|
|
|
|
const inFlightFamilyProbes = new Map<string, Promise<GiteaFamilySoftware | null>>();
|
|
|
|
/**
|
|
* Resolve which Gitea-family forge id a host maps to for the open registry:
|
|
* null when there is no usable `tea` login (Paseo cannot operate the host),
|
|
* otherwise the detected software. Concurrent calls for the same host — the
|
|
* gitea and forgejo registrations probing in parallel — share one probe so
|
|
* detection runs once.
|
|
*/
|
|
export function resolveGiteaFamilyForge(host: string): Promise<GiteaFamilySoftware | null> {
|
|
const existing = inFlightFamilyProbes.get(host);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const pending = (async () => {
|
|
if (!(await probeGiteaHost(host))) {
|
|
return null;
|
|
}
|
|
return detectGiteaFamilySoftware(host);
|
|
})().finally(() => {
|
|
inFlightFamilyProbes.delete(host);
|
|
});
|
|
inFlightFamilyProbes.set(host, pending);
|
|
return pending;
|
|
}
|
|
|
|
function isTeaSearchAuthFailure(reason: unknown): boolean {
|
|
return reason instanceof TeaCliMissingError || reason instanceof TeaAuthenticationError;
|
|
}
|
|
|
|
function getTeaUnavailableSearchAuthState(
|
|
results: PromiseSettledResult<unknown>[],
|
|
): "cli_missing" | "unauthenticated" | null {
|
|
if (
|
|
results.length === 0 ||
|
|
!results.every(
|
|
(result) => result.status === "rejected" && isTeaSearchAuthFailure(result.reason),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
return results.some(
|
|
(result) => result.status === "rejected" && result.reason instanceof TeaCliMissingError,
|
|
)
|
|
? "cli_missing"
|
|
: "unauthenticated";
|
|
}
|
|
|
|
function throwFirstNonTeaAuthSearchRejection(results: PromiseSettledResult<unknown>[]): void {
|
|
const failed = results.find(
|
|
(result): result is PromiseRejectedResult =>
|
|
result.status === "rejected" && !isTeaSearchAuthFailure(result.reason),
|
|
);
|
|
if (failed) {
|
|
throw failed.reason;
|
|
}
|
|
}
|
|
|
|
export function createGiteaService(options: CreateGiteaServiceOptions = {}): ForgeService {
|
|
const runner = options.runner ?? runTeaCommand;
|
|
const resolveTea = createCachedCliPathResolver(options.resolveTeaPath ?? resolveTeaPath);
|
|
const resolveRemoteUrl = options.resolveRemoteUrl ?? defaultResolveRemoteUrl;
|
|
const resolveCurrentBranch = options.resolveCurrentBranch ?? defaultResolveCurrentBranch;
|
|
const now = options.now ?? Date.now;
|
|
const currentPrHeadShaCache = new Map<string, { sha: string; expiresAt: number }>();
|
|
|
|
async function run(args: string[], runOptions: TeaCommandRunnerOptions): Promise<string> {
|
|
const teaPath = await resolveTea();
|
|
if (!teaPath) {
|
|
throw new TeaCliMissingError();
|
|
}
|
|
try {
|
|
const result = await runner(args, runOptions);
|
|
return result.stdout.trim();
|
|
} catch (error) {
|
|
throw teaCliRunner.normalizeError(error, { args: redactTeaArgs(args), cwd: runOptions.cwd });
|
|
}
|
|
}
|
|
|
|
function runJsonParse<T>(
|
|
args: string[],
|
|
runOptions: TeaCommandRunnerOptions,
|
|
stdout: string,
|
|
schema: z.ZodType<T>,
|
|
): T {
|
|
return parseCliJsonOutput({
|
|
commandName: "tea",
|
|
args,
|
|
cwd: runOptions.cwd,
|
|
stdout,
|
|
schema,
|
|
createCommandError: (params) => new TeaCommandError(params),
|
|
});
|
|
}
|
|
|
|
async function runJsonArray<T>(
|
|
args: string[],
|
|
runOptions: TeaCommandRunnerOptions,
|
|
itemSchema: z.ZodType<T>,
|
|
): Promise<T[]> {
|
|
const stdout = await run(args, runOptions);
|
|
if (!stdout) {
|
|
return [];
|
|
}
|
|
return runJsonParse(args, runOptions, stdout, z.array(itemSchema));
|
|
}
|
|
|
|
async function runJson<T>(
|
|
args: string[],
|
|
runOptions: TeaCommandRunnerOptions,
|
|
schema: z.ZodType<T>,
|
|
): Promise<T> {
|
|
const stdout = await run(args, runOptions);
|
|
return runJsonParse(args, runOptions, stdout, schema);
|
|
}
|
|
|
|
async function resolveForkCheckoutFacts(
|
|
cwd: string,
|
|
summary: PullRequestSummary,
|
|
): Promise<{
|
|
isCrossRepository: boolean;
|
|
headOwnerLogin: string | null;
|
|
headRepositorySshUrl: string | null;
|
|
headRepositoryUrl: string | null;
|
|
}> {
|
|
const sameRepo = {
|
|
isCrossRepository: false,
|
|
headOwnerLogin: null,
|
|
headRepositorySshUrl: null,
|
|
headRepositoryUrl: null,
|
|
};
|
|
const { owner, name } = parseGiteaRepoFromUrl(summary.url);
|
|
if (!owner || !name) {
|
|
return sameRepo;
|
|
}
|
|
const pr = await runJson(
|
|
[
|
|
"api",
|
|
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${summary.number}`,
|
|
],
|
|
{ cwd },
|
|
GiteaPullRequestApiSchema,
|
|
);
|
|
const headRepo = pr.head?.repo ?? null;
|
|
const baseRepo = pr.base?.repo ?? null;
|
|
if (headRepo?.id == null || baseRepo?.id == null || headRepo.id === baseRepo.id) {
|
|
return sameRepo;
|
|
}
|
|
return {
|
|
isCrossRepository: true,
|
|
headOwnerLogin: headRepo.owner?.login ?? null,
|
|
headRepositorySshUrl: headRepo.ssh_url ?? null,
|
|
headRepositoryUrl: headRepo.html_url ?? null,
|
|
};
|
|
}
|
|
|
|
async function listPullRequestItems(input: {
|
|
cwd: string;
|
|
state: "open" | "closed" | "all";
|
|
query?: string;
|
|
limit?: number;
|
|
page?: number;
|
|
}): Promise<GiteaPrListItem[]> {
|
|
const args = ["pr", "list", "--fields", PR_LIST_FIELDS, "--state", input.state, "-o", "json"];
|
|
if (typeof input.limit === "number") {
|
|
args.push("--limit", String(input.limit));
|
|
}
|
|
if (typeof input.page === "number") {
|
|
args.push("--page", String(input.page));
|
|
}
|
|
const items = await runJsonArray(args, { cwd: input.cwd }, GiteaPrListItemSchema);
|
|
const query = input.query?.trim().toLowerCase();
|
|
if (!query) {
|
|
return items;
|
|
}
|
|
return items.filter((item) => item.title.toLowerCase().includes(query));
|
|
}
|
|
|
|
async function runIssueList(input: {
|
|
cwd: string;
|
|
query?: string;
|
|
limit?: number;
|
|
}): Promise<IssueSummary[]> {
|
|
const args = ["issue", "list", "--fields", ISSUE_LIST_FIELDS, "--state", "open", "-o", "json"];
|
|
if (typeof input.limit === "number") {
|
|
args.push("--limit", String(input.limit));
|
|
}
|
|
const items = await runJsonArray(args, { cwd: input.cwd }, GiteaIssueListItemSchema);
|
|
const query = input.query?.trim().toLowerCase();
|
|
const filtered = query
|
|
? items.filter((item) => item.title.toLowerCase().includes(query))
|
|
: items;
|
|
return filtered.map(toIssueSummary);
|
|
}
|
|
|
|
async function listPullRequestReviewComments(input: {
|
|
cwd: string;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
prNumber: number;
|
|
reviewId: number;
|
|
}): Promise<GiteaTimelineBucket<GiteaReviewComment>> {
|
|
return listTimelineBucketPages(
|
|
input,
|
|
`pulls/${input.prNumber}/reviews/${input.reviewId}/comments`,
|
|
GiteaReviewCommentSchema,
|
|
);
|
|
}
|
|
|
|
async function listTimelineBucketPages<T>(
|
|
input: GetPullRequestTimelineOptions,
|
|
suffix: string,
|
|
itemSchema: z.ZodType<T>,
|
|
): Promise<GiteaTimelineBucket<T>> {
|
|
const items: T[] = [];
|
|
for (let page = 1; page <= TIMELINE_MAX_PAGES_PER_BUCKET; page += 1) {
|
|
const pageItems = await runJsonArray(
|
|
["api", timelineApiPath(input, `${suffix}?${timelinePageQuery(page)}`)],
|
|
{ cwd: input.cwd },
|
|
itemSchema,
|
|
);
|
|
items.push(...pageItems);
|
|
if (pageItems.length < TIMELINE_PAGE_SIZE) {
|
|
return { items, truncated: false };
|
|
}
|
|
}
|
|
return { items, truncated: true };
|
|
}
|
|
|
|
async function loadCurrentPullRequestStatus(input: {
|
|
cwd: string;
|
|
headRef: string;
|
|
headSha?: string;
|
|
}): Promise<CurrentPullRequestStatus | null> {
|
|
const match = await findCurrentPullRequestForHeadRef(input);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const status = toCurrentPullRequestStatus(match);
|
|
return loadCurrentPullRequestChecks(input.cwd, match, status);
|
|
}
|
|
|
|
async function resolveCurrentRepoIdentity(
|
|
cwd: string,
|
|
): Promise<{ owner: string; name: string } | null> {
|
|
const remoteUrl = await resolveRemoteUrl(cwd);
|
|
if (!remoteUrl) {
|
|
return null;
|
|
}
|
|
const location = parseGitRemoteLocation(remoteUrl);
|
|
if (!location) {
|
|
return null;
|
|
}
|
|
const identity = parseGitHubRemoteIdentity(location.path);
|
|
return identity ? { owner: identity.owner, name: identity.name } : null;
|
|
}
|
|
|
|
async function findCurrentPullRequestForHeadRef(input: {
|
|
cwd: string;
|
|
headRef: string;
|
|
headSha?: string;
|
|
}): Promise<GiteaPrListItem | null> {
|
|
const repoIdentity = await resolveCurrentRepoIdentity(input.cwd);
|
|
const expectedHeadOwner = repoIdentity?.owner ?? null;
|
|
const openMatch = await findOpenPullRequestForHeadRef(input, expectedHeadOwner);
|
|
if (openMatch) {
|
|
return openMatch;
|
|
}
|
|
|
|
// tea discovers repository context from any configured git remote, while
|
|
// the default remote resolver reads origin only. Preserve that broader
|
|
// discovery for repositories whose primary remote has another name.
|
|
const repoPath = repoIdentity
|
|
? `repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.name)}`
|
|
: "repos/{owner}/{repo}";
|
|
const recentItems = await runJsonArray(
|
|
[
|
|
"api",
|
|
`${repoPath}/pulls?state=all&sort=recentupdate&page=1&limit=${CURRENT_PR_LOOKUP_PAGE_SIZE}`,
|
|
],
|
|
{ cwd: input.cwd },
|
|
GiteaCurrentPullRequestApiSchema,
|
|
);
|
|
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(
|
|
(item) =>
|
|
mapGiteaState(item.state) !== "open" &&
|
|
input.headSha !== undefined &&
|
|
item.head.sha === input.headSha,
|
|
) ??
|
|
null;
|
|
return match ? currentPullRequestApiToListItem(match) : null;
|
|
}
|
|
|
|
async function findOpenPullRequestForHeadRef(
|
|
input: {
|
|
cwd: string;
|
|
headRef: string;
|
|
headSha?: string;
|
|
},
|
|
expectedHeadOwner: string | null,
|
|
): Promise<GiteaPrListItem | null> {
|
|
for (let page = 1; page <= CURRENT_PR_OPEN_LOOKUP_MAX_PAGES; page += 1) {
|
|
const items = await listPullRequestItems({
|
|
cwd: input.cwd,
|
|
state: "open",
|
|
limit: CURRENT_PR_LOOKUP_PAGE_SIZE,
|
|
page,
|
|
});
|
|
const match = items.find((item) =>
|
|
matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner),
|
|
);
|
|
if (match) {
|
|
return match;
|
|
}
|
|
if (items.length < CURRENT_PR_LOOKUP_PAGE_SIZE) {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function loadCurrentPullRequestChecks(
|
|
cwd: string,
|
|
item: GiteaPrListItem,
|
|
status: CurrentPullRequestStatus,
|
|
): Promise<CurrentPullRequestStatus> {
|
|
const number = parseGiteaInt(item.index);
|
|
if (number === null || !status.repoOwner || !status.repoName) {
|
|
return status;
|
|
}
|
|
try {
|
|
const pr = await runJson(
|
|
["pr", String(number), "-o", "json"],
|
|
{ cwd },
|
|
GiteaPullRequestViewSchema,
|
|
);
|
|
if (!pr.headSha) {
|
|
return status;
|
|
}
|
|
const checksInput = {
|
|
cwd,
|
|
repoOwner: status.repoOwner,
|
|
repoName: status.repoName,
|
|
sha: pr.headSha,
|
|
};
|
|
const [combined, actionTasks] = await Promise.all([
|
|
loadCombinedCommitStatusBestEffort(checksInput),
|
|
loadActionTasksBestEffort(checksInput),
|
|
]);
|
|
const actionRequiredRunIds = await loadActionRequiredRunIdsBestEffort(
|
|
checksInput,
|
|
combined,
|
|
actionTasks,
|
|
);
|
|
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
|
|
} catch {
|
|
return status;
|
|
}
|
|
}
|
|
|
|
async function loadCombinedCommitStatusBestEffort(input: {
|
|
cwd: string;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
sha: string;
|
|
}): Promise<GiteaCombinedCommitStatus> {
|
|
try {
|
|
return await loadCombinedCommitStatus(input);
|
|
} catch (error) {
|
|
rethrowTeaAuthFailure(error);
|
|
return { state: "", statuses: [], total_count: 0 };
|
|
}
|
|
}
|
|
|
|
async function loadCombinedCommitStatus(input: {
|
|
cwd: string;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
sha: string;
|
|
}): Promise<GiteaCombinedCommitStatus> {
|
|
const owner = encodeURIComponent(input.repoOwner);
|
|
const repo = encodeURIComponent(input.repoName);
|
|
const sha = encodeURIComponent(input.sha);
|
|
return runJson(
|
|
["api", `repos/${owner}/${repo}/commits/${sha}/status`],
|
|
{ cwd: input.cwd },
|
|
GiteaCombinedCommitStatusSchema,
|
|
);
|
|
}
|
|
|
|
// 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<GiteaActionTask[]> {
|
|
try {
|
|
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 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;
|
|
}): Promise<GiteaActionTask[]> {
|
|
return parseGiteaActionTasks(await loadGiteaActionPages(input, "tasks"));
|
|
}
|
|
|
|
async function resolveCurrentPullRequestHeadSha(cwd: string): Promise<string> {
|
|
const headRef = await resolveCurrentBranch(cwd);
|
|
if (!headRef) {
|
|
throw new Error("Gitea check details require a current branch");
|
|
}
|
|
const cacheKey = `${cwd}:branch:${headRef}`;
|
|
const cached = currentPrHeadShaCache.get(cacheKey);
|
|
const nowMs = now();
|
|
if (cached && cached.expiresAt > nowMs) {
|
|
return cached.sha;
|
|
}
|
|
const match = await findCurrentPullRequestForHeadRef({ cwd, headRef });
|
|
const number = match ? parseGiteaInt(match.index) : null;
|
|
if (!match || number === null) {
|
|
throw new Error(`Gitea pull request for branch ${headRef} was not found`);
|
|
}
|
|
const pr = await runJson(
|
|
["pr", String(number), "-o", "json"],
|
|
{ cwd },
|
|
GiteaPullRequestViewSchema,
|
|
);
|
|
if (!pr.headSha) {
|
|
throw new Error(`Gitea pull request #${number} did not include a head SHA`);
|
|
}
|
|
currentPrHeadShaCache.set(cacheKey, {
|
|
sha: pr.headSha,
|
|
expiresAt: nowMs + CURRENT_PR_HEAD_SHA_CACHE_TTL_MS,
|
|
});
|
|
return pr.headSha;
|
|
}
|
|
|
|
// Resolve the head SHA of a specific PR by its number. The current branch's
|
|
// head can move (a push right after a run was listed), so a user-triggered
|
|
// check-details request for a known PR must resolve against that PR's head,
|
|
// not the branch's, or the requested run no longer maps to the SHA.
|
|
async function resolvePullRequestHeadShaByNumber(cwd: string, number: number): Promise<string> {
|
|
const cacheKey = `${cwd}:pr:${number}`;
|
|
const cached = currentPrHeadShaCache.get(cacheKey);
|
|
const nowMs = now();
|
|
if (cached && cached.expiresAt > nowMs) {
|
|
return cached.sha;
|
|
}
|
|
const pr = await runJson(
|
|
["pr", String(number), "-o", "json"],
|
|
{ cwd },
|
|
GiteaPullRequestViewSchema,
|
|
);
|
|
if (!pr.headSha) {
|
|
throw new Error(`Gitea pull request #${number} did not include a head SHA`);
|
|
}
|
|
currentPrHeadShaCache.set(cacheKey, {
|
|
sha: pr.headSha,
|
|
expiresAt: nowMs + CURRENT_PR_HEAD_SHA_CACHE_TTL_MS,
|
|
});
|
|
return pr.headSha;
|
|
}
|
|
|
|
function toGiteaCheckDetails(status: GiteaCommitStatus): CheckDetails {
|
|
return {
|
|
checkRunId: status.id,
|
|
name: status.context || `status-${status.id}`,
|
|
status: status.status,
|
|
conclusion: mapGiteaCommitState(status.status),
|
|
url: status.target_url ?? null,
|
|
detailsUrl: status.url ?? null,
|
|
output:
|
|
status.description != null
|
|
? {
|
|
title: status.context || null,
|
|
summary: status.description,
|
|
text: null,
|
|
}
|
|
: null,
|
|
annotations: [],
|
|
failedJobs: [],
|
|
truncated: false,
|
|
};
|
|
}
|
|
|
|
function toGiteaActionTaskDetails(task: GiteaActionTask): CheckDetails {
|
|
const conclusion = mapGiteaActionTaskStatus(task.status);
|
|
return {
|
|
checkRunId: task.id,
|
|
workflowRunId: task.id,
|
|
name: getGiteaActionTaskName(task),
|
|
status: task.status,
|
|
conclusion,
|
|
url: task.url ?? null,
|
|
detailsUrl: task.url ?? null,
|
|
output: {
|
|
title: task.display_title ?? task.name ?? task.workflow_id ?? null,
|
|
summary: task.workflow_id ?? null,
|
|
text: null,
|
|
},
|
|
annotations: [],
|
|
failedJobs: [],
|
|
truncated: false,
|
|
};
|
|
}
|
|
|
|
function notSupported(method: string): never {
|
|
throw new Error(`${method} is not supported on Gitea yet`);
|
|
}
|
|
|
|
return {
|
|
async isAuthenticated(input: { cwd: string } & ForgeReadOptions): Promise<boolean> {
|
|
const teaPath = await resolveTea();
|
|
if (!teaPath) {
|
|
return false;
|
|
}
|
|
const remoteUrl = await resolveRemoteUrl(input.cwd);
|
|
const host = remoteUrl ? parseGiteaHostFromRemoteUrl(remoteUrl) : null;
|
|
if (!host) {
|
|
return false;
|
|
}
|
|
try {
|
|
const stdout = await run(["login", "list", "-o", "json"], { cwd: input.cwd });
|
|
return hostHasLogin(stdout, host);
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
|
|
return loadCurrentPullRequestStatus({
|
|
cwd: input.cwd,
|
|
headRef: input.headRef,
|
|
headSha: input.headSha,
|
|
});
|
|
},
|
|
|
|
async getPullRequest(input: GetPullRequestOptions): Promise<PullRequestSummary> {
|
|
// Fetch the single PR by number (like gh `pr view <n>` / glab MR view) so
|
|
// PRs outside the recent-list window are reachable; tea errors surface as
|
|
// a not-found rather than silently missing.
|
|
const view = await runJson(
|
|
["pr", String(input.number), "-o", "json"],
|
|
{ cwd: input.cwd },
|
|
GiteaPullRequestViewSchema,
|
|
);
|
|
return viewToPullRequestSummary(view);
|
|
},
|
|
|
|
async getPullRequestHeadRef(input: GetPullRequestOptions): Promise<string> {
|
|
const summary = await this.getPullRequest(input);
|
|
return summary.headRefName;
|
|
},
|
|
|
|
async getPullRequestCheckoutTarget(
|
|
input: GetPullRequestOptions,
|
|
): Promise<PullRequestCheckoutTarget> {
|
|
const summary = await this.getPullRequest(input);
|
|
const checkoutRefs = [
|
|
{ remoteName: "origin", remoteRef: `refs/pull/${summary.number}/head` },
|
|
];
|
|
if (summary.headRefName) {
|
|
checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` });
|
|
}
|
|
const fork = await resolveForkCheckoutFacts(input.cwd, summary);
|
|
return {
|
|
number: summary.number,
|
|
baseRefName: summary.baseRefName,
|
|
headRefName: summary.headRefName,
|
|
checkoutRefs,
|
|
headOwnerLogin: fork.headOwnerLogin,
|
|
headRepositorySshUrl: fork.headRepositorySshUrl,
|
|
headRepositoryUrl: fork.headRepositoryUrl,
|
|
isCrossRepository: fork.isCrossRepository,
|
|
};
|
|
},
|
|
|
|
listPullRequests(input: ListPullRequestsOptions): Promise<PullRequestSummary[]> {
|
|
return listPullRequestItems({
|
|
cwd: input.cwd,
|
|
state: "open",
|
|
query: input.query,
|
|
limit: input.limit,
|
|
}).then((items) => items.map(toPullRequestSummary));
|
|
},
|
|
|
|
listIssues(input: ListIssuesOptions): Promise<IssueSummary[]> {
|
|
return runIssueList(input);
|
|
},
|
|
|
|
async createPullRequest(input: CreatePullRequestOptions): Promise<PullRequestCreateResult> {
|
|
const args = [
|
|
"pr",
|
|
"create",
|
|
"--title",
|
|
input.title,
|
|
"--description",
|
|
input.body ?? "",
|
|
"--head",
|
|
input.head,
|
|
"--base",
|
|
input.base,
|
|
];
|
|
const stdout = await run(args, { cwd: input.cwd });
|
|
const url = extractPullRequestUrl(stdout);
|
|
if (!url) {
|
|
throw new TeaCommandError({
|
|
args: redactTeaArgs(args),
|
|
cwd: input.cwd,
|
|
exitCode: null,
|
|
stderr: "tea reported a created pull request but returned no URL",
|
|
});
|
|
}
|
|
const number = parseIndexFromUrl(url);
|
|
if (number === null) {
|
|
throw new TeaCommandError({
|
|
args: redactTeaArgs(args),
|
|
cwd: input.cwd,
|
|
exitCode: null,
|
|
stderr: `tea returned a pull request URL without an index: ${url}`,
|
|
});
|
|
}
|
|
return { url, number };
|
|
},
|
|
|
|
async mergePullRequest(input: MergePullRequestOptions): Promise<PullRequestMergeResult> {
|
|
assertGiteaDirectMergeReady(input);
|
|
const args = ["pr", "merge", String(input.prNumber), "--style", input.mergeMethod];
|
|
await run(args, { cwd: input.cwd });
|
|
return { success: true };
|
|
},
|
|
|
|
async getPullRequestTimeline(
|
|
input: GetPullRequestTimelineOptions,
|
|
): Promise<PullRequestTimeline> {
|
|
const identity = {
|
|
prNumber: input.prNumber,
|
|
repoOwner: input.repoOwner,
|
|
repoName: input.repoName,
|
|
};
|
|
try {
|
|
const pr = await runJson(
|
|
["pr", String(input.prNumber), "-o", "json"],
|
|
{ cwd: input.cwd },
|
|
GiteaPullRequestViewSchema,
|
|
);
|
|
const [commentsResult, reviewsResult] = await Promise.allSettled([
|
|
listTimelineBucketPages(
|
|
input,
|
|
`issues/${input.prNumber}/comments`,
|
|
GiteaIssueCommentSchema,
|
|
),
|
|
listTimelineBucketPages(input, `pulls/${input.prNumber}/reviews`, GiteaReviewSchema),
|
|
]);
|
|
if (commentsResult.status === "rejected" && reviewsResult.status === "rejected") {
|
|
return {
|
|
...identity,
|
|
items: [],
|
|
truncated: false,
|
|
error: mapGiteaTimelineError(commentsResult.reason),
|
|
};
|
|
}
|
|
const commentsBucket =
|
|
commentsResult.status === "fulfilled"
|
|
? commentsResult.value
|
|
: { items: [], truncated: false };
|
|
const reviewsBucket =
|
|
reviewsResult.status === "fulfilled"
|
|
? reviewsResult.value
|
|
: { items: [], truncated: false };
|
|
const comments = commentsBucket.items;
|
|
const reviews = reviewsBucket.items;
|
|
const reviewsWithComments = reviews.filter((review) => review.comments_count > 0);
|
|
const reviewCommentResults = await Promise.allSettled(
|
|
reviewsWithComments.map((review) =>
|
|
reviewCommentsLimit(() =>
|
|
listPullRequestReviewComments({
|
|
cwd: input.cwd,
|
|
repoOwner: input.repoOwner,
|
|
repoName: input.repoName,
|
|
prNumber: input.prNumber,
|
|
reviewId: review.id,
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
const reviewCommentGroups = reviewCommentResults.flatMap((result, index) =>
|
|
result.status === "fulfilled"
|
|
? [
|
|
{
|
|
reviewId: reviewsWithComments[index].id,
|
|
comments: result.value.items,
|
|
truncated: result.value.truncated,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const reviewComments = reviewCommentGroups.flatMap((group) => group.comments);
|
|
const items = [
|
|
...comments.map((comment) => toGiteaTimelineComment(comment, pr)),
|
|
...reviews.map(toGiteaTimelineReview),
|
|
...reviewComments.map(toGiteaTimelineReviewComment),
|
|
]
|
|
.filter((item): item is PullRequestTimelineItem => item !== null)
|
|
.sort(compareTimelineItems);
|
|
const truncated =
|
|
commentsBucket.truncated ||
|
|
reviewsBucket.truncated ||
|
|
reviewCommentGroups.some((group) => group.truncated);
|
|
return {
|
|
...identity,
|
|
items,
|
|
truncated,
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return { ...identity, items: [], truncated: false, error: mapGiteaTimelineError(error) };
|
|
}
|
|
},
|
|
|
|
async getCheckDetails(input: GetCheckDetailsOptions): Promise<CheckDetails> {
|
|
if (!input.repoOwner || !input.repoName) {
|
|
throw new Error("Gitea getCheckDetails requires repoOwner and repoName");
|
|
}
|
|
if (input.checkRunId === undefined && input.workflowRunId === undefined) {
|
|
throw new Error("Gitea getCheckDetails requires a checkRunId or workflowRunId");
|
|
}
|
|
const sha =
|
|
input.changeRequestNumber !== undefined
|
|
? await resolvePullRequestHeadShaByNumber(input.cwd, input.changeRequestNumber)
|
|
: await resolveCurrentPullRequestHeadSha(input.cwd);
|
|
const combined = await loadCombinedCommitStatusBestEffort({
|
|
cwd: input.cwd,
|
|
repoOwner: input.repoOwner,
|
|
repoName: input.repoName,
|
|
sha,
|
|
});
|
|
const status = combined.statuses.find((entry) => entry.id === input.checkRunId);
|
|
if (status) {
|
|
return toGiteaCheckDetails(status);
|
|
}
|
|
const tasks = await loadActionTasksBestEffort({
|
|
cwd: input.cwd,
|
|
repoOwner: input.repoOwner,
|
|
repoName: input.repoName,
|
|
sha,
|
|
});
|
|
const workflowRunId = input.workflowRunId ?? input.checkRunId;
|
|
const task = tasks.find((entry) => entry.id === workflowRunId);
|
|
if (!task) {
|
|
throw new Error(`Gitea check ${workflowRunId} was not found`);
|
|
}
|
|
return toGiteaActionTaskDetails(task);
|
|
},
|
|
|
|
async searchIssuesAndPrs(input: SearchIssuesAndPrsOptions): Promise<SearchResult> {
|
|
if (input.force && !input.reason) {
|
|
throw new Error("ForgeService forced read requires a reason");
|
|
}
|
|
|
|
const kinds = normalizeForgeSearchKinds(input.kinds);
|
|
const shouldFetchIssues = kinds.includes("issue");
|
|
const shouldFetchMergeRequests = kinds.includes("change_request");
|
|
const [issuesResult, mergeRequestsResult] = await Promise.allSettled([
|
|
shouldFetchIssues
|
|
? runIssueList({ cwd: input.cwd, query: input.query, limit: input.limit })
|
|
: Promise.resolve(null),
|
|
shouldFetchMergeRequests
|
|
? listPullRequestItems({
|
|
cwd: input.cwd,
|
|
state: "open",
|
|
query: input.query,
|
|
limit: input.limit,
|
|
}).then((items) => items.map(toPullRequestSummary))
|
|
: Promise.resolve(null),
|
|
]);
|
|
|
|
const requestedResults = [
|
|
shouldFetchIssues ? issuesResult : null,
|
|
shouldFetchMergeRequests ? mergeRequestsResult : null,
|
|
].filter((result) => result !== null);
|
|
const unavailableAuthState = getTeaUnavailableSearchAuthState(requestedResults);
|
|
if (unavailableAuthState) {
|
|
return createUnavailableSearchResult(unavailableAuthState);
|
|
}
|
|
throwFirstNonTeaAuthSearchRejection(requestedResults);
|
|
|
|
const items: SearchResult["items"] = [];
|
|
if (shouldFetchIssues && issuesResult.status === "fulfilled") {
|
|
for (const issue of issuesResult.value ?? []) {
|
|
items.push({
|
|
kind: "issue",
|
|
number: issue.number,
|
|
title: issue.title,
|
|
url: issue.url,
|
|
state: issue.state,
|
|
body: issue.body,
|
|
labels: issue.labels,
|
|
...(issue.projectPath ? { projectPath: issue.projectPath } : {}),
|
|
baseRefName: null,
|
|
headRefName: null,
|
|
updatedAt: issue.updatedAt,
|
|
});
|
|
}
|
|
}
|
|
if (shouldFetchMergeRequests && mergeRequestsResult.status === "fulfilled") {
|
|
for (const pullRequest of mergeRequestsResult.value ?? []) {
|
|
items.push({
|
|
kind: "change_request",
|
|
number: pullRequest.number,
|
|
title: pullRequest.title,
|
|
url: pullRequest.url,
|
|
state: pullRequest.state,
|
|
body: pullRequest.body,
|
|
labels: pullRequest.labels,
|
|
...(pullRequest.projectPath ? { projectPath: pullRequest.projectPath } : {}),
|
|
baseRefName: pullRequest.baseRefName,
|
|
headRefName: pullRequest.headRefName,
|
|
updatedAt: pullRequest.updatedAt,
|
|
});
|
|
}
|
|
}
|
|
items.sort(
|
|
(left, right) => parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt),
|
|
);
|
|
|
|
return {
|
|
items,
|
|
featuresEnabled: true,
|
|
authState: "authenticated",
|
|
githubFeaturesEnabled: true,
|
|
};
|
|
},
|
|
|
|
enablePullRequestAutoMerge(_input: EnablePullRequestAutoMergeOptions): never {
|
|
return notSupported("enablePullRequestAutoMerge");
|
|
},
|
|
|
|
disablePullRequestAutoMerge(_input: DisablePullRequestAutoMergeOptions): never {
|
|
return notSupported("disablePullRequestAutoMerge");
|
|
},
|
|
|
|
invalidate(input: { cwd: string }): void {
|
|
const prefix = `${input.cwd}:`;
|
|
for (const key of currentPrHeadShaCache.keys()) {
|
|
if (key.startsWith(prefix)) {
|
|
currentPrHeadShaCache.delete(key);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|