diff options
| author | Frank <[email protected]> | 2025-07-16 14:58:32 +0800 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-07-16 14:59:53 +0800 |
| commit | a86d42149f52e4cb2b595016d1e81f04a0ecba3b (patch) | |
| tree | f40bde648ad8c8db158863a5981b3ba318cabe3d /sdks/github/src | |
| parent | 82a36acfe36c112ace91042b68a07b9803a61aba (diff) | |
| download | opencode-a86d42149f52e4cb2b595016d1e81f04a0ecba3b.tar.gz opencode-a86d42149f52e4cb2b595016d1e81f04a0ecba3b.zip | |
wip: github actions
Diffstat (limited to 'sdks/github/src')
| -rw-r--r-- | sdks/github/src/index.ts | 426 |
1 files changed, 189 insertions, 237 deletions
diff --git a/sdks/github/src/index.ts b/sdks/github/src/index.ts index 2ccc4e8ea..fd6e08aa0 100644 --- a/sdks/github/src/index.ts +++ b/sdks/github/src/index.ts @@ -1,341 +1,299 @@ #!/usr/bin/env bun -import os from "os"; -import path from "path"; -import { $ } from "bun"; -import { Octokit } from "@octokit/rest"; -import { graphql } from "@octokit/graphql"; -import * as core from "@actions/core"; -import * as github from "@actions/github"; -import type { IssueCommentEvent } from "@octokit/webhooks-types"; -import type { - GitHubIssue, - GitHubPullRequest, - IssueQueryResponse, - PullRequestQueryResponse, -} from "./types"; +import os from "os" +import path from "path" +import { $ } from "bun" +import { Octokit } from "@octokit/rest" +import { graphql } from "@octokit/graphql" +import * as core from "@actions/core" +import * as github from "@actions/github" +import type { IssueCommentEvent } from "@octokit/webhooks-types" +import type { GitHubIssue, GitHubPullRequest, IssueQueryResponse, PullRequestQueryResponse } from "./types" if (github.context.eventName !== "issue_comment") { - core.setFailed(`Unsupported event type: ${github.context.eventName}`); - process.exit(1); + core.setFailed(`Unsupported event type: ${github.context.eventName}`) + process.exit(1) } -const { owner, repo } = github.context.repo; -const payload = github.context.payload as IssueCommentEvent; -const actor = github.context.actor; -const issueId = payload.issue.number; -const body = payload.comment.body; - -let appToken: string; -let octoRest: Octokit; -let octoGraph: typeof graphql; -let commentId: number; -let gitCredentials: string; -let shareUrl: string | undefined; +const { owner, repo } = github.context.repo +const payload = github.context.payload as IssueCommentEvent +const actor = github.context.actor +const issueId = payload.issue.number +const body = payload.comment.body + +let appToken: string +let octoRest: Octokit +let octoGraph: typeof graphql +let commentId: number +let gitCredentials: string +let shareUrl: string | undefined let state: | { - type: "issue"; - issue: GitHubIssue; + type: "issue" + issue: GitHubIssue } | { - type: "local-pr"; - pr: GitHubPullRequest; + type: "local-pr" + pr: GitHubPullRequest } | { - type: "fork-pr"; - pr: GitHubPullRequest; - }; + type: "fork-pr" + pr: GitHubPullRequest + } async function run() { try { - const match = body.match(/^hey\s*opencode,?\s*(.*)$/); - if (!match?.[1]) throw new Error("Command must start with `hey opencode`"); - const userPrompt = match[1]; + const match = body.match(/^hey\s*opencode,?\s*(.*)$/) + if (!match?.[1]) throw new Error("Command must start with `hey opencode`") + const userPrompt = match[1] - const oidcToken = await generateGitHubToken(); - appToken = await exchangeForAppToken(oidcToken); - octoRest = new Octokit({ auth: appToken }); + const oidcToken = await generateGitHubToken() + appToken = await exchangeForAppToken(oidcToken) + octoRest = new Octokit({ auth: appToken }) octoGraph = graphql.defaults({ headers: { authorization: `token ${appToken}` }, - }); + }) - await configureGit(appToken); - await assertPermissions(); + await configureGit(appToken) + await assertPermissions() - const comment = await createComment("opencode started..."); - commentId = comment.data.id; + const comment = await createComment("opencode started...") + commentId = comment.data.id // Set state - const repoData = await fetchRepo(); + const repoData = await fetchRepo() if (payload.issue.pull_request) { - const prData = await fetchPR(); + const prData = await fetchPR() state = { - type: - prData.headRepository.nameWithOwner === - prData.baseRepository.nameWithOwner - ? "local-pr" - : "fork-pr", + type: prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner ? "local-pr" : "fork-pr", pr: prData, - }; + } } else { state = { type: "issue", issue: await fetchIssue(), - }; + } } // Setup git branch - if (state.type === "local-pr") await checkoutLocalBranch(state.pr); - else if (state.type === "fork-pr") await checkoutForkBranch(state.pr); + if (state.type === "local-pr") await checkoutLocalBranch(state.pr) + else if (state.type === "fork-pr") await checkoutForkBranch(state.pr) // Prompt - const share = process.env.INPUT_SHARE === "true" || !repoData.data.private; - const promptData = - state.type === "issue" - ? buildPromptDataForIssue(state.issue) - : buildPromptDataForPR(state.pr); + const share = process.env.INPUT_SHARE === "true" || !repoData.data.private + const promptData = state.type === "issue" ? buildPromptDataForIssue(state.issue) : buildPromptDataForPR(state.pr) const responseRet = await runOpencode(`${userPrompt}\n\n${promptData}`, { share, - }); + }) - const response = responseRet.stdout; - shareUrl = responseRet.stderr.match(/https:\/\/opencode\.ai\/s\/\w+/)?.[0]; + const response = responseRet.stdout + shareUrl = responseRet.stderr.match(/https:\/\/opencode\.ai\/s\/\w+/)?.[0] // Comment and push changes if (await branchIsDirty()) { const summary = - ( - await runOpencode( - `Summarize the following in less than 40 characters:\n\n${response}`, - { share: false } - ) - )?.stdout || `Fix issue: ${payload.issue.title}`; + (await runOpencode(`Summarize the following in less than 40 characters:\n\n${response}`, { share: false })) + ?.stdout || `Fix issue: ${payload.issue.title}` if (state.type === "issue") { - const branch = await pushToNewBranch(summary); - const pr = await createPR( - repoData.data.default_branch, - branch, - summary, - `${response}\n\nCloses #${issueId}` - ); - await updateComment(`opencode created pull request #${pr}`); + const branch = await pushToNewBranch(summary) + const pr = await createPR(repoData.data.default_branch, branch, summary, `${response}\n\nCloses #${issueId}`) + await updateComment(`opencode created pull request #${pr}`) } else if (state.type === "local-pr") { - await pushToCurrentBranch(summary); - await updateComment(response); + await pushToCurrentBranch(summary) + await updateComment(response) } else if (state.type === "fork-pr") { - await pushToForkBranch(summary, state.pr); - await updateComment(response); + await pushToForkBranch(summary, state.pr) + await updateComment(response) } } else { - await updateComment(response); + await updateComment(response) } - await restoreGitConfig(); - await revokeAppToken(); + await restoreGitConfig() + await revokeAppToken() } catch (e: any) { - await restoreGitConfig(); - await revokeAppToken(); - console.error(e); - let msg = e; + await restoreGitConfig() + await revokeAppToken() + console.error(e) + let msg = e if (e instanceof $.ShellError) { - msg = e.stderr.toString(); + msg = e.stderr.toString() } else if (e instanceof Error) { - msg = e.message; + msg = e.message } - if (commentId) await updateComment(msg); - core.setFailed(`opencode failed with error: ${msg}`); + if (commentId) await updateComment(msg) + core.setFailed(`opencode failed with error: ${msg}`) // Also output the clean error message for the action to capture //core.setOutput("prepare_error", e.message); - process.exit(1); + process.exit(1) } } if (import.meta.main) { - run(); + run() } async function generateGitHubToken() { try { - return await core.getIDToken("opencode-github-action"); + return await core.getIDToken("opencode-github-action") } catch (error) { - console.error("Failed to get OIDC token:", error); - throw new Error( - "Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions." - ); + console.error("Failed to get OIDC token:", error) + throw new Error("Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions.") } } async function exchangeForAppToken(oidcToken: string) { - const response = await fetch( - "https://api.frank.dev.opencode.ai/exchange_github_app_token", - { - method: "POST", - headers: { - Authorization: `Bearer ${oidcToken}`, - }, - } - ); + const 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 { 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; + const responseJson = (await response.json()) as { token: string } + return responseJson.token } async function configureGit(appToken: string) { - console.log("Configuring git..."); - const config = "http.https://github.com/.extraheader"; - const ret = await $`git config --local --get ${config}`; - gitCredentials = 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"`; + console.log("Configuring git...") + const config = "http.https://github.com/.extraheader" + const ret = await $`git config --local --get ${config}` + gitCredentials = 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 checkoutLocalBranch(pr: GitHubPullRequest) { - console.log("Checking out local branch..."); + console.log("Checking out local branch...") - const branch = pr.headRefName; - const depth = Math.max(pr.commits.totalCount, 20); + const branch = pr.headRefName + const depth = Math.max(pr.commits.totalCount, 20) - await $`git fetch origin --depth=${depth} ${branch}`; - await $`git checkout ${branch}`; + await $`git fetch origin --depth=${depth} ${branch}` + await $`git checkout ${branch}` } async function checkoutForkBranch(pr: GitHubPullRequest) { - console.log("Checking out fork branch..."); + console.log("Checking out fork branch...") - const remoteBranch = pr.headRefName; - const localBranch = generateBranchName(); - const depth = Math.max(pr.commits.totalCount, 20); + const remoteBranch = pr.headRefName + const localBranch = generateBranchName() + const depth = Math.max(pr.commits.totalCount, 20) - await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`; - await $`git fetch fork --depth=${depth} ${remoteBranch}`; - await $`git checkout -b ${localBranch} fork/${remoteBranch}`; + await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git` + await $`git fetch fork --depth=${depth} ${remoteBranch}` + await $`git checkout -b ${localBranch} fork/${remoteBranch}` } async function restoreGitConfig() { - if (!gitCredentials) return; - const config = "http.https://github.com/.extraheader"; - await $`git config --local ${config} "${gitCredentials}"`; + if (!gitCredentials) return + const config = "http.https://github.com/.extraheader" + await $`git config --local ${config} "${gitCredentials}"` } async function assertPermissions() { - console.log(`Asserting permissions for user ${actor}...`); + console.log(`Asserting permissions for user ${actor}...`) - let permission; + let permission try { const response = await octoRest.repos.getCollaboratorPermissionLevel({ owner, repo, username: actor, - }); + }) - permission = response.data.permission; - console.log(` permission: ${permission}`); + 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}`); + console.error(`Failed to check permissions: ${error}`) + throw new Error(`Failed to check permissions for user ${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 ${actor} does not have write permissions`) } function buildComment(content: string) { - const runId = process.env.GITHUB_RUN_ID!; - const runUrl = `/${owner}/${repo}/actions/runs/${runId}`; - return [ - content, - "\n\n", - shareUrl ? `[view session](${shareUrl}) | ` : "", - `[view log](${runUrl})`, - ].join(""); + const runId = process.env.GITHUB_RUN_ID! + const runUrl = `/${owner}/${repo}/actions/runs/${runId}` + return [content, "\n\n", shareUrl ? `[view session](${shareUrl}) | ` : "", `[view log](${runUrl})`].join("") } async function createComment(body: string) { - console.log("Creating comment..."); + console.log("Creating comment...") return await octoRest.rest.issues.createComment({ owner, repo, issue_number: issueId, body: buildComment(body), - }); + }) } async function updateComment(body: string) { - console.log("Updating comment..."); + console.log("Updating comment...") return await octoRest.rest.issues.updateComment({ owner, repo, comment_id: commentId, body: buildComment(body), - }); + }) } function generateBranchName() { - const type = state.type === "issue" ? "issue" : "pr"; + const type = state.type === "issue" ? "issue" : "pr" const timestamp = new Date() .toISOString() .replace(/[:-]/g, "") .replace(/\.\d{3}Z/, "") .split("T") - .join("_"); - return `opencode/${type}${issueId}-${timestamp}`; + .join("_") + return `opencode/${type}${issueId}-${timestamp}` } async function pushToCurrentBranch(summary: string) { - console.log("Pushing to current branch..."); - await $`git add .`; + console.log("Pushing to current branch...") + await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`; - await $`git push`; +Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` + await $`git push` } async function pushToForkBranch(summary: string, pr: GitHubPullRequest) { - console.log("Pushing to fork branch..."); + console.log("Pushing to fork branch...") - const remoteBranch = pr.headRefName; + const remoteBranch = pr.headRefName - await $`git add .`; + await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`; - await $`git push fork HEAD:${remoteBranch}`; +Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` + await $`git push fork HEAD:${remoteBranch}` } async function pushToNewBranch(summary: string) { - console.log("Pushing to new branch..."); - const branch = generateBranchName(); - await $`git checkout -b ${branch}`; - await $`git add .`; + console.log("Pushing to new branch...") + const branch = generateBranchName() + await $`git checkout -b ${branch}` + await $`git add .` await $`git commit -m "${summary} -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`; - await $`git push -u origin ${branch}`; - return branch; +Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` + await $`git push -u origin ${branch}` + return branch } -async function createPR( - base: string, - branch: string, - title: string, - body: string -) { - console.log("Creating pull request..."); +async function createPR(base: string, branch: string, title: string, body: string) { + console.log("Creating pull request...") const pr = await octoRest.rest.pulls.create({ owner, repo, @@ -343,41 +301,39 @@ async function createPR( base, title, body: buildComment(body), - }); - return pr.data.number; + }) + return pr.data.number } async function runOpencode( prompt: string, opts?: { - share?: boolean; - } + share?: boolean + }, ) { - console.log("Running opencode..."); + console.log("Running opencode...") - const promptPath = path.join(os.tmpdir(), "PROMPT"); - await Bun.write(promptPath, prompt); - const ret = await $`cat ${promptPath} | opencode run -m ${ - process.env.INPUT_MODEL - } ${opts?.share ? "--share" : ""}`; + const promptPath = path.join(os.tmpdir(), "PROMPT") + await Bun.write(promptPath, prompt) + const ret = await $`cat ${promptPath} | opencode run -m ${process.env.INPUT_MODEL} ${opts?.share ? "--share" : ""}` return { stdout: ret.stdout.toString().trim(), stderr: ret.stderr.toString().trim(), - }; + } } async function branchIsDirty() { - console.log("Checking if branch is dirty..."); - const ret = await $`git status --porcelain`; - return ret.stdout.toString().trim().length > 0; + console.log("Checking if branch is dirty...") + const ret = await $`git status --porcelain` + return ret.stdout.toString().trim().length > 0 } async function fetchRepo() { - return await octoRest.rest.repos.get({ owner, repo }); + return await octoRest.rest.repos.get({ owner, repo }) } async function fetchIssue() { - console.log("Fetching prompt data for issue..."); + console.log("Fetching prompt data for issue...") const issueResult = await octoGraph<IssueQueryResponse>( ` query($owner: String!, $repo: String!, $number: Int!) { @@ -408,22 +364,22 @@ query($owner: String!, $repo: String!, $number: Int!) { owner, repo, number: issueId, - } - ); + }, + ) - const issue = issueResult.repository.issue; - if (!issue) throw new Error(`Issue #${issueId} not found`); + const issue = issueResult.repository.issue + if (!issue) throw new Error(`Issue #${issueId} not found`) - return issue; + return issue } function buildPromptDataForIssue(issue: GitHubIssue) { const comments = (issue.comments?.nodes || []) .filter((c) => { - const id = parseInt(c.databaseId); - return id !== commentId && id !== payload.comment.id; + const id = parseInt(c.databaseId) + return id !== commentId && id !== payload.comment.id }) - .map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`); + .map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`) return [ "Here is the context for the issue:", @@ -433,11 +389,11 @@ function buildPromptDataForIssue(issue: GitHubIssue) { `- Created At: ${issue.createdAt}`, `- State: ${issue.state}`, ...(comments.length > 0 ? ["- Comments:", ...comments] : []), - ].join("\n"); + ].join("\n") } async function fetchPR() { - console.log("Fetching prompt data for PR..."); + console.log("Fetching prompt data for PR...") const prResult = await octoGraph<PullRequestQueryResponse>( ` query($owner: String!, $repo: String!, $number: Int!) { @@ -525,36 +481,32 @@ query($owner: String!, $repo: String!, $number: Int!) { owner, repo, number: issueId, - } - ); + }, + ) - const pr = prResult.repository.pullRequest; - if (!pr) throw new Error(`PR #${issueId} not found`); + const pr = prResult.repository.pullRequest + if (!pr) throw new Error(`PR #${issueId} not found`) - return pr; + return pr } function buildPromptDataForPR(pr: GitHubPullRequest) { const comments = (pr.comments?.nodes || []) .filter((c) => { - const id = parseInt(c.databaseId); - return id !== commentId && id !== payload.comment.id; + const id = parseInt(c.databaseId) + return id !== commentId && id !== payload.comment.id }) - .map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`); + .map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`) - const files = (pr.files.nodes || []).map( - (f) => ` - ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}` - ); + const files = (pr.files.nodes || []).map((f) => ` - ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`) const reviewData = (pr.reviews.nodes || []).map((r) => { - const comments = (r.comments.nodes || []).map( - (c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}` - ); + const comments = (r.comments.nodes || []).map((c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`) return [ ` - ${r.author.login} at ${r.submittedAt}:`, ` - Review body: ${r.body}`, ...(comments.length > 0 ? [" - Comments:", ...comments] : []), - ]; - }); + ] + }) return [ "Here is the context for the pull request:", @@ -572,11 +524,11 @@ function buildPromptDataForPR(pr: GitHubPullRequest) { ...(comments.length > 0 ? ["- Comments:", ...comments] : []), ...(files.length > 0 ? ["- Changed files:", ...files] : []), ...(reviewData.length > 0 ? ["- Reviews:", ...reviewData] : []), - ].join("\n"); + ].join("\n") } async function revokeAppToken() { - if (!appToken) return; + if (!appToken) return await fetch("https://api.github.com/installation/token", { method: "DELETE", @@ -585,5 +537,5 @@ async function revokeAppToken() { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }, - }); + }) } |
