Browse Source

github: support schedule events (#5810)

Matt Silverlock 1 month ago
parent
commit
10375263ef
3 changed files with 152 additions and 42 deletions
  1. 4 1
      github/index.ts
  2. 98 41
      packages/opencode/src/cli/cmd/github.ts
  3. 50 0
      packages/web/src/content/docs/github.mdx

+ 4 - 1
github/index.ts

@@ -574,10 +574,13 @@ async function subscribeSessionEvents() {
 }
 
 async function summarize(response: string) {
-  const payload = useContext().payload as IssueCommentEvent
   try {
     return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
   } catch (e) {
+    if (isScheduleEvent()) {
+      return "Scheduled task changes"
+    }
+    const payload = useContext().payload as IssueCommentEvent
     return `Fix issue: ${payload.issue.title}`
   }
 }

+ 98 - 41
packages/opencode/src/cli/cmd/github.ts

@@ -127,6 +127,7 @@ 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"] as const
 
 // Parses GitHub remote URLs in various formats:
 // - https://github.com/owner/repo.git
@@ -387,22 +388,27 @@ export const GithubRunCommand = cmd({
       const isMock = args.token || args.event
 
       const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
-      if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") {
+      if (!SUPPORTED_EVENTS.includes(context.eventName as (typeof SUPPORTED_EVENTS)[number])) {
         core.setFailed(`Unsupported event type: ${context.eventName}`)
         process.exit(1)
       }
+      const isScheduleEvent = context.eventName === "schedule"
 
       const { providerID, modelID } = normalizeModel()
       const runId = normalizeRunId()
       const share = normalizeShare()
       const oidcBaseUrl = normalizeOidcBaseUrl()
       const { owner, repo } = context.repo
-      const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
-      const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
-      const actor = context.actor
-
-      const issueId =
-        context.eventName === "pull_request_review_comment"
+      // For schedule events, payload has no issue/comment data
+      const payload = isScheduleEvent
+        ? undefined
+        : (context.payload as IssueCommentEvent | PullRequestReviewCommentEvent)
+      const issueEvent = payload && isIssueCommentEvent(payload) ? payload : undefined
+      const actor = isScheduleEvent ? undefined : context.actor
+
+      const issueId = isScheduleEvent
+        ? undefined
+        : context.eventName === "pull_request_review_comment"
           ? (payload as PullRequestReviewCommentEvent).pull_request.number
           : (payload as IssueCommentEvent).issue.number
       const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
@@ -416,9 +422,13 @@ export const GithubRunCommand = cmd({
       let shareId: string | undefined
       let exitCode = 0
       type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
-      const triggerCommentId = payload.comment.id
+      const triggerCommentId = payload?.comment.id
       const useGithubToken = normalizeUseGithubToken()
-      const commentType = context.eventName === "pull_request_review_comment" ? "pr_review" : "issue"
+      const commentType = isScheduleEvent
+        ? undefined
+        : context.eventName === "pull_request_review_comment"
+          ? "pr_review"
+          : "issue"
 
       try {
         if (useGithubToken) {
@@ -442,9 +452,11 @@ export const GithubRunCommand = cmd({
         if (!useGithubToken) {
           await configureGit(appToken)
         }
-        await assertPermissions()
-
-        await addReaction(commentType)
+        // Skip permission check for schedule events (no actor to check)
+        if (!isScheduleEvent) {
+          await assertPermissions()
+          await addReaction(commentType!)
+        }
 
         // Setup opencode session
         const repoData = await fetchRepo()
@@ -458,11 +470,31 @@ export const GithubRunCommand = cmd({
         })()
         console.log("opencode session", session.id)
 
-        // Handle 3 cases
-        // 1. Issue
-        // 2. Local PR
-        // 3. Fork PR
-        if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
+        // 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")
+          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 pr = await createPR(
+              repoData.data.default_branch,
+              branch,
+              summary,
+              `${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
+            )
+            console.log(`Created PR #${pr}`)
+          } else {
+            console.log("Response:", response)
+          }
+        } else if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
           const prData = await fetchPR()
           // Local PR
           if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
@@ -477,7 +509,7 @@ export const GithubRunCommand = cmd({
             }
             const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
             await createComment(`${response}${footer({ image: !hasShared })}`)
-            await removeReaction(commentType)
+            await removeReaction(commentType!)
           }
           // Fork PR
           else {
@@ -492,12 +524,12 @@ export const GithubRunCommand = cmd({
             }
             const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
             await createComment(`${response}${footer({ image: !hasShared })}`)
-            await removeReaction(commentType)
+            await removeReaction(commentType!)
           }
         }
         // Issue
         else {
-          const branch = await checkoutNewBranch()
+          const branch = await checkoutNewBranch("issue")
           const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
           const issueData = await fetchIssue()
           const dataPrompt = buildPromptDataForIssue(issueData)
@@ -505,7 +537,7 @@ export const GithubRunCommand = cmd({
           const { dirty, uncommittedChanges } = await branchIsDirty(head)
           if (dirty) {
             const summary = await summarize(response)
-            await pushToNewBranch(summary, branch, uncommittedChanges)
+            await pushToNewBranch(summary, branch, uncommittedChanges, false)
             const pr = await createPR(
               repoData.data.default_branch,
               branch,
@@ -513,10 +545,10 @@ export const GithubRunCommand = cmd({
               `${response}\n\nCloses #${issueId}${footer({ image: true })}`,
             )
             await createComment(`Created PR #${pr}${footer({ image: true })}`)
-            await removeReaction(commentType)
+            await removeReaction(commentType!)
           } else {
             await createComment(`${response}${footer({ image: true })}`)
-            await removeReaction(commentType)
+            await removeReaction(commentType!)
           }
         }
       } catch (e: any) {
@@ -528,8 +560,10 @@ export const GithubRunCommand = cmd({
         } else if (e instanceof Error) {
           msg = e.message
         }
-        await createComment(`${msg}${footer()}`)
-        await removeReaction(commentType)
+        if (!isScheduleEvent) {
+          await createComment(`${msg}${footer()}`)
+          await removeReaction(commentType!)
+        }
         core.setFailed(msg)
         // Also output the clean error message for the action to capture
         //core.setOutput("prepare_error", e.message);
@@ -605,6 +639,14 @@ 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) {
+          if (!customPrompt) {
+            throw new Error("PROMPT input is required for scheduled events")
+          }
+          return { userPrompt: customPrompt, promptFiles: [] }
+        }
+
         if (customPrompt) {
           return { userPrompt: customPrompt, promptFiles: [] }
         }
@@ -615,7 +657,7 @@ export const GithubRunCommand = cmd({
           .map((m) => m.trim().toLowerCase())
           .filter(Boolean)
         let prompt = (() => {
-          const body = payload.comment.body.trim()
+          const body = payload!.comment.body.trim()
           const bodyLower = body.toLowerCase()
           if (mentions.some((m) => bodyLower === m)) {
             if (reviewContext) {
@@ -865,9 +907,9 @@ export const GithubRunCommand = cmd({
         await $`git config --local ${config} "${gitConfig}"`
       }
 
-      async function checkoutNewBranch() {
+      async function checkoutNewBranch(type: "issue" | "schedule") {
         console.log("Checking out new branch...")
-        const branch = generateBranchName("issue")
+        const branch = generateBranchName(type)
         await $`git checkout -b ${branch}`
         return branch
       }
@@ -894,23 +936,32 @@ export const GithubRunCommand = cmd({
         await $`git checkout -b ${localBranch} fork/${remoteBranch}`
       }
 
-      function generateBranchName(type: "issue" | "pr") {
+      function generateBranchName(type: "issue" | "pr" | "schedule") {
         const timestamp = new Date()
           .toISOString()
           .replace(/[:-]/g, "")
           .replace(/\.\d{3}Z/, "")
           .split("T")
           .join("")
+        if (type === "schedule") {
+          const hex = crypto.randomUUID().slice(0, 6)
+          return `opencode/scheduled-${hex}-${timestamp}`
+        }
         return `opencode/${type}${issueId}-${timestamp}`
       }
 
-      async function pushToNewBranch(summary: string, branch: string, commit: boolean) {
+      async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
         console.log("Pushing to new branch...")
         if (commit) {
           await $`git add .`
-          await $`git commit -m "${summary}
+          if (isSchedule) {
+            // No co-author for scheduled events - the schedule is operating as the repo
+            await $`git commit -m "${summary}"`
+          } else {
+            await $`git commit -m "${summary}
 
 Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
+          }
         }
         await $`git push -u origin ${branch}`
       }
@@ -958,6 +1009,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
       }
 
       async function assertPermissions() {
+        // Only called for non-schedule events, so actor is defined
         console.log(`Asserting permissions for user ${actor}...`)
 
         let permission
@@ -965,7 +1017,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
           const response = await octoRest.repos.getCollaboratorPermissionLevel({
             owner,
             repo,
-            username: actor,
+            username: actor!,
           })
 
           permission = response.data.permission
@@ -979,30 +1031,32 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
       }
 
       async function addReaction(commentType: "issue" | "pr_review") {
+        // Only called for non-schedule events, so triggerCommentId is defined
         console.log("Adding reaction...")
         if (commentType === "pr_review") {
           return await octoRest.rest.reactions.createForPullRequestReviewComment({
             owner,
             repo,
-            comment_id: triggerCommentId,
+            comment_id: triggerCommentId!,
             content: AGENT_REACTION,
           })
         }
         return await octoRest.rest.reactions.createForIssueComment({
           owner,
           repo,
-          comment_id: triggerCommentId,
+          comment_id: triggerCommentId!,
           content: AGENT_REACTION,
         })
       }
 
       async function removeReaction(commentType: "issue" | "pr_review") {
+        // Only called for non-schedule events, so triggerCommentId is defined
         console.log("Removing reaction...")
         if (commentType === "pr_review") {
           const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
             owner,
             repo,
-            comment_id: triggerCommentId,
+            comment_id: triggerCommentId!,
             content: AGENT_REACTION,
           })
 
@@ -1012,7 +1066,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
           await octoRest.rest.reactions.deleteForPullRequestComment({
             owner,
             repo,
-            comment_id: triggerCommentId,
+            comment_id: triggerCommentId!,
             reaction_id: eyesReaction.id,
           })
           return
@@ -1021,7 +1075,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
         const reactions = await octoRest.rest.reactions.listForIssueComment({
           owner,
           repo,
-          comment_id: triggerCommentId,
+          comment_id: triggerCommentId!,
           content: AGENT_REACTION,
         })
 
@@ -1031,17 +1085,18 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
         await octoRest.rest.reactions.deleteForIssueComment({
           owner,
           repo,
-          comment_id: triggerCommentId,
+          comment_id: triggerCommentId!,
           reaction_id: eyesReaction.id,
         })
       }
 
       async function createComment(body: string) {
+        // Only called for non-schedule events, so issueId is defined
         console.log("Creating comment...")
         return await octoRest.rest.issues.createComment({
           owner,
           repo,
-          issue_number: issueId,
+          issue_number: issueId!,
           body,
         })
       }
@@ -1119,10 +1174,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
       }
 
       function buildPromptDataForIssue(issue: GitHubIssue) {
+        // Only called for non-schedule events, so payload is defined
         const comments = (issue.comments?.nodes || [])
           .filter((c) => {
             const id = parseInt(c.databaseId)
-            return id !== payload.comment.id
+            return id !== payload!.comment.id
           })
           .map((c) => `  - ${c.author.login} at ${c.createdAt}: ${c.body}`)
 
@@ -1246,10 +1302,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
       }
 
       function buildPromptDataForPR(pr: GitHubPullRequest) {
+        // Only called for non-schedule events, so payload is defined
         const comments = (pr.comments?.nodes || [])
           .filter((c) => {
             const id = parseInt(c.databaseId)
-            return id !== payload.comment.id
+            return id !== payload!.comment.id
           })
           .map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
 

+ 50 - 0
packages/web/src/content/docs/github.mdx

@@ -100,6 +100,56 @@ Or you can set it up manually.
 
 ---
 
+## Supported Events
+
+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. |
+
+### Schedule Example
+
+Run OpenCode on a schedule to perform automated tasks:
+
+```yaml title=".github/workflows/opencode-scheduled.yml"
+name: Scheduled OpenCode Task
+
+on:
+  schedule:
+    - cron: "0 9 * * 1" # Every Monday at 9am UTC
+
+jobs:
+  opencode:
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+      contents: write
+      pull-requests: write
+      issues: write
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Run OpenCode
+        uses: sst/opencode/github@latest
+        env:
+          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+        with:
+          model: anthropic/claude-sonnet-4-20250514
+          prompt: |
+            Review the codebase for any TODO comments and create a summary.
+            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.
+
+---
+
 ## Custom prompts
 
 Override the default prompt to customize OpenCode's behavior for your workflow.