Submatter/apps/web/server/lib/forge/adapters.ts
Randall Stillwell 86155455a7 feat(worker): close Epic 7 — draft PR + operator notes + doc-writer
- Draft PR via forge (apps/worker/src/git/pr.ts) push branch + createPullRequest draft with echodo-convoy marker, expose prUrl in Mission Control (convoy-service, convoys router, detail panel)
- Operator notes mid-build: convoy_operator_messages (0016), tRPC post/list, worker poll between tool rounds (queued) and before generateText (direct), detail panel thread for in_progress
- Doc-writer: harness doc-writer examples (docs/examples/research-pipeline.md), SOP template (docs/templates/doc-sop.md), role-doc-writer wiring

Convoy: .convoys/worker-closeout-docwriter

Co-authored-by: orchestrator
2026-08-25 16:56:02 -05:00

274 lines
7.9 KiB
TypeScript

import type { ForgeType, RepoMetadata } from "@tasks/shared/connected-repos";
export type ForgeAdapterContext = {
apiBaseUrl: string;
owner: string;
repo: string;
token?: string;
};
export type CreatePullRequestInput = {
base: string;
head: string;
title: string;
body: string;
draft: boolean;
};
export type CreatePullRequestResult = {
url: string;
number: number;
};
export interface ForgeAdapter {
readonly forgeType: ForgeType;
fetchRepoMetadata(ctx: ForgeAdapterContext): Promise<RepoMetadata>;
createPullRequest(
ctx: ForgeAdapterContext,
input: CreatePullRequestInput,
): Promise<CreatePullRequestResult>;
}
async function readJsonResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new Error(`Forge API returned non-JSON response (${response.status}).`);
}
}
function authHeaders(token: string | undefined): HeadersInit {
if (!token) {
return {};
}
return { Authorization: `Bearer ${token}` };
}
async function createGithubPullRequest(
ctx: ForgeAdapterContext,
input: CreatePullRequestInput,
): Promise<CreatePullRequestResult> {
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}/pulls`;
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...authHeaders(ctx.token),
},
body: JSON.stringify({
title: input.title,
body: input.body,
head: input.head,
base: input.base,
draft: input.draft,
}),
});
const body = await readJsonResponse(response);
if (!response.ok) {
const message =
typeof body === "object" &&
body !== null &&
"message" in body &&
typeof (body as { message: unknown }).message === "string"
? (body as { message: string }).message
: `GitHub API error (${response.status})`;
throw new Error(message);
}
const htmlUrl =
typeof body === "object" &&
body !== null &&
"html_url" in body &&
typeof (body as { html_url: unknown }).html_url === "string"
? (body as { html_url: string }).html_url
: typeof body === "object" &&
body !== null &&
"url" in body &&
typeof (body as { url: unknown }).url === "string"
? (body as { url: string }).url
: `${ctx.apiBaseUrl.replace(/\/$/, "")}/${ctx.owner}/${ctx.repo}/pulls/unknown`;
const number =
typeof body === "object" &&
body !== null &&
"number" in body &&
typeof (body as { number: unknown }).number === "number"
? (body as { number: number }).number
: 0;
return { url: htmlUrl, number };
}
async function createForgejoPullRequest(
ctx: ForgeAdapterContext,
input: CreatePullRequestInput,
): Promise<CreatePullRequestResult> {
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}/pulls`;
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...authHeaders(ctx.token),
},
body: JSON.stringify({
title: input.title,
body: input.body,
head: input.head,
base: input.base,
draft: input.draft,
}),
});
const body = await readJsonResponse(response);
if (!response.ok) {
const message =
typeof body === "object" &&
body !== null &&
"message" in body &&
typeof (body as { message: unknown }).message === "string"
? (body as { message: string }).message
: `Forge API error (${response.status})`;
throw new Error(message);
}
const htmlUrl =
typeof body === "object" &&
body !== null &&
"html_url" in body &&
typeof (body as { html_url: unknown }).html_url === "string"
? (body as { html_url: string }).html_url
: typeof body === "object" &&
body !== null &&
"url" in body &&
typeof (body as { url: unknown }).url === "string"
? (body as { url: string }).url
: `${ctx.apiBaseUrl.replace(/\/api\/v1$/, "")}/${ctx.owner}/${ctx.repo}/pulls/${typeof body === "object" && body !== null && "number" in body ? String((body as { number: unknown }).number) : "unknown"}`;
const number =
typeof body === "object" &&
body !== null &&
"number" in body &&
typeof (body as { number: unknown }).number === "number"
? (body as { number: number }).number
: 0;
return { url: htmlUrl, number };
}
export const githubForgeAdapter: ForgeAdapter = {
forgeType: "github",
async fetchRepoMetadata(ctx) {
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}`;
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
...authHeaders(ctx.token),
},
});
const body = await readJsonResponse(response);
if (!response.ok) {
const message =
typeof body === "object" &&
body !== null &&
"message" in body &&
typeof body.message === "string"
? body.message
: `GitHub API error (${response.status})`;
throw new Error(message);
}
if (
typeof body !== "object" ||
body === null ||
!("default_branch" in body) ||
typeof body.default_branch !== "string"
) {
throw new Error("GitHub API response missing default_branch.");
}
const cloneUrl =
typeof body === "object" &&
body !== null &&
"clone_url" in body &&
typeof body.clone_url === "string"
? body.clone_url
: `https://github.com/${ctx.owner}/${ctx.repo}.git`;
const description =
typeof body === "object" &&
body !== null &&
"description" in body &&
typeof body.description === "string"
? body.description
: undefined;
return {
defaultBranch: body.default_branch,
cloneUrl,
description,
};
},
async createPullRequest(ctx, input) {
return createGithubPullRequest(ctx, input);
},
};
export const forgejoForgeAdapter: ForgeAdapter = {
forgeType: "forgejo",
async fetchRepoMetadata(ctx) {
const url = `${ctx.apiBaseUrl.replace(/\/$/, "")}/repos/${ctx.owner}/${ctx.repo}`;
const response = await fetch(url, {
headers: {
Accept: "application/json",
...authHeaders(ctx.token),
},
});
const body = await readJsonResponse(response);
if (!response.ok) {
const message =
typeof body === "object" &&
body !== null &&
"message" in body &&
typeof body.message === "string"
? body.message
: `Forge API error (${response.status})`;
throw new Error(message);
}
if (
typeof body !== "object" ||
body === null ||
!("default_branch" in body) ||
typeof body.default_branch !== "string"
) {
throw new Error("Forge API response missing default_branch.");
}
const cloneUrl =
typeof body === "object" &&
body !== null &&
"clone_url" in body &&
typeof body.clone_url === "string"
? body.clone_url
: `${ctx.apiBaseUrl.replace(/\/api\/v1$/, "")}/${ctx.owner}/${ctx.repo}.git`;
const description =
typeof body === "object" &&
body !== null &&
"description" in body &&
typeof body.description === "string"
? body.description
: undefined;
return {
defaultBranch: body.default_branch,
cloneUrl,
description,
};
},
async createPullRequest(ctx, input) {
return createForgejoPullRequest(ctx, input);
},
};
const ADAPTERS: Record<ForgeType, ForgeAdapter> = {
github: githubForgeAdapter,
forgejo: forgejoForgeAdapter,
gitlab: forgejoForgeAdapter,
bitbucket: forgejoForgeAdapter,
};
export function getForgeAdapter(forgeType: ForgeType): ForgeAdapter {
return ADAPTERS[forgeType];
}