From f61339fce687fcd0e01b4aed16ab33a859dc4e3f Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 19:36:13 +0200 Subject: [PATCH 01/22] feat(action): simplify action flow to incremental/full CLI contract --- action.yml | 2208 +++++++-------------------- scripts/analyze_repository.py | 146 ++ scripts/build_component_files.py | 220 --- scripts/build_cta.py | 193 --- scripts/engine_adapter.py | 771 ---------- scripts/render_sync_docs.py | 52 + scripts/run_local.sh | 313 ++-- scripts/submit_feedback.py | 176 --- tests/test_analyze_repository.py | 88 ++ tests/test_build_component_files.py | 321 ---- tests/test_build_cta.py | 195 --- tests/test_engine_adapter.py | 998 ------------ tests/test_render_sync_docs.py | 92 ++ tests/test_submit_feedback.py | 196 --- tests/test_sync_subcommands.py | 719 --------- 15 files changed, 1140 insertions(+), 5548 deletions(-) create mode 100755 scripts/analyze_repository.py delete mode 100644 scripts/build_component_files.py delete mode 100644 scripts/build_cta.py delete mode 100644 scripts/engine_adapter.py create mode 100755 scripts/render_sync_docs.py delete mode 100644 scripts/submit_feedback.py create mode 100644 tests/test_analyze_repository.py delete mode 100644 tests/test_build_component_files.py delete mode 100644 tests/test_build_cta.py delete mode 100644 tests/test_engine_adapter.py create mode 100644 tests/test_render_sync_docs.py delete mode 100644 tests/test_submit_feedback.py delete mode 100644 tests/test_sync_subcommands.py diff --git a/action.yml b/action.yml index 1f413dc..189075b 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: 'CodeBoarding Action' -description: 'Visual system-design review on PRs and a versioned architecture baseline kept current on your branch: a Mermaid diff comment on every pull request, and the architecture synced to your branch on push.' +description: 'CodeBoarding architecture sync and review via the CodeBoarding CLI incremental/full contract.' author: 'CodeBoarding' branding: @@ -7,1754 +7,531 @@ branding: color: 'blue' inputs: - llm_api_key: - description: 'Your LLM provider API key (see llm_provider). Optional: when empty, the action runs on the free hosted tier via a GitHub OIDC token (requires "permissions: id-token: write"). Set it (e.g. secrets.OPENROUTER_API_KEY) for unmetered usage or a non-OpenRouter provider.' - required: false - default: '' - llm_provider: - description: 'Provider for llm_api_key. The key is handed to the engine as that provider''s env var (anthropic -> ANTHROPIC_API_KEY, openai -> OPENAI_API_KEY, ...; aws_bedrock -> AWS_BEARER_TOKEN_BEDROCK, ollama -> OLLAMA_BASE_URL) and the engine auto-selects it. Default openrouter. Ignored on the free hosted tier (always OpenRouter via the proxy).' - required: false - default: 'openrouter' - proxy_url: - description: 'Base URL of the CodeBoarding hosted LLM proxy used for the free tier (no llm_api_key) and for license_key mode. The engine''s OPENROUTER_BASE_URL is pointed here; the proxy verifies the GitHub OIDC token, meters per repo owner, and swaps in the real key. Override only to point at a self-hosted/dev proxy.' + mode: + description: | + review: analyze pull requests and post a single sticky comment + sync: sync baseline artifacts on push/workflow_dispatch/schedule required: false - default: 'https://auduihjmm4b735zci7vyabuikq0hppqn.lambda-url.us-east-1.on.aws' # prod gha_proxy Function URL (licensing-aws LicensingStack-Stateless-prod) + default: 'review' license_key: - description: 'A CodeBoarding license key (e.g. secrets.CODEBOARDING_LICENSE) for unmetered hosted usage via proxy_url. Requires "permissions: id-token: write" — the license rides the OIDC bearer (the proxy still verifies the OIDC identity, then the license skips the quota). Takes precedence over the free OIDC tier; ignored when llm_api_key is set (BYO key talks to the provider directly).' + description: 'Optional CodeBoarding license key appended to the OIDC bearer token.' required: false default: '' github_token: - description: 'GITHUB_TOKEN used to post the PR comment. Defaults to the workflow token.' + description: 'Token for comments, PR metadata, and rolling sync PR writes.' required: false default: ${{ github.token }} push_token: - description: 'Token used for sync-mode delivery. Defaults to the workflow github.token, which can push when the calling workflow grants "permissions: contents: write". Kept separate from github_token so commenting can use a GitHub App token while the push uses the workflow token (whose write access the consumer controls). In sync_strategy: pull_request this token ALSO opens/updates the rolling PR (via gh), so it must additionally carry pull-requests: write; a github.token-opened PR does not trigger other workflows, while an App/PAT-opened PR does.' + description: 'Token used for sync branch push operations.' required: false default: ${{ github.token }} codeboarding_version: - description: 'CodeBoarding PyPI package version used as the analysis engine. Pin for reproducibility; set to a newer released version to opt into newer engine releases.' + description: 'CodeBoarding package version.' required: false default: '0.13.5' depth_level: - description: 'Analysis depth for cold-start or force_full rebuilds. Max depends on tier: 3 on the free hosted tier, 10 with a CodeBoarding license or your own llm_api_key. Once .codeboarding/analysis.json exists, its metadata.depth_level is the source of truth: sync runs incremental at the baseline depth, and review analyzes the PR head at the committed baseline depth so the diff is apples-to-apples (clamped to the tier max). Empty (default): 2 for cold starts.' - required: false - default: '' - agent_model: - description: 'Analysis model (AGENT_MODEL env var). A bare OpenRouter slug. Defaults to google/gemini-3-flash-preview on OpenRouter; for other providers, empty uses the engine''s per-provider default.' - required: false - default: '' - parsing_model: - description: 'Parsing model (PARSING_MODEL env var). A bare OpenRouter slug. Defaults to google/gemini-3.1-flash-lite-preview on OpenRouter; for other providers, empty uses the engine''s per-provider default.' + description: 'Full-analysis depth fallback when no committed baseline depth is available.' required: false default: '' comment_header: - description: 'Review mode: header line used inside the sticky PR comment.' + description: 'Header text for the review comment.' required: false default: 'Architecture review' diagram_direction: - description: 'Review mode: Mermaid layout direction: LR, TD, TB, RL, or BT.' + description: 'Mermaid direction: LR, TD, TB, RL, or BT.' required: false default: 'LR' - changed_only: - description: 'Review mode: render only changed components and their incident edges (also auto-applied when the full graph exceeds GitHub''s Mermaid limit).' - required: false - default: 'false' - render_depth: - description: 'Review mode: component levels to DRAW in the PR Mermaid (independent of depth_level): 1 = top-level flat (default), 2 = +one nesting level as subgraphs, etc. Lets you analyze deep (depth_level=2) but display a clean level-1 diagram.' - required: false - default: '1' - cta_base_url: - description: 'Review mode: base URL of the click proxy (e.g. https://go.codeboarding.org). When set, the editor link deep-links into VS Code/Cursor via the proxy and a "get the extension" link is added (owner/repo/pr tracked). Empty (default) links to the extension listing instead, since GitHub strips vscode:/cursor: schemes from comment links.' - required: false - default: '' - webview_base_url: - description: 'Review mode: hosted webview base URL. The PR comment links to an artifact-backed head-vs-comparison-branch architecture diff; review mode does not commit generated files to PR branches. Set empty to disable the browser link.' - required: false - default: 'https://app.codeboarding.org' - trigger_command: - description: 'Review mode: slash-command that triggers the action from a PR comment (issue_comment event). A comment whose first word is this runs the diagram on-demand.' - required: false - default: '/codeboarding' - feedback_command: - description: 'Review mode: slash-command for submitting explicit feedback to CodeBoarding. The command and following text are sent to CodeBoarding via PostHog (not anonymous telemetry, and no analysis runs). Opt out with CODEBOARDING_TELEMETRY=false or DO_NOT_TRACK=1.' - required: false - default: '/codeboarding-feedback' - feedback_max_chars: - description: 'Review mode: maximum number of feedback characters sent from /codeboarding-feedback (the text is truncated past this).' - required: false - default: '4000' - mode: - description: 'What the action does. "review" (default): post a Mermaid architecture-diff comment on the PR (pull_request / issue_comment events). "sync": analyze on push and commit the architecture (analysis.json + rendered docs) to target_branch, keeping it versioned and current (the baseline review mode diffs against). Events: push / workflow_dispatch / schedule. Run the two modes from separate workflow files with least-privilege permissions each.' - required: false - default: 'review' - output_dir: - description: 'Sync mode: directory where the rendered docs and analysis metadata are committed. The action OWNS this directory: pre-existing top-level markdown files in it are deleted on every run, so do not point it at a directory with hand-written docs.' - required: false - default: '.codeboarding' - output_format: - description: 'Sync mode: rendered docs format. Currently only .md is supported.' - required: false - default: '.md' - target_branch: - description: 'Sync mode: branch the generated docs are pushed to.' - required: false - default: ${{ github.ref_name }} - write_architecture_md: - description: 'Sync mode: also write docs/development/architecture.md (all rendered docs concatenated, overview first).' - required: false - default: 'true' - commit_message: - description: 'Sync mode: commit message for the generated docs. Deliberately carries no "[skip ci]" — the regen loop is prevented by the workflow''s paths-ignore plus the action''s own bot-commit guard, so the marker is unneeded and (via squash-merge) would skip the workflows real merges should run.' - required: false - default: 'chore(codeboarding): sync architecture baseline' - force_full: - description: 'Sync mode: ignore any committed baseline and run a full analysis from scratch. Use to rebuild a stale or corrupt baseline (the manual escape hatch that replaces the old refresh-baseline workflow).' - required: false - default: 'false' sync_strategy: - description: 'Sync mode: how the generated baseline is delivered. "push" (default): commit and fast-forward directly to target_branch (needs contents: write and an unprotected target_branch). "pull_request": commit to a dedicated bot branch (sync_pr_branch) and open/update ONE rolling PR into target_branch, for repositories whose default branch is protected against direct pushes (needs contents: write AND pull-requests: write, and push_token scoped to open PRs). Merge that PR on a cadence to keep the baseline on target_branch current — that is what keeps review-mode diffs fast and incremental sync warm.' + description: 'Sync delivery strategy: push or pull_request.' required: false default: 'push' sync_pr_branch: - description: 'Sync mode (pull_request strategy): the machine-owned head branch the baseline is committed to and the rolling PR is opened from. Force-updated (reset to the current target_branch tip + one baseline commit) on every run, so exactly one PR stays open and its diff is always just the generated files. Do not commit to it by hand — pushes are overwritten. Ignored when sync_strategy is "push".' + description: 'Rolling sync branch used when sync_strategy=pull_request.' required: false default: 'codeboarding/sync' sync_pr_title: - description: 'Sync mode (pull_request strategy): prefix for the rolling baseline PR title. The analyzed commit hash and first 20 characters of its subject are appended and refreshed on every run. Ignored when sync_strategy is "push".' + description: 'Rolling sync PR title prefix.' required: false default: '[CodeBoarding sync]' + commit_message: + description: 'Sync commit message.' + required: false + default: 'chore(codeboarding): sync architecture baseline' + + # deprecated compatibility no-op inputs (kept for workflow compatibility) + changed_only: + description: 'Deprecated. Accepted for compatibility only.' + required: false + default: 'false' + render_depth: + description: 'Deprecated. Accepted for compatibility only.' + required: false + default: '1' outputs: diagram_md: - description: 'Review mode: path to the rendered ```mermaid block (in the runner workspace).' - value: ${{ steps.diagram.outputs.diagram_md }} + description: 'Rendered Mermaid payload (review mode).' + value: ${{ steps.review_render.outputs.diagram_md }} n_changed: - description: 'Review mode: number of components added/modified/deleted, counted recursively.' - value: ${{ steps.diagram.outputs.n_changed }} + description: 'Change count in the review diff.' + value: ${{ steps.review_render.outputs.n_changed }} truncated: - description: 'Review mode: true if the diagram was reduced to changed-only to fit GitHub''s Mermaid limit.' - value: ${{ steps.diagram.outputs.truncated }} + description: 'True when review render switched to changed-only for GitHub limits.' + value: ${{ steps.review_render.outputs.truncated }} analysis_mode: - description: 'Sync mode: "full" or "incremental".' + description: 'sync mode used: incremental or full.' value: ${{ steps.sync_analyze.outputs.analysis_mode }} files_written: - description: 'Sync mode: number of rendered markdown files in output_dir.' + description: 'Number of markdown files written on sync.' value: ${{ steps.sync_commit.outputs.files_written }} committed: - description: 'Sync mode: true when a baseline commit was created and delivered (pushed to target_branch in "push" strategy, or pushed to sync_pr_branch with its PR opened/updated in "pull_request" strategy).' + description: 'Whether sync delivery happened.' value: ${{ steps.sync_commit.outputs.committed }} sync_pr_url: - description: 'Sync mode (pull_request strategy): URL of the opened/updated rolling baseline PR (empty in "push" strategy or when no PR was produced).' + description: 'Rolling sync PR URL when strategy is pull_request.' value: ${{ steps.sync_commit.outputs.sync_pr_url }} sync_pr_number: - description: 'Sync mode (pull_request strategy): number of the opened/updated rolling baseline PR (empty in "push" strategy or when no PR was produced).' + description: 'Rolling sync PR number when strategy is pull_request.' value: ${{ steps.sync_commit.outputs.sync_pr_number }} review_artifact_url: - description: 'Review mode: GitHub Actions artifact URL containing the PR-head analysis.json and metadata.' + description: 'Artifact URL used by the hosted review webview.' value: ${{ steps.upload_review_artifact.outputs.artifact-url }} runs: using: 'composite' - # One spine, two heads. Steps run in four phases; every mode-specific step is - # gated `if: steps.guard.outputs.mode == 'review' | 'sync'`, and the two modes - # interleave only because they share the LLM-key lifecycle, NOT because they - # are unrelated: - # 1. GUARD — resolve mode + event eligibility + SHAs (one source of truth) - # 2. SHARED SETUP — checkout target, Python/Node/uv, install CodeBoarding, caches, LLM-key prep - # 3. ANALYSIS (key live) — review: base/seed/head/health ; sync: seed/analyze - # 4. Drop LLM key, then OUTPUT (no key) — review: diff→artifact→comment ; sync: render→commit - # The binding between the modes is the committed .codeboarding/analysis.json - # baseline: sync (phase 3/4) writes it, review (phase 3) reads it from the - # target branch tip that the PR is opened against. steps: - - name: Guard — resolve mode and target + - name: Guard + resolve mode id: guard shell: bash env: - GH_TOKEN: ${{ inputs.github_token }} - # Read from env, NEVER interpolated into the script — a comment body is - # untrusted input and must not reach the shell as code (injection). + MODE: ${{ inputs.mode }} + EVENT: ${{ github.event_name }} + REPO: ${{ github.repository }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + GITHUB_SHA: ${{ github.sha }} + HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} + SYNC_STRATEGY: ${{ inputs.sync_strategy }} + SYNC_PR_BRANCH: ${{ inputs.sync_pr_branch }} + COMMENT_BODY: ${{ github.event.comment.body }} AUTHOR_ASSOC: ${{ github.event.comment.author_association }} - TRIGGER: ${{ inputs.trigger_command }} - # Feedback path (/codeboarding-feedback): a lightweight branch that sends - # the comment text to PostHog and exits before any checkout/engine setup. - # The script reads CODEBOARDING_POSTHOG_KEY/HOST and the opt-outs - # DO_NOT_TRACK / CODEBOARDING_TELEMETRY straight from the caller's env: a - # composite action inherits the calling workflow/job `env:` as real env - # vars, so those need no re-mapping here. POSTHOG_KEY/HOST below are a - # redundant alias the script only falls back to if the canonical vars are - # unset — kept as a hint that the destination is overridable. - FEEDBACK_COMMAND: ${{ inputs.feedback_command }} - FEEDBACK_MAX_CHARS: ${{ inputs.feedback_max_chars }} - POSTHOG_KEY: ${{ env.CODEBOARDING_POSTHOG_KEY }} - POSTHOG_HOST: ${{ env.CODEBOARDING_POSTHOG_HOST }} - SENDER_LOGIN: ${{ github.event.sender.login }} - SENDER_ID: ${{ github.event.sender.id }} - COMMENT_ID: ${{ github.event.comment.id }} - COMMENT_URL: ${{ github.event.comment.html_url }} - REPOSITORY_ID: ${{ github.event.repository.id }} - RUN_ATTEMPT: ${{ github.run_attempt }} - ACTION_PATH: ${{ github.action_path }} - ACTION_REF: ${{ github.action_ref || github.sha }} - EVENT: ${{ github.event_name }} - REPOSITORY: ${{ github.repository }} + ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} PR_NUMBER_PULL: ${{ github.event.pull_request.number }} - PULL_BASE_SHA: ${{ github.event.pull_request.base.sha }} PULL_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PULL_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PULL_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} PULL_HEAD_REF: ${{ github.event.pull_request.head.ref }} PULL_BASE_REF: ${{ github.event.pull_request.base.ref }} - PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} - PULL_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} - MODE: ${{ inputs.mode }} - REF_TYPE: ${{ github.ref_type }} - TARGET_SHA: ${{ github.sha }} - TARGET_BRANCH: ${{ inputs.target_branch }} - OUTPUT_DIR: ${{ inputs.output_dir }} - OUTPUT_FORMAT: ${{ inputs.output_format }} - WRITE_ARCH: ${{ inputs.write_architecture_md }} - DEPTH: ${{ inputs.depth_level }} - HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} - SYNC_STRATEGY: ${{ inputs.sync_strategy }} - SYNC_PR_BRANCH: ${{ inputs.sync_pr_branch }} run: | - set -uo pipefail - skip() { echo "::notice::$1 Skipping."; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } - - # Misconfigured inputs hard-fail; event mismatches soft-skip (a workflow - # may legitimately deliver events the selected mode doesn't handle). - case "$MODE" in - review|sync) ;; - *) echo "::error::mode must be 'review' or 'sync' (got '$MODE')."; exit 1 ;; - esac - # Structural range only (1-10). The per-tier ceiling (free=3, licensed=10) - # is enforced later in 'Resolve analysis depth', once the credential mode - # is known, with a message pointing at the license/BYO-key upgrade path. - case "$DEPTH" in - ''|1|2|3|4|5|6|7|8|9|10) ;; - *) echo "::error::depth_level must be an integer from 1 to 10 (empty = default cold-start depth 2; the usable max depends on your tier)."; exit 1 ;; - esac + set -euo pipefail + + fail() { echo "::error::$1"; exit 1; } + skip() { echo "::notice::$1"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } + + [ "$MODE" = "review" ] || [ "$MODE" = "sync" ] || fail "Unsupported mode '$MODE'." echo "mode=$MODE" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "event=$EVENT" >> "$GITHUB_OUTPUT" + echo "sync_strategy=$SYNC_STRATEGY" >> "$GITHUB_OUTPUT" + echo "sync_pr_branch=$SYNC_PR_BRANCH" >> "$GITHUB_OUTPUT" if [ "$MODE" = "sync" ]; then - # Sync mode: analyze the pushed/selected commit and push generated docs - # to target_branch. Sync is an explicit opt-in (never inferred from the - # event) and accepts only non-PR triggers — a branch-pushing mode must - # not be reachable from a workflow run over PR-head code. - case "$EVENT" in - push|workflow_dispatch|schedule) ;; - *) skip "Unsupported event '$EVENT' in sync mode (use push, workflow_dispatch, or schedule; mode: review handles pull_request)." ;; - esac + if [ "$EVENT" != "push" ] && [ "$EVENT" != "workflow_dispatch" ] && [ "$EVENT" != "schedule" ]; then + skip "Sync mode supports only push, workflow_dispatch, and schedule events." + fi if [ "$EVENT" = "push" ] && [ "$REF_TYPE" = "tag" ]; then - skip "Sync mode runs on branch pushes, not tag pushes." + skip "Sync mode ignores tag pushes." fi - # Regen-loop guard: never re-analyze our OWN baseline commit. Sync - # commits as codeboarding-review[bot]; if a push's head commit is ours, - # skip. The legacy codeboarding[bot] email is still matched so baselines - # committed before the identity change don't re-trigger on the next push. - # This holds even if a consumer omits the workflow's paths-ignore (the - # primary guard), and replaces the old "[skip ci]" marker — which - # leaked into squash-merge commit messages and wrongly skipped the - # workflows real merges should run (sync itself, release tooling, CI). + + case "$SYNC_STRATEGY" in + push|pull_request) ;; + *) fail "sync_strategy must be push or pull_request." ;; + esac + if [ "$SYNC_STRATEGY" = "pull_request" ] && [ "$SYNC_PR_BRANCH" = "$REF_NAME" ]; then + fail "sync_pr_branch must differ from the target branch." + fi + case "$HEAD_AUTHOR_EMAIL" in + "" ) ;; "codeboarding-review[bot]@users.noreply.github.com"|"codeboarding[bot]@users.noreply.github.com") if [ "$EVENT" = "push" ]; then - skip "Push head commit is CodeBoarding's own baseline commit; not re-analyzing (loop guard)." + skip "Skipping loop-guarded bot-owned head commit." fi ;; esac - # Validate the sync delivery strategy up front (misconfig = hard fail). - case "$SYNC_STRATEGY" in - push|pull_request) ;; - *) echo "::error::sync_strategy must be 'push' or 'pull_request' (got '$SYNC_STRATEGY')."; exit 1 ;; - esac - if [ "$SYNC_STRATEGY" = "pull_request" ]; then - [ -n "$SYNC_PR_BRANCH" ] || { echo "::error::sync_pr_branch is empty."; exit 1; } - # Must differ from the PR base, or the force-push would target the base - # branch itself instead of opening a PR (silently no-PR on a protected - # base, or an overwrite of the base on a writable one). - [ "$SYNC_PR_BRANCH" != "$TARGET_BRANCH" ] || { echo "::error::sync_pr_branch ('$SYNC_PR_BRANCH') must differ from target_branch in pull_request strategy."; exit 1; } - fi - # Note: pull_request strategy has NO author-email loop guard — merging the - # rolling sync PR re-authors the commit to the merger, not the bot. The - # loop is prevented by the workflow's paths-ignore (a merged baseline PR - # touches only generated files, all listed there) plus the "nothing to - # commit" gate in the commit step, which stops any stray re-run from - # producing a new commit. See README "Protected default branch". - case "$OUTPUT_FORMAT" in - .md) ;; - *) echo "::error::output_format must be .md."; exit 1 ;; - esac - case "$OUTPUT_DIR" in - ""|/*|..|../*|*/../*|*/..) echo "::error::output_dir must be a relative path inside the repository."; exit 1 ;; - esac - case "$WRITE_ARCH" in - true|false) ;; - *) echo "::error::write_architecture_md must be true or false."; exit 1 ;; - esac - [ -n "$TARGET_BRANCH" ] || { echo "::error::target_branch is empty."; exit 1; } - { - echo "skip=false" - echo "target_sha=$TARGET_SHA" - echo "target_branch=$TARGET_BRANCH" - echo "start_ts=$(date +%s)" - echo "checkout_repo=$REPOSITORY" - echo "checkout_sha=$TARGET_SHA" - } >> "$GITHUB_OUTPUT" - echo "Sync mode: analyzing ${REPOSITORY}@${TARGET_SHA}, pushing docs to ${TARGET_BRANCH} (via $EVENT)" + + echo "target_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "target_branch=$REF_NAME" >> "$GITHUB_OUTPUT" + echo "checkout_ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "commit_message=${{ inputs.commit_message }}" >> "$GITHUB_OUTPUT" + echo "start_ts=$(date +%s)" >> "$GITHUB_OUTPUT" exit 0 fi - # Review mode from here down. - # Sticky-comment targeting. Written up front so the failure-comment step - # always has a header even if PR resolution below fails. Automatic - # pull_request runs reuse one stable header → the comment is updated in - # place. An on-demand /codeboarding run uses a run-unique header so it - # posts a NEW comment and never touches comments from earlier runs; its - # own in-progress→result→failure steps still share it (one comment per - # invocation). Re-running the same run keeps the same run id, so a re-run - # updates that invocation's comment rather than spawning another. - if [ "$EVENT" = "issue_comment" ]; then - echo "sticky_header=codeboarding-architecture-diff-run-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" - else - echo "sticky_header=codeboarding-architecture-diff" >> "$GITHUB_OUTPUT" + if [ "$EVENT" != "pull_request" ] && [ "$EVENT" != "issue_comment" ]; then + skip "Review mode supports pull_request and issue_comment only." + fi + + if [ "$EVENT" = "issue_comment" ] && [ "$REF_TYPE" != "" ] && [ "$REF_TYPE" != "branch" ]; then + skip "Issue comment on non-PR context is not supported." fi + PR_NUMBER="" + BASE_SHA="" + HEAD_SHA="" + BASE_REPO="" + HEAD_REPO="" + BASE_REF="" + HEAD_REF="" + BASELINE_EXISTS="false" + if [ "$EVENT" = "pull_request" ]; then PR_NUMBER="$PR_NUMBER_PULL" BASE_SHA="$PULL_BASE_SHA" HEAD_SHA="$PULL_HEAD_SHA" - HEAD_REF="$PULL_HEAD_REF" - BASE_REF="$PULL_BASE_REF" BASE_REPO="$PULL_BASE_REPO" HEAD_REPO="$PULL_HEAD_REPO" - EMPTY_BASE="false" - FORK_COMPARE="false" - if [ "$HEAD_REPO" != "$BASE_REPO" ]; then - FORK_COMPARE="true" - # Fork PRs should compare against the fork's branch with the same - # name as the PR target branch, not upstream/main. If the fork has - # no such branch/baseline, the review starts from an empty baseline - # so the PR architecture renders as newly introduced. - FORK_BASE_SHA="$(git ls-remote "https://github.com/${HEAD_REPO}.git" "refs/heads/${BASE_REF}" | awk '{print $1; exit}')" - if [ -n "$FORK_BASE_SHA" ]; then - BASE_SHA="$FORK_BASE_SHA" - BASE_REPO="$HEAD_REPO" - else - BASE_SHA="" - BASE_REPO="$HEAD_REPO" - EMPTY_BASE="true" - fi - fi - elif [ "$EVENT" = "pull_request_target" ]; then - skip "pull_request_target is not supported because it can expose secrets to PR-head code; use pull_request or trusted issue_comment." - elif [ "$EVENT" = "issue_comment" ]; then - # On-demand "/codeboarding" command. Must be a PR comment whose first - # word is the trigger; the payload lacks SHAs so we query the API. - [ -n "$ISSUE_PR_URL" ] || skip "Comment is on a plain issue, not a PR." - FIRST_WORD="$(printf '%s' "$COMMENT_BODY" | tr -d '\r' | awk 'NR==1{print $1; exit}')" - # Feedback command: forward the comment text to PostHog, then stop BEFORE - # the trusted-association check, checkout, engine setup, and LLM key. - # Sending user-written feedback runs no PR code, so it doesn't need the - # collaborator gate the analysis path does — the workflow's own `if:` - # decides who can reach this step. The script swallows its own failures - # and the guard has no `set -e`, so feedback can never block the action. - if [ "$FIRST_WORD" = "$FEEDBACK_COMMAND" ]; then - python3 "$ACTION_PATH/scripts/submit_feedback.py" - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "feedback_received=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - [ "$FIRST_WORD" = "$TRIGGER" ] || skip "Comment does not start with '$TRIGGER'." - # SECURITY (pwn-request guard): issue_comment runs in the base repo WITH - # secrets for ANY commenter. Only a trusted collaborator may trigger an - # analysis that checks out + runs over PR-head code with the LLM key present. - case "$AUTHOR_ASSOC" in - OWNER|MEMBER|COLLABORATOR) : ;; - *) skip "Commenter is '$AUTHOR_ASSOC' (not OWNER/MEMBER/COLLABORATOR)." ;; - esac - PR_NUMBER="$ISSUE_NUMBER" - PR_JSON="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}" 2>/dev/null)" || skip "Could not fetch PR #$PR_NUMBER from the API." - BASE_SHA="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["sha"])' 2>/dev/null)" || skip "Could not parse base SHA from the PR API." - HEAD_SHA="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["head"]["sha"])' 2>/dev/null)" || skip "Could not parse head SHA from the PR API." - HEAD_REF="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["head"]["ref"])' 2>/dev/null)" || HEAD_REF="" - BASE_REF="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["ref"])' 2>/dev/null)" || BASE_REF="" - BASE_REPO="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["repo"]["full_name"])' 2>/dev/null)" || skip "Could not parse base repo from the PR API." - HEAD_REPO="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["head"]["repo"]["full_name"])' 2>/dev/null)" || skip "Could not parse head repo from the PR API." - [ "$HEAD_REPO" = "$REPOSITORY" ] || skip "On-demand runs with secrets are disabled for fork PRs." - EMPTY_BASE="false" - FORK_COMPARE="false" + BASE_REF="$PULL_BASE_REF" + HEAD_REF="$PULL_HEAD_REF" + [ -n "$PR_NUMBER" ] && [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ] || skip "Could not parse pull_request payload." else - skip "Unsupported event '$EVENT' in review mode (use pull_request or issue_comment; set mode: sync to version your architecture on push)." + [ -n "$COMMENT_BODY" ] || skip "Issue comment body is empty." + FIRST_WORD="$(printf '%s' "$COMMENT_BODY" | tr -d '\r' | awk 'NR==1 {print $1}' || true)" + [ "$FIRST_WORD" = "/codeboarding" ] || skip "Issue comment is not /codeboarding." + case "${AUTHOR_ASSOC:-}" in + OWNER|MEMBER|COLLABORATOR|MAINTAINER) ;; + *) skip "Untrusted actor association for /codeboarding trigger (${AUTHOR_ASSOC:-})." ;; + esac + [ -n "$ISSUE_PR_URL" ] || skip "Not a pull request comment." + + PR_JSON="$(gh api "$ISSUE_PR_URL")" + PR_NUMBER="$(printf '%s' "$PR_JSON" | jq -r '.number // empty')" + BASE_SHA="$(printf '%s' "$PR_JSON" | jq -r '.base.sha // empty')" + HEAD_SHA="$(printf '%s' "$PR_JSON" | jq -r '.head.sha // empty')" + BASE_REPO="$(printf '%s' "$PR_JSON" | jq -r '.base.repo.full_name // empty')" + HEAD_REPO="$(printf '%s' "$PR_JSON" | jq -r '.head.repo.full_name // empty')" + BASE_REF="$(printf '%s' "$PR_JSON" | jq -r '.base.ref // empty')" + HEAD_REF="$(printf '%s' "$PR_JSON" | jq -r '.head.ref // empty')" + [ -n "$PR_NUMBER" ] && [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ] || skip "Failed to read pull request fields." + fi + + if [ "$HEAD_REPO" != "$BASE_REPO" ]; then + FORK_BASE_SHA="$(git ls-remote "https://github.com/${HEAD_REPO}.git" "refs/heads/${BASE_REF}" | awk '{print $1; exit}')" + if [ -n "$FORK_BASE_SHA" ]; then + BASE_SHA="$FORK_BASE_SHA" + BASE_REPO="$HEAD_REPO" + else + BASE_SHA="" + fi fi - { [ -n "$PR_NUMBER" ] && [ -n "$HEAD_SHA" ] && [ -n "$BASE_REPO" ] && [ -n "$HEAD_REPO" ]; } || skip "Could not resolve PR/base/head SHAs/repos." - { [ -n "$BASE_SHA" ] || [ "${EMPTY_BASE:-false}" = "true" ]; } || skip "Could not resolve comparison branch SHA." - { - echo "skip=false" - echo "pr_number=$PR_NUMBER" - echo "base_sha=$BASE_SHA" - echo "head_sha=$HEAD_SHA" - echo "head_ref=$HEAD_REF" - echo "base_ref=$BASE_REF" - echo "base_repo=$BASE_REPO" - echo "head_repo=$HEAD_REPO" - echo "empty_base=${EMPTY_BASE:-false}" - echo "fork_compare=${FORK_COMPARE:-false}" - echo "checkout_repo=$HEAD_REPO" - echo "checkout_sha=$HEAD_SHA" - # same_repo gates pushing the head analysis: forks give a read-only token. - if [ "$HEAD_REPO" = "$REPOSITORY" ]; then echo "same_repo=true"; else echo "same_repo=false"; fi - } >> "$GITHUB_OUTPUT" - if [ "${EMPTY_BASE:-false}" = "true" ]; then - echo "Resolved PR #$PR_NUMBER (comparison branch ${HEAD_REPO}:${BASE_REF} has no tip; using empty baseline, head=$HEAD_REPO@$HEAD_SHA) via $EVENT" - else - echo "Resolved PR #$PR_NUMBER (comparison=$BASE_REPO@$BASE_SHA head=$HEAD_REPO@$HEAD_SHA) via $EVENT" + if [ -n "$BASE_SHA" ]; then + BASELINE_EXISTS="true" fi - # Feedback exits the guard with skip=true, so the analysis "Acknowledge - # command" step below (gated on skip != 'true') never fires for it; this one - # reacts instead, keyed on the feedback_received flag the guard set. - - name: Acknowledge feedback - if: steps.guard.outputs.feedback_received == 'true' - shell: bash - env: - GH_TOKEN: ${{ inputs.github_token }} - REPOSITORY: ${{ github.repository }} - COMMENT_ID: ${{ github.event.comment.id }} - run: | - # 👍 react to the feedback comment so the user knows it was received. - gh api -X POST "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ - -f content='+1' >/dev/null 2>&1 || true - - - name: Acknowledge command - if: steps.guard.outputs.skip != 'true' && github.event_name == 'issue_comment' - shell: bash - env: - GH_TOKEN: ${{ inputs.github_token }} - REPOSITORY: ${{ github.repository }} - COMMENT_ID: ${{ github.event.comment.id }} - run: | - # 👀 react to the triggering comment so the user knows it was picked up. - gh api -X POST "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ - -f content=eyes >/dev/null 2>&1 || true - - - name: Post in-progress comment - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - continue-on-error: true - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: ${{ steps.guard.outputs.sticky_header }} - number: ${{ steps.guard.outputs.pr_number }} - message: | - ### ${{ inputs.comment_header }} · analyzing… - - ⏳ CodeBoarding is analyzing the architecture changes in this PR. This usually takes a few minutes. - - codeboarding-action · run ${{ github.run_id }} - GITHUB_TOKEN: ${{ inputs.github_token }} + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "base_repo=$BASE_REPO" >> "$GITHUB_OUTPUT" + echo "head_repo=$HEAD_REPO" >> "$GITHUB_OUTPUT" + echo "base_ref=$BASE_REF" >> "$GITHUB_OUTPUT" + echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT" + echo "checkout_ref=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "baseline_exists=$BASELINE_EXISTS" >> "$GITHUB_OUTPUT" + echo "sticky_header=codeboarding-architecture-diff" >> "$GITHUB_OUTPUT" - - name: Setup Java for JDTLS - if: steps.guard.outputs.skip != 'true' - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - - name: Setup .NET SDKs for C# LSP - if: steps.guard.outputs.skip != 'true' - uses: actions/setup-dotnet@v5 - with: - dotnet-version: | - 8.0.x - 9.0.x - 10.0.x - - # Review mode: the PR head repo at the head SHA. Sync mode: this repo at the - # pushed/selected SHA. Always credential-free — sync pushes authenticate - # explicitly via push_token. - - name: Checkout target repository - if: steps.guard.outputs.skip != 'true' + - name: Checkout uses: actions/checkout@v4 with: - repository: ${{ steps.guard.outputs.checkout_repo }} - path: target-repo + token: ${{ inputs.github_token }} + ref: ${{ steps.guard.outputs.checkout_ref || github.sha }} fetch-depth: 0 - ref: ${{ steps.guard.outputs.checkout_sha }} persist-credentials: false - - name: Ensure PR comparison commits are fetched - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + - name: Prepare review head for issue_comment + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.event == 'issue_comment' shell: bash - working-directory: target-repo - env: - BASE_SHA: ${{ steps.guard.outputs.base_sha }} - BASE_REPO: ${{ steps.guard.outputs.base_repo }} - BASE_REF: ${{ steps.guard.outputs.base_ref }} - EMPTY_BASE: ${{ steps.guard.outputs.empty_base }} run: | - if [ "$EMPTY_BASE" = "true" ]; then - echo "No comparison branch tip is available; using empty baseline." - exit 0 - fi - git remote add base "https://github.com/${BASE_REPO}.git" 2>/dev/null || git remote set-url base "https://github.com/${BASE_REPO}.git" - # The baseline search walks the PR head ancestry, which checkout fetched - # with fetch-depth: 0. We still fetch the PR target-branch commit for - # GitHub-style changed-file diffs and for the no-baseline fallback full - # target analysis. - if [ -n "$BASE_REF" ]; then - git fetch --no-tags base "+refs/heads/${BASE_REF}:refs/remotes/codeboarding-base/${BASE_REF}" || true - fi - git fetch origin "$BASE_SHA" --depth=2 || true - if ! git cat-file -e "$BASE_SHA" 2>/dev/null; then - git remote add base "https://github.com/${BASE_REPO}.git" 2>/dev/null || git remote set-url base "https://github.com/${BASE_REPO}.git" - git fetch base "$BASE_SHA" --depth=2 || true - fi - git cat-file -e "$BASE_SHA" && echo "Target branch commit reachable." || \ - (echo "::error::Target branch commit $BASE_SHA is not reachable." && exit 1) + set -euo pipefail + git fetch "https://github.com/${{ steps.guard.outputs.head_repo }}.git" "${{ steps.guard.outputs.head_sha }}" --depth=1 + git checkout -f "${{ steps.guard.outputs.head_sha }}" - - name: Set up Python 3.12 - if: steps.guard.outputs.skip != 'true' + - name: Setup Python uses: actions/setup-python@v5 with: - # 3.12, not 3.13: the codeboarding package's transitive dep - # langchain-cerebras (>=0.8) is published only for >=3.11,<3.13, so a 3.13 - # runner can't resolve the engine install. The engine itself supports - # <3.14, so bump this back to 3.13 once langchain-cerebras ships a 3.13 wheel. - python-version: '3.12' - - - name: Set up Node.js 20 - if: steps.guard.outputs.skip != 'true' - uses: actions/setup-node@v4 - with: - node-version: '20' + python-version: '3.13' - - name: Cache LSP servers - if: steps.guard.outputs.skip != 'true' - uses: actions/cache@v4 - with: - path: | - ~/.codeboarding/servers - key: cb-lsp-${{ runner.os }}-${{ inputs.codeboarding_version }}-v1 - restore-keys: | - cb-lsp-${{ runner.os }}- - - - name: Install CodeBoarding package - if: steps.guard.outputs.skip != 'true' + - name: Install CodeBoarding CLI shell: bash - env: - CODEBOARDING_VERSION: ${{ inputs.codeboarding_version }} run: | - set -euo pipefail - SPEC="$CODEBOARDING_VERSION" - case "$SPEC" in - '' ) SPEC='' ;; - [0-9]* ) SPEC="==$SPEC" ;; - esac - python -m pip install --upgrade pip - python -m pip install "codeboarding${SPEC}" - - - name: Install LSP servers - if: steps.guard.outputs.skip != 'true' - shell: bash - run: | - codeboarding-setup --auto-install-npm + python -m pip install --disable-pip-version-check --upgrade pip + python -m pip install --disable-pip-version-check "codeboarding==${{ inputs.codeboarding_version }}" - - name: Prepare & verify LLM key - if: steps.guard.outputs.skip != 'true' - id: llm + - name: Start OIDC relay + id: relay + if: always() && steps.guard.outputs.skip != 'true' shell: bash env: - RAW_KEY: ${{ inputs.llm_api_key }} - RAW_PROVIDER: ${{ inputs.llm_provider }} - RAW_AGENT_MODEL: ${{ inputs.agent_model }} - RAW_PARSING_MODEL: ${{ inputs.parsing_model }} - RAW_LICENSE: ${{ inputs.license_key }} - RAW_PROXY_URL: ${{ inputs.proxy_url }} + LICENSE_KEY: ${{ inputs.license_key }} ACTION_PATH: ${{ github.action_path }} run: | set -euo pipefail - AUTH_FILE="${RUNNER_TEMP}/openrouter-auth.json" - trap 'rm -f "$AUTH_FILE"' EXIT - - _strip() { printf '%s' "$1" | tr -d '[:space:]' | sed -e 's/^"//;s/"$//' -e "s/^'//;s/'\$//"; } - # Cache keys reject some characters (model slugs carry '/'); sanitize for them. - _safe() { printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_'; } - - KEY="$(_strip "$RAW_KEY")" - LICENSE="$(_strip "$RAW_LICENSE")" - PROXY_URL="$(_strip "$RAW_PROXY_URL")" - PROXY_URL="${PROXY_URL%/}" # no trailing slash; engine appends /chat/completions - AGENT_MODEL="$(_strip "$RAW_AGENT_MODEL")" - PARSING_MODEL="$(_strip "$RAW_PARSING_MODEL")" - umask 077 - - # Three credential modes, in precedence order: - # 1. BYO key set -> talk to the provider directly (current behavior) - # 2. license_key set -> hosted proxy, bearer = the license - # 3. neither (zero-config) -> hosted proxy, bearer = a GitHub OIDC JWT - # Modes 2 & 3 force provider=openrouter and point the engine's - # OPENROUTER_BASE_URL at the proxy (written to cb-base-url). The proxy - # swaps in the real key, so no provider preflight here. - if [ -n "$KEY" ]; then - MODE="byokey" - elif [ -n "$LICENSE" ]; then - MODE="license" - else - MODE="oidc" - fi - echo "mode=$MODE" >> "$GITHUB_OUTPUT" - echo "Credential mode: $MODE" - - # ── Hosted modes (license / oidc): provider is always OpenRouter via proxy ── - # The hosted tiers run on CodeBoarding's OpenRouter account. A loopback relay - # mints a fresh GitHub OIDC JWT for every engine request, then forwards it to - # the hosted proxy. This matters because an analysis can outlive a single OIDC - # JWT. To use a DIFFERENT provider, set llm_api_key (BYO-key mode, below). - if [ "$MODE" != "byokey" ]; then - if [ -z "$PROXY_URL" ]; then - echo "::error::proxy_url is empty but no llm_api_key was provided. Set llm_api_key, or restore proxy_url." - exit 1 - fi - # Warn if the user asked for a non-OpenRouter provider but gave no key: - # the hosted tier can only use OpenRouter, so llm_provider is ignored here. - PROVIDER_NORM="$(printf '%s' "$RAW_PROVIDER" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_')" - if [ -n "$PROVIDER_NORM" ] && [ "$PROVIDER_NORM" != "openrouter" ]; then - echo "::warning::llm_provider='$PROVIDER_NORM' is ignored on the free/license hosted tier (OpenRouter only). To use $PROVIDER_NORM, pass its key via llm_api_key." - fi - PROVIDER_ENV="OPENROUTER_API_KEY" - AGENT_MODEL="${AGENT_MODEL:-google/gemini-3-flash-preview}" - PARSING_MODEL="${PARSING_MODEL:-google/gemini-3.1-flash-lite-preview}" - - # ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN are injected into the runner process - # env (NOT the `env` context) only when the job grants `id-token: write`. - # Pass them to the local relay through its inherited environment; it requests - # a new JWT per forwarded request instead of freezing one into the engine. - if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then - echo "::error::No GitHub OIDC token available. Add \`permissions: id-token: write\` to the job (the hosted tier — free and license — needs the OIDC token to identify your repository; an llm_api_key avoids the proxy entirely)." - exit 1 - fi - RELAY_READY="${RUNNER_TEMP}/cb-oidc-relay-port" - RELAY_PID_FILE="${RUNNER_TEMP}/cb-oidc-relay.pid" - RELAY_LICENSE_FILE="${RUNNER_TEMP}/cb-oidc-relay-license" - RELAY_LOG="${RUNNER_TEMP}/cb-oidc-relay.log" - rm -f "$RELAY_READY" "$RELAY_PID_FILE" "$RELAY_LICENSE_FILE" "$RELAY_LOG" - relay_args=(--upstream-base-url "$PROXY_URL" --ready-file "$RELAY_READY") - if [ "$MODE" = "license" ]; then - echo "::add-mask::$LICENSE" - printf '%s' "$LICENSE" > "$RELAY_LICENSE_FILE" - relay_args+=(--license-file "$RELAY_LICENSE_FILE") - fi - python3 "$ACTION_PATH/scripts/oidc_relay.py" "${relay_args[@]}" >"$RELAY_LOG" 2>&1 & - RELAY_PID=$! - printf '%s' "$RELAY_PID" > "$RELAY_PID_FILE" - for _ in $(seq 1 50); do - [ -s "$RELAY_READY" ] && break - kill -0 "$RELAY_PID" 2>/dev/null || break - sleep 0.1 - done - if [ ! -s "$RELAY_READY" ]; then - echo "::error::Failed to start the GitHub OIDC relay." - sed -n '1,20p' "$RELAY_LOG" || true - exit 1 - fi - RELAY_PORT="$(cat "$RELAY_READY")" - case "$RELAY_PORT" in *[!0-9]*|'') echo "::error::OIDC relay returned an invalid port."; exit 1 ;; esac - printf '%s' 'github-actions-oidc-relay' > "${RUNNER_TEMP}/cb-llm-key" - printf '%s' "$PROVIDER_ENV" > "${RUNNER_TEMP}/cb-provider-env" - printf '%s' "http://127.0.0.1:${RELAY_PORT}" > "${RUNNER_TEMP}/cb-base-url" - printf '%s' "$AGENT_MODEL" > "${RUNNER_TEMP}/cb-agent-model" - printf '%s' "$PARSING_MODEL" > "${RUNNER_TEMP}/cb-parsing-model" - if [ "$MODE" = "license" ]; then - echo "Using CodeBoarding license via a GitHub OIDC relay (token refreshed per request)." - else - echo "Using the free hosted tier via a GitHub OIDC relay (token refreshed per request)." - fi - exit 0 + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "::error::Missing OIDC token. Add permissions: id-token: write." && exit 1 fi - # ── BYO key mode: unchanged behavior (talk to the provider directly) ── - # Resolve the provider -> the env var the engine reads. Convention is - # _API_KEY; two providers don't follow it. The engine is the source - # of truth: an unknown provider just yields an env var it won't recognize, - # and the engine errors with the list of valid keys. - PROVIDER="$(printf '%s' "$RAW_PROVIDER" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_')" - PROVIDER="${PROVIDER:-openrouter}" - case "$PROVIDER" in - aws_bedrock) PROVIDER_ENV="AWS_BEARER_TOKEN_BEDROCK" ;; - ollama) PROVIDER_ENV="OLLAMA_BASE_URL" ;; - *) PROVIDER_ENV="$(printf '%s' "$PROVIDER" | tr '[:lower:]' '[:upper:]')_API_KEY" ;; - esac - - # Normalize a pasted key: strip a leading `=`. - case "$KEY" in "${PROVIDER_ENV}="*) KEY="${KEY#${PROVIDER_ENV}=}";; esac - KEY="$(_strip "$KEY")" - echo "::add-mask::$KEY" - echo "Provider: $PROVIDER -> $PROVIDER_ENV; key length: ${#KEY}" - - if [ "$PROVIDER" = "openrouter" ]; then - # Default models on OpenRouter when the user didn't pin one (cheap Gemini). - # Other providers fall through to the engine's own per-provider default. - AGENT_MODEL="${AGENT_MODEL:-google/gemini-3-flash-preview}" - PARSING_MODEL="${PARSING_MODEL:-google/gemini-3.1-flash-lite-preview}" - # OpenRouter-only checks. The litellm 'openrouter/...' model prefix 400s - # the engine's native OpenRouter call; other providers use native ids. - for M in "$AGENT_MODEL" "$PARSING_MODEL"; do - case "$M" in - openrouter/*) - echo "::error::Invalid model '$M': drop the 'openrouter/' prefix and use a bare OpenRouter slug, e.g. anthropic/claude-sonnet-4." - exit 1 ;; - esac - done - # Cheap preflight; other providers are validated by the engine at run time. - STATUS=$(curl -sS -o "$AUTH_FILE" -w "%{http_code}" \ - -H "Authorization: Bearer $KEY" --max-time 10 \ - https://openrouter.ai/api/v1/auth/key || echo "curl-fail") - echo "OpenRouter /auth/key response: HTTP $STATUS" - if [ "$STATUS" != "200" ]; then - # Surface the upstream error MESSAGE only — never the whole auth body (avoid leaking). - MSG="$(AUTH_FILE="$AUTH_FILE" python3 -c 'import json,os;print(json.load(open(os.environ["AUTH_FILE"])).get("error",{}).get("message",""))' 2>/dev/null || true)" - echo "::error::OpenRouter rejected the API key (HTTP $STATUS). ${MSG:-Verify the OPENROUTER_API_KEY secret.}" - exit 1 - fi + RELAY_DIR="${RUNNER_TEMP}/codeboarding-relay" + mkdir -p "$RELAY_DIR" + READY="$RELAY_DIR/ready-port" + PID="$RELAY_DIR/relay.pid" + LOG="$RELAY_DIR/relay.log" + LICENSE_FILE="$RELAY_DIR/license.txt" + : > "$LICENSE_FILE" + if [ -n "${LICENSE_KEY:-}" ]; then + printf '%s' "$LICENSE_KEY" > "$LICENSE_FILE" fi - # Store key material in runner-temp files. Later shell steps read these - # explicitly; third-party post-comment actions do not inherit the LLM key. - # No cb-base-url in BYO mode -> the engine talks to the provider directly. - printf '%s' "$KEY" > "${RUNNER_TEMP}/cb-llm-key" - printf '%s' "$PROVIDER_ENV" > "${RUNNER_TEMP}/cb-provider-env" - printf '%s' "$AGENT_MODEL" > "${RUNNER_TEMP}/cb-agent-model" - printf '%s' "$PARSING_MODEL" > "${RUNNER_TEMP}/cb-parsing-model" - - # Sanitized copies for use inside actions/cache keys (sync mode). - { - echo "cache_provider=$(_safe "$PROVIDER")" - echo "cache_agent_model=$(_safe "${AGENT_MODEL:-default}")" - echo "cache_parsing_model=$(_safe "${PARSING_MODEL:-default}")" - } >> "$GITHUB_OUTPUT" - - # Runs AFTER the LLM-key step so the credential mode (byokey/license/oidc) is - # known: it sets the per-tier depth ceiling (free = 3, licensed/BYO = 10). The - # CodeBoarding package is installed by now, so baseline-depth runs against the - # set-up python (stdlib-only parse either way). - - name: Resolve analysis depth - id: resolve_depth - if: steps.guard.outputs.skip != 'true' - shell: bash - working-directory: target-repo - env: - INPUT_DEPTH: ${{ inputs.depth_level }} - MODE: ${{ steps.guard.outputs.mode }} - BASE_SHA: ${{ steps.guard.outputs.base_sha }} - EMPTY_BASE: ${{ steps.guard.outputs.empty_base }} - ACTION_PATH: ${{ github.action_path }} - CRED_MODE: ${{ steps.llm.outputs.mode }} - run: | - set -euo pipefail - # Licensed = BYO key or a CodeBoarding license; the free OIDC tier is capped lower. - if [ "$CRED_MODE" = "byokey" ] || [ "$CRED_MODE" = "license" ]; then - LICENSED_FLAG="--licensed"; MAX_DEPTH=10; TIER="licensed" - else - LICENSED_FLAG=""; MAX_DEPTH=3; TIER="free-tier" + RELAY_ARGS=(--upstream-base-url https://openrouter.ai/api/v1 --ready-file "$READY") + if [ -s "$LICENSE_FILE" ]; then + RELAY_ARGS+=(--license-file "$LICENSE_FILE") fi - # Explicit input controls cold-start/force_full rebuilds. It is capped to the - # tier ceiling (an over-cap request is a clear error, not a silent clamp). - if [ -n "$INPUT_DEPTH" ]; then - if [ "$INPUT_DEPTH" -gt "$MAX_DEPTH" ]; then - echo "::error::depth_level=$INPUT_DEPTH exceeds the $TIER maximum of $MAX_DEPTH. Use a CodeBoarding license or your own llm_api_key for deeper analysis." - exit 1 - fi - echo "depth=$INPUT_DEPTH" >> "$GITHUB_OUTPUT" - echo "Using explicit depth_level=$INPUT_DEPTH (max $MAX_DEPTH for $TIER)." - exit 0 - fi - # Review against a committed baseline: analyze the PR head at the SAME - # depth the committed .codeboarding/analysis.json was generated with, so - # head and base diff apples-to-apples. Defaulting to a shallower depth - # would make validate-base reject the deeper baseline, force the action to - # regenerate a shallower base, and (because the artifact then carries no - # committed base SHA) leave the webview diffing the deeper committed base - # against the shallower head — reporting phantom "deleted" components. - # baseline-depth clamps the inherited value to the tier ceiling and only - # parses the committed JSON (stdlib). - if [ "$MODE" = "review" ] && [ "$EMPTY_BASE" != "true" ] && [ -n "$BASE_SHA" ]; then - BASE_ANALYSIS="$(mktemp)" - if git show "${BASE_SHA}:.codeboarding/analysis.json" > "$BASE_ANALYSIS" 2>/dev/null; then - INHERITED="$(python3 "$ACTION_PATH/scripts/engine_adapter.py" baseline-depth --analysis "$BASE_ANALYSIS" $LICENSED_FLAG | sed -n 's/^depth_level=//p')" - rm -f "$BASE_ANALYSIS" - if [ -n "$INHERITED" ]; then - echo "depth=$INHERITED" >> "$GITHUB_OUTPUT" - echo "Inheriting committed baseline depth_level=$INHERITED for the PR-head analysis (max $MAX_DEPTH for $TIER)." - exit 0 - fi - echo "Committed baseline has no usable depth_level; using default cold-start depth." - else - rm -f "$BASE_ANALYSIS" - echo "No committed baseline at ${BASE_SHA}; using default cold-start depth." - fi - fi - DEPTH=2 - echo "depth=$DEPTH" >> "$GITHUB_OUTPUT" - echo "Using default cold-start depth_level=$DEPTH." - - name: Resolve base analysis (committed baseline) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - id: base - shell: bash - working-directory: target-repo - env: - BASE_SHA: ${{ steps.guard.outputs.base_sha }} - ACTION_PATH: ${{ github.action_path }} - DEPTH: ${{ steps.resolve_depth.outputs.depth }} - EMPTY_BASE: ${{ steps.guard.outputs.empty_base }} - FORK_COMPARE: ${{ steps.guard.outputs.fork_compare }} - run: | - BASE_DIR="${RUNNER_TEMP}/cb-base" - HEAD_DIR="${RUNNER_TEMP}/cb-head" - mkdir -p "$BASE_DIR" "$HEAD_DIR" - echo "base_dir=$BASE_DIR" >> $GITHUB_OUTPUT - echo "head_dir=$HEAD_DIR" >> $GITHUB_OUTPUT - if [ "$EMPTY_BASE" = "true" ]; then - printf '%s\n' '{"metadata": {"baseline": "empty"}, "components": [], "components_relations": []}' > "${BASE_DIR}/analysis.json" - echo "committed=false" >> $GITHUB_OUTPUT - echo "empty_base=true" >> $GITHUB_OUTPUT - echo "baseline_sha=empty" >> $GITHUB_OUTPUT - echo "No committed baseline exists on the fork comparison branch; using an empty baseline so PR architecture renders as new." - exit 0 - fi - echo "empty_base=false" >> $GITHUB_OUTPUT - if git show "${BASE_SHA}:.codeboarding/analysis.json" > "${BASE_DIR}/analysis.json" 2>/dev/null; then - if python3 "$ACTION_PATH/scripts/engine_adapter.py" validate-base \ - --analysis "${BASE_DIR}/analysis.json" \ - --expected-sha "$BASE_SHA" \ - --expected-depth "$DEPTH"; then - echo "committed=true" >> $GITHUB_OUTPUT - echo "baseline_sha=$BASE_SHA" >> $GITHUB_OUTPUT - echo "Using committed .codeboarding/analysis.json at target branch commit ${BASE_SHA}." - else - rm -f "${BASE_DIR}/analysis.json" - echo "committed=false" >> $GITHUB_OUTPUT - echo "baseline_sha=$BASE_SHA" >> $GITHUB_OUTPUT - echo "Committed baseline at target branch commit ${BASE_SHA} is unusable; will generate a fresh target analysis." - fi - else - rm -f "${BASE_DIR}/analysis.json" - if [ "$FORK_COMPARE" = "true" ]; then - printf '%s\n' '{"metadata": {"baseline": "empty"}, "components": [], "components_relations": []}' > "${BASE_DIR}/analysis.json" - echo "committed=false" >> $GITHUB_OUTPUT - echo "empty_base=true" >> $GITHUB_OUTPUT - echo "baseline_sha=empty" >> $GITHUB_OUTPUT - echo "No committed baseline found on the fork comparison branch at ${BASE_SHA}; using an empty baseline so PR architecture renders as new." - exit 0 - fi - echo "committed=false" >> $GITHUB_OUTPUT - echo "baseline_sha=$BASE_SHA" >> $GITHUB_OUTPUT - echo "No committed baseline found at target branch commit ${BASE_SHA}; will generate one via a full analysis on that commit." - fi + python3 "$ACTION_PATH/scripts/oidc_relay.py" "${RELAY_ARGS[@]}" > "$LOG" 2>&1 & + echo $! > "$PID" - - name: Restore base artifacts (keyed by baseline SHA) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.empty_base != 'true' - id: basecache - uses: actions/cache/restore@v4 - with: - path: ${{ runner.temp }}/cb-base - # Fold in the credential mode (byokey/license/oidc). The cache key is - # otherwise (provider, agent_model, parsing_model) from the raw INPUTS, but - # the hosted tiers force OpenRouter + Gemini defaults regardless of those - # inputs. So a free-tier run (oidc, forced Gemini) and a BYO OpenRouter-key - # run with no model pinned would share a key yet produce different base - # analyses; the mode discriminator keeps them from reusing each other's cache. - key: cb-base-v3-${{ runner.os }}-${{ steps.base.outputs.baseline_sha }}-d${{ steps.resolve_depth.outputs.depth }}-${{ inputs.codeboarding_version }}-${{ steps.llm.outputs.mode }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }} - - - name: Reassert committed base analysis after cache restore - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.base.outputs.committed == 'true' - shell: bash - working-directory: target-repo - env: - BASE_SHA: ${{ steps.guard.outputs.base_sha }} - BASE_DIR: ${{ steps.base.outputs.base_dir }} - ACTION_PATH: ${{ github.action_path }} - DEPTH: ${{ steps.resolve_depth.outputs.depth }} - run: | - # The cache stores generated base artifacts under the baseline SHA so the - # static-analysis pkl can be reused. It must never replace the committed - # target-branch baseline used for the PR diff. Re-copy the versioned - # baseline after restore: cache may supply pkl/sha, but git is the source - # of truth for cb-base/analysis.json. - git show "${BASE_SHA}:.codeboarding/analysis.json" > "${BASE_DIR}/analysis.json" - python3 "$ACTION_PATH/scripts/engine_adapter.py" validate-base \ - --analysis "${BASE_DIR}/analysis.json" \ - --expected-sha "$BASE_SHA" \ - --expected-depth "$DEPTH" - - # A committed analysis.json gives the head analysis stable component ids, - # but the engine's incremental path ALSO needs the base static_analysis.pkl - # with its cluster baseline — which git can't provide (the pkl is never - # committed, by design). Build it here deterministically: LSP + Leiden - # clustering, no LLM key read. Fail-open: a failed seed degrades to exactly - # the previous behavior (the head run falls back to a full analysis). - - name: Seed base static-analysis cache (committed baseline, no cached pkl) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.base.outputs.committed == 'true' && steps.basecache.outputs.cache-hit != 'true' - id: seedbase - continue-on-error: true - shell: bash - env: - CACHING_DOCUMENTATION: 'false' - ENABLE_MONITORING: 'false' - ACTION_PATH: ${{ github.action_path }} - TARGET: ${{ github.workspace }}/target-repo - BASE_DIR: ${{ steps.base.outputs.base_dir }} - BASELINE_SHA: ${{ steps.base.outputs.baseline_sha }} - run: | - # Clean up any stale registration before re-adding (rm -rf alone leaves a - # dangling worktree entry that makes a retry's `worktree add` fail). - BASE_SRC="${RUNNER_TEMP}/base-src" - git -C "$TARGET" worktree remove --force "$BASE_SRC" 2>/dev/null || true - git -C "$TARGET" worktree prune - rm -rf "$BASE_SRC" - git -C "$TARGET" worktree add --detach "$BASE_SRC" "$BASELINE_SHA" - if python "$ACTION_PATH/scripts/engine_adapter.py" seed \ - --repo "$BASE_SRC" \ - --out "$BASE_DIR" \ - --source-sha "$BASELINE_SHA" \ - && [ -f "$BASE_DIR/static_analysis.pkl" ] && [ -f "$BASE_DIR/static_analysis.sha" ]; then - echo "seed_ok=true" >> "$GITHUB_OUTPUT" - echo "::notice::Seeded base static-analysis cache for ${BASELINE_SHA}; head analysis can run incrementally." - else - # Never leave a partial pkl/sha pair behind: the save step below would - # cache it under this base SHA's key and suppress every retry. - rm -f "$BASE_DIR/static_analysis.pkl" "$BASE_DIR/static_analysis.sha" - echo "seed_ok=false" >> "$GITHUB_OUTPUT" - echo "::warning::Base static-analysis seeding failed; head analysis will fall back to a full run." - fi - git -C "$TARGET" worktree remove --force "$BASE_SRC" 2>/dev/null || true + for _ in {1..60}; do + [ -s "$READY" ] && break + sleep 0.25 + done - - name: Generate base analysis (no committed baseline) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.base.outputs.empty_base != 'true' && steps.base.outputs.committed == 'false' && steps.basecache.outputs.cache-hit != 'true' - shell: bash - env: - DIAGRAM_DEPTH_LEVEL: ${{ steps.resolve_depth.outputs.depth }} - CACHING_DOCUMENTATION: 'false' - ENABLE_MONITORING: 'false' - ACTION_PATH: ${{ github.action_path }} - TARGET: ${{ github.workspace }}/target-repo - BASE_DIR: ${{ steps.base.outputs.base_dir }} - REPO_NAME: ${{ github.event.repository.name }} - RUN_ID_BASE: ${{ github.run_id }}-${{ github.run_attempt }}-base - DEPTH: ${{ steps.resolve_depth.outputs.depth }} - BASE_SHA: ${{ steps.guard.outputs.base_sha }} - run: | - # Export the key under the selected provider's env var (only this one), - # so the engine auto-selects that provider. - PROVIDER_ENV="$(cat "${RUNNER_TEMP}/cb-provider-env")" - export "$PROVIDER_ENV"="$(cat "${RUNNER_TEMP}/cb-llm-key")" - # Hosted modes (license/oidc) point the engine at the CodeBoarding proxy; - # absent in BYO-key mode, where the engine talks to the provider directly. - if [ -f "${RUNNER_TEMP}/cb-base-url" ]; then - export OPENROUTER_BASE_URL="$(cat "${RUNNER_TEMP}/cb-base-url")" - fi - # The engine_adapter drops this sentinel on a 402 (free-tier cap reached) - # so the failure-comment step can post a tailored message. - export CB_QUOTA_SENTINEL="${RUNNER_TEMP}/cb-quota-exhausted" - # Export the model env only when the user set it; empty -> the engine uses - # its own valid per-provider default (no stale hardcoded model id to rot). - AGENT_MODEL="$(cat "${RUNNER_TEMP}/cb-agent-model")" - PARSING_MODEL="$(cat "${RUNNER_TEMP}/cb-parsing-model")" - if [ -n "$AGENT_MODEL" ]; then export AGENT_MODEL; fi - if [ -n "$PARSING_MODEL" ]; then export PARSING_MODEL; fi - - BASE_SRC="${RUNNER_TEMP}/base-src" - # Clean up any stale registration before re-adding (rm -rf alone leaves a - # dangling worktree entry that makes a retry's `worktree add` fail). - git -C "$TARGET" worktree remove --force "$BASE_SRC" 2>/dev/null || true - git -C "$TARGET" worktree prune - rm -rf "$BASE_SRC" - git -C "$TARGET" worktree add --detach "$BASE_SRC" "$BASE_SHA" - python "$ACTION_PATH/scripts/engine_adapter.py" base \ - --repo "$BASE_SRC" \ - --out "$BASE_DIR" \ - --name "$REPO_NAME" \ - --run-id "$RUN_ID_BASE" \ - --depth "$DEPTH" \ - --source-sha "$BASE_SHA" - git -C "$TARGET" worktree remove --force "$BASE_SRC" 2>/dev/null || true - if [ ! -f "$BASE_DIR/analysis.json" ]; then - echo "::error::Base full analysis ran but analysis.json is missing." + if [ ! -s "$READY" ]; then + echo "::error::OIDC relay did not start." >&2 + cat "$LOG" >&2 || true exit 1 fi - # Covers both base-artifact producers: the full analysis (no committed - # baseline) and the seeded pkl (committed baseline). The seed_ok gate is - # load-bearing — caching a pkl-less dir under this base SHA's key would - # permanently suppress seeding retries for it. - - name: Save generated base artifacts - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.base.outputs.empty_base != 'true' && steps.basecache.outputs.cache-hit != 'true' && (steps.base.outputs.committed == 'false' || steps.seedbase.outputs.seed_ok == 'true') - uses: actions/cache/save@v4 - with: - path: ${{ runner.temp }}/cb-base - # Fold in the credential mode (byokey/license/oidc). The cache key is - # otherwise (provider, agent_model, parsing_model) from the raw INPUTS, but - # the hosted tiers force OpenRouter + Gemini defaults regardless of those - # inputs. So a free-tier run (oidc, forced Gemini) and a BYO OpenRouter-key - # run with no model pinned would share a key yet produce different base - # analyses; the mode discriminator keeps them from reusing each other's cache. - key: cb-base-v3-${{ runner.os }}-${{ steps.base.outputs.baseline_sha }}-d${{ steps.resolve_depth.outputs.depth }}-${{ inputs.codeboarding_version }}-${{ steps.llm.outputs.mode }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }} - - - name: Analyze PR head (incremental from base) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - id: analyze - shell: bash - env: - DIAGRAM_DEPTH_LEVEL: ${{ steps.resolve_depth.outputs.depth }} - CACHING_DOCUMENTATION: 'false' - ENABLE_MONITORING: 'false' - ACTION_PATH: ${{ github.action_path }} - TARGET_REPO: ${{ github.workspace }}/target-repo - BASE_DIR: ${{ steps.base.outputs.base_dir }} - HEAD_DIR: ${{ steps.base.outputs.head_dir }} - REPO_NAME: ${{ github.event.repository.name }} - RUN_ID_HEAD: ${{ github.run_id }}-${{ github.run_attempt }}-head - DEPTH: ${{ steps.resolve_depth.outputs.depth }} - BASELINE_SHA: ${{ steps.base.outputs.baseline_sha }} - HEAD_SHA: ${{ steps.guard.outputs.head_sha }} - EMPTY_BASE: ${{ steps.base.outputs.empty_base }} - CRED_MODE: ${{ steps.llm.outputs.mode }} - run: | - # Export the key under the selected provider's env var (only this one), - # so the engine auto-selects that provider. - PROVIDER_ENV="$(cat "${RUNNER_TEMP}/cb-provider-env")" - export "$PROVIDER_ENV"="$(cat "${RUNNER_TEMP}/cb-llm-key")" - # Hosted modes (license/oidc) point the engine at the CodeBoarding proxy; - # absent in BYO-key mode, where the engine talks to the provider directly. - if [ -f "${RUNNER_TEMP}/cb-base-url" ]; then - export OPENROUTER_BASE_URL="$(cat "${RUNNER_TEMP}/cb-base-url")" - fi - # The engine_adapter drops this sentinel on a 402 (free-tier cap reached) - # so the failure-comment step can post a tailored message. - export CB_QUOTA_SENTINEL="${RUNNER_TEMP}/cb-quota-exhausted" - # Export the model env only when the user set it; empty -> the engine uses - # its own valid per-provider default (no stale hardcoded model id to rot). - AGENT_MODEL="$(cat "${RUNNER_TEMP}/cb-agent-model")" - PARSING_MODEL="$(cat "${RUNNER_TEMP}/cb-parsing-model")" - if [ -n "$AGENT_MODEL" ]; then export AGENT_MODEL; fi - if [ -n "$PARSING_MODEL" ]; then export PARSING_MODEL; fi - - # Seed the head dir from the base analysis so incremental stitches - # component ids from the baseline (stable diff). Base dir is left - # untouched as the "before" snapshot for the diff. - cp -a "$BASE_DIR"/. "$HEAD_DIR"/ 2>/dev/null || true - rm -rf "$HEAD_DIR/health" - head_args=( - head - --repo "$TARGET_REPO" - --out "$HEAD_DIR" - --name "$REPO_NAME" - --run-id "$RUN_ID_HEAD" - --depth "$DEPTH" - --source-sha "$HEAD_SHA" - ) - # Raise the depth ceiling for licensed/BYO-key runs (controls the - # fallback-full depth when incremental can't run). - if [ "$CRED_MODE" = "byokey" ] || [ "$CRED_MODE" = "license" ]; then - head_args+=(--licensed) - fi - if [ "$EMPTY_BASE" = "true" ]; then - head_args+=(--force-full) - fi - python "$ACTION_PATH/scripts/engine_adapter.py" "${head_args[@]}" - if [ ! -f "$HEAD_DIR/analysis.json" ]; then - echo "::error::Head analysis ran but analysis.json is missing." - exit 1 - fi - echo "base_analysis=$BASE_DIR/analysis.json" >> "$GITHUB_OUTPUT" - echo "head_analysis=$HEAD_DIR/analysis.json" >> $GITHUB_OUTPUT + PORT="$(cat "$READY")" + echo "OPENROUTER_BASE_URL=http://127.0.0.1:$PORT" >> "$GITHUB_ENV" + echo "OPENROUTER_API_KEY=github-actions-oidc-relay" >> "$GITHUB_ENV" + echo "relay_pid=$(/bin/cat "$PID")" >> "$GITHUB_OUTPUT" - - name: Architecture health check (best-effort) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - id: health - continue-on-error: true + - name: Analyze (sync) + id: sync_analyze + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' shell: bash - env: - ACTION_PATH: ${{ github.action_path }} - ARTIFACT_DIR: ${{ steps.base.outputs.head_dir }} - TARGET_REPO: ${{ github.workspace }}/target-repo - REPO_NAME: ${{ github.event.repository.name }} - run: | - rm -f /tmp/cb-issues.txt - # engine_adapter writes the WARNING/CRITICAL count (0 on any failure — best-effort). - python "$ACTION_PATH/scripts/engine_adapter.py" health \ - --artifact-dir "$ARTIFACT_DIR" \ - --repo "$TARGET_REPO" \ - --name "$REPO_NAME" \ - --issues-out /tmp/cb-issues.txt || true - ISSUE_COUNT=$(cat /tmp/cb-issues.txt 2>/dev/null || echo 0) - echo "issues=$ISSUE_COUNT" >> $GITHUB_OUTPUT - echo "Architecture issues: $ISSUE_COUNT" - - # ---- Sync mode: analyze the target commit (mirrors the review pipeline's - # baseline/seed/incremental strategy, but against the committed analysis.json - # in the working tree instead of a PR comparison branch). ---- - - - name: Seed sync workdir from committed baseline - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' - id: sync_seed - shell: bash - working-directory: target-repo - env: - OUTPUT_DIR: ${{ inputs.output_dir }} - ACTION_PATH: ${{ github.action_path }} run: | set -euo pipefail - ANALYSIS_DIR="${RUNNER_TEMP}/cb-sync" - RENDER_DIR="${RUNNER_TEMP}/cb-sync-render" - ARCHITECTURE_FILE="${RUNNER_TEMP}/cb-architecture.md" - rm -rf "$ANALYSIS_DIR" "$RENDER_DIR" - mkdir -p "$ANALYSIS_DIR" "$RENDER_DIR" - { - echo "cb_dir=$ANALYSIS_DIR" - echo "render_dir=$RENDER_DIR" - echo "arch_file=$ARCHITECTURE_FILE" - } >> "$GITHUB_OUTPUT" - - # Git-free baseline: the committed .codeboarding/ is self-contained - # (analysis.json for component ids + fingerprint.json for change detection, - # plus the optional static_analysis.pkl warm-start the prior sync committed). - # Incremental needs analysis.json AND fingerprint.json; a missing pkl only - # costs a cold LSP pass, so it doesn't gate baseline-present. - if [ -f "$OUTPUT_DIR/analysis.json" ] && [ -f "$OUTPUT_DIR/fingerprint.json" ]; then - cp "$OUTPUT_DIR/analysis.json" "$ANALYSIS_DIR/analysis.json" - cp "$OUTPUT_DIR/fingerprint.json" "$ANALYSIS_DIR/fingerprint.json" - if [ -f "$OUTPUT_DIR/static_analysis.pkl" ] && [ -f "$OUTPUT_DIR/static_analysis.sha" ]; then - cp "$OUTPUT_DIR/static_analysis.pkl" "$ANALYSIS_DIR/static_analysis.pkl" - cp "$OUTPUT_DIR/static_analysis.sha" "$ANALYSIS_DIR/static_analysis.sha" - fi - echo "baseline_present=true" >> "$GITHUB_OUTPUT" - echo "Using committed baseline (analysis.json + fingerprint.json)." - else - echo "baseline_present=false" >> "$GITHUB_OUTPUT" - echo "No committed baseline (analysis.json + fingerprint.json); a full analysis will run." - fi - # Git-free sync needs no baseline-SHA worktree seed: the committed - # fingerprint.json is the change-detection baseline and the committed - # static_analysis.pkl (staged by the prior sync) is the warm-start cache. - # The first-ever run has no committed baseline and runs full, which writes - # all three artifacts for the next run to reuse. + parse_value() { + local key="$1" + local text="$2" + printf '%s\n' "$text" | awk -F= -v key="$key" '$1 == key {print $2; exit}' + } - - name: Analyze target repository (sync) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' - id: sync_analyze - shell: bash - env: - DIAGRAM_DEPTH_LEVEL: ${{ steps.resolve_depth.outputs.depth }} - CACHING_DOCUMENTATION: 'false' - ENABLE_MONITORING: 'false' - ACTION_PATH: ${{ github.action_path }} - TARGET_REPO: ${{ github.workspace }}/target-repo - ANALYSIS_DIR: ${{ steps.sync_seed.outputs.cb_dir }} - REPO_NAME: ${{ github.event.repository.name }} - RUN_ID_SYNC: ${{ github.run_id }}-${{ github.run_attempt }}-sync - TARGET_SHA: ${{ steps.guard.outputs.target_sha }} - DEPTH: ${{ steps.resolve_depth.outputs.depth }} - FORCE_FULL: ${{ inputs.force_full }} - CRED_MODE: ${{ steps.llm.outputs.mode }} - run: | - set -euo pipefail - # Export the key under the selected provider's env var (only this one), - # so the engine auto-selects that provider. - PROVIDER_ENV="$(cat "${RUNNER_TEMP}/cb-provider-env")" - export "$PROVIDER_ENV"="$(cat "${RUNNER_TEMP}/cb-llm-key")" - # Hosted modes (license/oidc) point the engine at the CodeBoarding proxy; - # absent in BYO-key mode, where the engine talks to the provider directly. - if [ -f "${RUNNER_TEMP}/cb-base-url" ]; then - export OPENROUTER_BASE_URL="$(cat "${RUNNER_TEMP}/cb-base-url")" + DEPTH_INPUT='${{ inputs.depth_level }}' + if [ -n "$DEPTH_INPUT" ] && ! [[ "$DEPTH_INPUT" =~ ^[0-9]+$ ]]; then + echo "::error::depth_level must be an integer." && exit 1 fi - # The engine_adapter drops this sentinel on a 402 (free-tier cap reached) - # so the failure-comment step can post a tailored message. - export CB_QUOTA_SENTINEL="${RUNNER_TEMP}/cb-quota-exhausted" - # Export the model env only when the user set it; empty -> the engine uses - # its own valid per-provider default (no stale hardcoded model id to rot). - AGENT_MODEL="$(cat "${RUNNER_TEMP}/cb-agent-model")" - PARSING_MODEL="$(cat "${RUNNER_TEMP}/cb-parsing-model")" - if [ -n "$AGENT_MODEL" ]; then export AGENT_MODEL; fi - if [ -n "$PARSING_MODEL" ]; then export PARSING_MODEL; fi - - args=( - --repo "$TARGET_REPO" - --out "$ANALYSIS_DIR" - --name "$REPO_NAME" - --run-id "$RUN_ID_SYNC" - --source-sha "$TARGET_SHA" - --depth "$DEPTH" - ) - if [ "$CRED_MODE" = "byokey" ] || [ "$CRED_MODE" = "license" ]; then - args+=(--licensed) + + ACTION_PATH="${{ github.action_path }}" + WORK="${RUNNER_TEMP}/cb-sync" + ANALYSIS_DIR="$WORK/analysis" + rm -rf "$WORK" && mkdir -p "$ANALYSIS_DIR" + + BASELINE_DEPTH="" + if [ -f "${GITHUB_WORKSPACE}/.codeboarding/analysis.json" ]; then + BASELINE_DEPTH="$(python3 - <<'PY' "${GITHUB_WORKSPACE}/.codeboarding/analysis.json") +import json, sys +try: + data = json.loads(open(sys.argv[1]).read()) + print(data.get('metadata', {}).get('depth_level', '')) +except Exception: + print('') +PY +" fi - [ "$FORCE_FULL" = "true" ] && args+=(--force-full) - LOG="${RUNNER_TEMP}/cb-sync-analyze.log" - python "$ACTION_PATH/scripts/engine_adapter.py" analyze "${args[@]}" | tee "$LOG" - MODE="$(sed -n 's/^analysis_mode=//p' "$LOG" | tail -1)" - MODE="${MODE:-unknown}" - echo "analysis_mode=$MODE" >> "$GITHUB_OUTPUT" - echo "analysis_path=$ANALYSIS_DIR/analysis.json" >> "$GITHUB_OUTPUT" - [ -f "$ANALYSIS_DIR/analysis.json" ] || { echo "::error::Analysis ran but analysis.json is missing."; exit 1; } - - name: Drop LLM key material - if: always() && steps.guard.outputs.skip != 'true' - shell: bash - run: | - if [ -f "${RUNNER_TEMP}/cb-oidc-relay.pid" ]; then - kill "$(cat "${RUNNER_TEMP}/cb-oidc-relay.pid")" 2>/dev/null || true + DEPTH="${BASELINE_DEPTH:-$DEPTH_INPUT}" + DEPTH="${DEPTH:-2}" + + if [ -d "${GITHUB_WORKSPACE}/.codeboarding" ]; then + cp -a "${GITHUB_WORKSPACE}/.codeboarding/." "$ANALYSIS_DIR/" 2>/dev/null || true fi - rm -f "${RUNNER_TEMP}/cb-llm-key" \ - "${RUNNER_TEMP}/cb-provider-env" \ - "${RUNNER_TEMP}/cb-base-url" \ - "${RUNNER_TEMP}/cb-agent-model" \ - "${RUNNER_TEMP}/cb-parsing-model" \ - "${RUNNER_TEMP}/cb-oidc-relay.pid" \ - "${RUNNER_TEMP}/cb-oidc-relay-port" \ - "${RUNNER_TEMP}/cb-oidc-relay-license" \ - "${RUNNER_TEMP}/cb-oidc-relay.log" - - - name: Diff analyses → Mermaid - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - id: diagram - shell: bash - env: - ACTION_PATH: ${{ github.action_path }} - BASE_ANALYSIS: ${{ steps.analyze.outputs.base_analysis }} - HEAD_ANALYSIS: ${{ steps.analyze.outputs.head_analysis }} - DIRECTION: ${{ inputs.diagram_direction }} - RENDER_DEPTH: ${{ inputs.render_depth }} - CHANGED_ONLY: ${{ inputs.changed_only }} - run: | - case "$CHANGED_ONLY" in - true|false) ;; - *) echo "::error::changed_only must be 'true' or 'false'."; exit 1 ;; - esac - case "$RENDER_DEPTH" in - ''|*[!0-9]*) echo "::error::render_depth must be a positive integer."; exit 1 ;; - esac - - args=( - --base "$BASE_ANALYSIS" - --head "$HEAD_ANALYSIS" - --out "${RUNNER_TEMP}/diagram.md" - --direction "$DIRECTION" - --render-depth "$RENDER_DEPTH" - ) - [ "$CHANGED_ONLY" = "true" ] && args+=(--changed-only) - META=$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" "${args[@]}") - echo "$META" > "${RUNNER_TEMP}/diagram_meta.json" - echo "diff meta: $META" - read CHANGED_COUNT CHANGED RENDERED TRUNCATED < <(python3 -c "import json;d=json.load(open('${RUNNER_TEMP}/diagram_meta.json'));print(d['n_changed'], str(d.get('changed', d['n_changed']>0)).lower(), str(d['rendered']).lower(), str(d['truncated']).lower())") - echo "n_changed=$CHANGED_COUNT" >> $GITHUB_OUTPUT - echo "changed=$CHANGED" >> $GITHUB_OUTPUT - echo "rendered=$RENDERED" >> $GITHUB_OUTPUT - echo "truncated=$TRUNCATED" >> $GITHUB_OUTPUT - echo "diagram_md=${RUNNER_TEMP}/diagram.md" >> $GITHUB_OUTPUT - - - name: Prepare PR analysis artifact - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - id: review_artifact - shell: bash - env: - HEAD_ANALYSIS: ${{ steps.analyze.outputs.head_analysis }} - BASE_SHA: ${{ steps.guard.outputs.base_sha }} - BASELINE_SHA: ${{ steps.base.outputs.baseline_sha }} - BASELINE_COMMITTED: ${{ steps.base.outputs.committed }} - HEAD_SHA: ${{ steps.guard.outputs.head_sha }} - PR: ${{ steps.guard.outputs.pr_number }} - OWNER_REPO: ${{ github.repository }} - RUN_ID: ${{ github.run_id }} - RUN_ATTEMPT: ${{ github.run_attempt }} - run: | - set -euo pipefail - ARTIFACT_DIR="${RUNNER_TEMP}/codeboarding-pr-artifact" - ARTIFACT_NAME="codeboarding-pr-${PR}-${HEAD_SHA}" - export ARTIFACT_NAME - rm -rf "$ARTIFACT_DIR" - mkdir -p "$ARTIFACT_DIR" - cp "$HEAD_ANALYSIS" "$ARTIFACT_DIR/analysis.json" - python3 - <<'PY' > "$ARTIFACT_DIR/metadata.json" - import json, os - - baseline_committed = os.environ["BASELINE_COMMITTED"] == "true" - print(json.dumps({ - "repository": os.environ["OWNER_REPO"], - "pr": os.environ["PR"], - "pr_base_sha": os.environ["BASE_SHA"], - "base_commit_sha": os.environ["BASELINE_SHA"] if baseline_committed else None, - "base_commit_found": baseline_committed, - "head_sha": os.environ["HEAD_SHA"], - "run_id": os.environ["RUN_ID"], - "run_attempt": os.environ["RUN_ATTEMPT"], - "artifact_name": os.environ["ARTIFACT_NAME"], - }, indent=2)) - PY - echo "path=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" - echo "name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT" - - - name: Upload PR analysis artifact - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - id: upload_review_artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.review_artifact.outputs.name }} - path: ${{ steps.review_artifact.outputs.path }} - retention-days: 14 - - name: Build PR comment body - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - id: body - shell: bash - env: - # Pass event/input-derived strings as DATA (not interpolated into the script). - HEADER: ${{ inputs.comment_header }} - BASE_REF: ${{ steps.guard.outputs.base_ref }} - CTA_BASE: ${{ inputs.cta_base_url }} - OWNER_REPO: ${{ github.repository }} - ACTION_PATH: ${{ github.action_path }} - TARGET_REPO: ${{ github.workspace }}/target-repo - DIAGRAM_MD: ${{ steps.diagram.outputs.diagram_md }} - BASE_ANALYSIS: ${{ steps.analyze.outputs.base_analysis }} - HEAD_ANALYSIS: ${{ steps.analyze.outputs.head_analysis }} - RUN_ID: ${{ github.run_id }} - CHANGED_COUNT: ${{ steps.diagram.outputs.n_changed }} - CHANGED: ${{ steps.diagram.outputs.changed }} - RENDERED: ${{ steps.diagram.outputs.rendered }} - TRUNCATED: ${{ steps.diagram.outputs.truncated }} - PR: ${{ steps.guard.outputs.pr_number }} - ISSUES: ${{ steps.health.outputs.issues }} - WEBVIEW_BASE: ${{ inputs.webview_base_url }} - HEAD_SHA: ${{ steps.guard.outputs.head_sha }} - ARTIFACT_NAME: ${{ steps.review_artifact.outputs.name }} - ARTIFACT_URL: ${{ steps.upload_review_artifact.outputs.artifact-url }} - BASE_SHA: ${{ steps.guard.outputs.base_sha }} - run: | - BODY_FILE=$(mktemp) - OWNER="${OWNER_REPO%%/*}"; REPO="${OWNER_REPO##*/}" - - headline() { - if [ "$CHANGED" != "true" ]; then echo "no architectural changes"; - elif [ "$CHANGED_COUNT" = "1" ]; then echo "1 component changed"; - elif [ "$CHANGED_COUNT" = "0" ]; then echo "architecture updated"; - else echo "$CHANGED_COUNT components changed"; fi + run_inc() { + python3 "$ACTION_PATH/scripts/analyze_repository.py" incremental --checkout "$GITHUB_WORKSPACE" --output-dir "$ANALYSIS_DIR" } - # CTA footer: a hosted webview browser link backed by the uploaded PR - # artifact, editor link(s), and the ⚠️ banner. PR analysis data is not - # committed to the PR branch. - cta() { - local extra=() - # ARTIFACT_NAME presence is the readiness gate (an analysis artifact was - # uploaded for this run). The short webview link itself needs only the run id - # — the webview re-derives head/base/artifact from the run + its metadata. - if [ -n "$WEBVIEW_BASE" ] && [ -n "$ARTIFACT_NAME" ]; then - extra+=( - --webview-ready - --webview-base "$WEBVIEW_BASE" - --run-id "$RUN_ID" - ) - fi - python3 "$ACTION_PATH/scripts/build_cta.py" \ - --cta-base "$CTA_BASE" --owner "$OWNER" --repo "$REPO" --pr "$PR" \ - --repo-path "$TARGET_REPO" --issues "${ISSUES:-0}" "${extra[@]}" + run_full() { + python3 "$ACTION_PATH/scripts/analyze_repository.py" full --checkout "$GITHUB_WORKSPACE" --output-dir "$ANALYSIS_DIR" --depth-level "$DEPTH" } - # Per-component changed-file dropdowns: which files made each node change - # color. The git listing is the PR's own changes (three-dot merge-base..head, - # the same set as the Files-changed tab; --no-renames so a moved file's old - # path still appears for the donor component). Node colors compare against - # the target branch tip, so a node colored only by target-branch churn on a stale PR - # gets no dropdown. Best-effort: if the git diff fails (e.g. merge-base - # unreachable on a shallow fork fetch) the script falls back to - # analysis-derived changes, and if the script fails the section is omitted — - # the comment always posts. - FILES_MD="${RUNNER_TEMP}/component_files.md" - : > "$FILES_MD" - if [ -n "$BASE_ANALYSIS" ] && [ -n "$HEAD_ANALYSIS" ]; then - CHANGED_LIST="${RUNNER_TEMP}/changed_files.txt" - files_args=(--base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" --out "$FILES_MD") - if git -C "$TARGET_REPO" -c core.quotepath=off diff --no-renames --name-only "$BASE_SHA...HEAD" > "$CHANGED_LIST" 2>/dev/null; then - files_args+=(--changed-files "$CHANGED_LIST") - fi - python3 "$ACTION_PATH/scripts/build_component_files.py" "${files_args[@]}" || : > "$FILES_MD" + OUT="$(run_inc)" + MODE="$(parse_value analysis_mode "$OUT")" + NEED_FULL="$(parse_value requires_full_analysis "$OUT")" + ANALYSIS_PATH="$(parse_value analysis_path "$OUT")" + + if [ -z "$MODE" ] || [ -z "$ANALYSIS_PATH" ] || [ "$MODE" != "incremental" ] && [ "$MODE" != "full" ]; then + echo "$OUT" + echo "::error::Could not parse incremental output contract." && exit 1 fi - { - echo "### ${HEADER} · $(headline)" - echo "" - if [ "$RENDERED" = "true" ]; then - cat "$DIAGRAM_MD" - echo "" - echo "" - echo "Colors indicate component changes compared to target branch \`${BASE_REF}\`: 🟩 Added · 🟨 Modified · 🟥 Removed" - if [ "$TRUNCATED" = "true" ]; then - echo "" - echo "Showing changed components only — the full graph exceeds GitHub's inline Mermaid limit." - fi - elif [ "$CHANGED" = "true" ]; then - echo "Architecture changed versus target branch \`${BASE_REF}\`, but the diagram is too large to render inline (GitHub caps inline Mermaid at ~500 edges)." - else - echo "No architectural changes detected versus target branch \`${BASE_REF}\`." + if [ "$NEED_FULL" = "true" ]; then + rm -rf "$ANALYSIS_DIR" && mkdir -p "$ANALYSIS_DIR" + OUT="$(run_full)" + MODE="$(parse_value analysis_mode "$OUT")" + NEED_FULL="$(parse_value requires_full_analysis "$OUT")" + ANALYSIS_PATH="$(parse_value analysis_path "$OUT")" + if [ -z "$MODE" ] || [ -z "$ANALYSIS_PATH" ] || [ "$MODE" != "full" ]; then + echo "$OUT" + echo "::error::Could not parse full output contract." && exit 1 fi - if [ -n "$ARTIFACT_URL" ]; then - echo "" - echo "Download the PR analysis artifacts from this workflow [artifact](${ARTIFACT_URL})." - fi - if [ -s "$FILES_MD" ]; then - echo "" - cat "$FILES_MD" - fi - cta - echo "" - echo "codeboarding-action · run ${RUN_ID}" - } > "$BODY_FILE" - - echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT" - - # `id` so the failure paths below can tell "the review never got posted" from - # "the review posted fine and something after it broke". They share this step's - # sticky header, so without that distinction they REPLACE a good review. - - name: Post sticky PR comment - id: review_comment - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: ${{ steps.guard.outputs.sticky_header }} - number: ${{ steps.guard.outputs.pr_number }} - path: ${{ steps.body.outputs.body_file }} - GITHUB_TOKEN: ${{ inputs.github_token }} + fi - # ---- Sync mode: render and commit the architecture. ---- + echo "analysis_mode=$MODE" >> "$GITHUB_OUTPUT" + echo "analysis_path=$ANALYSIS_PATH" >> "$GITHUB_OUTPUT" + echo "analysis_depth=$DEPTH" >> "$GITHUB_OUTPUT" + echo "analysis_dir=$ANALYSIS_DIR" >> "$GITHUB_OUTPUT" - - name: Render docs - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' + - name: Render sync docs id: sync_render + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' shell: bash - env: - ACTION_PATH: ${{ github.action_path }} - ANALYSIS_DIR: ${{ steps.sync_seed.outputs.cb_dir }} - RENDER_DIR: ${{ steps.sync_seed.outputs.render_dir }} - ARCHITECTURE_FILE: ${{ steps.sync_seed.outputs.arch_file }} - REPO_NAME: ${{ github.event.repository.name }} - REPOSITORY: ${{ github.repository }} - TARGET_BRANCH: ${{ steps.guard.outputs.target_branch }} - OUTPUT_DIR: ${{ inputs.output_dir }} - OUTPUT_FORMAT: ${{ inputs.output_format }} - WRITE_ARCHITECTURE_MD: ${{ inputs.write_architecture_md }} run: | set -euo pipefail - REPO_REF="https://github.com/${REPOSITORY}/blob/${TARGET_BRANCH}/${OUTPUT_DIR}" - python "$ACTION_PATH/scripts/engine_adapter.py" render \ - --analysis "$ANALYSIS_DIR/analysis.json" \ - --out "$RENDER_DIR" \ - --repo-name "$REPO_NAME" \ - --repo-ref "$REPO_REF" \ - --format "$OUTPUT_FORMAT" - if [ "$WRITE_ARCHITECTURE_MD" = "true" ]; then - python "$ACTION_PATH/scripts/engine_adapter.py" concat \ - --docs-dir "$RENDER_DIR" \ - --out "$ARCHITECTURE_FILE" - fi - echo "docs_dir=$RENDER_DIR" >> "$GITHUB_OUTPUT" - echo "architecture_file=$ARCHITECTURE_FILE" >> "$GITHUB_OUTPUT" - - # Sync no longer saves the static-analysis pkl to the Actions cache: it is - # committed back to .codeboarding/ alongside analysis.json + fingerprint.json, - # a more durable cross-runner baseline than the ephemeral, SHA-keyed cache. - - - name: Commit and push synced architecture - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' + ACTION_PATH="${{ github.action_path }}" + DOC_DIR="${RUNNER_TEMP}/cb-sync-docs" + rm -rf "$DOC_DIR" + python3 "$ACTION_PATH/scripts/render_sync_docs.py" \ + --analysis "${{ steps.sync_analyze.outputs.analysis_path }}" \ + --output-dir "$DOC_DIR" \ + --repo-name "${{ github.repository }}" \ + --repo-ref "${{ steps.guard.outputs.target_sha }}" \ + --format ".md" \ + --architecture-file "${GITHUB_WORKSPACE}/docs/development/architecture.md" + echo "docs_dir=$DOC_DIR" >> "$GITHUB_OUTPUT" + + - name: Commit sync artifacts id: sync_commit + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' shell: bash - working-directory: target-repo env: - ANALYSIS_DIR: ${{ steps.sync_seed.outputs.cb_dir }} - DOCS_DIR: ${{ steps.sync_render.outputs.docs_dir }} - ARCHITECTURE_FILE: ${{ steps.sync_render.outputs.architecture_file }} - OUTPUT_DIR: ${{ inputs.output_dir }} - OUTPUT_FORMAT: ${{ inputs.output_format }} - WRITE_ARCHITECTURE_MD: ${{ inputs.write_architecture_md }} - COMMIT_MESSAGE: ${{ inputs.commit_message }} + ANALYSIS_PATH: ${{ steps.sync_analyze.outputs.analysis_path }} + ANALYSIS_DIR: ${{ steps.sync_analyze.outputs.analysis_dir }} + DOC_DIR: ${{ steps.sync_render.outputs.docs_dir }} TARGET_BRANCH: ${{ steps.guard.outputs.target_branch }} - PUSH_TOKEN: ${{ inputs.push_token }} - REPOSITORY: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - SYNC_STRATEGY: ${{ inputs.sync_strategy }} + STRATEGY: ${{ steps.guard.outputs.sync_strategy }} SYNC_PR_BRANCH: ${{ inputs.sync_pr_branch }} - SYNC_PR_TITLE: ${{ inputs.sync_pr_title }} - # The source commit that was analyzed (recorded in the rolling PR body so - # reviewers see exactly which revision the baseline describes). - ANALYZED_SHA: ${{ steps.guard.outputs.target_sha }} - # gh (PR create/update) authenticates via GH_TOKEN. Reuse push_token so the - # branch push and the PR use one identity; it must carry pull-requests: write - # in pull_request strategy. Unused by the "push" strategy. - GH_TOKEN: ${{ inputs.push_token }} + TARGET_SHA: ${{ steps.guard.outputs.target_sha }} + COMMIT_MESSAGE: ${{ steps.guard.outputs.commit_message }} run: | set -euo pipefail - echo "committed=false" >> "$GITHUB_OUTPUT" - echo "files_written=0" >> "$GITHUB_OUTPUT" - [ -n "$PUSH_TOKEN" ] && echo "::add-mask::$PUSH_TOKEN" - AUTH_URL="https://x-access-token:${PUSH_TOKEN}@${SERVER_URL#https://}/${REPOSITORY}.git" - - # The pull_request strategy rebuilds a machine-owned rolling branch on the - # LIVE target_branch tip each run (not the analyzed checkout), so the PR - # diff is always exactly the generated baseline — never a stale merge-base - # that would show conflicts once a prior sync PR has merged. The no-op gate - # below then compares against live target_branch too. The "push" strategy - # keeps committing on the analyzed checkout (github.sha) and fast-forwarding - # target_branch. The tree is clean here (analysis writes to a scratch dir), - # so resetting to the fetched tip cannot clobber local work. - SYNC_TIP="" - if [ "$SYNC_STRATEGY" = "pull_request" ]; then - if ! git fetch "$AUTH_URL" "$TARGET_BRANCH"; then - echo "::warning::Could not fetch ${TARGET_BRANCH} to rebuild ${SYNC_PR_BRANCH}; skipping this run. The next repository change can regenerate." - exit 0 - fi - # If target_branch advanced PAST the commit we analyzed (a newer push - # landed during this run), the push that caused it has its OWN queued sync - # run. Publishing here would put a baseline describing ANALYZED_SHA on top - # of a newer tip — a rolling PR whose baseline doesn't match its base. - # Defer to the newer run instead. Gated on ANALYZED_SHA being a strict - # ANCESTOR of the tip (a genuine forward move on this branch), so a - # target_branch deliberately decoupled from the triggering ref still - # publishes rather than silently deferring on every run. - BASE_TIP="$(git rev-parse FETCH_HEAD)" - if [ -n "$ANALYZED_SHA" ] && [ "$BASE_TIP" != "$ANALYZED_SHA" ] \ - && git merge-base --is-ancestor "$ANALYZED_SHA" "$BASE_TIP" 2>/dev/null; then - echo "::notice::${TARGET_BRANCH} advanced past the analyzed commit ${ANALYZED_SHA} (tip is now ${BASE_TIP}); deferring to the queued run for the newer commit." - exit 0 - fi - # Record the sync branch tip BEFORE we rebuild it, to lease the later - # force-push against it (see the push below). Empty when the branch does - # not exist yet (first run) — then the push plain-creates it. - SYNC_TIP="$(git ls-remote "$AUTH_URL" "refs/heads/${SYNC_PR_BRANCH}" 2>/dev/null | awk '{print $1; exit}')" - git checkout -B "$SYNC_PR_BRANCH" FETCH_HEAD - fi - # Replace the previously committed rendered docs wholesale: stale files - # (components that no longer exist) must be deleted, not left behind. - shopt -s nullglob - old_md=() - if [ -d "$OUTPUT_DIR" ]; then - while IFS= read -r -d '' path; do - if git ls-files --error-unmatch "$path" >/dev/null 2>&1; then - old_md+=("$path") - fi - done < <(find "$OUTPUT_DIR" -maxdepth 1 -type f -name "*${OUTPUT_FORMAT}" -print0) - find "$OUTPUT_DIR" -maxdepth 1 -type f -name "*${OUTPUT_FORMAT}" -delete - fi + OUTPUT_DIR="${GITHUB_WORKSPACE}/.codeboarding" + push_token="${{ inputs.push_token }}" + REPO="${{ github.repository }}" + rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" "$OUTPUT_DIR/health" - rendered=("$DOCS_DIR"/*"$OUTPUT_FORMAT") - if [ "${#rendered[@]}" -eq 0 ]; then - echo "::error::No rendered docs found in $DOCS_DIR." - exit 1 - fi - cp "${rendered[@]}" "$OUTPUT_DIR/" - cp "$ANALYSIS_DIR/analysis.json" "$OUTPUT_DIR/analysis.json" - # Commit the whole-tree fingerprint so the next sync's git-free incremental - # has its change-detection baseline (analysis.json alone can't drive it). - if [ -f "$ANALYSIS_DIR/fingerprint.json" ]; then - cp "$ANALYSIS_DIR/fingerprint.json" "$OUTPUT_DIR/fingerprint.json" - else - rm -f "$OUTPUT_DIR/fingerprint.json" - fi - if [ -f "$ANALYSIS_DIR/static_analysis.pkl" ] && [ -f "$ANALYSIS_DIR/static_analysis.sha" ]; then - cp "$ANALYSIS_DIR/static_analysis.pkl" "$OUTPUT_DIR/static_analysis.pkl" - cp "$ANALYSIS_DIR/static_analysis.sha" "$OUTPUT_DIR/static_analysis.sha" - else - rm -f "$OUTPUT_DIR/static_analysis.pkl" "$OUTPUT_DIR/static_analysis.sha" - fi - # Core 0.13.0 no longer emits the legacy version sidecar. Remove a stale - # tracked copy while migrating the accompanying analysis.json format. - if [ -f "$ANALYSIS_DIR/codeboarding_version.json" ]; then - cp "$ANALYSIS_DIR/codeboarding_version.json" "$OUTPUT_DIR/codeboarding_version.json" - else - rm -f "$OUTPUT_DIR/codeboarding_version.json" - fi - if [ -f "$ANALYSIS_DIR/health/health_report.json" ]; then - cp "$ANALYSIS_DIR/health/health_report.json" "$OUTPUT_DIR/health/health_report.json" - fi - if [ "$WRITE_ARCHITECTURE_MD" = "true" ]; then - mkdir -p docs/development - cp "$ARCHITECTURE_FILE" docs/development/architecture.md - fi - new_md=("$OUTPUT_DIR"/*"$OUTPUT_FORMAT") - stage_paths=("$OUTPUT_DIR/analysis.json") - add_if_present_or_tracked() { - if [ -e "$1" ] || git ls-files --error-unmatch "$1" >/dev/null 2>&1; then - stage_paths+=("$1") - fi - } - add_if_present_or_tracked "$OUTPUT_DIR/fingerprint.json" - add_if_present_or_tracked "$OUTPUT_DIR/static_analysis.pkl" - add_if_present_or_tracked "$OUTPUT_DIR/static_analysis.sha" - add_if_present_or_tracked "$OUTPUT_DIR/codeboarding_version.json" - [ -f "$OUTPUT_DIR/health/health_report.json" ] && stage_paths+=("$OUTPUT_DIR/health/health_report.json") - if [ "$WRITE_ARCHITECTURE_MD" = "true" ]; then - stage_paths+=("docs/development/architecture.md") - fi - stage_paths+=("${old_md[@]}" "${new_md[@]}") - git add -f -A -- "${stage_paths[@]}" - - # A no-op means target_branch already carries the current baseline. In - # pull_request strategy that can happen while a rolling PR is still open — - # e.g. a source change was reverted before its baseline PR merged — leaving - # that PR proposing an OBSOLETE delta that must not stay mergeable. Deleting - # the remote branch auto-closes the PR; the next real change recreates both. - # Fail-open; a no-op in push strategy or when no sync branch exists. - reconcile_stale_sync_pr() { - [ "$SYNC_STRATEGY" = "pull_request" ] || return 0 - # Only remove the branch as we OBSERVED it at the start of this run - # (SYNC_TIP). If the remote tip has since changed, a concurrent newer run - # (when the caller omits the required concurrency group) already pushed a - # valid baseline — leave it and its PR alone rather than closing it. - local current - current="$(git ls-remote "$AUTH_URL" "refs/heads/${SYNC_PR_BRANCH}" 2>/dev/null | awk '{print $1; exit}')" - [ -n "$current" ] || return 0 - if [ "$current" != "$SYNC_TIP" ]; then - echo "Skipping reconcile: ${SYNC_PR_BRANCH} advanced since this run started; a newer run owns it." - return 0 - fi - if git push "$AUTH_URL" --delete "refs/heads/${SYNC_PR_BRANCH}" >/dev/null 2>&1; then - echo "Reconciled: deleted ${SYNC_PR_BRANCH} to close a now-obsolete sync PR (${TARGET_BRANCH} already current)." - else - echo "::warning::Could not delete ${SYNC_PR_BRANCH} to close a stale sync PR; it may need manual closing." - fi - } + cp -a "$DOC_DIR"/*.md "$OUTPUT_DIR/" 2>/dev/null || true + cp "$ANALYSIS_PATH" "$OUTPUT_DIR/analysis.json" + [ -f "$ANALYSIS_DIR/fingerprint.json" ] && cp "$ANALYSIS_DIR/fingerprint.json" "$OUTPUT_DIR/fingerprint.json" + [ -f "$ANALYSIS_DIR/static_analysis.pkl" ] && cp "$ANALYSIS_DIR/static_analysis.pkl" "$OUTPUT_DIR/static_analysis.pkl" + [ -f "$ANALYSIS_DIR/static_analysis.sha" ] && cp "$ANALYSIS_DIR/static_analysis.sha" "$OUTPUT_DIR/static_analysis.sha" + [ -f "$ANALYSIS_DIR/health/health_report.json" ] && cp "$ANALYSIS_DIR/health/health_report.json" "$OUTPUT_DIR/health/health_report.json" + + git config user.name "codeboarding-review[bot]" + git config user.email "codeboarding-review[bot]@users.noreply.github.com" + git add "$OUTPUT_DIR" 2>/dev/null || true if git diff --cached --quiet; then - echo "::notice::Generated architecture is unchanged; nothing to commit." - reconcile_stale_sync_pr - echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" + echo "::notice::Generated architecture is unchanged." + echo "files_written=0" >> "$GITHUB_OUTPUT" + echo "committed=false" >> "$GITHUB_OUTPUT" exit 0 fi + if git diff --cached --quiet -I '"generated_at"' -I '"timestamp"'; then git reset -q - echo "::notice::Only volatile timestamp fields changed; skipping commit." - reconcile_stale_sync_pr - echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" + echo "::notice::Only timestamp-only changes were detected." + echo "files_written=0" >> "$GITHUB_OUTPUT" + echo "committed=false" >> "$GITHUB_OUTPUT" exit 0 fi - git config user.name "codeboarding-review[bot]" - git config user.email "codeboarding-review[bot]@users.noreply.github.com" - git commit -m "$COMMIT_MESSAGE" >/dev/null + shopt -s nullglob + FILES=("$OUTPUT_DIR"/*.md) + N_FILES="${#FILES[@]}" - # ---- Deliver: pull_request strategy — force-push the rolling branch and - # ensure exactly one open PR into target_branch. ---- - if [ "$SYNC_STRATEGY" = "pull_request" ]; then - NEW_SHA="$(git rev-parse HEAD)" - # Push the machine-owned rolling branch, leased so a concurrent run (when - # the caller omits the required serializing concurrency group) is never - # silently clobbered — the losing push fails and we fail open, letting the - # next change reconcile. If the branch existed at the start of this step - # (SYNC_TIP), force-with-lease against that exact tip. If it did NOT exist, - # a plain (non-force) create is itself a lease: it fails if a concurrent - # first run created the branch first. - if [ -n "$SYNC_TIP" ]; then - push_args=(--force-with-lease="refs/heads/${SYNC_PR_BRANCH}:${SYNC_TIP}") - else - push_args=() + if [ "$STRATEGY" = "pull_request" ]; then + AUTH_URL="https://x-access-token:${push_token}@github.com/${REPO}.git" + if ! git fetch "$AUTH_URL" "$TARGET_BRANCH"; then + echo "::warning::Could not fetch $TARGET_BRANCH." + echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" + echo "committed=false" >> "$GITHUB_OUTPUT" + exit 0 fi - # `${push_args[@]+...}` guards the empty-array case: a bare "${push_args[@]}" - # aborts under `set -u` on bash < 4.4 (macOS-hosted / minimal-container - # runners), which would break the fail-open contract on a first run. - if ! git push ${push_args[@]+"${push_args[@]}"} "$AUTH_URL" "HEAD:refs/heads/${SYNC_PR_BRANCH}"; then - echo "::warning::Could not update ${SYNC_PR_BRANCH} (a concurrent run may have pushed a newer baseline, or contents: write is missing). The next repository change can regenerate." - echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" + + BASE_TIP="$(git rev-parse FETCH_HEAD)" + if [ -n "$TARGET_SHA" ] && [ "$BASE_TIP" != "$TARGET_SHA" ] && git merge-base --is-ancestor "$TARGET_SHA" "$BASE_TIP" 2>/dev/null; then + echo "::notice::Target branch advanced; skipping to newer run." + echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" + echo "committed=false" >> "$GITHUB_OUTPUT" exit 0 fi - echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" - echo "Pushed baseline to ${SYNC_PR_BRANCH} as ${NEW_SHA}." - - # PR body records the analyzed source revision so reviewers see exactly - # which commit the baseline describes. Kept current on every run. - SRC_SHORT="$(git rev-parse --short "$ANALYZED_SHA" 2>/dev/null || printf '%s' "${ANALYZED_SHA:0:7}")" - SRC_SUBJECT="$(git log -1 --format=%s "$ANALYZED_SHA" 2>/dev/null || true)" - SRC_SUMMARY="$(printf '%s' "${SRC_SUBJECT:0:20}" | sed 's/[[:space:]]*$//')" - PR_TITLE="${SYNC_PR_TITLE} ${SRC_SHORT}: ${SRC_SUMMARY}" - PR_BODY="$(printf '%s\n\n%s' \ - "Generated from source revision \`${SRC_SHORT}\`${SRC_SUBJECT:+: ${SRC_SUBJECT}} (target \`${TARGET_BRANCH}\`)." \ - "Automated CodeBoarding architecture baseline sync. Regenerated and force-updated on every push; merge it to keep the committed analysis under \`${OUTPUT_DIR}/\` current, which keeps pull-request reviews fast and incremental sync warm. \`${SYNC_PR_BRANCH}\` is machine-owned and overwritten on every run.")" - - # Ensure ONE open PR from the sync branch into target_branch. Every gh call - # fails open: an under-scoped token (no pull-requests: write) or an API - # error degrades to "branch pushed, no PR" with a warning, never a red job. - # `gh pr list --head` is a bare branch-NAME filter (no owner:branch), so a - # fork PR that happens to use the same head branch name could match; the - # isCrossRepository==false filter keeps this to OUR own sync branch's PR so - # we never edit a contributor's PR body. - own_open_sync_pr() { - gh pr list --repo "$REPOSITORY" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --state open \ - --json number,isCrossRepository --jq 'map(select(.isCrossRepository == false))[0].number // empty' 2>/dev/null || true - } - pr_number="$(own_open_sync_pr)" - if [ -n "$pr_number" ]; then - # Existing rolling PR — refresh its title and body to the current - # source revision. - gh pr edit "$pr_number" --repo "$REPOSITORY" --title "$PR_TITLE" --body "$PR_BODY" >/dev/null 2>&1 || true - pr_url="$(gh pr view "$pr_number" --repo "$REPOSITORY" --json url --jq '.url' 2>/dev/null || true)" + + SYNC_TIP="$(git ls-remote "$AUTH_URL" "refs/heads/${SYNC_PR_BRANCH}" | awk '{print $1; exit}')" + git checkout -B "$SYNC_PR_BRANCH" FETCH_HEAD + + if [ -n "$SYNC_TIP" ]; then + PUSH_ARGS=(--force-with-lease="refs/heads/${SYNC_PR_BRANCH}:${SYNC_TIP}") else - # No open PR yet — create one. A dedup error from an eventually-consistent - # replica ("already exists" / 422) is success: re-query for the open PR. - pr_url="$(gh pr create --repo "$REPOSITORY" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --title "$PR_TITLE" --body "$PR_BODY" 2>/dev/null || true)" - pr_number="$(own_open_sync_pr)" - if [ -z "$pr_url" ] && [ -n "$pr_number" ]; then - pr_url="$(gh pr view "$pr_number" --repo "$REPOSITORY" --json url --jq '.url' 2>/dev/null || true)" - fi + PUSH_ARGS=() fi - if [ -n "$pr_url" ]; then - echo "committed=true" >> "$GITHUB_OUTPUT" - echo "pushed_sha=$NEW_SHA" >> "$GITHUB_OUTPUT" - echo "sync_pr_url=$pr_url" >> "$GITHUB_OUTPUT" - echo "sync_pr_number=$pr_number" >> "$GITHUB_OUTPUT" - echo "Sync PR ready: $pr_url" - else - echo "::warning::Sync branch ${SYNC_PR_BRANCH} pushed, but no PR into ${TARGET_BRANCH} could be opened or found. Ensure the token carries pull-requests: write and that the repository allows GitHub Actions to create pull requests." + + git commit -m "$COMMIT_MESSAGE" >/dev/null + if ! git push ${PUSH_ARGS[@]+"${PUSH_ARGS[@]}"} "$AUTH_URL" "HEAD:refs/heads/${SYNC_PR_BRANCH}"; then + echo "::warning::Could not update ${SYNC_PR_BRANCH}." + echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" + echo "committed=false" >> "$GITHUB_OUTPUT" + exit 0 fi + + PR_TITLE="${{ inputs.sync_pr_title }} $(git rev-parse --short "$TARGET_SHA")" + PR_URL="$(gh pr list --repo "$REPO" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --state open --json url --jq '.[0].url // empty' || true)" + if [ -z "$PR_URL" ]; then + gh pr create --repo "$REPO" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --title "$PR_TITLE" --body "CodeBoarding sync PR for ${TARGET_BRANCH}." >/dev/null 2>&1 || true + PR_URL="$(gh pr list --repo "$REPO" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --state open --json url --jq '.[0].url // empty' || true)" + fi + PR_NUM="$(gh pr list --repo "$REPO" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --state open --json number --jq '.[0].number // empty' || true)" + + echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" + echo "committed=true" >> "$GITHUB_OUTPUT" + [ -n "$PR_URL" ] && echo "sync_pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + [ -n "$PR_NUM" ] && echo "sync_pr_number=$PR_NUM" >> "$GITHUB_OUTPUT" exit 0 fi - # ---- Deliver: push strategy — fast-forward target_branch with fetch+rebase - # retries: another push may land on target_branch while the analysis runs. - # Fail-open on final rejection — the next repository change regenerates. ---- + git commit -m "$COMMIT_MESSAGE" >/dev/null + AUTH_URL="https://x-access-token:${push_token}@github.com/${REPO}.git" + for attempt in 1 2 3; do if git push "$AUTH_URL" "HEAD:refs/heads/${TARGET_BRANCH}"; then - NEW_SHA="$(git rev-parse HEAD)" + echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" echo "committed=true" >> "$GITHUB_OUTPUT" - echo "pushed_sha=$NEW_SHA" >> "$GITHUB_OUTPUT" - echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" - echo "Committed architecture to ${TARGET_BRANCH} as ${NEW_SHA}." exit 0 fi if [ "$attempt" -eq 3 ]; then - echo "::warning::Could not push architecture to ${TARGET_BRANCH}; likely missing contents: write permission or branch protection rejected the bot (use sync_strategy: pull_request for a protected branch)." - echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "::warning::Push was rejected; fetching and rebasing before retry ${attempt}." - # The checkout is credential-free (persist-credentials: false), so the - # fetch must authenticate through the same URL as the push; on private - # repos a bare `git fetch origin` would 403 and hard-fail the step. - if ! git fetch "$AUTH_URL" "$TARGET_BRANCH"; then - echo "::warning::Could not fetch ${TARGET_BRANCH} to rebase; skipping this push. The next repository change can regenerate." - echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" - exit 0 - fi - if ! git rebase FETCH_HEAD; then - git rebase --abort || true - echo "::warning::Could not rebase generated docs onto ${TARGET_BRANCH}; skipping this push. The next repository change can regenerate." - echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" + echo "::warning::Could not push sync artifacts to ${TARGET_BRANCH}." + echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" + echo "committed=false" >> "$GITHUB_OUTPUT" exit 0 fi + git fetch "$AUTH_URL" "$TARGET_BRANCH" + git rebase --ff-only FETCH_HEAD || true done - name: Write sync summary @@ -1763,88 +540,277 @@ runs: env: START_TS: ${{ steps.guard.outputs.start_ts }} MODE: ${{ steps.sync_analyze.outputs.analysis_mode }} - DEPTH: ${{ steps.resolve_depth.outputs.depth }} - FILES_WRITTEN: ${{ steps.sync_commit.outputs.files_written }} + DEPTH: ${{ steps.sync_analyze.outputs.analysis_depth }} COMMITTED: ${{ steps.sync_commit.outputs.committed }} - PUSHED_SHA: ${{ steps.sync_commit.outputs.pushed_sha }} - SYNC_STRATEGY: ${{ inputs.sync_strategy }} - SYNC_PR_URL: ${{ steps.sync_commit.outputs.sync_pr_url }} + FILES: ${{ steps.sync_commit.outputs.files_written }} + STRATEGY: ${{ steps.guard.outputs.sync_strategy }} + PR_URL: ${{ steps.sync_commit.outputs.sync_pr_url }} run: | set -euo pipefail - NOW="$(date +%s)" - # start_ts is missing when the guard hard-failed on input validation. - if [ -n "${START_TS:-}" ]; then DURATION=$((NOW - START_TS)); else DURATION=0; fi + NOW=$(date +%s) + DUR=0 + if [ -n "${START_TS:-}" ]; then DUR=$((NOW - START_TS)); fi { echo "### CodeBoarding Sync" - echo "" - echo "- Analysis mode: ${MODE:-unknown}" - echo "- Depth: $DEPTH" - echo "- Rendered markdown files: ${FILES_WRITTEN:-0}" - if [ "${SYNC_STRATEGY:-push}" = "pull_request" ]; then - echo "- Baseline PR updated: ${COMMITTED:-false}" - if [ -n "${SYNC_PR_URL:-}" ]; then - echo "- Sync PR: $SYNC_PR_URL" - fi - else - echo "- Commit pushed: ${COMMITTED:-false}" - fi - if [ -n "${PUSHED_SHA:-}" ]; then - echo "- Pushed SHA: $PUSHED_SHA" + echo "- Analysis: ${MODE}" + echo "- Depth: ${DEPTH}" + echo "- Rendered markdown files: ${FILES:-0}" + echo "- Delivered: ${COMMITTED:-false}" + echo "- Duration: ${DUR}s" + echo "- Strategy: ${STRATEGY}" + if [ -n "${PR_URL:-}" ]; then + echo "- Sync PR: ${PR_URL}" fi - echo "- Duration: ${DURATION}s" } >> "$GITHUB_STEP_SUMMARY" - # If any analysis step failed, replace the sticky comment with a short failure - # note (same header) instead of leaving the PR with nothing / a stale diagram. - # Distinguish a free-tier quota failure (engine_adapter dropped the sentinel) - # from a generic failure, so the comment can tell the user how to get more. - - name: Detect quota-exhausted failure - if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' - id: quota + - name: Analyze (review with fallback) + id: review_analyze + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' shell: bash run: | - if [ -f "${RUNNER_TEMP}/cb-quota-exhausted" ]; then - echo "exhausted=true" >> "$GITHUB_OUTPUT" - else - echo "exhausted=false" >> "$GITHUB_OUTPUT" + set -euo pipefail + + parse_value() { + local key="$1" + local text="$2" + printf '%s\n' "$text" | awk -F= -v key="$key" '$1 == key {print $2; exit}' + } + + ACTION_PATH="${{ github.action_path }}" + BASE_SHA="${{ steps.guard.outputs.base_sha }}" + HEAD_SHA="${{ steps.guard.outputs.head_sha }}" + BASE_REPO="${{ steps.guard.outputs.base_repo }}" + HEAD_REPO="${{ steps.guard.outputs.head_repo }}" + BASELINE_EXISTS="${{ steps.guard.outputs.baseline_exists }}" + DEPTH_INPUT='${{ inputs.depth_level }}' + + if [ -n "$DEPTH_INPUT" ] && ! [[ "$DEPTH_INPUT" =~ ^[0-9]+$ ]]; then + echo "::error::depth_level must be an integer." && exit 1 fi - - name: Post quota-exhausted comment - if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.quota.outputs.exhausted == 'true' && steps.review_comment.outcome != 'success' - continue-on-error: true + WORK="${RUNNER_TEMP}/cb-review" + BASE_DIR="$WORK/base" + HEAD_DIR="$WORK/head" + mkdir -p "$BASE_DIR" "$HEAD_DIR" + + ensure_commit() { + local repo="$1" + local sha="$2" + git cat-file -e "$sha^{commit}" 2>/dev/null && return 0 + git fetch "https://github.com/${repo}.git" "$sha" --depth=1 + } + + if [ -n "$BASE_SHA" ]; then + ensure_commit "$BASE_REPO" "$BASE_SHA" + git worktree add --detach "$BASE_DIR" "$BASE_SHA" >/dev/null + fi + ensure_commit "${{ github.repository }}" "$HEAD_SHA" || true + ensure_commit "$HEAD_REPO" "$HEAD_SHA" + + BASELINE_DEPTH="" + if [ -n "$BASE_SHA" ] && [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then + BASELINE_DEPTH="$(python3 - <<'PY' "$BASE_DIR/.codeboarding/analysis.json") +import json, sys +try: + data = json.loads(open(sys.argv[1]).read()) + print(data.get('metadata', {}).get('depth_level', '')) +except Exception: + print('') +PY +" + fi + + DEPTH="${BASELINE_DEPTH:-$DEPTH_INPUT}" + DEPTH="${DEPTH:-2}" + + if [ -n "$BASE_SHA" ] && [ -d "$BASE_DIR/.codeboarding" ]; then + cp -a "$BASE_DIR/.codeboarding/." "$HEAD_DIR/" + fi + + run_incremental() { + python3 "$ACTION_PATH/scripts/analyze_repository.py" incremental --checkout "$GITHUB_WORKSPACE" --output-dir "$HEAD_DIR" + } + + run_full() { + local checkout="$1" + local out="$2" + python3 "$ACTION_PATH/scripts/analyze_repository.py" full --checkout "$checkout" --output-dir "$out" --depth-level "$DEPTH" + } + + BASE_OUT="$WORK/base-full" + HAS_BASELINE="false" + if [ -n "$BASE_SHA" ] && [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then + HAS_BASELINE="true" + fi + + OUT="$(run_incremental)" + MODE="$(parse_value analysis_mode "$OUT")" + NEED_FULL="$(parse_value requires_full_analysis "$OUT")" + HEAD_PATH="$(parse_value analysis_path "$OUT")" + + if [ -z "$MODE" ] || [ -z "$HEAD_PATH" ] || [ "$MODE" != "incremental" ] && [ "$MODE" != "full" ]; then + echo "$OUT" + echo "::error::Could not parse incremental output contract." && exit 1 + fi + + if [ "$HAS_BASELINE" = "false" ] || [ "$NEED_FULL" = "true" ]; then + BASE_FULL_PATH="" + if [ -n "$BASE_SHA" ]; then + BASE_FULL="$(run_full "$BASE_DIR" "$BASE_OUT")" + BASE_FULL_PATH="$(parse_value analysis_path "$BASE_FULL")" + if [ -z "$BASE_FULL_PATH" ] || [ ! -f "$BASE_FULL_PATH" ]; then + echo "$BASE_FULL" + echo "::error::Base full analysis did not produce analysis_path." + exit 1 + fi + + rm -rf "$HEAD_DIR" && mkdir -p "$HEAD_DIR" + cp -a "$BASE_OUT/." "$HEAD_DIR/" + OUT="$(run_incremental)" + MODE="$(parse_value analysis_mode "$OUT")" + NEED_FULL="$(parse_value requires_full_analysis "$OUT")" + HEAD_PATH="$(parse_value analysis_path "$OUT")" + if [ -z "$MODE" ] || [ -z "$HEAD_PATH" ]; then + echo "$OUT" + echo "::error::Could not parse head retry output contract." && exit 1 + fi + + if [ "$NEED_FULL" = "true" ]; then + FULL_HEAD_OUT="$(run_full "$GITHUB_WORKSPACE" "$WORK/head-full")" + MODE="$(parse_value analysis_mode "$FULL_HEAD_OUT")" + HEAD_PATH="$(parse_value analysis_path "$FULL_HEAD_OUT")" + if [ -z "$MODE" ] || [ -z "$HEAD_PATH" ] || [ "$MODE" != "full" ]; then + echo "$FULL_HEAD_OUT" + echo "::error::Could not parse head full output contract." && exit 1 + fi + fi + fi + fi + + BASE_FOR_DIFF="" + if [ "$HAS_BASELINE" = "true" ]; then + BASE_FOR_DIFF="$BASE_DIR/.codeboarding/analysis.json" + elif [ -n "$BASE_FULL_PATH" ] && [ -f "$BASE_FULL_PATH" ]; then + BASE_FOR_DIFF="$BASE_FULL_PATH" + fi + + if [ -n "${BASE_FOR_DIFF:-}" ] && [ -f "$BASE_FOR_DIFF" ]; then + : + elif [ -n "$BASE_SHA" ] && [ ! -f "$BASE_FOR_DIFF" ]; then + echo "::error::Review baseline still unavailable after fallback." + exit 1 + fi + + if [ -n "$BASE_DIR" ] && git worktree list | awk '{print $1}' | grep -q "$BASE_DIR"; then + git worktree remove --force "$BASE_DIR" >/dev/null 2>&1 || true + fi + + echo "analysis_mode=$MODE" >> "$GITHUB_OUTPUT" + echo "analysis_path=$HEAD_PATH" >> "$GITHUB_OUTPUT" + echo "analysis_depth=$DEPTH" >> "$GITHUB_OUTPUT" + echo "base_analysis_path=$BASE_FOR_DIFF" >> "$GITHUB_OUTPUT" + + - name: Render review diagram + id: review_render + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + shell: bash + run: | + set -euo pipefail + ACTION_PATH="${{ github.action_path }}" + BASE_ANALYSIS="${{ steps.review_analyze.outputs.base_analysis_path }}" + HEAD_ANALYSIS="${{ steps.review_analyze.outputs.analysis_path }}" + + if [ -z "$BASE_ANALYSIS" ] || [ ! -f "$BASE_ANALYSIS" ]; then + echo "::error::Review baseline missing." + exit 1 + fi + if [ -z "$HEAD_ANALYSIS" ] || [ ! -f "$HEAD_ANALYSIS" ]; then + echo "::error::Review head analysis missing." + exit 1 + fi + + DIAGRAM_OUT="${RUNNER_TEMP}/diagram.md" + META="${RUNNER_TEMP}/diagram_meta.json" + DIFF="$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" --out "$DIAGRAM_OUT" --direction "${{ inputs.diagram_direction }}" --render-depth 1)" + printf '%s' "$DIFF" > "$META" + + N_CHANGED="$(jq -r '.n_changed' "$META")" + TRUNCATED="$(jq -r '.truncated | ascii_downcase' "$META")" + echo "diagram_md=$DIAGRAM_OUT" >> "$GITHUB_OUTPUT" + echo "n_changed=$N_CHANGED" >> "$GITHUB_OUTPUT" + echo "truncated=$TRUNCATED" >> "$GITHUB_OUTPUT" + echo "rendered=true" >> "$GITHUB_OUTPUT" + + - name: Build review artifact + id: review_artifact_inputs + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + shell: bash + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/cb-review-artifact" + cp "${{ steps.review_analyze.outputs.analysis_path }}" "${RUNNER_TEMP}/cb-review-artifact/analysis.json" + cat > "${RUNNER_TEMP}/cb-review-artifact/metadata.json" <> "$GITHUB_OUTPUT" + + - name: Upload review artifact + id: upload_review_artifact + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outputs.rendered == 'true' + uses: actions/upload-artifact@v4 + with: + name: codeboarding-review-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.review_artifact_inputs.outputs.artifact_dir }} + if-no-files-found: error + + - name: Post review comment + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outputs.rendered == 'true' uses: marocchino/sticky-pull-request-comment@v2 with: header: ${{ steps.guard.outputs.sticky_header }} number: ${{ steps.guard.outputs.pr_number }} + GITHUB_TOKEN: ${{ inputs.github_token }} message: | - ### ${{ inputs.comment_header }} · free tier limit reached + ### ${{ inputs.comment_header }} - This repository owner's **free weekly CodeBoarding usage** is used up, so the architecture diff couldn't be generated. It resets at the start of next week. + **Status:** ${{ steps.review_render.outputs.n_changed }} changed component(s) + **Mode:** `${{ steps.review_analyze.outputs.analysis_mode }}` + **Render:** `${{ steps.review_render.outputs.truncated == 'true' && 'changed-only (auto-trimmed)' || 'full top-level' }}` - For more (or unmetered) usage, add one of these repository secrets and pass it to the action: - - **`OPENROUTER_API_KEY`** — your own [OpenRouter](https://openrouter.ai) key, passed via the action's `llm_api_key` input, or - - **`CODEBOARDING_LICENSE`** — a [CodeBoarding license](https://codeboarding.org), passed via the action's `license_key` input. + ```mermaid + $(cat "${{ steps.review_render.outputs.diagram_md }}") + ``` - See the [setup guide](https://github.com/CodeBoarding/CodeBoarding-action#more-usage) for the exact workflow snippet. + **Hosted webview:** [open artifact](${{ steps.upload_review_artifact.outputs.artifact-url }}) - codeboarding-action · run ${{ github.run_id }} - GITHUB_TOKEN: ${{ inputs.github_token }} + run ${{ github.run_id }} · attempt ${{ github.run_attempt }} - # Not posted when the review itself already went out: these comments reuse the - # review's sticky header, so posting one REPLACES the architecture diff the user - # came for. A step failing after the review has been published (the walkthrough - # add-on, an artifact upload) cannot un-publish it, and must not erase it either. - - name: Post failure comment - if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.quota.outputs.exhausted != 'true' && steps.review_comment.outcome != 'success' + - name: Post review failure comment + if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outcome != 'success' continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: header: ${{ steps.guard.outputs.sticky_header }} number: ${{ steps.guard.outputs.pr_number }} + GITHUB_TOKEN: ${{ inputs.github_token }} message: | ### ${{ inputs.comment_header }} · failed - The architecture diff couldn't be generated for this run. See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. + The review run could not generate a diagram. See workflow logs: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - codeboarding-action · run ${{ github.run_id }} - GITHUB_TOKEN: ${{ inputs.github_token }} + run ${{ github.run_id }} + + - name: Stop OIDC relay and remove relay state + if: always() && steps.relay.outputs.relay_pid != '' + shell: bash + run: | + set -euo pipefail + PID="${{ steps.relay.outputs.relay_pid }}" + [ -n "$PID" ] && kill "$PID" 2>/dev/null || true + rm -rf "${RUNNER_TEMP}/codeboarding-relay" diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py new file mode 100755 index 0000000..ea75a55 --- /dev/null +++ b/scripts/analyze_repository.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Thin helper to execute CodeBoarding CLI incremental/full commands. + +The action is intentionally logic-light: all analysis orchestration happens in +shell through this script's small JSON contract parser, which only invokes +CodeBoarding's own ``incremental`` and ``full`` commands. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +PROG = "codeboarding" + + +class AnalysisError(RuntimeError): + pass + + +def _parse_bool(value: object, *, field: str) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "y"}: + return True + if lowered in {"false", "0", "no", "n"}: + return False + raise AnalysisError(f"Invalid contract field '{field}': {value!r}") + + +def _normalize_analysis_path(payload: dict, output_dir: str) -> Path: + path = payload.get("analysis_path") + if not isinstance(path, str) or not path.strip(): + raise AnalysisError("Missing or empty 'analysis_path' in CLI response") + + candidate = Path(path) + if not candidate.is_absolute(): + candidate = Path(output_dir) / candidate + return candidate + + +def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path, dict]: + if not raw.strip(): + raise AnalysisError("CodeBoarding command produced no JSON output") + + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise AnalysisError(f"Invalid CodeBoarding JSON response: {exc}") from exc + + if not isinstance(payload, dict): + raise AnalysisError("CodeBoarding JSON response is not an object") + + requires_full = _parse_bool(payload.get("requiresFullAnalysis"), field="requiresFullAnalysis") + analysis_path = _normalize_analysis_path(payload, output_dir) + + if not analysis_path.is_file(): + raise AnalysisError(f"analysis_path points to a non-file: {analysis_path}") + + return requires_full, analysis_path, payload + + +def _run_command(args: list[str], output_dir: Path) -> str: + try: + completed = subprocess.run( + args, + check=True, + capture_output=True, + text=True, + cwd=str(output_dir.parent), + env=None, + ) + except subprocess.CalledProcessError as exc: + stdout = (exc.stdout or "").strip() + stderr = (exc.stderr or "").strip() + details = (stderr or stdout or "no command output").strip() + raise AnalysisError(f"Command failed ({' '.join(args)}): {details}") from exc + + return completed.stdout + + +def run_incremental(checkout: Path, output_dir: Path) -> tuple[bool, Path, dict]: + output_dir.mkdir(parents=True, exist_ok=True) + raw = _run_command([PROG, "incremental", "--local", str(checkout), "--output-dir", str(output_dir)], output_dir) + return _parse_cli_response(raw, str(output_dir)) + + +def run_full(checkout: Path, output_dir: Path, depth_level: str) -> tuple[bool, Path, dict]: + output_dir.mkdir(parents=True, exist_ok=True) + raw = _run_command( + [ + PROG, + "full", + "--local", + str(checkout), + "--output-dir", + str(output_dir), + "--depth-level", + str(depth_level), + "--force", + ], + output_dir, + ) + return _parse_cli_response(raw, str(output_dir)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=["incremental", "full"], help="Which CLI command to invoke") + parser.add_argument("--checkout", required=True, help="Path to repository checkout") + parser.add_argument("--output-dir", required=True, help="Action-owned output directory") + parser.add_argument("--depth-level", help="Depth passed to full analyses") + + args = parser.parse_args(argv) + checkout = Path(args.checkout) + output_dir = Path(args.output_dir) + if not checkout.is_dir(): + raise SystemExit(f"Missing checkout directory: {checkout}") + + if args.mode == "incremental": + requires_full, analysis_path, _ = run_incremental(checkout, output_dir) + print(f"analysis_mode=incremental") + print(f"requires_full_analysis={str(requires_full).lower()}") + print(f"analysis_path={analysis_path}") + return 0 + + if not args.depth_level: + raise SystemExit("--depth-level is required for mode=full") + requires_full, analysis_path, _ = run_full(checkout, output_dir, args.depth_level) + print(f"analysis_mode=full") + print("requires_full_analysis=false") + print(f"analysis_path={analysis_path}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except AnalysisError as exc: + print(f"::error::{exc}") + raise SystemExit(1) diff --git a/scripts/build_component_files.py b/scripts/build_component_files.py deleted file mode 100644 index 8939d73..0000000 --- a/scripts/build_component_files.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Render per-component changed-file dropdowns for the sticky PR comment. - -Takes the same base/head ``analysis.json`` pair as ``diff_to_mermaid.py`` (the -diff logic is imported from there, so the dropdowns and the diagram always -agree on what changed) and emits one collapsed ``
`` block per changed -top-level component, listing the files that made it change color — the question -the colored diagram raises but can't answer. - -Which files count as "changed" for a component: - - * With ``--changed-files`` (a ``git diff --no-renames --name-only`` listing of - the PR's own changes, merge-base..head — the same set as the Files-changed - tab): the intersection of the component subtree's file paths with that - listing. A component can own 40 files while the PR touched 2 — listing all - 40 would answer the wrong question. ``--no-renames`` matters: with rename - detection on, a moved file's old path never appears in ``--name-only`` and - the donor component's dropdown would silently vanish. Node colors compare - head against the target branch tip, so a node colored only by target-branch - churn on a stale PR intentionally gets no dropdown. - * Without it: the analysis-derived change set — files added to / removed - from the component plus files whose method set changed. This misses - body-only edits (the analysis can't see them), so the git listing is - preferred. - -Component file paths come from ``file_methods[].file_path`` plus -``key_entities[].reference_file`` — some engine outputs (observed on -TypeScript repos) carry file linkage only in ``key_entities``. - -Names and paths are emitted as HTML (````/````, escaped) rather than -markdown spans so arbitrary repo content can't break the comment markup. -Per-component and total size caps keep the block a small fraction of GitHub's -65,536-char comment limit (the Mermaid diagram alone may use ~45k). - -Self-contained stdlib; imports the diff from its sibling diff_to_mermaid.py. -""" - -from __future__ import annotations - -import argparse -import html -import json -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import diff_to_mermaid as dm # noqa: E402 - -# Budget: ~45k diagram + this block + ~1.5k header/CTA/footer must stay under -# GitHub's 65,536-char comment cap; overflow drops dropdowns, never the diagram. -MAX_TEXT = 10_000 -MAX_FILES_PER_COMPONENT = 15 - -_NOUN = {"added": "added", "modified": "changed", "deleted": "removed"} - - -def _walk(comp: dict, skip_deleted: bool = False): - """Yield ``comp`` and its whole subtree; optionally prune deleted ghosts.""" - if skip_deleted and comp.get("diff_status") == "deleted": - return - yield comp - for sub in comp.get("components") or []: - yield from _walk(sub, skip_deleted) - - -def _subtree_files(comp: dict | None, skip_deleted: bool = False) -> set: - if comp is None: - return set() - files: set = set() - for c in _walk(comp, skip_deleted): - files.update(fm.get("file_path") or "" for fm in c.get("file_methods") or []) - # Some engine outputs carry file linkage only in key_entities. - files.update(ke.get("reference_file") or "" for ke in c.get("key_entities") or []) - files.discard("") - return files - - -def _subtree_methods(comp: dict | None, skip_deleted: bool = False) -> dict: - merged: dict = {} - if comp is None: - return merged - for c in _walk(comp, skip_deleted): - for fp, names in dm._methods_by_file(c).items(): - merged.setdefault(fp, set()).update(names) - merged.pop("", None) # entries lacking file_path must not become a phantom file - return merged - - -def _changed_files_for(comp: dict, base_match: dict | None, changed_files: set | None) -> list: - """Files to list for one changed top-level component (see module docstring).""" - if changed_files is not None: - # Ghost subtrees inside the diff carry base-side files, so the union of - # both sides covers added, modified, and deleted components alike. - return sorted((_subtree_files(comp) | _subtree_files(base_match)) & changed_files) - head_files = _subtree_files(comp, skip_deleted=True) - base_files = _subtree_files(base_match) - head_methods = _subtree_methods(comp, skip_deleted=True) - base_methods = _subtree_methods(base_match) - method_changed = { - fp for fp in set(head_methods) | set(base_methods) if head_methods.get(fp, set()) != base_methods.get(fp, set()) - } - return sorted((head_files ^ base_files) | method_changed) - - -def _block(name: str, status: str, files: list, n_sub: int = 0) -> str: - shown = files[:MAX_FILES_PER_COMPONENT] - hidden = len(files) - len(shown) - n = len(files) - # Rollup parents (only nested components changed) carry the recursive count - # the headline and diagram use, so the dropdown explains "3 components - # changed" instead of contradicting it. - rollup = f"{n_sub} changed sub-component{'' if n_sub == 1 else 's'}, " if n_sub else "" - lines = [ - "
", - f"{html.escape(name)} : {rollup}{n} file{'' if n == 1 else 's'} {_NOUN[status]}", - "", # blank line: required for GitHub to render markdown after - ] - lines += [f"- {html.escape(fp)}" for fp in shown] - if hidden: - lines.append(f"- …and {hidden} more") - lines += ["", "
"] - return "\n".join(lines) - - -def render_component_files( - diff: dict, - base: dict, - changed_files: set | None = None, - max_chars: int = MAX_TEXT, -) -> tuple: - """Return (markdown_text, meta). ``markdown_text`` is "" when there's nothing to list.""" - base_by_name = {dm._comp_name(c): c for c in base.get("components") or []} - blocks: list = [] # (block_text, n_files_listed) - truncated = False - for comp in diff.get("components") or []: - status = dm._display_status(comp) - if status not in dm.CHANGED: - continue - # A deleted ghost IS its base component (the diff builds it from base), - # so use it directly; the name lookup would misattribute files when two - # top-level components share a name. - base_match = comp if comp.get("diff_status") == "deleted" else base_by_name.get(dm._comp_name(comp)) - files = _changed_files_for(comp, base_match, changed_files) - if not files: - continue # e.g. relation-only change, comparison-branch churn, or a reorg the PR didn't touch - truncated = truncated or len(files) > MAX_FILES_PER_COMPONENT - n_sub = ( - dm._count_changed_components(comp.get("components") or []) - if comp.get("diff_status") not in dm.CHANGED - else 0 - ) - blocks.append( - ( - _block(dm._comp_name(comp) or dm._comp_id(comp) or "(unnamed)", status, files, n_sub), - min(len(files), MAX_FILES_PER_COMPONENT), - ) - ) - - rendered: list = [] - size = 0 - n_components = n_files = 0 - for i, (block, n_listed) in enumerate(blocks): - if size + len(block) > max_chars: - truncated = True - if rendered: # never emit a dangling "…and N more" with no blocks above it - left = len(blocks) - i - rendered.append(f"…and {left} more changed component{'' if left == 1 else 's'}") - break - rendered.append(block) - size += len(block) + 1 - n_components += 1 - n_files += n_listed - - text = "\n".join(rendered) - meta = {"rendered": bool(text), "n_components": n_components, "n_files": n_files, "truncated": truncated} - return text, meta - - -def main() -> int: - p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--base", required=True, type=Path, help="Path to the base (before) analysis.json") - p.add_argument("--head", required=True, type=Path, help="Path to the head (after) analysis.json") - p.add_argument("--out", required=True, type=Path, help="Where to write the
markdown block") - p.add_argument( - "--changed-files", - type=Path, - default=None, - help="File with the PR's changed paths, one per line (git diff --no-renames " - "--name-only merge-base..head). Omit to fall back to analysis-derived changes.", - ) - args = p.parse_args() - - changed: set | None = None - if args.changed_files is not None: - try: - # surrogateescape: core.quotepath=off emits raw filename bytes; a - # non-UTF-8 path must not kill the whole section (it simply won't - # intersect with the analysis's UTF-8 paths). - raw = args.changed_files.read_text(encoding="utf-8", errors="surrogateescape") - except OSError as exc: - sys.exit(f"::error::Could not read changed-files list at {args.changed_files}: {exc}") - changed = {line.strip() for line in raw.splitlines() if line.strip()} - - # Use the same projected relation view as Mermaid so relation-only changes - # agree on which parent component changed. Projection does not alter the - # component file membership consumed below. - base = dm.load_analysis(args.base) - head = dm.load_analysis(args.head) - text, meta = render_component_files(dm.build_diff(base, head), base, changed) - - # Trailing newline so the following CTA's "---" isn't absorbed into the - #
HTML block; conditional so an empty result stays 0 bytes for - # the action's [ -s ] gate. - args.out.write_text(text + "\n" if text else "", encoding="utf-8") - # Machine-readable summary on stdout for the action to consume. - print(json.dumps(meta)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/build_cta.py b/scripts/build_cta.py deleted file mode 100644 index 6f5b6ce..0000000 --- a/scripts/build_cta.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Build the call-to-action footer appended to the architecture-diff PR comment. - -The body is a single line — "Explore this PR's architecture in your browser or -VS Code" — that merges the hosted-webview link with the editor link(s), preceded by -a warning banner when real health findings exist. The "browser" link (a no-install -hosted webview) is included only when ``webview_ready``; it's a short GitHub-style -link (``/owner/repo/pull/?run=``) that the webview resolves to this PR's -uploaded analysis artifact for the run. With a click proxy (``cta_base``) the -editor link routes through it (owner/repo/pr tracked) and deep-links into the editor -(the proxy redirects to a ``vscode:``/``cursor:`` URL), and a separate "install the -extension" link is appended. Without a proxy GitHub's comment sanitizer strips custom -``vscode:``/``cursor:`` schemes — a deep link would render as dead text — so the editor -link points at the extension's plain-https listing instead (VS Code Marketplace, Cursor -via Open VSX), which is the only clickable option. - -Editor coverage is deliberately limited to **VS Code and Cursor**. Per the 2025 -Stack Overflow Developer Survey (https://survey.stackoverflow.co/2025/technology/), -editor usage is VS Code 75.9%, Cursor 17.9%, VSCodium 6.2%, Windsurf 4.9%, -Trae 0.8% — so VS Code + Cursor alone cover ~94% of developers. The long-tail -forks each carry their own URL scheme and extension registry, and don't justify -that upkeep for <7% reach apiece. - -Which editor link(s) appear is inferred from the analyzed repo's own signals: -a ``.vscode`` directory -> VS Code, a ``.cursor`` directory -> Cursor, both -> -both, neither -> VS Code (the safe majority default). - -Self-contained stdlib. -""" - -from __future__ import annotations - -import argparse -from pathlib import Path -from urllib.parse import urlencode - - -def detect_editors(repo_path: Path) -> list[str]: - """Return the editor link(s) to offer, from the repo's ``.vscode``/``.cursor`` dirs. - - ``.vscode`` -> ['vscode'], ``.cursor`` -> ['cursor'], both -> both (VS Code - first), neither -> ['vscode']. Only VS Code and Cursor are considered (see - module docstring for the market-share rationale). - """ - editors: list[str] = [] - if (repo_path / ".vscode").is_dir(): - editors.append("vscode") - if (repo_path / ".cursor").is_dir(): - editors.append("cursor") - return editors or ["vscode"] - - -_EDITOR_LABEL = {"vscode": "VS Code", "cursor": "Cursor"} - -# No-proxy editor targets. Must be plain https: GitHub strips custom URI schemes -# (vscode:/cursor:) from comment links, so a deep link renders as dead text. Each -# editor points at its extension listing instead — clickable, and installs there. -_EDITOR_MARKETPLACE = { - "vscode": "https://marketplace.visualstudio.com/items?itemName=Codeboarding.codeboarding", - "cursor": "https://open-vsx.org/extension/CodeBoarding/codeboarding", -} - - -def webview_url( - webview_base: str, - owner: str, - repo: str, - *, - pr: str = "", - run_id: str = "", -) -> str | None: - """Return the hosted-webview PR deep-link, or None. - - A GitHub-style short link: ``{base}/{owner}/{repo}/pull/{pr}?run={run_id}``. The - webview re-derives the head SHA, base SHA, and artifact name from the workflow - run's uploaded artifact (+ its metadata.json), so the link carries only the PR - number and the run id — short, and stable across re-runs. - """ - if not (webview_base and owner and repo and pr and run_id): - return None - base = webview_base.rstrip("/") - return f"{base}/{owner}/{repo}/pull/{pr}?{urlencode({'run': run_id})}" - - -def _join_or(items: list[str]) -> str: - """Join with commas and a trailing 'or': 'a' / 'a or b' / 'a, b, or c'.""" - if len(items) <= 1: - return items[0] if items else "" - if len(items) == 2: - return f"{items[0]} or {items[1]}" - return ", ".join(items[:-1]) + f", or {items[-1]}" - - -def build_cta( - cta_base: str, - owner: str, - repo: str, - pr: str, - repo_path: Path, - issues: int = 0, - *, - webview_base: str = "", - webview_ready: bool = False, - run_id: str = "", -) -> str: - """Return the markdown CTA footer: a health-warning banner plus an editor link. - - With a ``cta_base`` proxy the links route through it (owner/repo/pr tracked), - deep-link into the editor, and add a separate "get the extension" link. Without - a proxy the editor link is the extension's https listing (GitHub strips custom - ``vscode:``/``cursor:`` schemes), and the redundant install link is dropped. - The ⚠️ banner shows whenever ``issues > 0``. - - When ``webview_ready`` an "explore in browser" line deep-links the hosted webview - to this PR's diff (``/owner/repo/pull/?run=``); the webview re-derives - the head/base/artifact from the run, so only the PR number and run id are needed. - """ - parts: list[str] = [] - if issues > 0: - noun = "issue" if issues == 1 else "issues" - parts.append(f"⚠️ **{issues} architecture {noun} found** — open CodeBoarding to explore them.") - - editors = detect_editors(repo_path) - if cta_base: - base = cta_base.rstrip("/") - - def link(path: str, **extra: str) -> str: - return f"{base}/{path}?" + urlencode({"owner": owner, "repo": repo, "pr": pr, **extra}) - - editor_href = {e: link("open-in-editor", editor=e) for e in editors} - extension_href: str | None = link("use-marketplace") - else: - editor_href = {e: _EDITOR_MARKETPLACE[e] for e in editors} - extension_href = None - - # One line that merges the hosted-webview "browser" link with the editor - # link(s), which always render. "your" rides with the browser entry alone, - # so the sentence reads naturally with or without it: - # "in your browser or VS Code" / "in VS Code". - targets: list[str] = [] - if webview_ready: - wv = webview_url(webview_base, owner, repo, pr=pr, run_id=run_id) - if wv: - targets.append(f"your [**browser**]({wv})") - targets += [f"[**{_EDITOR_LABEL[e]}**]({editor_href[e]})" for e in editors] - parts.append(f"Explore this PR’s architecture in {_join_or(targets)}.") - if extension_href: - parts.append(f"💡 New to CodeBoarding? [**Get the extension →**]({extension_href})") - - lines = ["", "---"] - for p in parts: - lines += ["", p] - return "\n".join(lines) - - -def main() -> int: - p = argparse.ArgumentParser(description="Build the architecture-diff PR-comment CTA footer.") - p.add_argument("--cta-base", required=True, help="Click-proxy base URL (empty -> no footer)") - p.add_argument("--owner", required=True) - p.add_argument("--repo", required=True) - p.add_argument("--pr", required=True) - p.add_argument("--repo-path", required=True, type=Path, help="Path to the analyzed repo checkout") - p.add_argument("--issues", default="0", help="Real architecture-issue count (0 -> no warning banner)") - p.add_argument("--webview-base", default="", help="Hosted webview base URL (e.g. https://app.codeboarding.org)") - p.add_argument("--run-id", default="", help="GitHub Actions run id containing the PR analysis artifact") - p.add_argument( - "--webview-ready", - action="store_true", - help="Emit the artifact-backed hosted webview link", - ) - args = p.parse_args() - - try: - issues = int(args.issues or 0) - except ValueError: - issues = 0 - print( - build_cta( - args.cta_base, - args.owner, - args.repo, - args.pr, - args.repo_path, - issues, - webview_base=args.webview_base, - webview_ready=args.webview_ready, - run_id=args.run_id, - ) - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/engine_adapter.py b/scripts/engine_adapter.py deleted file mode 100644 index 85fa50e..0000000 --- a/scripts/engine_adapter.py +++ /dev/null @@ -1,771 +0,0 @@ -"""CLI adapter between the action and the CodeBoarding analysis ENGINE. - -No analysis logic lives here. The engine is the published ``codeboarding`` PyPI -package installed by the action (``codeboarding_workflows`` etc.); this module -just turns the action's shell steps into typed, tested calls into it. The engine -imports are best-effort at module load, so this file imports fine without the -package present — the metadata-only subcommands (``baseline-info`` and -``baseline-depth``) run with the stdlib alone, while ``validate-base`` uses the -installed Core model to check schema compatibility. The tests stub the engine -modules to assert we call the engine with the right args. - -Subcommands (all paths/refs come in as argv, never interpolated into source): - - base --repo P --out D --name N --run-id ID --depth K --source-sha SHA - seed --repo P --out D --source-sha SHA - head --repo P --out D --name N --run-id ID --depth K --source-sha SHA - health --artifact-dir D --repo P --name N --issues-out FILE - validate-base --analysis F --expected-sha SHA [--expected-depth K] - baseline-info --analysis F - baseline-depth --analysis F - analyze --repo P --out D --name N --run-id ID --source-sha SHA --depth K [--force-full] - render --analysis F --out D --repo-name N --repo-ref R [--format .md] - concat --docs-dir D --out F - -REVIEW mode uses base/seed/head/health/validate-base; SYNC mode uses -baseline-info/analyze/render/concat. ``base`` runs a full analysis; ``seed`` -builds the SHA-tagged static-analysis pkl for a committed-analysis.json baseline -(LSP + clustering, no LLM) so the incremental path can run; ``health`` writes -the WARNING/CRITICAL finding count to ``--issues-out`` (never fails the run); -``validate-base`` exits non-zero only when the committed baseline is unreadable -or — with ``--expected-depth`` — its depth_level is DEEPER than requested; -shallower is accepted because the engine records the depth reached, not the -depth asked. The baseline's metadata.commit_hash is informational in review -mode: sync commits generated artifacts on top of the analyzed source commit, so -review trusts the analysis.json committed at the PR target branch tip rather than -regenerating because the metadata SHA differs. ``baseline-info`` prints the -baseline's ``commit_hash=`` (empty unless present and SHA-shaped). - -``head`` (review) and ``analyze`` (sync) are the SAME incremental-or-full -operation via the shared ``_incremental_or_full`` helper. Change detection is -git-free: Core diffs the current checkout against the seeded ``fingerprint.json`` -sidecar, so neither passes a base/target ref. The helper asks the installed -Core's ``UnifiedAnalysisJson`` model to validate the baseline and rebuilds with -a full analysis when the model cannot load it (including the pre-0.13.0 format). -``analyze`` is baseline-aware (full when the committed analysis.json is missing -or ``--force-full`` is set) and prints ``analysis_mode=full|incremental`` on -stdout for the action to grep. Once a compatible analysis.json exists, its -metadata.depth_level is the source of truth for incremental and fallback-full -depth; ``--depth`` is the cold-start/force-full depth. -``render`` writes per-component markdown with root name ``overview``; ``concat`` -joins overview.md first plus the remaining *.md (sorted) into one architecture -file. - -Telemetry: ``CODEBOARDING_SOURCE`` is defaulted after argument parsing — -``sync`` for analyze/render/concat, ``github_action`` for everything else -(the sync seed step overrides it to ``sync`` via env, since ``seed`` is shared). -""" - -from __future__ import annotations - -import argparse -import json -import os -import re -import shutil -import sys -from pathlib import Path - -# The engine packages are imported best-effort so the metadata-only subcommands -# (``baseline-info`` and ``baseline-depth``) run with the stdlib alone — they -# parse a committed analysis.json and never touch the engine. Keeping imports -# best-effort also gives analysis commands a clear error for an old package. The -# analysis subcommands that DO need the engine fail loudly when these are None. -try: - from agents.content_hash import hash_repo_source_files - from codeboarding_workflows.analysis import BaselineUnavailableError, run_full, run_incremental - from codeboarding_workflows.rendering import render_docs - from diagram_analysis import RunContext, RunPaths - from diagram_analysis.exceptions import IncrementalCacheMissingError - from diagram_analysis.io_utils import write_fingerprint - from logging_config import setup_logging - from static_analyzer import get_static_analysis - from static_analyzer.analysis_cache import StaticAnalysisCache - from static_analyzer.cluster_helpers import build_all_cluster_results -except Exception: # engine package not installed (metadata-only subcommands don't need it) - BaselineUnavailableError = IncrementalCacheMissingError = _MissingEngine = type("_MissingEngine", (Exception,), {}) - run_full = run_incremental = render_docs = None - RunContext = RunPaths = None - setup_logging = None - get_static_analysis = StaticAnalysisCache = build_all_cluster_results = None - hash_repo_source_files = write_fingerprint = None - -try: - from health.models import Severity - from health.runner import run_health_checks -except Exception as _health_import_error: # engine without the health module - Severity = None - run_health_checks = None - -try: - from diagram_analysis.analysis_json import UnifiedAnalysisJson -except Exception: # unavailable only for metadata commands run without Core installed - UnifiedAnalysisJson = None - -# The engine imports above are all-or-nothing: on failure every engine symbol is -# None. The metadata-only subcommands (baseline-info/baseline-depth), concat, -# and best-effort health run fine without the engine; analysis and validation -# subcommands do NOT. Guard those so a missing OR too-old engine fails with a -# clear, actionable message instead of a cryptic "'NoneType' object is not -# callable" deep in the run. "Too old" is the live case: an engine that predates -# Core #401 has no RunPaths/RunContext, so those symbols import as None here. -_ENGINE_COMMANDS = ("base", "seed", "head", "validate-base", "analyze", "render") - - -def _require_engine(cmd: str) -> None: - if cmd == "validate-base" and UnifiedAnalysisJson is not None: - return - if cmd != "validate-base" and RunPaths is not None: - return - if cmd == "validate-base": - raise RuntimeError( - "The 'validate-base' subcommand needs the CodeBoarding analysis engine, but the installed " - "'codeboarding' package is missing or too old: it does not export the unified analysis " - "model used to validate analysis.json. Pin the action's codeboarding_version input to 0.13.0 or newer." - ) - raise RuntimeError( - f"The '{cmd}' subcommand needs the CodeBoarding analysis engine, but the installed " - "'codeboarding' package is missing or too old: it does not export the git-free " - "content-versioning API (RunPaths/RunContext, added in Core #401). Pin the action's " - "codeboarding_version input to a release that includes it." - ) - - -# A committed analysis.json's commit_hash is trusted only as far as a SHA shape: -# it flows into GITHUB_OUTPUT, cache keys, and git refs, so anything else must -# be rejected before it reaches the action shell. -_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$") -_DEFAULT_DEPTH = 2 - -# Per-tier ceiling on analysis depth. The engine has no hard cap (depth_level is -# just the abstraction-expansion bound), so the limit is a product decision: -# the free hosted tier is capped to keep per-run cost bounded; licensed/BYO-key -# users (who pay for their own tokens or hold a license) get a much higher ceiling. -_FREE_MAX_DEPTH = 3 -_LICENSED_MAX_DEPTH = 10 - - -def _max_depth(licensed: bool) -> int: - return _LICENSED_MAX_DEPTH if licensed else _FREE_MAX_DEPTH - - -# On the free hosted tier the proxy returns HTTP 402 with this message when the -# repo owner's weekly token budget is spent. We detect it so the action can post -# a helpful "add a key/license" comment instead of a generic failure. -_QUOTA_MARKER = "Resource exhausted: token limit reached" - - -def _is_quota_exhausted(exc: BaseException) -> bool: - """True when *exc* (or its cause chain) looks like the proxy's 402 quota - error. Matches on the HTTP status 402 when the exception exposes one, and on - the marker string as a fallback (the engine wraps provider errors, so the - status may only survive as text).""" - seen = set() - cur: BaseException | None = exc - while cur is not None and id(cur) not in seen: - seen.add(id(cur)) - status = getattr(cur, "status_code", None) or getattr(cur, "status", None) - if status == 402: - return True - if _QUOTA_MARKER in str(cur): - return True - cur = cur.__cause__ or cur.__context__ - return False - - -def _flag_quota_exhausted() -> None: - """Drop the sentinel the action's failure-comment step branches on. Path comes - from the action via CB_QUOTA_SENTINEL; best-effort (never masks the real error).""" - sentinel = os.environ.get("CB_QUOTA_SENTINEL") - if not sentinel: - return - try: - Path(sentinel).write_text("1") - except OSError: - pass - - -def _log_path(output_dir: str, filename: str) -> str: - return str(Path(output_dir) / filename) - - -def _run_ctx(repo_path: str, output_dir: str, repo_name: str, run_id: str, log_name: str): - """Build the (RunPaths, RunContext) pair Core's run_full/run_incremental take.""" - repo_dir = Path(repo_path) - run_paths = RunPaths(repo_path=repo_dir, output_dir=Path(output_dir), project_name=repo_name) - run_context = RunContext(run_id=run_id, log_path=_log_path(output_dir, log_name), repo_dir=repo_dir) - return run_paths, run_context - - -def _clear_dir(path: Path) -> None: - path.mkdir(parents=True, exist_ok=True) - for child in path.iterdir(): - if child.is_dir() and not child.is_symlink(): - shutil.rmtree(child) - else: - child.unlink() - - -def _load_analysis(analysis_path: Path) -> dict | None: - try: - data = json.loads(analysis_path.read_text(encoding="utf-8")) - except FileNotFoundError: - return None - except (OSError, json.JSONDecodeError): - return {} - return data if isinstance(data, dict) else {} - - -def _load_metadata(analysis_path: Path) -> dict | None: - data = _load_analysis(analysis_path) - if data is None: - return None - metadata = data.get("metadata") - return metadata if isinstance(metadata, dict) else {} - - -def _metadata_depth(metadata: dict) -> int | None: - try: - return int(metadata.get("depth_level")) - except (TypeError, ValueError): - return None - - -def _resolve_depth(metadata: dict, licensed: bool, default_depth: int = _DEFAULT_DEPTH) -> int: - """Resolve a usable analysis depth from a committed baseline's metadata. - - Always returns a number in [1, tier-max] — never None. Each case is logged - with its exact condition: - * missing/unparseable/non-positive depth_level -> the default cold-start - depth (every "not a usable depth" spec violation is treated the same); - * a depth above the tier ceiling -> clamped down to the ceiling. - The tier ceiling is the free cap for unlicensed runs and a much higher cap - for licensed/BYO-key runs (see ``_max_depth``). A valid in-range depth passes - through unchanged. - """ - cap = _max_depth(licensed) - depth = _metadata_depth(metadata) - # Diagnostics go to stderr so stdout stays a clean machine-readable channel - # (the ``baseline-depth`` subcommand prints only ``depth_level=`` to stdout). - if depth is None: - print( - f"Baseline metadata.depth_level is missing/unparseable; using default depth {default_depth}.", - file=sys.stderr, - ) - return min(default_depth, cap) - if depth < 1: - print(f"Baseline depth_level {depth} is not positive; using default depth {default_depth}.", file=sys.stderr) - return min(default_depth, cap) - if depth > cap: - tier = "licensed" if licensed else "free-tier" - print(f"Baseline depth_level {depth} exceeds the {tier} max {cap}; clamping to {cap}.", file=sys.stderr) - return cap - return depth - - -def _analysis_depth_or_default(output_dir: Path, licensed: bool, default_depth: int = _DEFAULT_DEPTH) -> int: - metadata = _load_metadata(output_dir / "analysis.json") - if not isinstance(metadata, dict): - return min(default_depth, _max_depth(licensed)) - return _resolve_depth(metadata, licensed, default_depth) - - -def _metadata_commit(metadata: dict) -> str: - value = metadata.get("commit_hash") - return value if isinstance(value, str) else "" - - -def _analysis_model_error(data: dict) -> str | None: - """Return an error when the installed Core model cannot load *data* losslessly. - - Pydantic models may accept an older document by ignoring unknown fields and - filling new defaults. A model round-trip catches that schema drift without - teaching the action about any particular Core field. - """ - if UnifiedAnalysisJson is None: - raise RuntimeError( - "Validating analysis.json compatibility requires the installed CodeBoarding engine model " - "(diagram_analysis.analysis_json.UnifiedAnalysisJson)." - ) - try: - model = UnifiedAnalysisJson.model_validate(data) - normalized = model.model_dump(mode="json", exclude_none=True) - except Exception as exc: - detail = str(exc).splitlines()[0] if str(exc) else type(exc).__name__ - return f"Installed CodeBoarding model could not load baseline analysis.json ({detail})" - if normalized != data: - return "Installed CodeBoarding model could not load baseline analysis.json without schema changes" - return None - - -def baseline_info(analysis_path: Path) -> str: - """Return the committed baseline's commit_hash when present and SHA-shaped, - else "". Sync mode uses this (via the ``baseline-info`` subcommand) instead - of an inline shell heredoc, so baseline parsing + the SHA-shape guard live - in one tested place. - """ - metadata = _load_metadata(analysis_path) - if not isinstance(metadata, dict): - return "" - commit = _metadata_commit(metadata) - return commit if _SHA_RE.match(commit) else "" - - -def baseline_depth(analysis_path: Path, licensed: bool) -> int | None: - """Return the depth to analyze the PR head at, inherited from the committed - baseline and clamped to the tier ceiling — or None when there is NO committed - baseline at all (genuine cold start; the caller then uses its own default). - - Review mode uses this (via the ``baseline-depth`` subcommand) so the head is - analyzed at the SAME depth as the base it is diffed against (apples-to-apples). - A present-but-out-of-range or unparseable depth is clamped/defaulted (and - logged) by ``_resolve_depth`` rather than rejected, so review never silently - shallows a usable baseline. Parsing lives here so the action shell never reads - the JSON inline (mirrors ``baseline_info``). - """ - metadata = _load_metadata(analysis_path) - if not isinstance(metadata, dict) or not metadata: - return None # no baseline / no metadata → cold start, caller defaults - return _resolve_depth(metadata, licensed) - - -def validate_base_analysis( - analysis_path: Path, expected_sha: str, expected_depth: int | None = None -) -> tuple[bool, str]: - """Return whether ``analysis.json`` is valid for ``expected_sha``. - - Review mode reuses the analysis.json that is committed at the PR target branch tip. The - diagram's metadata.commit_hash records the source commit analyzed by sync, - but the sync commit itself necessarily has a newer SHA because it adds the - generated artifacts. Treat that metadata SHA as provenance, not freshness. - - A baseline that the installed Core model cannot load is rejected so review - mode generates a fresh target analysis before running the PR-head - incremental analysis. This handles the 0.13.0 format break without - duplicating Core's schema or coupling the action to individual JSON fields. - - When ``expected_depth`` is given, a baseline whose metadata.depth_level - parses as an int and is DEEPER than expected is rejected (review mode - regenerates instead of diffing against a deeper analysis). A shallower - depth_level is accepted: the engine records the depth actually REACHED, - not the depth requested, so a depth-2 run on a repo where no component - expands persists depth_level 1 — rejecting that would force a full - regeneration on every PR without ever converging. A missing or - unparseable depth_level is accepted — legacy baselines predate the field. - - Review now derives ``expected_depth`` from the committed baseline's own - depth_level (via the ``baseline-depth`` subcommand), so the deeper-than-expected - rejection no longer fires for the normal case — head and base are analyzed at - the same depth. The rejection remains a safety net for an explicit - ``depth_level`` input that is shallower than the committed baseline. - """ - try: - data = json.loads(analysis_path.read_text(encoding="utf-8")) - except FileNotFoundError: - return False, f"Baseline analysis is missing: {analysis_path}" - except (OSError, json.JSONDecodeError) as exc: - return False, f"Baseline analysis is unreadable: {exc}" - - if not isinstance(data, dict): - return False, "Baseline analysis root is not a JSON object." - - metadata = data.get("metadata") - if not isinstance(metadata, dict): - return False, "Baseline analysis metadata is missing." - - model_error = _analysis_model_error(data) - if model_error: - return False, f"{model_error}; a full analysis is required." - - actual_sha = metadata.get("commit_hash") - if isinstance(actual_sha, str) and actual_sha: - if actual_sha == expected_sha: - message = f"Baseline analysis commit matches target branch commit {expected_sha}." - else: - message = f"Using committed baseline at target branch commit {expected_sha}; analysis metadata source is {actual_sha}." - else: - message = f"Using committed baseline at target branch commit {expected_sha}; analysis metadata.commit_hash is missing." - - if expected_depth is not None: - baseline_depth = _metadata_depth(metadata) - if baseline_depth is not None and baseline_depth > expected_depth: - return ( - False, - f"Baseline analysis depth_level {baseline_depth} is deeper than expected depth {expected_depth}.", - ) - - return True, message - - -def run_base(repo_path: str, output_dir: str, repo_name: str, run_id: str, depth: int, source_sha: str) -> None: - run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, "cb-base.log") - res = run_full(run_paths, run_context, depth_level=depth, source_sha=source_sha) - print(f"Base analysis written: {res}") - - -def run_seed(repo_path: str, output_dir: str, source_sha: str) -> None: - """Build the SHA-tagged static-analysis artifact for *repo* with no LLM calls. - - A committed analysis.json gives the head analysis its component ids, but - the engine's incremental path also needs the base ``static_analysis.pkl`` - with a populated cluster cache — which ``git show`` of analysis.json can - never provide. LSP indexing plus Leiden clustering are deterministic and - cost no LLM spend, so the action seeds the pkl here instead of letting the - head run degrade to a full analysis. - - ``build_all_cluster_results`` is the same call the full run's abstraction - agent makes, so the seeded cluster baseline matches a real full analysis. - The explicit ``save`` AFTER clustering matters: ``get_static_analysis`` - persists the pkl on LSP teardown, before clustering — saving only there - would recreate the pkl-without-cluster-baseline state this fixes. - - Also writes the whole-tree ``fingerprint.json`` sidecar: git-free incremental - diffs the head checkout against it, so a committed baseline that only carries - analysis.json + pkl would otherwise leave incremental with no baseline and - force a full run. - - Errors propagate; the action step treats a failed seed as fail-open (the - head run falls back to a full analysis, today's behavior). - """ - results = get_static_analysis(Path(repo_path), cache_dir=Path(output_dir), source_sha=source_sha) - cluster_results = build_all_cluster_results(results) - StaticAnalysisCache(Path(output_dir), Path(repo_path)).save(results, source_sha=source_sha) - write_fingerprint(Path(output_dir), hash_repo_source_files(Path(repo_path))) - summary = ", ".join(f"{lang}={len(cr.clusters)}" for lang, cr in sorted(cluster_results.items())) - print(f"Seeded static-analysis baseline in {output_dir} (clusters: {summary or 'none'})") - - -def _incremental_or_full( - *, - repo_path: str, - output_dir: str, - repo_name: str, - run_id: str, - depth: int, - source_sha: str, - log_name: str, - licensed: bool, -) -> str: - """Run incremental against the seeded baseline; on a cache/baseline miss, - clear the output dir and run a full analysis. Returns "incremental" or "full". - - Change detection is git-free: Core diffs the current checkout against the - seeded ``fingerprint.json`` sidecar itself, so no base/target ref is passed. - ``source_sha`` still tags the full fallback's static-analysis cache. - - Shared by the review head path and the sync analyze path so the fallback - rule (which exceptions degrade to full, and the clear-before-full) lives in - exactly one place — a bug fixed here is fixed for both modes. - """ - run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, log_name) - out_path = Path(output_dir) - analysis = _load_analysis(out_path / "analysis.json") - model_error = "Baseline analysis.json is missing or unreadable" if not analysis else _analysis_model_error(analysis) - if model_error: - print(f"Incremental unavailable ({model_error}); running full analysis.") - fallback_depth = _analysis_depth_or_default(out_path, licensed, depth) - _clear_dir(out_path) - res = run_full(run_paths, run_context, depth_level=fallback_depth, source_sha=source_sha) - print(f"Analysis written: {res}") - return "full" - - try: - res = run_incremental(run_paths, run_context) - print(f"Analysis written: {res}") - return "incremental" - except (IncrementalCacheMissingError, BaselineUnavailableError) as exc: - print(f"Incremental unavailable ({exc}); running full analysis.") - fallback_depth = _analysis_depth_or_default(out_path, licensed, depth) - _clear_dir(out_path) - res = run_full(run_paths, run_context, depth_level=fallback_depth, source_sha=source_sha) - print(f"Analysis written: {res}") - return "full" - - -def run_head( - repo_path: str, - output_dir: str, - repo_name: str, - run_id: str, - depth: int, - source_sha: str, - force_full: bool = False, - licensed: bool = False, -) -> None: - """Review PR head: incremental against the seeded baseline, full on a cache miss. - - Print the selected mode explicitly so GitHub Action logs make it obvious - whether review used the incremental path or fell back to a full analysis. - """ - if force_full: - out_path = Path(output_dir) - _clear_dir(out_path) - run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, "cb-head.log") - res = run_full(run_paths, run_context, depth_level=depth, source_sha=source_sha) - print(f"Analysis written: {res}") - print("head_analysis_mode=full") - return - - mode = _incremental_or_full( - repo_path=repo_path, - output_dir=output_dir, - repo_name=repo_name, - run_id=run_id, - depth=depth, - source_sha=source_sha, - log_name="cb-head.log", - licensed=licensed, - ) - print(f"head_analysis_mode={mode}") - - -def run_analyze( - repo_path: str, - output_dir: str, - repo_name: str, - run_id: str, - source_sha: str, - depth: int, - force_full: bool = False, - licensed: bool = False, -) -> str: - """Sync analysis: incremental against the committed baseline, full when the - baseline is absent or ``force_full`` is set. Prints ``analysis_mode=full|incremental`` - on stdout — the action greps that line, so it must be printed exactly once per run. - - Change detection is git-free: the baseline is trusted when its ``fingerprint.json`` - sidecar is present (``_incremental_or_full`` falls back to full itself if the - sidecar or cache is missing), so there's no ``commit_hash`` gate. The baseline's - depth_level is resolved (clamped/defaulted, never None) for the incremental run. - """ - out_path = Path(output_dir) - - def full(reason: str, analysis_depth: int) -> str: - print(f"{reason}; running full analysis.") - _clear_dir(out_path) - run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, "cb-sync.log") - res = run_full(run_paths, run_context, depth_level=analysis_depth, source_sha=source_sha) - print(f"Analysis written: {res}") - print("analysis_mode=full") - return "full" - - if force_full: - return full("Full analysis forced (force_full)", min(depth, _max_depth(licensed))) - - metadata = _load_metadata(out_path / "analysis.json") - if metadata is None: - return full("No baseline analysis.json found", min(depth, _max_depth(licensed))) - - baseline_depth = _resolve_depth(metadata, licensed) - mode = _incremental_or_full( - repo_path=repo_path, - output_dir=output_dir, - repo_name=repo_name, - run_id=run_id, - depth=baseline_depth, - source_sha=source_sha, - log_name="cb-sync.log", - licensed=licensed, - ) - print(f"analysis_mode={mode}") - return mode - - -def run_render(analysis: str, output_dir: str, repo_name: str, repo_ref: str, output_format: str) -> None: - out_path = Path(output_dir) - _clear_dir(out_path) - render_docs( - Path(analysis), - repo_name=repo_name, - repo_ref=repo_ref, - temp_dir=out_path, - format=output_format, - root_name="overview", - ) - print(f"Rendered docs in {out_path}") - - -def run_concat(docs_dir: str, out: str) -> None: - docs_path = Path(docs_dir) - overview = docs_path / "overview.md" - if not overview.is_file(): - raise FileNotFoundError(f"Missing required root docs file: {overview}") - - files = [overview] - files.extend(sorted(p for p in docs_path.glob("*.md") if p.name != "overview.md")) - - out_path = Path(out) - out_path.parent.mkdir(parents=True, exist_ok=True) - sections = [path.read_text(encoding="utf-8").rstrip() for path in files] - out_path.write_text("\n\n".join(sections) + "\n", encoding="utf-8") - print(f"Concatenated {len(files)} docs into {out_path}") - - -def _count_report_issues(report: dict) -> int: - issues = 0 - if not isinstance(report, dict): - raise ValueError("health report root is not an object") - for cs in report.get("check_summaries") or []: - if not isinstance(cs, dict): - continue - for fg in cs.get("finding_groups") or []: - if not isinstance(fg, dict): - continue - if fg.get("severity") in ("warning", "critical"): - entities = fg.get("entities") or [] - issues += len(entities) if isinstance(entities, list) else 0 - return issues - - -def _count_health_report(artifact_dir: str) -> int | None: - report_path = Path(artifact_dir) / "health" / "health_report.json" - if not report_path.is_file(): - return None - try: - return _count_report_issues(json.loads(report_path.read_text(encoding="utf-8"))) - except (OSError, json.JSONDecodeError, ValueError) as exc: - print(f"Health report unreadable ({exc}); falling back to health runner.") - return None - - -def run_health(artifact_dir: str, repo_path: str, repo_name: str) -> int: - """Return the WARNING/CRITICAL finding count; 0 on any failure (best-effort).""" - report_count = _count_health_report(artifact_dir) - if report_count is not None: - print(f"Architecture issues found in health report: {report_count}") - return report_count - - if Severity is None or run_health_checks is None: - print(f"Health check skipped ({_health_import_error}).") - return 0 - - try: - cache = StaticAnalysisCache(artifact_dir=Path(artifact_dir), repo_root=Path(repo_path)) - sa = cache.get() - issues = 0 - if sa is not None: - report = run_health_checks(sa, repo_name=repo_name, repo_path=Path(repo_path)) - if report is not None: - for cs in report.check_summaries: - for fg in getattr(cs, "finding_groups", []): - if getattr(fg, "severity", None) in (Severity.WARNING, Severity.CRITICAL): - issues += len(fg.entities) - print(f"Architecture issues found: {issues}") - return issues - except Exception as exc: - print(f"Health check skipped ({exc}).") - return 0 - - -def main(argv=None) -> int: - p = argparse.ArgumentParser(description=__doc__) - sub = p.add_subparsers(dest="cmd", required=True) - - # The structural ceiling is the licensed max; the per-run tier cap (free vs - # licensed) is enforced by the action guard (explicit input) and _resolve_depth - # (inherited baseline), so a too-deep value is rejected or clamped, not silently - # truncated by argparse. - depth_choices = range(1, _LICENSED_MAX_DEPTH + 1) - - b = sub.add_parser("base") - for a in ("--repo", "--out", "--name", "--run-id", "--source-sha"): - b.add_argument(a, required=True) - b.add_argument("--depth", required=True, type=int, choices=depth_choices) - - s = sub.add_parser("seed") - for a in ("--repo", "--out", "--source-sha"): - s.add_argument(a, required=True) - - h = sub.add_parser("head") - for a in ("--repo", "--out", "--name", "--run-id", "--source-sha"): - h.add_argument(a, required=True) - h.add_argument("--depth", required=True, type=int, choices=depth_choices) - h.add_argument("--licensed", action="store_true", help="Licensed/BYO-key run (raises the depth ceiling).") - h.add_argument("--force-full", action="store_true", help="Run a full PR-head analysis instead of incremental.") - - hc = sub.add_parser("health") - for a in ("--artifact-dir", "--repo", "--name", "--issues-out"): - hc.add_argument(a, required=True) - - vb = sub.add_parser("validate-base") - vb.add_argument("--analysis", required=True) - vb.add_argument("--expected-sha", required=True) - vb.add_argument("--expected-depth", type=int, choices=depth_choices) - - bi = sub.add_parser("baseline-info") - bi.add_argument("--analysis", required=True) - - bd = sub.add_parser("baseline-depth") - bd.add_argument("--analysis", required=True) - bd.add_argument("--licensed", action="store_true", help="Licensed/BYO-key run (raises the depth ceiling).") - - an = sub.add_parser("analyze") - for a in ("--repo", "--out", "--name", "--run-id", "--source-sha"): - an.add_argument(a, required=True) - an.add_argument("--depth", required=True, type=int, choices=depth_choices) - an.add_argument("--licensed", action="store_true", help="Licensed/BYO-key run (raises the depth ceiling).") - an.add_argument("--force-full", action="store_true", help="Ignore any committed baseline and run a full analysis.") - - rn = sub.add_parser("render") - for a in ("--analysis", "--out", "--repo-name", "--repo-ref"): - rn.add_argument(a, required=True) - rn.add_argument("--format", default=".md") - - cc = sub.add_parser("concat") - for a in ("--docs-dir", "--out"): - cc.add_argument(a, required=True) - - args = p.parse_args(argv) - source = "sync" if args.cmd in ("analyze", "render", "concat") else "github_action" - os.environ.setdefault("CODEBOARDING_SOURCE", source) - if args.cmd in _ENGINE_COMMANDS: - _require_engine(args.cmd) - setup_logging(default_level=os.getenv("CODEBOARDING_LOG_LEVEL", "INFO")) - try: - if args.cmd == "base": - run_base(args.repo, args.out, args.name, args.run_id, args.depth, args.source_sha) - elif args.cmd == "seed": - run_seed(args.repo, args.out, args.source_sha) - elif args.cmd == "head": - run_head( - args.repo, - args.out, - args.name, - args.run_id, - args.depth, - args.source_sha, - # action.yml adds --force-full for EMPTY_BASE PRs (no comparison baseline). - args.force_full, - args.licensed, - ) - elif args.cmd == "health": - Path(args.issues_out).write_text(str(run_health(args.artifact_dir, args.repo, args.name))) - elif args.cmd == "validate-base": - ok, message = validate_base_analysis(Path(args.analysis), args.expected_sha, args.expected_depth) - print(message) - return 0 if ok else 1 - elif args.cmd == "baseline-info": - print(f"commit_hash={baseline_info(Path(args.analysis))}") - elif args.cmd == "baseline-depth": - depth = baseline_depth(Path(args.analysis), args.licensed) - print(f"depth_level={depth if depth is not None else ''}") - elif args.cmd == "analyze": - run_analyze( - args.repo, args.out, args.name, args.run_id, args.source_sha, args.depth, args.force_full, args.licensed - ) - elif args.cmd == "render": - run_render(args.analysis, args.out, args.repo_name, args.repo_ref, args.format) - elif args.cmd == "concat": - run_concat(args.docs_dir, args.out) - except BaseException as exc: - # Free-tier weekly cap hit: drop a sentinel so the action posts a - # "add a key/license" comment, then re-raise so the step still fails. - if _is_quota_exhausted(exc): - _flag_quota_exhausted() - print(f"::error::{_QUOTA_MARKER}", flush=True) - raise - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/render_sync_docs.py b/scripts/render_sync_docs.py new file mode 100755 index 0000000..9edecc6 --- /dev/null +++ b/scripts/render_sync_docs.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Thin wrapper around installed CodeBoarding renderer. + +No analysis or rendering orchestration lives here, only direct calls into the +package's renderer. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from codeboarding_workflows.rendering import render_docs + + +def concat_overview(docs_dir: Path, out_path: Path) -> None: + overview = docs_dir / "overview.md" + if not overview.is_file(): + raise SystemExit("Missing required root docs file: overview.md") + + files = [overview] + files.extend(sorted(p for p in docs_dir.glob("*.md") if p.name != "overview.md")) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text("\n\n".join(p.read_text(encoding="utf-8").rstrip() for p in files) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render markdown docs from an analysis JSON.") + parser.add_argument("--analysis", required=True, type=Path, help="Path to analysis.json") + parser.add_argument("--output-dir", required=True, type=Path, help="Directory to write docs") + parser.add_argument("--repo-name", required=True) + parser.add_argument("--repo-ref", required=True) + parser.add_argument("--format", default=".md") + parser.add_argument("--architecture-file", required=False, type=Path) + args = parser.parse_args() + + render_docs( + args.analysis, + repo_name=args.repo_name, + repo_ref=args.repo_ref, + temp_dir=args.output_dir, + format=args.format, + root_name="overview", + ) + + if args.architecture_file: + concat_overview(args.output_dir, args.architecture_file) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_local.sh b/scripts/run_local.sh index aae494f..ab6fa2c 100755 --- a/scripts/run_local.sh +++ b/scripts/run_local.sh @@ -1,40 +1,27 @@ #!/usr/bin/env bash +# Local test harness for the CodeBoarding Action. # -# Local test harness for the CodeBoarding Mermaid architecture-diff action. -# Mirrors action.yml so you can iterate without waiting on a GitHub runner. -# -# Two modes: -# -# FAST (no LLM, instant) — diff two existing analysis.json files and preview: -# scripts/run_local.sh --base-json BASE.json --head-json HEAD.json -# -# FULL pipeline (needs OPENROUTER_API_KEY) — run the installed codeboarding -# package on two refs of a local repo, exactly like the action -# (committed-or-generated base, then incremental head), then diff + preview: -# export OPENROUTER_API_KEY=sk-or-... -# scripts/run_local.sh --repo /path/to/repo --base --head -# -# Outputs (default ./.cb-local): -# diagram.md the ```mermaid block (what the action posts) -# preview.html opens in a browser and renders the colored diagram via mermaid.js +# Three modes: +# 1) FAST review preview (no repo analysis): +# scripts/run_local.sh --base-json BASE.json --head-json HEAD.json +# 2) REVIEW LOCAL (full local pipeline): +# scripts/run_local.sh --repo /path/to/repo --base --head +# 3) REVIEW LOCAL against committed baseline only (if available): +# scripts/run_local.sh --repo /path/to/repo --base --head --depth 2 # +# Output: +# diagram.md Mermaid payload posted by the action +# preview.html browser preview + set -euo pipefail ACTION_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ENGINE="${ENGINE:-}" -OUT="$ACTION_DIR/.cb-local" +OUT="${ACTION_DIR}/.cb-local" DEPTH="1" DIRECTION="LR" -CHANGED_ONLY=() -NO_EDGE_LABELS=() -RENDER_DEPTH=() -EXTRA=() OPEN="auto" -REPO="" BASE_REF="" HEAD_REF="" BASE_JSON="" HEAD_JSON="" -# Empty by default: the engine then uses its own valid per-provider default. -# Override with a bare OpenRouter slug, e.g. AGENT_MODEL=anthropic/claude-sonnet-4 -AGENT_MODEL="${AGENT_MODEL:-}" -PARSING_MODEL="${PARSING_MODEL:-}" +REPO="" BASE_REF="" HEAD_REF="" +BASE_JSON="" HEAD_JSON="" while [ $# -gt 0 ]; do case "$1" in @@ -43,163 +30,213 @@ while [ $# -gt 0 ]; do --head) HEAD_REF="$2"; shift 2;; --base-json) BASE_JSON="$2"; shift 2;; --head-json) HEAD_JSON="$2"; shift 2;; - --engine) ENGINE="$2"; shift 2;; # optional local CodeBoarding checkout for engine development --out) OUT="$2"; shift 2;; --depth) DEPTH="$2"; shift 2;; --direction) DIRECTION="$2"; shift 2;; - --changed-only) CHANGED_ONLY=(--changed-only); shift;; - --no-edge-labels) NO_EDGE_LABELS=(--no-edge-labels); shift;; - --render-depth) RENDER_DEPTH=(--render-depth "$2"); shift 2;; - --extra) read -r -a EXTRA <<< "$2"; shift 2;; # raw args forwarded to diff_to_mermaid.py, e.g. --extra "--font-size 20 --node-padding 16" --no-open) OPEN="no"; shift;; - -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}"; exit 0;; - *) echo "Unknown arg: $1" >&2; exit 2;; + -h|--help) + sed -n '1,80p' "${BASH_SOURCE[0]}" + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + exit 2 + ;; esac done mkdir -p "$OUT" -run_engine() { - ( - if [ -n "$ENGINE" ]; then cd "$ENGINE"; fi - export DIAGRAM_DEPTH_LEVEL="$DEPTH" \ - CACHING_DOCUMENTATION="false" \ - ENABLE_MONITORING="false" - # OPENROUTER_API_KEY is inherited from the environment (full mode requires it). - # Pass the model only when set; empty -> engine's own valid per-provider default. - if [ -n "$AGENT_MODEL" ]; then export AGENT_MODEL; fi - if [ -n "$PARSING_MODEL" ]; then export PARSING_MODEL; fi - python "$ACTION_DIR/scripts/engine_adapter.py" "$@" ) +parse_value() { + local key="$1" + local text="$2" + printf '%s\n' "$text" | awk -F= -v key="$key" '$1 == key {print $2; exit}' +} + +run_inc() { + local checkout="$1" + local out_dir="$2" + + python3 "$ACTION_DIR/scripts/analyze_repository.py" incremental \ + --checkout "$checkout" \ + --output-dir "$out_dir" +} + +run_full() { + local checkout="$1" + local out_dir="$2" + + python3 "$ACTION_DIR/scripts/analyze_repository.py" full \ + --checkout "$checkout" \ + --output-dir "$out_dir" \ + --depth-level "$DEPTH" } if [ -n "$BASE_JSON" ] && [ -n "$HEAD_JSON" ]; then - echo "== Fast mode: diffing existing analyses (no engine run) ==" BASE_ANALYSIS="$BASE_JSON" HEAD_ANALYSIS="$HEAD_JSON" else if [ -z "$REPO" ] || [ -z "$BASE_REF" ] || [ -z "$HEAD_REF" ]; then - echo "Need either --base-json/--head-json, or --repo/--base/--head." >&2; exit 2 + echo "Need either --base-json/--head-json, or --repo/--base/--head." >&2 + exit 2 fi - if [ -n "$ENGINE" ] && [ ! -d "$ENGINE" ]; then - echo "Engine not found at $ENGINE (set --engine or install the codeboarding package)." >&2; exit 2 + + if [[ ! "$DEPTH" =~ ^[0-9]+$ ]]; then + echo "depth must be an integer." >&2 + exit 2 fi - [ -n "${OPENROUTER_API_KEY:-}" ] || { echo "Export OPENROUTER_API_KEY for the full pipeline." >&2; exit 2; } + + if [ -d "$OUT" ]; then + rm -rf "$OUT" + mkdir -p "$OUT" + fi + + WORK="$OUT/work" + BASE_DIR="$WORK/base" + HEAD_DIR="$WORK/head" + BASE_FULL_DIR="$WORK/base-full" + HEAD_FULL_DIR="$WORK/head-full" + mkdir -p "$BASE_DIR" "$HEAD_DIR" "$BASE_FULL_DIR" "$HEAD_FULL_DIR" + REPO="$(cd "$REPO" && pwd)" + + git -C "$REPO" rev-parse -q --verify "$BASE_REF"^{commit} >/dev/null 2>&1 || git -C "$REPO" fetch origin "$BASE_REF" --depth=1 + git -C "$REPO" rev-parse -q --verify "$HEAD_REF"^{commit} >/dev/null 2>&1 || git -C "$REPO" fetch origin "$HEAD_REF" --depth=1 BASE_SHA="$(git -C "$REPO" rev-parse "$BASE_REF^{commit}")" HEAD_SHA="$(git -C "$REPO" rev-parse "$HEAD_REF^{commit}")" - BASE_DIR="$OUT/base"; HEAD_DIR="$OUT/head" - rm -rf "$BASE_DIR" "$HEAD_DIR"; mkdir -p "$BASE_DIR" "$HEAD_DIR" - - echo "== Resolving base analysis from head history at or before $HEAD_SHA ==" - BASELINE_SHA="" - while IFS= read -r candidate; do - if git -C "$REPO" cat-file -e "${candidate}:.codeboarding/analysis.json" 2>/dev/null; then - BASELINE_SHA="$candidate" - break + + git -C "$REPO" worktree add --detach "$BASE_DIR" "$BASE_SHA" >/dev/null + git -C "$REPO" worktree add --detach "$HEAD_DIR" "$HEAD_SHA" >/dev/null + + cleanup() { + git -C "$REPO" worktree remove --force "$BASE_DIR" >/dev/null 2>&1 || true + git -C "$REPO" worktree remove --force "$HEAD_DIR" >/dev/null 2>&1 || true + rm -rf "$WORK" + } + trap cleanup EXIT + + BASELINE_DEPTH="" + if [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then + BASELINE_DEPTH="$(python3 - "$BASE_DIR/.codeboarding/analysis.json" <<'PY' +import json, sys +try: + print(json.loads(open(sys.argv[1]).read()).get("metadata", {}).get("depth_level", "")) +except Exception: + print("") +PY)" + fi + + if [ -n "$BASELINE_DEPTH" ] && [[ "$BASELINE_DEPTH" =~ ^[0-9]+$ ]]; then + DEPTH="$BASELINE_DEPTH" + fi + + if [ -d "$BASE_DIR/.codeboarding" ] && [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then + cp -a "$BASE_DIR/.codeboarding/." "$HEAD_DIR/." + BASE_FOR_DIFF="$BASE_DIR/.codeboarding/analysis.json" + BASE_FULL_PATH="" + else + BASE_FULL_OUTPUT="$(run_full "$BASE_DIR" "$BASE_FULL_DIR")" + BASE_FULL_PATH="$(parse_value analysis_path "$BASE_FULL_OUTPUT")" + if [ -z "$BASE_FULL_PATH" ] || [ ! -f "$BASE_FULL_PATH" ]; then + echo "::error::Base full analysis did not produce analysis_path." >&2 + exit 1 fi - done < <(git -C "$REPO" rev-list "$HEAD_SHA" -- .codeboarding/analysis.json 2>/dev/null || true) + BASE_FOR_DIFF="$BASE_FULL_PATH" + cp -a "$BASE_FULL_DIR/." "$HEAD_DIR/." + fi + + HEAD_OUTPUT="$(run_inc "$HEAD_DIR" "$HEAD_DIR")" + HEAD_MODE="$(parse_value analysis_mode "$HEAD_OUTPUT")" + NEED_FULL="$(parse_value requires_full_analysis "$HEAD_OUTPUT")" + HEAD_PATH="$(parse_value analysis_path "$HEAD_OUTPUT")" - if [ -n "$BASELINE_SHA" ] \ - && git -C "$REPO" show "$BASELINE_SHA:.codeboarding/analysis.json" > "$BASE_DIR/analysis.json" 2>/dev/null \ - && run_engine validate-base --analysis "$BASE_DIR/analysis.json" --expected-sha "$BASELINE_SHA"; then - if [ "$BASELINE_SHA" = "$HEAD_SHA" ]; then - echo " using committed baseline at head" + if [ "$HEAD_MODE" != "incremental" ] || [ -z "$HEAD_PATH" ]; then + echo "::error::Could not parse head incremental output contract." >&2 + echo "$HEAD_OUTPUT" + exit 1 + fi + + if [ "$NEED_FULL" = "true" ]; then + rm -rf "$HEAD_DIR" && mkdir -p "$HEAD_DIR" + if [ -n "${BASE_FULL_DIR:-}" ] && [ -d "$BASE_FULL_DIR" ]; then + cp -a "$BASE_FULL_DIR/." "$HEAD_DIR/." 2>/dev/null || true else - echo " using nearest committed baseline at $BASELINE_SHA from head history" + cp -a "$BASE_DIR/." "$HEAD_DIR/." 2>/dev/null || true fi - # Mirror action.yml: a committed analysis.json alone can't drive incremental — - # the engine needs the base static_analysis.pkl with its cluster baseline. - # Seed it deterministically (LSP + clustering, no LLM); fail-open on error. - BASE_SRC="$OUT/base-src" - git -C "$REPO" worktree remove --force "$BASE_SRC" 2>/dev/null || true - git -C "$REPO" worktree prune - rm -rf "$BASE_SRC" - git -C "$REPO" worktree add --detach "$BASE_SRC" "$BASELINE_SHA" >/dev/null - if run_engine seed --repo "$BASE_SRC" --out "$BASE_DIR" --source-sha "$BASELINE_SHA"; then - echo " seeded static-analysis baseline (no LLM)" - else - rm -f "$BASE_DIR/static_analysis.pkl" "$BASE_DIR/static_analysis.sha" - echo " WARNING: seeding failed; head will fall back to a full run" >&2 + HEAD_FULL_OUTPUT="$(run_full "$HEAD_DIR" "$HEAD_FULL_DIR")" + HEAD_MODE="$(parse_value analysis_mode "$HEAD_FULL_OUTPUT")" + HEAD_PATH="$(parse_value analysis_path "$HEAD_FULL_OUTPUT")" + if [ "$HEAD_MODE" != "full" ] || [ -z "$HEAD_PATH" ]; then + echo "::error::Could not parse head full output contract." >&2 + echo "$HEAD_FULL_OUTPUT" + exit 1 fi - git -C "$REPO" worktree remove --force "$BASE_SRC" >/dev/null 2>&1 || true - else - rm -f "$BASE_DIR/analysis.json" - echo " no committed baseline; running FULL analysis on base (LLM)..." - BASE_SRC="$OUT/base-src" - git -C "$REPO" worktree remove --force "$BASE_SRC" 2>/dev/null || true - git -C "$REPO" worktree prune - rm -rf "$BASE_SRC" - git -C "$REPO" worktree add --detach "$BASE_SRC" "$BASE_SHA" >/dev/null - run_engine base \ - --repo "$BASE_SRC" \ - --out "$BASE_DIR" \ - --name "$(basename "$REPO")" \ - --run-id local-base \ - --depth "$DEPTH" \ - --source-sha "$BASE_SHA" - git -C "$REPO" worktree remove --force "$BASE_SRC" >/dev/null 2>&1 || true - [ -f "$BASE_DIR/analysis.json" ] || { echo "Base full analysis ran but analysis.json is missing." >&2; exit 1; } fi - echo "== Analyzing head at $HEAD_SHA (incremental from base) ==" - cp -a "$BASE_DIR"/. "$HEAD_DIR"/ 2>/dev/null || true - run_engine head \ - --repo "$REPO" \ - --out "$HEAD_DIR" \ - --name "$(basename "$REPO")" \ - --run-id local-head \ - --depth "$DEPTH" \ - --base-ref "${BASELINE_SHA:-$BASE_SHA}" \ - --target-ref "$HEAD_SHA" \ - --source-sha "$HEAD_SHA" - [ -f "$HEAD_DIR/analysis.json" ] || { echo "Head analysis ran but analysis.json is missing." >&2; exit 1; } - BASE_ANALYSIS="$BASE_DIR/analysis.json" - HEAD_ANALYSIS="$HEAD_DIR/analysis.json" + if [ ! -f "$HEAD_PATH" ]; then + echo "::error::Missing generated head analysis.json." >&2 + exit 1 + fi + + BASE_ANALYSIS="$BASE_FOR_DIFF" + HEAD_ANALYSIS="$HEAD_PATH" fi -echo "== Diff -> Mermaid ==" -META="$(python3 "$ACTION_DIR/scripts/diff_to_mermaid.py" \ - --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" \ - --out "$OUT/diagram.md" --direction "$DIRECTION" \ - ${CHANGED_ONLY[@]+"${CHANGED_ONLY[@]}"} ${NO_EDGE_LABELS[@]+"${NO_EDGE_LABELS[@]}"} ${RENDER_DEPTH[@]+"${RENDER_DEPTH[@]}"} ${EXTRA[@]+"${EXTRA[@]}"})" -echo " $META" +if [ -z "$BASE_ANALYSIS" ] || [ ! -f "$BASE_ANALYSIS" ]; then + echo "::error::Missing review base analysis.json." >&2 + exit 1 +fi +if [ -z "$HEAD_ANALYSIS" ] || [ ! -f "$HEAD_ANALYSIS" ]; then + echo "::error::Missing review head analysis.json." >&2 + exit 1 +fi -# Browser preview: render the (fence-stripped) mermaid via mermaid.js, strict mode -# to match GitHub. HTML-escape the body so labels with < > & stay valid. -python3 - "$OUT/diagram.md" "$OUT/preview.html" <<'PY' +DIAGRAM_OUT="$OUT/diagram.md" +python3 "$ACTION_DIR/scripts/diff_to_mermaid.py" \ + --base "$BASE_ANALYSIS" \ + --head "$HEAD_ANALYSIS" \ + --out "$DIAGRAM_OUT" \ + --direction "$DIRECTION" \ + --render-depth 1 + +python3 - "$DIAGRAM_OUT" "$OUT/preview.html" <<'PY' import html, sys src, dst = sys.argv[1], sys.argv[2] body = open(src, encoding="utf-8").read().strip() lines = body.splitlines() -if lines and lines[0].startswith("```"): lines = lines[1:] -if lines and lines[-1].startswith("```"): lines = lines[:-1] +if lines and lines[0].startswith("```"): + lines = lines[1:] +if lines and lines[-1].startswith("```"): + lines = lines[:-1] graph = html.escape("\n".join(lines)) -open(dst, "w", encoding="utf-8").write(f""" -CodeBoarding architecture diff +open(dst, "w", encoding="utf-8").write(f"""CodeBoarding architecture diff

Architecture diff preview

-
- ■ added - ■ modified - ■ deleted +
+ ■ added + ■ modified + ■ deleted
-
-{graph}
-
-""") print(f" wrote {dst}") PY echo -echo "diagram : $OUT/diagram.md" + +echo "diagram : $DIAGRAM_OUT" echo "preview : $OUT/preview.html" if [ "$OPEN" != "no" ]; then - if command -v open >/dev/null 2>&1; then open "$OUT/preview.html"; - elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$OUT/preview.html"; - else echo "(open $OUT/preview.html in your browser)"; fi + if command -v open >/dev/null 2>&1; then + open "$OUT/preview.html" + elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$OUT/preview.html" + else + echo "(open $OUT/preview.html in your browser)" + fi fi diff --git a/scripts/submit_feedback.py b/scripts/submit_feedback.py deleted file mode 100644 index b0d9edc..0000000 --- a/scripts/submit_feedback.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Submit explicit user feedback (/codeboarding-feedback) to PostHog. - -Standard-library only, on purpose: this runs in the action's guard phase, before -the engine checkout and any dependency install, so it must not import third-party -packages. Unlike Core's anonymous telemetry, this event intentionally carries the -user-written feedback text and PR context — that difference is documented in the -README. All sending failures are swallowed; feedback must never break a PR. -""" - -from __future__ import annotations - -import json -import os -import sys -import urllib.error -import urllib.request - -# Public PostHog ingest key — the same write-only project key Core ships. -DEFAULT_POSTHOG_KEY = "phc_BQWpoXuPYQhW7mPWQcRv4yzSfuoAmh48EmXuUpeXPUB2" -DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" -DEFAULT_COMMAND = "/codeboarding-feedback" -DEFAULT_MAX_CHARS = 4000 -EVENT_NAME = "codeboarding_feedback_submitted" -SOURCE = "github_action_feedback" - - -def telemetry_disabled(env: dict) -> bool: - """Mirror Core's opt-out: DO_NOT_TRACK or CODEBOARDING_TELEMETRY=false.""" - if env.get("DO_NOT_TRACK", "").strip().lower() in ("1", "true", "yes"): - return True - return env.get("CODEBOARDING_TELEMETRY", "true").strip().lower() == "false" - - -def resolve_key(env: dict) -> str: - return (env.get("CODEBOARDING_POSTHOG_KEY") or env.get("POSTHOG_KEY") or DEFAULT_POSTHOG_KEY).strip() - - -def resolve_host(env: dict) -> str: - host = (env.get("CODEBOARDING_POSTHOG_HOST") or env.get("POSTHOG_HOST") or DEFAULT_POSTHOG_HOST).strip() - return host.rstrip("/") or DEFAULT_POSTHOG_HOST - - -def resolve_command(env: dict) -> str: - return (env.get("FEEDBACK_COMMAND") or "").strip() or DEFAULT_COMMAND - - -def resolve_max_chars(env: dict) -> int: - try: - n = int((env.get("FEEDBACK_MAX_CHARS") or "").strip()) - except ValueError: - return DEFAULT_MAX_CHARS - return n if n > 0 else DEFAULT_MAX_CHARS - - -def extract_feedback(comment_body: str, command: str) -> str: - """Return everything after the leading command token, newlines preserved. - - The command is the first whitespace-delimited token of the comment. Only that - one token is removed; the remainder (including any later lines) is kept - verbatim, then outer whitespace is trimmed. Returns "" when the comment does - not actually start with the command, or carries no text after it. - """ - body = (comment_body or "").replace("\r\n", "\n").replace("\r", "\n").lstrip() - if not body: - return "" - parts = body.split(None, 1) # split once on the first run of whitespace - if parts[0] != command: - return "" - return parts[1].strip() if len(parts) > 1 else "" - - -def cap_feedback(text: str, max_chars: int) -> tuple[str, int, bool]: - """Return (capped_text, original_length, truncated).""" - original_length = len(text) - truncated = original_length > max_chars - return (text[:max_chars] if truncated else text), original_length, truncated - - -def _first(env: dict, *names: str) -> str: - for name in names: - value = (env.get(name) or "").strip() - if value: - return value - return "" - - -def distinct_id(env: dict) -> str: - sender_id = _first(env, "SENDER_ID") - if sender_id: - return f"github-user:{sender_id}" - return f"github-run:{_first(env, 'RUN_ID', 'GITHUB_RUN_ID')}" - - -def build_properties(env: dict, command: str, feedback_text: str, feedback_length: int, truncated: bool) -> dict: - props: dict = { - "source": SOURCE, - "command": command, - "feedback_text": feedback_text, - "feedback_length": feedback_length, - "feedback_truncated": truncated, - } - optional = { - "repository": _first(env, "REPOSITORY"), - "repository_id": _first(env, "REPOSITORY_ID"), - "pr_number": _first(env, "PR_NUMBER", "ISSUE_NUMBER"), - "comment_id": _first(env, "COMMENT_ID"), - "comment_url": _first(env, "COMMENT_URL"), - "author_association": _first(env, "AUTHOR_ASSOC", "AUTHOR_ASSOCIATION"), - "sender_login": _first(env, "SENDER_LOGIN"), - "sender_id": _first(env, "SENDER_ID"), - "run_id": _first(env, "RUN_ID", "GITHUB_RUN_ID"), - "run_attempt": _first(env, "RUN_ATTEMPT", "GITHUB_RUN_ATTEMPT"), - "action_ref": _first(env, "ACTION_REF", "GITHUB_ACTION_REF", "GITHUB_SHA"), - } - props.update({key: value for key, value in optional.items() if value}) - return props - - -def build_payload(env: dict) -> dict | None: - """Build the PostHog event payload, or None when there is nothing to send.""" - command = resolve_command(env) - feedback_text, feedback_length, truncated = cap_feedback( - extract_feedback(env.get("COMMENT_BODY", ""), command), resolve_max_chars(env) - ) - if not feedback_text: - return None - return { - "api_key": resolve_key(env), - "event": EVENT_NAME, - "distinct_id": distinct_id(env), - "properties": build_properties(env, command, feedback_text, feedback_length, truncated), - } - - -def post(payload: dict, host: str, timeout: int = 10) -> int: - """POST one event to PostHog's ingest endpoint; return the HTTP status.""" - request = urllib.request.Request( - f"{host}/i/v0/e/", - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(request, timeout=timeout) as response: - return response.status - - -def main(env: dict | None = None) -> int: - env = os.environ if env is None else env - - if telemetry_disabled(env): - print("Feedback disabled via DO_NOT_TRACK / CODEBOARDING_TELEMETRY; not sending.") - return 0 - - payload = build_payload(env) - if payload is None: - print("No feedback text after the command; nothing to send.") - return 0 - if not payload["api_key"]: - print("No PostHog key configured; skipping feedback send.") - return 0 - - truncated = payload["properties"].get("feedback_truncated") - try: - status = post(payload, resolve_host(env)) - print(f"Feedback submitted (HTTP {status}, truncated={truncated}).") - except urllib.error.HTTPError as exc: - print(f"Feedback endpoint returned HTTP {exc.code}; ignoring.") - except urllib.error.URLError as exc: - print(f"Feedback endpoint unreachable ({type(exc.reason).__name__}); ignoring.") - except Exception as exc: # never let feedback break the action - print(f"Feedback send failed ({type(exc).__name__}); ignoring.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py new file mode 100644 index 0000000..e092ef4 --- /dev/null +++ b/tests/test_analyze_repository.py @@ -0,0 +1,88 @@ +"""Smoke tests for scripts/analyze_repository.py — JSON contract parsing and mode dispatch.""" + +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import analyze_repository as ar + + +class AnalyzeRepositoryTests(unittest.TestCase): + def _analysis_json(self, base: Path) -> Path: + path = base / "analysis.json" + path.write_text(json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 2}}, encoding="utf-8") ) + return path + + def test_parse_cli_response_accepts_contract_json(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + out = root / "analysis.json" + out.write_text("ok", encoding="utf-8") + payload = json.dumps({"analysis_path": "analysis.json", "requiresFullAnalysis": True}) + requires_full, path, _ = ar._parse_cli_response(payload, str(root)) + self.assertTrue(requires_full) + self.assertEqual(path, out) + + def test_parse_cli_response_rejects_invalid_bool(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + payload = json.dumps({"analysis_path": "analysis.json", "requiresFullAnalysis": "maybe"}) + root = Path(tmp) + (root / "analysis.json").write_text("x", encoding="utf-8") + with self.assertRaises(ar.AnalysisError): + ar._parse_cli_response(payload, str(root)) + + def test_parse_main_incremental_success(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + checkout = root / "repo" + out_dir = root / "out" + checkout.mkdir() + out_dir.mkdir() + analysis_path = out_dir / "analysis.json" + analysis_path.write_text("ok", encoding="utf-8") + + stdout = io.StringIO() + with unittest.mock.patch("sys.stdout", stdout): + with patch.object( + ar, + "_run_command", + return_value=json.dumps( + { + "analysis_path": str(analysis_path.relative_to(root)), + "requiresFullAnalysis": False, + } + ), + ) as _mock: + ar.main(["incremental", "--checkout", str(checkout), "--output-dir", str(out_dir)]) + lines = dict(line.split("=", 1) for line in stdout.getvalue().splitlines() if "=" in line) + self.assertEqual(lines.get("analysis_mode"), "incremental") + self.assertEqual(lines.get("requires_full_analysis"), "false") + self.assertEqual(lines.get("analysis_path"), str(analysis_path)) + + def test_main_full_fails_without_depth(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + checkout = root / "repo" + out_dir = root / "out" + checkout.mkdir() + out_dir.mkdir() + with self.assertRaises(SystemExit): + ar.main(["full", "--checkout", str(checkout), "--output-dir", str(out_dir)]) + + def test_main_rejects_bad_cli_output(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + checkout = root / "repo" + out_dir = root / "out" + checkout.mkdir() + out_dir.mkdir() + with self.assertRaises(SystemExit): + with patch.object(ar, "_run_command", return_value="not-json"): + ar.main(["incremental", "--checkout", str(checkout), "--output-dir", str(out_dir)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_build_component_files.py b/tests/test_build_component_files.py deleted file mode 100644 index d4f36fc..0000000 --- a/tests/test_build_component_files.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Unit tests for scripts/build_component_files.py — per-component changed-file dropdowns.""" - -import json -import os -import re -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -import build_component_files as bcf # noqa: E402 -import diff_to_mermaid as dm # noqa: E402 - -SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "build_component_files.py" - - -def comp(name, files=None, subs=None, key_files=None): - c = { - "name": name, - "component_id": name, - "file_methods": [{"file_path": f, "methods": m} for f, m in (files or {}).items()], - } - if key_files is not None: - c["key_entities"] = [{"reference_file": f} for f in key_files] - if subs is not None: - c["components"] = subs - return c - - -def render(base, head, changed_files=None, max_chars=bcf.MAX_TEXT): - diff = dm.build_diff(base, head) - return bcf.render_component_files(diff, base, changed_files, max_chars) - - -class TestGitIntersection(unittest.TestCase): - def test_modified_component_lists_only_touched_files(self): - base = {"components": [comp("Auth", {"a.py": ["f"], "b.py": ["g"], "c.py": ["h"]})]} - head = {"components": [comp("Auth", {"a.py": ["f", "f2"], "b.py": ["g"], "c.py": ["h"]})]} - text, meta = render(base, head, changed_files={"a.py", "unrelated.py"}) - self.assertIn("a.py", text) - self.assertNotIn("b.py", text) # owned but untouched - self.assertNotIn("unrelated.py", text) # touched but not owned - self.assertIn("Auth : 1 file changed", text) - self.assertEqual(meta["n_components"], 1) - self.assertEqual(meta["n_files"], 1) - - def test_added_component_wording(self): - base = {"components": []} - head = {"components": [comp("RateLimiter", {"rl/bucket.py": ["acquire"], "rl/config.py": ["load"]})]} - text, _ = render(base, head, changed_files={"rl/bucket.py", "rl/config.py"}) - self.assertIn("RateLimiter : 2 files added", text) - - def test_deleted_component_lists_base_files(self): - base = {"components": [comp("Legacy", {"legacy/store.py": ["get"], "legacy/migrations.py": ["mig"]})]} - head = {"components": []} - text, _ = render(base, head, changed_files={"legacy/store.py", "legacy/migrations.py"}) - self.assertIn("Legacy : 2 files removed", text) - self.assertIn("legacy/migrations.py", text) - - def test_unchanged_component_emits_nothing(self): - base = {"components": [comp("A", {"a.py": ["f"]})]} - head = {"components": [comp("A", {"a.py": ["f"]})]} - text, meta = render(base, head, changed_files={"a.py"}) - self.assertEqual(text, "") - self.assertFalse(meta["rendered"]) - - def test_changed_component_with_no_touched_files_is_skipped(self): - # Model reorg: file moved between components, but the PR's git diff is elsewhere. - base = {"components": [comp("A", {"a.py": ["f"], "b.py": ["g"]})]} - head = {"components": [comp("A", {"a.py": ["f"]})]} - text, _ = render(base, head, changed_files={"elsewhere.py"}) - self.assertEqual(text, "") - - def test_empty_changed_files_set_means_no_dropdowns_not_fallback(self): - # Empty git diff (net-zero PR / re-run): an empty set must NOT fall - # back to analysis-derived changes — only None (flag omitted) does. - base = {"components": [comp("A", {"a.py": ["f"]})]} - head = {"components": [comp("A", {"a.py": ["f", "g"]})]} - text, meta = render(base, head, changed_files=set()) - self.assertEqual(text, "") - self.assertFalse(meta["rendered"]) - - def test_nested_subtree_files_aggregate_to_top_level(self): - base = {"components": [comp("Parent", {}, subs=[comp("Child", {"deep/x.py": ["f"]})])]} - head = {"components": [comp("Parent", {}, subs=[comp("Child", {"deep/x.py": ["f", "g"]})])]} - text, _ = render(base, head, changed_files={"deep/x.py"}) - self.assertIn("Parent", text) - self.assertIn("deep/x.py", text) - self.assertNotIn("Child", text) # one dropdown per top-level component - - def test_rollup_parent_labels_changed_subcomponents(self): - # Parent unchanged itself (display_status rollup): the summary carries the - # recursive count the headline/diagram use, so counts don't contradict. - base = {"components": [comp("Parent", {"p.py": ["f"]}, subs=[comp("Child", {"c.py": ["g"]})])]} - head = {"components": [comp("Parent", {"p.py": ["f"]}, subs=[comp("Child", {"c.py": ["g", "g2"]})])]} - text, _ = render(base, head, changed_files={"c.py"}) - self.assertIn("Parent : 1 changed sub-component, 1 file changed", text) - - def test_deleted_nested_child_files_list_under_modified_parent(self): - base = {"components": [comp("Parent", {"p.py": ["f"]}, subs=[comp("Child", {"child/x.py": ["g"]})])]} - head = {"components": [comp("Parent", {"p.py": ["f"]}, subs=[])]} - text, _ = render(base, head, changed_files={"child/x.py"}) - self.assertIn("Parent", text) - self.assertIn("child/x.py", text) - - def test_key_entities_only_shape(self): - # Some engine outputs have file_methods: [] everywhere and carry file - # linkage only in key_entities[].reference_file (observed on TS repos). - base = {"components": []} - head = {"components": [comp("Webview", key_files=["src/panel.ts", "src/render.ts"])]} - text, _ = render(base, head, changed_files={"src/panel.ts", "src/render.ts"}) - self.assertIn("Webview : 2 files added", text) - self.assertIn("src/panel.ts", text) - - def test_duplicate_deleted_names_attribute_files_to_own_block(self): - base = {"components": [comp("Dup", {"one.py": ["f"]}), comp("Dup", {"two.py": ["g"]})]} - head = {"components": []} - for changed in (None, {"one.py", "two.py"}): - text, _ = render(base, head, changed_files=changed) - self.assertEqual(text.count("one.py"), 1, text) - self.assertEqual(text.count("two.py"), 1, text) - - -class TestAnalysisFallback(unittest.TestCase): - def test_fallback_lists_structural_and_method_changes(self): - base = {"components": [comp("A", {"kept.py": ["f"], "gone.py": ["g"], "same.py": ["h"]})]} - head = {"components": [comp("A", {"kept.py": ["f", "f2"], "new.py": ["n"], "same.py": ["h"]})]} - text, _ = render(base, head, changed_files=None) - self.assertIn("kept.py", text) # method set changed - self.assertIn("gone.py", text) # removed from component - self.assertIn("new.py", text) # added to component - self.assertNotIn("same.py", text) - - def test_fallback_deleted_component_lists_all_base_files(self): - base = {"components": [comp("Legacy", {"l/a.py": ["f"], "l/b.py": ["g"]})]} - head = {"components": []} - text, _ = render(base, head, changed_files=None) - self.assertIn("Legacy : 2 files removed", text) - - def test_missing_file_path_entry_emits_no_phantom(self): - base = {"components": [comp("A", {"a.py": ["f"]})]} - head = {"components": [comp("A", {"a.py": ["f"]})]} - head["components"][0]["file_methods"].append({"methods": ["orphan"]}) # no file_path - text, _ = render(base, head, changed_files=None) - self.assertNotIn("", text) - - -class TestOrdering(unittest.TestCase): - def test_file_lists_are_sorted(self): - files = {f: ["m"] for f in ["e.py", "b.py", "f.py", "a.py", "d.py", "c.py"]} - base = {"components": []} - head = {"components": [comp("A", files)]} - text, _ = render(base, head, changed_files=set(files)) - paths = re.findall(r"([^<]+)", text) - self.assertEqual(paths, ["a.py", "b.py", "c.py", "d.py", "e.py", "f.py"]) - - def test_blocks_follow_diagram_order_deleted_ghosts_last(self): - # Head order first (matches Mermaid node emission), deleted ghosts appended - # last — NOT alphabetical: Alpha is deleted and must render after Zeta. - base = {"components": [comp("Zeta", {"z.py": ["f"]}), comp("Alpha", {"a.py": ["g"]})]} - head = {"components": [comp("Zeta", {"z.py": ["f", "f2"]})]} - text, _ = render(base, head, changed_files={"z.py", "a.py"}) - self.assertEqual(re.findall(r"(\w+)", text), ["Zeta", "Alpha"]) - - -class TestCapsAndEscaping(unittest.TestCase): - def test_per_component_file_cap(self): - files = {f"src/f{i:02}.py": ["m"] for i in range(20)} - base = {"components": []} - head = {"components": [comp("Big", files)]} - text, meta = render(base, head, changed_files=set(files)) - self.assertEqual(text.count(""), bcf.MAX_FILES_PER_COMPONENT) - self.assertIn("…and 5 more", text) - self.assertIn(": 20 files added", text) # count reflects reality, list is capped - self.assertTrue(meta["truncated"]) - - def test_total_char_budget_drops_whole_components(self): - base = {"components": []} - head = {"components": [comp(f"C{i}", {f"c{i}/f.py": ["m"]}) for i in range(10)]} - text, meta = render(base, head, changed_files={f"c{i}/f.py" for i in range(10)}, max_chars=300) - self.assertIn("more changed components", text) - self.assertTrue(meta["truncated"]) - # meta counts what actually rendered, not what the budget dropped - self.assertEqual(meta["n_files"], text.count("")) - self.assertEqual(meta["n_components"], text.count("
")) - - def test_first_block_exceeding_budget_renders_nothing(self): - # Never a dangling "…and N more" with no blocks above it. - base = {"components": []} - head = {"components": [comp("Big", {f"very/long/path/file{i}.py": ["m"] for i in range(15)})]} - text, meta = render(base, head, changed_files={f"very/long/path/file{i}.py" for i in range(15)}, max_chars=100) - self.assertEqual(text, "") - self.assertFalse(meta["rendered"]) - self.assertEqual(meta["n_files"], 0) - self.assertTrue(meta["truncated"]) - - def test_html_escaping_of_names_and_paths(self): - base = {"components": []} - head = {"components": [comp("A <& B", {"weird/&.py": ["m"]})]} - text, _ = render(base, head, changed_files={"weird/&.py"}) - self.assertIn("A <& B", text) - self.assertIn("weird/<path>&.py", text) - self.assertNotIn("", text) - - def test_blank_line_after_summary(self): - # GitHub only renders markdown inside
after a blank line. - base = {"components": []} - head = {"components": [comp("A", {"a.py": ["m"]})]} - text, _ = render(base, head, changed_files={"a.py"}) - self.assertIn("\n\n-", text) - self.assertIn("\n\n
", text) - - -class TestCLI(unittest.TestCase): - def _analyses(self, d): - (d / "base.json").write_text(json.dumps({"components": [comp("Auth", {"a.py": ["f"], "b.py": ["g"]})]})) - (d / "head.json").write_text(json.dumps({"components": [comp("Auth", {"a.py": ["f", "f2"], "b.py": ["g"]})]})) - - def _run(self, d, *extra): - out = d / "out.md" - core = d / "fake-core" - (core / "codeboarding_workflows").mkdir(parents=True) - (core / "diagram_analysis").mkdir() - (core / "codeboarding_workflows" / "__init__.py").write_text("") - (core / "diagram_analysis" / "__init__.py").write_text("") - (core / "codeboarding_workflows" / "rendering.py").write_text( - "def project_relations_to_level(relations, level_ids, id_to_name):\n" - " return [r for r in relations if r.src_id in level_ids and r.dst_id in level_ids]\n" - ) - (core / "diagram_analysis" / "analysis_json.py").write_text( - "from types import SimpleNamespace\n" - "def parse_unified_analysis(data):\n" - " components = [SimpleNamespace(component_id=c['component_id']) for c in data.get('components', [])]\n" - " relations = [SimpleNamespace(**r) for r in data.get('components_relations', [])]\n" - " return SimpleNamespace(components=components, components_relations=relations), {}\n" - "def build_id_to_name_map(root, subs):\n" - " return {}\n" - ) - args = [ - sys.executable, - str(SCRIPT), - "--base", - str(d / "base.json"), - "--head", - str(d / "head.json"), - "--out", - str(out), - ] - env = {**os.environ, "PYTHONPATH": str(core)} - return out, subprocess.run([*args, *extra], capture_output=True, text=True, env=env) - - def test_main_writes_out_file_and_prints_meta(self): - with tempfile.TemporaryDirectory() as tmp: - d = Path(tmp) - self._analyses(d) - (d / "changed.txt").write_text("a.py\nunrelated.py\n") - out, r = self._run(d, "--changed-files", str(d / "changed.txt")) - self.assertEqual(r.returncode, 0, r.stderr) - content = out.read_text(encoding="utf-8") - self.assertIn("a.py", content) - self.assertTrue(content.endswith("
\n")) # trailing newline: see main() - meta = json.loads(r.stdout) - self.assertEqual(set(meta), {"rendered", "n_components", "n_files", "truncated"}) - self.assertTrue(meta["rendered"]) - - def test_non_utf8_changed_files_does_not_crash(self): - # core.quotepath=off emits raw filename bytes; a non-UTF-8 path must not - # kill the section — it just can't intersect with the analysis's paths. - with tempfile.TemporaryDirectory() as tmp: - d = Path(tmp) - self._analyses(d) - (d / "changed.txt").write_bytes(b"r\xe9sum\xe9.py\na.py\n") - out, r = self._run(d, "--changed-files", str(d / "changed.txt")) - self.assertEqual(r.returncode, 0, r.stderr) - self.assertIn("a.py", out.read_text(encoding="utf-8")) - - def test_empty_result_writes_zero_bytes(self): - # The action gates the section on [ -s "$FILES_MD" ]. - with tempfile.TemporaryDirectory() as tmp: - d = Path(tmp) - self._analyses(d) - (d / "changed.txt").write_text("elsewhere.py\n") - out, r = self._run(d, "--changed-files", str(d / "changed.txt")) - self.assertEqual(r.returncode, 0, r.stderr) - self.assertEqual(out.read_bytes(), b"") - - -class TestEngineGitPathContract(unittest.TestCase): - """file_methods[].file_path must be repo-relative forward-slash paths identical - to git --name-only output; pinned against the committed engine artifact (the - dogfood workflows regenerate it on engine bumps, so format drift fails here).""" - - def test_committed_analysis_paths_are_git_name_only_format(self): - root = Path(__file__).resolve().parent.parent - analysis = json.loads((root / ".codeboarding" / "analysis.json").read_text()) - paths = set() - - def collect(c): - for fm in c.get("file_methods") or []: - paths.add(fm["file_path"]) - for ke in c.get("key_entities") or []: - if ke.get("reference_file"): - paths.add(ke["reference_file"]) - for s in c.get("components") or []: - collect(s) - - for c in analysis.get("components") or []: - collect(c) - self.assertTrue(paths, "committed analysis.json has no file paths") - tracked = set( - subprocess.run( - ["git", "-C", str(root), "ls-files"], capture_output=True, text=True, check=True - ).stdout.splitlines() - ) - self.assertLessEqual(paths, tracked, f"paths not in git --name-only format: {sorted(paths - tracked)[:5]}") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_build_cta.py b/tests/test_build_cta.py deleted file mode 100644 index e439d51..0000000 --- a/tests/test_build_cta.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Unit tests for scripts/build_cta.py — editor detection + CTA footer.""" - -import sys -import tempfile -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -import build_cta as bc # noqa: E402 - - -def repo_with(*dirs): - d = Path(tempfile.mkdtemp()) - for x in dirs: - (d / x).mkdir() - return d - - -class TestDetectEditors(unittest.TestCase): - def test_neither_defaults_to_vscode(self): - self.assertEqual(bc.detect_editors(repo_with()), ["vscode"]) - - def test_vscode_only(self): - self.assertEqual(bc.detect_editors(repo_with(".vscode")), ["vscode"]) - - def test_cursor_only(self): - self.assertEqual(bc.detect_editors(repo_with(".cursor")), ["cursor"]) - - def test_both_vscode_first(self): - self.assertEqual(bc.detect_editors(repo_with(".vscode", ".cursor")), ["vscode", "cursor"]) - - -class TestBuildCta(unittest.TestCase): - def test_no_proxy_links_editor_to_https_listing_no_get_extension(self): - out = bc.build_cta("", "o", "r", "1", repo_with(".cursor"), issues=3) - self.assertIn("3 architecture issues found", out) - # Cursor -> Open VSX https listing. A cursor: scheme would be stripped by GitHub. - self.assertIn("[**Cursor**](https://open-vsx.org/extension/CodeBoarding/codeboarding)", out) - self.assertNotIn("cursor:extension", out) - self.assertNotIn("Get the extension", out) # dropped without a proxy - self.assertNotIn("VS Code", out) # cursor-only repo - - def test_no_proxy_vscode_marketplace_https_no_banner_at_zero(self): - out = bc.build_cta("", "o", "r", "1", repo_with()) # neither dir, no issues - self.assertIn( - "[**VS Code**](https://marketplace.visualstudio.com/items?itemName=Codeboarding.codeboarding)", - out, - ) - self.assertNotIn("vscode:extension", out) # custom scheme stripped by GitHub - self.assertNotIn("Get the extension", out) - self.assertNotIn("architecture issue", out) # banner suppressed at 0 issues - - def test_links_banner_and_cursor_only(self): - out = bc.build_cta("https://x.dev/", "Org", "Repo", "9", repo_with(".cursor"), issues=2) - self.assertIn("2 architecture issues found", out) - self.assertIn("open-in-editor?owner=Org&repo=Repo&pr=9&editor=cursor", out) - self.assertIn("use-marketplace?owner=Org&repo=Repo&pr=9", out) # proxy "Get the extension" - self.assertNotIn("VS Code", out) # cursor-only repo - - def test_no_banner_when_zero_issues_and_default_vscode(self): - out = bc.build_cta("https://x.dev", "o", "r", "1", repo_with(), issues=0) - self.assertNotIn("architecture issue", out) - self.assertIn("VS Code", out) - self.assertNotIn("Cursor", out) - - def test_both_editors_singular_issue(self): - out = bc.build_cta("https://x.dev", "o", "r", "1", repo_with(".vscode", ".cursor"), issues=1) - self.assertIn("1 architecture issue found", out) # singular - self.assertIn("VS Code", out) - self.assertIn("Cursor", out) - - def test_trailing_slash_in_base_is_normalized(self): - a = bc.build_cta("https://x.dev/", "o", "r", "1", repo_with()) - b = bc.build_cta("https://x.dev", "o", "r", "1", repo_with()) - self.assertNotIn("x.dev//", a) - self.assertEqual(a, b) - - -class TestWebviewUrl(unittest.TestCase): - WV = "https://app.codeboarding.org" - - def test_url_is_github_style_pr_path(self): - url = bc.webview_url(self.WV, "Org", "Repo", pr="9", run_id="123") - self.assertEqual(url, "https://app.codeboarding.org/Org/Repo/pull/9?run=123") - - def test_url_carries_only_pr_path_and_run(self): - # Head/base SHAs and the artifact name/url are re-derived by the webview, so - # none of them appear in the short link. - url = bc.webview_url(self.WV, "o", "r", pr="9", run_id="123") - self.assertIn("/o/r/pull/9", url) - self.assertIn("run=123", url) - self.assertNotIn("ref=", url) - self.assertNotIn("compare=", url) - self.assertNotIn("artifact", url) - self.assertNotIn("repo=o%2Fr", url) # not the old query-style link - - def test_url_none_without_pr_or_run(self): - self.assertIsNone(bc.webview_url(self.WV, "o", "r", pr="9")) # no run - self.assertIsNone(bc.webview_url(self.WV, "o", "r", run_id="123")) # no pr - self.assertIsNone(bc.webview_url("", "o", "r", pr="9", run_id="123")) # no base - - def test_trailing_slash_in_webview_base_is_normalized(self): - a = bc.webview_url("https://app.codeboarding.org/", "o", "r", pr="9", run_id="1") - b = bc.webview_url("https://app.codeboarding.org", "o", "r", pr="9", run_id="1") - self.assertEqual(a, b) - self.assertNotIn(".org//", a) - - def test_cta_includes_browser_link_when_ready(self): - out = bc.build_cta( - "", - "Org", - "Repo", - "9", - repo_with(), - issues=0, - webview_base=self.WV, - webview_ready=True, - run_id="123", - ) - self.assertIn("Explore this PR", out) - self.assertIn("your [**browser**](", out) - self.assertIn("/Org/Repo/pull/9?run=123", out) - self.assertIn("VS Code", out) # editor merged into the same line - - def test_cta_omits_browser_link_when_not_ready(self): - # No uploaded analysis artifact -> webview can't fetch PR-specific data. - out = bc.build_cta( - "", - "Org", - "Repo", - "9", - repo_with(), - issues=0, - webview_base=self.WV, - webview_ready=False, - run_id="123", - ) - self.assertNotIn("/pull/", out) # no browser link - self.assertNotIn("[**browser**]", out) - self.assertIn("Explore this PR", out) # the line is still there, editor-only - self.assertIn("VS Code", out) - - def test_cta_omits_browser_link_when_ready_but_no_base_url(self): - out = bc.build_cta( - "", - "Org", - "Repo", - "9", - repo_with(), - issues=0, - webview_base="", - webview_ready=True, - run_id="123", - ) - self.assertNotIn("[**browser**]", out) - self.assertNotIn("/pull/", out) - - -class TestJoinOr(unittest.TestCase): - def test_join_shapes(self): - self.assertEqual(bc._join_or(["a"]), "a") - self.assertEqual(bc._join_or(["a", "b"]), "a or b") - self.assertEqual(bc._join_or(["a", "b", "c"]), "a, b, or c") - - -class TestMergedExploreLine(unittest.TestCase): - WV = "https://app.codeboarding.org" - - def _ready(self, repo, cta=""): - return bc.build_cta(cta, "o", "r", "1", repo, webview_base=self.WV, webview_ready=True, run_id="123") - - def test_browser_and_single_editor_joined_with_or(self): - out = self._ready(repo_with()) # default VS Code - self.assertIn("in your [**browser**](", out) - self.assertIn(") or [**VS Code**](", out) # browser editor on one line - - def test_editor_only_has_no_your_and_no_browser(self): - out = bc.build_cta("", "o", "r", "1", repo_with()) # no webview - self.assertIn("architecture in [**VS Code**](", out) # "in " with no "your" - self.assertNotIn("browser", out) - - def test_browser_and_two_editors_use_oxford_or(self): - out = self._ready(repo_with(".vscode", ".cursor")) - self.assertIn("your [**browser**](", out) - self.assertIn(", or [**Cursor**](", out) # 3 targets -> ", or" before the last - - def test_two_editors_no_browser_joined_with_or(self): - out = bc.build_cta("", "o", "r", "1", repo_with(".vscode", ".cursor")) - self.assertIn(" or [**Cursor**](", out) - self.assertNotIn(", or [**Cursor**]", out) # 2 targets -> plain "or", no Oxford comma - self.assertNotIn("browser", out) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_engine_adapter.py b/tests/test_engine_adapter.py deleted file mode 100644 index 433e7f9..0000000 --- a/tests/test_engine_adapter.py +++ /dev/null @@ -1,998 +0,0 @@ -"""Smoke tests for scripts/engine_adapter.py — verify it calls the engine API correctly, -using stub modules so no real engine venv is needed.""" - -import json -import os -import subprocess -import sys -import tempfile -import types -import unittest -from contextlib import redirect_stderr, redirect_stdout -from io import StringIO -from pathlib import Path -from unittest.mock import patch - - -def _preload(name, **attrs): - m = types.ModuleType(name) - for k, v in attrs.items(): - setattr(m, k, v) - sys.modules[name] = m - return m - - -class _InitialBaselineUnavailableError(Exception): - pass - - -class _InitialIncrementalCacheMissingError(Exception): - pass - - -class _InitialSeverity: - WARNING, CRITICAL = "warning", "critical" - - -class _InitialStaticAnalysisCache: - def __init__(self, *args, **kwargs): - pass - - def get(self): - return None - - def save(self, *args, **kwargs): - pass - - -class _RunPaths: - def __init__(self, repo_path=None, output_dir=None, project_name=None): - self.repo_path, self.output_dir, self.project_name = repo_path, output_dir, project_name - - -class _RunContext: - def __init__(self, run_id=None, log_path=None, repo_dir=None): - self.run_id, self.log_path, self.repo_dir = run_id, log_path, repo_dir - - -class _InitialUnifiedAnalysisJson: - def __init__(self, data): - self.data = data - - @classmethod - def model_validate(cls, data): - return cls(data) - - def model_dump(self, **kwargs): - return self.data - - -class _RejectingUnifiedAnalysisJson: - @classmethod - def model_validate(cls, data): - raise ValueError("incompatible analysis schema") - - -class _LossyUnifiedAnalysisJson(_InitialUnifiedAnalysisJson): - def model_dump(self, **kwargs): - return {"normalized": True} - - -analysis = _preload( - "codeboarding_workflows.analysis", - run_full=lambda *a, **k: "OUT", - run_incremental=lambda *a, **k: "OUT", - BaselineUnavailableError=_InitialBaselineUnavailableError, -) -pkg = _preload("codeboarding_workflows") -pkg.analysis = analysis -exc = _preload("diagram_analysis.exceptions", IncrementalCacheMissingError=_InitialIncrementalCacheMissingError) -da = _preload("diagram_analysis", RunPaths=_RunPaths, RunContext=_RunContext) -da.exceptions = exc -_preload("diagram_analysis.analysis_json", UnifiedAnalysisJson=_InitialUnifiedAnalysisJson) -_preload("diagram_analysis.io_utils", write_fingerprint=lambda *a, **k: None) -_preload("logging_config", setup_logging=lambda **kwargs: None) -_preload("agents.content_hash", hash_repo_source_files=lambda *a, **k: {}) -_preload("agents") -_preload("codeboarding_workflows.rendering", render_docs=lambda *args, **kwargs: None) -_preload("health.models", Severity=_InitialSeverity) -_preload("health.runner", run_health_checks=lambda *args, **kwargs: None) -_preload("health") -_preload("static_analyzer", get_static_analysis=lambda *args, **kwargs: {}) -_preload("static_analyzer.analysis_cache", StaticAnalysisCache=_InitialStaticAnalysisCache) -_preload("static_analyzer.cluster_helpers", build_all_cluster_results=lambda *args, **kwargs: {}) - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -import engine_adapter # noqa: E402 - -_STUBBED = [ - "agents", - "agents.content_hash", - "codeboarding_workflows", - "codeboarding_workflows.analysis", - "diagram_analysis", - "diagram_analysis.analysis_json", - "diagram_analysis.exceptions", - "diagram_analysis.io_utils", - "logging_config", - "health", - "health.models", - "health.runner", - "static_analyzer", - "static_analyzer.analysis_cache", - "static_analyzer.cluster_helpers", -] - - -class _Rec: - def __init__(self, ret="OUT", raises=None): - self.calls = [] # kwargs of each call - self.args = [] # positional args of each call - self._ret, self._raises = ret, raises - - def __call__(self, *a, **k): - self.calls.append(k) - self.args.append(a) - if self._raises: - raise self._raises("boom") - return self._ret - - -def _mod(name, **attrs): - m = types.ModuleType(name) - for k, v in attrs.items(): - setattr(m, k, v) - sys.modules[name] = m - return m - - -def _write_model_valid_analysis(output_dir, **metadata): - path = Path(output_dir) - path.mkdir(parents=True, exist_ok=True) - (path / "analysis.json").write_text( - json.dumps({"metadata": metadata}), - encoding="utf-8", - ) - - -class _Base(unittest.TestCase): - def tearDown(self): - for n in _STUBBED: - sys.modules.pop(n, None) - - -class TestAnalysis(_Base): - def _install(self, run_full=None, run_incremental=None): - class BaselineUnavailableError(Exception): - pass - - class IncrementalCacheMissingError(Exception): - pass - - analysis = _mod( - "codeboarding_workflows.analysis", - run_full=run_full or _Rec(), - run_incremental=run_incremental or _Rec(), - BaselineUnavailableError=BaselineUnavailableError, - ) - pkg = _mod("codeboarding_workflows") - pkg.analysis = analysis - exc = _mod("diagram_analysis.exceptions", IncrementalCacheMissingError=IncrementalCacheMissingError) - da = _mod("diagram_analysis") - da.exceptions = exc - engine_adapter.run_full = analysis.run_full - engine_adapter.run_incremental = analysis.run_incremental - engine_adapter.BaselineUnavailableError = BaselineUnavailableError - engine_adapter.IncrementalCacheMissingError = IncrementalCacheMissingError - return analysis, IncrementalCacheMissingError, BaselineUnavailableError - - def test_base_calls_run_full(self): - rf = _Rec() - self._install(run_full=rf) - engine_adapter.run_base("/repo", "/out", "myrepo", "rid-base", 2, "abc123") - self.assertEqual(len(rf.calls), 1) - run_paths, run_context = rf.args[0] - self.assertEqual(run_paths.project_name, "myrepo") - self.assertEqual(str(run_paths.repo_path), "/repo") - self.assertEqual(str(run_paths.output_dir), "/out") - self.assertEqual(run_context.run_id, "rid-base") - self.assertEqual(rf.calls[0]["depth_level"], 2) - self.assertEqual(rf.calls[0]["source_sha"], "abc123") - - def test_main_parses_depth_as_int(self): - rf = _Rec() - self._install(run_full=rf) - engine_adapter.main( - [ - "base", - "--repo", - "/repo", - "--out", - "/out", - "--name", - "myrepo", - "--run-id", - "rid-base", - "--depth", - "2", - "--source-sha", - "abc123", - ] - ) - self.assertEqual(rf.calls[0]["depth_level"], 2) - - def test_main_enables_engine_console_logging(self): - self._install() - setup_logging = _Rec() - with ( - patch.object(engine_adapter, "setup_logging", setup_logging), - patch.dict(os.environ, {"CODEBOARDING_LOG_LEVEL": "DEBUG"}), - ): - engine_adapter.main( - [ - "base", - "--repo", - "/repo", - "--out", - "/out", - "--name", - "myrepo", - "--run-id", - "rid-base", - "--depth", - "2", - "--source-sha", - "abc123", - ] - ) - - self.assertEqual(setup_logging.calls, [{"default_level": "DEBUG"}]) - - def test_main_sets_github_action_source(self): - rf = _Rec() - self._install(run_full=rf) - with patch.dict(os.environ, {}, clear=True): - engine_adapter.main( - [ - "base", - "--repo", - "/repo", - "--out", - "/out", - "--name", - "myrepo", - "--run-id", - "rid-base", - "--depth", - "2", - "--source-sha", - "abc123", - ] - ) - self.assertEqual(os.environ["CODEBOARDING_SOURCE"], "github_action") - - def test_main_rejects_invalid_depth(self): - # argparse enforces the structural range 1-10; the per-tier cap (free=3) - # is applied later by the action/resolver, not here. - for depth in ("0", "11", "x"): - with self.subTest(depth=depth): - with redirect_stderr(StringIO()): - with self.assertRaises(SystemExit): - engine_adapter.main( - [ - "base", - "--repo", - "/repo", - "--out", - "/out", - "--name", - "myrepo", - "--run-id", - "rid-base", - "--depth", - depth, - "--source-sha", - "abc123", - ] - ) - - def test_main_accepts_depth_four(self): - # The action's accepted depth ceiling is 4 so a committed depth-4 baseline - # is a first-class value review can inherit (the engine has no depth cap). - rf = _Rec() - self._install(run_full=rf) - engine_adapter.main( - [ - "base", - "--repo", - "/repo", - "--out", - "/out", - "--name", - "myrepo", - "--run-id", - "rid-base", - "--depth", - "4", - "--source-sha", - "abc123", - ] - ) - self.assertEqual(rf.calls[0]["depth_level"], 4) - - def test_head_uses_incremental(self): - ri, rf = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = tempfile.mkdtemp() - _write_model_valid_analysis(out, depth_level=1) - buf = StringIO() - with redirect_stdout(buf): - engine_adapter.run_head("/repo", out, "r", "rid", 1, "head") - self.assertEqual(len(ri.calls), 1) - self.assertEqual(len(rf.calls), 0) # no fallback - # Git-free: no base/target ref — Core diffs the seeded fingerprint itself. - run_paths, run_context = ri.args[0] - self.assertEqual(str(run_paths.repo_path), "/repo") - self.assertEqual(str(run_paths.output_dir), out) - self.assertEqual(run_context.run_id, "rid") - self.assertIn("head_analysis_mode=incremental", buf.getvalue()) - - def test_head_force_full_skips_incremental(self): - ri, rf = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = tempfile.mkdtemp() - (Path(out) / "stale.json").write_text("{}") - buf = StringIO() - - with redirect_stdout(buf): - engine_adapter.run_head("/repo", out, "r", "rid", 2, "head", force_full=True) - - self.assertEqual(len(ri.calls), 0) - self.assertEqual(len(rf.calls), 1) - self.assertEqual(rf.calls[0]["depth_level"], 2) - self.assertEqual(rf.calls[0]["source_sha"], "head") - self.assertFalse((Path(out) / "stale.json").exists()) - self.assertIn("head_analysis_mode=full", buf.getvalue()) - - def test_head_falls_back_to_full_on_cache_miss(self): - analysis, IncMiss, _ = self._install() # install once so the exception class identity matches - rf = _Rec() - analysis.run_full = rf - analysis.run_incremental = _Rec(raises=IncMiss) - engine_adapter.run_full = analysis.run_full - engine_adapter.run_incremental = analysis.run_incremental - out = tempfile.mkdtemp() - _write_model_valid_analysis(out, depth_level=3) - (Path(out) / "stale.json").write_text("{}") # must be wiped before the full run - (Path(out) / "health").mkdir() - (Path(out) / "health" / "stale.json").write_text("{}") - buf = StringIO() - with redirect_stdout(buf): - engine_adapter.run_head("/repo", out, "r", "rid", 3, "head") - self.assertEqual(len(rf.calls), 1) # fell back to full - self.assertEqual(rf.calls[0]["depth_level"], 3) - self.assertFalse((Path(out) / "stale.json").exists()) # head dir wiped before full - self.assertFalse((Path(out) / "health").exists()) # nested stale artifacts wiped too - self.assertIn("head_analysis_mode=full", buf.getvalue()) - - def test_head_falls_back_to_full_on_baseline_unavailable(self): - analysis, _, BaseUnavail = self._install() # the other warm-start failure must also fall back - rf = _Rec() - analysis.run_full = rf - analysis.run_incremental = _Rec(raises=BaseUnavail) - engine_adapter.run_full = analysis.run_full - engine_adapter.run_incremental = analysis.run_incremental - out = tempfile.mkdtemp() - _write_model_valid_analysis(out, depth_level=1) - engine_adapter.run_head("/repo", out, "r", "rid", 1, "head") - self.assertEqual(len(rf.calls), 1) # BaselineUnavailableError also triggers the full re-run - - def test_head_rebuilds_analysis_rejected_by_core_model(self): - ri, rf = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = Path(tempfile.mkdtemp()) - (out / "analysis.json").write_text( - json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 3}}), - encoding="utf-8", - ) - (out / "stale.json").write_text("{}", encoding="utf-8") - buf = StringIO() - - with patch.object(engine_adapter, "UnifiedAnalysisJson", _RejectingUnifiedAnalysisJson): - with redirect_stdout(buf): - engine_adapter.run_head("/repo", str(out), "r", "rid", 1, "head") - - self.assertEqual(len(ri.calls), 0) - self.assertEqual(len(rf.calls), 1) - self.assertEqual(rf.calls[0]["depth_level"], 3) - self.assertFalse((out / "stale.json").exists()) - self.assertIn("could not load baseline analysis.json", buf.getvalue()) - self.assertIn("head_analysis_mode=full", buf.getvalue()) - - -class TestValidateBase(_Base): - def test_validate_base_accepts_matching_commit(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text(json.dumps({"metadata": {"commit_hash": "abc123"}}), encoding="utf-8") - - ok, message = engine_adapter.validate_base_analysis(path, "abc123") - - self.assertTrue(ok) - self.assertIn("matches", message) - - def test_validate_base_accepts_mismatched_commit(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text(json.dumps({"metadata": {"commit_hash": "old"}}), encoding="utf-8") - - ok, message = engine_adapter.validate_base_analysis(path, "new") - - self.assertTrue(ok) - self.assertIn("old", message) - self.assertIn("new", message) - - def test_validate_base_accepts_docs_only_bot_commit(self): - with tempfile.TemporaryDirectory() as tmp: - repo = Path(tmp) / "repo" - repo.mkdir() - self._git(repo, "init") - self._git(repo, "config", "user.name", "Test") - self._git(repo, "config", "user.email", "test@example.com") - (repo / "app.py").write_text("print('base')\n", encoding="utf-8") - self._git(repo, "add", "app.py") - self._git(repo, "commit", "-m", "base") - base_sha = self._git(repo, "rev-parse", "HEAD").stdout.strip() - - (repo / ".codeboarding").mkdir() - (repo / ".codeboarding" / "analysis.json").write_text( - json.dumps({"metadata": {"commit_hash": base_sha}}), - encoding="utf-8", - ) - (repo / ".codeboarding" / "overview.md").write_text("overview\n", encoding="utf-8") - (repo / "docs" / "development").mkdir(parents=True) - (repo / "docs" / "development" / "architecture.md").write_text("overview\n", encoding="utf-8") - self._git(repo, "add", ".codeboarding", "docs/development/architecture.md") - self._git(repo, "commit", "-m", "docs bot") - docs_sha = self._git(repo, "rev-parse", "HEAD").stdout.strip() - - cwd = os.getcwd() - try: - os.chdir(repo) - ok, message = engine_adapter.validate_base_analysis(repo / ".codeboarding" / "analysis.json", docs_sha) - finally: - os.chdir(cwd) - - self.assertTrue(ok) - self.assertIn("Using committed baseline", message) - - def test_validate_base_accepts_committed_baseline_even_after_code_drift(self): - with tempfile.TemporaryDirectory() as tmp: - repo = Path(tmp) / "repo" - repo.mkdir() - self._git(repo, "init") - self._git(repo, "config", "user.name", "Test") - self._git(repo, "config", "user.email", "test@example.com") - (repo / "app.py").write_text("print('base')\n", encoding="utf-8") - self._git(repo, "add", "app.py") - self._git(repo, "commit", "-m", "base") - base_sha = self._git(repo, "rev-parse", "HEAD").stdout.strip() - (repo / ".codeboarding").mkdir() - analysis_path = repo / ".codeboarding" / "analysis.json" - analysis_path.write_text(json.dumps({"metadata": {"commit_hash": base_sha}}), encoding="utf-8") - (repo / "app.py").write_text("print('changed')\n", encoding="utf-8") - self._git(repo, "add", "app.py", ".codeboarding/analysis.json") - self._git(repo, "commit", "-m", "code change") - code_sha = self._git(repo, "rev-parse", "HEAD").stdout.strip() - - cwd = os.getcwd() - try: - os.chdir(repo) - ok, message = engine_adapter.validate_base_analysis(analysis_path, code_sha) - finally: - os.chdir(cwd) - - self.assertTrue(ok) - self.assertIn("Using committed baseline", message) - - def _git(self, repo, *args): - return subprocess.run( - ["git", *args], - cwd=repo, - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - def test_validate_base_accepts_missing_commit(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text(json.dumps({"metadata": {}}), encoding="utf-8") - - ok, message = engine_adapter.validate_base_analysis(path, "abc123") - - self.assertTrue(ok) - self.assertIn("commit_hash", message) - - def test_validate_base_rejects_lossy_model_load(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text( - json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 2}}), - encoding="utf-8", - ) - - with patch.object(engine_adapter, "UnifiedAnalysisJson", _LossyUnifiedAnalysisJson): - ok, message = engine_adapter.validate_base_analysis(path, "abc123") - - self.assertFalse(ok) - self.assertIn("could not load baseline analysis.json", message) - self.assertIn("without schema changes", message) - self.assertIn("full analysis", message) - - def test_main_validate_base_exit_codes(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text(json.dumps({"metadata": {"commit_hash": "abc123"}}), encoding="utf-8") - - self.assertEqual( - engine_adapter.main(["validate-base", "--analysis", str(path), "--expected-sha", "abc123"]), - 0, - ) - self.assertEqual( - engine_adapter.main(["validate-base", "--analysis", str(path), "--expected-sha", "def456"]), - 0, - ) - - def test_validate_base_accepts_matching_depth(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text( - json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 2}}), - encoding="utf-8", - ) - - ok, message = engine_adapter.validate_base_analysis(path, "abc123", expected_depth=2) - - self.assertTrue(ok) - self.assertIn("matches", message) - - def test_validate_base_rejects_deeper_baseline(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text( - json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 3}}), - encoding="utf-8", - ) - - ok, message = engine_adapter.validate_base_analysis(path, "abc123", expected_depth=1) - - self.assertFalse(ok) - self.assertIn("3", message) # baseline depth - self.assertIn("1", message) # expected depth - - def test_validate_base_accepts_shallower_baseline(self): - # The engine records the depth REACHED, not requested: a depth-2 run on - # a repo that never expands persists depth_level 1. Rejecting it would - # regenerate (computing 1 again) on every PR without converging. - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text( - json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 1}}), - encoding="utf-8", - ) - - ok, _ = engine_adapter.validate_base_analysis(path, "abc123", expected_depth=3) - - self.assertTrue(ok) - - def test_validate_base_depth_checked_on_drift_path(self): - # The deeper-baseline rejection must also apply when the commit matched - # only via the docs-only-drift allowance, not just on exact SHA match. - with tempfile.TemporaryDirectory() as tmp: - repo = Path(tmp) / "repo" - repo.mkdir() - self._git(repo, "init") - self._git(repo, "config", "user.name", "Test") - self._git(repo, "config", "user.email", "test@example.com") - (repo / "app.py").write_text("print('base')\n", encoding="utf-8") - self._git(repo, "add", "app.py") - self._git(repo, "commit", "-m", "base") - base_sha = self._git(repo, "rev-parse", "HEAD").stdout.strip() - - (repo / ".codeboarding").mkdir() - analysis_path = repo / ".codeboarding" / "analysis.json" - analysis_path.write_text( - json.dumps({"metadata": {"commit_hash": base_sha, "depth_level": 3}}), - encoding="utf-8", - ) - self._git(repo, "add", ".codeboarding") - self._git(repo, "commit", "-m", "docs bot") - docs_sha = self._git(repo, "rev-parse", "HEAD").stdout.strip() - - cwd = os.getcwd() - try: - os.chdir(repo) - ok_drift, _ = engine_adapter.validate_base_analysis(analysis_path, docs_sha) - ok_depth, message = engine_adapter.validate_base_analysis(analysis_path, docs_sha, expected_depth=1) - finally: - os.chdir(cwd) - - self.assertTrue(ok_drift) # drift alone is accepted... - self.assertFalse(ok_depth) # ...but the depth check still applies - self.assertIn("deeper", message) - - def test_validate_base_accepts_legacy_baseline_without_depth(self): - # Missing or unparseable depth_level remains acceptable when the - # installed Core model accepts the document. - for metadata in ( - {"commit_hash": "abc123"}, - {"commit_hash": "abc123", "depth_level": "not-a-number"}, - ): - with self.subTest(metadata=metadata): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text(json.dumps({"metadata": metadata}), encoding="utf-8") - - ok, _ = engine_adapter.validate_base_analysis(path, "abc123", expected_depth=2) - - self.assertTrue(ok) - - def test_validate_base_without_expected_depth_ignores_depth(self): - # No --expected-depth -> behavior unchanged even when depth_level disagrees. - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text( - json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 3}}), - encoding="utf-8", - ) - - ok, message = engine_adapter.validate_base_analysis(path, "abc123") - - self.assertTrue(ok) - self.assertIn("matches", message) - - def test_validate_base_accepts_depth_four_baseline(self): - # The core fix: review inherits the committed baseline's depth, so a - # depth-4 baseline validated at --expected-depth 4 is accepted (reused, - # not regenerated). Validated at a shallower expected depth it is still - # rejected (an explicit shallower depth_level input). - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text( - json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 4}}), - encoding="utf-8", - ) - - ok_same, _ = engine_adapter.validate_base_analysis(path, "abc123", expected_depth=4) - ok_shallower, message = engine_adapter.validate_base_analysis(path, "abc123", expected_depth=2) - - self.assertTrue(ok_same) - self.assertFalse(ok_shallower) - self.assertIn("deeper", message) - - def test_main_validate_base_expected_depth_exit_codes(self): - # patch.dict: main() setdefaults CODEBOARDING_SOURCE; don't leak it. - with patch.dict(os.environ), tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text( - json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 2}}), - encoding="utf-8", - ) - - self.assertEqual( - engine_adapter.main( - ["validate-base", "--analysis", str(path), "--expected-sha", "abc123", "--expected-depth", "2"] - ), - 0, - ) - self.assertEqual( - engine_adapter.main( - ["validate-base", "--analysis", str(path), "--expected-sha", "abc123", "--expected-depth", "1"] - ), - 1, - ) - # depth 4 is now an accepted value (against a depth-2 baseline a - # shallower-or-equal expected depth passes the depth check). - self.assertEqual( - engine_adapter.main( - ["validate-base", "--analysis", str(path), "--expected-sha", "abc123", "--expected-depth", "4"] - ), - 0, - ) - with redirect_stderr(StringIO()): - with self.assertRaises(SystemExit): # depth outside 1-10 rejected by argparse - engine_adapter.main( - ["validate-base", "--analysis", str(path), "--expected-sha", "abc123", "--expected-depth", "11"] - ) - - -class TestSeed(_Base): - """run_seed must analyze, cluster, then save — in that order, same results object. - - The save-after-clustering order is the point of the subcommand: the engine - persists a pkl on LSP teardown BEFORE clustering, and a pkl saved then has - no cluster baseline, which is exactly the state that forces the head run - into a full-analysis fallback. - """ - - def _install(self, fail_at=None): - log = [] - results = object() - - def get_static_analysis(repo_path, cache_dir, skip_cache=False, source_sha=None): - log.append(("analyze", str(repo_path), str(cache_dir), source_sha)) - if fail_at == "analyze": - raise RuntimeError("boom") - return results - - def build_all_cluster_results(res): - log.append(("cluster", res)) - if fail_at == "cluster": - raise RuntimeError("boom") - return {"python": types.SimpleNamespace(clusters={1: {"a"}, 2: {"b"}})} - - class _Cache: - def __init__(self, artifact_dir, repo_root): - log.append(("cache_init", str(artifact_dir), str(repo_root))) - - def save(self, res, source_sha=None): - log.append(("save", res, source_sha)) - - sa = _mod("static_analyzer", get_static_analysis=get_static_analysis) - sa.cluster_helpers = _mod( - "static_analyzer.cluster_helpers", build_all_cluster_results=build_all_cluster_results - ) - sa.analysis_cache = _mod("static_analyzer.analysis_cache", StaticAnalysisCache=_Cache) - engine_adapter.get_static_analysis = get_static_analysis - engine_adapter.build_all_cluster_results = build_all_cluster_results - engine_adapter.StaticAnalysisCache = _Cache - return log, results - - def test_seed_analyzes_clusters_then_saves(self): - log, results = self._install() - engine_adapter.run_seed("/repo", "/out", "abc123") - self.assertEqual( - log, - [ - ("analyze", "/repo", "/out", "abc123"), - ("cluster", results), - ("cache_init", "/out", "/repo"), - ("save", results, "abc123"), - ], - ) - - def test_seed_propagates_engine_errors(self): - # Fail-open lives in the action step; run_seed itself must not swallow. - for stage in ("analyze", "cluster"): - with self.subTest(stage=stage): - log, _ = self._install(fail_at=stage) - with self.assertRaises(RuntimeError): - engine_adapter.run_seed("/repo", "/out", "abc123") - self.assertNotIn("save", [e[0] for e in log]) - self.tearDown() - - def test_main_seed_wires_args(self): - log, _ = self._install() - rc = engine_adapter.main(["seed", "--repo", "/r", "--out", "/o", "--source-sha", "s1"]) - self.assertEqual(rc, 0) - self.assertEqual(log[0], ("analyze", "/r", "/o", "s1")) - self.assertEqual(log[-1][0], "save") - - -class TestHealth(_Base): - def _install_health(self, report): - class Severity: - WARNING, CRITICAL = "warning", "critical" - - class _Cache: - def __init__(self, artifact_dir, repo_root): - pass - - def get(self): - return object() # non-None static analysis - - _mod("health.models", Severity=Severity) - _mod("health.runner", run_health_checks=lambda sa, repo_name, repo_path: report) - _mod( - "health", - ) - _mod("static_analyzer.analysis_cache", StaticAnalysisCache=_Cache) - _mod( - "static_analyzer", - ) - engine_adapter.Severity = Severity - engine_adapter.run_health_checks = lambda sa, repo_name, repo_path: report - engine_adapter.StaticAnalysisCache = _Cache - return Severity - - def test_counts_warning_and_critical(self): - Sev = self._install_health(report=None) - - class FG: - def __init__(self, sev, n): - self.severity, self.entities = sev, list(range(n)) - - class CS: - finding_groups = [FG(Sev.WARNING, 2), FG(Sev.CRITICAL, 1), FG("info", 5)] - - report = types.SimpleNamespace(check_summaries=[CS()]) - self._install_health(report=report) - self.assertEqual(engine_adapter.run_health("/art", "/repo", "r"), 3) # 2 warnings + 1 critical, info ignored - - def test_prefers_written_health_report(self): - artifact_dir = Path(tempfile.mkdtemp()) - report_dir = artifact_dir / "health" - report_dir.mkdir() - (report_dir / "health_report.json").write_text( - """ - { - "check_summaries": [ - {"finding_groups": [ - {"severity": "warning", "entities": [{}, {}]}, - {"severity": "critical", "entities": [{}]}, - {"severity": "info", "entities": [{}, {}, {}, {}, {}]} - ]} - ] - } - """, - encoding="utf-8", - ) - self.assertEqual(engine_adapter.run_health(str(artifact_dir), "/repo", "r"), 3) - - def test_malformed_health_report_falls_back(self): - self._install_health(report=None) - artifact_dir = Path(tempfile.mkdtemp()) - report_dir = artifact_dir / "health" - report_dir.mkdir() - (report_dir / "health_report.json").write_text("[]", encoding="utf-8") - self.assertEqual(engine_adapter.run_health(str(artifact_dir), "/repo", "r"), 0) - - def test_missing_module_yields_zero(self): - # Health failures are best-effort: return 0, never raise. - class _BrokenCache: - def __init__(self, *args, **kwargs): - raise ImportError("missing health dependency") - - old_cache = engine_adapter.StaticAnalysisCache - engine_adapter.StaticAnalysisCache = _BrokenCache - try: - self.assertEqual(engine_adapter.run_health("/art", "/repo", "r"), 0) - finally: - engine_adapter.StaticAnalysisCache = old_cache - - -class TestQuotaExhausted(_Base): - def test_detects_402_status_attr(self): - class APIErr(Exception): - status_code = 402 - - self.assertTrue(engine_adapter._is_quota_exhausted(APIErr("nope"))) - - def test_detects_status_attr(self): - class FunctionUrlErr(Exception): - status = 402 - - self.assertTrue(engine_adapter._is_quota_exhausted(FunctionUrlErr("nope"))) - - def test_detects_marker_string(self): - exc = RuntimeError("upstream said: Resource exhausted: token limit reached") - self.assertTrue(engine_adapter._is_quota_exhausted(exc)) - - def test_detects_in_cause_chain(self): - inner = RuntimeError("Resource exhausted: token limit reached") - try: - raise ValueError("wrapped") from inner - except ValueError as e: - self.assertTrue(engine_adapter._is_quota_exhausted(e)) - - def test_other_errors_not_flagged(self): - self.assertFalse(engine_adapter._is_quota_exhausted(RuntimeError("disk full"))) - - class OtherStatus(Exception): - status_code = 500 - - self.assertFalse(engine_adapter._is_quota_exhausted(OtherStatus("boom"))) - - def _install_raising(self, exc): - analysis = _mod( - "codeboarding_workflows.analysis", - run_full=_Rec(raises=exc), - run_incremental=_Rec(), - BaselineUnavailableError=type("BaselineUnavailableError", (Exception,), {}), - ) - pkg = _mod("codeboarding_workflows") - pkg.analysis = analysis - excmod = _mod( - "diagram_analysis.exceptions", - IncrementalCacheMissingError=type("IncrementalCacheMissingError", (Exception,), {}), - ) - da = _mod("diagram_analysis") - da.exceptions = excmod - engine_adapter.run_full = analysis.run_full - engine_adapter.run_incremental = analysis.run_incremental - engine_adapter.BaselineUnavailableError = analysis.BaselineUnavailableError - engine_adapter.IncrementalCacheMissingError = excmod.IncrementalCacheMissingError - - def _run_base(self): - return engine_adapter.main( - [ - "base", - "--repo", - "/r", - "--out", - "/o", - "--name", - "n", - "--run-id", - "rid", - "--depth", - "2", - "--source-sha", - "abc123", - ] - ) - - def test_main_drops_sentinel_on_quota_error(self): - class APIErr(Exception): - status_code = 402 - - self._install_raising(APIErr) - sentinel = Path(tempfile.mkdtemp()) / "cb-quota-exhausted" - with patch.dict(os.environ, {"CB_QUOTA_SENTINEL": str(sentinel)}): - with redirect_stderr(StringIO()): - with self.assertRaises(APIErr): # re-raised so the step still fails - self._run_base() - self.assertTrue(sentinel.exists(), "quota sentinel should be written") - - def test_main_no_sentinel_on_other_error(self): - self._install_raising(RuntimeError) - sentinel = Path(tempfile.mkdtemp()) / "cb-quota-exhausted" - with patch.dict(os.environ, {"CB_QUOTA_SENTINEL": str(sentinel)}): - with redirect_stderr(StringIO()): - with self.assertRaises(RuntimeError): - self._run_base() - self.assertFalse(sentinel.exists(), "non-quota errors must not write the sentinel") - - -class TestEngineRequired(_Base): - """A missing/too-old engine (RunPaths imported as None) fails the analysis - subcommands with a clear message, while metadata-only subcommands still run.""" - - def _argv(self, cmd): - run = ["--repo", "/r", "--out", "/o", "--name", "n", "--run-id", "id", "--source-sha", "s"] - return { - "base": [cmd, *run, "--depth", "2"], - "seed": [cmd, "--repo", "/r", "--out", "/o", "--source-sha", "s"], - "head": [cmd, *run, "--depth", "2"], - "validate-base": [cmd, "--analysis", "/a.json", "--expected-sha", "abc123"], - "analyze": [cmd, *run, "--depth", "2"], - "render": [cmd, "--analysis", "/a.json", "--out", "/o", "--repo-name", "n", "--repo-ref", "r"], - }[cmd] - - def test_engine_commands_fail_clearly_when_engine_missing(self): - for cmd in engine_adapter._ENGINE_COMMANDS: - with ( - self.subTest(cmd=cmd), - patch.object(engine_adapter, "RunPaths", None), - patch.object(engine_adapter, "UnifiedAnalysisJson", None), - ): - with self.assertRaises(RuntimeError) as ctx: - engine_adapter.main(self._argv(cmd)) - msg = str(ctx.exception) - self.assertIn(cmd, msg) - self.assertIn("too old", msg) - self.assertIn("codeboarding_version", msg) - - def test_metadata_command_runs_without_engine(self): - with tempfile.TemporaryDirectory() as d: - path = Path(d) / "analysis.json" - path.write_text(json.dumps({"metadata": {"commit_hash": "abc1234"}})) - with patch.object(engine_adapter, "RunPaths", None), redirect_stdout(StringIO()): - rc = engine_adapter.main(["baseline-info", "--analysis", str(path)]) - self.assertEqual(rc, 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_render_sync_docs.py b/tests/test_render_sync_docs.py new file mode 100644 index 0000000..775951e --- /dev/null +++ b/tests/test_render_sync_docs.py @@ -0,0 +1,92 @@ +"""Smoke tests for scripts/render_sync_docs.py.""" + +import sys +import tempfile +import unittest +from pathlib import Path +from types import ModuleType +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +stub_pkg = ModuleType("codeboarding_workflows") +stub_rendering = ModuleType("codeboarding_workflows.rendering") +stub_pkg.rendering = stub_rendering +sys.modules["codeboarding_workflows"] = stub_pkg +sys.modules["codeboarding_workflows.rendering"] = stub_rendering + +import render_sync_docs as rsd # noqa: E402 + + +class RenderSyncDocsTests(unittest.TestCase): + def setUp(self) -> None: + self.render_calls = [] + + def _make_fake_render(self, with_overview: bool = True): + def _render(analysis, repo_name, repo_ref, temp_dir, format=".md", root_name="overview"): + self.render_calls.append((analysis, repo_name, repo_ref, temp_dir, format, root_name)) + out = Path(temp_dir) + out.mkdir(parents=True, exist_ok=True) + if with_overview: + (out / "overview.md").write_text("# Overview\n", encoding="utf-8") + (out / "api.md").write_text("# API\n", encoding="utf-8") + (out / "zeta.md").write_text("# Zeta\n", encoding="utf-8") + return _render + + def test_concat_prefers_overview_first_and_appends_sorted(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + analysis = root / "analysis.json" + analysis.write_text("{}", encoding="utf-8") + output = root / "docs" + architecture = root / "architecture.md" + with patch.object(rsd, "render_docs", new=self._make_fake_render(True)): + rsd.main([ + "--analysis", + str(analysis), + "--output-dir", + str(output), + "--repo-name", + "org/repo", + "--repo-ref", + "abc123", + "--format", + ".md", + "--architecture-file", + str(architecture), + ]) + result = architecture.read_text(encoding="utf-8") + self.assertIn("# Overview", result) + self.assertIn("# API", result) + self.assertIn("# Zeta", result) + self.assertLess(result.index("# Overview"), result.index("# API")) + self.assertLess(result.index("# API"), result.index("# Zeta")) + self.assertEqual(len(self.render_calls), 1) + + def test_missing_overview_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + analysis = root / "analysis.json" + analysis.write_text("{}", encoding="utf-8") + output = root / "docs" + architecture = root / "architecture.md" + with patch.object(rsd, "render_docs", new=self._make_fake_render(False)): + with self.assertRaises(SystemExit): + rsd.main([ + "--analysis", + str(analysis), + "--output-dir", + str(output), + "--repo-name", + "org/repo", + "--repo-ref", + "abc123", + "--format", + ".md", + "--architecture-file", + str(architecture), + ]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_submit_feedback.py b/tests/test_submit_feedback.py deleted file mode 100644 index 29e1e75..0000000 --- a/tests/test_submit_feedback.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Unit tests for scripts/submit_feedback.py — /codeboarding-feedback capture.""" - -import io -import json -import sys -import unittest -from contextlib import redirect_stdout -from pathlib import Path -from unittest import mock - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -import submit_feedback as sf # noqa: E402 - -COMMAND = "/codeboarding-feedback" -HOST = "https://us.i.posthog.com" - - -def base_env(**overrides): - env = { - "COMMENT_BODY": f"{COMMAND} the diagram is great", - "FEEDBACK_COMMAND": COMMAND, - "REPOSITORY": "octo/repo", - "REPOSITORY_ID": "555", - "ISSUE_NUMBER": "42", - "COMMENT_ID": "99", - "COMMENT_URL": "https://github.com/octo/repo/pull/42#issuecomment-99", - "AUTHOR_ASSOC": "CONTRIBUTOR", - "SENDER_LOGIN": "octocat", - "SENDER_ID": "1234", - "GITHUB_RUN_ID": "777", - "RUN_ATTEMPT": "1", - "ACTION_REF": "v1", - } - env.update(overrides) - return env - - -class TestExtractFeedback(unittest.TestCase): - def test_extracts_text_after_command(self): - self.assertEqual(sf.extract_feedback(f"{COMMAND} hello there", COMMAND), "hello there") - - def test_preserves_multiline_feedback(self): - body = f"{COMMAND} first line\nsecond line\n\nfourth" - self.assertEqual(sf.extract_feedback(body, COMMAND), "first line\nsecond line\n\nfourth") - - def test_command_only_yields_empty(self): - self.assertEqual(sf.extract_feedback(COMMAND, COMMAND), "") - self.assertEqual(sf.extract_feedback(f"{COMMAND} ", COMMAND), "") - - def test_command_on_its_own_line_then_body(self): - self.assertEqual(sf.extract_feedback(f"{COMMAND}\nthe body", COMMAND), "the body") - - def test_leading_whitespace_and_crlf_normalized(self): - self.assertEqual(sf.extract_feedback(f" {COMMAND} a\r\nb\r\n", COMMAND), "a\nb") - - def test_wrong_command_yields_empty(self): - self.assertEqual(sf.extract_feedback("/codeboarding run it", COMMAND), "") - self.assertEqual(sf.extract_feedback(f"{COMMAND}-typo hi", COMMAND), "") - - -class TestCapFeedback(unittest.TestCase): - def test_short_text_not_truncated(self): - self.assertEqual(sf.cap_feedback("abc", 10), ("abc", 3, False)) - - def test_long_text_capped_and_marked(self): - capped, length, truncated = sf.cap_feedback("x" * 50, 10) - self.assertEqual(capped, "x" * 10) - self.assertEqual(length, 50) - self.assertTrue(truncated) - - -class TestOptOut(unittest.TestCase): - def test_do_not_track_disables(self): - self.assertTrue(sf.telemetry_disabled({"DO_NOT_TRACK": "1"})) - self.assertTrue(sf.telemetry_disabled({"DO_NOT_TRACK": "true"})) - - def test_codeboarding_telemetry_false_disables(self): - self.assertTrue(sf.telemetry_disabled({"CODEBOARDING_TELEMETRY": "false"})) - - def test_default_enabled(self): - self.assertFalse(sf.telemetry_disabled({})) - - -class TestResolvers(unittest.TestCase): - def test_key_and_host_defaults(self): - self.assertEqual(sf.resolve_key({}), sf.DEFAULT_POSTHOG_KEY) - self.assertEqual(sf.resolve_host({}), sf.DEFAULT_POSTHOG_HOST) - - def test_host_override_strips_trailing_slash(self): - self.assertEqual( - sf.resolve_host({"CODEBOARDING_POSTHOG_HOST": "https://eu.example.com/"}), "https://eu.example.com" - ) - - def test_max_chars_invalid_falls_back(self): - self.assertEqual(sf.resolve_max_chars({"FEEDBACK_MAX_CHARS": "nope"}), sf.DEFAULT_MAX_CHARS) - self.assertEqual(sf.resolve_max_chars({"FEEDBACK_MAX_CHARS": "0"}), sf.DEFAULT_MAX_CHARS) - self.assertEqual(sf.resolve_max_chars({"FEEDBACK_MAX_CHARS": "25"}), 25) - - def test_distinct_id_prefers_sender_then_run(self): - self.assertEqual(sf.distinct_id({"SENDER_ID": "5"}), "github-user:5") - self.assertEqual(sf.distinct_id({"GITHUB_RUN_ID": "9"}), "github-run:9") - - -class TestBuildPayload(unittest.TestCase): - def test_empty_feedback_returns_none(self): - self.assertIsNone(sf.build_payload(base_env(COMMENT_BODY=COMMAND))) - - def test_payload_shape(self): - payload = sf.build_payload(base_env()) - self.assertEqual(payload["event"], "codeboarding_feedback_submitted") - self.assertEqual(payload["distinct_id"], "github-user:1234") - self.assertEqual(payload["api_key"], sf.DEFAULT_POSTHOG_KEY) - props = payload["properties"] - self.assertEqual(props["source"], "github_action_feedback") - self.assertEqual(props["command"], COMMAND) - self.assertEqual(props["feedback_text"], "the diagram is great") - self.assertEqual(props["feedback_length"], len("the diagram is great")) - self.assertFalse(props["feedback_truncated"]) - self.assertEqual(props["repository"], "octo/repo") - self.assertEqual(props["repository_id"], "555") - self.assertEqual(props["pr_number"], "42") - self.assertEqual(props["comment_id"], "99") - self.assertEqual(props["author_association"], "CONTRIBUTOR") - self.assertEqual(props["sender_login"], "octocat") - self.assertEqual(props["run_id"], "777") - - def test_truncation_recorded_in_payload(self): - payload = sf.build_payload(base_env(COMMENT_BODY=f"{COMMAND} " + "y" * 50, FEEDBACK_MAX_CHARS="10")) - props = payload["properties"] - self.assertEqual(len(props["feedback_text"]), 10) - self.assertEqual(props["feedback_length"], 50) - self.assertTrue(props["feedback_truncated"]) - - def test_optional_props_omitted_when_absent(self): - payload = sf.build_payload({"COMMENT_BODY": f"{COMMAND} hi", "SENDER_ID": "1"}) - self.assertNotIn("repository", payload["properties"]) - self.assertNotIn("comment_url", payload["properties"]) - - -class TestMain(unittest.TestCase): - def _run(self, env): - with mock.patch.object(sf.urllib.request, "urlopen") as urlopen: - urlopen.return_value.__enter__.return_value.status = 200 - buf = io.StringIO() - with redirect_stdout(buf): - rc = sf.main(env) - return rc, urlopen, buf.getvalue() - - def test_sends_expected_json_shape(self): - rc, urlopen, _ = self._run(base_env()) - self.assertEqual(rc, 0) - urlopen.assert_called_once() - request = urlopen.call_args.args[0] - self.assertEqual(request.full_url, f"{HOST}/i/v0/e/") - self.assertEqual(request.get_method(), "POST") - self.assertEqual(request.headers.get("Content-type"), "application/json") - body = json.loads(request.data) - self.assertEqual(body["event"], "codeboarding_feedback_submitted") - self.assertEqual(body["distinct_id"], "github-user:1234") - self.assertEqual(body["properties"]["feedback_text"], "the diagram is great") - - def test_host_override_used(self): - _, urlopen, _ = self._run(base_env(CODEBOARDING_POSTHOG_HOST="https://eu.example.com")) - request = urlopen.call_args.args[0] - self.assertEqual(request.full_url, "https://eu.example.com/i/v0/e/") - - def test_do_not_track_skips_sending(self): - _, urlopen, out = self._run(base_env(DO_NOT_TRACK="1")) - urlopen.assert_not_called() - self.assertIn("disabled", out) - - def test_telemetry_false_skips_sending(self): - _, urlopen, _ = self._run(base_env(CODEBOARDING_TELEMETRY="false")) - urlopen.assert_not_called() - - def test_empty_feedback_not_sent(self): - _, urlopen, out = self._run(base_env(COMMENT_BODY=COMMAND)) - urlopen.assert_not_called() - self.assertIn("nothing to send", out) - - def test_does_not_print_feedback_text(self): - secret = "PLEASE_DO_NOT_LEAK_THIS_abc123" - _, _, out = self._run(base_env(COMMENT_BODY=f"{COMMAND} {secret}")) - self.assertNotIn(secret, out) - - def test_network_failure_is_swallowed(self): - with mock.patch.object(sf.urllib.request, "urlopen", side_effect=sf.urllib.error.URLError("down")): - buf = io.StringIO() - with redirect_stdout(buf): - rc = sf.main(base_env()) - self.assertEqual(rc, 0) - self.assertIn("ignoring", buf.getvalue()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_sync_subcommands.py b/tests/test_sync_subcommands.py deleted file mode 100644 index bad730b..0000000 --- a/tests/test_sync_subcommands.py +++ /dev/null @@ -1,719 +0,0 @@ -"""Smoke tests for the sync-mode subcommands of scripts/engine_adapter.py (analyze, -render, concat) with stubbed engine modules — ported from the standalone -docs-action's test_docs_engine.py. Seed tests are not ported: engine_adapter's seed -is byte-identical and already covered by tests/test_engine_adapter.py.""" - -import json -import os -import subprocess -import sys -import tempfile -import types -import unittest -from contextlib import redirect_stderr, redirect_stdout -from io import StringIO -from pathlib import Path -from unittest.mock import patch - - -def _preload(name, **attrs): - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - return module - - -class _InitialBaselineUnavailableError(Exception): - pass - - -class _InitialIncrementalCacheMissingError(Exception): - pass - - -class _InitialSeverity: - WARNING, CRITICAL = "warning", "critical" - - -class _InitialStaticAnalysisCache: - def __init__(self, *args, **kwargs): - pass - - def get(self): - return None - - def save(self, *args, **kwargs): - pass - - -class _RunPaths: - def __init__(self, repo_path=None, output_dir=None, project_name=None): - self.repo_path, self.output_dir, self.project_name = repo_path, output_dir, project_name - - -class _RunContext: - def __init__(self, run_id=None, log_path=None, repo_dir=None): - self.run_id, self.log_path, self.repo_dir = run_id, log_path, repo_dir - - -class _InitialUnifiedAnalysisJson: - def __init__(self, data): - self.data = data - - @classmethod - def model_validate(cls, data): - return cls(data) - - def model_dump(self, **kwargs): - return self.data - - -class _LossyUnifiedAnalysisJson(_InitialUnifiedAnalysisJson): - def model_dump(self, **kwargs): - return {"normalized": True} - - -analysis = _preload( - "codeboarding_workflows.analysis", - run_full=lambda *a, **k: "OUT", - run_incremental=lambda *a, **k: "OUT", - BaselineUnavailableError=_InitialBaselineUnavailableError, -) -pkg = _preload("codeboarding_workflows") -pkg.analysis = analysis -rendering = _preload("codeboarding_workflows.rendering", render_docs=lambda *args, **kwargs: None) -pkg.rendering = rendering -exc = _preload("diagram_analysis.exceptions", IncrementalCacheMissingError=_InitialIncrementalCacheMissingError) -da = _preload("diagram_analysis", RunPaths=_RunPaths, RunContext=_RunContext) -da.exceptions = exc -_preload("diagram_analysis.analysis_json", UnifiedAnalysisJson=_InitialUnifiedAnalysisJson) -_preload("diagram_analysis.io_utils", write_fingerprint=lambda *a, **k: None) -_preload("logging_config", setup_logging=lambda **kwargs: None) -_preload("agents.content_hash", hash_repo_source_files=lambda *a, **k: {}) -_preload("agents") -_preload("health.models", Severity=_InitialSeverity) -_preload("health.runner", run_health_checks=lambda *args, **kwargs: None) -_preload("health") -_preload("static_analyzer", get_static_analysis=lambda *args, **kwargs: {}) -_preload("static_analyzer.analysis_cache", StaticAnalysisCache=_InitialStaticAnalysisCache) -_preload("static_analyzer.cluster_helpers", build_all_cluster_results=lambda *args, **kwargs: {}) - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -import engine_adapter # noqa: E402 - -_STUBBED = [ - "agents", - "agents.content_hash", - "codeboarding_workflows", - "codeboarding_workflows.analysis", - "codeboarding_workflows.rendering", - "diagram_analysis", - "diagram_analysis.analysis_json", - "diagram_analysis.exceptions", - "diagram_analysis.io_utils", - "logging_config", - "static_analyzer", - "static_analyzer.analysis_cache", - "static_analyzer.cluster_helpers", -] - - -class _Rec: - def __init__(self, ret="OUT", raises=None): - self.calls = [] - self._ret = ret - self._raises = raises - - def __call__(self, *args, **kwargs): - self.calls.append((args, kwargs)) - if self._raises: - raise self._raises("boom") - return self._ret - - -def _mod(name, **attrs): - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - return module - - -def _write_analysis(out, *, commit="base123", depth=2): - path = Path(out) - path.mkdir(parents=True, exist_ok=True) - (path / "analysis.json").write_text( - json.dumps({"metadata": {"commit_hash": commit, "depth_level": depth}}), - encoding="utf-8", - ) - - -class _Base(unittest.TestCase): - def tearDown(self): - for name in _STUBBED: - sys.modules.pop(name, None) - - -class TestAnalyze(_Base): - def _install(self, run_full=None, run_incremental=None): - class BaselineUnavailableError(Exception): - pass - - class IncrementalCacheMissingError(Exception): - pass - - analysis = _mod( - "codeboarding_workflows.analysis", - run_full=run_full or _Rec(), - run_incremental=run_incremental or _Rec(), - BaselineUnavailableError=BaselineUnavailableError, - ) - pkg = _mod("codeboarding_workflows") - pkg.analysis = analysis - exc = _mod("diagram_analysis.exceptions", IncrementalCacheMissingError=IncrementalCacheMissingError) - da = _mod("diagram_analysis") - da.exceptions = exc - engine_adapter.run_full = analysis.run_full - engine_adapter.run_incremental = analysis.run_incremental - engine_adapter.BaselineUnavailableError = BaselineUnavailableError - engine_adapter.IncrementalCacheMissingError = IncrementalCacheMissingError - return analysis, IncrementalCacheMissingError, BaselineUnavailableError - - def test_no_baseline_runs_full(self): - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = tempfile.mkdtemp() - - mode = engine_adapter.run_analyze("/repo", out, "myrepo", "rid", "head123", 2) - - self.assertEqual(mode, "full") - self.assertEqual(len(rf.calls), 1) - self.assertEqual(len(ri.calls), 0) - run_paths, run_context = rf.calls[0][0] - self.assertEqual(run_paths.project_name, "myrepo") - self.assertEqual(str(run_paths.repo_path), "/repo") - self.assertEqual(rf.calls[0][1]["depth_level"], 2) - self.assertEqual(rf.calls[0][1]["source_sha"], "head123") - - def test_committed_baseline_runs_incremental(self): - # Git-free: a committed analysis.json is the baseline; incremental runs - # (Core diffs the committed fingerprint itself), with no commit_hash gate. - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = tempfile.mkdtemp() - _write_analysis(out, depth=2) - - mode = engine_adapter.run_analyze("/repo", out, "myrepo", "rid", "head123", 2) - - self.assertEqual(mode, "incremental") - self.assertEqual(len(rf.calls), 0) - self.assertEqual(len(ri.calls), 1) - run_paths, run_context = ri.calls[0][0] - self.assertEqual(str(run_paths.repo_path), "/repo") - self.assertEqual(run_context.run_id, "rid") - - def test_incompatible_baseline_runs_full_at_baseline_depth(self): - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = Path(tempfile.mkdtemp()) - _write_analysis(out, depth=3) - (out / "stale.json").write_text("{}", encoding="utf-8") - buf = StringIO() - - with patch.object(engine_adapter, "UnifiedAnalysisJson", _LossyUnifiedAnalysisJson): - with redirect_stdout(buf): - mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 1) - - self.assertEqual(mode, "full") - self.assertEqual(len(ri.calls), 0) - self.assertEqual(len(rf.calls), 1) - self.assertEqual(rf.calls[0][1]["depth_level"], 3) - self.assertFalse((out / "stale.json").exists()) - self.assertIn("could not load baseline analysis.json", buf.getvalue()) - self.assertEqual(self._markers(buf), ["analysis_mode=full"]) - - def test_deeper_baseline_still_runs_incremental(self): - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = Path(tempfile.mkdtemp()) - _write_analysis(out, commit="metadata-base", depth=3) - (out / "stale.json").write_text("{}", encoding="utf-8") - (out / "health").mkdir() - (out / "health" / "stale.json").write_text("{}", encoding="utf-8") - - mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 2) - - self.assertEqual(mode, "incremental") - self.assertEqual(len(rf.calls), 0) - self.assertEqual(len(ri.calls), 1) - self.assertTrue((out / "stale.json").exists()) - self.assertTrue((out / "health").exists()) - - def test_deep_baseline_runs_incremental_regardless_of_tier(self): - # A committed depth-7 baseline still runs incremental (the depth value - # doesn't gate incremental — baseline presence does); on the free tier the - # depth is clamped for any eventual run, but incremental is unaffected. - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = Path(tempfile.mkdtemp()) - _write_analysis(out, commit="metadata-base", depth=7) - - mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 2) - - self.assertEqual(mode, "incremental") - self.assertEqual(len(rf.calls), 0) - self.assertEqual(len(ri.calls), 1) - - def test_over_cap_depth_clamped_on_forced_full(self): - # When a full run happens (here: force_full), the requested depth is - # clamped to the tier ceiling: free clamps 7 -> 3, licensed keeps 7. - for licensed, expected in ((False, 3), (True, 7)): - with self.subTest(licensed=licensed): - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = Path(tempfile.mkdtemp()) - _write_analysis(out, depth=2) - - mode = engine_adapter.run_analyze( - "/repo", str(out), "myrepo", "rid", "head123", 7, force_full=True, licensed=licensed - ) - - self.assertEqual(mode, "full") - self.assertEqual(rf.calls[0][1]["depth_level"], expected) - - def test_shallower_baseline_runs_incremental(self): - # The engine records the depth REACHED, not requested: a depth-2 push on - # a repo that never expands keeps writing depth_level 1, so a strict != - # gate would run full on every push forever. - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = tempfile.mkdtemp() - _write_analysis(out, commit="metadata-base", depth=1) - - mode = engine_adapter.run_analyze("/repo", out, "myrepo", "rid", "head123", 2) - - self.assertEqual(mode, "incremental") - self.assertEqual(len(rf.calls), 0) - self.assertEqual(len(ri.calls), 1) - - def test_missing_depth_still_runs_incremental(self): - # A missing/unparseable depth_level is not a reason to force a full: the - # baseline (analysis.json) is present, so incremental runs; the depth - # resolves to a default and Core falls back to full itself if the cache - # is actually absent. - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = Path(tempfile.mkdtemp()) - out.joinpath("analysis.json").write_text(json.dumps({"metadata": {}}), encoding="utf-8") - - mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 3) - - self.assertEqual(mode, "incremental") - self.assertEqual(len(rf.calls), 0) - self.assertEqual(len(ri.calls), 1) - - def test_baseline_without_commit_still_runs_incremental(self): - # commit_hash is gone from #401 metadata, so its absence no longer forces - # a full rebuild — a present analysis.json runs incremental git-free. - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = Path(tempfile.mkdtemp()) - out.joinpath("analysis.json").write_text(json.dumps({"metadata": {"depth_level": 3}}), encoding="utf-8") - - mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 1) - - self.assertEqual(mode, "incremental") - self.assertEqual(len(ri.calls), 1) - self.assertEqual(len(rf.calls), 0) - - def test_falls_back_to_full_on_cache_miss(self): - analysis, IncMiss, _ = self._install() - rf = _Rec() - analysis.run_full = rf - analysis.run_incremental = _Rec(raises=IncMiss) - engine_adapter.run_full = analysis.run_full - engine_adapter.run_incremental = analysis.run_incremental - out = Path(tempfile.mkdtemp()) - _write_analysis(out, commit="metadata-base", depth=3) - (out / "stale.json").write_text("{}", encoding="utf-8") - - mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 1) - - self.assertEqual(mode, "full") - self.assertEqual(len(rf.calls), 1) - self.assertEqual(rf.calls[0][1]["depth_level"], 3) - self.assertFalse((out / "stale.json").exists()) - - def test_falls_back_to_full_on_baseline_unavailable(self): - analysis, _, BaseUnavailable = self._install() - rf = _Rec() - analysis.run_full = rf - analysis.run_incremental = _Rec(raises=BaseUnavailable) - engine_adapter.run_full = analysis.run_full - engine_adapter.run_incremental = analysis.run_incremental - out = tempfile.mkdtemp() - _write_analysis(out, commit="metadata-base", depth=2) - - mode = engine_adapter.run_analyze("/repo", out, "myrepo", "rid", "head123", 1) - - self.assertEqual(mode, "full") - self.assertEqual(len(rf.calls), 1) - self.assertEqual(rf.calls[0][1]["depth_level"], 2) - - def _markers(self, buf): - return [line for line in buf.getvalue().splitlines() if line.startswith("analysis_mode=")] - - def test_stdout_marker_full_printed_exactly_once(self): - # The action reads the mode from stdout (tee + sed 's/^analysis_mode=//p'); - # main() discards run_analyze's return value, so the print IS the interface. - self._install() - buf = StringIO() - with redirect_stdout(buf): - engine_adapter.run_analyze("/repo", tempfile.mkdtemp(), "myrepo", "rid", "head123", 2) - self.assertEqual(self._markers(buf), ["analysis_mode=full"]) - - def test_stdout_marker_incremental_printed_exactly_once(self): - self._install() - out = tempfile.mkdtemp() - _write_analysis(out, commit="metadata-base", depth=2) - buf = StringIO() - with redirect_stdout(buf): - engine_adapter.run_analyze("/repo", out, "myrepo", "rid", "head123", 2) - self.assertEqual(self._markers(buf), ["analysis_mode=incremental"]) - - def test_stdout_marker_fallback_prints_full_exactly_once(self): - analysis, IncMiss, _ = self._install() - analysis.run_full = _Rec() - analysis.run_incremental = _Rec(raises=IncMiss) - engine_adapter.run_full = analysis.run_full - engine_adapter.run_incremental = analysis.run_incremental - out = tempfile.mkdtemp() - _write_analysis(out, commit="metadata-base", depth=2) - buf = StringIO() - with redirect_stdout(buf): - engine_adapter.run_analyze("/repo", out, "myrepo", "rid", "head123", 2) - self.assertEqual(self._markers(buf), ["analysis_mode=full"]) - - def test_force_full_ignores_valid_baseline(self): - # force_full must run a full analysis even when a reusable baseline is - # present (the escape hatch that replaces refresh-baseline.yml). - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = tempfile.mkdtemp() - _write_analysis(out, commit="metadata-base", depth=2) # a perfectly reusable baseline - buf = StringIO() - with redirect_stdout(buf): - mode = engine_adapter.run_analyze("/repo", out, "myrepo", "rid", "head123", 2, force_full=True) - self.assertEqual(mode, "full") - self.assertEqual(len(rf.calls), 1) - self.assertEqual(len(ri.calls), 0) # baseline never consulted - self.assertEqual(self._markers(buf), ["analysis_mode=full"]) - - def test_main_force_full_flag_wires_through(self): - rf, ri = _Rec(), _Rec() - self._install(run_full=rf, run_incremental=ri) - out = tempfile.mkdtemp() - _write_analysis(out, commit="metadata-base", depth=2) - with patch.dict(os.environ, {}, clear=True): - engine_adapter.main( - [ - "analyze", - "--repo", - "/r", - "--out", - out, - "--name", - "n", - "--run-id", - "rid", - "--source-sha", - "head123", - "--depth", - "2", - "--force-full", - ] - ) - self.assertEqual(len(rf.calls), 1) - self.assertEqual(len(ri.calls), 0) - - def test_main_parses_depth_as_int_and_sets_sync_source(self): - rf = _Rec() - self._install(run_full=rf) - with patch.dict(os.environ, {}, clear=True): - engine_adapter.main( - [ - "analyze", - "--repo", - "/repo", - "--out", - tempfile.mkdtemp(), - "--name", - "myrepo", - "--run-id", - "rid", - "--source-sha", - "head123", - "--depth", - "2", - ] - ) - self.assertEqual(rf.calls[0][1]["depth_level"], 2) - self.assertEqual(os.environ["CODEBOARDING_SOURCE"], "sync") - - def test_main_rejects_invalid_depth(self): - # argparse enforces the structural range 1-10; the per-tier cap is applied - # later by the action/resolver, not here. - for depth in ("0", "11", "x"): - with self.subTest(depth=depth): - with redirect_stderr(StringIO()): - with self.assertRaises(SystemExit): - engine_adapter.main( - [ - "analyze", - "--repo", - "/repo", - "--out", - "/out", - "--name", - "myrepo", - "--run-id", - "rid", - "--source-sha", - "head123", - "--depth", - depth, - ] - ) - - -class TestRenderAndConcat(_Base): - def _install_rendering(self, render_docs=None): - rec = render_docs or _Rec() - rendering = _mod("codeboarding_workflows.rendering", render_docs=rec) - pkg = _mod("codeboarding_workflows") - pkg.rendering = rendering - engine_adapter.render_docs = rec - return rec - - def test_render_calls_engine_with_overview_root(self): - rec = self._install_rendering() - - engine_adapter.run_render( - "/tmp/analysis.json", "/tmp/docs", "repo", "https://example/repo/.codeboarding", ".md" - ) - - args, kwargs = rec.calls[0] - self.assertEqual(str(args[0]), "/tmp/analysis.json") - self.assertEqual(kwargs["repo_name"], "repo") - self.assertEqual(kwargs["repo_ref"], "https://example/repo/.codeboarding") - self.assertEqual(str(kwargs["temp_dir"]), "/tmp/docs") - self.assertEqual(kwargs["format"], ".md") - self.assertEqual(kwargs["root_name"], "overview") - - def test_concat_orders_overview_first_then_sorted_markdown(self): - docs_dir = Path(tempfile.mkdtemp()) - (docs_dir / "z_component.md").write_text("z", encoding="utf-8") - (docs_dir / "overview.md").write_text("overview", encoding="utf-8") - (docs_dir / "a_component.md").write_text("a", encoding="utf-8") - (docs_dir / "notes.txt").write_text("ignored", encoding="utf-8") - out = Path(tempfile.mkdtemp()) / "docs" / "development" / "architecture.md" - - engine_adapter.run_concat(str(docs_dir), str(out)) - - self.assertEqual(out.read_text(encoding="utf-8"), "overview\n\na\n\nz\n") - - -class TestSourceDispatch(_Base): - """CODEBOARDING_SOURCE is setdefault'ed after argparse: sync for - analyze/render/concat, github_action for everything else (base/seed/head/ - health/validate-base — base is asserted in test_engine_adapter.py).""" - - def test_main_render_sets_sync_source(self): - rec = _Rec() - rendering = _mod("codeboarding_workflows.rendering", render_docs=rec) - pkg = _mod("codeboarding_workflows") - pkg.rendering = rendering - engine_adapter.render_docs = rec - with patch.dict(os.environ, {}, clear=True): - rc = engine_adapter.main( - [ - "render", - "--analysis", - "/tmp/analysis.json", - "--out", - tempfile.mkdtemp(), - "--repo-name", - "repo", - "--repo-ref", - "ref", - ] - ) - self.assertEqual(rc, 0) - self.assertEqual(rec.calls[0][1]["format"], ".md") # default --format - self.assertEqual(os.environ["CODEBOARDING_SOURCE"], "sync") - - def test_main_concat_sets_sync_source(self): - docs_dir = Path(tempfile.mkdtemp()) - (docs_dir / "overview.md").write_text("overview", encoding="utf-8") - out = Path(tempfile.mkdtemp()) / "architecture.md" - with patch.dict(os.environ, {}, clear=True): - rc = engine_adapter.main(["concat", "--docs-dir", str(docs_dir), "--out", str(out)]) - self.assertEqual(rc, 0) - self.assertEqual(os.environ["CODEBOARDING_SOURCE"], "sync") - - def test_main_validate_base_keeps_github_action_source(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "analysis.json" - path.write_text(json.dumps({"metadata": {"commit_hash": "abc123"}}), encoding="utf-8") - with patch.dict(os.environ, {}, clear=True): - engine_adapter.main(["validate-base", "--analysis", str(path), "--expected-sha", "abc123"]) - self.assertEqual(os.environ["CODEBOARDING_SOURCE"], "github_action") - - def test_main_does_not_override_existing_source(self): - docs_dir = Path(tempfile.mkdtemp()) - (docs_dir / "overview.md").write_text("overview", encoding="utf-8") - out = Path(tempfile.mkdtemp()) / "architecture.md" - with patch.dict(os.environ, {"CODEBOARDING_SOURCE": "custom"}, clear=True): - engine_adapter.main(["concat", "--docs-dir", str(docs_dir), "--out", str(out)]) - self.assertEqual(os.environ["CODEBOARDING_SOURCE"], "custom") - - -class TestBaselineInfo(_Base): - """baseline-info replaces the sync_seed step's inline heredoc: it returns the - committed baseline's commit_hash only when present and SHA-shaped.""" - - def _write(self, metadata): - out = Path(tempfile.mkdtemp()) - (out / "analysis.json").write_text(json.dumps({"metadata": metadata}), encoding="utf-8") - return out / "analysis.json" - - def test_returns_sha_shaped_commit(self): - path = self._write({"commit_hash": "a1b2c3d4e5f6"}) - self.assertEqual(engine_adapter.baseline_info(path), "a1b2c3d4e5f6") - - def test_rejects_non_sha_commit(self): - # A non-SHA value must not flow into GITHUB_OUTPUT / cache keys / git. - for bad in ("not-a-sha", "abc\ncb_dir=/evil", "ABC123", "", "12345"): # too short / wrong charset / injection - with self.subTest(commit=bad): - self.assertEqual(engine_adapter.baseline_info(self._write({"commit_hash": bad})), "") - - def test_missing_metadata_or_file(self): - self.assertEqual(engine_adapter.baseline_info(self._write({})), "") - self.assertEqual(engine_adapter.baseline_info(Path(tempfile.mkdtemp()) / "absent.json"), "") - - def test_main_prints_commit_hash_line(self): - path = self._write({"commit_hash": "deadbeef1234"}) - buf = StringIO() - with patch.dict(os.environ, {}, clear=True), redirect_stdout(buf): - rc = engine_adapter.main(["baseline-info", "--analysis", str(path)]) - self.assertEqual(rc, 0) - self.assertIn("commit_hash=deadbeef1234", buf.getvalue()) - - def test_main_prints_empty_for_bad_baseline(self): - path = self._write({"commit_hash": "nope"}) - buf = StringIO() - with patch.dict(os.environ, {}, clear=True), redirect_stdout(buf): - engine_adapter.main(["baseline-info", "--analysis", str(path)]) - self.assertIn("commit_hash=", buf.getvalue()) - self.assertNotIn("nope", buf.getvalue()) - - -class TestBaselineDepth(_Base): - """baseline-depth lets review inherit the committed baseline's depth_level - (clamped to the tier ceiling) so the PR head is analyzed at the same depth as - the base it is diffed against. It returns a usable number for any present - baseline, and None only when there is no baseline at all (cold start).""" - - def _write(self, metadata): - out = Path(tempfile.mkdtemp()) - (out / "analysis.json").write_text(json.dumps({"metadata": metadata}), encoding="utf-8") - return out / "analysis.json" - - def test_in_range_passes_through(self): - for depth in (1, 2, 3): # within the free cap - with self.subTest(depth=depth): - self.assertEqual(engine_adapter.baseline_depth(self._write({"depth_level": depth}), False), depth) - - def test_clamps_over_cap_per_tier(self): - # depth 4-10 exceed the free cap (3) -> clamp to 3; licensed cap is 10. - self.assertEqual(engine_adapter.baseline_depth(self._write({"depth_level": 7}), False), 3) - self.assertEqual(engine_adapter.baseline_depth(self._write({"depth_level": 7}), True), 7) - self.assertEqual(engine_adapter.baseline_depth(self._write({"depth_level": 4}), False), 3) - self.assertEqual(engine_adapter.baseline_depth(self._write({"depth_level": 4}), True), 4) - self.assertEqual(engine_adapter.baseline_depth(self._write({"depth_level": 99}), True), 10) - - def test_invalid_depth_uses_default(self): - # Every spec violation that isn't an over-cap clamp falls back to the - # default depth (2): a non-positive depth, an unparseable value, or a - # missing depth_level are all handled the same way. - for metadata in ( - {"depth_level": 0}, - {"depth_level": -3}, - {"depth_level": "x"}, - {"commit_hash": "deadbeef1234"}, - ): - with self.subTest(metadata=metadata): - self.assertEqual(engine_adapter.baseline_depth(self._write(metadata), False), 2) - - def test_none_only_when_no_baseline(self): - # No file, or an empty/no-metadata object -> cold start (caller defaults). - self.assertIsNone(engine_adapter.baseline_depth(Path(tempfile.mkdtemp()) / "absent.json", False)) - self.assertIsNone(engine_adapter.baseline_depth(self._write({}), False)) - - def test_main_prints_depth_line(self): - path = self._write({"depth_level": 3}) - buf = StringIO() - with patch.dict(os.environ, {}, clear=True), redirect_stdout(buf): - rc = engine_adapter.main(["baseline-depth", "--analysis", str(path)]) - self.assertEqual(rc, 0) - self.assertIn("depth_level=3", buf.getvalue()) - - def test_main_licensed_raises_ceiling(self): - path = self._write({"depth_level": 7}) - free, lic = StringIO(), StringIO() - with patch.dict(os.environ, {}, clear=True), redirect_stdout(free): - engine_adapter.main(["baseline-depth", "--analysis", str(path)]) - with patch.dict(os.environ, {}, clear=True), redirect_stdout(lic): - engine_adapter.main(["baseline-depth", "--analysis", str(path), "--licensed"]) - self.assertIn("depth_level=3", free.getvalue()) # clamped - self.assertIn("depth_level=7", lic.getvalue()) # within licensed cap - - def test_main_prints_empty_for_no_baseline(self): - buf = StringIO() - with patch.dict(os.environ, {}, clear=True), redirect_stdout(buf): - engine_adapter.main(["baseline-depth", "--analysis", str(Path(tempfile.mkdtemp()) / "absent.json")]) - self.assertIn("depth_level=", buf.getvalue()) - self.assertNotIn("depth_level=None", buf.getvalue()) - - def test_diagnostics_go_to_stderr_not_stdout(self): - # Clamp/default messages must not pollute the machine-readable stdout line. - path = self._write({"depth_level": 7}) - adapter = Path(__file__).resolve().parent.parent / "scripts" / "engine_adapter.py" - result = subprocess.run( - [sys.executable, str(adapter), "baseline-depth", "--analysis", str(path)], - capture_output=True, - text=True, - cwd=tempfile.mkdtemp(), - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "depth_level=3") # stdout is JUST the value - self.assertIn("clamping to 3", result.stderr) # the log is on stderr - - def test_runs_without_engine_installed(self): - # The action calls baseline-depth BEFORE the engine package is installed, - # so it must work as a subprocess with no engine modules on sys.path. - path = self._write({"depth_level": 3}) - adapter = Path(__file__).resolve().parent.parent / "scripts" / "engine_adapter.py" - result = subprocess.run( - [sys.executable, str(adapter), "baseline-depth", "--analysis", str(path)], - capture_output=True, - text=True, - cwd=tempfile.mkdtemp(), # not the repo: no stub engine modules importable - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("depth_level=3", result.stdout) - - -if __name__ == "__main__": - unittest.main() From d59229f3ef60c649812c7e163219a6a93662dbdd Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 20:28:53 +0200 Subject: [PATCH 02/22] fix(action): restore manifest and CI checks --- action.yml | 37 +++++------------ scripts/render_sync_docs.py | 4 +- tests/test_analyze_repository.py | 42 ++++++++++++++++--- tests/test_render_sync_docs.py | 71 +++++++++++++++++++------------- 4 files changed, 90 insertions(+), 64 deletions(-) diff --git a/action.yml b/action.yml index 189075b..195cd71 100644 --- a/action.yml +++ b/action.yml @@ -342,15 +342,7 @@ runs: BASELINE_DEPTH="" if [ -f "${GITHUB_WORKSPACE}/.codeboarding/analysis.json" ]; then - BASELINE_DEPTH="$(python3 - <<'PY' "${GITHUB_WORKSPACE}/.codeboarding/analysis.json") -import json, sys -try: - data = json.loads(open(sys.argv[1]).read()) - print(data.get('metadata', {}).get('depth_level', '')) -except Exception: - print('') -PY -" + BASELINE_DEPTH="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1])); print(data.get("metadata", {}).get("depth_level", ""))' "${GITHUB_WORKSPACE}/.codeboarding/analysis.json" 2>/dev/null || true)" fi DEPTH="${BASELINE_DEPTH:-$DEPTH_INPUT}" @@ -609,15 +601,7 @@ PY BASELINE_DEPTH="" if [ -n "$BASE_SHA" ] && [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then - BASELINE_DEPTH="$(python3 - <<'PY' "$BASE_DIR/.codeboarding/analysis.json") -import json, sys -try: - data = json.loads(open(sys.argv[1]).read()) - print(data.get('metadata', {}).get('depth_level', '')) -except Exception: - print('') -PY -" + BASELINE_DEPTH="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1])); print(data.get("metadata", {}).get("depth_level", ""))' "$BASE_DIR/.codeboarding/analysis.json" 2>/dev/null || true)" fi DEPTH="${BASELINE_DEPTH:-$DEPTH_INPUT}" @@ -749,15 +733,14 @@ PY set -euo pipefail mkdir -p "${RUNNER_TEMP}/cb-review-artifact" cp "${{ steps.review_analyze.outputs.analysis_path }}" "${RUNNER_TEMP}/cb-review-artifact/analysis.json" - cat > "${RUNNER_TEMP}/cb-review-artifact/metadata.json" < "${RUNNER_TEMP}/cb-review-artifact/metadata.json" echo "artifact_dir=${RUNNER_TEMP}/cb-review-artifact" >> "$GITHUB_OUTPUT" - name: Upload review artifact diff --git a/scripts/render_sync_docs.py b/scripts/render_sync_docs.py index 9edecc6..0ff9d2f 100755 --- a/scripts/render_sync_docs.py +++ b/scripts/render_sync_docs.py @@ -24,7 +24,7 @@ def concat_overview(docs_dir: Path, out_path: Path) -> None: out_path.write_text("\n\n".join(p.read_text(encoding="utf-8").rstrip() for p in files) + "\n", encoding="utf-8") -def main() -> int: +def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Render markdown docs from an analysis JSON.") parser.add_argument("--analysis", required=True, type=Path, help="Path to analysis.json") parser.add_argument("--output-dir", required=True, type=Path, help="Directory to write docs") @@ -32,7 +32,7 @@ def main() -> int: parser.add_argument("--repo-ref", required=True) parser.add_argument("--format", default=".md") parser.add_argument("--architecture-file", required=False, type=Path) - args = parser.parse_args() + args = parser.parse_args(argv) render_docs( args.analysis, diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py index e092ef4..821e25c 100644 --- a/tests/test_analyze_repository.py +++ b/tests/test_analyze_repository.py @@ -2,18 +2,24 @@ import io import json +import sys import tempfile import unittest from pathlib import Path from unittest.mock import patch +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + import analyze_repository as ar class AnalyzeRepositoryTests(unittest.TestCase): def _analysis_json(self, base: Path) -> Path: path = base / "analysis.json" - path.write_text(json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 2}}, encoding="utf-8") ) + path.write_text( + json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 2}}), + encoding="utf-8", + ) return path def test_parse_cli_response_accepts_contract_json(self) -> None: @@ -51,12 +57,20 @@ def test_parse_main_incremental_success(self) -> None: "_run_command", return_value=json.dumps( { - "analysis_path": str(analysis_path.relative_to(root)), + "analysis_path": str(analysis_path.relative_to(out_dir)), "requiresFullAnalysis": False, } ), ) as _mock: - ar.main(["incremental", "--checkout", str(checkout), "--output-dir", str(out_dir)]) + ar.main( + [ + "incremental", + "--checkout", + str(checkout), + "--output-dir", + str(out_dir), + ] + ) lines = dict(line.split("=", 1) for line in stdout.getvalue().splitlines() if "=" in line) self.assertEqual(lines.get("analysis_mode"), "incremental") self.assertEqual(lines.get("requires_full_analysis"), "false") @@ -70,7 +84,15 @@ def test_main_full_fails_without_depth(self) -> None: checkout.mkdir() out_dir.mkdir() with self.assertRaises(SystemExit): - ar.main(["full", "--checkout", str(checkout), "--output-dir", str(out_dir)]) + ar.main( + [ + "full", + "--checkout", + str(checkout), + "--output-dir", + str(out_dir), + ] + ) def test_main_rejects_bad_cli_output(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -79,9 +101,17 @@ def test_main_rejects_bad_cli_output(self) -> None: out_dir = root / "out" checkout.mkdir() out_dir.mkdir() - with self.assertRaises(SystemExit): + with self.assertRaises(ar.AnalysisError): with patch.object(ar, "_run_command", return_value="not-json"): - ar.main(["incremental", "--checkout", str(checkout), "--output-dir", str(out_dir)]) + ar.main( + [ + "incremental", + "--checkout", + str(checkout), + "--output-dir", + str(out_dir), + ] + ) if __name__ == "__main__": diff --git a/tests/test_render_sync_docs.py b/tests/test_render_sync_docs.py index 775951e..766b0f8 100644 --- a/tests/test_render_sync_docs.py +++ b/tests/test_render_sync_docs.py @@ -11,6 +11,7 @@ stub_pkg = ModuleType("codeboarding_workflows") stub_rendering = ModuleType("codeboarding_workflows.rendering") +stub_rendering.render_docs = lambda *args, **kwargs: None stub_pkg.rendering = stub_rendering sys.modules["codeboarding_workflows"] = stub_pkg sys.modules["codeboarding_workflows.rendering"] = stub_rendering @@ -23,7 +24,14 @@ def setUp(self) -> None: self.render_calls = [] def _make_fake_render(self, with_overview: bool = True): - def _render(analysis, repo_name, repo_ref, temp_dir, format=".md", root_name="overview"): + def _render( + analysis, + repo_name, + repo_ref, + temp_dir, + format=".md", + root_name="overview", + ): self.render_calls.append((analysis, repo_name, repo_ref, temp_dir, format, root_name)) out = Path(temp_dir) out.mkdir(parents=True, exist_ok=True) @@ -31,6 +39,7 @@ def _render(analysis, repo_name, repo_ref, temp_dir, format=".md", root_name="ov (out / "overview.md").write_text("# Overview\n", encoding="utf-8") (out / "api.md").write_text("# API\n", encoding="utf-8") (out / "zeta.md").write_text("# Zeta\n", encoding="utf-8") + return _render def test_concat_prefers_overview_first_and_appends_sorted(self) -> None: @@ -41,20 +50,22 @@ def test_concat_prefers_overview_first_and_appends_sorted(self) -> None: output = root / "docs" architecture = root / "architecture.md" with patch.object(rsd, "render_docs", new=self._make_fake_render(True)): - rsd.main([ - "--analysis", - str(analysis), - "--output-dir", - str(output), - "--repo-name", - "org/repo", - "--repo-ref", - "abc123", - "--format", - ".md", - "--architecture-file", - str(architecture), - ]) + rsd.main( + [ + "--analysis", + str(analysis), + "--output-dir", + str(output), + "--repo-name", + "org/repo", + "--repo-ref", + "abc123", + "--format", + ".md", + "--architecture-file", + str(architecture), + ] + ) result = architecture.read_text(encoding="utf-8") self.assertIn("# Overview", result) self.assertIn("# API", result) @@ -72,20 +83,22 @@ def test_missing_overview_fails(self) -> None: architecture = root / "architecture.md" with patch.object(rsd, "render_docs", new=self._make_fake_render(False)): with self.assertRaises(SystemExit): - rsd.main([ - "--analysis", - str(analysis), - "--output-dir", - str(output), - "--repo-name", - "org/repo", - "--repo-ref", - "abc123", - "--format", - ".md", - "--architecture-file", - str(architecture), - ]) + rsd.main( + [ + "--analysis", + str(analysis), + "--output-dir", + str(output), + "--repo-name", + "org/repo", + "--repo-ref", + "abc123", + "--format", + ".md", + "--architecture-file", + str(architecture), + ] + ) if __name__ == "__main__": From 4bc6cf39a08869cf9a7ef3a860ab22349c2adfae Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 20:41:53 +0200 Subject: [PATCH 03/22] fix(ci): align dogfood workflows with action inputs --- .github/workflows/codeboarding-sync.yml | 7 +------ .github/workflows/codeboarding.yml | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeboarding-sync.yml b/.github/workflows/codeboarding-sync.yml index c28bf22..bcadaa9 100644 --- a/.github/workflows/codeboarding-sync.yml +++ b/.github/workflows/codeboarding-sync.yml @@ -70,6 +70,7 @@ jobs: 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. @@ -145,15 +146,9 @@ jobs: - uses: ./ with: mode: sync - force_full: ${{ inputs.force_full || false }} - # Push events retain direct delivery to their branch. A manual - # pull_request-strategy run targets main even though the workflow code - # itself is checked out from the feature ref being dogfooded. - target_branch: ${{ github.event_name == 'workflow_dispatch' && inputs.sync_strategy == 'pull_request' && 'main' || github.ref_name }} sync_strategy: ${{ inputs.sync_strategy || 'push' }} sync_pr_branch: ${{ inputs.sync_pr_branch || 'codeboarding/sync' }} # 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. push_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} - llm_api_key: ${{ secrets.OPENROUTER_API_KEY }} diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index 595d5d0..58659d1 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -31,6 +31,7 @@ jobs: contents: read 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 sync mode's own baseline PR (head branch # 'codeboarding/sync', the sync_pr_branch default): it only changes generated # files, so a diff comment would be noise. Scoped to THIS repo's head so a fork @@ -123,4 +124,3 @@ jobs: - uses: ./ with: github_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} - llm_api_key: ${{ secrets.OPENROUTER_API_KEY }} From 220cc37d5ae6ae8b46275fb029afab3a6f6d144c Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 21:34:54 +0200 Subject: [PATCH 04/22] fix(lint): make local runner shellcheck clean --- scripts/run_local.sh | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/scripts/run_local.sh b/scripts/run_local.sh index ab6fa2c..dfba988 100755 --- a/scripts/run_local.sh +++ b/scripts/run_local.sh @@ -100,8 +100,8 @@ else REPO="$(cd "$REPO" && pwd)" - git -C "$REPO" rev-parse -q --verify "$BASE_REF"^{commit} >/dev/null 2>&1 || git -C "$REPO" fetch origin "$BASE_REF" --depth=1 - git -C "$REPO" rev-parse -q --verify "$HEAD_REF"^{commit} >/dev/null 2>&1 || git -C "$REPO" fetch origin "$HEAD_REF" --depth=1 + git -C "$REPO" rev-parse -q --verify "$BASE_REF^{commit}" >/dev/null 2>&1 || git -C "$REPO" fetch origin "$BASE_REF" --depth=1 + git -C "$REPO" rev-parse -q --verify "$HEAD_REF^{commit}" >/dev/null 2>&1 || git -C "$REPO" fetch origin "$HEAD_REF" --depth=1 BASE_SHA="$(git -C "$REPO" rev-parse "$BASE_REF^{commit}")" HEAD_SHA="$(git -C "$REPO" rev-parse "$HEAD_REF^{commit}")" @@ -117,13 +117,7 @@ else BASELINE_DEPTH="" if [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then - BASELINE_DEPTH="$(python3 - "$BASE_DIR/.codeboarding/analysis.json" <<'PY' -import json, sys -try: - print(json.loads(open(sys.argv[1]).read()).get("metadata", {}).get("depth_level", "")) -except Exception: - print("") -PY)" + BASELINE_DEPTH="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1])); print(data.get("metadata", {}).get("depth_level", ""))' "$BASE_DIR/.codeboarding/analysis.json" 2>/dev/null || true)" fi if [ -n "$BASELINE_DEPTH" ] && [[ "$BASELINE_DEPTH" =~ ^[0-9]+$ ]]; then From de457c0ba70884b4c32fbe7e9755cf7bc379e498 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 22:42:42 +0200 Subject: [PATCH 05/22] fix(action): restore review authentication compatibility Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- action.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/action.yml b/action.yml index 195cd71..9fafb4b 100644 --- a/action.yml +++ b/action.yml @@ -7,6 +7,10 @@ branding: color: 'blue' inputs: + llm_api_key: + description: 'Optional OpenRouter API key. When empty, the action uses the hosted tier via GitHub OIDC.' + required: false + default: '' mode: description: | review: analyze pull requests and post a single sticky comment @@ -104,6 +108,7 @@ runs: id: guard shell: bash env: + GH_TOKEN: ${{ inputs.github_token }} MODE: ${{ inputs.mode }} EVENT: ${{ github.event_name }} REPO: ${{ github.repository }} @@ -274,10 +279,18 @@ runs: if: always() && steps.guard.outputs.skip != 'true' shell: bash env: + LLM_API_KEY: ${{ inputs.llm_api_key }} LICENSE_KEY: ${{ inputs.license_key }} ACTION_PATH: ${{ github.action_path }} run: | set -euo pipefail + + if [ -n "${LLM_API_KEY:-}" ]; then + echo "::add-mask::$LLM_API_KEY" + echo "OPENROUTER_API_KEY=$LLM_API_KEY" >> "$GITHUB_ENV" + exit 0 + fi + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then echo "::error::Missing OIDC token. Add permissions: id-token: write." && exit 1 fi From e13086ebbca155d73eed2ac0764027745140527b Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 22:47:18 +0200 Subject: [PATCH 06/22] fix(action): run engine on supported Python Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 9fafb4b..51c746e 100644 --- a/action.yml +++ b/action.yml @@ -266,7 +266,7 @@ runs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.12' - name: Install CodeBoarding CLI shell: bash From 060e7e3d26116b7341ba0bbbeb3f8631c51abc73 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 22:53:20 +0200 Subject: [PATCH 07/22] fix(action): honor engine fallback contract Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- action.yml | 6 +++--- scripts/analyze_repository.py | 20 ++++++++++++------- tests/test_analyze_repository.py | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/action.yml b/action.yml index 51c746e..c3313ad 100644 --- a/action.yml +++ b/action.yml @@ -378,7 +378,7 @@ runs: NEED_FULL="$(parse_value requires_full_analysis "$OUT")" ANALYSIS_PATH="$(parse_value analysis_path "$OUT")" - if [ -z "$MODE" ] || [ -z "$ANALYSIS_PATH" ] || [ "$MODE" != "incremental" ] && [ "$MODE" != "full" ]; then + if [ -z "$MODE" ] || { [ "$NEED_FULL" != "true" ] && [ -z "$ANALYSIS_PATH" ]; } || [ "$MODE" != "incremental" ] && [ "$MODE" != "full" ]; then echo "$OUT" echo "::error::Could not parse incremental output contract." && exit 1 fi @@ -645,7 +645,7 @@ runs: NEED_FULL="$(parse_value requires_full_analysis "$OUT")" HEAD_PATH="$(parse_value analysis_path "$OUT")" - if [ -z "$MODE" ] || [ -z "$HEAD_PATH" ] || [ "$MODE" != "incremental" ] && [ "$MODE" != "full" ]; then + if [ -z "$MODE" ] || { [ "$NEED_FULL" != "true" ] && [ -z "$HEAD_PATH" ]; } || [ "$MODE" != "incremental" ] && [ "$MODE" != "full" ]; then echo "$OUT" echo "::error::Could not parse incremental output contract." && exit 1 fi @@ -667,7 +667,7 @@ runs: MODE="$(parse_value analysis_mode "$OUT")" NEED_FULL="$(parse_value requires_full_analysis "$OUT")" HEAD_PATH="$(parse_value analysis_path "$OUT")" - if [ -z "$MODE" ] || [ -z "$HEAD_PATH" ]; then + if [ -z "$MODE" ] || { [ "$NEED_FULL" != "true" ] && [ -z "$HEAD_PATH" ]; }; then echo "$OUT" echo "::error::Could not parse head retry output contract." && exit 1 fi diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py index ea75a55..301111a 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -44,7 +44,7 @@ def _normalize_analysis_path(payload: dict, output_dir: str) -> Path: return candidate -def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path, dict]: +def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path | None, dict]: if not raw.strip(): raise AnalysisError("CodeBoarding command produced no JSON output") @@ -57,6 +57,9 @@ def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path, dict]: raise AnalysisError("CodeBoarding JSON response is not an object") requires_full = _parse_bool(payload.get("requiresFullAnalysis"), field="requiresFullAnalysis") + if requires_full and not payload.get("analysis_path"): + return True, None, payload + analysis_path = _normalize_analysis_path(payload, output_dir) if not analysis_path.is_file(): @@ -84,15 +87,15 @@ def _run_command(args: list[str], output_dir: Path) -> str: return completed.stdout -def run_incremental(checkout: Path, output_dir: Path) -> tuple[bool, Path, dict]: +def run_incremental(checkout: Path, output_dir: Path) -> tuple[bool, Path | None, dict]: output_dir.mkdir(parents=True, exist_ok=True) raw = _run_command([PROG, "incremental", "--local", str(checkout), "--output-dir", str(output_dir)], output_dir) return _parse_cli_response(raw, str(output_dir)) -def run_full(checkout: Path, output_dir: Path, depth_level: str) -> tuple[bool, Path, dict]: +def run_full(checkout: Path, output_dir: Path, depth_level: str) -> Path: output_dir.mkdir(parents=True, exist_ok=True) - raw = _run_command( + _run_command( [ PROG, "full", @@ -106,7 +109,10 @@ def run_full(checkout: Path, output_dir: Path, depth_level: str) -> tuple[bool, ], output_dir, ) - return _parse_cli_response(raw, str(output_dir)) + analysis_path = output_dir / "analysis.json" + if not analysis_path.is_file(): + raise AnalysisError(f"Full analysis did not produce: {analysis_path}") + return analysis_path def main(argv: list[str] | None = None) -> int: @@ -126,12 +132,12 @@ def main(argv: list[str] | None = None) -> int: requires_full, analysis_path, _ = run_incremental(checkout, output_dir) print(f"analysis_mode=incremental") print(f"requires_full_analysis={str(requires_full).lower()}") - print(f"analysis_path={analysis_path}") + print(f"analysis_path={analysis_path or ''}") return 0 if not args.depth_level: raise SystemExit("--depth-level is required for mode=full") - requires_full, analysis_path, _ = run_full(checkout, output_dir, args.depth_level) + analysis_path = run_full(checkout, output_dir, args.depth_level) print(f"analysis_mode=full") print("requires_full_analysis=false") print(f"analysis_path={analysis_path}") diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py index 821e25c..64af268 100644 --- a/tests/test_analyze_repository.py +++ b/tests/test_analyze_repository.py @@ -40,6 +40,12 @@ def test_parse_cli_response_rejects_invalid_bool(self) -> None: with self.assertRaises(ar.AnalysisError): ar._parse_cli_response(payload, str(root)) + def test_parse_cli_response_accepts_full_fallback_without_analysis_path(self) -> None: + payload = json.dumps({"error": "baseline unavailable", "requiresFullAnalysis": True}) + requires_full, path, _ = ar._parse_cli_response(payload, "/tmp/output") + self.assertTrue(requires_full) + self.assertIsNone(path) + def test_parse_main_incremental_success(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -94,6 +100,33 @@ def test_main_full_fails_without_depth(self) -> None: ] ) + def test_main_full_uses_generated_analysis_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + checkout = root / "repo" + out_dir = root / "out" + checkout.mkdir() + + def fake_run(_args, output_dir): + (output_dir / "analysis.json").write_text("ok", encoding="utf-8") + return "human-readable CLI output" + + stdout = io.StringIO() + with patch("sys.stdout", stdout), patch.object(ar, "_run_command", side_effect=fake_run): + ar.main( + [ + "full", + "--checkout", + str(checkout), + "--output-dir", + str(out_dir), + "--depth-level", + "1", + ] + ) + + self.assertIn(f"analysis_path={out_dir / 'analysis.json'}", stdout.getvalue()) + def test_main_rejects_bad_cli_output(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) From 6397805a014c451712a63c3d0477f8b86d728e02 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 22:55:56 +0200 Subject: [PATCH 08/22] fix(action): surface engine contract errors Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- scripts/analyze_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py index 301111a..5aa3e4c 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -148,5 +148,5 @@ def main(argv: list[str] | None = None) -> int: try: raise SystemExit(main()) except AnalysisError as exc: - print(f"::error::{exc}") + print(f"::error::{exc}", file=sys.stderr) raise SystemExit(1) From a7259e01ad4fba59a27ea6528921333ffd336410 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 22:58:42 +0200 Subject: [PATCH 09/22] fix(action): parse logged engine responses Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- scripts/analyze_repository.py | 11 ++++++++++- tests/test_analyze_repository.py | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py index 5aa3e4c..d7d27f0 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -51,7 +51,16 @@ def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path | None, d try: payload = json.loads(raw) except json.JSONDecodeError as exc: - raise AnalysisError(f"Invalid CodeBoarding JSON response: {exc}") from exc + payload = None + lines = raw.splitlines() + for index, line in enumerate(lines): + if line.lstrip().startswith("{"): + try: + payload = json.loads("\n".join(lines[index:])) + except json.JSONDecodeError: + continue + if payload is None: + raise AnalysisError(f"Invalid CodeBoarding JSON response: {exc}") from exc if not isinstance(payload, dict): raise AnalysisError("CodeBoarding JSON response is not an object") diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py index 64af268..f69d2e6 100644 --- a/tests/test_analyze_repository.py +++ b/tests/test_analyze_repository.py @@ -46,6 +46,14 @@ def test_parse_cli_response_accepts_full_fallback_without_analysis_path(self) -> self.assertTrue(requires_full) self.assertIsNone(path) + def test_parse_cli_response_accepts_logs_before_json(self) -> None: + raw = "Analyzing repository...\n" + json.dumps( + {"error": "baseline unavailable", "requiresFullAnalysis": True}, indent=2 + ) + requires_full, path, _ = ar._parse_cli_response(raw, "/tmp/output") + self.assertTrue(requires_full) + self.assertIsNone(path) + def test_parse_main_incremental_success(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) From 96de6a723a5971622b59b82e70c9a4cb833c48b9 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 23:01:24 +0200 Subject: [PATCH 10/22] fix(action): normalize direct LLM keys Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- action.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/action.yml b/action.yml index c3313ad..79f2e68 100644 --- a/action.yml +++ b/action.yml @@ -286,6 +286,9 @@ runs: set -euo pipefail if [ -n "${LLM_API_KEY:-}" ]; then + echo "::add-mask::$LLM_API_KEY" + LLM_API_KEY="$(printf '%s' "$LLM_API_KEY" | tr -d '[:space:]' | sed -e 's/^"//;s/"$//' -e "s/^'//;s/'\$//")" + LLM_API_KEY="${LLM_API_KEY#OPENROUTER_API_KEY=}" echo "::add-mask::$LLM_API_KEY" echo "OPENROUTER_API_KEY=$LLM_API_KEY" >> "$GITHUB_ENV" exit 0 From 812ed52a71813bbf6c1de09146314ad5109aa7f5 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 23:04:29 +0200 Subject: [PATCH 11/22] fix(review): retain baseline through rendering Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- action.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/action.yml b/action.yml index 79f2e68..fa519c6 100644 --- a/action.yml +++ b/action.yml @@ -701,10 +701,6 @@ runs: exit 1 fi - if [ -n "$BASE_DIR" ] && git worktree list | awk '{print $1}' | grep -q "$BASE_DIR"; then - git worktree remove --force "$BASE_DIR" >/dev/null 2>&1 || true - fi - echo "analysis_mode=$MODE" >> "$GITHUB_OUTPUT" echo "analysis_path=$HEAD_PATH" >> "$GITHUB_OUTPUT" echo "analysis_depth=$DEPTH" >> "$GITHUB_OUTPUT" From bde08fa21269a5fd3f132ff6fbe3da910ec77312 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 23:07:19 +0200 Subject: [PATCH 12/22] fix(review): read boolean render metadata Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index fa519c6..5958319 100644 --- a/action.yml +++ b/action.yml @@ -731,7 +731,7 @@ runs: printf '%s' "$DIFF" > "$META" N_CHANGED="$(jq -r '.n_changed' "$META")" - TRUNCATED="$(jq -r '.truncated | ascii_downcase' "$META")" + TRUNCATED="$(jq -r '.truncated' "$META")" echo "diagram_md=$DIAGRAM_OUT" >> "$GITHUB_OUTPUT" echo "n_changed=$N_CHANGED" >> "$GITHUB_OUTPUT" echo "truncated=$TRUNCATED" >> "$GITHUB_OUTPUT" From fa744877f8baac2154d10674f357b72b6b1a1225 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 3 Aug 2026 23:11:10 +0200 Subject: [PATCH 13/22] fix(review): post rendered diagram content Amp-Thread-ID: https://ampcode.com/threads/T-019fc956-723a-737d-a4ac-b0470f146cd4 Co-authored-by: Amp --- action.yml | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/action.yml b/action.yml index 5958319..f700c7e 100644 --- a/action.yml +++ b/action.yml @@ -764,6 +764,28 @@ runs: path: ${{ steps.review_artifact_inputs.outputs.artifact_dir }} if-no-files-found: error + - name: Build review comment + id: review_body + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outputs.rendered == 'true' + shell: bash + env: + HEADER: ${{ inputs.comment_header }} + DIAGRAM: ${{ steps.review_render.outputs.diagram_md }} + N_CHANGED: ${{ steps.review_render.outputs.n_changed }} + ANALYSIS_MODE: ${{ steps.review_analyze.outputs.analysis_mode }} + TRUNCATED: ${{ steps.review_render.outputs.truncated }} + ARTIFACT_URL: ${{ steps.upload_review_artifact.outputs.artifact-url }} + run: | + RENDER="full top-level" + if [ "$TRUNCATED" = "true" ]; then + RENDER="changed-only (auto-trimmed)" + fi + BODY="${RUNNER_TEMP}/review-comment.md" + printf '### %s\n\n**Status:** %s changed component(s)\n**Mode:** `%s`\n**Render:** `%s`\n\n' "$HEADER" "$N_CHANGED" "$ANALYSIS_MODE" "$RENDER" > "$BODY" + cat "$DIAGRAM" >> "$BODY" + printf '\n\n**Hosted webview:** [open artifact](%s)\n\nrun %s · attempt %s\n' "$ARTIFACT_URL" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$BODY" + echo "path=$BODY" >> "$GITHUB_OUTPUT" + - name: Post review comment if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outputs.rendered == 'true' uses: marocchino/sticky-pull-request-comment@v2 @@ -771,20 +793,7 @@ runs: header: ${{ steps.guard.outputs.sticky_header }} number: ${{ steps.guard.outputs.pr_number }} GITHUB_TOKEN: ${{ inputs.github_token }} - message: | - ### ${{ inputs.comment_header }} - - **Status:** ${{ steps.review_render.outputs.n_changed }} changed component(s) - **Mode:** `${{ steps.review_analyze.outputs.analysis_mode }}` - **Render:** `${{ steps.review_render.outputs.truncated == 'true' && 'changed-only (auto-trimmed)' || 'full top-level' }}` - - ```mermaid - $(cat "${{ steps.review_render.outputs.diagram_md }}") - ``` - - **Hosted webview:** [open artifact](${{ steps.upload_review_artifact.outputs.artifact-url }}) - - run ${{ github.run_id }} · attempt ${{ github.run_attempt }} + path: ${{ steps.review_body.outputs.path }} - name: Post review failure comment if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outcome != 'success' From 463ab43f3ce41e630cfa426961bd3508346f8a5c Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Wed, 5 Aug 2026 11:37:22 +0200 Subject: [PATCH 14/22] fix(action): stream CLI progress logs --- scripts/analyze_repository.py | 41 +++++++++++++++++++------------- tests/test_analyze_repository.py | 16 +++++++++++++ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py index d7d27f0..7b23765 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -78,22 +78,31 @@ def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path | None, d def _run_command(args: list[str], output_dir: Path) -> str: - try: - completed = subprocess.run( - args, - check=True, - capture_output=True, - text=True, - cwd=str(output_dir.parent), - env=None, - ) - except subprocess.CalledProcessError as exc: - stdout = (exc.stdout or "").strip() - stderr = (exc.stderr or "").strip() - details = (stderr or stdout or "no command output").strip() - raise AnalysisError(f"Command failed ({' '.join(args)}): {details}") from exc - - return completed.stdout + process = subprocess.Popen( + args, + stdout=subprocess.PIPE, + text=True, + bufsize=1, + cwd=str(output_dir.parent), + env=None, + ) + if process.stdout is None: # pragma: no cover - guaranteed by stdout=PIPE + raise AnalysisError(f"Unable to read command output ({' '.join(args)})") + + stdout_lines: list[str] = [] + for line in process.stdout: + stdout_lines.append(line) + # The shell captures this helper's stdout as its result contract. Mirror + # CLI stdout to stderr so engine progress remains visible in Actions. + print(line, end="", file=sys.stderr, flush=True) + + return_code = process.wait() + stdout = "".join(stdout_lines) + if return_code != 0: + details = stdout.strip() or f"exit code {return_code}; see command logs above" + raise AnalysisError(f"Command failed ({' '.join(args)}): {details}") + + return stdout def run_incremental(checkout: Path, output_dir: Path) -> tuple[bool, Path | None, dict]: diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py index f69d2e6..e0783de 100644 --- a/tests/test_analyze_repository.py +++ b/tests/test_analyze_repository.py @@ -54,6 +54,22 @@ def test_parse_cli_response_accepts_logs_before_json(self) -> None: self.assertTrue(requires_full) self.assertIsNone(path) + def test_run_command_streams_stdout_to_action_logs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + stderr = io.StringIO() + command = [ + sys.executable, + "-c", + "print('Analyzing repository...'); print('{\"requiresFullAnalysis\": true}')", + ] + + with patch("sys.stderr", stderr): + stdout = ar._run_command(command, Path(tmp) / "out") + + self.assertIn("Analyzing repository...", stderr.getvalue()) + self.assertIn('{"requiresFullAnalysis": true}', stderr.getvalue()) + self.assertEqual(stdout, 'Analyzing repository...\n{"requiresFullAnalysis": true}\n') + def test_parse_main_incremental_success(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) From 07de8875f6f991391ada49cbb70a6675361d0055 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Wed, 5 Aug 2026 12:06:51 +0200 Subject: [PATCH 15/22] fix(review): restore slash command feedback --- action.yml | 46 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index f700c7e..a09ef9d 100644 --- a/action.yml +++ b/action.yml @@ -13,7 +13,7 @@ inputs: default: '' mode: description: | - review: analyze pull requests and post a single sticky comment + review: analyze pull requests and post architecture review comments sync: sync baseline artifacts on push/workflow_dispatch/schedule required: false default: 'review' @@ -45,6 +45,10 @@ inputs: description: 'Mermaid direction: LR, TD, TB, RL, or BT.' required: false default: 'LR' + trigger_command: + description: 'Review-mode slash command that triggers an on-demand run from a pull request comment.' + required: false + default: '/codeboarding' sync_strategy: description: 'Sync delivery strategy: push or pull_request.' required: false @@ -121,6 +125,7 @@ runs: COMMENT_BODY: ${{ github.event.comment.body }} AUTHOR_ASSOC: ${{ github.event.comment.author_association }} + TRIGGER_COMMAND: ${{ inputs.trigger_command }} ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} PR_NUMBER_PULL: ${{ github.event.pull_request.number }} PULL_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -204,10 +209,11 @@ runs: else [ -n "$COMMENT_BODY" ] || skip "Issue comment body is empty." FIRST_WORD="$(printf '%s' "$COMMENT_BODY" | tr -d '\r' | awk 'NR==1 {print $1}' || true)" - [ "$FIRST_WORD" = "/codeboarding" ] || skip "Issue comment is not /codeboarding." + [ -n "$TRIGGER_COMMAND" ] || fail "trigger_command must not be empty." + [ "$FIRST_WORD" = "$TRIGGER_COMMAND" ] || skip "Issue comment does not start with '$TRIGGER_COMMAND'." case "${AUTHOR_ASSOC:-}" in OWNER|MEMBER|COLLABORATOR|MAINTAINER) ;; - *) skip "Untrusted actor association for /codeboarding trigger (${AUTHOR_ASSOC:-})." ;; + *) skip "Untrusted actor association for $TRIGGER_COMMAND trigger (${AUTHOR_ASSOC:-})." ;; esac [ -n "$ISSUE_PR_URL" ] || skip "Not a pull request comment." @@ -245,7 +251,39 @@ runs: echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT" echo "checkout_ref=$HEAD_SHA" >> "$GITHUB_OUTPUT" echo "baseline_exists=$BASELINE_EXISTS" >> "$GITHUB_OUTPUT" - echo "sticky_header=codeboarding-architecture-diff" >> "$GITHUB_OUTPUT" + if [ "$EVENT" = "issue_comment" ]; then + # Each slash-command invocation gets its own visible response. Steps + # within the same run still update one comment through this header. + echo "sticky_header=codeboarding-architecture-diff-run-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" + else + echo "sticky_header=codeboarding-architecture-diff" >> "$GITHUB_OUTPUT" + fi + + - name: Acknowledge review command + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.event == 'issue_comment' + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + REPOSITORY: ${{ github.repository }} + COMMENT_ID: ${{ github.event.comment.id }} + run: | + gh api -X POST "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ + -f content=eyes >/dev/null 2>&1 || true + + - name: Post review in-progress comment + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + continue-on-error: true + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: ${{ steps.guard.outputs.sticky_header }} + number: ${{ steps.guard.outputs.pr_number }} + GITHUB_TOKEN: ${{ inputs.github_token }} + message: | + ### ${{ inputs.comment_header }} · analyzing… + + ⏳ CodeBoarding is analyzing the architecture changes in this PR. This usually takes a few minutes. + + run ${{ github.run_id }} · attempt ${{ github.run_attempt }} - name: Checkout uses: actions/checkout@v4 From b6d04c3c04c5955112ef5fd57f478c2946b1edf8 Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Wed, 5 Aug 2026 12:30:37 +0200 Subject: [PATCH 16/22] fix(review): refine comment presentation --- README.md | 2 +- action.yml | 35 +++++++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fe70adb..5f0aad5 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ Review mode does not need `contents: write`: PR-specific generated files are sto | `changed_only` | review | `false` | Render only changed components and incident edges. | | `agent_model` | both | `google/gemini-3-flash-preview` | Analysis model. OpenRouter default shown; other providers use their own engine default. | | `parsing_model` | both | `google/gemini-3.1-flash-lite-preview` | Parsing model. OpenRouter default shown; other providers use their own engine default. | -| `comment_header` | review | `Architecture review` | Heading for the PR comment. | +| `comment_header` | review | `CodeBoarding review` | Heading for the PR comment. | | `trigger_command` | review | `/codeboarding` | Slash command for trusted on-demand runs. | | `cta_base_url` | review | empty | Click-proxy base URL: deep-links the editor link into VS Code/Cursor and adds a "get the extension" link (tracks owner/repo/pr). Empty links to the extension listing instead (GitHub strips `vscode:`/`cursor:` from comments). | | `webview_base_url` | review | `https://app.codeboarding.org` | Hosted webview base URL. The PR comment links to an artifact-backed head-vs-comparison-branch architecture diff. Set empty to disable the browser link. | diff --git a/action.yml b/action.yml index a09ef9d..6890f0a 100644 --- a/action.yml +++ b/action.yml @@ -40,7 +40,7 @@ inputs: comment_header: description: 'Header text for the review comment.' required: false - default: 'Architecture review' + default: 'CodeBoarding review' diagram_direction: description: 'Mermaid direction: LR, TD, TB, RL, or BT.' required: false @@ -49,6 +49,10 @@ inputs: description: 'Review-mode slash command that triggers an on-demand run from a pull request comment.' required: false default: '/codeboarding' + webview_base_url: + description: 'Hosted CodeBoarding webview base URL. Set empty to omit the full-change link.' + required: false + default: 'https://app.codeboarding.org' sync_strategy: description: 'Sync delivery strategy: push or pull_request.' required: false @@ -810,18 +814,33 @@ runs: HEADER: ${{ inputs.comment_header }} DIAGRAM: ${{ steps.review_render.outputs.diagram_md }} N_CHANGED: ${{ steps.review_render.outputs.n_changed }} - ANALYSIS_MODE: ${{ steps.review_analyze.outputs.analysis_mode }} - TRUNCATED: ${{ steps.review_render.outputs.truncated }} ARTIFACT_URL: ${{ steps.upload_review_artifact.outputs.artifact-url }} + WEBVIEW_BASE_URL: ${{ inputs.webview_base_url }} + PR_NUMBER: ${{ steps.guard.outputs.pr_number }} run: | - RENDER="full top-level" - if [ "$TRUNCATED" = "true" ]; then - RENDER="changed-only (auto-trimmed)" + COMPONENT_NOUN="components" + if [ "$N_CHANGED" = "1" ]; then + COMPONENT_NOUN="component" + fi + + RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + WEBVIEW_URL="" + if [ -n "$WEBVIEW_BASE_URL" ]; then + WEBVIEW_URL="${WEBVIEW_BASE_URL%/}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}?run=${GITHUB_RUN_ID}" fi + BODY="${RUNNER_TEMP}/review-comment.md" - printf '### %s\n\n**Status:** %s changed component(s)\n**Mode:** `%s`\n**Render:** `%s`\n\n' "$HEADER" "$N_CHANGED" "$ANALYSIS_MODE" "$RENDER" > "$BODY" + printf '### %s\n\n**Status:** %s changed %s\n' "$HEADER" "$N_CHANGED" "$COMPONENT_NOUN" > "$BODY" + if [ -n "$WEBVIEW_URL" ]; then + printf '\nSee the full change in [CodeBoarding](%s).\n' "$WEBVIEW_URL" >> "$BODY" + fi + printf '\n' >> "$BODY" cat "$DIAGRAM" >> "$BODY" - printf '\n\n**Hosted webview:** [open artifact](%s)\n\nrun %s · attempt %s\n' "$ARTIFACT_URL" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$BODY" + printf '\n\n' >> "$BODY" + if [ -n "$ARTIFACT_URL" ]; then + printf '[download artifacts](%s) · ' "$ARTIFACT_URL" >> "$BODY" + fi + printf 'run [%s](%s)\n' "$GITHUB_RUN_ID" "$RUN_URL" >> "$BODY" echo "path=$BODY" >> "$GITHUB_OUTPUT" - name: Post review comment From 3024fdb8b4614ab738653f62a72bfacbfc1b4c5f Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Wed, 5 Aug 2026 12:53:44 +0200 Subject: [PATCH 17/22] fix(sync): preserve user CodeBoarding config --- action.yml | 29 ++++--- scripts/install_sync_artifacts.py | 113 +++++++++++++++++++++++++++ tests/test_install_sync_artifacts.py | 94 ++++++++++++++++++++++ 3 files changed, 226 insertions(+), 10 deletions(-) create mode 100755 scripts/install_sync_artifacts.py create mode 100644 tests/test_install_sync_artifacts.py diff --git a/action.yml b/action.yml index 6890f0a..72a82f3 100644 --- a/action.yml +++ b/action.yml @@ -468,6 +468,7 @@ runs: if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' shell: bash env: + ACTION_PATH: ${{ github.action_path }} ANALYSIS_PATH: ${{ steps.sync_analyze.outputs.analysis_path }} ANALYSIS_DIR: ${{ steps.sync_analyze.outputs.analysis_dir }} DOC_DIR: ${{ steps.sync_render.outputs.docs_dir }} @@ -480,22 +481,30 @@ runs: set -euo pipefail OUTPUT_DIR="${GITHUB_WORKSPACE}/.codeboarding" + GENERATED_PATHS="${RUNNER_TEMP}/cb-sync-generated-paths" push_token="${{ inputs.push_token }}" REPO="${{ github.repository }}" - rm -rf "$OUTPUT_DIR" - mkdir -p "$OUTPUT_DIR" "$OUTPUT_DIR/health" - - cp -a "$DOC_DIR"/*.md "$OUTPUT_DIR/" 2>/dev/null || true - cp "$ANALYSIS_PATH" "$OUTPUT_DIR/analysis.json" - [ -f "$ANALYSIS_DIR/fingerprint.json" ] && cp "$ANALYSIS_DIR/fingerprint.json" "$OUTPUT_DIR/fingerprint.json" - [ -f "$ANALYSIS_DIR/static_analysis.pkl" ] && cp "$ANALYSIS_DIR/static_analysis.pkl" "$OUTPUT_DIR/static_analysis.pkl" - [ -f "$ANALYSIS_DIR/static_analysis.sha" ] && cp "$ANALYSIS_DIR/static_analysis.sha" "$OUTPUT_DIR/static_analysis.sha" - [ -f "$ANALYSIS_DIR/health/health_report.json" ] && cp "$ANALYSIS_DIR/health/health_report.json" "$OUTPUT_DIR/health/health_report.json" + python3 "$ACTION_PATH/scripts/install_sync_artifacts.py" \ + --output-dir "$OUTPUT_DIR" \ + --docs-dir "$DOC_DIR" \ + --analysis "$ANALYSIS_PATH" \ + --analysis-dir "$ANALYSIS_DIR" \ + > "$GENERATED_PATHS" git config user.name "codeboarding-review[bot]" git config user.email "codeboarding-review[bot]@users.noreply.github.com" - git add "$OUTPUT_DIR" 2>/dev/null || true + STAGE_PATHS=() + while IFS= read -r path; do + if [ -e "$path" ] || git ls-files --error-unmatch "$path" >/dev/null 2>&1; then + STAGE_PATHS+=("$path") + fi + done < "$GENERATED_PATHS" + if [ "${#STAGE_PATHS[@]}" -eq 0 ]; then + echo "::error::No generated sync artifacts were installed." + exit 1 + fi + git add -f -A -- "${STAGE_PATHS[@]}" if git diff --cached --quiet; then echo "::notice::Generated architecture is unchanged." diff --git a/scripts/install_sync_artifacts.py b/scripts/install_sync_artifacts.py new file mode 100755 index 0000000..3e5149e --- /dev/null +++ b/scripts/install_sync_artifacts.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Install generated sync artifacts without deleting user-authored config.""" + +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path + + +class ArtifactInstallError(RuntimeError): + pass + + +ROOT_ARTIFACTS = ( + "fingerprint.json", + "static_analysis.pkl", + "static_analysis.sha", + "codeboarding_version.json", +) + + +def _remove_file(path: Path) -> bool: + if not path.exists() and not path.is_symlink(): + return False + if path.is_dir(): + raise ArtifactInstallError(f"Generated artifact path is a directory: {path}") + path.unlink() + return True + + +def _replace_optional(source: Path, target: Path, stage_paths: set[Path]) -> None: + existed = _remove_file(target) + if source.is_file(): + shutil.copy2(source, target) + stage_paths.add(target) + elif existed: + stage_paths.add(target) + + +def install_sync_artifacts( + *, + output_dir: Path, + docs_dir: Path, + analysis_path: Path, + analysis_dir: Path, +) -> list[Path]: + """Replace only action-owned artifacts and return paths that should be staged.""" + + rendered_docs = sorted(docs_dir.glob("*.md")) + if not rendered_docs: + raise ArtifactInstallError(f"No rendered markdown files found in: {docs_dir}") + if not analysis_path.is_file(): + raise ArtifactInstallError(f"Missing analysis artifact: {analysis_path}") + + stage_paths: set[Path] = set() + if output_dir.is_dir(): + for old_doc in output_dir.glob("*.md"): + _remove_file(old_doc) + stage_paths.add(old_doc) + + output_dir.mkdir(parents=True, exist_ok=True) + health_dir = output_dir / "health" + health_dir.mkdir(parents=True, exist_ok=True) + + for source in rendered_docs: + target = output_dir / source.name + _remove_file(target) + shutil.copy2(source, target) + stage_paths.add(target) + + analysis_target = output_dir / "analysis.json" + _remove_file(analysis_target) + shutil.copy2(analysis_path, analysis_target) + stage_paths.add(analysis_target) + + for name in ROOT_ARTIFACTS: + _replace_optional(analysis_dir / name, output_dir / name, stage_paths) + + _replace_optional( + analysis_dir / "health" / "health_report.json", + health_dir / "health_report.json", + stage_paths, + ) + + return sorted(stage_paths) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--docs-dir", required=True, type=Path) + parser.add_argument("--analysis", required=True, type=Path) + parser.add_argument("--analysis-dir", required=True, type=Path) + args = parser.parse_args(argv) + + try: + paths = install_sync_artifacts( + output_dir=args.output_dir, + docs_dir=args.docs_dir, + analysis_path=args.analysis, + analysis_dir=args.analysis_dir, + ) + except ArtifactInstallError as exc: + parser.error(str(exc)) + + for path in paths: + print(path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_install_sync_artifacts.py b/tests/test_install_sync_artifacts.py new file mode 100644 index 0000000..d2b3dde --- /dev/null +++ b/tests/test_install_sync_artifacts.py @@ -0,0 +1,94 @@ +"""Regression tests for selective sync-artifact installation.""" + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import install_sync_artifacts as isa + + +class InstallSyncArtifactsTests(unittest.TestCase): + def test_preserves_user_config_while_replacing_generated_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / ".codeboarding" + health = output / "health" + docs = root / "docs" + analysis = root / "analysis" + analysis_health = analysis / "health" + for directory in (health, docs, analysis_health): + directory.mkdir(parents=True) + + preserved = { + output / ".codeboardingignore": "ignore me\n", + output / "health_config.json": '{"root": true}\n', + health / ".healthignore": "known issue\n", + health / "health_config.json": '{"health": true}\n', + output / "notes.txt": "user notes\n", + } + for path, content in preserved.items(): + path.write_text(content, encoding="utf-8") + + (output / "stale-component.md").write_text("stale\n", encoding="utf-8") + (output / "codeboarding_version.json").write_text("stale\n", encoding="utf-8") + (health / "health_report.json").write_text("old report\n", encoding="utf-8") + (docs / "overview.md").write_text("# New overview\n", encoding="utf-8") + analysis_path = analysis / "analysis.json" + analysis_path.write_text('{"new": true}\n', encoding="utf-8") + (analysis / "fingerprint.json").write_text('{"fingerprint": true}\n', encoding="utf-8") + (analysis_health / "health_report.json").write_text("new report\n", encoding="utf-8") + + stage_paths = isa.install_sync_artifacts( + output_dir=output, + docs_dir=docs, + analysis_path=analysis_path, + analysis_dir=analysis, + ) + + for path, content in preserved.items(): + self.assertEqual(path.read_text(encoding="utf-8"), content) + self.assertFalse((output / "stale-component.md").exists()) + self.assertFalse((output / "codeboarding_version.json").exists()) + self.assertEqual((output / "overview.md").read_text(encoding="utf-8"), "# New overview\n") + self.assertEqual((output / "analysis.json").read_text(encoding="utf-8"), '{"new": true}\n') + self.assertEqual((health / "health_report.json").read_text(encoding="utf-8"), "new report\n") + + staged = set(stage_paths) + self.assertIn(output / "stale-component.md", staged) + self.assertIn(output / "codeboarding_version.json", staged) + self.assertIn(output / "overview.md", staged) + self.assertIn(output / "analysis.json", staged) + self.assertNotIn(output / ".codeboardingignore", staged) + self.assertNotIn(health / ".healthignore", staged) + self.assertNotIn(health / "health_config.json", staged) + + def test_rejects_empty_render_output_before_modifying_destination(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / ".codeboarding" + docs = root / "docs" + analysis = root / "analysis" + output.mkdir() + docs.mkdir() + analysis.mkdir() + existing = output / "overview.md" + existing.write_text("keep on failure\n", encoding="utf-8") + analysis_path = analysis / "analysis.json" + analysis_path.write_text("{}\n", encoding="utf-8") + + with self.assertRaises(isa.ArtifactInstallError): + isa.install_sync_artifacts( + output_dir=output, + docs_dir=docs, + analysis_path=analysis_path, + analysis_dir=analysis, + ) + + self.assertEqual(existing.read_text(encoding="utf-8"), "keep on failure\n") + + +if __name__ == "__main__": + unittest.main() From ef9fdea9356ae574f79adb1ceb6bf9ca8d4bd4ae Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 8 Aug 2026 10:44:17 +0000 Subject: [PATCH 18/22] feat!: simplify the v2 action contract Move orchestration into focused scripts, reduce persisted sync output to Core analysis state, support direct providers with model overrides, and route hosted OIDC requests through the deployed AWS proxy. BREAKING CHANGE: v2 removes deprecated v1 inputs and generated sync documentation. Amp-Thread-ID: https://ampcode.com/threads/T-019fdc90-d8c3-779f-92ce-ce9888aad94d Co-authored-by: Ivan Milev --- README.md | 366 +++------- action.yml | 883 ++++-------------------- scripts/action/analyze.sh | 104 +++ scripts/action/build-review-artifact.sh | 12 + scripts/action/build-review-comment.sh | 23 + scripts/action/configure-auth.sh | 60 ++ scripts/action/deliver-sync.sh | 127 ++++ scripts/action/guard.sh | 86 +++ scripts/action/install-sync.sh | 48 ++ scripts/action/render-review.sh | 28 + scripts/action/sync-summary.sh | 12 + scripts/action/with-auth.sh | 54 ++ scripts/analyze_repository.py | 83 +-- scripts/install_sync_artifacts.py | 113 --- scripts/render_sync_docs.py | 52 -- {scripts => tests}/run_local.sh | 6 +- tests/test_action_auth.py | 185 +++++ tests/test_action_sync.py | 117 ++++ tests/test_analyze_repository.py | 4 + tests/test_install_sync_artifacts.py | 94 --- tests/test_render_sync_docs.py | 105 --- 21 files changed, 1146 insertions(+), 1416 deletions(-) create mode 100755 scripts/action/analyze.sh create mode 100755 scripts/action/build-review-artifact.sh create mode 100755 scripts/action/build-review-comment.sh create mode 100755 scripts/action/configure-auth.sh create mode 100755 scripts/action/deliver-sync.sh create mode 100755 scripts/action/guard.sh create mode 100755 scripts/action/install-sync.sh create mode 100755 scripts/action/render-review.sh create mode 100755 scripts/action/sync-summary.sh create mode 100755 scripts/action/with-auth.sh delete mode 100755 scripts/install_sync_artifacts.py delete mode 100755 scripts/render_sync_docs.py rename {scripts => tests}/run_local.sh (96%) create mode 100644 tests/test_action_auth.py create mode 100644 tests/test_action_sync.py delete mode 100644 tests/test_install_sync_artifacts.py delete mode 100644 tests/test_render_sync_docs.py diff --git a/README.md b/README.md index 5f0aad5..dbf1647 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,15 @@ # CodeBoarding Action -One action, two modes: architecture review on every pull request, and a versioned, always-current architecture baseline on your main branch. - -- **`mode: review`** (the default) — CodeBoarding analyzes your architecture on the target branch and PR branch, comments on the PR with an inline Mermaid diagram and hosted webview link, and uploads the PR-head `analysis.json` plus target-branch metadata as a GitHub Actions artifact. It never commits generated files to the PR branch. Runs on `pull_request` and `issue_comment`. -- **`mode: sync`** — CodeBoarding keeps your architecture analysis versioned and current on your branch: on every push it commits the `analysis.json` baseline, `static_analysis.pkl` cache pair, health report, and readable markdown (`.codeboarding/*.md`), so reviews diff against your current architecture and your architecture has real git history. Runs on `push`, `workflow_dispatch`, and `schedule`. See [sync mode](#keep-your-architecture-versioned-sync-mode). - -Both modes run the [CodeBoarding](https://github.com/CodeBoarding/CodeBoarding) engine in CI: static analysis combined with LLM reasoning. They are designed to be used together — [sync mode keeps the baseline fresh that review mode diffs against](#how-the-two-modes-work-together) — but each works on its own. - -[CodeBoarding](https://github.com/CodeBoarding/CodeBoarding) · [Website](https://codeboarding.org) · [Explore examples](https://codeboarding.org/diagrams) · [VS Code extension](https://marketplace.visualstudio.com/items?itemName=Codeboarding.codeboarding) · [Discord](https://discord.gg/T5zHTJYFuy) - -[![JavaScript](https://img.shields.io/badge/JavaScript-222222?style=flat-square&logo=javascript&logoColor=F7DF1E)](https://developer.mozilla.org/en-US/docs/Web/JavaScript) -[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Java](https://img.shields.io/badge/Java-E76F00?style=flat-square&logo=openjdk&logoColor=white)](https://www.java.com/) -[![Python](https://img.shields.io/badge/Python-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/) -[![Go](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev/) -[![PHP](https://img.shields.io/badge/PHP-777BB4?style=flat-square&logo=php&logoColor=white)](https://www.php.net/) -[![Rust](https://img.shields.io/badge/Rust-000000?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org/) -[![C#](https://custom-icon-badges.demolab.com/badge/C%23-512BD4.svg?style=flat-square&logo=cshrp&logoColor=white)](https://learn.microsoft.com/en-us/dotnet/csharp/) - -## What review mode does - -- Builds or reuses a baseline architecture analysis for the target branch tip the PR is opened against. -- Runs incremental analysis on the PR head, then diffs components and relationships. -- Posts a sticky PR comment with an inline Mermaid map. Green is added, yellow is modified, red (dashed) is deleted, for both nodes and edges. -- Uploads the PR-head `analysis.json` plus target-branch metadata as a GitHub Actions artifact and links the hosted webview to that artifact instead of committing generated files to the PR branch. - -A PR comment looks like this: - -```mermaid -graph LR - Orchestration_Workflow_Manager["Orchestration & Workflow Manager"] - Incremental_Analysis_Controller["Incremental Analysis Controller"] - Static_Analysis_Engine["Static Analysis Engine"] - Agentic_Intelligence_Core["Agentic Intelligence Core"] - Health_Quality_Monitor["Health & Quality Monitor"] - Rendering_Output_Engine["Rendering & Output Engine"] - Persistence_Provider_Infrastructure["Persistence & Provider Infrastructure"] - Orchestration_Workflow_Manager -- "triggers change detection" --> Incremental_Analysis_Controller - Incremental_Analysis_Controller -- "passes filtered file sets" --> Static_Analysis_Engine - Static_Analysis_Engine -- "provides CFGs and symbol tables" --> Agentic_Intelligence_Core - Static_Analysis_Engine -- "supplies structural metrics" --> Health_Quality_Monitor - Agentic_Intelligence_Core -- "delivers summaries and diagrams" --> Rendering_Output_Engine - Health_Quality_Monitor -- "provides health reports" --> Rendering_Output_Engine - Persistence_Provider_Infrastructure -- "supplies LLM clients" --> Agentic_Intelligence_Core - Orchestration_Workflow_Manager -- "persists pipeline state" --> Persistence_Provider_Infrastructure - classDef added fill:#1f883d,stroke:#0b5d23,color:#fff; - classDef modified fill:#bf8700,stroke:#7d4e00,color:#fff; - classDef deleted fill:#cf222e,stroke:#82071e,color:#fff,stroke-dasharray:5 3; - class Health_Quality_Monitor added; - class Static_Analysis_Engine,Agentic_Intelligence_Core modified; - class Persistence_Provider_Infrastructure deleted; - linkStyle 3,5 stroke:#1f883d,stroke-width:2px; - linkStyle 2 stroke:#bf8700,stroke-width:2px; - linkStyle 6,7 stroke:#cf222e,stroke-width:2px,stroke-dasharray:5 3; -``` +One GitHub Action with two modes: + +- **`review`** (default) compares a pull request's exact base and head commits, posts an inline Mermaid architecture diff, and uploads the head `analysis.json` 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. + +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. + +[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) -## Quick start: PR review (review mode) +## Review pull requests Create `.github/workflows/codeboarding.yml`: @@ -64,11 +18,7 @@ name: CodeBoarding review on: pull_request: - # Generate once, when the PR becomes reviewable, not on every push, so you - # don't spend an LLM job per commit. Use [opened] for strictly creation-only, - # or add `synchronize` to re-run on each push. Refresh anytime with /codeboarding. - # 'closed' only cancels an in-flight review (see concurrency), it doesn't start one. - types: [opened, reopened, ready_for_review, closed] + types: [opened, reopened, ready_for_review] issue_comment: types: [created] @@ -76,112 +26,87 @@ permissions: contents: read pull-requests: write issues: write - # Lets the action mint a short-lived GitHub OIDC token so the free hosted tier - # can identify your repository. Required for the no-secret (free-tier) path; - # harmless to keep when you bring your own key. id-token: write -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. - cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} - jobs: review: - runs-on: ubuntu-latest - timeout-minutes: 60 if: > - (github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.draft == false) || + (github.event_name == 'pull_request' && github.event.pull_request.draft == false && + 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 steps: - - uses: CodeBoarding/CodeBoarding-action@v1 + - uses: CodeBoarding/CodeBoarding-action@v2 ``` -That's it — **no extra setup**. With `id-token: write` granted, the action runs on the **free hosted tier**: it mints a GitHub OIDC token, and CodeBoarding's proxy supplies the LLM, metered per repository owner against a weekly cap. Merge the workflow and your next pull request gets an architecture diff. +Automatic runs update one sticky **CodeBoarding review** comment. A trusted repository owner, member, or collaborator can comment `/codeboarding` to analyze the current PR head again; every command creates a new result comment. -Models are optional. Omit `agent_model` and `parsing_model` to use the defaults, or pin them inline or from a repository variable (a model name is not a secret, so use `vars.`, not `secrets.`): +The action checks out and analyzes the exact PR head SHA, but compares it with the exact upstream base SHA from the event. It does not commit generated files to either branch. -```yaml - with: - agent_model: anthropic/claude-sonnet-4 # optional; or ${{ vars.AGENT_MODEL }} - parsing_model: google/gemini-3-flash-preview # optional -``` +Fork PRs are rejected before authentication. Core language servers may execute repository-controlled build or plugin logic, so checking out a fork under `pull_request_target` or a privileged `/codeboarding` run would expose OIDC or provider credentials. Supporting forks safely requires an isolated Core analysis stage rather than more action configuration. - -## More usage (your own key or a license) +## Authentication and providers -The free tier is metered per repository owner against a weekly cap. For more — or unmetered — usage, supply a credential. Option A (your own key) talks to the provider directly and needs no `id-token: write`; Option B (a license) still uses the hosted proxy, so it keeps `id-token: write` (the proxy verifies the OIDC identity, then the license skips the quota): +With no LLM inputs, the action uses CodeBoarding's hosted OpenRouter tier. It mints short-lived GitHub OIDC credentials per request, so the job needs `id-token: write` and no stored LLM secret. + +For a direct provider, pass its name and key: ```yaml + - uses: CodeBoarding/CodeBoarding-action@v2 with: - # Option A — your own OpenRouter key (talks to OpenRouter directly): - llm_api_key: ${{ secrets.OPENROUTER_API_KEY }} - # Option B — a CodeBoarding license (unmetered hosted usage): - # license_key: ${{ secrets.CODEBOARDING_LICENSE }} + llm_provider: anthropic + llm_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ``` -Add the secret under **Settings → Secrets and variables → Actions** (e.g. `OPENROUTER_API_KEY = sk-or-...`). For local runs with `scripts/run_local.sh`, export `OPENROUTER_API_KEY` as an environment variable instead. When `llm_api_key` is set it takes precedence; `license_key` is used only when no key is set; with neither, the free OIDC tier is used. +The action maps a provider name to the environment variable Core uses (`anthropic` → `ANTHROPIC_API_KEY`, `openai` → `OPENAI_API_KEY`, and so on). `aws`/`aws_bedrock` maps to `AWS_BEARER_TOKEN_BEDROCK`. Provider names following the standard convention are not restricted by an action-side allowlist; the pinned Core release remains the source of truth for which providers it implements. -## Bring your own LLM provider +CodeBoarding 0.13.5 supports OpenRouter, OpenAI-compatible endpoints, Anthropic, Google, Vercel AI Gateway, AWS Bedrock, Cerebras, DeepSeek, GLM, Kimi, Ollama, and LiteLLM. See Core's [`agents/llm_config.py`](https://github.com/CodeBoarding/CodeBoarding/blob/main/agents/llm_config.py) for current defaults and endpoint variables. In particular, Ollama needs `OLLAMA_BASE_URL` or `OLLAMA_HOST`, and LiteLLM needs `LITELLM_BASE_URL` on the action step. -OpenRouter is the default, but you can use any provider the engine supports. Set `llm_provider` and pass that provider's key: +A CodeBoarding license keeps the hosted OIDC path but removes hosted quota limits: ```yaml with: - llm_provider: anthropic # omit for OpenRouter (default) - llm_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + license_key: ${{ secrets.CODEBOARDING_LICENSE }} ``` -`llm_provider: ` hands your key to the engine as `_API_KEY`, and the engine auto-selects that provider. Set exactly one key per run. - -
Supported providers - -| `llm_provider` | Environment variable the engine reads | -|---|---| -| `openrouter` (default) | `OPENROUTER_API_KEY` | -| `openai` | `OPENAI_API_KEY` | -| `anthropic` | `ANTHROPIC_API_KEY` | -| `google` | `GOOGLE_API_KEY` | -| `vercel` | `VERCEL_API_KEY` | -| `deepseek` | `DEEPSEEK_API_KEY` | -| `cerebras` | `CEREBRAS_API_KEY` | -| `glm` / `kimi` | `GLM_API_KEY` / `KIMI_API_KEY` | -| `aws_bedrock` | `AWS_BEARER_TOKEN_BEDROCK` | -| `ollama` | `OLLAMA_BASE_URL` | - -This table mirrors the engine and may lag it. The source of truth is the engine's provider registry, [`agents/llm_config.py`](https://github.com/CodeBoarding/CodeBoarding/blob/main/agents/llm_config.py). Any provider it adds that follows the `_API_KEY` convention works here with no action change. +`llm_api_key` takes precedence over `license_key`. A direct provider key does not require `id-token: write`; hosted free and licensed usage does. -
+## Model selection -## When review mode runs +All model inputs are optional and are passed directly to Core without action-side validation: -- On a PR being opened, reopened, or marked ready for review, the diagram is generated once (per the `on:` triggers above). It does not re-run on every push, so you never spend an LLM job per commit; the comment reflects that point until refreshed. -- On a `/codeboarding` comment, a trusted collaborator (`OWNER`, `MEMBER`, or `COLLABORATOR`) regenerates the diagram against the current PR head, even if one already exists. Each `/codeboarding` invocation posts a **new** comment and leaves earlier comments untouched (the automatic on-open comment, and any previous `/codeboarding` results, stay put). Change the keyword via `trigger_command`. +```yaml + with: + model: google/gemini-3-flash-preview + agent_model: anthropic/claude-sonnet-4 # optional analysis-only override + parsing_model: openai/gpt-5-mini # optional parsing-only override +``` -The command needs the `issue_comment` trigger and runs from your default branch (a GitHub rule), so it only works once the workflow is merged there. On-demand runs on fork PRs are refused, so fork code is never analyzed with your secrets. +Precedence is intentionally simple: -### Feedback command +| Work | Resolution | +|---|---| +| Analysis | `agent_model` → `model` → active provider's Core default | +| Parsing | `parsing_model` → `model` → active provider's Core default | -In review workflows that include `issue_comment`, anyone whose comment reaches the action can send product feedback with: +Set only `model` when both jobs should use the same model. Set either specialized input only when that job needs a different model. Model identifiers are not secrets and can be stored in GitHub repository variables. -```text -/codeboarding-feedback -``` +## Keep the baseline current -## Keep your architecture versioned (sync mode) +Sync mode commits only Core's persisted incremental-analysis state under `.codeboarding/`: -With `mode: sync`, the action analyzes the pushed commit and commits the results back to the branch (as `codeboarding-review[bot]` when the CodeBoarding GitHub App token is configured, otherwise `github-actions[bot]`), so your architecture analysis stays versioned in git and tracks the code instead of drifting from it: +- `analysis.json` +- `fingerprint.json` +- `static_analysis.pkl` +- `static_analysis.sha` +- `codeboarding_version.json` when emitted by Core -- `.codeboarding/*.md` — rendered architecture docs: `overview.md` plus one page per component (directory configurable via `output_dir`). -- `.codeboarding/analysis.json` — the machine-readable analysis, which doubles as the baseline that review mode diffs against. -- `.codeboarding/static_analysis.pkl` + `.codeboarding/static_analysis.sha` — the static-analysis cache pair used to keep future incremental runs fast and reproducible. -- `.codeboarding/health/health_report.json` — health findings for the committed baseline. -- `docs/development/architecture.md` (optional, on by default) — all pages concatenated into a single document, `overview.md` first. Disable with `write_architecture_md: false`. +It does **not** render or commit architecture Markdown. Existing v1-generated `.codeboarding/*.md`, `.codeboarding/health/health_report.json`, and `docs/development/architecture.md` are removed on the first v2 sync; user-authored CodeBoarding configuration is preserved. -Create `.github/workflows/codeboarding-sync.yml` next to your review workflow: +Create `.github/workflows/codeboarding-sync.yml`: ```yaml name: CodeBoarding sync @@ -189,28 +114,22 @@ name: CodeBoarding sync on: push: branches: [main] - # Loop guard: don't re-trigger on the files this workflow itself commits. - # List every GENERATED file, and only generated files — never a user-authored - # input, whose edit changes analysis scope and must regenerate. So not - # '.codeboarding/**' (would swallow .codeboarding/.codeboardingignore) and not - # '.codeboarding/health/**' (would swallow your health/.healthignore and - # health/health_config.json); list only the generated health/health_report.json. - # (The action also skips re-analyzing its own bot commit as a backstop, and - # deliberately does NOT use [skip ci] — that would leak through squash-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: Rebuild without the committed baseline + type: boolean + default: false permissions: - contents: write # commit the generated docs to the branch - id-token: write # hosted tiers, free + license (omit only if you set llm_api_key) + contents: write + id-token: write concurrency: group: codeboarding-sync @@ -221,162 +140,87 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: CodeBoarding/CodeBoarding-action@v1 + - uses: CodeBoarding/CodeBoarding-action@v2 with: mode: sync - # Runs on the free tier with no extra setup. For more/unmetered usage add - # `llm_api_key: ${{ secrets.OPENROUTER_API_KEY }}` (and drop id-token: write). + target_branch: main + force_full: ${{ inputs.force_full || false }} ``` -Behavior worth knowing: - -- The first run on a branch is a full analysis at depth 2 by default; subsequent runs reuse the committed baseline and run incrementally when they can (the `analysis_mode` output tells you which happened). Once an `analysis.json` exists, its recorded `metadata.depth_level` is preserved for incremental runs and fallback-full recovery. Baselines in a format the installed engine can no longer load—including the pre-0.13.0 format—are rebuilt automatically with a full analysis at that preserved depth. -- The commit is skipped when nothing meaningful changed (an empty diff, or only `generated_at`/timestamp fields). The push retries a few times with fetch+rebase and fails open, so a race with another push never fails your CI. -- Tag pushes are skipped. `pull_request` events soft-skip in sync mode, so a mistakenly shared workflow can never push docs from a PR run. -- The bot commit carries **no `[skip ci]`** — on a squash-merge that marker leaks into the merge commit and would skip the very sync run (and release tooling, CI) the merge should trigger. The regen loop is instead prevented by the `paths-ignore` list above **and** by the action skipping re-analysis of its own bot commit, so a merge to `main` reliably triggers a fresh incremental sync. -- `output_dir` is owned by the action: pre-existing top-level markdown files in it are deleted on every run (stale component pages must not linger). Don't point it at a directory with hand-written docs. +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. -### Protected default branch (open a PR instead of pushing) +### Protected branches -If your default branch rejects direct pushes (branch protection), set `sync_strategy: pull_request`. Instead of committing the baseline straight to `target_branch`, the action commits it to a machine-owned branch (`sync_pr_branch`, default `codeboarding/sync`) and opens — then keeps force-updating — a **single rolling PR** into `target_branch`. Merge that PR to land the baseline; your protection rules stay fully intact (no bypass actor), and the PR diff is always just the generated files. - -This builds on the sync workflow above — same `on:` (with the `paths-ignore` list) and the same `concurrency` block, which is **required** here (see the note on concurrency below). Only the job changes: +Set `sync_strategy: pull_request` and grant `pull-requests: write`: ```yaml -jobs: - sync: - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: write # push the sync branch - pull-requests: write # open/update the rolling PR - id-token: write # hosted tiers, free + license (omit only if you set llm_api_key) - steps: - - uses: CodeBoarding/CodeBoarding-action@v1 +permissions: + contents: write + pull-requests: write + id-token: write + +# ... + - uses: CodeBoarding/CodeBoarding-action@v2 with: mode: sync + target_branch: main sync_strategy: pull_request - # push_token must carry pull-requests: write. The default github.token - # does when the job grants it (above) AND the repo/org setting "Allow - # GitHub Actions to create and approve pull requests" is enabled — see the - # note below. A GitHub App token or PAT also works, attributes the PR to - # that identity, and (unlike github.token) lets the PR trigger other - # workflows. - # push_token: ${{ steps.app-token.outputs.token }} ``` -Operational requirements and behavior: - -- **Merge the rolling PR on a cadence.** The baseline reaches `target_branch` only when this PR is merged. That is what keeps review-mode diffs fast (review reads the baseline from the PR's base branch) *and* keeps incremental sync warm: each sync run seeds its incremental analysis from the baseline committed on `target_branch`, and re-detects **every** change since that last-merged baseline (change detection is whole-tree content-based, so no commits in between are ever missed — just larger diffs the longer you wait to merge). The action never seeds from the unmerged sync branch, so a poisoned or half-written baseline on that branch can never influence an analysis. -- **Merging the PR does not re-trigger a full analysis.** A merge of a baseline-only PR changes only generated files, which the `paths-ignore` above already excludes — so the workflow never starts. Keep that list complete and generated-only: your own `.codeboardingignore` / `.healthignore` / `health_config.json` are deliberately left out so editing analysis scope still regenerates. (If you drop a generated path from the list, the merge triggers one run that then no-ops at the "nothing to commit" step — a single wasted run, never a loop.) -- **Exclude the sync branch from your other PR workflows.** The rolling PR would otherwise trigger your review workflow, tests, and lint on a baseline-only diff. Use a **job-level head-branch guard** on each — `if: github.head_ref != 'codeboarding/sync'`. Note that `on.pull_request.branches-ignore` filters the PR's *base* branch, not its head, so it will **not** exclude the rolling PR (which targets `main`) — the `if:` guard is the correct tool. -- **Allow Actions to create PRs.** With the default `github.token`, `pull-requests: write` alone is not enough if your repo or org has disabled *Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests"*. In that configuration the branch is pushed but every PR-create fails open (no PR). Enable that setting, or set `push_token` to a GitHub App token / PAT with `pull-requests: write`. -- **Keep the serializing `concurrency` block.** It is required, not optional: it makes sync runs execute one at a time so the newest commit's baseline wins the rolling PR. The action leases its force-push (`--force-with-lease`) as a safety net, but without the concurrency group two runs can still race and one will fail open rather than land — so a run may be skipped until the next change. -- **The sync branch is machine-owned.** It is reset to the current `target_branch` tip plus one baseline commit and force-pushed every run. Don't commit to it by hand. Closing the PR without merging is not sticky — the next push reopens it; use `workflow_dispatch` to pause. - -### How the two modes work together +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. -Sync mode keeps the committed `.codeboarding/analysis.json` baseline fresh on main. Review mode reuses that committed baseline from the target branch tip, so PR reviews diff against your *current* main architecture and run incrementally instead of rebuilding the target analysis from scratch — faster and cheaper per PR. - -For fork PRs, review mode compares the PR branch against the fork's branch with the same name as the PR target branch. For example, a PR opened into `upstream/main` from `alice:feature` compares `alice:main` to `alice:feature` when `alice:main` exists. If the fork comparison branch has no committed `.codeboarding/analysis.json`, review mode uses an empty baseline and renders the PR architecture as newly added instead of silently comparing against upstream's baseline. - -Leave `depth_level` empty unless you are choosing the depth for a first run or an intentional `force_full` rebuild. After a baseline exists, the committed `analysis.json` records the depth the engine should continue using, so review and sync mode do not need duplicate depth-selection logic. - -Review mode never commits generated artifacts to PR branches, so squash merges do not orphan PR-head `analysis.json` files on main. Sync mode running on main is the only writer of the committed baseline. - -### Security: keep the two modes in separate workflows - -Use two thin workflow files, each with least privilege, exactly as in the snippets above: - -- **review workflow** — `on: pull_request` (types `[opened, reopened, ready_for_review]`; the quick start adds `closed` purely to cancel in-flight runs) + `issue_comment` (types `[created]`); `permissions: contents: read, pull-requests: write, issues: write`. -- **sync workflow** — `on: push` (branches `[main]`, with the `paths-ignore` list) + `workflow_dispatch`; `permissions: contents: write`. - -The anti-pattern to avoid: one workflow with `on: [push, pull_request]` and a single union permissions block — it forces every privilege either mode needs onto every trigger. Sync mode soft-skips on `pull_request` events as a backstop, but don't rely on it: keep the triggers and permissions split so each workflow grants only what its own mode uses. - -Review mode does not need `contents: write`: PR-specific generated files are stored as workflow artifacts. Only sync mode pushes generated architecture state back to git. +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. ## Inputs | Input | Mode | Default | Description | |---|---|---|---| -| `llm_api_key` | both | empty | Your LLM provider API key (see `llm_provider`). Leave empty to use the free hosted tier via a GitHub OIDC token (needs `permissions: id-token: write`). | -| `llm_provider` | both | `openrouter` | Provider for the key, mapped to `_API_KEY` (e.g. `anthropic`, `openai`, `google`). Ignored on the free/license hosted tier (always OpenRouter via the proxy). | -| `license_key` | both | empty | A CodeBoarding license for unmetered hosted usage. Used when `llm_api_key` is empty; takes precedence over the free tier. | -| `proxy_url` | both | CodeBoarding proxy | Hosted LLM proxy base URL for the free/license tiers (the engine's `OPENROUTER_BASE_URL`). Override only for a self-hosted/dev proxy. | -| `mode` | both | `review` | `review` posts the PR architecture-diff comment; `sync` analyzes on push and commits the architecture (`analysis.json` + rendered docs) to `target_branch`, keeping it versioned and current. | -| `github_token` | both | `${{ github.token }}` | Token for GitHub API calls; in review mode it posts or updates the PR comment. | -| `push_token` | sync | `${{ github.token }}` | Token for sync-mode delivery. The workflow token can push when the workflow grants `permissions: contents: write`. Separate from `github_token` so commenting can use a GitHub App token while the push uses the workflow token. In `sync_strategy: pull_request` it also opens/updates the rolling PR, so it must additionally carry `pull-requests: write`. | -| `codeboarding_version` | both | `0.13.5` | CodeBoarding PyPI package version used as the analysis engine. Pin for reproducibility. | -| `depth_level` | both | empty (`2` for cold starts) | Analysis depth for first analysis and `force_full` rebuilds. Max depends on tier: **3** on the free hosted tier, **10** with a CodeBoarding license or your own `llm_api_key`. Once `.codeboarding/analysis.json` exists, its `metadata.depth_level` is the source of truth: sync runs incremental at the baseline depth, and review analyzes the PR head at the committed baseline depth so the diff is apples-to-apples (clamped to the tier max). | -| `render_depth` | review | `1` | Display depth for the PR diagram. Keep `1` for a clean top-level view. | -| `diagram_direction` | review | `LR` | Mermaid direction: `LR`, `TD`, `TB`, `RL`, or `BT`. | -| `changed_only` | review | `false` | Render only changed components and incident edges. | -| `agent_model` | both | `google/gemini-3-flash-preview` | Analysis model. OpenRouter default shown; other providers use their own engine default. | -| `parsing_model` | both | `google/gemini-3.1-flash-lite-preview` | Parsing model. OpenRouter default shown; other providers use their own engine default. | -| `comment_header` | review | `CodeBoarding review` | Heading for the PR comment. | -| `trigger_command` | review | `/codeboarding` | Slash command for trusted on-demand runs. | -| `cta_base_url` | review | empty | Click-proxy base URL: deep-links the editor link into VS Code/Cursor and adds a "get the extension" link (tracks owner/repo/pr). Empty links to the extension listing instead (GitHub strips `vscode:`/`cursor:` from comments). | -| `webview_base_url` | review | `https://app.codeboarding.org` | Hosted webview base URL. The PR comment links to an artifact-backed head-vs-comparison-branch architecture diff. Set empty to disable the browser link. | -| `output_dir` | sync | `.codeboarding` | Directory the rendered docs and analysis metadata are committed to. Owned by the action: pre-existing top-level `.md` files in it are deleted on every run. | -| `output_format` | sync | `.md` | Output format. Only `.md` is supported. | -| `target_branch` | sync | `${{ github.ref_name }}` | In `push` strategy, the branch the docs are pushed to. In `pull_request` strategy, the PR base branch. | -| `write_architecture_md` | sync | `true` | Also write `docs/development/architecture.md`: all rendered pages concatenated, `overview.md` first. | -| `commit_message` | sync | `chore(codeboarding): sync architecture baseline` | Commit message for the generated docs. No `[skip ci]` (it would leak through squash-merges); the regen loop is guarded by `paths-ignore` + the action's own bot-commit check. | -| `force_full` | sync | `false` | Ignore any committed baseline and run a full analysis from scratch. Use to rebuild a stale or corrupt baseline (e.g. from a `workflow_dispatch`). | -| `sync_strategy` | sync | `push` | Delivery method. `push` commits and fast-forwards `target_branch` (needs `contents: write` and an unprotected branch). `pull_request` commits to `sync_pr_branch` and opens/updates one rolling PR into `target_branch`, for protected branches (needs `contents: write` **and** `pull-requests: write`). See [Protected default branch](#protected-default-branch-open-a-pr-instead-of-pushing). | -| `sync_pr_branch` | sync | `codeboarding/sync` | `pull_request` strategy only: the machine-owned head branch, force-updated each run. Ignored in `push` strategy. | -| `sync_pr_title` | sync | `[CodeBoarding sync]` | `pull_request` strategy only: prefix for the rolling baseline PR title. The analyzed commit hash and first 20 characters of its subject are appended and refreshed on every run. | +| `mode` | both | `review` | `review` or `sync`. | +| `llm_api_key` | both | empty | Direct-provider key. With the default provider, empty selects hosted OIDC usage. | +| `llm_provider` | both | `openrouter` | Provider for `llm_api_key`. | +| `license_key` | both | empty | License for unmetered hosted usage. | +| `model` | both | empty | Default model for both analysis and parsing. | +| `agent_model` | both | empty | Analysis-only override for `model`. | +| `parsing_model` | both | empty | Parsing-only override for `model`. | +| `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. | +| `force_full` | sync | `false` | Ignore the committed baseline for this run. | + +The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, rolling sync branch, commit message, and CodeBoarding version are intentionally fixed in v2 rather than exposed as configuration. ## Outputs | Output | Mode | Description | |---|---|---| -| `diagram_md` | review | Path to the generated Mermaid markdown block on the runner. | -| `n_changed` | review | Number of changed components, counted recursively. | -| `truncated` | review | `true` when the graph was reduced to fit GitHub Mermaid limits. | -| `review_artifact_url` | review | GitHub Actions artifact URL containing the PR-head `analysis.json` and comparison-branch metadata. | -| `analysis_mode` | sync | `full` or `incremental`: whether the run rebuilt the analysis from scratch or reused the committed baseline. | -| `files_written` | sync | The generated files written for the docs commit. | -| `committed` | sync | `true` when a baseline commit was delivered (pushed to `target_branch` in `push` strategy, or pushed to `sync_pr_branch` with its PR opened/updated in `pull_request` strategy); `false` when sync ran but had nothing to commit (or delivery failed open). Empty only if sync mode did not run. | -| `sync_pr_url` | sync | `pull_request` strategy: URL of the opened/updated rolling baseline PR. Empty in `push` strategy or when no PR was produced. | -| `sync_pr_number` | sync | `pull_request` strategy: number of the rolling baseline PR. Empty in `push` strategy or when no PR was produced. | - -Outputs of the mode that did not run are empty strings. +| `diagram_md` | review | Path to the rendered Mermaid block on the runner. | +| `n_changed` | review | Number of changed components. | +| `truncated` | review | Whether the graph was reduced to fit GitHub limits. | +| `review_artifact_url` | review | URL of the uploaded head analysis. | +| `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. | -## Notes +## GitHub Enterprise Server -- No checkout step is required in your workflow. This action checks out the target (the PR in review mode, the pushed commit in sync mode) and installs the CodeBoarding engine from PyPI internally. -- GitHub withholds secrets from fork PRs on `pull_request`, so fork runs fail early if an LLM key is unavailable. -- Do not use `pull_request_target` for this action. It can expose secrets to PR-head code. -- GitHub renders Mermaid in strict mode, so node click-through links are not supported in the PR diagram. +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. -## Local testing +## Local test harness -Fast path, no LLM calls: +Render a diff without making LLM calls: ```bash -scripts/run_local.sh --base-json /tmp/base.json --head-json /tmp/head.json +tests/run_local.sh --base-json /tmp/base.json --head-json /tmp/head.json ``` -Full local pipeline: +Run the local analysis pipeline: ```bash export OPENROUTER_API_KEY=sk-or-... python -m pip install codeboarding==0.13.5 -codeboarding-setup --auto-install-npm -scripts/run_local.sh --repo /path/to/repo --base --head -``` - -Useful flags: - -```text ---depth N ---render-depth N ---direction LR|TD|TB|RL|BT ---changed-only ---no-edge-labels ---out DIR ---no-open +tests/run_local.sh --repo /path/to/repo --base main --head feature ``` ## License diff --git a/action.yml b/action.yml index 72a82f3..982100b 100644 --- a/action.yml +++ b/action.yml @@ -1,886 +1,301 @@ name: 'CodeBoarding Action' -description: 'CodeBoarding architecture sync and review via the CodeBoarding CLI incremental/full contract.' +description: 'Review pull request architecture changes or sync a versioned CodeBoarding baseline.' author: 'CodeBoarding' - branding: icon: 'git-pull-request' color: 'blue' - inputs: - llm_api_key: - description: 'Optional OpenRouter API key. When empty, the action uses the hosted tier via GitHub OIDC.' - required: false - default: '' mode: - description: | - review: analyze pull requests and post architecture review comments - sync: sync baseline artifacts on push/workflow_dispatch/schedule + description: 'review posts a PR architecture diff; sync updates the versioned analysis baseline.' required: false default: 'review' - license_key: - description: 'Optional CodeBoarding license key appended to the OIDC bearer token.' + llm_api_key: + description: 'Optional direct-provider key. With the default provider, empty selects hosted OIDC usage.' required: false default: '' - github_token: - description: 'Token for comments, PR metadata, and rolling sync PR writes.' + llm_provider: + description: 'Provider for llm_api_key, for example openrouter, anthropic, openai, google, or ollama.' required: false - default: ${{ github.token }} - push_token: - description: 'Token used for sync branch push operations.' - required: false - default: ${{ github.token }} - codeboarding_version: - description: 'CodeBoarding package version.' - required: false - default: '0.13.5' - depth_level: - description: 'Full-analysis depth fallback when no committed baseline depth is available.' + default: 'openrouter' + license_key: + description: 'Optional CodeBoarding license for unmetered hosted usage.' required: false default: '' - comment_header: - description: 'Header text for the review comment.' + model: + description: 'Optional model used for both analysis and parsing.' required: false - default: 'CodeBoarding review' - diagram_direction: - description: 'Mermaid direction: LR, TD, TB, RL, or BT.' + default: '' + agent_model: + description: 'Optional analysis-model override. Takes precedence over model.' required: false - default: 'LR' - trigger_command: - description: 'Review-mode slash command that triggers an on-demand run from a pull request comment.' + default: '' + parsing_model: + description: 'Optional parsing-model override. Takes precedence over model.' required: false - default: '/codeboarding' - webview_base_url: - description: 'Hosted CodeBoarding webview base URL. Set empty to omit the full-change link.' + default: '' + github_token: + description: 'Token used for comments and sync delivery.' required: false - default: 'https://app.codeboarding.org' + default: ${{ github.token }} sync_strategy: - description: 'Sync delivery strategy: push or pull_request.' + description: 'Sync delivery method: push or pull_request.' required: false default: 'push' - sync_pr_branch: - description: 'Rolling sync branch used when sync_strategy=pull_request.' - required: false - default: 'codeboarding/sync' - sync_pr_title: - description: 'Rolling sync PR title prefix.' + target_branch: + description: 'Branch updated by sync mode. Defaults to the event branch.' required: false - default: '[CodeBoarding sync]' - commit_message: - description: 'Sync commit message.' - required: false - default: 'chore(codeboarding): sync architecture baseline' - - # deprecated compatibility no-op inputs (kept for workflow compatibility) - changed_only: - description: 'Deprecated. Accepted for compatibility only.' + default: '' + force_full: + description: 'Run a full sync analysis instead of reusing the committed baseline.' required: false default: 'false' - render_depth: - description: 'Deprecated. Accepted for compatibility only.' - required: false - default: '1' - outputs: diagram_md: - description: 'Rendered Mermaid payload (review mode).' + description: 'Path to the rendered Mermaid review diagram.' value: ${{ steps.review_render.outputs.diagram_md }} n_changed: - description: 'Change count in the review diff.' + description: 'Number of changed components in review mode.' value: ${{ steps.review_render.outputs.n_changed }} truncated: - description: 'True when review render switched to changed-only for GitHub limits.' + description: 'Whether the review graph was reduced to fit GitHub limits.' value: ${{ steps.review_render.outputs.truncated }} + review_artifact_url: + description: 'URL of the uploaded review analysis artifact.' + value: ${{ steps.upload_review_artifact_dotcom.outputs.artifact-url || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} analysis_mode: - description: 'sync mode used: incremental or full.' + description: 'Whether sync used incremental or full analysis.' value: ${{ steps.sync_analyze.outputs.analysis_mode }} files_written: - description: 'Number of markdown files written on sync.' + description: 'Number of persisted analysis artifacts produced by sync.' value: ${{ steps.sync_commit.outputs.files_written }} committed: - description: 'Whether sync delivery happened.' + description: 'Whether sync delivered a baseline commit.' value: ${{ steps.sync_commit.outputs.committed }} sync_pr_url: - description: 'Rolling sync PR URL when strategy is pull_request.' + description: 'Rolling sync PR URL when sync_strategy is pull_request.' value: ${{ steps.sync_commit.outputs.sync_pr_url }} sync_pr_number: - description: 'Rolling sync PR number when strategy is pull_request.' + description: 'Rolling sync PR number when sync_strategy is pull_request.' value: ${{ steps.sync_commit.outputs.sync_pr_number }} - review_artifact_url: - description: 'Artifact URL used by the hosted review webview.' - value: ${{ steps.upload_review_artifact.outputs.artifact-url }} runs: using: 'composite' steps: - - name: Guard + resolve mode + - name: Resolve event id: guard shell: bash env: GH_TOKEN: ${{ inputs.github_token }} + GH_ENTERPRISE_TOKEN: ${{ inputs.github_token }} + GH_HOST: ${{ github.server_url }} MODE: ${{ inputs.mode }} EVENT: ${{ github.event_name }} - REPO: ${{ github.repository }} REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} - GITHUB_SHA: ${{ github.sha }} + EVENT_SHA: ${{ github.sha }} HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} + TARGET_BRANCH_INPUT: ${{ inputs.target_branch }} SYNC_STRATEGY: ${{ inputs.sync_strategy }} - SYNC_PR_BRANCH: ${{ inputs.sync_pr_branch }} - COMMENT_BODY: ${{ github.event.comment.body }} - AUTHOR_ASSOC: ${{ github.event.comment.author_association }} - TRIGGER_COMMAND: ${{ inputs.trigger_command }} + AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} - PR_NUMBER_PULL: ${{ github.event.pull_request.number }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} PULL_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PULL_BASE_SHA: ${{ github.event.pull_request.base.sha }} PULL_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} - PULL_HEAD_REF: ${{ github.event.pull_request.head.ref }} - PULL_BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - set -euo pipefail - - fail() { echo "::error::$1"; exit 1; } - skip() { echo "::notice::$1"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } - - [ "$MODE" = "review" ] || [ "$MODE" = "sync" ] || fail "Unsupported mode '$MODE'." - echo "mode=$MODE" >> "$GITHUB_OUTPUT" - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "event=$EVENT" >> "$GITHUB_OUTPUT" - echo "sync_strategy=$SYNC_STRATEGY" >> "$GITHUB_OUTPUT" - echo "sync_pr_branch=$SYNC_PR_BRANCH" >> "$GITHUB_OUTPUT" - - if [ "$MODE" = "sync" ]; then - if [ "$EVENT" != "push" ] && [ "$EVENT" != "workflow_dispatch" ] && [ "$EVENT" != "schedule" ]; then - skip "Sync mode supports only push, workflow_dispatch, and schedule events." - fi - if [ "$EVENT" = "push" ] && [ "$REF_TYPE" = "tag" ]; then - skip "Sync mode ignores tag pushes." - fi - - case "$SYNC_STRATEGY" in - push|pull_request) ;; - *) fail "sync_strategy must be push or pull_request." ;; - esac - if [ "$SYNC_STRATEGY" = "pull_request" ] && [ "$SYNC_PR_BRANCH" = "$REF_NAME" ]; then - fail "sync_pr_branch must differ from the target branch." - fi - - case "$HEAD_AUTHOR_EMAIL" in - "" ) ;; - "codeboarding-review[bot]@users.noreply.github.com"|"codeboarding[bot]@users.noreply.github.com") - if [ "$EVENT" = "push" ]; then - skip "Skipping loop-guarded bot-owned head commit." - fi - ;; - esac - - echo "target_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" - echo "target_branch=$REF_NAME" >> "$GITHUB_OUTPUT" - echo "checkout_ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT" - echo "commit_message=${{ inputs.commit_message }}" >> "$GITHUB_OUTPUT" - echo "start_ts=$(date +%s)" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [ "$EVENT" != "pull_request" ] && [ "$EVENT" != "issue_comment" ]; then - skip "Review mode supports pull_request and issue_comment only." - fi - - if [ "$EVENT" = "issue_comment" ] && [ "$REF_TYPE" != "" ] && [ "$REF_TYPE" != "branch" ]; then - skip "Issue comment on non-PR context is not supported." - fi - - PR_NUMBER="" - BASE_SHA="" - HEAD_SHA="" - BASE_REPO="" - HEAD_REPO="" - BASE_REF="" - HEAD_REF="" - BASELINE_EXISTS="false" - - if [ "$EVENT" = "pull_request" ]; then - PR_NUMBER="$PR_NUMBER_PULL" - BASE_SHA="$PULL_BASE_SHA" - HEAD_SHA="$PULL_HEAD_SHA" - BASE_REPO="$PULL_BASE_REPO" - HEAD_REPO="$PULL_HEAD_REPO" - BASE_REF="$PULL_BASE_REF" - HEAD_REF="$PULL_HEAD_REF" - [ -n "$PR_NUMBER" ] && [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ] || skip "Could not parse pull_request payload." - else - [ -n "$COMMENT_BODY" ] || skip "Issue comment body is empty." - FIRST_WORD="$(printf '%s' "$COMMENT_BODY" | tr -d '\r' | awk 'NR==1 {print $1}' || true)" - [ -n "$TRIGGER_COMMAND" ] || fail "trigger_command must not be empty." - [ "$FIRST_WORD" = "$TRIGGER_COMMAND" ] || skip "Issue comment does not start with '$TRIGGER_COMMAND'." - case "${AUTHOR_ASSOC:-}" in - OWNER|MEMBER|COLLABORATOR|MAINTAINER) ;; - *) skip "Untrusted actor association for $TRIGGER_COMMAND trigger (${AUTHOR_ASSOC:-})." ;; - esac - [ -n "$ISSUE_PR_URL" ] || skip "Not a pull request comment." - - PR_JSON="$(gh api "$ISSUE_PR_URL")" - PR_NUMBER="$(printf '%s' "$PR_JSON" | jq -r '.number // empty')" - BASE_SHA="$(printf '%s' "$PR_JSON" | jq -r '.base.sha // empty')" - HEAD_SHA="$(printf '%s' "$PR_JSON" | jq -r '.head.sha // empty')" - BASE_REPO="$(printf '%s' "$PR_JSON" | jq -r '.base.repo.full_name // empty')" - HEAD_REPO="$(printf '%s' "$PR_JSON" | jq -r '.head.repo.full_name // empty')" - BASE_REF="$(printf '%s' "$PR_JSON" | jq -r '.base.ref // empty')" - HEAD_REF="$(printf '%s' "$PR_JSON" | jq -r '.head.ref // empty')" - [ -n "$PR_NUMBER" ] && [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ] || skip "Failed to read pull request fields." - fi - - if [ "$HEAD_REPO" != "$BASE_REPO" ]; then - FORK_BASE_SHA="$(git ls-remote "https://github.com/${HEAD_REPO}.git" "refs/heads/${BASE_REF}" | awk '{print $1; exit}')" - if [ -n "$FORK_BASE_SHA" ]; then - BASE_SHA="$FORK_BASE_SHA" - BASE_REPO="$HEAD_REPO" - else - BASE_SHA="" - fi - fi - - if [ -n "$BASE_SHA" ]; then - BASELINE_EXISTS="true" - fi - - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" - echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" - echo "base_repo=$BASE_REPO" >> "$GITHUB_OUTPUT" - echo "head_repo=$HEAD_REPO" >> "$GITHUB_OUTPUT" - echo "base_ref=$BASE_REF" >> "$GITHUB_OUTPUT" - echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT" - echo "checkout_ref=$HEAD_SHA" >> "$GITHUB_OUTPUT" - echo "baseline_exists=$BASELINE_EXISTS" >> "$GITHUB_OUTPUT" - if [ "$EVENT" = "issue_comment" ]; then - # Each slash-command invocation gets its own visible response. Steps - # within the same run still update one comment through this header. - echo "sticky_header=codeboarding-architecture-diff-run-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" - else - echo "sticky_header=codeboarding-architecture-diff" >> "$GITHUB_OUTPUT" - fi + REPOSITORY: ${{ github.repository }} + run: "$GITHUB_ACTION_PATH/scripts/action/guard.sh" - name: Acknowledge review command - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.event == 'issue_comment' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.event == 'issue_comment' + continue-on-error: true shell: bash env: GH_TOKEN: ${{ inputs.github_token }} + GH_ENTERPRISE_TOKEN: ${{ inputs.github_token }} + GITHUB_SERVER_URL: ${{ github.server_url }} REPOSITORY: ${{ github.repository }} COMMENT_ID: ${{ github.event.comment.id }} - run: | - gh api -X POST "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \ - -f content=eyes >/dev/null 2>&1 || true + run: GH_HOST="${GITHUB_SERVER_URL#*://}" gh api -X POST "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" -f content=eyes >/dev/null - - name: Post review in-progress comment - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + - name: Post review progress + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: - header: ${{ steps.guard.outputs.sticky_header }} + header: ${{ steps.guard.outputs.comment_id }} number: ${{ steps.guard.outputs.pr_number }} GITHUB_TOKEN: ${{ inputs.github_token }} message: | - ### ${{ inputs.comment_header }} · analyzing… + ### CodeBoarding review · analyzing… - ⏳ CodeBoarding is analyzing the architecture changes in this PR. This usually takes a few minutes. + ⏳ CodeBoarding is analyzing this pull request's architecture changes. run ${{ github.run_id }} · attempt ${{ github.run_attempt }} - - name: Checkout + - name: Checkout analysis target + if: steps.guard.outputs.skip != 'true' uses: actions/checkout@v4 with: + repository: ${{ steps.guard.outputs.checkout_repo }} + ref: ${{ steps.guard.outputs.checkout_ref }} token: ${{ inputs.github_token }} - ref: ${{ steps.guard.outputs.checkout_ref || github.sha }} - fetch-depth: 0 + fetch-depth: 1 persist-credentials: false - - - name: Prepare review head for issue_comment - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.event == 'issue_comment' - shell: bash - run: | - set -euo pipefail - git fetch "https://github.com/${{ steps.guard.outputs.head_repo }}.git" "${{ steps.guard.outputs.head_sha }}" --depth=1 - git checkout -f "${{ steps.guard.outputs.head_sha }}" + path: .codeboarding-target - name: Setup Python + if: steps.guard.outputs.skip != 'true' uses: actions/setup-python@v5 with: python-version: '3.12' - - name: Install CodeBoarding CLI + - name: Install CodeBoarding + if: steps.guard.outputs.skip != 'true' shell: bash - run: | - python -m pip install --disable-pip-version-check --upgrade pip - python -m pip install --disable-pip-version-check "codeboarding==${{ inputs.codeboarding_version }}" + run: python -m pip install --disable-pip-version-check 'codeboarding==0.13.5' - - name: Start OIDC relay - id: relay - if: always() && steps.guard.outputs.skip != 'true' + - name: Configure analysis authentication + if: steps.guard.outputs.skip != 'true' shell: bash env: + ACTION_PATH: ${{ github.action_path }} LLM_API_KEY: ${{ inputs.llm_api_key }} + LLM_PROVIDER: ${{ inputs.llm_provider }} LICENSE_KEY: ${{ inputs.license_key }} - ACTION_PATH: ${{ github.action_path }} - run: | - set -euo pipefail - - if [ -n "${LLM_API_KEY:-}" ]; then - echo "::add-mask::$LLM_API_KEY" - LLM_API_KEY="$(printf '%s' "$LLM_API_KEY" | tr -d '[:space:]' | sed -e 's/^"//;s/"$//' -e "s/^'//;s/'\$//")" - LLM_API_KEY="${LLM_API_KEY#OPENROUTER_API_KEY=}" - echo "::add-mask::$LLM_API_KEY" - echo "OPENROUTER_API_KEY=$LLM_API_KEY" >> "$GITHUB_ENV" - exit 0 - fi - - if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then - echo "::error::Missing OIDC token. Add permissions: id-token: write." && exit 1 - fi - - RELAY_DIR="${RUNNER_TEMP}/codeboarding-relay" - mkdir -p "$RELAY_DIR" - READY="$RELAY_DIR/ready-port" - PID="$RELAY_DIR/relay.pid" - LOG="$RELAY_DIR/relay.log" - LICENSE_FILE="$RELAY_DIR/license.txt" - : > "$LICENSE_FILE" - if [ -n "${LICENSE_KEY:-}" ]; then - printf '%s' "$LICENSE_KEY" > "$LICENSE_FILE" - fi + run: "$GITHUB_ACTION_PATH/scripts/action/configure-auth.sh" - RELAY_ARGS=(--upstream-base-url https://openrouter.ai/api/v1 --ready-file "$READY") - if [ -s "$LICENSE_FILE" ]; then - RELAY_ARGS+=(--license-file "$LICENSE_FILE") - fi - - python3 "$ACTION_PATH/scripts/oidc_relay.py" "${RELAY_ARGS[@]}" > "$LOG" 2>&1 & - echo $! > "$PID" - - for _ in {1..60}; do - [ -s "$READY" ] && break - sleep 0.25 - done - - if [ ! -s "$READY" ]; then - echo "::error::OIDC relay did not start." >&2 - cat "$LOG" >&2 || true - exit 1 - fi - - PORT="$(cat "$READY")" - echo "OPENROUTER_BASE_URL=http://127.0.0.1:$PORT" >> "$GITHUB_ENV" - echo "OPENROUTER_API_KEY=github-actions-oidc-relay" >> "$GITHUB_ENV" - echo "relay_pid=$(/bin/cat "$PID")" >> "$GITHUB_OUTPUT" - - - name: Analyze (sync) + - name: Analyze baseline id: sync_analyze - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' - shell: bash - run: | - set -euo pipefail - - parse_value() { - local key="$1" - local text="$2" - printf '%s\n' "$text" | awk -F= -v key="$key" '$1 == key {print $2; exit}' - } - - DEPTH_INPUT='${{ inputs.depth_level }}' - if [ -n "$DEPTH_INPUT" ] && ! [[ "$DEPTH_INPUT" =~ ^[0-9]+$ ]]; then - echo "::error::depth_level must be an integer." && exit 1 - fi - - ACTION_PATH="${{ github.action_path }}" - WORK="${RUNNER_TEMP}/cb-sync" - ANALYSIS_DIR="$WORK/analysis" - rm -rf "$WORK" && mkdir -p "$ANALYSIS_DIR" - - BASELINE_DEPTH="" - if [ -f "${GITHUB_WORKSPACE}/.codeboarding/analysis.json" ]; then - BASELINE_DEPTH="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1])); print(data.get("metadata", {}).get("depth_level", ""))' "${GITHUB_WORKSPACE}/.codeboarding/analysis.json" 2>/dev/null || true)" - fi - - DEPTH="${BASELINE_DEPTH:-$DEPTH_INPUT}" - DEPTH="${DEPTH:-2}" - - if [ -d "${GITHUB_WORKSPACE}/.codeboarding" ]; then - cp -a "${GITHUB_WORKSPACE}/.codeboarding/." "$ANALYSIS_DIR/" 2>/dev/null || true - fi - - run_inc() { - python3 "$ACTION_PATH/scripts/analyze_repository.py" incremental --checkout "$GITHUB_WORKSPACE" --output-dir "$ANALYSIS_DIR" - } - - run_full() { - python3 "$ACTION_PATH/scripts/analyze_repository.py" full --checkout "$GITHUB_WORKSPACE" --output-dir "$ANALYSIS_DIR" --depth-level "$DEPTH" - } - - OUT="$(run_inc)" - MODE="$(parse_value analysis_mode "$OUT")" - NEED_FULL="$(parse_value requires_full_analysis "$OUT")" - ANALYSIS_PATH="$(parse_value analysis_path "$OUT")" - - if [ -z "$MODE" ] || { [ "$NEED_FULL" != "true" ] && [ -z "$ANALYSIS_PATH" ]; } || [ "$MODE" != "incremental" ] && [ "$MODE" != "full" ]; then - echo "$OUT" - echo "::error::Could not parse incremental output contract." && exit 1 - fi - - if [ "$NEED_FULL" = "true" ]; then - rm -rf "$ANALYSIS_DIR" && mkdir -p "$ANALYSIS_DIR" - OUT="$(run_full)" - MODE="$(parse_value analysis_mode "$OUT")" - NEED_FULL="$(parse_value requires_full_analysis "$OUT")" - ANALYSIS_PATH="$(parse_value analysis_path "$OUT")" - if [ -z "$MODE" ] || [ -z "$ANALYSIS_PATH" ] || [ "$MODE" != "full" ]; then - echo "$OUT" - echo "::error::Could not parse full output contract." && exit 1 - fi - fi - - echo "analysis_mode=$MODE" >> "$GITHUB_OUTPUT" - echo "analysis_path=$ANALYSIS_PATH" >> "$GITHUB_OUTPUT" - echo "analysis_depth=$DEPTH" >> "$GITHUB_OUTPUT" - echo "analysis_dir=$ANALYSIS_DIR" >> "$GITHUB_OUTPUT" - - - name: Render sync docs - id: sync_render - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' shell: bash - run: | - set -euo pipefail - ACTION_PATH="${{ github.action_path }}" - DOC_DIR="${RUNNER_TEMP}/cb-sync-docs" - rm -rf "$DOC_DIR" - python3 "$ACTION_PATH/scripts/render_sync_docs.py" \ - --analysis "${{ steps.sync_analyze.outputs.analysis_path }}" \ - --output-dir "$DOC_DIR" \ - --repo-name "${{ github.repository }}" \ - --repo-ref "${{ steps.guard.outputs.target_sha }}" \ - --format ".md" \ - --architecture-file "${GITHUB_WORKSPACE}/docs/development/architecture.md" - echo "docs_dir=$DOC_DIR" >> "$GITHUB_OUTPUT" - - - name: Commit sync artifacts + env: + ACTION_PATH: ${{ github.action_path }} + ANALYSIS_KIND: sync + CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target + FORCE_FULL: ${{ inputs.force_full }} + 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"' + + - name: Deliver baseline id: sync_commit - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' shell: bash env: ACTION_PATH: ${{ github.action_path }} - ANALYSIS_PATH: ${{ steps.sync_analyze.outputs.analysis_path }} ANALYSIS_DIR: ${{ steps.sync_analyze.outputs.analysis_dir }} - DOC_DIR: ${{ steps.sync_render.outputs.docs_dir }} + CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target TARGET_BRANCH: ${{ steps.guard.outputs.target_branch }} - STRATEGY: ${{ steps.guard.outputs.sync_strategy }} - SYNC_PR_BRANCH: ${{ inputs.sync_pr_branch }} - TARGET_SHA: ${{ steps.guard.outputs.target_sha }} - COMMIT_MESSAGE: ${{ steps.guard.outputs.commit_message }} - run: | - set -euo pipefail - - OUTPUT_DIR="${GITHUB_WORKSPACE}/.codeboarding" - GENERATED_PATHS="${RUNNER_TEMP}/cb-sync-generated-paths" - push_token="${{ inputs.push_token }}" - REPO="${{ github.repository }}" - - python3 "$ACTION_PATH/scripts/install_sync_artifacts.py" \ - --output-dir "$OUTPUT_DIR" \ - --docs-dir "$DOC_DIR" \ - --analysis "$ANALYSIS_PATH" \ - --analysis-dir "$ANALYSIS_DIR" \ - > "$GENERATED_PATHS" - - git config user.name "codeboarding-review[bot]" - git config user.email "codeboarding-review[bot]@users.noreply.github.com" - STAGE_PATHS=() - while IFS= read -r path; do - if [ -e "$path" ] || git ls-files --error-unmatch "$path" >/dev/null 2>&1; then - STAGE_PATHS+=("$path") - fi - done < "$GENERATED_PATHS" - if [ "${#STAGE_PATHS[@]}" -eq 0 ]; then - echo "::error::No generated sync artifacts were installed." - exit 1 - fi - git add -f -A -- "${STAGE_PATHS[@]}" - - if git diff --cached --quiet; then - echo "::notice::Generated architecture is unchanged." - echo "files_written=0" >> "$GITHUB_OUTPUT" - echo "committed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if git diff --cached --quiet -I '"generated_at"' -I '"timestamp"'; then - git reset -q - echo "::notice::Only timestamp-only changes were detected." - echo "files_written=0" >> "$GITHUB_OUTPUT" - echo "committed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - shopt -s nullglob - FILES=("$OUTPUT_DIR"/*.md) - N_FILES="${#FILES[@]}" - - if [ "$STRATEGY" = "pull_request" ]; then - AUTH_URL="https://x-access-token:${push_token}@github.com/${REPO}.git" - if ! git fetch "$AUTH_URL" "$TARGET_BRANCH"; then - echo "::warning::Could not fetch $TARGET_BRANCH." - echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" - echo "committed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - BASE_TIP="$(git rev-parse FETCH_HEAD)" - if [ -n "$TARGET_SHA" ] && [ "$BASE_TIP" != "$TARGET_SHA" ] && git merge-base --is-ancestor "$TARGET_SHA" "$BASE_TIP" 2>/dev/null; then - echo "::notice::Target branch advanced; skipping to newer run." - echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" - echo "committed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - SYNC_TIP="$(git ls-remote "$AUTH_URL" "refs/heads/${SYNC_PR_BRANCH}" | awk '{print $1; exit}')" - git checkout -B "$SYNC_PR_BRANCH" FETCH_HEAD - - if [ -n "$SYNC_TIP" ]; then - PUSH_ARGS=(--force-with-lease="refs/heads/${SYNC_PR_BRANCH}:${SYNC_TIP}") - else - PUSH_ARGS=() - fi - - git commit -m "$COMMIT_MESSAGE" >/dev/null - if ! git push ${PUSH_ARGS[@]+"${PUSH_ARGS[@]}"} "$AUTH_URL" "HEAD:refs/heads/${SYNC_PR_BRANCH}"; then - echo "::warning::Could not update ${SYNC_PR_BRANCH}." - echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" - echo "committed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - PR_TITLE="${{ inputs.sync_pr_title }} $(git rev-parse --short "$TARGET_SHA")" - PR_URL="$(gh pr list --repo "$REPO" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --state open --json url --jq '.[0].url // empty' || true)" - if [ -z "$PR_URL" ]; then - gh pr create --repo "$REPO" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --title "$PR_TITLE" --body "CodeBoarding sync PR for ${TARGET_BRANCH}." >/dev/null 2>&1 || true - PR_URL="$(gh pr list --repo "$REPO" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --state open --json url --jq '.[0].url // empty' || true)" - fi - PR_NUM="$(gh pr list --repo "$REPO" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --state open --json number --jq '.[0].number // empty' || true)" - - echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" - echo "committed=true" >> "$GITHUB_OUTPUT" - [ -n "$PR_URL" ] && echo "sync_pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - [ -n "$PR_NUM" ] && echo "sync_pr_number=$PR_NUM" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git commit -m "$COMMIT_MESSAGE" >/dev/null - AUTH_URL="https://x-access-token:${push_token}@github.com/${REPO}.git" - - for attempt in 1 2 3; do - if git push "$AUTH_URL" "HEAD:refs/heads/${TARGET_BRANCH}"; then - echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" - echo "committed=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "$attempt" -eq 3 ]; then - echo "::warning::Could not push sync artifacts to ${TARGET_BRANCH}." - echo "files_written=$N_FILES" >> "$GITHUB_OUTPUT" - echo "committed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - git fetch "$AUTH_URL" "$TARGET_BRANCH" - git rebase --ff-only FETCH_HEAD || true - done + SYNC_BRANCH_START_SHA: ${{ steps.guard.outputs.sync_branch_start_sha }} + SYNC_STRATEGY: ${{ inputs.sync_strategy }} + 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" - name: Write sync summary if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' shell: bash env: - START_TS: ${{ steps.guard.outputs.start_ts }} MODE: ${{ steps.sync_analyze.outputs.analysis_mode }} - DEPTH: ${{ steps.sync_analyze.outputs.analysis_depth }} COMMITTED: ${{ steps.sync_commit.outputs.committed }} FILES: ${{ steps.sync_commit.outputs.files_written }} - STRATEGY: ${{ steps.guard.outputs.sync_strategy }} + STRATEGY: ${{ inputs.sync_strategy }} PR_URL: ${{ steps.sync_commit.outputs.sync_pr_url }} - run: | - set -euo pipefail - NOW=$(date +%s) - DUR=0 - if [ -n "${START_TS:-}" ]; then DUR=$((NOW - START_TS)); fi - { - echo "### CodeBoarding Sync" - echo "- Analysis: ${MODE}" - echo "- Depth: ${DEPTH}" - echo "- Rendered markdown files: ${FILES:-0}" - echo "- Delivered: ${COMMITTED:-false}" - echo "- Duration: ${DUR}s" - echo "- Strategy: ${STRATEGY}" - if [ -n "${PR_URL:-}" ]; then - echo "- Sync PR: ${PR_URL}" - fi - } >> "$GITHUB_STEP_SUMMARY" + run: "$GITHUB_ACTION_PATH/scripts/action/sync-summary.sh" - - name: Analyze (review with fallback) + - name: Analyze pull request id: review_analyze - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' shell: bash - run: | - set -euo pipefail - - parse_value() { - local key="$1" - local text="$2" - printf '%s\n' "$text" | awk -F= -v key="$key" '$1 == key {print $2; exit}' - } - - ACTION_PATH="${{ github.action_path }}" - BASE_SHA="${{ steps.guard.outputs.base_sha }}" - HEAD_SHA="${{ steps.guard.outputs.head_sha }}" - BASE_REPO="${{ steps.guard.outputs.base_repo }}" - HEAD_REPO="${{ steps.guard.outputs.head_repo }}" - BASELINE_EXISTS="${{ steps.guard.outputs.baseline_exists }}" - DEPTH_INPUT='${{ inputs.depth_level }}' - - if [ -n "$DEPTH_INPUT" ] && ! [[ "$DEPTH_INPUT" =~ ^[0-9]+$ ]]; then - echo "::error::depth_level must be an integer." && exit 1 - fi - - WORK="${RUNNER_TEMP}/cb-review" - BASE_DIR="$WORK/base" - HEAD_DIR="$WORK/head" - mkdir -p "$BASE_DIR" "$HEAD_DIR" - - ensure_commit() { - local repo="$1" - local sha="$2" - git cat-file -e "$sha^{commit}" 2>/dev/null && return 0 - git fetch "https://github.com/${repo}.git" "$sha" --depth=1 - } - - if [ -n "$BASE_SHA" ]; then - ensure_commit "$BASE_REPO" "$BASE_SHA" - git worktree add --detach "$BASE_DIR" "$BASE_SHA" >/dev/null - fi - ensure_commit "${{ github.repository }}" "$HEAD_SHA" || true - ensure_commit "$HEAD_REPO" "$HEAD_SHA" - - BASELINE_DEPTH="" - if [ -n "$BASE_SHA" ] && [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then - BASELINE_DEPTH="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1])); print(data.get("metadata", {}).get("depth_level", ""))' "$BASE_DIR/.codeboarding/analysis.json" 2>/dev/null || true)" - fi - - DEPTH="${BASELINE_DEPTH:-$DEPTH_INPUT}" - DEPTH="${DEPTH:-2}" - - if [ -n "$BASE_SHA" ] && [ -d "$BASE_DIR/.codeboarding" ]; then - cp -a "$BASE_DIR/.codeboarding/." "$HEAD_DIR/" - fi - - run_incremental() { - python3 "$ACTION_PATH/scripts/analyze_repository.py" incremental --checkout "$GITHUB_WORKSPACE" --output-dir "$HEAD_DIR" - } - - run_full() { - local checkout="$1" - local out="$2" - python3 "$ACTION_PATH/scripts/analyze_repository.py" full --checkout "$checkout" --output-dir "$out" --depth-level "$DEPTH" - } - - BASE_OUT="$WORK/base-full" - HAS_BASELINE="false" - if [ -n "$BASE_SHA" ] && [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then - HAS_BASELINE="true" - fi - - OUT="$(run_incremental)" - MODE="$(parse_value analysis_mode "$OUT")" - NEED_FULL="$(parse_value requires_full_analysis "$OUT")" - HEAD_PATH="$(parse_value analysis_path "$OUT")" - - if [ -z "$MODE" ] || { [ "$NEED_FULL" != "true" ] && [ -z "$HEAD_PATH" ]; } || [ "$MODE" != "incremental" ] && [ "$MODE" != "full" ]; then - echo "$OUT" - echo "::error::Could not parse incremental output contract." && exit 1 - fi - - if [ "$HAS_BASELINE" = "false" ] || [ "$NEED_FULL" = "true" ]; then - BASE_FULL_PATH="" - if [ -n "$BASE_SHA" ]; then - BASE_FULL="$(run_full "$BASE_DIR" "$BASE_OUT")" - BASE_FULL_PATH="$(parse_value analysis_path "$BASE_FULL")" - if [ -z "$BASE_FULL_PATH" ] || [ ! -f "$BASE_FULL_PATH" ]; then - echo "$BASE_FULL" - echo "::error::Base full analysis did not produce analysis_path." - exit 1 - fi - - rm -rf "$HEAD_DIR" && mkdir -p "$HEAD_DIR" - cp -a "$BASE_OUT/." "$HEAD_DIR/" - OUT="$(run_incremental)" - MODE="$(parse_value analysis_mode "$OUT")" - NEED_FULL="$(parse_value requires_full_analysis "$OUT")" - HEAD_PATH="$(parse_value analysis_path "$OUT")" - if [ -z "$MODE" ] || { [ "$NEED_FULL" != "true" ] && [ -z "$HEAD_PATH" ]; }; then - echo "$OUT" - echo "::error::Could not parse head retry output contract." && exit 1 - fi - - if [ "$NEED_FULL" = "true" ]; then - FULL_HEAD_OUT="$(run_full "$GITHUB_WORKSPACE" "$WORK/head-full")" - MODE="$(parse_value analysis_mode "$FULL_HEAD_OUT")" - HEAD_PATH="$(parse_value analysis_path "$FULL_HEAD_OUT")" - if [ -z "$MODE" ] || [ -z "$HEAD_PATH" ] || [ "$MODE" != "full" ]; then - echo "$FULL_HEAD_OUT" - echo "::error::Could not parse head full output contract." && exit 1 - fi - fi - fi - fi - - BASE_FOR_DIFF="" - if [ "$HAS_BASELINE" = "true" ]; then - BASE_FOR_DIFF="$BASE_DIR/.codeboarding/analysis.json" - elif [ -n "$BASE_FULL_PATH" ] && [ -f "$BASE_FULL_PATH" ]; then - BASE_FOR_DIFF="$BASE_FULL_PATH" - fi - - if [ -n "${BASE_FOR_DIFF:-}" ] && [ -f "$BASE_FOR_DIFF" ]; then - : - elif [ -n "$BASE_SHA" ] && [ ! -f "$BASE_FOR_DIFF" ]; then - echo "::error::Review baseline still unavailable after fallback." - exit 1 - fi - - echo "analysis_mode=$MODE" >> "$GITHUB_OUTPUT" - echo "analysis_path=$HEAD_PATH" >> "$GITHUB_OUTPUT" - echo "analysis_depth=$DEPTH" >> "$GITHUB_OUTPUT" - echo "base_analysis_path=$BASE_FOR_DIFF" >> "$GITHUB_OUTPUT" + env: + ACTION_PATH: ${{ github.action_path }} + ANALYSIS_KIND: review + CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target + REVIEW_BASE_SHA: ${{ steps.guard.outputs.base_sha }} + REVIEW_HEAD_SHA: ${{ steps.guard.outputs.head_sha }} + REVIEW_BASE_REPO: ${{ steps.guard.outputs.base_repo }} + GIT_TOKEN: ${{ inputs.github_token }} + GITHUB_SERVER_URL: ${{ github.server_url }} + 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"' - name: Render review diagram id: review_render - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' shell: bash - run: | - set -euo pipefail - ACTION_PATH="${{ github.action_path }}" - BASE_ANALYSIS="${{ steps.review_analyze.outputs.base_analysis_path }}" - HEAD_ANALYSIS="${{ steps.review_analyze.outputs.analysis_path }}" - - if [ -z "$BASE_ANALYSIS" ] || [ ! -f "$BASE_ANALYSIS" ]; then - echo "::error::Review baseline missing." - exit 1 - fi - if [ -z "$HEAD_ANALYSIS" ] || [ ! -f "$HEAD_ANALYSIS" ]; then - echo "::error::Review head analysis missing." - exit 1 - fi - - DIAGRAM_OUT="${RUNNER_TEMP}/diagram.md" - META="${RUNNER_TEMP}/diagram_meta.json" - DIFF="$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" --out "$DIAGRAM_OUT" --direction "${{ inputs.diagram_direction }}" --render-depth 1)" - printf '%s' "$DIFF" > "$META" - - N_CHANGED="$(jq -r '.n_changed' "$META")" - TRUNCATED="$(jq -r '.truncated' "$META")" - echo "diagram_md=$DIAGRAM_OUT" >> "$GITHUB_OUTPUT" - echo "n_changed=$N_CHANGED" >> "$GITHUB_OUTPUT" - echo "truncated=$TRUNCATED" >> "$GITHUB_OUTPUT" - echo "rendered=true" >> "$GITHUB_OUTPUT" + env: + ACTION_PATH: ${{ github.action_path }} + BASE_ANALYSIS_PATH: ${{ steps.review_analyze.outputs.base_analysis_path }} + HEAD_ANALYSIS_PATH: ${{ steps.review_analyze.outputs.analysis_path }} + run: "$GITHUB_ACTION_PATH/scripts/action/render-review.sh" - name: Build review artifact - id: review_artifact_inputs - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + id: review_artifact + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' shell: bash - run: | - set -euo pipefail - mkdir -p "${RUNNER_TEMP}/cb-review-artifact" - cp "${{ steps.review_analyze.outputs.analysis_path }}" "${RUNNER_TEMP}/cb-review-artifact/analysis.json" - jq -n \ - --arg mode "${{ steps.review_analyze.outputs.analysis_mode }}" \ - --arg analysis_depth "${{ steps.review_analyze.outputs.analysis_depth }}" \ - --arg base_sha "${{ steps.guard.outputs.base_sha }}" \ - --arg head_sha "${{ steps.guard.outputs.head_sha }}" \ - --arg pr_number "${{ steps.guard.outputs.pr_number }}" \ - '{mode: $mode, analysis_depth: $analysis_depth, base_sha: $base_sha, head_sha: $head_sha, pr_number: $pr_number}' \ - > "${RUNNER_TEMP}/cb-review-artifact/metadata.json" - echo "artifact_dir=${RUNNER_TEMP}/cb-review-artifact" >> "$GITHUB_OUTPUT" + env: + ANALYSIS_PATH: ${{ steps.review_analyze.outputs.analysis_path }} + ANALYSIS_MODE: ${{ steps.review_analyze.outputs.analysis_mode }} + BASE_SHA: ${{ steps.guard.outputs.base_sha }} + HEAD_SHA: ${{ steps.guard.outputs.head_sha }} + PR_NUMBER: ${{ steps.guard.outputs.pr_number }} + run: "$GITHUB_ACTION_PATH/scripts/action/build-review-artifact.sh" - name: Upload review artifact - id: upload_review_artifact - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outputs.rendered == 'true' + id: upload_review_artifact_dotcom + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && github.server_url == 'https://github.com' uses: actions/upload-artifact@v4 with: name: codeboarding-review-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ steps.review_artifact_inputs.outputs.artifact_dir }} + path: ${{ steps.review_artifact.outputs.artifact_dir }} + if-no-files-found: error + + - name: Upload review artifact (GitHub Enterprise Server) + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && github.server_url != 'https://github.com' + uses: actions/upload-artifact@v3.2.2-node20 + with: + name: codeboarding-review-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.review_artifact.outputs.artifact_dir }} if-no-files-found: error - name: Build review comment id: review_body - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outputs.rendered == 'true' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' shell: bash env: - HEADER: ${{ inputs.comment_header }} DIAGRAM: ${{ steps.review_render.outputs.diagram_md }} N_CHANGED: ${{ steps.review_render.outputs.n_changed }} - ARTIFACT_URL: ${{ steps.upload_review_artifact.outputs.artifact-url }} - WEBVIEW_BASE_URL: ${{ inputs.webview_base_url }} + ARTIFACT_URL: ${{ steps.upload_review_artifact_dotcom.outputs.artifact-url || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} PR_NUMBER: ${{ steps.guard.outputs.pr_number }} - run: | - COMPONENT_NOUN="components" - if [ "$N_CHANGED" = "1" ]; then - COMPONENT_NOUN="component" - fi - - RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - WEBVIEW_URL="" - if [ -n "$WEBVIEW_BASE_URL" ]; then - WEBVIEW_URL="${WEBVIEW_BASE_URL%/}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}?run=${GITHUB_RUN_ID}" - fi - - BODY="${RUNNER_TEMP}/review-comment.md" - printf '### %s\n\n**Status:** %s changed %s\n' "$HEADER" "$N_CHANGED" "$COMPONENT_NOUN" > "$BODY" - if [ -n "$WEBVIEW_URL" ]; then - printf '\nSee the full change in [CodeBoarding](%s).\n' "$WEBVIEW_URL" >> "$BODY" - fi - printf '\n' >> "$BODY" - cat "$DIAGRAM" >> "$BODY" - printf '\n\n' >> "$BODY" - if [ -n "$ARTIFACT_URL" ]; then - printf '[download artifacts](%s) · ' "$ARTIFACT_URL" >> "$BODY" - fi - printf 'run [%s](%s)\n' "$GITHUB_RUN_ID" "$RUN_URL" >> "$BODY" - echo "path=$BODY" >> "$GITHUB_OUTPUT" + run: "$GITHUB_ACTION_PATH/scripts/action/build-review-comment.sh" - name: Post review comment - if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outputs.rendered == 'true' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' uses: marocchino/sticky-pull-request-comment@v2 with: - header: ${{ steps.guard.outputs.sticky_header }} + header: ${{ steps.guard.outputs.comment_id }} number: ${{ steps.guard.outputs.pr_number }} GITHUB_TOKEN: ${{ inputs.github_token }} path: ${{ steps.review_body.outputs.path }} - - name: Post review failure comment - if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_render.outcome != 'success' + - name: Post review failure + if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_body.outcome != 'success' continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: - header: ${{ steps.guard.outputs.sticky_header }} + header: ${{ steps.guard.outputs.comment_id }} number: ${{ steps.guard.outputs.pr_number }} GITHUB_TOKEN: ${{ inputs.github_token }} message: | - ### ${{ inputs.comment_header }} · failed - - The review run could not generate a diagram. See workflow logs: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ### CodeBoarding review · failed - run ${{ github.run_id }} - - - name: Stop OIDC relay and remove relay state - if: always() && steps.relay.outputs.relay_pid != '' - shell: bash - run: | - set -euo pipefail - PID="${{ steps.relay.outputs.relay_pid }}" - [ -n "$PID" ] && kill "$PID" 2>/dev/null || true - rm -rf "${RUNNER_TEMP}/codeboarding-relay" + See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh new file mode 100755 index 0000000..4f4ab6e --- /dev/null +++ b/scripts/action/analyze.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail +parse_output() { + local output="$1" + 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")" +} +incremental() { + local checkout="$1" output_dir="$2" output + output="$(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 + echo "::error::Invalid incremental-analysis result." + exit 1 + fi +} +full() { + local checkout="$1" output_dir="$2" depth="$3" output + output="$(python3 "$ACTION_PATH/scripts/analyze_repository.py" full \ + --checkout "$checkout" --output-dir "$output_dir" --depth-level "$depth")" + parse_output "$output" + if [ "$ANALYSIS_MODE" != full ] || [ ! -f "$ANALYSIS_PATH" ]; then + echo "::error::Invalid full-analysis result." + exit 1 + fi +} +depth_from() { + local analysis="$1" + [ -f "$analysis" ] || return 0 + python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("metadata", {}).get("depth_level", ""))' \ + "$analysis" 2>/dev/null || true +} + +seed_state() { + local checkout="$1" state="$2" + mkdir -p "$state" + [ ! -d "$checkout/.codeboarding" ] || cp -a "$checkout/.codeboarding/." "$state/" +} + +analyze_sync() { + local work="$RUNNER_TEMP/codeboarding-sync" state="$RUNNER_TEMP/codeboarding-sync/analysis" + rm -rf "$work" + seed_state "$CHECKOUT_DIR" "$state" + local depth + depth="$(depth_from "$state/analysis.json")" + depth="${depth:-2}" + + if [ "${FORCE_FULL,,}" = true ]; then + full "$CHECKOUT_DIR" "$state" "$depth" + else + incremental "$CHECKOUT_DIR" "$state" + if [ "$REQUIRES_FULL" = true ]; then + full "$CHECKOUT_DIR" "$state" "$depth" + fi + fi + printf 'analysis_mode=%s\nanalysis_path=%s\nanalysis_dir=%s\n' \ + "$ANALYSIS_MODE" "$ANALYSIS_PATH" "$state" >> "$GITHUB_OUTPUT" +} + +fetch_commit() { + local repository="$1" sha="$2" + git -C "$CHECKOUT_DIR" cat-file -e "$sha^{commit}" 2>/dev/null && return 0 + 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=1 +} + +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" + fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" + unset GIT_TOKEN + git -C "$CHECKOUT_DIR" worktree add --detach "$base_checkout" "$REVIEW_BASE_SHA" >/dev/null + seed_state "$base_checkout" "$base_state" + + local depth + depth="$(depth_from "$base_state/analysis.json")" + depth="${depth:-2}" + incremental "$base_checkout" "$base_state" + if [ "$REQUIRES_FULL" = true ]; then + full "$base_checkout" "$base_state" "$depth" + fi + local base_analysis="$base_state/analysis.json" + cp -a "$base_state" "$head_state" + incremental "$CHECKOUT_DIR" "$head_state" + + if [ "$REQUIRES_FULL" = true ]; then + full "$CHECKOUT_DIR" "$head_state" "$depth" + fi + + printf 'analysis_mode=%s\nanalysis_path=%s\nbase_analysis_path=%s\n' \ + "$ANALYSIS_MODE" "$ANALYSIS_PATH" "$base_analysis" >> "$GITHUB_OUTPUT" +} + +case "$ANALYSIS_KIND" in + sync) analyze_sync ;; + review) analyze_review ;; + *) echo "::error::Unknown analysis kind: $ANALYSIS_KIND"; exit 1 ;; +esac diff --git a/scripts/action/build-review-artifact.sh b/scripts/action/build-review-artifact.sh new file mode 100755 index 0000000..d487987 --- /dev/null +++ b/scripts/action/build-review-artifact.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +mkdir -p "${RUNNER_TEMP}/cb-review-artifact" +cp "$ANALYSIS_PATH" "${RUNNER_TEMP}/cb-review-artifact/analysis.json" +jq -n \ + --arg mode "$ANALYSIS_MODE" \ + --arg base_sha "$BASE_SHA" \ + --arg head_sha "$HEAD_SHA" \ + --arg pr_number "$PR_NUMBER" \ + '{mode: $mode, base_sha: $base_sha, head_sha: $head_sha, pr_number: $pr_number}' \ + > "${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 new file mode 100755 index 0000000..2564064 --- /dev/null +++ b/scripts/action/build-review-comment.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +COMPONENT_NOUN="components" +if [ "$N_CHANGED" = "1" ]; then + COMPONENT_NOUN="component" +fi + +RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" +WEBVIEW_URL="https://app.codeboarding.org/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}?run=${GITHUB_RUN_ID}" + +BODY="${RUNNER_TEMP}/review-comment.md" +printf '### CodeBoarding review\n\n**Status:** %s changed %s\n' "$N_CHANGED" "$COMPONENT_NOUN" > "$BODY" +printf '\nSee the full change in [CodeBoarding](%s).\n' "$WEBVIEW_URL" >> "$BODY" +{ + printf '\n' + cat "$DIAGRAM" + printf '\n\n' + if [ -n "$ARTIFACT_URL" ]; then + printf '[download artifacts](%s) · ' "$ARTIFACT_URL" + fi + printf 'run [%s](%s)\n' "$GITHUB_RUN_ID" "$RUN_URL" +} >> "$BODY" +echo "path=$BODY" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/configure-auth.sh b/scripts/action/configure-auth.sh new file mode 100755 index 0000000..95ac47a --- /dev/null +++ b/scripts/action/configure-auth.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail +AUTH_DIR="${RUNNER_TEMP}/codeboarding-auth" +HOSTED_PROXY_URL="https://auduihjmm4b735zci7vyabuikq0hppqn.lambda-url.us-east-1.on.aws" +umask 077 +rm -rf "$AUTH_DIR" +mkdir -p "$AUTH_DIR" +provider="$(printf '%s' "${LLM_PROVIDER:-openrouter}" | tr '[:upper:]-' '[:lower:]_' | tr -cd 'a-z0-9_')" +[ -n "$provider" ] || { echo "::error::llm_provider is empty."; exit 1; } +printf '%s' "$provider" > "$AUTH_DIR/provider-name" +if [ -n "${LLM_API_KEY:-}" ] || [ "$provider" != openrouter ]; then + case "$provider" in + aws|aws_bedrock) provider_env="AWS_BEARER_TOKEN_BEDROCK" ;; + *) provider_env="$(printf '%s' "$provider" | tr '[:lower:]' '[:upper:]')_API_KEY" ;; + esac + printf '%s' "$provider_env" > "$AUTH_DIR/provider-env" + if [ -n "${LLM_API_KEY:-}" ]; then + echo "::add-mask::$LLM_API_KEY" + printf '%s' "$LLM_API_KEY" > "$AUTH_DIR/provider-key" + fi + echo "Using direct $provider credentials." + exit 0 +fi +if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "::error::Missing OIDC token. Add permissions: id-token: write." && exit 1 +fi + +READY="$AUTH_DIR/ready-port" +PID="$AUTH_DIR/relay.pid" +LOG="$AUTH_DIR/relay.log" +LICENSE_FILE="$AUTH_DIR/license.txt" +: > "$LICENSE_FILE" +if [ -n "${LICENSE_KEY:-}" ]; then + echo "::add-mask::$LICENSE_KEY" + printf '%s' "$LICENSE_KEY" > "$LICENSE_FILE" +fi + +RELAY_ARGS=(--upstream-base-url "$HOSTED_PROXY_URL" --ready-file "$READY") +if [ -s "$LICENSE_FILE" ]; then + RELAY_ARGS+=(--license-file "$LICENSE_FILE") +fi + +python3 "$ACTION_PATH/scripts/oidc_relay.py" "${RELAY_ARGS[@]}" > "$LOG" 2>&1 & +echo $! > "$PID" + +for _ in {1..60}; do + [ -s "$READY" ] && break + sleep 0.25 +done + +if [ ! -s "$READY" ]; then + echo "::error::OIDC relay did not start." >&2 + cat "$LOG" >&2 || true + exit 1 +fi + +PORT="$(cat "$READY")" +printf '%s' "OPENROUTER_API_KEY" > "$AUTH_DIR/provider-env" +printf '%s' "github-actions-oidc-relay" > "$AUTH_DIR/provider-key" +printf '%s' "http://127.0.0.1:$PORT" > "$AUTH_DIR/base-url" diff --git a/scripts/action/deliver-sync.sh b/scripts/action/deliver-sync.sh new file mode 100755 index 0000000..68f39d3 --- /dev/null +++ b/scripts/action/deliver-sync.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +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" +BASE_SHA="$(git rev-parse HEAD)" +cat > "$ASKPASS" <<'SH' +#!/bin/sh +case "$1" in + *Username*) echo x-access-token ;; + *Password*) printf '%s\n' "$GITHUB_TOKEN" ;; +esac +SH +chmod 700 "$ASKPASS" +export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 +export GH_HOST="${GH_HOST#*://}" +trap 'rm -f "$ASKPASS"' EXIT +close_stale_pr() { + [ "$SYNC_STRATEGY" = pull_request ] || return 0 + git fetch "$REMOTE" "$TARGET_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 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}')" + [ "$current" != "$(git rev-parse HEAD)" ] || return 0 + if [ "$current" != "$expected" ]; then + printf 'files_written=%s\ncommitted=false\n' "$files_written" >> "$GITHUB_OUTPUT" + 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 +} + +"$ACTION_PATH/scripts/action/install-sync.sh" > "$GENERATED_PATHS" +stage_paths=() +while IFS= read -r path; do + if [ -e "$path" ] || git ls-files --error-unmatch "$path" >/dev/null 2>&1; then + stage_paths+=("$path") + fi +done < "$GENERATED_PATHS" +git config user.name 'codeboarding-review[bot]' +git config user.email 'codeboarding-review[bot]@users.noreply.github.com' +git add -f -A -- "${stage_paths[@]}" + +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" +remote_sha="$(git rev-parse FETCH_HEAD)" +if [ "$remote_sha" != "$BASE_SHA" ]; then + printf 'files_written=%s\ncommitted=false\n' "$files_written" >> "$GITHUB_OUTPUT" + echo "::notice::$TARGET_BRANCH advanced during analysis; a newer run should update its baseline." + exit 0 +fi + +if git diff --cached --quiet || git diff --cached --quiet -I '"generated_at"' -I '"timestamp"'; then + git reset -q + close_stale_pr + printf 'files_written=%s\ncommitted=false\n' "$files_written" >> "$GITHUB_OUTPUT" + echo "::notice::The CodeBoarding baseline is unchanged." + exit 0 +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" + fi + printf 'files_written=%s\ncommitted=true\n' "$files_written" >> "$GITHUB_OUTPUT" + 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")" != "$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." + 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" +fi + +if [ -z "$pr_json" ]; then + gh pr create --repo "$REPOSITORY" --base "$TARGET_BRANCH" --head "$SYNC_BRANCH" \ + --title 'chore(codeboarding): sync analysis baseline' \ + --body "Updates the versioned CodeBoarding analysis for \`$TARGET_BRANCH\`." + pr_json="$(gh api --method GET "repos/$REPOSITORY/pulls" \ + -f state=open -f base="$TARGET_BRANCH" \ + -f head="${REPOSITORY%%/*}:$SYNC_BRANCH" --jq '.[0]')" +fi + +pr_url="$(jq -r .html_url <<< "$pr_json")" +pr_number="$(jq -r .number <<< "$pr_json")" +{ + echo "files_written=$files_written" + echo "committed=true" + echo "sync_pr_url=$pr_url" + echo "sync_pr_number=$pr_number" +} >> "$GITHUB_OUTPUT" diff --git a/scripts/action/guard.sh b/scripts/action/guard.sh new file mode 100755 index 0000000..11135f8 --- /dev/null +++ b/scripts/action/guard.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail +fail() { echo "::error::$1"; exit 1; } +skip() { echo "::notice::$1"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } +case "$MODE" in + review|sync) ;; + *) fail "mode must be review or sync." ;; +esac +printf 'mode=%s\nskip=false\nevent=%s\n' "$MODE" "$EVENT" >> "$GITHUB_OUTPUT" +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 "$SYNC_STRATEGY" in + push|pull_request) ;; + *) fail "sync_strategy must be push or pull_request." ;; + 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." + sync_branch_start_sha="" + if [ "$SYNC_STRATEGY" = pull_request ]; then + export GH_HOST="${GH_HOST#*://}" + 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 "sync_branch_start_sha=$sync_branch_start_sha" + echo "checkout_repo=$REPOSITORY" + echo "checkout_ref=$target_branch" + } >> "$GITHUB_OUTPUT" + exit 0 +fi + +case "$EVENT" in + pull_request) + pr_number="$EVENT_PR_NUMBER" + base_sha="$PULL_BASE_SHA" + head_sha="$PULL_HEAD_SHA" + base_repo="$PULL_BASE_REPO" + head_repo="$PULL_HEAD_REPO" + ;; + issue_comment) + first_word="$(printf '%s' "$COMMENT_BODY" | tr -d '\r' | awk 'NR == 1 {print $1}')" + [ "$first_word" = /codeboarding ] || skip "Comment is not a /codeboarding command." + case "$AUTHOR_ASSOCIATION" in + OWNER|MEMBER|COLLABORATOR) ;; + *) skip "Only trusted collaborators may run /codeboarding." ;; + esac + [ -n "$ISSUE_PR_URL" ] || skip "The command was not posted on a pull request." + export GH_HOST="${GH_HOST#*://}" + pr_json="$(gh api "$ISSUE_PR_URL")" + pr_number="$(jq -r '.number // empty' <<< "$pr_json")" + base_sha="$(jq -r '.base.sha // empty' <<< "$pr_json")" + head_sha="$(jq -r '.head.sha // empty' <<< "$pr_json")" + base_repo="$(jq -r '.base.repo.full_name // empty' <<< "$pr_json")" + head_repo="$(jq -r '.head.repo.full_name // empty' <<< "$pr_json")" + ;; + *) skip "Review mode ignores $EVENT events." ;; +esac + +if [ -z "$pr_number" ] || [ -z "$base_sha" ] || [ -z "$head_sha" ] || [ -z "$base_repo" ] || [ -z "$head_repo" ]; then + fail "Could not resolve the pull request base and head." +fi +[ "$head_repo" = "$base_repo" ] || skip "Fork pull requests are not analyzed with repository credentials." + +comment_id=codeboarding-review +[ "$EVENT" != issue_comment ] || comment_id="codeboarding-review-${GITHUB_RUN_ID}" +{ + echo "pr_number=$pr_number" + echo "base_sha=$base_sha" + echo "head_sha=$head_sha" + echo "base_repo=$base_repo" + echo "head_repo=$head_repo" + echo "checkout_repo=$head_repo" + echo "checkout_ref=$head_sha" + echo "comment_id=$comment_id" +} >> "$GITHUB_OUTPUT" diff --git a/scripts/action/install-sync.sh b/scripts/action/install-sync.sh new file mode 100755 index 0000000..31617e2 --- /dev/null +++ b/scripts/action/install-sync.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +output="$CHECKOUT_DIR/.codeboarding" +mkdir -p "$output" + +# New Core releases expose one canonical manifest. The compatibility imports +# keep the currently pinned 0.13.5 release usable without duplicating filenames. +manifest="$(python3 - <<'PY' +try: + from constants import 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) +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; } +mapfile -t artifacts <<< "$manifest" + +for required in "${artifacts[@]:0:4}"; do + [ -f "$ANALYSIS_DIR/$required" ] || { echo "::error::Core did not produce $required."; exit 1; } +done + +installed=0 +for name in "${artifacts[@]}"; do + source="$ANALYSIS_DIR/$name" + target="$output/$name" + if [ -f "$source" ]; then + cp "$source" "$target" + installed=$((installed + 1)) + elif [ -e "$target" ]; then + rm -f "$target" + fi + printf '%s\n' "$target" +done + +# Remove documentation generated by v1. User-owned configuration in the same +# directory is deliberately untouched. +for legacy in "$output"/*.md "$output/health/health_report.json" \ + "$CHECKOUT_DIR/docs/development/architecture.md"; do + [ -e "$legacy" ] || continue + rm -f "$legacy" + printf '%s\n' "$legacy" +done + +echo "installed=$installed" >> "$GITHUB_OUTPUT" diff --git a/scripts/action/render-review.sh b/scripts/action/render-review.sh new file mode 100755 index 0000000..0d99248 --- /dev/null +++ b/scripts/action/render-review.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE_ANALYSIS="$BASE_ANALYSIS_PATH" +HEAD_ANALYSIS="$HEAD_ANALYSIS_PATH" + +if [ -z "$BASE_ANALYSIS" ] || [ ! -f "$BASE_ANALYSIS" ]; then + echo "::error::Review baseline missing." + exit 1 +fi +if [ -z "$HEAD_ANALYSIS" ] || [ ! -f "$HEAD_ANALYSIS" ]; then + echo "::error::Review head analysis missing." + exit 1 +fi + +DIAGRAM_OUT="${RUNNER_TEMP}/diagram.md" +META="${RUNNER_TEMP}/diagram_meta.json" +DIFF="$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" --out "$DIAGRAM_OUT" --direction LR --render-depth 1)" +printf '%s' "$DIFF" > "$META" + +N_CHANGED="$(jq -r '.n_changed' "$META")" +TRUNCATED="$(jq -r '.truncated | tostring' "$META")" +RENDERED="$(jq -r '.rendered | tostring' "$META")" +[ "$RENDERED" = true ] || { echo "::error::The architecture diff is too large to render."; exit 1; } +{ + echo "diagram_md=$DIAGRAM_OUT" + echo "n_changed=$N_CHANGED" + echo "truncated=$TRUNCATED" +} >> "$GITHUB_OUTPUT" diff --git a/scripts/action/sync-summary.sh b/scripts/action/sync-summary.sh new file mode 100755 index 0000000..f54dd82 --- /dev/null +++ b/scripts/action/sync-summary.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +{ + echo "### CodeBoarding Sync" + echo "- Analysis: ${MODE}" + echo "- Analysis artifacts: ${FILES:-0}" + echo "- Delivered: ${COMMITTED:-false}" + echo "- Strategy: ${STRATEGY}" + if [ -n "${PR_URL:-}" ]; then + echo "- Sync PR: ${PR_URL}" + fi +} >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/action/with-auth.sh b/scripts/action/with-auth.sh new file mode 100755 index 0000000..73bea5b --- /dev/null +++ b/scripts/action/with-auth.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail +AUTH_DIR="${RUNNER_TEMP}/codeboarding-auth" +PROVIDER_FILE="$AUTH_DIR/provider-name" +PROVIDER_ENV_FILE="$AUTH_DIR/provider-env" +PROVIDER_KEY_FILE="$AUTH_DIR/provider-key" +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 +if [ ! -s "$PROVIDER_FILE" ] || [ ! -s "$PROVIDER_ENV_FILE" ]; then + echo "::error::CodeBoarding analysis credentials are unavailable." + exit 1 +fi + +PROVIDER="$(cat "$PROVIDER_FILE")" +case "$PROVIDER" in + ollama) [ -n "${OLLAMA_BASE_URL:-${OLLAMA_HOST:-}}" ] || { echo "::error::ollama requires OLLAMA_BASE_URL or OLLAMA_HOST." >&2; exit 1; } ;; + litellm) [ -n "${LITELLM_BASE_URL:-}" ] || { echo "::error::litellm requires LITELLM_BASE_URL." >&2; exit 1; } ;; +esac + +# Core selects a provider from its environment. Remove inherited selectors for +# every provider except the one explicitly requested by this action invocation. +selectors=(OPENAI_API_KEY OPENAI_BASE_URL VERCEL_API_KEY VERCEL_BASE_URL ANTHROPIC_API_KEY GOOGLE_API_KEY + AWS_BEARER_TOKEN_BEDROCK CEREBRAS_API_KEY OLLAMA_API_KEY OLLAMA_BASE_URL OLLAMA_HOST DEEPSEEK_API_KEY + DEEPSEEK_BASE_URL GLM_API_KEY GLM_BASE_URL KIMI_API_KEY KIMI_BASE_URL OPENROUTER_API_KEY LITELLM_API_KEY LITELLM_BASE_URL) +for selector in "${selectors[@]}"; do + case "$PROVIDER:$selector" in + openai:OPENAI_*|vercel:VERCEL_*|anthropic:ANTHROPIC_*|google:GOOGLE_*|aws:AWS_*|aws_bedrock:AWS_*|cerebras:CEREBRAS_*|ollama:OLLAMA_*|deepseek:DEEPSEEK_*|glm:GLM_*|kimi:KIMI_*|openrouter:OPENROUTER_*|litellm:LITELLM_*) ;; + *) unset "$selector" ;; + esac +done + +PROVIDER_ENV="$(cat "$PROVIDER_ENV_FILE")" +if [ -s "$PROVIDER_KEY_FILE" ]; then + export "$PROVIDER_ENV=$(cat "$PROVIDER_KEY_FILE")" +fi +if [ -s "$AUTH_DIR/base-url" ]; then + OPENROUTER_BASE_URL="$(cat "$AUTH_DIR/base-url")" + export OPENROUTER_BASE_URL +fi + +export CODEBOARDING_SOURCE=github_action +if [ -n "${MODEL:-}" ]; then + export AGENT_MODEL="$MODEL" + export PARSING_MODEL="$MODEL" +fi +[ -z "${AGENT_MODEL_INPUT:-}" ] || export AGENT_MODEL="$AGENT_MODEL_INPUT" +[ -z "${PARSING_MODEL_INPUT:-}" ] || export PARSING_MODEL="$PARSING_MODEL_INPUT" + +"$@" diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py index 7b23765..53f952d 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -1,15 +1,11 @@ #!/usr/bin/env python3 -"""Thin helper to execute CodeBoarding CLI incremental/full commands. - -The action is intentionally logic-light: all analysis orchestration happens in -shell through this script's small JSON contract parser, which only invokes -CodeBoarding's own ``incremental`` and ``full`` commands. -""" +"""Execute CodeBoarding CLI commands and validate their JSON contract.""" from __future__ import annotations import argparse import json +import shutil import subprocess import sys from pathlib import Path @@ -33,17 +29,6 @@ def _parse_bool(value: object, *, field: str) -> bool: raise AnalysisError(f"Invalid contract field '{field}': {value!r}") -def _normalize_analysis_path(payload: dict, output_dir: str) -> Path: - path = payload.get("analysis_path") - if not isinstance(path, str) or not path.strip(): - raise AnalysisError("Missing or empty 'analysis_path' in CLI response") - - candidate = Path(path) - if not candidate.is_absolute(): - candidate = Path(output_dir) / candidate - return candidate - - def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path | None, dict]: if not raw.strip(): raise AnalysisError("CodeBoarding command produced no JSON output") @@ -69,8 +54,12 @@ def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path | None, d if requires_full and not payload.get("analysis_path"): return True, None, payload - analysis_path = _normalize_analysis_path(payload, output_dir) - + path = payload.get("analysis_path") + if not isinstance(path, str) or not path.strip(): + raise AnalysisError("Missing or empty 'analysis_path' in CLI response") + analysis_path = Path(path) + if not analysis_path.is_absolute(): + analysis_path = Path(output_dir) / analysis_path if not analysis_path.is_file(): raise AnalysisError(f"analysis_path points to a non-file: {analysis_path}") @@ -84,13 +73,9 @@ def _run_command(args: list[str], output_dir: Path) -> str: text=True, bufsize=1, cwd=str(output_dir.parent), - env=None, ) - if process.stdout is None: # pragma: no cover - guaranteed by stdout=PIPE - raise AnalysisError(f"Unable to read command output ({' '.join(args)})") - stdout_lines: list[str] = [] - for line in process.stdout: + for line in process.stdout or (): stdout_lines.append(line) # The shell captures this helper's stdout as its result contract. Mirror # CLI stdout to stderr so engine progress remains visible in Actions. @@ -105,34 +90,6 @@ def _run_command(args: list[str], output_dir: Path) -> str: return stdout -def run_incremental(checkout: Path, output_dir: Path) -> tuple[bool, Path | None, dict]: - output_dir.mkdir(parents=True, exist_ok=True) - raw = _run_command([PROG, "incremental", "--local", str(checkout), "--output-dir", str(output_dir)], output_dir) - return _parse_cli_response(raw, str(output_dir)) - - -def run_full(checkout: Path, output_dir: Path, depth_level: str) -> Path: - output_dir.mkdir(parents=True, exist_ok=True) - _run_command( - [ - PROG, - "full", - "--local", - str(checkout), - "--output-dir", - str(output_dir), - "--depth-level", - str(depth_level), - "--force", - ], - output_dir, - ) - analysis_path = output_dir / "analysis.json" - if not analysis_path.is_file(): - raise AnalysisError(f"Full analysis did not produce: {analysis_path}") - return analysis_path - - def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("mode", choices=["incremental", "full"], help="Which CLI command to invoke") @@ -146,8 +103,10 @@ def main(argv: list[str] | None = None) -> int: if not checkout.is_dir(): raise SystemExit(f"Missing checkout directory: {checkout}") + output_dir.mkdir(parents=True, exist_ok=True) if args.mode == "incremental": - requires_full, analysis_path, _ = run_incremental(checkout, output_dir) + command = [PROG, "incremental", "--local", str(checkout), "--output-dir", str(output_dir)] + requires_full, analysis_path, _ = _parse_cli_response(_run_command(command, output_dir), str(output_dir)) print(f"analysis_mode=incremental") print(f"requires_full_analysis={str(requires_full).lower()}") print(f"analysis_path={analysis_path or ''}") @@ -155,7 +114,23 @@ def main(argv: list[str] | None = None) -> int: if not args.depth_level: raise SystemExit("--depth-level is required for mode=full") - analysis_path = run_full(checkout, output_dir, args.depth_level) + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True) + command = [ + PROG, + "full", + "--local", + str(checkout), + "--output-dir", + str(output_dir), + "--depth-level", + args.depth_level, + "--force", + ] + _run_command(command, output_dir) + analysis_path = output_dir / "analysis.json" + if not analysis_path.is_file(): + raise AnalysisError(f"Full analysis did not produce: {analysis_path}") print(f"analysis_mode=full") print("requires_full_analysis=false") print(f"analysis_path={analysis_path}") diff --git a/scripts/install_sync_artifacts.py b/scripts/install_sync_artifacts.py deleted file mode 100755 index 3e5149e..0000000 --- a/scripts/install_sync_artifacts.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -"""Install generated sync artifacts without deleting user-authored config.""" - -from __future__ import annotations - -import argparse -import shutil -from pathlib import Path - - -class ArtifactInstallError(RuntimeError): - pass - - -ROOT_ARTIFACTS = ( - "fingerprint.json", - "static_analysis.pkl", - "static_analysis.sha", - "codeboarding_version.json", -) - - -def _remove_file(path: Path) -> bool: - if not path.exists() and not path.is_symlink(): - return False - if path.is_dir(): - raise ArtifactInstallError(f"Generated artifact path is a directory: {path}") - path.unlink() - return True - - -def _replace_optional(source: Path, target: Path, stage_paths: set[Path]) -> None: - existed = _remove_file(target) - if source.is_file(): - shutil.copy2(source, target) - stage_paths.add(target) - elif existed: - stage_paths.add(target) - - -def install_sync_artifacts( - *, - output_dir: Path, - docs_dir: Path, - analysis_path: Path, - analysis_dir: Path, -) -> list[Path]: - """Replace only action-owned artifacts and return paths that should be staged.""" - - rendered_docs = sorted(docs_dir.glob("*.md")) - if not rendered_docs: - raise ArtifactInstallError(f"No rendered markdown files found in: {docs_dir}") - if not analysis_path.is_file(): - raise ArtifactInstallError(f"Missing analysis artifact: {analysis_path}") - - stage_paths: set[Path] = set() - if output_dir.is_dir(): - for old_doc in output_dir.glob("*.md"): - _remove_file(old_doc) - stage_paths.add(old_doc) - - output_dir.mkdir(parents=True, exist_ok=True) - health_dir = output_dir / "health" - health_dir.mkdir(parents=True, exist_ok=True) - - for source in rendered_docs: - target = output_dir / source.name - _remove_file(target) - shutil.copy2(source, target) - stage_paths.add(target) - - analysis_target = output_dir / "analysis.json" - _remove_file(analysis_target) - shutil.copy2(analysis_path, analysis_target) - stage_paths.add(analysis_target) - - for name in ROOT_ARTIFACTS: - _replace_optional(analysis_dir / name, output_dir / name, stage_paths) - - _replace_optional( - analysis_dir / "health" / "health_report.json", - health_dir / "health_report.json", - stage_paths, - ) - - return sorted(stage_paths) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output-dir", required=True, type=Path) - parser.add_argument("--docs-dir", required=True, type=Path) - parser.add_argument("--analysis", required=True, type=Path) - parser.add_argument("--analysis-dir", required=True, type=Path) - args = parser.parse_args(argv) - - try: - paths = install_sync_artifacts( - output_dir=args.output_dir, - docs_dir=args.docs_dir, - analysis_path=args.analysis, - analysis_dir=args.analysis_dir, - ) - except ArtifactInstallError as exc: - parser.error(str(exc)) - - for path in paths: - print(path) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/render_sync_docs.py b/scripts/render_sync_docs.py deleted file mode 100755 index 0ff9d2f..0000000 --- a/scripts/render_sync_docs.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -"""Thin wrapper around installed CodeBoarding renderer. - -No analysis or rendering orchestration lives here, only direct calls into the -package's renderer. -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from codeboarding_workflows.rendering import render_docs - - -def concat_overview(docs_dir: Path, out_path: Path) -> None: - overview = docs_dir / "overview.md" - if not overview.is_file(): - raise SystemExit("Missing required root docs file: overview.md") - - files = [overview] - files.extend(sorted(p for p in docs_dir.glob("*.md") if p.name != "overview.md")) - out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_text("\n\n".join(p.read_text(encoding="utf-8").rstrip() for p in files) + "\n", encoding="utf-8") - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Render markdown docs from an analysis JSON.") - parser.add_argument("--analysis", required=True, type=Path, help="Path to analysis.json") - parser.add_argument("--output-dir", required=True, type=Path, help="Directory to write docs") - parser.add_argument("--repo-name", required=True) - parser.add_argument("--repo-ref", required=True) - parser.add_argument("--format", default=".md") - parser.add_argument("--architecture-file", required=False, type=Path) - args = parser.parse_args(argv) - - render_docs( - args.analysis, - repo_name=args.repo_name, - repo_ref=args.repo_ref, - temp_dir=args.output_dir, - format=args.format, - root_name="overview", - ) - - if args.architecture_file: - concat_overview(args.output_dir, args.architecture_file) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/run_local.sh b/tests/run_local.sh similarity index 96% rename from scripts/run_local.sh rename to tests/run_local.sh index dfba988..502fd9b 100755 --- a/scripts/run_local.sh +++ b/tests/run_local.sh @@ -3,11 +3,11 @@ # # Three modes: # 1) FAST review preview (no repo analysis): -# scripts/run_local.sh --base-json BASE.json --head-json HEAD.json +# tests/run_local.sh --base-json BASE.json --head-json HEAD.json # 2) REVIEW LOCAL (full local pipeline): -# scripts/run_local.sh --repo /path/to/repo --base --head +# tests/run_local.sh --repo /path/to/repo --base --head # 3) REVIEW LOCAL against committed baseline only (if available): -# scripts/run_local.sh --repo /path/to/repo --base --head --depth 2 +# tests/run_local.sh --repo /path/to/repo --base --head --depth 2 # # Output: # diagram.md Mermaid payload posted by the action diff --git a/tests/test_action_auth.py b/tests/test_action_auth.py new file mode 100644 index 0000000..bfdb6ca --- /dev/null +++ b/tests/test_action_auth.py @@ -0,0 +1,185 @@ +"""Tests for the composite action's provider credential boundary.""" + +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +CONFIGURE_AUTH = ROOT / "scripts" / "action" / "configure-auth.sh" +WITH_AUTH = ROOT / "scripts" / "action" / "with-auth.sh" + + +class ActionAuthTests(unittest.TestCase): + def _configure(self, provider: str, key: str, **extra_env: str) -> tuple[subprocess.CompletedProcess, Path]: + temp_dir = Path(self.temp_dir.name) + runner_temp = temp_dir / "runner" + runner_temp.mkdir() + output = temp_dir / "output" + github_env = temp_dir / "github-env" + env = { + "PATH": os.environ["PATH"], + "ACTION_PATH": str(ROOT), + "GITHUB_ENV": str(github_env), + "GITHUB_OUTPUT": str(output), + "LICENSE_KEY": "", + "LLM_API_KEY": key, + "LLM_PROVIDER": provider, + "RUNNER_TEMP": str(runner_temp), + **extra_env, + } + result = subprocess.run( + [str(CONFIGURE_AUTH)], + env=env, + capture_output=True, + text=True, + check=False, + ) + return result, runner_temp / "codeboarding-auth" + + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_maps_provider_keys_without_exporting_them_to_github_env(self) -> None: + cases = { + "openai": "OPENAI_API_KEY", + "vercel": "VERCEL_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", + "aws": "AWS_BEARER_TOKEN_BEDROCK", + "aws_bedrock": "AWS_BEARER_TOKEN_BEDROCK", + "cerebras": "CEREBRAS_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + "glm": "GLM_API_KEY", + "kimi": "KIMI_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + } + for provider, expected_env in cases.items(): + with self.subTest(provider=provider): + self.temp_dir.cleanup() + self.temp_dir = tempfile.TemporaryDirectory() + result, auth_dir = self._configure(provider, "fake-key") + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertEqual((auth_dir / "provider-env").read_text(), expected_env) + self.assertEqual((auth_dir / "provider-key").read_text(), "fake-key") + self.assertFalse((Path(self.temp_dir.name) / "github-env").exists()) + mode = stat.S_IMODE((auth_dir / "provider-key").stat().st_mode) + self.assertEqual(mode & 0o077, 0) + + def test_maps_standard_custom_provider_names_without_an_action_allowlist(self) -> None: + result, auth_dir = self._configure("acme-ai", "fake-key") + + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertEqual((auth_dir / "provider-env").read_text(), "ACME_AI_API_KEY") + + def test_ollama_and_litellm_keys_use_core_environment_names(self) -> None: + for provider in ("ollama", "litellm"): + with self.subTest(provider=provider): + self.temp_dir.cleanup() + self.temp_dir = tempfile.TemporaryDirectory() + result, auth_dir = self._configure(provider, "fake-key") + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertEqual((auth_dir / "provider-env").read_text(), f"{provider.upper()}_API_KEY") + self.assertEqual((auth_dir / "provider-key").read_text(), "fake-key") + + def test_keyless_direct_provider_does_not_fall_back_to_hosted_openrouter(self) -> None: + result, auth_dir = self._configure("ollama", "") + + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertEqual((auth_dir / "provider-env").read_text(), "OLLAMA_API_KEY") + self.assertFalse((auth_dir / "provider-key").exists()) + + def test_hosted_auth_relays_to_aws_proxy_instead_of_openrouter(self) -> None: + temp_dir = Path(self.temp_dir.name) + fake_bin = temp_dir / "bin" + fake_bin.mkdir() + captured_args = temp_dir / "relay-args" + fake_python = fake_bin / "python3" + fake_python.write_text( + """#!/usr/bin/env bash +printf '%s\\n' "$@" > "$CAPTURED_ARGS" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--ready-file" ]; then + printf '12345' > "$2" + break + fi + shift +done +""", + encoding="utf-8", + ) + fake_python.chmod(0o755) + + result, auth_dir = self._configure( + "openrouter", + "", + ACTIONS_ID_TOKEN_REQUEST_URL="https://oidc.example/token", + ACTIONS_ID_TOKEN_REQUEST_TOKEN="request-token", + CAPTURED_ARGS=str(captured_args), + PATH=f"{fake_bin}:{os.environ['PATH']}", + ) + + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + args = captured_args.read_text(encoding="utf-8").splitlines() + upstream_index = args.index("--upstream-base-url") + 1 + self.assertEqual( + args[upstream_index], + "https://auduihjmm4b735zci7vyabuikq0hppqn.lambda-url.us-east-1.on.aws", + ) + self.assertNotIn("https://openrouter.ai/api/v1", args) + self.assertEqual((auth_dir / "provider-key").read_text(), "github-actions-oidc-relay") + + def test_with_auth_scopes_credentials_source_and_model_precedence(self) -> None: + result, auth_dir = self._configure("anthropic", "fake=key") + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + command = [ + str(WITH_AUTH), + "bash", + "-c", + 'test "$ANTHROPIC_API_KEY" = "fake=key" && ' + 'test -z "${OPENAI_API_KEY:-}" && ' + 'test -z "${OPENAI_BASE_URL:-}" && ' + 'test -z "${OPENROUTER_API_KEY:-}" && ' + 'test "$CODEBOARDING_SOURCE" = github_action && ' + 'test "$AGENT_MODEL" = analysis-model && ' + 'test "$PARSING_MODEL" = shared-model', + ] + env = { + "PATH": os.environ["PATH"], + "RUNNER_TEMP": str(auth_dir.parent), + "MODEL": "shared-model", + "AGENT_MODEL_INPUT": "analysis-model", + "PARSING_MODEL_INPUT": "", + "OPENAI_API_KEY": "inherited-key", + "OPENAI_BASE_URL": "https://inherited.example", + } + scoped = subprocess.run(command, env=env, capture_output=True, text=True, check=False) + self.assertEqual(scoped.returncode, 0, scoped.stderr or scoped.stdout) + self.assertFalse(auth_dir.exists()) + + def test_keyless_provider_requires_its_endpoint(self) -> None: + result, auth_dir = self._configure("ollama", "") + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + + scoped = subprocess.run( + [str(WITH_AUTH), "true"], + env={"PATH": os.environ["PATH"], "RUNNER_TEMP": str(auth_dir.parent)}, + capture_output=True, + text=True, + check=False, + ) + + self.assertNotEqual(scoped.returncode, 0) + self.assertIn("ollama requires OLLAMA_BASE_URL or OLLAMA_HOST", scoped.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_action_sync.py b/tests/test_action_sync.py new file mode 100644 index 0000000..7fdabd4 --- /dev/null +++ b/tests/test_action_sync.py @@ -0,0 +1,117 @@ +"""Tests for the sync artifact boundary owned by Core.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +INSTALL_SYNC = ROOT / "scripts" / "action" / "install-sync.sh" +GUARD = ROOT / "scripts" / "action" / "guard.sh" + + +class ActionSyncTests(unittest.TestCase): + def test_review_guard_rejects_fork_before_checkout(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "github-output" + result = subprocess.run( + [str(GUARD)], + env={ + "PATH": os.environ["PATH"], + "GITHUB_OUTPUT": str(output), + "MODE": "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": "contributor/repo", + }, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + values = output.read_text(encoding="utf-8") + self.assertTrue(values.endswith("skip=true\n")) + self.assertNotIn("checkout_ref=", values) + + def test_installs_core_manifest_and_preserves_user_configuration(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + checkout = root / "checkout" + output = checkout / ".codeboarding" + analysis = root / "analysis" + fake_core = root / "core" + for directory in (output / "health", analysis, fake_core, checkout / "docs" / "development"): + directory.mkdir(parents=True) + + (fake_core / "constants.py").write_text( + "PERSISTED_ANALYSIS_ARTIFACT_FILENAMES = (\n" + " 'analysis.json', 'fingerprint.json', 'static_analysis.pkl',\n" + " 'static_analysis.sha', 'codeboarding_version.json',\n" + ")\n", + encoding="utf-8", + ) + for name in ( + "analysis.json", + "fingerprint.json", + "static_analysis.pkl", + "static_analysis.sha", + "codeboarding_version.json", + ): + (analysis / name).write_text(f"new {name}\n", encoding="utf-8") + + preserved = { + output / ".codeboardingignore": "ignore me\n", + output / "health_config.json": "{}\n", + output / "health" / ".healthignore": "known issue\n", + } + 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\n", encoding="utf-8") + + github_output = root / "github-output" + result = subprocess.run( + [str(INSTALL_SYNC)], + env={ + "PATH": os.environ["PATH"], + "PYTHONPATH": str(fake_core), + "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) + for name in ( + "analysis.json", + "fingerprint.json", + "static_analysis.pkl", + "static_analysis.sha", + "codeboarding_version.json", + ): + self.assertEqual((output / name).read_text(encoding="utf-8"), f"new {name}\n") + for path, content in preserved.items(): + self.assertEqual(path.read_text(encoding="utf-8"), content) + for path in legacy: + self.assertFalse(path.exists()) + self.assertEqual(github_output.read_text(encoding="utf-8"), "installed=5\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py index e0783de..9804546 100644 --- a/tests/test_analyze_repository.py +++ b/tests/test_analyze_repository.py @@ -130,6 +130,9 @@ def test_main_full_uses_generated_analysis_file(self) -> None: checkout = root / "repo" out_dir = root / "out" checkout.mkdir() + out_dir.mkdir() + stale_artifact = out_dir / "fingerprint.json" + stale_artifact.write_text("stale", encoding="utf-8") def fake_run(_args, output_dir): (output_dir / "analysis.json").write_text("ok", encoding="utf-8") @@ -150,6 +153,7 @@ def fake_run(_args, output_dir): ) self.assertIn(f"analysis_path={out_dir / 'analysis.json'}", stdout.getvalue()) + self.assertFalse(stale_artifact.exists()) def test_main_rejects_bad_cli_output(self) -> None: with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_install_sync_artifacts.py b/tests/test_install_sync_artifacts.py deleted file mode 100644 index d2b3dde..0000000 --- a/tests/test_install_sync_artifacts.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Regression tests for selective sync-artifact installation.""" - -import sys -import tempfile -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) - -import install_sync_artifacts as isa - - -class InstallSyncArtifactsTests(unittest.TestCase): - def test_preserves_user_config_while_replacing_generated_files(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - output = root / ".codeboarding" - health = output / "health" - docs = root / "docs" - analysis = root / "analysis" - analysis_health = analysis / "health" - for directory in (health, docs, analysis_health): - directory.mkdir(parents=True) - - preserved = { - output / ".codeboardingignore": "ignore me\n", - output / "health_config.json": '{"root": true}\n', - health / ".healthignore": "known issue\n", - health / "health_config.json": '{"health": true}\n', - output / "notes.txt": "user notes\n", - } - for path, content in preserved.items(): - path.write_text(content, encoding="utf-8") - - (output / "stale-component.md").write_text("stale\n", encoding="utf-8") - (output / "codeboarding_version.json").write_text("stale\n", encoding="utf-8") - (health / "health_report.json").write_text("old report\n", encoding="utf-8") - (docs / "overview.md").write_text("# New overview\n", encoding="utf-8") - analysis_path = analysis / "analysis.json" - analysis_path.write_text('{"new": true}\n', encoding="utf-8") - (analysis / "fingerprint.json").write_text('{"fingerprint": true}\n', encoding="utf-8") - (analysis_health / "health_report.json").write_text("new report\n", encoding="utf-8") - - stage_paths = isa.install_sync_artifacts( - output_dir=output, - docs_dir=docs, - analysis_path=analysis_path, - analysis_dir=analysis, - ) - - for path, content in preserved.items(): - self.assertEqual(path.read_text(encoding="utf-8"), content) - self.assertFalse((output / "stale-component.md").exists()) - self.assertFalse((output / "codeboarding_version.json").exists()) - self.assertEqual((output / "overview.md").read_text(encoding="utf-8"), "# New overview\n") - self.assertEqual((output / "analysis.json").read_text(encoding="utf-8"), '{"new": true}\n') - self.assertEqual((health / "health_report.json").read_text(encoding="utf-8"), "new report\n") - - staged = set(stage_paths) - self.assertIn(output / "stale-component.md", staged) - self.assertIn(output / "codeboarding_version.json", staged) - self.assertIn(output / "overview.md", staged) - self.assertIn(output / "analysis.json", staged) - self.assertNotIn(output / ".codeboardingignore", staged) - self.assertNotIn(health / ".healthignore", staged) - self.assertNotIn(health / "health_config.json", staged) - - def test_rejects_empty_render_output_before_modifying_destination(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - output = root / ".codeboarding" - docs = root / "docs" - analysis = root / "analysis" - output.mkdir() - docs.mkdir() - analysis.mkdir() - existing = output / "overview.md" - existing.write_text("keep on failure\n", encoding="utf-8") - analysis_path = analysis / "analysis.json" - analysis_path.write_text("{}\n", encoding="utf-8") - - with self.assertRaises(isa.ArtifactInstallError): - isa.install_sync_artifacts( - output_dir=output, - docs_dir=docs, - analysis_path=analysis_path, - analysis_dir=analysis, - ) - - self.assertEqual(existing.read_text(encoding="utf-8"), "keep on failure\n") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_render_sync_docs.py b/tests/test_render_sync_docs.py deleted file mode 100644 index 766b0f8..0000000 --- a/tests/test_render_sync_docs.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Smoke tests for scripts/render_sync_docs.py.""" - -import sys -import tempfile -import unittest -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) - -stub_pkg = ModuleType("codeboarding_workflows") -stub_rendering = ModuleType("codeboarding_workflows.rendering") -stub_rendering.render_docs = lambda *args, **kwargs: None -stub_pkg.rendering = stub_rendering -sys.modules["codeboarding_workflows"] = stub_pkg -sys.modules["codeboarding_workflows.rendering"] = stub_rendering - -import render_sync_docs as rsd # noqa: E402 - - -class RenderSyncDocsTests(unittest.TestCase): - def setUp(self) -> None: - self.render_calls = [] - - def _make_fake_render(self, with_overview: bool = True): - def _render( - analysis, - repo_name, - repo_ref, - temp_dir, - format=".md", - root_name="overview", - ): - self.render_calls.append((analysis, repo_name, repo_ref, temp_dir, format, root_name)) - out = Path(temp_dir) - out.mkdir(parents=True, exist_ok=True) - if with_overview: - (out / "overview.md").write_text("# Overview\n", encoding="utf-8") - (out / "api.md").write_text("# API\n", encoding="utf-8") - (out / "zeta.md").write_text("# Zeta\n", encoding="utf-8") - - return _render - - def test_concat_prefers_overview_first_and_appends_sorted(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - analysis = root / "analysis.json" - analysis.write_text("{}", encoding="utf-8") - output = root / "docs" - architecture = root / "architecture.md" - with patch.object(rsd, "render_docs", new=self._make_fake_render(True)): - rsd.main( - [ - "--analysis", - str(analysis), - "--output-dir", - str(output), - "--repo-name", - "org/repo", - "--repo-ref", - "abc123", - "--format", - ".md", - "--architecture-file", - str(architecture), - ] - ) - result = architecture.read_text(encoding="utf-8") - self.assertIn("# Overview", result) - self.assertIn("# API", result) - self.assertIn("# Zeta", result) - self.assertLess(result.index("# Overview"), result.index("# API")) - self.assertLess(result.index("# API"), result.index("# Zeta")) - self.assertEqual(len(self.render_calls), 1) - - def test_missing_overview_fails(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - analysis = root / "analysis.json" - analysis.write_text("{}", encoding="utf-8") - output = root / "docs" - architecture = root / "architecture.md" - with patch.object(rsd, "render_docs", new=self._make_fake_render(False)): - with self.assertRaises(SystemExit): - rsd.main( - [ - "--analysis", - str(analysis), - "--output-dir", - str(output), - "--repo-name", - "org/repo", - "--repo-ref", - "abc123", - "--format", - ".md", - "--architecture-file", - str(architecture), - ] - ) - - -if __name__ == "__main__": - unittest.main() From 15ea6a89bef6908771ce80f7870774fad6e52815 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 8 Aug 2026 10:48:08 +0000 Subject: [PATCH 19/22] fix: resolve action paths and local fallbacks safely Amp-Thread-ID: https://ampcode.com/threads/T-019fdc90-d8c3-779f-92ce-ce9888aad94d Co-authored-by: Ivan Milev --- scripts/analyze_repository.py | 6 +++--- scripts/run_local.sh | 3 +++ tests/run_local.sh | 14 ++------------ tests/test_analyze_repository.py | 2 +- 4 files changed, 9 insertions(+), 16 deletions(-) create mode 100755 scripts/run_local.sh diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py index 53f952d..6dab352 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -29,7 +29,7 @@ def _parse_bool(value: object, *, field: str) -> bool: raise AnalysisError(f"Invalid contract field '{field}': {value!r}") -def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path | None, dict]: +def _parse_cli_response(raw: str, working_dir: str) -> tuple[bool, Path | None, dict]: if not raw.strip(): raise AnalysisError("CodeBoarding command produced no JSON output") @@ -59,7 +59,7 @@ def _parse_cli_response(raw: str, output_dir: str) -> tuple[bool, Path | None, d raise AnalysisError("Missing or empty 'analysis_path' in CLI response") analysis_path = Path(path) if not analysis_path.is_absolute(): - analysis_path = Path(output_dir) / analysis_path + analysis_path = Path(working_dir) / analysis_path if not analysis_path.is_file(): raise AnalysisError(f"analysis_path points to a non-file: {analysis_path}") @@ -106,7 +106,7 @@ def main(argv: list[str] | None = None) -> int: output_dir.mkdir(parents=True, exist_ok=True) if args.mode == "incremental": command = [PROG, "incremental", "--local", str(checkout), "--output-dir", str(output_dir)] - requires_full, analysis_path, _ = _parse_cli_response(_run_command(command, output_dir), str(output_dir)) + requires_full, analysis_path, _ = _parse_cli_response(_run_command(command, output_dir), str(output_dir.parent)) print(f"analysis_mode=incremental") print(f"requires_full_analysis={str(requires_full).lower()}") print(f"analysis_path={analysis_path or ''}") diff --git a/scripts/run_local.sh b/scripts/run_local.sh new file mode 100755 index 0000000..96595dd --- /dev/null +++ b/scripts/run_local.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec "$(dirname "${BASH_SOURCE[0]}")/../tests/run_local.sh" "$@" diff --git a/tests/run_local.sh b/tests/run_local.sh index 502fd9b..ed9d1e6 100755 --- a/tests/run_local.sh +++ b/tests/run_local.sh @@ -86,12 +86,8 @@ else exit 2 fi - if [ -d "$OUT" ]; then - rm -rf "$OUT" - mkdir -p "$OUT" - fi - WORK="$OUT/work" + rm -rf "$WORK" BASE_DIR="$WORK/base" HEAD_DIR="$WORK/head" BASE_FULL_DIR="$WORK/base-full" @@ -151,12 +147,6 @@ else fi if [ "$NEED_FULL" = "true" ]; then - rm -rf "$HEAD_DIR" && mkdir -p "$HEAD_DIR" - if [ -n "${BASE_FULL_DIR:-}" ] && [ -d "$BASE_FULL_DIR" ]; then - cp -a "$BASE_FULL_DIR/." "$HEAD_DIR/." 2>/dev/null || true - else - cp -a "$BASE_DIR/." "$HEAD_DIR/." 2>/dev/null || true - fi HEAD_FULL_OUTPUT="$(run_full "$HEAD_DIR" "$HEAD_FULL_DIR")" HEAD_MODE="$(parse_value analysis_mode "$HEAD_FULL_OUTPUT")" HEAD_PATH="$(parse_value analysis_path "$HEAD_FULL_OUTPUT")" @@ -215,7 +205,7 @@ open(dst, "w", encoding="utf-8").write(f"""
\n{graph}\n
""") print(f" wrote {dst}") diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py index 9804546..66fdfc5 100644 --- a/tests/test_analyze_repository.py +++ b/tests/test_analyze_repository.py @@ -87,7 +87,7 @@ def test_parse_main_incremental_success(self) -> None: "_run_command", return_value=json.dumps( { - "analysis_path": str(analysis_path.relative_to(out_dir)), + "analysis_path": str(analysis_path.relative_to(out_dir.parent)), "requiresFullAnalysis": False, } ), From ef706cca0c9e7bb282ce679d97c5bd0724d68743 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 8 Aug 2026 11:30:04 +0000 Subject: [PATCH 20/22] fix: remove deprecated artifact fallback Amp-Thread-ID: https://ampcode.com/threads/T-019fdc90-d8c3-779f-92ce-ce9888aad94d Co-authored-by: Ivan Milev --- .github/workflows/codeboarding-sync.yml | 8 +------- action.yml | 8 -------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/codeboarding-sync.yml b/.github/workflows/codeboarding-sync.yml index bcadaa9..a118837 100644 --- a/.github/workflows/codeboarding-sync.yml +++ b/.github/workflows/codeboarding-sync.yml @@ -47,11 +47,6 @@ on: options: [push, pull_request] required: false default: push - sync_pr_branch: - description: 'Machine-owned branch used when sync_strategy is pull_request.' - type: string - required: false - default: codeboarding/sync # No workflow-level permissions: the single job below requests only what it # needs (least privilege), so the default token starts with none. @@ -147,8 +142,7 @@ jobs: with: mode: sync sync_strategy: ${{ inputs.sync_strategy || 'push' }} - sync_pr_branch: ${{ inputs.sync_pr_branch || 'codeboarding/sync' }} # 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. - push_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} + github_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} diff --git a/action.yml b/action.yml index 982100b..50aa9f3 100644 --- a/action.yml +++ b/action.yml @@ -259,14 +259,6 @@ runs: path: ${{ steps.review_artifact.outputs.artifact_dir }} if-no-files-found: error - - name: Upload review artifact (GitHub Enterprise Server) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && github.server_url != 'https://github.com' - uses: actions/upload-artifact@v3.2.2-node20 - with: - name: codeboarding-review-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ steps.review_artifact.outputs.artifact_dir }} - if-no-files-found: error - - name: Build review comment id: review_body if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' From 821ff56f38f5664af21fdb2b49c4084ec2e991c7 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 8 Aug 2026 11:45:52 +0000 Subject: [PATCH 21/22] chore: defer dogfood workflow cleanup Amp-Thread-ID: https://ampcode.com/threads/T-019fdc90-d8c3-779f-92ce-ce9888aad94d Co-authored-by: Ivan Milev --- .github/workflows/codeboarding-sync.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeboarding-sync.yml b/.github/workflows/codeboarding-sync.yml index a118837..bcadaa9 100644 --- a/.github/workflows/codeboarding-sync.yml +++ b/.github/workflows/codeboarding-sync.yml @@ -47,6 +47,11 @@ on: options: [push, pull_request] required: false default: push + sync_pr_branch: + description: 'Machine-owned branch used when sync_strategy is pull_request.' + type: string + required: false + default: codeboarding/sync # No workflow-level permissions: the single job below requests only what it # needs (least privilege), so the default token starts with none. @@ -142,7 +147,8 @@ jobs: with: mode: sync sync_strategy: ${{ inputs.sync_strategy || 'push' }} + sync_pr_branch: ${{ inputs.sync_pr_branch || 'codeboarding/sync' }} # 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 }} + push_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} From 281e85dd0614cae21442a13eb3ef7dbbefbbdea2 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 8 Aug 2026 12:46:35 +0000 Subject: [PATCH 22/22] fix(review): highlight components owning changed methods Amp-Thread-ID: https://ampcode.com/threads/T-019fdc90-d8c3-779f-92ce-ce9888aad94d Co-authored-by: Ivan Milev --- scripts/diff_to_mermaid.py | 63 ++++++++++++++++++++++++++++++++++- tests/test_diff_to_mermaid.py | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/scripts/diff_to_mermaid.py b/scripts/diff_to_mermaid.py index 0d86bf8..290b8eb 100644 --- a/scripts/diff_to_mermaid.py +++ b/scripts/diff_to_mermaid.py @@ -144,6 +144,55 @@ def _has_method_changes(base: dict, current: dict) -> bool: ) +def _analysis_changes(base: dict, current: dict) -> tuple[set[str], set[str]]: + """Return changed method keys and changed files without method-level detail.""" + + def content_changed(before: dict | None, after: dict | None) -> bool: + if before is None or after is None: + return before != after + return before.get("content_hash") != after.get("content_hash") + + base_index = base.get("methods_index") or {} + current_index = current.get("methods_index") or {} + changed_methods: set[str] = set() + member_files: set[str] = set() + for key in set(base_index) | set(current_index): + before = base_index.get(key) + after = current_index.get(key) + if not content_changed(before, after): + continue + record = after or before or {} + changed_methods.add(key) + member_files.add(record.get("file_path") or key.partition("|")[0]) + + base_files = base.get("files") or {} + current_files = current.get("files") or {} + changed_files = { + path + for path in set(base_files) | set(current_files) + if content_changed(base_files.get(path), current_files.get(path)) + } + return changed_methods, changed_files - member_files + + +def _owns_analysis_change( + base: dict, + current: dict, + changed_methods: set[str], + changed_files: set[str], +) -> bool: + base_by_file = _methods_by_file(base) + current_by_file = _methods_by_file(current) + owned_methods = { + f"{path}|{method}" + for by_file in (base_by_file, current_by_file) + for path, methods in by_file.items() + for method in methods + } + owned_files = set(base_by_file) | set(current_by_file) + return bool(owned_methods & changed_methods or owned_files & changed_files) + + def _rel_key(r: dict) -> tuple: # Name is the stable join across two independent analyses; component ids are # positional and can be reshuffled on a full re-run, so prefer names. @@ -259,8 +308,20 @@ def _diff_components(base_components: list, current_components: list) -> list: def build_diff(base: dict, head: dict) -> dict: + changed_methods, changed_files = _analysis_changes(base, head) + components = _diff_components(base.get("components") or [], head.get("components") or []) + base_by_name = {_comp_name(component): component for component in base.get("components") or []} + for component in components: + base_component = base_by_name.get(_comp_name(component)) + if base_component is not None and _owns_analysis_change( + base_component, + component, + changed_methods, + changed_files, + ): + component["diff_status"] = "modified" return { - "components": _diff_components(base.get("components") or [], head.get("components") or []), + "components": components, "components_relations": _diff_relations( base.get("components_relations") or [], head.get("components_relations") or [], diff --git a/tests/test_diff_to_mermaid.py b/tests/test_diff_to_mermaid.py index 4ad22bb..bf51b6e 100644 --- a/tests/test_diff_to_mermaid.py +++ b/tests/test_diff_to_mermaid.py @@ -204,6 +204,69 @@ def test_structural_change_is_modified(self): head = {"components": [comp("A", {"a.py": ["f"], "b.py": ["h"]})], "components_relations": []} self.assertEqual(dm.build_diff(base, head)["components"][0]["diff_status"], "modified") + def test_method_body_change_marks_owning_top_level_component(self): + base = { + "components": [ + comp("Owner", {"shared.py": ["Owner.changed"]}, subs=[comp("Inner", {"shared.py": ["Owner.changed"]})]), + comp("Reference", {"shared.py": ["Reference.unchanged"]}), + ], + "components_relations": [], + "methods_index": { + "shared.py|Owner.changed": { + "file_path": "shared.py", + "qualified_name": "Owner.changed", + "content_hash": "before", + } + }, + } + head = { + **base, + "methods_index": { + "shared.py|Owner.changed": { + "file_path": "shared.py", + "qualified_name": "Owner.changed", + "content_hash": "after", + } + }, + } + + diff = dm.build_diff(base, head) + statuses = {component["name"]: component["diff_status"] for component in diff["components"]} + text, meta = dm.render_mermaid(diff, render_depth=1) + + self.assertEqual(statuses, {"Owner": "modified", "Reference": "unchanged"}) + self.assertIn("class n_Owner modified;", text) + self.assertNotIn("class n_Reference modified;", text) + self.assertEqual(meta["n_changed"], 1) + + def test_line_number_shift_does_not_mark_method_changed(self): + component = comp("Owner", {"shared.py": ["Owner.method"]}) + base = { + "components": [component], + "components_relations": [], + "methods_index": { + "shared.py|Owner.method": { + "file_path": "shared.py", + "qualified_name": "Owner.method", + "content_hash": "same", + "start_line": 10, + } + }, + } + head = { + **base, + "methods_index": { + "shared.py|Owner.method": { + "file_path": "shared.py", + "qualified_name": "Owner.method", + "content_hash": "same", + "start_line": 11, + } + }, + } + + self.assertEqual(dm.build_diff(base, head)["components"][0]["diff_status"], "unchanged") + def test_rename_is_add_plus_delete(self): base = {"components": [comp("Old")], "components_relations": []} head = {"components": [comp("New")], "components_relations": []}