From 7a091f5975f22d3f772b083a4be9c742275c78d4 Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Wed, 7 Oct 2026 17:05:38 +0200 Subject: [PATCH 01/19] feat: report how the review base was built A review's base comes from a saved artifact, a baseline committed at the merge base, or a full analysis in this run, and nothing said which. The slow path is the last one, and a reader had no way to tell why a run took ten minutes instead of two. analyze.sh now records base_source (saved, committed, computed), base_reason (no_baseline, incompatible), base_from_sha, catchup_commits, base_seconds and head_seconds. They go into the review metadata.json as strings, onto the comment's machine-readable marker, and into one "Base:" line in the comment. While a base is computed, the sticky progress comment is rewritten into two steps with the elapsed time and the reason, never an estimate. Co-Authored-By: Claude Opus 5.5 --- action.yml | 22 ++++ docs/COMMIT_STRATEGY.md | 15 ++- scripts/action/analyze.sh | 104 ++++++++++++++-- scripts/action/build-review-artifact.sh | 13 +- scripts/action/build-review-comment.sh | 48 +++++++- scripts/action/fetch-state.sh | 4 + scripts/action/post-progress.sh | 55 +++++++++ tests/test_action_state.py | 156 ++++++++++++++++++++++++ tests/test_progress_comment.py | 84 +++++++++++++ tests/test_review_artifact_metadata.py | 40 ++++++ tests/test_review_comment.py | 66 ++++++++++ 11 files changed, 593 insertions(+), 14 deletions(-) create mode 100755 scripts/action/post-progress.sh create mode 100644 tests/test_progress_comment.py diff --git a/action.yml b/action.yml index ab7f483..dc805e0 100644 --- a/action.yml +++ b/action.yml @@ -183,6 +183,9 @@ outputs: seed_source: description: 'Which state the review head analysis grew from: pr-chain or base.' value: ${{ steps.review_analyze.outputs.seed_source }} + base_source: + description: 'How the review base was obtained: saved, committed, or computed.' + value: ${{ steps.review_analyze.outputs.base_source }} merge_base_sha: description: 'Merge base used as the review comparison baseline.' value: ${{ steps.guard.outputs.merge_base_sha }} @@ -533,8 +536,14 @@ runs: REVIEW_BASE_REPO: ${{ steps.guard.outputs.base_repo }} PR_NUMBER: ${{ steps.guard.outputs.pr_number }} BASE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/base + BASE_FETCH_SECONDS: ${{ steps.fetch_base.outputs.seconds }} WARMSTART_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/warmstart RENEW_BASE: ${{ steps.fetch_base.outputs.renew }} + # For rewriting the progress comment while a base is built from scratch. + PROGRESS_HEADER: ${{ steps.guard.outputs.comment_id }} + BASE_REF: ${{ steps.guard.outputs.base_ref }} + REPOSITORY: ${{ github.repository }} + GH_HOST: ${{ github.server_url }} STAGE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/out ENGINE_VERSION: ${{ steps.state.outputs.engine_version }} CFG_HASH: ${{ steps.state.outputs.cfg_hash }} @@ -608,6 +617,12 @@ runs: SEED_SOURCE: ${{ steps.review_analyze.outputs.seed_source }} CHAIN_DEPTH: ${{ steps.review_analyze.outputs.chain_depth }} ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }} + BASE_SOURCE: ${{ steps.review_analyze.outputs.base_source }} + BASE_REASON: ${{ steps.review_analyze.outputs.base_reason }} + BASE_FROM_SHA: ${{ steps.review_analyze.outputs.base_from_sha }} + CATCHUP_COMMITS: ${{ steps.review_analyze.outputs.catchup_commits }} + BASE_SECONDS: ${{ steps.review_analyze.outputs.base_seconds }} + HEAD_SECONDS: ${{ steps.review_analyze.outputs.head_seconds }} run: "$GITHUB_ACTION_PATH/scripts/action/build-review-artifact.sh" - name: Upload review artifact @@ -639,6 +654,13 @@ runs: HEAD_SHA: ${{ steps.guard.outputs.head_sha }} # Analysed files whose content hash differs between base and head, from the render step. ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }} + MERGE_BASE_SHA: ${{ steps.guard.outputs.merge_base_sha }} + BASE_SOURCE: ${{ steps.review_analyze.outputs.base_source }} + BASE_REASON: ${{ steps.review_analyze.outputs.base_reason }} + BASE_FROM_SHA: ${{ steps.review_analyze.outputs.base_from_sha }} + CATCHUP_COMMITS: ${{ steps.review_analyze.outputs.catchup_commits }} + BASE_SECONDS: ${{ steps.review_analyze.outputs.base_seconds }} + HEAD_SECONDS: ${{ steps.review_analyze.outputs.head_seconds }} run: "$GITHUB_ACTION_PATH/scripts/action/build-review-comment.sh" - name: Post review comment diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index 2849dfb..d188966 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -70,7 +70,15 @@ never fires. | `base_artifact_id` | string | **which one**, since two artifacts can share that name and disagree: the engine is not deterministic, and a sync run publishes bases for the same commit | | `merge_base_resolved` | **boolean** | `false` means the merge base could not be resolved, so the comparison is against `base_sha` | | `base_sha` | string | the base branch tip when the event fired — *not* what was compared against | -| `pr_number`, `mode`, `seed_source`, `chain_depth` | string | provenance; nothing rendering a diagram needs them | +| `kind` | string | always `review`, so a reader can tell this artifact from a base or warm-start bundle | +| `analysed_files_changed` | string | analysed files whose content hash differs between base and head; `unknown` when the analyses cannot say | +| `base_source` | string | how this run obtained the base graph: `saved` (the artifact for the merge base), `committed` (`.codeboarding/` committed at the merge base, caught up), or `computed` (full analysis in this run) | +| `base_reason` | string | only with `computed`: `no_baseline` (nothing to seed from) or `incompatible` (a candidate existed but its depth cap differed, or the engine demanded a full run); empty otherwise | +| `base_from_sha` | string | the commit whose saved analysis seeded the base; the merge base for `saved`, empty for `computed` or when it lies beyond the fetched history | +| `catchup_commits` | string | first-parent commits from `base_from_sha` to the merge base that change anything outside `.codeboarding/`; `0` when exact, empty when unknown | +| `base_seconds` | string | wall time spent obtaining the base, the artifact lookup included | +| `head_seconds` | string | wall time of the head analysis | +| `pr_number`, `mode`, `seed_source`, `chain_depth` | string | provenance; nothing rendering a diagram needs them. `mode` is the head's engine mode; `base_source` answers for the base | **A sync run** publishes the base graph under both the commit it analyzed and the baseline commit it writes on top, because a pull request opened either side of @@ -104,7 +112,10 @@ them: | no compatible committed baseline either | full analysis directly, at the configured `depth_cap` | A trusted run that computed the base publishes it, so the next pull request -forking from that commit gets the first row. +forking from that commit gets the first row. The rows are `base_source` `saved`, +`committed` and `computed` in the review metadata, and the review comment says +which one ran, with measured times. While a base is computed, the progress +comment says so in two steps, with the elapsed time and the reason. The configuration hash includes `depth_cap`. The workflow input controls depth for both fresh and fallback analyses; stored legacy depth values never override it. diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index 5f246a0..228f6b1 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -139,13 +139,73 @@ analyze_sync() { "$ANALYSIS_MODE" "$ANALYSIS_PATH" "$state" >> "$GITHUB_OUTPUT" } +# How far below the merge base this run looks for the commit a saved analysis +# describes. Past it, a catch-up count is reported as unknown. +CATCHUP_BOUND=100 + +# A depth above 1 also deepens a commit the shallow checkout already holds. fetch_commit() { - local repository="$1" sha="$2" - git -C "$CHECKOUT_DIR" cat-file -e "$sha^{commit}" 2>/dev/null && return 0 + local repository="$1" sha="$2" depth="${3:-1}" + if git -C "$CHECKOUT_DIR" cat-file -e "$sha^{commit}" 2>/dev/null; then + [ "$depth" -gt 1 ] && [ "$(git -C "$CHECKOUT_DIR" rev-parse --is-shallow-repository)" = true ] || return 0 + fi local auth - auth="$(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)" + auth="$(printf 'x-access-token:%s' "${GIT_TOKEN:-}" | base64 -w0)" git -C "$CHECKOUT_DIR" -c "http.extraheader=AUTHORIZATION: basic $auth" fetch \ - "${GITHUB_SERVER_URL%/}/${repository}.git" "$sha" --depth=1 + "${GITHUB_SERVER_URL%/}/${repository}.git" "$sha" --depth="$depth" +} + +# The commit a baseline committed at $1 describes. Sync writes it in a commit of +# its own on top of the analysed one, so that commit's parent is the answer. +# Empty when nothing within the fetched history wrote it. +baseline_commit() { + local tip="$1" writer shallow + writer="$(git -C "$CHECKOUT_DIR" log --first-parent -1 --format=%H "$tip" -- .codeboarding/analysis.json 2>/dev/null || true)" + [ -n "$writer" ] || return 0 + # A shallow boundary looks like it added every file, so it proves nothing. + shallow="$(git -C "$CHECKOUT_DIR" rev-parse --git-path shallow)" + case "$shallow" in /*) ;; *) shallow="$CHECKOUT_DIR/$shallow" ;; esac + if [ -f "$shallow" ] && grep -qx "$writer" "$shallow"; then + return 0 + fi + if [ -z "$(code_paths_changed "$writer")" ] && git -C "$CHECKOUT_DIR" rev-parse -q --verify "$writer^1" >/dev/null; then + git -C "$CHECKOUT_DIR" rev-parse "$writer^1" + else + echo "$writer" + fi +} +code_paths_changed() { + git -C "$CHECKOUT_DIR" diff-tree --no-commit-id --name-only -r "$1" -- . ':(exclude).codeboarding' 2>/dev/null || true +} +# First-parent commits from $1 to $2 that change anything outside .codeboarding/: +# a sync commit changes nothing the analysis reads, so it is nothing to catch up. +catchup_count() { + local from="$1" to="$2" commit count=0 + for commit in $(git -C "$CHECKOUT_DIR" rev-list --first-parent "$from..$to" 2>/dev/null); do + [ -z "$(code_paths_changed "$commit")" ] || count=$(( count + 1 )) + done + echo "$count" +} + +# 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="" +progress() { + GH_TOKEN="${GIT_TOKEN:-}" GH_ENTERPRISE_TOKEN="${GIT_TOKEN:-}" BASE_REASON="$base_reason" \ + "$ACTION_PATH/scripts/action/post-progress.sh" "$@" >/dev/null 2>&1 || true +} +progress_start() { + local started="$1" + progress base 0 + ( while sleep 60; do progress base "$(( $(date +%s) - started ))"; done ) >/dev/null 2>&1 & + PROGRESS_PID=$! +} +progress_stop() { + [ -n "$PROGRESS_PID" ] || return 0 + pkill -P "$PROGRESS_PID" 2>/dev/null || true + kill "$PROGRESS_PID" 2>/dev/null || true + wait "$PROGRESS_PID" 2>/dev/null || true + PROGRESS_PID="" } # The artifact name pins configuration; verify the stored cap and lineage too. @@ -174,24 +234,47 @@ analyze_review() { # A published base graph is this merge base's own analysis, named for it, so it # needs no engine run at all. Without one, the merge base is checked out and - # analyzed from whatever baseline the repository committed there. - local base_source=published + # 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="" + 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_from_sha="$REVIEW_BASE_SHA" catchup_commits=0 else - base_source=computed + # A bundle under this exact name that the run cannot use was made with another cap. + [ ! -f "${BASE_DIR:-}/analysis.json" ] || base_reason=incompatible fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" git -C "$CHECKOUT_DIR" worktree add --detach "$base_checkout" "$REVIEW_BASE_SHA" >/dev/null seed_state "$base_checkout" "$base_state" REQUIRES_FULL=true if [ "$(depth_cap_from "$base_state/analysis.json")" = "$DEPTH_CAP" ]; then incremental "$base_checkout" "$base_state" + if [ "$REQUIRES_FULL" = true ]; then + base_reason=incompatible + else + base_source=committed + fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$(( CATCHUP_BOUND + 1 ))" || true + base_from_sha="$(baseline_commit "$REVIEW_BASE_SHA")" + [ -z "$base_from_sha" ] || catchup_commits="$(catchup_count "$base_from_sha" "$REVIEW_BASE_SHA")" + fi + elif [ -f "$base_state/analysis.json" ]; then + base_reason=incompatible fi if [ "$REQUIRES_FULL" = true ]; then + base_source=computed + base_reason="${base_reason:-no_baseline}" + trap progress_stop EXIT + progress_start "$base_started" full "$base_checkout" "$base_state" "$DEPTH_CAP" + progress_stop + progress head "$(( $(date +%s) - base_started ))" fi fi + [ "$base_source" = computed ] || base_reason="" + # The lookup in the step before this one is part of obtaining the base too. + local base_seconds=$(( $(date +%s) - base_started + ${BASE_FETCH_SECONDS:-0} )) unset GIT_TOKEN local base_analysis="$base_state/analysis.json" @@ -213,10 +296,13 @@ analyze_review() { fi rm -f "$head_state/origin.json" + local head_started + head_started="$(date +%s)" incremental "$CHECKOUT_DIR" "$head_state" if [ "$REQUIRES_FULL" = true ]; then full "$CHECKOUT_DIR" "$head_state" "$DEPTH_CAP" fi + local head_seconds=$(( $(date +%s) - head_started )) write_origin "$head_state" "$seed_source" "$chain_depth" "$(analysis_digest "$base_analysis")" stage "$head_state" warmstart @@ -226,13 +312,15 @@ analyze_review() { # by id for its whole retention, so one about to expire is renewed rather than # left dangling under a review that outlives it. local publish_base=false - if [ "$base_source" = computed ] || [ "${RENEW_BASE:-false}" = true ]; then + if [ "$base_source" != saved ] || [ "${RENEW_BASE:-false}" = true ]; then stage "$base_state" base publish_base=true fi printf 'analysis_mode=%s\nanalysis_path=%s\nbase_analysis_path=%s\nseed_source=%s\nchain_depth=%s\npublish_base=%s\n' \ "$ANALYSIS_MODE" "$ANALYSIS_PATH" "$base_analysis" "$seed_source" "$chain_depth" "$publish_base" >> "$GITHUB_OUTPUT" + printf 'base_source=%s\nbase_reason=%s\nbase_from_sha=%s\ncatchup_commits=%s\nbase_seconds=%s\nhead_seconds=%s\n' \ + "$base_source" "$base_reason" "$base_from_sha" "$catchup_commits" "$base_seconds" "$head_seconds" >> "$GITHUB_OUTPUT" } case "$ANALYSIS_KIND" in diff --git a/scripts/action/build-review-artifact.sh b/scripts/action/build-review-artifact.sh index acdf52a..aa14c95 100755 --- a/scripts/action/build-review-artifact.sh +++ b/scripts/action/build-review-artifact.sh @@ -23,7 +23,8 @@ HEALTH_REPORT="$(dirname "$ANALYSIS_PATH")/health/health_report.json" # pr_base_sha carries the same value under the name the webview already reads: # its lookup is base_commit_sha || pr_base_sha || base_sha, so without it the # webview silently falls through to the branch tip and compares against a base -# this review never used. +# this review never used. The base_* fields say how this run obtained the base +# graph; all strings, empty when not applicable. jq -n \ --arg kind review \ --arg mode "$ANALYSIS_MODE" \ @@ -37,10 +38,18 @@ jq -n \ --arg base_artifact "$BASE_ARTIFACT_NAME" \ --arg base_artifact_id "$BASE_ARTIFACT_ID" \ --arg analysed_files_changed "${ANALYSED_FILES_CHANGED:-unknown}" \ + --arg base_source "${BASE_SOURCE:-}" \ + --arg base_reason "${BASE_REASON:-}" \ + --arg base_from_sha "${BASE_FROM_SHA:-}" \ + --arg catchup_commits "${CATCHUP_COMMITS:-}" \ + --arg base_seconds "${BASE_SECONDS:-}" \ + --arg head_seconds "${HEAD_SECONDS:-}" \ '{kind: $kind, mode: $mode, base_sha: $base_sha, merge_base_sha: $merge_base_sha, pr_base_sha: $merge_base_sha, merge_base_resolved: $merge_base_resolved, head_sha: $head_sha, pr_number: $pr_number, seed_source: $seed_source, chain_depth: $chain_depth, base_artifact: $base_artifact, base_artifact_id: $base_artifact_id, - analysed_files_changed: $analysed_files_changed}' \ + analysed_files_changed: $analysed_files_changed, + base_source: $base_source, base_reason: $base_reason, base_from_sha: $base_from_sha, + catchup_commits: $catchup_commits, base_seconds: $base_seconds, head_seconds: $head_seconds}' \ > "${RUNNER_TEMP}/cb-review-artifact/metadata.json" echo "artifact_dir=${RUNNER_TEMP}/cb-review-artifact" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/build-review-comment.sh b/scripts/action/build-review-comment.sh index 96743a6..a1869fa 100755 --- a/scripts/action/build-review-comment.sh +++ b/scripts/action/build-review-comment.sh @@ -45,6 +45,44 @@ elif [ "$BEHIND" -gt 0 ] 2>/dev/null; then printf '\nCompared against the merge base: this branch is %s %s behind `%s`.\n' \ "$BEHIND" "$COMMIT_NOUN" "${BASE_REF:-the base branch}" >> "$BODY" fi +# How the base was obtained, with measured times only: an estimate would be wrong for +# exactly the slow runs it is meant to explain. +duration() { + local seconds="${1:-0}" + case "$seconds" in ''|*[!0-9]*) seconds=0 ;; esac + if [ "$seconds" -ge 60 ]; then + printf '%s m %s s' "$(( seconds / 60 ))" "$(( seconds % 60 ))" + else + printf '%s s' "$seconds" + fi +} +BASE_SOURCE="${BASE_SOURCE:-}" +BASE_BRANCH="${BASE_REF:-the base branch}" +BASE_AT="${BASE_FROM_SHA:-${MERGE_BASE_SHA:-}}" +CHANGES="changes $(duration "${HEAD_SECONDS:-}")" +BASE_LINE="" +case "$BASE_SOURCE" in + computed) + case "${BASE_REASON:-}" in + incompatible) WHY="saved diagram incompatible" ;; + too_far_behind) WHY="saved diagram too far behind" ;; + *) WHY="no saved diagram" ;; + esac + BASE_LINE="Base: built from scratch (${WHY}), $(duration "${BASE_SECONDS:-}") · ${CHANGES}" + ;; + saved | committed | ancestor) + CATCHUP="${CATCHUP_COMMITS:-}" + case "$CATCHUP" in ''|*[!0-9]*) CATCHUP=0 ;; esac + if [ "$CATCHUP" -gt 0 ]; then + COMMIT_NOUN="commits" + [ "$CATCHUP" != 1 ] || COMMIT_NOUN="commit" + BASE_LINE="Base: caught up ${CATCHUP} ${COMMIT_NOUN} from ${BASE_BRANCH} @${BASE_AT:0:7}, $(duration "${BASE_SECONDS:-}") · ${CHANGES}" + else + BASE_LINE="Base: saved diagram of ${BASE_BRANCH} @${BASE_AT:0:7} · ${CHANGES}" + fi + ;; +esac +[ -z "$BASE_LINE" ] || printf '\n%s\n' "$BASE_LINE" >> "$BODY" { printf '\n' cat "$DIAGRAM" @@ -56,7 +94,13 @@ fi # The machine-readable line: what a reader of the comment (the web platform's dashboard, an # agent) needs without parsing the prose or the diagram. An HTML comment renders as nothing. # Keep it one line, `key=value` pairs, values without spaces, so a regex over it stays trivial. - printf '\n' \ - "$PLATFORM_URL" "$N_CHANGED" "$ANALYSED_FILES_CHANGED" "${HEAD_SHA:-}" + BASE_KEYS="" + if [ -n "$BASE_SOURCE" ]; then + BASE_KEYS=" base=${BASE_SOURCE}" + [ -z "${BASE_REASON:-}" ] || BASE_KEYS="${BASE_KEYS} base_reason=${BASE_REASON}" + BASE_KEYS="${BASE_KEYS} base_seconds=${BASE_SECONDS:-} head_seconds=${HEAD_SECONDS:-}" + fi + printf '\n' \ + "$PLATFORM_URL" "$N_CHANGED" "$ANALYSED_FILES_CHANGED" "${HEAD_SHA:-}" "$BASE_KEYS" } >> "$BODY" echo "path=$BODY" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/fetch-state.sh b/scripts/action/fetch-state.sh index cf16a1e..e90dacd 100755 --- a/scripts/action/fetch-state.sh +++ b/scripts/action/fetch-state.sh @@ -4,6 +4,10 @@ # leave the directory absent, and the caller derives from the base instead. set -euo pipefail [ -n "${ARTIFACT_NAME:-}" ] || exit 0 +# A review reports how long obtaining its base took, and this lookup is part of it. +started="$(date +%s)" +report_seconds() { [ -z "${GITHUB_OUTPUT:-}" ] || echo "seconds=$(( $(date +%s) - started ))" >> "$GITHUB_OUTPUT"; } +trap report_seconds EXIT # Clear first, on every path. These destinations are fixed, so a second use of # the action in one job would otherwise inherit the first one's files and treat diff --git a/scripts/action/post-progress.sh b/scripts/action/post-progress.sh new file mode 100755 index 0000000..746fcbe --- /dev/null +++ b/scripts/action/post-progress.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Rewrites the review's sticky progress comment into two steps while the base is +# built from scratch: that is the slow path, and the reader should know why. +# Usage: post-progress.sh base|head . Best effort throughout. +set -euo pipefail +step="$1" elapsed="$2" +[ -n "${PROGRESS_HEADER:-}" ] && [ -n "${PR_NUMBER:-}" ] && [ -n "${REPOSITORY:-}" ] || exit 0 +export GH_HOST="${GH_HOST:-github.com}" +GH_HOST="${GH_HOST#*://}" + +# The sticky-comment action finds its comment by this line, so it must survive the edit. +marker="" +id_file="${RUNNER_TEMP:?}/codeboarding-progress-comment-${PROGRESS_HEADER}" +id="$(cat "$id_file" 2>/dev/null || true)" +if [ -z "$id" ]; then + id="$(gh api --paginate "repos/$REPOSITORY/issues/$PR_NUMBER/comments?per_page=100" \ + --jq ".[] | select((.body // \"\") | startswith(\"### CodeBoarding review\") and contains(\"$marker\")) | .id" | + tail -n 1)" + [ -n "$id" ] || exit 0 + echo "$id" > "$id_file" +fi + +if [ -n "${BASE_REF:-}" ]; then + branch="\`$BASE_REF\`" +else + branch="the base branch" +fi +sha7="${REVIEW_BASE_SHA:0:7}" +case "${BASE_REASON:-}" in + incompatible) why="The saved diagram of $branch was made by a different engine version or settings, so this review builds a new one." ;; + too_far_behind) why="The nearest saved diagram of $branch is too far behind this pull request's base, so this review builds one from scratch." ;; + *) why="$branch has no saved diagram yet, so this review builds one first. Once a diagram of $branch is saved, reviews start from it and skip this step." ;; +esac +minutes=$(( elapsed / 60 )) +if [ "$step" = base ]; then + running="running for $minutes min" + [ "$minutes" -gt 0 ] || running="running for less than a minute" + first="1. ⏳ Building the diagram of $branch @$sha7 from scratch · $running" + second="2. Analysing this PR's changes" +else + took="$(( elapsed % 60 )) s" + [ "$minutes" -eq 0 ] || took="$minutes m $took" + first="1. ✅ Built the diagram of $branch @$sha7 from scratch in $took" + second="2. ⏳ Analysing this PR's changes" +fi + +platform="https://app.codeboarding.org/$REPOSITORY/pull/$PR_NUMBER?utm_source=github&utm_medium=pr_comment&utm_campaign=gh_action" +run_url="${GITHUB_SERVER_URL:-https://github.com}/$REPOSITORY/actions/runs/${GITHUB_RUN_ID:-}" +body="$(printf '%s\n\n%s\n %s\n%s\n\n%s\n\n%s\n%s' \ + '### CodeBoarding review · analyzing…' \ + "$first" "$why" "$second" \ + "Open it in [CodeBoarding]($platform) meanwhile: the files, comments and review are there already, and the diff appears when the run finishes." \ + "run [${GITHUB_RUN_ID:-}]($run_url) · attempt ${GITHUB_RUN_ATTEMPT:-1}" \ + "$marker")" +gh api -X PATCH "repos/$REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null diff --git a/tests/test_action_state.py b/tests/test_action_state.py index 7b5a8aa..1e53262 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -312,6 +312,162 @@ def test_base_and_head_fallbacks_keep_configured_depth(self) -> None: self.assertEqual([c["mode"] for c in calls], ["incremental", "full", "incremental", "full"]) self.assertEqual([c["depth"] for c in calls if c["mode"] == "full"], ["4", "4"]) + # How the base was obtained is reported, not just used: the review comment and + # the webview explain a slow run by it. + + def _git(self, *args: str) -> str: + return subprocess.run( + [ + "git", + "-C", + str(self.checkout), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + *args, + ], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + def _commit(self, message: str, files: dict[str, str]) -> str: + for name, content in files.items(): + (self.checkout / name).parent.mkdir(parents=True, exist_ok=True) + (self.checkout / name).write_text(content, encoding="utf-8") + self._git("add", "-A") + self._git("commit", "-q", "-m", message) + return self._git("rev-parse", "HEAD") + + def _sync_history(self) -> tuple[str, str]: + """An analysed commit with a sync commit on top that writes only .codeboarding/.""" + self._git("init", "-q") + analysed = self._commit("feat: code", {"code.py": "pass\n"}) + _state(self.checkout / ".codeboarding", cap=2) + sync = self._commit("chore(codeboarding): sync analysis baseline", {}) + return analysed, sync + + def _provenance(self, values: dict[str, str]) -> dict[str, str]: + keys = ("base_source", "base_reason", "base_from_sha", "catchup_commits") + for key in ("base_seconds", "head_seconds"): + self.assertRegex(values[key], r"^[0-9]+$", key) + return {key: values[key] for key in keys} + + def test_a_saved_base_reports_itself_as_exact(self) -> None: + _state(self.base_dir) + + values = self._analyze(BASE_FETCH_SECONDS="7") + + self.assertEqual( + self._provenance(values), + {"base_source": "saved", "base_reason": "", "base_from_sha": "merge-base-sha", "catchup_commits": "0"}, + ) + # The download happened in the step before; it is still time spent on the base. + self.assertGreaterEqual(int(values["base_seconds"]), 7) + + def test_a_base_with_nothing_to_seed_it_is_computed_for_lack_of_a_baseline(self) -> None: + sha = self._commit_base() + + values = self._analyze(REVIEW_BASE_SHA=sha) + + self.assertEqual( + self._provenance(values), + {"base_source": "computed", "base_reason": "no_baseline", "base_from_sha": "", "catchup_commits": ""}, + ) + + def test_a_committed_baseline_with_another_depth_is_incompatible(self) -> None: + sha = self._commit_base(legacy=True) + + values = self._analyze(REVIEW_BASE_SHA=sha) + + self.assertEqual(values["base_source"], "computed") + self.assertEqual(values["base_reason"], "incompatible") + + def test_a_saved_base_with_another_depth_is_incompatible(self) -> None: + sha = self._commit_base() + _state(self.base_dir, cap=1) + + values = self._analyze(REVIEW_BASE_SHA=sha) + + self.assertEqual(values["base_source"], "computed") + self.assertEqual(values["base_reason"], "incompatible") + + def test_an_engine_that_demands_a_full_run_makes_the_baseline_incompatible(self) -> None: + sha = self._commit_base(cap=2) + + values = self._analyze(REVIEW_BASE_SHA=sha, CB_REQUIRE_FULL="true") + + self.assertEqual(values["base_source"], "computed") + self.assertEqual(values["base_reason"], "incompatible") + + def test_a_baseline_committed_at_the_merge_base_needs_no_catching_up(self) -> None: + # The sync commit changes only .codeboarding/, which the analysis ignores, + # so a merge base on it is exactly the commit the baseline describes. + analysed, sync = self._sync_history() + + values = self._analyze(REVIEW_BASE_SHA=sync) + + self.assertEqual( + self._provenance(values), + {"base_source": "committed", "base_reason": "", "base_from_sha": analysed, "catchup_commits": "0"}, + ) + self.assertEqual(values["publish_base"], "true") + + def test_a_committed_baseline_counts_the_commits_it_caught_up(self) -> None: + analysed, _sync = self._sync_history() + self._commit("feat: more", {"more.py": "pass\n"}) + merge_base = self._commit("feat: again", {"code.py": "print()\n"}) + + values = self._analyze(REVIEW_BASE_SHA=merge_base) + + self.assertEqual(values["base_source"], "committed") + self.assertEqual(values["base_from_sha"], analysed) + self.assertEqual(values["catchup_commits"], "2") + + def _progress_stub(self) -> Path: + calls = self.root / "gh-calls" + gh = self.bin_dir / "gh" + gh.write_text( + "#!/usr/bin/env bash\n" + f'printf "%s\\n----\\n" "$*" >> "{calls}"\n' + 'case "$*" in *"/comments?per_page"*) echo 77 ;; esac\n', + encoding="utf-8", + ) + gh.chmod(0o755) + return calls + + def test_building_the_base_from_scratch_rewrites_the_progress_comment(self) -> None: + calls = self._progress_stub() + sha = self._commit_base() + + self._analyze( + REVIEW_BASE_SHA=sha, + PROGRESS_HEADER="codeboarding-review", + REPOSITORY="owner/repo", + BASE_REF="develop", + GIT_TOKEN="token", + ) + + patches = [call for call in calls.read_text().split("\n----\n") if "PATCH" in call] + self.assertGreaterEqual(len(patches), 2, calls.read_text()) + self.assertIn("repos/owner/repo/issues/comments/77", patches[0]) + self.assertIn(f"Building the diagram of `develop` @{sha[:7]} from scratch", patches[0]) + self.assertIn("`develop` has no saved diagram yet", patches[0]) + self.assertIn("2. ⏳ Analysing this PR's changes", patches[-1]) + # The sticky-comment action finds its comment by this line on the final write. + self.assertIn("", patches[-1]) + + def test_a_saved_base_leaves_the_progress_comment_alone(self) -> None: + calls = self._progress_stub() + _state(self.base_dir) + + self._analyze(PROGRESS_HEADER="codeboarding-review", REPOSITORY="owner/repo", GIT_TOKEN="token") + + self.assertFalse(calls.exists()) + def test_invalid_depth_fails_before_analysis(self) -> None: result = subprocess.run( [str(ANALYZE)], diff --git a/tests/test_progress_comment.py b/tests/test_progress_comment.py new file mode 100644 index 0000000..f96c2bf --- /dev/null +++ b/tests/test_progress_comment.py @@ -0,0 +1,84 @@ +"""The progress comment while a base is built from scratch: two steps, elapsed time only.""" + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +POST_PROGRESS = ROOT / "scripts" / "action" / "post-progress.sh" + + +class ProgressCommentTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + self.bin_dir = self.root / "bin" + self.bin_dir.mkdir() + self.calls = self.root / "calls" + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def _post(self, step: str, elapsed: int, comment_id: str = "77", **extra: str) -> list[str]: + gh = self.bin_dir / "gh" + gh.write_text( + "#!/usr/bin/env bash\n" + f'printf "%s\\n----\\n" "$*" >> "{self.calls}"\n' + f'case "$*" in *"/comments?per_page"*) echo "{comment_id}" ;; esac\n', + encoding="utf-8", + ) + gh.chmod(0o755) + subprocess.run( + [str(POST_PROGRESS), step, str(elapsed)], + env={ + "PATH": f"{self.bin_dir}:{os.environ['PATH']}", + "RUNNER_TEMP": str(self.root), + "PROGRESS_HEADER": "codeboarding-review", + "PR_NUMBER": "9", + "REPOSITORY": "owner/repo", + "BASE_REF": "main", + "REVIEW_BASE_SHA": "a1b2c3d4e5f6", + "GITHUB_RUN_ID": "55", + **extra, + }, + check=True, + capture_output=True, + text=True, + ) + return [c for c in self.calls.read_text().split("\n----\n") if "PATCH" in c] if self.calls.exists() else [] + + def test_the_base_step_shows_elapsed_minutes_and_the_reason(self) -> None: + (patch,) = self._post("base", 125, BASE_REASON="no_baseline") + self.assertIn("1. ⏳ Building the diagram of `main` @a1b2c3d from scratch · running for 2 min\n", patch) + self.assertIn(" `main` has no saved diagram yet, so this review builds one first.", patch) + self.assertIn("\n2. Analysing this PR's changes\n", patch) + self.assertNotIn("estimate", patch.lower()) + + def test_the_first_minute_does_not_read_as_zero(self) -> None: + (patch,) = self._post("base", 0) + self.assertIn("running for less than a minute", patch) + + def test_an_incompatible_base_says_why(self) -> None: + (patch,) = self._post("base", 60, BASE_REASON="incompatible") + self.assertIn("The saved diagram of `main` was made by a different engine version or settings", patch) + + def test_the_head_step_reports_the_measured_base_time(self) -> None: + (patch,) = self._post("head", 534) + self.assertIn("1. ✅ Built the diagram of `main` @a1b2c3d from scratch in 8 m 54 s\n", patch) + self.assertIn("2. ⏳ Analysing this PR's changes", patch) + self.assertTrue(patch.rstrip().endswith("")) + + def test_the_comment_is_looked_up_once(self) -> None: + self._post("base", 0) + self._post("base", 60) + lookups = [c for c in self.calls.read_text().split("\n----\n") if "/comments?per_page" in c] + self.assertEqual(len(lookups), 1) + + def test_no_progress_comment_means_no_edit(self) -> None: + self.assertEqual(self._post("base", 0, comment_id=""), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_review_artifact_metadata.py b/tests/test_review_artifact_metadata.py index 6b43526..a9bd90c 100644 --- a/tests/test_review_artifact_metadata.py +++ b/tests/test_review_artifact_metadata.py @@ -65,6 +65,46 @@ def test_a_count_the_render_step_could_not_make_is_recorded_as_unknown(self) -> metadata, _outputs = _build(Path(tmp)) self.assertEqual(metadata["analysed_files_changed"], "unknown") + def test_how_the_base_was_obtained_is_recorded_as_strings(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + metadata, _outputs = _build( + Path(tmp), + BASE_SOURCE="committed", + BASE_REASON="", + BASE_FROM_SHA="abc", + CATCHUP_COMMITS="3", + BASE_SECONDS="41", + HEAD_SECONDS="159", + ) + self.assertEqual( + {key: metadata[key] for key in metadata if key.startswith("base_") or key.endswith("_seconds")} + | {"catchup_commits": metadata["catchup_commits"]}, + { + "base_sha": "tip-sha", + "base_artifact": "codeboarding-base-cfg-mergebasesha", + "base_artifact_id": "4242", + "base_source": "committed", + "base_reason": "", + "base_from_sha": "abc", + "catchup_commits": "3", + "base_seconds": "41", + "head_seconds": "159", + }, + ) + + def test_an_older_run_without_provenance_records_empty_strings(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + metadata, _outputs = _build(Path(tmp)) + for key in ( + "base_source", + "base_reason", + "base_from_sha", + "catchup_commits", + "base_seconds", + "head_seconds", + ): + self.assertEqual(metadata[key], "", key) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_review_comment.py b/tests/test_review_comment.py index 709f693..f88a67a 100644 --- a/tests/test_review_comment.py +++ b/tests/test_review_comment.py @@ -82,5 +82,71 @@ def test_a_changed_analysed_file_gets_no_verdict_even_at_zero_components(self) - self.assertIn("changed=0 analysed_files_changed=2 head=abc123", body) +class BaseLineTests(unittest.TestCase): + """One line saying how the base was obtained, with measured times and never an estimate.""" + + def _base_line(self, **extra: str) -> str: + with tempfile.TemporaryDirectory() as tmp: + body = _build(Path(tmp), BASE_REF="main", MERGE_BASE_SHA="f00dfeed" * 5, **extra) + lines = [line for line in body.splitlines() if line.startswith("Base: ")] + self.assertEqual(len(lines), 1, body) + return lines[0] + + def test_a_computed_base_says_why_and_how_long(self) -> None: + line = self._base_line( + BASE_SOURCE="computed", BASE_REASON="no_baseline", BASE_SECONDS="534", HEAD_SECONDS="192" + ) + self.assertEqual(line, "Base: built from scratch (no saved diagram), 8 m 54 s · changes 3 m 12 s") + + def test_an_incompatible_base_says_so(self) -> None: + line = self._base_line(BASE_SOURCE="computed", BASE_REASON="incompatible", BASE_SECONDS="60", HEAD_SECONDS="5") + self.assertEqual( + line, "Base: built from scratch (saved diagram incompatible), 1 m 0 s · changes 5 s" + ) + + def test_a_saved_base_names_the_commit(self) -> None: + line = self._base_line( + BASE_SOURCE="saved", BASE_FROM_SHA="a1b2c3d4e5", CATCHUP_COMMITS="0", BASE_SECONDS="3", HEAD_SECONDS="159" + ) + self.assertEqual(line, "Base: saved diagram of main @a1b2c3d · changes 2 m 39 s") + + def test_a_committed_base_at_the_merge_base_reads_as_saved(self) -> None: + line = self._base_line(BASE_SOURCE="committed", BASE_FROM_SHA="", CATCHUP_COMMITS="", HEAD_SECONDS="4") + self.assertEqual(line, "Base: saved diagram of main @f00dfee · changes 4 s") + + def test_a_caught_up_base_counts_the_commits(self) -> None: + line = self._base_line( + BASE_SOURCE="committed", + BASE_FROM_SHA="a1b2c3d4e5", + CATCHUP_COMMITS="4", + BASE_SECONDS="41", + HEAD_SECONDS="159", + ) + self.assertEqual(line, "Base: caught up 4 commits from main @a1b2c3d, 41 s · changes 2 m 39 s") + + def test_the_marker_carries_the_base_after_the_existing_keys(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + body = _build( + Path(tmp), BASE_SOURCE="computed", BASE_REASON="no_baseline", BASE_SECONDS="534", HEAD_SECONDS="192" + ) + self.assertTrue( + body.rstrip("\n").endswith( + "head=abc123 base=computed base_reason=no_baseline base_seconds=534 head_seconds=192 -->" + ), + body, + ) + + def test_the_marker_omits_an_empty_reason(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + body = _build(Path(tmp), BASE_SOURCE="saved", BASE_REASON="", BASE_SECONDS="2", HEAD_SECONDS="9") + self.assertIn("head=abc123 base=saved base_seconds=2 head_seconds=9 -->", body) + + def test_without_a_base_source_there_is_no_line_and_no_keys(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + body = _build(Path(tmp)) + self.assertNotIn("Base:", body) + self.assertNotIn(" base=", body) + + if __name__ == "__main__": unittest.main() From 74d36cee6e53f0550700e48a9fd459ce0e729d3a Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Wed, 7 Oct 2026 17:29:13 +0200 Subject: [PATCH 02/19] fix: count merges as catch-up and never call an unknown base exact Review follow-ups on the base provenance: - code_paths_changed diffs against the first parent, so a --no-ff merge on the first-parent chain counts as the change it brought in; diff-tree printed nothing for merges and catch-up read 0. .gitattributes is not code either. - base_from_sha for a committed baseline comes only from a commit sync made (its bot committer, nothing but .codeboarding/): pushed directly, or on the second-parent side of a merged sync pull request. Anything else (a squash, a hand edit) leaves it empty rather than guessing. - An empty sha or count is unknown, not 0: the comment then says "saved diagram of , caught up" without a sha, and an ancestor never reads as "saved". - The progress ticker stops through a stop file and is waited for, and post-progress.sh checks the file right before editing, so a stale "running" edit cannot land after step 2. Co-Authored-By: Claude Opus 5.5 --- action.yml | 1 - scripts/action/analyze.sh | 61 ++++++++++++++++------ scripts/action/build-review-comment.sh | 26 +++++++--- scripts/action/post-progress.sh | 5 ++ tests/test_action_state.py | 72 ++++++++++++++++++++++++-- tests/test_progress_comment.py | 7 +++ tests/test_review_comment.py | 17 +++++- 7 files changed, 157 insertions(+), 32 deletions(-) diff --git a/action.yml b/action.yml index dc805e0..420559f 100644 --- a/action.yml +++ b/action.yml @@ -654,7 +654,6 @@ runs: HEAD_SHA: ${{ steps.guard.outputs.head_sha }} # Analysed files whose content hash differs between base and head, from the render step. ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }} - MERGE_BASE_SHA: ${{ steps.guard.outputs.merge_base_sha }} BASE_SOURCE: ${{ steps.review_analyze.outputs.base_source }} BASE_REASON: ${{ steps.review_analyze.outputs.base_reason }} BASE_FROM_SHA: ${{ steps.review_analyze.outputs.base_from_sha }} diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index 228f6b1..7283373 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -155,12 +155,24 @@ fetch_commit() { "${GITHUB_SERVER_URL%/}/${repository}.git" "$sha" --depth="$depth" } -# The commit a baseline committed at $1 describes. Sync writes it in a commit of -# its own on top of the analysed one, so that commit's parent is the answer. -# Empty when nothing within the fetched history wrote it. +# The commit a baseline committed at $1 describes, or empty when that cannot be +# told. Sync writes the baseline in a commit of its own on top of the analysed +# commit, pushed straight to the branch or merged in from its pull request's +# branch. A writer sync did not make (a squash, a rebase, a hand edit) says +# nothing about which commit was analysed. baseline_commit() { - local tip="$1" writer shallow - writer="$(git -C "$CHECKOUT_DIR" log --first-parent -1 --format=%H "$tip" -- .codeboarding/analysis.json 2>/dev/null || true)" + local writer + writer="$(baseline_writer "$1")" + if [ -n "$writer" ] && git -C "$CHECKOUT_DIR" rev-parse -q --verify "$writer^2" >/dev/null; then + writer="$(baseline_writer "$writer^2")" + fi + [ -n "$writer" ] && is_sync_commit "$writer" || return 0 + git -C "$CHECKOUT_DIR" rev-parse -q --verify "$writer^1" || true +} +# The newest first-parent commit at or below $1 that wrote the baseline. +baseline_writer() { + local writer shallow + writer="$(git -C "$CHECKOUT_DIR" log --first-parent -1 --format=%H "$1" -- .codeboarding/analysis.json 2>/dev/null || true)" [ -n "$writer" ] || return 0 # A shallow boundary looks like it added every file, so it proves nothing. shallow="$(git -C "$CHECKOUT_DIR" rev-parse --git-path shallow)" @@ -168,17 +180,27 @@ baseline_commit() { if [ -f "$shallow" ] && grep -qx "$writer" "$shallow"; then return 0 fi - if [ -z "$(code_paths_changed "$writer")" ] && git -C "$CHECKOUT_DIR" rev-parse -q --verify "$writer^1" >/dev/null; then - git -C "$CHECKOUT_DIR" rev-parse "$writer^1" - else - echo "$writer" - fi + echo "$writer" } +is_sync_commit() { + case "$(git -C "$CHECKOUT_DIR" log -1 --format=%ce "$1")" in + 'codeboarding-review[bot]@users.noreply.github.com' | 'codeboarding[bot]@users.noreply.github.com') ;; + *) return 1 ;; + esac + [ -z "$(code_paths_changed "$1")" ] +} +# Against the first parent, so a merge counts as the change it brought in. The +# baseline and the attributes line sync may add are not code. code_paths_changed() { - git -C "$CHECKOUT_DIR" diff-tree --no-commit-id --name-only -r "$1" -- . ':(exclude).codeboarding' 2>/dev/null || true + local exclude=(-- . ':(exclude).codeboarding' ':(exclude).gitattributes') + if git -C "$CHECKOUT_DIR" rev-parse -q --verify "$1^1" >/dev/null; then + git -C "$CHECKOUT_DIR" diff --name-only "$1^1" "$1" "${exclude[@]}" 2>/dev/null || true + else + git -C "$CHECKOUT_DIR" diff-tree --root --no-commit-id --name-only -r "$1" "${exclude[@]}" 2>/dev/null || true + fi } -# First-parent commits from $1 to $2 that change anything outside .codeboarding/: -# a sync commit changes nothing the analysis reads, so it is nothing to catch up. +# First-parent commits from $1 to $2 that change code: a sync commit changes +# nothing the analysis reads, so it is nothing to catch up. catchup_count() { local from="$1" to="$2" commit count=0 for commit in $(git -C "$CHECKOUT_DIR" rev-list --first-parent "$from..$to" 2>/dev/null); do @@ -190,20 +212,25 @@ catchup_count() { # 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="" +PROGRESS_STOP="${RUNNER_TEMP:-}/codeboarding-progress-stop" progress() { GH_TOKEN="${GIT_TOKEN:-}" GH_ENTERPRISE_TOKEN="${GIT_TOKEN:-}" BASE_REASON="$base_reason" \ - "$ACTION_PATH/scripts/action/post-progress.sh" "$@" >/dev/null 2>&1 || true + PROGRESS_STOP_FILE="$PROGRESS_STOP" "$ACTION_PATH/scripts/action/post-progress.sh" "$@" >/dev/null 2>&1 || true } progress_start() { local started="$1" + rm -f "$PROGRESS_STOP" progress base 0 - ( while sleep 60; do progress base "$(( $(date +%s) - started ))"; done ) >/dev/null 2>&1 & + ( while sleep 60 && [ ! -e "$PROGRESS_STOP" ]; do progress base "$(( $(date +%s) - started ))"; done ) >/dev/null 2>&1 & PROGRESS_PID=$! } +# Stops the ticker and waits for it, so no "still running" edit can land after the +# next one. Only its sleep is killed: an edit in flight finishes or, having seen +# the stop file, never starts. progress_stop() { [ -n "$PROGRESS_PID" ] || return 0 - pkill -P "$PROGRESS_PID" 2>/dev/null || true - kill "$PROGRESS_PID" 2>/dev/null || true + touch "$PROGRESS_STOP" + pkill -x sleep -P "$PROGRESS_PID" 2>/dev/null || true wait "$PROGRESS_PID" 2>/dev/null || true PROGRESS_PID="" } diff --git a/scripts/action/build-review-comment.sh b/scripts/action/build-review-comment.sh index a1869fa..51e2ea0 100755 --- a/scripts/action/build-review-comment.sh +++ b/scripts/action/build-review-comment.sh @@ -58,9 +58,14 @@ duration() { } BASE_SOURCE="${BASE_SOURCE:-}" BASE_BRANCH="${BASE_REF:-the base branch}" -BASE_AT="${BASE_FROM_SHA:-${MERGE_BASE_SHA:-}}" +BASE_FROM="${BASE_FROM_SHA:-}" +CATCHUP="${CATCHUP_COMMITS:-}" +case "$CATCHUP" in *[!0-9]*) CATCHUP="" ;; esac CHANGES="changes $(duration "${HEAD_SECONDS:-}")" +TOOK="$(duration "${BASE_SECONDS:-}")" BASE_LINE="" +# An empty sha or count is unknown, not zero: the line then says it caught up +# without claiming the base was exact. case "$BASE_SOURCE" in computed) case "${BASE_REASON:-}" in @@ -68,17 +73,22 @@ case "$BASE_SOURCE" in too_far_behind) WHY="saved diagram too far behind" ;; *) WHY="no saved diagram" ;; esac - BASE_LINE="Base: built from scratch (${WHY}), $(duration "${BASE_SECONDS:-}") · ${CHANGES}" + BASE_LINE="Base: built from scratch (${WHY}), ${TOOK} · ${CHANGES}" ;; - saved | committed | ancestor) - CATCHUP="${CATCHUP_COMMITS:-}" - case "$CATCHUP" in ''|*[!0-9]*) CATCHUP=0 ;; esac - if [ "$CATCHUP" -gt 0 ]; then + saved) + BASE_LINE="Base: saved diagram of ${BASE_BRANCH} @${BASE_FROM:0:7} · ${CHANGES}" + ;; + committed | ancestor) + if [ -z "$BASE_FROM" ] || [ -z "$CATCHUP" ]; then + BASE_LINE="Base: saved diagram of ${BASE_BRANCH}, caught up, ${TOOK} · ${CHANGES}" + elif [ "$CATCHUP" -gt 0 ]; then COMMIT_NOUN="commits" [ "$CATCHUP" != 1 ] || COMMIT_NOUN="commit" - BASE_LINE="Base: caught up ${CATCHUP} ${COMMIT_NOUN} from ${BASE_BRANCH} @${BASE_AT:0:7}, $(duration "${BASE_SECONDS:-}") · ${CHANGES}" + BASE_LINE="Base: caught up ${CATCHUP} ${COMMIT_NOUN} from ${BASE_BRANCH} @${BASE_FROM:0:7}, ${TOOK} · ${CHANGES}" + elif [ "$BASE_SOURCE" = ancestor ]; then + BASE_LINE="Base: caught up from ${BASE_BRANCH} @${BASE_FROM:0:7}, ${TOOK} · ${CHANGES}" else - BASE_LINE="Base: saved diagram of ${BASE_BRANCH} @${BASE_AT:0:7} · ${CHANGES}" + BASE_LINE="Base: saved diagram of ${BASE_BRANCH} @${BASE_FROM:0:7} · ${CHANGES}" fi ;; esac diff --git a/scripts/action/post-progress.sh b/scripts/action/post-progress.sh index 746fcbe..ce5471d 100755 --- a/scripts/action/post-progress.sh +++ b/scripts/action/post-progress.sh @@ -52,4 +52,9 @@ body="$(printf '%s\n\n%s\n %s\n%s\n\n%s\n\n%s\n%s' \ "Open it in [CodeBoarding]($platform) meanwhile: the files, comments and review are there already, and the diff appears when the run finishes." \ "run [${GITHUB_RUN_ID:-}]($run_url) · attempt ${GITHUB_RUN_ATTEMPT:-1}" \ "$marker")" +# Checked last: the ticker may have been stopped while this ran, and a stale +# "running" edit must not land on top of the next step. +if [ "$step" = base ] && [ -n "${PROGRESS_STOP_FILE:-}" ] && [ -e "$PROGRESS_STOP_FILE" ]; then + exit 0 +fi gh api -X PATCH "repos/$REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null diff --git a/tests/test_action_state.py b/tests/test_action_state.py index 1e53262..8008eef 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -334,22 +334,31 @@ def _git(self, *args: str) -> str: text=True, ).stdout.strip() - def _commit(self, message: str, files: dict[str, str]) -> str: + def _commit(self, message: str, files: dict[str, str], *, bot: bool = False) -> str: for name, content in files.items(): (self.checkout / name).parent.mkdir(parents=True, exist_ok=True) (self.checkout / name).write_text(content, encoding="utf-8") self._git("add", "-A") - self._git("commit", "-q", "-m", message) + committer = ("-c", "user.email=codeboarding-review[bot]@users.noreply.github.com") if bot else () + self._git(*committer, "commit", "-q", "-m", message) return self._git("rev-parse", "HEAD") def _sync_history(self) -> tuple[str, str]: """An analysed commit with a sync commit on top that writes only .codeboarding/.""" - self._git("init", "-q") + self._git("init", "-q", "-b", "main") analysed = self._commit("feat: code", {"code.py": "pass\n"}) _state(self.checkout / ".codeboarding", cap=2) - sync = self._commit("chore(codeboarding): sync analysis baseline", {}) + sync = self._commit("chore(codeboarding): sync analysis baseline", {}, bot=True) return analysed, sync + def _merge(self, branch: str, files: dict[str, str], *, bot: bool = False) -> str: + """A commit on `branch` merged into main with --no-ff.""" + self._git("checkout", "-q", "-b", branch) + self._commit(f"work on {branch}", files, bot=bot) + self._git("checkout", "-q", "main") + self._git("merge", "-q", "--no-ff", "-m", f"Merge {branch}", branch) + return self._git("rev-parse", "HEAD") + def _provenance(self, values: dict[str, str]) -> dict[str, str]: keys = ("base_source", "base_reason", "base_from_sha", "catchup_commits") for key in ("base_seconds", "head_seconds"): @@ -427,6 +436,61 @@ def test_a_committed_baseline_counts_the_commits_it_caught_up(self) -> None: self.assertEqual(values["base_from_sha"], analysed) self.assertEqual(values["catchup_commits"], "2") + def test_merged_pull_requests_count_as_commits_to_catch_up(self) -> None: + # Merged with --no-ff, the first-parent chain is merge commits only, and + # each one brings code in even though it changes nothing against itself. + analysed, _sync = self._sync_history() + self._merge("feature-a", {"a.py": "pass\n"}) + merge_base = self._merge("feature-b", {"b.py": "pass\n"}) + + values = self._analyze(REVIEW_BASE_SHA=merge_base) + + self.assertEqual(values["base_from_sha"], analysed) + self.assertEqual(values["catchup_commits"], "2") + + def test_a_merged_sync_pull_request_describes_its_own_parent(self) -> None: + # sync_strategy: pull_request. The sync commit sits on codeboarding/sync on + # top of the analysed commit; main moved on before the merge. + self._git("init", "-q", "-b", "main") + analysed = self._commit("feat: code", {"code.py": "pass\n"}) + self._git("checkout", "-q", "-b", "codeboarding/sync") + _state(self.checkout / ".codeboarding", cap=2) + self._commit("chore(codeboarding): sync analysis baseline", {}, bot=True) + self._git("checkout", "-q", "main") + self._commit("feat: meanwhile", {"later.py": "pass\n"}) + self._git("merge", "-q", "--no-ff", "-m", "Merge codeboarding/sync", "codeboarding/sync") + merge_base = self._git("rev-parse", "HEAD") + + values = self._analyze(REVIEW_BASE_SHA=merge_base) + + self.assertEqual(values["base_source"], "committed") + self.assertEqual(values["base_from_sha"], analysed) + self.assertEqual(values["catchup_commits"], "1") + + def test_a_baseline_sync_did_not_write_is_of_unknown_origin(self) -> None: + # A squash or a hand edit: its parent is not known to be what was analysed. + self._git("init", "-q", "-b", "main") + self._commit("feat: code", {"code.py": "pass\n"}) + _state(self.checkout / ".codeboarding", cap=2) + merge_base = self._commit("chore(codeboarding): sync analysis baseline (#7)", {}) + + values = self._analyze(REVIEW_BASE_SHA=merge_base) + + self.assertEqual(values["base_source"], "committed") + self.assertEqual(values["base_from_sha"], "") + self.assertEqual(values["catchup_commits"], "") + + def test_an_attributes_line_is_not_code_to_catch_up(self) -> None: + analysed, _sync = self._sync_history() + merge_base = self._commit( + "chore: attributes", {".gitattributes": ".codeboarding/analysis.json linguist-generated=true\n"} + ) + + values = self._analyze(REVIEW_BASE_SHA=merge_base) + + self.assertEqual(values["base_from_sha"], analysed) + self.assertEqual(values["catchup_commits"], "0") + def _progress_stub(self) -> Path: calls = self.root / "gh-calls" gh = self.bin_dir / "gh" diff --git a/tests/test_progress_comment.py b/tests/test_progress_comment.py index f96c2bf..034dd72 100644 --- a/tests/test_progress_comment.py +++ b/tests/test_progress_comment.py @@ -76,6 +76,13 @@ def test_the_comment_is_looked_up_once(self) -> None: lookups = [c for c in self.calls.read_text().split("\n----\n") if "/comments?per_page" in c] self.assertEqual(len(lookups), 1) + def test_a_stopped_ticker_never_edits_after_the_next_step(self) -> None: + stop = self.root / "stop" + stop.touch() + self.assertEqual(self._post("base", 120, PROGRESS_STOP_FILE=str(stop)), []) + # The step-2 edit is the one that follows the stop, so it still lands. + self.assertEqual(len(self._post("head", 120, PROGRESS_STOP_FILE=str(stop))), 1) + def test_no_progress_comment_means_no_edit(self) -> None: self.assertEqual(self._post("base", 0, comment_id=""), []) diff --git a/tests/test_review_comment.py b/tests/test_review_comment.py index f88a67a..9df2135 100644 --- a/tests/test_review_comment.py +++ b/tests/test_review_comment.py @@ -111,8 +111,21 @@ def test_a_saved_base_names_the_commit(self) -> None: self.assertEqual(line, "Base: saved diagram of main @a1b2c3d · changes 2 m 39 s") def test_a_committed_base_at_the_merge_base_reads_as_saved(self) -> None: - line = self._base_line(BASE_SOURCE="committed", BASE_FROM_SHA="", CATCHUP_COMMITS="", HEAD_SECONDS="4") - self.assertEqual(line, "Base: saved diagram of main @f00dfee · changes 4 s") + line = self._base_line(BASE_SOURCE="committed", BASE_FROM_SHA="a1b2c3d4", CATCHUP_COMMITS="0", HEAD_SECONDS="4") + self.assertEqual(line, "Base: saved diagram of main @a1b2c3d · changes 4 s") + + def test_an_unknown_catch_up_does_not_claim_an_exact_base(self) -> None: + for sha, count in (("", ""), ("a1b2c3d4", ""), ("", "3")): + line = self._base_line( + BASE_SOURCE="committed", BASE_FROM_SHA=sha, CATCHUP_COMMITS=count, BASE_SECONDS="41", HEAD_SECONDS="4" + ) + self.assertEqual(line, "Base: saved diagram of main, caught up, 41 s · changes 4 s") + + def test_an_ancestor_never_reads_as_saved(self) -> None: + line = self._base_line( + BASE_SOURCE="ancestor", BASE_FROM_SHA="a1b2c3d4", CATCHUP_COMMITS="0", BASE_SECONDS="41", HEAD_SECONDS="4" + ) + self.assertEqual(line, "Base: caught up from main @a1b2c3d, 41 s · changes 4 s") def test_a_caught_up_base_counts_the_commits(self) -> None: line = self._base_line( From 6b46ac320faed2ae77f5998bccace51b4d56f383 Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Fri, 9 Oct 2026 23:03:47 +0200 Subject: [PATCH 03/19] refactor: report the base analysis as reused, incremental or full Review feedback: base_source/base_reason/base_from_sha/catchup_commits used "base" for too many things. A review compares the base commit's analysis with the head's; what matters is how the base analysis was obtained. - base_analysis_method: reused | incremental | full (replaces base_source). A committed baseline with nothing to catch up is reused. - base_analysis_reason: the method in words; the starting commit and the number of commits caught up are detail in it, not fields of their own. - base_seconds / head_seconds unchanged. - The comment's Base: line moves under the diagram, above the run links, and the marker carries base_analysis_method instead of base/base_reason. - Drops the too_far_behind and ancestor wording, which belong to the ancestor lookup in the next PR. Co-Authored-By: Claude Opus 5.5 --- action.yml | 18 ++--- docs/COMMIT_STRATEGY.md | 15 ++-- scripts/action/analyze.sh | 60 +++++++++++---- scripts/action/build-review-artifact.sh | 14 ++-- scripts/action/build-review-comment.sh | 60 +++++---------- scripts/action/post-progress.sh | 3 +- tests/test_action_state.py | 85 ++++++++++++--------- tests/test_progress_comment.py | 4 +- tests/test_review_artifact_metadata.py | 24 ++---- tests/test_review_comment.py | 98 ++++++++++++------------- 10 files changed, 188 insertions(+), 193 deletions(-) diff --git a/action.yml b/action.yml index 420559f..7ca8012 100644 --- a/action.yml +++ b/action.yml @@ -183,9 +183,9 @@ outputs: seed_source: description: 'Which state the review head analysis grew from: pr-chain or base.' value: ${{ steps.review_analyze.outputs.seed_source }} - base_source: - description: 'How the review base was obtained: saved, committed, or computed.' - value: ${{ steps.review_analyze.outputs.base_source }} + base_analysis_method: + description: 'How the analysis of the review base commit was obtained: reused, incremental, or full.' + value: ${{ steps.review_analyze.outputs.base_analysis_method }} merge_base_sha: description: 'Merge base used as the review comparison baseline.' value: ${{ steps.guard.outputs.merge_base_sha }} @@ -617,10 +617,8 @@ runs: SEED_SOURCE: ${{ steps.review_analyze.outputs.seed_source }} CHAIN_DEPTH: ${{ steps.review_analyze.outputs.chain_depth }} ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }} - BASE_SOURCE: ${{ steps.review_analyze.outputs.base_source }} - BASE_REASON: ${{ steps.review_analyze.outputs.base_reason }} - BASE_FROM_SHA: ${{ steps.review_analyze.outputs.base_from_sha }} - CATCHUP_COMMITS: ${{ steps.review_analyze.outputs.catchup_commits }} + BASE_ANALYSIS_METHOD: ${{ steps.review_analyze.outputs.base_analysis_method }} + BASE_ANALYSIS_REASON: ${{ steps.review_analyze.outputs.base_analysis_reason }} BASE_SECONDS: ${{ steps.review_analyze.outputs.base_seconds }} HEAD_SECONDS: ${{ steps.review_analyze.outputs.head_seconds }} run: "$GITHUB_ACTION_PATH/scripts/action/build-review-artifact.sh" @@ -654,10 +652,8 @@ runs: HEAD_SHA: ${{ steps.guard.outputs.head_sha }} # Analysed files whose content hash differs between base and head, from the render step. ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }} - BASE_SOURCE: ${{ steps.review_analyze.outputs.base_source }} - BASE_REASON: ${{ steps.review_analyze.outputs.base_reason }} - BASE_FROM_SHA: ${{ steps.review_analyze.outputs.base_from_sha }} - CATCHUP_COMMITS: ${{ steps.review_analyze.outputs.catchup_commits }} + BASE_ANALYSIS_METHOD: ${{ steps.review_analyze.outputs.base_analysis_method }} + BASE_ANALYSIS_REASON: ${{ steps.review_analyze.outputs.base_analysis_reason }} BASE_SECONDS: ${{ steps.review_analyze.outputs.base_seconds }} HEAD_SECONDS: ${{ steps.review_analyze.outputs.head_seconds }} run: "$GITHUB_ACTION_PATH/scripts/action/build-review-comment.sh" diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index d188966..9f9fdcf 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -72,13 +72,11 @@ never fires. | `base_sha` | string | the base branch tip when the event fired — *not* what was compared against | | `kind` | string | always `review`, so a reader can tell this artifact from a base or warm-start bundle | | `analysed_files_changed` | string | analysed files whose content hash differs between base and head; `unknown` when the analyses cannot say | -| `base_source` | string | how this run obtained the base graph: `saved` (the artifact for the merge base), `committed` (`.codeboarding/` committed at the merge base, caught up), or `computed` (full analysis in this run) | -| `base_reason` | string | only with `computed`: `no_baseline` (nothing to seed from) or `incompatible` (a candidate existed but its depth cap differed, or the engine demanded a full run); empty otherwise | -| `base_from_sha` | string | the commit whose saved analysis seeded the base; the merge base for `saved`, empty for `computed` or when it lies beyond the fetched history | -| `catchup_commits` | string | first-parent commits from `base_from_sha` to the merge base that change anything outside `.codeboarding/`; `0` when exact, empty when unknown | +| `base_analysis_method` | string | how this run obtained the analysis of the merge base: `reused` (the merge base already had one: its saved artifact, or a committed baseline with nothing to catch up), `incremental` (an earlier commit's analysis updated to the merge base) or `full` (analyzed from scratch in this run). Whichever, the base graph is the merge base's own analysis | +| `base_analysis_reason` | string | the method in words, e.g. `updated the analysis of 9f8e7d6 to a1b2c3d, 4 commits caught up`; where an incremental run started and how far it caught up are detail here, present when known | | `base_seconds` | string | wall time spent obtaining the base, the artifact lookup included | | `head_seconds` | string | wall time of the head analysis | -| `pr_number`, `mode`, `seed_source`, `chain_depth` | string | provenance; nothing rendering a diagram needs them. `mode` is the head's engine mode; `base_source` answers for the base | +| `pr_number`, `mode`, `seed_source`, `chain_depth` | string | provenance; nothing rendering a diagram needs them. `mode` is the head's engine mode; `base_analysis_method` answers for the base | **A sync run** publishes the base graph under both the commit it analyzed and the baseline commit it writes on top, because a pull request opened either side of @@ -112,9 +110,10 @@ them: | no compatible committed baseline either | full analysis directly, at the configured `depth_cap` | A trusted run that computed the base publishes it, so the next pull request -forking from that commit gets the first row. The rows are `base_source` `saved`, -`committed` and `computed` in the review metadata, and the review comment says -which one ran, with measured times. While a base is computed, the progress +forking from that commit gets the first row. The review metadata reports the +row as `base_analysis_method` (`reused`, `incremental` or `full`) with a +`base_analysis_reason`, and the review comment repeats both under the diagram, +with measured times. While a base is computed, the progress comment says so in two steps, with the elapsed time and the reason. The configuration hash includes `depth_cap`. The workflow input controls depth diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index 7283373..a7048e5 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -214,7 +214,7 @@ catchup_count() { PROGRESS_PID="" PROGRESS_STOP="${RUNNER_TEMP:-}/codeboarding-progress-stop" progress() { - GH_TOKEN="${GIT_TOKEN:-}" GH_ENTERPRISE_TOKEN="${GIT_TOKEN:-}" BASE_REASON="$base_reason" \ + GH_TOKEN="${GIT_TOKEN:-}" GH_ENTERPRISE_TOKEN="${GIT_TOKEN:-}" FULL_CAUSE="$full_cause" \ PROGRESS_STOP_FILE="$PROGRESS_STOP" "$ACTION_PATH/scripts/action/post-progress.sh" "$@" >/dev/null 2>&1 || true } progress_start() { @@ -262,16 +262,17 @@ analyze_review() { # A published base graph is this merge base's own analysis, named for it, so it # 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="" + # records how the base analysis was obtained, for the comment and the review + # artifact: reused, incremental or full. + local base_started base_method=reused base_published=true full_cause="" base_from_sha="" catchup_commits="" 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_from_sha="$REVIEW_BASE_SHA" catchup_commits=0 else + base_published=false # A bundle under this exact name that the run cannot use was made with another cap. - [ ! -f "${BASE_DIR:-}/analysis.json" ] || base_reason=incompatible + [ ! -f "${BASE_DIR:-}/analysis.json" ] || full_cause=incompatible fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" git -C "$CHECKOUT_DIR" worktree add --detach "$base_checkout" "$REVIEW_BASE_SHA" >/dev/null seed_state "$base_checkout" "$base_state" @@ -279,19 +280,20 @@ analyze_review() { if [ "$(depth_cap_from "$base_state/analysis.json")" = "$DEPTH_CAP" ]; then incremental "$base_checkout" "$base_state" if [ "$REQUIRES_FULL" = true ]; then - base_reason=incompatible + full_cause=incompatible else - base_source=committed fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$(( CATCHUP_BOUND + 1 ))" || true base_from_sha="$(baseline_commit "$REVIEW_BASE_SHA")" [ -z "$base_from_sha" ] || catchup_commits="$(catchup_count "$base_from_sha" "$REVIEW_BASE_SHA")" + # Nothing to catch up means the committed analysis already describes this code. + [ "$catchup_commits" = 0 ] || base_method=incremental fi elif [ -f "$base_state/analysis.json" ]; then - base_reason=incompatible + full_cause=incompatible fi if [ "$REQUIRES_FULL" = true ]; then - base_source=computed - base_reason="${base_reason:-no_baseline}" + base_method=full + full_cause="${full_cause:-no_baseline}" trap progress_stop EXIT progress_start "$base_started" full "$base_checkout" "$base_state" "$DEPTH_CAP" @@ -299,7 +301,6 @@ analyze_review() { progress head "$(( $(date +%s) - base_started ))" fi fi - [ "$base_source" = computed ] || base_reason="" # The lookup in the step before this one is part of obtaining the base too. local base_seconds=$(( $(date +%s) - base_started + ${BASE_FETCH_SECONDS:-0} )) unset GIT_TOKEN @@ -339,15 +340,46 @@ analyze_review() { # by id for its whole retention, so one about to expire is renewed rather than # left dangling under a review that outlives it. local publish_base=false - if [ "$base_source" != saved ] || [ "${RENEW_BASE:-false}" = true ]; then + if [ "$base_published" != true ] || [ "${RENEW_BASE:-false}" = true ]; then stage "$base_state" base publish_base=true fi printf 'analysis_mode=%s\nanalysis_path=%s\nbase_analysis_path=%s\nseed_source=%s\nchain_depth=%s\npublish_base=%s\n' \ "$ANALYSIS_MODE" "$ANALYSIS_PATH" "$base_analysis" "$seed_source" "$chain_depth" "$publish_base" >> "$GITHUB_OUTPUT" - printf 'base_source=%s\nbase_reason=%s\nbase_from_sha=%s\ncatchup_commits=%s\nbase_seconds=%s\nhead_seconds=%s\n' \ - "$base_source" "$base_reason" "$base_from_sha" "$catchup_commits" "$base_seconds" "$head_seconds" >> "$GITHUB_OUTPUT" + printf 'base_analysis_method=%s\nbase_analysis_reason=%s\nbase_seconds=%s\nhead_seconds=%s\n' \ + "$base_method" "$(base_reason "$base_method" "$full_cause" "$base_from_sha" "$catchup_commits")" \ + "$base_seconds" "$head_seconds" >> "$GITHUB_OUTPUT" +} + +# Why the base analysis was obtained the way it was, in words. Whichever way, the +# result is an analysis of the merge base itself, so where an incremental run +# started is detail for this sentence, not a field of its own. +base_reason() { + local method="$1" cause="$2" from="$3" count="$4" base="${REVIEW_BASE_SHA:0:7}" reason + case "$method" in + reused) reason="$base already has a saved analysis" ;; + incremental) + if [ -z "$from" ]; then + reason="updated an existing analysis to $base" + else + reason="updated the analysis of ${from:0:7} to $base" + case "$count" in + '') ;; + 1) reason="$reason, 1 commit caught up" ;; + *) reason="$reason, $count commits caught up" ;; + esac + fi + ;; + full) + if [ "$cause" = incompatible ]; then + reason="the existing analysis was incompatible or could not be updated incrementally" + else + reason="no usable analysis was available" + fi + ;; + esac + echo "$reason" } case "$ANALYSIS_KIND" in diff --git a/scripts/action/build-review-artifact.sh b/scripts/action/build-review-artifact.sh index aa14c95..9db59e4 100755 --- a/scripts/action/build-review-artifact.sh +++ b/scripts/action/build-review-artifact.sh @@ -23,8 +23,8 @@ HEALTH_REPORT="$(dirname "$ANALYSIS_PATH")/health/health_report.json" # pr_base_sha carries the same value under the name the webview already reads: # its lookup is base_commit_sha || pr_base_sha || base_sha, so without it the # webview silently falls through to the branch tip and compares against a base -# this review never used. The base_* fields say how this run obtained the base -# graph; all strings, empty when not applicable. +# this review never used. base_analysis_method and base_analysis_reason say how +# this run obtained the base analysis; all strings, empty from an older run. jq -n \ --arg kind review \ --arg mode "$ANALYSIS_MODE" \ @@ -38,10 +38,8 @@ jq -n \ --arg base_artifact "$BASE_ARTIFACT_NAME" \ --arg base_artifact_id "$BASE_ARTIFACT_ID" \ --arg analysed_files_changed "${ANALYSED_FILES_CHANGED:-unknown}" \ - --arg base_source "${BASE_SOURCE:-}" \ - --arg base_reason "${BASE_REASON:-}" \ - --arg base_from_sha "${BASE_FROM_SHA:-}" \ - --arg catchup_commits "${CATCHUP_COMMITS:-}" \ + --arg base_analysis_method "${BASE_ANALYSIS_METHOD:-}" \ + --arg base_analysis_reason "${BASE_ANALYSIS_REASON:-}" \ --arg base_seconds "${BASE_SECONDS:-}" \ --arg head_seconds "${HEAD_SECONDS:-}" \ '{kind: $kind, mode: $mode, base_sha: $base_sha, merge_base_sha: $merge_base_sha, pr_base_sha: $merge_base_sha, @@ -49,7 +47,7 @@ jq -n \ pr_number: $pr_number, seed_source: $seed_source, chain_depth: $chain_depth, base_artifact: $base_artifact, base_artifact_id: $base_artifact_id, analysed_files_changed: $analysed_files_changed, - base_source: $base_source, base_reason: $base_reason, base_from_sha: $base_from_sha, - catchup_commits: $catchup_commits, base_seconds: $base_seconds, head_seconds: $head_seconds}' \ + base_analysis_method: $base_analysis_method, base_analysis_reason: $base_analysis_reason, + base_seconds: $base_seconds, head_seconds: $head_seconds}' \ > "${RUNNER_TEMP}/cb-review-artifact/metadata.json" echo "artifact_dir=${RUNNER_TEMP}/cb-review-artifact" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/build-review-comment.sh b/scripts/action/build-review-comment.sh index 51e2ea0..4e092a1 100755 --- a/scripts/action/build-review-comment.sh +++ b/scripts/action/build-review-comment.sh @@ -45,8 +45,9 @@ elif [ "$BEHIND" -gt 0 ] 2>/dev/null; then printf '\nCompared against the merge base: this branch is %s %s behind `%s`.\n' \ "$BEHIND" "$COMMIT_NOUN" "${BASE_REF:-the base branch}" >> "$BODY" fi -# How the base was obtained, with measured times only: an estimate would be wrong for -# exactly the slow runs it is meant to explain. +# How the base analysis was obtained, with measured times only: an estimate would be +# wrong for exactly the slow runs it is meant to explain. Supporting detail, so it +# sits under the diagram with the run links. duration() { local seconds="${1:-0}" case "$seconds" in ''|*[!0-9]*) seconds=0 ;; esac @@ -56,47 +57,21 @@ duration() { printf '%s s' "$seconds" fi } -BASE_SOURCE="${BASE_SOURCE:-}" -BASE_BRANCH="${BASE_REF:-the base branch}" -BASE_FROM="${BASE_FROM_SHA:-}" -CATCHUP="${CATCHUP_COMMITS:-}" -case "$CATCHUP" in *[!0-9]*) CATCHUP="" ;; esac -CHANGES="changes $(duration "${HEAD_SECONDS:-}")" -TOOK="$(duration "${BASE_SECONDS:-}")" +BASE_METHOD="${BASE_ANALYSIS_METHOD:-}" BASE_LINE="" -# An empty sha or count is unknown, not zero: the line then says it caught up -# without claiming the base was exact. -case "$BASE_SOURCE" in - computed) - case "${BASE_REASON:-}" in - incompatible) WHY="saved diagram incompatible" ;; - too_far_behind) WHY="saved diagram too far behind" ;; - *) WHY="no saved diagram" ;; - esac - BASE_LINE="Base: built from scratch (${WHY}), ${TOOK} · ${CHANGES}" - ;; - saved) - BASE_LINE="Base: saved diagram of ${BASE_BRANCH} @${BASE_FROM:0:7} · ${CHANGES}" - ;; - committed | ancestor) - if [ -z "$BASE_FROM" ] || [ -z "$CATCHUP" ]; then - BASE_LINE="Base: saved diagram of ${BASE_BRANCH}, caught up, ${TOOK} · ${CHANGES}" - elif [ "$CATCHUP" -gt 0 ]; then - COMMIT_NOUN="commits" - [ "$CATCHUP" != 1 ] || COMMIT_NOUN="commit" - BASE_LINE="Base: caught up ${CATCHUP} ${COMMIT_NOUN} from ${BASE_BRANCH} @${BASE_FROM:0:7}, ${TOOK} · ${CHANGES}" - elif [ "$BASE_SOURCE" = ancestor ]; then - BASE_LINE="Base: caught up from ${BASE_BRANCH} @${BASE_FROM:0:7}, ${TOOK} · ${CHANGES}" - else - BASE_LINE="Base: saved diagram of ${BASE_BRANCH} @${BASE_FROM:0:7} · ${CHANGES}" - fi - ;; -esac -[ -z "$BASE_LINE" ] || printf '\n%s\n' "$BASE_LINE" >> "$BODY" +if [ -n "$BASE_METHOD" ]; then + BASE_LINE="Base: ${BASE_METHOD}" + # A reused analysis had nothing to catch up, so its time is not worth a figure. + [ "$BASE_METHOD" = reused ] || BASE_LINE="${BASE_LINE} in $(duration "${BASE_SECONDS:-}")" + [ -z "${BASE_ANALYSIS_REASON:-}" ] || BASE_LINE="${BASE_LINE} (${BASE_ANALYSIS_REASON})" + BASE_LINE="${BASE_LINE} · changes $(duration "${HEAD_SECONDS:-}")" +fi { printf '\n' cat "$DIAGRAM" - printf '\n\n' + printf '\n' + [ -z "$BASE_LINE" ] || printf '\n%s\n' "$BASE_LINE" + printf '\n' if [ -n "$ARTIFACT_URL" ]; then printf '[download artifacts](%s) · ' "$ARTIFACT_URL" fi @@ -104,11 +79,10 @@ esac # The machine-readable line: what a reader of the comment (the web platform's dashboard, an # agent) needs without parsing the prose or the diagram. An HTML comment renders as nothing. # Keep it one line, `key=value` pairs, values without spaces, so a regex over it stays trivial. + # The reason is prose, so it stays out of here: it is in the review artifact's metadata. BASE_KEYS="" - if [ -n "$BASE_SOURCE" ]; then - BASE_KEYS=" base=${BASE_SOURCE}" - [ -z "${BASE_REASON:-}" ] || BASE_KEYS="${BASE_KEYS} base_reason=${BASE_REASON}" - BASE_KEYS="${BASE_KEYS} base_seconds=${BASE_SECONDS:-} head_seconds=${HEAD_SECONDS:-}" + if [ -n "$BASE_METHOD" ]; then + BASE_KEYS=" base_analysis_method=${BASE_METHOD} base_seconds=${BASE_SECONDS:-} head_seconds=${HEAD_SECONDS:-}" fi printf '\n' \ "$PLATFORM_URL" "$N_CHANGED" "$ANALYSED_FILES_CHANGED" "${HEAD_SHA:-}" "$BASE_KEYS" diff --git a/scripts/action/post-progress.sh b/scripts/action/post-progress.sh index ce5471d..7ccb210 100755 --- a/scripts/action/post-progress.sh +++ b/scripts/action/post-progress.sh @@ -26,9 +26,8 @@ else branch="the base branch" fi sha7="${REVIEW_BASE_SHA:0:7}" -case "${BASE_REASON:-}" in +case "${FULL_CAUSE:-}" in incompatible) why="The saved diagram of $branch was made by a different engine version or settings, so this review builds a new one." ;; - too_far_behind) why="The nearest saved diagram of $branch is too far behind this pull request's base, so this review builds one from scratch." ;; *) why="$branch has no saved diagram yet, so this review builds one first. Once a diagram of $branch is saved, reviews start from it and skip this step." ;; esac minutes=$(( elapsed / 60 )) diff --git a/tests/test_action_state.py b/tests/test_action_state.py index 8008eef..de351fd 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -360,69 +360,70 @@ def _merge(self, branch: str, files: dict[str, str], *, bot: bool = False) -> st return self._git("rev-parse", "HEAD") def _provenance(self, values: dict[str, str]) -> dict[str, str]: - keys = ("base_source", "base_reason", "base_from_sha", "catchup_commits") + keys = ("base_analysis_method", "base_analysis_reason") for key in ("base_seconds", "head_seconds"): self.assertRegex(values[key], r"^[0-9]+$", key) return {key: values[key] for key in keys} - def test_a_saved_base_reports_itself_as_exact(self) -> None: + def test_a_saved_base_is_reused(self) -> None: _state(self.base_dir) values = self._analyze(BASE_FETCH_SECONDS="7") self.assertEqual( self._provenance(values), - {"base_source": "saved", "base_reason": "", "base_from_sha": "merge-base-sha", "catchup_commits": "0"}, + {"base_analysis_method": "reused", "base_analysis_reason": "merge-b already has a saved analysis"}, ) # The download happened in the step before; it is still time spent on the base. self.assertGreaterEqual(int(values["base_seconds"]), 7) - def test_a_base_with_nothing_to_seed_it_is_computed_for_lack_of_a_baseline(self) -> None: + def test_a_base_with_nothing_to_seed_it_is_a_full_analysis(self) -> None: sha = self._commit_base() values = self._analyze(REVIEW_BASE_SHA=sha) self.assertEqual( self._provenance(values), - {"base_source": "computed", "base_reason": "no_baseline", "base_from_sha": "", "catchup_commits": ""}, + {"base_analysis_method": "full", "base_analysis_reason": "no usable analysis was available"}, + ) + + def _assert_incompatible(self, values: dict[str, str]) -> None: + self.assertEqual( + self._provenance(values), + { + "base_analysis_method": "full", + "base_analysis_reason": "the existing analysis was incompatible or could not be updated incrementally", + }, ) def test_a_committed_baseline_with_another_depth_is_incompatible(self) -> None: sha = self._commit_base(legacy=True) - values = self._analyze(REVIEW_BASE_SHA=sha) - - self.assertEqual(values["base_source"], "computed") - self.assertEqual(values["base_reason"], "incompatible") + self._assert_incompatible(self._analyze(REVIEW_BASE_SHA=sha)) def test_a_saved_base_with_another_depth_is_incompatible(self) -> None: sha = self._commit_base() _state(self.base_dir, cap=1) - values = self._analyze(REVIEW_BASE_SHA=sha) - - self.assertEqual(values["base_source"], "computed") - self.assertEqual(values["base_reason"], "incompatible") + self._assert_incompatible(self._analyze(REVIEW_BASE_SHA=sha)) def test_an_engine_that_demands_a_full_run_makes_the_baseline_incompatible(self) -> None: sha = self._commit_base(cap=2) - values = self._analyze(REVIEW_BASE_SHA=sha, CB_REQUIRE_FULL="true") + self._assert_incompatible(self._analyze(REVIEW_BASE_SHA=sha, CB_REQUIRE_FULL="true")) - self.assertEqual(values["base_source"], "computed") - self.assertEqual(values["base_reason"], "incompatible") - - def test_a_baseline_committed_at_the_merge_base_needs_no_catching_up(self) -> None: + def test_a_baseline_committed_at_the_merge_base_is_reused(self) -> None: # The sync commit changes only .codeboarding/, which the analysis ignores, # so a merge base on it is exactly the commit the baseline describes. - analysed, sync = self._sync_history() + _analysed, sync = self._sync_history() values = self._analyze(REVIEW_BASE_SHA=sync) self.assertEqual( self._provenance(values), - {"base_source": "committed", "base_reason": "", "base_from_sha": analysed, "catchup_commits": "0"}, + {"base_analysis_method": "reused", "base_analysis_reason": f"{sync[:7]} already has a saved analysis"}, ) + # Not under the merge base's name yet, so this run publishes it. self.assertEqual(values["publish_base"], "true") def test_a_committed_baseline_counts_the_commits_it_caught_up(self) -> None: @@ -432,9 +433,13 @@ def test_a_committed_baseline_counts_the_commits_it_caught_up(self) -> None: values = self._analyze(REVIEW_BASE_SHA=merge_base) - self.assertEqual(values["base_source"], "committed") - self.assertEqual(values["base_from_sha"], analysed) - self.assertEqual(values["catchup_commits"], "2") + self.assertEqual( + self._provenance(values), + { + "base_analysis_method": "incremental", + "base_analysis_reason": f"updated the analysis of {analysed[:7]} to {merge_base[:7]}, 2 commits caught up", + }, + ) def test_merged_pull_requests_count_as_commits_to_catch_up(self) -> None: # Merged with --no-ff, the first-parent chain is merge commits only, and @@ -445,8 +450,10 @@ def test_merged_pull_requests_count_as_commits_to_catch_up(self) -> None: values = self._analyze(REVIEW_BASE_SHA=merge_base) - self.assertEqual(values["base_from_sha"], analysed) - self.assertEqual(values["catchup_commits"], "2") + self.assertEqual( + values["base_analysis_reason"], + f"updated the analysis of {analysed[:7]} to {merge_base[:7]}, 2 commits caught up", + ) def test_a_merged_sync_pull_request_describes_its_own_parent(self) -> None: # sync_strategy: pull_request. The sync commit sits on codeboarding/sync on @@ -463,12 +470,17 @@ def test_a_merged_sync_pull_request_describes_its_own_parent(self) -> None: values = self._analyze(REVIEW_BASE_SHA=merge_base) - self.assertEqual(values["base_source"], "committed") - self.assertEqual(values["base_from_sha"], analysed) - self.assertEqual(values["catchup_commits"], "1") + self.assertEqual( + self._provenance(values), + { + "base_analysis_method": "incremental", + "base_analysis_reason": f"updated the analysis of {analysed[:7]} to {merge_base[:7]}, 1 commit caught up", + }, + ) def test_a_baseline_sync_did_not_write_is_of_unknown_origin(self) -> None: - # A squash or a hand edit: its parent is not known to be what was analysed. + # A squash or a hand edit: its parent is not known to be what was analysed, + # so the reason names no starting commit and claims nothing was exact. self._git("init", "-q", "-b", "main") self._commit("feat: code", {"code.py": "pass\n"}) _state(self.checkout / ".codeboarding", cap=2) @@ -476,20 +488,23 @@ def test_a_baseline_sync_did_not_write_is_of_unknown_origin(self) -> None: values = self._analyze(REVIEW_BASE_SHA=merge_base) - self.assertEqual(values["base_source"], "committed") - self.assertEqual(values["base_from_sha"], "") - self.assertEqual(values["catchup_commits"], "") + self.assertEqual( + self._provenance(values), + { + "base_analysis_method": "incremental", + "base_analysis_reason": f"updated an existing analysis to {merge_base[:7]}", + }, + ) def test_an_attributes_line_is_not_code_to_catch_up(self) -> None: - analysed, _sync = self._sync_history() + _analysed, _sync = self._sync_history() merge_base = self._commit( "chore: attributes", {".gitattributes": ".codeboarding/analysis.json linguist-generated=true\n"} ) values = self._analyze(REVIEW_BASE_SHA=merge_base) - self.assertEqual(values["base_from_sha"], analysed) - self.assertEqual(values["catchup_commits"], "0") + self.assertEqual(values["base_analysis_method"], "reused") def _progress_stub(self) -> Path: calls = self.root / "gh-calls" diff --git a/tests/test_progress_comment.py b/tests/test_progress_comment.py index 034dd72..8994422 100644 --- a/tests/test_progress_comment.py +++ b/tests/test_progress_comment.py @@ -50,7 +50,7 @@ def _post(self, step: str, elapsed: int, comment_id: str = "77", **extra: str) - return [c for c in self.calls.read_text().split("\n----\n") if "PATCH" in c] if self.calls.exists() else [] def test_the_base_step_shows_elapsed_minutes_and_the_reason(self) -> None: - (patch,) = self._post("base", 125, BASE_REASON="no_baseline") + (patch,) = self._post("base", 125, FULL_CAUSE="no_baseline") self.assertIn("1. ⏳ Building the diagram of `main` @a1b2c3d from scratch · running for 2 min\n", patch) self.assertIn(" `main` has no saved diagram yet, so this review builds one first.", patch) self.assertIn("\n2. Analysing this PR's changes\n", patch) @@ -61,7 +61,7 @@ def test_the_first_minute_does_not_read_as_zero(self) -> None: self.assertIn("running for less than a minute", patch) def test_an_incompatible_base_says_why(self) -> None: - (patch,) = self._post("base", 60, BASE_REASON="incompatible") + (patch,) = self._post("base", 60, FULL_CAUSE="incompatible") self.assertIn("The saved diagram of `main` was made by a different engine version or settings", patch) def test_the_head_step_reports_the_measured_base_time(self) -> None: diff --git a/tests/test_review_artifact_metadata.py b/tests/test_review_artifact_metadata.py index a9bd90c..7a79060 100644 --- a/tests/test_review_artifact_metadata.py +++ b/tests/test_review_artifact_metadata.py @@ -69,24 +69,19 @@ def test_how_the_base_was_obtained_is_recorded_as_strings(self) -> None: with tempfile.TemporaryDirectory() as tmp: metadata, _outputs = _build( Path(tmp), - BASE_SOURCE="committed", - BASE_REASON="", - BASE_FROM_SHA="abc", - CATCHUP_COMMITS="3", + BASE_ANALYSIS_METHOD="incremental", + BASE_ANALYSIS_REASON="updated the analysis of 9f8e7d6 to a1b2c3d, 3 commits caught up", BASE_SECONDS="41", HEAD_SECONDS="159", ) self.assertEqual( - {key: metadata[key] for key in metadata if key.startswith("base_") or key.endswith("_seconds")} - | {"catchup_commits": metadata["catchup_commits"]}, + {key: metadata[key] for key in metadata if key.startswith("base_") or key.endswith("_seconds")}, { "base_sha": "tip-sha", "base_artifact": "codeboarding-base-cfg-mergebasesha", "base_artifact_id": "4242", - "base_source": "committed", - "base_reason": "", - "base_from_sha": "abc", - "catchup_commits": "3", + "base_analysis_method": "incremental", + "base_analysis_reason": "updated the analysis of 9f8e7d6 to a1b2c3d, 3 commits caught up", "base_seconds": "41", "head_seconds": "159", }, @@ -95,14 +90,7 @@ def test_how_the_base_was_obtained_is_recorded_as_strings(self) -> None: def test_an_older_run_without_provenance_records_empty_strings(self) -> None: with tempfile.TemporaryDirectory() as tmp: metadata, _outputs = _build(Path(tmp)) - for key in ( - "base_source", - "base_reason", - "base_from_sha", - "catchup_commits", - "base_seconds", - "head_seconds", - ): + for key in ("base_analysis_method", "base_analysis_reason", "base_seconds", "head_seconds"): self.assertEqual(metadata[key], "", key) diff --git a/tests/test_review_comment.py b/tests/test_review_comment.py index 9df2135..ca77256 100644 --- a/tests/test_review_comment.py +++ b/tests/test_review_comment.py @@ -83,82 +83,76 @@ def test_a_changed_analysed_file_gets_no_verdict_even_at_zero_components(self) - class BaseLineTests(unittest.TestCase): - """One line saying how the base was obtained, with measured times and never an estimate.""" + """One line under the diagram saying how the base analysis was obtained, with measured times only.""" - def _base_line(self, **extra: str) -> str: + def _body(self, **extra: str) -> str: with tempfile.TemporaryDirectory() as tmp: - body = _build(Path(tmp), BASE_REF="main", MERGE_BASE_SHA="f00dfeed" * 5, **extra) + return _build(Path(tmp), BASE_REF="main", MERGE_BASE_SHA="f00dfeed" * 5, **extra) + + def _base_line(self, **extra: str) -> str: + body = self._body(**extra) lines = [line for line in body.splitlines() if line.startswith("Base: ")] self.assertEqual(len(lines), 1, body) return lines[0] - def test_a_computed_base_says_why_and_how_long(self) -> None: + def test_a_full_analysis_says_why_and_how_long(self) -> None: line = self._base_line( - BASE_SOURCE="computed", BASE_REASON="no_baseline", BASE_SECONDS="534", HEAD_SECONDS="192" + BASE_ANALYSIS_METHOD="full", + BASE_ANALYSIS_REASON="no usable analysis was available", + BASE_SECONDS="534", + HEAD_SECONDS="192", ) - self.assertEqual(line, "Base: built from scratch (no saved diagram), 8 m 54 s · changes 3 m 12 s") - - def test_an_incompatible_base_says_so(self) -> None: - line = self._base_line(BASE_SOURCE="computed", BASE_REASON="incompatible", BASE_SECONDS="60", HEAD_SECONDS="5") self.assertEqual( - line, "Base: built from scratch (saved diagram incompatible), 1 m 0 s · changes 5 s" - ) - - def test_a_saved_base_names_the_commit(self) -> None: - line = self._base_line( - BASE_SOURCE="saved", BASE_FROM_SHA="a1b2c3d4e5", CATCHUP_COMMITS="0", BASE_SECONDS="3", HEAD_SECONDS="159" + line, "Base: full in 8 m 54 s (no usable analysis was available) · changes 3 m 12 s" ) - self.assertEqual(line, "Base: saved diagram of main @a1b2c3d · changes 2 m 39 s") - - def test_a_committed_base_at_the_merge_base_reads_as_saved(self) -> None: - line = self._base_line(BASE_SOURCE="committed", BASE_FROM_SHA="a1b2c3d4", CATCHUP_COMMITS="0", HEAD_SECONDS="4") - self.assertEqual(line, "Base: saved diagram of main @a1b2c3d · changes 4 s") - - def test_an_unknown_catch_up_does_not_claim_an_exact_base(self) -> None: - for sha, count in (("", ""), ("a1b2c3d4", ""), ("", "3")): - line = self._base_line( - BASE_SOURCE="committed", BASE_FROM_SHA=sha, CATCHUP_COMMITS=count, BASE_SECONDS="41", HEAD_SECONDS="4" - ) - self.assertEqual(line, "Base: saved diagram of main, caught up, 41 s · changes 4 s") - def test_an_ancestor_never_reads_as_saved(self) -> None: + def test_a_reused_analysis_has_no_base_time(self) -> None: line = self._base_line( - BASE_SOURCE="ancestor", BASE_FROM_SHA="a1b2c3d4", CATCHUP_COMMITS="0", BASE_SECONDS="41", HEAD_SECONDS="4" + BASE_ANALYSIS_METHOD="reused", + BASE_ANALYSIS_REASON="a1b2c3d already has a saved analysis", + BASE_SECONDS="3", + HEAD_SECONDS="159", ) - self.assertEqual(line, "Base: caught up from main @a1b2c3d, 41 s · changes 4 s") + self.assertEqual(line, "Base: reused (a1b2c3d already has a saved analysis) · changes 2 m 39 s") - def test_a_caught_up_base_counts_the_commits(self) -> None: + def test_an_incremental_analysis_carries_where_it_started(self) -> None: line = self._base_line( - BASE_SOURCE="committed", - BASE_FROM_SHA="a1b2c3d4e5", - CATCHUP_COMMITS="4", + BASE_ANALYSIS_METHOD="incremental", + BASE_ANALYSIS_REASON="updated the analysis of 9f8e7d6 to a1b2c3d, 4 commits caught up", BASE_SECONDS="41", HEAD_SECONDS="159", ) - self.assertEqual(line, "Base: caught up 4 commits from main @a1b2c3d, 41 s · changes 2 m 39 s") + self.assertEqual( + line, + "Base: incremental in 41 s (updated the analysis of 9f8e7d6 to a1b2c3d, 4 commits caught up)" + " · changes 2 m 39 s", + ) - def test_the_marker_carries_the_base_after_the_existing_keys(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - body = _build( - Path(tmp), BASE_SOURCE="computed", BASE_REASON="no_baseline", BASE_SECONDS="534", HEAD_SECONDS="192" - ) + def test_the_base_line_sits_under_the_diagram_above_the_run_links(self) -> None: + body = self._body(BASE_ANALYSIS_METHOD="reused", BASE_ANALYSIS_REASON="r", HEAD_SECONDS="4") + diagram = body.index("```mermaid") + base = body.index("Base: ") + footer = body.index("run [1234]") + self.assertLess(diagram, base) + self.assertLess(base, footer) + self.assertIn("· changes 4 s\n\nrun [1234]", body) + + def test_the_marker_carries_the_method_after_the_existing_keys(self) -> None: + body = self._body( + BASE_ANALYSIS_METHOD="full", + BASE_ANALYSIS_REASON="no usable analysis was available", + BASE_SECONDS="534", + HEAD_SECONDS="192", + ) self.assertTrue( - body.rstrip("\n").endswith( - "head=abc123 base=computed base_reason=no_baseline base_seconds=534 head_seconds=192 -->" - ), + body.rstrip("\n").endswith("head=abc123 base_analysis_method=full base_seconds=534 head_seconds=192 -->"), body, ) - def test_the_marker_omits_an_empty_reason(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - body = _build(Path(tmp), BASE_SOURCE="saved", BASE_REASON="", BASE_SECONDS="2", HEAD_SECONDS="9") - self.assertIn("head=abc123 base=saved base_seconds=2 head_seconds=9 -->", body) - - def test_without_a_base_source_there_is_no_line_and_no_keys(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - body = _build(Path(tmp)) + def test_without_a_method_there_is_no_line_and_no_keys(self) -> None: + body = self._body() self.assertNotIn("Base:", body) - self.assertNotIn(" base=", body) + self.assertNotIn("base_analysis_method=", body) if __name__ == "__main__": From 3ca8dc1db1efab00b07b254ae7dc4fbe28f21af5 Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Fri, 9 Oct 2026 23:06:11 +0200 Subject: [PATCH 04/19] feat: catch up the review base from the nearest saved ancestor With no saved base for the merge base and no usable baseline committed there, a review analyzed the merge base from scratch, even when a saved analysis of a commit a few steps below it was sitting in the artifact store. find-ancestor-base.sh walks the merge base's first-parent history up to 100 commits, deepening the shallow checkout, then pages through the artifact listing newest first, keeping base analyses for this configuration that a run on the repository's own code produced (the same provenance rule as fetch-state.sh). It stops at the first page holding a walked commit (50 pages at most; one or two calls in practice, and only on a run that would otherwise analyze from scratch). The nearest hit seeds an incremental catch-up to the merge base, which is then published under the merge base's own name. Order: exact artifact, committed at the merge base, nearest ancestor, full. Seeding keeps the merge base's own .codeboardingignore and health configuration. Reported as base_analysis_method=incremental, the reason naming the ancestor and the commits caught up. A saved analysis of the merge base under another configuration makes a full run's reason "incompatible"; anything else is "no usable analysis was available". There is no separate "too far behind" reason: proving it took compare API calls that only changed the wording. Sync uses the same lookup when the branch has no usable committed baseline, so the first sync after the setup pull request merges catches up from the base that pull request's review saved. FORCE_FULL is now lowercased with tr, which also runs on the bash 3.2 macOS ships. Co-Authored-By: Claude Opus 5.5 --- action.yml | 9 + docs/COMMIT_STRATEGY.md | 20 +- scripts/action/analyze.sh | 82 +++++++- scripts/action/find-ancestor-base.sh | 81 ++++++++ tests/test_action_state.py | 291 +++++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 13 deletions(-) create mode 100755 scripts/action/find-ancestor-base.sh diff --git a/action.yml b/action.yml index 7ca8012..3dbd9a6 100644 --- a/action.yml +++ b/action.yml @@ -451,6 +451,13 @@ runs: CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target STAGE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/out FORCE_FULL: ${{ inputs.force_full }} + 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 != '' }} + GIT_TOKEN: ${{ inputs.github_token }} + REPOSITORY: ${{ github.repository }} + GH_HOST: ${{ github.server_url }} + GITHUB_SERVER_URL: ${{ github.server_url }} DEPTH_CAP: ${{ inputs.depth_cap }} MODEL: ${{ inputs.model }} AGENT_MODEL_INPUT: ${{ inputs.agent_model }} @@ -539,6 +546,8 @@ runs: BASE_FETCH_SECONDS: ${{ steps.fetch_base.outputs.seconds }} WARMSTART_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/warmstart RENEW_BASE: ${{ steps.fetch_base.outputs.renew }} + # 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 != '' }} # For rewriting the progress comment while a base is built from scratch. PROGRESS_HEADER: ${{ steps.guard.outputs.comment_id }} BASE_REF: ${{ steps.guard.outputs.base_ref }} diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index 9f9fdcf..52407b2 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -72,7 +72,7 @@ never fires. | `base_sha` | string | the base branch tip when the event fired — *not* what was compared against | | `kind` | string | always `review`, so a reader can tell this artifact from a base or warm-start bundle | | `analysed_files_changed` | string | analysed files whose content hash differs between base and head; `unknown` when the analyses cannot say | -| `base_analysis_method` | string | how this run obtained the analysis of the merge base: `reused` (the merge base already had one: its saved artifact, or a committed baseline with nothing to catch up), `incremental` (an earlier commit's analysis updated to the merge base) or `full` (analyzed from scratch in this run). Whichever, the base graph is the merge base's own analysis | +| `base_analysis_method` | string | how this run obtained the analysis of the merge base: `reused` (the merge base already had one: its saved artifact, or a committed baseline with nothing to catch up), `incremental` (an earlier commit's analysis, committed or saved, updated to the merge base) or `full` (analyzed from scratch in this run). Whichever, the base graph is the merge base's own analysis | | `base_analysis_reason` | string | the method in words, e.g. `updated the analysis of 9f8e7d6 to a1b2c3d, 4 commits caught up`; where an incremental run started and how far it caught up are detail here, present when known | | `base_seconds` | string | wall time spent obtaining the base, the artifact lookup included | | `head_seconds` | string | wall time of the head analysis | @@ -107,15 +107,29 @@ 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 | -| no compatible committed baseline either | full analysis directly, at the configured `depth_cap` | +| 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` | A trusted run that computed the base publishes it, so the next pull request -forking from that commit gets the first row. The review metadata reports the +forking from that commit gets the first row; that includes a base caught up from +an ancestor. The review metadata reports the row as `base_analysis_method` (`reused`, `incremental` or `full`) with a `base_analysis_reason`, and the review comment repeats both under the diagram, with measured times. While a base is computed, the progress comment says so in two steps, with the elapsed time and the reason. +The ancestor lookup walks the merge base's first-parent history, deepening the +shallow checkout to 101 commits, then pages through the repository's artifacts +newest first, keeping those named for this configuration and produced by a run on +the repository's own code. It stops at the first page holding one of the walked +commits (usually the first; at most 50 pages) and takes the nearest commit seen. +The merge base's own `.codeboardingignore` and health configuration replace the +seed's. A base caught up this way reports `incremental`, its reason naming the +ancestor and the commits caught up. Sync uses the same lookup when the branch has +no usable committed baseline, so the first sync after the setup pull request +merges catches up from the base that pull request's review saved, instead of +analyzing from scratch. + The configuration hash includes `depth_cap`. The workflow input controls depth for both fresh and fallback analyses; stored legacy depth values never override it. diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index a7048e5..25a9b5e 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -124,14 +124,24 @@ analyze_sync() { rm -rf "$work" seed_state "$CHECKOUT_DIR" "$state" - if [ "${FORCE_FULL,,}" = true ] || [ "$(depth_cap_from "$state/analysis.json")" != "$DEPTH_CAP" ]; then - full "$CHECKOUT_DIR" "$state" "$DEPTH_CAP" - else - incremental "$CHECKOUT_DIR" "$state" - if [ "$REQUIRES_FULL" = true ]; then - full "$CHECKOUT_DIR" "$state" "$DEPTH_CAP" + REQUIRES_FULL=true + if [ "$(printf '%s' "${FORCE_FULL:-false}" | tr '[:upper:]' '[:lower:]')" != true ]; then + if [ "$(depth_cap_from "$state/analysis.json")" = "$DEPTH_CAP" ]; then + incremental "$CHECKOUT_DIR" "$state" + fi + # A branch with no usable committed baseline, such as the first sync after the + # setup pull request merged, catches up from that pull request's saved base. + if [ "$REQUIRES_FULL" = true ] && + seed_from_ancestor "${REPOSITORY:-}" "$(git -C "$CHECKOUT_DIR" rev-parse HEAD 2>/dev/null || true)" "$state" true "$CHECKOUT_DIR"; then + incremental "$CHECKOUT_DIR" "$state" + [ "$REQUIRES_FULL" = true ] || + echo "::notice::Caught up from the saved analysis of $ANCESTOR_SHA instead of analyzing from scratch." fi fi + unset GIT_TOKEN + if [ "$REQUIRES_FULL" = true ]; then + full "$CHECKOUT_DIR" "$state" "$DEPTH_CAP" + fi # Sync already computes the graph every review of this branch compares against, # so publish it instead of making the first pull request recompute it. stage "$state" base @@ -140,8 +150,9 @@ analyze_sync() { } # How far below the merge base this run looks for the commit a saved analysis -# describes. Past it, a catch-up count is reported as unknown. -CATCHUP_BOUND=100 +# describes, and for a saved ancestor to catch up from. Past it, a catch-up count +# is unknown and no ancestor is used. It is also the fetch depth. +CATCHUP_BOUND="${CATCHUP_BOUND:-100}" # A depth above 1 also deepens a commit the shallow checkout already holds. fetch_commit() { @@ -209,6 +220,46 @@ catchup_count() { echo "$count" } +# Replaces $3 with the saved analysis of the nearest first-parent ancestor of $2 +# (or of $2 itself when $4 is true), keeping the user-authored configuration of +# checkout $5. Sets ANCESTOR_SHA, or ANCESTOR_REASON=incompatible when the only +# candidate was saved under another configuration or depth. +ANCESTOR_SHA="" ANCESTOR_REASON="" +seed_from_ancestor() { + local repository="$1" tip="$2" state="$3" include_tip="$4" config_from="$5" found dest="$RUNNER_TEMP/codeboarding-ancestor" + ANCESTOR_SHA="" ANCESTOR_REASON="" + [ "${ANCESTOR_LOOKUP:-false}" = true ] && [ -n "${CFG_HASH:-}" ] && [ -n "${REPOSITORY:-}" ] && [ -n "$tip" ] || return 1 + fetch_commit "$repository" "$tip" "$(( CATCHUP_BOUND + 1 ))" || true + found="$(GH_TOKEN="${GIT_TOKEN:-}" GH_ENTERPRISE_TOKEN="${GIT_TOKEN:-}" TIP_SHA="$tip" DEST="$dest" \ + INCLUDE_TIP="$include_tip" CATCHUP_BOUND="$CATCHUP_BOUND" \ + "$ACTION_PATH/scripts/action/find-ancestor-base.sh" || true)" + ANCESTOR_SHA="$(awk -F= '$1 == "ancestor_sha" {print $2; exit}' <<< "$found")" + if [ -z "$ANCESTOR_SHA" ]; then + ! grep -qx 'other_cfg_at_tip=true' <<< "$found" || ANCESTOR_REASON=incompatible + return 1 + fi + if [ "$(depth_cap_from "$dest/analysis.json")" != "$DEPTH_CAP" ]; then + ANCESTOR_SHA="" ANCESTOR_REASON=incompatible + return 1 + fi + rm -rf "$state" + cp -a "$dest" "$state" + keep_user_config "$config_from" "$state" +} +# What the user writes under .codeboarding/ belongs to the commit being analysed, +# not to whichever analysis seeded it. +USER_CONFIG=(.codeboardingignore health/health_config.json health/.healthignore) +keep_user_config() { + local checkout="$1" state="$2" file + for file in "${USER_CONFIG[@]}"; do + rm -f "${state:?}/$file" + [ ! -f "$checkout/.codeboarding/$file" ] || { + mkdir -p "$(dirname "$state/$file")" + cp "$checkout/.codeboarding/$file" "$state/$file" + } + done +} + # 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="" @@ -291,9 +342,20 @@ analyze_review() { elif [ -f "$base_state/analysis.json" ]; then full_cause=incompatible 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 + incremental "$base_checkout" "$base_state" + if [ "$REQUIRES_FULL" = true ]; then + full_cause=incompatible + else + base_method=incremental base_from_sha="$ANCESTOR_SHA" + catchup_commits="$(catchup_count "$ANCESTOR_SHA" "$REVIEW_BASE_SHA")" + fi + fi if [ "$REQUIRES_FULL" = true ]; then base_method=full - full_cause="${full_cause:-no_baseline}" + full_cause="${full_cause:-${ANCESTOR_REASON:-no_baseline}}" trap progress_stop EXIT progress_start "$base_started" full "$base_checkout" "$base_state" "$DEPTH_CAP" @@ -365,7 +427,7 @@ base_reason() { else reason="updated the analysis of ${from:0:7} to $base" case "$count" in - '') ;; + '' | 0) ;; 1) reason="$reason, 1 commit caught up" ;; *) reason="$reason, $count commits caught up" ;; esac diff --git a/scripts/action/find-ancestor-base.sh b/scripts/action/find-ancestor-base.sh new file mode 100755 index 0000000..e3cf119 --- /dev/null +++ b/scripts/action/find-ancestor-base.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Finds the nearest first-parent ancestor of TIP_SHA with a saved base analysis +# under this configuration and downloads it into DEST, so a run without an exact +# base can catch up from it instead of analyzing from scratch. Best effort. +# +# Prints key=value lines on stdout and everything else on stderr: +# ancestor_sha the commit whose analysis is now in DEST, or empty +# other_cfg_at_tip true when TIP_SHA has a saved analysis under another configuration +# +# Needs the tip's first-parent history in CHECKOUT_DIR, at least CATCHUP_BOUND deep. +set -euo pipefail +: "${REPOSITORY:?}" "${CFG_HASH:?}" "${TIP_SHA:?}" "${CHECKOUT_DIR:?}" "${DEST:?}" +BOUND="${CATCHUP_BOUND:-100}" +rm -rf "$DEST" + +api() { gh api -H 'Accept: application/vnd.github+json' "$@"; } +export GH_HOST="${GH_HOST:-github.com}" +GH_HOST="${GH_HOST#*://}" + +ancestor="" other_cfg=false +report() { + printf 'ancestor_sha=%s\nother_cfg_at_tip=%s\n' "$ancestor" "$other_cfg" +} +trap report EXIT + +# The merge base's first-parent history, nearest first. Distance 0 is the tip +# itself, which a review already looked up by its exact name; sync has no such +# lookup and passes INCLUDE_TIP=true. +walk="$(git -C "$CHECKOUT_DIR" rev-list --first-parent --max-count=$(( BOUND + 1 )) "$TIP_SHA" 2>/dev/null || true)" +[ "${INCLUDE_TIP:-false}" = true ] || walk="$(tail -n +2 <<< "$walk")" +nearest() { + local commit + for commit in $walk; do + if grep -qx "$commit" <<< "$saved"; then + echo "$commit" + return 0 + fi + done +} + +# The artifact listing, newest first. Its name filter is exact, so a prefix needs +# the pages themselves; paging stops at the first page holding a saved ancestor. +# Bases are published as their commits are synced or reviewed, so the nearest one +# is nearly always the newest: one or two calls in practice, MAX_PAGES (50, 5,000 +# artifacts) at worst, against GITHUB_TOKEN's 1,000 requests an hour per +# repository, and only on a run that would otherwise analyze from scratch. The +# provenance rule is fetch-state.sh's: only artifacts from a run on this +# repository's own code, since a fork's workflow can upload under any name and +# its bytes would reach a pickle loader. +trusted='.artifacts[]? + | select(.expired == false) + | select(.workflow_run != null) + | select(.workflow_run.head_repository_id == .workflow_run.repository_id) + | select(.name | startswith("codeboarding-base-")) + | .name' +prefix="codeboarding-base-$CFG_HASH-" +saved="" page=1 +while [ "$page" -le "${MAX_PAGES:-50}" ]; do + if ! listing="$(api "repos/$REPOSITORY/actions/artifacts?per_page=100&page=$page" 2>/dev/null)"; then + echo "::warning::Could not list artifacts in $REPOSITORY; not looking for an older saved analysis." >&2 + exit 0 + fi + names="$(jq -r "$trusted" <<< "$listing" 2>/dev/null || true)" + saved="$saved$(grep "^$prefix" <<< "$names" | cut -c$(( ${#prefix} + 1 ))- || true)"$'\n' + if grep "^codeboarding-base-.*-$TIP_SHA\$" <<< "$names" | grep -vq "^$prefix"; then + other_cfg=true + fi + ancestor="$(nearest)" + [ -z "$ancestor" ] || break + returned="$(jq -r '.artifacts | length' <<< "$listing" 2>/dev/null || echo 0)" + [ "${returned:-0}" -eq 100 ] || break + page=$(( page + 1 )) +done + +[ -n "$ancestor" ] || exit 0 + +# Downloaded by its exact name, through the same checks as every other lookup. +if ! GITHUB_OUTPUT="" ARTIFACT_NAME="$prefix$ancestor" DEST="$DEST" \ + "$(dirname "$0")/fetch-state.sh" >&2 || [ ! -f "$DEST/analysis.json" ]; then + ancestor="" +fi diff --git a/tests/test_action_state.py b/tests/test_action_state.py index de351fd..70d07ed 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -654,6 +654,297 @@ def test_analysis_is_staged_for_publication(self) -> None: self.assertFalse((self.stage_dir / "base").exists(), "a published base needs no republishing") +GH_STUB = """#!/usr/bin/env python3 +\"\"\"gh stand-in: serves an artifact listing and one zip from a JSON config.\"\"\" +import json, os, sys +from urllib.parse import parse_qs, urlparse + +config = json.load(open(os.environ["CB_GH_CONFIG"])) +with open(os.environ["CB_GH_LOG"], "a") as log: + log.write(" ".join(sys.argv[1:]) + "\\n") +path = next(a for a in sys.argv[1:] if a.startswith("repos/")) +url = urlparse(path) +query = parse_qs(url.query) +if url.path.endswith("/zip"): + sys.stdout.buffer.write(open(config["zip"], "rb").read()) +elif url.path.endswith("/actions/artifacts"): + artifacts = config["artifacts"] + if "name" in query: + artifacts = [a for a in artifacts if a["name"] == query["name"][0]] + page = int(query.get("page", ["1"])[0]) + if "name" not in query and "pages" in config: + pages = config["pages"] + print(json.dumps({"artifacts": pages[page - 1] if page <= len(pages) else []})) + else: + print(json.dumps({"artifacts": artifacts if page == 1 else []})) +""" + + +class AncestorSeedTests(unittest.TestCase): + """With no saved base for the merge base and nothing committed there, a review + catches up from the nearest saved ancestor on the base branch's first-parent + history instead of analyzing the merge base from scratch.""" + + def setUp(self) -> None: + import zipfile + + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + self.bin_dir = self.root / "bin" + self.bin_dir.mkdir() + for name, body in (("codeboarding", ENGINE_STUB), ("gh", GH_STUB)): + (self.bin_dir / name).write_text(body, encoding="utf-8") + (self.bin_dir / name).chmod(0o755) + self.engine_log = self.root / "engine.log" + self.engine_log.write_text("", encoding="utf-8") + self.gh_log = self.root / "gh.log" + self.gh_log.write_text("", encoding="utf-8") + self.gh_config = self.root / "gh.json" + self.output = self.root / "github-output" + self.runner_temp = self.root / "runner" + self.runner_temp.mkdir() + self.stage_dir = self.root / "state" / "out" + self.origin = self.root / "origin" + self.origin.mkdir() + self.bundle = self.root / "bundle.zip" + with zipfile.ZipFile(self.bundle, "w") as archive: + archive.writestr("analysis.json", json.dumps({"metadata": {"depth_cap": 2}, "components": []})) + archive.writestr("static_analysis.pkl", "pickle") + archive.writestr("metadata.json", json.dumps({"kind": "base", "merge_base_sha": "ancestor"})) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def _history(self, commits: int) -> list[str]: + """A base branch of `commits` code commits, oldest first, in origin/ (the checkout).""" + git = ["git", "-C", str(self.origin), "-c", "user.name=T", "-c", "user.email=t@example.com"] + subprocess.run([*git, "init", "-q"], check=True) + shas = [] + for index in range(commits): + (self.origin / f"file{index}.py").write_text("pass\n", encoding="utf-8") + subprocess.run([*git, "add", "-A"], check=True) + subprocess.run([*git, "-c", "commit.gpgsign=false", "commit", "-q", "-m", f"c{index}"], check=True) + shas.append(subprocess.check_output([*git, "rev-parse", "HEAD"], text=True).strip()) + return shas + + @staticmethod + def _artifact(name: str, *, fork: bool = False) -> dict: + return { + "id": abs(hash(name)) % 100000, + "name": name, + "expired": False, + "created_at": "2026-10-01T00:00:00Z", + "expires_at": "2027-01-01T00:00:00Z", + "workflow_run": {"id": 1, "repository_id": 1, "head_repository_id": 2 if fork else 1}, + } + + def _serve(self, artifacts: list[dict], pages: list | None = None) -> None: + config = {"artifacts": artifacts, "zip": str(self.bundle)} + if pages is not None: + config["pages"] = pages + self.gh_config.write_text(json.dumps(config), encoding="utf-8") + + def _analyze(self, checkout: Path, **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_temp), + "CB_ENGINE_LOG": str(self.engine_log), + "CB_GH_CONFIG": str(self.gh_config), + "CB_GH_LOG": str(self.gh_log), + "ACTION_PATH": str(ROOT), + "ANALYSIS_KIND": "review", + "CHECKOUT_DIR": str(checkout), + "REVIEW_HEAD_SHA": "head-sha", + "REVIEW_BASE_REPO": "origin", + "REPOSITORY": "owner/repo", + "GITHUB_SERVER_URL": f"file://{self.root}", + "PR_NUMBER": "42", + "ENGINE_VERSION": "0.14.5", + "CFG_HASH": "cfg", + "ANCESTOR_LOOKUP": "true", + "BASE_DIR": str(self.root / "state" / "base"), + "WARMSTART_DIR": str(self.root / "state" / "warmstart"), + "STAGE_DIR": str(self.stage_dir), + "DEPTH_CAP": "2", + **extra, + }, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + values: dict[str, str] = {} + for line in self.output.read_text(encoding="utf-8").splitlines(): + key, _, value = line.partition("=") + values[key] = value + return values + + def _modes(self) -> list[str]: + return [json.loads(line)["mode"] for line in self.engine_log.read_text().splitlines()] + + def _assert_full(self, values: dict[str, str], reason: str = "no usable analysis was available") -> None: + self.assertEqual(values["base_analysis_method"], "full") + self.assertEqual(values["base_analysis_reason"], reason) + + def test_it_catches_up_from_the_nearest_saved_ancestor(self) -> None: + shas = self._history(5) + merge_base = shas[-1] + # Two saved ancestors: the nearer one wins. + self._serve( + [self._artifact(f"codeboarding-base-cfg-{shas[0]}"), self._artifact(f"codeboarding-base-cfg-{shas[2]}")] + ) + + values = self._analyze(self.origin, REVIEW_BASE_SHA=merge_base) + + self.assertEqual(values["base_analysis_method"], "incremental") + self.assertEqual( + values["base_analysis_reason"], + f"updated the analysis of {shas[2][:7]} to {merge_base[:7]}, 2 commits caught up", + ) + self.assertEqual(self._modes(), ["incremental", "incremental"]) + # Published under the merge base's own name, so the next review hits it exactly. + self.assertEqual(values["publish_base"], "true") + staged = json.loads((self.stage_dir / "base" / "metadata.json").read_text()) + self.assertEqual(staged["merge_base_sha"], merge_base) + + def test_an_ancestor_beyond_the_bound_is_not_used(self) -> None: + shas = self._history(5) + self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) + + values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1], CATCHUP_BOUND="2") + + self._assert_full(values) + self.assertEqual(self._modes(), ["full", "incremental"]) + self.assertNotIn("/zip", self.gh_log.read_text()) + + def test_a_saved_analysis_off_this_history_is_not_used(self) -> None: + shas = self._history(2) + self._serve([self._artifact("codeboarding-base-cfg-" + "e" * 40)]) + + values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1]) + + self._assert_full(values) + + def test_a_busy_artifact_store_is_paged_until_a_saved_ancestor_appears(self) -> None: + shas = self._history(3) + noise = [self._artifact(f"codeboarding-review-{i}-1") for i in range(100)] + found = self._artifact(f"codeboarding-base-cfg-{shas[0]}") + later = [self._artifact(f"codeboarding-warmstart-cfg-pr{i}") for i in range(100)] + self._serve([found], pages=[noise] * 11 + [noise[:99] + [found], later]) + + values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1]) + + self.assertEqual(values["base_analysis_method"], "incremental") + self.assertIn(f"updated the analysis of {shas[0][:7]} ", values["base_analysis_reason"]) + listings = [ + line + for line in self.gh_log.read_text().splitlines() + if "per_page=100&page=" in line and "name=" not in line + ] + self.assertEqual(len(listings), 12, "paging stops at the page holding the ancestor") + + def test_a_failed_deepen_falls_back_to_a_full_analysis(self) -> None: + # The deepen fails, so the shallow checkout walks no ancestor at all. + shas = self._history(4) + bare = self.root / "origin.git" + subprocess.run(["git", "clone", "-q", "--bare", str(self.origin), str(bare)], check=True) + checkout = self.root / "shallow" + subprocess.run(["git", "clone", "-q", "--depth=1", f"file://{bare}", str(checkout)], check=True) + self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) + + values = self._analyze(checkout, REVIEW_BASE_SHA=shas[-1], GITHUB_SERVER_URL=f"file://{self.root}/missing") + + self._assert_full(values) + + def test_the_merge_bases_own_configuration_survives_the_seed(self) -> None: + shas = self._history(2) + (self.origin / ".codeboarding").mkdir() + (self.origin / ".codeboarding" / ".codeboardingignore").write_text("docs/\n", encoding="utf-8") + git = ["git", "-C", str(self.origin), "-c", "user.name=T", "-c", "user.email=t@example.com"] + subprocess.run([*git, "add", "-A"], check=True) + subprocess.run([*git, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "ignore docs"], check=True) + merge_base = subprocess.check_output([*git, "rev-parse", "HEAD"], text=True).strip() + import zipfile + + with zipfile.ZipFile(self.bundle, "a") as archive: + archive.writestr(".codeboardingignore", "stale/\n") + archive.writestr("health/health_config.json", "{}") + self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) + + values = self._analyze(self.origin, REVIEW_BASE_SHA=merge_base) + + self.assertEqual(values["base_analysis_method"], "incremental") + staged = self.stage_dir / "base" + self.assertEqual((staged / ".codeboardingignore").read_text(), "docs/\n") + self.assertFalse((staged / "health" / "health_config.json").exists(), "the merge base has none") + + def test_an_ancestor_saved_by_a_run_on_forked_code_is_never_read(self) -> None: + shas = self._history(3) + self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}", fork=True)]) + + values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1]) + + self._assert_full(values) + self.assertNotIn("/zip", self.gh_log.read_text(), "a fork's bundle was downloaded") + + def test_another_configuration_saved_at_the_merge_base_is_incompatible(self) -> None: + shas = self._history(2) + self._serve([self._artifact(f"codeboarding-base-othercfg-{shas[-1]}")]) + + values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1]) + + self._assert_full(values, "the existing analysis was incompatible or could not be updated incrementally") + + def test_a_shallow_checkout_is_deepened_to_find_the_ancestor(self) -> None: + shas = self._history(4) + bare = self.root / "origin.git" + subprocess.run(["git", "clone", "-q", "--bare", str(self.origin), str(bare)], check=True) + subprocess.run(["git", "-C", str(bare), "config", "uploadpack.allowAnySHA1InWant", "true"], check=True) + checkout = self.root / "shallow" + subprocess.run(["git", "clone", "-q", "--depth=1", f"file://{bare}", str(checkout)], check=True) + self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) + + values = self._analyze(checkout, REVIEW_BASE_SHA=shas[-1]) + + self.assertEqual( + values["base_analysis_reason"], + f"updated the analysis of {shas[0][:7]} to {shas[-1][:7]}, 3 commits caught up", + ) + + def test_without_the_lookup_nothing_is_listed(self) -> None: + # GHES has no artifact store, so the action turns the lookup off there. + shas = self._history(2) + self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) + + values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1], ANCESTOR_LOOKUP="false") + + self.assertEqual(values["base_analysis_method"], "full") + self.assertEqual(self.gh_log.read_text(), "") + + def test_a_first_sync_catches_up_from_the_setup_reviews_base(self) -> None: + # Merging the setup pull request leaves no committed baseline, but its + # preview review saved the base at its merge base, the new tip's parent. + shas = self._history(3) + self._serve([self._artifact(f"codeboarding-base-cfg-{shas[1]}")]) + + self._analyze(self.origin, ANALYSIS_KIND="sync", FORCE_FULL="false") + + self.assertEqual(self._modes(), ["incremental"]) + + def test_a_forced_sync_never_seeds(self) -> None: + shas = self._history(2) + self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) + + self._analyze(self.origin, ANALYSIS_KIND="sync", FORCE_FULL="True") + + self.assertEqual(self._modes(), ["full"]) + self.assertEqual(self.gh_log.read_text(), "") + + class ReviewArtifactTests(unittest.TestCase): """The artifact is the only channel a reader outside the run can use: cache entries have no download API, so whatever the webview needs must ship here.""" From 7a65300301d850cd297c767bc279005973c0d001 Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Fri, 9 Oct 2026 23:11:58 +0200 Subject: [PATCH 05/19] feat: save the diagram to an analysis branch of its own sync_strategy: branch saves the analysis to an orphan branch in the same repository, codeboarding/analysis unless analysis_branch names another, one fast-forward commit per sync, and never writes to the target branch. Each commit carries CodeBoarding-Source and CodeBoarding-Config trailers, also recorded in .codeboarding/source.json. Reviews read their base from the branch after an exact artifact and a baseline committed at the merge base: an entry for the merge base is reused, an entry for an ancestor is caught up. Only entries made under this run's configuration count, so a different engine version or model is never reused as is. Sync continues from the tip under the same rule, replacing the generated state wholesale and keeping only the checkout's user config. Delivery refuses an existing branch that is not an analysis branch (no trailer, or anything besides .codeboarding/), builds on the tip it fetched rather than the one ls-remote saw, and the guard rejects an analysis_branch git cannot use before anything runs. The importable ruleset now restricts creating and updating the branch to its bypass actor, since sync and review load a pickle from it. target_branch's description now says it is the code branch sync analyzes, written only by push and pull_request. The README carries a prompt for moving an existing setup. Co-Authored-By: Claude Opus 5.5 --- README.md | 40 ++- action.yml | 16 +- docs/COMMIT_STRATEGY.md | 67 +++++ docs/analysis-branch-ruleset.json | 24 ++ scripts/action/analyze.sh | 104 ++++++- scripts/action/deliver-sync.sh | 77 ++++++ scripts/action/guard.sh | 15 +- tests/test_analysis_branch.py | 445 ++++++++++++++++++++++++++++++ 8 files changed, 781 insertions(+), 7 deletions(-) create mode 100644 docs/analysis-branch-ruleset.json create mode 100644 tests/test_analysis_branch.py diff --git a/README.md b/README.md index 922b666..80f6820 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,41 @@ 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. +### Save the diagram to a branch of its own + +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 +``` + +Here `target_branch` is the code branch sync analyzes, and it is only read. Each sync adds one commit to `codeboarding/analysis` in the same repository (set `analysis_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 and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. 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 [analysis branch section](docs/COMMIT_STRATEGY.md#the-analysis-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. + +**Moving an existing setup.** Nothing changes until you opt in: `push` and `pull_request` keep working as before. To switch, paste this into your coding agent: + +```text +Move this repository's CodeBoarding sync to sync_strategy: branch. +1. In the workflow that runs CodeBoarding/CodeBoarding-action with mode: sync, set + `sync_strategy: branch` in its `with:` block (replace push or pull_request). + Keep every other input. +2. Delete the generated files under .codeboarding/ from the default branch, keeping + the user configuration: .codeboarding/.codeboardingignore, + .codeboarding/health/health_config.json and .codeboarding/health/.healthignore. + Reviews prefer a baseline committed on the branch, so a stale one left there + would keep being used. +3. Remove any .gitattributes lines that mark .codeboarding/ files as + linguist-generated, if nothing else is left under .codeboarding/ for them. +4. Open a pull request with these changes. After it merges, close any open + pull request from the codeboarding/sync branch and delete that branch. +``` + +The first sync after the merge creates `codeboarding/analysis`, catching up from a saved analysis when there is one. + ## Inputs | Input | Mode | Default | Description | @@ -316,8 +351,9 @@ 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`. | -| `target_branch` | sync | event branch | Branch receiving the baseline or rolling PR. | +| `sync_strategy` | sync | `push` | Where sync saves the analysis: `push` (a commit on `target_branch`), `pull_request` (a rolling PR into it), or `branch` (commits on `analysis_branch`). | +| `analysis_branch` | both | `codeboarding/analysis` | Branch in this repository that `sync_strategy: branch` saves the analysis to; reviews read their base from it when it exists. | +| `target_branch` | sync | event branch | Code branch sync analyzes. With `push` or `pull_request` it also receives the analysis commit or rolling PR; with `branch` it is only read. | | `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 3dbd9a6..9401b62 100644 --- a/action.yml +++ b/action.yml @@ -143,11 +143,15 @@ inputs: required: false default: ${{ github.token }} sync_strategy: - description: 'Sync delivery method: push or pull_request.' + description: 'Where sync saves the analysis: push (a commit on target_branch), pull_request (a rolling PR into target_branch), or branch (commits on analysis_branch; target_branch is never written).' required: false default: 'push' + analysis_branch: + description: 'Branch in this repository that sync_strategy branch saves the analysis to, one commit per sync. Reviews read their base analysis from it when it exists.' + required: false + default: 'codeboarding/analysis' target_branch: - description: 'Branch updated by sync mode. Defaults to the event branch.' + description: 'Code branch sync mode analyzes. With sync_strategy push or pull_request the analysis is also committed to it; with branch it is only read. Defaults to the event branch.' required: false default: '' force_full: @@ -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 }} + ANALYSIS_BRANCH: ${{ inputs.analysis_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 }} + ANALYSIS_BRANCH: ${{ inputs.analysis_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,10 @@ runs: TARGET_BRANCH: ${{ steps.guard.outputs.target_branch }} SYNC_BRANCH_START_SHA: ${{ steps.guard.outputs.sync_branch_start_sha }} SYNC_STRATEGY: ${{ inputs.sync_strategy }} + ANALYSIS_BRANCH: ${{ inputs.analysis_branch }} + ENGINE_VERSION: ${{ steps.state.outputs.engine_version }} + # Recorded with each analysis-branch commit, so a reader can tell which configuration made it. + CFG_HASH: ${{ steps.state.outputs.cfg_hash }} GITHUB_TOKEN: ${{ inputs.github_token }} GH_TOKEN: ${{ inputs.github_token }} GH_ENTERPRISE_TOKEN: ${{ inputs.github_token }} @@ -550,6 +561,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 }} + ANALYSIS_BRANCH: ${{ inputs.analysis_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 52407b2..d028050 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -107,6 +107,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 analysis branch (`sync_strategy: branch`): its commit for the merge base, else for the nearest of the merge base's last 100 first-parent ancestors, made under this configuration | none for the merge base itself (`reused`), one incremental otherwise (`incremental`) | | 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` | @@ -145,6 +146,72 @@ 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 analysis branch + +`sync_strategy: branch` saves the analysis to a branch of its own in the same +repository, `codeboarding/analysis` unless `analysis_branch` names another. +`target_branch` is then the code branch sync analyzes; it is never written. + +**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": "", "config": ""} +``` + +The commit is `chore(codeboarding): diagram of main @` with two trailers: +`CodeBoarding-Source: ` and `CodeBoarding-Config: `, the same +configuration hash that names the base artifacts (engine version, provider, model, +depth cap). Engine output is never edited; which commit it describes and how it was +made live only in `source.json` and the trailers. 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 when the tip was made under +this configuration, and runs incrementally. The generated files are replaced +wholesale; only the checkout's own `.codeboardingignore` and health configuration +are kept. The push is a fast-forward onto the tip it fetched, never forced. Sync +refuses to write to an existing branch that is not an analysis branch (its tip has +no `CodeBoarding-Source` trailer, or holds anything besides `.codeboarding/`), so +pointing `analysis_branch` at a code branch fails instead of emptying it. If the +target branch moved during the analysis, the result is dropped, as with `push`. If +another sync moved the analysis 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. + +The target branch is checked just before the push, not in the same transaction: +if it moves in that window, the branch can end on an analysis of the older commit. +Its trailer still names that commit, so no reader takes it for newer, and the run +queued for the newer commit replaces it. + +**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. +Only entries made under this run's configuration count: an entry for the merge +base itself is reused as is, so nothing else would catch a different engine or +model. An entry for an ancestor is caught up incrementally. Entries found only +under another configuration make a full run's reason `incompatible`. 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.** Sync and review load `static_analysis.pkl` from this branch, and +a pickle runs code when loaded, so whoever can write the branch can run code in +the sync and review workflows. Import +[`analysis-branch-ruleset.json`](analysis-branch-ruleset.json) under Settings, +Rules, Rulesets, New ruleset, Import a ruleset. It blocks creating, updating, +deleting and force-pushing `codeboarding/analysis` for everyone except its bypass +actor, GitHub Actions (integration `15368`), which is what the default +`github.token` pushes as. If sync pushes with a GitHub App token instead, such as +the CodeBoarding Review app (`4021464`), make that app the only bypass actor: +any workflow can use `github.token`, while only the workflows you give the app's +key can push as the app. 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/analysis-branch-ruleset.json b/docs/analysis-branch-ruleset.json new file mode 100644 index 0000000..a4a81ac --- /dev/null +++ b/docs/analysis-branch-ruleset.json @@ -0,0 +1,24 @@ +{ + "name": "CodeBoarding analysis branch", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/codeboarding/analysis"], + "exclude": [] + } + }, + "rules": [ + { "type": "creation" }, + { "type": "update", "parameters": { "update_allows_fetch_and_merge": false } }, + { "type": "deletion" }, + { "type": "non_fast_forward" } + ], + "bypass_actors": [ + { + "actor_id": 15368, + "actor_type": "Integration", + "bypass_mode": "always" + } + ] +} diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index 25a9b5e..c9d5e2e 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -126,6 +126,20 @@ analyze_sync() { REQUIRES_FULL=true if [ "$(printf '%s' "${FORCE_FULL:-false}" | tr '[:upper:]' '[:lower:]')" != true ]; then + # The analysis branch's tip is this branch's last analysis. Without a usable + # 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="$(analysis_branch_index "${REPOSITORY:-}" | awk '{print $1, $3; exit}')" + if [ -z "$tip_entry" ]; then + echo "::notice::$ANALYSIS_BRANCH has no analysis to continue from; this sync creates it." + elif ! usable_config "${tip_entry#* }"; then + echo "::notice::The analysis on $ANALYSIS_BRANCH was made with another engine version or settings; not continuing from it." + elif ! restore_analysis_branch "${REPOSITORY:-}" "${tip_entry%% *}" "$state" "$CHECKOUT_DIR"; then + echo "::notice::Could not read the analysis on $ANALYSIS_BRANCH; not continuing from it." + fi + fi if [ "$(depth_cap_from "$state/analysis.json")" = "$DEPTH_CAP" ]; then incremental "$CHECKOUT_DIR" "$state" fi @@ -260,6 +274,73 @@ keep_user_config() { done } +# sync_strategy: branch keeps one commit per sync on ANALYSIS_BRANCH, each with +# trailers naming the commit it analysed (CodeBoarding-Source) and the +# configuration it ran under (CodeBoarding-Config). Lists them as +# " ", newest first, at most +# ANALYSIS_BRANCH_DEPTH of them; an entry without a config has none to compare. +# Fetched without blobs into a scratch repository: the lookup needs messages, and +# a hundred pickles would cost more than it saves. +ANALYSIS_BRANCH_DEPTH="${ANALYSIS_BRANCH_DEPTH:-100}" +analysis_branch_index() { + local repository="$1" scratch="$RUNNER_TEMP/codeboarding-analysis-index.git" auth + [ -n "${ANALYSIS_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="$ANALYSIS_BRANCH_DEPTH" "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$ANALYSIS_BRANCH" 2>/dev/null || + return 0 + git -C "$scratch" log --format='%H %(trailers:key=CodeBoarding-Source,valueonly,separator=%x20) %(trailers:key=CodeBoarding-Config,valueonly,separator=%x20)' FETCH_HEAD | + awk 'NF >= 2 {print $1, $2, (NF >= 3 ? $3 : "-")}' +} +# An entry is only as good as the configuration it ran under: the artifact name +# pins it for saved analyses, the trailer does here. Without a configuration hash +# this run cannot tell, so it uses none, as it reuses no artifact either. +usable_config() { + [ -n "${CFG_HASH:-}" ] && [ "$1" = "$CFG_HASH" ] +} +# Replaces the generated state in $3 with an analysis-branch commit's, keeping the +# user configuration of checkout $4. source.json is provenance, not engine state. +restore_analysis_branch() { + local repository="$1" commit="$2" state="$3" config_from="$4" scratch="$RUNNER_TEMP/codeboarding-analysis-restore" + fetch_commit "$repository" "$commit" || return 1 + rm -rf "$scratch" + mkdir -p "$scratch" + git -C "$CHECKOUT_DIR" archive "$commit" .codeboarding | tar -x -C "$scratch" || return 1 + [ -f "$scratch/.codeboarding/analysis.json" ] || return 1 + rm -f "$scratch/.codeboarding/source.json" + rm -rf "$state" + cp -a "$scratch/.codeboarding" "$state" + keep_user_config "$config_from" "$state" +} +# Seeds $3 from the analysis branch's entry for $2, or for its nearest first-parent +# ancestor that has one under this configuration, keeping checkout $4's user +# configuration. Sets BRANCH_SOURCE to the commit it describes and BRANCH_DISTANCE +# to how far below $2 that is; BRANCH_REASON=incompatible when the only entries +# found were made under another configuration. +BRANCH_SOURCE="" BRANCH_DISTANCE="" BRANCH_REASON="" +seed_from_analysis_branch() { + local repository="$1" tip="$2" state="$3" config_from="$4" index commit line entry="" distance=0 + BRANCH_SOURCE="" BRANCH_DISTANCE="" BRANCH_REASON="" + index="$(analysis_branch_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 + while read -r line; do + if usable_config "${line#* }"; then + entry="${line%% *}" + break + fi + BRANCH_REASON=incompatible + done < <(awk -v source="$commit" '$2 == source {print $1, $3}' <<< "$index") + [ -z "$entry" ] || break + distance=$(( distance + 1 )) + done + [ -n "$entry" ] && restore_analysis_branch "$repository" "$entry" "$state" "$config_from" || return 1 + BRANCH_SOURCE="$commit" BRANCH_DISTANCE="$distance" BRANCH_REASON="" +} + # 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="" @@ -342,6 +423,26 @@ analyze_review() { elif [ -f "$base_state/analysis.json" ]; then full_cause=incompatible fi + # The analysis 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_analysis_branch "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$base_state" "$base_checkout"; then + if [ "$(depth_cap_from "$base_state/analysis.json")" != "$DEPTH_CAP" ]; then + full_cause=incompatible + elif [ "$BRANCH_DISTANCE" -eq 0 ]; then + REQUIRES_FULL=false base_method=reused + else + incremental "$base_checkout" "$base_state" + if [ "$REQUIRES_FULL" = true ]; then + full_cause=incompatible + else + base_method=incremental base_from_sha="$BRANCH_SOURCE" + catchup_commits="$(catchup_count "$BRANCH_SOURCE" "$REVIEW_BASE_SHA")" + fi + fi + elif [ "$BRANCH_REASON" = incompatible ]; then + full_cause=incompatible + 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 @@ -400,7 +501,8 @@ 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_published" != true ] || [ "${RENEW_BASE:-false}" = true ]; then stage "$base_state" base diff --git a/scripts/action/deliver-sync.sh b/scripts/action/deliver-sync.sh index bbc454c..d503416 100755 --- a/scripts/action/deliver-sync.sh +++ b/scripts/action/deliver-sync.sh @@ -63,6 +63,83 @@ 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_analysis_branch() { + local branch="$ANALYSIS_BRANCH" tree="$RUNNER_TEMP/codeboarding-analysis-tree" + local index="$RUNNER_TEMP/codeboarding-analysis-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, and under which + # configuration, lives here and in the commit's trailers 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", ""), + "config": os.environ.get("CFG_HASH", ""), +}, 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' + local trailers=(-m "CodeBoarding-Source: $BASE_SHA") + [ -z "${CFG_HASH:-}" ] || trailers=(-m "CodeBoarding-Source: $BASE_SHA +CodeBoarding-Config: $CFG_HASH") + + # 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="" parent=() + if [ -n "$(git ls-remote "$REMOTE" "refs/heads/$branch")" ]; then + git fetch -q --depth=1 "$REMOTE" "refs/heads/$branch" + # The parent is what was fetched, not what ls-remote saw: the branch may move in between. + tip="$(git rev-parse FETCH_HEAD)" + # Building on any other branch would leave it holding nothing but .codeboarding/. + if [ -z "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" ] || + [ "$(git ls-tree --name-only "$tip")" != .codeboarding ]; then + echo "::error::$branch already exists and is not a CodeBoarding analysis branch, so sync will not write to it. Set analysis_branch to a branch name that is not in use." + exit 1 + fi + 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}" "${trailers[@]}")" + # 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 "analysis_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 identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) 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_analysis_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..b469f0e 100755 --- a/scripts/action/guard.sh +++ b/scripts/action/guard.sh @@ -9,6 +9,11 @@ case "$MODE" in *) fail "mode must be review or sync." ;; esac printf 'mode=%s\nskip=false\nevent=%s\n' "$MODE" "$EVENT" >> "$GITHUB_OUTPUT" +# Both modes read the analysis branch, so a name git cannot use fails here, before +# anything is analyzed, rather than reading as a missing branch and costing a full run. +if [ -n "${ANALYSIS_BRANCH:-}" ] && ! git check-ref-format "refs/heads/$ANALYSIS_BRANCH"; then + fail "analysis_branch '$ANALYSIS_BRANCH' is not a valid branch name." +fi if [ "$MODE" = sync ]; then case "$EVENT" in push|workflow_dispatch|schedule) ;; @@ -16,8 +21,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 +32,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 "${ANALYSIS_BRANCH:-}" ] || fail "analysis_branch is required with sync_strategy: branch." + # The analysis branch holds only analysis; a workflow that also fires on it must not analyze it. + [ "$REF_NAME" != "$ANALYSIS_BRANCH" ] || skip "Ignoring a push to the analysis branch $ANALYSIS_BRANCH." + [ "$target_branch" != "$ANALYSIS_BRANCH" ] || fail "target_branch must differ from analysis_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_analysis_branch.py b/tests/test_analysis_branch.py new file mode 100644 index 0000000..92a912f --- /dev/null +++ b/tests/test_analysis_branch.py @@ -0,0 +1,445 @@ +"""sync_strategy: branch saves the analysis to 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/analysis" + + +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 AnalysisBranchDeliveryTests(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", + "ANALYSIS_BRANCH": BRANCH, + "ENGINE_VERSION": "0.14.5", + "CFG_HASH": "cfg", + }, + 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["analysis_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.assertEqual(source["config"], "cfg") + 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\n" + f"CodeBoarding-Source: {main_before}\nCodeBoarding-Config: cfg", + ) + # 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["analysis_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_an_existing_branch_that_is_not_an_analysis_branch_is_never_written(self) -> None: + # analysis_branch pointed at a code branch: building on it would leave it + # holding nothing but .codeboarding/. + git(self.checkout, "push", "-q", "origin", f"main:refs/heads/{BRANCH}") + before = git(self.remote, "rev-parse", BRANCH) + + result, values = self._deliver(expect_ok=False) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"{BRANCH} already exists and is not a CodeBoarding analysis branch", result.stdout) + self.assertEqual(git(self.remote, "rev-parse", BRANCH), before) + self.assertNotIn("analysis_branch_sha", values) + + 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 AnalysisBranchReadTests(unittest.TestCase): + """Reviews and sync read the branch: commits c0..c4 on main, branch entries for c1 and c3 + made under configuration `cfg`.""" + + 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}\nCodeBoarding-Config: cfg", + ) + 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", + "ANALYSIS_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_reused(self) -> None: + values = self._analyze(REVIEW_BASE_SHA=self.shas[3]) + + self.assertEqual(values["base_analysis_method"], "reused") + self.assertEqual(values["base_analysis_reason"], f"{self.shas[3][:7]} already has a saved analysis") + 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_analysis_method"], "incremental") + self.assertEqual( + values["base_analysis_reason"], + f"updated the analysis of {self.shas[3][:7]} to {self.shas[4][:7]}, 1 commit caught up", + ) + self.assertEqual(self._modes(), ["incremental", "incremental"]) + + def test_an_entry_made_under_another_configuration_is_never_reused(self) -> None: + # Another engine version or model: reusing it as is would diff an old + # configuration's base against a new head. + values = self._analyze(REVIEW_BASE_SHA=self.shas[3], CFG_HASH="othercfg") + + self.assertEqual(values["base_analysis_method"], "full") + self.assertEqual( + values["base_analysis_reason"], + "the existing analysis was incompatible or could not be updated incrementally", + ) + self.assertEqual(self._modes(), ["full", "incremental"]) + + def test_without_a_configuration_hash_no_entry_is_trusted(self) -> None: + values = self._analyze(REVIEW_BASE_SHA=self.shas[3], CFG_HASH="") + + self.assertEqual(values["base_analysis_method"], "full") + + def test_without_the_branch_the_base_is_a_full_analysis(self) -> None: + values = self._analyze(REVIEW_BASE_SHA=self.shas[4], ANALYSIS_BRANCH="codeboarding/none") + + self.assertEqual(values["base_analysis_method"], "full") + self.assertEqual(values["base_analysis_reason"], "no usable analysis was available") + 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_replaces_the_committed_state_with_the_branch_tip(self) -> None: + # Switching from push: the checkout still holds the old committed baseline. + # Only its user configuration may survive; generated files come from the tip. + board = self.checkout / ".codeboarding" + board.mkdir() + (board / "analysis.json").write_text(json.dumps({"metadata": {"depth_cap": 2}, "old": True})) + (board / "static_analysis.pkl").write_text("old pickle") + (board / "static_analysis.sha").write_text("stale\n") + (board / ".codeboardingignore").write_text("docs/\n") + + values = self._analyze(ANALYSIS_KIND="sync", SYNC_STRATEGY="branch", FORCE_FULL="false") + + state = Path(values["analysis_dir"]) + self.assertEqual((state / "static_analysis.pkl").read_text(), "pickle", "the tip's engine state") + self.assertFalse((state / "static_analysis.sha").exists(), "a generated file from the old baseline") + self.assertFalse((state / "source.json").exists()) + self.assertEqual((state / ".codeboardingignore").read_text(), "docs/\n") + + def test_sync_does_not_continue_from_a_tip_made_under_another_configuration(self) -> None: + self._analyze(ANALYSIS_KIND="sync", SYNC_STRATEGY="branch", FORCE_FULL="false", CFG_HASH="othercfg") + + self.assertEqual(self._modes(), ["full"]) + + def test_sync_without_the_branch_analyzes_from_scratch(self) -> None: + self._analyze( + ANALYSIS_KIND="sync", SYNC_STRATEGY="branch", FORCE_FULL="false", ANALYSIS_BRANCH="codeboarding/none" + ) + + self.assertEqual(self._modes(), ["full"]) + + +class AnalysisBranchGuardTests(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", + "ANALYSIS_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_analysis_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) + + def test_a_branch_name_git_cannot_use_fails_before_anything_runs(self) -> None: + for mode in ("sync", "review"): + for name in ("codeboarding/a..b", "codeboarding/with space", "codeboarding/trailing."): + result, _values = self._guard(MODE=mode, ANALYSIS_BRANCH=name) + self.assertNotEqual(result.returncode, 0, (mode, name)) + self.assertIn("is not a valid branch name", result.stdout) + + def test_target_branch_must_differ_from_the_analysis_branch(self) -> None: + result, _values = self._guard(TARGET_BRANCH_INPUT=BRANCH) + self.assertNotEqual(result.returncode, 0) + self.assertIn("target_branch must differ from analysis_branch", result.stdout) + + +if __name__ == "__main__": + unittest.main() From 608b37dd482e8b4b595b2ee08f387b3a8b3f8eb4 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sat, 10 Oct 2026 02:56:49 +0200 Subject: [PATCH 06/19] feat: name sync inputs for what they do, and move base timings to telemetry target_branch becomes synced_branch, sync_strategy becomes save_baseline_to (synced_branch | pull_request | baseline_branch), and the unreleased analysis_branch becomes baseline_branch with default codeboarding/baseline. The released names keep working through old_api_migrator.sh, the only reader of deprecated inputs. The base_analysis_method output and the review comment's base line and timers are removed. Engine runs carry CODEBOARDING_RUN_ID tagged base, head or sync, so the engine's own telemetry shows how each base was obtained. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/codeboarding-sync.yml | 10 +- README.md | 117 +++++++++++++++--- action.yml | 75 ++++++----- docs/COMMIT_STRATEGY.md | 56 +++++---- ...eset.json => baseline-branch-ruleset.json} | 4 +- scripts/action/analyze.sh | 74 +++++------ scripts/action/build-review-artifact.sh | 5 +- scripts/action/build-review-comment.sh | 34 +---- scripts/action/deliver-sync.sh | 56 ++++----- scripts/action/fetch-state.sh | 4 - scripts/action/guard.sh | 34 ++--- scripts/action/old_api_migrator.sh | 32 +++++ scripts/action/sync-summary.sh | 2 +- tests/test_action_inputs.py | 8 ++ tests/test_action_state.py | 22 ++-- tests/test_action_sync.py | 4 +- ...ysis_branch.py => test_baseline_branch.py} | 69 ++++++----- tests/test_old_api_migrator.py | 77 ++++++++++++ tests/test_review_artifact_metadata.py | 8 +- tests/test_review_comment.py | 73 ----------- 20 files changed, 441 insertions(+), 323 deletions(-) rename docs/{analysis-branch-ruleset.json => baseline-branch-ruleset.json} (81%) create mode 100755 scripts/action/old_api_migrator.sh rename tests/{test_analysis_branch.py => test_baseline_branch.py} (88%) create mode 100644 tests/test_old_api_migrator.py diff --git a/.github/workflows/codeboarding-sync.yml b/.github/workflows/codeboarding-sync.yml index 2660515..c947eca 100644 --- a/.github/workflows/codeboarding-sync.yml +++ b/.github/workflows/codeboarding-sync.yml @@ -41,12 +41,12 @@ on: type: boolean required: false default: false - sync_strategy: - description: 'Deliver directly to the target branch or open/update a rolling baseline PR.' + save_baseline_to: + description: 'Commit to main, open/update a rolling baseline PR, or commit to codeboarding/baseline.' type: choice - options: [push, pull_request] + options: [synced_branch, pull_request, baseline_branch] required: false - default: push + default: synced_branch # No workflow-level permissions: the single job below requests only what it # needs (least privilege), so the default token starts with none. @@ -144,7 +144,7 @@ jobs: # CodeBoarding's own repositories run on the CodeBoarding plan. llm: license license_key: ${{ secrets.CODEBOARDING_LICENSE }} - sync_strategy: ${{ inputs.sync_strategy || 'push' }} + save_baseline_to: ${{ inputs.save_baseline_to || 'synced_branch' }} force_full: ${{ inputs.force_full || false }} # App token authenticates the baseline push so the commit is attributed # to the CodeBoarding App (logo avatar). Falls back to the workflow token, diff --git a/README.md b/README.md index 80f6820..2bea5c9 100644 --- a/README.md +++ b/README.md @@ -272,15 +272,17 @@ jobs: with: mode: sync llm: hosted - target_branch: main + synced_branch: main force_full: ${{ inputs.force_full || false }} ``` -The first run, `force_full: true`, or an incompatible baseline causes a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the target advances while analysis is running, the stale result is not rebased onto code it did not analyze; the newer push run is allowed to produce the current baseline. +`synced_branch` is the code branch sync keeps an up-to-date analysis of; it defaults to the branch that triggered the run. `save_baseline_to` chooses where that analysis is saved: `synced_branch` (the default, a commit on that branch), `pull_request` (a rolling PR into it) or `baseline_branch` (a branch of its own, see below). + +The first run, `force_full: true`, or an incompatible baseline causes a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the synced branch advances while analysis is running, the stale result is not rebased onto code it did not analyze; the newer push run is allowed to produce the current baseline. ### Protected branches -Set `sync_strategy: pull_request` and grant `pull-requests: write`: +Set `save_baseline_to: pull_request` and grant `pull-requests: write`: ```yaml permissions: @@ -293,36 +295,39 @@ permissions: with: mode: sync llm: hosted - target_branch: main - sync_strategy: pull_request + synced_branch: main + save_baseline_to: pull_request ``` -Generation is identical to direct push. Only delivery changes: the same commit is force-with-lease pushed to the machine-owned `codeboarding/sync` branch and one rolling PR is opened into `target_branch`. When there is no longer a generated diff, an obsolete rolling PR is closed. +Generation is identical to direct push. Only delivery changes: the same commit is force-with-lease pushed to the machine-owned `codeboarding/sync` branch and one rolling PR is opened into `synced_branch`. When there is no longer a generated diff, an obsolete rolling PR is closed. 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. ### Save the diagram to a branch of its own -Set `sync_strategy: branch` to keep the analysis off your code branches entirely: +Set `save_baseline_to: baseline_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 + synced_branch: main + save_baseline_to: baseline_branch ``` -Here `target_branch` is the code branch sync analyzes, and it is only read. Each sync adds one commit to `codeboarding/analysis` in the same repository (set `analysis_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 and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. 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 [analysis branch section](docs/COMMIT_STRATEGY.md#the-analysis-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. +`main` is then only read. Each sync adds one commit to `codeboarding/baseline` in the same repository (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 and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. 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 should import to protect it: sync and review load a pickle from it. + +No permission beyond the `contents: write` every sync already needs: the first sync creates the branch with an ordinary push. If you renamed it with `baseline_branch`, set the same name in your review workflow too, since reviews read it — or keep both jobs in one workflow, below. -**Moving an existing setup.** Nothing changes until you opt in: `push` and `pull_request` keep working as before. To switch, paste this into your coding agent: +**Moving an existing setup.** Nothing changes until you opt in: the default keeps committing to the synced branch. To switch, paste this into your coding agent: ```text -Move this repository's CodeBoarding sync to sync_strategy: branch. +Move this repository's CodeBoarding sync to save_baseline_to: baseline_branch. 1. In the workflow that runs CodeBoarding/CodeBoarding-action with mode: sync, set - `sync_strategy: branch` in its `with:` block (replace push or pull_request). - Keep every other input. + `save_baseline_to: baseline_branch` in its `with:` block, replacing any + sync_strategy. Rename target_branch to synced_branch if it is set. Keep every + other input. 2. Delete the generated files under .codeboarding/ from the default branch, keeping the user configuration: .codeboarding/.codeboardingignore, .codeboarding/health/health_config.json and .codeboarding/health/.healthignore. @@ -334,7 +339,81 @@ Move this repository's CodeBoarding sync to sync_strategy: branch. pull request from the codeboarding/sync branch and delete that branch. ``` -The first sync after the merge creates `codeboarding/analysis`, catching up from a saved analysis when there is one. +The first sync after the merge creates `codeboarding/baseline`, catching up from a saved analysis when there is one. + +### Review and sync in one workflow + +The two workflows above can be one file with two jobs. Each job keeps its own permissions, and settings both modes read live in one `env:` block, so review always looks for the baseline where sync saves it: + +```yaml +name: CodeBoarding + +on: + pull_request: + types: [opened, reopened, synchronize] + issue_comment: + types: [created] + push: + branches: [main] # the synced branch; `on:` cannot read env + workflow_dispatch: + inputs: + force_full: + description: Rebuild without the saved baseline + type: boolean + default: false + +env: + SYNCED_BRANCH: main + BASELINE_BRANCH: codeboarding/baseline + +permissions: {} + +# Reviews queue per pull request, syncs per branch. +concurrency: + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number || github.ref_name }} + cancel-in-progress: false + +jobs: + review: + if: > + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '/codeboarding') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + actions: read + pull-requests: write + issues: write + id-token: write + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + llm: hosted + baseline_branch: ${{ env.BASELINE_BRANCH }} + + sync: + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write + id-token: write + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + llm: hosted + synced_branch: ${{ env.SYNCED_BRANCH }} + save_baseline_to: baseline_branch + baseline_branch: ${{ env.BASELINE_BRANCH }} + force_full: ${{ inputs.force_full || false }} +``` + +With `save_baseline_to: baseline_branch` sync never commits to `main`, so the `push` trigger needs no `paths-ignore`. Existing two-file setups keep working unchanged; this is only a different way to call the same action. ## Inputs @@ -351,12 +430,14 @@ The first sync after the merge creates `codeboarding/analysis`, catching up from | `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` | Where sync saves the analysis: `push` (a commit on `target_branch`), `pull_request` (a rolling PR into it), or `branch` (commits on `analysis_branch`). | -| `analysis_branch` | both | `codeboarding/analysis` | Branch in this repository that `sync_strategy: branch` saves the analysis to; reviews read their base from it when it exists. | -| `target_branch` | sync | event branch | Code branch sync analyzes. With `push` or `pull_request` it also receives the analysis commit or rolling PR; with `branch` it is only read. | +| `synced_branch` | sync | event branch | Code branch sync keeps an up-to-date analysis of. | +| `save_baseline_to` | sync | `synced_branch` | Where sync saves the analysis: `synced_branch` (a commit on it), `pull_request` (a rolling PR into it), or `baseline_branch` (a commit on `baseline_branch`; the synced branch is never written). | +| `baseline_branch` | both | `codeboarding/baseline` | Branch `save_baseline_to: baseline_branch` saves the analysis to; reviews read their base from it when it exists. | | `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. | +`target_branch` and `sync_strategy` are deprecated names for `synced_branch` and `save_baseline_to` (`push` is `synced_branch`, `pull_request` is `pull_request`). They still work, with a warning; setting a value under both names fails. + The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, rolling sync branch, commit message, and CodeBoarding 0.14.5 version are intentionally fixed rather than exposed as configuration. Review mode needs no sync workflow or committed `.codeboarding` directory. If no diff --git a/action.yml b/action.yml index 9401b62..171c267 100644 --- a/action.yml +++ b/action.yml @@ -142,18 +142,18 @@ inputs: description: 'Token used for comments and sync delivery.' required: false default: ${{ github.token }} - sync_strategy: - description: 'Where sync saves the analysis: push (a commit on target_branch), pull_request (a rolling PR into target_branch), or branch (commits on analysis_branch; target_branch is never written).' + synced_branch: + description: 'Code branch that sync keeps an up-to-date analysis of. Defaults to the event branch.' required: false - default: 'push' - analysis_branch: - description: 'Branch in this repository that sync_strategy branch saves the analysis to, one commit per sync. Reviews read their base analysis from it when it exists.' + default: '' + save_baseline_to: + description: 'Where sync saves the analysis: synced_branch (a commit on synced_branch), pull_request (a rolling PR into synced_branch), or baseline_branch (a commit on baseline_branch; synced_branch is never written).' required: false - default: 'codeboarding/analysis' - target_branch: - description: 'Code branch sync mode analyzes. With sync_strategy push or pull_request the analysis is also committed to it; with branch it is only read. Defaults to the event branch.' + default: 'synced_branch' + baseline_branch: + description: 'Branch that save_baseline_to: baseline_branch stores the analysis on, one commit per sync. Reviews read their base analysis from it when it exists.' required: false - default: '' + default: 'codeboarding/baseline' force_full: description: 'Run a full sync analysis instead of reusing the committed baseline.' required: false @@ -162,6 +162,17 @@ inputs: description: 'Days to keep the reusable analysis. Only the next run reads it.' required: false default: '1' + # Deprecated inputs. scripts/action/old_api_migrator.sh is the only reader. + target_branch: + description: 'Deprecated: use synced_branch.' + deprecationMessage: 'target_branch is deprecated; use synced_branch.' + required: false + default: '' + sync_strategy: + description: 'Deprecated: use save_baseline_to (push is synced_branch, pull_request is pull_request).' + deprecationMessage: 'sync_strategy is deprecated; use save_baseline_to.' + required: false + default: '' outputs: llm_tier: description: 'Resolved credential tier: hosted, license, byok, or byok+license.' @@ -187,9 +198,6 @@ outputs: seed_source: description: 'Which state the review head analysis grew from: pr-chain or base.' value: ${{ steps.review_analyze.outputs.seed_source }} - base_analysis_method: - description: 'How the analysis of the review base commit was obtained: reused, incremental, or full.' - value: ${{ steps.review_analyze.outputs.base_analysis_method }} merge_base_sha: description: 'Merge base used as the review comparison baseline.' value: ${{ steps.guard.outputs.merge_base_sha }} @@ -203,15 +211,25 @@ outputs: description: 'Whether sync delivered a baseline commit.' value: ${{ steps.sync_commit.outputs.committed }} sync_pr_url: - description: 'Rolling sync PR URL when sync_strategy is pull_request.' + description: 'Rolling sync PR URL when save_baseline_to is pull_request.' value: ${{ steps.sync_commit.outputs.sync_pr_url }} sync_pr_number: - description: 'Rolling sync PR number when sync_strategy is pull_request.' + description: 'Rolling sync PR number when save_baseline_to is pull_request.' value: ${{ steps.sync_commit.outputs.sync_pr_number }} runs: using: 'composite' steps: + - name: Translate deprecated inputs + id: inputs + shell: bash + env: + SYNCED_BRANCH: ${{ inputs.synced_branch }} + SAVE_BASELINE_TO: ${{ inputs.save_baseline_to }} + OLD_TARGET_BRANCH: ${{ inputs.target_branch }} + OLD_SYNC_STRATEGY: ${{ inputs.sync_strategy }} + run: "$GITHUB_ACTION_PATH/scripts/action/old_api_migrator.sh" + - name: Resolve event id: guard shell: bash @@ -225,9 +243,9 @@ runs: REF_TYPE: ${{ github.ref_type }} EVENT_SHA: ${{ github.sha }} HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} - TARGET_BRANCH_INPUT: ${{ inputs.target_branch }} - SYNC_STRATEGY: ${{ inputs.sync_strategy }} - ANALYSIS_BRANCH: ${{ inputs.analysis_branch }} + SYNCED_BRANCH_INPUT: ${{ steps.inputs.outputs.synced_branch }} + SAVE_BASELINE_TO: ${{ steps.inputs.outputs.save_baseline_to }} + 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 }} @@ -456,8 +474,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 }} - ANALYSIS_BRANCH: ${{ inputs.analysis_branch }} + SAVE_BASELINE_TO: ${{ steps.inputs.outputs.save_baseline_to }} + 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 != '' }} @@ -479,12 +497,12 @@ runs: ACTION_PATH: ${{ github.action_path }} ANALYSIS_DIR: ${{ steps.sync_analyze.outputs.analysis_dir }} CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target - TARGET_BRANCH: ${{ steps.guard.outputs.target_branch }} + SYNCED_BRANCH: ${{ steps.guard.outputs.synced_branch }} SYNC_BRANCH_START_SHA: ${{ steps.guard.outputs.sync_branch_start_sha }} - SYNC_STRATEGY: ${{ inputs.sync_strategy }} - ANALYSIS_BRANCH: ${{ inputs.analysis_branch }} + SAVE_BASELINE_TO: ${{ steps.inputs.outputs.save_baseline_to }} + BASELINE_BRANCH: ${{ inputs.baseline_branch }} ENGINE_VERSION: ${{ steps.state.outputs.engine_version }} - # Recorded with each analysis-branch commit, so a reader can tell which configuration made it. + # Recorded with each baseline-branch commit, so a reader can tell which configuration made it. CFG_HASH: ${{ steps.state.outputs.cfg_hash }} GITHUB_TOKEN: ${{ inputs.github_token }} GH_TOKEN: ${{ inputs.github_token }} @@ -537,7 +555,7 @@ runs: MODE: ${{ steps.sync_analyze.outputs.analysis_mode }} COMMITTED: ${{ steps.sync_commit.outputs.committed }} FILES: ${{ steps.sync_commit.outputs.files_written }} - STRATEGY: ${{ inputs.sync_strategy }} + SAVE_BASELINE_TO: ${{ steps.inputs.outputs.save_baseline_to }} PR_URL: ${{ steps.sync_commit.outputs.sync_pr_url }} run: "$GITHUB_ACTION_PATH/scripts/action/sync-summary.sh" @@ -554,14 +572,13 @@ runs: REVIEW_BASE_REPO: ${{ steps.guard.outputs.base_repo }} PR_NUMBER: ${{ steps.guard.outputs.pr_number }} BASE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/base - BASE_FETCH_SECONDS: ${{ steps.fetch_base.outputs.seconds }} WARMSTART_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/warmstart RENEW_BASE: ${{ steps.fetch_base.outputs.renew }} # 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 != '' }} # For rewriting the progress comment while a base is built from scratch. PROGRESS_HEADER: ${{ steps.guard.outputs.comment_id }} - ANALYSIS_BRANCH: ${{ inputs.analysis_branch }} + BASELINE_BRANCH: ${{ inputs.baseline_branch }} BASE_REF: ${{ steps.guard.outputs.base_ref }} REPOSITORY: ${{ github.repository }} GH_HOST: ${{ github.server_url }} @@ -640,8 +657,6 @@ runs: ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }} BASE_ANALYSIS_METHOD: ${{ steps.review_analyze.outputs.base_analysis_method }} BASE_ANALYSIS_REASON: ${{ steps.review_analyze.outputs.base_analysis_reason }} - BASE_SECONDS: ${{ steps.review_analyze.outputs.base_seconds }} - HEAD_SECONDS: ${{ steps.review_analyze.outputs.head_seconds }} run: "$GITHUB_ACTION_PATH/scripts/action/build-review-artifact.sh" - name: Upload review artifact @@ -673,10 +688,6 @@ runs: HEAD_SHA: ${{ steps.guard.outputs.head_sha }} # Analysed files whose content hash differs between base and head, from the render step. ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }} - BASE_ANALYSIS_METHOD: ${{ steps.review_analyze.outputs.base_analysis_method }} - BASE_ANALYSIS_REASON: ${{ steps.review_analyze.outputs.base_analysis_reason }} - BASE_SECONDS: ${{ steps.review_analyze.outputs.base_seconds }} - HEAD_SECONDS: ${{ steps.review_analyze.outputs.head_seconds }} run: "$GITHUB_ACTION_PATH/scripts/action/build-review-comment.sh" - name: Post review comment diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index d028050..bf801c2 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -3,11 +3,12 @@ Where CodeBoarding keeps analysis state, and how a review run finds the two graphs it compares. -## Three stores +## Where analysis is stored | Store | Holds | Lifetime | Who can read it | |---|---|---|---| -| **Git** (`sync` commits) | `.codeboarding/` on the target branch | forever | anyone with repo read | +| **Git, synced branch** (`save_baseline_to: synced_branch` or `pull_request`) | `.codeboarding/` on the synced branch | forever | anyone with repo read | +| **Git, baseline branch** (`save_baseline_to: baseline_branch`) | one commit per sync on `codeboarding/baseline` | forever | anyone with repo read | | **Workflow artifacts** | every analysis this action reuses or publishes | a retention window | any run with `actions: read`, plus humans | | ~~Actions cache~~ | — | — | not used | @@ -74,8 +75,6 @@ never fires. | `analysed_files_changed` | string | analysed files whose content hash differs between base and head; `unknown` when the analyses cannot say | | `base_analysis_method` | string | how this run obtained the analysis of the merge base: `reused` (the merge base already had one: its saved artifact, or a committed baseline with nothing to catch up), `incremental` (an earlier commit's analysis, committed or saved, updated to the merge base) or `full` (analyzed from scratch in this run). Whichever, the base graph is the merge base's own analysis | | `base_analysis_reason` | string | the method in words, e.g. `updated the analysis of 9f8e7d6 to a1b2c3d, 4 commits caught up`; where an incremental run started and how far it caught up are detail here, present when known | -| `base_seconds` | string | wall time spent obtaining the base, the artifact lookup included | -| `head_seconds` | string | wall time of the head analysis | | `pr_number`, `mode`, `seed_source`, `chain_depth` | string | provenance; nothing rendering a diagram needs them. `mode` is the head's engine mode; `base_analysis_method` answers for the base | **A sync run** publishes the base graph under both the commit it analyzed and the @@ -107,7 +106,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 analysis branch (`sync_strategy: branch`): its commit for the merge base, else for the nearest of the merge base's last 100 first-parent ancestors, made under this configuration | none for the merge base itself (`reused`), one incremental otherwise (`incremental`) | +| the baseline branch (`save_baseline_to: baseline_branch`): its commit for the merge base, else for the nearest of the merge base's last 100 first-parent ancestors, made under this configuration | none for the merge base itself (`reused`), one incremental otherwise (`incremental`) | | 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` | @@ -115,9 +114,16 @@ A trusted run that computed the base publishes it, so the next pull request forking from that commit gets the first row; that includes a base caught up from an ancestor. The review metadata reports the row as `base_analysis_method` (`reused`, `incremental` or `full`) with a -`base_analysis_reason`, and the review comment repeats both under the diagram, -with measured times. While a base is computed, the progress -comment says so in two steps, with the elapsed time and the reason. +`base_analysis_reason`. The review comment does not: how the base was obtained is +for us, not the reader. While a base is computed, the progress comment says so in +two steps, with the elapsed time and the reason. + +**Telemetry.** Every engine run carries `CODEBOARDING_RUN_ID=gh---`, +the role being `base`, `head` or `sync`, so the engine's own `analysis_started` / +`analysis_completed` events (mode, duration, tokens) can be read per run. A review +with no `base` events reused its base; `base` events say whether it was caught up +or built in full. Where a caught-up base started from is only in +`base_analysis_reason`. The ancestor lookup walks the merge base's first-parent history, deepening the shallow checkout to 101 commits, then pages through the repository's artifacts @@ -146,25 +152,26 @@ 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 analysis branch +## The baseline branch -`sync_strategy: branch` saves the analysis to a branch of its own in the same -repository, `codeboarding/analysis` unless `analysis_branch` names another. -`target_branch` is then the code branch sync analyzes; it is never written. +`save_baseline_to: baseline_branch` saves the analysis to a branch of its own in +the same repository, `codeboarding/baseline` unless `baseline_branch` names +another. The synced branch is then only read. **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`: +Each sync adds one commit holding the same `.codeboarding/` files +`save_baseline_to: synced_branch` would commit to the synced branch, plus +`.codeboarding/source.json`: ```json -{"schema": 1, "source_branch": "main", "source_sha": "", "generated_at": "", "engine_version": "", "config": ""} +{"schema": 1, "synced_branch": "main", "source_sha": "", "generated_at": "", "engine_version": "", "config": ""} ``` The commit is `chore(codeboarding): diagram of main @` with two trailers: `CodeBoarding-Source: ` and `CodeBoarding-Config: `, the same configuration hash that names the base artifacts (engine version, provider, model, depth cap). Engine output is never edited; which commit it describes and how it was -made live only in `source.json` and the trailers. The target branch is never +made live only in `source.json` and the trailers. The synced branch is never written, not even `.gitattributes`. The base artifacts are still published, named for the analysed commit. @@ -172,15 +179,15 @@ for the analysed commit. this configuration, and runs incrementally. The generated files are replaced wholesale; only the checkout's own `.codeboardingignore` and health configuration are kept. The push is a fast-forward onto the tip it fetched, never forced. Sync -refuses to write to an existing branch that is not an analysis branch (its tip has +refuses to write to an existing branch that is not a baseline branch (its tip has no `CodeBoarding-Source` trailer, or holds anything besides `.codeboarding/`), so -pointing `analysis_branch` at a code branch fails instead of emptying it. If the -target branch moved during the analysis, the result is dropped, as with `push`. If -another sync moved the analysis branch, it builds on that tip once. A push the +pointing `baseline_branch` at a code branch fails instead of emptying it. If the +synced branch moved during the analysis, the result is dropped, as when committing +to it. 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. -The target branch is checked just before the push, not in the same transaction: +The synced branch is checked just before the push, not in the same transaction: if it moves in that window, the branch can end on an analysis of the older commit. Its trailer still names that commit, so no reader takes it for newer, and the run queued for the newer commit replaces it. @@ -202,14 +209,15 @@ otherwise. The history is lost; the current diagram is not. **Protecting it.** Sync and review load `static_analysis.pkl` from this branch, and a pickle runs code when loaded, so whoever can write the branch can run code in the sync and review workflows. Import -[`analysis-branch-ruleset.json`](analysis-branch-ruleset.json) under Settings, +[`baseline-branch-ruleset.json`](baseline-branch-ruleset.json) under Settings, Rules, Rulesets, New ruleset, Import a ruleset. It blocks creating, updating, -deleting and force-pushing `codeboarding/analysis` for everyone except its bypass +deleting and force-pushing `codeboarding/baseline` for everyone except its bypass actor, GitHub Actions (integration `15368`), which is what the default `github.token` pushes as. If sync pushes with a GitHub App token instead, such as the CodeBoarding Review app (`4021464`), make that app the only bypass actor: any workflow can use `github.token`, while only the workflows you give the app's -key can push as the app. Rulesets on a private repository need a paid GitHub plan +key can push as the app. Creation is covered too, so the bypass actor must be the +identity sync pushes as before the first sync, or that sync fails on the rule. Rulesets on a private repository need a paid GitHub plan (Pro, Team or Enterprise); on Free they apply to public repositories only. ## Trust boundary diff --git a/docs/analysis-branch-ruleset.json b/docs/baseline-branch-ruleset.json similarity index 81% rename from docs/analysis-branch-ruleset.json rename to docs/baseline-branch-ruleset.json index a4a81ac..a96dfb7 100644 --- a/docs/analysis-branch-ruleset.json +++ b/docs/baseline-branch-ruleset.json @@ -1,10 +1,10 @@ { - "name": "CodeBoarding analysis branch", + "name": "CodeBoarding baseline branch", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { - "include": ["refs/heads/codeboarding/analysis"], + "include": ["refs/heads/codeboarding/baseline"], "exclude": [] } }, diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index c9d5e2e..fc6b1ce 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -12,9 +12,16 @@ parse_output() { REQUIRES_FULL="$(awk -F= '$1 == "requires_full_analysis" {print $2; exit}' <<< "$output")" ANALYSIS_PATH="$(awk -F= '$1 == "analysis_path" {print substr($0, index($0, "=") + 1); exit}' <<< "$output")" } +# Tags the engine's own telemetry with this run and the analysis it belongs to +# (base, head or sync), so its events can be read per run: a review with no base +# events reused its base, one with base events caught it up or built it in full. +ENGINE_ROLE="" +engine_run_id() { + [ -z "${GITHUB_RUN_ID:-}" ] || printf 'gh-%s-%s-%s' "$GITHUB_RUN_ID" "${GITHUB_RUN_ATTEMPT:-1}" "$ENGINE_ROLE" +} incremental() { local checkout="$1" output_dir="$2" output - output="$(python3 "$ACTION_PATH/scripts/analyze_repository.py" incremental \ + output="$(CODEBOARDING_RUN_ID="$(engine_run_id)" python3 "$ACTION_PATH/scripts/analyze_repository.py" incremental \ --checkout "$checkout" --output-dir "$output_dir")" parse_output "$output" if [ "$ANALYSIS_MODE" != incremental ] || { [ "$REQUIRES_FULL" != true ] && [ ! -f "$ANALYSIS_PATH" ]; }; then @@ -24,7 +31,7 @@ incremental() { } full() { local checkout="$1" output_dir="$2" depth="$3" output - output="$(python3 "$ACTION_PATH/scripts/analyze_repository.py" full \ + output="$(CODEBOARDING_RUN_ID="$(engine_run_id)" python3 "$ACTION_PATH/scripts/analyze_repository.py" full \ --checkout "$checkout" --output-dir "$output_dir" --depth-cap "$depth")" parse_output "$output" if [ "$ANALYSIS_MODE" != full ] || [ ! -f "$ANALYSIS_PATH" ]; then @@ -123,21 +130,22 @@ analyze_sync() { local work="$RUNNER_TEMP/codeboarding-sync" state="$RUNNER_TEMP/codeboarding-sync/analysis" rm -rf "$work" seed_state "$CHECKOUT_DIR" "$state" + ENGINE_ROLE=sync REQUIRES_FULL=true if [ "$(printf '%s' "${FORCE_FULL:-false}" | tr '[:upper:]' '[:lower:]')" != true ]; then - # The analysis branch's tip is this branch's last analysis. Without a usable + # The baseline branch's tip is this branch's last analysis. Without a usable # one, the run below seeds from a saved ancestor or analyzes in full, and # delivery creates the branch again. - if [ "${SYNC_STRATEGY:-}" = branch ]; then + if [ "${SAVE_BASELINE_TO:-}" = baseline_branch ]; then local tip_entry - tip_entry="$(analysis_branch_index "${REPOSITORY:-}" | awk '{print $1, $3; exit}')" + tip_entry="$(baseline_branch_index "${REPOSITORY:-}" | awk '{print $1, $3; exit}')" if [ -z "$tip_entry" ]; then - echo "::notice::$ANALYSIS_BRANCH has no analysis to continue from; this sync creates it." + echo "::notice::$BASELINE_BRANCH has no analysis to continue from; this sync creates it." elif ! usable_config "${tip_entry#* }"; then - echo "::notice::The analysis on $ANALYSIS_BRANCH was made with another engine version or settings; not continuing from it." - elif ! restore_analysis_branch "${REPOSITORY:-}" "${tip_entry%% *}" "$state" "$CHECKOUT_DIR"; then - echo "::notice::Could not read the analysis on $ANALYSIS_BRANCH; not continuing from it." + echo "::notice::The analysis on $BASELINE_BRANCH was made with another engine version or settings; not continuing from it." + elif ! restore_baseline_branch "${REPOSITORY:-}" "${tip_entry%% *}" "$state" "$CHECKOUT_DIR"; then + echo "::notice::Could not read the analysis on $BASELINE_BRANCH; not continuing from it." fi fi if [ "$(depth_cap_from "$state/analysis.json")" = "$DEPTH_CAP" ]; then @@ -274,22 +282,22 @@ keep_user_config() { done } -# sync_strategy: branch keeps one commit per sync on ANALYSIS_BRANCH, each with +# save_baseline_to: baseline_branch keeps one commit per sync on BASELINE_BRANCH, each with # trailers naming the commit it analysed (CodeBoarding-Source) and the # configuration it ran under (CodeBoarding-Config). Lists them as # " ", newest first, at most -# ANALYSIS_BRANCH_DEPTH of them; an entry without a config has none to compare. +# BASELINE_BRANCH_DEPTH of them; an entry without a config has none to compare. # Fetched without blobs into a scratch repository: the lookup needs messages, and # a hundred pickles would cost more than it saves. -ANALYSIS_BRANCH_DEPTH="${ANALYSIS_BRANCH_DEPTH:-100}" -analysis_branch_index() { - local repository="$1" scratch="$RUNNER_TEMP/codeboarding-analysis-index.git" auth - [ -n "${ANALYSIS_BRANCH:-}" ] || return 0 +BASELINE_BRANCH_DEPTH="${BASELINE_BRANCH_DEPTH:-100}" +baseline_branch_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="$ANALYSIS_BRANCH_DEPTH" "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$ANALYSIS_BRANCH" 2>/dev/null || + --depth="$BASELINE_BRANCH_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) %(trailers:key=CodeBoarding-Config,valueonly,separator=%x20)' FETCH_HEAD | awk 'NF >= 2 {print $1, $2, (NF >= 3 ? $3 : "-")}' @@ -300,10 +308,10 @@ analysis_branch_index() { usable_config() { [ -n "${CFG_HASH:-}" ] && [ "$1" = "$CFG_HASH" ] } -# Replaces the generated state in $3 with an analysis-branch commit's, keeping the +# Replaces the generated state in $3 with a baseline-branch commit's, keeping the # user configuration of checkout $4. source.json is provenance, not engine state. -restore_analysis_branch() { - local repository="$1" commit="$2" state="$3" config_from="$4" scratch="$RUNNER_TEMP/codeboarding-analysis-restore" +restore_baseline_branch() { + local repository="$1" commit="$2" state="$3" config_from="$4" scratch="$RUNNER_TEMP/codeboarding-baseline-restore" fetch_commit "$repository" "$commit" || return 1 rm -rf "$scratch" mkdir -p "$scratch" @@ -314,16 +322,16 @@ restore_analysis_branch() { cp -a "$scratch/.codeboarding" "$state" keep_user_config "$config_from" "$state" } -# Seeds $3 from the analysis branch's entry for $2, or for its nearest first-parent +# Seeds $3 from the baseline branch's entry for $2, or for its nearest first-parent # ancestor that has one under this configuration, keeping checkout $4's user # configuration. Sets BRANCH_SOURCE to the commit it describes and BRANCH_DISTANCE # to how far below $2 that is; BRANCH_REASON=incompatible when the only entries # found were made under another configuration. BRANCH_SOURCE="" BRANCH_DISTANCE="" BRANCH_REASON="" -seed_from_analysis_branch() { +seed_from_baseline_branch() { local repository="$1" tip="$2" state="$3" config_from="$4" index commit line entry="" distance=0 BRANCH_SOURCE="" BRANCH_DISTANCE="" BRANCH_REASON="" - index="$(analysis_branch_index "$repository")" + index="$(baseline_branch_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 @@ -337,7 +345,7 @@ seed_from_analysis_branch() { [ -z "$entry" ] || break distance=$(( distance + 1 )) done - [ -n "$entry" ] && restore_analysis_branch "$repository" "$entry" "$state" "$config_from" || return 1 + [ -n "$entry" ] && restore_baseline_branch "$repository" "$entry" "$state" "$config_from" || return 1 BRANCH_SOURCE="$commit" BRANCH_DISTANCE="$distance" BRANCH_REASON="" } @@ -394,8 +402,9 @@ analyze_review() { # A published base graph is this merge base's own analysis, named for it, so it # 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 analysis was obtained, for the comment and the review - # artifact: reused, incremental or full. + # records how the base analysis was obtained, for the review artifact: reused, + # incremental or full. + ENGINE_ROLE=base local base_started base_method=reused base_published=true full_cause="" base_from_sha="" catchup_commits="" base_started="$(date +%s)" if [ "$(depth_cap_from "${BASE_DIR:-}/analysis.json")" = "$DEPTH_CAP" ]; then @@ -423,10 +432,10 @@ analyze_review() { elif [ -f "$base_state/analysis.json" ]; then full_cause=incompatible fi - # The analysis branch: its entry for the merge base is that commit's own + # 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_analysis_branch "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$base_state" "$base_checkout"; then + seed_from_baseline_branch "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$base_state" "$base_checkout"; then if [ "$(depth_cap_from "$base_state/analysis.json")" != "$DEPTH_CAP" ]; then full_cause=incompatible elif [ "$BRANCH_DISTANCE" -eq 0 ]; then @@ -464,8 +473,6 @@ analyze_review() { progress head "$(( $(date +%s) - base_started ))" fi fi - # The lookup in the step before this one is part of obtaining the base too. - local base_seconds=$(( $(date +%s) - base_started + ${BASE_FETCH_SECONDS:-0} )) unset GIT_TOKEN local base_analysis="$base_state/analysis.json" @@ -487,13 +494,11 @@ analyze_review() { fi rm -f "$head_state/origin.json" - local head_started - head_started="$(date +%s)" + ENGINE_ROLE=head incremental "$CHECKOUT_DIR" "$head_state" if [ "$REQUIRES_FULL" = true ]; then full "$CHECKOUT_DIR" "$head_state" "$DEPTH_CAP" fi - local head_seconds=$(( $(date +%s) - head_started )) write_origin "$head_state" "$seed_source" "$chain_depth" "$(analysis_digest "$base_analysis")" stage "$head_state" warmstart @@ -511,9 +516,8 @@ analyze_review() { printf 'analysis_mode=%s\nanalysis_path=%s\nbase_analysis_path=%s\nseed_source=%s\nchain_depth=%s\npublish_base=%s\n' \ "$ANALYSIS_MODE" "$ANALYSIS_PATH" "$base_analysis" "$seed_source" "$chain_depth" "$publish_base" >> "$GITHUB_OUTPUT" - printf 'base_analysis_method=%s\nbase_analysis_reason=%s\nbase_seconds=%s\nhead_seconds=%s\n' \ - "$base_method" "$(base_reason "$base_method" "$full_cause" "$base_from_sha" "$catchup_commits")" \ - "$base_seconds" "$head_seconds" >> "$GITHUB_OUTPUT" + printf 'base_analysis_method=%s\nbase_analysis_reason=%s\n' \ + "$base_method" "$(base_reason "$base_method" "$full_cause" "$base_from_sha" "$catchup_commits")" >> "$GITHUB_OUTPUT" } # Why the base analysis was obtained the way it was, in words. Whichever way, the diff --git a/scripts/action/build-review-artifact.sh b/scripts/action/build-review-artifact.sh index 9db59e4..b8ce994 100755 --- a/scripts/action/build-review-artifact.sh +++ b/scripts/action/build-review-artifact.sh @@ -40,14 +40,11 @@ jq -n \ --arg analysed_files_changed "${ANALYSED_FILES_CHANGED:-unknown}" \ --arg base_analysis_method "${BASE_ANALYSIS_METHOD:-}" \ --arg base_analysis_reason "${BASE_ANALYSIS_REASON:-}" \ - --arg base_seconds "${BASE_SECONDS:-}" \ - --arg head_seconds "${HEAD_SECONDS:-}" \ '{kind: $kind, mode: $mode, base_sha: $base_sha, merge_base_sha: $merge_base_sha, pr_base_sha: $merge_base_sha, merge_base_resolved: $merge_base_resolved, head_sha: $head_sha, pr_number: $pr_number, seed_source: $seed_source, chain_depth: $chain_depth, base_artifact: $base_artifact, base_artifact_id: $base_artifact_id, analysed_files_changed: $analysed_files_changed, - base_analysis_method: $base_analysis_method, base_analysis_reason: $base_analysis_reason, - base_seconds: $base_seconds, head_seconds: $head_seconds}' \ + base_analysis_method: $base_analysis_method, base_analysis_reason: $base_analysis_reason}' \ > "${RUNNER_TEMP}/cb-review-artifact/metadata.json" echo "artifact_dir=${RUNNER_TEMP}/cb-review-artifact" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/build-review-comment.sh b/scripts/action/build-review-comment.sh index 4e092a1..96743a6 100755 --- a/scripts/action/build-review-comment.sh +++ b/scripts/action/build-review-comment.sh @@ -45,33 +45,10 @@ elif [ "$BEHIND" -gt 0 ] 2>/dev/null; then printf '\nCompared against the merge base: this branch is %s %s behind `%s`.\n' \ "$BEHIND" "$COMMIT_NOUN" "${BASE_REF:-the base branch}" >> "$BODY" fi -# How the base analysis was obtained, with measured times only: an estimate would be -# wrong for exactly the slow runs it is meant to explain. Supporting detail, so it -# sits under the diagram with the run links. -duration() { - local seconds="${1:-0}" - case "$seconds" in ''|*[!0-9]*) seconds=0 ;; esac - if [ "$seconds" -ge 60 ]; then - printf '%s m %s s' "$(( seconds / 60 ))" "$(( seconds % 60 ))" - else - printf '%s s' "$seconds" - fi -} -BASE_METHOD="${BASE_ANALYSIS_METHOD:-}" -BASE_LINE="" -if [ -n "$BASE_METHOD" ]; then - BASE_LINE="Base: ${BASE_METHOD}" - # A reused analysis had nothing to catch up, so its time is not worth a figure. - [ "$BASE_METHOD" = reused ] || BASE_LINE="${BASE_LINE} in $(duration "${BASE_SECONDS:-}")" - [ -z "${BASE_ANALYSIS_REASON:-}" ] || BASE_LINE="${BASE_LINE} (${BASE_ANALYSIS_REASON})" - BASE_LINE="${BASE_LINE} · changes $(duration "${HEAD_SECONDS:-}")" -fi { printf '\n' cat "$DIAGRAM" - printf '\n' - [ -z "$BASE_LINE" ] || printf '\n%s\n' "$BASE_LINE" - printf '\n' + printf '\n\n' if [ -n "$ARTIFACT_URL" ]; then printf '[download artifacts](%s) · ' "$ARTIFACT_URL" fi @@ -79,12 +56,7 @@ fi # The machine-readable line: what a reader of the comment (the web platform's dashboard, an # agent) needs without parsing the prose or the diagram. An HTML comment renders as nothing. # Keep it one line, `key=value` pairs, values without spaces, so a regex over it stays trivial. - # The reason is prose, so it stays out of here: it is in the review artifact's metadata. - BASE_KEYS="" - if [ -n "$BASE_METHOD" ]; then - BASE_KEYS=" base_analysis_method=${BASE_METHOD} base_seconds=${BASE_SECONDS:-} head_seconds=${HEAD_SECONDS:-}" - fi - printf '\n' \ - "$PLATFORM_URL" "$N_CHANGED" "$ANALYSED_FILES_CHANGED" "${HEAD_SHA:-}" "$BASE_KEYS" + printf '\n' \ + "$PLATFORM_URL" "$N_CHANGED" "$ANALYSED_FILES_CHANGED" "${HEAD_SHA:-}" } >> "$BODY" echo "path=$BODY" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/deliver-sync.sh b/scripts/action/deliver-sync.sh index d503416..a359578 100755 --- a/scripts/action/deliver-sync.sh +++ b/scripts/action/deliver-sync.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Installs generated state and delivers it by direct push or a rolling sync PR. +# Installs generated state and delivers it to the synced branch, a rolling sync PR, or the baseline branch. set -euo pipefail cd "$CHECKOUT_DIR" SYNC_BRANCH=codeboarding/sync @@ -28,12 +28,12 @@ emit_result() { "$1" "$2" "$3" "$BASE_SHA" >> "$GITHUB_OUTPUT" } close_stale_pr() { - [ "$SYNC_STRATEGY" = pull_request ] || return 0 - git fetch "$REMOTE" "$TARGET_BRANCH" + [ "$SAVE_BASELINE_TO" = pull_request ] || return 0 + git fetch "$REMOTE" "$SYNCED_BRANCH" [ "$(git rev-parse FETCH_HEAD)" = "$BASE_SHA" ] || return 0 local number sync_sha number="$(gh api --method GET "repos/$REPOSITORY/pulls" \ - -f state=open -f base="$TARGET_BRANCH" \ + -f state=open -f base="$SYNCED_BRANCH" \ -f head="${REPOSITORY%%/*}:$SYNC_BRANCH" --jq '.[0].number // empty')" if [ -n "$number" ]; then sync_sha="$(git ls-remote "$REMOTE" "refs/heads/$SYNC_BRANCH" | awk '{print $1; exit}')" @@ -63,11 +63,11 @@ 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_analysis_branch() { - local branch="$ANALYSIS_BRANCH" tree="$RUNNER_TEMP/codeboarding-analysis-tree" - local index="$RUNNER_TEMP/codeboarding-analysis-index" git_dir files new_tree tip parent commit now +# save_baseline_to: baseline_branch keeps the analysis on an orphan branch of its +# own, one fast-forward commit per sync, and never writes to the synced 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" @@ -78,7 +78,7 @@ deliver_to_analysis_branch() { python3 -c 'import datetime,json,os,sys json.dump({ "schema": 1, - "source_branch": os.environ["TARGET_BRANCH"], + "synced_branch": os.environ["SYNCED_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", ""), @@ -95,10 +95,10 @@ CodeBoarding-Config: $CFG_HASH") # 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" + git fetch -q "$REMOTE" "$SYNCED_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." + echo "::notice::$SYNCED_BRANCH advanced during analysis; a newer run should update $branch." exit 0 fi tip="" parent=() @@ -109,28 +109,28 @@ CodeBoarding-Config: $CFG_HASH") # Building on any other branch would leave it holding nothing but .codeboarding/. if [ -z "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" ] || [ "$(git ls-tree --name-only "$tip")" != .codeboarding ]; then - echo "::error::$branch already exists and is not a CodeBoarding analysis branch, so sync will not write to it. Set analysis_branch to a branch name that is not in use." + echo "::error::$branch already exists and is not a CodeBoarding baseline branch, so sync will not write to it. Set baseline_branch to a branch name that is not in use." exit 1 fi 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}." + echo "::notice::$branch already holds this analysis of $SYNCED_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}" "${trailers[@]}")" + -m "chore(codeboarding): diagram of $SYNCED_BRANCH @${BASE_SHA:0:7}" "${trailers[@]}")" # 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 "analysis_branch_sha=$commit" >> "$GITHUB_OUTPUT" + 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 identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor for $branch in the repository's rulesets, or set sync_strategy: push." + echo "::error::GitHub refused the push to $branch, most likely because a branch rule protects it. Add the identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor for $branch in the repository's rulesets, or set save_baseline_to: synced_branch." exit 1 fi done @@ -138,7 +138,7 @@ CodeBoarding-Config: $CFG_HASH") echo "::notice::Another sync keeps updating $branch; leaving it to that run." exit 0 } -[ "$SYNC_STRATEGY" != branch ] || deliver_to_analysis_branch +[ "$SAVE_BASELINE_TO" != baseline_branch ] || deliver_to_baseline_branch "$ACTION_PATH/scripts/action/install-sync.sh" > "$GENERATED_PATHS" stage_paths=() @@ -155,11 +155,11 @@ files_written="$(find "$CHECKOUT_DIR/.codeboarding" -maxdepth 1 -type f \ \( -name analysis.json -o -name fingerprint.json -o -name static_analysis.pkl -o -name static_analysis.sha \ -o -name codeboarding_version.json \) | wc -l)" -git fetch "$REMOTE" "$TARGET_BRANCH" +git fetch "$REMOTE" "$SYNCED_BRANCH" remote_sha="$(git rev-parse FETCH_HEAD)" if [ "$remote_sha" != "$BASE_SHA" ]; then emit_result "$files_written" false "$BASE_SHA" - echo "::notice::$TARGET_BRANCH advanced during analysis; a newer run should update its baseline." + echo "::notice::$SYNCED_BRANCH advanced during analysis; a newer run should update its baseline." exit 0 fi @@ -176,9 +176,9 @@ fi git commit -m 'chore(codeboarding): sync analysis baseline' >/dev/null -if [ "$SYNC_STRATEGY" = push ]; then - if ! git push "$REMOTE" "HEAD:refs/heads/$TARGET_BRANCH"; then - classify_push_failure "$BASE_SHA" "$TARGET_BRANCH" +if [ "$SAVE_BASELINE_TO" = synced_branch ]; then + if ! git push "$REMOTE" "HEAD:refs/heads/$SYNCED_BRANCH"; then + classify_push_failure "$BASE_SHA" "$SYNCED_BRANCH" fi emit_result "$files_written" true "$(git rev-parse HEAD)" exit 0 @@ -186,8 +186,8 @@ fi pr_json="$(gh api --method GET "repos/$REPOSITORY/pulls" \ -f state=open -f head="${REPOSITORY%%/*}:$SYNC_BRANCH" --jq '.[0] // empty')" -if [ -n "$pr_json" ] && [ "$(jq -r .base.ref <<< "$pr_json")" != "$TARGET_BRANCH" ]; then - echo "::error::$SYNC_BRANCH already has an open PR into $(jq -r .base.ref <<< "$pr_json"); close it before changing target_branch." +if [ -n "$pr_json" ] && [ "$(jq -r .base.ref <<< "$pr_json")" != "$SYNCED_BRANCH" ]; then + echo "::error::$SYNC_BRANCH already has an open PR into $(jq -r .base.ref <<< "$pr_json"); close it before changing synced_branch." exit 1 fi @@ -198,11 +198,11 @@ if ! git push "--force-with-lease=refs/heads/$SYNC_BRANCH:$old_sync_sha" \ fi if [ -z "$pr_json" ]; then - gh pr create --repo "$REPOSITORY" --base "$TARGET_BRANCH" --head "$SYNC_BRANCH" \ + gh pr create --repo "$REPOSITORY" --base "$SYNCED_BRANCH" --head "$SYNC_BRANCH" \ --title 'chore(codeboarding): sync analysis baseline' \ - --body "Updates the versioned CodeBoarding analysis for \`$TARGET_BRANCH\`." + --body "Updates the versioned CodeBoarding analysis for \`$SYNCED_BRANCH\`." pr_json="$(gh api --method GET "repos/$REPOSITORY/pulls" \ - -f state=open -f base="$TARGET_BRANCH" \ + -f state=open -f base="$SYNCED_BRANCH" \ -f head="${REPOSITORY%%/*}:$SYNC_BRANCH" --jq '.[0]')" fi diff --git a/scripts/action/fetch-state.sh b/scripts/action/fetch-state.sh index e90dacd..cf16a1e 100755 --- a/scripts/action/fetch-state.sh +++ b/scripts/action/fetch-state.sh @@ -4,10 +4,6 @@ # leave the directory absent, and the caller derives from the base instead. set -euo pipefail [ -n "${ARTIFACT_NAME:-}" ] || exit 0 -# A review reports how long obtaining its base took, and this lookup is part of it. -started="$(date +%s)" -report_seconds() { [ -z "${GITHUB_OUTPUT:-}" ] || echo "seconds=$(( $(date +%s) - started ))" >> "$GITHUB_OUTPUT"; } -trap report_seconds EXIT # Clear first, on every path. These destinations are fixed, so a second use of # the action in one job would otherwise inherit the first one's files and treat diff --git a/scripts/action/guard.sh b/scripts/action/guard.sh index b469f0e..8e1bf6c 100755 --- a/scripts/action/guard.sh +++ b/scripts/action/guard.sh @@ -9,10 +9,10 @@ case "$MODE" in *) fail "mode must be review or sync." ;; esac printf 'mode=%s\nskip=false\nevent=%s\n' "$MODE" "$EVENT" >> "$GITHUB_OUTPUT" -# Both modes read the analysis branch, so a name git cannot use fails here, before +# Both modes read the baseline branch, so a name git cannot use fails here, before # anything is analyzed, rather than reading as a missing branch and costing a full run. -if [ -n "${ANALYSIS_BRANCH:-}" ] && ! git check-ref-format "refs/heads/$ANALYSIS_BRANCH"; then - fail "analysis_branch '$ANALYSIS_BRANCH' is not a valid branch name." +if [ -n "${BASELINE_BRANCH:-}" ] && ! git check-ref-format "refs/heads/$BASELINE_BRANCH"; then + fail "baseline_branch '$BASELINE_BRANCH' is not a valid branch name." fi if [ "$MODE" = sync ]; then case "$EVENT" in @@ -20,33 +20,33 @@ if [ "$MODE" = sync ]; then *) skip "Sync mode ignores $EVENT events." ;; esac [ "$REF_TYPE" != tag ] || skip "Sync mode ignores tag pushes." - case "$SYNC_STRATEGY" in - push|pull_request|branch) ;; - *) fail "sync_strategy must be push, pull_request or branch." ;; + case "$SAVE_BASELINE_TO" in + synced_branch|pull_request|baseline_branch) ;; + *) fail "save_baseline_to must be synced_branch, pull_request or baseline_branch." ;; esac case "$HEAD_AUTHOR_EMAIL" in codeboarding-review\[bot\]@users.noreply.github.com|codeboarding\[bot\]@users.noreply.github.com) [ "$EVENT" != push ] || skip "Ignoring CodeBoarding's own baseline commit." ;; esac - 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 "${ANALYSIS_BRANCH:-}" ] || fail "analysis_branch is required with sync_strategy: branch." - # The analysis branch holds only analysis; a workflow that also fires on it must not analyze it. - [ "$REF_NAME" != "$ANALYSIS_BRANCH" ] || skip "Ignoring a push to the analysis branch $ANALYSIS_BRANCH." - [ "$target_branch" != "$ANALYSIS_BRANCH" ] || fail "target_branch must differ from analysis_branch." + synced_branch="${SYNCED_BRANCH_INPUT:-$REF_NAME}" + [ -n "$synced_branch" ] || fail "synced_branch is required for this event." + [ "$SAVE_BASELINE_TO" != pull_request ] || [ "$synced_branch" != codeboarding/sync ] || fail "synced_branch must differ from codeboarding/sync." + if [ "$SAVE_BASELINE_TO" = baseline_branch ]; then + [ -n "${BASELINE_BRANCH:-}" ] || fail "baseline_branch is required with save_baseline_to: baseline_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." + [ "$synced_branch" != "$BASELINE_BRANCH" ] || fail "synced_branch must differ from baseline_branch." fi sync_branch_start_sha="" - if [ "$SYNC_STRATEGY" = pull_request ]; then + if [ "$SAVE_BASELINE_TO" = pull_request ]; then sync_branch_start_sha="$(gh api "repos/$REPOSITORY/branches/codeboarding%2Fsync" --jq '.commit.sha' 2>/dev/null || true)" fi { - echo "target_branch=$target_branch" + echo "synced_branch=$synced_branch" echo "sync_branch_start_sha=$sync_branch_start_sha" echo "checkout_repo=$REPOSITORY" - echo "checkout_ref=$target_branch" + echo "checkout_ref=$synced_branch" } >> "$GITHUB_OUTPUT" exit 0 fi diff --git a/scripts/action/old_api_migrator.sh b/scripts/action/old_api_migrator.sh new file mode 100755 index 0000000..5d03cff --- /dev/null +++ b/scripts/action/old_api_migrator.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Translates deprecated inputs into the current ones, and is the only place that +# knows they exist. Every later step reads this step's outputs, never the inputs +# themselves, so no other script carries backward-compatibility logic. +# +# target_branch -> synced_branch +# sync_strategy -> save_baseline_to (push -> synced_branch, pull_request -> pull_request) +# +# Delete a mapping, and its input in action.yml, once no supported workflow sets it. +set -euo pipefail +fail() { echo "::error::$1"; exit 1; } + +synced_branch="${SYNCED_BRANCH:-}" +if [ -n "${OLD_TARGET_BRANCH:-}" ]; then + [ -z "$synced_branch" ] || fail "Set synced_branch only; target_branch is its deprecated name." + echo "::warning::target_branch is deprecated; rename it to synced_branch." + synced_branch="$OLD_TARGET_BRANCH" +fi + +save_baseline_to="${SAVE_BASELINE_TO:-}" +if [ -n "${OLD_SYNC_STRATEGY:-}" ]; then + # save_baseline_to has a default, so only a value other than it is a conflict. + [ "$save_baseline_to" = synced_branch ] || fail "Set save_baseline_to only; sync_strategy is its deprecated name." + case "$OLD_SYNC_STRATEGY" in + push) save_baseline_to=synced_branch ;; + pull_request) save_baseline_to=pull_request ;; + *) fail "sync_strategy is deprecated and only accepts push or pull_request; use save_baseline_to." ;; + esac + echo "::warning::sync_strategy is deprecated; use save_baseline_to: $save_baseline_to." +fi + +printf 'synced_branch=%s\nsave_baseline_to=%s\n' "$synced_branch" "$save_baseline_to" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/sync-summary.sh b/scripts/action/sync-summary.sh index 287b9af..7233825 100755 --- a/scripts/action/sync-summary.sh +++ b/scripts/action/sync-summary.sh @@ -6,7 +6,7 @@ set -euo pipefail echo "- Analysis: ${MODE}" echo "- Analysis artifacts: ${FILES:-0}" echo "- Delivered: ${COMMITTED:-false}" - echo "- Strategy: ${STRATEGY}" + echo "- Saved to: ${SAVE_BASELINE_TO}" if [ -n "${PR_URL:-}" ]; then echo "- Sync PR: ${PR_URL}" fi diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 4757bc7..233987e 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -86,6 +86,14 @@ def test_the_inferred_credential_inputs_are_gone(self) -> None: self.assertNotIn(stale, self.inputs) self.assertNotIn(f"inputs.{stale}", ACTION) + def test_deprecated_inputs_are_read_only_by_the_migrator(self) -> None: + """Backward compatibility lives in one script; every other step reads its outputs.""" + for old, new in (("target_branch", "synced_branch"), ("sync_strategy", "save_baseline_to")): + self.assertIn("deprecationMessage:", self.inputs[old]) + self.assertEqual(ACTION.count(f"inputs.{old} }}}}"), 1, f"only the migrator may read {old}") + self.assertEqual(ACTION.count(f"inputs.{new} }}}}"), 1, f"only the migrator may read {new}") + self.assertLess(ACTION.index("- name: Translate deprecated inputs"), ACTION.index("- name: Resolve event")) + def test_credentials_resolve_before_the_checkout_and_the_engine_install(self) -> None: """Fail-fast is positional: preflight is worth little after a minute of setup.""" preflight = ACTION.index("- name: Check LLM configuration") diff --git a/tests/test_action_state.py b/tests/test_action_state.py index 70d07ed..5275992 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -26,6 +26,7 @@ "mode": argv[0], "checkout": argv[argv.index("--local") + 1], "depth": argv[argv.index("--depth-cap") + 1] if "--depth-cap" in argv else None, + "run_id": os.environ.get("CODEBOARDING_RUN_ID", ""), }) + "\\n") analysis = os.path.join(output, "analysis.json") metadata = json.load(open(analysis))["metadata"] if os.path.isfile(analysis) else {} @@ -286,6 +287,16 @@ def test_compatible_committed_baseline_runs_incrementally(self) -> None: self._analyze(REVIEW_BASE_SHA=sha, DEPTH_CAP="4") self.assertEqual([c["mode"] for c in self._engine_calls()], ["incremental", "incremental"]) + def test_engine_runs_are_tagged_with_the_run_and_the_analysis_they_belong_to(self) -> None: + sha = self._commit_base(cap=4) + self._analyze(REVIEW_BASE_SHA=sha, DEPTH_CAP="4", GITHUB_RUN_ID="991", GITHUB_RUN_ATTEMPT="2") + self.assertEqual([c["run_id"] for c in self._engine_calls()], ["gh-991-2-base", "gh-991-2-head"]) + + def test_a_reused_base_leaves_only_head_engine_runs(self) -> None: + _state(self.base_dir) + self._analyze(GITHUB_RUN_ID="991", GITHUB_RUN_ATTEMPT="1") + self.assertEqual([c["run_id"] for c in self._engine_calls()], ["gh-991-1-head"]) + def test_legacy_committed_depth_is_not_inherited(self) -> None: sha = self._commit_base(legacy=True) self._analyze(REVIEW_BASE_SHA=sha, DEPTH_CAP="4") @@ -360,22 +371,17 @@ def _merge(self, branch: str, files: dict[str, str], *, bot: bool = False) -> st return self._git("rev-parse", "HEAD") def _provenance(self, values: dict[str, str]) -> dict[str, str]: - keys = ("base_analysis_method", "base_analysis_reason") - for key in ("base_seconds", "head_seconds"): - self.assertRegex(values[key], r"^[0-9]+$", key) - return {key: values[key] for key in keys} + return {key: values[key] for key in ("base_analysis_method", "base_analysis_reason")} def test_a_saved_base_is_reused(self) -> None: _state(self.base_dir) - values = self._analyze(BASE_FETCH_SECONDS="7") + values = self._analyze() self.assertEqual( self._provenance(values), {"base_analysis_method": "reused", "base_analysis_reason": "merge-b already has a saved analysis"}, ) - # The download happened in the step before; it is still time spent on the base. - self.assertGreaterEqual(int(values["base_seconds"]), 7) def test_a_base_with_nothing_to_seed_it_is_a_full_analysis(self) -> None: sha = self._commit_base() @@ -456,7 +462,7 @@ def test_merged_pull_requests_count_as_commits_to_catch_up(self) -> None: ) def test_a_merged_sync_pull_request_describes_its_own_parent(self) -> None: - # sync_strategy: pull_request. The sync commit sits on codeboarding/sync on + # save_baseline_to: pull_request. The sync commit sits on codeboarding/sync on # top of the analysed commit; main moved on before the merge. self._git("init", "-q", "-b", "main") analysed = self._commit("feat: code", {"code.py": "pass\n"}) diff --git a/tests/test_action_sync.py b/tests/test_action_sync.py index b0b87aa..e2e0cb1 100644 --- a/tests/test_action_sync.py +++ b/tests/test_action_sync.py @@ -392,8 +392,8 @@ def test_it_publishes_the_analysis_under_both_commits(self) -> None: "GITHUB_TOKEN": "unused", "GH_HOST": "github.com", "REPOSITORY": "owner/repo", - "TARGET_BRANCH": "main", - "SYNC_STRATEGY": "push", + "SYNCED_BRANCH": "main", + "SAVE_BASELINE_TO": "synced_branch", }, capture_output=True, text=True, diff --git a/tests/test_analysis_branch.py b/tests/test_baseline_branch.py similarity index 88% rename from tests/test_analysis_branch.py rename to tests/test_baseline_branch.py index 92a912f..96c8aee 100644 --- a/tests/test_analysis_branch.py +++ b/tests/test_baseline_branch.py @@ -1,4 +1,4 @@ -"""sync_strategy: branch saves the analysis to an orphan branch, and reviews read their base from it.""" +"""save_baseline_to: baseline_branch saves the analysis to an orphan branch, and reviews read their base from it.""" from __future__ import annotations @@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parent.parent DELIVER = ROOT / "scripts" / "action" / "deliver-sync.sh" -BRANCH = "codeboarding/analysis" +BRANCH = "codeboarding/baseline" def git(cwd: Path, *args: str) -> str: @@ -26,7 +26,7 @@ def git(cwd: Path, *args: str) -> str: ).stdout.strip() -class AnalysisBranchDeliveryTests(unittest.TestCase): +class BaselineBranchDeliveryTests(unittest.TestCase): """deliver-sync.sh against a real local remote.""" def setUp(self) -> None: @@ -84,9 +84,9 @@ def _deliver(self, expect_ok: bool = True) -> tuple[subprocess.CompletedProcess, "GITHUB_TOKEN": "unused", "GH_HOST": "github.com", "REPOSITORY": "owner/repo", - "TARGET_BRANCH": "main", - "SYNC_STRATEGY": "branch", - "ANALYSIS_BRANCH": BRANCH, + "SYNCED_BRANCH": "main", + "SAVE_BASELINE_TO": "baseline_branch", + "BASELINE_BRANCH": BRANCH, "ENGINE_VERSION": "0.14.5", "CFG_HASH": "cfg", }, @@ -108,7 +108,7 @@ def test_the_first_sync_creates_an_orphan_branch_with_its_provenance(self) -> No _result, values = self._deliver() (only,) = self._branch_log() - self.assertEqual(only.split(), [values["analysis_branch_sha"]], "the branch must have no parent") + 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()) @@ -123,7 +123,7 @@ def test_the_first_sync_creates_an_orphan_branch_with_its_provenance(self) -> No ) 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["synced_branch"], "main") self.assertEqual(source["source_sha"], main_before) self.assertEqual(source["engine_version"], "0.14.5") self.assertEqual(source["config"], "cfg") @@ -134,7 +134,7 @@ def test_the_first_sync_creates_an_orphan_branch_with_its_provenance(self) -> No f"chore(codeboarding): diagram of main @{main_before[:7]}\n\n" f"CodeBoarding-Source: {main_before}\nCodeBoarding-Config: cfg", ) - # The default branch is never written in this strategy. + # The synced branch is never written when the baseline has a branch of its own. self.assertEqual(git(self.remote, "rev-parse", "main"), main_before) def test_the_next_sync_appends_a_fast_forward_commit(self) -> None: @@ -147,7 +147,7 @@ def test_the_next_sync_appends_a_fast_forward_commit(self) -> None: log = self._branch_log() self.assertEqual(len(log), 2) - self.assertEqual(log[0].split(), [values["analysis_branch_sha"], first]) + 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: @@ -173,7 +173,7 @@ def test_a_push_refused_by_a_branch_rule_says_how_to_fix_it(self) -> None: 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) + self.assertIn("save_baseline_to: synced_branch", result.stdout) def test_a_deleted_branch_is_recreated_as_a_new_orphan(self) -> None: self._deliver() @@ -185,8 +185,8 @@ def test_a_deleted_branch_is_recreated_as_a_new_orphan(self) -> None: (only,) = self._branch_log() self.assertEqual(len(only.split()), 1, "a recreated branch starts a new history") - def test_an_existing_branch_that_is_not_an_analysis_branch_is_never_written(self) -> None: - # analysis_branch pointed at a code branch: building on it would leave it + def test_an_existing_branch_that_is_not_a_baseline_branch_is_never_written(self) -> None: + # baseline_branch pointed at a code branch: building on it would leave it # holding nothing but .codeboarding/. git(self.checkout, "push", "-q", "origin", f"main:refs/heads/{BRANCH}") before = git(self.remote, "rev-parse", BRANCH) @@ -194,11 +194,11 @@ def test_an_existing_branch_that_is_not_an_analysis_branch_is_never_written(self result, values = self._deliver(expect_ok=False) self.assertNotEqual(result.returncode, 0) - self.assertIn(f"{BRANCH} already exists and is not a CodeBoarding analysis branch", result.stdout) + self.assertIn(f"{BRANCH} already exists and is not a CodeBoarding baseline branch", result.stdout) self.assertEqual(git(self.remote, "rev-parse", BRANCH), before) - self.assertNotIn("analysis_branch_sha", values) + self.assertNotIn("baseline_branch_sha", values) - def test_a_target_that_moved_during_analysis_keeps_the_branch_unchanged(self) -> None: + def test_a_synced_branch_that_moved_during_analysis_keeps_the_branch_unchanged(self) -> None: self._deliver() before = git(self.remote, "rev-parse", BRANCH) other = self.root / "other" @@ -215,7 +215,7 @@ def test_a_target_that_moved_during_analysis_keeps_the_branch_unchanged(self) -> self.assertEqual(git(self.remote, "rev-parse", BRANCH), before) -class AnalysisBranchReadTests(unittest.TestCase): +class BaselineBranchReadTests(unittest.TestCase): """Reviews and sync read the branch: commits c0..c4 on main, branch entries for c1 and c3 made under configuration `cfg`.""" @@ -293,7 +293,7 @@ def _analyze(self, **extra: str) -> dict[str, str]: "PR_NUMBER": "42", "ENGINE_VERSION": "0.14.5", "CFG_HASH": "cfg", - "ANALYSIS_BRANCH": BRANCH, + "BASELINE_BRANCH": BRANCH, "BASE_DIR": str(self.root / "state" / "base"), "WARMSTART_DIR": str(self.root / "state" / "warmstart"), "STAGE_DIR": str(self.root / "state" / "out"), @@ -350,14 +350,14 @@ def test_without_a_configuration_hash_no_entry_is_trusted(self) -> None: self.assertEqual(values["base_analysis_method"], "full") def test_without_the_branch_the_base_is_a_full_analysis(self) -> None: - values = self._analyze(REVIEW_BASE_SHA=self.shas[4], ANALYSIS_BRANCH="codeboarding/none") + values = self._analyze(REVIEW_BASE_SHA=self.shas[4], BASELINE_BRANCH="codeboarding/none") self.assertEqual(values["base_analysis_method"], "full") self.assertEqual(values["base_analysis_reason"], "no usable analysis was available") 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._analyze(ANALYSIS_KIND="sync", SAVE_BASELINE_TO="baseline_branch", FORCE_FULL="false") self.assertEqual(self._modes(), ["incremental"]) @@ -371,7 +371,7 @@ def test_sync_replaces_the_committed_state_with_the_branch_tip(self) -> None: (board / "static_analysis.sha").write_text("stale\n") (board / ".codeboardingignore").write_text("docs/\n") - values = self._analyze(ANALYSIS_KIND="sync", SYNC_STRATEGY="branch", FORCE_FULL="false") + values = self._analyze(ANALYSIS_KIND="sync", SAVE_BASELINE_TO="baseline_branch", FORCE_FULL="false") state = Path(values["analysis_dir"]) self.assertEqual((state / "static_analysis.pkl").read_text(), "pickle", "the tip's engine state") @@ -380,19 +380,22 @@ def test_sync_replaces_the_committed_state_with_the_branch_tip(self) -> None: self.assertEqual((state / ".codeboardingignore").read_text(), "docs/\n") def test_sync_does_not_continue_from_a_tip_made_under_another_configuration(self) -> None: - self._analyze(ANALYSIS_KIND="sync", SYNC_STRATEGY="branch", FORCE_FULL="false", CFG_HASH="othercfg") + self._analyze(ANALYSIS_KIND="sync", SAVE_BASELINE_TO="baseline_branch", FORCE_FULL="false", CFG_HASH="othercfg") self.assertEqual(self._modes(), ["full"]) def test_sync_without_the_branch_analyzes_from_scratch(self) -> None: self._analyze( - ANALYSIS_KIND="sync", SYNC_STRATEGY="branch", FORCE_FULL="false", ANALYSIS_BRANCH="codeboarding/none" + ANALYSIS_KIND="sync", + SAVE_BASELINE_TO="baseline_branch", + FORCE_FULL="false", + BASELINE_BRANCH="codeboarding/none", ) self.assertEqual(self._modes(), ["full"]) -class AnalysisBranchGuardTests(unittest.TestCase): +class BaselineBranchGuardTests(unittest.TestCase): def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: with tempfile.TemporaryDirectory() as tmp: output = Path(tmp) / "github-output" @@ -407,8 +410,8 @@ def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: "REF_NAME": "main", "REF_TYPE": "branch", "HEAD_AUTHOR_EMAIL": "dev@example.com", - "SYNC_STRATEGY": "branch", - "ANALYSIS_BRANCH": BRANCH, + "SAVE_BASELINE_TO": "baseline_branch", + "BASELINE_BRANCH": BRANCH, "REPOSITORY": "owner/repo", **extra, }, @@ -418,12 +421,12 @@ def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: ) return result, output.read_text(encoding="utf-8") - def test_the_branch_strategy_is_accepted(self) -> None: + def test_the_baseline_branch_destination_is_accepted(self) -> None: result, values = self._guard() self.assertEqual(result.returncode, 0, result.stdout) - self.assertIn("target_branch=main", values) + self.assertIn("synced_branch=main", values) - def test_a_push_to_the_analysis_branch_itself_is_ignored(self) -> None: + 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) @@ -431,14 +434,14 @@ def test_a_push_to_the_analysis_branch_itself_is_ignored(self) -> None: def test_a_branch_name_git_cannot_use_fails_before_anything_runs(self) -> None: for mode in ("sync", "review"): for name in ("codeboarding/a..b", "codeboarding/with space", "codeboarding/trailing."): - result, _values = self._guard(MODE=mode, ANALYSIS_BRANCH=name) + result, _values = self._guard(MODE=mode, BASELINE_BRANCH=name) self.assertNotEqual(result.returncode, 0, (mode, name)) self.assertIn("is not a valid branch name", result.stdout) - def test_target_branch_must_differ_from_the_analysis_branch(self) -> None: - result, _values = self._guard(TARGET_BRANCH_INPUT=BRANCH) + def test_synced_branch_must_differ_from_the_baseline_branch(self) -> None: + result, _values = self._guard(SYNCED_BRANCH_INPUT=BRANCH) self.assertNotEqual(result.returncode, 0) - self.assertIn("target_branch must differ from analysis_branch", result.stdout) + self.assertIn("synced_branch must differ from baseline_branch", result.stdout) if __name__ == "__main__": diff --git a/tests/test_old_api_migrator.py b/tests/test_old_api_migrator.py new file mode 100644 index 0000000..5d7e390 --- /dev/null +++ b/tests/test_old_api_migrator.py @@ -0,0 +1,77 @@ +"""Deprecated inputs keep working, translated in one place, and never silently lose to the new ones.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MIGRATOR = ROOT / "scripts" / "action" / "old_api_migrator.sh" + + +def migrate(**inputs: str) -> tuple[subprocess.CompletedProcess, dict[str, str]]: + """Runs the migrator with action.yml's defaults, overridden by `inputs`.""" + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "github-output" + output.write_text("", encoding="utf-8") + result = subprocess.run( + [str(MIGRATOR)], + env={ + "PATH": os.environ["PATH"], + "GITHUB_OUTPUT": str(output), + "SYNCED_BRANCH": "", + "SAVE_BASELINE_TO": "synced_branch", + "OLD_TARGET_BRANCH": "", + "OLD_SYNC_STRATEGY": "", + **inputs, + }, + capture_output=True, + text=True, + check=False, + ) + values = dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) + return result, values + + +class OldApiMigratorTests(unittest.TestCase): + def test_current_inputs_pass_through_without_warnings(self) -> None: + result, values = migrate(SYNCED_BRANCH="main", SAVE_BASELINE_TO="baseline_branch") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(values, {"synced_branch": "main", "save_baseline_to": "baseline_branch"}) + self.assertNotIn("::warning::", result.stdout) + + def test_target_branch_becomes_synced_branch(self) -> None: + result, values = migrate(OLD_TARGET_BRANCH="develop") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(values["synced_branch"], "develop") + self.assertIn("::warning::target_branch is deprecated", result.stdout) + + def test_sync_strategy_values_map_onto_save_baseline_to(self) -> None: + for old, new in (("push", "synced_branch"), ("pull_request", "pull_request")): + with self.subTest(old=old): + result, values = migrate(OLD_SYNC_STRATEGY=old) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(values["save_baseline_to"], new) + self.assertIn("::warning::sync_strategy is deprecated", result.stdout) + + def test_a_value_sync_strategy_never_accepted_fails(self) -> None: + result, _values = migrate(OLD_SYNC_STRATEGY="branch") + self.assertNotEqual(result.returncode, 0) + self.assertIn("use save_baseline_to", result.stdout) + + def test_setting_an_input_under_both_names_fails(self) -> None: + for inputs in ( + {"SYNCED_BRANCH": "main", "OLD_TARGET_BRANCH": "main"}, + {"SAVE_BASELINE_TO": "baseline_branch", "OLD_SYNC_STRATEGY": "push"}, + ): + with self.subTest(inputs=inputs): + result, _values = migrate(**inputs) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("deprecated name", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_review_artifact_metadata.py b/tests/test_review_artifact_metadata.py index 7a79060..b630201 100644 --- a/tests/test_review_artifact_metadata.py +++ b/tests/test_review_artifact_metadata.py @@ -71,26 +71,22 @@ def test_how_the_base_was_obtained_is_recorded_as_strings(self) -> None: Path(tmp), BASE_ANALYSIS_METHOD="incremental", BASE_ANALYSIS_REASON="updated the analysis of 9f8e7d6 to a1b2c3d, 3 commits caught up", - BASE_SECONDS="41", - HEAD_SECONDS="159", ) self.assertEqual( - {key: metadata[key] for key in metadata if key.startswith("base_") or key.endswith("_seconds")}, + {key: metadata[key] for key in metadata if key.startswith("base_")}, { "base_sha": "tip-sha", "base_artifact": "codeboarding-base-cfg-mergebasesha", "base_artifact_id": "4242", "base_analysis_method": "incremental", "base_analysis_reason": "updated the analysis of 9f8e7d6 to a1b2c3d, 3 commits caught up", - "base_seconds": "41", - "head_seconds": "159", }, ) def test_an_older_run_without_provenance_records_empty_strings(self) -> None: with tempfile.TemporaryDirectory() as tmp: metadata, _outputs = _build(Path(tmp)) - for key in ("base_analysis_method", "base_analysis_reason", "base_seconds", "head_seconds"): + for key in ("base_analysis_method", "base_analysis_reason"): self.assertEqual(metadata[key], "", key) diff --git a/tests/test_review_comment.py b/tests/test_review_comment.py index ca77256..709f693 100644 --- a/tests/test_review_comment.py +++ b/tests/test_review_comment.py @@ -82,78 +82,5 @@ def test_a_changed_analysed_file_gets_no_verdict_even_at_zero_components(self) - self.assertIn("changed=0 analysed_files_changed=2 head=abc123", body) -class BaseLineTests(unittest.TestCase): - """One line under the diagram saying how the base analysis was obtained, with measured times only.""" - - def _body(self, **extra: str) -> str: - with tempfile.TemporaryDirectory() as tmp: - return _build(Path(tmp), BASE_REF="main", MERGE_BASE_SHA="f00dfeed" * 5, **extra) - - def _base_line(self, **extra: str) -> str: - body = self._body(**extra) - lines = [line for line in body.splitlines() if line.startswith("Base: ")] - self.assertEqual(len(lines), 1, body) - return lines[0] - - def test_a_full_analysis_says_why_and_how_long(self) -> None: - line = self._base_line( - BASE_ANALYSIS_METHOD="full", - BASE_ANALYSIS_REASON="no usable analysis was available", - BASE_SECONDS="534", - HEAD_SECONDS="192", - ) - self.assertEqual( - line, "Base: full in 8 m 54 s (no usable analysis was available) · changes 3 m 12 s" - ) - - def test_a_reused_analysis_has_no_base_time(self) -> None: - line = self._base_line( - BASE_ANALYSIS_METHOD="reused", - BASE_ANALYSIS_REASON="a1b2c3d already has a saved analysis", - BASE_SECONDS="3", - HEAD_SECONDS="159", - ) - self.assertEqual(line, "Base: reused (a1b2c3d already has a saved analysis) · changes 2 m 39 s") - - def test_an_incremental_analysis_carries_where_it_started(self) -> None: - line = self._base_line( - BASE_ANALYSIS_METHOD="incremental", - BASE_ANALYSIS_REASON="updated the analysis of 9f8e7d6 to a1b2c3d, 4 commits caught up", - BASE_SECONDS="41", - HEAD_SECONDS="159", - ) - self.assertEqual( - line, - "Base: incremental in 41 s (updated the analysis of 9f8e7d6 to a1b2c3d, 4 commits caught up)" - " · changes 2 m 39 s", - ) - - def test_the_base_line_sits_under_the_diagram_above_the_run_links(self) -> None: - body = self._body(BASE_ANALYSIS_METHOD="reused", BASE_ANALYSIS_REASON="r", HEAD_SECONDS="4") - diagram = body.index("```mermaid") - base = body.index("Base: ") - footer = body.index("run [1234]") - self.assertLess(diagram, base) - self.assertLess(base, footer) - self.assertIn("· changes 4 s\n\nrun [1234]", body) - - def test_the_marker_carries_the_method_after_the_existing_keys(self) -> None: - body = self._body( - BASE_ANALYSIS_METHOD="full", - BASE_ANALYSIS_REASON="no usable analysis was available", - BASE_SECONDS="534", - HEAD_SECONDS="192", - ) - self.assertTrue( - body.rstrip("\n").endswith("head=abc123 base_analysis_method=full base_seconds=534 head_seconds=192 -->"), - body, - ) - - def test_without_a_method_there_is_no_line_and_no_keys(self) -> None: - body = self._body() - self.assertNotIn("Base:", body) - self.assertNotIn("base_analysis_method=", body) - - if __name__ == "__main__": unittest.main() From 1fb65b1829862850df8c31a6c79a0fa2dc729819 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sat, 10 Oct 2026 03:21:03 +0200 Subject: [PATCH 07/19] refactor: split the review into baseline and head steps, and analyze one commit at a time analyze.sh now only analyzes one checkout into a state directory. Where an analysis to continue from comes from lives in seed-sources.sh, and the baseline branch is read and written in one place, codeboarding-baseline.sh. The review runs as two steps, review-generate-baseline.sh and review-analyze-head.sh; sync runs sync-generate-baseline.sh. The baseline branch is now consulted before a baseline committed on the branch, since only its entries are pinned to the configuration. The commit archaeology that existed only to report how a base was obtained is gone, with base_analysis_method and base_analysis_reason: engine runs carry their role in CODEBOARDING_RUN_ID instead. The progress comment is two fixed edits rather than a background ticker. guard.sh, state-names.sh and post-progress.sh are renamed to resolve-github-event.sh, resolve-cache-keys.sh and update-review-progress.sh. tests/test_merge_base_contract.py follows the new script paths, with explicit consent; its assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- action.yml | 61 +- docs/COMMIT_STRATEGY.md | 45 +- scripts/action/analysis-state.sh | 127 ++++ scripts/action/analyze.sh | 576 +----------------- scripts/action/build-review-artifact.sh | 8 +- scripts/action/codeboarding-baseline.sh | 137 +++++ scripts/action/deliver-sync.sh | 78 +-- scripts/action/find-ancestor-base.sh | 14 +- .../{state-names.sh => resolve-cache-keys.sh} | 0 .../{guard.sh => resolve-github-event.sh} | 0 scripts/action/review-analyze-head.sh | 49 ++ scripts/action/review-generate-baseline.sh | 59 ++ scripts/action/seed-sources.sh | 44 ++ scripts/action/sync-generate-baseline.sh | 32 + ...-progress.sh => update-review-progress.sh} | 26 +- tests/test_action_state.py | 266 +++----- tests/test_action_sync.py | 2 +- tests/test_baseline_branch.py | 71 ++- tests/test_merge_base_contract.py | 50 +- tests/test_progress_comment.py | 44 +- tests/test_review_artifact_metadata.py | 24 - 22 files changed, 716 insertions(+), 1000 deletions(-) create mode 100644 scripts/action/analysis-state.sh create mode 100644 scripts/action/codeboarding-baseline.sh rename scripts/action/{state-names.sh => resolve-cache-keys.sh} (100%) rename scripts/action/{guard.sh => resolve-github-event.sh} (100%) create mode 100755 scripts/action/review-analyze-head.sh create mode 100755 scripts/action/review-generate-baseline.sh create mode 100644 scripts/action/seed-sources.sh create mode 100755 scripts/action/sync-generate-baseline.sh rename scripts/action/{post-progress.sh => update-review-progress.sh} (67%) diff --git a/README.md b/README.md index 2bea5c9..12da9c8 100644 --- a/README.md +++ b/README.md @@ -331,8 +331,7 @@ Move this repository's CodeBoarding sync to save_baseline_to: baseline_branch. 2. Delete the generated files under .codeboarding/ from the default branch, keeping the user configuration: .codeboarding/.codeboardingignore, .codeboarding/health/health_config.json and .codeboarding/health/.healthignore. - Reviews prefer a baseline committed on the branch, so a stale one left there - would keep being used. + Reviews read the baseline branch first, so this is cleanup, not a requirement. 3. Remove any .gitattributes lines that mark .codeboarding/ files as linguist-generated, if nothing else is left under .codeboarding/ for them. 4. Open a pull request with these changes. After it merges, close any open diff --git a/action.yml b/action.yml index 171c267..78c0a7c 100644 --- a/action.yml +++ b/action.yml @@ -256,7 +256,7 @@ runs: PULL_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} REPOSITORY: ${{ github.repository }} - run: "$GITHUB_ACTION_PATH/scripts/action/guard.sh" + run: "$GITHUB_ACTION_PATH/scripts/action/resolve-github-event.sh" - name: Acknowledge review command if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.event == 'issue_comment' @@ -357,7 +357,7 @@ runs: fi exit 1 - - name: Post review progress + - name: Update review progress if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 @@ -413,7 +413,7 @@ runs: ACTION_PATH: ${{ github.action_path }} run: "$GITHUB_ACTION_PATH/scripts/action/configure-auth.sh" - - name: Resolve analysis identity + - name: Resolve analysis cache keys id: state if: steps.guard.outputs.skip != 'true' continue-on-error: true @@ -429,7 +429,7 @@ runs: MODEL: ${{ inputs.model }} AGENT_MODEL_INPUT: ${{ inputs.agent_model }} PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} - run: "$GITHUB_ACTION_PATH/scripts/action/state-names.sh" + run: "$GITHUB_ACTION_PATH/scripts/action/resolve-cache-keys.sh" # Both lookups are best effort. Without them the run derives the base from # the committed baseline and the head from the base, which is what every run @@ -464,13 +464,12 @@ runs: DEST: ${{ runner.temp }}/cb-state/${{ github.action }}/warmstart run: "$GITHUB_ACTION_PATH/scripts/action/fetch-state.sh" - - name: Analyze baseline + - name: Generate sync baseline id: sync_analyze if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' shell: bash env: ACTION_PATH: ${{ github.action_path }} - ANALYSIS_KIND: sync CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target STAGE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/out FORCE_FULL: ${{ inputs.force_full }} @@ -487,7 +486,7 @@ runs: MODEL: ${{ inputs.model }} AGENT_MODEL_INPUT: ${{ inputs.agent_model }} PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} - run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/analyze.sh"' + run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/sync-generate-baseline.sh"' - name: Deliver baseline id: sync_commit @@ -559,26 +558,23 @@ runs: PR_URL: ${{ steps.sync_commit.outputs.sync_pr_url }} run: "$GITHUB_ACTION_PATH/scripts/action/sync-summary.sh" - - name: Analyze pull request - id: review_analyze + - name: Generate review baseline + id: review_baseline if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' shell: bash env: ACTION_PATH: ${{ github.action_path }} - ANALYSIS_KIND: review CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target REVIEW_BASE_SHA: ${{ steps.guard.outputs.merge_base_sha }} - REVIEW_HEAD_SHA: ${{ steps.guard.outputs.head_sha }} REVIEW_BASE_REPO: ${{ steps.guard.outputs.base_repo }} - PR_NUMBER: ${{ steps.guard.outputs.pr_number }} BASE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/base - WARMSTART_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/warmstart RENEW_BASE: ${{ steps.fetch_base.outputs.renew }} - # Lets a branch without a usable committed baseline catch up from a saved analysis. + BASELINE_BRANCH: ${{ inputs.baseline_branch }} + # Lets a merge base without a saved analysis catch up from an ancestor's. 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. + # For the progress comment while a base is built from scratch. PROGRESS_HEADER: ${{ steps.guard.outputs.comment_id }} - BASELINE_BRANCH: ${{ inputs.baseline_branch }} + PR_NUMBER: ${{ steps.guard.outputs.pr_number }} BASE_REF: ${{ steps.guard.outputs.base_ref }} REPOSITORY: ${{ github.repository }} GH_HOST: ${{ github.server_url }} @@ -591,7 +587,28 @@ runs: MODEL: ${{ inputs.model }} AGENT_MODEL_INPUT: ${{ inputs.agent_model }} PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} - run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/analyze.sh"' + run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/review-generate-baseline.sh"' + + - name: Analyze pull request + id: review_analyze + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target + BASE_ANALYSIS_PATH: ${{ steps.review_baseline.outputs.base_analysis_path }} + WARMSTART_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/warmstart + REVIEW_BASE_SHA: ${{ steps.guard.outputs.merge_base_sha }} + REVIEW_HEAD_SHA: ${{ steps.guard.outputs.head_sha }} + PR_NUMBER: ${{ steps.guard.outputs.pr_number }} + STAGE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/out + ENGINE_VERSION: ${{ steps.state.outputs.engine_version }} + CFG_HASH: ${{ steps.state.outputs.cfg_hash }} + DEPTH_CAP: ${{ inputs.depth_cap }} + MODEL: ${{ inputs.model }} + AGENT_MODEL_INPUT: ${{ inputs.agent_model }} + PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} + run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/review-analyze-head.sh"' # Published before the review is rendered: the analysis is the expensive part, # so a later rendering or posting failure must not throw it away. Only the @@ -611,7 +628,7 @@ runs: # never be what they read. - name: Publish base analysis id: publish_base - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_analyze.outputs.publish_base == 'true' && steps.guard.outputs.is_fork != 'true' && steps.state.outputs.base_name != '' && github.server_url == 'https://github.com' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_baseline.outputs.publish_base == 'true' && steps.guard.outputs.is_fork != 'true' && steps.state.outputs.base_name != '' && github.server_url == 'https://github.com' continue-on-error: true uses: actions/upload-artifact@v4 with: @@ -632,7 +649,7 @@ runs: shell: bash env: ACTION_PATH: ${{ github.action_path }} - BASE_ANALYSIS_PATH: ${{ steps.review_analyze.outputs.base_analysis_path }} + BASE_ANALYSIS_PATH: ${{ steps.review_baseline.outputs.base_analysis_path }} HEAD_ANALYSIS_PATH: ${{ steps.review_analyze.outputs.analysis_path }} run: "$GITHUB_ACTION_PATH/scripts/action/render-review.sh" @@ -644,7 +661,7 @@ runs: ANALYSIS_PATH: ${{ steps.review_analyze.outputs.analysis_path }} BASE_ARTIFACT_NAME: ${{ steps.state.outputs.base_name }} BASE_ARTIFACT_ID: ${{ steps.publish_base.outputs.artifact-id || steps.fetch_base.outputs.artifact_id }} - BASE_ANALYSIS_PATH: ${{ steps.review_analyze.outputs.base_analysis_path }} + BASE_ANALYSIS_PATH: ${{ steps.review_baseline.outputs.base_analysis_path }} INLINE_BASE: ${{ steps.publish_base.outputs.artifact-id == '' && steps.fetch_base.outputs.artifact_id == '' }} ANALYSIS_MODE: ${{ steps.review_analyze.outputs.analysis_mode }} BASE_SHA: ${{ steps.guard.outputs.base_sha }} @@ -655,8 +672,6 @@ runs: SEED_SOURCE: ${{ steps.review_analyze.outputs.seed_source }} CHAIN_DEPTH: ${{ steps.review_analyze.outputs.chain_depth }} ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }} - BASE_ANALYSIS_METHOD: ${{ steps.review_analyze.outputs.base_analysis_method }} - BASE_ANALYSIS_REASON: ${{ steps.review_analyze.outputs.base_analysis_reason }} run: "$GITHUB_ACTION_PATH/scripts/action/build-review-artifact.sh" - name: Upload review artifact @@ -728,6 +743,6 @@ runs: message: | ### CodeBoarding review · failed - ${{ steps.review_analyze.outputs.failure_reason }} + ${{ steps.review_baseline.outputs.failure_reason || steps.review_analyze.outputs.failure_reason }} See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index bf801c2..72b7569 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -73,9 +73,7 @@ never fires. | `base_sha` | string | the base branch tip when the event fired — *not* what was compared against | | `kind` | string | always `review`, so a reader can tell this artifact from a base or warm-start bundle | | `analysed_files_changed` | string | analysed files whose content hash differs between base and head; `unknown` when the analyses cannot say | -| `base_analysis_method` | string | how this run obtained the analysis of the merge base: `reused` (the merge base already had one: its saved artifact, or a committed baseline with nothing to catch up), `incremental` (an earlier commit's analysis, committed or saved, updated to the merge base) or `full` (analyzed from scratch in this run). Whichever, the base graph is the merge base's own analysis | -| `base_analysis_reason` | string | the method in words, e.g. `updated the analysis of 9f8e7d6 to a1b2c3d, 4 commits caught up`; where an incremental run started and how far it caught up are detail here, present when known | -| `pr_number`, `mode`, `seed_source`, `chain_depth` | string | provenance; nothing rendering a diagram needs them. `mode` is the head's engine mode; `base_analysis_method` answers for the base | +| `pr_number`, `mode`, `seed_source`, `chain_depth` | string | provenance; nothing rendering a diagram needs them. `mode` is the head's engine mode | **A sync run** publishes the base graph under both the commit it analyzed and the baseline commit it writes on top, because a pull request opened either side of @@ -100,30 +98,35 @@ them: ## How a review resolves its two graphs -**Base**, first match wins: +**Base**, first match wins. This is `review-generate-baseline.sh`, in this order: | Source | Engine cost | |---|---| | 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 (`save_baseline_to: baseline_branch`): its commit for the merge base, else for the nearest of the merge base's last 100 first-parent ancestors, made under this configuration | none for the merge base itself (`reused`), one incremental otherwise (`incremental`) | -| 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` | +| the baseline branch: its entry for the merge base, else for the nearest of the merge base's last 100 first-parent ancestors, made under this configuration | none for the merge base itself, one incremental otherwise | +| the baseline committed on the branch at the merge base, with a compatible depth cap | one incremental | +| the nearest `codeboarding-base--` artifact among the merge base's last 100 first-parent ancestors | one incremental | +| nothing | full analysis, at the configured `depth_cap` | + +The baseline branch comes before the committed baseline because its entries are +pinned to this configuration and a committed baseline is not: one left behind +after a repository moved to the baseline branch must not win. A seed is only +checked before the run (depth cap, and configuration where the source records +it); if Core then asks for a full analysis, the base is analyzed in full rather +than retried from the next source. In practice only a committed baseline can be +refused that way, as it is the one source not pinned to the engine version. A trusted run that computed the base publishes it, so the next pull request forking from that commit gets the first row; that includes a base caught up from -an ancestor. The review metadata reports the -row as `base_analysis_method` (`reused`, `incremental` or `full`) with a -`base_analysis_reason`. The review comment does not: how the base was obtained is -for us, not the reader. While a base is computed, the progress comment says so in -two steps, with the elapsed time and the reason. +an ancestor. Which source won is logged as a notice and not recorded anywhere +else. While a base is built from scratch, the progress comment says so in two +steps: building the base, then analysing the pull request. **Telemetry.** Every engine run carries `CODEBOARDING_RUN_ID=gh---`, the role being `base`, `head` or `sync`, so the engine's own `analysis_started` / `analysis_completed` events (mode, duration, tokens) can be read per run. A review with no `base` events reused its base; `base` events say whether it was caught up -or built in full. Where a caught-up base started from is only in -`base_analysis_reason`. +or built in full. The ancestor lookup walks the merge base's first-parent history, deepening the shallow checkout to 101 commits, then pages through the repository's artifacts @@ -131,9 +134,7 @@ newest first, keeping those named for this configuration and produced by a run o the repository's own code. It stops at the first page holding one of the walked commits (usually the first; at most 50 pages) and takes the nearest commit seen. The merge base's own `.codeboardingignore` and health configuration replace the -seed's. A base caught up this way reports `incremental`, its reason naming the -ancestor and the commits caught up. Sync uses the same lookup when the branch has -no usable committed baseline, so the first sync after the setup pull request +seed's. Sync uses the same sources, in the same order, without the exact artifact, so the first sync after the setup pull request merges catches up from the base that pull request's review saved, instead of analyzing from scratch. @@ -192,15 +193,13 @@ if it moves in that window, the branch can end on an analysis of the older commi Its trailer still names that commit, so no reader takes it for newer, and the run queued for the newer commit replaces it. -**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 +**How a review reads it.** Right after an exact artifact, 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. Only entries made under this run's configuration count: an entry for the merge base itself is reused as is, so nothing else would catch a different engine or -model. An entry for an ancestor is caught up incrementally. Entries found only -under another configuration make a full run's reason `incompatible`. Only then -does it look for ancestor artifacts. +model. An entry for an ancestor is caught up incrementally. Only without a usable +entry does it fall back to a committed baseline, then to 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 diff --git a/scripts/action/analysis-state.sh b/scripts/action/analysis-state.sh new file mode 100644 index 0000000..9904c11 --- /dev/null +++ b/scripts/action/analysis-state.sh @@ -0,0 +1,127 @@ +# Helpers for the directories that hold analysis state. Sourced, not run. + +DEPTH_CAP="${DEPTH_CAP:-2}" +if [[ ! "$DEPTH_CAP" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::depth_cap must be a positive integer." + exit 1 +fi + +# Metadata is only a compatibility check, never the source of configuration. +# Legacy baselines without a configured cap are rebuilt at the requested depth. +depth_cap_from() { + local analysis="$1" + [ -f "$analysis" ] || return 0 + python3 -c 'import json,sys +metadata = json.load(open(sys.argv[1])).get("metadata", {}) +print(metadata.get("depth_cap", ""))' "$analysis" 2>/dev/null || true +} +# Whether $1 holds an analysis this run can continue from. +compatible_state() { + [ "$(depth_cap_from "$1/analysis.json")" = "$DEPTH_CAP" ] +} + +# What the user writes under .codeboarding/ belongs to the commit being analysed, +# not to whichever analysis seeded it. +USER_CONFIG=(.codeboardingignore health/health_config.json health/.healthignore) +keep_user_config() { + local checkout="$1" state="$2" file + mkdir -p "$state" + for file in "${USER_CONFIG[@]}"; do + rm -f "${state:?}/$file" + [ ! -f "$checkout/.codeboarding/$file" ] || { + mkdir -p "$(dirname "$state/$file")" + cp "$checkout/.codeboarding/$file" "$state/$file" + } + done +} +# Replaces $2 with $1, keeping checkout $3's user configuration. +replace_state() { + local from="$1" state="$2" config_from="$3" + rm -rf "$state" + mkdir -p "$(dirname "$state")" + cp -a "$from" "$state" + keep_user_config "$config_from" "$state" +} + +# A depth above 1 also deepens a commit the shallow checkout already holds. +fetch_commit() { + local repository="$1" sha="$2" depth="${3:-1}" + if git -C "$CHECKOUT_DIR" cat-file -e "$sha^{commit}" 2>/dev/null; then + [ "$depth" -gt 1 ] && [ "$(git -C "$CHECKOUT_DIR" rev-parse --is-shallow-repository)" = true ] || return 0 + fi + local auth + auth="$(printf 'x-access-token:%s' "${GIT_TOKEN:-}" | base64 -w0)" + git -C "$CHECKOUT_DIR" -c "http.extraheader=AUTHORIZATION: basic $auth" fetch \ + "${GITHUB_SERVER_URL%/}/${repository}.git" "$sha" --depth="$depth" +} + +# Two independently generated analyses of the same commit need not name the same +# components, so a head that grew from one base cannot be diffed against another. +analysis_digest() { + local analysis="$1" + [ -f "$analysis" ] || return 0 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$analysis" | cut -c1-16 + else + shasum -a 256 "$analysis" | cut -c1-16 + fi +} + +# Records which state this analysis grew from, so a later run can tell whether +# it may continue from it. +origin_field() { + local state="$1" field="$2" + [ -f "$state/origin.json" ] || return 0 + python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' \ + "$state/origin.json" "$field" 2>/dev/null || true +} +write_origin() { + local state="$1" + python3 -c 'import json,os,sys +json.dump({ + "schema": 1, + "pr_number": os.environ.get("PR_NUMBER", ""), + "merge_base_sha": os.environ.get("REVIEW_BASE_SHA", ""), + "head_sha": os.environ.get("REVIEW_HEAD_SHA", ""), + "engine_version": os.environ.get("ENGINE_VERSION", ""), + "cfg_hash": os.environ.get("CFG_HASH", ""), + "seed_source": sys.argv[2], + "chain_depth": int(sys.argv[3]), + "base_digest": sys.argv[4], +}, open(sys.argv[1], "w"), indent=2)' "$state/origin.json" "$2" "$3" "$4" +} + +# Lays out what the upload steps publish. The warm-start bundle is a complete +# working directory rather than the pickle alone, so restoring it needs one +# lookup and no correlating of two artifacts. +stage() { + local state="$1" kind="$2" + [ -n "${STAGE_DIR:-}" ] || return 0 + rm -rf "${STAGE_DIR:?}/$kind" + mkdir -p "$STAGE_DIR" + cp -a "$state" "$STAGE_DIR/$kind" + # Run logs and lock files are the engine's scratch, not analysis state. Nobody + # inflates them, and every fetch pays for them: they were a fifth of a bundle. + # Health config stays, because a run seeded from this bundle reads it. + rm -rf "$STAGE_DIR/$kind/logs" + find "$STAGE_DIR/$kind" -name '*.lock' -delete + # Say what this bundle is. Without it a base bundle is an analysis.json and + # nothing else, which unpacks exactly like a head artifact and would be + # rendered as one by a reader that fetched the wrong name. Written at staging + # time rather than carried in the state directory, so a bundle can never + # inherit the label of the one it was seeded from. + python3 -c 'import json,os,sys +kind = sys.argv[2] +marker = { + "kind": kind, + "engine_version": os.environ.get("ENGINE_VERSION", ""), + "cfg_hash": os.environ.get("CFG_HASH", ""), + "merge_base_sha": os.environ.get("REVIEW_BASE_SHA", ""), +} +# A base describes one commit and is shared by every pull request that forks +# there, so the run that happened to compute it is not part of its identity. +if kind == "warmstart": + marker["pr_number"] = os.environ.get("PR_NUMBER", "") + marker["head_sha"] = os.environ.get("REVIEW_HEAD_SHA", "") +json.dump(marker, open(sys.argv[1], "w"), indent=2)' "$STAGE_DIR/$kind/metadata.json" "$kind" +} diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index fc6b1ce..37088c9 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -1,557 +1,43 @@ #!/usr/bin/env bash -# Runs incremental/full Core analysis and outputs the selected analysis paths and mode. +# Analyzes one checkout into a state directory: incrementally when the state holds +# a compatible analysis to continue from, in full otherwise or when Core asks for it. +# +# Usage: analyze.sh base|head|sync +# Prints analysis_mode= and analysis_path= on stdout; everything else goes to stderr. set -euo pipefail -DEPTH_CAP="${DEPTH_CAP:-2}" -if [[ ! "$DEPTH_CAP" =~ ^[1-9][0-9]*$ ]]; then - echo "::error::depth_cap must be a positive integer." - exit 1 -fi -parse_output() { - local output="$1" +source "$(dirname "$0")/analysis-state.sh" +role="$1" checkout="$2" state="$3" + +# Tags Core's own telemetry with this run and the analysis it belongs to, so its +# events can be read per run: a review with no base events reused its base, one +# with base events caught it up or built it in full. +run_id="" +[ -z "${GITHUB_RUN_ID:-}" ] || run_id="gh-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-$role" + +ANALYSIS_MODE="" REQUIRES_FULL="" ANALYSIS_PATH="" +run_core() { + local output + output="$(CODEBOARDING_RUN_ID="$run_id" python3 "$ACTION_PATH/scripts/analyze_repository.py" "$@" \ + --checkout "$checkout" --output-dir "$state")" ANALYSIS_MODE="$(awk -F= '$1 == "analysis_mode" {print $2; exit}' <<< "$output")" REQUIRES_FULL="$(awk -F= '$1 == "requires_full_analysis" {print $2; exit}' <<< "$output")" ANALYSIS_PATH="$(awk -F= '$1 == "analysis_path" {print substr($0, index($0, "=") + 1); exit}' <<< "$output")" } -# Tags the engine's own telemetry with this run and the analysis it belongs to -# (base, head or sync), so its events can be read per run: a review with no base -# events reused its base, one with base events caught it up or built it in full. -ENGINE_ROLE="" -engine_run_id() { - [ -z "${GITHUB_RUN_ID:-}" ] || printf 'gh-%s-%s-%s' "$GITHUB_RUN_ID" "${GITHUB_RUN_ATTEMPT:-1}" "$ENGINE_ROLE" -} -incremental() { - local checkout="$1" output_dir="$2" output - output="$(CODEBOARDING_RUN_ID="$(engine_run_id)" python3 "$ACTION_PATH/scripts/analyze_repository.py" incremental \ - --checkout "$checkout" --output-dir "$output_dir")" - parse_output "$output" + +mkdir -p "$state" +if compatible_state "$state"; then + run_core incremental if [ "$ANALYSIS_MODE" != incremental ] || { [ "$REQUIRES_FULL" != true ] && [ ! -f "$ANALYSIS_PATH" ]; }; then - echo "::error::Invalid incremental-analysis result." + echo "::error::Invalid incremental-analysis result." >&2 exit 1 fi -} -full() { - local checkout="$1" output_dir="$2" depth="$3" output - output="$(CODEBOARDING_RUN_ID="$(engine_run_id)" python3 "$ACTION_PATH/scripts/analyze_repository.py" full \ - --checkout "$checkout" --output-dir "$output_dir" --depth-cap "$depth")" - parse_output "$output" + [ "$REQUIRES_FULL" != true ] || echo "::notice::Core cannot update the $role analysis incrementally; analyzing in full." >&2 +fi +if [ "$ANALYSIS_MODE" != incremental ] || [ "$REQUIRES_FULL" = true ]; then + run_core full --depth-cap "$DEPTH_CAP" if [ "$ANALYSIS_MODE" != full ] || [ ! -f "$ANALYSIS_PATH" ]; then - echo "::error::Invalid full-analysis result." + echo "::error::Invalid full-analysis result." >&2 exit 1 fi -} -# Metadata is only a compatibility check, never the source of configuration. -# Legacy baselines without a configured cap are rebuilt at the requested depth. -depth_cap_from() { - local analysis="$1" - [ -f "$analysis" ] || return 0 - python3 -c 'import json,sys -metadata = json.load(open(sys.argv[1])).get("metadata", {}) -print(metadata.get("depth_cap", ""))' "$analysis" 2>/dev/null || true -} - -seed_state() { - local checkout="$1" state="$2" - mkdir -p "$state" - [ ! -d "$checkout/.codeboarding" ] || cp -a "$checkout/.codeboarding/." "$state/" -} - -# Records which state this analysis grew from, so a later run can report its -# provenance and the review artifact can answer "which base did we use". -origin_field() { - local state="$1" field="$2" - [ -f "$state/origin.json" ] || return 0 - python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' \ - "$state/origin.json" "$field" 2>/dev/null || true -} -write_origin() { - local state="$1" - python3 -c 'import json,os,sys -json.dump({ - "schema": 1, - "pr_number": os.environ.get("PR_NUMBER", ""), - "merge_base_sha": os.environ.get("REVIEW_BASE_SHA", ""), - "head_sha": os.environ.get("REVIEW_HEAD_SHA", ""), - "engine_version": os.environ.get("ENGINE_VERSION", ""), - "cfg_hash": os.environ.get("CFG_HASH", ""), - "seed_source": sys.argv[2], - "chain_depth": int(sys.argv[3]), - "base_digest": sys.argv[4], -}, open(sys.argv[1], "w"), indent=2)' "$state/origin.json" "$2" "$3" "$4" -} - -# Two independently generated analyses of the same commit need not name the same -# components, so a head that grew from one base cannot be diffed against another. -analysis_digest() { - local analysis="$1" - [ -f "$analysis" ] || return 0 - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$analysis" | cut -c1-16 - else - shasum -a 256 "$analysis" | cut -c1-16 - fi -} - -# Lays out what the upload steps publish. The warm-start bundle is a complete -# working directory rather than the pickle alone, so restoring it needs one -# lookup and no correlating of two artifacts. -stage() { - local state="$1" kind="$2" - [ -n "${STAGE_DIR:-}" ] || return 0 - rm -rf "${STAGE_DIR:?}/$kind" - mkdir -p "$STAGE_DIR" - cp -a "$state" "$STAGE_DIR/$kind" - # Run logs and lock files are the engine's scratch, not analysis state. Nobody - # inflates them, and every fetch pays for them: they were a fifth of a bundle. - # Health config stays, because a run seeded from this bundle reads it. - rm -rf "$STAGE_DIR/$kind/logs" - find "$STAGE_DIR/$kind" -name '*.lock' -delete - # Say what this bundle is. Without it a base bundle is an analysis.json and - # nothing else, which unpacks exactly like a head artifact and would be - # rendered as one by a reader that fetched the wrong name. Written at staging - # time rather than carried in the state directory, so a bundle can never - # inherit the label of the one it was seeded from. - python3 -c 'import json,os,sys -kind = sys.argv[2] -marker = { - "kind": kind, - "engine_version": os.environ.get("ENGINE_VERSION", ""), - "cfg_hash": os.environ.get("CFG_HASH", ""), - "merge_base_sha": os.environ.get("REVIEW_BASE_SHA", ""), -} -# A base describes one commit and is shared by every pull request that forks -# there, so the run that happened to compute it is not part of its identity. -if kind == "warmstart": - marker["pr_number"] = os.environ.get("PR_NUMBER", "") - marker["head_sha"] = os.environ.get("REVIEW_HEAD_SHA", "") -json.dump(marker, open(sys.argv[1], "w"), indent=2)' "$STAGE_DIR/$kind/metadata.json" "$kind" -} - -analyze_sync() { - local work="$RUNNER_TEMP/codeboarding-sync" state="$RUNNER_TEMP/codeboarding-sync/analysis" - rm -rf "$work" - seed_state "$CHECKOUT_DIR" "$state" - ENGINE_ROLE=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 a usable - # one, the run below seeds from a saved ancestor or analyzes in full, and - # delivery creates the branch again. - if [ "${SAVE_BASELINE_TO:-}" = baseline_branch ]; then - local tip_entry - tip_entry="$(baseline_branch_index "${REPOSITORY:-}" | awk '{print $1, $3; exit}')" - if [ -z "$tip_entry" ]; then - echo "::notice::$BASELINE_BRANCH has no analysis to continue from; this sync creates it." - elif ! usable_config "${tip_entry#* }"; then - echo "::notice::The analysis on $BASELINE_BRANCH was made with another engine version or settings; not continuing from it." - elif ! restore_baseline_branch "${REPOSITORY:-}" "${tip_entry%% *}" "$state" "$CHECKOUT_DIR"; then - echo "::notice::Could not read the analysis on $BASELINE_BRANCH; not continuing from it." - fi - fi - if [ "$(depth_cap_from "$state/analysis.json")" = "$DEPTH_CAP" ]; then - incremental "$CHECKOUT_DIR" "$state" - fi - # A branch with no usable committed baseline, such as the first sync after the - # setup pull request merged, catches up from that pull request's saved base. - if [ "$REQUIRES_FULL" = true ] && - seed_from_ancestor "${REPOSITORY:-}" "$(git -C "$CHECKOUT_DIR" rev-parse HEAD 2>/dev/null || true)" "$state" true "$CHECKOUT_DIR"; then - incremental "$CHECKOUT_DIR" "$state" - [ "$REQUIRES_FULL" = true ] || - echo "::notice::Caught up from the saved analysis of $ANCESTOR_SHA instead of analyzing from scratch." - fi - fi - unset GIT_TOKEN - if [ "$REQUIRES_FULL" = true ]; then - full "$CHECKOUT_DIR" "$state" "$DEPTH_CAP" - fi - # Sync already computes the graph every review of this branch compares against, - # so publish it instead of making the first pull request recompute it. - stage "$state" base - printf 'analysis_mode=%s\nanalysis_path=%s\nanalysis_dir=%s\n' \ - "$ANALYSIS_MODE" "$ANALYSIS_PATH" "$state" >> "$GITHUB_OUTPUT" -} - -# How far below the merge base this run looks for the commit a saved analysis -# describes, and for a saved ancestor to catch up from. Past it, a catch-up count -# is unknown and no ancestor is used. It is also the fetch depth. -CATCHUP_BOUND="${CATCHUP_BOUND:-100}" - -# A depth above 1 also deepens a commit the shallow checkout already holds. -fetch_commit() { - local repository="$1" sha="$2" depth="${3:-1}" - if git -C "$CHECKOUT_DIR" cat-file -e "$sha^{commit}" 2>/dev/null; then - [ "$depth" -gt 1 ] && [ "$(git -C "$CHECKOUT_DIR" rev-parse --is-shallow-repository)" = true ] || return 0 - fi - local auth - auth="$(printf 'x-access-token:%s' "${GIT_TOKEN:-}" | base64 -w0)" - git -C "$CHECKOUT_DIR" -c "http.extraheader=AUTHORIZATION: basic $auth" fetch \ - "${GITHUB_SERVER_URL%/}/${repository}.git" "$sha" --depth="$depth" -} - -# The commit a baseline committed at $1 describes, or empty when that cannot be -# told. Sync writes the baseline in a commit of its own on top of the analysed -# commit, pushed straight to the branch or merged in from its pull request's -# branch. A writer sync did not make (a squash, a rebase, a hand edit) says -# nothing about which commit was analysed. -baseline_commit() { - local writer - writer="$(baseline_writer "$1")" - if [ -n "$writer" ] && git -C "$CHECKOUT_DIR" rev-parse -q --verify "$writer^2" >/dev/null; then - writer="$(baseline_writer "$writer^2")" - fi - [ -n "$writer" ] && is_sync_commit "$writer" || return 0 - git -C "$CHECKOUT_DIR" rev-parse -q --verify "$writer^1" || true -} -# The newest first-parent commit at or below $1 that wrote the baseline. -baseline_writer() { - local writer shallow - writer="$(git -C "$CHECKOUT_DIR" log --first-parent -1 --format=%H "$1" -- .codeboarding/analysis.json 2>/dev/null || true)" - [ -n "$writer" ] || return 0 - # A shallow boundary looks like it added every file, so it proves nothing. - shallow="$(git -C "$CHECKOUT_DIR" rev-parse --git-path shallow)" - case "$shallow" in /*) ;; *) shallow="$CHECKOUT_DIR/$shallow" ;; esac - if [ -f "$shallow" ] && grep -qx "$writer" "$shallow"; then - return 0 - fi - echo "$writer" -} -is_sync_commit() { - case "$(git -C "$CHECKOUT_DIR" log -1 --format=%ce "$1")" in - 'codeboarding-review[bot]@users.noreply.github.com' | 'codeboarding[bot]@users.noreply.github.com') ;; - *) return 1 ;; - esac - [ -z "$(code_paths_changed "$1")" ] -} -# Against the first parent, so a merge counts as the change it brought in. The -# baseline and the attributes line sync may add are not code. -code_paths_changed() { - local exclude=(-- . ':(exclude).codeboarding' ':(exclude).gitattributes') - if git -C "$CHECKOUT_DIR" rev-parse -q --verify "$1^1" >/dev/null; then - git -C "$CHECKOUT_DIR" diff --name-only "$1^1" "$1" "${exclude[@]}" 2>/dev/null || true - else - git -C "$CHECKOUT_DIR" diff-tree --root --no-commit-id --name-only -r "$1" "${exclude[@]}" 2>/dev/null || true - fi -} -# First-parent commits from $1 to $2 that change code: a sync commit changes -# nothing the analysis reads, so it is nothing to catch up. -catchup_count() { - local from="$1" to="$2" commit count=0 - for commit in $(git -C "$CHECKOUT_DIR" rev-list --first-parent "$from..$to" 2>/dev/null); do - [ -z "$(code_paths_changed "$commit")" ] || count=$(( count + 1 )) - done - echo "$count" -} - -# Replaces $3 with the saved analysis of the nearest first-parent ancestor of $2 -# (or of $2 itself when $4 is true), keeping the user-authored configuration of -# checkout $5. Sets ANCESTOR_SHA, or ANCESTOR_REASON=incompatible when the only -# candidate was saved under another configuration or depth. -ANCESTOR_SHA="" ANCESTOR_REASON="" -seed_from_ancestor() { - local repository="$1" tip="$2" state="$3" include_tip="$4" config_from="$5" found dest="$RUNNER_TEMP/codeboarding-ancestor" - ANCESTOR_SHA="" ANCESTOR_REASON="" - [ "${ANCESTOR_LOOKUP:-false}" = true ] && [ -n "${CFG_HASH:-}" ] && [ -n "${REPOSITORY:-}" ] && [ -n "$tip" ] || return 1 - fetch_commit "$repository" "$tip" "$(( CATCHUP_BOUND + 1 ))" || true - found="$(GH_TOKEN="${GIT_TOKEN:-}" GH_ENTERPRISE_TOKEN="${GIT_TOKEN:-}" TIP_SHA="$tip" DEST="$dest" \ - INCLUDE_TIP="$include_tip" CATCHUP_BOUND="$CATCHUP_BOUND" \ - "$ACTION_PATH/scripts/action/find-ancestor-base.sh" || true)" - ANCESTOR_SHA="$(awk -F= '$1 == "ancestor_sha" {print $2; exit}' <<< "$found")" - if [ -z "$ANCESTOR_SHA" ]; then - ! grep -qx 'other_cfg_at_tip=true' <<< "$found" || ANCESTOR_REASON=incompatible - return 1 - fi - if [ "$(depth_cap_from "$dest/analysis.json")" != "$DEPTH_CAP" ]; then - ANCESTOR_SHA="" ANCESTOR_REASON=incompatible - return 1 - fi - rm -rf "$state" - cp -a "$dest" "$state" - keep_user_config "$config_from" "$state" -} -# What the user writes under .codeboarding/ belongs to the commit being analysed, -# not to whichever analysis seeded it. -USER_CONFIG=(.codeboardingignore health/health_config.json health/.healthignore) -keep_user_config() { - local checkout="$1" state="$2" file - for file in "${USER_CONFIG[@]}"; do - rm -f "${state:?}/$file" - [ ! -f "$checkout/.codeboarding/$file" ] || { - mkdir -p "$(dirname "$state/$file")" - cp "$checkout/.codeboarding/$file" "$state/$file" - } - done -} - -# save_baseline_to: baseline_branch keeps one commit per sync on BASELINE_BRANCH, each with -# trailers naming the commit it analysed (CodeBoarding-Source) and the -# configuration it ran under (CodeBoarding-Config). Lists them as -# " ", newest first, at most -# BASELINE_BRANCH_DEPTH of them; an entry without a config has none to compare. -# Fetched without blobs into a scratch repository: the lookup needs messages, and -# a hundred pickles would cost more than it saves. -BASELINE_BRANCH_DEPTH="${BASELINE_BRANCH_DEPTH:-100}" -baseline_branch_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_BRANCH_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) %(trailers:key=CodeBoarding-Config,valueonly,separator=%x20)' FETCH_HEAD | - awk 'NF >= 2 {print $1, $2, (NF >= 3 ? $3 : "-")}' -} -# An entry is only as good as the configuration it ran under: the artifact name -# pins it for saved analyses, the trailer does here. Without a configuration hash -# this run cannot tell, so it uses none, as it reuses no artifact either. -usable_config() { - [ -n "${CFG_HASH:-}" ] && [ "$1" = "$CFG_HASH" ] -} -# Replaces the generated state in $3 with a baseline-branch commit's, keeping the -# user configuration of checkout $4. source.json is provenance, not engine state. -restore_baseline_branch() { - local repository="$1" commit="$2" state="$3" config_from="$4" scratch="$RUNNER_TEMP/codeboarding-baseline-restore" - fetch_commit "$repository" "$commit" || return 1 - rm -rf "$scratch" - mkdir -p "$scratch" - git -C "$CHECKOUT_DIR" archive "$commit" .codeboarding | tar -x -C "$scratch" || return 1 - [ -f "$scratch/.codeboarding/analysis.json" ] || return 1 - rm -f "$scratch/.codeboarding/source.json" - rm -rf "$state" - cp -a "$scratch/.codeboarding" "$state" - keep_user_config "$config_from" "$state" -} -# Seeds $3 from the baseline branch's entry for $2, or for its nearest first-parent -# ancestor that has one under this configuration, keeping checkout $4's user -# configuration. Sets BRANCH_SOURCE to the commit it describes and BRANCH_DISTANCE -# to how far below $2 that is; BRANCH_REASON=incompatible when the only entries -# found were made under another configuration. -BRANCH_SOURCE="" BRANCH_DISTANCE="" BRANCH_REASON="" -seed_from_baseline_branch() { - local repository="$1" tip="$2" state="$3" config_from="$4" index commit line entry="" distance=0 - BRANCH_SOURCE="" BRANCH_DISTANCE="" BRANCH_REASON="" - index="$(baseline_branch_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 - while read -r line; do - if usable_config "${line#* }"; then - entry="${line%% *}" - break - fi - BRANCH_REASON=incompatible - done < <(awk -v source="$commit" '$2 == source {print $1, $3}' <<< "$index") - [ -z "$entry" ] || break - distance=$(( distance + 1 )) - done - [ -n "$entry" ] && restore_baseline_branch "$repository" "$entry" "$state" "$config_from" || return 1 - BRANCH_SOURCE="$commit" BRANCH_DISTANCE="$distance" BRANCH_REASON="" -} - -# 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="" -PROGRESS_STOP="${RUNNER_TEMP:-}/codeboarding-progress-stop" -progress() { - GH_TOKEN="${GIT_TOKEN:-}" GH_ENTERPRISE_TOKEN="${GIT_TOKEN:-}" FULL_CAUSE="$full_cause" \ - PROGRESS_STOP_FILE="$PROGRESS_STOP" "$ACTION_PATH/scripts/action/post-progress.sh" "$@" >/dev/null 2>&1 || true -} -progress_start() { - local started="$1" - rm -f "$PROGRESS_STOP" - progress base 0 - ( while sleep 60 && [ ! -e "$PROGRESS_STOP" ]; do progress base "$(( $(date +%s) - started ))"; done ) >/dev/null 2>&1 & - PROGRESS_PID=$! -} -# Stops the ticker and waits for it, so no "still running" edit can land after the -# next one. Only its sleep is killed: an edit in flight finishes or, having seen -# the stop file, never starts. -progress_stop() { - [ -n "$PROGRESS_PID" ] || return 0 - touch "$PROGRESS_STOP" - pkill -x sleep -P "$PROGRESS_PID" 2>/dev/null || true - wait "$PROGRESS_PID" 2>/dev/null || true - PROGRESS_PID="" -} - -# The artifact name pins configuration; verify the stored cap and lineage too. -warmstart_usable() { - local base_analysis="$1" bundle_cap - [ -f "${WARMSTART_DIR:-}/analysis.json" ] || return 1 - bundle_cap="$(depth_cap_from "$WARMSTART_DIR/analysis.json")" - if [ "$bundle_cap" != "$DEPTH_CAP" ]; then - echo "::notice::Analysis depth changed since the last run; re-seeding from the base analysis." - return 1 - fi - # A head that grew from one base graph cannot be diffed against another: two - # runs of the engine over one commit need not name components identically, so - # keeping it would report additions and removals for code nobody touched. - if [ "$(origin_field "$WARMSTART_DIR" base_digest)" != "$(analysis_digest "$base_analysis")" ]; then - echo "::notice::The base analysis is not the one this pull request's stored analysis grew from; re-seeding from it." - return 1 - fi -} - -analyze_review() { - local work="$RUNNER_TEMP/codeboarding-review" - local base_checkout="$work/base" base_state="$work/base-state" head_state="$work/head-state" - rm -rf "$work" - mkdir -p "$work" - - # A published base graph is this merge base's own analysis, named for it, so it - # 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 analysis was obtained, for the review artifact: reused, - # incremental or full. - ENGINE_ROLE=base - local base_started base_method=reused base_published=true full_cause="" base_from_sha="" catchup_commits="" - 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/" - else - base_published=false - # A bundle under this exact name that the run cannot use was made with another cap. - [ ! -f "${BASE_DIR:-}/analysis.json" ] || full_cause=incompatible - fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" - git -C "$CHECKOUT_DIR" worktree add --detach "$base_checkout" "$REVIEW_BASE_SHA" >/dev/null - seed_state "$base_checkout" "$base_state" - REQUIRES_FULL=true - if [ "$(depth_cap_from "$base_state/analysis.json")" = "$DEPTH_CAP" ]; then - incremental "$base_checkout" "$base_state" - if [ "$REQUIRES_FULL" = true ]; then - full_cause=incompatible - else - fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$(( CATCHUP_BOUND + 1 ))" || true - base_from_sha="$(baseline_commit "$REVIEW_BASE_SHA")" - [ -z "$base_from_sha" ] || catchup_commits="$(catchup_count "$base_from_sha" "$REVIEW_BASE_SHA")" - # Nothing to catch up means the committed analysis already describes this code. - [ "$catchup_commits" = 0 ] || base_method=incremental - fi - elif [ -f "$base_state/analysis.json" ]; then - full_cause=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" "$base_checkout"; then - if [ "$(depth_cap_from "$base_state/analysis.json")" != "$DEPTH_CAP" ]; then - full_cause=incompatible - elif [ "$BRANCH_DISTANCE" -eq 0 ]; then - REQUIRES_FULL=false base_method=reused - else - incremental "$base_checkout" "$base_state" - if [ "$REQUIRES_FULL" = true ]; then - full_cause=incompatible - else - base_method=incremental base_from_sha="$BRANCH_SOURCE" - catchup_commits="$(catchup_count "$BRANCH_SOURCE" "$REVIEW_BASE_SHA")" - fi - fi - elif [ "$BRANCH_REASON" = incompatible ]; then - full_cause=incompatible - 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 - incremental "$base_checkout" "$base_state" - if [ "$REQUIRES_FULL" = true ]; then - full_cause=incompatible - else - base_method=incremental base_from_sha="$ANCESTOR_SHA" - catchup_commits="$(catchup_count "$ANCESTOR_SHA" "$REVIEW_BASE_SHA")" - fi - fi - if [ "$REQUIRES_FULL" = true ]; then - base_method=full - full_cause="${full_cause:-${ANCESTOR_REASON:-no_baseline}}" - trap progress_stop EXIT - progress_start "$base_started" - full "$base_checkout" "$base_state" "$DEPTH_CAP" - progress_stop - progress head "$(( $(date +%s) - base_started ))" - fi - fi - unset GIT_TOKEN - - local base_analysis="$base_state/analysis.json" - [ -f "$base_analysis" ] || { echo "::error::Review baseline analysis is missing."; exit 1; } - - # Seed the head from this pull request's own last analysis when there is one, - # so the run only covers commits pushed since it. - local seed_source=base chain_depth=1 previous_depth - if warmstart_usable "$base_analysis"; then - cp -a "$WARMSTART_DIR" "$head_state" - seed_source=pr-chain - previous_depth="$(origin_field "$head_state" chain_depth)" - case "$previous_depth" in - ''|*[!0-9]*) previous_depth=0 ;; - esac - chain_depth=$(( previous_depth + 1 )) - else - cp -a "$base_state" "$head_state" - fi - rm -f "$head_state/origin.json" - - ENGINE_ROLE=head - incremental "$CHECKOUT_DIR" "$head_state" - if [ "$REQUIRES_FULL" = true ]; then - full "$CHECKOUT_DIR" "$head_state" "$DEPTH_CAP" - fi - - write_origin "$head_state" "$seed_source" "$chain_depth" "$(analysis_digest "$base_analysis")" - stage "$head_state" warmstart - # Republishing a base graph that was only read back would store the same bytes - # 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. A base read from the baseline - # branch is published too: no artifact holds it yet. - local publish_base=false - if [ "$base_published" != true ] || [ "${RENEW_BASE:-false}" = true ]; then - stage "$base_state" base - publish_base=true - fi - - printf 'analysis_mode=%s\nanalysis_path=%s\nbase_analysis_path=%s\nseed_source=%s\nchain_depth=%s\npublish_base=%s\n' \ - "$ANALYSIS_MODE" "$ANALYSIS_PATH" "$base_analysis" "$seed_source" "$chain_depth" "$publish_base" >> "$GITHUB_OUTPUT" - printf 'base_analysis_method=%s\nbase_analysis_reason=%s\n' \ - "$base_method" "$(base_reason "$base_method" "$full_cause" "$base_from_sha" "$catchup_commits")" >> "$GITHUB_OUTPUT" -} - -# Why the base analysis was obtained the way it was, in words. Whichever way, the -# result is an analysis of the merge base itself, so where an incremental run -# started is detail for this sentence, not a field of its own. -base_reason() { - local method="$1" cause="$2" from="$3" count="$4" base="${REVIEW_BASE_SHA:0:7}" reason - case "$method" in - reused) reason="$base already has a saved analysis" ;; - incremental) - if [ -z "$from" ]; then - reason="updated an existing analysis to $base" - else - reason="updated the analysis of ${from:0:7} to $base" - case "$count" in - '' | 0) ;; - 1) reason="$reason, 1 commit caught up" ;; - *) reason="$reason, $count commits caught up" ;; - esac - fi - ;; - full) - if [ "$cause" = incompatible ]; then - reason="the existing analysis was incompatible or could not be updated incrementally" - else - reason="no usable analysis was available" - fi - ;; - esac - echo "$reason" -} - -case "$ANALYSIS_KIND" in - sync) analyze_sync ;; - review) analyze_review ;; - *) echo "::error::Unknown analysis kind: $ANALYSIS_KIND"; exit 1 ;; -esac +fi +printf 'analysis_mode=%s\nanalysis_path=%s\n' "$ANALYSIS_MODE" "$ANALYSIS_PATH" diff --git a/scripts/action/build-review-artifact.sh b/scripts/action/build-review-artifact.sh index b8ce994..acdf52a 100755 --- a/scripts/action/build-review-artifact.sh +++ b/scripts/action/build-review-artifact.sh @@ -23,8 +23,7 @@ HEALTH_REPORT="$(dirname "$ANALYSIS_PATH")/health/health_report.json" # pr_base_sha carries the same value under the name the webview already reads: # its lookup is base_commit_sha || pr_base_sha || base_sha, so without it the # webview silently falls through to the branch tip and compares against a base -# this review never used. base_analysis_method and base_analysis_reason say how -# this run obtained the base analysis; all strings, empty from an older run. +# this review never used. jq -n \ --arg kind review \ --arg mode "$ANALYSIS_MODE" \ @@ -38,13 +37,10 @@ jq -n \ --arg base_artifact "$BASE_ARTIFACT_NAME" \ --arg base_artifact_id "$BASE_ARTIFACT_ID" \ --arg analysed_files_changed "${ANALYSED_FILES_CHANGED:-unknown}" \ - --arg base_analysis_method "${BASE_ANALYSIS_METHOD:-}" \ - --arg base_analysis_reason "${BASE_ANALYSIS_REASON:-}" \ '{kind: $kind, mode: $mode, base_sha: $base_sha, merge_base_sha: $merge_base_sha, pr_base_sha: $merge_base_sha, merge_base_resolved: $merge_base_resolved, head_sha: $head_sha, pr_number: $pr_number, seed_source: $seed_source, chain_depth: $chain_depth, base_artifact: $base_artifact, base_artifact_id: $base_artifact_id, - analysed_files_changed: $analysed_files_changed, - base_analysis_method: $base_analysis_method, base_analysis_reason: $base_analysis_reason}' \ + analysed_files_changed: $analysed_files_changed}' \ > "${RUNNER_TEMP}/cb-review-artifact/metadata.json" echo "artifact_dir=${RUNNER_TEMP}/cb-review-artifact" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/codeboarding-baseline.sh b/scripts/action/codeboarding-baseline.sh new file mode 100644 index 0000000..da3eb16 --- /dev/null +++ b/scripts/action/codeboarding-baseline.sh @@ -0,0 +1,137 @@ +# The baseline branch (save_baseline_to: baseline_branch): an orphan branch holding +# one commit per sync, each with trailers naming the commit it analysed +# (CodeBoarding-Source) and the configuration it ran under (CodeBoarding-Config). +# Sync writes it; sync and review read it. Sourced, not run; needs analysis-state.sh. + +# How far below a commit a reader looks for an entry describing it or an ancestor. +CATCHUP_BOUND="${CATCHUP_BOUND:-100}" +BASELINE_BRANCH_DEPTH="${BASELINE_BRANCH_DEPTH:-100}" + +# ---- Reading ---- + +# Lists the branch's entries as " ", newest +# first; an entry without a config has none to compare. Fetched without blobs +# into a scratch repository: the lookup needs messages, and a hundred pickles +# would cost more than it saves. +codeboarding_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_BRANCH_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) %(trailers:key=CodeBoarding-Config,valueonly,separator=%x20)' FETCH_HEAD | + awk 'NF >= 2 {print $1, $2, (NF >= 3 ? $3 : "-")}' +} + +# Seeds $3 from the branch's entry for $2, or for its nearest first-parent +# ancestor, made under this run's configuration: the trailer pins it the way the +# name pins a saved artifact, and without a configuration hash no entry is +# trusted. Keeps checkout $4's user configuration. Sets SEED_SHA to the commit +# the seed describes. +from_codeboarding_baseline() { + local repository="$1" tip="$2" state="$3" config_from="$4" index commit entry="" scratch="$RUNNER_TEMP/codeboarding-baseline-restore" + [ -n "${CFG_HASH:-}" ] || return 1 + index="$(codeboarding_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" -v cfg="$CFG_HASH" '$2 == source && $3 == cfg {print $1; exit}' <<< "$index")" + [ -z "$entry" ] || break + done + if [ -z "$entry" ]; then + echo "::notice::$BASELINE_BRANCH has no analysis of ${tip:0:7} or its last $CATCHUP_BOUND ancestors made with this configuration." + return 1 + fi + fetch_commit "$repository" "$entry" || return 1 + rm -rf "$scratch" + mkdir -p "$scratch" + git -C "$CHECKOUT_DIR" archive "$entry" .codeboarding | tar -x -C "$scratch" || return 1 + # source.json is provenance, not engine state. + rm -f "$scratch/.codeboarding/source.json" + compatible_state "$scratch/.codeboarding" || return 1 + replace_state "$scratch/.codeboarding" "$state" "$config_from" + SEED_SHA="$commit" + echo "::notice::Starting from the analysis of ${commit:0:7} saved on $BASELINE_BRANCH." +} + +# ---- Writing ---- + +# Saves the analysis installed by install-sync.sh as one fast-forward commit on +# BASELINE_BRANCH, never writing to the synced branch. Called by deliver-sync.sh, +# whose REMOTE, BASE_SHA and emit_result it uses; always exits. +save_to_codeboarding_baseline() { + 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, and under which + # configuration, lives here and in the commit's trailers only. + python3 -c 'import datetime,json,os,sys +json.dump({ + "schema": 1, + "synced_branch": os.environ["SYNCED_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", ""), + "config": os.environ.get("CFG_HASH", ""), +}, 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' + local trailers=(-m "CodeBoarding-Source: $BASE_SHA") + [ -z "${CFG_HASH:-}" ] || trailers=(-m "CodeBoarding-Source: $BASE_SHA +CodeBoarding-Config: $CFG_HASH") + + # 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" "$SYNCED_BRANCH" + if [ "$(git rev-parse FETCH_HEAD)" != "$BASE_SHA" ]; then + emit_result "$files" false "$BASE_SHA" + echo "::notice::$SYNCED_BRANCH advanced during analysis; a newer run should update $branch." + exit 0 + fi + tip="" parent=() + if [ -n "$(git ls-remote "$REMOTE" "refs/heads/$branch")" ]; then + git fetch -q --depth=1 "$REMOTE" "refs/heads/$branch" + # The parent is what was fetched, not what ls-remote saw: the branch may move in between. + tip="$(git rev-parse FETCH_HEAD)" + # Building on any other branch would leave it holding nothing but .codeboarding/. + if [ -z "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" ] || + [ "$(git ls-tree --name-only "$tip")" != .codeboarding ]; then + echo "::error::$branch already exists and is not a CodeBoarding baseline branch, so sync will not write to it. Set baseline_branch to a branch name that is not in use." + exit 1 + fi + 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 $SYNCED_BRANCH @${BASE_SHA:0:7}." + exit 0 + fi + fi + commit="$(git commit-tree "$new_tree" ${parent[@]+"${parent[@]}"} \ + -m "chore(codeboarding): diagram of $SYNCED_BRANCH @${BASE_SHA:0:7}" "${trailers[@]}")" + # 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 identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor for $branch in the repository's rulesets, or set save_baseline_to: synced_branch." + exit 1 + fi + done + emit_result "$files" false "$BASE_SHA" + echo "::notice::Another sync keeps updating $branch; leaving it to that run." + exit 0 +} diff --git a/scripts/action/deliver-sync.sh b/scripts/action/deliver-sync.sh index a359578..fe2aee1 100755 --- a/scripts/action/deliver-sync.sh +++ b/scripts/action/deliver-sync.sh @@ -63,82 +63,8 @@ classify_push_failure() { exit 1 } -# save_baseline_to: baseline_branch keeps the analysis on an orphan branch of its -# own, one fast-forward commit per sync, and never writes to the synced 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, and under which - # configuration, lives here and in the commit's trailers only. - python3 -c 'import datetime,json,os,sys -json.dump({ - "schema": 1, - "synced_branch": os.environ["SYNCED_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", ""), - "config": os.environ.get("CFG_HASH", ""), -}, 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' - local trailers=(-m "CodeBoarding-Source: $BASE_SHA") - [ -z "${CFG_HASH:-}" ] || trailers=(-m "CodeBoarding-Source: $BASE_SHA -CodeBoarding-Config: $CFG_HASH") - - # 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" "$SYNCED_BRANCH" - if [ "$(git rev-parse FETCH_HEAD)" != "$BASE_SHA" ]; then - emit_result "$files" false "$BASE_SHA" - echo "::notice::$SYNCED_BRANCH advanced during analysis; a newer run should update $branch." - exit 0 - fi - tip="" parent=() - if [ -n "$(git ls-remote "$REMOTE" "refs/heads/$branch")" ]; then - git fetch -q --depth=1 "$REMOTE" "refs/heads/$branch" - # The parent is what was fetched, not what ls-remote saw: the branch may move in between. - tip="$(git rev-parse FETCH_HEAD)" - # Building on any other branch would leave it holding nothing but .codeboarding/. - if [ -z "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" ] || - [ "$(git ls-tree --name-only "$tip")" != .codeboarding ]; then - echo "::error::$branch already exists and is not a CodeBoarding baseline branch, so sync will not write to it. Set baseline_branch to a branch name that is not in use." - exit 1 - fi - 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 $SYNCED_BRANCH @${BASE_SHA:0:7}." - exit 0 - fi - fi - commit="$(git commit-tree "$new_tree" ${parent[@]+"${parent[@]}"} \ - -m "chore(codeboarding): diagram of $SYNCED_BRANCH @${BASE_SHA:0:7}" "${trailers[@]}")" - # 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 identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor for $branch in the repository's rulesets, or set save_baseline_to: synced_branch." - exit 1 - fi - done - emit_result "$files" false "$BASE_SHA" - echo "::notice::Another sync keeps updating $branch; leaving it to that run." - exit 0 -} -[ "$SAVE_BASELINE_TO" != baseline_branch ] || deliver_to_baseline_branch +source "$ACTION_PATH/scripts/action/codeboarding-baseline.sh" +[ "$SAVE_BASELINE_TO" != baseline_branch ] || save_to_codeboarding_baseline "$ACTION_PATH/scripts/action/install-sync.sh" > "$GENERATED_PATHS" stage_paths=() diff --git a/scripts/action/find-ancestor-base.sh b/scripts/action/find-ancestor-base.sh index e3cf119..1038796 100755 --- a/scripts/action/find-ancestor-base.sh +++ b/scripts/action/find-ancestor-base.sh @@ -3,9 +3,8 @@ # under this configuration and downloads it into DEST, so a run without an exact # base can catch up from it instead of analyzing from scratch. Best effort. # -# Prints key=value lines on stdout and everything else on stderr: -# ancestor_sha the commit whose analysis is now in DEST, or empty -# other_cfg_at_tip true when TIP_SHA has a saved analysis under another configuration +# Prints ancestor_sha= on +# stdout and everything else on stderr. # # Needs the tip's first-parent history in CHECKOUT_DIR, at least CATCHUP_BOUND deep. set -euo pipefail @@ -17,10 +16,8 @@ api() { gh api -H 'Accept: application/vnd.github+json' "$@"; } export GH_HOST="${GH_HOST:-github.com}" GH_HOST="${GH_HOST#*://}" -ancestor="" other_cfg=false -report() { - printf 'ancestor_sha=%s\nother_cfg_at_tip=%s\n' "$ancestor" "$other_cfg" -} +ancestor="" +report() { echo "ancestor_sha=$ancestor"; } trap report EXIT # The merge base's first-parent history, nearest first. Distance 0 is the tip @@ -62,9 +59,6 @@ while [ "$page" -le "${MAX_PAGES:-50}" ]; do fi names="$(jq -r "$trusted" <<< "$listing" 2>/dev/null || true)" saved="$saved$(grep "^$prefix" <<< "$names" | cut -c$(( ${#prefix} + 1 ))- || true)"$'\n' - if grep "^codeboarding-base-.*-$TIP_SHA\$" <<< "$names" | grep -vq "^$prefix"; then - other_cfg=true - fi ancestor="$(nearest)" [ -z "$ancestor" ] || break returned="$(jq -r '.artifacts | length' <<< "$listing" 2>/dev/null || echo 0)" diff --git a/scripts/action/state-names.sh b/scripts/action/resolve-cache-keys.sh similarity index 100% rename from scripts/action/state-names.sh rename to scripts/action/resolve-cache-keys.sh diff --git a/scripts/action/guard.sh b/scripts/action/resolve-github-event.sh similarity index 100% rename from scripts/action/guard.sh rename to scripts/action/resolve-github-event.sh diff --git a/scripts/action/review-analyze-head.sh b/scripts/action/review-analyze-head.sh new file mode 100755 index 0000000..67a9e0b --- /dev/null +++ b/scripts/action/review-analyze-head.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Analyzes the pull request head. It continues from this pull request's last +# analysis when that grew from the same base graph, so a push only costs the +# commits pushed since; otherwise it starts from the base. +set -euo pipefail +source "$(dirname "$0")/analysis-state.sh" +base_analysis="$BASE_ANALYSIS_PATH" +state="$RUNNER_TEMP/codeboarding-review/head-state" +rm -rf "$state" + +# The artifact name pins configuration; verify the stored cap and lineage too. +warmstart_usable() { + [ -f "${WARMSTART_DIR:-}/analysis.json" ] || return 1 + if ! compatible_state "$WARMSTART_DIR"; then + echo "::notice::Analysis depth changed since the last run; re-seeding from the base analysis." + return 1 + fi + # A head that grew from one base graph cannot be diffed against another: two + # runs of the engine over one commit need not name components identically, so + # keeping it would report additions and removals for code nobody touched. + if [ "$(origin_field "$WARMSTART_DIR" base_digest)" != "$(analysis_digest "$base_analysis")" ]; then + echo "::notice::The base analysis is not the one this pull request's stored analysis grew from; re-seeding from it." + return 1 + fi +} + +seed_source=base chain_depth=1 +if warmstart_usable; then + cp -a "$WARMSTART_DIR" "$state" + seed_source=pr-chain + previous_depth="$(origin_field "$state" chain_depth)" + case "$previous_depth" in + '' | *[!0-9]*) previous_depth=0 ;; + esac + chain_depth=$(( previous_depth + 1 )) +else + cp -a "$(dirname "$base_analysis")" "$state" +fi +rm -f "$state/origin.json" + +unset GIT_TOKEN +result="$("$ACTION_PATH/scripts/action/analyze.sh" head "$CHECKOUT_DIR" "$state")" +write_origin "$state" "$seed_source" "$chain_depth" "$(analysis_digest "$base_analysis")" +stage "$state" warmstart + +{ + echo "$result" + printf 'seed_source=%s\nchain_depth=%s\n' "$seed_source" "$chain_depth" +} >> "$GITHUB_OUTPUT" diff --git a/scripts/action/review-generate-baseline.sh b/scripts/action/review-generate-baseline.sh new file mode 100755 index 0000000..5df1047 --- /dev/null +++ b/scripts/action/review-generate-baseline.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Produces the analysis of the review's merge base, the graph the pull request is +# compared against. First match wins: +# +# 1. the published analysis of the merge base (fetched by the step before) used as is +# 2. an entry on the baseline branch for the merge base or an ancestor \ +# 3. the analysis committed on the branch at the merge base > caught up to the merge base +# 4. the saved analysis of the nearest ancestor / +# 5. nothing built from scratch +# +# Outputs base_analysis_path, and publish_base when this run produced a base +# other pull requests forking there should reuse. +set -euo pipefail +source "$(dirname "$0")/seed-sources.sh" +work="$RUNNER_TEMP/codeboarding-review" +checkout="$work/base" state="$work/base-state" +rm -rf "$work" +mkdir -p "$work" +token="${GIT_TOKEN:-}" + +progress() { + GH_TOKEN="$token" GH_ENTERPRISE_TOKEN="$token" \ + "$ACTION_PATH/scripts/action/update-review-progress.sh" "$1" >/dev/null 2>&1 || true +} + +publish_base=true +if compatible_state "${BASE_DIR:-}"; then + cp -a "$BASE_DIR" "$state" + echo "::notice::Using the published analysis of ${REVIEW_BASE_SHA:0:7}." + # Only a base about to expire is published again: a review artifact references + # it by id for its whole retention. + publish_base="${RENEW_BASE:-false}" +else + fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" + git -C "$CHECKOUT_DIR" worktree add --detach "$checkout" "$REVIEW_BASE_SHA" >/dev/null + if from_codeboarding_baseline "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$state" "$checkout" || + from_committed_baseline "$checkout" "$state" || + from_ancestor_artifact "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" "$state" false "$checkout"; then + seeded=true + else + seeded=false + keep_user_config "$checkout" "$state" + fi + # Core never sees the token. + unset GIT_TOKEN + if [ "$SEED_SHA" = "$REVIEW_BASE_SHA" ]; then + echo "::notice::That analysis describes the merge base itself; nothing to catch up." + elif [ "$seeded" = true ]; then + "$ACTION_PATH/scripts/action/analyze.sh" base "$checkout" "$state" >/dev/null + else + echo "::notice::No saved analysis to start from; analyzing ${REVIEW_BASE_SHA:0:7} from scratch." + progress base + "$ACTION_PATH/scripts/action/analyze.sh" base "$checkout" "$state" >/dev/null + progress head + fi +fi +[ -f "$state/analysis.json" ] || { echo "::error::Review baseline analysis is missing."; exit 1; } +[ "$publish_base" != true ] || stage "$state" base +printf 'base_analysis_path=%s\npublish_base=%s\n' "$state/analysis.json" "$publish_base" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/seed-sources.sh b/scripts/action/seed-sources.sh new file mode 100644 index 0000000..d6afcb4 --- /dev/null +++ b/scripts/action/seed-sources.sh @@ -0,0 +1,44 @@ +# Where an analysis to continue from can come from. Sourced, not run. +# +# Each from_* function fills a state directory with a compatible analysis of a +# commit or one of its ancestors and returns 0, or leaves the directory alone and +# returns 1. SEED_SHA is the commit the seed describes, when the source knows it; +# a seed of the very commit being analyzed needs no engine run at all. +HERE="$(dirname "${BASH_SOURCE[0]}")" +source "$HERE/analysis-state.sh" +source "$HERE/codeboarding-baseline.sh" +SEED_SHA="" + +# The analysis sync committed under .codeboarding/ on the branch itself. Which +# commit it describes is not recorded, so it is always caught up. +from_committed_baseline() { + local checkout="$1" state="$2" + [ -f "$checkout/.codeboarding/analysis.json" ] || return 1 + if ! compatible_state "$checkout/.codeboarding"; then + echo "::notice::The analysis committed on this branch was made with another depth; not starting from it." + return 1 + fi + replace_state "$checkout/.codeboarding" "$state" "$checkout" + echo "::notice::Starting from the analysis committed on this branch." +} + +# A codeboarding-base-- artifact for the nearest first-parent ancestor +# of $2, or of $2 itself when $4 is true. A review already looked its tip up by +# exact name; sync has no such lookup. +from_ancestor_artifact() { + local repository="$1" tip="$2" state="$3" include_tip="$4" config_from="$5" found ancestor dest="$RUNNER_TEMP/codeboarding-ancestor" + [ "${ANCESTOR_LOOKUP:-false}" = true ] && [ -n "${CFG_HASH:-}" ] && [ -n "${REPOSITORY:-}" ] && [ -n "$tip" ] || return 1 + fetch_commit "$repository" "$tip" "$(( CATCHUP_BOUND + 1 ))" || true + found="$(GH_TOKEN="${GIT_TOKEN:-}" GH_ENTERPRISE_TOKEN="${GIT_TOKEN:-}" TIP_SHA="$tip" DEST="$dest" \ + INCLUDE_TIP="$include_tip" CATCHUP_BOUND="$CATCHUP_BOUND" \ + "$HERE/find-ancestor-base.sh" || true)" + ancestor="$(awk -F= '$1 == "ancestor_sha" {print $2; exit}' <<< "$found")" + [ -n "$ancestor" ] || return 1 + if ! compatible_state "$dest"; then + echo "::notice::The saved analysis of ${ancestor:0:7} was made with another depth; not starting from it." + return 1 + fi + replace_state "$dest" "$state" "$config_from" + SEED_SHA="$ancestor" + echo "::notice::Starting from the saved analysis of ${ancestor:0:7}." +} diff --git a/scripts/action/sync-generate-baseline.sh b/scripts/action/sync-generate-baseline.sh new file mode 100755 index 0000000..b262b8b --- /dev/null +++ b/scripts/action/sync-generate-baseline.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Produces the analysis of the synced branch's head, continuing from the nearest +# saved analysis unless force_full asks for a fresh one. First match wins: +# +# 1. an entry on the baseline branch for this commit or an ancestor +# 2. the analysis committed on the branch +# 3. the saved analysis of this commit or the nearest ancestor +# 4. nothing: analyzed from scratch +set -euo pipefail +source "$(dirname "$0")/seed-sources.sh" +state="$RUNNER_TEMP/codeboarding-sync/analysis" +rm -rf "$RUNNER_TEMP/codeboarding-sync" +head_sha="$(git -C "$CHECKOUT_DIR" rev-parse HEAD 2>/dev/null || true)" + +if [ "$(printf '%s' "${FORCE_FULL:-false}" | tr '[:upper:]' '[:lower:]')" = true ] || ! { + from_codeboarding_baseline "${REPOSITORY:-}" "$head_sha" "$state" "$CHECKOUT_DIR" || + from_committed_baseline "$CHECKOUT_DIR" "$state" || + from_ancestor_artifact "${REPOSITORY:-}" "$head_sha" "$state" true "$CHECKOUT_DIR" +}; then + keep_user_config "$CHECKOUT_DIR" "$state" +fi + +# Core never sees the token. +unset GIT_TOKEN +result="$("$ACTION_PATH/scripts/action/analyze.sh" sync "$CHECKOUT_DIR" "$state")" +# Sync already computes the graph every review of this branch compares against, +# so publish it instead of making the first pull request recompute it. +stage "$state" base +{ + echo "$result" + echo "analysis_dir=$state" +} >> "$GITHUB_OUTPUT" diff --git a/scripts/action/post-progress.sh b/scripts/action/update-review-progress.sh similarity index 67% rename from scripts/action/post-progress.sh rename to scripts/action/update-review-progress.sh index 7ccb210..ec5be1d 100755 --- a/scripts/action/post-progress.sh +++ b/scripts/action/update-review-progress.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# Rewrites the review's sticky progress comment into two steps while the base is +# Rewrites the review's sticky progress comment into two steps when the base is # built from scratch: that is the slow path, and the reader should know why. -# Usage: post-progress.sh base|head . Best effort throughout. +# Usage: update-review-progress.sh base|head, once as the base build starts and +# once as it ends. Best effort throughout. set -euo pipefail -step="$1" elapsed="$2" +step="$1" [ -n "${PROGRESS_HEADER:-}" ] && [ -n "${PR_NUMBER:-}" ] && [ -n "${REPOSITORY:-}" ] || exit 0 export GH_HOST="${GH_HOST:-github.com}" GH_HOST="${GH_HOST#*://}" @@ -26,20 +27,12 @@ else branch="the base branch" fi sha7="${REVIEW_BASE_SHA:0:7}" -case "${FULL_CAUSE:-}" in - incompatible) why="The saved diagram of $branch was made by a different engine version or settings, so this review builds a new one." ;; - *) why="$branch has no saved diagram yet, so this review builds one first. Once a diagram of $branch is saved, reviews start from it and skip this step." ;; -esac -minutes=$(( elapsed / 60 )) +why="$branch has no saved diagram this review can start from, so it builds one first. Once a diagram of $branch is saved, reviews start from it and skip this step." if [ "$step" = base ]; then - running="running for $minutes min" - [ "$minutes" -gt 0 ] || running="running for less than a minute" - first="1. ⏳ Building the diagram of $branch @$sha7 from scratch · $running" + first="1. ⏳ Building the diagram of $branch @$sha7 from scratch" second="2. Analysing this PR's changes" else - took="$(( elapsed % 60 )) s" - [ "$minutes" -eq 0 ] || took="$minutes m $took" - first="1. ✅ Built the diagram of $branch @$sha7 from scratch in $took" + first="1. ✅ Built the diagram of $branch @$sha7 from scratch" second="2. ⏳ Analysing this PR's changes" fi @@ -51,9 +44,4 @@ body="$(printf '%s\n\n%s\n %s\n%s\n\n%s\n\n%s\n%s' \ "Open it in [CodeBoarding]($platform) meanwhile: the files, comments and review are there already, and the diff appears when the run finishes." \ "run [${GITHUB_RUN_ID:-}]($run_url) · attempt ${GITHUB_RUN_ATTEMPT:-1}" \ "$marker")" -# Checked last: the ticker may have been stopped while this ran, and a stale -# "running" edit must not land on top of the next step. -if [ "$step" = base ] && [ -n "${PROGRESS_STOP_FILE:-}" ] && [ -e "$PROGRESS_STOP_FILE" ]; then - exit 0 -fi gh api -X PATCH "repos/$REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null diff --git a/tests/test_action_state.py b/tests/test_action_state.py index 5275992..8a99ed0 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -11,8 +11,46 @@ ROOT = Path(__file__).resolve().parent.parent -STATE_NAMES = ROOT / "scripts" / "action" / "state-names.sh" -ANALYZE = ROOT / "scripts" / "action" / "analyze.sh" +SCRIPTS = ROOT / "scripts" / "action" +STATE_NAMES = SCRIPTS / "resolve-cache-keys.sh" +# The analysis steps of each mode, in the order action.yml runs them. +STEPS = { + "review": (SCRIPTS / "review-generate-baseline.sh", SCRIPTS / "review-analyze-head.sh"), + "sync": (SCRIPTS / "sync-generate-baseline.sh",), +} + + +def read_outputs(output: Path) -> dict[str, str]: + values: dict[str, str] = {} + for line in output.read_text(encoding="utf-8").splitlines(): + key, _, value = line.partition("=") + values[key] = value + return values + + +def run_steps(env: dict[str, str], output: Path) -> tuple[int, str, dict[str, str]]: + """Runs one mode's analysis steps as action.yml does, each seeing the outputs before it. + + Returns the first failing exit code (or 0), everything the steps logged, and their outputs. + """ + env = dict(env) + kind = env.pop("ANALYSIS_KIND", "review") + output.write_text("", encoding="utf-8") + log = "" + for script in STEPS[kind]: + base = read_outputs(output).get("base_analysis_path", "") + result = subprocess.run( + [str(script)], + env={**env, "GITHUB_OUTPUT": str(output), "BASE_ANALYSIS_PATH": base}, + capture_output=True, + text=True, + check=False, + ) + log += result.stdout + result.stderr + if result.returncode != 0: + return result.returncode, log, read_outputs(output) + return 0, log, read_outputs(output) + ENGINE_STUB = '''#!/usr/bin/env python3 """CodeBoarding CLI stand-in: records each call and writes a minimal analysis.""" @@ -177,16 +215,13 @@ 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={ + code, self.log, values = run_steps( + { "PATH": f"{self.bin_dir}:{os.environ['PATH']}", - "GITHUB_OUTPUT": str(self.output), "RUNNER_TEMP": str(self.runner_temp), "CB_ENGINE_LOG": str(self.engine_log), "ACTION_PATH": str(ROOT), - "ANALYSIS_KIND": "review", + "GITHUB_RUN_ID": "1", "CHECKOUT_DIR": str(self.checkout), "REVIEW_BASE_SHA": "merge-base-sha", "REVIEW_HEAD_SHA": "head-sha", @@ -200,20 +235,18 @@ def _analyze(self, **extra: str) -> dict[str, str]: "STAGE_DIR": str(self.stage_dir), **extra, }, - capture_output=True, - text=True, - check=False, + self.output, ) - self.assertEqual(result.returncode, 0, result.stderr or result.stdout) - values: dict[str, str] = {} - for line in self.output.read_text(encoding="utf-8").splitlines(): - key, _, value = line.partition("=") - values[key] = value + self.assertEqual(code, 0, self.log) return values def _engine_calls(self) -> list[dict[str, str]]: return [json.loads(line) for line in self.engine_log.read_text(encoding="utf-8").splitlines()] + def _base_modes(self) -> list[str]: + """How the base was obtained, read the way telemetry reads it: no base run means reused.""" + return [c["mode"] for c in self._engine_calls() if c["run_id"].endswith("-base")] + def _bind(self, **origin: object) -> None: """Chain fixture bound to the base it was derived from.""" _state(self.warmstart_dir, base_digest=_digest(self.base_dir / "analysis.json"), **origin) @@ -354,53 +387,23 @@ def _commit(self, message: str, files: dict[str, str], *, bot: bool = False) -> self._git(*committer, "commit", "-q", "-m", message) return self._git("rev-parse", "HEAD") - def _sync_history(self) -> tuple[str, str]: - """An analysed commit with a sync commit on top that writes only .codeboarding/.""" - self._git("init", "-q", "-b", "main") - analysed = self._commit("feat: code", {"code.py": "pass\n"}) - _state(self.checkout / ".codeboarding", cap=2) - sync = self._commit("chore(codeboarding): sync analysis baseline", {}, bot=True) - return analysed, sync - - def _merge(self, branch: str, files: dict[str, str], *, bot: bool = False) -> str: - """A commit on `branch` merged into main with --no-ff.""" - self._git("checkout", "-q", "-b", branch) - self._commit(f"work on {branch}", files, bot=bot) - self._git("checkout", "-q", "main") - self._git("merge", "-q", "--no-ff", "-m", f"Merge {branch}", branch) - return self._git("rev-parse", "HEAD") - - def _provenance(self, values: dict[str, str]) -> dict[str, str]: - return {key: values[key] for key in ("base_analysis_method", "base_analysis_reason")} - def test_a_saved_base_is_reused(self) -> None: _state(self.base_dir) - values = self._analyze() + self._analyze() - self.assertEqual( - self._provenance(values), - {"base_analysis_method": "reused", "base_analysis_reason": "merge-b already has a saved analysis"}, - ) + self.assertEqual(self._base_modes(), []) + self.assertIn("Using the published analysis of merge-b.", self.log) def test_a_base_with_nothing_to_seed_it_is_a_full_analysis(self) -> None: sha = self._commit_base() - values = self._analyze(REVIEW_BASE_SHA=sha) + self._analyze(REVIEW_BASE_SHA=sha) - self.assertEqual( - self._provenance(values), - {"base_analysis_method": "full", "base_analysis_reason": "no usable analysis was available"}, - ) + self.assertEqual(self._base_modes(), ["full"]) - def _assert_incompatible(self, values: dict[str, str]) -> None: - self.assertEqual( - self._provenance(values), - { - "base_analysis_method": "full", - "base_analysis_reason": "the existing analysis was incompatible or could not be updated incrementally", - }, - ) + def _assert_incompatible(self, _values: dict[str, str]) -> None: + self.assertEqual(self._base_modes()[-1:], ["full"]) def test_a_committed_baseline_with_another_depth_is_incompatible(self) -> None: sha = self._commit_base(legacy=True) @@ -418,100 +421,6 @@ def test_an_engine_that_demands_a_full_run_makes_the_baseline_incompatible(self) self._assert_incompatible(self._analyze(REVIEW_BASE_SHA=sha, CB_REQUIRE_FULL="true")) - def test_a_baseline_committed_at_the_merge_base_is_reused(self) -> None: - # The sync commit changes only .codeboarding/, which the analysis ignores, - # so a merge base on it is exactly the commit the baseline describes. - _analysed, sync = self._sync_history() - - values = self._analyze(REVIEW_BASE_SHA=sync) - - self.assertEqual( - self._provenance(values), - {"base_analysis_method": "reused", "base_analysis_reason": f"{sync[:7]} already has a saved analysis"}, - ) - # Not under the merge base's name yet, so this run publishes it. - self.assertEqual(values["publish_base"], "true") - - def test_a_committed_baseline_counts_the_commits_it_caught_up(self) -> None: - analysed, _sync = self._sync_history() - self._commit("feat: more", {"more.py": "pass\n"}) - merge_base = self._commit("feat: again", {"code.py": "print()\n"}) - - values = self._analyze(REVIEW_BASE_SHA=merge_base) - - self.assertEqual( - self._provenance(values), - { - "base_analysis_method": "incremental", - "base_analysis_reason": f"updated the analysis of {analysed[:7]} to {merge_base[:7]}, 2 commits caught up", - }, - ) - - def test_merged_pull_requests_count_as_commits_to_catch_up(self) -> None: - # Merged with --no-ff, the first-parent chain is merge commits only, and - # each one brings code in even though it changes nothing against itself. - analysed, _sync = self._sync_history() - self._merge("feature-a", {"a.py": "pass\n"}) - merge_base = self._merge("feature-b", {"b.py": "pass\n"}) - - values = self._analyze(REVIEW_BASE_SHA=merge_base) - - self.assertEqual( - values["base_analysis_reason"], - f"updated the analysis of {analysed[:7]} to {merge_base[:7]}, 2 commits caught up", - ) - - def test_a_merged_sync_pull_request_describes_its_own_parent(self) -> None: - # save_baseline_to: pull_request. The sync commit sits on codeboarding/sync on - # top of the analysed commit; main moved on before the merge. - self._git("init", "-q", "-b", "main") - analysed = self._commit("feat: code", {"code.py": "pass\n"}) - self._git("checkout", "-q", "-b", "codeboarding/sync") - _state(self.checkout / ".codeboarding", cap=2) - self._commit("chore(codeboarding): sync analysis baseline", {}, bot=True) - self._git("checkout", "-q", "main") - self._commit("feat: meanwhile", {"later.py": "pass\n"}) - self._git("merge", "-q", "--no-ff", "-m", "Merge codeboarding/sync", "codeboarding/sync") - merge_base = self._git("rev-parse", "HEAD") - - values = self._analyze(REVIEW_BASE_SHA=merge_base) - - self.assertEqual( - self._provenance(values), - { - "base_analysis_method": "incremental", - "base_analysis_reason": f"updated the analysis of {analysed[:7]} to {merge_base[:7]}, 1 commit caught up", - }, - ) - - def test_a_baseline_sync_did_not_write_is_of_unknown_origin(self) -> None: - # A squash or a hand edit: its parent is not known to be what was analysed, - # so the reason names no starting commit and claims nothing was exact. - self._git("init", "-q", "-b", "main") - self._commit("feat: code", {"code.py": "pass\n"}) - _state(self.checkout / ".codeboarding", cap=2) - merge_base = self._commit("chore(codeboarding): sync analysis baseline (#7)", {}) - - values = self._analyze(REVIEW_BASE_SHA=merge_base) - - self.assertEqual( - self._provenance(values), - { - "base_analysis_method": "incremental", - "base_analysis_reason": f"updated an existing analysis to {merge_base[:7]}", - }, - ) - - def test_an_attributes_line_is_not_code_to_catch_up(self) -> None: - _analysed, _sync = self._sync_history() - merge_base = self._commit( - "chore: attributes", {".gitattributes": ".codeboarding/analysis.json linguist-generated=true\n"} - ) - - values = self._analyze(REVIEW_BASE_SHA=merge_base) - - self.assertEqual(values["base_analysis_method"], "reused") - def _progress_stub(self) -> Path: calls = self.root / "gh-calls" gh = self.bin_dir / "gh" @@ -537,10 +446,10 @@ def test_building_the_base_from_scratch_rewrites_the_progress_comment(self) -> N ) patches = [call for call in calls.read_text().split("\n----\n") if "PATCH" in call] - self.assertGreaterEqual(len(patches), 2, calls.read_text()) + self.assertEqual(len(patches), 2, calls.read_text()) self.assertIn("repos/owner/repo/issues/comments/77", patches[0]) self.assertIn(f"Building the diagram of `develop` @{sha[:7]} from scratch", patches[0]) - self.assertIn("`develop` has no saved diagram yet", patches[0]) + self.assertIn("`develop` has no saved diagram this review can start from", patches[0]) self.assertIn("2. ⏳ Analysing this PR's changes", patches[-1]) # The sticky-comment action finds its comment by this line on the final write. self.assertIn("", patches[-1]) @@ -555,7 +464,7 @@ def test_a_saved_base_leaves_the_progress_comment_alone(self) -> None: def test_invalid_depth_fails_before_analysis(self) -> None: result = subprocess.run( - [str(ANALYZE)], + [str(STEPS["review"][0])], env={"PATH": os.environ["PATH"], "DEPTH_CAP": "-1"}, capture_output=True, text=True, @@ -751,18 +660,15 @@ def _serve(self, artifacts: list[dict], pages: list | None = None) -> None: self.gh_config.write_text(json.dumps(config), encoding="utf-8") def _analyze(self, checkout: Path, **extra: str) -> dict[str, str]: - self.output.write_text("", encoding="utf-8") - result = subprocess.run( - [str(ANALYZE)], - env={ + code, self.log, values = run_steps( + { "PATH": f"{self.bin_dir}:{os.environ['PATH']}", - "GITHUB_OUTPUT": str(self.output), "RUNNER_TEMP": str(self.runner_temp), "CB_ENGINE_LOG": str(self.engine_log), "CB_GH_CONFIG": str(self.gh_config), "CB_GH_LOG": str(self.gh_log), "ACTION_PATH": str(ROOT), - "ANALYSIS_KIND": "review", + "GITHUB_RUN_ID": "1", "CHECKOUT_DIR": str(checkout), "REVIEW_HEAD_SHA": "head-sha", "REVIEW_BASE_REPO": "origin", @@ -778,23 +684,20 @@ def _analyze(self, checkout: Path, **extra: str) -> dict[str, str]: "DEPTH_CAP": "2", **extra, }, - capture_output=True, - text=True, - check=False, + self.output, ) - self.assertEqual(result.returncode, 0, result.stderr or result.stdout) - values: dict[str, str] = {} - for line in self.output.read_text(encoding="utf-8").splitlines(): - key, _, value = line.partition("=") - values[key] = value + self.assertEqual(code, 0, self.log) return values def _modes(self) -> list[str]: return [json.loads(line)["mode"] for line in self.engine_log.read_text().splitlines()] - def _assert_full(self, values: dict[str, str], reason: str = "no usable analysis was available") -> None: - self.assertEqual(values["base_analysis_method"], "full") - self.assertEqual(values["base_analysis_reason"], reason) + def _base_modes(self) -> list[str]: + calls = [json.loads(line) for line in self.engine_log.read_text().splitlines()] + return [c["mode"] for c in calls if c["run_id"].endswith("-base")] + + def _assert_full(self, _values: dict[str, str]) -> None: + self.assertEqual(self._base_modes(), ["full"]) def test_it_catches_up_from_the_nearest_saved_ancestor(self) -> None: shas = self._history(5) @@ -806,11 +709,7 @@ def test_it_catches_up_from_the_nearest_saved_ancestor(self) -> None: values = self._analyze(self.origin, REVIEW_BASE_SHA=merge_base) - self.assertEqual(values["base_analysis_method"], "incremental") - self.assertEqual( - values["base_analysis_reason"], - f"updated the analysis of {shas[2][:7]} to {merge_base[:7]}, 2 commits caught up", - ) + self.assertIn(f"Starting from the saved analysis of {shas[2][:7]}.", self.log) self.assertEqual(self._modes(), ["incremental", "incremental"]) # Published under the merge base's own name, so the next review hits it exactly. self.assertEqual(values["publish_base"], "true") @@ -844,8 +743,7 @@ def test_a_busy_artifact_store_is_paged_until_a_saved_ancestor_appears(self) -> values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1]) - self.assertEqual(values["base_analysis_method"], "incremental") - self.assertIn(f"updated the analysis of {shas[0][:7]} ", values["base_analysis_reason"]) + self.assertIn(f"Starting from the saved analysis of {shas[0][:7]}.", self.log) listings = [ line for line in self.gh_log.read_text().splitlines() @@ -881,9 +779,9 @@ def test_the_merge_bases_own_configuration_survives_the_seed(self) -> None: archive.writestr("health/health_config.json", "{}") self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) - values = self._analyze(self.origin, REVIEW_BASE_SHA=merge_base) + self._analyze(self.origin, REVIEW_BASE_SHA=merge_base) - self.assertEqual(values["base_analysis_method"], "incremental") + self.assertEqual(self._base_modes(), ["incremental"]) staged = self.stage_dir / "base" self.assertEqual((staged / ".codeboardingignore").read_text(), "docs/\n") self.assertFalse((staged / "health" / "health_config.json").exists(), "the merge base has none") @@ -903,7 +801,7 @@ def test_another_configuration_saved_at_the_merge_base_is_incompatible(self) -> values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1]) - self._assert_full(values, "the existing analysis was incompatible or could not be updated incrementally") + self._assert_full(values) def test_a_shallow_checkout_is_deepened_to_find_the_ancestor(self) -> None: shas = self._history(4) @@ -914,21 +812,19 @@ def test_a_shallow_checkout_is_deepened_to_find_the_ancestor(self) -> None: subprocess.run(["git", "clone", "-q", "--depth=1", f"file://{bare}", str(checkout)], check=True) self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) - values = self._analyze(checkout, REVIEW_BASE_SHA=shas[-1]) + self._analyze(checkout, REVIEW_BASE_SHA=shas[-1]) - self.assertEqual( - values["base_analysis_reason"], - f"updated the analysis of {shas[0][:7]} to {shas[-1][:7]}, 3 commits caught up", - ) + self.assertIn(f"Starting from the saved analysis of {shas[0][:7]}.", self.log) + self.assertEqual(self._base_modes(), ["incremental"]) def test_without_the_lookup_nothing_is_listed(self) -> None: # GHES has no artifact store, so the action turns the lookup off there. shas = self._history(2) self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) - values = self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1], ANCESTOR_LOOKUP="false") + self._analyze(self.origin, REVIEW_BASE_SHA=shas[-1], ANCESTOR_LOOKUP="false") - self.assertEqual(values["base_analysis_method"], "full") + self.assertEqual(self._base_modes(), ["full"]) self.assertEqual(self.gh_log.read_text(), "") def test_a_first_sync_catches_up_from_the_setup_reviews_base(self) -> None: @@ -1095,7 +991,7 @@ def test_a_fork_publishes_nothing_another_run_would_read(self) -> None: for s in self._uploads() if "outputs.base_name" in s.get("path", "") + s.get("name", "") or "base_name" in s.get("if", "") ] - published_by_review = [s for s in self._uploads() if "review_analyze.outputs.publish_base" in s.get("if", "")] + published_by_review = [s for s in self._uploads() if "review_baseline.outputs.publish_base" in s.get("if", "")] self.assertTrue(published_by_review, "no base publication step found") for step in published_by_review: self.assertIn("is_fork != 'true'", step["if"], f"{step['name']} would let a fork publish a shared base") diff --git a/tests/test_action_sync.py b/tests/test_action_sync.py index e2e0cb1..64997bc 100644 --- a/tests/test_action_sync.py +++ b/tests/test_action_sync.py @@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parent.parent INSTALL_SYNC = ROOT / "scripts" / "action" / "install-sync.sh" RENDER_REVIEW = ROOT / "scripts" / "action" / "render-review.sh" -GUARD = ROOT / "scripts" / "action" / "guard.sh" +GUARD = ROOT / "scripts" / "action" / "resolve-github-event.sh" class ActionSyncTests(unittest.TestCase): diff --git a/tests/test_baseline_branch.py b/tests/test_baseline_branch.py index 96c8aee..29be865 100644 --- a/tests/test_baseline_branch.py +++ b/tests/test_baseline_branch.py @@ -9,7 +9,7 @@ import unittest from pathlib import Path -from test_action_state import ANALYZE, ENGINE_STUB +from test_action_state import ENGINE_STUB, run_steps ROOT = Path(__file__).resolve().parent.parent DELIVER = ROOT / "scripts" / "action" / "deliver-sync.sh" @@ -275,16 +275,13 @@ 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={ + code, self.log, values = run_steps( + { "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", + "GITHUB_RUN_ID": "1", "CHECKOUT_DIR": str(self.checkout), "REVIEW_HEAD_SHA": "head-sha", "REVIEW_BASE_REPO": "origin", @@ -300,22 +297,23 @@ def _analyze(self, **extra: str) -> dict[str, str]: "DEPTH_CAP": "2", **extra, }, - capture_output=True, - text=True, - check=False, + self.output, ) - 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) + self.assertEqual(code, 0, self.log) + return values def _modes(self) -> list[str]: return [json.loads(line)["mode"] for line in self.engine_log.read_text().splitlines()] + def _base_modes(self) -> list[str]: + calls = [json.loads(line) for line in self.engine_log.read_text().splitlines()] + return [c["mode"] for c in calls if c["run_id"].endswith("-base")] + def test_a_branch_entry_for_the_merge_base_is_reused(self) -> None: values = self._analyze(REVIEW_BASE_SHA=self.shas[3]) - self.assertEqual(values["base_analysis_method"], "reused") - self.assertEqual(values["base_analysis_reason"], f"{self.shas[3][:7]} already has a saved analysis") - self.assertEqual(self._modes(), ["incremental"], "only the head is analyzed") + self.assertEqual(self._base_modes(), [], "only the head is analyzed") + self.assertEqual(self._modes(), ["incremental"]) 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()) @@ -323,39 +321,46 @@ def test_a_branch_entry_for_the_merge_base_is_reused(self) -> None: 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._analyze(REVIEW_BASE_SHA=self.shas[4]) - self.assertEqual(values["base_analysis_method"], "incremental") - self.assertEqual( - values["base_analysis_reason"], - f"updated the analysis of {self.shas[3][:7]} to {self.shas[4][:7]}, 1 commit caught up", - ) + self.assertIn(f"Starting from the analysis of {self.shas[3][:7]} saved on {BRANCH}.", self.log) + self.assertEqual(self._base_modes(), ["incremental"]) self.assertEqual(self._modes(), ["incremental", "incremental"]) def test_an_entry_made_under_another_configuration_is_never_reused(self) -> None: # Another engine version or model: reusing it as is would diff an old # configuration's base against a new head. - values = self._analyze(REVIEW_BASE_SHA=self.shas[3], CFG_HASH="othercfg") + self._analyze(REVIEW_BASE_SHA=self.shas[3], CFG_HASH="othercfg") - self.assertEqual(values["base_analysis_method"], "full") - self.assertEqual( - values["base_analysis_reason"], - "the existing analysis was incompatible or could not be updated incrementally", - ) self.assertEqual(self._modes(), ["full", "incremental"]) def test_without_a_configuration_hash_no_entry_is_trusted(self) -> None: - values = self._analyze(REVIEW_BASE_SHA=self.shas[3], CFG_HASH="") + self._analyze(REVIEW_BASE_SHA=self.shas[3], CFG_HASH="") - self.assertEqual(values["base_analysis_method"], "full") + self.assertEqual(self._base_modes(), ["full"]) def test_without_the_branch_the_base_is_a_full_analysis(self) -> None: - values = self._analyze(REVIEW_BASE_SHA=self.shas[4], BASELINE_BRANCH="codeboarding/none") + self._analyze(REVIEW_BASE_SHA=self.shas[4], BASELINE_BRANCH="codeboarding/none") - self.assertEqual(values["base_analysis_method"], "full") - self.assertEqual(values["base_analysis_reason"], "no usable analysis was available") self.assertEqual(self._modes(), ["full", "incremental"]) + def test_the_branch_outranks_a_baseline_committed_at_the_merge_base(self) -> None: + # Its entries are pinned to this configuration; a committed baseline is not, + # so one left behind after switching to the baseline branch must not win. + board = self.checkout / ".codeboarding" + board.mkdir() + (board / "analysis.json").write_text(json.dumps({"metadata": {"depth_cap": 2}, "components": ["stale"]})) + git(self.checkout, "add", "-A") + git(self.checkout, "commit", "-q", "-m", "stale baseline") + merge_base = git(self.checkout, "rev-parse", "HEAD") + git(self.checkout, "push", "-q", "origin", "HEAD:main") + + values = self._analyze(REVIEW_BASE_SHA=merge_base) + + self.assertIn(f"saved on {BRANCH}.", self.log) + base = json.loads(Path(values["base_analysis_path"]).read_text()) + self.assertNotEqual(base["components"], ["stale"]) + def test_sync_continues_from_the_branch_tip(self) -> None: self._analyze(ANALYSIS_KIND="sync", SAVE_BASELINE_TO="baseline_branch", FORCE_FULL="false") @@ -401,7 +406,7 @@ def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: output = Path(tmp) / "github-output" output.write_text("", encoding="utf-8") result = subprocess.run( - [str(ROOT / "scripts" / "action" / "guard.sh")], + [str(ROOT / "scripts" / "action" / "resolve-github-event.sh")], env={ "PATH": os.environ["PATH"], "GITHUB_OUTPUT": str(output), diff --git a/tests/test_merge_base_contract.py b/tests/test_merge_base_contract.py index 938de62..67c0f81 100644 --- a/tests/test_merge_base_contract.py +++ b/tests/test_merge_base_contract.py @@ -33,8 +33,9 @@ ROOT = Path(__file__).resolve().parent.parent -GUARD = ROOT / "scripts" / "action" / "guard.sh" -ANALYZE = ROOT / "scripts" / "action" / "analyze.sh" +GUARD = ROOT / "scripts" / "action" / "resolve-github-event.sh" +GENERATE_BASELINE = ROOT / "scripts" / "action" / "review-generate-baseline.sh" +ANALYZE_HEAD = ROOT / "scripts" / "action" / "review-analyze-head.sh" GH_STUB = '''#!/usr/bin/env python3 """Minimal `gh` stand-in answering from a real git repository.""" @@ -218,28 +219,31 @@ def test_review_analyzes_the_merge_base_tree_not_the_base_branch_tip(self) -> No check=True, ) - result = subprocess.run( - [str(ANALYZE)], - env=self._env( - ACTION_PATH=str(ROOT), - ANALYSIS_KIND="review", - CHECKOUT_DIR=str(checkout), - REVIEW_BASE_SHA=self.fork_point, - REVIEW_HEAD_SHA=self.head_sha, - REVIEW_BASE_REPO="owner/repo", - GIT_TOKEN="unused", - GITHUB_SERVER_URL="https://github.com", - PR_NUMBER="7", - SEED_MODE="chain", - CACHE_OUT_DIR=str(Path(self.temp_dir.name) / "cache-out"), - ENGINE_VERSION="test", - CFG_HASH="test", - ), - capture_output=True, - text=True, - check=False, + env = self._env( + ACTION_PATH=str(ROOT), + CHECKOUT_DIR=str(checkout), + REVIEW_BASE_SHA=self.fork_point, + REVIEW_HEAD_SHA=self.head_sha, + REVIEW_BASE_REPO="owner/repo", + GIT_TOKEN="unused", + GITHUB_SERVER_URL="https://github.com", + PR_NUMBER="7", + SEED_MODE="chain", + CACHE_OUT_DIR=str(Path(self.temp_dir.name) / "cache-out"), + ENGINE_VERSION="test", + CFG_HASH="test", ) - self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + # The review runs as two steps: the merge base's analysis, then the head's. + self.output.write_text("", encoding="utf-8") + for script in (GENERATE_BASELINE, ANALYZE_HEAD): + result = subprocess.run( + [str(script)], + env={**env, "BASE_ANALYSIS_PATH": self._outputs().get("base_analysis_path", "")}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) runs = [json.loads(line) for line in self.engine_log.read_text(encoding="utf-8").splitlines()] self.assertEqual(len(runs), 2, f"expected a base and a head analysis, got {runs}") diff --git a/tests/test_progress_comment.py b/tests/test_progress_comment.py index 8994422..3ae0bfe 100644 --- a/tests/test_progress_comment.py +++ b/tests/test_progress_comment.py @@ -1,4 +1,4 @@ -"""The progress comment while a base is built from scratch: two steps, elapsed time only.""" +"""The progress comment while a base is built from scratch: two steps, base then head.""" import os import subprocess @@ -7,7 +7,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -POST_PROGRESS = ROOT / "scripts" / "action" / "post-progress.sh" +UPDATE_PROGRESS = ROOT / "scripts" / "action" / "update-review-progress.sh" class ProgressCommentTests(unittest.TestCase): @@ -21,7 +21,7 @@ def setUp(self) -> None: def tearDown(self) -> None: self.temp_dir.cleanup() - def _post(self, step: str, elapsed: int, comment_id: str = "77", **extra: str) -> list[str]: + def _post(self, step: str, comment_id: str = "77", **extra: str) -> list[str]: gh = self.bin_dir / "gh" gh.write_text( "#!/usr/bin/env bash\n" @@ -31,7 +31,7 @@ def _post(self, step: str, elapsed: int, comment_id: str = "77", **extra: str) - ) gh.chmod(0o755) subprocess.run( - [str(POST_PROGRESS), step, str(elapsed)], + [str(UPDATE_PROGRESS), step], env={ "PATH": f"{self.bin_dir}:{os.environ['PATH']}", "RUNNER_TEMP": str(self.root), @@ -49,42 +49,26 @@ def _post(self, step: str, elapsed: int, comment_id: str = "77", **extra: str) - ) return [c for c in self.calls.read_text().split("\n----\n") if "PATCH" in c] if self.calls.exists() else [] - def test_the_base_step_shows_elapsed_minutes_and_the_reason(self) -> None: - (patch,) = self._post("base", 125, FULL_CAUSE="no_baseline") - self.assertIn("1. ⏳ Building the diagram of `main` @a1b2c3d from scratch · running for 2 min\n", patch) - self.assertIn(" `main` has no saved diagram yet, so this review builds one first.", patch) + def test_the_base_step_says_the_base_is_being_built_and_why(self) -> None: + (patch,) = self._post("base") + self.assertIn("1. ⏳ Building the diagram of `main` @a1b2c3d from scratch\n", patch) + self.assertIn(" `main` has no saved diagram this review can start from, so it builds one first.", patch) self.assertIn("\n2. Analysing this PR's changes\n", patch) - self.assertNotIn("estimate", patch.lower()) - def test_the_first_minute_does_not_read_as_zero(self) -> None: - (patch,) = self._post("base", 0) - self.assertIn("running for less than a minute", patch) - - def test_an_incompatible_base_says_why(self) -> None: - (patch,) = self._post("base", 60, FULL_CAUSE="incompatible") - self.assertIn("The saved diagram of `main` was made by a different engine version or settings", patch) - - def test_the_head_step_reports_the_measured_base_time(self) -> None: - (patch,) = self._post("head", 534) - self.assertIn("1. ✅ Built the diagram of `main` @a1b2c3d from scratch in 8 m 54 s\n", patch) + def test_the_head_step_marks_the_base_done(self) -> None: + (patch,) = self._post("head") + self.assertIn("1. ✅ Built the diagram of `main` @a1b2c3d from scratch\n", patch) self.assertIn("2. ⏳ Analysing this PR's changes", patch) self.assertTrue(patch.rstrip().endswith("")) def test_the_comment_is_looked_up_once(self) -> None: - self._post("base", 0) - self._post("base", 60) + self._post("base") + self._post("head") lookups = [c for c in self.calls.read_text().split("\n----\n") if "/comments?per_page" in c] self.assertEqual(len(lookups), 1) - def test_a_stopped_ticker_never_edits_after_the_next_step(self) -> None: - stop = self.root / "stop" - stop.touch() - self.assertEqual(self._post("base", 120, PROGRESS_STOP_FILE=str(stop)), []) - # The step-2 edit is the one that follows the stop, so it still lands. - self.assertEqual(len(self._post("head", 120, PROGRESS_STOP_FILE=str(stop))), 1) - def test_no_progress_comment_means_no_edit(self) -> None: - self.assertEqual(self._post("base", 0, comment_id=""), []) + self.assertEqual(self._post("base", comment_id=""), []) if __name__ == "__main__": diff --git a/tests/test_review_artifact_metadata.py b/tests/test_review_artifact_metadata.py index b630201..6b43526 100644 --- a/tests/test_review_artifact_metadata.py +++ b/tests/test_review_artifact_metadata.py @@ -65,30 +65,6 @@ def test_a_count_the_render_step_could_not_make_is_recorded_as_unknown(self) -> metadata, _outputs = _build(Path(tmp)) self.assertEqual(metadata["analysed_files_changed"], "unknown") - def test_how_the_base_was_obtained_is_recorded_as_strings(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - metadata, _outputs = _build( - Path(tmp), - BASE_ANALYSIS_METHOD="incremental", - BASE_ANALYSIS_REASON="updated the analysis of 9f8e7d6 to a1b2c3d, 3 commits caught up", - ) - self.assertEqual( - {key: metadata[key] for key in metadata if key.startswith("base_")}, - { - "base_sha": "tip-sha", - "base_artifact": "codeboarding-base-cfg-mergebasesha", - "base_artifact_id": "4242", - "base_analysis_method": "incremental", - "base_analysis_reason": "updated the analysis of 9f8e7d6 to a1b2c3d, 3 commits caught up", - }, - ) - - def test_an_older_run_without_provenance_records_empty_strings(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - metadata, _outputs = _build(Path(tmp)) - for key in ("base_analysis_method", "base_analysis_reason"): - self.assertEqual(metadata[key], "", key) - if __name__ == "__main__": unittest.main() From 17cd1e8162162e842b06e1336339966bb5223aea Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sat, 10 Oct 2026 03:41:25 +0200 Subject: [PATCH 08/19] ci: run review and sync from one CodeBoarding workflow codeboarding-sync.yml folds into codeboarding.yml as a second job, the way the README now recommends. Concurrency stays workflow-level so a closed pull request still cancels its running review, and syncs still share one group. The App token block both jobs repeated is now the local action .github/actions/app-token. Co-Authored-By: Claude Opus 5.5 --- .github/actions/app-token/action.yml | 83 +++++++++++++ .github/workflows/codeboarding-sync.yml | 152 ----------------------- .github/workflows/codeboarding.yml | 158 ++++++++++++++---------- 3 files changed, 174 insertions(+), 219 deletions(-) create mode 100644 .github/actions/app-token/action.yml delete mode 100644 .github/workflows/codeboarding-sync.yml diff --git a/.github/actions/app-token/action.yml b/.github/actions/app-token/action.yml new file mode 100644 index 0000000..3a99288 --- /dev/null +++ b/.github/actions/app-token/action.yml @@ -0,0 +1,83 @@ +name: 'CodeBoarding App token' +description: >- + Mints a CodeBoarding GitHub App token so comments and baseline commits appear as + codeboarding-review[bot] (the App avatar is the CodeBoarding logo) instead of + github-actions[bot]. Fails open: with App credentials absent or invalid, `token` + is empty and the caller falls back to github.token. End-user repositories cannot + reuse this, since the App private key is never shipped to their runners. +inputs: + client_id: + description: 'vars.CODEBOARDING_APP_CLIENT_ID' + required: false + default: '' + app_id: + description: 'vars.CODEBOARDING_APP_ID' + required: false + default: '' + private_key: + description: 'secrets.CODEBOARDING_APP_PRIVATE_KEY' + required: false + default: '' +outputs: + token: + description: 'The App installation token, or empty.' + value: ${{ steps.client.outputs.token || steps.app.outputs.token }} +runs: + using: 'composite' + steps: + - name: Detect CodeBoarding GitHub App credentials + id: config + shell: bash + env: + CLIENT_ID: ${{ inputs.client_id }} + APP_ID: ${{ inputs.app_id }} + PRIVATE_KEY: ${{ inputs.private_key }} + run: | + client_id="${CLIENT_ID:-}" + app_id="${APP_ID:-}" + + # GitHub App client IDs start with "Iv". If that value was stored in + # CODEBOARDING_APP_ID, use it as a client ID to avoid the deprecated + # app-id input path. + if [ -z "$client_id" ] && [ "${app_id#Iv}" != "$app_id" ]; then + client_id="$app_id" + app_id="" + fi + + private_key_valid=false + if [ -n "$PRIVATE_KEY" ]; then + if printf '%s' "$PRIVATE_KEY" | openssl pkey -noout >/dev/null 2>&1; then + private_key_valid=true + else + echo "::warning::CODEBOARDING_APP_PRIVATE_KEY is not a valid PEM private key, so CodeBoarding will fall back to github-actions[bot]." + if printf '%b' "$PRIVATE_KEY" | openssl pkey -noout >/dev/null 2>&1; then + printf '%s\n' "::warning::CODEBOARDING_APP_PRIVATE_KEY looks like it contains literal \\n escapes. Store the downloaded PEM as multi-line secret text instead." + fi + fi + fi + + { + [ -n "$client_id" ] && echo "has_client_id=true" || echo "has_client_id=false" + [ -n "$app_id" ] && echo "has_app_id=true" || echo "has_app_id=false" + echo "client_id=$client_id" + echo "private_key_valid=$private_key_valid" + } >> "$GITHUB_OUTPUT" + - uses: actions/create-github-app-token@v3 + id: client + if: steps.config.outputs.has_client_id == 'true' && steps.config.outputs.private_key_valid == 'true' + continue-on-error: true + with: + client-id: ${{ steps.config.outputs.client_id }} + private-key: ${{ inputs.private_key }} + - uses: actions/create-github-app-token@v3 + id: app + if: steps.config.outputs.has_client_id != 'true' && steps.config.outputs.has_app_id == 'true' && steps.config.outputs.private_key_valid == 'true' + continue-on-error: true + with: + app-id: ${{ inputs.app_id }} + private-key: ${{ inputs.private_key }} + - name: Warn when CodeBoarding App token is unavailable + if: steps.client.outputs.token == '' && steps.app.outputs.token == '' + shell: bash + run: | + echo "::warning::CodeBoarding GitHub App token is unavailable; falling back to github-actions[bot]. Check CODEBOARDING_APP_PRIVATE_KEY formatting if app credentials are configured." diff --git a/.github/workflows/codeboarding-sync.yml b/.github/workflows/codeboarding-sync.yml deleted file mode 100644 index c947eca..0000000 --- a/.github/workflows/codeboarding-sync.yml +++ /dev/null @@ -1,152 +0,0 @@ -# Dogfood sync mode: keep THIS repo's committed architecture baseline -# (.codeboarding/analysis.json + rendered docs) current on every push to main, -# so the PR review workflow always diffs against an up-to-date baseline. -# -# This is the ONLY baseline writer for this repo. The manual "rebuild from -# scratch" path (previously a separate refresh-baseline.yml) is now the -# workflow_dispatch + force_full input below, which runs the same tested action -# instead of a hand-rolled copy of its pipeline. - -name: CodeBoarding sync - -on: - push: - branches: [main] - # Loop guard: don't re-trigger on the files this workflow itself commits. - # This list must name EVERY generated artifact (an un-ignored one leaks the - # bot's own commit through and re-triggers), and must name ONLY generated - # artifacts — never a user-authored input, whose edit changes analysis scope - # and must regenerate the baseline. So: deliberately NOT '.codeboarding/**' - # (would swallow .codeboarding/.codeboardingignore) and NOT - # '.codeboarding/health/**' (would swallow the user-authored health/.healthignore - # and health/health_config.json) — only the generated health/health_report.json - # is listed. In push mode the action also skips re-analyzing its own commit by - # author email as a backstop; in pull_request mode this list is the loop guard - # (a merged baseline PR touches only these files), with the commit step's - # "nothing to commit" gate stopping any stray re-run. The bot commit uses no - # [skip ci] (that would leak through squash-merges and skip real merges). - paths-ignore: - - '.codeboarding/*.md' - - '.codeboarding/analysis.json' - - '.codeboarding/fingerprint.json' - - '.codeboarding/static_analysis.pkl' - - '.codeboarding/static_analysis.sha' - - '.codeboarding/codeboarding_version.json' - - '.codeboarding/health/health_report.json' - - 'docs/development/architecture.md' - workflow_dispatch: - inputs: - force_full: - description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).' - type: boolean - required: false - default: false - save_baseline_to: - description: 'Commit to main, open/update a rolling baseline PR, or commit to codeboarding/baseline.' - type: choice - options: [synced_branch, pull_request, baseline_branch] - required: false - default: synced_branch - -# No workflow-level permissions: the single job below requests only what it -# needs (least privilege), so the default token starts with none. -permissions: {} - -concurrency: - # Serialize this workflow against itself: a push landing while a manual - # dispatch is mid-run must not produce two concurrent commits to main. - group: codeboarding-baseline-writers - cancel-in-progress: false - -jobs: - sync: - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: write # push the generated baseline branch - pull-requests: write # workflow_dispatch may exercise pull_request delivery - id-token: write # mint per-request OIDC credentials for the relay - steps: - # Dogfood: run the action from the checked-out repo (uses: ./) so pushes to - # main exercise the action code on main, not the last published release. - # The action reads its scripts via github.action_path and checks the engine - # and target repo into subdirectories, so this local checkout is untouched. - - uses: actions/checkout@v4 - # Mint the CodeBoarding GitHub App token so the baseline commit lands as - # codeboarding-review[bot] (App avatar = the CodeBoarding logo) instead of - # the generic github-actions[bot]. Mirrors the review workflow's token flow. - # Fails open: when App credentials are absent/invalid we fall back to - # github.token below, and the commit shows the default Actions identity. - # End-user repos can't reuse this — the App private key is never shipped to - # their runners; they authenticate the free tier via OIDC (see README). - - name: Detect CodeBoarding GitHub App credentials - id: codeboarding-app-config - shell: bash - env: - CLIENT_ID: ${{ vars.CODEBOARDING_APP_CLIENT_ID }} - APP_ID: ${{ vars.CODEBOARDING_APP_ID }} - PRIVATE_KEY: ${{ secrets.CODEBOARDING_APP_PRIVATE_KEY }} - run: | - client_id="${CLIENT_ID:-}" - app_id="${APP_ID:-}" - - # GitHub App client IDs start with "Iv". If that value was stored in - # CODEBOARDING_APP_ID, use it as a client ID to avoid the deprecated - # app-id input path. - if [ -z "$client_id" ] && [ "${app_id#Iv}" != "$app_id" ]; then - client_id="$app_id" - app_id="" - fi - - has_private_key=false - private_key_valid=false - if [ -n "$PRIVATE_KEY" ]; then - has_private_key=true - if printf '%s' "$PRIVATE_KEY" | openssl pkey -noout >/dev/null 2>&1; then - private_key_valid=true - else - echo "::warning::CODEBOARDING_APP_PRIVATE_KEY is not a valid PEM private key, so the sync commit will fall back to github-actions[bot]." - if printf '%b' "$PRIVATE_KEY" | openssl pkey -noout >/dev/null 2>&1; then - printf '%s\n' "::warning::CODEBOARDING_APP_PRIVATE_KEY looks like it contains literal \\n escapes. Store the downloaded PEM as multi-line secret text instead." - fi - fi - fi - - { - [ -n "$client_id" ] && echo "has_client_id=true" || echo "has_client_id=false" - [ -n "$app_id" ] && echo "has_app_id=true" || echo "has_app_id=false" - echo "client_id=$client_id" - echo "has_private_key=$has_private_key" - echo "private_key_valid=$private_key_valid" - } >> "$GITHUB_OUTPUT" - - uses: actions/create-github-app-token@v3 - id: codeboarding-app-token-client - if: steps.codeboarding-app-config.outputs.has_client_id == 'true' && steps.codeboarding-app-config.outputs.private_key_valid == 'true' - continue-on-error: true - with: - client-id: ${{ steps.codeboarding-app-config.outputs.client_id }} - private-key: ${{ secrets.CODEBOARDING_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@v3 - id: codeboarding-app-token-app - if: steps.codeboarding-app-config.outputs.has_client_id != 'true' && steps.codeboarding-app-config.outputs.has_app_id == 'true' && steps.codeboarding-app-config.outputs.private_key_valid == 'true' - continue-on-error: true - with: - app-id: ${{ vars.CODEBOARDING_APP_ID }} - private-key: ${{ secrets.CODEBOARDING_APP_PRIVATE_KEY }} - - name: Warn when CodeBoarding App token is unavailable - if: steps.codeboarding-app-token-client.outputs.token == '' && steps.codeboarding-app-token-app.outputs.token == '' - shell: bash - run: | - echo "::warning::CodeBoarding GitHub App token is unavailable; the sync commit falls back to github-actions[bot]. Check CODEBOARDING_APP_PRIVATE_KEY formatting if app credentials are configured." - - uses: ./ - with: - mode: sync - # CodeBoarding's own repositories run on the CodeBoarding plan. - llm: license - license_key: ${{ secrets.CODEBOARDING_LICENSE }} - save_baseline_to: ${{ inputs.save_baseline_to || 'synced_branch' }} - force_full: ${{ inputs.force_full || false }} - # App token authenticates the baseline push so the commit is attributed - # to the CodeBoarding App (logo avatar). Falls back to the workflow token, - # which can push because this job grants contents: write. - github_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index 3495800..0ac9c61 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -1,4 +1,11 @@ -name: CodeBoarding review +# Dogfood: this repository reviews its pull requests and keeps its own baseline +# current with the action checked out here (uses: ./), so every change exercises +# the action code on that ref rather than the last published release. +# +# review pull requests and /codeboarding comments +# sync pushes to main, and manual runs (workflow_dispatch) + +name: CodeBoarding on: pull_request: @@ -8,15 +15,55 @@ on: types: [opened, reopened, closed, synchronize] issue_comment: types: [created] + push: + branches: [main] + # Loop guard: don't re-trigger on the files sync itself commits to main. + # This list must name EVERY generated artifact (an un-ignored one leaks the + # bot's own commit through and re-triggers), and must name ONLY generated + # artifacts — never a user-authored input, whose edit changes analysis scope + # and must regenerate the baseline. So: deliberately NOT '.codeboarding/**' + # (would swallow .codeboarding/.codeboardingignore) and NOT + # '.codeboarding/health/**' (would swallow the user-authored health/.healthignore + # and health/health_config.json) — only the generated health/health_report.json + # is listed. The action also skips re-analyzing its own commit by author email + # as a backstop. The bot commit uses no [skip ci] (that would leak through + # squash-merges and skip real merges). + paths-ignore: + - '.codeboarding/*.md' + - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' + - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + - 'docs/development/architecture.md' + workflow_dispatch: + inputs: + force_full: + description: 'Ignore the saved baseline and rebuild it from scratch (full analysis).' + type: boolean + required: false + default: false + save_baseline_to: + description: 'Commit to main, open/update a rolling baseline PR, or commit to codeboarding/baseline.' + type: choice + options: [synced_branch, pull_request, baseline_branch] + required: false + default: synced_branch -# No workflow-level permissions: the single job below requests only what it -# needs (least privilege), so the default token starts with none. +# No workflow-level permissions: each job requests only what it needs (least +# privilege), so the default token starts with none. permissions: {} +# Workflow-level, not per job: a 'closed' run skips the review job, and only a run +# that joins the group can cancel the review still in flight. +# review one group per pull request; queue, and cancel only when the PR closes — +# bot comments (issue_comment) and re-triggers must not cancel a running +# review, they queue behind it instead. +# sync one group for every baseline writer: a push landing while a manual +# dispatch is mid-run must not produce two concurrent commits to main. concurrency: - group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} - # Cancel only when the PR closes — bot comments (issue_comment) and re-triggers - # must not cancel a running review; they queue behind it instead. + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number || 'baseline-writers' }} cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} jobs: @@ -25,8 +72,8 @@ jobs: timeout-minutes: 60 permissions: # Review mode reads the repo + committed baseline and posts a PR comment; - # it does NOT commit generated files back to the branch (that is sync mode - # only — see action.yml `mode` input). So contents stays read-only. + # it does NOT commit generated files back to the branch (that is the sync + # job below). So contents stays read-only. contents: read actions: read # download the analysis an earlier run published pull-requests: write # post / update the architecture-diff PR comment @@ -49,65 +96,12 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'issue_comment' && github.event.repository.default_branch || '' }} - - name: Detect CodeBoarding GitHub App credentials - id: codeboarding-app-config - shell: bash - env: - CLIENT_ID: ${{ vars.CODEBOARDING_APP_CLIENT_ID }} - APP_ID: ${{ vars.CODEBOARDING_APP_ID }} - PRIVATE_KEY: ${{ secrets.CODEBOARDING_APP_PRIVATE_KEY }} - run: | - client_id="${CLIENT_ID:-}" - app_id="${APP_ID:-}" - - # GitHub App client IDs start with "Iv". If that value was stored in - # CODEBOARDING_APP_ID, use it as a client ID to avoid the deprecated - # app-id input path. - if [ -z "$client_id" ] && [ "${app_id#Iv}" != "$app_id" ]; then - client_id="$app_id" - app_id="" - fi - - has_private_key=false - private_key_valid=false - if [ -n "$PRIVATE_KEY" ]; then - has_private_key=true - if printf '%s' "$PRIVATE_KEY" | openssl pkey -noout >/dev/null 2>&1; then - private_key_valid=true - else - echo "::warning::CODEBOARDING_APP_PRIVATE_KEY is not a valid PEM private key, so CodeBoarding will fall back to github-actions[bot]." - if printf '%b' "$PRIVATE_KEY" | openssl pkey -noout >/dev/null 2>&1; then - printf '%s\n' "::warning::CODEBOARDING_APP_PRIVATE_KEY looks like it contains literal \\n escapes. Store the downloaded PEM as multi-line secret text instead." - fi - fi - fi - - { - [ -n "$client_id" ] && echo "has_client_id=true" || echo "has_client_id=false" - [ -n "$app_id" ] && echo "has_app_id=true" || echo "has_app_id=false" - echo "client_id=$client_id" - echo "has_private_key=$has_private_key" - echo "private_key_valid=$private_key_valid" - } >> "$GITHUB_OUTPUT" - - uses: actions/create-github-app-token@v3 - id: codeboarding-app-token-client - if: steps.codeboarding-app-config.outputs.has_client_id == 'true' && steps.codeboarding-app-config.outputs.private_key_valid == 'true' - continue-on-error: true - with: - client-id: ${{ steps.codeboarding-app-config.outputs.client_id }} - private-key: ${{ secrets.CODEBOARDING_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@v3 - id: codeboarding-app-token-app - if: steps.codeboarding-app-config.outputs.has_client_id != 'true' && steps.codeboarding-app-config.outputs.has_app_id == 'true' && steps.codeboarding-app-config.outputs.private_key_valid == 'true' - continue-on-error: true + - uses: ./.github/actions/app-token + id: app with: - app-id: ${{ vars.CODEBOARDING_APP_ID }} - private-key: ${{ secrets.CODEBOARDING_APP_PRIVATE_KEY }} - - name: Warn when CodeBoarding App token is unavailable - if: steps.codeboarding-app-token-client.outputs.token == '' && steps.codeboarding-app-token-app.outputs.token == '' - shell: bash - run: | - echo "::warning::CodeBoarding GitHub App token is unavailable; falling back to github-actions[bot]. Check CODEBOARDING_APP_PRIVATE_KEY formatting if app credentials are configured." + client_id: ${{ vars.CODEBOARDING_APP_CLIENT_ID }} + app_id: ${{ vars.CODEBOARDING_APP_ID }} + private_key: ${{ secrets.CODEBOARDING_APP_PRIVATE_KEY }} - uses: ./ with: # Named, not inferred: the action refuses to guess where credentials come @@ -115,4 +109,34 @@ jobs: # wants. CodeBoarding's own repositories run on the CodeBoarding plan. llm: license license_key: ${{ secrets.CODEBOARDING_LICENSE }} - github_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} + github_token: ${{ steps.app.outputs.token || github.token }} + + # The ONLY baseline writer for this repository. The manual "rebuild from + # scratch" path is workflow_dispatch + force_full, which runs the same tested + # action instead of a hand-rolled copy of its pipeline. + sync: + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write # push the generated baseline + pull-requests: write # workflow_dispatch may exercise pull_request delivery + id-token: write # mint per-request OIDC credentials for the relay + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/app-token + id: app + with: + client_id: ${{ vars.CODEBOARDING_APP_CLIENT_ID }} + app_id: ${{ vars.CODEBOARDING_APP_ID }} + private_key: ${{ secrets.CODEBOARDING_APP_PRIVATE_KEY }} + - uses: ./ + with: + mode: sync + llm: license + license_key: ${{ secrets.CODEBOARDING_LICENSE }} + save_baseline_to: ${{ inputs.save_baseline_to || 'synced_branch' }} + force_full: ${{ inputs.force_full || false }} + # The App token attributes the baseline commit to the CodeBoarding App. + # The workflow token can push too, since this job grants contents: write. + github_token: ${{ steps.app.outputs.token || github.token }} From 1560c905dd74bb3a6c084eff1cccb02a497752a0 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sat, 10 Oct 2026 03:46:11 +0200 Subject: [PATCH 09/19] fix: keep analysis credentials for the review's second analysis step with-auth.sh removes the credentials once its command finishes, so splitting the review into a baseline step and a head step left the head step with none. The baseline step now keeps them (KEEP_AUTH), the head step removes them, and a final always() step clears them if a failure skipped the head step. Co-Authored-By: Claude Opus 5.5 --- action.yml | 8 ++++ scripts/action/clear-auth.sh | 9 +++++ scripts/action/with-auth.sh | 10 ++--- tests/test_with_auth.py | 71 ++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 7 deletions(-) create mode 100755 scripts/action/clear-auth.sh create mode 100644 tests/test_with_auth.py diff --git a/action.yml b/action.yml index 78c0a7c..9c7e406 100644 --- a/action.yml +++ b/action.yml @@ -587,6 +587,8 @@ runs: MODEL: ${{ inputs.model }} AGENT_MODEL_INPUT: ${{ inputs.agent_model }} PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} + # The head step below analyzes with the same credentials, and removes them. + KEEP_AUTH: 'true' run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/review-generate-baseline.sh"' - name: Analyze pull request @@ -610,6 +612,12 @@ runs: PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} run: '"$GITHUB_ACTION_PATH/scripts/action/with-auth.sh" "$GITHUB_ACTION_PATH/scripts/action/review-analyze-head.sh"' + # The head step removes the credentials; this covers a run that failed before it. + - name: Remove analysis credentials + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + shell: bash + run: '"$GITHUB_ACTION_PATH/scripts/action/clear-auth.sh"' + # Published before the review is rendered: the analysis is the expensive part, # so a later rendering or posting failure must not throw it away. Only the # next run reads this, hence the short retention. diff --git a/scripts/action/clear-auth.sh b/scripts/action/clear-auth.sh new file mode 100755 index 0000000..b14eabd --- /dev/null +++ b/scripts/action/clear-auth.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Stops the credential relay and deletes the resolved credentials. with-auth.sh +# calls it after its command; the action also runs it once at the end, so a step +# that kept credentials for the next one never leaves them behind. +AUTH_DIR="${RUNNER_TEMP}/codeboarding-auth" +if [ -s "$AUTH_DIR/relay.pid" ]; then + kill "$(cat "$AUTH_DIR/relay.pid")" 2>/dev/null || true +fi +rm -rf "$AUTH_DIR" diff --git a/scripts/action/with-auth.sh b/scripts/action/with-auth.sh index 1b4f1ad..57a8b00 100755 --- a/scripts/action/with-auth.sh +++ b/scripts/action/with-auth.sh @@ -1,14 +1,10 @@ #!/usr/bin/env bash # Runs one command with the resolved provider credentials, then removes them. +# KEEP_AUTH=true leaves them for a following step that analyzes too; the action's +# final clear-auth.sh step removes them then. set -euo pipefail AUTH_DIR="${RUNNER_TEMP}/codeboarding-auth" -cleanup() { - if [ -s "$AUTH_DIR/relay.pid" ]; then - kill "$(cat "$AUTH_DIR/relay.pid")" 2>/dev/null || true - fi - rm -rf "$AUTH_DIR" -} -trap cleanup EXIT +[ "${KEEP_AUTH:-false}" = true ] || trap '"$(dirname "$0")/clear-auth.sh"' EXIT if [ ! -s "$AUTH_DIR/tier" ] || [ ! -s "$AUTH_DIR/provider-name" ]; then echo "::error::CodeBoarding analysis credentials are unavailable." diff --git a/tests/test_with_auth.py b/tests/test_with_auth.py new file mode 100644 index 0000000..49cfdbc --- /dev/null +++ b/tests/test_with_auth.py @@ -0,0 +1,71 @@ +"""Credentials last exactly as long as the analysis steps that need them.""" + +from __future__ import annotations + +import os +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +WITH_AUTH = ROOT / "scripts" / "action" / "with-auth.sh" +ACTION = (ROOT / "action.yml").read_text(encoding="utf-8") + + +class WithAuthTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.runner_temp = Path(self.temp_dir.name) + self.auth = self.runner_temp / "codeboarding-auth" + self.auth.mkdir() + (self.auth / "tier").write_text("byok", encoding="utf-8") + (self.auth / "provider-name").write_text("openai", encoding="utf-8") + + def _run(self, **extra: str) -> subprocess.CompletedProcess: + return subprocess.run( + [str(WITH_AUTH), "true"], + env={"PATH": os.environ["PATH"], "RUNNER_TEMP": str(self.runner_temp), **extra}, + capture_output=True, + text=True, + check=False, + ) + + def test_credentials_are_removed_after_the_command(self) -> None: + self.assertEqual(self._run().returncode, 0) + self.assertFalse(self.auth.exists()) + + def test_a_step_that_keeps_them_leaves_them_for_the_next(self) -> None: + self.assertEqual(self._run(KEEP_AUTH="true").returncode, 0) + self.assertTrue((self.auth / "tier").exists()) + # The next step analyzes, then removes them. + self.assertEqual(self._run().returncode, 0, "the second analysis step found no credentials") + self.assertFalse(self.auth.exists()) + + +class ActionWiringTests(unittest.TestCase): + def _steps(self) -> list[str]: + runs = ACTION[ACTION.index("\nruns:\n") :] + return re.split(r"\n - name: ", runs)[1:] + + def test_every_step_but_the_last_of_a_mode_keeps_credentials(self) -> None: + """with-auth.sh consumes credentials; a second analysis step in one run needs them kept.""" + for mode in ("review", "sync"): + steps = [s for s in self._steps() if "with-auth.sh" in s and f"mode == '{mode}'" in s] + self.assertTrue(steps, mode) + for step in steps[:-1]: + self.assertIn("KEEP_AUTH: 'true'", step, f"{mode}: {step.splitlines()[0]} consumes the credentials") + self.assertNotIn("KEEP_AUTH", steps[-1], f"{mode}: the last analysis step must remove them") + + def test_kept_credentials_are_always_removed(self) -> None: + names = [s.splitlines()[0] for s in self._steps()] + cleanup = self._steps()[names.index("Remove analysis credentials")] + self.assertIn("clear-auth.sh", cleanup) + self.assertIn("always()", cleanup) + self.assertGreater(names.index("Remove analysis credentials"), names.index("Analyze pull request")) + + +if __name__ == "__main__": + unittest.main() From 7776d203b83d4c163782983f06cfdca61e3e363a Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sat, 10 Oct 2026 03:48:05 +0200 Subject: [PATCH 10/19] fix: let sync read the analyses reviews save Sync catches up from a saved ancestor analysis, which needs actions: read to list artifacts. Without it the lookup fails quietly and sync analyzes in full. The README's sync setups and this repository's sync job now grant it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/codeboarding.yml | 1 + README.md | 5 ++++- tests/test_action_inputs.py | 9 +++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index 0ac9c61..8b25e79 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -120,6 +120,7 @@ jobs: timeout-minutes: 60 permissions: contents: write # push the generated baseline + actions: read # catch up from an analysis a review saved pull-requests: write # workflow_dispatch may exercise pull_request delivery id-token: write # mint per-request OIDC credentials for the relay steps: diff --git a/README.md b/README.md index 12da9c8..5ba479a 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ on: permissions: contents: write + actions: read # catch up from an analysis a review saved id-token: write concurrency: @@ -287,6 +288,7 @@ Set `save_baseline_to: pull_request` and grant `pull-requests: write`: ```yaml permissions: contents: write + actions: read pull-requests: write id-token: write @@ -318,7 +320,7 @@ Set `save_baseline_to: baseline_branch` to keep the analysis off your code branc `main` is then only read. Each sync adds one commit to `codeboarding/baseline` in the same repository (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 and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. 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 should import to protect it: sync and review load a pickle from it. -No permission beyond the `contents: write` every sync already needs: the first sync creates the branch with an ordinary push. If you renamed it with `baseline_branch`, set the same name in your review workflow too, since reviews read it — or keep both jobs in one workflow, below. +No permission beyond what every sync already has: the first sync creates the branch with an ordinary push, which `contents: write` covers. If you renamed it with `baseline_branch`, set the same name in your review workflow too, since reviews read it — or keep both jobs in one workflow, below. **Moving an existing setup.** Nothing changes until you opt in: the default keeps committing to the synced branch. To switch, paste this into your coding agent: @@ -400,6 +402,7 @@ jobs: timeout-minutes: 60 permissions: contents: write + actions: read id-token: write steps: - uses: CodeBoarding/CodeBoarding-action@v1 diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 233987e..82f0b41 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -86,6 +86,15 @@ def test_the_inferred_credential_inputs_are_gone(self) -> None: self.assertNotIn(stale, self.inputs) self.assertNotIn(f"inputs.{stale}", ACTION) + def test_every_sync_setup_can_read_saved_analyses(self) -> None: + """Sync catches up from an analysis a review saved; without actions: read it silently analyzes in full.""" + readme = (ROOT / "README.md").read_text(encoding="utf-8") + sync_setups = [block for block in readme.split("```yaml")[1:] if "mode: sync" in block] + sync_setups.append(DOGFOOD[DOGFOOD.index("\n sync:\n") :]) + for block in sync_setups: + if "permissions:" in block: + self.assertIn("actions: read", block, block[:200]) + def test_deprecated_inputs_are_read_only_by_the_migrator(self) -> None: """Backward compatibility lives in one script; every other step reads its outputs.""" for old, new in (("target_branch", "synced_branch"), ("sync_strategy", "save_baseline_to")): From 6597352fa2e5f3de6cf0d4f9bd6b2ea05610834a Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sat, 10 Oct 2026 18:30:11 +0200 Subject: [PATCH 11/19] feat: keep the analysis on codeboarding_analysis_branch, and fail instead of degrading silently One input, codeboarding_analysis_branch (default codeboarding/baseline), replaces synced_branch, save_baseline_to and baseline_branch. Sync analyzes the branch it runs on; naming that branch commits .codeboarding/ there instead of to the analysis branch. Rolling sync pull requests are gone. The released target_branch and sync_strategy inputs are rewritten into the new input by old_api_migrator.sh, or fail with a link to the migration steps; no other script knows they exist. Failures that used to fall back to a full analysis without saying so now fail: an analysis branch that cannot be read, an entry that cannot be fetched, an unreadable engine version, an empty analysis branch name, and a sync run on the analysis branch itself. Sync no longer deletes v1-generated Markdown, and reads Core's artifact manifest without the fallback for engines older than the pinned one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/codeboarding.yml | 20 +--- AGENTS.md | 7 +- README.md | 138 +++++++++-------------- action.yml | 54 +++------ docs/COMMIT_STRATEGY.md | 15 +-- scripts/action/codeboarding-baseline.sh | 47 +++++--- scripts/action/deliver-sync.sh | 70 +----------- scripts/action/install-sync.sh | 22 +--- scripts/action/old_api_migrator.sh | 64 +++++++---- scripts/action/resolve-cache-keys.sh | 8 +- scripts/action/resolve-github-event.sh | 34 +++--- scripts/action/sync-generate-baseline.sh | 8 +- scripts/action/sync-summary.sh | 5 +- tests/test_action_inputs.py | 5 +- tests/test_action_state.py | 21 +++- tests/test_action_sync.py | 89 ++++----------- tests/test_baseline_branch.py | 103 +++++++++++------ tests/test_old_api_migrator.py | 80 ++++++++----- 18 files changed, 354 insertions(+), 436 deletions(-) diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index 8b25e79..16dfe75 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -29,14 +29,12 @@ on: # as a backstop. The bot commit uses no [skip ci] (that would leak through # squash-merges and skip real merges). paths-ignore: - - '.codeboarding/*.md' - '.codeboarding/analysis.json' - '.codeboarding/fingerprint.json' - '.codeboarding/static_analysis.pkl' - '.codeboarding/static_analysis.sha' - '.codeboarding/codeboarding_version.json' - '.codeboarding/health/health_report.json' - - 'docs/development/architecture.md' workflow_dispatch: inputs: force_full: @@ -44,12 +42,6 @@ on: type: boolean required: false default: false - save_baseline_to: - description: 'Commit to main, open/update a rolling baseline PR, or commit to codeboarding/baseline.' - type: choice - options: [synced_branch, pull_request, baseline_branch] - required: false - default: synced_branch # No workflow-level permissions: each job requests only what it needs (least # privilege), so the default token starts with none. @@ -79,13 +71,9 @@ jobs: pull-requests: write # post / update the architecture-diff PR comment issues: write # the /codeboarding issue_comment trigger + comment API id-token: write # mint per-request OIDC credentials for the relay - # Never auto-review the fixed machine-owned 'codeboarding/sync' PR: it only - # changes generated files, so a diff comment would be noise. Scope this to - # this repository so a fork using the same branch name is still reviewed. if: > (github.event_name == 'pull_request' && github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository && - !(github.head_ref == 'codeboarding/sync' && github.event.pull_request.head.repo.full_name == github.repository)) || + github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/codeboarding') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) @@ -109,6 +97,8 @@ jobs: # wants. CodeBoarding's own repositories run on the CodeBoarding plan. llm: license license_key: ${{ secrets.CODEBOARDING_LICENSE }} + # This repository commits its analysis to main, so reviews read it there. + codeboarding_analysis_branch: main github_token: ${{ steps.app.outputs.token || github.token }} # The ONLY baseline writer for this repository. The manual "rebuild from @@ -121,7 +111,6 @@ jobs: permissions: contents: write # push the generated baseline actions: read # catch up from an analysis a review saved - pull-requests: write # workflow_dispatch may exercise pull_request delivery id-token: write # mint per-request OIDC credentials for the relay steps: - uses: actions/checkout@v4 @@ -136,7 +125,8 @@ jobs: mode: sync llm: license license_key: ${{ secrets.CODEBOARDING_LICENSE }} - save_baseline_to: ${{ inputs.save_baseline_to || 'synced_branch' }} + # Must match the review job above, which reads it. + codeboarding_analysis_branch: main force_full: ${{ inputs.force_full || false }} # The App token attributes the baseline commit to the CodeBoarding App. # The workflow token can push too, since this job grants contents: write. diff --git a/AGENTS.md b/AGENTS.md index fd9fbbd..690ef75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,10 @@ This repo is a GitHub Action with two modes, selected by the `mode` input: - **`mode: review`** (default): analyzes a PR with the CodeBoarding engine and posts a Mermaid diagram of the added, modified, and removed components as a PR comment (runs on `pull_request` / `issue_comment`). -- **`mode: sync`**: on push to a branch, regenerates the architecture and commits - the versioned baseline (`.codeboarding/analysis.json` + rendered markdown) back - to the branch, so review mode always diffs against a current baseline. +- **`mode: sync`**: on push to a branch, regenerates the analysis and saves the + `.codeboarding/` state to `codeboarding_analysis_branch` (an orphan + `codeboarding/baseline` branch by default, or the code branch itself when the + input names it), so review mode always diffs against a current baseline. The action is a thin orchestration wrapper, not the analysis engine: the engine (`CodeBoarding/CodeBoarding`) is a separate repo checked out at runtime and diff --git a/README.md b/README.md index 5ba479a..52efda4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ One GitHub Action with two modes: - **`review`** (default) compares a pull request's head with its merge base, posts an inline Mermaid architecture diff, and uploads both analyses as a workflow artifact. -- **`sync`** updates the versioned analysis state used by future incremental runs. It can push directly or open one rolling PR for protected branches. +- **`sync`** updates the versioned analysis state used by future incremental runs. By default it saves to a branch of its own, so code branches are never written. The action is a thin wrapper around the [CodeBoarding](https://github.com/CodeBoarding/CodeBoarding) CLI. Analysis logic and provider defaults live in Core, not in this repository. @@ -223,7 +223,7 @@ Set only `model` when both jobs should use the same model. Set either specialize ## Keep the baseline current -Sync mode commits only Core's persisted incremental-analysis state under `.codeboarding/`: +Sync mode saves Core's persisted incremental-analysis state under `.codeboarding/`: - `analysis.json` - `fingerprint.json` @@ -232,9 +232,9 @@ Sync mode commits only Core's persisted incremental-analysis state under `.codeb - `codeboarding_version.json` when emitted by Core - `health/health_report.json` -It does **not** render or commit architecture Markdown. Existing v1-generated `.codeboarding/*.md` and `docs/development/architecture.md` files carrying CodeBoarding's generated badge are removed on the first v2 sync. Hand-written Markdown and user-authored CodeBoarding configuration, including `health/health_config.json` and `health/.healthignore`, are preserved. +It does **not** render or commit architecture Markdown, and leaves every other file alone, including user-authored CodeBoarding configuration such as `health/health_config.json` and `health/.healthignore`. -Create `.github/workflows/codeboarding-sync.yml`: +Sync analyzes the branch it runs on, and saves the result to `codeboarding_analysis_branch`, which is `codeboarding/baseline` unless you name another. Create `.github/workflows/codeboarding-sync.yml`: ```yaml name: CodeBoarding sync @@ -242,16 +242,10 @@ name: CodeBoarding sync on: push: branches: [main] - paths-ignore: - - '.codeboarding/analysis.json' - - '.codeboarding/fingerprint.json' - - '.codeboarding/static_analysis.pkl' - - '.codeboarding/static_analysis.sha' - - '.codeboarding/codeboarding_version.json' workflow_dispatch: inputs: force_full: - description: Rebuild without the committed baseline + description: Rebuild without the saved baseline type: boolean default: false @@ -273,78 +267,46 @@ jobs: with: mode: sync llm: hosted - synced_branch: main force_full: ${{ inputs.force_full || false }} ``` -`synced_branch` is the code branch sync keeps an up-to-date analysis of; it defaults to the branch that triggered the run. `save_baseline_to` chooses where that analysis is saved: `synced_branch` (the default, a commit on that branch), `pull_request` (a rolling PR into it) or `baseline_branch` (a branch of its own, see below). +`main` is only read. Each sync adds one commit to `codeboarding/baseline` in the same repository, an orphan branch that shares no history with `main`. It holds the `.codeboarding/` files listed above, plus `.codeboarding/source.json` naming the commit they describe and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. Pushes only ever fast-forward, and no code branch is ever written, so the `push` trigger needs no `paths-ignore` and a protected `main` needs no exception. Reviews read their base from the branch, and the web platform reads the latest diagram from it. The [analysis branch section](docs/COMMIT_STRATEGY.md#the-baseline-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. -The first run, `force_full: true`, or an incompatible baseline causes a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the synced branch advances while analysis is running, the stale result is not rebased onto code it did not analyze; the newer push run is allowed to produce the current baseline. +No permission beyond `contents: write` is needed: the first sync creates the branch with an ordinary push. If you rename it with `codeboarding_analysis_branch`, set the same name in your review workflow too, since reviews read it — or keep both jobs in one workflow, below. -### Protected branches +To sync more than one branch, list them under `push: branches:`. Each run analyzes the branch that was pushed, and every pull request is compared with the analysis of its own merge base. -Set `save_baseline_to: pull_request` and grant `pull-requests: write`: +The first run, `force_full: true`, or an incompatible baseline causes a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the synced branch advances while analysis is running, the stale result is not saved for code it did not analyze; the newer push run is allowed to produce the current baseline. -```yaml -permissions: - contents: write - actions: read - pull-requests: write - id-token: write +### Keep the analysis on the code branch -# ... - - uses: CodeBoarding/CodeBoarding-action@v1 - with: - mode: sync - llm: hosted - synced_branch: main - save_baseline_to: pull_request -``` - -Generation is identical to direct push. Only delivery changes: the same commit is force-with-lease pushed to the machine-owned `codeboarding/sync` branch and one rolling PR is opened into `synced_branch`. When there is no longer a generated diff, an obsolete rolling PR is closed. - -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. - -### Save the diagram to a branch of its own - -Set `save_baseline_to: baseline_branch` to keep the analysis off your code branches entirely: +Set `codeboarding_analysis_branch` to the code branch itself to commit `.codeboarding/` there instead, next to the code. Set it in the review workflow as well, so reviews read it from there, and add a `paths-ignore` so sync's own commit does not trigger another sync: ```yaml +on: + push: + branches: [main] + paths-ignore: + - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' + - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + +# ... - uses: CodeBoarding/CodeBoarding-action@v1 with: mode: sync llm: hosted - synced_branch: main - save_baseline_to: baseline_branch -``` - -`main` is then only read. Each sync adds one commit to `codeboarding/baseline` in the same repository (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 and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. 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 should import to protect it: sync and review load a pickle from it. - -No permission beyond what every sync already has: the first sync creates the branch with an ordinary push, which `contents: write` covers. If you renamed it with `baseline_branch`, set the same name in your review workflow too, since reviews read it — or keep both jobs in one workflow, below. - -**Moving an existing setup.** Nothing changes until you opt in: the default keeps committing to the synced branch. To switch, paste this into your coding agent: - -```text -Move this repository's CodeBoarding sync to save_baseline_to: baseline_branch. -1. In the workflow that runs CodeBoarding/CodeBoarding-action with mode: sync, set - `save_baseline_to: baseline_branch` in its `with:` block, replacing any - sync_strategy. Rename target_branch to synced_branch if it is set. Keep every - other input. -2. Delete the generated files under .codeboarding/ from the default branch, keeping - the user configuration: .codeboarding/.codeboardingignore, - .codeboarding/health/health_config.json and .codeboarding/health/.healthignore. - Reviews read the baseline branch first, so this is cleanup, not a requirement. -3. Remove any .gitattributes lines that mark .codeboarding/ files as - linguist-generated, if nothing else is left under .codeboarding/ for them. -4. Open a pull request with these changes. After it merges, close any open - pull request from the codeboarding/sync branch and delete that branch. + codeboarding_analysis_branch: main ``` -The first sync after the merge creates `codeboarding/baseline`, catching up from a saved analysis when there is one. +The list names only generated files on purpose: an edit to `.codeboarding/.codeboardingignore` or the health configuration changes what is analyzed, and must still trigger a sync. Sync pushes to `main` directly, so a branch rule protecting `main` needs the identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor, or a `github_token` that may push there. ### Review and sync in one workflow -The two workflows above can be one file with two jobs. Each job keeps its own permissions, and settings both modes read live in one `env:` block, so review always looks for the baseline where sync saves it: +The two workflows above can be one file with two jobs, each keeping its own permissions. ```yaml name: CodeBoarding @@ -355,7 +317,7 @@ on: issue_comment: types: [created] push: - branches: [main] # the synced branch; `on:` cannot read env + branches: [main] workflow_dispatch: inputs: force_full: @@ -363,10 +325,6 @@ on: type: boolean default: false -env: - SYNCED_BRANCH: main - BASELINE_BRANCH: codeboarding/baseline - permissions: {} # Reviews queue per pull request, syncs per branch. @@ -394,7 +352,6 @@ jobs: - uses: CodeBoarding/CodeBoarding-action@v1 with: llm: hosted - baseline_branch: ${{ env.BASELINE_BRANCH }} sync: if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' @@ -409,13 +366,32 @@ jobs: with: mode: sync llm: hosted - synced_branch: ${{ env.SYNCED_BRANCH }} - save_baseline_to: baseline_branch - baseline_branch: ${{ env.BASELINE_BRANCH }} force_full: ${{ inputs.force_full || false }} ``` -With `save_baseline_to: baseline_branch` sync never commits to `main`, so the `push` trigger needs no `paths-ignore`. Existing two-file setups keep working unchanged; this is only a different way to call the same action. +Both jobs use the default `codeboarding_analysis_branch`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Existing two-file setups keep working unchanged; this is only a different way to call the same action. + +### Moving an existing setup + +Before this release sync committed `.codeboarding/` to the synced branch by default; it now saves to `codeboarding/baseline`. A workflow that set `sync_strategy: push` keeps committing to the code branch, and one that set `sync_strategy: pull_request` moves to `codeboarding/baseline`, each with a warning. A `target_branch` naming a branch other than the one sync runs on fails, since sync now analyzes the branch it runs on. A workflow that set none of them moves to `codeboarding/baseline`; the `.codeboarding/` committed on the code branch is then no longer updated. To finish the move, paste this into your coding agent: + +```text +Move this repository's CodeBoarding sync to the codeboarding/baseline analysis branch. +1. In the workflows that run CodeBoarding/CodeBoarding-action, remove the + sync_strategy, target_branch and codeboarding_analysis_branch inputs if set. + Keep every other input. +2. Delete the generated files under .codeboarding/ from the default branch, keeping + the user configuration: .codeboarding/.codeboardingignore, + .codeboarding/health/health_config.json and .codeboarding/health/.healthignore. +3. Remove the paths-ignore entries for generated .codeboarding/ files from the + sync workflow's push trigger, and any .gitattributes lines that mark + .codeboarding/ files as linguist-generated, if nothing else is left under + .codeboarding/ for them. +4. Open a pull request with these changes. After it merges, close any open + pull request from the codeboarding/sync branch and delete that branch. +``` + +The first sync after the merge creates `codeboarding/baseline`, catching up from a saved analysis when there is one. To keep committing to the code branch instead, set `codeboarding_analysis_branch` to it, as [above](#keep-the-analysis-on-the-code-branch). ## Inputs @@ -432,15 +408,13 @@ With `save_baseline_to: baseline_branch` sync never commits to `main`, so the `p | `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. | -| `synced_branch` | sync | event branch | Code branch sync keeps an up-to-date analysis of. | -| `save_baseline_to` | sync | `synced_branch` | Where sync saves the analysis: `synced_branch` (a commit on it), `pull_request` (a rolling PR into it), or `baseline_branch` (a commit on `baseline_branch`; the synced branch is never written). | -| `baseline_branch` | both | `codeboarding/baseline` | Branch `save_baseline_to: baseline_branch` saves the analysis to; reviews read their base from it when it exists. | -| `force_full` | sync | `false` | Ignore the committed baseline for this run. | +| `codeboarding_analysis_branch` | both | `codeboarding/baseline` | Branch the analysis is kept on: sync saves to it, reviews read their base from it. Set it to the code branch (e.g. `main`) to commit `.codeboarding/` there instead. | +| `force_full` | sync | `false` | Run a full analysis instead of continuing from a saved one. | | `warmstart_retention_days` | review | `1` | Days to keep the reusable analysis. Only the next run reads it. | -`target_branch` and `sync_strategy` are deprecated names for `synced_branch` and `save_baseline_to` (`push` is `synced_branch`, `pull_request` is `pull_request`). They still work, with a warning; setting a value under both names fails. +`target_branch` and `sync_strategy` are deprecated, and read as the `codeboarding_analysis_branch` they stand for, with a warning: `sync_strategy: push` as the synced branch, `sync_strategy: pull_request` as the default. `target_branch` is accepted only when it names the branch sync runs on. Anything the new input cannot express fails, linking to [moving an existing setup](#moving-an-existing-setup). -The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, rolling sync branch, commit message, and CodeBoarding 0.14.5 version are intentionally fixed rather than exposed as configuration. +The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, commit message, and CodeBoarding 0.14.5 version are intentionally fixed rather than exposed as configuration. Review mode needs no sync workflow or committed `.codeboarding` directory. If no usable merge-base analysis exists, it runs full analysis there directly, then @@ -480,12 +454,10 @@ breaking CLI migration. | `analysis_mode` | sync | `incremental` or `full`. | | `files_written` | sync | Number of persisted analysis artifacts produced. | | `committed` | sync | Whether a baseline commit was delivered. | -| `sync_pr_url` | sync | Rolling PR URL for PR delivery. | -| `sync_pr_number` | sync | Rolling PR number for PR delivery. | ## GitHub Enterprise Server -Repository fetches, comments, pushes, and rolling-PR API calls use `github.server_url`; GitHub.com is not hardcoded for repository operations. The hosted CodeBoarding webview and LLM proxy remain CodeBoarding-operated production services. +Repository fetches, comments, and pushes use `github.server_url`; GitHub.com is not hardcoded for repository operations. The hosted CodeBoarding webview and LLM proxy remain CodeBoarding-operated production services. ## Local test harness diff --git a/action.yml b/action.yml index 9c7e406..8afa2fa 100644 --- a/action.yml +++ b/action.yml @@ -142,20 +142,12 @@ inputs: description: 'Token used for comments and sync delivery.' required: false default: ${{ github.token }} - synced_branch: - description: 'Code branch that sync keeps an up-to-date analysis of. Defaults to the event branch.' - required: false - default: '' - save_baseline_to: - description: 'Where sync saves the analysis: synced_branch (a commit on synced_branch), pull_request (a rolling PR into synced_branch), or baseline_branch (a commit on baseline_branch; synced_branch is never written).' - required: false - default: 'synced_branch' - baseline_branch: - description: 'Branch that save_baseline_to: baseline_branch stores the analysis on, one commit per sync. Reviews read their base analysis from it when it exists.' + codeboarding_analysis_branch: + description: 'Branch the analysis is kept on. Sync adds one commit per run to it and never writes the code branch; reviews read their base from it. Set it to the code branch itself (for example main) to commit .codeboarding/ there instead.' required: false default: 'codeboarding/baseline' force_full: - description: 'Run a full sync analysis instead of reusing the committed baseline.' + description: 'Run a full sync analysis instead of continuing from a saved one.' required: false default: 'false' warmstart_retention_days: @@ -164,13 +156,13 @@ inputs: default: '1' # Deprecated inputs. scripts/action/old_api_migrator.sh is the only reader. target_branch: - description: 'Deprecated: use synced_branch.' - deprecationMessage: 'target_branch is deprecated; use synced_branch.' + description: 'Deprecated: sync analyzes the branch the run is on. Accepted only when it names that branch.' + deprecationMessage: 'target_branch is deprecated; sync analyzes the branch the run is on.' required: false default: '' sync_strategy: - description: 'Deprecated: use save_baseline_to (push is synced_branch, pull_request is pull_request).' - deprecationMessage: 'sync_strategy is deprecated; use save_baseline_to.' + description: 'Deprecated: use codeboarding_analysis_branch (push is the code branch itself, pull_request the default).' + deprecationMessage: 'sync_strategy is deprecated; use codeboarding_analysis_branch.' required: false default: '' outputs: @@ -210,12 +202,6 @@ outputs: committed: description: 'Whether sync delivered a baseline commit.' value: ${{ steps.sync_commit.outputs.committed }} - sync_pr_url: - description: 'Rolling sync PR URL when save_baseline_to is pull_request.' - value: ${{ steps.sync_commit.outputs.sync_pr_url }} - sync_pr_number: - description: 'Rolling sync PR number when save_baseline_to is pull_request.' - value: ${{ steps.sync_commit.outputs.sync_pr_number }} runs: using: 'composite' @@ -224,8 +210,9 @@ runs: id: inputs shell: bash env: - SYNCED_BRANCH: ${{ inputs.synced_branch }} - SAVE_BASELINE_TO: ${{ inputs.save_baseline_to }} + MODE: ${{ inputs.mode }} + REF_NAME: ${{ github.ref_name }} + ANALYSIS_BRANCH: ${{ inputs.codeboarding_analysis_branch }} OLD_TARGET_BRANCH: ${{ inputs.target_branch }} OLD_SYNC_STRATEGY: ${{ inputs.sync_strategy }} run: "$GITHUB_ACTION_PATH/scripts/action/old_api_migrator.sh" @@ -241,11 +228,8 @@ runs: EVENT: ${{ github.event_name }} REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} - EVENT_SHA: ${{ github.sha }} HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} - SYNCED_BRANCH_INPUT: ${{ steps.inputs.outputs.synced_branch }} - SAVE_BASELINE_TO: ${{ steps.inputs.outputs.save_baseline_to }} - BASELINE_BRANCH: ${{ inputs.baseline_branch }} + ANALYSIS_BRANCH: ${{ steps.inputs.outputs.analysis_branch }} COMMENT_BODY: ${{ github.event.comment.body }} AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} @@ -416,7 +400,6 @@ runs: - name: Resolve analysis cache keys id: state if: steps.guard.outputs.skip != 'true' - continue-on-error: true shell: bash env: CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target @@ -473,8 +456,7 @@ runs: CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target STAGE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/out FORCE_FULL: ${{ inputs.force_full }} - SAVE_BASELINE_TO: ${{ steps.inputs.outputs.save_baseline_to }} - BASELINE_BRANCH: ${{ inputs.baseline_branch }} + BASELINE_BRANCH: ${{ steps.guard.outputs.analysis_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 != '' }} @@ -497,16 +479,11 @@ runs: ANALYSIS_DIR: ${{ steps.sync_analyze.outputs.analysis_dir }} CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target SYNCED_BRANCH: ${{ steps.guard.outputs.synced_branch }} - SYNC_BRANCH_START_SHA: ${{ steps.guard.outputs.sync_branch_start_sha }} - SAVE_BASELINE_TO: ${{ steps.inputs.outputs.save_baseline_to }} - BASELINE_BRANCH: ${{ inputs.baseline_branch }} + BASELINE_BRANCH: ${{ steps.guard.outputs.analysis_branch }} ENGINE_VERSION: ${{ steps.state.outputs.engine_version }} # Recorded with each baseline-branch commit, so a reader can tell which configuration made it. CFG_HASH: ${{ steps.state.outputs.cfg_hash }} GITHUB_TOKEN: ${{ inputs.github_token }} - GH_TOKEN: ${{ inputs.github_token }} - GH_ENTERPRISE_TOKEN: ${{ inputs.github_token }} - GH_HOST: ${{ github.server_url }} GITHUB_SERVER_URL: ${{ github.server_url }} REPOSITORY: ${{ github.repository }} run: "$GITHUB_ACTION_PATH/scripts/action/deliver-sync.sh" @@ -554,8 +531,7 @@ runs: MODE: ${{ steps.sync_analyze.outputs.analysis_mode }} COMMITTED: ${{ steps.sync_commit.outputs.committed }} FILES: ${{ steps.sync_commit.outputs.files_written }} - SAVE_BASELINE_TO: ${{ steps.inputs.outputs.save_baseline_to }} - PR_URL: ${{ steps.sync_commit.outputs.sync_pr_url }} + SAVED_TO: ${{ steps.guard.outputs.analysis_branch || steps.guard.outputs.synced_branch }} run: "$GITHUB_ACTION_PATH/scripts/action/sync-summary.sh" - name: Generate review baseline @@ -569,7 +545,7 @@ runs: REVIEW_BASE_REPO: ${{ steps.guard.outputs.base_repo }} BASE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/base RENEW_BASE: ${{ steps.fetch_base.outputs.renew }} - BASELINE_BRANCH: ${{ inputs.baseline_branch }} + BASELINE_BRANCH: ${{ steps.guard.outputs.analysis_branch }} # Lets a merge base without a saved analysis catch up from an ancestor's. ANCESTOR_LOOKUP: ${{ github.server_url == 'https://github.com' && steps.state.outputs.cfg_hash != '' }} # For the progress comment while a base is built from scratch. diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index 72b7569..66aea3b 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -7,8 +7,8 @@ graphs it compares. | Store | Holds | Lifetime | Who can read it | |---|---|---|---| -| **Git, synced branch** (`save_baseline_to: synced_branch` or `pull_request`) | `.codeboarding/` on the synced branch | forever | anyone with repo read | -| **Git, baseline branch** (`save_baseline_to: baseline_branch`) | one commit per sync on `codeboarding/baseline` | forever | anyone with repo read | +| **Git, analysis branch** (`codeboarding_analysis_branch`, the default) | one commit per sync on `codeboarding/baseline` | forever | anyone with repo read | +| **Git, code branch** (`codeboarding_analysis_branch` set to the synced branch) | `.codeboarding/` on the synced branch | forever | anyone with repo read | | **Workflow artifacts** | every analysis this action reuses or publishes | a retention window | any run with `actions: read`, plus humans | | ~~Actions cache~~ | — | — | not used | @@ -155,13 +155,14 @@ base and a diagram drawn against another would report changes nobody made. ## The baseline branch -`save_baseline_to: baseline_branch` saves the analysis to a branch of its own in -the same repository, `codeboarding/baseline` unless `baseline_branch` names -another. The synced branch is then only read. +By default sync saves the analysis to a branch of its own in the same +repository, `codeboarding/baseline` unless `codeboarding_analysis_branch` names +another. The synced branch is then only read. Naming the synced branch itself +commits the analysis there instead, and skips everything below. **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 -`save_baseline_to: synced_branch` would commit to the synced branch, plus +sync would otherwise commit to the synced branch, plus `.codeboarding/source.json`: ```json @@ -182,7 +183,7 @@ wholesale; only the checkout's own `.codeboardingignore` and health configuratio are kept. The push is a fast-forward onto the tip it fetched, never forced. Sync refuses to write to an existing branch that is not a baseline branch (its tip has no `CodeBoarding-Source` trailer, or holds anything besides `.codeboarding/`), so -pointing `baseline_branch` at a code branch fails instead of emptying it. If the +pointing `codeboarding_analysis_branch` at another code branch fails instead of emptying it. If the synced branch moved during the analysis, the result is dropped, as when committing to it. 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 diff --git a/scripts/action/codeboarding-baseline.sh b/scripts/action/codeboarding-baseline.sh index da3eb16..449546b 100644 --- a/scripts/action/codeboarding-baseline.sh +++ b/scripts/action/codeboarding-baseline.sh @@ -1,4 +1,4 @@ -# The baseline branch (save_baseline_to: baseline_branch): an orphan branch holding +# The analysis branch (codeboarding_analysis_branch, BASELINE_BRANCH here): an orphan branch holding # one commit per sync, each with trailers naming the commit it analysed # (CodeBoarding-Source) and the configuration it ran under (CodeBoarding-Config). # Sync writes it; sync and review read it. Sourced, not run; needs analysis-state.sh. @@ -15,13 +15,11 @@ BASELINE_BRANCH_DEPTH="${BASELINE_BRANCH_DEPTH:-100}" # would cost more than it saves. codeboarding_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_BRANCH_DEPTH" "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$BASELINE_BRANCH" 2>/dev/null || - return 0 + --depth="$BASELINE_BRANCH_DEPTH" "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$BASELINE_BRANCH" || return 1 git -C "$scratch" log --format='%H %(trailers:key=CodeBoarding-Source,valueonly,separator=%x20) %(trailers:key=CodeBoarding-Config,valueonly,separator=%x20)' FETCH_HEAD | awk 'NF >= 2 {print $1, $2, (NF >= 3 ? $3 : "-")}' } @@ -33,10 +31,28 @@ codeboarding_baseline_index() { # the seed describes. from_codeboarding_baseline() { local repository="$1" tip="$2" state="$3" config_from="$4" index commit entry="" scratch="$RUNNER_TEMP/codeboarding-baseline-restore" + local auth status=0 + [ -n "${BASELINE_BRANCH:-}" ] || return 1 [ -n "${CFG_HASH:-}" ] || return 1 - index="$(codeboarding_baseline_index "$repository")" - [ -n "$index" ] || return 1 - fetch_commit "$repository" "$tip" "$(( CATCHUP_BOUND + 1 ))" || true + # A branch that does not exist yet is a miss: the first sync creates it. Any other + # failure to read it is an error, not a reason to quietly analyze from scratch. + auth="$(printf 'x-access-token:%s' "${GIT_TOKEN:-}" | base64 -w0)" + git -c "http.extraheader=AUTHORIZATION: basic $auth" ls-remote --exit-code \ + "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$BASELINE_BRANCH" >/dev/null || status=$? + case "$status" in + 0) ;; + 2) echo "::notice::$BASELINE_BRANCH does not exist yet; the first sync creates it."; return 1 ;; + *) echo "::error::Could not read $BASELINE_BRANCH from $repository. Check that github_token can read the repository."; exit 1 ;; + esac + index="$(codeboarding_baseline_index "$repository")" || + { echo "::error::Could not fetch $BASELINE_BRANCH from $repository."; exit 1; } + if [ -z "$index" ]; then + # A code branch, as when a review into another branch names the one that holds the analysis. + echo "::warning::$BASELINE_BRANCH is not a CodeBoarding analysis branch, so its analysis is not read here." + return 1 + fi + fetch_commit "$repository" "$tip" "$(( CATCHUP_BOUND + 1 ))" || + { echo "::error::Could not fetch the history of ${tip:0:7} to look for its analysis."; exit 1; } 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" -v cfg="$CFG_HASH" '$2 == source && $3 == cfg {print $1; exit}' <<< "$index")" [ -z "$entry" ] || break @@ -45,10 +61,13 @@ from_codeboarding_baseline() { echo "::notice::$BASELINE_BRANCH has no analysis of ${tip:0:7} or its last $CATCHUP_BOUND ancestors made with this configuration." return 1 fi - fetch_commit "$repository" "$entry" || return 1 + # The entry is known to exist from here on, so failing to read it is an error. + fetch_commit "$repository" "$entry" || + { echo "::error::Could not fetch the analysis ${entry:0:7} from $BASELINE_BRANCH."; exit 1; } rm -rf "$scratch" mkdir -p "$scratch" - git -C "$CHECKOUT_DIR" archive "$entry" .codeboarding | tar -x -C "$scratch" || return 1 + git -C "$CHECKOUT_DIR" archive "$entry" .codeboarding | tar -x -C "$scratch" || + { echo "::error::Could not extract the analysis ${entry:0:7} from $BASELINE_BRANCH."; exit 1; } # source.json is provenance, not engine state. rm -f "$scratch/.codeboarding/source.json" compatible_state "$scratch/.codeboarding" || return 1 @@ -85,8 +104,9 @@ json.dump({ 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' - local trailers=(-m "CodeBoarding-Source: $BASE_SHA") - [ -z "${CFG_HASH:-}" ] || trailers=(-m "CodeBoarding-Source: $BASE_SHA + # Readers trust only entries made under their own configuration. + [ -n "${CFG_HASH:-}" ] || { echo "::error::No configuration hash to record with the analysis."; exit 1; } + local trailers=(-m "CodeBoarding-Source: $BASE_SHA CodeBoarding-Config: $CFG_HASH") # Two tries: a concurrent sync that moved the branch for an older commit is @@ -106,7 +126,7 @@ CodeBoarding-Config: $CFG_HASH") # Building on any other branch would leave it holding nothing but .codeboarding/. if [ -z "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" ] || [ "$(git ls-tree --name-only "$tip")" != .codeboarding ]; then - echo "::error::$branch already exists and is not a CodeBoarding baseline branch, so sync will not write to it. Set baseline_branch to a branch name that is not in use." + echo "::error::$branch already exists and is not a CodeBoarding baseline branch, so sync will not write to it. Set codeboarding_analysis_branch to a branch name that is not in use, or to $SYNCED_BRANCH to commit the analysis there." exit 1 fi parent=(-p "$tip") @@ -122,12 +142,11 @@ CodeBoarding-Config: $CFG_HASH") # 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 identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor for $branch in the repository's rulesets, or set save_baseline_to: synced_branch." + echo "::error::GitHub refused the push to $branch, most likely because a branch rule protects it. Add the identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor for $branch in the repository's rulesets, and check that github_token may push to it." exit 1 fi done diff --git a/scripts/action/deliver-sync.sh b/scripts/action/deliver-sync.sh index fe2aee1..aa4dea6 100755 --- a/scripts/action/deliver-sync.sh +++ b/scripts/action/deliver-sync.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Installs generated state and delivers it to the synced branch, a rolling sync PR, or the baseline branch. +# Installs generated state and delivers it to the analysis branch, or, when there +# is none, commits it to the synced branch itself. set -euo pipefail cd "$CHECKOUT_DIR" -SYNC_BRANCH=codeboarding/sync REMOTE="${GITHUB_SERVER_URL%/}/${REPOSITORY}.git" ASKPASS="$RUNNER_TEMP/codeboarding-git-askpass.sh" GENERATED_PATHS="$RUNNER_TEMP/codeboarding-sync-paths" @@ -16,7 +16,6 @@ esac SH chmod 700 "$ASKPASS" export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 -export GH_HOST="${GH_HOST#*://}" trap 'rm -f "$ASKPASS"' EXIT # A pull request's merge base is whichever commit its author branched from, and # this run's analysis is valid for two of them: the commit it analyzed, and the @@ -27,29 +26,6 @@ emit_result() { printf 'files_written=%s\ncommitted=%s\nbaseline_sha=%s\nanalyzed_sha=%s\n' \ "$1" "$2" "$3" "$BASE_SHA" >> "$GITHUB_OUTPUT" } -close_stale_pr() { - [ "$SAVE_BASELINE_TO" = pull_request ] || return 0 - git fetch "$REMOTE" "$SYNCED_BRANCH" - [ "$(git rev-parse FETCH_HEAD)" = "$BASE_SHA" ] || return 0 - local number sync_sha - number="$(gh api --method GET "repos/$REPOSITORY/pulls" \ - -f state=open -f base="$SYNCED_BRANCH" \ - -f head="${REPOSITORY%%/*}:$SYNC_BRANCH" --jq '.[0].number // empty')" - if [ -n "$number" ]; then - sync_sha="$(git ls-remote "$REMOTE" "refs/heads/$SYNC_BRANCH" | awk '{print $1; exit}')" - if [ "$sync_sha" != "${SYNC_BRANCH_START_SHA:-}" ]; then - echo "::notice::A newer run updated $SYNC_BRANCH; leaving its PR open." - return 0 - fi - if [ -n "$sync_sha" ] && ! git push \ - "--force-with-lease=refs/heads/$SYNC_BRANCH:$sync_sha" "$REMOTE" ":refs/heads/$SYNC_BRANCH"; then - echo "::notice::A newer run updated $SYNC_BRANCH; leaving its PR open." - return 0 - fi - gh pr close "$number" --repo "$REPOSITORY" - echo "::notice::Closed obsolete CodeBoarding sync PR #$number." - fi -} classify_push_failure() { local expected="$1" branch="$2" current current="$(git ls-remote "$REMOTE" "refs/heads/$branch" | awk '{print $1; exit}')" @@ -64,7 +40,7 @@ classify_push_failure() { } source "$ACTION_PATH/scripts/action/codeboarding-baseline.sh" -[ "$SAVE_BASELINE_TO" != baseline_branch ] || save_to_codeboarding_baseline +[ -z "${BASELINE_BRANCH:-}" ] || save_to_codeboarding_baseline "$ACTION_PATH/scripts/action/install-sync.sh" > "$GENERATED_PATHS" stage_paths=() @@ -94,7 +70,6 @@ fi # every run. Dropping either filter turns every sync into a commit. if git diff --cached --quiet || git diff --cached --quiet -I '"generated_at"' -I '"timestamp"'; then git reset -q - close_stale_pr emit_result "$files_written" false "$BASE_SHA" echo "::notice::The CodeBoarding baseline is unchanged." exit 0 @@ -102,40 +77,7 @@ fi git commit -m 'chore(codeboarding): sync analysis baseline' >/dev/null -if [ "$SAVE_BASELINE_TO" = synced_branch ]; then - if ! git push "$REMOTE" "HEAD:refs/heads/$SYNCED_BRANCH"; then - classify_push_failure "$BASE_SHA" "$SYNCED_BRANCH" - fi - emit_result "$files_written" true "$(git rev-parse HEAD)" - exit 0 -fi - -pr_json="$(gh api --method GET "repos/$REPOSITORY/pulls" \ - -f state=open -f head="${REPOSITORY%%/*}:$SYNC_BRANCH" --jq '.[0] // empty')" -if [ -n "$pr_json" ] && [ "$(jq -r .base.ref <<< "$pr_json")" != "$SYNCED_BRANCH" ]; then - echo "::error::$SYNC_BRANCH already has an open PR into $(jq -r .base.ref <<< "$pr_json"); close it before changing synced_branch." - exit 1 -fi - -old_sync_sha="$(git ls-remote "$REMOTE" "refs/heads/$SYNC_BRANCH" | awk '{print $1; exit}')" -if ! git push "--force-with-lease=refs/heads/$SYNC_BRANCH:$old_sync_sha" \ - "$REMOTE" "HEAD:refs/heads/$SYNC_BRANCH"; then - classify_push_failure "$old_sync_sha" "$SYNC_BRANCH" +if ! git push "$REMOTE" "HEAD:refs/heads/$SYNCED_BRANCH"; then + classify_push_failure "$BASE_SHA" "$SYNCED_BRANCH" fi - -if [ -z "$pr_json" ]; then - gh pr create --repo "$REPOSITORY" --base "$SYNCED_BRANCH" --head "$SYNC_BRANCH" \ - --title 'chore(codeboarding): sync analysis baseline' \ - --body "Updates the versioned CodeBoarding analysis for \`$SYNCED_BRANCH\`." - pr_json="$(gh api --method GET "repos/$REPOSITORY/pulls" \ - -f state=open -f base="$SYNCED_BRANCH" \ - -f head="${REPOSITORY%%/*}:$SYNC_BRANCH" --jq '.[0]')" -fi - -pr_url="$(jq -r .html_url <<< "$pr_json")" -pr_number="$(jq -r .number <<< "$pr_json")" -emit_result "$files_written" true "$BASE_SHA" -{ - echo "sync_pr_url=$pr_url" - echo "sync_pr_number=$pr_number" -} >> "$GITHUB_OUTPUT" +emit_result "$files_written" true "$(git rev-parse HEAD)" diff --git a/scripts/action/install-sync.sh b/scripts/action/install-sync.sh index eb0f748..7c25a03 100755 --- a/scripts/action/install-sync.sh +++ b/scripts/action/install-sync.sh @@ -1,17 +1,12 @@ #!/usr/bin/env bash -# Copies Core's persisted artifacts into .codeboarding and removes generated v1 files. +# Copies Core's persisted artifacts into .codeboarding. set -euo pipefail output="$CHECKOUT_DIR/.codeboarding" mkdir -p "$output" -# Core exposes one canonical manifest. The compatibility imports keep the local -# harness usable with older Core checkouts without duplicating filenames. +# Core exposes one canonical manifest. Read from the action's directory, never the +# analyzed repository's, which may have a constants.py of its own. manifest="$(cd "$ACTION_PATH" && python3 - <<'PY' -try: - from constants import ANALYSIS_FILENAME, PERSISTED_ANALYSIS_ARTIFACT_FILENAMES as artifacts -except ImportError: - from static_analyzer.analysis_cache import STATIC_ANALYSIS_PKL, STATIC_ANALYSIS_SHA - from utils import ANALYSIS_FILENAME, FINGERPRINT_FILENAME - artifacts = (ANALYSIS_FILENAME, FINGERPRINT_FILENAME, STATIC_ANALYSIS_PKL, STATIC_ANALYSIS_SHA, "codeboarding_version.json") +from constants import ANALYSIS_FILENAME, PERSISTED_ANALYSIS_ARTIFACT_FILENAMES as artifacts print(ANALYSIS_FILENAME) print(*artifacts, sep="\n") PY @@ -52,13 +47,4 @@ elif [ -e "$health_target" ]; then rm -f "$health_target" fi printf '%s\n' "$health_target" - -# Remove identifiable v1 Markdown. -marker='https://img.shields.io/badge/Generated%20by-CodeBoarding' -for legacy in "$output"/*.md "$CHECKOUT_DIR/docs/development/architecture.md"; do - [ -f "$legacy" ] || continue - grep -Fq "$marker" "$legacy" || continue - rm -f "$legacy" - printf '%s\n' "$legacy" -done echo "installed=$installed" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/old_api_migrator.sh b/scripts/action/old_api_migrator.sh index 5d03cff..ee66824 100755 --- a/scripts/action/old_api_migrator.sh +++ b/scripts/action/old_api_migrator.sh @@ -1,32 +1,48 @@ #!/usr/bin/env bash -# Translates deprecated inputs into the current ones, and is the only place that -# knows they exist. Every later step reads this step's outputs, never the inputs -# themselves, so no other script carries backward-compatibility logic. +# Rewrites deprecated inputs as codeboarding_analysis_branch, the way a person +# editing the workflow would, and fails on any it cannot express. It is the only +# place that knows they exist: every later step reads its output, never the inputs. # -# target_branch -> synced_branch -# sync_strategy -> save_baseline_to (push -> synced_branch, pull_request -> pull_request) +# sync_strategy: push -> codeboarding_analysis_branch: +# sync_strategy: pull_request -> codeboarding_analysis_branch: (code branches are never written) +# target_branch: -> nothing +# target_branch: -> fails: sync analyzes the branch it runs on # # Delete a mapping, and its input in action.yml, once no supported workflow sets it. set -euo pipefail -fail() { echo "::error::$1"; exit 1; } +MIGRATE_URL=https://github.com/CodeBoarding/CodeBoarding-action#moving-an-existing-setup +fail() { echo "::error title=CodeBoarding inputs::$1 See $MIGRATE_URL for the change, or a prompt that makes it for you."; exit 1; } +warn() { echo "::warning title=CodeBoarding inputs::$1 See $MIGRATE_URL."; } +DEFAULT_ANALYSIS_BRANCH=codeboarding/baseline -synced_branch="${SYNCED_BRANCH:-}" -if [ -n "${OLD_TARGET_BRANCH:-}" ]; then - [ -z "$synced_branch" ] || fail "Set synced_branch only; target_branch is its deprecated name." - echo "::warning::target_branch is deprecated; rename it to synced_branch." - synced_branch="$OLD_TARGET_BRANCH" +analysis_branch="${ANALYSIS_BRANCH:-}" +[ -n "$analysis_branch" ] || { echo "::error::codeboarding_analysis_branch must name a branch."; exit 1; } +# Both inputs only ever applied to sync. +if [ "$MODE" = sync ]; then + if [ -n "${OLD_TARGET_BRANCH:-}" ]; then + [ "$OLD_TARGET_BRANCH" = "$REF_NAME" ] || + fail "target_branch is no longer supported: sync analyzes the branch it runs on, $REF_NAME, not $OLD_TARGET_BRANCH. Remove target_branch and run sync on $OLD_TARGET_BRANCH." + warn "target_branch is deprecated and has no effect here; remove it." + fi + if [ -n "${OLD_SYNC_STRATEGY:-}" ]; then + # codeboarding_analysis_branch has a default, so only a value other than it is a conflict. + [ "$analysis_branch" = "$DEFAULT_ANALYSIS_BRANCH" ] || + fail "Set codeboarding_analysis_branch only; sync_strategy is the input it replaces." + case "$OLD_SYNC_STRATEGY" in + push) + analysis_branch="$REF_NAME" + warn "sync_strategy is deprecated; replace sync_strategy: push with codeboarding_analysis_branch: $REF_NAME." + ;; + pull_request) + warn "sync_strategy: pull_request is deprecated; the analysis is saved to $analysis_branch instead, and code branches are never written. Remove sync_strategy, and close any open codeboarding/sync pull request." + ;; + *) fail "sync_strategy is no longer supported; use codeboarding_analysis_branch." ;; + esac + fi +elif [ "${OLD_SYNC_STRATEGY:-}" = push ]; then + warn "sync_strategy only applied to sync and is deprecated; replace it with codeboarding_analysis_branch set to the branch sync commits to, here and in the sync workflow." +elif [ -n "${OLD_TARGET_BRANCH:-}${OLD_SYNC_STRATEGY:-}" ]; then + warn "target_branch and sync_strategy only applied to sync, and are deprecated; remove them." fi -save_baseline_to="${SAVE_BASELINE_TO:-}" -if [ -n "${OLD_SYNC_STRATEGY:-}" ]; then - # save_baseline_to has a default, so only a value other than it is a conflict. - [ "$save_baseline_to" = synced_branch ] || fail "Set save_baseline_to only; sync_strategy is its deprecated name." - case "$OLD_SYNC_STRATEGY" in - push) save_baseline_to=synced_branch ;; - pull_request) save_baseline_to=pull_request ;; - *) fail "sync_strategy is deprecated and only accepts push or pull_request; use save_baseline_to." ;; - esac - echo "::warning::sync_strategy is deprecated; use save_baseline_to: $save_baseline_to." -fi - -printf 'synced_branch=%s\nsave_baseline_to=%s\n' "$synced_branch" "$save_baseline_to" >> "$GITHUB_OUTPUT" +echo "analysis_branch=$analysis_branch" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/resolve-cache-keys.sh b/scripts/action/resolve-cache-keys.sh index 3c7dec9..1dff351 100755 --- a/scripts/action/resolve-cache-keys.sh +++ b/scripts/action/resolve-cache-keys.sh @@ -18,11 +18,9 @@ digest() { # where the package is not importable, such as tests. engine_version="${ENGINE_VERSION:-}" if [ -z "$engine_version" ]; then - engine_version="$(python3 -c 'from importlib.metadata import version; print(version("codeboarding"))' 2>/dev/null || true)" -fi -if [ -z "$engine_version" ]; then - echo "::notice::Could not resolve the installed CodeBoarding version; this run will not reuse or publish analysis state." - exit 0 + # The engine was just installed and started, so an unreadable version is a broken install. + engine_version="$(python3 -c 'from importlib.metadata import version; print(version("codeboarding"))')" || + { echo "::error::Could not read the installed CodeBoarding version."; exit 1; } fi # The name pins everything that decides what an analysis says, so a bundle is diff --git a/scripts/action/resolve-github-event.sh b/scripts/action/resolve-github-event.sh index 8e1bf6c..2b7513a 100755 --- a/scripts/action/resolve-github-event.sh +++ b/scripts/action/resolve-github-event.sh @@ -9,42 +9,33 @@ case "$MODE" in *) fail "mode must be review or sync." ;; esac printf 'mode=%s\nskip=false\nevent=%s\n' "$MODE" "$EVENT" >> "$GITHUB_OUTPUT" -# Both modes read the baseline branch, so a name git cannot use fails here, before +# Both modes read the analysis branch, so a name git cannot use fails here, before # anything is analyzed, rather than reading as a missing branch and costing a full run. -if [ -n "${BASELINE_BRANCH:-}" ] && ! git check-ref-format "refs/heads/$BASELINE_BRANCH"; then - fail "baseline_branch '$BASELINE_BRANCH' is not a valid branch name." +if [ -n "${ANALYSIS_BRANCH:-}" ] && ! git check-ref-format "refs/heads/$ANALYSIS_BRANCH"; then + fail "codeboarding_analysis_branch '$ANALYSIS_BRANCH' is not a valid branch name." fi +# analysis_branch is the separate branch the analysis is kept on, or empty when +# codeboarding_analysis_branch names the code branch itself, which then holds it. +separate_branch() { + [ "${ANALYSIS_BRANCH:-}" = "$1" ] || echo "${ANALYSIS_BRANCH:-}" +} if [ "$MODE" = sync ]; then case "$EVENT" in push|workflow_dispatch|schedule) ;; *) skip "Sync mode ignores $EVENT events." ;; esac [ "$REF_TYPE" != tag ] || skip "Sync mode ignores tag pushes." - case "$SAVE_BASELINE_TO" in - synced_branch|pull_request|baseline_branch) ;; - *) fail "save_baseline_to must be synced_branch, pull_request or baseline_branch." ;; - esac case "$HEAD_AUTHOR_EMAIL" in codeboarding-review\[bot\]@users.noreply.github.com|codeboarding\[bot\]@users.noreply.github.com) [ "$EVENT" != push ] || skip "Ignoring CodeBoarding's own baseline commit." ;; esac - synced_branch="${SYNCED_BRANCH_INPUT:-$REF_NAME}" - [ -n "$synced_branch" ] || fail "synced_branch is required for this event." - [ "$SAVE_BASELINE_TO" != pull_request ] || [ "$synced_branch" != codeboarding/sync ] || fail "synced_branch must differ from codeboarding/sync." - if [ "$SAVE_BASELINE_TO" = baseline_branch ]; then - [ -n "${BASELINE_BRANCH:-}" ] || fail "baseline_branch is required with save_baseline_to: baseline_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." - [ "$synced_branch" != "$BASELINE_BRANCH" ] || fail "synced_branch must differ from baseline_branch." - fi - sync_branch_start_sha="" - if [ "$SAVE_BASELINE_TO" = pull_request ]; then - sync_branch_start_sha="$(gh api "repos/$REPOSITORY/branches/codeboarding%2Fsync" --jq '.commit.sha' 2>/dev/null || true)" - fi + synced_branch="$REF_NAME" + [ -n "$synced_branch" ] || fail "Sync needs a branch to analyze; run it on one." + analysis_branch="$(separate_branch "$synced_branch")" { echo "synced_branch=$synced_branch" - echo "sync_branch_start_sha=$sync_branch_start_sha" + echo "analysis_branch=$analysis_branch" echo "checkout_repo=$REPOSITORY" echo "checkout_ref=$synced_branch" } >> "$GITHUB_OUTPUT" @@ -133,6 +124,7 @@ is_fork=false echo "merge_base_resolved=$merge_base_resolved" echo "behind_by=$behind_by" echo "base_ref=$base_ref" + echo "analysis_branch=$(separate_branch "$base_ref")" echo "head_sha=$head_sha" echo "base_repo=$base_repo" echo "head_repo=$head_repo" diff --git a/scripts/action/sync-generate-baseline.sh b/scripts/action/sync-generate-baseline.sh index b262b8b..87ea7be 100755 --- a/scripts/action/sync-generate-baseline.sh +++ b/scripts/action/sync-generate-baseline.sh @@ -10,7 +10,13 @@ set -euo pipefail source "$(dirname "$0")/seed-sources.sh" state="$RUNNER_TEMP/codeboarding-sync/analysis" rm -rf "$RUNNER_TEMP/codeboarding-sync" -head_sha="$(git -C "$CHECKOUT_DIR" rev-parse HEAD 2>/dev/null || true)" +head_sha="$(git -C "$CHECKOUT_DIR" rev-parse HEAD)" +# An analysis branch holds only analysis: analyzing it, and committing the result +# onto it, would leave it unreadable as one. +if [ -n "$(git -C "$CHECKOUT_DIR" log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' | tr -d '[:space:]')" ]; then + echo "::error::This run is on a CodeBoarding analysis branch. Run sync on the code branch it analyzes." + exit 1 +fi if [ "$(printf '%s' "${FORCE_FULL:-false}" | tr '[:upper:]' '[:lower:]')" = true ] || ! { from_codeboarding_baseline "${REPOSITORY:-}" "$head_sha" "$state" "$CHECKOUT_DIR" || diff --git a/scripts/action/sync-summary.sh b/scripts/action/sync-summary.sh index 7233825..2c01a3a 100755 --- a/scripts/action/sync-summary.sh +++ b/scripts/action/sync-summary.sh @@ -6,8 +6,5 @@ set -euo pipefail echo "- Analysis: ${MODE}" echo "- Analysis artifacts: ${FILES:-0}" echo "- Delivered: ${COMMITTED:-false}" - echo "- Saved to: ${SAVE_BASELINE_TO}" - if [ -n "${PR_URL:-}" ]; then - echo "- Sync PR: ${PR_URL}" - fi + echo "- Saved to: ${SAVED_TO}" } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 82f0b41..2d240da 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -97,10 +97,11 @@ def test_every_sync_setup_can_read_saved_analyses(self) -> None: def test_deprecated_inputs_are_read_only_by_the_migrator(self) -> None: """Backward compatibility lives in one script; every other step reads its outputs.""" - for old, new in (("target_branch", "synced_branch"), ("sync_strategy", "save_baseline_to")): + for old in ("target_branch", "sync_strategy"): self.assertIn("deprecationMessage:", self.inputs[old]) self.assertEqual(ACTION.count(f"inputs.{old} }}}}"), 1, f"only the migrator may read {old}") - self.assertEqual(ACTION.count(f"inputs.{new} }}}}"), 1, f"only the migrator may read {new}") + new = "codeboarding_analysis_branch" + self.assertEqual(ACTION.count(f"inputs.{new} }}}}"), 1, f"only the migrator may read {new}") self.assertLess(ACTION.index("- name: Translate deprecated inputs"), ACTION.index("- name: Resolve event")) def test_credentials_resolve_before_the_checkout_and_the_engine_install(self) -> None: diff --git a/tests/test_action_state.py b/tests/test_action_state.py index 8a99ed0..57c2ea8 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -179,16 +179,28 @@ def test_model_selection_changes_the_identity(self) -> None: self.assertNotEqual(baseline["cfg_hash"], self._run(PARSING_MODEL_INPUT="gpt-5")["cfg_hash"]) self.assertNotEqual(baseline["cfg_hash"], self._run(LLM_PROVIDER="anthropic")["cfg_hash"]) - def test_unresolvable_engine_version_disables_reuse_instead_of_failing(self) -> None: + def test_an_unresolvable_engine_version_fails_instead_of_disabling_reuse(self) -> None: stub_bin = self.root / "bin" stub_bin.mkdir() python_stub = stub_bin / "python3" python_stub.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") python_stub.chmod(0o755) - values = self._run(ENGINE_VERSION="", PATH=f"{stub_bin}:{os.environ['PATH']}") + result = subprocess.run( + [str(STATE_NAMES)], + env={ + "PATH": f"{stub_bin}:{os.environ['PATH']}", + "GITHUB_OUTPUT": str(self.output), + "CHECKOUT_DIR": str(self.checkout), + "ENGINE_VERSION": "", + }, + capture_output=True, + text=True, + check=False, + ) - self.assertEqual(values, {}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Could not read the installed CodeBoarding version", result.stdout) class ReviewChainTests(unittest.TestCase): @@ -474,6 +486,9 @@ def test_invalid_depth_fails_before_analysis(self) -> None: self.assertEqual(self._engine_calls(), []) def test_sync_without_baseline_uses_configured_depth_directly(self) -> None: + # Sync always runs on a checkout of the synced branch. + self._git("init", "-q") + self._commit("code", {"app.py": "pass\n"}) self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false", DEPTH_CAP="4") self.assertEqual([c["mode"] for c in self._engine_calls()], ["full"]) self.assertEqual(self._engine_calls()[0]["depth"], "4") diff --git a/tests/test_action_sync.py b/tests/test_action_sync.py index 64997bc..2987a2e 100644 --- a/tests/test_action_sync.py +++ b/tests/test_action_sync.py @@ -15,6 +15,19 @@ GUARD = ROOT / "scripts" / "action" / "resolve-github-event.sh" +def write_core(path: Path) -> None: + """The part of Core's constants.py (0.14.5) that names the persisted artifacts.""" + path.mkdir(parents=True, exist_ok=True) + (path / "constants.py").write_text( + "ANALYSIS_FILENAME = 'analysis.json'\n" + "PERSISTED_ANALYSIS_ARTIFACT_FILENAMES = (\n" + " ANALYSIS_FILENAME, 'fingerprint.json', 'static_analysis.pkl', 'static_analysis.sha',\n" + " 'codeboarding_version.json',\n" + ")\n", + encoding="utf-8", + ) + + class ActionSyncTests(unittest.TestCase): def test_review_guard_rejects_fork_before_checkout(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -154,22 +167,14 @@ def test_installs_core_manifest_and_preserves_user_configuration(self) -> None: output = checkout / ".codeboarding" analysis = root / "analysis" fake_core = root / "core" - static_analyzer = fake_core / "static_analyzer" - for directory in (output / "health", analysis, static_analyzer, checkout / "docs" / "development"): + for directory in (output / "health", analysis): directory.mkdir(parents=True) + # The analyzed repository's own constants.py must never be what is read. (checkout / "constants.py").write_text( "raise RuntimeError('imported target constants')\n", encoding="utf-8" ) - (fake_core / "utils.py").write_text( - "ANALYSIS_FILENAME = 'analysis.json'\nFINGERPRINT_FILENAME = 'fingerprint.json'\n", - encoding="utf-8", - ) - (static_analyzer / "__init__.py").touch() - (static_analyzer / "analysis_cache.py").write_text( - "STATIC_ANALYSIS_PKL = 'static_analysis.pkl'\nSTATIC_ANALYSIS_SHA = 'static_analysis.sha'\n", - encoding="utf-8", - ) + write_core(fake_core) produced = ("analysis.json", "fingerprint.json", "static_analysis.pkl") for name in produced: (analysis / name).write_text(f"new {name}\n", encoding="utf-8") @@ -185,16 +190,9 @@ def test_installs_core_manifest_and_preserves_user_configuration(self) -> None: } for path, content in preserved.items(): path.write_text(content, encoding="utf-8") - legacy = ( - output / "overview.md", - output / "health" / "health_report.json", - checkout / "docs" / "development" / "architecture.md", - ) - for path in legacy: - path.write_text( - "old generated content\nhttps://img.shields.io/badge/Generated%20by-CodeBoarding\n", - encoding="utf-8", - ) + # A health report this run did not produce is stale. + stale_report = output / "health" / "health_report.json" + stale_report.write_text("{}\n", encoding="utf-8") github_output = root / "github-output" result = subprocess.run( @@ -220,48 +218,18 @@ def test_installs_core_manifest_and_preserves_user_configuration(self) -> None: self.assertFalse((output / name).exists()) for path, content in preserved.items(): self.assertEqual(path.read_text(encoding="utf-8"), content) - for path in legacy: - self.assertFalse(path.exists()) + self.assertFalse(stale_report.exists()) self.assertEqual(github_output.read_text(encoding="utf-8"), "installed=3\n") - architecture = checkout / "docs" / "development" / "architecture.md" - architecture.write_text("hand-written architecture\n", encoding="utf-8") - result = subprocess.run( - [str(INSTALL_SYNC)], - cwd=checkout, - env={ - "PATH": os.environ["PATH"], - "PYTHONPATH": str(fake_core), - "ACTION_PATH": str(ROOT), - "ANALYSIS_DIR": str(analysis), - "CHECKOUT_DIR": str(checkout), - "GITHUB_OUTPUT": str(github_output), - }, - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr or result.stdout) - self.assertEqual(architecture.read_text(encoding="utf-8"), "hand-written architecture\n") - def test_installs_the_health_report_the_engine_produced(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) checkout = root / "checkout" analysis = root / "analysis" fake_core = root / "core" - static_analyzer = fake_core / "static_analyzer" - for directory in (checkout / ".codeboarding", analysis / "health", static_analyzer): + for directory in (checkout / ".codeboarding", analysis / "health"): directory.mkdir(parents=True) - (fake_core / "utils.py").write_text( - "ANALYSIS_FILENAME = 'analysis.json'\nFINGERPRINT_FILENAME = 'fingerprint.json'\n", - encoding="utf-8", - ) - (static_analyzer / "__init__.py").touch() - (static_analyzer / "analysis_cache.py").write_text( - "STATIC_ANALYSIS_PKL = 'static_analysis.pkl'\nSTATIC_ANALYSIS_SHA = 'static_analysis.sha'\n", - encoding="utf-8", - ) + write_core(fake_core) (analysis / "analysis.json").write_text("{}\n", encoding="utf-8") (analysis / "health" / "health_report.json").write_text('{"overall_score": 1.0}', encoding="utf-8") @@ -359,16 +327,7 @@ def setUp(self) -> None: (self.analysis / name).write_text(f"fresh {name}\n", encoding="utf-8") 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", - ) + write_core(self.core) def tearDown(self) -> None: self.temp_dir.cleanup() @@ -390,10 +349,8 @@ def test_it_publishes_the_analysis_under_both_commits(self) -> None: "RUNNER_TEMP": str(self.root), "GITHUB_SERVER_URL": str(self.root), "GITHUB_TOKEN": "unused", - "GH_HOST": "github.com", "REPOSITORY": "owner/repo", "SYNCED_BRANCH": "main", - "SAVE_BASELINE_TO": "synced_branch", }, capture_output=True, text=True, diff --git a/tests/test_baseline_branch.py b/tests/test_baseline_branch.py index 29be865..9b7565d 100644 --- a/tests/test_baseline_branch.py +++ b/tests/test_baseline_branch.py @@ -1,4 +1,4 @@ -"""save_baseline_to: baseline_branch saves the analysis to an orphan branch, and reviews read their base from it.""" +"""codeboarding_analysis_branch saves the analysis to an orphan branch, and reviews read their base from it.""" from __future__ import annotations @@ -10,6 +10,7 @@ from pathlib import Path from test_action_state import ENGINE_STUB, run_steps +from test_action_sync import write_core ROOT = Path(__file__).resolve().parent.parent DELIVER = ROOT / "scripts" / "action" / "deliver-sync.sh" @@ -43,15 +44,7 @@ def setUp(self) -> None: 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", - ) + write_core(self.core) def tearDown(self) -> None: self.temp_dir.cleanup() @@ -85,7 +78,6 @@ def _deliver(self, expect_ok: bool = True) -> tuple[subprocess.CompletedProcess, "GH_HOST": "github.com", "REPOSITORY": "owner/repo", "SYNCED_BRANCH": "main", - "SAVE_BASELINE_TO": "baseline_branch", "BASELINE_BRANCH": BRANCH, "ENGINE_VERSION": "0.14.5", "CFG_HASH": "cfg", @@ -108,7 +100,7 @@ def test_the_first_sync_creates_an_orphan_branch_with_its_provenance(self) -> No _result, values = self._deliver() (only,) = self._branch_log() - self.assertEqual(only.split(), [values["baseline_branch_sha"]], "the branch must have no parent") + self.assertEqual(only.split(), [git(self.remote, "rev-parse", BRANCH)], "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()) @@ -147,7 +139,7 @@ def test_the_next_sync_appends_a_fast_forward_commit(self) -> None: log = self._branch_log() self.assertEqual(len(log), 2) - self.assertEqual(log[0].split(), [values["baseline_branch_sha"], first]) + self.assertEqual(log[0].split()[1:], [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: @@ -173,7 +165,6 @@ def test_a_push_refused_by_a_branch_rule_says_how_to_fix_it(self) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn(f"GitHub refused the push to {BRANCH}", result.stdout) self.assertIn("bypass actor", result.stdout) - self.assertIn("save_baseline_to: synced_branch", result.stdout) def test_a_deleted_branch_is_recreated_as_a_new_orphan(self) -> None: self._deliver() @@ -186,7 +177,7 @@ def test_a_deleted_branch_is_recreated_as_a_new_orphan(self) -> None: self.assertEqual(len(only.split()), 1, "a recreated branch starts a new history") def test_an_existing_branch_that_is_not_a_baseline_branch_is_never_written(self) -> None: - # baseline_branch pointed at a code branch: building on it would leave it + # codeboarding_analysis_branch pointed at another code branch: building on it would leave it # holding nothing but .codeboarding/. git(self.checkout, "push", "-q", "origin", f"main:refs/heads/{BRANCH}") before = git(self.remote, "rev-parse", BRANCH) @@ -195,8 +186,9 @@ def test_an_existing_branch_that_is_not_a_baseline_branch_is_never_written(self) self.assertNotEqual(result.returncode, 0) self.assertIn(f"{BRANCH} already exists and is not a CodeBoarding baseline branch", result.stdout) + self.assertIn("codeboarding_analysis_branch", result.stdout) self.assertEqual(git(self.remote, "rev-parse", BRANCH), before) - self.assertNotIn("baseline_branch_sha", values) + self.assertEqual(values.get("committed", "false"), "false") def test_a_synced_branch_that_moved_during_analysis_keeps_the_branch_unchanged(self) -> None: self._deliver() @@ -274,7 +266,7 @@ def setUp(self) -> None: def tearDown(self) -> None: self.temp_dir.cleanup() - def _analyze(self, **extra: str) -> dict[str, str]: + def _analyze(self, expect_ok: bool = True, **extra: str) -> dict[str, str]: code, self.log, values = run_steps( { "PATH": f"{self.bin_dir}:{os.environ['PATH']}", @@ -299,7 +291,7 @@ def _analyze(self, **extra: str) -> dict[str, str]: }, self.output, ) - self.assertEqual(code, 0, self.log) + self.assertEqual(code == 0, expect_ok, self.log) return values def _modes(self) -> list[str]: @@ -342,6 +334,21 @@ def test_without_a_configuration_hash_no_entry_is_trusted(self) -> None: def test_without_the_branch_the_base_is_a_full_analysis(self) -> None: self._analyze(REVIEW_BASE_SHA=self.shas[4], BASELINE_BRANCH="codeboarding/none") + self.assertIn("codeboarding/none does not exist yet", self.log) + self.assertEqual(self._modes(), ["full", "incremental"]) + + def test_a_branch_that_cannot_be_read_fails_instead_of_analyzing_from_scratch(self) -> None: + # A token without access looks like this too: it must not read as a missing branch. + self._analyze(False, REVIEW_BASE_SHA=self.shas[4], GITHUB_SERVER_URL=f"file://{self.root}/missing") + + self.assertIn(f"Could not read {BRANCH}", self.log) + self.assertEqual(self._modes(), []) + + def test_a_code_branch_named_as_the_analysis_branch_is_said_and_skipped(self) -> None: + # codeboarding_analysis_branch: main, read by a review of a pull request into another branch. + self._analyze(REVIEW_BASE_SHA=self.shas[4], BASELINE_BRANCH="main") + + self.assertIn("main is not a CodeBoarding analysis branch", self.log) self.assertEqual(self._modes(), ["full", "incremental"]) def test_the_branch_outranks_a_baseline_committed_at_the_merge_base(self) -> None: @@ -362,7 +369,7 @@ def test_the_branch_outranks_a_baseline_committed_at_the_merge_base(self) -> Non self.assertNotEqual(base["components"], ["stale"]) def test_sync_continues_from_the_branch_tip(self) -> None: - self._analyze(ANALYSIS_KIND="sync", SAVE_BASELINE_TO="baseline_branch", FORCE_FULL="false") + self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false") self.assertEqual(self._modes(), ["incremental"]) @@ -376,7 +383,7 @@ def test_sync_replaces_the_committed_state_with_the_branch_tip(self) -> None: (board / "static_analysis.sha").write_text("stale\n") (board / ".codeboardingignore").write_text("docs/\n") - values = self._analyze(ANALYSIS_KIND="sync", SAVE_BASELINE_TO="baseline_branch", FORCE_FULL="false") + values = self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false") state = Path(values["analysis_dir"]) self.assertEqual((state / "static_analysis.pkl").read_text(), "pickle", "the tip's engine state") @@ -385,14 +392,23 @@ def test_sync_replaces_the_committed_state_with_the_branch_tip(self) -> None: self.assertEqual((state / ".codeboardingignore").read_text(), "docs/\n") def test_sync_does_not_continue_from_a_tip_made_under_another_configuration(self) -> None: - self._analyze(ANALYSIS_KIND="sync", SAVE_BASELINE_TO="baseline_branch", FORCE_FULL="false", CFG_HASH="othercfg") + self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false", CFG_HASH="othercfg") self.assertEqual(self._modes(), ["full"]) + def test_sync_refuses_to_run_on_the_analysis_branch(self) -> None: + # Committing an analysis onto it, code-branch style, would make it unreadable as one. + analysis_checkout = self.root / "analysis-checkout" + git(self.root, "clone", "-q", "--branch", BRANCH, f"file://{self.root}/origin.git", str(analysis_checkout)) + + self._analyze(False, ANALYSIS_KIND="sync", FORCE_FULL="false", CHECKOUT_DIR=str(analysis_checkout)) + + self.assertIn("This run is on a CodeBoarding analysis branch", self.log) + self.assertEqual(self._modes(), []) + def test_sync_without_the_branch_analyzes_from_scratch(self) -> None: self._analyze( ANALYSIS_KIND="sync", - SAVE_BASELINE_TO="baseline_branch", FORCE_FULL="false", BASELINE_BRANCH="codeboarding/none", ) @@ -415,8 +431,7 @@ def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: "REF_NAME": "main", "REF_TYPE": "branch", "HEAD_AUTHOR_EMAIL": "dev@example.com", - "SAVE_BASELINE_TO": "baseline_branch", - "BASELINE_BRANCH": BRANCH, + "ANALYSIS_BRANCH": BRANCH, "REPOSITORY": "owner/repo", **extra, }, @@ -426,28 +441,42 @@ def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: ) return result, output.read_text(encoding="utf-8") - def test_the_baseline_branch_destination_is_accepted(self) -> None: + def test_sync_saves_to_the_analysis_branch_by_default(self) -> None: result, values = self._guard() self.assertEqual(result.returncode, 0, result.stdout) - self.assertIn("synced_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) + self.assertIn("synced_branch=main\n", values) + self.assertIn(f"analysis_branch={BRANCH}\n", values) + + def test_naming_the_code_branch_commits_the_analysis_there(self) -> None: + with tempfile.TemporaryDirectory() as bin_dir: + gh = Path(bin_dir) / "gh" + gh.write_text("#!/bin/sh\nprintf 'base-sha\\t0\\n'\n", encoding="utf-8") + gh.chmod(0o755) + review = { + "EVENT": "pull_request", + "EVENT_PR_NUMBER": "42", + "PULL_BASE_SHA": "base-sha", + "PULL_HEAD_SHA": "head-sha", + "PULL_BASE_REPO": "owner/repo", + "PULL_HEAD_REPO": "owner/repo", + "PULL_BASE_REF": "main", + "PATH": f"{bin_dir}:{os.environ['PATH']}", + } + for mode, extra in (("sync", {}), ("review", review)): + with self.subTest(mode=mode): + result, values = self._guard(MODE=mode, ANALYSIS_BRANCH="main", **extra) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("analysis_branch=\n", values, result.stdout) + result, values = self._guard(MODE="review", **review) + self.assertIn(f"analysis_branch={BRANCH}\n", values, "a review of main reads the analysis branch") def test_a_branch_name_git_cannot_use_fails_before_anything_runs(self) -> None: for mode in ("sync", "review"): for name in ("codeboarding/a..b", "codeboarding/with space", "codeboarding/trailing."): - result, _values = self._guard(MODE=mode, BASELINE_BRANCH=name) + result, _values = self._guard(MODE=mode, ANALYSIS_BRANCH=name) self.assertNotEqual(result.returncode, 0, (mode, name)) self.assertIn("is not a valid branch name", result.stdout) - def test_synced_branch_must_differ_from_the_baseline_branch(self) -> None: - result, _values = self._guard(SYNCED_BRANCH_INPUT=BRANCH) - self.assertNotEqual(result.returncode, 0) - self.assertIn("synced_branch must differ from baseline_branch", result.stdout) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_old_api_migrator.py b/tests/test_old_api_migrator.py index 5d7e390..015ca3d 100644 --- a/tests/test_old_api_migrator.py +++ b/tests/test_old_api_migrator.py @@ -10,10 +10,11 @@ ROOT = Path(__file__).resolve().parent.parent MIGRATOR = ROOT / "scripts" / "action" / "old_api_migrator.sh" +DEFAULT_ANALYSIS_BRANCH = "codeboarding/baseline" -def migrate(**inputs: str) -> tuple[subprocess.CompletedProcess, dict[str, str]]: - """Runs the migrator with action.yml's defaults, overridden by `inputs`.""" +def migrate(**inputs: str) -> tuple[subprocess.CompletedProcess, str]: + """Runs the migrator for a sync of main with action.yml's defaults, overridden by `inputs`.""" with tempfile.TemporaryDirectory() as tmp: output = Path(tmp) / "github-output" output.write_text("", encoding="utf-8") @@ -22,8 +23,9 @@ def migrate(**inputs: str) -> tuple[subprocess.CompletedProcess, dict[str, str]] env={ "PATH": os.environ["PATH"], "GITHUB_OUTPUT": str(output), - "SYNCED_BRANCH": "", - "SAVE_BASELINE_TO": "synced_branch", + "MODE": "sync", + "REF_NAME": "main", + "ANALYSIS_BRANCH": DEFAULT_ANALYSIS_BRANCH, "OLD_TARGET_BRANCH": "", "OLD_SYNC_STRATEGY": "", **inputs, @@ -33,44 +35,62 @@ def migrate(**inputs: str) -> tuple[subprocess.CompletedProcess, dict[str, str]] check=False, ) values = dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) - return result, values + return result, values.get("analysis_branch", "") class OldApiMigratorTests(unittest.TestCase): + """Each deprecated input becomes the codeboarding_analysis_branch a person would write, or fails.""" + def test_current_inputs_pass_through_without_warnings(self) -> None: - result, values = migrate(SYNCED_BRANCH="main", SAVE_BASELINE_TO="baseline_branch") + result, branch = migrate(ANALYSIS_BRANCH="main") self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(values, {"synced_branch": "main", "save_baseline_to": "baseline_branch"}) - self.assertNotIn("::warning::", result.stdout) + self.assertEqual(branch, "main") + self.assertNotIn("::warning", result.stdout) - def test_target_branch_becomes_synced_branch(self) -> None: - result, values = migrate(OLD_TARGET_BRANCH="develop") + def test_sync_strategy_push_names_the_synced_branch(self) -> None: + result, branch = migrate(OLD_SYNC_STRATEGY="push") self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(values["synced_branch"], "develop") - self.assertIn("::warning::target_branch is deprecated", result.stdout) - - def test_sync_strategy_values_map_onto_save_baseline_to(self) -> None: - for old, new in (("push", "synced_branch"), ("pull_request", "pull_request")): - with self.subTest(old=old): - result, values = migrate(OLD_SYNC_STRATEGY=old) - self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(values["save_baseline_to"], new) - self.assertIn("::warning::sync_strategy is deprecated", result.stdout) - - def test_a_value_sync_strategy_never_accepted_fails(self) -> None: - result, _values = migrate(OLD_SYNC_STRATEGY="branch") - self.assertNotEqual(result.returncode, 0) - self.assertIn("use save_baseline_to", result.stdout) + self.assertEqual(branch, "main") + self.assertIn("codeboarding_analysis_branch: main", result.stdout) + + def test_sync_strategy_pull_request_keeps_the_default_analysis_branch(self) -> None: + result, branch = migrate(OLD_SYNC_STRATEGY="pull_request") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(branch, DEFAULT_ANALYSIS_BRANCH) + self.assertIn("codeboarding/sync", result.stdout) + + def test_target_branch_naming_the_synced_branch_changes_nothing(self) -> None: + result, branch = migrate(OLD_TARGET_BRANCH="main") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(branch, DEFAULT_ANALYSIS_BRANCH) + self.assertIn("::warning", result.stdout) - def test_setting_an_input_under_both_names_fails(self) -> None: + def test_what_the_new_inputs_cannot_express_fails_with_the_way_to_migrate(self) -> None: for inputs in ( - {"SYNCED_BRANCH": "main", "OLD_TARGET_BRANCH": "main"}, - {"SAVE_BASELINE_TO": "baseline_branch", "OLD_SYNC_STRATEGY": "push"}, + {"OLD_TARGET_BRANCH": "develop"}, + {"OLD_SYNC_STRATEGY": "branch"}, + {"ANALYSIS_BRANCH": "main", "OLD_SYNC_STRATEGY": "push"}, + {"ANALYSIS_BRANCH": "main", "OLD_SYNC_STRATEGY": "pull_request"}, ): with self.subTest(inputs=inputs): - result, _values = migrate(**inputs) + result, branch = migrate(**inputs) self.assertNotEqual(result.returncode, 0, result.stdout) - self.assertIn("deprecated name", result.stdout) + self.assertEqual(branch, "") + self.assertIn("#moving-an-existing-setup", result.stdout) + + def test_an_empty_analysis_branch_fails(self) -> None: + result, branch = migrate(ANALYSIS_BRANCH="") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(branch, "") + self.assertIn("must name a branch", result.stdout) + + def test_review_never_reads_the_sync_inputs(self) -> None: + result, branch = migrate( + MODE="review", REF_NAME="42/merge", OLD_TARGET_BRANCH="develop", OLD_SYNC_STRATEGY="push" + ) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(branch, DEFAULT_ANALYSIS_BRANCH) + self.assertIn("replace it with codeboarding_analysis_branch", result.stdout) if __name__ == "__main__": From c4820eed4af3b20a5ba3f10ac6bdde3c4c1cd879 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sat, 10 Oct 2026 22:50:02 +0200 Subject: [PATCH 12/19] fix: name the branch the run is on when target_branch points elsewhere Co-Authored-By: Claude Opus 5.5 --- scripts/action/old_api_migrator.sh | 2 +- tests/test_old_api_migrator.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/action/old_api_migrator.sh b/scripts/action/old_api_migrator.sh index ee66824..4c2757d 100755 --- a/scripts/action/old_api_migrator.sh +++ b/scripts/action/old_api_migrator.sh @@ -21,7 +21,7 @@ analysis_branch="${ANALYSIS_BRANCH:-}" if [ "$MODE" = sync ]; then if [ -n "${OLD_TARGET_BRANCH:-}" ]; then [ "$OLD_TARGET_BRANCH" = "$REF_NAME" ] || - fail "target_branch is no longer supported: sync analyzes the branch it runs on, $REF_NAME, not $OLD_TARGET_BRANCH. Remove target_branch and run sync on $OLD_TARGET_BRANCH." + fail "target_branch is no longer supported: sync analyzes the branch it runs on, which for this run is $REF_NAME, not $OLD_TARGET_BRANCH. Remove target_branch: $OLD_TARGET_BRANCH, and sync analyzes $REF_NAME." warn "target_branch is deprecated and has no effect here; remove it." fi if [ -n "${OLD_SYNC_STRATEGY:-}" ]; then diff --git a/tests/test_old_api_migrator.py b/tests/test_old_api_migrator.py index 015ca3d..b132531 100644 --- a/tests/test_old_api_migrator.py +++ b/tests/test_old_api_migrator.py @@ -78,6 +78,15 @@ def test_what_the_new_inputs_cannot_express_fails_with_the_way_to_migrate(self) self.assertEqual(branch, "") self.assertIn("#moving-an-existing-setup", result.stdout) + def test_a_stale_target_branch_names_the_branch_the_run_is_on(self) -> None: + # A repository whose branch is master, with target_branch: main left in its workflow: + # "run sync on main" would send it to a branch that does not exist. + result, _branch = migrate(REF_NAME="master", OLD_TARGET_BRANCH="main") + self.assertNotEqual(result.returncode, 0) + self.assertIn("for this run is master", result.stdout) + self.assertIn("Remove target_branch: main, and sync analyzes master.", result.stdout) + self.assertNotIn("run sync on main", result.stdout) + def test_an_empty_analysis_branch_fails(self) -> None: result, branch = migrate(ANALYSIS_BRANCH="") self.assertNotEqual(result.returncode, 0) From 381a0c413736a8da739c425b86af30f13f95d1a9 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sun, 11 Oct 2026 04:02:08 +0200 Subject: [PATCH 13/19] feat: choose where the analysis lives, and keep one branch's analysis per repository codeboarding_analysis_location (codeboarding_branch | in_place) replaces codeboarding_analysis_branch: the only choice is where the analysis of the synced branch lives, and a free branch name only added ways to name the wrong one. The analysis branch is always codeboarding/baseline. The push trigger picks the synced branch, and it lists exactly one. Each sync records CodeBoarding-Branch on codeboarding/baseline, and a new step before the checkout and the engine install refuses a sync from another branch, or a code branch already named codeboarding/baseline, instead of failing after a full analysis. workflow_dispatch and force_full are gone from sync: a manual run could sync any branch, and a full analysis is now only for a missing or incompatible saved one. Failures on the analysis branch record a reason the review comment and the sync job summary show; its lookup is retried once. Sync warns while the synced branch still holds an old committed analysis. The recommended ruleset lets only the CodeBoarding app write codeboarding/baseline. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/codeboarding.yml | 25 ++--- AGENTS.md | 11 +- README.md | 38 ++----- action.yml | 43 +++++--- docs/COMMIT_STRATEGY.md | 37 ++++--- docs/baseline-branch-ruleset-actions.json | 37 +++++++ docs/baseline-branch-ruleset.json | 25 ++++- scripts/action/check-analysis-branch.sh | 31 ++++++ scripts/action/codeboarding-baseline.sh | 63 +++++++---- scripts/action/deliver-sync.sh | 11 +- scripts/action/old_api_migrator.sh | 67 ++++++----- scripts/action/resolve-github-event.sh | 19 +--- scripts/action/sync-generate-baseline.sh | 10 +- scripts/action/sync-summary.sh | 11 +- tests/test_action_inputs.py | 2 +- tests/test_action_state.py | 13 +-- tests/test_action_sync.py | 18 +++ tests/test_baseline_branch.py | 129 ++++++++++++++++++---- tests/test_old_api_migrator.py | 97 ++++++++-------- 19 files changed, 439 insertions(+), 248 deletions(-) create mode 100644 docs/baseline-branch-ruleset-actions.json create mode 100755 scripts/action/check-analysis-branch.sh diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index 16dfe75..342d5bd 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -3,7 +3,7 @@ # the action code on that ref rather than the last published release. # # review pull requests and /codeboarding comments -# sync pushes to main, and manual runs (workflow_dispatch) +# sync pushes to main name: CodeBoarding @@ -35,13 +35,6 @@ on: - '.codeboarding/static_analysis.sha' - '.codeboarding/codeboarding_version.json' - '.codeboarding/health/health_report.json' - workflow_dispatch: - inputs: - force_full: - description: 'Ignore the saved baseline and rebuild it from scratch (full analysis).' - type: boolean - required: false - default: false # No workflow-level permissions: each job requests only what it needs (least # privilege), so the default token starts with none. @@ -52,8 +45,8 @@ permissions: {} # review one group per pull request; queue, and cancel only when the PR closes — # bot comments (issue_comment) and re-triggers must not cancel a running # review, they queue behind it instead. -# sync one group for every baseline writer: a push landing while a manual -# dispatch is mid-run must not produce two concurrent commits to main. +# sync one group for every baseline writer: a push landing while another +# sync is mid-run must not produce two concurrent commits to main. concurrency: group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number || 'baseline-writers' }} cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} @@ -98,14 +91,13 @@ jobs: llm: license license_key: ${{ secrets.CODEBOARDING_LICENSE }} # This repository commits its analysis to main, so reviews read it there. - codeboarding_analysis_branch: main + codeboarding_analysis_location: in_place github_token: ${{ steps.app.outputs.token || github.token }} - # The ONLY baseline writer for this repository. The manual "rebuild from - # scratch" path is workflow_dispatch + force_full, which runs the same tested - # action instead of a hand-rolled copy of its pipeline. + # The ONLY baseline writer for this repository, started by pushes to main only: + # the push filter above is what decides which branch is synced. sync: - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 60 permissions: @@ -126,8 +118,7 @@ jobs: llm: license license_key: ${{ secrets.CODEBOARDING_LICENSE }} # Must match the review job above, which reads it. - codeboarding_analysis_branch: main - force_full: ${{ inputs.force_full || false }} + codeboarding_analysis_location: in_place # The App token attributes the baseline commit to the CodeBoarding App. # The workflow token can push too, since this job grants contents: write. github_token: ${{ steps.app.outputs.token || github.token }} diff --git a/AGENTS.md b/AGENTS.md index 690ef75..7492a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,10 +5,11 @@ This repo is a GitHub Action with two modes, selected by the `mode` input: - **`mode: review`** (default): analyzes a PR with the CodeBoarding engine and posts a Mermaid diagram of the added, modified, and removed components as a PR comment (runs on `pull_request` / `issue_comment`). -- **`mode: sync`**: on push to a branch, regenerates the analysis and saves the - `.codeboarding/` state to `codeboarding_analysis_branch` (an orphan - `codeboarding/baseline` branch by default, or the code branch itself when the - input names it), so review mode always diffs against a current baseline. +- **`mode: sync`**: on push to the one branch its trigger lists, regenerates the + analysis and saves the `.codeboarding/` state where + `codeboarding_analysis_location` says (an orphan `codeboarding/baseline` branch + by default, or `in_place` on that branch itself), so review mode always diffs + against a current baseline. The action is a thin orchestration wrapper, not the analysis engine: the engine (`CodeBoarding/CodeBoarding`) is a separate repo checked out at runtime and @@ -83,6 +84,8 @@ bump moves adopters to `v2` and freezes `v1`, which would have left every existing workflow on the old silent-fallback behaviour forever — the opposite of the intent. Shipping it as a minor bump on the moving `v1` tag is what makes adopters actually receive it. Do not "correct" this to `feat!:` after the fact. +The analysis-location change (#143) is a second such exception: sync's default +moved from committing to the synced branch to `codeboarding/baseline`. - `chore:` / `docs:` / `ci:` / `refactor:` / `test:` → ride along in the next release but do not trigger one. diff --git a/README.md b/README.md index 52efda4..88c29b7 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Sync mode saves Core's persisted incremental-analysis state under `.codeboarding It does **not** render or commit architecture Markdown, and leaves every other file alone, including user-authored CodeBoarding configuration such as `health/health_config.json` and `health/.healthignore`. -Sync analyzes the branch it runs on, and saves the result to `codeboarding_analysis_branch`, which is `codeboarding/baseline` unless you name another. Create `.github/workflows/codeboarding-sync.yml`: +Sync keeps the analysis of exactly one branch current: the one branch you list under `on: push: branches:`. It is started by pushes only, so it never runs anywhere else. `codeboarding_analysis_location` decides where that analysis is stored, `codeboarding/baseline` by default. Create `.github/workflows/codeboarding-sync.yml`: ```yaml name: CodeBoarding sync @@ -242,12 +242,6 @@ name: CodeBoarding sync on: push: branches: [main] - workflow_dispatch: - inputs: - force_full: - description: Rebuild without the saved baseline - type: boolean - default: false permissions: contents: write @@ -267,20 +261,19 @@ jobs: with: mode: sync llm: hosted - force_full: ${{ inputs.force_full || false }} ``` `main` is only read. Each sync adds one commit to `codeboarding/baseline` in the same repository, an orphan branch that shares no history with `main`. It holds the `.codeboarding/` files listed above, plus `.codeboarding/source.json` naming the commit they describe and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. Pushes only ever fast-forward, and no code branch is ever written, so the `push` trigger needs no `paths-ignore` and a protected `main` needs no exception. Reviews read their base from the branch, and the web platform reads the latest diagram from it. The [analysis branch section](docs/COMMIT_STRATEGY.md#the-baseline-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. -No permission beyond `contents: write` is needed: the first sync creates the branch with an ordinary push. If you rename it with `codeboarding_analysis_branch`, set the same name in your review workflow too, since reviews read it — or keep both jobs in one workflow, below. +No permission beyond `contents: write` is needed: the first sync creates the branch with an ordinary push, and records which branch it keeps the analysis of. A sync from any other branch then fails before anything is installed, naming both branches. To keep another branch's analysis instead, change the push trigger and delete `codeboarding/baseline`; the next sync creates it again. To sync more than one branch, list them under `push: branches:`. Each run analyzes the branch that was pushed, and every pull request is compared with the analysis of its own merge base. -The first run, `force_full: true`, or an incompatible baseline causes a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the synced branch advances while analysis is running, the stale result is not saved for code it did not analyze; the newer push run is allowed to produce the current baseline. +The first run, or one without a compatible saved analysis to continue from, is a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the synced branch advances while analysis is running, the stale result is not saved for code it did not analyze; the newer push run is allowed to produce the current baseline. ### Keep the analysis on the code branch -Set `codeboarding_analysis_branch` to the code branch itself to commit `.codeboarding/` there instead, next to the code. Set it in the review workflow as well, so reviews read it from there, and add a `paths-ignore` so sync's own commit does not trigger another sync: +Set `codeboarding_analysis_location: in_place` to commit `.codeboarding/` to the synced branch itself instead, next to the code. Set it in the review workflow as well, so reviews read it from there, and add a `paths-ignore` so sync's own commit does not trigger another sync: ```yaml on: @@ -299,7 +292,7 @@ on: with: mode: sync llm: hosted - codeboarding_analysis_branch: main + codeboarding_analysis_location: in_place ``` The list names only generated files on purpose: an edit to `.codeboarding/.codeboardingignore` or the health configuration changes what is analyzed, and must still trigger a sync. Sync pushes to `main` directly, so a branch rule protecting `main` needs the identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor, or a `github_token` that may push there. @@ -318,12 +311,6 @@ on: types: [created] push: branches: [main] - workflow_dispatch: - inputs: - force_full: - description: Rebuild without the saved baseline - type: boolean - default: false permissions: {} @@ -354,7 +341,7 @@ jobs: llm: hosted sync: - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 60 permissions: @@ -366,10 +353,9 @@ jobs: with: mode: sync llm: hosted - force_full: ${{ inputs.force_full || false }} ``` -Both jobs use the default `codeboarding_analysis_branch`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Existing two-file setups keep working unchanged; this is only a different way to call the same action. +Both jobs use the default `codeboarding_analysis_location`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Two-file setups work the same way, except that a sync on the old defaults now saves to `codeboarding/baseline`; see [moving an existing setup](#moving-an-existing-setup). ### Moving an existing setup @@ -378,7 +364,8 @@ Before this release sync committed `.codeboarding/` to the synced branch by defa ```text Move this repository's CodeBoarding sync to the codeboarding/baseline analysis branch. 1. In the workflows that run CodeBoarding/CodeBoarding-action, remove the - sync_strategy, target_branch and codeboarding_analysis_branch inputs if set. + sync_strategy, target_branch, force_full and codeboarding_analysis_location + inputs if set, and any workflow_dispatch trigger that starts the sync job. Keep every other input. 2. Delete the generated files under .codeboarding/ from the default branch, keeping the user configuration: .codeboarding/.codeboardingignore, @@ -391,7 +378,7 @@ Move this repository's CodeBoarding sync to the codeboarding/baseline analysis b pull request from the codeboarding/sync branch and delete that branch. ``` -The first sync after the merge creates `codeboarding/baseline`, catching up from a saved analysis when there is one. To keep committing to the code branch instead, set `codeboarding_analysis_branch` to it, as [above](#keep-the-analysis-on-the-code-branch). +The first sync after the merge creates `codeboarding/baseline`, catching up from a saved analysis when there is one. To keep committing to the code branch instead, set `codeboarding_analysis_location: in_place`, as [above](#keep-the-analysis-on-the-code-branch). ## Inputs @@ -408,11 +395,10 @@ The first sync after the merge creates `codeboarding/baseline`, catching up from | `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. | -| `codeboarding_analysis_branch` | both | `codeboarding/baseline` | Branch the analysis is kept on: sync saves to it, reviews read their base from it. Set it to the code branch (e.g. `main`) to commit `.codeboarding/` there instead. | -| `force_full` | sync | `false` | Run a full analysis instead of continuing from a saved one. | +| `codeboarding_analysis_location` | both | `codeboarding_branch` | Where the synced branch's analysis is stored: `codeboarding_branch` (on `codeboarding/baseline`; the code is never written) or `in_place` (committed as `.codeboarding/` on the synced branch). Sync saves it there and review reads it from there. | | `warmstart_retention_days` | review | `1` | Days to keep the reusable analysis. Only the next run reads it. | -`target_branch` and `sync_strategy` are deprecated, and read as the `codeboarding_analysis_branch` they stand for, with a warning: `sync_strategy: push` as the synced branch, `sync_strategy: pull_request` as the default. `target_branch` is accepted only when it names the branch sync runs on. Anything the new input cannot express fails, linking to [moving an existing setup](#moving-an-existing-setup). +`target_branch` and `sync_strategy` are deprecated, and read as the `codeboarding_analysis_location` they stand for, with a warning: `sync_strategy: push` as `in_place`, `sync_strategy: pull_request` as `codeboarding_branch`. `target_branch` is accepted only when it names the branch the run is on: the push trigger picks the branch. Anything the new input cannot express fails, linking to [moving an existing setup](#moving-an-existing-setup). The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, commit message, and CodeBoarding 0.14.5 version are intentionally fixed rather than exposed as configuration. diff --git a/action.yml b/action.yml index 8afa2fa..cf4a5b1 100644 --- a/action.yml +++ b/action.yml @@ -142,14 +142,16 @@ inputs: description: 'Token used for comments and sync delivery.' required: false default: ${{ github.token }} - codeboarding_analysis_branch: - description: 'Branch the analysis is kept on. Sync adds one commit per run to it and never writes the code branch; reviews read their base from it. Set it to the code branch itself (for example main) to commit .codeboarding/ there instead.' - required: false - default: 'codeboarding/baseline' - force_full: - description: 'Run a full sync analysis instead of continuing from a saved one.' - required: false - default: 'false' + codeboarding_analysis_location: + description: >- + Where the analysis of the synced branch (the one branch the sync workflow's + push trigger lists) is stored. Sync saves it there and review reads its base + from there, so set the same value for both. + codeboarding_branch (default): on codeboarding/baseline, an orphan branch of its + own with one commit per sync; the synced branch is never written. + in_place: committed as .codeboarding/ on the synced branch itself. + required: false + default: 'codeboarding_branch' warmstart_retention_days: description: 'Days to keep the reusable analysis. Only the next run reads it.' required: false @@ -161,8 +163,8 @@ inputs: required: false default: '' sync_strategy: - description: 'Deprecated: use codeboarding_analysis_branch (push is the code branch itself, pull_request the default).' - deprecationMessage: 'sync_strategy is deprecated; use codeboarding_analysis_branch.' + description: 'Deprecated: use codeboarding_analysis_location (push is in_place, pull_request is codeboarding_branch).' + deprecationMessage: 'sync_strategy is deprecated; use codeboarding_analysis_location.' required: false default: '' outputs: @@ -212,7 +214,7 @@ runs: env: MODE: ${{ inputs.mode }} REF_NAME: ${{ github.ref_name }} - ANALYSIS_BRANCH: ${{ inputs.codeboarding_analysis_branch }} + ANALYSIS_LOCATION: ${{ inputs.codeboarding_analysis_location }} OLD_TARGET_BRANCH: ${{ inputs.target_branch }} OLD_SYNC_STRATEGY: ${{ inputs.sync_strategy }} run: "$GITHUB_ACTION_PATH/scripts/action/old_api_migrator.sh" @@ -229,7 +231,7 @@ runs: REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} - ANALYSIS_BRANCH: ${{ steps.inputs.outputs.analysis_branch }} + ANALYSIS_LOCATION: ${{ steps.inputs.outputs.analysis_location }} COMMENT_BODY: ${{ github.event.comment.body }} AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} @@ -341,6 +343,20 @@ runs: fi exit 1 + # Before the checkout and the engine install, like the credential check: a sync + # that could not save its result fails in seconds, not after a full analysis. + - name: Check analysis branch + id: analysis_check + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' + shell: bash + env: + BASELINE_BRANCH: ${{ steps.guard.outputs.analysis_branch }} + SYNCED_BRANCH: ${{ steps.guard.outputs.synced_branch }} + GIT_TOKEN: ${{ inputs.github_token }} + GITHUB_SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + run: "$GITHUB_ACTION_PATH/scripts/action/check-analysis-branch.sh" + - name: Update review progress if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' continue-on-error: true @@ -455,7 +471,6 @@ runs: ACTION_PATH: ${{ github.action_path }} CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target STAGE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/out - FORCE_FULL: ${{ inputs.force_full }} BASELINE_BRANCH: ${{ steps.guard.outputs.analysis_branch }} CFG_HASH: ${{ steps.state.outputs.cfg_hash }} # Lets a branch without a usable committed baseline catch up from a saved analysis. @@ -532,6 +547,8 @@ runs: COMMITTED: ${{ steps.sync_commit.outputs.committed }} FILES: ${{ steps.sync_commit.outputs.files_written }} SAVED_TO: ${{ steps.guard.outputs.analysis_branch || steps.guard.outputs.synced_branch }} + # A failed sync has no pull request to comment on; the summary is where it says why. + FAILURE_REASON: ${{ steps.guard.outputs.failure_reason || steps.analysis_check.outputs.failure_reason || steps.sync_analyze.outputs.failure_reason || steps.sync_commit.outputs.failure_reason }} run: "$GITHUB_ACTION_PATH/scripts/action/sync-summary.sh" - name: Generate review baseline diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index 66aea3b..e2d4056 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -7,8 +7,8 @@ graphs it compares. | Store | Holds | Lifetime | Who can read it | |---|---|---|---| -| **Git, analysis branch** (`codeboarding_analysis_branch`, the default) | one commit per sync on `codeboarding/baseline` | forever | anyone with repo read | -| **Git, code branch** (`codeboarding_analysis_branch` set to the synced branch) | `.codeboarding/` on the synced branch | forever | anyone with repo read | +| **Git, analysis branch** (`codeboarding_analysis_location: codeboarding_branch`, the default) | one commit per sync on `codeboarding/baseline` | forever | anyone with repo read | +| **Git, code branch** (`codeboarding_analysis_location: in_place`) | `.codeboarding/` on the synced branch | forever | anyone with repo read | | **Workflow artifacts** | every analysis this action reuses or publishes | a retention window | any run with `actions: read`, plus humans | | ~~Actions cache~~ | — | — | not used | @@ -156,9 +156,9 @@ base and a diagram drawn against another would report changes nobody made. ## The baseline branch By default sync saves the analysis to a branch of its own in the same -repository, `codeboarding/baseline` unless `codeboarding_analysis_branch` names -another. The synced branch is then only read. Naming the synced branch itself -commits the analysis there instead, and skips everything below. +repository, `codeboarding/baseline`. The synced branch is then only read. +`codeboarding_analysis_location: in_place` commits the analysis to the synced +branch instead, and skips everything below. **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 @@ -169,8 +169,9 @@ sync would otherwise commit to the synced branch, plus {"schema": 1, "synced_branch": "main", "source_sha": "", "generated_at": "", "engine_version": "", "config": ""} ``` -The commit is `chore(codeboarding): diagram of main @` with two trailers: -`CodeBoarding-Source: ` and `CodeBoarding-Config: `, the same +The commit is `chore(codeboarding): diagram of main @` with three trailers: +`CodeBoarding-Source: `, `CodeBoarding-Branch: main` and +`CodeBoarding-Config: `, the same configuration hash that names the base artifacts (engine version, provider, model, depth cap). Engine output is never edited; which commit it describes and how it was made live only in `source.json` and the trailers. The synced branch is never @@ -183,7 +184,7 @@ wholesale; only the checkout's own `.codeboardingignore` and health configuratio are kept. The push is a fast-forward onto the tip it fetched, never forced. Sync refuses to write to an existing branch that is not a baseline branch (its tip has no `CodeBoarding-Source` trailer, or holds anything besides `.codeboarding/`), so -pointing `codeboarding_analysis_branch` at another code branch fails instead of emptying it. If the +a code branch that happens to be named `codeboarding/baseline` fails instead of being emptied. The branch belongs to the first branch that saved there: before installing anything, a sync from another branch fails, naming the branch it keeps the analysis of. If the synced branch moved during the analysis, the result is dropped, as when committing to it. 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 @@ -211,13 +212,19 @@ a pickle runs code when loaded, so whoever can write the branch can run code in the sync and review workflows. Import [`baseline-branch-ruleset.json`](baseline-branch-ruleset.json) under Settings, Rules, Rulesets, New ruleset, Import a ruleset. It blocks creating, updating, -deleting and force-pushing `codeboarding/baseline` for everyone except its bypass -actor, GitHub Actions (integration `15368`), which is what the default -`github.token` pushes as. If sync pushes with a GitHub App token instead, such as -the CodeBoarding Review app (`4021464`), make that app the only bypass actor: -any workflow can use `github.token`, while only the workflows you give the app's -key can push as the app. Creation is covered too, so the bypass actor must be the -identity sync pushes as before the first sync, or that sync fails on the rule. Rulesets on a private repository need a paid GitHub plan +deleting and force-pushing `codeboarding/baseline` for everyone except the +CodeBoarding Review app (`4021464`), so only the workflows you give the app's key +can write it; sync must then push with the app's token as `github_token`. + +If sync can only use the default `github.token`, import +[`baseline-branch-ruleset-actions.json`](baseline-branch-ruleset-actions.json) +instead, whose bypass actor is GitHub Actions (integration `15368`). **It only stops +people pushing by hand:** any workflow with `contents: write`, including one run +from a same-repository pull request branch, can still push to the branch and plant +the pickle sync loads. + +Creation is covered too, so the bypass actor must be the identity sync pushes as +before the first sync, or that sync fails on the rule. Rulesets on a private repository need a paid GitHub plan (Pro, Team or Enterprise); on Free they apply to public repositories only. ## Trust boundary diff --git a/docs/baseline-branch-ruleset-actions.json b/docs/baseline-branch-ruleset-actions.json new file mode 100644 index 0000000..19eab2d --- /dev/null +++ b/docs/baseline-branch-ruleset-actions.json @@ -0,0 +1,37 @@ +{ + "name": "CodeBoarding baseline branch (GitHub Actions: stops manual pushes only)", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/codeboarding/baseline" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "creation" + }, + { + "type": "update", + "parameters": { + "update_allows_fetch_and_merge": false + } + }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + } + ], + "bypass_actors": [ + { + "actor_id": 15368, + "actor_type": "Integration", + "bypass_mode": "always" + } + ] +} diff --git a/docs/baseline-branch-ruleset.json b/docs/baseline-branch-ruleset.json index a96dfb7..a3e46b2 100644 --- a/docs/baseline-branch-ruleset.json +++ b/docs/baseline-branch-ruleset.json @@ -4,19 +4,32 @@ "enforcement": "active", "conditions": { "ref_name": { - "include": ["refs/heads/codeboarding/baseline"], + "include": [ + "refs/heads/codeboarding/baseline" + ], "exclude": [] } }, "rules": [ - { "type": "creation" }, - { "type": "update", "parameters": { "update_allows_fetch_and_merge": false } }, - { "type": "deletion" }, - { "type": "non_fast_forward" } + { + "type": "creation" + }, + { + "type": "update", + "parameters": { + "update_allows_fetch_and_merge": false + } + }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + } ], "bypass_actors": [ { - "actor_id": 15368, + "actor_id": 4021464, "actor_type": "Integration", "bypass_mode": "always" } diff --git a/scripts/action/check-analysis-branch.sh b/scripts/action/check-analysis-branch.sh new file mode 100755 index 0000000..0e39c49 --- /dev/null +++ b/scripts/action/check-analysis-branch.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Sync keeps one branch's analysis on the analysis branch: the branch that first +# saved there. Checked before the engine is installed, so a sync from a second +# branch, or an analysis branch name already used by code, fails in seconds +# instead of after a full analysis. +set -euo pipefail +source "$(dirname "$0")/codeboarding-baseline.sh" +[ -n "${BASELINE_BRANCH:-}" ] || exit 0 + +status=0 +baseline_branch_exists "$REPOSITORY" || status=$? +# The first sync creates it. +[ "$status" = 0 ] || exit 0 + +# Commit messages and the tree listing only: no analysis files are downloaded. +scratch="$RUNNER_TEMP/codeboarding-analysis-branch-check.git" +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=1 \ + "${GITHUB_SERVER_URL%/}/${REPOSITORY}.git" "refs/heads/$BASELINE_BRANCH" || + baseline_fail "Could not fetch $BASELINE_BRANCH from $REPOSITORY." +trailer() { git -C "$scratch" log -1 --format="%(trailers:key=$1,valueonly)" FETCH_HEAD | tr -d '[:space:]'; } + +if [ -z "$(trailer CodeBoarding-Source)" ] || [ "$(git -C "$scratch" ls-tree --name-only FETCH_HEAD)" != .codeboarding ]; then + baseline_fail "$BASELINE_BRANCH already exists and is not a CodeBoarding analysis branch, so sync will not write to it. Rename that branch, or set codeboarding_analysis_location: in_place." +fi +owner="$(trailer CodeBoarding-Branch)" +if [ -n "$owner" ] && [ "$owner" != "$SYNCED_BRANCH" ]; then + baseline_fail "$BASELINE_BRANCH keeps the analysis of $owner, and this sync runs on $SYNCED_BRANCH. Sync keeps one branch current: list only $owner under on: push: branches:, or delete $BASELINE_BRANCH to keep $SYNCED_BRANCH's analysis there instead." +fi diff --git a/scripts/action/codeboarding-baseline.sh b/scripts/action/codeboarding-baseline.sh index 449546b..73e3fe9 100644 --- a/scripts/action/codeboarding-baseline.sh +++ b/scripts/action/codeboarding-baseline.sh @@ -1,12 +1,37 @@ -# The analysis branch (codeboarding_analysis_branch, BASELINE_BRANCH here): an orphan branch holding -# one commit per sync, each with trailers naming the commit it analysed -# (CodeBoarding-Source) and the configuration it ran under (CodeBoarding-Config). +# The analysis branch (codeboarding_analysis_location: codeboarding_branch, BASELINE_BRANCH +# here): an orphan branch holding one commit per sync, each with trailers naming the +# commit it analysed (CodeBoarding-Source), the branch that commit is on +# (CodeBoarding-Branch) and the configuration it ran under (CodeBoarding-Config). # Sync writes it; sync and review read it. Sourced, not run; needs analysis-state.sh. # How far below a commit a reader looks for an entry describing it or an ancestor. CATCHUP_BOUND="${CATCHUP_BOUND:-100}" BASELINE_BRANCH_DEPTH="${BASELINE_BRANCH_DEPTH:-100}" +# Fails the run with a reason the review comment and the sync summary repeat. +baseline_fail() { + echo "::error::$1" + echo "failure_reason=$1" >> "$GITHUB_OUTPUT" + exit 1 +} + +# Returns 0 when BASELINE_BRANCH exists in repository $1 and 2 when it does not. +# Any other failure is retried once, so one network blip does not fail the run. +baseline_branch_exists() { + local repository="$1" auth status + auth="$(printf 'x-access-token:%s' "${GIT_TOKEN:-}" | base64 -w0)" + for attempt in 1 2; do + status=0 + git -c "http.extraheader=AUTHORIZATION: basic $auth" ls-remote --exit-code \ + "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$BASELINE_BRANCH" >/dev/null || status=$? + case "$status" in + 0 | 2) return "$status" ;; + esac + [ "$attempt" = 2 ] || sleep "${BASELINE_RETRY_DELAY:-2}" + done + baseline_fail "Could not read $BASELINE_BRANCH from $repository. Check that github_token can read the repository." +} + # ---- Reading ---- # Lists the branch's entries as " ", newest @@ -31,28 +56,25 @@ codeboarding_baseline_index() { # the seed describes. from_codeboarding_baseline() { local repository="$1" tip="$2" state="$3" config_from="$4" index commit entry="" scratch="$RUNNER_TEMP/codeboarding-baseline-restore" - local auth status=0 + local status=0 [ -n "${BASELINE_BRANCH:-}" ] || return 1 [ -n "${CFG_HASH:-}" ] || return 1 # A branch that does not exist yet is a miss: the first sync creates it. Any other # failure to read it is an error, not a reason to quietly analyze from scratch. - auth="$(printf 'x-access-token:%s' "${GIT_TOKEN:-}" | base64 -w0)" - git -c "http.extraheader=AUTHORIZATION: basic $auth" ls-remote --exit-code \ - "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$BASELINE_BRANCH" >/dev/null || status=$? - case "$status" in - 0) ;; - 2) echo "::notice::$BASELINE_BRANCH does not exist yet; the first sync creates it."; return 1 ;; - *) echo "::error::Could not read $BASELINE_BRANCH from $repository. Check that github_token can read the repository."; exit 1 ;; - esac + baseline_branch_exists "$repository" || status=$? + if [ "$status" = 2 ]; then + echo "::notice::$BASELINE_BRANCH does not exist yet; the first sync creates it." + return 1 + fi index="$(codeboarding_baseline_index "$repository")" || - { echo "::error::Could not fetch $BASELINE_BRANCH from $repository."; exit 1; } + baseline_fail "Could not fetch $BASELINE_BRANCH from $repository." if [ -z "$index" ]; then # A code branch, as when a review into another branch names the one that holds the analysis. echo "::warning::$BASELINE_BRANCH is not a CodeBoarding analysis branch, so its analysis is not read here." return 1 fi fetch_commit "$repository" "$tip" "$(( CATCHUP_BOUND + 1 ))" || - { echo "::error::Could not fetch the history of ${tip:0:7} to look for its analysis."; exit 1; } + baseline_fail "Could not fetch the history of ${tip:0:7} to look for its analysis." 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" -v cfg="$CFG_HASH" '$2 == source && $3 == cfg {print $1; exit}' <<< "$index")" [ -z "$entry" ] || break @@ -63,11 +85,11 @@ from_codeboarding_baseline() { fi # The entry is known to exist from here on, so failing to read it is an error. fetch_commit "$repository" "$entry" || - { echo "::error::Could not fetch the analysis ${entry:0:7} from $BASELINE_BRANCH."; exit 1; } + baseline_fail "Could not fetch the analysis ${entry:0:7} from $BASELINE_BRANCH." rm -rf "$scratch" mkdir -p "$scratch" git -C "$CHECKOUT_DIR" archive "$entry" .codeboarding | tar -x -C "$scratch" || - { echo "::error::Could not extract the analysis ${entry:0:7} from $BASELINE_BRANCH."; exit 1; } + baseline_fail "Could not extract the analysis ${entry:0:7} from $BASELINE_BRANCH." # source.json is provenance, not engine state. rm -f "$scratch/.codeboarding/source.json" compatible_state "$scratch/.codeboarding" || return 1 @@ -105,8 +127,9 @@ json.dump({ git config user.name 'codeboarding-review[bot]' git config user.email 'codeboarding-review[bot]@users.noreply.github.com' # Readers trust only entries made under their own configuration. - [ -n "${CFG_HASH:-}" ] || { echo "::error::No configuration hash to record with the analysis."; exit 1; } + [ -n "${CFG_HASH:-}" ] || baseline_fail "No configuration hash to record with the analysis." local trailers=(-m "CodeBoarding-Source: $BASE_SHA +CodeBoarding-Branch: $SYNCED_BRANCH CodeBoarding-Config: $CFG_HASH") # Two tries: a concurrent sync that moved the branch for an older commit is @@ -126,8 +149,7 @@ CodeBoarding-Config: $CFG_HASH") # Building on any other branch would leave it holding nothing but .codeboarding/. if [ -z "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" ] || [ "$(git ls-tree --name-only "$tip")" != .codeboarding ]; then - echo "::error::$branch already exists and is not a CodeBoarding baseline branch, so sync will not write to it. Set codeboarding_analysis_branch to a branch name that is not in use, or to $SYNCED_BRANCH to commit the analysis there." - exit 1 + baseline_fail "$branch already exists and is not a CodeBoarding baseline branch, so sync will not write to it. Rename that branch, or set codeboarding_analysis_location: in_place." fi parent=(-p "$tip") if [ "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" = "$BASE_SHA" ] && @@ -146,8 +168,7 @@ CodeBoarding-Config: $CFG_HASH") 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 identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor for $branch in the repository's rulesets, and check that github_token may push to it." - exit 1 + baseline_fail "GitHub refused the push to $branch, most likely because a branch rule protects it. Add the identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor for $branch in the repository's rulesets, and check that github_token may push to it." fi done emit_result "$files" false "$BASE_SHA" diff --git a/scripts/action/deliver-sync.sh b/scripts/action/deliver-sync.sh index aa4dea6..ac462c9 100755 --- a/scripts/action/deliver-sync.sh +++ b/scripts/action/deliver-sync.sh @@ -35,12 +35,17 @@ classify_push_failure() { echo "::notice::A newer run updated $branch; leaving it untouched." exit 0 fi - echo "::error::Could not push the CodeBoarding baseline to $branch." - exit 1 + baseline_fail "Could not push the CodeBoarding baseline to $branch." } source "$ACTION_PATH/scripts/action/codeboarding-baseline.sh" -[ -z "${BASELINE_BRANCH:-}" ] || save_to_codeboarding_baseline +if [ -n "${BASELINE_BRANCH:-}" ]; then + # The synced branch is never written from here, so its old copy can only be pointed out. + if git ls-files --error-unmatch .codeboarding/analysis.json >/dev/null 2>&1; then + echo "::warning::$SYNCED_BRANCH still has an old analysis in .codeboarding/ that sync no longer updates; it is saved to $BASELINE_BRANCH now. You can delete it from $SYNCED_BRANCH, keeping .codeboarding/.codeboardingignore and the health configuration." + fi + save_to_codeboarding_baseline +fi "$ACTION_PATH/scripts/action/install-sync.sh" > "$GENERATED_PATHS" stage_paths=() diff --git a/scripts/action/old_api_migrator.sh b/scripts/action/old_api_migrator.sh index 4c2757d..2388ca7 100755 --- a/scripts/action/old_api_migrator.sh +++ b/scripts/action/old_api_migrator.sh @@ -1,48 +1,45 @@ #!/usr/bin/env bash -# Rewrites deprecated inputs as codeboarding_analysis_branch, the way a person +# Rewrites deprecated inputs as codeboarding_analysis_location, the way a person # editing the workflow would, and fails on any it cannot express. It is the only # place that knows they exist: every later step reads its output, never the inputs. # -# sync_strategy: push -> codeboarding_analysis_branch: -# sync_strategy: pull_request -> codeboarding_analysis_branch: (code branches are never written) -# target_branch: -> nothing -# target_branch: -> fails: sync analyzes the branch it runs on +# sync_strategy: push -> codeboarding_analysis_location: in_place +# sync_strategy: pull_request -> codeboarding_analysis_location: codeboarding_branch +# target_branch: -> nothing +# target_branch: -> fails: the push trigger picks the branch # # Delete a mapping, and its input in action.yml, once no supported workflow sets it. set -euo pipefail MIGRATE_URL=https://github.com/CodeBoarding/CodeBoarding-action#moving-an-existing-setup fail() { echo "::error title=CodeBoarding inputs::$1 See $MIGRATE_URL for the change, or a prompt that makes it for you."; exit 1; } warn() { echo "::warning title=CodeBoarding inputs::$1 See $MIGRATE_URL."; } -DEFAULT_ANALYSIS_BRANCH=codeboarding/baseline +DEFAULT_LOCATION=codeboarding_branch -analysis_branch="${ANALYSIS_BRANCH:-}" -[ -n "$analysis_branch" ] || { echo "::error::codeboarding_analysis_branch must name a branch."; exit 1; } -# Both inputs only ever applied to sync. -if [ "$MODE" = sync ]; then - if [ -n "${OLD_TARGET_BRANCH:-}" ]; then - [ "$OLD_TARGET_BRANCH" = "$REF_NAME" ] || - fail "target_branch is no longer supported: sync analyzes the branch it runs on, which for this run is $REF_NAME, not $OLD_TARGET_BRANCH. Remove target_branch: $OLD_TARGET_BRANCH, and sync analyzes $REF_NAME." - warn "target_branch is deprecated and has no effect here; remove it." - fi - if [ -n "${OLD_SYNC_STRATEGY:-}" ]; then - # codeboarding_analysis_branch has a default, so only a value other than it is a conflict. - [ "$analysis_branch" = "$DEFAULT_ANALYSIS_BRANCH" ] || - fail "Set codeboarding_analysis_branch only; sync_strategy is the input it replaces." - case "$OLD_SYNC_STRATEGY" in - push) - analysis_branch="$REF_NAME" - warn "sync_strategy is deprecated; replace sync_strategy: push with codeboarding_analysis_branch: $REF_NAME." - ;; - pull_request) - warn "sync_strategy: pull_request is deprecated; the analysis is saved to $analysis_branch instead, and code branches are never written. Remove sync_strategy, and close any open codeboarding/sync pull request." - ;; - *) fail "sync_strategy is no longer supported; use codeboarding_analysis_branch." ;; - esac - fi -elif [ "${OLD_SYNC_STRATEGY:-}" = push ]; then - warn "sync_strategy only applied to sync and is deprecated; replace it with codeboarding_analysis_branch set to the branch sync commits to, here and in the sync workflow." -elif [ -n "${OLD_TARGET_BRANCH:-}${OLD_SYNC_STRATEGY:-}" ]; then - warn "target_branch and sync_strategy only applied to sync, and are deprecated; remove them." +location="${ANALYSIS_LOCATION:-}" +case "$location" in + codeboarding_branch | in_place) ;; + *) fail "codeboarding_analysis_location must be codeboarding_branch or in_place, not '$location'." ;; +esac + +if [ -n "${OLD_SYNC_STRATEGY:-}" ]; then + # codeboarding_analysis_location has a default, so only a value other than it is a conflict. + [ "$location" = "$DEFAULT_LOCATION" ] || + fail "Set codeboarding_analysis_location only; sync_strategy is the input it replaces." + case "$OLD_SYNC_STRATEGY" in + push) location=in_place ;; + pull_request) location=codeboarding_branch ;; + *) fail "sync_strategy is no longer supported; use codeboarding_analysis_location." ;; + esac + warn "sync_strategy is deprecated; replace sync_strategy: $OLD_SYNC_STRATEGY with codeboarding_analysis_location: $location, in the review workflow too." + [ "$OLD_SYNC_STRATEGY" != pull_request ] || + warn "Rolling sync pull requests are gone; close any open codeboarding/sync pull request." +fi + +if [ -n "${OLD_TARGET_BRANCH:-}" ]; then + # Only sync read it; the branch a sync runs on is the one its push trigger lists. + [ "$MODE" != sync ] || [ "$OLD_TARGET_BRANCH" = "$REF_NAME" ] || + fail "target_branch is no longer supported: sync analyzes the branch its push trigger lists, and this run is on $REF_NAME. List $OLD_TARGET_BRANCH under on: push: branches: instead, and remove target_branch." + warn "target_branch is deprecated and has no effect; remove it." fi -echo "analysis_branch=$analysis_branch" >> "$GITHUB_OUTPUT" +echo "analysis_location=$location" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/resolve-github-event.sh b/scripts/action/resolve-github-event.sh index 2b7513a..3c2aa99 100755 --- a/scripts/action/resolve-github-event.sh +++ b/scripts/action/resolve-github-event.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Validates the event and outputs the exact refs and metadata used by later steps. set -euo pipefail -fail() { echo "::error::$1"; exit 1; } +fail() { echo "::error::$1"; echo "failure_reason=$1" >> "$GITHUB_OUTPUT"; exit 1; } skip() { echo "::notice::$1"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } [ -z "${GH_HOST:-}" ] || export GH_HOST="${GH_HOST#*://}" case "$MODE" in @@ -9,16 +9,10 @@ case "$MODE" in *) fail "mode must be review or sync." ;; esac printf 'mode=%s\nskip=false\nevent=%s\n' "$MODE" "$EVENT" >> "$GITHUB_OUTPUT" -# Both modes read the analysis branch, so a name git cannot use fails here, before -# anything is analyzed, rather than reading as a missing branch and costing a full run. -if [ -n "${ANALYSIS_BRANCH:-}" ] && ! git check-ref-format "refs/heads/$ANALYSIS_BRANCH"; then - fail "codeboarding_analysis_branch '$ANALYSIS_BRANCH' is not a valid branch name." -fi -# analysis_branch is the separate branch the analysis is kept on, or empty when -# codeboarding_analysis_branch names the code branch itself, which then holds it. -separate_branch() { - [ "${ANALYSIS_BRANCH:-}" = "$1" ] || echo "${ANALYSIS_BRANCH:-}" -} +# The branch the analysis is kept on, or empty when it is committed in place, on +# the code branch itself. +analysis_branch="" +[ "${ANALYSIS_LOCATION:-}" != codeboarding_branch ] || analysis_branch=codeboarding/baseline if [ "$MODE" = sync ]; then case "$EVENT" in push|workflow_dispatch|schedule) ;; @@ -32,7 +26,6 @@ if [ "$MODE" = sync ]; then esac synced_branch="$REF_NAME" [ -n "$synced_branch" ] || fail "Sync needs a branch to analyze; run it on one." - analysis_branch="$(separate_branch "$synced_branch")" { echo "synced_branch=$synced_branch" echo "analysis_branch=$analysis_branch" @@ -124,7 +117,7 @@ is_fork=false echo "merge_base_resolved=$merge_base_resolved" echo "behind_by=$behind_by" echo "base_ref=$base_ref" - echo "analysis_branch=$(separate_branch "$base_ref")" + echo "analysis_branch=$analysis_branch" echo "head_sha=$head_sha" echo "base_repo=$base_repo" echo "head_repo=$head_repo" diff --git a/scripts/action/sync-generate-baseline.sh b/scripts/action/sync-generate-baseline.sh index 87ea7be..2bdd691 100755 --- a/scripts/action/sync-generate-baseline.sh +++ b/scripts/action/sync-generate-baseline.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Produces the analysis of the synced branch's head, continuing from the nearest -# saved analysis unless force_full asks for a fresh one. First match wins: +# saved analysis. First match wins: # # 1. an entry on the baseline branch for this commit or an ancestor # 2. the analysis committed on the branch @@ -18,11 +18,9 @@ if [ -n "$(git -C "$CHECKOUT_DIR" log -1 --format='%(trailers:key=CodeBoarding-S exit 1 fi -if [ "$(printf '%s' "${FORCE_FULL:-false}" | tr '[:upper:]' '[:lower:]')" = true ] || ! { - from_codeboarding_baseline "${REPOSITORY:-}" "$head_sha" "$state" "$CHECKOUT_DIR" || - from_committed_baseline "$CHECKOUT_DIR" "$state" || - from_ancestor_artifact "${REPOSITORY:-}" "$head_sha" "$state" true "$CHECKOUT_DIR" -}; then +if ! from_codeboarding_baseline "${REPOSITORY:-}" "$head_sha" "$state" "$CHECKOUT_DIR" && + ! from_committed_baseline "$CHECKOUT_DIR" "$state" && + ! from_ancestor_artifact "${REPOSITORY:-}" "$head_sha" "$state" true "$CHECKOUT_DIR"; then keep_user_config "$CHECKOUT_DIR" "$state" fi diff --git a/scripts/action/sync-summary.sh b/scripts/action/sync-summary.sh index 2c01a3a..eae914f 100755 --- a/scripts/action/sync-summary.sh +++ b/scripts/action/sync-summary.sh @@ -3,8 +3,11 @@ set -euo pipefail { echo "### CodeBoarding Sync" - echo "- Analysis: ${MODE}" - echo "- Analysis artifacts: ${FILES:-0}" - echo "- Delivered: ${COMMITTED:-false}" - echo "- Saved to: ${SAVED_TO}" + [ -z "${FAILURE_REASON:-}" ] || echo "- **Failed:** ${FAILURE_REASON}" + if [ -n "${MODE:-}" ]; then + echo "- Analysis: ${MODE}" + echo "- Analysis artifacts: ${FILES:-0}" + echo "- Delivered: ${COMMITTED:-false}" + echo "- Saved to: ${SAVED_TO}" + fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 2d240da..5fc96e7 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -100,7 +100,7 @@ def test_deprecated_inputs_are_read_only_by_the_migrator(self) -> None: for old in ("target_branch", "sync_strategy"): self.assertIn("deprecationMessage:", self.inputs[old]) self.assertEqual(ACTION.count(f"inputs.{old} }}}}"), 1, f"only the migrator may read {old}") - new = "codeboarding_analysis_branch" + new = "codeboarding_analysis_location" self.assertEqual(ACTION.count(f"inputs.{new} }}}}"), 1, f"only the migrator may read {new}") self.assertLess(ACTION.index("- name: Translate deprecated inputs"), ACTION.index("- name: Resolve event")) diff --git a/tests/test_action_state.py b/tests/test_action_state.py index 57c2ea8..0a02ea4 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -489,7 +489,7 @@ def test_sync_without_baseline_uses_configured_depth_directly(self) -> None: # Sync always runs on a checkout of the synced branch. self._git("init", "-q") self._commit("code", {"app.py": "pass\n"}) - self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false", DEPTH_CAP="4") + self._analyze(ANALYSIS_KIND="sync", DEPTH_CAP="4") self.assertEqual([c["mode"] for c in self._engine_calls()], ["full"]) self.assertEqual(self._engine_calls()[0]["depth"], "4") @@ -848,19 +848,10 @@ def test_a_first_sync_catches_up_from_the_setup_reviews_base(self) -> None: shas = self._history(3) self._serve([self._artifact(f"codeboarding-base-cfg-{shas[1]}")]) - self._analyze(self.origin, ANALYSIS_KIND="sync", FORCE_FULL="false") + self._analyze(self.origin, ANALYSIS_KIND="sync") self.assertEqual(self._modes(), ["incremental"]) - def test_a_forced_sync_never_seeds(self) -> None: - shas = self._history(2) - self._serve([self._artifact(f"codeboarding-base-cfg-{shas[0]}")]) - - self._analyze(self.origin, ANALYSIS_KIND="sync", FORCE_FULL="True") - - self.assertEqual(self._modes(), ["full"]) - self.assertEqual(self.gh_log.read_text(), "") - class ReviewArtifactTests(unittest.TestCase): """The artifact is the only channel a reader outside the run can use: cache diff --git a/tests/test_action_sync.py b/tests/test_action_sync.py index 2987a2e..85cf8ba 100644 --- a/tests/test_action_sync.py +++ b/tests/test_action_sync.py @@ -256,6 +256,24 @@ def test_installs_the_health_report_the_engine_produced(self) -> None: # file that is never printed is silently left out of the commit. self.assertIn(str(installed), result.stdout.splitlines()) + def test_a_failed_sync_says_why_in_the_job_summary(self) -> None: + # A push-triggered sync has no pull request to comment on. + with tempfile.TemporaryDirectory() as tmp: + summary = Path(tmp) / "summary" + subprocess.run( + [str(ROOT / "scripts" / "action" / "sync-summary.sh")], + env={ + "PATH": os.environ["PATH"], + "GITHUB_STEP_SUMMARY": str(summary), + "FAILURE_REASON": "codeboarding/baseline keeps the analysis of main, and this sync runs on dev.", + "SAVED_TO": "codeboarding/baseline", + }, + check=True, + ) + text = summary.read_text(encoding="utf-8") + self.assertIn("- **Failed:** codeboarding/baseline keeps the analysis of main", text) + self.assertNotIn("- Analysis:", text, "nothing was analyzed") + def test_empty_review_is_successful(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tests/test_baseline_branch.py b/tests/test_baseline_branch.py index 9b7565d..533bc0f 100644 --- a/tests/test_baseline_branch.py +++ b/tests/test_baseline_branch.py @@ -1,9 +1,11 @@ -"""codeboarding_analysis_branch saves the analysis to an orphan branch, and reviews read their base from it.""" +"""codeboarding_analysis_location: codeboarding_branch saves the analysis to an orphan branch, and reviews read +their base from it.""" from __future__ import annotations import json import os +import shutil import subprocess import tempfile import unittest @@ -124,7 +126,7 @@ def test_the_first_sync_creates_an_orphan_branch_with_its_provenance(self) -> No self.assertEqual( message, f"chore(codeboarding): diagram of main @{main_before[:7]}\n\n" - f"CodeBoarding-Source: {main_before}\nCodeBoarding-Config: cfg", + f"CodeBoarding-Source: {main_before}\nCodeBoarding-Branch: main\nCodeBoarding-Config: cfg", ) # The synced branch is never written when the baseline has a branch of its own. self.assertEqual(git(self.remote, "rev-parse", "main"), main_before) @@ -140,7 +142,9 @@ def test_the_next_sync_appends_a_fast_forward_commit(self) -> None: log = self._branch_log() self.assertEqual(len(log), 2) self.assertEqual(log[0].split()[1:], [first]) - self.assertIn(f"CodeBoarding-Source: {new_main}", git(self.remote, "log", "-1", "--format=%B", BRANCH)) + message = git(self.remote, "log", "-1", "--format=%B", BRANCH) + self.assertIn(f"CodeBoarding-Source: {new_main}", message) + self.assertIn("CodeBoarding-Branch: main", message, "the branch it keeps the analysis of") def test_a_rerun_on_the_same_commit_adds_nothing(self) -> None: self._deliver() @@ -166,6 +170,63 @@ def test_a_push_refused_by_a_branch_rule_says_how_to_fix_it(self) -> None: self.assertIn(f"GitHub refused the push to {BRANCH}", result.stdout) self.assertIn("bypass actor", result.stdout) + def _check(self, synced: str) -> subprocess.CompletedProcess: + (self.root / "check-output").write_text("", encoding="utf-8") + return subprocess.run( + [str(ROOT / "scripts" / "action" / "check-analysis-branch.sh")], + env={ + "PATH": os.environ["PATH"], + "GITHUB_OUTPUT": str(self.root / "check-output"), + "RUNNER_TEMP": str(self.root), + "GITHUB_SERVER_URL": str(self.root), + "GIT_TOKEN": "unused", + "REPOSITORY": "owner/repo", + "BASELINE_BRANCH": BRANCH, + "SYNCED_BRANCH": synced, + }, + capture_output=True, + text=True, + check=False, + ) + + def test_the_check_before_the_install_lets_the_first_and_the_same_branch_through(self) -> None: + self.assertEqual(self._check("main").returncode, 0, "no analysis branch yet: the first sync creates it") + self._deliver() + result = self._check("main") + self.assertEqual(result.returncode, 0, result.stdout) + + def test_the_check_before_the_install_refuses_a_second_branch(self) -> None: + # A trigger listing [main, dev] would show whichever synced last as the latest diagram. + self._deliver() + + result = self._check("dev") + + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"{BRANCH} keeps the analysis of main, and this sync runs on dev.", result.stdout) + reason = (self.root / "check-output").read_text(encoding="utf-8") + self.assertIn("failure_reason=codeboarding/baseline keeps the analysis of main", reason, "for the job summary") + + def test_the_check_before_the_install_refuses_a_code_branch_with_the_name(self) -> None: + git(self.checkout, "push", "-q", "origin", f"main:refs/heads/{BRANCH}") + + result = self._check("main") + + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"{BRANCH} already exists and is not a CodeBoarding analysis branch", result.stdout) + + def test_an_old_analysis_left_on_the_synced_branch_is_pointed_out(self) -> None: + # The synced branch is never written from here, so its old copy can only be pointed out. + board = self.checkout / ".codeboarding" + board.mkdir() + (board / "analysis.json").write_text("{}\n", encoding="utf-8") + self._push_code("committed-baseline") + + result, values = self._deliver() + + self.assertEqual(values["committed"], "true") + self.assertIn("main still has an old analysis in .codeboarding/", result.stdout) + self.assertIn("keeping .codeboarding/.codeboardingignore", result.stdout) + def test_a_deleted_branch_is_recreated_as_a_new_orphan(self) -> None: self._deliver() git(self.remote, "branch", "-D", BRANCH) @@ -177,8 +238,8 @@ def test_a_deleted_branch_is_recreated_as_a_new_orphan(self) -> None: self.assertEqual(len(only.split()), 1, "a recreated branch starts a new history") def test_an_existing_branch_that_is_not_a_baseline_branch_is_never_written(self) -> None: - # codeboarding_analysis_branch pointed at another code branch: building on it would leave it - # holding nothing but .codeboarding/. + # A code branch that happens to have the analysis branch's name: building on it would + # leave it holding nothing but .codeboarding/. git(self.checkout, "push", "-q", "origin", f"main:refs/heads/{BRANCH}") before = git(self.remote, "rev-parse", BRANCH) @@ -186,7 +247,7 @@ def test_an_existing_branch_that_is_not_a_baseline_branch_is_never_written(self) self.assertNotEqual(result.returncode, 0) self.assertIn(f"{BRANCH} already exists and is not a CodeBoarding baseline branch", result.stdout) - self.assertIn("codeboarding_analysis_branch", result.stdout) + self.assertIn("codeboarding_analysis_location: in_place", result.stdout) self.assertEqual(git(self.remote, "rev-parse", BRANCH), before) self.assertEqual(values.get("committed", "false"), "false") @@ -283,6 +344,7 @@ def _analyze(self, expect_ok: bool = True, **extra: str) -> dict[str, str]: "ENGINE_VERSION": "0.14.5", "CFG_HASH": "cfg", "BASELINE_BRANCH": BRANCH, + "BASELINE_RETRY_DELAY": "0", "BASE_DIR": str(self.root / "state" / "base"), "WARMSTART_DIR": str(self.root / "state" / "warmstart"), "STAGE_DIR": str(self.root / "state" / "out"), @@ -294,6 +356,9 @@ def _analyze(self, expect_ok: bool = True, **extra: str) -> dict[str, str]: self.assertEqual(code == 0, expect_ok, self.log) return values + def _outputs(self) -> dict[str, str]: + return dict(line.split("=", 1) for line in self.output.read_text().splitlines() if "=" in line) + def _modes(self) -> list[str]: return [json.loads(line)["mode"] for line in self.engine_log.read_text().splitlines()] @@ -312,6 +377,30 @@ def test_a_branch_entry_for_the_merge_base_is_reused(self) -> None: # No artifact holds it yet, so it is published under the merge base's name. self.assertEqual(values["publish_base"], "true") + def test_a_branch_that_cannot_be_read_fails_and_the_review_comment_says_why(self) -> None: + # A token without access looks like this too: it must not read as a missing branch. + self._analyze(False, REVIEW_BASE_SHA=self.shas[4], GITHUB_SERVER_URL=f"file://{self.root}/missing") + + self.assertIn(f"Could not read {BRANCH}", self._outputs().get("failure_reason", "")) + self.assertEqual(self._modes(), []) + + def test_a_single_failed_lookup_is_retried(self) -> None: + real_git = shutil.which("git") + marker = self.root / "blipped" + wrapper = self.bin_dir / "git" + wrapper.write_text( + "#!/bin/sh\n" + f'for a in "$@"; do [ "$a" = ls-remote ] && [ ! -e {marker} ] && touch {marker} && exit 128; done\n' + f'exec {real_git} "$@"\n', + encoding="utf-8", + ) + wrapper.chmod(0o755) + + self._analyze(REVIEW_BASE_SHA=self.shas[3]) + + self.assertTrue(marker.exists(), "the first lookup failed") + self.assertEqual(self._base_modes(), [], "the retry found the entry") + def test_the_nearest_branch_entry_below_the_merge_base_is_caught_up(self) -> None: self._analyze(REVIEW_BASE_SHA=self.shas[4]) @@ -345,7 +434,7 @@ def test_a_branch_that_cannot_be_read_fails_instead_of_analyzing_from_scratch(se self.assertEqual(self._modes(), []) def test_a_code_branch_named_as_the_analysis_branch_is_said_and_skipped(self) -> None: - # codeboarding_analysis_branch: main, read by a review of a pull request into another branch. + # A code branch read as the analysis branch, as a review into another branch could. self._analyze(REVIEW_BASE_SHA=self.shas[4], BASELINE_BRANCH="main") self.assertIn("main is not a CodeBoarding analysis branch", self.log) @@ -369,7 +458,7 @@ def test_the_branch_outranks_a_baseline_committed_at_the_merge_base(self) -> Non self.assertNotEqual(base["components"], ["stale"]) def test_sync_continues_from_the_branch_tip(self) -> None: - self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false") + self._analyze(ANALYSIS_KIND="sync") self.assertEqual(self._modes(), ["incremental"]) @@ -383,7 +472,7 @@ def test_sync_replaces_the_committed_state_with_the_branch_tip(self) -> None: (board / "static_analysis.sha").write_text("stale\n") (board / ".codeboardingignore").write_text("docs/\n") - values = self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false") + values = self._analyze(ANALYSIS_KIND="sync") state = Path(values["analysis_dir"]) self.assertEqual((state / "static_analysis.pkl").read_text(), "pickle", "the tip's engine state") @@ -392,7 +481,7 @@ def test_sync_replaces_the_committed_state_with_the_branch_tip(self) -> None: self.assertEqual((state / ".codeboardingignore").read_text(), "docs/\n") def test_sync_does_not_continue_from_a_tip_made_under_another_configuration(self) -> None: - self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false", CFG_HASH="othercfg") + self._analyze(ANALYSIS_KIND="sync", CFG_HASH="othercfg") self.assertEqual(self._modes(), ["full"]) @@ -401,7 +490,7 @@ def test_sync_refuses_to_run_on_the_analysis_branch(self) -> None: analysis_checkout = self.root / "analysis-checkout" git(self.root, "clone", "-q", "--branch", BRANCH, f"file://{self.root}/origin.git", str(analysis_checkout)) - self._analyze(False, ANALYSIS_KIND="sync", FORCE_FULL="false", CHECKOUT_DIR=str(analysis_checkout)) + self._analyze(False, ANALYSIS_KIND="sync", CHECKOUT_DIR=str(analysis_checkout)) self.assertIn("This run is on a CodeBoarding analysis branch", self.log) self.assertEqual(self._modes(), []) @@ -409,7 +498,6 @@ def test_sync_refuses_to_run_on_the_analysis_branch(self) -> None: def test_sync_without_the_branch_analyzes_from_scratch(self) -> None: self._analyze( ANALYSIS_KIND="sync", - FORCE_FULL="false", BASELINE_BRANCH="codeboarding/none", ) @@ -431,7 +519,7 @@ def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: "REF_NAME": "main", "REF_TYPE": "branch", "HEAD_AUTHOR_EMAIL": "dev@example.com", - "ANALYSIS_BRANCH": BRANCH, + "ANALYSIS_LOCATION": "codeboarding_branch", "REPOSITORY": "owner/repo", **extra, }, @@ -447,7 +535,7 @@ def test_sync_saves_to_the_analysis_branch_by_default(self) -> None: self.assertIn("synced_branch=main\n", values) self.assertIn(f"analysis_branch={BRANCH}\n", values) - def test_naming_the_code_branch_commits_the_analysis_there(self) -> None: + def test_in_place_commits_the_analysis_to_the_code_branch_in_both_modes(self) -> None: with tempfile.TemporaryDirectory() as bin_dir: gh = Path(bin_dir) / "gh" gh.write_text("#!/bin/sh\nprintf 'base-sha\\t0\\n'\n", encoding="utf-8") @@ -459,23 +547,16 @@ def test_naming_the_code_branch_commits_the_analysis_there(self) -> None: "PULL_HEAD_SHA": "head-sha", "PULL_BASE_REPO": "owner/repo", "PULL_HEAD_REPO": "owner/repo", - "PULL_BASE_REF": "main", + "PULL_BASE_REF": "develop", "PATH": f"{bin_dir}:{os.environ['PATH']}", } for mode, extra in (("sync", {}), ("review", review)): with self.subTest(mode=mode): - result, values = self._guard(MODE=mode, ANALYSIS_BRANCH="main", **extra) + result, values = self._guard(MODE=mode, ANALYSIS_LOCATION="in_place", **extra) self.assertEqual(result.returncode, 0, result.stdout) self.assertIn("analysis_branch=\n", values, result.stdout) result, values = self._guard(MODE="review", **review) - self.assertIn(f"analysis_branch={BRANCH}\n", values, "a review of main reads the analysis branch") - - def test_a_branch_name_git_cannot_use_fails_before_anything_runs(self) -> None: - for mode in ("sync", "review"): - for name in ("codeboarding/a..b", "codeboarding/with space", "codeboarding/trailing."): - result, _values = self._guard(MODE=mode, ANALYSIS_BRANCH=name) - self.assertNotEqual(result.returncode, 0, (mode, name)) - self.assertIn("is not a valid branch name", result.stdout) + self.assertIn(f"analysis_branch={BRANCH}\n", values, "a review reads the analysis branch") if __name__ == "__main__": diff --git a/tests/test_old_api_migrator.py b/tests/test_old_api_migrator.py index b132531..ca84467 100644 --- a/tests/test_old_api_migrator.py +++ b/tests/test_old_api_migrator.py @@ -10,7 +10,6 @@ ROOT = Path(__file__).resolve().parent.parent MIGRATOR = ROOT / "scripts" / "action" / "old_api_migrator.sh" -DEFAULT_ANALYSIS_BRANCH = "codeboarding/baseline" def migrate(**inputs: str) -> tuple[subprocess.CompletedProcess, str]: @@ -25,7 +24,7 @@ def migrate(**inputs: str) -> tuple[subprocess.CompletedProcess, str]: "GITHUB_OUTPUT": str(output), "MODE": "sync", "REF_NAME": "main", - "ANALYSIS_BRANCH": DEFAULT_ANALYSIS_BRANCH, + "ANALYSIS_LOCATION": "codeboarding_branch", "OLD_TARGET_BRANCH": "", "OLD_SYNC_STRATEGY": "", **inputs, @@ -35,71 +34,71 @@ def migrate(**inputs: str) -> tuple[subprocess.CompletedProcess, str]: check=False, ) values = dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) - return result, values.get("analysis_branch", "") + return result, values.get("analysis_location", "") class OldApiMigratorTests(unittest.TestCase): - """Each deprecated input becomes the codeboarding_analysis_branch a person would write, or fails.""" + """Each deprecated input becomes the codeboarding_analysis_location a person would write, or fails.""" def test_current_inputs_pass_through_without_warnings(self) -> None: - result, branch = migrate(ANALYSIS_BRANCH="main") - self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(branch, "main") - self.assertNotIn("::warning", result.stdout) - - def test_sync_strategy_push_names_the_synced_branch(self) -> None: - result, branch = migrate(OLD_SYNC_STRATEGY="push") - self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(branch, "main") - self.assertIn("codeboarding_analysis_branch: main", result.stdout) - - def test_sync_strategy_pull_request_keeps_the_default_analysis_branch(self) -> None: - result, branch = migrate(OLD_SYNC_STRATEGY="pull_request") - self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(branch, DEFAULT_ANALYSIS_BRANCH) + for location in ("codeboarding_branch", "in_place"): + with self.subTest(location=location): + result, value = migrate(ANALYSIS_LOCATION=location) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(value, location) + self.assertNotIn("::warning", result.stdout) + + def test_a_location_that_is_not_one_of_the_two_fails(self) -> None: + for location in ("", "main", "codeboarding/baseline"): + with self.subTest(location=location): + result, value = migrate(ANALYSIS_LOCATION=location) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(value, "") + self.assertIn("must be codeboarding_branch or in_place", result.stdout) + + def test_sync_strategy_maps_onto_the_location_in_both_modes(self) -> None: + # Review reads where sync wrote, so a shared with-block keeps both in step. + for mode in ("sync", "review"): + for old, new in (("push", "in_place"), ("pull_request", "codeboarding_branch")): + with self.subTest(mode=mode, old=old): + result, value = migrate(MODE=mode, OLD_SYNC_STRATEGY=old) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(value, new) + self.assertIn(f"codeboarding_analysis_location: {new}", result.stdout) + + def test_sync_strategy_pull_request_says_to_close_the_rolling_pull_request(self) -> None: + result, _value = migrate(OLD_SYNC_STRATEGY="pull_request") self.assertIn("codeboarding/sync", result.stdout) - def test_target_branch_naming_the_synced_branch_changes_nothing(self) -> None: - result, branch = migrate(OLD_TARGET_BRANCH="main") + def test_target_branch_naming_the_branch_the_run_is_on_changes_nothing(self) -> None: + result, value = migrate(OLD_TARGET_BRANCH="main") self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(branch, DEFAULT_ANALYSIS_BRANCH) - self.assertIn("::warning", result.stdout) + self.assertEqual(value, "codeboarding_branch") + self.assertIn("target_branch is deprecated", result.stdout) + + def test_a_target_branch_elsewhere_points_at_the_push_trigger(self) -> None: + # It must not tell anyone to analyze a branch they did not choose. + result, _value = migrate(REF_NAME="master", OLD_TARGET_BRANCH="develop") + self.assertNotEqual(result.returncode, 0) + self.assertIn("this run is on master. List develop under on: push: branches:", result.stdout) - def test_what_the_new_inputs_cannot_express_fails_with_the_way_to_migrate(self) -> None: + def test_what_the_new_input_cannot_express_fails_with_the_way_to_migrate(self) -> None: for inputs in ( {"OLD_TARGET_BRANCH": "develop"}, {"OLD_SYNC_STRATEGY": "branch"}, - {"ANALYSIS_BRANCH": "main", "OLD_SYNC_STRATEGY": "push"}, - {"ANALYSIS_BRANCH": "main", "OLD_SYNC_STRATEGY": "pull_request"}, + {"ANALYSIS_LOCATION": "in_place", "OLD_SYNC_STRATEGY": "push"}, + {"ANALYSIS_LOCATION": "in_place", "OLD_SYNC_STRATEGY": "pull_request"}, ): with self.subTest(inputs=inputs): - result, branch = migrate(**inputs) + result, value = migrate(**inputs) self.assertNotEqual(result.returncode, 0, result.stdout) - self.assertEqual(branch, "") + self.assertEqual(value, "") self.assertIn("#moving-an-existing-setup", result.stdout) - def test_a_stale_target_branch_names_the_branch_the_run_is_on(self) -> None: - # A repository whose branch is master, with target_branch: main left in its workflow: - # "run sync on main" would send it to a branch that does not exist. - result, _branch = migrate(REF_NAME="master", OLD_TARGET_BRANCH="main") - self.assertNotEqual(result.returncode, 0) - self.assertIn("for this run is master", result.stdout) - self.assertIn("Remove target_branch: main, and sync analyzes master.", result.stdout) - self.assertNotIn("run sync on main", result.stdout) - - def test_an_empty_analysis_branch_fails(self) -> None: - result, branch = migrate(ANALYSIS_BRANCH="") - self.assertNotEqual(result.returncode, 0) - self.assertEqual(branch, "") - self.assertIn("must name a branch", result.stdout) - - def test_review_never_reads_the_sync_inputs(self) -> None: - result, branch = migrate( - MODE="review", REF_NAME="42/merge", OLD_TARGET_BRANCH="develop", OLD_SYNC_STRATEGY="push" - ) + def test_review_never_fails_on_target_branch(self) -> None: + result, value = migrate(MODE="review", REF_NAME="42/merge", OLD_TARGET_BRANCH="develop") self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(branch, DEFAULT_ANALYSIS_BRANCH) - self.assertIn("replace it with codeboarding_analysis_branch", result.stdout) + self.assertEqual(value, "codeboarding_branch") if __name__ == "__main__": From 08d55da4d0d347f9ece27f2e5cb61de9c89b8db7 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sun, 11 Oct 2026 15:28:25 +0200 Subject: [PATCH 14/19] feat: keep each branch's analysis on an analysis branch named after it Sync saves to codeboarding/baseline-, and a review reads the one of its base branch. There is no ownership record left to go stale: a renamed branch, a branch switch or a manual run elsewhere starts its own analysis branch instead of locking the synced branch out. Also: force_full is accepted again and ignored with a warning; sync failures in install, analysis and cache-key steps state their reason in the job summary; the README points default-token setups at the GitHub Actions ruleset and documents the one synced branch. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +-- README.md | 20 ++++++------- action.yml | 13 +++++++-- docs/COMMIT_STRATEGY.md | 18 ++++++++---- docs/baseline-branch-ruleset-actions.json | 3 +- docs/baseline-branch-ruleset.json | 3 +- scripts/action/analyze.sh | 9 +++--- scripts/action/check-analysis-branch.sh | 15 +++------- scripts/action/codeboarding-baseline.sh | 2 +- scripts/action/install-sync.sh | 9 ++++-- scripts/action/old_api_migrator.sh | 5 ++++ scripts/action/resolve-cache-keys.sh | 2 +- scripts/action/resolve-github-event.sh | 15 ++++++---- scripts/action/sync-generate-baseline.sh | 4 ++- tests/test_action_sync.py | 35 +++++++++++++++++++++-- tests/test_baseline_branch.py | 28 ++++++++++-------- tests/test_old_api_migrator.py | 9 ++++++ 17 files changed, 129 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7492a2f..5e5f059 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This repo is a GitHub Action with two modes, selected by the `mode` input: comment (runs on `pull_request` / `issue_comment`). - **`mode: sync`**: on push to the one branch its trigger lists, regenerates the analysis and saves the `.codeboarding/` state where - `codeboarding_analysis_location` says (an orphan `codeboarding/baseline` branch + `codeboarding_analysis_location` says (an orphan `codeboarding/baseline-` branch by default, or `in_place` on that branch itself), so review mode always diffs against a current baseline. @@ -85,7 +85,7 @@ existing workflow on the old silent-fallback behaviour forever — the opposite the intent. Shipping it as a minor bump on the moving `v1` tag is what makes adopters actually receive it. Do not "correct" this to `feat!:` after the fact. The analysis-location change (#143) is a second such exception: sync's default -moved from committing to the synced branch to `codeboarding/baseline`. +moved from committing to the synced branch to `codeboarding/baseline-`. - `chore:` / `docs:` / `ci:` / `refactor:` / `test:` → ride along in the next release but do not trigger one. diff --git a/README.md b/README.md index 88c29b7..760aa5e 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Sync mode saves Core's persisted incremental-analysis state under `.codeboarding It does **not** render or commit architecture Markdown, and leaves every other file alone, including user-authored CodeBoarding configuration such as `health/health_config.json` and `health/.healthignore`. -Sync keeps the analysis of exactly one branch current: the one branch you list under `on: push: branches:`. It is started by pushes only, so it never runs anywhere else. `codeboarding_analysis_location` decides where that analysis is stored, `codeboarding/baseline` by default. Create `.github/workflows/codeboarding-sync.yml`: +Sync keeps the analysis of exactly one branch current: the one branch you list under `on: push: branches:`. It is started by pushes only, so it never runs anywhere else. `codeboarding_analysis_location` decides where that analysis is stored: by default on an analysis branch named after it, `codeboarding/baseline-main` for `main`. Create `.github/workflows/codeboarding-sync.yml`: ```yaml name: CodeBoarding sync @@ -263,11 +263,11 @@ jobs: llm: hosted ``` -`main` is only read. Each sync adds one commit to `codeboarding/baseline` in the same repository, an orphan branch that shares no history with `main`. It holds the `.codeboarding/` files listed above, plus `.codeboarding/source.json` naming the commit they describe and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. Pushes only ever fast-forward, and no code branch is ever written, so the `push` trigger needs no `paths-ignore` and a protected `main` needs no exception. Reviews read their base from the branch, and the web platform reads the latest diagram from it. The [analysis branch section](docs/COMMIT_STRATEGY.md#the-baseline-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. +`main` is only read. Each sync adds one commit to `codeboarding/baseline-main` in the same repository, an orphan branch that shares no history with `main`. It holds the `.codeboarding/` files listed above, plus `.codeboarding/source.json` naming the commit they describe and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. Pushes only ever fast-forward, and no code branch is ever written, so the `push` trigger needs no `paths-ignore` and a protected `main` needs no exception. Reviews read their base from the branch, and the web platform reads the latest diagram from it. The [analysis branch section](docs/COMMIT_STRATEGY.md#the-baseline-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. -No permission beyond `contents: write` is needed: the first sync creates the branch with an ordinary push, and records which branch it keeps the analysis of. A sync from any other branch then fails before anything is installed, naming both branches. To keep another branch's analysis instead, change the push trigger and delete `codeboarding/baseline`; the next sync creates it again. +The branch push itself needs nothing beyond `contents: write`: the first sync creates the branch with an ordinary push. If you import the ruleset that protects it, import the [GitHub Actions variant](docs/baseline-branch-ruleset-actions.json): the example pushes with the default `github.token`, which the CodeBoarding app ruleset refuses, so the sync would fail at the push after a full analysis. -To sync more than one branch, list them under `push: branches:`. Each run analyzes the branch that was pushed, and every pull request is compared with the analysis of its own merge base. +List one branch under `push: branches:`. Its analysis branch is named after it, so pointing the trigger at another branch starts a new analysis branch, with one full analysis. After renaming `main`, rename `codeboarding/baseline-main` to match to keep its analysis. The first run, or one without a compatible saved analysis to continue from, is a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the synced branch advances while analysis is running, the stale result is not saved for code it did not analyze; the newer push run is allowed to produce the current baseline. @@ -355,14 +355,14 @@ jobs: llm: hosted ``` -Both jobs use the default `codeboarding_analysis_location`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Two-file setups work the same way, except that a sync on the old defaults now saves to `codeboarding/baseline`; see [moving an existing setup](#moving-an-existing-setup). +Both jobs use the default `codeboarding_analysis_location`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Two-file setups work the same way, except that a sync on the old defaults now saves to `codeboarding/baseline-`; see [moving an existing setup](#moving-an-existing-setup). ### Moving an existing setup -Before this release sync committed `.codeboarding/` to the synced branch by default; it now saves to `codeboarding/baseline`. A workflow that set `sync_strategy: push` keeps committing to the code branch, and one that set `sync_strategy: pull_request` moves to `codeboarding/baseline`, each with a warning. A `target_branch` naming a branch other than the one sync runs on fails, since sync now analyzes the branch it runs on. A workflow that set none of them moves to `codeboarding/baseline`; the `.codeboarding/` committed on the code branch is then no longer updated. To finish the move, paste this into your coding agent: +Before this release sync committed `.codeboarding/` to the synced branch by default; it now saves to `codeboarding/baseline-`, `codeboarding/baseline-main` for `main`. A workflow that set `sync_strategy: push` keeps committing to the code branch, and one that set `sync_strategy: pull_request` moves to the analysis branch, each with a warning. A `target_branch` naming a branch other than the one sync runs on fails, since sync now analyzes the branch it runs on. A workflow that set none of them moves to the analysis branch; the `.codeboarding/` committed on the code branch is then no longer updated. To finish the move, paste this into your coding agent: ```text -Move this repository's CodeBoarding sync to the codeboarding/baseline analysis branch. +Move this repository's CodeBoarding sync to its codeboarding/baseline- analysis branch. 1. In the workflows that run CodeBoarding/CodeBoarding-action, remove the sync_strategy, target_branch, force_full and codeboarding_analysis_location inputs if set, and any workflow_dispatch trigger that starts the sync job. @@ -378,7 +378,7 @@ Move this repository's CodeBoarding sync to the codeboarding/baseline analysis b pull request from the codeboarding/sync branch and delete that branch. ``` -The first sync after the merge creates `codeboarding/baseline`, catching up from a saved analysis when there is one. To keep committing to the code branch instead, set `codeboarding_analysis_location: in_place`, as [above](#keep-the-analysis-on-the-code-branch). +The first sync after the merge creates `codeboarding/baseline-`, catching up from a saved analysis when there is one. To keep committing to the code branch instead, set `codeboarding_analysis_location: in_place`, as [above](#keep-the-analysis-on-the-code-branch). ## Inputs @@ -395,10 +395,10 @@ The first sync after the merge creates `codeboarding/baseline`, catching up from | `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. | -| `codeboarding_analysis_location` | both | `codeboarding_branch` | Where the synced branch's analysis is stored: `codeboarding_branch` (on `codeboarding/baseline`; the code is never written) or `in_place` (committed as `.codeboarding/` on the synced branch). Sync saves it there and review reads it from there. | +| `codeboarding_analysis_location` | both | `codeboarding_branch` | Where the synced branch's analysis is stored: `codeboarding_branch` (on `codeboarding/baseline-`; the code is never written) or `in_place` (committed as `.codeboarding/` on the synced branch). Sync saves it there and review reads it from there. | | `warmstart_retention_days` | review | `1` | Days to keep the reusable analysis. Only the next run reads it. | -`target_branch` and `sync_strategy` are deprecated, and read as the `codeboarding_analysis_location` they stand for, with a warning: `sync_strategy: push` as `in_place`, `sync_strategy: pull_request` as `codeboarding_branch`. `target_branch` is accepted only when it names the branch the run is on: the push trigger picks the branch. Anything the new input cannot express fails, linking to [moving an existing setup](#moving-an-existing-setup). +`target_branch` and `sync_strategy` are deprecated, and read as the `codeboarding_analysis_location` they stand for, with a warning: `sync_strategy: push` as `in_place`, `sync_strategy: pull_request` as `codeboarding_branch`. `target_branch` is accepted only when it names the branch the run is on: the push trigger picks the branch. `force_full` is ignored with a warning; for a fresh full analysis, run the CodeBoarding CLI locally. Anything the new input cannot express fails, linking to [moving an existing setup](#moving-an-existing-setup). The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, commit message, and CodeBoarding 0.14.5 version are intentionally fixed rather than exposed as configuration. diff --git a/action.yml b/action.yml index cf4a5b1..82793b6 100644 --- a/action.yml +++ b/action.yml @@ -147,8 +147,9 @@ inputs: Where the analysis of the synced branch (the one branch the sync workflow's push trigger lists) is stored. Sync saves it there and review reads its base from there, so set the same value for both. - codeboarding_branch (default): on codeboarding/baseline, an orphan branch of its - own with one commit per sync; the synced branch is never written. + codeboarding_branch (default): on codeboarding/baseline-, an + orphan branch of its own with one commit per sync; the synced branch is never + written. in_place: committed as .codeboarding/ on the synced branch itself. required: false default: 'codeboarding_branch' @@ -167,6 +168,11 @@ inputs: deprecationMessage: 'sync_strategy is deprecated; use codeboarding_analysis_location.' required: false default: '' + force_full: + description: 'Deprecated and ignored: sync always continues from the nearest saved analysis.' + deprecationMessage: 'force_full is deprecated and ignored.' + required: false + default: '' outputs: llm_tier: description: 'Resolved credential tier: hosted, license, byok, or byok+license.' @@ -217,6 +223,7 @@ runs: ANALYSIS_LOCATION: ${{ inputs.codeboarding_analysis_location }} OLD_TARGET_BRANCH: ${{ inputs.target_branch }} OLD_SYNC_STRATEGY: ${{ inputs.sync_strategy }} + OLD_FORCE_FULL: ${{ inputs.force_full }} run: "$GITHUB_ACTION_PATH/scripts/action/old_api_migrator.sh" - name: Resolve event @@ -548,7 +555,7 @@ runs: FILES: ${{ steps.sync_commit.outputs.files_written }} SAVED_TO: ${{ steps.guard.outputs.analysis_branch || steps.guard.outputs.synced_branch }} # A failed sync has no pull request to comment on; the summary is where it says why. - FAILURE_REASON: ${{ steps.guard.outputs.failure_reason || steps.analysis_check.outputs.failure_reason || steps.sync_analyze.outputs.failure_reason || steps.sync_commit.outputs.failure_reason }} + FAILURE_REASON: ${{ steps.guard.outputs.failure_reason || steps.analysis_check.outputs.failure_reason || steps.state.outputs.failure_reason || steps.sync_analyze.outputs.failure_reason || steps.sync_commit.outputs.failure_reason }} run: "$GITHUB_ACTION_PATH/scripts/action/sync-summary.sh" - name: Generate review baseline diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index e2d4056..20ecb00 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -7,7 +7,7 @@ graphs it compares. | Store | Holds | Lifetime | Who can read it | |---|---|---|---| -| **Git, analysis branch** (`codeboarding_analysis_location: codeboarding_branch`, the default) | one commit per sync on `codeboarding/baseline` | forever | anyone with repo read | +| **Git, analysis branch** (`codeboarding_analysis_location: codeboarding_branch`, the default) | one commit per sync on `codeboarding/baseline-` | forever | anyone with repo read | | **Git, code branch** (`codeboarding_analysis_location: in_place`) | `.codeboarding/` on the synced branch | forever | anyone with repo read | | **Workflow artifacts** | every analysis this action reuses or publishes | a retention window | any run with `actions: read`, plus humans | | ~~Actions cache~~ | — | — | not used | @@ -156,7 +156,11 @@ base and a diagram drawn against another would report changes nobody made. ## The baseline branch By default sync saves the analysis to a branch of its own in the same -repository, `codeboarding/baseline`. The synced branch is then only read. +repository, named after the synced branch: `codeboarding/baseline-main` for +`main`. The synced branch is then only read, and a review reads the analysis +branch of its pull request's base branch. Each code branch has its own, so a +sync never writes over another branch's analysis; a renamed code branch starts a +new one with a full analysis, unless its analysis branch is renamed to match. `codeboarding_analysis_location: in_place` commits the analysis to the synced branch instead, and skips everything below. @@ -184,7 +188,7 @@ wholesale; only the checkout's own `.codeboardingignore` and health configuratio are kept. The push is a fast-forward onto the tip it fetched, never forced. Sync refuses to write to an existing branch that is not a baseline branch (its tip has no `CodeBoarding-Source` trailer, or holds anything besides `.codeboarding/`), so -a code branch that happens to be named `codeboarding/baseline` fails instead of being emptied. The branch belongs to the first branch that saved there: before installing anything, a sync from another branch fails, naming the branch it keeps the analysis of. If the +a code branch that happens to be named `codeboarding/baseline-main` fails, before anything is installed, instead of being emptied. If the synced branch moved during the analysis, the result is dropped, as when committing to it. 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 @@ -212,11 +216,13 @@ a pickle runs code when loaded, so whoever can write the branch can run code in the sync and review workflows. Import [`baseline-branch-ruleset.json`](baseline-branch-ruleset.json) under Settings, Rules, Rulesets, New ruleset, Import a ruleset. It blocks creating, updating, -deleting and force-pushing `codeboarding/baseline` for everyone except the +deleting and force-pushing every `codeboarding/baseline-*` branch for everyone except the CodeBoarding Review app (`4021464`), so only the workflows you give the app's key -can write it; sync must then push with the app's token as `github_token`. +can write it; sync must then push with the app's token as `github_token`. That +token is only available to CodeBoarding's own workflows: the app's private key is +never shipped to other repositories. -If sync can only use the default `github.token`, import +If sync uses the default `github.token`, as the README's examples do, import [`baseline-branch-ruleset-actions.json`](baseline-branch-ruleset-actions.json) instead, whose bypass actor is GitHub Actions (integration `15368`). **It only stops people pushing by hand:** any workflow with `contents: write`, including one run diff --git a/docs/baseline-branch-ruleset-actions.json b/docs/baseline-branch-ruleset-actions.json index 19eab2d..2d24886 100644 --- a/docs/baseline-branch-ruleset-actions.json +++ b/docs/baseline-branch-ruleset-actions.json @@ -5,7 +5,8 @@ "conditions": { "ref_name": { "include": [ - "refs/heads/codeboarding/baseline" + "refs/heads/codeboarding/baseline-*", + "refs/heads/codeboarding/baseline-*/**" ], "exclude": [] } diff --git a/docs/baseline-branch-ruleset.json b/docs/baseline-branch-ruleset.json index a3e46b2..71ba38c 100644 --- a/docs/baseline-branch-ruleset.json +++ b/docs/baseline-branch-ruleset.json @@ -5,7 +5,8 @@ "conditions": { "ref_name": { "include": [ - "refs/heads/codeboarding/baseline" + "refs/heads/codeboarding/baseline-*", + "refs/heads/codeboarding/baseline-*/**" ], "exclude": [] } diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index 37088c9..cb9b55c 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -7,6 +7,9 @@ set -euo pipefail source "$(dirname "$0")/analysis-state.sh" role="$1" checkout="$2" state="$3" +# Stdout is the result, so the error goes to stderr; the reason goes to the step's +# outputs for the review comment and the sync summary. +fail() { echo "::error::$1" >&2; echo "failure_reason=$1" >> "${GITHUB_OUTPUT:-/dev/null}"; exit 1; } # Tags Core's own telemetry with this run and the analysis it belongs to, so its # events can be read per run: a review with no base events reused its base, one @@ -28,16 +31,14 @@ mkdir -p "$state" if compatible_state "$state"; then run_core incremental if [ "$ANALYSIS_MODE" != incremental ] || { [ "$REQUIRES_FULL" != true ] && [ ! -f "$ANALYSIS_PATH" ]; }; then - echo "::error::Invalid incremental-analysis result." >&2 - exit 1 + fail "Core returned an invalid incremental-analysis result." fi [ "$REQUIRES_FULL" != true ] || echo "::notice::Core cannot update the $role analysis incrementally; analyzing in full." >&2 fi if [ "$ANALYSIS_MODE" != incremental ] || [ "$REQUIRES_FULL" = true ]; then run_core full --depth-cap "$DEPTH_CAP" if [ "$ANALYSIS_MODE" != full ] || [ ! -f "$ANALYSIS_PATH" ]; then - echo "::error::Invalid full-analysis result." >&2 - exit 1 + fail "Core returned an invalid full-analysis result." fi fi printf 'analysis_mode=%s\nanalysis_path=%s\n' "$ANALYSIS_MODE" "$ANALYSIS_PATH" diff --git a/scripts/action/check-analysis-branch.sh b/scripts/action/check-analysis-branch.sh index 0e39c49..627a5a7 100755 --- a/scripts/action/check-analysis-branch.sh +++ b/scripts/action/check-analysis-branch.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash -# Sync keeps one branch's analysis on the analysis branch: the branch that first -# saved there. Checked before the engine is installed, so a sync from a second -# branch, or an analysis branch name already used by code, fails in seconds -# instead of after a full analysis. +# An analysis branch name already used by code fails here, before the engine is +# installed, instead of after a full analysis. set -euo pipefail source "$(dirname "$0")/codeboarding-baseline.sh" [ -n "${BASELINE_BRANCH:-}" ] || exit 0 @@ -20,12 +18,7 @@ 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=1 \ "${GITHUB_SERVER_URL%/}/${REPOSITORY}.git" "refs/heads/$BASELINE_BRANCH" || baseline_fail "Could not fetch $BASELINE_BRANCH from $REPOSITORY." -trailer() { git -C "$scratch" log -1 --format="%(trailers:key=$1,valueonly)" FETCH_HEAD | tr -d '[:space:]'; } - -if [ -z "$(trailer CodeBoarding-Source)" ] || [ "$(git -C "$scratch" ls-tree --name-only FETCH_HEAD)" != .codeboarding ]; then +source_trailer="$(git -C "$scratch" log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' FETCH_HEAD | tr -d '[:space:]')" +if [ -z "$source_trailer" ] || [ "$(git -C "$scratch" ls-tree --name-only FETCH_HEAD)" != .codeboarding ]; then baseline_fail "$BASELINE_BRANCH already exists and is not a CodeBoarding analysis branch, so sync will not write to it. Rename that branch, or set codeboarding_analysis_location: in_place." fi -owner="$(trailer CodeBoarding-Branch)" -if [ -n "$owner" ] && [ "$owner" != "$SYNCED_BRANCH" ]; then - baseline_fail "$BASELINE_BRANCH keeps the analysis of $owner, and this sync runs on $SYNCED_BRANCH. Sync keeps one branch current: list only $owner under on: push: branches:, or delete $BASELINE_BRANCH to keep $SYNCED_BRANCH's analysis there instead." -fi diff --git a/scripts/action/codeboarding-baseline.sh b/scripts/action/codeboarding-baseline.sh index 73e3fe9..784b840 100644 --- a/scripts/action/codeboarding-baseline.sh +++ b/scripts/action/codeboarding-baseline.sh @@ -1,5 +1,5 @@ # The analysis branch (codeboarding_analysis_location: codeboarding_branch, BASELINE_BRANCH -# here): an orphan branch holding one commit per sync, each with trailers naming the +# here, codeboarding/baseline-): an orphan branch holding one commit per sync, each with trailers naming the # commit it analysed (CodeBoarding-Source), the branch that commit is on # (CodeBoarding-Branch) and the configuration it ran under (CodeBoarding-Config). # Sync writes it; sync and review read it. Sourced, not run; needs analysis-state.sh. diff --git a/scripts/action/install-sync.sh b/scripts/action/install-sync.sh index 7c25a03..90cca50 100755 --- a/scripts/action/install-sync.sh +++ b/scripts/action/install-sync.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # Copies Core's persisted artifacts into .codeboarding. set -euo pipefail +# Stdout is captured by the caller, so the error goes to stderr; the reason goes +# to the step's outputs for the job summary. +fail() { echo "::error::$1" >&2; echo "failure_reason=$1" >> "${GITHUB_OUTPUT:-/dev/null}"; exit 1; } output="$CHECKOUT_DIR/.codeboarding" mkdir -p "$output" # Core exposes one canonical manifest. Read from the action's directory, never the @@ -10,8 +13,8 @@ from constants import ANALYSIS_FILENAME, PERSISTED_ANALYSIS_ARTIFACT_FILENAMES a print(ANALYSIS_FILENAME) print(*artifacts, sep="\n") PY -)" || { echo "::error::Could not read Core's persisted artifact manifest."; exit 1; } -[ -n "$manifest" ] || { echo "::error::Core's persisted artifact manifest is empty."; exit 1; } +)" || fail "Could not read Core's persisted artifact manifest." +[ -n "$manifest" ] || fail "Core's persisted artifact manifest is empty." # Read into an array without mapfile, which needs bash 4: macOS ships bash 3.2, # so mapfile made this script, and its tests, unrunnable for local development. manifest_entries=() @@ -20,7 +23,7 @@ while IFS= read -r entry; do done <<< "$manifest" required="${manifest_entries[0]}" artifacts=("${manifest_entries[@]:1}") -[ -f "$ANALYSIS_DIR/$required" ] || { echo "::error::Core did not produce $required."; exit 1; } +[ -f "$ANALYSIS_DIR/$required" ] || fail "Core did not produce $required." installed=0 for name in "${artifacts[@]}"; do diff --git a/scripts/action/old_api_migrator.sh b/scripts/action/old_api_migrator.sh index 2388ca7..a72a2cc 100755 --- a/scripts/action/old_api_migrator.sh +++ b/scripts/action/old_api_migrator.sh @@ -7,6 +7,7 @@ # sync_strategy: pull_request -> codeboarding_analysis_location: codeboarding_branch # target_branch: -> nothing # target_branch: -> fails: the push trigger picks the branch +# force_full: -> nothing, with a warning # # Delete a mapping, and its input in action.yml, once no supported workflow sets it. set -euo pipefail @@ -42,4 +43,8 @@ if [ -n "${OLD_TARGET_BRANCH:-}" ]; then warn "target_branch is deprecated and has no effect; remove it." fi +# Forcing a fresh analysis is a manual job now: run the CodeBoarding CLI and commit its result. +[ -z "${OLD_FORCE_FULL:-}" ] || + warn "force_full is no longer supported and has no effect; remove it. For a fresh full analysis, run the CodeBoarding CLI locally." + echo "analysis_location=$location" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/resolve-cache-keys.sh b/scripts/action/resolve-cache-keys.sh index 1dff351..b658ba6 100755 --- a/scripts/action/resolve-cache-keys.sh +++ b/scripts/action/resolve-cache-keys.sh @@ -20,7 +20,7 @@ engine_version="${ENGINE_VERSION:-}" if [ -z "$engine_version" ]; then # The engine was just installed and started, so an unreadable version is a broken install. engine_version="$(python3 -c 'from importlib.metadata import version; print(version("codeboarding"))')" || - { echo "::error::Could not read the installed CodeBoarding version."; exit 1; } + { reason="Could not read the installed CodeBoarding version."; echo "::error::$reason"; echo "failure_reason=$reason" >> "$GITHUB_OUTPUT"; exit 1; } fi # The name pins everything that decides what an analysis says, so a bundle is diff --git a/scripts/action/resolve-github-event.sh b/scripts/action/resolve-github-event.sh index 3c2aa99..0f9f085 100755 --- a/scripts/action/resolve-github-event.sh +++ b/scripts/action/resolve-github-event.sh @@ -9,10 +9,13 @@ case "$MODE" in *) fail "mode must be review or sync." ;; esac printf 'mode=%s\nskip=false\nevent=%s\n' "$MODE" "$EVENT" >> "$GITHUB_OUTPUT" -# The branch the analysis is kept on, or empty when it is committed in place, on -# the code branch itself. -analysis_branch="" -[ "${ANALYSIS_LOCATION:-}" != codeboarding_branch ] || analysis_branch=codeboarding/baseline +# The branch the analysis of code branch $1 is kept on, or nothing when it is +# committed in place, on the code branch itself. Named after the code branch, so +# each code branch has its own and a sync can never write into another's. +analysis_branch_of() { + [ "${ANALYSIS_LOCATION:-}" = codeboarding_branch ] && [ -n "$1" ] || return 0 + echo "codeboarding/baseline-$1" +} if [ "$MODE" = sync ]; then case "$EVENT" in push|workflow_dispatch|schedule) ;; @@ -28,7 +31,7 @@ if [ "$MODE" = sync ]; then [ -n "$synced_branch" ] || fail "Sync needs a branch to analyze; run it on one." { echo "synced_branch=$synced_branch" - echo "analysis_branch=$analysis_branch" + echo "analysis_branch=$(analysis_branch_of "$synced_branch")" echo "checkout_repo=$REPOSITORY" echo "checkout_ref=$synced_branch" } >> "$GITHUB_OUTPUT" @@ -117,7 +120,7 @@ is_fork=false echo "merge_base_resolved=$merge_base_resolved" echo "behind_by=$behind_by" echo "base_ref=$base_ref" - echo "analysis_branch=$analysis_branch" + echo "analysis_branch=$(analysis_branch_of "$base_ref")" echo "head_sha=$head_sha" echo "base_repo=$base_repo" echo "head_repo=$head_repo" diff --git a/scripts/action/sync-generate-baseline.sh b/scripts/action/sync-generate-baseline.sh index 2bdd691..02b20d2 100755 --- a/scripts/action/sync-generate-baseline.sh +++ b/scripts/action/sync-generate-baseline.sh @@ -14,7 +14,9 @@ head_sha="$(git -C "$CHECKOUT_DIR" rev-parse HEAD)" # An analysis branch holds only analysis: analyzing it, and committing the result # onto it, would leave it unreadable as one. if [ -n "$(git -C "$CHECKOUT_DIR" log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' | tr -d '[:space:]')" ]; then - echo "::error::This run is on a CodeBoarding analysis branch. Run sync on the code branch it analyzes." + reason="This run is on a CodeBoarding analysis branch. Run sync on the code branch it analyzes." + echo "::error::$reason" + echo "failure_reason=$reason" >> "$GITHUB_OUTPUT" exit 1 fi diff --git a/tests/test_action_sync.py b/tests/test_action_sync.py index 85cf8ba..1be8458 100644 --- a/tests/test_action_sync.py +++ b/tests/test_action_sync.py @@ -256,6 +256,35 @@ def test_installs_the_health_report_the_engine_produced(self) -> None: # file that is never printed is silently left out of the commit. self.assertIn(str(installed), result.stdout.splitlines()) + def test_a_missing_analysis_is_logged_and_given_as_the_failure_reason(self) -> None: + # Stdout is the list of installed paths, so an error printed there never reached the log. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "checkout").mkdir() + (root / "analysis").mkdir() + write_core(root / "core") + output = root / "github-output" + output.write_text("", encoding="utf-8") + + result = subprocess.run( + [str(INSTALL_SYNC)], + env={ + "PATH": os.environ["PATH"], + "PYTHONPATH": str(root / "core"), + "ACTION_PATH": str(ROOT), + "ANALYSIS_DIR": str(root / "analysis"), + "CHECKOUT_DIR": str(root / "checkout"), + "GITHUB_OUTPUT": str(output), + }, + capture_output=True, + text=True, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("::error::Core did not produce analysis.json.", result.stderr) + self.assertIn("failure_reason=Core did not produce analysis.json.", output.read_text(encoding="utf-8")) + def test_a_failed_sync_says_why_in_the_job_summary(self) -> None: # A push-triggered sync has no pull request to comment on. with tempfile.TemporaryDirectory() as tmp: @@ -265,13 +294,13 @@ def test_a_failed_sync_says_why_in_the_job_summary(self) -> None: env={ "PATH": os.environ["PATH"], "GITHUB_STEP_SUMMARY": str(summary), - "FAILURE_REASON": "codeboarding/baseline keeps the analysis of main, and this sync runs on dev.", - "SAVED_TO": "codeboarding/baseline", + "FAILURE_REASON": "codeboarding/baseline-main already exists and is not a CodeBoarding analysis branch.", + "SAVED_TO": "codeboarding/baseline-main", }, check=True, ) text = summary.read_text(encoding="utf-8") - self.assertIn("- **Failed:** codeboarding/baseline keeps the analysis of main", text) + self.assertIn("- **Failed:** codeboarding/baseline-main already exists", text) self.assertNotIn("- Analysis:", text, "nothing was analyzed") def test_empty_review_is_successful(self) -> None: diff --git a/tests/test_baseline_branch.py b/tests/test_baseline_branch.py index 533bc0f..d2a4c2a 100644 --- a/tests/test_baseline_branch.py +++ b/tests/test_baseline_branch.py @@ -16,7 +16,7 @@ ROOT = Path(__file__).resolve().parent.parent DELIVER = ROOT / "scripts" / "action" / "deliver-sync.sh" -BRANCH = "codeboarding/baseline" +BRANCH = "codeboarding/baseline-main" def git(cwd: Path, *args: str) -> str: @@ -195,16 +195,6 @@ def test_the_check_before_the_install_lets_the_first_and_the_same_branch_through result = self._check("main") self.assertEqual(result.returncode, 0, result.stdout) - def test_the_check_before_the_install_refuses_a_second_branch(self) -> None: - # A trigger listing [main, dev] would show whichever synced last as the latest diagram. - self._deliver() - - result = self._check("dev") - - self.assertNotEqual(result.returncode, 0) - self.assertIn(f"{BRANCH} keeps the analysis of main, and this sync runs on dev.", result.stdout) - reason = (self.root / "check-output").read_text(encoding="utf-8") - self.assertIn("failure_reason=codeboarding/baseline keeps the analysis of main", reason, "for the job summary") def test_the_check_before_the_install_refuses_a_code_branch_with_the_name(self) -> None: git(self.checkout, "push", "-q", "origin", f"main:refs/heads/{BRANCH}") @@ -213,6 +203,8 @@ def test_the_check_before_the_install_refuses_a_code_branch_with_the_name(self) self.assertNotEqual(result.returncode, 0) self.assertIn(f"{BRANCH} already exists and is not a CodeBoarding analysis branch", result.stdout) + reason = (self.root / "check-output").read_text(encoding="utf-8") + self.assertIn(f"failure_reason={BRANCH} already exists", reason, "for the job summary") def test_an_old_analysis_left_on_the_synced_branch_is_pointed_out(self) -> None: # The synced branch is never written from here, so its old copy can only be pointed out. @@ -493,6 +485,7 @@ def test_sync_refuses_to_run_on_the_analysis_branch(self) -> None: self._analyze(False, ANALYSIS_KIND="sync", CHECKOUT_DIR=str(analysis_checkout)) self.assertIn("This run is on a CodeBoarding analysis branch", self.log) + self.assertIn("This run is on a CodeBoarding analysis branch", self._outputs().get("failure_reason", "")) self.assertEqual(self._modes(), []) def test_sync_without_the_branch_analyzes_from_scratch(self) -> None: @@ -535,6 +528,15 @@ def test_sync_saves_to_the_analysis_branch_by_default(self) -> None: self.assertIn("synced_branch=main\n", values) self.assertIn(f"analysis_branch={BRANCH}\n", values) + def test_each_code_branch_has_an_analysis_branch_of_its_own(self) -> None: + # One shared analysis branch let whichever branch synced first lock out the + # others, and a renamed branch could never sync again. + for synced in ("dev", "release/1.x"): + with self.subTest(synced=synced): + result, values = self._guard(REF_NAME=synced) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn(f"analysis_branch=codeboarding/baseline-{synced}\n", values) + def test_in_place_commits_the_analysis_to_the_code_branch_in_both_modes(self) -> None: with tempfile.TemporaryDirectory() as bin_dir: gh = Path(bin_dir) / "gh" @@ -556,7 +558,9 @@ def test_in_place_commits_the_analysis_to_the_code_branch_in_both_modes(self) -> self.assertEqual(result.returncode, 0, result.stdout) self.assertIn("analysis_branch=\n", values, result.stdout) result, values = self._guard(MODE="review", **review) - self.assertIn(f"analysis_branch={BRANCH}\n", values, "a review reads the analysis branch") + self.assertIn( + "analysis_branch=codeboarding/baseline-develop\n", values, "a review reads its base branch's analysis" + ) if __name__ == "__main__": diff --git a/tests/test_old_api_migrator.py b/tests/test_old_api_migrator.py index ca84467..c1f24de 100644 --- a/tests/test_old_api_migrator.py +++ b/tests/test_old_api_migrator.py @@ -76,6 +76,15 @@ def test_target_branch_naming_the_branch_the_run_is_on_changes_nothing(self) -> self.assertEqual(value, "codeboarding_branch") self.assertIn("target_branch is deprecated", result.stdout) + def test_force_full_is_ignored_with_a_warning(self) -> None: + # It is released, so a workflow that still sets it must keep running. + for old in ("true", "false"): + with self.subTest(old=old): + result, value = migrate(OLD_FORCE_FULL=old) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(value, "codeboarding_branch") + self.assertIn("::warning title=CodeBoarding inputs::force_full is no longer supported", result.stdout) + def test_a_target_branch_elsewhere_points_at_the_push_trigger(self) -> None: # It must not tell anyone to analyze a branch they did not choose. result, _value = migrate(REF_NAME="master", OLD_TARGET_BRANCH="develop") From 53282692b2c79f84bc230c8204cd9671831dac15 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sun, 11 Oct 2026 18:24:31 +0200 Subject: [PATCH 15/19] style: black-format test_baseline_branch.py Co-Authored-By: Claude Opus 5.5 --- tests/test_baseline_branch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_baseline_branch.py b/tests/test_baseline_branch.py index d2a4c2a..88b38da 100644 --- a/tests/test_baseline_branch.py +++ b/tests/test_baseline_branch.py @@ -195,7 +195,6 @@ def test_the_check_before_the_install_lets_the_first_and_the_same_branch_through result = self._check("main") self.assertEqual(result.returncode, 0, result.stdout) - def test_the_check_before_the_install_refuses_a_code_branch_with_the_name(self) -> None: git(self.checkout, "push", "-q", "origin", f"main:refs/heads/{BRANCH}") From f1ebc0c3a1717e46ee38219955645b94912f175f Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sun, 11 Oct 2026 21:58:36 +0200 Subject: [PATCH 16/19] fix: keep one codeboarding/baseline for the default branch, and save analyses the branch moved past Back to a single analysis branch, which the webview reads: in branch mode a sync on any branch but the default fails before anything is installed, and a renamed default branch just continues, since entries are keyed by commit. Also: - branch mode saves an analysis even when the synced branch moved during it, unless the analysis branch already holds a newer commit's - the first sync after .codeboarding/ was deleted continues from the parent's copy - a branch named codeboarding, which blocks creating the analysis branch, fails early - force_full: false is silent; sync_strategy agreeing with the location only warns - README documents branches-ignore for App-token pushes - CI shellchecks scripts/action/*.sh Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- AGENTS.md | 4 +- README.md | 28 ++++--- action.yml | 7 +- docs/COMMIT_STRATEGY.md | 30 ++++---- docs/baseline-branch-ruleset-actions.json | 3 +- docs/baseline-branch-ruleset.json | 3 +- scripts/action/analysis-state.sh | 1 + scripts/action/analyze.sh | 1 + scripts/action/check-analysis-branch.sh | 19 ++++- scripts/action/codeboarding-baseline.sh | 41 ++++++---- scripts/action/deliver-sync.sh | 2 + scripts/action/old_api_migrator.sh | 19 +++-- scripts/action/resolve-github-event.sh | 25 +++--- scripts/action/review-analyze-head.sh | 1 + scripts/action/review-generate-baseline.sh | 1 + scripts/action/seed-sources.sh | 24 ++++++ scripts/action/sync-generate-baseline.sh | 4 +- tests/test_action_sync.py | 6 +- tests/test_baseline_branch.py | 88 ++++++++++++++++++---- tests/test_old_api_migrator.py | 32 ++++++-- 21 files changed, 242 insertions(+), 99 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e910cb9..280e3be 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,4 +74,4 @@ jobs: - name: Run actionlint run: actionlint - name: Run shellcheck - run: shellcheck scripts/run_local.sh + run: shellcheck -x scripts/run_local.sh scripts/action/*.sh diff --git a/AGENTS.md b/AGENTS.md index 5e5f059..7492a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This repo is a GitHub Action with two modes, selected by the `mode` input: comment (runs on `pull_request` / `issue_comment`). - **`mode: sync`**: on push to the one branch its trigger lists, regenerates the analysis and saves the `.codeboarding/` state where - `codeboarding_analysis_location` says (an orphan `codeboarding/baseline-` branch + `codeboarding_analysis_location` says (an orphan `codeboarding/baseline` branch by default, or `in_place` on that branch itself), so review mode always diffs against a current baseline. @@ -85,7 +85,7 @@ existing workflow on the old silent-fallback behaviour forever — the opposite the intent. Shipping it as a minor bump on the moving `v1` tag is what makes adopters actually receive it. Do not "correct" this to `feat!:` after the fact. The analysis-location change (#143) is a second such exception: sync's default -moved from committing to the synced branch to `codeboarding/baseline-`. +moved from committing to the synced branch to `codeboarding/baseline`. - `chore:` / `docs:` / `ci:` / `refactor:` / `test:` → ride along in the next release but do not trigger one. diff --git a/README.md b/README.md index 760aa5e..51a5051 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Sync mode saves Core's persisted incremental-analysis state under `.codeboarding It does **not** render or commit architecture Markdown, and leaves every other file alone, including user-authored CodeBoarding configuration such as `health/health_config.json` and `health/.healthignore`. -Sync keeps the analysis of exactly one branch current: the one branch you list under `on: push: branches:`. It is started by pushes only, so it never runs anywhere else. `codeboarding_analysis_location` decides where that analysis is stored: by default on an analysis branch named after it, `codeboarding/baseline-main` for `main`. Create `.github/workflows/codeboarding-sync.yml`: +Sync keeps the analysis of your repository's default branch current. `codeboarding_analysis_location` decides where that analysis is stored, `codeboarding/baseline` by default. Trigger it on pushes to the default branch: a sync that runs on any other branch, from any event, fails in seconds, before anything is installed. Create `.github/workflows/codeboarding-sync.yml`: ```yaml name: CodeBoarding sync @@ -263,13 +263,21 @@ jobs: llm: hosted ``` -`main` is only read. Each sync adds one commit to `codeboarding/baseline-main` in the same repository, an orphan branch that shares no history with `main`. It holds the `.codeboarding/` files listed above, plus `.codeboarding/source.json` naming the commit they describe and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. Pushes only ever fast-forward, and no code branch is ever written, so the `push` trigger needs no `paths-ignore` and a protected `main` needs no exception. Reviews read their base from the branch, and the web platform reads the latest diagram from it. The [analysis branch section](docs/COMMIT_STRATEGY.md#the-baseline-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. +`main` is only read. Each sync adds one commit to `codeboarding/baseline` in the same repository, an orphan branch that shares no history with `main`. It holds the `.codeboarding/` files listed above, plus `.codeboarding/source.json` naming the commit they describe and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. Pushes only ever fast-forward, and no code branch is ever written, so the `push` trigger needs no `paths-ignore` and a protected `main` needs no exception. Reviews read their base from the branch, and the web platform reads the latest diagram from it. The [analysis branch section](docs/COMMIT_STRATEGY.md#the-baseline-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. The branch push itself needs nothing beyond `contents: write`: the first sync creates the branch with an ordinary push. If you import the ruleset that protects it, import the [GitHub Actions variant](docs/baseline-branch-ruleset-actions.json): the example pushes with the default `github.token`, which the CodeBoarding app ruleset refuses, so the sync would fail at the push after a full analysis. -List one branch under `push: branches:`. Its analysis branch is named after it, so pointing the trigger at another branch starts a new analysis branch, with one full analysis. After renaming `main`, rename `codeboarding/baseline-main` to match to keep its analysis. +If sync pushes with an App token or a personal access token rather than the default `github.token`, its push to `codeboarding/baseline` starts your own `on: push` workflows, which then run on a branch with no code and fail. Exclude it from them: -The first run, or one without a compatible saved analysis to continue from, is a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the synced branch advances while analysis is running, the stale result is not saved for code it did not analyze; the newer push run is allowed to produce the current baseline. +```yaml +on: + push: + branches-ignore: ['codeboarding/**'] +``` + +Entries are keyed by the commit they describe, so renaming the default branch, say `master` to `main`, just continues incrementally. + +The first run, or one without a compatible saved analysis to continue from, is a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the synced branch advances while analysis is running, the result is still saved for the commit it analyzed, unless the analysis branch already holds a newer commit's. ### Keep the analysis on the code branch @@ -355,14 +363,14 @@ jobs: llm: hosted ``` -Both jobs use the default `codeboarding_analysis_location`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Two-file setups work the same way, except that a sync on the old defaults now saves to `codeboarding/baseline-`; see [moving an existing setup](#moving-an-existing-setup). +Both jobs use the default `codeboarding_analysis_location`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Two-file setups work the same way, except that a sync on the old defaults now saves to `codeboarding/baseline`; see [moving an existing setup](#moving-an-existing-setup). ### Moving an existing setup -Before this release sync committed `.codeboarding/` to the synced branch by default; it now saves to `codeboarding/baseline-`, `codeboarding/baseline-main` for `main`. A workflow that set `sync_strategy: push` keeps committing to the code branch, and one that set `sync_strategy: pull_request` moves to the analysis branch, each with a warning. A `target_branch` naming a branch other than the one sync runs on fails, since sync now analyzes the branch it runs on. A workflow that set none of them moves to the analysis branch; the `.codeboarding/` committed on the code branch is then no longer updated. To finish the move, paste this into your coding agent: +Before this release sync committed `.codeboarding/` to the synced branch by default; it now saves to `codeboarding/baseline`, and runs on the default branch only. A workflow that set `sync_strategy: push` keeps committing to the code branch, and one that set `sync_strategy: pull_request` moves to the analysis branch, each with a warning. A `target_branch` naming a branch other than the one sync runs on fails, since sync now analyzes the branch it runs on. A workflow that set none of them moves to the analysis branch; the `.codeboarding/` committed on the code branch is then no longer updated. To finish the move, paste this into your coding agent: ```text -Move this repository's CodeBoarding sync to its codeboarding/baseline- analysis branch. +Move this repository's CodeBoarding sync to the codeboarding/baseline analysis branch. 1. In the workflows that run CodeBoarding/CodeBoarding-action, remove the sync_strategy, target_branch, force_full and codeboarding_analysis_location inputs if set, and any workflow_dispatch trigger that starts the sync job. @@ -378,7 +386,7 @@ Move this repository's CodeBoarding sync to its codeboarding/baseline- a pull request from the codeboarding/sync branch and delete that branch. ``` -The first sync after the merge creates `codeboarding/baseline-`, catching up from a saved analysis when there is one. To keep committing to the code branch instead, set `codeboarding_analysis_location: in_place`, as [above](#keep-the-analysis-on-the-code-branch). +The first sync after the merge creates `codeboarding/baseline`, catching up from a saved analysis when there is one. To keep committing to the code branch instead, set `codeboarding_analysis_location: in_place`, as [above](#keep-the-analysis-on-the-code-branch). ## Inputs @@ -395,10 +403,10 @@ The first sync after the merge creates `codeboarding/baseline-`, catchin | `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. | -| `codeboarding_analysis_location` | both | `codeboarding_branch` | Where the synced branch's analysis is stored: `codeboarding_branch` (on `codeboarding/baseline-`; the code is never written) or `in_place` (committed as `.codeboarding/` on the synced branch). Sync saves it there and review reads it from there. | +| `codeboarding_analysis_location` | both | `codeboarding_branch` | Where the synced branch's analysis is stored: `codeboarding_branch` (on `codeboarding/baseline`, for the default branch; the code is never written) or `in_place` (committed as `.codeboarding/` on the synced branch). Sync saves it there and review reads it from there. | | `warmstart_retention_days` | review | `1` | Days to keep the reusable analysis. Only the next run reads it. | -`target_branch` and `sync_strategy` are deprecated, and read as the `codeboarding_analysis_location` they stand for, with a warning: `sync_strategy: push` as `in_place`, `sync_strategy: pull_request` as `codeboarding_branch`. `target_branch` is accepted only when it names the branch the run is on: the push trigger picks the branch. `force_full` is ignored with a warning; for a fresh full analysis, run the CodeBoarding CLI locally. Anything the new input cannot express fails, linking to [moving an existing setup](#moving-an-existing-setup). +`target_branch` and `sync_strategy` are deprecated, and read as the `codeboarding_analysis_location` they stand for, with a warning: `sync_strategy: push` as `in_place`, `sync_strategy: pull_request` as `codeboarding_branch`. `target_branch` is accepted only when it names the branch the run is on: the push trigger picks the branch. `force_full: true` is ignored with a warning. Anything the new input cannot express fails, linking to [moving an existing setup](#moving-an-existing-setup). The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, commit message, and CodeBoarding 0.14.5 version are intentionally fixed rather than exposed as configuration. diff --git a/action.yml b/action.yml index 82793b6..33ef7d0 100644 --- a/action.yml +++ b/action.yml @@ -147,9 +147,9 @@ inputs: Where the analysis of the synced branch (the one branch the sync workflow's push trigger lists) is stored. Sync saves it there and review reads its base from there, so set the same value for both. - codeboarding_branch (default): on codeboarding/baseline-, an - orphan branch of its own with one commit per sync; the synced branch is never - written. + codeboarding_branch (default): on codeboarding/baseline, an orphan branch of its + own with one commit per sync; the synced branch is never written. Sync then runs + on the repository's default branch only. in_place: committed as .codeboarding/ on the synced branch itself. required: false default: 'codeboarding_branch' @@ -249,6 +249,7 @@ runs: PULL_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} REPOSITORY: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: "$GITHUB_ACTION_PATH/scripts/action/resolve-github-event.sh" - name: Acknowledge review command diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index 20ecb00..49d5712 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -7,7 +7,7 @@ graphs it compares. | Store | Holds | Lifetime | Who can read it | |---|---|---|---| -| **Git, analysis branch** (`codeboarding_analysis_location: codeboarding_branch`, the default) | one commit per sync on `codeboarding/baseline-` | forever | anyone with repo read | +| **Git, analysis branch** (`codeboarding_analysis_location: codeboarding_branch`, the default) | one commit per sync on `codeboarding/baseline` | forever | anyone with repo read | | **Git, code branch** (`codeboarding_analysis_location: in_place`) | `.codeboarding/` on the synced branch | forever | anyone with repo read | | **Workflow artifacts** | every analysis this action reuses or publishes | a retention window | any run with `actions: read`, plus humans | | ~~Actions cache~~ | — | — | not used | @@ -156,11 +156,11 @@ base and a diagram drawn against another would report changes nobody made. ## The baseline branch By default sync saves the analysis to a branch of its own in the same -repository, named after the synced branch: `codeboarding/baseline-main` for -`main`. The synced branch is then only read, and a review reads the analysis -branch of its pull request's base branch. Each code branch has its own, so a -sync never writes over another branch's analysis; a renamed code branch starts a -new one with a full analysis, unless its analysis branch is renamed to match. +repository, `codeboarding/baseline`, which the webview also reads. It holds the +default branch's analysis: a sync on any other branch fails before anything is +installed, so a manual run elsewhere cannot make another branch's analysis the +latest diagram. Entries are keyed by commit, so a renamed default branch just +continues. The synced branch is then only read. `codeboarding_analysis_location: in_place` commits the analysis to the synced branch instead, and skips everything below. @@ -188,16 +188,18 @@ wholesale; only the checkout's own `.codeboardingignore` and health configuratio are kept. The push is a fast-forward onto the tip it fetched, never forced. Sync refuses to write to an existing branch that is not a baseline branch (its tip has no `CodeBoarding-Source` trailer, or holds anything besides `.codeboarding/`), so -a code branch that happens to be named `codeboarding/baseline-main` fails, before anything is installed, instead of being emptied. If the -synced branch moved during the analysis, the result is dropped, as when committing -to it. If another sync moved the baseline branch, it builds on that tip once. A push the +a code branch that happens to be named `codeboarding/baseline` fails, before anything is installed, instead of being emptied, as does a branch named +`codeboarding`, next to which git cannot create it. If the synced branch moved +during the analysis, the result is still saved for the commit it analyzed: +entries are keyed by commit, and a branch that merges faster than sync runs would +otherwise never be saved. Only an analysis of an older commit than the tip's is +dropped, so the tip stays the latest diagram. 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. -The synced branch is checked just before the push, not in the same transaction: -if it moves in that window, the branch can end on an analysis of the older commit. -Its trailer still names that commit, so no reader takes it for newer, and the run -queued for the newer commit replaces it. +The tip is compared just before the push, not in the same transaction: if a sync +of a newer commit lands in that window, the push is no longer a fast-forward and +is retried on top of it once. **How a review reads it.** Right after an exact artifact, a review lists the newest 100 commits of the branch (fetched without file contents, so the listing costs commit messages only) and matches their @@ -216,7 +218,7 @@ a pickle runs code when loaded, so whoever can write the branch can run code in the sync and review workflows. Import [`baseline-branch-ruleset.json`](baseline-branch-ruleset.json) under Settings, Rules, Rulesets, New ruleset, Import a ruleset. It blocks creating, updating, -deleting and force-pushing every `codeboarding/baseline-*` branch for everyone except the +deleting and force-pushing `codeboarding/baseline` for everyone except the CodeBoarding Review app (`4021464`), so only the workflows you give the app's key can write it; sync must then push with the app's token as `github_token`. That token is only available to CodeBoarding's own workflows: the app's private key is diff --git a/docs/baseline-branch-ruleset-actions.json b/docs/baseline-branch-ruleset-actions.json index 2d24886..19eab2d 100644 --- a/docs/baseline-branch-ruleset-actions.json +++ b/docs/baseline-branch-ruleset-actions.json @@ -5,8 +5,7 @@ "conditions": { "ref_name": { "include": [ - "refs/heads/codeboarding/baseline-*", - "refs/heads/codeboarding/baseline-*/**" + "refs/heads/codeboarding/baseline" ], "exclude": [] } diff --git a/docs/baseline-branch-ruleset.json b/docs/baseline-branch-ruleset.json index 71ba38c..a3e46b2 100644 --- a/docs/baseline-branch-ruleset.json +++ b/docs/baseline-branch-ruleset.json @@ -5,8 +5,7 @@ "conditions": { "ref_name": { "include": [ - "refs/heads/codeboarding/baseline-*", - "refs/heads/codeboarding/baseline-*/**" + "refs/heads/codeboarding/baseline" ], "exclude": [] } diff --git a/scripts/action/analysis-state.sh b/scripts/action/analysis-state.sh index 9904c11..0e10b05 100644 --- a/scripts/action/analysis-state.sh +++ b/scripts/action/analysis-state.sh @@ -1,3 +1,4 @@ +# shellcheck shell=bash # Helpers for the directories that hold analysis state. Sourced, not run. DEPTH_CAP="${DEPTH_CAP:-2}" diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index cb9b55c..bfff9fd 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -5,6 +5,7 @@ # Usage: analyze.sh base|head|sync # Prints analysis_mode= and analysis_path= on stdout; everything else goes to stderr. set -euo pipefail +# shellcheck source=scripts/action/analysis-state.sh source "$(dirname "$0")/analysis-state.sh" role="$1" checkout="$2" state="$3" # Stdout is the result, so the error goes to stderr; the reason goes to the step's diff --git a/scripts/action/check-analysis-branch.sh b/scripts/action/check-analysis-branch.sh index 627a5a7..2adf053 100755 --- a/scripts/action/check-analysis-branch.sh +++ b/scripts/action/check-analysis-branch.sh @@ -1,14 +1,25 @@ #!/usr/bin/env bash -# An analysis branch name already used by code fails here, before the engine is -# installed, instead of after a full analysis. +# An analysis branch name git cannot create, or one already used by code, fails +# here, before the engine is installed, instead of after a full analysis. set -euo pipefail +# shellcheck source=scripts/action/codeboarding-baseline.sh source "$(dirname "$0")/codeboarding-baseline.sh" [ -n "${BASELINE_BRANCH:-}" ] || exit 0 status=0 +# shellcheck disable=SC2153 # REPOSITORY comes from the step's env. baseline_branch_exists "$REPOSITORY" || status=$? -# The first sync creates it. -[ "$status" = 0 ] || exit 0 +if [ "$status" = 2 ]; then + # The first sync creates it, which git refuses next to a branch named like its parent directory. + blocking="${BASELINE_BRANCH%/*}" + [ "$blocking" = "$BASELINE_BRANCH" ] || { + status=0 + baseline_branch_exists "$REPOSITORY" "$blocking" || status=$? + [ "$status" = 2 ] || + baseline_fail "$REPOSITORY has a branch named $blocking, so git cannot create $BASELINE_BRANCH next to it. Rename $blocking, or set codeboarding_analysis_location: in_place." + } + exit 0 +fi # Commit messages and the tree listing only: no analysis files are downloaded. scratch="$RUNNER_TEMP/codeboarding-analysis-branch-check.git" diff --git a/scripts/action/codeboarding-baseline.sh b/scripts/action/codeboarding-baseline.sh index 784b840..be19508 100644 --- a/scripts/action/codeboarding-baseline.sh +++ b/scripts/action/codeboarding-baseline.sh @@ -1,5 +1,6 @@ +# shellcheck shell=bash # The analysis branch (codeboarding_analysis_location: codeboarding_branch, BASELINE_BRANCH -# here, codeboarding/baseline-): an orphan branch holding one commit per sync, each with trailers naming the +# here, codeboarding/baseline): an orphan branch holding one commit per sync, each with trailers naming the # commit it analysed (CodeBoarding-Source), the branch that commit is on # (CodeBoarding-Branch) and the configuration it ran under (CodeBoarding-Config). # Sync writes it; sync and review read it. Sourced, not run; needs analysis-state.sh. @@ -15,21 +16,22 @@ baseline_fail() { exit 1 } -# Returns 0 when BASELINE_BRANCH exists in repository $1 and 2 when it does not. -# Any other failure is retried once, so one network blip does not fail the run. +# Returns 0 when branch $2 (BASELINE_BRANCH by default) exists in repository $1 and +# 2 when it does not. Any other failure is retried once, so one network blip does +# not fail the run. baseline_branch_exists() { - local repository="$1" auth status + local repository="$1" branch="${2:-$BASELINE_BRANCH}" auth status auth="$(printf 'x-access-token:%s' "${GIT_TOKEN:-}" | base64 -w0)" for attempt in 1 2; do status=0 git -c "http.extraheader=AUTHORIZATION: basic $auth" ls-remote --exit-code \ - "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$BASELINE_BRANCH" >/dev/null || status=$? + "${GITHUB_SERVER_URL%/}/${repository}.git" "refs/heads/$branch" >/dev/null || status=$? case "$status" in 0 | 2) return "$status" ;; esac [ "$attempt" = 2 ] || sleep "${BASELINE_RETRY_DELAY:-2}" done - baseline_fail "Could not read $BASELINE_BRANCH from $repository. Check that github_token can read the repository." + baseline_fail "Could not read $branch from $repository. Check that github_token can read the repository." } # ---- Reading ---- @@ -94,6 +96,7 @@ from_codeboarding_baseline() { rm -f "$scratch/.codeboarding/source.json" compatible_state "$scratch/.codeboarding" || return 1 replace_state "$scratch/.codeboarding" "$state" "$config_from" + # shellcheck disable=SC2034 # read by the scripts that source this file SEED_SHA="$commit" echo "::notice::Starting from the analysis of ${commit:0:7} saved on $BASELINE_BRANCH." } @@ -105,7 +108,7 @@ from_codeboarding_baseline() { # whose REMOTE, BASE_SHA and emit_result it uses; always exits. save_to_codeboarding_baseline() { 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 + local index="$RUNNER_TEMP/codeboarding-baseline-index" git_dir files new_tree tip tip_source parent commit now git_dir="$(git rev-parse --absolute-git-dir)" rm -rf "$tree" "$index" mkdir -p "$tree" @@ -134,30 +137,36 @@ CodeBoarding-Config: $CFG_HASH") # 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. + # The synced branch moving on during the analysis is no reason to drop it: + # entries are keyed by the commit they describe, and a branch that merges + # faster than sync runs would otherwise never be saved. for _ in 1 2; do - git fetch -q "$REMOTE" "$SYNCED_BRANCH" - if [ "$(git rev-parse FETCH_HEAD)" != "$BASE_SHA" ]; then - emit_result "$files" false "$BASE_SHA" - echo "::notice::$SYNCED_BRANCH advanced during analysis; a newer run should update $branch." - exit 0 - fi tip="" parent=() if [ -n "$(git ls-remote "$REMOTE" "refs/heads/$branch")" ]; then git fetch -q --depth=1 "$REMOTE" "refs/heads/$branch" # The parent is what was fetched, not what ls-remote saw: the branch may move in between. tip="$(git rev-parse FETCH_HEAD)" + tip_source="$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" # Building on any other branch would leave it holding nothing but .codeboarding/. - if [ -z "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" ] || - [ "$(git ls-tree --name-only "$tip")" != .codeboarding ]; then + if [ -z "$tip_source" ] || [ "$(git ls-tree --name-only "$tip")" != .codeboarding ]; then baseline_fail "$branch already exists and is not a CodeBoarding baseline branch, so sync will not write to it. Rename that branch, or set codeboarding_analysis_location: in_place." fi parent=(-p "$tip") - if [ "$(git log -1 --format='%(trailers:key=CodeBoarding-Source,valueonly)' "$tip" | tr -d '[:space:]')" = "$BASE_SHA" ] && + if [ "$tip_source" = "$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 $SYNCED_BRANCH @${BASE_SHA:0:7}." exit 0 fi + # The tip is the latest diagram, so an older analysis never replaces a newer one. + if [ "$tip_source" != "$BASE_SHA" ]; then + git fetch -q --depth="$(( CATCHUP_BOUND + 1 ))" "$REMOTE" "$SYNCED_BRANCH" + if git merge-base --is-ancestor "$BASE_SHA" "$tip_source" 2>/dev/null; then + emit_result "$files" false "$BASE_SHA" + echo "::notice::$branch already holds the analysis of a newer commit, ${tip_source:0:7}; leaving it." + exit 0 + fi + fi fi commit="$(git commit-tree "$new_tree" ${parent[@]+"${parent[@]}"} \ -m "chore(codeboarding): diagram of $SYNCED_BRANCH @${BASE_SHA:0:7}" "${trailers[@]}")" diff --git a/scripts/action/deliver-sync.sh b/scripts/action/deliver-sync.sh index ac462c9..98100a3 100755 --- a/scripts/action/deliver-sync.sh +++ b/scripts/action/deliver-sync.sh @@ -3,6 +3,7 @@ # is none, commits it to the synced branch itself. set -euo pipefail cd "$CHECKOUT_DIR" +# shellcheck disable=SC2153 # REPOSITORY comes from the step's env. REMOTE="${GITHUB_SERVER_URL%/}/${REPOSITORY}.git" ASKPASS="$RUNNER_TEMP/codeboarding-git-askpass.sh" GENERATED_PATHS="$RUNNER_TEMP/codeboarding-sync-paths" @@ -38,6 +39,7 @@ classify_push_failure() { baseline_fail "Could not push the CodeBoarding baseline to $branch." } +# shellcheck source=scripts/action/codeboarding-baseline.sh source "$ACTION_PATH/scripts/action/codeboarding-baseline.sh" if [ -n "${BASELINE_BRANCH:-}" ]; then # The synced branch is never written from here, so its old copy can only be pointed out. diff --git a/scripts/action/old_api_migrator.sh b/scripts/action/old_api_migrator.sh index a72a2cc..b5be9e1 100755 --- a/scripts/action/old_api_migrator.sh +++ b/scripts/action/old_api_migrator.sh @@ -23,14 +23,16 @@ case "$location" in esac if [ -n "${OLD_SYNC_STRATEGY:-}" ]; then - # codeboarding_analysis_location has a default, so only a value other than it is a conflict. - [ "$location" = "$DEFAULT_LOCATION" ] || - fail "Set codeboarding_analysis_location only; sync_strategy is the input it replaces." case "$OLD_SYNC_STRATEGY" in - push) location=in_place ;; - pull_request) location=codeboarding_branch ;; + push) mapped=in_place ;; + pull_request) mapped=codeboarding_branch ;; *) fail "sync_strategy is no longer supported; use codeboarding_analysis_location." ;; esac + # codeboarding_analysis_location has a default, so only a value other than it can + # conflict; one that agrees is a migration half done. + [ "$location" = "$DEFAULT_LOCATION" ] || [ "$location" = "$mapped" ] || + fail "sync_strategy: $OLD_SYNC_STRATEGY and codeboarding_analysis_location: $location disagree. Set codeboarding_analysis_location only; sync_strategy is the input it replaces." + location="$mapped" warn "sync_strategy is deprecated; replace sync_strategy: $OLD_SYNC_STRATEGY with codeboarding_analysis_location: $location, in the review workflow too." [ "$OLD_SYNC_STRATEGY" != pull_request ] || warn "Rolling sync pull requests are gone; close any open codeboarding/sync pull request." @@ -43,8 +45,9 @@ if [ -n "${OLD_TARGET_BRANCH:-}" ]; then warn "target_branch is deprecated and has no effect; remove it." fi -# Forcing a fresh analysis is a manual job now: run the CodeBoarding CLI and commit its result. -[ -z "${OLD_FORCE_FULL:-}" ] || - warn "force_full is no longer supported and has no effect; remove it. For a fresh full analysis, run the CodeBoarding CLI locally." +# Old templates pass `force_full: ${{ inputs.force_full || false }}`, so false is +# what nearly every one of them sends; only true asked for something. +[ "${OLD_FORCE_FULL:-false}" = false ] || + warn "force_full is no longer supported and has no effect; remove it." echo "analysis_location=$location" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/resolve-github-event.sh b/scripts/action/resolve-github-event.sh index 0f9f085..e873304 100755 --- a/scripts/action/resolve-github-event.sh +++ b/scripts/action/resolve-github-event.sh @@ -9,13 +9,10 @@ case "$MODE" in *) fail "mode must be review or sync." ;; esac printf 'mode=%s\nskip=false\nevent=%s\n' "$MODE" "$EVENT" >> "$GITHUB_OUTPUT" -# The branch the analysis of code branch $1 is kept on, or nothing when it is -# committed in place, on the code branch itself. Named after the code branch, so -# each code branch has its own and a sync can never write into another's. -analysis_branch_of() { - [ "${ANALYSIS_LOCATION:-}" = codeboarding_branch ] && [ -n "$1" ] || return 0 - echo "codeboarding/baseline-$1" -} +# The branch the analysis is kept on, or empty when it is committed in place, on +# the code branch itself. The webview reads the latest diagram from this name. +analysis_branch="" +[ "${ANALYSIS_LOCATION:-}" != codeboarding_branch ] || analysis_branch=codeboarding/baseline if [ "$MODE" = sync ]; then case "$EVENT" in push|workflow_dispatch|schedule) ;; @@ -29,9 +26,19 @@ if [ "$MODE" = sync ]; then esac synced_branch="$REF_NAME" [ -n "$synced_branch" ] || fail "Sync needs a branch to analyze; run it on one." + # One analysis branch holds one code branch's analysis: the default branch's. + # Checked here, in seconds, rather than after a full analysis. + if [ -n "$analysis_branch" ]; then + default_branch="${DEFAULT_BRANCH:-}" + [ -n "$default_branch" ] || + default_branch="$(gh api "repos/$REPOSITORY" --jq .default_branch 2>/dev/null || true)" + [ -n "$default_branch" ] || fail "Could not read the default branch of $REPOSITORY." + [ "$synced_branch" = "$default_branch" ] || + fail "Sync keeps the analysis of the default branch, $default_branch, and this run is on $synced_branch. Run sync on pushes to $default_branch only, or set codeboarding_analysis_location: in_place." + fi { echo "synced_branch=$synced_branch" - echo "analysis_branch=$(analysis_branch_of "$synced_branch")" + echo "analysis_branch=$analysis_branch" echo "checkout_repo=$REPOSITORY" echo "checkout_ref=$synced_branch" } >> "$GITHUB_OUTPUT" @@ -120,7 +127,7 @@ is_fork=false echo "merge_base_resolved=$merge_base_resolved" echo "behind_by=$behind_by" echo "base_ref=$base_ref" - echo "analysis_branch=$(analysis_branch_of "$base_ref")" + echo "analysis_branch=$analysis_branch" echo "head_sha=$head_sha" echo "base_repo=$base_repo" echo "head_repo=$head_repo" diff --git a/scripts/action/review-analyze-head.sh b/scripts/action/review-analyze-head.sh index 67a9e0b..511790f 100755 --- a/scripts/action/review-analyze-head.sh +++ b/scripts/action/review-analyze-head.sh @@ -3,6 +3,7 @@ # analysis when that grew from the same base graph, so a push only costs the # commits pushed since; otherwise it starts from the base. set -euo pipefail +# shellcheck source=scripts/action/analysis-state.sh source "$(dirname "$0")/analysis-state.sh" base_analysis="$BASE_ANALYSIS_PATH" state="$RUNNER_TEMP/codeboarding-review/head-state" diff --git a/scripts/action/review-generate-baseline.sh b/scripts/action/review-generate-baseline.sh index 5df1047..bfe664e 100755 --- a/scripts/action/review-generate-baseline.sh +++ b/scripts/action/review-generate-baseline.sh @@ -11,6 +11,7 @@ # Outputs base_analysis_path, and publish_base when this run produced a base # other pull requests forking there should reuse. set -euo pipefail +# shellcheck source=scripts/action/seed-sources.sh source "$(dirname "$0")/seed-sources.sh" work="$RUNNER_TEMP/codeboarding-review" checkout="$work/base" state="$work/base-state" diff --git a/scripts/action/seed-sources.sh b/scripts/action/seed-sources.sh index d6afcb4..6922d10 100644 --- a/scripts/action/seed-sources.sh +++ b/scripts/action/seed-sources.sh @@ -1,3 +1,4 @@ +# shellcheck shell=bash # Where an analysis to continue from can come from. Sourced, not run. # # Each from_* function fills a state directory with a compatible analysis of a @@ -5,7 +6,9 @@ # returns 1. SEED_SHA is the commit the seed describes, when the source knows it; # a seed of the very commit being analyzed needs no engine run at all. HERE="$(dirname "${BASH_SOURCE[0]}")" +# shellcheck source=scripts/action/analysis-state.sh source "$HERE/analysis-state.sh" +# shellcheck source=scripts/action/codeboarding-baseline.sh source "$HERE/codeboarding-baseline.sh" SEED_SHA="" @@ -22,6 +25,26 @@ from_committed_baseline() { echo "::notice::Starting from the analysis committed on this branch." } +# The analysis committed under .codeboarding/ on the parent of $2, when $2 deleted +# it: the commit that moves a repository to the analysis branch does, and would +# otherwise cost the first sync a full analysis. +from_parent_committed_baseline() { + local repository="$1" tip="$2" state="$3" checkout="$4" parent dest="$RUNNER_TEMP/codeboarding-parent-baseline" + [ ! -e "$checkout/.codeboarding/analysis.json" ] && [ -n "$repository" ] || return 1 + fetch_commit "$repository" "$tip" 2 >&2 || return 1 + parent="$(git -C "$checkout" rev-parse -q --verify "$tip^")" || return 1 + git -C "$checkout" cat-file -e "$parent:.codeboarding/analysis.json" 2>/dev/null || return 1 + rm -rf "$dest" + mkdir -p "$dest" + git -C "$checkout" archive "$parent" .codeboarding | tar -x -C "$dest" || return 1 + if ! compatible_state "$dest/.codeboarding"; then + echo "::notice::The analysis committed on ${parent:0:7} was made with another depth; not starting from it." + return 1 + fi + replace_state "$dest/.codeboarding" "$state" "$checkout" + echo "::notice::Starting from the analysis committed on ${parent:0:7}, which ${tip:0:7} removed." +} + # A codeboarding-base-- artifact for the nearest first-parent ancestor # of $2, or of $2 itself when $4 is true. A review already looked its tip up by # exact name; sync has no such lookup. @@ -39,6 +62,7 @@ from_ancestor_artifact() { return 1 fi replace_state "$dest" "$state" "$config_from" + # shellcheck disable=SC2034 # read by the scripts that source this file SEED_SHA="$ancestor" echo "::notice::Starting from the saved analysis of ${ancestor:0:7}." } diff --git a/scripts/action/sync-generate-baseline.sh b/scripts/action/sync-generate-baseline.sh index 02b20d2..88daf72 100755 --- a/scripts/action/sync-generate-baseline.sh +++ b/scripts/action/sync-generate-baseline.sh @@ -3,10 +3,11 @@ # saved analysis. First match wins: # # 1. an entry on the baseline branch for this commit or an ancestor -# 2. the analysis committed on the branch +# 2. the analysis committed on the branch, or on the parent of a commit that removed it # 3. the saved analysis of this commit or the nearest ancestor # 4. nothing: analyzed from scratch set -euo pipefail +# shellcheck source=scripts/action/seed-sources.sh source "$(dirname "$0")/seed-sources.sh" state="$RUNNER_TEMP/codeboarding-sync/analysis" rm -rf "$RUNNER_TEMP/codeboarding-sync" @@ -22,6 +23,7 @@ fi if ! from_codeboarding_baseline "${REPOSITORY:-}" "$head_sha" "$state" "$CHECKOUT_DIR" && ! from_committed_baseline "$CHECKOUT_DIR" "$state" && + ! from_parent_committed_baseline "${REPOSITORY:-}" "$head_sha" "$state" "$CHECKOUT_DIR" && ! from_ancestor_artifact "${REPOSITORY:-}" "$head_sha" "$state" true "$CHECKOUT_DIR"; then keep_user_config "$CHECKOUT_DIR" "$state" fi diff --git a/tests/test_action_sync.py b/tests/test_action_sync.py index 1be8458..0aab1e2 100644 --- a/tests/test_action_sync.py +++ b/tests/test_action_sync.py @@ -294,13 +294,13 @@ def test_a_failed_sync_says_why_in_the_job_summary(self) -> None: env={ "PATH": os.environ["PATH"], "GITHUB_STEP_SUMMARY": str(summary), - "FAILURE_REASON": "codeboarding/baseline-main already exists and is not a CodeBoarding analysis branch.", - "SAVED_TO": "codeboarding/baseline-main", + "FAILURE_REASON": "codeboarding/baseline already exists and is not a CodeBoarding analysis branch.", + "SAVED_TO": "codeboarding/baseline", }, check=True, ) text = summary.read_text(encoding="utf-8") - self.assertIn("- **Failed:** codeboarding/baseline-main already exists", text) + self.assertIn("- **Failed:** codeboarding/baseline already exists", text) self.assertNotIn("- Analysis:", text, "nothing was analyzed") def test_empty_review_is_successful(self) -> None: diff --git a/tests/test_baseline_branch.py b/tests/test_baseline_branch.py index 88b38da..522263a 100644 --- a/tests/test_baseline_branch.py +++ b/tests/test_baseline_branch.py @@ -16,7 +16,8 @@ ROOT = Path(__file__).resolve().parent.parent DELIVER = ROOT / "scripts" / "action" / "deliver-sync.sh" -BRANCH = "codeboarding/baseline-main" +# The webview reads the latest diagram from this branch, so the name is part of the contract. +BRANCH = "codeboarding/baseline" def git(cwd: Path, *args: str) -> str: @@ -195,6 +196,15 @@ def test_the_check_before_the_install_lets_the_first_and_the_same_branch_through result = self._check("main") self.assertEqual(result.returncode, 0, result.stdout) + def test_the_check_before_the_install_refuses_a_branch_that_blocks_creating_it(self) -> None: + # Otherwise the full analysis runs and the push fails, blaming a branch rule. + git(self.checkout, "push", "-q", "origin", "main:refs/heads/codeboarding") + + result = self._check("main") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("has a branch named codeboarding, so git cannot create codeboarding/baseline", result.stdout) + def test_the_check_before_the_install_refuses_a_code_branch_with_the_name(self) -> None: git(self.checkout, "push", "-q", "origin", f"main:refs/heads/{BRANCH}") @@ -242,20 +252,38 @@ def test_an_existing_branch_that_is_not_a_baseline_branch_is_never_written(self) self.assertEqual(git(self.remote, "rev-parse", BRANCH), before) self.assertEqual(values.get("committed", "false"), "false") - def test_a_synced_branch_that_moved_during_analysis_keeps_the_branch_unchanged(self) -> None: - self._deliver() - before = git(self.remote, "rev-parse", BRANCH) + def _push_elsewhere(self) -> None: 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") + + def test_a_synced_branch_that_moved_during_analysis_still_saves_it(self) -> None: + # A branch that merges faster than sync runs would otherwise never be saved. + self._deliver() + analyzed = self._push_code("feature") + self._push_elsewhere() + self._analysis("second") _result, values = self._deliver() + self.assertEqual(values["committed"], "true") + self.assertIn(f"CodeBoarding-Source: {analyzed}", git(self.remote, "log", "-1", "--format=%B", BRANCH)) + + def test_an_older_analysis_never_replaces_a_newer_one(self) -> None: + first = git(self.checkout, "rev-parse", "HEAD") + self._push_code("feature") + self._deliver() + before = git(self.remote, "rev-parse", BRANCH) + git(self.checkout, "checkout", "-q", first) + self._analysis("older") + + result, values = self._deliver() + self.assertEqual(values["committed"], "false") + self.assertIn("already holds the analysis of a newer commit", result.stdout) self.assertEqual(git(self.remote, "rev-parse", BRANCH), before) @@ -487,6 +515,25 @@ def test_sync_refuses_to_run_on_the_analysis_branch(self) -> None: self.assertIn("This run is on a CodeBoarding analysis branch", self._outputs().get("failure_reason", "")) self.assertEqual(self._modes(), []) + def test_sync_continues_from_the_analysis_the_head_commit_removed(self) -> None: + # The commit that moves a repository to the analysis branch deletes .codeboarding/. + work = self.root / "move" + git(self.root, "clone", "-q", f"file://{self.root}/origin.git", str(work)) + (work / ".codeboarding").mkdir() + (work / ".codeboarding" / "analysis.json").write_text(json.dumps({"metadata": {"depth_cap": 2}})) + git(work, "add", "-A") + git(work, "commit", "-q", "-m", "committed analysis") + git(work, "rm", "-rq", ".codeboarding") + git(work, "commit", "-q", "-m", "move to the analysis branch") + git(work, "push", "-q", "origin", "HEAD:main") + shutil.rmtree(self.checkout) + git(self.root, "clone", "-q", "--depth=1", "--branch", "main", f"file://{self.root}/origin.git", str(self.checkout)) + + self._analyze(ANALYSIS_KIND="sync", BASELINE_BRANCH="codeboarding/none") + + self.assertEqual(self._modes(), ["incremental"]) + self.assertRegex(self.log, r"Starting from the analysis committed on \w{7}, which \w{7} removed") + def test_sync_without_the_branch_analyzes_from_scratch(self) -> None: self._analyze( ANALYSIS_KIND="sync", @@ -513,6 +560,7 @@ def _guard(self, **extra: str) -> tuple[subprocess.CompletedProcess, str]: "HEAD_AUTHOR_EMAIL": "dev@example.com", "ANALYSIS_LOCATION": "codeboarding_branch", "REPOSITORY": "owner/repo", + "DEFAULT_BRANCH": "main", **extra, }, capture_output=True, @@ -527,14 +575,24 @@ def test_sync_saves_to_the_analysis_branch_by_default(self) -> None: self.assertIn("synced_branch=main\n", values) self.assertIn(f"analysis_branch={BRANCH}\n", values) - def test_each_code_branch_has_an_analysis_branch_of_its_own(self) -> None: - # One shared analysis branch let whichever branch synced first lock out the - # others, and a renamed branch could never sync again. - for synced in ("dev", "release/1.x"): - with self.subTest(synced=synced): - result, values = self._guard(REF_NAME=synced) - self.assertEqual(result.returncode, 0, result.stdout) - self.assertIn(f"analysis_branch=codeboarding/baseline-{synced}\n", values) + def test_sync_from_any_branch_but_the_default_fails_before_the_install(self) -> None: + # A manual run or a second push branch would otherwise make another branch's + # analysis the latest diagram. + result, values = self._guard(REF_NAME="feature-x") + self.assertNotEqual(result.returncode, 0) + self.assertIn("default branch, main, and this run is on feature-x", result.stdout) + self.assertIn("failure_reason=Sync keeps the analysis of the default branch", values) + + def test_a_renamed_default_branch_keeps_syncing(self) -> None: + # Entries are keyed by commit, so a master -> main rename just continues. + result, values = self._guard(REF_NAME="trunk", DEFAULT_BRANCH="trunk") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn(f"analysis_branch={BRANCH}\n", values) + + def test_in_place_syncs_any_branch(self) -> None: + result, values = self._guard(REF_NAME="dev", ANALYSIS_LOCATION="in_place") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("synced_branch=dev\n", values) def test_in_place_commits_the_analysis_to_the_code_branch_in_both_modes(self) -> None: with tempfile.TemporaryDirectory() as bin_dir: @@ -557,9 +615,7 @@ def test_in_place_commits_the_analysis_to_the_code_branch_in_both_modes(self) -> self.assertEqual(result.returncode, 0, result.stdout) self.assertIn("analysis_branch=\n", values, result.stdout) result, values = self._guard(MODE="review", **review) - self.assertIn( - "analysis_branch=codeboarding/baseline-develop\n", values, "a review reads its base branch's analysis" - ) + self.assertIn(f"analysis_branch={BRANCH}\n", values, "a review reads the analysis branch") if __name__ == "__main__": diff --git a/tests/test_old_api_migrator.py b/tests/test_old_api_migrator.py index c1f24de..c64289c 100644 --- a/tests/test_old_api_migrator.py +++ b/tests/test_old_api_migrator.py @@ -76,14 +76,31 @@ def test_target_branch_naming_the_branch_the_run_is_on_changes_nothing(self) -> self.assertEqual(value, "codeboarding_branch") self.assertIn("target_branch is deprecated", result.stdout) - def test_force_full_is_ignored_with_a_warning(self) -> None: + def test_force_full_true_is_ignored_with_a_warning(self) -> None: # It is released, so a workflow that still sets it must keep running. - for old in ("true", "false"): - with self.subTest(old=old): - result, value = migrate(OLD_FORCE_FULL=old) - self.assertEqual(result.returncode, 0, result.stdout) - self.assertEqual(value, "codeboarding_branch") - self.assertIn("::warning title=CodeBoarding inputs::force_full is no longer supported", result.stdout) + result, value = migrate(OLD_FORCE_FULL="true") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(value, "codeboarding_branch") + self.assertIn("::warning title=CodeBoarding inputs::force_full is no longer supported", result.stdout) + + def test_force_full_false_is_silent(self) -> None: + # Old templates pass `${{ inputs.force_full || false }}` on every push. + result, _value = migrate(OLD_FORCE_FULL="false") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertNotIn("::warning", result.stdout) + + def test_sync_strategy_agreeing_with_the_location_only_warns(self) -> None: + # Halfway through a migration both are set, saying the same thing. + result, value = migrate(OLD_SYNC_STRATEGY="push", ANALYSIS_LOCATION="in_place") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(value, "in_place") + self.assertIn("sync_strategy is deprecated", result.stdout) + + def test_sync_strategy_disagreeing_with_the_location_fails(self) -> None: + result, value = migrate(OLD_SYNC_STRATEGY="pull_request", ANALYSIS_LOCATION="in_place") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(value, "") + self.assertIn("disagree", result.stdout) def test_a_target_branch_elsewhere_points_at_the_push_trigger(self) -> None: # It must not tell anyone to analyze a branch they did not choose. @@ -95,7 +112,6 @@ def test_what_the_new_input_cannot_express_fails_with_the_way_to_migrate(self) - for inputs in ( {"OLD_TARGET_BRANCH": "develop"}, {"OLD_SYNC_STRATEGY": "branch"}, - {"ANALYSIS_LOCATION": "in_place", "OLD_SYNC_STRATEGY": "push"}, {"ANALYSIS_LOCATION": "in_place", "OLD_SYNC_STRATEGY": "pull_request"}, ): with self.subTest(inputs=inputs): From 4a9e3f4e9bcc4af32d5a9b2dcae7933515a8780e Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sun, 11 Oct 2026 22:00:12 +0200 Subject: [PATCH 17/19] style: black-format test_baseline_branch.py Co-Authored-By: Claude Opus 5.5 --- tests/test_baseline_branch.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_baseline_branch.py b/tests/test_baseline_branch.py index 522263a..eebc855 100644 --- a/tests/test_baseline_branch.py +++ b/tests/test_baseline_branch.py @@ -527,7 +527,16 @@ def test_sync_continues_from_the_analysis_the_head_commit_removed(self) -> None: git(work, "commit", "-q", "-m", "move to the analysis branch") git(work, "push", "-q", "origin", "HEAD:main") shutil.rmtree(self.checkout) - git(self.root, "clone", "-q", "--depth=1", "--branch", "main", f"file://{self.root}/origin.git", str(self.checkout)) + git( + self.root, + "clone", + "-q", + "--depth=1", + "--branch", + "main", + f"file://{self.root}/origin.git", + str(self.checkout), + ) self._analyze(ANALYSIS_KIND="sync", BASELINE_BRANCH="codeboarding/none") From ee4a205ee3c0ab0fa16f2de59f0a3fae3730ed8a Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Sun, 11 Oct 2026 22:34:47 +0200 Subject: [PATCH 18/19] docs: set up CodeBoarding as one workflow file The README built the setup as a review file and a sync file, and offered one file at the end. One file with a review and a sync job is now the setup, the review and sync sections describe their jobs, and separate files are an older setup to fold in: the webview flags them and opens that pull request. The migration prompt deletes only the six files sync writes. Co-Authored-By: Claude Opus 5.5 --- README.md | 178 +++++++++++++++++------------------------------------- 1 file changed, 57 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 51a5051..922386e 100644 --- a/README.md +++ b/README.md @@ -9,34 +9,31 @@ The action is a thin wrapper around the [CodeBoarding](https://github.com/CodeBo [CodeBoarding](https://github.com/CodeBoarding/CodeBoarding) · [Website](https://codeboarding.org) · [Examples](https://codeboarding.org/diagrams) · [VS Code extension](https://marketplace.visualstudio.com/items?itemName=Codeboarding.codeboarding) · [Discord](https://discord.gg/T5zHTJYFuy) -## Review pull requests +## Set up -Create `.github/workflows/codeboarding.yml`: +CodeBoarding runs from one workflow, `.github/workflows/codeboarding.yml`, with a `review` job and a `sync` job. Each job asks only for the permissions it needs. Replace `main` with your default branch: ```yaml -name: CodeBoarding review +name: CodeBoarding on: pull_request: types: [opened, reopened, synchronize] issue_comment: types: [created] + push: + branches: [main] -permissions: - contents: read - actions: read # download the analysis an earlier run published - pull-requests: write - issues: write - id-token: write - -# One review at a time per pull request. Two pushes in quick succession would -# otherwise analyze concurrently, and both would start from the same older -# analysis instead of the newer one continuing from its predecessor. They also -# share one sticky comment, so whichever finishes last wins — which can be the -# run for the older commit. Queue rather than cancel, so a /codeboarding command -# waits for a running review instead of killing it. +permissions: {} + +# One review at a time per pull request, and one sync at a time per branch. Two +# pushes in quick succession would otherwise analyze concurrently, both starting +# from the same older analysis instead of the newer one continuing from its +# predecessor, and both writing the one sticky comment, so the run for the older +# commit could win. Queue rather than cancel, so a /codeboarding command waits for +# a running review instead of killing it. concurrency: - group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number || github.ref_name }} cancel-in-progress: false jobs: @@ -49,13 +46,37 @@ jobs: contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) runs-on: ubuntu-latest timeout-minutes: 60 + permissions: + contents: read + actions: read # download the analysis an earlier run published + pull-requests: write + issues: write + id-token: write steps: - uses: CodeBoarding/CodeBoarding-action@v1 with: llm: hosted # or license, or a provider name -- see Authentication + + sync: + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write + actions: read # catch up from an analysis a review saved + id-token: write + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + llm: hosted ``` -Automatic runs review both draft and non-draft pull requests and update one sticky **CodeBoarding review** comment. Opening, reopening, or pushing a commit runs analysis; changing only the draft state does not. A trusted repository owner, member, or collaborator can comment `/codeboarding` to analyze the current PR head again, including on fork PRs; every command creates a new result comment. +Both jobs use the default `codeboarding_analysis_location`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Keep both jobs in this one file: a repository running CodeBoarding from more than one workflow file is an [older setup](#moving-an-existing-setup), which the CodeBoarding dashboard flags and offers to fold into one. + +## Review pull requests + +The `review` job's automatic runs review both draft and non-draft pull requests and update one sticky **CodeBoarding review** comment. Opening, reopening, or pushing a commit runs analysis; changing only the draft state does not. A trusted repository owner, member, or collaborator can comment `/codeboarding` to analyze the current PR head again, including on fork PRs; every command creates a new result comment. `synchronize` re-runs the review on every push to the branch. Each of those runs covers only the commits pushed since the previous one, so a push costs a fraction of a first analysis — and a pushed commit is the only thing that builds the reusable analysis, since GitHub gives comment-triggered runs a read-only cache. Drop `synchronize` from the list if you would rather spend one analysis per pull request than one per push. @@ -234,34 +255,7 @@ Sync mode saves Core's persisted incremental-analysis state under `.codeboarding It does **not** render or commit architecture Markdown, and leaves every other file alone, including user-authored CodeBoarding configuration such as `health/health_config.json` and `health/.healthignore`. -Sync keeps the analysis of your repository's default branch current. `codeboarding_analysis_location` decides where that analysis is stored, `codeboarding/baseline` by default. Trigger it on pushes to the default branch: a sync that runs on any other branch, from any event, fails in seconds, before anything is installed. Create `.github/workflows/codeboarding-sync.yml`: - -```yaml -name: CodeBoarding sync - -on: - push: - branches: [main] - -permissions: - contents: write - actions: read # catch up from an analysis a review saved - id-token: write - -concurrency: - group: codeboarding-sync - cancel-in-progress: false - -jobs: - sync: - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: CodeBoarding/CodeBoarding-action@v1 - with: - mode: sync - llm: hosted -``` +Sync keeps the analysis of your repository's default branch current. `codeboarding_analysis_location` decides where that analysis is stored, `codeboarding/baseline` by default. The `sync` job does this, on pushes to the default branch: a sync that runs on any other branch, from any event, fails in seconds, before anything is installed. `main` is only read. Each sync adds one commit to `codeboarding/baseline` in the same repository, an orphan branch that shares no history with `main`. It holds the `.codeboarding/` files listed above, plus `.codeboarding/source.json` naming the commit they describe and the configuration that made them; the commit message carries both as `CodeBoarding-Source:` and `CodeBoarding-Config:` trailers. Pushes only ever fast-forward, and no code branch is ever written, so the `push` trigger needs no `paths-ignore` and a protected `main` needs no exception. Reviews read their base from the branch, and the web platform reads the latest diagram from it. The [analysis branch section](docs/COMMIT_STRATEGY.md#the-baseline-branch) covers what happens if the branch is deleted, and a ruleset you should import to protect it: sync and review load a pickle from it. @@ -281,7 +275,7 @@ The first run, or one without a compatible saved analysis to continue from, is a ### Keep the analysis on the code branch -Set `codeboarding_analysis_location: in_place` to commit `.codeboarding/` to the synced branch itself instead, next to the code. Set it in the review workflow as well, so reviews read it from there, and add a `paths-ignore` so sync's own commit does not trigger another sync: +Set `codeboarding_analysis_location: in_place` to commit `.codeboarding/` to the synced branch itself instead, next to the code. Set it on the review job as well, so reviews read it from there, and add a `paths-ignore` so sync's own commit does not trigger another sync: ```yaml on: @@ -305,84 +299,26 @@ on: The list names only generated files on purpose: an edit to `.codeboarding/.codeboardingignore` or the health configuration changes what is analyzed, and must still trigger a sync. Sync pushes to `main` directly, so a branch rule protecting `main` needs the identity sync pushes with (the CodeBoarding app, or GitHub Actions for the default token) as a bypass actor, or a `github_token` that may push there. -### Review and sync in one workflow - -The two workflows above can be one file with two jobs, each keeping its own permissions. - -```yaml -name: CodeBoarding - -on: - pull_request: - types: [opened, reopened, synchronize] - issue_comment: - types: [created] - push: - branches: [main] - -permissions: {} - -# Reviews queue per pull request, syncs per branch. -concurrency: - group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number || github.ref_name }} - cancel-in-progress: false - -jobs: - review: - if: > - (github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && - startsWith(github.event.comment.body, '/codeboarding') && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: read - actions: read - pull-requests: write - issues: write - id-token: write - steps: - - uses: CodeBoarding/CodeBoarding-action@v1 - with: - llm: hosted - - sync: - if: github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: write - actions: read - id-token: write - steps: - - uses: CodeBoarding/CodeBoarding-action@v1 - with: - mode: sync - llm: hosted -``` - -Both jobs use the default `codeboarding_analysis_location`, so review always reads the analysis where sync saves it. If you set it, set it in both jobs. Two-file setups work the same way, except that a sync on the old defaults now saves to `codeboarding/baseline`; see [moving an existing setup](#moving-an-existing-setup). - ### Moving an existing setup -Before this release sync committed `.codeboarding/` to the synced branch by default; it now saves to `codeboarding/baseline`, and runs on the default branch only. A workflow that set `sync_strategy: push` keeps committing to the code branch, and one that set `sync_strategy: pull_request` moves to the analysis branch, each with a warning. A `target_branch` naming a branch other than the one sync runs on fails, since sync now analyzes the branch it runs on. A workflow that set none of them moves to the analysis branch; the `.codeboarding/` committed on the code branch is then no longer updated. To finish the move, paste this into your coding agent: +Before this release sync committed `.codeboarding/` to the synced branch by default; it now saves to `codeboarding/baseline`, and runs on the default branch only. A workflow that set `sync_strategy: push` keeps committing to the code branch, and one that set `sync_strategy: pull_request` moves to the analysis branch, each with a warning. A `target_branch` naming a branch other than the one sync runs on fails, since sync now analyzes the branch it runs on. A workflow that set none of them moves to the analysis branch; the `.codeboarding/` committed on the code branch is then no longer updated. Separate review and sync workflow files still run, but belong in [one file](#set-up). The CodeBoarding dashboard flags all of these and opens the pull request that moves them; to make the move yourself, paste this into your coding agent: ```text Move this repository's CodeBoarding sync to the codeboarding/baseline analysis branch. -1. In the workflows that run CodeBoarding/CodeBoarding-action, remove the - sync_strategy, target_branch, force_full and codeboarding_analysis_location - inputs if set, and any workflow_dispatch trigger that starts the sync job. - Keep every other input. -2. Delete the generated files under .codeboarding/ from the default branch, keeping - the user configuration: .codeboarding/.codeboardingignore, - .codeboarding/health/health_config.json and .codeboarding/health/.healthignore. -3. Remove the paths-ignore entries for generated .codeboarding/ files from the - sync workflow's push trigger, and any .gitattributes lines that mark - .codeboarding/ files as linguist-generated, if nothing else is left under - .codeboarding/ for them. -4. Open a pull request with these changes. After it merges, close any open +1. Put the review and sync jobs that run CodeBoarding/CodeBoarding-action in one + file, .github/workflows/codeboarding.yml, as the action README's "Set up" shows, + and delete the workflow files they came from once nothing else is left in them. +2. In those jobs, remove the sync_strategy, target_branch, force_full and + codeboarding_analysis_location inputs if set, and any workflow_dispatch trigger + that starts the sync job. Keep every other input. List only the default branch + under the push trigger. +3. Delete the files sync generated from the default branch: + .codeboarding/analysis.json, fingerprint.json, static_analysis.pkl, + static_analysis.sha, codeboarding_version.json and health/health_report.json. + Leave everything else under .codeboarding/. +4. Remove the paths-ignore entries for those files from the push trigger, and any + .gitattributes lines that mark them as linguist-generated. +5. Open a pull request with these changes. After it merges, close any open pull request from the codeboarding/sync branch and delete that branch. ``` @@ -410,7 +346,7 @@ The first sync after the merge creates `codeboarding/baseline`, catching up from The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, commit message, and CodeBoarding 0.14.5 version are intentionally fixed rather than exposed as configuration. -Review mode needs no sync workflow or committed `.codeboarding` directory. If no +Review mode needs no sync job or committed `.codeboarding` directory. If no usable merge-base analysis exists, it runs full analysis there directly, then seeds an incremental analysis of the PR head and publishes both states. Later runs prefer compatible prior PR state for incremental updates, while the review From 226c68f679c3698fffe3f1c66e565c02a775ad3e Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 12 Oct 2026 01:59:26 +0200 Subject: [PATCH 19/19] style: spell out the merge-base retry for shellcheck SC2015 Co-Authored-By: Claude Opus 5.5 --- scripts/action/resolve-github-event.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/action/resolve-github-event.sh b/scripts/action/resolve-github-event.sh index e873304..4f968ba 100755 --- a/scripts/action/resolve-github-event.sh +++ b/scripts/action/resolve-github-event.sh @@ -96,7 +96,9 @@ for attempt in 1 2; do compare_behind="${compare#*$'\t'}" # A transient failure of one API call should not decide how this pull request # is measured, so try once more before giving up on the merge base. - [ -z "$compare_merge_base" ] && [ "$attempt" = 1 ] || break + if [ -n "$compare_merge_base" ] || [ "$attempt" != 1 ]; then + break + fi sleep 2 done case "$compare_behind" in