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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+}
\ 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