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 }} 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 1f413dc..72a82f3 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: @@ -8,1753 +8,589 @@ branding: 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.' + description: 'Optional OpenRouter API key. When empty, the action uses the hosted tier via GitHub OIDC.' 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 architecture review comments + 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' + default: 'CodeBoarding 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.' + description: 'Review-mode slash command that triggers an on-demand run from a pull request comment.' 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).' + webview_base_url: + description: 'Hosted CodeBoarding webview base URL. Set empty to omit the full-change link.' required: false - default: 'false' + default: 'https://app.codeboarding.org' 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 }} + TRIGGER_COMMAND: ${{ inputs.trigger_command }} + 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)" + [ -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 - { [ -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 + 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 - - name: Acknowledge command - if: steps.guard.outputs.skip != 'true' && github.event_name == 'issue_comment' + - 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: | - # 👀 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' + - 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. - codeboarding-action · run ${{ github.run_id }} - GITHUB_TOKEN: ${{ inputs.github_token }} + run ${{ github.run_id }} · attempt ${{ github.run_attempt }} - - 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' - - - 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 }} + LLM_API_KEY: ${{ inputs.llm_api_key }} + 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 + + 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 - # ── 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 + 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 - # 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" - 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 + 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 - # 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." + 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 - - 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 + python3 "$ACTION_PATH/scripts/oidc_relay.py" "${RELAY_ARGS[@]}" > "$LOG" 2>&1 & + echo $! > "$PID" - - 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." - exit 1 - fi + for _ in {1..60}; do + [ -s "$READY" ] && break + sleep 0.25 + done - # 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." + if [ ! -s "$READY" ]; then + echo "::error::OIDC relay did not start." >&2 + cat "$LOG" >&2 || true exit 1 fi - echo "base_analysis=$BASE_DIR/analysis.json" >> "$GITHUB_OUTPUT" - echo "head_analysis=$HEAD_DIR/analysis.json" >> $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 - 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. + 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 target repository (sync) - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' + - name: Analyze (sync) id: sync_analyze + if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' 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")" + + 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 - # 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 -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 - [ "$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" ] || { [ "$NEED_FULL" != "true" ] && [ -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 }} + 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 }} 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" + GENERATED_PATHS="${RUNNER_TEMP}/cb-sync-generated-paths" + push_token="${{ inputs.push_token }}" + REPO="${{ github.repository }}" - 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 + 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" - 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") + 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 - 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 - } + git add -f -A -- "${STAGE_PATHS[@]}" 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 +599,288 @@ 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 - uses: marocchino/sticky-pull-request-comment@v2 + 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" + + - 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' "$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" + 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" + + - 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: - header: ${{ steps.guard.outputs.sticky_header }} - number: ${{ steps.guard.outputs.pr_number }} - message: | - ### ${{ inputs.comment_header }} · free tier limit reached + name: codeboarding-review-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.review_artifact_inputs.outputs.artifact_dir }} + if-no-files-found: error - 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. + - 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 }} + ARTIFACT_URL: ${{ steps.upload_review_artifact.outputs.artifact-url }} + WEBVIEW_BASE_URL: ${{ inputs.webview_base_url }} + PR_NUMBER: ${{ steps.guard.outputs.pr_number }} + run: | + COMPONENT_NOUN="components" + if [ "$N_CHANGED" = "1" ]; then + COMPONENT_NOUN="component" + fi - 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. + 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 - See the [setup guide](https://github.com/CodeBoarding/CodeBoarding-action#more-usage) for the exact workflow snippet. + 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" - codeboarding-action · run ${{ github.run_id }} + - 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 }} + path: ${{ steps.review_body.outputs.path }} - # 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..7b23765 --- /dev/null +++ b/scripts/analyze_repository.py @@ -0,0 +1,170 @@ +#!/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 | None, dict]: + if not raw.strip(): + raise AnalysisError("CodeBoarding command produced no JSON output") + + try: + payload = json.loads(raw) + except json.JSONDecodeError as 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") + + 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(): + 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: + 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]: + 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") + 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 or ''}") + return 0 + + if not args.depth_level: + raise SystemExit("--depth-level is required for mode=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}", file=sys.stderr) + 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/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/scripts/render_sync_docs.py b/scripts/render_sync_docs.py new file mode 100755 index 0000000..0ff9d2f --- /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(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/scripts/run_local.sh index aae494f..dfba988 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,207 @@ 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 -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 + 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..e0783de --- /dev/null +++ b/tests/test_analyze_repository.py @@ -0,0 +1,175 @@ +"""Smoke tests for scripts/analyze_repository.py — JSON contract parsing and mode dispatch.""" + +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", + ) + 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_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_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_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) + 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(out_dir)), + "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_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) + checkout = root / "repo" + out_dir = root / "out" + checkout.mkdir() + out_dir.mkdir() + 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), + ] + ) + + +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_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() diff --git a/tests/test_render_sync_docs.py b/tests/test_render_sync_docs.py new file mode 100644 index 0000000..766b0f8 --- /dev/null +++ b/tests/test_render_sync_docs.py @@ -0,0 +1,105 @@ +"""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() 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()