Skip to content

fix(action): fetch PR head sha when event payload has none - #404

Merged
thypon merged 1 commit into
mainfrom
fix/schedule-head-sha
Sep 9, 2026
Merged

thypon merged 1 commit into
mainfrom
fix/schedule-head-sha

Conversation

@thypon

@thypon thypon commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Runs triggered by schedule / workflow_dispatch have no pull_request payload, so context.payload.pull_request.head.sha is undefined and the same-commit skip from #402 never engaged. Example: brave/uBlock sync-from-fork runs puLL-Merge hourly on PR #336 (mirror branch, unchanged head) with debounce_time: 0 — every run deleted and re-posted the review (run log: Deleting 1 message(s)).

Fix

  • Prefer context.payload.pull_request.head.sha when present (unchanged behavior).
  • Otherwise fetch head sha via GET /repos/{owner}/{repo}/pulls/{pull_number} using the already-known owner/repo/prnum.
  • Fetch failure is non-fatal (logged in debug, skip disabled) so schedule runs without a resolvable sha keep old behavior.

Test

BDD: 2 new scenarios in action.feature — schedule run skips when API head sha matches the reviewed marker; schedule run re-comments when it changed. 178/178 green locally and in the Ubuntu VM.

Runs triggered by schedule or workflow_dispatch have no
pull_request payload, so the same-commit skip never engaged
and every run deleted and re-posted the review (uBlock sync
fork runs hourly with debounce disabled). Fall back to
fetching the head sha from the pull request API.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

bedrock debug - [puLL-Merge] - brave/pull-merge@404

Diff
diff --git action.cjs action.cjs
index 16d9f01..dfb2537 100644
--- action.cjs
+++ action.cjs
@@ -92,9 +92,27 @@ module.exports = async ({ github, context, inputs, actionPath }) => {
       debug
     })
 
-    const headSha = context.payload.pull_request && context.payload.pull_request.head
+    // head commit sha: prefer the pull_request event payload, otherwise
+    // (schedule / workflow_dispatch runs) fetch it from the PR so the
+    // same-commit skip can still fire
+    const fetchHeadSha = async () => {
+      try {
+        if (!Number.isFinite(options.prnum)) return undefined
+        const prResponse = await github.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
+          owner: options.owner,
+          repo: options.repo,
+          pull_number: options.prnum
+        })
+        return prResponse.data.head.sha
+      } catch (error) {
+        if (debug) console.log(`failed to fetch PR head sha: ${error.message}`)
+        return undefined
+      }
+    }
+
+    const headSha = context.payload.pull_request && context.payload.pull_request.head && context.payload.pull_request.head.sha
       ? context.payload.pull_request.head.sha
-      : undefined
+      : await fetchHeadSha()
 
     const explainPatchCb = async () => await explainPatch({
       apiKey: options.key,
diff --git test/features/action.feature test/features/action.feature
index 5cc4289..bc4f37e 100644
--- test/features/action.feature
+++ test/features/action.feature
@@ -293,3 +293,26 @@ Feature: action orchestrator
     Then it resolves to undefined
     And a comment was created containing "<!-- Generated by gpt-5.3-codex @ deadbeef -->"
     And the puLL-Merge label was added
+
+  Scenario: a schedule run skips re-commenting when the API head commit was already reviewed
+    Given the action context has no pull request payload
+    And the PR head sha from the API is "deadbeef"
+    And the PR has these comments:
+      | id | author              | body | age_hours |
+      | C1 | github-actions[bot] | [[puLL-Merge](https://github.com/brave/pull-merge)] - [brave/pull-merge@42](https://github.com/brave/pull-merge/pull/42)<!-- Generated by gpt-5.3-codex @ deadbeef --> | 48 |
+    When the action runs
+    Then it resolves to undefined
+    And no comment was created
+    And no labels were added
+    And 0 chat completions were called
+
+  Scenario: a schedule run re-comments when the API head commit changed
+    Given the action context has no pull request payload
+    And the PR head sha from the API is "deadbeef"
+    And the PR has these comments:
+      | id | author              | body | age_hours |
+      | C1 | github-actions[bot] | [[puLL-Merge](https://github.com/brave/pull-merge)] - [brave/pull-merge@42](https://github.com/brave/pull-merge/pull/42)<!-- Generated by gpt-5.3-codex @ cafe1234 --> | 48 |
+    When the action runs
+    Then it resolves to undefined
+    And a comment was created containing "<!-- Generated by gpt-5.3-codex @ deadbeef -->"
+    And the puLL-Merge label was added
diff --git test/steps/action.mjs test/steps/action.mjs
index 976ea07..7dd5a6b 100644
--- test/steps/action.mjs
+++ test/steps/action.mjs
@@ -28,6 +28,13 @@ Given('the action context has PR head sha {string}', function (sha) {
   this.prHeadSha = sha
 })
 
+Given('the PR head sha from the API is {string}', function (sha) {
+  mockState().github.requestRoutes.push({
+    match: (route, opts) => route === PULLS_ROUTE && opts?.mediaType?.format !== 'diff',
+    reply: { data: { head: { sha } } }
+  })
+})
+
 Given('the action inputs:', function (doc) {
   this.actionInputs = JSON.parse(trimDoc(doc))
 })

Description

Enables same-commit skip logic for schedule/workflow_dispatch runs by fetching the PR head SHA from the GitHub API when the pull_request event payload is absent.

Possible Issues

  • fetchHeadSha makes an extra API call on every non-pull_request trigger even when the skip check will ultimately pass; no caching or short-circuit before the call.
  • context.payload.pull_request.head truthy check is redundant — if pull_request exists, head is always present in GitHub's payload; minor noise but harmless.
  • Error silently swallowed in fetchHeadSha; if rate-limited or permissions issue, skip check is silently bypassed and a duplicate comment may be created.
Changes

Changes

action.cjs

  • Replaces inline undefined fallback for headSha with async fetchHeadSha() that calls GET /repos/{owner}/{repo}/pulls/{pull_number} and returns prResponse.data.head.sha.
  • Adds Number.isFinite(options.prnum) guard and error catch returning undefined.
  • Adds .sha to the existing truthy-chain for the pull_request payload path (was missing .sha — bug fix).

test/features/action.feature

  • Adds scenario: schedule run skips re-commenting when API-returned head SHA matches existing comment SHA.
  • Adds scenario: schedule run creates comment when API-returned head SHA differs from existing comment SHA.

test/steps/action.mjs

  • Adds Given('the PR head sha from the API is {string}') step that pushes a mock route matching PULLS_ROUTE (non-diff) returning { data: { head: { sha } } }.
sequenceDiagram
    participant Trigger as schedule/workflow_dispatch
    participant Action as action.cjs
    participant GH_API as GitHub API

    Trigger->>Action: run (no pull_request payload)
    Action->>Action: context.payload.pull_request falsy
    Action->>GH_API: GET /repos/{owner}/{repo}/pulls/{prnum}
    GH_API-->>Action: { head: { sha: "deadbeef" } }
    Action->>Action: headSha = "deadbeef"
    Action->>Action: check existing comments for sha match
    alt sha already reviewed
        Action-->>Trigger: resolve undefined (skip)
    else sha changed
        Action->>GH_API: create comment with new sha
        Action->>GH_API: add puLL-Merge label
        Action-->>Trigger: resolve undefined
    end
Loading

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

anthropic debug - [puLL-Merge] - brave/pull-merge@404

Diff
diff --git action.cjs action.cjs
index 16d9f01..dfb2537 100644
--- action.cjs
+++ action.cjs
@@ -92,9 +92,27 @@ module.exports = async ({ github, context, inputs, actionPath }) => {
       debug
     })
 
-    const headSha = context.payload.pull_request && context.payload.pull_request.head
+    // head commit sha: prefer the pull_request event payload, otherwise
+    // (schedule / workflow_dispatch runs) fetch it from the PR so the
+    // same-commit skip can still fire
+    const fetchHeadSha = async () => {
+      try {
+        if (!Number.isFinite(options.prnum)) return undefined
+        const prResponse = await github.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
+          owner: options.owner,
+          repo: options.repo,
+          pull_number: options.prnum
+        })
+        return prResponse.data.head.sha
+      } catch (error) {
+        if (debug) console.log(`failed to fetch PR head sha: ${error.message}`)
+        return undefined
+      }
+    }
+
+    const headSha = context.payload.pull_request && context.payload.pull_request.head && context.payload.pull_request.head.sha
       ? context.payload.pull_request.head.sha
-      : undefined
+      : await fetchHeadSha()
 
     const explainPatchCb = async () => await explainPatch({
       apiKey: options.key,
diff --git test/features/action.feature test/features/action.feature
index 5cc4289..bc4f37e 100644
--- test/features/action.feature
+++ test/features/action.feature
@@ -293,3 +293,26 @@ Feature: action orchestrator
     Then it resolves to undefined
     And a comment was created containing "<!-- Generated by gpt-5.3-codex @ deadbeef -->"
     And the puLL-Merge label was added
+
+  Scenario: a schedule run skips re-commenting when the API head commit was already reviewed
+    Given the action context has no pull request payload
+    And the PR head sha from the API is "deadbeef"
+    And the PR has these comments:
+      | id | author              | body | age_hours |
+      | C1 | github-actions[bot] | [[puLL-Merge](https://github.com/brave/pull-merge)] - [brave/pull-merge@42](https://github.com/brave/pull-merge/pull/42)<!-- Generated by gpt-5.3-codex @ deadbeef --> | 48 |
+    When the action runs
+    Then it resolves to undefined
+    And no comment was created
+    And no labels were added
+    And 0 chat completions were called
+
+  Scenario: a schedule run re-comments when the API head commit changed
+    Given the action context has no pull request payload
+    And the PR head sha from the API is "deadbeef"
+    And the PR has these comments:
+      | id | author              | body | age_hours |
+      | C1 | github-actions[bot] | [[puLL-Merge](https://github.com/brave/pull-merge)] - [brave/pull-merge@42](https://github.com/brave/pull-merge/pull/42)<!-- Generated by gpt-5.3-codex @ cafe1234 --> | 48 |
+    When the action runs
+    Then it resolves to undefined
+    And a comment was created containing "<!-- Generated by gpt-5.3-codex @ deadbeef -->"
+    And the puLL-Merge label was added
diff --git test/steps/action.mjs test/steps/action.mjs
index 976ea07..7dd5a6b 100644
--- test/steps/action.mjs
+++ test/steps/action.mjs
@@ -28,6 +28,13 @@ Given('the action context has PR head sha {string}', function (sha) {
   this.prHeadSha = sha
 })
 
+Given('the PR head sha from the API is {string}', function (sha) {
+  mockState().github.requestRoutes.push({
+    match: (route, opts) => route === PULLS_ROUTE && opts?.mediaType?.format !== 'diff',
+    reply: { data: { head: { sha } } }
+  })
+})
+
 Given('the action inputs:', function (doc) {
   this.actionInputs = JSON.parse(trimDoc(doc))
 })

Description

Adds fallback head-SHA resolution in action.cjs: when the event payload lacks pull_request.head.sha (schedule / workflow_dispatch), fetch it via GET /repos/{owner}/{repo}/pulls/{pull_number} so the "already reviewed this commit" skip works outside PR events. Adds Cucumber scenarios + step for mocking the API head sha.

Possible Issues

  • Number.isFinite(options.prnum) — if prnum comes from action inputs as a string ("42"), check fails and fetch silently returns undefined, disabling the skip. Verify normalization; otherwise use Number(options.prnum).
  • Redundant API call: the PR is already fetched for the diff (same route with mediaType.format === 'diff', per the test matcher). Reuse that response's head sha or the diff fetch's ETag rather than an extra request per run.
  • Fetch happens unconditionally on non-PR events even when no prior bot comment exists (skip can't fire). Lazily resolve only when a marker comment is found.
  • Failure is swallowed → undefined → duplicate comment + label + LLM spend. Consider bailing out (or retrying) instead of proceeding blind on transient 5xx/rate-limit.
  • Race: head sha fetched after comment scan/diff; a push between diff fetch and sha fetch stamps a comment with a sha that doesn't match the reviewed diff, suppressing the next legitimate run.
Changes

Changes

action.cjs

  • New fetchHeadSha() helper; guards on finite prnum, catches and logs under debug.
  • headSha now also requires payload.pull_request.head.sha before use, else awaits fetchHeadSha().

test/features/action.feature

  • Two scenarios: schedule run skips on matching API head sha; re-comments when sha differs.

test/steps/action.mjs

  • Given the PR head sha from the API is {string} pushes a mock route on PULLS_ROUTE excluding mediaType.format === 'diff'.
sequenceDiagram
    participant WF as Workflow (schedule)
    participant A as action.cjs
    participant GH as GitHub API
    participant LLM as LLM

    WF->>A: run(github, context, inputs)
    A->>GH: list comments
    GH-->>A: existing bot comment (sha marker)
    alt payload.pull_request.head.sha present
        A->>A: headSha = payload sha
    else missing (schedule/dispatch)
        A->>GH: GET /repos/{owner}/{repo}/pulls/{pull_number}
        GH-->>A: data.head.sha (or error -> undefined)
    end
    alt marker sha === headSha
        A-->>WF: undefined (skip, no LLM call)
    else differs or undefined
        A->>LLM: explainPatch(diff)
        LLM-->>A: explanation
        A->>GH: create comment w/ marker @ headSha
        A->>GH: add puLL-Merge label
        A-->>WF: undefined
    end
Loading

@thypon
thypon merged commit 38c2008 into main Sep 9, 2026
7 checks passed
@thypon
thypon deleted the fix/schedule-head-sha branch September 9, 2026 04:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants