diff --git a/.crane/scripts/score.go b/.crane/scripts/score.go index 4c1fbacc..70ebe370 100644 --- a/.crane/scripts/score.go +++ b/.crane/scripts/score.go @@ -64,6 +64,8 @@ type CutoverGates struct { FunctionalContracts float64 `json:"functional_contracts"` StateDiffContracts float64 `json:"state_diff_contracts"` PythonBehaviorContracts float64 `json:"python_behavior_contracts"` + UpstreamFreshness string `json:"upstream_freshness"` + UpstreamContracts float64 `json:"upstream_contracts"` GoldenFixtureCorpus string `json:"golden_fixture_corpus"` AllGoGoldenTests string `json:"all_go_golden_tests"` NoPythonRuntime string `json:"no_python_runtime_dependency"` @@ -104,6 +106,8 @@ type Score struct { PythonTestsPassing bool `json:"python_tests_passing"` GoTestsPassing bool `json:"go_tests_passing"` BenchmarksPassing bool `json:"benchmarks_passing"` + UpstreamFreshness bool `json:"upstream_freshness"` + UpstreamContracts float64 `json:"upstream_contracts"` GoldenFixtureCorpus bool `json:"golden_fixture_corpus"` AllGoGoldenTests bool `json:"all_go_golden_tests"` NoPythonRuntime bool `json:"no_python_runtime_dependency"` @@ -152,6 +156,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { functional := RatioGate{} stateDiff := RatioGate{} behaviorContracts := RatioGate{} + upstreamFreshness := BoolGate{} + upstreamContracts := RatioGate{} goldenFixtureCorpus := BoolGate{} allGoGoldenTests := BoolGate{} noPythonRuntime := BoolGate{} @@ -172,6 +178,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { &functional, &stateDiff, &behaviorContracts, + &upstreamFreshness, + &upstreamContracts, &goldenFixtureCorpus, &allGoGoldenTests, &noPythonRuntime, @@ -199,6 +207,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { &functional, &stateDiff, &behaviorContracts, + &upstreamFreshness, + &upstreamContracts, &goldenFixtureCorpus, &allGoGoldenTests, &noPythonRuntime, @@ -283,6 +293,12 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { if !behaviorContracts.Seen { behaviorContracts = missingRatioGate() } + if !upstreamFreshness.Seen { + upstreamFreshness = BoolGate{Seen: true, Passed: false} + } + if !upstreamContracts.Seen { + upstreamContracts = missingRatioGate() + } if !pythonTests.Seen { pythonTests = BoolGate{Seen: true, Passed: testPassed(passed, failed, "TestParityCompletionPythonSuite")} } @@ -302,6 +318,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { FunctionalContracts: functional.Percent(), StateDiffContracts: stateDiff.Percent(), PythonBehaviorContracts: behaviorContracts.Percent(), + UpstreamFreshness: passFail(upstreamFreshness.OK()), + UpstreamContracts: upstreamContracts.Percent(), GoldenFixtureCorpus: passFail(goldenFixtureCorpus.OK()), AllGoGoldenTests: passFail(allGoGoldenTests.OK()), NoPythonRuntime: passFail(noPythonRuntime.OK()), @@ -328,6 +346,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { gates.FunctionalContracts == 1.0 && gates.StateDiffContracts == 1.0 && gates.PythonBehaviorContracts == 1.0 && + gates.UpstreamFreshness == "pass" && + gates.UpstreamContracts == 1.0 && gates.GoldenFixtureCorpus == "pass" && gates.AllGoGoldenTests == "pass" && gates.NoPythonRuntime == "pass" && @@ -372,6 +392,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { PythonTestsPassing: gates.PythonTests == "pass", GoTestsPassing: gates.GoTests == "pass", BenchmarksPassing: gates.Benchmarks == "pass", + UpstreamFreshness: gates.UpstreamFreshness == "pass", + UpstreamContracts: gates.UpstreamContracts, GoldenFixtureCorpus: gates.GoldenFixtureCorpus == "pass", AllGoGoldenTests: gates.AllGoGoldenTests == "pass", NoPythonRuntime: gates.NoPythonRuntime == "pass", @@ -405,6 +427,8 @@ func applyGateEvent( functional *RatioGate, stateDiff *RatioGate, behaviorContracts *RatioGate, + upstreamFreshness *BoolGate, + upstreamContracts *RatioGate, goldenFixtureCorpus *BoolGate, allGoGoldenTests *BoolGate, noPythonRuntime *BoolGate, @@ -427,6 +451,10 @@ func applyGateEvent( *stateDiff = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total} case "python_behavior_contracts": *behaviorContracts = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total} + case "upstream_freshness": + *upstreamFreshness = BoolGate{Seen: true, Passed: gate.Passed} + case "upstream_contracts": + *upstreamContracts = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total} case "golden_fixture_corpus": *goldenFixtureCorpus = BoolGate{Seen: true, Passed: gate.Passed} case "all_go_golden_tests": @@ -485,6 +513,8 @@ func gateResults(gates CutoverGates) []GateResult { {Name: "functional_contracts", Passing: gates.FunctionalContracts == 1.0}, {Name: "state_diff_contracts", Passing: gates.StateDiffContracts == 1.0}, {Name: "python_behavior_contracts", Passing: gates.PythonBehaviorContracts == 1.0}, + {Name: "upstream_freshness", Passing: gates.UpstreamFreshness == "pass"}, + {Name: "upstream_contracts", Passing: gates.UpstreamContracts == 1.0}, {Name: "golden_fixture_corpus", Passing: gates.GoldenFixtureCorpus == "pass"}, {Name: "all_go_golden_tests", Passing: gates.AllGoGoldenTests == "pass"}, {Name: "no_python_runtime_dependency", Passing: gates.NoPythonRuntime == "pass"}, diff --git a/.github/workflows/migration-ci.yml b/.github/workflows/migration-ci.yml index d5958e05..94ba3646 100644 --- a/.github/workflows/migration-ci.yml +++ b/.github/workflows/migration-ci.yml @@ -64,6 +64,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: @@ -92,12 +94,16 @@ jobs: - name: Run CLI-agnostic Python behavior tests shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ENFORCE_COMPLETION_INPUT: ${{ inputs.enforce_completion }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | go build -o "$RUNNER_TEMP/apm-go" ./cmd/apm enforce_behavior_contracts=false - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.enforce_completion == true }}" = "true" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${ENFORCE_COMPLETION_INPUT:-false}" = "true" ]; then enforce_behavior_contracts=true - elif [ "${{ github.event_name }}" = "pull_request" ] && [[ "${{ github.event.pull_request.head.ref }}" == crane/* ]]; then + elif [ "$EVENT_NAME" = "pull_request" ] && [[ "${HEAD_REF:-}" == crane/* ]]; then enforce_behavior_contracts=true fi if [ "$enforce_behavior_contracts" = "true" ]; then @@ -113,11 +119,15 @@ jobs: - name: Run Go parity tests shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ENFORCE_COMPLETION_INPUT: ${{ inputs.enforce_completion }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | enforce_completion=false - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.enforce_completion == true }}" = "true" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${ENFORCE_COMPLETION_INPUT:-false}" = "true" ]; then enforce_completion=true - elif [ "${{ github.event_name }}" = "pull_request" ] && [[ "${{ github.event.pull_request.head.ref }}" == crane/* ]]; then + elif [ "$EVENT_NAME" = "pull_request" ] && [[ "${HEAD_REF:-}" == crane/* ]]; then enforce_completion=true fi @@ -136,10 +146,10 @@ jobs: shell: bash run: | set +e - APM_PYTHON_BIN= \ - APM_PYTHON_CONTRACT_INVENTORY= \ - PYTHONPATH= \ - VIRTUAL_ENV= \ + APM_PYTHON_BIN="" \ + APM_PYTHON_CONTRACT_INVENTORY="" \ + PYTHONPATH="" \ + VIRTUAL_ENV="" \ go test -json ./cmd/apm -run '^TestGoCutover' \ | tee "$RUNNER_TEMP/go-cutover-events.json" status=${PIPESTATUS[0]} @@ -147,6 +157,32 @@ jobs: cat "$RUNNER_TEMP/go-cutover-events.json" >> "$RUNNER_TEMP/go-test-events.json" echo "GO_CUTOVER_STATUS=$status" >> "$GITHUB_ENV" + - name: Check upstream APM contract coverage + shell: bash + run: | + git remote add upstream https://github.com/microsoft/apm.git 2>/dev/null || \ + git remote set-url upstream https://github.com/microsoft/apm.git + git fetch upstream main --prune + + upstream_args=( + --upstream-ref upstream/main + --head-ref HEAD + --coverage tests/parity/upstream_contract_coverage.yml + --summary "$RUNNER_TEMP/upstream-apm-contracts.md" + ) + if [ "${MIGRATION_COMPLETION_ENFORCED:-false}" = "true" ]; then + upstream_args+=(--enforce) + fi + + set +e + uv run python scripts/ci/upstream_apm_contracts.py check \ + "${upstream_args[@]}" \ + | tee "$RUNNER_TEMP/upstream-apm-contracts.txt" + status=${PIPESTATUS[0]} + set -e + cat "$RUNNER_TEMP/upstream-apm-contracts.txt" >> "$RUNNER_TEMP/go-test-events.json" + echo "UPSTREAM_APM_STATUS=$status" >> "$GITHUB_ENV" + - name: Compute migration score run: | go run .crane/scripts/score.go < "$RUNNER_TEMP/go-test-events.json" | tee "$RUNNER_TEMP/migration-score.json" @@ -191,6 +227,7 @@ jobs: test "${PYTHON_CLI_CONTRACT_STATUS:-1}" = "0" test "${GO_TEST_STATUS:-1}" = "0" test "${GO_CUTOVER_STATUS:-1}" = "0" + test "${UPSTREAM_APM_STATUS:-1}" = "0" else if [ "${PYTHON_CLI_CONTRACT_STATUS:-1}" != "0" ]; then echo "::notice::Python behavior contract tests are incomplete in collection mode." @@ -201,6 +238,9 @@ jobs: if [ "${GO_CUTOVER_STATUS:-1}" != "0" ]; then echo "::notice::Go-only cutover gate is incomplete in collection mode." fi + if [ "${UPSTREAM_APM_STATUS:-1}" != "0" ]; then + echo "::notice::Upstream APM freshness/contract coverage is incomplete in collection mode." + fi fi - name: Upload parity evidence @@ -215,6 +255,8 @@ jobs: ${{ runner.temp }}/python-behavior-contracts.json ${{ runner.temp }}/python-contract-coverage.md ${{ runner.temp }}/python-cli-contract-tests.txt + ${{ runner.temp }}/upstream-apm-contracts.txt + ${{ runner.temp }}/upstream-apm-contracts.md if-no-files-found: ignore retention-days: 14 @@ -247,11 +289,15 @@ jobs: - name: Run Python-vs-Go CLI benchmark shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ENFORCE_COMPLETION_INPUT: ${{ inputs.enforce_completion }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | enforce_completion=false - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.enforce_completion == true }}" = "true" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${ENFORCE_COMPLETION_INPUT:-false}" = "true" ]; then enforce_completion=true - elif [ "${{ github.event_name }}" = "pull_request" ] && [[ "${{ github.event.pull_request.head.ref }}" == crane/* ]]; then + elif [ "$EVENT_NAME" = "pull_request" ] && [[ "${HEAD_REF:-}" == crane/* ]]; then enforce_completion=true fi diff --git a/.github/workflows/upstream-apm-sync.yml b/.github/workflows/upstream-apm-sync.yml new file mode 100644 index 00000000..7848499e --- /dev/null +++ b/.github/workflows/upstream-apm-sync.yml @@ -0,0 +1,137 @@ +name: Upstream APM Sync + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: upstream-apm-sync + cancel-in-progress: false + +env: + UPSTREAM_REPO: https://github.com/microsoft/apm.git + UPSTREAM_BRANCH: main + SYNC_BRANCH: automation/upstream-microsoft-apm-main + +jobs: + sync: + name: Sync microsoft/apm main + runs-on: ubuntu-24.04 + steps: + - name: Check out main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Fetch upstream + run: | + git remote add upstream "$UPSTREAM_REPO" 2>/dev/null || \ + git remote set-url upstream "$UPSTREAM_REPO" + git fetch upstream "$UPSTREAM_BRANCH" --prune + git fetch origin main --prune + + - name: Merge upstream into sync branch + id: merge + shell: bash + run: | + upstream_ref="upstream/${UPSTREAM_BRANCH}" + upstream_sha="$(git rev-parse "$upstream_ref")" + origin_sha="$(git rev-parse origin/main)" + echo "upstream_sha=$upstream_sha" >> "$GITHUB_OUTPUT" + echo "origin_sha=$origin_sha" >> "$GITHUB_OUTPUT" + + if git merge-base --is-ancestor "$upstream_ref" origin/main; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "origin/main already contains ${upstream_ref} (${upstream_sha})." + exit 0 + fi + + git switch --force-create "$SYNC_BRANCH" origin/main + if ! git merge --no-ff --no-edit "$upstream_ref"; then + git status --short + echo "::error::Automatic upstream merge conflicted. Resolve manually by merging ${upstream_ref} into main." + exit 1 + fi + git push --force-with-lease origin "$SYNC_BRANCH" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Create or update sync PR + if: steps.merge.outputs.changed == 'true' + id: pr + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + body="$RUNNER_TEMP/upstream-sync-pr.md" + cat > "$body" <> "$GITHUB_OUTPUT" + gh pr view "$pr_number" --json url --jq .url + + - name: Request merge-commit auto-merge + if: steps.merge.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh pr merge "${{ steps.pr.outputs.number }}" --auto --merge --delete-branch; then + echo "Auto-merge requested for upstream sync PR #${{ steps.pr.outputs.number }}." + else + echo "::warning::Could not enable auto-merge. PR #${{ steps.pr.outputs.number }} is ready for maintainer review/merge." + fi + + - name: Summarize + if: always() + run: | + { + echo "## Upstream APM Sync" + echo + echo "- Upstream: \`${UPSTREAM_REPO}\`" + echo "- Branch: \`${UPSTREAM_BRANCH}\`" + echo "- Sync branch: \`${SYNC_BRANCH}\`" + echo "- Changed: \`${{ steps.merge.outputs.changed || 'unknown' }}\`" + echo "- Upstream SHA: \`${{ steps.merge.outputs.upstream_sha || 'unknown' }}\`" + echo "- Origin SHA: \`${{ steps.merge.outputs.origin_sha || 'unknown' }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/cmd/apm/CUTOVER.md b/cmd/apm/CUTOVER.md index aa772e66..5697342a 100644 --- a/cmd/apm/CUTOVER.md +++ b/cmd/apm/CUTOVER.md @@ -26,6 +26,8 @@ Gate summary: | functional_contracts | required | | state_diff_contracts | required | | python_behavior_contracts | required; no obsolete or help-only mappings | +| upstream_freshness | required; `HEAD` must contain the reviewed `microsoft/apm@main` SHA | +| upstream_contracts | required; every upstream Python behavior delta must map to existing Go tests | | golden_fixture_corpus | required | | all_go_golden_tests | required | | no_python_runtime_dependency | required | @@ -52,8 +54,10 @@ The output must show `"migration_score": 1` and `"cutover_ready": true`. Every completion criterion must be backed by real command execution. The scorer does not infer completion from test names for `surface`, `help`, -`option_parity`, `functional`, `state_diff`, `python_behavior_contracts`, or -`benchmarks`; each one must emit an explicit ratio gate. +`option_parity`, `functional`, `state_diff`, `python_behavior_contracts`, +`upstream_contracts`, or `benchmarks`; each ratio criterion must emit an +explicit ratio gate. The `upstream_freshness` boolean gate must also pass before +completion can be claimed. Crane must run `APM_PYTHON_BIN= go test ./cmd/apm -run TestGoCutover -json`. These fixture-backed tests execute the built Go `apm` binary in temporary @@ -64,6 +68,8 @@ directly: {"crane":"gate","name":"functional","passing":N,"total":N} {"crane":"gate","name":"state_diff","passing":N,"total":N} {"crane":"gate","name":"python_behavior_contracts","passing":N,"total":N} +{"crane":"gate","name":"upstream_freshness","passed":true} +{"crane":"gate","name":"upstream_contracts","passing":N,"total":N} {"crane":"gate","name":"golden_fixture_corpus","passed":true} {"crane":"gate","name":"all_go_golden_tests","passed":true} {"crane":"gate","name":"no_python_runtime_dependency","passed":true} @@ -117,6 +123,28 @@ benchmark fixture coverage before Crane can claim it moved the migration forward. Shims, dry-runs, mocks, and help-only assertions do not count as command completion. +## Upstream Freshness Criteria + +The migration is incomplete if this repository is stale relative to upstream +`microsoft/apm@main`. The scheduled `Upstream APM Sync` workflow fetches +`microsoft/apm`, creates or updates an upstream merge PR, and requests +merge-commit auto-merge so upstream history remains reachable. + +After each upstream merge, reviewers must inspect the upstream Python diff and +advance `tests/parity/upstream_contract_coverage.yml` with a reviewed range from +the previous upstream SHA to the new upstream SHA. Every changed public Python +source contract under `src/apm_cli/` and every changed Python test under +`tests/` must map to one or more existing Go tests. The checker emits: + +```json +{"crane":"gate","name":"upstream_freshness","passed":true} +{"crane":"gate","name":"upstream_contracts","passing":N,"total":N} +``` + +Both gates are deletion-grade completion gates. A stale upstream SHA, a missing +reviewed range, a missing Go test mapping, or a stale Go test name blocks +`migration_score = 1.0`. + ## Cutover Trigger Conditions The Go binary becomes the shipped `apm` command when ALL of the following @@ -138,18 +166,21 @@ are true: paths while the Python reference is still available 6. Migration benchmarks pass real fixture-backed command workloads and emit a passing counted `benchmarks` gate -7. The final Python-reference parity run has been frozen into a committed, +7. `HEAD` contains the current reviewed `microsoft/apm@main` SHA, and every + upstream Python behavior delta since the upstream baseline has reviewed Go + test coverage in `tests/parity/upstream_contract_coverage.yml` +8. The final Python-reference parity run has been frozen into a committed, versioned golden fixture corpus. The corpus must include CLI inventory, help and usage output, error output, exit codes, generated files, lockfiles, config files, managed-file manifests, deterministic cache/config layout, and audit artifacts for the full command matrix. -8. An all-Go golden replay passes against that corpus with no live Python +9. An all-Go golden replay passes against that corpus with no live Python oracle. The replay must build `cmd/apm` and compare only the Go binary against checked-in fixtures. -9. A no-Python-runtime check passes: `APM_PYTHON_BIN` is unset, the Python CLI +10. A no-Python-runtime check passes: `APM_PYTHON_BIN` is unset, the Python CLI is hidden or unavailable to the replay, and the golden replay still passes. -10. `go build ./cmd/apm` produces a single static binary -11. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`) +11. `go build ./cmd/apm` produces a single static binary +12. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`) ## Cutover Steps diff --git a/cmd/apm/testdata/go_cutover/python_test_coverage.json b/cmd/apm/testdata/go_cutover/python_test_coverage.json index 341efbe0..dca4fcf1 100644 --- a/cmd/apm/testdata/go_cutover/python_test_coverage.json +++ b/cmd/apm/testdata/go_cutover/python_test_coverage.json @@ -66219,6 +66219,9 @@ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_crane_score.py::test_crane_score_blocks_incomplete_upstream_contract_gate": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], "tests/unit/test_crane_score.py::test_crane_score_blocks_known_exceptions": [ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" @@ -66227,6 +66230,9 @@ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_crane_score.py::test_crane_score_blocks_stale_upstream_freshness_gate": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], "tests/unit/test_crane_score.py::test_crane_score_can_reach_one_with_all_deletion_grade_gates": [ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" @@ -78936,6 +78942,27 @@ "tests/unit/test_update_policy.py::TestIsSelfUpdateEnabled::test_returns_true_when_enabled_is_true": [ "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_upstream_apm_contracts.py::test_upstream_contracts_accept_reviewed_range_with_existing_go_tests": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_contracts.py::test_upstream_contracts_fail_when_upstream_adds_unreviewed_python_behavior": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_contracts.py::test_upstream_contracts_pass_when_reviewed_sha_matches_head": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_contracts.py::test_upstream_contracts_require_chained_reviewed_range_when_sha_advances": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_sync_workflow.py::test_upstream_sync_workflow_fetches_and_merges_microsoft_apm": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_sync_workflow.py::test_upstream_sync_workflow_tells_reviewers_to_update_go_coverage": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_sync_workflow.py::test_upstream_sync_workflow_uses_pr_auto_merge_not_squash": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], "tests/unit/test_version.py::TestGetBuildSha::test_build_sha_constant_skips_git": [ "TestParityCLIVersionOutputFormat", "TestGoCutoverRealFunctionalAndStateDiffContracts" diff --git a/scripts/ci/upstream_apm_contracts.py b/scripts/ci/upstream_apm_contracts.py new file mode 100644 index 00000000..995feb28 --- /dev/null +++ b/scripts/ci/upstream_apm_contracts.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +"""Check upstream microsoft/apm freshness and Go migration coverage. + +The Go migration is not complete just because it matches the Python code that +was present when the migration started. It must also be current with upstream +``microsoft/apm`` and every upstream Python behavior delta must be reviewed and +mapped to real Go tests before Crane can claim completion. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +GO_TEST_RE = re.compile(r"^func\s+(Test[A-Za-z0-9_]*)\s*\(") + + +@dataclass(frozen=True) +class Contract: + id: str + kind: str + + +@dataclass(frozen=True) +class Finding: + code: str + contract: str + message: str + + +@dataclass(frozen=True) +class CheckResult: + upstream_sha: str + reviewed_sha: str + freshness_ok: bool + contracts_passing: int + contracts_total: int + findings: list[Finding] + freshness_findings: list[str] + + +def _run_git( + root: Path, args: list[str], *, check: bool = True +) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 - git args are fixed by callers in this CI checker. + ["git", *args], # noqa: S607 - git is expected on PATH in CI and local tests. + cwd=root, + text=True, + capture_output=True, + check=check, + ) + + +def _git_stdout(root: Path, args: list[str]) -> str: + return _run_git(root, args).stdout.strip() + + +def _rev_parse(root: Path, ref: str) -> str: + return _git_stdout(root, ["rev-parse", ref]) + + +def _has_object(root: Path, ref: str) -> bool: + return _run_git(root, ["cat-file", "-e", f"{ref}^{{commit}}"], check=False).returncode == 0 + + +def _is_ancestor(root: Path, ancestor: str, descendant: str) -> bool: + return ( + _run_git( + root, + ["merge-base", "--is-ancestor", ancestor, descendant], + check=False, + ).returncode + == 0 + ) + + +def _changed_python_files(root: Path, start: str, end: str) -> list[str]: + out = _git_stdout( + root, + [ + "diff", + "--name-only", + "--diff-filter=ACMR", + f"{start}..{end}", + "--", + "src/apm_cli", + "tests", + ], + ) + return sorted( + path + for path in out.splitlines() + if path.endswith(".py") and not path.startswith("tests/parity/") + ) + + +def _blob_text(root: Path, ref: str, path: str) -> str | None: + proc = _run_git(root, ["show", f"{ref}:{path}"], check=False) + if proc.returncode != 0: + return None + return proc.stdout + + +def _source_contracts(path: str, text: str) -> list[Contract]: + try: + tree = ast.parse(text, filename=path) + except SyntaxError: + return [] + contracts: list[Contract] = [] + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + if node.name.startswith("_") and node.name != "__init__": + continue + contracts.append(Contract(id=f"{path}::{node.name}", kind="source")) + return contracts + + +def _test_contracts(path: str, text: str) -> list[Contract]: + try: + tree = ast.parse(text, filename=path) + except SyntaxError: + return [] + contracts: list[Contract] = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith( + "test_" + ): + contracts.append(Contract(id=f"{path}::{node.name}", kind="python_test")) + elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"): + for item in node.body: + if isinstance( + item, (ast.FunctionDef, ast.AsyncFunctionDef) + ) and item.name.startswith("test_"): + contracts.append( + Contract(id=f"{path}::{node.name}::{item.name}", kind="python_test") + ) + return contracts + + +def changed_contracts(root: Path, start: str, end: str) -> list[Contract]: + contracts: list[Contract] = [] + for path in _changed_python_files(root, start, end): + text = _blob_text(root, end, path) + if text is None: + continue + if path.startswith("src/apm_cli/"): + contracts.extend(_source_contracts(path, text)) + elif path.startswith("tests/"): + contracts.extend(_test_contracts(path, text)) + unique = {contract.id: contract for contract in contracts} + return [unique[key] for key in sorted(unique)] + + +def discover_go_tests(root: Path) -> set[str]: + tests: set[str] = set() + for file in sorted((root / "cmd" / "apm").rglob("*_test.go")): + with file.open(encoding="utf-8") as fh: + for line in fh: + match = GO_TEST_RE.match(line.strip()) + if match: + tests.add(match.group(1)) + return tests + + +def _load_yaml(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise ValueError(f"coverage manifest must be a mapping: {path}") + return data + + +def _go_tests_for(entry: object) -> list[str]: + if not isinstance(entry, dict): + return [] + tests = entry.get("go_tests") + if not isinstance(tests, list): + return [] + return [test for test in tests if isinstance(test, str) and test] + + +def _coverage_for_contract(range_entry: dict[str, Any], contract: Contract) -> object: + key = "source_contracts" if contract.kind == "source" else "python_tests" + entries = range_entry.get(key) + if not isinstance(entries, dict): + return None + return entries.get(contract.id) + + +def _validate_contracts( + *, + contracts: list[Contract], + range_entry: dict[str, Any], + go_tests: set[str], + findings: list[Finding], +) -> int: + passing = 0 + for contract in contracts: + entry = _coverage_for_contract(range_entry, contract) + mapped_tests = _go_tests_for(entry) + if not mapped_tests: + findings.append( + Finding( + "missing-upstream-go-tests", + contract.id, + "upstream Python contract lacks mapped Go tests", + ) + ) + continue + unknown = [test for test in mapped_tests if test not in go_tests] + if unknown: + findings.append( + Finding( + "unknown-upstream-go-test", + contract.id, + "mapped Go tests do not exist: " + ", ".join(sorted(unknown)), + ) + ) + continue + passing += 1 + return passing + + +def _range_chain( + coverage: dict[str, Any], + *, + baseline_sha: str, + reviewed_sha: str, +) -> tuple[list[dict[str, Any]], list[Finding]]: + ranges = coverage.get("reviewed_ranges") or [] + if not isinstance(ranges, list): + return [], [Finding("invalid-reviewed-ranges", "reviewed_ranges", "must be a list")] + + by_start: dict[str, dict[str, Any]] = {} + for index, entry in enumerate(ranges): + if not isinstance(entry, dict): + return [], [ + Finding("invalid-reviewed-range", f"reviewed_ranges[{index}]", "must be a mapping") + ] + start = entry.get("from") + end = entry.get("to") + if not isinstance(start, str) or not isinstance(end, str): + return [], [ + Finding( + "invalid-reviewed-range", + f"reviewed_ranges[{index}]", + "range must include string 'from' and 'to' SHAs", + ) + ] + if start in by_start: + return [], [Finding("duplicate-reviewed-range", start, "multiple ranges start here")] + by_start[start] = entry + + chain: list[dict[str, Any]] = [] + cursor = baseline_sha + seen: set[str] = set() + while cursor != reviewed_sha: + if cursor in seen: + return chain, [Finding("cycle-reviewed-range", cursor, "reviewed range chain loops")] + seen.add(cursor) + entry = by_start.get(cursor) + if entry is None: + return chain, [ + Finding( + "missing-reviewed-range", + f"{cursor}..{reviewed_sha}", + "reviewed_sha advanced without a chained reviewed_ranges entry", + ) + ] + chain.append(entry) + cursor = str(entry["to"]) + return chain, [] + + +def check_upstream_contracts( + *, + root: Path, + coverage_path: Path, + upstream_ref: str, + head_ref: str, +) -> CheckResult: + coverage = _load_yaml(coverage_path) + upstream = coverage.get("upstream") or {} + if not isinstance(upstream, dict): + raise ValueError("coverage manifest must contain upstream mapping") + + baseline_sha = upstream.get("baseline_sha") + reviewed_sha = upstream.get("reviewed_sha") + if not isinstance(baseline_sha, str) or not isinstance(reviewed_sha, str): + raise ValueError("upstream.baseline_sha and upstream.reviewed_sha are required") + + upstream_sha = _rev_parse(root, upstream_ref) + head_sha = _rev_parse(root, head_ref) + freshness_findings: list[str] = [] + if reviewed_sha != upstream_sha: + freshness_findings.append( + f"reviewed upstream SHA {reviewed_sha} does not match {upstream_ref} at {upstream_sha}" + ) + if not _has_object(root, reviewed_sha): + freshness_findings.append(f"reviewed upstream SHA is not present locally: {reviewed_sha}") + elif not _is_ancestor(root, reviewed_sha, head_sha): + freshness_findings.append(f"HEAD does not contain reviewed upstream SHA {reviewed_sha}") + if _has_object(root, upstream_sha) and not _is_ancestor(root, upstream_sha, head_sha): + freshness_findings.append(f"HEAD does not contain current upstream SHA {upstream_sha}") + + go_tests = discover_go_tests(root) + findings: list[Finding] = [] + passing = 0 + total = 0 + + chain, chain_findings = _range_chain( + coverage, + baseline_sha=baseline_sha, + reviewed_sha=reviewed_sha, + ) + findings.extend(chain_findings) + + for range_entry in chain: + start = str(range_entry["from"]) + end = str(range_entry["to"]) + contracts = changed_contracts(root, start, end) + total += len(contracts) + passing += _validate_contracts( + contracts=contracts, + range_entry=range_entry, + go_tests=go_tests, + findings=findings, + ) + + if _has_object(root, reviewed_sha): + pending_contracts = changed_contracts(root, reviewed_sha, upstream_sha) + total += len(pending_contracts) + passing += _validate_contracts( + contracts=pending_contracts, + range_entry={}, + go_tests=go_tests, + findings=findings, + ) + + if total == 0: + total = 1 + passing = 1 if not findings else 0 + + return CheckResult( + upstream_sha=upstream_sha, + reviewed_sha=reviewed_sha, + freshness_ok=not freshness_findings, + contracts_passing=passing, + contracts_total=total, + findings=findings, + freshness_findings=freshness_findings, + ) + + +def render_summary(result: CheckResult, *, limit: int = 80) -> str: + lines = [ + "# Upstream APM Contract Coverage", + "", + f"- Current upstream SHA: `{result.upstream_sha}`", + f"- Reviewed upstream SHA: `{result.reviewed_sha}`", + f"- Freshness: {'pass' if result.freshness_ok else 'fail'}", + f"- Contract coverage: {result.contracts_passing}/{result.contracts_total}", + "", + "## Freshness Findings", + "", + ] + if result.freshness_findings: + lines.extend(f"- {finding}" for finding in result.freshness_findings) + else: + lines.append("No freshness findings.") + + lines.extend(["", "## Contract Findings", ""]) + if result.findings: + for finding in result.findings[:limit]: + lines.append(f"- `{finding.code}` `{finding.contract}`: {finding.message}") + if len(result.findings) > limit: + lines.append(f"- ... {len(result.findings) - limit} more findings omitted") + else: + lines.append("No contract findings.") + return "\n".join(lines) + "\n" + + +def _emit_gates(result: CheckResult) -> None: + print( + json.dumps( + { + "crane": "gate", + "name": "upstream_freshness", + "passed": result.freshness_ok, + }, + sort_keys=True, + ) + ) + print( + json.dumps( + { + "crane": "gate", + "name": "upstream_contracts", + "passing": result.contracts_passing, + "total": result.contracts_total, + }, + sort_keys=True, + ) + ) + + +def cmd_check(args: argparse.Namespace) -> int: + result = check_upstream_contracts( + root=Path(args.root).resolve(), + coverage_path=Path(args.coverage), + upstream_ref=args.upstream_ref, + head_ref=args.head_ref, + ) + _emit_gates(result) + summary = render_summary(result) + print(summary) + if args.summary: + Path(args.summary).write_text(summary, encoding="utf-8") + if args.enforce and ( + not result.freshness_ok or result.contracts_passing != result.contracts_total + ): + return 1 + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + check = sub.add_parser("check", help="check upstream APM freshness and coverage") + check.add_argument("--root", default=str(ROOT), help="repository root") + check.add_argument( + "--coverage", + default=str(ROOT / "tests" / "parity" / "upstream_contract_coverage.yml"), + help="upstream contract coverage manifest", + ) + check.add_argument("--upstream-ref", default="upstream/main") + check.add_argument("--head-ref", default="HEAD") + check.add_argument("--summary", help="write markdown summary to path") + check.add_argument("--enforce", action="store_true", help="fail on stale or uncovered upstream") + check.set_defaults(func=cmd_check) + + args = parser.parse_args(argv) + os.chdir(Path(args.root).resolve()) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/parity/upstream_contract_coverage.yml b/tests/parity/upstream_contract_coverage.yml new file mode 100644 index 00000000..d6a6fcd3 --- /dev/null +++ b/tests/parity/upstream_contract_coverage.yml @@ -0,0 +1,13 @@ +schema_version: 1 +description: > + Upstream microsoft/apm freshness and Go migration coverage ledger. The Go + migration cannot be declared complete unless upstream.reviewed_sha matches + microsoft/apm@main, that SHA is contained in HEAD, and every reviewed range + from baseline_sha to reviewed_sha maps changed Python source/test contracts + to existing Go tests. +upstream: + repo: microsoft/apm + branch: main + baseline_sha: ccdafc451ae92d2c2beb5fdaf9a0311252ce5577 + reviewed_sha: ccdafc451ae92d2c2beb5fdaf9a0311252ce5577 +reviewed_ranges: [] diff --git a/tests/unit/test_crane_score.py b/tests/unit/test_crane_score.py index 243e205b..279de945 100644 --- a/tests/unit/test_crane_score.py +++ b/tests/unit/test_crane_score.py @@ -75,6 +75,8 @@ def _deletion_gates() -> list[str]: '{"crane":"gate","name":"functional","passing":1,"total":1}', '{"crane":"gate","name":"state_diff","passing":1,"total":1}', '{"crane":"gate","name":"python_behavior_contracts","passing":1,"total":1}', + '{"crane":"gate","name":"upstream_freshness","passed":true}', + '{"crane":"gate","name":"upstream_contracts","passing":1,"total":1}', '{"crane":"gate","name":"golden_fixture_corpus","passed":true}', '{"crane":"gate","name":"all_go_golden_tests","passed":true}', '{"crane":"gate","name":"no_python_runtime_dependency","passed":true}', @@ -187,6 +189,8 @@ def test_crane_score_can_reach_one_with_all_deletion_grade_gates() -> None: "functional_contracts": 1.0, "state_diff_contracts": 1.0, "python_behavior_contracts": 1.0, + "upstream_freshness": "pass", + "upstream_contracts": 1.0, "known_exceptions": 0, "golden_fixture_corpus": "pass", "all_go_golden_tests": "pass", @@ -226,6 +230,8 @@ def test_crane_score_can_reach_one_with_no_python_all_go_replay() -> None: '{"crane":"gate","name":"functional","passing":0,"total":1}', '{"crane":"gate","name":"state_diff","passing":0,"total":1}', '{"crane":"gate","name":"python_behavior_contracts","passing":0,"total":1}', + '{"crane":"gate","name":"upstream_freshness","passed":false}', + '{"crane":"gate","name":"upstream_contracts","passing":0,"total":1}', '{"crane":"gate","name":"golden_fixture_corpus","passed":false}', '{"crane":"gate","name":"all_go_golden_tests","passed":false}', '{"crane":"gate","name":"no_python_runtime_dependency","passed":false}', @@ -330,6 +336,8 @@ def test_crane_score_does_not_infer_completion_gates_from_test_names() -> None: assert gates["functional_contracts"]["passing"] is False assert gates["state_diff_contracts"]["passing"] is False assert gates["python_behavior_contracts"]["passing"] is False + assert gates["upstream_freshness"]["passing"] is False + assert gates["upstream_contracts"]["passing"] is False assert gates["benchmarks_pass"]["passing"] is False @@ -356,6 +364,49 @@ def test_crane_score_blocks_incomplete_behavior_contract_gate() -> None: assert gates["python_behavior_contracts"]["passing"] is False +def test_crane_score_blocks_stale_upstream_freshness_gate() -> None: + gates = [line for line in _deletion_gates() if json.loads(line)["name"] != "upstream_freshness"] + score = _run_score( + [ + *_parity_passes(293), + *_completion_gate_events(), + *gates, + '{"crane":"gate","name":"upstream_freshness","passed":false}', + _package_pass(), + ] + ) + gates = _gates(score) + + assert score["progress"] == 1.0 + assert score["migration_score"] < 1.0 + assert score["deletion_grade_ready"] is False + assert gates["upstream_freshness"]["passing"] is False + + +def test_crane_score_blocks_incomplete_upstream_contract_gate() -> None: + gates = [line for line in _deletion_gates() if json.loads(line)["name"] != "upstream_contracts"] + score = _run_score( + [ + *_parity_passes(293), + *_completion_gate_events(), + *gates, + _ratio_gate_output( + "TestParityCompletionUpstreamContracts", + "upstream_contracts", + 1, + 2, + ), + _package_pass(), + ] + ) + gates = _gates(score) + + assert score["progress"] == 1.0 + assert score["migration_score"] < 1.0 + assert score["deletion_grade_ready"] is False + assert gates["upstream_contracts"]["passing"] is False + + def test_crane_score_blocks_incomplete_real_functional_gate() -> None: gates = [line for line in _deletion_gates() if json.loads(line)["name"] != "functional"] score = _run_score( diff --git a/tests/unit/test_migration_ci_workflow.py b/tests/unit/test_migration_ci_workflow.py index 15a0286c..57f618e7 100644 --- a/tests/unit/test_migration_ci_workflow.py +++ b/tests/unit/test_migration_ci_workflow.py @@ -17,9 +17,12 @@ def test_migration_ci_enforces_completion_for_crane_prs_and_explicit_manual_runs assert "MIGRATION_COMPLETION_ENFORCED=$enforce_completion" in text assert "APM_ENFORCE_COMPLETION_GATES=1" in text assert "APM_ENFORCE_PYTHON_BEHAVIOR_CONTRACTS=1" in text + assert "scripts/ci/upstream_apm_contracts.py check" in text + assert "--enforce" in text + assert "UPSTREAM_APM_STATUS" in text assert "--allow-obsolete-python-tests" in text - assert "inputs.enforce_completion == true" in text - assert 'github.event.pull_request.head.ref }}" == crane/*' in text + assert '[ "${ENFORCE_COMPLETION_INPUT:-false}" = "true" ]' in text + assert '[[ "${HEAD_REF:-}" == crane/* ]]' in text assert "manual runs with enforce_completion=true" in text @@ -30,3 +33,4 @@ def test_migration_ci_collects_incomplete_evidence_for_non_crane_prs() -> None: assert "Non-enforcing migration evidence run" in text assert "Python behavior contract tests are incomplete in collection mode." in text assert "Go parity tests are incomplete in collection mode." in text + assert "Upstream APM freshness/contract coverage is incomplete in collection mode." in text diff --git a/tests/unit/test_upstream_apm_contracts.py b/tests/unit/test_upstream_apm_contracts.py new file mode 100644 index 00000000..12b2c01d --- /dev/null +++ b/tests/unit/test_upstream_apm_contracts.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts" / "ci")) + +from upstream_apm_contracts import check_upstream_contracts # noqa: E402 + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], + cwd=repo, + text=True, + capture_output=True, + check=True, + ).stdout.strip() + + +def _commit(repo: Path, message: str) -> str: + _git(repo, "add", ".") + _git(repo, "commit", "-m", message) + return _git(repo, "rev-parse", "HEAD") + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + _git(tmp_path, "init") + _git(tmp_path, "config", "user.email", "test@example.com") + _git(tmp_path, "config", "user.name", "Test User") + (tmp_path / "src" / "apm_cli").mkdir(parents=True) + (tmp_path / "tests" / "unit").mkdir(parents=True) + (tmp_path / "cmd" / "apm").mkdir(parents=True) + (tmp_path / "src" / "apm_cli" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "cmd" / "apm" / "real_behavior_test.go").write_text( + "package main\n\nfunc TestGoUpstreamBehavior(t *testing.T) {}\n", + encoding="utf-8", + ) + _commit(tmp_path, "base") + return tmp_path + + +def _write_manifest(repo: Path, data: dict[str, object]) -> Path: + path = repo / "coverage.yml" + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return path + + +def test_upstream_contracts_pass_when_reviewed_sha_matches_head(repo: Path) -> None: + head = _git(repo, "rev-parse", "HEAD") + manifest = _write_manifest( + repo, + { + "schema_version": 1, + "upstream": {"baseline_sha": head, "reviewed_sha": head}, + "reviewed_ranges": [], + }, + ) + + result = check_upstream_contracts( + root=repo, + coverage_path=manifest, + upstream_ref=head, + head_ref=head, + ) + + assert result.freshness_ok is True + assert result.contracts_passing == result.contracts_total == 1 + assert result.findings == [] + + +def test_upstream_contracts_fail_when_upstream_adds_unreviewed_python_behavior( + repo: Path, +) -> None: + base = _git(repo, "rev-parse", "HEAD") + (repo / "src" / "apm_cli" / "new_feature.py").write_text( + "def kiro_target():\n return 'kiro'\n", + encoding="utf-8", + ) + (repo / "tests" / "unit" / "test_new_feature.py").write_text( + "def test_kiro_target():\n assert True\n", + encoding="utf-8", + ) + upstream = _commit(repo, "upstream behavior") + manifest = _write_manifest( + repo, + { + "schema_version": 1, + "upstream": {"baseline_sha": base, "reviewed_sha": base}, + "reviewed_ranges": [], + }, + ) + + result = check_upstream_contracts( + root=repo, + coverage_path=manifest, + upstream_ref=upstream, + head_ref=base, + ) + + assert result.freshness_ok is False + assert result.contracts_passing == 0 + assert result.contracts_total == 2 + assert {finding.code for finding in result.findings} == {"missing-upstream-go-tests"} + + +def test_upstream_contracts_require_chained_reviewed_range_when_sha_advances( + repo: Path, +) -> None: + base = _git(repo, "rev-parse", "HEAD") + (repo / "src" / "apm_cli" / "new_feature.py").write_text( + "def source_base():\n return 'marketplace'\n", + encoding="utf-8", + ) + upstream = _commit(repo, "upstream source") + manifest = _write_manifest( + repo, + { + "schema_version": 1, + "upstream": {"baseline_sha": base, "reviewed_sha": upstream}, + "reviewed_ranges": [], + }, + ) + + result = check_upstream_contracts( + root=repo, + coverage_path=manifest, + upstream_ref=upstream, + head_ref=upstream, + ) + + assert result.freshness_ok is True + assert [finding.code for finding in result.findings] == ["missing-reviewed-range"] + assert result.contracts_passing == 0 + + +def test_upstream_contracts_accept_reviewed_range_with_existing_go_tests(repo: Path) -> None: + base = _git(repo, "rev-parse", "HEAD") + (repo / "src" / "apm_cli" / "new_feature.py").write_text( + "def optional_registry_inputs():\n return True\n", + encoding="utf-8", + ) + (repo / "tests" / "unit" / "test_new_feature.py").write_text( + "class TestOptionalRegistryInputs:\n" + " def test_preserves_optional_input(self):\n" + " assert True\n", + encoding="utf-8", + ) + upstream = _commit(repo, "upstream contracts") + manifest = _write_manifest( + repo, + { + "schema_version": 1, + "upstream": {"baseline_sha": base, "reviewed_sha": upstream}, + "reviewed_ranges": [ + { + "from": base, + "to": upstream, + "source_contracts": { + "src/apm_cli/new_feature.py::optional_registry_inputs": { + "go_tests": ["TestGoUpstreamBehavior"] + } + }, + "python_tests": { + ( + "tests/unit/test_new_feature.py::" + "TestOptionalRegistryInputs::test_preserves_optional_input" + ): {"go_tests": ["TestGoUpstreamBehavior"]} + }, + } + ], + }, + ) + + result = check_upstream_contracts( + root=repo, + coverage_path=manifest, + upstream_ref=upstream, + head_ref=upstream, + ) + + assert result.freshness_ok is True + assert result.contracts_passing == result.contracts_total == 2 + assert result.findings == [] diff --git a/tests/unit/test_upstream_apm_sync_workflow.py b/tests/unit/test_upstream_apm_sync_workflow.py new file mode 100644 index 00000000..4dde5626 --- /dev/null +++ b/tests/unit/test_upstream_apm_sync_workflow.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "upstream-apm-sync.yml" + + +def _workflow_text() -> str: + return WORKFLOW.read_text(encoding="utf-8") + + +def test_upstream_sync_workflow_fetches_and_merges_microsoft_apm() -> None: + text = _workflow_text() + + assert "https://github.com/microsoft/apm.git" in text + assert "git fetch upstream" in text + assert "git merge --no-ff --no-edit" in text + assert "automation/upstream-microsoft-apm-main" in text + + +def test_upstream_sync_workflow_uses_pr_auto_merge_not_squash() -> None: + text = _workflow_text() + + assert "gh pr create" in text + assert "gh pr merge" in text + assert "--auto --merge --delete-branch" in text + assert "--squash" not in text + + +def test_upstream_sync_workflow_tells_reviewers_to_update_go_coverage() -> None: + text = _workflow_text() + + assert "Review the upstream Python diff" in text + assert "real Go behavior tests" in text + assert "tests/parity/upstream_contract_coverage.yml" in text