diff --git a/.github/workflows/qa-live.yml b/.github/workflows/qa-live.yml
index 5f0440d..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: 15
+ timeout-minutes: 30
environment:
name: qa
deployment: false
@@ -80,3 +80,30 @@ 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 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 }}
+ 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}"
+
+ - 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}"
diff --git a/docs/repo/pr-governance-architecture.md b/docs/repo/pr-governance-architecture.md
index cf85c4e..4e002e7 100644
--- a/docs/repo/pr-governance-architecture.md
+++ b/docs/repo/pr-governance-architecture.md
@@ -4,313 +4,197 @@
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:
-
-- implementation PRs use one canonical linked issue/task;
-- promotion PRs use an aggregate set of related 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]
+ 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 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]
+ R -->|Promotion| PAF[Related PR Detection]
+ PAF --> PB[Configured branch patterns]
PAF --> PE[Explicit body references]
- PAF --> PI[Inherited references from prior promotion PRs]
- PB --> PM[Aggregate and deduplicate]
+ PAF --> PI[Inherited promotion references]
+ 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 -- 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]
+ LIFE[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[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 --> 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 --> 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 --> QAC[Clean sandbox resources and historical Q.A 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. If Autofill changes a PR body and a synchronization stage immediately consumes the original event payload, that stage can observe stale metadata.
-
-The safe handoff is therefore:
-
-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.
+`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.
-This is the same architectural lesson learned in Take Your Pills after stale PR state was observed between independent automation stages.
+## Implementation PR contract
-## Implementation PR flow
+Implementation PRs use one canonical issue/task. Existing `Closes #N`, `Fixes #N`, and `Resolves #N` body references remain authoritative for GPA metadata resolution.
-Implementation PRs keep the existing deterministic model:
+The synchronized native state is:
```text
-branch
- -> explicit issue/task token or configured backlog mapping
- -> one canonical issue/task
- -> Linked Issue + Milestone
- -> Guardrails
- -> PR Sync
+canonical task
+ -> PR labels / milestone / assignees
+ -> parent/sub-issue relationship
+ -> task Project v2 lifecycle
+ -> implementation PR Project v2 lifecycle
```
-Closing references such as `Closes #123`, `Fixes #123`, or `Resolves #123` remain authoritative when already present.
+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.
-## Promotion PR flow
+### Native Development relationship
-Configured promotion paths are **routing rules**, not skip rules.
+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.
-The committed GPA paths are:
+GPA therefore supports GitHub Linked Branches as the native Development path:
```text
-develop -> Q.A
-Q.A -> main
+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 promotion PR receives an aggregate context instead of one implementation task.
-
-### Related PR Detection
-
-GPA combines two primary discovery mechanisms and one propagation mechanism:
-
-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.
+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.
-The result is unioned and deduplicated.
+## Promotion PR contract
-Default branch patterns are intentionally broad examples:
+Configured promotion paths are routing rules, not skip rules:
```text
-^feat/
-^fix/
-^docs/
-^refactor/
-^test/
-^hotfix/
-^phase/
-^task/
-^chore/
-^ci/
-^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/"]
- }
- }
-}
+develop -> Q.A
+Q.A -> main
```
-Explicit body references continue to work even when the referenced PR branch does not match a configured branch pattern.
-
-### Detection window
-
-For `develop -> Q.A`, automatic discovery considers PRs merged into `develop` 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`.
-
-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.
-
-## Promotion Autofill contract
-
-For promotion PRs, Related PR Detection may 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.
+A promotion represents a set of implementation PRs and never selects an arbitrary first task as its source of truth.
-Human-authored summaries, risks, evidence, testing notes, and DoD decisions are preserved.
-
-## Promotion validation contract
-
-Promotion Guardrails verify that:
-
-- 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.
-
-Promotion PRs therefore no longer mean "skip validation". They use a different validation contract.
-
-## PR Sync routing
-
-`.github/workflows/pr-sync.yml` invokes `project_setup.pr_sync_router`.
-
-The router performs exactly one of two modes:
+### Related PR Detection
-```text
-implementation PR -> project_setup.pr_sync
-promotion PR -> project_setup.related_prs promotion sync
-```
+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.
-Implementation Sync continues to own task-derived metadata, parent/sub-issue linkage, and optional Project v2 lifecycle synchronization.
+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`.
-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.
+### Promotion native metadata
-## Workflow responsibilities
+Promotion Sync treats Related PR objects as the aggregate source of truth:
-### `.github/workflows/pr-metadata.yml` — Guardrails
+- 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.
-Execution order:
+## Project v2 contract
-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.
+Default lifecycle mapping:
-### `.github/workflows/pr-sync.yml` — Sync/Hygiene
+| PR state | Project Status |
+| --- | --- |
+| Draft | `In progress` |
+| Open / review | `In review` |
+| Closed without merge | `In progress` |
+| Merged | `Done` |
-Normal synchronization runs from:
+Project operations use `PROJECT_SETUP_PAT`. Target Project resolution is deterministic:
```text
-workflow_run(PR metadata validation = success)
-```
-
-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"}
- ]
- }
- }
-}
+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
```
-There is no promotion skip switch in the committed configuration. `promotionPaths` selects promotion mode.
+Repository-scoped synchronization continues when Project configuration is unavailable.
## Authentication boundary
-Repository-scoped PR/issue operations use the built-in Actions token:
+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.
-```text
-github.token
-```
-
-The relevant workflows request:
-
-```yaml
-permissions:
- contents: read
- issues: write
- 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.
+Privileged workflows execute trusted base/default-branch code, exclude forks from privileged mutations, and use `persist-credentials: false` on trusted checkouts.
-## Security invariants
-
-- 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.
-
-## Regression contract
-
-A new implementation PR must converge without a second event:
-
-```text
-Implementation Autofill
- -> validate live PR
- -> workflow_run
- -> refetch live PR
- -> Implementation Sync
-```
+## Live regression contract
-A promotion PR must converge without manually constructing a fake single-task link:
+The protected `Q.A -> main` lane is fail-closed and must prove native state, not comments:
```text
-Related PR Detection
- -> aggregate body Autofill
- -> validate live promotion context
- -> workflow_run
- -> Promotion Sync
- -> backlinks
+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, Project, milestone and labels removed
+ -> historical Q.A deployments cleaned
```
-Both branch-pattern detection and explicit body references are first-class inputs, and the branch patterns must remain replaceable through configuration.
+A sticky status comment alone is never accepted as proof of structured synchronization.
diff --git a/docs/repo/pr-governance-architecture.pt-BR.md b/docs/repo/pr-governance-architecture.pt-BR.md
index 661c921..f2a7eda 100644
--- a/docs/repo/pr-governance-architecture.pt-BR.md
+++ b/docs/repo/pr-governance-architecture.pt-BR.md
@@ -4,313 +4,197 @@
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:
-
-- PRs de implementação usam uma issue/task canônica;
-- PRs de promoção usam um conjunto agregado de PRs relacionados.
+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]
+ 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 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[Patterns de branch 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 de promoções]
+ 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]
+ IV --> OK{Guardrails passou?}
+ PV --> OK
+ OK -- Não --> STOP[Interromper governança]
+ OK -- Sim --> WR[workflow_run]
WR --> S[PR Sync Router]
+ LIFE[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[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 --> 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 --> 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[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. Se o Autofill altera o body do PR e uma etapa seguinte usa o payload original, ela pode consumir metadata antiga.
-
-A passagem segura é:
-
-1. Autofill altera o pull request real pela API do GitHub.
-2. Guardrails valida o **PR vivo**.
-3. O sucesso do Guardrails gera um novo evento `workflow_run`.
-4. PR Sync busca novamente o **PR vivo** antes de sincronizar.
+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.
-Esse é o mesmo aprendizado arquitetural usado no Take Your Pills após o problema de stale state entre automações independentes.
+## Contrato de PR de implementação
-## Fluxo de PR de implementação
+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.
-PRs de implementação preservam o modelo determinístico:
+Estado nativo sincronizado:
```text
-branch
- -> token explícito de issue/task ou mapeamento configurado
- -> uma issue/task canônica
- -> Linked Issue + Milestone
- -> Guardrails
- -> PR 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
```
-Referências já informadas como `Closes #123`, `Fixes #123` ou `Resolves #123` continuam sendo autoritativas.
+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 PR de promoção
+### Relação Development nativa
-`promotionPaths` passam a ser **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.
-Os caminhos configurados no GPA são:
+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 recebe um contexto agregado em vez de uma falsa task única.
-
-### 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 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.
-Os resultados são unidos e deduplicados.
+## Contrato de promoção
-Os padrões default são propositalmente amplos como exemplos:
+`promotionPaths` são regras de roteamento, não skip:
```text
-^feat/
-^fix/
-^docs/
-^refactor/
-^test/
-^hotfix/
-^phase/
-^task/
-^chore/
-^ci/
-^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/"]
- }
- }
-}
+develop -> Q.A
+Q.A -> main
```
-Referências explícitas no body continuam funcionando mesmo quando a branch do PR referenciado não corresponde a nenhum pattern configurado.
-
-### 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.
-
-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`.
-
-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.
-
-## Contrato do Promotion Autofill
-
-Em PRs de promoção, o detector 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;
-- `## Summary` somente enquanto a seção ainda for placeholder.
+Uma promoção representa um conjunto de PRs de implementação e nunca escolhe uma primeira task arbitrária.
-Resumo escrito por humano, riscos, evidências, instruções de teste e decisões de DoD são preservados.
-
-## Contrato de validação de promoção
-
-Guardrails de promoção verificam que:
-
-- 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.
-
-Portanto, promotion PR não significa mais “pular validação”. Ele usa um contrato de validação próprio.
-
-## Roteamento do PR Sync
-
-`.github/workflows/pr-sync.yml` executa `project_setup.pr_sync_router`.
-
-O router escolhe exatamente um modo:
+### Related PR Detection
-```text
-PR de implementação -> project_setup.pr_sync
-PR de promoção -> project_setup.related_prs Promotion Sync
-```
+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.
-Implementation Sync continua responsável por metadata derivada da task, vínculo pai/sub-issue e lifecycle opcional no Project v2.
+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.
-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.
+### Metadata nativa de promoção
-## Responsabilidades dos workflows
+Promotion Sync usa os objetos dos Related PRs como fonte agregada:
-### `.github/workflows/pr-metadata.yml` — Guardrails
+- 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.
-Ordem interna:
+## Contrato do Project v2
-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.
+Lifecycle padrão:
-### `.github/workflows/pr-sync.yml` — Sync/Hygiene
+| Estado do PR | Project Status |
+| --- | --- |
+| Draft | `In progress` |
+| Open / review | `In review` |
+| Fechado sem merge | `In progress` |
+| Mergeado | `Done` |
-Sincronização normal é acionada por:
+Operações de Project usam `PROJECT_SETUP_PAT`. A resolução do board é determinística:
```text
-workflow_run(PR metadata validation = success)
-```
-
-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"}
- ]
- }
- }
-}
+--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
```
-Não existe mais um switch de skip de promoção na configuração versionada. `promotionPaths` seleciona o modo de promoção.
+A sincronização restrita ao repositório continua funcionando quando Project não está disponível.
## Fronteira de autenticação
-Operações de PR/issue dentro do repositório usam:
+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.
-```text
-github.token
-```
-
-Os workflows relevantes solicitam:
-
-```yaml
-permissions:
- contents: read
- issues: write
- 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.
+Workflows privilegiados executam código confiável da base/default, excluem forks das mutações privilegiadas e usam `persist-credentials: false`.
-## Invariantes de segurança
-
-- 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.
-
-## Contrato de regressão
-
-Um PR de implementação novo deve convergir sem exigir um segundo evento:
-
-```text
-Implementation Autofill
- -> validar PR vivo
- -> workflow_run
- -> buscar PR vivo
- -> Implementation Sync
-```
+## Contrato de regressão live
-Um PR de promoção deve convergir sem inventar uma task única:
+O lane protegido `Q.A -> main` precisa provar estado nativo, não comentários:
```text
-Related PR Detection
- -> Autofill agregado do body
- -> validar contexto vivo de promoção
- -> workflow_run
- -> Promotion Sync
- -> backlinks
+Metadata de implementação
+ -> labels / milestone / assignee no PR
+ -> base não-default funciona
+
+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, Project, milestone e labels removidos
+ -> deployments históricos de Q.A limpos
```
-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.
+Comentário sticky isolado nunca é aceito como prova da sincronização estruturada.
diff --git a/docs/repo/pr-sync.md b/docs/repo/pr-sync.md
index 0167bd0..4a7c6fe 100644
--- a/docs/repo/pr-sync.md
+++ b/docs/repo/pr-sync.md
@@ -4,102 +4,111 @@
**Implemented.**
-PR Sync is GPA's post-Guardrails synchronization lane. It now supports two distinct contexts:
-
-- **Implementation Sync** — one canonical linked issue/task;
-- **Promotion Sync** — an aggregate manifest of related pull requests.
-
-The public workflow remains `.github/workflows/pr-sync.yml`, while `project_setup/pr_sync_router.py` chooses the correct synchronization mode.
-
-## Pipeline
+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
-PR event
- -> Autofill
- -> Guardrails
- -> workflow_run on success
- -> PR Sync Router
- -> Implementation Sync
- -> Promotion Sync
+Implementation PR -> project_setup.pr_sync + PR Project membership
+Promotion PR -> project_setup.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.
+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.
+
+## 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`, and `closed`.
## Implementation Sync
-Implementation PRs use `project_setup/pr_sync.py`.
+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 linked issue/task is identified by a closing reference:
+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.
-```text
-Closes #123
-Fixes #123
-Resolves #123
-```
+Default lifecycle:
-The linked task can provide:
+| PR state | Project status |
+| --- | --- |
+| Draft | `In progress` |
+| Open / review | `In review` |
+| Closed without merge | `In progress` |
+| Merged | `Done` |
-- configured label families;
-- milestone;
-- assignees;
-- parent/sub-issue relationship;
-- optional Project v2 membership/status.
+## Native Development relationship on non-default branches
-If the task has no assignee and `assignAuthorWhenTaskUnassigned` is enabled, the PR author can be assigned to the task and synchronized to the PR.
+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.
-### Default Project lifecycle
+For native Development linkage, create the implementation branch as a GitHub **Linked Branch** before opening the PR:
-| 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` |
+```bash
+python -m project_setup.linked_branch \
+ --repo owner/repository \
+ --issue 123 \
+ --branch feat/issue-123-example \
+ --base develop \
+ --live
+```
-Project v2 operations remain optional and use `PROJECT_SETUP_PAT`. Ordinary PR/issue mutations use the built-in Actions token.
+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.
-## Promotion Sync
+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.
-Promotion paths are not skipped anymore at the workflow level. They route to aggregate Promotion Sync.
+## Promotion Sync
-Committed paths:
+Promotion paths are not skipped. Committed paths are:
```text
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.
+Promotion Sync reads the validated `## Related PRs` manifest and never selects a first issue as a fake canonical task. It:
-For example:
+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.
-```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
-```
+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.
-Related PR discovery and promotion-body Autofill happen before Guardrails in `project_setup.related_prs`; see `pr-governance-architecture.md`.
+## Project v2 resolution
-## Related PR Detection
+Project operations require `PROJECT_SETUP_PAT`. GPA resolves the target board in this order:
+
+1. explicit `--project-number`;
+2. `PROJECT_SETUP_PROJECT_NUMBER`;
+3. if a Project PAT exists, exact unique title match using the `name` in `projectDefinitionFile`.
+
+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.
-The detector unions and deduplicates:
+Repository-scoped labels, milestone, assignees, Related PRs, and backlinks continue to work when Project configuration is absent.
-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.
+## Related PR Detection
+
+`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.
-Default branch patterns are deliberately broad configuration examples:
+Default branch-pattern examples are intentionally broad and fully replaceable:
```text
^feat/
@@ -115,8 +124,6 @@ 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.
-
## Configuration
```json
@@ -125,17 +132,8 @@ A repository can replace the entire list. Explicit body references remain valid
"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,
@@ -168,67 +166,23 @@ 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`:
+## Authentication and security
-- `ready_for_review`;
-- `converted_to_draft`;
-- `closed`.
-
-Both paths execute trusted base-branch automation. Fork PRs are excluded from privileged mutations.
-
-## Authentication and permissions
-
-Repository-scoped synchronization uses `${{ github.token }}` with:
-
-```yaml
-permissions:
- contents: read
- issues: write
- pull-requests: write
-```
+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.
-`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.
-
-## 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.
-
-## Security model
-
-- 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
-```
+Privileged Actions continue to run trusted base/default-branch code; untrusted PR head code is never executed with write credentials.
-Existing target files remain subject to the installer's preserve-by-default behavior.
+## Live validation
-## Validation
+The protected `Q.A -> main` lane now verifies:
-Coverage is split between:
+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.
-- `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.
+A sticky comment alone is never sufficient evidence: the tests re-read the native GitHub objects/Project state.
-Live Project v2 and Q.A integration behavior remain sandbox concerns rather than destructive source-repository tests.
+See `pr-governance-architecture.md` for the overall governance model.
diff --git a/docs/repo/pr-sync.pt-BR.md b/docs/repo/pr-sync.pt-BR.md
index fdec47a..9d551c6 100644
--- a/docs/repo/pr-sync.pt-BR.md
+++ b/docs/repo/pr-sync.pt-BR.md
@@ -4,102 +4,111 @@
**Implementado.**
-PR Sync é o fluxo de sincronização executado após Guardrails no GPA. Ele agora possui dois contextos distintos:
-
-- **Implementation Sync** — uma issue/task canônica;
-- **Promotion Sync** — um manifesto agregado de pull requests relacionados.
-
-O workflow público continua sendo `.github/workflows/pr-sync.yml`, enquanto `project_setup/pr_sync_router.py` escolhe o modo correto.
-
-## Pipeline
+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
-Evento de PR
- -> Autofill
- -> Guardrails
- -> workflow_run em caso de sucesso
- -> PR Sync Router
- -> Implementation Sync
- -> Promotion Sync
+PR de implementação -> project_setup.pr_sync + membership do PR no Project
+PR de promoção -> project_setup.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.
+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 `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 usam `project_setup/pr_sync.py`.
+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 issue/task vinculada é identificada por closing reference:
+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.
-```text
-Closes #123
-Fixes #123
-Resolves #123
-```
+Lifecycle padrão:
-A task pode fornecer:
+| Estado do PR | Project Status |
+| --- | --- |
+| Draft | `In progress` |
+| Open / review | `In review` |
+| Fechado sem merge | `In progress` |
+| Mergeado | `Done` |
-- famílias configuradas de labels;
-- milestone;
-- assignees;
-- relação pai/sub-issue;
-- membership/status opcional no Project v2.
+## Development nativo em PR para branch não-default
-Se a task estiver sem assignee e `assignAuthorWhenTaskUnassigned` estiver habilitado, o autor do PR pode ser atribuído à task e sincronizado com o PR.
+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.
-### Lifecycle padrão no Project
+Para obter o vínculo Development nativo, crie a branch de implementação como uma **Linked Branch** antes de abrir o PR:
-| 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` |
+```bash
+python -m project_setup.linked_branch \
+ --repo owner/repository \
+ --issue 123 \
+ --branch feat/issue-123-exemplo \
+ --base develop \
+ --live
+```
-Operações de Project v2 continuam opcionais e usam `PROJECT_SETUP_PAT`. Mutações comuns de PR/issues usam o token nativo do Actions.
+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.
-## Promotion Sync
+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.
-Promotion paths não são mais ignorados pelo workflow. Eles são roteados para Promotion Sync agregado.
+## Promotion Sync
-Caminhos versionados:
+Promotion paths não são pulados. Os caminhos versionados são:
```text
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.
+Promotion Sync lê o manifesto validado `## Related PRs` e nunca escolhe a primeira issue como falsa task canônica. Ele:
-Exemplo:
+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.
-```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
-```
+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.
-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`.
+## Resolução do Project v2
-## Related PR Detection
+Operações de Project exigem `PROJECT_SETUP_PAT`. O GPA resolve o board alvo nesta ordem:
+
+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`.
+
+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.
-O detector une e deduplica:
+Labels, milestone, assignees, Related PRs e backlinks continuam funcionando sem configuração de Project.
-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.
+## Related PR Detection
+
+`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.
-Os patterns default são propositalmente amplos como exemplos:
+Patterns default são exemplos amplos e totalmente substituíveis:
```text
^feat/
@@ -115,8 +124,6 @@ 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.
-
## Configuração
```json
@@ -125,17 +132,8 @@ O repositório pode substituir a lista inteira. Referências explícitas no body
"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,
@@ -168,67 +166,23 @@ 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`:
+## Autenticação e segurança
-- `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.
-
-## Autenticação e permissões
-
-Sincronização restrita ao repositório usa `${{ github.token }}` com:
-
-```yaml
-permissions:
- contents: read
- issues: write
- pull-requests: write
-```
+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.
-`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.
-
-## 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.
-
-## Modelo de segurança
-
-- 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
-```
+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.
-Arquivos existentes no target continuam sujeitos ao comportamento preserve-by-default do instalador.
+## Validação live
-## Validação
+O lane protegido `Q.A -> main` agora exige:
-A cobertura fica dividida em:
+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.
-- `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.
+Comentário sticky, sozinho, nunca é evidência suficiente: os testes releem os objetos nativos do GitHub e o estado do Project.
-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 geral de governança.
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())
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}."
diff --git a/project_setup/pr_sync_router.py b/project_setup/pr_sync_router.py
index ae4da2f..e84f124 100644
--- a/project_setup/pr_sync_router.py
+++ b/project_setup/pr_sync_router.py
@@ -6,13 +6,19 @@
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 .related_prs import apply_promotion_sync, is_promotion_context
+from .project_lookup import resolve_project_number
+from .promotion_sync import apply_promotion_sync
+from .related_prs import is_promotion_context
def load_event(path: str | os.PathLike[str]) -> dict:
@@ -37,19 +43,51 @@ 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,
)
- 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:
@@ -73,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,
)
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")}
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."
+ )
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
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.
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.
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())
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())
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
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())
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()
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()
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)
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()