Skip to content
Closed
5 changes: 5 additions & 0 deletions github/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ inputs:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
required: false

comment_key:
description: "A key used to identify a specific comment. When set, opencode will look for an existing bot comment with this key on the current issue or PR and update it; if no matching comment is found, a new one is created. Use a fixed string (e.g. 'review-summary') or compose with GitHub context expressions (e.g. the workflow name and job name). Leave unset to always create a new comment."
required: false

runs:
using: "composite"
steps:
Expand Down Expand Up @@ -77,3 +81,4 @@ runs:
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
COMMENT_KEY: ${{ inputs.comment_key }}
115 changes: 108 additions & 7 deletions packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from "path"
import { createHash } from "crypto"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { exec } from "child_process"
import { Filesystem } from "@/util/filesystem"
Expand Down Expand Up @@ -141,6 +142,7 @@ type IssueQueryResponse = {
}

const AGENT_USERNAME = "opencode-agent[bot]"
const GITHUB_ACTIONS_BOT_USERNAME = "github-actions[bot]"
const AGENT_REACTION = "eyes"
const WORKFLOW_FILE = ".github/workflows/opencode.yml"

Expand Down Expand Up @@ -183,6 +185,34 @@ export function formatPromptTooLargeError(files: { filename: string; content: st
return `PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.${fileDetails}`
}

/**
* Returns the sha256 hex digest of the given comment key string.
* Used as a stable, safe anchor identifier embedded in comment bodies.
*/
export function buildCommentKeyDigest(key: string): string {
return createHash("sha256").update(key).digest("hex")
}

/**
* Appends a hidden HTML comment anchor to body so that future runs can
* locate and update the same comment via findExistingStickyComment.
* The anchor is placed on its own line at the very end.
*/
export function appendCommentAnchor(body: string, digest: string): string {
return `${body}\n<!-- opencode:comment-key:sha256:${digest} -->`
}

/**
* Returns matching sticky comment ids in original API order.
* A comment matches when it contains `anchor`.
*/
export function findStickyCommentIds(
comments: Array<{ id: number; body?: string | null; user?: { login?: string | null } | null }>,
anchor: string,
): number[] {
return comments.filter((comment) => comment.body?.includes(anchor)).map((comment) => comment.id)
}

export const GithubCommand = cmd({
command: "github",
describe: "manage GitHub agent",
Expand Down Expand Up @@ -489,6 +519,7 @@ export const GithubRunCommand = effectCmd({
let octoRest: Octokit
let octoGraph: typeof graphql
let gitConfig: string
let commentAuthorLogin = AGENT_USERNAME
let session: { id: SessionID; title: string; version: string }
let shareId: string | undefined
let exitCode = 0
Expand Down Expand Up @@ -540,6 +571,13 @@ export const GithubRunCommand = effectCmd({
octoGraph = graphql.defaults({
headers: { authorization: `token ${appToken}` },
})
commentAuthorLogin = await octoRest.rest.users
.getAuthenticated()
.then((response) => response.data.login)
.catch((error) => {
console.warn(`Warning: failed to resolve authenticated user login: ${String(error)}`)
return AGENT_USERNAME
})

const { userPrompt, promptFiles } = await getUserPrompt()
if (!useGithubToken) {
Expand Down Expand Up @@ -630,7 +668,7 @@ export const GithubRunCommand = effectCmd({
await pushToLocalBranch(summary, uncommittedChanges)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await createComment(`${response}${footer({ image: !hasShared })}`)
await publishComment(`${response}${footer({ image: !hasShared })}`)
await removeReaction(commentType)
}
// Fork PR
Expand All @@ -648,7 +686,7 @@ export const GithubRunCommand = effectCmd({
await pushToForkBranch(summary, prData, uncommittedChanges)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await createComment(`${response}${footer({ image: !hasShared })}`)
await publishComment(`${response}${footer({ image: !hasShared })}`)
await removeReaction(commentType)
}
}
Expand All @@ -663,7 +701,7 @@ export const GithubRunCommand = effectCmd({
if (switched) {
// Agent switched branches (likely created its own branch/PR).
// Don't push the stale infrastructure branch — just comment.
await createComment(`${response}${footer({ image: true })}`)
await publishComment(`${response}${footer({ image: true })}`)
await removeReaction(commentType)
} else if (dirty) {
const summary = await summarize(response)
Expand All @@ -675,13 +713,13 @@ export const GithubRunCommand = effectCmd({
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
)
if (pr) {
await createComment(`Created PR #${pr}${footer({ image: true })}`)
await publishComment(`Created PR #${pr}${footer({ image: true })}`)
} else {
await createComment(`${response}${footer({ image: true })}`)
await publishComment(`${response}${footer({ image: true })}`)
}
await removeReaction(commentType)
} else {
await createComment(`${response}${footer({ image: true })}`)
await publishComment(`${response}${footer({ image: true })}`)
await removeReaction(commentType)
}
}
Expand All @@ -695,7 +733,7 @@ export const GithubRunCommand = effectCmd({
msg = e.message
}
if (isUserEvent) {
await createComment(`${msg}${footer()}`)
await publishComment(`${msg}${footer()}`)
await removeReaction(commentType)
}
core.setFailed(msg)
Expand Down Expand Up @@ -1234,6 +1272,36 @@ export const GithubRunCommand = effectCmd({
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
}

// Returns the sha256 hex digest of COMMENT_KEY (trimmed), or undefined when
// COMMENT_KEY is absent or blank. Used as the stable upsert key so that
// arbitrary user-supplied strings (spaces, special chars, etc.) never
// break the hidden HTML anchor embedded in comment bodies.
function commentKeyDigest(): string | undefined {
const raw = process.env["COMMENT_KEY"]?.trim()
if (!raw) return undefined
return buildCommentKeyDigest(raw)
}

// Searches the current issue/PR thread for a bot comment whose body
// contains the anchor for `digest`. Returns the comment id when found,
// or undefined otherwise. If more than one matching comment is found the
// most-recently created one wins and a warning is logged.
async function findExistingStickyComment(digest: string): Promise<number | undefined> {
const anchor = `<!-- opencode:comment-key:sha256:${digest} -->`
const comments = await octoRest.paginate(octoRest.rest.issues.listComments, {
owner,
repo,
issue_number: issueId!,
per_page: 100,
})
const matchedIds = findStickyCommentIds(comments, anchor)
if (matchedIds.length === 0) return undefined
if (matchedIds.length > 1) {
console.warn(`Warning: found ${matchedIds.length} sticky comments with same key; updating the most recent one`)
}
return matchedIds[matchedIds.length - 1]
}

async function addReaction(commentType?: "issue" | "pr_review") {
// Only called for non-schedule events, so triggerCommentId is defined
console.log("Adding reaction...")
Expand Down Expand Up @@ -1263,7 +1331,16 @@ export const GithubRunCommand = effectCmd({

async function removeReaction(commentType?: "issue" | "pr_review") {
// Only called for non-schedule events, so triggerCommentId is defined
// Reaction cleanup is best-effort: don't fail the action if it errors
console.log("Removing reaction...")
try {
await removeReactionInner(commentType)
} catch (e) {
console.warn(`Warning: failed to remove reaction: ${e instanceof Error ? e.message : String(e)}`)
}
}

async function removeReactionInner(commentType?: "issue" | "pr_review") {
if (triggerCommentId) {
if (commentType === "pr_review") {
const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
Expand Down Expand Up @@ -1331,6 +1408,30 @@ export const GithubRunCommand = effectCmd({
})
}

// publishComment is the single exit point for all bot comments.
// When COMMENT_KEY is set it performs an upsert: it searches the current
// issue/PR thread for a previously published comment with a matching
// hidden anchor, updates it if found, and creates a new one otherwise.
// When COMMENT_KEY is not set the behaviour is identical to createComment.
async function publishComment(content: string) {
const digest = commentKeyDigest()
const body = digest ? appendCommentAnchor(content, digest) : content
if (!digest) return createComment(body)

console.log(`Sticky comment enabled (key digest: ${digest})`)
const existingId = await findExistingStickyComment(digest)
if (existingId) {
console.log(`Updating existing sticky comment ${existingId}...`)
return await octoRest.rest.issues.updateComment({
owner,
repo,
comment_id: existingId,
body,
})
}
return createComment(body)
}

async function createPR(base: string, branch: string, title: string, body: string): Promise<number | null> {
console.log("Creating pull request...")

Expand Down
115 changes: 114 additions & 1 deletion packages/opencode/test/cli/github-action.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { test, expect, describe } from "bun:test"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
import {
extractResponseText,
formatPromptTooLargeError,
buildCommentKeyDigest,
appendCommentAnchor,
findStickyCommentIds,
} from "../../src/cli/cmd/github"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID, PartID } from "../../src/session/schema"

Expand Down Expand Up @@ -201,3 +207,110 @@ describe("formatPromptTooLargeError", () => {
expect(result).toInclude("img3.gif (9 KB)")
})
})

describe("buildCommentKeyDigest", () => {
test("returns a 64-character hex sha256 digest", () => {
const digest = buildCommentKeyDigest("review-summary")
expect(digest).toHaveLength(64)
expect(digest).toMatch(/^[0-9a-f]+$/)
})

test("same key always produces same digest", () => {
expect(buildCommentKeyDigest("my-key")).toBe(buildCommentKeyDigest("my-key"))
})

test("different keys produce different digests", () => {
expect(buildCommentKeyDigest("key-a")).not.toBe(buildCommentKeyDigest("key-b"))
})

test("special characters and spaces are handled without error", () => {
expect(() => buildCommentKeyDigest("hello world & <test> --> done")).not.toThrow()
})

test("empty string produces a stable digest (callers decide whether to use it)", () => {
const digest = buildCommentKeyDigest("")
expect(digest).toHaveLength(64)
})
})

describe("appendCommentAnchor", () => {
test("appends anchor on a new line at the end", () => {
const result = appendCommentAnchor("hello", "abc123")
expect(result).toBe("hello\n<!-- opencode:comment-key:sha256:abc123 -->")
})

test("anchor contains the exact digest", () => {
const digest = buildCommentKeyDigest("review-summary")
const result = appendCommentAnchor("body text", digest)
expect(result).toContain(`<!-- opencode:comment-key:sha256:${digest} -->`)
})

test("anchor is always at the very end of the body", () => {
const result = appendCommentAnchor("line1\nline2", "d1g3st")
expect(result.endsWith("-->")).toBe(true)
})

test("body with no trailing newline gets exactly one newline before anchor", () => {
const result = appendCommentAnchor("content", "digest")
const parts = result.split("\n")
expect(parts[parts.length - 1]).toBe("<!-- opencode:comment-key:sha256:digest -->")
expect(parts[parts.length - 2]).toBe("content")
})
})

describe("findStickyCommentIds", () => {
test("matches comments by anchor regardless of author", () => {
const ids = findStickyCommentIds(
[
{
id: 1,
user: { login: "opencode-agent[bot]" },
body: "body\n<!-- opencode:comment-key:sha256:abc -->",
},
{
id: 2,
user: { login: "someone-else" },
body: "body\n<!-- opencode:comment-key:sha256:abc -->",
},
],
"<!-- opencode:comment-key:sha256:abc -->",
)

expect(ids).toEqual([1, 2])
})

test("does not match comments without anchor", () => {
const ids = findStickyCommentIds(
[
{
id: 3,
user: { login: "someone-else" },
body: "body without sticky anchor",
},
],
"<!-- opencode:comment-key:sha256:abc -->",
)

expect(ids).toEqual([])
})

test("returns matching ids in API order", () => {
const ids = findStickyCommentIds(
[
{
id: 4,
user: { login: "github-actions[bot]" },
body: "body\n<!-- opencode:comment-key:sha256:abc -->",
},
{
id: 5,
user: { login: "github-actions[bot]" },
body: "body\n<!-- opencode:comment-key:sha256:abc -->",
},
],
"<!-- opencode:comment-key:sha256:abc -->",
)

expect(ids).toEqual([4, 5])
})
})
Loading
Loading