2072 lines
67 KiB
TypeScript
2072 lines
67 KiB
TypeScript
import { z } from "zod";
|
|
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_ACTIONS_RUNS_PAGE_LIMIT = 50;
|
|
const GITEA_ACTIONS_RUNS_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(""),
|
|
})
|
|
.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 GiteaActionsRunSchema = 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(),
|
|
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-run rather than as `z.array(GiteaActionsRunSchema)`
|
|
// so one malformed run (missing id/status/head_sha) drops only itself instead of
|
|
// throwing out the whole page and zeroing every Actions check for the PR.
|
|
const GiteaActionsRunsSchema = z
|
|
.object({
|
|
workflow_runs: z.array(z.unknown()).optional().default([]),
|
|
total_count: z.number().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 GiteaActionsRun = z.infer<typeof GiteaActionsRunSchema>;
|
|
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 mapGiteaCommitStatus(state: string): PullRequestCheck["status"] {
|
|
switch (state.toLowerCase()) {
|
|
case "success":
|
|
return "success";
|
|
case "failure":
|
|
case "error":
|
|
// See mapGiteaCiStatus: "warning" is terminal and non-passing (blocks merge).
|
|
case "warning":
|
|
return "failure";
|
|
case "pending":
|
|
return "pending";
|
|
default:
|
|
return "pending";
|
|
}
|
|
}
|
|
|
|
function mapGiteaActionsRunStatus(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): PullRequestCheck {
|
|
return {
|
|
name: status.context || `status-${status.id}`,
|
|
status: mapGiteaCommitStatus(status.status),
|
|
url: status.target_url ?? null,
|
|
checkRunId: status.id,
|
|
};
|
|
}
|
|
|
|
function getGiteaActionsRunName(workflowRun: GiteaActionsRun): string {
|
|
return (
|
|
workflowRun.name ||
|
|
workflowRun.display_title ||
|
|
workflowRun.workflow_id ||
|
|
`actions-${workflowRun.id}`
|
|
);
|
|
}
|
|
|
|
function toGiteaActionsPullRequestCheck(workflowRun: GiteaActionsRun): PullRequestCheck {
|
|
return {
|
|
name: getGiteaActionsRunName(workflowRun),
|
|
status: mapGiteaActionsRunStatus(workflowRun.status),
|
|
url: workflowRun.url ?? null,
|
|
workflowRunId: workflowRun.id,
|
|
};
|
|
}
|
|
|
|
function getGiteaActionsWorkflowIdentity(workflowRun: GiteaActionsRun): string {
|
|
return workflowRun.workflow_id || workflowRun.name || `actions-${workflowRun.id}`;
|
|
}
|
|
|
|
function parseGiteaActionsRunTime(workflowRun: GiteaActionsRun): number {
|
|
const timestamp = workflowRun.created_at ?? workflowRun.run_started_at ?? null;
|
|
if (!timestamp) {
|
|
return 0;
|
|
}
|
|
const time = Date.parse(timestamp);
|
|
return Number.isNaN(time) ? 0 : time;
|
|
}
|
|
|
|
function compareGiteaActionsRunRecency(left: GiteaActionsRun, right: GiteaActionsRun): number {
|
|
const leftRunNumber = left.run_number ?? 0;
|
|
const rightRunNumber = right.run_number ?? 0;
|
|
if (leftRunNumber !== rightRunNumber) {
|
|
return leftRunNumber - rightRunNumber;
|
|
}
|
|
|
|
const leftTime = parseGiteaActionsRunTime(left);
|
|
const rightTime = parseGiteaActionsRunTime(right);
|
|
if (leftTime !== rightTime) {
|
|
return leftTime - rightTime;
|
|
}
|
|
|
|
return left.id - right.id;
|
|
}
|
|
|
|
function parseGiteaActionsRuns(rawRuns: unknown[]): GiteaActionsRun[] {
|
|
const runs: GiteaActionsRun[] = [];
|
|
for (const raw of rawRuns) {
|
|
const parsed = GiteaActionsRunSchema.safeParse(raw);
|
|
if (parsed.success) {
|
|
runs.push(parsed.data);
|
|
}
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
function latestGiteaActionsRunsByWorkflow(actionsRuns: GiteaActionsRun[]): GiteaActionsRun[] {
|
|
const latestRuns = new Map<string, GiteaActionsRun>();
|
|
for (const workflowRun of actionsRuns) {
|
|
const identity = getGiteaActionsWorkflowIdentity(workflowRun);
|
|
const current = latestRuns.get(identity);
|
|
if (!current || compareGiteaActionsRunRecency(current, workflowRun) < 0) {
|
|
latestRuns.set(identity, workflowRun);
|
|
}
|
|
}
|
|
return [...latestRuns.values()];
|
|
}
|
|
|
|
function combineGiteaChecks(
|
|
commitStatuses: GiteaCommitStatus[],
|
|
actionsRuns: GiteaActionsRun[],
|
|
): PullRequestCheck[] {
|
|
const checks = commitStatuses.map(toGiteaPullRequestCheck);
|
|
const seen = new Set(
|
|
checks.map((check) => `${check.name}\u0000${check.url ?? ""}\u0000${check.status}`),
|
|
);
|
|
for (const workflowRun of actionsRuns) {
|
|
const check = toGiteaActionsPullRequestCheck(workflowRun);
|
|
const key = `${check.name}\u0000${check.url ?? ""}\u0000${check.status}`;
|
|
if (!seen.has(key)) {
|
|
checks.push(check);
|
|
seen.add(key);
|
|
}
|
|
}
|
|
return checks;
|
|
}
|
|
|
|
function applyGiteaChecks(
|
|
status: CurrentPullRequestStatus,
|
|
combined: GiteaCombinedCommitStatus,
|
|
actionsRuns: GiteaActionsRun[],
|
|
): CurrentPullRequestStatus {
|
|
const checks = combineGiteaChecks(combined.statuses, actionsRuns);
|
|
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) =>
|
|
matchesCurrentHeadRef(
|
|
currentPullRequestApiToListItem(item),
|
|
input.headRef,
|
|
expectedHeadOwner,
|
|
),
|
|
);
|
|
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 [combined, actionsRuns] = await Promise.all([
|
|
loadCombinedCommitStatusBestEffort({
|
|
cwd,
|
|
repoOwner: status.repoOwner,
|
|
repoName: status.repoName,
|
|
sha: pr.headSha,
|
|
}),
|
|
loadActionsRunsBestEffort({
|
|
cwd,
|
|
repoOwner: status.repoOwner,
|
|
repoName: status.repoName,
|
|
sha: pr.headSha,
|
|
}),
|
|
]);
|
|
return applyGiteaChecks(status, combined, actionsRuns);
|
|
} 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,
|
|
);
|
|
}
|
|
|
|
async function loadActionsRunsBestEffort(input: {
|
|
cwd: string;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
sha: string;
|
|
}): Promise<GiteaActionsRun[]> {
|
|
try {
|
|
const runs = await loadActionsRuns(input);
|
|
const matchingRuns = runs.filter((workflowRun) => workflowRun.head_sha === input.sha);
|
|
return latestGiteaActionsRunsByWorkflow(matchingRuns);
|
|
} catch (error) {
|
|
rethrowTeaAuthFailure(error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// 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 the target sha is found, a page comes back empty, or the bounded
|
|
// max-pages cap is hit.
|
|
async function loadActionsRuns(input: {
|
|
cwd: string;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
sha: string;
|
|
}): Promise<GiteaActionsRun[]> {
|
|
const owner = encodeURIComponent(input.repoOwner);
|
|
const repo = encodeURIComponent(input.repoName);
|
|
const runs: GiteaActionsRun[] = [];
|
|
let fetched = 0;
|
|
for (let page = 1; page <= GITEA_ACTIONS_RUNS_MAX_PAGES; page += 1) {
|
|
const batch = await runJson(
|
|
[
|
|
"api",
|
|
`repos/${owner}/${repo}/actions/tasks?limit=${GITEA_ACTIONS_RUNS_PAGE_LIMIT}&page=${page}`,
|
|
],
|
|
{ cwd: input.cwd },
|
|
GiteaActionsRunsSchema,
|
|
);
|
|
if (batch.workflow_runs.length === 0) {
|
|
break;
|
|
}
|
|
fetched += batch.workflow_runs.length;
|
|
runs.push(...parseGiteaActionsRuns(batch.workflow_runs));
|
|
if (runs.some((workflowRun) => workflowRun.head_sha === input.sha)) {
|
|
break;
|
|
}
|
|
// Stop once the whole result set is fetched: relying on a short page as
|
|
// the terminator is unsafe on instances whose page cap is below the
|
|
// requested limit, but `total_count` bounds the walk exactly, so a sha
|
|
// with no run does not pay the full max-page fan-out.
|
|
if (batch.total_count !== undefined && fetched >= batch.total_count) {
|
|
break;
|
|
}
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
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: mapGiteaCommitStatus(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 toGiteaActionsCheckDetails(workflowRun: GiteaActionsRun): CheckDetails {
|
|
const conclusion = mapGiteaActionsRunStatus(workflowRun.status);
|
|
return {
|
|
checkRunId: workflowRun.id,
|
|
workflowRunId: workflowRun.id,
|
|
name: getGiteaActionsRunName(workflowRun),
|
|
status: workflowRun.status,
|
|
conclusion,
|
|
url: workflowRun.url ?? null,
|
|
detailsUrl: workflowRun.url ?? null,
|
|
output: {
|
|
title: workflowRun.display_title ?? workflowRun.name ?? workflowRun.workflow_id ?? null,
|
|
summary: workflowRun.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 runs = await loadActionsRunsBestEffort({
|
|
cwd: input.cwd,
|
|
repoOwner: input.repoOwner,
|
|
repoName: input.repoName,
|
|
sha,
|
|
});
|
|
const workflowRunId = input.workflowRunId ?? input.checkRunId;
|
|
const workflowRun = runs.find((entry) => entry.id === workflowRunId);
|
|
if (!workflowRun) {
|
|
throw new Error(`Gitea check ${workflowRunId} was not found`);
|
|
}
|
|
return toGiteaActionsCheckDetails(workflowRun);
|
|
},
|
|
|
|
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);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|