index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. #!/usr/bin/env bun
  2. import os from "os"
  3. import path from "path"
  4. import { $ } from "bun"
  5. import { Octokit } from "@octokit/rest"
  6. import { graphql } from "@octokit/graphql"
  7. import * as core from "@actions/core"
  8. import * as github from "@actions/github"
  9. import type { IssueCommentEvent } from "@octokit/webhooks-types"
  10. import type { GitHubIssue, GitHubPullRequest, IssueQueryResponse, PullRequestQueryResponse } from "./types"
  11. if (github.context.eventName !== "issue_comment") {
  12. core.setFailed(`Unsupported event type: ${github.context.eventName}`)
  13. process.exit(1)
  14. }
  15. const { owner, repo } = github.context.repo
  16. const payload = github.context.payload as IssueCommentEvent
  17. const actor = github.context.actor
  18. const issueId = payload.issue.number
  19. const body = payload.comment.body
  20. let appToken: string
  21. let octoRest: Octokit
  22. let octoGraph: typeof graphql
  23. let commentId: number
  24. let gitCredentials: string
  25. let shareUrl: string | undefined
  26. let state:
  27. | {
  28. type: "issue"
  29. issue: GitHubIssue
  30. }
  31. | {
  32. type: "local-pr"
  33. pr: GitHubPullRequest
  34. }
  35. | {
  36. type: "fork-pr"
  37. pr: GitHubPullRequest
  38. }
  39. async function run() {
  40. try {
  41. const match = body.match(/^hey\s*opencode,?\s*(.*)$/)
  42. if (!match?.[1]) throw new Error("Command must start with `hey opencode`")
  43. const userPrompt = match[1]
  44. const oidcToken = await generateGitHubToken()
  45. appToken = await exchangeForAppToken(oidcToken)
  46. octoRest = new Octokit({ auth: appToken })
  47. octoGraph = graphql.defaults({
  48. headers: { authorization: `token ${appToken}` },
  49. })
  50. await configureGit(appToken)
  51. await assertPermissions()
  52. const comment = await createComment("opencode started...")
  53. commentId = comment.data.id
  54. // Set state
  55. const repoData = await fetchRepo()
  56. if (payload.issue.pull_request) {
  57. const prData = await fetchPR()
  58. state = {
  59. type: prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner ? "local-pr" : "fork-pr",
  60. pr: prData,
  61. }
  62. } else {
  63. state = {
  64. type: "issue",
  65. issue: await fetchIssue(),
  66. }
  67. }
  68. // Setup git branch
  69. if (state.type === "local-pr") await checkoutLocalBranch(state.pr)
  70. else if (state.type === "fork-pr") await checkoutForkBranch(state.pr)
  71. // Prompt
  72. const share = process.env.INPUT_SHARE === "true" || !repoData.data.private
  73. const promptData = state.type === "issue" ? buildPromptDataForIssue(state.issue) : buildPromptDataForPR(state.pr)
  74. const responseRet = await runOpencode(`${userPrompt}\n\n${promptData}`, {
  75. share,
  76. })
  77. const response = responseRet.stdout
  78. shareUrl = responseRet.stderr.match(/https:\/\/opencode\.ai\/s\/\w+/)?.[0]
  79. // Comment and push changes
  80. if (await branchIsDirty()) {
  81. const summary =
  82. (await runOpencode(`Summarize the following in less than 40 characters:\n\n${response}`, { share: false }))
  83. ?.stdout || `Fix issue: ${payload.issue.title}`
  84. if (state.type === "issue") {
  85. const branch = await pushToNewBranch(summary)
  86. const pr = await createPR(repoData.data.default_branch, branch, summary, `${response}\n\nCloses #${issueId}`)
  87. await updateComment(`opencode created pull request #${pr}`)
  88. } else if (state.type === "local-pr") {
  89. await pushToCurrentBranch(summary)
  90. await updateComment(response)
  91. } else if (state.type === "fork-pr") {
  92. await pushToForkBranch(summary, state.pr)
  93. await updateComment(response)
  94. }
  95. } else {
  96. await updateComment(response)
  97. }
  98. await restoreGitConfig()
  99. await revokeAppToken()
  100. } catch (e: any) {
  101. await restoreGitConfig()
  102. await revokeAppToken()
  103. console.error(e)
  104. let msg = e
  105. if (e instanceof $.ShellError) {
  106. msg = e.stderr.toString()
  107. } else if (e instanceof Error) {
  108. msg = e.message
  109. }
  110. if (commentId) await updateComment(msg)
  111. core.setFailed(`opencode failed with error: ${msg}`)
  112. // Also output the clean error message for the action to capture
  113. //core.setOutput("prepare_error", e.message);
  114. process.exit(1)
  115. }
  116. }
  117. if (import.meta.main) {
  118. run()
  119. }
  120. async function generateGitHubToken() {
  121. try {
  122. return await core.getIDToken("opencode-github-action")
  123. } catch (error) {
  124. console.error("Failed to get OIDC token:", error)
  125. throw new Error("Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions.")
  126. }
  127. }
  128. async function exchangeForAppToken(oidcToken: string) {
  129. const response = await fetch("https://api.opencode.ai/exchange_github_app_token", {
  130. method: "POST",
  131. headers: {
  132. Authorization: `Bearer ${oidcToken}`,
  133. },
  134. })
  135. if (!response.ok) {
  136. const responseJson = (await response.json()) as { error?: string }
  137. throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`)
  138. }
  139. const responseJson = (await response.json()) as { token: string }
  140. return responseJson.token
  141. }
  142. async function configureGit(appToken: string) {
  143. console.log("Configuring git...")
  144. const config = "http.https://github.com/.extraheader"
  145. const ret = await $`git config --local --get ${config}`
  146. gitCredentials = ret.stdout.toString().trim()
  147. const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
  148. await $`git config --local --unset-all ${config}`
  149. await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
  150. await $`git config --global user.name "opencode-agent[bot]"`
  151. await $`git config --global user.email "opencode-agent[bot]@users.noreply.github.com"`
  152. }
  153. async function checkoutLocalBranch(pr: GitHubPullRequest) {
  154. console.log("Checking out local branch...")
  155. const branch = pr.headRefName
  156. const depth = Math.max(pr.commits.totalCount, 20)
  157. await $`git fetch origin --depth=${depth} ${branch}`
  158. await $`git checkout ${branch}`
  159. }
  160. async function checkoutForkBranch(pr: GitHubPullRequest) {
  161. console.log("Checking out fork branch...")
  162. const remoteBranch = pr.headRefName
  163. const localBranch = generateBranchName()
  164. const depth = Math.max(pr.commits.totalCount, 20)
  165. await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
  166. await $`git fetch fork --depth=${depth} ${remoteBranch}`
  167. await $`git checkout -b ${localBranch} fork/${remoteBranch}`
  168. }
  169. async function restoreGitConfig() {
  170. if (!gitCredentials) return
  171. const config = "http.https://github.com/.extraheader"
  172. await $`git config --local ${config} "${gitCredentials}"`
  173. }
  174. async function assertPermissions() {
  175. console.log(`Asserting permissions for user ${actor}...`)
  176. let permission
  177. try {
  178. const response = await octoRest.repos.getCollaboratorPermissionLevel({
  179. owner,
  180. repo,
  181. username: actor,
  182. })
  183. permission = response.data.permission
  184. console.log(` permission: ${permission}`)
  185. } catch (error) {
  186. console.error(`Failed to check permissions: ${error}`)
  187. throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
  188. }
  189. if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
  190. }
  191. function buildComment(content: string) {
  192. const runId = process.env.GITHUB_RUN_ID!
  193. const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
  194. return [content, "\n\n", shareUrl ? `[view session](${shareUrl}) | ` : "", `[view log](${runUrl})`].join("")
  195. }
  196. async function createComment(body: string) {
  197. console.log("Creating comment...")
  198. return await octoRest.rest.issues.createComment({
  199. owner,
  200. repo,
  201. issue_number: issueId,
  202. body: buildComment(body),
  203. })
  204. }
  205. async function updateComment(body: string) {
  206. console.log("Updating comment...")
  207. return await octoRest.rest.issues.updateComment({
  208. owner,
  209. repo,
  210. comment_id: commentId,
  211. body: buildComment(body),
  212. })
  213. }
  214. function generateBranchName() {
  215. const type = state.type === "issue" ? "issue" : "pr"
  216. const timestamp = new Date()
  217. .toISOString()
  218. .replace(/[:-]/g, "")
  219. .replace(/\.\d{3}Z/, "")
  220. .split("T")
  221. .join("_")
  222. return `opencode/${type}${issueId}-${timestamp}`
  223. }
  224. async function pushToCurrentBranch(summary: string) {
  225. console.log("Pushing to current branch...")
  226. await $`git add .`
  227. await $`git commit -m "${summary}
  228. Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
  229. await $`git push`
  230. }
  231. async function pushToForkBranch(summary: string, pr: GitHubPullRequest) {
  232. console.log("Pushing to fork branch...")
  233. const remoteBranch = pr.headRefName
  234. await $`git add .`
  235. await $`git commit -m "${summary}
  236. Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
  237. await $`git push fork HEAD:${remoteBranch}`
  238. }
  239. async function pushToNewBranch(summary: string) {
  240. console.log("Pushing to new branch...")
  241. const branch = generateBranchName()
  242. await $`git checkout -b ${branch}`
  243. await $`git add .`
  244. await $`git commit -m "${summary}
  245. Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
  246. await $`git push -u origin ${branch}`
  247. return branch
  248. }
  249. async function createPR(base: string, branch: string, title: string, body: string) {
  250. console.log("Creating pull request...")
  251. const pr = await octoRest.rest.pulls.create({
  252. owner,
  253. repo,
  254. head: branch,
  255. base,
  256. title,
  257. body: buildComment(body),
  258. })
  259. return pr.data.number
  260. }
  261. async function runOpencode(
  262. prompt: string,
  263. opts?: {
  264. share?: boolean
  265. },
  266. ) {
  267. console.log("Running opencode...")
  268. const promptPath = path.join(os.tmpdir(), "PROMPT")
  269. await Bun.write(promptPath, prompt)
  270. const ret = await $`cat ${promptPath} | opencode run -m ${process.env.INPUT_MODEL} ${opts?.share ? "--share" : ""}`
  271. return {
  272. stdout: ret.stdout.toString().trim(),
  273. stderr: ret.stderr.toString().trim(),
  274. }
  275. }
  276. async function branchIsDirty() {
  277. console.log("Checking if branch is dirty...")
  278. const ret = await $`git status --porcelain`
  279. return ret.stdout.toString().trim().length > 0
  280. }
  281. async function fetchRepo() {
  282. return await octoRest.rest.repos.get({ owner, repo })
  283. }
  284. async function fetchIssue() {
  285. console.log("Fetching prompt data for issue...")
  286. const issueResult = await octoGraph<IssueQueryResponse>(
  287. `
  288. query($owner: String!, $repo: String!, $number: Int!) {
  289. repository(owner: $owner, name: $repo) {
  290. issue(number: $number) {
  291. title
  292. body
  293. author {
  294. login
  295. }
  296. createdAt
  297. state
  298. comments(first: 100) {
  299. nodes {
  300. id
  301. databaseId
  302. body
  303. author {
  304. login
  305. }
  306. createdAt
  307. }
  308. }
  309. }
  310. }
  311. }`,
  312. {
  313. owner,
  314. repo,
  315. number: issueId,
  316. },
  317. )
  318. const issue = issueResult.repository.issue
  319. if (!issue) throw new Error(`Issue #${issueId} not found`)
  320. return issue
  321. }
  322. function buildPromptDataForIssue(issue: GitHubIssue) {
  323. const comments = (issue.comments?.nodes || [])
  324. .filter((c) => {
  325. const id = parseInt(c.databaseId)
  326. return id !== commentId && id !== payload.comment.id
  327. })
  328. .map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
  329. return [
  330. "Here is the context for the issue:",
  331. `- Title: ${issue.title}`,
  332. `- Body: ${issue.body}`,
  333. `- Author: ${issue.author.login}`,
  334. `- Created At: ${issue.createdAt}`,
  335. `- State: ${issue.state}`,
  336. ...(comments.length > 0 ? ["- Comments:", ...comments] : []),
  337. ].join("\n")
  338. }
  339. async function fetchPR() {
  340. console.log("Fetching prompt data for PR...")
  341. const prResult = await octoGraph<PullRequestQueryResponse>(
  342. `
  343. query($owner: String!, $repo: String!, $number: Int!) {
  344. repository(owner: $owner, name: $repo) {
  345. pullRequest(number: $number) {
  346. title
  347. body
  348. author {
  349. login
  350. }
  351. baseRefName
  352. headRefName
  353. headRefOid
  354. createdAt
  355. additions
  356. deletions
  357. state
  358. baseRepository {
  359. nameWithOwner
  360. }
  361. headRepository {
  362. nameWithOwner
  363. }
  364. commits(first: 100) {
  365. totalCount
  366. nodes {
  367. commit {
  368. oid
  369. message
  370. author {
  371. name
  372. email
  373. }
  374. }
  375. }
  376. }
  377. files(first: 100) {
  378. nodes {
  379. path
  380. additions
  381. deletions
  382. changeType
  383. }
  384. }
  385. comments(first: 100) {
  386. nodes {
  387. id
  388. databaseId
  389. body
  390. author {
  391. login
  392. }
  393. createdAt
  394. }
  395. }
  396. reviews(first: 100) {
  397. nodes {
  398. id
  399. databaseId
  400. author {
  401. login
  402. }
  403. body
  404. state
  405. submittedAt
  406. comments(first: 100) {
  407. nodes {
  408. id
  409. databaseId
  410. body
  411. path
  412. line
  413. author {
  414. login
  415. }
  416. createdAt
  417. }
  418. }
  419. }
  420. }
  421. }
  422. }
  423. }`,
  424. {
  425. owner,
  426. repo,
  427. number: issueId,
  428. },
  429. )
  430. const pr = prResult.repository.pullRequest
  431. if (!pr) throw new Error(`PR #${issueId} not found`)
  432. return pr
  433. }
  434. function buildPromptDataForPR(pr: GitHubPullRequest) {
  435. const comments = (pr.comments?.nodes || [])
  436. .filter((c) => {
  437. const id = parseInt(c.databaseId)
  438. return id !== commentId && id !== payload.comment.id
  439. })
  440. .map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
  441. const files = (pr.files.nodes || []).map((f) => ` - ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`)
  442. const reviewData = (pr.reviews.nodes || []).map((r) => {
  443. const comments = (r.comments.nodes || []).map((c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`)
  444. return [
  445. ` - ${r.author.login} at ${r.submittedAt}:`,
  446. ` - Review body: ${r.body}`,
  447. ...(comments.length > 0 ? [" - Comments:", ...comments] : []),
  448. ]
  449. })
  450. return [
  451. "Here is the context for the pull request:",
  452. `- Title: ${pr.title}`,
  453. `- Body: ${pr.body}`,
  454. `- Author: ${pr.author.login}`,
  455. `- Created At: ${pr.createdAt}`,
  456. `- Base Branch: ${pr.baseRefName}`,
  457. `- Head Branch: ${pr.headRefName}`,
  458. `- State: ${pr.state}`,
  459. `- Additions: ${pr.additions}`,
  460. `- Deletions: ${pr.deletions}`,
  461. `- Total Commits: ${pr.commits.totalCount}`,
  462. `- Changed Files: ${pr.files.nodes.length} files`,
  463. ...(comments.length > 0 ? ["- Comments:", ...comments] : []),
  464. ...(files.length > 0 ? ["- Changed files:", ...files] : []),
  465. ...(reviewData.length > 0 ? ["- Reviews:", ...reviewData] : []),
  466. ].join("\n")
  467. }
  468. async function revokeAppToken() {
  469. if (!appToken) return
  470. await fetch("https://api.github.com/installation/token", {
  471. method: "DELETE",
  472. headers: {
  473. Authorization: `Bearer ${appToken}`,
  474. Accept: "application/vnd.github+json",
  475. "X-GitHub-Api-Version": "2022-11-28",
  476. },
  477. })
  478. }