lakehouse/auditor 9 blocking issues: cloud: claim not backed — "password). Verified live: /api/v1/user auths as profit, pulls list 200s."
git.agentview.dev has served a Traefik default cert + API 404 since Aug 25 (VPS consolidation moved repos to warren); listOpenPrs failed every 90s cycle since. Host default moves to git.warren.help and auth now resolves per-host from ~/.git-credentials with fallback to the origin remote's embedded credentials, sent as Basic (works for PAT or password). Verified live: /api/v1/user auths as profit, pulls list 200s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
160 lines
6.2 KiB
TypeScript
160 lines
6.2 KiB
TypeScript
// Gitea API client. Minimal surface — only what the auditor needs:
|
|
// list open PRs, get commits + files for a PR, fetch a diff, post a
|
|
// commit status, post a review.
|
|
//
|
|
// Auth: reads PAT from ~/.git-credentials (set up by the credential
|
|
// helper flow in 2026-04-22 session). Gitea's "token" auth scheme
|
|
// matches what `git fetch` is already using.
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import type { PrSnapshot } from "./types.ts";
|
|
|
|
// 2026-09-02: repo home moved to git.warren.help in the VPS
|
|
// consolidation; git.agentview.dev now dead-ends at a Traefik default
|
|
// cert + 404 (auditor had been failing listOpenPrs every cycle since
|
|
// Aug 25). Credentials come from ~/.git-credentials for HOST's domain,
|
|
// falling back to the user:pass embedded in this repo's origin remote.
|
|
// Basic auth works for both a PAT and a password on Gitea.
|
|
const HOST = process.env.GITEA_HOST ?? "https://git.warren.help";
|
|
const OWNER = "profit";
|
|
const REPO = "lakehouse";
|
|
const CRED_FILE = "/home/profit/.git-credentials";
|
|
const GIT_CONFIG = "/home/profit/lakehouse/.git/config";
|
|
|
|
let cachedAuth: string | null = null;
|
|
|
|
async function getAuthHeader(): Promise<string> {
|
|
if (cachedAuth) return cachedAuth;
|
|
const hostDomain = new URL(HOST).host.replace(/\./g, "\\.");
|
|
const credRe = new RegExp(`^https://([^:]+):([^@]+)@${hostDomain}`);
|
|
for (const file of [CRED_FILE, GIT_CONFIG]) {
|
|
let raw: string;
|
|
try { raw = await readFile(file, "utf8"); } catch { continue; }
|
|
for (const line of raw.split("\n")) {
|
|
const m = line.trim().replace(/^url = /, "").match(credRe);
|
|
if (m) {
|
|
cachedAuth = `Basic ${Buffer.from(`${decodeURIComponent(m[1])}:${decodeURIComponent(m[2])}`).toString("base64")}`;
|
|
return cachedAuth;
|
|
}
|
|
}
|
|
}
|
|
throw new Error(`no Gitea credentials for ${HOST} in ${CRED_FILE} or ${GIT_CONFIG}`);
|
|
}
|
|
|
|
async function giteaFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
const url = `${HOST}/api/v1${path}`;
|
|
const headers = new Headers(init.headers);
|
|
headers.set("Authorization", await getAuthHeader());
|
|
if (init.body && !headers.has("content-type")) {
|
|
headers.set("content-type", "application/json");
|
|
}
|
|
return fetch(url, { ...init, headers, signal: AbortSignal.timeout(20000) });
|
|
}
|
|
|
|
export async function listOpenPrs(): Promise<PrSnapshot[]> {
|
|
const r = await giteaFetch(`/repos/${OWNER}/${REPO}/pulls?state=open&page=1&limit=50`);
|
|
if (!r.ok) throw new Error(`listOpenPrs ${r.status}: ${await r.text()}`);
|
|
const rows = (await r.json()) as any[];
|
|
return Promise.all(rows.map(row => snapshotFromPr(row)));
|
|
}
|
|
|
|
export async function getPrSnapshot(num: number): Promise<PrSnapshot> {
|
|
const r = await giteaFetch(`/repos/${OWNER}/${REPO}/pulls/${num}`);
|
|
if (!r.ok) throw new Error(`getPr ${num} ${r.status}: ${await r.text()}`);
|
|
return snapshotFromPr((await r.json()) as any);
|
|
}
|
|
|
|
async function snapshotFromPr(row: any): Promise<PrSnapshot> {
|
|
const num = row.number;
|
|
const commitsResp = await giteaFetch(`/repos/${OWNER}/${REPO}/pulls/${num}/commits`);
|
|
const commits = commitsResp.ok ? ((await commitsResp.json()) as any[]) : [];
|
|
const filesResp = await giteaFetch(`/repos/${OWNER}/${REPO}/pulls/${num}/files`);
|
|
const files = filesResp.ok ? ((await filesResp.json()) as any[]) : [];
|
|
return {
|
|
number: num,
|
|
head_sha: row.head?.sha ?? "",
|
|
base_sha: row.base?.sha ?? "",
|
|
title: row.title ?? "",
|
|
body: row.body ?? "",
|
|
state: row.state === "open" ? "open" : (row.merged ? "merged" : "closed"),
|
|
author: row.user?.login ?? "",
|
|
commits: commits.map(c => ({
|
|
sha: (c.sha ?? "").slice(0, 12),
|
|
message: c.commit?.message ?? "",
|
|
author: c.commit?.author?.name ?? "",
|
|
})),
|
|
files: files.map(f => ({
|
|
path: f.filename ?? "",
|
|
additions: f.additions ?? 0,
|
|
deletions: f.deletions ?? 0,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/// Returns the unified diff text of the PR. Used by static checks.
|
|
export async function getPrDiff(num: number): Promise<string> {
|
|
const r = await giteaFetch(`/repos/${OWNER}/${REPO}/pulls/${num}.diff`);
|
|
if (!r.ok) throw new Error(`getDiff ${num} ${r.status}: ${await r.text()}`);
|
|
return await r.text();
|
|
}
|
|
|
|
/// Hard-block mechanism: post a failing commit status on the PR head
|
|
/// SHA. Branch protection (if enabled on `main`) treats this as a
|
|
/// required-check fail and prevents merge. The description is shown
|
|
/// in the Gitea UI next to the red X.
|
|
export async function postCommitStatus(args: {
|
|
sha: string;
|
|
state: "success" | "pending" | "failure" | "error";
|
|
context: string;
|
|
description: string;
|
|
target_url?: string;
|
|
}): Promise<void> {
|
|
const r = await giteaFetch(`/repos/${OWNER}/${REPO}/statuses/${args.sha}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
state: args.state,
|
|
context: args.context,
|
|
description: args.description.slice(0, 140),
|
|
target_url: args.target_url ?? "",
|
|
}),
|
|
});
|
|
if (!r.ok) throw new Error(`postCommitStatus ${r.status}: ${await r.text()}`);
|
|
}
|
|
|
|
/// Post a review comment. Gitea typically blocks self-review
|
|
/// (author posting a review on their own PR). Prefer
|
|
/// `postIssueComment` when running with the author's PAT.
|
|
export async function postReview(args: {
|
|
pr_number: number;
|
|
commit_id: string;
|
|
body: string;
|
|
event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
|
|
}): Promise<void> {
|
|
const r = await giteaFetch(`/repos/${OWNER}/${REPO}/pulls/${args.pr_number}/reviews`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
commit_id: args.commit_id,
|
|
body: args.body,
|
|
event: args.event,
|
|
}),
|
|
});
|
|
if (!r.ok) throw new Error(`postReview ${r.status}: ${await r.text()}`);
|
|
}
|
|
|
|
/// Plain issue comment. Works for the auditor's own PAT because
|
|
/// Gitea allows authors to comment on their own PRs (just not
|
|
/// review them). Auditor uses this for the reasoning body; the
|
|
/// actual block signal is the commit status.
|
|
export async function postIssueComment(args: {
|
|
pr_number: number;
|
|
body: string;
|
|
}): Promise<{ id: number; html_url: string }> {
|
|
const r = await giteaFetch(`/repos/${OWNER}/${REPO}/issues/${args.pr_number}/comments`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body: args.body }),
|
|
});
|
|
if (!r.ok) throw new Error(`postIssueComment ${r.status}: ${await r.text()}`);
|
|
const j = await r.json() as any;
|
|
return { id: j.id, html_url: j.html_url };
|
|
}
|