diff --git a/.gitignore b/.gitignore index 73bad890..1d29c36f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +!__tests__/.env.example !docker/env/.env.neo4j.example !docker/env/.env.worker.example diff --git a/__tests__/.env.example b/__tests__/.env.example new file mode 100644 index 00000000..f672360b --- /dev/null +++ b/__tests__/.env.example @@ -0,0 +1,19 @@ +# Neo4j Test Database Configuration +# Copy this file to .env and fill in your test database credentials +# IMPORTANT: Use a separate test database, not your production/development database! + +# Neo4j connection URI +# Example: bolt://localhost:7687 +NEO4J_URI= + +# Neo4j username +# Example: neo4j +NEO4J_USER= + +# Neo4j password +NEO4J_PASSWORD= + +# Optional: Neo4j Database Name +# If using Neo4j Enterprise/AuraDB, you can specify a database name +# Default is "neo4j" +# NEO4J_DATABASE=neo4j-test diff --git a/__tests__/README.md b/__tests__/README.md index 1f11b282..94ab8df2 100644 --- a/__tests__/README.md +++ b/__tests__/README.md @@ -31,9 +31,54 @@ pnpm test:agent npx jest __tests__/llm-lint.llm.test.ts -t "test name" ``` +## Neo4j Integration Tests + +Neo4j integration tests require a running Neo4j database. **IMPORTANT: Use a separate test database, not your production or development database!** + +### Setup + +1. **Create test environment file:** + + ```bash + cp __tests__/.env.example __tests__/.env + ``` + +2. **Configure test database credentials:** + Edit `__tests__/.env` and fill in your test Neo4j database credentials: + + ```env + NEO4J_URI=bolt://localhost:7687 + NEO4J_USER=neo4j + NEO4J_PASSWORD=your-test-password + ``` + +3. **Ensure Neo4j is running:** + - If using Docker: + ```bash + docker run -d \ + --name neo4j-test \ + -p 7687:7687 -p 7474:7474 \ + -e NEO4J_AUTH=neo4j/your-test-password \ + neo4j:latest + ``` + - Or use a local Neo4j installation pointing to a test database + +### Running Neo4j Tests + +```bash +pnpm test:neo4j +``` + +### Test Database Isolation + +- Tests use hardcoded test data IDs (prefixed with `test-` or `test-prog-`) +- Cleanup functions remove only these specific test nodes +- Tests are designed to be idempotent and can be run multiple times + ## Environment Variables -Agent tests automatically load environment variables from `.env.local` during test setup. Make sure your `.env.local` file contains all required variables for agent tests. +- **Agent tests**: Automatically load environment variables from `.env.local` (at project root) during test setup. Make sure your `.env.local` file contains all required variables for agent tests. +- **Neo4j integration tests**: Load environment variables from `__tests__/.env` for database configuration. This allows tests to use a separate test database. ## Notes diff --git a/__tests__/api/webhook/github.route.test.ts b/__tests__/api/webhook/github.route.test.ts deleted file mode 100644 index 5f6d6973..00000000 --- a/__tests__/api/webhook/github.route.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @jest-environment node - */ -import crypto from "crypto" -import { NextRequest } from "next/server" - -jest.mock( - "@/lib/webhook/github/handlers/installation/revalidateRepositoriesCache.handler", - () => ({ - revalidateUserInstallationReposCache: jest.fn(), - }) -) -jest.mock("@/lib/webhook/github/handlers/issue/label.resolve.handler", () => ({ - handleIssueLabelResolve: jest.fn(), -})) -jest.mock("@/lib/webhook/github/handlers/pullRequest/closed.removeContainer.handler", () => ({ - handlePullRequestClosedRemoveContainer: jest.fn(), -})) -jest.mock("@/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler", () => ({ - handlePullRequestLabelCreateDependentPR: jest.fn(), -})) -jest.mock("@/lib/webhook/github/handlers/repository/edited.revalidateRepoCache.handler", () => ({ - handleRepositoryEditedRevalidate: jest.fn(), -})) -import { POST } from "@/app/api/webhook/github/route" -import { handleIssueLabelAutoResolve } from "@/lib/webhook/github/handlers/issue/label.autoResolveIssue.handler" -import { handlePullRequestLabelCreateDependentPR } from "@/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler" - -jest.mock("@/lib/webhook/github/handlers/issue/label.autoResolveIssue.handler", () => ({ - handleIssueLabelAutoResolve: jest.fn(), -})) - -describe("POST /api/webhook/github", () => { - const secret = "test-secret" - const originalSecret = process.env.GITHUB_WEBHOOK_SECRET - - beforeEach(() => { - jest.resetAllMocks() - process.env.GITHUB_WEBHOOK_SECRET = secret - }) - - afterAll(() => { - process.env.GITHUB_WEBHOOK_SECRET = originalSecret - }) - - it("routes i2pr resolve issue label payloads to the auto-resolve handler", async () => { - const payload = { - action: "labeled", - label: { name: "i2pr: resolve issue" }, - repository: { full_name: "octo-org/octo-repo" }, - issue: { number: 42 }, - sender: { login: "octocat" }, - installation: { id: 9876 }, - } - - const rawBody = Buffer.from(JSON.stringify(payload)) - const signature = - "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex") - - const headers = new Headers({ - "x-hub-signature-256": signature, - "x-github-event": "issues", - }) - - const mockRequest = { - headers, - arrayBuffer: jest.fn().mockResolvedValue(rawBody), - } as unknown as NextRequest - - const response = await POST(mockRequest) - - expect(response.status).toBe(200) - - expect(handleIssueLabelAutoResolve).toHaveBeenCalledTimes(1) - const callArgs = jest.mocked(handleIssueLabelAutoResolve).mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - expect(callArgs.installationId).toBe(String(payload.installation.id)) - expect(callArgs.payload.repository.full_name).toBe(payload.repository.full_name) - expect(callArgs.payload.issue.number).toBe(payload.issue.number) - expect(callArgs.payload.sender.login).toBe(payload.sender.login) - }) - - it("routes PR label 'I2PR: Update PR' payloads to createDependentPR handler", async () => { - const payload = { - action: "labeled", - number: 42, - label: { name: "I2PR: Update PR" }, - sender: { login: "octocat" }, - pull_request: { merged: false, head: { ref: "feature/foo" }, number: 42 }, - repository: { name: "repo", owner: { login: "owner" } }, - installation: { id: 9876 }, - } - - const rawBody = Buffer.from(JSON.stringify(payload)) - const signature = - "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex") - - const headers = new Headers({ - "x-hub-signature-256": signature, - "x-github-event": "pull_request", - }) - - const mockRequest = { - headers, - arrayBuffer: jest.fn().mockResolvedValue(rawBody), - } as unknown as NextRequest - - const response = await POST(mockRequest) - - expect(response.status).toBe(200) - - expect(handlePullRequestLabelCreateDependentPR).toHaveBeenCalledTimes(1) - const callArgs = jest.mocked(handlePullRequestLabelCreateDependentPR).mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - expect(callArgs.installationId).toBe(String(payload.installation.id)) - expect(callArgs.payload.number).toBe(payload.number) - expect(callArgs.payload.label?.name).toBe(payload.label.name) - expect(callArgs.payload.sender?.login).toBe(payload.sender.login) - }) -}) \ No newline at end of file diff --git a/__tests__/api/webhook/github/route.test.ts b/__tests__/api/webhook/github/route.test.ts new file mode 100644 index 00000000..fa3c9553 --- /dev/null +++ b/__tests__/api/webhook/github/route.test.ts @@ -0,0 +1,640 @@ +/** + * @jest-environment node + */ +import crypto from "crypto" +import { NextRequest } from "next/server" + +jest.mock( + "@/lib/webhook/github/handlers/installation/revalidateRepositoriesCache.handler", + () => ({ + revalidateUserInstallationReposCache: jest.fn(), + }) +) +jest.mock("@/lib/webhook/github/handlers/issue/label.resolve.handler", () => ({ + handleIssueLabelResolve: jest.fn(), +})) +jest.mock( + "@/lib/webhook/github/handlers/pullRequest/closed.removeContainer.handler", + () => ({ + handlePullRequestClosedRemoveContainer: jest.fn(), + }) +) +jest.mock( + "@/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler", + () => ({ + handlePullRequestLabelCreateDependentPR: jest.fn(), + }) +) +jest.mock( + "@/lib/webhook/github/handlers/repository/edited.revalidateRepoCache.handler", + () => ({ + handleRepositoryEditedRevalidate: jest.fn(), + }) +) +jest.mock( + "@/lib/webhook/github/handlers/issue/label.autoResolveIssue.handler", + () => ({ + handleIssueLabelAutoResolve: jest.fn(), + }) +) + +import { POST } from "@/app/api/webhook/github/route" +import { revalidateUserInstallationReposCache } from "@/lib/webhook/github/handlers/installation/revalidateRepositoriesCache.handler" +import { handleIssueLabelAutoResolve } from "@/lib/webhook/github/handlers/issue/label.autoResolveIssue.handler" +import { handleIssueLabelResolve } from "@/lib/webhook/github/handlers/issue/label.resolve.handler" +import { handlePullRequestClosedRemoveContainer } from "@/lib/webhook/github/handlers/pullRequest/closed.removeContainer.handler" +import { handlePullRequestLabelCreateDependentPR } from "@/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler" +import { handleRepositoryEditedRevalidate } from "@/lib/webhook/github/handlers/repository/edited.revalidateRepoCache.handler" + +describe("POST /api/webhook/github", () => { + const secret = "test-secret" + const originalSecret = process.env.GITHUB_WEBHOOK_SECRET + + beforeEach(() => { + jest.resetAllMocks() + process.env.GITHUB_WEBHOOK_SECRET = secret + }) + + afterAll(() => { + process.env.GITHUB_WEBHOOK_SECRET = originalSecret + }) + + // Helper function to create signed requests + function createSignedRequest( + payload: object, + eventType: string, + options: { invalidSignature?: boolean; noSignature?: boolean } = {} + ): NextRequest { + const rawBody = Buffer.from(JSON.stringify(payload)) + + let signature: string | undefined + if (!options.noSignature) { + if (options.invalidSignature) { + signature = "sha256=invalid_signature_here" + } else { + signature = + "sha256=" + + crypto.createHmac("sha256", secret).update(rawBody).digest("hex") + } + } + + const headers = new Headers({ + "x-github-event": eventType, + }) + if (signature) { + headers.set("x-hub-signature-256", signature) + } + + return { + headers, + arrayBuffer: jest.fn().mockResolvedValue(rawBody), + } as unknown as NextRequest + } + + describe("Security & Validation", () => { + it("returns 500 when GITHUB_WEBHOOK_SECRET is not configured", async () => { + delete process.env.GITHUB_WEBHOOK_SECRET + + const payload = { action: "labeled" } + const mockRequest = createSignedRequest(payload, "issues") + + const response = await POST(mockRequest) + + expect(response.status).toBe(500) + const text = await response.text() + expect(text).toBe("Webhook secret not configured") + }) + + it("returns 400 when x-github-event header is missing", async () => { + const payload = { action: "labeled" } + const rawBody = Buffer.from(JSON.stringify(payload)) + const signature = + "sha256=" + + crypto.createHmac("sha256", secret).update(rawBody).digest("hex") + + const headers = new Headers({ + "x-hub-signature-256": signature, + }) + // No x-github-event header + + const mockRequest = { + headers, + arrayBuffer: jest.fn().mockResolvedValue(rawBody), + } as unknown as NextRequest + + const response = await POST(mockRequest) + + expect(response.status).toBe(400) + const text = await response.text() + expect(text).toBe("Missing event header") + }) + + it("returns 401 when signature is invalid", async () => { + const payload = { action: "labeled" } + const mockRequest = createSignedRequest(payload, "issues", { + invalidSignature: true, + }) + + const response = await POST(mockRequest) + + expect(response.status).toBe(401) + const text = await response.text() + expect(text).toBe("Invalid signature") + }) + + it("returns 401 when signature is missing", async () => { + const payload = { action: "labeled" } + const mockRequest = createSignedRequest(payload, "issues", { + noSignature: true, + }) + + const response = await POST(mockRequest) + + expect(response.status).toBe(401) + const text = await response.text() + expect(text).toBe("Invalid signature") + }) + + it("returns 401 when signature does not start with sha256=", async () => { + const payload = { action: "labeled" } + const rawBody = Buffer.from(JSON.stringify(payload)) + + const headers = new Headers({ + "x-hub-signature-256": "sha1=wrong_algorithm", + "x-github-event": "issues", + }) + + const mockRequest = { + headers, + arrayBuffer: jest.fn().mockResolvedValue(rawBody), + } as unknown as NextRequest + + const response = await POST(mockRequest) + + expect(response.status).toBe(401) + const text = await response.text() + expect(text).toBe("Invalid signature") + }) + + it("returns 400 when event type is unsupported", async () => { + const payload = { action: "something" } + const mockRequest = createSignedRequest(payload, "unsupported_event") + + const response = await POST(mockRequest) + + expect(response.status).toBe(400) + const text = await response.text() + expect(text).toBe("Unsupported event") + }) + + it("returns 400 when issues payload is invalid", async () => { + const invalidPayload = { + action: "labeled", + // Missing required fields like repository, issue, sender + } + const mockRequest = createSignedRequest(invalidPayload, "issues") + + const response = await POST(mockRequest) + + expect(response.status).toBe(400) + const text = await response.text() + expect(text).toBe("Invalid payload") + }) + + it("returns 400 when issues payload is missing installation ID", async () => { + const payload = { + action: "labeled", + label: { name: "i2pr: resolve issue" }, + repository: { + id: 123, + node_id: "R_kgDOTest", + full_name: "octo-org/octo-repo", + name: "octo-repo", + owner: { login: "octo-org" }, + }, + issue: { number: 42 }, + sender: { id: 1001, login: "octocat" }, + // Missing installation field + } + const mockRequest = createSignedRequest(payload, "issues") + + const response = await POST(mockRequest) + + expect(response.status).toBe(400) + const text = await response.text() + // Schema validation catches missing installation before manual check + expect(text).toBe("Invalid payload") + }) + + it("returns 400 when pull_request payload is invalid", async () => { + const invalidPayload = { + action: "labeled", + // Missing required fields + } + const mockRequest = createSignedRequest(invalidPayload, "pull_request") + + const response = await POST(mockRequest) + + expect(response.status).toBe(400) + const text = await response.text() + expect(text).toBe("Invalid payload") + }) + }) + + describe("Issues Event Routing", () => { + it("routes i2pr resolve issue label payloads to the auto-resolve handler", async () => { + const payload = { + action: "labeled", + label: { name: "i2pr: resolve issue" }, + repository: { + id: 123, + node_id: "R_kgDOTest", + full_name: "octo-org/octo-repo", + name: "octo-repo", + owner: { login: "octo-org" }, + }, + issue: { number: 42 }, + sender: { id: 1001, login: "octocat" }, + installation: { id: 9876 }, + } + const mockRequest = createSignedRequest(payload, "issues") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + + expect(handleIssueLabelAutoResolve).toHaveBeenCalledTimes(1) + const callArgs = jest.mocked(handleIssueLabelAutoResolve).mock + .calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.installationId).toBe(String(payload.installation.id)) + expect(callArgs.payload.repository.full_name).toBe( + payload.repository.full_name + ) + expect(callArgs.payload.issue.number).toBe(payload.issue.number) + expect(callArgs.payload.sender.login).toBe(payload.sender.login) + }) + + it("routes resolve issue label payloads to the resolve handler", async () => { + const payload = { + action: "labeled", + label: { name: "resolve" }, + repository: { + id: 456, + node_id: "R_kgDOTest2", + full_name: "org/repo", + name: "repo", + owner: { login: "org" }, + }, + issue: { number: 99 }, + sender: { id: 2002, login: "user" }, + installation: { id: 5555 }, + } + const mockRequest = createSignedRequest(payload, "issues") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + + expect(handleIssueLabelResolve).toHaveBeenCalledTimes(1) + const callArgs = jest.mocked(handleIssueLabelResolve).mock.calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.installationId).toBe(String(payload.installation.id)) + expect(callArgs.payload.repository.full_name).toBe( + payload.repository.full_name + ) + expect(callArgs.payload.issue.number).toBe(payload.issue.number) + }) + + it("ignores issues labeled with unhandled labels", async () => { + const payload = { + action: "labeled", + label: { name: "bug" }, + repository: { + id: 123, + node_id: "R_kgDOTest", + full_name: "octo-org/octo-repo", + name: "octo-repo", + owner: { login: "octo-org" }, + }, + issue: { number: 42 }, + sender: { id: 1001, login: "octocat" }, + installation: { id: 9876 }, + } + const mockRequest = createSignedRequest(payload, "issues") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + expect(handleIssueLabelAutoResolve).not.toHaveBeenCalled() + expect(handleIssueLabelResolve).not.toHaveBeenCalled() + }) + + it("ignores issues with unhandled actions (opened, closed, etc.)", async () => { + const payload = { + action: "opened", + repository: { + id: 123, + node_id: "R_kgDOTest", + full_name: "octo-org/octo-repo", + name: "octo-repo", + owner: { login: "octo-org" }, + }, + issue: { number: 42 }, + sender: { id: 1001, login: "octocat" }, + installation: { id: 9876 }, + } + const mockRequest = createSignedRequest(payload, "issues") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + expect(handleIssueLabelAutoResolve).not.toHaveBeenCalled() + expect(handleIssueLabelResolve).not.toHaveBeenCalled() + }) + }) + + describe("Pull Request Event Routing", () => { + it("routes PR label 'I2PR: Update PR' payloads to createDependentPR handler", async () => { + const payload = { + action: "labeled", + number: 42, + label: { name: "I2PR: Update PR" }, + sender: { login: "octocat" }, + pull_request: { + merged: false, + head: { ref: "feature/foo" }, + number: 42, + }, + repository: { name: "repo", owner: { login: "owner" } }, + installation: { id: 9876 }, + } + const mockRequest = createSignedRequest(payload, "pull_request") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + + expect(handlePullRequestLabelCreateDependentPR).toHaveBeenCalledTimes(1) + const callArgs = jest.mocked(handlePullRequestLabelCreateDependentPR).mock + .calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.installationId).toBe(String(payload.installation.id)) + expect(callArgs.payload.number).toBe(payload.number) + expect(callArgs.payload.label?.name).toBe(payload.label.name) + expect(callArgs.payload.sender?.login).toBe(payload.sender.login) + }) + + it("routes closed+merged PR to removeContainer handler", async () => { + const payload = { + action: "closed", + number: 123, + sender: { login: "merger" }, + pull_request: { + merged: true, + head: { ref: "feature/test" }, + number: 123, + }, + repository: { name: "repo", owner: { login: "owner" } }, + installation: { id: 7777 }, + } + const mockRequest = createSignedRequest(payload, "pull_request") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + + expect(handlePullRequestClosedRemoveContainer).toHaveBeenCalledTimes(1) + const callArgs = jest.mocked(handlePullRequestClosedRemoveContainer).mock + .calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.payload.pull_request?.merged).toBe(true) + expect(callArgs.payload.number).toBe(payload.number) + }) + + it("does not call removeContainer handler when PR is closed but not merged", async () => { + const payload = { + action: "closed", + number: 456, + sender: { login: "closer" }, + pull_request: { + merged: false, + head: { ref: "feature/abandoned" }, + number: 456, + }, + repository: { name: "repo", owner: { login: "owner" } }, + installation: { id: 8888 }, + } + const mockRequest = createSignedRequest(payload, "pull_request") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + expect(handlePullRequestClosedRemoveContainer).not.toHaveBeenCalled() + }) + + it("ignores PRs labeled with unhandled labels", async () => { + const payload = { + action: "labeled", + number: 42, + label: { name: "enhancement" }, + sender: { login: "octocat" }, + pull_request: { + merged: false, + head: { ref: "feature/foo" }, + number: 42, + }, + repository: { name: "repo", owner: { login: "owner" } }, + installation: { id: 9876 }, + } + const mockRequest = createSignedRequest(payload, "pull_request") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + expect(handlePullRequestLabelCreateDependentPR).not.toHaveBeenCalled() + }) + + it("ignores PRs with unhandled actions (opened, synchronize, reopened)", async () => { + const payload = { + action: "opened", + number: 789, + sender: { login: "opener" }, + pull_request: { + merged: false, + head: { ref: "feature/new" }, + number: 789, + }, + repository: { name: "repo", owner: { login: "owner" } }, + installation: { id: 3333 }, + } + const mockRequest = createSignedRequest(payload, "pull_request") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + expect(handlePullRequestClosedRemoveContainer).not.toHaveBeenCalled() + expect(handlePullRequestLabelCreateDependentPR).not.toHaveBeenCalled() + }) + }) + + describe("Installation Event Routing", () => { + it("routes installation events to revalidate cache handler", async () => { + const payload = { + action: "created", + installation: { id: 12345 }, + sender: { login: "installer" }, + } + const mockRequest = createSignedRequest(payload, "installation") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + + expect(revalidateUserInstallationReposCache).toHaveBeenCalledTimes(1) + const callArgs = jest.mocked(revalidateUserInstallationReposCache).mock + .calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.installationId).toBe(String(payload.installation.id)) + }) + + it("routes installation_repositories events to revalidate cache handler", async () => { + const payload = { + action: "added", + installation: { id: 54321 }, + sender: { login: "repoAdder" }, + repositories_added: [{ id: 1, name: "repo1" }], + } + const mockRequest = createSignedRequest( + payload, + "installation_repositories" + ) + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + + expect(revalidateUserInstallationReposCache).toHaveBeenCalledTimes(1) + const callArgs = jest.mocked(revalidateUserInstallationReposCache).mock + .calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.installationId).toBe(String(payload.installation.id)) + }) + }) + + describe("Repository Event Routing", () => { + it("routes repository edited events to revalidate handler", async () => { + const payload = { + action: "edited", + repository: { + full_name: "owner/edited-repo", + }, + installation: { id: 1111 }, + } + const mockRequest = createSignedRequest(payload, "repository") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + + expect(handleRepositoryEditedRevalidate).toHaveBeenCalledTimes(1) + const callArgs = jest.mocked(handleRepositoryEditedRevalidate).mock + .calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.payload.repository.full_name).toBe( + payload.repository.full_name + ) + expect(callArgs.payload.action).toBe("edited") + }) + + it("returns 400 for repository events with unhandled actions", async () => { + const payload = { + action: "created", + repository: { + full_name: "owner/new-repo", + }, + installation: { id: 2222 }, + } + const mockRequest = createSignedRequest(payload, "repository") + + const response = await POST(mockRequest) + + // Schema only accepts "edited" action, so "created" fails validation + expect(response.status).toBe(400) + const text = await response.text() + expect(text).toBe("Invalid payload") + }) + }) + + describe("No-op Events", () => { + it("accepts push events without calling handlers", async () => { + const payload = { + ref: "refs/heads/main", + repository: { full_name: "owner/repo" }, + installation: { id: 5555 }, + } + const mockRequest = createSignedRequest(payload, "push") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + }) + + it("accepts create events without calling handlers", async () => { + const payload = { + ref: "feature/new", + ref_type: "branch", + repository: { full_name: "owner/repo" }, + installation: { id: 6666 }, + } + const mockRequest = createSignedRequest(payload, "create") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + }) + + it("accepts delete events without calling handlers", async () => { + const payload = { + ref: "feature/old", + ref_type: "branch", + repository: { full_name: "owner/repo" }, + installation: { id: 7777 }, + } + const mockRequest = createSignedRequest(payload, "delete") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + }) + + it("accepts status events without calling handlers", async () => { + const payload = { + state: "success", + context: "ci/test", + repository: { full_name: "owner/repo" }, + installation: { id: 8888 }, + } + const mockRequest = createSignedRequest(payload, "status") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + }) + + it("accepts issue_comment events without calling handlers", async () => { + const payload = { + action: "created", + comment: { id: 123 }, + issue: { number: 1 }, + repository: { full_name: "owner/repo" }, + installation: { id: 9999 }, + } + const mockRequest = createSignedRequest(payload, "issue_comment") + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + }) + }) +}) diff --git a/__tests__/shared/adapters/neo4j/StorageAdapter.neo4j.test.ts b/__tests__/shared/adapters/neo4j/StorageAdapter.neo4j.test.ts index 17b5c765..b6f2cb03 100644 --- a/__tests__/shared/adapters/neo4j/StorageAdapter.neo4j.test.ts +++ b/__tests__/shared/adapters/neo4j/StorageAdapter.neo4j.test.ts @@ -5,12 +5,13 @@ * They are designed to run against a local Neo4j instance with existing data. * * Setup: - * 1. Ensure Neo4j is running locally - * 2. Set environment variables in .env.local: + * 1. Ensure Neo4j test database is running + * 2. Copy __tests__/.env.example to __tests__/.env and configure: * - NEO4J_URI (e.g., bolt://localhost:7687) * - NEO4J_USER (e.g., neo4j) * - NEO4J_PASSWORD - * 3. Ensure your database has some workflow run data + * IMPORTANT: Use a separate test database, not production! + * 3. Ensure your test database has some workflow run data * * Run with: pnpm test:neo4j */ @@ -130,16 +131,16 @@ describe("StorageAdapter - Read Operations", () => { describe("workflow.run.listEvents", () => { it("should return empty array (placeholder implementation)", async () => { - const result = await adapter.workflow.run.listEvents("any-run-id") + const result = await adapter.workflow.events.list("any-run-id") expect(result).toEqual([]) }) it("should accept any run id without error", async () => { + await expect(adapter.workflow.events.list("test-run-1")).resolves.toEqual( + [] + ) await expect( - adapter.workflow.run.listEvents("test-run-1") - ).resolves.toEqual([]) - await expect( - adapter.workflow.run.listEvents("non-existent") + adapter.workflow.events.list("non-existent") ).resolves.toEqual([]) }) }) diff --git a/__tests__/shared/adapters/neo4j/queries.neo4j.test.ts b/__tests__/shared/adapters/neo4j/queries.neo4j.test.ts index 4a085e68..1c9fe427 100644 --- a/__tests__/shared/adapters/neo4j/queries.neo4j.test.ts +++ b/__tests__/shared/adapters/neo4j/queries.neo4j.test.ts @@ -5,9 +5,10 @@ * against a real Neo4j database with existing data. * * Setup: - * 1. Ensure Neo4j is running locally - * 2. Set environment variables in .env.local - * 3. Ensure your database has workflow runs with relationships + * 1. Ensure Neo4j test database is running + * 2. Copy __tests__/.env.example to __tests__/.env and configure test database credentials + * IMPORTANT: Use a separate test database, not production! + * 3. Ensure your test database has workflow runs with relationships * * Run with: pnpm test:neo4j */ diff --git a/__tests__/shared/adapters/neo4j/testUtils.ts b/__tests__/shared/adapters/neo4j/testUtils.ts index 7e114341..5e19123d 100644 --- a/__tests__/shared/adapters/neo4j/testUtils.ts +++ b/__tests__/shared/adapters/neo4j/testUtils.ts @@ -1,3 +1,6 @@ +import * as dotenv from "dotenv" +import * as path from "path" + import { createNeo4jDataSource, type Neo4jDataSource, @@ -8,9 +11,14 @@ import { * These utilities help set up and tear down test data in a local Neo4j instance */ +// Load test-specific environment variables from __tests__/.env +// This allows tests to use a separate test database +const testEnvPath = path.resolve(__dirname, "../../../.env") +dotenv.config({ path: testEnvPath }) + /** * Creates a Neo4j data source for testing - * Uses environment variables: NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD + * Uses environment variables from __tests__/.env: NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD */ export function createTestDataSource(): Neo4jDataSource { const uri = process.env.NEO4J_URI @@ -19,8 +27,9 @@ export function createTestDataSource(): Neo4jDataSource { if (!uri || !user || !password) { throw new Error( - "NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD must be set for integration tests. " + - "Create a .env.local file with these values pointing to your local Neo4j instance." + "NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD must be set for integration tests.\n" + + "Create a __tests__/.env file (use __tests__/.env.example as a template) with these values pointing to your TEST Neo4j database.\n" + + "IMPORTANT: Use a separate test database, not your production/development database!" ) } @@ -61,12 +70,55 @@ export async function cleanupTestData( const session = ds.getSession("WRITE") try { - // Delete workflow runs and their relationships created during tests + // Delete workflow runs and all related nodes created during tests + // This includes repositories, issues, commits, and actors that were created await session.run( ` + // Find all workflow runs to delete MATCH (wr:WorkflowRun) WHERE wr.id IN $runIds + + // Find related nodes + OPTIONAL MATCH (wr)-[:BASED_ON_REPOSITORY]->(repo:Repository) + OPTIONAL MATCH (wr)-[:BASED_ON_ISSUE]->(issue:Issue) + OPTIONAL MATCH (wr)-[:BASED_ON_COMMIT]->(commit:Commit) + OPTIONAL MATCH (wr)-[:INITIATED_BY]->(actor) + + // Only delete repositories/issues/commits/actors if they're ONLY connected to these test workflow runs + // First, detach delete the workflow run DETACH DELETE wr + + // Then check and delete orphaned nodes + WITH repo, issue, commit, actor + WHERE repo IS NOT NULL + WITH repo, issue, commit, actor + OPTIONAL MATCH (repo)<-[:BASED_ON_REPOSITORY]-(otherWr:WorkflowRun) + WITH repo, issue, commit, actor, count(otherWr) AS repoConnections + WHERE repoConnections = 0 + DETACH DELETE repo + + WITH issue, commit, actor + WHERE issue IS NOT NULL + WITH issue, commit, actor + OPTIONAL MATCH (issue)<-[:BASED_ON_ISSUE]-(otherWr2:WorkflowRun) + WITH issue, commit, actor, count(otherWr2) AS issueConnections + WHERE issueConnections = 0 + DETACH DELETE issue + + WITH commit, actor + WHERE commit IS NOT NULL + WITH commit, actor + OPTIONAL MATCH (commit)<-[:BASED_ON_COMMIT]-(otherWr3:WorkflowRun) + WITH commit, actor, count(otherWr3) AS commitConnections + WHERE commitConnections = 0 + DETACH DELETE commit + + WITH actor + WHERE actor IS NOT NULL + OPTIONAL MATCH (actor)<-[:INITIATED_BY]-(otherWr4:WorkflowRun) + WITH actor, count(otherWr4) AS actorConnections + WHERE actorConnections = 0 + DETACH DELETE actor `, { runIds: testRunIds } ) diff --git a/__tests__/shared/adapters/neo4j/workflowRuns.neo4j.test.ts b/__tests__/shared/adapters/neo4j/workflowRuns.neo4j.test.ts new file mode 100644 index 00000000..11d04506 --- /dev/null +++ b/__tests__/shared/adapters/neo4j/workflowRuns.neo4j.test.ts @@ -0,0 +1,412 @@ +/** + * Integration tests for Workflow Runs in Neo4j + * + * These tests verify that workflow runs can be created with minimal data + * and metadata can be attached progressively using handle methods: + * - handle.attach.repository() + * - handle.attach.issue() + * - handle.attach.actor() + * - handle.attach.commit() + * - handle.add.event() + * + * Setup: + * 1. Ensure Neo4j test database is running + * 2. Copy __tests__/.env.example to __tests__/.env and configure: + * - NEO4J_URI (e.g., bolt://localhost:7687) + * - NEO4J_USER (e.g., neo4j) + * - NEO4J_PASSWORD + * IMPORTANT: Use a separate test database, not production! + * + * Run with: pnpm test:neo4j + */ + +import { int } from "neo4j-driver" + +import { StorageAdapter } from "@/shared/adapters/neo4j/StorageAdapter" + +import { createTestDataSource, verifyConnection } from "./testUtils" + +// Hardcoded test data IDs for idempotent testing +const TEST_DATA = { + workflowRuns: [ + "test-workflow-run-minimal", + "test-workflow-run-full", + "test-workflow-run-events", + "test-workflow-run-step-by-step", + "test-workflow-run-backward-compat", + ], + repositories: ["test-prog-repo-1", "test-prog-repo-2"], + users: ["test-prog-user-1"], + githubUsers: ["test-prog-github-user-1"], + commits: ["test-prog-commit-1"], + issues: [ + { number: 42, repoFullName: "test/repo" }, + { number: 100, repoFullName: "test/prog-repo-2" }, + ], +} + +describe("Workflow Runs Tests", () => { + let dataSource: ReturnType + let adapter: StorageAdapter + + beforeAll(async () => { + dataSource = createTestDataSource() + await verifyConnection(dataSource) + adapter = new StorageAdapter(dataSource) + + // Clean up any existing test data before starting + await cleanupHardcodedTestData() + }) + + afterAll(async () => { + // Cleanup test data + await cleanupHardcodedTestData() + // Close the driver + await dataSource.getDriver().close() + }) + + /** + * Cleanup function that only deletes our specific hardcoded test data + */ + async function cleanupHardcodedTestData() { + const session = dataSource.getSession("WRITE") + try { + // Delete workflow runs + await session.run( + ` + MATCH (wr:WorkflowRun) + WHERE wr.id IN $runIds + DETACH DELETE wr + `, + { runIds: TEST_DATA.workflowRuns } + ) + + // Delete repositories + await session.run( + ` + MATCH (repo:Repository) + WHERE repo.id IN $repoIds + DETACH DELETE repo + `, + { repoIds: TEST_DATA.repositories } + ) + + // Delete users + await session.run( + ` + MATCH (user:User) + WHERE user.id IN $userIds + DETACH DELETE user + `, + { userIds: TEST_DATA.users } + ) + + // Delete GitHub users + await session.run( + ` + MATCH (ghUser:GithubUser) + WHERE ghUser.id IN $ghUserIds + DETACH DELETE ghUser + `, + { ghUserIds: TEST_DATA.githubUsers } + ) + + // Delete commits + await session.run( + ` + MATCH (commit:Commit) + WHERE commit.sha IN $commitShas + DETACH DELETE commit + `, + { commitShas: TEST_DATA.commits } + ) + + // Delete issues + for (const issue of TEST_DATA.issues) { + await session.run( + ` + MATCH (issue:Issue {number: $number, repoFullName: $repoFullName}) + DETACH DELETE issue + `, + { number: int(issue.number), repoFullName: issue.repoFullName } + ) + } + + // Delete events + await session.run( + ` + MATCH (event:Event) + WHERE event.id STARTS WITH 'test-prog-' + DETACH DELETE event + ` + ) + } finally { + await session.close() + } + } + + describe("Minimal workflow run creation", () => { + it("should create workflow run with only type", async () => { + const handle = await adapter.workflow.run.create({ + id: TEST_DATA.workflowRuns[0], + type: "resolveIssue", + }) + + expect(handle.run.id).toBe(TEST_DATA.workflowRuns[0]) + expect(handle.run.type).toBe("resolveIssue") + expect(handle.run.state).toBe("pending") + + // Verify only WorkflowRun node exists, no relationships + const session = dataSource.getSession("READ") + try { + const result = await session.run( + ` + MATCH (wr:WorkflowRun {id: $id}) + RETURN + COUNT { (wr)-[:BASED_ON_REPOSITORY]->() } AS repoCount, + COUNT { (wr)-[:BASED_ON_ISSUE]->() } AS issueCount, + COUNT { (wr)-[:INITIATED_BY]->() } AS actorCount + `, + { id: handle.run.id } + ) + + const record = result.records[0] + const repoCount = record?.get("repoCount") + const issueCount = record?.get("issueCount") + const actorCount = record?.get("actorCount") + + // COUNT {} returns Neo4j Integer, convert to number + expect( + typeof repoCount === "number" ? repoCount : repoCount?.toNumber() + ).toBe(0) + expect( + typeof issueCount === "number" ? issueCount : issueCount?.toNumber() + ).toBe(0) + expect( + typeof actorCount === "number" ? actorCount : actorCount?.toNumber() + ).toBe(0) + } finally { + await session.close() + } + }) + + it("should auto-generate UUID if id not provided", async () => { + const handle = await adapter.workflow.run.create({ + type: "resolveIssue", + }) + + expect(handle.run.id).toBeDefined() + expect(handle.run.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ) + + // Cleanup + const session = dataSource.getSession("WRITE") + try { + await session.run( + ` + MATCH (wr:WorkflowRun {id: $id}) + DETACH DELETE wr + `, + { id: handle.run.id } + ) + } finally { + await session.close() + } + }) + }) + + describe("Progressive attachment via handle methods", () => { + it("should attach repository after creation", async () => { + const handle = await adapter.workflow.run.create({ + id: TEST_DATA.workflowRuns[1], + type: "resolveIssue", + }) + + // Attach repository + await handle.attach.repository({ + id: 12345, + fullName: "test/prog-repo-1", + owner: "test", + name: "prog-repo-1", + githubInstallationId: "inst-123", + }) + + // Verify repository relationship exists + const retrieved = await adapter.workflow.run.getById(handle.run.id) + expect(retrieved?.repository?.fullName).toBe("test/prog-repo-1") + }) + + it("should attach issue after creation", async () => { + const handle = await adapter.workflow.run.create({ + id: TEST_DATA.workflowRuns[2], + type: "resolveIssue", + }) + + // Attach issue + await handle.attach.issue({ + number: 42, + repoFullName: "test/repo", + }) + + // Verify issue relationship exists + const retrieved = await adapter.workflow.run.getById(handle.run.id) + expect(retrieved?.issue?.number).toBe(42) + }) + + it("should attach actor after creation", async () => { + const handle = await adapter.workflow.run.create({ + id: TEST_DATA.workflowRuns[3], + type: "resolveIssue", + }) + + // Attach user actor + await handle.attach.actor({ + type: "user", + userId: TEST_DATA.users[0], + }) + + // Verify actor relationship exists + const retrieved = await adapter.workflow.run.getById(handle.run.id) + expect(retrieved?.actor?.type).toBe("user") + }) + + it("should attach metadata step-by-step", async () => { + const handle = await adapter.workflow.run.create({ + id: TEST_DATA.workflowRuns[4], + type: "resolveIssue", + }) + + // Step 1: Attach repository + await handle.attach.repository({ + id: 67890, + fullName: "test/prog-repo-2", + owner: "test", + name: "prog-repo-2", + }) + + // Step 2: Attach issue + await handle.attach.issue({ + number: 100, + repoFullName: "test/prog-repo-2", + }) + + // Step 3: Attach actor + await handle.attach.actor({ + type: "webhook", + source: "github", + event: "issues", + action: "opened", + sender: { + id: TEST_DATA.githubUsers[0], + login: "testuser", + }, + installationId: "inst-456", + }) + + // Step 4: Attach commit + await handle.attach.commit({ + sha: TEST_DATA.commits[0], + message: "Fix issue", + }) + + // Verify all relationships exist + const retrieved = await adapter.workflow.run.getById(handle.run.id) + expect(retrieved?.repository?.fullName).toBe("test/prog-repo-2") + expect(retrieved?.issue?.number).toBe(100) + expect(retrieved?.actor?.type).toBe("webhook") + expect(retrieved?.commit?.sha).toBe(TEST_DATA.commits[0]) + + // Verify commit is linked to repository + const session = dataSource.getSession("READ") + try { + const result = await session.run( + ` + MATCH (commit:Commit {sha: $commitSha})-[:IN_REPOSITORY]->(repo:Repository) + RETURN repo.fullName AS repoFullName + `, + { commitSha: TEST_DATA.commits[0] } + ) + + expect(result.records[0]?.get("repoFullName")).toBe("test/prog-repo-2") + } finally { + await session.close() + } + }) + }) + + describe("Event chaining", () => { + it("should chain events in order via handle.add.event", async () => { + const handle = await adapter.workflow.run.create({ + type: "resolveIssue", + }) + + // Add first event + await handle.add.event({ + type: "status", + payload: { content: "Event 1" }, + }) + + // Add second event + await handle.add.event({ + type: "status", + payload: { content: "Event 2" }, + }) + + // Add third event + await handle.add.event({ + type: "status", + payload: { content: "Event 3" }, + }) + + // Verify events are in order + const events = await adapter.workflow.events.list(handle.run.id) + expect(events.length).toBeGreaterThanOrEqual(3) + + // Cleanup + const session = dataSource.getSession("WRITE") + try { + await session.run( + ` + MATCH (wr:WorkflowRun {id: $id}) + DETACH DELETE wr + `, + { id: handle.run.id } + ) + } finally { + await session.close() + } + }) + }) + + describe("Return handle methods", () => { + it("should return handle with all attach methods", async () => { + const handle = await adapter.workflow.run.create({ + type: "resolveIssue", + }) + + // Verify handle has all expected methods + expect(handle.run).toBeDefined() + expect(handle.add.event).toBeInstanceOf(Function) + expect(handle.attach.target).toBeInstanceOf(Function) + expect(handle.attach.actor).toBeInstanceOf(Function) + expect(handle.attach.repository).toBeInstanceOf(Function) + expect(handle.attach.issue).toBeInstanceOf(Function) + expect(handle.attach.commit).toBeInstanceOf(Function) + + // Cleanup + const session = dataSource.getSession("WRITE") + try { + await session.run( + ` + MATCH (wr:WorkflowRun {id: $id}) + DETACH DELETE wr + `, + { id: handle.run.id } + ) + } finally { + await session.close() + } + }) + }) +}) diff --git a/apps/workers/workflow-workers/src/orchestrators/autoResolveIssue.ts b/apps/workers/workflow-workers/src/orchestrators/autoResolveIssue.ts index e93ac6cf..c173c4eb 100644 --- a/apps/workers/workflow-workers/src/orchestrators/autoResolveIssue.ts +++ b/apps/workers/workflow-workers/src/orchestrators/autoResolveIssue.ts @@ -3,15 +3,17 @@ import { Octokit } from "@octokit/rest" import type { Transaction } from "neo4j-driver" import { EventBusAdapter } from "@/shared/adapters/ioredis/EventBusAdapter" -import { createNeo4jDataSource } from "@/shared/adapters/neo4j/dataSource" import { makeSettingsReaderAdapter } from "@/shared/adapters/neo4j/repositories/SettingsReaderAdapter" +import { StorageAdapter } from "@/shared/adapters/neo4j/StorageAdapter" import { setAccessToken } from "@/shared/auth" import { getPrivateKeyFromFile } from "@/shared/services/fs" import { autoResolveIssue as autoResolveIssueWorkflow } from "@/shared/usecases/workflows/autoResolveIssue" import { getEnvVar, publishJobStatus } from "../helper" +import { neo4jDs } from "../neo4j" // Minimal user repository implementation for SettingsReaderAdapter +// TODO: This should not be here. Find another place to implement this. const userRepo = { async getUserSettings(tx: Transaction, username: string) { const res = await tx.run( @@ -57,24 +59,12 @@ export async function autoResolveIssue( ) // Load environment - const { - NEO4J_URI, - NEO4J_USER, - NEO4J_PASSWORD, - GITHUB_APP_ID, - GITHUB_APP_PRIVATE_KEY_PATH, - REDIS_URL, - } = getEnvVar() + const { GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY_PATH, REDIS_URL } = getEnvVar() // Settings adapter (loads OpenAI API key from Neo4j) - const neo4jDs = createNeo4jDataSource({ - uri: NEO4J_URI, - user: NEO4J_USER, - password: NEO4J_PASSWORD, - }) - + // TODO: We're making 2 adapters that just connect to neo4j. Find a way to combine. const settingsAdapter = makeSettingsReaderAdapter({ - getSession: () => neo4jDs.getSession(), + getSession: () => neo4jDs.getSession("READ"), userRepo, }) @@ -104,8 +94,12 @@ export async function autoResolveIssue( ) { throw new Error("Failed to get installation token") } + + // TODO: We gotta get rid of this. setAccessToken(auth.token) + const storage = new StorageAdapter(neo4jDs) + await publishJobStatus(jobId, "Fetching issue and running LLM") const result = await autoResolveIssueWorkflow( @@ -118,6 +112,7 @@ export async function autoResolveIssue( { settings: settingsAdapter, eventBus: eventBusAdapter, + storage: storage, } ) diff --git a/lib/neo4j/repositories/workflowRun.ts b/lib/neo4j/repositories/workflowRun.ts index 822dc854..ba2cdebd 100644 --- a/lib/neo4j/repositories/workflowRun.ts +++ b/lib/neo4j/repositories/workflowRun.ts @@ -1,4 +1,4 @@ -import { int, Integer, ManagedTransaction, Node } from "neo4j-driver" +import { Integer, ManagedTransaction, Node } from "neo4j-driver" import { ZodError } from "zod" import { @@ -137,29 +137,6 @@ export async function listForIssue( }) } -export async function linkToIssue( - tx: ManagedTransaction, - { - workflowId, - issueId, - repoFullName, - }: { workflowId: string; issueId: number; repoFullName: string } -) { - const result = await withTiming( - `Neo4j QUERY: linkToIssue ${repoFullName}#${issueId}`, - () => - tx.run( - ` - MATCH (w:WorkflowRun {id: $workflowId}), (i:Issue {number: $issueId, repoFullName: $repoFullName}) - CREATE (w)-[:BASED_ON_ISSUE]->(i) - RETURN w, i - `, - { workflowId, issueId: int(issueId), repoFullName } - ) - ) - return result -} - /** * Helper function to parse AnyEvent + LLMResponseWithPlan * LLMResponseWithPlan is a superset of LLMResponse, so it needs to be parsed first. diff --git a/lib/neo4j/services/workflow.ts b/lib/neo4j/services/workflow.ts index 3b095c1e..1d0aa401 100644 --- a/lib/neo4j/services/workflow.ts +++ b/lib/neo4j/services/workflow.ts @@ -37,6 +37,8 @@ import { withTiming } from "@/shared/utils/telemetry" * The function then links the WorkflowRun to the Issue with the following pattern: * (w:WorkflowRun)-[:BASED_ON_ISSUE]->(i:Issue) * and returns the application representations of both. + * + * @deprecated Use StorageAdapter.workflow.run.create instead. This legacy function does not create Repository/User attribution. */ export async function initializeWorkflowRun({ id, @@ -115,6 +117,7 @@ function deriveState( /** * Returns workflows with run state and connected issue (if any) + * @deprecated Use StorageAdapter.workflow.run.list instead */ export async function listWorkflowRuns(issue?: { repoFullName: string diff --git a/lib/webhook/github/handlers/issue/label.resolve.handler.ts b/lib/webhook/github/handlers/issue/label.resolve.handler.ts index f8cf3ab2..1e03dc80 100644 --- a/lib/webhook/github/handlers/issue/label.resolve.handler.ts +++ b/lib/webhook/github/handlers/issue/label.resolve.handler.ts @@ -29,6 +29,7 @@ export async function handleIssueLabelResolve({ const repoFullName = payload.repository?.full_name const issueNumber = payload.issue?.number const labelerLogin = payload.sender?.login + const labelerId = payload.sender?.id if (!repoFullName || typeof issueNumber !== "number") { console.error( @@ -43,7 +44,7 @@ export async function handleIssueLabelResolve({ } const settingsReader = makeSettingsReaderAdapter({ - getSession: () => neo4jDs.getSession(), + getSession: () => neo4jDs.getSession("READ"), userRepo: userRepo, }) diff --git a/lib/webhook/github/types.ts b/lib/webhook/github/types.ts index 229a5fa6..8710f729 100644 --- a/lib/webhook/github/types.ts +++ b/lib/webhook/github/types.ts @@ -25,9 +25,21 @@ const InstallationSchema = z.object({ id: z.number() }) export const IssuesPayloadSchema = z.object({ action: z.string(), label: z.object({ name: z.string() }).optional(), - repository: z.object({ full_name: z.string() }), + repository: z.object({ + id: z.number(), + node_id: z.string(), + full_name: z.string(), + name: z.string(), + owner: z.object({ login: z.string() }), + default_branch: z.string().optional(), + visibility: z.enum(["public", "private", "internal"]).optional(), + has_issues: z.boolean().optional(), + }), issue: z.object({ number: z.number() }), - sender: z.object({ login: z.string() }), + sender: z.object({ + id: z.number(), + login: z.string(), + }), installation: InstallationSchema, }) export type IssuesPayload = z.infer diff --git a/lib/workflows/resolveIssue.ts b/lib/workflows/resolveIssue.ts index f3435040..ada66812 100644 --- a/lib/workflows/resolveIssue.ts +++ b/lib/workflows/resolveIssue.ts @@ -53,6 +53,10 @@ interface ResolveIssueParams { installCommand?: string // Legacy alias; treat as setupCommands when provided } +/** + * + * @deprecated Use shared/src/usecases/workflows/autoResolveIssue.ts instead (also to be renamed to resolveIssue.ts in the future) + */ export const resolveIssue = async ({ issue, repository, diff --git a/shared/src/adapters/neo4j/StorageAdapter.ts b/shared/src/adapters/neo4j/StorageAdapter.ts index 2752235a..406ad477 100644 --- a/shared/src/adapters/neo4j/StorageAdapter.ts +++ b/shared/src/adapters/neo4j/StorageAdapter.ts @@ -1,188 +1,418 @@ -import { AllEvents } from "@/shared/entities" -import { WorkflowRun, WorkflowRunActor } from "@/shared/entities/WorkflowRun" +import type { AllEvents } from "@/shared/entities" +import { + type WorkflowRun, + type WorkflowRunActor, +} from "@/shared/entities/WorkflowRun" import type { + CommitAttachment, CreateWorkflowRunInput, DatabaseStorage, + IssueAttachment, + RepositoryAttachment, + Target, + WorkflowEventInput, WorkflowRunFilter, WorkflowRunHandle, } from "@/shared/ports/db/index" import type { Neo4jDataSource } from "./dataSource" -import { listEventsForWorkflowRun, mapListEvents } from "./queries/workflowRuns" +import { + addEvent, + attachActor, + attachCommit, + attachIssue, + attachRepository, + createWorkflowRun, + type CreateWorkflowRunParams, + getWorkflowRunById, + listEventsForWorkflowRun, + mapAddEventResult, + mapGetWorkflowRunById, + mapListEvents, +} from "./queries/workflowRuns" -// Minimal StorageAdapter implementation that satisfies the DatabaseStorage port. -// Focused on add-only behavior for WorkflowRun creation with Repository/User nodes. -export class StorageAdapter implements DatabaseStorage { - constructor(private readonly ds: Neo4jDataSource) {} +export type Neo4jCtx = { + ds: Neo4jDataSource +} - public workflow = { - run: { - create: (input: CreateWorkflowRunInput): Promise => - this.createWorkflowRun(input), - getById: (id: string): Promise => - this.getWorkflowRunById(id), - list: (filter: WorkflowRunFilter): Promise => - this.listWorkflowRuns(filter), - listEvents: (runId: string): Promise => - this.listWorkflowRunEvents(runId), - }, +/** + * Transforms CreateWorkflowRunInput to CreateWorkflowRunParams + * Handles optional fields and progressive metadata attachment + */ +function transformCreateWorkflowRunInput(input: CreateWorkflowRunInput): { + core: CreateWorkflowRunParams + attach: { + repository?: { + id: string + nodeId?: string + owner: string + name: string + githubInstallationId?: string + } + issue?: { number: number; repoFullName: string } + actor?: Parameters[1]["actor"] + commit?: { sha: string; nodeId?: string; message?: string } } +} { + // Generate ID if not provided + const runId = input.id ?? crypto.randomUUID() - // TODO: Create a query helper file for this. - private async createWorkflowRun( - input: CreateWorkflowRunInput - ): Promise { - const session = this.ds.getSession("WRITE") - try { - const params: Record = { - runId: input.id, - type: input.type, - postToGithub: input.postToGithub, - repo: { - id: String(input.repository.id), - nodeId: input.repository.nodeId, - fullName: input.repository.fullName, - owner: input.repository.owner, - name: input.repository.name, - defaultBranch: input.repository.defaultBranch ?? null, - visibility: input.repository.visibility ?? null, - hasIssues: input.repository.hasIssues ?? null, - }, - issueNumber: input.issueNumber, + const core: CreateWorkflowRunParams = { + runId, + type: input.type, + postToGithub: input.config?.postToGithub ?? false, + } + + const attach: { + repository?: { + id: string + nodeId?: string + owner: string + name: string + githubInstallationId?: string + } + issue?: { number: number; repoFullName: string } + actor?: Parameters[1]["actor"] + commit?: { sha: string; nodeId?: string; message?: string } + } = {} + + // Add repository if provided via target (only attach when we have enough data) + if (input.target?.repository) { + const repo = input.target.repository + if (repo.id != null && repo.owner && repo.name) { + attach.repository = { + id: String(repo.id), + nodeId: repo.nodeId, + owner: repo.owner, + name: repo.name, + githubInstallationId: repo.githubInstallationId, } + } + } - // Add commit params if provided - let commitCypher = "" - if (input.commit) { - params.commit = { - sha: input.commit.sha, - nodeId: input.commit.nodeId, - message: input.commit.message, - treeSha: input.commit.treeSha, - authorName: input.commit.author.name, - authorEmail: input.commit.author.email, - authoredAt: input.commit.author.date, - committerName: input.commit.committer.name, - committerEmail: input.commit.committer.email, - committedAt: input.commit.committer.date, - } - commitCypher = ` - // Commit (optional - MERGE to avoid duplicates) - MERGE (commit:Commit { sha: $commit.sha }) - ON CREATE SET commit.nodeId = $commit.nodeId, - commit.message = $commit.message, - commit.treeSha = $commit.treeSha, - commit.authorName = $commit.authorName, - commit.authorEmail = $commit.authorEmail, - commit.authoredAt = datetime($commit.authoredAt), - commit.committerName = $commit.committerName, - commit.committerEmail = $commit.committerEmail, - commit.committedAt = datetime($commit.committedAt), - commit.createdAt = datetime() - ` + // Add issue if provided via target + if (input.target?.issue) { + attach.issue = { + number: input.target.issue.number, + repoFullName: input.target.issue.repoFullName, + } + } + + // Add actor if provided + if (input.actor) { + if (input.actor.type === "user") { + attach.actor = { + actorType: "user", + actorUserId: input.actor.userId, } + } else if (input.actor.type === "webhook") { + attach.actor = { + actorType: "webhook", + actorGithubUserId: input.actor.sender.id, + actorGithubUserLogin: input.actor.sender.login, + webhookEventId: `webhook-${runId}`, + webhookEvent: input.actor.event, + webhookAction: input.actor.action, + } + } + } + + // Add commit if provided via target + if (input.target?.ref?.type === "commit") { + attach.commit = { + sha: input.target.ref.sha, + } + } + + return { core, attach } +} + +// Helper functions for handle methods +async function addEventToRun( + ctx: Neo4jCtx, + runId: string, + event: WorkflowEventInput +): Promise { + const session = ctx.ds.getSession("WRITE") + try { + const eventId = crypto.randomUUID() + const createdAt = event.createdAt ?? new Date().toISOString() + + const result = await session.executeWrite((tx) => + addEvent(tx, { + runId, + eventId, + eventType: event.type, + content: JSON.stringify(event.payload), + createdAt, + }) + ) + + return mapAddEventResult(result) + } finally { + await session.close() + } +} - let actorCypher = "" - if (input.actor.type === "user") { - params.userId = input.actor.userId - actorCypher = `MERGE (actor:User {id: $userId})\nON CREATE SET actor.createdAt = datetime()` - } else if (input.actor.type === "webhook") { - params.senderId = String(input.actor.sender.id) - params.senderLogin = input.actor.sender.login - params.webhook = { - source: input.actor.source, - event: input.actor.event, - action: input.actor.action, - installationId: input.actor.installationId, +async function attachTargetToRun( + ctx: Neo4jCtx, + runId: string, + target: Target +): Promise { + const session = ctx.ds.getSession("WRITE") + try { + await session.executeWrite(async (tx) => { + // Attach repository + if (target.repository) { + if ( + target.repository.id == null || + !target.repository.owner || + !target.repository.name + ) { + // Can't attach without id - require caller to use handle.attach.repository() + throw new Error( + "Repository attachment via target requires repository.id + owner + name - use handle.attach.repository() instead" + ) } - actorCypher = `MERGE (actor:GithubUser {id: $senderId})\nON CREATE SET actor.login = $senderLogin, actor.createdAt = datetime()` - } else { - actorCypher = `MERGE (actor:System {id: 'system'})` + + await attachRepository(tx, { + runId, + repoId: String(target.repository.id), + repoNodeId: target.repository.nodeId, + repoOwner: target.repository.owner, + repoName: target.repository.name, + repoGithubInstallationId: target.repository.githubInstallationId, + }) } - const cypher = ` - // Repository - MERGE (repo:Repository { id: $repo.id }) - ON CREATE SET repo.nodeId = $repo.nodeId, - repo.fullName = $repo.fullName, - repo.owner = $repo.owner, - repo.name = $repo.name, - repo.defaultBranch = $repo.defaultBranch, - repo.visibility = $repo.visibility, - repo.hasIssues = $repo.hasIssues, - repo.createdAt = datetime() - ON MATCH SET repo.nodeId = coalesce($repo.nodeId, repo.nodeId), - repo.fullName = coalesce($repo.fullName, repo.fullName), - repo.owner = coalesce($repo.owner, repo.owner), - repo.name = coalesce($repo.name, repo.name), - repo.defaultBranch = coalesce($repo.defaultBranch, repo.defaultBranch), - repo.visibility = coalesce($repo.visibility, repo.visibility), - repo.hasIssues = coalesce($repo.hasIssues, repo.hasIssues) - - // Actor - ${actorCypher} - - // Issue (optional) - MERGE (issue:Issue { number: $issueNumber, repoFullName: $repo.fullName }) - - ${commitCypher} - - // WorkflowRun - CREATE (wr:WorkflowRun { id: $runId, type: $type, postToGithub: $postToGithub, createdAt: datetime(), state: 'pending' }) - - // Relationships - MERGE (wr)-[:BASED_ON_REPOSITORY]->(repo) - MERGE (wr)-[:BASED_ON_ISSUE]->(issue) - MERGE (wr)-[:INITIATED_BY]->(actor) - ${input.commit ? "MERGE (wr)-[:BASED_ON_COMMIT]->(commit)" : ""} - ${input.commit ? "MERGE (commit)-[:IN_REPOSITORY]->(repo)" : ""} - ` - - await session.run(cypher, params) - return { id: input.id } - } finally { - await session.close() + // Attach issue + if (target.issue) { + await attachIssue(tx, { + runId, + issueNumber: target.issue.number, + repoFullName: target.issue.repoFullName, + }) + } + + // Attach commit + if (target.ref?.type === "commit") { + await attachCommit(tx, { + runId, + commitSha: target.ref.sha, + }) + } + }) + } finally { + await session.close() + } +} + +async function attachActorToRun( + ctx: Neo4jCtx, + runId: string, + actor: WorkflowRunActor +): Promise { + const session = ctx.ds.getSession("WRITE") + try { + let actorParams: Parameters[1]["actor"] + + if (actor.type === "user") { + actorParams = { + actorType: "user", + actorUserId: actor.userId, + } + } else if (actor.type === "webhook") { + actorParams = { + actorType: "webhook", + actorGithubUserId: actor.sender.id, + actorGithubUserLogin: actor.sender.login, + webhookEventId: `webhook-${runId}`, + webhookEvent: actor.event, + webhookAction: actor.action, + } + } else { + // Exhaustive check - should never reach here with proper typing + const _exhaustive: never = actor + throw new Error(`Invalid actor type: ${JSON.stringify(_exhaustive)}`) } + + await session.executeWrite((tx) => + attachActor(tx, { runId, actor: actorParams }) + ) + } finally { + await session.close() } +} - // TODO: Create a query helper file for this. - private async getWorkflowRunById(id: string): Promise { - const session = this.ds.getSession("READ") - try { - // TODO: This is not exactly the right implementation. Something is off with actor attribution, mixing of domain and adapter types. To be reviewed. - const res = await session.run( - `MATCH (wr:WorkflowRun { id: $id }) - OPTIONAL MATCH (wr)-[:BASED_ON_REPOSITORY]->(repo:Repository) - OPTIONAL MATCH (wr)-[:INITIATED_BY]->(actor:User) - OPTIONAL MATCH (wr)-[:INITIATED_BY]->(actor:GithubUser) - RETURN wr { .id, .type, .createdAt, .postToGithub, .state } AS wr, repo.fullName AS repoFullName, actor as actor`, - { id } - ) - const rec = res.records[0] - if (!rec) return null - const wr = rec.get("wr") as { - id: string - type: string - createdAt: { toString(): string } - postToGithub: boolean - state: WorkflowRun["state"] +async function attachRepositoryToRun( + ctx: Neo4jCtx, + runId: string, + repo: RepositoryAttachment +): Promise { + const session = ctx.ds.getSession("WRITE") + try { + await session.executeWrite((tx) => + attachRepository(tx, { + runId, + repoId: String(repo.id), + repoNodeId: repo.nodeId, + repoOwner: repo.owner, + repoName: repo.name, + repoGithubInstallationId: repo.githubInstallationId, + }) + ) + } finally { + await session.close() + } +} + +async function attachIssueToRun( + ctx: Neo4jCtx, + runId: string, + issue: IssueAttachment +): Promise { + const session = ctx.ds.getSession("WRITE") + try { + await session.executeWrite((tx) => + attachIssue(tx, { + runId, + issueNumber: issue.number, + repoFullName: issue.repoFullName, + }) + ) + } finally { + await session.close() + } +} + +async function attachCommitToRun( + ctx: Neo4jCtx, + runId: string, + commit: CommitAttachment +): Promise { + const session = ctx.ds.getSession("WRITE") + try { + await session.executeWrite((tx) => + attachCommit(tx, { + runId, + commitSha: commit.sha, + commitNodeId: commit.nodeId, + commitMessage: commit.message, + }) + ) + } finally { + await session.close() + } +} + +export async function runCreate( + ctx: Neo4jCtx, + input: CreateWorkflowRunInput +): Promise { + const session = ctx.ds.getSession("WRITE") + try { + const { core, attach } = transformCreateWorkflowRunInput(input) + + const run = await session.executeWrite(async (tx) => { + // 1) Create workflow run node + await createWorkflowRun(tx, core) + + // 2) Attach metadata if provided (all in same transaction) + if (attach.repository) { + await attachRepository(tx, { + runId: core.runId, + repoId: attach.repository.id, + repoNodeId: attach.repository.nodeId, + repoOwner: attach.repository.owner, + repoName: attach.repository.name, + repoGithubInstallationId: attach.repository.githubInstallationId, + }) } - const actor = rec.get("actor") as WorkflowRunActor - const repoFullName = rec.get("repoFullName") as string | undefined - return { - id: wr.id, - type: wr.type, - createdAt: new Date(wr.createdAt.toString()), - postToGithub: wr.postToGithub, - state: wr.state ?? "pending", - actor: actor, - repository: repoFullName ? { fullName: repoFullName } : undefined, + + if (attach.issue) { + await attachIssue(tx, { + runId: core.runId, + issueNumber: attach.issue.number, + repoFullName: attach.issue.repoFullName, + }) } - } finally { - await session.close() + + if (attach.actor) { + await attachActor(tx, { + runId: core.runId, + actor: attach.actor, + }) + } + + if (attach.commit) { + await attachCommit(tx, { + runId: core.runId, + commitSha: attach.commit.sha, + commitNodeId: attach.commit.nodeId, + commitMessage: attach.commit.message, + }) + } + + // 3) Return the fully-populated WorkflowRun (including attachments) + const full = await getWorkflowRunById(tx, { id: core.runId }) + const mapped = mapGetWorkflowRunById(full) + if (!mapped) { + throw new Error(`Failed to load created workflow run ${core.runId}`) + } + return mapped + }) + + return { + run, + add: { + event: (event) => addEventToRun(ctx, run.id, event), + }, + attach: { + target: (target) => attachTargetToRun(ctx, run.id, target), + actor: (actor) => attachActorToRun(ctx, run.id, actor), + repository: (repo) => attachRepositoryToRun(ctx, run.id, repo), + issue: (issue) => attachIssueToRun(ctx, run.id, issue), + commit: (commit) => attachCommitToRun(ctx, run.id, commit), + }, + } + } finally { + await session.close() + } +} + +export class StorageAdapter implements DatabaseStorage { + private readonly ctx: Neo4jCtx + + constructor(private readonly ds: Neo4jDataSource) { + this.ctx = { + ds, } } + public workflow = { + run: { + create: async (input: CreateWorkflowRunInput) => + runCreate(this.ctx, input), + getById: async (id: string): Promise => { + const session = this.ds.getSession("READ") + try { + const result = await session.executeRead((tx) => + getWorkflowRunById(tx, { id }) + ) + return mapGetWorkflowRunById(result) + } finally { + await session.close() + } + }, + list: (filter: WorkflowRunFilter): Promise => + this.listWorkflowRuns(filter), + }, + events: { + list: (runId: string): Promise => + this.listWorkflowRunEvents(runId), + }, + } + private async listWorkflowRuns( _filter: WorkflowRunFilter ): Promise { diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/addEvent.mapper.ts b/shared/src/adapters/neo4j/queries/workflowRuns/addEvent.mapper.ts new file mode 100644 index 00000000..d4a3a998 --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/addEvent.mapper.ts @@ -0,0 +1,153 @@ +import type { QueryResult } from "neo4j-driver" + +import { type AllEvents } from "@/shared/entities" + +import { type AnyEvent, anyEventSchema } from "../../types" +import type { AddEventResult } from "./addEvent" + +/** + * Maps the Neo4j query result from addEvent to an AllEvents entity + * + * Pattern: Parse (Neo4j schema) → Transform (to domain structure) → Return + */ +export function mapAddEventResult( + result: QueryResult +): AllEvents { + const record = result.records[0] + if (!record) { + throw new Error("No event was created") + } + + // 1. Parse: Reconstruct event node and validate with Neo4j schema + const eventNode = { + id: record.get("eventId"), + type: record.get("eventType"), + content: record.get("content"), + createdAt: record.get("createdAt"), + } + + // Validate against Neo4j event schemas - this gives us type safety on Neo4j structure + const validatedEvent = anyEventSchema.parse(eventNode) + + // 2. Transform: Map Neo4j event to domain event + return mapNeo4jEventToDomain(validatedEvent) +} + +/** + * Transforms a validated Neo4j event to domain AllEvents type + * Maps createdAt (Neo4j DateTime) → timestamp (JavaScript Date) + * Maps Neo4j event type names → domain event type names + */ +function mapNeo4jEventToDomain(neo4jEvent: AnyEvent): AllEvents { + const jsDate = neo4jEvent.createdAt.toStandardDate() + + // WorkflowEvent uses Date, MessageEvent uses ISO string + // TODO: Standardize timestamp types across all events + const workflowTimestamp = jsDate + const messageTimestamp = jsDate.toISOString() + + // Switch on Neo4j event type and map to domain event structure + switch (neo4jEvent.type) { + case "error": + // Neo4j "error" → WorkflowEvent "workflow.error" + return { + id: neo4jEvent.id, + timestamp: workflowTimestamp, + type: "workflow.error", + message: neo4jEvent.content, + } + + case "status": + // Maps directly - WorkflowEvent "status" + return { + id: neo4jEvent.id, + timestamp: workflowTimestamp, + type: "status", + content: neo4jEvent.content, + } + + case "workflowState": + // Neo4j "workflowState" → WorkflowEvent "workflow.state" + return { + id: neo4jEvent.id, + timestamp: workflowTimestamp, + type: "workflow.state", + state: neo4jEvent.state, + } + + case "systemPrompt": + // Neo4j "systemPrompt" → MessageEvent "system_prompt" + return { + timestamp: messageTimestamp, + type: "system_prompt", + content: neo4jEvent.content, + metadata: {}, + } + + case "userMessage": + // Neo4j "userMessage" → MessageEvent "user_message" + return { + timestamp: messageTimestamp, + type: "user_message", + content: neo4jEvent.content, + metadata: {}, + } + + case "llmResponse": + // Neo4j "llmResponse" → MessageEvent "assistant_message" + return { + timestamp: messageTimestamp, + type: "assistant_message", + content: neo4jEvent.content, + metadata: {}, + } + + case "reasoning": + // Maps directly - MessageEvent "reasoning" + return { + timestamp: messageTimestamp, + type: "reasoning", + content: neo4jEvent.summary, + metadata: {}, + } + + case "reviewComment": + // Neo4j "reviewComment" → map to assistant_message for now + // TODO: Consider if this should be a separate domain event type + return { + timestamp: messageTimestamp, + type: "assistant_message", + content: neo4jEvent.content, + metadata: { original_type: "reviewComment" }, + } + + case "toolCall": + // Neo4j "toolCall" → MessageEvent "tool.call" + return { + timestamp: messageTimestamp, + type: "tool.call", + content: neo4jEvent.toolName, + metadata: { + toolCallId: neo4jEvent.toolCallId, + args: neo4jEvent.args, + }, + } + + case "toolCallResult": + // Neo4j "toolCallResult" → MessageEvent "tool.result" + return { + timestamp: messageTimestamp, + type: "tool.result", + content: neo4jEvent.content, + metadata: { + toolCallId: neo4jEvent.toolCallId, + toolName: neo4jEvent.toolName, + }, + } + + default: + // TypeScript will ensure this is exhaustive + const _exhaustive: never = neo4jEvent + throw new Error(`Unknown event type: ${(_exhaustive as AnyEvent).type}`) + } +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/addEvent.ts b/shared/src/adapters/neo4j/queries/workflowRuns/addEvent.ts new file mode 100644 index 00000000..77c6663a --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/addEvent.ts @@ -0,0 +1,55 @@ +import { + DateTime, + Integer, + type ManagedTransaction, + type QueryResult, +} from "neo4j-driver" + +const QUERY = ` + // Find the WorkflowRun + MATCH (wr:WorkflowRun { id: $runId }) + + // Find the last event (if any) + OPTIONAL MATCH (wr)-[:STARTS_WITH|NEXT*]->(lastEvent:Event) + WHERE NOT (lastEvent)-[:NEXT]->() + + // Create new event node + CREATE (newEvent:Event { + id: $eventId, + type: $eventType, + createdAt: datetime($createdAt), + content: $content + }) + + // Link to workflow run or previous event + FOREACH (_ IN CASE WHEN lastEvent IS NULL THEN [1] ELSE [] END | + MERGE (wr)-[:STARTS_WITH]->(newEvent) + ) + FOREACH (_ IN CASE WHEN lastEvent IS NOT NULL THEN [1] ELSE [] END | + MERGE (lastEvent)-[:NEXT]->(newEvent) + ) + + RETURN newEvent.id AS eventId, newEvent.type AS eventType, newEvent.content AS content, newEvent.createdAt AS createdAt +` + +export interface AddEventParams { + runId: string + eventId: string + eventType: string + content: string + createdAt: string +} + +export interface AddEventResult { + eventId: string + eventType: string + content: string + createdAt: DateTime +} + +export async function addEvent( + tx: ManagedTransaction, + params: AddEventParams +): Promise> { + return await tx.run(QUERY, params) +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/attachActor.ts b/shared/src/adapters/neo4j/queries/workflowRuns/attachActor.ts new file mode 100644 index 00000000..c3b920a5 --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/attachActor.ts @@ -0,0 +1,123 @@ +import type { ManagedTransaction, QueryResult } from "neo4j-driver" + +const USER_ACTOR_QUERY = ` + // Find the WorkflowRun + MATCH (wr:WorkflowRun { id: $runId }) + + // MERGE User actor + MERGE (actor:User {id: $actorUserId}) + ON CREATE SET actor.createdAt = datetime() + + WITH wr, actor + // Remove any existing INITIATED_BY relationships + OPTIONAL MATCH (wr)-[oldRel:INITIATED_BY]->() + DELETE oldRel + + WITH wr, actor + // Create new relationship + MERGE (wr)-[:INITIATED_BY]->(actor) + + RETURN wr.id AS runId +` + +const GITHUB_USER_ACTOR_QUERY = ` + // Find the WorkflowRun + MATCH (wr:WorkflowRun { id: $runId }) + + // MERGE GithubUser actor + MERGE (actor:GithubUser {id: $actorGithubUserId}) + ON CREATE SET actor.login = $actorGithubUserLogin, actor.createdAt = datetime() + + WITH wr, actor + // Remove any existing INITIATED_BY relationships + OPTIONAL MATCH (wr)-[oldRel:INITIATED_BY]->() + DELETE oldRel + + WITH wr, actor + // Create new relationship + MERGE (wr)-[:INITIATED_BY]->(actor) + + RETURN wr.id AS runId +` + +const WEBHOOK_ACTOR_QUERY = ` + // Find the WorkflowRun + MATCH (wr:WorkflowRun { id: $runId }) + + // MERGE GithubUser actor + MERGE (actor:GithubUser {id: $actorGithubUserId}) + ON CREATE SET actor.login = $actorGithubUserLogin, actor.createdAt = datetime() + + // MERGE GithubWebhookEvent + MERGE (webhookEvent:GithubWebhookEvent {id: $webhookEventId}) + ON CREATE SET webhookEvent.event = $webhookEvent, + webhookEvent.action = $webhookAction, + webhookEvent.createdAt = datetime() + + // Link webhook event to sender + MERGE (webhookEvent)-[:SENDER]->(actor) + + WITH wr, webhookEvent + // Remove any existing TRIGGERED_BY relationships + OPTIONAL MATCH (wr)-[oldRel:TRIGGERED_BY]->() + DELETE oldRel + + WITH wr, webhookEvent + // Create new TRIGGERED_BY relationship for webhook-initiated runs + MERGE (wr)-[:TRIGGERED_BY]->(webhookEvent) + + RETURN wr.id AS runId +` + +type ActorParams = + | { actorType: "user"; actorUserId: string } + | { + actorType: "githubUser" + actorGithubUserId: string + actorGithubUserLogin: string + } + | { + actorType: "webhook" + actorGithubUserId: string + actorGithubUserLogin: string + webhookEventId: string + webhookEvent: string + webhookAction: string + } + +export interface AttachActorParams { + runId: string + actor: ActorParams +} + +export interface AttachActorResult { + runId: string +} + +export async function attachActor( + tx: ManagedTransaction, + params: AttachActorParams +): Promise> { + let query: string + const cypherParams: Record = { + runId: params.runId, + } + + if (params.actor.actorType === "user") { + query = USER_ACTOR_QUERY + cypherParams.actorUserId = params.actor.actorUserId + } else if (params.actor.actorType === "webhook") { + query = WEBHOOK_ACTOR_QUERY + cypherParams.actorGithubUserId = params.actor.actorGithubUserId + cypherParams.actorGithubUserLogin = params.actor.actorGithubUserLogin + cypherParams.webhookEventId = params.actor.webhookEventId + cypherParams.webhookEvent = params.actor.webhookEvent + cypherParams.webhookAction = params.actor.webhookAction + } else { + query = GITHUB_USER_ACTOR_QUERY + cypherParams.actorGithubUserId = params.actor.actorGithubUserId + cypherParams.actorGithubUserLogin = params.actor.actorGithubUserLogin + } + + return await tx.run(query, cypherParams) +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/attachCommit.ts b/shared/src/adapters/neo4j/queries/workflowRuns/attachCommit.ts new file mode 100644 index 00000000..23413abc --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/attachCommit.ts @@ -0,0 +1,46 @@ +import type { ManagedTransaction, QueryResult } from "neo4j-driver" + +const QUERY = ` + // Find the WorkflowRun and its Repository (if attached) + MATCH (wr:WorkflowRun { id: $runId }) + OPTIONAL MATCH (wr)-[:BASED_ON_REPOSITORY]->(repo:Repository) + + // MERGE Commit + MERGE (commit:Commit { sha: $commitSha }) + ON CREATE SET commit.nodeId = $commitNodeId, + commit.message = $commitMessage, + commit.createdAt = datetime() + + // Create or update relationships + MERGE (wr)-[:BASED_ON_COMMIT]->(commit) + + // Link commit to repository if it exists + FOREACH (_ IN CASE WHEN repo IS NOT NULL THEN [1] ELSE [] END | + MERGE (commit)-[:IN_REPOSITORY]->(repo) + ) + + RETURN wr.id AS runId +` + +export interface AttachCommitParams { + runId: string + commitSha: string + commitNodeId?: string + commitMessage?: string +} + +export interface AttachCommitResult { + runId: string +} + +export async function attachCommit( + tx: ManagedTransaction, + params: AttachCommitParams +): Promise> { + return await tx.run(QUERY, { + runId: params.runId, + commitSha: params.commitSha, + commitNodeId: params.commitNodeId ?? null, + commitMessage: params.commitMessage ?? null, + }) +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/attachIssue.ts b/shared/src/adapters/neo4j/queries/workflowRuns/attachIssue.ts new file mode 100644 index 00000000..459074a0 --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/attachIssue.ts @@ -0,0 +1,36 @@ +import { int, type ManagedTransaction, type QueryResult } from "neo4j-driver" + +const QUERY = ` + // Find the WorkflowRun + MATCH (wr:WorkflowRun { id: $runId }) + + // MERGE Issue + MERGE (issue:Issue { number: $issueNumber, repoFullName: $repoFullName }) + ON CREATE SET issue.createdAt = datetime() + + // Create or update relationship + MERGE (wr)-[:BASED_ON_ISSUE]->(issue) + + RETURN wr.id AS runId +` + +export interface AttachIssueParams { + runId: string + issueNumber: number + repoFullName: string +} + +export interface AttachIssueResult { + runId: string +} + +export async function attachIssue( + tx: ManagedTransaction, + params: AttachIssueParams +): Promise> { + return await tx.run(QUERY, { + runId: params.runId, + issueNumber: int(params.issueNumber), + repoFullName: params.repoFullName, + }) +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/attachRepository.ts b/shared/src/adapters/neo4j/queries/workflowRuns/attachRepository.ts new file mode 100644 index 00000000..f38503ff --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/attachRepository.ts @@ -0,0 +1,60 @@ +import type { ManagedTransaction, QueryResult } from "neo4j-driver" + +const QUERY = ` + // Find the WorkflowRun + MATCH (wr:WorkflowRun { id: $runId }) + + // MERGE Repository + MERGE (repo:Repository { id: $repoId }) + ON CREATE SET repo.nodeId = $repoNodeId, + repo.owner = $repoOwner, + repo.name = $repoName, + repo.fullName = $repoFullName, + repo.githubInstallationId = $repoGithubInstallationId, + repo.createdAt = datetime() + ON MATCH SET repo.fullName = $repoFullName, + repo.githubInstallationId = coalesce($repoGithubInstallationId, repo.githubInstallationId), + repo.owner = coalesce($repoOwner, repo.owner), + repo.name = coalesce($repoName, repo.name) + + // Create or update relationship + MERGE (wr)-[:BASED_ON_REPOSITORY]->(repo) + + RETURN wr.id AS runId +` + +export interface AttachRepositoryParams { + runId: string + repoId: string + repoNodeId?: string + repoOwner: string + repoName: string + repoGithubInstallationId?: string +} + +export interface AttachRepositoryResult { + runId: string +} + +export async function attachRepository( + tx: ManagedTransaction, + params: AttachRepositoryParams +): Promise> { + const { + runId, + repoId, + repoNodeId, + repoOwner, + repoName, + repoGithubInstallationId, + } = params + return await tx.run(QUERY, { + runId: runId, + repoId: repoId, + repoNodeId: repoNodeId ?? null, + repoFullName: repoOwner + "/" + repoName, + repoOwner: repoOwner, + repoName: repoName, + repoGithubInstallationId: repoGithubInstallationId ?? null, + }) +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/createWorkflowRun.mapper.ts b/shared/src/adapters/neo4j/queries/workflowRuns/createWorkflowRun.mapper.ts new file mode 100644 index 00000000..b378a0f5 --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/createWorkflowRun.mapper.ts @@ -0,0 +1,30 @@ +import type { QueryResult } from "neo4j-driver" + +import type { WorkflowRun } from "@/shared/entities/WorkflowRun" + +import { workflowRunSchema } from "../../types" +import type { + CreateWorkflowRunParams, + CreateWorkflowRunResult, +} from "./createWorkflowRun" + +/** + * Maps Neo4j query result to domain WorkflowRun entity + * Takes both the query result and input params to construct a complete entity + */ +export function mapCreateWorkflowRunResult( + result: QueryResult, + params: CreateWorkflowRunParams +): WorkflowRun { + const record = result.records[0] + const wrNode = record.get("wr") + const wr = workflowRunSchema.parse(wrNode.properties) + + return { + id: wr.id, + type: wr.type, + createdAt: wr.createdAt.toStandardDate(), + postToGithub: wr.postToGithub ?? false, + state: wr.state ?? "pending", + } +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/createWorkflowRun.ts b/shared/src/adapters/neo4j/queries/workflowRuns/createWorkflowRun.ts new file mode 100644 index 00000000..4825262c --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/createWorkflowRun.ts @@ -0,0 +1,55 @@ +import type { + Integer, + ManagedTransaction, + Node, + QueryResult, +} from "neo4j-driver" + +import type { WorkflowRun as Neo4jWorkflowRun } from "../../types" + +/** + * Simple query to create a WorkflowRun node + * All relationships (actor, issue, repository, commit) are attached separately + * using the attach* functions for cleaner composition + */ +const QUERY = ` + CREATE (wr:WorkflowRun { + id: $runId, + type: $type, + postToGithub: $postToGithub, + createdAt: datetime() + }) + RETURN wr +` + +/** + * Parameters for creating a workflow run + * Core fields used in the creation query. + * + * Relationships (actor, issue, repository, commit) are attached separately in + * `StorageAdapter` via the dedicated `attach*` queries. + */ +export interface CreateWorkflowRunParams { + runId: string + type: string + postToGithub: boolean +} + +export interface CreateWorkflowRunResult { + wr: Node +} + +/** + * Creates a WorkflowRun node in Neo4j + * Does NOT create any relationships - use attach* functions for that + */ +export async function createWorkflowRun( + tx: ManagedTransaction, + params: CreateWorkflowRunParams +): Promise> { + return await tx.run(QUERY, { + runId: params.runId, + type: params.type, + postToGithub: params.postToGithub, + }) +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/getWorkflowRunById.mapper.ts b/shared/src/adapters/neo4j/queries/workflowRuns/getWorkflowRunById.mapper.ts new file mode 100644 index 00000000..6c982f9f --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/getWorkflowRunById.mapper.ts @@ -0,0 +1,144 @@ +import type { Node, QueryResult } from "neo4j-driver" + +import type { + WorkflowRun, + WorkflowRunActor, +} from "@/shared/entities/WorkflowRun" + +import { + commitSchema, + genericWebhookEventSchema, + githubUserSchema, + githubWebhookEventSchema, + issueSchema, + repositorySchema, + userSchema, + workflowRunSchema, +} from "../../types" +import type { GetWorkflowRunByIdResult } from "./getWorkflowRunById" + +/** + * Maps actor nodes to a WorkflowRunActor domain type + * Handles two patterns: + * - User-initiated: (wr)-[:INITIATED_BY]->(User) + * - Webhook-triggered: (wr)-[:TRIGGERED_BY]->(WebhookEvent)-[:SENDER]->(GithubUser) + */ +function mapActor( + actorNode: Node | null, + webhookEventNode: Node | null, + senderNode: Node | null, + installationId: string +): WorkflowRunActor | undefined { + // Webhook-triggered workflow + if (webhookEventNode && senderNode) { + const githubUser = githubUserSchema.parse(senderNode.properties) + + // Try parsing with specific schema first, fall back to generic schema + let webhookEvent + const parseResult = githubWebhookEventSchema.safeParse( + webhookEventNode.properties + ) + if (parseResult.success) { + webhookEvent = parseResult.data + } else { + // Fall back to generic schema for non-standard webhook events + webhookEvent = genericWebhookEventSchema.parse( + webhookEventNode.properties + ) + } + + return { + type: "webhook", + source: "github", + event: webhookEvent.event, + action: webhookEvent.action ?? "", + sender: { + id: githubUser.id, + login: githubUser.login, + }, + installationId, + } + } + + // User-initiated workflow + if (actorNode) { + const labels = Array.from(actorNode.labels || []) as string[] + if (labels.includes("User")) { + const user = userSchema.parse(actorNode.properties) + return { + type: "user", + userId: user.id, + } + } + } + + return undefined +} + +/** + * Maps Neo4j query result to domain WorkflowRun entity + */ +export function mapGetWorkflowRunById( + result: QueryResult +): WorkflowRun | null { + if (result.records.length === 0) return null + + const record = result.records[0] + const wrNode = record.get("wr") + const repoNode = record.get("repo") + const issueNode = record.get("issue") + const commitNode = record.get("commit") + const actorNode = record.get("actor") + const webhookEventNode = record.get("webhookEvent") + const senderNode = record.get("sender") + + // Validate and parse WorkflowRun + const wr = workflowRunSchema.parse(wrNode.properties) + + // Parse repository to get installationId + const repo = repoNode ? repositorySchema.parse(repoNode.properties) : null + const installationId = repo?.githubInstallationId ?? "" + + // Map optional relationships + const actor = mapActor( + actorNode, + webhookEventNode, + senderNode, + installationId + ) + const repository = repo ? { fullName: repo.fullName } : undefined + const issue = issueNode + ? (() => { + const parsed = issueSchema.parse(issueNode.properties) + return { + repoFullName: parsed.repoFullName, + number: + typeof parsed.number === "number" + ? parsed.number + : parsed.number.toNumber(), + } + })() + : undefined + const commit = commitNode + ? (() => { + const parsed = commitSchema.parse(commitNode.properties) + return { + sha: parsed.sha, + message: parsed.message, + repository: repository!, + } + })() + : undefined + + return { + id: wr.id, + type: wr.type, + createdAt: wr.createdAt.toStandardDate(), + postToGithub: wr.postToGithub ?? false, + state: wr.state ?? "pending", + actor, + repository, + issue, + commit, + } +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/getWorkflowRunById.ts b/shared/src/adapters/neo4j/queries/workflowRuns/getWorkflowRunById.ts new file mode 100644 index 00000000..1867f330 --- /dev/null +++ b/shared/src/adapters/neo4j/queries/workflowRuns/getWorkflowRunById.ts @@ -0,0 +1,51 @@ +import { + Integer, + type ManagedTransaction, + type Node, + type QueryResult, +} from "neo4j-driver" + +import type { + Commit, + GithubUser, + GithubWebhookEvent, + Issue, + Repository, + WorkflowRun, +} from "../../types" + +const QUERY = ` + MATCH (wr:WorkflowRun { id: $id }) + OPTIONAL MATCH (wr)-[:BASED_ON_REPOSITORY]->(repo:Repository) + OPTIONAL MATCH (wr)-[:BASED_ON_ISSUE]->(issue:Issue) + OPTIONAL MATCH (wr)-[:BASED_ON_COMMIT]->(commit:Commit) + + // Get actor node for user-initiated workflows + OPTIONAL MATCH (wr)-[:INITIATED_BY]->(actor:User) + + // For webhook-triggered workflows, get the webhook event and sender + OPTIONAL MATCH (wr)-[:TRIGGERED_BY]->(webhookEvent:GithubWebhookEvent)-[:SENDER]->(sender:GithubUser) + + RETURN wr, repo, issue, commit, actor, webhookEvent, sender +` + +export interface GetWorkflowRunByIdParams { + id: string +} + +export interface GetWorkflowRunByIdResult { + wr: Node // Required + repo: Node | null + issue: Node | null + commit: Node | null + actor: Node | null // User node for user-initiated workflows + webhookEvent: Node | null + sender: Node | null +} + +export async function getWorkflowRunById( + tx: ManagedTransaction, + params: GetWorkflowRunByIdParams +): Promise> { + return await tx.run(QUERY, params) +} diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/index.ts b/shared/src/adapters/neo4j/queries/workflowRuns/index.ts index c61537f5..a87b1d13 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/index.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/index.ts @@ -1,3 +1,13 @@ +export * from "./addEvent" +export * from "./addEvent.mapper" +export * from "./attachActor" +export * from "./attachCommit" +export * from "./attachIssue" +export * from "./attachRepository" +export * from "./createWorkflowRun" +export * from "./createWorkflowRun.mapper" +export * from "./getWorkflowRunById" +export * from "./getWorkflowRunById.mapper" export * from "./listByUser" export * from "./listByUser.mapper" export * from "./listEvents" diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/listByUser.mapper.ts b/shared/src/adapters/neo4j/queries/workflowRuns/listByUser.mapper.ts index d5d8bccc..4b4b1765 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/listByUser.mapper.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/listByUser.mapper.ts @@ -1,4 +1,4 @@ -import { QueryResult } from "neo4j-driver" +import { type QueryResult } from "neo4j-driver" import { commitSchema, @@ -8,9 +8,9 @@ import { workflowRunSchema, workflowRunStateSchema, } from "@/shared/adapters/neo4j/types" -import { WorkflowRun } from "@/shared/entities/WorkflowRun" +import { type WorkflowRun } from "@/shared/entities/WorkflowRun" -import { ListByUserResult } from "./listByUser" +import { type ListByUserResult } from "./listByUser" // Maps types from Neo4j to the domain types export function mapListByUser( diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/listByUser.ts b/shared/src/adapters/neo4j/queries/workflowRuns/listByUser.ts index e75603b7..5b698af3 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/listByUser.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/listByUser.ts @@ -1,25 +1,28 @@ -import { Integer, ManagedTransaction, Node, QueryResult } from "neo4j-driver" +import { + Integer, + ManagedTransaction, + Node, + type QueryResult, +} from "neo4j-driver" import { - Commit, - GithubUser, - Issue, - User, - WorkflowRun, - WorkflowRunState, + type Commit, + type Issue, + type User, + type WorkflowRun, + type WorkflowRunState, } from "@/shared/adapters/neo4j/types" const QUERY = ` MATCH (w:WorkflowRun)-[:INITIATED_BY]->(u:User {id: $user.id}) - OPTIONAL MATCH (u)-[:LINKED_GITHUB_USER]->(gh:GithubUser) OPTIONAL MATCH (w)-[:BASED_ON_ISSUE]->(i:Issue) OPTIONAL MATCH (w)-[:BASED_ON_REPOSITORY]->(r:Repository) OPTIONAL MATCH (w)-[:BASED_ON_COMMIT]->(c:Commit) OPTIONAL MATCH (w)-[:STARTS_WITH|NEXT*]->(e:Event {type: 'workflowState'}) - WITH w, u, gh, i, r, c, e + WITH w, u, i, r, c, e ORDER BY e.createdAt DESC - WITH w, u, gh, i, r, c, collect(e)[0] as latestWorkflowState - RETURN w, u, gh, latestWorkflowState.state AS state, i, r, c + WITH w, u, i, r, c, collect(e)[0] as latestWorkflowState + RETURN w, u, latestWorkflowState.state AS state, i, r, c ` export interface ListByUserParams { @@ -29,7 +32,6 @@ export interface ListByUserParams { export interface ListByUserResult { w: Node u: Node - gh: Node | null state: WorkflowRunState i: Node | null r: Node | null diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/listEvents.mapper.ts b/shared/src/adapters/neo4j/queries/workflowRuns/listEvents.mapper.ts index 44057a81..ac1d9008 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/listEvents.mapper.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/listEvents.mapper.ts @@ -1,9 +1,9 @@ -import { QueryResult } from "neo4j-driver" +import { type QueryResult } from "neo4j-driver" -import { AllEvents } from "@/shared/entities" +import { type AllEvents } from "@/shared/entities" -import { type AnyEvent, anyEventSchema, ReviewComment } from "../../types" -import { ListEventsForWorkflowRunResult } from "./listEvents" +import { type AnyEvent, anyEventSchema, type ReviewComment } from "../../types" +import { type ListEventsForWorkflowRunResult } from "./listEvents" /** * Translates Neo4j event to domain event. diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/listEvents.ts b/shared/src/adapters/neo4j/queries/workflowRuns/listEvents.ts index 79bbe4ce..38799e2f 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/listEvents.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/listEvents.ts @@ -1,6 +1,11 @@ -import { Integer, ManagedTransaction, Node, QueryResult } from "neo4j-driver" +import { + Integer, + ManagedTransaction, + Node, + type QueryResult, +} from "neo4j-driver" -import { AnyEvent } from "@/shared/adapters/neo4j/types" +import { type AnyEvent } from "@/shared/adapters/neo4j/types" const QUERY = ` MATCH (w:WorkflowRun {id: $workflowRunId})-[:STARTS_WITH|NEXT*]->(e:Event) diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/listForIssue.mapper.ts b/shared/src/adapters/neo4j/queries/workflowRuns/listForIssue.mapper.ts index 9c794ed4..0b075ded 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/listForIssue.mapper.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/listForIssue.mapper.ts @@ -1,4 +1,4 @@ -import { QueryResult } from "neo4j-driver" +import { type QueryResult } from "neo4j-driver" import { commitSchema, @@ -6,9 +6,9 @@ import { workflowRunSchema, workflowRunStateSchema, } from "@/shared/adapters/neo4j/types" -import { WorkflowRun } from "@/shared/entities/WorkflowRun" +import { type WorkflowRun } from "@/shared/entities/WorkflowRun" -import { ListForIssueResult } from "./listForIssue" +import { type ListForIssueResult } from "./listForIssue" // Maps types from Neo4j to the domain types export function mapListForIssue( diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/listForIssue.ts b/shared/src/adapters/neo4j/queries/workflowRuns/listForIssue.ts index c4cf7d97..e40a0d3d 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/listForIssue.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/listForIssue.ts @@ -1,10 +1,15 @@ -import { Integer, ManagedTransaction, Node, QueryResult } from "neo4j-driver" +import { + Integer, + ManagedTransaction, + Node, + type QueryResult, +} from "neo4j-driver" import { - Commit, - Issue, - WorkflowRun, - WorkflowRunState, + type Commit, + type Issue, + type WorkflowRun, + type WorkflowRunState, } from "@/shared/adapters/neo4j/types" const QUERY = ` diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/listForRepo.mapper.ts b/shared/src/adapters/neo4j/queries/workflowRuns/listForRepo.mapper.ts index 85f9cb92..5592a2d7 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/listForRepo.mapper.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/listForRepo.mapper.ts @@ -1,4 +1,4 @@ -import { Node, QueryResult } from "neo4j-driver" +import { Node, type QueryResult } from "neo4j-driver" import { commitSchema, @@ -10,9 +10,12 @@ import { workflowRunSchema, workflowRunStateSchema, } from "@/shared/adapters/neo4j/types" -import { WorkflowRun, WorkflowRunActor } from "@/shared/entities/WorkflowRun" +import { + type WorkflowRun, + type WorkflowRunActor, +} from "@/shared/entities/WorkflowRun" -import { ListForRepoResult } from "./listForRepo" +import { type ListForRepoResult } from "./listForRepo" // ============================================================================ // Actor Mapping Helpers @@ -20,78 +23,46 @@ import { ListForRepoResult } from "./listForRepo" // ============================================================================ /** - * Maps a validated User node to a UserActor domain type - */ -function mapUserActor( - userNode: Node, - _githubUserNode: Node | null // Available but not needed for UserActor -): WorkflowRunActor { - const user = userSchema.parse(userNode.properties) - - return { - type: "user", - userId: user.id, - } -} - -/** - * Maps validated GithubWebhookEvent and GithubUser nodes to a WebhookActor domain type - */ -function mapWebhookActor( - webhookEventNode: Node, - senderNode: Node | null, - installationId: string -): WorkflowRunActor { - const webhookEvent = githubWebhookEventSchema.parse( - webhookEventNode.properties - ) - const sender = senderNode - ? githubUserSchema.parse(senderNode.properties) - : null - - if (!sender) { - throw new Error( - `WebhookActor requires a sender, but got null for webhook event ${webhookEvent.id}` - ) - } - - return { - type: "webhook", - source: "github", - event: webhookEvent.event, - action: webhookEvent.action, - sender: { - id: sender.id, - login: sender.login, - }, - installationId, - } -} - -/** - * Maps the actor node based on its labels (User or GithubWebhookEvent) - * Uses Zod to validate each node before composing into domain actor + * Maps actor nodes to a WorkflowRunActor domain type + * Handles two patterns: + * - User-initiated: userActor node present + * - Webhook-triggered: webhookEvent and webhookSender nodes present */ function mapActor( - actorNode: Node | null, - userGhNode: Node | null, - webhookGhNode: Node | null, + userActorNode: Node | null, + webhookEventNode: Node | null, + webhookSenderNode: Node | null, installationId: string ): WorkflowRunActor | undefined { - if (!actorNode) { - return undefined + // Webhook-triggered workflow + if (webhookEventNode && webhookSenderNode) { + const webhookEvent = githubWebhookEventSchema.parse( + webhookEventNode.properties + ) + const sender = githubUserSchema.parse(webhookSenderNode.properties) + + return { + type: "webhook", + source: "github", + event: webhookEvent.event, + action: webhookEvent.action, + sender: { + id: sender.id, + login: sender.login, + }, + installationId, + } } - const labels = Array.from(actorNode.labels || []) as string[] - - if (labels.includes("User")) { - return mapUserActor(actorNode, userGhNode) - } else if (labels.includes("GithubWebhookEvent")) { - return mapWebhookActor(actorNode, webhookGhNode, installationId) + // User-initiated workflow + if (userActorNode) { + const user = userSchema.parse(userActorNode.properties) + return { + type: "user", + userId: user.id, + } } - // Unknown actor type - log warning and return undefined - console.warn(`Unknown actor type with labels: ${labels.join(", ")}`) return undefined } @@ -109,9 +80,9 @@ export function mapListForRepoResult( return result.records.map((record) => { // Get the records const w = record.get("w") - const actorNode = record.get("actor") - const userGhNode = record.get("userGh") - const webhookGhNode = record.get("webhookGh") + const userActorNode = record.get("userActor") + const webhookEventNode = record.get("webhookEvent") + const webhookSenderNode = record.get("webhookSender") const stateNode = record.get("state") const i = record.get("i") const r = record.get("r") @@ -126,9 +97,9 @@ export function mapListForRepoResult( // Map actor using helper function with Zod validation const actor = mapActor( - actorNode, - userGhNode, - webhookGhNode, + userActorNode, + webhookEventNode, + webhookSenderNode, repo?.githubInstallationId ?? "" ) diff --git a/shared/src/adapters/neo4j/queries/workflowRuns/listForRepo.ts b/shared/src/adapters/neo4j/queries/workflowRuns/listForRepo.ts index 82ce427b..63f95411 100644 --- a/shared/src/adapters/neo4j/queries/workflowRuns/listForRepo.ts +++ b/shared/src/adapters/neo4j/queries/workflowRuns/listForRepo.ts @@ -1,30 +1,34 @@ -import { Integer, ManagedTransaction, Node, QueryResult } from "neo4j-driver" +import { + Integer, + ManagedTransaction, + Node, + type QueryResult, +} from "neo4j-driver" import { - Commit, - GithubUser, - GithubWebhookEvent, - Issue, - Repository, - User, - WorkflowRun, - WorkflowRunState, + type Commit, + type GithubUser, + type GithubWebhookEvent, + type Issue, + type Repository, + type User, + type WorkflowRun, + type WorkflowRunState, } from "@/shared/adapters/neo4j/types" // Query workflow runs for a repository by traversing BASED_ON_REPOSITORY relationship -// Also retrieves initiator information via INITIATED_BY relationship +// Retrieves actor information via INITIATED_BY (for users) and TRIGGERED_BY (for webhooks) const QUERY = ` MATCH (w:WorkflowRun)-[:BASED_ON_REPOSITORY]->(r:Repository {fullName: $repo.fullName}) - OPTIONAL MATCH (w)-[:INITIATED_BY]->(actor) - OPTIONAL MATCH (actor:User)-[:LINKED_GITHUB_USER]->(userGh:GithubUser) - OPTIONAL MATCH (actor:GithubWebhookEvent)-[:SENDER]->(webhookGh:GithubUser) + OPTIONAL MATCH (w)-[:INITIATED_BY]->(userActor:User) + OPTIONAL MATCH (w)-[:TRIGGERED_BY]->(webhookEvent:GithubWebhookEvent)-[:SENDER]->(webhookSender:GithubUser) OPTIONAL MATCH (w)-[:BASED_ON_ISSUE]->(i:Issue) OPTIONAL MATCH (w)-[:BASED_ON_COMMIT]->(c:Commit) OPTIONAL MATCH (w)-[:STARTS_WITH|NEXT*]->(e:Event {type: 'workflowState'}) - WITH w, actor, userGh, webhookGh, i, r, c, e + WITH w, userActor, webhookEvent, webhookSender, i, r, c, e ORDER BY e.createdAt DESC - WITH w, actor, userGh, webhookGh, i, r, c, collect(e)[0] as latestWorkflowState - RETURN w, actor, userGh, webhookGh, latestWorkflowState.state AS state, i, r, c + WITH w, userActor, webhookEvent, webhookSender, i, r, c, collect(e)[0] as latestWorkflowState + RETURN w, userActor, webhookEvent, webhookSender, latestWorkflowState.state AS state, i, r, c ` export interface ListForRepoParams { @@ -33,13 +37,9 @@ export interface ListForRepoParams { export interface ListForRepoResult { w: Node - actor: Node< - Integer, - User | GithubWebhookEvent, - "User" | "GithubWebhookEvent" - > | null - userGh: Node | null - webhookGh: Node | null + userActor: Node | null + webhookEvent: Node | null + webhookSender: Node | null state: WorkflowRunState i: Node | null r: Node diff --git a/shared/src/adapters/neo4j/types.ts b/shared/src/adapters/neo4j/types.ts index e3c5f4cb..f6a3e93a 100644 --- a/shared/src/adapters/neo4j/types.ts +++ b/shared/src/adapters/neo4j/types.ts @@ -9,18 +9,37 @@ import { z } from "zod" const neo4jDateTime = z.instanceof(DateTime) const neo4jInteger = z.instanceof(Integer) +export const workflowRunStateSchema = z.enum([ + "pending", + "running", + "completed", + "error", + "timedOut", +]) + +// Workflow run types - must match WorkflowRunTypes from domain +const workflowRunTypeSchema = z.enum([ + "summarizeIssue", + "generateIssueTitle", + "resolveIssue", + "createDependentPR", + "reviewPullRequest", + "commentOnIssue", +]) + export const workflowRunSchema = z.object({ id: z.string(), - type: z.string(), + type: workflowRunTypeSchema, createdAt: neo4jDateTime, postToGithub: z.boolean().optional(), + state: workflowRunStateSchema.optional(), }) // User node (app user) export const userSchema = z.object({ id: z.string(), username: z.string().optional(), // GitHub username used for auth - joinDate: neo4jDateTime, + joinDate: neo4jDateTime.optional(), }) // GithubUser node (GitHub identity) @@ -96,9 +115,9 @@ export const genericWebhookEventSchema = z.object({ // Immutable identifiers: id, nodeId // Mutable properties: fullName, owner, name, defaultBranch, visibility, hasIssues export const repositorySchema = z.object({ - id: z.string(), // GitHub numeric ID (stored as string in Neo4j) - nodeId: z.string(), // GitHub global node ID (immutable) - fullName: z.string(), // owner/repo format (mutable, can change via rename/transfer) + id: z.string().optional(), // GitHub numeric ID (stored as string in Neo4j) + nodeId: z.string().optional(), // GitHub global node ID (immutable) + fullName: z.string(), // Repository full name in "owner/name" format (mutable) owner: z.string(), // Repository owner (mutable) name: z.string(), // Repository name (mutable) defaultBranch: z.string().optional(), // Default branch name (mutable) @@ -113,38 +132,31 @@ export const repositorySchema = z.object({ // All fields are immutable - a commit's SHA is derived from its content, // so changing any field would result in a different SHA (a different commit) // Reference: https://docs.github.com/en/rest/git/commits +// Note: Most fields are optional to support progressive attachment export const commitSchema = z.object({ // Immutable identifiers sha: z.string(), // Primary key - Git SHA-1 hash (40 hex chars) - nodeId: z.string(), // GitHub GraphQL node ID + nodeId: z.string().optional(), // GitHub GraphQL node ID // Commit content (immutable) - message: z.string(), // Full commit message - treeSha: z.string(), // Git tree object SHA (represents file structure) + message: z.string().optional(), // Full commit message + treeSha: z.string().optional(), // Git tree object SHA (represents file structure) // Author (person who wrote the code) - authorName: z.string(), - authorEmail: z.string(), - authoredAt: neo4jDateTime, + authorName: z.string().optional(), + authorEmail: z.string().optional(), + authoredAt: neo4jDateTime.optional(), // Committer (person who applied the commit to the repository) // Often same as author, but differs in cases like rebasing, cherry-picking, or applying patches - committerName: z.string(), - committerEmail: z.string(), - committedAt: neo4jDateTime, + committerName: z.string().optional(), + committerEmail: z.string().optional(), + committedAt: neo4jDateTime.optional(), // Metadata about when we stored this in Neo4j createdAt: neo4jDateTime.optional(), }) -export const workflowRunStateSchema = z.enum([ - "pending", - "running", - "completed", - "error", - "timedOut", -]) - export const issueSchema = z.object({ number: neo4jInteger, createdAt: neo4jDateTime.optional(), diff --git a/shared/src/entities/Commit.ts b/shared/src/entities/Commit.ts index fbf6c359..438e530d 100644 --- a/shared/src/entities/Commit.ts +++ b/shared/src/entities/Commit.ts @@ -15,7 +15,7 @@ export interface Commit { * Commit message (first line or full message) * Used for display in UI and logs */ - message: string + message?: string repository: { fullName: string diff --git a/shared/src/entities/Queue.ts b/shared/src/entities/Queue.ts index cd061afc..fa1f230c 100644 --- a/shared/src/entities/Queue.ts +++ b/shared/src/entities/Queue.ts @@ -1,5 +1,7 @@ import { z } from "zod" +// TODO: Consider, does this belong in Channels.ts instead? +// Or should this be closer to the neo4j adapters? export const WORKFLOW_JOBS_QUEUE = "workflow-jobs" export const QueueEnum = z.enum([WORKFLOW_JOBS_QUEUE]) diff --git a/shared/src/entities/WorkflowRun.ts b/shared/src/entities/WorkflowRun.ts index 64a5df65..6bfe3b2e 100644 --- a/shared/src/entities/WorkflowRun.ts +++ b/shared/src/entities/WorkflowRun.ts @@ -1,4 +1,4 @@ -import { Commit } from "./Commit" +import type { Commit } from "./Commit" export interface UserActor { type: "user" @@ -16,9 +16,17 @@ export interface WebhookActor { export type WorkflowRunActor = UserActor | WebhookActor +export type WorkflowRunTypes = + | "summarizeIssue" + | "generateIssueTitle" + | "resolveIssue" + | "createDependentPR" + | "reviewPullRequest" + | "commentOnIssue" + export interface WorkflowRun { id: string - type: string + type: WorkflowRunTypes createdAt: Date postToGithub: boolean state: "pending" | "running" | "completed" | "error" | "timedOut" diff --git a/shared/src/lib/neo4j/services/workflow.ts b/shared/src/lib/neo4j/services/workflow.ts index 71732902..3724d9c4 100644 --- a/shared/src/lib/neo4j/services/workflow.ts +++ b/shared/src/lib/neo4j/services/workflow.ts @@ -40,6 +40,8 @@ import { withTiming } from "@/shared/utils/telemetry" * The function then links the WorkflowRun to the Issue with the following pattern: * (w:WorkflowRun)-[:BASED_ON_ISSUE]->(i:Issue) * and returns the application representations of both. + * + * @deprecated Use StorageAdapter.workflow.run.create instead. This legacy function does not create Repository/User attribution. */ export async function initializeWorkflowRun({ id, @@ -118,6 +120,7 @@ function deriveState( /** * Returns workflows with run state and connected issue (if any) + * @deprecated Use StorageAdapter.workflow.run.list instead */ export async function listWorkflowRuns(issue?: { repoFullName: string diff --git a/shared/src/ports/db/index.ts b/shared/src/ports/db/index.ts index 3c8318c3..0d8273ec 100644 --- a/shared/src/ports/db/index.ts +++ b/shared/src/ports/db/index.ts @@ -1,61 +1,76 @@ -import { AllEvents } from "@/shared/entities" +import type { AllEvents } from "@/shared/entities" import { - UserActor, - WebhookActor, - WorkflowRun, + type WorkflowRun, + type WorkflowRunActor, + type WorkflowRunTypes, } from "@/shared/entities/WorkflowRun" -export interface DatabaseStorage { - workflow: { - run: { - create(input: CreateWorkflowRunInput): Promise - getById(id: string): Promise - list(filter: WorkflowRunFilter): Promise - listEvents(runId: string): Promise - } +export type Target = { + issue?: { id?: string; number: number; repoFullName: string } + ref?: + | { type: "commit"; sha: string } + | { type: "branch"; name: string } + | { type: "tag"; name: string } + repository?: { + id?: number + nodeId?: string + owner?: string + name?: string + githubInstallationId?: string } } +export type WorkflowRunConfig = { + postToGithub?: boolean +} +export type WorkflowRunTrigger = { type: "ui" | "webhook" } export interface CreateWorkflowRunInput { - id: string - type: string - issueNumber: number - repository: { - id: number - nodeId: string - fullName: string - owner: string - name: string - defaultBranch?: string - visibility?: "PUBLIC" | "PRIVATE" | "INTERNAL" - hasIssues?: boolean - } - postToGithub: boolean - actor: UserActor | WebhookActor - // Optional: Commit information for the workflow run - // MIGRATION NOTE: Should be provided whenever possible - // Fetch from GitHub API: GET /repos/{owner}/{repo}/commits/{ref} - // where ref is the default branch or specific branch/tag - commit?: { - sha: string // Git commit SHA (40 hex chars) - nodeId: string // GitHub GraphQL node ID - message: string // Commit message - treeSha: string // Git tree SHA - author: { - name: string - email: string - date: string // ISO 8601 format - } - committer: { - name: string - email: string - date: string // ISO 8601 format - } - } + id?: string + type: WorkflowRunTypes + trigger?: WorkflowRunTrigger + actor?: WorkflowRunActor + target?: Target + config?: WorkflowRunConfig +} + +export interface WorkflowEventInput { + type: AllEvents["type"] + payload: unknown + createdAt?: string +} + +export interface RepositoryAttachment { + id: number + nodeId?: string + fullName: string + owner: string + name: string + githubInstallationId?: string +} + +export interface IssueAttachment { + number: number + repoFullName: string +} + +export interface CommitAttachment { + sha: string + nodeId?: string + message?: string } export interface WorkflowRunHandle { - id: string + readonly run: WorkflowRun + add: { + event(event: WorkflowEventInput): Promise + } + attach: { + target(target: Target): Promise + actor(actor: WorkflowRunActor): Promise + repository(repo: RepositoryAttachment): Promise + issue(issue: IssueAttachment): Promise + commit(commit: CommitAttachment): Promise + } } export interface WorkflowRunFilter { @@ -63,3 +78,16 @@ export interface WorkflowRunFilter { repositoryId?: string issueNumber?: number } + +export interface DatabaseStorage { + workflow: { + run: { + create(input: CreateWorkflowRunInput): Promise + getById(id: string): Promise + list(filter: WorkflowRunFilter): Promise + } + events: { + list(runId: string): Promise + } + } +} diff --git a/shared/src/usecases/workflows/autoResolveIssue.ts b/shared/src/usecases/workflows/autoResolveIssue.ts index 2406bdb3..68881d6f 100644 --- a/shared/src/usecases/workflows/autoResolveIssue.ts +++ b/shared/src/usecases/workflows/autoResolveIssue.ts @@ -19,13 +19,13 @@ import { createStatusEvent, createWorkflowStateEvent, } from "@/shared/lib/neo4j/services/event" -import { initializeWorkflowRun } from "@/shared/lib/neo4j/services/workflow" import { type RepoEnvironment } from "@/shared/lib/types" import { createContainerizedDirectoryTree, createContainerizedWorkspace, } from "@/shared/lib/utils/container" import { setupLocalRepository } from "@/shared/lib/utils/utils-server" +import { type DatabaseStorage } from "@/shared/ports/db" import { type EventBusPort } from "@/shared/ports/events/eventBus" import { createWorkflowEventPublisher } from "@/shared/ports/events/publisher" import { type SettingsReaderPort } from "@/shared/ports/repositories/settings.reader" @@ -43,6 +43,7 @@ interface Params { interface AutoResolveIssuePorts { settings: SettingsReaderPort + storage: DatabaseStorage eventBus?: EventBusPort } export const autoResolveIssue = async ( @@ -50,7 +51,7 @@ export const autoResolveIssue = async ( ports: AutoResolveIssuePorts ) => { const { issueNumber, repoFullName, login, jobId, branch } = params - const { settings, eventBus } = ports + const { settings, eventBus, storage } = ports // ================================================= // Step 0: Setup workflow publisher @@ -86,19 +87,37 @@ export const autoResolveIssue = async ( const issue = issueResult.issue const access_token = getAccessTokenOrThrow() const octokit = new Octokit({ auth: access_token }) + + // To consider: I would think that we should initialize the workflow run before we start any other operations, including data fetching. const repository = await octokit.rest.repos.get({ owner, repo }) // ================================================= // Step 2: Initialize workflow // ================================================= + // TODO: This should come before API queries to Github using our new port. + // Later, after getting the repo and issue details, we can attach them to the workflow run. try { - await initializeWorkflowRun({ + await storage.workflow.run.create({ id: workflowId, - type: "autoResolveIssue", - issueNumber, - repoFullName, - postToGithub: true, + type: "resolveIssue", + target: { + issue: { + id: issue.id.toString(), + repoFullName: repoFullName, + number: issue.number, + }, + repository: { + id: repository.data.id, + nodeId: repository.data.node_id, + owner: repository.data.owner?.login ?? owner, + name: repository.data.name ?? repo, + }, + }, + actor: { type: "user", userId: login }, + config: { + postToGithub: true, + }, }) await createWorkflowStateEvent({ workflowId, state: "running" })