diff --git a/.github/instructions/general.md b/.github/instructions/general.md new file mode 100644 index 0000000..69c921b --- /dev/null +++ b/.github/instructions/general.md @@ -0,0 +1,9 @@ +# General Instructions + +- Provide scalable solutions that follow best practices. +- Follow SOLID principles, especially single responsibility and open/closed. +- Follow domain-driven design principles. +- Follow CQRS principles. +- Do NOT create markdown files for guidance on changes. +- Do NOT create sample files in the codebase. +- Do NOT use deprecated or removed features. \ No newline at end of file diff --git a/.github/workflows/build.extension.yml b/.github/workflows/build.extension.yml new file mode 100644 index 0000000..6d72a06 --- /dev/null +++ b/.github/workflows/build.extension.yml @@ -0,0 +1,37 @@ +name: ๐Ÿ”จ Build Extension + +on: + workflow_call: + +jobs: + build: + name: ๐Ÿ”จ Build Extension + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ“ฅ Checkout Repository + uses: actions/checkout@v4 + + - name: ๐Ÿงฉ Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + registry-url: 'https://registry.npmjs.org/' + cache: 'npm' + + - name: ๐Ÿ“ฆ Install Dependencies + run: | + npm ci + npm install -g @vscode/vsce + + - name: ๐Ÿ—๏ธ Build Extension + run: npm run compile + + - name: ๐ŸŽ Create Package + run: vsce package + + - name: ๐Ÿ“ค Upload Extension Package + uses: actions/upload-artifact@v4 + with: + name: extension-package + path: "*.vsix" + retention-days: 30 diff --git a/.github/workflows/deploy.extension.yml b/.github/workflows/deploy.extension.yml index f4f4f52..d532d54 100644 --- a/.github/workflows/deploy.extension.yml +++ b/.github/workflows/deploy.extension.yml @@ -7,29 +7,25 @@ on: required: true jobs: + build: + name: ๐Ÿ”จ Build Extension + uses: ./.github/workflows/build.extension.yml + deploy-extension: name: ๐Ÿš€ Deploy Extension + needs: build runs-on: ubuntu-latest steps: - - name: ๐Ÿ“ฅ Checkout Repository - uses: actions/checkout@v2 - - - name: ๐Ÿงฉ Setup Node.js - uses: actions/setup-node@v2 + - name: ๐Ÿ“ฅ Download Extension Package + uses: actions/download-artifact@v4 with: - node-version: '18' - registry-url: 'https://registry.npmjs.org/' + name: extension-package - - name: ๐Ÿ“ฆ Install Dependencies + - name: ๐Ÿ“ฆ Install VSCE run: | - npm install npm install -g @vscode/vsce - - name: ๐ŸŽ Create Package - run: | - npx tsc - vsce package - - name: ๐Ÿš€ Publish Extension run: | - vsce publish --pat ${{ secrets.marketplace-token }} \ No newline at end of file + VSIX_FILE=$(ls *.vsix) + vsce publish --packagePath "${VSIX_FILE}" --pat ${{ secrets.marketplace-token }} \ No newline at end of file diff --git a/.github/workflows/validate.extension.yml b/.github/workflows/validate.extension.yml new file mode 100644 index 0000000..305f832 --- /dev/null +++ b/.github/workflows/validate.extension.yml @@ -0,0 +1,41 @@ +name: โœ… Validate Extension + +on: + pull_request: + branches: [ main ] + paths: + - 'src/extension/**' + - 'package.json' + - 'tsconfig.json' + - 'eslint.config.mjs' + - 'esbuild.js' + +jobs: + validate: + name: โœ… Validate Extension + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ“ฅ Checkout Repository + uses: actions/checkout@v4 + + - name: ๐Ÿงฉ Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + registry-url: 'https://registry.npmjs.org/' + cache: 'npm' + + - name: ๐Ÿ“ฆ Install Dependencies + run: npm ci + + - name: ๐Ÿ”จ Compile Extension + run: npm run compile + + - name: ๐Ÿ” Check Types + run: npm run check-types + + - name: ๐Ÿงน Lint Code + run: npm run lint + + - name: ๐Ÿงช Run Tests + run: npm run test \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 074580d..d78d078 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hazels-toolbox", - "version": "1.0.38", + "version": "1.0.57", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hazels-toolbox", - "version": "1.0.38", + "version": "1.0.57", "dependencies": { "azure-devops-node-api": "^14.1.0" }, @@ -21,7 +21,7 @@ "esbuild": "^0.25.0", "eslint": "^9.21.0", "npm-run-all": "^4.1.5", - "typescript": "^5.7.3" + "typescript": "^5.8.3" }, "engines": { "vscode": "^1.98.0" @@ -5589,9 +5589,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index de07acd..ded70b4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Hazel's Toolbox", "description": "Just a collection of tools created by and for Hazel to just do her job on a day-to-day basis.", "publisher": "tacosontitan", - "version": "1.0.38", + "version": "1.0.57", "engines": { "vscode": "^1.98.0" }, @@ -17,6 +17,184 @@ "activationEvents": [], "main": "./dist/extension.js", "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "toolbox", + "title": "Hazel's Toolbox", + "icon": "๐Ÿ’ƒ" + } + ] + }, + "views": { + "toolbox": [ + { + "id": "overviewWebView", + "name": "Overview", + "type": "webview", + "when": "true", + "icon": "$(dashboard)" + }, + { + "id": "tasksTreeView", + "name": "Active Work Items", + "when": "true", + "icon": "$(list)" + }, + { + "id": "meetingView", + "name": "Meeting Notes", + "when": "true", + "type": "webview", + "icon": "$(note)" + }, + { + "id": "timeTreeView", + "name": "Time Tracking", + "when": "true", + "icon": "$(clock)" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "tacosontitan.toolbox.workflow.refreshTasks", + "when": "view == tasksTreeView", + "group": "navigation" + }, + { + "command": "tacosontitan.toolbox.workflow.addTaskToWorkItem", + "when": "view == tasksTreeView", + "group": "navigation" + }, + { + "command": "tacosontitan.toolbox.workflow.startWorkItem", + "when": "view == tasksTreeView", + "group": "navigation" + }, + { + "command": "tacosontitan.toolbox.time.clockIn", + "when": "view == timeTreeView", + "group": "navigation@1" + }, + { + "command": "tacosontitan.toolbox.time.clockOut", + "when": "view == timeTreeView", + "group": "navigation@2" + }, + { + "command": "tacosontitan.toolbox.time.refresh", + "when": "view == timeTreeView", + "group": "navigation@3" + } + ], + "view/item/context": [ + { + "command": "tacosontitan.toolbox.workflow.setTaskStateToNew", + "when": "view == tasksTreeView && viewItem =~ /taskCanSetToNew/", + "group": "1_state" + }, + { + "command": "tacosontitan.toolbox.workflow.setTaskStateToActive", + "when": "view == tasksTreeView && viewItem =~ /taskCanSetToActive/", + "group": "1_state" + }, + { + "command": "tacosontitan.toolbox.workflow.setTaskStateToResolved", + "when": "view == tasksTreeView && viewItem =~ /taskCanSetToResolved/", + "group": "1_state" + }, + { + "command": "tacosontitan.toolbox.workflow.setTaskStateToClosed", + "when": "view == tasksTreeView && viewItem =~ /taskCanSetToClosed/", + "group": "1_state" + }, + { + "command": "tacosontitan.toolbox.time.loadHistory", + "when": "view == timeTreeView && viewItem == loadMore", + "group": "inline" + } + ] + }, + "commands": [ + { + "command": "tacosontitan.toolbox.workflow.createDefaultTasks", + "category": "Hazel's Toolbox ๐Ÿ’ป Workflow", + "title": "Create Default Tasks" + }, + { + "command": "tacosontitan.toolbox.workflow.refreshTasks", + "category": "Hazel's Toolbox ๐Ÿ’ป Workflow", + "title": "Refresh Active Work Items", + "icon": "$(refresh)" + }, + { + "command": "tacosontitan.toolbox.workflow.addTaskToWorkItem", + "category": "Hazel's Toolbox ๐Ÿ’ป Workflow", + "title": "Add Task to Work Item", + "icon": "$(add)" + }, + { + "command": "tacosontitan.toolbox.workflow.setTaskStateToNew", + "category": "Hazel's Toolbox ๐Ÿ’ป Workflow", + "title": "Set Task to New" + }, + { + "command": "tacosontitan.toolbox.workflow.setTaskStateToActive", + "category": "Hazel's Toolbox ๐Ÿ’ป Workflow", + "title": "Set Task to Active" + }, + { + "command": "tacosontitan.toolbox.workflow.setTaskStateToResolved", + "category": "Hazel's Toolbox ๐Ÿ’ป Workflow", + "title": "Set Task to Resolved" + }, + { + "command": "tacosontitan.toolbox.workflow.setTaskStateToClosed", + "category": "Hazel's Toolbox ๐Ÿ’ป Workflow", + "title": "Set Task to Closed" + }, + { + "command": "tacosontitan.toolbox.workflow.startWorkItem", + "category": "Hazel's Toolbox ๐Ÿ’ป Workflow", + "title": "Start Work Item", + "icon": "$(play)" + }, + { + "command": "tacosontitan.toolbox.time.clockIn", + "category": "Hazel's Toolbox โฐ Time Tracking", + "title": "Clock In", + "icon": "$(play)" + }, + { + "command": "tacosontitan.toolbox.time.clockOut", + "category": "Hazel's Toolbox โฐ Time Tracking", + "title": "Clock Out", + "icon": "$(stop)" + }, + { + "command": "tacosontitan.toolbox.time.refresh", + "category": "Hazel's Toolbox โฐ Time Tracking", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "tacosontitan.toolbox.time.loadHistory", + "category": "Hazel's Toolbox โฐ Time Tracking", + "title": "Load History" + }, + { + "command": "tacosontitan.toolbox.time.clear", + "category": "Hazel's Toolbox โฐ Time Tracking", + "title": "Clear" + }, + { + "command": "tacosontitan.toolbox.time.purge", + "category": "Hazel's Toolbox โฐ Time Tracking", + "title": "Purge Old Entries" + } + ], "configuration": { "title": "Hazel's Toolbox", "type": "object", @@ -97,114 +275,80 @@ "User Story" ], "description": "Work item types to include when loading active work items" - } - } - }, - "commands": [ - { - "command": "tacosontitan.toolbox.createDefaultTasks", - "title": "Hazel's Toolbox: Create Default Tasks" - }, - { - "command": "tacosontitan.toolbox.searchWorkItem", - "title": "Hazel's Toolbox: Search Work Item", - "icon": "$(search)" - }, - { - "command": "tacosontitan.toolbox.refreshTasks", - "title": "Hazel's Toolbox: Refresh", - "icon": "$(refresh)" - }, - { - "command": "tacosontitan.toolbox.addTask", - "title": "Hazel's Toolbox: Add Task", - "icon": "$(add)" - }, - { - "command": "tacosontitan.toolbox.setTaskStateToNew", - "title": "Hazel's Toolbox: Set Task to New" - }, - { - "command": "tacosontitan.toolbox.setTaskStateToActive", - "title": "Hazel's Toolbox: Set Task to Active" - }, - { - "command": "tacosontitan.toolbox.setTaskStateToResolved", - "title": "Hazel's Toolbox: Set Task to Resolved" - }, - { - "command": "tacosontitan.toolbox.setTaskStateToClosed", - "title": "Hazel's Toolbox: Set Task to Closed" - }, - { - "command": "tacosontitan.toolbox.startWorkItem", - "title": "Hazel's Toolbox: Start Work Item", - "icon": "$(play)" - } - ], - "viewsContainers": { - "activitybar": [ - { - "id": "toolbox", - "title": "Toolbox", - "icon": "$(tools)" - } - ] - }, - "views": { - "toolbox": [ - { - "id": "tasksTreeView", - "name": "Active Work Items", - "when": "true" - } - ] - }, - "menus": { - "view/title": [ - { - "command": "tacosontitan.toolbox.searchWorkItem", - "when": "view == tasksTreeView", - "group": "navigation" }, - { - "command": "tacosontitan.toolbox.refreshTasks", - "when": "view == tasksTreeView", - "group": "navigation" + "tacosontitan.toolbox.meetings.participants": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "Product Owner", + "Scrum Master", + "Tech Lead", + "Developer", + "QA Engineer", + "Business Analyst", + "Stakeholder", + "Designer", + "Architect" + ], + "description": "List of participants that can be selected for meeting notes" }, - { - "command": "tacosontitan.toolbox.addTask", - "when": "view == tasksTreeView", - "group": "navigation" + "tacosontitan.toolbox.time.retentionDays": { + "type": "number", + "default": 90, + "minimum": 1, + "maximum": 3650, + "description": "Number of days to retain time tracking data. Entries older than this will be automatically deleted." }, - { - "command": "tacosontitan.toolbox.startWorkItem", - "when": "view == tasksTreeView", - "group": "navigation" - } - ], - "view/item/context": [ - { - "command": "tacosontitan.toolbox.setTaskStateToNew", - "when": "view == tasksTreeView && viewItem =~ /taskCanSetToNew/", - "group": "1_state" + "tacosontitan.toolbox.time.autoCleanup": { + "type": "boolean", + "default": true, + "description": "Automatically clean up time entries older than the retention period when the extension starts." }, - { - "command": "tacosontitan.toolbox.setTaskStateToActive", - "when": "view == tasksTreeView && viewItem =~ /taskCanSetToActive/", - "group": "1_state" + "tacosontitan.toolbox.overview.recentCompletionsDays": { + "type": "number", + "default": 30, + "description": "Number of days to look back for recent work item completions in the Overview" }, - { - "command": "tacosontitan.toolbox.setTaskStateToResolved", - "when": "view == tasksTreeView && viewItem =~ /taskCanSetToResolved/", - "group": "1_state" + "tacosontitan.toolbox.customTemplatesPath": { + "type": "string", + "default": ".vscode/task-templates", + "description": "Path relative to workspace root where custom task templates are stored" }, - { - "command": "tacosontitan.toolbox.setTaskStateToClosed", - "when": "view == tasksTreeView && viewItem =~ /taskCanSetToClosed/", - "group": "1_state" + "tacosontitan.toolbox.taskTemplateSources": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of VS Code extension IDs that provide additional task templates" + }, + "tacosontitan.toolbox.enabledTemplateCategories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "planning", + "development", + "review", + "testing", + "release", + "general" + ] + }, + "default": [], + "description": "Specific template categories to enable. If empty, all categories are enabled" + }, + "tacosontitan.toolbox.disabledTaskTemplates": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of task template titles to disable" } - ] + } } }, "scripts": { @@ -232,7 +376,7 @@ "esbuild": "^0.25.0", "eslint": "^9.21.0", "npm-run-all": "^4.1.5", - "typescript": "^5.7.3" + "typescript": "^5.8.3" }, "dependencies": { "azure-devops-node-api": "^14.1.0" diff --git a/resources/schemas/task.schema.json b/resources/schemas/task.schema.json new file mode 100644 index 0000000..b86c46f --- /dev/null +++ b/resources/schemas/task.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://tacosontitan.github.io/schemas/task-template.json", + "title": "Task Template Schema", + "description": "Schema for task template JSON files in Hazel's Toolbox", + "type": "object", + "required": ["version", "metadata", "templates"], + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema URL" + }, + "version": { + "type": "string", + "description": "Version of the template file format", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "metadata": { + "type": "object", + "required": ["name", "description", "author"], + "properties": { + "name": { + "type": "string", + "description": "Display name of the template collection" + }, + "description": { + "type": "string", + "description": "Description of the template collection" + }, + "author": { + "type": "string", + "description": "Author of the templates" + } + + } + }, + "templates": { + "type": "array", + "items": { + "$ref": "#/definitions/TaskTemplate" + } + } + }, + "definitions": { + "TaskTemplate": { + "type": "object", + "required": ["title", "description", "remainingWork", "activity"], + "properties": { + "title": { + "type": "string", + "description": "Title of the task", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "type": "string", + "description": "Detailed description of the task" + }, + "remainingWork": { + "type": "number", + "description": "Estimated hours to complete the task", + "minimum": 0, + "maximum": 100 + }, + "activity": { + "type": "string", + "description": "Activity type for the task", + "enum": [ + "Planning", + "Development", + "Testing", + "Design", + "Documentation", + "Deployment", + "Requirements", + "Communication", + "Process Improvement", + "Infrastructure" + ] + }, + "requiredFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Azure DevOps fields that must have values" + }, + "metadata": { + "type": "object", + "properties": { + "appliesTo": { + "type": "array", + "items": { + "type": "string", + "enum": ["Feature", "User Story", "Bug", "Task", "Epic"] + }, + "description": "Work item types this template applies to" + }, + "assigneeRequired": { + "type": "boolean", + "description": "Whether the task must have an assignee" + } + } + } + } + } + } +} diff --git a/resources/task-templates/schemas/task-template.schema.json b/resources/task-templates/schemas/task-template.schema.json new file mode 100644 index 0000000..82fdece --- /dev/null +++ b/resources/task-templates/schemas/task-template.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://tacosontitan.github.io/schemas/task-template.json", + "title": "Task Template Schema", + "description": "Schema for task template JSON files in Hazel's Toolbox", + "type": "object", + "required": ["version", "metadata", "templates"], + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema URL" + }, + "version": { + "type": "string", + "description": "Version of the template file format", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "metadata": { + "type": "object", + "required": ["name", "description", "author", "category"], + "properties": { + "name": { + "type": "string", + "description": "Display name of the template collection" + }, + "description": { + "type": "string", + "description": "Description of the template collection" + }, + "author": { + "type": "string", + "description": "Author of the templates" + }, + "category": { + "type": "string", + "description": "Primary category for this template collection", + "enum": ["planning", "development", "review", "testing", "release", "general"] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for categorizing and searching" + } + } + }, + "templates": { + "type": "array", + "items": { + "$ref": "#/definitions/TaskTemplate" + } + } + }, + "definitions": { + "TaskTemplate": { + "type": "object", + "required": ["title", "description", "remainingWork", "activity", "assigneeRequired", "appliesTo"], + "properties": { + "title": { + "type": "string", + "description": "Title of the task", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "type": "string", + "description": "Detailed description of the task" + }, + "remainingWork": { + "type": "number", + "description": "Estimated hours to complete the task", + "minimum": 0, + "maximum": 100 + }, + "activity": { + "type": "string", + "description": "Activity type for the task", + "enum": [ + "Planning", + "Development", + "Testing", + "Design", + "Documentation", + "Deployment", + "Requirements", + "Communication", + "Process Improvement", + "Infrastructure" + ] + }, + "assigneeRequired": { + "type": "boolean", + "description": "Whether the task must have an assignee" + }, + "appliesTo": { + "type": "array", + "items": { + "type": "string", + "enum": ["Feature", "User Story", "Bug", "Task", "Epic"] + }, + "description": "Work item types this template applies to" + }, + "requiredFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Azure DevOps fields that must have values" + }, + "metadata": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["planning", "development", "review", "testing", "release", "general"] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/resources/tasks/development.json b/resources/tasks/development.json new file mode 100644 index 0000000..c965a24 --- /dev/null +++ b/resources/tasks/development.json @@ -0,0 +1,25 @@ +{ + "$schema": "../schemas/task.schema.json", + "version": "1.0.0", + "metadata": { + "name": "Development Tasks", + "description": "Pre-defined tasks for development and implementation work", + "author": "tacosontitan" + }, + "templates": [ + { + "title": "Setup Environment", + "description": "Setup the development environment to execute the work item.\n- Download repository.\n- Run any tests against main.\n- Create topic branch.\n- Install dependencies.", + "remainingWork": 0.15, + "activity": "Development", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story", + "Bug" + ] + } + } + ] +} \ No newline at end of file diff --git a/resources/tasks/planning.json b/resources/tasks/planning.json new file mode 100644 index 0000000..68dd5e3 --- /dev/null +++ b/resources/tasks/planning.json @@ -0,0 +1,147 @@ +{ + "$schema": "../schemas/task.schema.json", + "version": "1.0.0", + "metadata": { + "name": "Planning Tasks", + "description": "Pre-defined tasks for work item planning and analysis phase", + "author": "tacosontitan" + }, + "templates": [ + { + "title": "Review Feature", + "description": "Review the feature and its associated work items to ensure alignment with business objectives, coordination with team members, and add tasks if necessary.", + "remainingWork": 0.5, + "activity": "Requirements", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Review Work Item", + "description": "Review the work item and all pre-defined tasks to ensure that tasks are aligned with business objectives and add tasks if necessary.", + "remainingWork": 0.5, + "activity": "Requirements", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story", + "Bug" + ] + } + }, + { + "title": "Reproduce Locally", + "description": "Attempt to reproduce the issue locally to better understand the problem and identify potential solutions.", + "remainingWork": 1, + "activity": "Testing", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Bug" + ] + } + }, + { + "title": "Review Existing Implementation(s)", + "description": "Review existing implementations to gather insights and derive potential solutions.", + "remainingWork": 1, + "activity": "Design", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Meet with Feature Leader", + "description": "Meet with the feature leader to discuss the work item's scope within the grand scheme of the feature.", + "remainingWork": 0.5, + "activity": "Requirements", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Meet with Subject Matter Expert", + "description": "Meet with the noted subject matter expert to discuss the work item's implementation.", + "remainingWork": 0.5, + "activity": "Requirements", + "requiredFields": [ + "Subject Matter Expert" + ], + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Meet with Product Owners", + "description": "Meet with product owners to clarify requirements and ensure alignment with business objectives.", + "remainingWork": 0.5, + "activity": "Requirements", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Meet with Stakeholders", + "description": "Meet with stakeholders to gather feedback and ensure the work item aligns with their expectations.", + "remainingWork": 0.5, + "activity": "Requirements", + "requiredFields": [ + "Stakeholder" + ], + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Meet with Quality Assurance", + "description": "Meet with QA to discuss test cases and ensure alignment on quality expectations.", + "remainingWork": 0.5, + "activity": "Requirements", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Create Implementation Tasks", + "description": "After conducting the necessary reviews and meetings, create tasks that represent the steps needed to execute the work item's acceptance criteria (be sure to account for non-functional requirements).", + "remainingWork": 0.5, + "activity": "Design", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + } + ] +} \ No newline at end of file diff --git a/resources/tasks/quality-assurance.json b/resources/tasks/quality-assurance.json new file mode 100644 index 0000000..df94afc --- /dev/null +++ b/resources/tasks/quality-assurance.json @@ -0,0 +1,128 @@ +{ + "$schema": "../schemas/task.schema.json", + "version": "1.0.0", + "metadata": { + "name": "Quality Assurance Tasks", + "description": "Pre-defined tasks for quality assurance and testing workflows", + "author": "tacosontitan" + }, + "templates": [ + { + "title": "QA Write Test Cases", + "description": "Write test cases that are comprehensive and aligned with business objectives.", + "remainingWork": 2, + "activity": "Design", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "QA Review Test Cases", + "description": "Review the test cases to ensure they are correct, comprehensive, and cover all scenarios.", + "remainingWork": 0.5, + "activity": "Requirements", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Dev Review Test Cases", + "description": "Review the test cases to ensure they are correct, comprehensive, and cover all scenarios.", + "remainingWork": 0.5, + "activity": "Requirements", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Create Quality Assurance Build", + "description": "Create a build for QA testing to validate the implementation.", + "remainingWork": 0.01, + "activity": "Deployment", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "QA Deployment to Test Environment", + "description": "Deploy the build to the test environment for QA validation.", + "remainingWork": 2, + "activity": "Deployment", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "QA Test Case Execution", + "description": "Execute the test cases to validate the functionality of the work item.", + "remainingWork": 1, + "activity": "Testing", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Support QA Testing", + "description": "Provide support to the QA team during the testing process.", + "remainingWork": 1, + "activity": "Testing", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "QA Smoke Testing", + "description": "Perform smoke testing to validate the stability of the build in the test environment.", + "remainingWork": 0.5, + "activity": "Testing", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Support Smoke Testing", + "description": "Provide support during smoke testing to address any issues that arise.", + "remainingWork": 0.25, + "activity": "Testing", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + } + ] +} \ No newline at end of file diff --git a/resources/tasks/release.json b/resources/tasks/release.json new file mode 100644 index 0000000..9c8b668 --- /dev/null +++ b/resources/tasks/release.json @@ -0,0 +1,141 @@ +{ + "$schema": "../schemas/task.schema.json", + "version": "1.0.0", + "metadata": { + "name": "Release Tasks", + "description": "Pre-defined tasks for release management and deployment workflows", + "author": "tacosontitan" + }, + "templates": [ + { + "title": "Create Production Build", + "description": "Create a production-ready build for release deployment.", + "remainingWork": 1, + "activity": "Deployment", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Deploy to Staging Environment", + "description": "Deploy the production build to the staging environment for final validation.", + "remainingWork": 2, + "activity": "Deployment", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Final QA Validation", + "description": "Perform final QA validation in the staging environment before production release.", + "remainingWork": 1, + "activity": "Testing", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Security Review", + "description": "Conduct security review of the changes before production deployment.", + "remainingWork": 2, + "activity": "Requirements", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Performance Testing", + "description": "Conduct performance testing to ensure the changes meet performance requirements.", + "remainingWork": 3, + "activity": "Testing", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Create Rollback Plan", + "description": "Create a detailed rollback plan in case the deployment needs to be reverted.", + "remainingWork": 1, + "activity": "Design", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Deploy to Production", + "description": "Deploy the validated build to the production environment.", + "remainingWork": 3, + "activity": "Deployment", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Post-Deployment Monitoring", + "description": "Monitor the production environment after deployment to ensure stability.", + "remainingWork": 4, + "activity": "Testing", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "Update Release Documentation", + "description": "Update documentation to reflect the changes included in this release.", + "remainingWork": 2, + "activity": "Documentation", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + }, + { + "title": "User Notification", + "description": "Notify users about the new release and any important changes or features.", + "remainingWork": 1, + "activity": "Documentation", + "metadata": { + "assigneeRequired": false, + "appliesTo": [ + "Feature", + "User Story" + ] + } + } + ] +} \ No newline at end of file diff --git a/resources/tasks/review.json b/resources/tasks/review.json new file mode 100644 index 0000000..6a26c2e --- /dev/null +++ b/resources/tasks/review.json @@ -0,0 +1,95 @@ +{ + "$schema": "../schemas/task.schema.json", + "version": "1.0.0", + "metadata": { + "name": "Code Review Tasks", + "description": "Pre-defined tasks for code review and pull request workflows", + "author": "tacosontitan" + }, + "templates": [ + { + "title": "Create Draft PR", + "description": "Create a draft pull request to initiate the code review process.", + "remainingWork": 0.01, + "activity": "Development", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story", + "Bug" + ] + } + }, + { + "title": "Self-Review PR", + "description": "Perform a self-review of the pull request to ensure code quality and completeness.", + "remainingWork": 0.5, + "activity": "Development", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story", + "Bug" + ] + } + }, + { + "title": "Publish PR", + "description": "Publish the pull request for review by other team members.", + "remainingWork": 0.01, + "activity": "Development", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story", + "Bug" + ] + } + }, + { + "title": "Run PR Validations", + "description": "Run automated validations for the pull request to ensure compliance with standards.", + "remainingWork": 0.01, + "activity": "Development", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story", + "Bug" + ] + } + }, + { + "title": "Resolve PR Feedback", + "description": "Address feedback provided during the pull request review process.", + "remainingWork": 1, + "activity": "Development", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story", + "Bug" + ] + } + }, + { + "title": "Complete PR", + "description": "Complete the pull request after all validations and reviews are successful.", + "remainingWork": 0.01, + "activity": "Development", + "metadata": { + "assigneeRequired": true, + "appliesTo": [ + "Feature", + "User Story", + "Bug" + ] + } + } + ] +} \ No newline at end of file diff --git a/src/extension/application/providers/meeting-view-provider.ts b/src/extension/application/providers/meeting-view-provider.ts new file mode 100644 index 0000000..574c63a --- /dev/null +++ b/src/extension/application/providers/meeting-view-provider.ts @@ -0,0 +1,346 @@ +import * as azdev from 'azure-devops-node-api'; +import * as vscode from 'vscode'; +import { DevOpsService } from '../../infrastructure/azure/devops-service'; +import { getMeetingTemplate, getMeetingTypes } from '../../todo/meetings/meeting-templates'; +import { getConfiguredParticipants } from '../../todo/meetings/participant-config'; + +export class MeetingViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'meetingView'; + + private _view?: vscode.WebviewView; + + constructor( + private readonly _extensionUri: vscode.Uri, + private readonly devOpsService: DevOpsService + ) { } + + public resolveWebviewView( + webviewView: vscode.WebviewView, + context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + this._extensionUri + ] + }; + + webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); + + webviewView.webview.onDidReceiveMessage(data => { + switch (data.type) { + case 'templateSelected': + { + const template = getMeetingTemplate(data.meetingType, data.selectedParticipants || []); + webviewView.webview.postMessage({ + type: 'updateTemplate', + template: template + }); + break; + } + case 'participantsChanged': + { + // Regenerate template with new participants if a meeting type is selected + const meetingTypeElement = data.meetingType; + if (meetingTypeElement) { + const template = getMeetingTemplate(meetingTypeElement, data.selectedParticipants || []); + webviewView.webview.postMessage({ + type: 'updateTemplate', + template: template + }); + } + break; + } + case 'sendNotes': + { + this.sendNotesToWorkItem(data.workItemId, data.notes); + break; + } + } + }); + } + + private async sendNotesToWorkItem(workItemId: string, notes: string) { + try { + const id = parseInt(workItemId); + if (isNaN(id)) { + vscode.window.showErrorMessage('Please enter a valid work item ID.'); + return; + } + + // Add the meeting notes as a comment to the work item + await this.addCommentToWorkItem(id, notes); + + vscode.window.showInformationMessage(`Meeting notes successfully added to work item #${id}`); + + // Clear the form + if (this._view) { + this._view.webview.postMessage({ + type: 'clearForm' + }); + } + } catch (error: any) { + vscode.window.showErrorMessage(`Failed to send meeting notes: ${error.message}`); + } + } + + private async addCommentToWorkItem(workItemId: number, comment: string) { + // Get the Azure DevOps connection + const pat = await this.devOpsService.getPersonalAccessToken(); + const organizationUri = await this.devOpsService.getOrganizationUri(); + const projectName = await this.devOpsService.getProjectName(); + + if (!pat || !organizationUri || !projectName) { + throw new Error('Azure DevOps configuration is incomplete'); + } + const authHandler = azdev.getPersonalAccessTokenHandler(pat); + const connection = new azdev.WebApi(organizationUri, authHandler); + const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Add comment using work item update patch operation + const patchDocument = [ + { + op: 'add', + path: '/fields/System.History', + value: comment + } + ]; + + await workItemTrackingApi.updateWorkItem(null, patchDocument, workItemId, projectName); + } + + private _getHtmlForWebview(webview: vscode.Webview) { + const meetingTypes = getMeetingTypes(); + const meetingOptionsHtml = meetingTypes.map(type => + `` + ).join(''); + + const participants = getConfiguredParticipants(); + const participantsHtml = participants.map(participant => + `` + ).join(''); + + return ` + + + + + Meeting Notes + + + +
+ + +
+ +
+ +
+ ${participantsHtml} +
+
+ +
+ + +
+ +
+ +
+ + +
+
+ + + + `; + } +} \ No newline at end of file diff --git a/src/extension/application/providers/overview-webview-provider.ts b/src/extension/application/providers/overview-webview-provider.ts new file mode 100644 index 0000000..2553eb3 --- /dev/null +++ b/src/extension/application/providers/overview-webview-provider.ts @@ -0,0 +1,610 @@ +import * as devops from "azure-devops-node-api"; +import { WebApi } from 'azure-devops-node-api/WebApi'; +import { WorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; +import * as vscode from 'vscode'; +import { DevOpsService } from '../../infrastructure/azure/devops-service'; + +export class OverviewWebviewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'overviewWebView'; + private _view?: vscode.WebviewView; + private cycleTimeData: { average: string; count: number } | null = null; + private cycleTimeLoaded = false; + private recentCompletionsData: { count: number; days: number } | null = null; + private recentCompletionsLoaded = false; + private oldestWorkItemData: { daysAgo: number; days: number } | null = null; + private oldestWorkItemLoaded = false; + static MILLISECONDS_PER_DAY: number = 1000 * 60 * 60 * 24; + + constructor( + private readonly _extensionUri: vscode.Uri, + private devOpsService?: DevOpsService + ) { + // Load cycle time data asynchronously + if (this.devOpsService) { + this.loadCycleTimeData(); + this.loadRecentCompletionsData(); + this.loadOldestWorkItemData(); + } + } + + public resolveWebviewView( + webviewView: vscode.WebviewView, + context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this._extensionUri] + }; + + webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); + + webviewView.webview.onDidReceiveMessage((data: any) => { + switch (data.type) { + case 'refresh': + this.refresh(); + break; + } + }); + } + + public refresh() { + if (this.devOpsService) { + this.loadCycleTimeData(); + this.loadRecentCompletionsData(); + this.loadOldestWorkItemData(); + } + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + + private _getHtmlForWebview(webview: vscode.Webview) { + const greeting = this.getGreeting(); + const cycleTimeTile = this.getCycleTimeTileHtml(); + const recentCompletionsTile = this.getRecentCompletionsTileHtml(); + const oldestWorkItemTile = this.getOldestWorkItemTileHtml(); + + return ` + + + + + Overview + + + +
${greeting}
+ +
+ ${cycleTimeTile} + ${recentCompletionsTile} + ${oldestWorkItemTile} +
+ + + + + + `; + } + + private getGreeting(): string { + const now = new Date(); + const greeting = "hey there! today is"; + const dateInfo = this.getDateInfo(now); + const wordOfTheDay = this.getWordOfTheDay(); + + return `${greeting} ${dateInfo}, and the word of the day is ${wordOfTheDay}`; + } + + private getCycleTimeTileHtml(): string { + if (!this.devOpsService) { + return ''; + } + + if (!this.cycleTimeLoaded) { + return ` +
+
Average Cycle Time
+
Loading...
+
+ `; + } + + if (!this.cycleTimeData) { + return ` +
+
Average Cycle Time
+
No data available
+
+ `; + } + + return ` +
+
Average Cycle Time
+
${this.cycleTimeData.average} days
+
Based on ${this.cycleTimeData.count} completed bugs and user stories
+
+ `; + } + + private getRecentCompletionsTileHtml(): string { + if (!this.devOpsService) { + return ''; + } + + if (!this.recentCompletionsLoaded) { + return ` +
+
Recent Completions
+
Loading...
+
+ `; + } + + if (!this.recentCompletionsData) { + return ` +
+
Recent Completions
+
No data available
+
+ `; + } + + return ` +
+
Recent Completions
+
${this.recentCompletionsData.count} items
+
Bugs and user stories completed in the last ${this.recentCompletionsData.days} days
+
+ `; + } + + private getOldestWorkItemTileHtml(): string { + if (!this.devOpsService) { + return ''; + } + + if (!this.oldestWorkItemLoaded) { + return ` +
+
Oldest Completed Item
+
Loading...
+
+ `; + } + + if (!this.oldestWorkItemData) { + return ` +
+
Oldest Completed Item
+
No data available
+
+ `; + } + + return ` +
+
Oldest Completed Item
+
${this.oldestWorkItemData.daysAgo} days ago
+
Oldest item closed ${this.oldestWorkItemData.days}+ days ago
+
+ `; + } + + private getDateInfo(date: Date): string { + const monthNames = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ]; + + const month = monthNames[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + const dayOfYear = this.getDayOfYear(date); + + const dayWithSuffix = this.getDayWithSuffix(day); + + return `${month} ${dayWithSuffix}, the ${dayOfYear} day of ${year}`; + } + + private getDayWithSuffix(day: number): string { + if (day >= 11 && day <= 13) { + return `${day}th`; + } + switch (day % 10) { + case 1: return `${day}st`; + case 2: return `${day}nd`; + case 3: return `${day}rd`; + default: return `${day}th`; + } + } + + private getDayOfYear(date: Date): string { + const start = new Date(date.getFullYear(), 0, 0); + const diff = (date.getTime() - start.getTime()) + ((start.getTimezoneOffset() - date.getTimezoneOffset()) * 60 * 1000); + const oneDay = 1000 * 60 * 60 * 24; + const dayOfYear = Math.floor(diff / oneDay); + + return this.getNumberWithSuffix(dayOfYear); + } + + private getNumberWithSuffix(num: number): string { + if (num >= 11 && num <= 13) { + return `${num}th`; + } + switch (num % 10) { + case 1: return `${num}st`; + case 2: return `${num}nd`; + case 3: return `${num}rd`; + default: return `${num}th`; + } + } + + private getWordOfTheDay(): string { + // Simple word of the day based on date for consistency + const words = [ + "volcano", "serenity", "adventure", "harmony", "discovery", "creativity", "wonder", + "journey", "inspiration", "tranquility", "exploration", "imagination", "courage", + "wisdom", "beauty", "mystery", "freedom", "passion", "growth", "balance" + ]; + + const today = new Date(); + const dayOfYear = Math.floor((today.getTime() - new Date(today.getFullYear(), 0, 0).getTime()) / OverviewWebviewProvider.MILLISECONDS_PER_DAY); + return words[dayOfYear % words.length]; + } + + private async getRecentCompletionsDays(): Promise { + const config = vscode.workspace.getConfiguration('tacosontitan.toolbox'); + return config.get('overview.recentCompletionsDays', 30); + } + + private async getCycleTimeInfo(): Promise<{ average: string; count: number } | null> { + if (!this.devOpsService) { + return null; + } + + try { + const personalAccessToken = await this.devOpsService.getPersonalAccessToken(); + const organizationUri = await this.devOpsService.getOrganizationUri(); + const projectName = await this.devOpsService.getProjectName(); + const userDisplayName = await this.devOpsService.getUserDisplayName(); + + if (!personalAccessToken || !organizationUri || !projectName || !userDisplayName) { + return null; + } + + const authenticationHandler = devops.getPersonalAccessTokenHandler(personalAccessToken); + const connection = new WebApi(organizationUri, authenticationHandler); + const workItemTrackingClient: WorkItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Query for completed bugs and user stories assigned to the user + const wiql = { + query: `SELECT [System.Id], [System.WorkItemType], [System.ActivatedDate], [Microsoft.VSTS.Common.ClosedDate] + FROM WorkItems + WHERE [System.AssignedTo] = '${userDisplayName}' + AND [System.WorkItemType] IN ('Bug', 'User Story') + AND [System.State] IN ('Closed', 'Done', 'Resolved') + AND [System.ActivatedDate] <> '' + AND [Microsoft.VSTS.Common.ClosedDate] <> '' + ORDER BY [System.Id]` + }; + + const queryResult = await workItemTrackingClient.queryByWiql(wiql, { project: projectName }); + + if (!queryResult.workItems || queryResult.workItems.length === 0) { + return null; + } + + const workItemIds = queryResult.workItems.map((wi: any) => wi.id!); + const workItems = await workItemTrackingClient.getWorkItems( + workItemIds, + ['System.Id', 'System.WorkItemType', 'System.ActivatedDate', 'Microsoft.VSTS.Common.ClosedDate'], + undefined, + undefined, + undefined, + projectName + ); + + const cycleTimes: number[] = []; + + for (const workItem of workItems) { + const activatedDate = workItem.fields?.['System.ActivatedDate']; + const closedDate = workItem.fields?.['Microsoft.VSTS.Common.ClosedDate']; + + if (activatedDate && closedDate) { + const activated = new Date(activatedDate); + const closed = new Date(closedDate); + const cycleTimeMs = closed.getTime() - activated.getTime(); + const cycleTimeDays = cycleTimeMs / (1000 * 60 * 60 * 24); + + if (cycleTimeDays > 0) { + cycleTimes.push(cycleTimeDays); + } + } + } + + if (cycleTimes.length === 0) { + return null; + } + + const averageCycleTime = cycleTimes.reduce((sum, time) => sum + time, 0) / cycleTimes.length; + const formattedAverage = averageCycleTime.toFixed(1); + + return { + average: formattedAverage, + count: cycleTimes.length + }; + + } catch (error) { + // Return null to silently ignore errors and not disrupt the overview + return null; + } + } + + private async loadCycleTimeData(): Promise { + this.cycleTimeLoaded = false; + try { + this.cycleTimeData = await this.getCycleTimeInfo(); + this.cycleTimeLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } catch (error) { + // Silently ignore errors + this.cycleTimeLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + } + + private async loadRecentCompletionsData(): Promise { + this.recentCompletionsLoaded = false; + try { + this.recentCompletionsData = await this.getRecentCompletionsInfo(); + this.recentCompletionsLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } catch (error) { + // Silently ignore errors + this.recentCompletionsLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + } + + private async loadOldestWorkItemData(): Promise { + this.oldestWorkItemLoaded = false; + try { + this.oldestWorkItemData = await this.getOldestWorkItemInfo(); + this.oldestWorkItemLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } catch (error) { + // Silently ignore errors + this.oldestWorkItemLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + } + + private async getRecentCompletionsInfo(): Promise<{ count: number; days: number } | null> { + if (!this.devOpsService) { + return null; + } + + try { + const personalAccessToken = await this.devOpsService.getPersonalAccessToken(); + const organizationUri = await this.devOpsService.getOrganizationUri(); + const projectName = await this.devOpsService.getProjectName(); + const userDisplayName = await this.devOpsService.getUserDisplayName(); + const days = await this.getRecentCompletionsDays(); + + if (!personalAccessToken || !organizationUri || !projectName || !userDisplayName) { + return null; + } + + const authenticationHandler = devops.getPersonalAccessTokenHandler(personalAccessToken); + const connection = new WebApi(organizationUri, authenticationHandler); + const workItemTrackingClient: WorkItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Calculate the date N days ago + const nDaysAgo = new Date(); + nDaysAgo.setDate(nDaysAgo.getDate() - days); + const nDaysAgoISOString = nDaysAgo.toISOString(); + + // Query for completed bugs and user stories assigned to the user in the last N days + const wiql = { + query: `SELECT [System.Id], [System.WorkItemType], [Microsoft.VSTS.Common.ClosedDate] + FROM WorkItems + WHERE [System.AssignedTo] = '${userDisplayName}' + AND [System.WorkItemType] IN ('Bug', 'User Story') + AND [System.State] IN ('Closed', 'Done', 'Resolved') + AND [Microsoft.VSTS.Common.ClosedDate] >= '${nDaysAgoISOString}' + ORDER BY [Microsoft.VSTS.Common.ClosedDate] DESC` + }; + + const queryResult = await workItemTrackingClient.queryByWiql(wiql, { project: projectName }); + + const count = queryResult.workItems ? queryResult.workItems.length : 0; + + return { + count: count, + days: days + }; + + } catch (error) { + // Return null to silently ignore errors and not disrupt the overview + return null; + } + } + + private async getOldestWorkItemInfo(): Promise<{ daysAgo: number; days: number } | null> { + if (!this.devOpsService) { + return null; + } + + try { + const personalAccessToken = await this.devOpsService.getPersonalAccessToken(); + const organizationUri = await this.devOpsService.getOrganizationUri(); + const projectName = await this.devOpsService.getProjectName(); + const userDisplayName = await this.devOpsService.getUserDisplayName(); + const days = await this.getRecentCompletionsDays(); + + if (!personalAccessToken || !organizationUri || !projectName || !userDisplayName) { + return null; + } + + const authenticationHandler = devops.getPersonalAccessTokenHandler(personalAccessToken); + const connection = new WebApi(organizationUri, authenticationHandler); + const workItemTrackingClient: WorkItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Calculate the date N days ago + const nDaysAgo = new Date(); + nDaysAgo.setDate(nDaysAgo.getDate() - days); + const nDaysAgoISOString = nDaysAgo.toISOString(); + + // Query for completed bugs and user stories assigned to the user that were closed N or more days ago + const wiql = { + query: `SELECT [System.Id], [System.WorkItemType], [Microsoft.VSTS.Common.ClosedDate] + FROM WorkItems + WHERE [System.AssignedTo] = '${userDisplayName}' + AND [System.WorkItemType] IN ('Bug', 'User Story') + AND [System.State] IN ('Closed', 'Done', 'Resolved') + AND [Microsoft.VSTS.Common.ClosedDate] <= '${nDaysAgoISOString}' + ORDER BY [Microsoft.VSTS.Common.ClosedDate] ASC` + }; + + const queryResult = await workItemTrackingClient.queryByWiql(wiql, { project: projectName }); + + if (!queryResult.workItems || queryResult.workItems.length === 0) { + return null; + } + + // Get the oldest work item (first in the ascending order) + const workItemIds = [queryResult.workItems[0].id!]; + const workItems = await workItemTrackingClient.getWorkItems( + workItemIds, + ['System.Id', 'System.WorkItemType', 'Microsoft.VSTS.Common.ClosedDate'], + undefined, + undefined, + undefined, + projectName + ); + + if (workItems.length === 0) { + return null; + } + + const closedDate = workItems[0].fields?.['Microsoft.VSTS.Common.ClosedDate']; + if (!closedDate) { + return null; + } + + const closed = new Date(closedDate); + const now = new Date(); + const daysAgo = Math.floor((now.getTime() - closed.getTime()) / (1000 * 60 * 60 * 24)); + + return { + daysAgo: daysAgo, + days: days + }; + + } catch (error) { + // Return null to silently ignore errors and not disrupt the overview + return null; + } + } +} \ No newline at end of file diff --git a/src/extension/providers/tasks-tree-data-provider.ts b/src/extension/application/providers/tasks-tree-data-provider.ts similarity index 97% rename from src/extension/providers/tasks-tree-data-provider.ts rename to src/extension/application/providers/tasks-tree-data-provider.ts index b3a4dcf..bd83125 100644 --- a/src/extension/providers/tasks-tree-data-provider.ts +++ b/src/extension/application/providers/tasks-tree-data-provider.ts @@ -3,11 +3,11 @@ import { WebApi } from 'azure-devops-node-api/WebApi'; import { WorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; import { WorkItem } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; import * as vscode from 'vscode'; -import { PlaceholderTreeItem } from '../core/placeholder-tree-item'; -import { StateGroupTreeItem } from '../core/state-group-tree-item'; -import { TaskTreeItem } from '../core/task-tree-item'; -import { WorkItemTreeItem } from '../core/work-item-tree-item'; -import { DevOpsService } from '../services/devops-service'; +import { DevOpsService } from '../../infrastructure/azure/devops-service'; +import { PlaceholderTreeItem } from '../../presentation/placeholder-tree-item'; +import { StateGroupTreeItem } from '../../presentation/workflow/state-group-tree-item'; +import { TaskTreeItem } from '../../presentation/workflow/task-tree-item'; +import { WorkItemTreeItem } from '../../presentation/workflow/work-item-tree-item'; export class TasksTreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); @@ -25,13 +25,13 @@ export class TasksTreeDataProvider implements vscode.TreeDataProvider { - this._onDidChangeTreeData.fire(); + this._onDidChangeTreeData.fire(undefined); }).catch(() => { // Clear data if reload fails this.activeWorkItems = []; this.workItemTasks.clear(); this.stateGroupToWorkItem.clear(); - this._onDidChangeTreeData.fire(); + this._onDidChangeTreeData.fire(undefined); }); } diff --git a/src/extension/application/providers/tasks-tree-provider.ts b/src/extension/application/providers/tasks-tree-provider.ts new file mode 100644 index 0000000..b5efaf5 --- /dev/null +++ b/src/extension/application/providers/tasks-tree-provider.ts @@ -0,0 +1,7 @@ +// Re-export all tree-related classes from their individual files +export { PlaceholderTreeItem } from '../../presentation/placeholder-tree-item'; +export { StateGroupTreeItem } from '../../presentation/workflow/state-group-tree-item'; +export { TaskTreeItem } from '../../presentation/workflow/task-tree-item'; +export { WorkItemTreeItem } from '../../presentation/workflow/work-item-tree-item'; +export { TasksTreeDataProvider } from './tasks-tree-data-provider'; + diff --git a/src/extension/application/providers/time-tree-data-provider.ts b/src/extension/application/providers/time-tree-data-provider.ts new file mode 100644 index 0000000..32ddcd6 --- /dev/null +++ b/src/extension/application/providers/time-tree-data-provider.ts @@ -0,0 +1,75 @@ +import * as vscode from 'vscode'; +import { PlaceholderTreeItem } from '../../presentation/placeholder-tree-item'; +import { ClockEventTreeItem } from '../../presentation/time/clock-event-tree-item'; +import { DayTreeItem } from '../../presentation/time/day-tree-item'; +import { TimeEntryService } from '../time/time-entry-service'; + +export class TimeTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + constructor(private timeEntryService: TimeEntryService) {} + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: DayTreeItem | ClockEventTreeItem | PlaceholderTreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: DayTreeItem | ClockEventTreeItem | PlaceholderTreeItem): Promise<(DayTreeItem | ClockEventTreeItem | PlaceholderTreeItem)[]> { + if (!element) { + // Root level - show day entries + try { + const dayEntries = await this.timeEntryService.getTimeEntriesForDisplay(); + + if (dayEntries.length === 0) { + return [new PlaceholderTreeItem("No time entries", "Start tracking time by clocking in", 'clock')]; + } + + const result: (DayTreeItem | PlaceholderTreeItem)[] = []; + + // Add day entries + for (const dayEntry of dayEntries) { + result.push(new DayTreeItem(dayEntry, vscode.TreeItemCollapsibleState.Collapsed)); + } + + // Add "Load More" option if there might be more entries + if (dayEntries.length >= 10) { + const loadMoreItem = new PlaceholderTreeItem("Load more days...", "Click to load additional work days", 'arrow-down'); + loadMoreItem.contextValue = 'loadMore'; + result.push(loadMoreItem); + } + + return result; + } catch (error) { + return [new PlaceholderTreeItem("Error loading time entries", (error as Error).message, 'error')]; + } + } + + if (element instanceof DayTreeItem) { + // Show clock events for this day + const clockEvents = element.dayEntry.entries + .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + .map(entry => new ClockEventTreeItem(entry)); + + if (clockEvents.length === 0) { + return [new PlaceholderTreeItem("No entries", "No clock events for this day", 'info')]; + } + + return clockEvents; + } + + // Placeholder and ClockEvent items have no children + return []; + } + + /** + * Handles the "Load more days" functionality + */ + async loadMoreDays(): Promise { + await this.timeEntryService.loadMoreDays(); + this.refresh(); + } +} \ No newline at end of file diff --git a/src/extension/application/time/time-entry-service.ts b/src/extension/application/time/time-entry-service.ts new file mode 100644 index 0000000..334c7b9 --- /dev/null +++ b/src/extension/application/time/time-entry-service.ts @@ -0,0 +1,193 @@ +import * as vscode from 'vscode'; +import { DayTimeEntry, TimeEntry, TimeEntryUtils } from '../../domain/time/time-entry'; + +/** + * Service for managing time entries using VS Code's global state + */ +export class TimeEntryService { + private static readonly TIME_ENTRIES_KEY = 'tacosontitan.toolbox.timeEntries'; + private static readonly DAYS_TO_LOAD_KEY = 'tacosontitan.toolbox.daysToLoad'; + private static readonly DEFAULT_DAYS_TO_SHOW = 10; + + constructor(private context: vscode.ExtensionContext) { + // Auto-cleanup on startup if enabled + this.performAutoCleanupIfEnabled(); + } + + /** + * Records a clock in event + */ + async clockIn(): Promise { + const now = new Date(); + const entry: TimeEntry = { + id: TimeEntryUtils.generateId(), + type: 'clock-in', + timestamp: now + }; + + await this.addTimeEntry(entry); + vscode.window.showInformationMessage(`Clocked in at ${TimeEntryUtils.formatTime(now)}`); + } + + /** + * Records a clock out event + */ + async clockOut(): Promise { + const now = new Date(); + const entry: TimeEntry = { + id: TimeEntryUtils.generateId(), + type: 'clock-out', + timestamp: now + }; + + await this.addTimeEntry(entry); + vscode.window.showInformationMessage(`Clocked out at ${TimeEntryUtils.formatTime(now)}`); + } + + /** + * Gets the current clock status (whether user is clocked in or out) + */ + async getCurrentStatus(): Promise<'clocked-in' | 'clocked-out'> { + const entries = await this.getAllTimeEntries(); + if (entries.length === 0) { + return 'clocked-out'; + } + + // Find the most recent entry + const sortedEntries = entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + const lastEntry = sortedEntries[0]; + + return lastEntry.type === 'clock-in' ? 'clocked-in' : 'clocked-out'; + } + + /** + * Gets time entries grouped by day for display + */ + async getTimeEntriesForDisplay(): Promise { + const daysToShow = await this.getDaysToLoad(); + const allEntries = await this.getAllTimeEntries(); + + // Group by date + const groupedByDate = TimeEntryUtils.groupEntriesByDate(allEntries); + + // Filter to only show work days (exclude weekends) and limit to requested number of days + const workDays = groupedByDate.filter(day => { + const date = new Date(day.date); + const dayOfWeek = date.getDay(); + return dayOfWeek !== 0 && dayOfWeek !== 6; // 0 = Sunday, 6 = Saturday + }); + + return workDays.slice(0, daysToShow); + } + + /** + * Loads more days (increases the number of days to show) + */ + async loadMoreDays(): Promise { + const currentDays = await this.getDaysToLoad(); + const newDays = currentDays + 10; + await this.context.globalState.update(TimeEntryService.DAYS_TO_LOAD_KEY, newDays); + } + + /** + * Resets to show only the default number of days + */ + async resetDaysToLoad(): Promise { + await this.context.globalState.update(TimeEntryService.DAYS_TO_LOAD_KEY, TimeEntryService.DEFAULT_DAYS_TO_SHOW); + } + + /** + * Gets all time entries from storage + */ + private async getAllTimeEntries(): Promise { + const stored = this.context.globalState.get(TimeEntryService.TIME_ENTRIES_KEY, []); + + // Ensure all timestamps are converted to Date objects + return stored.map(item => ({ + id: item.id, + type: item.type, + timestamp: new Date(item.timestamp) + })); + } + + /** + * Adds a new time entry to storage + */ + private async addTimeEntry(entry: TimeEntry): Promise { + const entries = await this.getAllTimeEntries(); + entries.push(entry); + + // Sort by timestamp to maintain chronological order + entries.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + + // Convert to plain objects for storage (JSON serialization) + const plainEntries = entries.map(e => ({ + id: e.id, + type: e.type, + timestamp: e.timestamp.toISOString() + })); + + await this.context.globalState.update(TimeEntryService.TIME_ENTRIES_KEY, plainEntries); + } + + /** + * Gets the number of days to load for display + */ + private async getDaysToLoad(): Promise { + return this.context.globalState.get(TimeEntryService.DAYS_TO_LOAD_KEY, TimeEntryService.DEFAULT_DAYS_TO_SHOW); + } + + /** + * Clears all time entries (for testing/debugging) + */ + async clearAllEntries(): Promise { + await this.context.globalState.update(TimeEntryService.TIME_ENTRIES_KEY, []); + await this.resetDaysToLoad(); + vscode.window.showInformationMessage('All time entries cleared'); + } + + /** + * Performs automatic cleanup if enabled in settings + */ + private async performAutoCleanupIfEnabled(): Promise { + const config = vscode.workspace.getConfiguration('tacosontitan.toolbox.time'); + const autoCleanup = config.get('autoCleanup', true); + + if (autoCleanup) { + await this.cleanupOldEntries(false); // Silent cleanup on startup + } + } + + /** + * Cleans up time entries older than the retention period + */ + async cleanupOldEntries(showMessage: boolean = true): Promise { + const config = vscode.workspace.getConfiguration('tacosontitan.toolbox.time'); + const retentionDays = config.get('retentionDays', 90); + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + const allEntries = await this.getAllTimeEntries(); + const filteredEntries = allEntries.filter(entry => entry.timestamp >= cutoffDate); + + const removedCount = allEntries.length - filteredEntries.length; + + if (removedCount > 0) { + // Convert to plain objects for storage + const plainEntries = filteredEntries.map(e => ({ + id: e.id, + type: e.type, + timestamp: e.timestamp.toISOString() + })); + + await this.context.globalState.update(TimeEntryService.TIME_ENTRIES_KEY, plainEntries); + + if (showMessage) { + vscode.window.showInformationMessage(`Cleaned up ${removedCount} old time entries (older than ${retentionDays} days)`); + } + } else if (showMessage) { + vscode.window.showInformationMessage(`No old entries to clean up (retention period: ${retentionDays} days)`); + } + } +} \ No newline at end of file diff --git a/src/extension/application/workflow/workflow.service.ts b/src/extension/application/workflow/workflow.service.ts new file mode 100644 index 0000000..028da03 --- /dev/null +++ b/src/extension/application/workflow/workflow.service.ts @@ -0,0 +1,46 @@ +import { IConfiguration } from "../../core"; +import { IRepository } from "../../core/repository"; +import { ILogger, LogLevel } from "../../core/telemetry"; +import { ITaskService, WorkflowOptions, WorkItem } from "../../domain/workflow"; +import { IWorkflowService } from "../../domain/workflow/workflow.service.interface"; + +/** @inheritdoc */ +export class WorkflowService implements IWorkflowService { + + /** + * @constructor + * @param logger The service used to capture errors and diagnostic messaging. + * @param workflowConfiguration The configuration service for workflow options. + * @param workItemRepository The repository used to manage work items. + */ + constructor( + private readonly logger: ILogger, + private readonly workflowConfiguration: IConfiguration, + private readonly workItemRepository: IRepository, + private readonly taskService: ITaskService + ) { } + + /** @inheritdoc */ + public async start(workItemId: number): Promise { + this.logger.log(LogLevel.Trace, `Attempting to start work item ${workItemId}.`); + const workItem: WorkItem | undefined = await this.workItemRepository.getById(workItemId); + if (!workItem) { + throw new Error(`Unable to locate work item ${workItemId}.`); + } + + const workflowOptions: WorkflowOptions = await this.workflowConfiguration.get(); + workItem.start(workflowOptions.workItemStartedState); + + const defaultTasks: WorkItem[] = this.taskService.getDefaultTasksForWorkItem(workItem); + for (const task of defaultTasks) { + workItem.addChild(task); + } + + await this.workItemRepository.update(workItem); + for (const child of workItem.children) { + await this.workItemRepository.create(child); + } + + this.logger.log(LogLevel.Information, `Work item ${workItemId} has been started with ${defaultTasks.length} default tasks.`); + } +} \ No newline at end of file diff --git a/src/extension/commands/create-default-tasks.command.ts b/src/extension/commands/create-default-tasks.command.ts deleted file mode 100644 index 456cd40..0000000 --- a/src/extension/commands/create-default-tasks.command.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as devops from "azure-devops-node-api"; -import * as vscode from 'vscode'; - -import { WebApi } from 'azure-devops-node-api/WebApi'; -import { WorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; -import { Command } from '../core/command'; -import { IConfigurationProvider, ISecretProvider } from "../core/configuration"; -import { DefaultTasks } from "../core/default-tasks"; -import { ILogger, LogLevel } from "../core/telemetry"; -import { IWorkItemService } from "../core/workflow"; -import { PreDefinedTaskJsonPatchDocumentMapper } from '../core/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper'; -import { DevOpsService } from "../services/devops-service"; - -/** - * Represents a {@link Command} that creates pre-defined tasks representing the typical workflow of a work item. - */ -export class CreateDefaultTasksCommand - extends Command { - - /** - * Creates a new {@link CreateDefaultTasksCommand} instance. - */ - constructor( - private readonly secretProvider: ISecretProvider, - private readonly configurationProvider: IConfigurationProvider, - private readonly logger: ILogger, - private readonly workItemService: IWorkItemService, - private readonly devOpsService: DevOpsService, - ) { - super('createDefaultTasks'); - } - - /** @inheritdoc */ - public async execute(...args: any[]): Promise { - const personalAccessToken = await this.devOpsService.getPersonalAccessToken(); - if (!personalAccessToken) { - vscode.window.showErrorMessage('This command requires a personal access token (PAT) to be configured.'); - this.logger.log(LogLevel.Error, 'Personal access token (PAT) is not configured. Please set it in the configuration.'); - return; - } - - const organizationUri = await this.devOpsService.getOrganizationUri(); - if (!organizationUri) { - vscode.window.showErrorMessage('This command requires an Azure DevOps organization to be configured.'); - this.logger.log(LogLevel.Error, 'Azure DevOps organization is not configured. Please set it in the configuration.'); - return; - } - - const projectName = await this.devOpsService.getProjectName(); - if (!projectName) { - vscode.window.showErrorMessage("Azure DevOps project is not configured. Commands that require it will not work."); - this.logger.log(LogLevel.Error, "Azure DevOps project is not configured. Commands that require it will not work."); - return; - } - - const userDisplayName = await this.devOpsService.getUserDisplayName(); - if (!userDisplayName) { - vscode.window.showErrorMessage('This command requires a user display name to be configured.'); - this.logger.log(LogLevel.Error, 'User display name is not configured. Please set it in the configuration.'); - return; - } - - const authenticationHandler = devops.getPersonalAccessTokenHandler(personalAccessToken); - const connection = new WebApi(organizationUri, authenticationHandler); - const workItemTrackingClient: WorkItemTrackingApi = await connection.getWorkItemTrackingApi(); - - const workItemNumberResponse = await vscode.window.showInputBox({ prompt: 'Enter the work item number for which you want to create default tasks.' }); - const workItemNumber = parseInt(workItemNumberResponse ?? '-1'); - if (isNaN(workItemNumber) || workItemNumber <= 0) { - vscode.window.showErrorMessage('Work item number is required to create default tasks.'); - this.logger.log(LogLevel.Error, 'Invalid work item number provided. Please enter a valid work item number.'); - return; - } - - const workItem = await this.workItemService.getWorkItem(workItemNumber); - if (!workItem) { - vscode.window.showErrorMessage(`Work item #${workItemNumber} not found.`); - return; - } - - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Creating default tasks for work item #${workItemNumber} in project '${projectName}'.`, - cancellable: false - }, - async (progress) => { - this.logger.log(LogLevel.Debug, `Creating default tasks for work item #${workItemNumber} in project '${projectName}'.`); - const taskMapper = new PreDefinedTaskJsonPatchDocumentMapper(userDisplayName, organizationUri, workItemNumber, workItem.areaPath || "", workItem.iterationPath || ""); - let completed = 0; - - for (const task of DefaultTasks) { - // if (task.requiredFields && !task.requiredFields.every(field => workItemFields[field] !== undefined && workItemFields[field] !== null && workItemFields[field] !== '')) { - // this.logger.log(LogLevel.Warning, `Skipping task '${task.name}' as one or more required fields are missing or empty.`); - // continue; - // } - - progress.report({ message: `Creating '${task.name}' (${++completed}/${DefaultTasks.length})` }); - try { - const patchDocument = taskMapper.map(task); - const createdTask = await workItemTrackingClient.createWorkItem( - [], - patchDocument, - projectName, - 'Task' - ); - - this.logger.log(LogLevel.Debug, `Created task '${task.name}' with ID ${createdTask.id} under work item #${workItemNumber}.`); - } catch (error) { - const errorMessage = (error as Error).message; - this.logger.log(LogLevel.Error, `Failed to create task '${task.name}': ${errorMessage}`); - } - } - - this.logger.log(LogLevel.Information, `Default tasks created for work item #${workItemNumber}.`); - } - ); - - const action = await vscode.window.showInformationMessage( - `Default tasks created for work item #${workItemNumber}.`, - 'Show Output' - ); - - if (action === 'Show Output' && typeof this.logger.open === 'function') { - this.logger.open(); - } - - return Promise.resolve(); - } -} \ No newline at end of file diff --git a/src/extension/commands/set-task-state.command.ts b/src/extension/commands/set-task-state.command.ts deleted file mode 100644 index 814496a..0000000 --- a/src/extension/commands/set-task-state.command.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as vscode from 'vscode'; -import { Command } from '../core/command'; -import { IConfigurationProvider, ISecretProvider } from "../core/configuration"; -import { TaskTreeItem } from '../core/task-tree-item'; -import { IWorkItemService } from '../core/workflow'; -import { TasksTreeDataProvider } from '../providers/tasks-tree-data-provider'; - -/** - * Base command to set task state to a specific value. - */ -export abstract class SetTaskStateCommand extends Command { - constructor( - commandSuffix: string, - protected readonly secretProvider: ISecretProvider, - protected readonly configurationProvider: IConfigurationProvider, - protected tasksTreeProvider: TasksTreeDataProvider, - protected workItemService: IWorkItemService, - protected targetState: string - ) { - super(`setTaskStateTo${commandSuffix}`); - } - - async execute(taskItem?: TaskTreeItem): Promise { - if (!taskItem) { - vscode.window.showErrorMessage('No task selected'); - return; - } - - try { - await this.workItemService.updateWorkItemState(taskItem.task.id!, this.targetState); - vscode.window.showInformationMessage(`Task state changed to ${this.targetState}`); - this.tasksTreeProvider.refresh(); - } catch (error) { - vscode.window.showErrorMessage(`Failed to update task state: ${(error as Error).message}`); - } - } -} - -/** - * Command to set task state to New. - */ -export class SetTaskStateToNewCommand extends SetTaskStateCommand { - constructor( - secretProvider: ISecretProvider, - configurationProvider: IConfigurationProvider, - tasksTreeProvider: TasksTreeDataProvider, - workItemService: IWorkItemService - ) { - super('New', secretProvider, configurationProvider, tasksTreeProvider, workItemService, 'New'); - } -} - -/** - * Command to set task state to Active. - */ -export class SetTaskStateToActiveCommand extends SetTaskStateCommand { - constructor( - secretProvider: ISecretProvider, - configurationProvider: IConfigurationProvider, - tasksTreeProvider: TasksTreeDataProvider, - workItemService: IWorkItemService - ) { - super('Active', secretProvider, configurationProvider, tasksTreeProvider, workItemService, 'Active'); - } -} - -/** - * Command to set task state to Resolved. - */ -export class SetTaskStateToResolvedCommand extends SetTaskStateCommand { - constructor( - secretProvider: ISecretProvider, - configurationProvider: IConfigurationProvider, - tasksTreeProvider: TasksTreeDataProvider, - workItemService: IWorkItemService - ) { - super('Resolved', secretProvider, configurationProvider, tasksTreeProvider, workItemService, 'Resolved'); - } -} - -/** - * Command to set task state to Closed. - */ -export class SetTaskStateToClosedCommand extends SetTaskStateCommand { - constructor( - secretProvider: ISecretProvider, - configurationProvider: IConfigurationProvider, - tasksTreeProvider: TasksTreeDataProvider, - workItemService: IWorkItemService - ) { - super('Closed', secretProvider, configurationProvider, tasksTreeProvider, workItemService, 'Closed'); - } -} \ No newline at end of file diff --git a/src/extension/commands/set-work-item.command.ts b/src/extension/commands/set-work-item.command.ts deleted file mode 100644 index a46f686..0000000 --- a/src/extension/commands/set-work-item.command.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as vscode from 'vscode'; -import { Command } from '../core/command'; -import { IConfigurationProvider, ISecretProvider } from "../core/configuration"; -import { TasksTreeDataProvider } from '../providers/tasks-tree-data-provider'; -import { DevOpsService } from '../services/devops-service'; - -/** - * Command to search for work items. - */ -export class SetWorkItemCommand extends Command { - constructor( - private readonly secretProvider: ISecretProvider, - private readonly configurationProvider: IConfigurationProvider, - private tasksTreeProvider: TasksTreeDataProvider, - private devOpsService: DevOpsService - ) { - super('searchWorkItem'); - } - - async execute(...args: any[]): Promise { - const searchTerm = await vscode.window.showInputBox({ - prompt: 'Enter work item number or search term', - placeHolder: 'e.g., 12345 or "user authentication"' - }); - - if (!searchTerm || searchTerm.trim() === '') { - return; - } - - // Check if it's a number (work item ID) - const workItemNumber = parseInt(searchTerm.trim()); - if (!isNaN(workItemNumber) && workItemNumber > 0) { - // Open the work item in browser - try { - const organizationUri = await this.devOpsService.getOrganizationUri(); - const projectName = await this.devOpsService.getProjectName(); - - if (organizationUri && projectName) { - const workItemUrl = `${organizationUri}/${encodeURIComponent(projectName)}/_workitems/edit/${workItemNumber}`; - await vscode.env.openExternal(vscode.Uri.parse(workItemUrl)); - } else { - vscode.window.showErrorMessage('Azure DevOps configuration is incomplete. Cannot open work item.'); - } - } catch (error) { - vscode.window.showErrorMessage(`Failed to open work item: ${(error as Error).message}`); - } - } else { - // For now, just show a message about text search not being implemented yet - vscode.window.showInformationMessage(`Text search for "${searchTerm}" is not yet implemented. Use work item numbers for now.`); - } - } -} \ No newline at end of file diff --git a/src/extension/commands/start-work-item.command.ts b/src/extension/commands/start-work-item.command.ts deleted file mode 100644 index 6577534..0000000 --- a/src/extension/commands/start-work-item.command.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Command } from "../core/command"; -import { ICommunicationService } from "../core/communication"; -import { IConfigurationProvider, ISecretProvider } from "../core/configuration"; -import { ISourceControlService } from "../core/source-control/source-control.service"; -import { ILogger, LogLevel } from "../core/telemetry"; -import { IWorkItemService, WorkItem } from "../core/workflow"; - -export class StartWorkItemCommand - extends Command { - - constructor( - private readonly secretProvider: ISecretProvider, - private readonly configurationProvider: IConfigurationProvider, - private readonly logger: ILogger, - private readonly communicationService: ICommunicationService, - private readonly sourceControlService: ISourceControlService, - private readonly workItemService: IWorkItemService - ) { - super('startWorkItem'); - } - - /** @inheritdoc */ - public async execute(): Promise { - const workItemNumber = await this.communicationService.prompt(`Enter the work item number:`); - if (!workItemNumber) { - this.logger.log(LogLevel.Warning, "No work item number provided."); - return; - } - - const workItem: WorkItem | null = await this.workItemService.start(workItemNumber); - if (!workItem) { - this.logger.log(LogLevel.Error, `Unable to start work item #${workItemNumber}.`); - return; - } - - //await this.createTopicBranch(workItem); - } - - private async createTopicBranch(workItem: WorkItem): Promise { - try { - const branchName = `feature/${workItem.id}-${workItem.title.replace(/\s+/g, '-').toLowerCase()}`; - await this.sourceControlService.createBranchFromMain(branchName); - this.logger.log(LogLevel.Information, `Branch '${branchName}' created and published successfully.`); - } catch (error) { - if (error instanceof Error) { - this.logger.log(LogLevel.Error, `Error during branch creation: ${error.message}`); - } else { - this.logger.log(LogLevel.Error, "An unknown error occurred during branch creation."); - } - } - } -} \ No newline at end of file diff --git a/src/extension/commands/tasks-tree-commands.ts b/src/extension/commands/tasks-tree-commands.ts deleted file mode 100644 index f9ab660..0000000 --- a/src/extension/commands/tasks-tree-commands.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Re-export all command classes from their individual files -export { AddTaskCommand } from './add-task.command'; -export { RefreshTasksCommand } from './refresh-tasks.command'; -export { SetWorkItemCommand } from './set-work-item.command'; - diff --git a/src/extension/core/command-registry.ts b/src/extension/core/command-registry.ts deleted file mode 100644 index 2db8cb0..0000000 --- a/src/extension/core/command-registry.ts +++ /dev/null @@ -1,137 +0,0 @@ -import * as vscode from 'vscode'; -import { CreateDefaultTasksCommand } from '../commands/create-default-tasks.command'; -import { - SetTaskStateToActiveCommand, - SetTaskStateToClosedCommand, - SetTaskStateToNewCommand, - SetTaskStateToResolvedCommand -} from '../commands/set-task-state.command'; -import { StartWorkItemCommand } from '../commands/start-work-item.command'; -import { AddTaskCommand, RefreshTasksCommand, SetWorkItemCommand } from '../commands/tasks-tree-commands'; -import { TasksTreeDataProvider } from '../providers/tasks-tree-data-provider'; -import { DevOpsService } from '../services/devops-service'; -import { WorkItemService } from '../services/work-item.service'; -import { Command } from "./command"; -import { NativeCommunicationService } from './communication'; -import { IConfigurationProvider, ISecretProvider, NativeConfigurationProvider, NativeSecretProvider } from './configuration'; -import { GitService } from './source-control/git.service'; -import { LogLevel, OutputLogger } from './telemetry'; -import { IWorkItemService } from './workflow'; - -/** - * Registry for all commands in the extension. - */ -export class CommandRegistry { - private static readonly logger = new OutputLogger("Hazel's Toolbox"); - - /** - * Registers all commands with the provided extension context. - * @param context The extension context provided by Visual Studio Code. - */ - public static registerCommands(context: vscode.ExtensionContext) { - // Create and register the tasks tree view first - const tasksTreeProvider = this.createTasksTreeView(context); - - // Get regular commands - const commands = this.getCommandsToRegister(context); - for (const command of commands) { - this.registerCommand(command, context); - } - - // Get and register tasks tree view commands - const treeCommands = this.getTasksTreeCommands(context, tasksTreeProvider); - for (const command of treeCommands) { - this.registerCommand(command, context); - } - - // Special handling for the change task state command which takes a parameter - this.registerChangeTaskStateCommand(context, tasksTreeProvider); - } - - private static registerCommand(command: Command, context: vscode.ExtensionContext) { - const disposable = vscode.commands.registerCommand(command.id, () => { - command.execute().catch((error) => { - this.logger.log(LogLevel.Error, `Error executing command ${command.id}: ${error.message}`); - }); - }); - - context.subscriptions.push(disposable); - } - - private static getCommandsToRegister(context: vscode.ExtensionContext): Command[] { - const communicationService = new NativeCommunicationService(); - const secretProvider = new NativeSecretProvider(context); - const configurationProvider = new NativeConfigurationProvider(); - const sourceControlService = new GitService(); - const devOpsService = new DevOpsService(secretProvider, configurationProvider); - const workItemService = new WorkItemService(this.logger, communicationService, devOpsService); - let commands = [ - new CreateDefaultTasksCommand(secretProvider, configurationProvider, this.logger, workItemService, devOpsService), - new StartWorkItemCommand(secretProvider, configurationProvider, this.logger, communicationService, sourceControlService, workItemService) - ]; - - return commands; - } - - private static createTasksTreeView(context: vscode.ExtensionContext): TasksTreeDataProvider { - const secretProvider = new NativeSecretProvider(context); - const configurationProvider = new NativeConfigurationProvider(); - const devOpsService = new DevOpsService(secretProvider, configurationProvider); - - // Create the tasks tree provider - const tasksTreeProvider = new TasksTreeDataProvider(devOpsService); - - // Register the tree view - vscode.window.createTreeView('tasksTreeView', { - treeDataProvider: tasksTreeProvider, - showCollapseAll: true - }); - - return tasksTreeProvider; - } - - private static getTasksTreeCommands(context: vscode.ExtensionContext, tasksTreeProvider: TasksTreeDataProvider): Command[] { - const secretProvider = new NativeSecretProvider(context); - const configurationProvider = new NativeConfigurationProvider(); - const devOpsService = new DevOpsService(secretProvider, configurationProvider); - const workItemService = new WorkItemService(this.logger, new NativeCommunicationService(), devOpsService); - - return [ - new SetWorkItemCommand(secretProvider, configurationProvider, tasksTreeProvider, devOpsService), - new RefreshTasksCommand(secretProvider, configurationProvider, tasksTreeProvider), - new AddTaskCommand(secretProvider, configurationProvider, tasksTreeProvider, workItemService) - ]; - } - - private static registerChangeTaskStateCommand(context: vscode.ExtensionContext, tasksTreeProvider: TasksTreeDataProvider) { - const secretProvider = new NativeSecretProvider(context); - const configurationProvider = new NativeConfigurationProvider(); - const devOpsService = new DevOpsService(secretProvider, configurationProvider); - const workItemService = new WorkItemService(this.logger, new NativeCommunicationService(), devOpsService); - this.registerTaskStateCommands(context, tasksTreeProvider, secretProvider, configurationProvider, workItemService); - } - - private static registerTaskStateCommands( - context: vscode.ExtensionContext, - tasksTreeProvider: TasksTreeDataProvider, - secretProvider: ISecretProvider, - configurationProvider: IConfigurationProvider, - workItemService: IWorkItemService - ) { - const commands = [ - new SetTaskStateToNewCommand(secretProvider, configurationProvider, tasksTreeProvider, workItemService), - new SetTaskStateToActiveCommand(secretProvider, configurationProvider, tasksTreeProvider, workItemService), - new SetTaskStateToResolvedCommand(secretProvider, configurationProvider, tasksTreeProvider, workItemService), - new SetTaskStateToClosedCommand(secretProvider, configurationProvider, tasksTreeProvider, workItemService) - ]; - - for (const command of commands) { - const disposable = vscode.commands.registerCommand(command.id, (taskItem: any) => { - command.execute(taskItem).catch((error: Error) => { - this.logger.log(LogLevel.Error, `Error executing command ${command.id}: ${error.message}`); - }); - }); - context.subscriptions.push(disposable); - } - } -} \ No newline at end of file diff --git a/src/extension/core/communication/communication-service.interface.ts b/src/extension/core/communication/communication-service.interface.ts index a2310b0..7585246 100644 --- a/src/extension/core/communication/communication-service.interface.ts +++ b/src/extension/core/communication/communication-service.interface.ts @@ -1,18 +1,18 @@ /** * Defines members for handling communication with the user in the extension. */ -export interface ICommunicationService { +export abstract class ICommunicationService { /** * Sends a message to the user and waits for a response. * @param message The message to send to the user. * @returns A promise that resolves to the user's response. */ - prompt(message: string): Promise; + abstract prompt(message: string): Promise; /** * Displays a confirmation dialog to the user. * @param message The message to display in the confirmation dialog. * @returns A promise that resolves to true if the user confirms, or false if they cancel. */ - confirm(message: string): Promise; + abstract confirm(message: string): Promise; } \ No newline at end of file diff --git a/src/extension/core/communication/index.ts b/src/extension/core/communication/index.ts index c45cbf0..ef7fdf3 100644 --- a/src/extension/core/communication/index.ts +++ b/src/extension/core/communication/index.ts @@ -1,2 +1,2 @@ export * from "./communication-service.interface"; -export * from "./communication-service.native"; + diff --git a/src/extension/core/configuration/configuration-error.ts b/src/extension/core/configuration/configuration-error.ts new file mode 100644 index 0000000..9132a85 --- /dev/null +++ b/src/extension/core/configuration/configuration-error.ts @@ -0,0 +1,13 @@ +/** + * Base class for configuration-related errors + */ +export abstract class ConfigurationError extends Error { + public abstract readonly errorCode: string; + public abstract readonly userMessage: string; + public abstract readonly logMessage: string; + + constructor(message: string, public readonly configKey?: string) { + super(message); + this.name = this.constructor.name; + } +} \ No newline at end of file diff --git a/src/extension/core/configuration/configuration-locator.ts b/src/extension/core/configuration/configuration-locator.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/extension/core/configuration/configuration.interface.ts b/src/extension/core/configuration/configuration.interface.ts new file mode 100644 index 0000000..0b25442 --- /dev/null +++ b/src/extension/core/configuration/configuration.interface.ts @@ -0,0 +1,3 @@ +export abstract class IConfiguration { + abstract get(): Promise; +} \ No newline at end of file diff --git a/src/extension/core/configuration/index.ts b/src/extension/core/configuration/index.ts index 75de692..d9c075f 100644 --- a/src/extension/core/configuration/index.ts +++ b/src/extension/core/configuration/index.ts @@ -1,5 +1,8 @@ +export * from "./configuration-error"; +export * from "./configuration-locator"; export * from "./configuration-provider.interface"; -export * from "./configuration-provider.native"; +export * from "./configuration.interface"; +export * from "./invalid-configuration.error"; +export * from "./missing-configuration.error"; export * from "./secret-provider.interface"; -export * from "./secret-provider.native"; diff --git a/src/extension/core/configuration/invalid-configuration.error.ts b/src/extension/core/configuration/invalid-configuration.error.ts new file mode 100644 index 0000000..a80ee52 --- /dev/null +++ b/src/extension/core/configuration/invalid-configuration.error.ts @@ -0,0 +1,25 @@ +import { ConfigurationError } from "./configuration-error"; + +/** + * Error thrown when configuration value is invalid + */ +export class InvalidConfigurationError extends ConfigurationError { + public readonly errorCode = 'INVALID_CONFIGURATION'; + + constructor( + public readonly configKey: string, + public readonly currentValue: any, + public readonly expectedFormat: string, + public readonly friendlyName: string + ) { + super(`Invalid configuration value for ${configKey}: ${currentValue}`); + } + + public get userMessage(): string { + return `Invalid ${this.friendlyName} configuration. Expected: ${this.expectedFormat}`; + } + + public get logMessage(): string { + return `Invalid configuration for ${this.configKey}: "${this.currentValue}" (expected: ${this.expectedFormat})`; + } +} \ No newline at end of file diff --git a/src/extension/core/configuration/missing-configuration.error.ts b/src/extension/core/configuration/missing-configuration.error.ts new file mode 100644 index 0000000..daa00e6 --- /dev/null +++ b/src/extension/core/configuration/missing-configuration.error.ts @@ -0,0 +1,27 @@ +import { ConfigurationError } from "./configuration-error"; + +/** + * Error thrown when required configuration is missing + */ +export class MissingConfigurationError extends ConfigurationError { + public readonly errorCode = 'MISSING_CONFIGURATION'; + + constructor( + public readonly configKey: string, + public readonly friendlyName: string, + public readonly setupInstructions?: string + ) { + super(`Missing required configuration: ${configKey}`); + } + + public get userMessage(): string { + const baseMessage = `This feature requires ${this.friendlyName} to be configured.`; + return this.setupInstructions + ? `${baseMessage} ${this.setupInstructions}` + : baseMessage; + } + + public get logMessage(): string { + return `Missing required configuration: ${this.configKey} (${this.friendlyName})`; + } +} \ No newline at end of file diff --git a/src/extension/core/default-tasks.ts b/src/extension/core/default-tasks.ts deleted file mode 100644 index 5b36b7d..0000000 --- a/src/extension/core/default-tasks.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as PreDefinedTasks from './workflow/pre-defined-tasks'; -import { PreDefinedTask } from "./workflow/pre-defined-tasks/pre-defined-task"; - -/** - * Defines the default tasks used to execute a work item in an Agile workflow. - */ -export const DefaultTasks: PreDefinedTask[] = [ - PreDefinedTasks.ReviewFeature, - PreDefinedTasks.ReviewWorkItem, - PreDefinedTasks.ReproduceLocally, - PreDefinedTasks.ReviewExistingImplementations, - PreDefinedTasks.MeetWithFeatureLeader, - PreDefinedTasks.MeetWithSme, - PreDefinedTasks.MeetWithProductOwners, - PreDefinedTasks.MeetWithStakeholders, - PreDefinedTasks.MeetWithQualityAssurance, - PreDefinedTasks.QAWriteTestCases, - PreDefinedTasks.QAReviewTestCases, - PreDefinedTasks.DevReviewTestCases, - PreDefinedTasks.CreateImplementationTasks, - PreDefinedTasks.SetupEnvironment, - PreDefinedTasks.CreateDraftPR, - PreDefinedTasks.SelfReviewPR, - PreDefinedTasks.PublishPR, - PreDefinedTasks.CreateQualityAssuranceBuild, - PreDefinedTasks.QADeploymentToTestEnvironment, - PreDefinedTasks.QATestCaseExecution, - PreDefinedTasks.SupportQATesting, - PreDefinedTasks.ResolvePRFeedback, - PreDefinedTasks.RunPRValidations, - PreDefinedTasks.CompletePR, - PreDefinedTasks.CreateReleaseBuild, - PreDefinedTasks.CreateReleaseNotes, - PreDefinedTasks.QAReviewReleaseNotes, - PreDefinedTasks.QASmokeTesting, - PreDefinedTasks.SupportSmokeTesting, - PreDefinedTasks.DeployToProduction, - PreDefinedTasks.NotifyStakeholdersOfDeployment, - PreDefinedTasks.ValidateInProduction -]; \ No newline at end of file diff --git a/src/extension/core/index.ts b/src/extension/core/index.ts new file mode 100644 index 0000000..41bccd3 --- /dev/null +++ b/src/extension/core/index.ts @@ -0,0 +1,14 @@ +// Export core command system +export * from './command'; + +// Export mapper utilities +export * from './mapper'; + +// Export services (keep existing ServiceLocator for now as fallback) +export * from './services'; + +// Export common interfaces and providers +export * from './communication'; +export * from './configuration'; +export * from './telemetry'; + diff --git a/src/extension/core/placeholder-tree-item.ts b/src/extension/core/placeholder-tree-item.ts deleted file mode 100644 index 50fe3dd..0000000 --- a/src/extension/core/placeholder-tree-item.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as vscode from 'vscode'; - -export class PlaceholderTreeItem extends vscode.TreeItem { - constructor(label: string, description: string, iconName: string = 'info') { - super(label, vscode.TreeItemCollapsibleState.None); - this.description = description; - this.contextValue = 'placeholder'; - this.iconPath = new vscode.ThemeIcon(iconName); - } -} \ No newline at end of file diff --git a/src/extension/core/repository.ts b/src/extension/core/repository.ts new file mode 100644 index 0000000..edb4b4c --- /dev/null +++ b/src/extension/core/repository.ts @@ -0,0 +1,32 @@ +/** + * Defines members for repository operations. + */ +export abstract class IRepository { + /** + * Gets an item by its ID. + * @param id The ID of the item to retrieve. + * @returns A promise that resolves to the item if found, or undefined if not found. + */ + abstract getById(id: number): Promise; + + /** + * Creates a new item in the repository. + * @param item The item to create. + * @returns A promise that resolves to the created item. + */ + abstract create(item: T): Promise; + + /** + * Updates an existing item in the repository. + * @param item The item to update. + * @returns A promise that resolves to the updated item. + */ + abstract update(item: T): Promise; + + /** + * Deletes an item from the repository by its ID. + * @param id The ID of the item to delete. + * @returns A promise that resolves when the item has been deleted. + */ + abstract delete(id: number): Promise; +} \ No newline at end of file diff --git a/src/extension/core/serialization/json-file-reader.ts b/src/extension/core/serialization/json-file-reader.ts new file mode 100644 index 0000000..d9b2298 --- /dev/null +++ b/src/extension/core/serialization/json-file-reader.ts @@ -0,0 +1,18 @@ +import * as vscode from 'vscode'; + +/** + * Defines members for reading JSON files within the VS Code workspace. + */ +export class JsonFileReader { + /** + * Reads a JSON file from the workspace and parses it into an object of type T. + * @param filePath The path to the JSON file. + * @returns A promise that resolves to the parsed object or undefined if an error occurs. + */ + public static async read(filePath: string): Promise { + const fileUri = vscode.Uri.file(filePath); + const binaryData = await vscode.workspace.fs.readFile(fileUri); + const stringData = binaryData.toString(); + return JSON.parse(stringData) as T; + } +} \ No newline at end of file diff --git a/src/extension/core/services/index.ts b/src/extension/core/services/index.ts new file mode 100644 index 0000000..0fca34a --- /dev/null +++ b/src/extension/core/services/index.ts @@ -0,0 +1,2 @@ +export * from './service-locator'; + diff --git a/src/extension/core/services/service-locator.ts b/src/extension/core/services/service-locator.ts new file mode 100644 index 0000000..583c1b4 --- /dev/null +++ b/src/extension/core/services/service-locator.ts @@ -0,0 +1,117 @@ +import * as vscode from 'vscode'; + +// Constructor type for service resolution +type Constructor = new (...args: any[]) => T; +type ServiceFactory = () => T; +// Interface token type for abstraction-based resolution +type InterfaceToken = Function & { prototype: T }; +// String token for TypeScript interfaces that can't be used as runtime values +type StringToken = string; +// Combined service token type +type ServiceToken = Constructor | InterfaceToken | StringToken; + +/** + * Service locator for managing singleton instances of services throughout the extension. + * This helps eliminate dependency duplication and provides a central place for service creation. + * Supports both concrete class registration and abstraction-based service resolution. + * + * Usage: + * - ServiceLocator.getService(ILogger) // returns ILogger instance (abstraction) + * - ServiceLocator.getService(OutputLogger) // returns OutputLogger instance (concrete) + * - ServiceLocator.getService(DevOpsService) // returns DevOpsService instance + */ +export class ServiceLocator { + private static context: vscode.ExtensionContext; + private static services = new Map, any>(); + private static factories = new Map, ServiceFactory>(); + + /** + * Initializes the service locator with the extension context. + * This should be called once during extension activation. + */ + public static initialize(context: vscode.ExtensionContext): void { + this.context = context; + } + + /** + * Gets a service instance by its constructor/interface. + * @param serviceToken The constructor or interface token for the service + * @returns The singleton instance of the requested service + */ + public static getService(serviceToken: ServiceToken): T { + if (this.services.has(serviceToken)) { + return this.services.get(serviceToken); + } + + const factory = this.factories.get(serviceToken); + if (!factory) { + const tokenName = typeof serviceToken === 'string' ? serviceToken : serviceToken.name; + throw new Error(`No factory registered for service: ${tokenName}`); + } + + const instance = factory(); + this.services.set(serviceToken, instance); + return instance; + } + + /** + * Registers a factory function for a service type. + * @param serviceToken The constructor or interface token + * @param factory The factory function that creates the service + */ + public static registerFactory(serviceToken: ServiceToken, factory: ServiceFactory): void { + this.factories.set(serviceToken, factory); + } + + /** + * Registers a concrete implementation for an interface. + * This allows consumers to request services by their abstractions. + * @param interfaceToken The interface/abstract class token + * @param implementationToken The concrete implementation token + */ + public static registerInterface( + interfaceToken: InterfaceToken, + implementationToken: Constructor + ): void { + const implementationFactory = this.factories.get(implementationToken); + if (!implementationFactory) { + throw new Error(`Implementation factory must be registered before interface mapping: ${implementationToken.name}`); + } + + this.factories.set(interfaceToken, implementationFactory); + } + + /** + * Registers a concrete implementation for a TypeScript interface using a string token. + * This allows consumers to request services by their interface names. + * @param interfaceToken The string token for the interface + * @param implementationToken The concrete implementation token + */ + public static registerStringInterface( + interfaceToken: StringToken, + implementationToken: Constructor + ): void { + const implementationFactory = this.factories.get(implementationToken); + if (!implementationFactory) { + throw new Error(`Implementation factory must be registered before interface mapping: ${implementationToken.name}`); + } + + this.factories.set(interfaceToken, implementationFactory); + } + + /** + * Clears all cached service instances. Useful for testing. + */ + public static clear(): void { + this.services.clear(); + } + + /** + * Resets the service locator completely, clearing both instances and factories. + */ + public static reset(): void { + this.services.clear(); + this.factories.clear(); + this.context = undefined as any; + } +} diff --git a/src/extension/core/state-group-tree-item.ts b/src/extension/core/state-group-tree-item.ts deleted file mode 100644 index 0abf639..0000000 --- a/src/extension/core/state-group-tree-item.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as vscode from 'vscode'; - -export class StateGroupTreeItem extends vscode.TreeItem { - constructor( - public readonly stateName: string, - public readonly taskCount: number - ) { - super(`${stateName} (${taskCount})`, vscode.TreeItemCollapsibleState.Expanded); - this.tooltip = `${taskCount} task(s) in ${stateName} state`; - this.contextValue = 'stateGroup'; - - // Set icon based on state - if (stateName === 'In Progress') { - this.iconPath = new vscode.ThemeIcon('debug-start', new vscode.ThemeColor('charts.blue')); - } else if (stateName === 'Ready') { - this.iconPath = new vscode.ThemeIcon('circle-outline', new vscode.ThemeColor('charts.gray')); - } else if (stateName === 'Closed') { - this.iconPath = new vscode.ThemeIcon('check', new vscode.ThemeColor('charts.green')); - } else if (stateName === 'Removed') { - this.iconPath = new vscode.ThemeIcon('trash', new vscode.ThemeColor('charts.red')); - } else { - this.iconPath = new vscode.ThemeIcon('folder'); - } - } -} \ No newline at end of file diff --git a/src/extension/core/task.ts b/src/extension/core/task.ts deleted file mode 100644 index 17fca45..0000000 --- a/src/extension/core/task.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Represents a task in Azure DevOps. - */ -export class Task { - /** - * The unique identifier of the task. - */ - public id: number | undefined; - - /** - * The name of the task. - */ - public name: string; - - /** - * The description of the task. - */ - public description: string; - - /** - * The number of hours the task is estimated to take. - */ - public remainingWork: number; - - /** - * The activity associated with the task. - */ - public activity: string; - - /** - * Creates a new task instance with the specified details. - * @param name The name of the task. - * @param description The description of the task. - * @param remainingWork The number of hours the task is estimated to take. - * @param activity The activity associated with the task. - */ - constructor( - name: string, - description: string, - remainingWork: number, - activity: string - ) { - this.name = name; - this.description = description; - this.remainingWork = remainingWork; - this.activity = activity; - } -} \ No newline at end of file diff --git a/src/extension/core/telemetry/logger.ts b/src/extension/core/telemetry/logger.ts index 48e448e..d8d410c 100644 --- a/src/extension/core/telemetry/logger.ts +++ b/src/extension/core/telemetry/logger.ts @@ -11,6 +11,13 @@ export abstract class ILogger { */ abstract log(level: LogLevel, message: string): void; + /** + * Logs an error message with the specified error details. + * @param message The error message to log. + * @param error The error object containing details about the error. + */ + abstract logError(message: string, error: any): void; + /** * Opens the logs for the user to view. */ diff --git a/src/extension/core/telemetry/output.logger.ts b/src/extension/core/telemetry/output.logger.ts index 99ec33b..0c21e5d 100644 --- a/src/extension/core/telemetry/output.logger.ts +++ b/src/extension/core/telemetry/output.logger.ts @@ -28,6 +28,14 @@ export class OutputLogger implements ILogger { this.outputChannel.appendLine(formattedMessage); } + /** @inheritdoc */ + public logError(message: string, error: any): void { + const timestamp = new Date().toISOString(); + const errorMessage = error instanceof Error ? `${message} ${error.message}` : message; + const formattedMessage = `${timestamp} (error): ${errorMessage}\n${error.stack}`; + this.outputChannel.appendLine(formattedMessage); + } + /** @inheritdoc */ public open(): void { this.outputChannel.show(); diff --git a/src/extension/core/workflow/index.ts b/src/extension/core/workflow/index.ts deleted file mode 100644 index 17865ba..0000000 --- a/src/extension/core/workflow/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./work-item"; -export * from "./work-item-state"; -export * from "./work-item-type"; -export * from "./work-item.service"; diff --git a/src/extension/core/workflow/pre-defined-tasks/development/setup-environment.ts b/src/extension/core/workflow/pre-defined-tasks/development/setup-environment.ts deleted file mode 100644 index 717ea53..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/development/setup-environment.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for setting up the development environment. - */ -export const SetupEnvironment: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.15, - assigneeRequired: true, - name: 'Setup Environment', - activity: 'Development', - description: `Setup the development environment to execute the work item. -- Download repository. -- Run any tests against main. -- Create topic branch. -- Install dependencies.` -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/index.ts b/src/extension/core/workflow/pre-defined-tasks/index.ts deleted file mode 100644 index 57515b7..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -export * from './development/setup-environment'; -export * from './planning/create-implementation-tasks'; -export * from './planning/meet-with-feature-leader'; -export * from './planning/meet-with-product-owners'; -export * from './planning/meet-with-qa'; -export * from './planning/meet-with-sme'; -export * from './planning/meet-with-stakeholders'; -export * from './planning/reproduce-locally'; -export * from './planning/review-existing-implementations'; -export * from './planning/review-feature'; -export * from './planning/review-work-item'; -export * from './quality-assurance/create-qa-build'; -export * from './quality-assurance/dev-review-test-cases'; -export * from './quality-assurance/qa-deployment-to-test-environment'; -export * from './quality-assurance/qa-review-test-cases'; -export * from './quality-assurance/qa-smoke-testing'; -export * from './quality-assurance/qa-test-case-execution'; -export * from './quality-assurance/qa-write-test-cases'; -export * from './quality-assurance/support-qa-testing'; -export * from './quality-assurance/support-smoke-testing'; -export * from './release/create-release-build'; -export * from './release/create-release-notes'; -export * from './release/deploy-to-production'; -export * from './release/notify-stakeholders-of-deployment'; -export * from './release/qa-review-release-notes'; -export * from './release/validate-in-production'; -export * from './review/complete-pr'; -export * from './review/create-draft-pr'; -export * from './review/publish-pr'; -export * from './review/resolve-pr-feedback'; -export * from './review/run-pr-validations'; -export * from './review/self-review-pr'; - diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/create-implementation-tasks.ts b/src/extension/core/workflow/pre-defined-tasks/planning/create-implementation-tasks.ts deleted file mode 100644 index 9556086..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/create-implementation-tasks.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task to create tasks for implementing the changes needed to meet a work item's acceptance criteria. - */ -export const CreateImplementationTasks: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Create Implementation Tasks', - activity: 'Design', - description: 'After conducting the necessary reviews and meetings, create tasks that represent the steps needed to execute the work item\'s acceptance criteria (be sure to account for non-functional requirements).' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-feature-leader.ts b/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-feature-leader.ts deleted file mode 100644 index 719e61f..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-feature-leader.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for meeting with the feature leader to discuss the work item's scope within the grand scheme of the feature. - */ -export const MeetWithFeatureLeader: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Meet with Feature Leader', - activity: 'Requirements', - description: 'Meet with the feature leader to discuss the work item\'s scope within the grand scheme of the feature.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-product-owners.ts b/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-product-owners.ts deleted file mode 100644 index 1b6bced..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-product-owners.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for meeting with product owners to ensure alignment with business objectives. - */ -export const MeetWithProductOwners: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Meet with Product Owners', - activity: 'Requirements', - description: 'Meet with product owners to clarify requirements and ensure alignment with business objectives.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-qa.ts b/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-qa.ts deleted file mode 100644 index 6cdac0f..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-qa.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for meeting with QA to discuss test cases and ensure alignment on quality expectations. - */ -export const MeetWithQualityAssurance: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Meet with Quality Assurance', - activity: 'Requirements', - description: 'Meet with QA to discuss test cases and ensure alignment on quality expectations.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-sme.ts b/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-sme.ts deleted file mode 100644 index c0655da..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-sme.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for meeting with the noted subject matter expert to discuss the work item's implementation. - */ -export const MeetWithSme: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Meet with Subject Matter Expert', - activity: 'Requirements', - description: 'Meet with the noted subject matter expert to discuss the work item\'s implementation.', - requiredFields: [ 'Subject Matter Expert' ] -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-stakeholders.ts b/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-stakeholders.ts deleted file mode 100644 index 366ff4a..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/meet-with-stakeholders.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for meeting with stakeholders to gather feedback and ensure alignment with expectations. - */ -export const MeetWithStakeholders: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Meet with Stakeholders', - activity: 'Requirements', - description: 'Meet with stakeholders to gather feedback and ensure the work item aligns with their expectations.', - requiredFields: [ 'Stakeholder' ] -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/reproduce-locally.ts b/src/extension/core/workflow/pre-defined-tasks/planning/reproduce-locally.ts deleted file mode 100644 index 4a34e04..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/reproduce-locally.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { WorkItemType } from "../../work-item-type"; -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for reproducing an issue locally to better understand the problem and identify potential solutions. - */ -export const ReproduceLocally: PreDefinedTask = { - id: undefined, - appliesTo: [WorkItemType.Bug], - remainingWork: 1, - assigneeRequired: true, - name: 'Reproduce Locally', - activity: 'Testing', - description: 'Attempt to reproduce the issue locally to better understand the problem and identify potential solutions.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/review-existing-implementations.ts b/src/extension/core/workflow/pre-defined-tasks/planning/review-existing-implementations.ts deleted file mode 100644 index 38fd18c..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/review-existing-implementations.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for reviewing existing implementations to gather insights and derive potential solutions. - */ -export const ReviewExistingImplementations: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 1, - assigneeRequired: true, - name: 'Review Existing Implementation(s)', - activity: 'Design', - description: 'Review existing implementations to gather insights and derive potential solutions.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/review-feature.ts b/src/extension/core/workflow/pre-defined-tasks/planning/review-feature.ts deleted file mode 100644 index a135d59..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/review-feature.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task to review the work item and all pre-defined tasks to ensure that tasks are aligned with business objectives and add tasks if necessary. - */ -export const ReviewFeature: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Review Feature', - activity: 'Requirements', - description: 'Review the feature and its associated work items to ensure alignment with business objectives, coordination with team members, and add tasks if necessary.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/planning/review-work-item.ts b/src/extension/core/workflow/pre-defined-tasks/planning/review-work-item.ts deleted file mode 100644 index 5011b62..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/planning/review-work-item.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task to review the work item and all pre-defined tasks to ensure that tasks are aligned with business objectives and add tasks if necessary. - */ -export const ReviewWorkItem: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Review Work Item', - activity: 'Requirements', - description: 'Review the work item and all pre-defined tasks to ensure that tasks are aligned with business objectives and add tasks if necessary.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper.ts b/src/extension/core/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper.ts deleted file mode 100644 index 81407df..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { JsonPatchDocument, JsonPatchOperation, Operation } from "azure-devops-node-api/interfaces/common/VSSInterfaces"; -import { Mapper } from "../../mapper"; -import { PreDefinedTask } from "./pre-defined-task"; - -export class PreDefinedTaskJsonPatchDocumentMapper - implements Mapper { - - private static readonly TitleFieldPath: string = '/fields/System.Title'; - private static readonly DescriptionFieldPath: string = '/fields/System.Description'; - private static readonly RemainingWorkFieldPath: string = '/fields/Microsoft.VSTS.Scheduling.RemainingWork'; - private static readonly AreaPathFieldPath: string = '/fields/System.AreaPath'; - private static readonly IterationPathFieldPath: string = '/fields/System.IterationPath'; - private static readonly RelationsFieldPath: string = '/relations/-'; - private static readonly AssignedToFieldPath: string = '/fields/System.AssignedTo'; - - constructor( - private readonly userDisplayName: string, - private readonly organizationUri: string, - private readonly workItemNumber: number, - private readonly areaPath: string, - private readonly iterationPath: string - ) {} - - /** @inheritdoc */ - map(input: PreDefinedTask): JsonPatchDocument { - let operations: JsonPatchOperation[] = [ - { - op: Operation.Add, - path: PreDefinedTaskJsonPatchDocumentMapper.TitleFieldPath, - value: input.name, - }, - { - op: Operation.Add, - path: PreDefinedTaskJsonPatchDocumentMapper.RemainingWorkFieldPath, - value: input.remainingWork, - }, - { - op: Operation.Add, - path: PreDefinedTaskJsonPatchDocumentMapper.DescriptionFieldPath, - value: input.description, - }, - { - op: Operation.Add, - path: PreDefinedTaskJsonPatchDocumentMapper.AreaPathFieldPath, - value: this.areaPath, - }, - { - op: Operation.Add, - path: PreDefinedTaskJsonPatchDocumentMapper.IterationPathFieldPath, - value: this.iterationPath, - }, - { - op: Operation.Add, - path: PreDefinedTaskJsonPatchDocumentMapper.RelationsFieldPath, - value: { - rel: 'System.LinkTypes.Hierarchy-Reverse', - url: `${this.organizationUri}/_apis/wit/workItems/${this.workItemNumber}`, - attributes: { - comment: 'Task created by Hazel\'s Toolbox', - }, - }, - }, - { - op: Operation.Add, - path: '/fields/Microsoft.VSTS.Common.Activity', - value: input.activity, - }, - ]; - - if (input.assigneeRequired) { - operations.push({ - op: Operation.Add, - path: PreDefinedTaskJsonPatchDocumentMapper.AssignedToFieldPath, - value: this.userDisplayName - }); - } - - return operations; - } - -} \ No newline at end of file diff --git a/src/extension/core/workflow/pre-defined-tasks/pre-defined-task.ts b/src/extension/core/workflow/pre-defined-tasks/pre-defined-task.ts deleted file mode 100644 index 847aee0..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/pre-defined-task.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Task } from "../../task"; -import { WorkItemType } from "../work-item-type"; - -/** - * Represents a {@link Task} that is predefined as part of a workflow. - */ -export class PreDefinedTask - extends Task { - - /** - * Indicates whether the task should be have an assignee by default. - */ - assigneeRequired: boolean = true; - - /** - * The {@link WorkItemType}(s) to which the {@link Task} applies. - */ - appliesTo: WorkItemType[]; - - /** - * The fields that must have values for the task to be created. - */ - requiredFields?: string[]; - - /** - * Creates a new {@link Task} with the specified details. - * @param name The name of the task. - * @param remainingWork The number of hours the task is estimated to take. - * @param activity The activity associated with the task. - * @param appliesTo The work item type(s) to which the task applies. - * @param description The description of the task. - * @param requiredFields The fields that must have values for the task to be created. - */ - constructor( - name: string, - remainingWork: number, - activity: string, - appliesTo: WorkItemType[] = [], - description: string = '', - requiredFields?: string[] - ) { - super(name, description, remainingWork, activity); - this.appliesTo = appliesTo; - this.requiredFields = requiredFields; - } -} \ No newline at end of file diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/create-qa-build.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/create-qa-build.ts deleted file mode 100644 index fb51865..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/create-qa-build.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for creating a quality assurance build in Azure DevOps. - */ -export const CreateQualityAssuranceBuild: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.01, - assigneeRequired: true, - name: 'Create Quality Assurance Build', - activity: 'Deployment', - description: 'Create a build for QA testing to validate the implementation.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/dev-review-test-cases.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/dev-review-test-cases.ts deleted file mode 100644 index 83f5156..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/dev-review-test-cases.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for the developer review of test cases in Azure DevOps. - */ -export const DevReviewTestCases: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Dev Review Test Cases', - activity: 'Requirements', - description: 'Review the test cases to ensure they are correct, comprehensive, and cover all scenarios.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-deployment-to-test-environment.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-deployment-to-test-environment.ts deleted file mode 100644 index cbab36f..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-deployment-to-test-environment.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task to deploy the build to the test environment for QA validation in Azure DevOps. - */ -export const QADeploymentToTestEnvironment: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 2, - assigneeRequired: false, - name: 'QA Deployment to Test Environment', - activity: 'Deployment', - description: 'Deploy the build to the test environment for QA validation.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-review-test-cases.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-review-test-cases.ts deleted file mode 100644 index cfb8fe2..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-review-test-cases.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for quality assurance to cross-review test cases in Azure DevOps. - */ -export const QAReviewTestCases: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: false, - name: 'QA Review Test Cases', - activity: 'Requirements', - description: 'Review the test cases to ensure they are correct, comprehensive, and cover all scenarios.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-smoke-testing.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-smoke-testing.ts deleted file mode 100644 index 9b3dcca..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-smoke-testing.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for smoke testing in Azure DevOps. - */ -export const QASmokeTesting: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: false, - name: 'QA Smoke Testing', - activity: 'Testing', - description: 'Perform smoke testing to validate the stability of the build in the test environment.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-test-case-execution.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-test-case-execution.ts deleted file mode 100644 index 3c3a26b..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-test-case-execution.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for executing test cases in Azure DevOps. - */ -export const QATestCaseExecution: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 1, - assigneeRequired: false, - name: 'QA Test Case Execution', - activity: 'Testing', - description: 'Execute the test cases to validate the functionality of the work item.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-write-test-cases.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-write-test-cases.ts deleted file mode 100644 index 8d18bf8..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/qa-write-test-cases.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for writing test cases in Azure DevOps. - */ -export const QAWriteTestCases: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 2, - assigneeRequired: false, - name: 'QA Write Test Cases', - activity: 'Design', - description: 'Write test cases that are comprehensive and aligned with business objectives.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/support-qa-testing.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/support-qa-testing.ts deleted file mode 100644 index 6190f9b..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/support-qa-testing.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task to support the QA team during the testing process in Azure DevOps. - */ -export const SupportQATesting: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 1, - assigneeRequired: true, - name: 'Support QA Testing', - activity: 'Testing', - description: 'Provide support to the QA team during the testing process.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/support-smoke-testing.ts b/src/extension/core/workflow/pre-defined-tasks/quality-assurance/support-smoke-testing.ts deleted file mode 100644 index aa9369d..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/quality-assurance/support-smoke-testing.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task to support the QA team during the smoke-testing process in Azure DevOps. - */ -export const SupportSmokeTesting: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.25, - assigneeRequired: true, - name: 'Support Smoke Testing', - activity: 'Testing', - description: 'Provide support during smoke testing to address any issues that arise.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/review/complete-pr.ts b/src/extension/core/workflow/pre-defined-tasks/review/complete-pr.ts deleted file mode 100644 index 99b3443..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/review/complete-pr.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task to complete the pull request after all validations and reviews are successful in Azure DevOps. - */ -export const CompletePR: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.01, - assigneeRequired: true, - name: 'Complete PR', - activity: 'Development', - description: 'Complete the pull request after all validations and reviews are successful.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/review/create-draft-pr.ts b/src/extension/core/workflow/pre-defined-tasks/review/create-draft-pr.ts deleted file mode 100644 index 7e4373a..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/review/create-draft-pr.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for creating draft pull request in Azure DevOps. - */ -export const CreateDraftPR: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.01, - assigneeRequired: true, - name: 'Create Draft PR', - activity: 'Development', - description: 'Create a draft pull request to initiate the code review process.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/review/publish-pr.ts b/src/extension/core/workflow/pre-defined-tasks/review/publish-pr.ts deleted file mode 100644 index 233aa45..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/review/publish-pr.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for publishing a pull request in Azure DevOps. - */ -export const PublishPR: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.01, - assigneeRequired: true, - name: 'Publish PR', - activity: 'Development', - description: 'Publish the pull request for review by other team members.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/review/resolve-pr-feedback.ts b/src/extension/core/workflow/pre-defined-tasks/review/resolve-pr-feedback.ts deleted file mode 100644 index e8051ca..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/review/resolve-pr-feedback.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for resolving feedback provided during the pull request review process in Azure DevOps. - */ -export const ResolvePRFeedback: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 1, - assigneeRequired: true, - name: 'Resolve PR Feedback', - activity: 'Development', - description: 'Address feedback provided during the pull request review process.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/review/run-pr-validations.ts b/src/extension/core/workflow/pre-defined-tasks/review/run-pr-validations.ts deleted file mode 100644 index 5e82753..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/review/run-pr-validations.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for running validations for the pull request in Azure DevOps. - */ -export const RunPRValidations: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.01, - assigneeRequired: true, - name: 'Run PR Validations', - activity: 'Development', - description: 'Run automated validations for the pull request to ensure compliance with standards.' -}; diff --git a/src/extension/core/workflow/pre-defined-tasks/review/self-review-pr.ts b/src/extension/core/workflow/pre-defined-tasks/review/self-review-pr.ts deleted file mode 100644 index 43ff715..0000000 --- a/src/extension/core/workflow/pre-defined-tasks/review/self-review-pr.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PreDefinedTask } from "../pre-defined-task"; - -/** - * Represents a pre-defined task for self-reviewing a pull request in Azure DevOps. - */ -export const SelfReviewPR: PreDefinedTask = { - id: undefined, - appliesTo: [], - remainingWork: 0.5, - assigneeRequired: true, - name: 'Self-Review PR', - activity: 'Development', - description: 'Perform a self-review of the pull request to ensure code quality and completeness.' -}; diff --git a/src/extension/core/workflow/work-item.ts b/src/extension/core/workflow/work-item.ts deleted file mode 100644 index ac790a1..0000000 --- a/src/extension/core/workflow/work-item.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { WorkItemState } from "./work-item-state"; -import { WorkItemType } from "./work-item-type"; - -/** - * Represents a work item in Azure DevOps. - */ -export class WorkItem { - /** - * The unique identifier of the work item. - */ - public id: number | undefined; - - /** - * The title of the work item. - */ - public title: string; - - /** - * The description of the work item. - */ - public description: string; - - /** - * The number of hours the work item is estimated to take. - */ - public remainingWork: number; - - /** - * The activity associated with the work item. - */ - public activity: string; - - /** - * The state of the work item. - */ - public state: WorkItemState | undefined = undefined; - - /** - * The type of the work item. - * This can be a specific work item type like "Bug", "User Story", etc. - */ - public type: WorkItemType | undefined = undefined; - - /** - * The area path of the work item. - */ - public areaPath: string | undefined = undefined; - - /** - * The iteration path of the work item. - */ - public iterationPath: string | undefined = undefined; - - /** - * The additional fields associated with the work item. - */ - public additionalFields: { [key: string]: any } = {}; - - /** - * @param title The title of the work item. - * @param description The description of the work item. - * @param remainingWork The number of hours the work item is estimated to take. - * @param activity The activity associated with the work item. - */ - constructor( - title: string, - description: string, - remainingWork: number, - activity: string - ) { - this.title = title; - this.description = description; - this.remainingWork = remainingWork; - this.activity = activity; - } -} \ No newline at end of file diff --git a/src/extension/domain/statistics/configuration/statistics.configuration.ts b/src/extension/domain/statistics/configuration/statistics.configuration.ts new file mode 100644 index 0000000..2538a30 --- /dev/null +++ b/src/extension/domain/statistics/configuration/statistics.configuration.ts @@ -0,0 +1,42 @@ +import { IConfiguration, IConfigurationProvider } from "../../../core/configuration"; +import { ILogger, LogLevel } from "../../../core/telemetry"; +import { StatisticsOptions } from "./statistics.options"; + +/** + * Defines the configuration for statistics in the extension. + */ +export class StatisticsConfiguration implements IConfiguration { + private static readonly VELOCITY_SPAN_IN_WEEKS = 6; + private static readonly VELOCITY_SPAN_IN_DAYS = StatisticsConfiguration.VELOCITY_SPAN_IN_WEEKS * 7; + private static readonly REFRESH_INTERVAL_IN_MINUTES = 60; + private static readonly SHOW_PRODUCTIVITY_METRICS_KEY = "statistics.showProductivityMetrics"; + private static readonly VELOCITY_SPAN_KEY = "statistics.velocitySpan"; + private static readonly REFRESH_INTERVAL_KEY = "statistics.refreshInterval"; + + /** + * @constructor + * @param logger The service used to capture errors and log messages for diagnostic purposes. + * @param configProvider The service used to retrieve configuration values and populate runtime options. + */ + constructor( + private readonly logger: ILogger, + private readonly configProvider: IConfigurationProvider + ) {} + + /** @inheritdoc */ + async get(): Promise { + try { + const showProductivityMetrics = await this.configProvider.get(StatisticsConfiguration.SHOW_PRODUCTIVITY_METRICS_KEY) ?? true; + const velocitySpan = await this.configProvider.get(StatisticsConfiguration.VELOCITY_SPAN_KEY) ?? StatisticsConfiguration.VELOCITY_SPAN_IN_DAYS; + const refreshInterval = await this.configProvider.get(StatisticsConfiguration.REFRESH_INTERVAL_KEY) ?? StatisticsConfiguration.REFRESH_INTERVAL_IN_MINUTES; + return { + showProductivityMetrics, + velocitySpan, + refreshInterval + }; + } catch (error) { + this.logger.log(LogLevel.Error, `Unable to load configurations for statistics. ${error}`); + throw error; + } + } +} \ No newline at end of file diff --git a/src/extension/domain/statistics/configuration/statistics.options.ts b/src/extension/domain/statistics/configuration/statistics.options.ts new file mode 100644 index 0000000..6266b99 --- /dev/null +++ b/src/extension/domain/statistics/configuration/statistics.options.ts @@ -0,0 +1,20 @@ +/** + * Defines options for querying statistics in the extension. + */ +export interface StatisticsOptions { + /** + * Indicates whether or not to show productivity metrics. + */ + showProductivityMetrics: boolean; + + /** + * The span of time, in days, to consider for velocity calculations. + */ + velocitySpan: number; + + + /** + * The length of time, in minutes, to wait before refreshing statistics. + */ + refreshInterval: number; +} \ No newline at end of file diff --git a/src/extension/domain/statistics/index.ts b/src/extension/domain/statistics/index.ts new file mode 100644 index 0000000..b274bd0 --- /dev/null +++ b/src/extension/domain/statistics/index.ts @@ -0,0 +1,3 @@ +export * from './configuration/statistics.configuration'; +export * from './configuration/statistics.options'; + diff --git a/src/extension/domain/time/configuration/time.configuration.ts b/src/extension/domain/time/configuration/time.configuration.ts new file mode 100644 index 0000000..451a042 --- /dev/null +++ b/src/extension/domain/time/configuration/time.configuration.ts @@ -0,0 +1,34 @@ +import { IConfiguration, IConfigurationProvider } from "../../../core/configuration"; +import { ILogger, LogLevel } from "../../../core/telemetry"; +import { TimeOptions } from "./time.options"; + +/** + * Defines the configuration for time tracking features. + */ +export class TimeConfiguration implements IConfiguration { + + /** + * @constructor + * @param logger The service used to capture errors and log messages for diagnostic purposes. + * @param configProvider The service used to retrieve configuration values and populate runtime options. + */ + constructor( + private readonly logger: ILogger, + private readonly configProvider: IConfigurationProvider + ) {} + + /** @inheritdoc */ + async get(): Promise { + try { + const autoPurge = await this.configProvider.get("time.autoPurge") ?? false; + const retentionPeriod = await this.configProvider.get("time.retentionPeriod") ?? 90; + return { + autoPurge, + retentionPeriod + }; + } catch (error) { + this.logger.log(LogLevel.Error, `Unable to load configurations for time tracking. ${error}`); + throw error; + } + } +} \ No newline at end of file diff --git a/src/extension/domain/time/configuration/time.options.ts b/src/extension/domain/time/configuration/time.options.ts new file mode 100644 index 0000000..00cbf2a --- /dev/null +++ b/src/extension/domain/time/configuration/time.options.ts @@ -0,0 +1,14 @@ +/** + * Defines members for configuring time tracking in the extension. + */ +export interface TimeOptions { + /** + * Indicates whether or not old time entries should be automatically purged. + */ + autoPurge: boolean; + + /** + * The number of days to retain time entries before they are purged. + */ + retentionPeriod: number; +} \ No newline at end of file diff --git a/src/extension/domain/time/index.ts b/src/extension/domain/time/index.ts new file mode 100644 index 0000000..41174b6 --- /dev/null +++ b/src/extension/domain/time/index.ts @@ -0,0 +1,2 @@ +export * from './configuration/time.configuration'; +export * from './configuration/time.options'; diff --git a/src/extension/domain/time/time-entry.ts b/src/extension/domain/time/time-entry.ts new file mode 100644 index 0000000..0a940cf --- /dev/null +++ b/src/extension/domain/time/time-entry.ts @@ -0,0 +1,124 @@ +/** + * Represents a single time tracking event (clock in or clock out) + */ +export interface TimeEntry { + id: string; + type: 'clock-in' | 'clock-out'; + timestamp: Date; +} + +/** + * Represents a day's worth of time entries with calculated totals + */ +export interface DayTimeEntry { + date: string; // YYYY-MM-DD format + entries: TimeEntry[]; + totalHours: number; + totalMinutes: number; + formattedDuration: string; // e.g., "8 hours 15 minutes" +} + +/** + * Utility functions for working with time entries + */ +export class TimeEntryUtils { + /** + * Formats a date as YYYY-MM-DD + */ + static formatDate(date: Date): string { + return date.toISOString().split('T')[0]; + } + + /** + * Formats a date as MM/DD/YYYY + */ + static formatDisplayDate(date: Date): string { + return date.toLocaleDateString('en-US'); + } + + /** + * Formats time as HHMM (e.g., "0755") + */ + static formatTime(date: Date): string { + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}${minutes}`; + } + + /** + * Calculates total work time for a day from time entries + */ + static calculateDayTotal(entries: TimeEntry[]): { hours: number; minutes: number; formatted: string } { + let totalMinutes = 0; + + // Sort entries by timestamp + const sortedEntries = [...entries].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + + for (let i = 0; i < sortedEntries.length - 1; i += 2) { + const clockIn = sortedEntries[i]; + const clockOut = sortedEntries[i + 1]; + + // Only count if we have a matching pair (clock-in followed by clock-out) + if (clockIn.type === 'clock-in' && clockOut?.type === 'clock-out') { + const diff = clockOut.timestamp.getTime() - clockIn.timestamp.getTime(); + totalMinutes += diff / (1000 * 60); // Convert milliseconds to minutes + } + } + + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes % 60); + + let formatted = ''; + if (hours > 0) { + formatted += `${hours} hour${hours !== 1 ? 's' : ''}`; + } + if (minutes > 0) { + if (formatted) { + formatted += ' '; + } + formatted += `${minutes} minute${minutes !== 1 ? 's' : ''}`; + } + if (!formatted) { + formatted = '0 minutes'; + } + + return { hours, minutes, formatted }; + } + + /** + * Groups time entries by date + */ + static groupEntriesByDate(entries: TimeEntry[]): DayTimeEntry[] { + const grouped = new Map(); + + for (const entry of entries) { + const dateKey = this.formatDate(entry.timestamp); + if (!grouped.has(dateKey)) { + grouped.set(dateKey, []); + } + grouped.get(dateKey)!.push(entry); + } + + const result: DayTimeEntry[] = []; + for (const [date, dayEntries] of grouped) { + const total = this.calculateDayTotal(dayEntries); + result.push({ + date, + entries: dayEntries, + totalHours: total.hours, + totalMinutes: total.minutes, + formattedDuration: total.formatted + }); + } + + // Sort by date descending (most recent first) + return result.sort((a, b) => b.date.localeCompare(a.date)); + } + + /** + * Generates a unique ID for a time entry + */ + static generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } +} \ No newline at end of file diff --git a/src/extension/domain/workflow/configuration/workflow.configuration.ts b/src/extension/domain/workflow/configuration/workflow.configuration.ts new file mode 100644 index 0000000..fdd1944 --- /dev/null +++ b/src/extension/domain/workflow/configuration/workflow.configuration.ts @@ -0,0 +1,47 @@ +import { IConfiguration, IConfigurationProvider } from "../../../core/configuration"; +import { ILogger, LogLevel } from "../../../core/telemetry"; +import { WorkflowOptions } from "./workflow.options"; + +/** + * Defines the configuration for workflow management in the extension. + */ +export class WorkflowConfiguration implements IConfiguration { + private static readonly SHOW_INACTIVE_TASKS_KEY = "workflow.showInactiveTasks"; + private static readonly INACTIVE_STATES_KEY = "workflow.inactiveStates"; + private static readonly TASK_READY_STATE_KEY = "workflow.taskReadyState"; + private static readonly TASK_DOING_STATE_KEY = "workflow.taskDoingState"; + private static readonly TASK_DONE_STATE_KEY = "workflow.taskDoneState"; + + /** + * @constructor + * @param logger The service used to capture errors and log messages for diagnostic purposes. + * @param configProvider The service used to retrieve configuration values and populate runtime options. + */ + constructor( + private readonly logger: ILogger, + private readonly configProvider: IConfigurationProvider + ) {} + + /** @inheritdoc */ + async get(): Promise { + try { + const showInactiveTasks = await this.configProvider.get(WorkflowConfiguration.SHOW_INACTIVE_TASKS_KEY) ?? false; + const inactiveStates = await this.configProvider.get(WorkflowConfiguration.INACTIVE_STATES_KEY) ?? []; + const taskReadyState = await this.configProvider.get(WorkflowConfiguration.TASK_READY_STATE_KEY) ?? "Ready"; + const taskDoingState = await this.configProvider.get(WorkflowConfiguration.TASK_DOING_STATE_KEY) ?? "In Progress"; + const taskDoneState = await this.configProvider.get(WorkflowConfiguration.TASK_DONE_STATE_KEY) ?? "Done"; + const workItemStartedState = await this.configProvider.get('workflowOptions.workItemStartedState') ?? "Active"; + return { + showInactiveTasks, + inactiveStates, + taskReadyState, + taskDoingState, + taskDoneState, + workItemStartedState + }; + } catch (error) { + this.logger.log(LogLevel.Error, `Unable to load configurations for workflow management. ${error}`); + throw error; + } + } +} \ No newline at end of file diff --git a/src/extension/domain/workflow/configuration/workflow.options.ts b/src/extension/domain/workflow/configuration/workflow.options.ts new file mode 100644 index 0000000..fae6259 --- /dev/null +++ b/src/extension/domain/workflow/configuration/workflow.options.ts @@ -0,0 +1,36 @@ +/** + * Defines options for workflow management in the extension. + */ +export interface WorkflowOptions { + /** + * Indicates whether or not inactive tasks should be displayed when available. + */ + showInactiveTasks: boolean; + + /** + * Contains states that indicate a work item is considered inactive. + * + * @remarks This is used to filter out work items that are not currently being worked on. + */ + inactiveStates: string[]; + + /** + * The state used to indicate that a task is ready to be worked on. + */ + taskReadyState: string; + + /** + * The state used to indicate that a task is currently being worked on. + */ + taskDoingState: string; + + /** + * The state used to indicate that a task has been completed. + */ + taskDoneState: string; + + /** + * The state used to indicate that a work item has been started. + */ + workItemStartedState: string; +} \ No newline at end of file diff --git a/src/extension/domain/workflow/default-task.service.ts b/src/extension/domain/workflow/default-task.service.ts new file mode 100644 index 0000000..9e84748 --- /dev/null +++ b/src/extension/domain/workflow/default-task.service.ts @@ -0,0 +1,108 @@ +import { Task } from './task'; +import { TaskTemplateLoader } from './task-template-loader'; +import { TaskTemplate } from './task-template.schema'; +import { ITaskService } from './task.service.interface'; +import { WorkItem } from './work-item'; + +/** + * Service providing synchronous access to default task templates. + * This service assumes templates have already been loaded during extension initialization. + */ +export class DefaultTaskService implements ITaskService { + + /** + * Gets the default tasks that should be created for a specific work item. + * @param workItem The work item to get default tasks for. + * @returns An array of task work items that should be added as children. + */ + public getDefaultTasksForWorkItem(workItem: WorkItem): WorkItem[] { + const templates = this.getTaskTemplatesForWorkItem(workItem.type.name); + return templates.map(template => this.createTaskFromTemplate(template)); + } + + /** + * Gets task templates that apply to a specific work item type. + * @param workItemType The work item type to filter by. + * @returns An array of task templates. + */ + private getTaskTemplatesForWorkItem(workItemType: string): TaskTemplate[] { + if (!TaskTemplateLoader.isTemplatesLoaded()) { + console.warn('Task templates not loaded yet. Returning empty array.'); + return []; + } + + return TaskTemplateLoader.getTemplatesForWorkItemType(workItemType); + } + + /** + * Creates a Task work item from a template. + * @param template The task template. + * @returns A new Task work item. + */ + private createTaskFromTemplate(template: TaskTemplate): WorkItem { + return new Task( + template.title, + template.description, + template.remainingWork, + template.activity + ); + } + + // Static methods for backward compatibility and direct access + /** + * Gets default tasks for a specific work item type. + * @param workItemType The work item type (e.g., "Feature", "User Story", "Bug") + * @returns Array of task templates applicable to the work item type + */ + public static getDefaultTasksForWorkItem(workItemType: string): TaskTemplate[] { + if (!TaskTemplateLoader.isTemplatesLoaded()) { + console.warn('Task templates not loaded yet. Returning empty array.'); + return []; + } + + return TaskTemplateLoader.getTemplatesForWorkItemType(workItemType); + } + + /** + * Gets all default tasks regardless of work item type. + * @returns Array of all task templates + */ + public static getAllDefaultTasks(): TaskTemplate[] { + if (!TaskTemplateLoader.isTemplatesLoaded()) { + console.warn('Task templates not loaded yet. Returning empty array.'); + return []; + } + + return TaskTemplateLoader.getAllTemplates(); + } + + /** + * Gets default tasks by category. + * @param category The category name (e.g., "development", "planning") + * @returns Array of task templates in the specified category + */ + public static getDefaultTasksByCategory(category: string): TaskTemplate[] { + if (!TaskTemplateLoader.isTemplatesLoaded()) { + console.warn('Task templates not loaded yet. Returning empty array.'); + return []; + } + + return TaskTemplateLoader.getTemplatesByCategory(category); + } + + /** + * Checks if default tasks are available. + * @returns True if default tasks have been loaded, false otherwise + */ + public static isAvailable(): boolean { + return TaskTemplateLoader.isTemplatesLoaded(); + } +} + +/** + * Legacy function for backward compatibility. + * @deprecated Use DefaultTaskService.getAllDefaultTasks() instead + */ +export function getDefaultTasks(): TaskTemplate[] { + return DefaultTaskService.getAllDefaultTasks(); +} diff --git a/src/extension/domain/workflow/index.ts b/src/extension/domain/workflow/index.ts new file mode 100644 index 0000000..0273974 --- /dev/null +++ b/src/extension/domain/workflow/index.ts @@ -0,0 +1,13 @@ +export * from './default-task.service'; +export * from './task'; +export * from './task-template-loader'; +export * from './task-template.schema'; +export * from './task.service.interface'; +export * from './work-item'; +export * from './work-item-state'; +export * from './work-item-type'; +export * from './work-item.service'; + +export * from './configuration/workflow.configuration'; +export * from './configuration/workflow.options'; + diff --git a/src/extension/domain/workflow/pre-defined-tasks/json-template-loader.ts b/src/extension/domain/workflow/pre-defined-tasks/json-template-loader.ts new file mode 100644 index 0000000..db78075 --- /dev/null +++ b/src/extension/domain/workflow/pre-defined-tasks/json-template-loader.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import { DefaultTaskService } from '../default-task.service'; +import { TaskTemplate } from '../task-template.schema'; + +/** + * Legacy template loader for backward compatibility. + * @deprecated Use DefaultTaskService directly instead + */ +export class JsonTemplateLoader { + constructor(private readonly context: vscode.ExtensionContext) {} + + /** + * Loads all templates (no-op since templates are loaded during initialization). + * @deprecated Templates are now loaded during extension initialization + */ + public async loadAllTemplates(): Promise { + // Templates are already loaded during extension initialization + // This method is kept for backward compatibility + } + + /** + * Creates pre-defined tasks from loaded templates. + * @deprecated Use DefaultTaskService.getAllDefaultTasks() instead + */ + public createPreDefinedTasks(): TaskTemplate[] { + return DefaultTaskService.getAllDefaultTasks(); + } + + /** + * Gets templates for a specific work item type. + * @deprecated Use DefaultTaskService.getDefaultTasksForWorkItem() instead + */ + public getTemplatesForWorkItemType(workItemType: string): TaskTemplate[] { + return DefaultTaskService.getDefaultTasksForWorkItem(workItemType); + } +} diff --git a/src/extension/domain/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper.ts b/src/extension/domain/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper.ts new file mode 100644 index 0000000..d82839d --- /dev/null +++ b/src/extension/domain/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper.ts @@ -0,0 +1,76 @@ +import { TaskTemplate } from '../task-template.schema'; + +/** + * Maps task templates to Azure DevOps JSON patch documents for work item creation. + */ +export class PreDefinedTaskJsonPatchDocumentMapper { + constructor( + private readonly userDisplayName: string, + private readonly organizationUri: string, + private readonly parentWorkItemId: number, + private readonly areaPath: string, + private readonly iterationPath: string + ) {} + + /** + * Maps a task template to a JSON patch document for Azure DevOps work item creation. + * @param task The task template to map + * @returns The JSON patch document + */ + public map(task: TaskTemplate): any[] { + const patchDocument = [ + { + op: "add", + path: "/fields/System.Title", + value: task.title + }, + { + op: "add", + path: "/fields/System.Description", + value: task.description + }, + { + op: "add", + path: "/fields/Microsoft.VSTS.Scheduling.RemainingWork", + value: task.remainingWork + }, + { + op: "add", + path: "/fields/Microsoft.VSTS.Common.Activity", + value: task.activity + }, + { + op: "add", + path: "/fields/System.AreaPath", + value: this.areaPath + }, + { + op: "add", + path: "/fields/System.IterationPath", + value: this.iterationPath + }, + { + op: "add", + path: "/relations/-", + value: { + rel: "System.LinkTypes.Hierarchy-Reverse", + url: `${this.organizationUri}/_apis/wit/workItems/${this.parentWorkItemId}`, + attributes: { + comment: "Making this task a child of the parent work item." + } + } + } + ]; + + // Add assignee if required + if (task.metadata?.assigneeRequired) { + patchDocument.push({ + op: "add", + path: "/fields/System.AssignedTo", + value: this.userDisplayName + }); + } + + return patchDocument; + } +} diff --git a/src/extension/domain/workflow/task-template-loader.ts b/src/extension/domain/workflow/task-template-loader.ts new file mode 100644 index 0000000..617dc38 --- /dev/null +++ b/src/extension/domain/workflow/task-template-loader.ts @@ -0,0 +1,119 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { JsonFileReader } from '../../core/serialization/json-file-reader'; +import { TaskTemplate, TaskTemplateSchema } from './task-template.schema'; + +/** + * Service responsible for loading and managing task templates from JSON files. + * Templates are loaded once during extension initialization and cached for runtime access. + */ +export class TaskTemplateLoader { + private static taskTemplates: TaskTemplate[] = []; + private static isLoaded = false; + + constructor(private readonly context: vscode.ExtensionContext) {} + + /** + * Loads all task templates from JSON files in the resources/tasks directory. + * This should be called during extension initialization. + */ + public async loadTemplates(): Promise { + if (TaskTemplateLoader.isLoaded) { + return; + } + + try { + const templatesPath = path.join(this.context.extensionPath, 'resources', 'tasks'); + const templateFiles = ['development.json', 'planning.json', 'quality-assurance.json', 'release.json', 'review.json']; + const templates: TaskTemplate[] = []; + for (const fileName of templateFiles) { + const filePath = path.join(templatesPath, fileName); + try { + const templateSchema = await JsonFileReader.read(filePath); + if (!templateSchema || !templateSchema.templates) { + console.warn(`No templates found in file: ${fileName}`); + continue; + } + + const category = this.extractCategoryFromFileName(fileName); + for (const template of templateSchema.templates) { + templates.push({ + ...template, + metadata: { + ...template.metadata, + category + } + }); + } + } catch (error) { + console.warn(`Failed to load task template file ${fileName}:`, error); + } + } + + TaskTemplateLoader.taskTemplates = templates; + TaskTemplateLoader.isLoaded = true; + + console.log(`Loaded ${templates.length} task templates from ${templateFiles.length} files`); + } catch (error) { + console.error('Failed to load task templates:', error); + TaskTemplateLoader.taskTemplates = []; + TaskTemplateLoader.isLoaded = true; + } + } + + /** + * Gets all loaded task templates. + * @returns Array of all task templates + */ + public static getAllTemplates(): TaskTemplate[] { + return [...TaskTemplateLoader.taskTemplates]; + } + + /** + * Gets task templates that apply to a specific work item type. + * @param workItemType The work item type (e.g., "Feature", "User Story", "Bug") + * @returns Array of applicable task templates + */ + public static getTemplatesForWorkItemType(workItemType: string): TaskTemplate[] { + return TaskTemplateLoader.taskTemplates.filter(template => + template.metadata?.appliesTo?.includes(workItemType) || + template.appliesTo?.includes(workItemType) + ); + } + + /** + * Gets task templates by category. + * @param category The category to filter by (e.g., "development", "planning") + * @returns Array of task templates in the specified category + */ + public static getTemplatesByCategory(category: string): TaskTemplate[] { + return TaskTemplateLoader.taskTemplates.filter(template => + template.metadata?.category === category + ); + } + + /** + * Checks if templates have been loaded. + * @returns True if templates are loaded, false otherwise + */ + public static isTemplatesLoaded(): boolean { + return TaskTemplateLoader.isLoaded; + } + + /** + * Clears loaded templates (useful for testing). + */ + public static clearTemplates(): void { + TaskTemplateLoader.taskTemplates = []; + TaskTemplateLoader.isLoaded = false; + } + + /** + * Extracts category name from file name. + * @param fileName The JSON file name + * @returns The category name + */ + private extractCategoryFromFileName(fileName: string): string { + return fileName.replace('.json', '').replace(/-/g, ' '); + } +} diff --git a/src/extension/domain/workflow/task-template.schema.ts b/src/extension/domain/workflow/task-template.schema.ts new file mode 100644 index 0000000..e3b3ca6 --- /dev/null +++ b/src/extension/domain/workflow/task-template.schema.ts @@ -0,0 +1,71 @@ +/** + * Schema definition for task template JSON files. + */ +export interface TaskTemplateSchema { + $schema?: string; + version: string; + metadata: { + name: string; + description: string; + author: string; + }; + templates: TaskTemplate[]; +} + +/** + * Represents a task template loaded from JSON. + */ +export interface TaskTemplate { + /** + * The title of the task. + */ + title: string; + + /** + * The description of the task. + */ + description: string; + + /** + * The number of hours the task is estimated to take. + */ + remainingWork: number; + + /** + * The activity associated with the task. + */ + activity: string; + + /** + * The fields that must have values for the task to be created. + */ + requiredFields?: string[]; + + /** + * The WorkItemType names to which the task applies. + * This can be in either the root level or metadata for backward compatibility. + */ + appliesTo?: string[]; + + /** + * Additional metadata for the template. + */ + metadata?: { + /** + * The WorkItemType names to which the task applies. + */ + appliesTo?: string[]; + + /** + * Indicates whether the task should have an assignee by default. + */ + assigneeRequired?: boolean; + + /** + * The category of this template (e.g., "development", "planning"). + */ + category?: string; + + [key: string]: any; + }; +} diff --git a/src/extension/domain/workflow/task.service.interface.ts b/src/extension/domain/workflow/task.service.interface.ts new file mode 100644 index 0000000..56978b4 --- /dev/null +++ b/src/extension/domain/workflow/task.service.interface.ts @@ -0,0 +1,13 @@ +import { WorkItem } from './work-item'; + +/** + * Defines the contract for a service that manages tasks. + */ +export abstract class ITaskService { + /** + * Gets the default tasks that should be created for a specific work item. + * @param workItem The work item to get default tasks for. + * @returns An array of task work items that should be added as children. + */ + abstract getDefaultTasksForWorkItem(workItem: WorkItem): WorkItem[]; +} diff --git a/src/extension/domain/workflow/task.ts b/src/extension/domain/workflow/task.ts new file mode 100644 index 0000000..8bc24db --- /dev/null +++ b/src/extension/domain/workflow/task.ts @@ -0,0 +1,23 @@ +import { WorkItem } from "./work-item"; +import { WorkItemType } from "./work-item-type"; + +/** + * Represents a task in Azure DevOps. + */ +export class Task extends WorkItem { + /** + * Creates a new task instance with the specified details. + * @param title The title of the task. + * @param description The description of the task. + * @param remainingWork The number of hours the task is estimated to take. + * @param activity The activity associated with the task. + */ + constructor( + title: string, + description: string, + remainingWork: number, + activity: string + ) { + super(title, description, remainingWork, activity, WorkItemType.Task); + } +} \ No newline at end of file diff --git a/src/extension/core/workflow/work-item-state.ts b/src/extension/domain/workflow/work-item-state.ts similarity index 100% rename from src/extension/core/workflow/work-item-state.ts rename to src/extension/domain/workflow/work-item-state.ts diff --git a/src/extension/core/workflow/work-item-type.ts b/src/extension/domain/workflow/work-item-type.ts similarity index 73% rename from src/extension/core/workflow/work-item-type.ts rename to src/extension/domain/workflow/work-item-type.ts index ca1b0d4..11976e2 100644 --- a/src/extension/core/workflow/work-item-type.ts +++ b/src/extension/domain/workflow/work-item-type.ts @@ -19,10 +19,20 @@ export class WorkItemType { /** * Represents the "task" work item type. */ + public static Task = new WorkItemType('Task'); + + /** + * Represents the "bug" work item type. + */ public static Bug = new WorkItemType('Bug'); /** * Represents the "user story" work item type. */ public static UserStory = new WorkItemType('User Story'); + + /** + * Represents the "feature" work item type. + */ + public static Feature = new WorkItemType('Feature'); } diff --git a/src/extension/core/workflow/work-item.service.ts b/src/extension/domain/workflow/work-item.service.ts similarity index 100% rename from src/extension/core/workflow/work-item.service.ts rename to src/extension/domain/workflow/work-item.service.ts diff --git a/src/extension/domain/workflow/work-item.ts b/src/extension/domain/workflow/work-item.ts new file mode 100644 index 0000000..647d265 --- /dev/null +++ b/src/extension/domain/workflow/work-item.ts @@ -0,0 +1,181 @@ +import { WorkItemState } from "./work-item-state"; +import { WorkItemType } from "./work-item-type"; + +/** + * Represents a work item in Azure DevOps. + */ +export class WorkItem { + private parentWorkItem: WorkItem | undefined = undefined; + private currentState: WorkItemState | undefined = undefined; + private previousState: WorkItemState | undefined = undefined; + private assignedUserDisplayName: string | undefined; + private linkedChildren: WorkItem[] = []; + + /** + * The unique identifier of the work item. + */ + public id: number | undefined; + + /** + * The title of the work item. + */ + public title: string; + + /** + * The description of the work item. + */ + public description: string; + + /** + * The number of hours the work item is estimated to take. + */ + public remainingWork: number; + + /** + * The activity associated with the work item. + */ + public activity: string; + + /** + * The type of the work item. + * This can be a specific work item type like "Bug", "User Story", etc. + */ + public type: WorkItemType; + + /** + * The area path of the work item. + */ + public areaPath: string | undefined = undefined; + + /** + * The iteration path of the work item. + */ + public iterationPath: string | undefined = undefined; + + /** + * The additional fields associated with the work item. + */ + public additionalFields: { [key: string]: any } = {}; + + /** + * @constructor + * @param title The title of the work item. + * @param description The description of the work item. + * @param remainingWork The number of hours the work item is estimated to take. + * @param activity The activity associated with the work item. + */ + constructor( + title: string, + description: string, + remainingWork: number, + activity: string, + type: WorkItemType + ) { + this.title = title; + this.description = description; + this.remainingWork = remainingWork; + this.activity = activity; + this.type = type; + } + + /** + * Gets the parent of the work item. + * @return The parent work item, or undefined if there is no parent. + */ + public get parent(): WorkItem | undefined { + return this.parentWorkItem; + } + + /** + * Gets the current state of the work item. + * @returns The current state of the work item. + */ + public get state(): WorkItemState | undefined { + return this.currentState; + } + + /** + * Gets the display name of the user to whom the work item is assigned. + * @returns The display name of the user to whom the work item is assigned. + */ + public get assignedTo(): string | undefined { + return this.assignedUserDisplayName; + } + + /** + * Gets the children of this work item. + * @returns An array of child work items. + */ + public get children(): WorkItem[] { + return this.linkedChildren; + } + + /** + * Starts the work item. + * @param startingState The state to set when starting the work item. + */ + public start(startingState: string): void { + if (this.previousState || this.linkedChildren?.length > 0) { + throw new Error("Work item is already started."); + } + + this.currentState = new WorkItemState(startingState); + this.previousState = this.currentState; + } + + /** + * Changes the state of the work item. + * @param state The new state to set for the work item. + * @throws Error if the work item is already in the specified state. + */ + public changeState(state: WorkItemState): void { + if (this.currentState?.name === state.name) { + throw new Error(`Work item is already in the ${state.name} state.`); + } + + this.previousState = this.currentState; + this.currentState = state; + } + + /** + * Assigns the work item to a user. + * @param user The display name of the user to whom the work item is assigned. + */ + public assignTo(user: string): void { + if (this.assignedUserDisplayName) { + throw new Error("Work item is already assigned to a user; you must transfer it instead."); + } + + this.assignedUserDisplayName = user; + } + /** + * Transfers the work item to a different user. + * @param user The display name of the user to whom the work item is transferred. + */ + public transferTo(user: string): void { + if (!this.assignedUserDisplayName) { + throw new Error("Work item is not assigned to any user; you must assign it first."); + } + + this.assignedUserDisplayName = user; + } + + /** + * Adds a child work item. + * @param child The child work item to add. + */ + public addChild(child: WorkItem): void { + if (this.linkedChildren.some(c => c.id === child.id)) { + throw new Error("Child work item with the same ID already exists."); + } + + if (child.id === this.id) { + throw new Error("A work item cannot be a child of itself."); + } + + child.parentWorkItem = this; + child.areaPath = this.areaPath; + child.iterationPath = this.iterationPath; + this.linkedChildren.push(child); + } +} \ No newline at end of file diff --git a/src/extension/domain/workflow/workflow.service.interface.ts b/src/extension/domain/workflow/workflow.service.interface.ts new file mode 100644 index 0000000..504e42a --- /dev/null +++ b/src/extension/domain/workflow/workflow.service.interface.ts @@ -0,0 +1,11 @@ +/** + * Defines members for managing workflow operations. + */ +export abstract class IWorkflowService { + /** + * Starts the specified work item if it exists and not already started. + * @param workItemId The ID of the work item to start. + * @returns A promise that resolves when the work item has been started. + */ + abstract start(workItemId: number): Promise; +} \ No newline at end of file diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 9783441..00d0df0 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -1,10 +1,13 @@ import * as vscode from 'vscode'; -import { CommandRegistry } from './core/command-registry'; +import * as toolbox from './setup'; /** * Activates the extension when it is loaded by VS Code. * @param context The extension context provided by VS Code. */ -export function activate(context: vscode.ExtensionContext) { - CommandRegistry.registerCommands(context); +export async function activate(context: vscode.ExtensionContext) { + await toolbox.initialize(context); + toolbox.registerServices(context); + toolbox.registerViews(context); + toolbox.registerCommands(context); } \ No newline at end of file diff --git a/src/extension/infrastructure/azure/azure-devops-work-item.repository.ts b/src/extension/infrastructure/azure/azure-devops-work-item.repository.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/extension/services/azure.devops.work-item.service.ts b/src/extension/infrastructure/azure/azure-devops-work-item.service.ts similarity index 86% rename from src/extension/services/azure.devops.work-item.service.ts rename to src/extension/infrastructure/azure/azure-devops-work-item.service.ts index 7dd377f..845c298 100644 --- a/src/extension/services/azure.devops.work-item.service.ts +++ b/src/extension/infrastructure/azure/azure-devops-work-item.service.ts @@ -4,12 +4,12 @@ import { window } from "vscode"; import { WebApi } from 'azure-devops-node-api/WebApi'; import { WorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; -import { ICommunicationService } from "../core/communication"; -import { DefaultTasks } from "../core/default-tasks"; -import { ILogger, LogLevel } from "../core/telemetry"; -import { IWorkItemService, WorkItem, WorkItemState, WorkItemType } from "../core/workflow"; -import { PreDefinedTaskJsonPatchDocumentMapper } from "../core/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper"; -import { DevOpsService } from "../services/devops-service"; +import { ICommunicationService } from "../../core/communication"; +import { ILogger, LogLevel } from "../../core/telemetry"; +import { IWorkItemService, Task, WorkItem, WorkItemState, WorkItemType } from "../../domain/workflow"; +import { DefaultTaskService } from "../../domain/workflow/default-task.service"; +import { PreDefinedTaskJsonPatchDocumentMapper } from "../../domain/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper"; +import { DevOpsService } from "./devops-service"; export class AzureDevOpsWorkItemService implements IWorkItemService { constructor( @@ -48,18 +48,26 @@ export class AzureDevOpsWorkItemService implements IWorkItemService { const parentWorkItem = await workItemTrackingClient.getWorkItem(workItemId); const workItemTitle = parentWorkItem.fields?.['System.Title'] as string; - return { - id: workItemId, - title: workItemTitle, - type: new WorkItemType(parentWorkItem.fields?.['System.WorkItemType'] as string), - state: new WorkItemState(parentWorkItem.fields?.['System.State'] as string), - areaPath: parentWorkItem.fields?.['System.AreaPath'] as string, - iterationPath: parentWorkItem.fields?.['System.IterationPath'] as string, - description: parentWorkItem.fields?.['System.Description'] as string || "", - remainingWork: parentWorkItem.fields?.['Microsoft.VSTS.Scheduling.RemainingWork'] as number || 0, - activity: parentWorkItem.fields?.['Microsoft.VSTS.Common.Activity'] as string || "", - additionalFields: parentWorkItem.fields || {} - }; + const workItemType = new WorkItemType(parentWorkItem.fields?.['System.WorkItemType'] as string); + const workItemState = new WorkItemState(parentWorkItem.fields?.['System.State'] as string); + const description = parentWorkItem.fields?.['System.Description'] as string || ""; + const remainingWork = parentWorkItem.fields?.['Microsoft.VSTS.Scheduling.RemainingWork'] as number || 0; + const activity = parentWorkItem.fields?.['Microsoft.VSTS.Common.Activity'] as string || ""; + + // Create the appropriate WorkItem instance based on type + let workItem: WorkItem; + if (workItemType.name === 'Task') { + workItem = new Task(workItemTitle, description, remainingWork, activity); + } else { + workItem = new WorkItem(workItemTitle, description, remainingWork, activity, workItemType); + } + + // Set additional properties + workItem.id = workItemId; + workItem.areaPath = parentWorkItem.fields?.['System.AreaPath'] as string; + workItem.iterationPath = parentWorkItem.fields?.['System.IterationPath'] as string; + + return workItem; } catch (error) { window.showErrorMessage(`Failed to retrieve work item #${workItemId}: ${(error as Error).message}`); this.logger.log(LogLevel.Error, `Failed to retrieve work item #${workItemId}: ${(error as Error).message}`); @@ -101,7 +109,7 @@ export class AzureDevOpsWorkItemService implements IWorkItemService { workItem.id ); - workItem.state = state; + workItem.changeState(state); this.logger.log(LogLevel.Information, `Successfully changed work item #${workItem.id} state to '${state.name}'.`); } catch (error) { const errorMessage = `Failed to change work item #${workItem.id} state to '${state.name}': ${(error as Error).message}`; @@ -139,9 +147,13 @@ export class AzureDevOpsWorkItemService implements IWorkItemService { } const taskMapper = new PreDefinedTaskJsonPatchDocumentMapper(userDisplayName, organizationUri, workItem.id || -1, workItem.areaPath || "", workItem.iterationPath || ""); - for (const task of DefaultTasks) { + + // Load default tasks from JSON templates + const defaultTasks = DefaultTaskService.getDefaultTasksForWorkItem(workItem.type.name); + + for (const task of defaultTasks) { // if (task.requiredFields && !task.requiredFields.every(field => workItemFields[field] !== undefined && workItemFields[field] !== null && workItemFields[field] !== '')) { - // this.logger.log(LogLevel.Warning, `Skipping task '${task.name}' as one or more required fields are missing or empty.`); + // this.logger.log(LogLevel.Warning, `Skipping task '${task.title}' as one or more required fields are missing or empty.`); // continue; // } @@ -154,10 +166,10 @@ export class AzureDevOpsWorkItemService implements IWorkItemService { 'Task' ); - this.logger.log(LogLevel.Debug, `Created task '${task.name}' with ID ${createdTask.id} under work item #${workItem.id}.`); + this.logger.log(LogLevel.Debug, `Created task '${task.title}' with ID ${createdTask.id} under work item #${workItem.id}.`); } catch (error) { const errorMessage = (error as Error).message; - this.logger.log(LogLevel.Error, `Failed to create task '${task.name}': ${errorMessage}`); + this.logger.log(LogLevel.Error, `Failed to create task '${task.title}': ${errorMessage}`); } } } diff --git a/src/extension/infrastructure/azure/commands/create-task.command.ts b/src/extension/infrastructure/azure/commands/create-task.command.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/extension/infrastructure/azure/commands/update-work-item-state.command.ts b/src/extension/infrastructure/azure/commands/update-work-item-state.command.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/extension/services/devops-service.ts b/src/extension/infrastructure/azure/devops-service.ts similarity index 98% rename from src/extension/services/devops-service.ts rename to src/extension/infrastructure/azure/devops-service.ts index 5f89e59..59c10b5 100644 --- a/src/extension/services/devops-service.ts +++ b/src/extension/infrastructure/azure/devops-service.ts @@ -1,7 +1,7 @@ import * as devops from "azure-devops-node-api"; import { window } from "vscode"; -import { IConfigurationProvider, ISecretProvider } from "../core/configuration"; +import { IConfigurationProvider, ISecretProvider } from "../../core/configuration"; export class DevOpsService { constructor( diff --git a/src/extension/infrastructure/azure/devops.configuration.ts b/src/extension/infrastructure/azure/devops.configuration.ts new file mode 100644 index 0000000..b89ba46 --- /dev/null +++ b/src/extension/infrastructure/azure/devops.configuration.ts @@ -0,0 +1,67 @@ +import { IConfiguration, IConfigurationProvider, ISecretProvider, MissingConfigurationError } from "../../core/configuration"; +import { ILogger, LogLevel } from "../../core/telemetry"; +import { DevOpsOptions } from "./devops.options"; + +/** + * Defines the configuration for communicating with Azure DevOps. + */ +export class DevOpsConfiguration implements IConfiguration { + private static readonly USE_CLASSIC_URL_KEY = "azure.devops.useClassicUrl"; + private static readonly ORGANIZATION_KEY = "azure.devops.organization"; + private static readonly PROJECT_KEY = "azure.devops.project"; + private static readonly USER_DISPLAY_NAME_KEY = "azure.devops.userDisplayName"; + private static readonly PERSONAL_ACCESS_TOKEN_KEY = "azure.devops.personalAccessToken"; + + /** + * @constructor + * @param logger The service used to capture errors and log messages for diagnostic purposes. + * @param configProvider The service used to retrieve configuration values and populate runtime options. + * @param secretProvider The service used to retrieve sensitive values like personal access tokens. + */ + constructor( + private readonly logger: ILogger, + private readonly configProvider: IConfigurationProvider, + private readonly secretProvider: ISecretProvider + ) {} + + /** @inheritdoc */ + async get(): Promise { + try { + const organization = await this.configProvider.get(DevOpsConfiguration.ORGANIZATION_KEY); + if (!organization) + { + throw new MissingConfigurationError(DevOpsConfiguration.ORGANIZATION_KEY, "Azure DevOps Organization", "Please ensure your organization name is set in the configuration."); + } + + const project = await this.configProvider.get(DevOpsConfiguration.PROJECT_KEY); + if (!project) + { + throw new MissingConfigurationError(DevOpsConfiguration.PROJECT_KEY, "Azure DevOps Project", "Please ensure your project name is set in the configuration."); + } + + const userDisplayName = await this.configProvider.get(DevOpsConfiguration.USER_DISPLAY_NAME_KEY); + if (!userDisplayName) + { + throw new MissingConfigurationError(DevOpsConfiguration.USER_DISPLAY_NAME_KEY, "User Display Name", "Please ensure your display name is set in the configuration."); + } + + const personalAccessToken = await this.secretProvider.get(DevOpsConfiguration.PERSONAL_ACCESS_TOKEN_KEY); + if (!personalAccessToken) + { + throw new MissingConfigurationError(DevOpsConfiguration.PERSONAL_ACCESS_TOKEN_KEY, "Personal Access Token", "Please ensure your Personal Access Token is set in the secrets."); + } + + const useClassicUrl = await this.configProvider.get(DevOpsConfiguration.USE_CLASSIC_URL_KEY) ?? false; + return { + useClassicUrl: useClassicUrl, + organization: organization!, + project: project!, + userDisplayName: userDisplayName!, + personalAccessToken: personalAccessToken! + }; + } catch (error) { + this.logger.log(LogLevel.Error, `Unable to load configurations for communication with Azure DevOps. ${error}`); + throw error; + } + } +} \ No newline at end of file diff --git a/src/extension/infrastructure/azure/devops.options.ts b/src/extension/infrastructure/azure/devops.options.ts new file mode 100644 index 0000000..dc087ee --- /dev/null +++ b/src/extension/infrastructure/azure/devops.options.ts @@ -0,0 +1,31 @@ +/** + * Defines options for communicating with Azure DevOps. + */ +export interface DevOpsOptions { + /** + * Indicates whether or not the classic URL format should be used to communicate with the configured organization. + */ + useClassicUrl: boolean; + + /** + * The organization within Azure DevOps the extension should focus on supporting. + */ + organization: string; + + /** + * The name of the project the extension should focus on supporting. + */ + project: string; + + /** + * The display name used to represent the user in Azure DevOps. + * + * @remarks This is used for identifying the user in work items and other Azure DevOps features. + */ + userDisplayName: string; + + /** + * The personal access token used for authentication with Azure DevOps. + */ + personalAccessToken: string; +} \ No newline at end of file diff --git a/src/extension/infrastructure/azure/mappings/work-item.mapper.ts b/src/extension/infrastructure/azure/mappings/work-item.mapper.ts new file mode 100644 index 0000000..c1f5be5 --- /dev/null +++ b/src/extension/infrastructure/azure/mappings/work-item.mapper.ts @@ -0,0 +1,85 @@ +import { JsonPatchDocument, JsonPatchOperation, Operation } from "azure-devops-node-api/interfaces/common/VSSInterfaces"; +import { WorkItem as InfrastructureModel } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces"; +import { WorkItem as DomainModel, WorkItem, WorkItemState, WorkItemType } from "../../../domain/workflow"; + +/** @inheritdoc */ +export class WorkItemMapper { + + /** @inheritdoc */ + mapInfrastructureModelToDomainModel(input: InfrastructureModel): DomainModel { + const id = input.id || 0; + const title = this.getValue(input, 'System.Title', ""); + const description = this.getValue(input, 'System.Description', ""); + const remainingWork = this.getValue(input, 'Microsoft.VSTS.Scheduling.RemainingWork', 0); + const activity = this.getValue(input, 'Microsoft.VSTS.Common.Activity', ""); + const workItemType = this.getValue(input, 'System.WorkItemType', ""); + const workItem = new WorkItem(title, description, remainingWork, activity, new WorkItemType(workItemType)); + + const workItemState = this.getValue(input, 'System.State', ""); + workItem.changeState(new WorkItemState(workItemState)); + + const assignedTo = this.getValue(input, 'System.AssignedTo', ""); + workItem.assignTo(assignedTo); + + workItem.id = id; + workItem.areaPath = this.getValue(input, 'System.AreaPath', ""); + workItem.iterationPath = this.getValue(input, 'System.IterationPath', ""); + return workItem; + } + + mapDomainModelToJsonPatchDocument(input: DomainModel): JsonPatchDocument { + const patchDocument: JsonPatchOperation[] = []; + + if (input.title) { + patchDocument.push({ op: Operation.Add, path: "/title", value: input.title }); + } + + if (input.description) { + patchDocument.push({ op: Operation.Add, path: "/description", value: input.description }); + } + + if (input.remainingWork) { + patchDocument.push({ op: Operation.Add, path: "/remainingWork", value: input.remainingWork }); + } + + if (input.areaPath) { + patchDocument.push({ op: Operation.Add, path: "/areaPath", value: input.areaPath }); + } + + if (input.iterationPath) { + patchDocument.push({ op: Operation.Add, path: "/iterationPath", value: input.iterationPath }); + } + + if (input.assignedTo) { + patchDocument.push({ op: Operation.Add, path: "/assignedTo", value: input.assignedTo }); + } + + if (input.type) { + patchDocument.push({ op: Operation.Add, path: "/type", value: input.type.name }); + } + + if (input.state) { + patchDocument.push({ op: Operation.Add, path: "/state", value: input.state.name }); + } + + if (input.activity) { + patchDocument.push({ op: Operation.Add, path: "/activity", value: input.activity }); + } + + if (input.parent) { + const relationship = { + rel: "System.LinkTypes.Hierarchy", + url: `/workitems/${input.parent.id}`, + attributes: { comment: "Linked to parent work item by Hazel's toolbox in VS Code." } + }; + + patchDocument.push({ op: Operation.Add, path: "/relations/-", value: relationship }); + } + + return patchDocument; + } + + private getValue(input: InfrastructureModel, fieldName: string, defaultValue: T): T { + return input.fields?.[fieldName] as T | undefined || defaultValue; + } +} \ No newline at end of file diff --git a/src/extension/infrastructure/azure/queries/get-available-states-for-work-item-type.query.ts b/src/extension/infrastructure/azure/queries/get-available-states-for-work-item-type.query.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/extension/infrastructure/azure/queries/get-work-item-by-id.query.ts b/src/extension/infrastructure/azure/queries/get-work-item-by-id.query.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/extension/infrastructure/azure/work-item-repository.ts b/src/extension/infrastructure/azure/work-item-repository.ts new file mode 100644 index 0000000..eea5e67 --- /dev/null +++ b/src/extension/infrastructure/azure/work-item-repository.ts @@ -0,0 +1,70 @@ +import * as devops from "azure-devops-node-api"; +import { WebApi } from "azure-devops-node-api"; +import { WorkItemTrackingApi } from "azure-devops-node-api/WorkItemTrackingApi"; +import { IConfiguration, ILogger, LogLevel } from "../../core"; +import { IRepository } from "../../core/repository"; +import { WorkItem } from "../../domain/workflow"; +import { DevOpsOptions } from "./devops.options"; +import { WorkItemMapper } from "./mappings/work-item.mapper"; + +/** @inheritdoc */ +export class AzureDevOpsWorkItemRepository implements IRepository { + + private readonly workItemMapper: WorkItemMapper = new WorkItemMapper(); + + /** + * @constructor + * @param configuration The configuration for DevOps options. + * @param workItemMapper The mapper to convert between domain and infrastructure models. + */ + constructor( + private readonly logger: ILogger, + private readonly configuration: IConfiguration + ) { } + + /** @inheritdoc */ + async getById(id: number): Promise + { + this.logger.log(LogLevel.Trace, `Retrieving work item ${id} from Azure DevOps.`); + const workItemTrackingClient = await this.getWorkItemTrackingClient(); + const workItem = await workItemTrackingClient.getWorkItem(id); + return this.workItemMapper.mapInfrastructureModelToDomainModel(workItem); + } + + /** @inheritdoc */ + async create(item: WorkItem): Promise { + this.logger.log(LogLevel.Trace, `Creating work item ${item.title} in Azure DevOps.`); + const workItemTrackingClient = await this.getWorkItemTrackingClient(); + const patchDocument = this.workItemMapper.mapDomainModelToJsonPatchDocument(item); + const options: DevOpsOptions = await this.configuration.get(); + const response = await workItemTrackingClient.createWorkItem([], patchDocument, options.organization, item.type.name); + return this.workItemMapper.mapInfrastructureModelToDomainModel(response); + } + + /** @inheritdoc */ + async update(item: WorkItem): Promise { + this.logger.log(LogLevel.Trace, `Updating work item ${item.title} in Azure DevOps.`); + const workItemTrackingClient = await this.getWorkItemTrackingClient(); + const patchDocument = this.workItemMapper.mapDomainModelToJsonPatchDocument(item); + const options: DevOpsOptions = await this.configuration.get(); + const workItemNumber: number = item.id || 0; + const response = await workItemTrackingClient.updateWorkItem([], patchDocument, workItemNumber, options.organization); + return this.workItemMapper.mapInfrastructureModelToDomainModel(response); + } + + /** @inheritdoc */ + async delete(id: number): Promise { + this.logger.log(LogLevel.Trace, `Deleting work item with ID ${id} in Azure DevOps.`); + const workItemTrackingClient = await this.getWorkItemTrackingClient(); + const options: DevOpsOptions = await this.configuration.get(); + const _ = await workItemTrackingClient.deleteWorkItem(id, options.organization); + } + + /** @inheritdoc */ + private async getWorkItemTrackingClient(): Promise { + const options: DevOpsOptions = await this.configuration.get(); + const authenticationHandler = devops.getPersonalAccessTokenHandler(options.personalAccessToken); + const connection = new WebApi(options.organization, authenticationHandler); + return await connection.getWorkItemTrackingApi(); + } +} \ No newline at end of file diff --git a/src/extension/core/work-item-update.ts b/src/extension/infrastructure/azure/work-item-update.ts similarity index 98% rename from src/extension/core/work-item-update.ts rename to src/extension/infrastructure/azure/work-item-update.ts index 5acf9fc..a15dce8 100644 --- a/src/extension/core/work-item-update.ts +++ b/src/extension/infrastructure/azure/work-item-update.ts @@ -1,5 +1,5 @@ import { JsonPatchDocument, JsonPatchOperation, Operation } from "azure-devops-node-api/interfaces/common/VSSInterfaces"; -import { WorkItem } from "./workflow"; +import { WorkItem } from "../../domain/workflow"; export class WorkItemUpdate { private operations: JsonPatchOperation[] = []; diff --git a/src/extension/services/work-item.service.ts b/src/extension/infrastructure/azure/work-item.service.ts similarity index 86% rename from src/extension/services/work-item.service.ts rename to src/extension/infrastructure/azure/work-item.service.ts index 9851941..faca068 100644 --- a/src/extension/services/work-item.service.ts +++ b/src/extension/infrastructure/azure/work-item.service.ts @@ -4,12 +4,12 @@ import { window } from "vscode"; import { WebApi } from 'azure-devops-node-api/WebApi'; import { WorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; -import { ICommunicationService } from "../core/communication"; -import { DefaultTasks } from "../core/default-tasks"; -import { ILogger, LogLevel } from "../core/telemetry"; -import { IWorkItemService, WorkItem, WorkItemState, WorkItemType } from "../core/workflow"; -import { PreDefinedTaskJsonPatchDocumentMapper } from "../core/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper"; -import { DevOpsService } from "../services/devops-service"; +import { ICommunicationService } from "../../core/communication"; +import { ILogger, LogLevel } from "../../core/telemetry"; +import { IWorkItemService, Task, WorkItem, WorkItemState, WorkItemType } from "../../domain/workflow"; +import { DefaultTaskService } from "../../domain/workflow/default-task.service"; +import { PreDefinedTaskJsonPatchDocumentMapper } from "../../domain/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper"; +import { DevOpsService } from "./devops-service"; export class WorkItemService implements IWorkItemService { constructor( @@ -48,18 +48,26 @@ export class WorkItemService implements IWorkItemService { const parentWorkItem = await workItemTrackingClient.getWorkItem(workItemId); const workItemTitle = parentWorkItem.fields?.['System.Title'] as string; - return { - id: workItemId, - title: workItemTitle, - type: new WorkItemType(parentWorkItem.fields?.['System.WorkItemType'] as string), - state: new WorkItemState(parentWorkItem.fields?.['System.State'] as string), - areaPath: parentWorkItem.fields?.['System.AreaPath'] as string, - iterationPath: parentWorkItem.fields?.['System.IterationPath'] as string, - description: parentWorkItem.fields?.['System.Description'] as string || "", - remainingWork: parentWorkItem.fields?.['Microsoft.VSTS.Scheduling.RemainingWork'] as number || 0, - activity: parentWorkItem.fields?.['Microsoft.VSTS.Common.Activity'] as string || "", - additionalFields: parentWorkItem.fields || {} - }; + const workItemType = new WorkItemType(parentWorkItem.fields?.['System.WorkItemType'] as string); + const workItemState = new WorkItemState(parentWorkItem.fields?.['System.State'] as string); + const description = parentWorkItem.fields?.['System.Description'] as string || ""; + const remainingWork = parentWorkItem.fields?.['Microsoft.VSTS.Scheduling.RemainingWork'] as number || 0; + const activity = parentWorkItem.fields?.['Microsoft.VSTS.Common.Activity'] as string || ""; + + // Create the appropriate WorkItem instance based on type + let workItem: WorkItem; + if (workItemType.name === 'Task') { + workItem = new Task(workItemTitle, description, remainingWork, activity); + } else { + workItem = new WorkItem(workItemTitle, description, remainingWork, activity, workItemType); + } + + // Set additional properties + workItem.id = workItemId; + workItem.areaPath = parentWorkItem.fields?.['System.AreaPath'] as string; + workItem.iterationPath = parentWorkItem.fields?.['System.IterationPath'] as string; + + return workItem; } catch (error) { window.showErrorMessage(`Failed to retrieve work item #${workItemId}: ${(error as Error).message}`); this.logger.log(LogLevel.Error, `Failed to retrieve work item #${workItemId}: ${(error as Error).message}`); @@ -101,7 +109,7 @@ export class WorkItemService implements IWorkItemService { workItem.id ); - workItem.state = state; + workItem.changeState(state); this.logger.log(LogLevel.Information, `Successfully changed work item #${workItem.id} state to '${state.name}'.`); } catch (error) { const errorMessage = `Failed to change work item #${workItem.id} state to '${state.name}': ${(error as Error).message}`; @@ -139,7 +147,11 @@ export class WorkItemService implements IWorkItemService { } const taskMapper = new PreDefinedTaskJsonPatchDocumentMapper(userDisplayName, organizationUri, workItem.id || -1, workItem.areaPath || "", workItem.iterationPath || ""); - for (const task of DefaultTasks) { + + // Load default tasks from JSON templates + const defaultTasks = DefaultTaskService.getDefaultTasksForWorkItem(workItem.type.name); + + for (const task of defaultTasks) { // if (task.requiredFields && !task.requiredFields.every(field => workItemFields[field] !== undefined && workItemFields[field] !== null && workItemFields[field] !== '')) { // this.logger.log(LogLevel.Warning, `Skipping task '${task.name}' as one or more required fields are missing or empty.`); // continue; @@ -154,10 +166,10 @@ export class WorkItemService implements IWorkItemService { 'Task' ); - this.logger.log(LogLevel.Debug, `Created task '${task.name}' with ID ${createdTask.id} under work item #${workItem.id}.`); + this.logger.log(LogLevel.Debug, `Created task '${task.title}' with ID ${createdTask.id} under work item #${workItem.id}.`); } catch (error) { const errorMessage = (error as Error).message; - this.logger.log(LogLevel.Error, `Failed to create task '${task.name}': ${errorMessage}`); + this.logger.log(LogLevel.Error, `Failed to create task '${task.title}': ${errorMessage}`); } } } diff --git a/src/extension/core/source-control/git.service.ts b/src/extension/infrastructure/git/git.source-control.service.ts similarity index 97% rename from src/extension/core/source-control/git.service.ts rename to src/extension/infrastructure/git/git.source-control.service.ts index 5fc5467..8ca80e4 100644 --- a/src/extension/core/source-control/git.service.ts +++ b/src/extension/infrastructure/git/git.source-control.service.ts @@ -1,5 +1,5 @@ import { exec } from 'child_process'; -import { ISourceControlService } from "./source-control.service"; +import { ISourceControlService } from "../../core/source-control/source-control.service"; /** * Represents a service for managing git operations in a repository. diff --git a/src/extension/core/communication/communication-service.native.ts b/src/extension/infrastructure/vscode/communication-service.native.ts similarity index 83% rename from src/extension/core/communication/communication-service.native.ts rename to src/extension/infrastructure/vscode/communication-service.native.ts index 86749b8..67f8249 100644 --- a/src/extension/core/communication/communication-service.native.ts +++ b/src/extension/infrastructure/vscode/communication-service.native.ts @@ -1,5 +1,5 @@ import { window } from "vscode"; -import { ICommunicationService } from "./communication-service.interface"; +import { ICommunicationService } from "../../core/communication/communication-service.interface"; export class NativeCommunicationService implements ICommunicationService { public async prompt(message: string): Promise { diff --git a/src/extension/core/configuration/configuration-provider.native.ts b/src/extension/infrastructure/vscode/configuration-provider.native.ts similarity index 88% rename from src/extension/core/configuration/configuration-provider.native.ts rename to src/extension/infrastructure/vscode/configuration-provider.native.ts index 019a142..f452918 100644 --- a/src/extension/core/configuration/configuration-provider.native.ts +++ b/src/extension/infrastructure/vscode/configuration-provider.native.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { IConfigurationProvider } from "./configuration-provider.interface"; +import { IConfigurationProvider } from "../../core/configuration/configuration-provider.interface"; /** * Facilitates interactions with the native VS Code configuration system. diff --git a/src/extension/core/configuration/secret-provider.native.ts b/src/extension/infrastructure/vscode/secret-provider.native.ts similarity index 88% rename from src/extension/core/configuration/secret-provider.native.ts rename to src/extension/infrastructure/vscode/secret-provider.native.ts index 0b96bec..63b57d4 100644 --- a/src/extension/core/configuration/secret-provider.native.ts +++ b/src/extension/infrastructure/vscode/secret-provider.native.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { ISecretProvider } from "./secret-provider.interface"; +import { ISecretProvider } from "../../core/configuration/secret-provider.interface"; /** * Facilitates secure storage and retrieval of secrets using the native VS Code secrets API. diff --git a/src/extension/infrastructure/vscode/task-service.native.ts b/src/extension/infrastructure/vscode/task-service.native.ts new file mode 100644 index 0000000..cc8f646 --- /dev/null +++ b/src/extension/infrastructure/vscode/task-service.native.ts @@ -0,0 +1,109 @@ +import { DefaultTaskService } from '../../domain/workflow'; +import { Task } from '../../domain/workflow/task'; +import { TaskTemplateLoader } from '../../domain/workflow/task-template-loader'; +import { TaskTemplate } from '../../domain/workflow/task-template.schema'; +import { ITaskService } from '../../domain/workflow/task.service.interface'; +import { WorkItem } from '../../domain/workflow/work-item'; + +/** + * Service providing synchronous access to default task templates. + * This service assumes templates have already been loaded during extension initialization. + */ +export class NativeTaskService implements ITaskService { + + /** + * Gets the default tasks that should be created for a specific work item. + * @param workItem The work item to get default tasks for. + * @returns An array of task work items that should be added as children. + */ + public getDefaultTasksForWorkItem(workItem: WorkItem): WorkItem[] { + const templates = this.getTaskTemplatesForWorkItem(workItem.type.name); + return templates.map(template => this.createTaskFromTemplate(template)); + } + + /** + * Gets task templates that apply to a specific work item type. + * @param workItemType The work item type to filter by. + * @returns An array of task templates. + */ + private getTaskTemplatesForWorkItem(workItemType: string): TaskTemplate[] { + if (!TaskTemplateLoader.isTemplatesLoaded()) { + console.warn('Task templates not loaded yet. Returning empty array.'); + return []; + } + + return TaskTemplateLoader.getTemplatesForWorkItemType(workItemType); + } + + /** + * Creates a Task work item from a template. + * @param template The task template. + * @returns A new Task work item. + */ + private createTaskFromTemplate(template: TaskTemplate): WorkItem { + return new Task( + template.title, + template.description, + template.remainingWork, + template.activity + ); + } + + // Static methods for backward compatibility and direct access + /** + * Gets default tasks for a specific work item type. + * @param workItemType The work item type (e.g., "Feature", "User Story", "Bug") + * @returns Array of task templates applicable to the work item type + */ + public static getDefaultTasksForWorkItem(workItemType: string): TaskTemplate[] { + if (!TaskTemplateLoader.isTemplatesLoaded()) { + console.warn('Task templates not loaded yet. Returning empty array.'); + return []; + } + + return TaskTemplateLoader.getTemplatesForWorkItemType(workItemType); + } + + /** + * Gets all default tasks regardless of work item type. + * @returns Array of all task templates + */ + public static getAllDefaultTasks(): TaskTemplate[] { + if (!TaskTemplateLoader.isTemplatesLoaded()) { + console.warn('Task templates not loaded yet. Returning empty array.'); + return []; + } + + return TaskTemplateLoader.getAllTemplates(); + } + + /** + * Gets default tasks by category. + * @param category The category name (e.g., "development", "planning") + * @returns Array of task templates in the specified category + */ + public static getDefaultTasksByCategory(category: string): TaskTemplate[] { + if (!TaskTemplateLoader.isTemplatesLoaded()) { + console.warn('Task templates not loaded yet. Returning empty array.'); + return []; + } + + return TaskTemplateLoader.getTemplatesByCategory(category); + } + + /** + * Checks if default tasks are available. + * @returns True if default tasks have been loaded, false otherwise + */ + public static isAvailable(): boolean { + return TaskTemplateLoader.isTemplatesLoaded(); + } +} + +/** + * Legacy function for backward compatibility. + * @deprecated Use DefaultTaskService.getAllDefaultTasks() instead + */ +export function getDefaultTasks(): TaskTemplate[] { + return DefaultTaskService.getAllDefaultTasks(); +} diff --git a/src/extension/presentation/commands/tasks-tree-commands.ts b/src/extension/presentation/commands/tasks-tree-commands.ts new file mode 100644 index 0000000..e429fda --- /dev/null +++ b/src/extension/presentation/commands/tasks-tree-commands.ts @@ -0,0 +1,8 @@ +// Re-export all command classes from their individual files +export { AddTaskCommand } from './workflow/add-task.command'; +export { RefreshTasksCommand } from './workflow/refresh-tasks.command'; +export { SetTaskStateToActiveCommand } from './workflow/set-task-state-to-active.command'; +export { SetTaskStateToClosedCommand } from './workflow/set-task-state-to-closed.command'; +export { SetTaskStateToNewCommand } from './workflow/set-task-state-to-new.command'; +export { SetTaskStateToResolvedCommand } from './workflow/set-task-state-to-resolved.command'; + diff --git a/src/extension/presentation/commands/time/clear.command.ts b/src/extension/presentation/commands/time/clear.command.ts new file mode 100644 index 0000000..4062034 --- /dev/null +++ b/src/extension/presentation/commands/time/clear.command.ts @@ -0,0 +1,20 @@ +import { TimeTreeDataProvider } from "../../../application/providers/time-tree-data-provider"; +import { TimeEntryService } from "../../../application/time/time-entry-service"; +import { Command } from "../../../core"; + +/** + * Command to clear all time entries (for testing/debugging) + */ +export class ClearTimeEntriesCommand extends Command { + constructor( + private timeEntryService: TimeEntryService, + private treeProvider: TimeTreeDataProvider + ) { + super('time.clear'); + } + + async execute(): Promise { + await this.timeEntryService.clearAllEntries(); + this.treeProvider.refresh(); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/time/clock-in.command.ts b/src/extension/presentation/commands/time/clock-in.command.ts new file mode 100644 index 0000000..91c49d0 --- /dev/null +++ b/src/extension/presentation/commands/time/clock-in.command.ts @@ -0,0 +1,20 @@ +import { TimeTreeDataProvider } from "../../../application/providers/time-tree-data-provider"; +import { TimeEntryService } from "../../../application/time/time-entry-service"; +import { Command } from "../../../core"; + +/** + * Command to clock in + */ +export class ClockInCommand extends Command { + constructor( + private timeEntryService: TimeEntryService, + private treeProvider: TimeTreeDataProvider + ) { + super('time.clockIn'); + } + + async execute(): Promise { + await this.timeEntryService.clockIn(); + this.treeProvider.refresh(); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/time/clock-out.command.ts b/src/extension/presentation/commands/time/clock-out.command.ts new file mode 100644 index 0000000..286e18e --- /dev/null +++ b/src/extension/presentation/commands/time/clock-out.command.ts @@ -0,0 +1,20 @@ +import { TimeTreeDataProvider } from "../../../application/providers/time-tree-data-provider"; +import { TimeEntryService } from "../../../application/time/time-entry-service"; +import { Command } from "../../../core"; + +/** + * Command to clock out + */ +export class ClockOutCommand extends Command { + constructor( + private timeEntryService: TimeEntryService, + private treeProvider: TimeTreeDataProvider + ) { + super('time.clockOut'); + } + + async execute(): Promise { + await this.timeEntryService.clockOut(); + this.treeProvider.refresh(); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/time/load-history.command.ts b/src/extension/presentation/commands/time/load-history.command.ts new file mode 100644 index 0000000..f55d523 --- /dev/null +++ b/src/extension/presentation/commands/time/load-history.command.ts @@ -0,0 +1,15 @@ +import { TimeTreeDataProvider } from "../../../application/providers/time-tree-data-provider"; +import { Command } from "../../../core"; + +/** + * Command to load more days in the time tree view + */ +export class LoadMoreDaysCommand extends Command { + constructor(private treeProvider: TimeTreeDataProvider) { + super('time.loadHistory'); + } + + async execute(): Promise { + await this.treeProvider.loadMoreDays(); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/time/purge.command.ts b/src/extension/presentation/commands/time/purge.command.ts new file mode 100644 index 0000000..ba9cd03 --- /dev/null +++ b/src/extension/presentation/commands/time/purge.command.ts @@ -0,0 +1,20 @@ +import { TimeTreeDataProvider } from "../../../application/providers/time-tree-data-provider"; +import { TimeEntryService } from "../../../application/time/time-entry-service"; +import { Command } from "../../../core"; + +/** + * Command to clean up old time entries based on retention policy + */ +export class CleanupOldEntriesCommand extends Command { + constructor( + private timeEntryService: TimeEntryService, + private treeProvider: TimeTreeDataProvider + ) { + super('time.purge'); + } + + async execute(): Promise { + await this.timeEntryService.cleanupOldEntries(true); + this.treeProvider.refresh(); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/time/refresh.command.ts b/src/extension/presentation/commands/time/refresh.command.ts new file mode 100644 index 0000000..84081b7 --- /dev/null +++ b/src/extension/presentation/commands/time/refresh.command.ts @@ -0,0 +1,15 @@ +import { TimeTreeDataProvider } from "../../../application/providers/time-tree-data-provider"; +import { Command } from "../../../core"; + +/** + * Command to refresh the time tree view + */ +export class RefreshTimeCommand extends Command { + constructor(private treeProvider: TimeTreeDataProvider) { + super('time.refresh'); + } + + async execute(): Promise { + this.treeProvider.refresh(); + } +} \ No newline at end of file diff --git a/src/extension/commands/add-task.command.ts b/src/extension/presentation/commands/workflow/add-task.command.ts similarity index 86% rename from src/extension/commands/add-task.command.ts rename to src/extension/presentation/commands/workflow/add-task.command.ts index 6f3036b..370656b 100644 --- a/src/extension/commands/add-task.command.ts +++ b/src/extension/presentation/commands/workflow/add-task.command.ts @@ -1,20 +1,20 @@ import * as vscode from 'vscode'; -import { Command } from '../core/command'; -import { IConfigurationProvider, ISecretProvider } from "../core/configuration"; -import { IWorkItemService } from '../core/workflow'; -import { TasksTreeDataProvider } from '../providers/tasks-tree-data-provider'; +import { TasksTreeDataProvider } from '../../../application/providers/tasks-tree-data-provider'; +import { Command } from '../../../core/command'; +import { IConfigurationProvider } from "../../../core/configuration"; +import { IWorkItemService } from '../../../domain/workflow'; /** * Command to add a new task to the current work item. + * REFACTORED: Now uses IConfigurationProvider for simplified dependencies! */ export class AddTaskCommand extends Command { constructor( - private readonly secretProvider: ISecretProvider, private readonly configurationProvider: IConfigurationProvider, private tasksTreeProvider: TasksTreeDataProvider, private workItemService: IWorkItemService ) { - super('addTask'); + super('workflow.addTaskToWorkItem'); } async execute(...args: any[]): Promise { diff --git a/src/extension/presentation/commands/workflow/create-default-tasks.command.ts b/src/extension/presentation/commands/workflow/create-default-tasks.command.ts new file mode 100644 index 0000000..3128df7 --- /dev/null +++ b/src/extension/presentation/commands/workflow/create-default-tasks.command.ts @@ -0,0 +1,157 @@ +import * as devops from "azure-devops-node-api"; +import * as vscode from 'vscode'; + +import { WebApi } from 'azure-devops-node-api/WebApi'; +import { WorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; +import { Command } from '../../../core/command'; +import { ConfigurationError, IConfigurationProvider } from "../../../core/configuration"; +import { ILogger, LogLevel } from "../../../core/telemetry"; +import { IWorkItemService } from "../../../domain/workflow"; +import { JsonTemplateLoader } from "../../../domain/workflow/pre-defined-tasks/json-template-loader"; +import { PreDefinedTaskJsonPatchDocumentMapper } from '../../../domain/workflow/pre-defined-tasks/pre-defined-task-json-patch-document-mapper'; + +/** + * Represents a {@link Command} that creates pre-defined tasks representing the typical workflow of a work item. + * + * REFACTORED: Now uses IConfigurationProvider for automatic validation and error handling! + */ +export class CreateDefaultTasksCommand extends Command { + + /** + * Creates a new {@link CreateDefaultTasksCommand} instance. + */ + constructor( + private readonly configurationProvider: IConfigurationProvider, + private readonly logger: ILogger, + private readonly workItemService: IWorkItemService, + private readonly templateLoader: JsonTemplateLoader + ) { + super('workflow.createDefaultTasks'); + } + + /** @inheritdoc */ + public async execute(...args: any[]): Promise { + try { + // ๐ŸŽฏ SIMPLIFIED: Use vscode.workspace.getConfiguration directly for now + const config = vscode.workspace.getConfiguration('tacosontitan.toolbox'); + + // Check if basic required config is available + const pat = config.get('personalAccessToken'); + const org = config.get('organization'); + const project = config.get('project'); + + if (!pat || !org || !project) { + vscode.window.showErrorMessage('Azure DevOps configuration is incomplete. Please check your settings.'); + return; + } + + // If we reach this point, all required config is valid and available! โœ… + await this.createDefaultTasksForWorkItem({ + personalAccessToken: pat, + organization: org, + project: project, + userDisplayName: config.get('userDisplayName') || 'Unknown User' + }, args); + + } catch (error) { + // Configuration errors are already handled with user-friendly messages + // Only handle unexpected business logic errors here + if (!(error instanceof ConfigurationError)) { + this.logger.log(LogLevel.Error, `Failed to create default tasks: ${error}`); + vscode.window.showErrorMessage(`Failed to create default tasks: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + /** + * Creates default tasks for a work item with validated configuration. + */ + private async createDefaultTasksForWorkItem(config: { personalAccessToken: string, organization: string, project: string, userDisplayName: string }, args: any[]): Promise { + // Get work item number from args or prompt user + let workItemNumber: number; + if (args && args.length > 0 && args[0] && args[0].id) { + workItemNumber = args[0].id; + } else { + const workItemNumberResponse = await vscode.window.showInputBox({ + prompt: 'Enter the work item number for which you want to create default tasks.', + validateInput: (value) => { + const num = parseInt(value); + return isNaN(num) || num <= 0 ? 'Please enter a valid work item number' : null; + } + }); + + if (!workItemNumberResponse) { + this.logger.log(LogLevel.Warning, 'Work item number not provided. Task creation cancelled.'); + return; + } + + workItemNumber = parseInt(workItemNumberResponse); + } + + // Create Azure DevOps connection with validated config + const authenticationHandler = devops.getPersonalAccessTokenHandler(config.personalAccessToken); + const connection = new WebApi(config.organization, authenticationHandler); + const workItemTrackingClient: WorkItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Get and validate work item exists + const workItem = await this.workItemService.getWorkItem(workItemNumber); + if (!workItem) { + vscode.window.showErrorMessage(`Work item #${workItemNumber} not found.`); + return; + } + + // Create default tasks with progress indicator + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Creating default tasks for work item #${workItemNumber} in project '${config.project}'.`, + cancellable: false + }, + async (progress) => { + this.logger.log(LogLevel.Debug, `Creating default tasks for work item #${workItemNumber} in project '${config.project}'.`); + const taskMapper = new PreDefinedTaskJsonPatchDocumentMapper( + config.userDisplayName, + config.organization, + workItemNumber, + workItem.areaPath || "", + workItem.iterationPath || "" + ); + let completed = 0; + + // Load templates and convert to PreDefinedTask objects + await this.templateLoader.loadAllTemplates(); + const defaultTasks = this.templateLoader.createPreDefinedTasks(); + + for (const task of defaultTasks) { + progress.report({ message: `Creating '${task.title}' (${++completed}/${defaultTasks.length})` }); + try { + const patchDocument = taskMapper.map(task); + const createdTask = await workItemTrackingClient.createWorkItem( + [], + patchDocument, + config.project, + 'Task' + ); + + this.logger.log(LogLevel.Debug, `Created task '${task.title}' with ID ${createdTask.id} under work item #${workItemNumber}.`); + } catch (error) { + const errorMessage = (error as Error).message; + this.logger.log(LogLevel.Error, `Failed to create task '${task.title}': ${errorMessage}`); + } + } + + this.logger.log(LogLevel.Information, `Default tasks created for work item #${workItemNumber}.`); + } + ); + + // Show success message + const action = await vscode.window.showInformationMessage( + `Default tasks created for work item #${workItemNumber}.`, + 'Show Output' + ); + + if (action === 'Show Output' && typeof this.logger.open === 'function') { + this.logger.open(); + } + } +} \ No newline at end of file diff --git a/src/extension/commands/refresh-tasks.command.ts b/src/extension/presentation/commands/workflow/refresh-tasks.command.ts similarity index 59% rename from src/extension/commands/refresh-tasks.command.ts rename to src/extension/presentation/commands/workflow/refresh-tasks.command.ts index 8748122..64c76fe 100644 --- a/src/extension/commands/refresh-tasks.command.ts +++ b/src/extension/presentation/commands/workflow/refresh-tasks.command.ts @@ -1,18 +1,18 @@ import * as vscode from 'vscode'; -import { Command } from '../core/command'; -import { IConfigurationProvider, ISecretProvider } from "../core/configuration"; -import { TasksTreeDataProvider } from '../providers/tasks-tree-data-provider'; +import { TasksTreeDataProvider } from '../../../application/providers/tasks-tree-data-provider'; +import { Command } from '../../../core/command'; +import { IConfigurationProvider } from "../../../core/configuration"; /** * Command to refresh the tasks tree view. + * REFACTORED: Simplified dependencies by using IConfigurationProvider! */ export class RefreshTasksCommand extends Command { constructor( - private readonly secretProvider: ISecretProvider, private readonly configurationProvider: IConfigurationProvider, private tasksTreeProvider: TasksTreeDataProvider ) { - super('refreshTasks'); + super('workflow.refreshTasks'); } async execute(...args: any[]): Promise { diff --git a/src/extension/presentation/commands/workflow/set-task-state-to-active.command.ts b/src/extension/presentation/commands/workflow/set-task-state-to-active.command.ts new file mode 100644 index 0000000..9eccd49 --- /dev/null +++ b/src/extension/presentation/commands/workflow/set-task-state-to-active.command.ts @@ -0,0 +1,17 @@ +import { TasksTreeDataProvider } from "../../../application/providers/tasks-tree-data-provider"; +import { IConfigurationProvider } from "../../../core"; +import { IWorkItemService } from "../../../domain/workflow"; +import { SetTaskStateCommand } from "./set-task-state.command"; + +/** + * Command to set task state to Active. + */ +export class SetTaskStateToActiveCommand extends SetTaskStateCommand { + constructor( + configurationProvider: IConfigurationProvider, + tasksTreeProvider: TasksTreeDataProvider, + workItemService: IWorkItemService + ) { + super('Active', configurationProvider, tasksTreeProvider, workItemService, 'Active'); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/workflow/set-task-state-to-closed.command.ts b/src/extension/presentation/commands/workflow/set-task-state-to-closed.command.ts new file mode 100644 index 0000000..9530854 --- /dev/null +++ b/src/extension/presentation/commands/workflow/set-task-state-to-closed.command.ts @@ -0,0 +1,17 @@ +import { TasksTreeDataProvider } from "../../../application/providers/tasks-tree-data-provider"; +import { IConfigurationProvider } from "../../../core"; +import { IWorkItemService } from "../../../domain/workflow"; +import { SetTaskStateCommand } from "./set-task-state.command"; + +/** + * Command to set task state to Closed. + */ +export class SetTaskStateToClosedCommand extends SetTaskStateCommand { + constructor( + configurationProvider: IConfigurationProvider, + tasksTreeProvider: TasksTreeDataProvider, + workItemService: IWorkItemService + ) { + super('Closed', configurationProvider, tasksTreeProvider, workItemService, 'Closed'); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/workflow/set-task-state-to-new.command.ts b/src/extension/presentation/commands/workflow/set-task-state-to-new.command.ts new file mode 100644 index 0000000..4db3539 --- /dev/null +++ b/src/extension/presentation/commands/workflow/set-task-state-to-new.command.ts @@ -0,0 +1,17 @@ +import { TasksTreeDataProvider } from "../../../application/providers/tasks-tree-data-provider"; +import { IConfigurationProvider } from "../../../core"; +import { IWorkItemService } from "../../../domain/workflow"; +import { SetTaskStateCommand } from "./set-task-state.command"; + +/** + * Command to set task state to New. + */ +export class SetTaskStateToNewCommand extends SetTaskStateCommand { + constructor( + configurationProvider: IConfigurationProvider, + tasksTreeProvider: TasksTreeDataProvider, + workItemService: IWorkItemService + ) { + super('New', configurationProvider, tasksTreeProvider, workItemService, 'New'); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/workflow/set-task-state-to-resolved.command.ts b/src/extension/presentation/commands/workflow/set-task-state-to-resolved.command.ts new file mode 100644 index 0000000..438ff78 --- /dev/null +++ b/src/extension/presentation/commands/workflow/set-task-state-to-resolved.command.ts @@ -0,0 +1,17 @@ +import { TasksTreeDataProvider } from "../../../application/providers/tasks-tree-data-provider"; +import { IConfigurationProvider } from "../../../core"; +import { IWorkItemService } from "../../../domain/workflow"; +import { SetTaskStateCommand } from "./set-task-state.command"; + +/** + * Command to set task state to Resolved. + */ +export class SetTaskStateToResolvedCommand extends SetTaskStateCommand { + constructor( + configurationProvider: IConfigurationProvider, + tasksTreeProvider: TasksTreeDataProvider, + workItemService: IWorkItemService + ) { + super('Resolved', configurationProvider, tasksTreeProvider, workItemService, 'Resolved'); + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/workflow/set-task-state.command.ts b/src/extension/presentation/commands/workflow/set-task-state.command.ts new file mode 100644 index 0000000..019f9ba --- /dev/null +++ b/src/extension/presentation/commands/workflow/set-task-state.command.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import { TasksTreeDataProvider } from '../../../application/providers/tasks-tree-data-provider'; +import { Command } from '../../../core/command'; +import { IConfigurationProvider } from "../../../core/configuration"; +import { IWorkItemService } from '../../../domain/workflow'; +import { TaskTreeItem } from '../../../presentation/workflow/task-tree-item'; + +/** + * Base command to set task state to a specific value. + */ +export abstract class SetTaskStateCommand extends Command { + constructor( + commandSuffix: string, + protected readonly configurationProvider: IConfigurationProvider, + protected tasksTreeProvider: TasksTreeDataProvider, + protected workItemService: IWorkItemService, + protected targetState: string + ) { + super(`workflow.setTaskStateTo${commandSuffix}`); + } + + async execute(taskItem?: TaskTreeItem): Promise { + if (!taskItem) { + vscode.window.showErrorMessage('No task selected'); + return; + } + + try { + await this.workItemService.updateWorkItemState(taskItem.task.id!, this.targetState); + vscode.window.showInformationMessage(`Task state changed to ${this.targetState}`); + this.tasksTreeProvider.refresh(); + } catch (error) { + vscode.window.showErrorMessage(`Failed to update task state: ${(error as Error).message}`); + } + } +} \ No newline at end of file diff --git a/src/extension/presentation/commands/workflow/start-work-item.command.ts b/src/extension/presentation/commands/workflow/start-work-item.command.ts new file mode 100644 index 0000000..8fd7107 --- /dev/null +++ b/src/extension/presentation/commands/workflow/start-work-item.command.ts @@ -0,0 +1,39 @@ +import { Command } from "../../../core/command"; +import { ICommunicationService } from "../../../core/communication"; +import { ILogger, LogLevel } from "../../../core/telemetry"; +import { IWorkflowService } from "../../../domain/workflow/workflow.service.interface"; + +/** + * Starts a work item on behalf of the user. + */ +export class StartWorkItemCommand extends Command { + + /** + * @constructor + * @param logger The service used to capture errors and diagnostic messaging. + * @param communicationService The service used to communicate with the user directly. + * @param workflowService The service used to manage workflow operations. + */ + constructor( + private readonly logger: ILogger, + private readonly communicationService: ICommunicationService, + private readonly workflowService: IWorkflowService + ) { + super('workflow.startWorkItem'); + } + + /** @inheritdoc */ + public async execute(): Promise { + try { + const workItemNumber = await this.communicationService.prompt(`๐Ÿ” What is the number of the work item you'd like to start?`); + if (!workItemNumber) { + this.logger.log(LogLevel.Warning, "No work item number provided."); + return; + } + + await this.workflowService.start(workItemNumber); + } catch (error) { + this.logger.logError("Failed to start work item.", error); + } + } +} \ No newline at end of file diff --git a/src/extension/presentation/placeholder-tree-item.ts b/src/extension/presentation/placeholder-tree-item.ts new file mode 100644 index 0000000..fb02530 --- /dev/null +++ b/src/extension/presentation/placeholder-tree-item.ts @@ -0,0 +1,22 @@ +import * as vscode from 'vscode'; + +/** + * Tree item for placeholder content (loading, no data, etc.) + */ +export class PlaceholderTreeItem extends vscode.TreeItem { + constructor( + public readonly message: string, + public readonly detail?: string, + public readonly iconName: string = 'info' + ) { + super(message, vscode.TreeItemCollapsibleState.None); + + this.tooltip = detail || message; + this.contextValue = 'placeholder'; + this.iconPath = new vscode.ThemeIcon(iconName); + + if (detail) { + this.description = detail; + } + } +} \ No newline at end of file diff --git a/src/extension/presentation/providers/overview-webview-provider.ts b/src/extension/presentation/providers/overview-webview-provider.ts new file mode 100644 index 0000000..2553eb3 --- /dev/null +++ b/src/extension/presentation/providers/overview-webview-provider.ts @@ -0,0 +1,610 @@ +import * as devops from "azure-devops-node-api"; +import { WebApi } from 'azure-devops-node-api/WebApi'; +import { WorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; +import * as vscode from 'vscode'; +import { DevOpsService } from '../../infrastructure/azure/devops-service'; + +export class OverviewWebviewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'overviewWebView'; + private _view?: vscode.WebviewView; + private cycleTimeData: { average: string; count: number } | null = null; + private cycleTimeLoaded = false; + private recentCompletionsData: { count: number; days: number } | null = null; + private recentCompletionsLoaded = false; + private oldestWorkItemData: { daysAgo: number; days: number } | null = null; + private oldestWorkItemLoaded = false; + static MILLISECONDS_PER_DAY: number = 1000 * 60 * 60 * 24; + + constructor( + private readonly _extensionUri: vscode.Uri, + private devOpsService?: DevOpsService + ) { + // Load cycle time data asynchronously + if (this.devOpsService) { + this.loadCycleTimeData(); + this.loadRecentCompletionsData(); + this.loadOldestWorkItemData(); + } + } + + public resolveWebviewView( + webviewView: vscode.WebviewView, + context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this._extensionUri] + }; + + webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); + + webviewView.webview.onDidReceiveMessage((data: any) => { + switch (data.type) { + case 'refresh': + this.refresh(); + break; + } + }); + } + + public refresh() { + if (this.devOpsService) { + this.loadCycleTimeData(); + this.loadRecentCompletionsData(); + this.loadOldestWorkItemData(); + } + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + + private _getHtmlForWebview(webview: vscode.Webview) { + const greeting = this.getGreeting(); + const cycleTimeTile = this.getCycleTimeTileHtml(); + const recentCompletionsTile = this.getRecentCompletionsTileHtml(); + const oldestWorkItemTile = this.getOldestWorkItemTileHtml(); + + return ` + + + + + Overview + + + +
${greeting}
+ +
+ ${cycleTimeTile} + ${recentCompletionsTile} + ${oldestWorkItemTile} +
+ + + + + + `; + } + + private getGreeting(): string { + const now = new Date(); + const greeting = "hey there! today is"; + const dateInfo = this.getDateInfo(now); + const wordOfTheDay = this.getWordOfTheDay(); + + return `${greeting} ${dateInfo}, and the word of the day is ${wordOfTheDay}`; + } + + private getCycleTimeTileHtml(): string { + if (!this.devOpsService) { + return ''; + } + + if (!this.cycleTimeLoaded) { + return ` +
+
Average Cycle Time
+
Loading...
+
+ `; + } + + if (!this.cycleTimeData) { + return ` +
+
Average Cycle Time
+
No data available
+
+ `; + } + + return ` +
+
Average Cycle Time
+
${this.cycleTimeData.average} days
+
Based on ${this.cycleTimeData.count} completed bugs and user stories
+
+ `; + } + + private getRecentCompletionsTileHtml(): string { + if (!this.devOpsService) { + return ''; + } + + if (!this.recentCompletionsLoaded) { + return ` +
+
Recent Completions
+
Loading...
+
+ `; + } + + if (!this.recentCompletionsData) { + return ` +
+
Recent Completions
+
No data available
+
+ `; + } + + return ` +
+
Recent Completions
+
${this.recentCompletionsData.count} items
+
Bugs and user stories completed in the last ${this.recentCompletionsData.days} days
+
+ `; + } + + private getOldestWorkItemTileHtml(): string { + if (!this.devOpsService) { + return ''; + } + + if (!this.oldestWorkItemLoaded) { + return ` +
+
Oldest Completed Item
+
Loading...
+
+ `; + } + + if (!this.oldestWorkItemData) { + return ` +
+
Oldest Completed Item
+
No data available
+
+ `; + } + + return ` +
+
Oldest Completed Item
+
${this.oldestWorkItemData.daysAgo} days ago
+
Oldest item closed ${this.oldestWorkItemData.days}+ days ago
+
+ `; + } + + private getDateInfo(date: Date): string { + const monthNames = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ]; + + const month = monthNames[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + const dayOfYear = this.getDayOfYear(date); + + const dayWithSuffix = this.getDayWithSuffix(day); + + return `${month} ${dayWithSuffix}, the ${dayOfYear} day of ${year}`; + } + + private getDayWithSuffix(day: number): string { + if (day >= 11 && day <= 13) { + return `${day}th`; + } + switch (day % 10) { + case 1: return `${day}st`; + case 2: return `${day}nd`; + case 3: return `${day}rd`; + default: return `${day}th`; + } + } + + private getDayOfYear(date: Date): string { + const start = new Date(date.getFullYear(), 0, 0); + const diff = (date.getTime() - start.getTime()) + ((start.getTimezoneOffset() - date.getTimezoneOffset()) * 60 * 1000); + const oneDay = 1000 * 60 * 60 * 24; + const dayOfYear = Math.floor(diff / oneDay); + + return this.getNumberWithSuffix(dayOfYear); + } + + private getNumberWithSuffix(num: number): string { + if (num >= 11 && num <= 13) { + return `${num}th`; + } + switch (num % 10) { + case 1: return `${num}st`; + case 2: return `${num}nd`; + case 3: return `${num}rd`; + default: return `${num}th`; + } + } + + private getWordOfTheDay(): string { + // Simple word of the day based on date for consistency + const words = [ + "volcano", "serenity", "adventure", "harmony", "discovery", "creativity", "wonder", + "journey", "inspiration", "tranquility", "exploration", "imagination", "courage", + "wisdom", "beauty", "mystery", "freedom", "passion", "growth", "balance" + ]; + + const today = new Date(); + const dayOfYear = Math.floor((today.getTime() - new Date(today.getFullYear(), 0, 0).getTime()) / OverviewWebviewProvider.MILLISECONDS_PER_DAY); + return words[dayOfYear % words.length]; + } + + private async getRecentCompletionsDays(): Promise { + const config = vscode.workspace.getConfiguration('tacosontitan.toolbox'); + return config.get('overview.recentCompletionsDays', 30); + } + + private async getCycleTimeInfo(): Promise<{ average: string; count: number } | null> { + if (!this.devOpsService) { + return null; + } + + try { + const personalAccessToken = await this.devOpsService.getPersonalAccessToken(); + const organizationUri = await this.devOpsService.getOrganizationUri(); + const projectName = await this.devOpsService.getProjectName(); + const userDisplayName = await this.devOpsService.getUserDisplayName(); + + if (!personalAccessToken || !organizationUri || !projectName || !userDisplayName) { + return null; + } + + const authenticationHandler = devops.getPersonalAccessTokenHandler(personalAccessToken); + const connection = new WebApi(organizationUri, authenticationHandler); + const workItemTrackingClient: WorkItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Query for completed bugs and user stories assigned to the user + const wiql = { + query: `SELECT [System.Id], [System.WorkItemType], [System.ActivatedDate], [Microsoft.VSTS.Common.ClosedDate] + FROM WorkItems + WHERE [System.AssignedTo] = '${userDisplayName}' + AND [System.WorkItemType] IN ('Bug', 'User Story') + AND [System.State] IN ('Closed', 'Done', 'Resolved') + AND [System.ActivatedDate] <> '' + AND [Microsoft.VSTS.Common.ClosedDate] <> '' + ORDER BY [System.Id]` + }; + + const queryResult = await workItemTrackingClient.queryByWiql(wiql, { project: projectName }); + + if (!queryResult.workItems || queryResult.workItems.length === 0) { + return null; + } + + const workItemIds = queryResult.workItems.map((wi: any) => wi.id!); + const workItems = await workItemTrackingClient.getWorkItems( + workItemIds, + ['System.Id', 'System.WorkItemType', 'System.ActivatedDate', 'Microsoft.VSTS.Common.ClosedDate'], + undefined, + undefined, + undefined, + projectName + ); + + const cycleTimes: number[] = []; + + for (const workItem of workItems) { + const activatedDate = workItem.fields?.['System.ActivatedDate']; + const closedDate = workItem.fields?.['Microsoft.VSTS.Common.ClosedDate']; + + if (activatedDate && closedDate) { + const activated = new Date(activatedDate); + const closed = new Date(closedDate); + const cycleTimeMs = closed.getTime() - activated.getTime(); + const cycleTimeDays = cycleTimeMs / (1000 * 60 * 60 * 24); + + if (cycleTimeDays > 0) { + cycleTimes.push(cycleTimeDays); + } + } + } + + if (cycleTimes.length === 0) { + return null; + } + + const averageCycleTime = cycleTimes.reduce((sum, time) => sum + time, 0) / cycleTimes.length; + const formattedAverage = averageCycleTime.toFixed(1); + + return { + average: formattedAverage, + count: cycleTimes.length + }; + + } catch (error) { + // Return null to silently ignore errors and not disrupt the overview + return null; + } + } + + private async loadCycleTimeData(): Promise { + this.cycleTimeLoaded = false; + try { + this.cycleTimeData = await this.getCycleTimeInfo(); + this.cycleTimeLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } catch (error) { + // Silently ignore errors + this.cycleTimeLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + } + + private async loadRecentCompletionsData(): Promise { + this.recentCompletionsLoaded = false; + try { + this.recentCompletionsData = await this.getRecentCompletionsInfo(); + this.recentCompletionsLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } catch (error) { + // Silently ignore errors + this.recentCompletionsLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + } + + private async loadOldestWorkItemData(): Promise { + this.oldestWorkItemLoaded = false; + try { + this.oldestWorkItemData = await this.getOldestWorkItemInfo(); + this.oldestWorkItemLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } catch (error) { + // Silently ignore errors + this.oldestWorkItemLoaded = true; + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + } + + private async getRecentCompletionsInfo(): Promise<{ count: number; days: number } | null> { + if (!this.devOpsService) { + return null; + } + + try { + const personalAccessToken = await this.devOpsService.getPersonalAccessToken(); + const organizationUri = await this.devOpsService.getOrganizationUri(); + const projectName = await this.devOpsService.getProjectName(); + const userDisplayName = await this.devOpsService.getUserDisplayName(); + const days = await this.getRecentCompletionsDays(); + + if (!personalAccessToken || !organizationUri || !projectName || !userDisplayName) { + return null; + } + + const authenticationHandler = devops.getPersonalAccessTokenHandler(personalAccessToken); + const connection = new WebApi(organizationUri, authenticationHandler); + const workItemTrackingClient: WorkItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Calculate the date N days ago + const nDaysAgo = new Date(); + nDaysAgo.setDate(nDaysAgo.getDate() - days); + const nDaysAgoISOString = nDaysAgo.toISOString(); + + // Query for completed bugs and user stories assigned to the user in the last N days + const wiql = { + query: `SELECT [System.Id], [System.WorkItemType], [Microsoft.VSTS.Common.ClosedDate] + FROM WorkItems + WHERE [System.AssignedTo] = '${userDisplayName}' + AND [System.WorkItemType] IN ('Bug', 'User Story') + AND [System.State] IN ('Closed', 'Done', 'Resolved') + AND [Microsoft.VSTS.Common.ClosedDate] >= '${nDaysAgoISOString}' + ORDER BY [Microsoft.VSTS.Common.ClosedDate] DESC` + }; + + const queryResult = await workItemTrackingClient.queryByWiql(wiql, { project: projectName }); + + const count = queryResult.workItems ? queryResult.workItems.length : 0; + + return { + count: count, + days: days + }; + + } catch (error) { + // Return null to silently ignore errors and not disrupt the overview + return null; + } + } + + private async getOldestWorkItemInfo(): Promise<{ daysAgo: number; days: number } | null> { + if (!this.devOpsService) { + return null; + } + + try { + const personalAccessToken = await this.devOpsService.getPersonalAccessToken(); + const organizationUri = await this.devOpsService.getOrganizationUri(); + const projectName = await this.devOpsService.getProjectName(); + const userDisplayName = await this.devOpsService.getUserDisplayName(); + const days = await this.getRecentCompletionsDays(); + + if (!personalAccessToken || !organizationUri || !projectName || !userDisplayName) { + return null; + } + + const authenticationHandler = devops.getPersonalAccessTokenHandler(personalAccessToken); + const connection = new WebApi(organizationUri, authenticationHandler); + const workItemTrackingClient: WorkItemTrackingApi = await connection.getWorkItemTrackingApi(); + + // Calculate the date N days ago + const nDaysAgo = new Date(); + nDaysAgo.setDate(nDaysAgo.getDate() - days); + const nDaysAgoISOString = nDaysAgo.toISOString(); + + // Query for completed bugs and user stories assigned to the user that were closed N or more days ago + const wiql = { + query: `SELECT [System.Id], [System.WorkItemType], [Microsoft.VSTS.Common.ClosedDate] + FROM WorkItems + WHERE [System.AssignedTo] = '${userDisplayName}' + AND [System.WorkItemType] IN ('Bug', 'User Story') + AND [System.State] IN ('Closed', 'Done', 'Resolved') + AND [Microsoft.VSTS.Common.ClosedDate] <= '${nDaysAgoISOString}' + ORDER BY [Microsoft.VSTS.Common.ClosedDate] ASC` + }; + + const queryResult = await workItemTrackingClient.queryByWiql(wiql, { project: projectName }); + + if (!queryResult.workItems || queryResult.workItems.length === 0) { + return null; + } + + // Get the oldest work item (first in the ascending order) + const workItemIds = [queryResult.workItems[0].id!]; + const workItems = await workItemTrackingClient.getWorkItems( + workItemIds, + ['System.Id', 'System.WorkItemType', 'Microsoft.VSTS.Common.ClosedDate'], + undefined, + undefined, + undefined, + projectName + ); + + if (workItems.length === 0) { + return null; + } + + const closedDate = workItems[0].fields?.['Microsoft.VSTS.Common.ClosedDate']; + if (!closedDate) { + return null; + } + + const closed = new Date(closedDate); + const now = new Date(); + const daysAgo = Math.floor((now.getTime() - closed.getTime()) / (1000 * 60 * 60 * 24)); + + return { + daysAgo: daysAgo, + days: days + }; + + } catch (error) { + // Return null to silently ignore errors and not disrupt the overview + return null; + } + } +} \ No newline at end of file diff --git a/src/extension/presentation/providers/time-tree-data-provider.ts b/src/extension/presentation/providers/time-tree-data-provider.ts new file mode 100644 index 0000000..be8f2a3 --- /dev/null +++ b/src/extension/presentation/providers/time-tree-data-provider.ts @@ -0,0 +1,75 @@ +import * as vscode from 'vscode'; +import { TimeEntryService } from '../../application/time/time-entry-service'; +import { PlaceholderTreeItem } from '../placeholder-tree-item'; +import { ClockEventTreeItem } from '../time/clock-event-tree-item'; +import { DayTreeItem } from '../time/day-tree-item'; + +export class TimeTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + constructor(private timeEntryService: TimeEntryService) {} + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: DayTreeItem | ClockEventTreeItem | PlaceholderTreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: DayTreeItem | ClockEventTreeItem | PlaceholderTreeItem): Promise<(DayTreeItem | ClockEventTreeItem | PlaceholderTreeItem)[]> { + if (!element) { + // Root level - show day entries + try { + const dayEntries = await this.timeEntryService.getTimeEntriesForDisplay(); + + if (dayEntries.length === 0) { + return [new PlaceholderTreeItem("No time entries", "Start tracking time by clocking in", 'clock')]; + } + + const result: (DayTreeItem | PlaceholderTreeItem)[] = []; + + // Add day entries + for (const dayEntry of dayEntries) { + result.push(new DayTreeItem(dayEntry, vscode.TreeItemCollapsibleState.Collapsed)); + } + + // Add "Load More" option if there might be more entries + if (dayEntries.length >= 10) { + const loadMoreItem = new PlaceholderTreeItem("Load more days...", "Click to load additional work days", 'arrow-down'); + loadMoreItem.contextValue = 'loadMore'; + result.push(loadMoreItem); + } + + return result; + } catch (error) { + return [new PlaceholderTreeItem("Error loading time entries", (error as Error).message, 'error')]; + } + } + + if (element instanceof DayTreeItem) { + // Show clock events for this day + const clockEvents = element.dayEntry.entries + .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + .map(entry => new ClockEventTreeItem(entry)); + + if (clockEvents.length === 0) { + return [new PlaceholderTreeItem("No entries", "No clock events for this day", 'info')]; + } + + return clockEvents; + } + + // Placeholder and ClockEvent items have no children + return []; + } + + /** + * Handles the "Load more days" functionality + */ + async loadMoreDays(): Promise { + await this.timeEntryService.loadMoreDays(); + this.refresh(); + } +} \ No newline at end of file diff --git a/src/extension/presentation/styles/colors.ts b/src/extension/presentation/styles/colors.ts new file mode 100644 index 0000000..4988f38 --- /dev/null +++ b/src/extension/presentation/styles/colors.ts @@ -0,0 +1,8 @@ +import * as vscode from 'vscode'; + +export class Colors { + public static readonly red = new vscode.ThemeColor('charts.red'); + public static readonly green = new vscode.ThemeColor('charts.green'); + public static readonly blue = new vscode.ThemeColor('charts.blue'); + public static readonly gray = new vscode.ThemeColor('charts.gray'); +} \ No newline at end of file diff --git a/src/extension/presentation/time/clock-event-tree-item.ts b/src/extension/presentation/time/clock-event-tree-item.ts new file mode 100644 index 0000000..ee350dd --- /dev/null +++ b/src/extension/presentation/time/clock-event-tree-item.ts @@ -0,0 +1,30 @@ +import * as vscode from 'vscode'; +import { TimeEntry, TimeEntryUtils } from '../../domain/time/time-entry'; + +/** + * Tree item representing a clock in or clock out event + */ +export class ClockEventTreeItem extends vscode.TreeItem { + constructor( + public readonly timeEntry: TimeEntry + ) { + const time = TimeEntryUtils.formatTime(timeEntry.timestamp); + const action = timeEntry.type === 'clock-in' ? 'clocked in' : 'clocked out'; + const label = `${action} @ ${time}`; + + super(label, vscode.TreeItemCollapsibleState.None); + + this.tooltip = `${action} at ${timeEntry.timestamp.toLocaleString()}`; + this.contextValue = `clockEvent-${timeEntry.type}`; + + // Use different icons for clock in vs clock out + if (timeEntry.type === 'clock-in') { + this.iconPath = new vscode.ThemeIcon('play', new vscode.ThemeColor('charts.green')); + } else { + this.iconPath = new vscode.ThemeIcon('stop', new vscode.ThemeColor('charts.red')); + } + + // Show full timestamp as description + this.description = timeEntry.timestamp.toLocaleTimeString(); + } +} \ No newline at end of file diff --git a/src/extension/presentation/time/day-tree-item.ts b/src/extension/presentation/time/day-tree-item.ts new file mode 100644 index 0000000..b45f898 --- /dev/null +++ b/src/extension/presentation/time/day-tree-item.ts @@ -0,0 +1,31 @@ +import * as vscode from 'vscode'; +import { DayTimeEntry, TimeEntryUtils } from '../../domain/time/time-entry'; + +/** + * Tree item representing a single day's time entries + */ +export class DayTreeItem extends vscode.TreeItem { + constructor( + public readonly dayEntry: DayTimeEntry, + public readonly collapsibleState: vscode.TreeItemCollapsibleState + ) { + const displayDate = TimeEntryUtils.formatDisplayDate(new Date(dayEntry.date)); + const label = `${displayDate} - ${dayEntry.formattedDuration}`; + + super(label, collapsibleState); + + this.tooltip = `${displayDate}: ${dayEntry.formattedDuration}`; + this.contextValue = 'dayEntry'; + + // Use calendar icon for day entries + this.iconPath = new vscode.ThemeIcon('calendar'); + + // Show description with the date + this.description = this.getDayOfWeek(new Date(dayEntry.date)); + } + + private getDayOfWeek(date: Date): string { + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + return days[date.getDay()]; + } +} \ No newline at end of file diff --git a/src/extension/presentation/workflow/state-group-tree-item.ts b/src/extension/presentation/workflow/state-group-tree-item.ts new file mode 100644 index 0000000..91222a8 --- /dev/null +++ b/src/extension/presentation/workflow/state-group-tree-item.ts @@ -0,0 +1,25 @@ +import * as vscode from 'vscode'; +import { Colors } from '../styles/colors'; + +export class StateGroupTreeItem extends vscode.TreeItem { + constructor( + public readonly stateName: string, + public readonly taskCount: number + ) { + super(`${stateName} (${taskCount})`, vscode.TreeItemCollapsibleState.Expanded); + + this.contextValue = 'stateGroup'; + this.tooltip = `${taskCount} task(s) in ${stateName} state`; + this.iconPath = StateGroupTreeItem.getIconForState(stateName); + } + + private static getIconForState(stateName: string): vscode.ThemeIcon { + switch (stateName) { + case 'In Progress': return new vscode.ThemeIcon('debug-start', Colors.blue); + case 'Ready': return new vscode.ThemeIcon('circle-outline', Colors.gray); + case 'Closed': return new vscode.ThemeIcon('check', Colors.green); + case 'Removed': return new vscode.ThemeIcon('trash', Colors.red); + default: return new vscode.ThemeIcon('folder'); + } + } +} \ No newline at end of file diff --git a/src/extension/core/task-tree-item.ts b/src/extension/presentation/workflow/task-tree-item.ts similarity index 100% rename from src/extension/core/task-tree-item.ts rename to src/extension/presentation/workflow/task-tree-item.ts diff --git a/src/extension/core/work-item-tree-item.ts b/src/extension/presentation/workflow/work-item-tree-item.ts similarity index 100% rename from src/extension/core/work-item-tree-item.ts rename to src/extension/presentation/workflow/work-item-tree-item.ts diff --git a/src/extension/providers/tasks-tree-provider.ts b/src/extension/providers/tasks-tree-provider.ts deleted file mode 100644 index 154daf6..0000000 --- a/src/extension/providers/tasks-tree-provider.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Re-export all tree-related classes from their individual files -export { PlaceholderTreeItem } from '../core/placeholder-tree-item'; -export { StateGroupTreeItem } from '../core/state-group-tree-item'; -export { TaskTreeItem } from '../core/task-tree-item'; -export { WorkItemTreeItem } from '../core/work-item-tree-item'; -export { TasksTreeDataProvider } from '../providers/tasks-tree-data-provider'; - diff --git a/src/extension/setup/index.ts b/src/extension/setup/index.ts new file mode 100644 index 0000000..0d3cb7f --- /dev/null +++ b/src/extension/setup/index.ts @@ -0,0 +1,5 @@ +export * from './toolbox'; +export * from './toolbox.commands'; +export * from './toolbox.services'; +export * from './toolbox.views'; + diff --git a/src/extension/setup/toolbox.commands.ts b/src/extension/setup/toolbox.commands.ts new file mode 100644 index 0000000..1c92ba9 --- /dev/null +++ b/src/extension/setup/toolbox.commands.ts @@ -0,0 +1,107 @@ +import * as vscode from 'vscode'; +import { ExtensionContext } from "vscode"; +import { TasksTreeDataProvider } from "../application/providers/tasks-tree-data-provider"; +import { TimeTreeDataProvider } from "../application/providers/time-tree-data-provider"; +import { TimeEntryService } from "../application/time/time-entry-service"; +import { ICommunicationService, IConfigurationProvider, ILogger, ServiceLocator } from "../core"; +import { JsonTemplateLoader } from "../domain/workflow/pre-defined-tasks/json-template-loader"; +import { WorkItemService } from "../infrastructure/azure/work-item.service"; +import { SetTaskStateToActiveCommand, SetTaskStateToClosedCommand, SetTaskStateToNewCommand, SetTaskStateToResolvedCommand } from '../presentation/commands/tasks-tree-commands'; +import { CreateDefaultTasksCommand } from "../presentation/commands/workflow/create-default-tasks.command"; +import { StartWorkItemCommand } from "../presentation/commands/workflow/start-work-item.command"; + +export function registerCommands(context: ExtensionContext) { + const tasksTreeProvider = ServiceLocator.getService(TasksTreeDataProvider); + const timeTreeProvider = ServiceLocator.getService(TimeTreeDataProvider); + const timeEntryService = ServiceLocator.getService(TimeEntryService); + + registerTimeCommands(context, timeTreeProvider, timeEntryService); + registerTaskCommands(context, tasksTreeProvider); + registerWorkItemCommands(context, tasksTreeProvider); + registerWorkflowCommands(context); +} + +function registerTimeCommands(context: ExtensionContext, timeTreeProvider: TimeTreeDataProvider, timeEntryService: TimeEntryService) { + context.subscriptions.push( + vscode.commands.registerCommand('tacosontitan.toolbox.time.clockIn', async () => { + await timeEntryService.clockIn(); + timeTreeProvider.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('tacosontitan.toolbox.time.clockOut', async () => { + await timeEntryService.clockOut(); + timeTreeProvider.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('tacosontitan.toolbox.time.refresh', () => { + timeTreeProvider.refresh(); + }) + ); +} + +function registerTaskCommands(context: ExtensionContext, tasksTreeProvider: TasksTreeDataProvider) { + context.subscriptions.push( + vscode.commands.registerCommand('tacosontitan.toolbox.tasks.refresh', () => { + tasksTreeProvider.refresh(); + }) + ); +} + +function registerWorkItemCommands(context: ExtensionContext, tasksTreeProvider: TasksTreeDataProvider) { + context.subscriptions.push( + vscode.commands.registerCommand('tacosontitan.toolbox.workflow.refreshTasks', () => { + tasksTreeProvider.refresh(); + }) + ); + + const configurationProvider = ServiceLocator.getService(IConfigurationProvider); + const workItemService = ServiceLocator.getService(WorkItemService); + const taskStateCommands = [ + new SetTaskStateToActiveCommand(configurationProvider, tasksTreeProvider, workItemService), + new SetTaskStateToClosedCommand(configurationProvider, tasksTreeProvider, workItemService), + new SetTaskStateToNewCommand(configurationProvider, tasksTreeProvider, workItemService), + new SetTaskStateToResolvedCommand(configurationProvider, tasksTreeProvider, workItemService) + ]; + + for (const command of taskStateCommands) { + context.subscriptions.push( + vscode.commands.registerCommand(command.id, (taskItem) => command.execute(taskItem)) + ); + } +} + +function registerWorkflowCommands(context: ExtensionContext) { + const configurationProvider = ServiceLocator.getService(IConfigurationProvider); + const logger = ServiceLocator.getService(ILogger); + const communicationService = ServiceLocator.getService(ICommunicationService); + const workItemService = ServiceLocator.getService(WorkItemService); + const templateLoader = ServiceLocator.getService(JsonTemplateLoader); + const createDefaultTasksCommand = new CreateDefaultTasksCommand( + configurationProvider, + logger, + workItemService, + templateLoader + ); + + context.subscriptions.push( + vscode.commands.registerCommand(createDefaultTasksCommand.id, (...args) => + createDefaultTasksCommand.execute(...args) + ) + ); + + const startWorkItemCommand = new StartWorkItemCommand( + logger, + communicationService, + ServiceLocator.getService('IWorkflowService') + ); + + context.subscriptions.push( + vscode.commands.registerCommand(startWorkItemCommand.id, () => + startWorkItemCommand.execute() + ) + ); +} \ No newline at end of file diff --git a/src/extension/setup/toolbox.services.ts b/src/extension/setup/toolbox.services.ts new file mode 100644 index 0000000..05c519f --- /dev/null +++ b/src/extension/setup/toolbox.services.ts @@ -0,0 +1,92 @@ +import { ExtensionContext } from "vscode"; +import { TimeEntryService } from "../application/time/time-entry-service"; +import { WorkflowService } from "../application/workflow/workflow.service"; +import { ICommunicationService, IConfiguration, IConfigurationProvider, ILogger, ISecretProvider, OutputLogger, ServiceLocator } from "../core"; +import { IRepository } from "../core/repository"; +import { ISourceControlService } from "../core/source-control/source-control.service"; +import { DefaultTaskService, ITaskService, WorkflowConfiguration, WorkflowOptions, WorkItem } from "../domain/workflow"; +import { JsonTemplateLoader } from "../domain/workflow/pre-defined-tasks/json-template-loader"; +import { DevOpsService } from "../infrastructure/azure/devops-service"; +import { DevOpsConfiguration } from "../infrastructure/azure/devops.configuration"; +import { DevOpsOptions } from "../infrastructure/azure/devops.options"; +import { AzureDevOpsWorkItemRepository } from "../infrastructure/azure/work-item-repository"; +import { WorkItemService } from "../infrastructure/azure/work-item.service"; +import { GitService } from "../infrastructure/git/git.source-control.service"; +import { NativeCommunicationService } from "../infrastructure/vscode/communication-service.native"; +import { NativeConfigurationProvider } from "../infrastructure/vscode/configuration-provider.native"; +import { NativeSecretProvider } from "../infrastructure/vscode/secret-provider.native"; +import { NativeTaskService } from "../infrastructure/vscode/task-service.native"; + +export function registerServices(context: ExtensionContext) { + registerInfrastructureServices(context); + registerDomainServices(); + registerApplicationServices(); + ServiceLocator.registerFactory(ILogger, () => new OutputLogger("Hazel's Toolbox")); + ServiceLocator.registerFactory(TimeEntryService, () => new TimeEntryService(context)); + ServiceLocator.registerFactory(JsonTemplateLoader, () => new JsonTemplateLoader(context)); +} + +function registerInfrastructureServices(context: ExtensionContext) { + registerNativeServices(context); + registerAzureServices(); + registerGitServices(); +} + +function registerDomainServices() { + ServiceLocator.registerFactory(DefaultTaskService, () => new DefaultTaskService()); + ServiceLocator.registerStringInterface('ITaskService', DefaultTaskService); +} + +function registerApplicationServices() { + ServiceLocator.registerFactory(IConfiguration, () => new WorkflowConfiguration( + ServiceLocator.getService(ILogger), + ServiceLocator.getService(IConfigurationProvider) + )); + + ServiceLocator.registerFactory(WorkflowService, () => new WorkflowService( + ServiceLocator.getService(ILogger), + ServiceLocator.getService(IConfiguration), + ServiceLocator.getService(IRepository), + ServiceLocator.getService(ITaskService) + )); + + ServiceLocator.registerStringInterface('IWorkflowService', WorkflowService); +} + +function registerNativeServices(context: ExtensionContext) { + ServiceLocator.registerFactory(ISecretProvider, () => new NativeSecretProvider(context)); + ServiceLocator.registerFactory(IConfigurationProvider, () => new NativeConfigurationProvider()); + ServiceLocator.registerFactory(ICommunicationService, () => new NativeCommunicationService()); + ServiceLocator.registerFactory(IConfigurationProvider, () => new NativeConfigurationProvider()); + ServiceLocator.registerFactory(ITaskService, () => new NativeTaskService()); +} + +function registerGitServices() { + ServiceLocator.registerFactory(ISourceControlService, () => new GitService()); +} + +function registerAzureServices() { + ServiceLocator.registerFactory(IConfiguration, () => new DevOpsConfiguration( + ServiceLocator.getService(ILogger), + ServiceLocator.getService(IConfigurationProvider), + ServiceLocator.getService(ISecretProvider) + )); + + ServiceLocator.registerFactory(DevOpsService, () => new DevOpsService( + ServiceLocator.getService(ISecretProvider), + ServiceLocator.getService(IConfigurationProvider) + ) + ); + + ServiceLocator.registerFactory(WorkItemService, () => new WorkItemService( + ServiceLocator.getService(ILogger), + ServiceLocator.getService(ICommunicationService), + ServiceLocator.getService(DevOpsService) + ) + ); + + ServiceLocator.registerFactory(IRepository, () => new AzureDevOpsWorkItemRepository( + ServiceLocator.getService(ILogger), + ServiceLocator.getService(IConfiguration) + )); +} \ No newline at end of file diff --git a/src/extension/setup/toolbox.ts b/src/extension/setup/toolbox.ts new file mode 100644 index 0000000..7490bd9 --- /dev/null +++ b/src/extension/setup/toolbox.ts @@ -0,0 +1,13 @@ +import { ExtensionContext } from "vscode"; +import { ServiceLocator } from "../core/services"; +import { TaskTemplateLoader } from "../domain/workflow/task-template-loader"; + +export async function initialize(context: ExtensionContext) { + ServiceLocator.initialize(context); + await loadDefaultTasks(context); +} + +async function loadDefaultTasks(context: ExtensionContext) { + const templateLoader = new TaskTemplateLoader(context); + await templateLoader.loadTemplates(); +} \ No newline at end of file diff --git a/src/extension/setup/toolbox.views.ts b/src/extension/setup/toolbox.views.ts new file mode 100644 index 0000000..06203b6 --- /dev/null +++ b/src/extension/setup/toolbox.views.ts @@ -0,0 +1,64 @@ +import * as vscode from 'vscode'; +import { ExtensionContext } from "vscode"; +import { MeetingViewProvider } from "../application/providers/meeting-view-provider"; +import { OverviewWebviewProvider } from "../application/providers/overview-webview-provider"; +import { TasksTreeDataProvider } from "../application/providers/tasks-tree-data-provider"; +import { TimeTreeDataProvider } from "../application/providers/time-tree-data-provider"; +import { TimeEntryService } from "../application/time/time-entry-service"; +import { ServiceLocator } from "../core"; +import { DevOpsService } from "../infrastructure/azure/devops-service"; + +export function registerViews(context: ExtensionContext) { + createOverviewWebview(context); + createTasksTreeView(context); + createMeetingView(context); + createTimeTreeView(context); +} + +function createOverviewWebview(context: ExtensionContext): OverviewWebviewProvider { + const devOpsService = ServiceLocator.getService(DevOpsService); + const overviewWebviewProvider = new OverviewWebviewProvider(context.extensionUri, devOpsService); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(OverviewWebviewProvider.viewType, overviewWebviewProvider) + ); + + return overviewWebviewProvider; +} + +function createTasksTreeView(context: ExtensionContext): TasksTreeDataProvider { + const devOpsService = ServiceLocator.getService(DevOpsService); + const tasksTreeProvider = new TasksTreeDataProvider(devOpsService); + ServiceLocator.registerFactory(TasksTreeDataProvider, () => tasksTreeProvider); + + vscode.window.createTreeView('tasksTreeView', { + treeDataProvider: tasksTreeProvider, + showCollapseAll: true + }); + + return tasksTreeProvider; +} + +function createMeetingView(context: ExtensionContext): void { + const devOpsService = ServiceLocator.getService(DevOpsService); + const meetingViewProvider = new MeetingViewProvider(context.extensionUri, devOpsService); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + MeetingViewProvider.viewType, + meetingViewProvider + ) + ); +} + +function createTimeTreeView(context: ExtensionContext): TimeTreeDataProvider { + const timeEntryService = ServiceLocator.getService(TimeEntryService); + const timeTreeProvider = new TimeTreeDataProvider(timeEntryService); + ServiceLocator.registerFactory(TimeTreeDataProvider, () => timeTreeProvider); + + vscode.window.createTreeView('timeTreeView', { + treeDataProvider: timeTreeProvider, + showCollapseAll: true + }); + + return timeTreeProvider; +} diff --git a/src/extension/test/data-retention.test.ts b/src/extension/test/data-retention.test.ts new file mode 100644 index 0000000..7ca9143 --- /dev/null +++ b/src/extension/test/data-retention.test.ts @@ -0,0 +1,132 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { TimeEntryService } from '../application/time/time-entry-service'; +import { TimeEntry } from '../domain/time/time-entry'; + +suite('Data Retention Test Suite', () => { + let mockContext: vscode.ExtensionContext; + let timeEntryService: TimeEntryService; + + setup(() => { + // Create a mock extension context + const mockGlobalState = new Map(); + mockContext = { + globalState: { + get: (key: string, defaultValue?: T): T => { + return mockGlobalState.get(key) ?? defaultValue!; + }, + update: (key: string, value: any): Thenable => { + mockGlobalState.set(key, value); + return Promise.resolve(); + }, + keys: (): readonly string[] => { + return Array.from(mockGlobalState.keys()); + }, + setKeysForSync: (keys: readonly string[]): void => {} + } + } as any; + + timeEntryService = new TimeEntryService(mockContext); + }); + + test('Data retention should remove old entries based on retention policy', async () => { + // Mock the workspace configuration to return specific retention settings + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + return { + get: (key: string, defaultValue?: T): T => { + if (key === 'retentionDays') { return 30 as T; } + if (key === 'autoCleanup') { return false as T; } // Disable auto cleanup for manual testing + return defaultValue!; + }, + update: () => Promise.resolve(), + inspect: () => undefined, + has: () => true + } as any; + }; + + try { + // Create test data with entries older and newer than retention period + const now = new Date(); + const oldDate = new Date(now.getTime() - (35 * 24 * 60 * 60 * 1000)); // 35 days ago (should be removed) + const recentDate = new Date(now.getTime() - (5 * 24 * 60 * 60 * 1000)); // 5 days ago (should be kept) + + const testEntries = [ + { id: '1', type: 'clock-in', timestamp: oldDate.toISOString() }, + { id: '2', type: 'clock-out', timestamp: oldDate.toISOString() }, + { id: '3', type: 'clock-in', timestamp: recentDate.toISOString() }, + { id: '4', type: 'clock-out', timestamp: recentDate.toISOString() } + ]; + + // Set initial data + await mockContext.globalState.update('tacosontitan.toolbox.timeEntries', testEntries); + + // Perform cleanup + await timeEntryService.cleanupOldEntries(false); + + // Get remaining entries + const remainingEntries = mockContext.globalState.get('tacosontitan.toolbox.timeEntries', []); + + // Should have removed the 2 old entries and kept the 2 recent ones + assert.strictEqual(remainingEntries.length, 2, 'Should keep only recent entries'); + + // Verify that only recent entries remain + for (const entry of remainingEntries) { + const entryDate = new Date(entry.timestamp); + const daysDiff = (now.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24); + assert.ok(daysDiff < 30, 'Remaining entries should be within retention period'); + } + + } finally { + // Restore original getConfiguration + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); + + test('Data retention should not remove entries if all are within retention period', async () => { + // Mock the workspace configuration + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + return { + get: (key: string, defaultValue?: T): T => { + if (key === 'retentionDays') { return 90 as T; } + if (key === 'autoCleanup') { return false as T; } + return defaultValue!; + }, + update: () => Promise.resolve(), + inspect: () => undefined, + has: () => true + } as any; + }; + + try { + // Create test data with all entries within retention period + const now = new Date(); + const recentDate1 = new Date(now.getTime() - (10 * 24 * 60 * 60 * 1000)); // 10 days ago + const recentDate2 = new Date(now.getTime() - (20 * 24 * 60 * 60 * 1000)); // 20 days ago + + const testEntries = [ + { id: '1', type: 'clock-in', timestamp: recentDate1.toISOString() }, + { id: '2', type: 'clock-out', timestamp: recentDate1.toISOString() }, + { id: '3', type: 'clock-in', timestamp: recentDate2.toISOString() }, + { id: '4', type: 'clock-out', timestamp: recentDate2.toISOString() } + ]; + + // Set initial data + await mockContext.globalState.update('tacosontitan.toolbox.timeEntries', testEntries); + + // Perform cleanup + await timeEntryService.cleanupOldEntries(false); + + // Get remaining entries + const remainingEntries = mockContext.globalState.get('tacosontitan.toolbox.timeEntries', []); + + // Should have kept all entries + assert.strictEqual(remainingEntries.length, 4, 'Should keep all entries within retention period'); + + } finally { + // Restore original getConfiguration + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); +}); \ No newline at end of file diff --git a/src/extension/test/extension.test.ts b/src/extension/test/extension.test.ts index 077a031..0313611 100644 --- a/src/extension/test/extension.test.ts +++ b/src/extension/test/extension.test.ts @@ -3,9 +3,9 @@ import * as assert from 'assert'; // You can import and use all API from the 'vscode' module // as well as import your extension to test it import * as vscode from 'vscode'; -import { PlaceholderTreeItem } from '../core/placeholder-tree-item'; -import { TasksTreeDataProvider } from '../providers/tasks-tree-data-provider'; -import { DevOpsService } from '../services/devops-service'; +import { TasksTreeDataProvider } from '../application/providers/tasks-tree-data-provider'; +import { DevOpsService } from '../infrastructure/azure/devops-service'; +import { PlaceholderTreeItem } from '../presentation/placeholder-tree-item'; suite('Extension Test Suite', () => { vscode.window.showInformationMessage('Start all tests.'); diff --git a/src/extension/test/overview.test.ts b/src/extension/test/overview.test.ts new file mode 100644 index 0000000..68089be --- /dev/null +++ b/src/extension/test/overview.test.ts @@ -0,0 +1,21 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { OverviewWebviewProvider } from '../application/providers/overview-webview-provider'; + +suite('Overview Test Suite', () => { + test('OverviewWebviewProvider should create without DevOps service', () => { + const extensionUri = vscode.Uri.file('/test'); + const provider = new OverviewWebviewProvider(extensionUri); + + assert.ok(provider); + assert.strictEqual(OverviewWebviewProvider.viewType, 'overviewWebView'); + }); + + test('OverviewWebviewProvider should create with DevOps service', () => { + const extensionUri = vscode.Uri.file('/test'); + const mockDevOpsService = {} as any; // Mock service for testing + const provider = new OverviewWebviewProvider(extensionUri, mockDevOpsService); + + assert.ok(provider); + }); +}); \ No newline at end of file diff --git a/src/extension/test/task-tree-item.test.ts b/src/extension/test/task-tree-item.test.ts index 1524d26..6db5039 100644 --- a/src/extension/test/task-tree-item.test.ts +++ b/src/extension/test/task-tree-item.test.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import { WorkItem } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; import * as vscode from 'vscode'; -import { TaskTreeItem } from '../core/task-tree-item'; +import { TaskTreeItem } from '../presentation/workflow/task-tree-item'; suite('TaskTreeItem Test Suite', () => { test('Task with Removed state should have red icon', () => { diff --git a/src/extension/test/tasks-tree-data-provider.test.ts b/src/extension/test/tasks-tree-data-provider.test.ts index 35557cc..63443f9 100644 --- a/src/extension/test/tasks-tree-data-provider.test.ts +++ b/src/extension/test/tasks-tree-data-provider.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; -import { TasksTreeDataProvider } from '../providers/tasks-tree-data-provider'; -import { DevOpsService } from '../services/devops-service'; +import { TasksTreeDataProvider } from '../application/providers/tasks-tree-data-provider'; +import { DevOpsService } from '../infrastructure/azure/devops-service'; // Mock the DevOpsService class MockDevOpsService extends DevOpsService { diff --git a/src/extension/test/time-entry.test.ts b/src/extension/test/time-entry.test.ts new file mode 100644 index 0000000..50d644d --- /dev/null +++ b/src/extension/test/time-entry.test.ts @@ -0,0 +1,105 @@ +import * as assert from 'assert'; +import { TimeEntry, TimeEntryUtils } from '../domain/time/time-entry'; + +suite('Time Entry Utils Tests', () => { + test('Should format date correctly', () => { + const date = new Date('2025-01-10T08:30:00'); + const formatted = TimeEntryUtils.formatDate(date); + assert.strictEqual(formatted, '2025-01-10'); + }); + + test('Should format time correctly', () => { + const date = new Date('2025-01-10T07:55:00'); + const formatted = TimeEntryUtils.formatTime(date); + assert.strictEqual(formatted, '0755'); + }); + + test('Should calculate day total correctly', () => { + const entries: TimeEntry[] = [ + { + id: '1', + type: 'clock-in', + timestamp: new Date('2025-01-10T08:00:00') + }, + { + id: '2', + type: 'clock-out', + timestamp: new Date('2025-01-10T12:00:00') + }, + { + id: '3', + type: 'clock-in', + timestamp: new Date('2025-01-10T13:00:00') + }, + { + id: '4', + type: 'clock-out', + timestamp: new Date('2025-01-10T17:15:00') + } + ]; + + const result = TimeEntryUtils.calculateDayTotal(entries); + assert.strictEqual(result.hours, 8); + assert.strictEqual(result.minutes, 15); + assert.strictEqual(result.formatted, '8 hours 15 minutes'); + }); + + test('Should group entries by date correctly', () => { + const entries: TimeEntry[] = [ + { + id: '1', + type: 'clock-in', + timestamp: new Date('2025-01-10T08:00:00') + }, + { + id: '2', + type: 'clock-out', + timestamp: new Date('2025-01-10T17:00:00') + }, + { + id: '3', + type: 'clock-in', + timestamp: new Date('2025-01-09T08:00:00') + }, + { + id: '4', + type: 'clock-out', + timestamp: new Date('2025-01-09T16:30:00') + } + ]; + + const grouped = TimeEntryUtils.groupEntriesByDate(entries); + assert.strictEqual(grouped.length, 2); + + // Should be sorted by date descending (most recent first) + assert.strictEqual(grouped[0].date, '2025-01-10'); + assert.strictEqual(grouped[1].date, '2025-01-09'); + + // Check totals + assert.strictEqual(grouped[0].formattedDuration, '9 hours'); + assert.strictEqual(grouped[1].formattedDuration, '8 hours 30 minutes'); + }); + + test('Should handle empty entries', () => { + const result = TimeEntryUtils.calculateDayTotal([]); + assert.strictEqual(result.hours, 0); + assert.strictEqual(result.minutes, 0); + assert.strictEqual(result.formatted, '0 minutes'); + }); + + test('Should handle incomplete pairs', () => { + const entries: TimeEntry[] = [ + { + id: '1', + type: 'clock-in', + timestamp: new Date('2025-01-10T08:00:00') + } + // Missing clock-out + ]; + + const result = TimeEntryUtils.calculateDayTotal(entries); + assert.strictEqual(result.hours, 0); + assert.strictEqual(result.minutes, 0); + assert.strictEqual(result.formatted, '0 minutes'); + }); +}); \ No newline at end of file diff --git a/src/extension/todo/meetings/meeting-templates.ts b/src/extension/todo/meetings/meeting-templates.ts new file mode 100644 index 0000000..48aabae --- /dev/null +++ b/src/extension/todo/meetings/meeting-templates.ts @@ -0,0 +1,181 @@ +import { generateParticipantsSection } from './participant-config'; + +/** + * Templates for different meeting types in the meeting view. + */ +export interface MeetingTemplate { + name: string; + template: string; +} + +/** + * Generate template with dynamic participants + */ +function generateTemplate(baseTemplate: string, selectedParticipants: string[] = []): string { + const participantsSection = generateParticipantsSection(selectedParticipants); + return baseTemplate.replace('{{PARTICIPANTS_SECTION}}', participantsSection); +} + +export const MeetingTemplates: Record = { + "product owner": { + name: "Product Owner Meeting", + template: `# Product Owner Meeting Notes + +## Date: ${new Date().toLocaleDateString()} + +{{PARTICIPANTS_SECTION}} + +## Agenda: +1. Requirements Review +2. Priority Discussion +3. Acceptance Criteria Clarification +4. Timeline & Milestones + +## Discussion Points: +- + +## Key Decisions: +- + +## Action Items: +- [ ] +- [ ] + +## Next Steps: +- + +## Questions/Concerns: +- +` + }, + "quality assurance": { + name: "Quality Assurance Meeting", + template: `# Quality Assurance Meeting Notes + +## Date: ${new Date().toLocaleDateString()} + +{{PARTICIPANTS_SECTION}} + +## Agenda: +1. Test Strategy Review +2. Test Cases Discussion +3. Quality Standards +4. Risk Assessment + +## Testing Scope: +- Functional Testing: +- Non-functional Testing: +- Integration Testing: +- Security Testing: + +## Test Cases Reviewed: +- + +## Quality Concerns: +- + +## Action Items: +- [ ] +- [ ] + +## Test Timeline: +- + +## Notes: +- +` + }, + "stakeholders": { + name: "Stakeholders Meeting", + template: `# Stakeholders Meeting Notes + +## Date: ${new Date().toLocaleDateString()} + +{{PARTICIPANTS_SECTION}} + +## Agenda: +1. Project Status Update +2. Requirements Gathering +3. Feedback Collection +4. Expectations Alignment + +## Current Status: +- Progress: +- Completed: +- In Progress: +- Upcoming: + +## Stakeholder Feedback: +- + +## Requirements Changes: +- + +## Expectations & Concerns: +- + +## Action Items: +- [ ] +- [ ] + +## Next Meeting: +- Date: +- Topics: + +## Notes: +- +` + }, + "peer review": { + name: "Peer Review Meeting", + template: `# Peer Review Meeting Notes + +## Date: ${new Date().toLocaleDateString()} + +{{PARTICIPANTS_SECTION}} + +## Review Focus: +- Code Quality +- Architecture & Design +- Best Practices +- Performance Considerations + +## Items Reviewed: +- + +## Feedback Summary: +### Positive Points: +- + +### Areas for Improvement: +- + +### Critical Issues: +- + +## Recommendations: +- + +## Action Items: +- [ ] +- [ ] + +## Follow-up: +- Next review date: +- Items to address: + +## Notes: +- +` + } +}; + +export function getMeetingTemplate(meetingType: string, selectedParticipants: string[] = []): string { + const template = MeetingTemplates[meetingType.toLowerCase()]; + const baseTemplate = template ? template.template : MeetingTemplates["stakeholders"].template; + return generateTemplate(baseTemplate, selectedParticipants); +} + +export function getMeetingTypes(): string[] { + return Object.keys(MeetingTemplates); +} \ No newline at end of file diff --git a/src/extension/todo/meetings/participant-config.ts b/src/extension/todo/meetings/participant-config.ts new file mode 100644 index 0000000..4690935 --- /dev/null +++ b/src/extension/todo/meetings/participant-config.ts @@ -0,0 +1,39 @@ +import * as vscode from 'vscode'; + +/** + * Interface for participant configuration + */ +export interface ParticipantConfig { + name: string; + selected: boolean; +} + +/** + * Get the list of configured participants from VS Code settings + */ +export function getConfiguredParticipants(): string[] { + const config = vscode.workspace.getConfiguration('tacosontitan.toolbox.meetings'); + return config.get('participants', [ + 'Product Owner', + 'Scrum Master', + 'Tech Lead', + 'Developer', + 'QA Engineer', + 'Business Analyst', + 'Stakeholder', + 'Designer', + 'Architect' + ]); +} + +/** + * Generate participants section for meeting notes + */ +export function generateParticipantsSection(selectedParticipants: string[]): string { + if (selectedParticipants.length === 0) { + return '## Attendees:\n- \n'; + } + + return '## Attendees:\n' + + selectedParticipants.map(participant => `- ${participant}: `).join('\n') + '\n'; +} \ No newline at end of file