Skip to content

Commit cfe34dd

Browse files
authored
fix(ci): classify agent authorship with python3 instead of Ruby (#155)
* fix(ci): classify agent authorship with python3 instead of Ruby The reusable agent-authorship-label workflow fails fleet-wide with `ruby: command not found` (exit 127) on every pull_request_target event: callers pin it by SHA at a revision whose classify step runs `ruby org-defaults/.github/scripts/classify-agent-authorship.rb`, and the resolved PUBLIC_PR_VALIDATION_RUNNER image no longer ships Ruby. Provisioning Ruby at runtime (ruby/setup-ruby) is a live tool install, which the repo rail reserves against and which #153 already removed from the other org reusable workflows. Follow that established pattern instead: port the classifier to python3 (standard library only), which is proven to exist on every runner image we operate, and assert the toolchain up front with a preflight so a missing interpreter fails loudly instead of after a silent skip. The Python port matches the Ruby original on 65 differential cases (plus identical file-arg and --github-output behavior) covering complete, incomplete, mixed, case-varied, whitespace-varied, unicode, nested commit.message, and empty inputs. Consumers pin this repo by SHA and must bump their `uses:` ref to pick up this fix; the SHA pinned in evalops/mono#5205 cannot be retrofixed. Refs evalops/mono#5205 * fix: split commit records and messages on newline only Python str.splitlines() also splits on \v \f \x1c-\x1e \x85 U+2028 U+2029, which jq emits literally inside JSON strings — a commit message containing one tore a JSONL record into unparseable fragments and crashed the classify step. Ruby's each_line splits on "\n" only; match it exactly.
1 parent e30fcc5 commit cfe34dd

6 files changed

Lines changed: 252 additions & 178 deletions

.github/scripts/classify-agent-authorship.rb

Lines changed: 0 additions & 83 deletions
This file was deleted.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env python3
2+
"""Classify PR commit authorship from a JSONL stream of commits.
3+
4+
Port of classify-agent-authorship.rb. Ruby is not present on every runner
5+
image this reusable workflow runs on, so the classifier uses python3, which
6+
is proven to exist on all of them. Standard library only.
7+
"""
8+
9+
import argparse
10+
import json
11+
import re
12+
import sys
13+
14+
# Ruby \s / \S semantics are ASCII-only; keep re.ASCII so unicode whitespace
15+
# does not change trailer matching relative to the Ruby original.
16+
FLAGS = re.IGNORECASE | re.ASCII
17+
18+
REQUIRED_PATTERNS = {
19+
"co_author": re.compile(r"^Co-Authored-By:\s*Maestro\s+<maestro@evalops\.dev>\s*$", FLAGS),
20+
"version": re.compile(r"^Maestro-Version:\s*\S.*$", FLAGS),
21+
"prompt_id": re.compile(r"^Maestro-Prompt-Id:\s*\S.*$", FLAGS),
22+
"approvals_id": re.compile(r"^Maestro-Approvals-Id:\s*\S.*$", FLAGS),
23+
}
24+
25+
MARKER_PATTERN = re.compile(
26+
r"^Co-Authored-By:\s*Maestro\s+<maestro@evalops\.dev>\s*$"
27+
r"|^Maestro-(?:Version|Prompt-Id|Approvals-Id):",
28+
FLAGS,
29+
)
30+
31+
32+
def read_input(paths):
33+
if paths:
34+
chunks = []
35+
for path in paths:
36+
with open(path, encoding="utf-8") as handle:
37+
chunks.append(handle.read())
38+
return "".join(chunks)
39+
return sys.stdin.read()
40+
41+
42+
def extract_messages(text):
43+
messages = []
44+
# Split only on "\n" to match Ruby's each_line: jq emits characters like
45+
# U+2028/U+2029 literally inside JSON strings, and str.splitlines() would
46+
# tear one JSON record into unparseable fragments.
47+
for line in text.split("\n"):
48+
if not line.strip():
49+
continue
50+
parsed = json.loads(line)
51+
if isinstance(parsed, dict):
52+
commit = parsed.get("commit")
53+
message = commit.get("message") if isinstance(commit, dict) else None
54+
if message is None:
55+
message = parsed.get("message")
56+
if message is not None:
57+
messages.append(message)
58+
return messages
59+
60+
61+
def main():
62+
parser = argparse.ArgumentParser()
63+
parser.add_argument(
64+
"--github-output",
65+
metavar="PATH",
66+
help="Append key=value outputs for GitHub Actions",
67+
)
68+
parser.add_argument("files", nargs="*")
69+
args = parser.parse_args()
70+
71+
messages = extract_messages(read_input(args.files))
72+
73+
agent_commits = 0
74+
untrailered_commits = 0
75+
incomplete_commits = 0
76+
77+
for message in messages:
78+
# Ruby each_line splits on "\n" only; keep that exact segmentation.
79+
parts = message.split("\n")
80+
lines = [part + "\n" for part in parts[:-1]]
81+
if parts[-1]:
82+
lines.append(parts[-1])
83+
has_marker = any(MARKER_PATTERN.search(line) for line in lines)
84+
85+
if not has_marker:
86+
untrailered_commits += 1
87+
continue
88+
89+
agent_commits += 1
90+
missing_required = any(
91+
not any(pattern.search(line) for line in lines)
92+
for pattern in REQUIRED_PATTERNS.values()
93+
)
94+
if missing_required:
95+
incomplete_commits += 1
96+
97+
if agent_commits > 0 and untrailered_commits > 0:
98+
label = "mixed-authorship"
99+
elif agent_commits > 0:
100+
label = "agent-authored"
101+
else:
102+
label = "agent-assisted"
103+
104+
outputs = {
105+
"label": label,
106+
"total_commits": len(messages),
107+
"agent_commits": agent_commits,
108+
"untrailered_commits": untrailered_commits,
109+
"human_commits": untrailered_commits,
110+
"incomplete_agent_commits": incomplete_commits,
111+
}
112+
113+
for key, value in outputs.items():
114+
print(f"{key}={value}")
115+
116+
if args.github_output:
117+
with open(args.github_output, "a", encoding="utf-8") as handle:
118+
for key, value in outputs.items():
119+
handle.write(f"{key}={value}\n")
120+
121+
122+
if __name__ == "__main__":
123+
main()

.github/workflows/agent-authorship-label.yml

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
name: agent-authorship-label
22

3+
# Reusable org authorship labeler, called by repositories across the
4+
# organisation. It runs on whatever runner the caller names, so it may only
5+
# depend on tools proven to exist on every runner image we operate. Ruby is
6+
# absent from newer images (fleet-wide `ruby: command not found`, see
7+
# evalops/platform#5205), so the classifier runs on python3, asserted up
8+
# front by the preflight.
9+
310
on:
411
workflow_call:
512
inputs:
@@ -36,10 +43,16 @@ jobs:
3643
ref: ${{ inputs.helper_ref }}
3744
path: org-defaults
3845

39-
- name: Set up Ruby
40-
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
41-
with:
42-
ruby-version: "3.3.9"
46+
- name: Preflight the classification toolchain
47+
shell: bash
48+
run: |
49+
set -euo pipefail
50+
51+
if ! command -v python3 >/dev/null 2>&1; then
52+
echo "::error::python3 is not on this runner (${RUNNER_NAME:-unknown}). agent-authorship-label cannot classify commits without it."
53+
exit 1
54+
fi
55+
python3 -V
4356
4457
- name: Resolve pull request
4558
id: pr
@@ -70,7 +83,7 @@ jobs:
7083
shell: bash
7184
run: |
7285
set -euo pipefail
73-
ruby org-defaults/.github/scripts/classify-agent-authorship.rb \
86+
python3 org-defaults/.github/scripts/classify_agent_authorship.py \
7487
--github-output "${GITHUB_OUTPUT}" \
7588
commits.jsonl
7689
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import json
2+
import subprocess
3+
import sys
4+
import tempfile
5+
import unittest
6+
from pathlib import Path
7+
8+
ROOT = Path(__file__).resolve().parent.parent
9+
SCRIPT = ROOT / ".github" / "scripts" / "classify_agent_authorship.py"
10+
11+
12+
def parse_outputs(text):
13+
return dict(line.split("=", 1) for line in text.splitlines())
14+
15+
16+
def classify(commits, github_output=None):
17+
input_data = "\n".join(json.dumps(commit) for commit in commits)
18+
args = [sys.executable, str(SCRIPT)]
19+
if github_output:
20+
args += ["--github-output", github_output]
21+
result = subprocess.run(
22+
args, input=input_data, capture_output=True, text=True
23+
)
24+
assert result.returncode == 0, result.stderr
25+
return parse_outputs(result.stdout)
26+
27+
28+
class ClassifyAgentAuthorshipTest(unittest.TestCase):
29+
def test_untrailered_commits_are_agent_assisted(self):
30+
outputs = classify([{"sha": "abc", "message": "fix: regular change"}])
31+
32+
self.assertEqual("agent-assisted", outputs["label"])
33+
self.assertEqual("1", outputs["total_commits"])
34+
self.assertEqual("0", outputs["agent_commits"])
35+
self.assertEqual("1", outputs["untrailered_commits"])
36+
self.assertEqual("0", outputs["incomplete_agent_commits"])
37+
38+
def test_complete_maestro_trailers_are_agent_authored(self):
39+
outputs = classify(
40+
[
41+
{
42+
"sha": "abc",
43+
"message": (
44+
"feat: ship change\n"
45+
"\n"
46+
"Co-Authored-By: Maestro <maestro@evalops.dev>\n"
47+
"Maestro-Version: 2026.04.28 / gpt-5\n"
48+
"Maestro-Prompt-Id: prompt-123\n"
49+
"Maestro-Approvals-Id: approval-456\n"
50+
),
51+
}
52+
]
53+
)
54+
55+
self.assertEqual("agent-authored", outputs["label"])
56+
self.assertEqual("1", outputs["agent_commits"])
57+
self.assertEqual("0", outputs["untrailered_commits"])
58+
self.assertEqual("0", outputs["incomplete_agent_commits"])
59+
60+
def test_mixed_authorship_and_incomplete_trailers_are_reported(self):
61+
outputs = classify(
62+
[
63+
{
64+
"sha": "abc",
65+
"message": (
66+
"feat: partial agent change\n"
67+
"\n"
68+
"Co-Authored-By: Maestro <maestro@evalops.dev>\n"
69+
"Maestro-Version: 2026.04.28 / gpt-5\n"
70+
),
71+
},
72+
{"sha": "def", "message": "docs: human follow-up"},
73+
]
74+
)
75+
76+
self.assertEqual("mixed-authorship", outputs["label"])
77+
self.assertEqual("1", outputs["agent_commits"])
78+
self.assertEqual("1", outputs["untrailered_commits"])
79+
self.assertEqual("1", outputs["incomplete_agent_commits"])
80+
81+
def test_github_output_file_gets_same_outputs(self):
82+
with tempfile.NamedTemporaryFile(mode="r", suffix="github-output") as handle:
83+
outputs = classify(
84+
[{"sha": "abc", "message": "fix: regular change"}],
85+
github_output=handle.name,
86+
)
87+
file_outputs = parse_outputs(handle.read())
88+
89+
self.assertEqual(outputs, file_outputs)
90+
91+
92+
if __name__ == "__main__":
93+
unittest.main()

0 commit comments

Comments
 (0)