wip: github actions

This commit is contained in:
Frank
2025-07-13 23:59:25 +08:00
parent d465f150fc
commit 7361a02ef3
6 changed files with 955 additions and 0 deletions

589
sdks/github/src/index.ts Normal file
View File

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

103
sdks/github/src/types.ts Normal file
View File

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