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.
Files changed (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. 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
+ }