@@ -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