yaga-cli 0.1.0__py3-none-any.whl

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.
Files changed (137) hide show
  1. yaga/__init__.py +1 -0
  2. yaga/__main__.py +27 -0
  3. yaga/_update_worker.py +65 -0
  4. yaga/action.py +24 -0
  5. yaga/action_cli.py +25 -0
  6. yaga/agent_review/__init__.py +34 -0
  7. yaga/agent_review/evaluation.py +93 -0
  8. yaga/agent_review/github/__init__.py +1 -0
  9. yaga/agent_review/github/boundary.py +292 -0
  10. yaga/agent_review/github/candidate.py +193 -0
  11. yaga/agent_review/github/constants.py +59 -0
  12. yaga/agent_review/github/events.py +195 -0
  13. yaga/agent_review/github/evidence.py +450 -0
  14. yaga/agent_review/github/gate.py +1486 -0
  15. yaga/agent_review/github/publication.py +570 -0
  16. yaga/agent_review/github/requests.py +371 -0
  17. yaga/agent_review/github/runs.py +412 -0
  18. yaga/agent_review/github/runtime.py +303 -0
  19. yaga/agent_review/plan.py +116 -0
  20. yaga/agent_review/policy.py +164 -0
  21. yaga/agent_review/results.py +306 -0
  22. yaga/agent_review/runtime.py +7 -0
  23. yaga/branches/__init__.py +25 -0
  24. yaga/branches/checker.py +61 -0
  25. yaga/branches/models.py +180 -0
  26. yaga/branches/patterns.py +157 -0
  27. yaga/branches/policy.py +87 -0
  28. yaga/branches/reporting.py +189 -0
  29. yaga/branches/service.py +25 -0
  30. yaga/changes/__init__.py +30 -0
  31. yaga/changes/checker.py +158 -0
  32. yaga/changes/git.py +212 -0
  33. yaga/changes/models.py +279 -0
  34. yaga/changes/patterns.py +201 -0
  35. yaga/changes/policy.py +154 -0
  36. yaga/changes/reporting.py +260 -0
  37. yaga/changes/service.py +32 -0
  38. yaga/cli.py +82 -0
  39. yaga/commands/__init__.py +1 -0
  40. yaga/commands/branch.py +50 -0
  41. yaga/commands/change.py +58 -0
  42. yaga/commands/commit.py +203 -0
  43. yaga/commands/config.py +70 -0
  44. yaga/commands/gate.py +174 -0
  45. yaga/commands/github.py +56 -0
  46. yaga/commands/mode.py +54 -0
  47. yaga/commands/path.py +54 -0
  48. yaga/commands/repo.py +196 -0
  49. yaga/commands/self_manage.py +40 -0
  50. yaga/commands/size.py +54 -0
  51. yaga/commands/tree.py +54 -0
  52. yaga/commands/workflow.py +132 -0
  53. yaga/commit_action_cli.py +65 -0
  54. yaga/commit_action_runtime.py +42 -0
  55. yaga/commits/__init__.py +21 -0
  56. yaga/commits/checker.py +402 -0
  57. yaga/commits/config.py +661 -0
  58. yaga/commits/config_init.py +154 -0
  59. yaga/commits/git.py +212 -0
  60. yaga/commits/github_event.py +393 -0
  61. yaga/commits/github_reporting.py +259 -0
  62. yaga/commits/models.py +264 -0
  63. yaga/commits/parser.py +131 -0
  64. yaga/commits/quality.py +542 -0
  65. yaga/commits/reporting.py +476 -0
  66. yaga/commits/service.py +228 -0
  67. yaga/commits/sources.py +66 -0
  68. yaga/commits/typos.py +127 -0
  69. yaga/config_scope.py +28 -0
  70. yaga/errors.py +47 -0
  71. yaga/files.py +13 -0
  72. yaga/git/__init__.py +28 -0
  73. yaga/git/process.py +303 -0
  74. yaga/git/runtime.py +775 -0
  75. yaga/git/tree.py +182 -0
  76. yaga/github.py +320 -0
  77. yaga/models.py +175 -0
  78. yaga/modes/__init__.py +35 -0
  79. yaga/modes/checker.py +31 -0
  80. yaga/modes/git.py +179 -0
  81. yaga/modes/models.py +504 -0
  82. yaga/modes/patterns.py +207 -0
  83. yaga/modes/policy.py +159 -0
  84. yaga/modes/reporting.py +250 -0
  85. yaga/modes/service.py +44 -0
  86. yaga/paths/__init__.py +1 -0
  87. yaga/paths/checker.py +302 -0
  88. yaga/paths/git.py +133 -0
  89. yaga/paths/models.py +357 -0
  90. yaga/paths/policy.py +117 -0
  91. yaga/paths/reporting.py +225 -0
  92. yaga/paths/service.py +44 -0
  93. yaga/repository/__init__.py +1 -0
  94. yaga/repository/checker.py +265 -0
  95. yaga/repository/models.py +262 -0
  96. yaga/repository/plan.py +381 -0
  97. yaga/repository/reporting.py +388 -0
  98. yaga/self_update.py +113 -0
  99. yaga/sizes/__init__.py +33 -0
  100. yaga/sizes/checker.py +39 -0
  101. yaga/sizes/git.py +204 -0
  102. yaga/sizes/models.py +506 -0
  103. yaga/sizes/patterns.py +213 -0
  104. yaga/sizes/policy.py +156 -0
  105. yaga/sizes/reporting.py +323 -0
  106. yaga/sizes/service.py +44 -0
  107. yaga/status.py +63 -0
  108. yaga/trees/__init__.py +27 -0
  109. yaga/trees/checker.py +77 -0
  110. yaga/trees/git.py +127 -0
  111. yaga/trees/models.py +271 -0
  112. yaga/trees/patterns.py +227 -0
  113. yaga/trees/policy.py +108 -0
  114. yaga/trees/reporting.py +252 -0
  115. yaga/trees/service.py +38 -0
  116. yaga/version_requirement.py +49 -0
  117. yaga/workflows/__init__.py +1 -0
  118. yaga/workflows/actionlint_runtime.py +985 -0
  119. yaga/workflows/actionlint_snapshot.py +409 -0
  120. yaga/workflows/checker.py +98 -0
  121. yaga/workflows/inputs.py +260 -0
  122. yaga/workflows/lint.py +400 -0
  123. yaga/workflows/models.py +189 -0
  124. yaga/workflows/parser.py +41 -0
  125. yaga/workflows/references.py +304 -0
  126. yaga/workflows/reporting.py +280 -0
  127. yaga/workflows/security.py +125 -0
  128. yaga/workflows/security_facts.py +109 -0
  129. yaga/workflows/security_models.py +131 -0
  130. yaga/workflows/security_reporting.py +193 -0
  131. yaga/workflows/security_rules.py +396 -0
  132. yaga/workflows/yaml.py +1056 -0
  133. yaga_cli-0.1.0.dist-info/METADATA +84 -0
  134. yaga_cli-0.1.0.dist-info/RECORD +137 -0
  135. yaga_cli-0.1.0.dist-info/WHEEL +4 -0
  136. yaga_cli-0.1.0.dist-info/entry_points.txt +2 -0
  137. yaga_cli-0.1.0.dist-info/licenses/LICENSE +29 -0
yaga/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Extensible developer workflow automation for local and CI use."""
yaga/__main__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Select the installed CLI or dependency-free composite Action boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ if (
9
+ os.environ.get("YAGA_ACTION_RUNTIME") == "1"
10
+ and os.environ.get("YAGA_COMMIT_ACTION_RUNTIME") == "1"
11
+ ):
12
+ print("YAGA failed: multiple Action runtimes were selected", file=sys.stderr)
13
+ raise SystemExit(2)
14
+
15
+ if os.environ.get("YAGA_ACTION_RUNTIME") == "1":
16
+ from yaga.action_cli import main
17
+
18
+ raise SystemExit(main())
19
+
20
+ if os.environ.get("YAGA_COMMIT_ACTION_RUNTIME") == "1":
21
+ from yaga.commit_action_cli import main
22
+
23
+ raise SystemExit(main())
24
+
25
+ from yaga.cli import main
26
+
27
+ main()
yaga/_update_worker.py ADDED
@@ -0,0 +1,65 @@
1
+ """Standalone Windows update worker, copied outside the environment before replacement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import subprocess
7
+ import sys
8
+ from ctypes import wintypes
9
+ from pathlib import Path
10
+
11
+
12
+ def main() -> int:
13
+ if sys.platform != "win32":
14
+ return 2
15
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
16
+ kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
17
+ kernel.OpenProcess.restype = wintypes.HANDLE
18
+ kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
19
+ kernel.WaitForSingleObject.restype = wintypes.DWORD
20
+ kernel.CloseHandle.argtypes = [wintypes.HANDLE]
21
+ kernel.QueryFullProcessImageNameW.argtypes = [
22
+ wintypes.HANDLE,
23
+ wintypes.DWORD,
24
+ wintypes.LPWSTR,
25
+ ctypes.POINTER(wintypes.DWORD),
26
+ ]
27
+ handles = []
28
+ try:
29
+ for index, raw_pid in enumerate(sys.argv[2:4]):
30
+ handle = kernel.OpenProcess(0x00100000 | 0x1000, False, int(raw_pid))
31
+ if not handle:
32
+ if ctypes.get_last_error() == 87: # The process already exited.
33
+ continue
34
+ raise OSError("cannot wait for the YAGA process to exit")
35
+ handles.append(handle)
36
+ if index == 1:
37
+ name = ctypes.create_unicode_buffer(32768)
38
+ size = wintypes.DWORD(len(name))
39
+ if not kernel.QueryFullProcessImageNameW(handle, 0, name, ctypes.byref(size)):
40
+ raise OSError("cannot identify the YAGA launcher")
41
+ if Path(name.value).name.casefold() != "yaga.exe":
42
+ continue # python -m yaga has no launcher; never wait for the user's shell.
43
+ if kernel.WaitForSingleObject(handle, 60000) != 0:
44
+ raise OSError("YAGA did not exit within the update handoff timeout")
45
+ result = subprocess.run(
46
+ [sys.argv[1], "--no-config", "tool", "upgrade", "yaga-cli"],
47
+ timeout=600,
48
+ check=False,
49
+ )
50
+ print(f"YAGA_UPDATE_EXIT_CODE={result.returncode}", flush=True)
51
+ return result.returncode
52
+ except (OSError, subprocess.SubprocessError) as error:
53
+ print(
54
+ f"YAGA update failed: {type(error).__name__}; run uv tool upgrade yaga-cli", flush=True
55
+ )
56
+ print("YAGA_UPDATE_EXIT_CODE=2", flush=True)
57
+ return 2
58
+ finally:
59
+ for handle in handles:
60
+ kernel.CloseHandle(handle)
61
+ Path(__file__).unlink(missing_ok=True)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
yaga/action.py ADDED
@@ -0,0 +1,24 @@
1
+ """Strict public inputs for the YAGA composite action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from yaga.agent_review.runtime import operation_name, run_action
6
+ from yaga.errors import GateError
7
+
8
+ SUPPORTED_GATES = frozenset({"agent-review"})
9
+
10
+
11
+ def gate_name(value: object) -> str:
12
+ """Accept only a deliberately implemented gate."""
13
+ if not isinstance(value, str) or value not in SUPPORTED_GATES:
14
+ raise GateError("gate is invalid")
15
+ return value
16
+
17
+
18
+ def run_gate(gate: object, operation: object) -> int:
19
+ """Run one closed gate operation through the shared dispatcher."""
20
+ selected_gate = gate_name(gate)
21
+ selected_operation = operation_name(operation)
22
+ if selected_gate == "agent-review":
23
+ return run_action(selected_operation)
24
+ raise GateError("gate is invalid") # pragma: no cover - gate_name is closed above
yaga/action_cli.py ADDED
@@ -0,0 +1,25 @@
1
+ """Dependency-free command boundary used by the composite Action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from yaga.action import run_gate
8
+ from yaga.errors import GateError, safe_error_text
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ """Accept exactly one closed Action command and dispatch it fail-closed."""
13
+ arguments = list(sys.argv[1:] if argv is None else argv)
14
+ if len(arguments) != 3 or arguments[0] != "gate":
15
+ print("YAGA failed: action command is invalid", file=sys.stderr)
16
+ return 1
17
+ try:
18
+ return run_gate(arguments[1], arguments[2])
19
+ except GateError as error:
20
+ print(f"YAGA failed: {safe_error_text(error)}", file=sys.stderr)
21
+ return 1
22
+
23
+
24
+ if __name__ == "__main__":
25
+ raise SystemExit(main())
@@ -0,0 +1,34 @@
1
+ """Provider-neutral Agent review policy models and validation."""
2
+
3
+ from yaga.agent_review.evaluation import LensOutcome, ReviewAggregate, evaluate_policy
4
+ from yaga.agent_review.plan import ReviewPlan, ReviewPlanItem, build_plan, render_plan
5
+ from yaga.agent_review.policy import AgentReviewPolicy, ReviewLens, load_policy
6
+ from yaga.agent_review.results import (
7
+ AgentReviewResults,
8
+ LensResult,
9
+ build_pending_results,
10
+ evaluate_results,
11
+ load_results,
12
+ render_evaluation,
13
+ render_results_template,
14
+ )
15
+
16
+ __all__ = [
17
+ "AgentReviewPolicy",
18
+ "AgentReviewResults",
19
+ "LensOutcome",
20
+ "ReviewAggregate",
21
+ "ReviewLens",
22
+ "ReviewPlan",
23
+ "ReviewPlanItem",
24
+ "LensResult",
25
+ "build_plan",
26
+ "build_pending_results",
27
+ "evaluate_policy",
28
+ "evaluate_results",
29
+ "load_policy",
30
+ "load_results",
31
+ "render_plan",
32
+ "render_evaluation",
33
+ "render_results_template",
34
+ ]
@@ -0,0 +1,93 @@
1
+ """Provider-neutral evaluation of named Agent review outcomes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+
9
+ from yaga.agent_review.policy import AgentReviewPolicy
10
+ from yaga.errors import ConfigurationError
11
+
12
+
13
+ class LensOutcome(StrEnum):
14
+ """Closed outcome states emitted by provider adapters."""
15
+
16
+ PASSED = "passed"
17
+ FAILED = "failed"
18
+ PENDING = "pending"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ReviewAggregate:
23
+ """Deterministic policy result with separate blocking and advisory details."""
24
+
25
+ state: LensOutcome
26
+ blocking_failures: tuple[str, ...]
27
+ blocking_pending: tuple[str, ...]
28
+ advisory_failures: tuple[str, ...]
29
+ advisory_pending: tuple[str, ...]
30
+
31
+
32
+ def evaluate_policy(
33
+ policy: AgentReviewPolicy,
34
+ outcomes: Mapping[str, LensOutcome | str],
35
+ ) -> ReviewAggregate:
36
+ """Aggregate exact named-lens outcomes without letting advisory lenses block."""
37
+ if not isinstance(policy, AgentReviewPolicy):
38
+ raise TypeError("policy must be an AgentReviewPolicy")
39
+ if not isinstance(outcomes, Mapping):
40
+ raise TypeError("outcomes must be a mapping")
41
+ if any(not isinstance(name, str) for name in outcomes):
42
+ raise ConfigurationError("Agent review outcome names must be strings")
43
+ known = {lens.name for lens in policy.lenses}
44
+ unknown = sorted(set(outcomes) - known)
45
+ if unknown:
46
+ raise ConfigurationError(f"Agent review outcome names unknown lens {unknown[0]}")
47
+ normalized: dict[str, LensOutcome] = {}
48
+ for name, value in outcomes.items():
49
+ try:
50
+ normalized[name] = LensOutcome(value)
51
+ except (TypeError, ValueError) as error:
52
+ raise ConfigurationError(f"Agent review outcome for {name} is invalid") from error
53
+
54
+ advisory_names = tuple(lens.name for lens in policy.lenses if lens.name not in policy.required)
55
+ blocking_failures = tuple(
56
+ name for name in policy.required if normalized.get(name) is LensOutcome.FAILED
57
+ )
58
+ blocking_pending = tuple(
59
+ name
60
+ for name in policy.required
61
+ if normalized.get(name, LensOutcome.PENDING) is LensOutcome.PENDING
62
+ )
63
+ advisory_failures = tuple(
64
+ name for name in advisory_names if normalized.get(name) is LensOutcome.FAILED
65
+ )
66
+ advisory_pending = tuple(
67
+ name
68
+ for name in advisory_names
69
+ if normalized.get(name, LensOutcome.PENDING) is LensOutcome.PENDING
70
+ )
71
+ if policy.aggregation == "all-required":
72
+ state = (
73
+ LensOutcome.FAILED
74
+ if blocking_failures
75
+ else LensOutcome.PENDING
76
+ if blocking_pending
77
+ else LensOutcome.PASSED
78
+ )
79
+ else:
80
+ state = (
81
+ LensOutcome.PASSED
82
+ if any(normalized.get(name) is LensOutcome.PASSED for name in policy.required)
83
+ else LensOutcome.FAILED
84
+ if not blocking_pending and len(blocking_failures) == len(policy.required)
85
+ else LensOutcome.PENDING
86
+ )
87
+ return ReviewAggregate(
88
+ state,
89
+ blocking_failures,
90
+ blocking_pending,
91
+ advisory_failures,
92
+ advisory_pending,
93
+ )
@@ -0,0 +1 @@
1
+ """GitHub provider adapter for the provider-neutral Agent review gate."""
@@ -0,0 +1,292 @@
1
+ """Authenticate the lifecycle boundary carried by YAGA commit statuses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import urllib.parse
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+
10
+ from yaga.agent_review.github.constants import (
11
+ CODEX_STATUS_CONTEXT,
12
+ EVENT_ACTIONS,
13
+ GITHUB_ACTIONS_LOGIN,
14
+ GITHUB_ACTIONS_USER_ID,
15
+ MAX_WORKFLOW_RUN_RECORDS,
16
+ )
17
+ from yaga.errors import GateError
18
+ from yaga.github import RestApi
19
+ from yaga.models import (
20
+ commit_sha,
21
+ positive_int,
22
+ record,
23
+ repository_name,
24
+ timestamp,
25
+ workflow_path,
26
+ )
27
+ from yaga.status import CommitStatus
28
+
29
+ _ACTION_CODES = {
30
+ "closed": "c",
31
+ "converted_to_draft": "d",
32
+ "edited": "e",
33
+ "opened": "o",
34
+ "ready_for_review": "y",
35
+ "reopened": "r",
36
+ "synchronize": "s",
37
+ }
38
+ _ACTIONS_BY_CODE = {code: action for action, code in _ACTION_CODES.items()}
39
+ _PHASE_CODES = {
40
+ "CI pending": "CP",
41
+ "Codex pending": "RP",
42
+ "Codex passed": "RS",
43
+ "Codex failed": "RF",
44
+ "CI passed": "CS",
45
+ "CI failed": "CF",
46
+ }
47
+ _PHASES_BY_CODE = {code: phase for phase, code in _PHASE_CODES.items()}
48
+ _DESCRIPTION_RE = re.compile(
49
+ r"\AYAGA boundary (?P<run>[1-9][0-9]{0,18})/(?P<attempt>[1-9][0-9]{0,3}) "
50
+ r"(?P<action>[cdeorys]) PR (?P<pr>[1-9][0-9]{0,18}) "
51
+ r"base (?P<base>[0-9a-f]{40}) at (?P<time>[^\r\n]{10,40}) "
52
+ r"(?P<phase>CP|RP|RS|RF|CS|CF)\Z"
53
+ )
54
+ _BOUNDARY_TITLE_RE = re.compile(
55
+ r"\AYAGA (?P<action>closed|converted_to_draft|edited|opened|ready_for_review|reopened|synchronize) "
56
+ r"boundary for #(?P<pr>[1-9][0-9]{0,18})\Z"
57
+ )
58
+ _METADATA_TITLE_RE = re.compile(r"\AYAGA metadata edit for #(?P<pr>[1-9][0-9]{0,18})\Z")
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ReviewBoundary:
63
+ """Exact trusted lifecycle transition shared by all publisher stages."""
64
+
65
+ run_id: int
66
+ run_attempt: int
67
+ action: str
68
+ pull_request_number: int
69
+ head_sha: str
70
+ base_sha: str
71
+ occurred_at: datetime
72
+
73
+
74
+ def boundary_description(boundary: ReviewBoundary, phase: str) -> str:
75
+ """Encode a human-readable, strictly parseable status description."""
76
+ if phase not in _PHASE_CODES or boundary.action not in _ACTION_CODES:
77
+ raise GateError("YAGA boundary phase is invalid")
78
+ attempt = positive_int(boundary.run_attempt, "boundary attempt")
79
+ if attempt > 9_999:
80
+ raise GateError("YAGA boundary attempt is too large")
81
+ occurred_at = boundary.occurred_at.isoformat(timespec="seconds").replace("+00:00", "Z")
82
+ description = (
83
+ f"YAGA boundary {positive_int(boundary.run_id, 'boundary run')}/{attempt} "
84
+ f"{_ACTION_CODES[boundary.action]} PR "
85
+ f"{positive_int(boundary.pull_request_number, 'boundary PR')} "
86
+ f"base {commit_sha(boundary.base_sha, 'boundary base')} at {occurred_at} "
87
+ f"{_PHASE_CODES[phase]}"
88
+ )
89
+ if len(description) > 140:
90
+ raise GateError("YAGA boundary description is too long")
91
+ return description
92
+
93
+
94
+ def parse_boundary_description(
95
+ status: CommitStatus,
96
+ *,
97
+ head_sha: str,
98
+ ) -> tuple[ReviewBoundary, str] | None:
99
+ """Parse an untrusted boundary claim from one status description."""
100
+ if status.description is None:
101
+ return None
102
+ match = _DESCRIPTION_RE.fullmatch(status.description)
103
+ if match is None:
104
+ return None
105
+ try:
106
+ action = _ACTIONS_BY_CODE.get(match.group("action"))
107
+ if action not in EVENT_ACTIONS:
108
+ return None
109
+ boundary = ReviewBoundary(
110
+ run_id=positive_int(int(match.group("run")), "boundary run"),
111
+ run_attempt=positive_int(int(match.group("attempt")), "boundary attempt"),
112
+ action=action,
113
+ pull_request_number=positive_int(int(match.group("pr")), "boundary PR"),
114
+ head_sha=commit_sha(head_sha, "boundary head"),
115
+ base_sha=commit_sha(match.group("base"), "boundary base"),
116
+ occurred_at=timestamp(match.group("time"), "boundary occurrence"),
117
+ )
118
+ except (ValueError, GateError):
119
+ return None
120
+ phase = _PHASES_BY_CODE.get(match.group("phase"))
121
+ return (boundary, phase) if phase is not None else None
122
+
123
+
124
+ def _attempt_url(
125
+ server_url: str,
126
+ repository: str,
127
+ run_id: int,
128
+ run_attempt: int,
129
+ ) -> str:
130
+ parsed = urllib.parse.urlsplit(server_url)
131
+ if (
132
+ parsed.scheme != "https"
133
+ or not parsed.netloc
134
+ or parsed.username is not None
135
+ or parsed.password is not None
136
+ or parsed.path not in {"", "/"}
137
+ or parsed.query
138
+ or parsed.fragment
139
+ ):
140
+ raise GateError("GitHub server URL is invalid")
141
+ return (
142
+ f"{parsed.scheme}://{parsed.netloc}/{repository}/actions/runs/"
143
+ f"{run_id}/attempts/{run_attempt}"
144
+ )
145
+
146
+
147
+ def validate_boundary_source(
148
+ api: RestApi,
149
+ *,
150
+ repository: str,
151
+ lifecycle_workflow: str,
152
+ server_url: str,
153
+ boundary: ReviewBoundary,
154
+ provenance_status: CommitStatus,
155
+ ) -> datetime:
156
+ """Require the pending provenance and return its trusted run update time."""
157
+ repository = repository_name(repository)
158
+ lifecycle_workflow = workflow_path(lifecycle_workflow, "lifecycle workflow")
159
+ if (
160
+ provenance_status.context != CODEX_STATUS_CONTEXT
161
+ or provenance_status.state != "pending"
162
+ or provenance_status.creator_id != GITHUB_ACTIONS_USER_ID
163
+ or provenance_status.creator_login != GITHUB_ACTIONS_LOGIN
164
+ or provenance_status.target_url
165
+ != _attempt_url(
166
+ server_url,
167
+ repository,
168
+ boundary.run_id,
169
+ boundary.run_attempt,
170
+ )
171
+ ):
172
+ raise GateError("lifecycle boundary status provenance is invalid")
173
+ parsed = parse_boundary_description(provenance_status, head_sha=boundary.head_sha)
174
+ if parsed is None or parsed != (boundary, "CI pending"):
175
+ raise GateError("lifecycle boundary status does not carry the expected boundary")
176
+
177
+ payload = record(
178
+ api.get(f"/repos/{repository}/actions/runs/{boundary.run_id}"),
179
+ "lifecycle workflow run",
180
+ )
181
+ run_repository = record(payload.get("repository"), "lifecycle workflow repository")
182
+ if (
183
+ payload.get("id") != boundary.run_id
184
+ or payload.get("run_attempt") != boundary.run_attempt
185
+ or payload.get("path") != lifecycle_workflow
186
+ or payload.get("event") != "pull_request_target"
187
+ or payload.get("status") != "completed"
188
+ or payload.get("conclusion") != "success"
189
+ or payload.get("head_sha") != boundary.head_sha
190
+ or run_repository.get("full_name") != repository
191
+ or payload.get("display_title")
192
+ != f"YAGA {boundary.action} boundary for #{boundary.pull_request_number}"
193
+ ):
194
+ raise GateError("lifecycle boundary workflow run is not trusted")
195
+ created_at = timestamp(payload.get("created_at"), "lifecycle workflow creation")
196
+ updated_at = timestamp(payload.get("updated_at"), "lifecycle workflow update")
197
+ if (
198
+ updated_at < created_at
199
+ or boundary.occurred_at > created_at
200
+ or provenance_status.created_at < created_at
201
+ ):
202
+ raise GateError("lifecycle boundary timing is invalid")
203
+
204
+ run_number = positive_int(payload.get("run_number"), "lifecycle workflow number")
205
+ current_position = (run_number, boundary.run_attempt, boundary.run_id)
206
+ query = urllib.parse.urlencode(
207
+ {
208
+ "event": "pull_request_target",
209
+ "head_sha": boundary.head_sha,
210
+ "page": "1",
211
+ "per_page": str(MAX_WORKFLOW_RUN_RECORDS),
212
+ }
213
+ )
214
+ filename = lifecycle_workflow.rsplit("/", 1)[1]
215
+ listing = record(
216
+ api.get(f"/repos/{repository}/actions/workflows/{filename}/runs?{query}"),
217
+ "lifecycle workflow runs",
218
+ )
219
+ count = listing.get("total_count")
220
+ runs = listing.get("workflow_runs")
221
+ if (
222
+ isinstance(count, bool)
223
+ or not isinstance(count, int)
224
+ or count < 0
225
+ or not isinstance(runs, list)
226
+ or count != len(runs)
227
+ or count >= MAX_WORKFLOW_RUN_RECORDS
228
+ ):
229
+ raise GateError("lifecycle workflow run list is incomplete or invalid")
230
+ seen: set[int] = set()
231
+ current_seen = False
232
+ for index, value in enumerate(runs):
233
+ item = record(value, f"lifecycle workflow run {index}")
234
+ item_repository = record(
235
+ item.get("repository"),
236
+ f"lifecycle workflow run {index} repository",
237
+ )
238
+ run_id = positive_int(item.get("id"), f"lifecycle workflow run {index} ID")
239
+ if run_id in seen:
240
+ raise GateError("lifecycle workflow run ID is repeated")
241
+ seen.add(run_id)
242
+ if (
243
+ item.get("path") != lifecycle_workflow
244
+ or item.get("event") != "pull_request_target"
245
+ or item.get("head_sha") != boundary.head_sha
246
+ or item_repository.get("full_name") != repository
247
+ ):
248
+ raise GateError("lifecycle workflow run list contains an untrusted run")
249
+ position = (
250
+ positive_int(item.get("run_number"), f"lifecycle workflow run {index} number"),
251
+ positive_int(item.get("run_attempt"), f"lifecycle workflow run {index} attempt"),
252
+ run_id,
253
+ )
254
+ title = item.get("display_title")
255
+ boundary_title = _BOUNDARY_TITLE_RE.fullmatch(title) if isinstance(title, str) else None
256
+ metadata_title = _METADATA_TITLE_RE.fullmatch(title) if isinstance(title, str) else None
257
+ title_pr = (
258
+ boundary_title.group("pr")
259
+ if boundary_title is not None
260
+ else metadata_title.group("pr")
261
+ if metadata_title is not None
262
+ else None
263
+ )
264
+ if title_pr is None or positive_int(int(title_pr), "lifecycle title PR") != (
265
+ boundary.pull_request_number
266
+ ):
267
+ raise GateError("lifecycle workflow run title is invalid")
268
+ current_seen |= position == current_position
269
+ if position > current_position and metadata_title is None:
270
+ raise GateError("a newer lifecycle boundary exists for this head")
271
+ if not current_seen:
272
+ raise GateError("lifecycle boundary is outside the bounded run window")
273
+ return updated_at
274
+
275
+
276
+ def same_boundary(left: ReviewBoundary, right: ReviewBoundary) -> bool:
277
+ """Compare stable identity while ignoring the provenance status timestamp."""
278
+ return (
279
+ left.run_id,
280
+ left.run_attempt,
281
+ left.action,
282
+ left.pull_request_number,
283
+ left.head_sha,
284
+ left.base_sha,
285
+ ) == (
286
+ right.run_id,
287
+ right.run_attempt,
288
+ right.action,
289
+ right.pull_request_number,
290
+ right.head_sha,
291
+ right.base_sha,
292
+ )