Skip to content

Commit 1037526

Browse files
authored
github: support schedule events (anomalyco#5810)
1 parent ae00001 commit 1037526

File tree

3 files changed

+152
-42
lines changed

3 files changed

+152
-42
lines changed

github/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,10 +574,13 @@ async function subscribeSessionEvents() {
574574
}
575575

576576
async function summarize(response: string) {
577-
const payload = useContext().payload as IssueCommentEvent
578577
try {
579578
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
580579
} catch (e) {
580+
if (isScheduleEvent()) {
581+
return "Scheduled task changes"
582+
}
583+
const payload = useContext().payload as IssueCommentEvent
581584
return `Fix issue: ${payload.issue.title}`
582585
}
583586
}

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

Lines changed: 98 additions & 41 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,9 +422,13 @@ 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()
421-
const commentType = context.eventName === "pull_request_review_comment" ? "pr_review" : "issue"
427+
const commentType = isScheduleEvent
428+
? undefined
429+
: context.eventName === "pull_request_review_comment"
430+
? "pr_review"
431+
: "issue"
422432

423433
try {
424434
if (useGithubToken) {
@@ -442,9 +452,11 @@ export const GithubRunCommand = cmd({
442452
if (!useGithubToken) {
443453
await configureGit(appToken)
444454
}
445-
await assertPermissions()
446-
447-
await addReaction(commentType)
455+
// Skip permission check for schedule events (no actor to check)
456+
if (!isScheduleEvent) {
457+
await assertPermissions()
458+
await addReaction(commentType!)
459+
}
448460

449461
// Setup opencode session
450462
const repoData = await fetchRepo()
@@ -458,11 +470,31 @@ export const GithubRunCommand = cmd({
458470
})()
459471
console.log("opencode session", session.id)
460472

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) {
473+
// Handle 4 cases
474+
// 1. Schedule (no issue/PR context)
475+
// 2. Issue
476+
// 3. Local PR
477+
// 4. Fork PR
478+
if (isScheduleEvent) {
479+
// Schedule event - no issue/PR context, output goes to logs
480+
const branch = await checkoutNewBranch("schedule")
481+
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
482+
const response = await chat(userPrompt, promptFiles)
483+
const { dirty, uncommittedChanges } = await branchIsDirty(head)
484+
if (dirty) {
485+
const summary = await summarize(response)
486+
await pushToNewBranch(summary, branch, uncommittedChanges, true)
487+
const pr = await createPR(
488+
repoData.data.default_branch,
489+
branch,
490+
summary,
491+
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
492+
)
493+
console.log(`Created PR #${pr}`)
494+
} else {
495+
console.log("Response:", response)
496+
}
497+
} else if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
466498
const prData = await fetchPR()
467499
// Local PR
468500
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
@@ -477,7 +509,7 @@ export const GithubRunCommand = cmd({
477509
}
478510
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
479511
await createComment(`${response}${footer({ image: !hasShared })}`)
480-
await removeReaction(commentType)
512+
await removeReaction(commentType!)
481513
}
482514
// Fork PR
483515
else {
@@ -492,31 +524,31 @@ export const GithubRunCommand = cmd({
492524
}
493525
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
494526
await createComment(`${response}${footer({ image: !hasShared })}`)
495-
await removeReaction(commentType)
527+
await removeReaction(commentType!)
496528
}
497529
}
498530
// Issue
499531
else {
500-
const branch = await checkoutNewBranch()
532+
const branch = await checkoutNewBranch("issue")
501533
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
502534
const issueData = await fetchIssue()
503535
const dataPrompt = buildPromptDataForIssue(issueData)
504536
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
505537
const { dirty, uncommittedChanges } = await branchIsDirty(head)
506538
if (dirty) {
507539
const summary = await summarize(response)
508-
await pushToNewBranch(summary, branch, uncommittedChanges)
540+
await pushToNewBranch(summary, branch, uncommittedChanges, false)
509541
const pr = await createPR(
510542
repoData.data.default_branch,
511543
branch,
512544
summary,
513545
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
514546
)
515547
await createComment(`Created PR #${pr}${footer({ image: true })}`)
516-
await removeReaction(commentType)
548+
await removeReaction(commentType!)
517549
} else {
518550
await createComment(`${response}${footer({ image: true })}`)
519-
await removeReaction(commentType)
551+
await removeReaction(commentType!)
520552
}
521553
}
522554
} catch (e: any) {
@@ -528,8 +560,10 @@ export const GithubRunCommand = cmd({
528560
} else if (e instanceof Error) {
529561
msg = e.message
530562
}
531-
await createComment(`${msg}${footer()}`)
532-
await removeReaction(commentType)
563+
if (!isScheduleEvent) {
564+
await createComment(`${msg}${footer()}`)
565+
await removeReaction(commentType!)
566+
}
533567
core.setFailed(msg)
534568
// Also output the clean error message for the action to capture
535569
//core.setOutput("prepare_error", e.message);
@@ -605,6 +639,14 @@ export const GithubRunCommand = cmd({
605639

606640
async function getUserPrompt() {
607641
const customPrompt = process.env["PROMPT"]
642+
// For schedule events, PROMPT is required since there's no comment to extract from
643+
if (isScheduleEvent) {
644+
if (!customPrompt) {
645+
throw new Error("PROMPT input is required for scheduled events")
646+
}
647+
return { userPrompt: customPrompt, promptFiles: [] }
648+
}
649+
608650
if (customPrompt) {
609651
return { userPrompt: customPrompt, promptFiles: [] }
610652
}
@@ -615,7 +657,7 @@ export const GithubRunCommand = cmd({
615657
.map((m) => m.trim().toLowerCase())
616658
.filter(Boolean)
617659
let prompt = (() => {
618-
const body = payload.comment.body.trim()
660+
const body = payload!.comment.body.trim()
619661
const bodyLower = body.toLowerCase()
620662
if (mentions.some((m) => bodyLower === m)) {
621663
if (reviewContext) {
@@ -865,9 +907,9 @@ export const GithubRunCommand = cmd({
865907
await $`git config --local ${config} "${gitConfig}"`
866908
}
867909

868-
async function checkoutNewBranch() {
910+
async function checkoutNewBranch(type: "issue" | "schedule") {
869911
console.log("Checking out new branch...")
870-
const branch = generateBranchName("issue")
912+
const branch = generateBranchName(type)
871913
await $`git checkout -b ${branch}`
872914
return branch
873915
}
@@ -894,23 +936,32 @@ export const GithubRunCommand = cmd({
894936
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
895937
}
896938

897-
function generateBranchName(type: "issue" | "pr") {
939+
function generateBranchName(type: "issue" | "pr" | "schedule") {
898940
const timestamp = new Date()
899941
.toISOString()
900942
.replace(/[:-]/g, "")
901943
.replace(/\.\d{3}Z/, "")
902944
.split("T")
903945
.join("")
946+
if (type === "schedule") {
947+
const hex = crypto.randomUUID().slice(0, 6)
948+
return `opencode/scheduled-${hex}-${timestamp}`
949+
}
904950
return `opencode/${type}${issueId}-${timestamp}`
905951
}
906952

907-
async function pushToNewBranch(summary: string, branch: string, commit: boolean) {
953+
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
908954
console.log("Pushing to new branch...")
909955
if (commit) {
910956
await $`git add .`
911-
await $`git commit -m "${summary}
957+
if (isSchedule) {
958+
// No co-author for scheduled events - the schedule is operating as the repo
959+
await $`git commit -m "${summary}"`
960+
} else {
961+
await $`git commit -m "${summary}
912962
913963
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
964+
}
914965
}
915966
await $`git push -u origin ${branch}`
916967
}
@@ -958,14 +1009,15 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
9581009
}
9591010

9601011
async function assertPermissions() {
1012+
// Only called for non-schedule events, so actor is defined
9611013
console.log(`Asserting permissions for user ${actor}...`)
9621014

9631015
let permission
9641016
try {
9651017
const response = await octoRest.repos.getCollaboratorPermissionLevel({
9661018
owner,
9671019
repo,
968-
username: actor,
1020+
username: actor!,
9691021
})
9701022

9711023
permission = response.data.permission
@@ -979,30 +1031,32 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
9791031
}
9801032

9811033
async function addReaction(commentType: "issue" | "pr_review") {
1034+
// Only called for non-schedule events, so triggerCommentId is defined
9821035
console.log("Adding reaction...")
9831036
if (commentType === "pr_review") {
9841037
return await octoRest.rest.reactions.createForPullRequestReviewComment({
9851038
owner,
9861039
repo,
987-
comment_id: triggerCommentId,
1040+
comment_id: triggerCommentId!,
9881041
content: AGENT_REACTION,
9891042
})
9901043
}
9911044
return await octoRest.rest.reactions.createForIssueComment({
9921045
owner,
9931046
repo,
994-
comment_id: triggerCommentId,
1047+
comment_id: triggerCommentId!,
9951048
content: AGENT_REACTION,
9961049
})
9971050
}
9981051

9991052
async function removeReaction(commentType: "issue" | "pr_review") {
1053+
// Only called for non-schedule events, so triggerCommentId is defined
10001054
console.log("Removing reaction...")
10011055
if (commentType === "pr_review") {
10021056
const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
10031057
owner,
10041058
repo,
1005-
comment_id: triggerCommentId,
1059+
comment_id: triggerCommentId!,
10061060
content: AGENT_REACTION,
10071061
})
10081062

@@ -1012,7 +1066,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10121066
await octoRest.rest.reactions.deleteForPullRequestComment({
10131067
owner,
10141068
repo,
1015-
comment_id: triggerCommentId,
1069+
comment_id: triggerCommentId!,
10161070
reaction_id: eyesReaction.id,
10171071
})
10181072
return
@@ -1021,7 +1075,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10211075
const reactions = await octoRest.rest.reactions.listForIssueComment({
10221076
owner,
10231077
repo,
1024-
comment_id: triggerCommentId,
1078+
comment_id: triggerCommentId!,
10251079
content: AGENT_REACTION,
10261080
})
10271081

@@ -1031,17 +1085,18 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
10311085
await octoRest.rest.reactions.deleteForIssueComment({
10321086
owner,
10331087
repo,
1034-
comment_id: triggerCommentId,
1088+
comment_id: triggerCommentId!,
10351089
reaction_id: eyesReaction.id,
10361090
})
10371091
}
10381092

10391093
async function createComment(body: string) {
1094+
// Only called for non-schedule events, so issueId is defined
10401095
console.log("Creating comment...")
10411096
return await octoRest.rest.issues.createComment({
10421097
owner,
10431098
repo,
1044-
issue_number: issueId,
1099+
issue_number: issueId!,
10451100
body,
10461101
})
10471102
}
@@ -1119,10 +1174,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
11191174
}
11201175

11211176
function buildPromptDataForIssue(issue: GitHubIssue) {
1177+
// Only called for non-schedule events, so payload is defined
11221178
const comments = (issue.comments?.nodes || [])
11231179
.filter((c) => {
11241180
const id = parseInt(c.databaseId)
1125-
return id !== payload.comment.id
1181+
return id !== payload!.comment.id
11261182
})
11271183
.map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
11281184

@@ -1246,10 +1302,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
12461302
}
12471303

12481304
function buildPromptDataForPR(pr: GitHubPullRequest) {
1305+
// Only called for non-schedule events, so payload is defined
12491306
const comments = (pr.comments?.nodes || [])
12501307
.filter((c) => {
12511308
const id = parseInt(c.databaseId)
1252-
return id !== payload.comment.id
1309+
return id !== payload!.comment.id
12531310
})
12541311
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
12551312

0 commit comments

Comments
 (0)