| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541 |
- #!/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",
- },
- })
- }
|