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
27 changes: 6 additions & 21 deletions .github/workflows/dev-lead.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,32 +227,17 @@ jobs:
timeout-minutes: 5

steps:
- name: Checkout agent repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Check for associated non-fork PR
id: check-pr
env:
CHECK_RUN_PRS: ${{ toJson(github.event.check_run.pull_requests) }}
CHECK_RUN_HEAD_SHA: ${{ github.event.check_run.head_sha }}
REPO_FULL_NAME: ${{ github.repository }}
run: |
set -euo pipefail
# Require at least one PR in the check_run.pull_requests array
pr_count=$(echo "$CHECK_RUN_PRS" | jq 'length')
if [ "$pr_count" -eq 0 ]; then
echo "::notice::check_run has no associated PRs — skipping relay"
echo "should_relay=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fork check: check_run.pull_requests[*].head.repo only contains id/url/name
# (not full_name), so compare the API URL pattern against the expected repo URL.
head_repo_url=$(echo "$CHECK_RUN_PRS" | jq -r '.[0].head.repo.url // empty')
expected_url="https://api.github.com/repos/${REPO_FULL_NAME}"
if [ -n "$head_repo_url" ] && [ "$head_repo_url" != "$expected_url" ]; then
echo "::notice::check_run PR head repo URL ($head_repo_url) does not match expected ($expected_url) — fork, skipping relay"
echo "should_relay=false" >> "$GITHUB_OUTPUT"
exit 0
fi
pr_number=$(echo "$CHECK_RUN_PRS" | jq -r '.[0].number')
echo "should_relay=true" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS || secrets.GITHUB_TOKEN }}
run: bash scripts/dev-lead-ci-relay.sh

- name: Emit repository_dispatch dev-lead-ci-failure
if: steps.check-pr.outputs.should_relay == 'true'
Expand Down
13 changes: 13 additions & 0 deletions prompts/dev-lead/fix-bot-comment.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ Analyze the bot's findings and address each actionable issue:
3. Apply targeted fixes using Edit/Write tools
4. Verify that the fixes are complete and do not introduce regressions

## SonarQube / SonarCloud comments

If `${ACTOR}` is `sonarqubecloud[bot]` and the comment reports security hotspots or ratings **without referencing specific files or line numbers**, the SonarCloud dashboard link is not browsable — you must infer the hotspot from the PR's changed files:

1. Run `gh pr diff ${PR_NUMBER} --repo ${REPO}` to list all changed files and their diffs
2. Scan changed files for these known SonarQube hotspot patterns (in descending severity):
- **Script injection (S4830 / RSPEC-4830):** `curl … | bash`, `curl … | sh`, `wget … | bash` — replace with a pinned, checksum-verified install or `gh extension install`
- **Hardcoded credentials:** tokens, passwords, or API keys in source files
- **Dynamic code execution:** `eval`, `exec` with user-controlled input
- **Insecure download:** HTTP (non-HTTPS) URLs used to fetch scripts or packages
3. Fix each identified hotspot — for `curl | bash` patterns, replace with a safer alternative such as a pinned binary download with SHA verification, `gh extension install <owner>/<repo>`, or a package manager install
4. If no hotspot is found in changed files, read any newly introduced shell scripts or workflow YAML steps for the patterns above

## Constraints

- Only fix issues that are clearly actionable from the bot's output
Expand Down
13 changes: 13 additions & 0 deletions prompts/dev-lead/fix-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ Analyze the CI failure logs and annotations above, then fix the root cause(s). Y
3. Apply targeted fixes using the Edit/Write tools
4. Verify your fixes are consistent with the rest of the codebase

### External quality gate (SonarCloud, CodeQL, etc.)

If **Failure Logs** begins with `# External quality gate`, this check is not a GitHub Actions workflow — it is an external service that reported a quality gate failure. In this case:

- **Failure Logs** contains the PR diff instead of log output; **Annotations** will be empty
- Use the PR diff to identify what the gate likely flagged
- For **SonarQube / SonarCloud Security Hotspots**, scan changed files for:
- `curl … | bash` / `wget … | sh` — script injection hotspot (replace with a pinned install or `gh extension install`)
- Hardcoded credentials, tokens, or API keys
- `eval` / `exec` with dynamic input
- HTTP (non-HTTPS) URLs for script or package downloads
- Fix each identified hotspot and commit

## Constraints

- Fix only what is broken — do not refactor unrelated code
Expand Down
62 changes: 62 additions & 0 deletions scripts/dev-lead-ci-relay.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
# dev-lead-ci-relay.sh — Resolves the PR associated with a check_run and writes
# relay decision to GITHUB_OUTPUT.
#
# Env inputs:
# CHECK_RUN_PRS — JSON array from check_run.pull_requests (may be [])
# CHECK_RUN_HEAD_SHA — commit SHA from the check_run event
# REPO_FULL_NAME — "owner/repo" string
# GITHUB_OUTPUT — path to the GitHub Actions output file (or /dev/null)
#
# Writes to GITHUB_OUTPUT:
# should_relay=true|false
# pr_number=N (only when should_relay=true)

GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/null}"
CHECK_RUN_PRS="${CHECK_RUN_PRS:-[]}"
CHECK_RUN_HEAD_SHA="${CHECK_RUN_HEAD_SHA:-}"
REPO_FULL_NAME="${REPO_FULL_NAME:-}"

pr_count=$(echo "$CHECK_RUN_PRS" | jq 'length')

if [ "$pr_count" -eq 0 ]; then
# GitHub App check_runs (SonarCloud, external CodeQL gates, etc.) do not
# populate check_run.pull_requests. Fall back to the commits-to-pulls API.
echo "::notice::check_run.pull_requests is empty — trying commits-to-pulls API fallback for SHA ${CHECK_RUN_HEAD_SHA}"
pulls_json=$(gh api \
-H "Accept: application/vnd.github+json" \
"repos/${REPO_FULL_NAME}/commits/${CHECK_RUN_HEAD_SHA}/pulls" 2>/dev/null || echo "[]")
# Filter to open PRs only — merged PRs can share the same commit SHA.
open_pulls=$(echo "$pulls_json" | jq '[.[] | select(.state=="open")]')
pr_count=$(echo "$open_pulls" | jq 'length')
if [ "$pr_count" -eq 0 ]; then
echo "::notice::No open PRs found for commit ${CHECK_RUN_HEAD_SHA} — skipping relay"
echo "should_relay=false" >> "$GITHUB_OUTPUT"
exit 0
fi
head_repo_full=$(echo "$open_pulls" | jq -r '.[0].head.repo.full_name // empty')
if [ -n "$head_repo_full" ] && [ "$head_repo_full" != "$REPO_FULL_NAME" ]; then
echo "::notice::Commit ${CHECK_RUN_HEAD_SHA} belongs to fork (${head_repo_full}) — skipping relay"
echo "should_relay=false" >> "$GITHUB_OUTPUT"
exit 0
fi
pr_number=$(echo "$open_pulls" | jq -r '.[0].number')
echo "should_relay=true" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
exit 0
fi

# check_run.pull_requests is populated (GitHub Actions check_run).
# Fork check: pull_requests[*].head.repo only has id/url/name (not full_name),
# so compare the API URL against the expected base URL for this repo.
head_repo_url=$(echo "$CHECK_RUN_PRS" | jq -r '.[0].head.repo.url // empty')
expected_url="https://api.github.com/repos/${REPO_FULL_NAME}"
if [ -n "$head_repo_url" ] && [ "$head_repo_url" != "$expected_url" ]; then
echo "::notice::check_run PR head repo URL ($head_repo_url) does not match expected ($expected_url) — fork, skipping relay"
echo "should_relay=false" >> "$GITHUB_OUTPUT"
exit 0
fi
pr_number=$(echo "$CHECK_RUN_PRS" | jq -r '.[0].number')
echo "should_relay=true" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
7 changes: 6 additions & 1 deletion scripts/dev-lead-fix-ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ collect_logs() {
if [ -n "$run_id" ]; then
gh run view "$run_id" --log-failed 2>/dev/null | tail -n "$LOG_MAX_LINES" || true
else
echo "# No run logs available for check: $check_name"
# External quality gate (e.g. SonarCloud) — no GitHub Actions run logs exist.
# Provide the PR diff so the agent can identify what the gate likely flagged.
printf '# External quality gate — no GitHub Actions run logs available\n'
printf '# Check: %s | Details: %s\n\n' "$check_name" "$details_url"
printf '# PR diff (scan for hotspots / quality issues):\n'
gh pr diff "$PR_NUMBER" --repo "$REPO" 2>/dev/null | head -n 1000 || true
fi
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"_test_expected_intent": "skip",
"_test_note": "ci-relay job tries commits-to-pulls API fallback; this fixture represents the case where that API also returns no PRs (truly orphan commit)",
"action": "completed",
"check_run": {
"name": "lint / eslint",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"_test_expected_intent": "ci-relay",
"_test_note": "GitHub App check_run with empty pull_requests — ci-relay must use commits-to-pulls fallback",
"action": "completed",
"check_run": {
"name": "SonarCloud Code Analysis",
"conclusion": "failure",
"head_sha": "abc123def456",
"details_url": "https://sonarcloud.io",
"app": { "slug": "sonarcloud" },
"pull_requests": []
},
"repository": { "full_name": "petry-projects/.github-private" },
"sender": { "login": "sonarcloud[bot]", "type": "Bot" }
}
Comment thread
don-petry marked this conversation as resolved.
Comment thread
don-petry marked this conversation as resolved.
126 changes: 126 additions & 0 deletions tests/dev-lead/unit/test_ci_relay.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env bats
# Unit tests for scripts/dev-lead-ci-relay.sh
# Covers: non-empty pull_requests (GHA path), empty pull_requests (App fallback),
# fork rejection, open-PR filtering, and no-PR-found skip.

SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../.." && pwd)"
CI_RELAY_SCRIPT="$SCRIPT_DIR/scripts/dev-lead-ci-relay.sh"
GH_STUBS_DIR="$SCRIPT_DIR/tests/dev-lead/fixtures/stubs"

setup() {
export GITHUB_OUTPUT
GITHUB_OUTPUT="$(mktemp)"
STUB_BIN_DIR="$(mktemp -d)"
cp "$GH_STUBS_DIR/gh" "$STUB_BIN_DIR/gh"
chmod +x "$STUB_BIN_DIR/gh"
export PATH="$STUB_BIN_DIR:$PATH"
export STUB_BIN_DIR
export REPO_FULL_NAME="petry-projects/.github-private"
export CHECK_RUN_HEAD_SHA="abc123def456"
export GH_STUB_PR_NUMBER="42"
export GH_STUB_PR_HEAD_REPO="petry-projects/.github-private"
}

teardown() {
rm -f "$GITHUB_OUTPUT"
rm -rf "$STUB_BIN_DIR"
}

_out() { grep "^${1}=" "$GITHUB_OUTPUT" | cut -d= -f2- | head -1; }

# ── non-empty pull_requests (GitHub Actions check_run) ────────────────────────

@test "ci-relay: non-empty pull_requests, same repo → should_relay=true" {
export CHECK_RUN_PRS='[{"number":42,"head":{"repo":{"url":"https://api.github.com/repos/petry-projects/.github-private"}}}]'

run bash "$CI_RELAY_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_out should_relay)" = "true" ]
[ "$(_out pr_number)" = "42" ]
}

@test "ci-relay: non-empty pull_requests, fork repo URL → should_relay=false" {
export CHECK_RUN_PRS='[{"number":7,"head":{"repo":{"url":"https://api.github.com/repos/forker/other-repo"}}}]'

run bash "$CI_RELAY_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_out should_relay)" = "false" ]
}

# ── empty pull_requests (GitHub App check_run — commits-to-pulls fallback) ────

@test "ci-relay: empty pull_requests, API returns open PR → should_relay=true" {
export CHECK_RUN_PRS='[]'
export GH_STUB_PR_NUMBER="99"
export GH_STUB_PR_HEAD_REPO="petry-projects/.github-private"

run bash "$CI_RELAY_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_out should_relay)" = "true" ]
[ "$(_out pr_number)" = "99" ]
}

@test "ci-relay: empty pull_requests, API returns no PRs → should_relay=false" {
export CHECK_RUN_PRS='[]'
cat > "$STUB_BIN_DIR/gh" <<'GHEOF'
#!/usr/bin/env bash
case "$*" in
*"commits/"*"/pulls"*) echo "[]" ;;
*) echo "{}" ;;
esac
GHEOF
chmod +x "$STUB_BIN_DIR/gh"

run bash "$CI_RELAY_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_out should_relay)" = "false" ]
}

@test "ci-relay: empty pull_requests, API returns fork PR → should_relay=false" {
export CHECK_RUN_PRS='[]'
export GH_STUB_PR_NUMBER="55"
export GH_STUB_PR_HEAD_REPO="forker/.github-private"

run bash "$CI_RELAY_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_out should_relay)" = "false" ]
}

@test "ci-relay: empty pull_requests, API returns only merged PR → should_relay=false" {
export CHECK_RUN_PRS='[]'
cat > "$STUB_BIN_DIR/gh" <<'GHEOF'
#!/usr/bin/env bash
case "$*" in
*"commits/"*"/pulls"*) echo '[{"number":33,"state":"closed","head":{"repo":{"full_name":"petry-projects/.github-private"}}}]' ;;
*) echo "{}" ;;
esac
GHEOF
chmod +x "$STUB_BIN_DIR/gh"

run bash "$CI_RELAY_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_out should_relay)" = "false" ]
}

@test "ci-relay: empty pull_requests, API failure → should_relay=false" {
export CHECK_RUN_PRS='[]'
cat > "$STUB_BIN_DIR/gh" <<'GHEOF'
#!/usr/bin/env bash
case "$*" in
*"commits/"*"/pulls"*) exit 1 ;;
*) echo "{}" ;;
esac
GHEOF
chmod +x "$STUB_BIN_DIR/gh"

run bash "$CI_RELAY_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_out should_relay)" = "false" ]
}
56 changes: 56 additions & 0 deletions tests/dev-lead/unit/test_fix_ci.bats
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,59 @@ STUB
# Output should mention dedup skip
[[ "$output" == *"already posted"* || "$output" == *"skipping duplicate"* ]]
}

# ── external quality gate (Fix 3) ─────────────────────────────────────────────

@test "fix-ci: external quality gate: non-GHA details_url → PR diff embedded in prompt" {
cat > "$STUB_BIN_DIR/gh" <<'GHEOF'
#!/usr/bin/env bash
ARGS="$*"
case "$ARGS" in
*"issues/"*"/comments"*) echo "[]" ;;
*"pr comment"*) exit 0 ;;
*"pr diff"*) printf 'diff --git a/lint.yml b/lint.yml\n+run: curl -sL https://example.com/install.sh | bash\n' ;;
*) echo "{}" ;;
esac
GHEOF
chmod +x "$STUB_BIN_DIR/gh"
export DEV_LEAD_DRY_RUN="true"
export CHECKS_JSON='[{"name":"SonarCloud Code Analysis","conclusion":"failure","details_url":"https://sonarcloud.io","app_slug":"sonarcloud"}]'

run bash "$FIX_CI_SCRIPT"

[ "$status" -eq 0 ]
# Extract the rendered prompt file path from the dry-run notice line
local prompt_file
prompt_file=$(echo "$output" | awk '/would run engine with prompt:/{print $NF}')
[ -n "$prompt_file" ]
[ -f "$prompt_file" ]
grep -q "External quality gate" "$prompt_file"
grep -q "curl -sL" "$prompt_file"
rm -f "$prompt_file"
}

@test "fix-ci: external quality gate: GHA details_url → uses run view, not pr diff" {
local sentinel_dir
sentinel_dir="$(mktemp -d)"
cat > "$STUB_BIN_DIR/gh" <<GHEOF
#!/usr/bin/env bash
ARGS="\$*"
case "\$ARGS" in
*"issues/"*"/comments"*) echo "[]" ;;
*"pr comment"*) exit 0 ;;
*"run view"*) touch "${sentinel_dir}/.ran_run_view"; echo "github-actions log output" ;;
*"pr diff"*) touch "${sentinel_dir}/.ran_pr_diff"; echo "diff output" ;;
*) echo "{}" ;;
esac
GHEOF
chmod +x "$STUB_BIN_DIR/gh"
export DEV_LEAD_DRY_RUN="true"
export CHECKS_JSON='[{"name":"lint / eslint","conclusion":"failure","details_url":"https://github.com/petry-projects/.github-private/actions/runs/12345","app_slug":"github-actions"}]'

run bash "$FIX_CI_SCRIPT"

[ "$status" -eq 0 ]
[ -f "${sentinel_dir}/.ran_run_view" ]
[ ! -f "${sentinel_dir}/.ran_pr_diff" ]
rm -rf "$sentinel_dir"
}
Comment thread
don-petry marked this conversation as resolved.
Loading