mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
sync
This commit is contained in:
parent
0fd312346b
commit
98f021f38b
12 changed files with 1117 additions and 290 deletions
|
|
@ -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=="],
|
||||
|
||||
|
|
|
|||
341
github/index.ts
341
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<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}) | ` : ""
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
467
github/review.ts
Normal 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
67
github/src/auth.ts
Normal 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
40
github/src/context.ts
Normal 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
97
github/src/git.ts
Normal 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
71
github/src/github.ts
Normal 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
11
github/src/lazy.ts
Normal 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
27
github/src/mock.ts
Normal 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
178
github/src/opencode.ts
Normal 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
102
github/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue