From 87585867e62f325fb467e42ea21231b1b2edcd51 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Mon, 14 Jul 2025 14:13:27 +0200 Subject: [PATCH 1/4] chore: use readily installed chalk package --- automation/utils/bin/rui-publish-marketplace.ts | 6 +++--- .../utils/bin/rui-verify-package-format.ts | 15 ++++++++------- automation/utils/package.json | 2 +- automation/utils/src/ansi-colors.ts | 9 --------- automation/utils/src/api/contributor.ts | 6 +++--- automation/utils/src/build-config.ts | 6 +++--- automation/utils/src/steps.ts | 16 ++++++++-------- pnpm-lock.yaml | 12 ++++++------ 8 files changed, 32 insertions(+), 40 deletions(-) delete mode 100644 automation/utils/src/ansi-colors.ts diff --git a/automation/utils/bin/rui-publish-marketplace.ts b/automation/utils/bin/rui-publish-marketplace.ts index ad117ad6c7..ce1a514357 100755 --- a/automation/utils/bin/rui-publish-marketplace.ts +++ b/automation/utils/bin/rui-publish-marketplace.ts @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import { getPublishedInfo, gh } from "../src"; -import { fgGreen } from "../src/ansi-colors"; import { createDraft, publishDraft } from "../src/api/contributor"; +import chalk from "chalk"; async function main(): Promise { console.log(`Getting package information...`); @@ -13,11 +13,11 @@ async function main(): Promise { assert.ok(tag, "env.TAG is empty"); if (marketplace.appNumber === -1) { - console.log(`Skipping release process for tag ${fgGreen(tag)}. appNumber is set to -1 in package.json.`); + console.log(`Skipping release process for tag ${chalk.green(tag)}. appNumber is set to -1 in package.json.`); process.exit(2); } - console.log(`Starting release process for tag ${fgGreen(tag)}`); + console.log(`Starting release process for tag ${chalk.green(tag)}`); const artifactUrl = await gh.getMPKReleaseArtifactUrl(tag); diff --git a/automation/utils/bin/rui-verify-package-format.ts b/automation/utils/bin/rui-verify-package-format.ts index 3653aa2597..6be66d9548 100755 --- a/automation/utils/bin/rui-verify-package-format.ts +++ b/automation/utils/bin/rui-verify-package-format.ts @@ -2,15 +2,16 @@ import { ZodError } from "zod"; import { + getModuleChangelog, getPackageFileContent, - PackageSchema, - ModulePackageSchema, + getWidgetChangelog, JSActionsPackageSchema, + ModulePackageSchema, + PackageSchema, PublishedPackageSchema } from "../src"; import { verify as verifyWidget } from "../src/verify-widget-manifest"; -import { fgCyan, fgGreen, fgYellow } from "../src/ansi-colors"; -import { getModuleChangelog, getWidgetChangelog } from "../src"; +import chalk from "chalk"; async function main(): Promise { const path = process.cwd(); @@ -63,13 +64,13 @@ async function main(): Promise { // Changelog check coming soon... - console.log(fgGreen("Verification success")); + console.log(chalk.green("Verification success")); } catch (error) { if (error instanceof ZodError) { for (const issue of error.issues) { - const keys = issue.path.map(x => fgYellow(`${x}`)); + const keys = issue.path.map(x => chalk.yellow(`${x}`)); const code = `[${issue.code}]`; - console.error(`package.${keys.join(".")} - ${code} ${fgCyan(issue.message)}`); + console.error(`package.${keys.join(".")} - ${code} ${chalk.cyan(issue.message)}`); } // Just for new line console.log(""); diff --git a/automation/utils/package.json b/automation/utils/package.json index eba83a268e..b4908b2aaf 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -37,7 +37,7 @@ "@mendix/prettier-config-web-widgets": "workspace:*", "@types/cross-zip": "^4.0.2", "@types/node-fetch": "2.6.12", - "chalk": "^4.1.2", + "chalk": "^5.4.1", "cross-zip": "^4.0.1", "enquirer": "^2.4.1", "execa": "^5.1.1", diff --git a/automation/utils/src/ansi-colors.ts b/automation/utils/src/ansi-colors.ts deleted file mode 100644 index a48a1dbf06..0000000000 --- a/automation/utils/src/ansi-colors.ts +++ /dev/null @@ -1,9 +0,0 @@ -const bindColor = (ansiColor: string) => (str: string) => `${ansiColor}${str}\x1b[0m`; - -export const fgRed = bindColor("\x1b[31m"); -export const fgGreen = bindColor("\x1b[32m"); -export const fgYellow = bindColor("\x1b[33m"); -export const fgBlue = bindColor("\x1b[34m"); -export const fgMagenta = bindColor("\x1b[35m"); -export const fgCyan = bindColor("\x1b[36m"); -export const fgWhite = bindColor("\x1b[37m"); diff --git a/automation/utils/src/api/contributor.ts b/automation/utils/src/api/contributor.ts index b67e1c3ca1..6eb230ef3c 100644 --- a/automation/utils/src/api/contributor.ts +++ b/automation/utils/src/api/contributor.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; -import { fetch, BodyInit } from "../fetch"; +import { BodyInit, fetch } from "../fetch"; import { z } from "zod"; import { Version } from "../version"; -import { fgGreen } from "../ansi-colors"; +import chalk from "chalk"; export interface CreateDraftSuccessResponse { App: App; @@ -115,7 +115,7 @@ export async function createDraft(params: CreateDraftParams): Promise { @@ -65,7 +65,7 @@ export async function getWidgetBuildConfig({ console.info(`Creating build config for ${packageName}...`); if (MX_PROJECT_PATH) { - console.info(fgGreen(`targetProject: using project path from MX_PROJECT_PATH.`)); + console.info(chalk.green(`targetProject: using project path from MX_PROJECT_PATH.`)); } const paths = { @@ -118,7 +118,7 @@ export async function getModuleBuildConfig({ console.info(`Creating build config for ${packageName}...`); if (MX_PROJECT_PATH) { - console.info(fgGreen(`targetProject: using project path from MX_PROJECT_PATH.`)); + console.info(chalk.green(`targetProject: using project path from MX_PROJECT_PATH.`)); } const paths = { diff --git a/automation/utils/src/steps.ts b/automation/utils/src/steps.ts index ad6f8ba9d3..106540d446 100644 --- a/automation/utils/src/steps.ts +++ b/automation/utils/src/steps.ts @@ -1,6 +1,5 @@ -import { writeFile, readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { dirname, join, parse, relative, resolve } from "path"; -import { fgYellow } from "./ansi-colors"; import { CommonBuildConfig, getModuleConfigs, @@ -13,7 +12,8 @@ import { copyMpkFiles, getMpkPaths } from "./monorepo"; import { createModuleMpkInDocker } from "./mpk"; import { ModuleInfo, PackageInfo, WidgetInfo } from "./package-info"; import { addFilesToPackageXml, PackageType } from "./package-xml"; -import { cp, ensureFileExists, exec, mkdir, popd, pushd, rm, unzip, zip, chmod } from "./shell"; +import { chmod, cp, ensureFileExists, exec, mkdir, popd, pushd, rm, unzip, zip } from "./shell"; +import chalk from "chalk"; type Step = (params: { info: Info; config: Config }) => Promise; @@ -210,9 +210,9 @@ export async function pushUpdateToTestProject({ info, config }: ModuleStepParams logStep("Push update to test project"); if (!process.env.CI) { - console.warn(fgYellow("You run script in non CI env")); - console.warn(fgYellow("Set CI=1 in your env if you want to push changes to remote test project")); - console.warn(fgYellow("Skip push step")); + console.warn(chalk.yellow("You run script in non CI env")); + console.warn(chalk.yellow("Set CI=1 in your env if you want to push changes to remote test project")); + console.warn(chalk.yellow("Skip push step")); return; } @@ -222,8 +222,8 @@ export async function pushUpdateToTestProject({ info, config }: ModuleStepParams const status = (await exec(`git status --porcelain`, { stdio: "pipe" })).stdout.trim(); if (status === "") { - console.warn(fgYellow("Nothing to commit")); - console.warn(fgYellow("Skip push step")); + console.warn(chalk.yellow("Nothing to commit")); + console.warn(chalk.yellow("Skip push step")); return; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7a3cb2e71..74614e2af4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,8 +157,8 @@ importers: specifier: 2.6.12 version: 2.6.12 chalk: - specifier: ^4.1.2 - version: 4.1.2 + specifier: ^5.4.1 + version: 5.4.1 cross-zip: specifier: ^4.0.1 version: 4.0.1 @@ -5856,8 +5856,8 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} - cli-spinners@2.9.0: - resolution: {integrity: sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==} + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} cliui@6.0.0: @@ -15590,7 +15590,7 @@ snapshots: dependencies: restore-cursor: 3.1.0 - cli-spinners@2.9.0: {} + cli-spinners@2.9.2: {} cliui@6.0.0: dependencies: @@ -19700,7 +19700,7 @@ snapshots: bl: 4.1.0 chalk: 4.1.2 cli-cursor: 3.1.0 - cli-spinners: 2.9.0 + cli-spinners: 2.9.2 is-interactive: 1.0.0 is-unicode-supported: 0.1.0 log-symbols: 4.1.0 From 34ccb1f63775c9d7e659cf09db5c31e39f1bb171 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Mon, 14 Jul 2025 14:41:22 +0200 Subject: [PATCH 2/4] chore: make package selector a bit more user friendly --- automation/utils/src/monorepo.ts | 39 +++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/automation/utils/src/monorepo.ts b/automation/utils/src/monorepo.ts index 42dc5e9d1d..94441c172a 100644 --- a/automation/utils/src/monorepo.ts +++ b/automation/utils/src/monorepo.ts @@ -43,11 +43,34 @@ export async function copyMpkFiles(packageNames: string[], dest: string): Promis export async function selectPackage(): Promise { const pkgs = await oraPromise(listPackages(["'*'", "!web-widgets"]), "Loading packages..."); + // First, get all display names and find maximum length + const displayData = pkgs.map(pkg => { + const [category, folderName] = extractPathInfo(pkg.path); + const displayName = pkg.name.includes("/") ? pkg.name.split("/").pop()! : pkg.name; + const categoryInfo = `[${category}${displayName !== folderName ? "/" + folderName : ""}]`; + + return { + displayName, + categoryInfo, + packageName: pkg.name + }; + }); + + // Find maximum display name length for padding + const maxDisplayNameLength = Math.max(...displayData.map(item => item.displayName.length)); + const { packageName } = await prompt<{ packageName: string }>({ type: "autocomplete", name: "packageName", message: "Please select package", - choices: pkgs.map(pkg => pkg.name) + choices: displayData.map(item => { + // Pad the display name with spaces to align the category info + const paddedName = item.displayName.padEnd(maxDisplayNameLength + 2, " "); + return { + name: `${paddedName}${item.categoryInfo}`, + value: item.packageName + }; + }) }); const pkg = pkgs.find(p => p.name === packageName); @@ -58,3 +81,17 @@ export async function selectPackage(): Promise { return pkg; } + +function extractPathInfo(path: string): [string, string] { + const automationMatch = path.match(/automation\/([^\/]+)/); + if (automationMatch) { + return ["automation", automationMatch[1]]; + } + + const packagesMatch = path.match(/packages\/([^\/]+)\/([^\/]+)/); + if (packagesMatch) { + return [packagesMatch[1], packagesMatch[2]]; + } + + throw new Error(`Invalid path format: ${path}`); +} From 072f63c7199df9ad14288d6c10fba3bc6ea80d44 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Mon, 14 Jul 2025 19:37:18 +0200 Subject: [PATCH 3/4] feat: prepare release script --- automation/utils/bin/rui-prepare-release.ts | 454 ++++++++++++++++++++ automation/utils/package.json | 2 + automation/utils/src/github.ts | 40 ++ automation/utils/src/jira.ts | 184 ++++++++ automation/utils/src/monorepo.ts | 4 +- 5 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 automation/utils/bin/rui-prepare-release.ts create mode 100644 automation/utils/src/jira.ts diff --git a/automation/utils/bin/rui-prepare-release.ts b/automation/utils/bin/rui-prepare-release.ts new file mode 100644 index 0000000000..d05d38cc42 --- /dev/null +++ b/automation/utils/bin/rui-prepare-release.ts @@ -0,0 +1,454 @@ +import { Jira } from "../src/jira"; +import { PackageListing, selectPackage } from "../src/monorepo"; +import chalk from "chalk"; +import { prompt } from "enquirer"; +import { getNextVersion, writeVersion } from "../src/bump-version"; +import { exec } from "../src/shell"; +import { gh } from "../src/github"; + +async function main(): Promise { + try { + console.log(chalk.bold.cyan("\nšŸš€ RELEASE PREPARATION WIZARD šŸš€\n")); + + // Check for GitHub token + const githubToken = process.env.GH_PAT; + if (!githubToken) { + console.warn(chalk.yellow("āš ļø GH_PAT environment variable not set")); + console.warn(chalk.yellow(" GitHub workflow will need manual triggering")); + } + + // Step 1: Initialize Jira client + console.log(chalk.bold("šŸ“‹ STEP 1: Initialize Jira")); + let jira: Jira; + try { + jira = await initializeJiraClient(); + } catch (error) { + console.log(chalk.red(`āŒ ${(error as Error).message}`)); + process.exit(1); + } + + // Step 2: Select package and determine version + console.log(chalk.bold("\nšŸ“‹ STEP 2: Package Selection")); + const { pkg, baseName, nextVersion, jiraVersionName, isVersionBumped } = await selectPackageAndVersion(); + + // Step 3: Check if Jira version exists + console.log(chalk.bold("\nšŸ“‹ STEP 3: Jira Version Setup")); + const jiraVersion = await checkAndCreateJiraVersion(jira, jiraVersionName); + + // Step 4: Create release branch + console.log(chalk.bold("\nšŸ“‹ STEP 4: Git Operations")); + const tmpBranchName = await createReleaseBranch(baseName, nextVersion); + + // Track whether we need to commit changes + let hasCommits = false; + + // Step 4.1: Write versions to the files (if user chose to bump version) + if (isVersionBumped) { + await writeVersion(pkg, nextVersion); + console.log(chalk.green(`āœ… Updated ${baseName} to ${nextVersion}`)); + + await exec(`git reset`, { stdio: "pipe" }); // Unstage all files + await exec(`git add ${pkg.path}`, { stdio: "pipe" }); // Stage only the package + + // Step 4.2: Commit changes + const { confirmCommit } = await prompt<{ confirmCommit: boolean }>({ + type: "confirm", + name: "confirmCommit", + message: "ā“ Commit version changes? You can stage other files now, if needed", + initial: true + }); + + if (!confirmCommit) { + console.log(chalk.yellow("āš ļø Commit canceled. Changes remain uncommitted")); + process.exit(0); + } + await exec(`git commit -m "chore(${baseName}): bump version to ${nextVersion}"`, { stdio: "pipe" }); + console.log(chalk.green("āœ… Changes committed")); + hasCommits = true; + } else { + console.log(chalk.yellow("āš ļø Version bump skipped. No changes to commit.")); + } + + // Step 4.3: Push to GitHub + const { confirmPush } = await prompt<{ confirmPush: boolean }>({ + type: "confirm", + name: "confirmPush", + message: `ā“ Push branch ${chalk.blue(tmpBranchName)} to GitHub${!hasCommits ? " (without commits)" : ""}?`, + initial: true + }); + + if (!confirmPush) { + console.log(chalk.yellow("āš ļø Push canceled. Branch remains local")); + console.log(chalk.yellow(` To push manually: git push origin ${tmpBranchName}`)); + process.exit(0); + } + + await exec(`git push -u origin ${tmpBranchName}`, { stdio: "pipe" }); + console.log(chalk.green("āœ… Branch pushed to GitHub")); + + console.log(chalk.bold("\nšŸ“‹ STEP 5: GitHub Release Workflow")); + await triggerGitHubReleaseWorkflow(pkg.name, tmpBranchName, githubToken); + + console.log(chalk.bold("\nšŸ“‹ STEP 6: Jira Issue Management")); + await manageIssuesForVersion(jira, jiraVersion.id, jiraVersionName); + + console.log(chalk.cyan("\nšŸŽ‰ Release preparation completed! šŸŽ‰")); + console.log(chalk.cyan(` Package: ${baseName} v${nextVersion}`)); + console.log(chalk.cyan(` Branch: ${tmpBranchName}`)); + console.log(chalk.cyan(` Jira Version: ${jiraVersionName}`)); + if (!isVersionBumped) { + console.log(chalk.cyan(` Note: Version was not bumped as requested`)); + } + } catch (error) { + console.error(chalk.red("\nāŒ ERROR:"), error); + process.exit(1); + } +} + +function showManualTriggerInstructions(packageName: string, branchName: string): void { + console.log(chalk.yellow("\nāš ļø Trigger GitHub workflow manually:")); + console.log( + chalk.cyan(" 1. Go to") + " https://github.com/mendix/web-widgets/actions/workflows/CreateGitHubRelease.yml" + ); + console.log(chalk.cyan(" 2. Click") + " 'Run workflow' button"); + console.log(chalk.cyan(" 3. Enter branch:") + ` ${chalk.white(branchName)}`); + console.log(chalk.cyan(" 4. Enter package:") + ` ${chalk.white(packageName)}`); + console.log(chalk.cyan(" 5. Click") + " 'Run workflow'"); +} + +async function manageIssuesForVersion(jira: Jira, versionId: string, versionName: string): Promise { + const { manageIssues } = await prompt<{ manageIssues: boolean }>({ + type: "confirm", + name: "manageIssues", + message: `ā“ Manage issues for version ${chalk.blue(versionName)}?`, + initial: true + }); + + if (!manageIssues) { + return; + } + + console.log(chalk.bold(`\nšŸ“‹ Managing issues for version ${chalk.blue(versionName)}`)); + + let managing = true; + while (managing) { + // Get current issues + const issues = await jira.getIssuesWithDetailsForVersion(versionId); + + console.log(chalk.bold(`\nšŸ”– Issues for ${chalk.blue(versionName)} (${issues.length}):`)); + if (issues.length === 0) { + console.log(chalk.yellow(" No issues assigned to this version yet")); + } else { + issues.forEach((issue, index) => { + console.log(` ${index + 1}. ${chalk.cyan(issue.key)}: ${issue.fields.summary}`); + }); + } + + const { action } = await prompt<{ action: string }>({ + type: "select", + name: "action", + message: "What would you like to do?", + choices: [ + { name: "add", message: "Add an issue" }, + { name: "remove", message: "Remove an issue" }, + { name: "refresh", message: "Refresh issue list" }, + { name: "exit", message: "Exit" } + ] + }); + + switch (action) { + case "add": { + const { issueKey } = await prompt<{ issueKey: string }>({ + type: "input", + name: "issueKey", + message: "Enter issue key (e.g., WEB-1234)" + }); + + const issue = await jira.searchIssueByKey(issueKey); + if (!issue) { + console.log(chalk.red(`āŒ Issue ${chalk.cyan(issueKey)} not found`)); + break; + } + + console.log(`Found: ${chalk.cyan(issue.key)}: ${issue.fields.summary}`); + + const { confirm } = await prompt<{ confirm: boolean }>({ + type: "confirm", + name: "confirm", + message: `ā“ Assign ${chalk.cyan(issue.key)} to version ${chalk.blue(versionName)}?`, + initial: true + }); + + if (confirm) { + await jira.assignVersionToIssue(versionId, issue.key); + console.log( + chalk.green(`āœ… Issue ${chalk.cyan(issue.key)} assigned to ${chalk.blue(versionName)}`) + ); + } + break; + } + + case "remove": { + if (issues.length === 0) { + console.log(chalk.yellow("āš ļø No issues to remove")); + break; + } + + const { selectedIssue } = await prompt<{ selectedIssue: string }>({ + type: "select", + name: "selectedIssue", + message: "Select issue to remove", + choices: issues.map(issue => ({ + name: issue.key, + message: `${issue.key}: ${issue.fields.summary}` + })) + }); + + const { confirmRemove } = await prompt<{ confirmRemove: boolean }>({ + type: "confirm", + name: "confirmRemove", + message: `ā“ Remove ${chalk.cyan(selectedIssue)} from ${chalk.blue(versionName)}?`, + initial: true + }); + + if (confirmRemove) { + await jira.removeFixVersionFromIssue(versionId, selectedIssue); + console.log(chalk.green(`āœ… Removed ${chalk.cyan(selectedIssue)} from ${chalk.blue(versionName)}`)); + } + break; + } + + case "refresh": + console.log(chalk.blue("šŸ”„ Refreshing issue list...")); + break; + + case "exit": + managing = false; + break; + } + } +} + +async function triggerGitHubReleaseWorkflow( + packageName: string, + branchName: string, + githubToken?: string +): Promise { + if (githubToken) { + const { triggerWorkflow } = await prompt<{ triggerWorkflow: boolean }>({ + type: "confirm", + name: "triggerWorkflow", + message: "ā“ Trigger GitHub release workflow now?", + initial: true + }); + + if (triggerWorkflow) { + console.log(chalk.blue("šŸ”„ Triggering GitHub release workflow...")); + try { + await gh.triggerCreateReleaseWorkflow(packageName, branchName); + console.log(chalk.green("āœ… GitHub Release Workflow triggered")); + } catch (error) { + console.error(chalk.red(`āŒ Failed to trigger workflow: ${(error as Error).message}`)); + showManualTriggerInstructions(packageName, branchName); + } + } else { + showManualTriggerInstructions(packageName, branchName); + } + } else { + showManualTriggerInstructions(packageName, branchName); + } +} + +async function createReleaseBranch(packageName: string, version: string): Promise { + const tmpBranchName = `tmp-release/${packageName}-v${version}`; + + let branchToUse = tmpBranchName; + let branchesAreReady = false; + + while (!branchesAreReady) { + // Check if branch exists locally + let localBranchExists = false; + try { + const { stdout: localBranchCheck } = await exec(`git branch --list ${tmpBranchName}`, { stdio: "pipe" }); + localBranchExists = localBranchCheck.trim().includes(tmpBranchName); + } catch (error) { + console.warn(chalk.yellow(`āš ļø Could not check local branch: ${(error as Error).message}`)); + } + + // Check if branch exists on remote + let remoteBranchExists = false; + try { + const { stdout: remoteBranchCheck } = await exec(`git ls-remote --heads origin ${tmpBranchName}`, { + stdio: "pipe" + }); + remoteBranchExists = remoteBranchCheck.trim().includes(tmpBranchName); + } catch (error) { + console.warn(chalk.yellow(`āš ļø Could not check remote branch: ${(error as Error).message}`)); + } + + if (!localBranchExists && !remoteBranchExists) { + branchesAreReady = true; + continue; + } + + console.log( + chalk.yellow( + `āš ļø Branch ${chalk.blue(tmpBranchName)} exists ${localBranchExists ? "locally" : ""}${localBranchExists && remoteBranchExists ? " and " : ""}${remoteBranchExists ? "on remote" : ""}` + ) + ); + + // Show manual deletion instructions + console.log(chalk.cyan("\nšŸ—‘ļø Branch deletion instructions:")); + if (localBranchExists) { + console.log(chalk.cyan(" To delete local branch:")); + console.log(chalk.white(` 1. Switch to another branch: git checkout main`)); + console.log(chalk.white(` 2. Delete branch: git branch -D ${tmpBranchName}`)); + } + if (remoteBranchExists) { + console.log(chalk.cyan(" To delete remote branch:")); + console.log(chalk.white(` Run: git push origin --delete ${tmpBranchName}`)); + } + + const { branchAction } = await prompt<{ branchAction: string }>({ + type: "select", + name: "branchAction", + message: "What would you like to do?", + choices: [ + { name: "checkAgain", message: "I've deleted the branches, check again" }, + { name: "random", message: "Create branch with random suffix" }, + { name: "cancel", message: "Cancel operation" } + ] + }); + + switch (branchAction) { + case "checkAgain": + console.log(chalk.blue("šŸ”„ Rechecking branch status...")); + break; + + case "random": + const randomSuffix = Math.random().toString(36).substring(2, 8); + branchToUse = `${tmpBranchName}-${randomSuffix}`; + console.log(chalk.blue(`šŸ”€ Using branch: ${branchToUse}`)); + branchesAreReady = true; + break; + + case "cancel": + console.log(chalk.red("āŒ Process canceled")); + process.exit(1); + } + } + + // Now create the branch + console.log(`šŸ”€ Creating branch: ${chalk.blue(branchToUse)}`); + await exec(`git checkout -b ${branchToUse}`, { stdio: "pipe" }); + console.log(chalk.green("āœ… Branch created")); + + return branchToUse; +} + +async function initializeJiraClient(): Promise { + console.log(chalk.bold("šŸ” Checking Jira environment variables")); + const projectKey = process.env.JIRA_PROJECT_KEY; + const baseUrl = process.env.JIRA_BASE_URL; + const apiToken = process.env.JIRA_API_TOKEN; + + if (!projectKey || !baseUrl || !apiToken) { + console.error(chalk.red("āŒ Missing Jira environment variables")); + console.log(chalk.dim(" Required variables:")); + console.log(chalk.dim(" export JIRA_PROJECT_KEY=WEB")); + console.log(chalk.dim(" export JIRA_BASE_URL=https://your-company.atlassian.net")); + console.log(chalk.dim(" export JIRA_API_TOKEN=username@your-company.com:ATATT3xFfGF0...")); + throw new Error("Missing Jira environment variables"); + } + + // Initialize Jira client + const jira = new Jira(projectKey, baseUrl, apiToken); + + // Initialize Jira project data with retry mechanism + let initialized = false; + while (!initialized) { + try { + console.log("šŸ”„ Initializing Jira project data..."); + await jira.initializeProjectData(); + console.log(chalk.green("āœ… Jira project data initialized")); + initialized = true; + } catch (error) { + console.error(chalk.red(`āŒ Jira init failed: ${(error as Error).message}`)); + + const { retry } = await prompt<{ retry: boolean }>({ + type: "confirm", + name: "retry", + message: "ā“ Retry Jira initialization?", + initial: true + }); + + if (!retry) { + throw new Error("Cannot proceed without Jira initialization"); + } + } + } + + return jira; +} + +async function selectPackageAndVersion(): Promise<{ + pkg: PackageListing; + baseName: string; + nextVersion: string; + jiraVersionName: string; + isVersionBumped: boolean; +}> { + const pkg = await selectPackage(); + const baseName = pkg.name.split("/").pop()!; + + console.log(`šŸ“¦ Selected: ${chalk.blue(baseName)} (current: ${chalk.green(pkg.version)})`); + + // Ask user if they want to bump the version before showing version selection dialog + const { confirmBumpVersion } = await prompt<{ confirmBumpVersion: boolean }>({ + type: "confirm", + name: "confirmBumpVersion", + message: `ā“ Do you want to bump ${baseName} from version ${chalk.green(pkg.version)}?`, + initial: true + }); + + // Only call getNextVersion if user wants to bump version + let nextVersion = pkg.version; + if (confirmBumpVersion) { + nextVersion = await getNextVersion(pkg.version); + console.log(`šŸ”¼ Next version: ${chalk.green(nextVersion)}`); + } else { + console.log(chalk.yellow(`āš ļø Version bump skipped. Keeping version ${chalk.green(pkg.version)}`)); + } + + const jiraVersionName = `${baseName}-v${nextVersion}`; + + return { pkg, baseName, nextVersion, jiraVersionName, isVersionBumped: confirmBumpVersion }; +} + +async function checkAndCreateJiraVersion(jira: Jira, jiraVersionName: string): Promise { + let jiraVersion = jira.findVersion(jiraVersionName); + if (jiraVersion) { + console.log(chalk.yellow(`āš ļø Jira version ${chalk.blue(jiraVersionName)} already exists`)); + } else { + // Ask user for confirmation to create new version + const { createVersion } = await prompt<{ createVersion: boolean }>({ + type: "confirm", + name: "createVersion", + message: `ā“ Create Jira version ${chalk.blue(jiraVersionName)}?`, + initial: true + }); + + if (!createVersion) { + console.log(chalk.red("āŒ Process canceled")); + process.exit(1); + } + + // Create Jira version + jiraVersion = await jira.createVersion(jiraVersionName); + console.log(chalk.green(`āœ… Created Jira version ${chalk.blue(jiraVersionName)}`)); + } + + return jiraVersion; +} + +main(); diff --git a/automation/utils/package.json b/automation/utils/package.json index b4908b2aaf..5f2111508b 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -8,6 +8,7 @@ "rui-agent-rules": "bin/rui-agent-rules.ts", "rui-create-gh-release": "bin/rui-create-gh-release.ts", "rui-create-translation": "bin/rui-create-translation.ts", + "rui-prepare-release": "bin/rui-prepare-release.ts", "rui-publish-marketplace": "bin/rui-publish-marketplace.ts", "rui-update-changelog-module": "bin/rui-update-changelog-module.ts", "rui-update-changelog-widget": "bin/rui-update-changelog-widget.ts", @@ -29,6 +30,7 @@ "format": "prettier --write .", "lint": "eslint --ext .jsx,.js,.ts,.tsx src/", "prepare": "pnpm run compile:parser:widget && pnpm run compile:parser:module && tsc", + "prepare-release": "ts-node bin/rui-prepare-release.ts", "start": "tsc --watch", "version": "ts-node bin/rui-bump-version.ts" }, diff --git a/automation/utils/src/github.ts b/automation/utils/src/github.ts index 862c45f10a..647bee39d7 100644 --- a/automation/utils/src/github.ts +++ b/automation/utils/src/github.ts @@ -147,6 +147,46 @@ export class GitHub { return filePath; } + + private async triggerGithubWorkflow(params: { + workflowId: string; + ref: string; + inputs: Record; + owner?: string; + repo?: string; + }): Promise { + await this.ensureAuth(); + + const { workflowId, ref, inputs, owner = "mendix", repo = "web-widgets" } = params; + + // Convert inputs object to CLI parameters + const inputParams = Object.entries(inputs) + .map(([key, value]) => `-f ${key}=${value}`) + .join(" "); + + const repoParam = `${owner}/${repo}`; + + const command = [`gh workflow run`, `"${workflowId}"`, `--ref "${ref}"`, inputParams, `-R "${repoParam}"`] + .filter(Boolean) + .join(" "); + + try { + await exec(command); + console.log(`Successfully triggered workflow '${workflowId}'`); + } catch (error) { + throw new Error(`Failed to trigger workflow '${workflowId}': ${error}`); + } + } + + async triggerCreateReleaseWorkflow(packageName: string, ref = "main"): Promise { + return this.triggerGithubWorkflow({ + workflowId: "CreateGitHubRelease.yml", + ref, + inputs: { + package: packageName + } + }); + } } export const gh = new GitHub(); diff --git a/automation/utils/src/jira.ts b/automation/utils/src/jira.ts new file mode 100644 index 0000000000..c0d3a992e4 --- /dev/null +++ b/automation/utils/src/jira.ts @@ -0,0 +1,184 @@ +import nodefetch, { RequestInit } from "node-fetch"; + +interface JiraVersion { + id: string; + name: string; + archived: boolean; + released: boolean; +} + +interface JiraProject { + id: string; + key: string; + name: string; +} + +interface JiraIssue { + key: string; + fields: { + summary: string; + }; +} + +export class Jira { + private projectKey: string; + private baseUrl: string; + private apiToken: string; + + private projectId: string | undefined; + private projectVersions: JiraVersion[] | undefined; + + constructor(projectKey: string, baseUrl: string, apiToken: string) { + if (!apiToken) { + throw new Error("API token is required."); + } + this.projectKey = projectKey; + this.baseUrl = baseUrl; + + this.apiToken = Buffer.from(apiToken).toString("base64"); // Convert to Base64 + } + + // Private helper method for making API requests + private async apiRequest( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + endpoint: string, + body?: object + ): Promise { + const url = `${this.baseUrl}/rest/api/3${endpoint}`; + const headers = { Authorization: `Basic ${this.apiToken}` }; + + const httpsOptions: RequestInit = { + method, + redirect: "follow", + headers: { + Accept: "application/json", + ...headers, + ...(body && { "Content-Type": "application/json" }) + }, + body: body ? JSON.stringify(body) : undefined + }; + + let response; + try { + response = await nodefetch(url, httpsOptions); + } catch (error) { + throw new Error(`API request failed: ${(error as Error).message}`); + } + + if (!response.ok) { + throw new Error(`API request failed (${response.status}): ${response.statusText}`); + } + + if (response.status === 204) { + // No content, return empty object + return {} as T; + } + + return response.json(); + } + + async initializeProjectData(): Promise { + const projectData = await this.apiRequest( + "GET", + `/project/${this.projectKey}` + ); + + this.projectId = projectData.id; // Save project ID + this.projectVersions = projectData.versions.reverse(); // Save list of versions + } + + private versions(): JiraVersion[] { + if (!this.projectVersions) { + throw new Error("Project versions not initialized. Call initializeProjectData() first."); + } + return this.projectVersions; + } + + getVersions(): JiraVersion[] { + return this.versions(); + } + + findVersion(versionName: string): JiraVersion | undefined { + return this.versions().find(version => version.name === versionName); + } + + async createVersion(name: string): Promise { + const version = await this.apiRequest("POST", `/version`, { + projectId: this.projectId, + name + }); + + this.projectVersions!.unshift(version); + + return version; + } + + async assignVersionToIssue(versionId: string, issueKey: string): Promise { + await this.apiRequest("PUT", `/issue/${issueKey}`, { + fields: { + fixVersions: [{ id: versionId }] + } + }); + } + + async deleteVersion(versionId: string): Promise { + await this.apiRequest("DELETE", `/version/${versionId}`); + + // Remove the version from the cached project versions + this.projectVersions = this.projectVersions?.filter(version => version.id !== versionId); + } + + async getFixVersionsForIssue(issueKey: string): Promise { + const issue = await this.apiRequest<{ fields: { fixVersions: JiraVersion[] } }>( + "GET", + `/issue/${issueKey}?fields=fixVersions` + ); + + return issue.fields.fixVersions || []; + } + + async removeFixVersionFromIssue(versionId: string, issueKey: string): Promise { + // First, get current fix versions + const currentVersions = await this.getFixVersionsForIssue(issueKey); + + // Filter out the version to remove + const updatedVersions = currentVersions + .filter(version => version.id !== versionId) + .map(version => ({ id: version.id })); + + // Update the issue with the filtered versions + await this.apiRequest("PUT", `/issue/${issueKey}`, { + fields: { + fixVersions: updatedVersions + } + }); + } + + private async getIssuesForVersion(versionId: string): Promise { + const issues = await this.apiRequest<{ issues: Array<{ key: string }> }>( + "GET", + `/search?jql=fixVersion=${versionId}` + ); + + return issues.issues.map(issue => issue.key); + } + + async getIssuesWithDetailsForVersion(versionId: string): Promise { + const response = await this.apiRequest<{ issues: JiraIssue[] }>( + "GET", + `/search?jql=fixVersion=${versionId}&fields=summary` + ); + + return response.issues; + } + + async searchIssueByKey(issueKey: string): Promise { + try { + const issue = await this.apiRequest("GET", `/issue/${issueKey}?fields=summary`); + return issue; + } catch (_e) { + // If issue not found or other error + return null; + } + } +} diff --git a/automation/utils/src/monorepo.ts b/automation/utils/src/monorepo.ts index 94441c172a..57c33068c3 100644 --- a/automation/utils/src/monorepo.ts +++ b/automation/utils/src/monorepo.ts @@ -83,12 +83,12 @@ export async function selectPackage(): Promise { } function extractPathInfo(path: string): [string, string] { - const automationMatch = path.match(/automation\/([^\/]+)/); + const automationMatch = path.match(/automation\/([^/]+)/); if (automationMatch) { return ["automation", automationMatch[1]]; } - const packagesMatch = path.match(/packages\/([^\/]+)\/([^\/]+)/); + const packagesMatch = path.match(/packages\/([^/]+)\/([^/]+)/); if (packagesMatch) { return [packagesMatch[1], packagesMatch[2]]; } From 7987de2031930a6ae9dab57f38c5c39fb05fe6c9 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Tue, 15 Jul 2025 16:26:07 +0200 Subject: [PATCH 4/4] fix: use release tag name https://github.com/orgs/community/discussions/64528#discussioncomment-12978855 --- .github/workflows/PublishMarketplace.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/PublishMarketplace.yml b/.github/workflows/PublishMarketplace.yml index e5727c6664..62aaf5d923 100644 --- a/.github/workflows/PublishMarketplace.yml +++ b/.github/workflows/PublishMarketplace.yml @@ -14,7 +14,7 @@ jobs: name: "Publish a new package version from GitHub release" runs-on: ubuntu-latest env: - TAG: ${{ github.event_name == 'release' && github.ref_name || github.event.inputs.package }} + TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.package }} steps: - name: Check release tag