From 98f021f38b2ced29695690a16ca68f83b9664222 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 24 Aug 2025 16:30:13 +0800 Subject: [PATCH] sync --- github/bun.lock | 4 +- github/index.ts | 341 +++++------------------------- github/package.json | 2 +- github/review.ts | 467 +++++++++++++++++++++++++++++++++++++++++ github/src/auth.ts | 67 ++++++ github/src/context.ts | 40 ++++ github/src/git.ts | 97 +++++++++ github/src/github.ts | 71 +++++++ github/src/lazy.ts | 11 + github/src/mock.ts | 27 +++ github/src/opencode.ts | 178 ++++++++++++++++ github/src/types.ts | 102 +++++++++ 12 files changed, 1117 insertions(+), 290 deletions(-) create mode 100644 github/review.ts create mode 100644 github/src/auth.ts create mode 100644 github/src/context.ts create mode 100644 github/src/git.ts create mode 100644 github/src/github.ts create mode 100644 github/src/lazy.ts create mode 100644 github/src/mock.ts create mode 100644 github/src/opencode.ts create mode 100644 github/src/types.ts diff --git a/github/bun.lock b/github/bun.lock index 5fb125a7c..e9be43ac4 100644 --- a/github/bun.lock +++ b/github/bun.lock @@ -8,7 +8,7 @@ "@actions/github": "6.0.1", "@octokit/graphql": "9.0.1", "@octokit/rest": "22.0.0", - "@opencode-ai/sdk": "0.5.4", + "@opencode-ai/sdk": "^0.5.13", }, "devDependencies": { "@types/bun": "latest", @@ -55,7 +55,7 @@ "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@0.5.4", "", {}, "sha512-bNT9hJgTvmnWGZU4LM90PMy60xOxxCOI5IaGB5voP2EVj+8RdLxmkwuAB4FUHwLo7fNlmxkZp89NVsMYw2Y3Aw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@0.5.13", "", {}, "sha512-O+NFD/8390l/zlnjBlPNhDIwGmd2jiZuAZ4j7h9d+u5Fx7H5ESS0THjRAYIDEV0aYYY9JKvQsF+05v0bbIDr7A=="], "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], diff --git a/github/index.ts b/github/index.ts index 4d0e9e68a..cd0c73547 100644 --- a/github/index.ts +++ b/github/index.ts @@ -1,148 +1,37 @@ import { $ } from "bun" import path from "node:path" -import { Octokit } from "@octokit/rest" -import { graphql } from "@octokit/graphql" import * as core from "@actions/core" -import * as github from "@actions/github" -import type { Context as GitHubContext } from "@actions/github/lib/context" import type { IssueCommentEvent } from "@octokit/webhooks-types" import { createOpencodeClient } from "@opencode-ai/sdk" import { spawn } from "node:child_process" - -type GitHubAuthor = { - login: string - name?: string -} - -type GitHubComment = { - id: string - databaseId: string - body: string - author: GitHubAuthor - createdAt: string -} - -type GitHubReviewComment = GitHubComment & { - path: string - line: number | null -} - -type GitHubCommit = { - oid: string - message: string - author: { - name: string - email: string - } -} - -type GitHubFile = { - path: string - additions: number - deletions: number - changeType: string -} - -type GitHubReview = { - id: string - databaseId: string - author: GitHubAuthor - body: string - state: string - submittedAt: string - comments: { - nodes: GitHubReviewComment[] - } -} - -type GitHubPullRequest = { - title: string - body: string - author: GitHubAuthor - baseRefName: string - headRefName: string - headRefOid: string - createdAt: string - additions: number - deletions: number - state: string - baseRepository: { - nameWithOwner: string - } - headRepository: { - nameWithOwner: string - } - commits: { - totalCount: number - nodes: Array<{ - commit: GitHubCommit - }> - } - files: { - nodes: GitHubFile[] - } - comments: { - nodes: GitHubComment[] - } - reviews: { - nodes: GitHubReview[] - } -} - -type GitHubIssue = { - title: string - body: string - author: GitHubAuthor - createdAt: string - state: string - comments: { - nodes: GitHubComment[] - } -} - -type PullRequestQueryResponse = { - repository: { - pullRequest: GitHubPullRequest - } -} - -type IssueQueryResponse = { - repository: { - issue: GitHubIssue - } -} +import type { GitHubIssue, GitHubPullRequest, IssueQueryResponse, PullRequestQueryResponse } from "./src/types" +import { Context } from "./src/context" +import { Mock } from "./src/mock" +import { Auth } from "./src/auth" +import { Git } from "./src/git" +import { GitHub } from "./src/github" const { client, server } = createOpencode() -let accessToken: string -let octoRest: Octokit -let octoGraph: typeof graphql let commentId: number -let gitConfig: string let session: { id: string; title: string; version: string } let shareId: string | undefined let exitCode = 0 type PromptFiles = Awaited>["promptFiles"] try { - assertContextEvent("issue_comment") + Context.assertEventName("issue_comment") assertPayloadKeyword() await assertOpencodeConnected() - accessToken = await getAccessToken() - octoRest = new Octokit({ auth: accessToken }) - octoGraph = graphql.defaults({ - headers: { authorization: `token ${accessToken}` }, - }) - const { userPrompt, promptFiles } = await getUserPrompt() - await configureGit(accessToken) + await Git.configure() await assertPermissions() const comment = await createComment() commentId = comment.data.id // Setup opencode session - const repoData = await fetchRepo() + const repoData = await GitHub.repoData() session = await client.session.create().then((r) => r.data) await subscribeSessionEvents() shareId = await (async () => { @@ -219,8 +108,8 @@ try { //core.setOutput("prepare_error", e.message); } finally { server.close() - await restoreGitConfig() - await revokeAppToken() + await Git.restore() + await Auth.revoke() } process.exit(exitCode) @@ -238,7 +127,7 @@ function createOpencode() { } function assertPayloadKeyword() { - const payload = useContext().payload as IssueCommentEvent + const payload = Context.payload() const body = payload.comment.body.trim() if (!body.match(/(?:^|\s)(?:\/opencode|\/oc)(?=$|\s)/)) { throw new Error("Comments must mention `/opencode` or `/oc`") @@ -262,14 +151,6 @@ async function assertOpencodeConnected() { } } -function assertContextEvent(...events: string[]) { - const context = useContext() - if (!events.includes(context.eventName)) { - throw new Error(`Unsupported event type: ${context.eventName}`) - } - return context -} - function useEnvModel() { const value = process.env["MODEL"] if (!value) throw new Error(`Environment variable "MODEL" is not set`) @@ -282,15 +163,6 @@ function useEnvModel() { return { providerID, modelID } } -function useEnvRunUrl() { - const { repo } = useContext() - - const runId = process.env["GITHUB_RUN_ID"] - if (!runId) throw new Error(`Environment variable "GITHUB_RUN_ID" is not set`) - - return `/${repo.owner}/${repo.repo}/actions/runs/${runId}` -} - function useEnvShare() { const value = process.env["SHARE"] if (!value) return undefined @@ -299,90 +171,36 @@ function useEnvShare() { throw new Error(`Invalid share value: ${value}. Share must be a boolean.`) } -function useEnvMock() { - return { - mockEvent: process.env["MOCK_EVENT"], - mockToken: process.env["MOCK_TOKEN"], - } -} - function useEnvGithubToken() { return process.env["TOKEN"] } -function isMock() { - const { mockEvent, mockToken } = useEnvMock() - return Boolean(mockEvent || mockToken) -} - function isPullRequest() { - const context = useContext() - const payload = context.payload as IssueCommentEvent - return Boolean(payload.issue.pull_request) -} - -function useContext() { - return isMock() ? (JSON.parse(useEnvMock().mockEvent!) as GitHubContext) : github.context + return Boolean(Context.payload().issue.pull_request) } function useIssueId() { - const payload = useContext().payload as IssueCommentEvent - return payload.issue.number + return Context.payload().issue.number } function useShareUrl() { - return isMock() ? "https://dev.opencode.ai" : "https://opencode.ai" -} - -async function getAccessToken() { - const { repo } = useContext() - - const envToken = useEnvGithubToken() - if (envToken) return envToken - - let response - if (isMock()) { - response = await fetch("https://api.opencode.ai/exchange_github_app_token_with_pat", { - method: "POST", - headers: { - Authorization: `Bearer ${useEnvMock().mockToken}`, - }, - body: JSON.stringify({ owner: repo.owner, repo: repo.repo }), - }) - } else { - const oidcToken = await core.getIDToken("opencode-github-action") - response = await fetch("https://api.opencode.ai/exchange_github_app_token", { - method: "POST", - headers: { - Authorization: `Bearer ${oidcToken}`, - }, - }) - } - - if (!response.ok) { - const responseJson = (await response.json()) as { error?: string } - throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`) - } - - const responseJson = (await response.json()) as { token: string } - return responseJson.token + return Mock.isMock() ? "https://dev.opencode.ai" : "https://opencode.ai" } async function createComment() { - const { repo } = useContext() console.log("Creating comment...") - return await octoRest.rest.issues.createComment({ - owner: repo.owner, - repo: repo.repo, + const rest = await GitHub.rest() + return await rest.issues.createComment({ + owner: Context.repo().owner, + repo: Context.repo().repo, issue_number: useIssueId(), - body: `[Working...](${useEnvRunUrl()})`, + body: `[Working...](${GitHub.runUrl()})`, }) } async function getUserPrompt() { let prompt = (() => { - const payload = useContext().payload as IssueCommentEvent - const body = payload.comment.body.trim() + const body = Context.payload().comment.body.trim() if (body === "/opencode" || body === "/oc") return "Summarize this thread" if (body.includes("/opencode") || body.includes("/oc")) return body throw new Error("Comments must mention `/opencode` or `/oc`") @@ -419,7 +237,7 @@ async function getUserPrompt() { // Download image const res = await fetch(url, { headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: `Bearer ${await Auth.token()}`, Accept: "application/vnd.github.v3+json", }, }) @@ -530,11 +348,10 @@ async function subscribeSessionEvents() { } async function summarize(response: string) { - const payload = useContext().payload as IssueCommentEvent try { return await chat(`Summarize the following in less than 40 characters:\n\n${response}`) } catch (e) { - return `Fix issue: ${payload.issue.title}` + return `Fix issue: ${Context.payload().issue.title}` } } @@ -581,30 +398,6 @@ async function chat(text: string, files: PromptFiles = []) { return match.text } -async function configureGit(appToken: string) { - // Do not change git config when running locally - if (isMock()) return - - console.log("Configuring git...") - const config = "http.https://github.com/.extraheader" - const ret = await $`git config --local --get ${config}` - gitConfig = ret.stdout.toString().trim() - - const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64") - - await $`git config --local --unset-all ${config}` - await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"` - await $`git config --global user.name "opencode-agent[bot]"` - await $`git config --global user.email "opencode-agent[bot]@users.noreply.github.com"` -} - -async function restoreGitConfig() { - if (gitConfig === undefined) return - console.log("Restoring git config...") - const config = "http.https://github.com/.extraheader" - await $`git config --local ${config} "${gitConfig}"` -} - async function checkoutNewBranch() { console.log("Checking out new branch...") const branch = generateBranchName("issue") @@ -646,36 +439,33 @@ function generateBranchName(type: "issue" | "pr") { async function pushToNewBranch(summary: string, branch: string) { console.log("Pushing to new branch...") - const actor = useContext().actor await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${Context.actor()} <${Context.actor()}@users.noreply.github.com>"` await $`git push -u origin ${branch}` } async function pushToLocalBranch(summary: string) { console.log("Pushing to local branch...") - const actor = useContext().actor await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${Context.actor()} <${Context.actor()}@users.noreply.github.com>"` await $`git push` } async function pushToForkBranch(summary: string, pr: GitHubPullRequest) { console.log("Pushing to fork branch...") - const actor = useContext().actor const remoteBranch = pr.headRefName await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` +Co-authored-by: ${Context.actor()} <${Context.actor()}@users.noreply.github.com>"` await $`git push fork HEAD:${remoteBranch}` } @@ -686,9 +476,7 @@ async function branchIsDirty() { } async function assertPermissions() { - const { actor, repo } = useContext() - - console.log(`Asserting permissions for user ${actor}...`) + console.log(`Asserting permissions for user ${Context.actor()}...`) if (useEnvGithubToken()) { console.log(" skipped (using github token)") @@ -697,20 +485,22 @@ async function assertPermissions() { let permission try { - const response = await octoRest.repos.getCollaboratorPermissionLevel({ - owner: repo.owner, - repo: repo.repo, - username: actor, + const rest = await GitHub.rest() + const response = await rest.repos.getCollaboratorPermissionLevel({ + owner: Context.repo().owner, + repo: Context.repo().repo, + username: Context.actor(), }) permission = response.data.permission console.log(` permission: ${permission}`) } catch (error) { console.error(`Failed to check permissions: ${error}`) - throw new Error(`Failed to check permissions for user ${actor}: ${error}`) + throw new Error(`Failed to check permissions for user ${Context.actor()}: ${error}`) } - if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`) + if (!["admin", "write"].includes(permission)) + throw new Error(`User ${Context.actor()} does not have write permissions`) } async function updateComment(body: string) { @@ -718,10 +508,10 @@ async function updateComment(body: string) { console.log("Updating comment...") - const { repo } = useContext() - return await octoRest.rest.issues.updateComment({ - owner: repo.owner, - repo: repo.repo, + const rest = await GitHub.rest() + return await rest.issues.updateComment({ + owner: Context.repo().owner, + repo: Context.repo().repo, comment_id: commentId, body, }) @@ -729,10 +519,10 @@ async function updateComment(body: string) { async function createPR(base: string, branch: string, title: string, body: string) { console.log("Creating pull request...") - const { repo } = useContext() - const pr = await octoRest.rest.pulls.create({ - owner: repo.owner, - repo: repo.repo, + const rest = await GitHub.rest() + const pr = await rest.pulls.create({ + owner: Context.repo().owner, + repo: Context.repo().repo, head: branch, base, title, @@ -754,18 +544,13 @@ function footer(opts?: { image?: boolean }) { return `${titleAlt}\n` })() const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId})  |  ` : "" - return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})` -} - -async function fetchRepo() { - const { repo } = useContext() - return await octoRest.rest.repos.get({ owner: repo.owner, repo: repo.repo }) + return `\n\n${image}${shareUrl}[github run](${GitHub.runUrl()})` } async function fetchIssue() { console.log("Fetching prompt data for issue...") - const { repo } = useContext() - const issueResult = await octoGraph( + const graph = await GitHub.graph() + const issueResult = await graph( ` query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { @@ -792,8 +577,8 @@ query($owner: String!, $repo: String!, $number: Int!) { } }`, { - owner: repo.owner, - repo: repo.repo, + owner: Context.repo().owner, + repo: Context.repo().repo, number: useIssueId(), }, ) @@ -805,12 +590,10 @@ query($owner: String!, $repo: String!, $number: Int!) { } function buildPromptDataForIssue(issue: GitHubIssue) { - const payload = useContext().payload as IssueCommentEvent - const comments = (issue.comments?.nodes || []) .filter((c) => { const id = parseInt(c.databaseId) - return id !== commentId && id !== payload.comment.id + return id !== commentId && id !== Context.payload().comment.id }) .map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`) @@ -829,8 +612,8 @@ function buildPromptDataForIssue(issue: GitHubIssue) { async function fetchPR() { console.log("Fetching prompt data for PR...") - const { repo } = useContext() - const prResult = await octoGraph( + const graph = await GitHub.graph() + const prResult = await graph( ` query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { @@ -914,8 +697,8 @@ query($owner: String!, $repo: String!, $number: Int!) { } }`, { - owner: repo.owner, - repo: repo.repo, + owner: Context.repo().owner, + repo: Context.repo().repo, number: useIssueId(), }, ) @@ -927,12 +710,10 @@ query($owner: String!, $repo: String!, $number: Int!) { } function buildPromptDataForPR(pr: GitHubPullRequest) { - const payload = useContext().payload as IssueCommentEvent - const comments = (pr.comments?.nodes || []) .filter((c) => { const id = parseInt(c.databaseId) - return id !== commentId && id !== payload.comment.id + return id !== commentId && id !== Context.payload().comment.id }) .map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`) @@ -966,17 +747,3 @@ function buildPromptDataForPR(pr: GitHubPullRequest) { "", ].join("\n") } - -async function revokeAppToken() { - if (!accessToken) return - console.log("Revoking app token...") - - await fetch("https://api.github.com/installation/token", { - method: "DELETE", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - }) -} diff --git a/github/package.json b/github/package.json index 3be63d331..bd0c99a96 100644 --- a/github/package.json +++ b/github/package.json @@ -14,6 +14,6 @@ "@actions/github": "6.0.1", "@octokit/graphql": "9.0.1", "@octokit/rest": "22.0.0", - "@opencode-ai/sdk": "0.5.4" + "@opencode-ai/sdk": "0.5.13" } } diff --git a/github/review.ts b/github/review.ts new file mode 100644 index 000000000..c90c42ffc --- /dev/null +++ b/github/review.ts @@ -0,0 +1,467 @@ +import { $ } from "bun" +import os from "node:os" +import * as core from "@actions/core" +import type { PullRequestReviewCommentEditedEvent } from "@octokit/webhooks-types" +import { Context } from "./src/context" +import { Auth } from "./src/auth" +import { Git } from "./src/git" +import { GitHub } from "./src/github" +import { Opencode } from "./src/opencode" + +type Finding = { + file: string + line: number + description: string + related?: string[] +} + +try { + switch (Context.eventName()) { + case "pull_request_opened": + case "pull_request_synchronize": + await review() + break + case "pull_request_review_comment_edited": + await commitSuggestion() + break + default: + throw new Error(`Unsupported event type: ${Context.eventName()}`) + } + process.exit(0) +} catch (e: any) { + console.error(e) + let msg = e + if (e instanceof $.ShellError) msg = e.stderr.toString() + else if (e instanceof Error) msg = e.message + core.setFailed(msg) + // Also output the clean error message for the action to capture + //core.setOutput("prepare_error", e.message); + process.exit(1) +} + +export async function review() { + try { + await Opencode.start() + await Git.checkoutPrBranch() + + // List violations + const findings = await listFindings() + await Git.resetBranch() + console.log("findings", findings) + + // Fix each violation + const comments = [] + for (const finding of findings) { + const fix = await fixFinding(finding) + await Git.resetBranch() + comments.push(fix.comment) + } + + await createReview(comments) + } finally { + Opencode.closeServer() + await Auth.revoke() + } + + async function buildHunkValidator() { + const rest = await GitHub.rest() + const prRest = await rest.pulls.listFiles({ + owner: Context.repo().owner, + repo: Context.repo().repo, + pull_number: Context.payloadPullRequest().number, + per_page: 100, + }) + const prFiles = prRest.data.map((d) => ({ + filename: d.filename, + hunks: (d.patch?.split("\n") ?? []) + .filter((l) => l.startsWith("@@")) + .map((l) => { + // @@ -4,6 +4,7 @@ import { DynamoDBClient } from \"@aws-sdk/client-dynamodb\"; + const parts = l.split(" ") + const newInfo = parts[2]!.slice(1).split(",") + const start = Number(newInfo[0]) + const lines = Number(newInfo[1] ?? "1") + const end = start + lines - 1 + return { start, end } + }), + })) + return (file: string, start: number, end: number) => { + const hunks = prFiles.find((f) => f.filename === file)?.hunks + if (!hunks) return false + const startHunk = hunks?.find((h) => start >= h.start && start <= h.end) + if (!startHunk) return false + const endHunk = hunks?.find((h) => end >= h.start && end <= h.end) + if (!endHunk) return false + return startHunk.start === endHunk.start + } + } + + async function listFindings(): Promise { + console.log("Finding violations...") + + const filename = "pr-violations.json" + const prompt = `A new pull request has been created: + + +${Context.payloadPullRequest().number} + + + +${Context.payloadPullRequest().title} + + + +${Context.payloadPullRequest().body} + + +Review all code changes in this pull request and identify issues. Read the entire file to get context, but only report issues tied to changed lines. + +Produce a list of issues with the following fields: + - file: Path to the file with the issue. Must be a file included in the pull request's changed patch (e.g. "path/to/file.ts") + - line: Line number of the issue. Must be a line included in the pull request's changed patch (e.g. 7) + - description: A one-sentence description of the issue (e.g. "Unused variable") + +Write the list of issues to ${filename} in this format: + \`\`\` + [ + { + "file": "string", + "line": number, + "description": "string" + }, + { + "file": "string", + "line": number, + "description": "string" + }, + { + "file": "string", + "line": number, + "description": "string" + } + ] + \`\`\` + +Do not suggest fixes, only flag issues.` + + await Opencode.chat(prompt) + + try { + const unique: Finding[] = [] + const findings = (await Bun.file(filename).json()) as Finding[] + for (const f of findings) { + const existing = unique.find((u) => u.file === f.file && u.line === f.line) + if (existing) { + existing.related = [...(existing.related ?? []), f.description] + continue + } + unique.push(f) + } + return unique + } catch (e) {} + + return [] + } + + async function fixFinding(finding: Finding) { + console.log("Fixing finding:", finding) + + // Fix + const prompt = `Fix the issue: + + +${finding.file} + + + +${finding.description} +${finding.related?.map((r) => `- ${r}`).join("\n")} + +` + + const response = await Opencode.chat(prompt) + console.log("fix", response) + + // get git diff + /** + * Example diff: + * + * ``` + * diff --git a/packages/functions/src/foo.ts b/packages/functions/src/foo.ts + * index ef8a79d..205f2a8 100644 + * --- a/packages/functions/src/foo.ts + * +++ b/packages/functions/src/foo.ts + * @@ -4,7 +4,8 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; + * import { DeleteCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; + * + * const dynamoDb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + * -const dynamoDb4 = DynamoDBDocumentClient.from(new DynamoDBClient({})); + * + + * +// Suggestion: Remove unused variable dynamoDb4 + * + * export const main = Util.handler(async (event) => { + * const params = { + * @@ -20,16 +21,4 @@ export const main = Util.handler(async (event) => { + * ... + * ``` + */ + const diff0 = await $`git diff --unified=0 --patch`.text() + const diff0Lines = diff0.trim().split("\n") + const blockNum = diff0Lines.filter((l) => l.startsWith("@@ ")).length + + // Case: no blocks => create comment + if (blockNum === 0) { + return { + type: "notice", + finding, + comment: { + path: finding.file, + line: finding.line, + side: "RIGHT", + body: [`### ${finding.description}`, "", response].join("\n"), + }, + } + } + + // Case: 1 block => create suggestion + if (blockNum === 1) { + let file + let start + let lines + let newLines = [] + for (const line of diff0Lines) { + if (line.startsWith("diff --git")) { + file = line.split(" ")[2]?.slice(2)! + continue + } + if (line.startsWith("--- ") || line.startsWith("+++ ") || line.startsWith("index ")) continue + if (line.startsWith("@@ ")) { + const parts = line.split(" ") + const oldInfo = parts[1]!.slice(1).split(",") + start = Number(oldInfo[0]) + lines = Number(oldInfo[1] ?? "1") + continue + } + if (!line.startsWith("-")) newLines.push(line.slice(1)) + } + + const hunkValidator = await buildHunkValidator() + if (hunkValidator(file!, start!, start! + lines! - 1)) { + return { + type: "suggestion", + finding, + comment: { + path: file!, + start_line: lines === 1 ? undefined : start!, + line: start! + lines! - 1, + side: "RIGHT", + start_side: "RIGHT", + body: [`### ${finding.description}`, "", response, "", "```suggestion", ...newLines, "```"].join("\n"), + }, + } + } + } + + // Case: multiple blocks => create PR + const diffFull = await $`git diff --patch`.text() + let files = [] + let additions = 0 + let deletions = 0 + let diffLines = [] + for (const line of diffFull.trim().split("\n")) { + // count additions, deletions, and files + if (line.startsWith("diff --git")) files.push(line.split(" ")[2]?.slice(2)!) + else if (line.startsWith("+") && !line.startsWith("+++")) additions++ + else if (line.startsWith("-") && !line.startsWith("---")) deletions++ + + // add to lines + if (line.startsWith("diff --git")) { + diffLines.push(`diff -- ${line.split(" ")[2]?.slice(2)!}`) + } else if (!line.startsWith("+++") && !line.startsWith("---") && !line.startsWith("index")) { + diffLines.push(line) + } + } + return { + type: "diff", + finding, + comment: { + path: finding.file, + line: finding.line, + side: "RIGHT", + body: `### ${finding.description} + +${response} + +
+ View suggestion: ${additions === 1 ? "1 addition" : `${additions} additions`} and ${deletions === 1 ? "1 deletion" : `${deletions} deletions`} in ${files.length === 1 ? files[0] : `${files.length} files`} + +${GitHub.commentSectionBuild("diff", ["```diff", ...diffLines, "```"])} + +
+ +--- + +${GitHub.commentSectionBuild("commit", ["- [ ] 👈 Check here to commit suggestion"])} + +${GitHub.commentDataBuild("finding", finding)} +`, + }, + } + } + + async function createReview(comments: Awaited>["comment"][]) { + console.log("Creating review...") + + const rest = await GitHub.rest() + await rest.pulls.createReview({ + owner: Context.repo().owner, + repo: Context.repo().repo, + pull_number: Context.payloadPullRequest().number, + event: "COMMENT", + ...(comments.length + ? { + comments, + } + : { + body: "Review completed - no issues found ✅", + }), + }) + } +} + +export async function commitSuggestion() { + try { + await Git.configure() + + await updateReview("- [x] ⏳ Committing suggestion…") + const thread = await fetchReviewThread() + if (thread.isResolved) throw new Error("Review thread is already resolved") + + const comment = Context.payload().comment + const commitSection = GitHub.commentSectionParse(comment.body, "commit") + const diffSection = GitHub.commentSectionParse(comment.body, "diff") + const findingData = GitHub.commentDataParse(comment.body, "finding") + + if (!commitSection.some((l) => l.includes("- [x]"))) throw new Error("Commit button is not checked") + + const startIndex = diffSection.findIndex((l) => l.startsWith("```diff")) + const endIndex = diffSection.findLastIndex((l) => l.startsWith("```")) + if (startIndex === -1 || endIndex === -1) throw new Error("Cannot find diff in review comment") + const diff = diffSection.slice(startIndex, endIndex) + + // Fix issue + await Git.checkoutPrBranch() + const diffFile = `${os.tmpdir()}/patch.diff` + console.log("diff", diffFile) + await Bun.write(diffFile, diff.join("\n")) + await $`git apply ${diffFile}` + await Git.pushBranch(findingData.description.length ? findingData.description : "Fix issue") + + // Done + await resolveReviewThread(thread.id) + await updateReview("- [x] ✅ Suggestion committed successfully") + console.log("diff", diff) + } catch (e: any) { + let msg + if (e instanceof $.ShellError) + msg = e.stderr.toString().includes("patch does not apply") ? "the suggestion is outdated" : e.stderr.toString() + else if (e instanceof Error) msg = e.message + else msg = e.toString() + await updateReview(`- [x] ⚠️ Commit failed: ${msg} [view log](${GitHub.runUrl()})`) + throw e + } finally { + await Auth.revoke() + await Git.restore() + } + + async function fetchReviewThread() { + console.log("Fetching review thread...") + const graph = await GitHub.graph() + const result = await graph<{ + repository: { + pullRequest: { + reviewThreads: { + nodes: { + id: string + isResolved: boolean + comments: { + nodes: { + id: string + }[] + } + }[] + } + } + } + }>( + ` +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(last: 100) { + nodes { + id + isResolved + comments(last: 100) { + nodes { + id + } + } + } + } + } + } +}`, + { + owner: Context.repo().owner, + repo: Context.repo().repo, + number: Context.payloadPullRequest().number, + }, + ) + + const comment = Context.payload().comment + const thread = result.repository.pullRequest.reviewThreads.nodes.find((t) => + t.comments.nodes.some((c) => c.id === comment.node_id), + ) + if (!thread) throw new Error(`PR #${Context.payloadPullRequest().number} not found`) + + return { + id: thread.id, + isResolved: thread.isResolved, + } + } + + async function updateReview(commitSection: string) { + console.log("Creating review...") + + const comment = Context.payload().comment + const body = GitHub.commentSectionUpdate(comment.body, "commit", [commitSection]) + + const rest = await GitHub.rest() + await rest.pulls.updateReviewComment({ + owner: Context.repo().owner, + repo: Context.repo().repo, + comment_id: comment.id, + body, + }) + } + + async function resolveReviewThread(threadId: string) { + console.log("Resolving review thread...") + const graph = await GitHub.graph() + await graph( + ` +mutation($id: ID!) { + resolveReviewThread(input:{threadId:$id}) { + thread { + id + isResolved + } + } +}`, + { + id: threadId, + }, + ) + } +} diff --git a/github/src/auth.ts b/github/src/auth.ts new file mode 100644 index 000000000..627999431 --- /dev/null +++ b/github/src/auth.ts @@ -0,0 +1,67 @@ +import { getIDToken } from "@actions/core" +import { lazy } from "./lazy" +import { Mock } from "./mock" +import { Context } from "./context" + +export namespace Auth { + let init = false + + export const state = lazy(async () => { + init = true + + const envToken = process.env["TOKEN"] + if (envToken) return { token: envToken } + + let response + if (Mock.isMock()) { + response = await fetch("https://api.opencode.ai/exchange_github_app_token_with_pat", { + method: "POST", + headers: { + Authorization: `Bearer ${Mock.token()}`, + }, + body: JSON.stringify({ owner: Context.repo().owner, repo: Context.repo().repo }), + }) + } else { + const oidcToken = await getIDToken("opencode-github-action") + response = await fetch("https://api.opencode.ai/exchange_github_app_token", { + method: "POST", + headers: { + Authorization: `Bearer ${oidcToken}`, + }, + }) + } + + if (!response.ok) { + const responseJson = (await response.json()) as { error?: string } + throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`) + } + + const responseJson = (await response.json()) as { token: string } + + return { + token: responseJson.token, + } + }) + + export async function token() { + const { token } = await state() + return token + } + + export async function revoke() { + console.log("Revoking app token...") + + if (!init) return + + const { token } = await state() + + await fetch("https://api.github.com/installation/token", { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + } +} diff --git a/github/src/context.ts b/github/src/context.ts new file mode 100644 index 000000000..6e61fde48 --- /dev/null +++ b/github/src/context.ts @@ -0,0 +1,40 @@ +import * as github from "@actions/github" +import type { PullRequest } from "@octokit/webhooks-types" +import { lazy } from "./lazy" +import { Mock } from "./mock" + +export namespace Context { + export const state = lazy(() => { + return { + context: Mock.context() ?? github.context, + } + }) + + export function assertEventName(...events: string[]) { + if (!events.includes(eventName())) { + throw new Error(`Unsupported event type: ${eventName()}`) + } + } + + export function eventName() { + return state().context.eventName + } + + export function actor() { + return state().context.actor + } + + export function repo() { + return state().context.repo + } + + export function payload() { + return state().context.payload as T + } + + export function payloadPullRequest() { + const pr = Context.payload().pull_request + if (!pr) throw new Error("Pull request not found in context payload") + return pr as PullRequest + } +} diff --git a/github/src/git.ts b/github/src/git.ts new file mode 100644 index 000000000..8e41f2815 --- /dev/null +++ b/github/src/git.ts @@ -0,0 +1,97 @@ +import { $ } from "bun" +import { Mock } from "./mock" +import { Auth } from "./auth" +import { Context } from "./context" + +export namespace Git { + const CONFIG_KEY = "http.https://github.com/.extraheader" + let extraHeaderValue: string | undefined + + export async function configure() { + // Do not change git config when running locally + if (Mock.isMock()) return + + console.log("Configuring git...") + const ret = await $`git config --local --get ${CONFIG_KEY}` + const value = ret.stdout.toString().trim() + // configure() can be called multiple times, backup the value from the first call + extraHeaderValue = extraHeaderValue ?? value + + const appToken = await Auth.token() + const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64") + + await $`git config --local --unset-all ${CONFIG_KEY}` + await $`git config --local ${CONFIG_KEY} "AUTHORIZATION: basic ${newCredentials}"` + await $`git config --global user.name "opencode-agent[bot]"` + await $`git config --global user.email "opencode-agent[bot]@users.noreply.github.com"` + } + + export async function restore() { + console.log("Restoring git config...") + + if (!extraHeaderValue) return + + await $`git config --local ${CONFIG_KEY} "${extraHeaderValue}"` + } + + export async function isConfigured() { + return Boolean(extraHeaderValue) + } + + export function isForkedPr() { + const pr = Context.payloadPullRequest() + return pr.head.repo?.full_name !== pr.base.repo.full_name + } + + export async function checkoutPrBranch() { + console.log("Checking out PR branch...") + + const pr = Context.payloadPullRequest() + const depth = Math.max(pr.commits, 20) + const fromBranch = pr.head.ref + + if (isForkedPr()) { + const newBranch = generateBranchName("pr") + await $`git remote add fork https://github.com/${pr.head.repo?.full_name}.git` + await $`git fetch fork --depth=${depth} ${fromBranch}` + await $`git checkout -b ${newBranch} fork/${fromBranch}` + } else { + await $`git fetch origin --depth=${depth} ${fromBranch}` + await $`git checkout ${fromBranch}` + } + } + + export async function pushBranch(message: string) { + console.log("Pushing branch...") + + await $`git add .` + await $`git commit -m "${message} + +Co-authored-by: ${Context.actor()} <${Context.actor()}@users.noreply.github.com>"` + + const pr = Context.payloadPullRequest() + const fromBranch = pr.head.ref + if (isForkedPr()) { + await $`git push fork HEAD:${fromBranch}` + } else { + await $`git push` + } + } + + export async function resetBranch() { + console.log("Resetting branch...") + await $`git clean -fd` + await $`git reset --hard HEAD` + } + + function generateBranchName(type: "issue" | "pr") { + const pr = Context.payloadPullRequest() + const timestamp = new Date() + .toISOString() + .replace(/[:-]/g, "") + .replace(/\.\d{3}Z/, "") + .split("T") + .join("") + return `opencode/${type}${pr.number}-${timestamp}` + } +} diff --git a/github/src/github.ts b/github/src/github.ts new file mode 100644 index 000000000..e8b75165f --- /dev/null +++ b/github/src/github.ts @@ -0,0 +1,71 @@ +import { Octokit } from "@octokit/rest" +import { graphql } from "@octokit/graphql" +import { lazy } from "./lazy" +import { Auth } from "./auth" +import { Context } from "./context" + +export namespace GitHub { + export const rest = lazy(async () => { + const client = new Octokit({ auth: await Auth.token() }) + return client.rest + }) + + export const graph = lazy(async () => + graphql.defaults({ + headers: { authorization: `token ${await Auth.token()}` }, + }), + ) + + export function runUrl() { + const runId = process.env["GITHUB_RUN_ID"] + if (!runId) throw new Error(`Environment variable "GITHUB_RUN_ID" is not set`) + + return `/${Context.repo().owner}/${Context.repo().repo}/actions/runs/${runId}` + } + + export const repoData = lazy(async () => { + const rest = await GitHub.rest() + const repo = Context.repo() + return await rest.repos.get({ owner: repo.owner, repo: repo.repo }) + }) + + export function commentSectionBuild(sectionName: string, lines: string[]) { + return [``, ...lines, ``].join("\n") + } + + export function commentSectionParse(body: string, sectionName: string) { + const lines = body + .trim() + .split("\n") + .map((l) => l.trimEnd()) + const startIndex = lines.findIndex((l) => l.startsWith(``)) + const endIndex = lines.findIndex((l) => l.startsWith(``)) + if (startIndex === -1 || endIndex === -1) throw new Error(`Cannot find section:${sectionName} in review comment`) + return lines.slice(startIndex + 1, endIndex) + } + + export function commentSectionUpdate(body: string, sectionName: string, lines: string[]) { + const oldLines = body + .trim() + .split("\n") + .map((l) => l.trimEnd()) + const startIndex = oldLines.findIndex((l) => l.startsWith(``)) + const endIndex = oldLines.findIndex((l) => l.startsWith(``)) + if (startIndex === -1 || endIndex === -1) throw new Error(`Cannot find section:${sectionName} in review comment`) + + return [...oldLines.slice(0, startIndex + 1), ...lines, ...oldLines.slice(endIndex)].join("\n") + } + + export function commentDataBuild(dataName: string, data: Record) { + const encoded = Buffer.from(JSON.stringify(data)).toString("base64") + return `` + } + + export function commentDataParse(body: string, dataName: string) { + const data = body.match(new RegExp(``, "s")) + if (!data || !data[1]) throw new Error(`Cannot find data:${dataName} in review comment`) + + const decoded = Buffer.from(data[1], "base64").toString("utf-8") + return JSON.parse(decoded) as T + } +} diff --git a/github/src/lazy.ts b/github/src/lazy.ts new file mode 100644 index 000000000..935ebe0f9 --- /dev/null +++ b/github/src/lazy.ts @@ -0,0 +1,11 @@ +export function lazy(fn: () => T) { + let value: T | undefined + let loaded = false + + return (): T => { + if (loaded) return value as T + loaded = true + value = fn() + return value as T + } +} diff --git a/github/src/mock.ts b/github/src/mock.ts new file mode 100644 index 000000000..c2672f24d --- /dev/null +++ b/github/src/mock.ts @@ -0,0 +1,27 @@ +import type { Context } from "@actions/github/lib/context" +import { lazy } from "./lazy" + +export namespace Mock { + export const state = lazy(() => { + const mockContext = process.env["MOCK_CONTEXT"] + const mockToken = process.env["MOCK_TOKEN"] + + return { + isMock: Boolean(mockContext || mockToken), + context: mockContext ? (JSON.parse(mockContext) as Context) : undefined, + token: mockToken, + } + }) + + export function isMock() { + return state().isMock + } + + export function context() { + return state().context + } + + export function token() { + return state().token + } +} diff --git a/github/src/opencode.ts b/github/src/opencode.ts new file mode 100644 index 000000000..361135329 --- /dev/null +++ b/github/src/opencode.ts @@ -0,0 +1,178 @@ +import { spawn } from "node:child_process" +import { lazy } from "./lazy" +import { createOpencodeClient } from "@opencode-ai/sdk" +import { Git } from "./git" + +export namespace Opencode { + const HOST = "127.0.0.1" + const PORT = 4096 + const SERVER_URL = `http://${HOST}:${PORT}` + + export const state = lazy(() => { + const proc = spawn(`opencode`, [`serve`, `--hostname=${HOST}`, `--port=${PORT}`]) + const client = createOpencodeClient({ baseUrl: SERVER_URL }) + + // parse models + const value = process.env["MODEL"] + if (!value) throw new Error(`Environment variable "MODEL" is not set`) + + const [providerID, ...rest] = value.split("/") + const modelID = rest.join("/") + + if (!providerID?.length || !modelID.length) + throw new Error(`Invalid model ${value}. Model must be in the format "provider/model".`) + + return { + url: SERVER_URL, + server: proc, + client, + providerID, + modelID, + } + }) + + export function url() { + return state().url + } + + export function client() { + return state().client + } + + export function closeServer() { + return state().server.kill() + } + + export async function start(onEvent?: (event: any) => void) { + state() + await waitForServer() + await subscribeSessionEvents(onEvent) + } + + export async function chat(text: string) { + console.log("Sending message to opencode...") + const { providerID, modelID } = state() + + // restore git credentials temporarily to avoid prompt injection + const isGitConfigured = await Git.isConfigured() + if (isGitConfigured) await Git.restore() + + const session = await client() + .session.create() + .then((r) => r.data) + const chat = await client().session.chat({ + path: session, + body: { + providerID, + modelID, + agent: "build", + parts: [ + { + type: "text", + text, + }, + ], + }, + }) + + if (isGitConfigured) await Git.configure() + + // @ts-ignore + const match = chat.data.parts.findLast((p) => p.type === "text") + if (!match) throw new Error("Failed to parse the text response") + + return match.text + } + + async function waitForServer() { + let retry = 0 + let connected = false + do { + try { + await client().app.get() + connected = true + break + } catch (e) {} + await new Promise((resolve) => setTimeout(resolve, 300)) + } while (retry++ < 30) + + if (!connected) { + throw new Error("Failed to connect to opencode server") + } + } + + async function subscribeSessionEvents(onEvent?: (event: any) => void) { + console.log("Subscribing to session events...") + + const TOOL: Record = { + todowrite: ["Todo", "\x1b[33m\x1b[1m"], + todoread: ["Todo", "\x1b[33m\x1b[1m"], + bash: ["Bash", "\x1b[31m\x1b[1m"], + edit: ["Edit", "\x1b[32m\x1b[1m"], + glob: ["Glob", "\x1b[34m\x1b[1m"], + grep: ["Grep", "\x1b[34m\x1b[1m"], + list: ["List", "\x1b[34m\x1b[1m"], + read: ["Read", "\x1b[35m\x1b[1m"], + write: ["Write", "\x1b[32m\x1b[1m"], + websearch: ["Search", "\x1b[2m\x1b[1m"], + } + + const response = await fetch(`${Opencode.url()}/event`) + if (!response.body) throw new Error("No response body") + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + ;(async () => { + while (true) { + try { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const lines = chunk.split("\n") + + for (const line of lines) { + if (!line.startsWith("data: ")) continue + + const jsonStr = line.slice(6).trim() + if (!jsonStr) continue + + try { + const evt = JSON.parse(jsonStr) + + if (evt.type === "message.part.updated") { + const part = evt.properties.part + + if (part.type === "tool" && part.state.status === "completed") { + const [tool, color] = TOOL[part.tool] ?? [part.tool, "\x1b[34m\x1b[1m"] + const title = + part.state.title || Object.keys(part.state.input).length > 0 + ? JSON.stringify(part.state.input) + : "Unknown" + console.log() + console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title) + } + + if (part.type === "text") { + if (part.time?.end) { + console.log() + console.log(part.text) + console.log() + } + } + } + + await onEvent?.(evt) + } catch (e) { + // Ignore parse errors + } + } + } catch (e) { + console.log("Subscribing to session events done", e) + break + } + } + })() + } +} diff --git a/github/src/types.ts b/github/src/types.ts new file mode 100644 index 000000000..74ce49cea --- /dev/null +++ b/github/src/types.ts @@ -0,0 +1,102 @@ +type GitHubAuthor = { + login: string + name?: string +} + +type GitHubComment = { + id: string + databaseId: string + body: string + author: GitHubAuthor + createdAt: string +} + +type GitHubReviewComment = GitHubComment & { + path: string + line: number | null +} + +type GitHubCommit = { + oid: string + message: string + author: { + name: string + email: string + } +} + +type GitHubFile = { + path: string + additions: number + deletions: number + changeType: string +} + +type GitHubReview = { + id: string + databaseId: string + author: GitHubAuthor + body: string + state: string + submittedAt: string + comments: { + nodes: GitHubReviewComment[] + } +} + +export type GitHubPullRequest = { + title: string + body: string + author: GitHubAuthor + baseRefName: string + headRefName: string + headRefOid: string + createdAt: string + additions: number + deletions: number + state: string + baseRepository: { + nameWithOwner: string + } + headRepository: { + nameWithOwner: string + } + commits: { + totalCount: number + nodes: Array<{ + commit: GitHubCommit + }> + } + files: { + nodes: GitHubFile[] + } + comments: { + nodes: GitHubComment[] + } + reviews: { + nodes: GitHubReview[] + } +} + +export type GitHubIssue = { + title: string + body: string + author: GitHubAuthor + createdAt: string + state: string + comments: { + nodes: GitHubComment[] + } +} + +export type PullRequestQueryResponse = { + repository: { + pullRequest: GitHubPullRequest + } +} + +export type IssueQueryResponse = { + repository: { + issue: GitHubIssue + } +}