diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md new file mode 100644 index 000000000..e652fab95 --- /dev/null +++ b/.github/RELEASE_TEMPLATE.md @@ -0,0 +1,79 @@ + + +[OPTIONAL RELEASE INTRODUCTION] + +### πŸ“ Features + +#### #104 | Example Entry Title 1 + +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 + +Entry + + +### πŸ“ Bug Fixes + +Entry + + +### πŸ“ Security + +Entry + + +### πŸ“ Others + +Entry \ No newline at end of file diff --git a/.github/workflows/sync-historical-releases.yaml b/.github/workflows/sync-historical-releases.yaml new file mode 100644 index 000000000..211eb0e22 --- /dev/null +++ b/.github/workflows/sync-historical-releases.yaml @@ -0,0 +1,24 @@ +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 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 diff --git a/.github/workflows/sync-releases.yaml b/.github/workflows/sync-releases.yaml new file mode 100644 index 000000000..cd026516b --- /dev/null +++ b/.github/workflows/sync-releases.yaml @@ -0,0 +1,27 @@ +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: Install dependencies + run: npm install mongoose --legacy-peer-deps + + - 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/releases/syncNewReleases.js diff --git a/packages/app/__tests__/controllers/release.test.js b/packages/app/__tests__/controllers/release.test.js new file mode 100644 index 000000000..e605a5d3b --- /dev/null +++ b/packages/app/__tests__/controllers/release.test.js @@ -0,0 +1,155 @@ +"use strict"; + +const request = require("supertest"); +const app = require("../../server"); +const ReleaseService = require("../../services/release"); + +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", () => { + it("returns 200 with paginated releases data", 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).toBeUndefined(); + 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 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.", + }); + }); + }); + + 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 new file mode 100644 index 000000000..9a725c91b --- /dev/null +++ b/packages/app/__tests__/repositories/release.test.js @@ -0,0 +1,197 @@ +"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", + tagName: "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 = { + tagName: "v1.1.0", + 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", + tagName: "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 = { + tagName: "v1.2.0", + 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-p", + tagName: "v1.0.0-p", + releaseDate: new Date("2026-08-01"), + githubReleaseId: 1001, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.0.0-p", + entries: [], + }, + { + version: "v2.0.0-p", + tagName: "v2.0.0-p", + releaseDate: new Date("2026-09-01"), + githubReleaseId: 1002, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v2.0.0-p", + entries: [], + }, + { + version: "v1.5.0-p", + tagName: "v1.5.0-p", + releaseDate: new Date("2026-08-15"), + githubReleaseId: 1003, + githubReleaseUrl: "https://github.com/org/repo/releases/tag/v1.5.0-p", + 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-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-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__/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 new file mode 100644 index 000000000..b729d4292 --- /dev/null +++ b/packages/app/__tests__/scripts/syncReleases.test.js @@ -0,0 +1,1076 @@ +"use strict"; + +const mongoose = require("mongoose"); +const Release = require("../../models/release"); +const { + syncRelease, + parseReleaseBody, + validateGitHubEntities, + formatValidationErrors, +} = require("../../scripts/releases/syncNewReleases"); + +describe("syncNewReleases 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. Parses one category, one entry, and one contributor with emoji", () => { + 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. Allows an optional release introduction before the first category", () => { + const body = ` +Welcome to version 0.8.0! This release introduces major UI improvements. + +### πŸ“ 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. Parses multiple entries in one category", () => { + 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("4. Parses 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("5. Parses all supported categories", () => { + const body = ` +### πŸ“ Features + +#### #1042 | Revamp USER Dashboard + +Description. + +> Contributors: @personA + +### πŸ“ Enhancements + +#### #1052 | Improve Release Page + +Description. + +> Contributors: @personA + +### πŸ“ Bug Fixes + +#### #1050 | Fix Login Crash + +Description. + +> Contributors: @personC + +### πŸ“ Security + +#### #1044 | Add 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((entry) => entry.category)).toEqual([ + "Feature", + "Enhancement", + "Bug Fix", + "Security", + "Other", + ]); + }); + + 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 + +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 } = 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("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 | Add Search + +> Contributors: @personA +`; + + 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); + }); + + it("17. Rejects missing Contributors field", () => { + const body = ` +### πŸ“ Features + +#### #1042 | Add Search + +Description without contributors. +`; + + 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 + +#### #1042 | Add Search + +Description. + +> Contributors: @personA + +> Contributors: @personB +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => + /Duplicate Contributors fields/i.test(error.message) + ) + ).toBe(true); + }); + + it("19. Rejects content after Contributors field", () => { + const body = ` +### πŸ“ Features + +#### #1042 | Add Search + +Description. + +> Contributors: @personA + +Unexpected content. +`; + + 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("20. Allows another entry after the previous Contributors field", () => { + const body = ` +### πŸ“ Features + +#### #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 + +#### #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.some((error) => /Invalid entry heading/i.test(error.message)) + ).toBe(true); + }); + + it("26. Rejects invalid PR number", () => { + const body = ` +### πŸ“ Features + +#### #-10 | Invalid PR + +Description. + +> Contributors: @personA +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + expect( + errors.some((error) => /Invalid PR number/i.test(error.message)) + ).toBe(true); + }); + + it("27. Rejects missing title", () => { + const body = ` +### πŸ“ Features + +#### #1042 | + +Description. + +> Contributors: @personA +`; + + const { errors } = parseReleaseBody(body); + + expect(errors.length).toBeGreaterThan(0); + 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 validation errors into a 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("29. Rejects 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("30. Rejects 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 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 = ` +Welcome to 0.8.0 release. + +### πŸ“ 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, + name, + 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(); + + if (mongoose.connection.readyState === 0) { + await mongoose.connect(mongoUri); + } + + 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, + title: "Revamp USER Dashboard", + contributors: [{ username: "personA" }, { username: "personB" }], + }); + }); + + 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, + 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}`, + 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: "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/__tests__/services/release.test.js b/packages/app/__tests__/services/release.test.js new file mode 100644 index 000000000..e79e9ade7 --- /dev/null +++ b/packages/app/__tests__/services/release.test.js @@ -0,0 +1,98 @@ +"use strict"; + +const ReleaseService = require("../../services/release"); +const { ReleaseRepository } = require("../../repositories"); + +jest.mock("../../repositories"); + +describe("ReleaseService", () => { + let 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(); + }); + + describe("getReleaseByVersion", () => { + it("queries ReleaseRepository for version", 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 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); + }); + }); + + 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 new file mode 100644 index 000000000..0549684a5 --- /dev/null +++ b/packages/app/controllers/release.js @@ -0,0 +1,78 @@ +const { STATUS_CODES } = require("node:http"); +const { ReleaseService } = require("../services"); + +/** + * 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, + }, + }); + } catch (err) { + next(err); + } +} + +/** + * 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/models/release.js b/packages/app/models/release.js new file mode 100644 index 000000000..4fff7a1c7 --- /dev/null +++ b/packages/app/models/release.js @@ -0,0 +1,95 @@ +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, + required: true, + trim: true, + }, + }, + { _id: false } +); + +const releaseEntrySchema = new mongoose.Schema( + { + category: { + type: String, + enum: ["Feature", "Enhancement", "Bug Fix", "Security", "Other"], + required: true, + }, + + prNumber: { + type: Number, + default: null, + }, + + title: { + type: String, + required: true, + trim: true, + }, + + description: { + type: String, + default: "", + trim: true, + }, + + contributors: { + type: [contributorSchema], + default: null, + }, + }, + { _id: false } +); + +const releaseSchema = new mongoose.Schema( + { + version: { + type: String, + required: true, + unique: true, + trim: true, + }, + tagName: { + type: String, + required: 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/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/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/repositories/release.js b/packages/app/repositories/release.js new file mode 100644 index 000000000..08bd10a21 --- /dev/null +++ b/packages/app/repositories/release.js @@ -0,0 +1,73 @@ +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), + }; + } + + /** + * Return a lightweight list of all releases containing only version and releaseDate in reverse-chronological order. + * @returns {Promise>} + */ + async getAllVersions() { + return await this.model + .find({}) + .sort({ releaseDate: -1 }) + .select("version releaseDate -_id") + .lean(); + } +} + +module.exports = ReleaseRepository; diff --git a/packages/app/routes/index.js b/packages/app/routes/index.js index 83bb2a615..0f60f6f39 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", cors(privateRouteCORS), releasesRouter); + module.exports = router; diff --git a/packages/app/routes/releases.js b/packages/app/routes/releases.js new file mode 100644 index 000000000..eca8b0b68 --- /dev/null +++ b/packages/app/routes/releases.js @@ -0,0 +1,26 @@ +const router = require("express").Router(); +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) + * + * GET /api/releases/:version + * Returns a single release by version tag (e.g. "1.2.0"). + * Returns 404 if the version does not exist. + * + * 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 new file mode 100644 index 000000000..a38052dab --- /dev/null +++ b/packages/app/scripts/releases/historicalReleasesData.js @@ -0,0 +1,756 @@ +/** + * 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: "User Authentication System", + description: + "Includes signup, sign in, logout, and forgot-password functionality.", + contributors: null, + }, + { + category: "Feature", + prNumber: null, + title: "User 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", + 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: "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" }], + }, + { + category: "Enhancement", + prNumber: 345, + 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" }], + }, + { + category: "Enhancement", + prNumber: 350, + 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" }], + }, + { + category: "Feature", + prNumber: 347, + 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" }], + }, + { + category: "Enhancement", + prNumber: 358, + 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" }], + }, + { + category: "Bug Fix", + prNumber: 360, + 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" }], + }, + { + category: "Bug Fix", + prNumber: 363, + 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" }], + }, + { + category: "Feature", + prNumber: 365, + 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" }], + }, + { + category: "Feature", + prNumber: 357, + 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" }], + }, + { + category: "Feature", + prNumber: 366, + title: "Logo Search API", + 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: "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" }], + }, + { + category: "Enhancement", + prNumber: 373, + 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" }], + }, + ], + }, + + { + 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: "Enhancement", + prNumber: 389, + 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: "Enhancement", + prNumber: 397, + 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" }], + }, + { + category: "Feature", + prNumber: 413, + 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: "Feature", + prNumber: 415, + 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: "Feature", + prNumber: 448, + title: "Add Site Footer Component", + description: + "Adds a responsive footer with navigation links, OpenLogo branding, copyright information, and TeamShiksha attribution.", + contributors: [{ username: "001AM" }], + }, + { + category: "Enhancement", + prNumber: 462, + 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: "Enhancement", + prNumber: 456, + 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: "Feature", + prNumber: 463, + 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: "Feature", + prNumber: 450, + 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: "Bug Fix", + prNumber: 495, + 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: "Enhancement", + prNumber: 515, + 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: "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: "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: "Enhancement", + prNumber: 611, + 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" }], + }, + { + category: "Security", + prNumber: 597, + 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: "Bug Fix", + prNumber: 613, + 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: "Feature", + prNumber: 612, + 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: "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: "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: "Enhancement", + prNumber: 638, + 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: "Feature", + prNumber: 658, + 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: "Feature", + prNumber: 664, + 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: "Feature", + prNumber: 642, + 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: "Security", + prNumber: 673, + 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: "Bug Fix", + prNumber: 676, + 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: "Bug Fix", + prNumber: 678, + 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: "Bug Fix", + prNumber: 688, + 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: "Enhancement", + prNumber: 690, + 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: "Enhancement", + prNumber: 667, + 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: "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" }], + }, + { + category: "Other", + prNumber: 781, + 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" }], + }, + ], + }, + + { + 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: "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: "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: "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: "Bug Fix", + prNumber: 884, + 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: "Feature", + prNumber: 851, + 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" }, + ], + }, + { + category: "Feature", + prNumber: 876, + 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: "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: "Feature", + prNumber: 859, + 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" }], + }, + ], + }, + + { + 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: 896, + title: "API Usage Graph", + description: + "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" }, + { username: "YashDevani-source" }, + ], + }, + { + category: "Security", + prNumber: 893, + title: "Automatic API Key Expiration", + description: + "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" }, + ], + }, + { + category: "Feature", + prNumber: 897, + title: "Automatic Catalog Creation", + description: + "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" }, + ], + }, + { + category: "Feature", + prNumber: 891, + title: "Release Page", + description: + "Introduced a dedicated release page with version timelines, release information, contributors, and structured release notes.", + contributors: [{ username: "abhishek-2k23" }], + }, + { + category: "Enhancement", + prNumber: 876, + title: "Resend Verification Email", + description: + "Added backend support for resending verification emails, including token refresh, rate limiting, and integration with signin and verification flows.", + 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: 947, + title: "Migrate Authentication To Secure Sessions", + description: + "Authentication was migrated from JWT-based authentication to server-managed sessions, including session creation, validation, and invalidation.", + contributors: [ + { username: "Mantu01" }, + { username: "Smayur0" }, + { username: "printgourav" }, + ], + }, + { + category: "Enhancement", + prNumber: 934, + title: "Dynamic Light And Dark Themes", + description: + "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: 948, + title: "Notifications For API Expiry And Usage Limits", + description: + "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" }, + ], + }, + { + category: "Feature", + prNumber: 935, + title: "Custom Logo Creation", + description: + "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" }, + ], + }, + { + category: "Security", + prNumber: 946, + title: "API Key Masking", + description: + "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" }], + }, + ], + }, + + { + 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: 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: 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: 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: 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: 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: 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: 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: 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" }, + ], + }, + { + category: "Other", + 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: 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: 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" }], + }, + ], + }, +]; + +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..c56732705 --- /dev/null +++ b/packages/app/scripts/releases/seedHistoricalReleases.js @@ -0,0 +1,82 @@ +/** + * 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"; + +require("dotenv").config(); +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/releases/syncNewReleases.js b/packages/app/scripts/releases/syncNewReleases.js new file mode 100644 index 000000000..7bff7c425 --- /dev/null +++ b/packages/app/scripts/releases/syncNewReleases.js @@ -0,0 +1,930 @@ +/** + * 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, validation failure) + */ + +"use strict"; + +const mongoose = require("mongoose"); +const Release = require("../../models/release"); + +// --------------------------------------------------------------------------- +// Logging helpers +// --------------------------------------------------------------------------- + +function log(msg) { + console.log(`[sync-releases] ${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 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, + }; +} + +// --------------------------------------------------------------------------- +// GitHub API helpers +// --------------------------------------------------------------------------- + +/** + * Wraps fetch with GitHub API defaults and error handling. + * Throws on non-2xx responses. + */ +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 + ); +} + +// --------------------------------------------------------------------------- +// Release note parser +// --------------------------------------------------------------------------- + +const CATEGORY_MAP = { + features: "Feature", + enhancements: "Enhancement", + "bug fixes": "Bug Fix", + security: "Security", + 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(//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 + * } + */ +function parseReleaseBody(body) { + const entries = []; + const errors = []; + + 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 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 }; + } + + // ------------------------------------------------------------------------- + // 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 + ); + + const categoryEnd = nextH3 ? nextH3.index : cleanBody.length; + + const sectionContent = cleanBody + .substring(categoryStart, categoryEnd) + .trim(); + + if (!sectionContent) { + errors.push({ + section: category, + pr: null, + title: null, + message: `Empty section for category "${categoryHeading.text}". Unused categories must be removed.`, + }); + + continue; + } + + if (/^Entry$/i.test(sectionContent)) { + errors.push({ + section: category, + pr: null, + title: null, + message: `Leftover placeholder "Entry" found in section "${categoryHeading.text}". Unused categories must be removed.`, + }); + + continue; + } + + // ----------------------------------------------------------------------- + // 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 (h4Headings.length === 0) { + errors.push({ + section: category, + pr: null, + title: null, + message: `Malformed or empty entry in section "${categoryHeading.text}". Expected heading starting with "#### # | ".`, + }); + + continue; + } + + // ----------------------------------------------------------------------- + // 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 "${categoryHeading.text}".`, + }); + } else { + errors.push({ + section: category, + pr: null, + title: null, + message: `Malformed content before entry in section "${categoryHeading.text}": "${textBeforeH4.substring( + 0, + 80 + )}${textBeforeH4.length > 80 ? "..." : ""}"`, + }); + } + } + + // ----------------------------------------------------------------------- + // 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 < h4Headings.length + ? h4Headings[j + 1].index + : sectionContent.length; + + const blockBody = sectionContent.substring(blockStart, blockEnd).trim(); + + const h4Header = h4Heading.text; + + // --------------------------------------------------------------- + // Validate H4 format: #<PR_NUMBER> | <TITLE> + // --------------------------------------------------------------- + + const headerMatch = h4Header.match(/^#([^\s|]*)\s*\|\s*(.*)$/); + + let prNumber = null; + let title = null; + + if (!headerMatch) { + errors.push({ + section: category, + pr: null, + title: h4Header, + message: `Invalid entry heading "#### ${h4Header}". Expected format "#### #<PR_NUMBER> | <TITLE>".`, + }); + } else { + const prRaw = headerMatch[1].trim(); + title = headerMatch[2].trim(); + + 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}". PR number must be a positive integer.`, + }); + } else { + prNumber = Number.parseInt(prRaw, 10); + } + + if (!title) { + errors.push({ + section: category, + pr: prNumber, + title: null, + message: `Missing or empty title in entry heading "#### ${h4Header}".`, + }); + } + } + + // --------------------------------------------------------------- + // Validate Contributors fields + // --------------------------------------------------------------- + + const lines = blockBody.split(/\r?\n/); + + const contributorLineIndices = []; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + + /* + * 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, + message: "Missing required Contributors field.", + }); + } 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, + message: + 'Invalid Contributors field. Expected exact format "> Contributors: @username".', + }); + } else { + 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, + message: `Invalid contributor "${token}". Contributors must use GitHub username syntax beginning with "@".`, + }); + + continue; + } + + validContributors.push({ + username: token.slice(1), + }); + } + + contributors = validContributors; + } + } + + // ------------------------------------------------------------- + // 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 { + /* + * 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({ + category, + prNumber, + title, + description, + contributors, + }); + } + } + + // ------------------------------------------------------------------------- + // 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 }; +} + +// --------------------------------------------------------------------------- +// GitHub Entity Validation (PR existence & User existence) +// --------------------------------------------------------------------------- + +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 (!prCache.get(prNumber)) { + errors.push({ + section: category, + pr: prNumber, + title, + message: `PR #${prNumber} could not be found or fetched from repository "${owner}/${repo}".`, + }); + } + } + + // ------------------------------------------------------------------------- + // Validate contributor usernames + // ------------------------------------------------------------------------- + + for (const entry of entries) { + const { category, prNumber, title, contributors } = entry; + + 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); + } + } + + if (!userCache.get(username)) { + errors.push({ + section: category, + pr: prNumber, + title, + message: `Invalid contributor "@${username}". GitHub user could not be found.`, + }); + } + } + } + + return errors; +} + +// --------------------------------------------------------------------------- +// Validation error formatting +// --------------------------------------------------------------------------- + +function formatValidationErrors(errors) { + let output = "Release validation failed:\n"; + + errors.forEach((err, index) => { + const header = err.section + ? `${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; +} + +// --------------------------------------------------------------------------- +// 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}, 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 and validate release structure + const { entries: parsedEntries, errors: parseErrors } = + parseReleaseBody(body); + + // 4. Validate GitHub entities + const githubErrors = await validateGitHubEntities( + parsedEntries, + owner, + repo, + GITHUB_TOKEN + ); + + const allErrors = [...parseErrors, ...githubErrors]; + + // 5. Fail before touching MongoDB if validation fails + if (allErrors.length > 0) { + const formattedErrors = formatValidationErrors(allErrors); + + error(formattedErrors); + + throw new Error( + "Release validation failed. See the validation errors above." + ); + } + + log(`Validated ${parsedEntries.length} release entries successfully.`); + + // 6. Build release payload + const releasePayload = { + version, + tagName, + releaseDate: new Date(ghRelease.published_at), + githubReleaseId: ghRelease.id, + githubReleaseUrl: ghRelease.html_url, + entries: parsedEntries, + }; + + // 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, + } + ); + + log( + `Successfully synced release "${version}" (tagName="${tagName}") (MongoDB _id: ${saved._id}).` + ); + } finally { + if (process.env.NODE_ENV !== "test") { + 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); + }); +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +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, }; diff --git a/packages/app/services/release.js b/packages/app/services/release.js new file mode 100644 index 000000000..3069d3903 --- /dev/null +++ b/packages/app/services/release.js @@ -0,0 +1,44 @@ +const { ReleaseRepository } = require("../repositories"); + +/** + * ReleaseService: Read-only service layer for the releases API. + * + * 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() { + 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. + * Queries MongoDB directly. + * @param {string} version - e.g. "0.8.0" or "v1.2.0" + * @returns {Promise<Object|null>} + */ + 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;