Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 61 additions & 27 deletions packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -397,27 +408,38 @@ 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 isIssuesEvent = context.eventName === "issues"
const isScheduleEvent = context.eventName === "schedule"
const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"

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
// workflow_dispatch has an actor (the user who triggered it), schedule does not
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"
Expand Down Expand Up @@ -462,8 +484,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)
}
Expand All @@ -480,25 +502,30 @@ 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
const branch = await checkoutNewBranch("schedule")
// 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
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)
// 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,
summary,
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
)
console.log(`Created PR #${pr}`)
} else {
Expand Down Expand Up @@ -573,7 +600,7 @@ export const GithubRunCommand = cmd({
} else if (e instanceof Error) {
msg = e.message
}
if (!isScheduleEvent) {
if (isUserEvent) {
await createComment(`${msg}${footer()}`)
await removeReaction(commentType)
}
Expand Down Expand Up @@ -628,9 +655,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() {
Expand All @@ -652,10 +685,11 @@ 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 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 scheduled events")
const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues"
throw new Error(`PROMPT input is required for ${eventType} events`)
}
return { userPrompt: customPrompt, promptFiles: [] }
}
Expand Down Expand Up @@ -923,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}`
Expand Down Expand Up @@ -952,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}`
}
Expand Down
71 changes: 62 additions & 9 deletions packages/web/src/content/docs/github.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -188,6 +188,59 @@ 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: write
pull-requests: write
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.
Expand Down