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; } export interface TeaCommandResult { stdout: string; stderr: string; } export type TeaCommandRunner = ( args: string[], options: TeaCommandRunnerOptions, ) => Promise; export interface CreateGiteaServiceOptions { runner?: TeaCommandRunner; resolveTeaPath?: () => Promise; resolveRemoteUrl?: (cwd: string) => Promise; resolveCurrentBranch?: (cwd: string) => Promise; 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; type GiteaIssueListItem = z.infer; type GiteaPullRequestView = z.infer; type GiteaCurrentPullRequestApi = z.infer; type GiteaCommitStatus = z.infer; type GiteaCombinedCommitStatus = z.infer; type GiteaActionsRun = z.infer; type GiteaIssueComment = z.infer; type GiteaReview = z.infer; type GiteaReviewComment = z.infer; interface GiteaTimelineBucket { items: T[]; truncated: boolean; } async function resolveTeaPath(): Promise { 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 { return teaCliRunner.run(args, options); } async function defaultResolveCurrentBranch(cwd: string): Promise { 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 | null | undefined): string { return user?.login ?? user?.full_name ?? "unknown"; } function giteaUserUrl(user: z.infer | null | undefined): string | null { return user?.html_url ?? null; } function giteaUserAvatar(user: z.infer | 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(); 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): 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 = ""; /** * 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 { 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 { 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 { const runTea = options.runTea ?? defaultRunTea; const viaTea = await probeForgejoNamespaceByTea(host, runTea); if (viaTea !== null) { return viaTea ? "forgejo" : "gitea"; } return "gitea"; } const inFlightFamilyProbes = new Map>(); /** * 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 { 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[], ): "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[]): 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(); async function run(args: string[], runOptions: TeaCommandRunnerOptions): Promise { 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( args: string[], runOptions: TeaCommandRunnerOptions, stdout: string, schema: z.ZodType, ): T { return parseCliJsonOutput({ commandName: "tea", args, cwd: runOptions.cwd, stdout, schema, createCommandError: (params) => new TeaCommandError(params), }); } async function runJsonArray( args: string[], runOptions: TeaCommandRunnerOptions, itemSchema: z.ZodType, ): Promise { const stdout = await run(args, runOptions); if (!stdout) { return []; } return runJsonParse(args, runOptions, stdout, z.array(itemSchema)); } async function runJson( args: string[], runOptions: TeaCommandRunnerOptions, schema: z.ZodType, ): Promise { 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 { 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 { 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> { return listTimelineBucketPages( input, `pulls/${input.prNumber}/reviews/${input.reviewId}/comments`, GiteaReviewCommentSchema, ); } async function listTimelineBucketPages( input: GetPullRequestTimelineOptions, suffix: string, itemSchema: z.ZodType, ): Promise> { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return loadCurrentPullRequestStatus({ cwd: input.cwd, headRef: input.headRef, headSha: input.headSha, }); }, async getPullRequest(input: GetPullRequestOptions): Promise { // Fetch the single PR by number (like gh `pr view ` / 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 { const summary = await this.getPullRequest(input); return summary.headRefName; }, async getPullRequestCheckoutTarget( input: GetPullRequestOptions, ): Promise { 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 { return listPullRequestItems({ cwd: input.cwd, state: "open", query: input.query, limit: input.limit, }).then((items) => items.map(toPullRequestSummary)); }, listIssues(input: ListIssuesOptions): Promise { return runIssueList(input); }, async createPullRequest(input: CreatePullRequestOptions): Promise { 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 { 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 { 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 { 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 { 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); } } }, }; }