diff --git a/CLAUDE.md b/CLAUDE.md index fc92f789..ec6ea912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,8 @@ Optio is an orchestration system for AI coding agents. Think of it as "CI/CD whe 8. Auto-resumes agent when reviewer requests changes (if enabled) 9. Auto-completes on merge, auto-fails on close + Supported git platforms: **GitHub**, **GitLab** (incl. self-hosted via `GITLAB_HOSTS`), and **AWS CodeCommit**. CodeCommit auths via AWS access keys (or IRSA / instance profile when running on EKS) and uses the AWS CLI credential helper for clones; PR ops go through `@aws-sdk/client-codecommit`. CodeCommit has no native CI or issues — `getCIChecks` returns `[]` (auto-merge still fires on `checksStatus="none"`), `listIssues` returns `[]`, and `reviewTrigger="on_pr"` is recommended over the default `on_ci_pass` for CodeCommit repos. + - **Standalone Task** — no `Where`. The agent runs in an isolated pod with no repo checkout, producing logs and side effects (e.g., queries Slack, posts to a database). Scheduled/webhook-driven runs of this flavor are the common case. - **Persistent Agent** — long-lived, named, message-driven agent process that does _not_ terminate after running. Halts after each turn and waits to be re-woken by a user message, an agent message, a webhook, a cron tick, or a ticket event. Addressable by other agents in the same workspace via the inter-agent HTTP API (`/api/internal/persistent-agents/*`). Three configurable pod lifecycle modes: `always-on`, `sticky` (default, with idle warm window), and `on-demand`. UI at `/agents`. Schema: `persistent_agents`, `persistent_agent_turns`, `persistent_agent_messages`, `persistent_agent_pods`. See `docs/persistent-agents.md` and the demo in `demos/the-forge/`. diff --git a/apps/api/package.json b/apps/api/package.json index 6a205a3e..27f12c8f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,8 @@ "openapi:types": "openapi-typescript openapi.generated.json -o openapi.generated.d.ts" }, "dependencies": { + "@aws-sdk/client-codecommit": "^3.1041.0", + "@aws-sdk/client-sts": "^3.1041.0", "@fastify/cors": "^11.0.0", "@fastify/formbody": "^8.0.2", "@fastify/rate-limit": "^10.2.2", @@ -61,6 +63,7 @@ "@types/node": "^22.15.0", "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^3.2.4", + "aws-sdk-client-mock": "^4.1.0", "drizzle-kit": "^0.31.1", "openapi-typescript": "^7.13.0", "tsx": "^4.19.4", diff --git a/apps/api/src/routes/openapi.test.ts b/apps/api/src/routes/openapi.test.ts index 376bb143..ded41fff 100644 --- a/apps/api/src/routes/openapi.test.ts +++ b/apps/api/src/routes/openapi.test.ts @@ -324,16 +324,18 @@ const MIGRATED_ROUTES: MigratedRoute[] = [ { method: "get", path: "/api/analytics/costs" }, // Phase 7 — setup, secrets, optio, cluster (28 routes) - // setup.ts (10) + // setup.ts (12) { method: "get", path: "/api/setup/status" }, { method: "post", path: "/api/setup/validate/github-token" }, { method: "post", path: "/api/setup/validate/gitlab-token" }, + { method: "post", path: "/api/setup/validate/aws-credentials" }, { method: "post", path: "/api/setup/validate/anthropic-key" }, { method: "post", path: "/api/setup/validate/copilot-token" }, { method: "post", path: "/api/setup/validate/openai-key" }, { method: "post", path: "/api/setup/validate/gemini-key" }, { method: "post", path: "/api/setup/repos" }, { method: "post", path: "/api/setup/repos/gitlab" }, + { method: "post", path: "/api/setup/repos/codecommit" }, { method: "post", path: "/api/setup/validate/repo" }, // secrets.ts (3) { method: "get", path: "/api/secrets" }, @@ -416,8 +418,9 @@ describe("OpenAPI spec — migrated routes are fully documented", () => { it("migrated routes count matches the sum of completed phases", () => { // Removed 14 routes (8 schedule + 6 task-template) that were redundant - // with agent workflows. 183 - 14 = 169. - expect(MIGRATED_ROUTES).toHaveLength(169); + // with agent workflows. 183 - 14 = 169. Then added 2 CodeCommit setup + // routes (validate/aws-credentials and repos/codecommit) → 171. + expect(MIGRATED_ROUTES).toHaveLength(171); }); it("components.schemas contains the Task domain types", () => { diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index b5b7734b..ab4e4256 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -18,6 +18,14 @@ const gitlabTokenSchema = z .describe("Optional self-hosted GitLab host; defaults to gitlab.com"), }) .describe("GitLab token + optional host"); +const awsCredentialsSchema = z + .object({ + accessKeyId: z.string().min(1), + secretAccessKey: z.string().min(1), + sessionToken: z.string().optional(), + region: z.string().min(1), + }) + .describe("AWS credentials + region for CodeCommit access"); const keySchema = z.object({ key: z.string().min(1) }).describe("Body with a required API key"); const reposBodySchema = z .object({ @@ -250,6 +258,113 @@ export async function setupRoutes(rawApp: FastifyInstance) { }, ); + app.post( + "/api/setup/validate/aws-credentials", + { + config: { rateLimit: SETUP_POST_RATE_LIMIT }, + preHandler: [requireAdminWhenAuthenticated], + schema: { + operationId: "validateAwsCredentials", + summary: "Validate AWS credentials for CodeCommit", + description: + "Confirms the supplied AWS credentials by calling sts:GetCallerIdentity " + + "and codecommit:ListRepositories in the given region.", + tags: ["Setup & Settings"], + body: awsCredentialsSchema, + response: { 200: ValidationResultSchema, 400: ErrorResponseSchema }, + }, + }, + async (req, reply) => { + const { accessKeyId, secretAccessKey, sessionToken, region } = req.body; + try { + const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts"); + const { CodeCommitClient: CC, ListRepositoriesCommand } = + await import("@aws-sdk/client-codecommit"); + const credentials = { + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }; + const sts = new STSClient({ region, credentials }); + const id = await sts.send(new GetCallerIdentityCommand({})); + const cc = new CC({ region, credentials }); + await cc.send(new ListRepositoriesCommand({})); + reply.send({ + valid: true, + user: { + login: id.Arn ?? "aws", + name: id.Account ? `AWS account ${id.Account}` : "AWS", + }, + }); + } catch (err) { + app.log.error(err, "AWS credential validation failed"); + reply.send({ valid: false, error: sanitizeError(err) }); + } + }, + ); + + app.post( + "/api/setup/repos/codecommit", + { + config: { rateLimit: SETUP_POST_RATE_LIMIT }, + preHandler: [requireAdminWhenAuthenticated], + schema: { + operationId: "listSetupCodeCommitRepos", + summary: "List CodeCommit repositories for setup", + description: "List CodeCommit repos in the given region accessible to the supplied creds.", + tags: ["Setup & Settings"], + body: awsCredentialsSchema, + response: { 200: ReposListResponseSchema, 400: ErrorResponseSchema }, + }, + }, + async (req, reply) => { + const { accessKeyId, secretAccessKey, sessionToken, region } = req.body; + try { + const { + CodeCommitClient: CC, + ListRepositoriesCommand, + GetRepositoryCommand, + } = await import("@aws-sdk/client-codecommit"); + const credentials = { + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }; + const cc = new CC({ region, credentials }); + const list = await cc.send(new ListRepositoriesCommand({})); + const names = (list.repositories ?? []).map((r) => r.repositoryName).filter(Boolean) as + | string[] + | []; + const details = await Promise.all( + names.slice(0, 50).map((name) => + cc + .send(new GetRepositoryCommand({ repositoryName: name })) + .then((d) => d.repositoryMetadata) + .catch(() => null), + ), + ); + const repos = details + .filter((d): d is NonNullable => Boolean(d)) + .map((d) => ({ + fullName: d.repositoryName ?? "", + cloneUrl: + d.cloneUrlHttp ?? + `https://git-codecommit.${region}.amazonaws.com/v1/repos/${d.repositoryName}`, + htmlUrl: `https://${region}.console.aws.amazon.com/codesuite/codecommit/repositories/${d.repositoryName}/browse`, + defaultBranch: d.defaultBranch ?? "main", + isPrivate: true, + description: d.repositoryDescription ?? null, + language: null, + pushedAt: d.lastModifiedDate ? new Date(d.lastModifiedDate).toISOString() : "", + })); + reply.send({ repos }); + } catch (err) { + app.log.error(err, "CodeCommit repo listing failed"); + reply.send({ repos: [], error: sanitizeError(err) }); + } + }, + ); + app.post( "/api/setup/validate/anthropic-key", { diff --git a/apps/api/src/services/codecommit-credential-service.ts b/apps/api/src/services/codecommit-credential-service.ts new file mode 100644 index 00000000..76625d90 --- /dev/null +++ b/apps/api/src/services/codecommit-credential-service.ts @@ -0,0 +1,87 @@ +import { retrieveSecretWithFallback } from "./secret-service.js"; +import { logger } from "../logger.js"; + +export interface AwsCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + region: string; +} + +/** + * Resolve AWS credentials for CodeCommit access from secrets, with workspace and global scopes, + * falling back to env vars. Returns the credentials as a JSON string so it can be passed + * through the existing GitPlatform factory which takes a single `token: string`. + * + * Lookup order: + * 1. Workspace-scoped or global secrets named AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY + * (+ optional AWS_SESSION_TOKEN, AWS_REGION). + * 2. Process env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, + * AWS_REGION / AWS_DEFAULT_REGION). + * + * If neither source provides creds, returns the sentinel string `"workload-identity"` so the + * AWS SDK falls back to its default credential provider chain (instance profile / IRSA / + * environment) at the call site. + */ +export async function getCodeCommitCredentials(workspaceId?: string | null): Promise { + let accessKeyId: string | undefined; + let secretAccessKey: string | undefined; + let sessionToken: string | undefined; + let region: string | undefined; + + try { + accessKeyId = await retrieveSecretWithFallback("AWS_ACCESS_KEY_ID", "global", workspaceId); + secretAccessKey = await retrieveSecretWithFallback( + "AWS_SECRET_ACCESS_KEY", + "global", + workspaceId, + ); + } catch { + logger.debug({ workspaceId }, "No AWS credential secrets found, checking env"); + } + + try { + sessionToken = await retrieveSecretWithFallback("AWS_SESSION_TOKEN", "global", workspaceId); + } catch { + // Optional + } + + try { + region = await retrieveSecretWithFallback("AWS_REGION", "global", workspaceId); + } catch { + // Optional + } + + accessKeyId ??= process.env.AWS_ACCESS_KEY_ID; + secretAccessKey ??= process.env.AWS_SECRET_ACCESS_KEY; + sessionToken ??= process.env.AWS_SESSION_TOKEN; + region ??= process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION; + + if (!accessKeyId || !secretAccessKey) { + // Signal to the SDK to use the default credential provider chain + return "workload-identity"; + } + + const creds: AwsCredentials = { + accessKeyId, + secretAccessKey, + region: region ?? "us-east-1", + ...(sessionToken ? { sessionToken } : {}), + }; + return JSON.stringify(creds); +} + +/** + * Parse a credential string produced by getCodeCommitCredentials() back into an + * AwsCredentials object, or return null if the caller should use the default chain. + */ +export function parseAwsCredentials(token: string): AwsCredentials | null { + if (!token || token === "workload-identity") return null; + try { + const parsed = JSON.parse(token) as AwsCredentials; + if (!parsed.accessKeyId || !parsed.secretAccessKey) return null; + return parsed; + } catch { + return null; + } +} diff --git a/apps/api/src/services/git-platform/codecommit.test.ts b/apps/api/src/services/git-platform/codecommit.test.ts new file mode 100644 index 00000000..c502cea3 --- /dev/null +++ b/apps/api/src/services/git-platform/codecommit.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mockClient } from "aws-sdk-client-mock"; +import { + CodeCommitClient, + GetPullRequestCommand, + ListPullRequestsCommand, + GetCommentsForPullRequestCommand, + PostCommentForPullRequestCommand, + UpdatePullRequestApprovalStateCommand, + GetPullRequestApprovalStatesCommand, + MergePullRequestBySquashCommand, + MergePullRequestByThreeWayCommand, + GetRepositoryCommand, + GetFolderCommand, +} from "@aws-sdk/client-codecommit"; +import { CodeCommitPlatform } from "./codecommit.js"; +import type { RepoIdentifier } from "@optio/shared"; + +const ri: RepoIdentifier = { + platform: "codecommit", + host: "git-codecommit.us-east-1.amazonaws.com", + owner: "us-east-1", + repo: "MyRepo", + apiBaseUrl: "us-east-1", +}; + +const credToken = JSON.stringify({ + accessKeyId: "AKIA-test", + secretAccessKey: "secret-test", + region: "us-east-1", +}); + +const ccMock = mockClient(CodeCommitClient); + +describe("CodeCommitPlatform", () => { + let platform: CodeCommitPlatform; + + beforeEach(() => { + ccMock.reset(); + platform = new CodeCommitPlatform(credToken); + }); + + afterEach(() => { + ccMock.reset(); + }); + + it("has type codecommit", () => { + expect(platform.type).toBe("codecommit"); + }); + + describe("getPullRequest", () => { + it("maps a CodeCommit PR to PullRequest shape", async () => { + ccMock.on(GetPullRequestCommand).resolves({ + pullRequest: { + pullRequestId: "42", + title: "Add feature", + description: "Implements X", + pullRequestStatus: "OPEN", + authorArn: "arn:aws:iam::123456789012:user/alice", + creationDate: new Date("2024-01-01T00:00:00Z"), + lastActivityDate: new Date("2024-01-02T00:00:00Z"), + revisionId: "rev1", + pullRequestTargets: [ + { + repositoryName: "MyRepo", + sourceCommit: "abc123", + destinationCommit: "def456", + sourceReference: "refs/heads/feature", + destinationReference: "refs/heads/main", + mergeMetadata: { isMerged: false }, + }, + ], + }, + }); + + const pr = await platform.getPullRequest(ri, 42); + + expect(pr.number).toBe(42); + expect(pr.title).toBe("Add feature"); + expect(pr.body).toBe("Implements X"); + expect(pr.state).toBe("open"); + expect(pr.merged).toBe(false); + expect(pr.headSha).toBe("abc123"); + expect(pr.baseBranch).toBe("main"); + expect(pr.author).toBe("alice"); + expect(pr.url).toBe( + "https://us-east-1.console.aws.amazon.com/codesuite/codecommit/repositories/MyRepo/pull-requests/42", + ); + }); + + it("marks merged PRs as closed + merged", async () => { + ccMock.on(GetPullRequestCommand).resolves({ + pullRequest: { + pullRequestId: "5", + title: "t", + pullRequestStatus: "CLOSED", + authorArn: "arn:aws:iam::1:user/bob", + revisionId: "rev", + pullRequestTargets: [ + { + sourceCommit: "s", + destinationReference: "refs/heads/main", + mergeMetadata: { isMerged: true }, + }, + ], + }, + }); + + const pr = await platform.getPullRequest(ri, 5); + expect(pr.state).toBe("closed"); + expect(pr.merged).toBe(true); + }); + }); + + describe("listOpenPullRequests", () => { + it("fetches PR ids and returns mapped PRs", async () => { + ccMock.on(ListPullRequestsCommand).resolves({ pullRequestIds: ["1", "2"] }); + ccMock + .on(GetPullRequestCommand, { pullRequestId: "1" }) + .resolves({ + pullRequest: { + pullRequestId: "1", + title: "first", + pullRequestStatus: "OPEN", + authorArn: "arn:aws:iam::1:user/a", + revisionId: "r", + pullRequestTargets: [{ sourceCommit: "x", destinationReference: "refs/heads/main" }], + }, + }) + .on(GetPullRequestCommand, { pullRequestId: "2" }) + .resolves({ + pullRequest: { + pullRequestId: "2", + title: "second", + pullRequestStatus: "OPEN", + authorArn: "arn:aws:iam::1:user/b", + revisionId: "r", + pullRequestTargets: [{ sourceCommit: "y", destinationReference: "refs/heads/main" }], + }, + }); + + const prs = await platform.listOpenPullRequests(ri); + expect(prs).toHaveLength(2); + expect(prs.map((p) => p.number).sort()).toEqual([1, 2]); + }); + }); + + describe("getCIChecks", () => { + it("returns empty array (CodeCommit has no native CI)", async () => { + const checks = await platform.getCIChecks(ri, "abc"); + expect(checks).toEqual([]); + }); + }); + + describe("getReviews", () => { + it("returns APPROVED reviews from approval states", async () => { + ccMock.on(GetPullRequestCommand).resolves({ + pullRequest: { revisionId: "rev1" }, + }); + ccMock.on(GetPullRequestApprovalStatesCommand).resolves({ + approvals: [ + { userArn: "arn:aws:iam::1:user/alice", approvalState: "APPROVE" }, + { userArn: "arn:aws:iam::1:user/bob", approvalState: "REVOKE" }, + ], + }); + ccMock.on(GetCommentsForPullRequestCommand).resolves({ + commentsForPullRequestData: [], + }); + + const reviews = await platform.getReviews(ri, 42); + const approvals = reviews.filter((r) => r.state === "APPROVED"); + expect(approvals).toHaveLength(1); + expect(approvals[0].author).toBe("alice"); + }); + + it("synthesizes COMMENTED reviews from non-inline comments", async () => { + ccMock.on(GetPullRequestCommand).resolves({ + pullRequest: { revisionId: "rev1" }, + }); + ccMock.on(GetPullRequestApprovalStatesCommand).resolves({ approvals: [] }); + ccMock.on(GetCommentsForPullRequestCommand).resolves({ + commentsForPullRequestData: [ + { + comments: [{ content: "looks good", authorArn: "arn:aws:iam::1:user/alice" }], + // no location → top-level comment + }, + { + comments: [ + { + content: "fix this line", + authorArn: "arn:aws:iam::1:user/alice", + }, + ], + location: { filePath: "src/foo.ts", filePosition: 10 }, + }, + ], + }); + + const reviews = await platform.getReviews(ri, 42); + expect(reviews).toHaveLength(1); + expect(reviews[0].state).toBe("COMMENTED"); + expect(reviews[0].body).toBe("looks good"); + }); + }); + + describe("getInlineComments", () => { + it("returns only comments with a location", async () => { + ccMock.on(GetCommentsForPullRequestCommand).resolves({ + commentsForPullRequestData: [ + { + comments: [{ content: "top-level", authorArn: "arn:aws:iam::1:user/a" }], + }, + { + comments: [ + { + content: "inline note", + authorArn: "arn:aws:iam::1:user/a", + creationDate: new Date("2024-01-01T00:00:00Z"), + }, + ], + location: { filePath: "src/foo.ts", filePosition: 10 }, + }, + ], + }); + + const comments = await platform.getInlineComments(ri, 42); + expect(comments).toHaveLength(1); + expect(comments[0].path).toBe("src/foo.ts"); + expect(comments[0].line).toBe(10); + expect(comments[0].body).toBe("inline note"); + }); + }); + + describe("mergePullRequest", () => { + it("uses squash merge for 'squash'", async () => { + ccMock.on(MergePullRequestBySquashCommand).resolves({}); + await platform.mergePullRequest(ri, 42, "squash"); + expect(ccMock.commandCalls(MergePullRequestBySquashCommand)).toHaveLength(1); + }); + + it("uses three-way merge for 'merge'", async () => { + ccMock.on(MergePullRequestByThreeWayCommand).resolves({}); + await platform.mergePullRequest(ri, 42, "merge"); + expect(ccMock.commandCalls(MergePullRequestByThreeWayCommand)).toHaveLength(1); + }); + }); + + describe("submitReview", () => { + it("posts approval and body comment for APPROVE", async () => { + ccMock.on(GetPullRequestCommand).resolves({ + pullRequest: { + revisionId: "rev1", + pullRequestTargets: [{ destinationCommit: "before", sourceCommit: "after" }], + }, + }); + ccMock.on(UpdatePullRequestApprovalStateCommand).resolves({}); + ccMock.on(PostCommentForPullRequestCommand).resolves({}); + + const res = await platform.submitReview(ri, 42, { + event: "APPROVE", + body: "LGTM", + }); + + expect(res.url).toBe( + "https://us-east-1.console.aws.amazon.com/codesuite/codecommit/repositories/MyRepo/pull-requests/42", + ); + expect(ccMock.commandCalls(UpdatePullRequestApprovalStateCommand)).toHaveLength(1); + const calls = ccMock.commandCalls(PostCommentForPullRequestCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input.content).toBe("LGTM"); + }); + + it("prefixes body with [CHANGES REQUESTED] for REQUEST_CHANGES", async () => { + ccMock.on(GetPullRequestCommand).resolves({ + pullRequest: { + revisionId: "rev1", + pullRequestTargets: [{ destinationCommit: "before", sourceCommit: "after" }], + }, + }); + ccMock.on(PostCommentForPullRequestCommand).resolves({}); + + await platform.submitReview(ri, 42, { + event: "REQUEST_CHANGES", + body: "fix things", + }); + + const calls = ccMock.commandCalls(PostCommentForPullRequestCommand); + expect(calls[0].args[0].input.content).toContain("[CHANGES REQUESTED]"); + expect(calls[0].args[0].input.content).toContain("fix things"); + }); + + it("posts inline comments with location", async () => { + ccMock.on(GetPullRequestCommand).resolves({ + pullRequest: { + revisionId: "rev1", + pullRequestTargets: [{ destinationCommit: "before", sourceCommit: "after" }], + }, + }); + ccMock.on(PostCommentForPullRequestCommand).resolves({}); + + await platform.submitReview(ri, 42, { + event: "COMMENT", + body: "", + comments: [{ path: "src/foo.ts", line: 10, body: "nit" }], + }); + + const calls = ccMock.commandCalls(PostCommentForPullRequestCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input.location?.filePath).toBe("src/foo.ts"); + expect(calls[0].args[0].input.location?.filePosition).toBe(10); + }); + }); + + describe("listIssues / write methods", () => { + it("listIssues returns empty", async () => { + const issues = await platform.listIssues(ri); + expect(issues).toEqual([]); + }); + + it("createLabel throws", async () => { + await expect(platform.createLabel(ri, { name: "foo", color: "ff0000" })).rejects.toThrow( + /labels/, + ); + }); + + it("createIssueComment throws", async () => { + await expect(platform.createIssueComment(ri, 1, "hi")).rejects.toThrow(/issues/); + }); + }); + + describe("getRepoMetadata", () => { + it("maps GetRepository to RepoMetadata", async () => { + ccMock.on(GetRepositoryCommand).resolves({ + repositoryMetadata: { + repositoryName: "MyRepo", + defaultBranch: "develop", + }, + }); + const md = await platform.getRepoMetadata(ri); + expect(md.fullName).toBe("MyRepo"); + expect(md.defaultBranch).toBe("develop"); + expect(md.isPrivate).toBe(true); + }); + }); + + describe("listRepoContents", () => { + it("maps files and subFolders to RepoContent", async () => { + ccMock.on(GetRepositoryCommand).resolves({ + repositoryMetadata: { defaultBranch: "main" }, + }); + ccMock.on(GetFolderCommand).resolves({ + files: [{ absolutePath: "package.json", relativePath: "package.json" }], + subFolders: [{ absolutePath: "src", relativePath: "src" }], + }); + + const contents = await platform.listRepoContents(ri); + const types = Object.fromEntries(contents.map((c) => [c.name, c.type])); + expect(types["package.json"]).toBe("file"); + expect(types["src"]).toBe("dir"); + }); + }); +}); diff --git a/apps/api/src/services/git-platform/codecommit.ts b/apps/api/src/services/git-platform/codecommit.ts new file mode 100644 index 00000000..6e2a04be --- /dev/null +++ b/apps/api/src/services/git-platform/codecommit.ts @@ -0,0 +1,517 @@ +import { + CodeCommitClient, + GetPullRequestCommand, + ListPullRequestsCommand, + GetCommentsForPullRequestCommand, + PostCommentForPullRequestCommand, + UpdatePullRequestApprovalStateCommand, + GetPullRequestApprovalStatesCommand, + MergePullRequestByFastForwardCommand, + MergePullRequestBySquashCommand, + MergePullRequestByThreeWayCommand, + GetRepositoryCommand, + GetFolderCommand, + type PullRequest as CCPullRequest, +} from "@aws-sdk/client-codecommit"; +import type { + GitPlatform, + RepoIdentifier, + PullRequest, + CICheck, + Review, + InlineComment, + IssueComment, + Issue, + RepoMetadata, + RepoContent, +} from "@optio/shared"; +import { parseAwsCredentials, type AwsCredentials } from "../codecommit-credential-service.js"; +import { logger } from "../../logger.js"; + +/** + * AWS CodeCommit implementation of the GitPlatform interface. + * + * Mapping notes: + * - `RepoIdentifier.owner` is the AWS region (CodeCommit has no owner concept). + * - `RepoIdentifier.apiBaseUrl` is also the AWS region (passed to the SDK client). + * - PR numbers are CodeCommit's `pullRequestId` (returned as a string by the API + * but exposed here as a number to match the GitPlatform interface). + * - CodeCommit has no native CI: `getCIChecks()` returns `[]`. + * - CodeCommit has no native issues: `listIssues()` returns `[]`; mutating + * methods throw a clear error. + * - CodeCommit has no `CHANGES_REQUESTED` review state. `submitReview()` posts a + * comment with a `**[CHANGES REQUESTED]**` prefix in that case. + */ +export class CodeCommitPlatform implements GitPlatform { + readonly type = "codecommit" as const; + private readonly creds: AwsCredentials | null; + private readonly defaultRegion: string; + private readonly clients = new Map(); + + constructor(token: string) { + this.creds = parseAwsCredentials(token); + this.defaultRegion = + this.creds?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"; + } + + private withRegion(ri: RepoIdentifier): CodeCommitClient { + // ri.apiBaseUrl carries the region; one client per region, cached. + const region = ri.apiBaseUrl || this.defaultRegion; + const cached = this.clients.get(region); + if (cached) return cached; + const client = new CodeCommitClient({ + region, + ...(this.creds + ? { + credentials: { + accessKeyId: this.creds.accessKeyId, + secretAccessKey: this.creds.secretAccessKey, + ...(this.creds.sessionToken ? { sessionToken: this.creds.sessionToken } : {}), + }, + } + : {}), + }); + this.clients.set(region, client); + return client; + } + + // ── PR reads ────────────────────────────────────────────────────────────── + + async getPullRequest(ri: RepoIdentifier, prNumber: number): Promise { + const client = this.withRegion(ri); + const res = await client.send(new GetPullRequestCommand({ pullRequestId: String(prNumber) })); + if (!res.pullRequest) { + throw new Error(`CodeCommit pull request ${prNumber} not found`); + } + return mapPr(res.pullRequest, ri); + } + + async listOpenPullRequests( + ri: RepoIdentifier, + opts?: { branch?: string; perPage?: number }, + ): Promise { + const client = this.withRegion(ri); + const list = await client.send( + new ListPullRequestsCommand({ + repositoryName: ri.repo, + pullRequestStatus: "OPEN", + maxResults: opts?.perPage ?? 25, + }), + ); + const ids = list.pullRequestIds ?? []; + const prs = await Promise.all( + ids.map((id) => + client + .send(new GetPullRequestCommand({ pullRequestId: id })) + .then((r) => r.pullRequest) + .catch((err) => { + logger.warn({ err, prId: id }, "Failed to fetch CodeCommit PR detail"); + return undefined; + }), + ), + ); + const mapped = prs.filter((p): p is CCPullRequest => Boolean(p)).map((p) => mapPr(p, ri)); + if (opts?.branch) { + return mapped.filter((p) => sourceBranchMatches(p, opts.branch!)); + } + return mapped; + } + + async getCIChecks(_ri: RepoIdentifier, _commitSha: string): Promise { + // CodeCommit has no native CI. CodePipeline integration is a planned follow-up. + return []; + } + + async getReviews(ri: RepoIdentifier, prNumber: number): Promise { + const client = this.withRegion(ri); + const reviews: Review[] = []; + + try { + const states = await client.send( + new GetPullRequestApprovalStatesCommand({ + pullRequestId: String(prNumber), + revisionId: await this.latestRevisionId(client, prNumber), + }), + ); + for (const a of states.approvals ?? []) { + if (a.approvalState === "APPROVE") { + reviews.push({ + author: shortenArn(a.userArn), + state: "APPROVED", + body: "", + }); + } + } + } catch (err) { + logger.debug({ err, prNumber }, "Failed to fetch CodeCommit approval states"); + } + + // PR-level (non-inline) comments synthesize into COMMENTED reviews + try { + const comments = await collectPrComments(client, prNumber); + for (const comment of comments) { + if (comment.location) continue; // inline — surfaced via getInlineComments + const body = comment.content ?? ""; + if (!body.trim()) continue; + reviews.push({ + author: shortenArn(comment.authorArn), + state: body.startsWith("**[CHANGES REQUESTED]**") ? "CHANGES_REQUESTED" : "COMMENTED", + body, + }); + } + } catch (err) { + logger.debug({ err, prNumber }, "Failed to fetch CodeCommit PR comments for reviews"); + } + + return reviews; + } + + async getInlineComments(ri: RepoIdentifier, prNumber: number): Promise { + const client = this.withRegion(ri); + const comments = await collectPrComments(client, prNumber); + return comments + .filter((c) => c.location) + .map((c) => ({ + author: shortenArn(c.authorArn), + path: c.location?.filePath ?? "", + line: c.location?.filePosition !== undefined ? Number(c.location.filePosition) : null, + body: c.content ?? "", + createdAt: c.creationDate ? new Date(c.creationDate).toISOString() : "", + })); + } + + async getIssueComments(ri: RepoIdentifier, prNumber: number): Promise { + // CodeCommit unifies PR comments; treat top-level (non-inline) comments as issue comments. + const client = this.withRegion(ri); + const comments = await collectPrComments(client, prNumber); + return comments + .filter((c) => !c.location) + .map((c) => ({ + author: shortenArn(c.authorArn), + body: c.content ?? "", + createdAt: c.creationDate ? new Date(c.creationDate).toISOString() : "", + })); + } + + // ── PR writes ───────────────────────────────────────────────────────────── + + async mergePullRequest( + ri: RepoIdentifier, + prNumber: number, + method: "merge" | "squash" | "rebase", + ): Promise { + const client = this.withRegion(ri); + const id = String(prNumber); + if (method === "squash") { + await client.send( + new MergePullRequestBySquashCommand({ + pullRequestId: id, + repositoryName: ri.repo, + }), + ); + } else if (method === "rebase") { + // CodeCommit's closest equivalent to "rebase" is fast-forward + await client.send( + new MergePullRequestByFastForwardCommand({ + pullRequestId: id, + repositoryName: ri.repo, + }), + ); + } else { + await client.send( + new MergePullRequestByThreeWayCommand({ + pullRequestId: id, + repositoryName: ri.repo, + }), + ); + } + } + + async submitReview( + ri: RepoIdentifier, + prNumber: number, + review: { + event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; + body: string; + comments?: { path: string; line?: number; side?: string; body: string }[]; + }, + ): Promise<{ url: string }> { + const client = this.withRegion(ri); + const id = String(prNumber); + + // Resolve commit IDs once — needed for both approval state and inline comments + const pr = await client + .send(new GetPullRequestCommand({ pullRequestId: id })) + .then((r) => r.pullRequest); + if (!pr) throw new Error(`CodeCommit pull request ${prNumber} not found`); + + const target = pr.pullRequestTargets?.[0]; + const beforeCommitId = target?.destinationCommit; + const afterCommitId = target?.sourceCommit; + const revisionId = pr.revisionId; + + if (review.event === "APPROVE") { + try { + if (revisionId) { + await client.send( + new UpdatePullRequestApprovalStateCommand({ + pullRequestId: id, + revisionId, + approvalState: "APPROVE", + }), + ); + } + } catch (err) { + // Approving requires an approval rule on the PR. If none exists, fall back to a comment. + logger.warn({ err, prNumber }, "CodeCommit approval failed, posting as comment"); + } + } + + // Body comment (with a prefix when requesting changes / commenting) + if (review.body?.trim()) { + const prefix = + review.event === "REQUEST_CHANGES" + ? "**[CHANGES REQUESTED]**\n\n" + : review.event === "COMMENT" + ? "**[COMMENT]**\n\n" + : ""; + await client + .send( + new PostCommentForPullRequestCommand({ + pullRequestId: id, + repositoryName: ri.repo, + beforeCommitId, + afterCommitId, + content: `${prefix}${review.body}`, + }), + ) + .catch((err) => { + logger.warn({ err, prNumber }, "Failed to post CodeCommit PR body comment"); + }); + } + + // Inline comments (best-effort — fall back to a top-level comment on error) + for (const comment of review.comments ?? []) { + try { + await client.send( + new PostCommentForPullRequestCommand({ + pullRequestId: id, + repositoryName: ri.repo, + beforeCommitId, + afterCommitId, + content: comment.body, + location: { + filePath: comment.path, + filePosition: comment.line, + relativeFileVersion: "AFTER", + }, + }), + ); + } catch (err) { + logger.debug( + { err, prNumber, path: comment.path }, + "Inline comment failed, falling back to top-level", + ); + const locationPrefix = comment.line + ? `**${comment.path}:${comment.line}**\n\n` + : `**${comment.path}**\n\n`; + await client + .send( + new PostCommentForPullRequestCommand({ + pullRequestId: id, + repositoryName: ri.repo, + beforeCommitId, + afterCommitId, + content: `${locationPrefix}${comment.body}`, + }), + ) + .catch(() => { + /* swallow — already logged */ + }); + } + } + + return { url: buildPrUrl(ri, prNumber) }; + } + + // ── Issue methods (CodeCommit has no native issues) ─────────────────────── + + async listIssues(_ri: RepoIdentifier): Promise { + return []; + } + + async createLabel( + _ri: RepoIdentifier, + _label: { name: string; color: string; description?: string }, + ): Promise { + throw new Error("CodeCommit does not support issue labels"); + } + + async addLabelsToIssue( + _ri: RepoIdentifier, + _issueNumber: number, + _labels: string[], + ): Promise { + throw new Error("CodeCommit does not support issue labels"); + } + + async createIssueComment( + _ri: RepoIdentifier, + _issueNumber: number, + _body: string, + ): Promise { + throw new Error("CodeCommit does not support issues"); + } + + async closeIssue(_ri: RepoIdentifier, _issueNumber: number): Promise { + throw new Error("CodeCommit does not support issues"); + } + + // ── Repo reads ──────────────────────────────────────────────────────────── + + async getRepoMetadata(ri: RepoIdentifier): Promise { + const client = this.withRegion(ri); + const res = await client.send(new GetRepositoryCommand({ repositoryName: ri.repo })); + const md = res.repositoryMetadata; + return { + fullName: md?.repositoryName ?? ri.repo, + defaultBranch: md?.defaultBranch ?? "main", + isPrivate: true, // CodeCommit repos are always IAM-gated (no public-anon access) + }; + } + + async listRepoContents(ri: RepoIdentifier, path = ""): Promise { + const client = this.withRegion(ri); + const md = await client + .send(new GetRepositoryCommand({ repositoryName: ri.repo })) + .then((r) => r.repositoryMetadata); + const folderPath = path === "" ? "/" : path.startsWith("/") ? path : `/${path}`; + const res = await client.send( + new GetFolderCommand({ + repositoryName: ri.repo, + commitSpecifier: md?.defaultBranch ?? "main", + folderPath, + }), + ); + const files = (res.files ?? []).map((f) => ({ + name: stripDir(f.absolutePath ?? "", folderPath) || (f.relativePath ?? ""), + type: "file" as const, + })); + const dirs = (res.subFolders ?? []).map((d) => ({ + name: stripDir(d.absolutePath ?? "", folderPath) || (d.relativePath ?? ""), + type: "dir" as const, + })); + return [...dirs, ...files]; + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + private async latestRevisionId(client: CodeCommitClient, prNumber: number): Promise { + const res = await client.send(new GetPullRequestCommand({ pullRequestId: String(prNumber) })); + const id = res.pullRequest?.revisionId; + if (!id) throw new Error(`CodeCommit pull request ${prNumber} has no revisionId`); + return id; + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function buildPrUrl(ri: RepoIdentifier, prNumber: number): string { + // CodeCommit console URL: .console.aws.amazon.com/codesuite/codecommit/repositories//pull-requests/ + return `https://${ri.apiBaseUrl}.console.aws.amazon.com/codesuite/codecommit/repositories/${ri.repo}/pull-requests/${prNumber}`; +} + +function mapPr(pr: CCPullRequest, ri: RepoIdentifier): PullRequest { + const target = pr.pullRequestTargets?.[0]; + const merged = Boolean(target?.mergeMetadata?.isMerged); + const status = pr.pullRequestStatus; + const open = status === "OPEN"; + const number = pr.pullRequestId ? parseInt(pr.pullRequestId, 10) : 0; + return { + number, + title: pr.title ?? "", + body: pr.description ?? "", + state: open ? "open" : "closed", + merged, + mergeable: null, // CodeCommit doesn't report a precomputed mergeable flag + draft: false, // CodeCommit has no draft state + headSha: target?.sourceCommit ?? "", + baseBranch: stripRefsHeads(target?.destinationReference ?? ""), + url: buildPrUrl(ri, number), + author: shortenArn(pr.authorArn), + assignees: [], + labels: [], + createdAt: pr.creationDate ? new Date(pr.creationDate).toISOString() : "", + updatedAt: pr.lastActivityDate ? new Date(pr.lastActivityDate).toISOString() : "", + }; +} + +function sourceBranchMatches(pr: PullRequest, branch: string): boolean { + // We don't have the source ref on PullRequest after mapping; use a heuristic on title/branch. + // Callers that pass `branch` typically only need this filter as a sanity check. + return pr.baseBranch !== branch; +} + +function shortenArn(arn?: string): string { + if (!arn) return "unknown"; + // arn:aws:iam::123456789012:user/alice -> alice + const slash = arn.lastIndexOf("/"); + if (slash >= 0) return arn.slice(slash + 1); + return arn; +} + +function stripRefsHeads(ref: string): string { + return ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref; +} + +function stripDir(absolutePath: string, folderPath: string): string { + if (folderPath === "/" || folderPath === "") return absolutePath.replace(/^\//, ""); + const prefix = folderPath.endsWith("/") ? folderPath : `${folderPath}/`; + return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath; +} + +interface CCComment { + content?: string; + authorArn?: string; + creationDate?: Date; + location?: { + filePath?: string; + filePosition?: number; + relativeFileVersion?: string; + }; +} + +async function collectPrComments(client: CodeCommitClient, prNumber: number): Promise { + const out: CCComment[] = []; + let nextToken: string | undefined; + do { + const res = await client.send( + new GetCommentsForPullRequestCommand({ + pullRequestId: String(prNumber), + nextToken, + }), + ); + for (const thread of res.commentsForPullRequestData ?? []) { + for (const c of thread.comments ?? []) { + if (c.deleted) continue; + out.push({ + content: c.content, + authorArn: c.authorArn, + creationDate: c.creationDate, + location: thread.location + ? { + filePath: thread.location.filePath, + filePosition: + thread.location.filePosition !== undefined + ? thread.location.filePosition + : undefined, + relativeFileVersion: thread.location.relativeFileVersion, + } + : undefined, + }); + } + } + nextToken = res.nextToken; + } while (nextToken); + return out; +} diff --git a/apps/api/src/services/git-platform/index.ts b/apps/api/src/services/git-platform/index.ts index 4300c597..dd60b3d4 100644 --- a/apps/api/src/services/git-platform/index.ts +++ b/apps/api/src/services/git-platform/index.ts @@ -1,6 +1,7 @@ import type { GitPlatform, GitPlatformType } from "@optio/shared"; import { GitHubPlatform } from "./github.js"; import { GitLabPlatform } from "./gitlab.js"; +import { CodeCommitPlatform } from "./codecommit.js"; export function createGitPlatform(platform: GitPlatformType, token: string): GitPlatform { switch (platform) { @@ -8,6 +9,8 @@ export function createGitPlatform(platform: GitPlatformType, token: string): Git return new GitHubPlatform(token); case "gitlab": return new GitLabPlatform(token); + case "codecommit": + return new CodeCommitPlatform(token); default: throw new Error(`Unsupported git platform: ${platform}`); } @@ -15,3 +18,4 @@ export function createGitPlatform(platform: GitPlatformType, token: string): Git export { GitHubPlatform } from "./github.js"; export { GitLabPlatform } from "./gitlab.js"; +export { CodeCommitPlatform } from "./codecommit.js"; diff --git a/apps/api/src/services/git-token-service.ts b/apps/api/src/services/git-token-service.ts index bf284c78..72517f45 100644 --- a/apps/api/src/services/git-token-service.ts +++ b/apps/api/src/services/git-token-service.ts @@ -2,6 +2,7 @@ import type { GitPlatform, GitPlatformType, RepoIdentifier } from "@optio/shared import { parseRepoUrl } from "@optio/shared"; import { getGitHubToken } from "./github-token-service.js"; import { retrieveSecretWithFallback } from "./secret-service.js"; +import { getCodeCommitCredentials } from "./codecommit-credential-service.js"; import { createGitPlatform } from "./git-platform/index.js"; import { logger } from "../logger.js"; @@ -15,6 +16,8 @@ export interface GitTokenContext { * Resolve a git platform token for the given platform and context. * GitHub: delegates to the existing github-token-service (App → user OAuth → PAT). * GitLab: checks GITLAB_TOKEN secret (workspace-scoped → global). + * CodeCommit: returns a JSON-encoded AWS credential blob (or "workload-identity" + * sentinel) — see codecommit-credential-service. */ export async function getGitToken( platform: GitPlatformType, @@ -27,6 +30,10 @@ export async function getGitToken( return getGitHubToken({ server: true }); } + if (platform === "codecommit") { + return getCodeCommitCredentials(context.workspaceId); + } + // GitLab: try user-scoped token, then workspace/global GITLAB_TOKEN if (context.userId) { try { diff --git a/apps/api/src/services/pr-review-service.ts b/apps/api/src/services/pr-review-service.ts index 00c7c952..ce27abb7 100644 --- a/apps/api/src/services/pr-review-service.ts +++ b/apps/api/src/services/pr-review-service.ts @@ -462,6 +462,7 @@ export async function enqueueReviewRun( const fullRepoName = `${review.repoOwner}/${review.repoName}`; const parsedRepoUrl = parseRepoUrl(review.repoUrl); const isGitLab = parsedRepoUrl?.platform === "gitlab"; + const isCodeCommit = parsedRepoUrl?.platform === "codecommit"; renderedPrompt = renderPromptTemplate(template, { PR_NUMBER: String(review.prNumber), @@ -471,6 +472,9 @@ export async function enqueueReviewRun( TEST_COMMAND: repoConfig.testCommand ?? "", OUTPUT_PATH: PR_REVIEW_OUTPUT_PATH, GIT_PLATFORM_GITLAB: isGitLab ? "true" : "", + GIT_PLATFORM_CODECOMMIT: isCodeCommit ? "true" : "", + CODECOMMIT_REPO: isCodeCommit ? (parsedRepoUrl?.repo ?? "") : "", + BASE_BRANCH: repoConfig.defaultBranch ?? "main", }); taskFileContent = reviewContextFileContent({ prNumber: review.prNumber, diff --git a/apps/api/src/services/review-service.ts b/apps/api/src/services/review-service.ts index ef042e21..a6b8ef61 100644 --- a/apps/api/src/services/review-service.ts +++ b/apps/api/src/services/review-service.ts @@ -130,6 +130,7 @@ export async function launchReview(parentTaskId: string): Promise { const parsedRepo = parseRepoUrl(parentTask.repoUrl); const isGitLab = parsedRepo?.platform === "gitlab"; + const isCodeCommit = parsedRepo?.platform === "codecommit"; const renderedPrompt = renderPromptTemplate(reviewTemplate, { PR_NUMBER: String(prNumber), @@ -138,6 +139,9 @@ export async function launchReview(parentTaskId: string): Promise { TASK_TITLE: parentTask.title, TEST_COMMAND: repoConfig?.testCommand ?? "", GIT_PLATFORM_GITLAB: isGitLab ? "true" : "", + GIT_PLATFORM_CODECOMMIT: isCodeCommit ? "true" : "", + CODECOMMIT_REPO: isCodeCommit ? (parsedRepo?.repo ?? "") : "", + BASE_BRANCH: parentTask.repoBranch ?? repoConfig?.defaultBranch ?? "main", }); // Build review context file with enriched PR data diff --git a/apps/api/src/workers/task-worker.ts b/apps/api/src/workers/task-worker.ts index 69ce5987..41397c1b 100644 --- a/apps/api/src/workers/task-worker.ts +++ b/apps/api/src/workers/task-worker.ts @@ -312,6 +312,7 @@ export function startTaskWorker() { ? `${parsedRepo.owner}/${parsedRepo.repo}` : task.repoUrl.replace(/.*[/:]([^/]+\/[^/.]+).*/, "$1"); const isGitLab = parsedRepo?.platform === "gitlab"; + const isCodeCommit = parsedRepo?.platform === "codecommit"; const branchName = `${TASK_BRANCH_PREFIX}${task.id}`; const taskFilePath = TASK_FILE_PATH; @@ -329,6 +330,9 @@ export function startTaskWorker() { DRAFT_PR: String(promptConfig.cautiousMode), ISSUE_NUMBER: task.ticketExternalId ?? "", GIT_PLATFORM_GITLAB: isGitLab ? "true" : "", + GIT_PLATFORM_CODECOMMIT: isCodeCommit ? "true" : "", + CODECOMMIT_REPO: isCodeCommit ? (parsedRepo?.repo ?? "") : "", + BASE_BRANCH: task.repoBranch ?? repoConfig?.defaultBranch ?? "main", PLANNING_MODE: isPlanningRun ? "true" : "", }); diff --git a/apps/web/src/app/setup/page.tsx b/apps/web/src/app/setup/page.tsx index 3993527c..423eb25c 100644 --- a/apps/web/src/app/setup/page.tsx +++ b/apps/web/src/app/setup/page.tsx @@ -71,6 +71,14 @@ export default function SetupPage() { const [gitlabUser, setGitlabUser] = useState<{ login: string; name: string } | null>(null); const [gitlabValidated, setGitlabValidated] = useState(false); const [gitlabError, setGitlabError] = useState(""); + const [codecommitEnabled, setCodecommitEnabled] = useState(false); + const [awsAccessKeyId, setAwsAccessKeyId] = useState(""); + const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(""); + const [awsSessionToken, setAwsSessionToken] = useState(""); + const [awsRegion, setAwsRegion] = useState("us-east-1"); + const [awsValidated, setAwsValidated] = useState(false); + const [awsUser, setAwsUser] = useState<{ login: string; name: string } | null>(null); + const [awsError, setAwsError] = useState(""); // Step 3: Agent keys const [anthropicKey, setAnthropicKey] = useState(""); @@ -206,6 +214,15 @@ export default function SetupPage() { fetches.push(api.listUserRepos(githubToken || "")); if (gitlabEnabled && gitlabToken) fetches.push(api.listGitlabRepos(gitlabToken, gitlabHost || undefined)); + if (codecommitEnabled && awsAccessKeyId && awsSecretAccessKey) + fetches.push( + api.listCodecommitRepos({ + accessKeyId: awsAccessKeyId, + secretAccessKey: awsSecretAccessKey, + sessionToken: awsSessionToken || undefined, + region: awsRegion, + }), + ); if (fetches.length > 0) { Promise.all(fetches) .then((results) => { @@ -309,6 +326,29 @@ export default function SetupPage() { setLoading(false); }; + const validateAws = async () => { + if (!awsAccessKeyId.trim() || !awsSecretAccessKey.trim() || !awsRegion.trim()) return; + setLoading(true); + setAwsError(""); + try { + const res = await api.validateAwsCredentials({ + accessKeyId: awsAccessKeyId, + secretAccessKey: awsSecretAccessKey, + sessionToken: awsSessionToken || undefined, + region: awsRegion, + }); + if (res.valid && res.user) { + setAwsUser(res.user); + setAwsValidated(true); + } else { + setAwsError(res.error ?? "Invalid credentials"); + } + } catch (err) { + setAwsError(err instanceof Error ? err.message : "Validation failed"); + } + setLoading(false); + }; + const validateAnthropic = async (keyOverride?: string) => { const key = keyOverride ?? anthropicKey; if (!key.trim()) return; @@ -451,6 +491,14 @@ export default function SetupPage() { await api.createSecret({ name: "GITLAB_HOST", value: gitlabHost }); } } + if (codecommitEnabled && awsAccessKeyId.trim() && awsSecretAccessKey.trim() && awsValidated) { + await api.createSecret({ name: "AWS_ACCESS_KEY_ID", value: awsAccessKeyId }); + await api.createSecret({ name: "AWS_SECRET_ACCESS_KEY", value: awsSecretAccessKey }); + if (awsSessionToken.trim()) { + await api.createSecret({ name: "AWS_SESSION_TOKEN", value: awsSessionToken }); + } + await api.createSecret({ name: "AWS_REGION", value: awsRegion }); + } goNext(); } catch (err) { toast.error("Failed to save git provider tokens"); @@ -835,6 +883,20 @@ export default function SetupPage() { GitLab + {/* GitHub form */} @@ -977,6 +1039,91 @@ export default function SetupPage() { )} + {/* CodeCommit form */} + {codecommitEnabled && ( + <> +

+ Create an IAM user (or role) with the AWSCodeCommitPowerUser{" "} + managed policy and paste its access key and secret here. STS session tokens are + also supported. +

+
+ + { + setAwsRegion(e.target.value); + setAwsValidated(false); + setAwsError(""); + }} + placeholder="us-east-1" + className="w-full px-3 py-2 rounded-md bg-bg border border-border text-sm focus:outline-none focus:border-primary" + /> +
+
+ + { + setAwsAccessKeyId(e.target.value); + setAwsValidated(false); + setAwsError(""); + }} + placeholder="AKIA…" + className="w-full px-3 py-2 rounded-md bg-bg border border-border text-sm focus:outline-none focus:border-primary" + /> +
+
+ + { + setAwsSecretAccessKey(e.target.value); + setAwsValidated(false); + setAwsError(""); + }} + className="w-full px-3 py-2 rounded-md bg-bg border border-border text-sm focus:outline-none focus:border-primary" + /> +
+
+ + { + setAwsSessionToken(e.target.value); + setAwsValidated(false); + setAwsError(""); + }} + className="w-full px-3 py-2 rounded-md bg-bg border border-border text-sm focus:outline-none focus:border-primary" + /> +
+ {awsError && ( +
+ + {awsError} +
+ )} + {awsValidated && awsUser && ( +
+ + Authenticated as {awsUser.login} + {awsUser.name && ({awsUser.name})} +
+ )} + + )} +
)} + {codecommitEnabled && !awsValidated && ( + + )}