changeledger 0.7.0 → 0.9.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/AGENTS.md +11 -5
- package/README.md +5 -1
- package/bin/changeledger.mjs +82 -6
- package/package.json +1 -1
- package/src/check.mjs +37 -1
- package/src/commands/agent-context.mjs +64 -0
- package/src/commands/agent-prompt.mjs +22 -0
- package/src/commands/agent.mjs +50 -7
- package/src/commands/context.mjs +8 -9
- package/src/commands/graduate.mjs +20 -8
- package/src/commands/view.mjs +6 -2
- package/src/contract.mjs +11 -5
- package/src/framing.mjs +22 -0
- package/src/lifecycle.mjs +4 -3
- package/src/metrics.mjs +3 -3
- package/src/viewer/domain.mjs +17 -7
- package/src/viewer/public/app-state.js +77 -6
- package/src/viewer/public/app.js +193 -42
- package/src/viewer/public/index.html +2 -2
- package/src/viewer/public/state.js +6 -2
- package/src/viewer/public/styles.css +205 -14
- package/src/viewer/public/view-parts.js +52 -0
- package/templates/contract/agent-contexts/implementation.md +18 -0
- package/templates/contract/agent-contexts/investigation.md +14 -0
- package/templates/contract/agent-contexts/review.md +22 -0
- package/templates/contract/agent-prompts/implementation.md +38 -0
- package/templates/contract/agent-prompts/investigation.md +41 -0
- package/templates/contract/agent-prompts/review.md +36 -0
- package/templates/contract/budgets.yml +16 -0
- package/templates/contract/close.md +25 -14
- package/templates/contract/core.md +43 -35
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +27 -11
- package/templates/contract/review.md +12 -9
- package/templates/contract/validation.md +16 -2
package/src/lifecycle.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The change lifecycle as an explicit, testable graph — the single authority on
|
|
2
2
|
// which status moves are legal. Shared by the CLI (`changeledger status`) and the viewer
|
|
3
3
|
// so both decide validity the same way. The viewer layers an extra human-only
|
|
4
|
-
// policy on top (approval plus final acceptance
|
|
4
|
+
// policy on top (approval plus final acceptance); it never relaxes
|
|
5
5
|
// this graph.
|
|
6
6
|
|
|
7
7
|
export const CANONICAL_STATUSES = [
|
|
@@ -20,7 +20,8 @@ export const CANONICAL_STATUSES = [
|
|
|
20
20
|
// absent and therefore rejected. `in-review` is optional by type;
|
|
21
21
|
// `in-validation` is the universal human gate before done. Review or validation
|
|
22
22
|
// may route back to in-progress, while review may also block. `discarded` is a
|
|
23
|
-
// terminal tombstone reachable only before either closing gate.
|
|
23
|
+
// terminal tombstone reachable only before either closing gate. `done` has one
|
|
24
|
+
// policy-gated provisional reopen edge; generic status commands do not own it.
|
|
24
25
|
const TRANSITIONS = {
|
|
25
26
|
draft: ['approved', 'discarded'],
|
|
26
27
|
approved: ['in-progress', 'discarded'],
|
|
@@ -28,7 +29,7 @@ const TRANSITIONS = {
|
|
|
28
29
|
'in-review': ['in-validation', 'in-progress', 'blocked'],
|
|
29
30
|
'in-validation': ['done', 'in-progress'],
|
|
30
31
|
blocked: ['in-progress', 'discarded'],
|
|
31
|
-
done: [],
|
|
32
|
+
done: ['in-progress'],
|
|
32
33
|
discarded: [],
|
|
33
34
|
};
|
|
34
35
|
|
package/src/metrics.mjs
CHANGED
|
@@ -42,8 +42,8 @@ function transitions(change) {
|
|
|
42
42
|
return events;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// Time spent in each state: [{ state, ms }].
|
|
46
|
-
//
|
|
45
|
+
// Time spent in each state: [{ state, ms }]. A provisional `done` interval is
|
|
46
|
+
// retained in the event stream but excluded from active-state duration.
|
|
47
47
|
export function statusTimeline(change, now) {
|
|
48
48
|
const events = transitions(change);
|
|
49
49
|
if (!events.length) return [];
|
|
@@ -53,7 +53,7 @@ export function statusTimeline(change, now) {
|
|
|
53
53
|
const endIso = events[i + 1]?.at ?? now;
|
|
54
54
|
const end = Date.parse(endIso);
|
|
55
55
|
if (Number.isNaN(start) || Number.isNaN(end)) continue;
|
|
56
|
-
// `done` is
|
|
56
|
+
// `done` is outside active work, including a provisional interval before reopen.
|
|
57
57
|
if (events[i].state === 'done') continue;
|
|
58
58
|
segs.push({ state: events[i].state, ms: Math.max(0, end - start) });
|
|
59
59
|
}
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -3,8 +3,13 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { parseDocument } from 'yaml';
|
|
5
5
|
import { mutateFileAtomic } from '../atomic-write.mjs';
|
|
6
|
+
import { parseChange } from '../change.mjs';
|
|
6
7
|
import { checkRepo } from '../check.mjs';
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
reopen as applyReopen,
|
|
10
|
+
status as applyStatusCmd,
|
|
11
|
+
validation as applyValidation,
|
|
12
|
+
} from '../commands/agent.mjs';
|
|
8
13
|
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
|
|
9
14
|
import {
|
|
10
15
|
buildMigration,
|
|
@@ -14,7 +19,7 @@ import {
|
|
|
14
19
|
import { computeMetrics } from '../metrics.mjs';
|
|
15
20
|
import { nowUtc } from '../paths.mjs';
|
|
16
21
|
import { listProjects, remove, update } from '../registry.mjs';
|
|
17
|
-
import { loadRepo, loadRepoWithConfig } from '../repo.mjs';
|
|
22
|
+
import { loadRepo, loadRepoWithConfig, resolveChange } from '../repo.mjs';
|
|
18
23
|
import { parseYaml } from '../yaml.mjs';
|
|
19
24
|
|
|
20
25
|
// Serializes a loaded repo into the flat shape the UI consumes.
|
|
@@ -115,24 +120,29 @@ export function changeStatus(projects, { project, id, status, reason }) {
|
|
|
115
120
|
// the UI is bypassable.
|
|
116
121
|
let current;
|
|
117
122
|
try {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
current = change.frontmatter.status;
|
|
123
|
+
const { file } = resolveChange(proj.path, id);
|
|
124
|
+
current = parseChange(fs.readFileSync(file, 'utf8')).frontmatter.status;
|
|
121
125
|
} catch (e) {
|
|
126
|
+
if (/^No change with id /.test(e.message)) {
|
|
127
|
+
return { code: 404, body: { error: `no change with id "${id}"` } };
|
|
128
|
+
}
|
|
122
129
|
return { code: 400, body: { error: e.message } };
|
|
123
130
|
}
|
|
124
131
|
try {
|
|
125
132
|
if (current === 'draft' && status === 'approved') {
|
|
126
|
-
applyStatusCmd(id, status, proj.path);
|
|
133
|
+
applyStatusCmd(id, status, proj.path, { actor: 'human' });
|
|
127
134
|
} else if (current === 'in-validation' && status === 'done') {
|
|
128
135
|
applyValidation(id, 'pass', {}, proj.path);
|
|
129
136
|
} else if (current === 'in-validation' && status === 'in-progress') {
|
|
130
137
|
applyValidation(id, 'fail', { reason }, proj.path);
|
|
138
|
+
} else if (current === 'done' && status === 'in-progress') {
|
|
139
|
+
applyReopen(id, reason, proj.path);
|
|
131
140
|
} else {
|
|
132
141
|
return {
|
|
133
142
|
code: 403,
|
|
134
143
|
body: {
|
|
135
|
-
error:
|
|
144
|
+
error:
|
|
145
|
+
'the viewer only allows draft → approved, in-validation → done|in-progress, and eligible done → in-progress',
|
|
136
146
|
},
|
|
137
147
|
};
|
|
138
148
|
}
|
|
@@ -2,11 +2,14 @@ export const VIEWER_STATE_KEY = 'changeledger.viewer-state.v1';
|
|
|
2
2
|
|
|
3
3
|
const VALID_VIEWS = new Set(['board', 'table', 'graph', 'specs', 'metrics', 'projects']);
|
|
4
4
|
const VALID_SORT_KEYS = new Set(['id', 'title', 'type', 'status', 'progress', 'deps']);
|
|
5
|
+
const VALID_DETAIL_MODES = new Set(['side', 'floating']);
|
|
6
|
+
const VALID_DETAIL_SIZES = new Set(['compact', 'wide', 'full']);
|
|
5
7
|
let storage = null;
|
|
6
8
|
|
|
7
9
|
const emptyProjectFilters = () => ({
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
types: [],
|
|
11
|
+
owners: [],
|
|
12
|
+
includeUnassigned: false,
|
|
10
13
|
statuses: [],
|
|
11
14
|
showArchived: false,
|
|
12
15
|
showDiscarded: false,
|
|
@@ -19,6 +22,9 @@ export const state = {
|
|
|
19
22
|
text: '',
|
|
20
23
|
type: 'all',
|
|
21
24
|
owner: 'all',
|
|
25
|
+
types: new Set(),
|
|
26
|
+
owners: new Set(),
|
|
27
|
+
includeUnassigned: false,
|
|
22
28
|
statuses: new Set(),
|
|
23
29
|
showArchived: false,
|
|
24
30
|
showDiscarded: false,
|
|
@@ -31,12 +37,15 @@ export const state = {
|
|
|
31
37
|
projectsList: [],
|
|
32
38
|
localOnly: false,
|
|
33
39
|
globalMode: false,
|
|
40
|
+
detailMode: 'side',
|
|
41
|
+
detailSize: 'wide',
|
|
34
42
|
};
|
|
35
43
|
|
|
36
44
|
function currentProjectFilters() {
|
|
37
45
|
return {
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
types: [...state.filters.types],
|
|
47
|
+
owners: [...state.filters.owners],
|
|
48
|
+
includeUnassigned: state.filters.includeUnassigned,
|
|
40
49
|
statuses: [...state.filters.statuses],
|
|
41
50
|
showArchived: state.filters.showArchived,
|
|
42
51
|
showDiscarded: state.filters.showDiscarded,
|
|
@@ -49,8 +58,23 @@ function saveCurrentProjectFilters() {
|
|
|
49
58
|
|
|
50
59
|
function applyProjectFilters(id) {
|
|
51
60
|
const filters = state.projectFilters[id] ?? emptyProjectFilters();
|
|
52
|
-
state.filters.
|
|
53
|
-
|
|
61
|
+
state.filters.types = new Set(
|
|
62
|
+
Array.isArray(filters.types)
|
|
63
|
+
? filters.types.filter((value) => typeof value === 'string')
|
|
64
|
+
: typeof filters.type === 'string' && filters.type !== 'all'
|
|
65
|
+
? [filters.type]
|
|
66
|
+
: [],
|
|
67
|
+
);
|
|
68
|
+
state.filters.type = state.filters.types.size === 1 ? [...state.filters.types][0] : 'all';
|
|
69
|
+
state.filters.owners = new Set(
|
|
70
|
+
Array.isArray(filters.owners)
|
|
71
|
+
? filters.owners.filter((value) => typeof value === 'string')
|
|
72
|
+
: typeof filters.owner === 'string' && filters.owner !== 'all'
|
|
73
|
+
? [filters.owner]
|
|
74
|
+
: [],
|
|
75
|
+
);
|
|
76
|
+
state.filters.owner = state.filters.owners.size === 1 ? [...state.filters.owners][0] : 'all';
|
|
77
|
+
state.filters.includeUnassigned = filters.includeUnassigned === true;
|
|
54
78
|
state.filters.statuses = new Set(
|
|
55
79
|
Array.isArray(filters.statuses)
|
|
56
80
|
? filters.statuses.filter((value) => typeof value === 'string')
|
|
@@ -71,6 +95,8 @@ export function serializeViewerState() {
|
|
|
71
95
|
sortKey: state.sortKey,
|
|
72
96
|
sortDir: state.sortDir,
|
|
73
97
|
projects: state.projectFilters,
|
|
98
|
+
detailMode: state.detailMode,
|
|
99
|
+
detailSize: state.detailSize,
|
|
74
100
|
};
|
|
75
101
|
}
|
|
76
102
|
|
|
@@ -99,6 +125,8 @@ export function restoreViewerState(storageLike) {
|
|
|
99
125
|
if (typeof snapshot.text === 'string') state.filters.text = snapshot.text;
|
|
100
126
|
if (typeof snapshot.sortKey === 'string') state.sortKey = snapshot.sortKey;
|
|
101
127
|
if (snapshot.sortDir === 1 || snapshot.sortDir === -1) state.sortDir = snapshot.sortDir;
|
|
128
|
+
state.detailMode = VALID_DETAIL_MODES.has(snapshot.detailMode) ? snapshot.detailMode : 'side';
|
|
129
|
+
state.detailSize = VALID_DETAIL_SIZES.has(snapshot.detailSize) ? snapshot.detailSize : 'wide';
|
|
102
130
|
if (
|
|
103
131
|
snapshot.projects &&
|
|
104
132
|
typeof snapshot.projects === 'object' &&
|
|
@@ -136,9 +164,13 @@ export function normalizeRepoState(repo) {
|
|
|
136
164
|
}
|
|
137
165
|
if (state.sortDir !== 1 && state.sortDir !== -1) state.sortDir = 1;
|
|
138
166
|
if (!repo.types.includes(state.filters.type)) state.filters.type = 'all';
|
|
167
|
+
state.filters.types = new Set(
|
|
168
|
+
[...state.filters.types].filter((type) => repo.types.includes(type)),
|
|
169
|
+
);
|
|
139
170
|
const owners = new Set(repo.changes.map((change) => change.owner).filter(Boolean));
|
|
140
171
|
if (state.filters.owner !== 'all' && !owners.has(state.filters.owner))
|
|
141
172
|
state.filters.owner = 'all';
|
|
173
|
+
state.filters.owners = new Set([...state.filters.owners].filter((owner) => owners.has(owner)));
|
|
142
174
|
const statuses = new Set(repo.statuses);
|
|
143
175
|
state.filters.statuses = new Set(
|
|
144
176
|
[...state.filters.statuses].filter((status) => statuses.has(status)),
|
|
@@ -160,16 +192,48 @@ export function setTextFilter(text) {
|
|
|
160
192
|
persistViewerState();
|
|
161
193
|
}
|
|
162
194
|
|
|
195
|
+
export function toggleTypeFilter(type) {
|
|
196
|
+
if (state.filters.types.has(type)) state.filters.types.delete(type);
|
|
197
|
+
else state.filters.types.add(type);
|
|
198
|
+
state.filters.type = state.filters.types.size === 1 ? [...state.filters.types][0] : 'all';
|
|
199
|
+
persistViewerState();
|
|
200
|
+
}
|
|
201
|
+
|
|
163
202
|
export function setTypeFilter(type) {
|
|
203
|
+
state.filters.types = new Set(type === 'all' ? [] : [type]);
|
|
164
204
|
state.filters.type = type;
|
|
165
205
|
persistViewerState();
|
|
166
206
|
}
|
|
167
207
|
|
|
208
|
+
export function toggleOwnerFilter(owner) {
|
|
209
|
+
if (state.filters.owners.has(owner)) state.filters.owners.delete(owner);
|
|
210
|
+
else state.filters.owners.add(owner);
|
|
211
|
+
state.filters.owner = state.filters.owners.size === 1 ? [...state.filters.owners][0] : 'all';
|
|
212
|
+
persistViewerState();
|
|
213
|
+
}
|
|
214
|
+
|
|
168
215
|
export function setOwnerFilter(owner) {
|
|
216
|
+
state.filters.owners = new Set(owner === 'all' ? [] : [owner]);
|
|
169
217
|
state.filters.owner = owner;
|
|
170
218
|
persistViewerState();
|
|
171
219
|
}
|
|
172
220
|
|
|
221
|
+
export function toggleUnassignedOwner() {
|
|
222
|
+
state.filters.includeUnassigned = !state.filters.includeUnassigned;
|
|
223
|
+
persistViewerState();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function clearTypeFilters() {
|
|
227
|
+
state.filters.types.clear();
|
|
228
|
+
persistViewerState();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function clearOwnerFilters() {
|
|
232
|
+
state.filters.owners.clear();
|
|
233
|
+
state.filters.includeUnassigned = false;
|
|
234
|
+
persistViewerState();
|
|
235
|
+
}
|
|
236
|
+
|
|
173
237
|
export function toggleStatusFilter(status) {
|
|
174
238
|
if (state.filters.statuses.has(status)) state.filters.statuses.delete(status);
|
|
175
239
|
else state.filters.statuses.add(status);
|
|
@@ -224,3 +288,10 @@ export function toggleGlobalMode() {
|
|
|
224
288
|
persistViewerState();
|
|
225
289
|
return state.globalMode;
|
|
226
290
|
}
|
|
291
|
+
|
|
292
|
+
export function setDetailPresentation(mode, size) {
|
|
293
|
+
if (VALID_DETAIL_MODES.has(mode)) state.detailMode = mode;
|
|
294
|
+
if (VALID_DETAIL_SIZES.has(size)) state.detailSize = size;
|
|
295
|
+
persistViewerState();
|
|
296
|
+
return { mode: state.detailMode, size: state.detailSize };
|
|
297
|
+
}
|
package/src/viewer/public/app.js
CHANGED
|
@@ -13,30 +13,34 @@ import {
|
|
|
13
13
|
searchAllProjects,
|
|
14
14
|
} from './api.js';
|
|
15
15
|
import {
|
|
16
|
+
clearOwnerFilters,
|
|
16
17
|
clearStatusFilters,
|
|
18
|
+
clearTypeFilters,
|
|
17
19
|
initializeProjects,
|
|
18
20
|
invalidateCache,
|
|
19
21
|
normalizeRepoState,
|
|
20
22
|
restoreViewerState,
|
|
21
23
|
selectProject,
|
|
22
|
-
|
|
24
|
+
setDetailPresentation,
|
|
23
25
|
setRepo,
|
|
24
26
|
setSortKey,
|
|
25
27
|
setTextFilter,
|
|
26
|
-
setTypeFilter,
|
|
27
28
|
setView,
|
|
28
29
|
state,
|
|
29
30
|
toggleGlobalMode,
|
|
31
|
+
toggleOwnerFilter,
|
|
30
32
|
toggleShowArchived,
|
|
31
33
|
toggleShowDiscarded,
|
|
32
34
|
toggleStatusFilter,
|
|
35
|
+
toggleTypeFilter,
|
|
36
|
+
toggleUnassignedOwner,
|
|
33
37
|
} from './app-state.js';
|
|
34
38
|
import { cssIdent, initMermaid, makeMermaidExpandable, renderMermaid } from './security.js';
|
|
35
39
|
import { boardStatuses, isVisible, passesTombstones } from './state.js';
|
|
36
40
|
import { html, render as litRender, nothing } from './templates.js';
|
|
37
41
|
import {
|
|
38
42
|
card,
|
|
39
|
-
|
|
43
|
+
detailToolbar,
|
|
40
44
|
sortIndicator,
|
|
41
45
|
specBody,
|
|
42
46
|
stageBlock,
|
|
@@ -50,6 +54,8 @@ export { cssIdent, esc, makeMermaidExpandable, safeHtml } from './security.js';
|
|
|
50
54
|
export { boardStatuses, isVisible, passesTombstones } from './state.js';
|
|
51
55
|
export {
|
|
52
56
|
card,
|
|
57
|
+
detailPresentationControls,
|
|
58
|
+
detailToolbar,
|
|
53
59
|
sortIndicator,
|
|
54
60
|
stageBlock,
|
|
55
61
|
statusSummary,
|
|
@@ -112,27 +118,74 @@ export function showNoProjects(root = document) {
|
|
|
112
118
|
|
|
113
119
|
// Rebuilt on each project load (types/statuses can differ per project).
|
|
114
120
|
function hydrateFilters() {
|
|
115
|
-
litRender(
|
|
116
|
-
html`<option value="all">All types</option>
|
|
117
|
-
${state.repo.types.map((t) => html`<option value=${t}>${t}</option>`)}`,
|
|
118
|
-
$('#type-filter'),
|
|
119
|
-
);
|
|
120
|
-
$('#type-filter').value = state.filters.type;
|
|
121
121
|
$('#lang').textContent = state.repo.language;
|
|
122
|
-
|
|
123
122
|
const owners = [...new Set(state.repo.changes.map((c) => c.owner).filter(Boolean))].sort();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
renderChoiceFilter(
|
|
124
|
+
$('#type-filter'),
|
|
125
|
+
'Type',
|
|
126
|
+
state.repo.types,
|
|
127
|
+
state.filters.types,
|
|
128
|
+
toggleTypeFilter,
|
|
129
|
+
clearTypeFilters,
|
|
130
|
+
);
|
|
131
|
+
renderChoiceFilter(
|
|
127
132
|
$('#owner-filter'),
|
|
133
|
+
'Owner',
|
|
134
|
+
owners,
|
|
135
|
+
state.filters.owners,
|
|
136
|
+
toggleOwnerFilter,
|
|
137
|
+
clearOwnerFilters,
|
|
138
|
+
true,
|
|
128
139
|
);
|
|
129
|
-
if (state.filters.owner !== 'all' && !owners.includes(state.filters.owner)) setOwnerFilter('all');
|
|
130
|
-
$('#owner-filter').value = state.filters.owner;
|
|
131
|
-
$('#owner-filter').style.display = owners.length ? '' : 'none';
|
|
132
|
-
|
|
133
140
|
renderStatusFilter();
|
|
134
141
|
}
|
|
135
142
|
|
|
143
|
+
export function choiceFilterSummary(label, selected, includeUnassigned = false) {
|
|
144
|
+
const count = selected.size + Number(includeUnassigned);
|
|
145
|
+
if (count === 1 && includeUnassigned) return 'Unassigned';
|
|
146
|
+
return count
|
|
147
|
+
? count === 1
|
|
148
|
+
? [...selected][0]
|
|
149
|
+
: `${count} selected`
|
|
150
|
+
: `All ${label.toLowerCase()}s`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderChoiceFilter(host, label, choices, selected, toggle, clear, owners = false) {
|
|
154
|
+
const summary = choiceFilterSummary(label, selected, owners && state.filters.includeUnassigned);
|
|
155
|
+
litRender(
|
|
156
|
+
html`<details class="filter-menu">
|
|
157
|
+
<summary class="filter-trigger">
|
|
158
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.25h12M4.25 8h7.5M6.5 12.75h3"></path></svg>
|
|
159
|
+
<span>${summary}</span>
|
|
160
|
+
<svg class="filter-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6.25 3.5 3.5 3.5-3.5"></path></svg>
|
|
161
|
+
</summary>
|
|
162
|
+
<div class="filter-popover">
|
|
163
|
+
<div class="filter-heading"><span>${label}</span><button type="button" data-clear>Clear</button></div>
|
|
164
|
+
<div class="filter-options">
|
|
165
|
+
${choices.map((choice) => html`<label class="check-option"><input type="checkbox" data-choice=${choice} .checked=${selected.has(choice)} /><span class="check-box" aria-hidden="true"></span><span>${choice}</span></label>`)}
|
|
166
|
+
${owners ? html`<label class="check-option"><input type="checkbox" data-unassigned .checked=${state.filters.includeUnassigned} /><span class="check-box" aria-hidden="true"></span><span>Unassigned</span></label>` : nothing}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</details>`,
|
|
170
|
+
host,
|
|
171
|
+
);
|
|
172
|
+
host.querySelectorAll('[data-choice]').forEach((input) => {
|
|
173
|
+
input.onchange = () => {
|
|
174
|
+
toggle(input.dataset.choice);
|
|
175
|
+
render();
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
if (owners)
|
|
179
|
+
host.querySelector('[data-unassigned]').onchange = () => {
|
|
180
|
+
toggleUnassignedOwner();
|
|
181
|
+
render();
|
|
182
|
+
};
|
|
183
|
+
host.querySelector('[data-clear]').onclick = () => {
|
|
184
|
+
clear();
|
|
185
|
+
render();
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
136
189
|
function renderStatusFilter() {
|
|
137
190
|
const sf = $('#status-filter');
|
|
138
191
|
litRender(
|
|
@@ -312,6 +365,73 @@ export async function runValidationSubmission({ root, request, onSuccess }) {
|
|
|
312
365
|
return true;
|
|
313
366
|
}
|
|
314
367
|
|
|
368
|
+
export function reopenPanel(status) {
|
|
369
|
+
if (status !== 'done') return nothing;
|
|
370
|
+
return html`<section class="validation-actions" aria-labelledby="reopen-title">
|
|
371
|
+
<div class="validation-copy">
|
|
372
|
+
<span class="validation-kicker">Lifecycle correction</span>
|
|
373
|
+
<h2 id="reopen-title">Reopen completed change</h2>
|
|
374
|
+
<p>Return this change to active work while preserving why its completion was reconsidered.</p>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="reopen-controls">
|
|
377
|
+
<div class="rejection-field">
|
|
378
|
+
<label for="reopen-reason">Reason for reopening</label>
|
|
379
|
+
<input
|
|
380
|
+
id="reopen-reason"
|
|
381
|
+
data-reopen-reason
|
|
382
|
+
type="text"
|
|
383
|
+
placeholder="What requires more work?"
|
|
384
|
+
/>
|
|
385
|
+
<p class="validation-error" role="alert" hidden></p>
|
|
386
|
+
</div>
|
|
387
|
+
<button type="button" class="button button-danger" data-reopen>Reopen change</button>
|
|
388
|
+
</div>
|
|
389
|
+
</section>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function bindReopenAction({ root, request, onSuccess }) {
|
|
393
|
+
const button = root.querySelector('[data-reopen]');
|
|
394
|
+
if (!button) return;
|
|
395
|
+
button.onclick = async () => {
|
|
396
|
+
const input = root.querySelector('[data-reopen-reason]');
|
|
397
|
+
const reason = input?.value.trim();
|
|
398
|
+
if (!reason) {
|
|
399
|
+
showValidationError(root, 'A reopening reason is required.');
|
|
400
|
+
input?.focus();
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
return runValidationSubmission({
|
|
404
|
+
root,
|
|
405
|
+
request: () => request(reason),
|
|
406
|
+
onSuccess,
|
|
407
|
+
});
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function applyDetailPresentation(root = document) {
|
|
412
|
+
const overlay = root.querySelector('#overlay');
|
|
413
|
+
const detail = root.querySelector('#detail');
|
|
414
|
+
if (!overlay || !detail) return;
|
|
415
|
+
overlay.dataset.detailMode = state.detailMode;
|
|
416
|
+
detail.dataset.detailSize = state.detailSize;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function bindDetailPresentation(root = document) {
|
|
420
|
+
root.querySelectorAll('[data-detail-setting]').forEach((button) => {
|
|
421
|
+
button.onclick = () => {
|
|
422
|
+
const setting = button.dataset.detailSetting;
|
|
423
|
+
setDetailPresentation(
|
|
424
|
+
setting === 'mode' ? button.dataset.detailValue : state.detailMode,
|
|
425
|
+
setting === 'size' ? button.dataset.detailValue : state.detailSize,
|
|
426
|
+
);
|
|
427
|
+
applyDetailPresentation(root);
|
|
428
|
+
root.querySelectorAll(`[data-detail-setting="${setting}"]`).forEach((option) => {
|
|
429
|
+
option.setAttribute('aria-pressed', String(option === button));
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
315
435
|
async function submitValidation(id, status, reason) {
|
|
316
436
|
const root = $('#detail');
|
|
317
437
|
await runValidationSubmission({
|
|
@@ -325,6 +445,28 @@ async function submitValidation(id, status, reason) {
|
|
|
325
445
|
});
|
|
326
446
|
}
|
|
327
447
|
|
|
448
|
+
export function resetDetailScroll(
|
|
449
|
+
detail,
|
|
450
|
+
schedule = globalThis.requestAnimationFrame?.bind(globalThis),
|
|
451
|
+
) {
|
|
452
|
+
const reset = () => {
|
|
453
|
+
detail.scrollTo?.({ top: 0, left: 0, behavior: 'instant' });
|
|
454
|
+
detail.scrollTop = 0;
|
|
455
|
+
};
|
|
456
|
+
reset();
|
|
457
|
+
schedule?.(reset);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function scrollToStage(stage) {
|
|
461
|
+
stage.scrollIntoView({ behavior: 'auto', block: 'start' });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function renderOpenedDetail(content) {
|
|
465
|
+
const detail = $('#detail');
|
|
466
|
+
litRender(content, detail);
|
|
467
|
+
resetDetailScroll(detail);
|
|
468
|
+
}
|
|
469
|
+
|
|
328
470
|
function openDetail(id) {
|
|
329
471
|
const c = state.repo.changes.find((x) => String(x.id) === String(id));
|
|
330
472
|
if (!c) return;
|
|
@@ -334,14 +476,11 @@ function openDetail(id) {
|
|
|
334
476
|
? html`<span class="pill ext" data-extdep=${d} style="cursor:pointer">depends on ${d}</span>`
|
|
335
477
|
: html`<span class="pill" data-dep=${d} style="cursor:pointer">depends on #${d}</span>`;
|
|
336
478
|
});
|
|
337
|
-
const pipeline = c.stages.map(
|
|
338
|
-
(s) => html`<span class="stage-chip" data-go=${`stage-${s.key}`}>${s.heading}</span>`,
|
|
339
|
-
);
|
|
340
479
|
const stages = c.stages.map((s) => stageBlock(c, s));
|
|
341
480
|
|
|
342
|
-
|
|
481
|
+
renderOpenedDetail(
|
|
343
482
|
html`
|
|
344
|
-
${
|
|
483
|
+
${detailToolbar(state.detailMode, state.detailSize, c.stages)}
|
|
345
484
|
<h1>${c.title}</h1>
|
|
346
485
|
<div class="detail-meta">
|
|
347
486
|
<span class="pill">#${c.id}</span>
|
|
@@ -352,16 +491,18 @@ function openDetail(id) {
|
|
|
352
491
|
${deps}
|
|
353
492
|
</div>
|
|
354
493
|
${c.status === 'in-validation' ? validationPanel() : nothing}
|
|
355
|
-
|
|
494
|
+
${reopenPanel(c.status)}
|
|
356
495
|
${stages}
|
|
357
496
|
<div id="git-section"></div>`,
|
|
358
|
-
$('#detail'),
|
|
359
497
|
);
|
|
360
498
|
|
|
361
499
|
resetValidationState($('#detail'));
|
|
362
500
|
|
|
363
501
|
const overlay = $('#overlay');
|
|
364
502
|
overlay.classList.remove('hidden');
|
|
503
|
+
document.documentElement.classList.add('detail-open');
|
|
504
|
+
applyDetailPresentation();
|
|
505
|
+
bindDetailPresentation();
|
|
365
506
|
$('#detail').querySelector('.close').onclick = closeDetail;
|
|
366
507
|
const accept = $('#detail').querySelector('[data-validation="pass"]');
|
|
367
508
|
if (accept) accept.onclick = () => submitValidation(c.id, 'done');
|
|
@@ -378,14 +519,22 @@ function openDetail(id) {
|
|
|
378
519
|
submitValidation(c.id, 'in-progress', reason);
|
|
379
520
|
};
|
|
380
521
|
}
|
|
522
|
+
bindReopenAction({
|
|
523
|
+
root: $('#detail'),
|
|
524
|
+
request: (reason) => postStatus(state.currentProject, c.id, 'in-progress', reason),
|
|
525
|
+
onSuccess: async () => {
|
|
526
|
+
invalidateCache();
|
|
527
|
+
await load();
|
|
528
|
+
openDetail(c.id);
|
|
529
|
+
},
|
|
530
|
+
});
|
|
381
531
|
overlay.onclick = (e) => {
|
|
382
532
|
if (e.target === overlay) closeDetail();
|
|
383
533
|
};
|
|
384
534
|
$('#detail')
|
|
385
535
|
.querySelectorAll('[data-go]')
|
|
386
536
|
.forEach((el) => {
|
|
387
|
-
el.onclick = () =>
|
|
388
|
-
$(`#${el.dataset.go}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
537
|
+
el.onclick = () => scrollToStage($(`#${el.dataset.go}`));
|
|
389
538
|
});
|
|
390
539
|
$('#detail')
|
|
391
540
|
.querySelectorAll('[data-dep]')
|
|
@@ -441,6 +590,7 @@ async function loadGitRefs(id) {
|
|
|
441
590
|
|
|
442
591
|
function closeDetail() {
|
|
443
592
|
$('#overlay').classList.add('hidden');
|
|
593
|
+
document.documentElement.classList.remove('detail-open');
|
|
444
594
|
}
|
|
445
595
|
|
|
446
596
|
let diagramLightbox = null;
|
|
@@ -584,9 +734,9 @@ function renderSpecs() {
|
|
|
584
734
|
}
|
|
585
735
|
|
|
586
736
|
function openSpec(s) {
|
|
587
|
-
|
|
737
|
+
renderOpenedDetail(
|
|
588
738
|
html`
|
|
589
|
-
${
|
|
739
|
+
${detailToolbar(state.detailMode, state.detailSize)}
|
|
590
740
|
<h1>${s.title}</h1>
|
|
591
741
|
<div class="detail-meta">
|
|
592
742
|
<span class="pill">spec</span>
|
|
@@ -594,10 +744,12 @@ function openSpec(s) {
|
|
|
594
744
|
${(s.tags || []).map((t) => html`<span class="pill">${t}</span>`)}
|
|
595
745
|
</div>
|
|
596
746
|
${specBody(s.body)}`,
|
|
597
|
-
$('#detail'),
|
|
598
747
|
);
|
|
599
748
|
const overlay = $('#overlay');
|
|
600
749
|
overlay.classList.remove('hidden');
|
|
750
|
+
document.documentElement.classList.add('detail-open');
|
|
751
|
+
applyDetailPresentation();
|
|
752
|
+
bindDetailPresentation();
|
|
601
753
|
const detail = $('#detail');
|
|
602
754
|
detail.querySelector('.close').onclick = closeDetail;
|
|
603
755
|
overlay.onclick = (e) => {
|
|
@@ -1454,16 +1606,13 @@ function bootstrap() {
|
|
|
1454
1606
|
if (active) enterGlobal();
|
|
1455
1607
|
else activateView(state.currentView);
|
|
1456
1608
|
};
|
|
1457
|
-
$('#type-filter').onchange = (e) => {
|
|
1458
|
-
setTypeFilter(e.target.value);
|
|
1459
|
-
render();
|
|
1460
|
-
};
|
|
1461
|
-
$('#owner-filter').onchange = (e) => {
|
|
1462
|
-
setOwnerFilter(e.target.value);
|
|
1463
|
-
render();
|
|
1464
|
-
};
|
|
1465
1609
|
document.addEventListener('pointerdown', (event) => {
|
|
1466
|
-
|
|
1610
|
+
closeFilterMenusOnOutsideClick(
|
|
1611
|
+
['#type-filter', '#owner-filter', '#status-filter'].map((selector) =>
|
|
1612
|
+
$(`${selector} .filter-menu`),
|
|
1613
|
+
),
|
|
1614
|
+
event.target,
|
|
1615
|
+
);
|
|
1467
1616
|
});
|
|
1468
1617
|
$('#view-board').onclick = () => activateView('board');
|
|
1469
1618
|
$('#view-table').onclick = () => activateView('table');
|
|
@@ -1492,10 +1641,12 @@ function bootstrap() {
|
|
|
1492
1641
|
setInterval(load, 5000);
|
|
1493
1642
|
}
|
|
1494
1643
|
|
|
1495
|
-
export function
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1644
|
+
export function closeFilterMenusOnOutsideClick(menus, target) {
|
|
1645
|
+
return menus.reduce((closed, menu) => {
|
|
1646
|
+
if (!menu?.open || menu.contains(target)) return closed;
|
|
1647
|
+
menu.open = false;
|
|
1648
|
+
return true;
|
|
1649
|
+
}, false);
|
|
1499
1650
|
}
|
|
1500
1651
|
|
|
1501
1652
|
// Only a real browser page with the app shell bootstraps; importing the module
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
<select id="project" class="filter" title="Project"></select>
|
|
27
27
|
<input id="search" class="search" type="search" placeholder="Search anything…" />
|
|
28
28
|
<button type="button" id="toggle-global" class="chip" title="Search across all projects">Global</button>
|
|
29
|
-
<
|
|
30
|
-
<
|
|
29
|
+
<div id="type-filter"></div>
|
|
30
|
+
<div id="owner-filter"></div>
|
|
31
31
|
<div id="status-filter"></div>
|
|
32
32
|
<div class="spacer"></div>
|
|
33
33
|
<div class="tabs">
|
|
@@ -22,8 +22,12 @@ function haystack(c) {
|
|
|
22
22
|
// predicate so the rule is testable.
|
|
23
23
|
export function isVisible(c, f) {
|
|
24
24
|
if (!passesTombstones(c, f)) return false;
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const types = f.types ?? new Set(f.type && f.type !== 'all' ? [f.type] : []);
|
|
26
|
+
const owners = f.owners ?? new Set(f.owner && f.owner !== 'all' ? [f.owner] : []);
|
|
27
|
+
if (types.size && !types.has(c.type)) return false;
|
|
28
|
+
if (owners.size || f.includeUnassigned) {
|
|
29
|
+
if (c.owner ? !owners.has(c.owner) : !f.includeUnassigned) return false;
|
|
30
|
+
}
|
|
27
31
|
if (f.statuses.size && !f.statuses.has(c.status)) return false;
|
|
28
32
|
const q = f.text.toLowerCase();
|
|
29
33
|
if (!q) return true;
|