create-pmos 0.141.0 → 0.143.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-initiatives-test.js +16 -3
- package/workspace/guide/initiative-boards.md +2 -2
- package/workspace/guide/jira.md +15 -1
- package/workspace/guide/my-work.md +13 -3
- package/workspace/lib/engine/boards.js +15 -5
- package/workspace/lib/jira-status.js +29 -9
- package/workspace/lib/jira-tickets.js +30 -2
- package/workspace/lib/routes-boards.js +61 -14
- package/workspace/lib/routes-pm-items.js +35 -4
- package/workspace/lib/work-assigned.js +69 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +23 -4
- package/workspace/public/pm/c3-ib-items.js +3 -3
- package/workspace/public/pm/c3-ib-releases.js +2 -2
- package/workspace/public/pm/c3-ib-sprints.js +3 -3
- package/workspace/public/pm/c3-ii-tabs.js +8 -4
- package/workspace/public/pm/c3-ini-ui.js +2 -0
- package/workspace/public/pm/c3-initiatives.js +41 -12
- package/workspace/public/pm/pm-work-view.js +11 -1
- package/workspace/server.js +3 -1
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/test-files.json +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.143.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-12T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T01:40:01Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-12T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T01:40:01Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-12T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T01:40:01Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -61,12 +61,12 @@ const { harness } = require('./c3-ini-harness');
|
|
|
61
61
|
ok('S1 the settings sheet offers no Build template while Build and deliver is off', await st(() => document.querySelector('[data-ini-offer="fix"]').disabled && document.querySelector('[data-ini-offer="product"]').disabled));
|
|
62
62
|
await page.click('[data-ini="build"]');
|
|
63
63
|
await until(() => st(() => !!document.querySelector('[data-ini="jira-key"]')));
|
|
64
|
-
await page.fill('[data-ini="jira-key"]', 'off');
|
|
64
|
+
await page.fill('[data-ini="jira-key"]', 'off, mov');
|
|
65
65
|
ok('S2 turning it on offers the Build templates and asks for the Jira key', await st(() => !document.querySelector('[data-ini-offer="fix"]').disabled && document.querySelector('[data-ini-offer="fix"]').checked));
|
|
66
66
|
await page.click('[data-ini="settings-save"]');
|
|
67
67
|
await until(() => store(() => window.__c3boards.boards.find((b) => b.id === 'office').build === true));
|
|
68
|
-
const off = await store(() => { const b = window.__c3boards.boards.find((x) => x.id === 'office'); return { build: b.build, jira: b.jira, offer: b.offer }; });
|
|
69
|
-
ok('S3 Save posts buildDeliver: Office move mirrors Jira OFF and offers the Build templates', off.build === true && off.jira === 'OFF' && off.offer.includes('fix'), JSON.stringify(off));
|
|
68
|
+
const off = await store(() => { const b = window.__c3boards.boards.find((x) => x.id === 'office'); return { build: b.build, jira: b.jira, projects: (b.jiraProjects || []).join(), offer: b.offer }; });
|
|
69
|
+
ok('S3 Save posts buildDeliver: Office move mirrors Jira OFF and MOV (two Jira projects) and offers the Build templates', off.build === true && off.jira === 'OFF' && off.projects === 'OFF,MOV' && off.offer.includes('fix'), JSON.stringify(off));
|
|
70
70
|
const s4 = await until(async () => { const t = await toast(); return /Build and deliver on, mirroring Jira OFF/.test(t) && t; });
|
|
71
71
|
ok('S4 a toast says what changed', !!s4, await toast());
|
|
72
72
|
await until(() => st(() => !!document.querySelector('[data-ini-tab="sprints"]')));
|
|
@@ -144,6 +144,19 @@ const { harness } = require('./c3-ini-harness');
|
|
|
144
144
|
ok('W4 the initiative now runs Research only and moved to Plan; other initiatives keep theirs', r07.stage === 'Plan' && r07.wf === 'Discover,Plan,Evaluate'
|
|
145
145
|
&& await store(() => !window.__c3boards.rows.find((r) => r.id === 'IDPF-12').template), JSON.stringify(r07));
|
|
146
146
|
|
|
147
|
+
/* ── Jira epics of an initiative (jira-epic-items) ── */
|
|
148
|
+
await page.click('[data-ini="jira-epics"]');
|
|
149
|
+
await until(() => st(() => !!document.querySelector('[data-ini="epics-input"]')));
|
|
150
|
+
await page.fill('[data-ini="epics-input"]', 'IDPF-900, XX-1');
|
|
151
|
+
await page.click('[data-ini="epics-save"]');
|
|
152
|
+
const epErr = await until(() => st(() => { const e = document.querySelector('[data-ini="epics-form"] [data-ini="error"]'); return e && e.textContent; }));
|
|
153
|
+
ok('E1 an epic outside the board\'s Jira projects is refused, and the sheet says why', /XX-1 is not in a Jira project of IDPF/.test(epErr || ''), epErr);
|
|
154
|
+
await page.fill('[data-ini="epics-input"]', 'idpf-900, idpf-901');
|
|
155
|
+
await page.click('[data-ini="epics-save"]');
|
|
156
|
+
await until(() => store(() => (window.__c3boards.rows.find((r) => r.id === 'IDPF-07').epics || []).length === 2));
|
|
157
|
+
const epChip = await until(() => st(() => { const c = document.querySelector('[data-ini="jira-epics"]'); return c && /Jira epics IDPF-900, IDPF-901/.test(c.textContent) && c.textContent; }));
|
|
158
|
+
ok('E2 the epics are saved upper-cased, and the initiative header names them', !!epChip, epChip);
|
|
159
|
+
|
|
147
160
|
/* ── phone width ── */
|
|
148
161
|
await page.setViewportSize({ width: 400, height: 860 });
|
|
149
162
|
for (const loc of ['', 'board:idpf:summary', 'ini:idpf:IDPF-12:overview']) {
|
|
@@ -14,7 +14,7 @@ The Initiatives tab of the console page (`/c3.html`) has these levels: **Portfol
|
|
|
14
14
|
The Portfolio shows one row per board. A board is a product folder, for example IDPF. Each row shows:
|
|
15
15
|
|
|
16
16
|
- the board name and its health: On track, Needs attention or Off track
|
|
17
|
-
- the owner, and **Build and deliver** with the Jira project
|
|
17
|
+
- the owner, and **Build and deliver** with the Jira project keys, or "PMOS tasks only"
|
|
18
18
|
- the initiatives per category: Discover, Plan, Do and Learn
|
|
19
19
|
- the next release
|
|
20
20
|
|
|
@@ -39,7 +39,7 @@ The tabs are **Summary**, **Initiatives**, **Triage**, **Backlog**, **Board**, *
|
|
|
39
39
|
|
|
40
40
|
Press the gear in the board header to open the board settings.
|
|
41
41
|
|
|
42
|
-
- **Build and deliver**: turn it on to mirror
|
|
42
|
+
- **Build and deliver**: turn it on to mirror Jira. Type one or more Jira project keys, separated by commas, for example `IDPF, LB`. A board can map several Jira projects. With it off, every item is a PMOS task and the Build workflows are not available.
|
|
43
43
|
- **Workflows this board offers**: the templates that a new initiative on this board can use.
|
|
44
44
|
- **Default for new initiatives and accepted asks**: the template that a new initiative or an accepted ask starts on.
|
|
45
45
|
|
package/workspace/guide/jira.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Jira: tickets drafted from the PRD, and their status"
|
|
4
4
|
description: "How to draft Jira tickets from a PRD in your ticket template, push the ones that are ready, and see their status, sprint and progress on the initiative's Jira tickets tab."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-12
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Jira: tickets drafted from the PRD, and their status
|
|
@@ -110,6 +110,20 @@ pmos jira query [<initiative>]
|
|
|
110
110
|
pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
## Link an initiative to its Jira epics
|
|
114
|
+
|
|
115
|
+
Your team may already plan in Jira, with an epic for each piece of work. Then link the initiative to its epics. You do not have to draft or push tickets.
|
|
116
|
+
|
|
117
|
+
1. Open the initiative. On a board with **Build and deliver** on, press **Link Jira epics** in the initiative header.
|
|
118
|
+
2. Type the epic keys, separated by commas, for example `IDPF-2685, LB-120`. Each epic must be in one of the board's Jira projects.
|
|
119
|
+
3. Press **Save epics**.
|
|
120
|
+
|
|
121
|
+
The **Jira status check** then reads each epic and the issues under it. The initiative's **Jira items**, **Breakdown** and **Timeline** show them, and the board's **Backlog**, **Board** and **Timeline** too. They are read-only: change them in Jira.
|
|
122
|
+
|
|
123
|
+
An initiative record that names an epic in its frontmatter (`jira: IDPF-2685`) is linked to it until you save epics on the page. Save an empty list to unlink.
|
|
124
|
+
|
|
125
|
+
An initiative whose tickets PMOS pushed keeps the tickets it pushed. The epic link is for initiatives with no pushed ticket.
|
|
126
|
+
|
|
113
127
|
## When the PRD changes
|
|
114
128
|
|
|
115
129
|
Each time the tab opens, PMOS compares every ticket with its item in the PRD:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "My work: every Jira ticket and WBS item assigned to you, ranked, briefed to an agent, approved in Agenda"
|
|
4
|
-
timestamp: 2026-10-
|
|
4
|
+
timestamp: 2026-10-12
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# My work
|
|
@@ -69,9 +69,19 @@ Agenda shows one card, **N items need your brief**, when items wait in To brief.
|
|
|
69
69
|
|
|
70
70
|
None of these briefs an agent or takes an item. Do that in My work.
|
|
71
71
|
|
|
72
|
-
## The Work page: your
|
|
72
|
+
## The Work page: your work in six views
|
|
73
73
|
|
|
74
|
-
The Work page holds
|
|
74
|
+
The Work page holds all the work that is yours:
|
|
75
|
+
|
|
76
|
+
- the items you add for yourself, such as "send Dana the new date";
|
|
77
|
+
- every open Jira ticket and WBS item assigned to you, from the same read as My work;
|
|
78
|
+
- the items today's morning brief suggests for you. They are flagged, so they are in Today.
|
|
79
|
+
|
|
80
|
+
A row of assigned work shows its Jira key (a link to the ticket) or **WBS** and the repository, and the agent's suggestion when the read has one. A suggested row shows **Suggested** and the reason. An agent at work on a ticket puts it in Waiting; a ticket in progress, or with a draft ready for you, is flagged. A ticket that is done in Jira leaves the page.
|
|
81
|
+
|
|
82
|
+
Every action works on these rows. The first action writes the item to your own list, with its source. **Drop** takes it off your Work page only; Jira and the Board keep it, and it does not come back.
|
|
83
|
+
|
|
84
|
+
The server decides where each item shows, so one item is never in two views.
|
|
75
85
|
|
|
76
86
|
The six views follow the OmniFocus style. The **Work** tab badge counts Inbox plus Today.
|
|
77
87
|
|
|
@@ -83,6 +83,13 @@ const templateBuilds = (k) => !!INI_TEMPLATES[k] && INI_TEMPLATES[k].stages.some
|
|
|
83
83
|
/** A board's Build and deliver settings, with the defaults of a board that never set them: buildDeliver is true when the
|
|
84
84
|
* ledger line does not carry it (the stage-records thread reads the same field the same way), every template the board may run is offered, and new initiatives start with
|
|
85
85
|
* the board's own template. */
|
|
86
|
+
const JIRA_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
|
|
87
|
+
const JIRA_PROJECT_RE = /^[A-Za-z][A-Za-z0-9_]{0,19}$/;
|
|
88
|
+
/** The Jira projects a board mirrors: the ledger's `jira` (one key, or a list of keys), else the keys in its tracker label. */
|
|
89
|
+
function jiraProjectsOf(b) {
|
|
90
|
+
const raw = Array.isArray(b.jira) ? b.jira : typeof b.jira === 'string' && b.jira ? [b.jira] : String((/^Jira · (.+)$/.exec(b.tracker || '') || [])[1] || '').split(/[,\s]+/);
|
|
91
|
+
return [...new Set(raw.filter((k) => typeof k === 'string' && JIRA_PROJECT_RE.test(k.trim())).map((k) => k.trim().toUpperCase()))];
|
|
92
|
+
}
|
|
86
93
|
function boardSettings(board) {
|
|
87
94
|
const b = board || {};
|
|
88
95
|
const build = typeof b.buildDeliver === 'boolean' ? b.buildDeliver : true;
|
|
@@ -92,8 +99,8 @@ function boardSettings(board) {
|
|
|
92
99
|
const tpl = INI_TEMPLATES[b.template] ? b.template : 'product';
|
|
93
100
|
let defTpl = typeof b.defTpl === 'string' && offer.includes(b.defTpl) ? b.defTpl : (offer.includes(tpl) ? tpl : offer[0]);
|
|
94
101
|
if (!defTpl) defTpl = 'simple';
|
|
95
|
-
const
|
|
96
|
-
return { build, jira, offer, defTpl };
|
|
102
|
+
const projects = build ? jiraProjectsOf(b) : [];
|
|
103
|
+
return { build, jira: projects[0] || null, jiraProjects: projects, offer, defTpl };
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/* The PMOS-self board: the nine stage names of scripts/nine-stages.js, read-only, each in its category. */
|
|
@@ -139,7 +146,7 @@ const TOOLS = Object.freeze({
|
|
|
139
146
|
|
|
140
147
|
const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'done']);
|
|
141
148
|
const VIEWS = Object.freeze(['now', 'mine', 'risk', 'noowner', 'all']);
|
|
142
|
-
const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update']);
|
|
149
|
+
const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update', 'ini-epics']);
|
|
143
150
|
const TASK_STATES = Object.freeze(['To do', 'In progress', 'In review', 'Done']);
|
|
144
151
|
|
|
145
152
|
const copyStages = (stages) => stages.map((s) => ({ name: s.name, cat: s.cat, hint: s.hint || '' }));
|
|
@@ -166,6 +173,7 @@ function fold(events) {
|
|
|
166
173
|
const stages = new Map(); // key(board, id) → stage name (the last move)
|
|
167
174
|
const iniWorkflows = new Map(); // key(board, id) → { template, stages } (the initiative's own workflow)
|
|
168
175
|
const tasks = new Map(); // task id → { id, board, initiative, title, epic, owner, state, start, due, at, by, history }
|
|
176
|
+
const epics = new Map(); // initiative id → the Jira epic keys the PM linked to it (the last ini-epics line wins)
|
|
169
177
|
const list = [];
|
|
170
178
|
for (let e of Array.isArray(events) ? events : []) {
|
|
171
179
|
if (typeof e === 'string') { try { e = JSON.parse(e); } catch { continue; } }
|
|
@@ -232,13 +240,15 @@ function fold(events) {
|
|
|
232
240
|
if (typeof e.start === 'string') t.start = e.start;
|
|
233
241
|
if (typeof e.due === 'string') t.due = e.due;
|
|
234
242
|
t.history.push({ at: e.at || null, by: e.by || null, state: e.state || null, start: e.start || null, due: e.due || null, why: e.why || '' });
|
|
243
|
+
} else if (e.kind === 'ini-epics' && typeof e.id === 'string' && Array.isArray(e.epics)) {
|
|
244
|
+
epics.set(e.id, e.epics.filter((k) => typeof k === 'string' && JIRA_KEY_RE.test(k)).map((k) => k.toUpperCase()));
|
|
235
245
|
} else if (e.kind === 'ask-decline' && typeof e.ask === 'string' && asks.has(e.ask)) {
|
|
236
246
|
const a = asks.get(e.ask);
|
|
237
247
|
if (a.state !== 'open') continue;
|
|
238
248
|
Object.assign(a, { state: 'declined', why: e.why || '', closedAt: e.at || null });
|
|
239
249
|
}
|
|
240
250
|
}
|
|
241
|
-
return { boards, moves, assigns, asks, stages, iniWorkflows, tasks, events: list };
|
|
251
|
+
return { boards, moves, assigns, asks, stages, iniWorkflows, tasks, epics, events: list };
|
|
242
252
|
}
|
|
243
253
|
|
|
244
254
|
/** The stages an initiative runs on a board: its own workflow when one is recorded, else the board's. */
|
|
@@ -537,6 +547,6 @@ module.exports = {
|
|
|
537
547
|
CATEGORIES, WORKFLOW_TEMPLATES, TOOLS, SELF_LADDER, READING_CAT, STATUSES, VIEWS, SORTS,
|
|
538
548
|
templateWorkflow, isTemplateEdited, fold, readingOf, deriveStage, stageOf, catOf, moveWarnings, cardWarnings,
|
|
539
549
|
applyWorkflowEdit, filterRows, health, counts, sprintsOf, key,
|
|
540
|
-
INI_TEMPLATES, TASK_STATES, isBuildStage, templateBuilds, boardSettings, workflowOf, templateOf, applyIniWorkflow, taskOf, shiftDay,
|
|
550
|
+
INI_TEMPLATES, TASK_STATES, isBuildStage, templateBuilds, boardSettings, jiraProjectsOf, JIRA_KEY_RE, JIRA_PROJECT_RE, workflowOf, templateOf, applyIniWorkflow, taskOf, shiftDay,
|
|
541
551
|
ITEM_PROGRESS, itemStatus, taskItem, rollupItems,
|
|
542
552
|
};
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* This module reads that file and writes a snapshot the initiative's Jira tickets tab shows. It writes nothing else:
|
|
7
7
|
* no plan item, no record, no ledger line (D88: the Breakdown is the handoff checklist, not a copy of Jira).
|
|
8
8
|
*
|
|
9
|
-
* queryFor(root, initiative) → { linked, query }
|
|
9
|
+
* queryFor(root, initiative) → { linked, mode, query } the JQL for the tickets this initiative pushed, or, when it
|
|
10
|
+
* pushed none, for its Jira epics and their children (mode 'epic', jira-epic-items)
|
|
10
11
|
* linkedInitiatives(root) → [{ initiative, query }]
|
|
11
12
|
* normalize(raw) → { issues, skipped } | { error }
|
|
12
13
|
* sync(root, initiative, raw, opts) → the snapshot, written to <state>/tracker/jira/<initiative>.md
|
|
@@ -35,11 +36,15 @@ const nowIso = (now) => new Date(now == null ? Date.now() : now).toISOString().r
|
|
|
35
36
|
function queryFor(root, initiative) {
|
|
36
37
|
if (!jiraTickets.recordFm(root, initiative)) return { ok: false, error: `no initiative ${initiative} in this repository` };
|
|
37
38
|
const keys = [...jiraTickets.pushedTickets(root, initiative).tickets.keys()];
|
|
38
|
-
if (
|
|
39
|
-
|
|
39
|
+
if (keys.length) return { ok: true, initiative, linked: true, mode: 'pushed', keys, query: `key in (${keys.join(', ')}) ORDER BY key` };
|
|
40
|
+
// EPIC MODE (jira-epic-items): no ticket pushed, but the initiative has Jira epics. Read each epic and its children.
|
|
41
|
+
const epics = jiraTickets.epicKeysOf(root, initiative);
|
|
42
|
+
if (epics.length) return { ok: true, initiative, linked: true, mode: 'epic', keys: epics, epics, query: `key in (${epics.join(', ')}) OR parent in (${epics.join(', ')}) ORDER BY key` };
|
|
43
|
+
return { ok: true, initiative, linked: false, keys: [], query: null };
|
|
40
44
|
}
|
|
41
45
|
function linkedInitiatives(root) {
|
|
42
|
-
|
|
46
|
+
const ids = [...new Set([...jiraTickets.draftedInitiatives(root), ...jiraTickets.epicInitiatives(root)])].sort();
|
|
47
|
+
return ids.map((id) => queryFor(root, id)).filter((q) => q.ok);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
// ── reading a saved search result ──────────────────────────────────────────────────────────────────
|
|
@@ -147,6 +152,15 @@ function urlOf(x, key) {
|
|
|
147
152
|
return `${u.origin}/browse/${key}`;
|
|
148
153
|
}
|
|
149
154
|
/** One issue of either shape → a reading, or null when it has no key or no summary. */
|
|
155
|
+
/** The issue type's name and the parent's key, when the search returned them: { type?, parent? }. */
|
|
156
|
+
function typeParent(t, p) {
|
|
157
|
+
const out = {};
|
|
158
|
+
const type = clip(nameOf(t), 40);
|
|
159
|
+
if (type) out.type = type;
|
|
160
|
+
const pk = oneLine(p && typeof p === 'object' ? p.key : p).toUpperCase();
|
|
161
|
+
if (KEY_RE.test(pk)) out.parent = pk;
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
150
164
|
function normalizeIssue(x) {
|
|
151
165
|
if (!x || typeof x !== 'object') return null;
|
|
152
166
|
const key = oneLine(x.key).toUpperCase();
|
|
@@ -160,7 +174,8 @@ function normalizeIssue(x) {
|
|
|
160
174
|
const flagged = flaggedIn(f) || /block|impediment|on hold/i.test(status);
|
|
161
175
|
return { key, summary, status, category: categoryOf(status, st.statusCategory), sprint: sprintIn(f), subtasks: subtasksOf(f.subtasks), fixVersion: fixVersionsOf(f.fixVersions),
|
|
162
176
|
points: pointsIn(f), resolved: dayOf(f.resolutiondate || f.resolved),
|
|
163
|
-
flagged, reason: flagged ? (flaggedIn(f) ? 'Flagged in Jira' : `Status: ${status}`) : null, url: urlOf(x, key)
|
|
177
|
+
flagged, reason: flagged ? (flaggedIn(f) ? 'Flagged in Jira' : `Status: ${status}`) : null, url: urlOf(x, key),
|
|
178
|
+
...typeParent(f.issuetype, f.parent) };
|
|
164
179
|
}
|
|
165
180
|
// The flat shape: what the skill tells the agent to write when it is unsure of the connector's shape.
|
|
166
181
|
const summary = clip(oneLine(x.summary || x.title), 250);
|
|
@@ -170,7 +185,7 @@ function normalizeIssue(x) {
|
|
|
170
185
|
return { key, summary, status, category: categoryOf(status, x.category), sprint: sprintOf(x.sprint), subtasks: subtasksOf(x.subtasks), fixVersion: fixVersionsOf(x.fixVersions != null ? x.fixVersions : x.fixVersion),
|
|
171
186
|
points: pointsIn(x), resolved: dayOf(x.resolved || x.resolutiondate),
|
|
172
187
|
flagged, reason: flagged ? clip(oneLine(x.reason || x.flag_reason || (x.flagged === true ? 'Flagged in Jira' : `Status: ${status}`)), 160) : null,
|
|
173
|
-
url: urlOf(x, key) };
|
|
188
|
+
url: urlOf(x, key), ...typeParent(x.type != null ? x.type : x.issuetype, x.parent) };
|
|
174
189
|
}
|
|
175
190
|
function normalize(raw) {
|
|
176
191
|
const list = issuesList(raw);
|
|
@@ -223,13 +238,18 @@ function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
|
|
|
223
238
|
const n = normalize(raw);
|
|
224
239
|
if (n.error) return { ok: false, error: n.error };
|
|
225
240
|
const pushed = jiraTickets.pushedTickets(root, initiative).tickets;
|
|
226
|
-
|
|
241
|
+
const epics = pushed.size ? [] : jiraTickets.epicKeysOf(root, initiative);
|
|
242
|
+
if (!pushed.size && !epics.length) return { ok: false, error: `${initiative} has no ticket in Jira yet — push its tickets first, or link its Jira epic` };
|
|
227
243
|
const prev = readSnapshot(root, initiative);
|
|
228
|
-
|
|
244
|
+
// Pushed mode keeps the pushed keys. Epic mode keeps the epics and the issues under them; an issue the search returned
|
|
245
|
+
// with no parent field is kept, since the search was scoped to the epics; one under another parent is ignored.
|
|
246
|
+
const keep = pushed.size ? (i) => pushed.has(i.key) : (i) => epics.includes(i.key) || !i.parent || epics.includes(i.parent);
|
|
247
|
+
const got = new Map(n.issues.filter(keep).map((i) => [i.key, i]));
|
|
229
248
|
const ignored = n.issues.length - got.size;
|
|
230
249
|
const ids = new Map([...pushed].map(([k, v]) => [k, v.id]));
|
|
231
250
|
const readings = []; const changes = []; const missing = [];
|
|
232
|
-
|
|
251
|
+
const expected = pushed.size ? [...pushed.keys()] : [...new Set([...epics, ...got.keys(), ...(prev ? [...prev.tickets.keys()].filter((k) => { const r = prev.tickets.get(k); return epics.includes(k) || !r.parent || epics.includes(r.parent); }) : [])])];
|
|
252
|
+
for (const key of expected) {
|
|
233
253
|
const r = got.get(key);
|
|
234
254
|
const was = prev ? prev.tickets.get(key) : null;
|
|
235
255
|
if (!r) {
|
|
@@ -42,6 +42,7 @@ const crypto = require('crypto');
|
|
|
42
42
|
const planItems = require('./plan-items.js');
|
|
43
43
|
const planTree = require('./plan-tree.js');
|
|
44
44
|
const consoleState = require('./console-state');
|
|
45
|
+
const { repoScript } = require('./repo-script.js');
|
|
45
46
|
|
|
46
47
|
const KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
|
|
47
48
|
const KEY_IN = /\b([A-Z][A-Z0-9_]+-\d+)\b/;
|
|
@@ -354,8 +355,10 @@ function missingOf(t) {
|
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
// ── J3 · draft ─────────────────────────────────────────────────────────────────────────────────────
|
|
358
|
+
/** The Jira epic a record names: `jira_issue_key`, else `jira_epic`, else `jira`, when the value is a Jira key. */
|
|
357
359
|
function anchorOf(rfm) {
|
|
358
|
-
|
|
360
|
+
for (const k of ['jira_issue_key', 'jira_epic', 'jira']) if (rfm && isStr(rfm[k]) && KEY_RE.test(rfm[k].trim())) return rfm[k].trim().toUpperCase();
|
|
361
|
+
return null;
|
|
359
362
|
}
|
|
360
363
|
function draft(root, initiative, { dryRun = false, now } = {}) {
|
|
361
364
|
const st = items(root, initiative);
|
|
@@ -653,6 +656,31 @@ function pushedTickets(root, initiative) {
|
|
|
653
656
|
for (const t of d.tickets) if (t.jira) tickets.set(t.jira, { id: t.id, summary: t.summary });
|
|
654
657
|
return { epic: KEY_RE.test(d.fm.epic || '') ? String(d.fm.epic).toUpperCase() : null, tickets };
|
|
655
658
|
}
|
|
659
|
+
/** The Jira epics the PM linked on the initiative page: the boards ledger's `ini-epics` lines (lib/routes-boards.js),
|
|
660
|
+
* folded by scripts/boards.js. Map initiative id → keys; an initiative the PM never linked is absent. */
|
|
661
|
+
function linkedEpics(root) {
|
|
662
|
+
const base = mainRoot(root);
|
|
663
|
+
for (const rel of [['pmos', 'state'], ['state'], ['.pmos']]) {
|
|
664
|
+
let text;
|
|
665
|
+
try { text = fs.readFileSync(path.join(base, ...rel, 'boards.jsonl'), 'utf8'); } catch { continue; }
|
|
666
|
+
try { return require(repoScript('boards.js')).fold(text.split('\n').filter(Boolean)).epics || new Map(); } catch { return new Map(); }
|
|
667
|
+
}
|
|
668
|
+
return new Map();
|
|
669
|
+
}
|
|
670
|
+
/** The Jira epics of an initiative: the ones the PM linked on the page, else the one its record names (anchorOf). */
|
|
671
|
+
function epicKeysOf(root, initiative, linked = linkedEpics(root)) {
|
|
672
|
+
if (linked.has(initiative)) return linked.get(initiative);
|
|
673
|
+
const a = anchorOf(recordFm(root, initiative) || {});
|
|
674
|
+
return a ? [a] : [];
|
|
675
|
+
}
|
|
676
|
+
/** Every initiative with a Jira epic, linked on the page or named by its record, whether or not it has a ticket draft. */
|
|
677
|
+
function epicInitiatives(root) {
|
|
678
|
+
const linked = linkedEpics(root);
|
|
679
|
+
let names = [];
|
|
680
|
+
try { names = fs.readdirSync(engine().layout(mainRoot(root)).initiatives).filter((n) => n.endsWith('.md')).map((n) => n.slice(0, -3)); } catch { names = []; }
|
|
681
|
+
const ids = new Set([...names, ...linked.keys()].filter((id) => planItems.INIT_RE.test(id)));
|
|
682
|
+
return [...ids].filter((id) => epicKeysOf(root, id, linked).length && recordFm(root, id)).sort();
|
|
683
|
+
}
|
|
656
684
|
/** Every initiative with a ticket draft in this repository. */
|
|
657
685
|
function draftedInitiatives(root) {
|
|
658
686
|
let names = [];
|
|
@@ -663,6 +691,6 @@ function draftedInitiatives(root) {
|
|
|
663
691
|
module.exports = {
|
|
664
692
|
TYPES, PRIORITIES, ESTIMATES, SECTIONS, REQUIRED,
|
|
665
693
|
items, itemsInPrd, itemsInFile, fromItem, draft, view, edit, resolvePrd, setEpic, approve, pushPlan, record,
|
|
666
|
-
readDraft, parseDraft, pushedTickets, draftedInitiatives, missingOf, contentHash, describe,
|
|
694
|
+
readDraft, parseDraft, pushedTickets, draftedInitiatives, epicInitiatives, epicKeysOf, linkedEpics, anchorOf, missingOf, contentHash, describe,
|
|
667
695
|
draftPath, draftRel, jiraDir, relOf, recordFm, prdOf,
|
|
668
696
|
};
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* POST /api/boards/asks/accept { ws, board, ask, title, lane, anchor, intent?, id? } → { ok, initiative, stage }
|
|
20
20
|
* POST /api/boards/asks/decline { ws, board, ask, why } → { ok }
|
|
21
21
|
* POST /api/boards/asks/more { ws, board, ask, note? } → { ok }
|
|
22
|
-
* POST /api/boards/settings { ws, board, buildDeliver?, jira?, offer?, defTpl? } → { ok, board }
|
|
22
|
+
* POST /api/boards/settings { ws, board, buildDeliver?, jira?, offer?, defTpl? } → { ok, board } jira: one key or a list
|
|
23
23
|
* POST /api/boards/ini-workflow { ws, board, id, template?, stages, moveTo? } → { ok, row, moved }
|
|
24
|
+
* POST /api/boards/ini-epics { ws, board, id, epics: [Jira key] } → { ok, row } the Jira epics of an initiative ([] unlinks)
|
|
24
25
|
* POST /api/boards/tasks/add { ws, board, initiative, title, type?, epic?, start?, due?, owner? } → { ok, item }
|
|
25
26
|
* POST /api/boards/tasks/state { ws, board, task, state } → { ok, item }
|
|
26
27
|
* POST /api/boards/tasks/reschedule { ws, board, task, days } → { ok, item }
|
|
@@ -116,8 +117,8 @@ function boardsOf(ctx, w, folded) {
|
|
|
116
117
|
return {
|
|
117
118
|
ws: w.id, id: b0.id, name: typeof led.name === 'string' && led.name ? led.name : b0.name, domain: 'domain' in led ? led.domain : b0.domain,
|
|
118
119
|
kind: b0.kind, template, templateEdited: selfBoard ? false : B.isTemplateEdited(template, workflow), workflow, self: selfBoard,
|
|
119
|
-
tracker:
|
|
120
|
-
build: set.build, buildDeliver: set.build, jira: set.jira, offer: set.offer, defTpl: set.defTpl,
|
|
120
|
+
tracker: set.jiraProjects.length ? `Jira · ${set.jiraProjects.join(', ')}` : led.tracker || null, sprints: !!led.sprints || (set.build && !!set.jira),
|
|
121
|
+
build: set.build, buildDeliver: set.build, jira: set.jira, jiraProjects: set.jiraProjects, offer: set.offer, defTpl: set.defTpl,
|
|
121
122
|
};
|
|
122
123
|
});
|
|
123
124
|
}
|
|
@@ -201,12 +202,19 @@ function baseRows(w) {
|
|
|
201
202
|
reading: B.readingOf(r.nine_stages, { accepted: !!rec.accepted }),
|
|
202
203
|
lifecycle: rec.stopped ? rec.stopped.state : null,
|
|
203
204
|
hasSpec, record: relRec || null, domainFolder: dm ? dm[1] : null,
|
|
205
|
+
recordEpic: jiraTickets.anchorOf(fm),
|
|
204
206
|
});
|
|
205
207
|
}
|
|
206
208
|
rowCache.set(w.root, { key: cacheKey, rows });
|
|
207
209
|
return { ok: true, rows };
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
/** The Jira epics of an initiative: the ones the PM linked on the page (the ledger), else the epic its record names. */
|
|
213
|
+
function epicsOf(base, folded) {
|
|
214
|
+
if (folded && folded.epics && folded.epics.has(base.id)) return { epics: folded.epics.get(base.id), epicsFrom: 'pmos' };
|
|
215
|
+
return base.recordEpic ? { epics: [base.recordEpic], epicsFrom: 'record' } : { epics: [], epicsFrom: null };
|
|
216
|
+
}
|
|
217
|
+
|
|
210
218
|
/** The PRD row of one base row on one board. */
|
|
211
219
|
function boardRow(board, base, folded) {
|
|
212
220
|
const s = B.stageOf(board, base.id, base.reading, folded);
|
|
@@ -217,6 +225,7 @@ function boardRow(board, base, folded) {
|
|
|
217
225
|
horizon: base.horizon, status: base.status, start: base.start, end: base.end, progress: base.progress, warnings: [],
|
|
218
226
|
rank: base.rank, health: base.health, updated: base.updated, reading: base.reading, stageSource: s.source, lifecycle: base.lifecycle,
|
|
219
227
|
template: B.templateOf(board, base.id, folded), workflow: wf.map((x) => ({ name: x.name, cat: x.cat })),
|
|
228
|
+
...epicsOf(base, folded),
|
|
220
229
|
};
|
|
221
230
|
const w = [];
|
|
222
231
|
if (s.source === 'move') {
|
|
@@ -272,9 +281,9 @@ function itemsOf(w, fr, board, rows) {
|
|
|
272
281
|
let d = null;
|
|
273
282
|
let snap = null;
|
|
274
283
|
try { d = jiraTickets.readDraft(w.root, row.id); } catch { d = null; }
|
|
275
|
-
if (!d) continue;
|
|
276
284
|
try { snap = jiraStatus.readSnapshot(w.root, row.id); } catch { snap = null; }
|
|
277
285
|
const reading = (k) => (snap && snap.tickets && snap.tickets.get ? snap.tickets.get(k) : null) || null;
|
|
286
|
+
if (!d) { epicItems(row, board, snap, jiraKeys, out); continue; }
|
|
278
287
|
const epicKey = /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(String((d.fm && d.fm.epic) || '')) ? String(d.fm.epic).toUpperCase() : null;
|
|
279
288
|
if (epicKey) {
|
|
280
289
|
const rd = reading(epicKey);
|
|
@@ -294,6 +303,22 @@ function itemsOf(w, fr, board, rows) {
|
|
|
294
303
|
return B.rollupItems(out);
|
|
295
304
|
}
|
|
296
305
|
|
|
306
|
+
/** The Jira items of an initiative PMOS pushed nothing for: its linked epics and the issues under them, from the last
|
|
307
|
+
* read of the Jira status check (lib/jira-status.js, epic mode). An issue under no linked epic hangs under the first. */
|
|
308
|
+
function epicItems(row, board, snap, seen, out) {
|
|
309
|
+
if (!snap || !snap.tickets || !snap.tickets.size || !(row.epics || []).length) return;
|
|
310
|
+
const epics = row.epics.filter((k) => snap.tickets.has(k));
|
|
311
|
+
const first = epics[0] || null;
|
|
312
|
+
const item = (r, kind, epic) => ({ uid: r.key, key: r.key, kind, src: 'jira', title: r.summary || r.key, status: B.itemStatus(r.status, r.category), blocked: !!r.flagged, owner: null, ini: row.id, epic, start: null, end: null, ref: null, board: board.id });
|
|
313
|
+
for (const k of epics) { if (!seen.has(k)) { out.push(item(snap.tickets.get(k), 'Epic', null)); seen.add(k); } }
|
|
314
|
+
for (const r of snap.tickets.values()) {
|
|
315
|
+
if (seen.has(r.key) || (r.parent && !row.epics.includes(r.parent)) || (!r.parent && r.type === 'Epic')) continue; // a reading of an epic no longer linked
|
|
316
|
+
const kind = ['Story', 'Task', 'Bug'].find((t) => t.toLowerCase() === String(r.type || '').toLowerCase()) || 'Task';
|
|
317
|
+
out.push(item(r, kind, r.parent && epics.includes(r.parent) ? r.parent : first));
|
|
318
|
+
seen.add(r.key);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
297
322
|
// ── HTTP ──────────────────────────────────────────────────────────────────────────────────────────
|
|
298
323
|
function wsOf(ctx, wsParam) {
|
|
299
324
|
const w = ctx.getWs(wsParam || null);
|
|
@@ -415,8 +440,8 @@ const READS = {
|
|
|
415
440
|
'/api/boards/items': (ctx, res, url) => {
|
|
416
441
|
const r = boardRead(ctx, res, url);
|
|
417
442
|
if (!r) return;
|
|
418
|
-
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, build: r.board.build, jira: r.board.jira, items: itemsOf(r.w, r.fr, r.board, r.rows),
|
|
419
|
-
initiatives: r.rows.map((x) => ({ id: x.id, title: x.title, stage: x.stage, cat: x.cat, status: x.status, owner: x.owner, horizon: x.horizon, start: x.start, end: x.end, progress: x.progress, template: x.template })) });
|
|
443
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, build: r.board.build, jira: r.board.jira, jiraProjects: r.board.jiraProjects, items: itemsOf(r.w, r.fr, r.board, r.rows),
|
|
444
|
+
initiatives: r.rows.map((x) => ({ id: x.id, title: x.title, stage: x.stage, cat: x.cat, status: x.status, owner: x.owner, horizon: x.horizon, start: x.start, end: x.end, progress: x.progress, template: x.template, epics: x.epics })) });
|
|
420
445
|
},
|
|
421
446
|
'/api/boards/sprints': (ctx, res, url) => {
|
|
422
447
|
const r = boardRead(ctx, res, url);
|
|
@@ -461,10 +486,12 @@ function settingsOf(body, board) {
|
|
|
461
486
|
const v = {};
|
|
462
487
|
const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
|
|
463
488
|
if (bd != null) { if (typeof bd !== 'boolean') return { error: 'buildDeliver is true or false' }; v.buildDeliver = bd; }
|
|
464
|
-
if (body.jira != null && body.jira !== '') {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
489
|
+
if (body.jira != null && body.jira !== '' && !(Array.isArray(body.jira) && !body.jira.length)) {
|
|
490
|
+
const list = Array.isArray(body.jira) ? body.jira : typeof body.jira === 'string' ? body.jira.split(/[,\s]+/).filter(Boolean) : null;
|
|
491
|
+
if (!list || list.length > 10 || list.some((k) => typeof k !== 'string' || !B.JIRA_PROJECT_RE.test(k.trim()))) return { error: 'jira is one or more Jira project keys (at most 10), each a letter, then letters, digits or _ (at most 20)' };
|
|
492
|
+
const keys = [...new Set(list.map((k) => k.trim().toUpperCase()))];
|
|
493
|
+
v.jira = keys.length === 1 ? keys[0] : keys;
|
|
494
|
+
} else if (body.jira === '' || Array.isArray(body.jira)) v.jira = null;
|
|
468
495
|
const build = 'buildDeliver' in v ? v.buildDeliver : board ? board.build : true;
|
|
469
496
|
if (body.offer != null) {
|
|
470
497
|
if (!Array.isArray(body.offer) || !body.offer.length || body.offer.some((k) => !B.INI_TEMPLATES[k])) return { error: `offer is a list of templates: ${Object.keys(B.INI_TEMPLATES).join(', ')}` };
|
|
@@ -501,7 +528,7 @@ const WRITES = {
|
|
|
501
528
|
if (set.error) return fail(ctx, res, 400, set.error);
|
|
502
529
|
const ev = { kind: 'board', op: 'create', board: id, name, domain: body.domain ? String(body.domain).trim() : null, template: body.template, workflow, sprints: false, tracker: null };
|
|
503
530
|
for (const k of ['buildDeliver', 'jira', 'offer', 'defTpl']) if (k in set.value) ev[k] = set.value[k];
|
|
504
|
-
if (ev.buildDeliver !== false && ev.jira) { ev.tracker = `Jira · ${ev.jira}`; ev.sprints = true; }
|
|
531
|
+
if (ev.buildDeliver !== false && ev.jira) { ev.tracker = `Jira · ${[].concat(ev.jira).join(', ')}`; ev.sprints = true; }
|
|
505
532
|
append(p.file, g.actor, ev);
|
|
506
533
|
const board = boardsOf(ctx, p.w, B.fold(readLedger(ctx, p.w))).find((b) => b.id === id);
|
|
507
534
|
ctx.json(res, { ok: true, board });
|
|
@@ -613,9 +640,9 @@ const WRITES = {
|
|
|
613
640
|
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
614
641
|
const ev = { kind: 'board', op: 'settings', board: p.board.id, ...set.value };
|
|
615
642
|
const build = 'buildDeliver' in set.value ? set.value.buildDeliver : p.board.build;
|
|
616
|
-
const jira = 'jira' in set.value ? set.value.jira : p.board.
|
|
617
|
-
ev.tracker = build && jira ? `Jira · ${jira}` : null;
|
|
618
|
-
ev.sprints = !!(build && jira);
|
|
643
|
+
const jira = 'jira' in set.value ? (set.value.jira ? [].concat(set.value.jira) : []) : p.board.jiraProjects;
|
|
644
|
+
ev.tracker = build && jira.length ? `Jira · ${jira.join(', ')}` : null;
|
|
645
|
+
ev.sprints = !!(build && jira.length);
|
|
619
646
|
append(p.file, g.actor, ev);
|
|
620
647
|
const board = boardsOf(ctx, p.w, B.fold(readLedger(ctx, p.w))).find((b) => b.id === p.board.id);
|
|
621
648
|
ctx.json(res, { ok: true, board });
|
|
@@ -639,6 +666,26 @@ const WRITES = {
|
|
|
639
666
|
const row = boardRow(p.board, b0, B.fold(readLedger(ctx, p.w)));
|
|
640
667
|
ctx.json(res, { ok: true, row, moved: ed.moved });
|
|
641
668
|
},
|
|
669
|
+
'/api/boards/ini-epics': (ctx, res, body, g) => {
|
|
670
|
+
const p = postFrame(ctx, res, body);
|
|
671
|
+
if (!p) return;
|
|
672
|
+
if (typeof body.id !== 'string' || !body.id) return fail(ctx, res, 400, 'id is required');
|
|
673
|
+
if (!p.board.build) return fail(ctx, res, 409, `${p.board.name} has Build and deliver off, so it mirrors no Jira`);
|
|
674
|
+
const list = Array.isArray(body.epics) ? body.epics : typeof body.epics === 'string' ? body.epics.split(/[,\s]+/).filter(Boolean) : null;
|
|
675
|
+
if (!list || list.length > 20 || list.some((k) => typeof k !== 'string' || !B.JIRA_KEY_RE.test(k.trim()))) return fail(ctx, res, 400, 'epics is a list of Jira keys (at most 20), each like IDPF-2685');
|
|
676
|
+
const epics = [...new Set(list.map((k) => k.trim().toUpperCase()))];
|
|
677
|
+
const projects = p.board.jiraProjects || [];
|
|
678
|
+
const off = projects.length ? epics.filter((k) => !projects.includes(k.split('-')[0])) : [];
|
|
679
|
+
if (off.length) return fail(ctx, res, 400, `${off.join(', ')} ${off.length === 1 ? 'is' : 'are'} not in a Jira project of ${p.board.name} (${projects.join(', ')})`);
|
|
680
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
681
|
+
const base = needRows(ctx, res, p.w);
|
|
682
|
+
if (!base) return;
|
|
683
|
+
const b0 = base.find((r) => r.id === body.id);
|
|
684
|
+
if (!b0 || boardIdOf(b0, p.fr.boards, p.fr.folded, p.w) !== p.board.id) return fail(ctx, res, 404, `no initiative ${body.id} on this board`);
|
|
685
|
+
append(p.file, g.actor, { kind: 'ini-epics', board: p.board.id, id: body.id, epics });
|
|
686
|
+
const row = boardRow(p.board, b0, B.fold(readLedger(ctx, p.w)));
|
|
687
|
+
ctx.json(res, { ok: true, row });
|
|
688
|
+
},
|
|
642
689
|
'/api/boards/tasks/add': (ctx, res, body, g) => {
|
|
643
690
|
const p = postFrame(ctx, res, body);
|
|
644
691
|
if (!p) return;
|
|
@@ -47,6 +47,7 @@ const jobs = require('./jobs-to-be-done.js');
|
|
|
47
47
|
const workFocus = require('./work-focus.js');
|
|
48
48
|
const workEvidence = require('./work-evidence.js');
|
|
49
49
|
const steps = require('./item-steps.js');
|
|
50
|
+
const assigned = require('./work-assigned.js');
|
|
50
51
|
const { repoScript } = require('./repo-script.js');
|
|
51
52
|
|
|
52
53
|
const FIELDS = ['title', 'due', 'priority', 'waiting_on', 'job', 'defer', 'flagged', 'resolves', 'kind', 'person', 'evidence', 'outcome'];
|
|
@@ -77,10 +78,13 @@ function resolvesTitles(items, ctx, url) {
|
|
|
77
78
|
} catch { return {}; }
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
function view(itemLines, jobLines, now, stepLines = [], focusLines = [], proof = null) {
|
|
81
|
+
function view(itemLines, jobLines, now, stepLines = [], focusLines = [], proof = null, extra = []) {
|
|
81
82
|
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
82
83
|
const byItem = steps.foldSteps(stepLines).byItem;
|
|
83
|
-
|
|
84
|
+
// the work assigned to the PM and what the morning brief suggests (work-shows-assigned): the ledger's copy when it holds one, else the source's
|
|
85
|
+
const byId = new Map((extra || []).map((v) => [v.id, v]));
|
|
86
|
+
const folded = pmItems.fold(itemLines).map((i) => ({ ...assigned.overlay(i, byId), steps: byItem[i.id] || [] }))
|
|
87
|
+
.concat(assigned.addable(extra, itemLines).map((v) => ({ ...v, steps: [] })));
|
|
84
88
|
const items = proof ? workEvidence.withEvidence(folded, { ...proof, now: now.getTime() }) : folded;
|
|
85
89
|
const currentJobs = jobs.foldJobs(jobLines).map((j) => ({ ...j, ...jobs.progress(j.id, items) }));
|
|
86
90
|
const lists = Object.fromEntries(pmItems.LISTS.map((l) => [l, []]));
|
|
@@ -106,6 +110,28 @@ function proofSources(ctx, itemLines) {
|
|
|
106
110
|
return { events, notes };
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
// The work the PM did not type in (work-shows-assigned): open my-work items (Jira, WBS) and today's brief items for the PM.
|
|
114
|
+
// Best effort: a source that cannot be read adds nothing. `myWork` is GET /api/my-work's answer, read by the caller.
|
|
115
|
+
function readBrief(ctx, now) {
|
|
116
|
+
if (!ctx.runtimeDir) return null;
|
|
117
|
+
try { return JSON.parse(fs.readFileSync(path.join(ctx.runtimeDir, 'briefs', require('./brief.js').localDate(now.getTime()) + '.json'), 'utf8')); } catch { return null; }
|
|
118
|
+
}
|
|
119
|
+
function assignedFrom(myWork, brief, now) {
|
|
120
|
+
const todayIso = require('./brief.js').localDate(now.getTime());
|
|
121
|
+
return [...assigned.fromMyWork(myWork && myWork.ok ? myWork.items : []), ...assigned.fromBrief(brief, todayIso)];
|
|
122
|
+
}
|
|
123
|
+
async function assignedAsync(ctx, now) {
|
|
124
|
+
let mw = null;
|
|
125
|
+
try { mw = await require('./routes-my-work.js').listForAsync(ctx, now.getTime()); } catch { mw = null; }
|
|
126
|
+
return assignedFrom(mw, readBrief(ctx, now), now);
|
|
127
|
+
}
|
|
128
|
+
function assignedSync(ctx, now) {
|
|
129
|
+
let mw = null;
|
|
130
|
+
try { mw = require('./routes-my-work.js').listFor(ctx, now.getTime()); } catch { mw = null; }
|
|
131
|
+
return assignedFrom(mw, readBrief(ctx, now), now);
|
|
132
|
+
}
|
|
133
|
+
const VIRTUAL_ID = /^(jira:|wbs:|brief:)/;
|
|
134
|
+
|
|
109
135
|
// Each operation returns { status, body, events } and writes nothing itself; the caller appends `events`.
|
|
110
136
|
function checkFields(fields, allowed) {
|
|
111
137
|
if (!fields || typeof fields !== 'object') return 'fields must be an object';
|
|
@@ -307,12 +333,12 @@ function prepare(ctx, now = new Date()) {
|
|
|
307
333
|
|
|
308
334
|
module.exports = {
|
|
309
335
|
prefixes: ['/api/pm-items', '/api/pm-jobs'],
|
|
310
|
-
view, resolvesTitles, flagRoute, deferItem, somedayItem, waitingItem, notYetItem, keepItem, focusEvent, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
|
|
336
|
+
view, resolvesTitles, assignedFrom, flagRoute, deferItem, somedayItem, waitingItem, notYetItem, keepItem, focusEvent, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
|
|
311
337
|
handle: async (req, res, url, ctx) => {
|
|
312
338
|
const p = url.pathname;
|
|
313
339
|
if (p === '/api/pm-items' && req.method === 'GET') {
|
|
314
340
|
const c = prepare(ctx);
|
|
315
|
-
const v = view(c.itemLines, c.jobLines, c.now, c.stepLines, c.focusLines, proofSources(ctx, c.itemLines));
|
|
341
|
+
const v = view(c.itemLines, c.jobLines, c.now, c.stepLines, c.focusLines, proofSources(ctx, c.itemLines), await assignedAsync(ctx, c.now));
|
|
316
342
|
v.resolves_titles = resolvesTitles(v.items, ctx, url);
|
|
317
343
|
ctx.json(res, v);
|
|
318
344
|
return true;
|
|
@@ -325,6 +351,11 @@ module.exports = {
|
|
|
325
351
|
const g = guard.requirePagePress(req, body);
|
|
326
352
|
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
327
353
|
const c = prepare(ctx);
|
|
354
|
+
// the first press on assigned or suggested work puts it in the PM's ledger, with its source, before the press itself
|
|
355
|
+
if (typeof body.id === 'string' && VIRTUAL_ID.test(body.id) && !findItem(c.itemLines, body.id)) {
|
|
356
|
+
const v = assigned.addable(assignedSync(ctx, c.now), c.itemLines).find((x) => x.id === body.id);
|
|
357
|
+
if (v) { const ev = assigned.createdEvent(v); pmItems.appendEvent(itemsFile(ctx), ev, c.now); c.itemLines.push({ ...ev, at: c.now.toISOString() }); }
|
|
358
|
+
}
|
|
328
359
|
const r = ROUTES[key](body, c);
|
|
329
360
|
if (r.status === 200 && r.events) {
|
|
330
361
|
for (const ev of r.events) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/work-assigned.js — the work the Work page shows that the PM did not type in (initiative work-shows-assigned).
|
|
3
|
+
*
|
|
4
|
+
* fromMyWork(items) → [item] open my-work items (Jira tickets, WBS items owned by `you`) as Work items
|
|
5
|
+
* fromBrief(brief, todayIso) → [item] today's brief items for the PM (owner pm, not worth_asking), flagged for Today
|
|
6
|
+
* addable(virtuals, itemLines) → [item] the ones the PM's own ledger has never held (not added, not dropped)
|
|
7
|
+
* createdEvent(v) → event the `created` line that puts one in the PM's ledger on its first press
|
|
8
|
+
* overlay(item, byId) → item a ledger copy with the source's current status, suggestion and state
|
|
9
|
+
*
|
|
10
|
+
* PURE: no file, process or clock. A virtual item has the Work item's fields (title, due, priority, waiting_on, flagged) and
|
|
11
|
+
* says where it came from: origin jira | wbs | brief, plus key, url, status, suggest, why, my_work_state, repo. Its `source`
|
|
12
|
+
* is its origin, so a Drop is a drop (pm-items.js removalEvent), never a delete. */
|
|
13
|
+
|
|
14
|
+
const TIER_PRIORITY = { P1: 'P1', P2: 'P2', P3: 'P3' };
|
|
15
|
+
const AGENT = 'an agent';
|
|
16
|
+
const SHOWN = ['origin', 'key', 'url', 'status', 'suggest', 'why', 'my_work_state', 'repo', 'initiative'];
|
|
17
|
+
|
|
18
|
+
function fromMyWork(items) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const x of items || []) {
|
|
21
|
+
if (!x || !x.id || !x.title || x.state === 'done' || x.category === 'done') continue;
|
|
22
|
+
const v = {
|
|
23
|
+
id: String(x.id), title: String(x.title), source: x.source === 'wbs' ? 'wbs' : 'jira', origin: x.source === 'wbs' ? 'wbs' : 'jira', assigned: true,
|
|
24
|
+
due: x.due || null, priority: TIER_PRIORITY[x.wsjf && x.wsjf.tier] || null, waiting_on: null, job: null,
|
|
25
|
+
key: x.key || null, url: x.url || null, status: x.status || null, suggest: x.suggest || null, my_work_state: x.state || null,
|
|
26
|
+
repo: x.repo || null, initiative: x.initiative || null,
|
|
27
|
+
};
|
|
28
|
+
if (x.state === 'agent') v.waiting_on = AGENT;
|
|
29
|
+
if (x.state === 'ready' || x.category === 'doing') v.flagged = true;
|
|
30
|
+
out.push(v);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fromBrief(brief, todayIso) {
|
|
36
|
+
if (!brief || brief.date !== todayIso) return [];
|
|
37
|
+
const list = [...(Array.isArray(brief.priorities) ? brief.priorities : []), ...(Array.isArray(brief.items) ? brief.items : [])];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const x of list) {
|
|
41
|
+
if (!x || !x.id || !x.title || x.owner !== 'pm' || x.worth_asking || seen.has(x.id)) continue;
|
|
42
|
+
seen.add(x.id);
|
|
43
|
+
out.push({ id: 'brief:' + x.id, title: String(x.title), source: 'brief', origin: 'brief', suggested: true, flagged: true,
|
|
44
|
+
due: null, priority: null, waiting_on: null, job: null, why: x.why_now || null, initiative: x.initiative_id || null });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The virtual items whose id the ledger never held: an item the PM added, pressed or dropped is the ledger's from then on. */
|
|
50
|
+
function addable(virtuals, itemLines) {
|
|
51
|
+
const held = new Set((itemLines || []).filter((l) => l && l.id).map((l) => l.id));
|
|
52
|
+
return (virtuals || []).filter((v) => !held.has(v.id));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createdEvent(v) {
|
|
56
|
+
const { id, source, ...fields } = v;
|
|
57
|
+
return { event: 'created', id, fields, source };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A ledger copy of an assigned item takes the source's current status, suggestion and state; the PM's own fields stay. */
|
|
61
|
+
function overlay(item, byId) {
|
|
62
|
+
const v = byId && byId.get(item.id);
|
|
63
|
+
if (!v) return item;
|
|
64
|
+
const out = { ...item };
|
|
65
|
+
for (const k of SHOWN) if (v[k] !== undefined && v[k] !== null) out[k] = v[k];
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { fromMyWork, fromBrief, addable, createdEvent, overlay, AGENT };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.143.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.143.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -211,7 +211,7 @@ const openAsks = (S, bid) => S.asks.filter((a) => a.board === bid && a.state ===
|
|
|
211
211
|
function rowOut(S, r) {
|
|
212
212
|
const wf = wfOf(S, r);
|
|
213
213
|
return { id: r.id, title: r.title, lane: r.lane, stage: r.stage, cat: catIn(wf, r.stage), owner: r.owner || null, anchor: r.anchor, horizon: r.horizon, status: r.status, start: r.start, end: r.end, progress: r.progress,
|
|
214
|
-
warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })) };
|
|
214
|
+
warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })), epics: (r.epics || []).slice(), epicsFrom: r.epics ? 'pmos' : null };
|
|
215
215
|
}
|
|
216
216
|
function boardOut(S, b) {
|
|
217
217
|
const list = S.rows.filter((r) => r.board === b.id);
|
|
@@ -219,7 +219,7 @@ function boardOut(S, b) {
|
|
|
219
219
|
for (const r of list) byCat[catOfRow(S, r)]++;
|
|
220
220
|
const rel = nextRelease(S, b.id);
|
|
221
221
|
return { ws: b.ws, id: b.id, name: b.name, full: b.full, domain: b.domain, template: b.template, templateEdited: tplEdited(b), workflow: b.workflow.map((s) => ({ ...s })), self: b.self, tracker: b.tracker, sprints: b.sprints,
|
|
222
|
-
build: b.build, buildDeliver: b.build, jira: b.jira, offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
|
|
222
|
+
build: b.build, buildDeliver: b.build, jira: b.jira, jiraProjects: b.build && b.jira ? (b.jiraProjects || [b.jira]).slice() : [], offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
|
|
223
223
|
counts: { total: list.length, open: list.filter(isOpen).length, byCat, risk: list.filter((r) => r.status === 'at risk').length, late: list.filter((r) => r.status === 'late').length },
|
|
224
224
|
asks: openAsks(S, b.id), nextRelease: rel ? { id: rel.id, name: rel.name, at: rel.date, date: rel.date, status: rel.status } : null };
|
|
225
225
|
}
|
|
@@ -396,7 +396,11 @@ export function handle(S, method, path, p, body) {
|
|
|
396
396
|
const v = {};
|
|
397
397
|
const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
|
|
398
398
|
if (bd != null) { if (typeof bd !== 'boolean') return fail(400, 'buildDeliver is true or false'); v.build = bd; }
|
|
399
|
-
if (body.jira != null && body.jira !== '') {
|
|
399
|
+
if (body.jira != null && body.jira !== '') {
|
|
400
|
+
const keys = [].concat(body.jira).join(',').split(/[,\s]+/).filter(Boolean);
|
|
401
|
+
if (!keys.length || keys.some((j) => !/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(j))) return fail(400, 'jira is one or more Jira project keys (at most 10), each a letter, then letters, digits or _ (at most 20)');
|
|
402
|
+
v.jiraProjects = [...new Set(keys.map((j) => j.toUpperCase()))]; v.jira = v.jiraProjects[0];
|
|
403
|
+
}
|
|
400
404
|
const build = 'build' in v ? v.build : b.build;
|
|
401
405
|
if (body.offer != null) {
|
|
402
406
|
if (!Array.isArray(body.offer) || !body.offer.length || body.offer.some((k) => !INI_TEMPLATES[k])) return fail(400, 'offer is a list of templates: ' + Object.keys(INI_TEMPLATES).join(', '));
|
|
@@ -413,7 +417,8 @@ export function handle(S, method, path, p, body) {
|
|
|
413
417
|
Object.assign(b, v);
|
|
414
418
|
if (!b.build) { b.offer = b.offer.filter((k) => !templateBuilds(k)); if (!b.offer.length) b.offer = ['simple']; if (!b.offer.includes(b.defTpl)) b.defTpl = b.offer[0]; }
|
|
415
419
|
if (b.build && !b.jira) b.jira = b.prefix;
|
|
416
|
-
|
|
420
|
+
if (!b.jiraProjects || !b.jiraProjects.includes(b.jira)) b.jiraProjects = b.jira ? [b.jira] : [];
|
|
421
|
+
b.tracker = b.build && b.jira ? 'Jira · ' + b.jiraProjects.join(', ') : null; b.sprints = !!(b.build && b.jira);
|
|
417
422
|
S.events.push({ at: at(0, 8), by: 'You', kind: 'board', op: 'settings', board: b.id, ...v });
|
|
418
423
|
return okb({ board: boardOut(S, b) });
|
|
419
424
|
}
|
|
@@ -441,6 +446,20 @@ export function handle(S, method, path, p, body) {
|
|
|
441
446
|
S.events.push({ at: at(0, 8), by: 'You', kind: 'board-workflow', board: b.id, stages: b.workflow, moved: mr });
|
|
442
447
|
return okb({ board: boardOut(S, b), moved });
|
|
443
448
|
}
|
|
449
|
+
if (path === '/api/boards/ini-epics') {
|
|
450
|
+
if (!b.build) return fail(409, b.name + ' has Build and deliver off, so it mirrors no Jira');
|
|
451
|
+
const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
|
|
452
|
+
if (!r) return fail(404, 'no initiative ' + (body.id || '') + ' on this board');
|
|
453
|
+
const keys = Array.isArray(body.epics) ? body.epics : [];
|
|
454
|
+
if (keys.length > 20 || keys.some((k) => !/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(String(k)))) return fail(400, 'epics is a list of Jira keys (at most 20), each like IDPF-2685');
|
|
455
|
+
const epics = [...new Set(keys.map((k) => String(k).toUpperCase()))];
|
|
456
|
+
const projects = b.jiraProjects && b.jiraProjects.length ? b.jiraProjects : b.jira ? [b.jira] : [];
|
|
457
|
+
const off = projects.length ? epics.filter((k) => !projects.includes(k.split('-')[0])) : [];
|
|
458
|
+
if (off.length) return fail(400, off.join(', ') + (off.length === 1 ? ' is' : ' are') + ' not in a Jira project of ' + b.name + ' (' + projects.join(', ') + ')');
|
|
459
|
+
r.epics = epics;
|
|
460
|
+
S.events.push({ at: at(0, 8), by: 'You', kind: 'ini-epics', board: b.id, id: r.id, epics });
|
|
461
|
+
return okb({ row: rowOut(S, r) });
|
|
462
|
+
}
|
|
444
463
|
if (path === '/api/boards/ini-workflow') {
|
|
445
464
|
if (b.self) return fail(409, 'The PMOS board follows the ladder its records derive.');
|
|
446
465
|
const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Every control is a button; the drag handlers are drag events on the cards and columns (pointer handlers stay on the document). */
|
|
15
15
|
import { el } from './dom.js';
|
|
16
16
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
17
|
-
import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
|
|
17
|
+
import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, jiraLabel, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
|
|
18
18
|
import { STATUSES, wfOf, tplName, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
19
19
|
import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
|
|
20
20
|
|
|
@@ -67,7 +67,7 @@ function mountItems(tab) {
|
|
|
67
67
|
others > 0 ? el('span', { class: 'dim ini-note' }, plural(others, 'more initiative has', 'more initiatives have') + ' no epics or tasks yet') : null),
|
|
68
68
|
b.build
|
|
69
69
|
? el('div', { class: 'chips2', role: 'group', 'aria-label': 'Source filter', 'data-wk': 'srcs' }, lbl('SOURCE'),
|
|
70
|
-
...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + (b
|
|
70
|
+
...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + jiraLabel(b)]], ['pmos', 'PMOS tasks']].map(([v, l]) => pill({ 'data-wk-src': v, 'aria-pressed': String(st.src === v), onclick: () => { st.src = v; draw('[data-wk-src="' + v + '"]'); } }, ...[].concat(l))))
|
|
71
71
|
: el('div', { class: 'dim ini-note', 'data-wk': 'no-jira' }, icon('info', 'sm'), ' Build and deliver is off for ' + b.name + ', so there is no Jira here: every item is a PMOS task. ', lk({ onclick: () => ctx.openBoardSettings() }, 'Board settings')),
|
|
72
72
|
el('div', { class: 'dim ini-note', 'data-wk': 'count' }, plural(n, 'item') + (ne ? ' in ' + plural(ne, 'epic') : '') + (st.ini ? ' · ' + st.ini + ' only' : '') + (st.src ? ' · ' + (st.src === 'jira' ? 'Jira only' : 'PMOS only') : '') + ' · ',
|
|
73
73
|
any ? lk({ 'data-wk': 'clear', onclick: () => { Object.assign(st, { q: '', ini: '', src: '', kind: '', hideDone: false }); draw(); } }, 'Clear filters') : null, any ? ' · ' : null, 'same filters on Backlog, Board and Timeline'),
|
|
@@ -178,7 +178,7 @@ function mountItems(tab) {
|
|
|
178
178
|
requestAnimationFrame(() => { if (!dead) tlInit(host); });
|
|
179
179
|
return [sect({ ic: 'cal', title: 'Timeline', count: rows.length, right: mono('press a row to open its epics') }, node,
|
|
180
180
|
hidden > 0 ? el('div', { class: 'chips2' }, el('span', { class: 'dim ini-note' }, plural(hidden, 'more Now initiative', 'more Now initiatives') + ' with no epics yet'), pill({ 'data-wk': 'tl-all', onclick: () => { st.tlAll = true; draw(); } }, 'Show all')) : null,
|
|
181
|
-
b.build ? note('Release dates come from Jira ' + (b
|
|
181
|
+
b.build ? note('Release dates come from Jira ' + jiraLabel(b) + ' fix versions. PMOS bars drag to reschedule; Jira bars are locked.') : note('Every bar here is a PMOS task: drag it to reschedule.'))];
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
function draw(focusSel) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* its id, one with no date says "No date yet". */
|
|
9
9
|
import { el } from './dom.js';
|
|
10
10
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
11
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, plural, day, daysBetween } from './c3-ini-ui.js';
|
|
11
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween } from './c3-ini-ui.js';
|
|
12
12
|
import { loadItems } from './c3-ini-work.js';
|
|
13
13
|
|
|
14
14
|
const whenOf = (r) => String(r.release_at || r.deploy_at || r.date || '').slice(0, 10);
|
|
@@ -45,7 +45,7 @@ registerBoardTab('releases', async (host, ctx) => {
|
|
|
45
45
|
host.replaceChildren(cols(
|
|
46
46
|
[sect({ ic: 'out', title: 'Unreleased', count: up.length, attrs: { 'data-inb': 'release-next' } }, up.length ? list(up.map((r) => row(r, false))) : empty('No release planned for ' + b.name + '.', { 'data-inb': 'release-none-next' })),
|
|
47
47
|
past.length ? sect({ ic: 'check', title: 'Released', count: past.length, attrs: { 'data-inb': 'release-past' } }, list(past.map((r) => row(r, true)))) : null],
|
|
48
|
-
[card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + (b
|
|
48
|
+
[card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + jiraLabel(b) + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
|
|
49
49
|
el('div', { class: 'chips2' }, pbtn('outline', 'Open the Calendar', 'cal', { 'data-inb': 'open-calendar', onclick: () => ctx.showTab('cal') })))]));
|
|
50
50
|
return () => { dead = true; };
|
|
51
51
|
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the stage-records change; when that route is not on this server yet, the card says so quietly). source 'none' says there is no tracker. */
|
|
8
8
|
import { el } from './dom.js';
|
|
9
9
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
10
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, plural, day } from './c3-ini-ui.js';
|
|
10
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, jiraLabel, plural, day } from './c3-ini-ui.js';
|
|
11
11
|
import { loadItems } from './c3-ini-work.js';
|
|
12
12
|
import { recordCards } from './c3-ini-records.js';
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ registerBoardTab('sprints', async (host, ctx) => {
|
|
|
22
22
|
const [r, items] = await Promise.all([ctx.api.get('/api/boards/sprints', { board: b.id }), loadItems(ctx)]);
|
|
23
23
|
if (dead) return () => {};
|
|
24
24
|
if (!r || !r.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
|
|
25
|
-
if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + (b
|
|
25
|
+
if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
|
|
26
26
|
const sprints = r.sprints || [];
|
|
27
27
|
const active = sprints.find((s) => s.state === 'active');
|
|
28
28
|
const others = sprints.filter((s) => s !== active);
|
|
@@ -32,7 +32,7 @@ registerBoardTab('sprints', async (host, ctx) => {
|
|
|
32
32
|
right: [el('span', { class: 'chip2', title: 'Status lives in Jira' }, icon('lock', 'sm'))], attrs: { 'data-inb-issue': i.key } });
|
|
33
33
|
const t = readTime(r.readAt);
|
|
34
34
|
const left = [
|
|
35
|
-
el('div', { class: 'fold', 'data-inb': 'sprints-source' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' ' + (t ? 'Read ' + t + ' from Jira ' + (b
|
|
35
|
+
el('div', { class: 'fold', 'data-inb': 'sprints-source' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' ' + (t ? 'Read ' + t + ' from Jira ' + jiraLabel(b) + '.' : 'Not read yet.') + ' Change sprints in Jira.'),
|
|
36
36
|
active ? sect({ ic: 'check', title: active.name + ' items', count: (active.issues || []).length, right: tag('read-only'), attrs: { 'data-inb-sprint': active.name, 'data-inb-state': 'active' } },
|
|
37
37
|
(active.issues || []).length ? list(active.issues.map(issueRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }))
|
|
38
38
|
: empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Every tab has an honest empty state and an error line when its read fails. */
|
|
15
15
|
import { el } from './dom.js';
|
|
16
16
|
import { registerInitiativeTab } from './c3-initiatives.js';
|
|
17
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
|
|
17
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, jiraLabel, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
|
|
18
18
|
import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
19
19
|
import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
|
|
20
20
|
import { loadDocs, renderDocs } from './c3-ib-docs.js';
|
|
@@ -36,6 +36,10 @@ function tab(id, build) {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
const mine = (ctx, items) => items.filter((w) => w.ini === ctx.row.id);
|
|
39
|
+
/** Why an initiative shows no Jira item yet: no epic linked, or its epics not read yet. */
|
|
40
|
+
const epicsLine = (ctx) => ((ctx.row.epics || []).length
|
|
41
|
+
? 'No Jira item read yet for ' + ctx.row.epics.join(', ') + '. The daily Jira check reads each epic and the issues under it.'
|
|
42
|
+
: 'No Jira epic is linked to ' + ctx.row.id + ' yet. Press Link Jira epics above to choose its epics' + (jiraLabel(ctx.board) ? ' in Jira ' + jiraLabel(ctx.board) : '') + '.');
|
|
39
43
|
|
|
40
44
|
/* ── Overview ── */
|
|
41
45
|
async function toolCard(ctx, t, R, rels) {
|
|
@@ -48,7 +52,7 @@ async function toolCard(ctx, t, R, rels) {
|
|
|
48
52
|
case 'tickets': {
|
|
49
53
|
const tk = its.filter((w) => w.src === 'jira');
|
|
50
54
|
if (!b.build) body = [note(b.name + ' has Build and deliver off, so there is no Jira. Progress comes from PMOS tasks: ' + by('Done') + ' of ' + its.length + ' done.')];
|
|
51
|
-
else if (!tk.length) body = [note(
|
|
55
|
+
else if (!tk.length) body = [note(epicsLine(ctx))];
|
|
52
56
|
else {
|
|
53
57
|
const d = tk.filter((w) => w.status === 'Done').length; const p = tk.filter((w) => /progress|review/i.test(w.status)).length; const td = tk.length - d - p;
|
|
54
58
|
body = [hbs([hbr('Done', d, d / tk.length, 'ok'), hbr('In progress', p, p / tk.length, 'acc'), hbr('To do', td, td / tk.length, 'pm')]),
|
|
@@ -129,7 +133,7 @@ tab('overview', async (ctx, isDead, host) => {
|
|
|
129
133
|
['Owner', ownerText(r.owner)], ['Anchor', r.anchor || 'No anchor'], ['Horizon', r.horizon ? r.horizon[0].toUpperCase() + r.horizon.slice(1) : 'Not set'], ['Lane', r.lane || 'Not set']]),
|
|
130
134
|
hbs([hbr('Progress', Math.round(p * 100) + '%', p, toneOf(r.status))]),
|
|
131
135
|
el('div', { class: 'chips2', 'data-ini': 'board-links' }, ...['backlog', 'board', 'timeline'].map((k) => lk({ 'data-ini-boardlink': k, onclick: () => ctx.openBoard(b.id, k, { ini: r.id }) }, icon('next', 'sm'), b.name + ' ' + k[0].toUpperCase() + k.slice(1) + ', this initiative only')))),
|
|
132
|
-
card({ ic: 'bolt', title: 'Work', sub: b.build ? 'Jira ' + (b
|
|
136
|
+
card({ ic: 'bolt', title: 'Work', sub: b.build ? 'Jira ' + jiraLabel(b) + ' and PMOS' : 'PMOS tasks' },
|
|
133
137
|
note(plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') + '.'), el('div', { class: 'chips2' }, lk({ onclick: () => ctx.go({ view: 'ini', board: b.id, id: r.id, tab: 'breakdown' }) }, icon('next', 'sm'), 'Open breakdown'))),
|
|
134
138
|
rels.length ? card({ ic: 'out', title: 'Releases', sub: 'live in the Calendar' }, chgl(rels.map((x) => el('li', null, icon('out', 'sm'), el('span', null, (x.name || x.id) + ' · ' + day(String(x.release_at || x.deploy_at || '').slice(0, 10))), tag('Calendar'))))) : null,
|
|
135
139
|
];
|
|
@@ -196,7 +200,7 @@ tab('tickets', async (ctx, isDead) => {
|
|
|
196
200
|
if (isDead()) return null;
|
|
197
201
|
if (!R.ok) return errLine('The Jira items could not be read: ' + R.error + '.');
|
|
198
202
|
const tk = mine(ctx, R.items).filter((w) => w.src === 'jira');
|
|
199
|
-
if (!tk.length) return empty(
|
|
203
|
+
if (!tk.length) return empty(epicsLine(ctx), { 'data-inb': 'tk-empty' });
|
|
200
204
|
const its = tk.filter((w) => w.kind !== 'Epic'); const n = Math.max(1, its.length);
|
|
201
205
|
const c = (s) => its.filter((w) => w.status === s).length;
|
|
202
206
|
return cols(
|
|
@@ -75,6 +75,8 @@ export function geo(node, vars) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/* ── text bits ── */
|
|
78
|
+
/** The Jira projects a board mirrors, for a sentence: "IDPF, LB" (a board may map several Jira projects). */
|
|
79
|
+
export const jiraLabel = (b) => ((b && Array.isArray(b.jiraProjects) && b.jiraProjects.length) ? b.jiraProjects.join(', ') : (b && b.jira) || '');
|
|
78
80
|
export const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's');
|
|
79
81
|
export const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
80
82
|
export const chip = (...kids) => el('span', { class: 'chip2' }, ...kids);
|
|
@@ -42,7 +42,7 @@ import { ensureC3Css } from './pm-work-view.js';
|
|
|
42
42
|
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
43
43
|
import { toast } from './c3-toast.js';
|
|
44
44
|
import {
|
|
45
|
-
btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
|
|
45
|
+
btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, jiraLabel, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
|
|
46
46
|
stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO,
|
|
47
47
|
} from './c3-ini-ui.js';
|
|
48
48
|
import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf, catOf, kindOf, tplName, TOOL_META, toolLabel, toolIcon, loadItems, dropItems, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
@@ -107,7 +107,7 @@ function ensureCss() {
|
|
|
107
107
|
document.head.appendChild(l);
|
|
108
108
|
}
|
|
109
109
|
const warnChip = (w) => st('warn', 'alert', w, { title: 'An order rule, shown as a warning. It never blocks.', 'data-ini-warning': w });
|
|
110
|
-
const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), b
|
|
110
|
+
const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), jiraLabel(b) ? 'Jira · ' + jiraLabel(b) : 'Jira not set') : el('span', { class: 'tag' }, 'PMOS tasks only'));
|
|
111
111
|
const errLine = (text) => el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), text);
|
|
112
112
|
const sentenceOf = (bad, warn) => (bad.length || warn.length
|
|
113
113
|
? [bad.length ? plural(bad.length, 'board') + (bad.length === 1 ? ' is' : ' are') + ' off track' : '', warn.length ? plural(warn.length, 'board') + ' need' + (warn.length === 1 ? 's' : '') + ' attention' : ''].filter(Boolean).join(', ')
|
|
@@ -271,7 +271,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
271
271
|
const s = pick.map((i) => i.start).sort()[0]; const e = pick.map((i) => i.end).sort().pop();
|
|
272
272
|
const avg = pick.reduce((n, i) => n + (Number(i.progress) || 0), 0) / pick.length;
|
|
273
273
|
rows.push({ uid: 'b:' + b.id, label: b.name + ' · ' + pick.length + ' of ' + now.length, start: s, end: e, progress: avg, cls: 'grp', tone: b.health === 'bad' ? 'bad' : b.health === 'warn' ? 'warn' : 'ok',
|
|
274
|
-
open: () => openBoard(b.id, 'timeline'), tip: { t: b.name + (b.full ? ' · ' + b.full : ''), p: Math.round(avg * 100) + '%', st: HEALTH[b.health] || 'No health yet', o: b.owner || '', src: b.build ? 'Board · Jira ' + (b
|
|
274
|
+
open: () => openBoard(b.id, 'timeline'), tip: { t: b.name + (b.full ? ' · ' + b.full : ''), p: Math.round(avg * 100) + '%', st: HEALTH[b.health] || 'No health yet', o: b.owner || '', src: b.build ? 'Board · Jira ' + jiraLabel(b) : 'Board · PMOS tasks only', hint: 'Click the row to fold the board' },
|
|
275
275
|
kids: pick.map((i) => iniTlRow(ctx, i, workTlRows(ctx, r.items.filter((w) => w.ini === i.id), () => { dropItems(ctx); draw(); }), b)) });
|
|
276
276
|
}
|
|
277
277
|
const { from, to } = tlRange(P.today);
|
|
@@ -318,7 +318,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
318
318
|
lh('cols', b.name + (b.full ? ' · ' + b.full : ''), (HEALTH[b.health] || 'No health yet') + (risk ? ', ' + risk + ' at risk or late' : '') + (asks ? ', ' + plural(asks, 'new ask') + ' to triage' : '') + '. '
|
|
319
319
|
+ plural(c.total || 0, 'initiative') + (items.ok ? ', ' + plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') : '') + '.'),
|
|
320
320
|
el('div', { class: 'chips2', 'aria-label': 'Board setup', 'data-ini': 'setup' },
|
|
321
|
-
...(b.build ? [st('ok', 'bolt', 'Build and deliver on'), chip(icon('jira', 'sm'), b.
|
|
321
|
+
...(b.build ? [st('ok', 'bolt', 'Build and deliver on'), chip(icon('jira', 'sm'), jiraLabel(b) ? [(b.jiraProjects || []).length > 1 ? 'Jira projects ' : 'Jira project ', el('b', { class: 'mono' }, jiraLabel(b)), ' · ' + plural(jiraN, 'item')] : 'No Jira project set'), chip('PMOS tasks ' + pmN)]
|
|
322
322
|
: [chip(icon('x', 'sm'), 'Build and deliver off'), chip('No Jira · ' + plural(pmN, 'PMOS task'))]),
|
|
323
323
|
chip(icon('flag', 'sm'), 'New initiatives start with ' + def.name), lk({ onclick: () => openBoardSettings(b), 'data-ini': 'setup-change' }, 'Change')),
|
|
324
324
|
b.self ? note(SELF_WHY) : null,
|
|
@@ -356,7 +356,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
356
356
|
[card({ ic: 'flag', title: 'Where the initiatives are', sub: 'each on its own workflow' },
|
|
357
357
|
hbs(CATS.map((c) => btn({ class: 'ini-hbb', 'data-ini-catgo': c, title: 'See ' + c + ' initiatives', onclick: () => goIni({ view: 'all', cat: c }) }, hbr([sw(c), ' ', c], byCat(c), byCat(c) / maxN, 'acc', { class: 'hbr ini-c', 'data-cat': c })))),
|
|
358
358
|
note('Stages roll up into four categories. Press one to list its initiatives.')),
|
|
359
|
-
card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + (b
|
|
359
|
+
card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + jiraLabel(b) + ' and PMOS' : 'PMOS tasks' },
|
|
360
360
|
hbs(['To do', 'In progress', 'In review', 'Done'].map((s) => hbr(s, nSt(s), nSt(s) / tot, s === 'Done' ? 'ok' : s === 'To do' ? 'pm' : 'acc'))),
|
|
361
361
|
el('div', { class: 'chips2' }, lk({ onclick: () => ctx.openBoard(b.id, 'board') }, 'Open the Board'), lk({ onclick: () => ctx.openBoard(b.id, 'timeline') }, 'Open the Timeline'))),
|
|
362
362
|
card({ ic: 'report', title: 'At a glance' }, chgl([
|
|
@@ -381,7 +381,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
381
381
|
|
|
382
382
|
function openBoardSettings(b) {
|
|
383
383
|
if (b.self) { openSheet({ title: 'Board settings · ' + b.name, icon: 'lock', body: el('div', { class: 'ini-form' }, note(SELF_WHY)) }); return; }
|
|
384
|
-
const B = { build: b.build !== false, jira: b
|
|
384
|
+
const B = { build: b.build !== false, jira: jiraLabel(b), offer: new Set(offered(b)), def: b.defTpl || offered(b)[0], err: '', busy: false };
|
|
385
385
|
const host = el('div', { class: 'ini-form', 'data-ini': 'settings-form' });
|
|
386
386
|
const sh = openSheet({ title: 'Board settings · ' + b.name, icon: 'cols', body: host, wide: true });
|
|
387
387
|
let usage = null;
|
|
@@ -394,11 +394,11 @@ export function mountInitiatives(pane, opts) {
|
|
|
394
394
|
const inis = usage && usage.ok ? usage.initiatives : [];
|
|
395
395
|
const jiraN = usage && usage.ok ? usage.items.filter((w) => w.src === 'jira').length : 0;
|
|
396
396
|
const usingBuild = inis.filter((i) => wfOf(i, b).some(isBuildStage)).length;
|
|
397
|
-
const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project
|
|
397
|
+
const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project keys', placeholder: 'Project keys, e.g. IDPF, LB', 'data-ini': 'jira-key', oninput: (e) => { B.jira = e.target.value; } });
|
|
398
398
|
host.replaceChildren(
|
|
399
399
|
note('Settings for the board. Workflows belong to each initiative; here you choose which templates the board offers and which one new initiatives start with.'),
|
|
400
400
|
checkbox(B.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'build' }, (e) => { B.build = e.target.checked; if (B.build) Object.keys(TEMPLATES).filter(templateBuilds).forEach((k) => B.offer.add(k)); render('[data-ini="build"]'); }),
|
|
401
|
-
B.build ? el('div', { class: 'ini-field' }, lbl('JIRA
|
|
401
|
+
B.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, jira), note('One or more Jira projects, separated by commas. Shows their epics, stories, tasks and bugs next to PMOS tasks, and adds the Sprints and Releases tabs (mirrored, read-only). Link each initiative to its Jira epics on the initiative page.'))
|
|
402
402
|
: note('Off: Backlog, Board and Timeline show PMOS tasks only. No Sprints or Releases tab, and initiatives choose workflows without Build and Deliver.'),
|
|
403
403
|
!B.build && b.build ? el('div', { class: 'fold w', role: 'alert', 'data-ini': 'build-off-warning' }, icon('alert', 'sm'), el('span', null, plural(jiraN, 'Jira item') + ' will be hidden from this board.' + (usingBuild ? ' ' + plural(usingBuild, 'initiative') + ' use Build or Deliver stages; they keep them until you edit their workflow.' : ''))) : null,
|
|
404
404
|
lbl('WORKFLOWS THIS BOARD OFFERS'),
|
|
@@ -416,16 +416,16 @@ export function mountInitiatives(pane, opts) {
|
|
|
416
416
|
}
|
|
417
417
|
async function save() {
|
|
418
418
|
const key = B.jira.trim();
|
|
419
|
-
if (B.build && key &&
|
|
419
|
+
if (B.build && key && !key.split(/[,\s]+/).filter(Boolean).every((k) => /^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(k))) { B.err = 'Each Jira project key is a letter, then letters, digits or _. Separate keys with a comma.'; render(); return; }
|
|
420
420
|
B.busy = true; B.err = ''; render();
|
|
421
421
|
const body = { board: b.id, buildDeliver: B.build, offer: [...B.offer], defTpl: B.def };
|
|
422
|
-
if (B.build
|
|
422
|
+
if (B.build) body.jira = key;
|
|
423
423
|
const r = await apiFor(b.ws).post('/api/boards/settings', body);
|
|
424
424
|
B.busy = false;
|
|
425
425
|
if (!r || !r.ok) { B.err = 'The settings were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
|
|
426
426
|
sh.close();
|
|
427
427
|
const nb = r.board || {};
|
|
428
|
-
toast(b.name + ': Build and deliver ' + (nb.build ? 'on' + (nb
|
|
428
|
+
toast(b.name + ': Build and deliver ' + (nb.build ? 'on' + (jiraLabel(nb) ? ', mirroring Jira ' + jiraLabel(nb) : '') : 'off, PMOS tasks only') + '. New initiatives start with ' + (TEMPLATES[nb.defTpl] || TEMPLATES[B.def]).name + '.');
|
|
429
429
|
refresh();
|
|
430
430
|
}
|
|
431
431
|
render();
|
|
@@ -445,7 +445,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
445
445
|
lbl('BOARD NAME'), el('div', { class: 'form' }, name),
|
|
446
446
|
wsList.length > 1 ? el('label', { class: 'ini-field' }, lbl('WORKSPACE'), el('select', { 'data-ini': 'nb-ws', onchange: (e) => { N.ws = e.target.value; } }, ...wsList.map((w) => el('option', { value: w.id, selected: w.id === N.ws }, w.name || w.id)))) : null,
|
|
447
447
|
checkbox(N.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'nb-build' }, (e) => { N.build = e.target.checked; render('[data-ini="nb-build"]'); }),
|
|
448
|
-
N.build ? el('div', { class: 'ini-field' }, lbl('JIRA
|
|
448
|
+
N.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, el('input', { type: 'text', value: N.jira, placeholder: 'Project keys, e.g. PP, PPM', 'aria-label': 'Jira project keys', 'data-ini': 'nb-jira', oninput: (e) => { N.jira = e.target.value; } })),
|
|
449
449
|
note('Epics, stories, tasks and bugs from this project show next to PMOS tasks. Sprints and releases are mirrored, read-only.'))
|
|
450
450
|
: note('Off: no Jira. Initiatives here use workflows without Build and Deliver, and the work is PMOS tasks. Good for a microsite run by an agency, an office move or hiring.'),
|
|
451
451
|
lbl('DEFAULT WORKFLOW FOR NEW INITIATIVES'), tplSeg(allowed, N.tpl, (k) => { N.tpl = k; render('[data-ini-tpl="' + k + '"]'); }, 'Default workflow'), tplCase(N.tpl),
|
|
@@ -690,6 +690,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
690
690
|
el('div', { class: 'calbody', 'data-ini-page': row.id },
|
|
691
691
|
lh('target', row.title || row.id, [row.id, bb.name, row.anchor || 'no anchor', row.owner || 'no owner', row.start || row.end ? day(row.start) + ' to ' + day(row.end) : 'no dates yet', row.horizon ? row.horizon[0].toUpperCase() + row.horizon.slice(1) : '', Math.round((Number(row.progress) || 0) * 100) + '% done'].filter(Boolean).join(' · ')),
|
|
692
692
|
el('div', { class: 'chips2', 'data-ini': 'ini-chips' }, statusChip(row.status), chip(icon('bolt', 'sm'), plural(ep, 'epic') + ', ' + plural(work.length - ep, 'item')), buildTag(bb),
|
|
693
|
+
bb.build ? pill({ 'data-ini': 'jira-epics', 'aria-haspopup': 'dialog', title: 'The Jira epics of this initiative', onclick: () => openEpics(bb, row) }, icon('jira', 'sm'), (row.epics || []).length ? 'Jira epics ' + row.epics.join(', ') : 'Link Jira epics') : null,
|
|
693
694
|
row.lane ? tag(row.lane) : null, el('span', { class: 'chips2', 'data-ini': 'warnings', 'aria-live': 'polite' }, ...warnings.map(warnChip))),
|
|
694
695
|
stepper, bb.self ? note(SELF_WHY) : null, strip,
|
|
695
696
|
segTabs(tabs, tab, (k) => go({ view: 'ini', board: bb.id, id: row.id, tab: k }), 'Initiative views', { 'data-ini': 'ini-tabs' }),
|
|
@@ -697,6 +698,34 @@ export function mountInitiatives(pane, opts) {
|
|
|
697
698
|
if (P.focusStep) { const f = stepper.querySelector('[data-ini-step="' + CSS.escape(P.focusStep) + '"]'); P.focusStep = null; if (f) f.focus(); }
|
|
698
699
|
mountTab(host, null, (INI_TAB_LIST.find((t) => t[0] === tab) || [])[1], ctxI, token);
|
|
699
700
|
}
|
|
701
|
+
/* The Jira epics of an initiative: the daily Jira check reads each one and the issues under it, read-only. */
|
|
702
|
+
function openEpics(b, row) {
|
|
703
|
+
const E = { text: (row.epics || []).join(', '), err: '', busy: false };
|
|
704
|
+
const host = el('div', { class: 'ini-form', 'data-ini': 'epics-form' });
|
|
705
|
+
const sh = openSheet({ title: 'Jira epics · ' + row.id, icon: 'bolt', body: host });
|
|
706
|
+
function render() {
|
|
707
|
+
const input = el('input', { type: 'text', value: E.text, 'aria-label': 'Jira epic keys', placeholder: 'Epic keys, e.g. IDPF-2685, LB-120', 'data-ini': 'epics-input', oninput: (e) => { E.text = e.target.value; } });
|
|
708
|
+
host.replaceChildren(
|
|
709
|
+
note('The Jira epics this initiative runs on. The daily Jira check reads each epic and the issues under it, read-only, and shows them as its Jira items.' + (jiraLabel(b) ? ' ' + b.name + ' mirrors Jira ' + jiraLabel(b) + '.' : '')),
|
|
710
|
+
row.epicsFrom === 'record' ? note('Now from the initiative record (jira: ' + row.epics.join(', ') + '). Saving here takes over from it.') : null,
|
|
711
|
+
el('div', { class: 'ini-field' }, lbl('JIRA EPICS'), el('div', { class: 'form' }, input)),
|
|
712
|
+
E.err ? errLine(E.err) : null,
|
|
713
|
+
el('div', { class: 'chips2' }, pbtn('primary', E.busy ? 'Saving' : 'Save epics', null, { 'data-ini': 'epics-save', disabled: E.busy, onclick: save }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
|
|
714
|
+
input.focus();
|
|
715
|
+
}
|
|
716
|
+
async function save() {
|
|
717
|
+
const keys = E.text.split(/[,\s]+/).filter(Boolean);
|
|
718
|
+
if (!keys.every((k) => /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(k))) { E.err = 'An epic key is a project key, a dash and a number, like IDPF-2685. Separate keys with a comma.'; render(); return; }
|
|
719
|
+
E.busy = true; E.err = ''; render();
|
|
720
|
+
const r = await apiFor(b.ws).post('/api/boards/ini-epics', { board: b.id, id: row.id, epics: keys });
|
|
721
|
+
E.busy = false;
|
|
722
|
+
if (!r || !r.ok) { E.err = 'The epics were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
|
|
723
|
+
sh.close();
|
|
724
|
+
toast(keys.length ? row.id + ' runs on Jira ' + keys.map((k) => k.toUpperCase()).join(', ') + '. The next Jira check reads them.' : row.id + ' has no Jira epic now.');
|
|
725
|
+
P.ini = null; dropItems(); draw();
|
|
726
|
+
}
|
|
727
|
+
render();
|
|
728
|
+
}
|
|
700
729
|
function pickTool(id) {
|
|
701
730
|
if (P.loc.tab !== 'overview' || P.loc.tool !== id) { go({ ...P.loc, tab: 'overview', tool: id }); return; }
|
|
702
731
|
const n = root.querySelector('[data-ini-tool-card="' + CSS.escape(id) + '"]');
|
|
@@ -165,7 +165,7 @@ export const COPY = Object.freeze({
|
|
|
165
165
|
outcome: 'What came out of it (optional)', notYet: 'Not yet:', laterToday: 'Later today', tomorrow: 'Tomorrow', nextWeek: 'Next week',
|
|
166
166
|
looksDone: 'Looks done?', confirm: 'Confirm done', flag: 'Flag', unflag: 'Unflag', notBefore: 'Not before', someday: 'Someday', bringBack: 'Bring back',
|
|
167
167
|
waitingField: 'Waiting on', waitingWho: 'Who?', stopWaiting: 'Stop waiting', flagged: 'Flagged',
|
|
168
|
-
notBeforeChip: (d) => 'Not before ' + d, advances: (t) => 'Advances team task ' + t, moved: (n) => `Moved ${n}×`,
|
|
168
|
+
notBeforeChip: (d) => 'Not before ' + d, advances: (t) => 'Advances team task ' + t, suggested: 'Suggested', agentSuggests: (t) => 'Agent: ' + t, moved: (n) => `Moved ${n}×`,
|
|
169
169
|
overdue: (n) => (n === 1 ? 'Overdue since yesterday' : `${n} days overdue`), dueToday: 'Due today', dueOn: (d) => 'Due ' + d,
|
|
170
170
|
waitingSince: (w, since, ago) => `Waiting for ${w}` + (since ? ` since ${since}` : '') + (ago ? ` (${ago})` : ''), repeats: (r) => 'Repeats ' + String(r).toLowerCase(),
|
|
171
171
|
/* Review (PRD R7) */
|
|
@@ -793,6 +793,16 @@ function dueChip(inst, item) {
|
|
|
793
793
|
/* The chips the Work page adds to a row: outside PMOS and the person, Not before, Flagged, how often it moved, Looks done, the team task it advances. */
|
|
794
794
|
function workChips(inst, item, o = {}) {
|
|
795
795
|
const today = inst.data.today; const c = [];
|
|
796
|
+
/* work the PM did not type in (work-shows-assigned): where it came from, with its link, and what the agent suggests */
|
|
797
|
+
if (item.origin === 'jira' || item.origin === 'wbs') {
|
|
798
|
+
const label = item.origin === 'jira' ? 'Jira ' + (item.key || '') : 'WBS' + (item.repo ? ' ' + item.repo : '') + (item.key ? ' ' + item.key : '');
|
|
799
|
+
const attrs = { class: 'tag', 'data-pm-origin': item.origin, title: item.status || '' };
|
|
800
|
+
c.push(item.url ? el('a', { ...attrs, href: item.url, target: '_blank', rel: 'noopener', onclick: (e) => e.stopPropagation() }, label.trim()) : el('span', attrs, label.trim()));
|
|
801
|
+
if (item.suggest) c.push(el('span', { class: 'chip2', 'data-pm-suggest': item.id }, c3ic('spark', 'sm'), COPY.agentSuggests(item.suggest)));
|
|
802
|
+
} else if (item.origin === 'brief') {
|
|
803
|
+
c.push(el('span', { class: 'tag', 'data-pm-origin': 'brief', title: item.why || '' }, c3ic('spark', 'sm'), COPY.suggested));
|
|
804
|
+
if (item.why) c.push(el('span', { class: 'chip2', 'data-pm-why': item.id }, item.why));
|
|
805
|
+
}
|
|
796
806
|
if (item.kind === 'offline') c.push(el('span', { class: 'tag wk-off', 'data-pm-offline-tag': item.id }, COPY.offline), el('span', { class: 'chip2' }, c3ic('user', 'sm'), item.person || 'someone'));
|
|
797
807
|
if (item.defer && item.defer > today) c.push(el('span', { class: 'tag wk-nb cal3-up', 'data-pm-notbefore': item.id }, COPY.notBeforeChip(dayLabel(item.defer))));
|
|
798
808
|
if (item.flagged && !o.noFlag) c.push(el('span', { class: 'tag cal3-up', 'data-pm-flagged': item.id }, COPY.flagged));
|
package/workspace/server.js
CHANGED
|
@@ -7028,7 +7028,9 @@ async function handleRequest(req, res) {
|
|
|
7028
7028
|
const owners = () => {
|
|
7029
7029
|
const own = new Map();
|
|
7030
7030
|
if (wsShape(root) !== 'pmos') return own;
|
|
7031
|
-
|
|
7031
|
+
// a record lives in any of the repository's initiative folders: planning/initiatives/ here, pmos/state/initiatives/ in a kit
|
|
7032
|
+
const dirs = initiativeDirs(root);
|
|
7033
|
+
const real = (id) => dirs.some((d) => fs.existsSync(path.join(d, id + '.md')));
|
|
7032
7034
|
for (const g of initiativeIndex(root).initiatives) {
|
|
7033
7035
|
if (!real(g.id)) continue;
|
|
7034
7036
|
for (const a of g.artifacts) { const rel = String(a.path).split(path.sep).join('/'); if (!a.shared && !own.has(rel)) own.set(rel, { id: g.id, title: g.title || g.id }); }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: jira-sync
|
|
3
3
|
label: "Jira status check"
|
|
4
|
-
description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, and save them in PMOS for the initiative's Jira tickets tab. It writes nothing to Jira."
|
|
4
|
+
description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, or of its Jira epics and the issues under them, and save them in PMOS for the initiative's Jira tickets tab. It writes nothing to Jira."
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
7
|
output:
|
|
@@ -15,15 +15,17 @@ output:
|
|
|
15
15
|
Rules that always hold:
|
|
16
16
|
1. READ-ONLY on Jira. Use only the Jira search tool. Never create, edit, transition, assign, comment on, link, close or delete an issue. Never write to Confluence or to any other source.
|
|
17
17
|
2. THE ONLY WRITE is `pmos jira sync`, and `pmos people record` once at the end (step 5). Never edit a repository file. Never run `pmos item`. Never write a ledger by hand. Never commit; the PM's Sync carries the files.
|
|
18
|
-
3. LIGHT ON CONTENT. Ask Jira only for these fields: summary, status, the sprint field, subtasks, the Flagged field, the story points field, the resolution date, and the display names of the assignee and the reporter. Never ask for or copy a description, a comment, an attachment, an email address, any other detail of a person, or a token.
|
|
18
|
+
3. LIGHT ON CONTENT. Ask Jira only for these fields: summary, status, the issue type, the parent, the sprint field, subtasks, the Flagged field, the story points field, the resolution date, and the display names of the assignee and the reporter. Never ask for or copy a description, a comment, an attachment, an email address, any other detail of a person, or a token.
|
|
19
19
|
4. TEMPORARY FILES OUTSIDE THE REPOSITORY. Save each search result in this session's scratch folder, never in the repository, and delete it after the sync.
|
|
20
20
|
5. NOTHING INVENTED. Give `pmos jira sync` exactly what Jira returned. If Jira cannot be reached, list the initiative under "Not reached" and write nothing for it.
|
|
21
21
|
|
|
22
22
|
Procedure:
|
|
23
|
-
1. If the task names one initiative ("This check: initiative …"), check that one only: run `pmos jira query <initiative>`. Otherwise run `pmos jira query`: each line is an initiative id, a tab and the Jira search (JQL) to run for it. If no initiative has a ticket in Jira, say so in one line and stop.
|
|
23
|
+
1. If the task names one initiative ("This check: initiative …"), check that one only: run `pmos jira query <initiative>`. Otherwise run `pmos jira query`: each line is an initiative id, a tab and the Jira search (JQL) to run for it. An initiative PMOS pushed no ticket for, but which is linked to Jira epics, gets a search for those epics and the issues under them. If no initiative has a ticket or an epic in Jira, say so in one line and stop.
|
|
24
24
|
2. For each initiative, run its search with the Jira search tool and the fields in rule 3. The sprint is usually a custom field named "Sprint" (often `customfield_10020`); ask for it by name. Story points are also a custom field, named "Story Points" or "Story point estimate" (often `customfield_10016`); ask for it by name. Fetch every page.
|
|
25
25
|
3. Save the result as JSON in the scratch folder, for example `<scratch>/jira-<initiative>.json`, as a list with one object per issue:
|
|
26
|
-
`{"key", "summary", "status", "category", "sprint", "subtasks", "points", "resolved", "flagged", "reason", "url"}`
|
|
26
|
+
`{"key", "summary", "status", "category", "type", "parent", "sprint", "subtasks", "points", "resolved", "flagged", "reason", "url"}`
|
|
27
|
+
- `type`: the issue type's name, for example `Epic`, `Story`, `Task` or `Bug`.
|
|
28
|
+
- `parent`: the parent issue's key (the epic of a story), or `null`.
|
|
27
29
|
- `category`: the status category, `new`, `indeterminate` or `done`.
|
|
28
30
|
- `sprint`: `{"id": "42", "name": "Sprint 42", "state": "active", "start": "2026-10-01", "end": "2026-10-14"}` for the active sprint, `"future"` for a planned one, or `null` when the issue is in the backlog. Copy `id`, `start`, `end` and `completed` (a closed sprint's completion date) from Jira's sprint field; leave out any that Jira does not give.
|
|
29
31
|
- `points`: the story points as a number, or `null` when none are set.
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"work-focus.test.js",
|
|
30
30
|
"work-evidence.test.js",
|
|
31
31
|
"brief-page.test.js",
|
|
32
|
+
"work-assigned.test.js",
|
|
32
33
|
"calendar-events.test.js",
|
|
33
34
|
"item-steps.test.js",
|
|
34
35
|
"status-facts-panel.test.js",
|
|
@@ -158,6 +159,7 @@
|
|
|
158
159
|
"work-focus.test.js": 1,
|
|
159
160
|
"work-evidence.test.js": 1,
|
|
160
161
|
"brief-page.test.js": 1,
|
|
162
|
+
"work-assigned.test.js": 1,
|
|
161
163
|
"calendar-events.test.js": 1,
|
|
162
164
|
"pm-brief-page-test.js": 30,
|
|
163
165
|
"item-steps.test.js": 1,
|