diff --git a/.dockerignore b/.dockerignore index 270ab33..41e5a20 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,7 @@ node_modules +.output +.nuxt +dist Dockerfile* docker-compose* .dockerignore diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5660f81 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2000a55..f1d80d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ WORKDIR /app # this will cache them and speed up future builds FROM base AS install COPY package.json bun.lock ./ +COPY vendor ./vendor COPY . . ENV npm_config_optional=true ENV npm_config_platform=linux diff --git a/README.md b/README.md index 4132010..079333a 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,14 @@ - [x] List Issue from versions - [x] New Dev Tracker (Program Spec / Defect / Feature) - [x] Set your own access Token -- [ ] New Build Tracker (Build Request) +- [x] New Build Common Tracker (Build Request) - [x] Add Unit Test (Initial) - [x] Add API admin/release/thisweek-release to get current week release data (Version with due date in current week) and send to Line Notify - [x] Add API admin/release/send-release-mail to send current week release data to specific email address - [x] Add Admin Page to manage Release Notii Email Template (Subject / Body) with some variable such as {{versionName}} / {{versionDueDate}} / {{versionIssues}} (List of Issues in Version with name and tracker type) - [x] Add Simple Auth with password (No Register / No User Management) to protect Admin Page +- [x] Add API to List Branches from GitLab and Cache in Memory with Incremental Sync (Pull Latest 100 Events and Merge to Cache) +- [x] Add Admin Page to List Branches with Pagination (PER_PAGE = 5) and Search (Search by Branch Name) # Plan List @@ -29,6 +31,7 @@ - Refactor Code eq. remove hardcode to config such as List Versions (On Specific Project Id) / Tracker Template with some hardcode id of custom field - Add MCP - Add Chat +- Add Build Dotnet Set Build Tracker with some custom field such as Target Version / Git Branch / Build Status # Nuxt Minimal Starter @@ -116,14 +119,14 @@ bun add axios ## Build & Run ``` -docker build --pull -t bun-redmine:0.3.0rc15 . +docker build --pull -t bun-redmine:0.3.3rc9 . -docker build --pull -t bun-redmine:0.3.0rc15 . --no-cache --progress=plain +docker build --pull -t bun-redmine:0.3.3rc9 . --no-cache --progress=plain -docker run -d -p 3000:3000 --env-file .\.env --name bun-redmine bun-redmine:0.3.0rc15 +docker run -d -p 3000:3000 --env-file .\.env --name bun-redmine bun-redmine:0.3.3rc9 -docker tag bun-redmine:0.3.0rc15 pingkunga/bun-redmine:0.3.0rc15 -docker push pingkunga/bun-redmine:0.3.0rc15 +docker tag bun-redmine:0.3.3rc9 pingkunga/bun-redmine:0.3.3rc9 +docker push pingkunga/bun-redmine:0.3.3rc9 ``` ## Test diff --git a/app/components/devtrackers/ClientAccessKey.vue b/app/components/devtrackers/ClientAccessKey.vue new file mode 100644 index 0000000..2250bd7 --- /dev/null +++ b/app/components/devtrackers/ClientAccessKey.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/components/sidebar/Sidebar.vue b/app/components/sidebar/Sidebar.vue index 7ebcc5c..b8000fb 100644 --- a/app/components/sidebar/Sidebar.vue +++ b/app/components/sidebar/Sidebar.vue @@ -11,9 +11,13 @@ :key="i" class="mb-1" > - + - {{ item.title }} + {{ item.title }} @@ -25,6 +29,10 @@ Release Mail + + + GitLab Branches + @@ -34,6 +42,15 @@ diff --git a/app/components/sidebar/sidebarItems.ts b/app/components/sidebar/sidebarItems.ts index d0ac9e1..dfd4f59 100644 --- a/app/components/sidebar/sidebarItems.ts +++ b/app/components/sidebar/sidebarItems.ts @@ -19,6 +19,16 @@ export default [ icon: "i-mdi-code-block-braces", to: "/devtrackers", }, + { + title: "New build request\n(NET INV PRODUCT)", + icon: "i-mdi-code-tags-check", + to: "/buildinvset", + }, + { + title: "New Build Request\n(NET COMMON)", + icon: "i-mdi-code-tags-check", + to: "/buildnetcommon", + }, { title: "Client Setting", icon: "i-mdi-window-shutter-cog", diff --git a/app/composables/useBuildInvSetAPI.ts b/app/composables/useBuildInvSetAPI.ts new file mode 100644 index 0000000..04d182e --- /dev/null +++ b/app/composables/useBuildInvSetAPI.ts @@ -0,0 +1,24 @@ +import type { BuildInvSetRequest } from '~/shared/types/BuildInvSet' + +export default function useBuildInvSetAPI() { + const submitBuildInvSet = async (request: BuildInvSetRequest, headers?: Record) => { + const { data, error } = await useFetch('/api/buildinvset', { + method: 'POST', + body: JSON.stringify({ BuildInvSetRequest: request }), + headers: headers, + }) + + if (error.value) { + throw createError({ + statusCode: error.value.statusCode ?? 500, + statusMessage: error.value.statusMessage ?? 'Failed to submit Build InvSet request', + }) + } + + return data.value + } + + return { + submitBuildInvSet, + } +} diff --git a/app/composables/useBuildInvSetRelease.ts b/app/composables/useBuildInvSetRelease.ts new file mode 100644 index 0000000..bdaa22c --- /dev/null +++ b/app/composables/useBuildInvSetRelease.ts @@ -0,0 +1,39 @@ +import type { BuildInvSetRequest } from '~~/shared/types/BuildInvSet' +import type { Version, VersionWithReleaseNotes } from '~~/shared/types/Version' + +const formatDateInput = (date: Date) => date.toISOString().split('T')[0] + +const getDefaultDateRange = () => { + const today = new Date() + return { + startDate: formatDateInput(today), + endDate: formatDateInput(new Date(today.getFullYear(), today.getMonth(), today.getDate() + 4)), + } +} + +export const clearThisWeekReleaseSelection = (formState: Pick) => { + formState.targetVersion = undefined + formState.buildBranch = '' + const { startDate, endDate } = getDefaultDateRange() + formState.startDate = startDate + formState.endDate = endDate +} + +export const applyThisWeekReleaseSelection = ( + formState: Pick, + release: VersionWithReleaseNotes | null | undefined, +) => { + if (!release) { + clearThisWeekReleaseSelection(formState) + return + } + + const releaseDate = release.due_date ? new Date(release.due_date) : new Date() + const startDate = formatDateInput(releaseDate) + const endDate = formatDateInput(new Date(releaseDate.getFullYear(), releaseDate.getMonth(), releaseDate.getDate() + 4)) + + formState.targetVersion = release as Version + formState.buildBranch = release.currentReleaseBranch || '' + formState.startDate = startDate + formState.endDate = endDate +} diff --git a/app/composables/useBuildInvSetTour.ts b/app/composables/useBuildInvSetTour.ts new file mode 100644 index 0000000..eb2b61f --- /dev/null +++ b/app/composables/useBuildInvSetTour.ts @@ -0,0 +1,91 @@ +import { driver } from 'driver.js' +import 'driver.js/dist/driver.css' + +export default function useBuildInvSetTour() { + const startTour = () => { + const driverObj = driver({ + showProgress: true, + animate: true, + steps: [ + { + element: '#tour-title-section', + popover: { + title: 'Build Request — Primary', + description: 'Welcome! This page allows you to create comprehensive build-request issues in Redmine for .NET projects, including optional Gateway and VB6 subtasks.', + side: 'bottom', + align: 'start', + }, + }, + { + element: '#tour-access-key', + popover: { + title: 'Authentication', + description: 'Enter your Redmine Access Key here. You can also toggle "Use Server Token" if you want to use the pre-configured system token instead of your own.', + side: 'bottom', + align: 'start', + }, + }, + { + element: '#tour-build-info', + popover: { + title: 'Build Details', + description: 'Select your layout (template), tracker, target version, and build branch. Use "Thisweek Release" to auto-fill current release information.', + side: 'bottom', + align: 'start', + }, + }, + { + element: '#tour-dotnet-options', + popover: { + title: '.NET Options', + description: 'This is the primary build group. Configure flags like GEN_SBOM, SEND_NOTIFY, and CLEANUP_WS for Windows and Container builds.', + side: 'top', + align: 'start', + }, + }, + { + element: '#tour-gateway-options', + popover: { + title: 'Gateway Subtask', + description: 'Enable this switch if your release needs a Gateway (Spring Boot) subtask created alongside the primary .NET build.', + side: 'top', + align: 'start', + }, + }, + { + element: '#tour-vb6-options', + popover: { + title: 'VB6 Subtask', + description: 'Include a VB6 build subtask if needed by enabling this section.', + side: 'top', + align: 'start', + }, + }, + { + element: '#tour-template-save', + popover: { + title: 'Save Template', + description: 'Check this to save your current configuration as a layout template for future use.', + side: 'top', + align: 'start', + }, + }, + { + element: '#tour-submit-actions', + popover: { + title: 'Final Step', + description: 'Click Submit to create the issues in Redmine. All results and links will be displayed in a toast message upon completion.', + side: 'top', + align: 'center', + }, + }, + ], + }) + + driverObj.drive() + } + + return { + startTour, + } +} diff --git a/app/composables/useBuildInvSetValidation.test.ts b/app/composables/useBuildInvSetValidation.test.ts new file mode 100644 index 0000000..fc4850b --- /dev/null +++ b/app/composables/useBuildInvSetValidation.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { validateBuildInvSetForm } from './useBuildInvSetValidation' + +describe('validateBuildInvSetForm', () => { + it('reports missing required build information fields', () => { + const formState = { + layout: '', + trackerId: 0, + buildPurpose: '', + startDate: '', + endDate: '', + buildBranch: '', + project: { id: 1 }, + selectedAssignee: { id: 2 }, + targetVersion: undefined, + buildDOTNET: { enabled: true }, + buildSpringBoot: { enabled: false }, + buildVB6: { enabled: false }, + } as any + + const result = validateBuildInvSetForm(formState, '') + + expect(result.isValid).toBe(false) + expect(result.errors).toEqual(expect.arrayContaining([ + 'Layout is required', + 'Tracker is required', + 'Target version is required', + 'Build purpose is required', + 'Start date is required', + 'Build branch is required', + ])) + }) + + it('requires project and assignee for enabled gateway and vb6 groups', () => { + const formState = { + layout: 'Default', + trackerId: 10, + buildPurpose: 'Release', + startDate: '2026-06-01', + endDate: '2026-06-02', + buildBranch: 'release/1.0', + project: { id: 1 }, + selectedAssignee: { id: 2 }, + targetVersion: { id: 3 }, + buildDOTNET: { enabled: true }, + buildSpringBoot: { enabled: true, project: {}, selectedAssignee: undefined }, + buildVB6: { enabled: true, project: {}, selectedAssignee: undefined }, + } as any + + const result = validateBuildInvSetForm(formState, 'access-key') + + expect(result.isValid).toBe(false) + expect(result.errors).toEqual(expect.arrayContaining([ + 'Gateway: Please select a Project', + 'Gateway: Please select an Assignee', + 'VB6: Please select a Project', + 'VB6: Please select an Assignee', + ])) + }) + + it('allows a valid form state', () => { + const formState = { + layout: 'Default', + trackerId: 10, + buildPurpose: 'Release', + startDate: '2026-06-01', + endDate: '2026-06-02', + buildBranch: 'release/1.0', + project: { id: 1 }, + selectedAssignee: { id: 2 }, + targetVersion: { id: 3 }, + buildDOTNET: { enabled: true }, + buildSpringBoot: { enabled: false }, + buildVB6: { enabled: false }, + } as any + + const result = validateBuildInvSetForm(formState, 'access-key') + + expect(result.isValid).toBe(true) + expect(result.errors).toEqual([]) + }) +}) diff --git a/app/composables/useBuildInvSetValidation.ts b/app/composables/useBuildInvSetValidation.ts new file mode 100644 index 0000000..da5a491 --- /dev/null +++ b/app/composables/useBuildInvSetValidation.ts @@ -0,0 +1,114 @@ +import { z } from 'zod' + +export interface BuildInvSetValidationResult { + isValid: boolean + errors: string[] +} + +export const buildInvSetFormSchema = z.object({ + layout: z.string().trim().min(1, 'Layout is required'), + trackerId: z.number().int().positive('Tracker is required'), + buildPurpose: z.string().trim().min(1, 'Build purpose is required'), + startDate: z.string().trim().min(1, 'Start date is required'), + endDate: z.string().trim().min(1, 'End date is required'), + buildBranch: z.string().trim().min(1, 'Build branch is required'), + thisweekRelease: z.boolean().optional(), + saveAsTemplate: z.boolean().optional(), + targetVersion: z.object({ id: z.number().int().positive().optional() }).optional(), + project: z.object({ id: z.number().int().positive().optional() }).optional(), + selectedAssignee: z.object({ id: z.number().int().positive().optional() }).optional(), + buildSpringBoot: z.object({ + enabled: z.boolean().optional(), + project: z.object({ id: z.number().int().positive().optional() }).optional(), + selectedAssignee: z.object({ id: z.number().int().positive().optional() }).optional(), + }).optional(), + buildVB6: z.object({ + enabled: z.boolean().optional(), + project: z.object({ id: z.number().int().positive().optional() }).optional(), + selectedAssignee: z.object({ id: z.number().int().positive().optional() }).optional(), + }).optional(), +}).superRefine((value, ctx) => { + if (!value.targetVersion?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['targetVersion'], + message: 'Target version is required', + }) + } + + if (!value.project?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['project'], + message: '.NET: Please select a Project', + }) + } + + if (!value.selectedAssignee?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['selectedAssignee'], + message: '.NET: Please select an Assignee', + }) + } + + if (value.startDate && value.endDate && value.startDate > value.endDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['endDate'], + message: 'Start date cannot be greater than end date', + }) + } + + if (value.buildSpringBoot?.enabled) { + if (!value.buildSpringBoot.project?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['buildSpringBoot.project'], + message: 'Gateway: Please select a Project', + }) + } + + if (!value.buildSpringBoot.selectedAssignee?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['buildSpringBoot.selectedAssignee'], + message: 'Gateway: Please select an Assignee', + }) + } + } + + if (value.buildVB6?.enabled) { + if (!value.buildVB6.project?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['buildVB6.project'], + message: 'VB6: Please select a Project', + }) + } + + if (!value.buildVB6.selectedAssignee?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['buildVB6.selectedAssignee'], + message: 'VB6: Please select an Assignee', + }) + } + } +}) + +export const validateBuildInvSetForm = (formState: any, accessKey: string | null): BuildInvSetValidationResult => { + const result = buildInvSetFormSchema.safeParse(formState) + const errors = result.success + ? [] + : result.error.issues.map((issue) => issue.message) + + if (!accessKey?.trim()) { + errors.push('Access key is required') + } + + return { + isValid: errors.length === 0, + errors, + } +} diff --git a/app/composables/useBuildNetCommonTour.ts b/app/composables/useBuildNetCommonTour.ts new file mode 100644 index 0000000..6917668 --- /dev/null +++ b/app/composables/useBuildNetCommonTour.ts @@ -0,0 +1,64 @@ +import { driver } from 'driver.js' +import 'driver.js/dist/driver.css' + +export default function useBuildNetCommonTour() { + const startTour = () => { + const driverObj = driver({ + showProgress: true, + animate: true, + steps: [ + { + element: '#tour-title-section', + popover: { + title: 'Build Configuration - NET Common', + description: 'Welcome! This page allows you to create a Build-Request issue for .NET Common libraries.', + side: 'bottom', + align: 'start', + }, + }, + { + element: '#tour-access-key', + popover: { + title: 'Authentication', + description: 'Enter your Redmine Access Key here. You can also toggle "Use Server Token" if you want to use the pre-configured system token.', + side: 'bottom', + align: 'start', + }, + }, + { + element: '#tour-build-info', + popover: { + title: 'Build Information', + description: 'Select the tracker, project, assignee, and target version. The version is used as both COMMON_VERSION and TAG_VERSION.', + side: 'top', + align: 'start', + }, + }, + { + element: '#tour-build-options', + popover: { + title: 'Build Options', + description: 'Configure build settings like test execution, SonarQube analysis, and publishing options.', + side: 'top', + align: 'start', + }, + }, + { + element: '#tour-submit-actions', + popover: { + title: 'Final Step', + description: 'Click Submit to create the issue. All results and links will be displayed in a toast message upon completion.', + side: 'top', + align: 'center', + }, + }, + ], + }) + + driverObj.drive() + } + + return { + startTour, + } +} diff --git a/app/composables/useDevTrackersTour.ts b/app/composables/useDevTrackersTour.ts new file mode 100644 index 0000000..e241b1f --- /dev/null +++ b/app/composables/useDevTrackersTour.ts @@ -0,0 +1,64 @@ +import { driver } from 'driver.js' +import 'driver.js/dist/driver.css' + +export default function useDevTrackersTour() { + const startTour = () => { + const driverObj = driver({ + showProgress: true, + animate: true, + steps: [ + { + element: '#tour-title-section', + popover: { + title: 'Dev Trackers', + description: 'Welcome! This page allows you to create Dev Trackers (Program Spec or Defect) directly in Redmine.', + side: 'bottom', + align: 'start', + }, + }, + { + element: '#tour-access-key', + popover: { + title: 'Authentication', + description: 'Enter your Redmine Access Key here. You can also toggle "Use Server Token" if you want to use the pre-configured system token.', + side: 'bottom', + align: 'start', + }, + }, + { + element: '#tour-dev-info', + popover: { + title: 'Tracker Information', + description: 'Select the tracker type, project, assignee, and target version for your issue.', + side: 'top', + align: 'start', + }, + }, + { + element: '#tour-tracker-title', + popover: { + title: 'Tracker Title', + description: 'Provide a title following the format: [SITENAME][MODULE][IMPACT] Title. Hyphens are supported in Site Name and Module.', + side: 'top', + align: 'start', + }, + }, + { + element: '#tour-submit-actions', + popover: { + title: 'Final Step', + description: 'Click Submit to create the issue. All results and links will be displayed in a toast message upon completion.', + side: 'top', + align: 'center', + }, + }, + ], + }) + + driverObj.drive() + } + + return { + startTour, + } +} diff --git a/app/composables/useGitLabAPI.ts b/app/composables/useGitLabAPI.ts new file mode 100644 index 0000000..3cabd75 --- /dev/null +++ b/app/composables/useGitLabAPI.ts @@ -0,0 +1,30 @@ +import type { GitLabProject, GitLabBranch, GitLabSyncResult } from "~~/shared/types/GitLab"; + +export default () => { + const fetchGitLabProjects = async () => { + return await useFetch("/api/gitlab/projects"); + }; + + const fetchGitLabBranches = async (projectId: number) => { + return await useFetch("/api/gitlab/branches", { + query: { projectId }, + }); + }; + + const syncGitLabEvents = async (projectId: number, after: string, before: string) => { + return await $fetch("/api/gitlab/sync-events", { + method: "POST", + body: { + projectId, + after, + before, + }, + }); + }; + + return { + fetchGitLabProjects, + fetchGitLabBranches, + syncGitLabEvents, + }; +}; diff --git a/app/composables/useRedmineAPI.ts b/app/composables/useRedmineAPI.ts index 30c3bf8..c5802ce 100644 --- a/app/composables/useRedmineAPI.ts +++ b/app/composables/useRedmineAPI.ts @@ -42,7 +42,7 @@ export default () => { const versionShares : string[] = [versionShareType.NONE, versionShareType.DESCENDANTS, versionShareType.HIERARCHY, versionShareType.TREE, versionShareType.SYSTEM]; // - const addVersion = async(version: Version) => { + const addVersion = async(version: Version, projectId?: number) => { const body = { version: { name: version.name, @@ -50,7 +50,8 @@ export default () => { sharing: version.sharing, due_date: version.due_date, description: version.description - } + }, + projectId: projectId }; return await useFetch("/api/versions", { @@ -125,9 +126,15 @@ export default () => { return await useFetch("/api/versions", options); }; - const getVersionByProjectId = async (projectId: Number, headers?: Record) => { - const options = headers ? { headers } : undefined; - return await useFetch(`/api/versions/?projectId=${projectId}`, options); + const getVersionByProjectId = async (projectId: number, headers?: Record, status?: string) => { + const options = { + headers, + query: { + projectId, + ...(status ? { status } : {}) + } + }; + return await useFetch("/api/versions", options); }; function mapRawVersionToVersion(rawVersion: RawVersion): Version { @@ -257,6 +264,49 @@ export default () => { } }; + const createBuildNetCommonRequest = (trackerId: number, + project: Project, + assignTo: ProjectMemberShip, + targetVerion: Version, + subject: string, + options: BuildNetCommonOptions): BuildNetCommonRequest => { + return { + tracker_id: trackerId, + project, + assignTo, + targetVerion, + subject, + options + }; + } + + const createBuildNetCommon = async (buildNetCommonRequest: BuildNetCommonRequest, + headers?: Record): Promise => { + const body = { + BuildNetCommonRequest: buildNetCommonRequest + }; + + try { + const { data, error } = await useFetch("/api/buildnetcommon", { + method: "POST", + body: JSON.stringify(body), + headers + }); + + if (error.value) { + throw createError({ + ...error.value, + statusMessage: `Failed to create Build Request: ${error.value.statusMessage}`, + }); + } + + return data.value ?? 'No found Issue ID returned.'; + } catch (error) { + console.error('Error creating Build Request:', error); + return 'Error occurred while creating Build Request.'; + } + }; + //========================================================== // SERVER SIDE API //========================================================== @@ -286,6 +336,7 @@ export default () => { , getProject, mapRawProjectToProject , getProjectMemberShip, mapRawMembershipToProjectMemberShip , createDevTrackerRequest, createDevTracker + , createBuildNetCommonRequest, createBuildNetCommon , createBaseRedmineHeader , YourOwnRedmineAPI: YOUR_OWN_REDMINE_API, versionStatuses, versionShareType, versionShares, devTrackers, buildTrackers, TRACKER}; } \ No newline at end of file diff --git a/app/composables/useSupportConfig.test.ts b/app/composables/useSupportConfig.test.ts new file mode 100644 index 0000000..5d16f26 --- /dev/null +++ b/app/composables/useSupportConfig.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import useSupportConfig from './useSupportConfig' + +describe('useSupportConfig', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('loads trackers, projects, and build purposes with separate filterable methods', async () => { + const fetchSpy = vi.fn() + + fetchSpy + .mockResolvedValueOnce([ + { id: 1, name: 'Build Tracker', purpose: 'Build' }, + { id: 2, name: 'Other Tracker', purpose: 'Other' }, + ]) + .mockResolvedValueOnce([ + { id: 10, name: 'Library Project', purpose: 'Library' }, + { id: 11, name: 'Other Project', purpose: 'Other' }, + ]) + .mockResolvedValueOnce([{ name: 'Release', purpose: 'BuildInvSet' }]) + + vi.stubGlobal('$fetch', fetchSpy) + + const { + loadSupportTrackerOptions, + loadSupportProjectOptions, + loadSupportBuildPurposeOptions, + } = useSupportConfig() + + await expect(loadSupportTrackerOptions('Build')).resolves.toEqual([ + { id: 1, name: 'Build Tracker' }, + ]) + + await expect(loadSupportProjectOptions('Library')).resolves.toEqual([ + { id: 10, name: 'Library Project' }, + ]) + + await expect(loadSupportBuildPurposeOptions('BuildInvSet')).resolves.toEqual(['Release']) + }) + + it('loads custom lookup values for a specific category and purpose', async () => { + const fetchSpy = vi.fn().mockResolvedValue([ + { name: 'Dockerfile A', category: 'buildInvSetDOTNETCoreContainer', purpose: 'buildInvSetDOTNETCoreContainer' }, + { name: 'Dockerfile B', category: 'buildInvSetDOTNETCoreContainer', purpose: 'buildInvSetDOTNETCustomContainerTSY' }, + ]) + + vi.stubGlobal('$fetch', fetchSpy) + + const { loadSupportCustomLookupOptions } = useSupportConfig() + + await expect( + loadSupportCustomLookupOptions('buildInvSetDOTNETCoreContainer', 'buildInvSetDOTNETCoreContainer'), + ).resolves.toEqual([ + { label: 'Dockerfile A', value: 'Dockerfile A' }, + ]) + }) + + it('loads layout options from the buildinvset template json', async () => { + const fetchSpy = vi.fn().mockResolvedValue([ + { id: -99, name: 'Default', description: 'Default Template', order: 99 }, + { id: 1, name: 'Custom', description: 'Custom Template', order: 1 }, + ]) + + vi.stubGlobal('$fetch', fetchSpy) + + const { loadSupportLayoutOptions } = useSupportConfig() + + await expect(loadSupportLayoutOptions()).resolves.toEqual([ + { label: 'Custom', value: 'Custom' }, + { label: 'Default', value: 'Default' }, + ]) + }) + +}) diff --git a/app/composables/useSupportConfig.ts b/app/composables/useSupportConfig.ts new file mode 100644 index 0000000..b74e7ed --- /dev/null +++ b/app/composables/useSupportConfig.ts @@ -0,0 +1,68 @@ +const logConfigPayload = (fileName: string, payload: unknown) => { + console.log(`[useSupportConfig] ${fileName}:`, payload) +} + +export default function useSupportConfig() { + const loadSupportTrackerOptions = async (purpose = 'Build') => { + const trackerOptions = await $fetch('/api/config/SupportTracker.json') + logConfigPayload('SupportTracker.json', trackerOptions) + + if (!Array.isArray(trackerOptions)) return [] + + return trackerOptions + .filter((item) => item.purpose === purpose) + .map((item) => ({ id: item.id, name: item.name })) + } + + const loadSupportProjectOptions = async (purpose = 'Library') => { + const projectOptions = await $fetch('/api/config/SupportProject.json') + logConfigPayload('SupportProject.json', projectOptions) + + if (!Array.isArray(projectOptions)) return [] + + return projectOptions + .filter((item) => item.purpose === purpose) + .map((item) => ({ id: item.id, name: item.name })) + } + + const loadSupportBuildPurposeOptions = async (purpose?: string) => { + const buildPurposeConfig = await $fetch('/api/config/SupportBuildPurpose.json') + logConfigPayload('SupportBuildPurpose.json', buildPurposeConfig) + + if (!Array.isArray(buildPurposeConfig)) return [] + + return buildPurposeConfig + .filter((item) => !purpose || item.purpose === purpose) + .map((item) => item.name) + } + + const loadSupportCustomLookupOptions = async (category: string, purpose: string) => { + const lookupConfig = await $fetch('/api/config/SupportCustomLookup.json') + logConfigPayload('SupportCustomLookup.json', lookupConfig) + + if (!Array.isArray(lookupConfig)) return [] + + return lookupConfig + .filter((item) => item.category === category && item.purpose === purpose) + .map((item) => ({ label: item.name, value: item.name })) + } + + const loadSupportLayoutOptions = async () => { + const layoutConfig = await $fetch>('/api/config/buildinvset.json') + logConfigPayload('buildinvset.json', layoutConfig) + + if (!Array.isArray(layoutConfig)) return [] + + return [...layoutConfig] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((item) => ({ label: item.name, value: item.name })) + } + + return { + loadSupportTrackerOptions, + loadSupportProjectOptions, + loadSupportBuildPurposeOptions, + loadSupportCustomLookupOptions, + loadSupportLayoutOptions, + } +} diff --git a/app/composables/useTableGrouping.ts b/app/composables/useTableGrouping.ts new file mode 100644 index 0000000..db1a9f2 --- /dev/null +++ b/app/composables/useTableGrouping.ts @@ -0,0 +1,35 @@ +export function useTableGrouping(allowedColumns: string[]) { + const groupedColumns = ref([]) + const isDragOver = ref(false) + const draggedColumn = ref(null) + + const handleDragStart = (event: DragEvent, columnId: string) => { + draggedColumn.value = columnId + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', columnId) + } + } + + const handleDrop = (event: DragEvent) => { + isDragOver.value = false + const columnId = event.dataTransfer?.getData('text/plain') + if (columnId && allowedColumns.includes(columnId)) { + if (!groupedColumns.value.includes(columnId)) { + groupedColumns.value = [...groupedColumns.value, columnId] + } + } + } + + const handleRemoveGroup = (columnId: string) => { + groupedColumns.value = groupedColumns.value.filter(id => id !== columnId) + } + + return { + groupedColumns, + isDragOver, + handleDragStart, + handleDrop, + handleRemoveGroup, + } +} \ No newline at end of file diff --git a/app/composables/useVersionValidation.test.ts b/app/composables/useVersionValidation.test.ts new file mode 100644 index 0000000..3dffbf0 --- /dev/null +++ b/app/composables/useVersionValidation.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest' +import { versionFormSchema } from './useVersionValidation' + +describe('useVersionValidation', () => { + it('should validate a correct version form', () => { + const validData = { + name: 'v1.0.0', + description: 'First release', + due_date: '2026-12-31', + status: 'open', + sharing: 'none', + projectid: 123 + } + const result = versionFormSchema.safeParse(validData) + expect(result.success).toBe(true) + }) + + it('should fail when name is empty', () => { + const invalidData = { + name: '', + description: 'First release', + due_date: '2026-12-31', + status: 'open', + sharing: 'none', + projectid: 123 + } + const result = versionFormSchema.safeParse(invalidData) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Version name is required') + } + }) + + it('should fail when description is empty', () => { + const invalidData = { + name: 'v1.0.0', + description: '', + due_date: '2026-12-31', + status: 'open', + sharing: 'none', + projectid: 123 + } + const result = versionFormSchema.safeParse(invalidData) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Description is required') + } + }) + + it('should fail when due_date is empty', () => { + const invalidData = { + name: 'v1.0.0', + description: 'First release', + due_date: '', + status: 'open', + sharing: 'none', + projectid: 123 + } + const result = versionFormSchema.safeParse(invalidData) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Due date is required') + } + }) + + it('should fail when status is empty', () => { + const invalidData = { + name: 'v1.0.0', + description: 'First release', + due_date: '2026-12-31', + status: '', + sharing: 'none', + projectid: 123 + } + const result = versionFormSchema.safeParse(invalidData) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Status is required') + } + }) + + it('should fail when sharing is empty', () => { + const invalidData = { + name: 'v1.0.0', + description: 'First release', + due_date: '2026-12-31', + status: 'open', + sharing: '', + projectid: 123 + } + const result = versionFormSchema.safeParse(invalidData) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Sharing is required') + } + }) + + it('should fail when projectid is 0 or less', () => { + const invalidData1 = { + name: 'v1.0.0', + description: 'First release', + due_date: '2026-12-31', + status: 'open', + sharing: 'none', + projectid: 0 + } + const result1 = versionFormSchema.safeParse(invalidData1) + expect(result1.success).toBe(false) + if (!result1.success) { + expect(result1.error.issues[0].message).toBe('Project is required') + } + + const invalidData2 = { ...invalidData1, projectid: -1 } + const result2 = versionFormSchema.safeParse(invalidData2) + expect(result2.success).toBe(false) + if (!result2.success) { + expect(result2.error.issues[0].message).toBe('Project is required') + } + }) +}) diff --git a/app/composables/useVersionValidation.ts b/app/composables/useVersionValidation.ts new file mode 100644 index 0000000..f1ed31d --- /dev/null +++ b/app/composables/useVersionValidation.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +export const versionFormSchema = z.object({ + name: z.string().trim().min(1, 'Version name is required'), + description: z.string().trim().min(1, 'Description is required'), + due_date: z.string().trim().min(1, 'Due date is required'), + status: z.string().trim().min(1, 'Status is required'), + sharing: z.string().trim().min(1, 'Sharing is required'), + projectid: z.number().gt(0, 'Project is required') +}) + +export type VersionFormSchema = z.infer + +export const useVersionValidation = () => { + return { + versionFormSchema + } +} diff --git a/app/pages/admin/branches/index.vue b/app/pages/admin/branches/index.vue new file mode 100644 index 0000000..2d9234f --- /dev/null +++ b/app/pages/admin/branches/index.vue @@ -0,0 +1,483 @@ + + + diff --git a/app/pages/buildinvset/index.vue b/app/pages/buildinvset/index.vue new file mode 100644 index 0000000..f5132be --- /dev/null +++ b/app/pages/buildinvset/index.vue @@ -0,0 +1,930 @@ + + + diff --git a/app/pages/buildnetcommon/index.vue b/app/pages/buildnetcommon/index.vue new file mode 100644 index 0000000..bd1d92f --- /dev/null +++ b/app/pages/buildnetcommon/index.vue @@ -0,0 +1,330 @@ + + + \ No newline at end of file diff --git a/app/pages/clientsetting/index.vue b/app/pages/clientsetting/index.vue index cfd636b..5a75965 100644 --- a/app/pages/clientsetting/index.vue +++ b/app/pages/clientsetting/index.vue @@ -6,7 +6,7 @@
- + - +
-
Dev Trackers (Program Spec / Defect)
- - -
- Please set your access key in Client Setting -
-
- - - -
- - Use Server Token +
+
+
+

Dev Trackers (Program Spec / Defect)

+

+ Create Dev Tracker in Redmine directly from here. Please make sure you have set the access key in Client Setting before using this feature. +

-
- - - - - +
- - - - - - + - - - - - - - - - - -
- - Submit - - - Clear -
- + +
+

Spec Information

+ + + + + + + + + + + + + + + + + + + +
+
+ + Submit + + + Clear + +
+
+
@@ -102,7 +105,9 @@ import type { NuxtError } from "#app"; import { z } from 'zod'; import { h } from 'vue'; +import useDevTrackersTour from '~/composables/useDevTrackersTour' +const { startTour } = useDevTrackersTour() const accessKey = ref(null); const isUseServerToken = ref(false); @@ -127,7 +132,7 @@ const state = reactive({ const schema = z.object({ selectTracker: z.number('Tracker is required'), - trackerTitle: z.string().regex(/^\[[A-Za-z0-9]+\]\[[A-Za-z0-9]+\]\[(IMPACT|NOIMPACT)]\s.+$/, 'Input must match the required format.'), + trackerTitle: z.string().regex(/^\[[A-Za-z0-9-]+\]\[[A-Za-z0-9-]+\]\[(IMPACT|NOIMPACT)]\s.+$/, 'Input must match the required format.'), selectedProject: z.object({ id: z.number() }, { error: 'Project is required' }), selectedAssignee: z.object({ id: z.number() }, { error: 'Project Member is required' }), selectedVersion: z.object({ id: z.number() }, { error: 'Version is required' }) @@ -187,9 +192,7 @@ const projectChange = async (project: Project) => { const { data: dataVersions, error: errorVersions, - } = isUseServerToken.value - ? await useRedmineAPI().getVersionByProjectId(project.id) - : await useRedmineAPI().getVersionByProjectId(project.id, headers); + } = await useRedmineAPI().getVersionByProjectId(project.id, isUseServerToken.value ? undefined : headers, 'open'); versions.value = dataVersions.value ?? []; } }; diff --git a/app/pages/index.vue b/app/pages/index.vue index 4081624..86473d5 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,13 +1,124 @@ \ No newline at end of file diff --git a/app/pages/issues/index.vue b/app/pages/issues/index.vue index afbfbd9..8ad9b93 100644 --- a/app/pages/issues/index.vue +++ b/app/pages/issues/index.vue @@ -33,14 +33,15 @@
-
+
Search + Export Excel
import { useRuntimeConfig } from "#app"; import type { TableColumn } from "@nuxt/ui"; +import { saveAs } from 'file-saver' +import * as XLSX from 'xlsx' import { getGroupedRowModel } from '@tanstack/vue-table' +import type { Cell, Row, Table } from '@tanstack/vue-table' const config = useRuntimeConfig(); const baseUrl = config.public.redmineUrl; @@ -95,6 +100,7 @@ const versions = computed(() => dataversions.value ?? []); const selectedVersions = ref([]); const IssuesByVersions = ref([]); +const issuesTable = useTemplateRef<{ tableApi: Table }>('issuesTable') const groupedColumns = ref([]) @@ -149,6 +155,108 @@ const removeSelectedVersion = (v: Version) => { selectedVersions.value = selectedVersions.value.filter(sv => sv.id !== v.id) } +const normalizeCellText = (text: string): string => { + return text + .replace(/\r\n/g, '\n') + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +const stripHtml = (html: string | null | undefined): string => { + if (!html) { + return '' + } + + const htmlWithBreaks = html + .replace(//gi, '\n') + .replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n') + + if (import.meta.client) { + const doc = new DOMParser().parseFromString(htmlWithBreaks, 'text/html') + return normalizeCellText(doc.body.textContent || '') + } + + return normalizeCellText(htmlWithBreaks.replace(/<[^>]*>/g, '')) +} + +const flattenLeafRows = (rows: Row[]): TData[] => { + const result: TData[] = [] + + const visitRow = (row: Row): void => { + if (row.getIsGrouped()) { + row.subRows.forEach(visitRow) + return + } + + result.push(row.original) + } + + rows.forEach(visitRow) + return result +} + +const getExportIssues = (): Issue[] => { + const tableApi = issuesTable.value?.tableApi + if (!tableApi) { + return IssuesByVersions.value + } + + return flattenLeafRows(tableApi.getPrePaginationRowModel().rows) +} + +const formatExportDate = (value: string | null | undefined): string => { + if (!value) { + return '' + } + + return new Date(value).toLocaleDateString() +} + +const exportToExcel = (): void => { + const exportIssues = getExportIssues() + if (!exportIssues.length) { + return + } + + const rows = exportIssues.map((issue) => ({ + ID: issue.id, + Project: issue.projectName || '', + Version: issue.versionName || '', + Assignee: issue.assignedToUserName || '', + Subject: issue.subject || '', + Status: issue.statusName || '', + ImpactNote: stripHtml(issue.impactNote), + CreatedOn: formatExportDate(issue.created_on), + UpdatedOn: formatExportDate(issue.updated_on) + })) + + const worksheet = XLSX.utils.json_to_sheet(rows) + worksheet['!cols'] = [ + { wch: 10 }, + { wch: 24 }, + { wch: 18 }, + { wch: 20 }, + { wch: 60 }, + { wch: 18 }, + { wch: 50 }, + { wch: 14 }, + { wch: 14 } + ] + + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, worksheet, 'Issues') + + const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }) + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const fileName = `issues_${new Date().toISOString().slice(0, 10)}.xlsx` + saveAs(blob, fileName) +} + const renderDraggableHeader = (title: string, columnId: string) => { return h('div', { draggable: true, @@ -195,8 +303,8 @@ const columns: TableColumn[] = [ td: 'w-8 overflow-visible' }, colspan: { - td: (cell: any) => { - return cell.row?.getIsGrouped() ? cell.row.getAllCells().length : undefined + td: (cell: Cell) => { + return cell.row.getIsGrouped() ? String(cell.row.getAllCells().length) : '1' } } }, diff --git a/app/pages/versions/index.vue b/app/pages/versions/index.vue index 9434d4c..942a301 100644 --- a/app/pages/versions/index.vue +++ b/app/pages/versions/index.vue @@ -27,23 +27,30 @@