diff --git a/.github/workflows/pr-sync.yml b/.github/workflows/pr-sync.yml new file mode 100644 index 0000000..c089f47 --- /dev/null +++ b/.github/workflows/pr-sync.yml @@ -0,0 +1,67 @@ +--- +name: PR Sync + +"on": + pull_request_target: + types: + - converted_to_draft + - closed + workflow_run: + workflows: ["PR metadata validation", "PR guardrails"] + types: [completed] + +permissions: + contents: read + issues: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.ref || github.run_id }} + cancel-in-progress: true + +jobs: + sync: + if: >- + ( + github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name == github.repository + ) || ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request_target' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.pull_requests[0].number != null + ) + runs-on: ubuntu-latest + steps: + - name: Checkout trusted base commit for direct PR lifecycle events + if: github.event_name == 'pull_request_target' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Checkout trusted base branch after PR guardrails + if: github.event_name == 'workflow_run' + uses: actions/checkout@v6 + with: + ref: refs/heads/${{ github.event.workflow_run.pull_requests[0].base.ref }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Synchronize pull request context + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + PROJECT_SETUP_PROJECT_NUMBER: ${{ vars.PROJECT_SETUP_PROJECT_NUMBER }} + PROJECT_SETUP_OWNER_TYPE: ${{ vars.PROJECT_SETUP_OWNER_TYPE }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + python -m project_setup.pr_sync \ + --repo "$GITHUB_REPOSITORY" \ + --event-path "$GITHUB_EVENT_PATH" diff --git a/README.md b/README.md index 358c8d5..850d25a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ `project_setup` is a self-contained toolkit for installing and operating reusable GitHub repository automation. It combines a Makefile, a Python CLI, GitHub Actions workflows, manifests, and validation scripts so repositories can be configured manually, through automation, or with AI assistance without hiding what will be changed. -The project focuses on safe setup of labels, milestones, issues, sub-issues, pull-request guardrails, repository discovery, and GitHub Projects v2. Remote mutating commands default to dry-run and require an explicit live mode before writing to GitHub. +The project focuses on safe setup of labels, milestones, issues, sub-issues, pull-request guardrails, **PR Sync**, repository discovery, and GitHub Projects v2. Remote mutating commands default to dry-run and require an explicit live mode before writing to GitHub. If an AI assistant will perform or guide the setup, give it [`AI_SETUP_GUIDE.md`](AI_SETUP_GUIDE.md). That file tells the agent to inspect existing repository conventions before asking questions, pause at manual/credential/live checkpoints, re-verify user changes before continuing, and avoid duplicate resources. @@ -26,6 +26,7 @@ Typical uses include: - optionally link generated tasks as sub-issues; - create and synchronize GitHub Projects v2 owned by a personal account or GitHub Organization; - validate pull-request metadata and repository conventions; +- synchronize implementation PRs with their linked tasks and optional Project v2 lifecycle state through PR Sync; - inspect a repository and recommend a setup flow before applying it; - expose the same operations to humans, scripts, and AI agents through predictable Make/CLI commands. @@ -71,7 +72,7 @@ PROJECT_SETUP_CONFIG=project_setup.json PROJECT_SETUP_PROJECT_NUMBER= ``` -`PROJECT_SETUP_TARGET` is the local filesystem path. `GITHUB_REPOSITORY` is the GitHub `owner/repository` identifier. `PROJECT_SETUP_OWNER_TYPE` selects who owns GitHub Projects v2: use `user` for a personal account or `organization` for a company/team GitHub Organization. It may be left empty for authenticated auto-detection. Once a Project v2 exists, `PROJECT_SETUP_PROJECT_NUMBER` can store its number for `make project-sync`. +`PROJECT_SETUP_TARGET` is the local filesystem path. `GITHUB_REPOSITORY` is the GitHub `owner/repository` identifier. `PROJECT_SETUP_OWNER_TYPE` selects who owns GitHub Projects v2: use `user` for a personal account or `organization` for a company/team GitHub Organization. It may be left empty for authenticated auto-detection. Once a Project v2 exists, `PROJECT_SETUP_PROJECT_NUMBER` can store its number for `make project-sync` and is also the value PR Sync expects as an Actions repository variable when Project synchronization is enabled. If the tool is already embedded in and operated from the target repository itself, use: @@ -125,7 +126,7 @@ make setup TARGET=../other-project REPO=owner/other-project OWNER_TYPE=organizat | Repository operations inside GitHub Actions | `${{ github.token }}` exposed as `GITHUB_TOKEN` | [Automatic — no custom secret](#automatic-repository-token) | | Local labels, milestones, issues, comments, and similar repository operations | valid `gh auth`, `GITHUB_TOKEN`, `GH_TOKEN`, or `PROJECT_SETUP_PAT` | [Manual/configured](#local-authentication) | | Local GitHub Projects v2 | `PROJECT_SETUP_PAT` in `.env` | [Manual/configured PAT](#projects-v2-authentication) | -| GitHub Projects v2 from Actions | repository secret `PROJECT_SETUP_PAT` | [Manual/configured PAT + secret](#projects-v2-authentication) | +| GitHub Projects v2 from Actions / PR Sync | repository secret `PROJECT_SETUP_PAT` plus repository variable `PROJECT_SETUP_PROJECT_NUMBER` | [Manual/configured PAT + Actions configuration](#projects-v2-authentication) | ### Automatic repository token @@ -168,10 +169,26 @@ PROJECT_SETUP_PAT=ghp_your_token_here For Actions: repository **Settings** → **Secrets and variables** → **Actions** → **New repository secret** → `PROJECT_SETUP_PAT`. +When PR Sync should update Project v2, also create the Actions repository variable `PROJECT_SETUP_PROJECT_NUMBER` with the target Project number. `PROJECT_SETUP_OWNER_TYPE` may optionally be added as an Actions repository variable with `user` or `organization`; otherwise the existing authenticated owner auto-detection is used. + Project owner type is independent from authentication. Configure `PROJECT_SETUP_OWNER_TYPE=user` or `PROJECT_SETUP_OWNER_TYPE=organization`, or leave it empty for auto-detection. See [Project v2 owner type](docs/repo/project-owner-type.md). Never commit `.env`. See GitHub's documentation for [personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and [Projects automation](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions). +### PR Sync in installed repositories + +The core installer now includes `.github/workflows/pr-sync.yml` and the reusable `project_setup.pr_sync` module. PR Sync runs after successful trusted PR metadata/Guardrails validation and handles direct draft/closed lifecycle transitions without executing code from an untrusted PR head. + +For an implementation PR, use a closing reference in the PR body: + +```text +Closes #123 +``` + +The linked task can then drive configured PR labels, milestone and assignees, its parent/sub-issue relationship, and optional Project v2 lifecycle status. Repository-level synchronization uses `${{ github.token }}`. Project v2 synchronization is optional and requires the PAT/Project configuration described above. Promotion PRs `develop -> Q.A` and `Q.A -> main` are skipped by default. + +See [PR Sync](docs/repo/pr-sync.md) for the complete behavior and configuration contract. + ## 4. Common commands The target path and repository are normally read from `.env`. Remote mutating operations remain dry-run by default. Add `LIVE=1` only after reviewing the preview. @@ -231,10 +248,11 @@ The tool is intentionally conservative because repository setup mixes local file - **No credential logging:** diagnostics show credential source/status, never token values. - **Safe HTTP behavior:** GitHub requests have a finite timeout and are restricted to `https://api.github.com`. - **Mutation retry protection:** automatic transport retries are limited to idempotent reads. A lost response after a `POST`, `PATCH`, or `DELETE` is not automatically replayed. -- **Trusted privileged workflows:** workflows using `pull_request_target` execute automation from the trusted base branch. Read-only test workflows may validate proposed PR content. +- **Trusted privileged workflows:** workflows using `pull_request_target` execute only trusted metadata/base automation. The `Q.A` and `main` promotion gates are metadata-only and never check out PR-head code; PR Sync checks out only the trusted base with credential persistence disabled. +- **Explicit source identity:** `.project-setup-source` identifies this tool's source repository and is intentionally not installed into target repositories, preventing embedded targets from inheriting source-only validation contracts. - **Cross-platform entry points:** `.env` is parsed by Python rather than directly included by Make, keeping quoting and Windows behavior aligned with the CLI. -Current intentional limits: generated issues are not idempotent yet, Project v2 views remain manual, rulesets/branch protection are not created, and milestone synchronization inspects at most the first 100 existing milestones. +Current intentional limits: generated issues are not idempotent yet, Project v2 views remain manual, rulesets/branch protection are not created, milestone synchronization inspects at most the first 100 existing milestones, and PR Sync label synchronization is additive rather than destructive. PR Sync Project updates remain optional when their PAT/Project number are not configured. ## 6. Documentation @@ -242,10 +260,11 @@ Current intentional limits: generated issues are not idempotent yet, Project v2 | --- | --- | | [AI setup guide](AI_SETUP_GUIDE.md) | Operational contract for AI assistants: inspect existing patterns first, ask only for unresolved decisions, pause for manual/credential/live checkpoints, re-verify user changes, and verify results after application. | | [Portuguese README](README.pt-BR.md) | Complete Portuguese version of this overview, quick start, authentication, commands, safety model, environment defaults, and licensing information. | +| [PR Sync](docs/repo/pr-sync.md) | Implemented PR/task/Project synchronization contract, configuration, lifecycle mapping, promotion exclusions, security model, idempotency, and limits. | | [Project owner type](docs/repo/project-owner-type.md) | How to select `user` versus `organization`, auto-detection behavior, Make overrides, GraphQL namespace handling, and Q.A coverage. | | [Project Setup runbook (pt-BR)](docs/repo/project-setup-runbook.pt-BR.md) | Operational step-by-step procedure for configuring `.env`, diagnosing the environment, previewing, installing, and applying the tool in a target repository. | -| [Shared tool internals](docs/repo/project-setup-shared-tool.md) | Distribution model, package/CLI boundaries, authentication boundary, request-safety decisions, and what the reusable core automates. | -| [Branching policy](docs/repo/branching-policy.md) | Supported branch naming/source rules and the repository policy enforced around pull requests. | +| [Shared tool internals](docs/repo/project-setup-shared-tool.md) | Distribution model, source/embedded boundary, package/CLI responsibilities, authentication boundary, request-safety decisions, and reusable automation internals. | +| [Branching policy](docs/repo/branching-policy.md) | Supported branch naming/source rules, trusted metadata-only promotion gates, source marker behavior, and PR automation boundaries. | | [Project board policy](docs/repo/project-board-policy.md) | Expected Project v2 fields, statuses, item types, and conventions used by the generic manifests. | | [Script reference contract](docs/repo/script-reference-contract.md) | Contract that prevents validation scripts from becoming orphaned: each script must have an explicit caller and installer reference. | | [Documentation guide](docs/DOCUMENTATION-GUIDE.md) | Map connecting configuration files, workflows, implementation files, and their authoritative documentation. | diff --git a/README.pt-BR.md b/README.pt-BR.md index 70d4d4c..9ef79e3 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -10,7 +10,7 @@ O `project_setup` é uma ferramenta autocontida para instalar e operar automações reutilizáveis em repositórios GitHub. Ela combina Makefile, CLI Python, workflows do GitHub Actions, manifests e scripts de validação para que repositórios possam ser configurados manualmente, por automação ou com auxílio de IA sem esconder o que será alterado. -O foco é configurar com segurança labels, milestones, issues, sub-issues, guardrails de pull request, descoberta de repositório e GitHub Projects v2. Comandos remotos mutáveis usam dry-run por padrão e exigem modo live explícito antes de escrever no GitHub. +O foco é configurar com segurança labels, milestones, issues, sub-issues, guardrails de pull request, **PR Sync**, descoberta de repositório e GitHub Projects v2. Comandos remotos mutáveis usam dry-run por padrão e exigem modo live explícito antes de escrever no GitHub. Se uma IA for realizar ou conduzir o setup, forneça a ela o arquivo [`AI_SETUP_GUIDE.md`](AI_SETUP_GUIDE.md). Esse guia orienta o agente a inspecionar padrões existentes antes de perguntar, parar em checkpoints manuais/de credenciais/live, verificar novamente alterações feitas pelo usuário e evitar criação duplicada de recursos. @@ -26,6 +26,7 @@ Usos típicos: - opcionalmente vincular tasks como sub-issues; - criar e sincronizar GitHub Projects v2 pertencentes a uma conta pessoal ou GitHub Organization; - validar metadados de pull request e convenções do repositório; +- sincronizar PRs de implementação com suas tasks vinculadas e, opcionalmente, com o ciclo de vida no Project v2 através da PR Sync; - inspecionar um projeto e recomendar um fluxo de setup antes de aplicar alterações; - disponibilizar as mesmas operações para pessoas, scripts e agentes de IA através de comandos previsíveis em Make/CLI. @@ -71,7 +72,7 @@ PROJECT_SETUP_CONFIG=project_setup.json PROJECT_SETUP_PROJECT_NUMBER= ``` -`PROJECT_SETUP_TARGET` é o caminho local no sistema de arquivos. `GITHUB_REPOSITORY` é o identificador `owner/repository` no GitHub. `PROJECT_SETUP_OWNER_TYPE` seleciona quem é o proprietário do GitHub Projects v2: use `user` para uma conta pessoal ou `organization` para uma empresa/equipe representada por GitHub Organization. Ele pode ficar vazio para autodetecção durante operações autenticadas. Quando um Project v2 já existir, `PROJECT_SETUP_PROJECT_NUMBER` pode guardar o número utilizado por `make project-sync`. +`PROJECT_SETUP_TARGET` é o caminho local no sistema de arquivos. `GITHUB_REPOSITORY` é o identificador `owner/repository` no GitHub. `PROJECT_SETUP_OWNER_TYPE` seleciona quem é o proprietário do GitHub Projects v2: use `user` para uma conta pessoal ou `organization` para uma empresa/equipe representada por GitHub Organization. Ele pode ficar vazio para autodetecção durante operações autenticadas. Quando um Project v2 já existir, `PROJECT_SETUP_PROJECT_NUMBER` pode guardar o número utilizado por `make project-sync` e também é o valor que a PR Sync espera como variável de repositório do Actions quando a sincronização de Project estiver habilitada. Se a ferramenta já estiver incorporada no próprio repositório-alvo e for executada de dentro dele, use: @@ -125,7 +126,7 @@ make setup TARGET=../outro-projeto REPO=owner/outro-projeto OWNER_TYPE=organizat | Operações do próprio repositório dentro do GitHub Actions | `${{ github.token }}` exposto como `GITHUB_TOKEN` | [Automático — sem secret personalizado](#token-automático-do-repositório) | | Labels, milestones, issues, comentários e operações similares executadas localmente | `gh auth` válido, `GITHUB_TOKEN`, `GH_TOKEN` ou `PROJECT_SETUP_PAT` | [Manual/configurado](#autenticação-local) | | GitHub Projects v2 localmente | `PROJECT_SETUP_PAT` no `.env` | [PAT manual/configurada](#autenticação-do-projects-v2) | -| GitHub Projects v2 pelo Actions | secret de repositório `PROJECT_SETUP_PAT` | [PAT + secret manual/configurado](#autenticação-do-projects-v2) | +| GitHub Projects v2 pelo Actions / PR Sync | secret de repositório `PROJECT_SETUP_PAT` mais variável de repositório `PROJECT_SETUP_PROJECT_NUMBER` | [PAT + configuração do Actions](#autenticação-do-projects-v2) | ### Token automático do repositório @@ -168,10 +169,26 @@ PROJECT_SETUP_PAT=ghp_seu_token_aqui Para Actions: **Settings** do repositório → **Secrets and variables** → **Actions** → **New repository secret** → `PROJECT_SETUP_PAT`. +Quando a PR Sync também deve atualizar o Project v2, crie a variável de repositório do Actions `PROJECT_SETUP_PROJECT_NUMBER` com o número do Project. `PROJECT_SETUP_OWNER_TYPE` pode opcionalmente ser criada como variável de Actions com `user` ou `organization`; sem ela, permanece a autodetecção autenticada existente. + O tipo do owner é independente da autenticação. Configure `PROJECT_SETUP_OWNER_TYPE=user` ou `PROJECT_SETUP_OWNER_TYPE=organization`, ou deixe vazio para autodetecção. Consulte [Tipo de proprietário do Project v2](docs/repo/project-owner-type.pt-BR.md). Nunca versione o `.env`. Consulte a documentação do GitHub sobre [personal access tokens](https://docs.github.com/pt/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) e [automação de Projects](https://docs.github.com/pt/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions). +### PR Sync em repositórios instalados + +O instalador core agora inclui `.github/workflows/pr-sync.yml` e o módulo reutilizável `project_setup.pr_sync`. A PR Sync roda após a validação confiável de metadata/Guardrails e trata diretamente transições de draft/closed sem executar código do head não confiável do PR. + +Em um PR de implementação, use uma referência de fechamento no body: + +```text +Closes #123 +``` + +A task vinculada pode então fornecer as famílias configuradas de labels, milestone e assignees do PR, a relação pai/sub-issue e, opcionalmente, o Status no Project v2. A sincronização dentro do repositório usa `${{ github.token }}`. A parte de Project v2 é opcional e exige a configuração de PAT/Project descrita acima. PRs de promoção `develop -> Q.A` e `Q.A -> main` são ignorados por padrão. + +Consulte [PR Sync](docs/repo/pr-sync.pt-BR.md) para o contrato completo de comportamento e configuração. + ## 4. Comandos principais O caminho local e o repositório remoto são normalmente lidos do `.env`. Dry-run continua sendo o padrão para operações remotas mutáveis. Use `LIVE=1` somente depois de revisar a simulação. @@ -231,10 +248,11 @@ A ferramenta é deliberadamente conservadora porque o setup mistura arquivos loc - **Sem log de credenciais:** os diagnósticos mostram origem/estado, nunca o valor dos tokens. - **HTTP restrito:** as chamadas têm timeout finito e ficam restritas a `https://api.github.com`. - **Proteção contra repetição de mutações:** retentativas automáticas de transporte ficam limitadas a leituras idempotentes. Uma resposta perdida após `POST`, `PATCH` ou `DELETE` não provoca replay automático. -- **Workflows privilegiados confiáveis:** workflows com `pull_request_target` executam automação da branch-base confiável. Workflows de teste somente leitura podem validar o conteúdo proposto pelo PR. +- **Workflows privilegiados confiáveis:** workflows com `pull_request_target` executam somente metadata/automação da base confiável. Os gates de promoção para `Q.A` e `main` são metadata-only e nunca fazem checkout do head do PR; a PR Sync faz checkout somente da base confiável com persistência de credenciais desabilitada. +- **Identidade explícita do source:** `.project-setup-source` identifica o repositório-fonte desta ferramenta e não é instalado em targets, evitando que repositórios embarcados herdem contratos de validação exclusivos do source. - **Entrada multiplataforma:** o `.env` é interpretado pelo Python, em vez de ser incluído diretamente pelo Make, mantendo aspas e comportamento no Windows alinhados com a CLI. -Limitações intencionais atuais: geração de issues ainda não é idempotente, views do Project v2 continuam manuais, rulesets/branch protection não são criados e a sincronização de milestones consulta no máximo os primeiros 100 milestones existentes. +Limitações intencionais atuais: geração de issues ainda não é idempotente, views do Project v2 continuam manuais, rulesets/branch protection não são criados, a sincronização de milestones consulta no máximo os primeiros 100 milestones existentes e a sincronização de labels pela PR Sync é aditiva, não destrutiva. Atualizações de Project pela PR Sync continuam opcionais quando PAT/número do Project não estiverem configurados. ## 6. Documentação @@ -242,10 +260,11 @@ Limitações intencionais atuais: geração de issues ainda não é idempotente, | --- | --- | | [Guia de setup para IA](AI_SETUP_GUIDE.md) | Contrato operacional para agentes de IA: consultar padrões existentes primeiro, perguntar apenas decisões pendentes, parar em checkpoints manuais/de credenciais/live, verificar novamente mudanças do usuário e validar resultados após a aplicação. | | [README em inglês](README.md) | Versão principal internacional com visão geral, quick start, autenticação, comandos, segurança, defaults de ambiente e licença. | +| [PR Sync](docs/repo/pr-sync.pt-BR.md) | Contrato implementado de sincronização PR/task/Project, configuração, mapeamento de ciclo de vida, exclusões de promoção, segurança, idempotência e limites. | | [Tipo de proprietário do Project v2](docs/repo/project-owner-type.pt-BR.md) | Como selecionar `user` ou `organization`, autodetecção, overrides no Make, tratamento do namespace GraphQL e cobertura de Q.A. | | [Runbook do Project Setup](docs/repo/project-setup-runbook.pt-BR.md) | Procedimento operacional passo a passo para configurar `.env`, diagnosticar, simular, instalar e aplicar a ferramenta em outro repositório. | -| [Internos da ferramenta compartilhada](docs/repo/project-setup-shared-tool.md) | Modelo de distribuição, limites do pacote/CLI, fronteira de autenticação, decisões de segurança HTTP e escopo do core reutilizável. | -| [Política de branches](docs/repo/branching-policy.pt-BR.md) | Convenções de nomes/origens de branches e regras esperadas para pull requests. | +| [Internos da ferramenta compartilhada](docs/repo/project-setup-shared-tool.md) | Modelo de distribuição, fronteira source/embedded, responsabilidades do pacote/CLI, autenticação, segurança de requests e internos da automação reutilizável. | +| [Política de branches](docs/repo/branching-policy.pt-BR.md) | Convenções de nomes/origens, gates confiáveis metadata-only, comportamento do marker do source e limites da automação de PR. | | [Política do Project board](docs/repo/project-board-policy.pt-BR.md) | Fields, statuses, tipos de item e convenções esperadas pelo Project v2 e pelos manifests genéricos. | | [Contrato de referências de scripts](docs/repo/script-reference-contract.pt-BR.md) | Regra que evita scripts órfãos: todo script precisa ter chamador explícito e referência no instalador. | | [Guia da documentação](docs/DOCUMENTATION-GUIDE.md) | Mapa entre arquivos de configuração, workflows, implementações e documentação autoritativa. | diff --git a/docs/DOCUMENTATION-GUIDE.md b/docs/DOCUMENTATION-GUIDE.md index b048fb4..e0f8d02 100644 --- a/docs/DOCUMENTATION-GUIDE.md +++ b/docs/DOCUMENTATION-GUIDE.md @@ -6,11 +6,12 @@ This guide maps the reusable configuration, workflows and operational documentat | Configuration | Purpose | Documentation | | --- | --- | --- | -| `project_setup.json` | File paths and safe execution defaults | `docs/repo/project-setup-shared-tool.md`, `docs/repo/project-setup-runbook.pt-BR.md` | +| `project_setup.json` | File paths, safe execution defaults, and `prAutomation.sync` policy | `docs/repo/project-setup-shared-tool.md`, `docs/repo/pr-sync.md`, `docs/repo/project-setup-runbook.pt-BR.md` | | `config/project/labels.json` | Label names, colors and descriptions | `docs/repo/project-board-policy.md` | | `config/project/milestones.json` | Milestone definitions | `docs/milestones/MILESTONE-TEMPLATE.md` | | `config/project/project-definition.json` | Project v2 fields and options | `docs/repo/project-board-policy.md` | | `config/stories/backlog-manifest.json` | Phases, stories and tasks | milestone and story documentation in the target repository | +| `.project-setup-source` | Explicit identity marker for the GPA source repository; never installed into targets | `docs/repo/project-setup-shared-tool.md`, `docs/repo/branching-policy.md` | ## Operational guidance hierarchy @@ -19,6 +20,9 @@ This guide maps the reusable configuration, workflows and operational documentat | Target repository instructions and recorded conventions (`AGENTS.md`, `CONTRIBUTING*`, README/docs, existing workflows/configuration) | Authoritative for repository-specific decisions and established project standards. | | `AI_SETUP_GUIDE.md` | Authoritative for the AI interaction sequence: inspect before asking, pause at checkpoints, re-verify user changes, protect credentials, and verify applied results. | | `docs/repo/qa-policy.md` / `docs/repo/qa-policy.pt-BR.md` | Authoritative for the `develop -> Q.A -> main` promotion gates, compatibility matrix, sandbox requirements, and live/manual Q.A behavior. | +| `docs/repo/pr-sync.md` / `docs/repo/pr-sync.pt-BR.md` | Authoritative implementation contract for PR Sync: inputs, synchronization behavior, security, Project mapping, configuration, and limits. | +| `docs/repo/branching-policy.md` / `docs/repo/branching-policy.pt-BR.md` | Branch topology plus trusted metadata-only promotion-gate security contract. | +| `docs/repo/project-setup-shared-tool.md` | Distribution model, source/embedded repository boundary, authentication boundary, and reusable automation internals. | | `docs/repo/project-setup-runbook.pt-BR.md` | Detailed human operational procedure and troubleshooting path. | | `README.md` / `README.pt-BR.md` | Concise user-facing overview, setup, authentication, commands, and safety model. | @@ -30,13 +34,52 @@ The AI guide does not override project-specific conventions. Its purpose is to m | --- | --- | --- | | `.github/workflows/project-setup.yml` | Manual dry-run or live setup | `project_setup/cli.py`, `project_setup/runner.py`, `project_setup.json` | | `.github/workflows/auto-label.yml` | Infer labels for issues and PRs | `project_setup/auto_label.py` | -| `.github/workflows/pr-metadata.yml` | Validate branch names and PR metadata | `project_setup/pr_validation.py` | -| `.github/workflows/qa-source-branch.yml` | Allow promotion into `Q.A` only from `develop` | `docs/repo/branching-policy.md`, `project_setup/pr_validation.py` | +| `.github/workflows/pr-metadata.yml` | Validate branch names and PR metadata; current Guardrails-stage workflow | `project_setup/pr_validation.py` | +| `.github/workflows/pr-sync.yml` | After successful Guardrails/metadata validation, synchronize linked task/PR/Project state; handle draft/closed lifecycle events | `project_setup/pr_sync.py`, `project_setup.json`, `tests/test_pr_sync.py`, `docs/repo/pr-sync.md` | +| `.github/workflows/qa-source-branch.yml` | Metadata-only trusted gate: allow promotion into `Q.A` only from `develop` | `docs/repo/branching-policy.md`, `tests/test_qa_workflows.py` | | `.github/workflows/qa-validation.yml` | Run deterministic cross-platform and package Q.A gates | `tests/qa/test_cli_e2e.py`, `tests/test_makefile_env_defaults.py`, `docs/repo/qa-policy.md` | | `.github/workflows/qa-live.yml` | Run self-cleaning live integration tests in the protected `qa` Environment | `tests/qa/live_sandbox.py`, `docs/repo/qa-policy.md` | | `.github/workflows/qa-issue-generation.yml` | Run guarded manual non-idempotent issue-generation validation | `tests/qa/live_issue_generation.py`, `docs/repo/qa-policy.md` | -| `.github/workflows/main-source-branch.yml` | Allow promotion into `main` only from `Q.A` | `docs/repo/branching-policy.md`, `project_setup/pr_validation.py` | -| `.github/workflows/repo-quality.yml` | Validate this tool repository | `Makefile`, `scripts/validation/repo_quality.py`, `tests/` | +| `.github/workflows/main-source-branch.yml` | Metadata-only trusted gate: allow promotion into `main` only from `Q.A` | `docs/repo/branching-policy.md`, `tests/test_qa_workflows.py` | +| `.github/workflows/repo-quality.yml` | Validate this tool repository and distinguish source vs embedded target mode | `Makefile`, `scripts/validation/repo_quality.py`, `.project-setup-source`, `tests/` | + +## PR automation pipeline + +The reusable implementation path is: + +```text +implementation PR + | + v +PR metadata validation / PR Guardrails + | + | successful trusted context + v +PR Sync + | + +--> task labels / milestone / assignees -> PR + +--> parent Story <-> sub-issue + +--> task -> Project v2 + +--> PR lifecycle -> Project Status +``` + +Promotion PRs remain infrastructure transitions and are excluded from task-level PR Sync by default: + +```text +develop -> Q.A -> main +``` + +## Source and embedded repository validation + +Recent repository-mode hardening is part of the documented contract: + +- `.project-setup-source` explicitly identifies the GPA source repository; +- promotion source gates use `pull_request_target` as metadata-only workflows and do not check out PR code; +- the source marker is not distributed into target repositories; +- embedded targets may keep their own Makefile/workflow callers without inheriting source-only caller contracts; +- managed target files are still validated for target-appropriate correctness. + +This prevents a target project from being misdetected as GPA source merely because it contains similar paths or filenames. ## Adding a milestone template @@ -60,16 +103,17 @@ python -m project_setup apply --repo owner/repository --live ```text AI_SETUP_GUIDE.md Interaction contract for AI-guided configuration -project_setup/ Reusable Python package -project_setup.json File paths and execution defaults +.project-setup-source Source-only GPA repository identity marker +project_setup/ Reusable Python package, including PR Sync +project_setup.json File paths, execution defaults, PR automation policy config/project/ Labels, milestones and Project v2 definition config/stories/ Backlog manifest -.github/workflows/ Generic active workflows and Q.A gates -docs/repo/ Operational policies and runbooks +.github/workflows/ Generic active workflows, PR automation, Q.A gates +docs/repo/ Operational policies, runbooks, implementation contracts tests/qa/ Q.A black-box, live sandbox, and guarded manual tests scripts/validation/ Cross-platform validation entrypoints -tests/ Unit and installation tests +tests/ Unit, installation and workflow-contract tests Makefile Human and AI-oriented command interface ``` -When a configuration contract changes, update its loader, tests, README, AI guide when relevant, and the corresponding runbook or Q.A policy in the same pull request. +When a configuration or workflow contract changes, update its loader/implementation, tests, documentation guide, README or AI guide when user-facing behavior changes, and the corresponding policy/runbook in the same pull request. diff --git a/docs/repo/branching-policy.md b/docs/repo/branching-policy.md index 651c24b..276907e 100644 --- a/docs/repo/branching-policy.md +++ b/docs/repo/branching-policy.md @@ -19,7 +19,49 @@ Direct `develop -> main`, implementation branch -> `Q.A`, and implementation bra Q.A gates and sandbox requirements are documented in [`qa-policy.md`](qa-policy.md). -Repositories that intentionally use a different promotion model should adapt `.github/workflows/qa-source-branch.yml`, `.github/workflows/main-source-branch.yml`, and the PR branch validator together instead of changing only one layer. +Repositories that intentionally use a different promotion model should adapt `.github/workflows/qa-source-branch.yml`, `.github/workflows/main-source-branch.yml`, the PR branch validator, and the PR Sync promotion exclusions together instead of changing only one layer. + +## Trusted promotion gates + +The `Q.A` and `main` source-branch gates are privileged metadata checks. + +They use `pull_request_target` deliberately and **do not check out pull-request code**: + +- `.github/workflows/qa-source-branch.yml` accepts only `develop -> Q.A`; +- `.github/workflows/main-source-branch.yml` accepts only `Q.A -> main`. + +These workflows must remain metadata-only. Adding `actions/checkout`, shell execution from the PR head, or another path that executes untrusted head content under the `pull_request_target` token would violate the security contract. + +The gate logic therefore reads the PR source/base refs from the event payload and exits without executing repository code from the proposed change. + +## Source repository vs embedded target + +The GPA source repository is identified explicitly by `.project-setup-source`. + +The marker contains the source identity contract and is intentionally **not** distributed by the installer. A target repository containing files with names similar to GPA internals must not be misclassified as the source repository. + +Embedded targets may preserve target-owned files such as an existing `Makefile` or workflow callers. Source-only caller/reference checks are skipped there while managed automation still remains subject to target-appropriate validation. + +See [`project-setup-shared-tool.md`](project-setup-shared-tool.md) for the distribution boundary. + +## PR automation and branch promotions + +Implementation PR automation follows the same branch model: + +```text +implementation branch -> develop -> Q.A -> main +``` + +PR Guardrails / PR metadata validation establishes the implementation PR context. PR Sync then synchronizes the linked task and Project metadata. + +PR Sync excludes branch-promotion PRs by default: + +- `develop -> Q.A`; +- `Q.A -> main`. + +This prevents a promotion PR from inheriting task-specific labels, assignees, milestones, sub-issue relationships, or Project status. + +See [`pr-sync.md`](pr-sync.md). ## Naming diff --git a/docs/repo/branching-policy.pt-BR.md b/docs/repo/branching-policy.pt-BR.md index c7c646f..a21ac2a 100644 --- a/docs/repo/branching-policy.pt-BR.md +++ b/docs/repo/branching-policy.pt-BR.md @@ -19,7 +19,49 @@ Promoções diretas `develop -> main`, branch de implementação -> `Q.A` e bran Os gates, testes e requisitos do sandbox estão documentados em [`qa-policy.pt-BR.md`](qa-policy.pt-BR.md). -Repositórios que adotarem outro modelo de promoção devem adaptar juntos `.github/workflows/qa-source-branch.yml`, `.github/workflows/main-source-branch.yml` e o validador de branch de PR, em vez de alterar apenas uma camada. +Repositórios que adotarem outro modelo de promoção devem adaptar juntos `.github/workflows/qa-source-branch.yml`, `.github/workflows/main-source-branch.yml`, o validador de branch e as exclusões de promoção da PR Sync, em vez de alterar apenas uma camada. + +## Gates confiáveis de promoção + +Os gates de origem para `Q.A` e `main` são verificações privilegiadas apenas de metadata. + +Eles utilizam `pull_request_target` deliberadamente e **não fazem checkout do código do PR**: + +- `.github/workflows/qa-source-branch.yml` aceita somente `develop -> Q.A`; +- `.github/workflows/main-source-branch.yml` aceita somente `Q.A -> main`. + +Esses workflows devem permanecer metadata-only. Adicionar `actions/checkout`, executar shell vindo do head do PR ou qualquer caminho que execute conteúdo não confiável sob o token de `pull_request_target` quebra o contrato de segurança. + +A validação lê apenas os refs de origem/destino do payload do evento e termina sem executar código da mudança proposta. + +## Repositório-fonte vs target embarcado + +O repositório-fonte do GPA é identificado explicitamente por `.project-setup-source`. + +Esse marker contém o contrato de identidade do source e **não** é distribuído pelo instalador. Um target que possua arquivos com nomes semelhantes aos internos do GPA não deve ser confundido com o repositório-fonte. + +Targets embarcados podem preservar arquivos próprios, como `Makefile` ou workflows callers existentes. Validações de referência/caller exclusivas do source são ignoradas nesse modo, enquanto a automação gerenciada continua sujeita às validações apropriadas ao target. + +Veja [`project-setup-shared-tool.md`](project-setup-shared-tool.md). + +## Automação de PR e promoções + +A automação de implementação segue o mesmo fluxo: + +```text +branch de implementação -> develop -> Q.A -> main +``` + +PR Guardrails / validação de metadata estabelece o contexto do PR de implementação. PR Sync então sincroniza task e metadata do Project. + +Por padrão, PR Sync exclui PRs de promoção: + +- `develop -> Q.A`; +- `Q.A -> main`. + +Assim um PR de promoção não herda labels, assignees, milestone, sub-issue ou Status de Project pertencentes a uma task de implementação. + +Veja [`pr-sync.pt-BR.md`](pr-sync.pt-BR.md). ## Nomenclatura diff --git a/docs/repo/pr-sync.md b/docs/repo/pr-sync.md new file mode 100644 index 0000000..e6c77ef --- /dev/null +++ b/docs/repo/pr-sync.md @@ -0,0 +1,274 @@ +# PR Sync + +## Status + +**Implemented.** + +PR Sync is the generic GitHub Project Setup automation that keeps an implementation pull request aligned with its linked issue/task and, when configured, GitHub Projects v2. + +The implementation is composed of: + +- `.github/workflows/pr-sync.yml` — trusted GitHub Actions orchestration; +- `project_setup/pr_sync.py` — synchronization logic; +- `project_setup.json` → `prAutomation.sync` — repository policy/configuration; +- `tests/test_pr_sync.py` — unit and workflow-contract coverage. + +The feature is the generic successor to the repository-specific workflow previously called **PR Hygiene** in the Take Your Pills reference repository. New GPA code and documentation use **PR Sync**. + +## Responsibility boundary + +PR Sync does not replace PR Guardrails. + +The intended pipeline is: + +```text +Pull request event + | + v +PR Guardrails / current PR metadata validation + - resolve or validate trusted PR context + - validate branch/body contract + | + v +PR Sync + - resolve linked implementation task + - synchronize task -> PR metadata + - synchronize parent/sub-issue relationship + - synchronize task -> Project v2 + - synchronize PR lifecycle -> Project Status +``` + +The current GPA validation workflow is named `PR metadata validation`. PR Sync listens for successful completion of that workflow and also recognizes the reference-compatible `PR guardrails` workflow name so the orchestration remains valid when Guardrails is promoted to the generic name. + +## Event model + +Normal implementation updates run from `workflow_run` after the trusted PR validation/Guardrails workflow completes successfully. + +Lifecycle events that do not need another validation pass run directly from `pull_request_target`: + +- `converted_to_draft`; +- `closed`. + +Both paths use automation from the trusted base branch. The workflow does not execute code from an untrusted PR head with write permissions. + +Fork pull requests are skipped. + +## Linked implementation task + +PR Sync resolves the implementation issue/task from a closing reference in the PR body: + +```text +Closes #123 +Fixes #123 +Resolves #123 +``` + +If no closing reference exists, PR Sync writes or updates one marked status comment and returns a failure for the synchronization step. + +If the referenced item is itself a pull request, it is rejected as an implementation task. + +The closing reference is also the GitHub Development linkage between the PR and the issue/task; PR Sync builds its synchronization context from that canonical link rather than inventing another parallel association. + +## Metadata synchronization + +By default, the linked task is the source for these PR fields: + +### Labels + +Configured label families are copied to the PR when missing. + +Default prefixes: + +```text +type: +priority: +test: +``` + +PR Sync is additive: it does not remove unrelated or stale labels from the PR. + +### Milestone + +If the task has a milestone and the PR does not match it, the PR milestone is updated to the task milestone. + +### Assignees + +If task assignees exist, missing assignees are added to the PR. + +If the task is unassigned and `assignAuthorWhenTaskUnassigned` is enabled, the PR author is assigned to the task and then synchronized to the PR. + +These behaviors can be disabled independently in `project_setup.json`. + +## Parent Story / sub-issue synchronization + +Generated GPA task bodies already use a parent reference such as: + +```text +Parent story: US-12 (#45) +``` + +When `linkSubissues` is enabled, PR Sync resolves that parent issue number and ensures the implementation task is linked as a GitHub sub-issue. + +Repeated execution is idempotent for an already-existing parent/sub-issue relationship. Permission failures are reported as synchronization diagnostics instead of causing duplicate mutations. + +## Project v2 synchronization + +When all of the following are configured: + +- `syncProject: true`; +- Actions variable `PROJECT_SETUP_PROJECT_NUMBER`; +- Actions secret `PROJECT_SETUP_PAT`; + +PR Sync ensures that the linked task belongs to the configured Project v2 and updates the configured single-select status field. + +The Project owner continues to use the existing GPA owner-resolution contract. `PROJECT_SETUP_OWNER_TYPE` may be provided as an Actions variable when explicit `user` or `organization` selection is required. + +If the Project number or PAT is missing, repository-level PR/task synchronization still runs and the sticky comment explains why Project synchronization was skipped. + +## Default lifecycle mapping + +The committed default mapping is: + +| 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` | + +The field name and option names are configurable. GPA resolves the configured option by normalized name rather than hard-coding one repository-specific Project schema. + +## Promotion pull requests + +PR Sync is intended for implementation pull requests, not branch-promotion infrastructure. + +The default excluded paths are: + +```text +develop -> Q.A +Q.A -> main +``` + +Promotion PRs are skipped before task lookup or metadata mutation. Repositories with a different promotion model can replace `promotionPaths` or disable `skipPromotionPullRequests`. + +## Committed configuration schema + +`project_setup.json` contains the active PR Sync configuration: + +```json +{ + "prAutomation": { + "sync": { + "enabled": true, + "syncLabels": true, + "labelPrefixes": ["type:", "priority:", "test:"], + "syncMilestone": true, + "syncAssignees": true, + "assignAuthorWhenTaskUnassigned": true, + "linkSubissues": true, + "syncProject": true, + "skipPromotionPullRequests": 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" + } + } + } +} +``` + +This is now an implementation contract, not a design placeholder. + +## Authentication and permissions + +Repository-scoped synchronization uses `${{ github.token }}` with the workflow's minimum repository permissions. + +Project v2 synchronization uses the existing explicit GPA boundary: + +```text +PROJECT_SETUP_PAT +``` + +The PAT is never written to logs or comments. + +The workflow requests only: + +```yaml +permissions: + contents: read + issues: write +``` + +PR labels, assignees, milestone changes and PR comments are performed through GitHub's issue endpoints because pull requests are issue-backed resources. + +## Security model + +PR Sync follows the repository hardening introduced in the promotion-gate work: + +- privileged PR automation is based on `pull_request_target` / trusted `workflow_run`; +- checkout points at the trusted base commit or trusted base branch; +- `persist-credentials` is disabled; +- fork PRs are rejected by both workflow conditions and implementation checks; +- no untrusted head code is executed with write permissions; +- GitHub mutation calls are not automatically replayed after transport failures; +- optional Project permission failures are surfaced clearly. + +The same source/embedded-repository distinction documented in `project-setup-shared-tool.md` continues to apply when the workflow is distributed to target repositories. + +## Idempotency + +Repeated PR Sync execution is designed not to duplicate: + +- labels already present on the PR; +- assignees already present on the PR; +- Project membership for an existing task; +- parent/sub-issue relationships already established; +- marked PR Sync comments. + +Milestone and Project status updates converge toward the configured task/lifecycle state. + +## Installation + +`.github/workflows/pr-sync.yml` is part of the core installer manifest. Because package files under `project_setup/*.py` are distributed automatically, `project_setup/pr_sync.py` is installed with the workflow. + +Existing target files remain protected by the installer's normal preserve-by-default behavior. + +## Validation + +`tests/test_pr_sync.py` covers the reusable contract, including: + +- closing-reference parsing; +- generated parent-reference parsing; +- lifecycle status mapping; +- configuration overrides; +- label/milestone/assignee synchronization; +- author fallback for unassigned tasks; +- duplicate parent/sub-issue idempotency; +- fork safety; +- promotion-PR skip behavior; +- missing/invalid linked task behavior; +- sticky comment idempotency; +- trusted workflow checkout and dependency contract; +- installer distribution of the workflow. + +Live Project v2 behavior remains a Q.A-sandbox concern. It must not use the source repository as the destructive integration-test target. + +## Known limits + +- PR Sync adds configured task labels but does not remove stale PR labels. +- It synchronizes assignees from task to PR; reviewer-request automation remains a Guardrails/review-policy responsibility rather than PR Sync. +- Project v2 synchronization is optional when its PAT/Project number are not configured. +- Promotion PR task synchronization is disabled by default. + +## Naming + +The generic feature and workflow name is **PR Sync**. + +Do not introduce `PR Hygiene` in new GPA code, workflows, CLI/configuration names, or documentation except when referring historically to the Take Your Pills reference implementation. diff --git a/docs/repo/pr-sync.pt-BR.md b/docs/repo/pr-sync.pt-BR.md new file mode 100644 index 0000000..813e946 --- /dev/null +++ b/docs/repo/pr-sync.pt-BR.md @@ -0,0 +1,272 @@ +# PR Sync + +## Status + +**Implementado.** + +PR Sync é a automação genérica do GitHub Project Setup que mantém um pull request de implementação alinhado com sua issue/task vinculada e, quando configurado, com o GitHub Projects v2. + +A implementação é composta por: + +- `.github/workflows/pr-sync.yml` — orquestração confiável no GitHub Actions; +- `project_setup/pr_sync.py` — lógica de sincronização; +- `project_setup.json` → `prAutomation.sync` — política/configuração do repositório; +- `tests/test_pr_sync.py` — testes unitários e de contrato do workflow. + +A funcionalidade é o sucessor genérico do workflow específico anteriormente chamado **PR Hygiene** no repositório de referência Take Your Pills. Novos códigos e documentos do GPA usam **PR Sync**. + +## Limite de responsabilidade + +PR Sync não substitui PR Guardrails. + +O pipeline esperado é: + +```text +Evento de pull request + | + v +PR Guardrails / validação atual de metadata do PR + - resolve ou valida o contexto confiável + - valida contrato de branch/body + | + v +PR Sync + - resolve a task de implementação vinculada + - sincroniza metadata task -> PR + - sincroniza relação pai/sub-issue + - sincroniza task -> Project v2 + - sincroniza ciclo do PR -> Status do Project +``` + +O workflow atual do GPA chama-se `PR metadata validation`. PR Sync escuta sua conclusão com sucesso e também reconhece o nome compatível `PR guardrails`, permitindo a futura promoção do Guardrails ao nome genérico sem quebrar a orquestração. + +## Modelo de eventos + +Atualizações normais de implementação rodam via `workflow_run` depois que a validação confiável/Guardrails termina com sucesso. + +Eventos de ciclo de vida que não precisam de outra validação rodam diretamente por `pull_request_target`: + +- `converted_to_draft`; +- `closed`. + +Os dois caminhos usam automação da base confiável. O workflow não executa código do head não confiável com permissões de escrita. + +PRs vindos de forks são ignorados. + +## Task de implementação vinculada + +PR Sync resolve a issue/task por uma referência de fechamento no body do PR: + +```text +Closes #123 +Fixes #123 +Resolves #123 +``` + +Se não houver referência, PR Sync cria ou atualiza um único comentário marcado de status e retorna falha para a etapa de sincronização. + +Se o item referenciado for outro pull request, ele é rejeitado como task de implementação. + +A própria referência de fechamento estabelece o vínculo Development do GitHub entre PR e issue/task. PR Sync usa esse vínculo canônico como contexto, em vez de criar uma associação paralela. + +## Sincronização de metadata + +Por padrão, a task vinculada é a fonte para estes campos do PR. + +### Labels + +Famílias configuradas de labels são copiadas quando estiverem ausentes. + +Prefixos padrão: + +```text +type: +priority: +test: +``` + +A operação é aditiva: PR Sync não remove labels não relacionados ou antigos do PR. + +### Milestone + +Se a task possuir milestone diferente do PR, o milestone do PR é atualizado para o da task. + +### Assignees + +Assignees existentes na task são adicionados ao PR quando estiverem ausentes. + +Se a task estiver sem assignee e `assignAuthorWhenTaskUnassigned` estiver habilitado, o autor do PR é atribuído à task e então sincronizado com o PR. + +Cada comportamento pode ser desabilitado na configuração. + +## Story pai / sub-issue + +Tasks geradas pelo GPA já utilizam referência de pai, por exemplo: + +```text +Parent story: US-12 (#45) +``` + +Com `linkSubissues` habilitado, PR Sync resolve o número da Story/issue pai e garante que a task seja vinculada como sub-issue. + +Execuções repetidas tratam relações já existentes como idempotentes. Falhas de permissão são apresentadas como diagnóstico, sem tentar duplicar a mutação. + +## Sincronização com Project v2 + +Quando estão configurados: + +- `syncProject: true`; +- variável de Actions `PROJECT_SETUP_PROJECT_NUMBER`; +- secret de Actions `PROJECT_SETUP_PAT`; + +PR Sync garante que a task vinculada pertença ao Project v2 configurado e atualiza o campo single-select de status. + +A resolução de owner reutiliza o contrato já existente do GPA. `PROJECT_SETUP_OWNER_TYPE` pode ser fornecido como variável de Actions quando for necessário fixar `user` ou `organization`. + +Sem Project number ou PAT, a sincronização de PR/task dentro do repositório continua funcionando e o comentário sticky informa por que o Project foi ignorado. + +## Mapeamento padrão do ciclo de vida + +| Estado do PR | Status alvo no Project | +| --- | --- | +| Draft / convertido para draft | `In progress` | +| Ready for review / PR aberto validado | `In review` | +| Fechado sem merge | `In progress` | +| Merged | `Done` | + +O nome do campo e das opções são configuráveis. O GPA resolve a opção pelo nome normalizado, sem depender de um schema específico do Take Your Pills. + +## PRs de promoção + +PR Sync é voltado a PRs de implementação, não à infraestrutura de promoção de branches. + +Os caminhos ignorados por padrão são: + +```text +develop -> Q.A +Q.A -> main +``` + +Eles são ignorados antes da resolução de task ou qualquer mutação. Repositórios com outro modelo podem substituir `promotionPaths` ou desabilitar `skipPromotionPullRequests`. + +## Schema de configuração consolidado + +`project_setup.json` agora contém o contrato ativo: + +```json +{ + "prAutomation": { + "sync": { + "enabled": true, + "syncLabels": true, + "labelPrefixes": ["type:", "priority:", "test:"], + "syncMilestone": true, + "syncAssignees": true, + "assignAuthorWhenTaskUnassigned": true, + "linkSubissues": true, + "syncProject": true, + "skipPromotionPullRequests": 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" + } + } + } +} +``` + +Esse bloco deixa de ser exemplo de design e passa a ser contrato de implementação. + +## Autenticação e permissões + +Sincronizações restritas ao repositório usam `${{ github.token }}` com permissões mínimas. + +Projects v2 preserva a fronteira explícita já usada pelo GPA: + +```text +PROJECT_SETUP_PAT +``` + +O PAT nunca deve aparecer em logs ou comentários. + +O workflow solicita somente: + +```yaml +permissions: + contents: read + issues: write +``` + +Labels, assignees, milestone e comentários de PR são tratados pelos endpoints de issues, pois PRs são recursos baseados em issues no GitHub. + +## Modelo de segurança + +PR Sync segue o hardening recente do repositório: + +- automação privilegiada usa `pull_request_target` / `workflow_run` confiável; +- checkout aponta para commit/branch da base confiável; +- `persist-credentials` fica desabilitado; +- forks são bloqueados no workflow e novamente na implementação; +- nenhum código do head não confiável roda com permissões de escrita; +- mutações GitHub não são repetidas automaticamente após falhas de transporte; +- falhas opcionais de permissão no Project são expostas claramente. + +A distinção entre repositório-fonte e instalação embarcada descrita em `project-setup-shared-tool.md` continua válida ao distribuir o workflow. + +## Idempotência + +Execuções repetidas não devem duplicar: + +- labels já presentes; +- assignees já presentes; +- membership do Project para task já adicionada; +- relação pai/sub-issue existente; +- comentário marcado da PR Sync. + +Milestone e Status do Project convergem para o estado configurado. + +## Instalação + +`.github/workflows/pr-sync.yml` faz parte do manifest core do instalador. Como módulos `project_setup/*.py` são distribuídos automaticamente, `project_setup/pr_sync.py` acompanha o workflow. + +Arquivos já existentes no target continuam protegidos pelo comportamento preserve-by-default do instalador. + +## Validação + +`tests/test_pr_sync.py` cobre: + +- parsing de closing reference; +- parsing da referência de pai gerada; +- mapeamento do ciclo de vida; +- overrides de configuração; +- sincronização de labels/milestone/assignees; +- fallback de autor para task sem assignee; +- idempotência de pai/sub-issue; +- segurança para forks; +- skip de PR de promoção; +- task ausente/inválida; +- idempotência do comentário sticky; +- checkout confiável e dependência do workflow; +- distribuição do workflow pelo instalador. + +Validação live de Project v2 continua pertencendo ao sandbox de Q.A, nunca ao repositório-fonte. + +## Limites conhecidos + +- PR Sync adiciona labels configuradas da task, mas não remove labels antigas do PR. +- Sincronização de assignee pertence ao PR Sync; solicitação de reviewers permanece responsabilidade de Guardrails/review-policy. +- Project v2 é opcional quando PAT/Project number não estiverem configurados. +- Sincronização de task em PRs de promoção fica desabilitada por padrão. + +## Nomenclatura + +O nome genérico da funcionalidade e do workflow é **PR Sync**. + +Não introduzir `PR Hygiene` em novos códigos, workflows, nomes de configuração/CLI ou documentação do GPA, exceto em referências históricas à implementação avaliada no Take Your Pills. diff --git a/docs/repo/project-setup-shared-tool.md b/docs/repo/project-setup-shared-tool.md index 2a27b7c..05f155e 100644 --- a/docs/repo/project-setup-shared-tool.md +++ b/docs/repo/project-setup-shared-tool.md @@ -12,6 +12,12 @@ project_setup --help python -m project_setup --help ``` +Feature-specific trusted workflows may also call a package module directly. PR Sync uses: + +```bash +python -m project_setup.pr_sync --repo owner/repository --event-path "$GITHUB_EVENT_PATH" +``` + ## Distribution model The installer embeds the package and managed automation files directly in the target repository. It also installs `Makefile` and `.env.example` when they do not already exist. @@ -30,6 +36,33 @@ python -m project_setup init --target ../target-repository --live Existing files are preserved unless `--force` is explicitly selected. Existing Makefiles and environment templates should be reviewed and merged manually. Installation dry-runs do not create the target directory. +All `project_setup/*.py` package modules are distributed automatically. Managed core workflows are listed explicitly by the installer; this now includes `.github/workflows/pr-sync.yml`. + +## Source repository identity + +The GPA development repository and an embedded target intentionally have different validation contracts. + +The source repository is identified by the root marker: + +```text +.project-setup-source +``` + +The marker must contain the exact source identity expected by repository-quality validation. It is source-only and is **not** part of the installer manifest. + +This explicit marker replaces heuristic/path-based source detection. A target repository may legitimately contain a `Makefile`, tests, workflow files, or names that resemble GPA internals; those files alone must never switch the target into source-repository mode. + +### Embedded target behavior + +When the marker is absent, repository-quality validation treats the checkout as an embedded target: + +- target-owned `Makefile` and workflow callers may be preserved; +- source-only caller/reference contracts are skipped where they are not applicable; +- managed scripts and installed automation remain validated; +- target files do not acquire source-repository obligations merely because their names resemble GPA files. + +This boundary prevents the installer from making an external project accidentally responsible for the GPA source repository's internal wiring. + ## Local environment The CLI automatically loads `.env` from the current working directory without replacing variables that are already present in the process environment. @@ -43,14 +76,18 @@ make doctor ## Configuration -`project_setup.json` points to four manifests and selects which API modules participate: +`project_setup.json` points to the repository manifests and reusable automation policy. + +Core manifest references include: - labels; - milestones; - Project v2 definition; - backlog stories and tasks. -Every mutating CLI command remains dry-run by default. A live operation requires `--live`, the compatible `--no-dry-run` alias, or `LIVE=1` through Make. +`prAutomation.sync` configures PR Sync, including metadata families, assignee behavior, parent/sub-issue linking, Project status mapping, and promotion-PR exclusions. + +Every normal mutating CLI command remains dry-run by default. The PR Sync module is different because it is an event-driven Actions worker: the workflow invokes it to converge already-validated PR metadata, while local testing can use `--dry-run`. ## Discovery @@ -75,14 +112,36 @@ For the current GraphQL implementation: 1. create a personal access token classic; 2. select `repo` and `project` scopes; 3. save it as `PROJECT_SETUP_PAT` in the local `.env`; -4. save the same credential as the `PROJECT_SETUP_PAT` Actions secret when manual workflows must operate on Projects v2. +4. save the same credential as the `PROJECT_SETUP_PAT` Actions secret when workflows such as PR Sync must operate on Projects v2. + +PR Sync also reads `PROJECT_SETUP_PROJECT_NUMBER` from an Actions variable. `PROJECT_SETUP_OWNER_TYPE` may be supplied as an Actions variable when owner auto-detection should be replaced by explicit `user` or `organization` selection. -The CLI and workflow fail before a live Project v2 operation if the explicit PAT is missing. They do not silently use `github.token` for that operation. A Project sync dry-run without the PAT falls back to an offline preview and clearly states that remote data was not queried. +If Project credentials/configuration are absent, PR Sync still performs repository-scoped task/PR synchronization and reports that Project synchronization was skipped. ## Request safety GitHub API requests use a finite timeout. Automatic retries are limited to idempotent reads; mutation requests are not replayed after transport failures because a lost response could otherwise duplicate an issue or Project. +Privileged workflows that use `pull_request_target` must use trusted metadata and trusted base automation only. The promotion gates remain metadata-only and never check out PR code. PR Sync may check out automation, but only from the trusted base commit/branch with credentials persistence disabled. + +## PR Sync boundary + +PR Sync consumes a validated closing reference (`Closes/Fixes/Resolves #N`) and synchronizes: + +- configured task labels to the PR; +- task milestone to the PR; +- task assignees to the PR; +- optional author assignment when the task is unassigned; +- generated parent Story relationship to GitHub sub-issue; +- linked task membership/status in Project v2; +- one marked status comment. + +It skips forks and `develop -> Q.A` / `Q.A -> main` promotion PRs by default. + +See [`pr-sync.md`](pr-sync.md). + ## Automation boundaries -The tool automates repository files, labels, milestones, issues, sub-issues and Project v2 fields/items. Branch protection, rulesets and Project views remain outside the automated core. +The tool automates repository files, labels, milestones, issues, sub-issues, PR synchronization, and Project v2 fields/items. + +Branch protection, rulesets, Project views, and reviewer-selection policy remain outside PR Sync's automated core. Reviewer resolution belongs to PR Guardrails/review policy. diff --git a/project_setup.json b/project_setup.json index 6e42fe1..fd1f84a 100644 --- a/project_setup.json +++ b/project_setup.json @@ -5,6 +5,40 @@ "projectDefinitionFile": "config/project/project-definition.json", "backlogManifestFile": "config/stories/backlog-manifest.json", "secretName": "PROJECT_SETUP_PAT", + "prAutomation": { + "sync": { + "enabled": true, + "syncLabels": true, + "labelPrefixes": [ + "type:", + "priority:", + "test:" + ], + "syncMilestone": true, + "syncAssignees": true, + "assignAuthorWhenTaskUnassigned": true, + "linkSubissues": true, + "syncProject": true, + "skipPromotionPullRequests": 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" + } + } + }, "defaults": { "dryRun": true, "runLabels": true, diff --git a/project_setup/installer.py b/project_setup/installer.py index d323f8e..203ad05 100644 --- a/project_setup/installer.py +++ b/project_setup/installer.py @@ -18,6 +18,7 @@ ".github/workflows/qa-source-branch.yml", ".github/workflows/main-source-branch.yml", ".github/workflows/pr-metadata.yml", + ".github/workflows/pr-sync.yml", ".github/workflows/project-setup.yml", "config/project/labels.json", "config/project/milestones.json", diff --git a/project_setup/pr_sync.py b/project_setup/pr_sync.py new file mode 100644 index 0000000..8e8e167 --- /dev/null +++ b/project_setup/pr_sync.py @@ -0,0 +1,591 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +from typing import Any + +from .github import ( + API_BASE, + GitHubClient, + GitHubRequestError, + get_project_pat, + require_client, + split_repo, +) +from .issues import add_sub_issue +from .project import ( + add_issue_to_project, + find_project, + issue_node_id, + list_project_fields, + list_project_items, + option_id, + update_single_select, +) + + +SYNC_MARKER = "" +LINKED_TASK_PATTERN = re.compile(r"\b(?:closes|fixes|resolves)\s*:?\s*#(\d+)\b", re.IGNORECASE) +PARENT_ISSUE_PATTERN = re.compile( + r"\bParent\s+(?:story|issue|task)\s*:\s*[^\n#]*#(\d+)\b", + re.IGNORECASE, +) + +DEFAULT_SYNC_CONFIG: dict[str, Any] = { + "enabled": True, + "syncLabels": True, + "labelPrefixes": ["type:", "priority:", "test:"], + "syncMilestone": True, + "syncAssignees": True, + "assignAuthorWhenTaskUnassigned": True, + "linkSubissues": True, + "syncProject": True, + "skipPromotionPullRequests": 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", + }, +} + + +@dataclass(frozen=True) +class PullRequestContext: + number: int + action: str + body: str + base_ref: str + head_ref: str + head_repo: str + author: str + draft: bool + merged: bool + + +def load_event(path: str | os.PathLike[str]) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def load_sync_config(path: str | os.PathLike[str] = "project_setup.json") -> dict[str, Any]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + configured = data.get("prAutomation", {}).get("sync", {}) + if configured is None: + configured = {} + if not isinstance(configured, dict): + raise ValueError("project_setup.json prAutomation.sync must be an object") + + result = dict(DEFAULT_SYNC_CONFIG) + result.update(configured) + + status = dict(DEFAULT_SYNC_CONFIG["projectStatus"]) + custom_status = configured.get("projectStatus", {}) + if custom_status is not None: + if not isinstance(custom_status, dict): + raise ValueError("prAutomation.sync.projectStatus must be an object") + status.update(custom_status) + result["projectStatus"] = status + + prefixes = result.get("labelPrefixes", []) + if not isinstance(prefixes, list) or not all(isinstance(value, str) and value for value in prefixes): + raise ValueError("prAutomation.sync.labelPrefixes must be a list of non-empty strings") + promotions = result.get("promotionPaths", []) + if not isinstance(promotions, list) or not all( + isinstance(item, dict) and item.get("head") and item.get("base") for item in promotions + ): + raise ValueError("prAutomation.sync.promotionPaths must contain {head, base} objects") + return result + + +def context_from_pull_request(pr: dict[str, Any], action: str = "") -> PullRequestContext: + head = pr.get("head") or {} + base = pr.get("base") or {} + head_repo = (head.get("repo") or {}).get("full_name") or "" + return PullRequestContext( + number=int(pr["number"]), + action=action, + body=pr.get("body") or "", + base_ref=base.get("ref") or "", + head_ref=head.get("ref") or "", + head_repo=head_repo, + author=(pr.get("user") or {}).get("login") or "", + draft=bool(pr.get("draft")), + merged=bool(pr.get("merged")), + ) + + +def context_from_event( + event: dict[str, Any], + *, + client: GitHubClient | None = None, + repo: str | None = None, +) -> PullRequestContext: + pr = event.get("pull_request") + if pr: + return context_from_pull_request(pr, event.get("action") or "") + + workflow_run = event.get("workflow_run") + if workflow_run: + if client is None or not repo: + raise RuntimeError("workflow_run PR Sync requires a GitHub client and repository") + related_prs = workflow_run.get("pull_requests") or [] + if not related_prs or related_prs[0].get("number") is None: + raise RuntimeError("Unsupported workflow_run payload: no associated pull request") + number = int(related_prs[0]["number"]) + live_pr = client.request_json("GET", f"{API_BASE}/repos/{repo}/pulls/{number}") + return context_from_pull_request(live_pr, "synchronize") + + raise RuntimeError("Unsupported event payload: expected pull_request or workflow_run") + + +def linked_task_number(body: str) -> int | None: + match = LINKED_TASK_PATTERN.search(body or "") + return int(match.group(1)) if match else None + + +def parent_issue_number(body: str) -> int | None: + match = PARENT_ISSUE_PATTERN.search(body or "") + return int(match.group(1)) if match else None + + +def is_same_repository(ctx: PullRequestContext, repo: str) -> bool: + return not ctx.head_repo or ctx.head_repo.casefold() == repo.casefold() + + +def is_promotion_pull_request(ctx: PullRequestContext, config: dict[str, Any]) -> bool: + if not config.get("skipPromotionPullRequests", True): + return False + return any( + ctx.head_ref == str(path.get("head")) and ctx.base_ref == str(path.get("base")) + for path in config.get("promotionPaths", []) + ) + + +def project_status_for_context(ctx: PullRequestContext, config: dict[str, Any]) -> str: + mapping = config["projectStatus"] + if ctx.action == "closed": + return str(mapping["merged"] if ctx.merged else mapping["closed"]) + if ctx.action == "converted_to_draft" or ctx.draft: + return str(mapping["draft"]) + return str(mapping["review"]) + + +def issue_label_names(item: dict[str, Any]) -> set[str]: + return { + str(label["name"]) + for label in item.get("labels", []) + if isinstance(label, dict) and label.get("name") + } + + +def issue_assignee_logins(item: dict[str, Any]) -> list[str]: + return [ + str(assignee["login"]) + for assignee in item.get("assignees", []) + if isinstance(assignee, dict) and assignee.get("login") + ] + + +def issue_milestone_number(item: dict[str, Any]) -> int | None: + milestone = item.get("milestone") + if isinstance(milestone, dict) and milestone.get("number") is not None: + return int(milestone["number"]) + return None + + +def add_labels(client: GitHubClient, repo: str, issue_number: int, labels: list[str]) -> None: + client.request_json( + "POST", + f"{API_BASE}/repos/{repo}/issues/{issue_number}/labels", + {"labels": labels}, + ) + + +def add_assignees(client: GitHubClient, repo: str, issue_number: int, assignees: list[str]) -> None: + client.request_json( + "POST", + f"{API_BASE}/repos/{repo}/issues/{issue_number}/assignees", + {"assignees": assignees}, + ) + + +def upsert_sync_comment( + client: GitHubClient, + repo: str, + pr_number: int, + body: str, + *, + dry_run: bool = False, +) -> None: + existing = next( + ( + comment + for comment in client.list_issue_comments(repo, pr_number) + if SYNC_MARKER in (comment.get("body") or "") + ), + None, + ) + if dry_run: + verb = "update" if existing else "create" + print(f"[DRY-RUN] Would {verb} PR Sync comment on PR #{pr_number}") + print(body) + return + if existing: + client.update_issue_comment(repo, int(existing["id"]), body) + else: + client.create_issue_comment(repo, pr_number, body) + + +def render_failure_comment(message: str) -> str: + return "\n".join( + [ + SYNC_MARKER, + "## PR Sync needs attention", + "", + message, + "", + "Use `Closes #123`, `Fixes #123`, or `Resolves #123` to identify the implementation task.", + ] + ) + + +def render_success_comment( + ctx: PullRequestContext, + task_number: int, + status: str, + project_note: str, + parent_note: str, +) -> str: + return "\n".join( + [ + SYNC_MARKER, + "## PR Sync", + "", + f"- Linked task: #{task_number}", + f"- PR lifecycle target: `{status}`", + f"- Project v2: {project_note}", + f"- Parent/sub-issue: {parent_note}", + f"- PR: #{ctx.number}", + ] + ) + + +def is_permission_error(exc: BaseException) -> bool: + if isinstance(exc, GitHubRequestError): + return exc.status in {401, 403} + text = str(exc).casefold() + return any( + marker in text + for marker in ( + "forbidden", + "resource not accessible", + "insufficient", + "permission", + "scope", + ) + ) + + +def sync_pr_metadata( + client: GitHubClient, + repo: str, + ctx: PullRequestContext, + task: dict[str, Any], + pr_issue: dict[str, Any], + config: dict[str, Any], + *, + dry_run: bool = False, +) -> None: + if config.get("syncLabels", True): + prefixes = tuple(str(value) for value in config.get("labelPrefixes", [])) + task_labels = sorted(name for name in issue_label_names(task) if name.startswith(prefixes)) + existing = issue_label_names(pr_issue) + missing = [name for name in task_labels if name not in existing] + if missing: + if dry_run: + print(f"[DRY-RUN] Would add labels to PR #{ctx.number}: {', '.join(missing)}") + else: + add_labels(client, repo, ctx.number, missing) + + if config.get("syncMilestone", True): + task_milestone = issue_milestone_number(task) + pr_milestone = issue_milestone_number(pr_issue) + if task_milestone is not None and task_milestone != pr_milestone: + if dry_run: + print(f"[DRY-RUN] Would set PR #{ctx.number} milestone to #{task_milestone}") + else: + client.update_issue(repo, ctx.number, {"milestone": task_milestone}) + + if config.get("syncAssignees", True): + task_assignees = issue_assignee_logins(task) + if not task_assignees and config.get("assignAuthorWhenTaskUnassigned", True) and ctx.author: + task_assignees = [ctx.author] + if dry_run: + print(f"[DRY-RUN] Would assign task #{task['number']} to {ctx.author}") + else: + add_assignees(client, repo, int(task["number"]), [ctx.author]) + + existing_pr_assignees = set(issue_assignee_logins(pr_issue)) + missing_assignees = [login for login in task_assignees if login not in existing_pr_assignees] + if missing_assignees: + if dry_run: + print(f"[DRY-RUN] Would assign PR #{ctx.number} to {', '.join(missing_assignees)}") + else: + add_assignees(client, repo, ctx.number, missing_assignees) + + +def sync_parent_relationship( + client: GitHubClient, + repo: str, + task: dict[str, Any], + config: dict[str, Any], + *, + dry_run: bool = False, +) -> str: + if not config.get("linkSubissues", True): + return "disabled by configuration." + parent_number = parent_issue_number(task.get("body") or "") + if parent_number is None: + return "no parent reference found." + if dry_run: + print(f"[DRY-RUN] Would link task #{task['number']} as sub-issue of #{parent_number}") + return f"would ensure #{task['number']} is a sub-issue of #{parent_number}." + try: + add_sub_issue(client, repo, parent_number, int(task["number"])) + return f"linked #{task['number']} under #{parent_number}." + except Exception as exc: + text = str(exc).casefold() + if any( + marker in text + for marker in ("already", "exists", "duplicate sub-issues", "may only have one parent") + ): + return f"already linked under a parent (requested #{parent_number})." + if is_permission_error(exc): + return f"not synchronized: token lacks permission ({exc})." + raise + + +def normalized_option(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "", value.casefold()) + + +def status_option_id(field: dict[str, Any], desired: str) -> str | None: + desired_normalized = normalized_option(desired) + for option in field.get("options", []): + if normalized_option(str(option.get("name", ""))) == desired_normalized: + return str(option["id"]) + return option_id(field, desired) + + +def sync_project_status( + project_client: GitHubClient, + repo: str, + task: 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)'}" + ) + + task_node = task.get("node_id") or issue_node_id(project_client, repo, int(task["number"])) + project_items = list_project_items(project_client, str(project["id"])) + item_id = project_items.get(str(task_node)) + if not item_id: + if dry_run: + print(f"[DRY-RUN] Would add task #{task['number']} to Project v2 #{project_number}") + item_id = f"dry-run-{task['number']}" + else: + item_id = add_issue_to_project(project_client, str(project["id"]), str(task_node)) + + if dry_run: + print( + f"[DRY-RUN] Would set task #{task['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"synced to `{desired_status}` in Project v2 #{project_number}." + + +def project_number_from_value(value: int | None) -> int | None: + if value is not None: + return value + raw = os.getenv("PROJECT_SETUP_PROJECT_NUMBER") + if raw is None or not raw.strip(): + return None + try: + return int(raw) + except ValueError as exc: + raise ValueError("PROJECT_SETUP_PROJECT_NUMBER must be an integer") from exc + + +def apply_pr_sync( + client: GitHubClient, + repo: str, + event: dict[str, Any], + config: dict[str, Any], + *, + 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 config.get("enabled", True): + print("PR Sync is disabled by project_setup.json.") + return 0 + if not is_same_repository(ctx, repo): + print("Skipping PR Sync for a fork pull request.") + return 0 + if is_promotion_pull_request(ctx, config): + print(f"Skipping PR Sync for promotion PR {ctx.head_ref} -> {ctx.base_ref}.") + return 0 + + task_number = linked_task_number(ctx.body) + if task_number is None: + message = "No linked implementation task was found in the pull request body." + upsert_sync_comment(client, repo, ctx.number, render_failure_comment(message), dry_run=dry_run) + return 1 + + task = client.get_issue(repo, task_number) + if "pull_request" in task: + message = f"Linked item #{task_number} is a pull request, not an implementation issue/task." + upsert_sync_comment(client, repo, ctx.number, render_failure_comment(message), dry_run=dry_run) + return 1 + + pr_issue = client.get_issue(repo, ctx.number) + sync_pr_metadata(client, repo, ctx, task, pr_issue, config, dry_run=dry_run) + parent_note = sync_parent_relationship(client, repo, task, config, dry_run=dry_run) + + desired_status = project_status_for_context(ctx, config) + if not 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_project_status( + project_client, + repo, + task, + project_number, + desired_status, + 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})." + + upsert_sync_comment( + client, + repo, + ctx.number, + render_success_comment(ctx, task_number, desired_status, project_note, parent_note), + dry_run=dry_run, + ) + return 0 + + +def apply_pr_sync_from_path( + client: GitHubClient, + repo: str, + event_path: str | os.PathLike[str], + 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: + return apply_pr_sync( + client, + repo, + load_event(event_path), + load_sync_config(config_path), + project_client=project_client, + project_number=project_number, + owner=owner, + dry_run=dry_run, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Synchronize a pull request with its linked implementation task") + parser.add_argument("--repo", default=os.getenv("GITHUB_REPOSITORY")) + parser.add_argument("--event-path", default=os.getenv("GITHUB_EVENT_PATH")) + parser.add_argument("--config", default=os.getenv("PROJECT_SETUP_CONFIG", "project_setup.json")) + parser.add_argument("--project-number", type=int) + parser.add_argument("--owner") + parser.add_argument("--dry-run", action="store_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") + if not args.event_path: + raise SystemExit("Missing --event-path or GITHUB_EVENT_PATH") + + client = require_client() + project_pat = get_project_pat() + project_client = GitHubClient(project_pat) if project_pat else None + return apply_pr_sync_from_path( + client, + args.repo, + args.event_path, + args.config, + project_client=project_client, + project_number=project_number_from_value(args.project_number), + owner=args.owner, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validation/repo_quality.py b/scripts/validation/repo_quality.py index 0b50e5a..eb57c13 100644 --- a/scripts/validation/repo_quality.py +++ b/scripts/validation/repo_quality.py @@ -26,6 +26,7 @@ ".github/workflows/project-setup.yml", ".github/workflows/auto-label.yml", ".github/workflows/pr-metadata.yml", + ".github/workflows/pr-sync.yml", ".github/workflows/qa-source-branch.yml", ".github/workflows/main-source-branch.yml", "config/project/labels.json", @@ -40,6 +41,7 @@ "project_setup/runner.py", "project_setup/installer.py", "project_setup/github.py", + "project_setup/pr_sync.py", "scripts/validation/repo_quality.py", "scripts/validation/validate_pr_body.py", ) @@ -62,6 +64,7 @@ "tests/test_script_references.py", "tests/test_branch_promotion.py", "tests/test_qa_workflows.py", + "tests/test_pr_sync.py", "tests/qa/test_cli_e2e.py", "tests/qa/live_sandbox.py", "tests/qa/live_issue_generation.py", diff --git a/tests/test_pr_sync.py b/tests/test_pr_sync.py new file mode 100644 index 0000000..d48fed3 --- /dev/null +++ b/tests/test_pr_sync.py @@ -0,0 +1,299 @@ +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_sync import ( + DEFAULT_SYNC_CONFIG, + PullRequestContext, + SYNC_MARKER, + apply_pr_sync, + linked_task_number, + load_sync_config, + parent_issue_number, + project_status_for_context, + sync_parent_relationship, + sync_pr_metadata, + upsert_sync_comment, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class PrSyncTests(unittest.TestCase): + def context(self, **overrides) -> PullRequestContext: + values = { + "number": 33, + "action": "opened", + "body": "Closes #12", + "base_ref": "develop", + "head_ref": "feat/example", + "head_repo": "owner/repo", + "author": "alice", + "draft": False, + "merged": False, + } + values.update(overrides) + return PullRequestContext(**values) + + def event(self, **overrides) -> dict: + pull_request = { + "number": 33, + "body": "Closes #12", + "base": {"ref": "develop"}, + "head": {"ref": "feat/example", "repo": {"full_name": "owner/repo"}}, + "user": {"login": "alice"}, + "draft": False, + "merged": False, + } + pull_request.update(overrides.pop("pull_request", {})) + event = {"action": "opened", "pull_request": pull_request} + event.update(overrides) + return event + + def test_linked_task_parser_accepts_closing_keywords(self): + for body in ("Closes #12", "fixes: #12", "Resolves #12"): + with self.subTest(body=body): + self.assertEqual(linked_task_number(body), 12) + + def test_parent_parser_accepts_generated_task_body(self): + self.assertEqual(parent_issue_number("Parent story: US-12 (#8)"), 8) + self.assertEqual(parent_issue_number("Parent issue: #9"), 9) + + def test_default_lifecycle_mapping(self): + self.assertEqual( + project_status_for_context(self.context(draft=True), DEFAULT_SYNC_CONFIG), + "In progress", + ) + self.assertEqual( + project_status_for_context(self.context(action="ready_for_review"), DEFAULT_SYNC_CONFIG), + "In review", + ) + self.assertEqual( + project_status_for_context(self.context(action="closed", merged=False), DEFAULT_SYNC_CONFIG), + "In progress", + ) + self.assertEqual( + project_status_for_context(self.context(action="closed", merged=True), DEFAULT_SYNC_CONFIG), + "Done", + ) + + def test_config_merges_custom_status_with_defaults(self): + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: + json.dump( + { + "prAutomation": { + "sync": { + "syncAssignees": False, + "projectStatus": {"review": "Review"}, + } + } + }, + handle, + ) + path = handle.name + + config = load_sync_config(path) + + self.assertFalse(config["syncAssignees"]) + self.assertEqual(config["projectStatus"]["review"], "Review") + self.assertEqual(config["projectStatus"]["merged"], "Done") + + def test_metadata_sync_adds_only_configured_labels_and_missing_assignees(self): + client = Mock(spec=GitHubClient) + task = { + "number": 12, + "labels": [ + {"name": "type:task"}, + {"name": "priority:high"}, + {"name": "status:backlog"}, + ], + "milestone": {"number": 4}, + "assignees": [{"login": "alice"}], + } + pr_issue = { + "labels": [{"name": "type:task"}], + "milestone": None, + "assignees": [], + } + + sync_pr_metadata( + client, + "owner/repo", + self.context(), + task, + pr_issue, + DEFAULT_SYNC_CONFIG, + ) + + client.request_json.assert_any_call( + "POST", + "https://api.github.com/repos/owner/repo/issues/33/labels", + {"labels": ["priority:high"]}, + ) + client.request_json.assert_any_call( + "POST", + "https://api.github.com/repos/owner/repo/issues/33/assignees", + {"assignees": ["alice"]}, + ) + client.update_issue.assert_called_once_with("owner/repo", 33, {"milestone": 4}) + + def test_metadata_sync_can_assign_author_when_task_is_unassigned(self): + client = Mock(spec=GitHubClient) + task = {"number": 12, "labels": [], "milestone": None, "assignees": []} + pr_issue = {"labels": [], "milestone": None, "assignees": []} + + sync_pr_metadata( + client, + "owner/repo", + self.context(author="alice"), + task, + pr_issue, + DEFAULT_SYNC_CONFIG, + ) + + client.request_json.assert_any_call( + "POST", + "https://api.github.com/repos/owner/repo/issues/12/assignees", + {"assignees": ["alice"]}, + ) + client.request_json.assert_any_call( + "POST", + "https://api.github.com/repos/owner/repo/issues/33/assignees", + {"assignees": ["alice"]}, + ) + + def test_parent_sync_treats_existing_relationship_as_idempotent(self): + client = Mock(spec=GitHubClient) + task = {"number": 12, "body": "Parent story: US-1 (#8)"} + + with patch( + "project_setup.pr_sync.add_sub_issue", + side_effect=RuntimeError("duplicate sub-issues"), + ): + note = sync_parent_relationship( + client, + "owner/repo", + task, + DEFAULT_SYNC_CONFIG, + ) + + self.assertIn("already", note) + + def test_fork_pull_request_is_skipped_before_reads(self): + client = Mock(spec=GitHubClient) + event = self.event( + pull_request={ + "head": { + "ref": "feat/example", + "repo": {"full_name": "fork-owner/repo"}, + } + } + ) + + result = apply_pr_sync(client, "owner/repo", event, DEFAULT_SYNC_CONFIG) + + self.assertEqual(result, 0) + client.get_issue.assert_not_called() + + def test_promotion_pull_request_is_skipped(self): + client = Mock(spec=GitHubClient) + event = self.event( + pull_request={ + "base": {"ref": "Q.A"}, + "head": {"ref": "develop", "repo": {"full_name": "owner/repo"}}, + } + ) + + result = apply_pr_sync(client, "owner/repo", event, DEFAULT_SYNC_CONFIG) + + self.assertEqual(result, 0) + client.get_issue.assert_not_called() + + def test_missing_linked_task_sets_sticky_failure_comment(self): + client = Mock(spec=GitHubClient) + client.list_issue_comments.return_value = [] + event = self.event(pull_request={"body": ""}) + + result = apply_pr_sync(client, "owner/repo", event, DEFAULT_SYNC_CONFIG) + + self.assertEqual(result, 1) + body = client.create_issue_comment.call_args.args[2] + self.assertIn(SYNC_MARKER, body) + self.assertIn("No linked implementation task", body) + + def test_linked_pull_request_is_rejected_as_task(self): + client = Mock(spec=GitHubClient) + client.get_issue.return_value = { + "number": 12, + "pull_request": {"url": "https://api.github.com/repos/owner/repo/pulls/12"}, + } + client.list_issue_comments.return_value = [] + + result = apply_pr_sync(client, "owner/repo", self.event(), DEFAULT_SYNC_CONFIG) + + self.assertEqual(result, 1) + self.assertIn( + "not an implementation issue/task", + client.create_issue_comment.call_args.args[2], + ) + + def test_success_comment_is_updated_instead_of_duplicated(self): + client = Mock(spec=GitHubClient) + client.list_issue_comments.return_value = [{"id": 99, "body": f"{SYNC_MARKER}\nold"}] + + upsert_sync_comment( + client, + "owner/repo", + 33, + f"{SYNC_MARKER}\nnew", + ) + + client.update_issue_comment.assert_called_once_with( + "owner/repo", + 99, + f"{SYNC_MARKER}\nnew", + ) + client.create_issue_comment.assert_not_called() + + +class PrSyncWorkflowContractTests(unittest.TestCase): + def test_workflow_uses_trusted_base_and_guardrail_completion(self): + text = (ROOT / ".github/workflows/pr-sync.yml").read_text(encoding="utf-8") + + for expected in ( + "name: PR Sync", + "pull_request_target:", + "- converted_to_draft", + "- closed", + "workflow_run:", + '"PR metadata validation"', + '"PR guardrails"', + "github.event.workflow_run.conclusion == 'success'", + "github.event.pull_request.head.repo.full_name == github.repository", + "github.event.workflow_run.head_repository.full_name == github.repository", + "ref: ${{ github.event.pull_request.base.sha }}", + "ref: refs/heads/${{ github.event.workflow_run.pull_requests[0].base.ref }}", + "persist-credentials: false", + "PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }}", + "PROJECT_SETUP_PROJECT_NUMBER: ${{ vars.PROJECT_SETUP_PROJECT_NUMBER }}", + "python -m project_setup.pr_sync", + ): + with self.subTest(expected=expected): + self.assertIn(expected, text) + + self.assertNotIn("github.event.pull_request.head.sha", text) + self.assertNotIn("refs/heads/${{ github.event.pull_request.head.ref }}", text) + + def test_installer_distributes_pr_sync_workflow(self): + text = (ROOT / "project_setup/installer.py").read_text(encoding="utf-8") + self.assertIn('".github/workflows/pr-sync.yml"', text) + + +if __name__ == "__main__": + unittest.main()