diff --git a/README.md b/README.md index 922b666..5ceee08 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,21 @@ Generation is identical to direct push. Only delivery changes: the same commit i With the default `github.token`, the repository or organization must allow GitHub Actions to create pull requests. A GitHub App token or PAT can instead be passed as `github_token`. The same input is used for review comments and sync delivery. +### Keep the diagram on its own branch + +Set `sync_strategy: branch` to keep the analysis off your code branches entirely: + +```yaml + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + llm: hosted + target_branch: main + sync_strategy: branch +``` + +Each sync adds one commit to `codeboarding/baseline` (set `baseline_branch` to change the name), an orphan branch that shares no history with `main`. It holds the same `.codeboarding/` files sync would otherwise commit to `main`, plus `.codeboarding/source.json` naming the commit they describe; the commit message carries the same sha as a `CodeBoarding-Source:` trailer. Pushes only ever fast-forward, `main` is never written, and no pull request is opened. Reviews read their base from the branch, and the web platform reads the latest diagram from it. The [baseline branch section](docs/COMMIT_STRATEGY.md#the-baseline-branch) covers what happens if the branch is deleted and a ruleset you can import to protect it. + ## Inputs | Input | Mode | Default | Description | @@ -316,7 +331,8 @@ With the default `github.token`, the repository or organization must allow GitHu | `parsing_model` | both | empty | Parsing-only override for `model`. | | `depth_cap` | both | `2` | Positive integer maximum analysis depth, including full-analysis fallbacks. Changing it rebuilds incompatible state. | | `github_token` | both | `${{ github.token }}` | Token for comments and sync delivery. | -| `sync_strategy` | sync | `push` | `push` or `pull_request`. | +| `sync_strategy` | sync | `push` | `push`, `pull_request`, or `branch`. | +| `baseline_branch` | both | `codeboarding/baseline` | Branch `sync_strategy: branch` writes; reviews read their base from it when it exists. | | `target_branch` | sync | event branch | Branch receiving the baseline or rolling PR. | | `force_full` | sync | `false` | Ignore the committed baseline for this run. | | `warmstart_retention_days` | review | `1` | Days to keep the reusable analysis. Only the next run reads it. | diff --git a/action.yml b/action.yml index 6465540..dbf5451 100644 --- a/action.yml +++ b/action.yml @@ -143,9 +143,13 @@ inputs: required: false default: ${{ github.token }} sync_strategy: - description: 'Sync delivery method: push or pull_request.' + description: 'Sync delivery method: push, pull_request, or branch (an orphan branch of its own, see baseline_branch).' required: false default: 'push' + baseline_branch: + description: 'Branch that sync_strategy branch keeps the analysis on. Reviews read it too.' + required: false + default: 'codeboarding/baseline' target_branch: description: 'Branch updated by sync mode. Defaults to the event branch.' required: false @@ -223,6 +227,7 @@ runs: HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} TARGET_BRANCH_INPUT: ${{ inputs.target_branch }} SYNC_STRATEGY: ${{ inputs.sync_strategy }} + BASELINE_BRANCH: ${{ inputs.baseline_branch }} COMMENT_BODY: ${{ github.event.comment.body }} AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} @@ -451,6 +456,8 @@ runs: CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target STAGE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/out FORCE_FULL: ${{ inputs.force_full }} + SYNC_STRATEGY: ${{ inputs.sync_strategy }} + BASELINE_BRANCH: ${{ inputs.baseline_branch }} CFG_HASH: ${{ steps.state.outputs.cfg_hash }} # Lets a branch without a usable committed baseline catch up from a saved analysis. ANCESTOR_LOOKUP: ${{ github.server_url == 'https://github.com' && steps.state.outputs.cfg_hash != '' }} @@ -475,6 +482,8 @@ runs: TARGET_BRANCH: ${{ steps.guard.outputs.target_branch }} SYNC_BRANCH_START_SHA: ${{ steps.guard.outputs.sync_branch_start_sha }} SYNC_STRATEGY: ${{ inputs.sync_strategy }} + BASELINE_BRANCH: ${{ inputs.baseline_branch }} + ENGINE_VERSION: ${{ steps.state.outputs.engine_version }} GITHUB_TOKEN: ${{ inputs.github_token }} GH_TOKEN: ${{ inputs.github_token }} GH_ENTERPRISE_TOKEN: ${{ inputs.github_token }} @@ -550,6 +559,7 @@ runs: ANCESTOR_LOOKUP: ${{ github.server_url == 'https://github.com' && steps.state.outputs.cfg_hash != '' }} # For rewriting the progress comment while a base is built from scratch. PROGRESS_HEADER: ${{ steps.guard.outputs.comment_id }} + BASELINE_BRANCH: ${{ inputs.baseline_branch }} BASE_REF: ${{ steps.guard.outputs.base_ref }} REPOSITORY: ${{ github.repository }} GH_HOST: ${{ github.server_url }} diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index b107ed9..733ae65 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -109,6 +109,7 @@ them: |---|---| | the published `codeboarding-base--` artifact with a compatible depth cap | none | | no usable artifact — check out the merge base, seed from a compatible baseline committed there, catch up | one incremental, full if Core requires it | +| the baseline branch (`sync_strategy: branch`): its commit for the merge base, else for the nearest of the merge base's last 100 first-parent ancestors | none for the merge base itself (`saved`), one incremental otherwise (`ancestor`) | | no compatible committed baseline either: the nearest `codeboarding-base--` artifact among the merge base's last 100 first-parent ancestors | one incremental from that commit to the merge base | | none within 100 commits either | full analysis directly, at the configured `depth_cap` | @@ -146,6 +147,52 @@ diffs against, recorded as a digest in `origin.json`. Two runs of the engine ove one commit need not name components identically, so a head descended from one base and a diagram drawn against another would report changes nobody made. +## The baseline branch + +`sync_strategy: branch` keeps the analysis on a branch of its own, +`codeboarding/baseline` unless `baseline_branch` names another. + +**What lives where.** The branch is an orphan: it shares no history with the code. +Each sync adds one commit holding the same `.codeboarding/` files the `push` +strategy would commit to the target branch, plus `.codeboarding/source.json`: + +```json +{"schema": 1, "source_branch": "main", "source_sha": "", "generated_at": "", "engine_version": ""} +``` + +The commit is `chore(codeboarding): diagram of main @` with a +`CodeBoarding-Source: ` trailer. Engine output is never edited; which commit +it describes lives only in `source.json` and the trailer. The target branch is +never written, not even `.gitattributes`. The base artifacts are still published, +named for the analysed commit. + +**How a sync writes it.** It seeds from the branch tip and runs incrementally. The +push is a fast-forward onto the tip it read, never forced. If the target branch +moved during the analysis, the result is dropped, as with `push`. If another sync +moved the baseline branch, it builds on that tip once. A push the remote refuses +while the tip did not move is a branch rule, and the run fails saying so. + +**How a review reads it.** After an exact artifact and a baseline committed at the +merge base, a review lists the newest 100 commits of the branch (fetched without +file contents, so the listing costs commit messages only) and matches their +trailers against the merge base's first-parent history, up to 100 commits deep. +An entry for the merge base itself is `base_source=saved`; an entry for an +ancestor is caught up incrementally and reported as `ancestor`, with +`base_from_sha` naming it. Only then does it look for ancestor artifacts. + +**If the branch is deleted**, the next sync creates it again as a new orphan, +seeding from a saved ancestor artifact when there is one and analyzing in full +otherwise. The history is lost; the current diagram is not. + +**Protecting it.** Import [`baseline-branch-ruleset.json`](baseline-branch-ruleset.json) +under Settings, Rules, Rulesets, New ruleset, Import a ruleset. It blocks deleting +and force-pushing `codeboarding/baseline`, and lists the CodeBoarding Review app +(id `4021464`) as a bypass actor. Sync only fast-forwards, so these two rules +never stop it; the bypass keeps it working if you add stricter rules to that +branch later. If sync pushes with another app or a PAT, put that actor there +instead. Rulesets on a private repository need a paid GitHub plan (Pro, Team or +Enterprise); on Free they apply to public repositories only. + ## Trust boundary `static_analysis.pkl` is a Python pickle, so state derived from code the diff --git a/docs/baseline-branch-ruleset.json b/docs/baseline-branch-ruleset.json new file mode 100644 index 0000000..d03ce29 --- /dev/null +++ b/docs/baseline-branch-ruleset.json @@ -0,0 +1,22 @@ +{ + "name": "CodeBoarding baseline branch", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/codeboarding/baseline"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" } + ], + "bypass_actors": [ + { + "actor_id": 4021464, + "actor_type": "Integration", + "bypass_mode": "always" + } + ] +} diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index f662ca0..f362f6c 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -126,6 +126,16 @@ analyze_sync() { REQUIRES_FULL=true if [ "$(printf '%s' "${FORCE_FULL:-false}" | tr '[:upper:]' '[:lower:]')" != true ]; then + # The baseline branch's tip is this branch's last analysis. Without one, the + # run below seeds from a saved ancestor or analyzes in full, and delivery + # creates the branch again. + if [ "${SYNC_STRATEGY:-}" = branch ]; then + local tip_entry + tip_entry="$(baseline_index "${REPOSITORY:-}" | awk '{print $1; exit}')" + if [ -z "$tip_entry" ] || ! restore_baseline "${REPOSITORY:-}" "$tip_entry" "$state"; then + echo "::notice::$BASELINE_BRANCH has no analysis to continue from; this sync creates it." + fi + fi if [ "$(depth_cap_from "$state/analysis.json")" = "$DEPTH_CAP" ]; then incremental "$CHECKOUT_DIR" "$state" fi @@ -264,6 +274,54 @@ keep_user_config() { done } +# sync_strategy: branch keeps one commit per sync on BASELINE_BRANCH, each with a +# CodeBoarding-Source trailer naming the commit it analysed. Lists them as +# " ", newest first, at most BASELINE_DEPTH of them. +# Fetched without blobs into a scratch repository: the lookup needs messages, and +# a hundred pickles would cost more than it saves. +BASELINE_DEPTH="${BASELINE_DEPTH:-100}" +baseline_index() { + local repository="$1" scratch="$RUNNER_TEMP/codeboarding-baseline-index.git" auth + [ -n "${BASELINE_BRANCH:-}" ] || return 0 + rm -rf "$scratch" + git init -q --bare "$scratch" + auth="$(printf 'x-access-token:%s' "${GIT_TOKEN:-}" | base64 -w0)" + git -C "$scratch" -c "http.extraheader=AUTHORIZATION: basic $auth" fetch -q --filter=blob:none \ + --depth="$BASELINE_DEPTH" "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$BASELINE_BRANCH" 2>/dev/null || + return 0 + git -C "$scratch" log --format='%H %(trailers:key=CodeBoarding-Source,valueonly,separator=%x20)' FETCH_HEAD | + awk 'NF == 2' +} +# Lays a baseline-branch commit's analysis over $3, which keeps the configuration +# seeded from the checkout. source.json is provenance, not engine state. +restore_baseline() { + local repository="$1" commit="$2" state="$3" scratch="$RUNNER_TEMP/codeboarding-baseline-restore" + fetch_commit "$repository" "$commit" || return 1 + rm -rf "$scratch" + mkdir -p "$scratch" "$state" + git -C "$CHECKOUT_DIR" archive "$commit" .codeboarding | tar -x -C "$scratch" || return 1 + rm -f "$scratch/.codeboarding/source.json" + cp -a "$scratch/.codeboarding/." "$state/" +} +# Seeds $3 from the baseline branch's entry for $2, or for its nearest first-parent +# ancestor that has one. Sets BRANCH_SOURCE to the commit it describes and +# BRANCH_DISTANCE to how far below $2 that is. +BRANCH_SOURCE="" BRANCH_DISTANCE="" +seed_from_baseline_branch() { + local repository="$1" tip="$2" state="$3" index commit entry="" distance=0 + BRANCH_SOURCE="" BRANCH_DISTANCE="" + index="$(baseline_index "$repository")" + [ -n "$index" ] || return 1 + fetch_commit "$repository" "$tip" "$(( CATCHUP_BOUND + 1 ))" || true + for commit in $(git -C "$CHECKOUT_DIR" rev-list --first-parent --max-count=$(( CATCHUP_BOUND + 1 )) "$tip" 2>/dev/null); do + entry="$(awk -v source="$commit" '$2 == source {print $1; exit}' <<< "$index")" + [ -z "$entry" ] || break + distance=$(( distance + 1 )) + done + [ -n "$entry" ] && restore_baseline "$repository" "$entry" "$state" || return 1 + BRANCH_SOURCE="$commit" BRANCH_DISTANCE="$distance" +} + # Rewrites the sticky progress comment while the base is built from scratch. A # fork's read-only token makes every call fail, which costs nothing. PROGRESS_PID="" @@ -318,11 +376,12 @@ analyze_review() { # needs no engine run at all. Without one, the merge base is checked out and # analyzed from whatever baseline the repository committed there. Each path # records how the base was obtained, for the comment and the review artifact. - local base_started base_source=saved base_reason="" base_from_sha="" catchup_commits="" + local base_started base_source=saved base_reason="" base_from_sha="" catchup_commits="" base_was_published=false base_started="$(date +%s)" if [ "$(depth_cap_from "${BASE_DIR:-}/analysis.json")" = "$DEPTH_CAP" ]; then mkdir -p "$base_state" cp -a "$BASE_DIR/." "$base_state/" + base_was_published=true base_from_sha="$REVIEW_BASE_SHA" catchup_commits=0 else # A bundle under this exact name that the run cannot use was made with another cap. @@ -344,6 +403,23 @@ analyze_review() { elif [ -f "$base_state/analysis.json" ]; then base_reason=incompatible fi + # The baseline branch: its entry for the merge base is that commit's own + # analysis, and an entry for an ancestor is caught up like a committed one. + if [ "$REQUIRES_FULL" = true ] && seed_from_baseline_branch "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$base_state"; then + if [ "$(depth_cap_from "$base_state/analysis.json")" != "$DEPTH_CAP" ]; then + base_reason=incompatible + elif [ "$BRANCH_DISTANCE" -eq 0 ]; then + REQUIRES_FULL=false base_source=saved base_from_sha="$BRANCH_SOURCE" catchup_commits=0 + else + incremental "$base_checkout" "$base_state" + if [ "$REQUIRES_FULL" = true ]; then + base_reason=incompatible + else + base_source=ancestor base_from_sha="$BRANCH_SOURCE" + catchup_commits="$(catchup_count "$BRANCH_SOURCE" "$REVIEW_BASE_SHA")" + fi + fi + fi # Nothing at the merge base to grow from: catch up from the nearest saved # ancestor, and publish the result under the merge base's own name below. if [ "$REQUIRES_FULL" = true ] && seed_from_ancestor "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$base_state" false "$base_checkout"; then @@ -403,9 +479,10 @@ analyze_review() { # under the same name every run, so normally only a run that produced one # publishes it. The exception is lifetime: a review artifact references a base # by id for its whole retention, so one about to expire is renewed rather than - # left dangling under a review that outlives it. + # left dangling under a review that outlives it. A base read from the baseline + # branch is published too: no artifact holds it yet. local publish_base=false - if [ "$base_source" != saved ] || [ "${RENEW_BASE:-false}" = true ]; then + if [ "$base_was_published" != true ] || [ "${RENEW_BASE:-false}" = true ]; then stage "$base_state" base publish_base=true fi diff --git a/scripts/action/deliver-sync.sh b/scripts/action/deliver-sync.sh index bbc454c..c69824e 100755 --- a/scripts/action/deliver-sync.sh +++ b/scripts/action/deliver-sync.sh @@ -63,6 +63,72 @@ classify_push_failure() { exit 1 } +# sync_strategy: branch keeps the analysis on an orphan branch of its own, one +# fast-forward commit per sync, and never writes to the target branch. +deliver_to_baseline_branch() { + local branch="$BASELINE_BRANCH" tree="$RUNNER_TEMP/codeboarding-baseline-tree" + local index="$RUNNER_TEMP/codeboarding-baseline-index" git_dir files new_tree tip parent commit now + git_dir="$(git rev-parse --absolute-git-dir)" + rm -rf "$tree" "$index" + mkdir -p "$tree" + CHECKOUT_DIR="$tree" "$ACTION_PATH/scripts/action/install-sync.sh" > /dev/null + files="$(find "$tree/.codeboarding" -maxdepth 1 -type f | wc -l | tr -d ' ')" + # Engine output is never edited; which commit it describes lives here only. + python3 -c 'import datetime,json,os,sys +json.dump({ + "schema": 1, + "source_branch": os.environ["TARGET_BRANCH"], + "source_sha": sys.argv[2], + "generated_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "engine_version": os.environ.get("ENGINE_VERSION", ""), +}, open(sys.argv[1], "w"), indent=2)' "$tree/.codeboarding/source.json" "$BASE_SHA" + GIT_INDEX_FILE="$index" git --git-dir="$git_dir" --work-tree="$tree" -C "$tree" add -A -f .codeboarding + new_tree="$(GIT_INDEX_FILE="$index" git --git-dir="$git_dir" write-tree)" + git config user.name 'codeboarding-review[bot]' + git config user.email 'codeboarding-review[bot]@users.noreply.github.com' + + # Two tries: a concurrent sync that moved the branch for an older commit is + # built on top of once. A second move means a newer run is handling it. + for _ in 1 2; do + git fetch -q "$REMOTE" "$TARGET_BRANCH" + if [ "$(git rev-parse FETCH_HEAD)" != "$BASE_SHA" ]; then + emit_result "$files" false "$BASE_SHA" + echo "::notice::$TARGET_BRANCH advanced during analysis; a newer run should update $branch." + exit 0 + fi + tip="$(git ls-remote "$REMOTE" "refs/heads/$branch" | awk '{print $1; exit}')" + parent=() + if [ -n "$tip" ]; then + git fetch -q --depth=1 "$REMOTE" "refs/heads/$branch" + parent=(-p "$tip") + if [ "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" = "$BASE_SHA" ] && + git diff --quiet -I '"generated_at"' -I '"timestamp"' "$tip" "$new_tree"; then + emit_result "$files" false "$BASE_SHA" + echo "::notice::$branch already holds this analysis of $TARGET_BRANCH @${BASE_SHA:0:7}." + exit 0 + fi + fi + commit="$(git commit-tree "$new_tree" ${parent[@]+"${parent[@]}"} \ + -m "chore(codeboarding): diagram of $TARGET_BRANCH @${BASE_SHA:0:7}" \ + -m "CodeBoarding-Source: $BASE_SHA")" + # Never forced: the parent is the tip just read, so this only ever fast-forwards. + if git push -q "$REMOTE" "$commit:refs/heads/$branch"; then + emit_result "$files" true "$BASE_SHA" + echo "baseline_branch_sha=$commit" >> "$GITHUB_OUTPUT" + exit 0 + fi + now="$(git ls-remote "$REMOTE" "refs/heads/$branch" | awk '{print $1; exit}')" + if [ "$now" = "$tip" ]; then + echo "::error::GitHub refused the push to $branch, most likely because a branch rule protects it. Add the CodeBoarding app as a bypass actor for $branch in the repository's rulesets, or set sync_strategy: push." + exit 1 + fi + done + emit_result "$files" false "$BASE_SHA" + echo "::notice::Another sync keeps updating $branch; leaving it to that run." + exit 0 +} +[ "$SYNC_STRATEGY" != branch ] || deliver_to_baseline_branch + "$ACTION_PATH/scripts/action/install-sync.sh" > "$GENERATED_PATHS" stage_paths=() while IFS= read -r path; do diff --git a/scripts/action/guard.sh b/scripts/action/guard.sh index 84d9261..9c4e62d 100755 --- a/scripts/action/guard.sh +++ b/scripts/action/guard.sh @@ -16,8 +16,8 @@ if [ "$MODE" = sync ]; then esac [ "$REF_TYPE" != tag ] || skip "Sync mode ignores tag pushes." case "$SYNC_STRATEGY" in - push|pull_request) ;; - *) fail "sync_strategy must be push or pull_request." ;; + push|pull_request|branch) ;; + *) fail "sync_strategy must be push, pull_request or branch." ;; esac case "$HEAD_AUTHOR_EMAIL" in codeboarding-review\[bot\]@users.noreply.github.com|codeboarding\[bot\]@users.noreply.github.com) @@ -27,6 +27,12 @@ if [ "$MODE" = sync ]; then target_branch="${TARGET_BRANCH_INPUT:-$REF_NAME}" [ -n "$target_branch" ] || fail "target_branch is required for this event." [ "$SYNC_STRATEGY" != pull_request ] || [ "$target_branch" != codeboarding/sync ] || fail "target_branch must differ from codeboarding/sync." + if [ "$SYNC_STRATEGY" = branch ]; then + [ -n "${BASELINE_BRANCH:-}" ] || fail "baseline_branch is required with sync_strategy: branch." + # The baseline branch holds only analysis; a workflow that also fires on it must not analyze it. + [ "$REF_NAME" != "$BASELINE_BRANCH" ] || skip "Ignoring a push to the baseline branch $BASELINE_BRANCH." + [ "$target_branch" != "$BASELINE_BRANCH" ] || fail "target_branch must differ from baseline_branch." + fi sync_branch_start_sha="" if [ "$SYNC_STRATEGY" = pull_request ]; then sync_branch_start_sha="$(gh api "repos/$REPOSITORY/branches/codeboarding%2Fsync" --jq '.commit.sha' 2>/dev/null || true)" diff --git a/tests/test_baseline_branch.py b/tests/test_baseline_branch.py new file mode 100644 index 0000000..535c720 --- /dev/null +++ b/tests/test_baseline_branch.py @@ -0,0 +1,373 @@ +"""sync_strategy: branch keeps the analysis on an orphan branch, and reviews read their base from it.""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +from test_action_state import ANALYZE, ENGINE_STUB + +ROOT = Path(__file__).resolve().parent.parent +DELIVER = ROOT / "scripts" / "action" / "deliver-sync.sh" +BRANCH = "codeboarding/baseline" + + +def git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", "-c", "user.name=T", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", *args], + cwd=str(cwd), + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +class BaselineBranchDeliveryTests(unittest.TestCase): + """deliver-sync.sh against a real local remote.""" + + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + self.remote = self.root / "owner" / "repo.git" + self.remote.mkdir(parents=True) + git(self.remote, "init", "-q", "--bare", "-b", "main") + self.checkout = self.root / "checkout" + git(self.root, "clone", "-q", str(self.remote), str(self.checkout)) + (self.checkout / "app.py").write_text("print('hi')\n", encoding="utf-8") + self._push_code("initial") + self.analysis = self.root / "analysis" + self.analysis.mkdir() + self._analysis("first") + self.core = self.root / "core" + (self.core / "static_analyzer").mkdir(parents=True) + (self.core / "utils.py").write_text( + "ANALYSIS_FILENAME = 'analysis.json'\nFINGERPRINT_FILENAME = 'fingerprint.json'\n", encoding="utf-8" + ) + (self.core / "static_analyzer" / "__init__.py").touch() + (self.core / "static_analyzer" / "analysis_cache.py").write_text( + "STATIC_ANALYSIS_PKL = 'static_analysis.pkl'\nSTATIC_ANALYSIS_SHA = 'static_analysis.sha'\n", + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def _push_code(self, message: str) -> str: + (self.checkout / f"{message}.py").write_text("pass\n", encoding="utf-8") + git(self.checkout, "add", "-A") + git(self.checkout, "commit", "-q", "-m", message) + git(self.checkout, "push", "-q", "origin", "HEAD:main") + return git(self.checkout, "rev-parse", "HEAD") + + def _analysis(self, content: str) -> None: + for name in ("analysis.json", "fingerprint.json", "static_analysis.pkl"): + (self.analysis / name).write_text(f"{content} {name}\n", encoding="utf-8") + + def _deliver(self, expect_ok: bool = True) -> tuple[subprocess.CompletedProcess, dict[str, str]]: + output = self.root / "github-output" + output.write_text("", encoding="utf-8") + result = subprocess.run( + [str(DELIVER)], + env={ + "PATH": os.environ["PATH"], + "PYTHONPATH": str(self.core), + "ACTION_PATH": str(ROOT), + "ANALYSIS_DIR": str(self.analysis), + "CHECKOUT_DIR": str(self.checkout), + "GITHUB_OUTPUT": str(output), + "RUNNER_TEMP": str(self.root), + "GITHUB_SERVER_URL": str(self.root), + "GITHUB_TOKEN": "unused", + "GH_HOST": "github.com", + "REPOSITORY": "owner/repo", + "TARGET_BRANCH": "main", + "SYNC_STRATEGY": "branch", + "BASELINE_BRANCH": BRANCH, + "ENGINE_VERSION": "0.14.5", + }, + capture_output=True, + text=True, + check=False, + ) + if expect_ok: + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + values = dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines() if "=" in line) + return result, values + + def _branch_log(self) -> list[str]: + return git(self.remote, "log", "--format=%H %P", BRANCH).splitlines() + + def test_the_first_sync_creates_an_orphan_branch_with_its_provenance(self) -> None: + main_before = git(self.remote, "rev-parse", "main") + + _result, values = self._deliver() + + (only,) = self._branch_log() + self.assertEqual(only.split(), [values["baseline_branch_sha"]], "the branch must have no parent") + self.assertEqual(values["committed"], "true") + self.assertEqual(values["baseline_sha"], main_before, "artifacts are named for the analysed commit") + files = set(git(self.remote, "ls-tree", "-r", "--name-only", BRANCH).splitlines()) + self.assertEqual( + files, + { + ".codeboarding/analysis.json", + ".codeboarding/fingerprint.json", + ".codeboarding/static_analysis.pkl", + ".codeboarding/source.json", + }, + ) + source = json.loads(git(self.remote, "show", f"{BRANCH}:.codeboarding/source.json")) + self.assertEqual(source["schema"], 1) + self.assertEqual(source["source_branch"], "main") + self.assertEqual(source["source_sha"], main_before) + self.assertEqual(source["engine_version"], "0.14.5") + self.assertRegex(source["generated_at"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$") + message = git(self.remote, "log", "-1", "--format=%B", BRANCH) + self.assertEqual( + message, f"chore(codeboarding): diagram of main @{main_before[:7]}\n\nCodeBoarding-Source: {main_before}" + ) + # The default branch is never written in this strategy. + self.assertEqual(git(self.remote, "rev-parse", "main"), main_before) + + def test_the_next_sync_appends_a_fast_forward_commit(self) -> None: + self._deliver() + first = git(self.remote, "rev-parse", BRANCH) + new_main = self._push_code("feature") + self._analysis("second") + + _result, values = self._deliver() + + log = self._branch_log() + self.assertEqual(len(log), 2) + self.assertEqual(log[0].split(), [values["baseline_branch_sha"], first]) + self.assertIn(f"CodeBoarding-Source: {new_main}", git(self.remote, "log", "-1", "--format=%B", BRANCH)) + + def test_a_rerun_on_the_same_commit_adds_nothing(self) -> None: + self._deliver() + + _result, values = self._deliver() + + self.assertEqual(values["committed"], "false") + self.assertEqual(len(self._branch_log()), 1) + + def test_a_push_refused_by_a_branch_rule_says_how_to_fix_it(self) -> None: + hook = self.remote / "hooks" / "pre-receive" + hook.write_text( + "#!/bin/sh\nwhile read old new ref; do\n" + f' [ "$ref" != refs/heads/{BRANCH} ] || {{ echo "GH013: Repository rule violations found"; exit 1; }}\n' + "done\n", + encoding="utf-8", + ) + hook.chmod(0o755) + + result, _values = self._deliver(expect_ok=False) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"GitHub refused the push to {BRANCH}", result.stdout) + self.assertIn("bypass actor", result.stdout) + self.assertIn("sync_strategy: push", result.stdout) + + def test_a_deleted_branch_is_recreated_as_a_new_orphan(self) -> None: + self._deliver() + git(self.remote, "branch", "-D", BRANCH) + self._push_code("later") + + self._deliver() + + (only,) = self._branch_log() + self.assertEqual(len(only.split()), 1, "a recreated branch starts a new history") + + def test_a_target_that_moved_during_analysis_keeps_the_branch_unchanged(self) -> None: + self._deliver() + before = git(self.remote, "rev-parse", BRANCH) + other = self.root / "other" + git(self.root, "clone", "-q", str(self.remote), str(other)) + (other / "x.py").write_text("pass\n", encoding="utf-8") + git(other, "add", "-A") + git(other, "commit", "-q", "-m", "x") + git(other, "push", "-q", "origin", "HEAD:main") + self._analysis("stale") + + _result, values = self._deliver() + + self.assertEqual(values["committed"], "false") + self.assertEqual(git(self.remote, "rev-parse", BRANCH), before) + + +class BaselineBranchReadTests(unittest.TestCase): + """Reviews and sync read the branch: commits c0..c4 on main, branch entries for c1 and c3.""" + + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + work = self.root / "work" + work.mkdir() + git(work, "init", "-q", "-b", "main") + self.shas = [] + for index in range(5): + (work / f"f{index}.py").write_text("pass\n", encoding="utf-8") + git(work, "add", "-A") + git(work, "commit", "-q", "-m", f"c{index}") + self.shas.append(git(work, "rev-parse", "HEAD")) + git(work, "checkout", "-q", "--orphan", BRANCH) + git(work, "rm", "-rq", "--cached", ".") + for path in work.glob("f*.py"): + path.unlink() + board = work / ".codeboarding" + board.mkdir() + for source in (self.shas[1], self.shas[3]): + (board / "analysis.json").write_text( + json.dumps({"metadata": {"depth_cap": 2}, "components": [source]}), encoding="utf-8" + ) + (board / "static_analysis.pkl").write_text("pickle", encoding="utf-8") + (board / "source.json").write_text(json.dumps({"schema": 1, "source_sha": source}), encoding="utf-8") + git(work, "add", "-A") + git( + work, + "commit", + "-q", + "-m", + f"chore(codeboarding): diagram of main @{source[:7]}", + "-m", + f"CodeBoarding-Source: {source}", + ) + git(work, "checkout", "-q", "main") + bare = self.root / "origin.git" + git(self.root, "clone", "-q", "--bare", str(work), str(bare)) + git(bare, "config", "uploadpack.allowAnySHA1InWant", "true") + git(bare, "config", "uploadpack.allowFilter", "true") + self.checkout = self.root / "checkout" + git(self.root, "clone", "-q", "--depth=1", "--branch", "main", f"file://{bare}", str(self.checkout)) + + self.bin_dir = self.root / "bin" + self.bin_dir.mkdir() + (self.bin_dir / "codeboarding").write_text(ENGINE_STUB, encoding="utf-8") + (self.bin_dir / "codeboarding").chmod(0o755) + self.engine_log = self.root / "engine.log" + self.engine_log.write_text("", encoding="utf-8") + self.runner = self.root / "runner" + self.runner.mkdir() + self.output = self.root / "github-output" + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def _analyze(self, **extra: str) -> dict[str, str]: + self.output.write_text("", encoding="utf-8") + result = subprocess.run( + [str(ANALYZE)], + env={ + "PATH": f"{self.bin_dir}:{os.environ['PATH']}", + "GITHUB_OUTPUT": str(self.output), + "RUNNER_TEMP": str(self.runner), + "CB_ENGINE_LOG": str(self.engine_log), + "ACTION_PATH": str(ROOT), + "ANALYSIS_KIND": "review", + "CHECKOUT_DIR": str(self.checkout), + "REVIEW_HEAD_SHA": "head-sha", + "REVIEW_BASE_REPO": "origin", + "REPOSITORY": "origin", + "GITHUB_SERVER_URL": f"file://{self.root}", + "PR_NUMBER": "42", + "ENGINE_VERSION": "0.14.5", + "CFG_HASH": "cfg", + "BASELINE_BRANCH": BRANCH, + "BASE_DIR": str(self.root / "state" / "base"), + "WARMSTART_DIR": str(self.root / "state" / "warmstart"), + "STAGE_DIR": str(self.root / "state" / "out"), + "DEPTH_CAP": "2", + **extra, + }, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + return dict(line.split("=", 1) for line in self.output.read_text(encoding="utf-8").splitlines() if "=" in line) + + def _modes(self) -> list[str]: + return [json.loads(line)["mode"] for line in self.engine_log.read_text().splitlines()] + + def test_a_branch_entry_for_the_merge_base_is_the_saved_base(self) -> None: + values = self._analyze(REVIEW_BASE_SHA=self.shas[3]) + + self.assertEqual(values["base_source"], "saved") + self.assertEqual(values["base_from_sha"], self.shas[3]) + self.assertEqual(values["catchup_commits"], "0") + self.assertEqual(self._modes(), ["incremental"], "only the head is analyzed") + base = json.loads(Path(values["base_analysis_path"]).read_text()) + self.assertEqual(base["components"], [self.shas[3]]) + self.assertFalse(Path(values["base_analysis_path"]).with_name("source.json").exists()) + # No artifact holds it yet, so it is published under the merge base's name. + self.assertEqual(values["publish_base"], "true") + + def test_the_nearest_branch_entry_below_the_merge_base_is_caught_up(self) -> None: + values = self._analyze(REVIEW_BASE_SHA=self.shas[4]) + + self.assertEqual(values["base_source"], "ancestor") + self.assertEqual(values["base_from_sha"], self.shas[3]) + self.assertEqual(values["catchup_commits"], "1") + self.assertEqual(self._modes(), ["incremental", "incremental"]) + + def test_without_the_branch_the_base_is_computed(self) -> None: + values = self._analyze(REVIEW_BASE_SHA=self.shas[4], BASELINE_BRANCH="codeboarding/none") + + self.assertEqual(values["base_source"], "computed") + self.assertEqual(self._modes(), ["full", "incremental"]) + + def test_sync_continues_from_the_branch_tip(self) -> None: + self._analyze(ANALYSIS_KIND="sync", SYNC_STRATEGY="branch", FORCE_FULL="false") + + self.assertEqual(self._modes(), ["incremental"]) + + def test_sync_without_the_branch_analyzes_from_scratch(self) -> None: + self._analyze( + ANALYSIS_KIND="sync", SYNC_STRATEGY="branch", FORCE_FULL="false", BASELINE_BRANCH="codeboarding/none" + ) + + self.assertEqual(self._modes(), ["full"]) + + +class BaselineBranchGuardTests(unittest.TestCase): + def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "github-output" + output.write_text("", encoding="utf-8") + result = subprocess.run( + [str(ROOT / "scripts" / "action" / "guard.sh")], + env={ + "PATH": os.environ["PATH"], + "GITHUB_OUTPUT": str(output), + "MODE": "sync", + "EVENT": "push", + "REF_NAME": "main", + "REF_TYPE": "branch", + "HEAD_AUTHOR_EMAIL": "dev@example.com", + "SYNC_STRATEGY": "branch", + "BASELINE_BRANCH": BRANCH, + "REPOSITORY": "owner/repo", + **extra, + }, + capture_output=True, + text=True, + check=False, + ) + return result, output.read_text(encoding="utf-8") + + def test_the_branch_strategy_is_accepted(self) -> None: + result, values = self._guard() + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("target_branch=main", values) + + def test_a_push_to_the_baseline_branch_itself_is_ignored(self) -> None: + result, values = self._guard(REF_NAME=BRANCH) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("skip=true", values) + + +if __name__ == "__main__": + unittest.main()