This commit is contained in:
Frank 2025-08-24 16:30:13 +08:00
parent 0fd312346b
commit 98f021f38b
12 changed files with 1117 additions and 290 deletions

View file

@ -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=="],

View file

@ -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<ReturnType<typeof getUserPrompt>>["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<true>().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<IssueCommentEvent>()
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<IssueCommentEvent>().issue.pull_request)
}
function useIssueId() {
const payload = useContext().payload as IssueCommentEvent
return payload.issue.number
return Context.payload<IssueCommentEvent>().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<IssueCommentEvent>().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<IssueCommentEvent>().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 `<a href="${useShareUrl()}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
})()
const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId})&nbsp;&nbsp;|&nbsp;&nbsp;` : ""
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<IssueQueryResponse>(
const graph = await GitHub.graph()
const issueResult = await graph<IssueQueryResponse>(
`
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<IssueCommentEvent>().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<PullRequestQueryResponse>(
const graph = await GitHub.graph()
const prResult = await graph<PullRequestQueryResponse>(
`
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<IssueCommentEvent>().comment.id
})
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
@ -966,17 +747,3 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
"</pull_request>",
].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",
},
})
}

View file

@ -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"
}
}

467
github/review.ts Normal file
View file

@ -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<Finding[]> {
console.log("Finding violations...")
const filename = "pr-violations.json"
const prompt = `A new pull request has been created:
<pr-number>
${Context.payloadPullRequest().number}
</pr-number>
<pr-title>
${Context.payloadPullRequest().title}
</pr-title>
<pr-description>
${Context.payloadPullRequest().body}
</pr-description>
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:
<file>
${finding.file}
</file>
<issue>
${finding.description}
${finding.related?.map((r) => `- ${r}`).join("\n")}
</issue>
`
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}
<details>
<summary>View suggestion: <code>${additions === 1 ? "1 addition" : `${additions} additions`}</code> and <code>${deletions === 1 ? "1 deletion" : `${deletions} deletions`}</code> in <code>${files.length === 1 ? files[0] : `${files.length} files`}</code></summary>
${GitHub.commentSectionBuild("diff", ["```diff", ...diffLines, "```"])}
</details>
---
${GitHub.commentSectionBuild("commit", ["- [ ] 👈 Check here to commit suggestion"])}
${GitHub.commentDataBuild("finding", finding)}
`,
},
}
}
async function createReview(comments: Awaited<ReturnType<typeof fixFinding>>["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<PullRequestReviewCommentEditedEvent>().comment
const commitSection = GitHub.commentSectionParse(comment.body, "commit")
const diffSection = GitHub.commentSectionParse(comment.body, "diff")
const findingData = GitHub.commentDataParse<Finding>(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<PullRequestReviewCommentEditedEvent>().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<PullRequestReviewCommentEditedEvent>().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,
},
)
}
}

67
github/src/auth.ts Normal file
View file

@ -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",
},
})
}
}

40
github/src/context.ts Normal file
View file

@ -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<T>() {
return state().context.payload as T
}
export function payloadPullRequest() {
const pr = Context.payload<any>().pull_request
if (!pr) throw new Error("Pull request not found in context payload")
return pr as PullRequest
}
}

97
github/src/git.ts Normal file
View file

@ -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}`
}
}

71
github/src/github.ts Normal file
View file

@ -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 [`<!-- sec:${sectionName}:start -->`, ...lines, `<!-- sec:${sectionName}:end -->`].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(`<!-- sec:${sectionName}:start -->`))
const endIndex = lines.findIndex((l) => l.startsWith(`<!-- sec:${sectionName}:end -->`))
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(`<!-- sec:${sectionName}:start -->`))
const endIndex = oldLines.findIndex((l) => l.startsWith(`<!-- sec:${sectionName}:end -->`))
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<string, any>) {
const encoded = Buffer.from(JSON.stringify(data)).toString("base64")
return `<!-- data:${dataName}:${encoded} -->`
}
export function commentDataParse<T>(body: string, dataName: string) {
const data = body.match(new RegExp(`<!-- data:${dataName}:([^\s]+)\s*-->`, "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
}
}

11
github/src/lazy.ts Normal file
View file

@ -0,0 +1,11 @@
export function lazy<T>(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
}
}

27
github/src/mock.ts Normal file
View file

@ -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
}
}

178
github/src/opencode.ts Normal file
View file

@ -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<true>()
.then((r) => r.data)
const chat = await client().session.chat<true>({
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<true>()
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<string, [string, string]> = {
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
}
}
})()
}
}

102
github/src/types.ts Normal file
View file

@ -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
}
}