diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md
index 198e1d82..8a72c814 100644
--- a/.github/skills/s360-reporter/SKILL.md
+++ b/.github/skills/s360-reporter/SKILL.md
@@ -143,6 +143,25 @@ request: {
If more than 50 items, paginate using the `nextCursor` field.
+**Pagination is mandatory** — save each page's raw MCP response to a separate
+file (e.g. `s360-service-page1.json`, `s360-service-page2.json`, ...) and keep
+calling with the returned cursor until `nextCursor` is empty. Then consolidate
+with `fetch-items.js`:
+
+```powershell
+node .github/skills/s360-reporter/fetch-items.js `
+ --input "$env:TEMP\s360-service-page1.json" "$env:TEMP\s360-service-page2.json" `
+ --output "$env:TEMP\s360-service.json"
+```
+
+`fetch-items.js` exits non-zero if the last input page still has `nextCursor`
+set. This is a defense against the first-user bug reported in AB#3683197
+where a single-page fetch silently dropped every item past page 1.
+
+If your first page's response has no `nextCursor`, you can pass it directly as
+`--input` (single file) — the consolidator will still verify pagination
+completed and produce the same output shape.
+
#### 1b: Person-targeted items
Using the aliases discovered in Step 0, call `mcp_s360-breeze-m_search_active_s360_kpi_action_items`
@@ -158,6 +177,11 @@ request: {
This captures person-targeted items like on-call readiness checklists and certifications
that are tied to individuals rather than service tree IDs.
+**Same pagination rule applies** — the person query also paginates at
+`pageSize=50`. Save each page separately and consolidate with
+`fetch-items.js` before running the merger. `merge-items.js` refuses to run
+against a JSON file that still has a populated `nextCursor` (see 1c).
+
**Important**: The `assignedTo` search returns ALL items for those aliases across Microsoft,
including items from other team memberships. After fetching, filter results to only include
items where one of these conditions is met:
@@ -198,6 +222,11 @@ The script accepts MCP envelopes (`{ result: { resources: [...] } }`), trimmed
envelopes (`{ resources: [...] }`), or bare arrays for the `--service` / `--person`
inputs.
+**Coverage guard**: `merge-items.js` refuses to run if either input still has a
+populated `nextCursor` — this is defense-in-depth against skipping the
+pagination loop in 1a/1b. If it errors, go back and consolidate with
+`fetch-items.js`.
+
**Filter logic** (enforced by the script — do not duplicate ad-hoc):
- `TargetType == "Person"` AND `TargetId` is a team alias → keep
- `TargetId` is one of the three service tree GUIDs → keep
@@ -774,6 +803,15 @@ Write a JSON file to a temp location (e.g., `$env:TEMP/s360_data.json`) with thi
- `pbi`: This field is the ADO work item ID and accepts **either a PBI or a Bug** ID
(the generator's `pbiUrl()` builds a type-agnostic ADO URL that works for both).
When the matched item is a Bug, still put its ID here — do not leave it null.
+- `s360Url`: The item's title-link target. Populated from the S360 API `URL`
+ field (remediation / action link — may be `aka.ms/…`, IcM URL, or an ADO
+ work-item URL). May legitimately be missing for some KPIs. The generator
+ validates it — non-empty `http(s)` URLs render as an anchor; anything else
+ (null, empty, `undefined`, malformed) renders as plain text and the item is
+ logged to a `⚠ N item(s) have no S360 link` banner at the top of the report
+ and to stderr. **Do not** substitute a placeholder like `https://s360.msftcloudes.com/`
+ just to get an anchor — plain text is the correct fallback so the human
+ reviewer notices the missing link before sending.
#### 5b: Run the generator
diff --git a/.github/skills/s360-reporter/fetch-items.js b/.github/skills/s360-reporter/fetch-items.js
new file mode 100644
index 00000000..f2e7bd57
--- /dev/null
+++ b/.github/skills/s360-reporter/fetch-items.js
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+//
+// fetch-items.js
+// ----------------------------------------------------------------------------
+// Consolidates one or more paginated `search_active_s360_kpi_action_items` MCP
+// responses into a single JSON file and verifies pagination was completed.
+//
+// Background: the S360 MCP paginates at pageSize=50 and returns a `nextCursor`
+// on every page except the last. Before this script existed, SKILL.md relied
+// on a manual "loop until nextCursor is empty" instruction — a first-time user
+// (Sowmya Malayanur, Jun 2026) missed it and shipped an incomplete report.
+// This script + the guard in merge-items.js make that failure mode loud.
+//
+// Usage:
+// node fetch-items.js --input page1.json page2.json [pageN.json ...] \
+// --output consolidated.json
+//
+// Each input file must be one of:
+// • Full MCP envelope: { result: { resources: [...], nextCursor?: "..." } }
+// • Mid envelope: { resources: [...], nextCursor?: "..." }
+// • Bare array: [...] (assumed to be a complete final page)
+//
+// Exit behavior:
+// • Exits non-zero if the LAST input page still has a non-empty nextCursor.
+// The user must fetch the next page(s) and re-run.
+// • Emits a coverage summary to stderr (per-page counts + total).
+//
+// The output JSON is a bare array of items — same shape merge-items.js
+// already accepts.
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+// ── CLI args ──────────────────────────────────────────────────────────────────
+const args = process.argv.slice(2);
+const inputs = [];
+let outputPath = null;
+for (let i = 0; i < args.length; i++) {
+ if (args[i] === '--output') {
+ outputPath = args[++i];
+ } else if (args[i] === '--input') {
+ while (i + 1 < args.length && !args[i + 1].startsWith('--')) {
+ inputs.push(args[++i]);
+ }
+ }
+}
+
+if (inputs.length === 0) {
+ console.error('Usage: node fetch-items.js --input
- ${esc(item.title)} + ${(() => { + if (!safeHref(item.s360Url)) itemsWithoutLinks.push({ title: item.title, ownerAlias: item.ownerAlias }); + return linkOrText(item.s360Url, esc(item.title), 'color:#1a1a1a; text-decoration:none;'); + })()}
|
- ${esc(it.shortTitle || it.title)}${it.subtitle ? ` ${esc(it.subtitle)}` : ''} + ${(() => { + if (!safeHref(it.s360Url)) itemsWithoutLinks.push({ title: it.title, ownerAlias: it.ownerAlias }); + return linkOrText(it.s360Url, esc(it.shortTitle || it.title), 'color:#24292f; font-weight:600; text-decoration:none;'); + })()}${it.subtitle ? ` ${esc(it.subtitle)}` : ''} |
${esc(it.service)} | ${esc(it.ownerName)} (${esc(it.ownerAlias)}) |
@@ -516,6 +554,39 @@ html += `