diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..74b8045 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /workspace +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY packages ./packages +RUN pnpm install --frozen-lockfile +RUN pnpm build:packages + +FROM node:22-bookworm-slim + +WORKDIR /workspace +RUN apt-get update && apt-get install --yes --no-install-recommends git && rm -rf /var/lib/apt/lists/* +COPY --from=build /workspace /workspace +ENV NODE_ENV=production +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "packages/github-app/dist/server.js"] diff --git a/docs/github-app-manifest.json b/docs/github-app-manifest.json new file mode 100644 index 0000000..873666a --- /dev/null +++ b/docs/github-app-manifest.json @@ -0,0 +1,19 @@ +{ + "name": "CodeDecay", + "description": "Deterministic CodeDecay pull-request safety analysis.", + "url": "https://github.com/SubmuxHQ/CodeDecay", + "hook_attributes": { + "url": "https://codedecay-github-app-1093061541451.us-central1.run.app/github/webhooks", + "active": true + }, + "redirect_url": "https://github.com/SubmuxHQ/CodeDecay", + "public": false, + "default_permissions": { + "metadata": "read", + "contents": "read", + "pull_requests": "read", + "issues": "write", + "checks": "write" + }, + "default_events": ["pull_request"] +} diff --git a/docs/github-app.md b/docs/github-app.md index 956dbfc..f66600c 100644 --- a/docs/github-app.md +++ b/docs/github-app.md @@ -21,14 +21,15 @@ For the first hosted version, the app only runs deterministic PR analysis. It does not run project commands, deployment commands, LLM calls, model calls, or CodeDecayCloud services. -## GitHub App settings +## Create the GitHub App Create a GitHub App in the GitHub organization that will own the hosted app. -Set the webhook URL to: +Create the App from [`github-app-manifest.json`](./github-app-manifest.json) in +the GitHub App creation flow. Its webhook URL is the Cloud Run service URL: ```text -https:///github/webhooks +https://codedecay-github-app-1093061541451.us-central1.run.app/github/webhooks ``` Subscribe to these events: @@ -45,23 +46,43 @@ Use these repository permissions: The Issues permission is required because GitHub PR comments use the Issues API. -## Render deployment +## Google Cloud Run deployment -Create a Render Web Service connected to this repository. +The service is designed for Cloud Run in project +`project-7279e7f9-46cf-49d4-bbd`, region `us-central1`. Store each required +value in Secret Manager, never in a repository, command history, or GitHub +Actions secret. -Build command: +Create the secret containers, then add values from local files. Keep the +private key and webhook secret out of shell history and source control: ```bash -pnpm install --frozen-lockfile && pnpm --filter @submuxhq/codedecay-github-app build +gcloud secrets create codedecay-github-app-id +gcloud secrets create codedecay-github-app-private-key +gcloud secrets create codedecay-github-app-webhook-secret +gcloud secrets versions add codedecay-github-app-id --data-file=app-id.txt +gcloud secrets versions add codedecay-github-app-private-key --data-file=private-key.pem +gcloud secrets versions add codedecay-github-app-webhook-secret --data-file=webhook-secret.txt ``` -Start command: +Deploy the reviewed commit with Cloud Run. Keep a single instance during the +staging rollout so duplicate webhook handling cannot race across instances. ```bash -pnpm --filter @submuxhq/codedecay-github-app start +gcloud run deploy codedecay-github-app \ + --project=project-7279e7f9-46cf-49d4-bbd \ + --region=us-central1 \ + --source=. \ + --allow-unauthenticated \ + --min-instances=1 \ + --max-instances=1 \ + --memory=1Gi \ + --cpu=1 \ + --set-env-vars=NODE_ENV=production,GITHUB_WEBHOOK_PATH=/github/webhooks \ + --set-secrets=GITHUB_APP_ID=codedecay-github-app-id:latest,GITHUB_PRIVATE_KEY=codedecay-github-app-private-key:latest,GITHUB_WEBHOOK_SECRET=codedecay-github-app-webhook-secret:latest ``` -Required environment variables: +Cloud Run receives these required environment variables from Secret Manager: ```text GITHUB_APP_ID= diff --git a/packages/github-app/src/github/comments.ts b/packages/github-app/src/github/comments.ts index 8a9b5ae..8dc4a5c 100644 --- a/packages/github-app/src/github/comments.ts +++ b/packages/github-app/src/github/comments.ts @@ -33,12 +33,20 @@ async function findExistingComment( octokit: GitHubClient, target: GitHubTarget ): Promise { - const response = await octokit.rest.issues.listComments({ - owner: target.owner, - repo: target.repo, - issue_number: target.pullNumber, - per_page: 100 - }); + for (let page = 1; page <= 10; page += 1) { + const response = await octokit.rest.issues.listComments({ + owner: target.owner, + repo: target.repo, + issue_number: target.pullNumber, + per_page: 100, + page + }); + + const existing = response.data.find((comment) => comment.body?.includes(COMMENT_MARKER)); + if (existing || response.data.length < 100) { + return existing; + } + } - return response.data.find((comment) => comment.body?.includes(COMMENT_MARKER)); + return undefined; } diff --git a/packages/github-app/src/server.ts b/packages/github-app/src/server.ts index e14c4c9..d67194d 100644 --- a/packages/github-app/src/server.ts +++ b/packages/github-app/src/server.ts @@ -24,8 +24,10 @@ createServer((request, response) => { } void Promise.resolve(middleware(request, response)).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`CodeDecay GitHub App request failed: ${message}\n`); response.writeHead(500, { "content-type": "application/json" }); - response.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })); + response.end(JSON.stringify({ ok: false, error: "Internal server error" })); }); }).listen(port, () => { process.stdout.write(`CodeDecay GitHub App listening on port ${port} at ${webhooksPath}\n`); diff --git a/packages/github-app/test/github-app.test.ts b/packages/github-app/test/github-app.test.ts index 4eb0ab0..5dec0f7 100644 --- a/packages/github-app/test/github-app.test.ts +++ b/packages/github-app/test/github-app.test.ts @@ -80,6 +80,26 @@ describe("CodeDecay GitHub App", () => { expect(octokit.rest.issues.createComment).not.toHaveBeenCalled(); }); + it("finds a marker comment beyond the first page", async () => { + const octokit = createOctokit(); + const firstPage = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, body: "other comment" })); + vi.mocked(octokit.rest.issues.listComments) + .mockResolvedValueOnce({ data: firstPage }) + .mockResolvedValueOnce({ data: [{ id: 101, body: `${COMMENT_MARKER}\nOld report` }] }); + + await upsertPullRequestComment({ + octokit, + target: { owner: "SubmuxHQ", repo: "CodeDecay", pullNumber: 12, headSha: "head-sha" }, + markdown: "## New report" + }); + + expect(octokit.rest.issues.listComments).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ page: 2 }) + ); + expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(expect.objectContaining({ comment_id: 101 })); + }); + it("marks the check run as failed when analysis fails", async () => { const context = createContext(); const cleanup = vi.fn();