From 961e1cc52567d9783c5e1b66efb1b48bd044966c Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 03:27:58 +0000 Subject: [PATCH 01/23] =?UTF-8?q?feat:=20implement=20issue=20#232=20?= =?UTF-8?q?=E2=80=94=20Phase=201:=20Issue=20Triage=20gh-aw=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-triage-runner.yml | 60 ++++ .github/workflows/issue-triage.md | 76 +++++ .github/workflows/test.yml | 9 + docs/aw/issue-triage.md | 99 ++++++ scripts/aw.sh | 367 ++++++++++++++++++++++ tests/aw/issue-triage/scenarios.md | 75 +++++ 6 files changed, 686 insertions(+) create mode 100644 .github/workflows/issue-triage-runner.yml create mode 100644 .github/workflows/issue-triage.md create mode 100644 docs/aw/issue-triage.md create mode 100755 scripts/aw.sh create mode 100644 tests/aw/issue-triage/scenarios.md diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml new file mode 100644 index 000000000..a084e28e1 --- /dev/null +++ b/.github/workflows/issue-triage-runner.yml @@ -0,0 +1,60 @@ +name: Issue Triage + +on: + issues: + types: [opened, reopened] + +# Two-job pattern: triage runs with read-only issue access and produces a +# staged JSON artifact; apply runs with write access and calls safe-output. +jobs: + triage: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: read + outputs: + result: ${{ steps.run.outputs.result }} + skip: ${{ steps.run.outputs.skip }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install claude CLI + uses: anthropics/claude-code-action/setup@main + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + - name: Run issue-triage (staged) + id: run + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ',') }} + ISSUE_URL: ${{ github.event.issue.html_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + result=$(bash scripts/aw.sh run issue-triage --staged) + skip=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('skip', False))") + # Store encoded to survive multi-line JSON in GITHUB_OUTPUT + echo "result=$(echo "$result" | base64 -w0)" >> "$GITHUB_OUTPUT" + echo "skip=$skip" >> "$GITHUB_OUTPUT" + + apply: + needs: triage + if: needs.triage.outputs.skip != 'True' + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + issues: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Apply triage output (safe-output) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + echo "${{ needs.triage.outputs.result }}" | base64 -d > /tmp/triage-result.json + bash scripts/aw.sh safe-output apply issue-triage /tmp/triage-result.json diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md new file mode 100644 index 000000000..87e492220 --- /dev/null +++ b/.github/workflows/issue-triage.md @@ -0,0 +1,76 @@ +--- +name: issue-triage +trigger: + issues: + types: [opened, reopened] +engine: claude-sonnet-4-6 +permissions: + issues: read +output: staged +allowed-labels: + - bug + - enhancement + - documentation + - question + - needs-triage + - good-first-issue + - security +max-labels: 3 +--- + +# Issue Triage + +You are triaging a new GitHub issue for the `${REPO}` repository. + +## Context + +- **Repository:** `${REPO}` +- **Issue:** [#${ISSUE_NUMBER}](${ISSUE_URL}): ${ISSUE_TITLE} +- **Labels already applied:** ${ISSUE_LABELS} + +**Issue body:** + +${ISSUE_BODY} + +## Task + +If the issue already has **2 or more labels**, output `{"skip": true}` and stop — do not classify, do not comment. + +Otherwise: + +1. **Classify** the issue into the best-fit category: + - `bug` — something is not working as expected + - `enhancement` — request for new or extended functionality + - `documentation` — unclear, missing, or incorrect docs + - `question` — user asking how to do something + - `security` — potential security vulnerability + - `good-first-issue` — simple enough for a first-time contributor + +2. **Select ≤ 3 labels** from the allowed set: + `bug`, `enhancement`, `documentation`, `question`, `needs-triage`, + `good-first-issue`, `security`. + - Add `needs-triage` for bugs and ambiguous reports that need human review. + - Add `good-first-issue` only when the scope is clearly small and + self-contained. + +3. **Write one welcoming comment** that: + - Opens warmly and thanks the contributor. + - Asks the **single most important** clarifying question for this issue type: + - Bug → steps to reproduce + expected vs actual behaviour + - Feature → use case / motivation + - Question → what docs they checked and where they got stuck + - Documentation → which page or section is unclear and what they expected + - Security → (do not ask for details publicly; ask them to use the + private security advisory channel) + - Does **not** ask multiple unrelated questions. + - Is concise (≤ 5 sentences). + +## Output format + +Output **exactly one** JSON object — no markdown fences, no preamble: + +For a normal triage: +{"labels": ["label1", "label2"], "comment": "Your welcoming comment here."} + +For a skip (issue already has 2+ labels): +{"skip": true} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82901b13d..5f011c077 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,3 +20,12 @@ jobs: - name: Run list-prs sort tests run: bash tests/test_list_prs_sort.sh + + aw-compile: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v6 + + - name: Compile gh-aw workflow definitions + run: bash scripts/aw.sh compile --all diff --git a/docs/aw/issue-triage.md b/docs/aw/issue-triage.md new file mode 100644 index 000000000..19484af0c --- /dev/null +++ b/docs/aw/issue-triage.md @@ -0,0 +1,99 @@ +# Issue Triage — Agentic Workflow + +Part of the [GitHub Agentic Workflows](https://github.com/petry-projects/.github-private/discussions/228) rollout. + +## What it does + +When a new issue is opened or reopened, this workflow: + +1. Checks the existing label count. If the issue already has **2 or more labels** it exits silently (no-op). +2. Calls Claude (`claude-sonnet-4-6`) with the issue title and body to classify the issue. +3. Selects up to 3 labels from the allowed set and writes a single welcoming comment that asks the most relevant clarifying question. + +All of this happens within 60 seconds of the issue being opened. + +## Allowed labels + +`bug`, `enhancement`, `documentation`, `question`, `needs-triage`, `good-first-issue`, `security` + +## Files + +| File | Purpose | +|------|---------| +| `.github/workflows/issue-triage.md` | Workflow definition (frontmatter + Claude prompt template) | +| `.github/workflows/issue-triage-runner.yml` | GitHub Actions runner (two-job: triage → apply) | +| `scripts/aw.sh` | `gh aw` CLI — compile, run, safe-output | +| `tests/aw/issue-triage/scenarios.md` | Scenario spec used to validate behaviour | + +## Architecture + +``` +issues: opened/reopened + │ + ▼ + triage job (permissions: issues: read) + │ bash scripts/aw.sh run issue-triage --staged + │ → Claude classifies, returns JSON + ▼ + apply job (permissions: issues: write) + │ bash scripts/aw.sh safe-output apply issue-triage result.json + │ → validates labels, posts comment, adds labels + ▼ + issue updated +``` + +The two-job split is deliberate: Claude runs with the minimum permission it +needs (read), and writes are applied by a separate job after the output has +been validated by `safe-output`. + +## Validation + +`gh aw compile` validates every `.md` workflow definition file. It checks: + +- Required frontmatter fields: `name`, `trigger`, `engine`, `permissions` +- Engine is a known Claude model identifier +- `output` is `staged` or `live` (if present) +- Trigger is a non-empty event mapping +- Workflow body (instructions) is not empty + +Run locally: + +```bash +bash scripts/aw.sh compile .github/workflows/issue-triage.md +``` + +## Staged vs live mode + +The `output` frontmatter field controls how results are applied: + +| Value | Behaviour | +|-------|-----------| +| `staged` | `aw run` emits JSON to stdout; a separate `safe-output apply` step writes to GitHub | +| `live` (default when omitted) | `aw run` applies labels and posts the comment inline | + +The runner workflow always uses the two-job pattern regardless of this setting, +so `safe-output` always validates before writing. + +## Skip condition + +If the issue already has **2 or more labels** when the workflow fires, `aw run` +returns `{"skip": true}` immediately and the apply job is skipped. No API +writes occur. + +## Smoke-test procedure + +To validate against the scenario spec before going live: + +```bash +# Create a test issue in a non-critical repo and capture the number +ISSUE_NUMBER= \ +ISSUE_TITLE="Login button is broken" \ +ISSUE_BODY="When I click nothing happens." \ +ISSUE_LABELS="" \ +ISSUE_URL="https://github.com/petry-projects/.github-private/issues/" \ +GITHUB_REPOSITORY="petry-projects/.github-private" \ +bash scripts/aw.sh run issue-triage --staged +``` + +Compare the JSON output against the expected labels and comment style in +`tests/aw/issue-triage/scenarios.md`. diff --git a/scripts/aw.sh b/scripts/aw.sh new file mode 100755 index 000000000..440f69c1c --- /dev/null +++ b/scripts/aw.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env bash +# gh aw — GitHub Agentic Workflows CLI +# Subcommands: compile, run, safe-output +set -euo pipefail + +WORKFLOWS_DIR=".github/workflows" +COMMAND="${1:-}" +shift || true + +usage() { + cat >&2 <<'EOF' +Usage: + aw.sh compile [|--all] + aw.sh run [--staged] + aw.sh safe-output apply +EOF + exit 1 +} + +# --------------------------------------------------------------------------- +# compile — validate one or all workflow definition files +# --------------------------------------------------------------------------- +cmd_compile() { + local target="${1:---all}" + local files=() + + if [[ "$target" == "--all" ]]; then + while IFS= read -r -d '' f; do + files+=("$f") + done < <(find "$WORKFLOWS_DIR" -maxdepth 1 -name '*.md' -print0 2>/dev/null) + if [[ ${#files[@]} -eq 0 ]]; then + echo "aw compile: no workflow definitions found in $WORKFLOWS_DIR" >&2 + exit 0 + fi + else + files=("$target") + fi + + local errors=0 + for file in "${files[@]}"; do + if ! validate_workflow "$file"; then + errors=$(( errors + 1 )) + fi + done + + if [[ $errors -gt 0 ]]; then + echo "aw compile: $errors file(s) failed validation" >&2 + exit 1 + fi + + echo "aw compile: all workflow definitions are valid" +} + +# Validate a single workflow .md file. +# Uses python3 to parse the YAML frontmatter — always available on GitHub Actions runners. +validate_workflow() { + local file="$1" + + if [[ ! -f "$file" ]]; then + echo "aw compile: $file: file not found" >&2 + return 1 + fi + + python3 - "$file" <<'PYEOF' +import sys, re + +path = sys.argv[1] +text = open(path).read() + +# Extract YAML frontmatter +m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) +if not m: + print(f"aw compile: {path}: missing YAML frontmatter", file=sys.stderr) + sys.exit(1) + +import yaml +try: + fm = yaml.safe_load(m.group(1)) +except yaml.YAMLError as e: + print(f"aw compile: {path}: invalid YAML frontmatter: {e}", file=sys.stderr) + sys.exit(1) + +errors = [] + +# Required fields +for field in ("name", "trigger", "engine", "permissions"): + if field not in fm: + errors.append(f"missing required field: {field!r}") + +# Validate engine +valid_engines = { + "claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-7", + "claude-haiku-4-5", +} +engine = fm.get("engine", "") +if engine and engine not in valid_engines: + errors.append(f"unknown engine {engine!r}; valid: {sorted(valid_engines)}") + +# Validate output mode if present +output_mode = fm.get("output") +if output_mode and output_mode not in ("staged", "live"): + errors.append(f"output must be 'staged' or 'live', got {output_mode!r}") + +# Validate trigger has at least one event +trigger = fm.get("trigger") or {} +if not isinstance(trigger, dict) or not trigger: + errors.append("trigger must be a non-empty mapping of event types") + +# Instructions body must not be empty +body = text[m.end():].strip() +if not body: + errors.append("workflow body (instructions) must not be empty") + +if errors: + for e in errors: + print(f"aw compile: {path}: {e}", file=sys.stderr) + sys.exit(1) + +print(f"aw compile: {path}: ok") +PYEOF +} + +# --------------------------------------------------------------------------- +# run — execute a workflow against GitHub context (or a fixture) +# --------------------------------------------------------------------------- +cmd_run() { + local name="" + local staged=false + local fixture="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --staged) staged=true ;; + --fixture) fixture="${2:-}"; shift ;; + -*) echo "aw run: unknown flag $1" >&2; exit 1 ;; + *) name="$1" ;; + esac + shift + done + + if [[ -z "$name" ]]; then + echo "aw run: workflow name required" >&2 + exit 1 + fi + + local wf_file="$WORKFLOWS_DIR/${name}.md" + if [[ ! -f "$wf_file" ]]; then + echo "aw run: workflow file not found: $wf_file" >&2 + exit 1 + fi + + # Build context from environment or fixture + local issue_number issue_title issue_body issue_labels issue_url repo + if [[ -n "$fixture" ]]; then + issue_number="$(python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['issue']['number'])")" + issue_title="$(python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['issue']['title'])")" + issue_body="$( python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['issue']['body'] or '')")" + issue_labels="$(python3 -c "import json,sys; d=json.load(open('$fixture')); print(','.join(l['name'] for l in d['issue'].get('labels',[])))")" + issue_url="$( python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['issue']['html_url'])")" + repo="$( python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['repository']['full_name'])")" + else + issue_number="${ISSUE_NUMBER:-}" + issue_title="${ISSUE_TITLE:-}" + issue_body="${ISSUE_BODY:-}" + issue_labels="${ISSUE_LABELS:-}" + issue_url="${ISSUE_URL:-https://github.com/${GITHUB_REPOSITORY:-unknown}/issues/${ISSUE_NUMBER:-0}}" + repo="${GITHUB_REPOSITORY:-unknown}" + fi + + # Skip guard: if 2+ labels already applied, emit skip immediately + local label_count + label_count="$(echo "$issue_labels" | python3 -c " +import sys +raw = sys.stdin.read().strip() +labels = [l for l in raw.split(',') if l] +print(len(labels)) +")" + if [[ "$label_count" -ge 2 ]]; then + echo '{"skip": true}' + return 0 + fi + + # Substitute variables into workflow body + local prompt + prompt="$(python3 - "$wf_file" </dev/null \ + || { echo '{"error": "claude invocation failed"}'; return 1; })" + + # Validate JSON output + if ! echo "$result" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then + echo "aw run: claude returned non-JSON output" >&2 + echo "$result" >&2 + exit 1 + fi + + if [[ "$staged" == true ]]; then + # Staged mode: emit JSON to stdout for review; safe-output handles writes + echo "$result" + else + # Live mode: apply immediately + apply_result "$name" "$result" "$issue_number" + fi +} + +# --------------------------------------------------------------------------- +# safe-output apply — validate and apply a staged result artifact +# --------------------------------------------------------------------------- +cmd_safe_output() { + local subcommand="${1:-}" + shift || true + + case "$subcommand" in + apply) cmd_safe_output_apply "$@" ;; + *) echo "aw safe-output: unknown subcommand ${subcommand}" >&2; exit 1 ;; + esac +} + +cmd_safe_output_apply() { + local name="${1:-}" + local result_file="${2:-}" + + if [[ -z "$name" || -z "$result_file" ]]; then + echo "aw safe-output apply: requires " >&2 + exit 1 + fi + if [[ ! -f "$result_file" ]]; then + echo "aw safe-output apply: result file not found: $result_file" >&2 + exit 1 + fi + + local wf_file="$WORKFLOWS_DIR/${name}.md" + local allowed_labels + allowed_labels="$(python3 - "$wf_file" <<'PYEOF' +import sys, re, yaml, json +path = sys.argv[1] +text = open(path).read() +m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) +fm = yaml.safe_load(m.group(1)) +print(json.dumps(fm.get("allowed-labels", []))) +PYEOF +)" + + # Validate and apply via GitHub API + python3 - "$result_file" "$allowed_labels" <<'PYEOF' +import sys, json + +result_file = sys.argv[1] +allowed_set = set(json.loads(sys.argv[2])) + +result = json.load(open(result_file)) + +if result.get("skip"): + print("safe-output: skip flag set — no writes applied") + sys.exit(0) + +labels = result.get("labels", []) +comment = result.get("comment", "") + +# Validate labels against allowed set +invalid = [l for l in labels if l not in allowed_set] +if invalid: + print(f"safe-output: rejected — labels not in allowed set: {invalid}", file=sys.stderr) + sys.exit(1) +if len(labels) > 3: + print(f"safe-output: rejected — more than 3 labels: {labels}", file=sys.stderr) + sys.exit(1) +if not comment: + print("safe-output: rejected — empty comment", file=sys.stderr) + sys.exit(1) + +print(f"safe-output: labels={labels}, comment length={len(comment)}") +print("safe-output: validation passed — ready for API writes") +PYEOF + + # Apply writes via gh CLI + local issue_number="${ISSUE_NUMBER:-}" + local repo="${GITHUB_REPOSITORY:-}" + + if [[ -z "$issue_number" || -z "$repo" ]]; then + echo "safe-output apply: ISSUE_NUMBER and GITHUB_REPOSITORY must be set for live writes" >&2 + exit 1 + fi + + local result_json + result_json="$(cat "$result_file")" + + # Apply labels + local labels_array + labels_array="$(echo "$result_json" | python3 -c " +import json,sys +r=json.load(sys.stdin) +if not r.get('skip'): print(' '.join(r.get('labels',[]))) +")" + if [[ -n "$labels_array" ]]; then + # shellcheck disable=SC2086 + gh issue edit "$issue_number" --repo "$repo" --add-label "$labels_array" + fi + + # Post comment + local comment + comment="$(echo "$result_json" | python3 -c " +import json,sys +r=json.load(sys.stdin) +if not r.get('skip'): print(r.get('comment','')) +")" + if [[ -n "$comment" ]]; then + gh issue comment "$issue_number" --repo "$repo" --body "$comment" + fi + + echo "safe-output apply: done" +} + +# Apply result inline (live mode, called from cmd_run) +apply_result() { + local name="$1" result="$2" issue_number="$3" + local tmp + tmp="$(mktemp)" + echo "$result" > "$tmp" + ISSUE_NUMBER="$issue_number" cmd_safe_output_apply "$name" "$tmp" + rm -f "$tmp" +} + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +case "$COMMAND" in + compile) cmd_compile "$@" ;; + run) cmd_run "$@" ;; + safe-output) cmd_safe_output "$@" ;; + "") usage ;; + *) echo "aw.sh: unknown command '$COMMAND'" >&2; usage ;; +esac diff --git a/tests/aw/issue-triage/scenarios.md b/tests/aw/issue-triage/scenarios.md new file mode 100644 index 000000000..446d59d4a --- /dev/null +++ b/tests/aw/issue-triage/scenarios.md @@ -0,0 +1,75 @@ +# Issue Triage — Scenario Spec + +This spec defines the expected triage behaviour for representative inputs. All +scenarios must be validated before the workflow is promoted to live. + +--- + +## Scenario 1 — Bug report (no labels) + +**Input:** +- Title: `Login button is broken` +- Body: `When I click the login button nothing happens.` +- Existing labels: _(none)_ + +**Expected output:** +- Labels applied: `bug`, `needs-triage` +- Comment posted: yes — asks for steps to reproduce and expected vs actual + behaviour (e.g. "Could you share the steps to reproduce this? What did you + expect to happen, and what actually happened instead?") + +--- + +## Scenario 2 — Feature request (no labels) + +**Input:** +- Title: `Add dark mode support` +- Body: `It would be great if the app supported dark mode.` +- Existing labels: _(none)_ + +**Expected output:** +- Labels applied: `enhancement` +- Comment posted: yes — acknowledges the feature request and asks about the + use case (e.g. "Thanks for the suggestion! Could you tell us a bit more + about your use case — how would dark mode help your workflow?") + +--- + +## Scenario 3 — Question (no labels) + +**Input:** +- Title: `How do I configure X?` +- Body: `I'm trying to configure X but can't find any documentation.` +- Existing labels: _(none)_ + +**Expected output:** +- Labels applied: `question` +- Comment posted: yes — points to docs and asks what the user has already + tried (e.g. "Have you had a chance to check the docs at …? If so, what + step are you getting stuck on?") + +--- + +## Scenario 4 — Already labelled (edge case: no-op) + +**Input:** +- Title: `Something is wrong` +- Body: `Not sure what's happening.` +- Existing labels: `bug`, `P1` _(2 labels — threshold met)_ + +**Expected output:** +- Labels applied: _(none)_ +- Comment posted: _(none)_ +- Workflow exits silently (skip, no changes, no comment) + +--- + +## Validation notes + +- The allowed label set is: `bug`, `enhancement`, `documentation`, `question`, + `needs-triage`, `good-first-issue`, `security`. +- At most **3 labels** may be applied in a single run. +- The comment must be a **single** welcoming message; it must not ask multiple + unrelated questions. +- For Scenario 4 the JSON output must be `{"skip": true}` and the safe-output + step must apply zero GitHub API writes. From b4abacf4cbb9b71adc3aad80230267b478104099 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 03:30:00 +0000 Subject: [PATCH 02/23] fix(bot): address bot feedback [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index a084e28e1..28b7a6812 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -55,6 +55,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} ISSUE_NUMBER: ${{ github.event.issue.number }} + TRIAGE_RESULT: ${{ needs.triage.outputs.result }} run: | - echo "${{ needs.triage.outputs.result }}" | base64 -d > /tmp/triage-result.json + echo "$TRIAGE_RESULT" | base64 -d > /tmp/triage-result.json bash scripts/aw.sh safe-output apply issue-triage /tmp/triage-result.json From a2ad3dd4861adcc5574c6bbf33bd932482c6ee5a Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 03:32:26 +0000 Subject: [PATCH 03/23] fix(ci): auto-fix for SonarCloud Code Analysis [skip ci-relay] --- scripts/aw.sh | 54 +++++++++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/scripts/aw.sh b/scripts/aw.sh index 440f69c1c..24fb6453a 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -152,12 +152,13 @@ cmd_run() { # Build context from environment or fixture local issue_number issue_title issue_body issue_labels issue_url repo if [[ -n "$fixture" ]]; then - issue_number="$(python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['issue']['number'])")" - issue_title="$(python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['issue']['title'])")" - issue_body="$( python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['issue']['body'] or '')")" - issue_labels="$(python3 -c "import json,sys; d=json.load(open('$fixture')); print(','.join(l['name'] for l in d['issue'].get('labels',[])))")" - issue_url="$( python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['issue']['html_url'])")" - repo="$( python3 -c "import json,sys; d=json.load(open('$fixture')); print(d['repository']['full_name'])")" + # Pass path via env var to avoid injecting user-controlled paths into Python -c source + issue_number="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['number'])")" + issue_title="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['title'])")" + issue_body="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['body'] or '')")" + issue_labels="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(','.join(l['name'] for l in d['issue'].get('labels',[])))")" + issue_url="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['html_url'])")" + repo="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['repository']['full_name'])")" else issue_number="${ISSUE_NUMBER:-}" issue_title="${ISSUE_TITLE:-}" @@ -180,10 +181,18 @@ print(len(labels)) return 0 fi - # Substitute variables into workflow body + # Substitute variables into workflow body. + # Use a quoted heredoc and env vars so user-controlled issue data cannot + # escape into Python source code (code injection hotspot). local prompt - prompt="$(python3 - "$wf_file" < Date: Tue, 19 May 2026 03:42:14 +0000 Subject: [PATCH 04/23] chore: apply manual instructions [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 7 +- docs/aw/issue-triage.md | 2 +- scripts/aw.sh | 96 +++++++++++++------ .../issue-triage/fixtures/scenario-1-bug.json | 12 +++ .../fixtures/scenario-4-skip.json | 15 +++ tests/aw/issue-triage/test_aw_run.sh | 96 +++++++++++++++++++ 6 files changed, 193 insertions(+), 35 deletions(-) create mode 100644 tests/aw/issue-triage/fixtures/scenario-1-bug.json create mode 100644 tests/aw/issue-triage/fixtures/scenario-4-skip.json create mode 100755 tests/aw/issue-triage/test_aw_run.sh diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index 28b7a6812..bec38f9ea 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install claude CLI - uses: anthropics/claude-code-action/setup@main + uses: anthropics/claude-code-action/setup@24492741e0ccfdef4c1d19da8e11e0f373d07494 # main with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -34,15 +34,16 @@ jobs: ISSUE_URL: ${{ github.event.issue.html_url }} GITHUB_REPOSITORY: ${{ github.repository }} run: | + set -euo pipefail result=$(bash scripts/aw.sh run issue-triage --staged) - skip=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('skip', False))") + skip=$(echo "$result" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('skip') else 'false')") # Store encoded to survive multi-line JSON in GITHUB_OUTPUT echo "result=$(echo "$result" | base64 -w0)" >> "$GITHUB_OUTPUT" echo "skip=$skip" >> "$GITHUB_OUTPUT" apply: needs: triage - if: needs.triage.outputs.skip != 'True' + if: needs.triage.outputs.skip != 'true' runs-on: ubuntu-latest timeout-minutes: 3 permissions: diff --git a/docs/aw/issue-triage.md b/docs/aw/issue-triage.md index 19484af0c..3b0db88c1 100644 --- a/docs/aw/issue-triage.md +++ b/docs/aw/issue-triage.md @@ -1,6 +1,6 @@ # Issue Triage — Agentic Workflow -Part of the [GitHub Agentic Workflows](https://github.com/petry-projects/.github-private/discussions/228) rollout. +Part of the GitHub Agentic Workflows rollout (internal discussion: [petry-projects/.github-private#228](https://github.com/petry-projects/.github-private/discussions/228) — requires repo access). ## What it does diff --git a/scripts/aw.sh b/scripts/aw.sh index 24fb6453a..367ea282b 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -27,7 +27,7 @@ cmd_compile() { if [[ "$target" == "--all" ]]; then while IFS= read -r -d '' f; do files+=("$f") - done < <(find "$WORKFLOWS_DIR" -maxdepth 1 -name '*.md' -print0 2>/dev/null) + done < <(find "$WORKFLOWS_DIR" -name '*.md' -print0 2>/dev/null) if [[ ${#files[@]} -eq 0 ]]; then echo "aw compile: no workflow definitions found in $WORKFLOWS_DIR" >&2 exit 0 @@ -65,7 +65,8 @@ validate_workflow() { import sys, re path = sys.argv[1] -text = open(path).read() +with open(path, encoding='utf-8') as f: + text = f.read() # Extract YAML frontmatter m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) @@ -87,14 +88,14 @@ for field in ("name", "trigger", "engine", "permissions"): if field not in fm: errors.append(f"missing required field: {field!r}") -# Validate engine -valid_engines = { +# Validate engine — warn on unknown but don't fail (new models can be added without a code change) +known_engines = { "claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-7", "claude-haiku-4-5", } engine = fm.get("engine", "") -if engine and engine not in valid_engines: - errors.append(f"unknown engine {engine!r}; valid: {sorted(valid_engines)}") +if engine and engine not in known_engines: + print(f"aw compile: {path}: warning: unknown engine {engine!r}", file=sys.stderr) # Validate output mode if present output_mode = fm.get("output") @@ -168,15 +169,25 @@ cmd_run() { repo="${GITHUB_REPOSITORY:-unknown}" fi - # Skip guard: if 2+ labels already applied, emit skip immediately - local label_count + # Skip guard: read threshold from frontmatter (default 2), skip if label count >= threshold + local label_count skip_threshold label_count="$(echo "$issue_labels" | python3 -c " import sys raw = sys.stdin.read().strip() labels = [l for l in raw.split(',') if l] print(len(labels)) ")" - if [[ "$label_count" -ge 2 ]]; then + skip_threshold="$(python3 - "$wf_file" <<'PYEOF' +import sys, re, yaml +path = sys.argv[1] +with open(path, encoding='utf-8') as f: + text = f.read() +m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) +fm = yaml.safe_load(m.group(1)) if m else {} +print(fm.get('skip-if-labels-ge', 2)) +PYEOF +)" + if [[ "$label_count" -ge "$skip_threshold" ]]; then echo '{"skip": true}' return 0 fi @@ -195,15 +206,17 @@ print(len(labels)) import sys, re, os path = sys.argv[1] -text = open(path).read() +with open(path, encoding='utf-8') as f: + text = f.read() m = re.match(r'^---\n.*?\n---\n', text, re.DOTALL) body = text[m.end():] if m else text +labels_val = os.environ.get('AW_ISSUE_LABELS', '') subs = { 'ISSUE_NUMBER': os.environ.get('AW_ISSUE_NUMBER', ''), 'ISSUE_TITLE': os.environ.get('AW_ISSUE_TITLE', ''), 'ISSUE_BODY': os.environ.get('AW_ISSUE_BODY', ''), - 'ISSUE_LABELS': os.environ.get('AW_ISSUE_LABELS', ''), + 'ISSUE_LABELS': labels_val if labels_val else '(none)', 'ISSUE_URL': os.environ.get('AW_ISSUE_URL', ''), 'REPO': os.environ.get('AW_REPO', ''), } @@ -218,7 +231,8 @@ PYEOF engine="$(python3 - "$wf_file" <<'PYEOF' import sys, re, yaml path = sys.argv[1] -text = open(path).read() +with open(path, encoding='utf-8') as f: + text = f.read() m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) fm = yaml.safe_load(m.group(1)) print(fm.get("engine", "claude-sonnet-4-6")) @@ -226,9 +240,12 @@ PYEOF )" # Call Claude - local result - result="$(echo "$prompt" | claude --model "$engine" --print --no-markdown 2>/dev/null \ - || { echo '{"error": "claude invocation failed"}'; return 1; })" + local result rc=0 + result="$(echo "$prompt" | claude --model "$engine" --print --no-markdown 2>/dev/null)" || rc=$? + if [[ $rc -ne 0 ]]; then + echo "aw run: claude invocation failed" >&2 + exit 1 + fi # Validate JSON output if ! echo "$result" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then @@ -273,25 +290,38 @@ cmd_safe_output_apply() { fi local wf_file="$WORKFLOWS_DIR/${name}.md" - local allowed_labels + local allowed_labels max_labels allowed_labels="$(python3 - "$wf_file" <<'PYEOF' import sys, re, yaml, json path = sys.argv[1] -text = open(path).read() +with open(path, encoding='utf-8') as f: + text = f.read() m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) -fm = yaml.safe_load(m.group(1)) +fm = yaml.safe_load(m.group(1)) if m else {} print(json.dumps(fm.get("allowed-labels", []))) PYEOF +)" + max_labels="$(python3 - "$wf_file" <<'PYEOF' +import sys, re, yaml +path = sys.argv[1] +with open(path, encoding='utf-8') as f: + text = f.read() +m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) +fm = yaml.safe_load(m.group(1)) if m else {} +print(fm.get('max-labels', 3)) +PYEOF )" # Validate and apply via GitHub API - python3 - "$result_file" "$allowed_labels" <<'PYEOF' + python3 - "$result_file" "$allowed_labels" "$max_labels" <<'PYEOF' import sys, json result_file = sys.argv[1] allowed_set = set(json.loads(sys.argv[2])) +max_labels = int(sys.argv[3]) -result = json.load(open(result_file)) +with open(result_file, encoding='utf-8') as f: + result = json.load(f) if result.get("skip"): print("safe-output: skip flag set — no writes applied") @@ -305,8 +335,8 @@ invalid = [l for l in labels if l not in allowed_set] if invalid: print(f"safe-output: rejected — labels not in allowed set: {invalid}", file=sys.stderr) sys.exit(1) -if len(labels) > 3: - print(f"safe-output: rejected — more than 3 labels: {labels}", file=sys.stderr) +if len(labels) > max_labels: + print(f"safe-output: rejected — more than {max_labels} labels: {labels}", file=sys.stderr) sys.exit(1) if not comment: print("safe-output: rejected — empty comment", file=sys.stderr) @@ -339,16 +369,20 @@ if not r.get('skip'): print(','.join(r.get('labels',[]))) gh issue edit "$issue_number" --repo "$repo" --add-label "$labels_csv" fi - # Post comment - local comment - comment="$(echo "$result_json" | python3 -c " -import json,sys -r=json.load(sys.stdin) -if not r.get('skip'): print(r.get('comment','')) -")" - if [[ -n "$comment" ]]; then - gh issue comment "$issue_number" --repo "$repo" --body "$comment" + # Post comment via temp file to avoid ARG_MAX limits on large comment bodies + local comment_file + comment_file="$(mktemp)" + python3 - "$result_file" <<'PYEOF' > "$comment_file" +import json, sys +with open(sys.argv[1], encoding='utf-8') as f: + r = json.load(f) +c = r.get('comment', '') if not r.get('skip') else '' +sys.stdout.write(c) +PYEOF + if [[ -s "$comment_file" ]]; then + gh issue comment "$issue_number" --repo "$repo" --body-file "$comment_file" fi + rm -f "$comment_file" echo "safe-output apply: done" } diff --git a/tests/aw/issue-triage/fixtures/scenario-1-bug.json b/tests/aw/issue-triage/fixtures/scenario-1-bug.json new file mode 100644 index 000000000..b956770af --- /dev/null +++ b/tests/aw/issue-triage/fixtures/scenario-1-bug.json @@ -0,0 +1,12 @@ +{ + "issue": { + "number": 101, + "title": "Login button is broken", + "body": "When I click the login button nothing happens.", + "labels": [], + "html_url": "https://github.com/example/repo/issues/101" + }, + "repository": { + "full_name": "example/repo" + } +} diff --git a/tests/aw/issue-triage/fixtures/scenario-4-skip.json b/tests/aw/issue-triage/fixtures/scenario-4-skip.json new file mode 100644 index 000000000..cf4bb64a1 --- /dev/null +++ b/tests/aw/issue-triage/fixtures/scenario-4-skip.json @@ -0,0 +1,15 @@ +{ + "issue": { + "number": 102, + "title": "Something is wrong", + "body": "Not sure what's happening.", + "labels": [ + {"name": "bug"}, + {"name": "P1"} + ], + "html_url": "https://github.com/example/repo/issues/102" + }, + "repository": { + "full_name": "example/repo" + } +} diff --git a/tests/aw/issue-triage/test_aw_run.sh b/tests/aw/issue-triage/test_aw_run.sh new file mode 100755 index 000000000..d0bc67f00 --- /dev/null +++ b/tests/aw/issue-triage/test_aw_run.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# tests/aw/issue-triage/test_aw_run.sh +# +# Unit tests for aw.sh paths that do not require a live Claude call. +# Covers: skip guard, safe-output label validation, safe-output skip no-op. +# +# Run: bash tests/aw/issue-triage/test_aw_run.sh +set -euo pipefail + +PASS=0 +FAIL=0 +ERRORS="" + +ok() { PASS=$((PASS + 1)); printf 'PASS %s\n' "$1"; } +fail() { FAIL=$((FAIL + 1)); ERRORS="${ERRORS}FAIL $1: $2\n"; printf 'FAIL %s: %s\n' "$1" "$2"; } + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +FIXTURES="$(dirname "$0")/fixtures" + +# --------------------------------------------------------------------------- +# Test 1: Scenario 4 — issue already has 2 labels → aw run emits {"skip":true} +# --------------------------------------------------------------------------- +result=$(bash "$REPO_ROOT/scripts/aw.sh" run issue-triage \ + --fixture "$FIXTURES/scenario-4-skip.json" --staged) +skip=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('skip', False))") +if [[ "$skip" == "True" ]]; then + ok "scenario-4: 2 existing labels → skip=true" +else + fail "scenario-4: 2 existing labels → skip=true" "got skip=$skip, result=$result" +fi + +# --------------------------------------------------------------------------- +# Test 2: safe-output rejects result with more labels than max-labels (3) +# --------------------------------------------------------------------------- +tmp=$(mktemp) +printf '{"labels":["bug","needs-triage","question","enhancement"],"comment":"Hello!"}' > "$tmp" +output=$(ISSUE_NUMBER=1 GITHUB_REPOSITORY=example/repo \ + bash "$REPO_ROOT/scripts/aw.sh" safe-output apply issue-triage "$tmp" 2>&1 || true) +if echo "$output" | grep -q "rejected"; then + ok "safe-output: 4 labels exceeds max-labels:3 → rejected" +else + fail "safe-output: 4 labels exceeds max-labels:3 → rejected" "got: $output" +fi +rm -f "$tmp" + +# --------------------------------------------------------------------------- +# Test 3: safe-output rejects a label not in the allowed set +# --------------------------------------------------------------------------- +tmp=$(mktemp) +printf '{"labels":["not-a-real-label"],"comment":"Hello!"}' > "$tmp" +output=$(ISSUE_NUMBER=1 GITHUB_REPOSITORY=example/repo \ + bash "$REPO_ROOT/scripts/aw.sh" safe-output apply issue-triage "$tmp" 2>&1 || true) +if echo "$output" | grep -q "rejected"; then + ok "safe-output: disallowed label → rejected" +else + fail "safe-output: disallowed label → rejected" "got: $output" +fi +rm -f "$tmp" + +# --------------------------------------------------------------------------- +# Test 4: safe-output with skip flag → no-op, prints skip message +# --------------------------------------------------------------------------- +tmp=$(mktemp) +printf '{"skip":true}' > "$tmp" +output=$(ISSUE_NUMBER=1 GITHUB_REPOSITORY=example/repo \ + bash "$REPO_ROOT/scripts/aw.sh" safe-output apply issue-triage "$tmp" 2>&1) +if echo "$output" | grep -q "skip flag set"; then + ok "safe-output: skip flag → no writes" +else + fail "safe-output: skip flag → no writes" "got: $output" +fi +rm -f "$tmp" + +# --------------------------------------------------------------------------- +# Test 5: safe-output rejects result with empty comment +# --------------------------------------------------------------------------- +tmp=$(mktemp) +printf '{"labels":["bug"],"comment":""}' > "$tmp" +output=$(ISSUE_NUMBER=1 GITHUB_REPOSITORY=example/repo \ + bash "$REPO_ROOT/scripts/aw.sh" safe-output apply issue-triage "$tmp" 2>&1 || true) +if echo "$output" | grep -q "rejected"; then + ok "safe-output: empty comment → rejected" +else + fail "safe-output: empty comment → rejected" "got: $output" +fi +rm -f "$tmp" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -gt 0 ]]; then + printf '%b' "$ERRORS" + exit 1 +fi From a95a13fbff8a5d26df5b8abbdf828ddcd1e4673b Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 11:55:32 +0000 Subject: [PATCH 05/23] chore: apply manual instructions [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 8 ++++++++ .github/workflows/test.yml | 3 +++ scripts/aw.sh | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index bec38f9ea..decf15226 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -11,6 +11,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 permissions: + contents: read issues: read outputs: result: ${{ steps.run.outputs.result }} @@ -23,6 +24,9 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + - name: Install Python dependencies + run: pip install pyyaml --quiet + - name: Run issue-triage (staged) id: run env: @@ -47,10 +51,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 3 permissions: + contents: read issues: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install Python dependencies + run: pip install pyyaml --quiet + - name: Apply triage output (safe-output) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f011c077..c2afb474e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,5 +27,8 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Install Python dependencies + run: pip install pyyaml --quiet + - name: Compile gh-aw workflow definitions run: bash scripts/aw.sh compile --all diff --git a/scripts/aw.sh b/scripts/aw.sh index 367ea282b..90212c447 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -241,7 +241,7 @@ PYEOF # Call Claude local result rc=0 - result="$(echo "$prompt" | claude --model "$engine" --print --no-markdown 2>/dev/null)" || rc=$? + result="$(echo "$prompt" | claude --model "$engine" --print --output-format text 2>/dev/null)" || rc=$? if [[ $rc -ne 0 ]]; then echo "aw run: claude invocation failed" >&2 exit 1 From 86792aaa2faff17aa9e80499b8cf2961190548e9 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 23:01:58 +0000 Subject: [PATCH 06/23] chore: apply manual instructions [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 3 +- .github/workflows/issue-triage.md | 2 +- .github/workflows/test.yml | 4 +-- scripts/aw.sh | 37 +++++++++++++++++++++-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index decf15226..9a2ebd9a9 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -2,7 +2,7 @@ name: Issue Triage on: issues: - types: [opened, reopened] + types: [opened] # Two-job pattern: triage runs with read-only issue access and produces a # staged JSON artifact; apply runs with write access and calls safe-output. @@ -30,7 +30,6 @@ jobs: - name: Run issue-triage (staged) id: run env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_BODY: ${{ github.event.issue.body }} diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index 87e492220..f685c9a53 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -2,7 +2,7 @@ name: issue-triage trigger: issues: - types: [opened, reopened] + types: [opened] engine: claude-sonnet-4-6 permissions: issues: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2afb474e..bd3a23967 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run copilot_chat tests run: bash tests/test_copilot_chat.sh @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 3 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Python dependencies run: pip install pyyaml --quiet diff --git a/scripts/aw.sh b/scripts/aw.sh index 90212c447..c42154597 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -254,6 +254,32 @@ PYEOF exit 1 fi + # Reject skip flag from model — only the pre-Claude label-count guard may emit skip + if echo "$result" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(1) if d.get('skip') else None" 2>/dev/null; then + : # no skip flag from model, ok + else + echo "aw run: model returned skip flag — possible prompt injection, rejecting" >&2 + exit 1 + fi + + # Honor output:staged frontmatter when --staged was not explicitly passed + if [[ "$staged" == false ]]; then + local frontmatter_output + frontmatter_output="$(python3 - "$wf_file" <<'PYEOF' +import sys, re, yaml +path = sys.argv[1] +with open(path, encoding='utf-8') as f: + text = f.read() +m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) +fm = yaml.safe_load(m.group(1)) if m else {} +print(fm.get('output', 'live')) +PYEOF +)" + if [[ "$frontmatter_output" == "staged" ]]; then + staged=true + fi + fi + if [[ "$staged" == true ]]; then # Staged mode: emit JSON to stdout for review; safe-output handles writes echo "$result" @@ -330,6 +356,14 @@ if result.get("skip"): labels = result.get("labels", []) comment = result.get("comment", "") +# Validate comment is a non-empty string before any writes occur +if not isinstance(comment, str): + print(f"safe-output: rejected — comment must be a string, got {type(comment).__name__}", file=sys.stderr) + sys.exit(1) +if not comment: + print("safe-output: rejected — empty comment", file=sys.stderr) + sys.exit(1) + # Validate labels against allowed set invalid = [l for l in labels if l not in allowed_set] if invalid: @@ -338,9 +372,6 @@ if invalid: if len(labels) > max_labels: print(f"safe-output: rejected — more than {max_labels} labels: {labels}", file=sys.stderr) sys.exit(1) -if not comment: - print("safe-output: rejected — empty comment", file=sys.stderr) - sys.exit(1) print(f"safe-output: labels={labels}, comment length={len(comment)}") print("safe-output: validation passed — ready for API writes") From bbc4b2886713090e375dd4e7d9e3e03f25ab5df1 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 23:18:11 +0000 Subject: [PATCH 07/23] fix(reviews): address review comments [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 4 +++- .github/workflows/test.yml | 3 +++ scripts/aw.sh | 29 +++++++++++++++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index 9a2ebd9a9..b3d241e39 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -2,7 +2,7 @@ name: Issue Triage on: issues: - types: [opened] + types: [opened, reopened] # Two-job pattern: triage runs with read-only issue access and produces a # staged JSON artifact; apply runs with write access and calls safe-output. @@ -18,6 +18,8 @@ jobs: skip: ${{ steps.run.outputs.skip }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install claude CLI uses: anthropics/claude-code-action/setup@24492741e0ccfdef4c1d19da8e11e0f373d07494 # main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd3a23967..4642d0944 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,3 +32,6 @@ jobs: - name: Compile gh-aw workflow definitions run: bash scripts/aw.sh compile --all + + - name: Run aw issue-triage tests + run: bash tests/aw/issue-triage/test_aw_run.sh diff --git a/scripts/aw.sh b/scripts/aw.sh index c42154597..55aff3ded 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -192,6 +192,31 @@ PYEOF return 0 fi + # Skip if any existing label is in the allowed-labels set (prevents re-triage on reopen) + local allowed_json + allowed_json="$(python3 - "$wf_file" <<'PYEOF' +import sys, re, yaml, json +path = sys.argv[1] +with open(path, encoding='utf-8') as f: + text = f.read() +m = re.match(r'^---\n(.*?)\n---\n', text, re.DOTALL) +fm = yaml.safe_load(m.group(1)) if m else {} +print(json.dumps(fm.get('allowed-labels', []))) +PYEOF +)" + local has_triage_label + has_triage_label="$(AW_ALLOWED="$allowed_json" AW_LABELS="$issue_labels" python3 - <<'PYEOF' +import os, json +current = set(l for l in os.environ.get('AW_LABELS', '').split(',') if l) +allowed = set(json.loads(os.environ.get('AW_ALLOWED', '[]'))) +print('true' if current & allowed else 'false') +PYEOF +)" + if [[ "$has_triage_label" == "true" ]]; then + echo '{"skip": true}' + return 0 + fi + # Substitute variables into workflow body. # Use a quoted heredoc and env vars so user-controlled issue data cannot # escape into Python source code (code injection hotspot). @@ -220,8 +245,8 @@ subs = { 'ISSUE_URL': os.environ.get('AW_ISSUE_URL', ''), 'REPO': os.environ.get('AW_REPO', ''), } -for k, v in subs.items(): - body = body.replace('${' + k + '}', v) +pattern = re.compile('|'.join(re.escape('${' + k + '}') for k in subs)) +body = pattern.sub(lambda m: subs[m.group(0)[2:-1]], body) print(body) PYEOF )" From 43c8ade6150f13b9994a4cb926fadeaac71918ef Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 23:27:37 +0000 Subject: [PATCH 08/23] fix(reviews): address review comments [skip ci-relay] --- scripts/aw.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/aw.sh b/scripts/aw.sh index 55aff3ded..5792938e6 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -151,7 +151,7 @@ cmd_run() { fi # Build context from environment or fixture - local issue_number issue_title issue_body issue_labels issue_url repo + local issue_number issue_title issue_body issue_labels issue_url repo issue_action if [[ -n "$fixture" ]]; then # Pass path via env var to avoid injecting user-controlled paths into Python -c source issue_number="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['number'])")" @@ -160,6 +160,7 @@ cmd_run() { issue_labels="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(','.join(l['name'] for l in d['issue'].get('labels',[])))")" issue_url="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['html_url'])")" repo="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['repository']['full_name'])")" + issue_action="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d.get('action','opened'))")" else issue_number="${ISSUE_NUMBER:-}" issue_title="${ISSUE_TITLE:-}" @@ -167,6 +168,7 @@ cmd_run() { issue_labels="${ISSUE_LABELS:-}" issue_url="${ISSUE_URL:-https://github.com/${GITHUB_REPOSITORY:-unknown}/issues/${ISSUE_NUMBER:-0}}" repo="${GITHUB_REPOSITORY:-unknown}" + issue_action="${ISSUE_ACTION:-opened}" fi # Skip guard: read threshold from frontmatter (default 2), skip if label count >= threshold @@ -212,7 +214,7 @@ allowed = set(json.loads(os.environ.get('AW_ALLOWED', '[]'))) print('true' if current & allowed else 'false') PYEOF )" - if [[ "$has_triage_label" == "true" ]]; then + if [[ "$has_triage_label" == "true" && "$issue_action" == "reopened" ]]; then echo '{"skip": true}' return 0 fi @@ -381,6 +383,11 @@ if result.get("skip"): labels = result.get("labels", []) comment = result.get("comment", "") +# Validate labels is a JSON array of strings before any further checks +if not isinstance(labels, list) or not all(isinstance(l, str) for l in labels): + print(f"safe-output: rejected — labels must be a JSON array of strings, got {type(labels).__name__}", file=sys.stderr) + sys.exit(1) + # Validate comment is a non-empty string before any writes occur if not isinstance(comment, str): print(f"safe-output: rejected — comment must be a string, got {type(comment).__name__}", file=sys.stderr) From 73d9979db081aded47121b40b35a89c51fcb2f96 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 23:39:53 +0000 Subject: [PATCH 09/23] fix(reviews): address review comments [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index b3d241e39..84e8c7de2 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -37,6 +37,7 @@ jobs: ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ',') }} ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_ACTION: ${{ github.event.action }} GITHUB_REPOSITORY: ${{ github.repository }} run: | set -euo pipefail From 76b0b2e2885af31090535317c863514d89d7399e Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 23:46:20 +0000 Subject: [PATCH 10/23] fix(bot): address bot feedback [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 2 ++ .github/workflows/test.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index 84e8c7de2..9128540d9 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -57,6 +57,8 @@ jobs: issues: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install Python dependencies run: pip install pyyaml --quiet diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4642d0944..af3e85912 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,8 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Run copilot_chat tests run: bash tests/test_copilot_chat.sh @@ -26,6 +28,8 @@ jobs: timeout-minutes: 3 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install Python dependencies run: pip install pyyaml --quiet From 44dd640fa4bee3fbfc01d5a94e8ae9273c67fcbd Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 23:51:54 +0000 Subject: [PATCH 11/23] fix(bot): address bot feedback [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index 9128540d9..ce31ab4be 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -70,5 +70,7 @@ jobs: ISSUE_NUMBER: ${{ github.event.issue.number }} TRIAGE_RESULT: ${{ needs.triage.outputs.result }} run: | - echo "$TRIAGE_RESULT" | base64 -d > /tmp/triage-result.json - bash scripts/aw.sh safe-output apply issue-triage /tmp/triage-result.json + result_file=$(mktemp) + echo "$TRIAGE_RESULT" | base64 -d > "$result_file" + bash scripts/aw.sh safe-output apply issue-triage "$result_file" + rm -f "$result_file" From 84798b9a4588ed03d95a138d245616c9d4f6a70d Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 23:56:19 +0000 Subject: [PATCH 12/23] fix(ci): auto-fix for SonarCloud Code Analysis [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 7 +- scripts/aw.sh | 79 ++++++++++++++++++----- tests/aw/issue-triage/test_aw_run.sh | 6 +- 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index ce31ab4be..1b7a1b553 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -42,7 +42,12 @@ jobs: run: | set -euo pipefail result=$(bash scripts/aw.sh run issue-triage --staged) - skip=$(echo "$result" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('skip') else 'false')") + skip=$(echo "$result" | python3 - <<'PYEOF' +import json, sys +d = json.load(sys.stdin) +print('true' if d.get('skip') else 'false') +PYEOF +) # Store encoded to survive multi-line JSON in GITHUB_OUTPUT echo "result=$(echo "$result" | base64 -w0)" >> "$GITHUB_OUTPUT" echo "skip=$skip" >> "$GITHUB_OUTPUT" diff --git a/scripts/aw.sh b/scripts/aw.sh index 5792938e6..d5f87f51a 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -153,14 +153,49 @@ cmd_run() { # Build context from environment or fixture local issue_number issue_title issue_body issue_labels issue_url repo issue_action if [[ -n "$fixture" ]]; then - # Pass path via env var to avoid injecting user-controlled paths into Python -c source - issue_number="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['number'])")" - issue_title="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['title'])")" - issue_body="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['body'] or '')")" - issue_labels="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(','.join(l['name'] for l in d['issue'].get('labels',[])))")" - issue_url="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['issue']['html_url'])")" - repo="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d['repository']['full_name'])")" - issue_action="$(AW_FIXTURE="$fixture" python3 -c "import json,os; d=json.load(open(os.environ['AW_FIXTURE'])); print(d.get('action','opened'))")" + # Pass path via env var to avoid injecting user-controlled paths into Python source + issue_number="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' +import json, os +d = json.load(open(os.environ['AW_FIXTURE'])) +print(d['issue']['number']) +PYEOF +)" + issue_title="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' +import json, os +d = json.load(open(os.environ['AW_FIXTURE'])) +print(d['issue']['title']) +PYEOF +)" + issue_body="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' +import json, os +d = json.load(open(os.environ['AW_FIXTURE'])) +print(d['issue']['body'] or '') +PYEOF +)" + issue_labels="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' +import json, os +d = json.load(open(os.environ['AW_FIXTURE'])) +print(','.join(l['name'] for l in d['issue'].get('labels', []))) +PYEOF +)" + issue_url="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' +import json, os +d = json.load(open(os.environ['AW_FIXTURE'])) +print(d['issue']['html_url']) +PYEOF +)" + repo="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' +import json, os +d = json.load(open(os.environ['AW_FIXTURE'])) +print(d['repository']['full_name']) +PYEOF +)" + issue_action="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' +import json, os +d = json.load(open(os.environ['AW_FIXTURE'])) +print(d.get('action', 'opened')) +PYEOF +)" else issue_number="${ISSUE_NUMBER:-}" issue_title="${ISSUE_TITLE:-}" @@ -173,12 +208,13 @@ cmd_run() { # Skip guard: read threshold from frontmatter (default 2), skip if label count >= threshold local label_count skip_threshold - label_count="$(echo "$issue_labels" | python3 -c " + label_count="$(echo "$issue_labels" | python3 - <<'PYEOF' import sys raw = sys.stdin.read().strip() labels = [l for l in raw.split(',') if l] print(len(labels)) -")" +PYEOF +)" skip_threshold="$(python3 - "$wf_file" <<'PYEOF' import sys, re, yaml path = sys.argv[1] @@ -275,14 +311,21 @@ PYEOF fi # Validate JSON output - if ! echo "$result" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then + if ! echo "$result" | python3 - 2>/dev/null <<'PYEOF'; then +import json, sys +json.load(sys.stdin) +PYEOF echo "aw run: claude returned non-JSON output" >&2 echo "$result" >&2 exit 1 fi # Reject skip flag from model — only the pre-Claude label-count guard may emit skip - if echo "$result" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(1) if d.get('skip') else None" 2>/dev/null; then + if echo "$result" | python3 - 2>/dev/null <<'PYEOF'; then +import json, sys +d = json.load(sys.stdin) +sys.exit(1) if d.get('skip') else None +PYEOF : # no skip flag from model, ok else echo "aw run: model returned skip flag — possible prompt injection, rejecting" >&2 @@ -423,11 +466,13 @@ PYEOF # Apply labels — pass as comma-separated string so the argument stays quoted local labels_csv - labels_csv="$(echo "$result_json" | python3 -c " -import json,sys -r=json.load(sys.stdin) -if not r.get('skip'): print(','.join(r.get('labels',[]))) -")" + labels_csv="$(echo "$result_json" | python3 - <<'PYEOF' +import json, sys +r = json.load(sys.stdin) +if not r.get('skip'): + print(','.join(r.get('labels', []))) +PYEOF +)" if [[ -n "$labels_csv" ]]; then gh issue edit "$issue_number" --repo "$repo" --add-label "$labels_csv" fi diff --git a/tests/aw/issue-triage/test_aw_run.sh b/tests/aw/issue-triage/test_aw_run.sh index d0bc67f00..1eb37c664 100755 --- a/tests/aw/issue-triage/test_aw_run.sh +++ b/tests/aw/issue-triage/test_aw_run.sh @@ -22,7 +22,11 @@ FIXTURES="$(dirname "$0")/fixtures" # --------------------------------------------------------------------------- result=$(bash "$REPO_ROOT/scripts/aw.sh" run issue-triage \ --fixture "$FIXTURES/scenario-4-skip.json" --staged) -skip=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin).get('skip', False))") +skip=$(echo "$result" | python3 - <<'PYEOF' +import json, sys +print(json.load(sys.stdin).get('skip', False)) +PYEOF +) if [[ "$skip" == "True" ]]; then ok "scenario-4: 2 existing labels → skip=true" else From a7aff0a6b6c4e6645ee5cdf6b3657c8cfee4d797 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Tue, 19 May 2026 23:59:20 +0000 Subject: [PATCH 13/23] fix(ci): auto-fix for SonarCloud Code Analysis [skip ci-relay] --- scripts/aw.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/aw.sh b/scripts/aw.sh index d5f87f51a..d80dd51e8 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -144,6 +144,12 @@ cmd_run() { exit 1 fi + # Validate workflow name to prevent path traversal + if [[ ! "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then + echo "aw run: invalid workflow name: $name" >&2 + exit 1 + fi + local wf_file="$WORKFLOWS_DIR/${name}.md" if [[ ! -f "$wf_file" ]]; then echo "aw run: workflow file not found: $wf_file" >&2 @@ -302,6 +308,12 @@ print(fm.get("engine", "claude-sonnet-4-6")) PYEOF )" + # Validate engine to prevent command injection before passing to claude + if [[ ! "$engine" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then + echo "aw run: invalid engine value: $engine" >&2 + exit 1 + fi + # Call Claude local result rc=0 result="$(echo "$prompt" | claude --model "$engine" --print --output-format text 2>/dev/null)" || rc=$? @@ -461,6 +473,16 @@ PYEOF exit 1 fi + # Validate inputs before passing to gh CLI to prevent command injection + if [[ ! "$issue_number" =~ ^[0-9]+$ ]]; then + echo "safe-output apply: ISSUE_NUMBER must be a positive integer, got: $issue_number" >&2 + exit 1 + fi + if [[ ! "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "safe-output apply: GITHUB_REPOSITORY has unexpected format: $repo" >&2 + exit 1 + fi + local result_json result_json="$(cat "$result_file")" From b9da23037c9b785180b324d51213d61776265270 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Wed, 20 May 2026 00:08:26 +0000 Subject: [PATCH 14/23] fix(ci): auto-fix for SonarCloud Code Analysis [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 11 +++-- scripts/aw.sh | 56 +++++++++++++---------- tests/aw/issue-triage/test_aw_run.sh | 6 +-- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index 1b7a1b553..3d6abef9a 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -42,14 +42,15 @@ jobs: run: | set -euo pipefail result=$(bash scripts/aw.sh run issue-triage --staged) - skip=$(echo "$result" | python3 - <<'PYEOF' -import json, sys -d = json.load(sys.stdin) + skip=$(AW_RESULT="$result" python3 - <<'PYEOF' +import json, os +d = json.loads(os.environ['AW_RESULT']) print('true' if d.get('skip') else 'false') PYEOF ) # Store encoded to survive multi-line JSON in GITHUB_OUTPUT - echo "result=$(echo "$result" | base64 -w0)" >> "$GITHUB_OUTPUT" + encoded=$(printf '%s\n' "$result" | base64 -w0) + echo "result=$encoded" >> "$GITHUB_OUTPUT" echo "skip=$skip" >> "$GITHUB_OUTPUT" apply: @@ -76,6 +77,6 @@ PYEOF TRIAGE_RESULT: ${{ needs.triage.outputs.result }} run: | result_file=$(mktemp) - echo "$TRIAGE_RESULT" | base64 -d > "$result_file" + printf '%s\n' "$TRIAGE_RESULT" | base64 -d > "$result_file" bash scripts/aw.sh safe-output apply issue-triage "$result_file" rm -f "$result_file" diff --git a/scripts/aw.sh b/scripts/aw.sh index d80dd51e8..6f7131785 100755 --- a/scripts/aw.sh +++ b/scripts/aw.sh @@ -162,43 +162,50 @@ cmd_run() { # Pass path via env var to avoid injecting user-controlled paths into Python source issue_number="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' import json, os -d = json.load(open(os.environ['AW_FIXTURE'])) +with open(os.environ['AW_FIXTURE'], encoding='utf-8') as fh: + d = json.load(fh) print(d['issue']['number']) PYEOF )" issue_title="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' import json, os -d = json.load(open(os.environ['AW_FIXTURE'])) +with open(os.environ['AW_FIXTURE'], encoding='utf-8') as fh: + d = json.load(fh) print(d['issue']['title']) PYEOF )" issue_body="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' import json, os -d = json.load(open(os.environ['AW_FIXTURE'])) +with open(os.environ['AW_FIXTURE'], encoding='utf-8') as fh: + d = json.load(fh) print(d['issue']['body'] or '') PYEOF )" issue_labels="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' import json, os -d = json.load(open(os.environ['AW_FIXTURE'])) +with open(os.environ['AW_FIXTURE'], encoding='utf-8') as fh: + d = json.load(fh) print(','.join(l['name'] for l in d['issue'].get('labels', []))) PYEOF )" issue_url="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' import json, os -d = json.load(open(os.environ['AW_FIXTURE'])) +with open(os.environ['AW_FIXTURE'], encoding='utf-8') as fh: + d = json.load(fh) print(d['issue']['html_url']) PYEOF )" repo="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' import json, os -d = json.load(open(os.environ['AW_FIXTURE'])) +with open(os.environ['AW_FIXTURE'], encoding='utf-8') as fh: + d = json.load(fh) print(d['repository']['full_name']) PYEOF )" issue_action="$(AW_FIXTURE="$fixture" python3 - <<'PYEOF' import json, os -d = json.load(open(os.environ['AW_FIXTURE'])) +with open(os.environ['AW_FIXTURE'], encoding='utf-8') as fh: + d = json.load(fh) print(d.get('action', 'opened')) PYEOF )" @@ -214,9 +221,9 @@ PYEOF # Skip guard: read threshold from frontmatter (default 2), skip if label count >= threshold local label_count skip_threshold - label_count="$(echo "$issue_labels" | python3 - <<'PYEOF' -import sys -raw = sys.stdin.read().strip() + label_count="$(AW_LABELS="$issue_labels" python3 - <<'PYEOF' +import os +raw = os.environ.get('AW_LABELS', '').strip() labels = [l for l in raw.split(',') if l] print(len(labels)) PYEOF @@ -314,18 +321,21 @@ PYEOF exit 1 fi - # Call Claude - local result rc=0 - result="$(echo "$prompt" | claude --model "$engine" --print --output-format text 2>/dev/null)" || rc=$? + # Call Claude — write prompt to a temp file to avoid echo with user-controlled data + local result rc=0 prompt_file + prompt_file="$(mktemp)" + printf '%s\n' "$prompt" > "$prompt_file" + result="$(claude --model "$engine" --print --output-format text < "$prompt_file" 2>/dev/null)" || rc=$? + rm -f "$prompt_file" if [[ $rc -ne 0 ]]; then echo "aw run: claude invocation failed" >&2 exit 1 fi # Validate JSON output - if ! echo "$result" | python3 - 2>/dev/null <<'PYEOF'; then -import json, sys -json.load(sys.stdin) + if ! AW_RESULT="$result" python3 - 2>/dev/null <<'PYEOF'; then +import json, os +json.loads(os.environ['AW_RESULT']) PYEOF echo "aw run: claude returned non-JSON output" >&2 echo "$result" >&2 @@ -333,9 +343,9 @@ PYEOF fi # Reject skip flag from model — only the pre-Claude label-count guard may emit skip - if echo "$result" | python3 - 2>/dev/null <<'PYEOF'; then -import json, sys -d = json.load(sys.stdin) + if AW_RESULT="$result" python3 - 2>/dev/null <<'PYEOF'; then +import json, os, sys +d = json.loads(os.environ['AW_RESULT']) sys.exit(1) if d.get('skip') else None PYEOF : # no skip flag from model, ok @@ -488,9 +498,9 @@ PYEOF # Apply labels — pass as comma-separated string so the argument stays quoted local labels_csv - labels_csv="$(echo "$result_json" | python3 - <<'PYEOF' -import json, sys -r = json.load(sys.stdin) + labels_csv="$(AW_RESULT_JSON="$result_json" python3 - <<'PYEOF' +import json, os +r = json.loads(os.environ['AW_RESULT_JSON']) if not r.get('skip'): print(','.join(r.get('labels', []))) PYEOF @@ -522,7 +532,7 @@ apply_result() { local name="$1" result="$2" issue_number="$3" local tmp tmp="$(mktemp)" - echo "$result" > "$tmp" + printf '%s\n' "$result" > "$tmp" ISSUE_NUMBER="$issue_number" cmd_safe_output_apply "$name" "$tmp" rm -f "$tmp" } diff --git a/tests/aw/issue-triage/test_aw_run.sh b/tests/aw/issue-triage/test_aw_run.sh index 1eb37c664..6f54b6b19 100755 --- a/tests/aw/issue-triage/test_aw_run.sh +++ b/tests/aw/issue-triage/test_aw_run.sh @@ -22,9 +22,9 @@ FIXTURES="$(dirname "$0")/fixtures" # --------------------------------------------------------------------------- result=$(bash "$REPO_ROOT/scripts/aw.sh" run issue-triage \ --fixture "$FIXTURES/scenario-4-skip.json" --staged) -skip=$(echo "$result" | python3 - <<'PYEOF' -import json, sys -print(json.load(sys.stdin).get('skip', False)) +skip=$(AW_RESULT="$result" python3 - <<'PYEOF' +import json, os +print(json.loads(os.environ['AW_RESULT']).get('skip', False)) PYEOF ) if [[ "$skip" == "True" ]]; then From e606888c604fb7fbd9dd639bca53d85fb3b9bdfe Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Wed, 20 May 2026 01:34:03 +0000 Subject: [PATCH 15/23] chore: apply manual instructions [skip ci-relay] --- .github/workflows/issue-triage-runner.yml | 4 ++-- .github/workflows/test.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-triage-runner.yml b/.github/workflows/issue-triage-runner.yml index 3d6abef9a..e2ca17771 100644 --- a/.github/workflows/issue-triage-runner.yml +++ b/.github/workflows/issue-triage-runner.yml @@ -27,7 +27,7 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - name: Install Python dependencies - run: pip install pyyaml --quiet + run: pip install "pyyaml==6.0.3" --only-binary :all: --quiet - name: Run issue-triage (staged) id: run @@ -67,7 +67,7 @@ PYEOF persist-credentials: false - name: Install Python dependencies - run: pip install pyyaml --quiet + run: pip install "pyyaml==6.0.3" --only-binary :all: --quiet - name: Apply triage output (safe-output) env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af3e85912..3db2e49f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Install Python dependencies - run: pip install pyyaml --quiet + run: pip install "pyyaml==6.0.3" --only-binary :all: --quiet - name: Compile gh-aw workflow definitions run: bash scripts/aw.sh compile --all From e57f55c0a5e91707982578721cdf009899444a51 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 20 May 2026 07:34:25 -0500 Subject: [PATCH 16/23] chore: sync ci.yml with main to resolve merge conflict --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21363b4e2..b3dcfe811 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: - name: Lint YAML run: | pip install --quiet --only-binary :all: "yamllint==1.38.0" # v1.38.0 + shopt -s extglob yamllint -c <(cat <<'YAMLLINTRC' extends: default rules: @@ -43,7 +44,7 @@ jobs: comments: min-spaces-from-content: 1 YAMLLINTRC - ) .github/workflows/ + ) .github/workflows/!(*.lock).yml - name: Lint GitHub Actions run: | @@ -55,7 +56,8 @@ jobs: tar xzf actionlint.tar.gz actionlint chmod +x actionlint rm actionlint.tar.gz - ./actionlint -ignore 'SC2129' .github/workflows/*.yml + shopt -s extglob + ./actionlint -ignore 'SC2129' .github/workflows/!(*.lock).yml shellcheck: name: ShellCheck @@ -130,3 +132,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + From 3ac105af5712bde2b31178c70f375eb7dafacae2 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 20 May 2026 07:35:08 -0500 Subject: [PATCH 17/23] chore: sync test.yml with main to resolve merge conflict --- .github/workflows/test.yml | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3db2e49f2..bcf7efafa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,29 +13,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + - uses: actions/checkout@v6.0.2 - name: Run copilot_chat tests run: bash tests/test_copilot_chat.sh - name: Run list-prs sort tests run: bash tests/test_list_prs_sort.sh - - aw-compile: - runs-on: ubuntu-latest - timeout-minutes: 3 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Install Python dependencies - run: pip install "pyyaml==6.0.3" --only-binary :all: --quiet - - - name: Compile gh-aw workflow definitions - run: bash scripts/aw.sh compile --all - - - name: Run aw issue-triage tests - run: bash tests/aw/issue-triage/test_aw_run.sh From a82c83603b8b7fb61c9aea7ef0984b34dca0c8c7 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 20 May 2026 07:37:30 -0500 Subject: [PATCH 18/23] fix(aw): remove 'name' field from issue-triage.md frontmatter (not allowed in gh-aw) --- .github/workflows/issue-triage.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index f685c9a53..6ac052077 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -1,5 +1,4 @@ --- -name: issue-triage trigger: issues: types: [opened] From 5da1d8fc09278e942dda946be415cd644ef0e32c Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 20 May 2026 07:40:09 -0500 Subject: [PATCH 19/23] fix(aw): correct issue-triage.md frontmatter for gh-aw schema (on:, safe-outputs) --- .github/workflows/issue-triage.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index 6ac052077..a994451c0 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -1,4 +1,25 @@ --- +on: + issues: + types: [opened] +engine: claude-sonnet-4-6 +permissions: + issues: read +safe-outputs: + label-issue: + enabled: true + max-labels: 3 + allowed-labels: + - bug + - enhancement + - documentation + - question + - needs-triage + - good-first-issue + - security + +--- + trigger: issues: types: [opened] @@ -72,4 +93,4 @@ For a normal triage: {"labels": ["label1", "label2"], "comment": "Your welcoming comment here."} For a skip (issue already has 2+ labels): -{"skip": true} +{"skip": true} \ No newline at end of file From 64c85943d948c52eaf9f23be1de6cc9395c7341f Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 20 May 2026 07:41:56 -0500 Subject: [PATCH 20/23] fix(aw): use minimal valid gh-aw frontmatter for issue-triage.md --- .github/workflows/issue-triage.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index a994451c0..387e92df9 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -6,20 +6,11 @@ engine: claude-sonnet-4-6 permissions: issues: read safe-outputs: - label-issue: + add-labels: enabled: true - max-labels: 3 - allowed-labels: - - bug - - enhancement - - documentation - - question - - needs-triage - - good-first-issue - - security - --- + trigger: issues: types: [opened] From da227175ce1c683980bb6cfbdf6f60752d9053b0 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 20 May 2026 07:43:01 -0500 Subject: [PATCH 21/23] fix(aw): use correct add-labels schema in issue-triage.md --- .github/workflows/issue-triage.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index 387e92df9..3ab11ff25 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -7,10 +7,19 @@ permissions: issues: read safe-outputs: add-labels: - enabled: true + allowed: + - bug + - enhancement + - documentation + - question + - needs-triage + - good-first-issue + - security + max: 3 --- + trigger: issues: types: [opened] From 306fe5872573e4adf0e45acbf1186594b6978662 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 20 May 2026 08:54:56 -0500 Subject: [PATCH 22/23] fix(aw): remove stale frontmatter from body of issue-triage.md --- .github/workflows/issue-triage.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index 3ab11ff25..8e6d6ca65 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -20,6 +20,7 @@ safe-outputs: + trigger: issues: types: [opened] @@ -93,4 +94,4 @@ For a normal triage: {"labels": ["label1", "label2"], "comment": "Your welcoming comment here."} For a skip (issue already has 2+ labels): -{"skip": true} \ No newline at end of file +{"skip": true} From 5eac9c3a191fe261d4eb9164e49123a6b0f24eb0 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 20 May 2026 08:57:02 -0500 Subject: [PATCH 23/23] =?UTF-8?q?fix(aw):=20clean=20up=20issue-triage.md?= =?UTF-8?q?=20=E2=80=94=20remove=20duplicate=20frontmatter=20from=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue-triage.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index 8e6d6ca65..c5420fe86 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -18,27 +18,6 @@ safe-outputs: max: 3 --- - - - -trigger: - issues: - types: [opened] -engine: claude-sonnet-4-6 -permissions: - issues: read -output: staged -allowed-labels: - - bug - - enhancement - - documentation - - question - - needs-triage - - good-first-issue - - security -max-labels: 3 ---- - # Issue Triage You are triaging a new GitHub issue for the `${REPO}` repository.