Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/skills/s360-reporter/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
105 changes: 105 additions & 0 deletions .github/skills/s360-reporter/fetch-items.js
Original file line number Diff line number Diff line change
@@ -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 <page1.json> [page2.json ...] --output <consolidated.json>');
console.error('');
console.error('Save each MCP `search_active_s360_kpi_action_items` response to a separate file,');
console.error('then pass them all in page order. This script verifies pagination completed.');
process.exit(2);
}

// ── Envelope unwrap ───────────────────────────────────────────────────────────
// Returns { resources, nextCursor } regardless of envelope shape. A bare array
// input is treated as a complete final page (no nextCursor).
function unwrap(p) {
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
if (Array.isArray(j)) return { resources: j, nextCursor: null };
if (j && Array.isArray(j.resources)) return { resources: j.resources, nextCursor: j.nextCursor || null };
if (j && j.result && Array.isArray(j.result.resources)) {
return { resources: j.result.resources, nextCursor: j.result.nextCursor || null };
}
throw new Error(`Could not find a resources array in ${p}. Expected a top-level array, { resources: [...] }, or { result: { resources: [...] } }.`);
}

// ── Consolidate ───────────────────────────────────────────────────────────────
const all = [];
let lastCursor = null;
for (let i = 0; i < inputs.length; i++) {
const { resources, nextCursor } = unwrap(inputs[i]);
const isLast = i === inputs.length - 1;
console.error(`Page ${i + 1} (${path.basename(inputs[i])}): ${resources.length} items${nextCursor ? ` [nextCursor present]` : ''}`);
all.push(...resources);
if (isLast) lastCursor = nextCursor;
}

// ── Hard-fail if the caller forgot to fetch every page ────────────────────────
if (lastCursor) {
console.error('');
console.error('ERROR: pagination incomplete.');
console.error(`The last input page (${path.basename(inputs[inputs.length - 1])}) still has a nextCursor set.`);
console.error(`Call \`mcp_s360-breeze-m_search_active_s360_kpi_action_items\` again with cursor="${lastCursor}",`);
console.error('save the response as the next page file, then re-run this script with the additional --input file.');
console.error('');
console.error('Not enforcing this is how S360 reports have silently under-counted before (AB#3683197).');
process.exit(1);
}

console.error(`Consolidated ${all.length} items across ${inputs.length} page(s). Pagination complete (last nextCursor is empty).`);

// ── Write ─────────────────────────────────────────────────────────────────────
const out = JSON.stringify(all, null, 2);
if (outputPath) {
fs.writeFileSync(outputPath, out);
console.error(`Wrote ${all.length} items to ${outputPath}`);
} else {
process.stdout.write(out + '\n');
}
77 changes: 74 additions & 3 deletions .github/skills/s360-reporter/generate-report.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,37 @@ function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// ── Link safety ───────────────────────────────────────────────────────────────
// safeHref returns a rendered href attribute value only when the input is a
// non-empty http(s) URL. Anything else (null, undefined, empty string, the
// literal string "undefined", a bare path, javascript:, mailto:, etc.) returns
// null so callers can fall back to plain text.
//
// This exists because before this fix, `<a href="${item.s360Url}">` rendered
// as `href="undefined"` when the S360 API's URL field was missing — producing
// visibly broken links on hover and 404s on click. (Reported by first user
// Sowmya Malayanur, Jun 2026; PBI AB#3683197.)
function safeHref(url) {
if (typeof url !== 'string') return null;
const s = url.trim();
if (!s || s.toLowerCase() === 'undefined' || s.toLowerCase() === 'null') return null;
if (!/^https?:\/\//i.test(s)) return null;
return s;
}

// linkOrText wraps `innerHtml` in an anchor when the URL is safe, otherwise
// returns the inner HTML unwrapped. Callers pass the CSS for the anchor.
function linkOrText(url, innerHtml, anchorStyle) {
const href = safeHref(url);
if (!href) return innerHtml;
return `<a href="${href}" style="${anchorStyle}">${innerHtml}</a>`;
}

// Track items whose title link had to be rendered as plain text because the
// S360 API returned no usable URL. Surfaced in the self-check banner + stderr
// so a broken run is visible in-report instead of shipping silently.
const itemsWithoutLinks = [];

function slaStyle(sla) {
if (sla === 'OutOfSla') return {
rowBg: '#fff5f5', rowBgAlt: '#fff5f5', leftBorder: '#cf222e',
Expand Down Expand Up @@ -175,7 +206,8 @@ html += `<!DOCTYPE html>
Services: <strong>AuthN SDK - MSAL Android</strong> &bull; <strong>AuthN SDK - ADAL Android</strong> &bull; <strong>Microsoft Authenticator - Android</strong>
</p>
</td>
</tr>`;
</tr>
<!--LINK_CHECK_BANNER-->`;

// Summary cards
const cards = [
Expand Down Expand Up @@ -314,7 +346,10 @@ if (outOfSlaItems.length > 0) {
<tr>
<td valign="top" width="65%">
<p style="margin:0 0 8px 0; font-size:16px; font-weight:700;">
<a href="${item.s360Url}" style="color:#1a1a1a; text-decoration:none;">${esc(item.title)}</a>
${(() => {
if (!safeHref(item.s360Url)) itemsWithoutLinks.push({ title: item.title, ownerAlias: item.ownerAlias });
return linkOrText(item.s360Url, esc(item.title), 'color:#1a1a1a; text-decoration:none;');
})()}
</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:10px;">
<tr>
Expand Down Expand Up @@ -388,7 +423,10 @@ sortedPrograms.forEach(([progName, prog]) => {
html += `
<tr>
<td bgcolor="${bg}" style="padding:12px 14px; font-size:13px; border:1px solid #e8e8e8; border-left:4px solid ${s.leftBorder};">
<a href="${it.s360Url}" style="color:#24292f; font-weight:600; text-decoration:none;">${esc(it.shortTitle || it.title)}</a>${it.subtitle ? `<br><span style="font-size:11px;"><em>${esc(it.subtitle)}</em></span>` : ''}
${(() => {
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 ? `<br><span style="font-size:11px;"><em>${esc(it.subtitle)}</em></span>` : ''}
</td>
<td bgcolor="${bg}" style="padding:12px 14px; font-size:12px; border:1px solid #e8e8e8;">${esc(it.service)}</td>
<td bgcolor="${bg}" style="padding:12px 14px; font-size:12px; border:1px solid #e8e8e8;">${esc(it.ownerName)}<br><span style="font-size:11px;"><em>(${esc(it.ownerAlias)})</em></span></td>
Expand Down Expand Up @@ -516,6 +554,39 @@ html += `
</html>`;

// ── Output ────────────────────────────────────────────────────────────────────

// Self-check: surface any items whose title link had to be rendered as plain
// text. If items are missing links, prepend an in-report banner so the human
// reviewer sees it immediately rather than shipping a broken-looking report.
if (itemsWithoutLinks.length > 0) {
const preview = itemsWithoutLinks.slice(0, 5).map(i => `${esc(i.title)}${i.ownerAlias ? ` (${esc(i.ownerAlias)})` : ''}`).join(' &bull; ');
const more = itemsWithoutLinks.length > 5 ? ` &bull; +${itemsWithoutLinks.length - 5} more` : '';
const banner = `
<tr>
<td style="padding:16px 56px 0 56px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="4" bgcolor="#cf222e" style="font-size:1px;">&nbsp;</td>
<td bgcolor="#fef2f2" style="padding:14px 20px;">
<p style="margin:0 0 4px 0; font-size:13px; font-weight:700; color:#cf222e;">&#x26A0; ${itemsWithoutLinks.length} item(s) have no S360 link</p>
<p style="margin:0; font-size:12px;">The S360 API returned no usable URL for these items — their titles render as plain text. Investigate before sending this report externally.</p>
<p style="margin:6px 0 0 0; font-size:12px;"><em>${preview}${more}</em></p>
</td>
</tr>
</table>
</td>
</tr>`;
html = html.replace('<!--LINK_CHECK_BANNER-->', banner);
console.error(`WARN: ${itemsWithoutLinks.length} item(s) have no valid S360 link (rendered as plain text):`);
itemsWithoutLinks.forEach(i => console.error(` - ${i.title}${i.ownerAlias ? ` (${i.ownerAlias})` : ''}`));
} else {
html = html.replace('<!--LINK_CHECK_BANNER-->', '');
}

// Coverage summary — helps the human running the skill eyeball item count vs
// the S360 portal without having to hunt through the report.
console.error(`Coverage summary: ${total} active items rendered (${outCount} out-of-SLA, ${nearCount} approaching, ${inCount} in SLA, ${noEta} missing ETA). ${resolved.length} resolved. ${newItems.length} new this week.`);

if (outputPath) {
fs.writeFileSync(outputPath, html, 'utf8');
console.log('Report saved to ' + outputPath);
Expand Down
15 changes: 15 additions & 0 deletions .github/skills/s360-reporter/merge-items.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ if (!servicePath || !personPath || !teamPath) {
// ── Load inputs ───────────────────────────────────────────────────────────────
function loadResources(p) {
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
// Coverage guard: refuse to proceed if the input still carries an unspent
// nextCursor. A populated nextCursor means the caller only fetched one page
// — merging that partial data into the report silently under-counts items.
// This is the failure mode reported in AB#3683197 (first-user bug, Jun 2026).
// Use fetch-items.js to consolidate all pages before running the merger.
const cursor = (j && j.nextCursor) || (j && j.result && j.result.nextCursor) || null;
if (cursor) {
throw new Error(
`Refusing to merge ${p}: input still has a populated nextCursor ("${cursor}"). ` +
`This means only one page (up to pageSize=50) was fetched — the S360 items past that page ` +
`would be silently dropped from the report. Fetch every page via ` +
`\`mcp_s360-breeze-m_search_active_s360_kpi_action_items\` (looping until nextCursor is empty) ` +
`and consolidate with fetch-items.js before running the merger.`
);
}
if (Array.isArray(j)) return j;
if (j && j.resources && Array.isArray(j.resources)) return j.resources;
if (j && j.result && j.result.resources && Array.isArray(j.result.resources)) return j.result.resources;
Expand Down
Loading