Skip to content

Commit 5a779fd

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
1 parent d98ec97 commit 5a779fd

File tree

1 file changed

+88
-34
lines changed

1 file changed

+88
-34
lines changed

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

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,12 @@ export const GithubRunCommand = cmd({
387387
const isMock = args.token || args.event
388388

389389
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
390-
if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") {
390+
const isScheduleEvent = context.eventName === "schedule"
391+
if (
392+
context.eventName !== "issue_comment" &&
393+
context.eventName !== "pull_request_review_comment" &&
394+
context.eventName !== "schedule"
395+
) {
391396
core.setFailed(`Unsupported event type: ${context.eventName}`)
392397
process.exit(1)
393398
}
@@ -397,12 +402,16 @@ export const GithubRunCommand = cmd({
397402
const share = normalizeShare()
398403
const oidcBaseUrl = normalizeOidcBaseUrl()
399404
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"
405+
// For schedule events, payload has no issue/comment data
406+
const payload = isScheduleEvent
407+
? undefined
408+
: (context.payload as IssueCommentEvent | PullRequestReviewCommentEvent)
409+
const issueEvent = payload && isIssueCommentEvent(payload) ? payload : undefined
410+
const actor = isScheduleEvent ? undefined : context.actor
411+
412+
const issueId = isScheduleEvent
413+
? undefined
414+
: context.eventName === "pull_request_review_comment"
406415
? (payload as PullRequestReviewCommentEvent).pull_request.number
407416
: (payload as IssueCommentEvent).issue.number
408417
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
@@ -416,7 +425,7 @@ export const GithubRunCommand = cmd({
416425
let shareId: string | undefined
417426
let exitCode = 0
418427
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
419-
const triggerCommentId = payload.comment.id
428+
const triggerCommentId = payload?.comment.id
420429
const useGithubToken = normalizeUseGithubToken()
421430
const commentType = context.eventName === "pull_request_review_comment" ? "pr_review" : "issue"
422431

@@ -442,9 +451,11 @@ export const GithubRunCommand = cmd({
442451
if (!useGithubToken) {
443452
await configureGit(appToken)
444453
}
445-
await assertPermissions()
446-
447-
await addReaction(commentType)
454+
// Skip permission check for schedule events (no actor to check)
455+
if (!isScheduleEvent) {
456+
await assertPermissions()
457+
await addReaction(commentType)
458+
}
448459

449460
// Setup opencode session
450461
const repoData = await fetchRepo()
@@ -458,11 +469,31 @@ export const GithubRunCommand = cmd({
458469
})()
459470
console.log("opencode session", session.id)
460471

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) {
472+
// Handle 4 cases
473+
// 1. Schedule (no issue/PR context)
474+
// 2. Issue
475+
// 3. Local PR
476+
// 4. Fork PR
477+
if (isScheduleEvent) {
478+
// Schedule event - no issue/PR context, output goes to logs
479+
const branch = await checkoutNewBranch("schedule")
480+
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
481+
const response = await chat(userPrompt, promptFiles)
482+
const { dirty, uncommittedChanges } = await branchIsDirty(head)
483+
if (dirty) {
484+
const summary = await summarize(response)
485+
await pushToNewBranch(summary, branch, uncommittedChanges, true)
486+
const pr = await createPR(
487+
repoData.data.default_branch,
488+
branch,
489+
summary,
490+
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
491+
)
492+
console.log(`Created PR #${pr}`)
493+
} else {
494+
console.log("Response:", response)
495+
}
496+
} else if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
466497
const prData = await fetchPR()
467498
// Local PR
468499
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
@@ -497,15 +528,15 @@ export const GithubRunCommand = cmd({
497528
}
498529
// Issue
499530
else {
500-
const branch = await checkoutNewBranch()
531+
const branch = await checkoutNewBranch("issue")
501532
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
502533
const issueData = await fetchIssue()
503534
const dataPrompt = buildPromptDataForIssue(issueData)
504535
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
505536
const { dirty, uncommittedChanges } = await branchIsDirty(head)
506537
if (dirty) {
507538
const summary = await summarize(response)
508-
await pushToNewBranch(summary, branch, uncommittedChanges)
539+
await pushToNewBranch(summary, branch, uncommittedChanges, false)
509540
const pr = await createPR(
510541
repoData.data.default_branch,
511542
branch,
@@ -605,6 +636,14 @@ export const GithubRunCommand = cmd({
605636

606637
async function getUserPrompt() {
607638
const customPrompt = process.env["PROMPT"]
639+
// For schedule events, PROMPT is required since there's no comment to extract from
640+
if (isScheduleEvent) {
641+
if (!customPrompt) {
642+
throw new Error("PROMPT input is required for scheduled events")
643+
}
644+
return { userPrompt: customPrompt, promptFiles: [] }
645+
}
646+
608647
if (customPrompt) {
609648
return { userPrompt: customPrompt, promptFiles: [] }
610649
}
@@ -615,7 +654,7 @@ export const GithubRunCommand = cmd({
615654
.map((m) => m.trim().toLowerCase())
616655
.filter(Boolean)
617656
let prompt = (() => {
618-
const body = payload.comment.body.trim()
657+
const body = payload!.comment.body.trim()
619658
const bodyLower = body.toLowerCase()
620659
if (mentions.some((m) => bodyLower === m)) {
621660
if (reviewContext) {
@@ -865,9 +904,9 @@ export const GithubRunCommand = cmd({
865904
await $`git config --local ${config} "${gitConfig}"`
866905
}
867906

868-
async function checkoutNewBranch() {
907+
async function checkoutNewBranch(type: "issue" | "schedule") {
869908
console.log("Checking out new branch...")
870-
const branch = generateBranchName("issue")
909+
const branch = generateBranchName(type)
871910
await $`git checkout -b ${branch}`
872911
return branch
873912
}
@@ -894,23 +933,32 @@ export const GithubRunCommand = cmd({
894933
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
895934
}
896935

897-
function generateBranchName(type: "issue" | "pr") {
936+
function generateBranchName(type: "issue" | "pr" | "schedule") {
898937
const timestamp = new Date()
899938
.toISOString()
900939
.replace(/[:-]/g, "")
901940
.replace(/\.\d{3}Z/, "")
902941
.split("T")
903942
.join("")
943+
if (type === "schedule") {
944+
const hex = crypto.randomUUID().slice(0, 6)
945+
return `opencode/scheduled-${hex}-${timestamp}`
946+
}
904947
return `opencode/${type}${issueId}-${timestamp}`
905948
}
906949

907-
async function pushToNewBranch(summary: string, branch: string, commit: boolean) {
950+
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
908951
console.log("Pushing to new branch...")
909952
if (commit) {
910953
await $`git add .`
911-
await $`git commit -m "${summary}
954+
if (isSchedule) {
955+
// No co-author for scheduled events - the schedule is operating as the repo
956+
await $`git commit -m "${summary}"`
957+
} else {
958+
await $`git commit -m "${summary}
912959
913960
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
961+
}
914962
}
915963
await $`git push -u origin ${branch}`
916964
}
@@ -958,14 +1006,15 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
9581006
}
9591007

9601008
async function assertPermissions() {
1009+
// Only called for non-schedule events, so actor is defined
9611010
console.log(`Asserting permissions for user ${actor}...`)
9621011

9631012
let permission
9641013
try {
9651014
const response = await octoRest.repos.getCollaboratorPermissionLevel({
9661015
owner,
9671016
repo,
968-
username: actor,
1017+
username: actor!,
9691018
})
9701019

9711020
permission = response.data.permission
@@ -979,30 +1028,32 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
9791028
}
9801029

9811030
async function addReaction(commentType: "issue" | "pr_review") {
1031+
// Only called for non-schedule events, so triggerCommentId is defined
9821032
console.log("Adding reaction...")
9831033
if (commentType === "pr_review") {
9841034
return await octoRest.rest.reactions.createForPullRequestReviewComment({
9851035
owner,
9861036
repo,
987-
comment_id: triggerCommentId,
1037+
comment_id: triggerCommentId!,
9881038
content: AGENT_REACTION,
9891039
})
9901040
}
9911041
return await octoRest.rest.reactions.createForIssueComment({
9921042
owner,
9931043
repo,
994-
comment_id: triggerCommentId,
1044+
comment_id: triggerCommentId!,
9951045
content: AGENT_REACTION,
9961046
})
9971047
}
9981048

9991049
async function removeReaction(commentType: "issue" | "pr_review") {
1050+
// Only called for non-schedule events, so triggerCommentId is defined
10001051
console.log("Removing reaction...")
10011052
if (commentType === "pr_review") {
10021053
const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
10031054
owner,
10041055
repo,
1005-
comment_id: triggerCommentId,
1056+
comment_id: triggerCommentId!,
10061057
content: AGENT_REACTION,
10071058
})
10081059

@@ -1012,7 +1063,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10121063
await octoRest.rest.reactions.deleteForPullRequestComment({
10131064
owner,
10141065
repo,
1015-
comment_id: triggerCommentId,
1066+
comment_id: triggerCommentId!,
10161067
reaction_id: eyesReaction.id,
10171068
})
10181069
return
@@ -1021,7 +1072,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10211072
const reactions = await octoRest.rest.reactions.listForIssueComment({
10221073
owner,
10231074
repo,
1024-
comment_id: triggerCommentId,
1075+
comment_id: triggerCommentId!,
10251076
content: AGENT_REACTION,
10261077
})
10271078

@@ -1031,17 +1082,18 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10311082
await octoRest.rest.reactions.deleteForIssueComment({
10321083
owner,
10331084
repo,
1034-
comment_id: triggerCommentId,
1085+
comment_id: triggerCommentId!,
10351086
reaction_id: eyesReaction.id,
10361087
})
10371088
}
10381089

10391090
async function createComment(body: string) {
1091+
// Only called for non-schedule events, so issueId is defined
10401092
console.log("Creating comment...")
10411093
return await octoRest.rest.issues.createComment({
10421094
owner,
10431095
repo,
1044-
issue_number: issueId,
1096+
issue_number: issueId!,
10451097
body,
10461098
})
10471099
}
@@ -1119,10 +1171,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
11191171
}
11201172

11211173
function buildPromptDataForIssue(issue: GitHubIssue) {
1174+
// Only called for non-schedule events, so payload is defined
11221175
const comments = (issue.comments?.nodes || [])
11231176
.filter((c) => {
11241177
const id = parseInt(c.databaseId)
1125-
return id !== payload.comment.id
1178+
return id !== payload!.comment.id
11261179
})
11271180
.map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
11281181

@@ -1246,10 +1299,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
12461299
}
12471300

12481301
function buildPromptDataForPR(pr: GitHubPullRequest) {
1302+
// Only called for non-schedule events, so payload is defined
12491303
const comments = (pr.comments?.nodes || [])
12501304
.filter((c) => {
12511305
const id = parseInt(c.databaseId)
1252-
return id !== payload.comment.id
1306+
return id !== payload!.comment.id
12531307
})
12541308
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
12551309

0 commit comments

Comments
 (0)