From 927932b83756100b7b677f2fad21dcc302a98e05 Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Wed, 1 Apr 2026 16:04:46 -0700 Subject: [PATCH 01/13] Add skill to create S360 report --- .github/copilot-instructions.md | 1 + .github/skills/s360-reporter/SKILL.md | 147 ++++++++++++++++++++++++++ .vscode/mcp.json | 5 + 3 files changed, 153 insertions(+) create mode 100644 .github/skills/s360-reporter/SKILL.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0fbd81c0..1b9aec93 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -134,6 +134,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..11fc064f --- /dev/null +++ b/.github/skills/s360-reporter/SKILL.md @@ -0,0 +1,147 @@ +--- +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 + +- **S360 MCP Server** must be running (configured in `.vscode/mcp.json` as `s360-breeze-mcp`) +- **ADO MCP Server** must be running (for PBI creation) +- Read the **Outlook HTML report prompt** at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` + for HTML rendering rules before generating the report + +## 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` | + +## Workflow + +### Step 1: Fetch S360 Data + +Call `mcp_s360-breeze-m_search_active_s360_kpi_action_items` with all three target 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. + +### Step 2: Parse and Deduplicate Items + +The response contains an array at `result.resources`. For each item extract: + +| Field | JSON Path | Notes | +|-------|-----------|-------| +| Title | `Title` | | +| Service | Map `TargetId` → service name from table above | +| Owner Alias | `S360Dimensions.ActionOwnerAlias` | Falls back to `AssignedTo` | +| Owner Name | `S360Dimensions.ActionOwner` | | +| Due Date | `CurrentDueDate` | Format as `Mon DD, YYYY` | +| SLA State | `SLAState` | Values: `OutOfSla`, `ApproachingSla`, `InSla` | +| ETA | `CurrentETA` | If null → flag as **"Missing ETA ⚠"** | +| Status Notes | `CurrentStatus` | May be empty | +| Status Author | `CurrentStatusAuthor` | | +| ADO Work Item | `S360Dimensions.ADOWorkItemHTMLUrl` | Empty = no PBI linked | +| S360 URL | `URL` | Link to details/remediation | +| KPI ID | `KpiId` | For dedup | +| Action Item ID | `KpiActionItemId` | For dedup | +| Initiative | `CustomDimensions.initiative` | JSON array string | +| Wave | Extract from `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` | + +**Dedup**: Some items appear twice with different `KpiActionItemId` but same `Title` and +`TargetId`. Group by `Title` + `TargetId` and merge, keeping the one with worst SLA state. + +### Step 3: Ensure ADO PBIs Exist + +For each item where `S360Dimensions.ADOWorkItemHTMLUrl` is empty: + +1. **Ask the user** if they want PBIs auto-created for untracked items. +2. If yes, for each untracked item use `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"}, + {"name": "Microsoft.VSTS.Common.Priority", "value": "<1 for OutOfSla, 2 for ApproachingSla, 3 for InSla>"}, + {"name": "System.Tags", "value": "s360; ai-generated"} + ] + } + ``` +3. Follow the `pbi-creator` skill's Step 2 for ADO defaults discovery (area path, iteration) + — but batch the question, don't ask per item. +4. Record created PBI IDs for the report. + +### Step 4: Generate HTML Report + +Read the Outlook HTML report prompt at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` +to follow all rendering rules. + +**Report structure:** + +1. **Header** — "S360 Weekly Report — {date}" with subtitle "Android Auth Team" +2. **Summary banner** — Total items, Out of SLA count, Approaching SLA count, In SLA count, + Missing ETA count. Use colored stat cards. +3. **🔴 Out of SLA section** — Table of items past due. Columns: #, Title, Service, Owner, + Due Date, ETA, Notes, PBI Link. Use red-tinted rows. +4. **🟠 Approaching SLA section** — Same table format. Orange-tinted rows. +5. **🟢 In SLA section** — Same table format. Standard rows. +6. **By Assignee breakdown** — Table: Assignee, Total, Out of SLA, Approaching, In SLA. + Sorted by severity (most out-of-SLA first). +7. **Items Missing ETA** — Callout box listing items with no ETA set. +8. **Items Without ADO PBI** — Callout box listing items with no linked work item (or newly + created PBIs with links). +9. **Footer** — "Auto-generated by S360 Reporter skill" + link to S360 dashboard + date. + +**Display conventions:** +- SLA State badges: `OutOfSla` → red badge "MISSED SLA", `ApproachingSla` → orange "NEAR SLA", + `InSla` → green "IN SLA" +- Missing ETA: show "⚠ No ETA" in orange +- Owner: show full name with alias in parentheses, e.g. "Richard Zhang (zhangrichard)" +- Due dates in the past: bold red +- PBI link: show as "AB#12345" hyperlink, or "None" if missing +- Truncate long status notes to ~100 chars with "..." in the table; full notes in tooltip/title + +### Step 5: 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. Preview opened in browser. Copy the HTML into a + new Outlook email (Edit → Paste Special → HTML) and send to the team." + +## 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 + +- **S360 MCP auth failure**: Instruct user to restart MCP server via Command Palette → + `MCP: Restart Server` → `s360-breeze-mcp` +- **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 diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 5fcd2aa1..7825d821 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/" + }, + "MyDRICopilot": { "type": "http", "url": "https://msalandroiddricopilot.azurewebsites.net/mcp" From 027817e084d76e8403f6e3c007415d61cc046dcb Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Wed, 1 Apr 2026 17:29:00 -0700 Subject: [PATCH 02/13] Update SKILL.md --- .github/skills/s360-reporter/SKILL.md | 153 ++++++++++++++++++++++---- 1 file changed, 129 insertions(+), 24 deletions(-) diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index 11fc064f..2d71a0d1 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -12,7 +12,8 @@ HTML email report. ## Prerequisites - **S360 MCP Server** must be running (configured in `.vscode/mcp.json` as `s360-breeze-mcp`) -- **ADO MCP Server** must be running (for PBI creation) +- **ADO MCP Server** must be running (for PBI creation and lookup) +- **WorkIQ MCP Server** must be running (for pulling last week's email report) - Read the **Outlook HTML report prompt** at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` for HTML rendering rules before generating the report @@ -68,32 +69,122 @@ The response contains an array at `result.resources`. For each item extract: **Dedup**: Some items appear twice with different `KpiActionItemId` but same `Title` and `TargetId`. Group by `Title` + `TargetId` and merge, keeping the one with worst SLA state. -### Step 3: Ensure ADO PBIs Exist +### Step 3: Find Existing PBIs (Two Sources) -For each item where `S360Dimensions.ADOWorkItemHTMLUrl` is empty: +Before creating any new PBIs, search for existing ones from two sources. -1. **Ask the user** if they want PBIs auto-created for untracked items. -2. If yes, for each untracked item use `mcp_ado_wit_create_work_item`: +#### 3a: Pull last week's S360 email via WorkIQ + +Call `mcp_workiq_ask_work_iq` to find the most recent S360 report email: + +``` +question: "Find the most recent email with subject containing 'S360 Weekly Report' sent to androididentity@microsoft.com. Return the full email body content including any AB# work item references." +``` + +Parse the email body for: +- **AB# references** (e.g., `AB#12345`) — extract the number and the S360 item title nearby +- **Work item links** — ADO URLs like `dev.azure.com/.../workitems/12345` + +Build a map of **S360 item title → AB# number** from the previous report. +These are known-good PBI assignments from last week. + +If WorkIQ returns no results or the tool is unavailable, skip this step and continue +with Step 3b. Do not fail the workflow. + +#### 3b: Search ADO for existing S360 PBIs + +Search for work items that are tagged `s360` OR have `S360` in the title: + +``` +SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo] +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 +``` + +If the WIQL tool is unavailable, search via `mcp_ado_wit_my_work_items` and filter +results for titles containing `S360` or `[S360]`. + +**Also**: if Step 3a returned AB# numbers, call `mcp_ado_wit_get_work_items_batch_by_ids` +to fetch their current state. This catches PBIs 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. + +Build a map of **S360 item title → AB# number + state** from ADO. + +#### 3c: Merge PBI maps + +For each S360 item, check if a PBI exists from any source: +1. The S360 API field `S360Dimensions.ADOWorkItemHTMLUrl` (already linked in S360) +2. Last week's email AB# references (from 3a) — confirmed human assignments +3. ADO search results (from 3b) — broader search, may include loosely related items + +**Priority: S360-linked > last week's email > ADO search.** + +Rationale: S360-linked is authoritative. Last week's email represents a confirmed OCE +assignment (higher confidence than a title search). ADO search is a fallback that may +produce false positives from unrelated items with "S360" in the title. + +**Title matching logic** (for 3a and 3b): +- Normalize both titles: strip `[S360]` prefix, trim whitespace, lowercase +- Match if the S360 item title is **contained within** the ADO item title, or vice versa +- Example: S360 `"Update Vulnerable Container Image Reference"` matches + ADO `"[S360] Update Vulnerable Container Image Reference"` + +Mark each item's PBI status: `existing` (with AB# + URL), `needs-creation`, or +`resolved` (PBI exists but is Done/Removed — skip from report). + +### Step 4: Create Missing PBIs + +For items marked `needs-creation`: + +1. **Present a summary** to the user showing which items need PBIs: + ``` + The following S360 items have no ADO PBI: + - [Title 1] — Owner: alias — SLA: OutOfSla + - [Title 2] — Owner: alias — SLA: InSla + Create PBIs for these items? (Y/N) + ``` + +2. If user approves, discover ADO defaults using the same pattern as the `pbi-creator` + skill's Step 2 (but call ADO MCP tools directly — do not invoke pbi-creator as a + sub-skill since we have a simpler PBI structure with no dependency linking): + - Call `mcp_ado_wit_my_work_items` to find recent work items + - Extract area path, iteration path from the results + - Use `mcp_ado_work_list_iterations` with `depth: 6` for current iterations + - **Batch all questions** into a single `askQuestion` call: + - Area Path (with discovered options) + - Iteration (current/next month only) + +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.Description", "value": "", "format": "Html"}, + {"name": "System.AreaPath", "value": ""}, + {"name": "System.IterationPath", "value": ""}, {"name": "System.AssignedTo", "value": "@microsoft.com"}, - {"name": "Microsoft.VSTS.Common.Priority", "value": "<1 for OutOfSla, 2 for ApproachingSla, 3 for InSla>"}, + {"name": "Microsoft.VSTS.Common.Priority", "value": "<1=OutOfSla, 2=Approaching, 3=InSla>"}, {"name": "System.Tags", "value": "s360; ai-generated"} ] } ``` -3. Follow the `pbi-creator` skill's Step 2 for ADO defaults discovery (area path, iteration) - — but batch the question, don't ask per item. -4. Record created PBI IDs for the report. + 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) -### Step 4: Generate HTML Report +4. Record the created AB# for each item. + +### Step 5: Generate HTML Report Read the Outlook HTML report prompt at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` to follow all rendering rules. @@ -103,27 +194,34 @@ to follow all rendering rules. 1. **Header** — "S360 Weekly Report — {date}" with subtitle "Android Auth Team" 2. **Summary banner** — Total items, Out of SLA count, Approaching SLA count, In SLA count, Missing ETA count. Use colored stat cards. -3. **🔴 Out of SLA section** — Table of items past due. Columns: #, Title, Service, Owner, - Due Date, ETA, Notes, PBI Link. Use red-tinted rows. +3. **🔴 Out of SLA section** — Table with columns: Title, Service, Owner, Due Date, ETA, + Notes, PBI. Use red-tinted rows. Title is a **hyperlink to the S360 URL**. 4. **🟠 Approaching SLA section** — Same table format. Orange-tinted rows. 5. **🟢 In SLA section** — Same table format. Standard rows. + +**Title column**: Each title should be a hyperlink to the S360 remediation URL (`item.s360_url`). +This lets readers click through to the S360 dashboard directly from the email. 6. **By Assignee breakdown** — Table: Assignee, Total, Out of SLA, Approaching, In SLA. Sorted by severity (most out-of-SLA first). -7. **Items Missing ETA** — Callout box listing items with no ETA set. -8. **Items Without ADO PBI** — Callout box listing items with no linked work item (or newly - created PBIs with links). -9. **Footer** — "Auto-generated by S360 Reporter skill" + link to S360 dashboard + date. +7. **Callout boxes** — Items Missing ETA, Unassigned items, Newly created PBIs. +8. **Footer** — "Auto-generated by S360 Reporter skill" + S360 dashboard link + date. + +**PBI column display conventions:** +- Existing PBI: show as `AB#12345` hyperlinked to the ADO work item URL + (URL format: `https://dev.azure.com/IdentityDivision/Engineering/_workitems/edit/`) +- Newly created PBI: show as `AB#12345 🆕` with hyperlink +- No PBI (user declined creation): show "None" in italic +- Resolved PBI (from ADO search): omit from report (item was already handled) -**Display conventions:** +**Other display conventions:** - SLA State badges: `OutOfSla` → red badge "MISSED SLA", `ApproachingSla` → orange "NEAR SLA", `InSla` → green "IN SLA" - Missing ETA: show "⚠ No ETA" in orange - Owner: show full name with alias in parentheses, e.g. "Richard Zhang (zhangrichard)" -- Due dates in the past: bold red -- PBI link: show as "AB#12345" hyperlink, or "None" if missing -- Truncate long status notes to ~100 chars with "..." in the table; full notes in tooltip/title +- Due dates in the past: bold +- Truncate long status notes to ~100 chars with "..." in the table -### Step 5: Save and Preview +### 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 @@ -142,6 +240,13 @@ Within same SLA state, sort by `CurrentDueDate` ascending (earliest due first). - **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. +- **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 +- **PBI 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. +- **User declines PBI creation**: Proceed with report generation; show "None" in PBI column. From 55e67f5f22c18bee270e7ae95026ae491ce9df75 Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Tue, 7 Apr 2026 20:39:43 -0700 Subject: [PATCH 03/13] Improve S360 reporter --- .github/skills/s360-reporter/SKILL.md | 157 ++++++- .../skills/s360-reporter/report-template.md | 420 ++++++++++++++++++ .vscode/mcp.json | 5 + 3 files changed, 566 insertions(+), 16 deletions(-) create mode 100644 .github/skills/s360-reporter/report-template.md diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index 2d71a0d1..0f1eb235 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -13,6 +13,7 @@ HTML email report. - **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) +- **Microsoft Graph MCP Server** must be running (for dynamic team member discovery via org chart) - **WorkIQ MCP Server** must be running (for pulling last week's email report) - Read the **Outlook HTML report prompt** at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` for HTML rendering rules before generating the report @@ -27,9 +28,47 @@ HTML email report. ## 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:** + ``` + mcp_graph_microsoft_graph_get( + relativeUrl: "/v1.0/me/manager?$select=id,displayName,userPrincipalName" + ) + ``` + Extract the manager's `id`. + +2. **Get all direct reports of the manager (= your teammates):** + ``` + mcp_graph_microsoft_graph_get( + relativeUrl: "/v1.0/users/{managerId}/directReports/graph.user?$count=true&$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 Graph MCP server 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 1: Fetch S360 Data -Call `mcp_s360-breeze-m_search_active_s360_kpi_action_items` with all three target IDs: +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: { @@ -44,6 +83,54 @@ request: { 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. + +#### 1c: Merge results + +Combine items from 1a and 1b. Deduplicate by `KpiActionItemId` — if the same item +appears in both searches, keep only one copy. + +#### 1d: 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. Call `mcp_s360-breeze-m_search_resolved_s360_kpi_action_items` with the same + `targetIds` and `assignedTo` used in 1a/1b. This returns items that were active + but have since been resolved. + b. If the resolved search tool is unavailable, parse last week's email (from Step 3a) + and extract the item titles + AB# numbers listed there. + c. If neither is 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 The response contains an array at `result.resources`. For each item extract: @@ -51,7 +138,7 @@ The response contains an array at `result.resources`. For each item extract: | Field | JSON Path | Notes | |-------|-----------|-------| | Title | `Title` | | -| Service | Map `TargetId` → service name from table above | +| 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` | | Owner Name | `S360Dimensions.ActionOwner` | | | Due Date | `CurrentDueDate` | Format as `Mon DD, YYYY` | @@ -171,7 +258,8 @@ For items marked `needs-creation`: {"name": "System.IterationPath", "value": ""}, {"name": "System.AssignedTo", "value": "@microsoft.com"}, {"name": "Microsoft.VSTS.Common.Priority", "value": "<1=OutOfSla, 2=Approaching, 3=InSla>"}, - {"name": "System.Tags", "value": "s360; ai-generated"} + {"name": "System.State", "value": "Committed"}, + {"name": "System.Tags", "value": "S360; AI-Generated"} ] } ``` @@ -186,25 +274,59 @@ For items marked `needs-creation`: ### Step 5: Generate HTML Report -Read the Outlook HTML report prompt at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` -to follow all rendering rules. +Read the **report template reference** at `.github/skills/s360-reporter/report-template.md` +for all HTML building blocks, color palette, and assembly order. Also read the Outlook HTML +report prompt at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` (if available) +for general Outlook rendering rules (bgcolor, border-radius caveats, etc.). + +**CRITICAL**: Copy HTML building blocks **verbatim** from `report-template.md`. Do NOT +rephrase, restyle, or improvise the HTML. Only substitute the `{{PLACEHOLDER}}` values +with actual data. The template was carefully designed and tested for Outlook compatibility +and visual consistency — any deviation (different padding, colors, font sizes, structure) +will break the design. If you need a component not in the template, replicate the closest +existing block's style exactly. **Report structure:** -1. **Header** — "S360 Weekly Report — {date}" with subtitle "Android Auth Team" -2. **Summary banner** — Total items, Out of SLA count, Approaching SLA count, In SLA count, - Missing ETA count. Use colored stat cards. -3. **🔴 Out of SLA section** — Table with columns: Title, Service, Owner, Due Date, ETA, - Notes, PBI. Use red-tinted rows. Title is a **hyperlink to the S360 URL**. -4. **🟠 Approaching SLA section** — Same table format. Orange-tinted rows. -5. **🟢 In SLA section** — Same table format. Standard rows. +1. **Header** — Uppercase "S360 WEEKLY REPORT" label + large "Android Auth Team" title. + Right side: blue pill badge with "Week of {date}". Below: services line listing all 3 + service names in bold. +2. **Summary cards** — 5 stat cards with colored top borders and tinted backgrounds: + Total Items (blue), Out of SLA (red), Approaching (orange), In SLA (green), + No ETA (amber). Cards have `border-radius:12px`. +3. **Severity bar** — Horizontal proportional bar showing red/orange/green distribution + with counts. `border-radius:6px; overflow:hidden`. +4. **✅ Resolved Since Last Week** — Green left-bar callout box listing items resolved + since the previous report. Each entry shows: **Title** — AB#link — assignee — **Done**. + If no resolved items, show a note: "No resolved items were detected this week." +5. **Needs Attention** — Section header with red underline bar, followed by the Out of SLA + card (red bordered, rounded, pink tint, detailed metadata layout with PBI chip). +6. **Items by Compliance Area** — Each program gets its own section with: + - **Section header**: h3 title + italic subtitle + blue 3px underline bar + - **Table**: columns Title, Service, Owner, SLA, Due, ETA, PBI + - Blue-gray `#e8edf2` column headers, `border:1px solid` on all cells + - Inline SLA badges per row (Missed/Near/In SLA pills) + - Row background tints: `#fff5f5` for Missed, `#fff8f0` for Near, white/`#fafafa` zebra for In SLA + - Left border color per title cell: red for Missed, orange for Near, green for In SLA + - Programs ordered by worst SLA state first (programs with Missed items first, then Near, then In SLA) + - Related items (e.g., CFS pipelines) grouped under a single program section + - Typical program categories: GDPR & Data Classification, Continuous SDL, Vulnerability + Management, MSRC Security Response, CFS Pipeline Onboarding, On-Call Readiness, + PRC Violations. Derive program name directly from S360 API fields: + `CustomDimensions.S360_WavesMetadata[0].ProgramDisplayName`, `CustomDimensions.filter`, + `CustomDimensions.campaign`. For person-targeted items, use `CustomDimensions.TeamName` + or a fallback label. See `report-template.md` for details. +7. **Ownership Breakdown** — Table: Assignee, Total, 🔴, 🟠, 🟢, No ETA. + Blue-gray headers, zebra striped rows. Sorted by severity (most out-of-SLA first). + No ETA column highlights values in amber. +8. **Action Required callouts** — Three left-bar callout boxes: + - Red: Items needing owners (list item titles) + - Amber: Items missing ETA (list item titles) + - Blue: Newly created PBIs (list AB# links) +9. **Footer** — "Auto-generated by S360 Reporter" + S360 dashboard link + ADO Board link + date. **Title column**: Each title should be a hyperlink to the S360 remediation URL (`item.s360_url`). This lets readers click through to the S360 dashboard directly from the email. -6. **By Assignee breakdown** — Table: Assignee, Total, Out of SLA, Approaching, In SLA. - Sorted by severity (most out-of-SLA first). -7. **Callout boxes** — Items Missing ETA, Unassigned items, Newly created PBIs. -8. **Footer** — "Auto-generated by S360 Reporter skill" + S360 dashboard link + date. **PBI column display conventions:** - Existing PBI: show as `AB#12345` hyperlinked to the ADO work item URL @@ -238,6 +360,9 @@ Within same SLA state, sort by `CurrentDueDate` ascending (earliest due first). ## Edge Cases +- **Graph 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 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 7825d821..74891f0d 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -49,6 +49,11 @@ "MyDRICopilot": { "type": "http", "url": "https://msalandroiddricopilot.azurewebsites.net/mcp" + }, + + "graph": { + "type": "http", + "url": "https://mcp.svc.cloud.microsoft/enterprise" } } } From 4b1864edf06666519fbf9e6fcb314441dc9206ac Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Wed, 8 Apr 2026 16:51:25 -0700 Subject: [PATCH 04/13] Improve s360-reporter skill: auto-create PBIs, generator script, Graph email draft - Add committed Node.js report generator (generate-report.js) that reads JSON input and produces Outlook-compatible HTML reports - Auto-create PBIs without user confirmation, default area path and computed iteration - Add Step 4b to auto-close resolved PBIs (transition to Done) - Scope WorkIQ email query to last 7 days for freshness - Add Graph API email draft (POST /v1.0/me/messages) with file fallback - Add Quick Mode for CLI-only summary without full report - Filter person-targeted items to team members via org chart - Update edge cases for new features Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/s360-reporter/SKILL.md | 281 ++++++---- .../skills/s360-reporter/generate-report.js | 525 ++++++++++++++++++ 2 files changed, 713 insertions(+), 93 deletions(-) create mode 100644 .github/skills/s360-reporter/generate-report.js diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index 0f1eb235..55d39477 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -11,13 +11,33 @@ HTML email report. ## Prerequisites +- **Node.js** must be available (for the committed report generator script) - **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) -- **Microsoft Graph MCP Server** must be running (for dynamic team member discovery via org chart) +- **Microsoft Graph MCP Server** must be running (for dynamic team member discovery via org chart, + and optionally for creating an Outlook draft email) - **WorkIQ MCP Server** must be running (for pulling last week's email report) - 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 | @@ -98,6 +118,13 @@ request: { 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"` (on-call, certifications — always relevant to the person) +- `TargetId` matches one of our three service tree IDs +- `CustomDimensions.TenantName` contains "Auth Client", "MSAL", "ADAL", or "Authenticator" + #### 1c: Merge results Combine items from 1a and 1b. Deduplicate by `KpiActionItemId` — if the same item @@ -162,10 +189,10 @@ Before creating any new PBIs, search for existing ones from two sources. #### 3a: Pull last week's S360 email via WorkIQ -Call `mcp_workiq_ask_work_iq` to find the most recent S360 report email: +Call `mcp_workiq_ask_work_iq` to find the most recent S360 report email **from the last 7 days**: ``` -question: "Find the most recent email with subject containing 'S360 Weekly Report' sent to androididentity@microsoft.com. Return the full email body content including any AB# work item references." +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." ``` Parse the email body for: @@ -228,23 +255,32 @@ Mark each item's PBI status: `existing` (with AB# + URL), `needs-creation`, or For items marked `needs-creation`: -1. **Present a summary** to the user showing which items need PBIs: +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 + ... ``` - The following S360 items have no ADO PBI: - - [Title 1] — Owner: alias — SLA: OutOfSla - - [Title 2] — Owner: alias — SLA: InSla - Create PBIs for these items? (Y/N) + +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 -2. If user approves, discover ADO defaults using the same pattern as the `pbi-creator` - skill's Step 2 (but call ADO MCP tools directly — do not invoke pbi-creator as a - sub-skill since we have a simpler PBI structure with no dependency linking): - - Call `mcp_ado_wit_my_work_items` to find recent work items - - Extract area path, iteration path from the results - - Use `mcp_ado_work_list_iterations` with `depth: 6` for current iterations - - **Batch all questions** into a single `askQuestion` call: - - Area Path (with discovered options) - - Iteration (current/next month only) + 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 @@ -272,83 +308,134 @@ For items marked `needs-creation`: 4. Record the created AB# for each item. +### Step 4b: Auto-Close Resolved PBIs + +For each item in the **resolved items list** (from Step 1d) that has an associated ADO PBI: + +1. Look up the PBI state via `mcp_ado_wit_get_work_items_batch_by_ids` +2. If the PBI state is NOT `Done` or `Removed`, transition it to `Done`: + ``` + mcp_ado_wit_update_work_item( + id: , + updates: [{ path: "/fields/System.State", value: "Done" }] + ) + ``` +3. Log which PBIs were auto-closed for the report's "Resolved" section + +If no PBI is associated with a resolved item, skip it (no action needed). + ### Step 5: Generate HTML Report -Read the **report template reference** at `.github/skills/s360-reporter/report-template.md` -for all HTML building blocks, color palette, and assembly order. Also read the Outlook HTML -report prompt at `{{VSCODE_USER_PROMPTS_FOLDER}}/outlook-html-report.prompt.md` (if available) -for general Outlook rendering rules (bgcolor, border-radius caveats, etc.). - -**CRITICAL**: Copy HTML building blocks **verbatim** from `report-template.md`. Do NOT -rephrase, restyle, or improvise the HTML. Only substitute the `{{PLACEHOLDER}}` values -with actual data. The template was carefully designed and tested for Outlook compatibility -and visual consistency — any deviation (different padding, colors, font sizes, structure) -will break the design. If you need a component not in the template, replicate the closest -existing block's style exactly. - -**Report structure:** - -1. **Header** — Uppercase "S360 WEEKLY REPORT" label + large "Android Auth Team" title. - Right side: blue pill badge with "Week of {date}". Below: services line listing all 3 - service names in bold. -2. **Summary cards** — 5 stat cards with colored top borders and tinted backgrounds: - Total Items (blue), Out of SLA (red), Approaching (orange), In SLA (green), - No ETA (amber). Cards have `border-radius:12px`. -3. **Severity bar** — Horizontal proportional bar showing red/orange/green distribution - with counts. `border-radius:6px; overflow:hidden`. -4. **✅ Resolved Since Last Week** — Green left-bar callout box listing items resolved - since the previous report. Each entry shows: **Title** — AB#link — assignee — **Done**. - If no resolved items, show a note: "No resolved items were detected this week." -5. **Needs Attention** — Section header with red underline bar, followed by the Out of SLA - card (red bordered, rounded, pink tint, detailed metadata layout with PBI chip). -6. **Items by Compliance Area** — Each program gets its own section with: - - **Section header**: h3 title + italic subtitle + blue 3px underline bar - - **Table**: columns Title, Service, Owner, SLA, Due, ETA, PBI - - Blue-gray `#e8edf2` column headers, `border:1px solid` on all cells - - Inline SLA badges per row (Missed/Near/In SLA pills) - - Row background tints: `#fff5f5` for Missed, `#fff8f0` for Near, white/`#fafafa` zebra for In SLA - - Left border color per title cell: red for Missed, orange for Near, green for In SLA - - Programs ordered by worst SLA state first (programs with Missed items first, then Near, then In SLA) - - Related items (e.g., CFS pipelines) grouped under a single program section - - Typical program categories: GDPR & Data Classification, Continuous SDL, Vulnerability - Management, MSRC Security Response, CFS Pipeline Onboarding, On-Call Readiness, - PRC Violations. Derive program name directly from S360 API fields: - `CustomDimensions.S360_WavesMetadata[0].ProgramDisplayName`, `CustomDimensions.filter`, - `CustomDimensions.campaign`. For person-targeted items, use `CustomDimensions.TeamName` - or a fallback label. See `report-template.md` for details. -7. **Ownership Breakdown** — Table: Assignee, Total, 🔴, 🟠, 🟢, No ETA. - Blue-gray headers, zebra striped rows. Sorted by severity (most out-of-SLA first). - No ETA column highlights values in amber. -8. **Action Required callouts** — Three left-bar callout boxes: - - Red: Items needing owners (list item titles) - - Amber: Items missing ETA (list item titles) - - Blue: Newly created PBIs (list AB# links) -9. **Footer** — "Auto-generated by S360 Reporter" + S360 dashboard link + ADO Board link + date. - -**Title column**: Each title should be a hyperlink to the S360 remediation URL (`item.s360_url`). -This lets readers click through to the S360 dashboard directly from the email. - -**PBI column display conventions:** -- Existing PBI: show as `AB#12345` hyperlinked to the ADO work item URL - (URL format: `https://dev.azure.com/IdentityDivision/Engineering/_workitems/edit/`) -- Newly created PBI: show as `AB#12345 🆕` with hyperlink -- No PBI (user declined creation): show "None" in italic -- Resolved PBI (from ADO search): omit from report (item was already handled) - -**Other display conventions:** -- SLA State badges: `OutOfSla` → red badge "MISSED SLA", `ApproachingSla` → orange "NEAR SLA", - `InSla` → green "IN SLA" -- Missing ETA: show "⚠ No ETA" in orange -- Owner: show full name with alias in parentheses, e.g. "Richard Zhang (zhangrichard)" -- Due dates in the past: bold -- Truncate long status notes to ~100 chars with "..." in the table - -### 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. Preview opened in browser. Copy the HTML into a - new Outlook email (Edit → Paste Special → HTML) and send to the team." +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 for cards (optional, falls back to title)", + "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": "AB#12345 or null", + "isNew": true, + "s360Url": "https://s360.msftcloudes.com/...", + "program": "Program display name", + "programDesc": "Program subtitle/description (optional)", + "subtitle": "Wave or campaign name (optional)" + } + ], + "resolved": [ + { + "title": "Resolved item title", + "assignee": "Full Name (alias)", + "pbi": "AB#12345 or null" + } + ], + "nameMap": { + "alias": "Full Name" + }, + "newItems": [ + { "title": "New item title", "service": "Service name" } + ] +} +``` + +**Field notes:** +- `items`: All active S360 items from Steps 1–4 with PBI 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) + +#### 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, Draft Email, and Preview + +1. **Save HTML** to `C:\Users\shjameel\Desktop\s360-report-{date}.html` + +2. **Draft Outlook email via Graph API** (if available): + ``` + mcp_graph_microsoft_graph_suggest_queries( + intentDescription: "create a draft email message" + ) + ``` + Then call `mcp_graph_microsoft_graph_post` with the suggested endpoint: + ``` + relativeUrl: "/v1.0/me/messages" + body: { + "subject": "S360 Weekly Report — Android Auth Team — Week of {date}", + "body": { "contentType": "HTML", "content": "" }, + "toRecipients": [ + { "emailAddress": { "address": "androididentity@microsoft.com" } } + ], + "isDraft": true + } + ``` + If the Graph call succeeds, tell the user: "📧 Outlook draft created. Open Outlook → + Drafts → review and send." + + **Fallback**: If Graph MCP is unavailable or the call fails, fall back to the file-based + approach (step 3 below). + +3. **Open in browser** for preview using `Start-Process` in terminal + +4. Tell the user the report location and next steps: + - If email draft was created: "Report saved to Desktop and Outlook draft created." + - If file-only: "Report saved to Desktop. Preview opened in browser. Copy the HTML + into a new Outlook email (Edit → Paste Special → HTML) and send to the team." ## SLA State Sort Order @@ -363,10 +450,14 @@ Within same SLA state, sort by `CurrentDueDate` ascending (earliest due first). - **Graph 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. +- **Graph email draft fails**: Fall back to file-based approach — save HTML to Desktop and + instruct user to copy/paste into Outlook manually. Do not fail the workflow. - **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 @@ -374,4 +465,8 @@ Within same SLA state, sort by `CurrentDueDate` ascending (earliest due first). - **Owner alias empty**: Show "Unassigned" in the report and flag for attention - **PBI 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. -- **User declines PBI creation**: Proceed with report generation; show "None" in PBI column. +- **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..357ea242 --- /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; + 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)}
+ ${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)} (${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); +} From 805071becc6c5542c6115c88b992102d17537df2 Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Thu, 9 Apr 2026 12:07:34 -0700 Subject: [PATCH 05/13] s360-reporter: leave unassigned items unassigned in PBIs When ActionOwnerAlias is empty, omit System.AssignedTo from the PBI instead of falling back to the S360 AssignedTo field (which defaults to the manager). Show 'Unassigned' in the report for manual triage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/s360-reporter/SKILL.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index 55d39477..adc7dd9f 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -292,7 +292,7 @@ For items marked `needs-creation`: {"name": "System.Description", "value": "", "format": "Html"}, {"name": "System.AreaPath", "value": ""}, {"name": "System.IterationPath", "value": ""}, - {"name": "System.AssignedTo", "value": "@microsoft.com"}, + {"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"} @@ -462,7 +462,10 @@ Within same SLA state, sort by `CurrentDueDate` ascending (earliest due first). 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 +- **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. - **PBI 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` From 90f6e0c0f5ea260a05ee1512c395642f84f5974d Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Thu, 9 Apr 2026 12:35:35 -0700 Subject: [PATCH 06/13] s360-reporter: fix program name extraction and dedup logic Root cause of bad report quality: 1. Program headings used raw API codes (ADFunGlobal, ADFunCompliance) instead of ProgramDisplayName. Added explicit priority-ordered extraction: ProgramDisplayName > campaign > TeamName > filter (mapped) 2. Missing titles had no fallback. Added fallback to WaveDisplayName 3. Dedup was too weak for multi-target KPIs (CFS endpoints). Added fuzzy dedup by KpiId with merge-and-count logic. Also: owner name now falls back to nameMap from Step 0 when S360Dimensions.ActionOwner is empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/s360-reporter/SKILL.md | 38 ++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index adc7dd9f..8d84c054 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -164,10 +164,10 @@ The response contains an array at `result.resources`. For each item extract: | Field | JSON Path | Notes | |-------|-----------|-------| -| Title | `Title` | | -| 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` | -| Owner Name | `S360Dimensions.ActionOwner` | | +| Title | `Title` | **Required** — if empty, fall back to `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` or KPI name via `KpiId` lookup. Never leave blank. | +| 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" | +| 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 | | Due Date | `CurrentDueDate` | Format as `Mon DD, YYYY` | | SLA State | `SLAState` | Values: `OutOfSla`, `ApproachingSla`, `InSla` | | ETA | `CurrentETA` | If null → flag as **"Missing ETA ⚠"** | @@ -177,11 +177,37 @@ The response contains an array at `result.resources`. For each item extract: | S360 URL | `URL` | Link to details/remediation | | KPI ID | `KpiId` | For dedup | | Action Item ID | `KpiActionItemId` | For dedup | +| Program Name | See extraction logic below | For grouping items by compliance area | +| Program Desc | `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` | Subtitle under program heading | | Initiative | `CustomDimensions.initiative` | JSON array string | | Wave | Extract from `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` | -**Dedup**: Some items appear twice with different `KpiActionItemId` but same `Title` and -`TargetId`. Group by `Title` + `TargetId` and merge, keeping the one with worst SLA state. +**Program Name extraction** (priority order — use the first non-empty value): + +1. `CustomDimensions.S360_WavesMetadata[0].ProgramDisplayName` — the human-readable name + (e.g., "Continuous SDL", "Vulnerability Management", "GDPR & Data Classification") +2. `CustomDimensions.campaign` — sometimes contains a readable program name +3. `CustomDimensions.TeamName` — for person-targeted items (e.g., "On-Call Readiness") +4. `CustomDimensions.filter` — **last resort only**. This contains internal codes like + "ADFunGlobal" or "ADFunCompliance". If you must use this, map known codes to friendly names: + - `ADFunGlobal` → "Global Compliance" + - `ADFunCompliance` → "Security & Compliance" + - `ADFunReliability` → "Reliability" + - Otherwise, title-case the value and strip the "ADFun" prefix +5. If all empty → "Other Compliance Items" + +**IMPORTANT**: Never use raw `CustomDimensions.filter` values (like "ADFunGlobal") as +section headings. Always prefer `ProgramDisplayName` which contains the user-facing name. + +**Dedup**: 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. ### Step 3: Find Existing PBIs (Two Sources) From 8ef3d9e3c9590868e38273e6547200ead96338d3 Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Fri, 10 Apr 2026 11:49:41 -0700 Subject: [PATCH 07/13] Update SKILL.md --- .github/skills/s360-reporter/SKILL.md | 67 ++++++++++++++++++--------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index 8d84c054..2d22d9df 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -130,7 +130,28 @@ items where one of these conditions is met: Combine items from 1a and 1b. Deduplicate by `KpiActionItemId` — if the same item appears in both searches, keep only one copy. -#### 1d: Detect Resolved Items (Week-over-Week) +#### 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: @@ -164,7 +185,7 @@ The response contains an array at `result.resources`. For each item extract: | Field | JSON Path | Notes | |-------|-----------|-------| -| Title | `Title` | **Required** — if empty, fall back to `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` or KPI name via `KpiId` lookup. Never leave blank. | +| Title | `Title` | **Required** — sanitize before display (see below). If empty, use KPI `displayName` from Step 1d. Never leave blank. | | 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" | | 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 | @@ -177,27 +198,31 @@ The response contains an array at `result.resources`. For each item extract: | S360 URL | `URL` | Link to details/remediation | | KPI ID | `KpiId` | For dedup | | Action Item ID | `KpiActionItemId` | For dedup | -| Program Name | See extraction logic below | For grouping items by compliance area | -| Program Desc | `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` | Subtitle under program heading | -| Initiative | `CustomDimensions.initiative` | JSON array string | +| Program Name | KPI metadata `displayName` (from Step 1d) | For grouping items by compliance area | +| Program Desc | `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` | Subtitle under program heading (optional) | | Wave | Extract from `CustomDimensions.S360_WavesMetadata[0].WaveDisplayName` | -**Program Name extraction** (priority order — use the first non-empty value): - -1. `CustomDimensions.S360_WavesMetadata[0].ProgramDisplayName` — the human-readable name - (e.g., "Continuous SDL", "Vulnerability Management", "GDPR & Data Classification") -2. `CustomDimensions.campaign` — sometimes contains a readable program name -3. `CustomDimensions.TeamName` — for person-targeted items (e.g., "On-Call Readiness") -4. `CustomDimensions.filter` — **last resort only**. This contains internal codes like - "ADFunGlobal" or "ADFunCompliance". If you must use this, map known codes to friendly names: - - `ADFunGlobal` → "Global Compliance" - - `ADFunCompliance` → "Security & Compliance" - - `ADFunReliability` → "Reliability" - - Otherwise, title-case the value and strip the "ADFun" prefix -5. If all empty → "Other Compliance Items" - -**IMPORTANT**: Never use raw `CustomDimensions.filter` values (like "ADFunGlobal") as -section headings. Always prefer `ProgramDisplayName` which contains the user-facing name. +**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 + +**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**: Some items appear multiple times with different `KpiActionItemId` but same or similar `Title` and `TargetId`. Apply dedup in two passes: From c2a1512f31c6f1775f24a71d6d0187ee01543c41 Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Fri, 17 Apr 2026 13:31:35 -0700 Subject: [PATCH 08/13] Create agency.toml --- agency.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 agency.toml diff --git a/agency.toml b/agency.toml new file mode 100644 index 00000000..f3f49968 --- /dev/null +++ b/agency.toml @@ -0,0 +1,5 @@ +# 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" } From 3a709cf76a98841e7e20240947fac81247d49461 Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Sat, 9 May 2026 11:15:28 -0700 Subject: [PATCH 09/13] Remove Mail MCP dependency from S360 reporter skill - Remove mail MCP from agency.toml (no longer needed) - Remove Outlook draft creation from Step 6 (user copies from browser preview instead) - Replace Mail Search fallback in Step 3a with ask_user prompt - Remove Mail MCP edge case entry - Update SKILL.md prerequisites to reflect removal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/s360-reporter/SKILL.md | 246 ++++++++++++------ .../skills/s360-reporter/generate-report.js | 6 +- agency.toml | 6 +- 3 files changed, 179 insertions(+), 79 deletions(-) diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index 2d22d9df..ef788ac1 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -14,9 +14,8 @@ HTML email report. - **Node.js** must be available (for the committed report generator script) - **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) -- **Microsoft Graph MCP Server** must be running (for dynamic team member discovery via org chart, - and optionally for creating an Outlook draft email) -- **WorkIQ MCP Server** must be running (for pulling last week's email report) +- **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 @@ -46,6 +45,19 @@ Skip PBI creation, report generation, and email drafting in quick mode. | 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 @@ -56,16 +68,17 @@ misses these. To capture them, dynamically discover team member aliases from the 1. **Get current user's manager:** ``` - mcp_graph_microsoft_graph_get( - relativeUrl: "/v1.0/me/manager?$select=id,displayName,userPrincipalName" + m365-user-GetManagerDetails( + select: "id,displayName,userPrincipalName" ) ``` - Extract the manager's `id`. + Extract the manager's `id` (GUID) and `userPrincipalName`. 2. **Get all direct reports of the manager (= your teammates):** ``` - mcp_graph_microsoft_graph_get( - relativeUrl: "/v1.0/users/{managerId}/directReports/graph.user?$count=true&$select=id,displayName,userPrincipalName,jobTitle,accountEnabled" + m365-user-GetDirectReportsDetails( + userId: "", + select: "id,displayName,userPrincipalName,jobTitle,accountEnabled" ) ``` @@ -75,13 +88,39 @@ misses these. To capture them, dynamically discover team member aliases from the - 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 Graph MCP server is unavailable, fall back to the ADO Teams API: +**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 PBI IDs (e.g., `AB#12345` or `Product Backlog Item 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: @@ -121,10 +160,18 @@ 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"` (on-call, certifications — always relevant to the person) +- `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 Combine items from 1a and 1b. Deduplicate by `KpiActionItemId` — if the same item @@ -157,12 +204,14 @@ To populate the "Resolved Since Last Week" section, compare the current S360 ite against last week's report: 1. **Pull last week's S360 items** via one of these sources (in priority order): - a. Call `mcp_s360-breeze-m_search_resolved_s360_kpi_action_items` with the same - `targetIds` and `assignedTo` used in 1a/1b. This returns items that were active - but have since been resolved. - b. If the resolved search tool is unavailable, parse last week's email (from Step 3a) - and extract the item titles + AB# numbers listed there. - c. If neither is available, skip this step. + 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. @@ -187,15 +236,15 @@ The response contains an array at `result.resources`. For each item extract: |-------|-----------|-------| | Title | `Title` | **Required** — sanitize before display (see below). If empty, use KPI `displayName` from Step 1d. Never leave blank. | | 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" | -| 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 | +| 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 | `CurrentETA` | If null → flag as **"Missing ETA ⚠"** | +| ETA | `CurrentETA` | If null and KpiId is in the "ETA Not Applicable" table → show **"N/A"**. If null 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 = no PBI linked | -| S360 URL | `URL` | Link to details/remediation | +| S360 URL | `URL` | Remediation/action link from S360 API (aka.ms, IcM, ADO, etc.) | | 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 | @@ -238,25 +287,31 @@ or similar `Title` and `TargetId`. Apply dedup in two passes: Before creating any new PBIs, search for existing ones from two sources. -#### 3a: Pull last week's S360 email via WorkIQ +#### 3a: Pull last week's S360 email -Call `mcp_workiq_ask_work_iq` to find the most recent S360 report email **from the last 7 days**: +**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." ``` -Parse the email body for: +**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 - **Work item links** — ADO URLs like `dev.azure.com/.../workitems/12345` +- **Item titles with owners** — build a title → (AB#, owner) map Build a map of **S360 item title → AB# number** from the previous report. These are known-good PBI assignments from last week. -If WorkIQ returns no results or the tool is unavailable, skip this step and continue -with Step 3b. Do not fail the workflow. +If all methods fail, skip this step and continue with Step 3b. Do not fail the workflow. -#### 3b: Search ADO for existing S360 PBIs +#### 3b: Search ADO for existing S360 PBIs (tag/title search) Search for work items that are tagged `s360` OR have `S360` in the title: @@ -280,28 +335,93 @@ handled and exclude it from the "needs PBI" list. Build a map of **S360 item title → AB# number + state** from ADO. -#### 3c: Merge PBI maps +#### 3c: Keyword-based search for pre-existing PBIs (CRITICAL) + +**Why this step exists**: Team members often create PBIs for S360 items manually — +without the `[S360]` prefix or tag. Step 3b will MISS these. Skipping this step +creates **duplicate PBIs**. 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`): + +``` +search_workitem( + project: "Engineering", + areaPath: "Engineering\\Auth Client\\Broker\\Android", + searchText: "<2-4 distinctive keywords from the item title>", + state: ["Committed", "New", "Active", "In Progress"], + top: 5 +) +``` + +**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# and assignee. + +**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 PBIs. + +**Mark matched items as `existing`** — do NOT create new PBIs for them. + +#### 3d: Merge PBI maps For each S360 item, check if a PBI exists from any source: 1. The S360 API field `S360Dimensions.ADOWorkItemHTMLUrl` (already linked in S360) 2. Last week's email AB# references (from 3a) — confirmed human assignments -3. ADO search results (from 3b) — broader search, may include loosely related items +3. ADO tag/title search results (from 3b) — items explicitly tagged S360 +4. ADO keyword search results (from 3c) — catches manually-created PBIs without S360 tag -**Priority: S360-linked > last week's email > ADO search.** +**Priority: S360-linked > last week's email > keyword search (3c) > tag search (3b).** Rationale: S360-linked is authoritative. Last week's email represents a confirmed OCE -assignment (higher confidence than a title search). ADO search is a fallback that may -produce false positives from unrelated items with "S360" in the title. - -**Title matching logic** (for 3a and 3b): -- Normalize both titles: strip `[S360]` prefix, trim whitespace, lowercase -- Match if the S360 item title is **contained within** the ADO item title, or vice versa -- Example: S360 `"Update Vulnerable Container Image Reference"` matches - ADO `"[S360] Update Vulnerable Container Image Reference"` +assignment (higher confidence than a title search). Keyword search (3c) catches real +duplicates that lack the `[S360]` prefix. Tag search (3b) is broadest but may produce +false positives from unrelated items. + +**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 PBI status: `existing` (with AB# + URL), `needs-creation`, or `resolved` (PBI exists but is Done/Removed — skip from report). +#### 3e: Override Owners from ADO PBI Assignees + +After PBI matching is complete, for every item that has an existing PBI (from any source), +fetch the PBI's `System.AssignedTo` field and **override the item's owner** with the ADO +assignee. This ensures the report reflects the actual PBI owner, not the S360 default. + +1. Collect all PBI IDs from matched items +2. Call `mcp_ado_wit_get_work_items_batch_by_ids` with fields `["System.Id", "System.AssignedTo"]` +3. For each item with a PBI: + - Extract alias from ADO `System.AssignedTo` (strip `@microsoft.com`) + - Set `ownerAlias` = ADO alias + - Set `ownerName` = ADO display name +4. Items WITHOUT a PBI keep their S360-sourced owner (from Step 2) + +**Rationale**: The S360 `ActionOwnerAlias` often defaults to the service dev owner or +a team lead, while the ADO PBI has been explicitly assigned to the person doing the work. +The ADO assignment is more accurate for reporting purposes. + ### Step 4: Create Missing PBIs For items marked `needs-creation`: @@ -391,26 +511,26 @@ Write a JSON file to a temp location (e.g., `$env:TEMP/s360_data.json`) with thi "items": [ { "title": "S360 item title", - "shortTitle": "Abbreviated title for cards (optional, falls back to 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": "AB#12345 or null", + "pbi": "12345 or null (number only — 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)" + "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": "Full Name (alias)", - "pbi": "AB#12345 or null" + "assignee": "alias (just the alias — generator looks up display name from nameMap)", + "pbi": "12345 or null (number only — generator adds AB# prefix)" } ], "nameMap": { @@ -453,40 +573,18 @@ and design rationale. The generator script implements these blocks programmatica **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, Draft Email, and Preview +### Step 6: Save and Preview 1. **Save HTML** to `C:\Users\shjameel\Desktop\s360-report-{date}.html` -2. **Draft Outlook email via Graph API** (if available): - ``` - mcp_graph_microsoft_graph_suggest_queries( - intentDescription: "create a draft email message" - ) - ``` - Then call `mcp_graph_microsoft_graph_post` with the suggested endpoint: +2. **Open in browser** for preview using `Start-Process` in terminal + +3. Tell the user: ``` - relativeUrl: "/v1.0/me/messages" - body: { - "subject": "S360 Weekly Report — Android Auth Team — Week of {date}", - "body": { "contentType": "HTML", "content": "" }, - "toRecipients": [ - { "emailAddress": { "address": "androididentity@microsoft.com" } } - ], - "isDraft": true - } + 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. ``` - If the Graph call succeeds, tell the user: "📧 Outlook draft created. Open Outlook → - Drafts → review and send." - - **Fallback**: If Graph MCP is unavailable or the call fails, fall back to the file-based - approach (step 3 below). - -3. **Open in browser** for preview using `Start-Process` in terminal - -4. Tell the user the report location and next steps: - - If email draft was created: "Report saved to Desktop and Outlook draft created." - - If file-only: "Report saved to Desktop. Preview opened in browser. Copy the HTML - into a new Outlook email (Edit → Paste Special → HTML) and send to the team." ## SLA State Sort Order @@ -498,12 +596,10 @@ Within same SLA state, sort by `CurrentDueDate` ascending (earliest due first). ## Edge Cases -- **Graph MCP unavailable**: Fall back to ADO Teams API (see Step 0 fallback). If both +- **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. -- **Graph email draft fails**: Fall back to file-based approach — save HTML to Desktop and - instruct user to copy/paste into Outlook manually. Do not fail the workflow. -- **S360 MCP auth failure**: Instruct user to restart MCP server via Command Palette → +- **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. diff --git a/.github/skills/s360-reporter/generate-report.js b/.github/skills/s360-reporter/generate-report.js index 357ea242..664f7131 100644 --- a/.github/skills/s360-reporter/generate-report.js +++ b/.github/skills/s360-reporter/generate-report.js @@ -56,6 +56,7 @@ 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' }); } @@ -387,8 +388,7 @@ sortedPrograms.forEach(([progName, prog]) => { html += ` - ${esc(it.shortTitle)}
- ${esc(it.subtitle)} + ${esc(it.shortTitle || it.title)}${it.subtitle ? `
${esc(it.subtitle)}` : ''} ${esc(it.service)} ${esc(it.ownerName)}
(${esc(it.ownerAlias)}) @@ -466,7 +466,7 @@ if (noEtaItems.length > 0) {  

${noEtaItems.length} Items Missing ETA

-

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

+

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

`; diff --git a/agency.toml b/agency.toml index f3f49968..ad77bc2c 100644 --- a/agency.toml +++ b/agency.toml @@ -2,4 +2,8 @@ # Docs: https://eng.ms/docs/cloud-ai-platform/github/github-copilot-suite/agency/config [mcps.builtins] -ado = { type = "ado", organization = "IdentityDivision" } +ado = { type = "ado", organization = "IdentityDivision" } +es-chat = { type = "es-chat" } +workiq = { type = "workiq" } +m365-user = { type = "m365-user" } +s360-breeze = { type = "s360-breeze" } From ec3e4c0b871aa5ed74606595eebba994a9c3654e Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Sat, 9 May 2026 12:02:56 -0700 Subject: [PATCH 10/13] Remove unused Graph MCP server from mcp.json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/mcp.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 74891f0d..7825d821 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -49,11 +49,6 @@ "MyDRICopilot": { "type": "http", "url": "https://msalandroiddricopilot.azurewebsites.net/mcp" - }, - - "graph": { - "type": "http", - "url": "https://mcp.svc.cloud.microsoft/enterprise" } } } From df332cf0711a31abe7fbb0a11f7014e5bfed846b Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Mon, 8 Jun 2026 16:30:41 -0700 Subject: [PATCH 11/13] s360-reporter: resolve ETA from multiple fields, match Bugs too Two team feedback fixes: 1. ETA showing 'No ETA' even when set in S360 portal: - Skill previously read only `CurrentETA`. For `OutOfSla` items, the S360 portal column is labeled 'ETA (Missed SLA)' and the API surfaces the value under a different field name, leaving `CurrentETA` null. - Add new 'ETA Field Resolution' subsection listing candidate fields (`CurrentETA`, `ETA`, `MissedSLAETA`, `S360Dimensions.ETA`, `CustomDimensions.ETA`, plus a name-contains-'ETA' fallback) and a diagnostic instruction to grep the raw item JSON when the resolved value is still null on a Missed SLA item. 2. Existing Bugs not being picked up: - Skill previously treated Step 3 as PBI-only. Dome noted that S360 items with existing Bugs (common for security/compliance defects) were being flagged as 'needs creation', producing duplicate work items. - Rename Step 3 to 'Find Existing Work Items (PBIs or Bugs)'. - Step 3a: AB# references are type-agnostic; capture 'Bug NNNN' too. - Step 3b: explicitly do not type-filter the WIQL query; also fetch `System.WorkItemType` so downstream steps know Bug vs PBI. - Step 3c: pass `workItemType: ['Product Backlog Item', 'Bug']` to `search_workitem`; do not post-filter out Bug hits. - Step 3d/3e: merge and owner-override apply to both Bugs and PBIs. - Step 4: clarified that creation is still PBI-only (we never file new Bugs from this skill we only match and reuse existing ones). - Step 4b: renamed to 'Auto-Close Resolved Work Items'; works for Bugs too. - Step 5a JSON schema: `pbi` field accepts any ADO work item ID. generate-report.js needs no changes its `pbiUrl()` already builds a type-agnostic ADO work-item URL that works for both PBIs and Bugs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/s360-reporter/SKILL.md | 175 ++++++++++++++++++-------- 1 file changed, 124 insertions(+), 51 deletions(-) diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index ef788ac1..6a8b9782 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -109,7 +109,7 @@ 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 PBI IDs (e.g., `AB#12345` or `Product Backlog Item 12345`) + - **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 } @@ -240,7 +240,7 @@ The response contains an array at `result.resources`. For each item extract: | 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 | `CurrentETA` | If null and KpiId is in the "ETA Not Applicable" table → show **"N/A"**. If null and KpiId is NOT in that table → flag as **"Missing ETA ⚠"** | +| 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 = no PBI linked | @@ -260,6 +260,36 @@ Do **NOT** use any of these fields for program names — they contain internal c - `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: @@ -283,9 +313,14 @@ or similar `Title` and `TargetId`. Apply dedup in two passes: (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. -### Step 3: Find Existing PBIs (Two Sources) +### Step 3: Find Existing Work Items (PBIs or Bugs) -Before creating any new PBIs, search for existing ones from two sources. +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 @@ -302,21 +337,27 @@ question: "Find the most recent email from the last 7 days with subject containi 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 -- **Work item links** — ADO URLs like `dev.azure.com/.../workitems/12345` +- **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 PBIs (tag/title search) +#### 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: +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] +SELECT [System.Id], [System.Title], [System.State], [System.AssignedTo], [System.WorkItemType] FROM WorkItems WHERE ([System.Tags] CONTAINS 's360' OR [System.Title] CONTAINS 'S360') @@ -325,35 +366,48 @@ 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]`. +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 PBIs 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. +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** from ADO. +Build a map of **S360 item title → AB# number + state + work-item type** from ADO. -#### 3c: Keyword-based search for pre-existing PBIs (CRITICAL) +#### 3c: Keyword-based search for pre-existing work items (CRITICAL) -**Why this step exists**: Team members often create PBIs for S360 items manually — -without the `[S360]` prefix or tag. Step 3b will MISS these. Skipping this step -creates **duplicate PBIs**. This step is mandatory. +**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`): +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" @@ -366,21 +420,26 @@ search_workitem( - 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# and assignee. +- 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 PBIs. +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. +**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 PBI maps +#### 3d: Merge work-item maps -For each S360 item, check if a PBI exists from any source: -1. The S360 API field `S360Dimensions.ADOWorkItemHTMLUrl` (already linked in S360) +For each S360 item, check if a work item (PBI or Bug) exists from any source: +1. The S360 API field `S360Dimensions.ADOWorkItemHTMLUrl` (already linked in S360 — + the linked item may be a PBI or a Bug; the URL doesn't encode the type) 2. Last week's email AB# references (from 3a) — confirmed human assignments -3. ADO tag/title search results (from 3b) — items explicitly tagged S360 -4. ADO keyword search results (from 3c) — catches manually-created PBIs without S360 tag +3. ADO tag/title search results (from 3b) — items explicitly tagged S360 (Bug or PBI) +4. ADO keyword search results (from 3c) — catches manually-created items (Bug or PBI) + without the S360 tag **Priority: S360-linked > last week's email > keyword search (3c) > tag search (3b).** @@ -401,30 +460,38 @@ false positives from unrelated items. - 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 PBI status: `existing` (with AB# + URL), `needs-creation`, or -`resolved` (PBI exists but is Done/Removed — skip from report). +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 from ADO PBI Assignees +#### 3e: Override Owners from ADO Assignees -After PBI matching is complete, for every item that has an existing PBI (from any source), -fetch the PBI's `System.AssignedTo` field and **override the item's owner** with the ADO -assignee. This ensures the report reflects the actual PBI owner, not the S360 default. +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 PBI IDs from matched items -2. Call `mcp_ado_wit_get_work_items_batch_by_ids` with fields `["System.Id", "System.AssignedTo"]` -3. For each item with a PBI: +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.AssignedTo", "System.WorkItemType", "System.State"]` +3. For each item with a matched work item: - Extract alias from ADO `System.AssignedTo` (strip `@microsoft.com`) - Set `ownerAlias` = ADO alias - Set `ownerName` = ADO display name -4. Items WITHOUT a PBI keep their S360-sourced owner (from Step 2) +4. Items WITHOUT a matched work item keep their S360-sourced owner (from Step 2) **Rationale**: The S360 `ActionOwnerAlias` often defaults to the service dev owner or -a team lead, while the ADO PBI has been explicitly assigned to the person doing the work. -The ADO assignment is more accurate for reporting purposes. +a team lead, while the ADO work item has been explicitly assigned to the person doing +the work. The ADO assignment is more accurate for reporting purposes. ### Step 4: Create Missing PBIs -For items marked `needs-creation`: +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): ``` @@ -479,21 +546,24 @@ For items marked `needs-creation`: 4. Record the created AB# for each item. -### Step 4b: Auto-Close Resolved PBIs +### Step 4b: Auto-Close Resolved Work Items -For each item in the **resolved items list** (from Step 1d) that has an associated ADO PBI: +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 PBI state via `mcp_ado_wit_get_work_items_batch_by_ids` -2. If the PBI state is NOT `Done` or `Removed`, transition it to `Done`: +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: , + id: , updates: [{ path: "/fields/System.State", value: "Done" }] ) ``` -3. Log which PBIs were auto-closed for the report's "Resolved" section +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 PBI is associated with a resolved item, skip it (no action needed). +If no work item is associated with a resolved item, skip it (no action needed). ### Step 5: Generate HTML Report @@ -518,7 +588,7 @@ Write a JSON file to a temp location (e.g., `$env:TEMP/s360_data.json`) with thi "sla": "OutOfSla | ApproachingSla | InSla", "due": "Mon DD, YYYY", "eta": "Mon DD, YYYY or null", - "pbi": "12345 or null (number only — generator adds AB# prefix)", + "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", @@ -530,7 +600,7 @@ Write a JSON file to a temp location (e.g., `$env:TEMP/s360_data.json`) with thi { "title": "Resolved item title", "assignee": "alias (just the alias — generator looks up display name from nameMap)", - "pbi": "12345 or null (number only — generator adds AB# prefix)" + "pbi": "12345 or null (ADO work item ID — PBI or Bug; generator adds AB# prefix)" } ], "nameMap": { @@ -543,11 +613,14 @@ Write a JSON file to a temp location (e.g., `$env:TEMP/s360_data.json`) with thi ``` **Field notes:** -- `items`: All active S360 items from Steps 1–4 with PBI info attached +- `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 @@ -613,7 +686,7 @@ Within same SLA state, sort by `CurrentDueDate` ascending (earliest due first). 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. -- **PBI already exists but title doesn't match exactly**: Use fuzzy matching — if an ADO +- **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. From 992737c12f7e94dfc0f52033735252f772c1ed3d Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Mon, 8 Jun 2026 17:17:07 -0700 Subject: [PATCH 12/13] s360-reporter: extract merge/reduce into committed scripts Adds two scripts that encode the dedup rules the skill workflow has been re-implementing ad-hoc each week. Moving them out of prose and into code so the rules are enforced consistently and cannot be skipped or reinterpreted. merge-items.js Filters person-targeted S360 items down to team-relevant ones and deduplicates by KpiActionItemId. Drops items that match only on AssignedTo (the person query already filters on assignedTo, so that alone is not a relevance signal). reduce-items.js Groups merged items into logical report rows. Per-finding items (items whose URL contains a _workitems/edit/{id} link) get their own row keyed on the ADO work-item ID. Items without per-finding work items can umbrella-merge by KpiId+baseTitle+TargetId (e.g. CFS multi-endpoint case). Includes: - Reused-ID detection (a shared template work item must not collapse unrelated rows). - Known per-finding KPI set (Nightwatch) as defense-in-depth fallback so a missing URL cannot silently re-introduce the umbrella bug. - Strict ISO date parsing for ETA candidate fields. - Deterministic sort: same input always produces identical output. - URL-coverage and ID-conflict warnings to stderr. SKILL.md Step 1c now invokes merge-items.js; Step 2 invokes reduce-items.js. The dedup rules are kept as the reference spec the scripts implement, so reviewers can verify behavior and the rules remain enforceable as a manual fallback if Node.js is unavailable. Verified on the current week's data: - 13 Nightwatch Security Code Bugs 13 distinct rows (regression case). - 22 SDL Annual Assessment Bugs 22 distinct rows (the old hard-coded "SDL = 1 row per service" logic was silently hiding all of them; same class of bug as Nightwatch). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/s360-reporter/SKILL.md | 182 +++++++++-- .github/skills/s360-reporter/merge-items.js | 168 ++++++++++ .github/skills/s360-reporter/reduce-items.js | 314 +++++++++++++++++++ 3 files changed, 642 insertions(+), 22 deletions(-) create mode 100644 .github/skills/s360-reporter/merge-items.js create mode 100644 .github/skills/s360-reporter/reduce-items.js diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index 6a8b9782..3d106a15 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -11,7 +11,8 @@ HTML email report. ## Prerequisites -- **Node.js** must be available (for the committed report generator script) +- **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 @@ -174,8 +175,45 @@ fabricate one. #### 1c: Merge results -Combine items from 1a and 1b. Deduplicate by `KpiActionItemId` — if the same item -appears in both searches, keep only one copy. +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) @@ -230,7 +268,59 @@ not appear here." ### Step 2: Parse and Deduplicate Items -The response contains an array at `result.resources`. For each item extract: +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 item the reducer extracts (and the rest of the workflow consumes): | Field | JSON Path | Notes | |-------|-----------|-------| @@ -243,8 +333,8 @@ The response contains an array at `result.resources`. For each item extract: | 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 = no PBI linked | -| S360 URL | `URL` | Remediation/action link from S360 API (aka.ms, IcM, ADO, etc.) | +| 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 | @@ -303,8 +393,11 @@ overly technical text that is not suitable for display. Apply these cleanups: 3. **Clean up resolved item titles** — Apply the same sanitization to resolved items before rendering in the report. -**Dedup**: Some items appear multiple times with different `KpiActionItemId` but same -or similar `Title` and `TargetId`. Apply dedup in two passes: +**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`). @@ -313,6 +406,34 @@ or similar `Title` and `TargetId`. Apply dedup in two passes: (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 @@ -434,19 +555,26 @@ 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. The S360 API field `S360Dimensions.ADOWorkItemHTMLUrl` (already linked in S360 — - the linked item may be a PBI or a Bug; the URL doesn't encode the type) -2. Last week's email AB# references (from 3a) — confirmed human assignments -3. ADO tag/title search results (from 3b) — items explicitly tagged S360 (Bug or PBI) -4. ADO keyword search results (from 3c) — catches manually-created items (Bug or PBI) +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: S360-linked > last week's email > keyword search (3c) > tag search (3b).** +**Priority: URL-parsed ADO ID (source 1) > ADOWorkItemHTMLUrl (source 2) > last week's +email > keyword search (3c) > tag search (3b).** -Rationale: S360-linked is authoritative. Last week's email represents a confirmed OCE -assignment (higher confidence than a title search). Keyword search (3c) catches real -duplicates that lack the `[S360]` prefix. Tag search (3b) is broadest but may produce -false positives from unrelated items. +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 @@ -475,14 +603,24 @@ owner, not the S360 default. 2. Call `mcp_ado_wit_get_work_items_batch_by_ids` with fields `["System.Id", "System.AssignedTo", "System.WorkItemType", "System.State"]` 3. For each item with a matched work item: - - Extract alias from ADO `System.AssignedTo` (strip `@microsoft.com`) - - Set `ownerAlias` = ADO alias - - Set `ownerName` = ADO display name + - **Skip the 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. 4. Items WITHOUT a matched work item keep their S360-sourced owner (from Step 2) **Rationale**: 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 for reporting purposes. +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. ### Step 4: Create Missing PBIs 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..f3944922 --- /dev/null +++ b/.github/skills/s360-reporter/reduce-items.js @@ -0,0 +1,314 @@ +#!/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, + // 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)}`); + } +} + +// ── 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'); +} From 7a8e637083427f2a696a57d51bcd4874f17a2418 Mon Sep 17 00:00:00 2001 From: Shahzaib Date: Mon, 8 Jun 2026 23:02:10 -0700 Subject: [PATCH 13/13] More updates to S360 Reporter --- .github/skills/s360-reporter/SKILL.md | 35 ++++++++++----- .github/skills/s360-reporter/reduce-items.js | 46 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/.github/skills/s360-reporter/SKILL.md b/.github/skills/s360-reporter/SKILL.md index 3d106a15..198e1d82 100644 --- a/.github/skills/s360-reporter/SKILL.md +++ b/.github/skills/s360-reporter/SKILL.md @@ -320,11 +320,11 @@ The script will still do the right thing if you forget (URL-based grouping handl it as long as URLs are populated) — the set is a defense-in-depth fallback for missing URLs. -For each item the reducer extracts (and the rest of the workflow consumes): +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. | +| 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.** | @@ -338,6 +338,7 @@ For each item the reducer extracts (and the rest of the workflow consumes): | 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` | @@ -591,7 +592,7 @@ PBI for the whole KPI) and losing the per-bug granularity. 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 from ADO Assignees +#### 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 @@ -601,9 +602,9 @@ 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.AssignedTo", "System.WorkItemType", "System.State"]` + `["System.Id", "System.Title", "System.AssignedTo", "System.WorkItemType", "System.State"]` 3. For each item with a matched work item: - - **Skip the override if the ADO assignee is a bot / automation account.** Treat as + - **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) @@ -615,13 +616,27 @@ owner, not the S360 default. 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. -4. Items WITHOUT a matched work item keep their S360-sourced owner (from Step 2) - -**Rationale**: 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, + - **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 diff --git a/.github/skills/s360-reporter/reduce-items.js b/.github/skills/s360-reporter/reduce-items.js index f3944922..8b9a0d93 100644 --- a/.github/skills/s360-reporter/reduce-items.js +++ b/.github/skills/s360-reporter/reduce-items.js @@ -272,6 +272,13 @@ for (const [groupKey, group] of groups) { 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() }); @@ -304,6 +311,45 @@ if (collapsed.length) { } } +// ── 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) {