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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ The first sync after the merge creates `codeboarding/baseline`, catching up from
| `depth_cap` | both | `2` | Positive integer maximum analysis depth, including full-analysis fallbacks. Changing it rebuilds incompatible state. |
| `github_token` | both | `${{ github.token }}` | Token for comments and sync delivery. |
| `codeboarding_analysis_location` | both | `codeboarding_branch` | Where the synced branch's analysis is stored: `codeboarding_branch` (on `codeboarding/baseline`, for the default branch; the code is never written) or `in_place` (committed as `.codeboarding/` on the synced branch). Sync saves it there and review reads it from there. |
| `comment_detail` | review | `top` | What the PR comment's diagram draws. `top` is every top-level component. `specific` is only the most specific components that changed, flat, joined by the relations that changed, with an unchanged component only where a changed relation ends at it. It falls back to `top` when nothing changed or the diagram is too large for GitHub. Rendering only: changing it neither rebuilds nor invalidates any analysis. |
| `warmstart_retention_days` | review | `1` | Days to keep the reusable analysis. Only the next run reads it. |
| `review_guide_url` | review | `https://jxivqwx4or6oip3krgdmdaiunm0iaybf.lambda-url.us-east-1.on.aws/` | Experimental. Production service base URL for the pull request's review guide. The run sends it this analysis and stores `review_guide.json` in the review artifact. Set to `''` to disable. Never fails the run. |

Expand Down
11 changes: 10 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ inputs:
description: 'Positive integer analysis depth cap, used for both review and sync, including full fallbacks.'
required: false
default: '2'
comment_detail:
description: >-
What the pull request comment's diagram draws. top: every top-level component (default).
specific: only the most specific components that changed, flat, joined by the relations
that changed. Rendering only: it does not change the analysis or its reuse.
required: false
default: 'top'
review_guide_url:
description: >-
Experimental. The CodeBoarding service that writes a pull request's review guide (main
Expand Down Expand Up @@ -263,6 +270,7 @@ runs:
PULL_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
REPOSITORY: ${{ github.repository }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
COMMENT_DETAIL: ${{ inputs.comment_detail }}
run: "$GITHUB_ACTION_PATH/scripts/action/resolve-github-event.sh"

- name: Acknowledge review command
Expand Down Expand Up @@ -671,6 +679,7 @@ runs:
ACTION_PATH: ${{ github.action_path }}
BASE_ANALYSIS_PATH: ${{ steps.review_baseline.outputs.base_analysis_path }}
HEAD_ANALYSIS_PATH: ${{ steps.review_analyze.outputs.analysis_path }}
COMMENT_DETAIL: ${{ inputs.comment_detail }}
run: "$GITHUB_ACTION_PATH/scripts/action/render-review.sh"

- name: Build review artifact
Expand Down Expand Up @@ -939,6 +948,6 @@ runs:
message: |
### CodeBoarding review · failed

${{ steps.review_baseline.outputs.failure_reason || steps.review_analyze.outputs.failure_reason }}
${{ steps.guard.outputs.failure_reason || steps.review_baseline.outputs.failure_reason || steps.review_analyze.outputs.failure_reason }}

See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
2 changes: 1 addition & 1 deletion scripts/action/render-review.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ if [ -z "$HEAD_ANALYSIS" ] || [ ! -f "$HEAD_ANALYSIS" ]; then
fi
DIAGRAM_OUT="${RUNNER_TEMP}/diagram.md"
META="${RUNNER_TEMP}/diagram_meta.json"
DIFF="$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" --out "$DIAGRAM_OUT" --direction LR --render-depth 1)"
DIFF="$(python3 "$ACTION_PATH/scripts/diff_to_mermaid.py" --base "$BASE_ANALYSIS" --head "$HEAD_ANALYSIS" --out "$DIAGRAM_OUT" --direction LR --render-depth 1 --detail "${COMMENT_DETAIL:-top}")"
printf '%s' "$DIFF" > "$META"

read -r N_CHANGED TRUNCATED RENDERED EMPTY ANALYSED_FILES_CHANGED < <(jq -r '[.n_changed, .truncated, .rendered, .empty, (.analysed_files_changed // "unknown")] | @tsv' "$META")
Expand Down
6 changes: 6 additions & 0 deletions scripts/action/resolve-github-event.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,9 @@ is_fork=false
echo "comment_id=$comment_id"
echo "is_fork=$is_fork"
} >> "$GITHUB_OUTPUT"
# Last, so the failure comment has its pull request. Checked here, in seconds, rather than
# after the analysis it would otherwise be paid for.
case "${COMMENT_DETAIL:-top}" in
top|specific) ;;
*) fail "comment_detail must be top or specific." ;;
esac
115 changes: 101 additions & 14 deletions scripts/diff_to_mermaid.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@ def _relation_dict(relation) -> dict:


def _project_analysis(data: dict, root_analysis, sub_analyses: dict, id_to_name: dict, projector) -> dict:
"""Attach Core's global-relation projection to every nested JSON level."""
"""Attach Core's global-relation projection to every nested JSON level.

The unprojected relations are kept too: a level only holds edges with both ends inside it,
so an edge between two parents' children survives nowhere else.
"""
global_relations = list(root_analysis.components_relations)
data["leaf_relations"] = [_relation_dict(relation) for relation in global_relations]

def project_level(container: dict, analysis) -> None:
level_ids = {component.component_id for component in analysis.components}
Expand Down Expand Up @@ -215,6 +220,27 @@ def _owns_analysis_change(
return bool(owned_methods & changed_methods or owned_files & changed_files)


def _mark_change_owners(
components: list,
base_components: list,
changed_methods: set[str],
changed_files: set[str],
) -> None:
"""Note, at every level, which components hold code that changed."""
base_by_name = {_comp_name(component): component for component in base_components}
for component in components:
base_component = base_by_name.get(_comp_name(component))
if base_component is None:
continue
component["owns_change"] = _owns_analysis_change(base_component, component, changed_methods, changed_files)
_mark_change_owners(
component.get("components") or [],
base_component.get("components") or [],
changed_methods,
changed_files,
)


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.
Expand Down Expand Up @@ -332,26 +358,17 @@ def _diff_components(base_components: list, current_components: list) -> list:
def build_diff(base: dict, head: dict) -> dict:
changed_methods, changed_files = _analysis_changes(base, head)
components = _diff_components(base.get("components") or [], head.get("components") or [])
base_by_name = {_comp_name(component): component for component in base.get("components") or []}
_mark_change_owners(components, base.get("components") or [], changed_methods, changed_files)
for component in components:
base_component = base_by_name.get(_comp_name(component))
if (
component.get("diff_status") == "unchanged"
and base_component is not None
and _owns_analysis_change(
base_component,
component,
changed_methods,
changed_files,
)
):
if component.get("diff_status") == "unchanged" and component.get("owns_change"):
component["diff_status"] = "modified"
return {
"components": components,
"components_relations": _diff_relations(
base.get("components_relations") or [],
head.get("components_relations") or [],
),
"leaf_relations": _diff_relations(base.get("leaf_relations") or [], head.get("leaf_relations") or []),
}


Expand Down Expand Up @@ -492,6 +509,53 @@ def touches(r: dict, side_id: str, side_name: str) -> bool:
return kept, rels


def _flat(comp: dict, status: str) -> dict:
return {"component_id": _comp_id(comp), "name": _comp_name(comp), "diff_status": status}


def _deepest_changed(components: list, force: str | None = None) -> list:
"""The changed components with nothing changed below them, as one flat list.

Status is read before ``display_status``, which only says something below a component
changed. A body-only edit moves no method between components, so nested components carry
it as ``owns_change`` alone.
"""
deepest: list = []
for comp in components:
status = force or comp.get("diff_status", "unchanged")
child_force = status if status in ("added", "deleted") else None
below = _deepest_changed(comp.get("components") or [], child_force)
if below:
deepest.extend(below)
elif status in CHANGED:
deepest.append(_flat(comp, status))
elif comp.get("owns_change"):
deepest.append(_flat(comp, "modified"))
return deepest


def _specific_view(diff: dict) -> dict:
"""The deepest changed components joined by every changed relation, as a one-level diff."""
components = _deepest_changed(diff.get("components") or [])
relations = [r for r in diff.get("leaf_relations") or [] if r.get("diff_status") in CHANGED]

known: dict = {}

def index(comps: list) -> None:
for comp in comps:
known[_comp_name(comp)] = comp

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 Keep component IDs when flattening duplicate names

When separate parents contain children with the same name and a changed leaf relation targets one of them, this name-only index overwrites one component with the other. The flattened renderer then resolves the relation by that duplicate name before considering its distinct component_id, so the specific diagram can omit the intended endpoint and attach the edge to the wrong component; index and track flattened endpoints by ID as well as name.

Useful? React with 👍 / 👎.

index(comp.get("components") or [])

index(diff.get("components") or [])
drawn = {_comp_name(c) for c in components}
for rel in relations: # an unchanged component is drawn only for a changed edge that ends at it
for name in (rel.get("src_name"), rel.get("dst_name")):
if name in known and name not in drawn:
drawn.add(name)
components.append(_flat(known[name], "unchanged"))

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 Preserve the actual status of relation endpoints

When a changed parent has changed descendants and a changed relation ends at that parent, _deepest_changed initially includes only the descendants, so this branch re-adds the parent but unconditionally marks it unchanged. For example, deleting a parent with children and one of its relations produces a normal, uncolored parent node attached to a red deleted edge; use the endpoint component's existing diff_status rather than forcing unchanged.

Useful? React with 👍 / 👎.

return {"components": components, "components_relations": relations}


def _init_directive(font_size, node_padding, node_spacing, rank_spacing) -> str | None:
"""Build a Mermaid ``%%{init}%%`` directive to enlarge nodes / spacing.

Expand Down Expand Up @@ -661,6 +725,22 @@ def emit_level(comps, rels, indent, force, level):
return text, meta


def render_specific(diff: dict, **options) -> tuple:
"""``render_mermaid`` of the specific view, or of ``diff`` when that cannot be drawn.

The counts describe the change, not the picture, so they stay the whole diff's.
"""
top_mermaid, top_meta = render_mermaid(diff, **options)
view = _specific_view(diff)
if not view["components"]: # nothing changed
return top_mermaid, top_meta
mermaid, meta = render_mermaid(view, **options)
if mermaid is None:
print("::warning::The specific diagram is too large to draw; drawing the top level.", file=sys.stderr)
return top_mermaid, {**top_meta, "truncated": True}
return mermaid, {**meta, "n_changed": top_meta["n_changed"], "changed": top_meta["changed"]}


# --------------------------------------------------------------------------- #
# cli
# --------------------------------------------------------------------------- #
Expand All @@ -680,6 +760,12 @@ def main() -> int:
default=1,
help="Component levels to draw: 1=top-level flat, 2=+one nesting level, ...",
)
p.add_argument(
"--detail",
default="top",
choices=["top", "specific"],
help="top=the --render-depth levels, specific=only the deepest changed components, flat",
)
p.add_argument(
"--font-size", type=int, default=None, help="Node label font size in px (bigger label ⇒ bigger node)"
)
Expand All @@ -690,7 +776,8 @@ def main() -> int:

base, head = load_analysis(args.base), load_analysis(args.head)
diff = build_diff(base, head)
mermaid, meta = render_mermaid(
render = render_specific if args.detail == "specific" else render_mermaid
mermaid, meta = render(
diff,
direction=args.direction,
changed_only=args.changed_only,
Expand Down
13 changes: 13 additions & 0 deletions tests/test_action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ def test_depth_is_wired_to_state_identity_and_both_analysis_modes(self) -> None:
block = ACTION[start : ACTION.index("\n run:", start)]
self.assertIn("DEPTH_CAP: ${{ inputs.depth_cap }}", block)

def test_comment_detail_reaches_rendering_and_nothing_else(self) -> None:
"""It changes one picture. Reaching a cache key or the engine would rebuild analyses for it."""
self.assertIn("default: 'top'", self.inputs["comment_detail"])
self.assertNotIn("comment_depth", self.inputs, "a free number can select nested subgraphs")
self.assertEqual(ACTION.count("inputs.comment_detail"), 2)
for identifier in ("id: guard", "id: review_render"):
start = ACTION.index(identifier)
block = ACTION[start : ACTION.index("\n run:", start)]
self.assertIn("COMMENT_DETAIL: ${{ inputs.comment_detail }}", block)
sources = [*(ROOT / "scripts").rglob("*.sh"), *(ROOT / "scripts").rglob("*.py")]
readers = {p.name for p in sources if "COMMENT_DETAIL" in p.read_text(encoding="utf-8")}
self.assertEqual(readers, {"render-review.sh", "resolve-github-event.sh"})

def test_default_workflow_reviews_drafts_on_open_and_new_commits(self) -> None:
self.assertNotIn("github.event.pull_request.draft", DOGFOOD)
start = DOGFOOD.index("types:")
Expand Down
Loading
Loading