Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 24 additions & 28 deletions apps/api/src/api/routes/admin-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
*
* Mounted by `admin.ts`, therefore already behind `requireDeploymentAdmin`.
*/
import { repoRefFromOwnerName } from "@decocms/shared/git-providers";
import { Hono } from "hono";
import type { Env } from "@/api/hono-env";
import { contentClientWithToken } from "@/git-providers/content";
import {
createGitDataClient,
type GitDataClient,
} from "@/decofile/github-git-data";
type RepoContentClient,
requireBranchHead,
} from "@/git-providers/content/types";
import { githubConnectionAccessToken } from "@/oauth/github-mint";
import { resolveGithubConnection } from "@/tools/task-board/prs-get";
import {
Expand Down Expand Up @@ -116,7 +118,7 @@ async function resolveActorOrg(
/** A GitHub client on the acting admin's own connection. */
async function clientForActor(
ctx: Ctx,
): Promise<{ gh: GitDataClient; org: { slug: string; name: string } }> {
): Promise<{ gh: RepoContentClient; org: { slug: string; name: string } }> {
const org = await resolveActorOrg(ctx);
// The mint path for a repo-scoped child connection reads `ctx.organization`,
// which is exactly what this route doesn't have — bind the resolved org so
Expand All @@ -137,7 +139,10 @@ async function clientForActor(
throw new PromptEditorError("Reconnect GitHub to edit prompts", 400);
}
return {
gh: createGitDataClient({ ...PROMPT_REPO, accessToken }),
gh: contentClientWithToken(
repoRefFromOwnerName(PROMPT_REPO.owner, PROMPT_REPO.repo),
accessToken,
),
org: { slug: org.slug, name: org.name },
};
}
Expand Down Expand Up @@ -173,12 +178,12 @@ export function applyPromptEdits(

/** Every distinct source file the registry touches, read once at `ref`. */
async function readSources(
gh: GitDataClient,
gh: RepoContentClient,
ref: string,
): Promise<Map<string, string>> {
const paths = [...new Set(PROMPTS.map((p) => p.path))];
const texts = await Promise.all(
paths.map((path) => gh.getFileTextAtRef(ref, path)),
paths.map((path) => gh.readFileAtRef(ref, path)),
);
const sources = new Map<string, string>();
paths.forEach((path, i) => {
Expand All @@ -200,7 +205,7 @@ export function createAdminPromptRoutes(): Hono<Env> {
// Pin the read to the commit, not the branch: the sha goes back to the
// client and is what a save is written against, so a push landing between
// the two can be reported as a conflict instead of silently reverted.
const baseSha = await gh.getHeadSha(branch);
const baseSha = await requireBranchHead(gh, branch);
const sources = await readSources(gh, baseSha);

return c.json({
Expand Down Expand Up @@ -245,7 +250,7 @@ export function createAdminPromptRoutes(): Hono<Env> {

const { gh } = await clientForActor(c.get("studioContext"));
const base = await gh.getDefaultBranch();
const baseSha = await gh.getHeadSha(base);
const baseSha = await requireBranchHead(gh, base);
if (typeof body.baseSha === "string" && body.baseSha !== baseSha) {
// The editor loaded an older HEAD; committing its text would revert
// whatever landed since. The client reloads and the operator re-applies.
Expand All @@ -266,29 +271,20 @@ export function createAdminPromptRoutes(): Hono<Env> {
const changedPaths = [
...new Set(edits.map((e) => PROMPTS.find((p) => p.id === e.id)!.path)),
];
const entries = await Promise.all(
changedPaths.map(async (path) => ({
const branch = `admin/prompts-${Date.now().toString(36)}`;
await gh.createBranch(branch, baseSha);
await gh.commitFiles({
branch,
message: title,
expectedHead: baseSha,
changes: changedPaths.map((path) => ({
path,
mode: "100644",
type: "blob" as const,
sha: await gh.createBlob(sources.get(path)!),
content: sources.get(path)!,
})),
);

const treeSha = await gh.createTree(
await gh.getCommitTreeSha(baseSha),
entries,
);
const commitSha = await gh.createCommit({
message: title,
treeSha,
parentShas: [baseSha],
});
const branch = `admin/prompts-${Date.now().toString(36)}`;
await gh.createRef(branch, commitSha);
const pr = await gh.createPullRequest({ base, head: branch, title });
const pr = await gh.createChangeRequest({ base, head: branch, title });

return c.json({ number: pr.number, url: pr.html_url, branch });
return c.json({ number: pr.number, url: pr.url, branch });
});

app.onError((error, c) => {
Expand Down
74 changes: 39 additions & 35 deletions apps/api/src/api/routes/decofile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ import { createMiddleware } from "hono/factory";
import { z } from "zod";
import { coAuthorFromStudioContext } from "@/lib/co-author-identity";
import { parseGithubRepoFromMetadata } from "@/tools/sandbox/sync-git-credentials";
import { gitDataClientForRepo } from "@/decofile/client-for-repo";
import { contentClientForProjectRepo } from "@/git-providers/content";
import {
enqueueDecofilePatch,
type DecofilePatch,
} from "@/decofile/commit-coalescer";
import { signDraftToken, verifyDraftToken } from "@/decofile/draft-token";
import { githubGitRebase } from "@/decofile/git-compat";
import { GitHubApiError, type GitDataClient } from "@/decofile/github-git-data";
import { repoGitRebase } from "@/decofile/git-compat";
import {
type RepoContentClient,
repoErrorStatus,
RepoWriteConflict,
} from "@/git-providers/content/types";
import { readDecofileSnapshot } from "@/decofile/read-decofile";
import type { Env } from "../hono-env";

Expand Down Expand Up @@ -204,32 +208,32 @@ function requestApiHost(c: Context<DecofileEnv>): string {
return c.req.header("x-forwarded-host") ?? new URL(c.req.url).host;
}

async function gitDataClientForScope(
async function contentClientForScope(
c: Context<DecofileEnv>,
): Promise<GitDataClient> {
): Promise<RepoContentClient> {
const scope = c.get("decofileScope");
return gitDataClientForRepo(
return contentClientForProjectRepo(
c.var.studioContext,
scope.organizationId,
scope.githubRepo,
);
}

function errorResponse(c: Context<DecofileEnv>, err: unknown) {
if (err instanceof GitHubApiError) {
// 401/403 from GitHub means our credential, not the caller's — map to 502
// so the client doesn't treat it as a Studio auth failure.
const message = err instanceof Error ? err.message : String(err);
if (err instanceof RepoWriteConflict) return c.json({ error: message }, 409);
const providerStatus = repoErrorStatus(err);
if (providerStatus !== null) {
/** 401/403 from the provider means OUR credential, not the caller's — map
* it to 502 so the client doesn't treat it as a Studio auth failure. */
const status =
err.status === 404
providerStatus === 404
? 404
: err.status === 409
: providerStatus === 409 || providerStatus === 422
? 409
: err.status === 422
? 409
: 502;
return c.json({ error: err.message }, status);
: 502;
return c.json({ error: message }, status);
}
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: message }, 500);
}

Expand All @@ -242,7 +246,7 @@ export function createDecofileRoutes() {
app.get("/:virtualMcpId/:branch", async (c) => {
const scope = c.get("decofileScope");
try {
const client = await gitDataClientForScope(c);
const client = await contentClientForScope(c);
const snapshot = await readDecofileSnapshot(
client,
scope.branch,
Expand Down Expand Up @@ -297,16 +301,16 @@ export function createDecofileRoutes() {
? `${scope.packagePath}/.deco/meta.gen.json`
: ".deco/meta.gen.json";
try {
const client = await gitDataClientForScope(c);
const client = await contentClientForScope(c);
/** The thread branch may not be materialized on GitHub yet (it forks
* from default at first CMS touch), so fall back to the default branch —
* meta.gen.json is a code artifact the CMS never edits, so the default's
* copy is the schema the forked branch would carry anyway. */
let text = await client.getFileTextAtRef(scope.branch, metaPath);
let text = await client.readFileAtRef(scope.branch, metaPath);
if (text === null) {
const defaultBranch = await client.getDefaultBranch();
if (defaultBranch !== scope.branch) {
text = await client.getFileTextAtRef(defaultBranch, metaPath);
text = await client.readFileAtRef(defaultBranch, metaPath);
}
}
if (text === null) {
Expand Down Expand Up @@ -364,7 +368,7 @@ export function createDecofileRoutes() {
}

try {
const client = await gitDataClientForScope(c);
const client = await contentClientForScope(c);
const sha = await enqueueDecofilePatch(
`${scope.organizationId}/${scope.virtualMcpId}/${scope.branch}`,
{
Expand All @@ -389,7 +393,7 @@ export function createDecofileRoutes() {
app.post("/:virtualMcpId/:branch/publish", async (c) => {
const scope = c.get("decofileScope");
try {
const client = await gitDataClientForScope(c);
const client = await contentClientForScope(c);
const baseBranch = await client.getDefaultBranch();
if (baseBranch === scope.branch) {
return c.json({ error: "Branch is already the default branch" }, 400);
Expand All @@ -398,37 +402,37 @@ export function createDecofileRoutes() {
try {
let sha: string | null;
try {
sha = await client.mergeBranch(baseBranch, scope.branch, message);
sha = await client.mergeBranches(baseBranch, scope.branch, message);
} catch (err) {
if (!(err instanceof GitHubApiError && err.status === 409)) throw err;
if (repoErrorStatus(err) !== 409) throw err;
// Sync branch-wins first; the branch then sits on base and this FFs.
await githubGitRebase(client, scope.branch, baseBranch);
sha = await client.mergeBranch(baseBranch, scope.branch, message);
await repoGitRebase(client, scope.branch, baseBranch);
sha = await client.mergeBranches(baseBranch, scope.branch, message);
}
return sha
? c.json({ result: "merged", sha })
: c.json({ result: "up-to-date" });
} catch (err) {
if (err instanceof GitHubApiError && err.status === 409) {
if (repoErrorStatus(err) === 409) {
return c.json({ error: "merge-conflict" }, 409);
}
// 405 = merge blocked (protected base branch) → fall back to a PR.
if (err instanceof GitHubApiError && err.status === 405) {
const existing = await client.findOpenPullRequest(
if (repoErrorStatus(err) === 405) {
const existing = await client.findOpenChangeRequest(
baseBranch,
scope.branch,
);
const pr =
existing ??
(await client.createPullRequest({
(await client.createChangeRequest({
base: baseBranch,
head: scope.branch,
title: `Publish ${scope.branch}`,
}));
return c.json({
result: "pull-request",
number: pr.number,
url: pr.html_url,
url: pr.url,
});
}
throw err;
Expand All @@ -441,7 +445,7 @@ export function createDecofileRoutes() {
app.get("/:virtualMcpId/:branch/status", async (c) => {
const scope = c.get("decofileScope");
try {
const client = await gitDataClientForScope(c);
const client = await contentClientForScope(c);
const baseBranch = await client.getDefaultBranch();
// Null lastCommitAt == "no age, never auto-switch off this branch".
if (baseBranch === scope.branch) {
Expand All @@ -455,17 +459,17 @@ export function createDecofileRoutes() {
try {
const [{ aheadBy, behindBy }, head] = await Promise.all([
client.compare(baseBranch, scope.branch),
client.getBranchHead(scope.branch),
client.getBranch(scope.branch),
]);
return c.json({
baseBranch,
aheadBy,
behindBy,
lastCommitAt: head.committedAt,
lastCommitAt: head?.committedAt ?? null,
});
} catch (err) {
// A thread-minted branch not materialized yet has no drift and no age.
if (err instanceof GitHubApiError && err.status === 404) {
if (repoErrorStatus(err) === 404) {
return c.json({
baseBranch,
aheadBy: 0,
Expand Down
Loading
Loading