diff options
| author | Frank <[email protected]> | 2025-07-25 17:34:47 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-07-25 17:34:47 -0400 |
| commit | 3a7a2a838ec6ba625aa27aaa73cf4ea8ee03e79e (patch) | |
| tree | 0c9b4fbaf21001511959b6ba803b8b1b162868b7 /sdks/github/src | |
| parent | 10ae43a12190ed3ac96ae1d672b1aa1dd006b072 (diff) | |
| download | opencode-3a7a2a838ec6ba625aa27aaa73cf4ea8ee03e79e.tar.gz opencode-3a7a2a838ec6ba625aa27aaa73cf4ea8ee03e79e.zip | |
wip: github actions
Diffstat (limited to 'sdks/github/src')
| -rw-r--r-- | sdks/github/src/index.ts | 541 | ||||
| -rw-r--r-- | sdks/github/src/types.ts | 103 |
2 files changed, 0 insertions, 644 deletions
diff --git a/sdks/github/src/index.ts b/sdks/github/src/index.ts deleted file mode 100644 index 51ee4f704..000000000 --- a/sdks/github/src/index.ts +++ /dev/null @@ -1,541 +0,0 @@ -#!/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,/) - 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.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 deleted file mode 100644 index fe0058fbd..000000000 --- a/sdks/github/src/types.ts +++ /dev/null @@ -1,103 +0,0 @@ -// 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; - }; -}; |
