Skip to content

Commit ea4ae5b

Browse files
Copilotmrjf
andauthored
Enforce single-PR-per-program invariant (config + scheduler + prose)
Agent-Logs-Url: https://github.com/githubnext/autoloop/sessions/a7fac0f2-e76c-4ece-b2e9-1b8661cbc715 Co-authored-by: mrjf <180956+mrjf@users.noreply.github.com>
1 parent afa3ddd commit ea4ae5b

3 files changed

Lines changed: 338 additions & 6 deletions

File tree

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def _load_workflow_functions():
4747
f.write(func_source)
4848
f.write(
4949
"\n\nmodule.exports = "
50-
"{ parseMachineState, parseSchedule, getProgramName, readProgramState, parseLinkHeader };\n"
50+
"{ parseMachineState, parseSchedule, getProgramName, readProgramState, parseLinkHeader, findExistingPRForBranch };\n"
5151
)
5252

5353
return True

tests/test_scheduling.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,3 +833,228 @@ def test_lock_creds_before_merge(self):
833833
f"'Configure Git credentials' (index {cred_idx}) must come before "
834834
f"merge step (index {merge_idx}). Steps: {steps}"
835835
)
836+
837+
838+
# ---------------------------------------------------------------------------
839+
# Single-PR-per-program invariant: safe-outputs config + existing_pr lookup
840+
# (issue: enforce single-PR-per-program invariant)
841+
# ---------------------------------------------------------------------------
842+
843+
class TestSafeOutputsConfig:
844+
"""Verify the safe-outputs config that defends the single-PR invariant.
845+
846+
Without `preserve-branch-name: true`, the gh-aw framework auto-suffixes
847+
branch names on every run, breaking the single-long-running-branch model.
848+
Without `max: 1` on both create-pull-request and push-to-pull-request-branch,
849+
the agent could emit a create+create or create+push pair in the same iteration.
850+
"""
851+
852+
def _frontmatter(self):
853+
import os
854+
wf_path = os.path.join(os.path.dirname(__file__), "..", "workflows", "autoloop.md")
855+
with open(wf_path) as f:
856+
content = f.read()
857+
# Frontmatter is the first --- ... --- block
858+
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
859+
assert m, "Could not find YAML frontmatter in workflows/autoloop.md"
860+
return m.group(1)
861+
862+
def test_create_pr_preserves_branch_name(self):
863+
fm = self._frontmatter()
864+
# create-pull-request block must contain preserve-branch-name: true
865+
m = re.search(r"create-pull-request:\s*\n((?:\s{4}.*\n)+)", fm)
866+
assert m, "Could not find create-pull-request block in safe-outputs"
867+
block = m.group(1)
868+
assert "preserve-branch-name: true" in block, (
869+
"create-pull-request must set 'preserve-branch-name: true' to keep "
870+
"the canonical branch name autoloop/{program}; otherwise gh-aw "
871+
"appends a hex salt and breaks the single-PR invariant.\n"
872+
f"Block: {block}"
873+
)
874+
875+
def test_create_pr_max_is_one(self):
876+
fm = self._frontmatter()
877+
m = re.search(r"create-pull-request:\s*\n((?:\s{4}.*\n)+)", fm)
878+
assert m
879+
block = m.group(1)
880+
assert re.search(r"^\s*max:\s*1\s*$", block, re.MULTILINE), (
881+
"create-pull-request must set 'max: 1' — the invariant is one "
882+
"safe-output of either create or push per iteration, never two.\n"
883+
f"Block: {block}"
884+
)
885+
886+
def test_push_to_pr_max_is_one(self):
887+
fm = self._frontmatter()
888+
m = re.search(r"push-to-pull-request-branch:\s*\n((?:\s{4}.*\n)+)", fm)
889+
assert m, "Could not find push-to-pull-request-branch block in safe-outputs"
890+
block = m.group(1)
891+
assert re.search(r"^\s*max:\s*1\s*$", block, re.MULTILINE), (
892+
"push-to-pull-request-branch must set 'max: 1'.\n"
893+
f"Block: {block}"
894+
)
895+
896+
897+
class TestProseGuidance:
898+
"""Verify the prose guidance enforcing the single-PR invariant is present."""
899+
900+
def _content(self):
901+
import os
902+
wf_path = os.path.join(os.path.dirname(__file__), "..", "workflows", "autoloop.md")
903+
with open(wf_path) as f:
904+
return f.read()
905+
906+
def test_branch_name_warning_present(self):
907+
c = self._content()
908+
assert "Branch Name Must Be Exact" in c, (
909+
"Missing the 'Branch Name Must Be Exact' warning that tells the "
910+
"agent to never use suffixed branch names."
911+
)
912+
assert "no suffixes" in c.lower(), "Warning should mention 'no suffixes'"
913+
914+
def test_common_mistakes_section_present(self):
915+
c = self._content()
916+
assert "## Common Mistakes to Avoid" in c, (
917+
"Missing the 'Common Mistakes to Avoid' section."
918+
)
919+
920+
def test_step5_uses_existing_pr(self):
921+
c = self._content()
922+
# Step 5 accept flow must reference existing_pr from autoloop.json
923+
assert "existing_pr" in c, (
924+
"Workflow prose must instruct the agent to consult the "
925+
"`existing_pr` field from /tmp/gh-aw/autoloop.json."
926+
)
927+
assert "head_branch" in c, (
928+
"Workflow prose must instruct the agent to use the `head_branch` "
929+
"field from /tmp/gh-aw/autoloop.json."
930+
)
931+
932+
933+
# ---------------------------------------------------------------------------
934+
# findExistingPRForBranch helper — tolerant lookup of the open draft PR
935+
# ---------------------------------------------------------------------------
936+
937+
import os
938+
import json
939+
import subprocess
940+
import tempfile
941+
942+
from conftest import _JS_MODULE_PATH
943+
944+
945+
def _run_find_existing_pr(program, mock_responses):
946+
"""Invoke findExistingPRForBranch with a stubbed global fetch.
947+
948+
`mock_responses` is a list of dicts: { url_match, status, body, link }.
949+
The first entry whose url_match substring is in the requested URL wins.
950+
The optional `link` field is returned as the Link response header
951+
(used by pagination via parseLinkHeader).
952+
"""
953+
script = (
954+
"const m = require(" + json.dumps(_JS_MODULE_PATH) + ");\n"
955+
"const responses = " + json.dumps(mock_responses) + ";\n"
956+
"global.fetch = async (url, opts) => {\n"
957+
" for (const r of responses) {\n"
958+
" if (url.indexOf(r.url_match) !== -1) {\n"
959+
" return {\n"
960+
" ok: r.status >= 200 && r.status < 300,\n"
961+
" status: r.status,\n"
962+
" json: async () => r.body,\n"
963+
" headers: { get: (h) => h.toLowerCase() === 'link' ? (r.link || null) : null },\n"
964+
" };\n"
965+
" }\n"
966+
" }\n"
967+
" return { ok: false, status: 404, json: async () => [], headers: { get: () => null } };\n"
968+
"};\n"
969+
"(async () => {\n"
970+
" const pr = await m.findExistingPRForBranch('owner/repo', " + json.dumps(program) + ", 'TOKEN');\n"
971+
" process.stdout.write(JSON.stringify(pr));\n"
972+
"})();\n"
973+
)
974+
result = subprocess.run(
975+
["node", "-e", script], capture_output=True, text=True, timeout=10,
976+
)
977+
if result.returncode != 0:
978+
raise RuntimeError("Node error: " + result.stderr)
979+
return json.loads(result.stdout) if result.stdout.strip() else None
980+
981+
982+
class TestFindExistingPRForBranch:
983+
"""The tolerant PR lookup that closes the single-PR-per-program invariant."""
984+
985+
def test_returns_null_when_no_pr_exists(self):
986+
# Strategy 1 returns []; strategy 2 returns []
987+
responses = [
988+
{"url_match": "head=owner%3Aautoloop%2Fcoverage", "status": 200, "body": []},
989+
{"url_match": "/pulls?state=open", "status": 200, "body": []},
990+
]
991+
assert _run_find_existing_pr("coverage", responses) is None
992+
993+
def test_finds_pr_with_canonical_branch_name(self):
994+
responses = [
995+
{
996+
"url_match": "head=owner%3Aautoloop%2Fcoverage",
997+
"status": 200,
998+
"body": [{"number": 42, "head": {"ref": "autoloop/coverage"}, "title": "[Autoloop] x"}],
999+
},
1000+
]
1001+
assert _run_find_existing_pr("coverage", responses) == 42
1002+
1003+
def test_finds_pr_with_legacy_hex_suffix(self):
1004+
# Strategy 1 finds nothing (the open PR has a suffixed branch name);
1005+
# Strategy 2 falls back to listing all open PRs and matches the suffix regex.
1006+
responses = [
1007+
{"url_match": "head=owner%3Aautoloop%2Fcoverage", "status": 200, "body": []},
1008+
{
1009+
"url_match": "/pulls?state=open",
1010+
"status": 200,
1011+
"body": [
1012+
{"number": 99, "head": {"ref": "autoloop/coverage-8724e9f9"}, "title": "[Autoloop] x"},
1013+
],
1014+
},
1015+
]
1016+
assert _run_find_existing_pr("coverage", responses) == 99
1017+
1018+
def test_finds_pr_via_title_prefix_fallback(self):
1019+
# Branch name doesn't match suffix pattern, but title prefix does
1020+
responses = [
1021+
{"url_match": "head=owner%3Aautoloop%2Fcoverage", "status": 200, "body": []},
1022+
{
1023+
"url_match": "/pulls?state=open",
1024+
"status": 200,
1025+
"body": [
1026+
{"number": 7, "head": {"ref": "totally-different-branch"}, "title": "[Autoloop: coverage] iter 3"},
1027+
],
1028+
},
1029+
]
1030+
assert _run_find_existing_pr("coverage", responses) == 7
1031+
1032+
def test_does_not_match_unrelated_program(self):
1033+
# autoloop/coverage-extras is a different program, not a hex suffix
1034+
responses = [
1035+
{"url_match": "head=owner%3Aautoloop%2Fcoverage", "status": 200, "body": []},
1036+
{
1037+
"url_match": "/pulls?state=open",
1038+
"status": 200,
1039+
"body": [
1040+
{"number": 11, "head": {"ref": "autoloop/coverage-extras"}, "title": "[Autoloop] other"},
1041+
],
1042+
},
1043+
]
1044+
assert _run_find_existing_pr("coverage", responses) is None
1045+
1046+
def test_does_not_match_other_program_with_similar_name(self):
1047+
# Program name with regex-special-ish characters (underscore is fine, but
1048+
# we want to make sure the regex is properly anchored to ^...$).
1049+
responses = [
1050+
{"url_match": "head=owner%3Aautoloop%2Fsignal_processing", "status": 200, "body": []},
1051+
{
1052+
"url_match": "/pulls?state=open",
1053+
"status": 200,
1054+
"body": [
1055+
# Branch for a different program that happens to share a prefix
1056+
{"number": 5, "head": {"ref": "autoloop/signal"}, "title": "[Autoloop] other"},
1057+
],
1058+
},
1059+
]
1060+
assert _run_find_existing_pr("signal_processing", responses) is None

0 commit comments

Comments
 (0)