@refrakt-md/plan 0.9.5 → 0.9.6
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/dist/cli-plugin.d.ts.map +1 -1
- package/dist/cli-plugin.js +54 -0
- package/dist/cli-plugin.js.map +1 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +13 -0
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/history.d.ts +21 -0
- package/dist/commands/history.d.ts.map +1 -0
- package/dist/commands/history.js +261 -0
- package/dist/commands/history.js.map +1 -0
- package/dist/commands/next.d.ts.map +1 -1
- package/dist/commands/next.js +22 -6
- package/dist/commands/next.js.map +1 -1
- package/dist/commands/plan-behaviors.js +2 -1
- package/dist/commands/plan-behaviors.js.map +1 -1
- package/dist/commands/render-pipeline.d.ts.map +1 -1
- package/dist/commands/render-pipeline.js +148 -128
- package/dist/commands/render-pipeline.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +54 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/templates.js +4 -4
- package/dist/commands/templates.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +39 -11
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +119 -1
- package/dist/commands/validate.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +86 -23
- package/dist/config.js.map +1 -1
- package/dist/entity-tabs-behavior.d.ts +13 -0
- package/dist/entity-tabs-behavior.d.ts.map +1 -0
- package/dist/entity-tabs-behavior.js +94 -0
- package/dist/entity-tabs-behavior.js.map +1 -0
- package/dist/history.d.ts +121 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +417 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline.d.ts +10 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +563 -47
- package/dist/pipeline.js.map +1 -1
- package/dist/scanner.d.ts +10 -2
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +112 -4
- package/dist/scanner.js.map +1 -1
- package/dist/tags/bug.d.ts.map +1 -1
- package/dist/tags/bug.js +22 -1
- package/dist/tags/bug.js.map +1 -1
- package/dist/tags/decision.d.ts.map +1 -1
- package/dist/tags/decision.js +23 -1
- package/dist/tags/decision.js.map +1 -1
- package/dist/tags/milestone.d.ts.map +1 -1
- package/dist/tags/milestone.js +7 -4
- package/dist/tags/milestone.js.map +1 -1
- package/dist/tags/plan-history.d.ts +4 -0
- package/dist/tags/plan-history.d.ts.map +1 -0
- package/dist/tags/plan-history.js +46 -0
- package/dist/tags/plan-history.js.map +1 -0
- package/dist/tags/spec.d.ts.map +1 -1
- package/dist/tags/spec.js +4 -5
- package/dist/tags/spec.js.map +1 -1
- package/dist/tags/work.d.ts.map +1 -1
- package/dist/tags/work.js +28 -7
- package/dist/tags/work.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/util.d.ts +2 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +10 -0
- package/dist/util.js.map +1 -1
- package/package.json +10 -9
- package/styles/default.css +0 -586
- package/styles/minimal.css +0 -386
- package/styles/tokens.css +0 -13
package/dist/pipeline.js
CHANGED
|
@@ -3,15 +3,18 @@ import { BACKLOG_SENTINEL } from './tags/backlog.js';
|
|
|
3
3
|
import { DECISION_LOG_SENTINEL } from './tags/decision-log.js';
|
|
4
4
|
import { PLAN_PROGRESS_SENTINEL } from './tags/plan-progress.js';
|
|
5
5
|
import { PLAN_ACTIVITY_SENTINEL } from './tags/plan-activity.js';
|
|
6
|
+
import { PLAN_HISTORY_SENTINEL } from './tags/plan-history.js';
|
|
6
7
|
import { parseFilter, matchesFilter, sortEntities, groupEntities } from './filter.js';
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import { extractBatchHistory, readHistoryCache, writeHistoryCache, } from './history.js';
|
|
7
10
|
const { Tag } = Markdoc;
|
|
8
11
|
const PLAN_RUNE_TYPES = new Set(['spec', 'work', 'bug', 'decision', 'milestone']);
|
|
9
12
|
/** Fields to extract from each rune type's property meta tags */
|
|
10
13
|
const RUNE_FIELDS = {
|
|
11
14
|
spec: ['id', 'status', 'version', 'supersedes', 'tags', 'modified'],
|
|
12
|
-
work: ['id', 'status', 'priority', 'complexity', 'assignee', 'milestone', 'tags', 'modified'],
|
|
13
|
-
bug: ['id', 'status', 'severity', 'assignee', 'milestone', 'tags', 'modified'],
|
|
14
|
-
decision: ['id', 'status', 'date', 'supersedes', 'tags', 'modified'],
|
|
15
|
+
work: ['id', 'status', 'priority', 'complexity', 'assignee', 'milestone', 'source', 'tags', 'modified'],
|
|
16
|
+
bug: ['id', 'status', 'severity', 'assignee', 'milestone', 'source', 'tags', 'modified'],
|
|
17
|
+
decision: ['id', 'status', 'date', 'supersedes', 'source', 'tags', 'modified'],
|
|
15
18
|
milestone: ['name', 'status', 'target', 'modified'],
|
|
16
19
|
};
|
|
17
20
|
function walkTags(node, fn) {
|
|
@@ -129,6 +132,15 @@ const PRIORITY_SENTIMENT = {
|
|
|
129
132
|
const SEVERITY_SENTIMENT = {
|
|
130
133
|
critical: 'negative', major: 'caution', minor: 'neutral', trivial: 'neutral',
|
|
131
134
|
};
|
|
135
|
+
const SPEC_STATUS_SENTIMENT = {
|
|
136
|
+
draft: 'neutral', review: 'caution', accepted: 'positive', superseded: 'caution', deprecated: 'negative',
|
|
137
|
+
};
|
|
138
|
+
const DECISION_STATUS_SENTIMENT = {
|
|
139
|
+
proposed: 'neutral', accepted: 'positive', superseded: 'caution', deprecated: 'negative',
|
|
140
|
+
};
|
|
141
|
+
const MILESTONE_STATUS_SENTIMENT = {
|
|
142
|
+
planning: 'neutral', active: 'positive', complete: 'positive',
|
|
143
|
+
};
|
|
132
144
|
/** Build a metadata badge matching the dimension system output */
|
|
133
145
|
function buildMetaBadge(label, value, opts) {
|
|
134
146
|
const labelAttrs = { 'data-meta-label': '' };
|
|
@@ -144,12 +156,12 @@ function buildMetaBadge(label, value, opts) {
|
|
|
144
156
|
attrs['data-meta-sentiment'] = opts.sentiment;
|
|
145
157
|
return new Tag('span', attrs, [labelEl, valueEl]);
|
|
146
158
|
}
|
|
147
|
-
/** Build a compact summary card Tag for
|
|
159
|
+
/** Build a compact summary card Tag for any plan entity */
|
|
148
160
|
function buildEntityCard(entity) {
|
|
149
|
-
const
|
|
161
|
+
const type = entity.type;
|
|
162
|
+
const id = String(type === 'milestone' ? (entity.data.name ?? entity.id) : (entity.data.id ?? entity.id));
|
|
150
163
|
const title = String(entity.data.title ?? '');
|
|
151
164
|
const status = String(entity.data.status ?? '');
|
|
152
|
-
const type = entity.type;
|
|
153
165
|
// Header: ID on the left, status + progress on the right
|
|
154
166
|
const headerLeft = [
|
|
155
167
|
buildMetaBadge('ID:', id, { metaType: 'id', metaRank: 'primary', labelHidden: true }),
|
|
@@ -157,7 +169,10 @@ function buildEntityCard(entity) {
|
|
|
157
169
|
const headerRight = [];
|
|
158
170
|
const statusSentiment = type === 'work' ? WORK_STATUS_SENTIMENT[status]
|
|
159
171
|
: type === 'bug' ? BUG_STATUS_SENTIMENT[status]
|
|
160
|
-
:
|
|
172
|
+
: type === 'spec' ? SPEC_STATUS_SENTIMENT[status]
|
|
173
|
+
: type === 'decision' ? DECISION_STATUS_SENTIMENT[status]
|
|
174
|
+
: type === 'milestone' ? MILESTONE_STATUS_SENTIMENT[status]
|
|
175
|
+
: undefined;
|
|
161
176
|
headerRight.push(buildMetaBadge('Status:', status, { metaType: 'status', metaRank: 'primary', sentiment: statusSentiment, labelHidden: true }));
|
|
162
177
|
// Progress in header (no circle indicator)
|
|
163
178
|
const checkedCount = Number(entity.data.checkedCount ?? 0);
|
|
@@ -190,6 +205,21 @@ function buildEntityCard(entity) {
|
|
|
190
205
|
if (severity)
|
|
191
206
|
footerBadges.push(buildMetaBadge('Severity:', severity, { metaType: 'category', metaRank: 'secondary', sentiment: SEVERITY_SENTIMENT[severity] }));
|
|
192
207
|
}
|
|
208
|
+
else if (type === 'spec') {
|
|
209
|
+
const version = String(entity.data.version ?? '');
|
|
210
|
+
if (version)
|
|
211
|
+
footerBadges.push(buildMetaBadge('Version:', version, { metaType: 'quantity', metaRank: 'secondary' }));
|
|
212
|
+
}
|
|
213
|
+
else if (type === 'decision') {
|
|
214
|
+
const date = String(entity.data.date ?? '');
|
|
215
|
+
if (date)
|
|
216
|
+
footerBadges.push(buildMetaBadge('Date:', date, { metaType: 'temporal', metaRank: 'secondary' }));
|
|
217
|
+
}
|
|
218
|
+
else if (type === 'milestone') {
|
|
219
|
+
const target = String(entity.data.target ?? '');
|
|
220
|
+
if (target)
|
|
221
|
+
footerBadges.push(buildMetaBadge('Target:', target, { metaType: 'temporal', metaRank: 'secondary' }));
|
|
222
|
+
}
|
|
193
223
|
const milestone = String(entity.data.milestone ?? '');
|
|
194
224
|
if (milestone)
|
|
195
225
|
footerBadges.push(buildMetaBadge('Milestone:', milestone, { metaType: 'tag', metaRank: 'secondary', labelHidden: true }));
|
|
@@ -207,9 +237,6 @@ function buildEntityCard(entity) {
|
|
|
207
237
|
'data-id': id,
|
|
208
238
|
}, children);
|
|
209
239
|
}
|
|
210
|
-
const DECISION_STATUS_SENTIMENT = {
|
|
211
|
-
proposed: 'neutral', accepted: 'positive', superseded: 'caution', deprecated: 'negative',
|
|
212
|
-
};
|
|
213
240
|
/** Build a decision log entry Tag */
|
|
214
241
|
function buildDecisionEntry(entity) {
|
|
215
242
|
const id = String(entity.data.id ?? entity.id);
|
|
@@ -234,15 +261,61 @@ function buildDecisionEntry(entity) {
|
|
|
234
261
|
'data-id': id,
|
|
235
262
|
}, children);
|
|
236
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Module-level store for dependency refs extracted from ## Dependencies sections.
|
|
266
|
+
* Maps entityId → array of dependency entity IDs.
|
|
267
|
+
* Set by render-pipeline.ts from scanner data before register() runs.
|
|
268
|
+
*/
|
|
269
|
+
const _scannerDependencies = new Map();
|
|
270
|
+
/** Set scanner dependency data for the pipeline's aggregate() hook to consume */
|
|
271
|
+
export function setScannerDependencies(deps) {
|
|
272
|
+
_scannerDependencies.clear();
|
|
273
|
+
for (const [k, v] of deps)
|
|
274
|
+
_scannerDependencies.set(k, v);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Module-level store for the plan directory path.
|
|
278
|
+
* Set by render-pipeline.ts before aggregate() runs.
|
|
279
|
+
*/
|
|
280
|
+
let _planDir;
|
|
281
|
+
/** Set the plan directory path for the pipeline's aggregate() hook to consume */
|
|
282
|
+
export function setPlanDir(dir) {
|
|
283
|
+
_planDir = dir;
|
|
284
|
+
}
|
|
285
|
+
/** Parse a comma-separated `source` attribute into typed ID references */
|
|
286
|
+
function parseSourceIds(source) {
|
|
287
|
+
if (!source)
|
|
288
|
+
return [];
|
|
289
|
+
const refs = [];
|
|
290
|
+
for (const raw of source.split(',')) {
|
|
291
|
+
const id = raw.trim();
|
|
292
|
+
if (!id)
|
|
293
|
+
continue;
|
|
294
|
+
const match = id.match(/^(WORK|SPEC|BUG|ADR)-\d+$/);
|
|
295
|
+
if (match) {
|
|
296
|
+
const type = ID_PREFIX_TO_TYPE[match[1]];
|
|
297
|
+
if (type)
|
|
298
|
+
refs.push({ id, type });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return refs;
|
|
302
|
+
}
|
|
237
303
|
/**
|
|
238
304
|
* Module-level store for ID references found during registration.
|
|
239
305
|
* Maps entityId → array of referenced entity IDs (with type).
|
|
240
306
|
* Populated by register(), consumed by aggregate().
|
|
241
307
|
*/
|
|
242
308
|
const _idReferences = new Map();
|
|
309
|
+
/**
|
|
310
|
+
* Module-level store for structured source references (from source= attribute).
|
|
311
|
+
* Maps entityId → array of source entity IDs (with type).
|
|
312
|
+
* These produce 'implements' / 'implemented-by' relationships.
|
|
313
|
+
*/
|
|
314
|
+
const _sourceReferences = new Map();
|
|
243
315
|
export const planPipelineHooks = {
|
|
244
316
|
register(pages, registry, ctx) {
|
|
245
317
|
_idReferences.clear();
|
|
318
|
+
_sourceReferences.clear();
|
|
246
319
|
for (const page of pages) {
|
|
247
320
|
walkTags(page.renderable, (tag) => {
|
|
248
321
|
const runeType = tag.attributes['data-rune'];
|
|
@@ -277,6 +350,12 @@ export const planPipelineHooks = {
|
|
|
277
350
|
if (refs.length > 0) {
|
|
278
351
|
_idReferences.set(entityId, refs);
|
|
279
352
|
}
|
|
353
|
+
// Extract structured source references from source= attribute
|
|
354
|
+
const sourceVal = String(data.source ?? '');
|
|
355
|
+
const sourceRefs = parseSourceIds(sourceVal).filter(r => r.id !== entityId);
|
|
356
|
+
if (sourceRefs.length > 0) {
|
|
357
|
+
_sourceReferences.set(entityId, sourceRefs);
|
|
358
|
+
}
|
|
280
359
|
registry.register({
|
|
281
360
|
type: runeType,
|
|
282
361
|
id: entityId,
|
|
@@ -286,7 +365,7 @@ export const planPipelineHooks = {
|
|
|
286
365
|
});
|
|
287
366
|
}
|
|
288
367
|
},
|
|
289
|
-
aggregate(registry) {
|
|
368
|
+
aggregate(registry, ctx) {
|
|
290
369
|
// Build bidirectional relationship index from ID references
|
|
291
370
|
const relationships = new Map();
|
|
292
371
|
function addRel(id, rel) {
|
|
@@ -301,6 +380,63 @@ export const planPipelineHooks = {
|
|
|
301
380
|
allEntities.set(entity.id, entity);
|
|
302
381
|
}
|
|
303
382
|
}
|
|
383
|
+
// Track IDs already linked via source= to avoid duplicate 'related' edges
|
|
384
|
+
const sourceLinked = new Set();
|
|
385
|
+
// Process structured source= references → implements / implemented-by (or informs / informed-by for decisions)
|
|
386
|
+
for (const [fromId, refs] of _sourceReferences) {
|
|
387
|
+
const fromEntity = allEntities.get(fromId);
|
|
388
|
+
if (!fromEntity)
|
|
389
|
+
continue;
|
|
390
|
+
// Decisions use informs/informed-by; work/bug use implements/implemented-by
|
|
391
|
+
const isDecision = fromEntity.type === 'decision';
|
|
392
|
+
const forwardKind = isDecision ? 'informs' : 'implements';
|
|
393
|
+
const reverseKind = isDecision ? 'informed-by' : 'implemented-by';
|
|
394
|
+
for (const ref of refs) {
|
|
395
|
+
const toEntity = allEntities.get(ref.id);
|
|
396
|
+
if (!toEntity)
|
|
397
|
+
continue;
|
|
398
|
+
sourceLinked.add(`${fromId}→${ref.id}`);
|
|
399
|
+
// A implements/informs B
|
|
400
|
+
addRel(fromId, {
|
|
401
|
+
fromId, fromType: fromEntity.type,
|
|
402
|
+
toId: ref.id, toType: toEntity.type,
|
|
403
|
+
kind: forwardKind,
|
|
404
|
+
});
|
|
405
|
+
// B is implemented-by/informed-by A
|
|
406
|
+
addRel(ref.id, {
|
|
407
|
+
fromId: ref.id, fromType: toEntity.type,
|
|
408
|
+
toId: fromId, toType: fromEntity.type,
|
|
409
|
+
kind: reverseKind,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Track IDs already linked via depends-on to avoid duplicate 'related' edges
|
|
414
|
+
const depLinked = new Set();
|
|
415
|
+
// Process scanner dependency data → depends-on / dependency-of
|
|
416
|
+
for (const [fromId, depIds] of _scannerDependencies) {
|
|
417
|
+
const fromEntity = allEntities.get(fromId);
|
|
418
|
+
if (!fromEntity)
|
|
419
|
+
continue;
|
|
420
|
+
for (const depId of depIds) {
|
|
421
|
+
const toEntity = allEntities.get(depId);
|
|
422
|
+
if (!toEntity)
|
|
423
|
+
continue;
|
|
424
|
+
depLinked.add(`${fromId}→${depId}`);
|
|
425
|
+
// A depends-on B
|
|
426
|
+
addRel(fromId, {
|
|
427
|
+
fromId, fromType: fromEntity.type,
|
|
428
|
+
toId: depId, toType: toEntity.type,
|
|
429
|
+
kind: 'depends-on',
|
|
430
|
+
});
|
|
431
|
+
// B is dependency-of A
|
|
432
|
+
addRel(depId, {
|
|
433
|
+
fromId: depId, fromType: toEntity.type,
|
|
434
|
+
toId: fromId, toType: fromEntity.type,
|
|
435
|
+
kind: 'dependency-of',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Process text-based ID references → blocks / blocked-by / related
|
|
304
440
|
for (const [fromId, refs] of _idReferences) {
|
|
305
441
|
const fromEntity = allEntities.get(fromId);
|
|
306
442
|
if (!fromEntity)
|
|
@@ -309,6 +445,11 @@ export const planPipelineHooks = {
|
|
|
309
445
|
const toEntity = allEntities.get(ref.id);
|
|
310
446
|
if (!toEntity)
|
|
311
447
|
continue; // Reference to unknown entity — skip
|
|
448
|
+
// Skip if already linked via source= attribute or dependency
|
|
449
|
+
if (sourceLinked.has(`${fromId}→${ref.id}`))
|
|
450
|
+
continue;
|
|
451
|
+
if (depLinked.has(`${fromId}→${ref.id}`))
|
|
452
|
+
continue;
|
|
312
453
|
// Determine relationship kind
|
|
313
454
|
// If entity A has status "blocked" and references entity B, A is "blocked-by" B
|
|
314
455
|
const fromStatus = String(fromEntity.data.status ?? '');
|
|
@@ -342,6 +483,37 @@ export const planPipelineHooks = {
|
|
|
342
483
|
}
|
|
343
484
|
}
|
|
344
485
|
}
|
|
486
|
+
// Extract git history for all entities
|
|
487
|
+
let history = new Map();
|
|
488
|
+
let repositoryUrl;
|
|
489
|
+
try {
|
|
490
|
+
const planDir = _planDir ?? 'plan';
|
|
491
|
+
const cache = readHistoryCache(planDir);
|
|
492
|
+
history = extractBatchHistory(planDir, '.', { cache });
|
|
493
|
+
writeHistoryCache(planDir, cache);
|
|
494
|
+
// Parse repository URL from git remote
|
|
495
|
+
try {
|
|
496
|
+
const remoteUrl = execSync('git remote get-url origin', {
|
|
497
|
+
encoding: 'utf-8',
|
|
498
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
499
|
+
}).trim();
|
|
500
|
+
// Convert SSH URLs to HTTPS
|
|
501
|
+
const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
502
|
+
if (sshMatch) {
|
|
503
|
+
repositoryUrl = `https://${sshMatch[1]}/${sshMatch[2]}`;
|
|
504
|
+
}
|
|
505
|
+
else if (remoteUrl.startsWith('https://')) {
|
|
506
|
+
repositoryUrl = remoteUrl.replace(/\.git$/, '');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// No remote configured
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
// Git not available or not a git repo — history will be empty
|
|
515
|
+
ctx.warn(`Could not extract git history: ${err instanceof Error ? err.message : String(err)}`);
|
|
516
|
+
}
|
|
345
517
|
return {
|
|
346
518
|
workEntities: registry.getAll('work'),
|
|
347
519
|
bugEntities: registry.getAll('bug'),
|
|
@@ -349,6 +521,8 @@ export const planPipelineHooks = {
|
|
|
349
521
|
specEntities: registry.getAll('spec'),
|
|
350
522
|
milestoneEntities: registry.getAll('milestone'),
|
|
351
523
|
relationships,
|
|
524
|
+
history,
|
|
525
|
+
repositoryUrl,
|
|
352
526
|
};
|
|
353
527
|
},
|
|
354
528
|
postProcess(page, aggregated) {
|
|
@@ -377,6 +551,11 @@ export const planPipelineHooks = {
|
|
|
377
551
|
modified = true;
|
|
378
552
|
return resolvePlanActivity(tag, planData);
|
|
379
553
|
}
|
|
554
|
+
// Handle plan-history sentinel
|
|
555
|
+
if (tag.attributes['data-rune'] === 'plan-history' && hasSentinel(tag, PLAN_HISTORY_SENTINEL)) {
|
|
556
|
+
modified = true;
|
|
557
|
+
return resolvePlanHistory(tag, planData);
|
|
558
|
+
}
|
|
380
559
|
// Inject auto-backlog into milestone rune tags
|
|
381
560
|
if (tag.attributes['data-rune'] === 'milestone') {
|
|
382
561
|
const milestoneName = readField(tag, 'name');
|
|
@@ -388,7 +567,7 @@ export const planPipelineHooks = {
|
|
|
388
567
|
}
|
|
389
568
|
}
|
|
390
569
|
}
|
|
391
|
-
//
|
|
570
|
+
// Wrap entity content in tab-group with Overview / Relationships / History panels
|
|
392
571
|
if (PLAN_RUNE_TYPES.has(tag.attributes['data-rune'])) {
|
|
393
572
|
const runeType = tag.attributes['data-rune'];
|
|
394
573
|
const entityId = runeType === 'milestone'
|
|
@@ -396,12 +575,32 @@ export const planPipelineHooks = {
|
|
|
396
575
|
: readField(tag, 'id');
|
|
397
576
|
if (entityId) {
|
|
398
577
|
const rels = planData.relationships.get(entityId);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
578
|
+
const relationshipsSection = (rels && rels.length > 0)
|
|
579
|
+
? buildRelationshipsSection(rels, planData)
|
|
580
|
+
: null;
|
|
581
|
+
const historySection = buildAutoHistorySection(entityId, planData);
|
|
582
|
+
// Only add tabs if there is content for at least one extra panel
|
|
583
|
+
if (relationshipsSection || historySection) {
|
|
584
|
+
modified = true;
|
|
585
|
+
// Partition children: structural (meta fields, title, blurb) stay at top;
|
|
586
|
+
// body content goes into the Overview tab panel.
|
|
587
|
+
// Note: postProcess runs BEFORE the identity transform, so children
|
|
588
|
+
// have data-name/data-field from createComponentRenderable but no
|
|
589
|
+
// data-section or BEM classes yet.
|
|
590
|
+
const PREAMBLE_NAMES = new Set(['title', 'blurb']);
|
|
591
|
+
const structural = [];
|
|
592
|
+
const bodyContent = [];
|
|
593
|
+
for (const child of tag.children) {
|
|
594
|
+
if (Markdoc.Tag.isTag(child) && (child.attributes['data-field'] != null ||
|
|
595
|
+
PREAMBLE_NAMES.has(child.attributes['data-name']))) {
|
|
596
|
+
structural.push(child);
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
bodyContent.push(child);
|
|
600
|
+
}
|
|
404
601
|
}
|
|
602
|
+
const tabWrapper = buildEntityTabGroup(bodyContent, relationshipsSection, historySection);
|
|
603
|
+
return new Tag(tag.name, tag.attributes, [...structural, tabWrapper]);
|
|
405
604
|
}
|
|
406
605
|
}
|
|
407
606
|
}
|
|
@@ -683,8 +882,289 @@ function resolvePlanActivity(tag, data) {
|
|
|
683
882
|
newChildren.push(list);
|
|
684
883
|
return new Tag(tag.name, tag.attributes, newChildren);
|
|
685
884
|
}
|
|
686
|
-
|
|
687
|
-
|
|
885
|
+
// ─── Plan History Resolution ───
|
|
886
|
+
function formatHistoryDate(isoDate) {
|
|
887
|
+
const d = new Date(isoDate);
|
|
888
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
889
|
+
return `${months[d.getMonth()]} ${d.getDate()}`;
|
|
890
|
+
}
|
|
891
|
+
function buildAttrChangeTag(change) {
|
|
892
|
+
const children = [
|
|
893
|
+
new Tag('span', { class: 'rf-plan-history__field' }, [change.field]),
|
|
894
|
+
];
|
|
895
|
+
if (change.from !== null) {
|
|
896
|
+
children.push(new Tag('span', { class: 'rf-plan-history__value', 'data-type': 'remove' }, [change.from]));
|
|
897
|
+
}
|
|
898
|
+
if (change.from !== null && change.to !== null) {
|
|
899
|
+
children.push(new Tag('span', { class: 'rf-plan-history__arrow' }, ['→']));
|
|
900
|
+
}
|
|
901
|
+
if (change.to !== null) {
|
|
902
|
+
const prefix = change.from === null ? '+' : '';
|
|
903
|
+
children.push(new Tag('span', { class: 'rf-plan-history__value', 'data-type': 'add' }, [prefix + change.to]));
|
|
904
|
+
}
|
|
905
|
+
if (change.from !== null && change.to === null) {
|
|
906
|
+
// Removed attribute — show as removal only
|
|
907
|
+
}
|
|
908
|
+
return new Tag('span', { class: 'rf-plan-history__change' }, children);
|
|
909
|
+
}
|
|
910
|
+
function buildEventTag(event, repositoryUrl, collapseThreshold = 3) {
|
|
911
|
+
const dateTag = new Tag('time', { class: 'rf-plan-history__date' }, [formatHistoryDate(event.date)]);
|
|
912
|
+
const hashChildren = [event.shortHash];
|
|
913
|
+
const hashAttrs = { class: 'rf-plan-history__hash' };
|
|
914
|
+
if (repositoryUrl) {
|
|
915
|
+
const hashTag = new Tag('a', {
|
|
916
|
+
class: 'rf-plan-history__hash',
|
|
917
|
+
href: `${repositoryUrl}/commit/${event.hash}`,
|
|
918
|
+
}, [event.shortHash]);
|
|
919
|
+
return buildEventTagInner(event, dateTag, hashTag, collapseThreshold);
|
|
920
|
+
}
|
|
921
|
+
const hashTag = new Tag('code', hashAttrs, hashChildren);
|
|
922
|
+
return buildEventTagInner(event, dateTag, hashTag, collapseThreshold);
|
|
923
|
+
}
|
|
924
|
+
function buildEventTagInner(event, dateTag, hashTag, collapseThreshold) {
|
|
925
|
+
const changesChildren = [];
|
|
926
|
+
if (event.kind === 'created') {
|
|
927
|
+
const attrs = event.initialAttributes ?? {};
|
|
928
|
+
const parts = Object.entries(attrs)
|
|
929
|
+
.filter(([k]) => k !== 'id' && k !== 'name')
|
|
930
|
+
.map(([, v]) => v);
|
|
931
|
+
changesChildren.push(new Tag('span', { class: 'rf-plan-history__created' }, [`Created (${parts.join(', ')})`]));
|
|
932
|
+
}
|
|
933
|
+
if (event.attributeChanges) {
|
|
934
|
+
for (const change of event.attributeChanges) {
|
|
935
|
+
changesChildren.push(buildAttrChangeTag(change));
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (event.criteriaChanges) {
|
|
939
|
+
const items = event.criteriaChanges.map(c => {
|
|
940
|
+
const marker = c.action === 'checked' ? '☑' : c.action === 'unchecked' ? '☐' : c.action === 'added' ? '+' : '−';
|
|
941
|
+
return new Tag('li', { 'data-action': c.action }, [`${marker} ${c.text}`]);
|
|
942
|
+
});
|
|
943
|
+
// Collapse if over threshold
|
|
944
|
+
if (items.length > collapseThreshold) {
|
|
945
|
+
const visible = items.slice(0, collapseThreshold);
|
|
946
|
+
const remaining = items.length - collapseThreshold;
|
|
947
|
+
visible.push(new Tag('li', { class: 'rf-plan-history__more' }, [`+${remaining} more criteria`]));
|
|
948
|
+
changesChildren.push(new Tag('ul', { class: 'rf-plan-history__criteria' }, visible));
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
changesChildren.push(new Tag('ul', { class: 'rf-plan-history__criteria' }, items));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (event.kind === 'resolution') {
|
|
955
|
+
changesChildren.push(new Tag('span', { class: 'rf-plan-history__resolution' }, ['Resolution recorded']));
|
|
956
|
+
}
|
|
957
|
+
if (event.kind === 'content') {
|
|
958
|
+
changesChildren.push(new Tag('span', { class: 'rf-plan-history__content-edit' }, ['Content edited']));
|
|
959
|
+
}
|
|
960
|
+
const changesDiv = new Tag('div', { class: 'rf-plan-history__changes' }, changesChildren);
|
|
961
|
+
return new Tag('li', {
|
|
962
|
+
class: 'rf-plan-history__event',
|
|
963
|
+
'data-kind': event.kind,
|
|
964
|
+
}, [dateTag, hashTag, changesDiv]);
|
|
965
|
+
}
|
|
966
|
+
function resolvePlanHistory(tag, data) {
|
|
967
|
+
const entityId = readField(tag, 'id');
|
|
968
|
+
const limit = parseInt(readField(tag, 'limit') || '20', 10);
|
|
969
|
+
const typeFilter = readField(tag, 'type') || 'all';
|
|
970
|
+
const group = readField(tag, 'group') || 'commit';
|
|
971
|
+
const allEntities = [
|
|
972
|
+
...data.workEntities,
|
|
973
|
+
...data.bugEntities,
|
|
974
|
+
...data.decisionEntities,
|
|
975
|
+
...data.specEntities,
|
|
976
|
+
...data.milestoneEntities,
|
|
977
|
+
];
|
|
978
|
+
let listContent;
|
|
979
|
+
let isGlobal = false;
|
|
980
|
+
if (entityId) {
|
|
981
|
+
// Per-entity mode: find the entity's file path and look up its history
|
|
982
|
+
const entity = allEntities.find(e => e.id === entityId || e.data.name === entityId);
|
|
983
|
+
if (!entity) {
|
|
984
|
+
listContent = new Tag('ol', { 'data-name': 'events', class: 'rf-plan-history__events' }, [
|
|
985
|
+
new Tag('li', { class: 'rf-plan-history__empty' }, [`No history found for ${entityId}`]),
|
|
986
|
+
]);
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
// Find history by matching entity file path
|
|
990
|
+
let entityEvents = [];
|
|
991
|
+
for (const [file, events] of data.history) {
|
|
992
|
+
// Match by file path containing the entity ID or by checking attributes
|
|
993
|
+
if (events.length > 0 && events[0].initialAttributes) {
|
|
994
|
+
const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
|
|
995
|
+
if (eventId === entityId) {
|
|
996
|
+
entityEvents = events;
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
// Reverse to newest-first, apply limit
|
|
1002
|
+
const limited = [...entityEvents].reverse().slice(0, limit);
|
|
1003
|
+
const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
|
|
1004
|
+
listContent = new Tag('ol', { 'data-name': 'events', class: 'rf-plan-history__events' }, items.length > 0 ? items : [new Tag('li', { class: 'rf-plan-history__empty' }, ['No history available'])]);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
// Global feed mode
|
|
1009
|
+
isGlobal = true;
|
|
1010
|
+
const typeSet = typeFilter !== 'all' ? new Set(typeFilter.split(',').map(t => t.trim())) : null;
|
|
1011
|
+
const entityByFile = new Map(allEntities.map(e => {
|
|
1012
|
+
// Try to find the file path from history keys
|
|
1013
|
+
const filePath = e.data.file;
|
|
1014
|
+
return [filePath ?? e.id, e];
|
|
1015
|
+
}));
|
|
1016
|
+
// Group events by commit
|
|
1017
|
+
const commitMap = new Map();
|
|
1018
|
+
for (const [file, events] of data.history) {
|
|
1019
|
+
// Determine entity type for filtering
|
|
1020
|
+
const firstEvent = events[0];
|
|
1021
|
+
const initialType = firstEvent?.initialAttributes?.id?.split('-')[0]?.toLowerCase();
|
|
1022
|
+
const entityTypeMap = { work: 'work', spec: 'spec', bug: 'bug', adr: 'decision' };
|
|
1023
|
+
const entityType = entityTypeMap[initialType ?? ''];
|
|
1024
|
+
if (typeSet && entityType && !typeSet.has(entityType))
|
|
1025
|
+
continue;
|
|
1026
|
+
const entityId = firstEvent?.initialAttributes?.id ?? firstEvent?.initialAttributes?.name ?? file;
|
|
1027
|
+
for (const event of events) {
|
|
1028
|
+
if (event.kind === 'content')
|
|
1029
|
+
continue; // Skip content events in global feed
|
|
1030
|
+
let commitGroup = commitMap.get(event.hash);
|
|
1031
|
+
if (!commitGroup) {
|
|
1032
|
+
commitGroup = {
|
|
1033
|
+
hash: event.hash,
|
|
1034
|
+
shortHash: event.shortHash,
|
|
1035
|
+
date: event.date,
|
|
1036
|
+
message: event.message,
|
|
1037
|
+
entities: [],
|
|
1038
|
+
};
|
|
1039
|
+
commitMap.set(event.hash, commitGroup);
|
|
1040
|
+
}
|
|
1041
|
+
commitGroup.entities.push({ id: String(entityId), event });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
// Sort commits newest-first, apply limit
|
|
1045
|
+
const sortedCommits = [...commitMap.values()]
|
|
1046
|
+
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
1047
|
+
.slice(0, limit);
|
|
1048
|
+
const commitItems = sortedCommits.map(commit => {
|
|
1049
|
+
const dateTag = new Tag('time', { class: 'rf-plan-history__date' }, [formatHistoryDate(commit.date)]);
|
|
1050
|
+
const hashAttrs = { class: 'rf-plan-history__hash' };
|
|
1051
|
+
let hashTag;
|
|
1052
|
+
if (data.repositoryUrl) {
|
|
1053
|
+
hashTag = new Tag('a', {
|
|
1054
|
+
class: 'rf-plan-history__hash',
|
|
1055
|
+
href: `${data.repositoryUrl}/commit/${commit.hash}`,
|
|
1056
|
+
}, [commit.shortHash]);
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
hashTag = new Tag('code', hashAttrs, [commit.shortHash]);
|
|
1060
|
+
}
|
|
1061
|
+
const messageTag = new Tag('span', { class: 'rf-plan-history__commit-message' }, [commit.message]);
|
|
1062
|
+
const entitySummaries = commit.entities.map(({ id, event }) => {
|
|
1063
|
+
const parts = [];
|
|
1064
|
+
if (event.kind === 'created') {
|
|
1065
|
+
const attrs = event.initialAttributes ?? {};
|
|
1066
|
+
const vals = Object.entries(attrs).filter(([k]) => k !== 'id' && k !== 'name').map(([, v]) => v);
|
|
1067
|
+
parts.push(`Created (${vals.join(', ')})`);
|
|
1068
|
+
}
|
|
1069
|
+
if (event.attributeChanges) {
|
|
1070
|
+
for (const c of event.attributeChanges) {
|
|
1071
|
+
if (c.from === null)
|
|
1072
|
+
parts.push(`${c.field}: +${c.to}`);
|
|
1073
|
+
else if (c.to === null)
|
|
1074
|
+
parts.push(`${c.field}: -${c.from}`);
|
|
1075
|
+
else
|
|
1076
|
+
parts.push(`${c.field}: ${c.from} → ${c.to}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (event.criteriaChanges && event.criteriaChanges.length > 0) {
|
|
1080
|
+
const checked = event.criteriaChanges.filter(c => c.action === 'checked').length;
|
|
1081
|
+
const total = event.criteriaChanges.length;
|
|
1082
|
+
parts.push(`☑ ${checked}/${total}`);
|
|
1083
|
+
}
|
|
1084
|
+
if (event.kind === 'resolution')
|
|
1085
|
+
parts.push('Resolution recorded');
|
|
1086
|
+
return new Tag('div', { class: 'rf-plan-history__entity-summary' }, [
|
|
1087
|
+
new Tag('span', { class: 'rf-plan-history__entity-id' }, [id]),
|
|
1088
|
+
new Tag('span', { class: 'rf-plan-history__entity-changes' }, [parts.join(', ')]),
|
|
1089
|
+
]);
|
|
1090
|
+
});
|
|
1091
|
+
return new Tag('li', { class: 'rf-plan-history__event' }, [
|
|
1092
|
+
dateTag, hashTag, messageTag,
|
|
1093
|
+
new Tag('div', { class: 'rf-plan-history__changes' }, entitySummaries),
|
|
1094
|
+
]);
|
|
1095
|
+
});
|
|
1096
|
+
listContent = new Tag('ol', { 'data-name': 'events', class: 'rf-plan-history__events' }, commitItems.length > 0 ? commitItems : [new Tag('li', { class: 'rf-plan-history__empty' }, ['No history available'])]);
|
|
1097
|
+
}
|
|
1098
|
+
const attrs = { ...tag.attributes };
|
|
1099
|
+
if (isGlobal) {
|
|
1100
|
+
attrs.class = ((attrs.class ?? '') + ' rf-plan-history--global').trim();
|
|
1101
|
+
}
|
|
1102
|
+
const newChildren = tag.children.filter((c) => !(Markdoc.Tag.isTag(c) && (c.attributes['data-field'] === PLAN_HISTORY_SENTINEL ||
|
|
1103
|
+
c.attributes['data-name'] === 'events' ||
|
|
1104
|
+
c.attributes['data-name'] === 'items')));
|
|
1105
|
+
newChildren.push(listContent);
|
|
1106
|
+
return new Tag(tag.name, attrs, newChildren);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Build a tab-group wrapper for entity pages with Overview, Relationships, and History panels.
|
|
1110
|
+
* Emits the same HTML contract that tabsBehavior expects.
|
|
1111
|
+
*/
|
|
1112
|
+
function buildEntityTabGroup(bodyContent, relationshipsSection, historySection) {
|
|
1113
|
+
const tabButtons = [];
|
|
1114
|
+
const tabPanels = [];
|
|
1115
|
+
// Overview tab (always present)
|
|
1116
|
+
tabButtons.push(new Tag('button', {
|
|
1117
|
+
role: 'tab',
|
|
1118
|
+
class: 'rf-plan-entity-tabs__tab',
|
|
1119
|
+
'data-tab': 'overview',
|
|
1120
|
+
}, ['Overview']));
|
|
1121
|
+
tabPanels.push(new Tag('div', {
|
|
1122
|
+
role: 'tabpanel',
|
|
1123
|
+
class: 'rf-plan-entity-tabs__panel',
|
|
1124
|
+
'data-tab': 'overview',
|
|
1125
|
+
}, bodyContent));
|
|
1126
|
+
// Relationships tab (only if there are relationships)
|
|
1127
|
+
if (relationshipsSection) {
|
|
1128
|
+
tabButtons.push(new Tag('button', {
|
|
1129
|
+
role: 'tab',
|
|
1130
|
+
class: 'rf-plan-entity-tabs__tab',
|
|
1131
|
+
'data-tab': 'relationships',
|
|
1132
|
+
}, ['Relationships']));
|
|
1133
|
+
tabPanels.push(new Tag('div', {
|
|
1134
|
+
role: 'tabpanel',
|
|
1135
|
+
class: 'rf-plan-entity-tabs__panel',
|
|
1136
|
+
'data-tab': 'relationships',
|
|
1137
|
+
}, [relationshipsSection]));
|
|
1138
|
+
}
|
|
1139
|
+
// History tab (only if there is history)
|
|
1140
|
+
if (historySection) {
|
|
1141
|
+
tabButtons.push(new Tag('button', {
|
|
1142
|
+
role: 'tab',
|
|
1143
|
+
class: 'rf-plan-entity-tabs__tab',
|
|
1144
|
+
'data-tab': 'history',
|
|
1145
|
+
}, ['History']));
|
|
1146
|
+
tabPanels.push(new Tag('div', {
|
|
1147
|
+
role: 'tabpanel',
|
|
1148
|
+
class: 'rf-plan-entity-tabs__panel',
|
|
1149
|
+
'data-tab': 'history',
|
|
1150
|
+
}, [historySection]));
|
|
1151
|
+
}
|
|
1152
|
+
const tabList = new Tag('div', {
|
|
1153
|
+
'data-name': 'tabs',
|
|
1154
|
+
role: 'tablist',
|
|
1155
|
+
class: 'rf-plan-entity-tabs__tabs',
|
|
1156
|
+
}, tabButtons);
|
|
1157
|
+
const panels = new Tag('div', {
|
|
1158
|
+
'data-name': 'panels',
|
|
1159
|
+
class: 'rf-plan-entity-tabs__panels',
|
|
1160
|
+
}, tabPanels);
|
|
1161
|
+
return new Tag('div', {
|
|
1162
|
+
class: 'rf-plan-entity-tabs',
|
|
1163
|
+
'data-rune': 'plan-entity-tabs',
|
|
1164
|
+
}, [tabList, panels]);
|
|
1165
|
+
}
|
|
1166
|
+
const KIND_ORDER = { 'blocked-by': 0, 'blocks': 1, 'depends-on': 2, 'dependency-of': 3, 'implements': 4, 'implemented-by': 5, 'informs': 6, 'informed-by': 7, 'related': 8 };
|
|
1167
|
+
const KIND_LABELS = { 'blocked-by': 'Blocked by', 'blocks': 'Blocks', 'depends-on': 'Depends on', 'dependency-of': 'Dependency of', 'implements': 'Implements', 'implemented-by': 'Implemented by', 'informs': 'Informs', 'informed-by': 'Decisions', 'related': 'Related' };
|
|
688
1168
|
/** Look up an entity across all aggregated type arrays */
|
|
689
1169
|
function findEntity(id, data) {
|
|
690
1170
|
const allArrays = [data.workEntities, data.bugEntities, data.decisionEntities, data.specEntities, data.milestoneEntities];
|
|
@@ -720,37 +1200,42 @@ function buildRelationshipsSection(rels, data) {
|
|
|
720
1200
|
for (const kind of sortedKinds) {
|
|
721
1201
|
const kindRels = byKind.get(kind);
|
|
722
1202
|
const label = KIND_LABELS[kind] || kind;
|
|
723
|
-
|
|
1203
|
+
// "Informed by" renders decision entry cards
|
|
1204
|
+
if (kind === 'informed-by') {
|
|
1205
|
+
const entries = [];
|
|
1206
|
+
for (const rel of kindRels) {
|
|
1207
|
+
const target = findEntity(rel.toId, data);
|
|
1208
|
+
if (target) {
|
|
1209
|
+
entries.push(buildDecisionEntry(target));
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (entries.length > 0) {
|
|
1213
|
+
groups.push(new Tag('div', {
|
|
1214
|
+
class: 'rf-plan-relationships__group',
|
|
1215
|
+
'data-kind': kind,
|
|
1216
|
+
}, [
|
|
1217
|
+
new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
|
|
1218
|
+
new Tag('ol', { class: 'rf-plan-relationships__decisions' }, entries),
|
|
1219
|
+
]));
|
|
1220
|
+
}
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
const cards = [];
|
|
724
1224
|
for (const rel of kindRels) {
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
class: 'rf-plan-relationships__status',
|
|
734
|
-
'data-status': status,
|
|
735
|
-
}, [status]),
|
|
736
|
-
new Tag('span', { class: 'rf-plan-relationships__type' }, [type]),
|
|
737
|
-
...(title ? [new Tag('span', { class: 'rf-plan-relationships__title' }, [title])] : []),
|
|
738
|
-
];
|
|
739
|
-
const children = target?.sourceUrl
|
|
740
|
-
? [new Tag('a', { class: 'rf-plan-relationships__link', href: target.sourceUrl }, innerChildren)]
|
|
741
|
-
: innerChildren;
|
|
742
|
-
items.push(new Tag('li', {
|
|
743
|
-
class: 'rf-plan-relationships__item',
|
|
1225
|
+
const target = findEntity(rel.toId, data);
|
|
1226
|
+
if (target) {
|
|
1227
|
+
cards.push(buildEntityCard(target));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (cards.length > 0) {
|
|
1231
|
+
groups.push(new Tag('div', {
|
|
1232
|
+
class: 'rf-plan-relationships__group',
|
|
744
1233
|
'data-kind': kind,
|
|
745
|
-
},
|
|
1234
|
+
}, [
|
|
1235
|
+
new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
|
|
1236
|
+
new Tag('div', { class: 'rf-plan-relationships__cards' }, cards),
|
|
1237
|
+
]));
|
|
746
1238
|
}
|
|
747
|
-
groups.push(new Tag('div', {
|
|
748
|
-
class: 'rf-plan-relationships__group',
|
|
749
|
-
'data-kind': kind,
|
|
750
|
-
}, [
|
|
751
|
-
new Tag('h3', { class: 'rf-plan-relationships__group-title' }, [label]),
|
|
752
|
-
new Tag('ul', { class: 'rf-plan-relationships__list' }, items),
|
|
753
|
-
]));
|
|
754
1239
|
}
|
|
755
1240
|
if (groups.length === 0)
|
|
756
1241
|
return null;
|
|
@@ -762,4 +1247,35 @@ function buildRelationshipsSection(rels, data) {
|
|
|
762
1247
|
...groups,
|
|
763
1248
|
]);
|
|
764
1249
|
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Build an auto-injected History section for an entity page.
|
|
1252
|
+
* Returns null for entities with only a single commit (created and never modified).
|
|
1253
|
+
*/
|
|
1254
|
+
function buildAutoHistorySection(entityId, data) {
|
|
1255
|
+
// Find history events by matching the entity ID in the first event's attributes
|
|
1256
|
+
let entityEvents = [];
|
|
1257
|
+
for (const [, events] of data.history) {
|
|
1258
|
+
if (events.length > 0 && events[0].initialAttributes) {
|
|
1259
|
+
const eventId = events[0].initialAttributes.id ?? events[0].initialAttributes.name;
|
|
1260
|
+
if (eventId === entityId) {
|
|
1261
|
+
entityEvents = events;
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
// Skip entities with only a single commit (creation only) — no meaningful history
|
|
1267
|
+
if (entityEvents.length <= 1)
|
|
1268
|
+
return null;
|
|
1269
|
+
// Build timeline (newest-first), limit to 20 events
|
|
1270
|
+
const limited = [...entityEvents].reverse().slice(0, 20);
|
|
1271
|
+
const items = limited.map(e => buildEventTag(e, data.repositoryUrl));
|
|
1272
|
+
const list = new Tag('ol', { class: 'rf-plan-history__events' }, items);
|
|
1273
|
+
return new Tag('section', {
|
|
1274
|
+
class: 'rf-plan-history',
|
|
1275
|
+
'data-name': 'history',
|
|
1276
|
+
}, [
|
|
1277
|
+
new Tag('h2', { class: 'rf-plan-history__heading' }, ['History']),
|
|
1278
|
+
list,
|
|
1279
|
+
]);
|
|
1280
|
+
}
|
|
765
1281
|
//# sourceMappingURL=pipeline.js.map
|