create-pmos 0.204.0 → 0.206.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 +2 -2
- 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/payload/state/log.md +1 -1
- package/workspace/c3-capture-test.js +206 -0
- package/workspace/c3-ib-more-test.js +7 -1
- package/workspace/extension/README.md +22 -7
- package/workspace/extension/capture.js +114 -0
- package/workspace/extension/manifest.json +49 -8
- package/workspace/extension/popup.css +28 -0
- package/workspace/extension/popup.html +15 -0
- package/workspace/extension/popup.js +195 -0
- package/workspace/guide/jira.md +1 -0
- package/workspace/guide/report-design.md +15 -1
- package/workspace/lib/engine/boards.js +45 -2
- package/workspace/lib/extension-cli.js +6 -4
- package/workspace/lib/jira-cli.js +26 -3
- package/workspace/lib/jira-status.js +136 -6
- package/workspace/lib/report-design.js +62 -4
- package/workspace/lib/routes-boards.js +7 -3
- package/workspace/lib/routes-report-design.js +33 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-boot.js +2 -1
- package/workspace/public/pm/c3-capture.css +23 -0
- package/workspace/public/pm/c3-capture.js +152 -0
- package/workspace/public/pm/c3-demo-boards.js +11 -1
- package/workspace/public/pm/c3-design-studio.js +17 -5
- package/workspace/public/pm/c3-ib-sprints.js +60 -13
- package/workspace/public/pm/c3-initiatives.css +5 -0
- package/workspace/public/pm/c3-shell.js +2 -0
- package/workspace/skills/jira-sync.skill +11 -5
- package/workspace/skills/report-design.skill +1 -0
- package/workspace/test-lists/report-design-studio-ui.json +3 -3
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/* popup.js — the PMOS button's popup (popup.html): "Add this page to a report design".
|
|
2
|
+
* PMOS draws the popup: the page is one frame, ${address}/c3.html?capture=1&pop=1&…, which lists the reports and has the Add button. The extension never posts to PMOS
|
|
3
|
+
* itself, because every PMOS write needs a press on a PMOS page (a nonce and an Origin check). So when the frame asks (postMessage { pmosCapture: 'request' }), this
|
|
4
|
+
* page takes the open tab and posts it back to the frame ({ pmosCapture: 'data', … }), and the frame uploads it with its own press. Both ways, the message must
|
|
5
|
+
* come from the other window and from the PMOS origin; the extension posts to the PMOS origin only.
|
|
6
|
+
* Google Slides and Google Docs → the PDF, fetched here with the user's own Google cookies (host permission for https://docs.google.com/*)
|
|
7
|
+
* any other page → pictures of the whole page, scrolled top to bottom: at most 8, JPEG, 650 ms apart (Chrome's capture rate limit), scroll put back after
|
|
8
|
+
* When nothing answers at the address, this page says so and offers Try again. Nothing else is fetched. */
|
|
9
|
+
(function () {
|
|
10
|
+
'use strict';
|
|
11
|
+
var A = window.PMOS_ADDRESS;
|
|
12
|
+
var C = window.PMOS_CAPTURE;
|
|
13
|
+
var CHECK_MS = 4000; // how long to wait for PMOS to answer
|
|
14
|
+
var READY_MS = 8000; // how long to wait for the frame to say it is drawn
|
|
15
|
+
var SETTLE_MS = 300; // after a scroll, before the picture
|
|
16
|
+
var msg = document.getElementById('msg');
|
|
17
|
+
var frame = null;
|
|
18
|
+
var frameOrigin = '';
|
|
19
|
+
var readyTimer = null;
|
|
20
|
+
var busy = false;
|
|
21
|
+
var current = null; // { tab, kind, address }
|
|
22
|
+
|
|
23
|
+
function readAddress(done) {
|
|
24
|
+
var fallback = A.DEFAULT_ADDRESS;
|
|
25
|
+
try {
|
|
26
|
+
var o = {}; o[A.KEY] = fallback;
|
|
27
|
+
chrome.storage.sync.get(o, function (v) { done(A.normalizeAddress(v && v[A.KEY]) || fallback); });
|
|
28
|
+
} catch (e) { done(fallback); }
|
|
29
|
+
}
|
|
30
|
+
function getTab(done) {
|
|
31
|
+
try { chrome.tabs.query({ active: true, currentWindow: true }, function (t) { done((t && t[0]) || null); }); } catch (e) { done(null); }
|
|
32
|
+
}
|
|
33
|
+
/** Resolves when something answers at the address (an opaque no-cors reply is enough), rejects when nothing listens. */
|
|
34
|
+
function answers(addr) {
|
|
35
|
+
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
36
|
+
var t = ctl ? setTimeout(function () { ctl.abort(); }, CHECK_MS) : null;
|
|
37
|
+
return fetch(addr + '/c3.html', { mode: 'no-cors', cache: 'no-store', signal: ctl ? ctl.signal : undefined })
|
|
38
|
+
.then(function (r) { if (t) clearTimeout(t); return r; }, function (e) { if (t) clearTimeout(t); throw e; });
|
|
39
|
+
}
|
|
40
|
+
var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
|
|
41
|
+
|
|
42
|
+
/* ── the popup's own states ── */
|
|
43
|
+
function head(host) {
|
|
44
|
+
var h = document.createElement('div'); h.className = 'head';
|
|
45
|
+
var b = document.createElement('b'); b.textContent = 'PMOS'; h.appendChild(b);
|
|
46
|
+
var s = document.createElement('span'); s.textContent = 'Add this page to a report design'; h.appendChild(s);
|
|
47
|
+
host.appendChild(h);
|
|
48
|
+
}
|
|
49
|
+
function show(text, withRetry, extra) {
|
|
50
|
+
if (frame) { frame.remove(); frame = null; }
|
|
51
|
+
clearTimeout(readyTimer);
|
|
52
|
+
msg.textContent = '';
|
|
53
|
+
msg.className = 'msg';
|
|
54
|
+
if (!msg.parentNode) document.body.appendChild(msg);
|
|
55
|
+
head(msg);
|
|
56
|
+
var p = document.createElement('p'); p.textContent = text; msg.appendChild(p);
|
|
57
|
+
if (extra) msg.appendChild(extra);
|
|
58
|
+
if (withRetry) {
|
|
59
|
+
var b = document.createElement('button'); b.type = 'button'; b.textContent = 'Try again'; b.setAttribute('data-act', 'retry');
|
|
60
|
+
b.addEventListener('click', function () { start(); });
|
|
61
|
+
msg.appendChild(b);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function showDown(addr) {
|
|
65
|
+
var a = document.createElement('a'); a.href = '#'; a.textContent = 'Set the address';
|
|
66
|
+
a.addEventListener('click', function (e) { e.preventDefault(); try { chrome.runtime.openOptionsPage(); } catch (x) { /* no options page */ } });
|
|
67
|
+
show('PMOS is not answering at ' + addr + '. Start PMOS, then try again.', true, a);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ── the frame ── */
|
|
71
|
+
function post(m) {
|
|
72
|
+
if (!frame || !frame.contentWindow || !C.validOutgoing(m)) return false;
|
|
73
|
+
frame.contentWindow.postMessage(m, frameOrigin);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
function showFrame(tab, kind, addr, screens) {
|
|
77
|
+
var q = new URLSearchParams({ capture: '1', pop: '1', pkind: kind, ptitle: C.sampleName(tab.title, tab.url), purl: tab.url || '' });
|
|
78
|
+
if (screens) q.set('pscreens', String(screens));
|
|
79
|
+
frame = document.createElement('iframe');
|
|
80
|
+
frame.title = 'PMOS: add this page to a report design';
|
|
81
|
+
frame.src = addr + '/c3.html?' + q.toString();
|
|
82
|
+
frameOrigin = new URL(addr).origin;
|
|
83
|
+
if (msg.parentNode) msg.remove();
|
|
84
|
+
document.body.appendChild(frame);
|
|
85
|
+
// an older PMOS answers but has no capture page: it never says ready
|
|
86
|
+
readyTimer = setTimeout(function () { showDown(addr); }, READY_MS);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ── what is taken ── */
|
|
90
|
+
var toBase64 = function (buf) {
|
|
91
|
+
var bytes = new Uint8Array(buf); var s = '';
|
|
92
|
+
for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
|
93
|
+
return btoa(s);
|
|
94
|
+
};
|
|
95
|
+
var run = function (tabId, func, args) {
|
|
96
|
+
return chrome.scripting.executeScript({ target: { tabId: tabId }, func: func, args: args || [] }).then(function (r) { return r && r[0] ? r[0].result : undefined; });
|
|
97
|
+
};
|
|
98
|
+
var measure = function () { var d = document.documentElement; var b = document.body; return { h: Math.max(d ? d.scrollHeight : 0, b ? b.scrollHeight : 0), vh: window.innerHeight, y: window.scrollY }; };
|
|
99
|
+
var scrollTo = function (y) { window.scrollTo({ top: y, left: 0, behavior: 'instant' }); return window.scrollY; };
|
|
100
|
+
|
|
101
|
+
function takePdf(tab, kind) {
|
|
102
|
+
var url = C.exportUrl(tab.url);
|
|
103
|
+
post({ pmosCapture: 'progress', text: 'Getting the PDF from Google', step: 1, of: 3 });
|
|
104
|
+
return fetch(url, { credentials: 'include', cache: 'no-store', redirect: 'follow' }).then(function (r) {
|
|
105
|
+
if (!r.ok) throw new Error('Google did not give the PDF (' + r.status + '). Open the file in Chrome while signed in to Google, then try again.');
|
|
106
|
+
return r.arrayBuffer();
|
|
107
|
+
}, function () { throw new Error('Chrome could not reach Google to get the PDF. Check the connection, then try again.'); }).then(function (buf) {
|
|
108
|
+
var bytes = new Uint8Array(buf);
|
|
109
|
+
if (!(bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46)) throw new Error('Google did not give a PDF. Sign in to Google in this Chrome, make sure you can open the file, then try again.');
|
|
110
|
+
if (bytes.length > C.MAX_BYTES) throw new Error('The PDF is over 20 MB. PMOS keeps a sample up to 20 MB. Add a smaller copy.');
|
|
111
|
+
post({ pmosCapture: 'progress', text: 'Reading the PDF', step: 2, of: 3 });
|
|
112
|
+
var pages = C.pdfPageCount(bytes);
|
|
113
|
+
return { pmosCapture: 'data', kind: 'pdf', name: C.sampleName(tab.title, tab.url), url: tab.url, pages: pages || undefined, data: toBase64(buf) };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function takePictures(tab) {
|
|
118
|
+
var tabId = tab.id; var windowId = tab.windowId;
|
|
119
|
+
var start = null; var pics = []; var bytes = 0; var last = 0;
|
|
120
|
+
return run(tabId, measure).then(function (m) {
|
|
121
|
+
if (!m || !m.vh) throw new Error('Chrome does not let PMOS read this page.');
|
|
122
|
+
start = m.y || 0;
|
|
123
|
+
var plan = C.scrollPlan(m.h, m.vh, C.MAX_PICTURES);
|
|
124
|
+
var n = plan.offsets.length;
|
|
125
|
+
var chain = Promise.resolve();
|
|
126
|
+
plan.offsets.forEach(function (y, i) {
|
|
127
|
+
chain = chain.then(function () {
|
|
128
|
+
post({ pmosCapture: 'progress', text: 'Picture ' + (i + 1) + ' of ' + n + (i ? ', scrolling down' : ''), step: i + 1, of: n + 1 });
|
|
129
|
+
return run(tabId, scrollTo, [y]);
|
|
130
|
+
}).then(function () {
|
|
131
|
+
var wait = Math.max(SETTLE_MS, C.MIN_GAP_MS - (Date.now() - last));
|
|
132
|
+
return sleep(wait);
|
|
133
|
+
}).then(function () {
|
|
134
|
+
last = Date.now();
|
|
135
|
+
return chrome.tabs.captureVisibleTab(windowId, { format: 'jpeg', quality: 80 });
|
|
136
|
+
}).then(function (dataUrl) {
|
|
137
|
+
var b64 = String(dataUrl || '').replace(/^data:[^,]*,/, '');
|
|
138
|
+
if (!b64) throw new Error('Chrome did not give a picture of the page.');
|
|
139
|
+
bytes += C.base64Bytes(b64);
|
|
140
|
+
if (bytes > C.MAX_BYTES) throw new Error('The pictures are over 20 MB. PMOS keeps a sample up to 20 MB. Open a shorter page, or add it as a PDF.');
|
|
141
|
+
pics.push(b64);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return chain;
|
|
145
|
+
}).then(function () {
|
|
146
|
+
return { pmosCapture: 'data', kind: 'pictures', name: C.sampleName(tab.title, tab.url), url: tab.url, pictures: pics };
|
|
147
|
+
}).then(function (out) { return restore(tabId, start).then(function () { return out; }); },
|
|
148
|
+
function (e) { return restore(tabId, start).then(function () { throw e; }); });
|
|
149
|
+
}
|
|
150
|
+
function restore(tabId, y) { return y == null ? Promise.resolve() : run(tabId, scrollTo, [y]).catch(function () { /* the page went away */ }); }
|
|
151
|
+
|
|
152
|
+
function capture() {
|
|
153
|
+
if (busy || !current) return;
|
|
154
|
+
busy = true;
|
|
155
|
+
var tab = current.tab;
|
|
156
|
+
var job = C.exportUrl(tab.url) ? takePdf(tab, current.kind) : takePictures(tab);
|
|
157
|
+
job.then(function (out) {
|
|
158
|
+
post({ pmosCapture: 'progress', text: 'Sending it to PMOS', step: out.kind === 'pdf' ? 3 : (out.pictures.length + 1), of: out.kind === 'pdf' ? 3 : (out.pictures.length + 1) });
|
|
159
|
+
if (!post(out)) throw new Error('The page could not be sent to PMOS.');
|
|
160
|
+
}).catch(function (e) {
|
|
161
|
+
post({ pmosCapture: 'error', error: String((e && e.message) || 'The page could not be taken.') });
|
|
162
|
+
}).then(function () { busy = false; });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
window.addEventListener('message', function (e) {
|
|
166
|
+
var m = C.fromFrame(e, frame && frame.contentWindow, frameOrigin);
|
|
167
|
+
if (!m) return;
|
|
168
|
+
if (m.pmosCapture === 'ready') { clearTimeout(readyTimer); return; }
|
|
169
|
+
if (m.pmosCapture === 'resize') { if (frame) frame.style.height = m.height + 'px'; return; }
|
|
170
|
+
if (m.pmosCapture === 'close') { window.close(); return; }
|
|
171
|
+
if (m.pmosCapture === 'open') {
|
|
172
|
+
try { chrome.tabs.create({ url: frameOrigin + '/c3.html?studio=' + encodeURIComponent(m.report) }); } catch (x) { /* no tabs */ }
|
|
173
|
+
window.close();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (m.pmosCapture === 'request') capture();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
function start() {
|
|
180
|
+
busy = false;
|
|
181
|
+
readAddress(function (addr) {
|
|
182
|
+
getTab(function (tab) {
|
|
183
|
+
var kind = tab ? C.classifyUrl(tab.url) : 'blocked';
|
|
184
|
+
if (!tab || kind === 'blocked') { show('Chrome does not let PMOS read this page. Open a web page, such as a Google Slides deck or any site, then press the PMOS button.', false); return; }
|
|
185
|
+
current = { tab: tab, kind: kind, address: addr };
|
|
186
|
+
var go = function (screens) {
|
|
187
|
+
answers(addr).then(function () { showFrame(tab, C.pageKind(tab.url), addr, screens); }, function () { showDown(addr); });
|
|
188
|
+
};
|
|
189
|
+
if (kind !== 'other') { go(0); return; }
|
|
190
|
+
run(tab.id, measure).then(function (m) { go(m && m.vh ? C.scrollPlan(m.h, m.vh, 1000).total : 0); }, function () { go(0); });
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
start();
|
|
195
|
+
})();
|
package/workspace/guide/jira.md
CHANGED
|
@@ -163,6 +163,7 @@ The morning brief, the day check and requirement gathering use it to tie a Jira
|
|
|
163
163
|
- `pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]`
|
|
164
164
|
- `pmos jira query --sprints`
|
|
165
165
|
- `pmos jira sync --sprints --from <file|-> [--query "<jql>"] [--dry-run]`
|
|
166
|
+
- `pmos jira summary [--from <file|->] [--dry-run]`
|
|
166
167
|
- `pmos jira which <KEY>`
|
|
167
168
|
|
|
168
169
|
Every command takes `--json` and `--root <dir>`.
|
|
@@ -22,7 +22,7 @@ Use one of these two buttons:
|
|
|
22
22
|
|
|
23
23
|
1. Select **Deck** or **Document** at the top of the studio.
|
|
24
24
|
2. Write what the report is for and who reads it. For example: "This goes to the progress session every other Thursday. The decisions must stand out."
|
|
25
|
-
3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS reads a link from Google Slides, Docs or Sheets, or from Confluence, through your own connectors. It saves the words and the structure (slide titles, order, sections and tables), and it changes nothing in the file. It does not get the look. To match the look, also add the file as a PDF or a screenshot. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. If your connectors cannot read the link, the sample says so. Any other link is kept by its address, and PMOS cannot open it. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
|
|
25
|
+
3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, add a page from Chrome (see below), or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS reads a link from Google Slides, Docs or Sheets, or from Confluence, through your own connectors. It saves the words and the structure (slide titles, order, sections and tables), and it changes nothing in the file. It does not get the look. To match the look, also add the file as a PDF or a screenshot. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. If your connectors cannot read the link, the sample says so. Any other link is kept by its address, and PMOS cannot open it. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
|
|
26
26
|
4. Press **Make the next version**. PMOS makes a version from your words and this cycle's data. This takes a short time. You can leave the studio. The studio shows when the version is ready.
|
|
27
27
|
5. Read the version in the preview. Write what to change and press **Make the next version** again. Each turn makes a new version: D1, D2, D3 and so on.
|
|
28
28
|
|
|
@@ -30,6 +30,20 @@ To set the width of the studio, use **Half**, **3/4** or **Whole** at the top ri
|
|
|
30
30
|
|
|
31
31
|
If a turn fails, the studio shows why. Nothing changes. Try the turn again.
|
|
32
32
|
|
|
33
|
+
|
|
34
|
+
### Add a page from Chrome
|
|
35
|
+
|
|
36
|
+
You can add the page you are looking at in Chrome, on any website, as a sample. This needs the PMOS extension in Chrome. Run `pmos extension` in a terminal for the steps to load it.
|
|
37
|
+
|
|
38
|
+
1. In Chrome, open the page, for example a Google Slides deck, a Google Doc or a Confluence page.
|
|
39
|
+
2. Press the **PMOS** button in the Chrome toolbar. The popup says "Add this page to a report design".
|
|
40
|
+
3. Choose the report in the **Report** list. It starts on the report whose design studio you opened last.
|
|
41
|
+
4. Press **Add as a sample**. Keep the popup open while it saves. When it says "Added to" and the name of the report, press **Open the studio**.
|
|
42
|
+
|
|
43
|
+
A Google Slides deck or a Google Doc comes to PMOS as a PDF, so PMOS can follow its look. The sample reads "From Chrome, PDF, 12 slides", or "pages" for a document. Chrome asks Google for the PDF with your own Google sign-in. Any other page comes as pictures of the whole page, scrolled top to bottom, at most 8 pictures. The sample reads "From Chrome, 3 page pictures". PMOS reads every picture, and follows its look as it does for a PDF. A sample is at most 20 MB. If the page is bigger, the popup says so: open a shorter page, or add a smaller copy.
|
|
44
|
+
|
|
45
|
+
The sample shows in a design studio that is already open, in a few seconds. If the popup says "PMOS is not answering", start PMOS and press **Try again**. Nothing leaves your computer: the popup sends the page only to your own PMOS.
|
|
46
|
+
|
|
33
47
|
### Point at a part
|
|
34
48
|
|
|
35
49
|
Keep **Point and say** on. Click a part of the preview and write what to change there. A numbered marker shows the spot. Your next turn sends the markers with your words.
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* filterRows(rows, query) → { total, offset, limit, rows, facets }
|
|
16
16
|
* health(rows) → 'ok' | 'warn' | 'bad'
|
|
17
17
|
* counts(rows, workflow) → { total, open, byCat, byStage, byStatus, risk, late }
|
|
18
|
-
* sprintsOf(snapshots, onBoard, whole?)
|
|
18
|
+
* sprintsOf(snapshots, onBoard, whole?, notes?) → { source, readAt, sprints } (notes: each sprint's goal and summary, sprint-goal-summary)
|
|
19
19
|
* INI_TEMPLATES · isBuildStage · templateBuilds · boardSettings(board) the per-initiative workflows (console3-initiatives-parity)
|
|
20
20
|
* workflowOf(board, id, folded) → the stages an initiative runs: its own (ini-workflow), else the board's
|
|
21
21
|
* applyIniWorkflow(board, wfNow, stage, template, stages, moveTo) → { ok, template, workflow, moved } | { ok:false, status, error }
|
|
@@ -582,7 +582,7 @@ function counts(rows, workflow) {
|
|
|
582
582
|
* lists EVERY issue Jira holds in it, whoever it is assigned to; an issue no initiative of the board holds has initiative null
|
|
583
583
|
* (jira-sprint-scope). Without it, a sprint lists only the board's own tickets.
|
|
584
584
|
*/
|
|
585
|
-
function sprintsOf(snapshots, onBoard = () => true, whole = null) {
|
|
585
|
+
function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null) {
|
|
586
586
|
const byName = new Map();
|
|
587
587
|
let readAt = null;
|
|
588
588
|
let any = false;
|
|
@@ -625,6 +625,49 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null) {
|
|
|
625
625
|
for (const i of sp.issues) sp.counts[i.category]++;
|
|
626
626
|
}
|
|
627
627
|
if (widened && whole.fetchedAt && (!readAt || whole.fetchedAt < readAt)) readAt = whole.fetchedAt; // the oldest read is the honest age
|
|
628
|
+
// The goal and summary the Jira status check wrote (sprint-goal-summary). A closed sprint is summarised once and not read again,
|
|
629
|
+
// so one the board's initiatives worked in is listed from its note even when none of the board's tickets is in it now.
|
|
630
|
+
const nl = notes && Array.isArray(notes.notes) ? notes.notes : [];
|
|
631
|
+
const same = (a, b) => ((a.id && b.id) ? String(a.id) === String(b.id) : String(a.name) === String(b.name));
|
|
632
|
+
if (any) {
|
|
633
|
+
const iniOf = new Map();
|
|
634
|
+
for (const s of snapshots || []) {
|
|
635
|
+
if (!s || !onBoard(s.initiative)) continue;
|
|
636
|
+
for (const t of s.tickets instanceof Map ? s.tickets.values() : Array.isArray(s.tickets) ? s.tickets : []) if (t && t.key) iniOf.set(t.key, s.initiative);
|
|
637
|
+
}
|
|
638
|
+
for (const n of nl) {
|
|
639
|
+
if (n.state !== 'closed' || byName.has(n.name)) continue;
|
|
640
|
+
if (!(n.themes || []).some((t) => t && t.initiative && onBoard(t.initiative))) continue;
|
|
641
|
+
const issues = wt.filter((t) => t && t.key && t.sprint && same(t.sprint, n)).map((t) => {
|
|
642
|
+
const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
|
|
643
|
+
return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: iniOf.get(t.key) || null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null };
|
|
644
|
+
});
|
|
645
|
+
const counts = { todo: 0, doing: 0, done: 0 };
|
|
646
|
+
for (const i of issues) counts[i.category]++;
|
|
647
|
+
byName.set(n.name, { name: n.name, state: 'closed', start: n.start || null, end: n.end || null, issues, counts });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const jiraGoal = (sp) => {
|
|
651
|
+
for (const t of wt) if (t && t.sprint && String(t.sprint.name) === sp.name && t.sprint.goal) return String(t.sprint.goal);
|
|
652
|
+
for (const s of snapshots || []) for (const t of s && s.tickets instanceof Map ? s.tickets.values() : s && Array.isArray(s.tickets) ? s.tickets : []) if (t && t.sprint && String(t.sprint.name) === sp.name && t.sprint.goal) return String(t.sprint.goal);
|
|
653
|
+
return null;
|
|
654
|
+
};
|
|
655
|
+
for (const sp of byName.values()) {
|
|
656
|
+
const n = nl.find((x) => x.name === sp.name);
|
|
657
|
+
const jg = jiraGoal(sp);
|
|
658
|
+
if (n) {
|
|
659
|
+
sp.goal = { from: n.goal.from, text: n.goal.text, jira: n.goal.from === 'jira' ? n.goal.text : n.goal.jira || null };
|
|
660
|
+
sp.summary = n.summary;
|
|
661
|
+
sp.themes = (n.themes || []).map((t) => ({ title: t.title, line: t.line || '', tickets: t.tickets || 0, done: t.done || 0, blocked: t.blocked || 0, people: t.people || '', initiative: t.initiative || null }));
|
|
662
|
+
sp.writtenAt = n.writtenAt || null;
|
|
663
|
+
sp.summarisedTickets = Number.isInteger(n.tickets) ? n.tickets : null;
|
|
664
|
+
sp.closed = sp.state === 'closed' && n.closed ? { ...n.closed } : null;
|
|
665
|
+
sp.frozen = sp.state === 'closed' && n.state === 'closed';
|
|
666
|
+
} else {
|
|
667
|
+
sp.goal = jg ? { from: 'jira', text: jg, jira: jg, unchecked: true } : null;
|
|
668
|
+
sp.summary = null; sp.themes = []; sp.writtenAt = null; sp.summarisedTickets = null; sp.closed = null; sp.frozen = false;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
628
671
|
const order = { active: 0, future: 1, closed: 2 };
|
|
629
672
|
const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
|
|
630
673
|
for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* extension-cli.js — `pmos extension`: where the Chrome extension
|
|
2
|
+
/* extension-cli.js — `pmos extension`: where the PMOS Chrome extension is (the Meet panel and the button that adds a page to a report design), and how to load it (initiatives c3-meet-extension, report-design-studio).
|
|
3
3
|
*
|
|
4
4
|
* pmos extension print the absolute path of workspace/extension and the steps to load it in Chrome
|
|
5
5
|
*
|
|
@@ -10,20 +10,22 @@ const path = require('path');
|
|
|
10
10
|
|
|
11
11
|
const COMMANDS = { extension: [] };
|
|
12
12
|
const FOLDER = path.resolve(__dirname, '..', 'extension');
|
|
13
|
-
const USAGE = ['usage: pmos extension — the Chrome extension
|
|
13
|
+
const USAGE = ['usage: pmos extension — the Chrome extension: the meeting companion over a Google Meet call, and a button that adds a page to a report design'];
|
|
14
14
|
|
|
15
15
|
/** The lines `pmos extension` prints for a folder. Pure. */
|
|
16
16
|
function lines(folder, exists = true) {
|
|
17
17
|
const out = [];
|
|
18
18
|
if (!exists) { out.push(`✗ the extension folder is not here: ${folder}`, ' Reinstall the workspace with `pmos upgrade`.'); return out; }
|
|
19
|
-
out.push('PMOS for
|
|
19
|
+
out.push('PMOS for Chrome', '', ` folder ${folder}`, '',
|
|
20
20
|
'Load it in three steps:',
|
|
21
21
|
' 1. In Chrome, open chrome://extensions',
|
|
22
22
|
' 2. Turn on Developer mode (top right)',
|
|
23
23
|
' 3. Press Load unpacked, then pick the folder above',
|
|
24
24
|
'',
|
|
25
25
|
'PMOS is looked for at http://localhost:7433. If pmos printed another port, open the extension\'s Options and set that address.',
|
|
26
|
-
'Then open a Google Meet call: the panel shows at the bottom right.'
|
|
26
|
+
'Then open a Google Meet call: the panel shows at the bottom right.',
|
|
27
|
+
'To add a page to a report design, open any page and press the PMOS button in the toolbar. Choose the report, then Add as a sample.',
|
|
28
|
+
'After an update, press the reload arrow on the extension\'s card in chrome://extensions.');
|
|
27
29
|
return out;
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] a saved search → the status snapshot
|
|
13
13
|
* pmos jira query --sprints the Jira search for every issue in the sprints the tickets are in
|
|
14
14
|
* pmos jira sync --sprints --from <file|-> [--query "<jql>"] a saved whole-sprint search → the Sprints tab (jira-sprint-scope)
|
|
15
|
+
* pmos jira summary [--from <file|->] [--dry-run] the sprints to summarise, or save their goal and summary (sprint-goal-summary)
|
|
15
16
|
* pmos jira which <KEY> the initiative a Jira key belongs to
|
|
16
17
|
* every command: [--json] [--root <dir>]
|
|
17
18
|
*
|
|
@@ -27,9 +28,9 @@ const jiraTickets = require('./jira-tickets.js');
|
|
|
27
28
|
const planItems = require('./plan-items.js');
|
|
28
29
|
const guard = require('./pm-guard.js');
|
|
29
30
|
|
|
30
|
-
const COMMANDS = { jira: ['draft', 'show', 'epic', 'approve', 'pending', 'record', 'query', 'sync', 'which'] };
|
|
31
|
+
const COMMANDS = { jira: ['draft', 'show', 'epic', 'approve', 'pending', 'record', 'query', 'sync', 'summary', 'which'] };
|
|
31
32
|
const USAGE = [
|
|
32
|
-
'usage: pmos jira draft|show|epic|approve|pending|record|query|sync|which — Jira tickets from the PRD, and their status',
|
|
33
|
+
'usage: pmos jira draft|show|epic|approve|pending|record|query|sync|summary|which — Jira tickets from the PRD, and their status',
|
|
33
34
|
' pmos jira draft <initiative> [--dry-run] draft one ticket per PRD item, in the ticket template',
|
|
34
35
|
' pmos jira show <initiative> every ticket, what needs you, and its status in Jira',
|
|
35
36
|
' pmos jira epic <initiative> new|<KEY or link> create a new epic at the first push, or use an existing one',
|
|
@@ -40,6 +41,8 @@ const USAGE = [
|
|
|
40
41
|
' pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] save the status read from Jira',
|
|
41
42
|
' pmos jira query --sprints the Jira search for every issue in those tickets\' sprints',
|
|
42
43
|
' pmos jira sync --sprints --from <file|-> [--query "<jql>"] save every issue of the sprints, for the Sprints tab',
|
|
44
|
+
' pmos jira summary the sprints that need a goal and summary written',
|
|
45
|
+
' pmos jira summary --from <file|-> [--dry-run] save each sprint\'s goal, summary and themes (a closed sprint is kept once written)',
|
|
43
46
|
' pmos jira which <KEY> the initiative a Jira key belongs to',
|
|
44
47
|
' every command: [--json] [--root <dir>]',
|
|
45
48
|
'This command never calls Jira. Your agent reads and writes Jira with your own connection.',
|
|
@@ -99,7 +102,7 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
99
102
|
if (flags.sprints && verb !== 'query' && verb !== 'sync') return fail('--sprints goes with query or sync', 2);
|
|
100
103
|
if (verb === 'query' && flags.sprints) {
|
|
101
104
|
const q = jiraStatus.sprintQuery(root);
|
|
102
|
-
return out(q, [q.linked ? q.query : 'no Jira reading has a sprint yet — run the initiatives\' searches first']);
|
|
105
|
+
return out(q, [q.linked ? q.query : q.kept.length ? 'every sprint is closed and summarised once — nothing to read' : 'no Jira reading has a sprint yet — run the initiatives\' searches first']);
|
|
103
106
|
}
|
|
104
107
|
if (verb === 'sync' && flags.sprints) {
|
|
105
108
|
if (!flags.from) return need('--sprints --from <file|->');
|
|
@@ -113,6 +116,26 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
113
116
|
if (!r.dryRun) lines.push(`snapshot ${r.snapshot}`);
|
|
114
117
|
return out(r, lines);
|
|
115
118
|
}
|
|
119
|
+
if (verb === 'summary') {
|
|
120
|
+
if (!flags.from) {
|
|
121
|
+
const r = jiraStatus.sprintsToSummarise(root);
|
|
122
|
+
if (!r.read) return out(r, ['no whole-sprint read yet — run pmos jira query --sprints and sync --sprints first']);
|
|
123
|
+
const lines = r.sprints.map((s) => `${s.name}\t${s.state}\t${s.tickets} ${s.tickets === 1 ? 'issue' : 'issues'}\t${s.jiraGoal ? `goal in Jira: ${s.jiraGoal}` : 'no goal in Jira'}`);
|
|
124
|
+
if (!lines.length) lines.push('no sprint needs a summary');
|
|
125
|
+
for (const k of r.kept) lines.push(`${k}\tclosed\tkept, summarised once`);
|
|
126
|
+
return out(r, lines);
|
|
127
|
+
}
|
|
128
|
+
let raw;
|
|
129
|
+
try { raw = JSON.parse(readInput(flags.from, cwd, stdin)); }
|
|
130
|
+
catch (e) { return fail(`could not read ${flags.from} as JSON — ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
131
|
+
const r = jiraStatus.saveSprintNotes(root, raw, { dryRun: !!flags['dry-run'], now });
|
|
132
|
+
if (!r.ok) return fail(r.error);
|
|
133
|
+
const lines = [`sprint summaries · saved ${r.saved.length}${r.kept.length ? ` · kept ${r.kept.length} closed, summarised once` : ''}${r.dryRun ? ' · dry run, nothing written' : ''}`];
|
|
134
|
+
for (const n of r.saved) lines.push(` saved ${n}`);
|
|
135
|
+
for (const n of r.kept) lines.push(` kept ${n} (closed; its first summary stays)`);
|
|
136
|
+
if (!r.dryRun && r.saved.length) lines.push(`file ${r.file}`);
|
|
137
|
+
return out(r, lines);
|
|
138
|
+
}
|
|
116
139
|
if (verb === 'query') {
|
|
117
140
|
if (initiative) {
|
|
118
141
|
const q = jiraStatus.queryFor(root, initiative);
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* are in, whoever holds them (jira-sprint-scope); linked false when no reading has a sprint
|
|
18
18
|
* syncSprints(root, raw, opts) → the whole-sprint snapshot, written to <state>/tracker/jira/_sprints.md
|
|
19
19
|
* readSprints(root) → { fetchedAt, query, tickets: Map<key, reading> } | null
|
|
20
|
+
* sprintsToSummarise(root) → { ok, sprints, kept } the sprints of the whole-sprint read that need a goal and summary
|
|
21
|
+
* saveSprintNotes(root, raw, opts) → each sprint's goal, summary and themes, written to <state>/tracker/jira/_sprint-notes.md
|
|
22
|
+
* readSprintNotes(root) → { writtenAt, notes: [note] } | null (sprint-goal-summary)
|
|
20
23
|
*
|
|
21
24
|
* A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed, id?, start?, end?,
|
|
22
|
-
* completed? } | null, subtasks: { done, total }, fixVersion: [names], points: number | null, resolved: YYYY-MM-DD | null,
|
|
25
|
+
* completed?, goal? } | null, subtasks: { done, total }, fixVersion: [names], points: number | null, resolved: YYYY-MM-DD | null,
|
|
23
26
|
* flagged, reason, url }. The sprint's dates, `points` and `resolved` feed the sprint burndown (lib/stage-tools.js); a
|
|
24
27
|
* reading saved before they were carried has none of them, and the burndown says it cannot be drawn. A ticket the search did not return keeps its last reading, marked
|
|
25
28
|
* stale. Titles and statuses only: never a description, a comment or a person.
|
|
@@ -100,6 +103,8 @@ function sprintOne(v) {
|
|
|
100
103
|
const d = dayOf(from.map((f) => v[f]).find((x) => x != null));
|
|
101
104
|
if (d) out[k] = d;
|
|
102
105
|
}
|
|
106
|
+
const goal = clip(oneLine(v.goal), 300); // Jira's sprint goal, often empty or broad (sprint-goal-summary)
|
|
107
|
+
if (goal) out.goal = goal;
|
|
103
108
|
return out;
|
|
104
109
|
}
|
|
105
110
|
/** An ISO date or date-time → YYYY-MM-DD (the date as written, never shifted by a zone), else null. */
|
|
@@ -286,7 +291,8 @@ function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
|
|
|
286
291
|
const SPRINTS_FILE = '_sprints';
|
|
287
292
|
const jqlName = (n) => `"${String(n).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
288
293
|
function sprintQuery(root) {
|
|
289
|
-
const
|
|
294
|
+
const frozen = frozenNotes(root);
|
|
295
|
+
const seen = new Map(); const kept = new Set();
|
|
290
296
|
for (const q of linkedInitiatives(root).filter((x) => x.linked)) {
|
|
291
297
|
const snap = readSnapshot(root, q.initiative);
|
|
292
298
|
if (!snap) continue;
|
|
@@ -294,20 +300,27 @@ function sprintQuery(root) {
|
|
|
294
300
|
const sp = t && t.sprint;
|
|
295
301
|
if (!sp || !sp.name) continue;
|
|
296
302
|
const k = sp.id || sp.name;
|
|
303
|
+
if (frozen.some((f) => sameSprint(f, sp))) { kept.add(sp.name); continue; } // a closed sprint is read once (sprint-goal-summary)
|
|
297
304
|
if (!seen.has(k)) seen.set(k, { name: sp.name, state: sp.state || null, ...(sp.id ? { id: sp.id } : {}) });
|
|
298
305
|
}
|
|
299
306
|
}
|
|
300
307
|
const sprints = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
301
|
-
if (!sprints.length) return { ok: true, linked: false, sprints, query: null };
|
|
302
|
-
return { ok: true, linked: true, sprints, query: `sprint in (${sprints.map((s) => s.id || jqlName(s.name)).join(', ')}) ORDER BY key` };
|
|
308
|
+
if (!sprints.length) return { ok: true, linked: false, sprints, kept: [...kept], query: null };
|
|
309
|
+
return { ok: true, linked: true, sprints, kept: [...kept], query: `sprint in (${sprints.map((s) => s.id || jqlName(s.name)).join(', ')}) ORDER BY key` };
|
|
303
310
|
}
|
|
304
|
-
/** Save a whole-sprint read. The read replaces the last one whole: an issue moved out of a sprint is gone from it.
|
|
311
|
+
/** Save a whole-sprint read. The read replaces the last one whole: an issue moved out of a sprint is gone from it. A closed
|
|
312
|
+
* sprint that has its summary is not searched again, so its issues are kept from the last read. */
|
|
305
313
|
function syncSprints(root, raw, { query, dryRun = false, now } = {}) {
|
|
306
314
|
const n = normalize(raw);
|
|
307
315
|
if (n.error) return { ok: false, error: n.error };
|
|
308
316
|
const kept = n.issues.filter((i) => i.sprint && i.sprint.name);
|
|
309
317
|
const fetchedAt = nowIso(now);
|
|
310
318
|
const prev = readSprints(root);
|
|
319
|
+
const frozen = frozenNotes(root);
|
|
320
|
+
if (prev && frozen.length) {
|
|
321
|
+
const got = new Set(kept.map((i) => i.key));
|
|
322
|
+
for (const t of prev.tickets.values()) if (!got.has(t.key) && t.sprint && frozen.some((f) => sameSprint(f, t.sprint))) kept.push(t);
|
|
323
|
+
}
|
|
311
324
|
const bySprint = {};
|
|
312
325
|
for (const i of kept) bySprint[i.sprint.name] = (bySprint[i.sprint.name] || 0) + 1;
|
|
313
326
|
const result = { ok: true, read: kept.length, ignored: n.issues.length - kept.length, skipped: n.skipped, sprints: bySprint,
|
|
@@ -322,6 +335,122 @@ function syncSprints(root, raw, { query, dryRun = false, now } = {}) {
|
|
|
322
335
|
}
|
|
323
336
|
function readSprints(root) { return readSnapshot(root, SPRINTS_FILE); }
|
|
324
337
|
|
|
338
|
+
// ── the goal and summary of each sprint (sprint-goal-summary) ─────────────────────────────────────────
|
|
339
|
+
// The Jira status check writes them from the tickets it read: Jira's goal when it says what the sprint delivers, else a goal
|
|
340
|
+
// written from the tickets, with why (none: Jira has none; broad: too broad; partial: leaves out part of the sprint). A closed
|
|
341
|
+
// sprint's note is written once and kept; its sprint is not searched again.
|
|
342
|
+
const NOTES_FILE = '_sprint-notes';
|
|
343
|
+
const GOAL_FROM = ['jira', 'none', 'broad', 'partial'];
|
|
344
|
+
const INI_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
345
|
+
const sameSprint = (a, b) => !!a && !!b && ((a.id && b.id) ? String(a.id) === String(b.id) : String(a.name) === String(b.name));
|
|
346
|
+
function readSprintNotes(root) {
|
|
347
|
+
let text;
|
|
348
|
+
try { text = fs.readFileSync(snapshotPath(root, NOTES_FILE), 'utf8'); } catch { return null; }
|
|
349
|
+
const m = /```json\n([\s\S]*?)\n```/.exec(text);
|
|
350
|
+
if (!m) return null;
|
|
351
|
+
let data;
|
|
352
|
+
try { data = JSON.parse(m[1]); } catch { return null; }
|
|
353
|
+
const notes = (Array.isArray(data.sprints) ? data.sprints : []).filter((x) => x && isStr(x.name) && x.goal && isStr(x.summary));
|
|
354
|
+
return { writtenAt: isStr(data.written_at) ? data.written_at : null, notes };
|
|
355
|
+
}
|
|
356
|
+
function frozenNotes(root) { const r = readSprintNotes(root); return r ? r.notes.filter((x) => x.state === 'closed') : []; }
|
|
357
|
+
const count = (v, max) => (Number.isInteger(v) && v >= 0 && v <= (max == null ? 100000 : max) ? v : null);
|
|
358
|
+
const text = (v, n) => clip(oneLine(v), n);
|
|
359
|
+
/** One sprint's note from the agent → { note } | { error }. Nothing is guessed: a missing required field refuses the note. */
|
|
360
|
+
function noteOne(x) {
|
|
361
|
+
if (!x || typeof x !== 'object') return { error: 'a sprint note is not an object' };
|
|
362
|
+
const name = text(x.name, 80);
|
|
363
|
+
if (!name) return { error: 'a sprint note has no name' };
|
|
364
|
+
const state = String(x.state || '').toLowerCase();
|
|
365
|
+
if (!SPRINT_STATES.includes(state)) return { error: `${name}: state is active, future or closed` };
|
|
366
|
+
const g = x.goal && typeof x.goal === 'object' ? x.goal : null;
|
|
367
|
+
if (!g || !GOAL_FROM.includes(g.from)) return { error: `${name}: goal.from is jira, none, broad or partial` };
|
|
368
|
+
const gt = text(g.text, 240);
|
|
369
|
+
if (!gt) return { error: `${name}: the goal has no text` };
|
|
370
|
+
const goal = { from: g.from, text: gt };
|
|
371
|
+
if (g.from === 'broad' || g.from === 'partial') {
|
|
372
|
+
const j = text(g.jira, 300);
|
|
373
|
+
if (!j) return { error: `${name}: a ${g.from} goal carries Jira's own goal in goal.jira` };
|
|
374
|
+
goal.jira = j;
|
|
375
|
+
}
|
|
376
|
+
const summary = text(x.summary, 900);
|
|
377
|
+
if (!summary) return { error: `${name}: the summary is empty` };
|
|
378
|
+
const themes = [];
|
|
379
|
+
for (const t of Array.isArray(x.themes) ? x.themes.slice(0, 8) : []) {
|
|
380
|
+
const title = t && text(t.title, 80);
|
|
381
|
+
if (!title) return { error: `${name}: a theme has no title` };
|
|
382
|
+
const tickets = count(t.tickets);
|
|
383
|
+
if (tickets == null) return { error: `${name}: theme "${title}" needs a ticket count` };
|
|
384
|
+
const ini = t.initiative == null || t.initiative === '' ? null : String(t.initiative).trim();
|
|
385
|
+
if (ini != null && !INI_RE.test(ini)) return { error: `${name}: theme "${title}" has a bad initiative id` };
|
|
386
|
+
themes.push({ title, line: text(t.line, 200), tickets, done: Math.min(count(t.done) || 0, tickets), blocked: Math.min(count(t.blocked) || 0, tickets), people: text(t.people, 80), initiative: ini });
|
|
387
|
+
}
|
|
388
|
+
const note = { name, state, goal, summary, themes };
|
|
389
|
+
const id = isStr(x.id) || Number.isInteger(x.id) ? String(x.id) : '';
|
|
390
|
+
if (/^\d{1,12}$/.test(id)) note.id = id;
|
|
391
|
+
if (state === 'closed' && x.closed && typeof x.closed === 'object') {
|
|
392
|
+
const c = x.closed;
|
|
393
|
+
note.closed = { done: count(c.done) || 0, total: count(c.total) || 0, carried: count(c.carried) || 0, carriedTo: text(c.carriedTo, 80) || null, points: text(c.points, 40) || null };
|
|
394
|
+
}
|
|
395
|
+
return { note };
|
|
396
|
+
}
|
|
397
|
+
/** The sprints of the whole-sprint read that need a note: every active and future one, and each closed one not yet noted. */
|
|
398
|
+
function sprintsToSummarise(root) {
|
|
399
|
+
const whole = readSprints(root);
|
|
400
|
+
const frozen = frozenNotes(root);
|
|
401
|
+
const by = new Map();
|
|
402
|
+
for (const t of whole ? whole.tickets.values() : []) {
|
|
403
|
+
const sp = t.sprint;
|
|
404
|
+
if (!sp || !sp.name) continue;
|
|
405
|
+
if (!by.has(sp.name)) by.set(sp.name, { name: sp.name, ...(sp.id ? { id: sp.id } : {}), state: sp.state, tickets: 0, jiraGoal: null });
|
|
406
|
+
const o = by.get(sp.name);
|
|
407
|
+
o.tickets++;
|
|
408
|
+
if (!o.jiraGoal && sp.goal) o.jiraGoal = sp.goal;
|
|
409
|
+
}
|
|
410
|
+
const all = [...by.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
411
|
+
const isKept = (s) => frozen.some((f) => sameSprint(f, s)); // the note was written when Jira said closed
|
|
412
|
+
return { ok: true, read: !!whole, sprints: all.filter((s) => !isKept(s)), kept: all.filter(isKept).map((s) => s.name) };
|
|
413
|
+
}
|
|
414
|
+
/** Save the notes the agent wrote. A closed sprint already noted keeps its first note. A bad note refuses the whole file. */
|
|
415
|
+
function saveSprintNotes(root, raw, { dryRun = false, now } = {}) {
|
|
416
|
+
const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.sprints) ? raw.sprints : null;
|
|
417
|
+
if (!list) return { ok: false, error: 'the file holds no "sprints": [...] list' };
|
|
418
|
+
const got = [];
|
|
419
|
+
for (const x of list) { const r = noteOne(x); if (r.error) return { ok: false, error: r.error }; got.push(r.note); }
|
|
420
|
+
const prev = readSprintNotes(root);
|
|
421
|
+
const notes = prev ? prev.notes.slice() : [];
|
|
422
|
+
const whole = readSprints(root);
|
|
423
|
+
const wt = whole ? [...whole.tickets.values()] : [];
|
|
424
|
+
const writtenAt = nowIso(now);
|
|
425
|
+
const saved = []; const kept = [];
|
|
426
|
+
for (const n of got) {
|
|
427
|
+
const i = notes.findIndex((o) => sameSprint(o, n));
|
|
428
|
+
if (i >= 0 && notes[i].state === 'closed') { kept.push(n.name); continue; }
|
|
429
|
+
const mine = wt.filter((t) => t.sprint && sameSprint(t.sprint, n));
|
|
430
|
+
const sp = mine.length ? mine[0].sprint : {};
|
|
431
|
+
const out = { ...n, writtenAt, tickets: mine.length, ...(sp.start ? { start: sp.start } : {}), ...(sp.end ? { end: sp.end } : {}), ...(sp.completed ? { completed: sp.completed } : {}) };
|
|
432
|
+
if (!out.id && sp.id) out.id = sp.id;
|
|
433
|
+
if (i >= 0) notes[i] = out; else notes.push(out);
|
|
434
|
+
saved.push(n.name);
|
|
435
|
+
}
|
|
436
|
+
const result = { ok: true, saved, kept, file: snapshotRel(root, NOTES_FILE), dryRun: !!dryRun };
|
|
437
|
+
if (dryRun || !saved.length) return result;
|
|
438
|
+
const file = snapshotPath(root, NOTES_FILE);
|
|
439
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
440
|
+
const rows = notes.map((o) => `| ${cell(o.name)} | ${cell(o.state)} | ${cell(o.goal.text)} | ${o.goal.from === 'jira' ? 'Jira' : 'written from the tickets'} | ${cell(o.writtenAt)} |`);
|
|
441
|
+
const body = [
|
|
442
|
+
'---', 'type: Reference', 'title: "Jira sprints: goal and summary"', `timestamp: ${writtenAt}`, '---', '',
|
|
443
|
+
'# Jira sprints: goal and summary', '',
|
|
444
|
+
'Written by the Jira status check from the tickets it read, read-only. The Sprints tab of a board shows it. A closed sprint is written once and kept. Written by `pmos jira summary`; do not edit by hand.', '',
|
|
445
|
+
'| Sprint | State | Goal | Goal from | Written |', '|---|---|---|---|---|', ...rows, '',
|
|
446
|
+
'```json', JSON.stringify({ written_at: writtenAt, sprints: notes }, null, 0), '```', '',
|
|
447
|
+
].join('\n');
|
|
448
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
449
|
+
fs.writeFileSync(tmp, body);
|
|
450
|
+
fs.renameSync(tmp, file);
|
|
451
|
+
return { ...result, writtenAt };
|
|
452
|
+
}
|
|
453
|
+
|
|
325
454
|
// ── J13 · which initiative ─────────────────────────────────────────────────────────────────────────
|
|
326
455
|
function which(root, key) {
|
|
327
456
|
const k = String(key || '').trim().toUpperCase();
|
|
@@ -335,4 +464,5 @@ function which(root, key) {
|
|
|
335
464
|
return { ok: true, key: k, initiatives: hits };
|
|
336
465
|
}
|
|
337
466
|
|
|
338
|
-
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE
|
|
467
|
+
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE,
|
|
468
|
+
sprintsToSummarise, saveSprintNotes, readSprintNotes, sameSprint, NOTES_FILE };
|