Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/codeboarding.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<NAME>_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. |
Expand All @@ -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

Expand Down
95 changes: 93 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
Expand All @@ -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."
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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##*/}"
Expand All @@ -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[@]}"
}

{
Expand Down
70 changes: 66 additions & 4 deletions scripts/build_cta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/),
Expand Down Expand Up @@ -58,20 +59,60 @@ 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=<head_sha>&compare=<base_sha>``. 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),
deep-link into the editor, and add a separate "get the extension" link. Without
a proxy the editor link is the extension's https listing (GitHub strips custom
``vscode:``/``cursor:`` schemes), and the redundant install link is dropped.
The ⚠️ banner shows whenever ``issues > 0``.

When ``webview_ready`` (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("/")
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading