diff options
| author | Frank <[email protected]> | 2025-07-13 23:59:25 +0800 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-07-13 23:59:25 +0800 |
| commit | 7361a02ef33c8e5831b72bde8d958e654f57ab96 (patch) | |
| tree | 1d975835aed9202d0cb7151ad7c2fe9c84d69cf9 /sdks/github/src | |
| parent | d465f150fc418ca47b2e63558a31274ea14621b5 (diff) | |
| download | opencode-7361a02ef33c8e5831b72bde8d958e654f57ab96.tar.gz opencode-7361a02ef33c8e5831b72bde8d958e654f57ab96.zip | |
wip: github actions
Diffstat (limited to 'sdks/github/src')
| -rw-r--r-- | sdks/github/src/index.ts | 589 | ||||
| -rw-r--r-- | sdks/github/src/types.ts | 103 |
2 files changed, 692 insertions, 0 deletions
diff --git a/sdks/github/src/index.ts b/sdks/github/src/index.ts new file mode 100644 index 000000000..2ccc4e8ea --- /dev/null +++ b/sdks/github/src/index.ts @@ -0,0 +1,589 @@ +#!/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"; + +if (github.context.eventName !== "issue_comment") { + 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; +let state: + | { + type: "issue"; + issue: GitHubIssue; + } + | { + type: "local-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 oidcToken = await generateGitHubToken(); + appToken = await exchangeForAppToken(oidcToken); + octoRest = new Octokit({ auth: appToken }); + octoGraph = graphql.defaults({ + headers: { authorization: `token ${appToken}` }, + }); + + await configureGit(appToken); + await assertPermissions(); + + const comment = await createComment("opencode started..."); + commentId = comment.data.id; + + // Set state + const repoData = await fetchRepo(); + if (payload.issue.pull_request) { + const prData = await fetchPR(); + state = { + 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); + + // Prompt + 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]; + + // 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}`; + + 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}`); + } else if (state.type === "local-pr") { + await pushToCurrentBranch(summary); + await updateComment(response); + } else if (state.type === "fork-pr") { + await pushToForkBranch(summary, state.pr); + await updateComment(response); + } + } else { + await updateComment(response); + } + await restoreGitConfig(); + await revokeAppToken(); + } catch (e: any) { + await restoreGitConfig(); + await revokeAppToken(); + console.error(e); + let msg = e; + if (e instanceof $.ShellError) { + msg = e.stderr.toString(); + } else if (e instanceof Error) { + msg = e.message; + } + 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); + } +} + +if (import.meta.main) { + run(); +} + +async function generateGitHubToken() { + try { + 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." + ); + } +} + +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}`, + }, + } + ); + + 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; +} + +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"`; +} + +async function checkoutLocalBranch(pr: GitHubPullRequest) { + console.log("Checking out local branch..."); + + const branch = pr.headRefName; + const depth = Math.max(pr.commits.totalCount, 20); + + await $`git fetch origin --depth=${depth} ${branch}`; + await $`git checkout ${branch}`; +} + +async function checkoutForkBranch(pr: GitHubPullRequest) { + console.log("Checking out fork branch..."); + + 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}`; +} + +async function restoreGitConfig() { + 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}...`); + + let permission; + try { + const response = await octoRest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: 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}`); + } + + 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(""); +} + +async function createComment(body: string) { + 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..."); + return await octoRest.rest.issues.updateComment({ + owner, + repo, + comment_id: commentId, + body: buildComment(body), + }); +} + +function generateBranchName() { + 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}`; +} + +async function pushToCurrentBranch(summary: string) { + 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`; +} + +async function pushToForkBranch(summary: string, pr: GitHubPullRequest) { + console.log("Pushing to fork branch..."); + + const remoteBranch = pr.headRefName; + + await $`git add .`; + await $`git commit -m "${summary} + +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 .`; + await $`git commit -m "${summary} + +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..."); + const pr = await octoRest.rest.pulls.create({ + owner, + repo, + head: branch, + base, + title, + body: buildComment(body), + }); + return pr.data.number; +} + +async function runOpencode( + prompt: string, + opts?: { + share?: boolean; + } +) { + 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" : ""}`; + 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; +} + +async function fetchRepo() { + return await octoRest.rest.repos.get({ owner, repo }); +} + +async function fetchIssue() { + console.log("Fetching prompt data for issue..."); + const issueResult = await octoGraph<IssueQueryResponse>( + ` +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + title + body + author { + login + } + createdAt + state + comments(first: 100) { + nodes { + id + databaseId + body + author { + login + } + createdAt + } + } + } + } +}`, + { + owner, + repo, + number: issueId, + } + ); + + const issue = issueResult.repository.issue; + if (!issue) throw new Error(`Issue #${issueId} not found`); + + 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; + }) + .map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`); + + return [ + "Here is the context for the issue:", + `- Title: ${issue.title}`, + `- Body: ${issue.body}`, + `- Author: ${issue.author.login}`, + `- Created At: ${issue.createdAt}`, + `- State: ${issue.state}`, + ...(comments.length > 0 ? ["- Comments:", ...comments] : []), + ].join("\n"); +} + +async function fetchPR() { + console.log("Fetching prompt data for PR..."); + const prResult = await octoGraph<PullRequestQueryResponse>( + ` +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + title + body + author { + login + } + baseRefName + headRefName + headRefOid + createdAt + additions + deletions + state + baseRepository { + nameWithOwner + } + headRepository { + nameWithOwner + } + commits(first: 100) { + totalCount + nodes { + commit { + oid + message + author { + name + email + } + } + } + } + files(first: 100) { + nodes { + path + additions + deletions + changeType + } + } + comments(first: 100) { + nodes { + id + databaseId + body + author { + login + } + createdAt + } + } + reviews(first: 100) { + nodes { + id + databaseId + author { + login + } + body + state + submittedAt + comments(first: 100) { + nodes { + id + databaseId + body + path + line + author { + login + } + createdAt + } + } + } + } + } + } +}`, + { + owner, + repo, + number: issueId, + } + ); + + const pr = prResult.repository.pullRequest; + if (!pr) throw new Error(`PR #${issueId} not found`); + + 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; + }) + .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 reviewData = (pr.reviews.nodes || []).map((r) => { + 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:", + `- Title: ${pr.title}`, + `- Body: ${pr.body}`, + `- Author: ${pr.author.login}`, + `- Created At: ${pr.createdAt}`, + `- Base Branch: ${pr.baseRefName}`, + `- Head Branch: ${pr.headRefName}`, + `- State: ${pr.state}`, + `- Additions: ${pr.additions}`, + `- Deletions: ${pr.deletions}`, + `- Total Commits: ${pr.commits.totalCount}`, + `- Changed Files: ${pr.files.nodes.length} files`, + ...(comments.length > 0 ? ["- Comments:", ...comments] : []), + ...(files.length > 0 ? ["- Changed files:", ...files] : []), + ...(reviewData.length > 0 ? ["- Reviews:", ...reviewData] : []), + ].join("\n"); +} + +async function revokeAppToken() { + if (!appToken) return; + + await fetch("https://api.github.com/installation/token", { + method: "DELETE", + headers: { + Authorization: `Bearer ${appToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); +} diff --git a/sdks/github/src/types.ts b/sdks/github/src/types.ts new file mode 100644 index 000000000..fe0058fbd --- /dev/null +++ b/sdks/github/src/types.ts @@ -0,0 +1,103 @@ +// Types for GitHub GraphQL query responses +export type GitHubAuthor = { + login: string; + name?: string; +}; + +export type GitHubComment = { + id: string; + databaseId: string; + body: string; + author: GitHubAuthor; + createdAt: string; +}; + +export type GitHubReviewComment = GitHubComment & { + path: string; + line: number | null; +}; + +export type GitHubCommit = { + oid: string; + message: string; + author: { + name: string; + email: string; + }; +}; + +export type GitHubFile = { + path: string; + additions: number; + deletions: number; + changeType: string; +}; + +export 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; + }; +}; |
