diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6ecc7ed1..f6f564d9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -141,6 +141,7 @@ For complex investigation tasks, use these skills (read the skill file for detai | **pbi-dispatcher** | `.github/skills/pbi-dispatcher/SKILL.md` | "dispatch PBIs to agent", "assign to Copilot", "send work items to coding agent" | | **test-planner** | `.github/skills/test-planner/SKILL.md` | "create test plan", "write test cases", "add tests to ADO", "export test plan", "E2E tests for" | | **threat-modeler** | `.github/skills/threat-modeler/SKILL.md` | "create a threat model", "threat model for", "threat model diagram", "STRIDE analysis for", "security diagram for" | +| **s360-reporter** | `.github/skills/s360-reporter/SKILL.md` | "S360 report", "generate S360 report", "weekly S360", "S360 status", "what are our S360 items" | | **copilot-review-analyst** | `.github/skills/copilot-review-analyst/SKILL.md` | "analyze Copilot reviews", "Copilot review effectiveness", "review analysis report", "how helpful are Copilot reviews" | ## 13. Azure DevOps Integration diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md new file mode 100644 index 00000000..198e1d82 --- /dev/null +++ b/.github/skills/s360-reporter/SKILL.md @@ -0,0 +1,848 @@ +--- +name: s360-reporter +description: Generate S360 weekly reports for the Android Auth team. Fetches active action items from S360 MCP server, creates ADO work items (PBIs) for untracked items, and produces a polished Outlook-compatible HTML email report. Triggers include "S360 report", "generate S360 report", "weekly S360", "S360 status", "what are our S360 items", or any request to review, report on, or triage S360 action items for the Android Auth team. +--- + +# S360 Weekly Report Generator + +Generate a polished S360 weekly report for the Android Auth team. Fetches live data from +the S360 MCP server, ensures every item has an ADO PBI, and produces an Outlook-compatible +HTML email report. + +## Prerequisites + +- **Node.js** must be available (for the committed merge/reduce/report scripts: + `merge-items.js`, `reduce-items.js`, `generate-report.js`) +- **S360 MCP Server** must be running (configured in `.vscode/mcp.json` as `s360-breeze-mcp`) +- **ADO MCP Server** must be running (for PBI creation and lookup) +- **M365 User MCP** (`m365-user`) — for dynamic team member discovery via org chart +- **WorkIQ MCP Server** (optional) — used as fallback for pulling last week's email if the user doesn't provide it +- Read the **Outlook HTML report prompt** at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` + for HTML rendering rules before generating the report + +## Quick Mode + +If the user says "quick S360" or "S360 status", run **Steps 0–2 only** and print a CLI +summary instead of generating the full report. Example output: + +``` +S360 Status — Android Auth Team (Apr 8, 2026) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +15 active items: 1 🔴 Out of SLA, 2 🟠 Approaching, 12 🟢 In SLA +7 items missing ETA + +🔴 GDPR Streams Left to Review — moghosh — Due Mar 22 (17 days overdue) +🟠 Disable local auth for container registries — moghosh — Due Apr 12 +🟠 Threat Model Review — zhangrichard — Due May 5 +``` + +Skip PBI creation, report generation, and email drafting in quick mode. + +## Target Services + +| Service | Service Tree ID | +|---------|----------------| +| AuthN SDK - ADAL Android | `937cdc57-1253-4b55-878e-5854368926a2` | +| AuthN SDK - MSAL Android | `8d0d308e-cd5c-44a3-9518-43eeeb424b57` | +| Microsoft Authenticator - Android | `0b97f26e-fcfc-4ed1-95e9-1dca3a2fde3b` | + +## KPIs Where ETA Is Not Applicable + +Some S360 KPIs do not have an ETA column in the portal. For items belonging to these KPIs, +show **"N/A"** in the ETA field instead of "Missing ETA ⚠". Do NOT count them in the +"Missing ETA" summary count. + +| KPI ID | KPI Name | +|--------|----------| +| `d573888d-4c6f-81cc-7992-50dc17c87d83` | [Compl-CC1.3] Data Type Classification (GDPR) | + +> **Maintaining this list**: If the user reports that an item shows "Missing ETA ⚠" but +> the S360 portal has no ETA column for it, add the KPI ID to this table. + +## Workflow + +### Step 0: Discover Team Members via Graph + +Some S360 items (e.g., on-call readiness) are **person-targeted** (`TargetType: "Person"`, +`TargetId: "alias"`) rather than service-targeted. Searching by service tree IDs alone +misses these. To capture them, dynamically discover team member aliases from the org chart. + +1. **Get current user's manager:** + ``` + m365-user-GetManagerDetails( + select: "id,displayName,userPrincipalName" + ) + ``` + Extract the manager's `id` (GUID) and `userPrincipalName`. + +2. **Get all direct reports of the manager (= your teammates):** + ``` + m365-user-GetDirectReportsDetails( + userId: "", + select: "id,displayName,userPrincipalName,jobTitle,accountEnabled" + ) + ``` + +3. **Filter and extract aliases:** + - Exclude accounts where `userPrincipalName` starts with `SC-` or `sc-` (non-EA service accounts) + - Exclude accounts where `accountEnabled` is `false` + - Extract alias from `userPrincipalName` by stripping `@microsoft.com` + - Store the list of aliases and a display-name map for later use in the report + +**Fallback**: If the M365 User MCP is unavailable, fall back to the ADO Teams API: +- Call `mcp_ado_core_list_project_teams(project: "Engineering", mine: true)` +- Find the team named "Auth Client - Android" and note its `id` +- Call the ADO REST API via terminal: + `Invoke-RestMethod -Uri "https://identitydivision.visualstudio.com/_apis/projects/Engineering/teams/{teamId}/members?api-version=7.1"` +- Extract `uniqueName` values, strip `@microsoft.com` to get aliases + +### Step 0b: Collect Last Week's Report + +Before fetching S360 data, ask the user if they have last week's S360 report available. +This is the **primary method** for determining "new this week" items, resolved items, +and pre-existing PBI assignments. Use the `ask_user` tool: + +``` +question: "Do you have last week's S360 report to paste? This helps detect new/resolved items and avoid duplicate PBIs. You can paste the report text, or skip and I'll try to find it automatically." +choices: ["I'll paste it now", "Skip — find it automatically"] +``` + +**If the user pastes the report:** +1. Parse the pasted text for: + - **Item titles with owners** — each row in the report table + - **AB# references** — extract numeric ADO work item IDs (e.g., `AB#12345`, `Product Backlog Item 12345`, or `Bug 12345`) + - **ADO work item URLs** — links like `dev.azure.com/.../workitems/12345` + - **SLA states** — Missed SLA, Near SLA, In SLA +2. Build a **previous report map**: title → { pbi, owner, slaState } +3. Store this map for use in: + - **Step 1e** (resolved items = items in last week's map but NOT in current active set) + - **Step 3** (existing PBIs = AB# numbers from the map) + - **Step 5** (new items = items in current active set but NOT in last week's map) + +**If the user skips or doesn't respond:** +Fall back to automatic discovery in Step 3a (WorkIQ → Mail Search → proceed without). + +### Step 1: Fetch S360 Data + +Fetch items from **two sources** and merge them: + +#### 1a: Service-targeted items + +Call `mcp_s360-breeze-m_search_active_s360_kpi_action_items` with all three service tree IDs: + +``` +request: { + "pageSize": 50, + "targetIds": [ + "937cdc57-1253-4b55-878e-5854368926a2", + "8d0d308e-cd5c-44a3-9518-43eeeb424b57", + "0b97f26e-fcfc-4ed1-95e9-1dca3a2fde3b" + ] +} +``` + +If more than 50 items, paginate using the `nextCursor` field. + +#### 1b: Person-targeted items + +Using the aliases discovered in Step 0, call `mcp_s360-breeze-m_search_active_s360_kpi_action_items` +with `assignedTo`: + +``` +request: { + "pageSize": 50, + "assignedTo": ["alias1", "alias2", ...all team aliases from Step 0...] +} +``` + +This captures person-targeted items like on-call readiness checklists and certifications +that are tied to individuals rather than service tree IDs. + +**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: +- `TargetType` is `"Person"` AND `TargetId` exactly matches one of the team aliases +- `TargetId` matches one of our three service tree IDs +- `CustomDimensions.TenantName` contains "Auth Client", "MSAL", "ADAL", or "Authenticator" + +**Critical — do NOT expand group items**: Each S360 item has exactly one `AssignedTo` +and one `TargetId`. Treat each item as-is — one row per `KpiActionItemId`. Never split +a single item into multiple rows by parsing names from the title or description. If +multiple team members share the same on-call KPI, S360 creates **separate items** for +each person (each with its own `KpiActionItemId` and `AssignedTo`). If an alias has no +matching item in the API response, that person simply has no action item — do not +fabricate one. + +#### 1c: Merge results + +Use the committed **`merge-items.js`** script to filter person items to team-relevance +and deduplicate by `KpiActionItemId`. The script encodes the filter rules from 1b and +the dedup rule below, so they cannot be forgotten or re-implemented inconsistently +across weekly runs. + +```powershell +# Write the team config (aliases + nameMap from Step 0) to a JSON file: +$team = @{ aliases = @('alias1','alias2',...); nameMap = @{ alias1='Name1'; ... } } | + ConvertTo-Json -Depth 3 +$team | Out-File -Encoding utf8 "$env:TEMP\s360-team.json" + +# Run the merger (writes warnings/counts to stderr, merged JSON to --output): +node .github/skills/s360-reporter/merge-items.js ` + --service "$env:TEMP\s360-service.json" ` + --person "$env:TEMP\s360-person.json" ` + --team "$env:TEMP\s360-team.json" ` + --output "$env:TEMP\s360-merged.json" +``` + +The script accepts MCP envelopes (`{ result: { resources: [...] } }`), trimmed +envelopes (`{ resources: [...] }`), or bare arrays for the `--service` / `--person` +inputs. + +**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 +- `CustomDimensions.TenantName` matches an Auth-team pattern → keep +- **`AssignedTo` alone is NOT sufficient** — the person query already filters by + `assignedTo`, so every returned item has a team-alias `AssignedTo` but many are + for OTHER teams the person also belongs to. Items that match only `AssignedTo` + with no direct relevance signal are dropped (the script logs sample drops to + stderr for sanity checking). + +**Dedup**: by `KpiActionItemId`. Cross-source duplicates (same item appearing in +both service and person queries) are removed. + +**Fallback**: If Node.js is unavailable, apply the filter + dedup logic manually +following the rules above. Do not invent new merging rules — anything that +collapses items into fewer rows MUST honor the per-finding exception in Step 2. + +#### 1d: Fetch KPI Metadata (for Program Names) + +After merging, collect the unique `KpiId` values from all items and fetch metadata +for each one: + +``` +mcp_s360-breeze-m_get_s360_kpi_metadata_by_kpi_id(kpiId: "") +``` + +Extract the `displayName` field from each response and build a **KpiId → displayName** +map. These display names are the authoritative program/category labels (e.g., +`[SFI-ES4.2.4] Network Isolation for CFS endpoints`, `[Compl-CC1.3] Data Type +Classification`, `Individual On-Call Readiness`). + +**Cache results** — multiple items share the same KpiId, so you only need one lookup +per unique KPI, not per item. + +**Important**: Validate that the KpiId is a proper GUID (8-4-4-4-12 hex format) before +calling the API. Some raw data may have malformed IDs — if invalid, log a warning and +fall back to the item `Title` as the program name. + +#### 1e: Detect Resolved Items (Week-over-Week) + +To populate the "Resolved Since Last Week" section, compare the current S360 item set +against last week's report: + +1. **Pull last week's S360 items** via one of these sources (in priority order): + a. **User-provided report** (from Step 0b) — if the user pasted last week's report, + use the parsed previous report map. This is the most reliable source. + b. Call `mcp_s360-breeze-m_search_resolved_s360_kpi_action_items` with the same + `targetIds` and `assignedTo` used in 1a/1b. Cross-reference results against the + user-provided report if available — only include items that appear in BOTH sources. + c. If no user-provided report and the resolved search tool is unavailable, parse + last week's email (from Step 3a) and extract the item titles + AB# numbers. + d. If none of the above are available, skip this step. + +2. **Identify resolved items**: Items that appeared in last week's report but are NOT + in the current active set (from 1c) are considered resolved. + +3. **For each resolved item**, look up its ADO PBI state: + - If the PBI is `Done` or `Removed`, mark as resolved with its AB# and assignee. + - If the PBI is still open, the S360 item may have been resolved but the PBI wasn't + closed — still include it in the resolved list but note the PBI state. + +4. **Store the resolved items list** for use in Step 5 (report generation). + Each resolved item should have: Title, AB#, Assignee, PBI State. + +**Fallback**: If no previous report data is available, show a note in the report: +"No resolved items were detected this week. If items were resolved manually, they may +not appear here." + +### Step 2: Parse and Deduplicate Items + +Use the committed **`reduce-items.js`** script to turn the merged items from Step 1c +into logical report rows. The script encodes every dedup rule below — most +importantly the CRITICAL per-finding exception — so they cannot be skipped or +reinterpreted on each weekly run. + +```powershell +# Build a KpiId → displayName map from the metadata you fetched in Step 1d: +$kpiMap = @{ + 'a0f0ce42-3063-5d3b-3b47-1ff3143abdc9' = '[SFI-PS3.1] Security Code Bugs' + '2d6597da-8e08-4495-a4e1-954f7697a4a8' = 'SDL Annual Assessment' + # ... (one entry per unique KpiId) +} +$kpiMap | ConvertTo-Json | Out-File -Encoding utf8 "$env:TEMP\s360-kpi.json" + +# Run the reducer (warnings to stderr, reduced rows to --output): +node .github/skills/s360-reporter/reduce-items.js ` + --input "$env:TEMP\s360-merged.json" ` + --kpi-metadata "$env:TEMP\s360-kpi.json" ` + --output "$env:TEMP\s360-reduced.json" +``` + +**What the reducer guarantees** (the rules below are now enforced in code): +1. Extracts a per-item ADO work-item ID from `URL` (regex `_workitems/edit/(\d+)`), + falling back to `S360Dimensions.ADOWorkItemHTMLUrl`. Warns on conflicts. +2. Resolves `ETA` by walking the item object for any key containing `eta` + (case-insensitive) that parses as a strict ISO date. Picks the most recent. +3. Groups items into rows using: + - `wi:` if the item has a non-null, non-reused ADO ID → one + row per work item (the per-finding case). + - `pf:` if the item's KPI is in the script's + `PER_FINDING_KPIS` set but URL is missing → still one row per item, so a + temporary URL outage cannot silently umbrella-merge. + - `nowi:||` otherwise → umbrella merge (CFS + multi-endpoint case). +4. Detects **reused work-item IDs** (same ADO ID referenced by multiple distinct + `KpiId|baseTitle|TargetId` tuples) and refuses to use them as grouping + authority — prevents a shared template work item from collapsing unrelated + items into one row. +5. Picks the worst-SLA + earliest-due item as the group representative + (deterministic tiebreak on `KpiActionItemId`). +6. Sorts output rows deterministically by SLA, due date, program, title — same + input always produces identical output. +7. Logs URL-coverage warnings for known per-finding KPIs when not every item + has an ADO link. + +**Maintaining the `PER_FINDING_KPIS` set**: If a new KPI is discovered where each +S360 item maps to its own ADO Bug (e.g. accessibility per-issue, BinSkim per-rule), +add its KpiId to the `PER_FINDING_KPIS` set near the top of `reduce-items.js`. +The script will still do the right thing if you forget (URL-based grouping handles +it as long as URLs are populated) — the set is a defense-in-depth fallback for +missing URLs. + +For each field the reducer extracts (and the rest of the workflow consumes): + +| Field | JSON Path | Notes | +|-------|-----------|-------| +| Title | `Title` | **Required** — sanitize before display (see below). If empty, use KPI `displayName` from Step 1d. Never leave blank. If the row is flagged with `usesGenericS360Title: true`, the workflow substitutes the ADO `System.Title` in Step 3e (see below). | +| Service | Map `TargetId` → service name from table above. For person-targeted items (`TargetType: "Person"`), use `CustomDimensions.TenantName` instead | +| Owner Alias | `S360Dimensions.ActionOwnerAlias` | Falls back to `AssignedTo`. If both empty → "unassigned". **Overridden by ADO PBI assignee in Step 3e.** | +| Owner Name | `S360Dimensions.ActionOwner` | If empty, use the `nameMap` from Step 0 to look up alias → display name. If still empty, use the alias as display name. **Overridden by ADO PBI assignee in Step 3e.** | +| Due Date | `CurrentDueDate` | Format as `Mon DD, YYYY` | +| SLA State | `SLAState` | Values: `OutOfSla`, `ApproachingSla`, `InSla` | +| ETA | See **ETA Field Resolution** below — do NOT just read `CurrentETA` | If no ETA can be resolved from any of the candidate fields AND KpiId is in the "ETA Not Applicable" table → show **"N/A"**. If unresolved AND KpiId is NOT in that table → flag as **"Missing ETA ⚠"** | +| Status Notes | `CurrentStatus` | May be empty | +| Status Author | `CurrentStatusAuthor` | | +| ADO Work Item | `S360Dimensions.ADOWorkItemHTMLUrl` | Empty does NOT mean no work item — also check the `URL` field (see next row) | +| S360 URL | `URL` | Remediation/action link from S360 API. May be `aka.ms/...`, IcM URL, **or an ADO work-item URL**. If it matches `dev.azure.com/.../_workitems/edit/(\d+)` or `*.visualstudio.com/.../_workitems/edit/(\d+)`, extract the ID — that is the **pre-created Bug/PBI for this specific S360 item** (highest-priority work-item match — see Step 3d). | +| KPI ID | `KpiId` | For dedup | +| Action Item ID | `KpiActionItemId` | For dedup | +| Program Name | KPI metadata `displayName` (from Step 1d) | For grouping items by compliance area | +| usesGenericS360Title | (computed by reducer) | `true` when the S360 publisher reused one identical `Title` across many rows that each link to a distinct ADO work item (e.g. SDL Annual Assessment → 22 rows). When true, Step 3e substitutes the ADO `System.Title` for each row so the report shows the actual finding instead of the umbrella label. | +| Program Desc | `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` | Subtitle under program heading (optional) | +| Wave | Extract from `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` | + +**Program Name**: Use the **KPI metadata `displayName`** fetched in Step 1d via the +`KpiId → displayName` map. This is the only reliable source for program/category names. + +Do **NOT** use any of these fields for program names — they contain internal codes: +- `CustomDimensions.initiative` — contains internal IDs like `"ADFunCompliance"`, `"CyberEO"` +- `CustomDimensions.filter` — contains codes like `"ADFunGlobal"` +- `CustomDimensions.campaign` — unreliable, often empty or internal +- `CustomDimensions.Ingestion_KpiName` — internal KPI ingestion label, not user-facing + +**ETA Field Resolution** (CRITICAL — do not skip): + +The S360 API does **not** always populate `CurrentETA` even when the S360 portal +shows an ETA. This is especially common for items where `SLAState == "OutOfSla"` +(Missed SLA) — the portal column reads "ETA (Missed SLA)" and the API surfaces +the value under a different field name. Reading only `CurrentETA` causes valid +ETAs to be reported as "No ETA" in the weekly report (a real bug reported by the +team). + +To resolve an item's ETA, check these fields in order and use the first non-null +ISO-date value found: + +1. `CurrentETA` +2. `ETA` +3. `MissedSLAETA` / `ETAMissedSLA` / `ETA_MissedSLA` (any casing/separator variant) +4. `S360Dimensions.ETA` / `S360Dimensions.CurrentETA` / `S360Dimensions.MissedSLAETA` +5. `CustomDimensions.ETA` / `CustomDimensions.CurrentETA` / `CustomDimensions.MissedSLAETA` +6. Any other top-level or nested key whose **name contains the substring `ETA`** + (case-insensitive) and whose value parses as a valid ISO date or `YYYY-MM-DD` + string. If multiple match, prefer the most recent (latest) date. + +**Diagnostic fallback** — for any item where the resolved ETA is still null AND +the KpiId is NOT in the "ETA Not Applicable" table AND `SLAState == "OutOfSla"`, +dump the raw item JSON to the console and `grep -i eta` it. If a new field name +shows up, add it to the candidate list above (and to this file via a follow-up +PR) so future runs pick it up automatically. + +**Do not** treat the S360 `SLAState`/`CurrentDueDate` fields as ETA — those are +separate (SLA deadline vs. owner's committed delivery date). + +**Title sanitization**: S360 raw `Title` values often contain service tree GUIDs or +overly technical text that is not suitable for display. Apply these cleanups: + +1. **Replace embedded GUIDs** — If the title contains a service tree ID (e.g., + `"8d0d308e-cd5c-44a3-9518-43eeeb424b57 has streams left to review"`), replace the + GUID with the mapped service name (e.g., `"MSAL Android has streams left to review"`) + or use a cleaner description from the KPI metadata. +2. **Set `shortTitle`** — For long titles (especially CFS pipeline items), extract the + meaningful suffix. For example, `"Use CFS package feeds for pipeline: Publish msal + to maven"` → shortTitle: `"Publish msal to maven"`. +3. **Clean up resolved item titles** — Apply the same sanitization to resolved items + before rendering in the report. + +**Dedup** *(reference spec — implemented by `reduce-items.js`; documented here +so reviewers can verify the script is doing the right thing and so the rules +remain enforceable if Node.js is unavailable as a fallback)*: Some items appear +multiple times with different `KpiActionItemId` but same or similar `Title` and +`TargetId`. Apply dedup in two passes: + +1. **Exact dedup**: Group by `Title` + `TargetId`. If duplicates, keep the one with worst + SLA state (`OutOfSla` > `ApproachingSla` > `InSla`). +2. **Fuzzy dedup**: After exact dedup, check for items with the same `KpiId` and + overlapping title text. Items with the same KPI but targeting different services + (e.g., CFS pipeline items targeting 8 endpoints) should be **merged into one row** + with a note like "(8 endpoints)" rather than listed 8 times. + + **CRITICAL exception — never merge per-finding items with distinct work items.** + If two items share a `KpiId` but each item's `URL` (or `S360Dimensions.ADOWorkItemHTMLUrl`) + resolves to a **different** ADO work-item ID, they are **distinct findings** and MUST + be rendered as separate rows. Examples (verified — these KPIs publish one Bug per + finding): + - **Security Code Bugs** (KPI `a0f0ce42-3063-5d3b-3b47-1ff3143abdc9`, Nightwatch + findings): each item has its own pre-created Bug in ADO (linked via `URL`). Render + one row per finding, with `pbi` set to that Bug's ID — never collapse them under an + umbrella PBI. + - **SDL Annual Assessment** (KPI `2d6597da-8e08-4495-a4e1-954f7697a4a8`): every + finding has its own per-finding Bug linked via `URL`. Older versions of this + workflow silently merged all SDL items into one row per service, hiding many + distinct Bugs — do not regress. + - **Accessibility bugs**, **BinSkim per-rule findings**, and similar per-finding + KPIs follow the same rule. + + Merging is only appropriate when the items represent the **same logical work** + (e.g., the same remediation applied to N targets, with no per-target work item). + When in doubt, **do not merge** — rendering N rows is recoverable; merging hides + distinct bugs and is the error mode this exception exists to prevent. + +**Presentation note for high-volume per-finding KPIs**: When a single KPI legitimately +produces many rows (e.g. 13 Nightwatch Bugs, 22 SDL Bugs), `generate-report.js` may +render them under a single program header with a count badge and individual AB# +links per row — but each row MUST remain individually present in the reducer's +output (`reduced.json`). The reducer's job is data fidelity; visual rollup is the +report's concern. + +### Step 3: Find Existing Work Items (PBIs or Bugs) + +Before creating any new PBIs, search for existing ADO work items that already +track each S360 item — these can be **either Product Backlog Items or Bugs**. +The team frequently files Bugs for S360 items that represent regressions or +defects (especially security/compliance items), and the skill must match those +just like it matches PBIs. **Do not restrict any lookup to `Product Backlog Item` +only.** Every search and merge step below applies equally to both work-item types. + +#### 3a: Pull last week's S360 email + +**Method 1: User-provided report** (from Step 0b) — If the user already pasted last +week's report, use the parsed map directly. This is the most reliable source and avoids +issues with Purview-encrypted emails or WorkIQ failures. **Skip Methods 2–3 entirely.** + +**Method 2: WorkIQ** (fallback if user skipped Step 0b) — Call `mcp_workiq_ask_work_iq`: +``` +question: "Find the most recent email from the last 7 days with subject containing 'S360 Weekly Report' sent to androididentity@microsoft.com. Return the full email body content including any AB# work item references." +``` + +**Method 3: Ask user** (fallback if WorkIQ errors) — Use `ask_user` to ask the user +to paste last week's report content. + +Parse the email/report body for: +- **AB# references** (e.g., `AB#12345`) — extract the number and the S360 item title nearby. + AB# is type-agnostic — the referenced work item may be a PBI **or a Bug** (or any other type). +- **Work item links** — ADO URLs like `dev.azure.com/.../workitems/12345` (also type-agnostic) +- **Item titles with owners** — build a title → (AB#, owner) map +- Both literal phrases **`Product Backlog Item 12345`** and **`Bug 12345`** map to the same + AB# number; capture either + +Build a map of **S360 item title → AB# number** from the previous report. +These are known-good PBI assignments from last week. + +If all methods fail, skip this step and continue with Step 3b. Do not fail the workflow. + +#### 3b: Search ADO for existing S360 work items (tag/title search) + +Search for work items that are tagged `s360` OR have `S360` in the title. +The WIQL below intentionally queries `WorkItems` (not `WorkItemLinks` and not +type-filtered) so it returns **both Bugs and PBIs** — do not add a +`[System.WorkItemType]` filter here: + +``` +SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo], [System.WorkItemType] +FROM WorkItems +WHERE ([System.Tags] CONTAINS 's360' + OR [System.Title] CONTAINS 'S360') +AND [System.State] <> 'Done' +AND [System.State] <> 'Removed' +ORDER BY [System.CreatedDate] DESC +``` + +Capture `System.WorkItemType` for each result so downstream steps know whether +each matched item is a Bug or a PBI (useful in the report and for Step 4b). + +If the WIQL tool is unavailable, search via `mcp_ado_wit_my_work_items` and filter +results for titles containing `S360` or `[S360]`. That tool also returns all +work-item types by default — do **not** restrict to PBIs. + +**Also**: if Step 3a returned AB# numbers, call `mcp_ado_wit_get_work_items_batch_by_ids` +to fetch their current state. This catches work items from last week's email that may +have been resolved since then — if state is `Done` or `Removed`, mark the S360 item as +already handled and exclude it from the "needs PBI" list. Works for both Bugs and PBIs. + +Build a map of **S360 item title → AB# number + state + work-item type** from ADO. + +#### 3c: Keyword-based search for pre-existing work items (CRITICAL) + +**Why this step exists**: Team members often create work items for S360 items +manually — without the `[S360]` prefix or tag, and frequently as **Bugs** rather +than PBIs (especially for security/compliance defects). Step 3b will MISS these +if the tag/title isn't present. Skipping this step creates **duplicate work +items**. This step is mandatory. + +For each S360 item that was NOT matched in 3a or 3b, perform a **keyword search** +using the ADO work item search tool (`search_workitem`). **Explicitly include +both `Product Backlog Item` and `Bug` work-item types** — do not let the tool +default to PBIs only: + +``` +search_workitem( + project: "Engineering", + areaPath: "Engineering\\Auth Client\\Broker\\Android", + searchText: "<2-4 distinctive keywords from the item title>", + workItemType: ["Product Backlog Item", "Bug"], + state: ["Committed", "New", "Active", "In Progress"], + top: 5 +) +``` + +If the `search_workitem` tool does not accept a `workItemType` parameter, run +the search without a type filter (the tool's default returns all types). Do +**NOT** post-filter the results down to PBIs only — keep Bug hits. + +**Keyword extraction rules:** +- Use the most distinctive 2–4 words from the S360 item title +- Omit generic words like "required", "should", "the", "for", "is" +- Examples: + - "MISE Compliance - 1.31.0+ [Wave 10]" → search: `"MISE Compliance Wave 10"` + - "AuthN SDK - MSAL Android is required to onboard in Trusted Platform OneCompliance" → search: `"OneCompliance Trusted Platform onboard"` + - "Establish and document a patch management process for DexGuard" → search: `"DexGuard patch management"` + +**Matching logic:** +- If a result has the **same core meaning** as the S360 item (even without `[S360]` + prefix), it's a match. Use title similarity — if 3+ significant words overlap, + treat it as the same item. +- Record the pre-existing AB#, assignee, **and `System.WorkItemType`** (Bug or PBI). +- Bugs and PBIs are equally valid matches — the team uses Bugs for many security + S360 items, so a Bug hit must be treated as "existing" just like a PBI hit. + +**Batch for efficiency**: Group items into batches of 3–5 keyword searches at a time +to reduce round-trips. Items that are very unique (e.g., on-call checklists with +person names) can skip this step as they're unlikely to have pre-existing work items. + +**Mark matched items as `existing`** — do NOT create new PBIs for them, regardless +of whether the existing item is a Bug or a PBI. + +#### 3d: Merge work-item maps + +For each S360 item, check if a work item (PBI or Bug) exists from any source: +1. **ADO ID parsed from the S360 `URL` field** — if `URL` matches + `dev.azure.com/.../_workitems/edit/(\d+)` or `*.visualstudio.com/.../_workitems/edit/(\d+)`, + extract the ID. This is the **per-item Bug/PBI created by the S360 publisher** and is + the most specific match available. Common for security KPIs (Nightwatch, accessibility, + etc.) where each finding gets its own auto-filed Bug. +2. The S360 API field `S360Dimensions.ADOWorkItemHTMLUrl` (legacy linked-item field — + often empty for newer KPIs that use `URL` instead; the linked item may be a PBI or a Bug; + the URL doesn't encode the type) +3. Last week's email AB# references (from 3a) — confirmed human assignments +4. ADO tag/title search results (from 3b) — items explicitly tagged S360 (Bug or PBI) +5. ADO keyword search results (from 3c) — catches manually-created items (Bug or PBI) + without the S360 tag + +**Priority: URL-parsed ADO ID (source 1) > ADOWorkItemHTMLUrl (source 2) > last week's +email > keyword search (3c) > tag search (3b).** + +Rationale: A per-item ADO link in the `URL` field is the most authoritative match — it +is the actual Bug/PBI the publisher created for that specific finding. Falling back to +keyword/tag search for these items risks matching an unrelated umbrella PBI (e.g., one +PBI for the whole KPI) and losing the per-bug granularity. + +**Title matching logic** (for 3a, 3b, 3c): +- **Normalize both titles identically before comparing**: strip `[S360]` prefix, replace + all non-alphanumeric characters (hyphens, underscores, brackets, etc.) with spaces, + collapse multiple spaces to one, trim whitespace, lowercase. Apply the **same** + normalizer to both the S360 title and the ADO/last-week-report title — mismatches + occur when one side keeps punctuation (e.g., hyphens) and the other strips it. +- Match if the normalized S360 title is **contained within** the normalized ADO title, + or vice versa +- For keyword search (3c): match if 3+ significant words overlap between titles +- Example: S360 `"MISE Compliance - 1.31.0+ [Wave 10]"` matches + ADO `"[S360] MISE Compliance - 1.31.0+ [Wave 10]"` after normalization + +Mark each item's status: `existing` (with AB# + URL + work-item type), `needs-creation`, +or `resolved` (work item exists but is Done/Removed — skip from report). + +#### 3e: Override Owners (and Titles) from ADO Work Items + +After matching is complete, for every item that has an existing work item (from any +source), fetch the `System.AssignedTo` field and **override the item's owner** with +the ADO assignee. This applies whether the matched work item is a PBI or a Bug — +both have a `System.AssignedTo` field. This ensures the report reflects the actual +owner, not the S360 default. + +1. Collect all work-item IDs from matched items (Bugs and PBIs) +2. Call `mcp_ado_wit_get_work_items_batch_by_ids` with fields + `["System.Id", "System.Title", "System.AssignedTo", "System.WorkItemType", "System.State"]` +3. For each item with a matched work item: + - **Skip the assignee override if the ADO assignee is a bot / automation account.** Treat as + a bot if any of the following is true: + - `System.AssignedTo.displayName` contains `Copilot`, `Bot`, `Service`, `Agent`, + or `Automation` (case-insensitive) + - `System.AssignedTo.uniqueName` starts with `sc-` or `SC-` (non-EA service accounts) + - The display name matches a known automation identity (e.g., `GitHub Copilot`) + + For bot-assigned items, **keep the S360-sourced `ActionOwnerAlias`** — a bot + assignment means "no human owner yet", not "the bot owns this work". Common case: + Nightwatch security Bugs are auto-filed by the GitHub Copilot bot. + - Otherwise: extract alias from ADO `System.AssignedTo` (strip `@microsoft.com`), + set `ownerAlias` = ADO alias, `ownerName` = ADO display name. + - **If the row is flagged with `usesGenericS360Title: true`** (set by `reduce-items.js` + Pass 7), substitute the ADO `System.Title` for the row's `Title` so the report + shows the actual finding instead of the umbrella label. Apply the same title + sanitization (GUID → service name, etc.) afterward. Example: SDL Annual Assessment + (KPI `2d6597da-…`) publishes 22 rows all titled "SDL Annual Assessment", each + linked to a distinct ADO Task — without this substitution every row would read + "SDL Annual Assessment"; with it, rows read like "Use only approved cryptographic + hash functions", "All issues identified by the Attack Surface Analyzer (ASA) tool + must be fixed", etc. +4. Items WITHOUT a matched work item keep their S360-sourced owner and title (from Step 2) + +**Rationale (assignee)**: The S360 `ActionOwnerAlias` often defaults to the service dev +owner or a team lead, while the ADO work item has been explicitly assigned to the person +doing the work. The ADO assignment is more accurate — *unless* the ADO assignee is a bot, +in which case the S360 owner is the better signal for the report. + +**Rationale (title)**: Some S360 KPIs (notably SDL Annual Assessment) reuse one generic +umbrella `Title` across every sub-item while linking each one to its own per-finding +ADO Task with a descriptive `System.Title`. Showing the umbrella title makes every row +indistinguishable; substituting the ADO title makes the report actionable. + +### Step 4: Create Missing PBIs + +For items marked `needs-creation` (i.e., items where Step 3 found **neither** a +matching PBI **nor** a matching Bug): + +> **Always create new items as Product Backlog Items**, never as Bugs. The skill +> only converts unmatched S360 items into PBIs — existing Bugs are matched and +> reused (Step 3), but we don't file new Bugs from this skill. + +1. **Show a summary** of what will be created (no confirmation needed — always create): + ``` + Creating PBIs for 14 S360 items: + - [Title 1] — Owner: alias — SLA: OutOfSla — Priority 1 + - [Title 2] — Owner: alias — SLA: InSla — Priority 3 + ... + ``` + +2. **Use default ADO configuration:** + - **Area Path**: `Engineering\Auth Client\Broker\Android` + - **Iteration**: Compute from the current date (see formula below) + - **State**: `Committed` + - **Tags**: `S360; AI-Generated` + - **Priority**: `1` for OutOfSla, `2` for ApproachingSla, `3` for InSla + + **Iteration computation** (from current date): + ``` + month = current month (1-12) + year2 = last 2 digits of year (e.g., 26) + quarter = ceil(month / 3) (1-4) + half = quarter <= 2 ? 1 : 2 + monthAbbr = Jan|Feb|...|Dec + + path = Engineering\CY{year2}\CY{year2}H{half}\CY{year2}Q{quarter}\Monthly\CY{year2}Q{quarter}_M{month}_{monthAbbr} + ``` + Example for April 2026: `Engineering\CY26\CY26H1\CY26Q2\Monthly\CY26Q2_M4_Apr` + +3. For each item, create a PBI via `mcp_ado_wit_create_work_item`: + ```json + { + "project": "Engineering", + "workItemType": "Product Backlog Item", + "fields": [ + {"name": "System.Title", "value": "[S360] "}, + {"name": "System.Description", "value": "", "format": "Html"}, + {"name": "System.AreaPath", "value": ""}, + {"name": "System.IterationPath", "value": ""}, + {"name": "System.AssignedTo", "value": "@microsoft.com"}, // OMIT this field if owner is empty — leave PBI unassigned + {"name": "Microsoft.VSTS.Common.Priority", "value": "<1=OutOfSla, 2=Approaching, 3=InSla>"}, + {"name": "System.State", "value": "Committed"}, + {"name": "System.Tags", "value": "S360; AI-Generated"} + ] + } + ``` + The description HTML should include: + - S360 item title and service name + - SLA state and due date + - Link to S360 URL for remediation details + - Current status notes (if any) + - Wave information (if any) + +4. Record the created AB# for each item. + +### Step 4b: Auto-Close Resolved Work Items + +For each item in the **resolved items list** (from Step 1d) that has an associated ADO +work item (PBI **or Bug** — applies to both): + +1. Look up the work item state via `mcp_ado_wit_get_work_items_batch_by_ids` +2. If the state is NOT `Done` or `Removed`, transition it to `Done` (works for both + Bugs and PBIs — both types support the `Done` state in the Engineering project): + ``` + mcp_ado_wit_update_work_item( + id: , + updates: [{ path: "/fields/System.State", value: "Done" }] + ) + ``` +3. Log which work items were auto-closed for the report's "Resolved" section + (include the type — e.g., `Bug AB#12345` vs `PBI AB#67890`) + +If no work item is associated with a resolved item, skip it (no action needed). + +### Step 5: Generate HTML Report + +Use the **committed generator script** at `.github/skills/s360-reporter/generate-report.js` +to produce the HTML report. This script is data-driven — you prepare a JSON input file and +the script handles all HTML rendering, Outlook compatibility, and styling. + +#### 5a: Prepare JSON input file + +Write a JSON file to a temp location (e.g., `$env:TEMP/s360_data.json`) with this schema: + +```json +{ + "reportDate": "YYYY-MM-DD", + "items": [ + { + "title": "S360 item title", + "shortTitle": "Abbreviated title (optional — generator falls back to title if null)", + "service": "Service name (e.g., MSAL Android)", + "ownerAlias": "alias", + "ownerName": "Full Name", + "sla": "OutOfSla | ApproachingSla | InSla", + "due": "Mon DD, YYYY", + "eta": "Mon DD, YYYY or null", + "pbi": "12345 or null (ADO work item ID — PBI or Bug; generator adds AB# prefix)", + "isNew": true, + "s360Url": "https://s360.msftcloudes.com/...", + "program": "Program display name", + "programDesc": "Program subtitle/description (optional)", + "subtitle": "Wave or campaign name (optional — renders inside TITLE column, do NOT set to service name since Service has its own column; leave null unless wave/campaign info is available)" + } + ], + "resolved": [ + { + "title": "Resolved item title", + "assignee": "alias (just the alias — generator looks up display name from nameMap)", + "pbi": "12345 or null (ADO work item ID — PBI or Bug; generator adds AB# prefix)" + } + ], + "nameMap": { + "alias": "Full Name" + }, + "newItems": [ + { "title": "New item title", "service": "Service name" } + ] +} +``` + +**Field notes:** +- `items`: All active S360 items from Steps 1–4 with work-item info attached +- `resolved`: Items from Step 1d that are no longer active +- `nameMap`: Alias → display name mapping from Step 0 (used for ownership table) +- `newItems`: Items not found in last week's email (new this week) — for the info callout +- `isNew`: Set `true` for newly created PBIs (shows 🆕 badge) +- `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. + +#### 5b: Run the generator + +```powershell +node .github/skills/s360-reporter/generate-report.js --input "$env:TEMP/s360_data.json" --output "C:\Users\shjameel\Desktop\s360-report-{date}.html" +``` + +The script produces a fully styled Outlook-compatible HTML report with: +- Header with team name, date badge, and service list +- Summary cards (Total, Out of SLA, Approaching, In SLA, No ETA) +- Severity distribution bar +- "New This Week" info callout (if any new items) +- Resolved items section +- Needs Attention cards for Out of SLA items +- Items by Compliance Area tables (grouped by program, sorted by severity) +- Ownership breakdown table +- Action Required callouts (missing owners, missing ETAs, new PBIs) +- Footer with dashboard links + +**Visual reference**: See `report-template.md` for the HTML building blocks, color palette, +and design rationale. The generator script implements these blocks programmatically. + +**Fallback**: If Node.js is unavailable, fall back to manually assembling HTML using the +building blocks in `report-template.md` — copy them verbatim and substitute placeholders. + +### Step 6: Save and Preview + +1. **Save HTML** to `C:\Users\shjameel\Desktop\s360-report-{date}.html` + +2. **Open in browser** for preview using `Start-Process` in terminal + +3. Tell the user: + ``` + Report saved to Desktop and preview opened in browser. + To send: Open the browser preview → Select All (Ctrl+A) → Copy (Ctrl+C) → + New Outlook email to androididentity@microsoft.com → Paste (Ctrl+V) → Send. + ``` + +## SLA State Sort Order + +1. `OutOfSla` (most urgent) +2. `ApproachingSla` +3. `InSla` + +Within same SLA state, sort by `CurrentDueDate` ascending (earliest due first). + +## Edge Cases + +- **M365 User MCP unavailable**: Fall back to ADO Teams API (see Step 0 fallback). If both + are unavailable, skip person-targeted search and proceed with service-targeted items only. + Add a callout in the report noting that on-call items may be missing. +- **S360 MCP auth failure**:Instruct user to restart MCP server via Command Palette → + `MCP: Restart Server` → `s360-breeze-mcp` +- **WorkIQ unavailable or no email found**: Skip Step 3a entirely; rely on S360 API field + and ADO search only. Do not fail the workflow. +- **WorkIQ returns emails older than 7 days**: The query is scoped to "last 7 days" to + ensure freshness. If no email is found within that window, proceed without previous report data. +- **ADO MCP unavailable**: Skip PBI creation; generate report with "None" in PBI column + and a callout noting ADO was unavailable. +- **No items found**: Generate a celebratory "all clear" report +- **Multiple pages**: Paginate using `nextCursor` until all items are fetched +- **Owner alias empty**: Show "Unassigned" in the report and flag for attention. + When creating PBIs, **omit the `System.AssignedTo` field entirely** — do NOT fall + back to the manager or `AssignedTo` from S360. Leave the PBI unassigned so the team + can triage it manually. +- **Work item (PBI or Bug) already exists but title doesn't match exactly**: Use fuzzy matching — if an ADO + item title contains the S360 item title (ignoring the `[S360]` prefix), consider it a match. +- **Node.js unavailable**: Fall back to manually assembling HTML from `report-template.md` + building blocks. Copy blocks verbatim and substitute placeholders. +- **Iteration computation edge cases**: The formula `CY{YY}Q{Q}_M{M}_{Mon}` uses calendar + month number (M=1–12). At year boundaries (Dec→Jan), ensure year rolls over. At quarter + boundaries, ensure Q increments correctly (e.g., March=Q1, April=Q2). diff --git a/.github/skills/s360-reporter/generate-report.js b/.github/skills/s360-reporter/generate-report.js new file mode 100644 index 00000000..664f7131 --- /dev/null +++ b/.github/skills/s360-reporter/generate-report.js @@ -0,0 +1,525 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// S360 Weekly Report Generator +// Reads a JSON data file and produces an Outlook-compatible HTML report. +// +// Usage: +// node generate-report.js --input data.json --output report.html +// node generate-report.js --input data.json (writes to stdout) +// +// Input JSON schema: +// { +// "reportDate": "2026-04-08", // ISO date string +// "items": [{ ... }], // Active S360 items (see below) +// "resolved": [{ title, assignee, pbi? }], // Resolved since last week +// "nameMap": { "alias": "Full Name" }, // Alias → display name +// "newItems": [{ title, service }] // NEW this week (for diff section) +// } +// +// Item shape: +// { +// title, shortTitle, service, ownerAlias, ownerName, +// sla ("OutOfSla"|"ApproachingSla"|"InSla"), +// due (ISO date), eta (ISO date|null), +// pbi (number|null), isNew (boolean), +// s360Url, program, programDesc, subtitle +// } + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// ── CLI Args ────────────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +function getArg(name) { + const idx = args.indexOf('--' + name); + return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : null; +} + +const inputPath = getArg('input'); +const outputPath = getArg('output'); + +if (!inputPath) { + console.error('Usage: node generate-report.js --input [--output ]'); + process.exit(1); +} + +// ── Load Data ───────────────────────────────────────────────────────────────── +const data = JSON.parse(fs.readFileSync(inputPath, 'utf8')); +const { reportDate, items, resolved = [], nameMap = {}, newItems = [] } = data; + +const reportDateObj = new Date(reportDate); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function fmtDate(d) { + if (!d) return null; + if (d === 'N/A') return 'N/A'; + return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function fmtDateLong(d) { + return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function daysBetween(a, b) { + return Math.round((new Date(b) - new Date(a)) / 86400000); +} + +function pbiUrl(id) { + return `https://dev.azure.com/IdentityDivision/Engineering/_workitems/edit/${id}`; +} + +function esc(s) { + if (!s) return ''; + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function slaStyle(sla) { + if (sla === 'OutOfSla') return { + rowBg: '#fff5f5', rowBgAlt: '#fff5f5', leftBorder: '#cf222e', + badgeBg: '#cf222e', badgeColor: '#fff', label: 'MISSED' + }; + if (sla === 'ApproachingSla') return { + rowBg: '#fff8f0', rowBgAlt: '#fff8f0', leftBorder: '#e65100', + badgeBg: '#e65100', badgeColor: '#fff', label: 'NEAR SLA' + }; + return { + rowBg: '#ffffff', rowBgAlt: '#fafafa', leftBorder: '#2e7d32', + badgeBg: '#e8f5e9', badgeColor: '#2e7d32', label: 'IN SLA' + }; +} + +// ── Statistics ───────────────────────────────────────────────────────────────── +const total = items.length; +const outCount = items.filter(i => i.sla === 'OutOfSla').length; +const nearCount = items.filter(i => i.sla === 'ApproachingSla').length; +const inCount = items.filter(i => i.sla === 'InSla').length; +const noEta = items.filter(i => !i.eta).length; +const newPbiCount = items.filter(i => i.isNew).length; + +const outPct = total > 0 ? Math.max(Math.round(outCount / total * 100), outCount > 0 ? 5 : 0) : 0; +const nearPct = total > 0 ? Math.max(Math.round(nearCount / total * 100), nearCount > 0 ? 8 : 0) : 0; +const inPct = Math.max(100 - outPct - nearPct, 0); + +// ── Group by Program ────────────────────────────────────────────────────────── +const programs = {}; +items.forEach(it => { + if (!programs[it.program]) programs[it.program] = { items: [], desc: it.programDesc, worstSla: 3 }; + programs[it.program].items.push(it); + const s = it.sla === 'OutOfSla' ? 1 : it.sla === 'ApproachingSla' ? 2 : 3; + if (s < programs[it.program].worstSla) programs[it.program].worstSla = s; +}); +const sortedPrograms = Object.entries(programs).sort((a, b) => a[1].worstSla - b[1].worstSla); + +// ── Ownership ───────────────────────────────────────────────────────────────── +const owners = {}; +items.forEach(it => { + if (!owners[it.ownerAlias]) owners[it.ownerAlias] = { name: it.ownerName, total: 0, out: 0, near: 0, inSla: 0, noEta: 0 }; + owners[it.ownerAlias].total++; + if (it.sla === 'OutOfSla') owners[it.ownerAlias].out++; + else if (it.sla === 'ApproachingSla') owners[it.ownerAlias].near++; + else owners[it.ownerAlias].inSla++; + if (!it.eta) owners[it.ownerAlias].noEta++; +}); +const sortedOwners = Object.entries(owners).sort((a, b) => { + if (b[1].out !== a[1].out) return b[1].out - a[1].out; + if (b[1].near !== a[1].near) return b[1].near - a[1].near; + return b[1].total - a[1].total; +}); + +// ── Build HTML ──────────────────────────────────────────────────────────────── +let html = ''; + +const shortDate = fmtDateLong(reportDate); + +// Page wrapper + header +html += ` + + + + + +S360 Weekly Report \u2014 ${shortDate} + + + + +`; +}); + +html += divider; + +// Ownership Breakdown +html += ` + + +`; + +html += divider; + +// Action Required callouts +const noEtaItems = items.filter(i => !i.eta); +const newPbiItems = items.filter(i => i.isNew); + +html += ` +`; + +// Footer +html += ` + + +`; + +// Close wrapper +html += ` +
+ + + + + +`; + +// Summary cards +const cards = [ + { count: total, label: 'Total Items', bg: '', border: '#e1e4e8', accent: '#0078d4' }, + { count: outCount, label: 'Out of SLA', bg: '#fef2f2', border: '#fca5a5', accent: '#cf222e' }, + { count: nearCount, label: 'Approaching', bg: '#fffbeb', border: '#fcd34d', accent: '#e65100' }, + { count: inCount, label: 'In SLA', bg: '#f0fdf4', border: '#86efac', accent: '#2e7d32' }, + { count: noEta, label: 'No ETA', bg: '#fffbeb', border: '#fcd34d', accent: '#bf8700' } +]; + +html += `\n`; + +// Severity bar +if (total > 0) { + html += ` + + +`; +} + +// Divider helper +const divider = ` +`; + +html += divider; + +// Resolved Since Last Week callout +html += ` + + +`; + +// Week-over-week diff: new items this week +if (newItems.length > 0) { + html += ` + + +`; +} + +// Needs Attention (Out of SLA cards) +const outOfSlaItems = items.filter(i => i.sla === 'OutOfSla'); +if (outOfSlaItems.length > 0) { + html += ` + + +`; +} + +html += divider; + +// Items by Compliance Area +sortedPrograms.forEach(([progName, prog]) => { + const count = prog.items.length; + html += ` + + + + + + + + + + `; + }); + + html += ` +
+ + +
 
+
+ + + + + +
+

S360 Weekly Report

+

Android Auth Team

+
+ + +
+ Week of ${shortDate} +
+
+

+ Services: AuthN SDK - MSAL AndroidAuthN SDK - ADAL AndroidMicrosoft Authenticator - Android +

+
+ + `; +cards.forEach(c => { + const bgAttr = c.bg ? ` bgcolor="${c.bg}"` : ''; + html += ` + `; +}); +html += ` + +
+ + +
+

${c.count}

+

${c.label}

+
+
+
+ + `; + if (outCount > 0) html += ` + `; + if (nearCount > 0) html += ` + `; + if (inCount > 0) html += ` + `; + html += ` + +
${outCount}${nearCount}${inCount} In SLA
+
+ + +
 
+
+ + + + + +
  +

✅ Resolved Since Last Week (${resolved.length} items)

+

`; +if (resolved.length > 0) { + resolved.forEach(r => { + const displayName = nameMap[r.assignee] || r.assignee; + const pbiLink = r.pbi ? ` — AB#${r.pbi}` : ''; + html += `${esc(r.title)}${pbiLink} — ${esc(displayName)} (${esc(r.assignee)}) — Done
`; + }); +} else { + html += `No resolved items were detected this week.`; +} +html += `

+
+
+ + + + + +
  +

📥 New This Week (${newItems.length} items)

+

`; + newItems.forEach(n => { + html += `${esc(n.title)} — ${esc(n.service)}
`; + }); + html += `

+
+
+

Needs Attention

+

Items past due or approaching their SLA deadline

+ + +
 
`; + + outOfSlaItems.forEach(item => { + const daysOverdue = daysBetween(item.due, reportDate); + const etaDisplay = item.eta + ? fmtDate(item.eta) + : `\u26a0 No ETA`; + const newBadge = item.isNew ? '🆕' : ''; + const pbiChip = item.pbi + ? `AB#${item.pbi} ${newBadge}` + : 'None'; + + html += ` + + + + +
+ + + + + +
+

+ ${esc(item.title)} +

+ + + + + +
MISSED SLA${esc(item.service)}
+

Due: ${fmtDate(item.due)} (${daysOverdue} days overdue)

+

ETA: ${etaDisplay}

+

Owner: ${esc(item.ownerName)} (${esc(item.ownerAlias)})

+
+ + +
+ ${pbiChip} +
+

${esc(item.program)}

+
+
`; + }); + + html += ` +
+

${esc(progName)}

+

${esc(prog.desc)} — ${count} item(s)

+ + +
 
+ + + + + + + + + + `; + + prog.items.forEach((it, idx) => { + const s = slaStyle(it.sla); + const bg = idx % 2 === 0 ? s.rowBg : s.rowBgAlt; + const etaCell = it.eta + ? fmtDate(it.eta) + : `
TitleServiceOwnerSLADueETAPBI
No ETA
`; + const pbiCell = it.pbi + ? (it.isNew + ? `AB#${it.pbi} 🆕` + : `AB#${it.pbi}`) + : 'None'; + const dueBold = new Date(it.due) < reportDateObj + ? `${fmtDate(it.due)}` + : fmtDate(it.due); + + html += ` +
+ ${esc(it.shortTitle || it.title)}${it.subtitle ? `
${esc(it.subtitle)}` : ''} +
${esc(it.service)}${esc(it.ownerName)}
(${esc(it.ownerAlias)})
+
${s.label}
+
${dueBold}${etaCell}${pbiCell}
+
+

👥 Ownership Breakdown

+ + + + + + + + + `; + +sortedOwners.forEach(([alias, o], idx) => { + const zebra = idx % 2 === 0 ? '#ffffff' : '#fafafa'; + const outCellBg = o.out > 0 ? '#fef2f2' : zebra; + const outStyle = o.out > 0 ? 'font-weight:700; color:#cf222e;' : ''; + const nearCellBg = o.near > 0 ? '#fffbeb' : zebra; + const nearStyle = o.near > 0 ? 'font-weight:600;' : ''; + const etaStyle = o.noEta > 0 ? 'font-weight:600; color:#bf8700;' : ''; + + html += ` + + + + + + + + `; +}); + +html += ` +
AssigneeTotal🔴🟠🟢No ETA
${esc(o.name)} (${esc(alias)})${o.total}${o.out}${o.near}${o.inSla}${o.noEta}
+
+

Action Required

`; + +// Missing ETA callout +if (noEtaItems.length > 0) { + html += ` + + + + + +
  +

${noEtaItems.length} Items Missing ETA

+

${noEtaItems.map(i => `${esc(i.shortTitle || i.title)} (${esc(i.ownerAlias)})`).join(' • ')}

+
`; +} + +// New PBIs callout +if (newPbiItems.length > 0) { + html += ` + + + + + +
  +

${newPbiItems.length} New PBIs Created

+

${newPbiItems.filter(i => i.pbi).map(i => `AB#${i.pbi}`).join(' • ')}

+
`; +} + +html += `
+ + +
 
+ + + + + +

Auto-generated by S360 Reporter • ${shortDate}

+ S360 Dashboard • + ADO Board +

+
+ + + +`; + +// ── Output ──────────────────────────────────────────────────────────────────── +if (outputPath) { + fs.writeFileSync(outputPath, html, 'utf8'); + console.log('Report saved to ' + outputPath); + console.log('Size: ' + (Buffer.byteLength(html, 'utf8') / 1024).toFixed(1) + ' KB'); +} else { + process.stdout.write(html); +} diff --git a/.github/skills/s360-reporter/merge-items.js b/.github/skills/s360-reporter/merge-items.js new file mode 100644 index 00000000..419d1699 --- /dev/null +++ b/.github/skills/s360-reporter/merge-items.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// merge-items.js +// ---------------------------------------------------------------------------- +// Merges service-targeted and person-targeted S360 API responses, filters +// person-targeted items down to team-relevant ones, and deduplicates by +// KpiActionItemId. +// +// Implements SKILL.md Steps 1b (filter) and 1c (merge). Encoded as a script so +// the same logic runs every week — see Step 2's "CRITICAL exception" for the +// rationale. +// +// Usage: +// node merge-items.js --service service.json --person person.json \ +// --team team.json --output merged.json +// +// Inputs: +// --service Raw response from search_active_s360_kpi_action_items keyed by +// targetIds (the 3 Android Auth service tree GUIDs). +// --person Raw response from the same tool keyed by assignedTo (team +// aliases). +// --team JSON file: { aliases, nameMap, serviceIds?, tenantPatterns? }. +// serviceIds and tenantPatterns default to Android Auth values if +// omitted. +// --output Path to write the merged JSON array. If omitted, prints to stdout. +// +// Each response may be: +// • Full MCP envelope: { result: { resources: [...] } } +// • Mid envelope: { resources: [...] } +// • Bare array: [...] +// All three are accepted. +// +// Diagnostics (counts, dropped items) are written to stderr so stdout stays +// machine-parseable. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// ── Defaults ────────────────────────────────────────────────────────────────── +const DEFAULT_SERVICE_IDS = [ + '937cdc57-1253-4b55-878e-5854368926a2', // AuthN SDK - ADAL Android + '8d0d308e-cd5c-44a3-9518-43eeeb424b57', // AuthN SDK - MSAL Android + '0b97f26e-fcfc-4ed1-95e9-1dca3a2fde3b' // Microsoft Authenticator - Android +]; +const DEFAULT_TENANT_PATTERNS = ['auth client', 'msal', 'adal', 'authenticator']; + +// ── CLI args ────────────────────────────────────────────────────────────────── +function getArg(name) { + const i = process.argv.indexOf('--' + name); + return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : null; +} + +const servicePath = getArg('service'); +const personPath = getArg('person'); +const teamPath = getArg('team'); +const outputPath = getArg('output'); + +if (!servicePath || !personPath || !teamPath) { + console.error('Usage: node merge-items.js --service --person --team [--output ]'); + process.exit(1); +} + +// ── Load inputs ─────────────────────────────────────────────────────────────── +function loadResources(p) { + const j = JSON.parse(fs.readFileSync(p, 'utf8')); + 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; + throw new Error(`Could not find a resources array in ${p}. Expected one of: top-level array, { resources: [...] }, or { result: { resources: [...] } }`); +} + +const svcItems = loadResources(servicePath); +const perItems = loadResources(personPath); +const team = JSON.parse(fs.readFileSync(teamPath, 'utf8')); + +const teamAliases = new Set((team.aliases || []).map(a => String(a).toLowerCase())); +const serviceIds = new Set(((team.serviceIds && team.serviceIds.length) ? team.serviceIds : DEFAULT_SERVICE_IDS).map(s => String(s).toLowerCase())); +const tenantPatterns = (team.tenantPatterns && team.tenantPatterns.length) ? team.tenantPatterns : DEFAULT_TENANT_PATTERNS; + +if (teamAliases.size === 0) { + console.error('WARN: team.aliases is empty — every person-targeted item will be dropped unless it matches a service ID or tenant pattern.'); +} + +console.error(`Loaded ${svcItems.length} service-targeted items from ${path.basename(servicePath)}`); +console.error(`Loaded ${perItems.length} person/assignedTo items from ${path.basename(personPath)}`); +console.error(`Team: ${teamAliases.size} aliases, ${serviceIds.size} service IDs, ${tenantPatterns.length} tenant patterns`); + +// ── Filter person items to team-relevant ────────────────────────────────────── +// IMPORTANT: AssignedTo alone is NOT a sufficient signal. The person query +// already filters by assignedTo, so every item has a team-alias AssignedTo — +// but many of those items are for OTHER teams the person also belongs to. +// Require at least one direct relevance signal: +// • Person-targeted AND TargetId is a team alias (on-call style items) +// • TargetId is one of our service IDs (mis-bucketed service items) +// • TenantName matches one of our tenant patterns (catch-all by team name) +const droppedReasons = { noSignal: 0 }; +const droppedSamples = []; + +function isTeamRelevant(it) { + const tgt = String(it.TargetId || '').toLowerCase(); + const tenant = String((it.CustomDimensions && it.CustomDimensions.TenantName) || '').toLowerCase(); + + if (it.TargetType === 'Person' && teamAliases.has(tgt)) return true; + if (serviceIds.has(tgt)) return true; + if (tenant && tenantPatterns.some(p => tenant.includes(String(p).toLowerCase()))) return true; + + return false; +} + +const filteredPer = []; +for (const it of perItems) { + if (isTeamRelevant(it)) { + filteredPer.push(it); + } else { + droppedReasons.noSignal++; + if (droppedSamples.length < 5) { + droppedSamples.push({ + KpiActionItemId: it.KpiActionItemId, + Title: String(it.Title || '').slice(0, 80), + AssignedTo: it.AssignedTo, + TargetType: it.TargetType, + TargetId: it.TargetId + }); + } + } +} + +console.error(`Person items filtered to: ${filteredPer.length} (dropped ${droppedReasons.noSignal} as not team-relevant)`); +if (droppedSamples.length) { + console.error(`Sample dropped items (first ${droppedSamples.length}):`); + for (const d of droppedSamples) console.error(` - ${d.KpiActionItemId} | AssignedTo=${d.AssignedTo} | ${d.Title}`); +} + +// ── Merge + dedupe by KpiActionItemId ───────────────────────────────────────── +// Stable order: service items first, then filtered person items, both sorted +// by KpiActionItemId. Determinism is important — same input → same output. +function stableSort(items) { + return [...items].sort((a, b) => String(a.KpiActionItemId || '').localeCompare(String(b.KpiActionItemId || ''))); +} + +const combined = [...stableSort(svcItems), ...stableSort(filteredPer)]; + +const seen = new Map(); +let dupes = 0; +for (const it of combined) { + const id = it.KpiActionItemId || `__nokey__|${it.KpiId}|${it.TargetId}|${it.Title}`; + if (seen.has(id)) { + dupes++; + continue; + } + seen.set(id, it); +} +const merged = [...seen.values()]; + +console.error(`Merged unique: ${merged.length} (deduped ${dupes} cross-source duplicates)`); + +// ── Write output ────────────────────────────────────────────────────────────── +const out = JSON.stringify(merged, null, 2); +if (outputPath) { + fs.writeFileSync(outputPath, out); + console.error(`Wrote ${merged.length} items to ${outputPath}`); +} else { + process.stdout.write(out + '\n'); +} diff --git a/.github/skills/s360-reporter/reduce-items.js b/.github/skills/s360-reporter/reduce-items.js new file mode 100644 index 00000000..8b9a0d93 --- /dev/null +++ b/.github/skills/s360-reporter/reduce-items.js @@ -0,0 +1,360 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// reduce-items.js +// ---------------------------------------------------------------------------- +// Reduces merged S360 items into logical report rows. Implements the dedup +// rules from SKILL.md Step 2 — most importantly, the CRITICAL exception that +// items with distinct per-finding ADO work items must NEVER be merged into a +// single umbrella row. +// +// Background — the bug this script exists to prevent: +// 13 Nightwatch Security Code Bugs (all sharing the same KpiId) were merged +// into one umbrella row, hiding 13 distinct pre-created ADO Bugs. The fix is +// to key dedup on the per-item ADO work-item ID extracted from the S360 URL +// field — if it's non-null, the item gets its own row regardless of how many +// peers share the same KpiId/title. +// +// Usage: +// node reduce-items.js --input merged.json [--kpi-metadata kpi.json] \ +// --output reduced.json +// +// Inputs: +// --input JSON array of merged items (from merge-items.js). +// --kpi-metadata Optional JSON object: { "": "Display Name", ... }. +// Used to populate `ProgramName`. Falls back to item title. +// --output Path to write reduced rows. If omitted, prints to stdout. +// +// Diagnostics (warnings, coverage anomalies) go to stderr. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// ── Known per-finding KPIs ──────────────────────────────────────────────────── +// These KPIs publish one ADO Bug per finding. If any item under one of these +// KPIs is missing a per-item ADO URL, we DO NOT umbrella-merge it — we give it +// its own row keyed by KpiActionItemId so a temporary URL outage cannot +// silently collapse rows like the original Nightwatch bug. +const PER_FINDING_KPIS = new Set([ + 'a0f0ce42-3063-5d3b-3b47-1ff3143abdc9' // [SFI-PS3.1] Security Code Bugs (Nightwatch) + // Add more KPIs here as they are discovered. Examples likely to belong: + // - Accessibility per-issue bugs + // - BinSkim per-rule findings + // - SDL per-tool findings +]); + +// ── CLI args ────────────────────────────────────────────────────────────────── +function getArg(name) { + const i = process.argv.indexOf('--' + name); + return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : null; +} + +const inputPath = getArg('input'); +const kpiMetaPath = getArg('kpi-metadata'); +const outputPath = getArg('output'); + +if (!inputPath) { + console.error('Usage: node reduce-items.js --input [--kpi-metadata ] [--output ]'); + process.exit(1); +} + +const items = JSON.parse(fs.readFileSync(inputPath, 'utf8')); +const kpiMeta = kpiMetaPath ? JSON.parse(fs.readFileSync(kpiMetaPath, 'utf8')) : {}; +console.error(`Loaded ${items.length} items from ${path.basename(inputPath)}`); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// Strict ISO date parse. Accepts: +// • YYYY-MM-DD +// • YYYY-MM-DDTHH:MM:SS(.fff)?(Z|±HH:MM)? +// Returns the original string if valid, else null. Rejects arbitrary +// Date.parse-able strings (e.g. "Today at 5pm") to avoid accidental ETAs. +const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/; +function parseIsoDate(v) { + if (typeof v !== 'string') return null; + if (!ISO_DATE_RX.test(v)) return null; + const d = new Date(v); + return isNaN(d.getTime()) ? null : v; +} + +// Walk an object collecting all ISO-date values from any key containing "eta" +// (case-insensitive). Return the most recent (latest) one. SKILL.md Step 2 +// "ETA Field Resolution" — the S360 API doesn't reliably populate CurrentETA. +function resolveETA(obj) { + const found = []; // [{ path, value }] + function walk(o, depth, prefix) { + if (!o || typeof o !== 'object' || depth > 4) return; + for (const [k, v] of Object.entries(o)) { + if (v == null) continue; + const p = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'string' && /eta/i.test(k)) { + const iso = parseIsoDate(v); + if (iso) found.push({ path: p, value: iso }); + } else if (typeof v === 'object') { + walk(v, depth + 1, p); + } + } + } + walk(obj, 0, ''); + if (!found.length) return null; + // Deterministic: sort by value desc, then path asc. + found.sort((a, b) => b.value.localeCompare(a.value) || a.path.localeCompare(b.path)); + return found[0].value; +} + +// Extract an ADO work item ID from a URL. Matches the two common forms: +// https://dev.azure.com/{org}/{project}/_workitems/edit/12345 +// https://{org}.visualstudio.com/{project}/_workitems/edit/12345 +const WORKITEM_URL_RX = /_workitems\/edit\/(\d+)/i; +function parseWorkItemId(url) { + if (!url || typeof url !== 'string') return null; + const m = url.match(WORKITEM_URL_RX); + return m ? m[1] : null; +} + +// Normalize a title for grouping: strip leading GUIDs (Nightwatch CWE prefix), +// strip trailing "(Last validated ...)" / "(Last completed ...)" / "(IcM Team +// ...)" suffixes, strip trailing "- [ServiceName: ...]" markers, lowercase, +// collapse whitespace. +function baseTitle(t) { + let s = String(t || ''); + s = s.replace(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\s*-\s*/i, ''); + s = s.replace(/\s*\(.*?(validated|completed|IcM Team).*?\)/gi, ''); + s = s.replace(/\s*-\s*\[ServiceName:.*$/i, ''); + s = s.replace(/\s+/g, ' ').trim().toLowerCase(); + return s; +} + +// ── Pass 1: enrich each item with ADO work-item ID + ETA + source ───────────── +const enriched = items.map(it => { + const url = it.URL || ''; + const legacy = (it.S360Dimensions && it.S360Dimensions.ADOWorkItemHTMLUrl) || ''; + + const idFromUrl = parseWorkItemId(url); + const idFromLegacy = parseWorkItemId(legacy); + + let adoId = null; + let adoIdSource = null; + if (idFromUrl) { + adoId = idFromUrl; + adoIdSource = 'URL'; + if (idFromLegacy && idFromLegacy !== idFromUrl) { + console.error(`WARN: item ${it.KpiActionItemId} has conflicting work-item IDs (URL=${idFromUrl}, ADOWorkItemHTMLUrl=${idFromLegacy}). Using URL.`); + } + } else if (idFromLegacy) { + adoId = idFromLegacy; + adoIdSource = 'ADOWorkItemHTMLUrl'; + } + + return { + raw: it, + adoId, + adoIdSource, + eta: resolveETA(it), + baseTitle: baseTitle(it.Title) + }; +}); + +// ── Pass 2: detect reused (shared/umbrella) ADO IDs ─────────────────────────── +// If the same ADO ID appears across multiple distinct (KpiId, baseTitle, +// TargetId) tuples, it's a template/umbrella work item, NOT a per-finding bug. +// Don't let it collapse unrelated rows into one — fall back to non-WI grouping +// for those items but keep the ADO ID on each row for display. +const tuplesById = new Map(); +for (const e of enriched) { + if (!e.adoId) continue; + const tuple = `${e.raw.KpiId}|${e.baseTitle}|${e.raw.TargetId}`; + if (!tuplesById.has(e.adoId)) tuplesById.set(e.adoId, new Set()); + tuplesById.get(e.adoId).add(tuple); +} +const reusedIds = new Set(); +for (const [id, tuples] of tuplesById) { + if (tuples.size > 1) { + reusedIds.add(id); + console.error(`WARN: ADO work-item ${id} is referenced by ${tuples.size} distinct (KpiId,baseTitle,TargetId) tuples — treating as shared/umbrella, not per-finding.`); + } +} + +// ── Pass 3: URL coverage diagnostics for known per-finding KPIs ─────────────── +const perFindingCoverage = new Map(); // kpiId -> { total, withId } +for (const e of enriched) { + if (!PER_FINDING_KPIS.has(e.raw.KpiId)) continue; + const c = perFindingCoverage.get(e.raw.KpiId) || { total: 0, withId: 0 }; + c.total++; + if (e.adoId && !reusedIds.has(e.adoId)) c.withId++; + perFindingCoverage.set(e.raw.KpiId, c); +} +for (const [kpiId, c] of perFindingCoverage) { + if (c.withId < c.total) { + const pct = ((c.withId / c.total) * 100).toFixed(0); + console.error(`WARN: per-finding KPI ${kpiId} has ADO URL coverage ${c.withId}/${c.total} (${pct}%). Items without a URL will get their own rows (keyed by KpiActionItemId) rather than umbrella-merging — safer default.`); + } +} + +// ── Pass 4: compute group keys ──────────────────────────────────────────────── +// +// Group-key rules, in priority order: +// 1. If item has a non-null adoId AND that ID is NOT reused across unrelated +// tuples → groupKey = `wi:` (per-finding: each work item = one row) +// 2. Else if KpiId is in PER_FINDING_KPIS (known per-finding KPI) → groupKey +// = `pf:` (every item gets its own row; missing URL must +// not cause umbrella collapse) +// 3. Else → groupKey = `nowi:||` (umbrella merge +// by KPI + normalized title + target — the CFS-pipeline-style case) +// +// For category (1), the ADO ID becomes the grouping authority. For (3), +// multiple items become one row with `count = N` for display. +function computeGroupKey(e) { + if (e.adoId && !reusedIds.has(e.adoId)) return `wi:${e.adoId}`; + if (PER_FINDING_KPIS.has(e.raw.KpiId)) return `pf:${e.raw.KpiActionItemId}`; + const tgt = e.raw.TargetType === 'Person' ? `Person:${e.raw.TargetId}` : (e.raw.TargetId || ''); + return `nowi:${e.raw.KpiId}|${e.baseTitle}|${tgt}`; +} + +const groups = new Map(); // groupKey -> enriched[] +for (const e of enriched) { + const k = computeGroupKey(e); + if (!groups.has(k)) groups.set(k, []); + groups.get(k).push(e); +} + +// ── Pass 5: pick representative per group + emit row ────────────────────────── +const SLA_RANK = { OutOfSla: 0, ApproachingSla: 1, InSla: 2 }; + +function pickRepresentative(group) { + // Worst SLA first, then earliest due date, then deterministic tiebreak on + // KpiActionItemId (so identical input always yields identical reps). + return [...group].sort((a, b) => { + const ra = SLA_RANK[a.raw.SLAState] ?? 9; + const rb = SLA_RANK[b.raw.SLAState] ?? 9; + if (ra !== rb) return ra - rb; + const da = String(a.raw.CurrentDueDate || ''); + const db = String(b.raw.CurrentDueDate || ''); + const dc = da.localeCompare(db); + if (dc !== 0) return dc; + return String(a.raw.KpiActionItemId || '').localeCompare(String(b.raw.KpiActionItemId || '')); + })[0]; +} + +const reduced = []; +for (const [groupKey, group] of groups) { + const rep = pickRepresentative(group); + const r = rep.raw; + // Pick the latest ETA across the group (safer than using the rep's alone — + // gives the most up-to-date commitment if any group member has one). This is + // a separate concern from the worst-SLA representative pick. + let groupEta = null; + for (const e of group) { + if (e.eta && (!groupEta || e.eta.localeCompare(groupEta) > 0)) groupEta = e.eta; + } + reduced.push({ + groupKey, + count: group.length, + KpiId: r.KpiId, + KpiActionItemId: r.KpiActionItemId, + Title: r.Title, + TargetType: r.TargetType, + TargetId: r.TargetId, + AssignedTo: r.AssignedTo, + ActionOwnerAlias: (r.S360Dimensions && r.S360Dimensions.ActionOwnerAlias) || r.AssignedTo || null, + ActionOwner: (r.S360Dimensions && r.S360Dimensions.ActionOwner) || null, + CurrentDueDate: r.CurrentDueDate, + SLAState: r.SLAState, + ETA: groupEta, + ADOWorkItemId: rep.adoId, + ADOWorkItemIdSource: rep.adoIdSource, // "URL" | "ADOWorkItemHTMLUrl" | null + ADOWorkItemHTMLUrl: (r.S360Dimensions && r.S360Dimensions.ADOWorkItemHTMLUrl) || null, + URL: r.URL || null, + ProgramName: kpiMeta[r.KpiId] || r.Title, + Wave: (r.CustomDimensions && r.CustomDimensions.S360_WavesMetadata && r.CustomDimensions.S360_WavesMetadata[0] && r.CustomDimensions.S360_WavesMetadata[0].WaveDisplayName) || null, + CurrentStatus: r.CurrentStatus || null, + CurrentStatusAuthor: r.CurrentStatusAuthor || null, + // Set to true by Pass 7 if every row sharing this row's KpiId also shares + // its exact Title AND each such row has a distinct ADOWorkItemId — meaning + // the S360 publisher reused one generic umbrella title across many distinct + // ADO work items. Step 3e of the workflow substitutes ADO System.Title for + // flagged rows so each row reads as its actual finding instead of the + // umbrella label (e.g. SDL Annual Assessment → "Use only approved hash..."). + usesGenericS360Title: false, + // Original IDs that fed into this row (for traceability + debugging) + underlyingActionItemIds: group.map(e => e.raw.KpiActionItemId).sort() + }); +} + +// ── Pass 6: deterministic output ordering ───────────────────────────────────── +// Sort: worst SLA, earliest due, program name, title, groupKey. Identical input +// must produce identical output across runs. +reduced.sort((a, b) => { + const ra = SLA_RANK[a.SLAState] ?? 9; + const rb = SLA_RANK[b.SLAState] ?? 9; + if (ra !== rb) return ra - rb; + const da = String(a.CurrentDueDate || ''); + const db = String(b.CurrentDueDate || ''); + const dc = da.localeCompare(db); + if (dc !== 0) return dc; + const pc = String(a.ProgramName || '').localeCompare(String(b.ProgramName || '')); + if (pc !== 0) return pc; + const tc = String(a.Title || '').localeCompare(String(b.Title || '')); + if (tc !== 0) return tc; + return a.groupKey.localeCompare(b.groupKey); +}); + +console.error(`Reduced ${items.length} items → ${reduced.length} logical rows`); +const collapsed = reduced.filter(r => r.count > 1); +if (collapsed.length) { + console.error(`Collapsed groups (count > 1): ${collapsed.length}`); + for (const r of collapsed) { + console.error(` [${r.count}x] ${r.SLAState} ${r.ProgramName.slice(0, 60)} :: ${String(r.Title).slice(0, 60)}`); + } +} + +// ── Pass 7: flag rows that share a generic umbrella S360 Title ──────────────── +// +// Some S360 publishers (e.g. SDL Annual Assessment) write one identical Title +// across every sub-item of a KPI but link each item to a distinct ADO work +// item with its own descriptive System.Title. Rendering "SDL Annual Assessment" +// on 22 separate rows is unhelpful — Step 3e of the workflow should substitute +// the ADO System.Title for each flagged row. We just identify the rows here; +// the actual ADO lookup happens downstream where the batch fetch already runs. +// +// Heuristic: within a KpiId, if N ≥ 2 rows share the EXACT same Title AND each +// of those rows has a distinct ADOWorkItemId, flag every row in that group with +// `usesGenericS360Title: true`. +const rowsByKpi = new Map(); +for (const r of reduced) { + if (!rowsByKpi.has(r.KpiId)) rowsByKpi.set(r.KpiId, []); + rowsByKpi.get(r.KpiId).push(r); +} +let genericFlaggedCount = 0; +for (const [kpiId, kpiRows] of rowsByKpi) { + if (kpiRows.length < 2) continue; + // Group by exact Title + const byTitle = new Map(); + for (const r of kpiRows) { + const t = String(r.Title || ''); + if (!byTitle.has(t)) byTitle.set(t, []); + byTitle.get(t).push(r); + } + for (const [title, group] of byTitle) { + if (group.length < 2) continue; + const distinctAdoIds = new Set(group.map(r => r.ADOWorkItemId).filter(Boolean)); + if (distinctAdoIds.size !== group.length) continue; // need 1:1 row→ADO ID + for (const r of group) { r.usesGenericS360Title = true; genericFlaggedCount++; } + console.error(`WARN: KPI ${kpiId} uses generic umbrella title "${title.slice(0, 60)}" across ${group.length} distinct ADO work items — flagged for Title substitution from ADO (Step 3e).`); + } +} +if (genericFlaggedCount) { + console.error(`Flagged ${genericFlaggedCount} rows with usesGenericS360Title=true (will pull descriptive ADO titles in Step 3e)`); +} + +// ── Write output ────────────────────────────────────────────────────────────── +const out = JSON.stringify(reduced, null, 2); +if (outputPath) { + fs.writeFileSync(outputPath, out); + console.error(`Wrote ${reduced.length} rows to ${outputPath}`); +} else { + process.stdout.write(out + '\n'); +} diff --git a/.github/skills/s360-reporter/report-template.md b/.github/skills/s360-reporter/report-template.md new file mode 100644 index 00000000..7d0e6133 --- /dev/null +++ b/.github/skills/s360-reporter/report-template.md @@ -0,0 +1,420 @@ +# S360 Report HTML Template Reference + +This file documents the HTML building blocks for generating the S360 weekly report. +The report is Outlook-compatible (uses tables for layout, `bgcolor` for colors, no CSS classes). + +## Design System + +### Color Palette + +| Purpose | Hex | Usage | +|---------|-----|-------| +| Primary accent | `#0078d4` | Header bar, blue pill, program underlines, info callout | +| Out of SLA | `#cf222e` | Cards, badges, left borders, row tint `#fff5f5` / `#fef2f2` | +| Approaching SLA | `#e65100` | Badges, left borders, row tint `#fff8f0` / `#fffbeb` | +| In SLA | `#2e7d32` | Badges, left borders, card tint `#f0fdf4` / `#e8f5e9` | +| Missing ETA | `#bf8700` | Badge bg, card tint `#fffbeb`, text color for warnings | +| Table header bg | `#e8edf2` | Column headers | +| Table header border | `#d0d7de` | Column header borders | +| Table cell border | `#e8e8e8` | All data cell borders | +| Zebra row | `#fafafa` | Alternating data rows | +| Body text | `#1a1a1a` | Headings | +| Link text | `#24292f` | Table title links | +| Link blue | `#0078d4` | PBI links, URL links | +| Resolved green | `#2da44e` | Resolved callout left bar | +| Muted text | `#656d76` | Subtle text | + +### Border Radius Values + +| Element | Radius | +|---------|--------| +| Main container | `12px` | +| Summary stat cards | `12px` | +| Severity bar | `6px` | +| Out of SLA card | `10px` | +| SLA/ETA badge pills | `4px` | +| Blue date pill (header) | `16px` | +| PBI chip (in card) | `16px` | +| MISSED SLA badge (in card) | `12px` | + +--- + +## Building Blocks + +### 1. Page Wrapper + +```html + + + + + + +S360 Weekly Report — {{DATE}} + + + + +
+ + + + +
+
+ + +``` + +### 2. Header + +Blue top bar + uppercase label + team name + blue date pill + services line. + +```html + + + +
 
+ + + + + + + + +
+

S360 Weekly Report

+

Android Auth Team

+
+ + +
+ Week of {{SHORT_DATE}} +
+
+

+ Services: AuthN SDK - MSAL AndroidAuthN SDK - ADAL AndroidMicrosoft Authenticator - Android +

+ + +``` + +### 3. Summary Stat Card + +One card. Repeat 5x with different colors/values. + +```html + + + +
+

{{COUNT}}

+

{{LABEL}}

+
+ +``` + +Card configs: + +| Card | CARD_BG | CARD_BORDER | CARD_ACCENT | +|------|---------|-------------|-------------| +| Total | (none) | `#e1e4e8` | `#0078d4` | +| Out of SLA | `#fef2f2` | `#fca5a5` | `#cf222e` | +| Approaching | `#fffbeb` | `#fcd34d` | `#e65100` | +| In SLA | `#f0fdf4` | `#86efac` | `#2e7d32` | +| No ETA | `#fffbeb` | `#fcd34d` | `#bf8700` | + +### 4. Severity Bar + +```html + + + + + + + + +
{{OUT_COUNT}}{{NEAR_COUNT}}{{IN_COUNT}} In SLA
+ + +``` + +### 5. Section Divider + +```html + + + +
 
+ +``` + +### 6. Resolved Since Last Week Callout + +```html + + + + + + + +
  +

✅ Resolved Since Last Week

+

+ + {{TITLE}}AB#{{PBI_ID}} — {{ASSIGNEE}} — Done
+ + No resolved items were detected this week. +

+
+ + +``` + +### 7. Needs Attention Header + Out of SLA Card + +Only shown if there are Out of SLA items. Render one card per Out of SLA item. + +```html + + +

Needs Attention

+

Items past due or approaching their SLA deadline

+ + +
 
+ + + + + +
+ + + + + +
+

+ {{TITLE}} +

+ + + + + +
MISSED SLA{{SERVICE_NAME}}
+

Due: {{DUE_DATE}} ({{DAYS_OVERDUE}} days overdue)

+

ETA: {{ETA_DISPLAY}}

+

Owner: {{OWNER_DISPLAY}}

+
+ + +
+ AB#{{PBI_ID}} {{NEW_BADGE}} +
+

{{PROGRAM}} • {{SUBTYPE}}

+

Wave: {{WAVE}}

+
+
+ + +``` + +### 8. Program Section Header + +```html + + +

{{PROGRAM_NAME}}

+

{{PROGRAM_DESCRIPTION}} — {{ITEM_COUNT}} item(s)

+ + +
 
+``` + +### 9. Program Table Column Headers + +```html + + + + + + + + + + +``` + +### 10. Table Data Row + +Row styling varies by SLA state and zebra stripe position. + +**SLA-based styling:** + +| SLA State | Row BG (odd) | Row BG (even) | Left Border | Badge BG | Badge Color | +|-----------|-------------|---------------|-------------|----------|-------------| +| Missed | `#fff5f5` | `#fff5f5` | `#cf222e` | `#cf222e` | white | +| Near | `#fff8f0` | `#fff8f0` | `#e65100` | `#e65100` | white | +| In SLA | (none) | `#fafafa` | `#2e7d32` | `#e8f5e9` | `#2e7d32` | + +```html + + + + + + + + + +``` + +**ETA cell variants:** +- Has ETA: plain text date, e.g. `May 4` +- Missing ETA: `
TitleServiceOwnerSLADueETAPBI
+ {{TITLE}}
+ {{SUBTITLE}} +
{{SERVICE}}{{OWNER_NAME}}
({{OWNER_ALIAS}})
+
{{SLA_LABEL}}
+
{{DUE_SHORT}}{{ETA_CELL}}{{PBI_CELL}}
No ETA
` + +**PBI cell variants:** +- Existing: `AB#{{ID}}` +- New: same but append ` 🆕` +- None: `None` + +### 11. Program Section Close + +```html + + + +``` + +### 12. Ownership Breakdown Table + +```html + + +

👥 Ownership Breakdown

+ + + + + + + + + + + + + + + + + + +
AssigneeTotal🔴🟠🟢No ETA
{{NAME}} ({{ALIAS}}){{TOTAL}}{{OUT_COUNT}}{{NEAR_COUNT}}{{IN_COUNT}}{{NO_ETA_COUNT}}
+ + +``` + +**Ownership cell highlighting:** +- Out of SLA count > 0: `bgcolor="#fef2f2"`, `font-weight:700; color:#cf222e;` +- Near SLA count > 0: `bgcolor="#fffbeb"`, `font-weight:600;` +- No ETA count > 0: `font-weight:600; color:#bf8700;` + +### 13. Action Required Callout + +Three variants — one per callout type. Build by repeating this pattern: + +```html + + + + + +
  +

{{HEADING}}

+

{{CONTENT}}

+
+``` + +| Callout | BAR_COLOR | BG_COLOR | Heading | +|---------|-----------|----------|---------| +| Needs owners | `#cf222e` | `#fef2f2` | "4 Items Need Owners" | +| Missing ETA | `#bf8700` | `#fffbeb` | "8 Items Missing ETA" | +| New PBIs | `#0078d4` | `#ddf4ff` | "14 New PBIs Created" | + +### 14. Footer + +```html + + + + +
 
+ + + + + +

Auto-generated by S360 Reporter • {{DATE}}

+ S360 Dashboard • + ADO Board +

+ + +``` + +--- + +## Report Assembly Order + +1. Page wrapper open +2. Header (block 2) +3. Summary cards row (block 3 × 5, wrapped in a `` with `cellspacing="6"`) +4. Severity bar (block 4) +5. Divider (block 5) +6. Resolved callout (block 6) +7. Needs Attention header + Out of SLA cards (block 7) — only if Out of SLA items exist +8. Divider (block 5) +9. For each program (sorted by worst SLA first): + - Program header (block 8) + - Column headers (block 9) + - Data rows (block 10 × N, with SLA-based styling) + - Section close (block 11) +10. Divider (block 5) +11. Ownership table (block 12) +12. Divider (block 5) +13. Action Required section header + callouts (block 13 × 3) +14. Footer (block 14) +15. Page wrapper close + +## Program Categorization + +Derive program name directly from S360 API fields (no heuristic mapping needed): + +**Primary**: Use `CustomDimensions.S360_WavesMetadata[0].ProgramDisplayName` (e.g., "IDNA Governed SFI Work Items", "Azure SDL"). + +**Refinement**: If `ProgramDisplayName` is too generic (e.g., multiple items share the same program), +further group by `CustomDimensions.filter` (e.g., "Threat Model Review", "CodeQL") or +`CustomDimensions.campaign` (e.g., "CFS Adoption"). + +**Person-targeted items** (e.g., on-call checklists) have no `ProgramDisplayName`. Group these +by `CustomDimensions.TeamName` or use a fallback label like "On-Call Readiness". + +**Section header content**: +- `h3` title = program/filter name (e.g., "Continuous SDL", "CFS Pipeline Onboarding") +- Subtitle = descriptive text derived from the items + item count + +Programs are ordered by their worst SLA state: programs containing Missed items first, +then programs with Near items, then all-In-SLA programs. Within same SLA tier, order by +earliest due date. diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 67defed7..86d03ed7 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -41,6 +41,11 @@ "tools": ["*"] }, + "s360-breeze-mcp": { + "type": "http", + "url": "https://mcp.vnext.s360.msftcloudes.com/" + }, + "android-dri-search": { "type": "http", "url": "https://android-dri-mcp.proudbeach-7e7ce77d.eastus.azurecontainerapps.io/mcp" diff --git a/agency.toml b/agency.toml new file mode 100644 index 00000000..ad77bc2c --- /dev/null +++ b/agency.toml @@ -0,0 +1,9 @@ +# Agency configuration for android-complete +# Docs: https://eng.ms/docs/cloud-ai-platform/github/github-copilot-suite/agency/config + +[mcps.builtins] +ado = { type = "ado", organization = "IdentityDivision" } +es-chat = { type = "es-chat" } +workiq = { type = "workiq" } +m365-user = { type = "m365-user" } +s360-breeze = { type = "s360-breeze" }