diff --git a/.talismanrc b/.talismanrc index 9ddfed208b..a02360d032 100644 --- a/.talismanrc +++ b/.talismanrc @@ -5,3 +5,42 @@ fileignoreconfig: version: '1.0' - filename: .github/workflows/release-production-pipeline.yml checksum: dd858a2c2a3297c5651c2843ddae73ad0776a0386329200d01b051ddc489871e +- filename: skills/release/references/sheets-api.md + checksum: e0e05865f67f07365e6e17e6b6d2d1a944c70ba4ee7fd8be555dbaf5a0e5ae6a + version: '1.0' +- filename: skills/release/scripts/build-deploy-plan.mjs + checksum: 59426bc226d31b39f00d5e95031cb47acef0c5835d02766b3bccb610ef15c4c1 + version: '1.0' +- filename: skills/back-merge/SKILL.md + checksum: fb9a17abf0d532ac5ff1580051408bc6d00b8ea0d9b7c02ac5a2128fa5efc717 + version: '1.0' +- filename: skills/release/scripts/refresh-google-token.sh + checksum: 32321da36f711ee265bb084e0f6e174fc6fe07b4037f5cfbb4ae4fff3383304e + version: '1.0' +- filename: skills/release/references/google-credentials.example.json + checksum: aabfa2f9d65835a43b7541dfe379f0b968f733d93be2eeac82b9b618c9cae7e2 + version: '1.0' +- filename: skills/release/scripts/fetch-release-data.mjs + checksum: ce8bb03c1e374abf2dd6c6aaf6da37c66e7d5322a8bbdff3934ddf64f89c17ed + version: '1.0' +- filename: skills/release/SKILL.md + checksum: 48be9892b46329734ebd953249d70532f2badc3399a49352c5a9aa0b51b29369 + version: '1.0' +- filename: skills/release/scripts/check-prs.mjs + checksum: 951efbb87e8ff3594e4117b50b970143c5485a02648df6daca55ee0662819d67 + version: '1.0' +- filename: skills/resolve-snyk/SKILL.md + checksum: c85525c91ff4da6b79e9446bc032641689403688cbb4409e193fb4005f75ff6d + version: '1.0' +- filename: skills/back-merge/scripts/back-merge.sh + checksum: 4adac8f3c3348aa5aaf6bdb4d92a99189c120a4687c5788b2783a9f96b1906e5 + version: '1.0' +- filename: skills/release/references/config.example.json + checksum: 67d56e7573403378fdb6b9500e70effd74111963ae0f72fde8183c8b2786e67f + version: '1.0' +- filename: skills/release/scripts/copy-template-sheet.sh + checksum: fac7d2eddab079ec010a31bdfedd6d3d4a53e4e2bfa0a7848100f185d6ac8a04 + version: '1.0' +- filename: skills/release/references/.gitignore + checksum: 7ef9f9de95ad10f7d7ab5a4ee1477bdc013ebe46bb227741b062a3b809d4566b + version: '1.0' diff --git a/skills/back-merge/SKILL.md b/skills/back-merge/SKILL.md new file mode 100644 index 0000000000..e356fb37aa --- /dev/null +++ b/skills/back-merge/SKILL.md @@ -0,0 +1,254 @@ +--- +name: back-merge +description: > + Creates or checks back-merge PRs (main/master β†’ development) across repos after a release. + Use whenever the user wants to check or create back-merge PRs, verify post-release branch sync, + or audit which repos are missing a back-merge. + Triggers on: /back-merge, "back merge", "backmerge", "check back merges", "create back-merge PRs", + "are back merges done", "post-release back merge". +--- + +# Back-Merge Skill + +Creates or checks PRs that merge `main/master β†’ development` across repos after a release. +Supports two input modes and two run modes. + +--- + +## Usage + +When this skill activates, greet the user with this help block before doing anything else: + +``` +πŸ‘‹ /back-merge β€” Back-merge PR checker & creator + +How to use: + /back-merge β†’ I'll ask how to specify repos + /back-merge "" β†’ pull repos from a Jira release + /back-merge ... β†’ use a direct repo list + /back-merge "" ... β†’ combine both sources + +Modes (specify after your input, or I'll ask): + check β€” read-only audit, shows what needs back-merging (default, recommended) + create β€” raises PRs for all repos that need them (asks for confirmation first) + +Examples: + /back-merge "DX | 16-08-2026 | Release" check + /back-merge https://github.com/org/repo1 https://github.com/org/repo2 + /back-merge "PROJ | 16-08-2026 | Release" create + +Requirements: + gh CLI β†’ brew install gh && gh auth login + Jira MCP (only needed for fixVersion mode) β€” must be connected in Claude Code +``` + +Only show this block once at the start. Then proceed to collect inputs. + +--- + +## Inputs + +| Input | Format | Example | +|-------|--------|---------| +| `fixVersion` | Jira fixVersion string | `PROJ \| 16-08-2026 \| Release` | +| `repo_list` | Space or comma-separated GitHub URLs | `https://github.com/org/repo1 https://github.com/org/repo2` | +| `mode` | `check` or `create` | `check` | + +**If neither fixVersion nor repo_list is provided** β€” use AskUserQuestion: + +> **Question:** "How would you like to specify the repos to back-merge?" +> - `fixVersion` β€” pull repos from a Jira release (e.g. `PROJ | 16-08-2026 | Release`) +> - `Repo list` β€” paste GitHub repo URLs directly +> - `Both` β€” combine repos from a fixVersion AND a manual list +> +> *(User can select "Other" to type a custom value)* + +Do not proceed without knowing the scope. + +**If mode is not provided** β€” use AskUserQuestion: + +> **Question:** "Which mode do you want to run?" +> - `Check` β€” read-only, shows what needs back-merging without creating any PRs *(Recommended)* +> - `Create PRs` β€” creates back-merge PRs for all repos that need them +> +> *(User can select "Other" to specify a custom behaviour)* + +--- + +## Step 1 β€” Resolve Repo List + +### 1a β€” From fixVersion + +**Extract the project key** from the fixVersion string β€” it is the first segment before the first `|`: +- `"DX | 16-08-2026 | Release"` β†’ project key = `DX` +- `"PROJ | 16-08-2026 | Release"` β†’ project key = `PROJ` + +Query Jira using the extracted project key: + +``` +project = {project_key} AND fixVersion = "{fixVersion}" ORDER BY created ASC +``` + +Scan every ticket's `comment.comments[].body` AND the master release ticket's `description` for GitHub PR URLs: + +``` +pattern: https://github\.com/[^/]+/[^/]+/pull/\d+ +``` + +For each unique PR URL, extract `owner` and `repo` from the URL path. Deduplicate by `owner/repo`. This is your repo list. + +If no PR URLs are found β†’ warn the user and stop: +> `⚠️ No GitHub PRs found for fixVersion "{fixVersion}". Cannot determine repo scope.` + +### 1b β€” From repo_list + +Parse each GitHub URL to extract `owner/repo`: +- `https://github.com/org/repo-name` β†’ `org/repo-name` + +Deduplicate silently. If a URL can't be parsed β†’ skip it and flag: +> `⚠️ Could not parse repo from URL: {url} β€” skipped.` + +### Step 1 output β€” display resolved repos before proceeding + +After resolving the repo list, always show this table: + +``` +πŸ“‹ Repos resolved (N total) + +# | Repo | Source +--|---------------------------|-------- +1 | org/repo-one | fixVersion +2 | org/repo-two | manual list +3 | org/repo-three | fixVersion +``` + +Do not proceed to Step 2 until this table is shown. + +--- + +## Step 2 & 3 β€” Run the bundled script + +Use `scripts/back-merge.sh` β€” do not regenerate this logic inline. + +```bash +SKILL_DIR="$(dirname "$(realpath "$0")")/.." # or: ~/.claude/skills/back-merge + +# check mode +bash "$SKILL_DIR/scripts/back-merge.sh" check \ + owner/repo1 owner/repo2 ... + +# create mode β€” pass the full fixVersion string as the second argument +bash "$SKILL_DIR/scripts/back-merge.sh" create "{fixVersion}" \ + owner/repo1 owner/repo2 ... + +# create mode β€” no fixVersion (repo list only) +bash "$SKILL_DIR/scripts/back-merge.sh" create "" \ + owner/repo1 owner/repo2 ... +``` + +**Before running in create mode** β€” show the resolved repos table from Step 1 if not already visible, then use AskUserQuestion: + +> **Question:** "Ready to create back-merge PRs for the N repos listed above. Proceed?" +> - `Yes, create PRs` β€” proceed with PR creation +> - `No, abort` β€” stop without making any changes +> - `Check only` β€” switch to check mode instead (read-only, no PRs created) +> +> *(User can select "Other" to specify which repos to skip or override)* + +Do not create any PRs until the user confirms. + +The script outputs one pipe-delimited line per repo: + +``` +owner/repo|STATUS_CODE|branch_used|details +``` + +| STATUS_CODE | Meaning | +|-------------|---------| +| `IN_SYNC` | branches are in sync | +| `NEEDS_MERGE` | back-merge needed (`details` = "base is N commits ahead (status)") | +| `PR_OPEN` | open PR already exists (`details` = "PR#N url") | +| `CREATED` | PR created (`details` = PR URL) | +| `NO_DEV` | no development branch | +| `NO_BASE` | no main or master branch | +| `ACCESS_ERROR` | API error / no access | +| `ERROR` | PR creation failed (`details` = error message) | + +Parse the output and render the table below. + +### Check mode output table + +``` +Repo | Status | Details +org/repo-one | βœ… In sync | β€” +org/repo-two | ⚠️ Back-merge needed | main is 4 commits ahead +org/repo-three | πŸ”„ PR open β€” pending merge | PR#456 +org/repo-four | ❌ No dev branch | β€” +org/repo-five | ❌ Access error | β€” +``` + +Do not create any PRs in check mode. + +### Create mode output table + +``` +Repo | Result | PR +org/repo-one | βœ… Created | PR#789 +org/repo-two | ⏭️ Skipped β€” PR open | PR#456 +org/repo-three | ⏭️ Skipped β€” in sync | β€” +org/repo-four | ⏭️ Skipped β€” no dev branch | β€” +``` + +**`ERROR` rows**: report the details verbatim β€” do not retry silently. If details contains "No commits between", treat it as `IN_SYNC`. + +--- + +## Corner Cases Handled + +| # | Scenario | Behaviour | +|---|----------|-----------| +| 1 | Repo has no `development` branch | Flagged `❌ No dev branch`, skipped | +| 2 | Repo has no `main` or `master` | Flagged `❌ No main/master branch`, skipped | +| 3 | main and development are identical (in sync) | Flagged `βœ… In sync`, no PR created | +| 4 | Back-merge PR already open | Reported as `πŸ”„ pending merge`, not duplicated | +| 5 | Back-merge PR was closed (not merged) | New PR created β€” closed β‰  done | +| 6 | Branches diverged (both have unique commits) | PR created anyway β€” GitHub surfaces conflicts | +| 7 | `gh` cannot access repo (private / permissions) | Flagged `❌ Access error`, skipped | +| 8 | Duplicate repos in manual list | Deduplicated silently before processing | +| 9 | Unparseable URL in manual list | Flagged and skipped, rest continues | +| 10 | fixVersion has no PRs / no repos found | Warn user and stop β€” no scope to act on | +| 11 | PR creation fails with "no commits between" | Treated as `βœ… In sync` β€” already resolved | +| 12 | Mode not specified | Ask via AskUserQuestion (see Inputs section) | +| 13 | Repo list + fixVersion both provided | Merge both lists, deduplicate, proceed | + +--- + +## Final Summary Output + +After all repos are processed: + +``` +βœ… Back-merge run complete + +Mode: check | create +Input: fixVersion "{fixVersion}" | {N} repos from list + +πŸ“Š Summary: + ⚠️ Needs back-merge: + πŸ”„ PR open (pending merge): + βœ… In sync: + ❌ Skipped (no dev branch): + ❌ Skipped (access error): + +[create mode only] + βœ… PRs created: β€” +``` + +--- + +## Error Handling + +- **`gh` not authenticated**: Run `gh auth status`; ask user to run `gh auth login` if needed +- **Jira MCP not available**: Cannot resolve fixVersion β€” ask user to switch to repo_list input mode +- **All repos in sync**: Report clearly β€” no action needed +- **Partial failures**: Always continue processing remaining repos; report failures at the end diff --git a/skills/back-merge/scripts/back-merge.sh b/skills/back-merge/scripts/back-merge.sh new file mode 100755 index 0000000000..30229d0de0 --- /dev/null +++ b/skills/back-merge/scripts/back-merge.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# back-merge.sh β€” check or create back-merge PRs (master/main β†’ development) +# +# Usage: +# back-merge.sh check [ ...] +# back-merge.sh create [ ...] +# +# create mode PR title: " | Back-merge" (pass "" for plain "Back-merge") + +MODE="$1"; shift +FIX_VERSION="" +if [ "$MODE" = "create" ]; then + FIX_VERSION="$1"; shift +fi + +REPOS=("$@") + +PR_TITLE="${FIX_VERSION:+$FIX_VERSION | }Back-merge" + +for REPO in "${REPOS[@]}"; do + # 2a β€” dev branch + DEV=$(gh api "repos/${REPO}/branches/development" 2>/dev/null | jq -r '.name // empty') + if [ -z "$DEV" ]; then + echo "${REPO}|NO_DEV|β€”|β€”" + continue + fi + + # 2b β€” main or master + MAIN=$(gh api "repos/${REPO}/branches/main" 2>/dev/null | jq -r '.name // empty') + MASTER=$(gh api "repos/${REPO}/branches/master" 2>/dev/null | jq -r '.name // empty') + if [ -n "$MAIN" ]; then + BASE="main" + elif [ -n "$MASTER" ]; then + BASE="master" + else + echo "${REPO}|NO_BASE|β€”|β€”" + continue + fi + + # 2c β€” existing open back-merge PR + OPEN_PR=$(gh pr list --repo "$REPO" --head "$BASE" --base development --state open \ + --json number,url --jq 'if length > 0 then .[0] | "\(.number)|\(.url)" else "" end' 2>/dev/null) + if [ -n "$OPEN_PR" ]; then + PR_NUM=$(echo "$OPEN_PR" | cut -d'|' -f1) + PR_URL=$(echo "$OPEN_PR" | cut -d'|' -f2) + echo "${REPO}|PR_OPEN|${BASE}|PR#${PR_NUM} ${PR_URL}" + continue + fi + + # 2d β€” compare + COMPARE=$(gh api "repos/${REPO}/compare/development...${BASE}" \ + --jq '{ahead_by:.ahead_by,status:.status}' 2>/dev/null) + AHEAD=$(echo "$COMPARE" | jq -r '.ahead_by // "error"') + STATUS=$(echo "$COMPARE" | jq -r '.status // "error"') + + if [ "$AHEAD" = "error" ] || [ "$STATUS" = "404" ] || [ "$STATUS" = "error" ]; then + echo "${REPO}|ACCESS_ERROR|${BASE}|β€”" + continue + fi + + if [ "$AHEAD" -gt 0 ] 2>/dev/null; then + if [ "$MODE" = "check" ]; then + echo "${REPO}|NEEDS_MERGE|${BASE}|${BASE} is ${AHEAD} commits ahead (${STATUS})" + else + # create mode β€” check for closed PR (just note it, don't block) + CLOSED=$(gh pr list --repo "$REPO" --head "$BASE" --base development --state closed \ + --json number,url --jq '.[0].number' 2>/dev/null) + CLOSED_NOTE="${CLOSED:+previous closed PR#$CLOSED}" + + RESULT=$(gh pr create \ + --repo "$REPO" \ + --base development \ + --head "$BASE" \ + --title "$PR_TITLE" \ + --body "Back-merge of \`${BASE}\` into \`development\` following ${FIX_VERSION:-release}." 2>&1) + + if echo "$RESULT" | grep -q "^https://"; then + echo "${REPO}|CREATED|${BASE}|${RESULT}${CLOSED_NOTE:+ ($CLOSED_NOTE)}" + elif echo "$RESULT" | grep -qi "no commits between"; then + echo "${REPO}|IN_SYNC|${BASE}|β€”" + else + echo "${REPO}|ERROR|${BASE}|${RESULT}" + fi + fi + else + echo "${REPO}|IN_SYNC|${BASE}|β€”" + fi +done diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md new file mode 100644 index 0000000000..ed0558383e --- /dev/null +++ b/skills/release/SKILL.md @@ -0,0 +1,1048 @@ +--- +name: release +description: > + End-to-end release automation. Fetches Jira tickets for a fix version, verifies GitHub PRs, + builds release notes, creates release PRs, writes a CAB Google Sheet, raises a release notes + ticket, and generates a Confluence SDK changelog table. + Use this skill whenever the user mentions running a release, starting a release process, + creating a release sheet, or any step in the release workflow β€” including fetching Jira tickets, + creating the Google Sheet, raising release notes tickets, or updating the SDK changelog. + Triggers on: /release, "run the release", "start the release", "create release sheet", + "do the release for", "kick off the release". +--- + +# Release Skill + +Automates the full release process from Jira ticket fetch through Google Sheet creation, +release notes ticket creation (CLI), and Confluence SDK changelog update (SDK). + +--- + +## Usage + +When this skill activates, greet the user with this help block before doing anything else: + +``` +πŸ‘‹ /release β€” End-to-end release automation + +How to use: + /release β†’ I'll collect all inputs via prompts + /release "PROJ | 16-08-2026 | Release" β†’ start with a fix version + /release --dry-run β†’ preview everything, no writes + +What I'll do: + 1. Fetch Jira tickets for the fix version + 2. Verify GitHub PRs (check merge state + dev branch status) + 2b. Build release notes (categorised by type) + 3. Build deployment plan (package versions, owners, platforms) + 4. Build rollback plan + 5. Create release PRs (dev β†’ staging β†’ main, per repo topology) + 6. Create CAB Google Sheet (Ticket List, Deployment Plan, Rollback Plan tabs) + 7. Create release notes ticket in your tracking project (CLI scope) + 8. Generate SDK Confluence changelog table (SDK scope) + +Options: + --dry-run All reads run normally. No Jira comments, no PRs, no Sheet, no tickets. + Dry-run is recommended for a first pass β€” shows you exactly what would happen. + +Requirements: + gh CLI β†’ brew install gh && gh auth login + Jira MCP β†’ must be connected in Claude Code + Google OAuth β†’ see references/google-credentials.example.json for setup (live runs only) + Config β†’ copy references/config.example.json β†’ references/config.json and fill in +``` + +Only show this block once at the start. Then proceed to load config and collect inputs. + +--- + +## Configuration + +Before collecting inputs, read `$HOME/.claude/skills/release/references/config.json` using the +Read tool. If the file exists, parse it as `config`. If the file does not exist or a value is +missing, ask the user for it and offer to save it for future runs. + +| Key | Description | Required when | +|-----|-------------|---------------| +| `google_sheet_template_id` | Google Drive file ID of your CAB sheet template | Live run, Step 6 | +| `confluence_sdk_page_id` | Confluence page ID for SDK changelog | SDK scope, Step 8 | +| `td_project_key` | Jira project key for release notes tickets (e.g. `TD`) | CLI scope, Step 7 | +| `td_assignee_account_id` | Jira account ID of the TD ticket assignee | Optional, Step 7 | +| `secondary_reviewer_account_id` | Jira account ID for PR comment CC (e.g. your release manager) | Optional, Step 2 | + +If a required value is missing at the point it is needed, ask the user: +> "I couldn't find `{key}` in references/config.json. Please provide your {description}:" + +Then offer: +> "Would you like me to save this to references/config.json so you don't have to enter it again?" + +If the user agrees, append the value to config.json using the Write tool. + +--- + +## Input Collection (before Step 1) + +Collect all required inputs before running any steps. Use `AskUserQuestion` for every missing +input β€” never ask in plain prose. + +### Run mode (if `--dry-run` was not passed in the invocation) + +Call `AskUserQuestion`: +``` +header: "Run mode" +question: "How do you want to run this release?" +options: + - label: "Dry run β€” preview everything, no writes (Recommended)" + description: "All reads run normally. No Jira comments, no PRs, no Sheet, no tickets." + - label: "Live run β€” execute all steps for real" + description: "Posts Jira comments, creates release PRs, writes the CAB Sheet, creates the release notes ticket." +multiSelect: false +``` + +If the user selects "Dry run", set `--dry-run` mode for the entire run. + +### Fix version (if not provided in the invocation) + +Call `AskUserQuestion`: +``` +header: "Fix Version" +question: "What is the Jira fix version for this release? (e.g. PROJ | 16-08-2026 | Release)" +options: + - label: "Enter fix version" + description: "Type your fix version string β€” format: PROJECT | DD-MM-YYYY | Release (or Hotfix)" +multiSelect: false +``` + +The user selects "Other" to type the exact fix version string. Derive automatically from the value: +- `project_key` β†’ first segment before the first `|` (e.g. `DX`, `PROJ`) +- `release_date` β†’ middle segment e.g. `16-08-2026` +- `release_type` β†’ last segment e.g. `Release` or `Hotfix` + +### Scope (if not provided in the invocation) + +Call `AskUserQuestion`: +``` +header: "Scope" +question: "Which packages are in scope for this release?" +options: + - label: "Both CLI and SDK" + description: "Runs Steps 7 (release notes ticket) and 8 (Confluence changelog)" + - label: "CLI only" + description: "Runs Step 7. Skips Step 8." + - label: "SDK only" + description: "Runs Step 8. Skips Step 7." +multiSelect: false +``` + +--- + +**Dry-run behaviour summary** (applies when `--dry-run` is active): + +| Step | Normal action | Dry-run action | +|------|---------------|----------------| +| Step 2 β€” Jira comments | Post ADF comment on affected tickets | Print a list of tickets that WOULD receive a comment, with reason | +| Step 5c β€” Release PRs | `gh pr create` / `gh pr edit` | Print a `[DRY RUN]` block per repo: topology, headβ†’base, title, version bump, filtered release notes | +| Step 6 β€” CAB Sheet | Copy template + populate via Sheets API | Skip OAuth + Drive entirely; render all tab data as markdown tables | +| Step 7 β€” Release notes ticket | `createJiraIssue` + `editJiraIssue` | Print the exact summary and description body that would be created | + +Steps 1, 2 (data fetch), 2b, 3, 4, 5a, 5b run fully in both modes β€” they are read-only. + +--- + +## Execution Steps + +Run steps in order. Each step depends on the previous one's output. + +**Working directory:** create once at the start of every run: +```bash +mkdir -p /tmp/release-run +``` +All intermediate JSON files go here. Do NOT use the `scripts/` directory for temp files. + +--- + +### Step 1 β€” Fetch Jira Tickets + +Call `searchJiraIssuesUsingJql` with: +- jql: `project = {project_key} AND fixVersion = "{fixVersion}" ORDER BY created ASC` +- fields: `["key","summary","issuetype","parent","status","created","assignee","reporter","labels","comment"]` +- maxResults: 100 + +Save the MCP response JSON to `/tmp/release-run/jira-raw.json` using the Write tool. + +If the response `total` exceeds `maxResults` (i.e. `startAt + maxResults < total`), the results are paginated. Fetch subsequent pages by calling `searchJiraIssuesUsingJql` again with `startAt` incremented by `maxResults` until all tickets are retrieved. Save each additional page as `jira-raw-2.json`, `jira-raw-3.json`, etc. + +Then compress: +```bash +node "$HOME/.claude/skills/release/scripts/fetch-release-data.mjs" \ + /tmp/release-run/jira-raw.json \ + --fix-version "${fixVersion}" \ + > /tmp/release-run/release-tickets.json +``` +(append additional page files if paginated: `… jira-raw-2.json jira-raw-3.json …`) + +The `--fix-version` flag is the fallback when no master tracking ticket exists in the Jira results β€” the script derives `releaseDate` and `releaseType` from it instead of leaving them null. + +Load `/tmp/release-run/release-tickets.json` as `ticket_data`. All subsequent steps read from `ticket_data`, not from the raw MCP response. + +#### Step 1 Summary + +Display the following after Step 1 completes: + +``` +--- +βœ… Step 1 complete β€” Jira Tickets Fetched + +Fix Version: {ticket_data.fixVersion} +Release Date: {ticket_data.releaseDate} +Release Type: {ticket_data.releaseType} +Master Ticket: {ticket_data.masterTicketKey} (or "none found") +Total Tickets: {ticket_data.tickets.length} + +Ticket breakdown: + Ready to Deploy / Done / Closed: {count of tickets NOT in notReadyToDeploy} + Not yet ready: {ticket_data.notReadyToDeploy.length} + +Not-ready tickets: {ticket_data.notReadyToDeploy.join(', ') or "none"} +--- +``` + +If `notReadyToDeploy` is non-empty, warn the user β€” but do not stop. + +Then call `AskUserQuestion`: +``` +header: "Step 1 done" +question: "Jira tickets fetched. Ready to verify GitHub PRs?" +options: + - label: "Continue to Step 2 (Recommended)" + - label: "Stop here" +multiSelect: false +``` + +--- + +### Step 2 β€” Extract & Verify GitHub PRs + +PR URLs were already extracted from descriptions and comments by the Step 1 script. Run the verification script: + +```bash +node "$HOME/.claude/skills/release/scripts/check-prs.mjs" \ + /tmp/release-run/release-tickets.json \ + > /tmp/release-run/pr-status.json +``` + +Load `/tmp/release-run/pr-status.json` as `pr_data`. + +- `pr_data.repos` β€” per-repo summary: verified/unverified/open PR counts, eligible flag, changed file paths +- `pr_data.flagged.needsJiraComment` β€” pre-computed list of tickets that need a warning comment +- `pr_data.flagged.notInDev` β€” merged PRs whose commit is not in development + +**Flag unmerged PRs to the user** β€” list any `pr_data.prs` where `state !== 'MERGED'` and `isReleasePR === false` and `!error`. + +**Comment on Jira ticket when a feature/fix PR is not in development:** + +A PR is a **release PR** if `isReleasePR === true` (`headRefName === "development"`) β€” open by design, no comment needed. + +For each entry in `pr_data.flagged.needsJiraComment` β€” post a comment using `addCommentToJiraIssue` on that **feature ticket**. + +The entry already contains `assigneeAccountId` (null if unassigned). Use it directly β€” no separate `lookupJiraAccountId` call needed. + +**If `--dry-run`:** Do NOT post any comment. Instead, print: + +``` +[DRY RUN] Jira comments that would be posted: + {ticket_key} (assigned to: ) β€” PR is OPEN / not in development + Would notify: @{secondary_reviewer_mention} + (none β€” all PRs verified) +``` + +Where `{secondary_reviewer_mention}` = ` + @` if `config.secondary_reviewer_account_id` is set, otherwise omit. + +**If NOT dry-run:** Build the comment body as ADF. Include a `mention` node for the ticket owner (if assigned). If `config.secondary_reviewer_account_id` is set, also include a mention for the secondary reviewer: + +> "Hi @[owner], the PR [URL] has not yet been merged into the `development` branch, but this ticket's fix version is already set to [fixVersion]. Please ensure the PR is merged into development before the release date.[cc_line]" + +Where `[cc_line]` = ` cc @` if `config.secondary_reviewer_account_id` is set, otherwise omit. + +**A repo proceeds to the deployment plan (Step 3) if at least one of its PRs is merged and verified in development (or flagged as no-dev-branch).** Repos where every PR is unmerged are excluded from the deployment plan. Step 5 (release PR creation) still runs on all repos regardless of PR merge state. + +#### Step 2 Summary + +Display the following after Step 2 completes: + +``` +--- +βœ… Step 2 complete β€” GitHub PRs Verified + +Total PRs found: {pr_data.prs.length} +Total repos: {pr_data.repos.length} + +Per-repo status: + Repo | Eligible | Verified | Unverified | Open | Rebase-merged | Fetch failed + {repo} | {yes/no} | {N} | {N} | {N} | {N} | {N} + ... + +Flagged: + Tickets needing Jira comment: {pr_data.flagged.needsJiraComment.length} β€” {list of keys or "none"} + PRs not in dev branch: {pr_data.flagged.notInDev.length} β€” {list of repos/PRs or "none"} + Unmerged feature PRs: {count} β€” {list or "none"} + +Jira comments: {N posted / DRY RUN β€” would post N} +--- +``` + +Then call `AskUserQuestion`: +``` +header: "Step 2 done" +question: "PRs verified. Ready to build release notes?" +options: + - label: "Continue to Step 2b (Recommended)" + - label: "Stop here" +multiSelect: false +``` + +--- + +### Step 2b β€” Build Release Notes + +Build `release_notes` from `ticket_data.tickets` (exclude the master release tracking ticket β€” the one whose `key === ticket_data.masterTicketKey`). This content is reused as: +- The body of release PRs created in Step 5 (filtered per repo β€” see Step 5c) +- The release notes section in the final output + +**Categorise each ticket:** + +| Condition | Category | +|-----------|----------| +| Ticket type = Bug AND summary contains a CVE-style vulnerability name (e.g. "Fix Cleartext Transmission", "Fix Command Injection") | Security | +| Ticket type = Bug, any other summary | Bug Fix | +| Ticket type = Task AND the change adds new behaviour or a new API | New Feature | +| Ticket type = Task AND the change improves or refines existing behaviour | Enhancement | + +**Write one human-readable sentence per logical change** β€” not per ticket. Group duplicates (e.g., the same vulnerability fixed across multiple repos) into a single line. Do NOT include ticket keys, assignee names, or any internal identifiers. + +**Format (markdown):** + +```markdown +### New features +- + +### Enhancements +- + +### Bug fixes +- + +### Security +- +``` + +Omit any heading whose list would be empty. + +Store as `release_notes` string. + +**Also build and persist a `ticket_key β†’ [note_lines]` map** β€” the list of note lines that each ticket contributed to. Write it to `/tmp/release-run/note-lines-map.json` immediately after building it. This map is never shown to the user; it is read in Step 5c to filter the PR body to only the lines relevant to each repo. + +#### Step 2b Summary + +Display the following after Step 2b completes: + +``` +--- +βœ… Step 2b complete β€” Release Notes Built + +Categories found: {list of non-empty categories β€” New Features / Enhancements / Bug Fixes / Security} +Total note lines: {N} + +Release Notes Preview: +────────────────────── +{release_notes} +────────────────────── +--- +``` + +Then call `AskUserQuestion`: +``` +header: "Step 2b done" +question: "Release notes built. Ready to build the deployment plan?" +options: + - label: "Continue to Step 3 (Recommended)" + - label: "Stop here" +multiSelect: false +``` + +--- + +### Step 3 β€” Build Deployment Plan + +Run the deploy plan script (covers this step plus Steps 5a and 5b β€” branch topology, version files, CHANGELOG checks): + +```bash +node "$HOME/.claude/skills/release/scripts/build-deploy-plan.mjs" \ + /tmp/release-run/pr-status.json \ + /tmp/release-run/release-tickets.json \ + > /tmp/release-run/deploy-plan.json +``` + +Load `/tmp/release-run/deploy-plan.json` as `deploy_data`. + +Each entry in `deploy_data.repos` contains: +- `repo`, `topology` (A/B/C), `mainBranch`, `stagingBranch` +- `platform` (NPM/NuGet/Maven/PyPI/GitHub), `packageName`, `versionFilePath` +- `versionDev`, `versionMain`, `detectedBump` (patch/minor/major/none) +- `semverRecommendation`, `changelogExists`, `changelogHasEntry` +- `owner` (display name of primary Task ticket assignee) +- `flags`: `versionBumpMissing`, `changelogMissing`, `changelogEntryMissing`, `directToMain` + +Build `deployment_plan` rows from `deploy_data.repos` where `topology !== 'C'` **and `eligible !== false`** β€” ineligible repos have no version/platform/owner data and must be excluded: +``` +Sr No. | Plugin/SDK (name@version) | Release Platform | Owner | Test Report | Status +``` + +Use `packageName@versionDev` for the name+version column. Leave Test Report and Status blank. + +**Package naming:** The script reads `packageName` from the manifest exactly as written β€” preserve casing. Do not normalise. + +#### Step 3 Summary + +Display the following after Step 3 completes: + +``` +--- +βœ… Step 3 complete β€” Deployment Plan Built + +Repos processed: {deploy_data.repos.length} + Topology A (2-hop): {count} + Topology B (1-hop): {count} + Topology C (skip): {count} + Ineligible: {count} + +Deployment Plan: + Sr No. | Package@Version | Platform | Owner + 1 | {packageName@versionDev} | {platform} | {owner} + ... + +Flags requiring attention: + ⚠️ Version bump missing: {repos with versionBumpMissing or "none"} + ⚠️ Changelog missing: {repos with changelogEntryMissing or "none"} + ⚠️ Direct to main: {repos with directToMain or "none"} +--- +``` + +Then call `AskUserQuestion`: +``` +header: "Step 3 done" +question: "Deployment plan built. Ready to build the rollback plan?" +options: + - label: "Continue to Step 4 (Recommended)" + - label: "Stop here" +multiSelect: false +``` + +--- + +### Step 4 β€” Build Rollback Plan + +For each row in `deployment_plan`: + +| Platform | During Push β€” Task | Command | +|----------|-------------------|---------| +| NPM | Deprecate from npm | `npm deprecate @ "Released in error β€” use previous version"` | +| NuGet | Deprecate from NuGet | `TBD - manual` | +| Maven | Deprecate from Maven | `TBD - manual` | +| PyPI | Yank from PyPI | `TBD - manual` | +| GitHub | Revert release | `TBD - manual` | + +Owner for each rollback row = same as deployment plan owner. +After Push section: leave empty rows for human to fill. + +#### Step 4 Summary + +Display the following after Step 4 completes: + +``` +--- +βœ… Step 4 complete β€” Rollback Plan Built + + Command | Owner | During Push + npm deprecate {package}@{version} "..." | {owner} | Deprecate from npm + ... + +After Push rows left blank β€” to be filled manually before release day. +--- +``` + +Then call `AskUserQuestion`: +``` +header: "Step 4 done" +question: "Rollback plan ready. Proceed to create release PRs?" +options: + - label: "Continue to Step 5 β€” Create Release PRs (Recommended)" + - label: "Stop here" +multiSelect: false +``` + +--- + +### Step 5 β€” Create Release PRs + +For each **unique repo** identified from the **full PR list in Step 2** (regardless of whether feature PRs are merged or verified) β€” run the topology check and pre-flight checks below, then create or update the release PR. + +This step runs on all repos. Even repos whose feature PRs are still pending will get a release PR created now so the PR is ready when the dev branch is updated. + +--- + +#### 5a β€” Detect branch topology + +**Already resolved by the script in Step 3.** Read from `deploy_data.repos`: +- `topology` β€” A (2-hop), B (1-hop), C (no dev branch) +- `mainBranch` β€” "main" or "master" +- `stagingBranch` β€” "staging", "next", or null + +Use these values directly in 5c. No additional `gh api` calls needed. + +--- + +#### 5b β€” Pre-flight checks per repo + +**Skip Step 5b entirely for repos where `eligible === false`** β€” they have no version, changelog, or semver data. Proceed directly to Step 5c for those repos. + +**Already resolved by the script in Step 3.** Read flags from `deploy_data.repos[i].flags`: + +- `changelogMissing: true` β†’ CHANGELOG.md doesn't exist β€” skip check, no flag needed +- `changelogEntryMissing: true` β†’ file exists but no entry for release date β†’ flag to operator: + > `⚠️ {repo}: CHANGELOG.md exists but has no new entry for this release. Owner: {owner} β€” please update before merging.` +- `versionBumpMissing: true` β†’ `versionDev === versionMain` β†’ flag to operator: + > `⚠️ {repo}: version on development ({versionDev}) matches {mainBranch} β€” no version bump detected. Owner: {owner}` +- `directToMain: true` β†’ PR merged directly to main/master β†’ flag automatically: + > `⚠️ {repo}: PR merged directly to {mainBranch} β€” please verify a version bump was applied if required.` + +**Check 3 β€” Semver confirmation** (eligible repos with a detected bump only): + +For each eligible repo where `detectedBump !== 'none'`, call `AskUserQuestion`: +``` +header: "Semver β€” {short repo name}" +question: "Version bump detected for {repo}: {versionMain} β†’ {versionDev} ({detectedBump}). Recommended: {semverRecommendation}. Confirm the bump type?" +options: + - label: "Confirmed β€” {detectedBump} (Recommended)" + description: "{versionMain} β†’ {versionDev}" + - label: "Override to patch" + description: "Use patch bump instead" + - label: "Override to minor" + description: "Use minor bump instead" + - label: "Override to major" + description: "Use major bump instead" +multiSelect: false +``` + +Record the confirmed or overridden bump type for use in the PR body. + +For eligible repos where `detectedBump === 'none'`, call `AskUserQuestion` per repo: +``` +header: "Semver β€” {short repo name}" +question: "No version bump detected on development for {repo} + (dev: {versionDev ?? 'not detected'}, main: {versionMain ?? 'not detected'}). + What bump type should be applied before merging?" +options: + - label: "minor (Recommended)" + description: "Apply a minor version bump β€” new features or enhancements" + - label: "patch" + description: "Apply a patch version bump β€” bug fixes only" + - label: "major" + description: "Apply a major version bump β€” breaking changes" + - label: "Skip β€” no bump needed" + description: "This repo does not publish a versioned package" +multiSelect: false +``` + +Record the selected bump type for use in the PR body and Step 5 summary. If "Skip" is selected, clear the `versionBumpMissing` flag for this repo β€” no warning needed. + +**In `--dry-run` mode:** Skip the `AskUserQuestion` call for both cases. Record `semverRecommendation` as the confirmed bump and continue without blocking. + +--- + +#### 5c β€” Create PRs + +**PR title** (all types): `{project_key} | {release_date} | {release_type}` + +**PR body β€” filtering mechanism:** + +From Step 2 you have a `repo β†’ [ticket_keys]` mapping. From Step 2b you have `/tmp/release-run/note-lines-map.json` (`ticket_key β†’ [note_lines]`). To build the filtered PR body for a repo: +1. Collect all ticket keys for this repo from the `repo β†’ [ticket_keys]` mapping +2. For each key, look up its `note_lines` from the internal map +3. Deduplicate lines and render as markdown β€” maintaining the category headings, omitting any heading whose list is empty after filtering +4. Do not include ticket numbers, names, or any internal identifiers in the final body + +--- + +**If `--dry-run`:** Skip all PR creation and editing. For each repo, print: + +``` +[DRY RUN] Would create/update release PR: + Repo: {owner}/{repo} + Topology: Type A (2-hop) | Type B (1-hop) | Type C (no dev branch β€” would skip) + Hop 1: development β†’ staging (or next) [Type A only] + Hop 2: staging β†’ {main_or_master} [Type A only] + Single hop: development β†’ {main_or_master} [Type B only] + PR title: "{project_key} | {release_date} | {release_type}" + Version bump: {old_version} β†’ {new_version} ({patch|minor|major}) [from Step 5b] + Existing PR: #N already open (would update body) | none (would create new) + + Release notes for this repo: + +``` + +**If NOT dry-run β€” check if one already exists (per hop, per topology):** + +**Type A repos** β€” check both hops independently: +```bash +# Hop 1: development β†’ staging +gh pr list --repo {owner}/{repo} --head development --base staging \ + --state open --json number,title + +# Hop 2: staging β†’ {main_or_master} +gh pr list --repo {owner}/{repo} --head staging --base {main_or_master} \ + --state open --json number,title +``` + +**Type B repos** β€” check one hop: +```bash +gh pr list --repo {owner}/{repo} --head development --base {main_or_master} \ + --state open --json number,title +``` + +For each hop: if a PR with title `{project_key} | {release_date} | {release_type}` already exists β†’ update its body: +```bash +printf '%s' "{filtered_release_notes}" > /tmp/release-run/pr-body.md +gh pr edit {number} --repo {owner}/{repo} --body-file /tmp/release-run/pr-body.md +``` +Report: `updated existing PR#{number}`. If no match β†’ create it. + +--- + +**Type A β€” 2 PRs per repo (create if not present):** +```bash +# PR 1: development β†’ staging (or next) +printf '%s' "{filtered_release_notes}" > /tmp/release-run/pr-body.md +gh pr create \ + --repo {owner}/{repo} \ + --base staging \ + --head development \ + --title "{project_key} | {release_date} | {release_type}" \ + --body-file /tmp/release-run/pr-body.md + +# PR 2: staging β†’ {main_or_master} +printf '%s' "{filtered_release_notes}" > /tmp/release-run/pr-body.md +gh pr create \ + --repo {owner}/{repo} \ + --base {main_or_master} \ + --head staging \ + --title "{project_key} | {release_date} | {release_type}" \ + --body-file /tmp/release-run/pr-body.md +``` + +**Type B β€” 1 PR per repo (create if not present):** +```bash +printf '%s' "{filtered_release_notes}" > /tmp/release-run/pr-body.md +gh pr create \ + --repo {owner}/{repo} \ + --base {main_or_master} \ + --head development \ + --title "{project_key} | {release_date} | {release_type}" \ + --body-file /tmp/release-run/pr-body.md +``` + +**Type C β€” flag only:** +> `⚠️ {owner}/{repo}: no development branch found β€” skipping PR creation` + +#### Step 5 Summary + +After all PRs are created, display: + +``` +--- +βœ… Step 5 complete β€” Release PRs Created + + Repo | Type | PRs created/updated | Semver bump + {owner}/{repo} | A | PR#N (devβ†’staging) | minor + | | PR#M (stagingβ†’{main}) | + {owner}/{repo} | B | PR#N (devβ†’{main}) | patch + {owner}/{repo} | C | ⚠️ no dev branch | β€” +--- +``` + +Then call `AskUserQuestion`: +``` +header: "Step 5 done" +question: "Release PRs created. Ready to create the CAB Google Sheet?" +options: + - label: "Continue to Step 6 β€” Create CAB Sheet (Recommended)" + - label: "Stop here" +multiSelect: false +``` + +--- + +### Step 6 β€” Create Google Sheet (CAB Sheet) + +**If `--dry-run`:** Skip Steps 6a–6c entirely (no OAuth, no Drive API, no curl). Instead, render all three documented tab contents as markdown tables: + +**[DRY RUN] CAB Sheet preview β€” what would be written:** + +**Ticket List tab** (`Ticket List!A2:I{N+1}`, GID 231599262) + +| Issue Type | Key | Summary | Parent Key | Sprint | Status | Created | Assignee | Reporter | +|------------|-----|---------|------------|--------|--------|---------|----------|----------| +| (one row per ticket from `ticket_data.tickets`) | + +**Deployment Plan tab** (`Deployement Plan!A3:F{M+2}`, GID 0) + +| Sr No. | Plugin/SDK (name@version) | Release Platform | Owner | Test Report | Status | +|--------|--------------------------|-----------------|-------|-------------|--------| +| (one row per entry in `deployment_plan`) | + +**Rollback Plan tab** (`Rollback Plan!A3:D{M+2}`, GID 1940902083) + +| Command | Owner | During Push | After Push | +|---------|-------|-------------|------------| +| (one row per entry in `rollback_plan`) | + +**Check List tab** (GID 878611207) + +> This tab exists in the template sheet but is not yet populated by the skill. Show a note: "Check List tab present in template β€” content must be filled manually." + +#### Step 6 Summary (dry-run) + +``` +--- +βœ… Step 6 complete β€” CAB Sheet Preview (DRY RUN) + +Sheet would be titled: "{project_key} | {release_date} | {release_type}" +Ticket List rows: {N} +Deployment Plan rows: {M} +Rollback Plan rows: {M} + +No sheet was created β€” re-run without --dry-run to write for real. +--- +``` + +Then skip the rest of Step 6 and proceed to Step 7. + +--- + +**If NOT dry-run:** Google Sheets is accessed via OAuth2 using stored credentials. + +#### 6a β€” Refresh the access token + +Read `$HOME/.claude/skills/release/references/google-credentials.json` using the Read tool. +If the file does not exist, tell the user: +> "google-credentials.json not found. Please copy references/google-credentials.example.json to references/google-credentials.json and fill in your OAuth credentials. See the 'Getting a new OAuth token' section below." + +Then run: +```bash +GOOGLE_ACCESS_TOKEN=$(bash "$HOME/.claude/skills/release/scripts/refresh-google-token.sh") +``` + +If the script fails (expired credentials, missing file), see "Getting a new OAuth token" section below. + +**Important:** capture the token into `GOOGLE_ACCESS_TOKEN` as shown. Do NOT use `source` β€” the exported variable does not survive across separate Bash tool calls. + +#### 6b β€” Copy the template sheet + +Read `config.google_sheet_template_id` from config. If not set, ask the user for their Google Sheet template Drive file ID. + +```bash +NEW_SHEET_ID=$(bash "$HOME/.claude/skills/release/scripts/copy-template-sheet.sh" \ + "$GOOGLE_ACCESS_TOKEN" \ + "{project_key} | {release_date} | {release_type}" \ + "{config.google_sheet_template_id}") +echo "New sheet ID: $NEW_SHEET_ID" +``` + +#### 6c β€” Populate all tabs via batchUpdate + +Build the JSON payload (see `$HOME/.claude/skills/release/references/sheets-api.md` for exact tab GIDs and payload structure) targeting: + +- **Ticket List** tab (`Ticket List!A2:I{N+1}`): one row per ticket β€” Issue Type, Key, Summary, Parent key, Sprint name, Status, Created date, Assignee display name, Reporter display name +- **Deployment Plan** tab (`Deployement Plan!A3:F{M+2}`): deployment plan rows β€” Sr No, package name@version, platform, owner, empty, empty +- **Rollback Plan** tab (`Rollback Plan!A3:D{M+2}`): rollback rows β€” command, owner, empty, empty + +Write the payload to a temp file, then POST: + +```bash +TOKEN="$GOOGLE_ACCESS_TOKEN" +SHEET_ID="$NEW_SHEET_ID" +curl -s -X POST \ + "https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values:batchUpdate" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d @/tmp/release-run/sheets_payload.json \ + | jq '{totalUpdatedRows, totalUpdatedCells, error: .error.message}' +``` + +#### Step 6 Summary (live run) + +``` +--- +βœ… Step 6 complete β€” CAB Sheet Created + +Sheet title: "{project_key} | {release_date} | {release_type}" +Ticket List rows: {N} +Deployment Plan rows: {M} +Rollback Plan rows: {M} +Sheet URL: https://docs.google.com/spreadsheets/d/{NEW_SHEET_ID} +--- +``` + +Share the sheet URL with the user. + +Then call `AskUserQuestion`: +``` +header: "Step 6 done" +question: "CAB Sheet created. Ready to create the release notes ticket?" +options: + - label: "Continue to Step 7 (Recommended)" + - label: "Stop here" +multiSelect: false +``` + +--- + +#### Getting a new OAuth token (first-time setup or when refresh token fails) + +Give the user these instructions: + +1. Go to `https://developers.google.com/oauthplayground` +2. In the left panel "Step 1 β€” Select & authorize APIs", find and select: + - **Google Sheets API v4** β†’ `https://www.googleapis.com/auth/spreadsheets` + - **Drive API v3** β†’ `https://www.googleapis.com/auth/drive` +3. Click **Authorize APIs** and sign in with the Google account that has access to your sheet template +4. In "Step 2 β€” Exchange authorization code for tokens", click **Exchange authorization code for tokens** +5. Copy the value shown for **Refresh token** +6. Also note the **client_id** and **client_secret** (visible in the OAuth Playground settings gear) +7. Copy `references/google-credentials.example.json` β†’ `references/google-credentials.json` and fill in the three values + +The refresh token does not expire unless manually revoked. + +--- + +### Step 7 β€” Create Release Notes Ticket (skip if scope = SDK only) + +Read `config.td_project_key` from config (default: `TD` if not set). Ask if missing. + +**If `--dry-run`:** Skip `createJiraIssue` and `editJiraIssue`. Instead, print: + +``` +[DRY RUN] Would create release notes ticket: + Project: {td_project_key} + Type: Task + Summary: "{project_key} | Release Notes | {release_date} | CLI" + Assignee: {config.td_assignee_account_id or "unassigned"} + + Description body that would be set: + ───────────────────────────────────── + Release Date: {release_date} + + Docs Changes: + + Plugin: + Version: + + New Features: + - ... + + Enhancements: + - ... + + Bug & Security Fixes: + - ... + ───────────────────────────────────── +``` + +Print one block per CLI package. Then display: + +``` +--- +βœ… Step 7 complete β€” Release Notes Ticket Preview (DRY RUN) + +Would create {N} ticket(s) for CLI packages. +No ticket was created β€” re-run without --dry-run to write for real. +--- +``` + +Then skip the rest of Step 7. + +**If NOT dry-run:** + +**7a β€” Create the ticket** using `createJiraIssue`: + +```json +{ + "projectKey": "{config.td_project_key}", + "issueType": "Task", + "summary": "{project_key} | Release Notes | {release_date} | CLI", + "assignee": "{config.td_assignee_account_id}" +} +``` + +If `config.td_assignee_account_id` is not set, create the ticket unassigned. + +**7b β€” Populate the description** with CLI release notes using `editJiraIssue`. + +**CLI scope** β€” packages whose release tickets carry a `CLI` label. **SDK scope** (Step 8) β€” packages whose release tickets carry an `SDK` label. Repos with neither label are excluded from both Step 7 and Step 8. If a repo's tickets have both labels, treat it as CLI. + +Use this format: + +``` +Release Date: + +Docs Changes: + +Plugin: +Version: + +New Features: +- + +Enhancements: +- + +Bug & Security Fixes: +- +``` + +Write one block per CLI package. Omit empty headings. Map from the release notes built in Step 2b. + +#### Step 7 Summary + +``` +--- +βœ… Step 7 complete β€” Release Notes Ticket Created + + Package | Ticket | URL + {package} | {key} | {jira_ticket_url} +--- +``` + +Then call `AskUserQuestion`: +``` +header: "Step 7 done" +question: "Release notes ticket created. Ready to generate the SDK Confluence changelog table?" +options: + - label: "Continue to Step 8 (Recommended)" + - label: "Stop here" +multiSelect: false +``` + +--- + +### Step 8 β€” Update SDK Confluence Changelog (skip if scope = CLI only) + +Read `config.confluence_sdk_page_id` from config. Ask the user if missing: +> "Please provide your Confluence SDK changelog page ID (the numeric ID in the page URL):" + +**The Confluence page body may be very large ADF** β€” do NOT attempt `updateConfluencePage` inline. +Instead: generate a table for the release manager to paste manually. + +**SDK scope for this table:** packages whose release tickets carry an `SDK` label. Packages with a `CLI` label belong in Step 7. Repos with neither label are excluded. + +**Generate the table** β€” render it as HTML so the release manager can copy it cleanly. Present **all SDK rows at once** in a single table (one row per SDK package), then tell the release manager to paste one row at a time into Confluence. + +Columns: + +| SDK/Utils | Change Log | Docs Reviewed | Docs Status | Code Release Date | +|-----------|------------|--------------|------------|------------------| +| `` | `` | β€” | β€” | `` | + +After showing the table, give the release manager these instructions: + +> To add these rows to the Confluence page (page ID: `{config.confluence_sdk_page_id}`): +> 1. Open the page in Confluence +> 2. Click **Edit** +> 3. Find the main changelog table (header: SDK/Utils, Change Log, Docs Reviewed, Docs Status, Code Release Date) +> 4. Click inside the first data row (below the header) +> 5. Insert a new row **above** it (right-click β†’ Insert row above) +> 6. Paste the content for the **first SDK package** into the appropriate cells +> 7. Repeat for each additional SDK package +> 8. Save the page + +#### Step 8 Summary + +``` +--- +βœ… Step 8 complete β€” SDK Confluence Table Generated + +SDK packages included: {N} + {package@version} + ... + +Paste table into Confluence page ID: {config.confluence_sdk_page_id} +--- +``` + +--- + +## Final Output to User + +After all steps complete, print a full run summary. + +**Normal run:** + +``` +βœ… Release {fixVersion} + +πŸ“‹ Tickets fetched: {N} tickets ({M} flagged not Ready to Deploy) +⚠️ Unmerged PRs: {list or "none"} +⚠️ Not in dev branch: {list or "none"} +⚠️ Jira comments posted: {list of tickets commented, or "none"} +⚠️ Version bump missing: {list of repos, or "none"} +⚠️ Changelog missing: {list of repos, or "none"} +πŸ”€ Release PRs created: {N} PRs across {R} repos + Type A (2-hop): {repos} + Type B (1-hop): {repos} + Type C (skipped): {repos} +πŸ“¦ Deployment Plan: {N} packages across {platforms} +πŸ“Š CAB Sheet: {URL} +🎫 Release Notes Ticket: {key} β€” {URL} [or SKIPPED] +πŸ“ Confluence (SDK): Table generated β€” paste into page ID {confluence_sdk_page_id} [or SKIPPED] + +πŸ“£ Release Notes: +{release_notes content} +``` + +**Dry-run (`--dry-run` flag):** + +``` +πŸ” DRY RUN β€” Release {fixVersion} ← no writes were performed + +πŸ“‹ Tickets fetched: {N} tickets ({M} flagged not Ready to Deploy) +⚠️ Unmerged PRs: {list or "none"} +⚠️ Not in dev branch: {list or "none"} +πŸ’¬ Jira comments: [DRY RUN] Would notify {N} ticket(s) β€” see Step 2 output above +πŸ”€ Release PRs: [DRY RUN] Would create/update {N} PRs across {R} repos β€” see Step 5 output above +πŸ“Š CAB Sheet: [DRY RUN] Sheet preview shown above β€” no sheet created +🎫 Release Notes Ticket: [DRY RUN] Ticket body shown above β€” no ticket created [or SKIPPED] +πŸ“ Confluence (SDK): Table generated β€” paste into page ID {confluence_sdk_page_id} [or SKIPPED] + +πŸ“£ Release Notes: +{release_notes content} + +───────────────────────────────────────────────────────────── +No Jira comments were posted. No GitHub PRs were created or updated. +No Google Sheet was created. No release notes ticket was created. +Re-run without --dry-run to execute for real. +───────────────────────────────────────────────────────────── +``` + +If any step fails, report the error clearly, skip that step, and continue with the rest. + +--- + +## Error Handling + +- **No fixVersion match in Jira**: Stop and ask user to verify the version string +- **gh CLI not authenticated**: Run `gh auth status`; ask user to run `gh auth login` if needed +- **google-credentials.json missing**: Ask user to copy the example file and fill in their OAuth credentials β€” see "Getting a new OAuth token" in Step 6 +- **Google token refresh fails**: Verify `client_id` and `client_secret` in `references/google-credentials.json` match the credentials used in OAuth Playground +- **Drive API 403 on template copy**: Re-authorize with `https://www.googleapis.com/auth/drive` scope included +- **PR URL in comment but `gh` can't access repo**: Note it and skip that PR; flag to user +- **config.json missing a required value**: Ask the user for the value and offer to save it diff --git a/skills/release/references/.gitignore b/skills/release/references/.gitignore new file mode 100644 index 0000000000..6e37d575e3 --- /dev/null +++ b/skills/release/references/.gitignore @@ -0,0 +1,2 @@ +google-credentials.json +config.json diff --git a/skills/release/references/config.example.json b/skills/release/references/config.example.json new file mode 100644 index 0000000000..e3f9b63e30 --- /dev/null +++ b/skills/release/references/config.example.json @@ -0,0 +1,8 @@ +{ + "_note": "Copy this file to config.json and fill in your org's values. Never commit config.json.", + "google_sheet_template_id": "YOUR_GOOGLE_SHEET_TEMPLATE_DRIVE_ID", + "confluence_sdk_page_id": "YOUR_CONFLUENCE_PAGE_ID", + "td_project_key": "TD", + "td_assignee_account_id": "OPTIONAL_JIRA_ACCOUNT_ID_OF_TD_TICKET_ASSIGNEE", + "secondary_reviewer_account_id": "OPTIONAL_JIRA_ACCOUNT_ID_FOR_PR_COMMENT_CC" +} diff --git a/skills/release/references/google-credentials.example.json b/skills/release/references/google-credentials.example.json new file mode 100644 index 0000000000..f33b2d279a --- /dev/null +++ b/skills/release/references/google-credentials.example.json @@ -0,0 +1,6 @@ +{ + "_note": "Copy this file to google-credentials.json and fill in your values. See SKILL.md Step 6 for setup instructions. Never commit google-credentials.json β€” it contains live OAuth tokens.", + "client_id": "YOUR_GOOGLE_CLIENT_ID", + "client_secret": "YOUR_GOOGLE_CLIENT_SECRET", + "refresh_token": "YOUR_GOOGLE_REFRESH_TOKEN" +} diff --git a/skills/release/references/sheets-api.md b/skills/release/references/sheets-api.md new file mode 100644 index 0000000000..f89e884346 --- /dev/null +++ b/skills/release/references/sheets-api.md @@ -0,0 +1,120 @@ +# Google Sheets API β€” batchUpdate Payloads + +All requests go to: +``` +POST https://sheets.googleapis.com/v4/spreadsheets//values:batchUpdate +``` + +All authenticated via OAuth2 bearer token (see SKILL.md Step 6a for token refresh). + +## Tab GIDs (template sheet) + +| Tab | GID | +|-----|-----| +| Ticket List | 231599262 | +| Deployment Plan | 0 | +| Rollback Plan | 1940902083 | +| Check List | 878611207 | + +After copying the template, the new sheet keeps the same GIDs. + +## Get sheet ID from GID + +```javascript +const meta = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/?fields=sheets.properties`) + .then(r => r.json()); +const sheet = meta.sheets.find(s => s.properties.sheetId === ); +const sheetTitle = sheet.properties.title; // use title for A1 notation range +``` + +## Write rows β€” Ticket List + +```javascript +const body = { + valueInputOption: 'USER_ENTERED', + data: [{ + range: 'Ticket List!A2:I', // N = number of tickets + values: tickets.map(t => [ + t.fields.issuetype.name, // A: Issue Type + t.key, // B: Key + t.fields.summary, // C: Summary + t.fields.parent?.key ?? '', // D: parent + t.fields.sprint?.name ?? '',// E: Sprint + t.fields.status.name, // F: Status + t.fields.created, // G: Created + t.fields.assignee?.displayName ?? '', // H: Assignee + t.fields.reporter?.displayName ?? '' // I: Reporter + ]) + }] +}; +await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${newId}/values:batchUpdate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) +}); +``` + +## Write rows β€” Deployment Plan + +```javascript +const body = { + valueInputOption: 'USER_ENTERED', + data: [{ + range: 'Deployement Plan!A3:F', // note: tab has typo "Deployement" + values: plan.map((row, i) => [ + i + 1, // A: Sr No. + row.packageAtVersion, // B: Plugin/SDK e.g. "@your-org/sdk@2.1.0" + row.platform, // C: Release Platform e.g. "NPM/GITHUB" + row.owner, // D: Owner + '', // E: Test Report (human fills) + '' // F: Status (human fills) + ]) + }] +}; +``` + +## Write rows β€” Rollback Plan + +```javascript +// During Push section starts at row 3 +const body = { + valueInputOption: 'USER_ENTERED', + data: [{ + range: 'Rollback Plan!A3:D', + values: rollback.map(row => [ + row.task, // A: Task (e.g. "npm deprecate @your-org/sdk@2.1.0 ...") + row.owner, // B: Owner + '', // C: Status + '' // D: Description + ]) + }] +}; +``` + +## Clear a range before writing + +```javascript +await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${id}/values/Ticket%20List!A2:I1000:clear`, { + method: 'POST' +}); +``` + +## Copy template via Drive API + +```javascript +// templateId comes from config.google_sheet_template_id (read from references/config.json) +const resp = await fetch( + `https://www.googleapis.com/drive/v3/files/${templateId}/copy`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: `${projectKey} | ${releaseDate} | ${releaseType}` }) + } +); +const { id, webViewLink } = await resp.json(); +// id = new spreadsheet ID to use in all subsequent Sheets API calls +// webViewLink = share URL to give the user +``` + +Note: Bearer token is obtained via scripts/refresh-google-token.sh using the credentials in +references/google-credentials.json (gitignored). See SKILL.md Step 6a for details. diff --git a/skills/release/scripts/build-deploy-plan.mjs b/skills/release/scripts/build-deploy-plan.mjs new file mode 100644 index 0000000000..6aacc8f425 --- /dev/null +++ b/skills/release/scripts/build-deploy-plan.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +/** + * build-deploy-plan.mjs + * + * For each eligible repo from pr-status.json: + * - Detects branch topology (development / staging / next / main / master) + * - Reads the version file from development and main (package.json, .csproj, pom.xml, etc.) + * For monorepos: picks the deepest changed package file, not the root + * - Checks CHANGELOG.md for a new entry matching the release date + * - Computes a semver recommendation from ticket types + * + * Usage: + * node build-deploy-plan.mjs + * + * Output: compact deploy-plan.json to stdout + * Requires: gh CLI authenticated with read access to all relevant repos + */ + +import { execSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const [, , prStatusFile, ticketsFile] = process.argv; +if (!prStatusFile || !ticketsFile) { + console.error('Usage: node build-deploy-plan.mjs '); + process.exit(1); +} + +const prStatus = JSON.parse(readFileSync(resolve(prStatusFile), 'utf8')); +const ticketData = JSON.parse(readFileSync(resolve(ticketsFile), 'utf8')); + +function run(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (e) { + return e.stdout || ''; + } +} + +function getFileContent(repo, filePath, ref) { + const encoded = run( + `gh api "repos/${repo}/contents/${filePath}?ref=${ref}" --jq '.content' 2>/dev/null` + ).trim(); + if (!encoded || encoded === 'null' || encoded === '') return null; + try { + return Buffer.from(encoded.replace(/\s/g, ''), 'base64').toString('utf8'); + } catch { + return null; + } +} + +function detectPlatform(changedFiles) { + if (changedFiles.some(f => f.endsWith('.csproj') || f.endsWith('.nuspec'))) return 'NuGet'; + if (changedFiles.some(f => f === 'pom.xml' || f.endsWith('/pom.xml'))) return 'Maven'; + if (changedFiles.some(f => + f === 'setup.py' || f === 'pyproject.toml' || + f.endsWith('/setup.py') || f.endsWith('/pyproject.toml') + )) return 'PyPI'; + if (changedFiles.some(f => f.endsWith('package.json'))) return 'NPM'; + return 'GitHub'; +} + +function pickVersionFilePath(platform, changedFiles) { + switch (platform) { + case 'NPM': { + const candidates = changedFiles.filter( + f => f.endsWith('package.json') && !f.includes('node_modules') + ); + if (!candidates.length) return 'package.json'; + // Prefer the deepest path (most specific package in a monorepo) + candidates.sort((a, b) => b.split('/').length - a.split('/').length); + return candidates[0]; + } + case 'NuGet': { + const csproj = changedFiles.find(f => f.endsWith('.csproj')); + return csproj || null; + } + case 'Maven': + return 'pom.xml'; + case 'PyPI': + return changedFiles.includes('pyproject.toml') ? 'pyproject.toml' + : changedFiles.includes('setup.py') ? 'setup.py' + : 'pyproject.toml'; + default: + return null; + } +} + +function extractVersion(content, platform) { + if (!content) return null; + try { + switch (platform) { + case 'NPM': + return JSON.parse(content).version || null; + case 'NuGet': { + const m = content.match(/(.*?)<\/Version>/i) || + content.match(/(.*?)<\/PackageVersion>/i); + return m?.[1]?.trim() || null; + } + case 'Maven': { + const m = content.match(/(.*?)<\/version>/i); + return m?.[1]?.trim() || null; + } + case 'PyPI': { + const m = content.match(/version\s*=\s*["']([^"']+)["']/); + return m?.[1] || null; + } + default: + return null; + } + } catch { + return null; + } +} + +function extractPackageName(content, platform) { + if (!content) return null; + try { + switch (platform) { + case 'NPM': + return JSON.parse(content).name || null; + case 'NuGet': { + const m = content.match(/(.*?)<\/PackageId>/i); + return m?.[1]?.trim() || null; + } + case 'Maven': { + const m = content.match(/(.*?)<\/artifactId>/i); + return m?.[1]?.trim() || null; + } + case 'PyPI': { + const m = content.match(/^name\s*=\s*["']([^"']+)["']/m); + return m?.[1] || null; + } + default: + return null; + } + } catch { + return null; + } +} + +function classifyBump(vDev, vMain) { + if (!vDev || !vMain || vDev === vMain) return 'none'; + const parse = v => v.split('.').map(Number); + const [dMaj, dMin, dPatch] = parse(vDev); + const [mMaj, mMin, mPatch] = parse(vMain); + if (dMaj > mMaj) return 'major'; + if (dMin > mMin) return 'minor'; + if (dPatch > mPatch) return 'patch'; + return 'none'; // dev version lower than main β€” flag as anomaly +} + +function semverRecommendation(ticketKeys, allTickets) { + const relevant = allTickets.filter(t => ticketKeys.includes(t.key)); + // A Task ticket can be a new feature or enhancement. Default conservative: if any Task exists, + // recommend minor. Only Bugs/Security β†’ patch. Caller should confirm with user. + if (relevant.some(t => t.type === 'Task')) return 'minor'; + return 'patch'; +} + +// Build ticket lookup map +const allTickets = ticketData.tickets || []; +const ticketByKey = Object.fromEntries(allTickets.map(t => [t.key, t])); + +const repos = []; + +for (const repoEntry of (prStatus.repos || [])) { + const repo = repoEntry.repo; // "owner/name" + + // ── Branch topology ──────────────────────────────────────────────────────── + // Always detect topology β€” Step 5 creates release PRs for ALL repos, including ineligible ones. + const branchExists = {}; + for (const branch of ['development', 'staging', 'next', 'main', 'master']) { + const result = run( + `gh api repos/${repo}/branches/${branch} --jq '.name' 2>/dev/null` + ).trim(); + branchExists[branch] = result === branch; + } + + const mainBranch = branchExists.main ? 'main' : branchExists.master ? 'master' : null; + const stagingBranch = branchExists.staging ? 'staging' : branchExists.next ? 'next' : null; + + let topology; + if (!branchExists.development || !mainBranch) { + topology = 'C'; + } else { + topology = stagingBranch ? 'A' : 'B'; + } + + if (topology === 'C') { + repos.push({ repo, topology: 'C', eligible: repoEntry.eligible, flags: { noDevBranch: true } }); + continue; + } + + // Ineligible repos (PRs not in dev) get topology recorded but skip version/changelog checks + if (!repoEntry.eligible) { + repos.push({ + repo, topology, mainBranch, stagingBranch, + eligible: false, + ticketKeys: repoEntry.ticketKeys, + flags: { ineligible: true }, + }); + continue; + } + + // ── Platform + version file ──────────────────────────────────────────────── + const changedFiles = repoEntry.changedFiles || []; + const platform = detectPlatform(changedFiles); + const versionPath = pickVersionFilePath(platform, changedFiles); + + let versionDev = null; + let versionMain = null; + let packageName = null; + + if (versionPath) { + const devContent = getFileContent(repo, versionPath, 'development'); + const mainContent = getFileContent(repo, versionPath, mainBranch); + versionDev = extractVersion(devContent, platform); + versionMain = extractVersion(mainContent, platform); + packageName = extractPackageName(devContent, platform); + } + + // ── CHANGELOG ────────────────────────────────────────────────────────────── + const changelogContent = getFileContent(repo, 'CHANGELOG.md', 'development'); + const changelogExists = changelogContent !== null; + // releaseDate is DD-MM-YYYY from the fixVersion string; convert to ISO YYYY-MM-DD for CHANGELOG matching + let changelogHasEntry = null; + if (changelogExists && ticketData.releaseDate) { + const [dd, mm, yyyy] = ticketData.releaseDate.split('-'); + const isoDate = `${yyyy}-${mm}-${dd}`; + changelogHasEntry = changelogContent.includes(isoDate) || changelogContent.includes(ticketData.releaseDate); + } + + // ── Semver analysis ──────────────────────────────────────────────────────── + const detectedBump = classifyBump(versionDev, versionMain); + const recommendation = semverRecommendation(repoEntry.ticketKeys, allTickets); + + // ── Owner: assignee of the primary Task ticket for this repo ─────────────── + const primaryTask = allTickets.find( + t => repoEntry.ticketKeys.includes(t.key) && t.type === 'Task' + ); + const fallback = allTickets.find(t => repoEntry.ticketKeys.includes(t.key)); + const owner = (primaryTask || fallback)?.assignee?.displayName || null; + + // ── Direct-to-main check ─────────────────────────────────────────────────── + const directToMain = (prStatus.prs || []).some( + p => p.repo === repo && p.baseRef === mainBranch && !p.isReleasePR && p.state === 'MERGED' + ); + + repos.push({ + repo, + topology, + eligible: true, + mainBranch, + stagingBranch, + platform, + packageName, + versionFilePath: versionPath, + versionDev, + versionMain, + detectedBump, + semverRecommendation: recommendation, + changelogExists, + changelogHasEntry, + owner, + ticketKeys: repoEntry.ticketKeys, + flags: { + versionBumpMissing: detectedBump === 'none', + changelogMissing: !changelogExists, + changelogEntryMissing: changelogExists && changelogHasEntry === false, + directToMain, + }, + }); +} + +process.stdout.write(JSON.stringify({ repos }, null, 2)); diff --git a/skills/release/scripts/check-prs.mjs b/skills/release/scripts/check-prs.mjs new file mode 100644 index 0000000000..a251a6b07e --- /dev/null +++ b/skills/release/scripts/check-prs.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * check-prs.mjs + * + * For every GitHub PR URL in the release, fetches PR state and verifies whether + * merged commits are present in the development branch. Groups results by repo. + * Pre-computes the list of Jira tickets that need a warning comment. + * + * Usage: + * node check-prs.mjs + * + * Input: release-tickets.json produced by fetch-release-data.mjs + * Output: compact pr-status.json to stdout + * Requires: gh CLI authenticated with read access to all relevant repos + */ + +import { execSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const [, , ticketsFile] = process.argv; +if (!ticketsFile) { + console.error('Usage: node check-prs.mjs '); + process.exit(1); +} + +const data = JSON.parse(readFileSync(resolve(ticketsFile), 'utf8')); + +function run(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (e) { + return e.stdout || ''; + } +} + +// Collect all unique PR URLs β†’ source ticket key +const prUrlToTicketKey = new Map(); +for (const url of (data.masterTicketPRs || [])) { + prUrlToTicketKey.set(url, data.masterTicketKey); +} +for (const ticket of (data.tickets || [])) { + for (const url of (ticket.prUrls || [])) { + prUrlToTicketKey.set(url, ticket.key); + } +} + +const allPRUrls = [...prUrlToTicketKey.keys()]; + +// Fetch PR metadata for each URL +const prs = []; +for (const url of allPRUrls) { + const raw = run( + `gh pr view "${url}" --json state,mergedAt,title,mergeCommit,headRefName,baseRefName,files,author,headRepository 2>/dev/null` + ); + + let pr; + try { + pr = JSON.parse(raw); + } catch { + prs.push({ url, error: 'fetch_failed', sourceTicketKey: prUrlToTicketKey.get(url) }); + continue; + } + + const repoOwner = pr.headRepository?.owner?.login || null; + const repoName = pr.headRepository?.name || null; + // Fallback: extract owner/repo directly from the PR URL + const repoFromUrl = url.match(/github\.com\/([^/]+\/[^/]+)\/pull\//)?.[1] || null; + const repo = (repoOwner && repoName) ? `${repoOwner}/${repoName}` : repoFromUrl; + + // A release PR has development as its head branch β€” open by design, skip dev-branch check + const isReleasePR = pr.headRefName === 'development'; + + let devBranchStatus = null; + if (!isReleasePR && pr.state === 'MERGED' && repo) { + // PR merged directly to development β€” commits are in dev by definition + if (pr.baseRefName === 'development') { + devBranchStatus = 'in-base'; + } else if (pr.mergeCommit?.oid) { + const devExists = run( + `gh api repos/${repo}/branches/development --jq '.name' 2>/dev/null` + ).trim(); + + if (devExists === 'development') { + const status = run( + `gh api "repos/${repo}/compare/${pr.mergeCommit.oid}...development" --jq '.status' 2>/dev/null` + ).trim(); + devBranchStatus = status || 'unknown'; + } else { + devBranchStatus = 'no-dev-branch'; + } + } else { + // Rebase-merged PR: no single merge commit SHA. Check if dev branch exists at minimum. + const devExists = run( + `gh api repos/${repo}/branches/development --jq '.name' 2>/dev/null` + ).trim(); + devBranchStatus = devExists === 'development' ? 'rebase-merged' : 'no-dev-branch'; + } + } + + prs.push({ + url, + state: pr.state, + mergedAt: pr.mergedAt || null, + title: pr.title || null, + isReleasePR, + repo, + headRef: pr.headRefName || null, + baseRef: pr.baseRefName || null, + author: pr.author?.login || null, + mergeCommit: pr.mergeCommit?.oid || null, + devBranchStatus, + // Keep only file paths β€” strip additions/deletions/status (not needed downstream) + files: (pr.files || []).map(f => f.path), + sourceTicketKey: prUrlToTicketKey.get(url), + }); +} + +// Group by repo β€” include error-failed PRs so their repos are not silently dropped +const repoMap = new Map(); +for (const pr of prs) { + if (!pr.repo) continue; + if (!repoMap.has(pr.repo)) { + repoMap.set(pr.repo, { repo: pr.repo, prList: [], ticketKeys: new Set(), allFiles: new Set() }); + } + const entry = repoMap.get(pr.repo); + entry.prList.push(pr); + if (pr.sourceTicketKey) entry.ticketKeys.add(pr.sourceTicketKey); + for (const f of (pr.files || [])) entry.allFiles.add(f); +} + +const repos = []; +for (const [repo, entry] of repoMap) { + const IN_DEV = new Set(['ahead', 'identical', 'in-base']); + const verified = entry.prList.filter(p => !p.error && !p.isReleasePR && p.state === 'MERGED' && + IN_DEV.has(p.devBranchStatus)); + const noDevBr = entry.prList.filter(p => p.devBranchStatus === 'no-dev-branch'); + const rebaseMerged = entry.prList.filter(p => p.devBranchStatus === 'rebase-merged'); + const unverified = entry.prList.filter(p => !p.error && !p.isReleasePR && p.state === 'MERGED' && + p.devBranchStatus && !IN_DEV.has(p.devBranchStatus) && + p.devBranchStatus !== 'no-dev-branch' && p.devBranchStatus !== 'rebase-merged'); + const open = entry.prList.filter(p => !p.error && !p.isReleasePR && p.state === 'OPEN'); + const fetchFailed = entry.prList.filter(p => p.error); + + repos.push({ + repo, + hasDevBranch: noDevBr.length === 0, + verifiedPRCount: verified.length, + unverifiedPRCount: unverified.length, + rebaseMergedCount: rebaseMerged.length, + openPRCount: open.length, + fetchFailedCount: fetchFailed.length, + ticketKeys: [...entry.ticketKeys], + // eligible = at least one verified PR OR all dev-branch-absent (flag but continue) + eligible: verified.length > 0 || noDevBr.length > 0, + changedFiles: [...entry.allFiles], + }); +} + +// Pre-compute which Jira tickets need a warning comment β€” one entry per ticket, not per PR +const ticketByKey = Object.fromEntries((data.tickets || []).map(t => [t.key, t])); +const commentMap = new Map(); // ticketKey β†’ entry (deduplicated) + +const IN_DEV_STATUS = new Set(['ahead', 'identical', 'in-base']); +for (const pr of prs) { + if (pr.isReleasePR || pr.error) continue; + + const shouldComment = + pr.state === 'OPEN' || + (pr.state === 'MERGED' && pr.devBranchStatus && + !IN_DEV_STATUS.has(pr.devBranchStatus) && + pr.devBranchStatus !== 'no-dev-branch'); + + if (shouldComment && pr.sourceTicketKey) { + const ticket = ticketByKey[pr.sourceTicketKey]; + if (commentMap.has(pr.sourceTicketKey)) { + // Merge PR URLs for tickets with multiple problematic PRs + commentMap.get(pr.sourceTicketKey).prUrls.push(pr.url); + } else { + commentMap.set(pr.sourceTicketKey, { + ticketKey: pr.sourceTicketKey, + prUrls: [pr.url], + reason: pr.state === 'OPEN' ? 'OPEN' : 'NOT_IN_DEV', + devBranchStatus: pr.devBranchStatus, + assigneeAccountId: ticket?.assignee?.accountId || null, + assigneeDisplayName: ticket?.assignee?.displayName || null, + }); + } + } +} +const needsJiraComment = [...commentMap.values()]; + +process.stdout.write(JSON.stringify({ + prs, + repos, + flagged: { + needsJiraComment, + notInDev: prs + .filter(p => !p.isReleasePR && p.state === 'MERGED' && p.devBranchStatus && + p.devBranchStatus !== 'ahead' && p.devBranchStatus !== 'identical' && + p.devBranchStatus !== 'no-dev-branch') + .map(p => ({ repo: p.repo, prUrl: p.url, devBranchStatus: p.devBranchStatus })), + }, +}, null, 2)); diff --git a/skills/release/scripts/copy-template-sheet.sh b/skills/release/scripts/copy-template-sheet.sh new file mode 100644 index 0000000000..d477dc88d5 --- /dev/null +++ b/skills/release/scripts/copy-template-sheet.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Copies the CAB sheet template and renames it for the current release. +# Usage: NEW_SHEET_ID=$(bash scripts/copy-template-sheet.sh "$GOOGLE_ACCESS_TOKEN" "PROJ | 16-08-2026 | Release" "$TEMPLATE_ID") +# Output: prints the new Google Sheet ID to stdout on success; error message to stderr on failure. + +TOKEN="$1" +SHEET_NAME="$2" +TEMPLATE_ID="$3" + +if [ -z "$TOKEN" ] || [ -z "$SHEET_NAME" ] || [ -z "$TEMPLATE_ID" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +RESPONSE=$(curl -s -X POST \ + "https://www.googleapis.com/drive/v3/files/${TEMPLATE_ID}/copy" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"${SHEET_NAME}\"}") + +NEW_ID=$(echo "$RESPONSE" | jq -r '.id // empty') + +if [ -z "$NEW_ID" ]; then + echo "ERROR: Failed to copy template. Response: $RESPONSE" >&2 + exit 1 +fi + +echo "$NEW_ID" diff --git a/skills/release/scripts/fetch-release-data.mjs b/skills/release/scripts/fetch-release-data.mjs new file mode 100644 index 0000000000..3417ad0cea --- /dev/null +++ b/skills/release/scripts/fetch-release-data.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +/** + * fetch-release-data.mjs + * + * Compresses raw Jira MCP response (searchJiraIssuesUsingJql output) into a + * compact JSON structure. Strips ADF formatting, null fields, and metadata bloat. + * Extracts GitHub PR URLs from descriptions and comments. + * + * Usage: + * node fetch-release-data.mjs [ ...] + * + * Input: one or more JSON files, each the direct MCP tool response for a page + * of searchJiraIssuesUsingJql results. + * Output: compact JSON to stdout (~85-90% smaller than raw input). + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const args = process.argv.slice(2); + +// Extract --fix-version before validating file args +let explicitFixVersion = null; +const fvIdx = args.indexOf('--fix-version'); +if (fvIdx !== -1) { + explicitFixVersion = args[fvIdx + 1] ?? null; + args.splice(fvIdx, explicitFixVersion !== null ? 2 : 1); +} + +if (!args.length) { + console.error('Usage: node fetch-release-data.mjs [ ...] [--fix-version "PROJ | DD-MM-YYYY | Release"]'); + process.exit(1); +} + +// Exclude ] [ ( ) to prevent capturing markdown link syntax like pull/123](https://... +const PR_URL_RE = /https:\/\/github\.com\/[^\s"'<>()\[\]]+\/pull\/\d+/g; + +// Recursively extract plain text from Jira ADF nodes +function adfToText(node) { + if (!node) return ''; + if (typeof node === 'string') return node; + if (node.type === 'text') return node.text || ''; + if (node.type === 'inlineCard' || node.type === 'blockCard') { + return node.attrs?.url || ''; + } + if (Array.isArray(node.content)) { + return node.content.map(adfToText).join(' '); + } + return ''; +} + +function extractPRUrls(fieldValue) { + if (!fieldValue) return []; + const text = typeof fieldValue === 'string' ? fieldValue : adfToText(fieldValue); + const matches = text.match(PR_URL_RE) || []; + // Normalize URLs: strip trailing punctuation or fragments + return [...new Set(matches.map(u => u.replace(/[.,)>\]]+$/, '')))]; +} + +// Merge all pages into one node array +let allNodes = []; +for (const file of args) { + let raw; + try { + raw = JSON.parse(readFileSync(resolve(file), 'utf8')); + } catch (e) { + console.error(`Failed to parse ${file}: ${e.message}`); + process.exit(1); + } + // MCP response shape: { issues: { nodes: [...] } } or { nodes: [...] } or { issues: [...] } + const nodes = raw?.issues?.nodes + || raw?.nodes + || (Array.isArray(raw?.issues) ? raw.issues : []) + || []; + allNodes = allNodes.concat(nodes); +} + +if (!allNodes.length) { + console.error('No issues found in input files'); + process.exit(1); +} + +let fixVersion = null; +let releaseDate = null; +let releaseType = null; +let masterTicketKey = null; +let masterTicketPRs = []; + +const tickets = []; +const notReadyToDeploy = []; + +// First pass: find the master release tracking ticket to extract fixVersion metadata +for (const issue of allNodes) { + const summary = issue.fields?.summary || ''; + // Pattern: "PROJ | MM-DD-YYYY | Release" or "PROJ | MM-DD-YYYY | Hotfix" (any project key) + const m = summary.match(/^[A-Z][A-Z0-9_-]*\s*\|\s*([\d-]+)\s*\|\s*(Release|Hotfix)/i); + if (m) { + masterTicketKey = issue.key; + releaseDate = m[1]; + releaseType = m[2]; + fixVersion = summary.trim(); + masterTicketPRs = extractPRUrls(issue.fields?.description); + break; + } +} + +// Fallback: derive metadata from --fix-version when no master ticket was found in results +if (!fixVersion && explicitFixVersion) { + fixVersion = explicitFixVersion.trim(); + const m = fixVersion.match(/^[A-Z][A-Z0-9_-]*\s*\|\s*([\d-]+)\s*\|\s*(Release|Hotfix)/i); + if (m) { + releaseDate = m[1]; + releaseType = m[2]; + } +} + +// Second pass: build compact ticket list +for (const issue of allNodes) { + const f = issue.fields || {}; + const key = issue.key; + const summary = f.summary || ''; + const status = f.status?.name || ''; + const type = f.issuetype?.name || ''; + const created = (f.created || '').slice(0, 10); + const assignee = f.assignee + ? { displayName: f.assignee.displayName, accountId: f.assignee.accountId } + : null; + const reporter = f.reporter ? { displayName: f.reporter.displayName } : null; + const parentKey = f.parent?.key || null; + + // Sprint: try standard field, then common custom field mappings + const sprint = + f.sprint?.name || + f.customfield_10020?.[0]?.name || + f.customfield_10010?.[0]?.name || + null; + + const labels = f.labels || []; + + // Extract PR URLs from description + comments; exclude master ticket PRs to avoid duplication + const descPRs = extractPRUrls(f.description); + const commentPRs = []; + for (const comment of (f.comment?.comments || [])) { + commentPRs.push(...extractPRUrls(comment.body)); + } + const allPRs = [...new Set([...descPRs, ...commentPRs])]; + const prUrls = key === masterTicketKey + ? [] // master ticket PRs captured separately + : allPRs.filter(u => !masterTicketPRs.includes(u)); + + // Flag not-ready tickets (exclude master tracking ticket and already-closed ones) + if (key !== masterTicketKey) { + const terminalStatuses = new Set(['Done', 'Closed', 'Resolved', 'Ready to Deploy']); + if (!terminalStatuses.has(status)) { + notReadyToDeploy.push(key); + } + } + + tickets.push({ + key, + type, + summary, + parentKey, + sprint, + status, + created, + assignee, + reporter, + labels, + prUrls, + }); +} + +process.stdout.write(JSON.stringify({ + fixVersion, + releaseDate, + releaseType, + masterTicketKey, + masterTicketPRs, + tickets, + notReadyToDeploy, +}, null, 2)); diff --git a/skills/release/scripts/refresh-google-token.sh b/skills/release/scripts/refresh-google-token.sh new file mode 100644 index 0000000000..507bd2c202 --- /dev/null +++ b/skills/release/scripts/refresh-google-token.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Exchanges the stored refresh token for a fresh Google access token. +# Usage: source scripts/refresh-google-token.sh +# Effect: exports $GOOGLE_ACCESS_TOKEN into the calling shell. + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CREDS_FILE="$SKILL_DIR/references/google-credentials.json" + +if [ ! -f "$CREDS_FILE" ]; then + echo "ERROR: $CREDS_FILE not found. Run the OAuth setup in SKILL.md Step 6 first." >&2 + return 1 2>/dev/null || exit 1 +fi + +REFRESH_TOKEN=$(jq -r '.refresh_token' "$CREDS_FILE") +CLIENT_ID=$(jq -r '.client_id' "$CREDS_FILE") +CLIENT_SECRET=$(jq -r '.client_secret' "$CREDS_FILE") + +if [ -z "$REFRESH_TOKEN" ] || [ "$REFRESH_TOKEN" = "null" ]; then + echo "ERROR: refresh_token missing in $CREDS_FILE" >&2 + return 1 2>/dev/null || exit 1 +fi + +RESPONSE=$(curl -s -X POST https://oauth2.googleapis.com/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&refresh_token=${REFRESH_TOKEN}&grant_type=refresh_token") + +GOOGLE_ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token // empty') + +if [ -z "$GOOGLE_ACCESS_TOKEN" ]; then + echo "ERROR: Failed to refresh token. Response: $RESPONSE" >&2 + return 1 2>/dev/null || exit 1 +fi + +export GOOGLE_ACCESS_TOKEN +echo "Access token refreshed successfully." diff --git a/skills/resolve-snyk/SKILL.md b/skills/resolve-snyk/SKILL.md new file mode 100644 index 0000000000..bae24d1794 --- /dev/null +++ b/skills/resolve-snyk/SKILL.md @@ -0,0 +1,497 @@ +--- +name: resolve-snyk +description: > + Resolve Snyk vulnerabilities for any GitHub repo. Clones the repo, runs a full audit + (npm audit + snyk test + snyk code test), shows all findings, determines upgrade versions, + edits package.json (direct deps + overrides for transitive deps), then does a single clean + install β†’ re-audit β†’ build β†’ commit/PR. Explicit user approval required before any file + changes or destructive steps. + Triggers on: /resolve-snyk, "resolve snyk", "fix snyk issues", "fix snyk vulnerabilities", + "snyk fix for", "clean up snyk". +--- + +# /resolve-snyk Skill + +Guides the user through a full Snyk vulnerability resolution cycle for a GitHub repo. +Never edit files, delete files, update dependencies, or run any install command without +explicit user approval first. + +--- + +## Usage + +When this skill activates, greet the user with this help block before doing anything else: + +``` +πŸ‘‹ /resolve-snyk β€” Snyk vulnerability resolver + +How to use: + /resolve-snyk β€” start with a specific repo + /resolve-snyk β€” I'll ask you for the repo URL + +What I'll do: + 1. Preflight β€” verify Snyk CLI + gh CLI are authenticated + 2. Clone β€” clone the repo to a temp directory + 3. Audit β€” run npm audit + snyk test + snyk code (full baseline) + 4. Plan β€” show safe upgrades (patch/minor) and transitive overrides to apply + 5. Approve β€” you pick which changes to apply (all / specific / none) + 6. Apply β€” update package.json only (no install yet) + 7. Install β€” delete lock file + node_modules, single clean install (your approval required) + 8. Re-audit β€” before vs after comparison + 9. Build β€” verify build still passes (hard gate before commit) + 10. PR β€” commit + raise PR via gh (your approval required) + 11. Cleanup β€” optionally delete the cloned temp directory + +Options you'll be asked about along the way: + β€’ Which safe upgrades to apply (all / list packages / none) + β€’ Which transitive overrides (all / list packages / none) + β€’ Whether to bump the package version (patch / minor / no) + β€’ Confirm before deleting node_modules + lock file + β€’ Target branch for the PR (defaults to main) + +What I won't touch automatically: + βœ— Major version bumps β€” listed for awareness, never applied + βœ— npm audit fix --force β€” surfaced as a decision, never run + βœ— Any file before you approve β€” every destructive step requires confirmation + +Requirements: + snyk CLI β†’ npm install -g snyk && snyk auth + gh CLI β†’ brew install gh && gh auth login + Node.js β†’ nodejs.org +``` + +Only show this block once at the start. Then proceed to Step 1. + +--- + +## Requirements + +The following tools must be installed and authenticated on the machine before this skill +can run. If any are missing, stop and tell the user exactly what to install or configure. + +| Tool | Purpose | Install | Auth | +|------|---------|---------|------| +| **Snyk CLI** | SCA + code scanning | `npm install -g snyk` | `snyk auth` | +| **GitHub CLI (`gh`)** | Raising PRs | `brew install gh` or [cli.github.com](https://cli.github.com) | `gh auth login` | +| **Node.js + npm** | Installing deps, running audits | [nodejs.org](https://nodejs.org) | β€” | +| **yarn** *(if repo uses it)* | Installing deps | `npm install -g yarn` | β€” | +| **pnpm** *(if repo uses it)* | Installing deps | `npm install -g pnpm` | β€” | +| **git** | Cloning, branching, committing | pre-installed on most systems | SSH or HTTPS access to the repo | + +These are hard requirements β€” the skill cannot proceed without them. + +> **Package manager support:** npm and pnpm are fully supported. For yarn repos, `npm audit` +> is used as a fallback (yarn audit outputs a different JSONL format); results are accurate +> but the audit command targets the npm registry endpoint directly rather than the yarn +> lockfile. Overrides are written as npm-style `overrides` for yarn repos (yarn v1 honours +> this; yarn berry uses `resolutions` β€” flag this to the user if the repo uses yarn berry). + +--- + +## Step 1 β€” Preflight checks and GitHub URL + +Before doing anything else, verify the required tools are present and authenticated: + +```bash +snyk whoami 2>&1 # must succeed β€” if not, run `snyk auth` first +gh auth status 2>&1 # must succeed β€” if not, run `gh auth login` first +``` + +If either check fails, stop and tell the user exactly which tool needs authenticating. +Do not proceed until both pass. + +If the user has not already provided a GitHub repo URL, ask for it. +Store it as `REPO_URL`. + +--- + +## Step 2 β€” Clone, detect package manager, and install + +```bash +REPO_DIR=$(mktemp -d) +git clone "$REPO_DIR" +``` + +**Detect the package manager** from the lock file present in the cloned repo: +- `package-lock.json` β†’ use `npm` +- `yarn.lock` β†’ use `yarn` +- `pnpm-lock.yaml` β†’ use `pnpm` + +Use the detected package manager for every install, audit, and outdated command throughout +the skill. If no lock file exists, default to `npm` and note this to the user. + +Install dependencies so the audit scans have actual resolved packages to analyse: + +```bash +# npm +cd "$REPO_DIR" && npm install + +# yarn +cd "$REPO_DIR" && yarn + +# pnpm +cd "$REPO_DIR" && pnpm install +``` + +> **Never use `--prefix` (npm) or `--cwd` / `--dir` flags for install commands.** These cause +> npm to embed absolute paths from the current working directory into `package-lock.json` instead +> of the standard `node_modules/…` relative keys. The resulting lockfile will fail Snyk CI scans +> and `npm ci` runs on any machine other than the one that generated it. Always `cd` into the +> repo directory first. + +Confirm both the clone and install succeeded. Print the temp path. +If install fails, report the error and stop β€” do not proceed to the audit. + +--- + +## Step 3 β€” Initial full audit (before any changes) + +Run `audit-scan.mjs` and save its output as the baseline for later diff comparison: + +```bash +node "$SKILL_DIR/scripts/audit-scan.mjs" "$REPO_DIR" > /tmp/snyk-baseline.json +``` + +Read `/tmp/snyk-baseline.json` and present a combined summary to the user: + +### npm audit findings + +Severity count table (`npmAudit.severityCounts`), then three groups: + +**Minor/patch fix available** (`npmAudit.directPatch` + `npmAudit.directMinor`) β€” show exact safe version per package: + +| Package | Current | Safe fix version | Severity | Title | +|---------|---------|-----------------|----------|-------| + +**Requires major version bump** (`npmAudit.directMajor`) β€” list for awareness; never touch automatically: + +| Package | Current | Major fix version | Severity | Title | +|---------|---------|------------------|----------|-------| + +**Transitive only** (`npmAudit.transitiveOnly`) β€” NOT in `package.json`; addressed via overrides in Step 4b: + +| Package | Current vulnerable range | Introduced via | Severity | +|---------|--------------------------|----------------|----------| + +If a package has no fix, it appears in `npmAudit.noFix` β€” list it as "no fix available". + +### Snyk SCA findings + +Severity count table (`snykTest.severityCounts`). Vulns from `snykTest.vulns`, licenses from `snykTest.licenseIssues`. + +### Snyk Code findings + +Severity count table (`snykCode.severityCounts`). Each finding from `snykCode.findings`: file:line, severity, title, CWE. + +> Note: Snyk Code findings are source-level static analysis β€” they will NOT change as a +> result of dependency updates. Do not expect them to move in the Step 9 re-audit. + +If any scan returns no issues, say so clearly. + +--- + +## Step 4a β€” Determine direct dependency upgrade candidates + +Run `find-upgrades.mjs` passing the repo dir and the baseline audit file: + +```bash +node "$SKILL_DIR/scripts/find-upgrades.mjs" "$REPO_DIR" /tmp/snyk-baseline.json > /tmp/snyk-upgrade-plan.json +``` + +Read `/tmp/snyk-upgrade-plan.json` and present two tables to the user: + +**Safe upgrades (patch + minor)** from `safeUpgrades`: + +| Package | Pin type | Current | Patch upgrade | Minor upgrade | +|---------|----------|---------|---------------|---------------| +| express | `^` range | 4.18.1 | 4.18.3 | 4.19.2 | +| lodash | exact | 4.17.19 | 4.17.21 | β€” | + +**Requires major bump** from `majorUpgrades` β€” list for awareness only; never apply automatically: + +| Package | Current | Major upgrade | +|---------|---------|---------------| +| react | 17.0.2 | 18.3.1 | + +Ask the user: + +> "Which of the safe (patch + minor) upgrades above would you like to apply? +> You can say 'all', list specific packages, or 'none'." + +Wait for the answer before continuing. + +--- + +## Step 4b β€” Handle transitive vulnerable deps via overrides + +No additional command needed β€” `find-upgrades.mjs` (Step 4a) already wrote both `safeUpgrades` +and `transitiveOverrides` into `/tmp/snyk-upgrade-plan.json`. Read `transitiveOverrides` from +that same file. The script has already: +- Found the lowest non-deprecated safe version above the vulnerable range for each package +- Checked deprecation status via `npm view @ deprecated` +- Confirmed each version exists on npm + +Present the overrides table to the user: + +| Package | Current vulnerable | Override to | Deprecated? | Skip? | Introduced via | +|---------|--------------------|-------------|-------------|-------|----------------| +| undici | <7.28.1 | 7.28.1 | No | No | some-framework | + +Before presenting the table, apply these three manual checks β€” the script does NOT catch all of them: + +1. **Vulnerable range is `*`** β€” if `currentVulnerable` is `*`, every version of the package is + flagged. Any override you pick is still inside the range and fixes nothing. Mark it SKIP and + explain there is no safe version to pin. + +2. **Override version falls inside the vulnerable range** β€” verify `overrideTo` is actually above + (not inside) `currentVulnerable`. If the script resolved a version that is still within the + range (e.g. the lowest available version on npm predates the CVE fix), mark it SKIP. + +3. **Cross-major override conflicting with direct deps** β€” if the proposed `overrideTo` jumps + to a different major version than what the project's direct deps already resolve to for that + package, mark it SKIP. A cross-major override forces incompatible peer dep versions into the + tree and often introduces *more* vulnerabilities than it removes. Check by comparing the + major of `overrideTo` against the major already locked in `package-lock.json` for that + package. + +For any entry that is skipped (script `skip: true`, or caught by the checks above), flag it +clearly in the table with the reason β€” do not include it in the approved plan. + +Ask the user: + +> "These transitive deps will be added to the `overrides` field in `package.json`. +> Which would you like to apply? 'all', specific packages, or 'none'." + +Wait for the answer before continuing. + +--- + +## Step 4c β€” Package version bump + +Ask the user: + +> "Should I bump this package's own `version` field? +> Current: X.Y.Z β€” patch bump would be X.Y.(Z+1). +> Yes (patch) / yes (minor) / no?" + +Wait for the answer before continuing. + +--- + +## Step 5 β€” Apply all approved changes to package.json only + +Only after all three questions in Steps 4a, 4b, and 4c are answered, build the approved +plan JSON and run `apply-upgrades.mjs`: + +```bash +# Write the approved plan to a temp file, e.g.: +cat > /tmp/snyk-approved-plan.json << 'EOF' +{ + "directUpgrades": [ + { "package": "express", "targetVersion": "^4.19.2" }, + { "package": "lodash", "targetVersion": "4.17.21" } + ], + "overrides": [ + { "package": "undici", "targetVersion": "7.28.1" } + ], + "versionBump": "patch" +} +EOF + +node "$SKILL_DIR/scripts/apply-upgrades.mjs" "$REPO_DIR" /tmp/snyk-approved-plan.json +``` + +The script handles: +- Exact-pinned packages β†’ writes bare version (no prefix) +- Range-pinned packages β†’ preserves original `^` or `~` prefix +- Overrides block created/merged in `package.json` +- Version field bumped atomically in the same pass + +Read the JSON output from `apply-upgrades.mjs` and show the user a diff-style summary +of every change made (`changes` array: section, package, before, after). + +**Do not run any install yet. Do not delete anything yet.** + +--- + +## Step 6 β€” Approval gate: delete package-lock.json and node_modules + +Ask the user: + +> "`package.json` is fully updated. To get a clean, in-sync install I need to delete +> `package-lock.json` and `node_modules`, then run a fresh install. +> +> **Proceed? [yes/no]**" + +Do NOT proceed until the user explicitly confirms. + +--- + +## Step 7 β€” Single authoritative install + +Only after approval: + +```bash +rm "$REPO_DIR/package-lock.json" # (or yarn.lock / pnpm-lock.yaml) +rm -rf "$REPO_DIR/node_modules" +cd "$REPO_DIR" && npm install # (or yarn / pnpm equivalent) +``` + +This is the **only authoritative install** β€” the one that produces the committed lock file. +`package.json` was fully finalised in Step 5 before anything was deleted, so the resulting +lock file is guaranteed to be in sync with `package.json`. + +**If install fails:** +- Identify the conflicting package from the error output. +- Roll back just that package's version in `package.json` (restore from `git diff`). +- Delete `node_modules` and the lock file again and retry once. +- If it still fails, stop and ask the user how to proceed. +- Do NOT add `--legacy-peer-deps` or `--force` without explicit user approval. + +--- + +## Step 8 β€” Run npm audit fix + +```bash +cd "$REPO_DIR" && npm audit fix # (or yarn/pnpm equivalent) +``` + +> Note: `npm audit fix` only modifies `package-lock.json`, never `package.json`. Any changes +> it makes are captured when we stage the lock file in Step 11. + +Show how many vulnerabilities were fixed and how many remain. If `--force` is the only +remaining option, tell the user β€” do NOT run it automatically. + +--- + +## Step 9 β€” Full re-audit (npm audit + Snyk) + +Run `audit-scan.mjs` again, passing the baseline for automatic diff computation: + +```bash +node "$SKILL_DIR/scripts/audit-scan.mjs" "$REPO_DIR" --baseline /tmp/snyk-baseline.json > /tmp/snyk-reaudit.json +``` + +Read `/tmp/snyk-reaudit.json` and present the **before vs after comparison** from the +`diff` field (computed automatically by the script): + +| Severity | Before | After | Fixed | +|----------|--------|-------|-------| +| High | 15 | 4 | βœ“ 11 | +| Moderate | 6 | 3 | βœ“ 3 | +| Low | 2 | 2 | β€” | + +For Snyk Code: show results separately with a note that findings are unchanged by design +(source-level analysis, not affected by dep updates). + +List all still-remaining issues from `npmAudit` and `snykTest` with package and title so +the user knows what needs manual attention. + +--- + +## Step 10 β€” Build verification (hard gate) + +First check whether a `build` script exists in `package.json`: + +```bash +node -e "const p=require('$REPO_DIR/package.json'); process.exit(p.scripts?.build ? 0 : 1);" 2>/dev/null \ + && echo "BUILD_SCRIPT=yes" || echo "BUILD_SCRIPT=no" +``` + +- If **no `build` script exists**: skip this step entirely. Note it to the user β€” this is + normal for library packages or repos that build via a separate pipeline. Proceed to Step 11. + +If a build script exists, run it using `cd`: + +```bash +cd "$REPO_DIR" && npm run build 2>&1 # (or yarn build / pnpm build) +``` + +> Do NOT use `--prefix` with `npm run` β€” it does not work reliably for scripts. + +- If the **build passes**: note any warnings (e.g. CommonJS bailout notices) but proceed. + Warnings from pre-existing issues in third-party deps are not blockers. +- If the **build fails due to a missing env var** from a prebuild script: note this is a + project configuration issue, not caused by the dep changes. Ask the user to confirm + before proceeding anyway. +- If the **build fails with a real error**: stop immediately. Do NOT commit. Show the full + error, identify which dep change likely caused it, and ask the user whether to roll back + that package or investigate further. + +--- + +## Step 11 β€” Commit and raise PR (approval gate) + +Ask the user: + +> "Build passed. Ready to commit and raise a PR? +> Suggested branch: `fix/snyk-patch-deps-YYYYMMDD` (today's date) +> Target branch: `main` β€” or specify another. +> **Proceed? [yes/no]**" + +Only after approval: + +1. `git checkout -b fix/snyk-patch-deps-YYYYMMDD` +2. Stage only `package.json` and the lock file β€” explicitly exclude any generated files + (e.g. `dist/`, generated config files, `.env`) that the prebuild or build step created: + ```bash + git add package.json package-lock.json # (or yarn.lock / pnpm-lock.yaml) + ``` +3. Commit β€” fill in actual numbers from Steps 4 and 9, not placeholder text: + ``` + fix: bump N dependencies and add M overrides to resolve Snyk vulnerabilities + + Direct dep upgrades: [list packages + versions] + Overrides added: [list transitive packages + versions] + Package version: X.Y.Z β†’ X.Y.(Z+1) + Vuln count: N (before) β†’ M (after). Build verified passing. + ``` +4. Push and raise PR via `gh pr create` with a body containing: + - Table of direct dep changes (before β†’ after) + - Table of overrides added + - Before/after vuln count table (from Step 9) + - Remaining issues and why they need `--force` or manual intervention + - Build status: passed + +--- + +## Step 12 β€” Cleanup + +Ask: + +> "Done! The cloned repo is at ``. Want me to delete it? [yes/no]" + +If yes: `rm -rf "$REPO_DIR"` + +--- + +## Important rules + +- **Preflight first** β€” Snyk auth and `gh` auth must both pass before Step 2. Fail fast. +- **Steps 1–4 are fully read-only** β€” no file edits, no deletions, no installs beyond the + initial scan install in Step 2. +- **Only one authoritative install** β€” `package.json` must be fully finalised (Steps 4a/4b/4c + 5) + before deleting the lock file and `node_modules`. One wipe, one install, guaranteed sync. +- **Preserve range prefixes** β€” for `^`/`~` pinned packages, keep the prefix when writing + the upgraded version back. Never silently change a flexible range to an exact pin. +- **Never touch major bumps automatically** β€” list them for awareness, never apply them. +- **Always check overrides for deprecation before adding them** β€” a deprecated override + version is worse than the vulnerability it was meant to fix. +- **Never run `npm audit fix --force`** β€” surface it as a user decision, never execute it. +- **Build is a hard gate** β€” do not commit if the build fails with a real error. +- **Always run both npm audit and Snyk** β€” mandatory at baseline (Step 3) and after changes + (Step 9). Never skip one. +- **Snyk Code findings do not change from dep updates** β€” present them separately in Step 9, + never imply they were fixed by dependency changes. +- **Stage only `package.json` and the lock file** β€” never stage generated or environment files. +- **On install failure** β€” roll back the specific conflicting dep and retry once before stopping. +- **Never use `--prefix` for installs** β€” `npm install --prefix ` embeds absolute paths in + `package-lock.json`, producing a lockfile that breaks Snyk CI and `npm ci` on any other machine. + Always `cd "$REPO_DIR"` first, then run the bare install command. +- **pnpm overrides go in `pnpm.overrides`** β€” `apply-upgrades.mjs` handles this automatically; + never manually write transitive overrides to the top-level `overrides` field for pnpm repos. +- **No build script = skip Step 10** β€” library packages and pipeline-built repos often have no + `build` script. Absence is not an error; skip gracefully and note it in the PR body. +- **Yarn berry uses `resolutions`, not `overrides`** β€” if the repo uses yarn berry (has + `.yarnrc.yml` or `packageManager: yarn@>=2`), flag this to the user; the overrides written + by the skill will not be honoured by yarn berry and the user must rename the field manually. diff --git a/skills/resolve-snyk/scripts/apply-upgrades.mjs b/skills/resolve-snyk/scripts/apply-upgrades.mjs new file mode 100644 index 0000000000..80c9798888 --- /dev/null +++ b/skills/resolve-snyk/scripts/apply-upgrades.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * apply-upgrades.mjs + * + * Applies an approved upgrade plan to package.json in one atomic pass: + * - Direct dep version bumps (preserving ^ / ~ prefix for range pins) + * - Transitive dep overrides block + * - Package version bump + * + * Usage: + * node apply-upgrades.mjs + * + * approved-plan.json shape: + * { + * "directUpgrades": [ + * { "package": "@angular/common", "targetVersion": "21.2.19" }, + * { "package": "express", "targetVersion": "^4.19.2" } + * ], + * "overrides": [ + * { "package": "undici", "targetVersion": "7.28.1" } + * ], + * "versionBump": "patch" | "minor" | null + * } + * + * Output: JSON diff summary of changes made to stdout. + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +function detectPkgManager(repoDir) { + if (existsSync(resolve(repoDir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(resolve(repoDir, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +const [, , repoDirArg, planFile] = process.argv; +if (!repoDirArg || !planFile) { + console.error('Usage: node apply-upgrades.mjs '); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const pkgPath = resolve(repoDir, 'package.json'); +const plan = JSON.parse(readFileSync(resolve(planFile), 'utf8')); +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + +const changes = []; + +// ── direct dep upgrades ─────────────────────────────────────────────────────── + +for (const { package: name, targetVersion } of (plan.directUpgrades || [])) { + const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + let applied = false; + + for (const section of sections) { + if (pkg[section]?.[name] !== undefined) { + const before = pkg[section][name]; + pkg[section][name] = targetVersion; + changes.push({ section, package: name, before, after: targetVersion }); + applied = true; + break; + } + } + + if (!applied) { + console.error(`Warning: package "${name}" not found in any dep section β€” skipping`); + } +} + +// ── transitive overrides ────────────────────────────────────────────────────── + +if (plan.overrides?.length) { + const pkgManager = detectPkgManager(repoDir); + + if (pkgManager === 'pnpm') { + // pnpm uses pnpm.overrides, not overrides + pkg.pnpm = pkg.pnpm || {}; + pkg.pnpm.overrides = pkg.pnpm.overrides || {}; + for (const { package: name, targetVersion } of plan.overrides) { + const before = pkg.pnpm.overrides[name] || null; + pkg.pnpm.overrides[name] = targetVersion; + changes.push({ section: 'pnpm.overrides', package: name, before, after: targetVersion }); + } + } else { + // npm and yarn both use the overrides field + pkg.overrides = pkg.overrides || {}; + for (const { package: name, targetVersion } of plan.overrides) { + const before = pkg.overrides[name] || null; + pkg.overrides[name] = targetVersion; + changes.push({ section: 'overrides', package: name, before, after: targetVersion }); + } + } +} + +// ── package version bump ────────────────────────────────────────────────────── + +if (plan.versionBump) { + const parts = (pkg.version || '0.0.0').split('.').map(Number); + const before = pkg.version; + + if (plan.versionBump === 'patch') { + parts[2]++; + } else if (plan.versionBump === 'minor') { + parts[1]++; + parts[2] = 0; + } + + pkg.version = parts.join('.'); + changes.push({ section: 'version', package: 'self', before, after: pkg.version }); +} + +// ── write ───────────────────────────────────────────────────────────────────── + +writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + +process.stdout.write(JSON.stringify({ appliedAt: new Date().toISOString(), changes }, null, 2)); diff --git a/skills/resolve-snyk/scripts/audit-scan.mjs b/skills/resolve-snyk/scripts/audit-scan.mjs new file mode 100644 index 0000000000..b6de338b50 --- /dev/null +++ b/skills/resolve-snyk/scripts/audit-scan.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * audit-scan.mjs + * + * Runs npm audit + snyk test + snyk code test in JSON mode, parses all three, + * and outputs a single structured JSON summary ready for the LLM to present. + * + * Usage: + * node audit-scan.mjs + * node audit-scan.mjs --baseline + * + * When --baseline is provided, also computes a before/after diff for re-audit. + * + * Output: JSON to stdout. Errors to stderr. + */ + +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +function detectPkgManager(repoDir) { + if (existsSync(resolve(repoDir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(resolve(repoDir, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +const args = process.argv.slice(2); +if (!args[0]) { + console.error('Usage: node audit-scan.mjs [--baseline ]'); + process.exit(1); +} + +const repoDir = resolve(args[0]); +const baselineIndex = args.indexOf('--baseline'); +const baselineFile = baselineIndex !== -1 ? args[baselineIndex + 1] : null; + +function run(cmd, cwd) { + try { + return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (e) { + // npm audit / snyk exit non-zero when findings exist β€” that's expected + return e.stdout || ''; + } +} + +function semverCompare(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if (pa[i] > pb[i]) return 1; + if (pa[i] < pb[i]) return -1; + } + return 0; +} + +function classifyFix(currentVer, fixVer) { + if (!fixVer) return 'none'; + const [cMaj, cMin] = currentVer.replace(/[^0-9.]/g, '').split('.').map(Number); + const [fMaj, fMin] = fixVer.replace(/[^0-9.]/g, '').split('.').map(Number); + if (fMaj > cMaj) return 'major'; + if (fMin > cMin) return 'minor'; + return 'patch'; +} + +// ── npm/pnpm audit ─────────────────────────────────────────────────────────── + +function parseNpmAudit(repoDir) { + const pkgManager = detectPkgManager(repoDir); + // pnpm audit --json uses the same JSON schema as npm audit --json + // yarn audit --json uses a different JSONL format; fall back to npm audit for yarn repos + const auditCmd = pkgManager === 'pnpm' ? 'pnpm audit --json' : 'npm audit --json'; + const raw = run(auditCmd, repoDir); + let data; + try { data = JSON.parse(raw); } catch { return { error: 'Failed to parse npm audit JSON', raw }; } + + const directDeps = new Set( + Object.keys({ + ...(JSON.parse(readFileSync(resolve(repoDir, 'package.json'), 'utf8')).dependencies || {}), + ...(JSON.parse(readFileSync(resolve(repoDir, 'package.json'), 'utf8')).devDependencies || {}), + ...(JSON.parse(readFileSync(resolve(repoDir, 'package.json'), 'utf8')).optionalDependencies || {}), + }) + ); + + const severityCounts = { critical: 0, high: 0, moderate: 0, low: 0, info: 0 }; + const directPatch = [], directMinor = [], directMajor = [], transitiveOnly = [], noFix = []; + + const vulns = data.vulnerabilities || {}; + for (const [name, vuln] of Object.entries(vulns)) { + const severity = vuln.severity || 'unknown'; + if (severityCounts[severity] !== undefined) severityCounts[severity]++; + + const isDirect = directDeps.has(name); + const fixAvailable = vuln.fixAvailable; + let fixVersion = null; + let fixType = 'none'; + let requiresForce = false; + + if (fixAvailable === true) { + fixType = 'patch'; // npm says it can fix without breaking changes + } else if (fixAvailable && typeof fixAvailable === 'object') { + fixVersion = fixAvailable.version || null; + requiresForce = fixAvailable.isSemVerMajor || false; + fixType = requiresForce ? 'major' : classifyFix(vuln.range || '', fixVersion || ''); + } + + const entry = { + package: name, + severity, + title: (vuln.via || []).map(v => typeof v === 'string' ? v : v.title).filter(Boolean).join('; '), + currentRange: vuln.range || '', + fixVersion, + requiresForce, + introducedVia: isDirect ? null : Object.keys(vulns).filter(k => + (vulns[k].nodes || []).some(n => n.includes(`node_modules/${name}`)) && k !== name + ).slice(0, 2), + }; + + if (!fixAvailable) { + noFix.push(entry); + } else if (!isDirect) { + transitiveOnly.push({ ...entry, fixType }); + } else if (fixType === 'patch' || fixType === 'minor') { + (fixType === 'patch' ? directPatch : directMinor).push(entry); + } else { + directMajor.push(entry); + } + } + + return { severityCounts, directPatch, directMinor, directMajor, transitiveOnly, noFix }; +} + +// ── snyk test ──────────────────────────────────────────────────────────────── + +function parseSnykTest(repoDir) { + const raw = run('snyk test --json', repoDir); + let data; + try { data = JSON.parse(raw); } catch { return { error: 'Failed to parse snyk test JSON', raw }; } + + const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + const vulns = []; + const licenseIssues = []; + + for (const vuln of (data.vulnerabilities || [])) { + const sev = vuln.severity || 'low'; + if (severityCounts[sev] !== undefined) severityCounts[sev]++; + + const entry = { + package: vuln.packageName, + version: vuln.version, + severity: sev, + title: vuln.title, + id: vuln.id, + fixedIn: (vuln.fixedIn || []).join(', ') || null, + isLicense: vuln.type === 'license', + }; + + if (vuln.type === 'license') licenseIssues.push(entry); + else vulns.push(entry); + } + + return { severityCounts, vulns, licenseIssues }; +} + +// ── snyk code test ─────────────────────────────────────────────────────────── + +function parseSnykCode(repoDir) { + const raw = run('snyk code test --json', repoDir); + let data; + try { data = JSON.parse(raw); } catch { return { error: 'Failed to parse snyk code JSON', raw }; } + + const severityCounts = { high: 0, medium: 0, low: 0 }; + const findings = []; + + for (const run_ of (data.runs || [])) { + for (const result of (run_.results || [])) { + const sev = (result.level || 'note') === 'error' ? 'high' + : (result.level === 'warning') ? 'medium' : 'low'; + if (severityCounts[sev] !== undefined) severityCounts[sev]++; + + const loc = (result.locations || [])[0]; + const region = loc?.physicalLocation?.region || {}; + findings.push({ + file: loc?.physicalLocation?.artifactLocation?.uri || 'unknown', + line: region.startLine || null, + severity: sev, + title: result.message?.text || '', + cwe: (result.taxa || []).map(t => t.id).join(', ') || null, + }); + } + } + + return { severityCounts, findings }; +} + +// ── diff (for re-audit) ────────────────────────────────────────────────────── + +function computeDiff(baseline, current) { + const diff = {}; + for (const scanner of ['npmAudit', 'snykTest']) { + const bCounts = baseline[scanner]?.severityCounts || {}; + const cCounts = current[scanner]?.severityCounts || {}; + diff[scanner] = {}; + for (const sev of Object.keys(bCounts)) { + diff[scanner][sev] = { before: bCounts[sev] || 0, after: cCounts[sev] || 0, fixed: (bCounts[sev] || 0) - (cCounts[sev] || 0) }; + } + } + return diff; +} + +// ── main ───────────────────────────────────────────────────────────────────── + +const result = { + repoDir, + scannedAt: new Date().toISOString(), + npmAudit: parseNpmAudit(repoDir), + snykTest: parseSnykTest(repoDir), + snykCode: parseSnykCode(repoDir), +}; + +if (baselineFile && existsSync(baselineFile)) { + const baseline = JSON.parse(readFileSync(baselineFile, 'utf8')); + result.diff = computeDiff(baseline, result); +} + +process.stdout.write(JSON.stringify(result, null, 2)); diff --git a/skills/resolve-snyk/scripts/find-upgrades.mjs b/skills/resolve-snyk/scripts/find-upgrades.mjs new file mode 100644 index 0000000000..c42f92838a --- /dev/null +++ b/skills/resolve-snyk/scripts/find-upgrades.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * find-upgrades.mjs + * + * Reads package.json, detects exact pins vs range pins, finds the latest + * patch and minor upgrade for each direct dep, and checks transitive-only + * vulnerable deps for safe override versions (including deprecation checks). + * + * Usage: + * node find-upgrades.mjs + * + * Output: JSON upgrade plan to stdout. + */ + +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +function detectPkgManager(repoDir) { + if (existsSync(resolve(repoDir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(resolve(repoDir, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +const [, , repoDirArg, auditFile] = process.argv; +if (!repoDirArg || !auditFile) { + console.error('Usage: node find-upgrades.mjs '); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const pkg = JSON.parse(readFileSync(resolve(repoDir, 'package.json'), 'utf8')); +const audit = JSON.parse(readFileSync(resolve(auditFile), 'utf8')); + +function run(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (e) { + return e.stdout || ''; + } +} + +function semverParts(v) { + return v.replace(/[^0-9.]/g, '').split('.').map(Number); +} + +function semverCompare(a, b) { + const pa = semverParts(a), pb = semverParts(b); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) > (pb[i] || 0)) return 1; + if ((pa[i] || 0) < (pb[i] || 0)) return -1; + } + return 0; +} + +function getVersions(pkg) { + const raw = run(`npm view ${pkg} versions --json`); + try { return JSON.parse(raw); } catch { return []; } +} + +function isDeprecated(pkg, version) { + const raw = run(`npm view ${pkg}@${version} deprecated 2>/dev/null`); + return raw.trim().length > 0; +} + +function versionExists(pkg, version) { + const raw = run(`npm view ${pkg}@${version} version 2>/dev/null`); + return raw.trim() === version; +} + +function findLatestPatch(versions, currentVer) { + const bare = currentVer.replace(/[^^~>=<]/g, match => /[0-9.]/.test(match) ? match : '').trim(); + const [maj, min] = semverParts(bare); + const patches = versions + .filter(v => { const [vMaj, vMin] = semverParts(v); return vMaj === maj && vMin === min && semverCompare(v, bare) > 0; }) + .sort(semverCompare); + return patches[patches.length - 1] || null; +} + +function findLatestMinor(versions, currentVer) { + const bare = currentVer.replace(/[^^~>=<]/g, match => /[0-9.]/.test(match) ? match : '').trim(); + const [maj, min] = semverParts(bare); + const minors = versions + .filter(v => { const [vMaj, vMin] = semverParts(v); return vMaj === maj && vMin > min; }) + .sort(semverCompare); + return minors[minors.length - 1] || null; +} + +function findLatestMajor(versions, currentVer) { + const bare = currentVer.replace(/[^^~>=<]/g, match => /[0-9.]/.test(match) ? match : '').trim(); + const [maj] = semverParts(bare); + const majors = versions + .filter(v => semverParts(v)[0] > maj) + .sort(semverCompare); + return majors[majors.length - 1] || null; +} + +function isExactPin(version) { + return /^[0-9]/.test(version); +} + +// ── direct dep upgrade candidates ──────────────────────────────────────────── + +const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.optionalDependencies, +}; + +// npm outdated for range-pinned packages (npm only β€” pnpm/yarn use different formats; +// version discovery via npm view below covers those repos adequately) +const pkgManager = detectPkgManager(repoDir); +let outdated = {}; +if (pkgManager === 'npm') { + const outdatedRaw = run(`npm outdated --prefix ${repoDir} --json 2>/dev/null`); + try { outdated = JSON.parse(outdatedRaw); } catch { outdated = {}; } +} + +const safeUpgrades = []; // patch + minor, user to approve +const majorUpgrades = []; // major bumps, awareness only + +for (const [name, currentSpec] of Object.entries(allDeps)) { + const versions = getVersions(name); + if (!versions.length) continue; + + const latestPatch = findLatestPatch(versions, currentSpec); + const latestMinor = findLatestMinor(versions, currentSpec); + const latestMajor = findLatestMajor(versions, currentSpec); + const pinType = isExactPin(currentSpec) ? 'exact' : 'range'; + const prefix = isExactPin(currentSpec) ? '' : currentSpec.match(/^[^^~>=<]*/)?.[0] || '^'; + + // For range-pinned, also pick up what npm outdated already found + const outdatedInfo = outdated[name]; + const effectivePatch = latestPatch || (outdatedInfo?.wanted !== outdatedInfo?.current ? outdatedInfo?.wanted : null) || null; + const effectiveMinor = latestMinor || null; + + if (effectivePatch || effectiveMinor) { + safeUpgrades.push({ + package: name, + pinType, + prefix, + current: currentSpec, + patchUpgrade: effectivePatch ? `${prefix}${effectivePatch}` : null, + minorUpgrade: effectiveMinor ? `${prefix}${effectiveMinor}` : null, + }); + } + + if (latestMajor) { + majorUpgrades.push({ + package: name, + pinType, + current: currentSpec, + majorUpgrade: `${prefix}${latestMajor}`, + }); + } +} + +// ── transitive dep overrides ────────────────────────────────────────────────── + +const transitiveOverrides = []; +const transitiveVulns = audit.npmAudit?.transitiveOnly || []; + +for (const vuln of transitiveVulns) { + const name = vuln.package; + const versions = getVersions(name); + if (!versions.length) continue; + + // Find lowest non-vulnerable version above the vulnerable range + // Parse the vulnerable range upper bound from the advisory + const bare = (vuln.currentRange || '').replace(/[<>=^~]/g, '').trim().split(' ').pop() || ''; + const [refMaj, refMin, refPatch] = semverParts(bare || '0.0.0'); + + // Get candidates: versions above the vulnerable range + const candidates = versions + .filter(v => semverCompare(v, bare || '0.0.0') > 0) + .sort(semverCompare); + + let safeVersion = null; + let deprecated = false; + + for (const candidate of candidates) { + const dep = isDeprecated(name, candidate); + if (!dep) { + safeVersion = candidate; + break; + } + } + + if (!safeVersion) { + // All candidates deprecated β€” pick latest and flag + safeVersion = candidates[candidates.length - 1] || null; + deprecated = true; + } + + const exists = safeVersion ? versionExists(name, safeVersion) : false; + + transitiveOverrides.push({ + package: name, + currentVulnerable: vuln.currentRange, + overrideTo: safeVersion, + deprecated, + exists, + introducedVia: vuln.introducedVia || [], + severity: vuln.severity, + skip: deprecated || !exists, // flag for user β€” don't auto-apply flagged ones + }); +} + +// ── output ──────────────────────────────────────────────────────────────────── + +const plan = { + generatedAt: new Date().toISOString(), + packageVersion: pkg.version, + safeUpgrades, + majorUpgrades, + transitiveOverrides, +}; + +process.stdout.write(JSON.stringify(plan, null, 2));