diff --git a/.codeboarding/static_analysis.pkl b/.codeboarding/static_analysis.pkl deleted file mode 100644 index 62c36b1..0000000 Binary files a/.codeboarding/static_analysis.pkl and /dev/null differ diff --git a/.github/workflows/example-usage.yml b/.github/workflows/example-usage.yml deleted file mode 100644 index 1f75c83..0000000 --- a/.github/workflows/example-usage.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: Example Usage of CodeBoarding Action - -on: - workflow_dispatch: - inputs: - repository_url: - description: 'Repository URL to test with' - required: false - default: 'https://github.com/microsoft/markitdown' - type: string - source_branch: - description: 'Source branch for comparison' - required: false - default: 'main' - type: string - target_branch: - description: 'Target branch for comparison' - required: false - default: 'develop' - type: string - output_format: - description: 'Output format for documentation' - required: false - default: '.md' - type: choice - options: - - '.md' - - '.rst' - - pull_request: - branches: [ main, master ] - types: [opened, synchronize, reopened] - - schedule: - # Run daily at 2 AM UTC - - cron: '0 2 * * *' - -jobs: - update-docs-action-usage: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 # Required to access branch history - - # Determine branches based on context - - name: Set branch variables - id: set-branches - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "source_branch=${{ github.head_ref }}" >> $GITHUB_OUTPUT - echo "target_branch=${{ github.base_ref }}" >> $GITHUB_OUTPUT - elif [ "${{ github.event.inputs.source_branch }}" != "" ] && [ "${{ github.event.inputs.target_branch }}" != "" ]; then - echo "source_branch=${{ github.event.inputs.source_branch }}" >> $GITHUB_OUTPUT - echo "target_branch=${{ github.event.inputs.target_branch }}" >> $GITHUB_OUTPUT - else - # Default to current branch and main - echo "source_branch=${{ github.ref_name }}" >> $GITHUB_OUTPUT - echo "target_branch=main" >> $GITHUB_OUTPUT - fi - - - name: Fetch CodeBoarding Documentation - id: codeboarding - uses: ./ - with: - repository_url: ${{ github.event.inputs.repository_url }} - source_branch: ${{ steps.set-branches.outputs.source_branch }} - target_branch: ${{ steps.set-branches.outputs.target_branch }} - output_directory: 'docs' - output_format: ${{ github.event.inputs.output_format || '.md' }} - - - name: Display Action Results - run: | - echo "Documentation files created: ${{ steps.codeboarding.outputs.markdown_files_created }}" - echo "JSON files created: ${{ steps.codeboarding.outputs.json_files_created }}" - echo "Documentation directory: ${{ steps.codeboarding.outputs.output_directory }}" - echo "JSON directory: ${{ steps.codeboarding.outputs.json_directory }}" - echo "Has changes: ${{ steps.codeboarding.outputs.has_changes }}" - - # Check if we have any changes to commit - - name: Check for changes - id: git-changes - run: | - if [ -n "$(git status --porcelain)" ]; then - echo "has_git_changes=true" >> $GITHUB_OUTPUT - else - echo "has_git_changes=false" >> $GITHUB_OUTPUT - fi - - - name: Create Pull Request - if: steps.git-changes.outputs.has_git_changes == 'true' && steps.codeboarding.outputs.has_changes == 'true' - uses: peter-evans/create-pull-request@v5 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "docs: update codeboarding documentation" - title: "๐Ÿ“š CodeBoarding Documentation Update" - body: | - ## ๐Ÿ“š Documentation Update - - This PR contains updated documentation files fetched from the CodeBoarding service. - - ### ๐Ÿ“Š Summary - - **Documentation files created/updated**: ${{ steps.codeboarding.outputs.markdown_files_created }} - - **JSON files created/updated**: ${{ steps.codeboarding.outputs.json_files_created }} - - **Documentation directory**: `${{ steps.codeboarding.outputs.output_directory }}/` - - **JSON directory**: `${{ steps.codeboarding.outputs.json_directory }}/` - - **Source branch**: `${{ steps.set-branches.outputs.source_branch }}` - - **Target branch**: `${{ steps.set-branches.outputs.target_branch }}` - - **Output format**: `${{ github.event.inputs.output_format || '.md' }}` - - **Repository analyzed**: `${{ steps.codeboarding.outputs.repo_url }}` - - ### ๐Ÿ” Changes - Files have been updated with fresh documentation content based on code changes between branches. - - --- - - ๐Ÿค– This PR was automatically generated by the CodeBoarding documentation update workflow. - branch: docs/codeboarding-update - base: ${{ steps.set-branches.outputs.target_branch }} - delete-branch: true diff --git a/.github/workflows/release-major-tag.yml b/.github/workflows/release-major-tag.yml new file mode 100644 index 0000000..4b3c08b --- /dev/null +++ b/.github/workflows/release-major-tag.yml @@ -0,0 +1,41 @@ +name: Move major version tag + +# Marketplace actions are pinned by consumers as `@v1`, a *moving* major tag that +# should always point at the newest v1.x.x release. This re-points it on every +# published (non-pre) release, e.g. publishing v1.4.2 moves `v1` -> v1.4.2. +# +# First release is still manual (cut a `vX.Y.Z` release once); after that `vX` +# is maintained here automatically. + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + major-tag: + if: github.event.release.prerelease == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Re-point major tag at ${{ github.event.release.tag_name }} + env: + TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + ver="${TAG#v}" + if ! printf '%s' "$ver" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::notice::Tag '$TAG' is not a clean vMAJOR.MINOR.PATCH release (prerelease/suffix); skipping major-tag move." + exit 0 + fi + major="v${ver%%.*}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -fa "$major" -m "Update ${major} to ${TAG}" + git push origin "refs/tags/${major}" --force + echo "::notice::${major} now points at ${TAG}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ea9ddb1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + unittest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Run unit tests (stdlib only) + run: python -m unittest discover -s tests -v + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Install actionlint + run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + - name: Run actionlint + run: actionlint + - name: Run shellcheck + run: shellcheck scripts/run_local.sh diff --git a/.gitignore b/.gitignore index 865fddd..5fc1409 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,24 @@ test_response.json test_codeboarding/ +# Local test harness output (scripts/run_local.sh) +.cb-local/ + +# Dependencies +node_modules/ + +# Python generated files +__pycache__/ +*.py[cod] + +# CodeBoarding generated cache/log artifacts +.codeboarding/static_analysis.pkl +.codeboarding/static_analysis.sha +.codeboarding/logs/ +.codeboarding/health/* +!.codeboarding/health/ +!.codeboarding/health/health_report.json + # Environment files .env diff --git a/README.md b/README.md index 043c743..e2cb55e 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,193 @@
- CodeBoarding Logo - - # CodeBoarding [Diagram-First Documentation] - - [![GitHub Action](https://img.shields.io/badge/GitHub-Action-blue?logo=github-actions)](https://github.com/marketplace/actions/codeboarding-diagram-first-documentation) + CodeBoarding Logo + + # CodeBoarding Visual Architecture Review + + Visual system-design review for pull requests. CodeBoarding analyzes the architecture before and after a change, then comments on the PR with an inline Mermaid diagram showing what changed.
-Generates diagram-first visualizations of your codebase using static analysis and large language models. +## What It Does + +- Builds or reuses a baseline architecture analysis for the PR base. +- Runs incremental analysis on the PR head, then diffs components and relationships. +- Posts a sticky PR comment with an inline Mermaid map โ€” ๐ŸŸฉ added ยท ๐ŸŸจ modified ยท ๐ŸŸฅ deleted (dashed), for both nodes and edges. + +A PR comment looks like this: + +```mermaid +graph LR + Gateway["API Gateway"] + Auth["Auth Service"] + Cache["Cache"] + Gateway -- "routes to" --> Auth + Auth -- "reads/writes" --> Cache + classDef added fill:#1f883d,stroke:#0b5d23,color:#fff; + classDef modified fill:#bf8700,stroke:#7d4e00,color:#fff; + classDef deleted fill:#cf222e,stroke:#82071e,color:#fff,stroke-dasharray:5 3; + class Cache added; + class Auth modified; + class Gateway deleted; + linkStyle 0 stroke:#cf222e,stroke-width:2px,stroke-dasharray:5 3; + linkStyle 1 stroke:#1f883d,stroke-width:2px; +``` ## Usage +Create `.github/workflows/codeboarding.yml`: + ```yaml -name: Generate Documentation +name: CodeBoarding review + on: - push: - branches: [ main ] pull_request: - branches: [ main ] - types: [opened, synchronize, reopened] + # Generate ONCE, when the PR becomes reviewable โ€” not on every push, so you + # don't spend an LLM job per commit. Use [opened] for strictly creation-only, + # or add `synchronize` to re-run on each push. Refresh anytime with /codeboarding. + types: [opened, reopened, ready_for_review] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + issues: write + +concurrency: + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true jobs: - documentation: + review: runs-on: ubuntu-latest + timeout-minutes: 60 + if: > + (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || + (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: CodeBoarding/CodeBoarding-action@v1 with: - fetch-depth: 0 # Required to access branch history - - - name: Generate Documentation - uses: codeboarding/codeboarding-ghaction@v1 + llm_api_key: ${{ secrets.OPENROUTER_API_KEY }} +``` + +Add the API key as a repository secret (**Settings โ†’ Secrets and variables โ†’ Actions**): + +```text +OPENROUTER_API_KEY = sk-or-... +``` + +That's the only required setup โ€” it's passed via `llm_api_key` above. (For local runs with `scripts/run_local.sh`, export `OPENROUTER_API_KEY` as an env var instead.) + +**Models are optional.** Omit `agent_model` / `parsing_model` to use the engine's default for your provider, or pin them โ€” inline or from a repository **variable** (a model name isn't a secret, so use `vars.`, not `secrets.`): + +```yaml with: - repository_url: ${{ github.server_url }}/${{ github.repository }} - source_branch: ${{ github.head_ref || github.ref_name }} - target_branch: ${{ github.base_ref || 'main' }} - output_directory: 'docs' - output_format: '.md' - - - name: Upload Documentation - uses: actions/upload-artifact@v4 + llm_api_key: ${{ secrets.OPENROUTER_API_KEY }} # secret + agent_model: anthropic/claude-sonnet-4 # optional; or ${{ vars.AGENT_MODEL }} + parsing_model: google/gemini-3-flash-preview # optional +``` + +**Model format (OpenRouter):** a bare OpenRouter slug (e.g. `anthropic/claude-sonnet-4`) โ€” exactly one `/`, **no `openrouter/` prefix** (that's the LiteLLM form; the action rejects it early). Other providers use their own native model ids. + +## Bring your own LLM provider + +OpenRouter is the default, but you can use any provider the engine supports โ€” set `llm_provider` and pass that provider's key: + +```yaml with: - name: documentation - path: | - docs/ - .codeboarding/ + llm_provider: anthropic # omit for OpenRouter (default) + llm_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ``` +`llm_provider: ` hands your key to the engine as `_API_KEY`, and the engine auto-selects that provider. Set **exactly one** key per run. + +
Supported providers + +| `llm_provider` | env var the engine reads | +|---|---| +| `openrouter` *(default)* | `OPENROUTER_API_KEY` | +| `openai` | `OPENAI_API_KEY` | +| `anthropic` | `ANTHROPIC_API_KEY` | +| `google` | `GOOGLE_API_KEY` | +| `vercel` | `VERCEL_API_KEY` | +| `deepseek` | `DEEPSEEK_API_KEY` | +| `cerebras` | `CEREBRAS_API_KEY` | +| `glm` / `kimi` | `GLM_API_KEY` / `KIMI_API_KEY` | +| `aws_bedrock` | `AWS_BEARER_TOKEN_BEDROCK` | +| `ollama` | `OLLAMA_BASE_URL` | + +This table mirrors the engine and may lag it โ€” the source of truth is the engine's provider registry ([`agents/llm_config.py`](https://github.com/CodeBoarding/CodeBoarding/blob/main/agents/llm_config.py)). Any provider it adds that follows the `_API_KEY` convention works here with no action change. + +
+ +## When it runs + +- **PR opened / reopened / marked ready** โ€” generated once (per the `on:` triggers above). It does **not** re-run on every push, so you never spend an LLM job per commit; the comment reflects that point until refreshed. +- **`/codeboarding` comment** โ€” a trusted collaborator (`OWNER`/`MEMBER`/`COLLABORATOR`) regenerates the diagram against the **current** PR head, even if one already exists. It re-runs and updates the same comment in place (the action reacts with ๐Ÿ‘€). Change the keyword via `trigger_command`. + +The command needs the `issue_comment` trigger and runs from your **default branch** (GitHub's rule), so it only works once the workflow is merged there. On-demand runs on fork PRs are refused, so fork code is never analyzed with your secrets. + ## Inputs -| Input | Description | Required | Default | -|-------|-------------|----------|---------| -| `repository_url` | Repository URL for which documentation will be generated | Yes | - | -| `source_branch` | Source branch for comparison (typically the PR branch) | Yes | - | -| `target_branch` | Target branch for comparison (typically the base branch) | Yes | - | -| `output_directory` | Directory where documentation files will be saved | No | `docs` | -| `output_format` | Format for documentation files (either `.md` or `.rst`) | No | `.md` | +| Input | Default | Description | +|---|---|---| +| `llm_api_key` | required | Your LLM provider API key (see `llm_provider`). | +| `llm_provider` | `openrouter` | Provider for the key โ€” mapped to `_API_KEY` (e.g. `anthropic`, `openai`, `google`). | +| `github_token` | `${{ github.token }}` | Token used to post/update the PR comment. | +| `engine_ref` | `v0.12.0` | CodeBoarding engine ref. Pin for reproducibility. | +| `depth_level` | `1` | Analysis depth, 1 to 3. Higher is slower and richer. | +| `render_depth` | `1` | Display depth for the PR diagram. Keep `1` for a clean top-level view. | +| `diagram_direction` | `LR` | Mermaid direction: `LR`, `TD`, `TB`, `RL`, or `BT`. | +| `changed_only` | `false` | Render only changed components and incident edges. | +| `agent_model` | engine default | Analysis model. Bare OpenRouter slug (e.g. `anthropic/claude-sonnet-4`); empty = engine's per-provider default. | +| `parsing_model` | engine default | Parsing model. Bare OpenRouter slug; empty = engine's per-provider default. | +| `comment_header` | `Architecture review` | Heading for the PR comment. | +| `trigger_command` | `/codeboarding` | Slash command for trusted on-demand runs. | +| `cta_base_url` | empty | Optional click-proxy base URL for editor and extension links. | ## Outputs | Output | Description | -|--------|-------------| -| `markdown_files_created` | Number of documentation files created | -| `json_files_created` | Number of JSON files created | -| `output_directory` | Directory where documentation files were saved | -| `json_directory` | Directory where JSON files were saved (always `.codeboarding`) | -| `has_changes` | Whether any files were created or changed | +|---|---| +| `diagram_md` | Path to the generated Mermaid markdown block on the runner. | +| `n_changed` | Number of changed components, counted recursively. | +| `truncated` | `true` when the graph was reduced to fit GitHub Mermaid limits. | -## How It Works +## Notes -The action works by: +- No checkout step is required in your workflow. This action checks out the target PR and the CodeBoarding engine internally. +- GitHub withholds secrets from fork PRs on `pull_request`, so fork runs fail early if an LLM key is unavailable. +- Do not use `pull_request_target` for this action. It can expose secrets to PR-head code. +- GitHub renders Mermaid in strict mode, so node click-through links are not supported in the PR diagram. -1. Analyzing the differences introduced in the source branch and putting the results in the target branch -2. Generating documentation files based on the latest version of the source branch -3. Outputting two types of files: - - Documentation files (Markdown or RST) in the specified output directory - - Metadata files in the `.codeboarding` directory +## Local Testing -## License +Fast path, no LLM calls: -MIT License - see [LICENSE](LICENSE) file for details. +```bash +scripts/run_local.sh --base-json /tmp/base.json --head-json /tmp/head.json +``` -# CodeBoarding GitHub Action +Full local pipeline: -## Important: Timeout Configuration +```bash +export OPENROUTER_API_KEY=sk-or-... +scripts/run_local.sh --repo /path/to/repo --base --head \ + --engine /path/to/CodeBoarding +``` -For large repositories, the analysis can take 15-45 minutes. Make sure to configure appropriate timeouts in your workflow: +Useful flags: -```yaml -jobs: - generate-docs: - runs-on: ubuntu-latest - timeout-minutes: 60 # Set to 60+ minutes for large repositories - steps: - - uses: actions/checkout@v4 - - uses: your-username/codeboarding-ghaction@v1 - with: - # your inputs here +```text +--depth N +--render-depth N +--direction LR|TD|TB|RL|BT +--changed-only +--no-edge-labels +--out DIR +--no-open ``` -## Timeout Guidelines - -- **Small repositories** (<1k files): 10-15 minutes -- **Medium repositories** (1k-5k files): 20-30 minutes -- **Large repositories** (5k+ files): 30-60 minutes -- **Very large repositories** (10k+ files): 45-90 minutes +## License -If your workflow consistently times out, consider: -1. Increasing `timeout-minutes` to 90 or higher -2. Running the action on a schedule during off-peak hours -3. Analyzing specific branches with smaller diffs +MIT. See [LICENSE](LICENSE). diff --git a/action.yml b/action.yml index 6f17d1d..24bcf5f 100644 --- a/action.yml +++ b/action.yml @@ -1,447 +1,613 @@ -name: 'CodeBoarding [Diagram-First Documentation]' -description: 'Generates diagram-first visualizations of your codebase using static analysis and large language models.' +name: 'CodeBoarding Architecture Diff (Mermaid)' +description: 'Posts a PR comment with a Mermaid architecture diagram showing which components changed (green added / yellow modified / red deleted) โ€” nodes and arrows.' author: 'CodeBoarding' branding: - icon: 'book-open' # or 'layers', 'git-branch', 'book-open', 'target' + icon: 'git-pull-request' color: 'blue' inputs: - output_directory: - description: 'Directory where documentation files will be saved' - required: false - default: 'docs' - repository_url: - description: 'Repository URL to fetch documentation for (defaults to current repository)' - required: true - source_branch: - description: 'Source branch for comparison' - required: true - target_branch: - description: 'Target branch for comparison' + llm_api_key: + description: 'Your LLM provider API key (see llm_provider). Required.' required: true - output_format: - description: 'Output format for documentation files (.md, .mdx, .rst, or .html)' + 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.' + required: false + default: 'openrouter' + github_token: + description: 'GITHUB_TOKEN used to post the PR comment. Defaults to the workflow token.' + required: false + default: ${{ github.token }} + engine_ref: + description: 'Git ref (tag/branch/SHA) of CodeBoarding/CodeBoarding used as the analysis engine. Pinned to a release for reproducibility; override to track a newer ref.' + required: false + default: 'v0.12.0' + depth_level: + description: 'Diagram depth (1-3). Higher is slower and more detailed.' + required: false + default: '1' + agent_model: + description: 'Analysis model (AGENT_MODEL env var). A bare OpenRouter slug, e.g. anthropic/claude-sonnet-4. Empty (default) uses the engine''s own per-provider default.' + required: false + default: '' + parsing_model: + description: 'Parsing model (PARSING_MODEL env var). A bare OpenRouter slug. Empty (default) uses the engine''s own per-provider default.' + required: false + default: '' + comment_header: + description: 'Header line used inside the sticky PR comment.' + required: false + default: 'Architecture review' + diagram_direction: + description: 'Mermaid layout direction: LR, TD, TB, RL, or BT.' + required: false + default: 'LR' + changed_only: + description: '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: '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: '.md' + default: '1' + cta_base_url: + description: 'Base URL of the click proxy (e.g. https://go.codeboarding.org). When set, the comment adds "open in VS Code/Cursor" / "get the extension" links with owner/repo/pr appended. Empty disables the CTA.' + required: false + default: '' + trigger_command: + description: 'Slash-command that triggers the action from a PR comment (issue_comment event). A comment whose first word is this runs the diagram on-demand.' + required: false + default: '/codeboarding' outputs: - markdown_files_created: - description: 'Number of Markdown files created' - value: ${{ steps.process-docs.outputs.markdown_files_created }} - json_files_created: - description: 'Number of JSON files created' - value: ${{ steps.process-docs.outputs.json_files_created }} - output_directory: - description: 'Directory where Markdown files were saved' - value: ${{ steps.process-docs.outputs.output_directory }} - json_directory: - description: 'Directory where JSON files were saved (.codeboarding)' - value: ${{ steps.process-docs.outputs.json_directory }} - has_changes: - description: 'Whether any files were created or changed' - value: ${{ steps.process-docs.outputs.has_changes }} - repo_url: - description: 'Repository URL that was analyzed' - value: ${{ steps.repo-url.outputs.repo_url }} + diagram_md: + description: 'Path to the rendered ```mermaid block (in the runner workspace).' + value: ${{ steps.diagram.outputs.diagram_md }} + n_changed: + description: 'Number of components added/modified/deleted, counted recursively.' + value: ${{ steps.diagram.outputs.n_changed }} + truncated: + description: 'True if the diagram was reduced to changed-only to fit GitHub''s Mermaid limit.' + value: ${{ steps.diagram.outputs.truncated }} runs: using: 'composite' steps: - - name: Determine repository URL - id: repo-url + - name: Guard โ€” resolve the target PR + 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). + COMMENT_BODY: ${{ github.event.comment.body }} + AUTHOR_ASSOC: ${{ github.event.comment.author_association }} + TRIGGER: ${{ inputs.trigger_command }} + EVENT: ${{ github.event_name }} + REPOSITORY: ${{ github.repository }} + 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_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 }} run: | - # Use the provided repository URL if it's not empty - if [ -n "${{ inputs.repository_url }}" ]; then - REPO_URL="${{ inputs.repository_url }}" - echo "Using provided repository URL: $REPO_URL" - # Otherwise try to determine from git if we're in a git repository - elif git config --get remote.origin.url > /dev/null 2>&1; then - REPO_URL=$(git config --get remote.origin.url) - # Convert SSH URL to HTTPS if needed - if [[ $REPO_URL == git@* ]]; then - REPO_URL=$(echo $REPO_URL | sed 's|git@github.com:|https://github.com/|') - fi - echo "Using git remote URL: $REPO_URL" + set -uo pipefail + skip() { echo "::notice::$1 Skipping."; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } + + if [ "$EVENT" = "pull_request" ]; then + PR_NUMBER="$PR_NUMBER_PULL" + BASE_SHA="$PULL_BASE_SHA" + HEAD_SHA="$PULL_HEAD_SHA" + BASE_REF="$PULL_BASE_REF" + BASE_REPO="$PULL_BASE_REPO" + HEAD_REPO="$PULL_HEAD_REPO" + 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}')" + [ "$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." + 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." else - REPO_URL="${{ github.server_url }}/${{ github.repository }}" - echo "Using GitHub context URL: $REPO_URL" + skip "Unsupported event '$EVENT' (use pull_request or issue_comment)." fi - echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT - - name: Create and poll documentation job - id: fetch-docs + { [ -n "$PR_NUMBER" ] && [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ] && [ -n "$BASE_REPO" ] && [ -n "$HEAD_REPO" ]; } || skip "Could not resolve PR/base/head SHAs/repos." + { + echo "skip=false" + echo "pr_number=$PR_NUMBER" + echo "base_sha=$BASE_SHA" + echo "head_sha=$HEAD_SHA" + echo "base_ref=$BASE_REF" + echo "base_repo=$BASE_REPO" + echo "head_repo=$HEAD_REPO" + } >> "$GITHUB_OUTPUT" + echo "Resolved PR #$PR_NUMBER (base=$BASE_REPO@$BASE_SHA head=$HEAD_REPO@$HEAD_SHA) via $EVENT" + + - name: Acknowledge command + if: steps.guard.outputs.skip != 'true' && github.event_name == 'issue_comment' shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + REPOSITORY: ${{ github.repository }} + COMMENT_ID: ${{ github.event.comment.id }} run: | - CREATE_JOB_URL="https://server.codeboarding.org/github_action/jobs" - REPO_URL="${{ steps.repo-url.outputs.repo_url }}" - SOURCE_BRANCH="${{ inputs.source_branch }}" - TARGET_BRANCH="${{ inputs.target_branch }}" - OUTPUT_DIRECTORY="${{ inputs.output_directory }}" - OUTPUT_FORMAT="${{ inputs.output_format }}" - - echo "๐Ÿš€ Creating CodeBoarding analysis job...$CREATE_JOB_URL" - echo "๐Ÿ“Š Repository: $REPO_URL" - echo "๐ŸŒฟ Source branch: $SOURCE_BRANCH" - echo "๐ŸŽฏ Target branch: $TARGET_BRANCH" - echo "๐Ÿ“„ Output format: $OUTPUT_FORMAT" - - # Create JSON payload - JSON_PAYLOAD=$(jq -n \ - --arg url "$REPO_URL" \ - --arg source_branch "$SOURCE_BRANCH" \ - --arg target_branch "$TARGET_BRANCH" \ - --arg output_directory "$OUTPUT_DIRECTORY" \ - --arg extension "$OUTPUT_FORMAT" \ - '{ - url: $url, - source_branch: $source_branch, - target_branch: $target_branch, - output_directory: $output_directory, - extension: $extension - }') - - echo "๐Ÿ“‹ Request payload:" - echo "$JSON_PAYLOAD" - - # Create temporary file for response - TEMP_FILE=$(mktemp) - - echo "๐ŸŒ Making API request to create job..." - - # Make the API call to create job - response=$(curl -s -w "%{http_code}" -o "$TEMP_FILE" \ - -X POST \ - -H "Content-Type: application/json" \ - -d "$JSON_PAYLOAD" \ - --max-time 60 \ - --connect-timeout 30 \ - "$CREATE_JOB_URL") - curl_exit_code=$? - - http_code=${response: -3} - - echo "โœ… Job creation request completed!" - echo "๐Ÿ“‹ Response status code: $http_code" - echo "๐Ÿ”ง Curl exit code: $curl_exit_code" - - # Handle curl errors - if [ $curl_exit_code -ne 0 ]; then - echo "โŒ Error: Curl failed with exit code $curl_exit_code" - case $curl_exit_code in - 6) echo "๐ŸŒ Couldn't resolve host - check network connectivity" ;; - 7) echo "๐Ÿ”Œ Failed to connect to host - server might be down" ;; - 28) echo "โฐ Request timed out - server might be busy" ;; - *) echo "โ“ Unknown curl error - check network and server status" ;; - esac - rm -f "$TEMP_FILE" + # ๐Ÿ‘€ 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: Checkout CodeBoarding engine + if: steps.guard.outputs.skip != 'true' + uses: actions/checkout@v4 + with: + repository: CodeBoarding/CodeBoarding + ref: ${{ inputs.engine_ref }} + path: codeboarding-engine + persist-credentials: false + + - name: Checkout target repository (PR head) + if: steps.guard.outputs.skip != 'true' + uses: actions/checkout@v4 + with: + repository: ${{ steps.guard.outputs.head_repo }} + path: target-repo + fetch-depth: 0 + ref: ${{ steps.guard.outputs.head_sha }} + persist-credentials: false + + - name: Ensure PR base commit is fetched + if: steps.guard.outputs.skip != 'true' + shell: bash + working-directory: target-repo + env: + BASE_SHA: ${{ steps.guard.outputs.base_sha }} + BASE_REPO: ${{ steps.guard.outputs.base_repo }} + run: | + git fetch origin "$BASE_SHA" --depth=1 || 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=1 || true + fi + git cat-file -e "$BASE_SHA" && echo "Base commit reachable." || \ + (echo "::error::Base commit $BASE_SHA is not reachable." && exit 1) + + - name: Set up Python 3.13 + if: steps.guard.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Set up Node.js 20 + if: steps.guard.outputs.skip != 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install uv + if: steps.guard.outputs.skip != 'true' + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true # cache ~/.cache/uv (wheels/builds) for fast cold installs + + - name: Cache uv venv (engine) + if: steps.guard.outputs.skip != 'true' + uses: actions/cache@v4 + with: + path: codeboarding-engine/.venv + # No restore-keys: a lockfile change must force a clean cold venv, not + # restore a venv built from a different lock (`uv pip install -e .` won't + # downgrade/sync already-installed transitive deps back to this uv.lock). + key: cb-uv-${{ runner.os }}-${{ hashFiles('codeboarding-engine/pyproject.toml', 'codeboarding-engine/uv.lock') }} + + - name: Cache LSP servers + if: steps.guard.outputs.skip != 'true' + uses: actions/cache@v4 + with: + path: | + codeboarding-engine/static_analyzer/servers/node_modules + codeboarding-engine/static_analyzer/servers/bin + key: cb-lsp-${{ runner.os }}-v1 + restore-keys: | + cb-lsp-${{ runner.os }}- + + - name: Install Python dependencies + if: steps.guard.outputs.skip != 'true' + shell: bash + working-directory: codeboarding-engine + run: | + test -d .venv || uv venv # reuse the cached venv instead of wiping it (--clear defeated the cache) + uv pip install -e . + + - name: Install LSP servers + if: steps.guard.outputs.skip != 'true' + shell: bash + working-directory: codeboarding-engine + run: | + uv run python install.py --auto-install-npm + + - name: Prepare & verify LLM key + if: 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 }} + run: | + AUTH_FILE="${RUNNER_TEMP}/openrouter-auth.json" + trap 'rm -f "$AUTH_FILE"' EXIT + if [ -z "$RAW_KEY" ]; then + echo "::error::llm_api_key is empty. On fork PRs, repo secrets are withheld by GitHub." exit 1 fi - - if [ "$http_code" != "202" ]; then - echo "โŒ Error: Job creation failed with status code $http_code" - echo "๐Ÿ“„ Response content:" - cat "$TEMP_FILE" - - # Try to parse as JSON for better error message - if jq -e '.detail' "$TEMP_FILE" > /dev/null 2>&1; then - echo "๐Ÿ” Error details: $(jq -r '.detail' "$TEMP_FILE")" + # 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 whitespace/quotes and a leading `=`. + _strip() { printf '%s' "$1" | tr -d '[:space:]' | sed -e 's/^"//;s/"$//' -e "s/^'//;s/'\$//"; } + KEY="$(_strip "$RAW_KEY")" + case "$KEY" in "${PROVIDER_ENV}="*) KEY="${KEY#${PROVIDER_ENV}=}";; esac + KEY="$(_strip "$KEY")" + AGENT_MODEL="$(_strip "$RAW_AGENT_MODEL")" + PARSING_MODEL="$(_strip "$RAW_PARSING_MODEL")" + echo "::add-mask::$KEY" + echo "Provider: $PROVIDER -> $PROVIDER_ENV; key length: ${#KEY}" + + if [ "$PROVIDER" = "openrouter" ]; then + # 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 - - rm -f "$TEMP_FILE" - exit 1 fi - - # Check if response is valid JSON - if ! jq empty "$TEMP_FILE" 2>/dev/null; then - echo "โŒ Error: Invalid JSON response" - echo "๐Ÿ“„ Response content:" - cat "$TEMP_FILE" - rm -f "$TEMP_FILE" - exit 1 + + # Store key material in runner-temp files. Later shell steps read these + # explicitly; third-party post-comment actions do not inherit the LLM key. + umask 077 + 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" + + - name: Resolve base analysis (committed baseline) + if: steps.guard.outputs.skip != 'true' + id: base + shell: bash + working-directory: target-repo + env: + BASE_SHA: ${{ steps.guard.outputs.base_sha }} + 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 git show "${BASE_SHA}:.codeboarding/analysis.json" > "${BASE_DIR}/analysis.json" 2>/dev/null; then + echo "committed=true" >> $GITHUB_OUTPUT + echo "Using committed .codeboarding/analysis.json at ${BASE_SHA}." + else + rm -f "${BASE_DIR}/analysis.json" + echo "committed=false" >> $GITHUB_OUTPUT + echo "No committed baseline at ${BASE_SHA}; will generate one via a full analysis on the base commit." fi - - # Extract job_id from response - JOB_ID=$(jq -r '.job_id' "$TEMP_FILE") - - if [ "$JOB_ID" = "null" ] || [ -z "$JOB_ID" ]; then - echo "โŒ Error: No job_id found in response" - echo "๐Ÿ“„ Response content:" - cat "$TEMP_FILE" - rm -f "$TEMP_FILE" + + - name: Restore base artifacts (keyed by base SHA) + if: steps.guard.outputs.skip != 'true' + id: basecache + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/cb-base + key: cb-base-${{ runner.os }}-${{ steps.guard.outputs.base_sha }}-d${{ inputs.depth_level }}-${{ inputs.engine_ref }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }} + + - name: Generate base analysis (no committed baseline) + if: steps.guard.outputs.skip != 'true' && steps.base.outputs.committed == 'false' && steps.basecache.outputs.cache-hit != 'true' + shell: bash + working-directory: codeboarding-engine + env: + STATIC_ANALYSIS_CONFIG: ${{ github.workspace }}/codeboarding-engine/static_analysis_config.yml + PROJECT_ROOT: ${{ github.workspace }}/codeboarding-engine + DIAGRAM_DEPTH_LEVEL: ${{ inputs.depth_level }} + 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: ${{ inputs.depth_level }} + 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")" + # 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" + uv run python "$ACTION_PATH/scripts/cb_engine.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 - - echo "โœ… Job created successfully!" - echo "๐Ÿ†” Job ID: $JOB_ID" - - # Start polling job status - STATUS_URL="https://server.codeboarding.org/github_action/jobs/$JOB_ID" - - echo "๐Ÿ“Š Starting job status polling..." - echo "โฐ This may take 15-45 minutes for large repositories..." - echo "๐Ÿ’ก If your workflow times out, increase 'timeout-minutes' in your job configuration" - - # Polling loop - POLL_COUNT=0 - MAX_POLLS=90 # 90 minutes max (90 * 1 minute intervals) - - while [ $POLL_COUNT -lt $MAX_POLLS ]; do - POLL_COUNT=$((POLL_COUNT + 1)) - - echo "๐Ÿ” Polling attempt $POLL_COUNT of $MAX_POLLS ($(date '+%H:%M:%S'))" - - # Make status check API call - response=$(curl -s -w "%{http_code}" -o "$TEMP_FILE" \ - --max-time 30 \ - --connect-timeout 10 \ - "$STATUS_URL") - - curl_exit_code=$? - http_code=${response: -3} - - # Handle curl errors - if [ $curl_exit_code -ne 0 ]; then - echo "โš ๏ธ Warning: Status check failed with curl exit code $curl_exit_code" - echo "๐Ÿ”„ Retrying in 30 seconds..." - sleep 30 - continue - fi - - if [ "$http_code" != "200" ]; then - echo "โš ๏ธ Warning: Status check failed with HTTP code $http_code" - echo "๐Ÿ“„ Response content:" - cat "$TEMP_FILE" - echo "๐Ÿ”„ Retrying in 30 seconds..." - sleep 30 - continue - fi - - # Check if response is valid JSON - if ! jq empty "$TEMP_FILE" 2>/dev/null; then - echo "โš ๏ธ Warning: Invalid JSON response" - echo "๐Ÿ“„ Response content:" - cat "$TEMP_FILE" - echo "๐Ÿ”„ Retrying in 30 seconds..." - sleep 30 - continue - fi - - # Extract status from response - STATUS=$(jq -r '.status' "$TEMP_FILE") - - echo "๐Ÿ“Š Current job status: $STATUS" - - if [ "$STATUS" = "COMPLETED" ]; then - echo "โœ… Job completed successfully!" - - # Check if result field exists and contains files - if jq -e '.result' "$TEMP_FILE" > /dev/null; then - echo "๐Ÿ“ฆ Result field found, preparing output..." - - # Check if result is a JSON string or already a JSON object - RESULT_TYPE=$(jq -r '.result | type' "$TEMP_FILE") - - if [ "$RESULT_TYPE" = "string" ]; then - echo "๐Ÿ”ง Result is a JSON string, parsing it..." - # Parse the JSON string in the result field - jq -r '.result' "$TEMP_FILE" | jq '.' > "${TEMP_FILE}_result" - else - echo "๐Ÿ”ง Result is already a JSON object, extracting it..." - # Extract the result object directly - jq '.result' "$TEMP_FILE" > "${TEMP_FILE}_result" - fi - - # Verify the extracted result - if jq -e '.files' "${TEMP_FILE}_result" > /dev/null; then - echo "โœ… Files extracted successfully" - mv "${TEMP_FILE}_result" "$TEMP_FILE" - echo "response_file=$TEMP_FILE" >> $GITHUB_OUTPUT - exit 0 # Successfully extracted files, exit with success - else - echo "โŒ Error: Extracted result is missing files structure" - echo "๐Ÿ“„ Extracted content:" - cat "${TEMP_FILE}_result" - rm -f "${TEMP_FILE}_result" "$TEMP_FILE" - exit 1 - fi - else - echo "โŒ Error: Job completed but no result or result.files found in response" - echo "๐Ÿ“„ Response structure:" - jq '.' "$TEMP_FILE" - - # If result exists, show what it contains - if jq -e '.result' "$TEMP_FILE" > /dev/null; then - echo "๐Ÿ“„ Result field content:" - RESULT_TYPE=$(jq -r '.result | type' "$TEMP_FILE") - echo "Result type: $RESULT_TYPE" - - if [ "$RESULT_TYPE" = "string" ]; then - echo "Result string content:" - jq -r '.result' "$TEMP_FILE" - else - echo "Result object content:" - jq '.result' "$TEMP_FILE" - fi - fi - - rm -f "$TEMP_FILE" - exit 1 - fi - elif [ "$STATUS" = "FAILED" ] || [ "$STATUS" = "ERROR" ]; then - echo "โŒ Job failed with status: $STATUS" - echo "๐Ÿ“„ Response content:" - cat "$TEMP_FILE" - rm -f "$TEMP_FILE" - exit 1 - else - # Job still in progress - echo "โณ Job in progress (status: $STATUS)..." - - # Show additional progress information if available - if jq -e '.updated_at' "$TEMP_FILE" > /dev/null; then - UPDATED_AT=$(jq -r '.updated_at' "$TEMP_FILE") - echo "๐Ÿ• Last updated: $UPDATED_AT" - fi - - echo "๐Ÿ’ค Waiting 15 seconds before next check..." - sleep 15 - fi - done - - # Only reach here if we've exceeded max polls without completion - echo "โŒ Error: Job polling timed out after $MAX_POLLS attempts" - echo "๐Ÿ—๏ธ The repository analysis is taking longer than expected." - echo "๐Ÿ“Š This might be due to:" - echo " โ€ข Very large repository size (>10k files)" - echo " โ€ข Complex codebase requiring extensive analysis" - echo " โ€ข Server load or processing delays" - echo "" - echo "๐Ÿ’ก Suggestions:" - echo " โ€ข Try again later when server load might be lower" - echo " โ€ข Consider analyzing smaller branches or specific directories" - echo " โ€ข Increase your GitHub Actions job timeout-minutes to 120+" - echo " โ€ข Contact support if the issue persists" - - rm -f "$TEMP_FILE" - exit 1 - - - name: Process documentation files - id: process-docs + + - name: Save generated base artifacts + if: steps.guard.outputs.skip != 'true' && steps.base.outputs.committed == 'false' && steps.basecache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/cb-base + key: cb-base-${{ runner.os }}-${{ steps.guard.outputs.base_sha }}-d${{ inputs.depth_level }}-${{ inputs.engine_ref }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }} + + - name: Analyze PR head (incremental from base) + if: steps.guard.outputs.skip != 'true' + id: analyze shell: bash + working-directory: codeboarding-engine + env: + STATIC_ANALYSIS_CONFIG: ${{ github.workspace }}/codeboarding-engine/static_analysis_config.yml + PROJECT_ROOT: ${{ github.workspace }}/codeboarding-engine + DIAGRAM_DEPTH_LEVEL: ${{ inputs.depth_level }} + 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: ${{ inputs.depth_level }} + BASE_SHA: ${{ steps.guard.outputs.base_sha }} + HEAD_SHA: ${{ steps.guard.outputs.head_sha }} run: | - RESPONSE_FILE="${{ steps.fetch-docs.outputs.response_file }}" - MD_OUTPUT_DIR="${{ inputs.output_directory }}" - JSON_OUTPUT_DIR=".codeboarding" - OUTPUT_FORMAT="${{ inputs.output_format }}" - - # Validate output format - if [[ "$OUTPUT_FORMAT" != ".md" && "$OUTPUT_FORMAT" != ".mdx" && "$OUTPUT_FORMAT" != ".rst" && "$OUTPUT_FORMAT" != ".html" ]]; then - echo "Error: Invalid output format '$OUTPUT_FORMAT'. Must be either '.md', '.mdx', '.rst', or '.html'" + # 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")" + # 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" + uv run python "$ACTION_PATH/scripts/cb_engine.py" head \ + --repo "$TARGET_REPO" \ + --out "$HEAD_DIR" \ + --name "$REPO_NAME" \ + --run-id "$RUN_ID_HEAD" \ + --depth "$DEPTH" \ + --base-ref "$BASE_SHA" \ + --target-ref "$HEAD_SHA" \ + --source-sha "$HEAD_SHA" + if [ ! -f "$HEAD_DIR/analysis.json" ]; then + echo "::error::Head analysis ran but analysis.json is missing." exit 1 fi - - # Clean and create the output directories - mkdir -p "$MD_OUTPUT_DIR" - - # Remove existing .codeboarding files before adding new ones - if [ -d "$JSON_OUTPUT_DIR" ]; then - echo "Cleaning existing JSON files from $JSON_OUTPUT_DIR" - rm -rf "$JSON_OUTPUT_DIR" - fi - mkdir -p "$JSON_OUTPUT_DIR" - - # Initialize counters - MARKDOWN_FILES_CREATED=0 - JSON_FILES_CREATED=0 - - echo "=== Processing Documentation Files ===" - echo "Response JSON structure:" - jq . "$RESPONSE_FILE" - echo "Using output format: $OUTPUT_FORMAT" - # Parse JSON response and create files using keys as filenames - if jq -e '.files' "$RESPONSE_FILE" > /dev/null; then - echo "Files key found, proceeding to create files..." - - # Check if files object is empty - FILES_COUNT=$(jq '.files | length' "$RESPONSE_FILE") - if [ "$FILES_COUNT" -eq 0 ]; then - echo "โ„น๏ธ No documentation files were generated for this repository/branch combination." - echo "๐Ÿ“ This might be because:" - echo " โ€ข No changes were detected between the source and target branches" - echo " โ€ข The repository or branches don't exist or are not accessible" - echo " โ€ข No analyzable code files were found" - echo " โ€ข The branches are identical (no diff to analyze)" + 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' + id: health + continue-on-error: true + shell: bash + working-directory: codeboarding-engine + env: + STATIC_ANALYSIS_CONFIG: ${{ github.workspace }}/codeboarding-engine/static_analysis_config.yml + PROJECT_ROOT: ${{ github.workspace }}/codeboarding-engine + 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 + # cb_engine writes the WARNING/CRITICAL count (0 on any failure โ€” best-effort). + uv run python "$ACTION_PATH/scripts/cb_engine.py" health \ + --artifact-dir "$ARTIFACT_DIR" \ + --repo "$TARGET_REPO" \ + --name "$REPO_NAME" \ + --issues-out /tmp/cb-issues.txt || true + N=$(cat /tmp/cb-issues.txt 2>/dev/null || echo 0) + echo "issues=$N" >> $GITHUB_OUTPUT + echo "Architecture issues: $N" + + - name: Drop LLM key material + if: always() && steps.guard.outputs.skip != 'true' + shell: bash + run: | + rm -f "${RUNNER_TEMP}/cb-llm-key" \ + "${RUNNER_TEMP}/cb-provider-env" \ + "${RUNNER_TEMP}/cb-agent-model" \ + "${RUNNER_TEMP}/cb-parsing-model" + + - name: Diff analyses โ†’ Mermaid + if: steps.guard.outputs.skip != 'true' + 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 N CHANGED RENDERED TRUNC < <(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=$N" >> $GITHUB_OUTPUT + echo "changed=$CHANGED" >> $GITHUB_OUTPUT + echo "rendered=$RENDERED" >> $GITHUB_OUTPUT + echo "truncated=$TRUNC" >> $GITHUB_OUTPUT + echo "diagram_md=${RUNNER_TEMP}/diagram.md" >> $GITHUB_OUTPUT + + - name: Build PR comment body + if: steps.guard.outputs.skip != 'true' + 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 }} + RUN_ID: ${{ github.run_id }} + N: ${{ steps.diagram.outputs.n_changed }} + CHANGED: ${{ steps.diagram.outputs.changed }} + RENDERED: ${{ steps.diagram.outputs.rendered }} + TRUNC: ${{ steps.diagram.outputs.truncated }} + PR: ${{ steps.guard.outputs.pr_number }} + ISSUES: ${{ steps.health.outputs.issues }} + run: | + BODY_FILE=$(mktemp) + OWNER="${OWNER_REPO%%/*}"; REPO="${OWNER_REPO##*/}" + + headline() { + if [ "$CHANGED" != "true" ]; then echo "no architectural changes"; + elif [ "$N" = "1" ]; then echo "1 component changed"; + elif [ "$N" = "0" ]; then echo "architecture updated"; + else echo "$N components changed"; fi + } + + # CTA footer (editor + extension links via the click proxy, warning banner + # on real health findings). build_cta also emits the โš ๏ธ banner with no proxy. + cta() { + python3 "$ACTION_PATH/scripts/build_cta.py" \ + --cta-base "$CTA_BASE" --owner "$OWNER" --repo "$REPO" --pr "$PR" \ + --repo-path "$TARGET_REPO" --issues "${ISSUES:-0}" + } + + { + echo "### ${HEADER} ยท $(headline)" + echo "" + if [ "$RENDERED" = "true" ]; then + cat "$DIAGRAM_MD" + echo "" + echo "" + echo "Colors indicate component changes compared to \`${BASE_REF}\`: ๐ŸŸฉ Added ยท ๐ŸŸจ Modified ยท ๐ŸŸฅ Removed" + if [ "$TRUNC" = "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 \`${BASE_REF}\`, but the diagram is too large to render inline (GitHub caps inline Mermaid at ~500 edges)." else - # Get each key from files object and create a file with that name - while IFS= read -r filename; do - echo "Processing file: $filename" - - # Get the content for this filename - content=$(jq -r ".files[\"$filename\"]" "$RESPONSE_FILE") - - # Determine file type and destination - if [[ "$filename" == *.json ]]; then - # JSON file - output_dir="$JSON_OUTPUT_DIR" - output_filename="$filename" - echo "$content" > "$output_dir/$output_filename" - echo "Created JSON file: $output_dir/$output_filename" - JSON_FILES_CREATED=$((JSON_FILES_CREATED + 1)) - else - # Documentation file - add appropriate extension if not present - output_dir="$MD_OUTPUT_DIR" - - # Check if filename has an extension - if [[ "$filename" == *.* ]]; then - # Extract basename without extension - basename="${filename%.*}" - else - basename="$filename" - fi - - # Add the selected output format extension - output_filename="${basename}${OUTPUT_FORMAT}" - - echo "$content" > "$output_dir/$output_filename" - echo "Created documentation file: $output_dir/$output_filename" - MARKDOWN_FILES_CREATED=$((MARKDOWN_FILES_CREATED + 1)) - fi - done < <(jq -r '.files | keys[]' "$RESPONSE_FILE") + echo "No architectural changes detected versus \`${BASE_REF}\`." fi - else - echo "No 'files' key found in response JSON - checking if job completed with no results" - fi - - # Clean up temporary file - rm -f "$RESPONSE_FILE" - - # Check if any files were created - TOTAL_FILES=$((MARKDOWN_FILES_CREATED + JSON_FILES_CREATED)) - if [ "$TOTAL_FILES" -gt 0 ]; then - HAS_CHANGES="true" - echo "Created $MARKDOWN_FILES_CREATED Markdown files in $MD_OUTPUT_DIR" - echo "Created $JSON_FILES_CREATED JSON files in $JSON_OUTPUT_DIR" - - # List created files - if [ "$MARKDOWN_FILES_CREATED" -gt 0 ]; then - echo "Markdown files created:" - ls -la "$MD_OUTPUT_DIR" - fi - - if [ "$JSON_FILES_CREATED" -gt 0 ]; then - echo "JSON files created:" - ls -la "$JSON_OUTPUT_DIR" - fi - else - HAS_CHANGES="false" - echo "No files were created" - fi - - # Set outputs - echo "markdown_files_created=$MARKDOWN_FILES_CREATED" >> $GITHUB_OUTPUT - echo "json_files_created=$JSON_FILES_CREATED" >> $GITHUB_OUTPUT - echo "output_directory=$MD_OUTPUT_DIR" >> $GITHUB_OUTPUT - echo "json_directory=$JSON_OUTPUT_DIR" >> $GITHUB_OUTPUT - echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT \ No newline at end of file + cta + echo "" + echo "codeboarding-action ยท run ${RUN_ID}" + } > "$BODY_FILE" + + echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT" + + - name: Post sticky PR comment + if: steps.guard.outputs.skip != 'true' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: codeboarding-architecture-diff + number: ${{ steps.guard.outputs.pr_number }} + path: ${{ steps.body.outputs.body_file }} + GITHUB_TOKEN: ${{ inputs.github_token }} + + # 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. + - name: Post failure comment + if: failure() && steps.guard.outputs.skip != 'true' + continue-on-error: true + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: codeboarding-architecture-diff + number: ${{ steps.guard.outputs.pr_number }} + 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. + + codeboarding-action ยท run ${{ github.run_id }} + GITHUB_TOKEN: ${{ inputs.github_token }} diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md new file mode 100644 index 0000000..8acaea2 --- /dev/null +++ b/docs/COMMIT_STRATEGY.md @@ -0,0 +1,52 @@ +# Baseline & artifact commit strategy + +What CodeBoarding writes into a repo, what we commit vs. cache, where, and how +today's choice keeps a future hosted-webview viewer possible without rework. + +## The artifacts + +The engine writes these under `.codeboarding/`: + +| File | Type | Size | Purpose | +|---|---|---|---| +| `analysis.json` | JSON (text) | KBโ€“low MB | The component graph โ€” **the diagram source** | +| `health/health_report.json` | JSON (text) | KB | Health findings โ†’ **the warnings** | +| `static_analysis.pkl` | binary pickle | MB-scale | LSP/CFG cache โ†’ **warm-start** (re-LSP only changed files) | +| `static_analysis.sha` | text (1 line) | bytes | Tag recording the pkl's commit โ†’ the warm-start gate | + +## Decision + +**Commit (text, small, display-critical):** +- โœ… `analysis.json` โ€” required for the extension (and later the webview) to **show the diagram instantly without regenerating** โ€” i.e. without spending the user's API key. It's text and diffs meaningfully. +- โœ… `health/health_report.json` โ€” required for warnings in the extension/webview. Small text. + +**Do NOT commit (binary, bloat):** +- โŒ `static_analysis.pkl` โ€” binary, MB-scale, noisy diffs, repo bloat. It is a *rebuildable speed cache*, not display data. Keep it in **`actions/cache` keyed by the base SHA** (or a backend). A cache miss just falls back to a cold (full) LSP pass โ€” slower but correct, and the committed `analysis.json` still drives the diagram. +- `static_analysis.sha` โ€” commit **only** if the pkl is kept reachable (cache/backend); on its own it's harmless but unused. + +> **Principle:** version-control the *source-of-truth display data* (text, small); *cache* the *rebuildable speed artifacts* (binary, large). This is exactly what keeps the repo clean โ€” the thing that bloats (`.pkl`) never enters git. + +## Where to commit โ€” two separate workflows + +1. **CI/CD on `main` (the baseline keeper).** On push to `main`, regenerate and commit `analysis.json` + `health/health_report.json` to `main`. Keeps the baseline current so PRs diff against an accurate, up-to-date snapshot and the extension shows a real diagram on the default branch. + +2. **The review action (PR).** **Comment-only by default** โ€” no commits to contributors' branches (no churn, and it still works on fork PRs where the token is read-only). The PR comment leads users to the extension. + - *Optional later:* commit the head `analysis.json` to the PR branch so opening the extension on that PR shows the exact head diagram. Deferred โ€” it pushes a bot commit to the contributor's branch and can't run on fork PRs. + +## Now vs. later + +- **Now โ€” extension-direct.** Committing `analysis.json` + `health_report.json` on `main` means a user who installs the extension and opens the repo sees the committed diagram + warnings **instantly, with no API key**. The PR comment's CTA points straight at the extension (install / open in editor). +- **Later โ€” hosted webview.** The webview needs the **same** committed `analysis.json` (+ a diff + health). So committing now is **forward-compatible**: when the viewer is built, the data already exists at each commit โ€” no migration, just a host layer that reads it. + +## Warm-start tradeoff (the `.pkl`) + +The warm-start needs the pkl **and** its `.sha`. When the review action has to generate a base analysis, it saves that generated base artifact directory in `actions/cache` keyed by base SHA / depth / engine ref, then seeds the head analysis from that directory. When a committed `analysis.json` already exists but no matching cache exists, the PR still diffs correctly but may run a cold LSP pass. This keeps the repo clean; the cache improves speed but is not required for correctness. + +## Summary + +| Artifact | Commit? | Where | Why | +|---|---|---|---| +| `analysis.json` | โœ… | `main` (CI/CD); PR branch optional/later | diagram source; powers extension now + webview later | +| `health_report.json` | โœ… | with `analysis.json` | warnings | +| `static_analysis.pkl` | โŒ | `actions/cache` (or backend), key = base SHA | binary speed cache; never bloat git | +| `static_analysis.sha` | โš ๏ธ optional | with the cached pkl | warm-start gate; useless without the pkl | diff --git a/scripts/build_cta.py b/scripts/build_cta.py new file mode 100644 index 0000000..553c06b --- /dev/null +++ b/scripts/build_cta.py @@ -0,0 +1,100 @@ +"""Build the call-to-action footer appended to the architecture-diff PR comment. + +The footer links into CodeBoarding's click proxy (so owner/repo/pr are tracked) +and currently drives straight to the VS Code/Cursor **extension**: an "open this +architecture in your editor" link (editor-specific) plus an "install the +extension" link, and a warning banner when real health findings exist. A +no-install hosted-webview ("explore in browser") tier is intentionally deferred +(see docs/COMMIT_STRATEGY.md) โ€” the committed analysis already supports it later. + +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"} + + +def build_cta(cta_base: str, owner: str, repo: str, pr: str, repo_path: Path, issues: int = 0) -> str: + """Return the markdown CTA footer (the warning banner shows even without a proxy URL). + + The โš ๏ธ health banner is informational and needs no proxy, so it renders + whenever ``issues > 0``; the editor/marketplace links require ``cta_base``. + Returns '' only when there's nothing to show. + """ + 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.") + + 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_links = " ยท ".join( + f"[**Open in {_EDITOR_LABEL[e]} โ†’**]({link('open-in-editor', editor=e)})" for e in detect_editors(repo_path) + ) + parts.append(f"See this architecture in your editor: {editor_links}") + parts.append(f"๐Ÿ’ก New to CodeBoarding? [**Get the extension โ†’**]({link('use-marketplace')})") + + if not parts: + return "" + 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)") + 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)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cb_engine.py b/scripts/cb_engine.py new file mode 100644 index 0000000..842fb69 --- /dev/null +++ b/scripts/cb_engine.py @@ -0,0 +1,173 @@ +"""Engine orchestration for the action โ€” extracted from inline ``python -c`` blocks +in action.yml so it is checked in, reviewable, and unit-testable. + +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 + head --repo P --out D --name N --run-id ID --depth K --base-ref B --target-ref T --source-sha SHA + health --artifact-dir D --repo P --name N --issues-out FILE + +``base`` runs a full analysis; ``head`` runs incremental, falling back to full on +``IncrementalCacheMissingError``/``BaselineUnavailableError``; ``health`` writes the +WARNING/CRITICAL finding count to ``--issues-out`` (and never fails the run). + +The engine (``codeboarding_workflows`` etc.) is imported lazily inside each +function so this module imports without the engine venv present โ€” the tests stub +those modules and assert we call the engine with the right arguments. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path + + +def _log_path(out: str, name: str) -> str: + return str(Path(out) / name) + + +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 run_base(repo: str, out: str, name: str, run_id: str, depth: int, source_sha: str) -> None: + from codeboarding_workflows.analysis import run_full + + res = run_full( + repo_name=name, + repo_path=Path(repo), + output_dir=Path(out), + run_id=run_id, + log_path=_log_path(out, "cb-base.log"), + depth_level=depth, + source_sha=source_sha, + ) + print(f"Base analysis written: {res}") + + +def run_head(repo: str, out: str, name: str, run_id: str, depth: int, base_ref: str, target_ref: str, source_sha: str) -> None: + from codeboarding_workflows.analysis import BaselineUnavailableError, run_full, run_incremental + from diagram_analysis.exceptions import IncrementalCacheMissingError + + try: + res = run_incremental( + repo_path=Path(repo), + output_dir=Path(out), + project_name=name, + run_id=run_id, + log_path=_log_path(out, "cb-head.log"), + base_ref=base_ref, + target_ref=target_ref, + source_sha=source_sha, + ) + except (IncrementalCacheMissingError, BaselineUnavailableError) as exc: + print(f"Incremental unavailable ({exc}); running full analysis on head.") + _clear_dir(Path(out)) + res = run_full( + repo_name=name, + repo_path=Path(repo), + output_dir=Path(out), + run_id=run_id, + log_path=_log_path(out, "cb-head.log"), + depth_level=depth, + source_sha=source_sha, + ) + print(f"Head analysis written: {res}") + + +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: str, 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 + + try: + from health.models import Severity + from health.runner import run_health_checks + from static_analyzer.analysis_cache import StaticAnalysisCache + except Exception as exc: # engine without the health module + print(f"Health check skipped ({exc}).") + return 0 + try: + cache = StaticAnalysisCache(artifact_dir=Path(artifact_dir), repo_root=Path(repo)) + sa = cache.get() + issues = 0 + if sa is not None: + report = run_health_checks(sa, repo_name=name, repo_path=Path(repo)) + 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) + + 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=range(1, 4)) + + h = sub.add_parser("head") + for a in ("--repo", "--out", "--name", "--run-id", "--base-ref", "--target-ref", "--source-sha"): + h.add_argument(a, required=True) + h.add_argument("--depth", required=True, type=int, choices=range(1, 4)) + + hc = sub.add_parser("health") + for a in ("--artifact-dir", "--repo", "--name", "--issues-out"): + hc.add_argument(a, required=True) + + args = p.parse_args(argv) + if args.cmd == "base": + run_base(args.repo, args.out, args.name, args.run_id, args.depth, args.source_sha) + elif args.cmd == "head": + run_head(args.repo, args.out, args.name, args.run_id, args.depth, args.base_ref, args.target_ref, args.source_sha) + elif args.cmd == "health": + Path(args.issues_out).write_text(str(run_health(args.artifact_dir, args.repo, args.name))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/diff_to_mermaid.py b/scripts/diff_to_mermaid.py new file mode 100644 index 0000000..0696d3f --- /dev/null +++ b/scripts/diff_to_mermaid.py @@ -0,0 +1,554 @@ +"""Diff two CodeBoarding analysis.json files and render the delta as a colored Mermaid graph. + +Reads a *base* (before) and *head* (after) ``analysis.json`` โ€” both already +materialized on disk by the engine โ€” computes a component/relation diff, and +emits a GitHub-renderable ```mermaid block where: + + * nodes are colored green=added / yellow=modified / red=deleted (deleted dashed) + * arrows are colored the same way (red dashed for deleted) + +GitHub renders ```mermaid fenced blocks natively inside PR/issue comments, so the +output goes straight into the sticky comment โ€” no image, no Playwright. + +The diff set-arithmetic is a port of the action's ``compute_diff.py``, with two +differences for this use case: both sides are read from plain file paths (not +``git show``), and a relation whose ``(src, dst)`` is unchanged but whose label +text changed is reported as ``modified`` (the original only did added/deleted). + +Self-contained stdlib. +""" + +from __future__ import annotations + +import argparse +from collections import defaultdict +import json +import re +import sys +from pathlib import Path + +# GitHub's mermaid config caps (config.schema.yaml defaults; NOT raisable on +# GitHub). Exceeding either renders a red error box with no diagram, so we stay +# comfortably under and degrade to a changed-only / text fallback instead. +MAX_EDGES = 480 # hard cap 500 +MAX_TEXT = 45_000 # hard cap 50000 chars + +# Primer-ish fills that read on both light and dark GitHub backgrounds. White +# label text is set explicitly so it survives dark mode. +COLORS = { + "added": {"fill": "#1f883d", "stroke": "#0b5d23"}, + "modified": {"fill": "#bf8700", "stroke": "#7d4e00"}, + "deleted": {"fill": "#cf222e", "stroke": "#82071e"}, +} +CHANGED = ("added", "modified", "deleted") +_EDGE_LABEL_MAX = 48 + + +# --------------------------------------------------------------------------- # +# load +# --------------------------------------------------------------------------- # +def load_analysis(path: Path) -> dict: + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + sys.exit(f"::error::Could not read analysis JSON at {path}: {exc}") + + +# --------------------------------------------------------------------------- # +# diff (ported from compute_diff.py; relation diff extended with 'modified') +# --------------------------------------------------------------------------- # +def _comp_id(c: dict) -> str: + return c.get("component_id") or c.get("name", "") + + +def _comp_name(c: dict) -> str: + return c.get("name", "") + + +def _file_methods(c: dict) -> list: + return c.get("file_methods") or [] + + +def _methods_by_file(c: dict) -> dict: + by_file: dict = {} + for fm in _file_methods(c): + fp = fm.get("file_path") or "" + names = {m for m in (fm.get("methods") or []) if isinstance(m, str)} + if names: + by_file.setdefault(fp, set()).update(names) + return by_file + + +def _has_structural_changes(base: dict, current: dict) -> bool: + base_files = {fm.get("file_path", "") for fm in _file_methods(base)} + current_files = {fm.get("file_path", "") for fm in _file_methods(current)} + return base_files != current_files or len(base.get("components") or []) != len(current.get("components") or []) + + +def _has_method_changes(base: dict, current: dict) -> bool: + base_by_file = _methods_by_file(base) + current_by_file = _methods_by_file(current) + return any( + base_by_file.get(fp, set()) != current_by_file.get(fp, set()) + for fp in set(base_by_file) | set(current_by_file) + ) + + +def _rel_key(r: dict) -> tuple: + # Name is the stable join across two independent analyses; component ids are + # positional and can be reshuffled on a full re-run, so prefer names. + return (r.get("src_name") or r.get("src_id") or "", r.get("dst_name") or r.get("dst_id") or "") + + +def _diff_relations(base_rels: list, current_rels: list) -> list: + base_by_endpoint: dict = defaultdict(list) + current_by_endpoint: dict = defaultdict(list) + for rel in base_rels or []: + base_by_endpoint[_rel_key(rel)].append(rel) + for rel in current_rels or []: + current_by_endpoint[_rel_key(rel)].append(rel) + + result: list = [] + keys = list(current_by_endpoint) + keys.extend(k for k in base_by_endpoint if k not in current_by_endpoint) + for key in keys: + base_group = base_by_endpoint.get(key, []) + current_group = current_by_endpoint.get(key, []) + if not base_group: + result.extend({**rel, "diff_status": "added"} for rel in current_group) + continue + if not current_group: + result.extend({**rel, "diff_status": "deleted"} for rel in base_group) + continue + + if len(base_group) == 1 and len(current_group) == 1: + status = "unchanged" if (base_group[0].get("relation") or "") == (current_group[0].get("relation") or "") else "modified" + result.append({**current_group[0], "diff_status": status}) + continue + + unmatched_base = list(base_group) + unmatched_current = [] + for rel in current_group: + label = rel.get("relation") or "" + match_idx = next((i for i, b in enumerate(unmatched_base) if (b.get("relation") or "") == label), None) + if match_idx is None: + unmatched_current.append(rel) + else: + unmatched_base.pop(match_idx) + result.append({**rel, "diff_status": "unchanged"}) + + if len(unmatched_base) == 1 and len(unmatched_current) == 1: + result.append({**unmatched_current[0], "diff_status": "modified"}) + else: + result.extend({**rel, "diff_status": "added"} for rel in unmatched_current) + result.extend({**rel, "diff_status": "deleted"} for rel in unmatched_base) + return result + + +def _has_changes(components: list, relations: list) -> bool: + if any(r.get("diff_status") in CHANGED for r in relations or []): + return True + for comp in components or []: + if comp.get("diff_status") in CHANGED: + return True + if _has_changes(comp.get("components") or [], comp.get("components_relations") or []): + return True + return False + + +def _diff_components(base_components: list, current_components: list) -> list: + base = base_components or [] + current = current_components or [] + base_by_name = {_comp_name(c): c for c in base} # name is the stable cross-analysis join + matched_names: set = set() + result: list = [] + + for comp in current: + base_match = base_by_name.get(_comp_name(comp)) + if base_match is None: + result.append({**comp, "diff_status": "added"}) + continue + matched_names.add(_comp_name(base_match)) + structural = _has_structural_changes(base_match, comp) + diff_status = "modified" if (structural or _has_method_changes(base_match, comp)) else "unchanged" + + annotated = {**comp, "diff_status": diff_status} + + base_subs = base_match.get("components") or [] + current_subs = comp.get("components") or [] + if base_subs or current_subs: + annotated["components"] = _diff_components(base_subs, current_subs) + + base_sub_rels = base_match.get("components_relations") or [] + current_sub_rels = comp.get("components_relations") or [] + if base_sub_rels or current_sub_rels: + annotated["components_relations"] = _diff_relations(base_sub_rels, current_sub_rels) + + if diff_status == "unchanged" and _has_changes( + annotated.get("components") or [], + annotated.get("components_relations") or [], + ): + annotated["display_status"] = "modified" + + result.append(annotated) + + for comp in base: + if _comp_name(comp) not in matched_names: + # Keep the subtree: a deleted parent's children/relations render as a + # deleted subgraph (the renderer forces 'deleted' down), mirroring how + # an added parent renders its whole subtree. + ghost = {k: v for k, v in comp.items() if k != "can_expand"} + ghost["diff_status"] = "deleted" + result.append(ghost) + + return result + + +def build_diff(base: dict, head: dict) -> dict: + return { + "components": _diff_components(base.get("components") or [], head.get("components") or []), + "components_relations": _diff_relations( + base.get("components_relations") or [], + head.get("components_relations") or [], + ), + } + + +# --------------------------------------------------------------------------- # +# mermaid emit +# --------------------------------------------------------------------------- # +def _sanitize(name: str) -> str: + """Match the engine's node-id sanitization (utils.sanitize).""" + return re.sub(r"\W+", "_", name or "") + + +# Mermaid label metacharacters โ†’ numeric/named char-refs (the ``#NNN;`` form +# GitHub's strict renderer accepts). A bare ``]`` / ``)`` / ``}`` terminates a +# node label and breaks the whole diagram, so escape the shape chars too โ€” not +# just ``#`` and ``"``. +_ESC_MAP = { + "&": "#amp;", '"': "#quot;", "<": "#lt;", ">": "#gt;", + "[": "#91;", "]": "#93;", "(": "#40;", ")": "#41;", + "{": "#123;", "}": "#125;", "|": "#124;", +} + + +def _esc(text: str) -> str: + """Escape arbitrary text for a Mermaid label under GitHub's strict renderer.""" + out = (text or "").replace("\n", " ").replace("\r", " ").strip() + out = out.replace("#", "#35;") # first: literal '#'; the entities below add their own '#' + for ch, ent in _ESC_MAP.items(): + out = out.replace(ch, ent) + return out + + +def _truncate(text: str, limit: int = _EDGE_LABEL_MAX) -> str: + text = (text or "").strip() + return text if len(text) <= limit else text[: limit - 1].rstrip() + "โ€ฆ" + + +def _display_status(comp: dict, force: str | None = None) -> str: + return force or comp.get("display_status") or comp.get("diff_status", "unchanged") + + +class _Scope: + """Per-level name/id -> mermaid key resolver for one nesting level. + + Deleted ghosts get a separate ``del_`` key namespace from present nodes so a + reused id/name can't merge an added node onto a deleted one. Keys are made + globally unique via the shared ``used`` set. Resolution is name-first (the + stable cross-analysis join); present edges resolve head-first, deleted edges + ghost-first. ``force`` overrides the per-component diff_status (used when a + wholly-added/deleted parent colors its whole subtree). + """ + + def __init__(self, components: list, used: set, force: str | None = None): + self.entries: list = [] # (key, label, status, component) + self.head_by_id: dict = {} + self.head_by_name: dict = {} + self.del_by_id: dict = {} + self.del_by_name: dict = {} + for comp in components: + status = _display_status(comp, force) + present = status != "deleted" + cid, cname = _comp_id(comp), _comp_name(comp) + base = ("n_" if present else "del_") + _sanitize(cname or cid or "node") + key, n = base, 1 + while key in used: + n += 1 + key = f"{base}_{n}" + used.add(key) + self.entries.append((key, cname or cid or "(unnamed)", status, comp)) + by_id = self.head_by_id if present else self.del_by_id + by_name = self.head_by_name if present else self.del_by_name + if cname: + by_name[cname] = key + if cid: + by_id[cid] = key + + def resolve(self, rid: str, rname: str, present: bool) -> str | None: + maps = [(self.head_by_id, self.head_by_name), (self.del_by_id, self.del_by_name)] + if not present: + maps.reverse() + for by_id, by_name in maps: + if rname and rname in by_name: # name-first: stable cross-analysis join + return by_name[rname] + if rid and rid in by_id: + return by_id[rid] + return None + + +def _filter_changed(components: list, relations: list) -> tuple: + """Keep changed components, changed-edge endpoints, ancestors, and edges among the kept.""" + changed_rels = [r for r in relations if r.get("diff_status") in CHANGED] + keep_ids: set = set() + keep_names: set = set() + filtered_children: dict[int, tuple] = {} + for c in components: + child_components, child_relations = _filter_changed( + c.get("components") or [], + c.get("components_relations") or [], + ) + filtered_children[id(c)] = (child_components, child_relations) + if _display_status(c) in CHANGED or child_components or child_relations: + keep_ids.add(_comp_id(c)) + keep_names.add(_comp_name(c)) + for r in changed_rels: # so a changed edge between two unchanged nodes still draws its endpoints + keep_ids.update(x for x in (r.get("src_id", ""), r.get("dst_id", "")) if x) + keep_names.update(x for x in (r.get("src_name", ""), r.get("dst_name", "")) if x) + keep_ids.discard("") + keep_names.discard("") + + kept = [] + for c in components: + if not ((_comp_id(c) and _comp_id(c) in keep_ids) or (_comp_name(c) and _comp_name(c) in keep_names)): + continue + child_components, child_relations = filtered_children[id(c)] + status = _display_status(c) + if child_components or child_relations or status == "modified": + c = {**c, "components": child_components, "components_relations": child_relations} + kept.append(c) + kept_ids = {_comp_id(c) for c in kept if _comp_id(c)} + kept_names = {_comp_name(c) for c in kept if _comp_name(c)} + + def touches(r: dict, side_id: str, side_name: str) -> bool: + rid, rname = r.get(side_id, ""), r.get(side_name, "") + return bool((rid and rid in kept_ids) or (rname and rname in kept_names)) + + rels = [ + r + for r in relations + if r.get("diff_status") in CHANGED + or (touches(r, "src_id", "src_name") and touches(r, "dst_id", "dst_name")) + ] + return kept, rels + + +def _init_directive(font_size, node_padding, node_spacing, rank_spacing) -> str | None: + """Build a Mermaid ``%%{init}%%`` directive to enlarge nodes / spacing. + + Nodes auto-size to their label, so the effective levers are font size and + interior padding (bigger nodes) plus node/rank spacing (less cramped). These + config keys are honored by GitHub's strict renderer. + """ + flowchart: dict = {} + if node_padding is not None: + flowchart["padding"] = node_padding + if node_spacing is not None: + flowchart["nodeSpacing"] = node_spacing + if rank_spacing is not None: + flowchart["rankSpacing"] = rank_spacing + cfg: dict = {} + if flowchart: + cfg["flowchart"] = flowchart + if font_size is not None: + cfg["themeVariables"] = {"fontSize": f"{font_size}px"} + return "%%{init: " + json.dumps(cfg) + "}%%" if cfg else None + + +def _count_changed_components(components: list) -> int: + """Recursively count components whose diff_status is added/modified/deleted.""" + n = 0 + for c in components or []: + if c.get("diff_status") in CHANGED: + n += 1 + n += _count_changed_components(c.get("components") or []) + return n + + +def _has_changed_relations(components: list, relations: list) -> bool: + """Recursively: is any relation (at any nesting level) added/modified/deleted?""" + return _has_changes([], relations) or any( + _has_changed_relations(c.get("components") or [], c.get("components_relations") or []) + for c in components or [] + ) + + +def render_mermaid( + diff: dict, + direction: str = "LR", + changed_only: bool = False, + edge_labels: bool = True, + render_depth: int = 1, + font_size: int | None = None, + node_padding: int | None = None, + node_spacing: int | None = None, + rank_spacing: int | None = None, +) -> tuple: + """Return (mermaid_text, meta). ``mermaid_text`` is None when there's nothing to draw. + + ``render_depth`` controls how many component levels are drawn, independent of + the engine's analysis depth: 1 = top-level flat (default), 2 = top-level plus + one level of sub-components as subgraphs, etc. ``meta`` reports ``n_changed`` + (recursive changed-component count) and ``changed`` (any changed component OR + relation at any level) so the caller never mistakes a relation/nested change + for "no changes". On overflow of GitHub's Mermaid caps the full graph degrades + to a changed-only graph (and finally to None) rather than emitting an + unrenderable blob. + """ + all_components = diff.get("components") or [] + all_relations = diff.get("components_relations") or [] + n_changed = _count_changed_components(all_components) + changed = n_changed > 0 or _has_changed_relations(all_components, all_relations) + directive = _init_directive(font_size, node_padding, node_spacing, rank_spacing) + + def build(only_changed: bool): + components, relations = ( + _filter_changed(all_components, all_relations) if only_changed else (all_components, all_relations) + ) + used: set = set() + body: list = [] + node_classes: dict = {"added": [], "modified": [], "deleted": []} + box_classes: dict = {"added": [], "modified": [], "deleted": []} + edge_styles: dict = {"added": [], "modified": [], "deleted": []} + counters = {"edges": 0, "nodes": 0} + + def emit_edges(rels, scope, pad, force): + for rel in rels: + status = force or rel.get("diff_status", "unchanged") + present = status != "deleted" + src = scope.resolve(rel.get("src_id", ""), rel.get("src_name", ""), present) + dst = scope.resolve(rel.get("dst_id", ""), rel.get("dst_name", ""), present) + if src is None or dst is None: + continue # endpoint not drawn โ€” skip, don't consume an edge index + label = _esc(_truncate(rel.get("relation", ""))) if edge_labels else "" + body.append(f'{pad}{src} -- "{label}" --> {dst}' if label else f"{pad}{src} --> {dst}") + if status in edge_styles: + edge_styles[status].append(counters["edges"]) + counters["edges"] += 1 + + def emit_level(comps, rels, indent, force, level): + pad = " " * indent + scope = _Scope(comps, used, force) + for key, label, status, comp in scope.entries: + children = comp.get("components") if level < render_depth else None # cap drawn nesting + if children: + body.append(f'{pad}subgraph {key}["{_esc(label)}"]') + if status in box_classes: + box_classes[status].append(key) + child_force = force or (status if status in ("added", "deleted") else None) + emit_level(children, comp.get("components_relations") or [], indent + 1, child_force, level + 1) + body.append(f"{pad}end") + else: + body.append(f'{pad}{key}["{_esc(label)}"]') + if status in node_classes: + node_classes[status].append(key) + counters["nodes"] += 1 + emit_edges(rels, scope, pad, force) + + emit_level(components, relations, 1, None, 1) + if counters["nodes"] == 0: + return None, 0, 0 + + style: list = [ + f' classDef added fill:{COLORS["added"]["fill"]},stroke:{COLORS["added"]["stroke"]},color:#ffffff;', + f' classDef modified fill:{COLORS["modified"]["fill"]},stroke:{COLORS["modified"]["stroke"]},color:#ffffff;', + f' classDef deleted fill:{COLORS["deleted"]["fill"]},stroke:{COLORS["deleted"]["stroke"]},' + f"color:#ffffff,stroke-dasharray:5 3;", + ] + if any(box_classes.values()): # stroke-only containers so big parents aren't solid blocks + for st in CHANGED: + dash = ",stroke-dasharray:5 3" if st == "deleted" else "" + style.append(f' classDef {st}Box stroke:{COLORS[st]["stroke"]},stroke-width:2px,fill:none{dash};') + for status in CHANGED: + if node_classes[status]: + style.append(f' class {",".join(node_classes[status])} {status};') + if box_classes[status]: + style.append(f' class {",".join(box_classes[status])} {status}Box;') + for status in CHANGED: + idxs = edge_styles[status] + if not idxs: + continue + s = f'stroke:{COLORS[status]["stroke"]},stroke-width:2px' + if status == "deleted": + s += ",stroke-dasharray:5 3" + style.append(f' linkStyle {",".join(str(i) for i in idxs)} {s};') + + head = ["```mermaid"] + ([directive] if directive else []) + [f"graph {direction}"] + return "\n".join(head + body + style + ["```"]), counters["nodes"], counters["edges"] + + text, n_nodes, n_edges = build(changed_only) + rendered_changed_only = changed_only + truncated = False + # Degrade an oversized full graph to changed-only before giving up (GitHub caps). + if text is not None and (n_edges > MAX_EDGES or len(text) > MAX_TEXT) and not changed_only: + t2, nn2, ne2 = build(True) + if t2 is not None: + text, n_nodes, n_edges, truncated = t2, nn2, ne2, True + rendered_changed_only = True + + meta = { + "n_changed": n_changed, + "changed": changed, + "n_nodes": n_nodes if text is not None else 0, + "n_edges": n_edges if text is not None else 0, + "truncated": bool(truncated or text is None), + "changed_only": bool(rendered_changed_only), + "requested_changed_only": bool(changed_only), + } + if text is None or n_edges > MAX_EDGES or len(text) > MAX_TEXT: # never trip GitHub's red error box + meta["truncated"] = True + return None, meta + return text, meta + + +# --------------------------------------------------------------------------- # +# cli +# --------------------------------------------------------------------------- # +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 ```mermaid block") + p.add_argument("--direction", default="LR", choices=["LR", "TD", "TB", "RL", "BT"]) + p.add_argument("--changed-only", action="store_true", help="Render only changed components + incident edges") + p.add_argument("--no-edge-labels", dest="edge_labels", action="store_false", help="Draw arrows without relation labels") + p.add_argument("--render-depth", type=int, default=1, help="Component levels to draw: 1=top-level flat, 2=+one nesting level, ...") + p.add_argument("--font-size", type=int, default=None, help="Node label font size in px (bigger label โ‡’ bigger node)") + p.add_argument("--node-padding", type=int, default=None, help="Interior padding around each node label") + p.add_argument("--node-spacing", type=int, default=None, help="Space between nodes in the same rank") + p.add_argument("--rank-spacing", type=int, default=None, help="Space between ranks") + args = p.parse_args() + + diff = build_diff(load_analysis(args.base), load_analysis(args.head)) + mermaid, meta = render_mermaid( + diff, + direction=args.direction, + changed_only=args.changed_only, + edge_labels=args.edge_labels, + render_depth=args.render_depth, + font_size=args.font_size, + node_padding=args.node_padding, + node_spacing=args.node_spacing, + rank_spacing=args.rank_spacing, + ) + + args.out.write_text(mermaid if mermaid is not None else "", encoding="utf-8") + meta["rendered"] = mermaid is not None + # 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/run_local.sh b/scripts/run_local.sh new file mode 100755 index 0000000..b575c02 --- /dev/null +++ b/scripts/run_local.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# +# 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 engine 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 +# +set -euo pipefail + +ACTION_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENGINE="${ENGINE:-$ACTION_DIR/../CodeBoarding}" +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:-}" + +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO="$2"; shift 2;; + --base) BASE_REF="$2"; shift 2;; + --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;; + --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;; + esac +done + +mkdir -p "$OUT" + +run_engine() { + ( cd "$ENGINE" + export STATIC_ANALYSIS_CONFIG="$ENGINE/static_analysis_config.yml" \ + PROJECT_ROOT="$ENGINE" \ + DIAGRAM_DEPTH_LEVEL="$DEPTH" \ + CACHING_DOCUMENTATION="false" \ + ENABLE_MONITORING="false" \ + OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" + # 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 + uv run python "$ACTION_DIR/scripts/cb_engine.py" "$@" ) +} + +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 + [ -n "$REPO" ] && [ -n "$BASE_REF" ] && [ -n "$HEAD_REF" ] || { + echo "Need either --base-json/--head-json, or --repo/--base/--head." >&2; exit 2; } + [ -d "$ENGINE" ] || { echo "Engine not found at $ENGINE (set --engine or \$ENGINE)." >&2; exit 2; } + [ -n "${OPENROUTER_API_KEY:-}" ] || { echo "Export OPENROUTER_API_KEY for the full pipeline." >&2; exit 2; } + REPO="$(cd "$REPO" && pwd)" + BASE_DIR="$OUT/base"; HEAD_DIR="$OUT/head" + rm -rf "$BASE_DIR" "$HEAD_DIR"; mkdir -p "$BASE_DIR" "$HEAD_DIR" + + echo "== Resolving base analysis at $BASE_REF ==" + if git -C "$REPO" show "$BASE_REF:.codeboarding/analysis.json" > "$BASE_DIR/analysis.json" 2>/dev/null; then + echo " using committed baseline" + 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_REF" >/dev/null + run_engine base \ + --repo "$BASE_SRC" \ + --out "$BASE_DIR" \ + --name "$(basename "$REPO")" \ + --run-id local-base \ + --depth "$DEPTH" \ + --source-sha "$BASE_REF" + 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_REF (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 "$BASE_REF" \ + --target-ref "$HEAD_REF" \ + --source-sha "$HEAD_REF" + [ -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" +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" + +# 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' +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] +graph = html.escape("\n".join(lines)) +open(dst, "w", encoding="utf-8").write(f""" +CodeBoarding architecture diff + +

Architecture diff preview

+
+ ■ added + ■ modified + ■ deleted +
+
+{graph}
+
+""") +print(f" wrote {dst}") +PY + +echo +echo "diagram : $OUT/diagram.md" +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 +fi diff --git a/tests/test_build_cta.py b/tests/test_build_cta.py new file mode 100644 index 0000000..acb5580 --- /dev/null +++ b/tests/test_build_cta.py @@ -0,0 +1,70 @@ +"""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_empty_base_yields_no_footer(self): + self.assertEqual(bc.build_cta("", "o", "r", "1", repo_with()), "") + + def test_warning_shows_without_cta_base(self): + out = bc.build_cta("", "o", "r", "1", repo_with(), issues=3) + self.assertIn("3 architecture issues found", out) + self.assertNotIn("http", out) # no links without a proxy base + + 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.assertNotIn("use-workspace", out) # webview/browser tier deferred โ€” extension-direct + 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) + self.assertNotIn("Open in 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("Open in VS Code", out) + self.assertNotIn("Open in 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("Open in VS Code", out) + self.assertIn("Open in 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) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cb_engine.py b/tests/test_cb_engine.py new file mode 100644 index 0000000..523db5d --- /dev/null +++ b/tests/test_cb_engine.py @@ -0,0 +1,209 @@ +"""Smoke tests for scripts/cb_engine.py โ€” verify it calls the engine API correctly, +using stub modules so no real engine venv is needed.""" + +import sys +import tempfile +import types +import unittest +from contextlib import redirect_stderr +from io import StringIO +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +import cb_engine # noqa: E402 + +_STUBBED = [ + "codeboarding_workflows", "codeboarding_workflows.analysis", + "diagram_analysis", "diagram_analysis.exceptions", + "health", "health.models", "health.runner", + "static_analyzer", "static_analyzer.analysis_cache", +] + + +class _Rec: + def __init__(self, ret="OUT", raises=None): + self.calls = [] + self._ret, self._raises = ret, raises + + def __call__(self, *a, **k): + self.calls.append(k) + 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 + + +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 + return analysis, IncrementalCacheMissingError, BaselineUnavailableError + + def test_base_calls_run_full(self): + rf = _Rec() + self._install(run_full=rf) + cb_engine.run_base("/repo", "/out", "myrepo", "rid-base", 2, "abc123") + self.assertEqual(len(rf.calls), 1) + k = rf.calls[0] + self.assertEqual(k["repo_name"], "myrepo") + self.assertEqual(str(k["repo_path"]), "/repo") + self.assertEqual(k["depth_level"], 2) + self.assertEqual(k["source_sha"], "abc123") + + def test_main_parses_depth_as_int(self): + rf = _Rec() + self._install(run_full=rf) + cb_engine.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_rejects_invalid_depth(self): + for depth in ("0", "4", "x"): + with self.subTest(depth=depth): + with redirect_stderr(StringIO()): + with self.assertRaises(SystemExit): + cb_engine.main([ + "base", + "--repo", "/repo", + "--out", "/out", + "--name", "myrepo", + "--run-id", "rid-base", + "--depth", depth, + "--source-sha", "abc123", + ]) + + def test_head_uses_incremental(self): + ri, rf = _Rec(), _Rec() + self._install(run_full=rf, run_incremental=ri) + cb_engine.run_head("/repo", "/out", "r", "rid", 1, "base", "head", "head") + self.assertEqual(len(ri.calls), 1) + self.assertEqual(len(rf.calls), 0) # no fallback + self.assertEqual(ri.calls[0]["base_ref"], "base") + self.assertEqual(ri.calls[0]["target_ref"], "head") + + 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) + out = tempfile.mkdtemp() + (Path(out) / "stale.json").write_text("{}") # must be wiped before the full run + (Path(out) / "health").mkdir() + (Path(out) / "health" / "stale.json").write_text("{}") + cb_engine.run_head("/repo", out, "r", "rid", 3, "base", "head", "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 + + 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) + cb_engine.run_head("/repo", tempfile.mkdtemp(), "r", "rid", 1, "base", "head", "head") + self.assertEqual(len(rf.calls), 1) # BaselineUnavailableError also triggers the full re-run + + +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", ) + 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(cb_engine.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(cb_engine.run_health(str(artifact_dir), "/repo", "r"), 3) + + def test_malformed_health_report_falls_back(self): + 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(cb_engine.run_health(str(artifact_dir), "/repo", "r"), 0) + + def test_missing_module_yields_zero(self): + # No health.* modules installed -> import fails -> 0, never raises. + self.assertEqual(cb_engine.run_health("/art", "/repo", "r"), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_diff_to_mermaid.py b/tests/test_diff_to_mermaid.py new file mode 100644 index 0000000..1b14daf --- /dev/null +++ b/tests/test_diff_to_mermaid.py @@ -0,0 +1,233 @@ +"""Unit tests for scripts/diff_to_mermaid.py โ€” diff logic + Mermaid rendering.""" + +import re +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +import diff_to_mermaid as dm # noqa: E402 + + +def comp(name, files=None, cid=None, subs=None, subrels=None): + c = { + "name": name, + "component_id": cid or name, + "file_methods": [{"file_path": f, "methods": m} for f, m in (files or {}).items()], + } + if subs is not None: + c["components"] = subs + if subrels is not None: + c["components_relations"] = subrels + return c + + +def rel(src, dst, label="calls"): + return {"src_name": src, "dst_name": dst, "src_id": src, "dst_id": dst, "relation": label} + + +def linkstyle_indices_in_range(text): + n_edges = text.count("-->") + idxs = [int(x) for m in re.finditer(r"linkStyle ([\d,]+)", text) for x in m.group(1).split(",")] + return all(i < n_edges for i in idxs) + + +class TestDiff(unittest.TestCase): + def test_added_modified_deleted_unchanged(self): + base = {"components": [comp("A", {"a.py": ["f"]}), comp("B"), comp("D")], "components_relations": []} + head = {"components": [comp("A", {"a.py": ["f", "g"]}), comp("B"), comp("C")], "components_relations": []} + status = {c["name"]: c["diff_status"] for c in dm.build_diff(base, head)["components"]} + self.assertEqual(status["A"], "modified") # method added inside the component + self.assertEqual(status["B"], "unchanged") + self.assertEqual(status["C"], "added") + self.assertEqual(status["D"], "deleted") + + def test_structural_change_is_modified(self): + base = {"components": [comp("A", {"a.py": ["f"]})], "components_relations": []} + head = {"components": [comp("A", {"a.py": ["f"], "b.py": ["h"]})], "components_relations": []} + self.assertEqual(dm.build_diff(base, head)["components"][0]["diff_status"], "modified") + + def test_rename_is_add_plus_delete(self): + base = {"components": [comp("Old")], "components_relations": []} + head = {"components": [comp("New")], "components_relations": []} + status = {c["name"]: c["diff_status"] for c in dm.build_diff(base, head)["components"]} + self.assertEqual(status, {"New": "added", "Old": "deleted"}) + + def test_relation_modified_on_label_change(self): + base = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "uses")]} + head = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "calls")]} + self.assertEqual(dm.build_diff(base, head)["components_relations"][0]["diff_status"], "modified") + + def test_relation_added_and_deleted(self): + base = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B")]} + head = {"components": [comp("A"), comp("B")], "components_relations": [rel("B", "A")]} + statuses = sorted(r["diff_status"] for r in dm.build_diff(base, head)["components_relations"]) + self.assertEqual(statuses, ["added", "deleted"]) + + def test_parallel_relation_deletion_is_not_label_modification(self): + base = { + "components": [comp("A"), comp("B")], + "components_relations": [rel("A", "B", "uses"), rel("A", "B", "publishes")], + } + head = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "uses")]} + statuses = sorted(r["diff_status"] for r in dm.build_diff(base, head)["components_relations"]) + self.assertEqual(statuses, ["deleted", "unchanged"]) + + +class TestRender(unittest.TestCase): + def _diff(self): + base = {"components": [comp("A"), comp("B"), comp("Gone")], "components_relations": [rel("A", "B"), rel("A", "Gone")]} + head = {"components": [comp("A", {"x.py": ["f"]}), comp("B"), comp("New")], "components_relations": [rel("A", "B"), rel("A", "New")]} + return dm.build_diff(base, head) + + def test_flat_default_has_no_subgraphs(self): + text, _ = dm.render_mermaid(self._diff(), render_depth=1) + self.assertNotIn("subgraph", text) + for cls in ("added", "modified", "deleted"): + self.assertIn(f"classDef {cls}", text) + self.assertTrue(linkstyle_indices_in_range(text)) + + def test_nested_subgraphs_balanced_and_valid(self): + base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2")])], "components_relations": []} + head = {"components": [comp("P", subs=[comp("c1"), comp("c3")], subrels=[rel("c1", "c3")])], "components_relations": []} + text, _ = dm.render_mermaid(dm.build_diff(base, head), render_depth=2) + sg = sum(1 for line in text.splitlines() if line.strip().startswith("subgraph ")) + en = sum(1 for line in text.splitlines() if line.strip() == "end") + self.assertGreater(sg, 0) + self.assertEqual(sg, en) + self.assertTrue(linkstyle_indices_in_range(text)) + + def test_render_depth_caps_at_data_depth(self): + base = {"components": [comp("P", subs=[comp("c1")], subrels=[])], "components_relations": []} + head = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []} + diff = dm.build_diff(base, head) + deep = dm.render_mermaid(diff, render_depth=5)[0] + two = dm.render_mermaid(diff, render_depth=2)[0] + self.assertEqual(deep, two) # no level-3 data, so depth 5 == depth 2 + + def test_label_escaping(self): + head = {"components": [comp('A "q" #h'), comp("B")], "components_relations": []} + base = {"components": [comp("B")], "components_relations": []} + text, _ = dm.render_mermaid(dm.build_diff(base, head), render_depth=1) + self.assertIn("#quot;", text) + self.assertIn("#35;", text) + + def test_label_escaping_brackets_break_chars(self): + # `]` / `(` / `&` would break GitHub's renderer if left raw. + self.assertEqual(dm._esc("Has]Bracket"), "Has#93;Bracket") + self.assertEqual(dm._esc("f(x)"), "f#40;x#41;") + self.assertEqual(dm._esc("A & B"), "A #amp; B") + head = {"components": [comp("Weird]Name(x)"), comp("B")], "components_relations": []} + base = {"components": [comp("B")], "components_relations": []} + text, _ = dm.render_mermaid(dm.build_diff(base, head)) + self.assertNotIn("]Name", text) # no raw ] inside a label + self.assertIn("#93;", text) + + def test_esc_strips_newlines(self): + # A raw newline/CR in a label breaks the whole Mermaid block. + self.assertNotIn("\n", dm._esc("line1\nline2")) + self.assertNotIn("\r", dm._esc("a\r\nb")) + + def test_truncate_caps_long_edge_label_with_ellipsis(self): + out = dm._truncate("x" * 60) + self.assertLessEqual(len(out), dm._EDGE_LABEL_MAX) + self.assertTrue(out.endswith("โ€ฆ")) + self.assertEqual(dm._truncate("short"), "short") # under the cap: unchanged + + def test_changed_flag_relation_only(self): + # A label-only relation change leaves n_changed=0 but must report changed=True. + base = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "uses")]} + head = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B", "calls")]} + text, meta = dm.render_mermaid(dm.build_diff(base, head)) + self.assertEqual(meta["n_changed"], 0) + self.assertTrue(meta["changed"]) + self.assertIsNotNone(text) + + def test_changed_flag_false_when_identical(self): + d = {"components": [comp("A"), comp("B")], "components_relations": [rel("A", "B")]} + _, meta = dm.render_mermaid(dm.build_diff(d, d)) + self.assertEqual(meta["n_changed"], 0) + self.assertFalse(meta["changed"]) + + def test_changed_flag_counts_nested(self): + base = {"components": [comp("P", subs=[comp("c1")], subrels=[])], "components_relations": []} + head = {"components": [comp("P", subs=[comp("c1", {"x.py": ["f"]})], subrels=[])], "components_relations": []} + _, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=2) + self.assertEqual(meta["n_changed"], 1) # the nested child counts + self.assertTrue(meta["changed"]) + + def test_nested_method_change_highlights_collapsed_parent(self): + base = {"components": [comp("P", subs=[comp("c1")], subrels=[])], "components_relations": []} + head = {"components": [comp("P", subs=[comp("c1", {"x.py": ["f"]})], subrels=[])], "components_relations": []} + text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=1) + self.assertEqual(meta["n_changed"], 1) + self.assertIn("class n_P modified;", text) + + def test_nested_relation_change_highlights_collapsed_parent(self): + base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2", "uses")])], "components_relations": []} + head = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2", "calls")])], "components_relations": []} + text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=1) + self.assertEqual(meta["n_changed"], 0) + self.assertTrue(meta["changed"]) + self.assertIn("class n_P modified;", text) + + def test_changed_only_keeps_nested_change(self): + base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []} + head = {"components": [comp("P", subs=[comp("c1", {"x.py": ["f"]}), comp("c2")], subrels=[])], "components_relations": []} + text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=2, changed_only=True) + self.assertIsNotNone(text) + self.assertTrue(meta["changed"]) + self.assertFalse(meta["truncated"]) + self.assertIn("subgraph n_P", text) + self.assertIn("class n_c1 modified;", text) + self.assertNotIn('n_c2["c2"]', text) + + def test_changed_only_prunes_unchanged_children_of_modified_parent(self): + base = {"components": [comp("P", {"p.py": ["old"]}, subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []} + head = {"components": [comp("P", {"p.py": ["old", "new"]}, subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []} + text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=2, changed_only=True) + self.assertIsNotNone(text) + self.assertTrue(meta["changed"]) + self.assertIn('n_P["P"]', text) + self.assertNotIn('n_c1["c1"]', text) + self.assertNotIn('n_c2["c2"]', text) + + def test_changed_only_is_not_auto_truncated(self): + text, meta = dm.render_mermaid(self._diff(), render_depth=1, changed_only=True) + self.assertIsNotNone(text) + self.assertFalse(meta["truncated"]) + self.assertTrue(meta["changed_only"]) + self.assertTrue(meta["requested_changed_only"]) + + def test_auto_truncation_reports_rendered_changed_only(self): + base = { + "components": [comp("A"), comp("B"), comp("C")], + "components_relations": [rel("B", "C", "uses"), rel("C", "B", "uses")], + } + head = { + "components": [comp("A", {"a.py": ["f"]}), comp("B"), comp("C")], + "components_relations": [rel("B", "C", "uses"), rel("C", "B", "uses")], + } + old = dm.MAX_EDGES + try: + dm.MAX_EDGES = 1 + text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=1) + finally: + dm.MAX_EDGES = old + self.assertIsNotNone(text) + self.assertTrue(meta["truncated"]) + self.assertTrue(meta["changed_only"]) + self.assertFalse(meta["requested_changed_only"]) + + def test_empty_returns_none(self): + text, meta = dm.render_mermaid({"components": [], "components_relations": []}) + self.assertIsNone(text) + self.assertEqual(meta["n_nodes"], 0) + + def test_no_edge_labels(self): + text, _ = dm.render_mermaid(self._diff(), render_depth=1, edge_labels=False) + self.assertNotIn(' -- "', text) + + +if __name__ == "__main__": + unittest.main()