diff --git a/github/index.ts b/github/index.ts index 51ee2a46a531..8dbb60007e8c 100644 --- a/github/index.ts +++ b/github/index.ts @@ -120,7 +120,7 @@ let octoGraph: typeof graphql let commentId: number let gitConfig: string let session: { id: string; title: string; version: string } -let shareId: string | undefined +let shareInfo: { id: string; url: string } | undefined let exitCode = 0 type PromptFiles = Awaited>["promptFiles"] @@ -146,15 +146,17 @@ try { const repoData = await fetchRepo() session = await client.session.create().then((r) => r.data) await subscribeSessionEvents() - shareId = await (async () => { + shareInfo = await (async () => { if (useEnvShare() === false) return if (!useEnvShare() && repoData.data.private) return - await client.session.share({ path: session }) - return session.id.slice(-8) + const response = await client.session.share({ path: session }) + const url = response.data.share?.url + if (!url) return + return { id: parseShareId(url), url } })() console.log("opencode session", session.id) - if (shareId) { - console.log("Share link:", `${useShareUrl()}/s/${shareId}`) + if (shareInfo?.url) { + console.log("Share link:", shareInfo.url) } // Handle 3 cases @@ -172,7 +174,8 @@ try { const summary = await summarize(response) await pushToLocalBranch(summary) } - const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`)) + const shareUrl = shareInfo?.url + const hasShared = shareUrl ? prData.comments.nodes.some((c) => c.body.includes(shareUrl)) : false await updateComment(`${response}${footer({ image: !hasShared })}`) } // Fork PR @@ -184,7 +187,8 @@ try { const summary = await summarize(response) await pushToForkBranch(summary, prData) } - const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`)) + const shareUrl = shareInfo?.url + const hasShared = shareUrl ? prData.comments.nodes.some((c) => c.body.includes(shareUrl)) : false await updateComment(`${response}${footer({ image: !hasShared })}`) } } @@ -362,10 +366,6 @@ function useIssueId() { return payload.issue.number } -function useShareUrl() { - return isMock() ? "https://dev.opencode.ai" : "https://opencode.ai" -} - async function getAccessToken() { const { repo } = useContext() @@ -815,18 +815,25 @@ function footer(opts?: { image?: boolean }) { const { providerID, modelID } = useEnvModel() const image = (() => { - if (!shareId) return "" + if (!shareInfo?.url) return "" + if (!shareInfo.id) return "" if (!opts?.image) return "" const titleAlt = encodeURIComponent(session.title.substring(0, 50)) const title64 = Buffer.from(session.title.substring(0, 700), "utf8").toString("base64") - return `${titleAlt}\n` + return `${titleAlt}\n` })() - const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId})  |  ` : "" + const shareUrl = shareInfo?.url ? `[opencode session](${shareInfo.url})  |  ` : "" return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})` } +function parseShareId(url: string) { + const pathname = url.split("?").at(0) + if (!pathname) return "" + return pathname.split("/").filter(Boolean).at(-1) ?? "" +} + async function fetchRepo() { const { repo } = useContext() return await octoRest.rest.repos.get({ owner: repo.owner, repo: repo.repo }) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index a6754ec2df63..e0994a997ed4 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -181,6 +181,35 @@ export function formatPromptTooLargeError(files: { filename: string; content: st return `PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.${fileDetails}` } +export function formatGithubFooter(input: { + runUrl: string + share?: { id: string; url: string } | undefined + image?: boolean | undefined + session?: { title: string; version: string } | undefined + providerID?: string | undefined + modelID?: string | undefined +}) { + const image = (() => { + if (!input.share?.url) return "" + if (!input.share.id) return "" + if (!input.image) return "" + if (!input.session || !input.providerID || !input.modelID) return "" + + const titleAlt = encodeURIComponent(input.session.title.substring(0, 50)) + const title64 = Buffer.from(input.session.title.substring(0, 700), "utf8").toString("base64") + + return `${titleAlt}\n` + })() + const shareUrl = input.share?.url ? `[opencode session](${input.share.url})  |  ` : "" + return `\n\n${image}${shareUrl}[github run](${input.runUrl})` +} + +function parseShareId(url: string) { + const pathname = url.split("?").at(0) + if (!pathname) return "" + return pathname.split("/").filter(Boolean).at(-1) ?? "" +} + export const GithubCommand = cmd({ command: "github", describe: "manage GitHub agent", @@ -478,14 +507,13 @@ export const GithubRunCommand = effectCmd({ ? (payload as IssueCommentEvent | IssuesEvent).issue.number : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number const runUrl = `/${owner}/${repo}/actions/runs/${runId}` - const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" let appToken: string let octoRest: Octokit let octoGraph: typeof graphql let gitConfig: string let session: { id: SessionID; title: string; version: string } - let shareId: string | undefined + let shareInfo: { id: string; url: string } | undefined let exitCode = 0 type PromptFiles = Awaited>["promptFiles"] const triggerCommentId = isCommentEvent @@ -560,13 +588,17 @@ export const GithubRunCommand = effectCmd({ }), ) subscribeSessionEvents() - shareId = await (async () => { + shareInfo = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await Effect.runPromise(sessionShare.share(session.id)) - return session.id.slice(-8) + const result = await Effect.runPromise(sessionShare.share(session.id)) + if (!result.url) return + return { id: parseShareId(result.url), url: result.url } })() console.log("opencode session", session.id) + if (shareInfo?.url) { + console.log("Share link:", shareInfo.url) + } // Handle event types: // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only @@ -624,7 +656,8 @@ export const GithubRunCommand = effectCmd({ const summary = await summarize(response) await pushToLocalBranch(summary, uncommittedChanges) } - const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`)) + const shareUrl = shareInfo?.url + const hasShared = shareUrl ? prData.comments.nodes.some((c) => c.body.includes(shareUrl)) : false await createComment(`${response}${footer({ image: !hasShared })}`) await removeReaction(commentType) } @@ -642,7 +675,8 @@ export const GithubRunCommand = effectCmd({ const summary = await summarize(response) await pushToForkBranch(summary, prData, uncommittedChanges) } - const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`)) + const shareUrl = shareInfo?.url + const hasShared = shareUrl ? prData.comments.nodes.some((c) => c.body.includes(shareUrl)) : false await createComment(`${response}${footer({ image: !hasShared })}`) await removeReaction(commentType) } @@ -1392,17 +1426,14 @@ export const GithubRunCommand = effectCmd({ } function footer(opts?: { image?: boolean }) { - const image = (() => { - if (!shareId) return "" - if (!opts?.image) return "" - - const titleAlt = encodeURIComponent(session.title.substring(0, 50)) - const title64 = Buffer.from(session.title.substring(0, 700), "utf8").toString("base64") - - return `${titleAlt}\n` - })() - const shareUrl = shareId ? `[opencode session](${shareBaseUrl}/s/${shareId})  |  ` : "" - return `\n\n${image}${shareUrl}[github run](${runUrl})` + return formatGithubFooter({ + runUrl, + share: shareInfo, + image: opts?.image, + session, + providerID, + modelID, + }) } async function fetchRepo() { diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 263f3a45f318..dab2e7c2bad3 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from "bun:test" -import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github" +import { extractResponseText, formatGithubFooter, formatPromptTooLargeError } from "../../src/cli/cmd/github" import type { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID, PartID } from "../../src/session/schema" @@ -196,3 +196,28 @@ describe("formatPromptTooLargeError", () => { expect(result).toInclude("img3.gif (9 KB)") }) }) + +describe("formatGithubFooter", () => { + test("uses canonical share URL returned by share service", () => { + const result = formatGithubFooter({ + runUrl: "/anomalyco/opencode/actions/runs/123", + share: { id: "uaFpXIla", url: "https://opncd.ai/share/uaFpXIla" }, + image: true, + session: { title: "Fix GitHub share links", version: "1.2.3" }, + providerID: "openai", + modelID: "gpt-5", + }) + + expect(result).toContain("[opencode session](https://opncd.ai/share/uaFpXIla)") + expect(result).toContain('') + expect(result).toContain("id=uaFpXIla") + expect(result).not.toContain("https://opencode.ai/s/") + expect(result).not.toContain("/s/uaFpXIla") + }) + + test("omits share link when sharing is unavailable", () => { + const result = formatGithubFooter({ runUrl: "/anomalyco/opencode/actions/runs/123" }) + + expect(result).toBe("\n\n[github run](/anomalyco/opencode/actions/runs/123)") + }) +})