summaryrefslogtreecommitdiffhomepage
path: root/sdks/github
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-07-16 14:58:32 +0800
committerFrank <[email protected]>2025-07-16 14:59:53 +0800
commita86d42149f52e4cb2b595016d1e81f04a0ecba3b (patch)
treef40bde648ad8c8db158863a5981b3ba318cabe3d /sdks/github
parent82a36acfe36c112ace91042b68a07b9803a61aba (diff)
downloadopencode-a86d42149f52e4cb2b595016d1e81f04a0ecba3b.tar.gz
opencode-a86d42149f52e4cb2b595016d1e81f04a0ecba3b.zip
wip: github actions
Diffstat (limited to 'sdks/github')
-rw-r--r--sdks/github/src/index.ts426
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",
},
- });
+ })
}