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
6 changes: 4 additions & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"./plugins/ban-ref-current-assignment.js",
"./plugins/ban-cross-tree-imports.js",
"./plugins/ban-e2e-app-imports.js",
"./plugins/ban-web-server-imports.js"
"./plugins/ban-web-server-imports.js",
"./plugins/ban-git-provider-reachthrough.js"
],
"ignorePatterns": ["apps/docs/*"],
"rules": {
Expand All @@ -26,7 +27,8 @@
"ban-ref-current-assignment/ban-ref-current-assignment": "error",
"ban-cross-tree-imports/ban-cross-tree-imports": "warn",
"ban-e2e-app-imports/ban-e2e-app-imports": "error",
"ban-web-server-imports/ban-web-server-imports": "warn"
"ban-web-server-imports/ban-web-server-imports": "warn",
"ban-git-provider-reachthrough/ban-git-provider-reachthrough": "error"
},
"plugins": ["react"]
}
46 changes: 24 additions & 22 deletions apps/api/src/api/routes/admin-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,28 @@
*
* Mounted by `admin.ts`, therefore already behind `requireDeploymentAdmin`.
*/
import { repoRefFromOwnerName } from "@decocms/shared/git-providers";
import {
repoRefFromOwnerName,
repoWebUrl,
} from "@decocms/shared/git-providers";
import { Hono } from "hono";
import type { Env } from "@/api/hono-env";
import { contentClientWithToken } from "@/git-providers/content";
import {
type RepoContentClient,
contentClientForProjectRepo,
requireBranchHead,
} from "@/git-providers/content/types";
import { githubConnectionAccessToken } from "@/oauth/github-mint";
import { resolveGithubConnection } from "@/tools/task-board/prs-get";
type RepoContentClient,
} from "@/git-providers";
import {
extractPromptRegion,
replacePromptRegion,
} from "./admin-prompt-region";

/** The repo the prompts live in — this one. */
const PROMPT_REPO = { owner: "decocms", repo: "studio" } as const;
const PROMPT_REPO_REF = repoRefFromOwnerName(
PROMPT_REPO.owner,
PROMPT_REPO.repo,
);

/**
* The editable prompts, each addressed by the marker pair that fences it in its
Expand Down Expand Up @@ -124,27 +129,24 @@ async function clientForActor(
// which is exactly what this route doesn't have — bind the resolved org so
// both the lookup and the token agree on one scope.
const orgCtx: Ctx = { ...ctx, organization: org };
const connection = await resolveGithubConnection(orgCtx, org.id, null, {
/**
* The same credential ladder every other repository read takes — a
* first-class `repositories` row for this repo when the org has one, else
* its legacy `mcp-github` connection.
*/
const gh = await contentClientForProjectRepo(orgCtx, org.id, {
url: repoWebUrl(PROMPT_REPO_REF),
owner: PROMPT_REPO.owner,
name: PROMPT_REPO.repo,
});
if (!connection) {
}).catch((cause: unknown) => {
throw new PromptEditorError(
"Connect GitHub in this organization to edit prompts",
cause instanceof Error
? cause.message
: "Connect GitHub in this organization to edit prompts",
400,
);
}
const accessToken = await githubConnectionAccessToken(orgCtx, connection);
if (!accessToken) {
throw new PromptEditorError("Reconnect GitHub to edit prompts", 400);
}
return {
gh: contentClientWithToken(
repoRefFromOwnerName(PROMPT_REPO.owner, PROMPT_REPO.repo),
accessToken,
),
org: { slug: org.slug, name: org.name },
};
});
return { gh, org: { slug: org.slug, name: org.name } };
}

/**
Expand Down
12 changes: 6 additions & 6 deletions apps/api/src/api/routes/decofile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +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 { contentClientForProjectRepo } from "@/git-providers/content";
import {
RepoWriteConflict,
contentClientForProjectRepo,
repoErrorStatus,
type RepoContentClient,
} from "@/git-providers";
import {
enqueueDecofilePatch,
type DecofilePatch,
} from "@/decofile/commit-coalescer";
import { signDraftToken, verifyDraftToken } from "@/decofile/draft-token";
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
8 changes: 3 additions & 5 deletions apps/api/src/api/routes/git-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,9 @@ import { Hono } from "hono";
import { ContextFactory } from "@/core/context-factory";
import type { StudioContext } from "@/core/studio-context";
import { getPublicUrl } from "@/core/server-constants";
import { getGithubAppAuth } from "@/git-providers/credentials";
import {
readGithubAppConfig,
readGitlabOAuthConfig,
} from "@/git-providers/env";
import { getGithubAppAuth } from "@/git-providers/github/app-auth";
import { readGithubAppConfig } from "@/git-providers/github/env";
import { readGitlabOAuthConfig } from "@/git-providers/gitlab/env";
import {
exchangeGithubCode,
githubAuthorizeUrl,
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/api/routes/sandbox-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ import {
suggestCommitMessageWithLlm,
} from "../../lib/suggest-commit-message";
import { judgeRequiresReviewWithLlm } from "../../lib/judge-requires-review";
import { contentClientForProjectRepo } from "../../git-providers/content";
import {
RepoWriteConflict,
contentClientForProjectRepo,
repoErrorStatus,
repoRateLimitRetryAfterMs,
RepoWriteConflict,
} from "../../git-providers/content/types";
} from "@/git-providers";
import { GitProviderError } from "../../git-providers/types";
import {
repoGitDiff,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/decofile/commit-coalescer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type FileChange,
type RepoContentClient,
RepoWriteConflict,
} from "@/git-providers/content/types";
} from "@/git-providers";
import {
aliasPathsForKey,
blockEntriesInTree,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/decofile/git-compat.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import type { TreeEntry } from "@/git-providers/content/types";
import type { TreeEntry } from "@/git-providers";
import {
buildDiscardPlan,
buildMergeReplayPlan,
Expand Down
10 changes: 5 additions & 5 deletions apps/api/src/decofile/git-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
*/

import {
type FileChange,
type RepoContentClient,
repoErrorStatus,
GitProviderError,
RepoWriteConflict,
repoErrorStatus,
requireBranchHead,
type FileChange,
type RepoContentClient,
type TreeEntry,
} from "@/git-providers/content/types";
import { GitProviderError } from "@/git-providers/types";
} from "@/git-providers";
import { mapBounded, resolveOrCreateHead } from "./read-decofile";

const DIFF_MAX_FILES = 200;
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/decofile/read-decofile.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import type { TreeEntry } from "@/git-providers/content/types";
import type { TreeEntry } from "@/git-providers";
import {
aliasPathsForKey,
blockEntriesInTree,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/decofile/read-decofile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
requireBranchHead,
RepoWriteConflict,
type TreeEntry,
} from "@/git-providers/content/types";
} from "@/git-providers";
import { meter } from "../observability";
import {
getBlob,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/file-storage/org-repo-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { OrgRepoSync } from "@/storage/types";
import {
clientForAccount,
repositoryUsesStudioCredentials,
} from "@/git-providers/credentials";
} from "@/git-providers";
import { repoRefOf } from "@/storage/repositories";
import { ensureGithubCloneToken } from "@/shared/github-clone-info";
import { getValidDownstreamAccessToken } from "@/oauth/token-refresh";
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/git-providers/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Which providers this deployment can connect through Studio-owned
* credentials, without the caller reading an environment variable.
*
* The connect flows themselves are provider-specific by construction (a GitHub
* App installation and a GitLab OAuth grant are different dances, and
* `api/routes/git-providers.ts` implements both), but "can we offer this at
* all" is one question with one shape per provider. Each provider answers for
* itself; this is only the assembly, so no host or env var is named here.
*/

import type { GitProviderKind } from "@decocms/shared/git-providers";
import { githubCapability } from "./github/app-auth";
import { gitlabCapability } from "./gitlab/env";
import type { GitProviderCapability } from "./types";

export function providerCapabilities(): Record<
GitProviderKind,
GitProviderCapability
> {
return { github: githubCapability(), gitlab: gitlabCapability() };
}
72 changes: 72 additions & 0 deletions apps/api/src/git-providers/change-requests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* The one reducer both providers share: a run list to a single CI summary.
*
* It lives in the interface rather than in either implementation precisely so
* these cases hold for both — a red build must not mean different things on
* GitHub and GitLab.
*/
import { describe, expect, it } from "bun:test";
import type { CheckRun } from "./change-requests";
import { summarizeChecks } from "./change-requests";

const run = (over: Partial<CheckRun> = {}): CheckRun => ({
id: "1",
name: "build",
state: "completed",
conclusion: "success",
url: null,
durationMs: null,
summary: null,
...over,
});

describe("summarizeChecks", () => {
/** A change request without CI is not "pending" — it has nothing to say. */
it("is null for no runs at all", () => {
expect(summarizeChecks([])).toBeNull();
});

it("is passing when every run finished well", () => {
expect(
summarizeChecks([
run(),
run({ conclusion: "neutral" }),
run({ conclusion: "skipped" }),
]),
).toBe("passing");
});

it("is pending while any run is unfinished", () => {
expect(
summarizeChecks([run(), run({ state: "queued", conclusion: null })]),
).toBe("pending");
expect(summarizeChecks([run({ state: "running", conclusion: null })])).toBe(
"pending",
);
});

/** Failing wins over pending: the worst answer is the actionable one. */
it("is failing as soon as one run failed, even mid-pipeline", () => {
expect(
summarizeChecks([
run({ state: "running", conclusion: null }),
run({ conclusion: "failure" }),
]),
).toBe("failing");
});

/**
* A run that did not finish is not evidence the head is good, so a
* cancellation and a timeout are red — the same reading GitLab's `canceled`
* pipeline gets.
*/
it("treats a cancelled, timed-out or action-required run as red", () => {
for (const conclusion of [
"cancelled",
"timed_out",
"action_required",
] as const) {
expect(summarizeChecks([run({ conclusion })])).toBe("failing");
}
});
});
Loading
Loading