- 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
274 lines
7.9 KiB
TypeScript
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];
|
|
}
|