From f4fe51864c633dff2385ea4c02c2fa2881af1b24 Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:32:11 +0530 Subject: [PATCH 01/10] feat: implement automated GitHub release synchronization to MongoDB with API parsing and retrieval routes --- .github/workflows/sync-releases.yaml | 32 +++ packages/app/controllers/release.js | 63 +++++ packages/app/models/release.js | 68 ++++++ packages/app/repositories/release.js | 61 +++++ packages/app/routes/releases.js | 17 ++ packages/app/scripts/syncReleases.js | 305 +++++++++++++++++++++++ packages/app/services/release.js | 43 ++++ packages/app/utils/constants.js | 348 +++++++++++++++++++++++++++ 8 files changed, 937 insertions(+) create mode 100644 .github/workflows/sync-releases.yaml create mode 100644 packages/app/controllers/release.js create mode 100644 packages/app/models/release.js create mode 100644 packages/app/repositories/release.js create mode 100644 packages/app/routes/releases.js create mode 100644 packages/app/scripts/syncReleases.js create mode 100644 packages/app/services/release.js diff --git a/.github/workflows/sync-releases.yaml b/.github/workflows/sync-releases.yaml new file mode 100644 index 000000000..f0ed630d7 --- /dev/null +++ b/.github/workflows/sync-releases.yaml @@ -0,0 +1,32 @@ +name: Sync GitHub Release to MongoDB + +on: + release: + types: [published, edited] + +jobs: + sync: + name: Sync release ${{ github.event.release.tag_name }} to MongoDB + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 10 + + - name: Install backend dependencies + run: pnpm --filter app install --frozen-lockfile + + - name: Run release sync script + working-directory: packages/app + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MONGO_URL: ${{ secrets.MONGO_URL }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: node scripts/syncReleases.js diff --git a/packages/app/controllers/release.js b/packages/app/controllers/release.js new file mode 100644 index 000000000..524749112 --- /dev/null +++ b/packages/app/controllers/release.js @@ -0,0 +1,63 @@ +const { STATUS_CODES } = require("node:http"); +const { ReleaseService } = require("../services"); +const { OLD_RELEASES } = require("../utils/constants"); + +/** + * GET /api/releases → paginated list of all releases + * GET /api/releases/:version → single release by version tag + * + * When :version is present: returns the matching release or 404. + * When :version is absent: returns releases in reverse-chronological order. + * Query params: + * - page {number} default: 1 + * - limit {number} default: 10 + */ +async function getReleasesController(req, res, next) { + try { + const releaseService = new ReleaseService(); + const { version } = req.params; + + // --- Single release by version --- + if (version) { + const release = await releaseService.getReleaseByVersion(version); + + if (!release) { + return res.status(404).json({ + statusCode: 404, + error: STATUS_CODES[404], + message: `Release ${version} not found.`, + }); + } + + return res.status(200).json({ + statusCode: 200, + data: release, + }); + } + + // --- All releases (paginated) --- + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10)); + + const result = await releaseService.getAllReleases(page, limit); + + return res.status(200).json({ + statusCode: 200, + data: result.data, + pagination: { + total: result.total, + currentPage: result.currentPage, + totalPages: result.totalPages, + limit, + }, + // Appended historical archive. Note: Pagination logic does not apply to this array. + archivedReleases: OLD_RELEASES, + }); + } catch (err) { + next(err); + } +} + +module.exports = { + getReleasesController, +}; diff --git a/packages/app/models/release.js b/packages/app/models/release.js new file mode 100644 index 000000000..bd0cf5741 --- /dev/null +++ b/packages/app/models/release.js @@ -0,0 +1,68 @@ +const mongoose = require("mongoose"); + +/** + * Release Model: Stores GitHub Release data synced via the release sync script. + * + * One document per release, uniquely identified by the version/tag string. + * GitHub Releases are the single source of truth — this collection is + * populated and kept up-to-date by scripts/syncReleases.js. + */ + +const contributorSchema = new mongoose.Schema( + { + username: { type: String, default: null }, + }, + { _id: false } +); + +const releaseEntrySchema = new mongoose.Schema( + { + category: { + type: String, + enum: ["Feature", "Enhancement", "Bug Fix", "Security"], + required: true, + }, + prNumber: { type: Number, required: true }, + title: { type: String, required: true }, + description: { type: String, default: "" }, + contributor: { type: contributorSchema, default: () => ({}) }, + }, + { _id: false } +); + +const releaseSchema = new mongoose.Schema( + { + version: { + type: String, + required: true, + unique: true, + trim: true, + }, + releaseDate: { + type: Date, + required: true, + }, + githubReleaseId: { + type: Number, + required: true, + }, + githubReleaseUrl: { + type: String, + required: true, + }, + entries: { + type: [releaseEntrySchema], + default: [], + }, + }, + { + timestamps: true, + } +); + +// Fast reverse-chronological listing for GET /api/releases +releaseSchema.index({ releaseDate: -1 }); + +const Release = mongoose.model("releases", releaseSchema); + +module.exports = Release; diff --git a/packages/app/repositories/release.js b/packages/app/repositories/release.js new file mode 100644 index 000000000..aa5bf23bf --- /dev/null +++ b/packages/app/repositories/release.js @@ -0,0 +1,61 @@ +const BaseRepository = require("./base"); +const Release = require("../models/release"); + +class ReleaseRepository extends BaseRepository { + constructor() { + super(Release); + } + + /** + * Find a release document by its version tag (e.g. "v1.2.0") + * @param {string} version + * @returns {Promise} + */ + async findByVersion(version) { + return await this.model.findOne({ version }).select("-__v"); + } + + /** + * Idempotent upsert — insert if version is new, replace all fields if it exists. + * Uses a single atomic findOneAndUpdate so there is no partial-write risk. + * @param {string} version + * @param {Object} data - Full release document payload (excluding version) + * @returns {Promise} - The updated/inserted document + */ + async upsertByVersion(version, data) { + return await this.model.findOneAndUpdate( + { version }, + { $set: { version, ...data } }, + { upsert: true, new: true, runValidators: true } + ); + } + + /** + * Return releases in reverse-chronological order with pagination metadata. + * @param {number} page - 1-indexed page number + * @param {number} limit - Results per page + * @returns {Promise<{ data: Array, total: number, currentPage: number, totalPages: number }>} + */ + async getPaginated(page = 1, limit = 10) { + const skip = (page - 1) * limit; + + const [data, total] = await Promise.all([ + this.model + .find({}) + .sort({ releaseDate: -1 }) + .skip(skip) + .limit(limit) + .select("-__v"), + this.model.countDocuments({}), + ]); + + return { + data, + total, + currentPage: page, + totalPages: Math.ceil(total / limit), + }; + } +} + +module.exports = ReleaseRepository; diff --git a/packages/app/routes/releases.js b/packages/app/routes/releases.js new file mode 100644 index 000000000..269fb35f1 --- /dev/null +++ b/packages/app/routes/releases.js @@ -0,0 +1,17 @@ +const router = require("express").Router(); +const { getReleasesController } = require("../controllers/release"); + +/** + * GET /api/releases + * Returns all releases in reverse-chronological order (paginated). + * Query params: page (default: 1), limit (default: 10) + * + * GET /api/releases/:version + * Returns a single release by version tag (e.g. "v1.2.0"). + * Returns 404 if the version does not exist. + * + * Public — no authentication required. + */ +router.get("/:version?", getReleasesController); + +module.exports = router; diff --git a/packages/app/scripts/syncReleases.js b/packages/app/scripts/syncReleases.js new file mode 100644 index 000000000..c72907943 --- /dev/null +++ b/packages/app/scripts/syncReleases.js @@ -0,0 +1,305 @@ +/** + * syncReleases.js + * + * Synchronises a single GitHub Release into MongoDB. + * Triggered by the GitHub Action `.github/workflows/sync-releases.yaml` + * on release `published` and `edited` events. + * + * Required environment variables: + * GITHUB_TOKEN - GitHub token (auto-provided by Actions) + * MONGO_URL - MongoDB connection string + * RELEASE_TAG - Tag name of the release to sync (e.g. "v1.2.0") + * GITHUB_REPOSITORY - "owner/repo" slug (auto-provided by Actions) + * + * For local testing, set GITHUB_REPO as a fallback for GITHUB_REPOSITORY. + * + * Exit codes: + * 0 - Success + * 1 - Fatal error (DB failure, GitHub API failure, missing env vars) + */ + +"use strict"; + +const mongoose = require("mongoose"); +const Release = require("../models/release"); + +// --------------------------------------------------------------------------- +// Logging helpers — prefixed for easy scanning in GitHub Actions log output +// --------------------------------------------------------------------------- + +function log(msg) { + console.log(`[sync-releases] ${msg}`); +} + +function warn(msg) { + console.warn(`[sync-releases][WARN] ${msg}`); +} + +function error(msg) { + console.error(`[sync-releases][ERROR] ${msg}`); +} + +// --------------------------------------------------------------------------- +// Environment / configuration +// --------------------------------------------------------------------------- + +function getConfig() { + const { GITHUB_TOKEN, MONGO_URL, RELEASE_TAG } = process.env; + const repository = + process.env.GITHUB_REPOSITORY || process.env.GITHUB_REPO || null; + + const missing = []; + if (!GITHUB_TOKEN) missing.push("GITHUB_TOKEN"); + if (!MONGO_URL) missing.push("MONGO_URL"); + if (!RELEASE_TAG) missing.push("RELEASE_TAG"); + if (!repository) missing.push("GITHUB_REPOSITORY (or GITHUB_REPO)"); + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(", ")}` + ); + } + + const [owner, repo] = repository.split("/"); + if (!owner || !repo) { + throw new Error( + `GITHUB_REPOSITORY must be in "owner/repo" format, got: "${repository}"` + ); + } + + return { GITHUB_TOKEN, MONGO_URL, RELEASE_TAG, owner, repo }; +} + +// --------------------------------------------------------------------------- +// GitHub API helpers +// --------------------------------------------------------------------------- + +/** + * Wraps fetch with GitHub API defaults and error handling. + * Throws on non-2xx responses — callers decide whether to abort or continue. + */ +async function githubFetch(path, token) { + const url = `https://api.github.com${path}`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "openlogo-sync-releases", + }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`GitHub API ${url} returned ${res.status}: ${body}`); + } + + return await res.json(); +} + +/** + * Fetch a release by tag from the GitHub Releases API. + */ +async function fetchRelease(owner, repo, tag, token) { + log(`Fetching release tag="${tag}" from GitHub...`); + return await githubFetch( + `/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, + token + ); +} + +/** + * Fetch a single pull request. + * Returns null (and logs a warning) instead of throwing if the PR is not found + * or if the API call fails — the caller will keep the entry without contributor data. + */ +async function fetchPullRequest(owner, repo, prNumber, token) { + try { + return await githubFetch( + `/repos/${owner}/${repo}/pulls/${prNumber}`, + token + ); + } catch (err) { + warn(`Could not fetch PR #${prNumber}: ${err.message}`); + return null; + } +} + +// --------------------------------------------------------------------------- +// Release note parser +// --------------------------------------------------------------------------- + +const CATEGORY_MAP = { + features: "Feature", + enhancements: "Enhancement", + "bug fixes": "Bug Fix", + security: "Security", +}; + +/** + * Parse release body into structured category sections and entries. + * + * Returns an array of raw entry objects: + * { category, prNumber, title, description } + * + * Invalid / malformed entries are skipped with a warning. + */ +function parseReleaseBody(body) { + const entries = []; + + // Split on H2 headings (## Section Name) + // Each segment: [ fullMatch, headingText, sectionContent ] + const sectionRegex = /##\s+(.+?)\s*\n([\s\S]*?)(?=\n##\s|\s*$)/gi; + let sectionMatch; + + while ((sectionMatch = sectionRegex.exec(body)) !== null) { + const headingRaw = sectionMatch[1].trim().toLowerCase(); + const sectionContent = sectionMatch[2]; + + const category = CATEGORY_MAP[headingRaw]; + if (!category) continue; // Skip Hero Image and any unknown sections + + // Split section on H3 headings (### #NNNN | Title) + const entryRegex = + /###\s+#(\d+)\s*\|\s*(.+?)\s*\n([\s\S]*?)(?=\n###\s|\s*$)/gi; + let entryMatch; + + while ((entryMatch = entryRegex.exec(sectionContent)) !== null) { + const prNumberRaw = entryMatch[1]; + const title = entryMatch[2].trim(); + const description = entryMatch[3].trim(); + + const prNumber = parseInt(prNumberRaw, 10); + if (isNaN(prNumber) || prNumber <= 0) { + warn( + `Skipping entry with invalid PR number "#${prNumberRaw}" in section "${category}".` + ); + continue; + } + + if (!title) { + warn(`Skipping PR #${prNumber} in section "${category}": empty title.`); + continue; + } + + entries.push({ category, prNumber, title, description }); + } + } + + return entries; +} + +// --------------------------------------------------------------------------- +// Contributor enrichment +// --------------------------------------------------------------------------- + +/** + * Fetch PR data and extract contributor metadata. + * Returns a contributor object — all fields are null on failure. + */ +async function enrichEntryWithPR(entry, owner, repo, token) { + const pr = await fetchPullRequest(owner, repo, entry.prNumber, token); + + if (!pr) { + return { + ...entry, + contributor: { username: null }, + }; + } + + const user = pr.user || {}; + return { + ...entry, + contributor: { + username: user.login || null, + }, + }; +} + +// --------------------------------------------------------------------------- +// Main sync function +// --------------------------------------------------------------------------- + +async function syncRelease() { + const config = getConfig(); + const { GITHUB_TOKEN, MONGO_URL, RELEASE_TAG, owner, repo } = config; + + // 1. Connect to MongoDB + log("Connecting to MongoDB..."); + await mongoose.connect(MONGO_URL); + log("Connected to MongoDB."); + + try { + // 2. Fetch release from GitHub + const ghRelease = await fetchRelease( + owner, + repo, + RELEASE_TAG, + GITHUB_TOKEN + ); + + log( + `Fetched release: id=${ghRelease.id}, tag=${ghRelease.tag_name}, published=${ghRelease.published_at}` + ); + + const body = ghRelease.body || ""; + + // 4. Parse release entries + const rawEntries = parseReleaseBody(body); + log(`Parsed ${rawEntries.length} release entries.`); + + // 5. Enrich entries with PR metadata (in parallel, failures are non-fatal) + const enrichedEntries = await Promise.all( + rawEntries.map((entry) => + enrichEntryWithPR(entry, owner, repo, GITHUB_TOKEN) + ) + ); + + log( + `Enriched ${enrichedEntries.length} entries (some contributor fields may be null if PR fetch failed).` + ); + + // 6. Build the release document payload + const releasePayload = { + releaseDate: new Date(ghRelease.published_at), + githubReleaseId: ghRelease.id, + githubReleaseUrl: ghRelease.html_url, + entries: enrichedEntries, + }; + + // 7. Atomic upsert — single write, no partial persistence risk + log(`Upserting release version="${RELEASE_TAG}" into MongoDB...`); + const saved = await Release.findOneAndUpdate( + { version: RELEASE_TAG }, + { $set: { version: RELEASE_TAG, ...releasePayload } }, + { upsert: true, new: true, runValidators: true } + ); + + log( + `Successfully synced release "${RELEASE_TAG}" (MongoDB _id: ${saved._id}).` + ); + } finally { + // Always close the connection + await mongoose.connection.close(); + log("MongoDB connection closed."); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +if (require.main === module) { + syncRelease() + .then(() => { + log("Sync completed successfully."); + process.exit(0); + }) + .catch((err) => { + error(`Sync failed: ${err.message}`); + if (err.stack) console.error(err.stack); + process.exit(1); + }); +} + +module.exports = { syncRelease, parseReleaseBody }; diff --git a/packages/app/services/release.js b/packages/app/services/release.js new file mode 100644 index 000000000..5a597c20b --- /dev/null +++ b/packages/app/services/release.js @@ -0,0 +1,43 @@ +const { ReleaseRepository } = require("../repositories"); +const { OLD_RELEASES } = require("../utils/constants"); + +/** + * ReleaseService: Read-only service layer for the releases API. + * + * Write operations (sync from GitHub) are handled by scripts/syncReleases.js + * which calls ReleaseRepository.upsertByVersion directly. + */ +class ReleaseService { + constructor() { + this.releaseRepository = new ReleaseRepository(); + } + + /** + * Returns a paginated list of releases in reverse-chronological order. + * @param {number} page - 1-indexed page number (default: 1) + * @param {number} limit - Results per page (default: 10) + * @returns {Promise<{ data: Array, total: number, currentPage: number, totalPages: number }>} + */ + async getAllReleases(page = 1, limit = 10) { + return await this.releaseRepository.getPaginated(page, limit); + } + + /** + * Returns a single release document matched by version tag, or null if not found. + * If not found in the database, checks the archived OLD_RELEASES constants. + * @param {string} version - e.g. "v1.2.0" + * @returns {Promise} + */ + async getReleaseByVersion(version) { + // 1. Check if the version is in the historical archive + const archivedRelease = OLD_RELEASES.find((r) => r.version === version); + if (archivedRelease) { + return archivedRelease; + } + + // 2. Query MongoDB for newer releases + return await this.releaseRepository.findByVersion(version); + } +} + +module.exports = ReleaseService; diff --git a/packages/app/utils/constants.js b/packages/app/utils/constants.js index d06eecfd4..de260db1b 100644 --- a/packages/app/utils/constants.js +++ b/packages/app/utils/constants.js @@ -174,7 +174,355 @@ const TEMPORARY_SESSION_TYPES = { MFA: "MFA", }; +/** + * OLD_RELEASES: Historical archived release data (pre-MongoDB era). + * + * @description + * This array contains release data for versions published BEFORE the automated + * GitHub → MongoDB release sync pipeline was implemented. + * + * ─── WHY THIS EXISTS ───────────────────────────────────────────────────────── + * The MongoDB `releases` collection is strictly for new releases synced from GitHub + * via `scripts/syncReleases.js`. Historical releases lack required fields like + * `githubReleaseId`, `githubReleaseUrl`, and `prNumber` — so they cannot be stored + * in the database without violating the schema. + * + * ─── HOW IT IS SERVED ──────────────────────────────────────────────────────── + * • GET /api/releases → included as `archivedReleases` in the response. + * NOTE: Pagination does NOT apply to this array. + * • GET /api/releases/:version → `ReleaseService.getReleaseByVersion()` checks + * this array FIRST before querying MongoDB. + * + * ─── ADDING NEW RELEASES ────────────────────────────────────────────────────── + * DO NOT add new releases here. All new releases should be published on GitHub + * and synced to MongoDB via the sync script / GitHub Action. + * + * @readonly + */ +const OLD_RELEASES = [ + { + version: "0.8.0 version", + releaseDate: "May 2026", + entries: [ + { + title: "Revamp USER dashboard according to the design.", + category: "Enhancement", + contributor: { + username: "AryaDharkar", + }, + }, + { + title: "Enhancing the UI of the admin dashboard.", + category: "Enhancement", + contributor: { + username: "L-Tarun-Aditya", + }, + }, + { + title: "Add 2FA section in user settings.", + category: "Enhancement", + contributor: { + username: "L-Tarun-Aditya", + }, + }, + { + title: "Implementing a dedicated settings page for MFA.", + category: "Enhancement", + contributor: { + username: "L-Tarun-Aditya", + }, + }, + { + title: "Multi factor authentication.", + category: "Enhancement", + contributor: { + username: "MukeshAbhi", + }, + }, + { + title: "Prevent Users From Reusing Old Password During Password Reset.", + category: "Enhancement", + contributor: { + username: "rishang14", + }, + }, + { + title: + "Fix bugs on createLogo page and allow users to access this page without authentication.", + category: "Enhancement", + contributor: { + username: "AryaDharkar", + }, + }, + { + title: "Feature for user session management.", + category: "Enhancement", + contributor: { + username: "kadamsahil2511", + }, + }, + { + title: + "Feature to enforce branch & PR naming conventions via husky + GitHub Actions.", + category: "Enhancement", + contributor: { + username: "Smayur0", + }, + }, + { + title: "Redesign documentation page.", + category: "Enhancement", + contributor: { + username: "Dhirenderchoudhary", + }, + }, + { + title: "Revamp sign in and sign up form", + category: "Enhancement", + contributor: { + username: "0-mstrmind", + }, + }, + ], + }, + { + version: "0.7.0 version", + releaseDate: "Mar 2026", + entries: [ + { + title: + "Authentication has been migrated from JWT to a secure session-based system, improving overall security and simplifying token management.", + category: "Enhancement", + contributor: { + username: "Mantu01", + }, + }, + { + title: + "You can now switch between Light and Dark themes to personalize your experience.", + category: "Enhancement", + contributor: { + username: "sachinkmrsin", + }, + }, + { + title: + "Notifications are now available for important events such as API expiry and usage limit being reached, so you never miss critical updates.", + category: "Enhancement", + contributor: { + username: "YashDevani-source", + }, + }, + { + title: + "You can now create and use your own custom logo image directly within the platform.", + category: "Enhancement", + contributor: { + username: "biplab-sutradhar", + }, + }, + { + title: + "API keys are now securely hidden to prevent accidental exposure and enhance account security.", + category: "Enhancement", + contributor: { + username: "L-Tarun-Aditya", + }, + }, + ], + }, + { + version: "0.6.0 version", + releaseDate: "Dec 2025", + entries: [ + { + title: + "You can now view a simple graph on your dashboard that helps you understand how much you’re using the API, including how many requests you’ve made and how much data you’ve used.", + category: "Enhancement", + contributor: { + username: "L-Tarun-Aditya", + }, + }, + { + title: + "API keys now expire automatically to keep accounts more secure. Users can set a custom expiry date, and existing API keys will expire after one year by default.", + category: "Enhancement", + contributor: { + username: "biplab-sutradhar", + }, + }, + { + title: + "Catalogs are now created automatically, so you don’t need to set them up manually anymore.", + category: "Enhancement", + contributor: { + username: "BansalAbhinav", + }, + }, + { + title: + "A new Release Page is now available, where you can easily see what’s new in each version and who helped build it.", + category: "Enhancement", + contributor: { + username: "abhishek-2k23", + }, + }, + { + title: + "If you don’t receive your verification email, you can now resend it easily and continue without getting stuck.", + category: "Enhancement", + contributor: { + username: "MukeshAbhi", + }, + }, + ], + }, + { + version: "Previous version", + releaseDate: "Oct 2024", + entries: [ + { + title: + "Dates are now displayed in a consistent and clear format across the entire platform.", + category: "Enhancement", + contributor: { + username: "Sumitgitup", + }, + }, + { + title: + "You can now easily download a copy of your data from the platform whenever you need it.", + category: "Enhancement", + contributor: { + username: "Sumitgitup", + }, + }, + { + title: + "Navigation has been simplified by grouping Dashboard and Sign Out options under a single profile menu.", + category: "Enhancement", + contributor: { + username: "abhishek-2k23", + }, + }, + { + title: + "Updates made by admins now show up instantly, so users always see the latest content without delays.", + category: "Enhancement", + contributor: { + username: "MukeshAbhi", + }, + }, + { + title: + "Image uploads are now faster and more stable, especially when uploading large files.", + category: "Enhancement", + contributor: { + username: "printgourav", + }, + }, + { + title: + "If you miss the verification email, you can now resend it directly without any hassle.", + category: "Enhancement", + contributor: { + username: "MukeshAbhi", + }, + }, + { + title: + "Admins can now quickly see how many images are stored in the system from the dashboard.", + category: "Enhancement", + contributor: { + username: "printgourav", + }, + }, + { + title: + "The platform moved from Firebase to MongoDB to better support growth and handle data more efficiently.", + category: "Enhancement", + contributor: { + username: "amankumarsingh77", + }, + }, + { + title: + "Testing was improved by switching to a faster and more reliable testing setup.", + category: "Enhancement", + contributor: { + username: "Ayushsanjdev", + }, + }, + { + title: + "A new Operator dashboard was added to make it easier to manage and respond to customer queries.", + category: "Enhancement", + contributor: { + username: "asharma991", + }, + }, + { + title: + "Admins gained the ability to re-upload images with checks to ensure correct file names and formats.", + category: "Enhancement", + contributor: { + username: "Soumava-221B", + }, + }, + { + title: + "Subscription usage limits are now reset automatically every month, removing the need for manual updates.", + category: "Enhancement", + contributor: { + username: "DeltaDynamo", + }, + }, + { + title: + "Several visual and usability improvements were made across the footer, About page, and sign-in experience.", + category: "Enhancement", + contributor: { + username: "AryaDharkar", + }, + }, + { + title: + "A new logo search feature was added, making it easier to find and retrieve logos securely.", + category: "Enhancement", + contributor: { + username: "DeltaDynamo", + }, + }, + { + title: + "Navigation behavior was improved so pages smoothly return to the top when links are clicked.", + category: "Enhancement", + contributor: { + username: "Asin-Junior-Honore", + }, + }, + { + title: + "An extra confirmation step was added before deleting API keys to help prevent accidental deletions.", + category: "Enhancement", + contributor: { + username: "anandbaraik", + }, + }, + { + title: + "API keys can now be viewed or copied only once, improving overall account security.", + category: "Enhancement", + contributor: { + username: "Sharathxct", + }, + }, + ], + }, +]; + module.exports = { + OLD_RELEASES, EmailValidationRegex, ExtractCompanyNameFromUrlRegex, UserTokenTypes, From 7dd70ce3a5249f57a2dfd40b8f1b2ce4a907466c Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:08:44 +0530 Subject: [PATCH 02/10] fix: update release sync workflow to use npm for mongoose dependency installation --- .github/workflows/sync-releases.yaml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sync-releases.yaml b/.github/workflows/sync-releases.yaml index f0ed630d7..aca6d258f 100644 --- a/.github/workflows/sync-releases.yaml +++ b/.github/workflows/sync-releases.yaml @@ -14,13 +14,8 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 - - - name: Install backend dependencies - run: pnpm --filter app install --frozen-lockfile + - name: Install dependencies + run: npm install mongoose --legacy-peer-deps - name: Run release sync script working-directory: packages/app From e78a42f9962907eb296e28f7af0b04703f765d77 Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:34:46 +0530 Subject: [PATCH 03/10] feat: implement strict release sync validation, contributors array, and RELEASE_TEMPLATE --- .github/RELEASE_TEMPLATE.md | 67 ++ .../app/__tests__/controllers/release.test.js | 139 ++++ .../__tests__/repositories/release.test.js | 160 ++++ .../__tests__/scripts/syncReleases.test.js | 683 ++++++++++++++++++ .../app/__tests__/services/release.test.js | 89 +++ packages/app/models/release.js | 38 +- packages/app/repositories/index.js | 2 + packages/app/routes/index.js | 3 + packages/app/scripts/syncReleases.js | 385 +++++++--- packages/app/services/index.js | 2 + 10 files changed, 1466 insertions(+), 102 deletions(-) create mode 100644 .github/RELEASE_TEMPLATE.md create mode 100644 packages/app/__tests__/controllers/release.test.js create mode 100644 packages/app/__tests__/repositories/release.test.js create mode 100644 packages/app/__tests__/scripts/syncReleases.test.js create mode 100644 packages/app/__tests__/services/release.test.js diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md new file mode 100644 index 000000000..4da1d6d87 --- /dev/null +++ b/.github/RELEASE_TEMPLATE.md @@ -0,0 +1,67 @@ +## Features + + + +## Enhancements + + + +## Bug Fixes + + + +## Security + + + +## Others + + diff --git a/packages/app/__tests__/controllers/release.test.js b/packages/app/__tests__/controllers/release.test.js new file mode 100644 index 000000000..1209b8d82 --- /dev/null +++ b/packages/app/__tests__/controllers/release.test.js @@ -0,0 +1,139 @@ +"use strict"; + +const request = require("supertest"); +const app = require("../../server"); +const ReleaseService = require("../../services/release"); +const { OLD_RELEASES } = require("../../utils/constants"); + +jest.mock("../../services/release"); + +describe("Releases Controller & Routes (/api/releases)", () => { + let mockGetAllReleases; + let mockGetReleaseByVersion; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetAllReleases = jest.fn(); + mockGetReleaseByVersion = jest.fn(); + + ReleaseService.prototype.getAllReleases = mockGetAllReleases; + ReleaseService.prototype.getReleaseByVersion = mockGetReleaseByVersion; + }); + + describe("GET /api/releases", () => { + it("returns 200 with paginated releases data and archivedReleases", async () => { + const mockResult = { + data: [ + { + version: "v1.0.0", + releaseDate: "2026-09-01T00:00:00.000Z", + githubReleaseId: 101, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.0.0", + entries: [], + }, + ], + total: 1, + currentPage: 1, + totalPages: 1, + }; + + mockGetAllReleases.mockResolvedValue(mockResult); + + const res = await request(app).get("/api/releases"); + + expect(res.status).toBe(200); + expect(res.body.statusCode).toBe(200); + expect(res.body.data).toEqual(mockResult.data); + expect(res.body.pagination).toEqual({ + total: 1, + currentPage: 1, + totalPages: 1, + limit: 10, + }); + expect(res.body.archivedReleases).toEqual(OLD_RELEASES); + expect(mockGetAllReleases).toHaveBeenCalledWith(1, 10); + }); + + it("parses page and limit query parameters", async () => { + mockGetAllReleases.mockResolvedValue({ + data: [], + total: 20, + currentPage: 2, + totalPages: 4, + }); + + const res = await request(app).get("/api/releases?page=2&limit=5"); + + expect(res.status).toBe(200); + expect(mockGetAllReleases).toHaveBeenCalledWith(2, 5); + expect(res.body.pagination.limit).toBe(5); + }); + + it("clamps page to minimum 1 and limit between 1 and 100", async () => { + mockGetAllReleases.mockResolvedValue({ + data: [], + total: 0, + currentPage: 1, + totalPages: 0, + }); + + await request(app).get("/api/releases?page=-5&limit=500"); + + expect(mockGetAllReleases).toHaveBeenCalledWith(1, 100); + }); + }); + + describe("GET /api/releases/:version", () => { + it("returns 200 with single release data when version exists", async () => { + const releaseData = { + version: "v1.2.0", + releaseDate: "2026-09-10T00:00:00.000Z", + githubReleaseId: 202, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.2.0", + entries: [ + { + category: "Feature", + prNumber: 1042, + title: "Revamp USER Dashboard", + description: "Redesign UI", + contributors: [{ username: "personA" }], + }, + ], + }; + + mockGetReleaseByVersion.mockResolvedValue(releaseData); + + const res = await request(app).get("/api/releases/v1.2.0"); + + expect(res.status).toBe(200); + expect(res.body.statusCode).toBe(200); + expect(res.body.data).toEqual(releaseData); + expect(mockGetReleaseByVersion).toHaveBeenCalledWith("v1.2.0"); + }); + + it("returns 200 for archived release version tag", async () => { + const archivedRelease = OLD_RELEASES[0]; + mockGetReleaseByVersion.mockResolvedValue(archivedRelease); + + const res = await request(app).get( + `/api/releases/${encodeURIComponent(archivedRelease.version)}` + ); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual(archivedRelease); + }); + + it("returns 404 when release version is not found", async () => { + mockGetReleaseByVersion.mockResolvedValue(null); + + const res = await request(app).get("/api/releases/v9.9.9"); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ + statusCode: 404, + error: "Not Found", + message: "Release v9.9.9 not found.", + }); + }); + }); +}); diff --git a/packages/app/__tests__/repositories/release.test.js b/packages/app/__tests__/repositories/release.test.js new file mode 100644 index 000000000..0b9fb01b9 --- /dev/null +++ b/packages/app/__tests__/repositories/release.test.js @@ -0,0 +1,160 @@ +"use strict"; + +const mongoose = require("mongoose"); +const ReleaseRepository = require("../../repositories/release"); +const Release = require("../../models/release"); + +describe("ReleaseRepository", () => { + let repository; + let mongoUri; + + beforeAll(async () => { + mongoUri = process.env.MONGO_URL; + if (mongoose.connection.readyState === 0) { + await mongoose.connect(mongoUri); + } + }); + + afterAll(async () => { + await Release.deleteMany({}); + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close(); + } + }); + + beforeEach(async () => { + await Release.deleteMany({}); + repository = new ReleaseRepository(); + }); + + describe("findByVersion", () => { + it("returns matching release document without __v field", async () => { + await Release.create({ + version: "v1.0.0", + releaseDate: new Date("2026-09-01"), + githubReleaseId: 100, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.0.0", + entries: [ + { + category: "Feature", + prNumber: 101, + title: "Test Feature", + description: "Test description", + contributors: [{ username: "alice" }], + }, + ], + }); + + const found = await repository.findByVersion("v1.0.0"); + expect(found).not.toBeNull(); + expect(found.version).toBe("v1.0.0"); + expect(found.githubReleaseId).toBe(100); + expect(found.entries).toHaveLength(1); + expect(found.__v).toBeUndefined(); + }); + + it("returns null when version tag does not exist", async () => { + const found = await repository.findByVersion("v9.9.9"); + expect(found).toBeNull(); + }); + }); + + describe("upsertByVersion", () => { + it("inserts a new release document when version does not exist", async () => { + const payload = { + releaseDate: new Date("2026-09-05"), + githubReleaseId: 200, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.1.0", + entries: [ + { + category: "Enhancement", + prNumber: 102, + title: "Performance boost", + description: "Faster query execution", + contributors: [{ username: "bob" }], + }, + ], + }; + + const result = await repository.upsertByVersion("v1.1.0", payload); + expect(result).not.toBeNull(); + expect(result.version).toBe("v1.1.0"); + expect(result.githubReleaseId).toBe(200); + + const count = await Release.countDocuments({ version: "v1.1.0" }); + expect(count).toBe(1); + }); + + it("updates existing release document atomically when version already exists", async () => { + await Release.create({ + version: "v1.2.0", + releaseDate: new Date("2026-09-10"), + githubReleaseId: 300, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.2.0", + entries: [], + }); + + const updatedPayload = { + releaseDate: new Date("2026-09-10"), + githubReleaseId: 300, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.2.0", + entries: [ + { + category: "Bug Fix", + prNumber: 103, + title: "Fix bug", + contributors: [{ username: "charlie" }], + }, + ], + }; + + const result = await repository.upsertByVersion("v1.2.0", updatedPayload); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].title).toBe("Fix bug"); + + const count = await Release.countDocuments({ version: "v1.2.0" }); + expect(count).toBe(1); + }); + }); + + describe("getPaginated", () => { + it("returns releases in reverse-chronological order with pagination metadata", async () => { + await Release.create([ + { + version: "v1.0.0", + releaseDate: new Date("2026-08-01"), + githubReleaseId: 1, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.0.0", + entries: [], + }, + { + version: "v2.0.0", + releaseDate: new Date("2026-09-01"), + githubReleaseId: 2, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v2.0.0", + entries: [], + }, + { + version: "v1.5.0", + releaseDate: new Date("2026-08-15"), + githubReleaseId: 3, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.5.0", + entries: [], + }, + ]); + + const result = await repository.getPaginated(1, 2); + + expect(result.total).toBe(3); + expect(result.currentPage).toBe(1); + expect(result.totalPages).toBe(2); + expect(result.data).toHaveLength(2); + expect(result.data[0].version).toBe("v2.0.0"); + expect(result.data[1].version).toBe("v1.5.0"); + + const page2 = await repository.getPaginated(2, 2); + expect(page2.data).toHaveLength(1); + expect(page2.data[0].version).toBe("v1.0.0"); + }); + }); +}); diff --git a/packages/app/__tests__/scripts/syncReleases.test.js b/packages/app/__tests__/scripts/syncReleases.test.js new file mode 100644 index 000000000..d53ea7a0d --- /dev/null +++ b/packages/app/__tests__/scripts/syncReleases.test.js @@ -0,0 +1,683 @@ +"use strict"; + +const mongoose = require("mongoose"); +const Release = require("../../models/release"); +const { + syncRelease, + parseReleaseBody, + validateGitHubEntities, + formatValidationErrors, +} = require("../../scripts/syncReleases"); + +describe("syncReleases Script & Parser", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Unit Tests: parseReleaseBody + // ------------------------------------------------------------------------- + describe("parseReleaseBody", () => { + it("1. One category, one entry, one contributor", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +Full redesign of the user interface according to the latest design specifications for improved usability. + +**Contributors:** @personA +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ + category: "Feature", + prNumber: 1042, + title: "Revamp USER Dashboard", + description: + "Full redesign of the user interface according to the latest design specifications for improved usability.", + contributors: [{ username: "personA" }], + }); + }); + + it("2. One category, multiple entries", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +First entry description. + +**Contributors:** @personA + +### #1043 | Add Dark Mode + +Second entry description. + +**Contributors:** @personB +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(2); + expect(entries[0].prNumber).toBe(1042); + expect(entries[1].prNumber).toBe(1043); + }); + + it("3. One entry with multiple contributors", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +Description here. + +**Contributors:** @personA @personB +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0].contributors).toEqual([ + { username: "personA" }, + { username: "personB" }, + ]); + }); + + it("4. Multiple categories", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +Description. + +**Contributors:** @personA + +## Enhancements + +### #1052 | Improve Release Page Performance + +Description. + +**Contributors:** @personA + +## Bug Fixes + +### #1050 | Fix Login Crash + +Description. + +**Contributors:** @personC + +## Security + +### #1044 | Two-Factor Authentication + +Description. + +**Contributors:** @personD + +## Others + +### #1060 | Improve CI Pipeline + +Description. + +**Contributors:** @personE +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(5); + expect(entries.map((e) => e.category)).toEqual([ + "Feature", + "Enhancement", + "Bug Fix", + "Security", + "Other", + ]); + }); + + it("5. Empty description", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +**Contributors:** @personA +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0].description).toBe(""); + }); + + it("6. Multiple contributors with varying whitespace", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +Description. + +**Contributors:** @alice @bob @charlie +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0].contributors).toEqual([ + { username: "alice" }, + { username: "bob" }, + { username: "charlie" }, + ]); + }); + + it("18. Valid 'Others' entry", () => { + const body = ` +## Others + +### #1060 | Improve CI Pipeline + +Migrated the CI pipeline to improve build reliability and reduce execution time. + +**Contributors:** @personA @personB +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ + category: "Other", + prNumber: 1060, + title: "Improve CI Pipeline", + description: + "Migrated the CI pipeline to improve build reliability and reduce execution time.", + contributors: [{ username: "personA" }, { username: "personB" }], + }); + }); + + it("19. Multiple 'Others' entries", () => { + const body = ` +## Others + +### #1060 | Improve CI Pipeline + +Description A. + +**Contributors:** @personA + +### #1062 | Update Docker Configuration + +Description B. + +**Contributors:** @personC +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(2); + expect(entries[0].category).toBe("Other"); + expect(entries[1].category).toBe("Other"); + expect(entries[0].prNumber).toBe(1060); + expect(entries[1].prNumber).toBe(1062); + }); + + it("20. Multiple contributors in 'Others'", () => { + const body = ` +## Others + +### #1060 | Improve CI Pipeline + +Description. + +**Contributors:** @personA @personB @personC +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries[0].category).toBe("Other"); + expect(entries[0].contributors).toEqual([ + { username: "personA" }, + { username: "personB" }, + { username: "personC" }, + ]); + }); + + // ----------------------------------------------------------------------- + // Invalid test cases + // ----------------------------------------------------------------------- + + it("7. Missing PR number", () => { + const body = ` +## Features + +### Revamp USER Dashboard + +Description. + +**Contributors:** @personA +`; + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch(/Invalid entry heading/i); + }); + + it("8. Invalid/non-positive PR number", () => { + const bodyPos = ` +## Features + +### #-10 | Revamp USER Dashboard + +Description. + +**Contributors:** @personA +`; + const { errors: errorsPos } = parseReleaseBody(bodyPos); + expect(errorsPos.length).toBeGreaterThan(0); + expect(errorsPos[0].message).toMatch(/Invalid PR number/i); + + const bodyEmptyPR = ` +## Features + +### # | Revamp USER Dashboard + +Description. + +**Contributors:** @personA +`; + const { errors: errorsEmpty } = parseReleaseBody(bodyEmptyPR); + expect(errorsEmpty.length).toBeGreaterThan(0); + expect(errorsEmpty[0].message).toMatch(/Invalid PR number/i); + }); + + it("9. Missing title", () => { + const body = ` +## Features + +### #1042 | + +Description. + +**Contributors:** @personA +`; + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch(/Missing or empty title/i); + }); + + it("10. Empty title", () => { + const body = ` +## Features + +### #1042 | + +Description. + +**Contributors:** @personA +`; + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch(/Missing or empty title/i); + }); + + it("11. Missing Contributors field", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +Description without contributors line. +`; + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch(/Missing required Contributors field/i); + }); + + it("12. Empty Contributors field", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +Description. + +**Contributors:** +`; + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch(/Empty Contributors field/i); + }); + + it("13. Contributor without @", () => { + const body = ` +## Features + +### #1042 | Revamp USER Dashboard + +Description. + +**Contributors:** personA +`; + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch( + /must use GitHub username syntax beginning with "@"/i + ); + }); + + it("16. Malformed H3 entry that would otherwise be silently skipped", () => { + const body = ` +## Features + +### #1042 | Valid Feature + +Description. + +**Contributors:** @alice + +### Invalid Feature + +Description. + +**Contributors:** @bob +`; + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((e) => e.message.includes("Invalid entry heading")) + ).toBe(true); + }); + + it("17. Unknown section should not create a DB entry or cause error", () => { + const body = ` +## Hero Image + +![Hero Banner](https://example.com/banner.png) + +## Features + +### #1042 | Valid Feature + +Description. + +**Contributors:** @alice +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0].category).toBe("Feature"); + }); + }); + + // ------------------------------------------------------------------------- + // Unit Tests: formatValidationErrors + // ------------------------------------------------------------------------- + describe("formatValidationErrors", () => { + it("formats errors into readable string", () => { + const mockErrors = [ + { + section: "Feature", + pr: 1042, + message: "Missing required Contributors field.", + }, + { + section: "Enhancement", + pr: 1052, + message: "Invalid PR number.", + }, + ]; + + const formatted = formatValidationErrors(mockErrors); + expect(formatted).toContain("Feature → PR #1042"); + expect(formatted).toContain("Missing required Contributors field."); + expect(formatted).toContain("Enhancement → PR #1052"); + }); + }); + + // ------------------------------------------------------------------------- + // Unit Tests: validateGitHubEntities + // ------------------------------------------------------------------------- + describe("validateGitHubEntities", () => { + it("14. Invalid GitHub username", async () => { + global.fetch = jest.fn().mockImplementation((url) => { + if (url.includes("/pulls/1042")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ number: 1042 }), + }); + } + if (url.includes("/users/invaliduser")) { + return Promise.resolve({ + ok: false, + status: 404, + text: () => Promise.resolve("Not Found"), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + + const entries = [ + { + category: "Feature", + prNumber: 1042, + title: "Valid Title", + contributors: [{ username: "invaliduser" }], + }, + ]; + + const errors = await validateGitHubEntities( + entries, + "owner", + "repo", + "token" + ); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch( + /Invalid contributor "@invaliduser". GitHub user could not be found/i + ); + }); + + it("15. Non-existent PR", async () => { + global.fetch = jest.fn().mockImplementation((url) => { + if (url.includes("/pulls/9999")) { + return Promise.resolve({ + ok: false, + status: 404, + text: () => Promise.resolve("Not Found"), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + + const entries = [ + { + category: "Feature", + prNumber: 9999, + title: "Valid Title", + contributors: [{ username: "alice" }], + }, + ]; + + const errors = await validateGitHubEntities( + entries, + "owner", + "repo", + "token" + ); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch(/PR #9999 could not be found/i); + }); + }); + + // ------------------------------------------------------------------------- + // Integration Tests: MongoDB atomic upsert & full syncRelease workflow + // ------------------------------------------------------------------------- + describe("syncRelease MongoDB Integration", () => { + let mongoUri; + + beforeAll(async () => { + mongoUri = process.env.MONGO_URL; + if (mongoose.connection.readyState === 0) { + await mongoose.connect(mongoUri); + } + }); + + afterAll(async () => { + await Release.deleteMany({}); + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close(); + } + }); + + beforeEach(async () => { + await Release.deleteMany({}); + }); + + it("successfully syncs valid release into MongoDB", async () => { + const tag = "v1.0.0-test"; + process.env.GITHUB_TOKEN = "fake_token"; + process.env.MONGO_URL = mongoUri; + process.env.RELEASE_TAG = tag; + process.env.GITHUB_REPOSITORY = "testowner/testrepo"; + + const validReleaseBody = ` +## Features + +### #1042 | Revamp USER Dashboard + +Full redesign description. + +**Contributors:** @personA @personB + +## Others + +### #1060 | Improve CI Pipeline + +Migrated CI pipeline. + +**Contributors:** @personC +`; + + global.fetch = jest.fn().mockImplementation((url) => { + if (url.includes(`/releases/tags/${encodeURIComponent(tag)}`)) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: 12345, + tag_name: tag, + published_at: "2026-09-11T12:00:00Z", + html_url: `https://github.com/testowner/testrepo/releases/tag/${tag}`, + body: validReleaseBody, + }), + }); + } + if (url.includes("/pulls/")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + if (url.includes("/users/")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ + ok: false, + status: 404, + text: () => Promise.resolve(""), + }); + }); + + await syncRelease(); + + // Connect back to inspect DB document (since syncRelease closes connection in finally) + if (mongoose.connection.readyState === 0) { + await mongoose.connect(mongoUri); + } + + const doc = await Release.findOne({ version: tag }); + expect(doc).not.toBeNull(); + expect(doc.version).toBe(tag); + expect(doc.githubReleaseId).toBe(12345); + expect(doc.entries).toHaveLength(2); + expect(doc.entries[0]).toMatchObject({ + category: "Feature", + prNumber: 1042, + title: "Revamp USER Dashboard", + contributors: [{ username: "personA" }, { username: "personB" }], + }); + expect(doc.entries[1]).toMatchObject({ + category: "Other", + prNumber: 1060, + title: "Improve CI Pipeline", + contributors: [{ username: "personC" }], + }); + }); + + it("fails validation on invalid release and writes NOTHING to MongoDB", async () => { + const tag = "v2.0.0-invalid"; + process.env.GITHUB_TOKEN = "fake_token"; + process.env.MONGO_URL = mongoUri; + process.env.RELEASE_TAG = tag; + process.env.GITHUB_REPOSITORY = "testowner/testrepo"; + + const invalidReleaseBody = ` +## Features + +### #1042 | Revamp USER Dashboard + +Description. + +**Contributors:** personA +`; + + global.fetch = jest.fn().mockImplementation((url) => { + if (url.includes(`/releases/tags/${encodeURIComponent(tag)}`)) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: 99999, + tag_name: tag, + published_at: "2026-09-11T12:00:00Z", + html_url: `https://github.com/testowner/testrepo/releases/tag/${tag}`, + body: invalidReleaseBody, + }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + + jest.spyOn(console, "error").mockImplementation(() => {}); + + await expect(syncRelease()).rejects.toThrow("Release validation failed."); + + if (mongoose.connection.readyState === 0) { + await mongoose.connect(mongoUri); + } + + const doc = await Release.findOne({ version: tag }); + expect(doc).toBeNull(); + }); + }); +}); diff --git a/packages/app/__tests__/services/release.test.js b/packages/app/__tests__/services/release.test.js new file mode 100644 index 000000000..6ecfde0bb --- /dev/null +++ b/packages/app/__tests__/services/release.test.js @@ -0,0 +1,89 @@ +"use strict"; + +const ReleaseService = require("../../services/release"); +const { ReleaseRepository } = require("../../repositories"); +const { OLD_RELEASES } = require("../../utils/constants"); + +jest.mock("../../repositories"); + +describe("ReleaseService", () => { + let releaseService; + let mockFindByVersion; + let mockGetPaginated; + + beforeEach(() => { + jest.clearAllMocks(); + mockFindByVersion = jest.fn(); + mockGetPaginated = jest.fn(); + + ReleaseRepository.prototype.findByVersion = mockFindByVersion; + ReleaseRepository.prototype.getPaginated = mockGetPaginated; + + releaseService = new ReleaseService(); + }); + + describe("getReleaseByVersion", () => { + it("returns archived release from OLD_RELEASES if version matches historical tag", async () => { + const archivedTag = OLD_RELEASES[0].version; // e.g. "0.8.0 version" + + const result = await releaseService.getReleaseByVersion(archivedTag); + + expect(result).toEqual(OLD_RELEASES[0]); + expect(mockFindByVersion).not.toHaveBeenCalled(); + }); + + it("queries ReleaseRepository for version when not found in historical archive", async () => { + const dbRelease = { + version: "v1.2.0", + releaseDate: new Date("2026-09-01"), + githubReleaseId: 100, + entries: [], + }; + mockFindByVersion.mockResolvedValue(dbRelease); + + const result = await releaseService.getReleaseByVersion("v1.2.0"); + + expect(mockFindByVersion).toHaveBeenCalledWith("v1.2.0"); + expect(result).toEqual(dbRelease); + }); + + it("returns null when version is not in archive or database", async () => { + mockFindByVersion.mockResolvedValue(null); + + const result = await releaseService.getReleaseByVersion("v9.9.9"); + + expect(mockFindByVersion).toHaveBeenCalledWith("v9.9.9"); + expect(result).toBeNull(); + }); + }); + + describe("getAllReleases", () => { + it("delegates to ReleaseRepository.getPaginated with provided parameters", async () => { + const paginatedResult = { + data: [{ version: "v1.0.0" }], + total: 1, + currentPage: 1, + totalPages: 1, + }; + mockGetPaginated.mockResolvedValue(paginatedResult); + + const result = await releaseService.getAllReleases(2, 5); + + expect(mockGetPaginated).toHaveBeenCalledWith(2, 5); + expect(result).toEqual(paginatedResult); + }); + + it("uses default page=1 and limit=10 if not passed", async () => { + mockGetPaginated.mockResolvedValue({ + data: [], + total: 0, + currentPage: 1, + totalPages: 0, + }); + + await releaseService.getAllReleases(); + + expect(mockGetPaginated).toHaveBeenCalledWith(1, 10); + }); + }); +}); diff --git a/packages/app/models/release.js b/packages/app/models/release.js index bd0cf5741..1db67837a 100644 --- a/packages/app/models/release.js +++ b/packages/app/models/release.js @@ -10,7 +10,11 @@ const mongoose = require("mongoose"); const contributorSchema = new mongoose.Schema( { - username: { type: String, default: null }, + username: { + type: String, + required: true, + trim: true, + }, }, { _id: false } ); @@ -19,13 +23,35 @@ const releaseEntrySchema = new mongoose.Schema( { category: { type: String, - enum: ["Feature", "Enhancement", "Bug Fix", "Security"], + enum: ["Feature", "Enhancement", "Bug Fix", "Security", "Other"], + required: true, + }, + + prNumber: { + type: Number, + required: true, + }, + + title: { + type: String, + required: true, + trim: true, + }, + + description: { + type: String, + default: "", + trim: true, + }, + + contributors: { + type: [contributorSchema], required: true, + validate: { + validator: (value) => Array.isArray(value) && value.length > 0, + message: "At least one contributor is required", + }, }, - prNumber: { type: Number, required: true }, - title: { type: String, required: true }, - description: { type: String, default: "" }, - contributor: { type: contributorSchema, default: () => ({}) }, }, { _id: false } ); diff --git a/packages/app/repositories/index.js b/packages/app/repositories/index.js index 3a05bae98..a22ed5137 100644 --- a/packages/app/repositories/index.js +++ b/packages/app/repositories/index.js @@ -11,6 +11,7 @@ const CreateLogoRequestRepository = require("./createLogoRequest"); const RewardsRepository = require("./rewards"); const RewardTransactionsRepository = require("./rewardTransactions"); const MilestoneConfigRepository = require("./milestoneConfig"); +const ReleaseRepository = require("./release"); module.exports = { ContactUsRepository, @@ -26,4 +27,5 @@ module.exports = { RewardsRepository, RewardTransactionsRepository, MilestoneConfigRepository, + ReleaseRepository, }; diff --git a/packages/app/routes/index.js b/packages/app/routes/index.js index 83bb2a615..85f04b4e3 100644 --- a/packages/app/routes/index.js +++ b/packages/app/routes/index.js @@ -13,6 +13,7 @@ const rewardsRouter = require("./rewards"); const adminRewardsRouter = require("./admin/rewards"); const adminMilestonesRouter = require("./admin/milestoneConfig"); const adminUsersRouter = require("./admin"); +const releasesRouter = require("./releases"); const privateRouteCORS = { origin: (origin, callback) => { @@ -69,4 +70,6 @@ router.use( cors(privateRouteCORS), adminMilestonesRouter ); +router.use("/releases", releasesRouter); + module.exports = router; diff --git a/packages/app/scripts/syncReleases.js b/packages/app/scripts/syncReleases.js index c72907943..fcf9721b3 100644 --- a/packages/app/scripts/syncReleases.js +++ b/packages/app/scripts/syncReleases.js @@ -15,7 +15,7 @@ * * Exit codes: * 0 - Success - * 1 - Fatal error (DB failure, GitHub API failure, missing env vars) + * 1 - Fatal error (DB failure, GitHub API failure, missing env vars, validation failure) */ "use strict"; @@ -31,10 +31,6 @@ function log(msg) { console.log(`[sync-releases] ${msg}`); } -function warn(msg) { - console.warn(`[sync-releases][WARN] ${msg}`); -} - function error(msg) { console.error(`[sync-releases][ERROR] ${msg}`); } @@ -76,7 +72,7 @@ function getConfig() { /** * Wraps fetch with GitHub API defaults and error handling. - * Throws on non-2xx responses — callers decide whether to abort or continue. + * Throws on non-2xx responses. */ async function githubFetch(path, token) { const url = `https://api.github.com${path}`; @@ -108,23 +104,6 @@ async function fetchRelease(owner, repo, tag, token) { ); } -/** - * Fetch a single pull request. - * Returns null (and logs a warning) instead of throwing if the PR is not found - * or if the API call fails — the caller will keep the entry without contributor data. - */ -async function fetchPullRequest(owner, repo, prNumber, token) { - try { - return await githubFetch( - `/repos/${owner}/${repo}/pulls/${prNumber}`, - token - ); - } catch (err) { - warn(`Could not fetch PR #${prNumber}: ${err.message}`); - return null; - } -} - // --------------------------------------------------------------------------- // Release note parser // --------------------------------------------------------------------------- @@ -134,86 +113,288 @@ const CATEGORY_MAP = { enhancements: "Enhancement", "bug fixes": "Bug Fix", security: "Security", + others: "Other", }; /** * Parse release body into structured category sections and entries. - * - * Returns an array of raw entry objects: - * { category, prNumber, title, description } - * - * Invalid / malformed entries are skipped with a warning. + * Returns { entries: Array, errors: Array } */ function parseReleaseBody(body) { const entries = []; + const errors = []; + + if (!body || typeof body !== "string") { + return { entries, errors }; + } + + // Remove HTML comments (like template guidance) + const cleanBody = body.replace(//g, ""); + + // Find all H2 headers: ## Header + const h2Regex = /^##\s+(.+)$/gm; + let match; + const sectionIndices = []; + while ((match = h2Regex.exec(cleanBody)) !== null) { + sectionIndices.push({ + header: match[1].trim(), + index: match.index, + headerLength: match[0].length, + }); + } + + for (let i = 0; i < sectionIndices.length; i++) { + const sectionHeader = sectionIndices[i].header; + const categoryKey = sectionHeader.toLowerCase(); + const category = CATEGORY_MAP[categoryKey]; + + // Ignore unsupported sections such as ## Hero Image + if (!category) continue; + + const start = sectionIndices[i].index + sectionIndices[i].headerLength; + const end = + i + 1 < sectionIndices.length + ? sectionIndices[i + 1].index + : cleanBody.length; + const sectionContent = cleanBody.substring(start, end).trim(); + + if (!sectionContent) continue; + + // Find all H3 headers inside this section + const h3Regex = /^###\s+(.+)$/gm; + let h3Match; + const h3Indices = []; + while ((h3Match = h3Regex.exec(sectionContent)) !== null) { + h3Indices.push({ + headerLine: h3Match[1].trim(), + fullHeader: h3Match[0], + index: h3Match.index, + headerLength: h3Match[0].length, + }); + } + + if (h3Indices.length === 0) { + if (sectionContent.length > 0) { + errors.push({ + section: category, + pr: null, + title: null, + message: `Malformed entry in section "${sectionHeader}". Expected heading starting with "### # | ".`, + }); + } + continue; + } + + const textBeforeH3 = sectionContent.substring(0, h3Indices[0].index).trim(); + if (textBeforeH3.length > 0) { + errors.push({ + section: category, + pr: null, + title: null, + message: `Malformed content before entry in section "${sectionHeader}": "${textBeforeH3.substring(0, 40)}..."`, + }); + } + + for (let j = 0; j < h3Indices.length; j++) { + const h3Header = h3Indices[j].headerLine; + const blockStart = h3Indices[j].index + h3Indices[j].headerLength; + const blockEnd = + j + 1 < h3Indices.length + ? h3Indices[j + 1].index + : sectionContent.length; + const blockBody = sectionContent.substring(blockStart, blockEnd).trim(); + + // Validate header format: #<PR_NUMBER> | <TITLE> + const headerMatch = h3Header.match(/^#([^\s|]*)\s*\|\s*(.*)$/); + let prNumber = null; + let title = null; + + if (!headerMatch) { + errors.push({ + section: category, + pr: null, + title: h3Header, + message: `Invalid entry heading "### ${h3Header}". Expected format "### #<PR_NUMBER> | <TITLE>".`, + }); + } else { + const prRaw = headerMatch[1].trim(); + title = headerMatch[2].trim(); + + if ( + !prRaw || + isNaN(prRaw) || + parseInt(prRaw, 10) <= 0 || + !/^\d+$/.test(prRaw) + ) { + errors.push({ + section: category, + pr: prRaw || null, + title: title || null, + message: `Invalid PR number "${prRaw}" in entry heading "### ${h3Header}". PR number must be a positive integer.`, + }); + } else { + prNumber = parseInt(prRaw, 10); + } + + if (!title) { + errors.push({ + section: category, + pr: prNumber, + title: null, + message: `Missing or empty title in entry heading "### ${h3Header}".`, + }); + } + } - // Split on H2 headings (## Section Name) - // Each segment: [ fullMatch, headingText, sectionContent ] - const sectionRegex = /##\s+(.+?)\s*\n([\s\S]*?)(?=\n##\s|\s*$)/gi; - let sectionMatch; - - while ((sectionMatch = sectionRegex.exec(body)) !== null) { - const headingRaw = sectionMatch[1].trim().toLowerCase(); - const sectionContent = sectionMatch[2]; - - const category = CATEGORY_MAP[headingRaw]; - if (!category) continue; // Skip Hero Image and any unknown sections - - // Split section on H3 headings (### #NNNN | Title) - const entryRegex = - /###\s+#(\d+)\s*\|\s*(.+?)\s*\n([\s\S]*?)(?=\n###\s|\s*$)/gi; - let entryMatch; - - while ((entryMatch = entryRegex.exec(sectionContent)) !== null) { - const prNumberRaw = entryMatch[1]; - const title = entryMatch[2].trim(); - const description = entryMatch[3].trim(); - - const prNumber = parseInt(prNumberRaw, 10); - if (isNaN(prNumber) || prNumber <= 0) { - warn( - `Skipping entry with invalid PR number "#${prNumberRaw}" in section "${category}".` - ); - continue; + // Extract Contributors + const contribMatch = blockBody.match(/^\*\*Contributors:\*\*\s*(.*)$/im); + let contributors = []; + + if (!contribMatch) { + errors.push({ + section: category, + pr: prNumber, + title: title, + message: `Missing required Contributors field.`, + }); + } else { + const contribLine = contribMatch[1].trim(); + if (!contribLine) { + errors.push({ + section: category, + pr: prNumber, + title: title, + message: `Empty Contributors field.`, + }); + } else { + const tokens = contribLine.split(/\s+/).filter(Boolean); + const validContributors = []; + + for (const token of tokens) { + if (!token.startsWith("@")) { + errors.push({ + section: category, + pr: prNumber, + title: title, + message: `Invalid contributor "${token}". All contributor values must use GitHub username syntax beginning with "@".`, + }); + } else { + const username = token.slice(1).trim(); + if (!username) { + errors.push({ + section: category, + pr: prNumber, + title: title, + message: `Invalid contributor username "${token}".`, + }); + } else { + validContributors.push({ username }); + } + } + } + + if (validContributors.length > 0) { + contributors = validContributors; + } + } } - if (!title) { - warn(`Skipping PR #${prNumber} in section "${category}": empty title.`); - continue; + // Extract description + let description = ""; + if (contribMatch) { + const contribIdx = blockBody.indexOf(contribMatch[0]); + description = blockBody.substring(0, contribIdx).trim(); + } else { + description = blockBody.trim(); } - entries.push({ category, prNumber, title, description }); + entries.push({ + category, + prNumber, + title, + description, + contributors, + }); } } - return entries; + return { entries, errors }; } // --------------------------------------------------------------------------- -// Contributor enrichment +// GitHub Entity Validation (PR existence & User existence) // --------------------------------------------------------------------------- -/** - * Fetch PR data and extract contributor metadata. - * Returns a contributor object — all fields are null on failure. - */ -async function enrichEntryWithPR(entry, owner, repo, token) { - const pr = await fetchPullRequest(owner, repo, entry.prNumber, token); +async function validateGitHubEntities(entries, owner, repo, token, cache = {}) { + const errors = []; + const prCache = cache.prCache || new Map(); + const userCache = cache.userCache || new Map(); + + // Validate PR numbers + for (const entry of entries) { + const { category, prNumber, title } = entry; + if (!prNumber) continue; + + if (!prCache.has(prNumber)) { + try { + await githubFetch(`/repos/${owner}/${repo}/pulls/${prNumber}`, token); + prCache.set(prNumber, true); + } catch { + prCache.set(prNumber, false); + } + } - if (!pr) { - return { - ...entry, - contributor: { username: null }, - }; + if (!prCache.get(prNumber)) { + errors.push({ + section: category, + pr: prNumber, + title: title, + message: `PR #${prNumber} could not be found or fetched from repository "${owner}/${repo}".`, + }); + } } - const user = pr.user || {}; - return { - ...entry, - contributor: { - username: user.login || null, - }, - }; + // Validate Contributor Usernames + for (const entry of entries) { + const { category, prNumber, title, contributors } = entry; + if (!contributors) continue; + + for (const c of contributors) { + const { username } = c; + if (!username) continue; + + if (!userCache.has(username)) { + try { + await githubFetch(`/users/${encodeURIComponent(username)}`, token); + userCache.set(username, true); + } catch { + userCache.set(username, false); + } + } + + if (!userCache.get(username)) { + errors.push({ + section: category, + pr: prNumber, + title: title, + message: `Invalid contributor "@${username}". GitHub user could not be found.`, + }); + } + } + } + + return errors; +} + +function formatValidationErrors(errors) { + let output = "Release validation failed:\n"; + errors.forEach((err, idx) => { + const header = err.section + ? `${err.section} → ${err.pr ? `PR #${err.pr}` : err.title ? `Entry "${err.title}"` : "Entry"}` + : "Entry"; + output += `\n${idx + 1}. ${header}\n ${err.message}\n`; + }); + return output; } // --------------------------------------------------------------------------- @@ -244,30 +425,37 @@ async function syncRelease() { const body = ghRelease.body || ""; - // 4. Parse release entries - const rawEntries = parseReleaseBody(body); - log(`Parsed ${rawEntries.length} release entries.`); + // 3. Parse release entries and structure + const { entries: parsedEntries, errors: parseErrors } = + parseReleaseBody(body); - // 5. Enrich entries with PR metadata (in parallel, failures are non-fatal) - const enrichedEntries = await Promise.all( - rawEntries.map((entry) => - enrichEntryWithPR(entry, owner, repo, GITHUB_TOKEN) - ) + // 4. Validate GitHub entities (PRs and users) + const githubErrors = await validateGitHubEntities( + parsedEntries, + owner, + repo, + GITHUB_TOKEN ); - log( - `Enriched ${enrichedEntries.length} entries (some contributor fields may be null if PR fetch failed).` - ); + const allErrors = [...parseErrors, ...githubErrors]; + + if (allErrors.length > 0) { + const formattedErr = formatValidationErrors(allErrors); + error(formattedErr); + throw new Error("Release validation failed."); + } + + log(`Validated ${parsedEntries.length} release entries successfully.`); - // 6. Build the release document payload + // 5. Build release payload const releasePayload = { releaseDate: new Date(ghRelease.published_at), githubReleaseId: ghRelease.id, githubReleaseUrl: ghRelease.html_url, - entries: enrichedEntries, + entries: parsedEntries, }; - // 7. Atomic upsert — single write, no partial persistence risk + // 6. Atomic upsert into MongoDB log(`Upserting release version="${RELEASE_TAG}" into MongoDB...`); const saved = await Release.findOneAndUpdate( { version: RELEASE_TAG }, @@ -279,7 +467,6 @@ async function syncRelease() { `Successfully synced release "${RELEASE_TAG}" (MongoDB _id: ${saved._id}).` ); } finally { - // Always close the connection await mongoose.connection.close(); log("MongoDB connection closed."); } @@ -302,4 +489,10 @@ if (require.main === module) { }); } -module.exports = { syncRelease, parseReleaseBody }; +module.exports = { + syncRelease, + parseReleaseBody, + validateGitHubEntities, + formatValidationErrors, + CATEGORY_MAP, +}; diff --git a/packages/app/services/index.js b/packages/app/services/index.js index 1af0e894a..68997a4ad 100644 --- a/packages/app/services/index.js +++ b/packages/app/services/index.js @@ -14,6 +14,7 @@ const RewardsService = require("./rewards"); const MilestoneConfigService = require("./milestoneConfig"); const RewardTransactionsService = require("./rewardTransactions"); const MfaService = require("./mfa"); +const ReleaseService = require("./release"); module.exports = { ContactUsService, @@ -32,4 +33,5 @@ module.exports = { RewardTransactionsService, MilestoneConfigService, MfaService, + ReleaseService, }; From b2408ff51522b638c9180e89140940de6d96c43d Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Tue, 15 Sep 2026 03:04:25 +0530 Subject: [PATCH 04/10] feat: add historical release seeding script and update release parser for new markdown formats --- .github/RELEASE_TEMPLATE.md | 95 +-- .github/workflows/sync-releases.yaml | 2 +- .../app/__tests__/controllers/release.test.js | 17 +- .../__tests__/repositories/release.test.js | 31 +- .../scripts/seedHistoricalReleases.test.js | 60 ++ .../__tests__/scripts/syncReleases.test.js | 359 +++------ .../app/__tests__/services/release.test.js | 14 +- packages/app/controllers/release.js | 3 - packages/app/models/release.js | 13 +- .../releases/historicalReleasesData.js | 698 ++++++++++++++++++ .../releases/seedHistoricalReleases.js | 81 ++ .../syncNewReleases.js} | 147 ++-- packages/app/services/release.js | 17 +- packages/app/utils/constants.js | 348 --------- 14 files changed, 1129 insertions(+), 756 deletions(-) create mode 100644 packages/app/__tests__/scripts/seedHistoricalReleases.test.js create mode 100644 packages/app/scripts/releases/historicalReleasesData.js create mode 100644 packages/app/scripts/releases/seedHistoricalReleases.js rename packages/app/scripts/{syncReleases.js => releases/syncNewReleases.js} (77%) diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md index 4da1d6d87..320e98daf 100644 --- a/.github/RELEASE_TEMPLATE.md +++ b/.github/RELEASE_TEMPLATE.md @@ -1,67 +1,72 @@ -## Features - <!-- -Add feature entries using exactly this format: - -### #<PR_NUMBER> | <TITLE> - -<DESCRIPTION> - -**Contributors:** @<USERNAME> [@<USERNAME> ...] - -Example: - -### #1042 | Revamp USER Dashboard +# Release Notes Instructions + +Please follow this format when drafting the release notes. +- The sync process validates the release body and will fail if the required format is not followed. + + +IMPORTANT: +- The release introduction is optional. +- Keep the category headings exactly as shown, including the 📝 emoji. +- Use only the categories that are relevant to this release. +- Remove any category that has no entries. Do not leave unused categories in the release body. +- Each entry must be placed under the appropriate category. +- Do not change the heading levels: + ### for categories + #### for entries +- Each entry must follow this format: + #### #<PR_NUMBER> | <TITLE> + + <DESCRIPTION> + + > Contributors: @<USERNAME> [@<USERNAME> ...] + +- PR_NUMBER must be the actual GitHub Pull Request number. +- TITLE must be the title of the entry. +- DESCRIPTION should briefly describe the change. +- Every entry must include a Contributors line. +- Contributors must be GitHub usernames beginning with @. +- Multiple contributors should be separated by spaces. +- Do not use commas between contributor usernames. +- Do not add text or entries outside the defined structure. +- Replace the `Entry` placeholder under each category with the actual release entry, or remove the entire category if it is not used. + +Example Entry: + +#### #1042 | Revamp USER Dashboard Full redesign of the user interface according to the latest design specifications for improved usability. -**Contributors:** @personA @personB ---> - -## Enhancements - -<!-- -Add enhancement entries using exactly this format: +> Contributors: @personA @personB -### #<PR_NUMBER> | <TITLE> +--> -<DESCRIPTION> +[OPTIONAL RELEASE INTRODUCTION] -**Contributors:** @<USERNAME> [@<USERNAME> ...] ---> +### 📝 Features -## Bug Fixes +#### #1042 | Revamp USER Dashboard -<!-- -Add bug-fix entries using exactly this format: +Full redesign of the user interface according to the latest design specifications for improved usability. -### #<PR_NUMBER> | <TITLE> +> Contributors: @personA @personB -<DESCRIPTION> -**Contributors:** @<USERNAME> [@<USERNAME> ...] ---> +### 📝 Enhancements -## Security +Entry -<!-- -Add security entries using exactly this format: -### #<PR_NUMBER> | <TITLE> +### 📝 Bug Fixes -<DESCRIPTION> +Entry -**Contributors:** @<USERNAME> [@<USERNAME> ...] ---> -## Others +### 📝 Security -<!-- -Add other entries using exactly this format: +Entry -### #<PR_NUMBER> | <TITLE> -<DESCRIPTION> +### 📝 Others -**Contributors:** @<USERNAME> [@<USERNAME> ...] ---> +Entry \ No newline at end of file diff --git a/.github/workflows/sync-releases.yaml b/.github/workflows/sync-releases.yaml index aca6d258f..cd026516b 100644 --- a/.github/workflows/sync-releases.yaml +++ b/.github/workflows/sync-releases.yaml @@ -24,4 +24,4 @@ jobs: MONGO_URL: ${{ secrets.MONGO_URL }} RELEASE_TAG: ${{ github.event.release.tag_name }} GITHUB_REPOSITORY: ${{ github.repository }} - run: node scripts/syncReleases.js + run: node scripts/releases/syncNewReleases.js diff --git a/packages/app/__tests__/controllers/release.test.js b/packages/app/__tests__/controllers/release.test.js index 1209b8d82..15214a8a1 100644 --- a/packages/app/__tests__/controllers/release.test.js +++ b/packages/app/__tests__/controllers/release.test.js @@ -3,7 +3,6 @@ const request = require("supertest"); const app = require("../../server"); const ReleaseService = require("../../services/release"); -const { OLD_RELEASES } = require("../../utils/constants"); jest.mock("../../services/release"); @@ -21,7 +20,7 @@ describe("Releases Controller & Routes (/api/releases)", () => { }); describe("GET /api/releases", () => { - it("returns 200 with paginated releases data and archivedReleases", async () => { + it("returns 200 with paginated releases data", async () => { const mockResult = { data: [ { @@ -50,7 +49,7 @@ describe("Releases Controller & Routes (/api/releases)", () => { totalPages: 1, limit: 10, }); - expect(res.body.archivedReleases).toEqual(OLD_RELEASES); + expect(res.body.archivedReleases).toBeUndefined(); expect(mockGetAllReleases).toHaveBeenCalledWith(1, 10); }); @@ -111,18 +110,6 @@ describe("Releases Controller & Routes (/api/releases)", () => { expect(mockGetReleaseByVersion).toHaveBeenCalledWith("v1.2.0"); }); - it("returns 200 for archived release version tag", async () => { - const archivedRelease = OLD_RELEASES[0]; - mockGetReleaseByVersion.mockResolvedValue(archivedRelease); - - const res = await request(app).get( - `/api/releases/${encodeURIComponent(archivedRelease.version)}` - ); - - expect(res.status).toBe(200); - expect(res.body.data).toEqual(archivedRelease); - }); - it("returns 404 when release version is not found", async () => { mockGetReleaseByVersion.mockResolvedValue(null); diff --git a/packages/app/__tests__/repositories/release.test.js b/packages/app/__tests__/repositories/release.test.js index 0b9fb01b9..b3ce69f1e 100644 --- a/packages/app/__tests__/repositories/release.test.js +++ b/packages/app/__tests__/repositories/release.test.js @@ -31,6 +31,7 @@ describe("ReleaseRepository", () => { it("returns matching release document without __v field", async () => { await Release.create({ version: "v1.0.0", + tagName: "v1.0.0", releaseDate: new Date("2026-09-01"), githubReleaseId: 100, githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.0.0", @@ -62,6 +63,7 @@ describe("ReleaseRepository", () => { describe("upsertByVersion", () => { it("inserts a new release document when version does not exist", async () => { const payload = { + tagName: "v1.1.0", releaseDate: new Date("2026-09-05"), githubReleaseId: 200, githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.1.0", @@ -88,6 +90,7 @@ describe("ReleaseRepository", () => { it("updates existing release document atomically when version already exists", async () => { await Release.create({ version: "v1.2.0", + tagName: "v1.2.0", releaseDate: new Date("2026-09-10"), githubReleaseId: 300, githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.2.0", @@ -95,6 +98,7 @@ describe("ReleaseRepository", () => { }); const updatedPayload = { + tagName: "v1.2.0", releaseDate: new Date("2026-09-10"), githubReleaseId: 300, githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.2.0", @@ -121,24 +125,27 @@ describe("ReleaseRepository", () => { it("returns releases in reverse-chronological order with pagination metadata", async () => { await Release.create([ { - version: "v1.0.0", + version: "v1.0.0-p", + tagName: "v1.0.0-p", releaseDate: new Date("2026-08-01"), - githubReleaseId: 1, - githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.0.0", + githubReleaseId: 1001, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.0.0-p", entries: [], }, { - version: "v2.0.0", + version: "v2.0.0-p", + tagName: "v2.0.0-p", releaseDate: new Date("2026-09-01"), - githubReleaseId: 2, - githubReleaseUrl: "https://github.com/org/repo/releases/tag/v2.0.0", + githubReleaseId: 1002, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v2.0.0-p", entries: [], }, { - version: "v1.5.0", + version: "v1.5.0-p", + tagName: "v1.5.0-p", releaseDate: new Date("2026-08-15"), - githubReleaseId: 3, - githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.5.0", + githubReleaseId: 1003, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.5.0-p", entries: [], }, ]); @@ -149,12 +156,12 @@ describe("ReleaseRepository", () => { expect(result.currentPage).toBe(1); expect(result.totalPages).toBe(2); expect(result.data).toHaveLength(2); - expect(result.data[0].version).toBe("v2.0.0"); - expect(result.data[1].version).toBe("v1.5.0"); + expect(result.data[0].version).toBe("v2.0.0-p"); + expect(result.data[1].version).toBe("v1.5.0-p"); const page2 = await repository.getPaginated(2, 2); expect(page2.data).toHaveLength(1); - expect(page2.data[0].version).toBe("v1.0.0"); + expect(page2.data[0].version).toBe("v1.0.0-p"); }); }); }); diff --git a/packages/app/__tests__/scripts/seedHistoricalReleases.test.js b/packages/app/__tests__/scripts/seedHistoricalReleases.test.js new file mode 100644 index 000000000..b267d9783 --- /dev/null +++ b/packages/app/__tests__/scripts/seedHistoricalReleases.test.js @@ -0,0 +1,60 @@ +"use strict"; + +const mongoose = require("mongoose"); +const Release = require("../../models/release"); +const { + seedHistoricalReleases, +} = require("../../scripts/releases/seedHistoricalReleases"); +const historicalReleases = require("../../scripts/releases/historicalReleasesData"); + +describe("seedHistoricalReleases Script", () => { + let mongoUri; + + beforeAll(async () => { + mongoUri = process.env.MONGO_URL; + if (mongoose.connection.readyState === 0) { + await mongoose.connect(mongoUri); + } + }); + + afterAll(async () => { + await Release.deleteMany({}); + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close(); + } + }); + + beforeEach(async () => { + await Release.deleteMany({}); + }); + + it("seeds historical releases into MongoDB idempotently", async () => { + process.env.MONGO_URL = mongoUri; + + await seedHistoricalReleases(); + + if (mongoose.connection.readyState === 0) { + await mongoose.connect(mongoUri); + } + + const count = await Release.countDocuments({}); + expect(count).toBe(historicalReleases.length); + + // Verify first historical release doc + const firstDoc = await Release.findOne({ + version: historicalReleases[0].version, + }); + expect(firstDoc).not.toBeNull(); + expect(firstDoc.tagName).toBe(historicalReleases[0].tagName); + + // Run again to test idempotency + await seedHistoricalReleases(); + + if (mongoose.connection.readyState === 0) { + await mongoose.connect(mongoUri); + } + + const countAfterReseed = await Release.countDocuments({}); + expect(countAfterReseed).toBe(historicalReleases.length); + }); +}); diff --git a/packages/app/__tests__/scripts/syncReleases.test.js b/packages/app/__tests__/scripts/syncReleases.test.js index d53ea7a0d..5931ad409 100644 --- a/packages/app/__tests__/scripts/syncReleases.test.js +++ b/packages/app/__tests__/scripts/syncReleases.test.js @@ -7,9 +7,9 @@ const { parseReleaseBody, validateGitHubEntities, formatValidationErrors, -} = require("../../scripts/syncReleases"); +} = require("../../scripts/releases/syncNewReleases"); -describe("syncReleases Script & Parser", () => { +describe("syncNewReleases Script & Parser", () => { const originalEnv = process.env; beforeEach(() => { @@ -26,15 +26,15 @@ describe("syncReleases Script & Parser", () => { // Unit Tests: parseReleaseBody // ------------------------------------------------------------------------- describe("parseReleaseBody", () => { - it("1. One category, one entry, one contributor", () => { + it("1. One category, one entry, one contributor with emoji in category header", () => { const body = ` -## Features +### 📝 Features -### #1042 | Revamp USER Dashboard +#### #1042 | Revamp USER Dashboard Full redesign of the user interface according to the latest design specifications for improved usability. -**Contributors:** @personA +> Contributors: @personA `; const { entries, errors } = parseReleaseBody(body); @@ -50,21 +50,40 @@ Full redesign of the user interface according to the latest design specification }); }); - it("2. One category, multiple entries", () => { + it("2. Optional release introduction before first category header is ignored", () => { const body = ` -## Features +Welcome to version 0.8.0! This release introduces major UI improvements. -### #1042 | Revamp USER Dashboard +### 📝 Features + +#### #1042 | Revamp USER Dashboard + +Full redesign description. + +> Contributors: @personA +`; + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0].prNumber).toBe(1042); + }); + + it("3. One category, multiple entries", () => { + const body = ` +### 📝 Features + +#### #1042 | Revamp USER Dashboard First entry description. -**Contributors:** @personA +> Contributors: @personA -### #1043 | Add Dark Mode +#### #1043 | Add Dark Mode Second entry description. -**Contributors:** @personB +> Contributors: @personB `; const { entries, errors } = parseReleaseBody(body); @@ -74,15 +93,15 @@ Second entry description. expect(entries[1].prNumber).toBe(1043); }); - it("3. One entry with multiple contributors", () => { + it("4. Multiple contributors in blockquote format", () => { const body = ` -## Features +### 📝 Features -### #1042 | Revamp USER Dashboard +#### #1042 | Revamp USER Dashboard Description here. -**Contributors:** @personA @personB +> Contributors: @personA @personB `; const { entries, errors } = parseReleaseBody(body); @@ -94,47 +113,47 @@ Description here. ]); }); - it("4. Multiple categories", () => { + it("5. Multiple categories with leading emojis", () => { const body = ` -## Features +### 📝 Features -### #1042 | Revamp USER Dashboard +#### #1042 | Revamp USER Dashboard Description. -**Contributors:** @personA +> Contributors: @personA -## Enhancements +### 📝 Enhancements -### #1052 | Improve Release Page Performance +#### #1052 | Improve Release Page Performance Description. -**Contributors:** @personA +> Contributors: @personA -## Bug Fixes +### 📝 Bug Fixes -### #1050 | Fix Login Crash +#### #1050 | Fix Login Crash Description. -**Contributors:** @personC +> Contributors: @personC -## Security +### 📝 Security -### #1044 | Two-Factor Authentication +#### #1044 | Two-Factor Authentication Description. -**Contributors:** @personD +> Contributors: @personD -## Others +### 📝 Others -### #1060 | Improve CI Pipeline +#### #1060 | Improve CI Pipeline Description. -**Contributors:** @personE +> Contributors: @personE `; const { entries, errors } = parseReleaseBody(body); @@ -149,126 +168,53 @@ Description. ]); }); - it("5. Empty description", () => { - const body = ` -## Features + it("6. Rejects empty category or leftover 'Entry' placeholder", () => { + const bodyEmpty = ` +### 📝 Features -### #1042 | Revamp USER Dashboard - -**Contributors:** @personA -`; - const { entries, errors } = parseReleaseBody(body); - - expect(errors).toHaveLength(0); - expect(entries).toHaveLength(1); - expect(entries[0].description).toBe(""); - }); - - it("6. Multiple contributors with varying whitespace", () => { - const body = ` -## Features - -### #1042 | Revamp USER Dashboard +#### #1042 | Valid Feature Description. -**Contributors:** @alice @bob @charlie -`; - const { entries, errors } = parseReleaseBody(body); +> Contributors: @personA - expect(errors).toHaveLength(0); - expect(entries).toHaveLength(1); - expect(entries[0].contributors).toEqual([ - { username: "alice" }, - { username: "bob" }, - { username: "charlie" }, - ]); - }); +### 📝 Enhancements - it("18. Valid 'Others' entry", () => { - const body = ` -## Others - -### #1060 | Improve CI Pipeline - -Migrated the CI pipeline to improve build reliability and reduce execution time. - -**Contributors:** @personA @personB -`; - const { entries, errors } = parseReleaseBody(body); - - expect(errors).toHaveLength(0); - expect(entries).toHaveLength(1); - expect(entries[0]).toEqual({ - category: "Other", - prNumber: 1060, - title: "Improve CI Pipeline", - description: - "Migrated the CI pipeline to improve build reliability and reduce execution time.", - contributors: [{ username: "personA" }, { username: "personB" }], - }); - }); - - it("19. Multiple 'Others' entries", () => { - const body = ` -## Others - -### #1060 | Improve CI Pipeline - -Description A. - -**Contributors:** @personA - -### #1062 | Update Docker Configuration - -Description B. - -**Contributors:** @personC +Entry `; - const { entries, errors } = parseReleaseBody(body); - - expect(errors).toHaveLength(0); - expect(entries).toHaveLength(2); - expect(entries[0].category).toBe("Other"); - expect(entries[1].category).toBe("Other"); - expect(entries[0].prNumber).toBe(1060); - expect(entries[1].prNumber).toBe(1062); + const { errors: errorsEmpty } = parseReleaseBody(bodyEmpty); + expect(errorsEmpty.length).toBeGreaterThan(0); + expect(errorsEmpty[0].message).toMatch(/Leftover placeholder "Entry"/i); }); - it("20. Multiple contributors in 'Others'", () => { + it("7. Empty description is supported", () => { const body = ` -## Others - -### #1060 | Improve CI Pipeline +### 📝 Features -Description. +#### #1042 | Revamp USER Dashboard -**Contributors:** @personA @personB @personC +> Contributors: @personA `; const { entries, errors } = parseReleaseBody(body); expect(errors).toHaveLength(0); - expect(entries[0].category).toBe("Other"); - expect(entries[0].contributors).toEqual([ - { username: "personA" }, - { username: "personB" }, - { username: "personC" }, - ]); + expect(entries).toHaveLength(1); + expect(entries[0].description).toBe(""); }); // ----------------------------------------------------------------------- // Invalid test cases // ----------------------------------------------------------------------- - it("7. Missing PR number", () => { + it("8. Missing PR number", () => { const body = ` -## Features +### 📝 Features -### Revamp USER Dashboard +#### Revamp USER Dashboard Description. -**Contributors:** @personA +> Contributors: @personA `; const { errors } = parseReleaseBody(body); @@ -276,59 +222,30 @@ Description. expect(errors[0].message).toMatch(/Invalid entry heading/i); }); - it("8. Invalid/non-positive PR number", () => { + it("9. Invalid/non-positive PR number", () => { const bodyPos = ` -## Features +### 📝 Features -### #-10 | Revamp USER Dashboard +#### #-10 | Revamp USER Dashboard Description. -**Contributors:** @personA +> Contributors: @personA `; const { errors: errorsPos } = parseReleaseBody(bodyPos); expect(errorsPos.length).toBeGreaterThan(0); expect(errorsPos[0].message).toMatch(/Invalid PR number/i); - - const bodyEmptyPR = ` -## Features - -### # | Revamp USER Dashboard - -Description. - -**Contributors:** @personA -`; - const { errors: errorsEmpty } = parseReleaseBody(bodyEmptyPR); - expect(errorsEmpty.length).toBeGreaterThan(0); - expect(errorsEmpty[0].message).toMatch(/Invalid PR number/i); - }); - - it("9. Missing title", () => { - const body = ` -## Features - -### #1042 | - -Description. - -**Contributors:** @personA -`; - const { errors } = parseReleaseBody(body); - - expect(errors.length).toBeGreaterThan(0); - expect(errors[0].message).toMatch(/Missing or empty title/i); }); - it("10. Empty title", () => { + it("10. Missing title", () => { const body = ` -## Features +### 📝 Features -### #1042 | +#### #1042 | Description. -**Contributors:** @personA +> Contributors: @personA `; const { errors } = parseReleaseBody(body); @@ -338,9 +255,9 @@ Description. it("11. Missing Contributors field", () => { const body = ` -## Features +### 📝 Features -### #1042 | Revamp USER Dashboard +#### #1042 | Revamp USER Dashboard Description without contributors line. `; @@ -350,31 +267,15 @@ Description without contributors line. expect(errors[0].message).toMatch(/Missing required Contributors field/i); }); - it("12. Empty Contributors field", () => { - const body = ` -## Features - -### #1042 | Revamp USER Dashboard - -Description. - -**Contributors:** -`; - const { errors } = parseReleaseBody(body); - - expect(errors.length).toBeGreaterThan(0); - expect(errors[0].message).toMatch(/Empty Contributors field/i); - }); - - it("13. Contributor without @", () => { + it("12. Contributor without @", () => { const body = ` -## Features +### 📝 Features -### #1042 | Revamp USER Dashboard +#### #1042 | Revamp USER Dashboard Description. -**Contributors:** personA +> Contributors: personA `; const { errors } = parseReleaseBody(body); @@ -383,51 +284,6 @@ Description. /must use GitHub username syntax beginning with "@"/i ); }); - - it("16. Malformed H3 entry that would otherwise be silently skipped", () => { - const body = ` -## Features - -### #1042 | Valid Feature - -Description. - -**Contributors:** @alice - -### Invalid Feature - -Description. - -**Contributors:** @bob -`; - const { errors } = parseReleaseBody(body); - - expect(errors.length).toBeGreaterThan(0); - expect( - errors.some((e) => e.message.includes("Invalid entry heading")) - ).toBe(true); - }); - - it("17. Unknown section should not create a DB entry or cause error", () => { - const body = ` -## Hero Image - -![Hero Banner](https://example.com/banner.png) - -## Features - -### #1042 | Valid Feature - -Description. - -**Contributors:** @alice -`; - const { entries, errors } = parseReleaseBody(body); - - expect(errors).toHaveLength(0); - expect(entries).toHaveLength(1); - expect(entries[0].category).toBe("Feature"); - }); }); // ------------------------------------------------------------------------- @@ -459,7 +315,7 @@ Description. // Unit Tests: validateGitHubEntities // ------------------------------------------------------------------------- describe("validateGitHubEntities", () => { - it("14. Invalid GitHub username", async () => { + it("13. Invalid GitHub username", async () => { global.fetch = jest.fn().mockImplementation((url) => { if (url.includes("/pulls/1042")) { return Promise.resolve({ @@ -499,7 +355,7 @@ Description. ); }); - it("15. Non-existent PR", async () => { + it("14. Non-existent PR", async () => { global.fetch = jest.fn().mockImplementation((url) => { if (url.includes("/pulls/9999")) { return Promise.resolve({ @@ -556,29 +412,32 @@ Description. await Release.deleteMany({}); }); - it("successfully syncs valid release into MongoDB", async () => { - const tag = "v1.0.0-test"; + it("successfully syncs valid release storing version and tagName separately", async () => { + const tag = "v0.8.0"; + const name = "0.8.0"; process.env.GITHUB_TOKEN = "fake_token"; process.env.MONGO_URL = mongoUri; process.env.RELEASE_TAG = tag; process.env.GITHUB_REPOSITORY = "testowner/testrepo"; const validReleaseBody = ` -## Features +Welcome to 0.8.0 release. -### #1042 | Revamp USER Dashboard +### 📝 Features + +#### #1042 | Revamp USER Dashboard Full redesign description. -**Contributors:** @personA @personB +> Contributors: @personA @personB -## Others +### 📝 Others -### #1060 | Improve CI Pipeline +#### #1060 | Improve CI Pipeline Migrated CI pipeline. -**Contributors:** @personC +> Contributors: @personC `; global.fetch = jest.fn().mockImplementation((url) => { @@ -588,6 +447,7 @@ Migrated CI pipeline. json: () => Promise.resolve({ id: 12345, + name: name, tag_name: tag, published_at: "2026-09-11T12:00:00Z", html_url: `https://github.com/testowner/testrepo/releases/tag/${tag}`, @@ -610,14 +470,14 @@ Migrated CI pipeline. await syncRelease(); - // Connect back to inspect DB document (since syncRelease closes connection in finally) if (mongoose.connection.readyState === 0) { await mongoose.connect(mongoUri); } - const doc = await Release.findOne({ version: tag }); + const doc = await Release.findOne({ version: name }); expect(doc).not.toBeNull(); - expect(doc.version).toBe(tag); + expect(doc.version).toBe("0.8.0"); + expect(doc.tagName).toBe("v0.8.0"); expect(doc.githubReleaseId).toBe(12345); expect(doc.entries).toHaveLength(2); expect(doc.entries[0]).toMatchObject({ @@ -626,12 +486,6 @@ Migrated CI pipeline. title: "Revamp USER Dashboard", contributors: [{ username: "personA" }, { username: "personB" }], }); - expect(doc.entries[1]).toMatchObject({ - category: "Other", - prNumber: 1060, - title: "Improve CI Pipeline", - contributors: [{ username: "personC" }], - }); }); it("fails validation on invalid release and writes NOTHING to MongoDB", async () => { @@ -642,13 +496,13 @@ Migrated CI pipeline. process.env.GITHUB_REPOSITORY = "testowner/testrepo"; const invalidReleaseBody = ` -## Features +### 📝 Features -### #1042 | Revamp USER Dashboard +#### #1042 | Revamp USER Dashboard Description. -**Contributors:** personA +> Contributors: personA `; global.fetch = jest.fn().mockImplementation((url) => { @@ -658,6 +512,7 @@ Description. json: () => Promise.resolve({ id: 99999, + name: "2.0.0-invalid", tag_name: tag, published_at: "2026-09-11T12:00:00Z", html_url: `https://github.com/testowner/testrepo/releases/tag/${tag}`, @@ -676,7 +531,7 @@ Description. await mongoose.connect(mongoUri); } - const doc = await Release.findOne({ version: tag }); + const doc = await Release.findOne({ version: "2.0.0-invalid" }); expect(doc).toBeNull(); }); }); diff --git a/packages/app/__tests__/services/release.test.js b/packages/app/__tests__/services/release.test.js index 6ecfde0bb..7a9f699a9 100644 --- a/packages/app/__tests__/services/release.test.js +++ b/packages/app/__tests__/services/release.test.js @@ -2,7 +2,6 @@ const ReleaseService = require("../../services/release"); const { ReleaseRepository } = require("../../repositories"); -const { OLD_RELEASES } = require("../../utils/constants"); jest.mock("../../repositories"); @@ -23,16 +22,7 @@ describe("ReleaseService", () => { }); describe("getReleaseByVersion", () => { - it("returns archived release from OLD_RELEASES if version matches historical tag", async () => { - const archivedTag = OLD_RELEASES[0].version; // e.g. "0.8.0 version" - - const result = await releaseService.getReleaseByVersion(archivedTag); - - expect(result).toEqual(OLD_RELEASES[0]); - expect(mockFindByVersion).not.toHaveBeenCalled(); - }); - - it("queries ReleaseRepository for version when not found in historical archive", async () => { + it("queries ReleaseRepository for version", async () => { const dbRelease = { version: "v1.2.0", releaseDate: new Date("2026-09-01"), @@ -47,7 +37,7 @@ describe("ReleaseService", () => { expect(result).toEqual(dbRelease); }); - it("returns null when version is not in archive or database", async () => { + it("returns null when version is not in database", async () => { mockFindByVersion.mockResolvedValue(null); const result = await releaseService.getReleaseByVersion("v9.9.9"); diff --git a/packages/app/controllers/release.js b/packages/app/controllers/release.js index 524749112..0c5552983 100644 --- a/packages/app/controllers/release.js +++ b/packages/app/controllers/release.js @@ -1,6 +1,5 @@ const { STATUS_CODES } = require("node:http"); const { ReleaseService } = require("../services"); -const { OLD_RELEASES } = require("../utils/constants"); /** * GET /api/releases → paginated list of all releases @@ -50,8 +49,6 @@ async function getReleasesController(req, res, next) { totalPages: result.totalPages, limit, }, - // Appended historical archive. Note: Pagination logic does not apply to this array. - archivedReleases: OLD_RELEASES, }); } catch (err) { next(err); diff --git a/packages/app/models/release.js b/packages/app/models/release.js index 1db67837a..4fff7a1c7 100644 --- a/packages/app/models/release.js +++ b/packages/app/models/release.js @@ -29,7 +29,7 @@ const releaseEntrySchema = new mongoose.Schema( prNumber: { type: Number, - required: true, + default: null, }, title: { @@ -46,11 +46,7 @@ const releaseEntrySchema = new mongoose.Schema( contributors: { type: [contributorSchema], - required: true, - validate: { - validator: (value) => Array.isArray(value) && value.length > 0, - message: "At least one contributor is required", - }, + default: null, }, }, { _id: false } @@ -64,6 +60,11 @@ const releaseSchema = new mongoose.Schema( unique: true, trim: true, }, + tagName: { + type: String, + required: true, + trim: true, + }, releaseDate: { type: Date, required: true, diff --git a/packages/app/scripts/releases/historicalReleasesData.js b/packages/app/scripts/releases/historicalReleasesData.js new file mode 100644 index 000000000..a0cb18ddf --- /dev/null +++ b/packages/app/scripts/releases/historicalReleasesData.js @@ -0,0 +1,698 @@ +/** + * Historical release data for the one-time MongoDB backfill. + * + * Source: GitHub release bodies and metadata. + * + * Missing historical PR numbers/contributors are intentionally represented + * as null rather than inferred. + * + * This file is used by seedHistoricalReleases.js only. + */ + +const historicalReleases = [ + { + version: "0.1.0", + tagName: "v1.0.0", + releaseDate: new Date("2024-07-01T11:31:07Z"), + githubReleaseId: 163328281, + githubReleaseUrl: + "https://github.com/TeamShiksha/openlogo/releases/tag/v1.0.0", + + entries: [ + { + category: "Feature", + prNumber: null, + title: "Logo Retrieval API", + description: + "Fetch company logos in PNG format using the /api/business/logo endpoint.", + contributors: null, + }, + { + category: "Feature", + prNumber: null, + title: "API Key Generation", + description: + "Users can generate and manage their API key through the dashboard.", + contributors: null, + }, + { + category: "Feature", + prNumber: null, + title: "Company Logo Availability Search", + description: + "Users can check whether a company's logo is available by searching with the exact domain name.", + contributors: null, + }, + { + category: "Feature", + prNumber: null, + title: "Admin Image Upload", + description: + "Admins can upload their own images through the admin page using drag and drop.", + contributors: null, + }, + { + category: "Feature", + prNumber: null, + title: "Authentication", + description: + "Includes signup, sign in, logout, and forgot-password functionality.", + contributors: null, + }, + { + category: "Feature", + prNumber: null, + title: "Profile Management", + description: + "Users can update profile details, update their password, and delete their account.", + contributors: null, + }, + ], + }, + + { + version: "0.2.0", + tagName: "1.1.0", + releaseDate: new Date("2024-10-05T16:02:52Z"), + githubReleaseId: 178529582, + githubReleaseUrl: + "https://github.com/TeamShiksha/openlogo/releases/tag/1.1.0", + + entries: [ + { + category: "Bug Fix", + prNumber: 339, + title: "Navigation links scroll to top on click", + description: "", + contributors: [{ username: "Asin-Junior-Honore" }], + }, + { + category: "Feature", + prNumber: 344, + title: "Show confirmation modal on click of delete API key button", + description: "", + contributors: [{ username: "anandbaraik" }], + }, + { + category: "Other", + prNumber: 345, + title: "Migrate database from Firestore to MongoDB", + description: "", + contributors: [{ username: "amankumarsingh77" }], + }, + { + category: "Enhancement", + prNumber: 350, + title: "Made usageCount functional", + description: "", + contributors: [{ username: "amankumarsingh77" }], + }, + { + category: "Feature", + prNumber: 347, + title: "One-time API Key View/Copy", + description: "", + contributors: [{ username: "Sharathxct" }], + }, + { + category: "Other", + prNumber: 358, + title: + "Add Automated Script to Reset Subscription Usage Count Every 30 Days", + description: "", + contributors: [{ username: "DeltaDynamo" }], + }, + { + category: "Bug Fix", + prNumber: 360, + title: + "NaN in usageCount on user dashboard and remove duplicate userId in API response", + description: "", + contributors: [{ username: "amankumarsingh77" }], + }, + { + category: "Bug Fix", + prNumber: 363, + title: "Reset and forget password API", + description: "", + contributors: [{ username: "amankumarsingh77" }], + }, + { + category: "Feature", + prNumber: 365, + title: "Admin Image Re-upload with Name & Extension Verification", + description: "", + contributors: [{ username: "Soumava-221B" }], + }, + { + category: "Feature", + prNumber: 357, + title: "Operator UI + APIs", + description: "", + contributors: [{ username: "asharma991" }], + }, + { + category: "Feature", + prNumber: 366, + title: "Logo Search API", + description: "", + contributors: [{ username: "DeltaDynamo" }], + }, + { + category: "Other", + prNumber: 376, + title: "Updates client test cases from Jest to Vitest", + description: "", + contributors: [{ username: "Ayushsanjdev" }], + }, + { + category: "Enhancement", + prNumber: 373, + title: "Footer, Sign-in Card, and About Section UI Improvements", + description: "", + contributors: [{ username: "AryaDharkar" }], + }, + ], + }, + + { + version: "0.3.0", + tagName: "0.3.0", + releaseDate: new Date("2025-09-01T18:07:42Z"), + githubReleaseId: 243938725, + githubReleaseUrl: + "https://github.com/TeamShiksha/openlogo/releases/tag/0.3.0", + + entries: [ + { + category: "Other", + prNumber: 389, + title: "First Contribution", + description: "", + contributors: [{ username: "sujal111" }], + }, + { + category: "Other", + prNumber: 397, + title: "First Contribution", + description: "", + contributors: [{ username: "ayush19sinha" }], + }, + { + category: "Other", + prNumber: 413, + title: "First Contribution", + description: "", + contributors: [{ username: "Katsuya21" }], + }, + { + category: "Other", + prNumber: 415, + title: "First Contribution", + description: "", + contributors: [{ username: "Hariom01010" }], + }, + { + category: "Other", + prNumber: 448, + title: "First Contribution", + description: "", + contributors: [{ username: "001AM" }], + }, + { + category: "Other", + prNumber: 462, + title: "First Contribution", + description: "", + contributors: [{ username: "abhishek-2k23" }], + }, + { + category: "Other", + prNumber: 456, + title: "First Contribution", + description: "", + contributors: [{ username: "Chandanadsc" }], + }, + { + category: "Other", + prNumber: 463, + title: "First Contribution", + description: "", + contributors: [{ username: "akshayaparida" }], + }, + { + category: "Other", + prNumber: 450, + title: "First Contribution", + description: "", + contributors: [{ username: "Ruchita3429" }], + }, + { + category: "Other", + prNumber: 495, + title: "First Contribution", + description: "", + contributors: [{ username: "mridxl" }], + }, + { + category: "Other", + prNumber: 515, + title: "First Contribution", + description: "", + contributors: [{ username: "tanwarAalok" }], + }, + { + category: "Other", + prNumber: 518, + title: "First Contribution", + description: "", + contributors: [{ username: "shafat730" }], + }, + { + category: "Other", + prNumber: 546, + title: "First Contribution", + description: "", + contributors: [{ username: "iamalhera" }], + }, + { + category: "Other", + prNumber: 611, + title: "First Contribution", + description: "", + contributors: [{ username: "Drshnnn" }], + }, + { + category: "Other", + prNumber: 597, + title: "First Contribution", + description: "", + contributors: [{ username: "farhan294sha" }], + }, + { + category: "Other", + prNumber: 613, + title: "First Contribution", + description: "", + contributors: [{ username: "GautamRaj-1200" }], + }, + { + category: "Other", + prNumber: 612, + title: "First Contribution", + description: "", + contributors: [{ username: "theboyofdream" }], + }, + { + category: "Other", + prNumber: 619, + title: "First Contribution", + description: "", + contributors: [{ username: "strawHat121" }], + }, + { + category: "Other", + prNumber: 622, + title: "First Contribution", + description: "", + contributors: [{ username: "anshika282" }], + }, + { + category: "Other", + prNumber: 638, + title: "First Contribution", + description: "", + contributors: [{ username: "dependabot[bot]" }], + }, + { + category: "Other", + prNumber: 658, + title: "First Contribution", + description: "", + contributors: [{ username: "Idontnol" }], + }, + { + category: "Other", + prNumber: 664, + title: "First Contribution", + description: "", + contributors: [{ username: "Allan2000-Git" }], + }, + { + category: "Other", + prNumber: 642, + title: "First Contribution", + description: "", + contributors: [{ username: "keshrinandan99" }], + }, + { + category: "Other", + prNumber: 673, + title: "First Contribution", + description: "", + contributors: [{ username: "SushankSinha" }], + }, + { + category: "Other", + prNumber: 676, + title: "First Contribution", + description: "", + contributors: [{ username: "RajeshPhayde" }], + }, + { + category: "Other", + prNumber: 678, + title: "First Contribution", + description: "", + contributors: [{ username: "ShlokMane" }], + }, + { + category: "Other", + prNumber: 688, + title: "First Contribution", + description: "", + contributors: [{ username: "printgourav" }], + }, + { + category: "Other", + prNumber: 690, + title: "First Contribution", + description: "", + contributors: [{ username: "Ravisukhwal76" }], + }, + { + category: "Other", + prNumber: 667, + title: "First Contribution", + description: "", + contributors: [{ username: "Aniketvish0" }], + }, + { + category: "Other", + prNumber: 782, + title: "First Contribution", + description: "", + contributors: [{ username: "biplab-sutradhar" }], + }, + { + category: "Other", + prNumber: 781, + title: "First Contribution", + description: "", + contributors: [{ username: "YashDevani-source" }], + }, + ], + }, + + { + version: "0.5.0", + tagName: "0.5.0", + releaseDate: new Date("2025-11-30T10:28:01Z"), + githubReleaseId: 266182710, + githubReleaseUrl: + "https://github.com/TeamShiksha/openlogo/releases/tag/0.5.0", + + entries: [ + { + category: "Bug Fix", + prNumber: 864, + title: "Date Format Consistency", + description: "", + contributors: [{ username: "Sumitgitup" }], + }, + { + category: "Feature", + prNumber: 878, + title: "Enabled download button & let user download their data", + description: "", + contributors: [{ username: "Sumitgitup" }], + }, + { + category: "Feature", + prNumber: 879, + title: "Dashboard & signout within a single dropdown", + description: "", + contributors: [{ username: "abhishek-2k23" }, { username: "nazibul7" }], + }, + { + category: "Enhancement", + prNumber: 884, + title: "Cache invalidation support", + description: "", + contributors: [{ username: "MukeshAbhi" }], + }, + { + category: "Other", + prNumber: 851, + title: "S3 upload flow update", + description: "", + contributors: [ + { username: "printgourav" }, + { username: "YashDevani-source" }, + ], + }, + { + category: "Feature", + prNumber: 876, + title: "Resend email verification", + description: "", + contributors: [{ username: "MukeshAbhi" }], + }, + { + category: "Feature", + prNumber: 877, + title: "Resend email verification", + description: "", + contributors: [{ username: "MukeshAbhi" }], + }, + { + category: "Enhancement", + prNumber: 859, + title: "Show total number of images available with admin", + description: "", + contributors: [{ username: "printgourav" }], + }, + ], + }, + + { + version: "0.6.0", + tagName: "0.6.0", + releaseDate: new Date("2026-01-02T16:38:02Z"), + githubReleaseId: 271931742, + githubReleaseUrl: + "https://github.com/TeamShiksha/openlogo/releases/tag/0.6.0", + + entries: [ + { + category: "Feature", + prNumber: null, + title: "API Usage Graph", + description: + "You can now view a simple graph on your dashboard that helps you understand how much you’re using the API, including how many requests you’ve made and how much data you’ve used.", + contributors: [ + { username: "L-Tarun-Aditya" }, + { username: "sachinkmrsin" }, + { username: "YashDevani-source" }, + ], + }, + { + category: "Security", + prNumber: null, + title: "Automatic API Key Expiration", + description: + "API keys now expire automatically to keep accounts more secure. Users can set a custom expiry date, and existing API keys will expire after one year by default.", + contributors: [ + { username: "biplab-sutradhar" }, + { username: "printgourav" }, + ], + }, + { + category: "Feature", + prNumber: null, + title: "Automatic Catalog Creation", + description: + "Catalogs are now created automatically, so you don’t need to set them up manually anymore.", + contributors: [ + { username: "BansalAbhinav" }, + { username: "Saurabhupadhyay8170" }, + ], + }, + { + category: "Feature", + prNumber: null, + title: "Release Page", + description: + "A new Release Page is now available, where you can easily see what’s new in each version and who helped build it.", + contributors: [{ username: "abhishek-2k23" }], + }, + { + category: "Enhancement", + prNumber: null, + title: "Resend Verification Email", + description: + "If you don’t receive your verification email, you can now resend it easily and continue without getting stuck.", + contributors: [{ username: "MukeshAbhi" }], + }, + ], + }, + + { + version: "0.7.0", + tagName: "0.7.0", + releaseDate: new Date("2026-03-01T06:40:49Z"), + githubReleaseId: 291754187, + githubReleaseUrl: + "https://github.com/TeamShiksha/openlogo/releases/tag/0.7.0", + + entries: [ + { + category: "Security", + prNumber: null, + title: + "Authentication migrated from JWT to a secure session-based system", + description: + "Authentication has been migrated from JWT to a secure session-based system, improving overall security and simplifying token management.", + contributors: [ + { username: "Mantu01" }, + { username: "Smayur0" }, + { username: "printgourav" }, + ], + }, + { + category: "Enhancement", + prNumber: null, + title: "Dynamic Light and Dark themes", + description: + "You can now switch between Light and Dark themes to personalize your experience.", + contributors: [{ username: "sachinkmrsin" }], + }, + { + category: "Enhancement", + prNumber: null, + title: "Notifications for API expiry and usage limits", + description: + "Notifications are now available for important events such as API expiry and usage limit being reached.", + contributors: [ + { username: "YashDevani-source" }, + { username: "L-Tarun-Aditya" }, + ], + }, + { + category: "Feature", + prNumber: null, + title: "Custom Logo Creation", + description: + "You can now create and use your own custom logo image directly within the platform.", + contributors: [ + { username: "biplab-sutradhar" }, + { username: "mridul-giri" }, + ], + }, + { + category: "Security", + prNumber: null, + title: "API Key Masking", + description: + "API keys are now securely hidden to prevent accidental exposure and enhance account security.", + contributors: [{ username: "L-Tarun-Aditya" }], + }, + ], + }, + + { + version: "0.8.0", + tagName: "0.8.0", + releaseDate: new Date("2026-06-19T19:46:10Z"), + githubReleaseId: 342105677, + githubReleaseUrl: + "https://github.com/TeamShiksha/openlogo/releases/tag/0.8.0", + + entries: [ + { + category: "Enhancement", + prNumber: null, + title: "Revamp USER dashboard according to the design", + description: "", + contributors: [{ username: "AryaDharkar" }], + }, + { + category: "Enhancement", + prNumber: null, + title: "Enhancing the UI of the admin dashboard", + description: "", + contributors: [{ username: "L-Tarun-Aditya" }], + }, + { + category: "Security", + prNumber: null, + title: "Add 2FA section in user settings", + description: "", + contributors: [{ username: "L-Tarun-Aditya" }], + }, + { + category: "Security", + prNumber: null, + title: "Implementing a dedicated settings page for MFA", + description: "", + contributors: [{ username: "L-Tarun-Aditya" }], + }, + { + category: "Security", + prNumber: null, + title: "Multi factor authentication", + description: "", + contributors: [{ username: "MukeshAbhi" }], + }, + { + category: "Security", + prNumber: null, + title: "Prevent Users From Reusing Old Password During Password Reset", + description: "", + contributors: [{ username: "rishang14" }], + }, + { + category: "Feature", + prNumber: null, + title: + "Fix bugs on createLogo page and allow users to access this page without authentication", + description: "", + contributors: [{ username: "AryaDharkar" }], + }, + { + category: "Security", + prNumber: null, + title: "Feature for user session management", + description: "", + contributors: [ + { username: "kadamsahil2511" }, + { username: "DeepAkdotcom" }, + ], + }, + { + category: "Other", + prNumber: null, + title: + "Feature to enforce branch & PR naming conventions via husky + GitHub Actions", + description: "", + contributors: [{ username: "Smayur0" }], + }, + { + category: "Enhancement", + prNumber: null, + title: "Redesign documentation page", + description: "", + contributors: [{ username: "Dhirenderchoudhary" }], + }, + { + category: "Enhancement", + prNumber: null, + title: "Revamp sign in and sign up form", + description: "", + contributors: [{ username: "0-mstrmind" }, { username: "kunjesh360" }], + }, + ], + }, +]; + +module.exports = historicalReleases; diff --git a/packages/app/scripts/releases/seedHistoricalReleases.js b/packages/app/scripts/releases/seedHistoricalReleases.js new file mode 100644 index 000000000..e33801199 --- /dev/null +++ b/packages/app/scripts/releases/seedHistoricalReleases.js @@ -0,0 +1,81 @@ +/** + * seedHistoricalReleases.js + * + * One-time seed script to insert/upsert historical releases into MongoDB. + * Source data: historicalReleasesData.js + * + * Required environment variable: + * MONGO_URL - MongoDB connection string + * + * Exit codes: + * 0 - Success + * 1 - Fatal error (DB connection error, missing env vars, validation failure) + */ + +"use strict"; + +const mongoose = require("mongoose"); +const Release = require("../../models/release"); +const historicalReleases = require("./historicalReleasesData"); + +function log(msg) { + console.log(`[seed-historical-releases] ${msg}`); +} + +function error(msg) { + console.error(`[seed-historical-releases][ERROR] ${msg}`); +} + +async function seedHistoricalReleases() { + const mongoUrl = process.env.MONGO_URL; + if (!mongoUrl) { + throw new Error("Missing required environment variable: MONGO_URL"); + } + + log("Connecting to MongoDB..."); + await mongoose.connect(mongoUrl); + log("Connected to MongoDB."); + + try { + log(`Seeding ${historicalReleases.length} historical releases...`); + + for (const release of historicalReleases) { + if (!release.version || !release.tagName) { + throw new Error( + `Invalid historical release document: missing version or tagName in ${JSON.stringify(release)}` + ); + } + + const saved = await Release.findOneAndUpdate( + { version: release.version }, + { $set: release }, + { upsert: true, new: true, runValidators: true } + ); + + log( + `Successfully seeded release version="${saved.version}" tagName="${saved.tagName}" (MongoDB _id: ${saved._id}).` + ); + } + + log("Historical release seeding completed successfully."); + } finally { + if (process.env.NODE_ENV !== "test") { + await mongoose.connection.close(); + log("MongoDB connection closed."); + } + } +} + +if (require.main === module) { + seedHistoricalReleases() + .then(() => { + process.exit(0); + }) + .catch((err) => { + error(`Seeding failed: ${err.message}`); + if (err.stack) console.error(err.stack); + process.exit(1); + }); +} + +module.exports = { seedHistoricalReleases }; diff --git a/packages/app/scripts/syncReleases.js b/packages/app/scripts/releases/syncNewReleases.js similarity index 77% rename from packages/app/scripts/syncReleases.js rename to packages/app/scripts/releases/syncNewReleases.js index fcf9721b3..06c85da52 100644 --- a/packages/app/scripts/syncReleases.js +++ b/packages/app/scripts/releases/syncNewReleases.js @@ -21,7 +21,7 @@ "use strict"; const mongoose = require("mongoose"); -const Release = require("../models/release"); +const Release = require("../../models/release"); // --------------------------------------------------------------------------- // Logging helpers — prefixed for easy scanning in GitHub Actions log output @@ -131,11 +131,11 @@ function parseReleaseBody(body) { // Remove HTML comments (like template guidance) const cleanBody = body.replace(/<!--[\s\S]*?-->/g, ""); - // Find all H2 headers: ## Header - const h2Regex = /^##\s+(.+)$/gm; + // Find all H3 headers: ### Header + const h3Regex = /^###\s+(.+)$/gm; let match; const sectionIndices = []; - while ((match = h2Regex.exec(cleanBody)) !== null) { + while ((match = h3Regex.exec(cleanBody)) !== null) { sectionIndices.push({ header: match[1].trim(), index: match.index, @@ -145,10 +145,14 @@ function parseReleaseBody(body) { for (let i = 0; i < sectionIndices.length; i++) { const sectionHeader = sectionIndices[i].header; - const categoryKey = sectionHeader.toLowerCase(); + // Strip leading emojis and symbols (non-alphanumeric/non-space prefix) + const cleanHeaderName = sectionHeader + .replace(/^[\p{Extended_Pictographic}\s\W]+/u, "") + .trim(); + const categoryKey = (cleanHeaderName || sectionHeader).toLowerCase(); const category = CATEGORY_MAP[categoryKey]; - // Ignore unsupported sections such as ## Hero Image + // Ignore unsupported sections if (!category) continue; const start = sectionIndices[i].index + sectionIndices[i].headerLength; @@ -158,54 +162,80 @@ function parseReleaseBody(body) { : cleanBody.length; const sectionContent = cleanBody.substring(start, end).trim(); - if (!sectionContent) continue; - - // Find all H3 headers inside this section - const h3Regex = /^###\s+(.+)$/gm; - let h3Match; - const h3Indices = []; - while ((h3Match = h3Regex.exec(sectionContent)) !== null) { - h3Indices.push({ - headerLine: h3Match[1].trim(), - fullHeader: h3Match[0], - index: h3Match.index, - headerLength: h3Match[0].length, + // Check for empty categories or leftover "Entry" placeholder + if (!sectionContent) { + errors.push({ + section: category, + pr: null, + title: null, + message: `Empty section for category "${sectionHeader}". Unused categories must be removed.`, }); + continue; } - if (h3Indices.length === 0) { - if (sectionContent.length > 0) { - errors.push({ - section: category, - pr: null, - title: null, - message: `Malformed entry in section "${sectionHeader}". Expected heading starting with "### #<PR_NUMBER> | <TITLE>".`, - }); - } + if (/^Entry$/i.test(sectionContent)) { + errors.push({ + section: category, + pr: null, + title: null, + message: `Leftover placeholder "Entry" found in section "${sectionHeader}". Unused categories must be removed.`, + }); continue; } - const textBeforeH3 = sectionContent.substring(0, h3Indices[0].index).trim(); - if (textBeforeH3.length > 0) { + // Find all H4 headers inside this section + const h4Regex = /^####\s+(.+)$/gm; + let h4Match; + const h4Indices = []; + while ((h4Match = h4Regex.exec(sectionContent)) !== null) { + h4Indices.push({ + headerLine: h4Match[1].trim(), + fullHeader: h4Match[0], + index: h4Match.index, + headerLength: h4Match[0].length, + }); + } + + if (h4Indices.length === 0) { errors.push({ section: category, pr: null, title: null, - message: `Malformed content before entry in section "${sectionHeader}": "${textBeforeH3.substring(0, 40)}..."`, + message: `Malformed or empty entry in section "${sectionHeader}". Expected heading starting with "#### #<PR_NUMBER> | <TITLE>".`, }); + continue; } - for (let j = 0; j < h3Indices.length; j++) { - const h3Header = h3Indices[j].headerLine; - const blockStart = h3Indices[j].index + h3Indices[j].headerLength; + const textBeforeH4 = sectionContent.substring(0, h4Indices[0].index).trim(); + if (textBeforeH4.length > 0) { + if (/^Entry$/i.test(textBeforeH4)) { + errors.push({ + section: category, + pr: null, + title: null, + message: `Leftover placeholder "Entry" found before entry in section "${sectionHeader}".`, + }); + } else { + errors.push({ + section: category, + pr: null, + title: null, + message: `Malformed content before entry in section "${sectionHeader}": "${textBeforeH4.substring(0, 40)}..."`, + }); + } + } + + for (let j = 0; j < h4Indices.length; j++) { + const h4Header = h4Indices[j].headerLine; + const blockStart = h4Indices[j].index + h4Indices[j].headerLength; const blockEnd = - j + 1 < h3Indices.length - ? h3Indices[j + 1].index + j + 1 < h4Indices.length + ? h4Indices[j + 1].index : sectionContent.length; const blockBody = sectionContent.substring(blockStart, blockEnd).trim(); // Validate header format: #<PR_NUMBER> | <TITLE> - const headerMatch = h3Header.match(/^#([^\s|]*)\s*\|\s*(.*)$/); + const headerMatch = h4Header.match(/^#([^\s|]*)\s*\|\s*(.*)$/); let prNumber = null; let title = null; @@ -213,8 +243,8 @@ function parseReleaseBody(body) { errors.push({ section: category, pr: null, - title: h3Header, - message: `Invalid entry heading "### ${h3Header}". Expected format "### #<PR_NUMBER> | <TITLE>".`, + title: h4Header, + message: `Invalid entry heading "#### ${h4Header}". Expected format "#### #<PR_NUMBER> | <TITLE>".`, }); } else { const prRaw = headerMatch[1].trim(); @@ -230,7 +260,7 @@ function parseReleaseBody(body) { section: category, pr: prRaw || null, title: title || null, - message: `Invalid PR number "${prRaw}" in entry heading "### ${h3Header}". PR number must be a positive integer.`, + message: `Invalid PR number "${prRaw}" in entry heading "#### ${h4Header}". PR number must be a positive integer.`, }); } else { prNumber = parseInt(prRaw, 10); @@ -241,13 +271,15 @@ function parseReleaseBody(body) { section: category, pr: prNumber, title: null, - message: `Missing or empty title in entry heading "### ${h3Header}".`, + message: `Missing or empty title in entry heading "#### ${h4Header}".`, }); } } - // Extract Contributors - const contribMatch = blockBody.match(/^\*\*Contributors:\*\*\s*(.*)$/im); + // Extract Contributors (format: > Contributors: @user1 @user2 or **Contributors:** @user1 @user2) + const contribMatch = blockBody.match( + /^(?:>\s*)?(?:\*\*)?Contributors:(?:\*\*)?\s*(.*)$/im + ); let contributors = []; if (!contribMatch) { @@ -420,9 +452,18 @@ async function syncRelease() { ); log( - `Fetched release: id=${ghRelease.id}, tag=${ghRelease.tag_name}, published=${ghRelease.published_at}` + `Fetched release: id=${ghRelease.id}, tag=${ghRelease.tag_name}, name=${ghRelease.name}, published=${ghRelease.published_at}` ); + const tagName = (ghRelease.tag_name || RELEASE_TAG || "").trim(); + const version = (ghRelease.name || RELEASE_TAG || "").trim(); + + if (!version || !tagName) { + throw new Error( + `Invalid release payload: version="${version}", tagName="${tagName}". Both version and tagName are required.` + ); + } + const body = ghRelease.body || ""; // 3. Parse release entries and structure @@ -449,26 +490,32 @@ async function syncRelease() { // 5. Build release payload const releasePayload = { + version, + tagName, releaseDate: new Date(ghRelease.published_at), githubReleaseId: ghRelease.id, githubReleaseUrl: ghRelease.html_url, entries: parsedEntries, }; - // 6. Atomic upsert into MongoDB - log(`Upserting release version="${RELEASE_TAG}" into MongoDB...`); + // 6. Atomic upsert into MongoDB using { version } + log( + `Upserting release version="${version}" (tagName="${tagName}") into MongoDB...` + ); const saved = await Release.findOneAndUpdate( - { version: RELEASE_TAG }, - { $set: { version: RELEASE_TAG, ...releasePayload } }, + { version }, + { $set: releasePayload }, { upsert: true, new: true, runValidators: true } ); log( - `Successfully synced release "${RELEASE_TAG}" (MongoDB _id: ${saved._id}).` + `Successfully synced release "${version}" (tagName="${tagName}") (MongoDB _id: ${saved._id}).` ); } finally { - await mongoose.connection.close(); - log("MongoDB connection closed."); + if (process.env.NODE_ENV !== "test") { + await mongoose.connection.close(); + log("MongoDB connection closed."); + } } } diff --git a/packages/app/services/release.js b/packages/app/services/release.js index 5a597c20b..f3195f9e9 100644 --- a/packages/app/services/release.js +++ b/packages/app/services/release.js @@ -1,11 +1,11 @@ const { ReleaseRepository } = require("../repositories"); -const { OLD_RELEASES } = require("../utils/constants"); /** * ReleaseService: Read-only service layer for the releases API. * - * Write operations (sync from GitHub) are handled by scripts/syncReleases.js - * which calls ReleaseRepository.upsertByVersion directly. + * Write operations (sync from GitHub or seeding historical data) are handled + * by scripts/releases/syncNewReleases.js and scripts/releases/seedHistoricalReleases.js + * which call ReleaseRepository directly. */ class ReleaseService { constructor() { @@ -24,18 +24,11 @@ class ReleaseService { /** * Returns a single release document matched by version tag, or null if not found. - * If not found in the database, checks the archived OLD_RELEASES constants. - * @param {string} version - e.g. "v1.2.0" + * Queries MongoDB directly. + * @param {string} version - e.g. "0.8.0" or "v1.2.0" * @returns {Promise<Object|null>} */ async getReleaseByVersion(version) { - // 1. Check if the version is in the historical archive - const archivedRelease = OLD_RELEASES.find((r) => r.version === version); - if (archivedRelease) { - return archivedRelease; - } - - // 2. Query MongoDB for newer releases return await this.releaseRepository.findByVersion(version); } } diff --git a/packages/app/utils/constants.js b/packages/app/utils/constants.js index de260db1b..d06eecfd4 100644 --- a/packages/app/utils/constants.js +++ b/packages/app/utils/constants.js @@ -174,355 +174,7 @@ const TEMPORARY_SESSION_TYPES = { MFA: "MFA", }; -/** - * OLD_RELEASES: Historical archived release data (pre-MongoDB era). - * - * @description - * This array contains release data for versions published BEFORE the automated - * GitHub → MongoDB release sync pipeline was implemented. - * - * ─── WHY THIS EXISTS ───────────────────────────────────────────────────────── - * The MongoDB `releases` collection is strictly for new releases synced from GitHub - * via `scripts/syncReleases.js`. Historical releases lack required fields like - * `githubReleaseId`, `githubReleaseUrl`, and `prNumber` — so they cannot be stored - * in the database without violating the schema. - * - * ─── HOW IT IS SERVED ──────────────────────────────────────────────────────── - * • GET /api/releases → included as `archivedReleases` in the response. - * NOTE: Pagination does NOT apply to this array. - * • GET /api/releases/:version → `ReleaseService.getReleaseByVersion()` checks - * this array FIRST before querying MongoDB. - * - * ─── ADDING NEW RELEASES ────────────────────────────────────────────────────── - * DO NOT add new releases here. All new releases should be published on GitHub - * and synced to MongoDB via the sync script / GitHub Action. - * - * @readonly - */ -const OLD_RELEASES = [ - { - version: "0.8.0 version", - releaseDate: "May 2026", - entries: [ - { - title: "Revamp USER dashboard according to the design.", - category: "Enhancement", - contributor: { - username: "AryaDharkar", - }, - }, - { - title: "Enhancing the UI of the admin dashboard.", - category: "Enhancement", - contributor: { - username: "L-Tarun-Aditya", - }, - }, - { - title: "Add 2FA section in user settings.", - category: "Enhancement", - contributor: { - username: "L-Tarun-Aditya", - }, - }, - { - title: "Implementing a dedicated settings page for MFA.", - category: "Enhancement", - contributor: { - username: "L-Tarun-Aditya", - }, - }, - { - title: "Multi factor authentication.", - category: "Enhancement", - contributor: { - username: "MukeshAbhi", - }, - }, - { - title: "Prevent Users From Reusing Old Password During Password Reset.", - category: "Enhancement", - contributor: { - username: "rishang14", - }, - }, - { - title: - "Fix bugs on createLogo page and allow users to access this page without authentication.", - category: "Enhancement", - contributor: { - username: "AryaDharkar", - }, - }, - { - title: "Feature for user session management.", - category: "Enhancement", - contributor: { - username: "kadamsahil2511", - }, - }, - { - title: - "Feature to enforce branch & PR naming conventions via husky + GitHub Actions.", - category: "Enhancement", - contributor: { - username: "Smayur0", - }, - }, - { - title: "Redesign documentation page.", - category: "Enhancement", - contributor: { - username: "Dhirenderchoudhary", - }, - }, - { - title: "Revamp sign in and sign up form", - category: "Enhancement", - contributor: { - username: "0-mstrmind", - }, - }, - ], - }, - { - version: "0.7.0 version", - releaseDate: "Mar 2026", - entries: [ - { - title: - "Authentication has been migrated from JWT to a secure session-based system, improving overall security and simplifying token management.", - category: "Enhancement", - contributor: { - username: "Mantu01", - }, - }, - { - title: - "You can now switch between Light and Dark themes to personalize your experience.", - category: "Enhancement", - contributor: { - username: "sachinkmrsin", - }, - }, - { - title: - "Notifications are now available for important events such as API expiry and usage limit being reached, so you never miss critical updates.", - category: "Enhancement", - contributor: { - username: "YashDevani-source", - }, - }, - { - title: - "You can now create and use your own custom logo image directly within the platform.", - category: "Enhancement", - contributor: { - username: "biplab-sutradhar", - }, - }, - { - title: - "API keys are now securely hidden to prevent accidental exposure and enhance account security.", - category: "Enhancement", - contributor: { - username: "L-Tarun-Aditya", - }, - }, - ], - }, - { - version: "0.6.0 version", - releaseDate: "Dec 2025", - entries: [ - { - title: - "You can now view a simple graph on your dashboard that helps you understand how much you’re using the API, including how many requests you’ve made and how much data you’ve used.", - category: "Enhancement", - contributor: { - username: "L-Tarun-Aditya", - }, - }, - { - title: - "API keys now expire automatically to keep accounts more secure. Users can set a custom expiry date, and existing API keys will expire after one year by default.", - category: "Enhancement", - contributor: { - username: "biplab-sutradhar", - }, - }, - { - title: - "Catalogs are now created automatically, so you don’t need to set them up manually anymore.", - category: "Enhancement", - contributor: { - username: "BansalAbhinav", - }, - }, - { - title: - "A new Release Page is now available, where you can easily see what’s new in each version and who helped build it.", - category: "Enhancement", - contributor: { - username: "abhishek-2k23", - }, - }, - { - title: - "If you don’t receive your verification email, you can now resend it easily and continue without getting stuck.", - category: "Enhancement", - contributor: { - username: "MukeshAbhi", - }, - }, - ], - }, - { - version: "Previous version", - releaseDate: "Oct 2024", - entries: [ - { - title: - "Dates are now displayed in a consistent and clear format across the entire platform.", - category: "Enhancement", - contributor: { - username: "Sumitgitup", - }, - }, - { - title: - "You can now easily download a copy of your data from the platform whenever you need it.", - category: "Enhancement", - contributor: { - username: "Sumitgitup", - }, - }, - { - title: - "Navigation has been simplified by grouping Dashboard and Sign Out options under a single profile menu.", - category: "Enhancement", - contributor: { - username: "abhishek-2k23", - }, - }, - { - title: - "Updates made by admins now show up instantly, so users always see the latest content without delays.", - category: "Enhancement", - contributor: { - username: "MukeshAbhi", - }, - }, - { - title: - "Image uploads are now faster and more stable, especially when uploading large files.", - category: "Enhancement", - contributor: { - username: "printgourav", - }, - }, - { - title: - "If you miss the verification email, you can now resend it directly without any hassle.", - category: "Enhancement", - contributor: { - username: "MukeshAbhi", - }, - }, - { - title: - "Admins can now quickly see how many images are stored in the system from the dashboard.", - category: "Enhancement", - contributor: { - username: "printgourav", - }, - }, - { - title: - "The platform moved from Firebase to MongoDB to better support growth and handle data more efficiently.", - category: "Enhancement", - contributor: { - username: "amankumarsingh77", - }, - }, - { - title: - "Testing was improved by switching to a faster and more reliable testing setup.", - category: "Enhancement", - contributor: { - username: "Ayushsanjdev", - }, - }, - { - title: - "A new Operator dashboard was added to make it easier to manage and respond to customer queries.", - category: "Enhancement", - contributor: { - username: "asharma991", - }, - }, - { - title: - "Admins gained the ability to re-upload images with checks to ensure correct file names and formats.", - category: "Enhancement", - contributor: { - username: "Soumava-221B", - }, - }, - { - title: - "Subscription usage limits are now reset automatically every month, removing the need for manual updates.", - category: "Enhancement", - contributor: { - username: "DeltaDynamo", - }, - }, - { - title: - "Several visual and usability improvements were made across the footer, About page, and sign-in experience.", - category: "Enhancement", - contributor: { - username: "AryaDharkar", - }, - }, - { - title: - "A new logo search feature was added, making it easier to find and retrieve logos securely.", - category: "Enhancement", - contributor: { - username: "DeltaDynamo", - }, - }, - { - title: - "Navigation behavior was improved so pages smoothly return to the top when links are clicked.", - category: "Enhancement", - contributor: { - username: "Asin-Junior-Honore", - }, - }, - { - title: - "An extra confirmation step was added before deleting API keys to help prevent accidental deletions.", - category: "Enhancement", - contributor: { - username: "anandbaraik", - }, - }, - { - title: - "API keys can now be viewed or copied only once, improving overall account security.", - category: "Enhancement", - contributor: { - username: "Sharathxct", - }, - }, - ], - }, -]; - module.exports = { - OLD_RELEASES, EmailValidationRegex, ExtractCompanyNameFromUrlRegex, UserTokenTypes, From d40cbf92caf7fd2e361fb14addad77d7cb00fb3d Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:49:00 +0530 Subject: [PATCH 05/10] feat: improve historical release data seeding and add releaase versions API endpoint --- .../workflows/sync-historical-releases.yaml | 23 ++ .../app/__tests__/controllers/release.test.js | 29 ++ .../__tests__/repositories/release.test.js | 30 ++ .../app/__tests__/services/release.test.js | 19 ++ packages/app/controllers/release.js | 18 ++ packages/app/package.json | 1 + packages/app/repositories/release.js | 12 + packages/app/routes/releases.js | 11 +- .../releases/historicalReleasesData.js | 294 +++++++++++------- .../releases/seedHistoricalReleases.js | 1 + packages/app/services/release.js | 8 + 11 files changed, 324 insertions(+), 122 deletions(-) create mode 100644 .github/workflows/sync-historical-releases.yaml diff --git a/.github/workflows/sync-historical-releases.yaml b/.github/workflows/sync-historical-releases.yaml new file mode 100644 index 000000000..a21aff9d4 --- /dev/null +++ b/.github/workflows/sync-historical-releases.yaml @@ -0,0 +1,23 @@ +name: Seed Historical Releases + +on: + workflow_dispatch: + +jobs: + seed: + name: Seed historical releases to MongoDB + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install mongoose --legacy-peer-deps + + - name: Run release seed script + working-directory: packages/app + env: + MONGO_URL: ${{ secrets.MONGO_URL }} + run: npm run seed:releases \ No newline at end of file diff --git a/packages/app/__tests__/controllers/release.test.js b/packages/app/__tests__/controllers/release.test.js index 15214a8a1..e605a5d3b 100644 --- a/packages/app/__tests__/controllers/release.test.js +++ b/packages/app/__tests__/controllers/release.test.js @@ -9,14 +9,17 @@ jest.mock("../../services/release"); describe("Releases Controller & Routes (/api/releases)", () => { let mockGetAllReleases; let mockGetReleaseByVersion; + let mockGetReleaseVersions; beforeEach(() => { jest.clearAllMocks(); mockGetAllReleases = jest.fn(); mockGetReleaseByVersion = jest.fn(); + mockGetReleaseVersions = jest.fn(); ReleaseService.prototype.getAllReleases = mockGetAllReleases; ReleaseService.prototype.getReleaseByVersion = mockGetReleaseByVersion; + ReleaseService.prototype.getReleaseVersions = mockGetReleaseVersions; }); describe("GET /api/releases", () => { @@ -123,4 +126,30 @@ describe("Releases Controller & Routes (/api/releases)", () => { }); }); }); + + describe("GET /api/releases/versions", () => { + it("returns 200 with lightweight list of versions", async () => { + const versionsData = [ + { + version: "0.8.0", + releaseDate: "2026-05-15T00:00:00.000Z", + }, + { + version: "0.7.0", + releaseDate: "2026-03-20T00:00:00.000Z", + }, + ]; + + mockGetReleaseVersions.mockResolvedValue(versionsData); + + const res = await request(app).get("/api/releases/versions"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + statusCode: 200, + data: versionsData, + }); + expect(mockGetReleaseVersions).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/app/__tests__/repositories/release.test.js b/packages/app/__tests__/repositories/release.test.js index b3ce69f1e..9a725c91b 100644 --- a/packages/app/__tests__/repositories/release.test.js +++ b/packages/app/__tests__/repositories/release.test.js @@ -164,4 +164,34 @@ describe("ReleaseRepository", () => { expect(page2.data[0].version).toBe("v1.0.0-p"); }); }); + + describe("getAllVersions", () => { + it("returns version and releaseDate only, sorted in reverse chronological order", async () => { + await Release.create([ + { + version: "0.7.0", + tagName: "0.7.0", + releaseDate: new Date("2026-03-20T00:00:00.000Z"), + githubReleaseId: 501, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/0.7.0", + entries: [], + }, + { + version: "0.8.0", + tagName: "0.8.0", + releaseDate: new Date("2026-05-15T00:00:00.000Z"), + githubReleaseId: 502, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/0.8.0", + entries: [], + }, + ]); + + const result = await repository.getAllVersions(); + expect(result).toHaveLength(2); + expect(result[0].version).toBe("0.8.0"); + expect(result[1].version).toBe("0.7.0"); + expect(result[0]._id).toBeUndefined(); + expect(result[0].githubReleaseId).toBeUndefined(); + }); + }); }); diff --git a/packages/app/__tests__/services/release.test.js b/packages/app/__tests__/services/release.test.js index 7a9f699a9..e79e9ade7 100644 --- a/packages/app/__tests__/services/release.test.js +++ b/packages/app/__tests__/services/release.test.js @@ -10,13 +10,17 @@ describe("ReleaseService", () => { let mockFindByVersion; let mockGetPaginated; + let mockGetAllVersions; + beforeEach(() => { jest.clearAllMocks(); mockFindByVersion = jest.fn(); mockGetPaginated = jest.fn(); + mockGetAllVersions = jest.fn(); ReleaseRepository.prototype.findByVersion = mockFindByVersion; ReleaseRepository.prototype.getPaginated = mockGetPaginated; + ReleaseRepository.prototype.getAllVersions = mockGetAllVersions; releaseService = new ReleaseService(); }); @@ -76,4 +80,19 @@ describe("ReleaseService", () => { expect(mockGetPaginated).toHaveBeenCalledWith(1, 10); }); }); + + describe("getReleaseVersions", () => { + it("delegates to ReleaseRepository.getAllVersions", async () => { + const mockVersions = [ + { version: "0.8.0", releaseDate: "2026-05-15T00:00:00.000Z" }, + { version: "0.7.0", releaseDate: "2026-03-20T00:00:00.000Z" }, + ]; + mockGetAllVersions.mockResolvedValue(mockVersions); + + const result = await releaseService.getReleaseVersions(); + + expect(mockGetAllVersions).toHaveBeenCalled(); + expect(result).toEqual(mockVersions); + }); + }); }); diff --git a/packages/app/controllers/release.js b/packages/app/controllers/release.js index 0c5552983..0549684a5 100644 --- a/packages/app/controllers/release.js +++ b/packages/app/controllers/release.js @@ -55,6 +55,24 @@ async function getReleasesController(req, res, next) { } } +/** + * GET /api/releases/versions → lightweight list of all available releases + */ +async function getReleaseVersionsController(req, res, next) { + try { + const releaseService = new ReleaseService(); + const versions = await releaseService.getReleaseVersions(); + + return res.status(200).json({ + statusCode: 200, + data: versions, + }); + } catch (err) { + next(err); + } +} + module.exports = { getReleasesController, + getReleaseVersionsController, }; diff --git a/packages/app/package.json b/packages/app/package.json index 1c1159b74..6ae7c177a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,6 +10,7 @@ "lint:fix": "eslint --fix .", "test": "jest --watchAll=false", "coverage": "jest --coverage --watchAll=false", + "seed:releases": "node scripts/releases/seedHistoricalReleases.js", "format": "prettier --write .", "check": "prettier --check ." }, diff --git a/packages/app/repositories/release.js b/packages/app/repositories/release.js index aa5bf23bf..08bd10a21 100644 --- a/packages/app/repositories/release.js +++ b/packages/app/repositories/release.js @@ -56,6 +56,18 @@ class ReleaseRepository extends BaseRepository { totalPages: Math.ceil(total / limit), }; } + + /** + * Return a lightweight list of all releases containing only version and releaseDate in reverse-chronological order. + * @returns {Promise<Array<{ version: string, releaseDate: Date }>>} + */ + async getAllVersions() { + return await this.model + .find({}) + .sort({ releaseDate: -1 }) + .select("version releaseDate -_id") + .lean(); + } } module.exports = ReleaseRepository; diff --git a/packages/app/routes/releases.js b/packages/app/routes/releases.js index 269fb35f1..339b5cdaf 100644 --- a/packages/app/routes/releases.js +++ b/packages/app/routes/releases.js @@ -1,7 +1,15 @@ const router = require("express").Router(); -const { getReleasesController } = require("../controllers/release"); +const { + getReleasesController, + getReleaseVersionsController, +} = require("../controllers/release"); /** + * GET /api/releases/versions + * Returns a lightweight list of all available releases. + * Return only version and releaseDate, sorted in reverse chronological order. + * No authentication required. + * * GET /api/releases * Returns all releases in reverse-chronological order (paginated). * Query params: page (default: 1), limit (default: 10) @@ -12,6 +20,7 @@ const { getReleasesController } = require("../controllers/release"); * * Public — no authentication required. */ +router.get("/versions", getReleaseVersionsController); router.get("/:version?", getReleasesController); module.exports = router; diff --git a/packages/app/scripts/releases/historicalReleasesData.js b/packages/app/scripts/releases/historicalReleasesData.js index a0cb18ddf..0322aaa83 100644 --- a/packages/app/scripts/releases/historicalReleasesData.js +++ b/packages/app/scripts/releases/historicalReleasesData.js @@ -83,43 +83,49 @@ const historicalReleases = [ category: "Bug Fix", prNumber: 339, title: "Navigation links scroll to top on click", - description: "", + description: + "Fixes page navigation behavior by automatically scrolling to the top whenever the route changes.", contributors: [{ username: "Asin-Junior-Honore" }], }, { category: "Feature", prNumber: 344, title: "Show confirmation modal on click of delete API key button", - description: "", + description: + "Adds a confirmation modal before deleting an API key, allowing users to confirm or cancel the deletion.", contributors: [{ username: "anandbaraik" }], }, { - category: "Other", + category: "Enhancement", prNumber: 345, title: "Migrate database from Firestore to MongoDB", - description: "", + description: + "Migrates the application's database and services from Firestore to MongoDB, removes Firebase dependencies, updates the data models and services, and adds MongoDB setup documentation.", contributors: [{ username: "amankumarsingh77" }], }, { category: "Enhancement", prNumber: 350, title: "Made usageCount functional", - description: "", + description: + "Makes subscription usage tracking functional by maintaining usageCount, enforcing API usage limits, and displaying usage data from the subscription on the user dashboard.", contributors: [{ username: "amankumarsingh77" }], }, { category: "Feature", prNumber: 347, title: "One-time API Key View/Copy", - description: "", + description: + "Changes API key handling so the generated key is displayed and available for copying only once, while removing stored API key values from subsequent dashboard responses.", contributors: [{ username: "Sharathxct" }], }, { - category: "Other", + category: "Enhancement", prNumber: 358, title: "Add Automated Script to Reset Subscription Usage Count Every 30 Days", - description: "", + description: + "Adds a scheduled GitHub Actions workflow and MongoDB script that automatically resets subscription usage counts every 30 days based on the subscription creation date.", contributors: [{ username: "DeltaDynamo" }], }, { @@ -127,49 +133,56 @@ const historicalReleases = [ prNumber: 360, title: "NaN in usageCount on user dashboard and remove duplicate userId in API response", - description: "", + description: + "Fixes usageCount displaying as NaN on the user dashboard and removes duplicated user information from key and subscription API responses.", contributors: [{ username: "amankumarsingh77" }], }, { category: "Bug Fix", prNumber: 363, title: "Reset and forget password API", - description: "", + description: + "Fixes the reset and forgot password APIs by correctly deriving the user ID from the MongoDB user record and storing it in the reset-password session token.", contributors: [{ username: "amankumarsingh77" }], }, { category: "Feature", prNumber: 365, title: "Admin Image Re-upload with Name & Extension Verification", - description: "", + description: + "Adds image re-upload functionality to the admin dashboard, allowing existing images to be replaced while enforcing matching image names and file extensions.", contributors: [{ username: "Soumava-221B" }], }, { category: "Feature", prNumber: 357, title: "Operator UI + APIs", - description: "", + description: + "Adds an operator interface with APIs for viewing customer queries, paginating results, and sending responses back to customers.", contributors: [{ username: "asharma991" }], }, { category: "Feature", prNumber: 366, title: "Logo Search API", - description: "", + description: + "Adds public and API-key-protected logo search endpoints that return logo URLs based on domain-name prefixes, with validation and test coverage.", contributors: [{ username: "DeltaDynamo" }], }, { category: "Other", prNumber: 376, title: "Updates client test cases from Jest to Vitest", - description: "", + description: + "Migrates the client-side test suite from Jest to Vitest and updates test mocks, assertions, dependencies, and configuration for the new test runner.", contributors: [{ username: "Ayushsanjdev" }], }, { category: "Enhancement", prNumber: 373, title: "Footer, Sign-in Card, and About Section UI Improvements", - description: "", + description: + "Improves the footer, sign-in form layout, and About page by reorganizing navigation elements, repositioning the forgot-password link, and restructuring content into cards.", contributors: [{ username: "AryaDharkar" }], }, ], @@ -185,220 +198,251 @@ const historicalReleases = [ entries: [ { - category: "Other", + category: "Enhancement", prNumber: 389, - title: "First Contribution", - description: "", + title: "Add Repository Layer for Models", + description: + "Introduces a reusable BaseRepository with model-specific repositories for common database operations. Also adds user-specific key lookup support.", contributors: [{ username: "sujal111" }], }, { - category: "Other", + category: "Enhancement", prNumber: 397, - title: "First Contribution", - description: "", + title: "Add Documentation and Other Templates", + description: + "Adds dedicated GitHub issue templates for documentation and other issues, making reports easier to structure and classify.", contributors: [{ username: "ayush19sinha" }], }, { - category: "Other", + category: "Feature", prNumber: 413, - title: "First Contribution", - description: "", + title: "Add API Demo Section", + description: + "Adds a redesigned API demo section to the landing page with search-driven interaction and responsive styling.", contributors: [{ username: "Katsuya21" }], }, { - category: "Other", + category: "Feature", prNumber: 415, - title: "First Contribution", - description: "", + title: "Add Responsive Pricing Section", + description: + "Introduces a responsive pricing section with plan cards, key benefits, pricing details, and mobile-friendly layouts.", contributors: [{ username: "Hariom01010" }], }, { - category: "Other", + category: "Feature", prNumber: 448, - title: "First Contribution", - description: "", + title: "Add Site Footer Component", + description: + "Adds a responsive footer with navigation links, OpenLogo branding, copyright information, and TeamShiksha attribution.", contributors: [{ username: "001AM" }], }, { - category: "Other", + category: "Enhancement", prNumber: 462, - title: "First Contribution", - description: "", + title: "Refine Features Section Layout", + description: + "Updates the Features section spacing, typography, icon styling, and responsive behavior to better match the landing-page design.", contributors: [{ username: "abhishek-2k23" }], }, { - category: "Other", + category: "Enhancement", prNumber: 456, - title: "First Contribution", - description: "", + title: "Update Pricing Section UI", + description: + "Updates pricing cards with rupee pricing, new plan labels, improved styling, a pricing route, and homepage placement.", contributors: [{ username: "Chandanadsc" }], }, { - category: "Other", + category: "Feature", prNumber: 463, - title: "First Contribution", - description: "", + title: "Add Contact Form Modal", + description: + "Introduces a Get In Touch modal with name, email, and message fields, validation, success feedback, and responsive styling.", contributors: [{ username: "akshayaparida" }], }, { - category: "Other", + category: "Feature", prNumber: 450, - title: "First Contribution", - description: "", + title: "Add Signup Authentication Form", + description: + "Adds a responsive signup modal with input validation and backend API integration for account creation.", contributors: [{ username: "Ruchita3429" }], }, { - category: "Other", + category: "Bug Fix", prNumber: 495, - title: "First Contribution", - description: "", + title: "Fix Get In Touch Overlay", + description: + "Fixes the Get In Touch dialog overlay by making the dialog position fixed so the modal displays correctly.", contributors: [{ username: "mridxl" }], }, { - category: "Other", + category: "Enhancement", prNumber: 515, - title: "First Contribution", - description: "", + title: "Add Reusable Dashboard Card Wrapper", + description: + "Introduces a reusable CardWrapper component and shared styling to standardize dashboard card layouts across multiple sections.", contributors: [{ username: "tanwarAalok" }], }, { category: "Other", prNumber: 518, - title: "First Contribution", - description: "", + title: "Add Hero and Footer Tests", + description: + "Adds Vitest and React Testing Library coverage for HeroSection and Footer rendering, interactions, links, and modal behavior.", contributors: [{ username: "shafat730" }], }, { category: "Other", prNumber: 546, - title: "First Contribution", - description: "", + title: "Add Authentication Component Tests", + description: + "Adds Vitest and React Testing Library tests for Auth, Signin, and Signup rendering, modal interactions, validation, and submission behavior.", contributors: [{ username: "iamalhera" }], }, { - category: "Other", + category: "Enhancement", prNumber: 611, - title: "First Contribution", - description: "", + title: "Smooth Auth Form Toggle", + description: + "Improves the Sign In and Sign Up switch with a smoother fade transition and updates tests for the delayed toggle behavior.", contributors: [{ username: "Drshnnn" }], }, { - category: "Other", + category: "Security", prNumber: 597, - title: "First Contribution", - description: "", + title: "Add API Rate Limiting", + description: + "Adds request-rate limiting for public API routes, with stricter controls for logo endpoints to reduce abuse and excessive traffic.", contributors: [{ username: "farhan294sha" }], }, { - category: "Other", + category: "Bug Fix", prNumber: 613, - title: "First Contribution", - description: "", + title: "Fix Admin Catalog UI Consistency", + description: + "Aligns the admin Catalog and CatalogItem UI styling and adds coverage for search, pagination, image actions, and modal behavior.", contributors: [{ username: "GautamRaj-1200" }], }, { - category: "Other", + category: "Feature", prNumber: 612, - title: "First Contribution", - description: "", + title: "Add Analytics Cards to Admin", + description: + "Adds responsive analytics cards to the admin dashboard, integrates them into the page, and covers the feature with automated tests.", contributors: [{ username: "theboyofdream" }], }, { category: "Other", prNumber: 619, - title: "First Contribution", - description: "", + title: "Add Logo API Endpoint Tests", + description: + "Adds API tests for logo retrieval and upload, covering successful responses, missing users, upload failures, and server errors.", contributors: [{ username: "strawHat121" }], }, { category: "Other", prNumber: 622, - title: "First Contribution", - description: "", + title: "Add Role and Logo Tests", + description: + "Adds endpoint tests for updating user roles and logos, including validation, error cases, and successful updates.", contributors: [{ username: "anshika282" }], }, { - category: "Other", + category: "Enhancement", prNumber: 638, - title: "First Contribution", - description: "", + title: "Upgrade React Router Dependency", + description: + "Updates the UI package from React Router 7.1.1 to 7.5.2 to incorporate upstream patches and improvements.", contributors: [{ username: "dependabot[bot]" }], }, { - category: "Other", + category: "Feature", prNumber: 658, - title: "First Contribution", - description: "", + title: "Add 404 Not Found Page", + description: + "Adds a dedicated 404 page for unmatched routes and a catch-all router entry with a link back to Home.", contributors: [{ username: "Idontnol" }], }, { - category: "Other", + category: "Feature", prNumber: 664, - title: "First Contribution", - description: "", + title: "Integrate User Info Update API", + description: + "Connects the User Info Save action to the backend update API, adding request handling, loading state, and related tests.", contributors: [{ username: "Allan2000-Git" }], }, { - category: "Other", + category: "Feature", prNumber: 642, - title: "First Contribution", - description: "", + title: "Add Forgot Password Flow", + description: + "Adds Forgot Password mode to the Sign In form with email validation, a password-reset API request, and a return-to-sign-in action.", contributors: [{ username: "keshrinandan99" }], }, { - category: "Other", + category: "Security", prNumber: 673, - title: "First Contribution", - description: "", + title: "Remove Hardcoded Test Passwords", + description: + "Replaces hard-coded passwords across the test suite with cryptographically generated values using a shared password utility.", contributors: [{ username: "SushankSinha" }], }, { - category: "Other", + category: "Bug Fix", prNumber: 676, - title: "First Contribution", - description: "", + title: "Fix Contact Form Textarea Overflow", + description: + "Constrains the Contact Us message field so it cannot grow beyond the viewport during vertical resizing across screen sizes.", contributors: [{ username: "RajeshPhayde" }], }, { - category: "Other", + category: "Bug Fix", prNumber: 678, - title: "First Contribution", - description: "", + title: "Preserve Signup Data on Errors", + description: + "Prevents the signup form from resetting when the API request fails, preserving the user’s entered values for correction or retry.", contributors: [{ username: "ShlokMane" }], }, { - category: "Other", + category: "Bug Fix", prNumber: 688, - title: "First Contribution", - description: "", + title: "Fix Footer Branding and Toast Tests", + description: + "Restores visible footer branding and fixes the Toast test infinite loop by improving timer handling and cleanup.", contributors: [{ username: "printgourav" }], }, { - category: "Other", + category: "Enhancement", prNumber: 690, - title: "First Contribution", - description: "", + title: "Highlight Active Navigation Page", + description: + "Adds active-state styling to desktop and mobile navigation so the current route or page section is clearly highlighted.", contributors: [{ username: "Ravisukhwal76" }], }, { - category: "Other", + category: "Enhancement", prNumber: 667, - title: "First Contribution", - description: "", + title: "Improve API Key Management Modals", + description: + "Adds a modal to display newly generated API keys and a confirmation modal before users delete existing keys.", contributors: [{ username: "Aniketvish0" }], }, { category: "Other", prNumber: 782, - title: "First Contribution", - description: "", + title: "Document Auth and User API Flows", + description: + "Adds Mermaid flow diagrams to the API documentation, covering authentication and user-management endpoint flows.", contributors: [{ username: "biplab-sutradhar" }], }, { category: "Other", prNumber: 781, - title: "First Contribution", - description: "", + title: "Add Logo Controller Unit Tests", + description: + "Adds unit tests for the logo retrieval and search controllers, covering validation, authorization, limits, success, and error cases.", contributors: [{ username: "YashDevani-source" }], }, ], @@ -416,36 +460,41 @@ const historicalReleases = [ { category: "Bug Fix", prNumber: 864, - title: "Date Format Consistency", - description: "", + title: "Standardize Dashboard Date Formatting", + description: + "Fixes inconsistent date formatting between the admin and operator dashboards by introducing and reusing a common date formatting helper.", contributors: [{ username: "Sumitgitup" }], }, { category: "Feature", prNumber: 878, - title: "Enabled download button & let user download their data", - description: "", + title: "Add User Data Download", + description: + "Adds a user data export feature that collects profile, generation history, usage statistics, and API key information and downloads it as a JSON file.", contributors: [{ username: "Sumitgitup" }], }, { category: "Feature", prNumber: 879, - title: "Dashboard & signout within a single dropdown", - description: "", + title: "Add Dashboard and Sign Out Dropdown", + description: + "Adds a profile dropdown that combines Dashboard and Sign Out actions for authenticated users, with equivalent options in the mobile navigation menu.", contributors: [{ username: "abhishek-2k23" }, { username: "nazibul7" }], }, { - category: "Enhancement", + category: "Bug Fix", prNumber: 884, - title: "Cache invalidation support", - description: "", + title: "Invalidate CloudFront Cache on Image Update", + description: + "Automatically invalidates the CloudFront cache after an existing logo is updated, ensuring the latest image is served and adding the required AWS permissions.", contributors: [{ username: "MukeshAbhi" }], }, { - category: "Other", + category: "Feature", prNumber: 851, - title: "S3 upload flow update", - description: "", + title: "Enable Direct S3 Image Uploads", + description: + "Introduces presigned S3 URL uploads so admins and operators can upload images directly to S3 without routing files through the backend, reducing server bandwidth usage. Also refactors upload handling, metadata persistence, and S3 configuration.", contributors: [ { username: "printgourav" }, { username: "YashDevani-source" }, @@ -454,22 +503,25 @@ const historicalReleases = [ { category: "Feature", prNumber: 876, - title: "Resend email verification", - description: "", + title: "Add Resend Verification Email Backend", + description: + "Adds backend support for resending email verification messages with token refresh and a limit of three resend requests per day, integrated into sign-in and email verification flows.", contributors: [{ username: "MukeshAbhi" }], }, { category: "Feature", prNumber: 877, - title: "Resend email verification", - description: "", + title: "Add Resend Verification Email UI", + description: + "Integrates the resend verification flow into the frontend sign-in and verification screens with toast notifications, error handling, and updated request handling.", contributors: [{ username: "MukeshAbhi" }], }, { - category: "Enhancement", + category: "Feature", prNumber: 859, - title: "Show total number of images available with admin", - description: "", + title: "Show Image Count in Admin Analytics", + description: + "Adds the total number of logos stored in the system to the admin analytics dashboard using a new image count service and repository method.", contributors: [{ username: "printgourav" }], }, ], diff --git a/packages/app/scripts/releases/seedHistoricalReleases.js b/packages/app/scripts/releases/seedHistoricalReleases.js index e33801199..c56732705 100644 --- a/packages/app/scripts/releases/seedHistoricalReleases.js +++ b/packages/app/scripts/releases/seedHistoricalReleases.js @@ -14,6 +14,7 @@ "use strict"; +require("dotenv").config(); const mongoose = require("mongoose"); const Release = require("../../models/release"); const historicalReleases = require("./historicalReleasesData"); diff --git a/packages/app/services/release.js b/packages/app/services/release.js index f3195f9e9..3069d3903 100644 --- a/packages/app/services/release.js +++ b/packages/app/services/release.js @@ -31,6 +31,14 @@ class ReleaseService { async getReleaseByVersion(version) { return await this.releaseRepository.findByVersion(version); } + + /** + * Returns a lightweight list of all releases (version & releaseDate only) in reverse-chronological order. + * @returns {Promise<Array<{ version: string, releaseDate: Date }>>} + */ + async getReleaseVersions() { + return await this.releaseRepository.getAllVersions(); + } } module.exports = ReleaseService; From 5dff47d74b01ee25bda9e6dee8166160e3be3c47 Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:03:10 +0530 Subject: [PATCH 06/10] fix: sync historical releases workflow --- .../workflows/sync-historical-releases.yaml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sync-historical-releases.yaml b/.github/workflows/sync-historical-releases.yaml index a21aff9d4..211eb0e22 100644 --- a/.github/workflows/sync-historical-releases.yaml +++ b/.github/workflows/sync-historical-releases.yaml @@ -10,14 +10,15 @@ jobs: timeout-minutes: 5 steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - - name: Install dependencies - run: npm install mongoose --legacy-peer-deps + - name: Install app dependencies + working-directory: packages/app + run: npm install --legacy-peer-deps - - name: Run release seed script - working-directory: packages/app - env: - MONGO_URL: ${{ secrets.MONGO_URL }} - run: npm run seed:releases \ No newline at end of file + - name: Run release seed script + working-directory: packages/app + env: + MONGO_URL: ${{ secrets.MONGO_URL }} + run: npm run seed:releases \ No newline at end of file From a8a945dfe5512fca4a9135cf23b53df96a08ee80 Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:08:49 +0530 Subject: [PATCH 07/10] fix: cors in /release endpoint --- packages/app/routes/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/routes/index.js b/packages/app/routes/index.js index 85f04b4e3..0f60f6f39 100644 --- a/packages/app/routes/index.js +++ b/packages/app/routes/index.js @@ -70,6 +70,6 @@ router.use( cors(privateRouteCORS), adminMilestonesRouter ); -router.use("/releases", releasesRouter); +router.use("/releases", cors(privateRouteCORS), releasesRouter); module.exports = router; From b7111733b64c586b6bdaed582a90436cbe9e2a79 Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:14:18 +0530 Subject: [PATCH 08/10] fix: typo in /release api endpoint --- packages/app/routes/releases.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/routes/releases.js b/packages/app/routes/releases.js index 339b5cdaf..eca8b0b68 100644 --- a/packages/app/routes/releases.js +++ b/packages/app/routes/releases.js @@ -15,7 +15,7 @@ const { * Query params: page (default: 1), limit (default: 10) * * GET /api/releases/:version - * Returns a single release by version tag (e.g. "v1.2.0"). + * Returns a single release by version tag (e.g. "1.2.0"). * Returns 404 if the version does not exist. * * Public — no authentication required. From e754a7e2cb84105e272a0d006f651d8aa1df5c85 Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:38:08 +0530 Subject: [PATCH 09/10] chore: improve release seed data and template instructions --- .github/RELEASE_TEMPLATE.md | 11 +- .../releases/historicalReleasesData.js | 180 +++++++++--------- 2 files changed, 102 insertions(+), 89 deletions(-) diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md index 320e98daf..e652fab95 100644 --- a/.github/RELEASE_TEMPLATE.md +++ b/.github/RELEASE_TEMPLATE.md @@ -3,6 +3,7 @@ Please follow this format when drafting the release notes. - The sync process validates the release body and will fail if the required format is not followed. +- If this release changes the hero section of landing page, please put the relevant screenshot in /packages/ui/src/assets/release IMPORTANT: @@ -45,12 +46,18 @@ Full redesign of the user interface according to the latest design specification ### 📝 Features -#### #1042 | Revamp USER Dashboard +#### #104 | Example Entry Title 1 -Full redesign of the user interface according to the latest design specifications for improved usability. +Description for example entry 1 > Contributors: @personA @personB +#### #102 | Revamp User Dashboard + +Full redesign of the user interface according to the latest design specifications for improved usability. + +> Contributors: @personC + ### 📝 Enhancements diff --git a/packages/app/scripts/releases/historicalReleasesData.js b/packages/app/scripts/releases/historicalReleasesData.js index 0322aaa83..a38052dab 100644 --- a/packages/app/scripts/releases/historicalReleasesData.js +++ b/packages/app/scripts/releases/historicalReleasesData.js @@ -54,7 +54,7 @@ const historicalReleases = [ { category: "Feature", prNumber: null, - title: "Authentication", + title: "User Authentication System", description: "Includes signup, sign in, logout, and forgot-password functionality.", contributors: null, @@ -62,7 +62,7 @@ const historicalReleases = [ { category: "Feature", prNumber: null, - title: "Profile Management", + title: "User Profile Management", description: "Users can update profile details, update their password, and delete their account.", contributors: null, @@ -82,7 +82,7 @@ const historicalReleases = [ { category: "Bug Fix", prNumber: 339, - title: "Navigation links scroll to top on click", + title: "Navigation Links Scroll To Top", description: "Fixes page navigation behavior by automatically scrolling to the top whenever the route changes.", contributors: [{ username: "Asin-Junior-Honore" }], @@ -90,7 +90,7 @@ const historicalReleases = [ { category: "Feature", prNumber: 344, - title: "Show confirmation modal on click of delete API key button", + title: "Delete API Key Confirmation Modal", description: "Adds a confirmation modal before deleting an API key, allowing users to confirm or cancel the deletion.", contributors: [{ username: "anandbaraik" }], @@ -98,7 +98,7 @@ const historicalReleases = [ { category: "Enhancement", prNumber: 345, - title: "Migrate database from Firestore to MongoDB", + title: "Migrate Database From Firestore To MongoDB", description: "Migrates the application's database and services from Firestore to MongoDB, removes Firebase dependencies, updates the data models and services, and adds MongoDB setup documentation.", contributors: [{ username: "amankumarsingh77" }], @@ -106,7 +106,7 @@ const historicalReleases = [ { category: "Enhancement", prNumber: 350, - title: "Made usageCount functional", + title: "Make Usage Count Functional", description: "Makes subscription usage tracking functional by maintaining usageCount, enforcing API usage limits, and displaying usage data from the subscription on the user dashboard.", contributors: [{ username: "amankumarsingh77" }], @@ -114,7 +114,7 @@ const historicalReleases = [ { category: "Feature", prNumber: 347, - title: "One-time API Key View/Copy", + title: "One-Time API Key View And Copy", description: "Changes API key handling so the generated key is displayed and available for copying only once, while removing stored API key values from subsequent dashboard responses.", contributors: [{ username: "Sharathxct" }], @@ -122,8 +122,7 @@ const historicalReleases = [ { category: "Enhancement", prNumber: 358, - title: - "Add Automated Script to Reset Subscription Usage Count Every 30 Days", + title: "Automated Subscription Usage Reset Every 30 Days", description: "Adds a scheduled GitHub Actions workflow and MongoDB script that automatically resets subscription usage counts every 30 days based on the subscription creation date.", contributors: [{ username: "DeltaDynamo" }], @@ -131,8 +130,7 @@ const historicalReleases = [ { category: "Bug Fix", prNumber: 360, - title: - "NaN in usageCount on user dashboard and remove duplicate userId in API response", + title: "Fix Usage Count And Duplicate User ID", description: "Fixes usageCount displaying as NaN on the user dashboard and removes duplicated user information from key and subscription API responses.", contributors: [{ username: "amankumarsingh77" }], @@ -140,7 +138,7 @@ const historicalReleases = [ { category: "Bug Fix", prNumber: 363, - title: "Reset and forget password API", + title: "Reset And Forgot Password API", description: "Fixes the reset and forgot password APIs by correctly deriving the user ID from the MongoDB user record and storing it in the reset-password session token.", contributors: [{ username: "amankumarsingh77" }], @@ -148,7 +146,7 @@ const historicalReleases = [ { category: "Feature", prNumber: 365, - title: "Admin Image Re-upload with Name & Extension Verification", + title: "Admin Image Re-Upload With Verification", description: "Adds image re-upload functionality to the admin dashboard, allowing existing images to be replaced while enforcing matching image names and file extensions.", contributors: [{ username: "Soumava-221B" }], @@ -156,7 +154,7 @@ const historicalReleases = [ { category: "Feature", prNumber: 357, - title: "Operator UI + APIs", + title: "Operator UI And APIs", description: "Adds an operator interface with APIs for viewing customer queries, paginating results, and sending responses back to customers.", contributors: [{ username: "asharma991" }], @@ -172,7 +170,7 @@ const historicalReleases = [ { category: "Other", prNumber: 376, - title: "Updates client test cases from Jest to Vitest", + title: "Migrate Client Tests From Jest To Vitest", description: "Migrates the client-side test suite from Jest to Vitest and updates test mocks, assertions, dependencies, and configuration for the new test runner.", contributors: [{ username: "Ayushsanjdev" }], @@ -180,7 +178,7 @@ const historicalReleases = [ { category: "Enhancement", prNumber: 373, - title: "Footer, Sign-in Card, and About Section UI Improvements", + title: "Footer Sign-In And About UI Improvements", description: "Improves the footer, sign-in form layout, and About page by reorganizing navigation elements, repositioning the forgot-password link, and restructuring content into cards.", contributors: [{ username: "AryaDharkar" }], @@ -200,7 +198,7 @@ const historicalReleases = [ { category: "Enhancement", prNumber: 389, - title: "Add Repository Layer for Models", + title: "Add Repository Layer For Models", description: "Introduces a reusable BaseRepository with model-specific repositories for common database operations. Also adds user-specific key lookup support.", contributors: [{ username: "sujal111" }], @@ -208,7 +206,7 @@ const historicalReleases = [ { category: "Enhancement", prNumber: 397, - title: "Add Documentation and Other Templates", + title: "Add Documentation And Issue Templates", description: "Adds dedicated GitHub issue templates for documentation and other issues, making reports easier to structure and classify.", contributors: [{ username: "ayush19sinha" }], @@ -288,7 +286,7 @@ const historicalReleases = [ { category: "Other", prNumber: 518, - title: "Add Hero and Footer Tests", + title: "Add Hero And Footer Tests", description: "Adds Vitest and React Testing Library coverage for HeroSection and Footer rendering, interactions, links, and modal behavior.", contributors: [{ username: "shafat730" }], @@ -304,7 +302,7 @@ const historicalReleases = [ { category: "Enhancement", prNumber: 611, - title: "Smooth Auth Form Toggle", + title: "Smooth Authentication Form Toggle", description: "Improves the Sign In and Sign Up switch with a smoother fade transition and updates tests for the delayed toggle behavior.", contributors: [{ username: "Drshnnn" }], @@ -328,7 +326,7 @@ const historicalReleases = [ { category: "Feature", prNumber: 612, - title: "Add Analytics Cards to Admin", + title: "Add Analytics Cards To Admin", description: "Adds responsive analytics cards to the admin dashboard, integrates them into the page, and covers the feature with automated tests.", contributors: [{ username: "theboyofdream" }], @@ -344,7 +342,7 @@ const historicalReleases = [ { category: "Other", prNumber: 622, - title: "Add Role and Logo Tests", + title: "Add Role And Logo Tests", description: "Adds endpoint tests for updating user roles and logos, including validation, error cases, and successful updates.", contributors: [{ username: "anshika282" }], @@ -400,7 +398,7 @@ const historicalReleases = [ { category: "Bug Fix", prNumber: 678, - title: "Preserve Signup Data on Errors", + title: "Preserve Signup Data On Errors", description: "Prevents the signup form from resetting when the API request fails, preserving the user’s entered values for correction or retry.", contributors: [{ username: "ShlokMane" }], @@ -408,7 +406,7 @@ const historicalReleases = [ { category: "Bug Fix", prNumber: 688, - title: "Fix Footer Branding and Toast Tests", + title: "Fix Footer Branding And Toast Tests", description: "Restores visible footer branding and fixes the Toast test infinite loop by improving timer handling and cleanup.", contributors: [{ username: "printgourav" }], @@ -432,7 +430,7 @@ const historicalReleases = [ { category: "Other", prNumber: 782, - title: "Document Auth and User API Flows", + title: "Document Authentication And User API Flows", description: "Adds Mermaid flow diagrams to the API documentation, covering authentication and user-management endpoint flows.", contributors: [{ username: "biplab-sutradhar" }], @@ -476,7 +474,7 @@ const historicalReleases = [ { category: "Feature", prNumber: 879, - title: "Add Dashboard and Sign Out Dropdown", + title: "Add Dashboard And Sign-Out Dropdown", description: "Adds a profile dropdown that combines Dashboard and Sign Out actions for authenticated users, with equivalent options in the mobile navigation menu.", contributors: [{ username: "abhishek-2k23" }, { username: "nazibul7" }], @@ -484,7 +482,7 @@ const historicalReleases = [ { category: "Bug Fix", prNumber: 884, - title: "Invalidate CloudFront Cache on Image Update", + title: "Invalidate CloudFront Cache On Image Update", description: "Automatically invalidates the CloudFront cache after an existing logo is updated, ensuring the latest image is served and adding the required AWS permissions.", contributors: [{ username: "MukeshAbhi" }], @@ -519,7 +517,7 @@ const historicalReleases = [ { category: "Feature", prNumber: 859, - title: "Show Image Count in Admin Analytics", + title: "Show Image Count In Admin Analytics", description: "Adds the total number of logos stored in the system to the admin analytics dashboard using a new image count service and repository method.", contributors: [{ username: "printgourav" }], @@ -538,10 +536,10 @@ const historicalReleases = [ entries: [ { category: "Feature", - prNumber: null, + prNumber: 896, title: "API Usage Graph", description: - "You can now view a simple graph on your dashboard that helps you understand how much you’re using the API, including how many requests you’ve made and how much data you’ve used.", + "Added a dashboard graph showing API request usage over weekly and monthly periods, along with a refresh action.", contributors: [ { username: "L-Tarun-Aditya" }, { username: "sachinkmrsin" }, @@ -550,10 +548,10 @@ const historicalReleases = [ }, { category: "Security", - prNumber: null, + prNumber: 893, title: "Automatic API Key Expiration", description: - "API keys now expire automatically to keep accounts more secure. Users can set a custom expiry date, and existing API keys will expire after one year by default.", + "Added API key expiration with configurable validity for new keys and one-year validity handling for existing keys, including expiry enforcement.", contributors: [ { username: "biplab-sutradhar" }, { username: "printgourav" }, @@ -561,10 +559,10 @@ const historicalReleases = [ }, { category: "Feature", - prNumber: null, + prNumber: 897, title: "Automatic Catalog Creation", description: - "Catalogs are now created automatically, so you don’t need to set them up manually anymore.", + "Added automatic catalog creation with web-search support, allowing missing logos to be fetched online and incorporated into the catalog.", contributors: [ { username: "BansalAbhinav" }, { username: "Saurabhupadhyay8170" }, @@ -572,18 +570,18 @@ const historicalReleases = [ }, { category: "Feature", - prNumber: null, + prNumber: 891, title: "Release Page", description: - "A new Release Page is now available, where you can easily see what’s new in each version and who helped build it.", + "Introduced a dedicated release page with version timelines, release information, contributors, and structured release notes.", contributors: [{ username: "abhishek-2k23" }], }, { category: "Enhancement", - prNumber: null, + prNumber: 876, title: "Resend Verification Email", description: - "If you don’t receive your verification email, you can now resend it easily and continue without getting stuck.", + "Added backend support for resending verification emails, including token refresh, rate limiting, and integration with signin and verification flows.", contributors: [{ username: "MukeshAbhi" }], }, ], @@ -600,11 +598,10 @@ const historicalReleases = [ entries: [ { category: "Security", - prNumber: null, - title: - "Authentication migrated from JWT to a secure session-based system", + prNumber: 947, + title: "Migrate Authentication To Secure Sessions", description: - "Authentication has been migrated from JWT to a secure session-based system, improving overall security and simplifying token management.", + "Authentication was migrated from JWT-based authentication to server-managed sessions, including session creation, validation, and invalidation.", contributors: [ { username: "Mantu01" }, { username: "Smayur0" }, @@ -613,18 +610,18 @@ const historicalReleases = [ }, { category: "Enhancement", - prNumber: null, - title: "Dynamic Light and Dark themes", + prNumber: 934, + title: "Dynamic Light And Dark Themes", description: - "You can now switch between Light and Dark themes to personalize your experience.", + "Added a dark-mode theme system with a toggle and theme context, allowing users to switch between light and dark experiences.", contributors: [{ username: "sachinkmrsin" }], }, { category: "Enhancement", - prNumber: null, - title: "Notifications for API expiry and usage limits", + prNumber: 948, + title: "Notifications For API Expiry And Usage Limits", description: - "Notifications are now available for important events such as API expiry and usage limit being reached.", + "Added toast notifications for API usage thresholds, warning users as they approach and reach their usage limits.", contributors: [ { username: "YashDevani-source" }, { username: "L-Tarun-Aditya" }, @@ -632,10 +629,10 @@ const historicalReleases = [ }, { category: "Feature", - prNumber: null, + prNumber: 935, title: "Custom Logo Creation", description: - "You can now create and use your own custom logo image directly within the platform.", + "Added a Fabric.js-based Create Logo editor with text, shapes, colors, image upload, layer controls, and export support.", contributors: [ { username: "biplab-sutradhar" }, { username: "mridul-giri" }, @@ -643,10 +640,10 @@ const historicalReleases = [ }, { category: "Security", - prNumber: null, + prNumber: 946, title: "API Key Masking", description: - "API keys are now securely hidden to prevent accidental exposure and enhance account security.", + "Removed the API key from the client-side user context so it could no longer be exposed through frontend state or console inspection.", contributors: [{ username: "L-Tarun-Aditya" }], }, ], @@ -663,59 +660,66 @@ const historicalReleases = [ entries: [ { category: "Enhancement", - prNumber: null, - title: "Revamp USER dashboard according to the design", - description: "", + prNumber: 965, + title: "Revamp User Dashboard Design", + description: + "Completely redesigned the USER dashboard with updated layouts, headers, settings cards, charts, and responsive UI to match the new design.", contributors: [{ username: "AryaDharkar" }], }, { category: "Enhancement", - prNumber: null, - title: "Enhancing the UI of the admin dashboard", - description: "", + prNumber: 1000, + title: "Enhance Admin Dashboard UI", + description: + "Updated the admin dashboard UI with the new design system, including refreshed analytics, catalog layouts, and image previews.", contributors: [{ username: "L-Tarun-Aditya" }], }, { category: "Security", - prNumber: null, - title: "Add 2FA section in user settings", - description: "", + prNumber: 1010, + title: "Add 2FA Section To User Settings", + description: + "Added Two-Factor Authentication controls to the user settings, allowing users to enable and disable 2FA.", contributors: [{ username: "L-Tarun-Aditya" }], }, { category: "Security", - prNumber: null, - title: "Implementing a dedicated settings page for MFA", - description: "", + prNumber: 1010, + title: "Implement Dedicated MFA Settings Page", + description: + "Introduced a dedicated Settings page for account management and moved user-related controls out of the main dashboard.", contributors: [{ username: "L-Tarun-Aditya" }], }, { category: "Security", - prNumber: null, - title: "Multi factor authentication", - description: "", + prNumber: 954, + title: "Multi-Factor Authentication", + description: + "Implemented TOTP-based multi-factor authentication with setup, verification, enable/disable flows, and login enforcement.", contributors: [{ username: "MukeshAbhi" }], }, { category: "Security", - prNumber: null, - title: "Prevent Users From Reusing Old Password During Password Reset", - description: "", + prNumber: 977, + title: "Prevent Password Reuse During Reset", + description: + "Prevented users from setting their new password to the same value as the existing password during password reset and password update.", contributors: [{ username: "rishang14" }], }, { category: "Feature", - prNumber: null, - title: - "Fix bugs on createLogo page and allow users to access this page without authentication", - description: "", + prNumber: 979, + title: "Fix CreateLogo Page And Authentication", + description: + "Fixed CreateLogo editing and upload issues and made the CreateLogo page publicly accessible while preserving the intended post-login redirect.", contributors: [{ username: "AryaDharkar" }], }, { category: "Security", - prNumber: null, - title: "Feature for user session management", - description: "", + prNumber: 1016, + title: "Implement User Session Management", + description: + "Added per-device session management with active-session viewing, session revocation, sign-out-other-devices, and supporting authentication changes.", contributors: [ { username: "kadamsahil2511" }, { username: "DeepAkdotcom" }, @@ -723,24 +727,26 @@ const historicalReleases = [ }, { category: "Other", - prNumber: null, - title: - "Feature to enforce branch & PR naming conventions via husky + GitHub Actions", - description: "", + prNumber: 992, + title: "Enforce Branch And PR Naming Conventions", + description: + "Added Husky and GitHub Actions checks to enforce standardized branch names and pull request titles.", contributors: [{ username: "Smayur0" }], }, { category: "Enhancement", - prNumber: null, - title: "Redesign documentation page", - description: "", + prNumber: 1020, + title: "Redesign Documentation Page", + description: + "Redesigned the documentation experience with improved endpoint presentation, quickstart sections, layout, and responsive styling.", contributors: [{ username: "Dhirenderchoudhary" }], }, { category: "Enhancement", - prNumber: null, - title: "Revamp sign in and sign up form", - description: "", + prNumber: 974, + title: "Revamp Sign-In And Sign-Up Forms", + description: + "Revamped the sign-in and sign-up UI, including improved layouts and real-time password-strength feedback for registration.", contributors: [{ username: "0-mstrmind" }, { username: "kunjesh360" }], }, ], From 41dd7f5d3a5c8b5cf35c0c6dc1ac979a8bb588aa Mon Sep 17 00:00:00 2001 From: Swaraj Keshari <147552314+SwarajKeshari@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:18:32 +0530 Subject: [PATCH 10/10] fix: strengthen release body validation and tests --- .../__tests__/scripts/syncReleases.test.js | 648 ++++++++++++++++-- .../app/scripts/releases/syncNewReleases.js | 641 +++++++++++++---- 2 files changed, 1106 insertions(+), 183 deletions(-) diff --git a/packages/app/__tests__/scripts/syncReleases.test.js b/packages/app/__tests__/scripts/syncReleases.test.js index 5931ad409..b729d4292 100644 --- a/packages/app/__tests__/scripts/syncReleases.test.js +++ b/packages/app/__tests__/scripts/syncReleases.test.js @@ -25,8 +25,9 @@ describe("syncNewReleases Script & Parser", () => { // ------------------------------------------------------------------------- // Unit Tests: parseReleaseBody // ------------------------------------------------------------------------- + describe("parseReleaseBody", () => { - it("1. One category, one entry, one contributor with emoji in category header", () => { + it("1. Parses one category, one entry, and one contributor with emoji", () => { const body = ` ### 📝 Features @@ -36,6 +37,7 @@ Full redesign of the user interface according to the latest design specification > Contributors: @personA `; + const { entries, errors } = parseReleaseBody(body); expect(errors).toHaveLength(0); @@ -50,7 +52,7 @@ Full redesign of the user interface according to the latest design specification }); }); - it("2. Optional release introduction before first category header is ignored", () => { + it("2. Allows an optional release introduction before the first category", () => { const body = ` Welcome to version 0.8.0! This release introduces major UI improvements. @@ -62,6 +64,7 @@ Full redesign description. > Contributors: @personA `; + const { entries, errors } = parseReleaseBody(body); expect(errors).toHaveLength(0); @@ -69,7 +72,7 @@ Full redesign description. expect(entries[0].prNumber).toBe(1042); }); - it("3. One category, multiple entries", () => { + it("3. Parses multiple entries in one category", () => { const body = ` ### 📝 Features @@ -85,6 +88,7 @@ Second entry description. > Contributors: @personB `; + const { entries, errors } = parseReleaseBody(body); expect(errors).toHaveLength(0); @@ -93,7 +97,7 @@ Second entry description. expect(entries[1].prNumber).toBe(1043); }); - it("4. Multiple contributors in blockquote format", () => { + it("4. Parses multiple contributors", () => { const body = ` ### 📝 Features @@ -103,6 +107,7 @@ Description here. > Contributors: @personA @personB `; + const { entries, errors } = parseReleaseBody(body); expect(errors).toHaveLength(0); @@ -113,7 +118,7 @@ Description here. ]); }); - it("5. Multiple categories with leading emojis", () => { + it("5. Parses all supported categories", () => { const body = ` ### 📝 Features @@ -125,7 +130,7 @@ Description. ### 📝 Enhancements -#### #1052 | Improve Release Page Performance +#### #1052 | Improve Release Page Description. @@ -141,7 +146,7 @@ Description. ### 📝 Security -#### #1044 | Two-Factor Authentication +#### #1044 | Add Two-Factor Authentication Description. @@ -155,11 +160,12 @@ Description. > Contributors: @personE `; + const { entries, errors } = parseReleaseBody(body); expect(errors).toHaveLength(0); expect(entries).toHaveLength(5); - expect(entries.map((e) => e.category)).toEqual([ + expect(entries.map((entry) => entry.category)).toEqual([ "Feature", "Enhancement", "Bug Fix", @@ -168,8 +174,26 @@ Description. ]); }); - it("6. Rejects empty category or leftover 'Entry' placeholder", () => { - const bodyEmpty = ` + it("6. Allows supported category headings without the emoji", () => { + const body = ` +### Features + +#### #1042 | Add Search + +Add search functionality. + +> Contributors: @personA +`; + + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0].category).toBe("Feature"); + }); + + it("7. Rejects empty category sections", () => { + const body = ` ### 📝 Features #### #1042 | Valid Feature @@ -179,118 +203,451 @@ Description. > Contributors: @personA ### 📝 Enhancements +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch(/Empty section/i); + }); + + it("8. Rejects leftover Entry placeholder", () => { + const body = ` +### 📝 Features Entry `; - const { errors: errorsEmpty } = parseReleaseBody(bodyEmpty); - expect(errorsEmpty.length).toBeGreaterThan(0); - expect(errorsEmpty[0].message).toMatch(/Leftover placeholder "Entry"/i); + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toMatch(/Leftover placeholder "Entry"/i); + }); + + it("9. Rejects zero parsed entries when release contains no categories", () => { + const body = ` +This release contains some introductory text but no release entries. +`; + + const { entries, errors } = parseReleaseBody(body); + + expect(entries).toHaveLength(0); + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => /zero parsed entries/i.test(error.message)) + ).toBe(true); }); - it("7. Empty description is supported", () => { + it("10. Rejects zero parsed entries for an empty release body", () => { + const { entries, errors } = parseReleaseBody(""); + + expect(entries).toHaveLength(0); + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => /zero parsed entries/i.test(error.message)) + ).toBe(true); + }); + + it("11. Rejects unsupported category headings", () => { + const body = ` +### 📝 Improvements + +#### #1042 | Improve Release Page + +Description. + +> Contributors: @personA +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => + /Unsupported category heading/i.test(error.message) + ) + ).toBe(true); + }); + + it("12. Rejects incorrect category heading level", () => { + const body = ` +## 📝 Features + +#### #1042 | Add Search + +Description. + +> Contributors: @personA +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => + /Invalid heading level "H2"/i.test(error.message) + ) + ).toBe(true); + }); + + it("13. Rejects incorrect heading level H4 used as a category", () => { + const body = ` +#### 📝 Features + +#### #1042 | Add Search + +Description. + +> Contributors: @personA +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + }); + + it("14. Rejects empty description", () => { const body = ` ### 📝 Features -#### #1042 | Revamp USER Dashboard +#### #1042 | Add Search > Contributors: @personA `; - const { entries, errors } = parseReleaseBody(body); - expect(errors).toHaveLength(0); - expect(entries).toHaveLength(1); - expect(entries[0].description).toBe(""); + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => /Missing description/i.test(error.message)) + ).toBe(true); + }); + + it("15. Rejects Contributors field without blockquote marker", () => { + const body = ` +### 📝 Features + +#### #1042 | Add Search + +Description. + +Contributors: @personA +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => + /Invalid Contributors field/i.test(error.message) + ) + ).toBe(true); + }); + + it("16. Rejects Contributors field without @ username syntax", () => { + const body = ` +### 📝 Features + +#### #1042 | Add Search + +Description. + +> Contributors: personA +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => + /must use GitHub username syntax beginning with "@"/i.test( + error.message + ) + ) + ).toBe(true); }); - // ----------------------------------------------------------------------- - // Invalid test cases - // ----------------------------------------------------------------------- + it("17. Rejects missing Contributors field", () => { + const body = ` +### 📝 Features + +#### #1042 | Add Search + +Description without contributors. +`; - it("8. Missing PR number", () => { + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => + /Missing required Contributors field/i.test(error.message) + ) + ).toBe(true); + }); + + it("18. Rejects duplicate Contributors fields", () => { const body = ` ### 📝 Features -#### Revamp USER Dashboard +#### #1042 | Add Search Description. > Contributors: @personA + +> Contributors: @personB `; + const { errors } = parseReleaseBody(body); expect(errors.length).toBeGreaterThan(0); - expect(errors[0].message).toMatch(/Invalid entry heading/i); + expect( + errors.some((error) => + /Duplicate Contributors fields/i.test(error.message) + ) + ).toBe(true); }); - it("9. Invalid/non-positive PR number", () => { - const bodyPos = ` + it("19. Rejects content after Contributors field", () => { + const body = ` ### 📝 Features -#### #-10 | Revamp USER Dashboard +#### #1042 | Add Search Description. > Contributors: @personA + +Unexpected content. `; - const { errors: errorsPos } = parseReleaseBody(bodyPos); - expect(errorsPos.length).toBeGreaterThan(0); - expect(errorsPos[0].message).toMatch(/Invalid PR number/i); + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => + /Unexpected content found after the Contributors field/i.test( + error.message + ) + ) + ).toBe(true); }); - it("10. Missing title", () => { + it("20. Allows another entry after the previous Contributors field", () => { const body = ` ### 📝 Features -#### #1042 | +#### #1042 | First Feature + +First description. + +> Contributors: @personA + +#### #1043 | Second Feature + +Second description. + +> Contributors: @personB +`; + + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(2); + expect(entries[0].prNumber).toBe(1042); + expect(entries[1].prNumber).toBe(1043); + }); + + it("21. Allows duplicate PR numbers within the same release", () => { + const body = ` +### 📝 Features + +#### #1042 | First Feature + +First description. + +> Contributors: @personA + +#### #1042 | Second Feature + +Second description. + +> Contributors: @personB +`; + + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(2); + expect(entries[0].prNumber).toBe(1042); + expect(entries[1].prNumber).toBe(1042); + }); + + it("22. Allows the same PR across different categories", () => { + const body = ` +### 📝 Features + +#### #1042 | Add Release Search + +Add release search. + +> Contributors: @personA + +### 📝 Enhancements + +#### #1042 | Improve Release Search + +Improve release search. + +> Contributors: @personA +`; + + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(2); + expect(entries[0].prNumber).toBe(1042); + expect(entries[1].prNumber).toBe(1042); + }); + + it("23. Ignores headings inside HTML comments", () => { + const body = ` +<!-- +### 📝 Features + +#### #999 | Fake Feature + +Fake description. + +> Contributors: @fake-user +--> + +### 📝 Features + +#### #1042 | Real Feature + +Real description. + +> Contributors: @personA +`; + + const { entries, errors } = parseReleaseBody(body); + + expect(errors).toHaveLength(0); + expect(entries).toHaveLength(1); + expect(entries[0].prNumber).toBe(1042); + }); + + it("24. Rejects content after the Contributors field", () => { + const body = ` +### 📝 Features + +#### #1042 | Real Feature + +Real description. + +> Contributors: @personA + +Unexpected content after contributors. +`; + + const { entries, errors } = parseReleaseBody(body); + + expect(entries).toHaveLength(1); + expect(errors).toHaveLength(1); + expect(errors[0].message).toMatch( + /Unexpected content found after the Contributors field/i + ); + }); + + it("25. Rejects malformed H4 entry heading", () => { + const body = ` +### 📝 Features + +#### Revamp Release Page Description. > Contributors: @personA `; + const { errors } = parseReleaseBody(body); expect(errors.length).toBeGreaterThan(0); - expect(errors[0].message).toMatch(/Missing or empty title/i); + expect( + errors.some((error) => /Invalid entry heading/i.test(error.message)) + ).toBe(true); }); - it("11. Missing Contributors field", () => { + it("26. Rejects invalid PR number", () => { const body = ` ### 📝 Features -#### #1042 | Revamp USER Dashboard +#### #-10 | Invalid PR -Description without contributors line. +Description. + +> Contributors: @personA `; + const { errors } = parseReleaseBody(body); expect(errors.length).toBeGreaterThan(0); - expect(errors[0].message).toMatch(/Missing required Contributors field/i); + expect( + errors.some((error) => /Invalid PR number/i.test(error.message)) + ).toBe(true); }); - it("12. Contributor without @", () => { + it("27. Rejects missing title", () => { const body = ` ### 📝 Features -#### #1042 | Revamp USER Dashboard +#### #1042 | Description. -> Contributors: personA +> Contributors: @personA `; + const { errors } = parseReleaseBody(body); expect(errors.length).toBeGreaterThan(0); - expect(errors[0].message).toMatch( - /must use GitHub username syntax beginning with "@"/i - ); + expect( + errors.some((error) => /Missing or empty title/i.test(error.message)) + ).toBe(true); + }); + + it("28. Rejects malformed content before first entry", () => { + const body = ` +### 📝 Features + +Unexpected content before entry. + +#### #1042 | Add Search + +Description. + +> Contributors: @personA +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => + /Malformed content before entry/i.test(error.message) + ) + ).toBe(true); }); }); // ------------------------------------------------------------------------- // Unit Tests: formatValidationErrors // ------------------------------------------------------------------------- + describe("formatValidationErrors", () => { - it("formats errors into readable string", () => { + it("formats validation errors into a readable string", () => { const mockErrors = [ { section: "Feature", @@ -305,6 +662,7 @@ Description. ]; const formatted = formatValidationErrors(mockErrors); + expect(formatted).toContain("Feature → PR #1042"); expect(formatted).toContain("Missing required Contributors field."); expect(formatted).toContain("Enhancement → PR #1052"); @@ -314,8 +672,9 @@ Description. // ------------------------------------------------------------------------- // Unit Tests: validateGitHubEntities // ------------------------------------------------------------------------- + describe("validateGitHubEntities", () => { - it("13. Invalid GitHub username", async () => { + it("29. Rejects invalid GitHub username", async () => { global.fetch = jest.fn().mockImplementation((url) => { if (url.includes("/pulls/1042")) { return Promise.resolve({ @@ -323,6 +682,7 @@ Description. json: () => Promise.resolve({ number: 1042 }), }); } + if (url.includes("/users/invaliduser")) { return Promise.resolve({ ok: false, @@ -330,7 +690,11 @@ Description. text: () => Promise.resolve("Not Found"), }); } - return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); }); const entries = [ @@ -355,7 +719,7 @@ Description. ); }); - it("14. Non-existent PR", async () => { + it("30. Rejects non-existent PR", async () => { global.fetch = jest.fn().mockImplementation((url) => { if (url.includes("/pulls/9999")) { return Promise.resolve({ @@ -364,7 +728,11 @@ Description. text: () => Promise.resolve("Not Found"), }); } - return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); }); const entries = [ @@ -391,11 +759,13 @@ Description. // ------------------------------------------------------------------------- // Integration Tests: MongoDB atomic upsert & full syncRelease workflow // ------------------------------------------------------------------------- + describe("syncRelease MongoDB Integration", () => { let mongoUri; beforeAll(async () => { mongoUri = process.env.MONGO_URL; + if (mongoose.connection.readyState === 0) { await mongoose.connect(mongoUri); } @@ -403,6 +773,7 @@ Description. afterAll(async () => { await Release.deleteMany({}); + if (mongoose.connection.readyState !== 0) { await mongoose.connection.close(); } @@ -415,6 +786,7 @@ Description. it("successfully syncs valid release storing version and tagName separately", async () => { const tag = "v0.8.0"; const name = "0.8.0"; + process.env.GITHUB_TOKEN = "fake_token"; process.env.MONGO_URL = mongoUri; process.env.RELEASE_TAG = tag; @@ -447,7 +819,7 @@ Migrated CI pipeline. json: () => Promise.resolve({ id: 12345, - name: name, + name, tag_name: tag, published_at: "2026-09-11T12:00:00Z", html_url: `https://github.com/testowner/testrepo/releases/tag/${tag}`, @@ -455,12 +827,21 @@ Migrated CI pipeline. }), }); } + if (url.includes("/pulls/")) { - return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); } + if (url.includes("/users/")) { - return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); } + return Promise.resolve({ ok: false, status: 404, @@ -475,11 +856,13 @@ Migrated CI pipeline. } const doc = await Release.findOne({ version: name }); + expect(doc).not.toBeNull(); expect(doc.version).toBe("0.8.0"); expect(doc.tagName).toBe("v0.8.0"); expect(doc.githubReleaseId).toBe(12345); expect(doc.entries).toHaveLength(2); + expect(doc.entries[0]).toMatchObject({ category: "Feature", prNumber: 1042, @@ -488,8 +871,9 @@ Migrated CI pipeline. }); }); - it("fails validation on invalid release and writes NOTHING to MongoDB", async () => { + it("fails validation on invalid release and writes nothing to MongoDB", async () => { const tag = "v2.0.0-invalid"; + process.env.GITHUB_TOKEN = "fake_token"; process.env.MONGO_URL = mongoUri; process.env.RELEASE_TAG = tag; @@ -520,7 +904,11 @@ Description. }), }); } - return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); }); jest.spyOn(console, "error").mockImplementation(() => {}); @@ -531,7 +919,157 @@ Description. await mongoose.connect(mongoUri); } - const doc = await Release.findOne({ version: "2.0.0-invalid" }); + const doc = await Release.findOne({ + version: "2.0.0-invalid", + }); + + expect(doc).toBeNull(); + }); + + it("fails validation when release contains zero parsed entries", async () => { + const tag = "v2.0.1-empty"; + + process.env.GITHUB_TOKEN = "fake_token"; + process.env.MONGO_URL = mongoUri; + process.env.RELEASE_TAG = tag; + process.env.GITHUB_REPOSITORY = "testowner/testrepo"; + + const invalidReleaseBody = ` +This release has no structured entries. +`; + + global.fetch = jest.fn().mockImplementation((url) => { + if (url.includes(`/releases/tags/${encodeURIComponent(tag)}`)) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: 100001, + name: "2.0.1-empty", + tag_name: tag, + published_at: "2026-09-11T12:00:00Z", + html_url: `https://github.com/testowner/testrepo/releases/tag/${tag}`, + body: invalidReleaseBody, + }), + }); + } + + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + }); + + jest.spyOn(console, "error").mockImplementation(() => {}); + + await expect(syncRelease()).rejects.toThrow("Release validation failed."); + + const doc = await Release.findOne({ + version: "2.0.1-empty", + }); + + expect(doc).toBeNull(); + }); + + it("fails validation for unsupported category heading and writes nothing", async () => { + const tag = "v2.0.2-unsupported-category"; + + process.env.GITHUB_TOKEN = "fake_token"; + process.env.MONGO_URL = mongoUri; + process.env.RELEASE_TAG = tag; + process.env.GITHUB_REPOSITORY = "testowner/testrepo"; + + const invalidReleaseBody = ` +### 📝 Improvements + +#### #1042 | Improve Release Page + +Description. + +> Contributors: @personA +`; + + global.fetch = jest.fn().mockImplementation((url) => { + if (url.includes(`/releases/tags/${encodeURIComponent(tag)}`)) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: 100002, + name: "2.0.2-unsupported-category", + tag_name: tag, + published_at: "2026-09-11T12:00:00Z", + html_url: `https://github.com/testowner/testrepo/releases/tag/${tag}`, + body: invalidReleaseBody, + }), + }); + } + + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + }); + + jest.spyOn(console, "error").mockImplementation(() => {}); + + await expect(syncRelease()).rejects.toThrow("Release validation failed."); + + const doc = await Release.findOne({ + version: "2.0.2-unsupported-category", + }); + + expect(doc).toBeNull(); + }); + + it("fails validation for incorrect category heading level and writes nothing", async () => { + const tag = "v2.0.3-invalid-heading"; + + process.env.GITHUB_TOKEN = "fake_token"; + process.env.MONGO_URL = mongoUri; + process.env.RELEASE_TAG = tag; + process.env.GITHUB_REPOSITORY = "testowner/testrepo"; + + const invalidReleaseBody = ` +## 📝 Features + +#### #1042 | Add Search + +Description. + +> Contributors: @personA +`; + + global.fetch = jest.fn().mockImplementation((url) => { + if (url.includes(`/releases/tags/${encodeURIComponent(tag)}`)) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + id: 100003, + name: "2.0.3-invalid-heading", + tag_name: tag, + published_at: "2026-09-11T12:00:00Z", + html_url: `https://github.com/testowner/testrepo/releases/tag/${tag}`, + body: invalidReleaseBody, + }), + }); + } + + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + }); + + jest.spyOn(console, "error").mockImplementation(() => {}); + + await expect(syncRelease()).rejects.toThrow("Release validation failed."); + + const doc = await Release.findOne({ + version: "2.0.3-invalid-heading", + }); + expect(doc).toBeNull(); }); }); diff --git a/packages/app/scripts/releases/syncNewReleases.js b/packages/app/scripts/releases/syncNewReleases.js index 06c85da52..7bff7c425 100644 --- a/packages/app/scripts/releases/syncNewReleases.js +++ b/packages/app/scripts/releases/syncNewReleases.js @@ -24,7 +24,7 @@ const mongoose = require("mongoose"); const Release = require("../../models/release"); // --------------------------------------------------------------------------- -// Logging helpers — prefixed for easy scanning in GitHub Actions log output +// Logging helpers // --------------------------------------------------------------------------- function log(msg) { @@ -45,6 +45,7 @@ function getConfig() { process.env.GITHUB_REPOSITORY || process.env.GITHUB_REPO || null; const missing = []; + if (!GITHUB_TOKEN) missing.push("GITHUB_TOKEN"); if (!MONGO_URL) missing.push("MONGO_URL"); if (!RELEASE_TAG) missing.push("RELEASE_TAG"); @@ -56,14 +57,29 @@ function getConfig() { ); } - const [owner, repo] = repository.split("/"); + const repositoryParts = repository.split("/"); + + if (repositoryParts.length !== 2) { + throw new Error( + `GITHUB_REPOSITORY must be in "owner/repo" format, got: "${repository}"` + ); + } + + const [owner, repo] = repositoryParts; + if (!owner || !repo) { throw new Error( `GITHUB_REPOSITORY must be in "owner/repo" format, got: "${repository}"` ); } - return { GITHUB_TOKEN, MONGO_URL, RELEASE_TAG, owner, repo }; + return { + GITHUB_TOKEN, + MONGO_URL, + RELEASE_TAG, + owner, + repo, + }; } // --------------------------------------------------------------------------- @@ -76,6 +92,7 @@ function getConfig() { */ async function githubFetch(path, token) { const url = `https://api.github.com${path}`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, @@ -87,6 +104,7 @@ async function githubFetch(path, token) { if (!res.ok) { const body = await res.text().catch(() => ""); + throw new Error(`GitHub API ${url} returned ${res.status}: ${body}`); } @@ -98,6 +116,7 @@ async function githubFetch(path, token) { */ async function fetchRelease(owner, repo, tag, token) { log(`Fetching release tag="${tag}" from GitHub...`); + return await githubFetch( `/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, token @@ -116,60 +135,231 @@ const CATEGORY_MAP = { others: "Other", }; +const CATEGORY_NAMES = Object.keys(CATEGORY_MAP); + +/** + * Removes HTML comments from release notes. + * + * The release template contains instructions/examples inside an HTML comment. + * Those examples must not be parsed as real release content. + */ +function removeHtmlComments(body) { + return body.replace(/<!--[\s\S]*?-->/g, ""); +} + +/** + * Normalises a category heading. + * + * The release template requires: + * + * ### 📝 Features + * + * The emoji is optional for validation, but the category name must match + * exactly. + */ +function getCategoryFromHeading(header) { + const categoryName = header + .trim() + .replace(/^📝\s*/u, "") + .trim() + .toLowerCase(); + + return CATEGORY_MAP[categoryName] || null; +} + +/** + * Checks whether a heading is an exact supported category heading. + */ +function isSupportedCategoryHeading(header) { + const normalized = header + .trim() + .replace(/^📝\s*/u, "") + .trim() + .toLowerCase(); + + return CATEGORY_NAMES.includes(normalized); +} + +/** + * Parse all Markdown ATX headings outside fenced code blocks. + */ +function getMarkdownHeadings(body) { + const headings = []; + const lines = body.split(/\r?\n/); + + let insideCodeFence = false; + let fenceCharacter = null; + + let offset = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + const fenceMatch = trimmed.match(/^(```+|~~~+)/); + + if (fenceMatch) { + const currentFence = fenceMatch[1][0]; + + if (!insideCodeFence) { + insideCodeFence = true; + fenceCharacter = currentFence; + } else if (fenceCharacter === currentFence) { + insideCodeFence = false; + fenceCharacter = null; + } + + offset += line.length + 1; + continue; + } + + if (!insideCodeFence) { + const headingMatch = line.match(/^(#{1,6})[ \t]+(.+?)\s*$/); + + if (headingMatch) { + headings.push({ + level: headingMatch[1].length, + text: headingMatch[2].trim(), + index: offset, + line, + }); + } + } + + offset += line.length + 1; + } + + return headings; +} + /** * Parse release body into structured category sections and entries. - * Returns { entries: Array, errors: Array } + * + * Returns: + * { + * entries: Array, + * errors: Array + * } */ function parseReleaseBody(body) { const entries = []; const errors = []; - if (!body || typeof body !== "string") { + if (!body || typeof body !== "string" || !body.trim()) { + errors.push({ + section: null, + pr: null, + title: null, + message: + "Release contains zero parsed entries. At least one valid release entry is required.", + }); + return { entries, errors }; } - // Remove HTML comments (like template guidance) - const cleanBody = body.replace(/<!--[\s\S]*?-->/g, ""); - - // Find all H3 headers: ### Header - const h3Regex = /^###\s+(.+)$/gm; - let match; - const sectionIndices = []; - while ((match = h3Regex.exec(cleanBody)) !== null) { - sectionIndices.push({ - header: match[1].trim(), - index: match.index, - headerLength: match[0].length, + // Remove template instructions and examples. + const cleanBody = removeHtmlComments(body); + + // Get Markdown headings while respecting fenced code blocks. + const headings = getMarkdownHeadings(cleanBody); + + // ------------------------------------------------------------------------- + // Validate heading structure + // ------------------------------------------------------------------------- + + for (const heading of headings) { + /* + * H3 headings represent release categories. + * Any H3 that is not a supported category is invalid. + */ + if (heading.level === 3) { + if (!isSupportedCategoryHeading(heading.text)) { + errors.push({ + section: null, + pr: null, + title: heading.text, + message: `Unsupported category heading "### ${heading.text}". Expected one of: Features, Enhancements, Bug Fixes, Security, Others.`, + }); + } + + continue; + } + + /* + * H1/H2/H5/H6 headings are never valid in the release structure. + * + * The optional release introduction is allowed, but it must not introduce + * another Markdown heading. + */ + if ( + heading.level === 1 || + heading.level === 2 || + heading.level === 5 || + heading.level === 6 + ) { + errors.push({ + section: null, + pr: null, + title: heading.text, + message: `Invalid heading level "H${heading.level}" for "${heading.text}". Release categories must use "###".`, + }); + } + } + + const categoryHeadings = headings.filter( + (heading) => heading.level === 3 && isSupportedCategoryHeading(heading.text) + ); + + // ------------------------------------------------------------------------- + // Zero-entry validation + // ------------------------------------------------------------------------- + + if (categoryHeadings.length === 0) { + errors.push({ + section: null, + pr: null, + title: null, + message: + "Release contains zero parsed entries. At least one valid release entry is required.", }); + + return { entries, errors }; } - for (let i = 0; i < sectionIndices.length; i++) { - const sectionHeader = sectionIndices[i].header; - // Strip leading emojis and symbols (non-alphanumeric/non-space prefix) - const cleanHeaderName = sectionHeader - .replace(/^[\p{Extended_Pictographic}\s\W]+/u, "") - .trim(); - const categoryKey = (cleanHeaderName || sectionHeader).toLowerCase(); - const category = CATEGORY_MAP[categoryKey]; + // ------------------------------------------------------------------------- + // Parse each supported category + // ------------------------------------------------------------------------- + + for (let i = 0; i < categoryHeadings.length; i++) { + const categoryHeading = categoryHeadings[i]; + const category = getCategoryFromHeading(categoryHeading.text); + + const categoryStart = + categoryHeading.index + categoryHeading.text.length + 4; + + /* + * Find the next H3 heading. + * + * Unsupported H3 headings have already been reported as validation + * errors, so they still act as section boundaries. + */ + const nextH3 = headings.find( + (heading) => heading.level === 3 && heading.index > categoryHeading.index + ); - // Ignore unsupported sections - if (!category) continue; + const categoryEnd = nextH3 ? nextH3.index : cleanBody.length; - const start = sectionIndices[i].index + sectionIndices[i].headerLength; - const end = - i + 1 < sectionIndices.length - ? sectionIndices[i + 1].index - : cleanBody.length; - const sectionContent = cleanBody.substring(start, end).trim(); + const sectionContent = cleanBody + .substring(categoryStart, categoryEnd) + .trim(); - // Check for empty categories or leftover "Entry" placeholder if (!sectionContent) { errors.push({ section: category, pr: null, title: null, - message: `Empty section for category "${sectionHeader}". Unused categories must be removed.`, + message: `Empty section for category "${categoryHeading.text}". Unused categories must be removed.`, }); + continue; } @@ -178,64 +368,99 @@ function parseReleaseBody(body) { section: category, pr: null, title: null, - message: `Leftover placeholder "Entry" found in section "${sectionHeader}". Unused categories must be removed.`, + message: `Leftover placeholder "Entry" found in section "${categoryHeading.text}". Unused categories must be removed.`, }); + continue; } - // Find all H4 headers inside this section - const h4Regex = /^####\s+(.+)$/gm; - let h4Match; - const h4Indices = []; - while ((h4Match = h4Regex.exec(sectionContent)) !== null) { - h4Indices.push({ - headerLine: h4Match[1].trim(), - fullHeader: h4Match[0], - index: h4Match.index, - headerLength: h4Match[0].length, - }); + // ----------------------------------------------------------------------- + // Find H4 entries within this category + // ----------------------------------------------------------------------- + + const sectionHeadings = getMarkdownHeadings(sectionContent); + + const h4Headings = sectionHeadings.filter((heading) => heading.level === 4); + + /* + * Any heading other than H4 inside a category is invalid. + * + * H3 is handled separately because it represents the next category. + */ + for (const heading of sectionHeadings) { + if (heading.level !== 4) { + errors.push({ + section: category, + pr: null, + title: heading.text, + message: `Invalid heading level "H${heading.level}" found inside "${categoryHeading.text}". Entries must use "####".`, + }); + } } - if (h4Indices.length === 0) { + if (h4Headings.length === 0) { errors.push({ section: category, pr: null, title: null, - message: `Malformed or empty entry in section "${sectionHeader}". Expected heading starting with "#### #<PR_NUMBER> | <TITLE>".`, + message: `Malformed or empty entry in section "${categoryHeading.text}". Expected heading starting with "#### #<PR_NUMBER> | <TITLE>".`, }); + continue; } - const textBeforeH4 = sectionContent.substring(0, h4Indices[0].index).trim(); - if (textBeforeH4.length > 0) { + // ----------------------------------------------------------------------- + // Validate content before first entry + // ----------------------------------------------------------------------- + + const firstEntryStart = h4Headings[0].index; + const textBeforeH4 = sectionContent.substring(0, firstEntryStart).trim(); + + if (textBeforeH4) { if (/^Entry$/i.test(textBeforeH4)) { errors.push({ section: category, pr: null, title: null, - message: `Leftover placeholder "Entry" found before entry in section "${sectionHeader}".`, + message: `Leftover placeholder "Entry" found before entry in section "${categoryHeading.text}".`, }); } else { errors.push({ section: category, pr: null, title: null, - message: `Malformed content before entry in section "${sectionHeader}": "${textBeforeH4.substring(0, 40)}..."`, + message: `Malformed content before entry in section "${categoryHeading.text}": "${textBeforeH4.substring( + 0, + 80 + )}${textBeforeH4.length > 80 ? "..." : ""}"`, }); } } - for (let j = 0; j < h4Indices.length; j++) { - const h4Header = h4Indices[j].headerLine; - const blockStart = h4Indices[j].index + h4Indices[j].headerLength; + // ----------------------------------------------------------------------- + // Parse individual entries + // ----------------------------------------------------------------------- + + for (let j = 0; j < h4Headings.length; j++) { + const h4Heading = h4Headings[j]; + + const blockStart = h4Heading.index + h4Heading.line.length; + const blockEnd = - j + 1 < h4Indices.length - ? h4Indices[j + 1].index + j + 1 < h4Headings.length + ? h4Headings[j + 1].index : sectionContent.length; + const blockBody = sectionContent.substring(blockStart, blockEnd).trim(); - // Validate header format: #<PR_NUMBER> | <TITLE> + const h4Header = h4Heading.text; + + // --------------------------------------------------------------- + // Validate H4 format: #<PR_NUMBER> | <TITLE> + // --------------------------------------------------------------- + const headerMatch = h4Header.match(/^#([^\s|]*)\s*\|\s*(.*)$/); + let prNumber = null; let title = null; @@ -250,20 +475,15 @@ function parseReleaseBody(body) { const prRaw = headerMatch[1].trim(); title = headerMatch[2].trim(); - if ( - !prRaw || - isNaN(prRaw) || - parseInt(prRaw, 10) <= 0 || - !/^\d+$/.test(prRaw) - ) { + if (!prRaw || !/^\d+$/.test(prRaw) || Number.parseInt(prRaw, 10) <= 0) { errors.push({ section: category, pr: prRaw || null, title: title || null, - message: `Invalid PR number "${prRaw}" in entry heading "#### ${h4Header}". PR number must be a positive integer.`, + message: `Invalid PR number "${prRaw}". PR number must be a positive integer.`, }); } else { - prNumber = parseInt(prRaw, 10); + prNumber = Number.parseInt(prRaw, 10); } if (!title) { @@ -276,68 +496,163 @@ function parseReleaseBody(body) { } } - // Extract Contributors (format: > Contributors: @user1 @user2 or **Contributors:** @user1 @user2) - const contribMatch = blockBody.match( - /^(?:>\s*)?(?:\*\*)?Contributors:(?:\*\*)?\s*(.*)$/im - ); - let contributors = []; + // --------------------------------------------------------------- + // Validate Contributors fields + // --------------------------------------------------------------- + + const lines = blockBody.split(/\r?\n/); + + const contributorLineIndices = []; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; - if (!contribMatch) { + /* + * Any line containing "Contributors:" is treated as an attempt + * to define the Contributors field. + * + * This lets us detect duplicate/malformed Contributors fields. + */ + if (/Contributors:/i.test(line)) { + contributorLineIndices.push(lineIndex); + } + } + + if (contributorLineIndices.length === 0) { errors.push({ section: category, pr: prNumber, - title: title, - message: `Missing required Contributors field.`, + title, + message: "Missing required Contributors field.", }); - } else { - const contribLine = contribMatch[1].trim(); - if (!contribLine) { + } else if (contributorLineIndices.length > 1) { + errors.push({ + section: category, + pr: prNumber, + title, + message: + "Duplicate Contributors fields found. Each entry must contain exactly one Contributors field.", + }); + } + + let contributors = []; + let description = ""; + + if (contributorLineIndices.length > 0) { + const contributorLineIndex = contributorLineIndices[0]; + const contributorLine = lines[contributorLineIndex]; + + /* + * Enforce the exact required format: + * + * > Contributors: @username + * + * Multiple usernames are allowed: + * + * > Contributors: @user1 @user2 + */ + const contributorMatch = contributorLine.match( + /^> Contributors: (.+)$/ + ); + + if (!contributorMatch) { errors.push({ section: category, pr: prNumber, - title: title, - message: `Empty Contributors field.`, + title, + message: + 'Invalid Contributors field. Expected exact format "> Contributors: @username".', }); } else { - const tokens = contribLine.split(/\s+/).filter(Boolean); - const validContributors = []; - - for (const token of tokens) { - if (!token.startsWith("@")) { - errors.push({ - section: category, - pr: prNumber, - title: title, - message: `Invalid contributor "${token}". All contributor values must use GitHub username syntax beginning with "@".`, - }); - } else { - const username = token.slice(1).trim(); - if (!username) { + const contributorValue = contributorMatch[1].trim(); + + if (!contributorValue) { + errors.push({ + section: category, + pr: prNumber, + title, + message: "Empty Contributors field.", + }); + } else { + const tokens = contributorValue.split(/\s+/); + const validContributors = []; + + for (const token of tokens) { + if (!/^@[A-Za-z0-9-]+$/.test(token)) { errors.push({ section: category, pr: prNumber, - title: title, - message: `Invalid contributor username "${token}".`, + title, + message: `Invalid contributor "${token}". Contributors must use GitHub username syntax beginning with "@".`, }); - } else { - validContributors.push({ username }); + + continue; } + + validContributors.push({ + username: token.slice(1), + }); } - } - if (validContributors.length > 0) { contributors = validContributors; } } - } - // Extract description - let description = ""; - if (contribMatch) { - const contribIdx = blockBody.indexOf(contribMatch[0]); - description = blockBody.substring(0, contribIdx).trim(); + // ------------------------------------------------------------- + // Description + // ------------------------------------------------------------- + + const descriptionLines = lines + .slice(0, contributorLineIndex) + .join("\n") + .trim(); + + description = descriptionLines; + + if (!description) { + errors.push({ + section: category, + pr: prNumber, + title, + message: "Missing description.", + }); + } + + // ------------------------------------------------------------- + // Content after Contributors + // ------------------------------------------------------------- + + const contentAfterContributor = lines + .slice(contributorLineIndex + 1) + .join("\n") + .trim(); + + if (contentAfterContributor) { + errors.push({ + section: category, + pr: prNumber, + title, + message: + "Unexpected content found after the Contributors field. The Contributors field must be the last content in the entry.", + }); + } } else { - description = blockBody.trim(); + /* + * No Contributors field exists. + * + * We still validate whether there is a description so that the + * resulting error is meaningful. + */ + description = blockBody; + + if (!description) { + errors.push({ + section: category, + pr: prNumber, + title, + message: "Missing description.", + }); + } } entries.push({ @@ -350,6 +665,20 @@ function parseReleaseBody(body) { } } + // ------------------------------------------------------------------------- + // Final validation + // ------------------------------------------------------------------------- + + if (entries.length === 0) { + errors.push({ + section: null, + pr: null, + title: null, + message: + "Release contains zero parsed entries. At least one valid release entry is required.", + }); + } + return { entries, errors }; } @@ -359,17 +688,25 @@ function parseReleaseBody(body) { async function validateGitHubEntities(entries, owner, repo, token, cache = {}) { const errors = []; + const prCache = cache.prCache || new Map(); const userCache = cache.userCache || new Map(); + // ------------------------------------------------------------------------- // Validate PR numbers + // ------------------------------------------------------------------------- + for (const entry of entries) { const { category, prNumber, title } = entry; - if (!prNumber) continue; + + if (!prNumber) { + continue; + } if (!prCache.has(prNumber)) { try { await githubFetch(`/repos/${owner}/${repo}/pulls/${prNumber}`, token); + prCache.set(prNumber, true); } catch { prCache.set(prNumber, false); @@ -380,24 +717,34 @@ async function validateGitHubEntities(entries, owner, repo, token, cache = {}) { errors.push({ section: category, pr: prNumber, - title: title, + title, message: `PR #${prNumber} could not be found or fetched from repository "${owner}/${repo}".`, }); } } - // Validate Contributor Usernames + // ------------------------------------------------------------------------- + // Validate contributor usernames + // ------------------------------------------------------------------------- + for (const entry of entries) { const { category, prNumber, title, contributors } = entry; - if (!contributors) continue; - for (const c of contributors) { - const { username } = c; - if (!username) continue; + if (!contributors || contributors.length === 0) { + continue; + } + + for (const contributor of contributors) { + const { username } = contributor; + + if (!username) { + continue; + } if (!userCache.has(username)) { try { await githubFetch(`/users/${encodeURIComponent(username)}`, token); + userCache.set(username, true); } catch { userCache.set(username, false); @@ -408,7 +755,7 @@ async function validateGitHubEntities(entries, owner, repo, token, cache = {}) { errors.push({ section: category, pr: prNumber, - title: title, + title, message: `Invalid contributor "@${username}". GitHub user could not be found.`, }); } @@ -418,14 +765,30 @@ async function validateGitHubEntities(entries, owner, repo, token, cache = {}) { return errors; } +// --------------------------------------------------------------------------- +// Validation error formatting +// --------------------------------------------------------------------------- + function formatValidationErrors(errors) { let output = "Release validation failed:\n"; - errors.forEach((err, idx) => { + + errors.forEach((err, index) => { const header = err.section - ? `${err.section} → ${err.pr ? `PR #${err.pr}` : err.title ? `Entry "${err.title}"` : "Entry"}` - : "Entry"; - output += `\n${idx + 1}. ${header}\n ${err.message}\n`; + ? `${err.section} → ${ + err.pr + ? `PR #${err.pr}` + : err.title + ? `Entry "${err.title}"` + : "Entry" + }` + : err.title + ? `Entry "${err.title}"` + : "Release"; + + output += `\n${index + 1}. ${header}\n`; + output += ` ${err.message}\n`; }); + return output; } @@ -435,11 +798,14 @@ function formatValidationErrors(errors) { async function syncRelease() { const config = getConfig(); + const { GITHUB_TOKEN, MONGO_URL, RELEASE_TAG, owner, repo } = config; // 1. Connect to MongoDB log("Connecting to MongoDB..."); + await mongoose.connect(MONGO_URL); + log("Connected to MongoDB."); try { @@ -456,6 +822,7 @@ async function syncRelease() { ); const tagName = (ghRelease.tag_name || RELEASE_TAG || "").trim(); + const version = (ghRelease.name || RELEASE_TAG || "").trim(); if (!version || !tagName) { @@ -466,11 +833,11 @@ async function syncRelease() { const body = ghRelease.body || ""; - // 3. Parse release entries and structure + // 3. Parse and validate release structure const { entries: parsedEntries, errors: parseErrors } = parseReleaseBody(body); - // 4. Validate GitHub entities (PRs and users) + // 4. Validate GitHub entities const githubErrors = await validateGitHubEntities( parsedEntries, owner, @@ -480,15 +847,20 @@ async function syncRelease() { const allErrors = [...parseErrors, ...githubErrors]; + // 5. Fail before touching MongoDB if validation fails if (allErrors.length > 0) { - const formattedErr = formatValidationErrors(allErrors); - error(formattedErr); - throw new Error("Release validation failed."); + const formattedErrors = formatValidationErrors(allErrors); + + error(formattedErrors); + + throw new Error( + "Release validation failed. See the validation errors above." + ); } log(`Validated ${parsedEntries.length} release entries successfully.`); - // 5. Build release payload + // 6. Build release payload const releasePayload = { version, tagName, @@ -498,14 +870,19 @@ async function syncRelease() { entries: parsedEntries, }; - // 6. Atomic upsert into MongoDB using { version } + // 7. Atomic upsert into MongoDB using { version } log( `Upserting release version="${version}" (tagName="${tagName}") into MongoDB...` ); + const saved = await Release.findOneAndUpdate( { version }, { $set: releasePayload }, - { upsert: true, new: true, runValidators: true } + { + upsert: true, + new: true, + runValidators: true, + } ); log( @@ -531,11 +908,19 @@ if (require.main === module) { }) .catch((err) => { error(`Sync failed: ${err.message}`); - if (err.stack) console.error(err.stack); + + if (err.stack) { + console.error(err.stack); + } + process.exit(1); }); } +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + module.exports = { syncRelease, parseReleaseBody,