From 18f5e7663de084be4a3bc0ef61c8d151ab6b84ec Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Thu, 25 Dec 2025 09:02:14 -0500 Subject: [PATCH 1/2] github: support issues and workflow_dispatch events - add issues and workflow_dispatch to supported event types - categorize events into USER_EVENTS and REPO_EVENTS for clearer routing - USER_EVENTS (issue_comment, pull_request_review_comment, issues, pull_request): have actor, support reactions/comments - REPO_EVENTS (schedule, workflow_dispatch): no actor, output to logs/PR only - require PROMPT input for issues events (no comment to parse) - add reaction to issue itself for issues events - update docs with new events in supported events table - add issues triage workflow example with account age filter - fix docs formatting for scheduled workflows note --- packages/opencode/src/cli/cmd/github.ts | 80 +++++++++++++++++------- packages/web/src/content/docs/github.mdx | 70 ++++++++++++++++++--- 2 files changed, 119 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 37aed2426d1..a1b93e79aed 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -9,7 +9,9 @@ import * as github from "@actions/github" import type { Context } from "@actions/github/lib/context" import type { IssueCommentEvent, + IssuesEvent, PullRequestReviewCommentEvent, + WorkflowDispatchEvent, WorkflowRunEvent, PullRequestEvent, } from "@octokit/webhooks-types" @@ -132,7 +134,16 @@ type IssueQueryResponse = { const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_REACTION = "eyes" const WORKFLOW_FILE = ".github/workflows/opencode.yml" -const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const + +// Event categories for routing +// USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments +// REPO_EVENTS: triggered by automation, no actor/issueId, output to logs/PR only +const USER_EVENTS = ["issue_comment", "pull_request_review_comment", "issues", "pull_request"] as const +const REPO_EVENTS = ["schedule", "workflow_dispatch"] as const +const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const + +type UserEvent = (typeof USER_EVENTS)[number] +type RepoEvent = (typeof REPO_EVENTS)[number] // Parses GitHub remote URLs in various formats: // - https://github.com/owner/repo.git @@ -397,27 +408,37 @@ export const GithubRunCommand = cmd({ core.setFailed(`Unsupported event type: ${context.eventName}`) process.exit(1) } + + // Determine event category for routing + // USER_EVENTS: have actor, issueId, support reactions/comments + // REPO_EVENTS: no actor/issueId, output to logs/PR only + const isUserEvent = USER_EVENTS.includes(context.eventName as UserEvent) + const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent) const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName) - const isScheduleEvent = context.eventName === "schedule" + const isIssuesEvent = context.eventName === "issues" + // Legacy alias for backward compatibility in existing code + const isScheduleEvent = isRepoEvent const { providerID, modelID } = normalizeModel() const runId = normalizeRunId() const share = normalizeShare() const oidcBaseUrl = normalizeOidcBaseUrl() const { owner, repo } = context.repo - // For schedule events, payload has no issue/comment data + // For repo events (schedule, workflow_dispatch), payload has no issue/comment data const payload = context.payload as | IssueCommentEvent + | IssuesEvent | PullRequestReviewCommentEvent + | WorkflowDispatchEvent | WorkflowRunEvent | PullRequestEvent const issueEvent = isIssueCommentEvent(payload) ? payload : undefined const actor = isScheduleEvent ? undefined : context.actor - const issueId = isScheduleEvent + const issueId = isRepoEvent ? undefined - : context.eventName === "issue_comment" - ? (payload as IssueCommentEvent).issue.number + : context.eventName === "issue_comment" || context.eventName === "issues" + ? (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" @@ -462,8 +483,8 @@ export const GithubRunCommand = cmd({ if (!useGithubToken) { await configureGit(appToken) } - // Skip permission check for schedule events (no actor to check) - if (!isScheduleEvent) { + // Skip permission check and reactions for repo events (no actor to check, no issue to react to) + if (isUserEvent) { await assertPermissions() await addReaction(commentType) } @@ -480,13 +501,12 @@ export const GithubRunCommand = cmd({ })() console.log("opencode session", session.id) - // Handle 4 cases - // 1. Schedule (no issue/PR context) - // 2. Issue - // 3. Local PR - // 4. Fork PR - if (isScheduleEvent) { - // Schedule event - no issue/PR context, output goes to logs + // Handle event types: + // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only + // USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch + // USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR + if (isRepoEvent) { + // Repo event - no issue/PR context, output goes to logs const branch = await checkoutNewBranch("schedule") const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const response = await chat(userPrompt, promptFiles) @@ -494,11 +514,12 @@ export const GithubRunCommand = cmd({ if (dirty) { const summary = await summarize(response) await pushToNewBranch(summary, branch, uncommittedChanges, true) + const triggerType = context.eventName === "workflow_dispatch" ? "workflow_dispatch" : "scheduled workflow" const pr = await createPR( repoData.data.default_branch, branch, summary, - `${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`, + `${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`, ) console.log(`Created PR #${pr}`) } else { @@ -573,7 +594,7 @@ export const GithubRunCommand = cmd({ } else if (e instanceof Error) { msg = e.message } - if (!isScheduleEvent) { + if (isUserEvent) { await createComment(`${msg}${footer()}`) await removeReaction(commentType) } @@ -628,9 +649,15 @@ export const GithubRunCommand = cmd({ } function isIssueCommentEvent( - event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent, + event: + | IssueCommentEvent + | IssuesEvent + | PullRequestReviewCommentEvent + | WorkflowDispatchEvent + | WorkflowRunEvent + | PullRequestEvent, ): event is IssueCommentEvent { - return "issue" in event + return "issue" in event && "comment" in event } function getReviewCommentContext() { @@ -652,10 +679,19 @@ export const GithubRunCommand = cmd({ async function getUserPrompt() { const customPrompt = process.env["PROMPT"] - // For schedule events, PROMPT is required since there's no comment to extract from - if (isScheduleEvent) { + // For repo events (schedule, workflow_dispatch), PROMPT is required since there's no comment/issue to extract from + if (isRepoEvent) { + if (!customPrompt) { + throw new Error("PROMPT input is required for scheduled and workflow_dispatch events") + } + return { userPrompt: customPrompt, promptFiles: [] } + } + + // For issues events, PROMPT is required since there's no comment to extract from + // The workflow defines the prompt (e.g., "triage this issue") + if (isIssuesEvent) { if (!customPrompt) { - throw new Error("PROMPT input is required for scheduled events") + throw new Error("PROMPT input is required for issues events") } return { userPrompt: customPrompt, promptFiles: [] } } diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 25c3ce927a1..90836fec614 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -104,12 +104,14 @@ Or you can set it up manually. OpenCode can be triggered by the following GitHub events: -| Event Type | Triggered By | Details | -| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads the issue/PR context and can create branches, open PRs, or reply with explanations. | -| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context for precise responses. | -| `schedule` | Cron-based schedule | Run OpenCode on a schedule using the `prompt` input. Useful for automated code reviews, reports, or maintenance tasks. OpenCode can create issues or PRs as needed. | -| `pull_request` | PR opened or updated | Automatically trigger OpenCode when PRs are opened, synchronized, or reopened. Useful for automated reviews without needing to leave a comment. | +| Event Type | Triggered By | Details | +| ----------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads context and can create branches, open PRs, or reply. | +| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context. | +| `issues` | Issue opened or edited | Automatically trigger OpenCode when issues are created or modified. Requires `prompt` input. | +| `pull_request` | PR opened or updated | Automatically trigger OpenCode when PRs are opened, synchronized, or reopened. Useful for automated reviews. | +| `schedule` | Cron-based schedule | Run OpenCode on a schedule. Requires `prompt` input. Output goes to logs and PRs (no issue to comment on). | +| `workflow_dispatch` | Manual trigger from GitHub UI | Trigger OpenCode on demand via Actions tab. Requires `prompt` input. Output goes to logs and PRs. | ### Schedule Example @@ -145,9 +147,7 @@ jobs: If you find issues worth addressing, open an issue to track them. ``` -For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from. - -> **Note:** Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs during a scheduled run. +For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from. Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs. --- @@ -188,6 +188,58 @@ For `pull_request` events, if no `prompt` is provided, OpenCode defaults to revi --- +### Issues Triage Example + +Automatically triage new issues. This example filters to accounts older than 30 days to reduce spam: + +```yaml title=".github/workflows/opencode-triage.yml" +name: Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + issues: write + steps: + - name: Check account age + id: check + uses: actions/github-script@v7 + with: + script: | + const user = await github.rest.users.getByUsername({ + username: context.payload.issue.user.login + }); + const created = new Date(user.data.created_at); + const days = (Date.now() - created) / (1000 * 60 * 60 * 24); + return days >= 30; + result-encoding: string + + - uses: actions/checkout@v4 + if: steps.check.outputs.result == 'true' + + - uses: sst/opencode/github@latest + if: steps.check.outputs.result == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review this issue. If there's a clear fix or relevant docs: + - Provide documentation links + - Add error handling guidance for code examples + Otherwise, do not comment. +``` + +For `issues` events, the `prompt` input is **required** since there's no comment to extract instructions from. + +--- + ## Custom prompts Override the default prompt to customize OpenCode's behavior for your workflow. From 88b37dd532d4438bfcc5c2cc6bc363d7e687a72c Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Thu, 25 Dec 2025 09:10:52 -0500 Subject: [PATCH 2/2] fix: workflow_dispatch actor attribution and docs permissions - workflow_dispatch now logs actor and includes co-author attribution - add separate branch prefix 'dispatch' for workflow_dispatch events - fix docs example to include contents:write and pull-requests:write permissions --- packages/opencode/src/cli/cmd/github.ts | 40 +++++++++++------------- packages/web/src/content/docs/github.mdx | 3 +- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index a1b93e79aed..748a9638490 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -416,8 +416,8 @@ export const GithubRunCommand = cmd({ const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent) const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName) const isIssuesEvent = context.eventName === "issues" - // Legacy alias for backward compatibility in existing code - const isScheduleEvent = isRepoEvent + const isScheduleEvent = context.eventName === "schedule" + const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch" const { providerID, modelID } = normalizeModel() const runId = normalizeRunId() @@ -433,6 +433,7 @@ export const GithubRunCommand = cmd({ | WorkflowRunEvent | PullRequestEvent const issueEvent = isIssueCommentEvent(payload) ? payload : undefined + // workflow_dispatch has an actor (the user who triggered it), schedule does not const actor = isScheduleEvent ? undefined : context.actor const issueId = isRepoEvent @@ -507,14 +508,19 @@ export const GithubRunCommand = cmd({ // USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR if (isRepoEvent) { // Repo event - no issue/PR context, output goes to logs - const branch = await checkoutNewBranch("schedule") + if (isWorkflowDispatchEvent && actor) { + console.log(`Triggered by: ${actor}`) + } + const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule" + const branch = await checkoutNewBranch(branchPrefix) const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const response = await chat(userPrompt, promptFiles) const { dirty, uncommittedChanges } = await branchIsDirty(head) if (dirty) { const summary = await summarize(response) - await pushToNewBranch(summary, branch, uncommittedChanges, true) - const triggerType = context.eventName === "workflow_dispatch" ? "workflow_dispatch" : "scheduled workflow" + // workflow_dispatch has an actor for co-author attribution, schedule does not + await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent) + const triggerType = isWorkflowDispatchEvent ? "workflow_dispatch" : "scheduled workflow" const pr = await createPR( repoData.data.default_branch, branch, @@ -679,19 +685,11 @@ export const GithubRunCommand = cmd({ async function getUserPrompt() { const customPrompt = process.env["PROMPT"] - // For repo events (schedule, workflow_dispatch), PROMPT is required since there's no comment/issue to extract from - if (isRepoEvent) { - if (!customPrompt) { - throw new Error("PROMPT input is required for scheduled and workflow_dispatch events") - } - return { userPrompt: customPrompt, promptFiles: [] } - } - - // For issues events, PROMPT is required since there's no comment to extract from - // The workflow defines the prompt (e.g., "triage this issue") - if (isIssuesEvent) { + // For repo events and issues events, PROMPT is required since there's no comment to extract from + if (isRepoEvent || isIssuesEvent) { if (!customPrompt) { - throw new Error("PROMPT input is required for issues events") + const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues" + throw new Error(`PROMPT input is required for ${eventType} events`) } return { userPrompt: customPrompt, promptFiles: [] } } @@ -959,7 +957,7 @@ export const GithubRunCommand = cmd({ await $`git config --local ${config} "${gitConfig}"` } - async function checkoutNewBranch(type: "issue" | "schedule") { + async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") { console.log("Checking out new branch...") const branch = generateBranchName(type) await $`git checkout -b ${branch}` @@ -988,16 +986,16 @@ export const GithubRunCommand = cmd({ await $`git checkout -b ${localBranch} fork/${remoteBranch}` } - function generateBranchName(type: "issue" | "pr" | "schedule") { + function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") { const timestamp = new Date() .toISOString() .replace(/[:-]/g, "") .replace(/\.\d{3}Z/, "") .split("T") .join("") - if (type === "schedule") { + if (type === "schedule" || type === "dispatch") { const hex = crypto.randomUUID().slice(0, 6) - return `opencode/scheduled-${hex}-${timestamp}` + return `opencode/${type}-${hex}-${timestamp}` } return `opencode/${type}${issueId}-${timestamp}` } diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 90836fec614..63c5d855b9c 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -204,7 +204,8 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - contents: read + contents: write + pull-requests: write issues: write steps: - name: Check account age