From 7765808f5e3b3b4b035cff98f0229ef1cd25653b Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 17:28:28 -0300 Subject: [PATCH 01/28] test: add structured PR Sync smoke marker --- tests/fixtures/structured-pr-sync-smoke-62.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/fixtures/structured-pr-sync-smoke-62.txt diff --git a/tests/fixtures/structured-pr-sync-smoke-62.txt b/tests/fixtures/structured-pr-sync-smoke-62.txt new file mode 100644 index 0000000..10e695e --- /dev/null +++ b/tests/fixtures/structured-pr-sync-smoke-62.txt @@ -0,0 +1,2 @@ +Disposable marker for issue #62 structured PR Sync smoke. +This file exists only to create a harmless implementation diff for the end-to-end governance test. From 61c179632a3528892f1154c773b08a64823e8ad0 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 17:40:10 -0300 Subject: [PATCH 02/28] fix: reconcile existing Project v2 single-select options --- project_setup/project.py | 57 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/project_setup/project.py b/project_setup/project.py index 858196b..7c1badf 100644 --- a/project_setup/project.py +++ b/project_setup/project.py @@ -170,17 +170,70 @@ def create_field(client: GitHubClient, project_id: str, field: dict) -> None: raise ValueError(f"Unsupported project field type: {field_type}") +def single_select_option_inputs(existing_field: dict, desired_field: dict) -> list[dict]: + existing_by_name = { + str(option.get("name") or "").casefold(): option + for option in existing_field.get("options", []) + if option.get("name") + } + result: list[dict] = [] + for desired_name in desired_field.get("options", []): + option = {"name": str(desired_name), "color": "GRAY", "description": ""} + current = existing_by_name.get(str(desired_name).casefold()) + if current and current.get("id"): + option["id"] = str(current["id"]) + result.append(option) + return result + + +def update_single_select_field(client: GitHubClient, existing_field: dict, desired_field: dict) -> None: + mutation = """ + mutation($field:ID!, $options:[ProjectV2SingleSelectFieldOptionInput!]!) { + updateProjectV2Field(input:{fieldId:$field,singleSelectOptions:$options}) { + projectV2Field { ... on ProjectV2SingleSelectField { id } } + } + } + """ + client.graphql( + mutation, + { + "field": existing_field["id"], + "options": single_select_option_inputs(existing_field, desired_field), + }, + ) + + +def single_select_options_match(existing_field: dict, desired_field: dict) -> bool: + existing_names = [str(option.get("name") or "") for option in existing_field.get("options", [])] + desired_names = [str(option) for option in desired_field.get("options", [])] + return existing_names == desired_names + + def ensure_fields(client: GitHubClient, project_id: str, definition: dict, dry_run: bool = False) -> dict[str, dict]: existing = {field["name"]: field for field in list_project_fields(client, project_id) if field.get("name")} + changed = False for field in definition.get("fields", []): - if field["name"] in existing: + existing_field = existing.get(field["name"]) + if existing_field: + if ( + field.get("type") == "single_select" + and existing_field.get("__typename") == "ProjectV2SingleSelectField" + and not single_select_options_match(existing_field, field) + ): + if dry_run: + print(f"[DRY-RUN] Would update field options: {field['name']}") + else: + update_single_select_field(client, existing_field, field) + changed = True + print(f"updated field options: {field['name']}") continue if dry_run: print(f"[DRY-RUN] Would create field: {field['name']} ({field['type']})") else: create_field(client, project_id, field) + changed = True print(f"created field: {field['name']}") - if dry_run: + if dry_run or not changed: return existing return {field["name"]: field for field in list_project_fields(client, project_id) if field.get("name")} From 139ef8021e66fa44c4882d617cabe456724b4fdd Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 17:40:33 -0300 Subject: [PATCH 03/28] test: cover Project v2 status option reconciliation --- tests/test_project_field_reconciliation.py | 108 +++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/test_project_field_reconciliation.py diff --git a/tests/test_project_field_reconciliation.py b/tests/test_project_field_reconciliation.py new file mode 100644 index 0000000..eb26bfe --- /dev/null +++ b/tests/test_project_field_reconciliation.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from unittest import TestCase +from unittest.mock import Mock, patch + +from project_setup.project import ensure_fields, single_select_option_inputs, update_single_select_field + + +class ProjectFieldReconciliationTests(TestCase): + def test_option_inputs_preserve_matching_option_ids(self): + existing = { + "id": "FIELD", + "options": [ + {"id": "DONE-ID", "name": "Done"}, + {"id": "TODO-ID", "name": "Todo"}, + ], + } + desired = {"name": "Status", "type": "single_select", "options": ["In review", "Done"]} + + self.assertEqual( + single_select_option_inputs(existing, desired), + [ + {"name": "In review", "color": "GRAY", "description": ""}, + {"name": "Done", "color": "GRAY", "description": "", "id": "DONE-ID"}, + ], + ) + + def test_update_single_select_field_uses_project_v2_field_mutation(self): + client = Mock() + client.graphql.return_value = {"updateProjectV2Field": {"projectV2Field": {"id": "FIELD"}}} + existing = { + "id": "FIELD", + "options": [ + {"id": "TODO-ID", "name": "Todo"}, + {"id": "INPROGRESS-ID", "name": "In Progress"}, + {"id": "DONE-ID", "name": "Done"}, + ], + } + desired = {"name": "Status", "type": "single_select", "options": ["In progress", "In review", "Done"]} + + update_single_select_field(client, existing, desired) + + query, variables = client.graphql.call_args.args + self.assertIn("updateProjectV2Field", query) + self.assertEqual(variables["field"], "FIELD") + self.assertEqual([item["name"] for item in variables["options"]], ["In progress", "In review", "Done"]) + self.assertEqual(variables["options"][0]["id"], "INPROGRESS-ID") + self.assertEqual(variables["options"][2]["id"], "DONE-ID") + + @patch("project_setup.project.update_single_select_field") + @patch("project_setup.project.list_project_fields") + def test_ensure_fields_reconciles_builtin_status_options(self, list_fields, update_field): + initial = { + "__typename": "ProjectV2SingleSelectField", + "id": "STATUS-FIELD", + "name": "Status", + "dataType": "SINGLE_SELECT", + "options": [ + {"id": "TODO-ID", "name": "Todo"}, + {"id": "INPROGRESS-ID", "name": "In Progress"}, + {"id": "DONE-ID", "name": "Done"}, + ], + } + reconciled = { + **initial, + "options": [ + {"id": "NEW-PROGRESS", "name": "In progress"}, + {"id": "NEW-REVIEW", "name": "In review"}, + {"id": "DONE-ID", "name": "Done"}, + ], + } + list_fields.side_effect = [[initial], [reconciled]] + definition = { + "fields": [ + {"name": "Status", "type": "single_select", "options": ["In progress", "In review", "Done"]} + ] + } + + result = ensure_fields(Mock(), "PROJECT", definition, dry_run=False) + + update_field.assert_called_once_with(update_field.call_args.args[0], initial, definition["fields"][0]) + self.assertEqual(result["Status"]["options"][1]["name"], "In review") + + @patch("project_setup.project.update_single_select_field") + @patch("project_setup.project.list_project_fields") + def test_ensure_fields_keeps_matching_status_idempotent(self, list_fields, update_field): + current = { + "__typename": "ProjectV2SingleSelectField", + "id": "STATUS-FIELD", + "name": "Status", + "dataType": "SINGLE_SELECT", + "options": [ + {"id": "PROGRESS", "name": "In progress"}, + {"id": "REVIEW", "name": "In review"}, + {"id": "DONE", "name": "Done"}, + ], + } + list_fields.return_value = [current] + definition = { + "fields": [ + {"name": "Status", "type": "single_select", "options": ["In progress", "In review", "Done"]} + ] + } + + result = ensure_fields(Mock(), "PROJECT", definition, dry_run=False) + + update_field.assert_not_called() + self.assertEqual(result["Status"], current) From da6eee0b4aa1ab495611d28081d92256582cdf58 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 17:52:19 -0300 Subject: [PATCH 04/28] fix: wait for Project v2 read-after-write convergence --- tests/qa/live_pr_sync.py | 47 ++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/tests/qa/live_pr_sync.py b/tests/qa/live_pr_sync.py index 3ce1f3e..9d7d449 100644 --- a/tests/qa/live_pr_sync.py +++ b/tests/qa/live_pr_sync.py @@ -7,6 +7,7 @@ from pathlib import Path import re import tempfile +import time import urllib.parse from project_setup.github import API_BASE, GitHubClient, split_repo @@ -20,6 +21,8 @@ QA_SYNC_ISSUE_PREFIX = "QA PR Sync task " QA_SYNC_PR_PREFIX = "QA PR Sync validation " QA_SYNC_BRANCH_PREFIX = "qa/pr-sync/" +PROJECT_READBACK_TIMEOUT_SECONDS = 20.0 +PROJECT_READBACK_INTERVAL_SECONDS = 1.0 def require_sandbox(repo: str) -> None: @@ -148,6 +151,30 @@ def project_item_status( cursor = page["pageInfo"]["endCursor"] +def wait_for_project_task_status( + client: GitHubClient, + project_id: str, + repo: str, + issue_number: int, + expected_status: str, + *, + timeout_seconds: float = PROJECT_READBACK_TIMEOUT_SECONDS, + interval_seconds: float = PROJECT_READBACK_INTERVAL_SECONDS, +) -> tuple[bool, str | None]: + task_node = issue_node_id(client, repo, issue_number) + deadline = time.monotonic() + timeout_seconds + last_status: str | None = None + while True: + project_items = list_project_items(client, project_id) + if task_node in project_items: + last_status = project_item_status(client, project_id, repo, issue_number) + if last_status == expected_status: + return True, last_status + if time.monotonic() >= deadline: + return False, last_status + time.sleep(interval_seconds) + + def cleanup_stale_resources(client: GitHubClient, repo: str, owner: str, owner_type: str) -> None: for pr in client.paginated(f"{API_BASE}/repos/{repo}/pulls?state=open"): if str(pr.get("title") or "").startswith(QA_SYNC_PR_PREFIX): @@ -355,12 +382,18 @@ def main() -> int: raise RuntimeError("PR/task assignee fallback was not synchronized") print("pr_assignees=passed") - task_node = issue_node_id(client, args.repo, created_issue_number) - project_items = list_project_items(client, created_project_id) - if task_node not in project_items: - raise RuntimeError("Linked implementation task was not added to Project v2") - if project_item_status(client, created_project_id, args.repo, created_issue_number) != "In review": - raise RuntimeError("Project v2 Status was not synchronized to In review") + project_converged, visible_status = wait_for_project_task_status( + client, + created_project_id, + args.repo, + created_issue_number, + "In review", + ) + if not project_converged: + raise RuntimeError( + "Project v2 task/status did not converge after synchronization; " + f"last visible status: {visible_status or '(task not visible)'}" + ) print("project_v2_task_status=passed") print("non_default_base_branch=passed") print("pr_sync_structured_metadata=passed") @@ -417,4 +450,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From f9ab9777778b45ed0d6b9f07102762da47a42141 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:28:14 -0300 Subject: [PATCH 05/28] feat: add promotion native metadata synchronization --- project_setup/promotion_sync.py | 366 ++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 project_setup/promotion_sync.py diff --git a/project_setup/promotion_sync.py b/project_setup/promotion_sync.py new file mode 100644 index 0000000..3b2b401 --- /dev/null +++ b/project_setup/promotion_sync.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +from typing import Any + +from .github import API_BASE, GitHubClient, split_repo +from .pr_sync import ( + PullRequestContext, + add_assignees, + context_from_event, + is_permission_error, + issue_assignee_logins, + issue_label_names, + issue_milestone_number, + load_sync_config, + project_status_for_context, + status_option_id, +) +from .project import add_issue_to_project, find_project, list_project_fields, update_single_select +from .related_prs import ( + RELATED_PRS_MARKER, + _promotion_link_marker, + _render_promotion_link, + _upsert_comment, + is_promotion_context, + load_related_prs_config, + pr_numbers_from_body_sections, +) + + +@dataclass(frozen=True) +class PromotionMetadata: + labels: list[str] + label_conflicts: list[str] + assignees: list[str] + milestone_number: int | None + milestone_title: str | None + milestone_conflict: str | None + + +def _label_values(item: dict[str, Any], prefix: str) -> list[str]: + return sorted(name for name in issue_label_names(item) if name.startswith(prefix)) + + +def aggregate_promotion_metadata( + related_items: list[dict[str, Any]], + label_prefixes: list[str], +) -> PromotionMetadata: + labels: list[str] = [] + label_conflicts: list[str] = [] + + for prefix in label_prefixes: + per_item = [_label_values(item, prefix) for item in related_items] + if per_item and all(not values for values in per_item): + continue + if per_item and all(len(values) == 1 for values in per_item): + values = {values[0] for values in per_item} + if len(values) == 1: + labels.append(next(iter(values))) + continue + if per_item: + rendered = ", ".join("/".join(values) if values else "missing" for values in per_item) + label_conflicts.append(f"{prefix} [{rendered}]") + + assignees: list[str] = [] + seen_assignees: set[str] = set() + for item in related_items: + for login in issue_assignee_logins(item): + if login not in seen_assignees: + seen_assignees.add(login) + assignees.append(login) + + milestone_numbers = [issue_milestone_number(item) for item in related_items] + milestone_number: int | None = None + milestone_title: str | None = None + milestone_conflict: str | None = None + if milestone_numbers and all(number is None for number in milestone_numbers): + pass + elif milestone_numbers and milestone_numbers[0] is not None and all( + number == milestone_numbers[0] for number in milestone_numbers + ): + milestone_number = milestone_numbers[0] + milestone = related_items[0].get("milestone") or {} + milestone_title = str(milestone.get("title") or "") or None + elif milestone_numbers: + rendered = ", ".join(str(number) if number is not None else "missing" for number in milestone_numbers) + milestone_conflict = f"related PR milestones disagree [{rendered}]" + + return PromotionMetadata( + labels=labels, + label_conflicts=label_conflicts, + assignees=assignees, + milestone_number=milestone_number, + milestone_title=milestone_title, + milestone_conflict=milestone_conflict, + ) + + +def sync_promotion_native_metadata( + client: GitHubClient, + repo: str, + ctx: PullRequestContext, + pr_issue: dict[str, Any], + metadata: PromotionMetadata, + config: dict[str, Any], + *, + dry_run: bool = False, +) -> list[str]: + notes: list[str] = [] + + if config.get("syncLabels", True): + prefixes = tuple(str(value) for value in config.get("labelPrefixes", [])) + existing = issue_label_names(pr_issue) + unmanaged = sorted(name for name in existing if not name.startswith(prefixes)) + target = sorted(set(unmanaged + metadata.labels)) + if set(target) != existing: + if dry_run: + print(f"[DRY-RUN] Would set promotion PR #{ctx.number} labels: {', '.join(target) or '(none)'}") + else: + client.request_json( + "PUT", + f"{API_BASE}/repos/{repo}/issues/{ctx.number}/labels", + {"labels": target}, + ) + if metadata.labels: + notes.append("labels=" + ", ".join(f"`{name}`" for name in metadata.labels)) + elif metadata.label_conflicts: + notes.append("labels=not synchronized (no consensus)") + else: + notes.append("labels=none") + + if config.get("syncMilestone", True): + current = issue_milestone_number(pr_issue) + desired = metadata.milestone_number + if desired != current: + if dry_run: + print(f"[DRY-RUN] Would set promotion PR #{ctx.number} milestone to {desired or 'none'}") + else: + client.update_issue(repo, ctx.number, {"milestone": desired}) + if desired is not None: + notes.append(f"milestone=`{metadata.milestone_title or f'#{desired}'}`") + elif metadata.milestone_conflict: + notes.append("milestone=cleared (no consensus)") + else: + notes.append("milestone=none") + + if config.get("syncAssignees", True): + current_assignees = set(issue_assignee_logins(pr_issue)) + missing = [login for login in metadata.assignees if login not in current_assignees] + if missing: + if dry_run: + print(f"[DRY-RUN] Would assign promotion PR #{ctx.number} to {', '.join(missing)}") + else: + add_assignees(client, repo, ctx.number, missing) + notes.append( + "assignees=" + (", ".join(f"`{login}`" for login in metadata.assignees) if metadata.assignees else "none") + ) + + return notes + + +def list_project_content_items(client: GitHubClient, project_id: str) -> dict[str, str]: + query = """ + query($project:ID!, $cursor:String) { + node(id:$project) { + ... on ProjectV2 { + items(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + content { + __typename + ... on Issue { id } + ... on PullRequest { id } + } + } + } + } + } + } + """ + result: dict[str, str] = {} + cursor = None + while True: + page = client.graphql(query, {"project": project_id, "cursor": cursor})["node"]["items"] + for item in page["nodes"]: + content = item.get("content") or {} + if content.get("__typename") in {"Issue", "PullRequest"} and content.get("id"): + result[str(content["id"])] = str(item["id"]) + if not page["pageInfo"]["hasNextPage"]: + return result + cursor = page["pageInfo"]["endCursor"] + + +def sync_promotion_project_status( + project_client: GitHubClient, + repo: str, + ctx: PullRequestContext, + pr_issue: dict[str, Any], + project_number: int, + desired_status: str, + config: dict[str, Any], + *, + owner: str | None = None, + dry_run: bool = False, +) -> str: + if not config.get("syncProject", True): + return "disabled by configuration." + + project_owner = owner or split_repo(repo)[0] + project = find_project(project_client, project_owner, project_number) + fields = { + str(field["name"]): field + for field in list_project_fields(project_client, str(project["id"])) + if field and field.get("name") + } + field_name = str(config.get("projectStatusField") or "Status") + status_field = fields.get(field_name) + if not status_field: + raise RuntimeError(f"Project field `{field_name}` was not found") + + selected = status_option_id(status_field, desired_status) + if not selected: + available = ", ".join(str(item.get("name")) for item in status_field.get("options", [])) + raise RuntimeError( + f"Project status option `{desired_status}` was not found in `{field_name}`. " + f"Available options: {available or '(none)'}" + ) + + pr_node = str(pr_issue.get("node_id") or "") + if not pr_node: + raise RuntimeError(f"Promotion PR #{ctx.number} has no GraphQL node id") + current_items = list_project_content_items(project_client, str(project["id"])) + item_id = current_items.get(pr_node) + if not item_id: + if dry_run: + print(f"[DRY-RUN] Would add promotion PR #{ctx.number} to Project v2 #{project_number}") + item_id = f"dry-run-pr-{ctx.number}" + else: + item_id = add_issue_to_project(project_client, str(project["id"]), pr_node) + + if dry_run: + print( + f"[DRY-RUN] Would set promotion PR #{ctx.number} `{field_name}` " + f"to `{desired_status}` in Project v2 #{project_number}" + ) + else: + update_single_select( + project_client, + str(project["id"]), + str(item_id), + str(status_field["id"]), + selected, + ) + return f"promotion PR synced to `{desired_status}` in Project v2 #{project_number}." + + +def _render_summary( + ctx: PullRequestContext, + state: str, + related_numbers: list[int], + metadata: PromotionMetadata, + metadata_notes: list[str], + project_note: str, +) -> str: + lines = [ + RELATED_PRS_MARKER, + "## Promotion Sync", + "", + f"- Promotion: `{ctx.head_ref} -> {ctx.base_ref}`", + f"- State: `{state}`", + "- Related PRs:", + *[f" - #{number}" for number in related_numbers], + "- Native metadata:", + *[f" - {note}" for note in metadata_notes], + ] + for conflict in metadata.label_conflicts: + lines.append(f" - label conflict: `{conflict}`") + if metadata.milestone_conflict: + lines.append(f" - milestone conflict: `{metadata.milestone_conflict}`") + lines.append(f"- Project v2: {project_note}") + return "\n".join(lines) + + +def apply_promotion_sync( + client: GitHubClient, + repo: str, + event: dict[str, Any], + *, + config_path: str | os.PathLike[str] = "project_setup.json", + project_client: GitHubClient | None = None, + project_number: int | None = None, + owner: str | None = None, + dry_run: bool = False, +) -> int: + ctx = context_from_event(event, client=client, repo=repo) + if not is_promotion_context(ctx, config_path): + return 0 + + related_config = load_related_prs_config(config_path) + related_numbers = pr_numbers_from_body_sections( + ctx.body, + [str(item) for item in related_config.get("bodySections", [])], + ) + if not related_numbers: + print(f"Promotion Sync: PR #{ctx.number} has no Related PRs context.") + return 1 + + sync_config = load_sync_config(config_path) + related_items = [client.get_issue(repo, number) for number in related_numbers] + metadata = aggregate_promotion_metadata( + related_items, + [str(value) for value in sync_config.get("labelPrefixes", [])], + ) + pr_issue = client.get_issue(repo, ctx.number) + metadata_notes = sync_promotion_native_metadata( + client, + repo, + ctx, + pr_issue, + metadata, + sync_config, + dry_run=dry_run, + ) + + desired_status = project_status_for_context(ctx, sync_config) + if not sync_config.get("syncProject", True): + project_note = "disabled by configuration." + elif project_number is None: + project_note = "skipped because `PROJECT_SETUP_PROJECT_NUMBER` is not configured." + elif project_client is None: + project_note = "skipped because `PROJECT_SETUP_PAT` is not configured." + else: + try: + project_note = sync_promotion_project_status( + project_client, + repo, + ctx, + pr_issue, + project_number, + desired_status, + sync_config, + owner=owner, + dry_run=dry_run, + ) + except Exception as exc: + if not is_permission_error(exc): + raise + project_note = f"not synchronized: Project token lacks permission ({exc})." + + state = "merged" if ctx.action == "closed" and ctx.merged else "planned" + marker = _promotion_link_marker(ctx.base_ref) + backlink = _render_promotion_link(ctx, state) + for number in related_numbers: + _upsert_comment(client, repo, number, marker, backlink, dry_run=dry_run) + + _upsert_comment( + client, + repo, + ctx.number, + RELATED_PRS_MARKER, + _render_summary(ctx, state, related_numbers, metadata, metadata_notes, project_note), + dry_run=dry_run, + ) + return 0 From ed77aceca5e7a14dfbae8e608cb4705c86b3a511 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:28:36 -0300 Subject: [PATCH 06/28] feat: route promotion sync with project context --- project_setup/pr_sync_router.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/project_setup/pr_sync_router.py b/project_setup/pr_sync_router.py index ae4da2f..0139251 100644 --- a/project_setup/pr_sync_router.py +++ b/project_setup/pr_sync_router.py @@ -12,7 +12,8 @@ load_sync_config, project_number_from_value, ) -from .related_prs import apply_promotion_sync, is_promotion_context +from .promotion_sync import apply_promotion_sync +from .related_prs import is_promotion_context def load_event(path: str | os.PathLike[str]) -> dict: @@ -37,6 +38,9 @@ def apply_routed_pr_sync( repo, event, config_path=config_path, + project_client=project_client, + project_number=project_number, + owner=owner, dry_run=dry_run, ) From b9649e9b3014b174a1c07f3949f1c9353baf1eaa Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:29:05 -0300 Subject: [PATCH 07/28] test: cover promotion native metadata aggregation --- tests/test_promotion_sync.py | 248 +++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 tests/test_promotion_sync.py diff --git a/tests/test_promotion_sync.py b/tests/test_promotion_sync.py new file mode 100644 index 0000000..d9e20f0 --- /dev/null +++ b/tests/test_promotion_sync.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from pathlib import Path +import unittest +from unittest.mock import Mock, patch + +from project_setup.github import GitHubClient +from project_setup.pr_sync import PullRequestContext +from project_setup.promotion_sync import ( + PromotionMetadata, + aggregate_promotion_metadata, + sync_promotion_native_metadata, + sync_promotion_project_status, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class PromotionMetadataAggregationTests(unittest.TestCase): + def item(self, *, labels=None, assignees=None, milestone=None): + return { + "labels": [{"name": name} for name in (labels or [])], + "assignees": [{"login": login} for login in (assignees or [])], + "milestone": milestone, + } + + def test_consensus_labels_milestone_and_assignee_union(self): + items = [ + self.item( + labels=["type:task", "priority:high", "test:manual", "status:backlog"], + assignees=["alice"], + milestone={"number": 3, "title": "M3"}, + ), + self.item( + labels=["type:task", "priority:high", "test:manual"], + assignees=["bob", "alice"], + milestone={"number": 3, "title": "M3"}, + ), + ] + + result = aggregate_promotion_metadata(items, ["type:", "priority:", "test:"]) + + self.assertEqual(result.labels, ["type:task", "priority:high", "test:manual"]) + self.assertEqual(result.label_conflicts, []) + self.assertEqual(result.assignees, ["alice", "bob"]) + self.assertEqual(result.milestone_number, 3) + self.assertEqual(result.milestone_title, "M3") + self.assertIsNone(result.milestone_conflict) + + def test_conflicting_single_value_metadata_is_not_invented(self): + items = [ + self.item( + labels=["type:task", "priority:high"], + milestone={"number": 3, "title": "M3"}, + ), + self.item( + labels=["type:task", "priority:medium"], + milestone={"number": 4, "title": "M4"}, + ), + ] + + result = aggregate_promotion_metadata(items, ["type:", "priority:", "test:"]) + + self.assertEqual(result.labels, ["type:task"]) + self.assertTrue(any(conflict.startswith("priority:") for conflict in result.label_conflicts)) + self.assertIsNone(result.milestone_number) + self.assertIsNotNone(result.milestone_conflict) + + +class PromotionNativeSyncTests(unittest.TestCase): + def context(self) -> PullRequestContext: + return PullRequestContext( + number=65, + action="synchronize", + body="## Related PRs\n- #63\n- #67\n", + base_ref="main", + head_ref="Q.A", + head_repo="owner/repo", + author="alice", + draft=False, + merged=False, + ) + + def config(self) -> dict: + return { + "syncLabels": True, + "labelPrefixes": ["type:", "priority:", "test:"], + "syncMilestone": True, + "syncAssignees": True, + "syncProject": True, + "projectStatusField": "Status", + } + + def test_native_sync_replaces_managed_labels_and_sets_milestone_and_assignees(self): + client = Mock(spec=GitHubClient) + pr_issue = { + "number": 65, + "labels": [{"name": "priority:old"}, {"name": "keep-me"}], + "assignees": [], + "milestone": None, + } + metadata = PromotionMetadata( + labels=["type:task", "priority:high", "test:manual"], + label_conflicts=[], + assignees=["alice", "bob"], + milestone_number=3, + milestone_title="M3", + milestone_conflict=None, + ) + + sync_promotion_native_metadata( + client, + "owner/repo", + self.context(), + pr_issue, + metadata, + self.config(), + ) + + client.request_json.assert_any_call( + "PUT", + "https://api.github.com/repos/owner/repo/issues/65/labels", + {"labels": ["keep-me", "priority:high", "test:manual", "type:task"]}, + ) + client.update_issue.assert_called_once_with("owner/repo", 65, {"milestone": 3}) + client.request_json.assert_any_call( + "POST", + "https://api.github.com/repos/owner/repo/issues/65/assignees", + {"assignees": ["alice", "bob"]}, + ) + + def test_conflict_clears_managed_labels_and_milestone(self): + client = Mock(spec=GitHubClient) + pr_issue = { + "number": 65, + "labels": [{"name": "priority:old"}, {"name": "keep-me"}], + "assignees": [], + "milestone": {"number": 3}, + } + metadata = PromotionMetadata( + labels=[], + label_conflicts=["priority: [priority:high, priority:medium]"], + assignees=[], + milestone_number=None, + milestone_title=None, + milestone_conflict="related PR milestones disagree [3, 4]", + ) + + sync_promotion_native_metadata( + client, + "owner/repo", + self.context(), + pr_issue, + metadata, + self.config(), + ) + + client.request_json.assert_called_once_with( + "PUT", + "https://api.github.com/repos/owner/repo/issues/65/labels", + {"labels": ["keep-me"]}, + ) + client.update_issue.assert_called_once_with("owner/repo", 65, {"milestone": None}) + + def test_promotion_pr_itself_is_added_to_project_and_statused(self): + client = Mock(spec=GitHubClient) + pr_issue = {"number": 65, "node_id": "PR_node_65"} + status_field = { + "id": "FIELD_STATUS", + "name": "Status", + "options": [{"id": "OPT_REVIEW", "name": "In review"}], + } + + with ( + patch("project_setup.promotion_sync.find_project", return_value={"id": "PROJECT"}), + patch("project_setup.promotion_sync.list_project_fields", return_value=[status_field]), + patch("project_setup.promotion_sync.list_project_content_items", return_value={}), + patch("project_setup.promotion_sync.add_issue_to_project", return_value="ITEM") as add_item, + patch("project_setup.promotion_sync.update_single_select") as update_status, + ): + note = sync_promotion_project_status( + client, + "owner/repo", + self.context(), + pr_issue, + 42, + "In review", + self.config(), + owner="owner", + ) + + add_item.assert_called_once_with(client, "PROJECT", "PR_node_65") + update_status.assert_called_once_with(client, "PROJECT", "ITEM", "FIELD_STATUS", "OPT_REVIEW") + self.assertIn("promotion PR synced", note) + + +class PromotionWorkflowContractTests(unittest.TestCase): + def test_live_qa_runs_promotion_native_metadata_smoke(self): + workflow = (ROOT / ".github/workflows/qa-live.yml").read_text(encoding="utf-8") + self.assertIn("python tests/qa/live_promotion_sync.py", workflow) + + def test_router_forwards_project_context_to_promotion_sync(self): + from project_setup.pr_sync_router import apply_routed_pr_sync + + event = { + "action": "opened", + "pull_request": { + "number": 65, + "body": "## Related PRs\n- #63\n", + "base": {"ref": "main"}, + "head": {"ref": "Q.A", "repo": {"full_name": "owner/repo"}}, + "user": {"login": "alice"}, + "draft": False, + "merged": False, + }, + } + client = Mock(spec=GitHubClient) + project_client = Mock(spec=GitHubClient) + + with patch("project_setup.pr_sync_router.is_promotion_context", return_value=True), patch( + "project_setup.pr_sync_router.apply_promotion_sync", return_value=0 + ) as promotion_sync: + result = apply_routed_pr_sync( + client, + "owner/repo", + event, + config_path="project_setup.json", + project_client=project_client, + project_number=42, + owner="owner", + ) + + self.assertEqual(result, 0) + promotion_sync.assert_called_once_with( + client, + "owner/repo", + event, + config_path="project_setup.json", + project_client=project_client, + project_number=42, + owner="owner", + dry_run=False, + ) + + +if __name__ == "__main__": + unittest.main() From d9a88dd0b83992f1ea4030793efca9e396b21a51 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:29:44 -0300 Subject: [PATCH 08/28] test: add live promotion metadata smoke --- .github/workflows/qa-live.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qa-live.yml b/.github/workflows/qa-live.yml index 5f0440d..0f91f93 100644 --- a/.github/workflows/qa-live.yml +++ b/.github/workflows/qa-live.yml @@ -23,7 +23,7 @@ jobs: live-sandbox: name: qa-live-gate runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 environment: name: qa deployment: false @@ -80,3 +80,12 @@ jobs: python tests/qa/live_pr_sync.py --repo "$QA_REPOSITORY" --run-id "pr-${{ inputs.pr_number }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + + - name: Run live Promotion Sync native metadata test + env: + QA_REPOSITORY: ${{ vars.QA_REPOSITORY }} + PROJECT_SETUP_PAT: ${{ secrets.QA_PROJECT_SETUP_PAT }} + run: >- + python tests/qa/live_promotion_sync.py + --repo "$QA_REPOSITORY" + --run-id "pr-${{ inputs.pr_number }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" From 4353e04c52d7f6ba6df2527c2dee9e65f945a144 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:30:55 -0300 Subject: [PATCH 09/28] test: add live promotion PR metadata validation --- tests/qa/live_promotion_sync.py | 616 ++++++++++++++++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 tests/qa/live_promotion_sync.py diff --git a/tests/qa/live_promotion_sync.py b/tests/qa/live_promotion_sync.py new file mode 100644 index 0000000..e756c71 --- /dev/null +++ b/tests/qa/live_promotion_sync.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +import argparse +import base64 +import json +import os +from pathlib import Path +import re +import tempfile +import time +import urllib.parse + +from project_setup.github import API_BASE, GitHubClient, GitHubRequestError, split_repo +from project_setup.pr_sync import DEFAULT_SYNC_CONFIG, apply_pr_sync +from project_setup.pr_sync_router import apply_routed_pr_sync +from project_setup.project import create_project, ensure_fields, resolve_owner_type + + +QA_LABEL_MARKER = ":qa-promotion-" +QA_MILESTONE_PREFIX = "QA-PROMOTION-" +QA_PROJECT_PREFIX = "QA Promotion Sync validation " +QA_ISSUE_PREFIX = "QA Promotion Sync task " +QA_IMPL_PR_PREFIX = "QA Promotion implementation " +QA_PROMOTION_PR_PREFIX = "QA Promotion aggregate " +QA_BRANCH_PREFIX = "qa/promotion-sync/" +PROJECT_READBACK_TIMEOUT_SECONDS = 20.0 +PROJECT_READBACK_INTERVAL_SECONDS = 1.0 + + +def require_sandbox(repo: str) -> None: + current_repo = os.getenv("GITHUB_REPOSITORY", "").strip() + if not repo: + raise SystemExit("QA_REPOSITORY is missing. Configure it in the `qa` Environment.") + if "/" not in repo: + raise SystemExit("QA_REPOSITORY must use owner/repository format.") + if current_repo and repo.casefold() == current_repo.casefold(): + raise SystemExit("Refusing live Promotion Sync validation against the GPA source repository.") + if not os.getenv("PROJECT_SETUP_PAT", "").strip(): + raise SystemExit("QA_PROJECT_SETUP_PAT is required for live Promotion Sync validation.") + + +def list_projects(client: GitHubClient, owner: str, owner_type: str) -> list[dict]: + query = f""" + query($login:String!, $cursor:String) {{ + {owner_type}(login:$login) {{ + projectsV2(first:100, after:$cursor) {{ + pageInfo {{ hasNextPage endCursor }} + nodes {{ id number title url }} + }} + }} + }} + """ + projects: list[dict] = [] + cursor = None + while True: + data = client.graphql(query, {"login": owner, "cursor": cursor}) + node = data.get(owner_type) + if not node: + return projects + page = node["projectsV2"] + projects.extend(project for project in page["nodes"] if project) + if not page["pageInfo"]["hasNextPage"]: + return projects + cursor = page["pageInfo"]["endCursor"] + + +def project_by_title(client: GitHubClient, owner: str, owner_type: str, title: str) -> dict | None: + return next((project for project in list_projects(client, owner, owner_type) if project.get("title") == title), None) + + +def delete_project(client: GitHubClient, project_id: str) -> None: + mutation = """ + mutation($project:ID!) { + deleteProjectV2(input:{projectId:$project}) { projectV2 { id } } + } + """ + client.graphql(mutation, {"project": project_id}) + + +def encoded_ref(branch: str) -> str: + return urllib.parse.quote(f"heads/{branch}", safe="/") + + +def branch_sha(client: GitHubClient, repo: str, branch: str) -> str: + ref = client.request_json("GET", f"{API_BASE}/repos/{repo}/git/ref/{encoded_ref(branch)}") + return str((ref.get("object") or {})["sha"]) + + +def create_branch(client: GitHubClient, repo: str, branch: str, sha: str) -> None: + client.request_json( + "POST", + f"{API_BASE}/repos/{repo}/git/refs", + {"ref": f"refs/heads/{branch}", "sha": sha}, + ) + + +def delete_branch(client: GitHubClient, repo: str, branch: str) -> None: + client.request_json("DELETE", f"{API_BASE}/repos/{repo}/git/refs/{encoded_ref(branch)}") + + +def create_marker_commit(client: GitHubClient, repo: str, branch: str, path: str, content: str, message: str) -> None: + client.request_json( + "PUT", + f"{API_BASE}/repos/{repo}/contents/{urllib.parse.quote(path, safe='')}", + { + "message": message, + "content": base64.b64encode(content.encode()).decode(), + "branch": branch, + }, + ) + + +def project_pull_request_status( + client: GitHubClient, + project_id: str, + repo: str, + pr_number: int, + field_name: str = "Status", +) -> tuple[bool, str | None]: + query = """ + query($project:ID!, $cursor:String) { + node(id:$project) { + ... on ProjectV2 { + items(first:100, after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + content { + __typename + ... on PullRequest { number repository { nameWithOwner } } + } + fieldValues(first:50) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + } + } + } + } + } + } + } + """ + cursor = None + while True: + page = client.graphql(query, {"project": project_id, "cursor": cursor})["node"]["items"] + for item in page["nodes"]: + content = item.get("content") or {} + repository = content.get("repository") or {} + if ( + content.get("__typename") == "PullRequest" + and int(content.get("number") or 0) == pr_number + and str(repository.get("nameWithOwner") or "").casefold() == repo.casefold() + ): + for value in (item.get("fieldValues") or {}).get("nodes", []): + if not value: + continue + field = value.get("field") or {} + if field.get("name") == field_name: + return True, str(value.get("name") or "") or None + return True, None + if not page["pageInfo"]["hasNextPage"]: + return False, None + cursor = page["pageInfo"]["endCursor"] + + +def wait_for_project_pr_status( + client: GitHubClient, + project_id: str, + repo: str, + pr_number: int, + expected_status: str, +) -> tuple[bool, str | None]: + deadline = time.monotonic() + PROJECT_READBACK_TIMEOUT_SECONDS + last_status: str | None = None + while True: + found, last_status = project_pull_request_status(client, project_id, repo, pr_number) + if found and last_status == expected_status: + return True, last_status + if time.monotonic() >= deadline: + return False, last_status + time.sleep(PROJECT_READBACK_INTERVAL_SECONDS) + + +def cleanup_stale_resources(client: GitHubClient, repo: str, owner: str, owner_type: str) -> None: + for pr in client.paginated(f"{API_BASE}/repos/{repo}/pulls?state=open"): + title = str(pr.get("title") or "") + if title.startswith(QA_IMPL_PR_PREFIX) or title.startswith(QA_PROMOTION_PR_PREFIX): + client.request_json("PATCH", f"{API_BASE}/repos/{repo}/pulls/{pr['number']}", {"state": "closed"}) + + for issue in client.paginated(f"{API_BASE}/repos/{repo}/issues?state=open"): + if "pull_request" in issue: + continue + if str(issue.get("title") or "").startswith(QA_ISSUE_PREFIX): + client.update_issue(repo, int(issue["number"]), {"state": "closed"}) + + for project in list_projects(client, owner, owner_type): + if str(project.get("title") or "").startswith(QA_PROJECT_PREFIX): + delete_project(client, str(project["id"])) + + for milestone in client.paginated(f"{API_BASE}/repos/{repo}/milestones?state=all"): + if str(milestone.get("title") or "").startswith(QA_MILESTONE_PREFIX): + client.request_json("DELETE", f"{API_BASE}/repos/{repo}/milestones/{milestone['number']}") + + for label in client.paginated(f"{API_BASE}/repos/{repo}/labels"): + name = str(label.get("name") or "") + if QA_LABEL_MARKER in name: + client.request_json("DELETE", f"{API_BASE}/repos/{repo}/labels/{urllib.parse.quote(name, safe='')}") + + refs = client.request_json( + "GET", + f"{API_BASE}/repos/{repo}/git/matching-refs/{urllib.parse.quote('heads/' + QA_BRANCH_PREFIX, safe='/')}", + ) + for ref in refs if isinstance(refs, list) else []: + name = str(ref.get("ref") or "") + if name.startswith("refs/heads/"): + try: + delete_branch(client, repo, name.removeprefix("refs/heads/")) + except Exception: + pass + + +def create_implementation( + client: GitHubClient, + repo: str, + source_branch: str, + branch: str, + issue_number: int, + milestone_title: str, + marker_path: str, + suffix: str, + project_number: int, + owner: str, +) -> int: + create_branch(client, repo, branch, branch_sha(client, repo, source_branch)) + create_marker_commit( + client, + repo, + branch, + marker_path, + f"Promotion Sync implementation marker {suffix}\n", + f"test: promotion implementation {suffix}", + ) + pr = client.request_json( + "POST", + f"{API_BASE}/repos/{repo}/pulls", + { + "title": f"{QA_IMPL_PR_PREFIX}{suffix}", + "head": branch, + "base": source_branch, + "body": ( + f"## Linked Issue\n- Closes #{issue_number}\n\n" + f"## Milestone\n- {milestone_title}\n\n" + "## Summary\n- Disposable Promotion Sync constituent.\n" + ), + }, + ) + result = apply_pr_sync( + client, + repo, + {"action": "opened", "pull_request": pr}, + dict(DEFAULT_SYNC_CONFIG), + project_client=client, + project_number=project_number, + owner=owner, + dry_run=False, + ) + if result != 0: + raise RuntimeError(f"Implementation PR Sync returned {result}") + merge = client.request_json( + "PUT", + f"{API_BASE}/repos/{repo}/pulls/{pr['number']}/merge", + {"merge_method": "merge"}, + ) + if not merge.get("merged"): + raise RuntimeError(f"Failed to merge disposable implementation PR #{pr['number']}") + return int(pr["number"]) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Live Promotion Sync native metadata and Project v2 validation") + parser.add_argument("--repo", required=True) + parser.add_argument("--run-id", required=True) + args = parser.parse_args() + + require_sandbox(args.repo) + client = GitHubClient(os.environ["PROJECT_SETUP_PAT"].strip()) + owner, _ = split_repo(args.repo) + owner_type = resolve_owner_type(client, owner) + suffix = re.sub(r"[^A-Za-z0-9_.-]+", "-", args.run_id).strip("-")[:30] or "manual" + + labels = [ + f"type:qa-promotion-{suffix}", + f"priority:qa-promotion-{suffix}", + f"test:qa-promotion-{suffix}", + ] + milestone_title = f"{QA_MILESTONE_PREFIX}{suffix}" + project_title = f"{QA_PROJECT_PREFIX}{suffix}" + base_branch = f"{QA_BRANCH_PREFIX}base-{suffix}" + source_branch = f"{QA_BRANCH_PREFIX}source-{suffix}" + impl_branches = [ + f"{QA_BRANCH_PREFIX}feat-a-{suffix}", + f"{QA_BRANCH_PREFIX}fix-b-{suffix}", + ] + + created_project_id: str | None = None + created_project_number: int | None = None + created_milestone_number: int | None = None + created_issue_numbers: list[int] = [] + created_pr_numbers: list[int] = [] + created_branches: list[str] = [] + primary_error: Exception | None = None + cleanup_errors: list[str] = [] + + cleanup_stale_resources(client, args.repo, owner, owner_type) + + try: + for label_name in labels: + client.request_json( + "POST", + f"{API_BASE}/repos/{args.repo}/labels", + {"name": label_name, "color": "ededed", "description": "Disposable Promotion Sync Q.A label"}, + ) + + milestone = client.request_json( + "POST", + f"{API_BASE}/repos/{args.repo}/milestones", + {"title": milestone_title, "description": "Disposable Promotion Sync Q.A milestone"}, + ) + created_milestone_number = int(milestone["number"]) + + with tempfile.TemporaryDirectory() as temporary_directory: + project_definition = Path(temporary_directory) / "project.json" + project_definition.write_text( + json.dumps( + { + "name": project_title, + "fields": [ + { + "name": "Status", + "type": "single_select", + "options": ["In progress", "In review", "Done"], + } + ], + }, + indent=2, + ), + encoding="utf-8", + ) + create_project(client, args.repo, str(project_definition), dry_run=False, owner_type=owner_type) + + project = project_by_title(client, owner, owner_type, project_title) + if not project: + raise RuntimeError("Promotion Sync Q.A Project v2 creation verification failed") + created_project_id = str(project["id"]) + created_project_number = int(project["number"]) + ensure_fields( + client, + created_project_id, + { + "fields": [ + { + "name": "Status", + "type": "single_select", + "options": ["In progress", "In review", "Done"], + } + ] + }, + dry_run=False, + ) + + for index in range(2): + issue = client.request_json( + "POST", + f"{API_BASE}/repos/{args.repo}/issues", + { + "title": f"{QA_ISSUE_PREFIX}{suffix}-{index + 1}", + "body": "Disposable task for Promotion Sync native metadata validation.", + "labels": labels, + "milestone": created_milestone_number, + }, + ) + created_issue_numbers.append(int(issue["number"])) + + repository = client.request_json("GET", f"{API_BASE}/repos/{args.repo}") + default_branch = str(repository["default_branch"]) + root_sha = branch_sha(client, args.repo, default_branch) + create_branch(client, args.repo, base_branch, root_sha) + created_branches.append(base_branch) + create_branch(client, args.repo, source_branch, root_sha) + created_branches.append(source_branch) + + related_prs: list[int] = [] + for index, impl_branch in enumerate(impl_branches): + pr_number = create_implementation( + client, + args.repo, + source_branch, + impl_branch, + created_issue_numbers[index], + milestone_title, + f"promotion-impl-{suffix}-{index + 1}.txt", + f"{suffix}-{index + 1}", + created_project_number, + owner, + ) + related_prs.append(pr_number) + created_pr_numbers.append(pr_number) + created_branches.append(impl_branch) + + promotion_pr = client.request_json( + "POST", + f"{API_BASE}/repos/{args.repo}/pulls", + { + "title": f"{QA_PROMOTION_PR_PREFIX}{suffix}", + "head": source_branch, + "base": base_branch, + "body": ( + "## Related PRs\n" + + "\n".join(f"- #{number}" for number in related_prs) + + "\n\n## Linked Issue\n" + + "\n".join(f"- Closes #{number}" for number in created_issue_numbers) + + f"\n\n## Milestone\n- {milestone_title}\n\n" + + "## Summary\n- Disposable aggregate promotion metadata validation.\n" + ), + }, + ) + promotion_number = int(promotion_pr["number"]) + created_pr_numbers.append(promotion_number) + + with tempfile.TemporaryDirectory() as temporary_directory: + config_path = Path(temporary_directory) / "project_setup.json" + config_path.write_text( + json.dumps( + { + "prAutomation": { + "relatedPrs": { + "enabled": True, + "bodySections": ["Related PRs"], + "includeBranchMatches": False, + "includeBodyReferences": True, + "inheritBodyReferences": True, + "fallbackDays": 0, + }, + "sync": { + "enabled": True, + "syncLabels": True, + "labelPrefixes": ["type:", "priority:", "test:"], + "syncMilestone": True, + "syncAssignees": True, + "syncProject": True, + "promotionPaths": [{"head": source_branch, "base": base_branch}], + "projectStatusField": "Status", + "projectStatus": { + "draft": "In progress", + "review": "In review", + "closed": "In progress", + "merged": "Done", + }, + }, + } + }, + indent=2, + ), + encoding="utf-8", + ) + + result = apply_routed_pr_sync( + client, + args.repo, + {"action": "opened", "pull_request": promotion_pr}, + config_path=str(config_path), + project_client=client, + project_number=created_project_number, + owner=owner, + dry_run=False, + ) + if result != 0: + raise RuntimeError(f"Promotion Sync returned {result}") + + pr_issue = client.get_issue(args.repo, promotion_number) + actual_labels = {str(label.get("name") or "") for label in pr_issue.get("labels", [])} + missing_labels = [label for label in labels if label not in actual_labels] + if missing_labels: + raise RuntimeError(f"Promotion PR labels were not synchronized: {', '.join(missing_labels)}") + print("promotion_pr_labels=passed") + + pr_milestone = pr_issue.get("milestone") or {} + if int(pr_milestone.get("number") or 0) != created_milestone_number: + raise RuntimeError("Promotion PR milestone was not synchronized") + print("promotion_pr_milestone=passed") + + author = str((promotion_pr.get("user") or {}).get("login") or "") + assignees = {str(item.get("login") or "") for item in pr_issue.get("assignees", [])} + if not author or author not in assignees: + raise RuntimeError("Promotion PR assignee union was not synchronized") + print("promotion_pr_assignees=passed") + + converged, visible_status = wait_for_project_pr_status( + client, + created_project_id, + args.repo, + promotion_number, + "In review", + ) + if not converged: + raise RuntimeError( + "Promotion PR Project v2 membership/status did not converge; " + f"last visible status: {visible_status or '(PR not visible)'}" + ) + print("promotion_pr_project_status_in_review=passed") + + merge = client.request_json( + "PUT", + f"{API_BASE}/repos/{args.repo}/pulls/{promotion_number}/merge", + {"merge_method": "merge"}, + ) + if not merge.get("merged"): + raise RuntimeError("Failed to merge disposable promotion PR") + merged_pr = client.request_json("GET", f"{API_BASE}/repos/{args.repo}/pulls/{promotion_number}") + result = apply_routed_pr_sync( + client, + args.repo, + {"action": "closed", "pull_request": merged_pr}, + config_path=str(config_path), + project_client=client, + project_number=created_project_number, + owner=owner, + dry_run=False, + ) + if result != 0: + raise RuntimeError(f"Merged Promotion Sync returned {result}") + + converged, visible_status = wait_for_project_pr_status( + client, + created_project_id, + args.repo, + promotion_number, + "Done", + ) + if not converged: + raise RuntimeError( + "Merged promotion PR Project v2 status did not converge to Done; " + f"last visible status: {visible_status or '(PR not visible)'}" + ) + print("promotion_pr_project_status_done=passed") + + comments = client.list_issue_comments(args.repo, promotion_number) + promotion_comment = next( + (comment for comment in comments if "" in (comment.get("body") or "")), + None, + ) + if not promotion_comment or "State: `merged`" not in (promotion_comment.get("body") or ""): + raise RuntimeError("Promotion Sync sticky comment did not converge to merged state") + print("promotion_sync_backlinks=passed") + + print("promotion_sync_structured_metadata=passed") + + except Exception as exc: + primary_error = exc + + for pr_number in reversed(created_pr_numbers): + try: + pr = client.request_json("GET", f"{API_BASE}/repos/{args.repo}/pulls/{pr_number}") + if str(pr.get("state") or "") == "open": + client.request_json("PATCH", f"{API_BASE}/repos/{args.repo}/pulls/{pr_number}", {"state": "closed"}) + except Exception as exc: + cleanup_errors.append(f"pull request #{pr_number}: {exc}") + + for issue_number in created_issue_numbers: + try: + client.update_issue(args.repo, issue_number, {"state": "closed"}) + except Exception as exc: + cleanup_errors.append(f"issue #{issue_number}: {exc}") + + for branch in reversed(created_branches): + try: + delete_branch(client, args.repo, branch) + except GitHubRequestError as exc: + if exc.status not in {404, 422}: + cleanup_errors.append(f"branch `{branch}`: {exc}") + except Exception as exc: + cleanup_errors.append(f"branch `{branch}`: {exc}") + + if created_project_id is not None: + try: + delete_project(client, created_project_id) + except Exception as exc: + cleanup_errors.append(f"project: {exc}") + + if created_milestone_number is not None: + try: + client.request_json("DELETE", f"{API_BASE}/repos/{args.repo}/milestones/{created_milestone_number}") + except Exception as exc: + cleanup_errors.append(f"milestone: {exc}") + + for label_name in labels: + try: + client.request_json("DELETE", f"{API_BASE}/repos/{args.repo}/labels/{urllib.parse.quote(label_name, safe='')}") + except Exception as exc: + cleanup_errors.append(f"label `{label_name}`: {exc}") + + if primary_error: + if cleanup_errors: + print("warning: cleanup also failed: " + "; ".join(cleanup_errors)) + raise primary_error + if cleanup_errors: + raise RuntimeError("Promotion Sync Q.A cleanup failed: " + "; ".join(cleanup_errors)) + + print("promotion_sync_cleanup=passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 6f3e695bfcfed7d71d87458ec6d0c05b56454bb4 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:31:25 -0300 Subject: [PATCH 10/28] docs: expand promotion sync architecture --- docs/repo/pr-governance-architecture.md | 336 +++++++++++------------- 1 file changed, 155 insertions(+), 181 deletions(-) diff --git a/docs/repo/pr-governance-architecture.md b/docs/repo/pr-governance-architecture.md index cf85c4e..6938d9d 100644 --- a/docs/repo/pr-governance-architecture.md +++ b/docs/repo/pr-governance-architecture.md @@ -4,118 +4,117 @@ This document is the execution contract for pull request governance in GitHub Project Automation (GPA). -The reference behavior comes from the proven Take Your Pills governance lane, but GPA generalizes the repository-specific release logic into configurable Related PR Detection. - -The invariant remains: +The core invariant is: > **Autofill -> Guardrails -> PR Sync** -The important addition is that Autofill and PR Sync are now routed by PR context: +PR state is always re-read between mutation and synchronization stages so later jobs do not consume stale webhook payloads. + +GPA has two PR contexts: -- implementation PRs use one canonical linked issue/task; -- promotion PRs use an aggregate set of related PRs. +- **Implementation PR** — one canonical linked issue/task; +- **Promotion PR** — an aggregate manifest of already merged implementation PRs. ## Architecture ```mermaid flowchart TD A[PR opened / synchronize / reopened / edited] --> G[PR metadata validation
Guardrails] - G --> R{PR context} - R -->|Implementation| IAF[Implementation Autofill
branch token -> issue/task] + R -->|Implementation| IAF[Implementation Autofill
branch/body -> issue/task] IAF --> ILIVE[Read live PR] ILIVE --> IV[Implementation validation] - R -->|Promotion path| PAF[Related PR Detection] - PAF --> PB[Branch-pattern matches] - PAF --> PE[Explicit body references] - PAF --> PI[Inherited references from prior promotion PRs] - PB --> PM[Aggregate and deduplicate] + R -->|Promotion| PAF[Related PR Detection] + PAF --> PB[Configured branch-pattern matches] + PAF --> PE[Explicit Related PR body references] + PAF --> PI[Inherited references from prior promotion] + PB --> PM[Union + deduplicate] PE --> PM PI --> PM PM --> PWRITE[Autofill Related PRs / Linked Issues / Milestones] PWRITE --> PLIVE[Read live promotion PR] - PLIVE --> PV[Promotion-context validation] + PLIVE --> PV[Promotion validation] IV --> V{Guardrails successful?} PV --> V - V -- No --> STOP[Stop governance lane
write/update validation feedback] + V -- No --> STOP[Stop governance lane] V -- Yes --> WR[workflow_run: Guardrails succeeded] WR --> S[PR Sync Router] + L[ready_for_review
converted_to_draft
closed] --> S S --> T{PR context} T -->|Implementation| IS[Implementation Sync] - IS --> ILIVE2[Refetch live PR] - ILIVE2 --> TASK[Resolve canonical linked issue/task] - TASK --> META[Sync labels / milestone / assignees] - META --> REL[Sync parent / sub-issue] - REL --> PROJ[Optional Project v2 status] + IS --> TASK[Resolve canonical task] + TASK --> IMETA[Sync PR labels / milestone / assignees] + IMETA --> REL[Sync parent / sub-issue] + REL --> IPROJ[Task Project v2 membership/status] T -->|Promotion| PS[Promotion Sync] - PS --> PLIVE2[Refetch live promotion PR] - PLIVE2 --> MANIFEST[Read aggregate Related PR manifest] - MANIFEST --> BACKLINK[Create/update promotion backlinks] - - L[ready_for_review
converted_to_draft
closed] --> S + PS --> MANIFEST[Read Related PR manifest] + MANIFEST --> AGG[Aggregate native metadata] + AGG --> PCONS[Consensus labels + milestone
union assignees] + PCONS --> PPR[Write promotion PR native metadata] + PPR --> PPROJ[Promotion PR Project v2 membership/status] + PPROJ --> BACKLINK[Create/update stage backlinks] PV -->|Q.A -> main and valid| QA[Live Q.A sandbox] - QA --> QAC[Clean sandbox resources and historical Q.A deployments] + QA --> QAR[Resource/idempotency test] + QAR --> QAI[Implementation PR Sync live test] + QAI --> QAP[Promotion PR native metadata + Project test] + QAP --> QAC[Cleanup sandbox resources + deployments] ``` ## Why the order matters -`pull_request_target` payloads are snapshots. If Autofill changes a PR body and a synchronization stage immediately consumes the original event payload, that stage can observe stale metadata. +`pull_request_target` payloads are snapshots. Autofill can update the real PR while the original event still contains the old body. GPA therefore uses this handoff: -The safe handoff is therefore: +1. Autofill mutates the real PR through the GitHub API. +2. Guardrails validates the **live PR**. +3. Successful Guardrails emits a separate `workflow_run`. +4. PR Sync refetches the **live PR** and applies synchronization. -1. Autofill mutates the real pull request through the GitHub API. -2. Guardrails validates the **live pull request**. -3. Successful Guardrails emits a separate `workflow_run` event. -4. PR Sync refetches the **live pull request** before synchronization. - -This is the same architectural lesson learned in Take Your Pills after stale PR state was observed between independent automation stages. +This is the same stale-state failure mode the reference Take Your Pills governance lane solved by serializing guardrails before hygiene. ## Implementation PR flow -Implementation PRs keep the existing deterministic model: - ```text -branch - -> explicit issue/task token or configured backlog mapping +implementation branch + -> branch/body resolution -> one canonical issue/task - -> Linked Issue + Milestone + -> Linked Issue + Milestone Autofill -> Guardrails - -> PR Sync + -> Implementation Sync + -> PR labels / milestone / assignees + -> task Project v2 lifecycle ``` -Closing references such as `Closes #123`, `Fixes #123`, or `Resolves #123` remain authoritative when already present. +Existing `Closes #N`, `Fixes #N`, and `Resolves #N` references remain authoritative. ## Promotion PR flow -Configured promotion paths are **routing rules**, not skip rules. +Configured promotion paths are routing rules, not skip rules. -The committed GPA paths are: +Committed GPA paths: ```text develop -> Q.A Q.A -> main ``` -A promotion PR receives an aggregate context instead of one implementation task. +A promotion represents an aggregate of implementation PRs and must never select an arbitrary first task as its source of truth. ### Related PR Detection -GPA combines two primary discovery mechanisms and one propagation mechanism: +The detector unions and deduplicates: -1. **Branch-pattern detection** — merged PRs entering the promotion source branch whose head branch matches configured regexes. -2. **Body references** — PR numbers explicitly listed in configured sections such as `## Related PRs`. -3. **Inherited references** — a later promotion can inherit Related PRs declared by an earlier promotion PR merged into its source branch. +1. merged PRs whose head branches match configured regexes; +2. explicit PR references from configured body sections such as `## Related PRs`; +3. inherited implementation PR references from prior promotion PRs. -The result is unioned and deduplicated. - -Default branch patterns are intentionally broad examples: +Default branch-pattern examples are deliberately broad: ```text ^feat/ @@ -131,143 +130,104 @@ Default branch patterns are intentionally broad examples: ^release/ ``` -They are configuration, not engine constants. A target repository can replace the entire list with its own convention, for example: - -```json -{ - "prAutomation": { - "relatedPrs": { - "branchPatterns": ["^work/", "^bug/"] - } - } -} -``` - -Explicit body references continue to work even when the referenced PR branch does not match a configured branch pattern. +A target repository can replace the entire list through `prAutomation.relatedPrs.branchPatterns`. ### Detection window -For `develop -> Q.A`, automatic discovery considers PRs merged into `develop` after the previous merged `develop -> Q.A` promotion. +For `develop -> Q.A`, automatic discovery starts after the previous merged `develop -> Q.A` promotion. -For `Q.A -> main`, the detector considers PRs merged into `Q.A` after the previous merged `Q.A -> main` promotion. When those source PRs are themselves promotions, their `Related PRs` sections are inherited so only already-promoted implementation work propagates toward `main`. +For `Q.A -> main`, GPA considers promotions merged into `Q.A` after the previous `Q.A -> main` promotion and inherits their constituent implementation PRs. Work that remains only in `develop` is therefore not attributed to `main`. -If no previous promotion exists, `fallbackDays` defines the bounded initial lookback. The committed default is seven days. Explicit body references are not dependent on the branch-pattern discovery window. +When no earlier promotion exists, `fallbackDays` bounds the initial lookup. Explicit body references do not depend on that window. -## Promotion Autofill contract +## Promotion Autofill -For promotion PRs, Related PR Detection may deterministically populate: +Promotion Autofill can deterministically populate: -- `## Related PRs` from detected source PRs; -- `## Linked Issue` from closing references contained in those related PRs; -- `## Milestone` from unique milestones present on those related PRs; -- `## Summary` only when the section is still a placeholder. +- `## Related PRs`; +- `## Linked Issue` from closing references in the constituent PRs; +- `## Milestone` from constituent PR milestone titles; +- `## Summary` only while that section is still a placeholder. -Human-authored summaries, risks, evidence, testing notes, and DoD decisions are preserved. +Human-authored summaries, evidence, risks, test notes, and DoD decisions are preserved. -## Promotion validation contract +## Promotion native metadata contract -Promotion Guardrails verify that: +Promotion Sync treats the **related PR objects** as the aggregate source of truth for GitHub-native PR fields. -- the PR is a configured promotion path; -- at least one merged PR appears in the configured Related PR section; -- referenced PRs are actually merged; -- automatically detected related PRs are not silently omitted. +### Labels -Promotion PRs therefore no longer mean "skip validation". They use a different validation contract. +Only configured managed families are synchronized, by default: -## PR Sync routing - -`.github/workflows/pr-sync.yml` invokes `project_setup.pr_sync_router`. +```text +type: +priority: +test: +``` -The router performs exactly one of two modes: +Each family uses **consensus**: ```text -implementation PR -> project_setup.pr_sync -promotion PR -> project_setup.related_prs promotion sync +#101 priority:high +#102 priority:high +#103 priority:high + -> promotion priority:high ``` -Implementation Sync continues to own task-derived metadata, parent/sub-issue linkage, and optional Project v2 lifecycle synchronization. +A disagreement or missing value does not cause GPA to select an arbitrary label. The managed family is omitted from the promotion PR and the conflict is reported in the Promotion Sync sticky comment. -Promotion Sync owns the aggregate promotion manifest and idempotent backlinks from each related PR to the promotion PR. It does not copy metadata from an arbitrary first issue/task. +Unmanaged/manual labels are preserved. -## Workflow responsibilities +### Milestone -### `.github/workflows/pr-metadata.yml` — Guardrails +Milestone is single-valued and also requires consensus across all related PRs. A unanimous milestone is applied to the promotion PR. Missing/disagreeing milestones clear the synchronized promotion milestone instead of selecting one arbitrarily. -Execution order: +### Assignees -1. Checkout trusted base commit. -2. Run promotion Related PR Autofill when applicable. -3. Run implementation Autofill when applicable. -4. Validate the live implementation PR contract. -5. Validate the live promotion context when applicable. -6. For a valid `Q.A -> main` PR, run live Q.A and cleanup. +Assignees are naturally multi-valued. Promotion Sync applies the deduplicated union of assignees from all related PRs. -### `.github/workflows/pr-sync.yml` — Sync/Hygiene +### Project v2 -Normal synchronization runs from: +Implementation Sync keeps its existing model: the **linked issue/task** is the Project v2 work item. -```text -workflow_run(PR metadata validation = success) -``` +Promotion Sync additionally treats the **promotion PR itself** as a Project v2 item because a promotion has an independent review/release lifecycle. -Direct lifecycle events remain: - -- `ready_for_review`; -- `converted_to_draft`; -- `closed`. - -The router refetches live PR state through the implementation or promotion path as appropriate. - -## Configuration - -The related-PR contract lives under `prAutomation.relatedPrs`: - -```json -{ - "prAutomation": { - "relatedPrs": { - "enabled": true, - "branchPatterns": [ - "^feat/", - "^fix/", - "^docs/", - "^refactor/", - "^test/", - "^hotfix/", - "^phase/", - "^task/", - "^chore/", - "^ci/", - "^release/" - ], - "bodySections": ["Related PRs", "Related Pull Requests"], - "includeBranchMatches": true, - "includeBodyReferences": true, - "inheritBodyReferences": true, - "fallbackDays": 7 - }, - "sync": { - "promotionPaths": [ - {"head": "develop", "base": "Q.A"}, - {"head": "Q.A", "base": "main"} - ] - } - } -} -``` +Default lifecycle mapping: -There is no promotion skip switch in the committed configuration. `promotionPaths` selects promotion mode. +| Promotion PR state | Project Status | +| --- | --- | +| Draft | `In progress` | +| Open / review | `In review` | +| Closed without merge | `In progress` | +| Merged | `Done` | -## Authentication boundary +This is what makes the native `Projects` sidebar field meaningful for promotion PRs when `PROJECT_SETUP_PROJECT_NUMBER` and `PROJECT_SETUP_PAT` are configured. + +## Promotion validation -Repository-scoped PR/issue operations use the built-in Actions token: +Guardrails verify that: + +- the head/base pair is a configured promotion path; +- at least one merged PR is listed in the configured Related PR section; +- every referenced PR is actually merged; +- auto-detected related PRs are not silently omitted. + +Promotion PRs use their own contract rather than bypassing validation. + +## PR Sync routing + +`.github/workflows/pr-sync.yml` invokes `project_setup.pr_sync_router`. ```text -github.token +implementation PR -> project_setup.pr_sync +promotion PR -> project_setup.promotion_sync ``` -The relevant workflows request: +`project_setup.related_prs` owns discovery, Autofill, and validation. `project_setup.promotion_sync` owns aggregate native metadata, promotion Project membership/status, and backlinks. + +## Authentication boundary + +Repository-scoped mutations use the built-in Actions token: ```yaml permissions: @@ -276,41 +236,55 @@ permissions: pull-requests: write ``` -`PROJECT_SETUP_PAT` remains optional and separate for GitHub Projects v2 operations. Related PR Detection and Promotion Sync do not require the Project PAT. +This covers PR labels, milestone, assignees, comments, and backlinks. -## Security invariants +GitHub Projects v2 uses the optional separate credential: + +```text +PROJECT_SETUP_PAT +``` -- Privileged automation executes trusted base/default-branch code. -- PR head code is never executed with write credentials by the governance lane. -- Fork PRs remain excluded from privileged mutations. -- `persist-credentials` is disabled on trusted checkouts. -- Guardrails must succeed before normal PR Sync runs. -- PR Sync must consume live PR state after Guardrails. -- Promotion paths route to aggregate synchronization instead of implementation-task mutation. -- Explicit Related PR references are verified as actual merged PRs. -- Project v2 credentials remain isolated from ordinary repository mutations. +and `PROJECT_SETUP_PROJECT_NUMBER` identifies the target Project. If either is absent, native PR metadata still synchronizes and Project synchronization is reported as skipped. -## Regression contract +## Live regression contract -A new implementation PR must converge without a second event: +The protected `Q.A -> main` lane must prove all of the following against the disposable sandbox: ```text -Implementation Autofill - -> validate live PR - -> workflow_run - -> refetch live PR - -> Implementation Sync +Implementation PR Sync + -> labels present on PR + -> milestone present on PR + -> assignee present on PR + -> linked task in Project v2 / In review + -> non-default base branch works + +Promotion Sync + -> two real constituent PRs merged into source branch + -> consensus labels present on promotion PR + -> consensus milestone present on promotion PR + -> assignee union present on promotion PR + -> promotion PR itself in Project v2 / In review + -> merged promotion PR Project status -> Done + -> stage backlinks converge to merged + +Cleanup + -> disposable PRs/issues closed + -> branches removed + -> Project removed + -> milestone removed + -> labels removed + -> historical Q.A deployments cleaned ``` -A promotion PR must converge without manually constructing a fake single-task link: +A sticky comment alone is never sufficient evidence that structured synchronization worked. -```text -Related PR Detection - -> aggregate body Autofill - -> validate live promotion context - -> workflow_run - -> Promotion Sync - -> backlinks -``` +## Security invariants -Both branch-pattern detection and explicit body references are first-class inputs, and the branch patterns must remain replaceable through configuration. +- privileged workflows execute trusted base/default-branch code; +- untrusted PR head code is never executed with write credentials; +- fork PRs are excluded from privileged mutations; +- privileged checkouts use `persist-credentials: false`; +- Guardrails success is required before normal synchronization; +- live PR state is refetched after Autofill; +- conflicts in single-valued promotion metadata are fail-safe rather than guessed; +- Project v2 credentials remain isolated from repository-scoped mutations. From 4615ce9f8549c8fd6ebfa24fc4b06c849997f4c7 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:31:56 -0300 Subject: [PATCH 11/28] docs: document promotion metadata sync in PT-BR --- docs/repo/pr-governance-architecture.pt-BR.md | 340 ++++++++---------- 1 file changed, 157 insertions(+), 183 deletions(-) diff --git a/docs/repo/pr-governance-architecture.pt-BR.md b/docs/repo/pr-governance-architecture.pt-BR.md index 661c921..5bd87a1 100644 --- a/docs/repo/pr-governance-architecture.pt-BR.md +++ b/docs/repo/pr-governance-architecture.pt-BR.md @@ -4,118 +4,117 @@ Este documento é o contrato de execução da governança de pull requests no GitHub Project Automation (GPA). -O comportamento de referência vem do fluxo comprovado no Take Your Pills, mas o GPA generaliza a lógica específica de release para um mecanismo configurável de **Related PR Detection**. - -A regra principal continua sendo: +A regra central é: > **Autofill -> Guardrails -> PR Sync** -A diferença é que Autofill e PR Sync agora são roteados conforme o contexto: +O estado do PR sempre é lido novamente entre etapas de mutação e sincronização, evitando consumo de payload antigo do webhook. + +O GPA possui dois contextos: -- PRs de implementação usam uma issue/task canônica; -- PRs de promoção usam um conjunto agregado de PRs relacionados. +- **PR de implementação** — uma issue/task canônica; +- **PR de promoção** — um manifesto agregado de PRs de implementação já mergeados. ## Arquitetura ```mermaid flowchart TD A[PR opened / synchronize / reopened / edited] --> G[PR metadata validation
Guardrails] - G --> R{Contexto do PR} - R -->|Implementação| IAF[Implementation Autofill
branch token -> issue/task] + R -->|Implementação| IAF[Implementation Autofill
branch/body -> issue/task] IAF --> ILIVE[Ler PR vivo] - ILIVE --> IV[Validar contrato de implementação] + ILIVE --> IV[Validação de implementação] - R -->|Promotion path| PAF[Related PR Detection] - PAF --> PB[Matches por padrão de branch] + R -->|Promoção| PAF[Related PR Detection] + PAF --> PB[Matches pelos patterns configurados] PAF --> PE[Referências explícitas no body] - PAF --> PI[Referências herdadas de promoções anteriores] - PB --> PM[Agregar e deduplicar] + PAF --> PI[Referências herdadas da promoção anterior] + PB --> PM[Unir + deduplicar] PE --> PM PI --> PM PM --> PWRITE[Autofill Related PRs / Linked Issues / Milestones] PWRITE --> PLIVE[Ler PR de promoção vivo] - PLIVE --> PV[Validar contexto de promoção] + PLIVE --> PV[Validação de promoção] IV --> V{Guardrails passou?} PV --> V - V -- Não --> STOP[Interromper governança
criar/atualizar feedback] - V -- Sim --> WR[workflow_run: Guardrails com sucesso] + V -- Não --> STOP[Interromper governança] + V -- Sim --> WR[workflow_run: Guardrails passou] WR --> S[PR Sync Router] + L[ready_for_review
converted_to_draft
closed] --> S S --> T{Contexto do PR} T -->|Implementação| IS[Implementation Sync] - IS --> ILIVE2[Buscar novamente PR vivo] - ILIVE2 --> TASK[Resolver issue/task canônica] - TASK --> META[Sync labels / milestone / assignees] - META --> REL[Sync relação pai / sub-issue] - REL --> PROJ[Status opcional no Project v2] + IS --> TASK[Resolver task canônica] + TASK --> IMETA[Sync labels / milestone / assignees do PR] + IMETA --> REL[Sync pai / sub-issue] + REL --> IPROJ[Task no Project v2 + Status] T -->|Promoção| PS[Promotion Sync] - PS --> PLIVE2[Buscar novamente PR de promoção vivo] - PLIVE2 --> MANIFEST[Ler manifesto agregado de Related PRs] - MANIFEST --> BACKLINK[Criar/atualizar backlinks de promoção] - - L[ready_for_review
converted_to_draft
closed] --> S - - PV -->|Q.A -> main e válido| QA[Sandbox Q.A live] - QA --> QAC[Limpar recursos do sandbox e deployments Q.A históricos] + PS --> MANIFEST[Ler manifesto Related PRs] + MANIFEST --> AGG[Agregar metadata nativa] + AGG --> PCONS[Consenso labels + milestone
união de assignees] + PCONS --> PPR[Escrever metadata nativa na promoção] + PPR --> PPROJ[PR de promoção no Project v2 + Status] + PPROJ --> BACKLINK[Criar/atualizar backlinks por estágio] + + PV -->|Q.A -> main válido| QA[Sandbox Q.A live] + QA --> QAR[Teste de recursos/idempotência] + QAR --> QAI[Teste live do Implementation PR Sync] + QAI --> QAP[Teste da promoção: metadata nativa + Project] + QAP --> QAC[Cleanup do sandbox + deployments] ``` ## Por que a ordem importa -Payloads de `pull_request_target` são snapshots. Se o Autofill altera o body do PR e uma etapa seguinte usa o payload original, ela pode consumir metadata antiga. - -A passagem segura é: +Payloads de `pull_request_target` são snapshots. O Autofill pode alterar o PR real enquanto o evento original continua com o body anterior. Por isso o GPA usa a seguinte passagem: -1. Autofill altera o pull request real pela API do GitHub. +1. Autofill altera o PR real pela API do GitHub. 2. Guardrails valida o **PR vivo**. -3. O sucesso do Guardrails gera um novo evento `workflow_run`. +3. Guardrails bem-sucedido gera um `workflow_run` separado. 4. PR Sync busca novamente o **PR vivo** antes de sincronizar. -Esse é o mesmo aprendizado arquitetural usado no Take Your Pills após o problema de stale state entre automações independentes. - -## Fluxo de PR de implementação +Esse é o mesmo tipo de stale state resolvido pelo fluxo de governança do Take Your Pills ao serializar guardrails antes de hygiene. -PRs de implementação preservam o modelo determinístico: +## Fluxo de implementação ```text -branch - -> token explícito de issue/task ou mapeamento configurado +branch de implementação + -> resolução por branch/body -> uma issue/task canônica - -> Linked Issue + Milestone + -> Autofill Linked Issue + Milestone -> Guardrails - -> PR Sync + -> Implementation Sync + -> labels / milestone / assignees no PR + -> lifecycle da task no Project v2 ``` -Referências já informadas como `Closes #123`, `Fixes #123` ou `Resolves #123` continuam sendo autoritativas. +`Closes #N`, `Fixes #N` e `Resolves #N` já existentes continuam autoritativos. -## Fluxo de PR de promoção +## Fluxo de promoção -`promotionPaths` passam a ser **regras de roteamento**, não regras de skip. +`promotionPaths` são regras de roteamento, não regras de skip. -Os caminhos configurados no GPA são: +Caminhos versionados: ```text develop -> Q.A Q.A -> main ``` -Uma promoção recebe um contexto agregado em vez de uma falsa task única. +Uma promoção representa um agregado de PRs de implementação e nunca deve escolher uma “primeira task” arbitrária como fonte de verdade. ### Related PR Detection -O GPA combina dois mecanismos primários de descoberta e um de propagação: - -1. **Padrão de branch** — PRs mergeados na branch-fonte da promoção cujo head corresponde a um regex configurado. -2. **Referência no body** — números de PR explicitamente listados em seções configuradas, como `## Related PRs`. -3. **Referência herdada** — uma promoção posterior pode herdar os Related PRs declarados por uma promoção anterior que foi mergeada na sua branch-fonte. +O detector une e deduplica: -Os resultados são unidos e deduplicados. +1. PRs mergeados cujas branches de origem correspondem aos regex configurados; +2. referências explícitas em seções como `## Related PRs`; +3. referências de implementação herdadas de uma promoção anterior. -Os padrões default são propositalmente amplos como exemplos: +Patterns default propositalmente amplos: ```text ^feat/ @@ -131,143 +130,104 @@ Os padrões default são propositalmente amplos como exemplos: ^release/ ``` -Eles são configuração, não uma limitação do engine. Um repositório pode substituir a lista inteira, por exemplo: - -```json -{ - "prAutomation": { - "relatedPrs": { - "branchPatterns": ["^work/", "^bug/"] - } - } -} -``` - -Referências explícitas no body continuam funcionando mesmo quando a branch do PR referenciado não corresponde a nenhum pattern configurado. +O repositório de destino pode substituir a lista inteira via `prAutomation.relatedPrs.branchPatterns`. ### Janela de detecção -Para `develop -> Q.A`, a autodetecção considera PRs mergeados em `develop` depois da última promoção `develop -> Q.A` mergeada. +Em `develop -> Q.A`, a autodetecção começa depois da última promoção `develop -> Q.A` mergeada. -Para `Q.A -> main`, a autodetecção considera PRs mergeados em `Q.A` depois da última promoção `Q.A -> main`. Quando esses PRs-fonte são promoções, suas seções `Related PRs` são herdadas, garantindo que apenas trabalho que já chegou em Q.A seja propagado para `main`. +Em `Q.A -> main`, o GPA considera promoções mergeadas em `Q.A` depois da última `Q.A -> main` e herda os PRs de implementação dessas promoções. Trabalho que ficou somente em `develop` não é atribuído ao `main`. -Se ainda não existir promoção anterior, `fallbackDays` define uma janela inicial limitada. O default versionado é sete dias. Referências explícitas no body não dependem dessa janela automática. +Sem promoção anterior, `fallbackDays` limita a primeira busca. Referências explícitas no body não dependem dessa janela. -## Contrato do Promotion Autofill +## Promotion Autofill -Em PRs de promoção, o detector pode preencher deterministicamente: +O Autofill de promoção pode preencher deterministicamente: -- `## Related PRs` com os PRs detectados; -- `## Linked Issue` com as closing references existentes nos PRs relacionados; -- `## Milestone` com os milestones únicos desses PRs; +- `## Related PRs`; +- `## Linked Issue` a partir das closing references dos PRs constituintes; +- `## Milestone` com os milestones dos PRs constituintes; - `## Summary` somente enquanto a seção ainda for placeholder. -Resumo escrito por humano, riscos, evidências, instruções de teste e decisões de DoD são preservados. +Resumo, evidências, riscos, testes e DoD escritos por humano são preservados. -## Contrato de validação de promoção +## Contrato de metadata nativa da promoção -Guardrails de promoção verificam que: +Promotion Sync usa os **objetos dos Related PRs** como fonte agregada para os campos nativos do PR de promoção. -- o par head/base é um `promotionPath` configurado; -- existe pelo menos um PR mergeado na seção Related PR configurada; -- os PRs referenciados realmente estão mergeados; -- PRs relacionados autodetectados não foram silenciosamente omitidos. +### Labels -Portanto, promotion PR não significa mais “pular validação”. Ele usa um contrato de validação próprio. +Somente famílias gerenciadas são sincronizadas. Default: -## Roteamento do PR Sync - -`.github/workflows/pr-sync.yml` executa `project_setup.pr_sync_router`. +```text +type: +priority: +test: +``` -O router escolhe exatamente um modo: +Cada família exige **consenso**: ```text -PR de implementação -> project_setup.pr_sync -PR de promoção -> project_setup.related_prs Promotion Sync +#101 priority:high +#102 priority:high +#103 priority:high + -> promoção priority:high ``` -Implementation Sync continua responsável por metadata derivada da task, vínculo pai/sub-issue e lifecycle opcional no Project v2. +Se houver divergência ou ausência, o GPA não escolhe um valor arbitrário. A família gerenciada é omitida do PR de promoção e o conflito aparece no comentário sticky do Promotion Sync. -Promotion Sync é responsável pelo manifesto agregado e pelos backlinks idempotentes de cada PR relacionado para a promoção. Ele não copia metadata de uma “primeira issue” arbitrária. +Labels manuais/não gerenciadas são preservadas. -## Responsabilidades dos workflows +### Milestone -### `.github/workflows/pr-metadata.yml` — Guardrails +Milestone também é single-value e exige consenso. Um único milestone unânime é aplicado à promoção. Ausência/divergência remove o milestone sincronizado em vez de escolher um aleatoriamente. -Ordem interna: +### Assignees -1. Checkout da base confiável. -2. Executar Related PR Autofill quando for promoção. -3. Executar Implementation Autofill quando for implementação. -4. Validar o contrato do PR vivo de implementação. -5. Validar o contexto vivo de promoção quando aplicável. -6. Para `Q.A -> main` válido, executar Q.A live e cleanup. +Assignees são multi-value. Promotion Sync aplica a união deduplicada dos assignees dos Related PRs. -### `.github/workflows/pr-sync.yml` — Sync/Hygiene +### Project v2 -Sincronização normal é acionada por: +Implementation Sync mantém o comportamento atual: a **issue/task vinculada** é o item de trabalho no Project v2. -```text -workflow_run(PR metadata validation = success) -``` +Promotion Sync passa a adicionar também o **próprio PR de promoção** ao Project v2, porque a promoção possui lifecycle independente de review/release. -Eventos diretos de lifecycle continuam: - -- `ready_for_review`; -- `converted_to_draft`; -- `closed`. - -O router consome estado vivo pelo caminho de implementação ou promoção conforme necessário. - -## Configuração - -O contrato de Related PR fica em `prAutomation.relatedPrs`: - -```json -{ - "prAutomation": { - "relatedPrs": { - "enabled": true, - "branchPatterns": [ - "^feat/", - "^fix/", - "^docs/", - "^refactor/", - "^test/", - "^hotfix/", - "^phase/", - "^task/", - "^chore/", - "^ci/", - "^release/" - ], - "bodySections": ["Related PRs", "Related Pull Requests"], - "includeBranchMatches": true, - "includeBodyReferences": true, - "inheritBodyReferences": true, - "fallbackDays": 7 - }, - "sync": { - "promotionPaths": [ - {"head": "develop", "base": "Q.A"}, - {"head": "Q.A", "base": "main"} - ] - } - } -} -``` +Mapeamento default: -Não existe mais um switch de skip de promoção na configuração versionada. `promotionPaths` seleciona o modo de promoção. +| Estado da promoção | Project Status | +| --- | --- | +| Draft | `In progress` | +| Open / review | `In review` | +| Fechado sem merge | `In progress` | +| Mergeado | `Done` | -## Fronteira de autenticação +Isso é o que torna o campo nativo `Projects` do sidebar significativo para PRs de promoção quando `PROJECT_SETUP_PROJECT_NUMBER` e `PROJECT_SETUP_PAT` estão configurados. + +## Validação de promoção -Operações de PR/issue dentro do repositório usam: +Guardrails verifica que: + +- head/base formam um `promotionPath` configurado; +- existe pelo menos um PR mergeado na seção Related PR; +- todos os PRs referenciados realmente foram mergeados; +- Related PRs autodetectados não foram omitidos silenciosamente. + +Promoção possui contrato próprio; não é bypass de validação. + +## Roteamento do PR Sync + +`.github/workflows/pr-sync.yml` executa `project_setup.pr_sync_router`. ```text -github.token +PR de implementação -> project_setup.pr_sync +PR de promoção -> project_setup.promotion_sync ``` -Os workflows relevantes solicitam: +`project_setup.related_prs` cuida de detecção, Autofill e validação. `project_setup.promotion_sync` cuida de metadata nativa agregada, membership/status da promoção no Project e backlinks. + +## Fronteira de autenticação + +Mutações dentro do repositório usam o token nativo do Actions: ```yaml permissions: @@ -276,41 +236,55 @@ permissions: pull-requests: write ``` -`PROJECT_SETUP_PAT` continua opcional e separado exclusivamente para GitHub Projects v2. Related PR Detection e Promotion Sync não dependem desse PAT. +Isso cobre labels, milestone, assignees, comentários e backlinks. -## Invariantes de segurança +Projects v2 usa a credencial separada opcional: + +```text +PROJECT_SETUP_PAT +``` -- Automação privilegiada executa código confiável da base/default branch. -- Código do head não é executado com credenciais de escrita pelo fluxo de governança. -- Forks permanecem excluídos das mutações privilegiadas. -- `persist-credentials` permanece desabilitado. -- Guardrails precisa passar antes do PR Sync normal. -- PR Sync precisa consumir estado vivo depois do Guardrails. -- Promotion paths são roteados para sincronização agregada em vez de mutação de task de implementação. -- Related PRs explícitos são verificados como PRs realmente mergeados. -- Credenciais de Project v2 permanecem isoladas. +`PROJECT_SETUP_PROJECT_NUMBER` identifica o Project. Sem um deles, a metadata nativa do PR continua funcionando e apenas a sincronização do Project é reportada como skipped. -## Contrato de regressão +## Contrato de regressão live -Um PR de implementação novo deve convergir sem exigir um segundo evento: +O lane protegido `Q.A -> main` precisa provar no sandbox descartável: ```text -Implementation Autofill - -> validar PR vivo - -> workflow_run - -> buscar PR vivo - -> Implementation Sync +Implementation PR Sync + -> labels presentes no PR + -> milestone presente no PR + -> assignee presente no PR + -> task vinculada no Project v2 / In review + -> base não-default funciona + +Promotion Sync + -> dois PRs constituintes reais mergeados na branch-fonte + -> labels por consenso no PR de promoção + -> milestone por consenso no PR de promoção + -> união de assignees no PR de promoção + -> próprio PR de promoção no Project v2 / In review + -> após merge, Status do PR de promoção -> Done + -> backlinks convergem para merged + +Cleanup + -> PRs/issues descartáveis fechados + -> branches removidas + -> Project removido + -> milestone removido + -> labels removidas + -> deployments Q.A históricos limpos ``` -Um PR de promoção deve convergir sem inventar uma task única: +Um comentário sticky isolado nunca é evidência suficiente de que a sincronização estruturada funcionou. -```text -Related PR Detection - -> Autofill agregado do body - -> validar contexto vivo de promoção - -> workflow_run - -> Promotion Sync - -> backlinks -``` +## Invariantes de segurança -Tanto pattern de branch quanto referência explícita no body são entradas de primeira classe, e os patterns precisam permanecer substituíveis por configuração. +- workflows privilegiados executam código confiável da base/default branch; +- código não confiável do head não roda com credenciais de escrita; +- forks são excluídos das mutações privilegiadas; +- checkouts privilegiados usam `persist-credentials: false`; +- Guardrails precisa passar antes do Sync normal; +- o PR vivo é lido novamente depois do Autofill; +- conflitos de metadata single-value são tratados de forma fail-safe, sem chute; +- credenciais de Project v2 continuam isoladas das mutações normais do repositório. From b1a4ba1e2ba37a602eecd8a3185f1c43f28efdf5 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:32:30 -0300 Subject: [PATCH 12/28] docs: document promotion native metadata sync --- docs/repo/pr-sync.md | 170 ++++++++++++++++++++----------------------- 1 file changed, 79 insertions(+), 91 deletions(-) diff --git a/docs/repo/pr-sync.md b/docs/repo/pr-sync.md index 0167bd0..58a4da3 100644 --- a/docs/repo/pr-sync.md +++ b/docs/repo/pr-sync.md @@ -4,32 +4,36 @@ **Implemented.** -PR Sync is GPA's post-Guardrails synchronization lane. It now supports two distinct contexts: +PR Sync is GPA's post-Guardrails synchronization lane. The public workflow is `.github/workflows/pr-sync.yml`; `project_setup.pr_sync_router` selects one of two modes: -- **Implementation Sync** — one canonical linked issue/task; -- **Promotion Sync** — an aggregate manifest of related pull requests. +```text +Implementation PR -> project_setup.pr_sync +Promotion PR -> project_setup.promotion_sync +``` -The public workflow remains `.github/workflows/pr-sync.yml`, while `project_setup/pr_sync_router.py` chooses the correct synchronization mode. +Normal synchronization runs only after successful Guardrails through `workflow_run`, and each path refetches live PR state instead of relying on an Autofill-mutated webhook payload. ## Pipeline ```text PR event -> Autofill - -> Guardrails + -> live Guardrails -> workflow_run on success -> PR Sync Router -> Implementation Sync -> Promotion Sync ``` -PR Sync never relies on an Autofill-mutated copy of the original webhook payload. Normal post-Guardrails execution refetches the live pull request. +Lifecycle events that need a direct transition also enter the router: -## Implementation Sync +- `ready_for_review`; +- `converted_to_draft`; +- `closed`. -Implementation PRs use `project_setup/pr_sync.py`. +## Implementation Sync -The linked issue/task is identified by a closing reference: +Implementation PRs identify one canonical linked issue/task with: ```text Closes #123 @@ -37,30 +41,19 @@ Fixes #123 Resolves #123 ``` -The linked task can provide: - -- configured label families; -- milestone; -- assignees; -- parent/sub-issue relationship; -- optional Project v2 membership/status. +The task can drive: -If the task has no assignee and `assignAuthorWhenTaskUnassigned` is enabled, the PR author can be assigned to the task and synchronized to the PR. +- configured PR label families; +- PR milestone; +- PR assignees; +- parent/sub-issue linkage; +- task Project v2 membership/status. -### Default Project lifecycle - -| Pull request state | Project status target | -| --- | --- | -| Draft / converted to draft | `In progress` | -| Ready for review / validated open PR | `In review` | -| Closed without merge | `In progress` | -| Merged | `Done` | - -Project v2 operations remain optional and use `PROJECT_SETUP_PAT`. Ordinary PR/issue mutations use the built-in Actions token. +When the task has no assignee and `assignAuthorWhenTaskUnassigned` is enabled, the PR author can be assigned to both the task and PR. ## Promotion Sync -Promotion paths are not skipped anymore at the workflow level. They route to aggregate Promotion Sync. +Promotion paths are not skipped. They route to aggregate synchronization. Committed paths: @@ -69,37 +62,57 @@ develop -> Q.A Q.A -> main ``` -Promotion Sync does **not** select an arbitrary first issue/task. It reads the promotion PR's `## Related PRs` manifest and maintains idempotent backlinks from those related PRs to the current promotion. +The promotion's `## Related PRs` manifest is authoritative after Guardrails. Promotion Sync never selects the first linked issue as a fake canonical task. + +It performs four responsibilities: + +1. aggregate GitHub-native metadata from all constituent PRs; +2. apply that metadata to the promotion PR; +3. add/update the promotion PR itself in Project v2 when configured; +4. maintain stage-specific backlinks on constituent PRs. + +### Native metadata aggregation -For example: +Configured managed label families use consensus. Defaults: ```text -feature/fix PRs -> develop - | - v -develop -> Q.A promotion - | - v -Promotion Sync backlinks related implementation PRs to Q.A - | - v -Q.A -> main promotion - | - v -Promotion Sync records the main-stage linkage +type: +priority: +test: ``` -Related PR discovery and promotion-body Autofill happen before Guardrails in `project_setup.related_prs`; see `pr-governance-architecture.md`. +If every related PR has the same single value for a family, the promotion receives that label. Missing/conflicting values result in no managed label for that family; the sticky Promotion Sync comment reports the conflict. Unmanaged labels already on the promotion are preserved. + +Milestone also requires unanimous agreement. A conflict or missing milestone does not cause GPA to pick one arbitrarily. + +Assignees are multi-valued and therefore use the deduplicated union of all related PR assignees. + +### Promotion Project v2 membership + +Implementation Sync keeps tasks as Project work items. Promotion Sync additionally adds the **promotion PR itself** to the configured Project so the promotion/release lifecycle can be represented and the PR's native `Projects` sidebar can show membership. + +Default lifecycle: + +| PR state | Project status | +| --- | --- | +| Draft | `In progress` | +| Open / review | `In review` | +| Closed without merge | `In progress` | +| Merged | `Done` | + +Project operations require both `PROJECT_SETUP_PAT` and `PROJECT_SETUP_PROJECT_NUMBER`. Repository-scoped metadata still synchronizes if Project configuration is absent. ## Related PR Detection +`project_setup.related_prs` owns promotion discovery, Autofill, and validation. + The detector unions and deduplicates: -1. merged PRs whose head branch matches configured branch regexes; -2. PR references explicitly provided in configured body sections; -3. references inherited from earlier promotion PRs merged into the current promotion source branch. +1. merged PRs whose head branch matches configured regexes; +2. explicit references in configured body sections; +3. references inherited from earlier promotion PRs. -Default branch patterns are deliberately broad configuration examples: +Default patterns are broad examples: ```text ^feat/ @@ -115,7 +128,7 @@ Default branch patterns are deliberately broad configuration examples: ^release/ ``` -A repository can replace the entire list. Explicit body references remain valid even when a referenced PR does not match those patterns. +Repositories may replace the entire list in `project_setup.json`. ## Configuration @@ -168,19 +181,7 @@ A repository can replace the entire list. Explicit body references remain valid } ``` -`promotionPaths` are routing rules. The committed configuration no longer exposes `skipPromotionPullRequests`. - -## Event model - -Normal synchronization runs from `workflow_run` after `PR metadata validation` succeeds. - -Lifecycle events that need a direct transition also enter the router through `pull_request_target`: - -- `ready_for_review`; -- `converted_to_draft`; -- `closed`. - -Both paths execute trusted base-branch automation. Fork PRs are excluded from privileged mutations. +The same `syncLabels`, `syncMilestone`, `syncAssignees`, and `syncProject` flags apply to implementation and promotion modes; promotion mode changes the aggregation semantics, not the configuration surface. ## Authentication and permissions @@ -193,42 +194,29 @@ permissions: pull-requests: write ``` -`PROJECT_SETUP_PAT` is reserved for optional GitHub Projects v2 operations. Promotion detection, promotion Autofill, promotion validation, and promotion backlinks require only repository-scoped permissions. +`PROJECT_SETUP_PAT` is reserved for optional GitHub Projects v2 operations. ## Idempotency -Implementation Sync converges without duplicating labels, assignees, Project membership, parent/sub-issue links, or its marked status comment. - -Promotion Sync uses stage-specific marked backlink comments so repeated runs update existing promotion linkage instead of adding duplicates. +Implementation Sync converges without duplicating labels, assignees, Project membership, parent relationships, or its marked status comment. -## Security model +Promotion Sync: -- trusted base/default-branch automation only; -- no untrusted head code runs with write credentials; -- `persist-credentials: false` on privileged checkouts; -- Guardrails success is required before normal synchronization; -- live PR state is refetched between state-changing and state-consuming stages; -- related PR references are validated as real merged PRs before promotion; -- Project v2 credentials stay isolated from ordinary repository mutations. - -## Installation - -The installer distributes `.github/workflows/pr-sync.yml`, while Python modules under `project_setup/*.py` include: - -```text -pr_sync.py -pr_sync_router.py -related_prs.py -``` +- replaces only managed label families while preserving unmanaged labels; +- converges milestone to the aggregate consensus; +- adds missing assignees without duplicates; +- reuses existing Project membership when visible; +- updates lifecycle Status on the same Project item; +- updates stage-specific backlink/sticky comments rather than appending duplicates. -Existing target files remain subject to the installer's preserve-by-default behavior. +## Live validation -## Validation +The protected `Q.A -> main` live lane validates three increasingly complete layers: -Coverage is split between: +1. resource create/update/idempotency/cleanup; +2. Implementation PR Sync on a non-default base branch; +3. Promotion Sync with real merged constituent PRs. -- `tests/test_pr_sync.py` — implementation synchronization and workflow safety; -- `tests/test_pr_sync_autofill.py` — implementation Autofill ordering; -- `tests/test_related_prs.py` — branch/body related-PR detection, configurable patterns, promotion-body aggregation, and router dispatch. +The promotion smoke fails unless the **promotion PR object itself** has native labels, milestone, assignees, Project v2 membership/status, and correct merged lifecycle convergence. A successful sticky comment alone is not accepted as evidence. -Live Project v2 and Q.A integration behavior remain sandbox concerns rather than destructive source-repository tests. +See `pr-governance-architecture.md` for the Mermaid execution model. From 7b6e8b381015c646d445a7be0faa4ba97be33b4c Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:32:54 -0300 Subject: [PATCH 13/28] docs: document promotion native metadata sync in PT-BR --- docs/repo/pr-sync.pt-BR.md | 172 +++++++++++++++++-------------------- 1 file changed, 80 insertions(+), 92 deletions(-) diff --git a/docs/repo/pr-sync.pt-BR.md b/docs/repo/pr-sync.pt-BR.md index fdec47a..30f8011 100644 --- a/docs/repo/pr-sync.pt-BR.md +++ b/docs/repo/pr-sync.pt-BR.md @@ -4,32 +4,36 @@ **Implementado.** -PR Sync é o fluxo de sincronização executado após Guardrails no GPA. Ele agora possui dois contextos distintos: +PR Sync é o lane de sincronização pós-Guardrails do GPA. O workflow público é `.github/workflows/pr-sync.yml`; `project_setup.pr_sync_router` escolhe entre: -- **Implementation Sync** — uma issue/task canônica; -- **Promotion Sync** — um manifesto agregado de pull requests relacionados. +```text +PR de implementação -> project_setup.pr_sync +PR de promoção -> project_setup.promotion_sync +``` -O workflow público continua sendo `.github/workflows/pr-sync.yml`, enquanto `project_setup/pr_sync_router.py` escolhe o modo correto. +A sincronização normal acontece somente depois de Guardrails bem-sucedido via `workflow_run`, e cada caminho relê o PR vivo em vez de depender do payload antigo do webhook. ## Pipeline ```text Evento de PR -> Autofill - -> Guardrails - -> workflow_run em caso de sucesso + -> Guardrails no PR vivo + -> workflow_run após sucesso -> PR Sync Router -> Implementation Sync -> Promotion Sync ``` -PR Sync não depende de uma cópia alterada do payload original. No fluxo normal pós-Guardrails, o pull request vivo é buscado novamente. +Eventos de lifecycle também entram diretamente no router: -## Implementation Sync +- `ready_for_review`; +- `converted_to_draft`; +- `closed`. -PRs de implementação usam `project_setup/pr_sync.py`. +## Implementation Sync -A issue/task vinculada é identificada por closing reference: +PRs de implementação identificam uma issue/task canônica através de: ```text Closes #123 @@ -37,30 +41,19 @@ Fixes #123 Resolves #123 ``` -A task pode fornecer: +A task pode dirigir: -- famílias configuradas de labels; -- milestone; -- assignees; +- famílias configuradas de labels no PR; +- milestone do PR; +- assignees do PR; - relação pai/sub-issue; -- membership/status opcional no Project v2. - -Se a task estiver sem assignee e `assignAuthorWhenTaskUnassigned` estiver habilitado, o autor do PR pode ser atribuído à task e sincronizado com o PR. - -### Lifecycle padrão no Project +- membership/status da task no Project v2. -| Estado do PR | Status alvo | -| --- | --- | -| Draft / convertido para draft | `In progress` | -| Ready for review / PR validado e aberto | `In review` | -| Fechado sem merge | `In progress` | -| Merged | `Done` | - -Operações de Project v2 continuam opcionais e usam `PROJECT_SETUP_PAT`. Mutações comuns de PR/issues usam o token nativo do Actions. +Se a task estiver sem assignee e `assignAuthorWhenTaskUnassigned` estiver habilitado, o autor pode ser atribuído à task e ao PR. ## Promotion Sync -Promotion paths não são mais ignorados pelo workflow. Eles são roteados para Promotion Sync agregado. +Promotion paths não são pulados. Eles são roteados para sincronização agregada. Caminhos versionados: @@ -69,37 +62,57 @@ develop -> Q.A Q.A -> main ``` -Promotion Sync **não** seleciona uma primeira issue/task arbitrária. Ele lê o manifesto `## Related PRs` e mantém backlinks idempotentes entre os PRs relacionados e a promoção atual. +O manifesto `## Related PRs` é autoritativo depois do Guardrails. Promotion Sync nunca escolhe a primeira issue vinculada como falsa task canônica. + +Ele possui quatro responsabilidades: + +1. agregar metadata nativa de todos os PRs constituintes; +2. aplicar a metadata no próprio PR de promoção; +3. adicionar/atualizar o próprio PR de promoção no Project v2 quando configurado; +4. manter backlinks específicos por estágio nos PRs constituintes. -Exemplo: +### Agregação de metadata nativa + +As famílias gerenciadas de labels exigem consenso. Defaults: ```text -feature/fix PRs -> develop - | - v -develop -> Q.A - | - v -Promotion Sync registra os PRs relacionados em Q.A - | - v -Q.A -> main - | - v -Promotion Sync registra o vínculo com main +type: +priority: +test: ``` -A descoberta de PRs e o Autofill do body de promoção acontecem antes de Guardrails em `project_setup.related_prs`; consulte `pr-governance-architecture.pt-BR.md`. +Se todos os Related PRs possuírem o mesmo valor único em uma família, a promoção recebe essa label. Ausência/divergência faz a família gerenciada ficar sem valor; o comentário sticky do Promotion Sync registra o conflito. Labels manuais/não gerenciadas são preservadas. + +Milestone também exige acordo unânime. O GPA não escolhe um milestone arbitrariamente em caso de conflito. + +Assignees são multi-value e usam a união deduplicada dos assignees de todos os Related PRs. + +### Membership da promoção no Project v2 + +Implementation Sync mantém as tasks como itens de trabalho do Project. Promotion Sync passa a adicionar também o **próprio PR de promoção** ao Project configurado, permitindo representar o lifecycle de review/release e preencher o campo nativo `Projects` no sidebar do PR. + +Lifecycle default: + +| Estado do PR | Project Status | +| --- | --- | +| Draft | `In progress` | +| Open / review | `In review` | +| Fechado sem merge | `In progress` | +| Mergeado | `Done` | + +Operações de Project exigem `PROJECT_SETUP_PAT` e `PROJECT_SETUP_PROJECT_NUMBER`. A metadata normal do PR continua sincronizando se o Project não estiver configurado. ## Related PR Detection +`project_setup.related_prs` é responsável por descoberta, Autofill e validação da promoção. + O detector une e deduplica: -1. PRs mergeados cuja branch head corresponde aos regexes configurados; -2. referências de PR explicitamente informadas em seções configuradas do body; -3. referências herdadas de promotion PRs anteriores mergeados na branch-fonte atual. +1. PRs mergeados cujas branches correspondem aos regex configurados; +2. referências explícitas nas seções configuradas do body; +3. referências herdadas de promoções anteriores. -Os patterns default são propositalmente amplos como exemplos: +Patterns default: ```text ^feat/ @@ -115,7 +128,7 @@ Os patterns default são propositalmente amplos como exemplos: ^release/ ``` -O repositório pode substituir a lista inteira. Referências explícitas no body continuam válidas mesmo quando a branch do PR referenciado não corresponde aos patterns. +O repositório pode substituir a lista inteira no `project_setup.json`. ## Configuração @@ -168,23 +181,11 @@ O repositório pode substituir a lista inteira. Referências explícitas no body } ``` -`promotionPaths` agora são regras de roteamento. A configuração versionada não expõe mais `skipPromotionPullRequests`. - -## Modelo de eventos - -Sincronização normal roda por `workflow_run` depois de `PR metadata validation` concluir com sucesso. - -Eventos de lifecycle que exigem transição direta também entram pelo router via `pull_request_target`: - -- `ready_for_review`; -- `converted_to_draft`; -- `closed`. - -Os dois caminhos usam automação confiável da base. Forks são excluídos das mutações privilegiadas. +As mesmas flags `syncLabels`, `syncMilestone`, `syncAssignees` e `syncProject` controlam implementation e promotion; o modo de promoção muda a semântica de agregação, não a superfície de configuração. ## Autenticação e permissões -Sincronização restrita ao repositório usa `${{ github.token }}` com: +Sincronização dentro do repositório usa `${{ github.token }}` com: ```yaml permissions: @@ -193,42 +194,29 @@ permissions: pull-requests: write ``` -`PROJECT_SETUP_PAT` fica reservado às operações opcionais de GitHub Projects v2. Related PR Detection, Autofill/validação de promoção e backlinks não dependem desse PAT. +`PROJECT_SETUP_PAT` fica reservado às operações opcionais de GitHub Projects v2. ## Idempotência -Implementation Sync converge sem duplicar labels, assignees, Project membership, relações pai/sub-issue ou comentário marcado. - -Promotion Sync usa comentários marcados específicos por estágio, de forma que execuções repetidas atualizam o vínculo existente em vez de criar duplicatas. +Implementation Sync converge sem duplicar labels, assignees, membership de Project, relações pai/sub-issue ou o comentário marcado. -## Modelo de segurança +Promotion Sync: -- somente automação confiável da base/default branch; -- nenhum código não confiável do head roda com credenciais de escrita; -- `persist-credentials: false` nos checkouts privilegiados; -- sucesso de Guardrails é obrigatório antes da sincronização normal; -- estado vivo do PR é buscado novamente entre etapas que alteram e consomem estado; -- Related PRs são validados como PRs realmente mergeados antes da promoção; -- credenciais do Project v2 ficam isoladas das mutações comuns do repositório. - -## Instalação - -O instalador distribui `.github/workflows/pr-sync.yml`. Os módulos Python sob `project_setup/*.py` incluem: - -```text -pr_sync.py -pr_sync_router.py -related_prs.py -``` +- substitui somente famílias gerenciadas de labels e preserva labels externas; +- converge milestone para o consenso agregado; +- adiciona assignees faltantes sem duplicatas; +- reutiliza membership existente do Project quando visível; +- atualiza Status no mesmo item; +- atualiza backlinks/comentários por marker em vez de duplicá-los. -Arquivos existentes no target continuam sujeitos ao comportamento preserve-by-default do instalador. +## Validação live -## Validação +O lane protegido `Q.A -> main` valida três níveis: -A cobertura fica dividida em: +1. criação/atualização/idempotência/cleanup de recursos; +2. Implementation PR Sync contra base não-default; +3. Promotion Sync com PRs constituintes realmente mergeados. -- `tests/test_pr_sync.py` — sincronização de implementação e segurança do workflow; -- `tests/test_pr_sync_autofill.py` — ordenação do Autofill de implementação; -- `tests/test_related_prs.py` — detecção por branch/body, patterns configuráveis, agregação do body de promoção e dispatch do router. +O smoke de promoção falha se o **próprio objeto do PR de promoção** não possuir labels, milestone, assignees, membership/status no Project v2 e convergência correta após merge. Comentário sticky verde, sozinho, não é evidência suficiente. -Comportamento live de Project v2 e integração Q.A continuam responsabilidades do sandbox, não de testes destrutivos no repositório-fonte. +Veja `pr-governance-architecture.pt-BR.md` para o modelo Mermaid. From 94250757427199dec16cf49e6615817123c4f1c3 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:51:36 -0300 Subject: [PATCH 14/28] feat: auto-discover configured Project v2 --- project_setup/project_lookup.py | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 project_setup/project_lookup.py diff --git a/project_setup/project_lookup.py b/project_setup/project_lookup.py new file mode 100644 index 0000000..2d2bdc8 --- /dev/null +++ b/project_setup/project_lookup.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from .github import GitHubClient, split_repo +from .project import resolve_owner_type + + +def _project_definition_title(config_path: str | os.PathLike[str]) -> str | None: + config_file = Path(config_path) + data = json.loads(config_file.read_text(encoding="utf-8")) + definition_value = data.get("projectDefinitionFile") + if not definition_value: + return None + definition_path = Path(str(definition_value)) + if not definition_path.is_absolute(): + definition_path = config_file.parent / definition_path + if not definition_path.is_file(): + return None + definition = json.loads(definition_path.read_text(encoding="utf-8")) + title = str(definition.get("name") or "").strip() + return title or None + + +def list_owner_projects( + client: GitHubClient, + owner: str, + *, + owner_type: str | None = None, +) -> list[dict[str, Any]]: + resolved_type = resolve_owner_type(client, owner, owner_type) + query = f""" + query($login:String!, $cursor:String) {{ + {resolved_type}(login:$login) {{ + projectsV2(first:100, after:$cursor) {{ + pageInfo {{ hasNextPage endCursor }} + nodes {{ id number title url }} + }} + }} + }} + """ + projects: list[dict[str, Any]] = [] + cursor = None + while True: + data = client.graphql(query, {"login": owner, "cursor": cursor}) + node = data.get(resolved_type) or {} + page = node.get("projectsV2") or {"nodes": [], "pageInfo": {"hasNextPage": False}} + projects.extend(project for project in page.get("nodes", []) if project) + page_info = page.get("pageInfo") or {} + if not page_info.get("hasNextPage"): + return projects + cursor = page_info.get("endCursor") + + +def resolve_project_number( + client: GitHubClient | None, + repo: str, + explicit_number: int | None, + *, + config_path: str | os.PathLike[str] = "project_setup.json", + owner: str | None = None, + owner_type: str | None = None, +) -> tuple[int | None, str]: + if explicit_number is not None: + return explicit_number, "configured explicitly" + if client is None: + return None, "Project PAT is not configured" + + title = _project_definition_title(config_path) + if not title: + return None, "project definition has no discoverable name" + + project_owner = owner or split_repo(repo)[0] + matches = [ + project + for project in list_owner_projects(client, project_owner, owner_type=owner_type) + if str(project.get("title") or "") == title + ] + if len(matches) == 1: + return int(matches[0]["number"]), f"auto-discovered by title `{title}`" + if not matches: + return None, f"no Project v2 named `{title}` was found" + numbers = ", ".join(f"#{project.get('number')}" for project in matches) + raise RuntimeError( + f"Multiple Project v2 boards named `{title}` were found ({numbers}). " + "Set PROJECT_SETUP_PROJECT_NUMBER explicitly." + ) From 08c26a81d1891c3d8fa21573afb61f9ac28da14f Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:52:01 -0300 Subject: [PATCH 15/28] feat: add pull request items to Project v2 --- project_setup/pr_project_sync.py | 104 +++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 project_setup/pr_project_sync.py diff --git a/project_setup/pr_project_sync.py b/project_setup/pr_project_sync.py new file mode 100644 index 0000000..7682b68 --- /dev/null +++ b/project_setup/pr_project_sync.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Any + +from .github import GitHubClient, split_repo +from .pr_sync import PullRequestContext, status_option_id +from .project import add_issue_to_project, find_project, list_project_fields, update_single_select + + +def list_project_content_items(client: GitHubClient, project_id: str) -> dict[str, str]: + query = """ + query($project:ID!, $cursor:String) { + node(id:$project) { + ... on ProjectV2 { + items(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + content { + __typename + ... on Issue { id } + ... on PullRequest { id } + } + } + } + } + } + } + """ + result: dict[str, str] = {} + cursor = None + while True: + page = client.graphql(query, {"project": project_id, "cursor": cursor})["node"]["items"] + for item in page["nodes"]: + content = item.get("content") or {} + if content.get("__typename") in {"Issue", "PullRequest"} and content.get("id"): + result[str(content["id"])] = str(item["id"]) + if not page["pageInfo"]["hasNextPage"]: + return result + cursor = page["pageInfo"]["endCursor"] + + +def sync_pull_request_project_status( + project_client: GitHubClient, + repo: str, + ctx: PullRequestContext, + pr_issue: dict[str, Any], + project_number: int, + desired_status: str, + config: dict[str, Any], + *, + owner: str | None = None, + dry_run: bool = False, +) -> str: + if not config.get("syncProject", True): + return "disabled by configuration." + + project_owner = owner or split_repo(repo)[0] + project = find_project(project_client, project_owner, project_number) + fields = { + str(field["name"]): field + for field in list_project_fields(project_client, str(project["id"])) + if field and field.get("name") + } + field_name = str(config.get("projectStatusField") or "Status") + status_field = fields.get(field_name) + if not status_field: + raise RuntimeError(f"Project field `{field_name}` was not found") + + selected = status_option_id(status_field, desired_status) + if not selected: + available = ", ".join(str(item.get("name")) for item in status_field.get("options", [])) + raise RuntimeError( + f"Project status option `{desired_status}` was not found in `{field_name}`. " + f"Available options: {available or '(none)'}" + ) + + pr_node = str(pr_issue.get("node_id") or "") + if not pr_node: + raise RuntimeError(f"PR #{ctx.number} has no GraphQL node id") + + current_items = list_project_content_items(project_client, str(project["id"])) + item_id = current_items.get(pr_node) + if not item_id: + if dry_run: + print(f"[DRY-RUN] Would add PR #{ctx.number} to Project v2 #{project_number}") + item_id = f"dry-run-pr-{ctx.number}" + else: + item_id = add_issue_to_project(project_client, str(project["id"]), pr_node) + + if dry_run: + print( + f"[DRY-RUN] Would set PR #{ctx.number} `{field_name}` " + f"to `{desired_status}` in Project v2 #{project_number}" + ) + else: + update_single_select( + project_client, + str(project["id"]), + str(item_id), + str(status_field["id"]), + selected, + ) + return f"PR #{ctx.number} synced to `{desired_status}` in Project v2 #{project_number}." From e402a3cfb8ff724accf3c5da724f8cb8aa90f25e Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:52:28 -0300 Subject: [PATCH 16/28] feat: sync implementation PR Project membership --- project_setup/pr_sync_router.py | 54 +++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/project_setup/pr_sync_router.py b/project_setup/pr_sync_router.py index 0139251..e84f124 100644 --- a/project_setup/pr_sync_router.py +++ b/project_setup/pr_sync_router.py @@ -6,12 +6,17 @@ from pathlib import Path from .github import GitHubClient, get_project_pat, require_client +from .pr_project_sync import sync_pull_request_project_status from .pr_sync import ( apply_pr_sync, context_from_event, + is_permission_error, + is_same_repository, load_sync_config, project_number_from_value, + project_status_for_context, ) +from .project_lookup import resolve_project_number from .promotion_sync import apply_promotion_sync from .related_prs import is_promotion_context @@ -44,16 +49,45 @@ def apply_routed_pr_sync( dry_run=dry_run, ) - return apply_pr_sync( + sync_config = load_sync_config(config_path) + result = apply_pr_sync( client, repo, event, - load_sync_config(config_path), + sync_config, project_client=project_client, project_number=project_number, owner=owner, dry_run=dry_run, ) + if result != 0 or not is_same_repository(ctx, repo): + return result + + # Implementation Sync keeps the linked task as a Project item, and the + # router additionally makes the PR itself a Project item so GitHub's + # native Projects sidebar reflects the active review lifecycle. + if not sync_config.get("syncProject", True) or project_number is None or project_client is None: + return result + + pr_issue = client.get_issue(repo, ctx.number) + try: + note = sync_pull_request_project_status( + project_client, + repo, + ctx, + pr_issue, + project_number, + project_status_for_context(ctx, sync_config), + sync_config, + owner=owner, + dry_run=dry_run, + ) + print(f"Implementation PR Project v2: {note}") + except Exception as exc: + if not is_permission_error(exc): + raise + print(f"Implementation PR Project v2 not synchronized: token lacks permission ({exc}).") + return result def build_parser() -> argparse.ArgumentParser: @@ -77,13 +111,27 @@ def main(argv: list[str] | None = None) -> int: client = require_client() project_pat = get_project_pat() project_client = GitHubClient(project_pat) if project_pat else None + explicit_project_number = project_number_from_value(args.project_number) + project_number, project_resolution = resolve_project_number( + project_client, + args.repo, + explicit_project_number, + config_path=args.config, + owner=args.owner, + owner_type=os.getenv("PROJECT_SETUP_OWNER_TYPE"), + ) + if project_number is not None: + print(f"Project v2 #{project_number}: {project_resolution}.") + elif project_client is not None: + print(f"Project v2 auto-discovery skipped: {project_resolution}.") + return apply_routed_pr_sync( client, args.repo, load_event(args.event_path), config_path=args.config, project_client=project_client, - project_number=project_number_from_value(args.project_number), + project_number=project_number, owner=args.owner, dry_run=args.dry_run, ) From 1e7981fac90629fe2a93305dd6b09817f021b9c1 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:52:54 -0300 Subject: [PATCH 17/28] feat: create issue-linked implementation branches --- project_setup/linked_branch.py | 134 +++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 project_setup/linked_branch.py diff --git a/project_setup/linked_branch.py b/project_setup/linked_branch.py new file mode 100644 index 0000000..fb446ed --- /dev/null +++ b/project_setup/linked_branch.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import argparse +import os +import urllib.parse +from typing import Any + +from .github import API_BASE, GitHubClient, require_client, split_repo + + +def _branch_oid(client: GitHubClient, repo: str, base_ref: str) -> str: + encoded = urllib.parse.quote(base_ref, safe="") + branch = client.request_json("GET", f"{API_BASE}/repos/{repo}/branches/{encoded}") + oid = str((branch.get("commit") or {}).get("sha") or "") + if not oid: + raise RuntimeError(f"Could not resolve base branch `{base_ref}` in {repo}") + return oid + + +def create_linked_branch( + client: GitHubClient, + repo: str, + issue_number: int, + branch_name: str, + *, + base_ref: str = "develop", + dry_run: bool = False, +) -> dict[str, Any]: + branch_name = branch_name.strip() + base_ref = base_ref.strip() + if not branch_name: + raise ValueError("Linked branch name cannot be empty") + if not base_ref: + raise ValueError("Linked branch base cannot be empty") + if dry_run: + print( + f"[DRY-RUN] Would create linked branch `{branch_name}` for issue #{issue_number} " + f"from `{base_ref}` in {repo}" + ) + return {"issue": {"number": issue_number}, "linkedBranch": {"ref": {"name": branch_name}}} + + issue = client.get_issue(repo, issue_number) + if "pull_request" in issue: + raise ValueError(f"#{issue_number} is a pull request, not an issue") + issue_id = str(issue.get("node_id") or "") + if not issue_id: + raise RuntimeError(f"Issue #{issue_number} has no GraphQL node id") + + repository = client.request_json("GET", f"{API_BASE}/repos/{repo}") + repository_id = str(repository.get("node_id") or "") + if not repository_id: + raise RuntimeError(f"Repository {repo} has no GraphQL node id") + oid = _branch_oid(client, repo, base_ref) + + mutation = """ + mutation($issue:ID!, $repository:ID!, $name:String!, $oid:GitObjectID!) { + createLinkedBranch( + input:{issueId:$issue,repositoryId:$repository,name:$name,oid:$oid} + ) { + issue { id number } + linkedBranch { id ref { name } } + } + } + """ + payload = client.graphql( + mutation, + { + "issue": issue_id, + "repository": repository_id, + "name": branch_name, + "oid": oid, + }, + )["createLinkedBranch"] + linked_name = str((((payload.get("linkedBranch") or {}).get("ref") or {}).get("name")) or branch_name) + print(f"Created linked branch `{linked_name}` for issue #{issue_number} from `{base_ref}`.") + return payload + + +def manually_linked_pr_numbers(client: GitHubClient, repo: str, issue_number: int) -> list[int]: + owner, name = split_repo(repo) + query = """ + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner,name:$repo) { + issue(number:$number) { + closedByPullRequestsReferences(first:50,userLinkedOnly:true,includeClosedPrs:true) { + nodes { number } + } + } + } + } + """ + issue = client.graphql( + query, + {"owner": owner, "repo": name, "number": issue_number}, + )["repository"]["issue"] + if not issue: + raise RuntimeError(f"Issue #{issue_number} not found in {repo}") + connection = issue.get("closedByPullRequestsReferences") or {} + return [int(pr["number"]) for pr in connection.get("nodes", []) if pr and pr.get("number") is not None] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Create a GitHub Linked Branch so a later PR can appear in the issue Development sidebar" + ) + parser.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) + parser.add_argument("--issue", type=int, required=True) + parser.add_argument("--branch", required=True) + parser.add_argument("--base", default="develop") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--dry-run", dest="dry_run", action="store_true") + mode.add_argument("--live", dest="dry_run", action="store_false") + parser.set_defaults(dry_run=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if not args.repo: + raise SystemExit("Missing --repo or GITHUB_REPOSITORY") + client = GitHubClient("") if args.dry_run else require_client() + create_linked_branch( + client, + args.repo, + args.issue, + args.branch, + base_ref=args.base, + dry_run=args.dry_run, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 0aab3c01cf4a1921716fbbbd2f961fa31dca022e Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:53:24 -0300 Subject: [PATCH 18/28] test: cover PR Project membership and discovery --- tests/test_pr_project_sync.py | 140 ++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/test_pr_project_sync.py diff --git a/tests/test_pr_project_sync.py b/tests/test_pr_project_sync.py new file mode 100644 index 0000000..42be822 --- /dev/null +++ b/tests/test_pr_project_sync.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + +from project_setup.github import GitHubClient +from project_setup.pr_project_sync import sync_pull_request_project_status +from project_setup.pr_sync import PullRequestContext +from project_setup.project_lookup import resolve_project_number + + +class PullRequestProjectSyncTests(unittest.TestCase): + def context(self) -> PullRequestContext: + return PullRequestContext( + number=73, + action="synchronize", + body="Closes #72", + base_ref="develop", + head_ref="feat/issue-72", + head_repo="owner/repo", + author="alice", + draft=False, + merged=False, + ) + + def test_pr_itself_is_added_to_project_and_statused(self): + client = Mock(spec=GitHubClient) + config = {"syncProject": True, "projectStatusField": "Status"} + status_field = { + "id": "status-field", + "name": "Status", + "options": [{"id": "review-option", "name": "In review"}], + } + with ( + patch("project_setup.pr_project_sync.find_project", return_value={"id": "project-id"}), + patch("project_setup.pr_project_sync.list_project_fields", return_value=[status_field]), + patch("project_setup.pr_project_sync.list_project_content_items", return_value={}), + patch("project_setup.pr_project_sync.add_issue_to_project", return_value="item-id") as add_item, + patch("project_setup.pr_project_sync.update_single_select") as update_status, + ): + note = sync_pull_request_project_status( + client, + "owner/repo", + self.context(), + {"number": 73, "node_id": "PR_node"}, + 6, + "In review", + config, + owner="owner", + ) + + add_item.assert_called_once_with(client, "project-id", "PR_node") + update_status.assert_called_once_with( + client, + "project-id", + "item-id", + "status-field", + "review-option", + ) + self.assertIn("PR #73", note) + + def test_existing_project_item_is_idempotent(self): + client = Mock(spec=GitHubClient) + status_field = { + "id": "status-field", + "name": "Status", + "options": [{"id": "review-option", "name": "In review"}], + } + with ( + patch("project_setup.pr_project_sync.find_project", return_value={"id": "project-id"}), + patch("project_setup.pr_project_sync.list_project_fields", return_value=[status_field]), + patch("project_setup.pr_project_sync.list_project_content_items", return_value={"PR_node": "existing-item"}), + patch("project_setup.pr_project_sync.add_issue_to_project") as add_item, + patch("project_setup.pr_project_sync.update_single_select") as update_status, + ): + sync_pull_request_project_status( + client, + "owner/repo", + self.context(), + {"number": 73, "node_id": "PR_node"}, + 6, + "In review", + {"syncProject": True, "projectStatusField": "Status"}, + owner="owner", + ) + add_item.assert_not_called() + update_status.assert_called_once() + + +class ProjectLookupTests(unittest.TestCase): + def make_config(self) -> str: + directory = Path(tempfile.mkdtemp()) + definition = directory / "project.json" + definition.write_text(json.dumps({"name": "Project Delivery Board"}), encoding="utf-8") + config = directory / "project_setup.json" + config.write_text(json.dumps({"projectDefinitionFile": "project.json"}), encoding="utf-8") + return str(config) + + def test_explicit_project_number_wins_without_remote_lookup(self): + client = Mock(spec=GitHubClient) + number, note = resolve_project_number(client, "owner/repo", 42, config_path=self.make_config()) + self.assertEqual(number, 42) + self.assertEqual(note, "configured explicitly") + client.assert_not_called() + + def test_unique_project_title_is_auto_discovered(self): + client = Mock(spec=GitHubClient) + with patch( + "project_setup.project_lookup.list_owner_projects", + return_value=[{"number": 6, "title": "Project Delivery Board"}], + ): + number, note = resolve_project_number( + client, + "owner/repo", + None, + config_path=self.make_config(), + owner="owner", + ) + self.assertEqual(number, 6) + self.assertIn("auto-discovered", note) + + def test_missing_project_is_reported_without_guessing(self): + client = Mock(spec=GitHubClient) + with patch("project_setup.project_lookup.list_owner_projects", return_value=[]): + number, note = resolve_project_number( + client, + "owner/repo", + None, + config_path=self.make_config(), + owner="owner", + ) + self.assertIsNone(number) + self.assertIn("no Project v2 named", note) + + +if __name__ == "__main__": + unittest.main() From e8c18dbc4c52582ff8d168d2c5d0baa6c97a6dbf Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:53:45 -0300 Subject: [PATCH 19/28] test: cover native Development linked branches --- tests/test_linked_branch.py | 80 +++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/test_linked_branch.py diff --git a/tests/test_linked_branch.py b/tests/test_linked_branch.py new file mode 100644 index 0000000..5caa14e --- /dev/null +++ b/tests/test_linked_branch.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import unittest +from unittest.mock import Mock + +from project_setup.github import GitHubClient +from project_setup.linked_branch import create_linked_branch, manually_linked_pr_numbers + + +class LinkedBranchTests(unittest.TestCase): + def test_create_linked_branch_uses_issue_repository_name_and_base_oid(self): + client = Mock(spec=GitHubClient) + client.get_issue.return_value = {"number": 72, "node_id": "I_issue"} + client.request_json.side_effect = [ + {"node_id": "R_repo"}, + {"commit": {"sha": "abc123"}}, + ] + client.graphql.return_value = { + "createLinkedBranch": { + "issue": {"id": "I_issue", "number": 72}, + "linkedBranch": {"id": "LB_1", "ref": {"name": "feat/issue-72"}}, + } + } + + result = create_linked_branch( + client, + "owner/repo", + 72, + "feat/issue-72", + base_ref="develop", + ) + + self.assertEqual(result["linkedBranch"]["ref"]["name"], "feat/issue-72") + mutation, variables = client.graphql.call_args.args + self.assertIn("createLinkedBranch", mutation) + self.assertEqual( + variables, + { + "issue": "I_issue", + "repository": "R_repo", + "name": "feat/issue-72", + "oid": "abc123", + }, + ) + self.assertIn("branches/develop", client.request_json.call_args_list[1].args[1]) + + def test_dry_run_does_not_touch_github(self): + client = Mock(spec=GitHubClient) + result = create_linked_branch( + client, + "owner/repo", + 72, + "fix/issue-72", + base_ref="develop", + dry_run=True, + ) + self.assertEqual(result["linkedBranch"]["ref"]["name"], "fix/issue-72") + client.get_issue.assert_not_called() + client.request_json.assert_not_called() + client.graphql.assert_not_called() + + def test_manual_development_query_returns_linked_pr_numbers(self): + client = Mock(spec=GitHubClient) + client.graphql.return_value = { + "repository": { + "issue": { + "closedByPullRequestsReferences": { + "nodes": [{"number": 81}, {"number": 82}] + } + } + } + } + self.assertEqual(manually_linked_pr_numbers(client, "owner/repo", 72), [81, 82]) + query, variables = client.graphql.call_args.args + self.assertIn("userLinkedOnly:true", query) + self.assertEqual(variables["number"], 72) + + +if __name__ == "__main__": + unittest.main() From 2085750e010694bddf61780101efe206f606e31d Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:54:16 -0300 Subject: [PATCH 20/28] test: verify Development linkage on non-default PR --- tests/qa/live_linked_branch.py | 164 +++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/qa/live_linked_branch.py diff --git a/tests/qa/live_linked_branch.py b/tests/qa/live_linked_branch.py new file mode 100644 index 0000000..09dde61 --- /dev/null +++ b/tests/qa/live_linked_branch.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import argparse +import base64 +import os +import re +import time +import urllib.parse + +from project_setup.github import API_BASE, GitHubClient +from project_setup.linked_branch import create_linked_branch, manually_linked_pr_numbers + + +BRANCH_PREFIX = "qa/development/" +ISSUE_PREFIX = "QA Development linkage " +PR_PREFIX = "QA Development linked PR " +TIMEOUT_SECONDS = 20.0 + + +def _ref_path(branch: str) -> str: + return urllib.parse.quote(f"heads/{branch}", safe="/") + + +def _branch_sha(client: GitHubClient, repo: str, branch: str) -> str: + ref = client.request_json("GET", f"{API_BASE}/repos/{repo}/git/ref/{_ref_path(branch)}") + return str((ref.get("object") or {})["sha"]) + + +def _create_plain_branch(client: GitHubClient, repo: str, branch: str, sha: str) -> None: + client.request_json( + "POST", + f"{API_BASE}/repos/{repo}/git/refs", + {"ref": f"refs/heads/{branch}", "sha": sha}, + ) + + +def _delete_branch(client: GitHubClient, repo: str, branch: str) -> None: + client.request_json("DELETE", f"{API_BASE}/repos/{repo}/git/refs/{_ref_path(branch)}") + + +def _wait_for_development_link( + client: GitHubClient, + repo: str, + issue_number: int, + pr_number: int, +) -> bool: + deadline = time.monotonic() + TIMEOUT_SECONDS + while True: + if pr_number in manually_linked_pr_numbers(client, repo, issue_number): + return True + if time.monotonic() >= deadline: + return False + time.sleep(1.0) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Live GitHub Development linkage validation") + parser.add_argument("--repo", required=True) + parser.add_argument("--run-id", required=True) + args = parser.parse_args() + + source_repo = os.getenv("GITHUB_REPOSITORY", "").strip() + if args.repo.casefold() == source_repo.casefold(): + raise SystemExit("Refusing Development linkage live test against the GPA source repository") + token = os.getenv("PROJECT_SETUP_PAT", "").strip() + if not token: + raise SystemExit("PROJECT_SETUP_PAT is required for linked-branch live validation") + + client = GitHubClient(token) + suffix = re.sub(r"[^A-Za-z0-9_.-]+", "-", args.run_id).strip("-")[:36] or "manual" + issue_number: int | None = None + pr_number: int | None = None + created_branches: list[str] = [] + primary_error: Exception | None = None + cleanup_errors: list[str] = [] + + try: + repository = client.request_json("GET", f"{API_BASE}/repos/{args.repo}") + default_branch = str(repository["default_branch"]) + root_sha = _branch_sha(client, args.repo, default_branch) + base_branch = f"{BRANCH_PREFIX}base-{suffix}" + head_branch = f"{BRANCH_PREFIX}head-{suffix}" + + _create_plain_branch(client, args.repo, base_branch, root_sha) + created_branches.append(base_branch) + + issue = client.request_json( + "POST", + f"{API_BASE}/repos/{args.repo}/issues", + {"title": f"{ISSUE_PREFIX}{suffix}", "body": "Disposable native Development linkage validation."}, + ) + issue_number = int(issue["number"]) + + create_linked_branch( + client, + args.repo, + issue_number, + head_branch, + base_ref=base_branch, + ) + created_branches.append(head_branch) + + marker_path = f"qa-development-{suffix}.txt" + client.request_json( + "PUT", + f"{API_BASE}/repos/{args.repo}/contents/{urllib.parse.quote(marker_path, safe='')}", + { + "message": f"test: native Development link {suffix}", + "content": base64.b64encode(f"development-link {suffix}\n".encode()).decode(), + "branch": head_branch, + }, + ) + + pr = client.request_json( + "POST", + f"{API_BASE}/repos/{args.repo}/pulls", + { + "title": f"{PR_PREFIX}{suffix}", + "head": head_branch, + "base": base_branch, + "body": "Native Development linkage must come from the Linked Branch, not a default-branch closing keyword.", + }, + ) + pr_number = int(pr["number"]) + if base_branch == default_branch: + raise RuntimeError("Development linkage smoke must target a non-default base branch") + if not _wait_for_development_link(client, args.repo, issue_number, pr_number): + raise RuntimeError( + f"PR #{pr_number} did not appear as a manually linked Development PR for issue #{issue_number}" + ) + print("development_linked_branch=passed") + print("development_non_default_pr=passed") + + except Exception as exc: + primary_error = exc + + if pr_number is not None: + try: + client.request_json("PATCH", f"{API_BASE}/repos/{args.repo}/pulls/{pr_number}", {"state": "closed"}) + except Exception as exc: + cleanup_errors.append(f"pull request: {exc}") + if issue_number is not None: + try: + client.update_issue(args.repo, issue_number, {"state": "closed"}) + except Exception as exc: + cleanup_errors.append(f"issue: {exc}") + for branch in reversed(created_branches): + try: + _delete_branch(client, args.repo, branch) + except Exception as exc: + cleanup_errors.append(f"branch `{branch}`: {exc}") + + if primary_error: + if cleanup_errors: + print("warning: cleanup also failed: " + "; ".join(cleanup_errors)) + raise primary_error + if cleanup_errors: + raise RuntimeError("Development linkage cleanup failed: " + "; ".join(cleanup_errors)) + print("development_link_cleanup=passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 74f607761171a70fa695f46d490dac3c99069550 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:54:37 -0300 Subject: [PATCH 21/28] test: add live Development linkage smoke --- .github/workflows/qa-live.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qa-live.yml b/.github/workflows/qa-live.yml index 0f91f93..add38e7 100644 --- a/.github/workflows/qa-live.yml +++ b/.github/workflows/qa-live.yml @@ -23,7 +23,7 @@ jobs: live-sandbox: name: qa-live-gate runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 environment: name: qa deployment: false @@ -89,3 +89,12 @@ jobs: python tests/qa/live_promotion_sync.py --repo "$QA_REPOSITORY" --run-id "pr-${{ inputs.pr_number }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + + - name: Run live Development linked-branch test + env: + QA_REPOSITORY: ${{ vars.QA_REPOSITORY }} + PROJECT_SETUP_PAT: ${{ secrets.QA_PROJECT_SETUP_PAT }} + run: >- + python tests/qa/live_linked_branch.py + --repo "$QA_REPOSITORY" + --run-id "pr-${{ inputs.pr_number }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" From 90beecee744d21e5ecaafd9eb1191388089f4ea2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:55:47 -0300 Subject: [PATCH 22/28] test: verify implementation PR Project membership --- tests/qa/live_implementation_project.py | 247 ++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 tests/qa/live_implementation_project.py diff --git a/tests/qa/live_implementation_project.py b/tests/qa/live_implementation_project.py new file mode 100644 index 0000000..b3b5bef --- /dev/null +++ b/tests/qa/live_implementation_project.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import argparse +import base64 +import json +import os +from pathlib import Path +import re +import tempfile +import time +import urllib.parse + +from project_setup.github import API_BASE, GitHubClient, split_repo +from project_setup.pr_sync_router import apply_routed_pr_sync +from project_setup.project import create_project, ensure_fields, resolve_owner_type + + +PREFIX = "QA Implementation Project " +BRANCH_PREFIX = "qa/implementation-project/" +TIMEOUT_SECONDS = 20.0 + + +def list_projects(client: GitHubClient, owner: str, owner_type: str) -> list[dict]: + query = f""" + query($login:String!, $cursor:String) {{ + {owner_type}(login:$login) {{ + projectsV2(first:100, after:$cursor) {{ + pageInfo {{ hasNextPage endCursor }} + nodes {{ id number title }} + }} + }} + }} + """ + result: list[dict] = [] + cursor = None + while True: + page = client.graphql(query, {"login": owner, "cursor": cursor})[owner_type]["projectsV2"] + result.extend(item for item in page["nodes"] if item) + if not page["pageInfo"]["hasNextPage"]: + return result + cursor = page["pageInfo"]["endCursor"] + + +def delete_project(client: GitHubClient, project_id: str) -> None: + client.graphql( + "mutation($project:ID!){deleteProjectV2(input:{projectId:$project}){projectV2{id}}}", + {"project": project_id}, + ) + + +def project_statuses(client: GitHubClient, project_id: str, repo: str) -> dict[tuple[str, int], str | None]: + query = """ + query($project:ID!, $cursor:String) { + node(id:$project) { + ... on ProjectV2 { + items(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + content { + __typename + ... on Issue { number repository { nameWithOwner } } + ... on PullRequest { number repository { nameWithOwner } } + } + fieldValues(first:50) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + } + } + } + } + } + } + } + """ + result: dict[tuple[str, int], str | None] = {} + cursor = None + while True: + page = client.graphql(query, {"project": project_id, "cursor": cursor})["node"]["items"] + for item in page["nodes"]: + content = item.get("content") or {} + repository = content.get("repository") or {} + if str(repository.get("nameWithOwner") or "").casefold() != repo.casefold(): + continue + kind = str(content.get("__typename") or "") + number = int(content.get("number") or 0) + if kind not in {"Issue", "PullRequest"} or not number: + continue + status = None + for value in (item.get("fieldValues") or {}).get("nodes", []): + if value and ((value.get("field") or {}).get("name") == "Status"): + status = str(value.get("name") or "") or None + break + result[(kind, number)] = status + if not page["pageInfo"]["hasNextPage"]: + return result + cursor = page["pageInfo"]["endCursor"] + + +def wait_for_items(client: GitHubClient, project_id: str, repo: str, issue_number: int, pr_number: int) -> bool: + deadline = time.monotonic() + TIMEOUT_SECONDS + while True: + statuses = project_statuses(client, project_id, repo) + if statuses.get(("Issue", issue_number)) == "In review" and statuses.get(("PullRequest", pr_number)) == "In review": + return True + if time.monotonic() >= deadline: + return False + time.sleep(1.0) + + +def ref_path(branch: str) -> str: + return urllib.parse.quote(f"heads/{branch}", safe="/") + + +def create_branch(client: GitHubClient, repo: str, branch: str, sha: str) -> None: + client.request_json("POST", f"{API_BASE}/repos/{repo}/git/refs", {"ref": f"refs/heads/{branch}", "sha": sha}) + + +def delete_branch(client: GitHubClient, repo: str, branch: str) -> None: + client.request_json("DELETE", f"{API_BASE}/repos/{repo}/git/refs/{ref_path(branch)}") + + +def branch_sha(client: GitHubClient, repo: str, branch: str) -> str: + return str(client.request_json("GET", f"{API_BASE}/repos/{repo}/git/ref/{ref_path(branch)}")["object"]["sha"]) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Live implementation PR Project v2 membership validation") + parser.add_argument("--repo", required=True) + parser.add_argument("--run-id", required=True) + args = parser.parse_args() + if args.repo.casefold() == os.getenv("GITHUB_REPOSITORY", "").casefold(): + raise SystemExit("Refusing implementation Project live test against GPA source repository") + token = os.getenv("PROJECT_SETUP_PAT", "").strip() + if not token: + raise SystemExit("PROJECT_SETUP_PAT is required") + + client = GitHubClient(token) + owner, _ = split_repo(args.repo) + owner_type = resolve_owner_type(client, owner) + suffix = re.sub(r"[^A-Za-z0-9_.-]+", "-", args.run_id).strip("-")[:32] or "manual" + project_title = f"{PREFIX}{suffix}" + issue_number: int | None = None + pr_number: int | None = None + project_id: str | None = None + branches: list[str] = [] + primary_error: Exception | None = None + cleanup_errors: list[str] = [] + + try: + with tempfile.TemporaryDirectory() as tempdir: + definition = Path(tempdir) / "project.json" + definition.write_text( + json.dumps({"name": project_title, "fields": [{"name": "Status", "type": "single_select", "options": ["In progress", "In review", "Done"]}]}), + encoding="utf-8", + ) + create_project(client, args.repo, str(definition), owner_type=owner_type) + project = next(item for item in list_projects(client, owner, owner_type) if item.get("title") == project_title) + project_id = str(project["id"]) + project_number = int(project["number"]) + ensure_fields(client, project_id, {"fields": [{"name": "Status", "type": "single_select", "options": ["In progress", "In review", "Done"]}]}) + + issue = client.request_json("POST", f"{API_BASE}/repos/{args.repo}/issues", {"title": f"{PREFIX}task {suffix}", "body": "Disposable implementation Project item test."}) + issue_number = int(issue["number"]) + repository = client.request_json("GET", f"{API_BASE}/repos/{args.repo}") + default_branch = str(repository["default_branch"]) + root = branch_sha(client, args.repo, default_branch) + base = f"{BRANCH_PREFIX}base-{suffix}" + head = f"{BRANCH_PREFIX}head-{suffix}" + create_branch(client, args.repo, base, root) + branches.append(base) + create_branch(client, args.repo, head, root) + branches.append(head) + marker = f"qa-implementation-project-{suffix}.txt" + client.request_json( + "PUT", + f"{API_BASE}/repos/{args.repo}/contents/{urllib.parse.quote(marker, safe='')}", + {"message": f"test: implementation Project {suffix}", "content": base64.b64encode(b"implementation-project\n").decode(), "branch": head}, + ) + pr = client.request_json( + "POST", + f"{API_BASE}/repos/{args.repo}/pulls", + {"title": f"{PREFIX}PR {suffix}", "head": head, "base": base, "body": f"## Linked Issue\n- Closes #{issue_number}\n\n## Milestone\n- None\n"}, + ) + pr_number = int(pr["number"]) + + with tempfile.TemporaryDirectory() as tempdir: + config = Path(tempdir) / "project_setup.json" + config.write_text( + json.dumps({"prAutomation": {"sync": {"enabled": True, "syncLabels": False, "syncMilestone": False, "syncAssignees": False, "linkSubissues": False, "syncProject": True, "promotionPaths": [{"head": "develop", "base": "Q.A"}, {"head": "Q.A", "base": "main"}], "projectStatusField": "Status", "projectStatus": {"draft": "In progress", "review": "In review", "closed": "In progress", "merged": "Done"}}}}), + encoding="utf-8", + ) + result = apply_routed_pr_sync( + client, + args.repo, + {"action": "opened", "pull_request": pr}, + config_path=str(config), + project_client=client, + project_number=project_number, + owner=owner, + ) + if result != 0: + raise RuntimeError(f"Routed implementation PR Sync returned {result}") + if not wait_for_items(client, project_id, args.repo, issue_number, pr_number): + raise RuntimeError("Issue and implementation PR did not both converge to In review in Project v2") + print("implementation_task_project_status=passed") + print("implementation_pr_project_status=passed") + print("implementation_pr_projects_sidebar_contract=passed") + + except Exception as exc: + primary_error = exc + + if pr_number is not None: + try: + client.request_json("PATCH", f"{API_BASE}/repos/{args.repo}/pulls/{pr_number}", {"state": "closed"}) + except Exception as exc: + cleanup_errors.append(f"PR: {exc}") + if issue_number is not None: + try: + client.update_issue(args.repo, issue_number, {"state": "closed"}) + except Exception as exc: + cleanup_errors.append(f"issue: {exc}") + for branch in reversed(branches): + try: + delete_branch(client, args.repo, branch) + except Exception as exc: + cleanup_errors.append(f"branch {branch}: {exc}") + if project_id is not None: + try: + delete_project(client, project_id) + except Exception as exc: + cleanup_errors.append(f"project: {exc}") + + if primary_error: + if cleanup_errors: + print("warning: cleanup also failed: " + "; ".join(cleanup_errors)) + raise primary_error + if cleanup_errors: + raise RuntimeError("Implementation Project cleanup failed: " + "; ".join(cleanup_errors)) + print("implementation_project_cleanup=passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 251ea486b1701b6d15430f035233c77e1653eb09 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:56:08 -0300 Subject: [PATCH 23/28] test: verify implementation PR Project membership live --- .github/workflows/qa-live.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qa-live.yml b/.github/workflows/qa-live.yml index add38e7..232dd08 100644 --- a/.github/workflows/qa-live.yml +++ b/.github/workflows/qa-live.yml @@ -23,7 +23,7 @@ jobs: live-sandbox: name: qa-live-gate runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 30 environment: name: qa deployment: false @@ -81,6 +81,15 @@ jobs: --repo "$QA_REPOSITORY" --run-id "pr-${{ inputs.pr_number }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + - name: Run live implementation PR Project membership test + env: + QA_REPOSITORY: ${{ vars.QA_REPOSITORY }} + PROJECT_SETUP_PAT: ${{ secrets.QA_PROJECT_SETUP_PAT }} + run: >- + python tests/qa/live_implementation_project.py + --repo "$QA_REPOSITORY" + --run-id "pr-${{ inputs.pr_number }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + - name: Run live Promotion Sync native metadata test env: QA_REPOSITORY: ${{ vars.QA_REPOSITORY }} From a2643964fbbe16d67e00baacc3061c835b407dae Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:57:01 -0300 Subject: [PATCH 24/28] docs: document Development and PR Project membership --- docs/repo/pr-sync.md | 192 ++++++++++++++++++------------------------- 1 file changed, 79 insertions(+), 113 deletions(-) diff --git a/docs/repo/pr-sync.md b/docs/repo/pr-sync.md index 58a4da3..4a7c6fe 100644 --- a/docs/repo/pr-sync.md +++ b/docs/repo/pr-sync.md @@ -7,112 +7,108 @@ PR Sync is GPA's post-Guardrails synchronization lane. The public workflow is `.github/workflows/pr-sync.yml`; `project_setup.pr_sync_router` selects one of two modes: ```text -Implementation PR -> project_setup.pr_sync +Implementation PR -> project_setup.pr_sync + PR Project membership Promotion PR -> project_setup.promotion_sync ``` Normal synchronization runs only after successful Guardrails through `workflow_run`, and each path refetches live PR state instead of relying on an Autofill-mutated webhook payload. -## Pipeline - -```text -PR event - -> Autofill - -> live Guardrails - -> workflow_run on success - -> PR Sync Router - -> Implementation Sync - -> Promotion Sync +## Architecture + +```mermaid +flowchart TD + I[Issue / task] --> LB[Optional GPA Linked Branch creation] + LB --> DEV[GitHub native Development relationship] + DEV --> IP[Implementation PR] + I --> AF[Autofill] + IP --> AF + AF --> G[Guardrails on live PR] + G -->|success| W[workflow_run] + W --> R[PR Sync Router] + + R -->|Implementation| IS[Implementation Sync] + IS --> META[Labels / milestone / assignees] + IS --> TASK[Task -> Project v2] + IS --> IPR[Implementation PR -> Project v2] + + R -->|Promotion| PS[Promotion Sync] + PS --> AGG[Aggregate Related PR metadata] + AGG --> PPR[Promotion PR native metadata] + PS --> PPROJ[Promotion PR -> Project v2] + PS --> BACK[Stage-specific backlinks] ``` -Lifecycle events that need a direct transition also enter the router: - -- `ready_for_review`; -- `converted_to_draft`; -- `closed`. +Lifecycle events that need a direct transition also enter the router: `ready_for_review`, `converted_to_draft`, and `closed`. ## Implementation Sync -Implementation PRs identify one canonical linked issue/task with: - -```text -Closes #123 -Fixes #123 -Resolves #123 -``` +Implementation PRs identify one canonical linked issue/task with `Closes #123`, `Fixes #123`, or `Resolves #123`. The task drives configured PR label families, milestone, assignees, parent/sub-issue linkage, and task Project v2 membership/status. -The task can drive: +When Project v2 is enabled, **both the linked task and the implementation PR itself are Project items**. This makes the PR's native `Projects` sidebar reflect the same review lifecycle instead of only tracking the task. -- configured PR label families; -- PR milestone; -- PR assignees; -- parent/sub-issue linkage; -- task Project v2 membership/status. +Default lifecycle: -When the task has no assignee and `assignAuthorWhenTaskUnassigned` is enabled, the PR author can be assigned to both the task and PR. +| PR state | Project status | +| --- | --- | +| Draft | `In progress` | +| Open / review | `In review` | +| Closed without merge | `In progress` | +| Merged | `Done` | -## Promotion Sync +## Native Development relationship on non-default branches -Promotion paths are not skipped. They route to aggregate synchronization. +GitHub interprets closing keywords as native issue links only when the PR targets the repository default branch. GPA's normal implementation lane targets `develop`, so `Closes #123` remains the canonical GPA metadata reference but cannot by itself populate GitHub's `Development` sidebar. -Committed paths: +For native Development linkage, create the implementation branch as a GitHub **Linked Branch** before opening the PR: -```text -develop -> Q.A -Q.A -> main +```bash +python -m project_setup.linked_branch \ + --repo owner/repository \ + --issue 123 \ + --branch feat/issue-123-example \ + --base develop \ + --live ``` -The promotion's `## Related PRs` manifest is authoritative after Guardrails. Promotion Sync never selects the first linked issue as a fake canonical task. +The branch name is caller-controlled; GPA does not require `US-*` or any single naming convention. GitHub transfers the Linked Branch relationship to the pull request when that branch is used to open a PR, including a PR whose base is not the default branch. -It performs four responsibilities: +An already-created ordinary branch/PR cannot be retroactively converted into a Linked Branch through this helper; use GitHub's manual Development-link UI for an existing PR. -1. aggregate GitHub-native metadata from all constituent PRs; -2. apply that metadata to the promotion PR; -3. add/update the promotion PR itself in Project v2 when configured; -4. maintain stage-specific backlinks on constituent PRs. - -### Native metadata aggregation +## Promotion Sync -Configured managed label families use consensus. Defaults: +Promotion paths are not skipped. Committed paths are: ```text -type: -priority: -test: +develop -> Q.A +Q.A -> main ``` -If every related PR has the same single value for a family, the promotion receives that label. Missing/conflicting values result in no managed label for that family; the sticky Promotion Sync comment reports the conflict. Unmanaged labels already on the promotion are preserved. +Promotion Sync reads the validated `## Related PRs` manifest and never selects a first issue as a fake canonical task. It: -Milestone also requires unanimous agreement. A conflict or missing milestone does not cause GPA to pick one arbitrarily. +1. aggregates GitHub-native metadata from all constituent PRs; +2. applies consensus labels/milestone and unioned assignees to the promotion PR; +3. adds/updates the promotion PR itself in Project v2; +4. maintains stage-specific backlinks on constituent PRs. -Assignees are multi-valued and therefore use the deduplicated union of all related PR assignees. +Managed label families use consensus; defaults are `type:`, `priority:`, and `test:`. A missing/conflicting value is reported rather than guessed. Milestone also requires unanimous agreement. Assignees are a deduplicated union. -### Promotion Project v2 membership +## Project v2 resolution -Implementation Sync keeps tasks as Project work items. Promotion Sync additionally adds the **promotion PR itself** to the configured Project so the promotion/release lifecycle can be represented and the PR's native `Projects` sidebar can show membership. +Project operations require `PROJECT_SETUP_PAT`. GPA resolves the target board in this order: -Default lifecycle: +1. explicit `--project-number`; +2. `PROJECT_SETUP_PROJECT_NUMBER`; +3. if a Project PAT exists, exact unique title match using the `name` in `projectDefinitionFile`. -| PR state | Project status | -| --- | --- | -| Draft | `In progress` | -| Open / review | `In review` | -| Closed without merge | `In progress` | -| Merged | `Done` | +If title discovery finds zero projects, GPA skips Project mutation with an explicit diagnostic. If multiple Projects share the configured title, GPA fails rather than choosing one arbitrarily. This makes `PROJECT_SETUP_PROJECT_NUMBER` optional when the configured board already exists with a unique name. -Project operations require both `PROJECT_SETUP_PAT` and `PROJECT_SETUP_PROJECT_NUMBER`. Repository-scoped metadata still synchronizes if Project configuration is absent. +Repository-scoped labels, milestone, assignees, Related PRs, and backlinks continue to work when Project configuration is absent. ## Related PR Detection -`project_setup.related_prs` owns promotion discovery, Autofill, and validation. - -The detector unions and deduplicates: +`project_setup.related_prs` owns promotion discovery, Autofill, and validation. It unions and deduplicates merged PRs whose head branches match configured regexes, explicit body references, and references inherited from earlier promotions. -1. merged PRs whose head branch matches configured regexes; -2. explicit references in configured body sections; -3. references inherited from earlier promotion PRs. - -Default patterns are broad examples: +Default branch-pattern examples are intentionally broad and fully replaceable: ```text ^feat/ @@ -128,8 +124,6 @@ Default patterns are broad examples: ^release/ ``` -Repositories may replace the entire list in `project_setup.json`. - ## Configuration ```json @@ -138,17 +132,8 @@ Repositories may replace the entire list in `project_setup.json`. "relatedPrs": { "enabled": true, "branchPatterns": [ - "^feat/", - "^fix/", - "^docs/", - "^refactor/", - "^test/", - "^hotfix/", - "^phase/", - "^task/", - "^chore/", - "^ci/", - "^release/" + "^feat/", "^fix/", "^docs/", "^refactor/", "^test/", + "^hotfix/", "^phase/", "^task/", "^chore/", "^ci/", "^release/" ], "bodySections": ["Related PRs", "Related Pull Requests"], "includeBranchMatches": true, @@ -181,42 +166,23 @@ Repositories may replace the entire list in `project_setup.json`. } ``` -The same `syncLabels`, `syncMilestone`, `syncAssignees`, and `syncProject` flags apply to implementation and promotion modes; promotion mode changes the aggregation semantics, not the configuration surface. - -## Authentication and permissions - -Repository-scoped synchronization uses `${{ github.token }}` with: - -```yaml -permissions: - contents: read - issues: write - pull-requests: write -``` - -`PROJECT_SETUP_PAT` is reserved for optional GitHub Projects v2 operations. - -## Idempotency - -Implementation Sync converges without duplicating labels, assignees, Project membership, parent relationships, or its marked status comment. +## Authentication and security -Promotion Sync: +Repository-scoped synchronization uses `${{ github.token }}`. `PROJECT_SETUP_PAT` is reserved for Projects v2 and for explicit local/live operations that require user-scoped GitHub capabilities such as creating Linked Branches. -- replaces only managed label families while preserving unmanaged labels; -- converges milestone to the aggregate consensus; -- adds missing assignees without duplicates; -- reuses existing Project membership when visible; -- updates lifecycle Status on the same Project item; -- updates stage-specific backlink/sticky comments rather than appending duplicates. +Privileged Actions continue to run trusted base/default-branch code; untrusted PR head code is never executed with write credentials. ## Live validation -The protected `Q.A -> main` live lane validates three increasingly complete layers: +The protected `Q.A -> main` lane now verifies: -1. resource create/update/idempotency/cleanup; -2. Implementation PR Sync on a non-default base branch; -3. Promotion Sync with real merged constituent PRs. +1. disposable GitHub resource lifecycle; +2. implementation metadata on a non-default base; +3. **linked task and implementation PR** Project v2 membership/status; +4. promotion PR native metadata and Project lifecycle; +5. a real Linked Branch becoming a native Development-linked PR against a non-default base; +6. complete cleanup. -The promotion smoke fails unless the **promotion PR object itself** has native labels, milestone, assignees, Project v2 membership/status, and correct merged lifecycle convergence. A successful sticky comment alone is not accepted as evidence. +A sticky comment alone is never sufficient evidence: the tests re-read the native GitHub objects/Project state. -See `pr-governance-architecture.md` for the Mermaid execution model. +See `pr-governance-architecture.md` for the overall governance model. From fcc757162f0cf555e91d0c8cb8e57833329c8b55 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:57:44 -0300 Subject: [PATCH 25/28] docs: documentar Development e Project do PR --- docs/repo/pr-sync.pt-BR.md | 196 +++++++++++++++---------------------- 1 file changed, 81 insertions(+), 115 deletions(-) diff --git a/docs/repo/pr-sync.pt-BR.md b/docs/repo/pr-sync.pt-BR.md index 30f8011..9d551c6 100644 --- a/docs/repo/pr-sync.pt-BR.md +++ b/docs/repo/pr-sync.pt-BR.md @@ -7,112 +7,108 @@ PR Sync é o lane de sincronização pós-Guardrails do GPA. O workflow público é `.github/workflows/pr-sync.yml`; `project_setup.pr_sync_router` escolhe entre: ```text -PR de implementação -> project_setup.pr_sync +PR de implementação -> project_setup.pr_sync + membership do PR no Project PR de promoção -> project_setup.promotion_sync ``` -A sincronização normal acontece somente depois de Guardrails bem-sucedido via `workflow_run`, e cada caminho relê o PR vivo em vez de depender do payload antigo do webhook. - -## Pipeline - -```text -Evento de PR - -> Autofill - -> Guardrails no PR vivo - -> workflow_run após sucesso - -> PR Sync Router - -> Implementation Sync - -> Promotion Sync +A sincronização normal ocorre somente após Guardrails bem-sucedido via `workflow_run`, sempre relendo o PR vivo. + +## Arquitetura + +```mermaid +flowchart TD + I[Issue / task] --> LB[Criação opcional de Linked Branch pelo GPA] + LB --> DEV[Relação Development nativa do GitHub] + DEV --> IP[PR de implementação] + I --> AF[Autofill] + IP --> AF + AF --> G[Guardrails no PR vivo] + G -->|sucesso| W[workflow_run] + W --> R[PR Sync Router] + + R -->|Implementação| IS[Implementation Sync] + IS --> META[Labels / milestone / assignees] + IS --> TASK[Task -> Project v2] + IS --> IPR[PR de implementação -> Project v2] + + R -->|Promoção| PS[Promotion Sync] + PS --> AGG[Agregar metadata dos Related PRs] + AGG --> PPR[Metadata nativa no PR de promoção] + PS --> PPROJ[PR de promoção -> Project v2] + PS --> BACK[Backlinks por estágio] ``` -Eventos de lifecycle também entram diretamente no router: - -- `ready_for_review`; -- `converted_to_draft`; -- `closed`. +Eventos `ready_for_review`, `converted_to_draft` e `closed` também entram diretamente no router para transições de lifecycle. ## Implementation Sync -PRs de implementação identificam uma issue/task canônica através de: - -```text -Closes #123 -Fixes #123 -Resolves #123 -``` +PRs de implementação identificam uma issue/task canônica por `Closes #123`, `Fixes #123` ou `Resolves #123`. A task dirige famílias configuradas de labels, milestone, assignees, relação pai/sub-issue e membership/status da task no Project v2. -A task pode dirigir: +Quando Project v2 está habilitado, **a task vinculada e o próprio PR de implementação são itens do Project**. Assim o campo nativo `Projects` do sidebar do PR representa o lifecycle de review, em vez de acompanhar somente a task. -- famílias configuradas de labels no PR; -- milestone do PR; -- assignees do PR; -- relação pai/sub-issue; -- membership/status da task no Project v2. +Lifecycle padrão: -Se a task estiver sem assignee e `assignAuthorWhenTaskUnassigned` estiver habilitado, o autor pode ser atribuído à task e ao PR. +| Estado do PR | Project Status | +| --- | --- | +| Draft | `In progress` | +| Open / review | `In review` | +| Fechado sem merge | `In progress` | +| Mergeado | `Done` | -## Promotion Sync +## Development nativo em PR para branch não-default -Promotion paths não são pulados. Eles são roteados para sincronização agregada. +O GitHub interpreta closing keywords como vínculo nativo de issue somente quando o PR aponta para a branch default. Como o fluxo normal do GPA é `feature/fix -> develop`, `Closes #123` continua sendo a referência canônica usada pelo GPA, mas sozinho não consegue preencher o campo `Development` do GitHub. -Caminhos versionados: +Para obter o vínculo Development nativo, crie a branch de implementação como uma **Linked Branch** antes de abrir o PR: -```text -develop -> Q.A -Q.A -> main +```bash +python -m project_setup.linked_branch \ + --repo owner/repository \ + --issue 123 \ + --branch feat/issue-123-exemplo \ + --base develop \ + --live ``` -O manifesto `## Related PRs` é autoritativo depois do Guardrails. Promotion Sync nunca escolhe a primeira issue vinculada como falsa task canônica. +O nome da branch é definido pelo usuário; o GPA não exige `US-*` nem uma convenção única. Quando essa branch é usada para abrir o PR, o GitHub transfere o vínculo da Linked Branch para o PR, inclusive quando a base do PR não é a branch default. -Ele possui quatro responsabilidades: +Uma branch/PR comum que já exista não pode ser convertida retroativamente em Linked Branch por este helper; para PR existente, use o vínculo manual no campo Development da interface do GitHub. -1. agregar metadata nativa de todos os PRs constituintes; -2. aplicar a metadata no próprio PR de promoção; -3. adicionar/atualizar o próprio PR de promoção no Project v2 quando configurado; -4. manter backlinks específicos por estágio nos PRs constituintes. - -### Agregação de metadata nativa +## Promotion Sync -As famílias gerenciadas de labels exigem consenso. Defaults: +Promotion paths não são pulados. Os caminhos versionados são: ```text -type: -priority: -test: +develop -> Q.A +Q.A -> main ``` -Se todos os Related PRs possuírem o mesmo valor único em uma família, a promoção recebe essa label. Ausência/divergência faz a família gerenciada ficar sem valor; o comentário sticky do Promotion Sync registra o conflito. Labels manuais/não gerenciadas são preservadas. +Promotion Sync lê o manifesto validado `## Related PRs` e nunca escolhe a primeira issue como falsa task canônica. Ele: -Milestone também exige acordo unânime. O GPA não escolhe um milestone arbitrariamente em caso de conflito. +1. agrega metadata nativa dos PRs constituintes; +2. aplica labels/milestone por consenso e assignees por união no próprio PR de promoção; +3. adiciona/atualiza o PR de promoção no Project v2; +4. mantém backlinks específicos por estágio. -Assignees são multi-value e usam a união deduplicada dos assignees de todos os Related PRs. +Famílias de labels gerenciadas usam consenso; defaults: `type:`, `priority:` e `test:`. Valor ausente/conflitante é relatado, não adivinhado. Milestone também exige unanimidade. Assignees usam união deduplicada. -### Membership da promoção no Project v2 +## Resolução do Project v2 -Implementation Sync mantém as tasks como itens de trabalho do Project. Promotion Sync passa a adicionar também o **próprio PR de promoção** ao Project configurado, permitindo representar o lifecycle de review/release e preencher o campo nativo `Projects` no sidebar do PR. +Operações de Project exigem `PROJECT_SETUP_PAT`. O GPA resolve o board alvo nesta ordem: -Lifecycle default: +1. `--project-number` explícito; +2. `PROJECT_SETUP_PROJECT_NUMBER`; +3. se houver Project PAT, busca por **um único Project com nome exatamente igual** ao `name` de `projectDefinitionFile`. -| Estado do PR | Project Status | -| --- | --- | -| Draft | `In progress` | -| Open / review | `In review` | -| Fechado sem merge | `In progress` | -| Mergeado | `Done` | +Se não existir Project com esse nome, o GPA não altera Project e registra diagnóstico. Se houver mais de um com o mesmo nome, ele falha em vez de escolher arbitrariamente. Assim `PROJECT_SETUP_PROJECT_NUMBER` passa a ser opcional quando o board configurado já existe com nome único. -Operações de Project exigem `PROJECT_SETUP_PAT` e `PROJECT_SETUP_PROJECT_NUMBER`. A metadata normal do PR continua sincronizando se o Project não estiver configurado. +Labels, milestone, assignees, Related PRs e backlinks continuam funcionando sem configuração de Project. ## Related PR Detection -`project_setup.related_prs` é responsável por descoberta, Autofill e validação da promoção. - -O detector une e deduplica: +`project_setup.related_prs` é responsável por descoberta, Autofill e validação da promoção. Ele une/deduplica PRs mergeados cujas branches correspondem aos regex configurados, referências explícitas do body e referências herdadas de promoções anteriores. -1. PRs mergeados cujas branches correspondem aos regex configurados; -2. referências explícitas nas seções configuradas do body; -3. referências herdadas de promoções anteriores. - -Patterns default: +Patterns default são exemplos amplos e totalmente substituíveis: ```text ^feat/ @@ -128,8 +124,6 @@ Patterns default: ^release/ ``` -O repositório pode substituir a lista inteira no `project_setup.json`. - ## Configuração ```json @@ -138,17 +132,8 @@ O repositório pode substituir a lista inteira no `project_setup.json`. "relatedPrs": { "enabled": true, "branchPatterns": [ - "^feat/", - "^fix/", - "^docs/", - "^refactor/", - "^test/", - "^hotfix/", - "^phase/", - "^task/", - "^chore/", - "^ci/", - "^release/" + "^feat/", "^fix/", "^docs/", "^refactor/", "^test/", + "^hotfix/", "^phase/", "^task/", "^chore/", "^ci/", "^release/" ], "bodySections": ["Related PRs", "Related Pull Requests"], "includeBranchMatches": true, @@ -181,42 +166,23 @@ O repositório pode substituir a lista inteira no `project_setup.json`. } ``` -As mesmas flags `syncLabels`, `syncMilestone`, `syncAssignees` e `syncProject` controlam implementation e promotion; o modo de promoção muda a semântica de agregação, não a superfície de configuração. - -## Autenticação e permissões - -Sincronização dentro do repositório usa `${{ github.token }}` com: - -```yaml -permissions: - contents: read - issues: write - pull-requests: write -``` - -`PROJECT_SETUP_PAT` fica reservado às operações opcionais de GitHub Projects v2. - -## Idempotência - -Implementation Sync converge sem duplicar labels, assignees, membership de Project, relações pai/sub-issue ou o comentário marcado. +## Autenticação e segurança -Promotion Sync: +Sincronização restrita ao repositório usa `${{ github.token }}`. `PROJECT_SETUP_PAT` permanece reservado ao Projects v2 e a operações locais/live explicitamente solicitadas que exigem capacidade no escopo do usuário, como criar Linked Branches. -- substitui somente famílias gerenciadas de labels e preserva labels externas; -- converge milestone para o consenso agregado; -- adiciona assignees faltantes sem duplicatas; -- reutiliza membership existente do Project quando visível; -- atualiza Status no mesmo item; -- atualiza backlinks/comentários por marker em vez de duplicá-los. +Actions privilegiadas continuam executando código confiável da base/default; código não confiável do head não roda com credenciais de escrita. ## Validação live -O lane protegido `Q.A -> main` valida três níveis: +O lane protegido `Q.A -> main` agora exige: -1. criação/atualização/idempotência/cleanup de recursos; -2. Implementation PR Sync contra base não-default; -3. Promotion Sync com PRs constituintes realmente mergeados. +1. lifecycle dos recursos descartáveis; +2. metadata de implementation PR contra base não-default; +3. **task e próprio implementation PR** com membership/status no Project v2; +4. PR de promoção com metadata nativa e lifecycle no Project; +5. uma Linked Branch real tornando-se PR nativamente ligado em Development contra base não-default; +6. cleanup completo. -O smoke de promoção falha se o **próprio objeto do PR de promoção** não possuir labels, milestone, assignees, membership/status no Project v2 e convergência correta após merge. Comentário sticky verde, sozinho, não é evidência suficiente. +Comentário sticky, sozinho, nunca é evidência suficiente: os testes releem os objetos nativos do GitHub e o estado do Project. -Veja `pr-governance-architecture.pt-BR.md` para o modelo Mermaid. +Veja `pr-governance-architecture.pt-BR.md` para o modelo geral de governança. From 1c023bd30b8d64f29104ef3de48a7d7a38f9d3b2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:58:36 -0300 Subject: [PATCH 26/28] docs: extend governance architecture for Development and Project --- docs/repo/pr-governance-architecture.md | 286 ++++++++---------------- 1 file changed, 98 insertions(+), 188 deletions(-) diff --git a/docs/repo/pr-governance-architecture.md b/docs/repo/pr-governance-architecture.md index 6938d9d..4e002e7 100644 --- a/docs/repo/pr-governance-architecture.md +++ b/docs/repo/pr-governance-architecture.md @@ -8,28 +8,26 @@ The core invariant is: > **Autofill -> Guardrails -> PR Sync** -PR state is always re-read between mutation and synchronization stages so later jobs do not consume stale webhook payloads. - -GPA has two PR contexts: - -- **Implementation PR** — one canonical linked issue/task; -- **Promotion PR** — an aggregate manifest of already merged implementation PRs. +PR state is re-read between mutation and synchronization stages so later jobs do not consume stale webhook payloads. GPA has two synchronization contexts: an implementation PR backed by one canonical issue/task, and a promotion PR backed by an aggregate Related PR manifest. ## Architecture ```mermaid flowchart TD - A[PR opened / synchronize / reopened / edited] --> G[PR metadata validation
Guardrails] - G --> R{PR context} + ISSUE[Issue / task] -->|optional before coding| LB[GPA Linked Branch
createLinkedBranch] + LB --> DEV[GitHub native Development relationship] + DEV --> IPR[Implementation PR
feature/fix -> develop] + IPR --> G[PR metadata validation
Guardrails] + G --> R{PR context} R -->|Implementation| IAF[Implementation Autofill
branch/body -> issue/task] IAF --> ILIVE[Read live PR] ILIVE --> IV[Implementation validation] R -->|Promotion| PAF[Related PR Detection] - PAF --> PB[Configured branch-pattern matches] - PAF --> PE[Explicit Related PR body references] - PAF --> PI[Inherited references from prior promotion] + PAF --> PB[Configured branch patterns] + PAF --> PE[Explicit body references] + PAF --> PI[Inherited promotion references] PB --> PM[Union + deduplicate] PE --> PM PI --> PM @@ -37,254 +35,166 @@ flowchart TD PWRITE --> PLIVE[Read live promotion PR] PLIVE --> PV[Promotion validation] - IV --> V{Guardrails successful?} - PV --> V - V -- No --> STOP[Stop governance lane] - V -- Yes --> WR[workflow_run: Guardrails succeeded] + IV --> OK{Guardrails successful?} + PV --> OK + OK -- No --> STOP[Stop governance lane] + OK -- Yes --> WR[workflow_run] WR --> S[PR Sync Router] - L[ready_for_review
converted_to_draft
closed] --> S + LIFE[ready_for_review
converted_to_draft
closed] --> S S --> T{PR context} T -->|Implementation| IS[Implementation Sync] IS --> TASK[Resolve canonical task] - TASK --> IMETA[Sync PR labels / milestone / assignees] - IMETA --> REL[Sync parent / sub-issue] - REL --> IPROJ[Task Project v2 membership/status] + TASK --> IMETA[PR labels / milestone / assignees] + IMETA --> REL[Parent / sub-issue] + REL --> TPROJ[Task -> Project v2] + TPROJ --> IPROJ[Implementation PR -> Project v2] T -->|Promotion| PS[Promotion Sync] PS --> MANIFEST[Read Related PR manifest] MANIFEST --> AGG[Aggregate native metadata] AGG --> PCONS[Consensus labels + milestone
union assignees] - PCONS --> PPR[Write promotion PR native metadata] - PPR --> PPROJ[Promotion PR Project v2 membership/status] - PPROJ --> BACKLINK[Create/update stage backlinks] + PCONS --> PMETA[Promotion PR native metadata] + PMETA --> PPROJ[Promotion PR -> Project v2] + PPROJ --> BACK[Stage-specific backlinks] PV -->|Q.A -> main and valid| QA[Live Q.A sandbox] - QA --> QAR[Resource/idempotency test] - QAR --> QAI[Implementation PR Sync live test] - QAI --> QAP[Promotion PR native metadata + Project test] - QAP --> QAC[Cleanup sandbox resources + deployments] + QA --> QAR[Resource lifecycle] + QAR --> QAI[Implementation metadata] + QAI --> QAIP[Task + implementation PR Project membership] + QAIP --> QAP[Promotion metadata + Project lifecycle] + QAP --> QAD[Linked Branch -> Development on non-default PR] + QAD --> QAC[Cleanup resources + deployments] ``` ## Why the order matters -`pull_request_target` payloads are snapshots. Autofill can update the real PR while the original event still contains the old body. GPA therefore uses this handoff: +`pull_request_target` payloads are snapshots. Autofill can mutate the real PR while the original event still carries the old body. GPA therefore mutates the live PR, validates that live PR, waits for successful Guardrails to emit a separate `workflow_run`, and then refetches the live PR before synchronization. -1. Autofill mutates the real PR through the GitHub API. -2. Guardrails validates the **live PR**. -3. Successful Guardrails emits a separate `workflow_run`. -4. PR Sync refetches the **live PR** and applies synchronization. +## Implementation PR contract -This is the same stale-state failure mode the reference Take Your Pills governance lane solved by serializing guardrails before hygiene. +Implementation PRs use one canonical issue/task. Existing `Closes #N`, `Fixes #N`, and `Resolves #N` body references remain authoritative for GPA metadata resolution. -## Implementation PR flow +The synchronized native state is: ```text -implementation branch - -> branch/body resolution - -> one canonical issue/task - -> Linked Issue + Milestone Autofill - -> Guardrails - -> Implementation Sync +canonical task -> PR labels / milestone / assignees + -> parent/sub-issue relationship -> task Project v2 lifecycle + -> implementation PR Project v2 lifecycle ``` -Existing `Closes #N`, `Fixes #N`, and `Resolves #N` references remain authoritative. - -## Promotion PR flow - -Configured promotion paths are routing rules, not skip rules. - -Committed GPA paths: - -```text -develop -> Q.A -Q.A -> main -``` - -A promotion represents an aggregate of implementation PRs and must never select an arbitrary first task as its source of truth. - -### Related PR Detection +Both task and PR use the configured lifecycle mapping. This intentionally makes the PR visible in GitHub's native `Projects` sidebar instead of tracking only the issue. -The detector unions and deduplicates: +### Native Development relationship -1. merged PRs whose head branches match configured regexes; -2. explicit PR references from configured body sections such as `## Related PRs`; -3. inherited implementation PR references from prior promotion PRs. +Closing keywords have a GitHub limitation: they create native issue links only when the PR targets the default branch. GPA's implementation lane normally targets `develop`, so the body reference is sufficient for GPA but not for GitHub's Development sidebar. -Default branch-pattern examples are deliberately broad: +GPA therefore supports GitHub Linked Branches as the native Development path: ```text -^feat/ -^fix/ -^docs/ -^refactor/ -^test/ -^hotfix/ -^phase/ -^task/ -^chore/ -^ci/ -^release/ +issue + -> project_setup.linked_branch createLinkedBranch + -> implementation branch linked to issue + -> open PR from that branch to develop + -> GitHub transfers branch relationship to PR + -> Development sidebar shows the PR ``` -A target repository can replace the entire list through `prAutomation.relatedPrs.branchPatterns`. - -### Detection window - -For `develop -> Q.A`, automatic discovery starts after the previous merged `develop -> Q.A` promotion. - -For `Q.A -> main`, GPA considers promotions merged into `Q.A` after the previous `Q.A -> main` promotion and inherits their constituent implementation PRs. Work that remains only in `develop` is therefore not attributed to `main`. - -When no earlier promotion exists, `fallbackDays` bounds the initial lookup. Explicit body references do not depend on that window. - -## Promotion Autofill - -Promotion Autofill can deterministically populate: - -- `## Related PRs`; -- `## Linked Issue` from closing references in the constituent PRs; -- `## Milestone` from constituent PR milestone titles; -- `## Summary` only while that section is still a placeholder. - -Human-authored summaries, evidence, risks, test notes, and DoD decisions are preserved. - -## Promotion native metadata contract +Branch naming is caller-controlled and independent of `US-*`; repositories can continue using `feat/`, `fix/`, `task/`, or their own convention. Existing ordinary PRs must be linked manually in GitHub because the public API creates a new Linked Branch rather than retroactively converting an existing branch. -Promotion Sync treats the **related PR objects** as the aggregate source of truth for GitHub-native PR fields. +## Promotion PR contract -### Labels - -Only configured managed families are synchronized, by default: - -```text -type: -priority: -test: -``` - -Each family uses **consensus**: +Configured promotion paths are routing rules, not skip rules: ```text -#101 priority:high -#102 priority:high -#103 priority:high - -> promotion priority:high +develop -> Q.A +Q.A -> main ``` -A disagreement or missing value does not cause GPA to select an arbitrary label. The managed family is omitted from the promotion PR and the conflict is reported in the Promotion Sync sticky comment. - -Unmanaged/manual labels are preserved. +A promotion represents a set of implementation PRs and never selects an arbitrary first task as its source of truth. -### Milestone +### Related PR Detection -Milestone is single-valued and also requires consensus across all related PRs. A unanimous milestone is applied to the promotion PR. Missing/disagreeing milestones clear the synchronized promotion milestone instead of selecting one arbitrarily. +The detector unions and deduplicates merged PRs matched by configured branch regexes, explicit references from configured body sections, and inherited references from prior promotions. Default branch-pattern examples are broad (`feat/`, `fix/`, `docs/`, `refactor/`, `test/`, `hotfix/`, `phase/`, `task/`, `chore/`, `ci/`, `release/`) and are replaceable through configuration. -### Assignees +For `develop -> Q.A`, automatic discovery starts after the previous merged promotion of the same path. For `Q.A -> main`, GPA inherits constituent implementation PRs from promotions that actually reached Q.A so work remaining only in `develop` is not attributed to `main`. -Assignees are naturally multi-valued. Promotion Sync applies the deduplicated union of assignees from all related PRs. +### Promotion native metadata -### Project v2 +Promotion Sync treats Related PR objects as the aggregate source of truth: -Implementation Sync keeps its existing model: the **linked issue/task** is the Project v2 work item. +- configured managed label families use consensus; +- milestone requires unanimous agreement; +- assignees use a deduplicated union; +- unmanaged labels are preserved; +- conflicts are reported rather than guessed; +- the promotion PR itself is a Project v2 item; +- stage-specific backlinks remain idempotent. -Promotion Sync additionally treats the **promotion PR itself** as a Project v2 item because a promotion has an independent review/release lifecycle. +## Project v2 contract Default lifecycle mapping: -| Promotion PR state | Project Status | +| PR state | Project Status | | --- | --- | | Draft | `In progress` | | Open / review | `In review` | | Closed without merge | `In progress` | | Merged | `Done` | -This is what makes the native `Projects` sidebar field meaningful for promotion PRs when `PROJECT_SETUP_PROJECT_NUMBER` and `PROJECT_SETUP_PAT` are configured. - -## Promotion validation - -Guardrails verify that: - -- the head/base pair is a configured promotion path; -- at least one merged PR is listed in the configured Related PR section; -- every referenced PR is actually merged; -- auto-detected related PRs are not silently omitted. - -Promotion PRs use their own contract rather than bypassing validation. - -## PR Sync routing - -`.github/workflows/pr-sync.yml` invokes `project_setup.pr_sync_router`. +Project operations use `PROJECT_SETUP_PAT`. Target Project resolution is deterministic: ```text -implementation PR -> project_setup.pr_sync -promotion PR -> project_setup.promotion_sync +explicit --project-number + ↓ otherwise +PROJECT_SETUP_PROJECT_NUMBER + ↓ otherwise, when PAT exists +unique exact title == projectDefinitionFile.name + ↓ +zero matches -> skip with diagnostic +multiple matches -> fail, never guess ``` -`project_setup.related_prs` owns discovery, Autofill, and validation. `project_setup.promotion_sync` owns aggregate native metadata, promotion Project membership/status, and backlinks. +Repository-scoped synchronization continues when Project configuration is unavailable. ## Authentication boundary -Repository-scoped mutations use the built-in Actions token: - -```yaml -permissions: - contents: read - issues: write - pull-requests: write -``` - -This covers PR labels, milestone, assignees, comments, and backlinks. +Repository-scoped PR/issue mutations use `${{ github.token }}` with narrowly scoped workflow permissions. GitHub Projects v2 uses `PROJECT_SETUP_PAT`. Explicit local/live Linked Branch creation also requires credentials with repository write access because it creates a real branch through GitHub GraphQL. -GitHub Projects v2 uses the optional separate credential: - -```text -PROJECT_SETUP_PAT -``` - -and `PROJECT_SETUP_PROJECT_NUMBER` identifies the target Project. If either is absent, native PR metadata still synchronizes and Project synchronization is reported as skipped. +Privileged workflows execute trusted base/default-branch code, exclude forks from privileged mutations, and use `persist-credentials: false` on trusted checkouts. ## Live regression contract -The protected `Q.A -> main` lane must prove all of the following against the disposable sandbox: +The protected `Q.A -> main` lane is fail-closed and must prove native state, not comments: ```text -Implementation PR Sync - -> labels present on PR - -> milestone present on PR - -> assignee present on PR - -> linked task in Project v2 / In review - -> non-default base branch works - -Promotion Sync - -> two real constituent PRs merged into source branch - -> consensus labels present on promotion PR - -> consensus milestone present on promotion PR - -> assignee union present on promotion PR - -> promotion PR itself in Project v2 / In review - -> merged promotion PR Project status -> Done - -> stage backlinks converge to merged +Implementation metadata + -> labels / milestone / assignee on PR + -> non-default base works + +Implementation Project lifecycle + -> linked task in Project / In review + -> implementation PR itself in Project / In review + +Promotion lifecycle + -> real merged constituent PRs + -> consensus native metadata on promotion PR + -> promotion PR in Project / In review + -> merged promotion -> Done + -> backlinks converge + +Development linkage + -> issue-linked branch created through createLinkedBranch + -> PR opened against non-default base + -> PR appears in issue's user-linked Development references Cleanup -> disposable PRs/issues closed - -> branches removed - -> Project removed - -> milestone removed - -> labels removed + -> branches, Project, milestone and labels removed -> historical Q.A deployments cleaned ``` -A sticky comment alone is never sufficient evidence that structured synchronization worked. - -## Security invariants - -- privileged workflows execute trusted base/default-branch code; -- untrusted PR head code is never executed with write credentials; -- fork PRs are excluded from privileged mutations; -- privileged checkouts use `persist-credentials: false`; -- Guardrails success is required before normal synchronization; -- live PR state is refetched after Autofill; -- conflicts in single-valued promotion metadata are fail-safe rather than guessed; -- Project v2 credentials remain isolated from repository-scoped mutations. +A sticky status comment alone is never accepted as proof of structured synchronization. From 0841345b3b954f7f41eb09d2496fbee0e7c5f8f4 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Tue, 11 Aug 2026 22:59:18 -0300 Subject: [PATCH 27/28] docs: ampliar arquitetura para Development e Project --- docs/repo/pr-governance-architecture.pt-BR.md | 284 ++++++------------ 1 file changed, 97 insertions(+), 187 deletions(-) diff --git a/docs/repo/pr-governance-architecture.pt-BR.md b/docs/repo/pr-governance-architecture.pt-BR.md index 5bd87a1..f2a7eda 100644 --- a/docs/repo/pr-governance-architecture.pt-BR.md +++ b/docs/repo/pr-governance-architecture.pt-BR.md @@ -8,28 +8,26 @@ A regra central é: > **Autofill -> Guardrails -> PR Sync** -O estado do PR sempre é lido novamente entre etapas de mutação e sincronização, evitando consumo de payload antigo do webhook. - -O GPA possui dois contextos: - -- **PR de implementação** — uma issue/task canônica; -- **PR de promoção** — um manifesto agregado de PRs de implementação já mergeados. +O estado do PR é relido entre etapas de mutação e sincronização para evitar stale state. O GPA possui dois contextos: PR de implementação com uma issue/task canônica e PR de promoção com manifesto agregado de Related PRs. ## Arquitetura ```mermaid flowchart TD - A[PR opened / synchronize / reopened / edited] --> G[PR metadata validation
Guardrails] - G --> R{Contexto do PR} + ISSUE[Issue / task] -->|opcional antes de codar| LB[GPA Linked Branch
createLinkedBranch] + LB --> DEV[Relação Development nativa do GitHub] + DEV --> IPR[PR de implementação
feature/fix -> develop] + IPR --> G[PR metadata validation
Guardrails] + G --> R{Contexto do PR} R -->|Implementação| IAF[Implementation Autofill
branch/body -> issue/task] IAF --> ILIVE[Ler PR vivo] ILIVE --> IV[Validação de implementação] R -->|Promoção| PAF[Related PR Detection] - PAF --> PB[Matches pelos patterns configurados] + PAF --> PB[Patterns de branch configurados] PAF --> PE[Referências explícitas no body] - PAF --> PI[Referências herdadas da promoção anterior] + PAF --> PI[Referências herdadas de promoções] PB --> PM[Unir + deduplicar] PE --> PM PI --> PM @@ -37,254 +35,166 @@ flowchart TD PWRITE --> PLIVE[Ler PR de promoção vivo] PLIVE --> PV[Validação de promoção] - IV --> V{Guardrails passou?} - PV --> V - V -- Não --> STOP[Interromper governança] - V -- Sim --> WR[workflow_run: Guardrails passou] + IV --> OK{Guardrails passou?} + PV --> OK + OK -- Não --> STOP[Interromper governança] + OK -- Sim --> WR[workflow_run] WR --> S[PR Sync Router] - L[ready_for_review
converted_to_draft
closed] --> S + LIFE[ready_for_review
converted_to_draft
closed] --> S S --> T{Contexto do PR} T -->|Implementação| IS[Implementation Sync] IS --> TASK[Resolver task canônica] - TASK --> IMETA[Sync labels / milestone / assignees do PR] - IMETA --> REL[Sync pai / sub-issue] - REL --> IPROJ[Task no Project v2 + Status] + TASK --> IMETA[Labels / milestone / assignees do PR] + IMETA --> REL[Pai / sub-issue] + REL --> TPROJ[Task -> Project v2] + TPROJ --> IPROJ[PR de implementação -> Project v2] T -->|Promoção| PS[Promotion Sync] PS --> MANIFEST[Ler manifesto Related PRs] MANIFEST --> AGG[Agregar metadata nativa] AGG --> PCONS[Consenso labels + milestone
união de assignees] - PCONS --> PPR[Escrever metadata nativa na promoção] - PPR --> PPROJ[PR de promoção no Project v2 + Status] - PPROJ --> BACKLINK[Criar/atualizar backlinks por estágio] + PCONS --> PMETA[Metadata nativa no PR de promoção] + PMETA --> PPROJ[PR de promoção -> Project v2] + PPROJ --> BACK[Backlinks por estágio] PV -->|Q.A -> main válido| QA[Sandbox Q.A live] - QA --> QAR[Teste de recursos/idempotência] - QAR --> QAI[Teste live do Implementation PR Sync] - QAI --> QAP[Teste da promoção: metadata nativa + Project] - QAP --> QAC[Cleanup do sandbox + deployments] + QA --> QAR[Lifecycle de recursos] + QAR --> QAI[Metadata de implementação] + QAI --> QAIP[Task + PR de implementação no Project] + QAIP --> QAP[Metadata da promoção + lifecycle no Project] + QAP --> QAD[Linked Branch -> Development em PR não-default] + QAD --> QAC[Cleanup de recursos + deployments] ``` ## Por que a ordem importa -Payloads de `pull_request_target` são snapshots. O Autofill pode alterar o PR real enquanto o evento original continua com o body anterior. Por isso o GPA usa a seguinte passagem: +Payloads de `pull_request_target` são snapshots. O Autofill pode alterar o PR real enquanto o evento original mantém o body anterior. O GPA altera o PR vivo, valida esse estado, aguarda um `workflow_run` de Guardrails bem-sucedido e então busca novamente o PR antes do Sync. -1. Autofill altera o PR real pela API do GitHub. -2. Guardrails valida o **PR vivo**. -3. Guardrails bem-sucedido gera um `workflow_run` separado. -4. PR Sync busca novamente o **PR vivo** antes de sincronizar. +## Contrato de PR de implementação -Esse é o mesmo tipo de stale state resolvido pelo fluxo de governança do Take Your Pills ao serializar guardrails antes de hygiene. +PRs de implementação usam uma issue/task canônica. `Closes #N`, `Fixes #N` e `Resolves #N` continuam autoritativos para a resolução interna do GPA. -## Fluxo de implementação +Estado nativo sincronizado: ```text -branch de implementação - -> resolução por branch/body - -> uma issue/task canônica - -> Autofill Linked Issue + Milestone - -> Guardrails - -> Implementation Sync +task canônica -> labels / milestone / assignees no PR + -> relação pai/sub-issue -> lifecycle da task no Project v2 + -> lifecycle do próprio PR de implementação no Project v2 ``` -`Closes #N`, `Fixes #N` e `Resolves #N` já existentes continuam autoritativos. +Task e PR usam o mesmo mapeamento de lifecycle. Isso faz o PR aparecer no campo nativo `Projects`, em vez de manter somente a issue no board. -## Fluxo de promoção +### Relação Development nativa -`promotionPaths` são regras de roteamento, não regras de skip. +Closing keywords do GitHub criam vínculo nativo apenas quando o PR aponta para a branch default. Como o lane de implementação do GPA normalmente aponta para `develop`, a referência do body é suficiente para o GPA, mas não para o sidebar Development. -Caminhos versionados: +O caminho nativo suportado pelo GPA usa Linked Branch: ```text -develop -> Q.A -Q.A -> main +issue + -> project_setup.linked_branch / createLinkedBranch + -> branch de implementação vinculada à issue + -> abrir PR dessa branch para develop + -> GitHub transfere o vínculo da branch para o PR + -> Development mostra o PR ``` -Uma promoção representa um agregado de PRs de implementação e nunca deve escolher uma “primeira task” arbitrária como fonte de verdade. - -### Related PR Detection - -O detector une e deduplica: - -1. PRs mergeados cujas branches de origem correspondem aos regex configurados; -2. referências explícitas em seções como `## Related PRs`; -3. referências de implementação herdadas de uma promoção anterior. - -Patterns default propositalmente amplos: - -```text -^feat/ -^fix/ -^docs/ -^refactor/ -^test/ -^hotfix/ -^phase/ -^task/ -^chore/ -^ci/ -^release/ -``` - -O repositório de destino pode substituir a lista inteira via `prAutomation.relatedPrs.branchPatterns`. - -### Janela de detecção - -Em `develop -> Q.A`, a autodetecção começa depois da última promoção `develop -> Q.A` mergeada. - -Em `Q.A -> main`, o GPA considera promoções mergeadas em `Q.A` depois da última `Q.A -> main` e herda os PRs de implementação dessas promoções. Trabalho que ficou somente em `develop` não é atribuído ao `main`. - -Sem promoção anterior, `fallbackDays` limita a primeira busca. Referências explícitas no body não dependem dessa janela. - -## Promotion Autofill - -O Autofill de promoção pode preencher deterministicamente: - -- `## Related PRs`; -- `## Linked Issue` a partir das closing references dos PRs constituintes; -- `## Milestone` com os milestones dos PRs constituintes; -- `## Summary` somente enquanto a seção ainda for placeholder. - -Resumo, evidências, riscos, testes e DoD escritos por humano são preservados. - -## Contrato de metadata nativa da promoção - -Promotion Sync usa os **objetos dos Related PRs** como fonte agregada para os campos nativos do PR de promoção. +O nome da branch é definido por quem configura o repositório; não depende de `US-*`. Branches `feat/`, `fix/`, `task/` ou qualquer convenção escolhida continuam válidas. PRs comuns já existentes precisam ser vinculados manualmente pelo GitHub porque a API pública cria uma nova Linked Branch em vez de converter uma branch existente. -### Labels +## Contrato de promoção -Somente famílias gerenciadas são sincronizadas. Default: +`promotionPaths` são regras de roteamento, não skip: ```text -type: -priority: -test: -``` - -Cada família exige **consenso**: - -```text -#101 priority:high -#102 priority:high -#103 priority:high - -> promoção priority:high +develop -> Q.A +Q.A -> main ``` -Se houver divergência ou ausência, o GPA não escolhe um valor arbitrário. A família gerenciada é omitida do PR de promoção e o conflito aparece no comentário sticky do Promotion Sync. - -Labels manuais/não gerenciadas são preservadas. +Uma promoção representa um conjunto de PRs de implementação e nunca escolhe uma primeira task arbitrária. -### Milestone +### Related PR Detection -Milestone também é single-value e exige consenso. Um único milestone unânime é aplicado à promoção. Ausência/divergência remove o milestone sincronizado em vez de escolher um aleatoriamente. +O detector une/deduplica PRs mergeados encontrados pelos regex configurados, referências explícitas do body e referências herdadas de promoções anteriores. Os patterns default (`feat/`, `fix/`, `docs/`, `refactor/`, `test/`, `hotfix/`, `phase/`, `task/`, `chore/`, `ci/`, `release/`) são exemplos amplos e substituíveis. -### Assignees +Para `develop -> Q.A`, a busca automática começa depois da última promoção equivalente mergeada. Para `Q.A -> main`, o GPA herda somente os PRs constituintes das promoções que realmente chegaram em Q.A, evitando atribuir ao main trabalho que permaneceu apenas em develop. -Assignees são multi-value. Promotion Sync aplica a união deduplicada dos assignees dos Related PRs. +### Metadata nativa de promoção -### Project v2 +Promotion Sync usa os objetos dos Related PRs como fonte agregada: -Implementation Sync mantém o comportamento atual: a **issue/task vinculada** é o item de trabalho no Project v2. +- famílias de labels gerenciadas exigem consenso; +- milestone exige unanimidade; +- assignees usam união deduplicada; +- labels externas são preservadas; +- conflitos são reportados, não adivinhados; +- o próprio PR de promoção é item do Project v2; +- backlinks por estágio são idempotentes. -Promotion Sync passa a adicionar também o **próprio PR de promoção** ao Project v2, porque a promoção possui lifecycle independente de review/release. +## Contrato do Project v2 -Mapeamento default: +Lifecycle padrão: -| Estado da promoção | Project Status | +| Estado do PR | Project Status | | --- | --- | | Draft | `In progress` | | Open / review | `In review` | | Fechado sem merge | `In progress` | | Mergeado | `Done` | -Isso é o que torna o campo nativo `Projects` do sidebar significativo para PRs de promoção quando `PROJECT_SETUP_PROJECT_NUMBER` e `PROJECT_SETUP_PAT` estão configurados. - -## Validação de promoção - -Guardrails verifica que: - -- head/base formam um `promotionPath` configurado; -- existe pelo menos um PR mergeado na seção Related PR; -- todos os PRs referenciados realmente foram mergeados; -- Related PRs autodetectados não foram omitidos silenciosamente. - -Promoção possui contrato próprio; não é bypass de validação. - -## Roteamento do PR Sync - -`.github/workflows/pr-sync.yml` executa `project_setup.pr_sync_router`. +Operações de Project usam `PROJECT_SETUP_PAT`. A resolução do board é determinística: ```text -PR de implementação -> project_setup.pr_sync -PR de promoção -> project_setup.promotion_sync +--project-number explícito + ↓ senão +PROJECT_SETUP_PROJECT_NUMBER + ↓ senão, se houver PAT +nome exato único == projectDefinitionFile.name + ↓ +zero matches -> skip com diagnóstico +mais de um -> falha, nunca escolhe por chute ``` -`project_setup.related_prs` cuida de detecção, Autofill e validação. `project_setup.promotion_sync` cuida de metadata nativa agregada, membership/status da promoção no Project e backlinks. +A sincronização restrita ao repositório continua funcionando quando Project não está disponível. ## Fronteira de autenticação -Mutações dentro do repositório usam o token nativo do Actions: - -```yaml -permissions: - contents: read - issues: write - pull-requests: write -``` - -Isso cobre labels, milestone, assignees, comentários e backlinks. - -Projects v2 usa a credencial separada opcional: - -```text -PROJECT_SETUP_PAT -``` +Mutações normais de PR/issue usam `${{ github.token }}` com permissões restritas. Projects v2 usa `PROJECT_SETUP_PAT`. A criação explícita local/live de Linked Branch também precisa de credencial com escrita no repositório, pois cria uma branch real pela API GraphQL do GitHub. -`PROJECT_SETUP_PROJECT_NUMBER` identifica o Project. Sem um deles, a metadata nativa do PR continua funcionando e apenas a sincronização do Project é reportada como skipped. +Workflows privilegiados executam código confiável da base/default, excluem forks das mutações privilegiadas e usam `persist-credentials: false`. ## Contrato de regressão live -O lane protegido `Q.A -> main` precisa provar no sandbox descartável: +O lane protegido `Q.A -> main` precisa provar estado nativo, não comentários: ```text -Implementation PR Sync - -> labels presentes no PR - -> milestone presente no PR - -> assignee presente no PR - -> task vinculada no Project v2 / In review +Metadata de implementação + -> labels / milestone / assignee no PR -> base não-default funciona -Promotion Sync - -> dois PRs constituintes reais mergeados na branch-fonte - -> labels por consenso no PR de promoção - -> milestone por consenso no PR de promoção - -> união de assignees no PR de promoção - -> próprio PR de promoção no Project v2 / In review - -> após merge, Status do PR de promoção -> Done - -> backlinks convergem para merged +Lifecycle de Project da implementação + -> task vinculada no Project / In review + -> próprio PR de implementação no Project / In review + +Lifecycle de promoção + -> PRs constituintes reais mergeados + -> metadata por consenso no PR de promoção + -> PR de promoção no Project / In review + -> promoção mergeada -> Done + -> backlinks convergem + +Development + -> branch criada com createLinkedBranch + -> PR aberto contra base não-default + -> PR aparece nas referências Development user-linked da issue Cleanup -> PRs/issues descartáveis fechados - -> branches removidas - -> Project removido - -> milestone removido - -> labels removidas - -> deployments Q.A históricos limpos + -> branches, Project, milestone e labels removidos + -> deployments históricos de Q.A limpos ``` -Um comentário sticky isolado nunca é evidência suficiente de que a sincronização estruturada funcionou. - -## Invariantes de segurança - -- workflows privilegiados executam código confiável da base/default branch; -- código não confiável do head não roda com credenciais de escrita; -- forks são excluídos das mutações privilegiadas; -- checkouts privilegiados usam `persist-credentials: false`; -- Guardrails precisa passar antes do Sync normal; -- o PR vivo é lido novamente depois do Autofill; -- conflitos de metadata single-value são tratados de forma fail-safe, sem chute; -- credenciais de Project v2 continuam isoladas das mutações normais do repositório. +Comentário sticky isolado nunca é aceito como prova da sincronização estruturada. From e3375bf52c3ffa7d853b28a73621cb1d61c451e2 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Mon, 24 Aug 2026 00:14:22 -0300 Subject: [PATCH 28/28] test: exercise Vintex GPA sandbox flow Sandbox-only marker used to validate GPA Autofill, Guardrails, PR Sync and promotion behavior. --- sandbox/vintex-vs014-fe-smoke.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 sandbox/vintex-vs014-fe-smoke.md diff --git a/sandbox/vintex-vs014-fe-smoke.md b/sandbox/vintex-vs014-fe-smoke.md new file mode 100644 index 0000000..6e7cf0a --- /dev/null +++ b/sandbox/vintex-vs014-fe-smoke.md @@ -0,0 +1,10 @@ +# Vintex GPA Sandbox — VS-014 FE + +Disposable implementation marker used to exercise Github Project Automation against the GPA repository itself. + +- Canonical story: VS-014 +- Layer: Front-end +- Sandbox task: #81 +- Purpose: validate branch detection, PR Autofill, Guardrails, PR Sync, labels, assignee, milestone and Project lifecycle without touching Vintex-Ages repositories. + +No production GPA behavior is changed by this file.