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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/skills/triage-issue/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ Parses `gh api` JSON output (single issue or search results) into a readable sum
## post_linear_comment.py

Posts the triage report to an existing Linear issue. Reads `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET` from environment variables — never pass secrets as CLI arguments.

## write_job_summary.py

Reads Claude Code execution output JSON (from the triage GitHub Action) and prints Markdown for the job summary: duration, turns, cost, and a note when the run stopped due to `error_max_turns`. Used by the workflow step that runs `if: always()` so the summary is posted even when the triage step fails (e.g. max turns reached).
109 changes: 109 additions & 0 deletions .claude/skills/triage-issue/scripts/write_job_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Read Claude Code execution output JSON and write duration, cost, and status
to stdout as Markdown for GitHub Actions job summary (GITHUB_STEP_SUMMARY).

Usage:
python3 write_job_summary.py <path-to-claude-execution-output.json>

Handles single JSON object or NDJSON (one JSON object per line).
Uses the last object with type "result" when multiple are present.

Job summary has a ~1MB limit; raw JSON is truncated if needed to avoid job abort.
"""

import json
import sys

# Stay under GITHUB_STEP_SUMMARY ~1MB limit; leave room for the table and text
MAX_RAW_BYTES = 800_000


def _append_raw_json_section(content: str, lines: list[str]) -> None:
"""Append a 'Full execution output' json block to lines, with truncation and fence escaping."""
raw = content.strip()
encoded = raw.encode("utf-8")
if len(encoded) > MAX_RAW_BYTES:
raw = encoded[:MAX_RAW_BYTES].decode("utf-8", errors="replace") + "\n\n... (truncated due to job summary size limit)"
raw = raw.replace("```", "`\u200b``")
lines.extend(["", "### Full execution output", "", "```json", raw, "```"])


def main() -> int:
if len(sys.argv) < 2:
print("Usage: write_job_summary.py <execution-output.json>", file=sys.stderr)
return 1

path = sys.argv[1]
try:
with open(path, encoding="utf-8") as f:
content = f.read()
except OSError as e:
msg = f"## Claude Triage Run\n\nCould not read execution output: {e}"
print(msg, file=sys.stderr)
print(msg) # Also to stdout so job summary shows something
return 1

# Support single JSON or NDJSON (one object per line)
results = []
for line in content.strip().splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
if obj.get("type") == "result":
results.append(obj)
except json.JSONDecodeError:
continue
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled AttributeError if JSON value is non-dict

Low Severity

The try/except blocks around json.loads only catch json.JSONDecodeError, but if a line (or the whole content) parses as a valid non-dict JSON value (e.g., an array, string, or number), the subsequent obj.get("type") call raises an AttributeError that propagates unhandled and crashes the script. The sibling scripts in this directory (parse_gh_issues.py, detect_prompt_injection.py) guard against this with isinstance(data, dict) checks before calling .get().

Additional Locations (1)

Fix in Cursor Fix in Web


if not results:
# Try parsing whole content as single JSON
try:
obj = json.loads(content)
if obj.get("type") == "result":
results = [obj]
except json.JSONDecodeError:
pass

if not results:
no_result_lines = ["## Claude Triage Run", "", "No execution result found in output."]
_append_raw_json_section(content, no_result_lines)
print("\n".join(no_result_lines))
return 0

last = results[-1]
duration_ms = last.get("duration_ms")
num_turns = last.get("num_turns")
total_cost = last.get("total_cost_usd")
subtype = last.get("subtype", "")

cost_str = f"${total_cost:.4f} USD" if isinstance(total_cost, (int, float)) else "n/a"
lines = [
"## Claude Triage Run",
"",
"| Metric | Value |",
"|--------|-------|",
f"| Duration | {duration_ms if duration_ms is not None else 'n/a'} ms |",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duration displays "n/a ms" when value is missing

Low Severity

The ms unit suffix is unconditionally appended to the duration value, so when duration_ms is None, the table cell renders as n/a ms instead of just n/a. Contrast this with the cost_str formatting on the line above, which correctly includes the USD unit only when a numeric value is present.

Fix in Cursor Fix in Web

f"| Turns | {num_turns if num_turns is not None else 'n/a'} |",
f"| Cost (USD) | {cost_str} |",
]
if subtype == "error_max_turns":
lines.extend([
"",
"⚠️ **Run stopped:** maximum turns reached. Consider increasing `max-turns` in the workflow or simplifying the issue scope.",
])
elif subtype and subtype != "success":
lines.extend([
"",
f"Result: `{subtype}`",
])

_append_raw_json_section(content, lines)

print("\n".join(lines))
return 0


if __name__ == "__main__":
sys.exit(main())
21 changes: 20 additions & 1 deletion .github/workflows/triage-issue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:
ref: develop

- name: Run Claude triage
id: triage
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
Expand All @@ -71,4 +72,22 @@ jobs:
Do NOT use `python3 -c` or other inline Python in Bash, only the provided scripts are allowed.
Do NOT attempt to delete (`rm`) temporary files you create.
claude_args: |
--max-turns 20 --allowedTools "Write,Bash(gh api *),Bash(gh pr list *),Bash(npm info *),Bash(npm ls *),Bash(python3 .claude/skills/triage-issue/scripts/post_linear_comment.py *),Bash(python3 .claude/skills/triage-issue/scripts/parse_gh_issues.py *),Bash(python3 .claude/skills/triage-issue/scripts/detect_prompt_injection.py *)"
--max-turns 40 --allowedTools "Write,Bash(gh api *),Bash(gh pr list *),Bash(npm info *),Bash(npm ls *),Bash(python3 .claude/skills/triage-issue/scripts/post_linear_comment.py *),Bash(python3 .claude/skills/triage-issue/scripts/parse_gh_issues.py *),Bash(python3 .claude/skills/triage-issue/scripts/detect_prompt_injection.py *),Bash(python3 .claude/skills/triage-issue/scripts/write_job_summary.py *)"

- name: Post triage job summary
if: always()
run: |
EXEC_FILE="${{ steps.triage.outputs.execution_file }}"
if [ -z "$EXEC_FILE" ] || [ ! -f "$EXEC_FILE" ]; then
EXEC_FILE="${RUNNER_TEMP}/claude-execution-output.json"
fi
if [ ! -f "$EXEC_FILE" ]; then
EXEC_FILE="${GITHUB_WORKSPACE}/../../_temp/claude-execution-output.json"
fi
if [ -f "$EXEC_FILE" ]; then
python3 .claude/skills/triage-issue/scripts/write_job_summary.py "$EXEC_FILE" >> "$GITHUB_STEP_SUMMARY"
else
echo "## Claude Triage Run" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No execution output file found. Run may have been skipped or failed before writing output." >> "$GITHUB_STEP_SUMMARY"
fi
Loading