Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions .github/workflows/reusable-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -396,29 +396,47 @@ jobs:
fi
fi

- name: Block fork PRs for fix stage
if: steps.route.outputs.stage == 'fix' && steps.role-check.outputs.skipped != 'true' && github.event.issue.pull_request
- name: Resolve PR head for issue_comment events
id: pr-head
if: steps.route.outputs.stage != '' && steps.role-check.outputs.skipped != 'true' && steps.pr-check.outputs.skipped != 'true' && github.event_name == 'issue_comment' && github.event.issue.pull_request
env:
GH_TOKEN: ${{ github.token }}
SOURCE_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
STAGE: ${{ steps.route.outputs.stage }}
run: |
set -euo pipefail
Comment thread
ggallen marked this conversation as resolved.
REPOS=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER" \
--jq '[.head.repo.full_name, .base.repo.full_name] | @tsv' 2>/dev/null) || {
echo "::error::Could not determine PR repos — blocking fix for safety"
exit 1
PR_JSON=$(gh api "repos/${SOURCE_REPO}/pulls/${PR_NUMBER}" \
--jq '{number, html_url,
head: {ref: .head.ref, sha: .head.sha, repo: {full_name: .head.repo.full_name}},
base: {ref: .base.ref, repo: {full_name: .base.repo.full_name}}}') || {
if [[ "${STAGE}" =~ ^(fix|review)$ ]]; then
echo "::error::Failed to fetch PR #${PR_NUMBER} head info"
exit 1
Comment thread
ggallen marked this conversation as resolved.
fi
echo "::warning::Failed to fetch PR #${PR_NUMBER} head info — continuing without PR context"
exit 0
}
HEAD_REPO=$(printf '%s' "$REPOS" | cut -f1)
BASE_REPO=$(printf '%s' "$REPOS" | cut -f2)
if [[ "$HEAD_REPO" != "$BASE_REPO" ]]; then
echo "::error::Fork PR detected (head=$HEAD_REPO, base=$BASE_REPO) — fix agent blocked"
exit 1
if [[ "${STAGE}" == "fix" ]]; then
HEAD_REPO=$(printf '%s' "${PR_JSON}" | jq -r '.head.repo.full_name')
BASE_REPO=$(printf '%s' "${PR_JSON}" | jq -r '.base.repo.full_name')
if [[ "${HEAD_REPO}" != "${BASE_REPO}" ]]; then
echo "::error::Fork PR detected (head=${HEAD_REPO}, base=${BASE_REPO}) — fix agent blocked"
exit 1
fi
fi
DELIM="PR_$(openssl rand -hex 8)"
{
echo "pr_json<<${DELIM}"
echo "${PR_JSON}"
echo "${DELIM}"
} >> "${GITHUB_OUTPUT}"

- name: Build event payload
id: payload
if: steps.route.outputs.stage != ''
env:
PR_HEAD_JSON: ${{ steps.pr-head.outputs.pr_json }}
run: |
set -euo pipefail
EVENT_PAYLOAD=$(jq -c '{
Expand All @@ -431,6 +449,13 @@ jobs:
echo "::error::Failed to extract event payload from GITHUB_EVENT_PATH"
exit 1
}

# For issue_comment events on PRs, the raw event has no top-level
# pull_request with head SHA. Merge the API-fetched PR info.
if [[ -n "${PR_HEAD_JSON}" ]]; then
EVENT_PAYLOAD=$(printf '%s' "${EVENT_PAYLOAD}" | jq -c --argjson pr "${PR_HEAD_JSON}" '.pull_request = $pr')
fi

if [[ -z "${EVENT_PAYLOAD}" || "${EVENT_PAYLOAD}" == "null" ]]; then
echo "::error::Event payload is empty after extraction"
exit 1
Expand Down Expand Up @@ -533,6 +558,7 @@ jobs:
status-repo: ${{ github.repository }}
status-number: ${{ fromJSON(needs.route.outputs.event_payload).issue.number }}
mint-url: ${{ inputs.mint_url }}
pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }}
Comment thread
waynesun09 marked this conversation as resolved.

code:
name: Code
Expand Down Expand Up @@ -657,6 +683,7 @@ jobs:
status-repo: ${{ github.repository }}
status-number: ${{ fromJSON(needs.route.outputs.event_payload).issue.number }}
mint-url: ${{ inputs.mint_url }}
pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }}

review:
name: Review
Expand Down Expand Up @@ -765,6 +792,7 @@ jobs:
status-repo: ${{ github.repository }}
status-number: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.number || fromJSON(needs.route.outputs.event_payload).issue.number }}
mint-url: ${{ inputs.mint_url }}
pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }}

fix:
name: Fix
Expand Down Expand Up @@ -1054,6 +1082,7 @@ jobs:
status-repo: ${{ github.repository }}
status-number: ${{ steps.context.outputs.pr_number }}
mint-url: ${{ inputs.mint_url }}
pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }}

retro:
name: Retro
Expand Down Expand Up @@ -1145,6 +1174,7 @@ jobs:
status-repo: ${{ github.repository }}
status-number: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.number || fromJSON(needs.route.outputs.event_payload).issue.number }}
mint-url: ${{ inputs.mint_url }}
pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }}

prioritize:
name: Prioritize
Expand Down
33 changes: 32 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ inputs:
status comment tokens and agent runtime tokens (GH_TOKEN,
PUSH_TOKEN, REVIEW_TOKEN) minted by mintAgentToken().
default: ""
pr-head-sha:
description: >-
PR head commit SHA, passed explicitly by per-repo (workflow_call)
callers where GITHUB_EVENT_PATH lacks the dispatched event_payload
wrapper. Takes precedence over GITHUB_EVENT_PATH extraction.
default: ""

runs:
using: composite
Expand Down Expand Up @@ -309,6 +315,7 @@ runs:
STATUS_REPO: ${{ inputs.status-repo }}
STATUS_NUMBER: ${{ inputs.status-number }}
MINT_URL: ${{ inputs.mint-url }}
PR_HEAD_SHA: ${{ inputs.pr-head-sha }}
run: |
set -euo pipefail
FULLSEND_DIR="${FULLSEND_DIR:-${GITHUB_WORKSPACE}}"
Expand Down Expand Up @@ -346,14 +353,38 @@ runs:
STATUS_NUMBER: ${{ inputs.status-number }}
RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ inputs.run-url }}
COMMIT_SHA: ${{ github.sha }}
JOB_STATUS: ${{ job.status }}
PR_HEAD_SHA_INPUT: ${{ inputs.pr-head-sha }}
run: |
set -euo pipefail
# When the fullsend process is hard-killed (SIGKILL, OOM, segfault),
# the deferred PostCompletion call never runs and the status comment
# remains in "Started" state. This step runs unconditionally (if:
# always()) to detect and finalize orphaned comments. See #2149.

# Resolve the correct commit SHA. Priority order:
# 1. Explicit pr-head-sha input (per-repo workflow_call callers)
# 2. Extracted from .inputs.event_payload (per-org workflow_dispatch)
# 3. GITHUB_SHA fallback
COMMIT_SHA=""
if [[ -n "${PR_HEAD_SHA_INPUT}" ]]; then
COMMIT_SHA="${PR_HEAD_SHA_INPUT}"
elif [[ -f "${GITHUB_EVENT_PATH}" ]]; then
EVENT_PAYLOAD_RAW=$(jq -r '.inputs.event_payload // empty' "${GITHUB_EVENT_PATH}" 2>/dev/null) || true
if [[ -n "${EVENT_PAYLOAD_RAW}" ]]; then
HAS_PR=$(printf '%s' "${EVENT_PAYLOAD_RAW}" | jq -r '.pull_request // empty' 2>/dev/null) || true
if [[ -n "${HAS_PR}" && "${HAS_PR}" != "null" ]]; then
COMMIT_SHA=$(printf '%s' "${EVENT_PAYLOAD_RAW}" | jq -r '.pull_request.head.sha // empty' 2>/dev/null) || true
if [[ -z "${COMMIT_SHA}" ]]; then
echo "::warning::event_payload has pull_request but head.sha extraction failed"
fi
fi
fi
fi
if [[ -z "${COMMIT_SHA}" ]]; then
COMMIT_SHA="${GITHUB_SHA}"
Comment thread
ggallen marked this conversation as resolved.
fi

RECONCILE_FLAGS=(--repo "${STATUS_REPO}" --number "${STATUS_NUMBER}" --run-id "${RUN_ID}")
RECONCILE_FLAGS+=(--mint-url "${MINT_URL}" --role "${AGENT}")
if [[ -n "${RUN_URL}" ]]; then
Expand Down
10 changes: 6 additions & 4 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2664,10 +2664,12 @@ func setupStatusNotifier(fullsendDir string, role string, sOpts statusOpts, prin
}

sha := os.Getenv("GITHUB_SHA")
// In cross-repo workflow_dispatch mode, GITHUB_SHA is the dispatching
// repo's default branch HEAD — not the PR's head commit. Prefer the
// PR head SHA from the event payload when available. See #2045.
if prSHA := prHeadSHAFromEventPath(os.Getenv("GITHUB_EVENT_PATH")); prSHA != "" {
// Prefer explicit PR_HEAD_SHA (set by per-repo workflow_call callers
// where GITHUB_EVENT_PATH lacks the dispatched event_payload wrapper).
// Fall back to extracting from event payload (per-org workflow_dispatch).
if prSHA := os.Getenv("PR_HEAD_SHA"); prSHA != "" {
sha = prSHA
} else if prSHA := prHeadSHAFromEventPath(os.Getenv("GITHUB_EVENT_PATH")); prSHA != "" {
sha = prSHA
}
runID := os.Getenv("GITHUB_RUN_ID")
Expand Down
31 changes: 31 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2212,6 +2212,37 @@ func TestPRHeadSHAFromEventPath_MissingFile(t *testing.T) {
assert.Empty(t, got)
}

func TestPRHeadSHAFromEventPath_NullPullRequest(t *testing.T) {
Comment thread
ggallen marked this conversation as resolved.
// issue_comment events dispatch with "pull_request": null when the
// dispatch layer hasn't resolved the PR head. The function should
// return empty (not panic) so the caller can fall back.
eventJSON := `{
"inputs": {
"event_payload": "{\"issue\":{\"number\":4100},\"pull_request\":null,\"comment\":{\"body\":\"/fs-review\"}}"
}
}`
f := filepath.Join(t.TempDir(), "event.json")
require.NoError(t, os.WriteFile(f, []byte(eventJSON), 0o644))

got := prHeadSHAFromEventPath(f)
assert.Empty(t, got)
}

func TestPRHeadSHAFromEventPath_IssueCommentWithResolvedPR(t *testing.T) {
// After the dispatch fix, issue_comment events include a resolved
// pull_request object with the head SHA from the API.
eventJSON := `{
"inputs": {
"event_payload": "{\"issue\":{\"number\":4100},\"pull_request\":{\"number\":4100,\"head\":{\"ref\":\"pr-4099\",\"sha\":\"03e61fc492fa89592dc4cd0f429ee926154ee8e5\",\"repo\":{\"full_name\":\"org/repo\"}}},\"comment\":{\"body\":\"/fs-review\"}}"
}
}`
f := filepath.Join(t.TempDir(), "event.json")
require.NoError(t, os.WriteFile(f, []byte(eventJSON), 0o644))

got := prHeadSHAFromEventPath(f)
assert.Equal(t, "03e61fc492fa89592dc4cd0f429ee926154ee8e5", got)
}

func TestPRHeadSHAFromEventPath_NoInputs(t *testing.T) {
// Direct event (not workflow_dispatch) — no inputs field.
eventJSON := `{"action": "opened", "pull_request": {"number": 1}}`
Expand Down
47 changes: 35 additions & 12 deletions internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
# lint-workflow-size: max-lines=475
# lint-workflow-size: max-lines=500
# Dispatcher workflow that routes events to agent workflows based on stage.
# Routing logic determines the stage from event context — the shim only
# forwards the raw event. Adding a new stage requires only a case branch
Expand Down Expand Up @@ -366,25 +366,41 @@ jobs:
fi
fi

- name: Block fork PRs for fix stage
if: steps.route.outputs.stage == 'fix' && steps.role-check.outputs.skipped != 'true' && github.event.issue.pull_request
- name: Resolve PR head for issue_comment events
id: pr-head
if: steps.route.outputs.stage != '' && steps.role-check.outputs.skipped != 'true' && steps.pr-check.outputs.skipped != 'true' && github.event_name == 'issue_comment' && github.event.issue.pull_request
env:
GH_TOKEN: ${{ github.token }}
SOURCE_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
STAGE: ${{ steps.route.outputs.stage }}
run: |
set -euo pipefail
REPOS=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER" \
--jq '[.head.repo.full_name, .base.repo.full_name] | @tsv' 2>/dev/null) || {
echo "::error::Could not determine PR repos — blocking fix for safety"
exit 1
PR_JSON=$(gh api "repos/${SOURCE_REPO}/pulls/${PR_NUMBER}" \
--jq '{number, html_url,
head: {ref: .head.ref, sha: .head.sha, repo: {full_name: .head.repo.full_name}},
base: {ref: .base.ref, repo: {full_name: .base.repo.full_name}}}') || {
if [[ "${STAGE}" =~ ^(fix|review)$ ]]; then
echo "::error::Failed to fetch PR #${PR_NUMBER} head info"
exit 1
fi
echo "::warning::Failed to fetch PR #${PR_NUMBER} head info — continuing without PR context"
exit 0
}
HEAD_REPO=$(printf '%s' "$REPOS" | cut -f1)
BASE_REPO=$(printf '%s' "$REPOS" | cut -f2)
if [[ "$HEAD_REPO" != "$BASE_REPO" ]]; then
echo "::error::Fork PR detected (head=$HEAD_REPO, base=$BASE_REPO) — fix agent blocked"
exit 1
if [[ "${STAGE}" == "fix" ]]; then
HEAD_REPO=$(printf '%s' "${PR_JSON}" | jq -r '.head.repo.full_name')
BASE_REPO=$(printf '%s' "${PR_JSON}" | jq -r '.base.repo.full_name')
if [[ "${HEAD_REPO}" != "${BASE_REPO}" ]]; then
echo "::error::Fork PR detected (head=${HEAD_REPO}, base=${BASE_REPO}) — fix agent blocked"
exit 1
fi
fi
DELIM="PR_$(openssl rand -hex 8)"
{
echo "pr_json<<${DELIM}"
echo "${PR_JSON}"
echo "${DELIM}"
} >> "${GITHUB_OUTPUT}"

- name: Find and trigger agent workflows for stage
if: steps.route.outputs.stage != '' && steps.role-check.outputs.skipped != 'true' && steps.pr-check.outputs.skipped != 'true'
Expand All @@ -395,6 +411,7 @@ jobs:
SOURCE_REPO: ${{ github.repository }}
TRIGGER_SOURCE: ${{ steps.route.outputs.trigger_source }}
DISPATCH_REPO: ${{ job.workflow_repository }}
PR_HEAD_JSON: ${{ steps.pr-head.outputs.pr_json }}
run: |
set -euo pipefail
Comment thread
ggallen marked this conversation as resolved.

Expand All @@ -409,6 +426,12 @@ jobs:
comment: (.comment // null | if . then {body: .body[:4096]} else null end)
}' "$GITHUB_EVENT_PATH")

# For issue_comment events on PRs, the raw event has no top-level
# pull_request with head SHA. Merge the API-fetched PR info.
if [[ -n "${PR_HEAD_JSON}" ]]; then
EVENT_PAYLOAD=$(printf '%s' "${EVENT_PAYLOAD}" | jq -c --argjson pr "${PR_HEAD_JSON}" '.pull_request = $pr')
fi

echo "Scanning for workflows with stage: $STAGE"

dispatched=0
Expand Down
Loading
Loading