Skip to content
Open
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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,46 @@ Every run reports what it resolved, so the answer never has to be inferred from
- on a configuration failure, an error annotation and — in review mode — a pull request
comment with the fix, so the person who has to add the secret sees it where they are.

### Analysis diagnostics

A run can finish, exit zero, and still have produced a diagram that is missing something:
a language server that never started, a language nothing indexed under, naming that
stopped answering mid-run. Core records each of those in the analysis it writes
(`metadata.run_diagnostics`), and this action reads them back rather than publishing the
result as though nothing happened:

- a `::warning::` annotation per degradation, on the run page;
- the same list in the job summary (sync) and at the top of the review comment, above the
diagram, since a caveat printed under a picture is read after the picture is believed;
- each entry carries what to do about it. Where nothing on your side would have changed
the outcome, it says so and links Discord instead of inventing an instruction.

Diagnostics never fail the run: a degraded analysis is still worth having, and the whole
point is that you learn it is degraded. The webview reads the same field out of the
analysis it loads, so a diagram opened there carries the same warning. The one
degradation that does fail the run is a used-up LLM quota, below.

### When the LLM quota runs out

If the LLM provider refuses the analysis because the token quota is used up (HTTP 402),
CodeBoarding stops rather than publish a map without AI naming. The run fails, and:

- the annotation on the run page says why (`CodeBoarding LLM quota exhausted`);
- in review mode, the review's sticky comment is replaced with the reason and what to change,
and ends with the machine-readable line carrying `failure=llm_quota_exhausted`;
- the job summary carries the same text;
- nothing is published: sync commits no baseline and uploads no base analysis, and review
posts no diagram.

On `llm: hosted` the free tier's allowance is per GitHub owner per week and resets Monday
00:00 UTC. To analyze before then, use your own key (`llm: anthropic` with
`anthropic_api_key`, or any provider above), or a CodeBoarding license (`llm: license` with
`license_key`). Credentials the provider rejects outright stop the run the same way, as
`failure=llm_auth`.

This needs a CodeBoarding release that stops on quota; with an older engine the run
finishes on folder-named components instead.

## Model selection

All model inputs are optional and are passed directly to Core without action-side validation:
Expand Down
56 changes: 53 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,19 @@ runs:
retention-days: 30
if-no-files-found: ignore

# An analysis that finished is not an analysis that finished cleanly. The engine
# records every degradation it survived; without this the run is green and the
# committed diagram is short of a language with nothing to say so.
- name: Read analysis diagnostics

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prefix the commit subject with feat:

If this commit is merged or rebased with its current subject, Report analysis diagnostics in workflow runs is not a Conventional Commit, so release-please will skip this adopter-facing feature and will not propose the release that moves the v1 tag. Use a feat: subject for the commit and PR title so consumers actually receive the new workflow behavior.

AGENTS.md reference: AGENTS.md:L88-L93

Useful? React with 👍 / 👎.

id: sync_diagnostics
if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' && steps.sync_analyze.outputs.analysis_path != ''
continue-on-error: true
shell: bash
env:
ANALYSIS_PATH: ${{ steps.sync_analyze.outputs.analysis_path }}
DIAGNOSTICS_OUT: ${{ runner.temp }}/codeboarding-diagnostics.md
run: 'python3 "$GITHUB_ACTION_PATH/scripts/analysis_diagnostics.py" --analysis "$ANALYSIS_PATH" --out "$DIAGNOSTICS_OUT"'

- name: Write sync summary
if: always() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync'
shell: bash
Expand All @@ -507,6 +520,7 @@ runs:
FILES: ${{ steps.sync_commit.outputs.files_written }}
STRATEGY: ${{ inputs.sync_strategy }}
PR_URL: ${{ steps.sync_commit.outputs.sync_pr_url }}
DIAGNOSTICS_MD: ${{ steps.sync_diagnostics.outputs.markdown_path }}
run: "$GITHUB_ACTION_PATH/scripts/action/sync-summary.sh"

- name: Analyze pull request
Expand Down Expand Up @@ -568,6 +582,16 @@ runs:
retention-days: 30
if-no-files-found: ignore

- name: Read analysis diagnostics
id: review_diagnostics
if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_analyze.outputs.analysis_path != ''
continue-on-error: true
shell: bash
env:
ANALYSIS_PATH: ${{ steps.review_analyze.outputs.analysis_path }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include baseline diagnostics in review warnings

When the merge-base analysis is degraded but the subsequent head analysis completes cleanly, the rendered review still compares against an incomplete base and can report structures missing from that base as PR additions, yet this step reads only the head analysis path. Because review_analyze produces a separate base_analysis_path, its run diagnostics are not reliably represented by the head's per-run metadata; read and deduplicate diagnostics from both analyses before building the review comment.

Useful? React with 👍 / 👎.

DIAGNOSTICS_OUT: ${{ runner.temp }}/codeboarding-diagnostics.md
run: 'python3 "$GITHUB_ACTION_PATH/scripts/analysis_diagnostics.py" --analysis "$ANALYSIS_PATH" --out "$DIAGNOSTICS_OUT"'

- name: Render review diagram
id: review_render
if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review'
Expand Down Expand Up @@ -628,6 +652,7 @@ runs:
HEAD_SHA: ${{ steps.guard.outputs.head_sha }}
# Analysed files whose content hash differs between base and head, from the render step.
ANALYSED_FILES_CHANGED: ${{ steps.review_render.outputs.analysed_files_changed }}
DIAGNOSTICS_MD: ${{ steps.review_diagnostics.outputs.markdown_path }}
run: "$GITHUB_ACTION_PATH/scripts/action/build-review-comment.sh"

- name: Post review comment
Expand All @@ -653,12 +678,37 @@ runs:
BODY: ${{ steps.review_body.outputs.path }}
run: cat "$BODY" >> "$GITHUB_STEP_SUMMARY"

# Skipped when the run stopped on a credential problem: that path already replaced
# this same sticky comment with the input and secret to fix, and "see the workflow
# The engine stops rather than publish a map without AI naming when the LLM quota runs
# out or its credentials are refused. The run stays failed; this says why, in the job
# summary and (below) in the pull request, instead of "see the workflow logs".
- name: Read engine failure
id: engine_failure
if: failure() && steps.guard.outputs.skip != 'true' && (steps.sync_analyze.outcome == 'failure' || steps.review_analyze.outcome == 'failure')
continue-on-error: true
shell: bash
env:
MODE: ${{ steps.guard.outputs.mode }}
LLM: ${{ inputs.llm }}
PR_NUMBER: ${{ steps.guard.outputs.pr_number }}
HEAD_SHA: ${{ steps.guard.outputs.head_sha }}
run: 'python3 "$GITHUB_ACTION_PATH/scripts/action/engine_failure.py"'

- name: Report engine failure
if: failure() && steps.engine_failure.outputs.reason != '' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.pr_number != ''
continue-on-error: true
uses: marocchino/sticky-pull-request-comment@v2
with:
header: ${{ steps.guard.outputs.comment_id }}
number: ${{ steps.guard.outputs.pr_number }}
GITHUB_TOKEN: ${{ inputs.github_token }}
path: ${{ steps.engine_failure.outputs.body_path }}

# Skipped when the run stopped on a credential problem or an engine refusal: those
# paths replaced this same sticky comment with what to fix, and "see the workflow
# logs" posted over the top of it would send the reader hunting for what they had
# just been told.
- name: Post review failure
if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_comment.outcome != 'success' && steps.llm.outputs.error == ''
if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_comment.outcome != 'success' && steps.llm.outputs.error == '' && steps.engine_failure.outputs.reason == ''
continue-on-error: true
uses: marocchino/sticky-pull-request-comment@v2
with:
Expand Down
3 changes: 3 additions & 0 deletions scripts/action/analyze.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ if [[ ! "$DEPTH_CAP" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::depth_cap must be a positive integer."
exit 1
fi
# analyze_repository.py records an engine refusal here for the steps that report it; one
# left by an earlier use of the action in this job is not this run's.
rm -f "$RUNNER_TEMP/codeboarding-engine-error.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stale engine failures before validation

When a job invokes the action twice and lets the first quota-aborted invocation continue, a second review with an invalid depth_cap exits at the validation above this cleanup. The old codeboarding-engine-error.json is therefore still present, so the subsequent Read engine failure step treats the second analysis failure as a quota exhaustion and replaces the PR's sticky comment with the wrong remediation. Remove the stale record before any early validation can exit.

Useful? React with 👍 / 👎.

parse_output() {
local output="$1"
ANALYSIS_MODE="$(awk -F= '$1 == "analysis_mode" {print $2; exit}' <<< "$output")"
Expand Down
6 changes: 6 additions & 0 deletions scripts/action/build-review-comment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ if [ "$ANALYSED_FILES_CHANGED" = "0" ] && [ "$N_CHANGED" != "0" ]; then
printf '\nNo file CodeBoarding analyses changed in this pull request, so the components marked below differ only because the analysis grouped the same code differently.\n' >> "$BODY"
fi
printf '\nSee the full change in [CodeBoarding](%s).\n' "$WEBVIEW_URL" >> "$BODY"
# Above the diagram, not below it: the whole point is that the picture that follows
# is missing something, and a caveat under it is read after the picture is believed.
if [ -n "${DIAGNOSTICS_MD:-}" ] && [ -s "${DIAGNOSTICS_MD}" ]; then
printf '\n' >> "$BODY"
cat "${DIAGNOSTICS_MD}" >> "$BODY"
fi
# The diagram compares against the merge base, so commits landed on the base
# branch since this PR forked are excluded. Say so rather than hide it.
BEHIND="${BEHIND_BY:-0}"
Expand Down
112 changes: 112 additions & 0 deletions scripts/action/engine_failure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Explain a run the engine refused to finish, in the pull request and the job summary.

analyze_repository.py records the engine's refusal (quota used up, credentials rejected) in
$RUNNER_TEMP/codeboarding-engine-error.json. This renders it for a person: a sticky comment
body in review mode, and the job summary in both modes. The run itself stays failed; this
only replaces "see the workflow logs" with the reason and what to change.

Writes `reason` (the engine's `kind`, empty when there is nothing to report) and `body_path`
to $GITHUB_OUTPUT.
"""

from __future__ import annotations

import json
import os
import sys
from pathlib import Path

ERROR_FILE = "codeboarding-engine-error.json"
DOCS = "https://github.com/CodeBoarding/CodeBoarding-action#authentication-and-providers"
HEADINGS = {
"llm_quota_exhausted": "stopped: LLM quota used up",
"llm_auth": "stopped: LLM credentials rejected",
}
OWN_KEY = "use your own LLM key: set `llm` to your provider and pass its key input, for example `llm: anthropic` with `anthropic_api_key`"
LICENSE = "use a CodeBoarding license: `llm: license` with `license_key`"


def _quota(llm: str) -> list[str]:
lines = ["The LLM provider refused the analysis because the token quota is used up."]
if llm == "hosted":
lines += [
"",
"On `llm: hosted` that is CodeBoarding's free tier, whose allowance is per GitHub owner per week and resets Monday 00:00 UTC. To analyze before then, either:",
"",
f"- {OWN_KEY};",
f"- or {LICENSE}.",
]
elif llm == "license":
lines += [
"",
f"On `llm: license` that is your CodeBoarding plan's allowance. To analyze before it renews, {OWN_KEY}.",
]
else:
lines += [
"",
f"On `llm: {llm}` that is the quota of your own provider account. Raise it with the provider, or {LICENSE}.",
]
return lines


def _auth(llm: str) -> list[str]:
return [
"The LLM provider rejected this run's credentials.",
"",
f"Check the key or license this workflow passes for `llm: {llm}`; see [Authentication and providers]({DOCS}).",
]


def render(error: dict, env: dict[str, str]) -> str:
kind = error["kind"]
mode = env.get("MODE", "review")
# credential_check.py accepts any case, so `Hosted` must read as hosted here too.
llm = env.get("LLM", "").strip().lower() or "hosted"
lines = [f"### CodeBoarding {mode} · {HEADINGS[kind]}", ""]
lines.append("CodeBoarding stopped instead of publishing a map without AI naming.")
lines += _quota(llm) if kind == "llm_quota_exhausted" else _auth(llm)
lines.append("")
if mode == "sync":
lines.append("The baseline was not updated, and no base analysis was published.")
else:
lines.append("No diagram was posted for this run.")
server = env.get("GITHUB_SERVER_URL", "https://github.com")
repository = env.get("GITHUB_REPOSITORY", "")
run_id = env.get("GITHUB_RUN_ID", "")
lines += [
"",
f"<sub>run [{run_id}]({server}/{repository}/actions/runs/{run_id}) · attempt {env.get('GITHUB_RUN_ATTEMPT', '1')}</sub>",
]
if mode == "review":
# The review comment's machine-readable line, with `failure` in place of the counts a
# stopped run does not have. One line, `key=value`, no spaces in values.
platform = f"https://app.codeboarding.org/{repository}/pull/{env.get('PR_NUMBER', '')}"
lines.append(f"<!-- codeboarding: platform_url={platform} head={env.get('HEAD_SHA', '')} failure={kind} -->")
return "\n".join(lines) + "\n"


def main() -> int:
env = dict(os.environ)
runner_temp = Path(env["RUNNER_TEMP"])
outputs = []
try:
error = json.loads((runner_temp / ERROR_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
error = None
if isinstance(error, dict) and error.get("kind") in HEADINGS:
body = render(error, env)
path = runner_temp / "codeboarding-engine-failure.md"
path.write_text(body, encoding="utf-8")
summary = env.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as handle:
handle.write(body)
outputs = [f"reason={error['kind']}", f"body_path={path}"]
with open(env["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle:
handle.writelines(line + "\n" for line in outputs)
return 0


if __name__ == "__main__":
sys.exit(main())
8 changes: 8 additions & 0 deletions scripts/action/sync-summary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,12 @@ set -euo pipefail
if [ -n "${PR_URL:-}" ]; then
echo "- Sync PR: ${PR_URL}"
fi
# A baseline that is short a language is committed and read for weeks. The
# bullets above cannot show that, so the engine's own account goes here.
if [ -n "${DIAGNOSTICS_MD:-}" ] && [ -s "${DIAGNOSTICS_MD}" ]; then
echo
echo "#### Analysis diagnostics"
echo
cat "${DIAGNOSTICS_MD}"
fi
} >> "$GITHUB_STEP_SUMMARY"
Loading
Loading