From c227725d03346e8915b9c861f7ebc8a86f977971 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Sun, 16 Aug 2026 16:23:00 +0530 Subject: [PATCH] Add release skill End-to-end release automation skill covering Jira ticket fetch, GitHub PR verification, deployment/rollback plan generation, release PR creation, CAB Google Sheet population, release notes ticket creation, and SDK Confluence changelog table generation. All org-specific values (project keys, account IDs, Confluence page IDs, Google Sheet template IDs, OAuth credentials) are excluded from the repo. Users configure their own values via references/config.json and references/google-credentials.json (both gitignored). Example files with placeholder values are provided. Co-Authored-By: Claude Sonnet 4.6 --- skills/release/SKILL.md | 1048 +++++++++++++++++ skills/release/references/.gitignore | 2 + skills/release/references/config.example.json | 8 + .../google-credentials.example.json | 6 + skills/release/references/sheets-api.md | 120 ++ skills/release/scripts/build-deploy-plan.mjs | 276 +++++ skills/release/scripts/check-prs.mjs | 204 ++++ skills/release/scripts/copy-template-sheet.sh | 28 + skills/release/scripts/fetch-release-data.mjs | 182 +++ .../release/scripts/refresh-google-token.sh | 35 + 10 files changed, 1909 insertions(+) create mode 100644 skills/release/SKILL.md create mode 100644 skills/release/references/.gitignore create mode 100644 skills/release/references/config.example.json create mode 100644 skills/release/references/google-credentials.example.json create mode 100644 skills/release/references/sheets-api.md create mode 100644 skills/release/scripts/build-deploy-plan.mjs create mode 100644 skills/release/scripts/check-prs.mjs create mode 100644 skills/release/scripts/copy-template-sheet.sh create mode 100644 skills/release/scripts/fetch-release-data.mjs create mode 100644 skills/release/scripts/refresh-google-token.sh diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md new file mode 100644 index 0000000..ed05583 --- /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 0000000..6e37d57 --- /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 0000000..e3f9b63 --- /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 0000000..f33b2d2 --- /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 0000000..f89e884 --- /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 0000000..6aacc8f --- /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 0000000..a251a6b --- /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 0000000..d477dc8 --- /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 0000000..3417ad0 --- /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 0000000..507bd2c --- /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."