claude-dev-env 1.38.1 → 1.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +10 -36
- package/_shared/pr-loop/audit-reply-template.md +147 -0
- package/_shared/pr-loop/fix-protocol.md +25 -4
- package/_shared/pr-loop/gh-payloads.md +37 -50
- package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +189 -0
- package/_shared/pr-loop/scripts/post_audit_thread.py +947 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +923 -0
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
- package/_shared/pr-loop/state-schema.md +1 -1
- package/agents/clean-coder.md +2 -2
- package/bin/install.mjs +6 -7
- package/bin/install.test.mjs +8 -0
- package/commands/doc-gist.md +16 -0
- package/commands/plan.md +0 -2
- package/commands/review-plan.md +1 -1
- package/docs/CODE_RULES.md +122 -2
- package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
- package/hooks/blocking/code_rules_enforcer.py +1143 -129
- package/hooks/blocking/convergence_gate_blocker.py +130 -0
- package/hooks/blocking/destructive_command_blocker.py +74 -0
- package/hooks/blocking/gh_body_arg_blocker.py +30 -0
- package/hooks/blocking/md_to_html_blocker.py +119 -0
- package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
- package/hooks/blocking/test_code_rules_enforcer.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
- package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
- package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
- package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
- package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
- package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
- package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
- package/hooks/blocking/test_destructive_command_blocker.py +146 -0
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
- package/hooks/blocking/test_md_to_html_blocker.py +317 -0
- package/hooks/config/any_type_config.py +7 -0
- package/hooks/config/banned_identifiers_constants.py +11 -0
- package/hooks/config/blocking_check_limits.py +38 -0
- package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
- package/hooks/config/code_rules_enforcer_constants.py +53 -0
- package/hooks/config/convergence_branch_constants.py +9 -0
- package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
- package/hooks/config/html_companion_constants.py +20 -0
- package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
- package/hooks/config/test_banned_identifiers_constants.py +17 -0
- package/hooks/hooks.json +28 -20
- package/hooks/pyproject.toml +69 -0
- package/hooks/validators/mypy_integration.py +47 -1
- package/hooks/validators/run_all_validators.py +3 -3
- package/hooks/validators/test_mypy_integration.py +50 -1
- package/hooks/workflow/doc_gist_auto_publish.py +144 -0
- package/hooks/workflow/md_to_html_companion.py +365 -0
- package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
- package/hooks/workflow/test_md_to_html_companion.py +452 -0
- package/package.json +1 -1
- package/rules/gh-body-file.md +2 -0
- package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
- package/scripts/check.ps1 +106 -0
- package/scripts/config/timing.py +11 -0
- package/scripts/sweep_empty_dirs.py +138 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_sweep_empty_dirs.py +183 -0
- package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
- package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
- package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
- package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
- package/skills/bugteam/CONSTRAINTS.md +21 -22
- package/skills/bugteam/EXAMPLES.md +3 -3
- package/skills/bugteam/PROMPTS.md +227 -67
- package/skills/bugteam/SKILL.md +114 -455
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +112 -39
- package/skills/bugteam/reference/audit-contract.md +4 -22
- package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
- package/skills/bugteam/reference/design-rationale.md +2 -2
- package/skills/bugteam/reference/github-pr-reviews.md +50 -57
- package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
- package/skills/bugteam/reference/team-setup.md +106 -9
- package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
- package/skills/bugteam/scripts/README.md +60 -0
- package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
- package/skills/bugteam/scripts/bugteam_preflight.py +294 -0
- package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
- package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
- package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
- package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
- package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
- package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
- package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +268 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
- package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
- package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
- package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
- package/skills/bugteam/test_skill_additions.py +1 -11
- package/skills/code/SKILL.md +176 -0
- package/skills/doc-gist/SKILL.md +99 -0
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
- package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
- package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
- package/skills/doc-gist/references/examples/05-design-system.html +629 -0
- package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
- package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
- package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
- package/skills/doc-gist/references/examples/11-status-report.html +528 -0
- package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
- package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
- package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
- package/skills/doc-gist/references/examples/README.md +5 -0
- package/skills/doc-gist/scripts/config/__init__.py +0 -0
- package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
- package/skills/doc-gist/scripts/gist_upload.py +177 -0
- package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
- package/skills/findbugs/SKILL.md +68 -2
- package/skills/monitor-open-prs/SKILL.md +13 -32
- package/skills/monitor-open-prs/test_skill_contract.py +0 -11
- package/skills/pr-consistency-audit/SKILL.md +112 -0
- package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
- package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
- package/skills/pr-converge/SKILL.md +227 -23
- package/skills/pr-converge/config/__init__.py +0 -0
- package/skills/pr-converge/config/constants.py +62 -0
- package/skills/pr-converge/reference/convergence-gates.md +138 -44
- package/skills/pr-converge/reference/examples.md +43 -11
- package/skills/pr-converge/reference/fix-protocol.md +6 -5
- package/skills/pr-converge/reference/ground-rules.md +5 -3
- package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
- package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
- package/skills/pr-converge/reference/per-tick.md +90 -31
- package/skills/pr-converge/reference/state-schema.md +22 -1
- package/skills/pr-converge/reference/stop-conditions.md +9 -7
- package/skills/pr-converge/scripts/README.md +34 -46
- package/skills/pr-converge/scripts/check_bugbot_ci.py +174 -0
- package/skills/pr-converge/scripts/check_convergence.py +497 -0
- package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
- package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
- package/skills/qbug/SKILL.md +132 -27
- package/skills/session-log/SKILL.md +216 -114
- package/skills/session-tidy/SKILL.md +1 -1
- package/skills/skill-builder/SKILL.md +138 -56
- package/skills/skill-builder/references/delegation-map.md +72 -113
- package/skills/skill-builder/references/progressive-disclosure.md +122 -0
- package/skills/skill-builder/references/self-audit-checklist.md +92 -0
- package/skills/skill-builder/references/skill-types.md +228 -0
- package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
- package/skills/skill-builder/templates/gap-analysis.md +15 -8
- package/skills/skill-builder/workflows/improve-skill.md +86 -57
- package/skills/skill-builder/workflows/new-skill.md +80 -168
- package/skills/skill-builder/workflows/polish-skill.md +78 -54
- package/skills/structure-prompt/SKILL.md +50 -0
- package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
- package/skills/structure-prompt/reference/block-classification.md +27 -0
- package/skills/structure-prompt/reference/canonical-case.md +48 -0
- package/skills/structure-prompt/reference/citation-depth.md +70 -0
- package/skills/structure-prompt/reference/cleanup.md +33 -0
- package/skills/structure-prompt/reference/constraints.md +33 -0
- package/skills/structure-prompt/reference/directives.md +37 -0
- package/skills/structure-prompt/reference/examples.md +72 -0
- package/skills/structure-prompt/reference/instantiation.md +51 -0
- package/skills/structure-prompt/reference/output-contract.md +72 -0
- package/skills/structure-prompt/reference/per-category.md +23 -0
- package/skills/structure-prompt/reference/persona.md +38 -0
- package/skills/structure-prompt/reference/research.md +33 -0
- package/skills/structure-prompt/reference/structure.md +28 -0
- package/agents/code-standards-agent.md +0 -93
- package/agents/groq-coder.md +0 -113
- package/agents/plan-executor.md +0 -226
- package/agents/project-docs-analyzer.md +0 -53
- package/agents/project-structure-organizer-agent.md +0 -72
- package/agents/skill-to-agent-converter.md +0 -370
- package/agents/skill-writer-agent.md +0 -470
- package/agents/user-docs-writer.md +0 -67
- package/agents/workflow-visual-documenter.md +0 -82
- package/commands/readability-review.md +0 -20
- package/hooks/mypy.ini +0 -2
- package/hooks/notification/attention_needed_notify.py +0 -71
- package/hooks/notification/claude_notification_handler.py +0 -67
- package/hooks/notification/notification_utils.py +0 -267
- package/hooks/notification/subagent_complete_notify.py +0 -381
- package/hooks/notification/test_attention_needed_notify.py +0 -47
- package/hooks/notification/test_claude_notification_handler.py +0 -54
- package/hooks/notification/test_notification_utils.py +0 -91
- package/hooks/notification/test_subagent_complete_notify.py +0 -79
- package/scripts/config/groq_bugteam_config.py +0 -230
- package/scripts/config/test_groq_bugteam_config.py +0 -83
- package/scripts/config/test_spec_implementer_prompt.py +0 -32
- package/scripts/groq_bugteam.README.md +0 -131
- package/scripts/groq_bugteam.py +0 -647
- package/scripts/groq_bugteam_dotenv.py +0 -40
- package/scripts/groq_bugteam_spec.py +0 -226
- package/scripts/test_groq_bugteam.py +0 -529
- package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
- package/scripts/test_groq_bugteam_dotenv.py +0 -66
- package/scripts/test_groq_bugteam_spec.py +0 -338
- package/skills/bugteam/SKILL_EVALS.md +0 -309
- package/skills/dream/SKILL.md +0 -118
- package/skills/ingest/SKILL.md +0 -40
- package/skills/npm-creator/SKILL.md +0 -187
- package/skills/readability-review/SKILL.md +0 -127
- package/skills/resume-review/SKILL.md +0 -261
- package/skills/rule-audit/SKILL.md +0 -307
- package/skills/rule-creator/SKILL.md +0 -150
- package/skills/searching-obsidian-vault/SKILL.md +0 -131
- package/skills/skill-writer/REFERENCE.md +0 -284
- package/skills/skill-writer/SKILL.md +0 -222
- package/skills/tdd-team/SKILL.md +0 -128
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
"""Post an audit review (APPROVE / REQUEST_CHANGES) to a draft PR.
|
|
2
|
+
|
|
3
|
+
Consumed by ``/bugteam``, ``/findbugs``, and ``/qbug`` at the end of every
|
|
4
|
+
audit invocation. Posts to ``/repos/{owner}/{repo}/pulls/{N}/reviews`` with
|
|
5
|
+
``commit_id=<SHA>``, a formatted body, and inline ``comments[]`` derived
|
|
6
|
+
from a findings JSON file. CLEAN state ``→`` APPROVE with empty
|
|
7
|
+
``comments[]``; DIRTY state ``→`` REQUEST_CHANGES with one inline comment
|
|
8
|
+
per finding so each becomes its own resolvable thread.
|
|
9
|
+
|
|
10
|
+
The body skeleton is read at runtime from ``audit-reply-template.md`` (the
|
|
11
|
+
canonical reference doc shipped in Phase 1) so the template stays the
|
|
12
|
+
single source of truth for the review-body shape.
|
|
13
|
+
|
|
14
|
+
Exit codes per spec:
|
|
15
|
+
- ``0`` on success (POSTs the new review's ``html_url`` to stdout)
|
|
16
|
+
- ``1`` on user error (bad CLI arguments, malformed findings JSON)
|
|
17
|
+
- ``2`` on retry exhaustion (four non-2xx responses — one initial attempt
|
|
18
|
+
plus three retries) — hard blocker
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import dataclasses
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import NoReturn
|
|
34
|
+
|
|
35
|
+
sys.modules.pop("config", None)
|
|
36
|
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
37
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
38
|
+
|
|
39
|
+
from config.post_audit_thread_constants import (
|
|
40
|
+
ALL_GH_AUTH_TOKEN_COMMAND_PARTS,
|
|
41
|
+
ALL_GH_TOKEN_ENV_VAR_NAMES,
|
|
42
|
+
ALL_REQUIRED_FINDING_FIELDS,
|
|
43
|
+
ALL_RETRY_BACKOFF_SECONDS,
|
|
44
|
+
ALL_SUPPORTED_INLINE_COMMENT_SIDES,
|
|
45
|
+
ALL_SUPPORTED_SEVERITY_TAGS,
|
|
46
|
+
ALL_SUPPORTED_SKILLS,
|
|
47
|
+
ALL_SUPPORTED_STATES,
|
|
48
|
+
AUDIT_BODY_SKELETON_CLOSE_MARKER,
|
|
49
|
+
AUDIT_BODY_SKELETON_OPEN_MARKER,
|
|
50
|
+
CLI_FLAG_COMMIT,
|
|
51
|
+
CLI_FLAG_FINDINGS_JSON,
|
|
52
|
+
CLI_FLAG_OWNER,
|
|
53
|
+
CLI_FLAG_PR_NUMBER,
|
|
54
|
+
CLI_FLAG_REPO,
|
|
55
|
+
CLI_FLAG_SKILL,
|
|
56
|
+
CLI_FLAG_STATE,
|
|
57
|
+
DETAILS_BLOCK_BULLET_TEMPLATE,
|
|
58
|
+
DETAILS_BLOCK_FOOTER,
|
|
59
|
+
DETAILS_BLOCK_HEADER,
|
|
60
|
+
ERROR_RESPONSE_PREVIEW_CHARS,
|
|
61
|
+
EXIT_CODE_RETRY_EXHAUSTED,
|
|
62
|
+
EXIT_CODE_USER_ERROR,
|
|
63
|
+
GITHUB_API_ACCEPT_HEADER,
|
|
64
|
+
GITHUB_API_BASE_URL,
|
|
65
|
+
GITHUB_API_USER_AGENT,
|
|
66
|
+
GITHUB_API_VERSION_HEADER,
|
|
67
|
+
GITHUB_REVIEW_EVENT_APPROVE,
|
|
68
|
+
GITHUB_REVIEW_EVENT_REQUEST_CHANGES,
|
|
69
|
+
HEADING_FOR_CLEAN,
|
|
70
|
+
HEADING_FOR_DIRTY,
|
|
71
|
+
HTTP_AUTHORIZATION_BEARER_PREFIX,
|
|
72
|
+
HTTP_HEADER_ACCEPT,
|
|
73
|
+
HTTP_HEADER_AUTHORIZATION,
|
|
74
|
+
HTTP_HEADER_CONTENT_TYPE,
|
|
75
|
+
HTTP_HEADER_GITHUB_API_VERSION,
|
|
76
|
+
HTTP_HEADER_USER_AGENT,
|
|
77
|
+
HTTP_METHOD_POST,
|
|
78
|
+
HTTP_REQUEST_CONTENT_TYPE,
|
|
79
|
+
HTTP_REQUEST_TIMEOUT_SECONDS,
|
|
80
|
+
HTTP_STATUS_SUCCESS_RANGE_HIGH,
|
|
81
|
+
HTTP_STATUS_SUCCESS_RANGE_LOW,
|
|
82
|
+
INLINE_COMMENT_BODY_TEMPLATE,
|
|
83
|
+
INLINE_COMMENT_FIELD_BODY,
|
|
84
|
+
INLINE_COMMENT_FIELD_LINE,
|
|
85
|
+
INLINE_COMMENT_FIELD_PATH,
|
|
86
|
+
INLINE_COMMENT_FIELD_SIDE,
|
|
87
|
+
JSON_FIELD_DESCRIPTION,
|
|
88
|
+
JSON_FIELD_FIX_SUMMARY,
|
|
89
|
+
JSON_FIELD_LINE,
|
|
90
|
+
JSON_FIELD_PATH,
|
|
91
|
+
JSON_FIELD_SEVERITY,
|
|
92
|
+
JSON_FIELD_SIDE,
|
|
93
|
+
MAX_RETRY_ATTEMPTS,
|
|
94
|
+
PLACEHOLDER_DETAILS_BLOCK,
|
|
95
|
+
PLACEHOLDER_FINDINGS_COUNT,
|
|
96
|
+
PLACEHOLDER_HEADING,
|
|
97
|
+
PLACEHOLDER_P0_COUNT,
|
|
98
|
+
PLACEHOLDER_P1_COUNT,
|
|
99
|
+
PLACEHOLDER_P2_COUNT,
|
|
100
|
+
PLACEHOLDER_SKILL,
|
|
101
|
+
PLACEHOLDER_STATE_LABEL,
|
|
102
|
+
PLACEHOLDER_SUMMARY_PARAGRAPH,
|
|
103
|
+
REVIEW_REQUEST_FIELD_BODY,
|
|
104
|
+
REVIEW_REQUEST_FIELD_COMMENTS,
|
|
105
|
+
REVIEW_REQUEST_FIELD_COMMIT_ID,
|
|
106
|
+
REVIEW_REQUEST_FIELD_EVENT,
|
|
107
|
+
REVIEW_RESPONSE_FIELD_HTML_URL,
|
|
108
|
+
REVIEWS_API_PATH_TEMPLATE,
|
|
109
|
+
SEVERITY_TAG_P0,
|
|
110
|
+
SEVERITY_TAG_P1,
|
|
111
|
+
SEVERITY_TAG_P2,
|
|
112
|
+
SHORT_SHA_LENGTH,
|
|
113
|
+
STATE_CLEAN,
|
|
114
|
+
STATE_DIRTY,
|
|
115
|
+
STATE_LABEL_FOR_CLEAN,
|
|
116
|
+
STATE_LABEL_FOR_DIRTY,
|
|
117
|
+
SUMMARY_PARAGRAPH_CLEAN_TEMPLATE,
|
|
118
|
+
SUMMARY_PARAGRAPH_DIRTY_TEMPLATE,
|
|
119
|
+
TEMPLATE_FENCE_TOKEN,
|
|
120
|
+
template_path,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class UserInputError(ValueError):
|
|
125
|
+
"""Raised on malformed CLI input or findings JSON.
|
|
126
|
+
|
|
127
|
+
Surfaces as exit code ``EXIT_CODE_USER_ERROR`` at the entry point.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class RetryExhaustedError(RuntimeError):
|
|
132
|
+
"""Raised after four non-2xx responses from the reviews endpoint.
|
|
133
|
+
|
|
134
|
+
Four attempts = one initial attempt plus three retries. Surfaces as
|
|
135
|
+
exit code ``EXIT_CODE_RETRY_EXHAUSTED`` at the entry point.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclasses.dataclass(frozen=True)
|
|
140
|
+
class AuditFinding:
|
|
141
|
+
"""One row of the findings JSON file consumed by ``--findings-json``.
|
|
142
|
+
|
|
143
|
+
Mirrors the schema in spec lines 158-169. Frozen so callers cannot
|
|
144
|
+
mutate fields after parsing.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
path: str
|
|
148
|
+
line: int
|
|
149
|
+
side: str
|
|
150
|
+
severity: str
|
|
151
|
+
description: str
|
|
152
|
+
fix_summary: str
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclasses.dataclass(frozen=True)
|
|
156
|
+
class PostedReview:
|
|
157
|
+
"""Result of a successful POST to the reviews endpoint.
|
|
158
|
+
|
|
159
|
+
``html_url`` is the field emitted to stdout per spec line 177;
|
|
160
|
+
``raw_response_text`` and ``status_code`` are retained for tests and
|
|
161
|
+
logging.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
html_url: str
|
|
165
|
+
raw_response_text: str
|
|
166
|
+
status_code: int
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _UserInputArgumentParser(argparse.ArgumentParser):
|
|
170
|
+
"""ArgumentParser that raises :class:`UserInputError` on parse errors.
|
|
171
|
+
|
|
172
|
+
The stock ``argparse.ArgumentParser.error`` raises ``SystemExit(2)``,
|
|
173
|
+
which collides with ``EXIT_CODE_RETRY_EXHAUSTED``. Routing parse
|
|
174
|
+
failures through :class:`UserInputError` lets the entry point map
|
|
175
|
+
them to ``EXIT_CODE_USER_ERROR`` (exit 1) instead.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def error(self, message: str) -> NoReturn:
|
|
179
|
+
raise UserInputError(f"argument parsing failed: {message}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_command_line_arguments(all_arguments: list[str]) -> argparse.Namespace:
|
|
183
|
+
"""Parse and validate the script's CLI surface.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
all_arguments: ``sys.argv[1:]`` or an equivalent list of strings.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Namespace with attributes ``skill``, ``owner``, ``repo``,
|
|
190
|
+
``pr_number``, ``commit``, ``state``, ``findings_json``.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
UserInputError: unrecognized argument, missing required argument,
|
|
194
|
+
or value outside a declared ``choices`` set.
|
|
195
|
+
"""
|
|
196
|
+
parser = _UserInputArgumentParser(
|
|
197
|
+
description=(
|
|
198
|
+
"Post an audit review to a draft PR. CLEAN state approves; "
|
|
199
|
+
"DIRTY state requests changes with one inline comment per finding."
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
CLI_FLAG_SKILL,
|
|
204
|
+
required=True,
|
|
205
|
+
choices=list(ALL_SUPPORTED_SKILLS),
|
|
206
|
+
help="Name of the calling audit skill.",
|
|
207
|
+
)
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
CLI_FLAG_OWNER,
|
|
210
|
+
required=True,
|
|
211
|
+
help="Repository owner (e.g., jl-cmd).",
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument(
|
|
214
|
+
CLI_FLAG_REPO,
|
|
215
|
+
required=True,
|
|
216
|
+
help="Repository name (e.g., claude-code-config).",
|
|
217
|
+
)
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
CLI_FLAG_PR_NUMBER,
|
|
220
|
+
required=True,
|
|
221
|
+
type=int,
|
|
222
|
+
dest="pr_number",
|
|
223
|
+
help="Pull request number.",
|
|
224
|
+
)
|
|
225
|
+
parser.add_argument(
|
|
226
|
+
CLI_FLAG_COMMIT,
|
|
227
|
+
required=True,
|
|
228
|
+
help="Commit SHA the review attaches to (commit_id field).",
|
|
229
|
+
)
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
CLI_FLAG_STATE,
|
|
232
|
+
required=True,
|
|
233
|
+
choices=list(ALL_SUPPORTED_STATES),
|
|
234
|
+
help="CLEAN approves; DIRTY requests changes.",
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
CLI_FLAG_FINDINGS_JSON,
|
|
238
|
+
required=True,
|
|
239
|
+
type=Path,
|
|
240
|
+
dest="findings_json",
|
|
241
|
+
help="Path to the findings JSON file (empty list for CLEAN).",
|
|
242
|
+
)
|
|
243
|
+
return parser.parse_args(all_arguments)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _require_string_field(
|
|
247
|
+
all_finding_fields: dict[str, object], field_name: str
|
|
248
|
+
) -> str:
|
|
249
|
+
field_value = all_finding_fields.get(field_name)
|
|
250
|
+
if not isinstance(field_value, str):
|
|
251
|
+
raise UserInputError(
|
|
252
|
+
f"finding field {field_name!r} must be a string; "
|
|
253
|
+
f"got {type(field_value).__name__}"
|
|
254
|
+
)
|
|
255
|
+
return field_value
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _require_nonempty_string_field(
|
|
259
|
+
all_finding_fields: dict[str, object], field_name: str
|
|
260
|
+
) -> str:
|
|
261
|
+
field_value = _require_string_field(all_finding_fields, field_name)
|
|
262
|
+
if not field_value:
|
|
263
|
+
raise UserInputError(
|
|
264
|
+
f"finding field {field_name!r} must be a non-empty string; got ''"
|
|
265
|
+
)
|
|
266
|
+
return field_value
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _require_int_field(
|
|
270
|
+
all_finding_fields: dict[str, object], field_name: str
|
|
271
|
+
) -> int:
|
|
272
|
+
field_value = all_finding_fields.get(field_name)
|
|
273
|
+
if isinstance(field_value, bool) or not isinstance(field_value, int):
|
|
274
|
+
raise UserInputError(
|
|
275
|
+
f"finding field {field_name!r} must be an int; "
|
|
276
|
+
f"got {type(field_value).__name__}"
|
|
277
|
+
)
|
|
278
|
+
return field_value
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def parse_findings_json_file(findings_json_path: Path) -> list[AuditFinding]:
|
|
282
|
+
"""Parse and validate the findings JSON file.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
findings_json_path: Path to a JSON file whose root is a list of
|
|
286
|
+
finding objects matching the schema in the unresolved-thread
|
|
287
|
+
spec.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
List of :class:`AuditFinding`. Empty list when the file contains
|
|
291
|
+
an empty JSON array (used on CLEAN state).
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
UserInputError: file missing, not parseable, JSON root not a list,
|
|
295
|
+
entries not dicts, required fields missing or mistyped, path
|
|
296
|
+
empty, or line value below ``1`` (the GitHub reviews API
|
|
297
|
+
rejects ``line=0`` as unprocessable).
|
|
298
|
+
"""
|
|
299
|
+
if not findings_json_path.is_file():
|
|
300
|
+
raise UserInputError(
|
|
301
|
+
f"findings-json path not found or not a file: {findings_json_path}"
|
|
302
|
+
)
|
|
303
|
+
findings_text = findings_json_path.read_text(encoding="utf-8")
|
|
304
|
+
try:
|
|
305
|
+
parsed_value: object = json.loads(findings_text)
|
|
306
|
+
except json.JSONDecodeError as decode_error:
|
|
307
|
+
raise UserInputError(
|
|
308
|
+
f"findings-json file is not parseable as JSON: {decode_error}"
|
|
309
|
+
) from decode_error
|
|
310
|
+
if not isinstance(parsed_value, list):
|
|
311
|
+
raise UserInputError(
|
|
312
|
+
f"findings JSON root must be a list; got {type(parsed_value).__name__}"
|
|
313
|
+
)
|
|
314
|
+
parsed_findings: list[AuditFinding] = []
|
|
315
|
+
for each_entry in parsed_value:
|
|
316
|
+
if not isinstance(each_entry, dict):
|
|
317
|
+
raise UserInputError(
|
|
318
|
+
"every findings JSON entry must be an object; got "
|
|
319
|
+
f"{type(each_entry).__name__}"
|
|
320
|
+
)
|
|
321
|
+
all_entry_fields: dict[str, object] = each_entry
|
|
322
|
+
for each_required_field in ALL_REQUIRED_FINDING_FIELDS:
|
|
323
|
+
if each_required_field not in all_entry_fields:
|
|
324
|
+
raise UserInputError(
|
|
325
|
+
f"finding entry missing required field: {each_required_field!r}"
|
|
326
|
+
)
|
|
327
|
+
severity_value = _require_string_field(all_entry_fields, JSON_FIELD_SEVERITY)
|
|
328
|
+
if severity_value not in ALL_SUPPORTED_SEVERITY_TAGS:
|
|
329
|
+
raise UserInputError(
|
|
330
|
+
f"finding severity {severity_value!r} not in supported set "
|
|
331
|
+
f"{list(ALL_SUPPORTED_SEVERITY_TAGS)!r}"
|
|
332
|
+
)
|
|
333
|
+
side_value = _require_string_field(all_entry_fields, JSON_FIELD_SIDE)
|
|
334
|
+
if side_value not in ALL_SUPPORTED_INLINE_COMMENT_SIDES:
|
|
335
|
+
raise UserInputError(
|
|
336
|
+
f"finding side {side_value!r} not in supported set "
|
|
337
|
+
f"{list(ALL_SUPPORTED_INLINE_COMMENT_SIDES)!r}"
|
|
338
|
+
)
|
|
339
|
+
path_value = _require_nonempty_string_field(all_entry_fields, JSON_FIELD_PATH)
|
|
340
|
+
line_value = _require_int_field(all_entry_fields, JSON_FIELD_LINE)
|
|
341
|
+
if line_value < 1:
|
|
342
|
+
raise UserInputError(
|
|
343
|
+
f"finding field {JSON_FIELD_LINE!r} must be >= 1 (GitHub "
|
|
344
|
+
f"reviews API rejects line=0); got {line_value} for path "
|
|
345
|
+
f"{path_value!r}"
|
|
346
|
+
)
|
|
347
|
+
parsed_findings.append(
|
|
348
|
+
AuditFinding(
|
|
349
|
+
path=path_value,
|
|
350
|
+
line=line_value,
|
|
351
|
+
side=side_value,
|
|
352
|
+
severity=severity_value,
|
|
353
|
+
description=_require_string_field(all_entry_fields, JSON_FIELD_DESCRIPTION),
|
|
354
|
+
fix_summary=_require_string_field(all_entry_fields, JSON_FIELD_FIX_SUMMARY),
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
return parsed_findings
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def extract_audit_body_skeleton(template_markdown_text: str) -> str:
|
|
361
|
+
"""Pull the audit review body skeleton out of the Phase 1 template markdown.
|
|
362
|
+
|
|
363
|
+
Locates the explicit HTML comment markers
|
|
364
|
+
``AUDIT_BODY_SKELETON_OPEN_MARKER`` and
|
|
365
|
+
``AUDIT_BODY_SKELETON_CLOSE_MARKER`` in the template, then captures
|
|
366
|
+
the fenced block (delimited by the token in ``TEMPLATE_FENCE_TOKEN``)
|
|
367
|
+
sitting between them. The captured text contains the placeholders the
|
|
368
|
+
rest of this script substitutes. Anchoring on explicit markers — not on
|
|
369
|
+
heading text or "the next fence after a heading" — keeps the contract
|
|
370
|
+
stable across template edits that rename headings, insert new fences,
|
|
371
|
+
or change fence syntax.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
template_markdown_text: Full text of ``audit-reply-template.md``.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Text between the fence markers, with any leading or trailing
|
|
378
|
+
newlines stripped.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
RuntimeError: open or close marker missing, markers out of order,
|
|
382
|
+
or the marker-bounded region is not a paired fence block.
|
|
383
|
+
"""
|
|
384
|
+
open_marker_index = template_markdown_text.find(AUDIT_BODY_SKELETON_OPEN_MARKER)
|
|
385
|
+
if open_marker_index < 0:
|
|
386
|
+
raise RuntimeError(
|
|
387
|
+
f"audit body skeleton open marker not found in template: "
|
|
388
|
+
f"{AUDIT_BODY_SKELETON_OPEN_MARKER!r}"
|
|
389
|
+
)
|
|
390
|
+
region_start = open_marker_index + len(AUDIT_BODY_SKELETON_OPEN_MARKER)
|
|
391
|
+
close_marker_index = template_markdown_text.find(
|
|
392
|
+
AUDIT_BODY_SKELETON_CLOSE_MARKER, region_start
|
|
393
|
+
)
|
|
394
|
+
if close_marker_index < 0:
|
|
395
|
+
raise RuntimeError(
|
|
396
|
+
f"audit body skeleton close marker not found after open marker: "
|
|
397
|
+
f"{AUDIT_BODY_SKELETON_CLOSE_MARKER!r}"
|
|
398
|
+
)
|
|
399
|
+
region_text = template_markdown_text[region_start:close_marker_index]
|
|
400
|
+
fence_open_index = region_text.find(TEMPLATE_FENCE_TOKEN)
|
|
401
|
+
if fence_open_index < 0:
|
|
402
|
+
raise RuntimeError(
|
|
403
|
+
"audit body skeleton marker region has no opening fence"
|
|
404
|
+
)
|
|
405
|
+
skeleton_start = fence_open_index + len(TEMPLATE_FENCE_TOKEN)
|
|
406
|
+
fence_close_index = region_text.find(TEMPLATE_FENCE_TOKEN, skeleton_start)
|
|
407
|
+
if fence_close_index < 0:
|
|
408
|
+
raise RuntimeError(
|
|
409
|
+
"audit body skeleton marker region has no closing fence"
|
|
410
|
+
)
|
|
411
|
+
return region_text[skeleton_start:fence_close_index].strip("\n")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def load_audit_body_skeleton() -> str:
|
|
415
|
+
"""Read ``audit-reply-template.md`` and return the audit body skeleton.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Skeleton text containing the placeholders the body formatter
|
|
419
|
+
substitutes. Reads from disk every call so a docs change picks
|
|
420
|
+
up without restarting the caller.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
RuntimeError: template file missing or malformed.
|
|
424
|
+
"""
|
|
425
|
+
template_file_path = template_path()
|
|
426
|
+
if not template_file_path.is_file():
|
|
427
|
+
raise RuntimeError(f"audit-reply-template.md not found at {template_file_path}")
|
|
428
|
+
template_text = template_file_path.read_text(encoding="utf-8")
|
|
429
|
+
return extract_audit_body_skeleton(template_text)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def short_commit_sha(commit_sha: str) -> str:
|
|
433
|
+
"""Return the short form of a Git SHA per ``SHORT_SHA_LENGTH``.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
commit_sha: Full or already-short Git SHA.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
First ``SHORT_SHA_LENGTH`` characters of the input.
|
|
440
|
+
"""
|
|
441
|
+
return commit_sha[:SHORT_SHA_LENGTH]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def skill_display_name(skill_argument: str) -> str:
|
|
445
|
+
"""Return the title-cased display form of a skill name.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
skill_argument: Lowercase skill identifier (``bugteam``,
|
|
449
|
+
``findbugs``, ``qbug``).
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Title-cased form for embedding in the review body
|
|
453
|
+
(``Bugteam``, ``Findbugs``, ``Qbug``).
|
|
454
|
+
"""
|
|
455
|
+
return skill_argument.title()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def severity_counts_by_tag(
|
|
459
|
+
all_findings: list[AuditFinding],
|
|
460
|
+
) -> dict[str, int]:
|
|
461
|
+
"""Tally findings by severity tag.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
all_findings: Parsed findings list (empty on CLEAN state).
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Mapping with every key in ``ALL_SUPPORTED_SEVERITY_TAGS`` present,
|
|
468
|
+
even when its count is ``0``. Callers can index the result without
|
|
469
|
+
a ``KeyError``.
|
|
470
|
+
"""
|
|
471
|
+
counts_by_tag: dict[str, int] = {
|
|
472
|
+
each_tag: 0 for each_tag in ALL_SUPPORTED_SEVERITY_TAGS
|
|
473
|
+
}
|
|
474
|
+
for each_finding in all_findings:
|
|
475
|
+
counts_by_tag[each_finding.severity] += 1
|
|
476
|
+
return counts_by_tag
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def build_details_block(all_findings: list[AuditFinding]) -> str:
|
|
480
|
+
"""Render the collapsed ``<details>`` block listing every finding.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
all_findings: Non-empty list of findings (DIRTY state only).
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Multi-line markdown string wrapped in ``<details>`` / ``</details>``,
|
|
487
|
+
or an empty string when no findings were supplied (CLEAN state).
|
|
488
|
+
"""
|
|
489
|
+
if not all_findings:
|
|
490
|
+
return ""
|
|
491
|
+
rendered_bullets = [
|
|
492
|
+
DETAILS_BLOCK_BULLET_TEMPLATE.format(
|
|
493
|
+
severity=each_finding.severity,
|
|
494
|
+
path=each_finding.path,
|
|
495
|
+
line=each_finding.line,
|
|
496
|
+
description=each_finding.description,
|
|
497
|
+
)
|
|
498
|
+
for each_finding in all_findings
|
|
499
|
+
]
|
|
500
|
+
return (
|
|
501
|
+
DETAILS_BLOCK_HEADER + "\n" + "\n".join(rendered_bullets) + DETAILS_BLOCK_FOOTER
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def fill_audit_body_skeleton(
|
|
506
|
+
skeleton_text: str,
|
|
507
|
+
skill_argument: str,
|
|
508
|
+
state_argument: str,
|
|
509
|
+
commit_sha: str,
|
|
510
|
+
all_findings: list[AuditFinding],
|
|
511
|
+
) -> str:
|
|
512
|
+
"""Substitute placeholders in the audit body skeleton with concrete values.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
skeleton_text: Skeleton produced by :func:`load_audit_body_skeleton`.
|
|
516
|
+
skill_argument: One of ``ALL_SUPPORTED_SKILLS``.
|
|
517
|
+
state_argument: One of ``ALL_SUPPORTED_STATES``.
|
|
518
|
+
commit_sha: Full SHA of the commit the review attaches to.
|
|
519
|
+
all_findings: Parsed findings list.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Markdown body string ready to send as the ``body`` field of the
|
|
523
|
+
reviews POST payload.
|
|
524
|
+
"""
|
|
525
|
+
display_skill = skill_display_name(skill_argument)
|
|
526
|
+
short_commit = short_commit_sha(commit_sha)
|
|
527
|
+
counts_by_tag = severity_counts_by_tag(all_findings)
|
|
528
|
+
is_clean = state_argument == STATE_CLEAN
|
|
529
|
+
state_label = STATE_LABEL_FOR_CLEAN if is_clean else STATE_LABEL_FOR_DIRTY
|
|
530
|
+
heading_text = HEADING_FOR_CLEAN if is_clean else HEADING_FOR_DIRTY
|
|
531
|
+
summary_template = (
|
|
532
|
+
SUMMARY_PARAGRAPH_CLEAN_TEMPLATE
|
|
533
|
+
if is_clean
|
|
534
|
+
else SUMMARY_PARAGRAPH_DIRTY_TEMPLATE
|
|
535
|
+
)
|
|
536
|
+
summary_paragraph_text = summary_template.format(
|
|
537
|
+
skill_display=display_skill,
|
|
538
|
+
short_commit=short_commit,
|
|
539
|
+
findings_count=len(all_findings),
|
|
540
|
+
)
|
|
541
|
+
details_block_text = "" if is_clean else build_details_block(all_findings)
|
|
542
|
+
placeholder_replacements: list[tuple[str, str]] = [
|
|
543
|
+
(PLACEHOLDER_SKILL, display_skill),
|
|
544
|
+
(PLACEHOLDER_STATE_LABEL, state_label),
|
|
545
|
+
(PLACEHOLDER_HEADING, heading_text),
|
|
546
|
+
(PLACEHOLDER_SUMMARY_PARAGRAPH, summary_paragraph_text),
|
|
547
|
+
(PLACEHOLDER_FINDINGS_COUNT, str(len(all_findings))),
|
|
548
|
+
(PLACEHOLDER_P0_COUNT, str(counts_by_tag[SEVERITY_TAG_P0])),
|
|
549
|
+
(PLACEHOLDER_P1_COUNT, str(counts_by_tag[SEVERITY_TAG_P1])),
|
|
550
|
+
(PLACEHOLDER_P2_COUNT, str(counts_by_tag[SEVERITY_TAG_P2])),
|
|
551
|
+
(PLACEHOLDER_DETAILS_BLOCK, details_block_text),
|
|
552
|
+
]
|
|
553
|
+
filled_text = skeleton_text
|
|
554
|
+
for each_placeholder, each_replacement in placeholder_replacements:
|
|
555
|
+
filled_text = filled_text.replace(each_placeholder, each_replacement)
|
|
556
|
+
return filled_text
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def build_inline_comments_payload(
|
|
560
|
+
skill_argument: str,
|
|
561
|
+
all_findings: list[AuditFinding],
|
|
562
|
+
) -> list[dict[str, object]]:
|
|
563
|
+
"""Render the findings list as a GitHub reviews ``comments[]`` payload.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
skill_argument: One of ``ALL_SUPPORTED_SKILLS``; embedded in each
|
|
567
|
+
comment's body text.
|
|
568
|
+
all_findings: Findings to render (empty list on CLEAN state).
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
List of dictionaries matching the GitHub API shape for inline
|
|
572
|
+
review comments: ``path``, ``line``, ``side``, ``body``.
|
|
573
|
+
"""
|
|
574
|
+
display_skill = skill_display_name(skill_argument)
|
|
575
|
+
rendered_comments: list[dict[str, object]] = []
|
|
576
|
+
for each_finding in all_findings:
|
|
577
|
+
comment_body_text = INLINE_COMMENT_BODY_TEMPLATE.format(
|
|
578
|
+
severity=each_finding.severity,
|
|
579
|
+
skill_display=display_skill,
|
|
580
|
+
description=each_finding.description,
|
|
581
|
+
fix_summary=each_finding.fix_summary,
|
|
582
|
+
)
|
|
583
|
+
rendered_comments.append(
|
|
584
|
+
{
|
|
585
|
+
INLINE_COMMENT_FIELD_PATH: each_finding.path,
|
|
586
|
+
INLINE_COMMENT_FIELD_LINE: each_finding.line,
|
|
587
|
+
INLINE_COMMENT_FIELD_SIDE: each_finding.side,
|
|
588
|
+
INLINE_COMMENT_FIELD_BODY: comment_body_text,
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
return rendered_comments
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def review_event_for_state(state_argument: str) -> str:
|
|
595
|
+
"""Return the GitHub API ``event`` string for an audit state.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
state_argument: One of ``ALL_SUPPORTED_STATES``.
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
``APPROVE`` for CLEAN, ``REQUEST_CHANGES`` for DIRTY.
|
|
602
|
+
|
|
603
|
+
Raises:
|
|
604
|
+
UserInputError: state outside the supported set.
|
|
605
|
+
"""
|
|
606
|
+
if state_argument == STATE_CLEAN:
|
|
607
|
+
return GITHUB_REVIEW_EVENT_APPROVE
|
|
608
|
+
if state_argument == STATE_DIRTY:
|
|
609
|
+
return GITHUB_REVIEW_EVENT_REQUEST_CHANGES
|
|
610
|
+
raise UserInputError(
|
|
611
|
+
f"state {state_argument!r} not in supported set {list(ALL_SUPPORTED_STATES)!r}"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def build_review_request_payload(
|
|
616
|
+
state_argument: str,
|
|
617
|
+
commit_sha: str,
|
|
618
|
+
review_body_text: str,
|
|
619
|
+
all_inline_comments: list[dict[str, object]],
|
|
620
|
+
) -> dict[str, object]:
|
|
621
|
+
"""Assemble the JSON payload sent to the reviews endpoint.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
state_argument: One of ``ALL_SUPPORTED_STATES``.
|
|
625
|
+
commit_sha: SHA bound into ``commit_id``.
|
|
626
|
+
review_body_text: Already-formatted review body.
|
|
627
|
+
all_inline_comments: Output of :func:`build_inline_comments_payload`;
|
|
628
|
+
empty list on CLEAN state.
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
Dictionary suitable for ``json.dumps`` and sending as the request
|
|
632
|
+
body to ``POST /repos/{owner}/{repo}/pulls/{N}/reviews``.
|
|
633
|
+
"""
|
|
634
|
+
return {
|
|
635
|
+
REVIEW_REQUEST_FIELD_COMMIT_ID: commit_sha,
|
|
636
|
+
REVIEW_REQUEST_FIELD_BODY: review_body_text,
|
|
637
|
+
REVIEW_REQUEST_FIELD_EVENT: review_event_for_state(state_argument),
|
|
638
|
+
REVIEW_REQUEST_FIELD_COMMENTS: all_inline_comments,
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def resolve_github_token() -> str:
|
|
643
|
+
"""Return the GitHub token to authenticate the reviews POST with.
|
|
644
|
+
|
|
645
|
+
Precedence (first non-empty wins):
|
|
646
|
+
- ``GH_TOKEN`` env var
|
|
647
|
+
- ``GITHUB_TOKEN`` env var
|
|
648
|
+
- ``gh auth token`` (current active ``gh`` account)
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
Token string, stripped of trailing whitespace.
|
|
652
|
+
|
|
653
|
+
Raises:
|
|
654
|
+
UserInputError: every source above failed or returned empty.
|
|
655
|
+
"""
|
|
656
|
+
for each_env_var_name in ALL_GH_TOKEN_ENV_VAR_NAMES:
|
|
657
|
+
env_token_value = os.environ.get(each_env_var_name, "").strip()
|
|
658
|
+
if env_token_value:
|
|
659
|
+
return env_token_value
|
|
660
|
+
try:
|
|
661
|
+
completion = subprocess.run(
|
|
662
|
+
list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS),
|
|
663
|
+
capture_output=True,
|
|
664
|
+
text=True,
|
|
665
|
+
encoding="utf-8",
|
|
666
|
+
errors="replace",
|
|
667
|
+
check=False,
|
|
668
|
+
)
|
|
669
|
+
except FileNotFoundError as missing_gh_error:
|
|
670
|
+
raise UserInputError(
|
|
671
|
+
"`gh` CLI not installed or not on PATH; cannot resolve a GitHub "
|
|
672
|
+
"token"
|
|
673
|
+
) from missing_gh_error
|
|
674
|
+
if completion.returncode != 0:
|
|
675
|
+
raise UserInputError(
|
|
676
|
+
f"`gh auth token` failed (exit {completion.returncode}): "
|
|
677
|
+
f"{completion.stderr.strip()}"
|
|
678
|
+
)
|
|
679
|
+
token_text = completion.stdout.strip()
|
|
680
|
+
if not token_text:
|
|
681
|
+
raise UserInputError("`gh auth token` returned empty output")
|
|
682
|
+
return token_text
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def build_reviews_endpoint_url(owner: str, repo: str, pr_number: int) -> str:
|
|
686
|
+
"""Compose the full reviews-endpoint URL for a PR.
|
|
687
|
+
|
|
688
|
+
Args:
|
|
689
|
+
owner: Repository owner.
|
|
690
|
+
repo: Repository name.
|
|
691
|
+
pr_number: Pull request number.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
Full URL string ready to pass to :class:`urllib.request.Request`.
|
|
695
|
+
"""
|
|
696
|
+
api_path = REVIEWS_API_PATH_TEMPLATE.format(
|
|
697
|
+
owner=owner,
|
|
698
|
+
repo=repo,
|
|
699
|
+
pr_number=pr_number,
|
|
700
|
+
)
|
|
701
|
+
return f"{GITHUB_API_BASE_URL}{api_path}"
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _build_authenticated_request(
|
|
705
|
+
endpoint_url: str,
|
|
706
|
+
token: str,
|
|
707
|
+
all_request_fields: dict[str, object],
|
|
708
|
+
) -> urllib.request.Request:
|
|
709
|
+
encoded_body = json.dumps(all_request_fields).encode("utf-8")
|
|
710
|
+
request_object = urllib.request.Request(
|
|
711
|
+
url=endpoint_url,
|
|
712
|
+
data=encoded_body,
|
|
713
|
+
method=HTTP_METHOD_POST,
|
|
714
|
+
)
|
|
715
|
+
request_object.add_header(
|
|
716
|
+
HTTP_HEADER_AUTHORIZATION, f"{HTTP_AUTHORIZATION_BEARER_PREFIX}{token}"
|
|
717
|
+
)
|
|
718
|
+
request_object.add_header(HTTP_HEADER_ACCEPT, GITHUB_API_ACCEPT_HEADER)
|
|
719
|
+
request_object.add_header(HTTP_HEADER_CONTENT_TYPE, HTTP_REQUEST_CONTENT_TYPE)
|
|
720
|
+
request_object.add_header(HTTP_HEADER_GITHUB_API_VERSION, GITHUB_API_VERSION_HEADER)
|
|
721
|
+
request_object.add_header(HTTP_HEADER_USER_AGENT, GITHUB_API_USER_AGENT)
|
|
722
|
+
return request_object
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def execute_review_post_attempt(
|
|
726
|
+
endpoint_url: str,
|
|
727
|
+
token: str,
|
|
728
|
+
all_request_fields: dict[str, object],
|
|
729
|
+
) -> tuple[int, str]:
|
|
730
|
+
"""Make one HTTP POST to the reviews endpoint and return its outcome.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
endpoint_url: Full URL produced by :func:`build_reviews_endpoint_url`.
|
|
734
|
+
token: GitHub token string.
|
|
735
|
+
all_request_fields: Payload produced by :func:`build_review_request_payload`.
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
Tuple ``(status_code, response_body_text)``. ``status_code`` is the
|
|
739
|
+
HTTP status; ``response_body_text`` is the decoded response body.
|
|
740
|
+
Client- and server-error responses are returned through this path
|
|
741
|
+
(not raised) so the retry loop can decide whether to back off.
|
|
742
|
+
|
|
743
|
+
Raises:
|
|
744
|
+
urllib.error.URLError: transport-level failure (no DNS, no TCP).
|
|
745
|
+
"""
|
|
746
|
+
request_object = _build_authenticated_request(endpoint_url, token, all_request_fields)
|
|
747
|
+
try:
|
|
748
|
+
with urllib.request.urlopen(
|
|
749
|
+
request_object, timeout=HTTP_REQUEST_TIMEOUT_SECONDS
|
|
750
|
+
) as response_object:
|
|
751
|
+
response_body = response_object.read().decode("utf-8", errors="replace")
|
|
752
|
+
return response_object.status, response_body
|
|
753
|
+
except urllib.error.HTTPError as http_error:
|
|
754
|
+
error_body_text = http_error.read().decode("utf-8", errors="replace")
|
|
755
|
+
return http_error.code, error_body_text
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def post_review_with_retries(
|
|
759
|
+
endpoint_url: str,
|
|
760
|
+
token: str,
|
|
761
|
+
all_request_fields: dict[str, object],
|
|
762
|
+
) -> PostedReview:
|
|
763
|
+
"""POST the review with retries on non-success outcomes.
|
|
764
|
+
|
|
765
|
+
Backoffs between attempts come from ``ALL_RETRY_BACKOFF_SECONDS``.
|
|
766
|
+
After every retry has failed, raise :class:`RetryExhaustedError` so
|
|
767
|
+
the entry point can exit with the retry-exhausted code.
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
endpoint_url: Full URL produced by :func:`build_reviews_endpoint_url`.
|
|
771
|
+
token: GitHub token string.
|
|
772
|
+
all_request_fields: Payload produced by :func:`build_review_request_payload`.
|
|
773
|
+
|
|
774
|
+
Returns:
|
|
775
|
+
:class:`PostedReview` carrying the response's ``html_url``, raw
|
|
776
|
+
body, and status code.
|
|
777
|
+
|
|
778
|
+
Raises:
|
|
779
|
+
RetryExhaustedError: every attempt across the four-attempt loop
|
|
780
|
+
(one initial attempt plus three retries) returned a non-2xx
|
|
781
|
+
response, raised a transport-level
|
|
782
|
+
:class:`urllib.error.URLError`, or produced a 2xx response
|
|
783
|
+
whose body could not be parsed for ``html_url``.
|
|
784
|
+
"""
|
|
785
|
+
last_status_code: int = 0
|
|
786
|
+
last_response_text: str = ""
|
|
787
|
+
total_attempts = MAX_RETRY_ATTEMPTS + 1
|
|
788
|
+
for each_attempt_index in range(total_attempts):
|
|
789
|
+
try:
|
|
790
|
+
status_code, response_text = execute_review_post_attempt(
|
|
791
|
+
endpoint_url, token, all_request_fields
|
|
792
|
+
)
|
|
793
|
+
except urllib.error.URLError as transport_error:
|
|
794
|
+
status_code = 0
|
|
795
|
+
response_text = f"transport-level URLError: {transport_error.reason!r}"
|
|
796
|
+
last_status_code = status_code
|
|
797
|
+
last_response_text = response_text
|
|
798
|
+
is_success = (
|
|
799
|
+
HTTP_STATUS_SUCCESS_RANGE_LOW
|
|
800
|
+
<= status_code
|
|
801
|
+
< HTTP_STATUS_SUCCESS_RANGE_HIGH
|
|
802
|
+
)
|
|
803
|
+
if is_success:
|
|
804
|
+
try:
|
|
805
|
+
html_url_value = extract_html_url_field(response_text)
|
|
806
|
+
except RuntimeError as malformed_body_error:
|
|
807
|
+
raise RetryExhaustedError(
|
|
808
|
+
f"reviews POST returned {status_code} but the response body "
|
|
809
|
+
f"was unusable: {malformed_body_error}; "
|
|
810
|
+
f"body={response_text[:ERROR_RESPONSE_PREVIEW_CHARS]!r}"
|
|
811
|
+
) from malformed_body_error
|
|
812
|
+
return PostedReview(
|
|
813
|
+
html_url=html_url_value,
|
|
814
|
+
raw_response_text=response_text,
|
|
815
|
+
status_code=status_code,
|
|
816
|
+
)
|
|
817
|
+
is_last_attempt = each_attempt_index == MAX_RETRY_ATTEMPTS
|
|
818
|
+
if is_last_attempt:
|
|
819
|
+
break
|
|
820
|
+
time.sleep(ALL_RETRY_BACKOFF_SECONDS[each_attempt_index])
|
|
821
|
+
raise RetryExhaustedError(
|
|
822
|
+
f"reviews POST failed after {total_attempts} attempts; "
|
|
823
|
+
f"last status={last_status_code}; "
|
|
824
|
+
f"last body={last_response_text[:ERROR_RESPONSE_PREVIEW_CHARS]!r}"
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def extract_html_url_field(response_text: str) -> str:
|
|
829
|
+
"""Pull the ``html_url`` field out of a successful reviews POST response.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
response_text: Decoded response body.
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
Value of the ``html_url`` field.
|
|
836
|
+
|
|
837
|
+
Raises:
|
|
838
|
+
RuntimeError: response is not JSON, root is not an object, or
|
|
839
|
+
``html_url`` is missing or not a string.
|
|
840
|
+
"""
|
|
841
|
+
try:
|
|
842
|
+
parsed_value: object = json.loads(response_text)
|
|
843
|
+
except json.JSONDecodeError as decode_error:
|
|
844
|
+
raise RuntimeError(
|
|
845
|
+
f"review response is not parseable as JSON: {decode_error}"
|
|
846
|
+
) from decode_error
|
|
847
|
+
if not isinstance(parsed_value, dict):
|
|
848
|
+
raise RuntimeError(
|
|
849
|
+
f"review response root is not an object; got {type(parsed_value).__name__}"
|
|
850
|
+
)
|
|
851
|
+
typed_response: dict[str, object] = parsed_value
|
|
852
|
+
html_url_value = typed_response.get(REVIEW_RESPONSE_FIELD_HTML_URL)
|
|
853
|
+
if not isinstance(html_url_value, str):
|
|
854
|
+
raise RuntimeError(
|
|
855
|
+
f"review response missing string {REVIEW_RESPONSE_FIELD_HTML_URL!r}"
|
|
856
|
+
)
|
|
857
|
+
return html_url_value
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def post_audit_review(parsed_arguments: argparse.Namespace) -> PostedReview:
|
|
861
|
+
"""Top-level pipeline: load findings, build body, POST, return result.
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
parsed_arguments: Output of :func:`parse_command_line_arguments`.
|
|
865
|
+
|
|
866
|
+
Returns:
|
|
867
|
+
:class:`PostedReview` containing the new review's ``html_url``.
|
|
868
|
+
|
|
869
|
+
Raises:
|
|
870
|
+
UserInputError: bad CLI argument, malformed findings JSON, state
|
|
871
|
+
inconsistent with findings list (CLEAN+non-empty or
|
|
872
|
+
DIRTY+empty), missing ``gh`` CLI, ``gh auth token`` failure,
|
|
873
|
+
or ``audit-reply-template.md`` misconfigured (translated from
|
|
874
|
+
:class:`RuntimeError` at the boundary).
|
|
875
|
+
RetryExhaustedError: every retry failed against the reviews API.
|
|
876
|
+
"""
|
|
877
|
+
all_findings = parse_findings_json_file(parsed_arguments.findings_json)
|
|
878
|
+
is_clean_state = parsed_arguments.state == STATE_CLEAN
|
|
879
|
+
if is_clean_state and all_findings:
|
|
880
|
+
raise UserInputError(
|
|
881
|
+
f"state {STATE_CLEAN} requires an empty findings list; got "
|
|
882
|
+
f"{len(all_findings)} finding(s)"
|
|
883
|
+
)
|
|
884
|
+
if not is_clean_state and not all_findings:
|
|
885
|
+
raise UserInputError(
|
|
886
|
+
f"state {STATE_DIRTY} requires at least one finding; got an "
|
|
887
|
+
f"empty findings list"
|
|
888
|
+
)
|
|
889
|
+
try:
|
|
890
|
+
skeleton_text = load_audit_body_skeleton()
|
|
891
|
+
except RuntimeError as template_error:
|
|
892
|
+
raise UserInputError(
|
|
893
|
+
f"audit-reply-template.md misconfigured: {template_error}"
|
|
894
|
+
) from template_error
|
|
895
|
+
review_body_text = fill_audit_body_skeleton(
|
|
896
|
+
skeleton_text=skeleton_text,
|
|
897
|
+
skill_argument=parsed_arguments.skill,
|
|
898
|
+
state_argument=parsed_arguments.state,
|
|
899
|
+
commit_sha=parsed_arguments.commit,
|
|
900
|
+
all_findings=all_findings,
|
|
901
|
+
)
|
|
902
|
+
inline_comments_payload = (
|
|
903
|
+
[]
|
|
904
|
+
if parsed_arguments.state == STATE_CLEAN
|
|
905
|
+
else build_inline_comments_payload(parsed_arguments.skill, all_findings)
|
|
906
|
+
)
|
|
907
|
+
all_request_fields = build_review_request_payload(
|
|
908
|
+
state_argument=parsed_arguments.state,
|
|
909
|
+
commit_sha=parsed_arguments.commit,
|
|
910
|
+
review_body_text=review_body_text,
|
|
911
|
+
all_inline_comments=inline_comments_payload,
|
|
912
|
+
)
|
|
913
|
+
endpoint_url = build_reviews_endpoint_url(
|
|
914
|
+
owner=parsed_arguments.owner,
|
|
915
|
+
repo=parsed_arguments.repo,
|
|
916
|
+
pr_number=parsed_arguments.pr_number,
|
|
917
|
+
)
|
|
918
|
+
token_text = resolve_github_token()
|
|
919
|
+
return post_review_with_retries(endpoint_url, token_text, all_request_fields)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def main(all_arguments: list[str]) -> int:
|
|
923
|
+
"""Entry-point. Returns the process exit code.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
all_arguments: ``sys.argv[1:]`` or equivalent.
|
|
927
|
+
|
|
928
|
+
Returns:
|
|
929
|
+
``0`` on success (emits the new review's ``html_url`` to stdout),
|
|
930
|
+
``EXIT_CODE_USER_ERROR`` on user input failure,
|
|
931
|
+
``EXIT_CODE_RETRY_EXHAUSTED`` on retry exhaustion.
|
|
932
|
+
"""
|
|
933
|
+
try:
|
|
934
|
+
parsed_arguments = parse_command_line_arguments(all_arguments)
|
|
935
|
+
posted_review = post_audit_review(parsed_arguments)
|
|
936
|
+
except UserInputError as user_error:
|
|
937
|
+
print(f"post_audit_thread: {user_error}", file=sys.stderr)
|
|
938
|
+
return EXIT_CODE_USER_ERROR
|
|
939
|
+
except RetryExhaustedError as retry_error:
|
|
940
|
+
print(f"post_audit_thread: {retry_error}", file=sys.stderr)
|
|
941
|
+
return EXIT_CODE_RETRY_EXHAUSTED
|
|
942
|
+
print(posted_review.html_url)
|
|
943
|
+
return 0
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
if __name__ == "__main__":
|
|
947
|
+
raise SystemExit(main(sys.argv[1:]))
|