Skip to content

Commit aecb8b8

Browse files
committed
feat(github): support schedule events
- add schedule to allowed event types - skip keyword check, permissions, reactions for schedule events - require PROMPT input for schedule events - branch naming: opencode/scheduled-{6-char-hex}-{timestamp} - omit co-authored-by on commits (schedule operates as the repo) - output to console instead of creating comments - add docs with Supported Events table and schedule example
1 parent 4c3336b commit aecb8b8

File tree

2 files changed

+139
-36
lines changed

2 files changed

+139
-36
lines changed

packages/opencode/src/cli/cmd/github.ts

Lines changed: 89 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ type IssueQueryResponse = {
127127
const AGENT_USERNAME = "opencode-agent[bot]"
128128
const AGENT_REACTION = "eyes"
129129
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
130+
const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule"] as const
130131

131132
// Parses GitHub remote URLs in various formats:
132133
// - https://github.com/owner/repo.git
@@ -387,22 +388,27 @@ export const GithubRunCommand = cmd({
387388
const isMock = args.token || args.event
388389

389390
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
390-
if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") {
391+
if (!SUPPORTED_EVENTS.includes(context.eventName as (typeof SUPPORTED_EVENTS)[number])) {
391392
core.setFailed(`Unsupported event type: ${context.eventName}`)
392393
process.exit(1)
393394
}
395+
const isScheduleEvent = context.eventName === "schedule"
394396

395397
const { providerID, modelID } = normalizeModel()
396398
const runId = normalizeRunId()
397399
const share = normalizeShare()
398400
const oidcBaseUrl = normalizeOidcBaseUrl()
399401
const { owner, repo } = context.repo
400-
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
401-
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
402-
const actor = context.actor
403-
404-
const issueId =
405-
context.eventName === "pull_request_review_comment"
402+
// For schedule events, payload has no issue/comment data
403+
const payload = isScheduleEvent
404+
? undefined
405+
: (context.payload as IssueCommentEvent | PullRequestReviewCommentEvent)
406+
const issueEvent = payload && isIssueCommentEvent(payload) ? payload : undefined
407+
const actor = isScheduleEvent ? undefined : context.actor
408+
409+
const issueId = isScheduleEvent
410+
? undefined
411+
: context.eventName === "pull_request_review_comment"
406412
? (payload as PullRequestReviewCommentEvent).pull_request.number
407413
: (payload as IssueCommentEvent).issue.number
408414
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
@@ -416,7 +422,7 @@ export const GithubRunCommand = cmd({
416422
let shareId: string | undefined
417423
let exitCode = 0
418424
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
419-
const triggerCommentId = payload.comment.id
425+
const triggerCommentId = payload?.comment.id
420426
const useGithubToken = normalizeUseGithubToken()
421427
const commentType = context.eventName === "pull_request_review_comment" ? "pr_review" : "issue"
422428

@@ -442,9 +448,11 @@ export const GithubRunCommand = cmd({
442448
if (!useGithubToken) {
443449
await configureGit(appToken)
444450
}
445-
await assertPermissions()
446-
447-
await addReaction(commentType)
451+
// Skip permission check for schedule events (no actor to check)
452+
if (!isScheduleEvent) {
453+
await assertPermissions()
454+
await addReaction(commentType)
455+
}
448456

449457
// Setup opencode session
450458
const repoData = await fetchRepo()
@@ -458,11 +466,31 @@ export const GithubRunCommand = cmd({
458466
})()
459467
console.log("opencode session", session.id)
460468

461-
// Handle 3 cases
462-
// 1. Issue
463-
// 2. Local PR
464-
// 3. Fork PR
465-
if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
469+
// Handle 4 cases
470+
// 1. Schedule (no issue/PR context)
471+
// 2. Issue
472+
// 3. Local PR
473+
// 4. Fork PR
474+
if (isScheduleEvent) {
475+
// Schedule event - no issue/PR context, output goes to logs
476+
const branch = await checkoutNewBranch("schedule")
477+
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
478+
const response = await chat(userPrompt, promptFiles)
479+
const { dirty, uncommittedChanges } = await branchIsDirty(head)
480+
if (dirty) {
481+
const summary = await summarize(response)
482+
await pushToNewBranch(summary, branch, uncommittedChanges, true)
483+
const pr = await createPR(
484+
repoData.data.default_branch,
485+
branch,
486+
summary,
487+
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
488+
)
489+
console.log(`Created PR #${pr}`)
490+
} else {
491+
console.log("Response:", response)
492+
}
493+
} else if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
466494
const prData = await fetchPR()
467495
// Local PR
468496
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
@@ -497,15 +525,15 @@ export const GithubRunCommand = cmd({
497525
}
498526
// Issue
499527
else {
500-
const branch = await checkoutNewBranch()
528+
const branch = await checkoutNewBranch("issue")
501529
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
502530
const issueData = await fetchIssue()
503531
const dataPrompt = buildPromptDataForIssue(issueData)
504532
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
505533
const { dirty, uncommittedChanges } = await branchIsDirty(head)
506534
if (dirty) {
507535
const summary = await summarize(response)
508-
await pushToNewBranch(summary, branch, uncommittedChanges)
536+
await pushToNewBranch(summary, branch, uncommittedChanges, false)
509537
const pr = await createPR(
510538
repoData.data.default_branch,
511539
branch,
@@ -528,8 +556,10 @@ export const GithubRunCommand = cmd({
528556
} else if (e instanceof Error) {
529557
msg = e.message
530558
}
531-
await createComment(`${msg}${footer()}`)
532-
await removeReaction(commentType)
559+
if (!isScheduleEvent) {
560+
await createComment(`${msg}${footer()}`)
561+
await removeReaction(commentType)
562+
}
533563
core.setFailed(msg)
534564
// Also output the clean error message for the action to capture
535565
//core.setOutput("prepare_error", e.message);
@@ -605,6 +635,14 @@ export const GithubRunCommand = cmd({
605635

606636
async function getUserPrompt() {
607637
const customPrompt = process.env["PROMPT"]
638+
// For schedule events, PROMPT is required since there's no comment to extract from
639+
if (isScheduleEvent) {
640+
if (!customPrompt) {
641+
throw new Error("PROMPT input is required for scheduled events")
642+
}
643+
return { userPrompt: customPrompt, promptFiles: [] }
644+
}
645+
608646
if (customPrompt) {
609647
return { userPrompt: customPrompt, promptFiles: [] }
610648
}
@@ -615,7 +653,7 @@ export const GithubRunCommand = cmd({
615653
.map((m) => m.trim().toLowerCase())
616654
.filter(Boolean)
617655
let prompt = (() => {
618-
const body = payload.comment.body.trim()
656+
const body = payload!.comment.body.trim()
619657
const bodyLower = body.toLowerCase()
620658
if (mentions.some((m) => bodyLower === m)) {
621659
if (reviewContext) {
@@ -865,9 +903,9 @@ export const GithubRunCommand = cmd({
865903
await $`git config --local ${config} "${gitConfig}"`
866904
}
867905

868-
async function checkoutNewBranch() {
906+
async function checkoutNewBranch(type: "issue" | "schedule") {
869907
console.log("Checking out new branch...")
870-
const branch = generateBranchName("issue")
908+
const branch = generateBranchName(type)
871909
await $`git checkout -b ${branch}`
872910
return branch
873911
}
@@ -894,23 +932,32 @@ export const GithubRunCommand = cmd({
894932
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
895933
}
896934

897-
function generateBranchName(type: "issue" | "pr") {
935+
function generateBranchName(type: "issue" | "pr" | "schedule") {
898936
const timestamp = new Date()
899937
.toISOString()
900938
.replace(/[:-]/g, "")
901939
.replace(/\.\d{3}Z/, "")
902940
.split("T")
903941
.join("")
942+
if (type === "schedule") {
943+
const hex = crypto.randomUUID().slice(0, 6)
944+
return `opencode/scheduled-${hex}-${timestamp}`
945+
}
904946
return `opencode/${type}${issueId}-${timestamp}`
905947
}
906948

907-
async function pushToNewBranch(summary: string, branch: string, commit: boolean) {
949+
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
908950
console.log("Pushing to new branch...")
909951
if (commit) {
910952
await $`git add .`
911-
await $`git commit -m "${summary}
953+
if (isSchedule) {
954+
// No co-author for scheduled events - the schedule is operating as the repo
955+
await $`git commit -m "${summary}"`
956+
} else {
957+
await $`git commit -m "${summary}
912958
913959
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
960+
}
914961
}
915962
await $`git push -u origin ${branch}`
916963
}
@@ -958,14 +1005,15 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
9581005
}
9591006

9601007
async function assertPermissions() {
1008+
// Only called for non-schedule events, so actor is defined
9611009
console.log(`Asserting permissions for user ${actor}...`)
9621010

9631011
let permission
9641012
try {
9651013
const response = await octoRest.repos.getCollaboratorPermissionLevel({
9661014
owner,
9671015
repo,
968-
username: actor,
1016+
username: actor!,
9691017
})
9701018

9711019
permission = response.data.permission
@@ -979,30 +1027,32 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
9791027
}
9801028

9811029
async function addReaction(commentType: "issue" | "pr_review") {
1030+
// Only called for non-schedule events, so triggerCommentId is defined
9821031
console.log("Adding reaction...")
9831032
if (commentType === "pr_review") {
9841033
return await octoRest.rest.reactions.createForPullRequestReviewComment({
9851034
owner,
9861035
repo,
987-
comment_id: triggerCommentId,
1036+
comment_id: triggerCommentId!,
9881037
content: AGENT_REACTION,
9891038
})
9901039
}
9911040
return await octoRest.rest.reactions.createForIssueComment({
9921041
owner,
9931042
repo,
994-
comment_id: triggerCommentId,
1043+
comment_id: triggerCommentId!,
9951044
content: AGENT_REACTION,
9961045
})
9971046
}
9981047

9991048
async function removeReaction(commentType: "issue" | "pr_review") {
1049+
// Only called for non-schedule events, so triggerCommentId is defined
10001050
console.log("Removing reaction...")
10011051
if (commentType === "pr_review") {
10021052
const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
10031053
owner,
10041054
repo,
1005-
comment_id: triggerCommentId,
1055+
comment_id: triggerCommentId!,
10061056
content: AGENT_REACTION,
10071057
})
10081058

@@ -1012,7 +1062,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10121062
await octoRest.rest.reactions.deleteForPullRequestComment({
10131063
owner,
10141064
repo,
1015-
comment_id: triggerCommentId,
1065+
comment_id: triggerCommentId!,
10161066
reaction_id: eyesReaction.id,
10171067
})
10181068
return
@@ -1021,7 +1071,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10211071
const reactions = await octoRest.rest.reactions.listForIssueComment({
10221072
owner,
10231073
repo,
1024-
comment_id: triggerCommentId,
1074+
comment_id: triggerCommentId!,
10251075
content: AGENT_REACTION,
10261076
})
10271077

@@ -1031,17 +1081,18 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10311081
await octoRest.rest.reactions.deleteForIssueComment({
10321082
owner,
10331083
repo,
1034-
comment_id: triggerCommentId,
1084+
comment_id: triggerCommentId!,
10351085
reaction_id: eyesReaction.id,
10361086
})
10371087
}
10381088

10391089
async function createComment(body: string) {
1090+
// Only called for non-schedule events, so issueId is defined
10401091
console.log("Creating comment...")
10411092
return await octoRest.rest.issues.createComment({
10421093
owner,
10431094
repo,
1044-
issue_number: issueId,
1095+
issue_number: issueId!,
10451096
body,
10461097
})
10471098
}
@@ -1119,10 +1170,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
11191170
}
11201171

11211172
function buildPromptDataForIssue(issue: GitHubIssue) {
1173+
// Only called for non-schedule events, so payload is defined
11221174
const comments = (issue.comments?.nodes || [])
11231175
.filter((c) => {
11241176
const id = parseInt(c.databaseId)
1125-
return id !== payload.comment.id
1177+
return id !== payload!.comment.id
11261178
})
11271179
.map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
11281180

@@ -1246,10 +1298,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
12461298
}
12471299

12481300
function buildPromptDataForPR(pr: GitHubPullRequest) {
1301+
// Only called for non-schedule events, so payload is defined
12491302
const comments = (pr.comments?.nodes || [])
12501303
.filter((c) => {
12511304
const id = parseInt(c.databaseId)
1252-
return id !== payload.comment.id
1305+
return id !== payload!.comment.id
12531306
})
12541307
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
12551308

packages/web/src/content/docs/github.mdx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,56 @@ Or you can set it up manually.
9999
100100
---
101101
102+
## Supported Events
103+
104+
OpenCode can be triggered by the following GitHub events:
105+
106+
| Event Type | Triggered By | Details |
107+
| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108+
| `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. |
109+
| `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. |
110+
| `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. |
111+
112+
### Schedule Example
113+
114+
Run OpenCode on a schedule to perform automated tasks:
115+
116+
```yaml title=".github/workflows/opencode-scheduled.yml"
117+
name: Scheduled OpenCode Task
118+
119+
on:
120+
schedule:
121+
- cron: "0 9 * * 1" # Every Monday at 9am UTC
122+
123+
jobs:
124+
opencode:
125+
runs-on: ubuntu-latest
126+
permissions:
127+
id-token: write
128+
contents: write
129+
pull-requests: write
130+
issues: write
131+
steps:
132+
- name: Checkout repository
133+
uses: actions/checkout@v4
134+
135+
- name: Run OpenCode
136+
uses: sst/opencode/github@latest
137+
env:
138+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
139+
with:
140+
model: anthropic/claude-sonnet-4-20250514
141+
prompt: |
142+
Review the codebase for any TODO comments and create a summary.
143+
If you find issues worth addressing, open an issue to track them.
144+
```
145+
146+
For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from.
147+
148+
> **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.
149+
150+
---
151+
102152
## Custom prompts
103153

104154
Override the default prompt to customize OpenCode's behavior for your workflow.

0 commit comments

Comments
 (0)