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.
- yaga/__init__.py +1 -0
- yaga/__main__.py +27 -0
- yaga/_update_worker.py +65 -0
- yaga/action.py +24 -0
- yaga/action_cli.py +25 -0
- yaga/agent_review/__init__.py +34 -0
- yaga/agent_review/evaluation.py +93 -0
- yaga/agent_review/github/__init__.py +1 -0
- yaga/agent_review/github/boundary.py +292 -0
- yaga/agent_review/github/candidate.py +193 -0
- yaga/agent_review/github/constants.py +59 -0
- yaga/agent_review/github/events.py +195 -0
- yaga/agent_review/github/evidence.py +450 -0
- yaga/agent_review/github/gate.py +1486 -0
- yaga/agent_review/github/publication.py +570 -0
- yaga/agent_review/github/requests.py +371 -0
- yaga/agent_review/github/runs.py +412 -0
- yaga/agent_review/github/runtime.py +303 -0
- yaga/agent_review/plan.py +116 -0
- yaga/agent_review/policy.py +164 -0
- yaga/agent_review/results.py +306 -0
- yaga/agent_review/runtime.py +7 -0
- yaga/branches/__init__.py +25 -0
- yaga/branches/checker.py +61 -0
- yaga/branches/models.py +180 -0
- yaga/branches/patterns.py +157 -0
- yaga/branches/policy.py +87 -0
- yaga/branches/reporting.py +189 -0
- yaga/branches/service.py +25 -0
- yaga/changes/__init__.py +30 -0
- yaga/changes/checker.py +158 -0
- yaga/changes/git.py +212 -0
- yaga/changes/models.py +279 -0
- yaga/changes/patterns.py +201 -0
- yaga/changes/policy.py +154 -0
- yaga/changes/reporting.py +260 -0
- yaga/changes/service.py +32 -0
- yaga/cli.py +82 -0
- yaga/commands/__init__.py +1 -0
- yaga/commands/branch.py +50 -0
- yaga/commands/change.py +58 -0
- yaga/commands/commit.py +203 -0
- yaga/commands/config.py +70 -0
- yaga/commands/gate.py +174 -0
- yaga/commands/github.py +56 -0
- yaga/commands/mode.py +54 -0
- yaga/commands/path.py +54 -0
- yaga/commands/repo.py +196 -0
- yaga/commands/self_manage.py +40 -0
- yaga/commands/size.py +54 -0
- yaga/commands/tree.py +54 -0
- yaga/commands/workflow.py +132 -0
- yaga/commit_action_cli.py +65 -0
- yaga/commit_action_runtime.py +42 -0
- yaga/commits/__init__.py +21 -0
- yaga/commits/checker.py +402 -0
- yaga/commits/config.py +661 -0
- yaga/commits/config_init.py +154 -0
- yaga/commits/git.py +212 -0
- yaga/commits/github_event.py +393 -0
- yaga/commits/github_reporting.py +259 -0
- yaga/commits/models.py +264 -0
- yaga/commits/parser.py +131 -0
- yaga/commits/quality.py +542 -0
- yaga/commits/reporting.py +476 -0
- yaga/commits/service.py +228 -0
- yaga/commits/sources.py +66 -0
- yaga/commits/typos.py +127 -0
- yaga/config_scope.py +28 -0
- yaga/errors.py +47 -0
- yaga/files.py +13 -0
- yaga/git/__init__.py +28 -0
- yaga/git/process.py +303 -0
- yaga/git/runtime.py +775 -0
- yaga/git/tree.py +182 -0
- yaga/github.py +320 -0
- yaga/models.py +175 -0
- yaga/modes/__init__.py +35 -0
- yaga/modes/checker.py +31 -0
- yaga/modes/git.py +179 -0
- yaga/modes/models.py +504 -0
- yaga/modes/patterns.py +207 -0
- yaga/modes/policy.py +159 -0
- yaga/modes/reporting.py +250 -0
- yaga/modes/service.py +44 -0
- yaga/paths/__init__.py +1 -0
- yaga/paths/checker.py +302 -0
- yaga/paths/git.py +133 -0
- yaga/paths/models.py +357 -0
- yaga/paths/policy.py +117 -0
- yaga/paths/reporting.py +225 -0
- yaga/paths/service.py +44 -0
- yaga/repository/__init__.py +1 -0
- yaga/repository/checker.py +265 -0
- yaga/repository/models.py +262 -0
- yaga/repository/plan.py +381 -0
- yaga/repository/reporting.py +388 -0
- yaga/self_update.py +113 -0
- yaga/sizes/__init__.py +33 -0
- yaga/sizes/checker.py +39 -0
- yaga/sizes/git.py +204 -0
- yaga/sizes/models.py +506 -0
- yaga/sizes/patterns.py +213 -0
- yaga/sizes/policy.py +156 -0
- yaga/sizes/reporting.py +323 -0
- yaga/sizes/service.py +44 -0
- yaga/status.py +63 -0
- yaga/trees/__init__.py +27 -0
- yaga/trees/checker.py +77 -0
- yaga/trees/git.py +127 -0
- yaga/trees/models.py +271 -0
- yaga/trees/patterns.py +227 -0
- yaga/trees/policy.py +108 -0
- yaga/trees/reporting.py +252 -0
- yaga/trees/service.py +38 -0
- yaga/version_requirement.py +49 -0
- yaga/workflows/__init__.py +1 -0
- yaga/workflows/actionlint_runtime.py +985 -0
- yaga/workflows/actionlint_snapshot.py +409 -0
- yaga/workflows/checker.py +98 -0
- yaga/workflows/inputs.py +260 -0
- yaga/workflows/lint.py +400 -0
- yaga/workflows/models.py +189 -0
- yaga/workflows/parser.py +41 -0
- yaga/workflows/references.py +304 -0
- yaga/workflows/reporting.py +280 -0
- yaga/workflows/security.py +125 -0
- yaga/workflows/security_facts.py +109 -0
- yaga/workflows/security_models.py +131 -0
- yaga/workflows/security_reporting.py +193 -0
- yaga/workflows/security_rules.py +396 -0
- yaga/workflows/yaml.py +1056 -0
- yaga_cli-0.1.0.dist-info/METADATA +84 -0
- yaga_cli-0.1.0.dist-info/RECORD +137 -0
- yaga_cli-0.1.0.dist-info/WHEEL +4 -0
- yaga_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|