claude-dev-env 1.38.1 → 1.40.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 +199 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/post_audit_thread.py +1242 -0
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -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 +1116 -0
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/state-schema.md +1 -1
- package/agents/clean-coder.md +2 -2
- package/agents/pr-description-writer.md +150 -52
- 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/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- 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/pr_description_enforcer.py +57 -22
- 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/blocking/test_pr_description_enforcer.py +69 -8
- 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/pr_description_enforcer_constants.py +14 -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 +132 -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 +111 -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 +328 -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 +309 -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/copilot-review/SKILL.md +16 -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 +96 -2
- package/skills/monitor-open-prs/SKILL.md +14 -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 +229 -23
- package/skills/pr-converge/config/__init__.py +0 -0
- package/skills/pr-converge/config/constants.py +63 -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 +107 -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 +279 -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/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
- package/skills/qbug/SKILL.md +157 -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,1116 @@
|
|
|
1
|
+
"""Live smoke tests for post_audit_thread.py.
|
|
2
|
+
|
|
3
|
+
Runs against the real GitHub repo ``JonEcho/tests``. The class opens a
|
|
4
|
+
single throwaway draft PR in ``setUpClass`` and reuses it across every
|
|
5
|
+
test in the class; ``tearDownClass`` closes the PR with
|
|
6
|
+
``--delete-branch``. The CLEAN and DIRTY tests post real reviews against
|
|
7
|
+
the shared PR. The retry tests stub the GitHub endpoint with a localhost
|
|
8
|
+
HTTP server so the four-attempt retry loop runs deterministically without
|
|
9
|
+
contacting api.github.com, but still reference the shared PR's number and
|
|
10
|
+
HEAD SHA so the request URL is exercised end-to-end. Authentication uses
|
|
11
|
+
``gh auth token`` — empty token fails loudly per spec.
|
|
12
|
+
|
|
13
|
+
Test files are exempt from the no-comment, magic-value, banned-identifier,
|
|
14
|
+
and constants-location enforcer rules.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import http.server
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import stat
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import textwrap
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import unittest
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import uuid
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
THIS_FILE_DIRECTORY = Path(__file__).resolve().parent
|
|
37
|
+
SCRIPT_DIRECTORY = THIS_FILE_DIRECTORY.parent
|
|
38
|
+
|
|
39
|
+
sys.modules.pop("config", None)
|
|
40
|
+
if str(SCRIPT_DIRECTORY) not in sys.path:
|
|
41
|
+
sys.path.insert(0, str(SCRIPT_DIRECTORY))
|
|
42
|
+
|
|
43
|
+
from config.post_audit_thread_constants import ( # noqa: E402
|
|
44
|
+
ALL_GH_AUTH_TOKEN_COMMAND_PARTS,
|
|
45
|
+
ALL_RETRY_BACKOFF_SECONDS,
|
|
46
|
+
BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME,
|
|
47
|
+
GH_TOKEN_ENV_VAR_NAME,
|
|
48
|
+
GITHUB_TOKEN_ENV_VAR_NAME,
|
|
49
|
+
CLI_FLAG_COMMIT,
|
|
50
|
+
CLI_FLAG_FINDINGS_JSON,
|
|
51
|
+
CLI_FLAG_OWNER,
|
|
52
|
+
CLI_FLAG_PR_NUMBER,
|
|
53
|
+
CLI_FLAG_REPO,
|
|
54
|
+
CLI_FLAG_SKILL,
|
|
55
|
+
CLI_FLAG_STATE,
|
|
56
|
+
EXIT_CODE_RETRY_EXHAUSTED,
|
|
57
|
+
INLINE_COMMENT_SIDE_RIGHT,
|
|
58
|
+
JSON_FIELD_DESCRIPTION,
|
|
59
|
+
JSON_FIELD_FIX_SUMMARY,
|
|
60
|
+
JSON_FIELD_LINE,
|
|
61
|
+
JSON_FIELD_PATH,
|
|
62
|
+
JSON_FIELD_SEVERITY,
|
|
63
|
+
JSON_FIELD_SIDE,
|
|
64
|
+
MAX_RETRY_ATTEMPTS,
|
|
65
|
+
SEVERITY_TAG_P0,
|
|
66
|
+
SEVERITY_TAG_P1,
|
|
67
|
+
SEVERITY_TAG_P2,
|
|
68
|
+
SINGLE_REVIEW_API_PATH_TEMPLATE,
|
|
69
|
+
SINGLE_REVIEW_COMMENTS_API_PATH_TEMPLATE,
|
|
70
|
+
SKILL_BUGTEAM,
|
|
71
|
+
STATE_CLEAN,
|
|
72
|
+
STATE_DIRTY,
|
|
73
|
+
)
|
|
74
|
+
from post_audit_thread import ( # noqa: E402
|
|
75
|
+
UserInputError,
|
|
76
|
+
build_reviews_endpoint_url,
|
|
77
|
+
fetch_gh_token_for_account,
|
|
78
|
+
list_authenticated_gh_account_logins,
|
|
79
|
+
query_active_gh_user_login,
|
|
80
|
+
query_pull_request_author_login,
|
|
81
|
+
resolve_reviewer_token,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
LIVE_TEST_OWNER = "JonEcho"
|
|
85
|
+
LIVE_TEST_REPO = "tests"
|
|
86
|
+
LIVE_TEST_BRANCH_PREFIX = "pr-loop-test"
|
|
87
|
+
LIVE_TEST_PR_TITLE = "TEST: post_audit_thread smoke test (auto-closed)"
|
|
88
|
+
LIVE_TEST_PR_BODY = (
|
|
89
|
+
"Throwaway PR for post_audit_thread.py live smoke tests. "
|
|
90
|
+
"Auto-created by `test_post_audit_thread.py`; closed in `tearDownClass`."
|
|
91
|
+
)
|
|
92
|
+
LIVE_TEST_BASE_BRANCH = "main"
|
|
93
|
+
LIVE_TEST_FIXTURE_FILENAME = "post-audit-thread-fixture.md"
|
|
94
|
+
LIVE_TEST_FIXTURE_CONTENT = (
|
|
95
|
+
"# Throwaway test fixture\n\n"
|
|
96
|
+
"Created by `test_post_audit_thread.py` to satisfy GitHub's "
|
|
97
|
+
"non-empty PR-diff requirement. Deleted when the PR closes.\n"
|
|
98
|
+
)
|
|
99
|
+
LIVE_TEST_FIXTURE_LINE_FOR_FINDING_ONE = 1
|
|
100
|
+
LIVE_TEST_FIXTURE_LINE_FOR_FINDING_TWO = 2
|
|
101
|
+
LIVE_TEST_FIXTURE_LINE_FOR_FINDING_THREE = 3
|
|
102
|
+
|
|
103
|
+
SCRIPT_PATH = SCRIPT_DIRECTORY / "post_audit_thread.py"
|
|
104
|
+
REPO_FULL_NAME = f"{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}"
|
|
105
|
+
|
|
106
|
+
LIVE_TEST_AUDIT_ACCOUNT_NAME = "jl-cmd"
|
|
107
|
+
|
|
108
|
+
GH_EVENT_APPROVED = "APPROVED"
|
|
109
|
+
GH_EVENT_CHANGES_REQUESTED = "CHANGES_REQUESTED"
|
|
110
|
+
|
|
111
|
+
UUID_SUFFIX_LENGTH = 8
|
|
112
|
+
|
|
113
|
+
REVIEW_URL_ID_DELIMITER = "#pullrequestreview-"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _strip_read_only_and_retry(
|
|
117
|
+
removal_function: Any, target_path: str, *_exc_info: Any
|
|
118
|
+
) -> None:
|
|
119
|
+
try:
|
|
120
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
121
|
+
removal_function(target_path)
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def force_remove_directory(target_path: Path) -> None:
|
|
127
|
+
if not target_path.exists():
|
|
128
|
+
return
|
|
129
|
+
handler_kwargs: dict[str, Any]
|
|
130
|
+
if sys.version_info >= (3, 12):
|
|
131
|
+
handler_kwargs = {"onexc": _strip_read_only_and_retry}
|
|
132
|
+
else:
|
|
133
|
+
handler_kwargs = {"onerror": _strip_read_only_and_retry}
|
|
134
|
+
try:
|
|
135
|
+
shutil.rmtree(str(target_path), **handler_kwargs)
|
|
136
|
+
except OSError as removal_error:
|
|
137
|
+
sys.stderr.write(
|
|
138
|
+
f"force_remove_directory: could not remove {target_path}: {removal_error}\n"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve_gh_auth_token() -> str:
|
|
143
|
+
completion = subprocess.run(
|
|
144
|
+
list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS),
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
encoding="utf-8",
|
|
148
|
+
check=False,
|
|
149
|
+
)
|
|
150
|
+
if completion.returncode != 0:
|
|
151
|
+
raise AssertionError(
|
|
152
|
+
f"`gh auth token` failed: rc={completion.returncode} "
|
|
153
|
+
f"stderr={completion.stderr.strip()} — live tests require gh to "
|
|
154
|
+
f"be authenticated against github.com"
|
|
155
|
+
)
|
|
156
|
+
token_text = completion.stdout.strip()
|
|
157
|
+
if not token_text:
|
|
158
|
+
raise AssertionError(
|
|
159
|
+
"`gh auth token` returned empty output — not authenticated"
|
|
160
|
+
)
|
|
161
|
+
return token_text
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def resolve_audit_account_token(account_name: str) -> str:
|
|
165
|
+
completion = subprocess.run(
|
|
166
|
+
list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS) + ["--user", account_name],
|
|
167
|
+
capture_output=True,
|
|
168
|
+
text=True,
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
check=False,
|
|
171
|
+
)
|
|
172
|
+
if completion.returncode != 0:
|
|
173
|
+
raise AssertionError(
|
|
174
|
+
f"`gh auth token --user {account_name}` failed — the audit-side "
|
|
175
|
+
f"account must be authenticated separately from the PR author so "
|
|
176
|
+
f"GitHub allows APPROVE / REQUEST_CHANGES on the throwaway PR. "
|
|
177
|
+
f"rc={completion.returncode} stderr={completion.stderr.strip()}"
|
|
178
|
+
)
|
|
179
|
+
token_text = completion.stdout.strip()
|
|
180
|
+
if not token_text:
|
|
181
|
+
raise AssertionError(
|
|
182
|
+
f"`gh auth token --user {account_name}` returned empty output"
|
|
183
|
+
)
|
|
184
|
+
return token_text
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def gh_api_object_json(api_path: str) -> dict[str, Any]:
|
|
188
|
+
completion = subprocess.run(
|
|
189
|
+
["gh", "api", api_path],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
text=True,
|
|
192
|
+
encoding="utf-8",
|
|
193
|
+
check=True,
|
|
194
|
+
)
|
|
195
|
+
parsed_object: Any = json.loads(completion.stdout)
|
|
196
|
+
if not isinstance(parsed_object, dict):
|
|
197
|
+
raise AssertionError(
|
|
198
|
+
f"unexpected gh api object shape: {type(parsed_object).__name__}"
|
|
199
|
+
)
|
|
200
|
+
return parsed_object
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def review_id_from_html_url(html_url: str) -> int:
|
|
204
|
+
suffix_parts = html_url.rsplit(REVIEW_URL_ID_DELIMITER, 1)
|
|
205
|
+
if len(suffix_parts) != 2:
|
|
206
|
+
raise AssertionError(
|
|
207
|
+
f"html_url {html_url!r} missing {REVIEW_URL_ID_DELIMITER!r} suffix"
|
|
208
|
+
)
|
|
209
|
+
return int(suffix_parts[1])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def gh_api_paginated_json(api_path: str) -> list[dict[str, Any]]:
|
|
213
|
+
completion = subprocess.run(
|
|
214
|
+
["gh", "api", api_path, "--paginate", "--slurp"],
|
|
215
|
+
capture_output=True,
|
|
216
|
+
text=True,
|
|
217
|
+
encoding="utf-8",
|
|
218
|
+
check=True,
|
|
219
|
+
)
|
|
220
|
+
parsed: Any = json.loads(completion.stdout)
|
|
221
|
+
if not isinstance(parsed, list):
|
|
222
|
+
raise AssertionError(
|
|
223
|
+
f"unexpected gh api response shape: {type(parsed).__name__}"
|
|
224
|
+
)
|
|
225
|
+
flattened: list[dict[str, Any]] = []
|
|
226
|
+
for each_page in parsed:
|
|
227
|
+
if isinstance(each_page, list):
|
|
228
|
+
for each_item in each_page:
|
|
229
|
+
if isinstance(each_item, dict):
|
|
230
|
+
flattened.append(each_item)
|
|
231
|
+
elif isinstance(each_page, dict):
|
|
232
|
+
flattened.append(each_page)
|
|
233
|
+
return flattened
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def write_pr_body_temporary_file(body_text: str) -> Path:
|
|
237
|
+
handle, body_path_str = tempfile.mkstemp(suffix=".md", prefix="post-audit-pr-body-")
|
|
238
|
+
os.close(handle)
|
|
239
|
+
body_path = Path(body_path_str)
|
|
240
|
+
body_path.write_text(body_text, encoding="utf-8")
|
|
241
|
+
return body_path
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def create_throwaway_pr(
|
|
245
|
+
clone_directory: Path,
|
|
246
|
+
branch_name: str,
|
|
247
|
+
) -> tuple[int, str]:
|
|
248
|
+
subprocess.run(
|
|
249
|
+
[
|
|
250
|
+
"gh",
|
|
251
|
+
"repo",
|
|
252
|
+
"clone",
|
|
253
|
+
REPO_FULL_NAME,
|
|
254
|
+
str(clone_directory),
|
|
255
|
+
"--",
|
|
256
|
+
"--branch",
|
|
257
|
+
LIVE_TEST_BASE_BRANCH,
|
|
258
|
+
"--single-branch",
|
|
259
|
+
"--depth",
|
|
260
|
+
"1",
|
|
261
|
+
],
|
|
262
|
+
check=True,
|
|
263
|
+
capture_output=True,
|
|
264
|
+
text=True,
|
|
265
|
+
)
|
|
266
|
+
subprocess.run(
|
|
267
|
+
[
|
|
268
|
+
"git",
|
|
269
|
+
"-C",
|
|
270
|
+
str(clone_directory),
|
|
271
|
+
"config",
|
|
272
|
+
"--local",
|
|
273
|
+
"core.hooksPath",
|
|
274
|
+
str(clone_directory / ".git" / "hooks"),
|
|
275
|
+
],
|
|
276
|
+
check=True,
|
|
277
|
+
capture_output=True,
|
|
278
|
+
text=True,
|
|
279
|
+
)
|
|
280
|
+
subprocess.run(
|
|
281
|
+
["git", "-C", str(clone_directory), "checkout", "-b", branch_name],
|
|
282
|
+
check=True,
|
|
283
|
+
capture_output=True,
|
|
284
|
+
text=True,
|
|
285
|
+
)
|
|
286
|
+
fixture_path = clone_directory / LIVE_TEST_FIXTURE_FILENAME
|
|
287
|
+
fixture_path.write_text(LIVE_TEST_FIXTURE_CONTENT, encoding="utf-8")
|
|
288
|
+
subprocess.run(
|
|
289
|
+
["git", "-C", str(clone_directory), "add", LIVE_TEST_FIXTURE_FILENAME],
|
|
290
|
+
check=True,
|
|
291
|
+
capture_output=True,
|
|
292
|
+
text=True,
|
|
293
|
+
)
|
|
294
|
+
subprocess.run(
|
|
295
|
+
[
|
|
296
|
+
"git",
|
|
297
|
+
"-C",
|
|
298
|
+
str(clone_directory),
|
|
299
|
+
"commit",
|
|
300
|
+
"-m",
|
|
301
|
+
"test: post_audit_thread.py live smoke fixture",
|
|
302
|
+
],
|
|
303
|
+
check=True,
|
|
304
|
+
capture_output=True,
|
|
305
|
+
text=True,
|
|
306
|
+
)
|
|
307
|
+
head_sha_completion = subprocess.run(
|
|
308
|
+
["git", "-C", str(clone_directory), "rev-parse", "HEAD"],
|
|
309
|
+
capture_output=True,
|
|
310
|
+
text=True,
|
|
311
|
+
encoding="utf-8",
|
|
312
|
+
check=True,
|
|
313
|
+
)
|
|
314
|
+
head_sha = head_sha_completion.stdout.strip()
|
|
315
|
+
subprocess.run(
|
|
316
|
+
["git", "-C", str(clone_directory), "push", "-u", "origin", branch_name],
|
|
317
|
+
check=True,
|
|
318
|
+
capture_output=True,
|
|
319
|
+
text=True,
|
|
320
|
+
)
|
|
321
|
+
body_path = write_pr_body_temporary_file(LIVE_TEST_PR_BODY)
|
|
322
|
+
try:
|
|
323
|
+
create_completion = subprocess.run(
|
|
324
|
+
[
|
|
325
|
+
"gh",
|
|
326
|
+
"pr",
|
|
327
|
+
"create",
|
|
328
|
+
"--draft",
|
|
329
|
+
"--head",
|
|
330
|
+
branch_name,
|
|
331
|
+
"--base",
|
|
332
|
+
LIVE_TEST_BASE_BRANCH,
|
|
333
|
+
"--title",
|
|
334
|
+
LIVE_TEST_PR_TITLE,
|
|
335
|
+
"--body-file",
|
|
336
|
+
str(body_path),
|
|
337
|
+
"--repo",
|
|
338
|
+
REPO_FULL_NAME,
|
|
339
|
+
],
|
|
340
|
+
capture_output=True,
|
|
341
|
+
text=True,
|
|
342
|
+
encoding="utf-8",
|
|
343
|
+
check=True,
|
|
344
|
+
cwd=str(clone_directory),
|
|
345
|
+
)
|
|
346
|
+
finally:
|
|
347
|
+
try:
|
|
348
|
+
body_path.unlink()
|
|
349
|
+
except OSError:
|
|
350
|
+
pass
|
|
351
|
+
pr_url = create_completion.stdout.strip().splitlines()[-1]
|
|
352
|
+
parsed_pr_url = urllib.parse.urlparse(pr_url)
|
|
353
|
+
pr_number = int(parsed_pr_url.path.rsplit("/", 1)[-1])
|
|
354
|
+
return pr_number, head_sha
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def close_throwaway_pr(pr_number: int) -> None:
|
|
358
|
+
subprocess.run(
|
|
359
|
+
[
|
|
360
|
+
"gh",
|
|
361
|
+
"pr",
|
|
362
|
+
"close",
|
|
363
|
+
str(pr_number),
|
|
364
|
+
"--delete-branch",
|
|
365
|
+
"--repo",
|
|
366
|
+
REPO_FULL_NAME,
|
|
367
|
+
],
|
|
368
|
+
capture_output=True,
|
|
369
|
+
text=True,
|
|
370
|
+
check=False,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def remove_local_clone(clone_directory: Path) -> None:
|
|
375
|
+
force_remove_directory(clone_directory)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def best_effort_delete_remote_branch(branch_name: str) -> None:
|
|
379
|
+
try:
|
|
380
|
+
subprocess.run(
|
|
381
|
+
[
|
|
382
|
+
"gh",
|
|
383
|
+
"api",
|
|
384
|
+
"--method",
|
|
385
|
+
"DELETE",
|
|
386
|
+
f"repos/{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}/git/refs/heads/{branch_name}",
|
|
387
|
+
],
|
|
388
|
+
capture_output=True,
|
|
389
|
+
text=True,
|
|
390
|
+
check=False,
|
|
391
|
+
)
|
|
392
|
+
except OSError as deletion_error:
|
|
393
|
+
sys.stderr.write(
|
|
394
|
+
f"best_effort_delete_remote_branch: could not delete "
|
|
395
|
+
f"{branch_name}: {deletion_error}\n"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def write_findings_json(findings_payload: list[dict[str, Any]]) -> Path:
|
|
400
|
+
handle, findings_path_str = tempfile.mkstemp(
|
|
401
|
+
suffix=".json", prefix="post-audit-findings-"
|
|
402
|
+
)
|
|
403
|
+
os.close(handle)
|
|
404
|
+
findings_path = Path(findings_path_str)
|
|
405
|
+
findings_path.write_text(json.dumps(findings_payload), encoding="utf-8")
|
|
406
|
+
return findings_path
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def invoke_post_audit_thread_script(
|
|
410
|
+
pr_number: int,
|
|
411
|
+
head_sha: str,
|
|
412
|
+
state_argument: str,
|
|
413
|
+
findings_json_path: Path,
|
|
414
|
+
audit_token: str,
|
|
415
|
+
) -> subprocess.CompletedProcess[str]:
|
|
416
|
+
child_environment = dict(os.environ)
|
|
417
|
+
child_environment[GH_TOKEN_ENV_VAR_NAME] = audit_token
|
|
418
|
+
return subprocess.run(
|
|
419
|
+
[
|
|
420
|
+
sys.executable,
|
|
421
|
+
str(SCRIPT_PATH),
|
|
422
|
+
CLI_FLAG_SKILL,
|
|
423
|
+
SKILL_BUGTEAM,
|
|
424
|
+
CLI_FLAG_OWNER,
|
|
425
|
+
LIVE_TEST_OWNER,
|
|
426
|
+
CLI_FLAG_REPO,
|
|
427
|
+
LIVE_TEST_REPO,
|
|
428
|
+
CLI_FLAG_PR_NUMBER,
|
|
429
|
+
str(pr_number),
|
|
430
|
+
CLI_FLAG_COMMIT,
|
|
431
|
+
head_sha,
|
|
432
|
+
CLI_FLAG_STATE,
|
|
433
|
+
state_argument,
|
|
434
|
+
CLI_FLAG_FINDINGS_JSON,
|
|
435
|
+
str(findings_json_path),
|
|
436
|
+
],
|
|
437
|
+
capture_output=True,
|
|
438
|
+
text=True,
|
|
439
|
+
encoding="utf-8",
|
|
440
|
+
check=False,
|
|
441
|
+
env=child_environment,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
STUB_SERVER_HOST = "127.0.0.1"
|
|
446
|
+
STUB_SERVER_PORT_DYNAMIC = 0
|
|
447
|
+
STUB_RESPONSE_HEADER_CONTENT_TYPE = "Content-Type"
|
|
448
|
+
STUB_RESPONSE_HEADER_CONTENT_LENGTH = "Content-Length"
|
|
449
|
+
STUB_RESPONSE_CONTENT_TYPE_VALUE = "application/json"
|
|
450
|
+
STUB_HTTP_STATUS_BAD_GATEWAY = 502
|
|
451
|
+
STUB_HTTP_STATUS_OK = 200
|
|
452
|
+
STUB_502_RESPONSE_BODY_BYTES = json.dumps(
|
|
453
|
+
{"message": "stub server: simulated transient 502 for retry test"}
|
|
454
|
+
).encode("utf-8")
|
|
455
|
+
STUB_200_RESPONSE_BODY_BYTES = json.dumps(
|
|
456
|
+
{
|
|
457
|
+
"html_url": (
|
|
458
|
+
"https://github.com/stub-host/stub-repo/pull/0#pullrequestreview-1"
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
).encode("utf-8")
|
|
462
|
+
STUB_SERVER_SHUTDOWN_JOIN_TIMEOUT_SECONDS = 5.0
|
|
463
|
+
|
|
464
|
+
FAILURE_COUNT_FOR_RETRY_SUCCESS = 1
|
|
465
|
+
TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS = 2
|
|
466
|
+
FAILURE_COUNT_FOR_RETRY_EXHAUSTION = MAX_RETRY_ATTEMPTS + 1
|
|
467
|
+
TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION = MAX_RETRY_ATTEMPTS + 1
|
|
468
|
+
|
|
469
|
+
BACKOFF_TIMING_EPSILON_SECONDS = 0.1
|
|
470
|
+
TOTAL_BACKOFF_SECONDS = sum(ALL_RETRY_BACKOFF_SECONDS)
|
|
471
|
+
|
|
472
|
+
EXPECTED_RETRY_SUCCESS_ELAPSED_LOWER_BOUND_SECONDS = (
|
|
473
|
+
ALL_RETRY_BACKOFF_SECONDS[0] - BACKOFF_TIMING_EPSILON_SECONDS
|
|
474
|
+
)
|
|
475
|
+
EXPECTED_RETRY_EXHAUSTION_ELAPSED_LOWER_BOUND_SECONDS = (
|
|
476
|
+
TOTAL_BACKOFF_SECONDS - BACKOFF_TIMING_EPSILON_SECONDS
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
EXIT_CODE_SUCCESS = 0
|
|
480
|
+
|
|
481
|
+
LAUNCHER_SOURCE_CODE = textwrap.dedent(
|
|
482
|
+
"""
|
|
483
|
+
import sys
|
|
484
|
+
sys.path.insert(0, sys.argv[1])
|
|
485
|
+
import post_audit_thread
|
|
486
|
+
post_audit_thread.GITHUB_API_BASE_URL = sys.argv[2]
|
|
487
|
+
sys.exit(post_audit_thread.main(sys.argv[3:]))
|
|
488
|
+
"""
|
|
489
|
+
).strip()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class _StubReviewsServer(http.server.HTTPServer):
|
|
493
|
+
"""HTTP server that records POST count and serves canned 502/200 responses."""
|
|
494
|
+
|
|
495
|
+
request_count: int = 0
|
|
496
|
+
failure_count: int = 0
|
|
497
|
+
recorded_request_path: str = ""
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class _StubReviewsHandler(http.server.BaseHTTPRequestHandler):
|
|
501
|
+
"""Returns 502 for the first ``failure_count`` POSTs, then 200 thereafter.
|
|
502
|
+
|
|
503
|
+
State lives on the owning :class:`_StubReviewsServer` instance so the
|
|
504
|
+
test can inspect the final request count and the path of the last
|
|
505
|
+
received POST after the script exits.
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
def do_POST(self) -> None:
|
|
509
|
+
owning_server = self.server
|
|
510
|
+
owning_server.request_count += 1
|
|
511
|
+
owning_server.recorded_request_path = self.path
|
|
512
|
+
if owning_server.request_count <= owning_server.failure_count:
|
|
513
|
+
response_status = STUB_HTTP_STATUS_BAD_GATEWAY
|
|
514
|
+
response_body_bytes = STUB_502_RESPONSE_BODY_BYTES
|
|
515
|
+
else:
|
|
516
|
+
response_status = STUB_HTTP_STATUS_OK
|
|
517
|
+
response_body_bytes = STUB_200_RESPONSE_BODY_BYTES
|
|
518
|
+
self.send_response(response_status)
|
|
519
|
+
self.send_header(
|
|
520
|
+
STUB_RESPONSE_HEADER_CONTENT_TYPE, STUB_RESPONSE_CONTENT_TYPE_VALUE
|
|
521
|
+
)
|
|
522
|
+
self.send_header(
|
|
523
|
+
STUB_RESPONSE_HEADER_CONTENT_LENGTH, str(len(response_body_bytes))
|
|
524
|
+
)
|
|
525
|
+
self.end_headers()
|
|
526
|
+
self.wfile.write(response_body_bytes)
|
|
527
|
+
|
|
528
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def spawn_stub_reviews_server(
|
|
533
|
+
failure_count: int,
|
|
534
|
+
) -> tuple[_StubReviewsServer, threading.Thread]:
|
|
535
|
+
"""Start a localhost stub server returning leading 502s then 200s.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
failure_count: Number of leading POSTs the stub responds to with
|
|
539
|
+
502. Subsequent POSTs receive a synthetic 200 carrying a fake
|
|
540
|
+
``html_url``. Set above the script's total retry budget to
|
|
541
|
+
force the retry-exhaustion path end-to-end.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Tuple of the stub server (bound to a random port on
|
|
545
|
+
``127.0.0.1``) and its serving thread.
|
|
546
|
+
"""
|
|
547
|
+
stub_server = _StubReviewsServer(
|
|
548
|
+
(STUB_SERVER_HOST, STUB_SERVER_PORT_DYNAMIC), _StubReviewsHandler
|
|
549
|
+
)
|
|
550
|
+
stub_server.request_count = 0
|
|
551
|
+
stub_server.failure_count = failure_count
|
|
552
|
+
stub_server.recorded_request_path = ""
|
|
553
|
+
stub_thread = threading.Thread(target=stub_server.serve_forever, daemon=True)
|
|
554
|
+
stub_thread.start()
|
|
555
|
+
return stub_server, stub_thread
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def shutdown_stub_reviews_server(
|
|
559
|
+
stub_server: _StubReviewsServer,
|
|
560
|
+
stub_thread: threading.Thread,
|
|
561
|
+
) -> None:
|
|
562
|
+
"""Stop the stub server and join its serving thread.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
stub_server: Server returned by :func:`spawn_stub_reviews_server`.
|
|
566
|
+
stub_thread: Serving thread returned by
|
|
567
|
+
:func:`spawn_stub_reviews_server`.
|
|
568
|
+
"""
|
|
569
|
+
stub_server.shutdown()
|
|
570
|
+
stub_server.server_close()
|
|
571
|
+
stub_thread.join(timeout=STUB_SERVER_SHUTDOWN_JOIN_TIMEOUT_SECONDS)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def stub_reviews_server_base_url(stub_server: _StubReviewsServer) -> str:
|
|
575
|
+
"""Return ``http://host:port`` for a bound stub server.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
stub_server: Server returned by :func:`spawn_stub_reviews_server`.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Base URL suitable for assigning to
|
|
582
|
+
``post_audit_thread.GITHUB_API_BASE_URL`` inside the launcher
|
|
583
|
+
subprocess so the script targets the stub instead of api.github.com.
|
|
584
|
+
"""
|
|
585
|
+
host_address, bound_port = stub_server.server_address[:2]
|
|
586
|
+
return f"http://{host_address}:{bound_port}"
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def invoke_post_audit_thread_with_url_override(
|
|
590
|
+
pr_number: int,
|
|
591
|
+
head_sha: str,
|
|
592
|
+
state_argument: str,
|
|
593
|
+
findings_json_path: Path,
|
|
594
|
+
audit_token: str,
|
|
595
|
+
overridden_base_url: str,
|
|
596
|
+
) -> subprocess.CompletedProcess[str]:
|
|
597
|
+
"""Subprocess-invoke the script with ``GITHUB_API_BASE_URL`` redirected.
|
|
598
|
+
|
|
599
|
+
The subprocess runs a short launcher (``LAUNCHER_SOURCE_CODE``) that
|
|
600
|
+
imports ``post_audit_thread`` as a module, rebinds its
|
|
601
|
+
``GITHUB_API_BASE_URL`` attribute to ``overridden_base_url``, then
|
|
602
|
+
delegates to ``main()``. Lets the retry tests point the script at the
|
|
603
|
+
local stub server without modifying production source.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
pr_number: Throwaway PR number created by ``setUpClass``.
|
|
607
|
+
head_sha: HEAD SHA the script attaches the review to.
|
|
608
|
+
state_argument: ``CLEAN`` or ``DIRTY``.
|
|
609
|
+
findings_json_path: Path to the (empty-list) findings JSON.
|
|
610
|
+
audit_token: Token assigned to ``GH_TOKEN`` in the child env.
|
|
611
|
+
overridden_base_url: Base URL handed to the launcher (the local
|
|
612
|
+
stub server URL).
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
Completed subprocess result with ``returncode``, ``stdout``, and
|
|
616
|
+
``stderr`` for the test to inspect.
|
|
617
|
+
"""
|
|
618
|
+
child_environment = dict(os.environ)
|
|
619
|
+
child_environment[GH_TOKEN_ENV_VAR_NAME] = audit_token
|
|
620
|
+
return subprocess.run(
|
|
621
|
+
[
|
|
622
|
+
sys.executable,
|
|
623
|
+
"-c",
|
|
624
|
+
LAUNCHER_SOURCE_CODE,
|
|
625
|
+
str(SCRIPT_DIRECTORY),
|
|
626
|
+
overridden_base_url,
|
|
627
|
+
CLI_FLAG_SKILL,
|
|
628
|
+
SKILL_BUGTEAM,
|
|
629
|
+
CLI_FLAG_OWNER,
|
|
630
|
+
LIVE_TEST_OWNER,
|
|
631
|
+
CLI_FLAG_REPO,
|
|
632
|
+
LIVE_TEST_REPO,
|
|
633
|
+
CLI_FLAG_PR_NUMBER,
|
|
634
|
+
str(pr_number),
|
|
635
|
+
CLI_FLAG_COMMIT,
|
|
636
|
+
head_sha,
|
|
637
|
+
CLI_FLAG_STATE,
|
|
638
|
+
state_argument,
|
|
639
|
+
CLI_FLAG_FINDINGS_JSON,
|
|
640
|
+
str(findings_json_path),
|
|
641
|
+
],
|
|
642
|
+
capture_output=True,
|
|
643
|
+
text=True,
|
|
644
|
+
encoding="utf-8",
|
|
645
|
+
check=False,
|
|
646
|
+
env=child_environment,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
class LivePostAuditThreadTests(unittest.TestCase):
|
|
651
|
+
"""Live smoke tests for post_audit_thread.py against JonEcho/tests.
|
|
652
|
+
|
|
653
|
+
Every test in this class reuses a single throwaway draft PR created
|
|
654
|
+
in :meth:`setUpClass` and closed in :meth:`tearDownClass`. The CLEAN
|
|
655
|
+
and DIRTY tests post real reviews against that PR; the retry tests
|
|
656
|
+
redirect the script's HTTP layer to a localhost stub server so the
|
|
657
|
+
retry loop runs deterministically without touching api.github.com,
|
|
658
|
+
while still consuming the shared PR's number and HEAD SHA.
|
|
659
|
+
"""
|
|
660
|
+
|
|
661
|
+
audit_account_token: str
|
|
662
|
+
local_clone_directory: Path
|
|
663
|
+
branch_name: str
|
|
664
|
+
pr_number: int
|
|
665
|
+
head_sha: str
|
|
666
|
+
|
|
667
|
+
@classmethod
|
|
668
|
+
def setUpClass(cls) -> None:
|
|
669
|
+
resolve_gh_auth_token()
|
|
670
|
+
cls.audit_account_token = resolve_audit_account_token(
|
|
671
|
+
LIVE_TEST_AUDIT_ACCOUNT_NAME
|
|
672
|
+
)
|
|
673
|
+
unique_suffix = uuid.uuid4().hex[:UUID_SUFFIX_LENGTH]
|
|
674
|
+
cls.branch_name = f"{LIVE_TEST_BRANCH_PREFIX}/{unique_suffix}"
|
|
675
|
+
cls.local_clone_directory = Path(
|
|
676
|
+
tempfile.mkdtemp(prefix=f"post-audit-thread-test-{unique_suffix}-")
|
|
677
|
+
)
|
|
678
|
+
cls.pr_number = 0
|
|
679
|
+
cls.head_sha = ""
|
|
680
|
+
try:
|
|
681
|
+
cls.pr_number, cls.head_sha = create_throwaway_pr(
|
|
682
|
+
cls.local_clone_directory,
|
|
683
|
+
cls.branch_name,
|
|
684
|
+
)
|
|
685
|
+
except Exception:
|
|
686
|
+
try:
|
|
687
|
+
remove_local_clone(cls.local_clone_directory)
|
|
688
|
+
finally:
|
|
689
|
+
best_effort_delete_remote_branch(cls.branch_name)
|
|
690
|
+
raise
|
|
691
|
+
|
|
692
|
+
@classmethod
|
|
693
|
+
def tearDownClass(cls) -> None:
|
|
694
|
+
try:
|
|
695
|
+
if cls.pr_number > 0:
|
|
696
|
+
close_throwaway_pr(cls.pr_number)
|
|
697
|
+
finally:
|
|
698
|
+
try:
|
|
699
|
+
remove_local_clone(cls.local_clone_directory)
|
|
700
|
+
finally:
|
|
701
|
+
best_effort_delete_remote_branch(cls.branch_name)
|
|
702
|
+
|
|
703
|
+
def _assert_review_state_for_url(
|
|
704
|
+
self, html_url: str, expected_state: str
|
|
705
|
+
) -> dict[str, Any]:
|
|
706
|
+
review_id = review_id_from_html_url(html_url)
|
|
707
|
+
single_review_api_path = SINGLE_REVIEW_API_PATH_TEMPLATE.format(
|
|
708
|
+
owner=LIVE_TEST_OWNER,
|
|
709
|
+
repo=LIVE_TEST_REPO,
|
|
710
|
+
pr_number=self.pr_number,
|
|
711
|
+
review_id=review_id,
|
|
712
|
+
)
|
|
713
|
+
single_review = gh_api_object_json(single_review_api_path)
|
|
714
|
+
self.assertEqual(
|
|
715
|
+
single_review.get("state"),
|
|
716
|
+
expected_state,
|
|
717
|
+
f"unexpected review state for {html_url!r}: {single_review!r}",
|
|
718
|
+
)
|
|
719
|
+
return single_review
|
|
720
|
+
|
|
721
|
+
def _fetch_comments_for_review(self, html_url: str) -> list[dict[str, Any]]:
|
|
722
|
+
review_id = review_id_from_html_url(html_url)
|
|
723
|
+
review_comments_api_path = SINGLE_REVIEW_COMMENTS_API_PATH_TEMPLATE.format(
|
|
724
|
+
owner=LIVE_TEST_OWNER,
|
|
725
|
+
repo=LIVE_TEST_REPO,
|
|
726
|
+
pr_number=self.pr_number,
|
|
727
|
+
review_id=review_id,
|
|
728
|
+
)
|
|
729
|
+
return gh_api_paginated_json(review_comments_api_path)
|
|
730
|
+
|
|
731
|
+
def _run_script_capturing_html_url(
|
|
732
|
+
self,
|
|
733
|
+
state_argument: str,
|
|
734
|
+
findings_payload: list[dict[str, Any]],
|
|
735
|
+
) -> str:
|
|
736
|
+
findings_path = write_findings_json(findings_payload)
|
|
737
|
+
try:
|
|
738
|
+
completion = invoke_post_audit_thread_script(
|
|
739
|
+
pr_number=self.pr_number,
|
|
740
|
+
head_sha=self.head_sha,
|
|
741
|
+
state_argument=state_argument,
|
|
742
|
+
findings_json_path=findings_path,
|
|
743
|
+
audit_token=self.audit_account_token,
|
|
744
|
+
)
|
|
745
|
+
finally:
|
|
746
|
+
try:
|
|
747
|
+
findings_path.unlink()
|
|
748
|
+
except OSError:
|
|
749
|
+
pass
|
|
750
|
+
self.assertEqual(
|
|
751
|
+
completion.returncode,
|
|
752
|
+
0,
|
|
753
|
+
f"script exited {completion.returncode}; stdout={completion.stdout!r} "
|
|
754
|
+
f"stderr={completion.stderr!r}",
|
|
755
|
+
)
|
|
756
|
+
emitted_html_url = completion.stdout.strip().splitlines()[-1]
|
|
757
|
+
self.assertTrue(
|
|
758
|
+
emitted_html_url.startswith("https://github.com/"),
|
|
759
|
+
f"expected an html_url on stdout, got {emitted_html_url!r}",
|
|
760
|
+
)
|
|
761
|
+
return emitted_html_url
|
|
762
|
+
|
|
763
|
+
def test_clean_state_posts_approved_review_with_empty_comments(self) -> None:
|
|
764
|
+
emitted_html_url = self._run_script_capturing_html_url(
|
|
765
|
+
state_argument=STATE_CLEAN, findings_payload=[]
|
|
766
|
+
)
|
|
767
|
+
self._assert_review_state_for_url(emitted_html_url, GH_EVENT_APPROVED)
|
|
768
|
+
review_comments = self._fetch_comments_for_review(emitted_html_url)
|
|
769
|
+
self.assertEqual(
|
|
770
|
+
len(review_comments),
|
|
771
|
+
0,
|
|
772
|
+
f"CLEAN state should produce zero inline comments on this review; "
|
|
773
|
+
f"saw {len(review_comments)}: {review_comments!r}",
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def test_dirty_state_with_three_findings_posts_changes_requested_with_three_inline_threads(
|
|
777
|
+
self,
|
|
778
|
+
) -> None:
|
|
779
|
+
findings_payload: list[dict[str, Any]] = [
|
|
780
|
+
{
|
|
781
|
+
JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
|
|
782
|
+
JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_ONE,
|
|
783
|
+
JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
|
|
784
|
+
JSON_FIELD_SEVERITY: SEVERITY_TAG_P0,
|
|
785
|
+
JSON_FIELD_DESCRIPTION: "Smoke finding one (heading line).",
|
|
786
|
+
JSON_FIELD_FIX_SUMMARY: "Trim the leading marker (smoke fix one).",
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
|
|
790
|
+
JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_TWO,
|
|
791
|
+
JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
|
|
792
|
+
JSON_FIELD_SEVERITY: SEVERITY_TAG_P1,
|
|
793
|
+
JSON_FIELD_DESCRIPTION: "Smoke finding two (blank-line anchor).",
|
|
794
|
+
JSON_FIELD_FIX_SUMMARY: "Collapse the blank separator (smoke fix two).",
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
|
|
798
|
+
JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_THREE,
|
|
799
|
+
JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
|
|
800
|
+
JSON_FIELD_SEVERITY: SEVERITY_TAG_P2,
|
|
801
|
+
JSON_FIELD_DESCRIPTION: "Smoke finding three (body-line anchor).",
|
|
802
|
+
JSON_FIELD_FIX_SUMMARY: "Tighten the description (smoke fix three).",
|
|
803
|
+
},
|
|
804
|
+
]
|
|
805
|
+
emitted_html_url = self._run_script_capturing_html_url(
|
|
806
|
+
state_argument=STATE_DIRTY, findings_payload=findings_payload
|
|
807
|
+
)
|
|
808
|
+
self._assert_review_state_for_url(
|
|
809
|
+
emitted_html_url, GH_EVENT_CHANGES_REQUESTED
|
|
810
|
+
)
|
|
811
|
+
review_comments = self._fetch_comments_for_review(emitted_html_url)
|
|
812
|
+
self.assertEqual(
|
|
813
|
+
len(review_comments),
|
|
814
|
+
len(findings_payload),
|
|
815
|
+
f"DIRTY state should produce one inline comment per finding on "
|
|
816
|
+
f"this review; expected {len(findings_payload)} got "
|
|
817
|
+
f"{len(review_comments)}: {review_comments!r}",
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def _expected_reviews_request_path(self) -> str:
|
|
821
|
+
full_endpoint_url = build_reviews_endpoint_url(
|
|
822
|
+
LIVE_TEST_OWNER, LIVE_TEST_REPO, self.pr_number
|
|
823
|
+
)
|
|
824
|
+
parsed_endpoint = urllib.parse.urlparse(full_endpoint_url)
|
|
825
|
+
return parsed_endpoint.path
|
|
826
|
+
|
|
827
|
+
def _run_retry_simulation_and_measure_elapsed(
|
|
828
|
+
self,
|
|
829
|
+
failure_count: int,
|
|
830
|
+
) -> tuple[subprocess.CompletedProcess[str], _StubReviewsServer, float]:
|
|
831
|
+
findings_path = write_findings_json([])
|
|
832
|
+
try:
|
|
833
|
+
stub_server, stub_thread = spawn_stub_reviews_server(
|
|
834
|
+
failure_count=failure_count
|
|
835
|
+
)
|
|
836
|
+
try:
|
|
837
|
+
overridden_base_url = stub_reviews_server_base_url(stub_server)
|
|
838
|
+
start_time = time.perf_counter()
|
|
839
|
+
completion = invoke_post_audit_thread_with_url_override(
|
|
840
|
+
pr_number=self.pr_number,
|
|
841
|
+
head_sha=self.head_sha,
|
|
842
|
+
state_argument=STATE_CLEAN,
|
|
843
|
+
findings_json_path=findings_path,
|
|
844
|
+
audit_token=self.audit_account_token,
|
|
845
|
+
overridden_base_url=overridden_base_url,
|
|
846
|
+
)
|
|
847
|
+
elapsed_seconds = time.perf_counter() - start_time
|
|
848
|
+
finally:
|
|
849
|
+
shutdown_stub_reviews_server(stub_server, stub_thread)
|
|
850
|
+
finally:
|
|
851
|
+
try:
|
|
852
|
+
findings_path.unlink()
|
|
853
|
+
except OSError:
|
|
854
|
+
pass
|
|
855
|
+
return completion, stub_server, elapsed_seconds
|
|
856
|
+
|
|
857
|
+
def test_retry_succeeds_after_one_transient_502_response(self) -> None:
|
|
858
|
+
completion, stub_server, elapsed_seconds = (
|
|
859
|
+
self._run_retry_simulation_and_measure_elapsed(
|
|
860
|
+
failure_count=FAILURE_COUNT_FOR_RETRY_SUCCESS
|
|
861
|
+
)
|
|
862
|
+
)
|
|
863
|
+
self.assertEqual(
|
|
864
|
+
completion.returncode,
|
|
865
|
+
EXIT_CODE_SUCCESS,
|
|
866
|
+
f"retry-success: expected exit {EXIT_CODE_SUCCESS}; got "
|
|
867
|
+
f"{completion.returncode}; stdout={completion.stdout!r} "
|
|
868
|
+
f"stderr={completion.stderr!r}",
|
|
869
|
+
)
|
|
870
|
+
self.assertEqual(
|
|
871
|
+
stub_server.request_count,
|
|
872
|
+
TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS,
|
|
873
|
+
f"retry-success: stub should have received exactly "
|
|
874
|
+
f"{TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS} POSTs (one 502 + "
|
|
875
|
+
f"one 200); got {stub_server.request_count}",
|
|
876
|
+
)
|
|
877
|
+
expected_request_path = self._expected_reviews_request_path()
|
|
878
|
+
self.assertEqual(
|
|
879
|
+
stub_server.recorded_request_path,
|
|
880
|
+
expected_request_path,
|
|
881
|
+
f"retry-success: stub received POST at "
|
|
882
|
+
f"{stub_server.recorded_request_path!r}; expected "
|
|
883
|
+
f"{expected_request_path!r} per build_reviews_endpoint_url",
|
|
884
|
+
)
|
|
885
|
+
self.assertGreaterEqual(
|
|
886
|
+
elapsed_seconds,
|
|
887
|
+
EXPECTED_RETRY_SUCCESS_ELAPSED_LOWER_BOUND_SECONDS,
|
|
888
|
+
f"retry-success should observe at least the first 1s backoff; "
|
|
889
|
+
f"elapsed={elapsed_seconds:.2f}s",
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
def test_retry_exhausts_and_exits_two_after_four_consecutive_502_responses(
|
|
893
|
+
self,
|
|
894
|
+
) -> None:
|
|
895
|
+
completion, stub_server, elapsed_seconds = (
|
|
896
|
+
self._run_retry_simulation_and_measure_elapsed(
|
|
897
|
+
failure_count=FAILURE_COUNT_FOR_RETRY_EXHAUSTION
|
|
898
|
+
)
|
|
899
|
+
)
|
|
900
|
+
self.assertEqual(
|
|
901
|
+
completion.returncode,
|
|
902
|
+
EXIT_CODE_RETRY_EXHAUSTED,
|
|
903
|
+
f"retry-exhaustion: expected exit "
|
|
904
|
+
f"{EXIT_CODE_RETRY_EXHAUSTED}; got "
|
|
905
|
+
f"{completion.returncode}; stdout={completion.stdout!r} "
|
|
906
|
+
f"stderr={completion.stderr!r}",
|
|
907
|
+
)
|
|
908
|
+
self.assertEqual(
|
|
909
|
+
stub_server.request_count,
|
|
910
|
+
TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION,
|
|
911
|
+
f"retry-exhaustion: stub should have received exactly "
|
|
912
|
+
f"{TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION} POSTs (one "
|
|
913
|
+
f"initial plus three retries); got "
|
|
914
|
+
f"{stub_server.request_count}",
|
|
915
|
+
)
|
|
916
|
+
expected_request_path = self._expected_reviews_request_path()
|
|
917
|
+
self.assertEqual(
|
|
918
|
+
stub_server.recorded_request_path,
|
|
919
|
+
expected_request_path,
|
|
920
|
+
f"retry-exhaustion: stub received POST at "
|
|
921
|
+
f"{stub_server.recorded_request_path!r}; expected "
|
|
922
|
+
f"{expected_request_path!r} per build_reviews_endpoint_url",
|
|
923
|
+
)
|
|
924
|
+
self.assertGreaterEqual(
|
|
925
|
+
elapsed_seconds,
|
|
926
|
+
EXPECTED_RETRY_EXHAUSTION_ELAPSED_LOWER_BOUND_SECONDS,
|
|
927
|
+
f"retry-exhaustion should observe ~21s of backoff "
|
|
928
|
+
f"(1s + 4s + 16s); elapsed={elapsed_seconds:.2f}s",
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
def _isolate_auth_env_vars(self) -> dict[str, str | None]:
|
|
932
|
+
all_managed_env_var_names = (
|
|
933
|
+
GH_TOKEN_ENV_VAR_NAME,
|
|
934
|
+
GITHUB_TOKEN_ENV_VAR_NAME,
|
|
935
|
+
BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME,
|
|
936
|
+
)
|
|
937
|
+
previous_env_state: dict[str, str | None] = {
|
|
938
|
+
each_name: os.environ.get(each_name)
|
|
939
|
+
for each_name in all_managed_env_var_names
|
|
940
|
+
}
|
|
941
|
+
for each_name in all_managed_env_var_names:
|
|
942
|
+
os.environ.pop(each_name, None)
|
|
943
|
+
return previous_env_state
|
|
944
|
+
|
|
945
|
+
def _restore_auth_env_vars(
|
|
946
|
+
self, previous_env_state: dict[str, str | None]
|
|
947
|
+
) -> None:
|
|
948
|
+
for each_name, prior_value in previous_env_state.items():
|
|
949
|
+
if prior_value is None:
|
|
950
|
+
os.environ.pop(each_name, None)
|
|
951
|
+
else:
|
|
952
|
+
os.environ[each_name] = prior_value
|
|
953
|
+
|
|
954
|
+
def test_query_active_gh_user_login_matches_gh_api_user_login_field(self) -> None:
|
|
955
|
+
active_login = query_active_gh_user_login()
|
|
956
|
+
self.assertTrue(
|
|
957
|
+
active_login,
|
|
958
|
+
"query_active_gh_user_login() returned empty",
|
|
959
|
+
)
|
|
960
|
+
gh_api_user_response = gh_api_object_json("user")
|
|
961
|
+
self.assertEqual(active_login, gh_api_user_response.get("login"))
|
|
962
|
+
|
|
963
|
+
def test_query_pull_request_author_login_matches_throwaway_pr_author(self) -> None:
|
|
964
|
+
author_login = query_pull_request_author_login(
|
|
965
|
+
owner=LIVE_TEST_OWNER,
|
|
966
|
+
repo=LIVE_TEST_REPO,
|
|
967
|
+
pr_number=self.pr_number,
|
|
968
|
+
)
|
|
969
|
+
pr_detail_path = f"repos/{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}/pulls/{self.pr_number}"
|
|
970
|
+
pr_detail_object = gh_api_object_json(pr_detail_path)
|
|
971
|
+
user_field_object = pr_detail_object.get("user")
|
|
972
|
+
self.assertIsInstance(user_field_object, dict)
|
|
973
|
+
if isinstance(user_field_object, dict):
|
|
974
|
+
self.assertEqual(author_login, user_field_object.get("login"))
|
|
975
|
+
|
|
976
|
+
def test_list_authenticated_gh_account_logins_includes_active_and_audit_accounts(
|
|
977
|
+
self,
|
|
978
|
+
) -> None:
|
|
979
|
+
all_logins = list_authenticated_gh_account_logins()
|
|
980
|
+
active_login = query_active_gh_user_login()
|
|
981
|
+
self.assertIn(active_login, all_logins)
|
|
982
|
+
self.assertIn(LIVE_TEST_AUDIT_ACCOUNT_NAME, all_logins)
|
|
983
|
+
|
|
984
|
+
def test_fetch_gh_token_for_account_returns_audit_account_cached_token(self) -> None:
|
|
985
|
+
fetched_token = fetch_gh_token_for_account(LIVE_TEST_AUDIT_ACCOUNT_NAME)
|
|
986
|
+
self.assertEqual(fetched_token, self.audit_account_token)
|
|
987
|
+
|
|
988
|
+
def test_resolve_reviewer_token_returns_env_var_when_gh_token_is_set(self) -> None:
|
|
989
|
+
sentinel_env_token = "sentinel-gh-token-from-env-var-precedence-test"
|
|
990
|
+
previous_env_state = self._isolate_auth_env_vars()
|
|
991
|
+
try:
|
|
992
|
+
os.environ[GH_TOKEN_ENV_VAR_NAME] = sentinel_env_token
|
|
993
|
+
returned_token = resolve_reviewer_token(
|
|
994
|
+
owner=LIVE_TEST_OWNER,
|
|
995
|
+
repo=LIVE_TEST_REPO,
|
|
996
|
+
pr_number=self.pr_number,
|
|
997
|
+
)
|
|
998
|
+
self.assertEqual(returned_token, sentinel_env_token)
|
|
999
|
+
finally:
|
|
1000
|
+
self._restore_auth_env_vars(previous_env_state)
|
|
1001
|
+
|
|
1002
|
+
def test_resolve_reviewer_token_toggles_to_alternate_token_on_self_pr(self) -> None:
|
|
1003
|
+
previous_env_state = self._isolate_auth_env_vars()
|
|
1004
|
+
try:
|
|
1005
|
+
returned_token = resolve_reviewer_token(
|
|
1006
|
+
owner=LIVE_TEST_OWNER,
|
|
1007
|
+
repo=LIVE_TEST_REPO,
|
|
1008
|
+
pr_number=self.pr_number,
|
|
1009
|
+
)
|
|
1010
|
+
active_login = query_active_gh_user_login()
|
|
1011
|
+
pr_author_login = query_pull_request_author_login(
|
|
1012
|
+
owner=LIVE_TEST_OWNER,
|
|
1013
|
+
repo=LIVE_TEST_REPO,
|
|
1014
|
+
pr_number=self.pr_number,
|
|
1015
|
+
)
|
|
1016
|
+
self.assertEqual(
|
|
1017
|
+
active_login.lower(),
|
|
1018
|
+
pr_author_login.lower(),
|
|
1019
|
+
"throwaway PR author must equal active gh account so the "
|
|
1020
|
+
"self-PR toggle branch is exercised",
|
|
1021
|
+
)
|
|
1022
|
+
all_alternates = [
|
|
1023
|
+
each_login
|
|
1024
|
+
for each_login in list_authenticated_gh_account_logins()
|
|
1025
|
+
if each_login.lower() != pr_author_login.lower()
|
|
1026
|
+
]
|
|
1027
|
+
self.assertTrue(
|
|
1028
|
+
all_alternates,
|
|
1029
|
+
"test setup requires at least one alternate authenticated account",
|
|
1030
|
+
)
|
|
1031
|
+
expected_first_alternate_token = fetch_gh_token_for_account(
|
|
1032
|
+
all_alternates[0]
|
|
1033
|
+
)
|
|
1034
|
+
self.assertEqual(returned_token, expected_first_alternate_token)
|
|
1035
|
+
active_account_token = resolve_gh_auth_token()
|
|
1036
|
+
self.assertNotEqual(
|
|
1037
|
+
returned_token,
|
|
1038
|
+
active_account_token,
|
|
1039
|
+
"self-PR toggle must not return the active (author) token",
|
|
1040
|
+
)
|
|
1041
|
+
finally:
|
|
1042
|
+
self._restore_auth_env_vars(previous_env_state)
|
|
1043
|
+
|
|
1044
|
+
def test_resolve_reviewer_token_honors_bugteam_reviewer_account_pin(self) -> None:
|
|
1045
|
+
previous_env_state = self._isolate_auth_env_vars()
|
|
1046
|
+
try:
|
|
1047
|
+
pr_author_login = query_pull_request_author_login(
|
|
1048
|
+
owner=LIVE_TEST_OWNER,
|
|
1049
|
+
repo=LIVE_TEST_REPO,
|
|
1050
|
+
pr_number=self.pr_number,
|
|
1051
|
+
)
|
|
1052
|
+
all_alternates_excluding_pr_author = [
|
|
1053
|
+
each_login
|
|
1054
|
+
for each_login in list_authenticated_gh_account_logins()
|
|
1055
|
+
if each_login.lower() != pr_author_login.lower()
|
|
1056
|
+
]
|
|
1057
|
+
self.assertTrue(
|
|
1058
|
+
all_alternates_excluding_pr_author,
|
|
1059
|
+
"test setup requires at least one authenticated account that "
|
|
1060
|
+
"is not the PR author so the pin has a valid target",
|
|
1061
|
+
)
|
|
1062
|
+
chosen_pin_login = all_alternates_excluding_pr_author[0]
|
|
1063
|
+
os.environ[BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME] = chosen_pin_login
|
|
1064
|
+
returned_token = resolve_reviewer_token(
|
|
1065
|
+
owner=LIVE_TEST_OWNER,
|
|
1066
|
+
repo=LIVE_TEST_REPO,
|
|
1067
|
+
pr_number=self.pr_number,
|
|
1068
|
+
)
|
|
1069
|
+
expected_pinned_token = fetch_gh_token_for_account(chosen_pin_login)
|
|
1070
|
+
self.assertEqual(returned_token, expected_pinned_token)
|
|
1071
|
+
finally:
|
|
1072
|
+
self._restore_auth_env_vars(previous_env_state)
|
|
1073
|
+
|
|
1074
|
+
def test_resolve_reviewer_token_error_excludes_pr_author_from_candidate_set(
|
|
1075
|
+
self,
|
|
1076
|
+
) -> None:
|
|
1077
|
+
unauthenticated_account_name = "intentionally-not-authenticated-account-zzz"
|
|
1078
|
+
previous_env_state = self._isolate_auth_env_vars()
|
|
1079
|
+
try:
|
|
1080
|
+
os.environ[BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME] = (
|
|
1081
|
+
unauthenticated_account_name
|
|
1082
|
+
)
|
|
1083
|
+
with self.assertRaises(UserInputError) as raised_context:
|
|
1084
|
+
resolve_reviewer_token(
|
|
1085
|
+
owner=LIVE_TEST_OWNER,
|
|
1086
|
+
repo=LIVE_TEST_REPO,
|
|
1087
|
+
pr_number=self.pr_number,
|
|
1088
|
+
)
|
|
1089
|
+
error_message_text = str(raised_context.exception)
|
|
1090
|
+
self.assertIn(unauthenticated_account_name, error_message_text)
|
|
1091
|
+
pr_author_login = query_pull_request_author_login(
|
|
1092
|
+
owner=LIVE_TEST_OWNER,
|
|
1093
|
+
repo=LIVE_TEST_REPO,
|
|
1094
|
+
pr_number=self.pr_number,
|
|
1095
|
+
)
|
|
1096
|
+
all_alternates_at_call_time = [
|
|
1097
|
+
each_login
|
|
1098
|
+
for each_login in list_authenticated_gh_account_logins()
|
|
1099
|
+
if each_login.lower() != pr_author_login.lower()
|
|
1100
|
+
]
|
|
1101
|
+
self.assertIn(
|
|
1102
|
+
repr(all_alternates_at_call_time),
|
|
1103
|
+
error_message_text,
|
|
1104
|
+
"error must show the alternate-reviewer set actually searched",
|
|
1105
|
+
)
|
|
1106
|
+
self.assertNotIn(
|
|
1107
|
+
f"authenticated set [{repr(pr_author_login)}",
|
|
1108
|
+
error_message_text,
|
|
1109
|
+
"error must not show a set whose head is the excluded PR author",
|
|
1110
|
+
)
|
|
1111
|
+
finally:
|
|
1112
|
+
self._restore_auth_env_vars(previous_env_state)
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
if __name__ == "__main__":
|
|
1116
|
+
unittest.main()
|