diff --git a/.codeboarding/analysis.json b/.codeboarding/analysis.json index 62330fe..a7d7e4f 100644 --- a/.codeboarding/analysis.json +++ b/.codeboarding/analysis.json @@ -1,7 +1,7 @@ { "metadata": { - "generated_at": "2026-06-11T16:36:04.958976+00:00", - "commit_hash": "e9aa6435ce9f0ffe6726d5c1a3758980f79f1c3b", + "generated_at": "2026-06-11T16:41:16.710439+00:00", + "commit_hash": "ba5d7787eaecfa38964dd4663606927d98e501ba", "repo_name": "CodeBoarding-action", "depth_level": 1, "file_coverage_summary": { @@ -14,7 +14,7 @@ } } }, - "description": "The CodeBoarding system orchestrates the analysis of git repositories, performs structural diffing between analysis states, and generates actionable visual feedback for pull requests by integrating with developer environments.", + "description": "The CodeBoarding-action architecture is a CI/CD pipeline that automates architectural observability by comparing codebase versions, generating visual Mermaid.js diffs, and providing actionable IDE deep-links within GitHub Pull Requests.", "files": { "scripts/cb_engine.py": { "method_keys": [ @@ -377,8 +377,8 @@ }, "components": [ { - "name": "Engine Orchestrator", - "description": "Manages the execution environment and lifecycle of the CodeBoarding analysis engine. It handles the checkout of different git references, executes the analysis, and validates the integrity of the generated data before downstream processing.", + "name": "Analysis Orchestrator", + "description": "Manages the execution lifecycle of the GitHub Action, including environment setup, triggering analysis, and data validation.", "key_entities": [ { "qualified_name": "scripts.cb_engine.main", @@ -429,8 +429,8 @@ "can_expand": true }, { - "name": "Visual Diff Engine", - "description": "The core analytical component that compares two sets of analysis data. It identifies structural modifications (additions, removals, or changes in relationships), filters for relevance to reduce diagram noise, and renders the final architectural diff using Mermaid.js syntax.", + "name": "Visual Diff Generator", + "description": "The core logic engine that compares structural metadata between codebase versions and renders differences into Mermaid.js graph syntax.", "key_entities": [ { "qualified_name": "scripts.diff_to_mermaid.main", @@ -451,10 +451,10 @@ "reference_end_line": 521 }, { - "qualified_name": "scripts.diff_to_mermaid._filter_changed", + "qualified_name": "scripts.diff_to_mermaid._diff_components", "reference_file": "scripts/diff_to_mermaid.py", - "reference_start_line": 312, - "reference_end_line": 354 + "reference_start_line": 162, + "reference_end_line": 207 } ], "source_cluster_ids": [ @@ -503,8 +503,8 @@ "can_expand": true }, { - "name": "Engagement & Feedback Manager", - "description": "Enhances the PR comment with interactive elements. It detects the developer's environment and generates deep-links to local IDEs or the CodeBoarding dashboard, transforming a static diagram into an actionable entry point for code review.", + "name": "UX & Integration Helper", + "description": "Manages the generation of user-facing outputs, including GitHub comments, status checks, and interactive links (CTAs) for external viewing. This component integrates the new 'build_cta.py' logic to generate interactive webview links and editor-specific deep links, expanding its role from simple reporting to providing actionable CI/CD report navigation. It receives processed data from the Visual Diff Generator to finalize the GitHub Action output.", "key_entities": [ { "qualified_name": "scripts.build_cta.main", @@ -545,22 +545,31 @@ ], "components_relations": [ { - "relation": "Supplies validated JSON analysis files to", - "src_name": "Engine Orchestrator", - "dst_name": "Visual Diff Engine", + "relation": "Passes validated analysis artifacts to initiate structural comparison", + "src_name": "Analysis Orchestrator", + "dst_name": "Visual Diff Generator", "src_id": "1", "dst_id": "2", "edge_count": 0, "is_static": false }, { - "relation": "Provides structural context and identified changes to", - "src_name": "Visual Diff Engine", - "dst_name": "Engagement & Feedback Manager", + "relation": "Provides context of changed files and components for deep-link generation", + "src_name": "Visual Diff Generator", + "dst_name": "UX & Integration Helper", "src_id": "2", "dst_id": "3", "edge_count": 0, "is_static": false + }, + { + "relation": "Returns formatted markdown snippets and CTA links for final output", + "src_name": "UX & Integration Helper", + "dst_name": "Analysis Orchestrator", + "src_id": "3", + "dst_id": "1", + "edge_count": 0, + "is_static": false } ] } \ No newline at end of file diff --git a/.codeboarding/health/health_report.json b/.codeboarding/health/health_report.json index 8d2b607..5dc4c6e 100644 --- a/.codeboarding/health/health_report.json +++ b/.codeboarding/health/health_report.json @@ -1,13 +1,13 @@ { "repository_name": "CodeBoarding-action", - "timestamp": "2026-06-11T16:35:41.927521+00:00", - "overall_score": 0.999609375, + "timestamp": "2026-06-11T16:41:11.294364+00:00", + "overall_score": 0.9996183206106869, "check_summaries": [ { "check_name": "function_size", "description": "Checks that functions/methods do not exceed line count thresholds", "check_type": "standard", - "total_entities_checked": 42, + "total_entities_checked": 43, "findings_count": 0, "warning_count": 0, "score": 1.0, @@ -17,7 +17,7 @@ "check_name": "fan_out", "description": "Checks efferent coupling: how many other functions each function calls", "check_type": "standard", - "total_entities_checked": 42, + "total_entities_checked": 43, "findings_count": 0, "warning_count": 0, "score": 1.0, @@ -27,7 +27,7 @@ "check_name": "fan_in", "description": "Checks afferent coupling: how many other functions call each function", "check_type": "standard", - "total_entities_checked": 42, + "total_entities_checked": 43, "findings_count": 0, "warning_count": 0, "score": 1.0, @@ -43,24 +43,6 @@ "score": 1.0, "finding_groups": [] }, - { - "check_name": "circular_dependencies", - "description": "Detects circular dependencies between packages", - "check_type": "circular_dependencies", - "cycles": [], - "packages_checked": 1, - "packages_in_cycles": 0 - }, - { - "check_name": "package_instability", - "description": "Computes Martin's instability metric (I = Ce / (Ca + Ce)) per package", - "check_type": "standard", - "total_entities_checked": 0, - "findings_count": 0, - "warning_count": 0, - "score": 1.0, - "finding_groups": [] - }, { "check_name": "unused_code_diagnostics", "description": "Detects unused imports, variables, functions, and dead code via LSP diagnostics", diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index 451103d..e5362fe 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -11,7 +11,9 @@ on: types: [created] permissions: - contents: read + # write: the action commits the generated .codeboarding/analysis.json back to the + # PR branch so the webview can open this PR's diff at the head SHA (same-repo PRs). + contents: write pull-requests: write issues: write diff --git a/README.md b/README.md index 6063451..7744753 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,9 @@ on: types: [created] permissions: - contents: read + # write lets the action commit analysis.json to the PR branch so the comment can + # link to the webview diff. Drop to `read` to keep the comment without that link. + contents: write pull-requests: write issues: write @@ -156,6 +158,7 @@ The command needs the `issue_comment` trigger and runs from your default branch | `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 or update the PR comment. | +| `push_token` | `${{ github.token }}` | Token used to push the generated `analysis.json` to the PR branch (for the webview link). The workflow token can push when the workflow grants `permissions: contents: write`. Separate from `github_token` so commenting can use a GitHub App token while the push uses the workflow token. | | `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. | @@ -166,6 +169,8 @@ The command needs the `issue_comment` trigger and runs from your default branch | `comment_header` | `Architecture review` | Heading for the PR comment. | | `trigger_command` | `/codeboarding` | Slash command for trusted on-demand runs. | | `cta_base_url` | empty | Click-proxy base URL: deep-links the editor link into VS Code/Cursor and adds a "get the extension" link (tracks owner/repo/pr). Empty links to the extension listing instead (GitHub strips `vscode:`/`cursor:` from comments). | +| `webview_base_url` | `https://app.codeboarding.org` | Hosted webview base URL. The PR comment adds an "explore in browser" link to this PR's head-vs-base diff. Needs `commit_head_analysis` (same-repo PRs only); omitted on forks. Set empty to disable. | +| `commit_head_analysis` | `true` | Commit the generated head `.codeboarding/analysis.json` (+ health report) to the PR branch so the webview can read it at the head SHA. Same-repo PRs only (the token is read-only on forks). | ## Outputs diff --git a/action.yml b/action.yml index 34349fa..e9b24b8 100644 --- a/action.yml +++ b/action.yml @@ -18,6 +18,10 @@ inputs: description: 'GITHUB_TOKEN used to post the PR comment. Defaults to the workflow token.' required: false default: ${{ github.token }} + push_token: + description: 'Token used to push the generated .codeboarding/analysis.json to the PR branch (for the webview link). Defaults to the workflow github.token, which can push when the calling workflow grants "permissions: contents: write". Kept separate from github_token so commenting can use a GitHub App token while the push uses the workflow token (whose write access the consumer controls).' + 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 @@ -54,6 +58,14 @@ inputs: description: 'Base URL of the click proxy (e.g. https://go.codeboarding.org). When set, the editor link deep-links into VS Code/Cursor via the proxy and a "get the extension" link is added (owner/repo/pr tracked). Empty (default) links to the extension listing instead, since GitHub strips vscode:/cursor: schemes from comment links.' required: false default: '' + webview_base_url: + description: 'Base URL of the hosted webview (default https://app.codeboarding.org). The PR comment adds an "explore in browser" link deep-linking to this PR''s head-vs-base architecture diff. Requires the head analysis.json to be committed to the PR branch (commit_head_analysis), so it is omitted on fork PRs. Set empty to disable the webview link.' + required: false + default: 'https://app.codeboarding.org' + commit_head_analysis: + description: 'Commit the generated head .codeboarding/analysis.json (+ health report) back to the PR branch so the webview can fetch it at the head SHA. Same-repo PRs only (the token is read-only on forks). Required for the webview "explore in browser" link.' + required: false + default: 'true' 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 @@ -88,6 +100,7 @@ runs: 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_HEAD_REF: ${{ github.event.pull_request.head.ref }} PULL_BASE_REF: ${{ github.event.pull_request.base.ref }} PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} PULL_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} @@ -115,6 +128,7 @@ runs: PR_NUMBER="$PR_NUMBER_PULL" BASE_SHA="$PULL_BASE_SHA" HEAD_SHA="$PULL_HEAD_SHA" + HEAD_REF="$PULL_HEAD_REF" BASE_REF="$PULL_BASE_REF" BASE_REPO="$PULL_BASE_REPO" HEAD_REPO="$PULL_HEAD_REPO" @@ -137,6 +151,7 @@ runs: PR_JSON="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}" 2>/dev/null)" || skip "Could not fetch PR #$PR_NUMBER from the API." BASE_SHA="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["sha"])' 2>/dev/null)" || skip "Could not parse base SHA from the PR API." HEAD_SHA="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["head"]["sha"])' 2>/dev/null)" || skip "Could not parse head SHA from the PR API." + HEAD_REF="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["head"]["ref"])' 2>/dev/null)" || HEAD_REF="" BASE_REF="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["ref"])' 2>/dev/null)" || BASE_REF="" BASE_REPO="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["base"]["repo"]["full_name"])' 2>/dev/null)" || skip "Could not parse base repo from the PR API." HEAD_REPO="$(printf '%s' "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["head"]["repo"]["full_name"])' 2>/dev/null)" || skip "Could not parse head repo from the PR API." @@ -151,9 +166,12 @@ runs: echo "pr_number=$PR_NUMBER" echo "base_sha=$BASE_SHA" echo "head_sha=$HEAD_SHA" + echo "head_ref=$HEAD_REF" echo "base_ref=$BASE_REF" echo "base_repo=$BASE_REPO" echo "head_repo=$HEAD_REPO" + # same_repo gates pushing the head analysis: forks give a read-only token. + if [ "$HEAD_REPO" = "$REPOSITORY" ]; then echo "same_repo=true"; else echo "same_repo=false"; fi } >> "$GITHUB_OUTPUT" echo "Resolved PR #$PR_NUMBER (base=$BASE_REPO@$BASE_SHA head=$HEAD_REPO@$HEAD_SHA) via $EVENT" @@ -576,6 +594,67 @@ runs: "${RUNNER_TEMP}/cb-agent-model" \ "${RUNNER_TEMP}/cb-parsing-model" + # Commit the generated head analysis (+ health report) to the PR branch so the + # hosted webview can fetch .codeboarding/analysis.json at the head SHA and open + # this PR's head-vs-base diff. Same-repo PRs only — the token is read-only on + # forks (the step is skipped there and the webview link is omitted). The push + # creates a NEW head commit; its SHA (webview_sha) is what the comment links to. + - name: Commit head analysis to PR branch + id: commit_head + if: >- + steps.guard.outputs.skip != 'true' + && inputs.commit_head_analysis == 'true' + && steps.guard.outputs.same_repo == 'true' + && inputs.webview_base_url != '' + shell: bash + working-directory: target-repo + env: + # Push with push_token (defaults to the workflow github.token, gated by the + # consumer's `permissions: contents: write`) — NOT github_token, which may be + # a GitHub App token used only for commenting and need not have write access. + GH_TOKEN: ${{ inputs.push_token }} + HEAD_DIR: ${{ steps.base.outputs.head_dir }} + HEAD_REF: ${{ steps.guard.outputs.head_ref }} + HEAD_SHA: ${{ steps.guard.outputs.head_sha }} + REPOSITORY: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "webview_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + [ -n "$HEAD_REF" ] || { echo "::notice::No head branch ref resolved; skipping head-analysis commit."; exit 0; } + [ -f "$HEAD_DIR/analysis.json" ] || { echo "::notice::No head analysis.json to commit."; exit 0; } + + mkdir -p .codeboarding/health + cp "$HEAD_DIR/analysis.json" .codeboarding/analysis.json + if [ -f "$HEAD_DIR/health/health_report.json" ]; then + cp "$HEAD_DIR/health/health_report.json" .codeboarding/health/health_report.json + fi + + git add .codeboarding/analysis.json .codeboarding/health/health_report.json 2>/dev/null || git add .codeboarding/analysis.json + if git diff --cached --quiet; then + echo "::notice::Head analysis unchanged; nothing to commit." + echo "ready=true" >> "$GITHUB_OUTPUT" # already committed at this SHA → webview can read it + exit 0 + fi + + git config user.name "codeboarding[bot]" + git config user.email "codeboarding[bot]@users.noreply.github.com" + git commit -m "chore(codeboarding): update architecture analysis [skip ci]" >/dev/null + + # Push to the PR head branch. The checkout used persist-credentials:false, so + # authenticate the push explicitly with the workflow token (same-repo only). + # Requires `contents: write` on the job's token — a read-only token (the + # default) is rejected here; the push error below names the cause. + AUTH_URL="https://x-access-token:${GH_TOKEN}@${SERVER_URL#https://}/${REPOSITORY}.git" + if git push "$AUTH_URL" "HEAD:refs/heads/${HEAD_REF}"; then + NEW_SHA="$(git rev-parse HEAD)" + echo "webview_sha=$NEW_SHA" >> "$GITHUB_OUTPUT" + echo "ready=true" >> "$GITHUB_OUTPUT" + echo "Committed head analysis to ${HEAD_REF} as ${NEW_SHA}." + else + echo "::warning::Could not push head analysis to ${HEAD_REF}; the webview link will be omitted. Most likely the job's token lacks 'contents: write' (add 'permissions: contents: write' to the calling workflow), or the branch is protected against this pusher." + fi + - name: Diff analyses → Mermaid if: steps.guard.outputs.skip != 'true' id: diagram @@ -634,6 +713,12 @@ runs: TRUNC: ${{ steps.diagram.outputs.truncated }} PR: ${{ steps.guard.outputs.pr_number }} ISSUES: ${{ steps.health.outputs.issues }} + WEBVIEW_BASE: ${{ inputs.webview_base_url }} + # SHA the head analysis.json was committed at (post-push), and whether that + # commit succeeded — gates the webview "explore in browser" link. + WEBVIEW_SHA: ${{ steps.commit_head.outputs.webview_sha }} + WEBVIEW_READY: ${{ steps.commit_head.outputs.ready }} + BASE_SHA: ${{ steps.guard.outputs.base_sha }} run: | BODY_FILE=$(mktemp) OWNER="${OWNER_REPO%%/*}"; REPO="${OWNER_REPO##*/}" @@ -646,11 +731,17 @@ runs: } # CTA footer: an editor link (proxy deep-link when CTA_BASE is set, else the - # extension's https listing — GitHub strips vscode:/cursor:) plus the ⚠️ banner. + # extension's https listing — GitHub strips vscode:/cursor:), the ⚠️ banner, + # and — when the head analysis was committed (WEBVIEW_READY) — a webview + # "explore in browser" link to this PR's head-vs-base diff. cta() { + local extra=() + if [ "$WEBVIEW_READY" = "true" ]; then + extra+=(--webview-ready --webview-base "$WEBVIEW_BASE" --head-sha "$WEBVIEW_SHA" --base-sha "$BASE_SHA") + fi python3 "$ACTION_PATH/scripts/build_cta.py" \ --cta-base "$CTA_BASE" --owner "$OWNER" --repo "$REPO" --pr "$PR" \ - --repo-path "$TARGET_REPO" --issues "${ISSUES:-0}" + --repo-path "$TARGET_REPO" --issues "${ISSUES:-0}" "${extra[@]}" } { diff --git a/scripts/build_cta.py b/scripts/build_cta.py index 5ee509d..0a4947e 100644 --- a/scripts/build_cta.py +++ b/scripts/build_cta.py @@ -8,8 +8,9 @@ custom ``vscode:``/``cursor:`` schemes — a deep link would render as dead text — so the editor link points at the extension's plain-https listing instead (VS Code Marketplace, Cursor via Open VSX), which is the only clickable option. A no-install -hosted-webview ("explore in browser") tier is intentionally deferred (see -docs/COMMIT_STRATEGY.md) — the committed analysis already supports it later. +hosted-webview ("explore in browser") line is added when ``webview_ready`` — i.e. the +head ``analysis.json`` was committed to the PR branch and this isn't a fork PR — so +the webview can fetch a committed analysis at the head SHA (see docs/COMMIT_STRATEGY.md). Editor coverage is deliberately limited to **VS Code and Cursor**. Per the 2025 Stack Overflow Developer Survey (https://survey.stackoverflow.co/2025/technology/), @@ -58,7 +59,37 @@ def detect_editors(repo_path: Path) -> list[str]: } -def build_cta(cta_base: str, owner: str, repo: str, pr: str, repo_path: Path, issues: int = 0) -> str: +def build_webview_link(webview_base: str, owner: str, repo: str, head_sha: str, base_sha: str) -> str | None: + """Return the markdown "explore in browser" line, or None if not buildable. + + Deep-links the hosted webview straight to this PR's head-vs-base architecture + diff: ``?repo=owner/repo&ref=&compare=``. Pinned to exact + SHAs so the committed ``analysis.json`` the webview fetches matches this run. For + a private repo the webview itself sends the viewer through GitHub sign-in and then + loads the same diff. Returns None when the base/head pieces aren't all present. + """ + if not (webview_base and owner and repo and head_sha): + return None + base = webview_base.rstrip("/") + params = {"repo": f"{owner}/{repo}", "ref": head_sha} + if base_sha: + params["compare"] = base_sha + return f"🌐 [**Explore this PR’s architecture in your browser →**]({base}/?{urlencode(params)})" + + +def build_cta( + cta_base: str, + owner: str, + repo: str, + pr: str, + repo_path: Path, + issues: int = 0, + *, + webview_base: str = "", + head_sha: str = "", + base_sha: str = "", + webview_ready: bool = False, +) -> str: """Return the markdown CTA footer: a health-warning banner plus an editor link. With a ``cta_base`` proxy the links route through it (owner/repo/pr tracked), @@ -66,12 +97,22 @@ def build_cta(cta_base: str, owner: str, repo: str, pr: str, repo_path: Path, is a proxy the editor link is the extension's https listing (GitHub strips custom ``vscode:``/``cursor:`` schemes), and the redundant install link is dropped. The ⚠️ banner shows whenever ``issues > 0``. + + When ``webview_ready`` (the head ``analysis.json`` was committed and this isn't a + fork PR) a "explore in browser" line deep-links the hosted webview to this PR's + head-vs-base diff. Otherwise that line is omitted (the webview couldn't fetch a + committed analysis at the head SHA). """ 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 webview_ready: + webview_line = build_webview_link(webview_base, owner, repo, head_sha, base_sha) + if webview_line: + parts.append(webview_line) + editors = detect_editors(repo_path) if cta_base: base = cta_base.rstrip("/") @@ -104,13 +145,34 @@ def main() -> int: p.add_argument("--pr", required=True) p.add_argument("--repo-path", required=True, type=Path, help="Path to the analyzed repo checkout") p.add_argument("--issues", default="0", help="Real architecture-issue count (0 -> no warning banner)") + p.add_argument("--webview-base", default="", help="Hosted webview base URL (e.g. https://app.codeboarding.org)") + p.add_argument("--head-sha", default="", help="PR head SHA the webview link pins to") + p.add_argument("--base-sha", default="", help="PR base SHA the webview link compares against") + p.add_argument( + "--webview-ready", + action="store_true", + help="Emit the webview link (head analysis.json was committed; not a fork PR)", + ) 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)) + print( + build_cta( + args.cta_base, + args.owner, + args.repo, + args.pr, + args.repo_path, + issues, + webview_base=args.webview_base, + head_sha=args.head_sha, + base_sha=args.base_sha, + webview_ready=args.webview_ready, + ) + ) return 0 diff --git a/tests/test_build_cta.py b/tests/test_build_cta.py index 1373315..22226fc 100644 --- a/tests/test_build_cta.py +++ b/tests/test_build_cta.py @@ -77,5 +77,75 @@ def test_trailing_slash_in_base_is_normalized(self): self.assertEqual(a, b) +class TestWebviewLink(unittest.TestCase): + WV = "https://app.codeboarding.org" + + def test_link_built_with_head_ref_and_compare_base(self): + link = bc.build_webview_link(self.WV, "Org", "Repo", "headsha", "basesha") + self.assertIn("https://app.codeboarding.org/?", link) + self.assertIn("repo=Org%2FRepo", link) + self.assertIn("ref=headsha", link) + self.assertIn("compare=basesha", link) + + def test_link_omits_compare_when_no_base(self): + link = bc.build_webview_link(self.WV, "o", "r", "headsha", "") + self.assertIn("ref=headsha", link) + self.assertNotIn("compare=", link) + + def test_link_none_without_head_sha_or_base(self): + self.assertIsNone(bc.build_webview_link(self.WV, "o", "r", "", "basesha")) + self.assertIsNone(bc.build_webview_link("", "o", "r", "headsha", "basesha")) + + def test_cta_emits_webview_line_when_ready(self): + out = bc.build_cta( + "", + "Org", + "Repo", + "9", + repo_with(), + issues=0, + webview_base=self.WV, + head_sha="headsha", + base_sha="basesha", + webview_ready=True, + ) + self.assertIn("Explore this PR", out) + self.assertIn("ref=headsha", out) + self.assertIn("compare=basesha", out) + + def test_cta_omits_webview_line_when_not_ready(self): + # Fork PR / head analysis not committed -> webview can't fetch at head SHA. + out = bc.build_cta( + "", + "Org", + "Repo", + "9", + repo_with(), + issues=0, + webview_base=self.WV, + head_sha="headsha", + base_sha="basesha", + webview_ready=False, + ) + self.assertNotIn("Explore this PR", out) + # Editor CTA is still present regardless. + self.assertIn("Open in VS Code", out) + + def test_cta_omits_webview_line_when_ready_but_no_base_url(self): + out = bc.build_cta( + "", + "Org", + "Repo", + "9", + repo_with(), + issues=0, + webview_base="", + head_sha="headsha", + base_sha="basesha", + webview_ready=True, + ) + self.assertNotIn("Explore this PR", out) + + if __name__ == "__main__": unittest.main()