diff --git a/.github/scripts/issue_format.py b/.github/scripts/issue_format.py index feb5170fd..a3f68f851 100644 --- a/.github/scripts/issue_format.py +++ b/.github/scripts/issue_format.py @@ -115,8 +115,10 @@ def _concrete_span(span: str) -> bool: return True if "_" in span or "." in span: return True - # Multi-segment CamelCase / PascalCase symbols (e.g. IssueFormatter). - return re.fullmatch(r"[A-Z][a-zA-Z0-9]*(?:[A-Z][a-zA-Z0-9]+)+", span) is not None + # Multi-segment PascalCase or lowerCamelCase symbols (e.g. IssueFormatter, + # calculateDiscount). A capitalized interior segment distinguishes them from + # generic lowercase prose. + return re.fullmatch(r"[A-Za-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]+", span) is not None def _task_has_concrete_target(item: str) -> bool: @@ -130,6 +132,13 @@ def _task_has_concrete_target(item: str) -> bool: for match in re.finditer(r"`([^`]+)`", item): if _concrete_span(match.group(1)): return True + for token in re.findall(r"\b[A-Za-z][A-Za-z0-9_]*\b", item): + # Unquoted: only unambiguous lowerCamelCase (calculateDiscount). + # Brand/prose capitals (GitHub, JavaScript, OpenAI) must not satisfy. + if not re.fullmatch(r"[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]+", token): + continue + if _concrete_span(token): + return True if re.search(rf"(?:^|[\s])({_TASK_KNOWN_BASENAME})\b", item, re.I): return True if re.search(rf"(?:^|[\s])([\w.-]+\.(?:{_TASK_EXTENSION}))\b", item, re.I): diff --git a/.github/workflows/agents-issue-format-guard.yml b/.github/workflows/agents-issue-format-guard.yml index ebf03c9c0..089672ed6 100644 --- a/.github/workflows/agents-issue-format-guard.yml +++ b/.github/workflows/agents-issue-format-guard.yml @@ -42,13 +42,6 @@ jobs: with: persist-credentials: false - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - # This guard only reads issue comments with the workflow token. Do not - # expose the repository-wide secret bundle to the composite action. - github_token: ${{ github.token }} - - name: Resolve issue id: issue env: @@ -77,8 +70,17 @@ jobs: echo "number=$NUMBER" echo "exempt=$exempt" echo "held=$held" + echo "state=$(jq -r '.state' issue.json)" } >> "$GITHUB_OUTPUT" + - name: Setup API client + if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' + uses: ./.github/actions/setup-api-client + with: + # This guard only reads issue comments with the workflow token. Do not + # expose the repository-wide secret bundle to the composite action. + github_token: ${{ github.token }} + - name: Validate against AGENT_ISSUE_FORMAT id: validate if: steps.issue.outputs.exempt != 'true' @@ -111,12 +113,36 @@ jobs: - name: Invalidate stale format completion after an invalid issue change if: >- - steps.issue.outputs.exempt != 'true' && steps.validate.outputs.rc == '1' + steps.issue.outputs.exempt != 'true' && steps.issue.outputs.state == 'OPEN' && + steps.validate.outputs.rc == '1' env: GH_TOKEN: ${{ github.token }} NUMBER: ${{ steps.issue.outputs.number }} run: | set -euo pipefail + live_issue="$(mktemp)" + live_body="$(mktemp)" + error_file="$(mktemp)" + trap 'rm -f "$live_issue" "$live_body" "$error_file"' EXIT + gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json body,state > "$live_issue" + if [[ "$(jq -r '.state' "$live_issue")" != "OPEN" ]]; then + echo "Issue is now closed — preserving agents:formatted." + exit 0 + fi + jq -r '.body // ""' "$live_issue" > "$live_body" + set +e + python3 .github/scripts/issue_format.py "$live_body" > /dev/null 2> "$error_file" + live_rc=$? + set -e + if [[ "$live_rc" -eq 0 ]]; then + echo "Live body now conforms — preserving agents:formatted." + exit 0 + fi + if [[ "$live_rc" -ne 1 || -s "$error_file" ]]; then + echo "::error::issue-format validator failed unexpectedly during label revalidation" + cat "$error_file" >&2 + exit "$live_rc" + fi if gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \ --jq '.labels[].name' | grep -qx 'agents:formatted'; then if gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "agents:formatted"; then @@ -127,7 +153,7 @@ jobs: fi - name: Route non-conforming issue to the optimizer - if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' && steps.validate.outputs.rc == '1' + if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' && steps.issue.outputs.state == 'OPEN' && steps.validate.outputs.rc == '1' env: GH_TOKEN: ${{ github.token }} NUMBER: ${{ steps.issue.outputs.number }} @@ -138,6 +164,10 @@ jobs: gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" \ --json number,body,labels,state,author > live.json jq -r '.body // ""' live.json > body.md + if [[ "$(jq -r '.state' live.json)" != "OPEN" ]]; then + echo "Issue is now closed — skipping optimizer dispatch." + exit 0 + fi if jq -e '[.labels[].name] | any(. == "agents:auto-pilot-pause" or . == "needs-human")' live.json >/dev/null; then echo "Issue is now held — skipping optimizer dispatch." exit 0 diff --git a/templates/consumer-repo/.github/scripts/issue_format.py b/templates/consumer-repo/.github/scripts/issue_format.py index feb5170fd..a3f68f851 100644 --- a/templates/consumer-repo/.github/scripts/issue_format.py +++ b/templates/consumer-repo/.github/scripts/issue_format.py @@ -115,8 +115,10 @@ def _concrete_span(span: str) -> bool: return True if "_" in span or "." in span: return True - # Multi-segment CamelCase / PascalCase symbols (e.g. IssueFormatter). - return re.fullmatch(r"[A-Z][a-zA-Z0-9]*(?:[A-Z][a-zA-Z0-9]+)+", span) is not None + # Multi-segment PascalCase or lowerCamelCase symbols (e.g. IssueFormatter, + # calculateDiscount). A capitalized interior segment distinguishes them from + # generic lowercase prose. + return re.fullmatch(r"[A-Za-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]+", span) is not None def _task_has_concrete_target(item: str) -> bool: @@ -130,6 +132,13 @@ def _task_has_concrete_target(item: str) -> bool: for match in re.finditer(r"`([^`]+)`", item): if _concrete_span(match.group(1)): return True + for token in re.findall(r"\b[A-Za-z][A-Za-z0-9_]*\b", item): + # Unquoted: only unambiguous lowerCamelCase (calculateDiscount). + # Brand/prose capitals (GitHub, JavaScript, OpenAI) must not satisfy. + if not re.fullmatch(r"[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]+", token): + continue + if _concrete_span(token): + return True if re.search(rf"(?:^|[\s])({_TASK_KNOWN_BASENAME})\b", item, re.I): return True if re.search(rf"(?:^|[\s])([\w.-]+\.(?:{_TASK_EXTENSION}))\b", item, re.I): diff --git a/templates/consumer-repo/.github/workflows/agents-issue-format-guard.yml b/templates/consumer-repo/.github/workflows/agents-issue-format-guard.yml index ebf03c9c0..089672ed6 100644 --- a/templates/consumer-repo/.github/workflows/agents-issue-format-guard.yml +++ b/templates/consumer-repo/.github/workflows/agents-issue-format-guard.yml @@ -42,13 +42,6 @@ jobs: with: persist-credentials: false - - name: Setup API client - uses: ./.github/actions/setup-api-client - with: - # This guard only reads issue comments with the workflow token. Do not - # expose the repository-wide secret bundle to the composite action. - github_token: ${{ github.token }} - - name: Resolve issue id: issue env: @@ -77,8 +70,17 @@ jobs: echo "number=$NUMBER" echo "exempt=$exempt" echo "held=$held" + echo "state=$(jq -r '.state' issue.json)" } >> "$GITHUB_OUTPUT" + - name: Setup API client + if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' + uses: ./.github/actions/setup-api-client + with: + # This guard only reads issue comments with the workflow token. Do not + # expose the repository-wide secret bundle to the composite action. + github_token: ${{ github.token }} + - name: Validate against AGENT_ISSUE_FORMAT id: validate if: steps.issue.outputs.exempt != 'true' @@ -111,12 +113,36 @@ jobs: - name: Invalidate stale format completion after an invalid issue change if: >- - steps.issue.outputs.exempt != 'true' && steps.validate.outputs.rc == '1' + steps.issue.outputs.exempt != 'true' && steps.issue.outputs.state == 'OPEN' && + steps.validate.outputs.rc == '1' env: GH_TOKEN: ${{ github.token }} NUMBER: ${{ steps.issue.outputs.number }} run: | set -euo pipefail + live_issue="$(mktemp)" + live_body="$(mktemp)" + error_file="$(mktemp)" + trap 'rm -f "$live_issue" "$live_body" "$error_file"' EXIT + gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json body,state > "$live_issue" + if [[ "$(jq -r '.state' "$live_issue")" != "OPEN" ]]; then + echo "Issue is now closed — preserving agents:formatted." + exit 0 + fi + jq -r '.body // ""' "$live_issue" > "$live_body" + set +e + python3 .github/scripts/issue_format.py "$live_body" > /dev/null 2> "$error_file" + live_rc=$? + set -e + if [[ "$live_rc" -eq 0 ]]; then + echo "Live body now conforms — preserving agents:formatted." + exit 0 + fi + if [[ "$live_rc" -ne 1 || -s "$error_file" ]]; then + echo "::error::issue-format validator failed unexpectedly during label revalidation" + cat "$error_file" >&2 + exit "$live_rc" + fi if gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \ --jq '.labels[].name' | grep -qx 'agents:formatted'; then if gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "agents:formatted"; then @@ -127,7 +153,7 @@ jobs: fi - name: Route non-conforming issue to the optimizer - if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' && steps.validate.outputs.rc == '1' + if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' && steps.issue.outputs.state == 'OPEN' && steps.validate.outputs.rc == '1' env: GH_TOKEN: ${{ github.token }} NUMBER: ${{ steps.issue.outputs.number }} @@ -138,6 +164,10 @@ jobs: gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" \ --json number,body,labels,state,author > live.json jq -r '.body // ""' live.json > body.md + if [[ "$(jq -r '.state' live.json)" != "OPEN" ]]; then + echo "Issue is now closed — skipping optimizer dispatch." + exit 0 + fi if jq -e '[.labels[].name] | any(. == "agents:auto-pilot-pause" or . == "needs-human")' live.json >/dev/null; then echo "Issue is now held — skipping optimizer dispatch." exit 0 diff --git a/tests/scripts/test_issue_format.py b/tests/scripts/test_issue_format.py index 98bb5ba9f..b6aeca863 100644 --- a/tests/scripts/test_issue_format.py +++ b/tests/scripts/test_issue_format.py @@ -172,6 +172,8 @@ def test_task_without_concrete_target_is_non_conforming() -> None: "Fix bugs `later`", "Fix file handling", "Update the configuration", + "Update the GitHub configuration", + "Update the JavaScript documentation", ], ) def test_vague_task_targets_are_non_conforming(task: str) -> None: @@ -192,6 +194,7 @@ def test_vague_task_targets_are_non_conforming(task: str) -> None: "Update pom.xml", "Update Dockerfile", "Wire `IssueFormatter` into the guard", + "Write unit tests for calculateDiscount function", "Touch file `src/client.py`", "Run pytest tests/scripts/test_issue_format.py", ], diff --git a/tests/workflows/test_agents_issue_optimizer_format_trigger.py b/tests/workflows/test_agents_issue_optimizer_format_trigger.py index 821e47768..d2fa6b9c3 100644 --- a/tests/workflows/test_agents_issue_optimizer_format_trigger.py +++ b/tests/workflows/test_agents_issue_optimizer_format_trigger.py @@ -132,3 +132,26 @@ def test_format_guard_uses_event_inputs_and_fails_closed_on_revalidation_errors( assert ( "github.event.issue.number || github.event.inputs.issue_number || github.run_id" in text ) + + +def test_format_guard_revalidates_before_clearing_state_and_skips_closed_issues() -> None: + for text in ( + GUARD_PATH.read_text(encoding="utf-8"), + CONSUMER_GUARD_PATH.read_text(encoding="utf-8"), + ): + resolve_idx = text.index("- name: Resolve issue") + setup_idx = text.index("- name: Setup API client") + assert resolve_idx < setup_idx + assert ( + "steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true'" + in text[setup_idx : setup_idx + 250] + ) + invalidate_idx = text.index("- name: Invalidate stale format completion") + route_idx = text.index("- name: Route non-conforming issue to the optimizer") + invalidate = text[invalidate_idx:route_idx] + assert "steps.issue.outputs.state == 'OPEN'" in invalidate + assert "Live body now conforms — preserving agents:formatted." in invalidate + assert "validator failed unexpectedly during label revalidation" in invalidate + route = text[route_idx:] + assert "steps.issue.outputs.state == 'OPEN'" in route + assert "Issue is now closed — skipping optimizer dispatch." in route