Skip to content

Commit 4ea70b1

Browse files
authored
Merge pull request #12 from githubnext/copilot/switch-to-actions-github-script
Switch autoloop workflow pre-steps from Python to JavaScript (Node.js)
2 parents 5abdd54 + d8dda09 commit 4ea70b1

5 files changed

Lines changed: 629 additions & 494 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
__pycache__/
2+
.pytest_cache/

tests/conftest.py

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,116 @@
11
"""
22
Extract scheduling functions directly from the workflow pre-step heredoc.
33
4-
Instead of duplicating the workflow's Python code in a separate module, we parse
5-
workflows/autoloop.md, extract the Python heredoc, pull out function definitions
6-
via the AST, and exec them into a namespace that tests can import from.
4+
Instead of duplicating the workflow's JavaScript code in a separate module, we parse
5+
workflows/autoloop.md, extract the JavaScript heredoc, write the function definitions
6+
to a temp CommonJS module, and call them via Node.js subprocess.
77
88
This ensures tests always run against the actual workflow code.
99
"""
1010

11-
import ast
11+
import json
1212
import os
1313
import re
14-
import textwrap
14+
import subprocess
15+
import tempfile
16+
from datetime import timedelta
1517

1618
WORKFLOW_PATH = os.path.join(os.path.dirname(__file__), "..", "workflows", "autoloop.md")
1719

20+
# Path to the extracted JS module
21+
_JS_MODULE_PATH = os.path.join(tempfile.gettempdir(), "autoloop_test_functions.cjs")
22+
1823

1924
def _load_workflow_functions():
20-
"""Parse workflows/autoloop.md and extract Python function defs from the pre-step."""
25+
"""Parse workflows/autoloop.md and extract JS function defs from the pre-step."""
2126
with open(WORKFLOW_PATH) as f:
2227
content = f.read()
2328

24-
# Extract the Python heredoc between PYEOF markers
25-
m = re.search(r"python3 - << 'PYEOF'\n(.*?)\n\s*PYEOF", content, re.DOTALL)
26-
assert m, "Could not find PYEOF heredoc in workflows/autoloop.md"
27-
source = textwrap.dedent(m.group(1))
29+
# Extract the JavaScript heredoc between JSEOF markers
30+
m = re.search(r"node - << 'JSEOF'\n(.*?)\n\s*JSEOF", content, re.DOTALL)
31+
assert m, "Could not find JSEOF heredoc in workflows/autoloop.md"
32+
source = m.group(1)
33+
34+
# Extract function definitions: everything up to the main() async function.
35+
# Functions are defined before 'async function main()'
36+
lines = source.split("\n")
37+
func_lines = []
38+
for line in lines:
39+
if line.strip().startswith("async function main"):
40+
break
41+
func_lines.append(line)
42+
43+
func_source = "\n".join(func_lines)
44+
45+
# Write to a temp .cjs file with module.exports
46+
with open(_JS_MODULE_PATH, "w") as f:
47+
f.write(func_source)
48+
f.write(
49+
"\n\nmodule.exports = "
50+
"{ parseMachineState, parseSchedule, getProgramName, readProgramState };\n"
51+
)
52+
53+
return True
54+
55+
56+
def _call_js(func_name, *args):
57+
"""Call a JS function from the extracted workflow module and return the result."""
58+
args_json = json.dumps(list(args))
59+
escaped_path = json.dumps(_JS_MODULE_PATH)
60+
script = (
61+
"const m = require(" + escaped_path + ");\n"
62+
"const result = m." + func_name + "(..." + args_json + ");\n"
63+
"process.stdout.write(JSON.stringify(result === undefined ? null : result));\n"
64+
)
65+
result = subprocess.run(
66+
["node", "-e", script],
67+
capture_output=True,
68+
text=True,
69+
timeout=10,
70+
)
71+
if result.returncode != 0:
72+
raise RuntimeError("Node.js error calling " + func_name + ": " + result.stderr)
73+
if not result.stdout.strip():
74+
return None
75+
return json.loads(result.stdout)
76+
77+
78+
# Initialize at import time
79+
_load_workflow_functions()
80+
81+
82+
def _parse_schedule_wrapper(s):
83+
"""Python wrapper for JS parseSchedule. Converts milliseconds to timedelta."""
84+
ms = _call_js("parseSchedule", s)
85+
if ms is None:
86+
return None
87+
return timedelta(milliseconds=ms)
88+
89+
90+
def _parse_machine_state_wrapper(content):
91+
"""Python wrapper for JS parseMachineState."""
92+
return _call_js("parseMachineState", content)
2893

29-
# Parse AST and extract only top-level FunctionDef nodes
30-
tree = ast.parse(source)
31-
source_lines = source.splitlines(keepends=True)
32-
func_sources = []
33-
for node in ast.iter_child_nodes(tree):
34-
if isinstance(node, ast.FunctionDef):
35-
func_sources.append("".join(source_lines[node.lineno - 1 : node.end_lineno]))
3694

37-
# Execute function defs with their required imports
38-
ns = {}
39-
preamble = "import os, re, json\nfrom datetime import datetime, timezone, timedelta\n\n"
40-
exec(preamble + "\n".join(func_sources), ns) # noqa: S102
41-
return ns
95+
def _get_program_name_wrapper(pf):
96+
"""Python wrapper for JS getProgramName."""
97+
return _call_js("getProgramName", pf)
4298

4399

44-
# Load once at import time
45-
_funcs = _load_workflow_functions()
100+
_funcs = {
101+
"parse_schedule": _parse_schedule_wrapper,
102+
"parse_machine_state": _parse_machine_state_wrapper,
103+
"get_program_name": _get_program_name_wrapper,
104+
"read_program_state": lambda name: _call_js("readProgramState", name),
105+
}
46106

47107

48108
def _extract_inline_pattern(name):
49-
"""Extract an inline code pattern from the workflow by name.
109+
"""Extract the JavaScript heredoc source from the workflow.
50110
51-
This is a helper for extracting small inline patterns (like the slugify regex)
52-
that aren't wrapped in function defs in the workflow source.
111+
This is a helper for inspecting the full inline source if needed.
53112
"""
54113
with open(WORKFLOW_PATH) as f:
55114
content = f.read()
56-
m = re.search(r"python3 - << 'PYEOF'\n(.*?)\n\s*PYEOF", content, re.DOTALL)
57-
return textwrap.dedent(m.group(1)) if m else ""
115+
m = re.search(r"node - << 'JSEOF'\n(.*?)\n\s*JSEOF", content, re.DOTALL)
116+
return m.group(1) if m else ""

tests/test_scheduling.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Tests for the scheduling pre-step in workflows/autoloop.md.
22
3-
Functions are extracted directly from the workflow heredoc at import time
4-
(see conftest.py) — there is no separate copy of the scheduling code.
3+
Functions are extracted directly from the workflow JavaScript heredoc at import
4+
time (see conftest.py) and called via Node.js subprocess — there is no separate
5+
copy of the scheduling code.
56
67
For inline logic (slugify, frontmatter parsing, skip conditions, etc.) that
7-
isn't wrapped in a function def in the workflow, we write thin test helpers
8+
isn't wrapped in a named function in the workflow, we write thin test helpers
89
that replicate the exact inline pattern. These are documented with the
9-
workflow source lines they correspond to.
10+
workflow source patterns they correspond to.
1011
"""
1112

1213
import re
@@ -27,14 +28,14 @@
2728
# ---------------------------------------------------------------------------
2829

2930
def slugify_issue_title(title):
30-
"""Replicates the inline slug logic at workflows/autoloop.md lines 236-237."""
31+
"""Replicates the inline slug logic in the workflow's issue scanning section."""
3132
slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')
3233
slug = re.sub(r'-+', '-', slug)
3334
return slug
3435

3536

3637
def parse_frontmatter(content):
37-
"""Replicates the inline frontmatter parsing at workflows/autoloop.md lines 316-330."""
38+
"""Replicates the inline frontmatter parsing in the workflow's program scanning loop."""
3839
content_stripped = re.sub(r'^(\s*<!--.*?-->\s*\n)*', '', content, flags=re.DOTALL)
3940
schedule_delta = None
4041
target_metric = None
@@ -53,7 +54,7 @@ def parse_frontmatter(content):
5354

5455

5556
def is_unconfigured(content):
56-
"""Replicates the inline unconfigured check at workflows/autoloop.md lines 306-312."""
57+
"""Replicates the inline unconfigured check in the workflow's program scanning loop."""
5758
if "<!-- AUTOLOOP:UNCONFIGURED -->" in content:
5859
return True
5960
if re.search(r'\bTODO\b|\bREPLACE', content):
@@ -62,7 +63,7 @@ def is_unconfigured(content):
6263

6364

6465
def check_skip_conditions(state):
65-
"""Replicates the inline skip logic at workflows/autoloop.md lines 347-361.
66+
"""Replicates the inline skip logic in the workflow's program scanning loop.
6667
6768
Returns (should_skip, reason).
6869
"""
@@ -80,7 +81,7 @@ def check_skip_conditions(state):
8081

8182

8283
def check_if_due(schedule_delta, last_run, now):
83-
"""Replicates the inline due check at workflows/autoloop.md lines 363-368.
84+
"""Replicates the inline due check in the workflow's program scanning loop.
8485
8586
Returns (is_due, next_due_iso).
8687
"""
@@ -91,7 +92,7 @@ def check_if_due(schedule_delta, last_run, now):
9192

9293

9394
def select_program(due, forced_program=None, all_programs=None, unconfigured=None, issue_programs=None):
94-
"""Replicates the selection logic at workflows/autoloop.md lines 379-409.
95+
"""Replicates the selection logic in the workflow's program selection section.
9596
9697
Returns (selected, selected_file, selected_issue, selected_target_metric, deferred, error).
9798
"""
@@ -312,7 +313,7 @@ def test_absolute_path_directory(self):
312313

313314

314315
# ---------------------------------------------------------------------------
315-
# slugify_issue_title (inline pattern, lines 236-237)
316+
# slugify_issue_title (inline pattern, issue scanning section)
316317
# ---------------------------------------------------------------------------
317318

318319
class TestSlugifyIssueTitle:
@@ -345,7 +346,7 @@ def test_consecutive_hyphens_collapsed(self):
345346
assert slugify_issue_title("a b c") == "a-b-c"
346347

347348
def test_collision_dedup(self):
348-
"""Replicates the slug collision dedup at workflows/autoloop.md lines 240-242."""
349+
"""Replicates the slug collision dedup in the workflow's issue scanning section."""
349350
# Simulate two issues that slugify to the same name
350351
issue_programs = {}
351352
titles = [("Improve Tests", 10), ("improve-tests", 20)]
@@ -363,7 +364,7 @@ def test_collision_dedup(self):
363364

364365

365366
# ---------------------------------------------------------------------------
366-
# parse_frontmatter (inline pattern, lines 316-330)
367+
# parse_frontmatter (inline pattern, program scanning loop)
367368
# ---------------------------------------------------------------------------
368369

369370
class TestParseFrontmatter:
@@ -416,7 +417,7 @@ def test_extra_frontmatter_fields_ignored(self):
416417

417418

418419
# ---------------------------------------------------------------------------
419-
# is_unconfigured (inline pattern, lines 306-312)
420+
# is_unconfigured (inline pattern, program scanning loop)
420421
# ---------------------------------------------------------------------------
421422

422423
class TestIsUnconfigured:
@@ -453,7 +454,7 @@ def test_issue_template_detected(self):
453454

454455

455456
# ---------------------------------------------------------------------------
456-
# check_skip_conditions (inline pattern, lines 347-361)
457+
# check_skip_conditions (inline pattern, program scanning loop)
457458
# ---------------------------------------------------------------------------
458459

459460
class TestCheckSkipConditions:
@@ -512,7 +513,7 @@ def test_completed_takes_priority_over_paused(self):
512513

513514

514515
# ---------------------------------------------------------------------------
515-
# check_if_due (inline pattern, lines 363-368)
516+
# check_if_due (inline pattern, program scanning loop)
516517
# ---------------------------------------------------------------------------
517518

518519
class TestCheckIfDue:
@@ -556,7 +557,7 @@ def test_next_due_timestamp(self):
556557

557558

558559
# ---------------------------------------------------------------------------
559-
# select_program (inline pattern, lines 379-409)
560+
# select_program (inline pattern, program selection section)
560561
# ---------------------------------------------------------------------------
561562

562563
class TestSelectProgram:
@@ -646,7 +647,7 @@ def test_forced_program_gets_target_metric_from_due(self):
646647
def test_forced_program_not_in_due_select_returns_none(self):
647648
# select_program itself returns None for target_metric when program isn't in due.
648649
# The workflow's forced-program path has a fallback that parses target_metric
649-
# directly from the program file (workflows/autoloop.md lines 399-410).
650+
# directly from the program file (see forced-program fallback in the workflow).
650651
due = []
651652
all_progs = {"a": "a.md"}
652653
selected, file, issue, target, deferred, err = select_program(

0 commit comments

Comments
 (0)