clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Put a commit's type in a column, and its risk as a small score #10

6 files changed, +77 -14
Mlib/forge.ts4+-
Mlib/jev-commits.ts7+++--
Mlib/types.ts2+
Msite/_includes/macros.njk18+++++++++----
Msite/assets/style.css18+++++++++++--
Atest/scenarios/log-columns.sh42++++++++++++++++++++++++++++++
M lib/forge.ts +2 -2
M lib/jev-commits.ts +4 -3
@@ -138,7 +138,7 @@ function messages(gitDir: string, shas: string[]): Map<string, Message> {
138138 // rebase and `git cherry` recognize a change. The patch streams from one git139139 // to the other without passing through here. A commit that changes nothing140140 // gets no id. Null when git failed.141-function patchIds(gitDir: string, shas: string[]): Map<string, string> | null {141+export function patchIds(gitDir: string, shas: string[]): Map<string, string> | null {142142 const ids = new Map<string, string>();143143 if (!shas.length) return ids;144144 const log = 'git -c core.quotepath=false --git-dir="$1" log --no-walk=unsorted --stdin --no-color --no-ext-diff --no-renames --diff-merges=first-parent --root -p --format="commit %H"';
@@ -153,7 +153,7 @@ function patchIds(gitDir: string, shas: string[]): Map<string, string> | null {
153153 154154 // A commit's key; "empty" stands in for the patch id of a commit that changes155155 // no lines. Null when it changes lines but has no patch id.156-function commitKey(id: string | undefined, subject: string, body: string, lines: number): string | null {156+export function commitKey(id: string | undefined, subject: string, body: string, lines: number): string | null {157157 const pid = id || (lines ? null : 'empty');158158 return pid && `${pid}-${hash(`${subject}\n\n${body}`, 10)}`;159159 }
@@ -277,6 +277,7 @@ function label(a: CommitAnswers): CommitLabel | null {
277277 return {278278 kind,279279 risk: risk >= 2.5 ? 'high' : risk >= 1.5 ? 'moderate' : null,280+ riskScore: a.risk?.score == null ? null : Math.round(a.risk.score * 10) / 10,280281 mismatch: (a.matches?.noul ?? 1) < MISMATCH,281282 section: section && Object.hasOwn(SECTIONS, section) ? section : null,282283 userFacing: (a.user_facing?.noul ?? 0) >= 0.5,
@@ -315,7 +316,7 @@ export function labelCommits(gitDir: string, commits: Iterable<Labelled>, cache:
315316 }316317 317318 // What the pages show of a label, for page fingerprints.318-export const labelKey = (c: { jev?: CommitLabel | null }): string => (c.jev ? `${c.jev.kind}:${c.jev.risk ?? ''}:${c.jev.mismatch ? 1 : 0}` : '');319+export const labelKey = (c: { jev?: CommitLabel | null }): string => (c.jev ? `${c.jev.kind}:${c.jev.riskScore ?? ''}:${c.jev.mismatch ? 1 : 0}` : '');319320 320321 // What the repo is, if Jev was sure enough and it is one of the named kinds.321322 export function repoTopic(repo: Repo): string | null {
M lib/types.ts +2 -0
M site/_includes/macros.njk +12 -6
@@ -25,18 +25,25 @@
2525 2626 {# Jev's label for a commit (lib/jev-commits.ts): its kind, its risk when2727 moderate or high, and a note when its message may not describe its diff. #}28-{% macro jevLabel(c) %}{% if c.jev %} <span class="jev-kind {{ c.jev.kind }}" title="kind of change, by Jev">{{ c.jev.kind }}</span>{% if c.jev.risk %} <span class="jev-risk {{ c.jev.risk }}" title="how likely it is to break something, by Jev">{{ c.jev.risk }} risk</span>{% endif %}{% if c.jev.mismatch %} <span class="jev-note">message may not match the diff</span>{% endif %}{% endif %}{% endmacro %}28+{# Jev on a commit (lib/jev-commits.ts): its kind, its risk as the score Jev29+ gave (0 can't break anything, 3 likely to), and a mark when the message30+ may not describe the diff. Estimates from the diff, not findings. #}31+{% macro jevKind(c) %}{% if c.jev %}<span class="jev-kind {{ c.jev.kind }}" title="kind of change, Jev's estimate">{{ c.jev.kind }}</span>{% endif %}{% endmacro %}32+{% macro jevRisk(c) %}{% if c.jev and c.jev.riskScore != null %}<span class="jev-score{% if c.jev.risk %} {{ c.jev.risk }}{% endif %}" title="risk score {{ c.jev.riskScore | tenths }} of 3: Jev's estimate from the diff of how likely this is to break something that worked (0 can't, 3 likely)">{{ c.jev.riskScore | tenths }}</span>{% endif %}{% endmacro %}33+{% macro jevMismatch(c) %}{% if c.jev and c.jev.mismatch %} <span class="jev-mismatch" title="the message may not describe the diff, Jev's estimate">&ne;</span>{% endif %}{% endmacro %}34+{% macro jevLabel(c) %}{% if c.jev %}{{ jevKind(c) }}{% if c.jev.riskScore != null %} <span class="desc">risk score</span> {{ jevRisk(c) }}<span class="desc"> of 3, Jev's estimate</span>{% endif %}{% if c.jev.mismatch %} <span class="jev-note">the message may not describe the diff</span>{% endif %}{% endif %}{% endmacro %}2935 3036 {# What a repo is, by Jev (lib/jev-commits.ts): s is a repo summary or a home page. #}3137 {% macro topic(s) %}{% if s.topic %} <span class="jev-topic" title="what this repo is, by Jev">{{ s.topic }}</span>{% endif %}{% endmacro %}3238 3339 {# Commit list: subject first; author, date, size and CI as the meta line on phones. #}3440 {% macro log(commits, repo) %}35-<table class="list stack nav">36-<thead><tr><th>Commit</th><th>Author</th><th>Date</th><th class="num">+</th><th class="num">-</th><th>CI</th></tr></thead>41+{% set jev = commits | selectattr('jev') | length %}42+<table class="list stack nav log">43+<thead><tr>{% if jev %}<th class="kind">Type</th>{% endif %}<th>Commit</th><th>Author</th><th>Date</th><th class="num">+</th><th class="num">-</th>{% if jev %}<th class="num" title="Jev's estimate of how likely a change is to break something, 0 to 3">Risk</th>{% endif %}<th>CI</th></tr></thead>3744 <tbody>3845 {% for c in commits %}39-<tr><td class="main"><a href="{{ repo.urls.commit(c.sha).href }}">{{ c.subject }}</a>{{ jevLabel(c) }}</td><td class="nowrap">{{ c.author.name }}</td><td class="date">{{ c.author.date | date }}</td><td class="num i">+{{ c.stats.add }}</td><td class="num d">-{{ c.stats.del }}</td><td>{{ c.ci | ciHtml | safe }}</td></tr>46+<tr>{% if jev %}<td class="kind">{{ jevKind(c) }}</td>{% endif %}<td class="main"><a href="{{ repo.urls.commit(c.sha).href }}">{{ c.subject }}</a>{{ jevMismatch(c) }}</td><td class="nowrap">{{ c.author.name }}</td><td class="date">{{ c.author.date | date }}</td><td class="num i">+{{ c.stats.add }}</td><td class="num d">-{{ c.stats.del }}</td>{% if jev %}<td class="num risk">{{ jevRisk(c) }}</td>{% endif %}<td>{{ c.ci | ciHtml | safe }}</td></tr>4047 {% endfor %}4148 </tbody>4249 </table>
@@ -151,7 +158,6 @@ a.rev { font-size: 0.85em; color: var(--muted); border: 1px solid var(--border);
151158 .card:hover, .card:focus-within, .card.sel { border-color: var(--faint); background: var(--hover); }152159 .card.sel { box-shadow: inset 2px 0 0 var(--accent); }153160 .card-top { display: flex; align-items: center; gap: 10px; min-width: 0; }154-.card-mark { flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 6px; background: hsl(var(--h) 55% 68%); color: #0d1117; font-weight: 700; font-size: 0.8rem; }155161 .card-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }156162 .card-name::after { content: ''; position: absolute; inset: 0; border-radius: var(--radius); }157163 .card-name .desc { font-weight: normal; }
@@ -174,7 +180,7 @@ a.rev { font-size: 0.85em; color: var(--muted); border: 1px solid var(--border);
174180 <ul class="cards nav-cards">175181 {% for s in sh.repos %}176182 <li class="card" data-name="{{ s.name | lower }}" data-date="{{ s.lastCommit }}"{% if s.counts.sites %} data-site{% endif %}>177-<div class="card-top"><span class="card-mark" style="--h: {{ s.name | hue }}" aria-hidden="true">{{ s.name[0] | upper }}</span><a class="card-name" href="{{ s.urls.home.href }}">{% if owners %}<span class="desc">{{ s.owner }} / </span>{% endif %}{{ s.name }}</a></div>183+<div class="card-top"><a class="card-name" href="{{ s.urls.home.href }}">{% if owners %}<span class="desc">{{ s.owner }} / </span>{% endif %}{{ s.name }}</a></div>178184 {% if s.description %}<p class="card-desc">{{ s.description }}</p>{% endif %}179185 <div class="card-foot">{{ sitesLink(s) }}{% if s.counts.reviews %}<a href="{{ s.urls.reviews.href }}">{{ s.counts.reviews | plural('review') }}</a>{% endif %}<span class="grow"></span>{{ s.lastCommit | date }}</div>180186 </li>
M site/assets/style.css +15 -3
@@ -342,10 +342,22 @@ details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywh
342342 .jev-kind, .jev-topic { display: inline-block; padding: 0 6px; border: 1px solid var(--border); border-radius: 999px; font-size: 0.75rem; line-height: 1.5; font-weight: 500; color: var(--muted); white-space: nowrap; vertical-align: 1px; }343343 .jev-kind.feat { color: var(--pass); }344344 .jev-kind.fix { color: var(--merged); }345-.jev-risk { font-size: 0.75rem; font-weight: 600; white-space: nowrap; }346-.jev-risk.moderate { color: var(--warn); }347-.jev-risk.high { color: var(--fail); }345+/* Jev's risk score, 0 to 3: a small number, colored once it is moderate. */346+.jev-score { font-size: 0.8rem; font-variant-numeric: tabular-nums; color: var(--faint); white-space: nowrap; }347+.jev-score.moderate { color: var(--warn); }348+.jev-score.high { color: var(--fail); font-weight: 600; }349+.jev-mismatch { font-size: 0.85em; color: var(--faint); cursor: help; }348350 .jev-note { font-size: 0.8rem; font-style: italic; color: var(--faint); }351+/* The log: Jev's type in a narrow column on the left. */352+table.log th.kind, table.log td.kind { width: 1%; white-space: nowrap; padding-right: 4px; }353+table.log td.kind .jev-kind { vertical-align: 0; }354+table.log td.risk { width: 1%; }355+@media (max-width: 720px) {356+ /* Stacked: the type leads the line under the subject. */357+ table.log td.kind, table.log td.risk { width: auto; padding-right: 0; }358+ table.log td.kind { order: 1; }359+ table.log td.kind:empty { display: none; }360+}349361 .releases-link { margin-left: 8px; font-size: 0.8rem; font-weight: normal; }350362 .notes { padding: 2px 16px 12px; font-size: 0.95em; }351363 .notes h3 { margin: 12px 0 4px; font-size: 0.9rem; }
A test/scenarios/log-columns.sh +42 -0
@@ -0,0 +1,42 @@
1+# The log shows Jev's answers about each commit in columns: its type on the2+# left, its risk as the score Jev gave (0 to 3, one decimal, colored from3+# moderate up), and a mark after the subject when the message may not4+# describe the diff. A repo without answers has neither column.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+clone dami dami/greet "$T/w/d"9+at "$T/w/d" commit "plain"10+at "$T/w/d" commit "add a greeting"11+at "$T/w/d" commit "fix the wave"12+push_as dami "$T/w/d" origin main13+export FORGE_STRICT=114+build dami/greet15+lacks "$FORGE_WWW/dami/greet/log/main/index.html" '<th class="kind">'16+g=$FORGE_ROOT/dami/greet.git17+# Stored answers, keyed the way the build looks them up.18+answer() { # sha kind risk matches19+ node -e '20+ const [gitDir, sha, kind, risk, matches] = process.argv.slice(1);21+ import(process.cwd() + "/lib/jev-commits.ts").then(({ patchIds, commitKey }) => {22+ const fs = require("fs"), cp = require("child_process");23+ const subject = cp.execFileSync("git", ["--git-dir=" + gitDir, "log", "-1", "--format=%s", sha], { encoding: "utf8" }).trim();24+ const key = commitKey(patchIds(gitDir, [sha]).get(sha), subject, "", 1);25+ fs.mkdirSync(gitDir + "/forge/jev/commit.v1", { recursive: true });26+ fs.writeFileSync(gitDir + "/forge/jev/commit.v1/" + key, JSON.stringify({ model: "test", at: 1, tokens: null, answers: {27+ kind: { type: "choice", choice: kind }, risk: { type: "score", score: Number(risk) }, matches: { type: "noul", noul: Number(matches) } } }));28+ });' "$g" "$1" "$2" "$3" "$4"29+}30+(cd "$T_HOME" && answer "$(git --git-dir="$g" rev-parse main~1)" feat 0.44 0.9)31+(cd "$T_HOME" && answer "$(git --git-dir="$g" rev-parse main)" fix 2.43 0.05)32+rm -f "$FORGE_CACHE/repos/dami/greet.json"33+build dami/greet34+p=$FORGE_WWW/dami/greet/log/main/index.html35+has "$p" '<th class="kind">Type</th><th>Commit</th>'36+has "$p" '<td class="kind"><span class="jev-kind feat"[^>]*>feat</span></td><td class="main"><a [^>]*>add a greeting</a></td>'37+has "$p" '<td class="kind"><span class="jev-kind fix"[^>]*>fix</span></td><td class="main"><a [^>]*>fix the wave</a> <span class="jev-mismatch"'38+has "$p" '<td class="num risk"><span class="jev-score" title="risk score 0.4 of 3[^"]*">0.4</span></td>'39+has "$p" '<td class="num risk"><span class="jev-score moderate" title="risk score 2.4 of 3[^"]*">2.4</span></td>'40+# The plain commit has no answer: empty cells, not a guess.41+has "$p" '<td class="kind"></td><td class="main"><a [^>]*>plain</a></td>'42+has "$FORGE_WWW/dami/greet/commit/$(git --git-dir="$g" rev-parse main)/index.html" '<span class="desc">risk score</span> <span class="jev-score moderate"'