beadcyte 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- package/src/web/wip.ts +83 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// changelog-cli.mjs — the `beadcyte changelog` entry point.
|
|
3
|
+
//
|
|
4
|
+
// beadcyte changelog regenerate CHANGELOG.md
|
|
5
|
+
// beadcyte changelog --check fail if the committed file is stale
|
|
6
|
+
// beadcyte changelog --stdout print instead of writing
|
|
7
|
+
//
|
|
8
|
+
// Runs on import, like cli.mjs and serve.mjs: bin/beadcyte.mjs dispatches with a
|
|
9
|
+
// bare `await import(...)`. The generator itself is in changelog.mjs, which
|
|
10
|
+
// stays free of side effects so a test can import it.
|
|
11
|
+
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { renderChangelog, CHANGELOG_PATH } from "./changelog.mjs";
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
|
|
22
|
+
/** Tags with their creation dates, oldest first. Empty when there are none. */
|
|
23
|
+
async function gitTags() {
|
|
24
|
+
try {
|
|
25
|
+
const { stdout } = await execFileAsync(
|
|
26
|
+
"git",
|
|
27
|
+
[
|
|
28
|
+
"for-each-ref",
|
|
29
|
+
"--sort=creatordate",
|
|
30
|
+
"--format=%(refname:short)|%(creatordate:iso-strict)",
|
|
31
|
+
"refs/tags",
|
|
32
|
+
],
|
|
33
|
+
{ cwd: ROOT },
|
|
34
|
+
);
|
|
35
|
+
return stdout
|
|
36
|
+
.split("\n")
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((line) => {
|
|
39
|
+
const i = line.indexOf("|");
|
|
40
|
+
return { name: line.slice(0, i), date: line.slice(i + 1) };
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
return []; // not a git repo, or no git — Unreleased is still correct
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function closedBeads() {
|
|
48
|
+
const { stdout } = await execFileAsync(
|
|
49
|
+
"bd",
|
|
50
|
+
["list", "--status=closed", "--limit", "0", "--json"],
|
|
51
|
+
{ cwd: ROOT, maxBuffer: 256 * 1024 * 1024 },
|
|
52
|
+
);
|
|
53
|
+
const parsed = JSON.parse(stdout);
|
|
54
|
+
return Array.isArray(parsed) ? parsed : (parsed.issues ?? parsed.beads ?? []);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function main() {
|
|
58
|
+
const args = process.argv.slice(2);
|
|
59
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
60
|
+
console.log(`
|
|
61
|
+
Generate CHANGELOG.md from closed beads.
|
|
62
|
+
|
|
63
|
+
beadcyte changelog regenerate CHANGELOG.md at the repo root
|
|
64
|
+
beadcyte changelog --check exit 2 if the committed file is out of date
|
|
65
|
+
beadcyte changelog --stdout print to stdout instead of writing
|
|
66
|
+
|
|
67
|
+
An entry appears when a closed bead carries ship evidence. Spikes,
|
|
68
|
+
upstream: bugs, chores and epics are excluded. Releases are cut by git tag.
|
|
69
|
+
`);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const [beads, tags] = await Promise.all([closedBeads(), gitTags()]);
|
|
74
|
+
const next = renderChangelog(beads, { tags });
|
|
75
|
+
const entries = (next.match(/^- /gm) ?? []).length;
|
|
76
|
+
|
|
77
|
+
if (args.includes("--stdout")) {
|
|
78
|
+
process.stdout.write(next);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (args.includes("--check")) {
|
|
83
|
+
let current = null;
|
|
84
|
+
try {
|
|
85
|
+
current = await readFile(CHANGELOG_PATH, "utf8");
|
|
86
|
+
} catch {
|
|
87
|
+
console.error("[beadcyte] CHANGELOG.md is missing; run `beadcyte changelog`");
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
if (current !== next) {
|
|
91
|
+
console.error("[beadcyte] CHANGELOG.md is out of date; run `beadcyte changelog`");
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
console.error(`[beadcyte] CHANGELOG.md is current (${entries} entries)`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await writeFile(CHANGELOG_PATH, next);
|
|
99
|
+
console.error(`[beadcyte] wrote CHANGELOG.md (${entries} entries)`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await main().catch((e) => {
|
|
103
|
+
console.error(`[beadcyte] changelog failed: ${e.message ?? e}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// changelog.mjs — CHANGELOG.md as a projection of the tracker.
|
|
2
|
+
//
|
|
3
|
+
// The pure half: beads in, markdown out. `changelog-cli.mjs` is the
|
|
4
|
+
// `beadcyte changelog` entry point and owns the shelling out and the file I/O.
|
|
5
|
+
//
|
|
6
|
+
// Split for a reason worth recording. `beadcyte` subcommands are side-effect entry
|
|
7
|
+
// modules — bin/beadcyte.mjs runs them with a bare `await import(...)`, the way
|
|
8
|
+
// cli.mjs and serve.mjs already work — so a module that both exports pure
|
|
9
|
+
// functions AND runs a CLI on import cannot be imported by a test without
|
|
10
|
+
// shelling out to `bd`. Guarding on `process.argv[1]` looked like the answer
|
|
11
|
+
// and was not: under the dispatcher argv[1] is `bin/beadcyte.mjs`, so the guard was
|
|
12
|
+
// false and `beadcyte changelog` exited 0 having done nothing at all.
|
|
13
|
+
//
|
|
14
|
+
// The data is already there and already good: every close in this project
|
|
15
|
+
// carries a reason written at ship time. Hand-maintaining a changelog
|
|
16
|
+
// alongside that would mean writing every ship note twice and having the two
|
|
17
|
+
// disagree within a fortnight. So this file is generated and the tracker
|
|
18
|
+
// stays the source of truth (bp-67g.34).
|
|
19
|
+
//
|
|
20
|
+
// ## What counts as a ship
|
|
21
|
+
//
|
|
22
|
+
// `isShipped()` from ship.mjs — the same predicate velocity, the estimator
|
|
23
|
+
// and the trend use. A close with no ship evidence is admin cleanup, and a
|
|
24
|
+
// changelog is exactly where that must not appear. This matters more than it
|
|
25
|
+
// sounds: an earlier draft tested `metadata.ship.mr_url` directly, a sixth
|
|
26
|
+
// copy of a rule that had already been consolidated, and silently emitted an
|
|
27
|
+
// EMPTY changelog on a repo whose 15 real ships are attested by commit SHA
|
|
28
|
+
// rather than by merge request.
|
|
29
|
+
//
|
|
30
|
+
// ## Where a release boundary comes from
|
|
31
|
+
//
|
|
32
|
+
// Git tags, and nothing else. A bead belongs to the earliest tag created at
|
|
33
|
+
// or after it closed; a bead closed after the last tag is Unreleased.
|
|
34
|
+
//
|
|
35
|
+
// The alternative — preserving hand-edited released sections and
|
|
36
|
+
// regenerating only Unreleased — was rejected because it makes `--check`
|
|
37
|
+
// meaningless: a file that is only partly generated cannot be compared
|
|
38
|
+
// against a generated one. Tags make the whole file a pure function of the
|
|
39
|
+
// repository, which is what lets a stale committed copy be caught.
|
|
40
|
+
//
|
|
41
|
+
// Until the first tag exists everything is Unreleased, which is honest for a
|
|
42
|
+
// project that has never cut a release.
|
|
43
|
+
|
|
44
|
+
import { join, dirname } from "node:path";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
46
|
+
import { isShipped } from "./ship.mjs";
|
|
47
|
+
|
|
48
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
49
|
+
|
|
50
|
+
/** Where the file lives. Root, not src/ — it is for a reader of the repo. */
|
|
51
|
+
export const CHANGELOG_PATH = join(ROOT, "CHANGELOG.md");
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Titles that are about the work rather than the product.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately a title prefix and an issue type rather than a label: this
|
|
57
|
+
* repo has 15 shipped beads and not one carries a label, so a label-only
|
|
58
|
+
* rule would exclude nothing. Labels are honoured as well for repos that do
|
|
59
|
+
* use them.
|
|
60
|
+
*/
|
|
61
|
+
const SKIP_TITLE = /^\s*(spike|upstream)\s*:/i;
|
|
62
|
+
const SKIP_TYPES = new Set(["chore", "epic"]);
|
|
63
|
+
const SKIP_LABELS = new Set(["changelog:skip", "spike", "upstream"]);
|
|
64
|
+
|
|
65
|
+
/** Keep a Changelog's own section names, since the header claims that format. */
|
|
66
|
+
const SECTIONS = ["Added", "Fixed", "Changed"];
|
|
67
|
+
|
|
68
|
+
function sectionFor(type) {
|
|
69
|
+
if (type === "feature") return "Added";
|
|
70
|
+
if (type === "bug") return "Fixed";
|
|
71
|
+
return "Changed";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Does this bead belong in a changelog?
|
|
76
|
+
*
|
|
77
|
+
* A changelog is for the reader, not the tracker. A spike is a question
|
|
78
|
+
* someone answered, an `upstream:` bead is a bug filed against a dependency,
|
|
79
|
+
* and a chore is housekeeping — none of them are things a user of this tool
|
|
80
|
+
* received.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ status?: string, issue_type?: string, title?: string,
|
|
83
|
+
* labels?: string[], metadata?: unknown }} bead
|
|
84
|
+
*/
|
|
85
|
+
export function isChangelogWorthy(bead) {
|
|
86
|
+
if (bead?.status !== "closed") return false;
|
|
87
|
+
if (!isShipped(bead)) return false;
|
|
88
|
+
if (SKIP_TYPES.has(bead.issue_type)) return false;
|
|
89
|
+
if (SKIP_TITLE.test(bead.title ?? "")) return false;
|
|
90
|
+
for (const l of bead.labels ?? []) if (SKIP_LABELS.has(l)) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Group shipped beads into releases.
|
|
96
|
+
*
|
|
97
|
+
* @param {Array<object>} beads
|
|
98
|
+
* @param {Array<{name: string, date: string}>} tags any order; sorted here
|
|
99
|
+
* @returns {Array<{version: string | null, date: string | null,
|
|
100
|
+
* sections: Record<string, string[]>}>}
|
|
101
|
+
* Newest first, Unreleased leading when it has anything.
|
|
102
|
+
*/
|
|
103
|
+
export function groupIntoReleases(beads, tags = []) {
|
|
104
|
+
// Instants, not strings: git hands over creatordate with the local offset
|
|
105
|
+
// ("…T21:51:33-05:00") while bd closes are UTC ("…T22:30:00Z"), and as
|
|
106
|
+
// strings the former reads as EARLIER though it is five hours later. That
|
|
107
|
+
// put beads closed the afternoon of a release under Unreleased.
|
|
108
|
+
const at = (iso) => Date.parse(iso);
|
|
109
|
+
const sorted = [...tags].sort((a, b) => at(a.date) - at(b.date));
|
|
110
|
+
const UNRELEASED = "\0unreleased"; // cannot collide with a tag name
|
|
111
|
+
const buckets = new Map();
|
|
112
|
+
buckets.set(UNRELEASED, { version: null, date: null, beads: [] });
|
|
113
|
+
for (const t of sorted) {
|
|
114
|
+
buckets.set(t.name, { version: t.name, date: t.date.slice(0, 10), beads: [] });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const b of beads.filter(isChangelogWorthy)) {
|
|
118
|
+
const closed = b.closed_at ?? "";
|
|
119
|
+
// Earliest tag created at or after this close.
|
|
120
|
+
const tag = sorted.find((t) => at(t.date) >= at(closed));
|
|
121
|
+
buckets.get(tag ? tag.name : UNRELEASED).beads.push(b);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Newest first, and stated rather than emergent: Unreleased leads, then
|
|
125
|
+
// tags newest-to-oldest. Relying on Map insertion order plus a reverse()
|
|
126
|
+
// put Unreleased LAST, because it is inserted before the tags are.
|
|
127
|
+
const ordered = [
|
|
128
|
+
buckets.get(UNRELEASED),
|
|
129
|
+
...[...sorted].reverse().map((t) => buckets.get(t.name)),
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const bucket of ordered) {
|
|
134
|
+
// An empty Unreleased is dropped; an empty tagged release is kept,
|
|
135
|
+
// because "we cut 0.3.0 and nothing shipped in it" is a real fact.
|
|
136
|
+
if (bucket.beads.length === 0 && bucket.version === null) continue;
|
|
137
|
+
const sections = {};
|
|
138
|
+
for (const s of SECTIONS) sections[s] = [];
|
|
139
|
+
// Newest close first inside a release, so the most recent work reads
|
|
140
|
+
// first — the order a person scanning a release actually wants.
|
|
141
|
+
const newestFirst = [...bucket.beads].sort((x, y) =>
|
|
142
|
+
(y.closed_at ?? "").localeCompare(x.closed_at ?? ""),
|
|
143
|
+
);
|
|
144
|
+
for (const b of newestFirst) {
|
|
145
|
+
sections[sectionFor(b.issue_type)].push(`- ${b.title} (\`${b.id}\`)`);
|
|
146
|
+
}
|
|
147
|
+
out.push({ version: bucket.version, date: bucket.date, sections });
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Render the whole file.
|
|
154
|
+
*
|
|
155
|
+
* The header states both rules — what counts as a ship and what is left out
|
|
156
|
+
* — because a reader cannot otherwise tell the difference between "nothing
|
|
157
|
+
* shipped" and "this file does not list that kind of thing".
|
|
158
|
+
*/
|
|
159
|
+
export function renderChangelog(beads, { tags = [] } = {}) {
|
|
160
|
+
const releases = groupIntoReleases(beads, tags);
|
|
161
|
+
let out = `# Changelog
|
|
162
|
+
|
|
163
|
+
All notable changes to this project will be documented in this file.
|
|
164
|
+
|
|
165
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
166
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
167
|
+
|
|
168
|
+
**This file is generated — do not edit it.** Run \`beadcyte changelog\` to
|
|
169
|
+
regenerate, and \`beadcyte changelog --check\` to verify it is current. The tracker
|
|
170
|
+
is the source of truth; this is a projection of it.
|
|
171
|
+
|
|
172
|
+
An entry appears here when a closed bead carries ship evidence
|
|
173
|
+
(\`metadata.ship.mr_url\` or \`metadata.ship.commit\`), so administrative
|
|
174
|
+
closes do not. Spikes, \`upstream:\` bugs filed against dependencies, chores
|
|
175
|
+
and epics are left out: they are about the work rather than about what you
|
|
176
|
+
received. Releases are cut by git tag — anything closed since the last tag is
|
|
177
|
+
Unreleased.
|
|
178
|
+
`;
|
|
179
|
+
|
|
180
|
+
if (releases.length === 0) {
|
|
181
|
+
return `${out}\n## [Unreleased]\n\nNothing recorded as shipped yet.\n`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const r of releases) {
|
|
185
|
+
out += r.version ? `\n## [${r.version}] - ${r.date}\n` : `\n## [Unreleased]\n`;
|
|
186
|
+
if (!SECTIONS.some((s) => r.sections[s].length)) {
|
|
187
|
+
out += `\nNothing recorded as shipped yet.\n`;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
for (const s of SECTIONS) {
|
|
191
|
+
if (!r.sections[s].length) continue;
|
|
192
|
+
out += `\n### ${s}\n\n${r.sections[s].join("\n")}\n`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|