create-pmos 0.142.0 → 0.144.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/console-calendar.md +20 -2
- package/workspace/guide/initiative-boards.md +2 -2
- package/workspace/guide/jira.md +15 -1
- package/workspace/guide/notes.md +2 -2
- 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/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +35 -4
- 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/c3-notes.js +316 -0
- package/workspace/public/pm/calendar-views.js +15 -1
- package/workspace/public/pm/console3.css +40 -0
- package/workspace/public/pm/shell-recording.js +7 -3
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/test-lists/c3-notes-place.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.144.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:54:19Z"}
|
|
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:54:19Z"}
|
|
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:54:19Z"}
|
|
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']) {
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: the Calendar tab of the console page"
|
|
4
|
-
description: "The Calendar tab of the console page (/c3.html): the five zooms, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
|
|
5
|
-
timestamp: 2026-10-
|
|
4
|
+
description: "The Calendar tab of the console page (/c3.html): Calendar and Notes, the five zooms, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
|
|
5
|
+
timestamp: 2026-10-12
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: the Calendar tab of the console page
|
|
9
9
|
|
|
10
10
|
The Calendar tab of the console page (`/c3.html`) shows time. It shows what happens and what falls due, by date. At the top it shows a short deck of headline cards about your calendar, for example a conflict or a meeting with no time to prepare. The deck shows nothing when PMOS has no card about your calendar. The tab shows no task list and no strategy panel. Your due items live in Work. The strategy and progress panels live in Reports and Initiatives.
|
|
11
11
|
|
|
12
|
+
## Calendar and Notes
|
|
13
|
+
|
|
14
|
+
The switch at the start of the bar has two parts. **Calendar** shows the grid. **Notes** shows your notes.
|
|
15
|
+
|
|
16
|
+
In **Notes**, the list is at the left. The notes are in groups, one for each initiative. **No initiative** is the last group. The number shows how many notes the list shows. Type in **Search notes** to keep only the notes whose title, initiative or summary has the words. A row shows when the note was made and its length. A row also shows **recording** or **paused** while the note is held, **no meeting** when the note has no meeting, and a warning mark when a statement differs from a file.
|
|
17
|
+
|
|
18
|
+
Press a row to open the note at the right. The note has three stages. Press **Before**, **During** or **After** to change the stage.
|
|
19
|
+
|
|
20
|
+
- **Before** shows what the note kept about its meeting: the agenda, what the meeting is for, what to do to be ready, what to read first and the people. A tick shows an agenda item that was heard in the transcript. A note with no meeting says so, and has **Link to a meeting** and **Link to an initiative**.
|
|
21
|
+
- **During** shows the live notes while the note is recorded or paused. At other times it shows the transcript.
|
|
22
|
+
- **After** shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file and the highlights. The transcript is folded at the bottom. When the final notes are written, click a text to correct it. Leave the text to save it. Press `Esc` to cancel. **Make task** adds a next step to the note's initiative.
|
|
23
|
+
|
|
24
|
+
A note that is recorded now opens on **During**. Other notes open on **After**. **Show in the calendar** goes back to the grid, on the day of the meeting, with the meeting selected. The copy button puts the note on the clipboard as Markdown.
|
|
25
|
+
|
|
26
|
+
In **Notes**, the zoom, the arrows and the calendar keys do nothing.
|
|
27
|
+
|
|
12
28
|
## Zoom and move
|
|
13
29
|
|
|
14
30
|
Use **Day**, **Week**, **Month**, **Quarter** or **Year** to change the zoom. Use the arrows to go to the previous or the next period. Press **Today** to come back to today.
|
|
@@ -53,6 +69,8 @@ Press a meeting to show its context in the side column. Under the title, a row s
|
|
|
53
69
|
- For a past meeting, **What came out of it** lists the decisions and actions of its note. **What moved after it** lists the items that changed within 48 hours and refer to the meeting or its initiative.
|
|
54
70
|
- For a coming meeting, **Prepared** lists what is ready, and **To prepare** lists what is still open.
|
|
55
71
|
|
|
72
|
+
When the meeting has a note, the detail has **Open the note**. It opens the note in **Notes**.
|
|
73
|
+
|
|
56
74
|
When PMOS holds nothing for a meeting, the detail says so. It does not guess.
|
|
57
75
|
|
|
58
76
|
## Free time
|
|
@@ -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:
|
package/workspace/guide/notes.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: notes"
|
|
4
4
|
description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; final notes written after Stop from the whole recording, with speakers and a quote for each item, decisions with their why and who, next steps with an open question, a discussion paragraph, and Wispr Flow's notes merged in and fetched by hand when the fetch fails; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository for conflicts, capture highlights during the meeting, create tasks from next steps, correct and tag lines, see what changed in your notes, replay the meeting, see screenshots of the shared screen beside the transcript, link a note to its meeting and its initiative, and get a summary."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-12
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: notes
|
|
@@ -22,7 +22,7 @@ Starting a note, from the Record control or from **Take notes** on a calendar ev
|
|
|
22
22
|
|
|
23
23
|
### The Record control in the console
|
|
24
24
|
|
|
25
|
-
The console (the page at `/c3.html`) has the same Record control in its top bar, beside the Sessions button. It is always there, also when nothing is recording. **Record** (the microphone) starts an untitled note and listens at once. The arrow lists the meetings of today that have not started. Press one to start a note linked to that calendar event. While a meeting records, the red group shows its name and clock, **Highlight**, **Pause** and **Stop**. While it is paused, the group shows **Resume** and **Stop**. **Stop** asks first and offers **Pause instead**. A red dot shows on the Calendar tab while a meeting is held.
|
|
25
|
+
The console (the page at `/c3.html`) has the same Record control in its top bar, beside the Sessions button. It is always there, also when nothing is recording. **Record** (the microphone) starts an untitled note and listens at once. The arrow lists the meetings of today that have not started. Press one to start a note linked to that calendar event. While a meeting records, the red group shows its name and clock, **Highlight**, **Pause** and **Stop**. While it is paused, the group shows **Resume** and **Stop**. **Stop** asks first and offers **Pause instead**. A red dot shows on the Calendar tab while a meeting is held. Press the name in the red group to open the note on the console's Calendar tab, in **Notes**, on the **During** stage. See [the Calendar tab of the console page](console-calendar.md).
|
|
26
26
|
|
|
27
27
|
When Whisper or Qwen3-ASR transcribes the meeting, the red group also names the engine and the language, for example **Whisper · English**. Check it when lines come out in the wrong language: the language is set in the Notes settings.
|
|
28
28
|
|
|
@@ -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;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.144.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.144.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED