diff --git a/.aspect/axl.axl b/.aspect/axl.axl index 329a4f427..ca575baf2 100644 --- a/.aspect/axl.axl +++ b/.aspect/axl.axl @@ -31,6 +31,7 @@ load("@aspect//lib/repro_commands.axl", "build_aspect_command", "dedup_and_attri load("@aspect//lib/result_text.axl", "severity_for_status") load("@aspect//lib/runnable.axl", "apparent_label", "runnable") load("@aspect//lib/sarif.axl", "get_sarif_summary", "parse_sarif", "parse_sarif_diagnostics", "sarif_to_review_comments") +load("@aspect//lib/template_styles.axl", "resolve_style") load("@aspect//lib/tips.axl", "tips") load("@aspect//lint.axl", "file_uri_to_path", "filter_all", "filter_changeset", "linter_error_message") load("@demo//answer.axl", "ANSWER") @@ -3378,6 +3379,22 @@ def test_bazel_render_snippets(ctx: TaskContext, tc: int, temp_dir: str) -> int: tc = test_case(tc, "…\nline 45" in out3["text"], "render: dropped-head truncation marker (leading …)") tc = test_case(tc, "line 49" in out3["text"], "render: windowed snippet keeps the tail") + # Strata style: same fixture `r` (snippets + per-failure repros), rendered + # through the Strata templates. The DX layout leads with a verdict heading + + # KPI strip, shows each failure snippet open with its repro beneath it, and + # keeps the whole-run command out of the combined Reproduce section. + _strata = resolve_style("strata", {}) + _st = {"summary": _strata["summary"], "details": _strata["details"]} + out_s = render_check_output(ctx, r, "failed", render_ctx, links, templates = _st, snippet_options = opts, max_snippets = max_snips) + tc = test_case(tc, "### ❌ Failed" in out_s["text"], "strata: verdict heading") + tc = test_case(tc, "### πŸ’₯ What broke" in out_s["text"], "strata: what-broke section") + tc = test_case(tc, "FAILED: expected 1 got 2" in out_s["text"], "strata: inlines failed-test snippet") + tc = test_case(tc, "no member named 'foo'" in out_s["text"], "strata: inlines failed-action snippet") + tc = test_case(tc, "aspect test -- //pkg:fail_test" in out_s["text"], "strata: per-failure aspect repro inline") + tc = test_case(tc, "aspect build -- //pkg:broken" in out_s["text"], "strata: per-failure action repro inline") + _s_repro = out_s["text"][out_s["text"].find("πŸ” Reproduce"):] if "πŸ” Reproduce" in out_s["text"] else "" + tc = test_case(tc, "//pkg:fail_test" not in _s_repro, "strata: combined Reproduce excludes per-failure commands") + ctx.std.fs.remove_dir_all(d) return tc diff --git a/.aspect/config.axl b/.aspect/config.axl index 85a678d99..a098f9041 100644 --- a/.aspect/config.axl +++ b/.aspect/config.axl @@ -32,6 +32,7 @@ load("@aspect//lib/runnable_test.axl", "runnable_tests") load("@aspect//lib/runner_job_history_test.axl", "runner_job_history_tests") load("@aspect//lib/sandbox_recovery_test.axl", "sandbox_recovery_tests") load("@aspect//lib/tar_test.axl", "tar_tests") +load("@aspect//lib/template_styles_test.axl", "template_styles_tests") load("@aspect//lib/tips_test.axl", "tips_tests") load("@aspect//lib/wrapper_test.axl", "wrapper_tests") load("@aspect//tips.axl", "TASK_SCREENS", "TIP_SUGGESTION", "TIP_TEMPLATE_RAW", "add_tip") @@ -272,6 +273,11 @@ def config(ctx: ConfigContext): # Run with: aspect dev test-tar ctx.tasks.add(tar_tests) + # Template-style machinery: resolve_style selection + override layering, + # compute_strata_kpi verdict/thresholds/slow-detection. + # Run with: aspect dev test-template-styles + ctx.tasks.add(template_styles_tests) + # CircleCI S3 error parsing: expired-credential 400 β†’ legible reason. # Run with: aspect dev test-circleci ctx.tasks.add(circleci_tests) diff --git a/crates/aspect-cli/src/builtins/aspect/README.md b/crates/aspect-cli/src/builtins/aspect/README.md index 747805458..41575f40b 100644 --- a/crates/aspect-cli/src/builtins/aspect/README.md +++ b/crates/aspect-cli/src/builtins/aspect/README.md @@ -218,5 +218,6 @@ Behavior notes: | [lib/format_results.axl](lib/format_results.axl) | `init_data`, `render_check_output`, `format_summary_title` | format | | [lib/gazelle_results.axl](lib/gazelle_results.axl) | `init_data`, `render_check_output`, `gazelle_summary_title` | gazelle | | [lib/delivery_results.axl](lib/delivery_results.axl) | `init_data`, `add_result`, `render_check_output`, `delivery_summary_title` | delivery | +| [lib/template_styles.axl](lib/template_styles.axl) | `resolve_style`, `builtin_style`, `STYLE_NAMES`, `STYLE_ARG_DESCRIPTION` (Strata strings in [lib/strata_templates.axl](lib/strata_templates.axl)) | every status feature (the `style` arg) | Each `*_results.axl` derives its `init_data()` from `bazel_results.init_data()` (so `process_event` can populate the full bazel state) and appends `SHARED_DETAILS_BODY_TEMPLATE` to its task-specific top section so the rendered details body has the same Targets / Build Metrics / Invocation / Workflows Runner / Workspace Status / Build Metadata / Options-parsed sections everywhere. diff --git a/crates/aspect-cli/src/builtins/aspect/feature/buildkite_annotations.axl b/crates/aspect-cli/src/builtins/aspect/feature/buildkite_annotations.axl index 78431652f..11e45cc6c 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/buildkite_annotations.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/buildkite_annotations.axl @@ -34,6 +34,7 @@ load("../lib/environment.axl", "feature_logger", "workflows_results_url") load("../lib/lifecycle.axl", "TaskLifecycleTrait", "TaskUpdate") load("../lib/rate_limit.axl", "usage_footer") load("../lib/result_text.axl", "severity_for_status") +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") load("../lib/tips.axl", "SURFACE_BUILDKITE_ANNOTATIONS", "collect_tips_sorted") # ─── Style selection ────────────────────────────────────────────────────────── @@ -205,6 +206,10 @@ def _buildkite_annotations(ctx: FeatureContext): mode = ctx.args.mode is_bk = bool(ctx.std.env.var("BUILDKITE")) + # Resolve the named style into the {summary, details} the renderer consumes. + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + _templates = {"summary": _style["summary"], "details": _style["details"]} + _LOG.trace("starting (mode=%s BUILDKITE=%r is_bk=%s)" % (mode, ctx.std.env.var("BUILDKITE") or "", is_bk)) @@ -322,7 +327,7 @@ def _buildkite_annotations(ctx: FeatureContext): status, make_render_ctx(_state, tips = collect_tips_sorted(ctx, surface = SURFACE_BUILDKITE_ANNOTATIONS), api_usage = usage_footer(ctx), last_updated_at = format_run_date(ctx, now_ms(ctx))), _make_links(data), - None, + _templates, None, snippet_options = _snippet_opts, max_snippets = _max_snippets, @@ -357,5 +362,18 @@ BuildkiteAnnotations = feature( "less-responsive live annotations. The task's terminal result and phase " + "boundaries always land immediately regardless of this setting.", ), + "style": args.string( + default = "strata", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), + "templates": args.custom( + dict, + default = {}, + description = "Map of template overrides for the rendered annotation body. " + + "Keys: \"summary\", \"details\". Each value is a Jinja2 template string. " + + "Empty dict (default) uses the selected `style`'s templates. Overrides " + + "layer on top of `style`.", + ), }, ) diff --git a/crates/aspect-cli/src/builtins/aspect/feature/github_status_checks.axl b/crates/aspect-cli/src/builtins/aspect/feature/github_status_checks.axl index fc087c179..25e3ce0ea 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/github_status_checks.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/github_status_checks.axl @@ -56,6 +56,7 @@ load( "should_emit_phase_change", "usage_footer", ) +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") load("../lib/tips.axl", "SURFACE_GITHUB_STATUS_CHECKS", "collect_tips_sorted") load("../lint.axl", "LintTrait") @@ -93,7 +94,12 @@ def _collect_ci_links(ctx, results_base_url): def _github_status_checks(ctx: FeatureContext): lifecycle = ctx.traits[TaskLifecycleTrait] - templates = ctx.args.templates or {} + # Resolve the named style (kilgore default) into its template bundle, then + # layer the per-key `templates` overrides on top (a custom variant). The + # resolved {summary, details} pair is what the renderer consumes; selecting + # the default style is byte-identical to the pre-style behavior. + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + templates = {"summary": _style["summary"], "details": _style["details"]} metadata_keys = list(ctx.args.metadata_keys or []) # Check-run URL is published below via `checkrun.set_url`; siblings @@ -490,12 +496,19 @@ GithubStatusChecks = feature( default = True, description = "Restrict lint check-run annotations to the displayed hunk regions of changed files (added lines + surrounding context from `detect_changed_files`). When false, off-region annotations are still posted but GitHub only renders them on the check-run detail page and commit page, not in Files Changed. Skipped silently when no changed-files context is available.", ), + "style": args.string( + default = "strata", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), "templates": args.custom( dict, default = {}, description = "Map of template overrides for the rendered check-run output. " + "Keys: \"summary\", \"details\". Each value is a Jinja2 template " + - "string. Empty dict (default) uses the feature's built-in templates.", + "string. Empty dict (default) uses the selected `style`'s templates. " + + "Overrides layer on top of `style`, so you can pick a style and replace " + + "just one slot.", ), "metadata_keys": args.string_list( default = [], diff --git a/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments.axl b/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments.axl index 866a70025..4d28a1b6b 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments.axl @@ -82,6 +82,7 @@ load( load("../lib/repro_commands.axl", "ASPECT_CLI_INSTALL_HINT_MARKDOWN", "dedup_and_attribute", "rehydrate_commands") load("../lib/result_text.axl", "severity_for_status") load("../lib/tar.axl", "bsdtar") +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") load( "../lib/tips.axl", "SURFACE_GITHUB_PR_SUMMARY", @@ -1264,9 +1265,13 @@ def _github_status_comments(ctx: FeatureContext): min_poll_interval_seconds = ctx.args.min_poll_interval_seconds artifact_expires_minutes = ctx.args.artifact_expires_minutes + # Resolve the named style (kilgore default), then layer the per-key + # `templates` overrides. The Kilgore bundle carries no `body` (the body + # template lives in this feature), so it falls back to _DEFAULT_BODY_TEMPLATE; + # Strata supplies its own body; a `templates["body"]` override wins over both. # args.custom(dict, ...) hands None at runtime, not the declared default. - templates = ctx.args.templates or {} - body_template = templates.get("body") or _DEFAULT_BODY_TEMPLATE + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + body_template = _style["body"] or _DEFAULT_BODY_TEMPLATE status_badges = dict(_DEFAULT_STATUS_BADGES) status_badges.update(ctx.args.status_badges or {}) @@ -1988,6 +1993,11 @@ GithubStatusComments = feature( "Set to 0 to disable (no expiry).", ), # Config-only (set via configure(..., feature_args = {...}) in config.axl). + "style": args.string( + default = "strata", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), "templates": args.custom( dict, default = {}, diff --git a/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments_test.axl b/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments_test.axl index 89dc26698..af86ac76a 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments_test.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/github_status_comments_test.axl @@ -72,6 +72,7 @@ load( load("../lib/bazel_results.axl", "compute_reproducer_command") load("../lib/github.axl", "github") load("../lib/repro_commands.axl", "rehydrate_commands") +load("../lib/template_styles.axl", "resolve_style") load("../lib/tips.axl", "tips") def _eq(label, got, want): @@ -2332,8 +2333,29 @@ def _test_impl(ctx): print(body) print("") + # Strata style: render a subset through the Strata body template so any + # Jinja/data-shape regression in the aggregated comment surfaces here. + strata = resolve_style("strata", {}) + for (label, raw_entries) in scenarios: + prepared = prepare_for_render(raw_entries) + sections = bucket_entries(prepared) + body = render_body( + ctx, + MARKER_FMT % 1234, + prepared, + "Fri May 10 07:16:05 UTC 2024", + strata["body"], + DEFAULT_STATUS_BADGES, + sections, + ) + print(sep) + print("SCENARIO: strata Β· " + label) + print(sep) + print(body) + print("") + print(sep) - print("All %d PR-comment scenarios rendered without error." % len(scenarios)) + print("All %d PR-comment scenarios rendered without error (Kilgore + Strata)." % len(scenarios)) return 0 def _aborted(data, reason): diff --git a/crates/aspect-cli/src/builtins/aspect/feature/gitlab_commit_statuses.axl b/crates/aspect-cli/src/builtins/aspect/feature/gitlab_commit_statuses.axl index da5e111c0..a44f5f23c 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/gitlab_commit_statuses.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/gitlab_commit_statuses.axl @@ -87,6 +87,7 @@ load( "effective_throttle_factor", "should_emit_phase_change", ) +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") _LOG = feature_logger("GitLab commit statuses") @@ -152,7 +153,9 @@ def _state_for(status, final): def _gitlab_commit_statuses(ctx: FeatureContext): lifecycle = ctx.traits[TaskLifecycleTrait] - templates = ctx.args.templates or {} + # Resolve the named style (kilgore default) + per-key `templates` overrides. + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + templates = {"summary": _style["summary"], "details": _style["details"]} metadata_keys = list(ctx.args.metadata_keys or []) mode = ctx.args.mode @@ -428,12 +431,18 @@ GitlabCommitStatuses = feature( "or the task runs longer. The task's terminal result always lands immediately " + "so the final state shows up without delay.", ), + "style": args.string( + default = "strata", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), "templates": args.custom( dict, default = {}, description = "Map of template overrides for the rendered status output. " + "Keys: \"summary\", \"details\". Each value is a Jinja2 template " + - "string. Empty dict (default) uses the feature's built-in templates. " + + "string. Empty dict (default) uses the selected `style`'s templates; " + + "overrides layer on top of `style`. " + "Note: only the rendered `title` lands on the commit status (in " + "`description`, truncated to 255 chars) β€” the full body is rendered " + "into the GitlabStatusComments rolling note.", diff --git a/crates/aspect-cli/src/builtins/aspect/feature/gitlab_status_comments.axl b/crates/aspect-cli/src/builtins/aspect/feature/gitlab_status_comments.axl index 260599f40..b514fe50e 100644 --- a/crates/aspect-cli/src/builtins/aspect/feature/gitlab_status_comments.axl +++ b/crates/aspect-cli/src/builtins/aspect/feature/gitlab_status_comments.axl @@ -76,6 +76,7 @@ load( "epoch_now_s", "usage_footer", ) +load("../lib/template_styles.axl", "STYLE_ARG_DESCRIPTION", "STYLE_NAMES", "resolve_style") _LOG = feature_logger("GitLab status comments") @@ -153,8 +154,11 @@ def _gitlab_status_comments(ctx: FeatureContext): ci_run_url = _ci_run_url(env) min_poll_interval_seconds = max(1, ctx.args.min_poll_interval_seconds) - templates = ctx.args.templates or {} - body_template = templates.get("body") or DEFAULT_BODY_TEMPLATE + # Resolve the named style, then layer per-key `templates` overrides. Kilgore + # carries no body (falls back to the shared GitHub body template); Strata + # supplies its own; a `templates["body"]` override wins over both. + _style = resolve_style(ctx.args.style, ctx.args.templates or {}) + body_template = _style["body"] or DEFAULT_BODY_TEMPLATE status_badges = dict(DEFAULT_STATUS_BADGES) status_badges.update(ctx.args.status_badges or {}) @@ -442,13 +446,18 @@ GitlabStatusComments = feature( "the cost stays flat regardless of how many run in parallel. Terminal task " + "verdicts always land immediately.", ), + "style": args.string( + default = "strata", + values = STYLE_NAMES, + description = STYLE_ARG_DESCRIPTION, + ), "templates": args.custom( dict, default = {}, description = "Per-section Jinja2 template overrides. Keys: 'body'. " + "Vars available match the GithubStatusComments template contract " + - "(see feature/github_status_comments.axl). Empty dict β†’ built-in default. " + - "config.axl only.", + "(see feature/github_status_comments.axl). Empty dict β†’ selected `style`'s " + + "body. Overrides layer on top of `style`. config.axl only.", ), "status_badges": args.custom( dict, diff --git a/crates/aspect-cli/src/builtins/aspect/lib/bazel_results.axl b/crates/aspect-cli/src/builtins/aspect/lib/bazel_results.axl index 4c906dea7..c83827eb6 100644 --- a/crates/aspect-cli/src/builtins/aspect/lib/bazel_results.axl +++ b/crates/aspect-cli/src/builtins/aspect/lib/bazel_results.axl @@ -3465,6 +3465,177 @@ def _build_invocation_stats(data): "miss_reasons_table": miss_reasons_pre, } +# Strata KPI thresholds: (green-floor, amber-floor). A value β‰₯ green-floor is +# 🟒, β‰₯ amber-floor is 🟑, else πŸ”΄. Echo the Strata web UI's tile colors, which +# markdown can't set directly. `_kpi_emoji` maps a value through these. +_KPI_CACHE_GREEN = 85 +_KPI_CACHE_AMBER = 60 +_KPI_PARALLEL_GREEN = 4.0 +_KPI_PARALLEL_AMBER = 1.5 + +# Critical-path / analysis SHARE-of-wall thresholds are inverted (lower is +# better): a share at/under green-ceiling is 🟒, at/under amber-ceiling is 🟑. +_KPI_CRIT_SHARE_GREEN = 40 +_KPI_CRIT_SHARE_AMBER = 60 + +def _kpi_emoji_high(value, green_floor, amber_floor): + """🟒/🟑/πŸ”΄ for a higher-is-better KPI (cache hit %, parallelism).""" + if value >= green_floor: + return "🟒" + if value >= amber_floor: + return "🟑" + return "πŸ”΄" + +def _kpi_emoji_low(value, green_ceiling, amber_ceiling): + """🟒/🟑/πŸ”΄ for a lower-is-better KPI (critical-path share of wall).""" + if value <= green_ceiling: + return "🟒" + if value <= amber_ceiling: + return "🟑" + return "πŸ”΄" + +def compute_strata_kpi(data, status): + """Compute the Strata KPI strip: a verdict line + the at-a-glance health + numbers (cache hit %, parallelism, wall, critical-path share), each paired + with a 🟒/🟑/πŸ”΄ threshold emoji that stands in for the web UI's tile color. + + Returns a dict the Strata `details`/`body` templates read. Before any + build_metrics arrive it carries only the verdict (the heading still renders) + with an empty `strip_lines`, so the fenced KPI block is skipped. Shape: + + { + "verdict_emoji": "πŸ”΄", "verdict_word": "Failed", + "slow": bool, # green-but-slow β†’ auto-expand perf + "cache": {"emoji","value","sub"} | None, + "parallelism": {...} | None, + "wall": {"emoji","value","sub"} | None, + "strip_lines": [str, ...], # pre-aligned monospace KPI lines + } + + `slow` fires when wall is the dominant cost AND there's a legible reason β€” + a low cache hit or low parallelism β€” so the perf section always has + something to point at when it auto-expands. Pure-numeric inputs come from + `data["bazel"]`; thresholds are the `_KPI_*` constants. + """ + b = data["bazel"] + wall_ms = b.get("wall_time_ms", 0) or 0 + cpu_ms = b.get("cpu_time_ms", 0) or 0 + critical_ms = b.get("critical_path_ms", 0) or 0 + + executed, cached, total = _effective_action_cache_counts(data) + has_metrics = wall_ms > 0 or total > 0 + if not has_metrics: + # No build_metrics yet β€” surface the verdict heading anyway (computable + # from status), with an empty strip so the fenced KPI block is skipped. + v_emoji, v_word = _strata_verdict(data, status, False) + return { + "verdict_emoji": v_emoji, + "verdict_word": v_word, + "slow": False, + "cache": None, + "parallelism": None, + "wall": None, + "strip_lines": [], + } + + cache = None + if total > 0: + pct = (cached * 100) // total + cache = { + "emoji": _kpi_emoji_high(pct, _KPI_CACHE_GREEN, _KPI_CACHE_AMBER), + "value": str(pct) + "%", + "sub": format_int_comma(cached) + " / " + format_int_comma(total) + " actions", + } + + parallelism = None + if wall_ms > 0 and cpu_ms > 0: + ratio = float(cpu_ms) / float(wall_ms) + + # One decimal, e.g. "6.2Γ—". + tenths = int(ratio * 10 + 0.5) + parallelism = { + "emoji": _kpi_emoji_high(ratio, _KPI_PARALLEL_GREEN, _KPI_PARALLEL_AMBER), + "value": str(tenths // 10) + "." + str(tenths % 10) + "Γ—", + "sub": "cpu " + format_duration_ms(cpu_ms), + } + + wall = None + if wall_ms > 0: + crit_sub = "" + wall_emoji = "🟒" + if critical_ms > 0: + share = (critical_ms * 100) // wall_ms + wall_emoji = _kpi_emoji_low(share, _KPI_CRIT_SHARE_GREEN, _KPI_CRIT_SHARE_AMBER) + crit_sub = "crit path " + format_duration_ms(critical_ms) + " (" + str(share) + "%)" + wall = { + "emoji": wall_emoji, + "value": format_duration_ms(wall_ms), + "sub": crit_sub, + } + + # Green-but-slow: terminal pass where the build's cost is dominated by a + # legible bottleneck (poor cache reuse or poor parallelism). The verdict + # then reads "Passed, but slow" and the perf section auto-expands. + cache_red = cache != None and cache["emoji"] == "πŸ”΄" + parallel_red = parallelism != None and parallelism["emoji"] == "πŸ”΄" + is_pass = status == "passed" + slow = is_pass and (cache_red or parallel_red) + + verdict_emoji, verdict_word = _strata_verdict(data, status, slow) + + # Pre-align the strip as one line per KPI: "