From 4af38e1c3b0178d14064e4cb272ba183eb27c6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 2 Aug 2026 17:08:22 +0100 Subject: [PATCH] feat: add editable Azure DevOps board (#2033) --- .../internal/azuredevops/board_models.go | 90 +++ .../internal/azuredevops/client_models.go | 33 + .../azuredevops/client_models_test.go | 23 + .../internal/azuredevops/controller.go | 330 ++++++++- .../internal/azuredevops/controller_test.go | 174 ++++- apps/backend/internal/azuredevops/copy.go | 6 + .../backend/internal/azuredevops/lifecycle.go | 15 + .../internal/azuredevops/mock_client.go | 244 ++++++- .../internal/azuredevops/mock_client_test.go | 136 ++++ apps/backend/internal/azuredevops/models.go | 21 + apps/backend/internal/azuredevops/poller.go | 159 ++++- apps/backend/internal/azuredevops/provider.go | 3 +- .../internal/azuredevops/rest_client.go | 391 ++++++++++- .../internal/azuredevops/rest_client_test.go | 194 ++++++ apps/backend/internal/azuredevops/service.go | 101 ++- .../internal/azuredevops/service_board.go | 109 +++ .../azuredevops/service_board_test.go | 126 ++++ .../azuredevops/service_detail_test.go | 75 ++ .../internal/azuredevops/service_reads.go | 48 ++ .../azuredevops/service_task_work_item.go | 39 ++ .../service_task_work_item_test.go | 31 + .../internal/azuredevops/service_test.go | 73 +- apps/backend/internal/azuredevops/store.go | 210 +++++- .../azuredevops/store_task_work_item.go | 66 ++ .../internal/azuredevops/store_test.go | 126 ++++ .../internal/azuredevops/store_watch_reset.go | 90 +++ .../internal/azuredevops/watch_cleanup.go | 149 ++++ .../azuredevops/watch_cleanup_test.go | 112 +++ .../azuredevops/watch_dependencies.go | 108 +++ .../internal/azuredevops/watch_models.go | 253 +++++++ .../internal/azuredevops/watch_service.go | 580 ++++++++++++++++ .../azuredevops/watch_service_test.go | 108 +++ .../internal/azuredevops/watch_store.go | 514 ++++++++++++++ .../internal/azuredevops/watch_store_test.go | 129 ++++ .../azuredevops/work_item_mutations.go | 76 +++ .../azuredevops/workspace_settings.go | 244 +++++++ .../azuredevops/workspace_settings_test.go | 105 +++ .../internal/backendapp/boot_state_routes.go | 1 + .../backendapp/boot_state_routes_test.go | 34 + apps/backend/internal/backendapp/helpers.go | 9 + apps/backend/internal/backendapp/main.go | 6 +- apps/backend/internal/backendapp/services.go | 1 + apps/backend/internal/events/types.go | 6 + apps/backend/internal/integrations/AGENTS.md | 2 +- .../event_handlers_azuredevops.go | 60 ++ .../event_handlers_azuredevops_test.go | 54 ++ apps/backend/internal/orchestrator/service.go | 9 + .../orchestrator/source_azuredevops.go | 310 +++++++++ .../orchestrator/source_azuredevops_test.go | 99 +++ .../internal/user/controller/controller.go | 1 + .../user/controller/controller_test.go | 73 ++ apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/dto/dto_test.go | 15 + apps/backend/internal/user/models/models.go | 1 + apps/backend/internal/user/service/service.go | 5 + .../internal/user/service/service_test.go | 45 +- apps/backend/internal/user/store/sqlite.go | 3 + .../azure-devops/azure-devops-page-client.tsx | 305 ++++++--- .../azure-devops-board-view.test.ts | 52 ++ .../azure-devops/azure-devops-board-view.ts | 15 + .../azure-devops/azure-devops-board.tsx | 423 ++++++++++++ .../azure-devops-default-queries.test.tsx | 88 +++ .../azure-devops-default-queries.tsx | 473 +++++++++++++ .../azure-devops/azure-devops-filters.tsx | 2 +- .../azure-devops/azure-devops-mode-tabs.tsx | 34 + .../azure-devops/azure-devops-presets.test.ts | 36 +- .../azure-devops/azure-devops-presets.ts | 130 ++-- .../azure-devops-pull-request-pagination.tsx | 51 ++ .../azure-devops-quick-actions.test.tsx | 102 +++ .../azure-devops-quick-actions.tsx | 430 ++++++++++++ .../azure-devops-results.test.tsx | 31 +- .../azure-devops/azure-devops-results.tsx | 139 +++- .../azure-devops/azure-devops-scope-bar.tsx | 10 +- .../azure-devops-settings.test.tsx | 63 +- .../azure-devops/azure-devops-settings.tsx | 25 +- .../azure-devops-task-launcher.test.tsx | 182 ++++- .../azure-devops-task-launcher.tsx | 95 ++- .../azure-devops-watch-settings.tsx | 642 ++++++++++++++++++ .../azure-devops-work-item-detail.test.tsx | 95 +++ .../azure-devops-work-item-detail.tsx | 503 ++++++++++++++ .../azure-devops-workspace-defaults.ts | 117 ++++ .../integrations/action-preset-icons.ts | 28 + .../tests/integrations/azure-devops.spec.ts | 194 +++++- .../integrations/mobile-azure-devops.spec.ts | 135 +++- .../use-azure-devops-board.test.tsx | 176 +++++ .../azure-devops/use-azure-devops-board.ts | 226 ++++++ .../use-azure-devops-preferences.test.tsx | 12 + .../use-azure-devops-preferences.ts | 124 ++++ .../use-azure-devops-task-work-items.test.tsx | 58 ++ .../use-azure-devops-task-work-items.ts | 96 +++ .../use-azure-devops-watches.test.ts | 18 + .../azure-devops/use-azure-devops-watches.ts | 176 +++++ .../use-azure-devops-work-item-detail.test.ts | 18 + .../use-azure-devops-work-item-detail.ts | 206 ++++++ .../use-azure-devops-workspace-actions.ts | 47 ++ .../hooks/use-ensure-user-settings.test.ts | 1 + .../lib/api/domains/azure-devops-api.test.ts | 70 ++ apps/web/lib/api/domains/azure-devops-api.ts | 326 +++++++++ apps/web/lib/ssr/user-settings.test.ts | 33 + apps/web/lib/ssr/user-settings.ts | 6 + apps/web/lib/state/default-state.ts | 6 + apps/web/lib/state/hydration/hydrator.ts | 3 + .../slices/azure-devops/azure-devops-slice.ts | 17 + .../lib/state/slices/azure-devops/types.ts | 10 +- apps/web/lib/state/slices/settings/types.ts | 1 + apps/web/lib/state/store-overrides.ts | 1 + apps/web/lib/state/store.ts | 1 + apps/web/lib/types/azure-devops.ts | 203 ++++++ apps/web/lib/types/http-user-settings.ts | 2 + docs/plans/azure-devops-integration/plan.md | 280 +++++++- .../task-14-board-discovery.md | 58 ++ .../task-15-board-mutations.md | 68 ++ .../task-16-board-ui.md | 101 +++ .../task-17-board-validation.md | 72 ++ .../task-18-immediate-activation.md | 61 ++ .../task-19-browse-preferences.md | 85 +++ .../task-20-work-item-detail.md | 66 ++ .../task-21-work-item-mutations.md | 61 ++ .../task-22-task-work-item-links.md | 63 ++ .../task-23-provider-presets.md | 97 +++ .../task-24-watcher-persistence.md | 95 +++ .../task-25-watcher-polling.md | 87 +++ .../task-26-watcher-dispatch.md | 90 +++ .../task-27-work-item-detail-ui.md | 130 ++++ .../task-28-automation-settings.md | 98 +++ .../task-29-enhancement-validation.md | 97 +++ docs/public/feature-status.md | 2 +- docs/public/integrations.md | 12 +- docs/specs/azure-devops-integration/spec.md | 360 +++++++++- 129 files changed, 13915 insertions(+), 333 deletions(-) create mode 100644 apps/backend/internal/azuredevops/board_models.go create mode 100644 apps/backend/internal/azuredevops/client_models_test.go create mode 100644 apps/backend/internal/azuredevops/service_board.go create mode 100644 apps/backend/internal/azuredevops/service_board_test.go create mode 100644 apps/backend/internal/azuredevops/service_detail_test.go create mode 100644 apps/backend/internal/azuredevops/service_task_work_item.go create mode 100644 apps/backend/internal/azuredevops/service_task_work_item_test.go create mode 100644 apps/backend/internal/azuredevops/store_task_work_item.go create mode 100644 apps/backend/internal/azuredevops/store_watch_reset.go create mode 100644 apps/backend/internal/azuredevops/watch_cleanup.go create mode 100644 apps/backend/internal/azuredevops/watch_cleanup_test.go create mode 100644 apps/backend/internal/azuredevops/watch_dependencies.go create mode 100644 apps/backend/internal/azuredevops/watch_models.go create mode 100644 apps/backend/internal/azuredevops/watch_service.go create mode 100644 apps/backend/internal/azuredevops/watch_service_test.go create mode 100644 apps/backend/internal/azuredevops/watch_store.go create mode 100644 apps/backend/internal/azuredevops/watch_store_test.go create mode 100644 apps/backend/internal/azuredevops/work_item_mutations.go create mode 100644 apps/backend/internal/azuredevops/workspace_settings.go create mode 100644 apps/backend/internal/azuredevops/workspace_settings_test.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_azuredevops.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_azuredevops_test.go create mode 100644 apps/backend/internal/orchestrator/source_azuredevops.go create mode 100644 apps/backend/internal/orchestrator/source_azuredevops_test.go create mode 100644 apps/web/components/azure-devops/azure-devops-board-view.test.ts create mode 100644 apps/web/components/azure-devops/azure-devops-board-view.ts create mode 100644 apps/web/components/azure-devops/azure-devops-board.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-default-queries.test.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-default-queries.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-mode-tabs.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-pull-request-pagination.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-quick-actions.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-watch-settings.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-work-item-detail.test.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-work-item-detail.tsx create mode 100644 apps/web/components/azure-devops/azure-devops-workspace-defaults.ts create mode 100644 apps/web/components/integrations/action-preset-icons.ts create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-board.test.tsx create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-board.ts create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-preferences.test.tsx create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-preferences.ts create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-task-work-items.test.tsx create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-task-work-items.ts create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-watches.test.ts create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-watches.ts create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-work-item-detail.test.ts create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-work-item-detail.ts create mode 100644 apps/web/hooks/domains/azure-devops/use-azure-devops-workspace-actions.ts create mode 100644 docs/plans/azure-devops-integration/task-14-board-discovery.md create mode 100644 docs/plans/azure-devops-integration/task-15-board-mutations.md create mode 100644 docs/plans/azure-devops-integration/task-16-board-ui.md create mode 100644 docs/plans/azure-devops-integration/task-17-board-validation.md create mode 100644 docs/plans/azure-devops-integration/task-18-immediate-activation.md create mode 100644 docs/plans/azure-devops-integration/task-19-browse-preferences.md create mode 100644 docs/plans/azure-devops-integration/task-20-work-item-detail.md create mode 100644 docs/plans/azure-devops-integration/task-21-work-item-mutations.md create mode 100644 docs/plans/azure-devops-integration/task-22-task-work-item-links.md create mode 100644 docs/plans/azure-devops-integration/task-23-provider-presets.md create mode 100644 docs/plans/azure-devops-integration/task-24-watcher-persistence.md create mode 100644 docs/plans/azure-devops-integration/task-25-watcher-polling.md create mode 100644 docs/plans/azure-devops-integration/task-26-watcher-dispatch.md create mode 100644 docs/plans/azure-devops-integration/task-27-work-item-detail-ui.md create mode 100644 docs/plans/azure-devops-integration/task-28-automation-settings.md create mode 100644 docs/plans/azure-devops-integration/task-29-enhancement-validation.md diff --git a/apps/backend/internal/azuredevops/board_models.go b/apps/backend/internal/azuredevops/board_models.go new file mode 100644 index 0000000000..17d16770f0 --- /dev/null +++ b/apps/backend/internal/azuredevops/board_models.go @@ -0,0 +1,90 @@ +package azuredevops + +import "context" + +const ( + assignCurrentUserAction = "assign_current_user" + unassignAction = "unassign" +) + +// Team is an Azure DevOps team that owns one or more boards. +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName,omitempty"` +} + +// BoardReference identifies a board available to a team. +type BoardReference struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type BoardColumn struct { + ID string `json:"id"` + Name string `json:"name"` + ColumnType string `json:"columnType,omitempty"` + Description string `json:"description,omitempty"` + IsSplit bool `json:"isSplit,omitempty"` + ItemLimit int `json:"itemLimit,omitempty"` + StateMappings map[string]string `json:"stateMappings,omitempty"` +} + +type FieldReference struct { + ReferenceName string `json:"referenceName"` +} + +type BoardFields struct { + ColumnField FieldReference `json:"columnField"` + DoneField FieldReference `json:"doneField"` + RowField FieldReference `json:"rowField"` +} + +type BoardRow struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` +} + +type Board struct { + ID string `json:"id"` + Name string `json:"name"` + Columns []BoardColumn `json:"columns"` + Fields BoardFields `json:"fields"` + Rows []BoardRow `json:"rows,omitempty"` +} + +type BoardWorkItem struct { + WorkItem + ColumnID string `json:"columnId"` + ColumnDone bool `json:"columnDone"` +} + +type BoardSnapshot struct { + Board Board `json:"board"` + Items []BoardWorkItem `json:"items"` +} + +// BoardReader is the optional Azure Boards read surface. It is kept separate +// from Client so existing integrations and test doubles remain source +// compatible while board support rolls out. +type BoardReader interface { + ListTeams(ctx context.Context, projectID string) ([]Team, error) + ListBoards(ctx context.Context, projectID, teamID string) ([]BoardReference, error) + GetBoardSnapshot(ctx context.Context, projectID, teamID, boardID string) (*BoardSnapshot, error) +} + +type BoardWorkItemUpdateRequest struct { + Revision int `json:"revision"` + AssigneeAction *string `json:"assigneeAction,omitempty"` + ColumnID *string `json:"columnId,omitempty"` + ColumnDone *bool `json:"columnDone,omitempty"` + + resolvedAssignee string + hasResolvedAssignee bool +} + +type BoardWriter interface { + UpdateBoardWorkItem(ctx context.Context, projectID, teamID, boardID string, id int, request BoardWorkItemUpdateRequest) (*BoardWorkItem, error) +} diff --git a/apps/backend/internal/azuredevops/client_models.go b/apps/backend/internal/azuredevops/client_models.go index 77b64fa578..4ea7738e1f 100644 --- a/apps/backend/internal/azuredevops/client_models.go +++ b/apps/backend/internal/azuredevops/client_models.go @@ -46,6 +46,7 @@ type WorkItem struct { Project string `json:"project,omitempty"` AreaPath string `json:"areaPath,omitempty"` AssignedTo string `json:"assignedTo,omitempty"` + Tags []string `json:"tags,omitempty"` WebURL string `json:"webUrl,omitempty"` APIURL string `json:"apiUrl,omitempty"` Fields map[string]any `json:"fields,omitempty"` @@ -56,6 +57,38 @@ type WorkItemSearchResult struct { Count int `json:"count"` } +// PlanningField is one supported Azure planning value normalized for the +// work-item detail surface. Values stay strings because Azure returns either +// numeric or string values depending on the process template. +type PlanningField struct { + ReferenceName string `json:"referenceName"` + Label string `json:"label"` + Value string `json:"value"` +} + +// WorkItemDetail is the full read-only projection used by the Azure work-item +// detail surface. WorkItem remains embedded so existing card fields retain +// their response shape. +type WorkItemDetail struct { + WorkItem + PlanningFields []PlanningField `json:"planningFields"` +} + +// WorkItemComment is the safe subset of an Azure Boards discussion comment. +type WorkItemComment struct { + ID int `json:"id"` + Content string `json:"content"` + Author Identity `json:"author"` + PublishedAt *time.Time `json:"publishedAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// WorkItemCommentPage carries Azure's opaque continuation token verbatim. +type WorkItemCommentPage struct { + Comments []WorkItemComment `json:"comments"` + ContinuationToken string `json:"continuationToken,omitempty"` +} + type PullRequestFilter struct { ProjectID string RepositoryID string diff --git a/apps/backend/internal/azuredevops/client_models_test.go b/apps/backend/internal/azuredevops/client_models_test.go new file mode 100644 index 0000000000..2f2a28bafb --- /dev/null +++ b/apps/backend/internal/azuredevops/client_models_test.go @@ -0,0 +1,23 @@ +package azuredevops + +import ( + "encoding/json" + "testing" +) + +func TestWorkItemCommentOmitsAbsentTimestamps(t *testing.T) { + payload, err := json.Marshal(WorkItemComment{ID: 1, Content: "No timestamps"}) + if err != nil { + t.Fatalf("marshal work item comment: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + t.Fatalf("unmarshal work item comment: %v", err) + } + if _, found := fields["publishedAt"]; found { + t.Fatalf("publishedAt serialized for absent timestamp: %s", payload) + } + if _, found := fields["updatedAt"]; found { + t.Fatalf("updatedAt serialized for absent timestamp: %s", payload) + } +} diff --git a/apps/backend/internal/azuredevops/controller.go b/apps/backend/internal/azuredevops/controller.go index 88af2db175..2837d236b8 100644 --- a/apps/backend/internal/azuredevops/controller.go +++ b/apps/backend/internal/azuredevops/controller.go @@ -2,6 +2,7 @@ package azuredevops import ( "context" + "encoding/json" "errors" "net/http" "strconv" @@ -28,11 +29,36 @@ func RegisterRoutes(router *gin.Engine, service *Service, log *logger.Logger) { api.POST("/config/copy", controller.copyConfig) api.GET("/views", controller.getSavedViews) api.PUT("/views", controller.setSavedViews) + api.GET("/workspace-settings", controller.getWorkspaceSettings) + api.PATCH("/workspace-settings", controller.updateWorkspaceSettings) api.GET("/projects", controller.listProjects) + api.GET("/teams", controller.listTeams) + api.GET("/boards", controller.listBoards) + api.GET("/boards/:boardId", controller.getBoardSnapshot) + api.PATCH("/boards/:boardId/work-items/:id", controller.updateBoardWorkItem) api.GET("/repositories", controller.listRepositories) api.GET("/branches", controller.listBranches) api.POST("/work-items/search", controller.searchWorkItems) + api.GET("/work-items/:id/comments", controller.listWorkItemComments) + api.PATCH("/work-items/:id", controller.updateWorkItem) api.GET("/work-items/:id", controller.getWorkItem) + api.GET("/identity", controller.getIdentity) + api.GET("/watches/work-items", controller.listWorkItemWatches) + api.POST("/watches/work-items", controller.createWorkItemWatch) + api.PATCH("/watches/work-items/:id", controller.updateWorkItemWatch) + api.DELETE("/watches/work-items/:id", controller.deleteWorkItemWatch) + api.POST("/watches/work-items/:id/trigger", controller.triggerWorkItemWatch) + api.GET("/watches/work-items/:id/reset/preview", controller.previewResetWorkItemWatch) + api.POST("/watches/work-items/:id/reset", controller.resetWorkItemWatch) + api.GET("/watches/pull-requests", controller.listPullRequestWatches) + api.POST("/watches/pull-requests", controller.createPullRequestWatch) + api.PATCH("/watches/pull-requests/:id", controller.updatePullRequestWatch) + api.DELETE("/watches/pull-requests/:id", controller.deletePullRequestWatch) + api.POST("/watches/pull-requests/:id/trigger", controller.triggerPullRequestWatch) + api.GET("/watches/pull-requests/:id/reset/preview", controller.previewResetPullRequestWatch) + api.POST("/watches/pull-requests/:id/reset", controller.resetPullRequestWatch) + api.GET("/workspaces/:workspaceId/task-work-items", controller.listWorkspaceTaskWorkItems) + api.POST("/tasks/:taskId/work-items", controller.associateTaskWorkItem) api.GET("/pull-requests", controller.listPullRequests) api.GET("/pull-requests/:projectId/:repositoryId/:pullRequestId", controller.getPullRequest) api.GET("/pull-requests/:projectId/:repositoryId/:pullRequestId/feedback", controller.getPullRequestFeedback) @@ -41,6 +67,30 @@ func RegisterRoutes(router *gin.Engine, service *Service, log *logger.Logger) { api.POST("/tasks/:taskId/pull-requests/sync", controller.syncTaskPR) } +func (c *Controller) getWorkspaceSettings(ctx *gin.Context) { + settings, err := c.service.GetWorkspaceSettings(ctx.Request.Context(), workspaceID(ctx)) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, settings) +} + +func (c *Controller) updateWorkspaceSettings(ctx *gin.Context) { + var request UpdateWorkspaceSettingsRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + request.WorkspaceID = workspaceID(ctx) + settings, err := c.service.UpdateWorkspaceSettings(ctx.Request.Context(), &request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, settings) +} + func (c *Controller) getSavedViews(ctx *gin.Context) { views, err := c.service.GetSavedViewsForWorkspace(ctx.Request.Context(), workspaceID(ctx)) if err != nil { @@ -144,6 +194,54 @@ func (c *Controller) listProjects(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"projects": projects}) } +func (c *Controller) listTeams(ctx *gin.Context) { + teams, err := c.service.ListTeamsForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"teams": teams}) +} + +func (c *Controller) listBoards(ctx *gin.Context) { + boards, err := c.service.ListBoardsForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), ctx.Query("team")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"boards": boards}) +} + +func (c *Controller) getBoardSnapshot(ctx *gin.Context) { + snapshot, err := c.service.GetBoardSnapshotForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), ctx.Query("team"), ctx.Param("boardId")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, snapshot) +} + +func (c *Controller) updateBoardWorkItem(ctx *gin.Context) { + id, err := strconv.Atoi(ctx.Param("id")) + if err != nil || id <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"}) + return + } + var request BoardWorkItemUpdateRequest + decoder := json.NewDecoder(ctx.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + item, err := c.service.UpdateBoardWorkItemForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), ctx.Query("team"), ctx.Param("boardId"), id, request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, item) +} + func (c *Controller) listRepositories(ctx *gin.Context) { repositories, err := c.service.ListRepositoriesForWorkspace( ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), @@ -193,7 +291,7 @@ func (c *Controller) getWorkItem(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"}) return } - item, err := c.service.GetWorkItemForWorkspace( + item, err := c.service.GetWorkItemDetailForWorkspace( ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), id, ) if err != nil { @@ -203,6 +301,198 @@ func (c *Controller) getWorkItem(ctx *gin.Context) { ctx.JSON(http.StatusOK, item) } +func (c *Controller) updateWorkItem(ctx *gin.Context) { + id, err := strconv.Atoi(ctx.Param("id")) + if err != nil || id <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"}) + return + } + var request WorkItemAssignmentRequest + decoder := json.NewDecoder(ctx.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + item, err := c.service.UpdateWorkItemForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), id, request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, item) +} + +func (c *Controller) listWorkItemComments(ctx *gin.Context) { + id, err := strconv.Atoi(ctx.Param("id")) + if err != nil || id <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"}) + return + } + page, err := c.service.ListWorkItemCommentsForWorkspace( + ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), id, ctx.Query("continuation_token"), + ) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, page) +} + +func (c *Controller) getIdentity(ctx *gin.Context) { + identity, err := c.service.GetCurrentIdentityForWorkspace(ctx.Request.Context(), workspaceID(ctx)) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, identity) +} + +func (c *Controller) listWorkItemWatches(ctx *gin.Context) { + watches, err := c.service.ListWorkItemWatches(ctx.Request.Context(), workspaceID(ctx)) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) createWorkItemWatch(ctx *gin.Context) { + var request CreateWorkItemWatchRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + request.WorkspaceID = workspaceID(ctx) + watch, err := c.service.CreateWorkItemWatch(ctx.Request.Context(), &request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, watch) +} + +func (c *Controller) updateWorkItemWatch(ctx *gin.Context) { + var request UpdateWorkItemWatchRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + watch, err := c.service.UpdateWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"), &request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, watch) +} + +func (c *Controller) deleteWorkItemWatch(ctx *gin.Context) { + if err := c.service.DeleteWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")); err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) triggerWorkItemWatch(ctx *gin.Context) { + result, err := c.service.TriggerWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) previewResetWorkItemWatch(ctx *gin.Context) { + count, err := c.service.PreviewResetWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"taskCount": count}) +} + +func (c *Controller) resetWorkItemWatch(ctx *gin.Context) { + result, err := c.service.ResetWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"generation": result.Generation, "taskCount": len(result.TaskIDs)}) +} + +func (c *Controller) listPullRequestWatches(ctx *gin.Context) { + watches, err := c.service.ListPullRequestWatches(ctx.Request.Context(), workspaceID(ctx)) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) createPullRequestWatch(ctx *gin.Context) { + var request CreatePullRequestWatchRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + request.WorkspaceID = workspaceID(ctx) + watch, err := c.service.CreatePullRequestWatch(ctx.Request.Context(), &request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, watch) +} + +func (c *Controller) updatePullRequestWatch(ctx *gin.Context) { + var request UpdatePullRequestWatchRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + watch, err := c.service.UpdatePullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"), &request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, watch) +} + +func (c *Controller) deletePullRequestWatch(ctx *gin.Context) { + if err := c.service.DeletePullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")); err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) triggerPullRequestWatch(ctx *gin.Context) { + result, err := c.service.TriggerPullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) previewResetPullRequestWatch(ctx *gin.Context) { + count, err := c.service.PreviewResetPullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"taskCount": count}) +} + +func (c *Controller) resetPullRequestWatch(ctx *gin.Context) { + result, err := c.service.ResetPullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"generation": result.Generation, "taskCount": len(result.TaskIDs)}) +} + func (c *Controller) listPullRequests(ctx *gin.Context) { skip, _ := strconv.Atoi(ctx.Query("skip")) top, _ := strconv.Atoi(ctx.Query("top")) @@ -249,6 +539,34 @@ type taskPRRequest struct { PullRequestID int `json:"pullRequestId" binding:"required"` } +type taskWorkItemRequest struct { + ProjectID string `json:"projectId" binding:"required"` + WorkItemID int `json:"workItemId" binding:"required"` +} + +func (c *Controller) listWorkspaceTaskWorkItems(ctx *gin.Context) { + rows, err := c.service.ListTaskWorkItemsByWorkspace(ctx.Request.Context(), ctx.Param("workspaceId")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, TaskWorkItemsResponse{TaskWorkItems: rows}) +} + +func (c *Controller) associateTaskWorkItem(ctx *gin.Context) { + var request taskWorkItemRequest + if err := ctx.ShouldBindJSON(&request); err != nil || request.WorkItemID <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "projectId and positive workItemId are required"}) + return + } + row, err := c.service.AssociateTaskWorkItem(ctx.Request.Context(), workspaceID(ctx), ctx.Param("taskId"), request.ProjectID, request.WorkItemID) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, row) +} + func (c *Controller) listWorkspaceTaskPRs(ctx *gin.Context) { rows, err := c.service.ListTaskPRsByWorkspace(ctx.Request.Context(), ctx.Param("workspaceId")) if err != nil { @@ -293,12 +611,20 @@ func (c *Controller) writeError(ctx *gin.Context, err error) { case errors.Is(err, ErrInvalidWorkspaceID), errors.Is(err, ErrInvalidConfig), errors.Is(err, ErrInvalidTaskPRAssociation): status = http.StatusBadRequest + case errors.Is(err, ErrWatchNotFound), errors.Is(err, ErrReservationNotFound): + status = http.StatusNotFound + case errors.Is(err, ErrWatchOwnershipLost): + status = http.StatusConflict case errors.Is(err, ErrNotConfigured): status, code = http.StatusServiceUnavailable, notConfiguredCode default: var apiErr *APIError if errors.As(err, &apiErr) { - status = upstreamStatus(apiErr.StatusCode) + if apiErr.StatusCode == http.StatusConflict { + status, code = http.StatusConflict, "azure_devops_revision_conflict" + } else { + status = upstreamStatus(apiErr.StatusCode) + } } } body := gin.H{"error": err.Error()} diff --git a/apps/backend/internal/azuredevops/controller_test.go b/apps/backend/internal/azuredevops/controller_test.go index 14c031569f..e7767cf4c9 100644 --- a/apps/backend/internal/azuredevops/controller_test.go +++ b/apps/backend/internal/azuredevops/controller_test.go @@ -21,21 +21,65 @@ type fakeReadClient struct { threads []Thread refs []WorkItemRef policies []PolicyEvaluation + teams []Team + boards []BoardReference + snapshot *BoardSnapshot + updated *BoardWorkItem + assigned *WorkItem + assignment *WorkItemAssignmentRequest + detail *WorkItemDetail + comments *WorkItemCommentPage + identity *Identity lastFilter PullRequestFilter + prPage *PullRequestPage } func (f *fakeReadClient) ListPullRequests(_ context.Context, filter PullRequestFilter) (*PullRequestPage, error) { f.lastFilter = filter + if f.prPage != nil { + return f.prPage, nil + } return &PullRequestPage{}, nil } func (f *fakeReadClient) TestAuth(context.Context) (*TestConnectionResult, error) { return &TestConnectionResult{OK: true, ID: "me", DisplayName: "Ada"}, nil } -func (f *fakeReadClient) ListProjects(context.Context) ([]Project, error) { return f.projects, nil } +func (f *fakeReadClient) ListProjects(context.Context) ([]Project, error) { return f.projects, nil } +func (f *fakeReadClient) ListTeams(context.Context, string) ([]Team, error) { return f.teams, nil } +func (f *fakeReadClient) ListBoards(context.Context, string, string) ([]BoardReference, error) { + return f.boards, nil +} +func (f *fakeReadClient) GetBoardSnapshot(context.Context, string, string, string) (*BoardSnapshot, error) { + return f.snapshot, nil +} +func (f *fakeReadClient) UpdateBoardWorkItem(context.Context, string, string, string, int, BoardWorkItemUpdateRequest) (*BoardWorkItem, error) { + if f.updated != nil { + return f.updated, nil + } + return &BoardWorkItem{WorkItem: WorkItem{ID: 101, Revision: 8, Title: "Updated"}}, nil +} +func (f *fakeReadClient) UpdateWorkItem(_ context.Context, _ string, id int, request WorkItemAssignmentRequest) (*WorkItem, error) { + f.assignment = &request + if f.assigned != nil { + copy := *f.assigned + copy.ID = id + return ©, nil + } + return &WorkItem{ID: id, Revision: request.Revision + 1, AssignedTo: "Ada"}, nil +} func (f *fakeReadClient) QueryWIQL(context.Context, string, string, int) (*WorkItemSearchResult, error) { return f.work, nil } +func (f *fakeReadClient) GetWorkItemDetail(context.Context, string, int) (*WorkItemDetail, error) { + return f.detail, nil +} +func (f *fakeReadClient) ListWorkItemComments(context.Context, string, int, string) (*WorkItemCommentPage, error) { + return f.comments, nil +} +func (f *fakeReadClient) GetCurrentIdentity(context.Context) (*Identity, error) { + return f.identity, nil +} func (f *fakeReadClient) GetPullRequest(context.Context, string, string, int) (*PullRequest, error) { return f.pr, nil } @@ -68,6 +112,13 @@ func newControllerFixture(t *testing.T) (*gin.Engine, *Service) { threads: []Thread{{ID: 7, Status: "active"}}, refs: []WorkItemRef{{ID: 101}}, policies: []PolicyEvaluation{{ID: "p1", Status: "approved", IsBlocking: true}}, + teams: []Team{{ID: "team-1", Name: "Platform Team", ProjectID: "project-1"}}, + boards: []BoardReference{{ID: "board-1", Name: "Stories"}}, + snapshot: &BoardSnapshot{Board: Board{ID: "board-1", Name: "Stories"}, Items: []BoardWorkItem{{WorkItem: WorkItem{ID: 101, Title: "Fix build"}}}}, + updated: &BoardWorkItem{WorkItem: WorkItem{ID: 101, Revision: 8, Title: "Updated"}}, + detail: &WorkItemDetail{WorkItem: WorkItem{ID: 101, Revision: 8, Title: "Fix build", Description: "Useful detail"}}, + comments: &WorkItemCommentPage{Comments: []WorkItemComment{{ID: 9, Content: "Looks good"}}}, + identity: &Identity{ID: "me", DisplayName: "Ada"}, } service := NewService(store, newFakeSecretStore(), func(*Config, string) Client { return client }, logger.Default()) if _, err := service.SetConfigForWorkspace(context.Background(), "ws-1", &SetConfigRequest{ @@ -80,6 +131,89 @@ func newControllerFixture(t *testing.T) (*gin.Engine, *Service) { return router, service } +func TestControllerGetWorkItemDetail(t *testing.T) { + router, _ := newControllerFixture(t) + response := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/work-items/101?workspace_id=ws-1&project=project-1", nil) + if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte("Useful detail")) { + t.Fatalf("detail response %d: %s", response.Code, response.Body.String()) + } +} + +func TestControllerListWorkItemComments(t *testing.T) { + router, _ := newControllerFixture(t) + response := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/work-items/101/comments?workspace_id=ws-1&project=project-1&continuation_token=opaque", nil) + if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte("Looks good")) { + t.Fatalf("comments response %d: %s", response.Code, response.Body.String()) + } +} + +func TestControllerGetIdentity(t *testing.T) { + router, _ := newControllerFixture(t) + response := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/identity?workspace_id=ws-1", nil) + if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte("Ada")) { + t.Fatalf("identity response %d: %s", response.Code, response.Body.String()) + } +} + +func TestControllerBoardReads(t *testing.T) { + router, _ := newControllerFixture(t) + teams := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/teams?workspace_id=ws-1&project=project-1", nil) + if teams.Code != http.StatusOK || !bytes.Contains(teams.Body.Bytes(), []byte("Platform Team")) { + t.Fatalf("teams response %d: %s", teams.Code, teams.Body.String()) + } + boards := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/boards?workspace_id=ws-1&project=project-1&team=team-1", nil) + if boards.Code != http.StatusOK || !bytes.Contains(boards.Body.Bytes(), []byte("Stories")) { + t.Fatalf("boards response %d: %s", boards.Code, boards.Body.String()) + } + snapshot := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/boards/board-1?workspace_id=ws-1&project=project-1&team=team-1", nil) + if snapshot.Code != http.StatusOK || !bytes.Contains(snapshot.Body.Bytes(), []byte("Fix build")) { + t.Fatalf("snapshot response %d: %s", snapshot.Code, snapshot.Body.String()) + } +} + +func TestControllerUpdateBoardWorkItem(t *testing.T) { + router, _ := newControllerFixture(t) + columnID := "column-active" + updated := performRequest(t, router, http.MethodPatch, "/api/v1/azure-devops/boards/board-1/work-items/101?workspace_id=ws-1&project=project-1&team=team-1", map[string]any{ + "revision": 7, "columnId": columnID, + }) + if updated.Code != http.StatusOK || !bytes.Contains(updated.Body.Bytes(), []byte("Updated")) { + t.Fatalf("updated response %d: %s", updated.Code, updated.Body.String()) + } +} + +func TestControllerRejectsUnsupportedBoardWorkItemFields(t *testing.T) { + router, _ := newControllerFixture(t) + updated := performRequest(t, router, http.MethodPatch, "/api/v1/azure-devops/boards/board-1/work-items/101?workspace_id=ws-1&project=project-1&team=team-1", map[string]any{ + "revision": 7, "title": "Cannot edit", + }) + if updated.Code != http.StatusBadRequest { + t.Fatalf("updated response %d: %s", updated.Code, updated.Body.String()) + } + unknown := performRequest(t, router, http.MethodPatch, "/api/v1/azure-devops/boards/board-1/work-items/101?workspace_id=ws-1&project=project-1&team=team-1", map[string]any{ + "revision": 7, "columnId": "column-active", "description": "Cannot edit", + }) + if unknown.Code != http.StatusBadRequest { + t.Fatalf("unknown-field response %d: %s", unknown.Code, unknown.Body.String()) + } +} + +func TestControllerUpdatesWorkItemAssignmentWithoutEditingFields(t *testing.T) { + router, _ := newControllerFixture(t) + updated := performRequest(t, router, http.MethodPatch, "/api/v1/azure-devops/work-items/101?workspace_id=ws-1&project=project-1", map[string]any{ + "revision": 7, "assigneeAction": assignCurrentUserAction, + }) + if updated.Code != http.StatusOK || !bytes.Contains(updated.Body.Bytes(), []byte("Ada")) { + t.Fatalf("assignment response %d: %s", updated.Code, updated.Body.String()) + } + invalid := performRequest(t, router, http.MethodPatch, "/api/v1/azure-devops/work-items/101?workspace_id=ws-1&project=project-1", map[string]any{ + "revision": 7, "title": "Cannot edit", + }) + if invalid.Code != http.StatusBadRequest { + t.Fatalf("unsupported field response %d: %s", invalid.Code, invalid.Body.String()) + } +} + func TestControllerConfigAndWorkspaceScopedReads(t *testing.T) { router, _ := newControllerFixture(t) @@ -99,6 +233,44 @@ func TestControllerConfigAndWorkspaceScopedReads(t *testing.T) { } } +func TestControllerWorkItemWatchCRUDAndReset(t *testing.T) { + router, _ := newControllerFixture(t) + path := "/api/v1/azure-devops/watches/work-items" + query := "?workspace_id=ws-1" + created := performRequest(t, router, http.MethodPost, path+query, map[string]any{ + "workflowId": "wf-1", "workflowStepId": "step-1", "projectId": "project-1", + "wiql": "SELECT [System.Id] FROM WorkItems", "repositoryId": "repo-1", + "baseBranch": "main", "agentProfileId": "agent-1", "executorProfileId": "executor-1", + "prompt": "Fix {{title}}", "pollIntervalSeconds": 1, + }) + if created.Code != http.StatusOK || !bytes.Contains(created.Body.Bytes(), []byte("project-1")) { + t.Fatalf("create watch response %d: %s", created.Code, created.Body.String()) + } + var watch WorkItemWatch + if err := json.Unmarshal(created.Body.Bytes(), &watch); err != nil { + t.Fatalf("decode watch: %v", err) + } + if watch.ID == "" || !watch.Enabled || watch.PollIntervalSeconds != minWatchPollIntervalSeconds { + t.Fatalf("created watch = %+v", watch) + } + listed := performRequest(t, router, http.MethodGet, path+query, nil) + if listed.Code != http.StatusOK || !bytes.Contains(listed.Body.Bytes(), []byte(watch.ID)) { + t.Fatalf("list watch response %d: %s", listed.Code, listed.Body.String()) + } + updated := performRequest(t, router, http.MethodPatch, path+"/"+watch.ID+query, map[string]any{"enabled": false}) + if updated.Code != http.StatusOK || bytes.Contains(updated.Body.Bytes(), []byte(`"enabled":true`)) { + t.Fatalf("update watch response %d: %s", updated.Code, updated.Body.String()) + } + preview := performRequest(t, router, http.MethodGet, path+"/"+watch.ID+"/reset/preview"+query, nil) + if preview.Code != http.StatusOK || !bytes.Contains(preview.Body.Bytes(), []byte(`"taskCount":0`)) { + t.Fatalf("reset preview response %d: %s", preview.Code, preview.Body.String()) + } + reset := performRequest(t, router, http.MethodPost, path+"/"+watch.ID+"/reset"+query, nil) + if reset.Code != http.StatusOK || !bytes.Contains(reset.Body.Bytes(), []byte(`"generation":2`)) { + t.Fatalf("reset response %d: %s", reset.Code, reset.Body.String()) + } +} + func TestControllerPersistsWorkspaceSavedViews(t *testing.T) { router, _ := newControllerFixture(t) path := "/api/v1/azure-devops/views?workspace_id=ws-1" diff --git a/apps/backend/internal/azuredevops/copy.go b/apps/backend/internal/azuredevops/copy.go index 3aa44f8738..eb3e43c84f 100644 --- a/apps/backend/internal/azuredevops/copy.go +++ b/apps/backend/internal/azuredevops/copy.go @@ -24,6 +24,12 @@ func (s *Service) CopyConfigToWorkspace( if err := validateWorkspaceID(targetWorkspaceID); err != nil { return nil, err } + if err := s.authorizeWorkspaceAccess(ctx, sourceWorkspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, targetWorkspaceID); err != nil { + return nil, err + } if sourceWorkspaceID == targetWorkspaceID { return nil, ErrSameWorkspace } diff --git a/apps/backend/internal/azuredevops/lifecycle.go b/apps/backend/internal/azuredevops/lifecycle.go index fceb9d3717..8ca74395e2 100644 --- a/apps/backend/internal/azuredevops/lifecycle.go +++ b/apps/backend/internal/azuredevops/lifecycle.go @@ -46,6 +46,15 @@ func (s *Service) handleTaskDeleted(ctx context.Context, event *bus.Event) error if err := s.store.DeleteTaskPRsByTask(ctx, taskID); err != nil { return fmt.Errorf("delete Azure DevOps task PRs for task %q: %w", taskID, err) } + if err := s.store.DeleteTaskWorkItemsByTask(ctx, taskID); err != nil { + return fmt.Errorf("delete Azure DevOps task work items for task %q: %w", taskID, err) + } + if err := s.store.DeleteWorkItemWatchTasksByTask(ctx, taskID); err != nil { + return fmt.Errorf("delete Azure DevOps work-item watch reservations for task %q: %w", taskID, err) + } + if err := s.store.DeletePullRequestWatchTasksByTask(ctx, taskID); err != nil { + return fmt.Errorf("delete Azure DevOps pull-request watch reservations for task %q: %w", taskID, err) + } return nil } @@ -54,6 +63,12 @@ func (s *Service) handleWorkspaceDeleted(ctx context.Context, event *bus.Event) if workspaceID == "" { return nil } + if err := s.store.DeleteTaskWorkItemsByWorkspace(ctx, workspaceID); err != nil { + return fmt.Errorf("delete Azure DevOps task work items for workspace %q: %w", workspaceID, err) + } + if err := s.store.DeleteWatchesByWorkspace(ctx, workspaceID); err != nil { + return fmt.Errorf("delete Azure DevOps watches for workspace %q: %w", workspaceID, err) + } if err := s.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { return fmt.Errorf("delete Azure DevOps config for workspace %q: %w", workspaceID, err) } diff --git a/apps/backend/internal/azuredevops/mock_client.go b/apps/backend/internal/azuredevops/mock_client.go index 1d5492df6f..985350448f 100644 --- a/apps/backend/internal/azuredevops/mock_client.go +++ b/apps/backend/internal/azuredevops/mock_client.go @@ -2,6 +2,7 @@ package azuredevops import ( "context" + "errors" "fmt" "strings" "sync" @@ -9,13 +10,17 @@ import ( // MockState is the deterministic E2E data exposed by MockClient. type MockState struct { - Authenticated bool `json:"authenticated"` - User TestConnectionResult `json:"user"` - Projects []Project `json:"projects"` - Repositories []Repository `json:"repositories"` - WorkItems []WorkItem `json:"workItems"` - PullRequests []PullRequest `json:"pullRequests"` - Feedback map[int]PullRequestFeedback `json:"feedback"` + Authenticated bool `json:"authenticated"` + User TestConnectionResult `json:"user"` + Projects []Project `json:"projects"` + Teams []Team `json:"teams"` + Boards []BoardReference `json:"boards"` + BoardSnapshots map[string]BoardSnapshot `json:"boardSnapshots"` + Repositories []Repository `json:"repositories"` + WorkItems []WorkItem `json:"workItems"` + WorkItemComments map[int][]WorkItemComment `json:"workItemComments"` + PullRequests []PullRequest `json:"pullRequests"` + Feedback map[int]PullRequestFeedback `json:"feedback"` } // MockClient implements Client with in-memory state for browser tests. @@ -36,6 +41,12 @@ func (c *MockClient) Seed(state MockState) { if state.Feedback == nil { state.Feedback = make(map[int]PullRequestFeedback) } + if state.BoardSnapshots == nil { + state.BoardSnapshots = make(map[string]BoardSnapshot) + } + if state.WorkItemComments == nil { + state.WorkItemComments = make(map[int][]WorkItemComment) + } c.state = state } @@ -59,6 +70,202 @@ func (c *MockClient) ListProjects(context.Context) ([]Project, error) { return c.snapshot().Projects, nil } +func (c *MockClient) ListTeams(_ context.Context, projectID string) ([]Team, error) { + items := make([]Team, 0) + for _, team := range c.snapshot().Teams { + if projectID == "" || team.ProjectID == "" || team.ProjectID == projectID { + items = append(items, team) + } + } + return items, nil +} + +func (c *MockClient) ListBoards(context.Context, string, string) ([]BoardReference, error) { + return c.snapshot().Boards, nil +} + +func (c *MockClient) GetBoardSnapshot(_ context.Context, _, _, boardID string) (*BoardSnapshot, error) { + c.mu.RLock() + defer c.mu.RUnlock() + snapshot, ok := c.state.BoardSnapshots[boardID] + if !ok { + return nil, fmt.Errorf("azure devops mock board %q not found", boardID) + } + result := cloneMockBoardSnapshot(snapshot) + return &result, nil +} + +func cloneMockBoardSnapshot(snapshot BoardSnapshot) BoardSnapshot { + clone := snapshot + clone.Board.Columns = append([]BoardColumn(nil), snapshot.Board.Columns...) + clone.Board.Rows = append([]BoardRow(nil), snapshot.Board.Rows...) + clone.Items = make([]BoardWorkItem, len(snapshot.Items)) + for index, item := range snapshot.Items { + clone.Items[index] = cloneMockBoardWorkItem(item) + } + return clone +} + +func cloneMockBoardWorkItem(item BoardWorkItem) BoardWorkItem { + clone := item + clone.Tags = append([]string(nil), item.Tags...) + if item.Fields != nil { + clone.Fields = make(map[string]any, len(item.Fields)) + for key, value := range item.Fields { + clone.Fields[key] = cloneMockFieldValue(value) + } + } + return clone +} + +func cloneMockFieldValue(value any) any { + switch typed := value.(type) { + case map[string]any: + clone := make(map[string]any, len(typed)) + for key, nested := range typed { + clone[key] = cloneMockFieldValue(nested) + } + return clone + case []any: + clone := make([]any, len(typed)) + for index, nested := range typed { + clone[index] = cloneMockFieldValue(nested) + } + return clone + case map[string]string: + clone := make(map[string]string, len(typed)) + for key, nested := range typed { + clone[key] = nested + } + return clone + case []string: + return append([]string(nil), typed...) + default: + return value + } +} + +func (c *MockClient) UpdateBoardWorkItem(_ context.Context, _, _, boardID string, id int, request BoardWorkItemUpdateRequest) (*BoardWorkItem, error) { + if err := validateBoardWorkItemUpdate(request); err != nil { + return nil, err + } + c.mu.Lock() + defer c.mu.Unlock() + if request.AssigneeAction != nil && !request.hasResolvedAssignee { + request.hasResolvedAssignee = true + if *request.AssigneeAction == assignCurrentUserAction { + request.resolvedAssignee = strings.TrimSpace(c.state.User.Email) + if request.resolvedAssignee == "" { + request.resolvedAssignee = c.state.User.DisplayName + } + } + } + snapshot, ok := c.state.BoardSnapshots[boardID] + if !ok { + return nil, fmt.Errorf("azure devops mock board %q not found", boardID) + } + for index := range snapshot.Items { + item := &snapshot.Items[index] + if item.ID != id { + continue + } + if item.Revision != request.Revision { + return nil, &APIError{StatusCode: 409, Endpoint: "mock board update", Body: "revision conflict"} + } + if err := applyMockBoardWorkItemUpdate(item, snapshot.Board, request); err != nil { + return nil, err + } + item.Revision++ + snapshot.Items[index] = *item + c.state.BoardSnapshots[boardID] = snapshot + for workItemIndex := range c.state.WorkItems { + if c.state.WorkItems[workItemIndex].ID == id { + c.state.WorkItems[workItemIndex] = item.WorkItem + break + } + } + copy := cloneMockBoardWorkItem(*item) + return ©, nil + } + return nil, mockNotFound("work item", id) +} + +func (c *MockClient) UpdateWorkItem(_ context.Context, _ string, id int, request WorkItemAssignmentRequest) (*WorkItem, error) { + if err := validateWorkItemAssignment(request); err != nil { + return nil, err + } + c.mu.Lock() + defer c.mu.Unlock() + if request.AssigneeAction != nil && *request.AssigneeAction == assignCurrentUserAction && !request.hasResolvedAssignee { + request.hasResolvedAssignee = true + request.resolvedAssignee = strings.TrimSpace(c.state.User.Email) + if request.resolvedAssignee == "" { + request.resolvedAssignee = c.state.User.DisplayName + } + } + for index := range c.state.WorkItems { + item := &c.state.WorkItems[index] + if item.ID != id { + continue + } + if item.Revision != request.Revision { + return nil, &APIError{StatusCode: 409, Endpoint: "mock work item update", Body: "revision conflict"} + } + if request.hasResolvedAssignee { + item.AssignedTo = request.resolvedAssignee + } + item.Revision++ + for boardID, snapshot := range c.state.BoardSnapshots { + for itemIndex := range snapshot.Items { + if snapshot.Items[itemIndex].ID == id { + snapshot.Items[itemIndex].AssignedTo = item.AssignedTo + snapshot.Items[itemIndex].Revision = item.Revision + } + } + c.state.BoardSnapshots[boardID] = snapshot + } + copy := *item + copy.Tags = append([]string(nil), item.Tags...) + return ©, nil + } + return nil, mockNotFound("work item", id) +} + +func applyMockBoardWorkItemUpdate(item *BoardWorkItem, board Board, request BoardWorkItemUpdateRequest) error { + if request.hasResolvedAssignee { + item.AssignedTo = request.resolvedAssignee + } + if request.ColumnID != nil { + column, ok := boardColumnByID(board.Columns, *request.ColumnID) + if !ok { + return fmt.Errorf("%w: unknown board column", ErrInvalidConfig) + } + item.ColumnID = column.ID + mockBoardFields(item)[board.Fields.ColumnField.ReferenceName] = column.Name + } + if request.ColumnDone != nil { + item.ColumnDone = *request.ColumnDone + mockBoardFields(item)[board.Fields.DoneField.ReferenceName] = *request.ColumnDone + } + return nil +} + +func boardColumnByID(columns []BoardColumn, id string) (BoardColumn, bool) { + for _, column := range columns { + if column.ID == id { + return column, true + } + } + return BoardColumn{}, false +} + +func mockBoardFields(item *BoardWorkItem) map[string]any { + if item.Fields == nil { + item.Fields = make(map[string]any) + } + return item.Fields +} + func (c *MockClient) ListRepositories(_ context.Context, projectID string) ([]Repository, error) { state := c.snapshot() items := make([]Repository, 0) @@ -104,6 +311,29 @@ func (c *MockClient) GetWorkItem(_ context.Context, _ string, id int) (*WorkItem return nil, mockNotFound("work item", id) } +func (c *MockClient) GetWorkItemDetail(ctx context.Context, projectID string, id int) (*WorkItemDetail, error) { + item, err := c.GetWorkItem(ctx, projectID, id) + if err != nil { + return nil, err + } + detail := &WorkItemDetail{WorkItem: *item, PlanningFields: planningFields(item.Fields)} + detail.Description = sanitizeDescriptionHTML(detail.Description) + return detail, nil +} + +func (c *MockClient) ListWorkItemComments(_ context.Context, _ string, id int, _ string) (*WorkItemCommentPage, error) { + comments := append([]WorkItemComment(nil), c.snapshot().WorkItemComments[id]...) + return &WorkItemCommentPage{Comments: comments}, nil +} + +func (c *MockClient) GetCurrentIdentity(context.Context) (*Identity, error) { + state := c.snapshot() + if !state.Authenticated { + return nil, errors.New("azure devops mock is not authenticated") + } + return &Identity{ID: state.User.ID, DisplayName: state.User.DisplayName, UniqueName: state.User.Email}, nil +} + func (c *MockClient) ListPullRequests(_ context.Context, filter PullRequestFilter) (*PullRequestPage, error) { items := make([]PullRequest, 0) for _, pr := range c.snapshot().PullRequests { diff --git a/apps/backend/internal/azuredevops/mock_client_test.go b/apps/backend/internal/azuredevops/mock_client_test.go index cf25c8a6b2..d82f872b36 100644 --- a/apps/backend/internal/azuredevops/mock_client_test.go +++ b/apps/backend/internal/azuredevops/mock_client_test.go @@ -29,6 +29,32 @@ func TestMockClientSeedsWorkspaceReadState(t *testing.T) { } } +func TestMockClientProvidesWorkItemDetailDiscussionAndIdentity(t *testing.T) { + mock := NewMockClient() + mock.Seed(MockState{ + Authenticated: true, + User: TestConnectionResult{OK: true, ID: "me", DisplayName: "Ada", Email: "ada@example.com"}, + WorkItems: []WorkItem{{ + ID: 101, Description: "

Useful

", + Fields: map[string]any{"Microsoft.VSTS.Scheduling.Effort": 5}, + }}, + WorkItemComments: map[int][]WorkItemComment{101: {{ID: 2, Content: "Newest"}, {ID: 1, Content: "Older"}}}, + }) + + detail, err := mock.GetWorkItemDetail(t.Context(), "p1", 101) + if err != nil || detail.Description != "Useful" || len(detail.PlanningFields) != 1 { + t.Fatalf("detail = %+v, %v", detail, err) + } + comments, err := mock.ListWorkItemComments(t.Context(), "p1", 101, "opaque") + if err != nil || len(comments.Comments) != 2 || comments.Comments[0].ID != 2 { + t.Fatalf("comments = %+v, %v", comments, err) + } + identity, err := mock.GetCurrentIdentity(t.Context()) + if err != nil || identity.ID != "me" || identity.UniqueName != "ada@example.com" { + t.Fatalf("identity = %+v, %v", identity, err) + } +} + func TestMockClientPaginatesPullRequests(t *testing.T) { mock := NewMockClient() mock.Seed(MockState{PullRequests: []PullRequest{ @@ -46,3 +72,113 @@ func TestMockClientPaginatesPullRequests(t *testing.T) { t.Fatalf("items = %#v, want second pull request only", page.Items) } } + +func TestMockClientBoardReads(t *testing.T) { + mock := NewMockClient() + mock.Seed(MockState{ + Teams: []Team{{ID: "team-1", Name: "Platform Team", ProjectID: "p1"}}, + Boards: []BoardReference{{ID: "board-1", Name: "Stories"}}, + BoardSnapshots: map[string]BoardSnapshot{ + "board-1": { + Board: Board{ + ID: "board-1", + Columns: []BoardColumn{{ID: "todo", Name: "Original column"}}, + Rows: []BoardRow{{ID: "row-1", Name: "Original row"}}, + }, + Items: []BoardWorkItem{{ + WorkItem: WorkItem{ + ID: 101, + Fields: map[string]any{ + "System.Title": "Original", + "Custom.Nested": map[string]any{"labels": []any{"initial-label"}}, + }, + Tags: []string{"initial"}, + }, + }}, + }, + }, + }) + teams, err := mock.ListTeams(t.Context(), "p1") + if err != nil || len(teams) != 1 { + t.Fatalf("teams = %+v, %v", teams, err) + } + boards, err := mock.ListBoards(t.Context(), "p1", "team-1") + if err != nil || len(boards) != 1 || boards[0].ID != "board-1" { + t.Fatalf("boards = %+v, %v", boards, err) + } + snapshot, err := mock.GetBoardSnapshot(t.Context(), "p1", "team-1", "board-1") + if err != nil || snapshot == nil || len(snapshot.Items) != 1 { + t.Fatalf("snapshot = %+v, %v", snapshot, err) + } + snapshot.Items[0].Title = "Mutated caller copy" + snapshot.Items[0].Fields["System.Title"] = "Mutated field" + snapshot.Items[0].Tags[0] = "mutated-tag" + snapshot.Board.Columns[0].Name = "Mutated column" + snapshot.Board.Rows[0].Name = "Mutated row" + snapshot.Items[0].Fields["Custom.Nested"].(map[string]any)["labels"].([]any)[0] = "mutated-label" + again, err := mock.GetBoardSnapshot(t.Context(), "p1", "team-1", "board-1") + if err != nil || again.Items[0].Title == "Mutated caller copy" { + t.Fatalf("subsequent snapshot = %+v, %v", again, err) + } + if again.Items[0].Fields["System.Title"] != "Original" || again.Items[0].Tags[0] != "initial" { + t.Fatalf("subsequent snapshot mutable data = %+v", again.Items[0]) + } + if again.Board.Columns[0].Name != "Original column" || again.Board.Rows[0].Name != "Original row" { + t.Fatalf("subsequent snapshot board data = %+v", again.Board) + } + if got := again.Items[0].Fields["Custom.Nested"].(map[string]any)["labels"].([]any)[0]; got != "initial-label" { + t.Fatalf("subsequent snapshot nested field = %q, want initial-label", got) + } +} + +func TestMockClientUpdateBoardWorkItem(t *testing.T) { + mock := NewMockClient() + action := "assign_current_user" + mock.Seed(MockState{ + Authenticated: true, + User: TestConnectionResult{OK: true, ID: "me", DisplayName: "Ada"}, + BoardSnapshots: map[string]BoardSnapshot{"board-1": {Board: Board{ID: "board-1"}, Items: []BoardWorkItem{{WorkItem: WorkItem{ID: 101, Revision: 7, Title: "Original"}}}}}, + }) + item, err := mock.UpdateBoardWorkItem(t.Context(), "p1", "team-1", "board-1", 101, BoardWorkItemUpdateRequest{Revision: 7, AssigneeAction: &action}) + if err != nil || item == nil || item.AssignedTo != "Ada" || item.Revision != 8 { + t.Fatalf("updated item = %+v, %v", item, err) + } +} + +func TestMockClientUpdateBoardWorkItemInitializesFieldsForDoneOnlyUpdate(t *testing.T) { + mock := NewMockClient() + done := true + mock.Seed(MockState{ + BoardSnapshots: map[string]BoardSnapshot{"board-1": { + Board: Board{ID: "board-1", Fields: BoardFields{DoneField: FieldReference{ReferenceName: "System.BoardDone"}}}, + Items: []BoardWorkItem{{WorkItem: WorkItem{ID: 101, Revision: 7}}}, + }}, + }) + + item, err := mock.UpdateBoardWorkItem(t.Context(), "p1", "team-1", "board-1", 101, BoardWorkItemUpdateRequest{Revision: 7, ColumnDone: &done}) + if err != nil || item == nil || !item.ColumnDone || item.Fields["System.BoardDone"] != true { + t.Fatalf("updated item = %+v, %v", item, err) + } +} + +func TestMockClientBoardUpdateKeepsWorkItemReadsInSync(t *testing.T) { + mock := NewMockClient() + action := "assign_current_user" + mock.Seed(MockState{ + Authenticated: true, + User: TestConnectionResult{OK: true, ID: "me", DisplayName: "Ada"}, + BoardSnapshots: map[string]BoardSnapshot{"board-1": { + Board: Board{ID: "board-1"}, + Items: []BoardWorkItem{{WorkItem: WorkItem{ID: 101, Revision: 7, Title: "Original"}}}, + }}, + WorkItems: []WorkItem{{ID: 101, Revision: 7, Title: "Original"}}, + }) + + if _, err := mock.UpdateBoardWorkItem(t.Context(), "p1", "team-1", "board-1", 101, BoardWorkItemUpdateRequest{Revision: 7, AssigneeAction: &action}); err != nil { + t.Fatalf("update board item: %v", err) + } + item, err := mock.GetWorkItem(t.Context(), "p1", 101) + if err != nil || item.AssignedTo != "Ada" || item.Revision != 8 { + t.Fatalf("work item = %+v, %v", item, err) + } +} diff --git a/apps/backend/internal/azuredevops/models.go b/apps/backend/internal/azuredevops/models.go index 1dc8888063..5547e82246 100644 --- a/apps/backend/internal/azuredevops/models.go +++ b/apps/backend/internal/azuredevops/models.go @@ -89,6 +89,27 @@ type TaskPRsResponse struct { TaskPRs map[string][]*TaskPR `json:"taskPrs"` } +// TaskWorkItem is the persisted Azure Boards summary associated with a Kandev +// task. The provider remains authoritative; this record only supports browse +// and quick-action linkage. +type TaskWorkItem struct { + ID string `json:"id" db:"id"` + TaskID string `json:"taskId" db:"task_id"` + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + ProjectID string `json:"projectId" db:"project_id"` + WorkItemID int `json:"workItemId" db:"work_item_id"` + WorkItemURL string `json:"workItemUrl" db:"work_item_url"` + Title string `json:"title" db:"title"` + State string `json:"state" db:"state"` + Type string `json:"type" db:"type"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +type TaskWorkItemsResponse struct { + TaskWorkItems map[string][]*TaskWorkItem `json:"taskWorkItems"` +} + // SecretKeyForWorkspace returns the workspace-isolated encrypted PAT key. func SecretKeyForWorkspace(workspaceID string) string { return cloneauth.AzureDevOpsPATKey(workspaceID) diff --git a/apps/backend/internal/azuredevops/poller.go b/apps/backend/internal/azuredevops/poller.go index aab69311bb..6cb3363ddd 100644 --- a/apps/backend/internal/azuredevops/poller.go +++ b/apps/backend/internal/azuredevops/poller.go @@ -2,15 +2,166 @@ package azuredevops import ( "context" + "sync" + "time" + + "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/integrations/healthpoll" ) -// NewPoller returns the shared auth-health poller for Azure DevOps. Task PR -// refresh remains request-driven in v1. -func NewPoller(service *Service, log *logger.Logger) *healthpoll.Poller { - return healthpoll.New("azure devops", authProber{service: service}, log) +const watcherPollTick = time.Minute + +// Poller combines the shared auth-health poller with Azure watcher checks. +type Poller struct { + service *Service + logger *logger.Logger + auth *healthpoll.Poller + + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + started bool +} + +func NewPoller(service *Service, log *logger.Logger) *Poller { + if service == nil { + return nil + } + if log == nil { + log = logger.Default() + } + return &Poller{service: service, logger: log, auth: healthpoll.New("azure devops", authProber{service: service}, log)} +} + +func (p *Poller) Start(ctx context.Context) { + if p == nil { + return + } + p.mu.Lock() + if p.started { + p.mu.Unlock() + return + } + p.started = true + watchCtx, cancel := context.WithCancel(ctx) + p.cancel = cancel + p.mu.Unlock() + p.auth.Start(watchCtx) + p.wg.Add(1) + go p.watchLoop(watchCtx) +} + +func (p *Poller) Stop() { + if p == nil { + return + } + p.mu.Lock() + if !p.started { + p.mu.Unlock() + return + } + cancel := p.cancel + p.started = false + p.cancel = nil + p.mu.Unlock() + if cancel != nil { + cancel() + } + p.auth.Stop() + p.wg.Wait() +} + +func (p *Poller) watchLoop(ctx context.Context) { + defer p.wg.Done() + p.runWatchChecks(ctx) + ticker := time.NewTicker(watcherPollTick) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.runWatchChecks(ctx) + } + } +} + +func (p *Poller) runWatchChecks(ctx context.Context) int { + matched := 0 + now := time.Now().UTC() + workWatches, err := p.service.store.ListEnabledWorkItemWatches(ctx) + if err != nil { + p.logger.Warn("azure devops watcher poll: list work-item watches", zap.Error(err)) + } else { + for _, watch := range workWatches { + if !watchDue(watch.LastPolledAt, watch.PollIntervalSeconds, now) { + continue + } + result, checkErr := p.service.TriggerWorkItemWatch(ctx, watch.WorkspaceID, watch.ID) + if checkErr != nil { + p.logger.Warn("azure devops watcher poll: work-item check failed", zap.String("watch_id", watch.ID), zap.Error(checkErr)) + continue + } + matched += result.Matched + if cleaned := p.service.cleanupWorkItemWatchForPoll(ctx, watch.ID); cleaned > 0 { + p.logger.Info("azure devops watcher poll: cleaned terminal work-item tasks", zap.String("watch_id", watch.ID), zap.Int("tasks", cleaned)) + } + } + } + prWatches, err := p.service.store.ListEnabledPullRequestWatches(ctx) + if err != nil { + p.logger.Warn("azure devops watcher poll: list pull-request watches", zap.Error(err)) + } else { + for _, watch := range prWatches { + if !watchDue(watch.LastPolledAt, watch.PollIntervalSeconds, now) { + continue + } + result, checkErr := p.service.TriggerPullRequestWatch(ctx, watch.WorkspaceID, watch.ID) + if checkErr != nil { + p.logger.Warn("azure devops watcher poll: pull-request check failed", zap.String("watch_id", watch.ID), zap.Error(checkErr)) + continue + } + matched += result.Matched + if cleaned := p.service.cleanupPullRequestWatchForPoll(ctx, watch.ID); cleaned > 0 { + p.logger.Info("azure devops watcher poll: cleaned terminal pull-request tasks", zap.String("watch_id", watch.ID), zap.Int("tasks", cleaned)) + } + } + } + return matched +} + +func (s *Service) cleanupWorkItemWatchForPoll(ctx context.Context, watchID string) int { + watch, err := s.store.GetWorkItemWatch(ctx, watchID) + if err != nil || watch == nil || !watch.Enabled || watch.Deleting { + return 0 + } + client, err := s.clientForWorkspace(ctx, watch.WorkspaceID) + if err != nil { + return 0 + } + return s.cleanupWorkItemWatchWithClient(ctx, watch, client) +} + +func (s *Service) cleanupPullRequestWatchForPoll(ctx context.Context, watchID string) int { + watch, err := s.store.GetPullRequestWatch(ctx, watchID) + if err != nil || watch == nil || !watch.Enabled || watch.Deleting { + return 0 + } + client, err := s.clientForWorkspace(ctx, watch.WorkspaceID) + if err != nil { + return 0 + } + return s.cleanupPullRequestWatchWithClient(ctx, watch, client) +} + +func watchDue(lastPolledAt *time.Time, intervalSeconds int, now time.Time) bool { + if lastPolledAt == nil { + return true + } + interval := time.Duration(normalizePollInterval(intervalSeconds)) * time.Second + return !lastPolledAt.Add(interval).After(now) } type authProber struct { diff --git a/apps/backend/internal/azuredevops/provider.go b/apps/backend/internal/azuredevops/provider.go index 53473f1bb2..e3666d8ba6 100644 --- a/apps/backend/internal/azuredevops/provider.go +++ b/apps/backend/internal/azuredevops/provider.go @@ -17,7 +17,7 @@ func Provide( writer *sqlx.DB, reader *sqlx.DB, secrets SecretStore, - _ bus.EventBus, + eventBus bus.EventBus, log *logger.Logger, ) (*Service, func() error, error) { store, err := NewStore(writer, reader) @@ -31,6 +31,7 @@ func Provide( factory = func(*Config, string) Client { return mock } } service := NewService(store, secrets, factory, log) + service.SetEventBus(eventBus) service.mock = mock return service, func() error { return nil }, nil } diff --git a/apps/backend/internal/azuredevops/rest_client.go b/apps/backend/internal/azuredevops/rest_client.go index e9464435ad..add2138b4d 100644 --- a/apps/backend/internal/azuredevops/rest_client.go +++ b/apps/backend/internal/azuredevops/rest_client.go @@ -10,9 +10,12 @@ import ( "io" "net/http" "net/url" + "sort" "strconv" "strings" "time" + + "golang.org/x/net/html" ) const ( @@ -23,6 +26,7 @@ const ( workItemBatchSize = 200 branchResultLimit = 1000 defaultPRPageSize = 50 + workItemCommentLimit = 100 ) // APIError is a bounded, credential-redacted Azure DevOps response error. @@ -164,6 +168,296 @@ func (c *RESTClient) GetWorkItem(ctx context.Context, projectID string, id int) return &item, nil } +func (c *RESTClient) GetWorkItemDetail(ctx context.Context, projectID string, id int) (*WorkItemDetail, error) { + item, err := c.GetWorkItem(ctx, projectID, id) + if err != nil { + return nil, err + } + detail := &WorkItemDetail{WorkItem: *item, PlanningFields: planningFields(item.Fields)} + detail.Description = sanitizeDescriptionHTML(detail.Description) + return detail, nil +} + +func (c *RESTClient) ListWorkItemComments(ctx context.Context, projectID string, id int, continuationToken string) (*WorkItemCommentPage, error) { + query := url.Values{} + query.Set("$top", strconv.Itoa(workItemCommentLimit)) + query.Set("api-version", "7.1-preview.4") + if strings.TrimSpace(continuationToken) != "" { + query.Set("continuationToken", continuationToken) + } + endpoint := fmt.Sprintf("/%s/_apis/wit/workItems/%d/comments?%s", pathPart(projectID), id, query.Encode()) + var response struct { + Comments []struct { + ID int `json:"id"` + Text string `json:"text"` + IsDeleted bool `json:"isDeleted"` + CreatedDate *time.Time `json:"createdDate"` + ModifiedDate *time.Time `json:"modifiedDate"` + CreatedBy Identity `json:"createdBy"` + } `json:"comments"` + } + var headers http.Header + if err := c.doJSONWithResponseHeaders(ctx, http.MethodGet, endpoint, nil, &response, &headers); err != nil { + return nil, err + } + comments := make([]WorkItemComment, 0, len(response.Comments)) + for _, comment := range response.Comments { + if comment.IsDeleted { + continue + } + comments = append(comments, WorkItemComment{ + ID: comment.ID, Content: comment.Text, Author: comment.CreatedBy, + PublishedAt: comment.CreatedDate, UpdatedAt: comment.ModifiedDate, + }) + } + sort.SliceStable(comments, func(i, j int) bool { + if comments[i].PublishedAt == nil { + return false + } + if comments[j].PublishedAt == nil { + return true + } + return comments[i].PublishedAt.After(*comments[j].PublishedAt) + }) + return &WorkItemCommentPage{Comments: comments, ContinuationToken: headers.Get("x-ms-continuationtoken")}, nil +} + +func (c *RESTClient) GetCurrentIdentity(ctx context.Context) (*Identity, error) { + auth, err := c.TestAuth(ctx) + if err != nil { + return nil, err + } + return &Identity{ID: auth.ID, DisplayName: auth.DisplayName, UniqueName: auth.Email}, nil +} + +func (c *RESTClient) ListTeams(ctx context.Context, projectID string) ([]Team, error) { + var response struct { + Value []struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + } `json:"value"` + } + endpoint := fmt.Sprintf("/_apis/projects/%s/teams?api-version=%s", pathPart(projectID), restAPIVersion) + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + teams := make([]Team, 0, len(response.Value)) + for _, raw := range response.Value { + teams = append(teams, Team{ID: raw.ID, Name: raw.Name, ProjectID: raw.ProjectID, ProjectName: raw.ProjectName}) + } + return teams, nil +} + +func (c *RESTClient) ListBoards(ctx context.Context, projectID, teamID string) ([]BoardReference, error) { + var response struct { + Value []struct { + ID string `json:"id"` + Name string `json:"name"` + IsHidden bool `json:"isHidden"` + } `json:"value"` + } + endpoint := fmt.Sprintf("/%s/%s/_apis/work/backlogs?api-version=%s", pathPart(projectID), pathPart(teamID), restAPIVersion) + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + boards := make([]BoardReference, 0, len(response.Value)) + for _, raw := range response.Value { + if raw.IsHidden { + continue + } + boards = append(boards, BoardReference{ID: raw.ID, Name: raw.Name}) + } + return boards, nil +} + +func (c *RESTClient) GetBoardSnapshot(ctx context.Context, projectID, teamID, boardID string) (*BoardSnapshot, error) { + var rawBoard struct { + ID string `json:"id"` + Name string `json:"name"` + Columns []struct { + ID string `json:"id"` + Name string `json:"name"` + ColumnType string `json:"columnType"` + Description string `json:"description"` + IsSplit bool `json:"isSplit"` + ItemLimit int `json:"itemLimit"` + StateMappings map[string]string `json:"stateMappings"` + } `json:"columns"` + Fields struct { + ColumnField FieldReference `json:"columnField"` + DoneField FieldReference `json:"doneField"` + RowField FieldReference `json:"rowField"` + } `json:"fields"` + Rows []BoardRow `json:"rows"` + } + boardEndpoint := fmt.Sprintf("/%s/%s/_apis/work/boards/%s?api-version=%s", pathPart(projectID), pathPart(teamID), pathPart(boardID), restAPIVersion) + if err := c.doJSON(ctx, http.MethodGet, boardEndpoint, nil, &rawBoard); err != nil { + return nil, err + } + board := Board{ID: rawBoard.ID, Name: rawBoard.Name, Fields: rawBoard.Fields, Rows: rawBoard.Rows, Columns: make([]BoardColumn, 0, len(rawBoard.Columns))} + for _, column := range rawBoard.Columns { + board.Columns = append(board.Columns, BoardColumn{ID: column.ID, Name: column.Name, ColumnType: column.ColumnType, Description: column.Description, IsSplit: column.IsSplit, ItemLimit: column.ItemLimit, StateMappings: column.StateMappings}) + } + var refs struct { + Value []struct { + ID int `json:"id"` + Target struct { + ID int `json:"id"` + } `json:"target"` + } `json:"value"` + WorkItems []struct { + ID int `json:"id"` + Target struct { + ID int `json:"id"` + } `json:"target"` + } `json:"workItems"` + } + itemsEndpoint := fmt.Sprintf("/%s/%s/_apis/work/backlogs/%s/workItems?api-version=%s", pathPart(projectID), pathPart(teamID), pathPart(boardID), restAPIVersion) + if err := c.doJSON(ctx, http.MethodGet, itemsEndpoint, nil, &refs); err != nil { + return nil, err + } + ids := make([]int, 0, len(refs.Value)) + refsList := refs.Value + if len(refsList) == 0 { + refsList = refs.WorkItems + } + for _, ref := range refsList { + id := ref.ID + if id == 0 { + id = ref.Target.ID + } + if id > 0 { + ids = append(ids, id) + } + } + items, err := c.hydrateWorkItems(ctx, projectID, ids) + if err != nil { + return nil, err + } + result := make([]BoardWorkItem, 0, len(items)) + for _, item := range items { + columnID, done := boardItemPlacement(board, item) + result = append(result, BoardWorkItem{WorkItem: item, ColumnID: columnID, ColumnDone: done}) + } + return &BoardSnapshot{Board: board, Items: result}, nil +} + +func (c *RESTClient) UpdateBoardWorkItem(ctx context.Context, projectID, teamID, boardID string, id int, request BoardWorkItemUpdateRequest) (*BoardWorkItem, error) { + if request.Revision <= 0 { + return nil, fmt.Errorf("%w: revision required", ErrInvalidConfig) + } + var board struct { + Columns []BoardColumn `json:"columns"` + Fields BoardFields `json:"fields"` + } + boardEndpoint := fmt.Sprintf("/%s/%s/_apis/work/boards/%s?api-version=%s", pathPart(projectID), pathPart(teamID), pathPart(boardID), restAPIVersion) + if err := c.doJSON(ctx, http.MethodGet, boardEndpoint, nil, &board); err != nil { + return nil, err + } + patch, err := boardWorkItemPatch(board, request) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("/%s/_apis/wit/workitems/%d?api-version=%s", pathPart(projectID), id, restAPIVersion) + var raw rawWorkItem + if err := c.doJSONWithContentType(ctx, http.MethodPatch, endpoint, patch, &raw, "application/json-patch+json"); err != nil { + return nil, err + } + item := convertWorkItem(raw) + columnID, done := boardItemPlacement(Board{Columns: board.Columns, Fields: board.Fields}, item) + if columnID == "" && request.ColumnID != nil { + columnID = *request.ColumnID + } + if request.ColumnDone != nil { + done = *request.ColumnDone + } + return &BoardWorkItem{WorkItem: item, ColumnID: columnID, ColumnDone: done}, nil +} + +func (c *RESTClient) UpdateWorkItem(ctx context.Context, projectID string, id int, request WorkItemAssignmentRequest) (*WorkItem, error) { + patch, err := workItemAssignmentPatch(request) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("/%s/_apis/wit/workitems/%d?api-version=%s", pathPart(projectID), id, restAPIVersion) + var raw rawWorkItem + if err := c.doJSONWithContentType(ctx, http.MethodPatch, endpoint, patch, &raw, "application/json-patch+json"); err != nil { + return nil, err + } + item := convertWorkItem(raw) + return &item, nil +} + +func workItemAssignmentPatch(request WorkItemAssignmentRequest) ([]map[string]any, error) { + if err := validateWorkItemAssignment(request); err != nil { + return nil, err + } + if !request.hasResolvedAssignee { + return nil, fmt.Errorf("%w: assignee action must be resolved server-side", ErrInvalidConfig) + } + patch := []map[string]any{{"op": "test", "path": "/rev", "value": request.Revision}} + if request.resolvedAssignee == "" { + patch = append(patch, map[string]any{"op": "remove", "path": "/fields/System.AssignedTo"}) + } else { + patch = append(patch, map[string]any{"op": "add", "path": "/fields/System.AssignedTo", "value": request.resolvedAssignee}) + } + return patch, nil +} + +func boardWorkItemPatch(board struct { + Columns []BoardColumn `json:"columns"` + Fields BoardFields `json:"fields"` +}, request BoardWorkItemUpdateRequest) ([]map[string]any, error) { + if err := validateBoardWorkItemUpdate(request); err != nil { + return nil, err + } + if request.AssigneeAction != nil && !request.hasResolvedAssignee { + return nil, fmt.Errorf("%w: assignee action must be resolved server-side", ErrInvalidConfig) + } + patch := []map[string]any{{"op": "test", "path": "/rev", "value": request.Revision}} + appendField := func(path string, value any) { + patch = append(patch, map[string]any{"op": "replace", "path": path, "value": value}) + } + appendOptionalField := func(path, value string) { + if value == "" { + patch = append(patch, map[string]any{"op": "remove", "path": path}) + return + } + patch = append(patch, map[string]any{"op": "add", "path": path, "value": value}) + } + if request.hasResolvedAssignee { + appendOptionalField("/fields/System.AssignedTo", request.resolvedAssignee) + } + if request.ColumnID != nil { + columnName := "" + for _, column := range board.Columns { + if column.ID == *request.ColumnID { + columnName = column.Name + break + } + } + if columnName == "" { + return nil, fmt.Errorf("%w: unknown board column", ErrInvalidConfig) + } + if board.Fields.ColumnField.ReferenceName == "" { + return nil, errors.New("azure devops: board column field is unavailable") + } + appendField("/fields/"+board.Fields.ColumnField.ReferenceName, columnName) + } + if request.ColumnDone != nil { + if board.Fields.DoneField.ReferenceName == "" { + return nil, errors.New("azure devops: board done field is unavailable") + } + appendField("/fields/"+board.Fields.DoneField.ReferenceName, *request.ColumnDone) + } + if len(patch) == 1 { + return nil, fmt.Errorf("%w: at least one board field is required", ErrInvalidConfig) + } + return patch, nil +} + func (c *RESTClient) ListPullRequests(ctx context.Context, filter PullRequestFilter) (*PullRequestPage, error) { values := url.Values{"api-version": {restAPIVersion}} setPRFilter(values, "searchCriteria.status", filter.Status) @@ -311,6 +605,18 @@ func (c *RESTClient) getWorkItemBatch(ctx context.Context, projectID string, ids } func (c *RESTClient) doJSON(ctx context.Context, method, endpoint string, requestBody, responseBody any) error { + return c.doJSONWithContentType(ctx, method, endpoint, requestBody, responseBody, "application/json") +} + +func (c *RESTClient) doJSONWithResponseHeaders(ctx context.Context, method, endpoint string, requestBody, responseBody any, responseHeaders *http.Header) error { + return c.doJSONWithContentTypeAndResponseHeaders(ctx, method, endpoint, requestBody, responseBody, "application/json", responseHeaders) +} + +func (c *RESTClient) doJSONWithContentType(ctx context.Context, method, endpoint string, requestBody, responseBody any, contentType string) error { + return c.doJSONWithContentTypeAndResponseHeaders(ctx, method, endpoint, requestBody, responseBody, contentType, nil) +} + +func (c *RESTClient) doJSONWithContentTypeAndResponseHeaders(ctx context.Context, method, endpoint string, requestBody, responseBody any, contentType string, responseHeaders *http.Header) error { if c.initErr != nil { return c.initErr } @@ -330,7 +636,7 @@ func (c *RESTClient) doJSON(ctx context.Context, method, endpoint string, reques req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+c.pat))) if requestBody != nil { - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", contentType) } resp, err := c.httpClient.Do(req) if err != nil { @@ -340,6 +646,9 @@ func (c *RESTClient) doJSON(ctx context.Context, method, endpoint string, reques if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return c.decodeAPIError(resp, endpointPath(endpoint)) } + if responseHeaders != nil { + *responseHeaders = resp.Header.Clone() + } limited := io.LimitReader(resp.Body, maxResponseBodyBytes+1) data, err := io.ReadAll(limited) if err != nil { @@ -420,16 +729,94 @@ func convertRepository(raw rawRepository) Repository { } func convertWorkItem(raw rawWorkItem) WorkItem { + tags := make([]string, 0) + for _, tag := range strings.Split(stringField(raw.Fields, "System.Tags"), ";") { + if tag = strings.TrimSpace(tag); tag != "" { + tags = append(tags, tag) + } + } return WorkItem{ ID: raw.ID, Revision: raw.Rev, Title: stringField(raw.Fields, "System.Title"), Description: stringField(raw.Fields, "System.Description"), State: stringField(raw.Fields, "System.State"), Type: stringField(raw.Fields, "System.WorkItemType"), Project: stringField(raw.Fields, "System.TeamProject"), AreaPath: stringField(raw.Fields, "System.AreaPath"), - AssignedTo: identityDisplayField(raw.Fields, "System.AssignedTo"), WebURL: raw.Links.HTML.Href, + AssignedTo: identityDisplayField(raw.Fields, "System.AssignedTo"), Tags: tags, WebURL: raw.Links.HTML.Href, APIURL: raw.URL, Fields: raw.Fields, } } +var supportedPlanningFields = []struct { + ReferenceName string + Label string +}{ + {ReferenceName: "Microsoft.VSTS.Scheduling.Effort", Label: "Effort"}, + {ReferenceName: "Microsoft.VSTS.Scheduling.StoryPoints", Label: "Story points"}, + {ReferenceName: "Microsoft.VSTS.Scheduling.Size", Label: "Size"}, + {ReferenceName: "Microsoft.VSTS.Scheduling.RemainingWork", Label: "Remaining work"}, + {ReferenceName: "Microsoft.VSTS.Scheduling.OriginalEstimate", Label: "Original estimate"}, + {ReferenceName: "Microsoft.VSTS.Scheduling.CompletedWork", Label: "Completed work"}, +} + +func planningFields(fields map[string]any) []PlanningField { + result := make([]PlanningField, 0, len(supportedPlanningFields)) + for _, field := range supportedPlanningFields { + value, ok := fields[field.ReferenceName] + if !ok || value == nil { + continue + } + formatted := strings.TrimSpace(fmt.Sprint(value)) + if formatted == "" { + continue + } + result = append(result, PlanningField{ReferenceName: field.ReferenceName, Label: field.Label, Value: formatted}) + } + return result +} + +func sanitizeDescriptionHTML(value string) string { + node, err := html.Parse(strings.NewReader(value)) + if err != nil { + return "" + } + var text strings.Builder + var visit func(*html.Node) + visit = func(current *html.Node) { + if current.Type == html.ElementNode && (current.Data == "script" || current.Data == "style") { + return + } + if current.Type == html.TextNode { + text.WriteString(current.Data) + text.WriteByte(' ') + } + for child := current.FirstChild; child != nil; child = child.NextSibling { + visit(child) + } + } + visit(node) + return strings.Join(strings.Fields(text.String()), " ") +} + +func boardItemPlacement(board Board, item WorkItem) (string, bool) { + columnValue := stringField(item.Fields, board.Fields.ColumnField.ReferenceName) + columnID := "" + for _, column := range board.Columns { + if column.Name == columnValue { + columnID = column.ID + break + } + } + done := false + if raw, ok := item.Fields[board.Fields.DoneField.ReferenceName]; ok { + switch value := raw.(type) { + case bool: + done = value + case string: + done = strings.EqualFold(value, "true") + } + } + return columnID, done +} + func convertPullRequest(raw rawPullRequest) PullRequest { webURL := "" if raw.Repository.WebURL != "" && raw.PullRequestID > 0 { diff --git a/apps/backend/internal/azuredevops/rest_client_test.go b/apps/backend/internal/azuredevops/rest_client_test.go index 168cd706e5..99e418f70b 100644 --- a/apps/backend/internal/azuredevops/rest_client_test.go +++ b/apps/backend/internal/azuredevops/rest_client_test.go @@ -58,6 +58,200 @@ func TestRESTClientAuthAndDiscovery(t *testing.T) { } } +func TestRESTClientBoardReads(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/acme/_apis/projects/project-1/teams": + _, _ = w.Write([]byte(`{"value":[{"id":"team-1","name":"Platform Team","projectId":"project-1","projectName":"Platform"}]}`)) + case "/acme/project-1/team-1/_apis/work/backlogs": + _, _ = w.Write([]byte(`{"value":[{"id":"Microsoft.RequirementCategory","name":"Stories","isHidden":false},{"id":"Microsoft.TaskCategory","name":"Tasks","isHidden":true}]}`)) + case "/acme/project-1/team-1/_apis/work/boards/Microsoft.RequirementCategory": + _, _ = w.Write([]byte(`{"id":"board-1","name":"Stories","fields":{"columnField":{"referenceName":"System.BoardColumn"},"doneField":{"referenceName":"System.BoardColumnDone"}},"columns":[{"id":"column-todo","name":"To Do","columnType":"incoming","itemLimit":10},{"id":"column-active","name":"Active","columnType":"inProgress"},{"id":"column-done","name":"Done","columnType":"outgoing"}]}`)) + case "/acme/project-1/team-1/_apis/work/backlogs/Microsoft.RequirementCategory/workItems": + _, _ = w.Write([]byte(`{"workItems":[{"target":{"id":2}},{"target":{"id":1}},{"target":{"id":3}}]}`)) + case "/acme/project-1/_apis/wit/workitemsbatch": + _, _ = w.Write([]byte(`{"value":[{"id":1,"rev":4,"fields":{"System.Title":"First","System.WorkItemType":"User Story","System.AssignedTo":"Ada","System.Tags":"one; two","System.BoardColumn":"To Do","System.BoardColumnDone":false}},{"id":2,"rev":5,"fields":{"System.Title":"Second","System.WorkItemType":"Bug","System.BoardColumn":"Done","System.BoardColumnDone":true}}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := newTestRESTClient(t, server, "pat") + teams, err := client.ListTeams(context.Background(), "project-1") + if err != nil || len(teams) != 1 || teams[0].Name != "Platform Team" || teams[0].ProjectID != "project-1" || teams[0].ProjectName != "Platform" { + t.Fatalf("ListTeams = %+v, %v", teams, err) + } + boards, err := client.ListBoards(context.Background(), "project-1", "team-1") + if err != nil || len(boards) != 1 || boards[0].ID != "Microsoft.RequirementCategory" { + t.Fatalf("ListBoards = %+v, %v", boards, err) + } + snapshot, err := client.GetBoardSnapshot(context.Background(), "project-1", "team-1", boards[0].ID) + if err != nil { + t.Fatalf("GetBoardSnapshot: %v", err) + } + if len(snapshot.Board.Columns) != 3 || len(snapshot.Items) != 2 { + t.Fatalf("snapshot = %+v", snapshot) + } + if snapshot.Items[0].ID != 2 || snapshot.Items[0].ColumnID != "column-done" || !snapshot.Items[0].ColumnDone { + t.Fatalf("first board item = %+v", snapshot.Items[0]) + } + if snapshot.Items[1].ID != 1 || snapshot.Items[1].ColumnID != "column-todo" || len(snapshot.Items[1].Tags) != 2 { + t.Fatalf("second board item = %+v", snapshot.Items[1]) + } +} + +func TestRESTClientGetWorkItemDetail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/acme/project-1/_apis/wit/workitems/101" || r.URL.Query().Get("$expand") != "all" { + t.Fatalf("detail request = %s", r.URL.String()) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":101,"rev":8,"url":"https://api/work-items/101","_links":{"html":{"href":"https://dev.azure.com/acme/project/_workitems/edit/101"}},"fields":{"System.Title":"Ship detail","System.WorkItemType":"User Story","System.State":"Active","System.AssignedTo":{"displayName":"Ada"},"System.Tags":"ui; azure","System.Description":"

Useful detail

","Microsoft.VSTS.Scheduling.Effort":5,"Microsoft.VSTS.Scheduling.StoryPoints":"3","Microsoft.VSTS.Scheduling.RemainingWork":2.5}}`)) + })) + t.Cleanup(server.Close) + + detail, err := newTestRESTClient(t, server, "pat").GetWorkItemDetail(t.Context(), "project-1", 101) + if err != nil { + t.Fatalf("GetWorkItemDetail: %v", err) + } + if detail.ID != 101 || detail.Revision != 8 || detail.AssignedTo != "Ada" || detail.WebURL == "" { + t.Fatalf("detail core fields = %+v", detail) + } + if strings.Contains(detail.Description, "script") || !strings.Contains(detail.Description, "Useful detail") { + t.Fatalf("description = %q, want sanitized useful content", detail.Description) + } + if len(detail.PlanningFields) != 3 || detail.PlanningFields[0].ReferenceName != "Microsoft.VSTS.Scheduling.Effort" || detail.PlanningFields[2].Value != "2.5" { + t.Fatalf("planning fields = %+v", detail.PlanningFields) + } +} + +func TestRESTClientListWorkItemComments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/acme/project-1/_apis/wit/workItems/101/comments" || r.URL.Query().Get("continuationToken") != "opaque-token" || r.URL.Query().Get("$top") != "100" { + t.Fatalf("comments request = %s", r.URL.String()) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-ms-continuationtoken", "next-opaque-token") + _, _ = w.Write([]byte(`{"comments":[{"id":1,"text":"Older","createdDate":"2026-07-20T10:00:00Z","createdBy":{"id":"u1","displayName":"Ada"}},{"id":2,"text":"Deleted","isDeleted":true,"createdDate":"2026-07-21T10:00:00Z"},{"id":3,"text":"Newest","createdDate":"2026-07-22T10:00:00Z"}]}`)) + })) + t.Cleanup(server.Close) + + page, err := newTestRESTClient(t, server, "pat").ListWorkItemComments(t.Context(), "project-1", 101, "opaque-token") + if err != nil { + t.Fatalf("ListWorkItemComments: %v", err) + } + if page.ContinuationToken != "next-opaque-token" || len(page.Comments) != 2 || page.Comments[0].ID != 3 || page.Comments[1].Author.DisplayName != "Ada" { + t.Fatalf("comment page = %+v", page) + } +} + +func TestRESTClientGetCurrentIdentity(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/acme/_apis/connectionData" { + t.Fatalf("identity request = %s", r.URL.String()) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"authenticatedUser":{"id":"u1","providerDisplayName":"Ada","properties":{"Account":{"$value":"ada@example.com"}}}}`)) + })) + t.Cleanup(server.Close) + + identity, err := newTestRESTClient(t, server, "pat").GetCurrentIdentity(t.Context()) + if err != nil || identity.ID != "u1" || identity.DisplayName != "Ada" || identity.UniqueName != "ada@example.com" { + t.Fatalf("GetCurrentIdentity = %+v, %v", identity, err) + } +} + +func TestBoardWorkItemPatchAssignCurrentUser(t *testing.T) { + action := "assign_current_user" + patch, err := boardWorkItemPatch(struct { + Columns []BoardColumn `json:"columns"` + Fields BoardFields `json:"fields"` + }{}, BoardWorkItemUpdateRequest{Revision: 7, AssigneeAction: &action, resolvedAssignee: "ada@example.com", hasResolvedAssignee: true}) + if err != nil { + t.Fatalf("boardWorkItemPatch: %v", err) + } + if len(patch) != 2 || patch[0]["op"] != "test" || patch[0]["path"] != "/rev" || patch[1]["op"] != "add" || patch[1]["path"] != "/fields/System.AssignedTo" || patch[1]["value"] != "ada@example.com" { + t.Fatalf("patch = %#v", patch) + } +} + +func TestBoardWorkItemPatchUnassign(t *testing.T) { + action := "unassign" + patch, err := boardWorkItemPatch(struct { + Columns []BoardColumn `json:"columns"` + Fields BoardFields `json:"fields"` + }{}, BoardWorkItemUpdateRequest{Revision: 7, AssigneeAction: &action, hasResolvedAssignee: true}) + if err != nil || len(patch) != 2 || patch[1]["op"] != "remove" || patch[1]["path"] != "/fields/System.AssignedTo" { + t.Fatalf("patch = %#v, %v", patch, err) + } +} + +func TestBoardWorkItemPatchRejectsUnsupportedFields(t *testing.T) { + if _, err := boardWorkItemPatch(struct { + Columns []BoardColumn `json:"columns"` + Fields BoardFields `json:"fields"` + }{}, BoardWorkItemUpdateRequest{Revision: 7}); err == nil { + t.Fatal("boardWorkItemPatch accepted an unsupported empty mutation") + } +} + +func TestRESTClientUpdateBoardWorkItem(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/acme/project-1/team-1/_apis/work/boards/board-1": + _, _ = w.Write([]byte(`{"id":"board-1","name":"Stories","fields":{"columnField":{"referenceName":"System.BoardColumn"},"doneField":{"referenceName":"System.BoardColumnDone"}},"columns":[{"id":"column-active","name":"Active"}]}`)) + case "/acme/project-1/_apis/wit/workitems/101": + if r.Method != http.MethodPatch || r.Header.Get("Content-Type") != "application/json-patch+json" { + t.Fatalf("request method/content type = %s/%q", r.Method, r.Header.Get("Content-Type")) + } + var patch []map[string]any + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + t.Fatalf("decode patch: %v", err) + } + if len(patch) != 3 || patch[0]["op"] != "test" || patch[0]["path"] != "/rev" || patch[0]["value"] != float64(7) || patch[1]["path"] != "/fields/System.BoardColumn" || patch[2]["path"] != "/fields/System.BoardColumnDone" { + t.Fatalf("patch = %#v", patch) + } + _, _ = w.Write([]byte(`{"id":101,"rev":8,"fields":{"System.Title":"Updated","System.AssignedTo":"Grace","System.Tags":"one; two","System.BoardColumn":"Active","System.BoardColumnDone":true}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + client := newTestRESTClient(t, server, "pat") + column := "column-active" + done := true + item, err := client.UpdateBoardWorkItem(context.Background(), "project-1", "team-1", "board-1", 101, BoardWorkItemUpdateRequest{Revision: 7, ColumnID: &column, ColumnDone: &done}) + if err != nil || item == nil || item.Revision != 8 || item.ColumnID != "column-active" || !item.ColumnDone { + t.Fatalf("updated item = %+v, %v", item, err) + } +} + +func TestRESTClientUpdateWorkItemAssignment(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/acme/project-1/_apis/wit/workitems/101" || r.Method != http.MethodPatch { + t.Fatalf("assignment request = %s %s", r.Method, r.URL.Path) + } + var patch []map[string]any + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + t.Fatalf("decode patch: %v", err) + } + if len(patch) != 2 || patch[0]["path"] != "/rev" || patch[0]["value"] != float64(7) || patch[1]["path"] != "/fields/System.AssignedTo" || patch[1]["value"] != "ada@example.com" { + t.Fatalf("patch = %#v", patch) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":101,"rev":8,"fields":{"System.Title":"Fix build","System.AssignedTo":"Ada"}}`)) + })) + t.Cleanup(server.Close) + action := assignCurrentUserAction + item, err := newTestRESTClient(t, server, "pat").UpdateWorkItem(context.Background(), "project-1", 101, WorkItemAssignmentRequest{Revision: 7, AssigneeAction: &action, resolvedAssignee: "ada@example.com", hasResolvedAssignee: true}) + if err != nil || item == nil || item.Revision != 8 || item.AssignedTo != "Ada" { + t.Fatalf("updated item = %+v, %v", item, err) + } +} + func TestRESTClientQueryWIQLBatchesAndPreservesOrder(t *testing.T) { var mu sync.Mutex var batches [][]int diff --git a/apps/backend/internal/azuredevops/service.go b/apps/backend/internal/azuredevops/service.go index eb7f0f7afe..884dab0110 100644 --- a/apps/backend/internal/azuredevops/service.go +++ b/apps/backend/internal/azuredevops/service.go @@ -7,11 +7,14 @@ import ( "net/url" "regexp" "strings" + "sync" "time" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/watchreset" ) var ( @@ -40,17 +43,90 @@ type SecretStore interface { // Service coordinates workspace configuration, encrypted PATs, and auth // probes. type Service struct { - store *Store - secrets SecretStore - clientFn ClientFactory - log *logger.Logger - mock *MockClient - repoLookup RepositoryLookup + store *Store + secrets SecretStore + clientFn ClientFactory + log *logger.Logger + mock *MockClient + repoLookup RepositoryLookup + watchRepositoryLookup WatchRepositoryLookup + watchDependencyValidator WatchDependencyValidator + // workspaceAuthorizer is wired to the task service when per-user auth is + // enabled. A nil authorizer preserves the unscoped local-development path. + workspaceAuthorizer func(context.Context, string) error + eventBus bus.EventBus + mu sync.RWMutex + cascadeTaskDeleter watchreset.TaskDeleter + taskSessionChecker TaskSessionChecker +} + +// SetEventBus wires watcher events and preserves the nil-safe local test path. +func (s *Service) SetEventBus(eventBus bus.EventBus) { + if s != nil { + s.mu.Lock() + defer s.mu.Unlock() + s.eventBus = eventBus + } +} + +// SetCascadeTaskDeleter wires the shared task-tree cleanup used by watcher +// reset and delete operations. A nil deleter keeps persistence usable in +// local tests while production still clears reservations safely. +func (s *Service) SetCascadeTaskDeleter(deleter watchreset.TaskDeleter) { + if s != nil { + s.mu.Lock() + defer s.mu.Unlock() + s.cascadeTaskDeleter = deleter + } +} + +func (s *Service) getCascadeTaskDeleter() watchreset.TaskDeleter { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.cascadeTaskDeleter +} + +// SetTaskSessionChecker wires the user-engagement check used by the "auto" +// watcher cleanup policy. It is optional for local tests; production wires +// the same task repository adapter used by the other integrations. +func (s *Service) SetTaskSessionChecker(checker TaskSessionChecker) { + if s != nil { + s.mu.Lock() + defer s.mu.Unlock() + s.taskSessionChecker = checker + } +} + +func (s *Service) getTaskSessionChecker() TaskSessionChecker { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.taskSessionChecker } // MockClient returns the E2E mock when the provider is in mock mode. func (s *Service) MockClient() *MockClient { return s.mock } +// SetWorkspaceAuthorizer installs the per-user workspace access boundary for +// Azure DevOps configuration and provider operations. +func (s *Service) SetWorkspaceAuthorizer(authorizer func(context.Context, string) error) { + if s != nil { + s.workspaceAuthorizer = authorizer + } +} + +func (s *Service) authorizeWorkspaceAccess(ctx context.Context, workspaceID string) error { + if s == nil || s.workspaceAuthorizer == nil { + return nil + } + return s.workspaceAuthorizer(ctx, workspaceID) +} + // NewService constructs the Azure DevOps configuration service. func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *logger.Logger) *Service { if clientFn == nil { @@ -90,6 +166,9 @@ func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) if err := validateWorkspaceID(workspaceID); err != nil { return nil, err } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } cfg, err := s.store.GetConfig(ctx, workspaceID) if err != nil || cfg == nil || s.secrets == nil { return cfg, err @@ -111,6 +190,9 @@ func (s *Service) SetConfigForWorkspace( if err := validateWorkspaceID(workspaceID); err != nil { return nil, err } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } cfg, err := configFromRequest(workspaceID, req) if err != nil { return nil, err @@ -171,6 +253,7 @@ func (s *Service) finishConfigUpdate( if err := s.store.ResetAuthHealth(ctx, workspaceID); err != nil { return nil, fmt.Errorf("reset azure devops auth health: %w", err) } + s.RecordAuthHealthForWorkspace(ctx, workspaceID) } return s.GetConfigForWorkspace(ctx, workspaceID) } @@ -180,6 +263,9 @@ func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID stri if err := validateWorkspaceID(workspaceID); err != nil { return err } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return err + } previous, err := s.readStoredPAT(ctx, workspaceID) if err != nil { return err @@ -295,6 +381,9 @@ func (s *Service) resolveCredentials( if err := validateWorkspaceID(workspaceID); err != nil { return nil, "", err } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, "", err + } if req == nil { req = &SetConfigRequest{} } diff --git a/apps/backend/internal/azuredevops/service_board.go b/apps/backend/internal/azuredevops/service_board.go new file mode 100644 index 0000000000..2034760d03 --- /dev/null +++ b/apps/backend/internal/azuredevops/service_board.go @@ -0,0 +1,109 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" + "strings" +) + +func (s *Service) boardReaderForWorkspace(ctx context.Context, workspaceID string) (BoardReader, error) { + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + reader, ok := client.(BoardReader) + if !ok { + return nil, fmt.Errorf("%w: board listing is unavailable", ErrNotConfigured) + } + return reader, nil +} + +func (s *Service) ListTeamsForWorkspace(ctx context.Context, workspaceID, projectID string) ([]Team, error) { + if strings.TrimSpace(projectID) == "" { + return nil, fmt.Errorf("%w: project required", ErrInvalidConfig) + } + reader, err := s.boardReaderForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return reader.ListTeams(ctx, projectID) +} + +func (s *Service) ListBoardsForWorkspace(ctx context.Context, workspaceID, projectID, teamID string) ([]BoardReference, error) { + if strings.TrimSpace(projectID) == "" || strings.TrimSpace(teamID) == "" { + return nil, fmt.Errorf("%w: project and team required", ErrInvalidConfig) + } + reader, err := s.boardReaderForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return reader.ListBoards(ctx, projectID, teamID) +} + +func (s *Service) GetBoardSnapshotForWorkspace(ctx context.Context, workspaceID, projectID, teamID, boardID string) (*BoardSnapshot, error) { + if strings.TrimSpace(projectID) == "" || strings.TrimSpace(teamID) == "" || strings.TrimSpace(boardID) == "" { + return nil, fmt.Errorf("%w: project, team, and board required", ErrInvalidConfig) + } + reader, err := s.boardReaderForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return reader.GetBoardSnapshot(ctx, projectID, teamID, boardID) +} + +func (s *Service) UpdateBoardWorkItemForWorkspace(ctx context.Context, workspaceID, projectID, teamID, boardID string, id int, request BoardWorkItemUpdateRequest) (*BoardWorkItem, error) { + if strings.TrimSpace(projectID) == "" || strings.TrimSpace(teamID) == "" || strings.TrimSpace(boardID) == "" || id <= 0 { + return nil, fmt.Errorf("%w: project, team, board, and positive work item id required", ErrInvalidConfig) + } + if err := validateBoardWorkItemUpdate(request); err != nil { + return nil, err + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + writer, ok := client.(BoardWriter) + if !ok { + return nil, fmt.Errorf("%w: board updates are unavailable", ErrNotConfigured) + } + if request.AssigneeAction != nil { + switch *request.AssigneeAction { + case unassignAction: + request.hasResolvedAssignee = true + case assignCurrentUserAction: + identityReader, available := client.(interface { + GetCurrentIdentity(context.Context) (*Identity, error) + }) + if !available { + return nil, fmt.Errorf("%w: current identity is unavailable", ErrNotConfigured) + } + identity, identityErr := identityReader.GetCurrentIdentity(ctx) + if identityErr != nil { + return nil, identityErr + } + assignee := strings.TrimSpace(identity.UniqueName) + if assignee == "" { + assignee = strings.TrimSpace(identity.DisplayName) + } + if assignee == "" { + return nil, errors.New("azure devops: current identity has no assignable name") + } + request.resolvedAssignee, request.hasResolvedAssignee = assignee, true + } + } + return writer.UpdateBoardWorkItem(ctx, projectID, teamID, boardID, id, request) +} + +func validateBoardWorkItemUpdate(request BoardWorkItemUpdateRequest) error { + if request.Revision <= 0 { + return fmt.Errorf("%w: revision required", ErrInvalidConfig) + } + if request.AssigneeAction != nil && *request.AssigneeAction != assignCurrentUserAction && *request.AssigneeAction != unassignAction { + return fmt.Errorf("%w: unsupported assignee action", ErrInvalidConfig) + } + if request.AssigneeAction == nil && request.ColumnID == nil && request.ColumnDone == nil { + return fmt.Errorf("%w: an assignee action, column, or split state is required", ErrInvalidConfig) + } + return nil +} diff --git a/apps/backend/internal/azuredevops/service_board_test.go b/apps/backend/internal/azuredevops/service_board_test.go new file mode 100644 index 0000000000..b4ede9fe25 --- /dev/null +++ b/apps/backend/internal/azuredevops/service_board_test.go @@ -0,0 +1,126 @@ +package azuredevops + +import ( + "context" + "errors" + "testing" +) + +type fakeBoardReader struct { + teams []Team + boards []BoardReference + snapshot *BoardSnapshot +} + +func TestServiceBoardReadsAuthorizeWorkspaceBeforeCreatingClient(t *testing.T) { + clientCalls := 0 + service, _, _ := newTestService(t, func(*Config, string) Client { + clientCalls++ + return &invalidClient{} + }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + clientCalls = 0 + denied := errors.New("workspace denied") + service.SetWorkspaceAuthorizer(func(context.Context, string) error { return denied }) + + if _, err := service.ListTeamsForWorkspace(t.Context(), "ws-1", "project-1"); !errors.Is(err, denied) { + t.Fatalf("ListTeamsForWorkspace error = %v, want authorization denial", err) + } + if clientCalls != 0 { + t.Fatalf("client factory calls = %d, want 0", clientCalls) + } +} + +func TestServiceBoardCapabilitiesReportNotConfigured(t *testing.T) { + service, _, _ := newTestService(t, func(*Config, string) Client { return &invalidClient{} }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + if _, err := service.ListTeamsForWorkspace(t.Context(), "ws-1", "project-1"); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("ListTeamsForWorkspace error = %v, want ErrNotConfigured", err) + } + column := "column-1" + if _, err := service.UpdateBoardWorkItemForWorkspace(t.Context(), "ws-1", "project-1", "team-1", "board-1", 101, BoardWorkItemUpdateRequest{Revision: 7, ColumnID: &column}); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("UpdateBoardWorkItemForWorkspace error = %v, want ErrNotConfigured", err) + } +} + +type fakeBoardWriter struct { + *fakeBoardReader + request BoardWorkItemUpdateRequest +} + +func (f *fakeBoardWriter) GetCurrentIdentity(context.Context) (*Identity, error) { + return &Identity{ID: "me", DisplayName: "Ada"}, nil +} + +func (f *fakeBoardWriter) UpdateBoardWorkItem(_ context.Context, _, _, _ string, _ int, request BoardWorkItemUpdateRequest) (*BoardWorkItem, error) { + f.request = request + return &BoardWorkItem{WorkItem: WorkItem{ID: 101, Revision: request.Revision + 1, Title: "Updated"}}, nil +} + +func (f *fakeBoardReader) ListTeams(context.Context, string) ([]Team, error) { + return f.teams, nil +} +func (f *fakeBoardReader) ListBoards(context.Context, string, string) ([]BoardReference, error) { + return f.boards, nil +} +func (f *fakeBoardReader) GetBoardSnapshot(context.Context, string, string, string) (*BoardSnapshot, error) { + return f.snapshot, nil +} + +func TestServiceBoardReads(t *testing.T) { + reader := &fakeBoardReader{ + teams: []Team{{ID: "team-1", Name: "Platform Team", ProjectID: "project-1"}}, + boards: []BoardReference{{ID: "board-1", Name: "Stories"}}, + snapshot: &BoardSnapshot{Board: Board{ID: "board-1", Name: "Stories"}, Items: []BoardWorkItem{{WorkItem: WorkItem{ID: 101, Title: "Fix build"}}}}, + } + service, _, _ := newTestService(t, func(*Config, string) Client { + return &struct { + invalidClient + *fakeBoardReader + }{fakeBoardReader: reader} + }) + if _, err := service.SetConfigForWorkspace(context.Background(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + teams, err := service.ListTeamsForWorkspace(context.Background(), "ws-1", "project-1") + if err != nil || len(teams) != 1 || teams[0].ID != "team-1" { + t.Fatalf("ListTeamsForWorkspace = %+v, %v", teams, err) + } + boards, err := service.ListBoardsForWorkspace(context.Background(), "ws-1", "project-1", "team-1") + if err != nil || len(boards) != 1 || boards[0].ID != "board-1" { + t.Fatalf("ListBoardsForWorkspace = %+v, %v", boards, err) + } + snapshot, err := service.GetBoardSnapshotForWorkspace(context.Background(), "ws-1", "project-1", "team-1", "board-1") + if err != nil || snapshot == nil || len(snapshot.Items) != 1 { + t.Fatalf("GetBoardSnapshotForWorkspace = %+v, %v", snapshot, err) + } +} + +func TestServiceUpdateBoardWorkItemIdentity(t *testing.T) { + reader := &fakeBoardReader{snapshot: &BoardSnapshot{Board: Board{ID: "board-1"}}} + writer := &fakeBoardWriter{fakeBoardReader: reader} + service, _, _ := newTestService(t, func(*Config, string) Client { + return &struct { + invalidClient + *fakeBoardWriter + }{fakeBoardWriter: writer} + }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + assign := "assign_current_user" + item, err := service.UpdateBoardWorkItemForWorkspace(t.Context(), "ws-1", "project-1", "team-1", "board-1", 101, BoardWorkItemUpdateRequest{Revision: 7, AssigneeAction: &assign}) + if err != nil || item == nil || writer.request.Revision != 7 || writer.request.resolvedAssignee != "Ada" { + t.Fatalf("updated item = %+v, request=%+v, err=%v", item, writer.request, err) + } +} + +func TestServiceUpdateBoardWorkItemRejectsUnsupportedFields(t *testing.T) { + if err := validateBoardWorkItemUpdate(BoardWorkItemUpdateRequest{Revision: 7}); err == nil { + t.Fatal("validateBoardWorkItemUpdate accepted an empty mutation") + } +} diff --git a/apps/backend/internal/azuredevops/service_detail_test.go b/apps/backend/internal/azuredevops/service_detail_test.go new file mode 100644 index 0000000000..62a22948cb --- /dev/null +++ b/apps/backend/internal/azuredevops/service_detail_test.go @@ -0,0 +1,75 @@ +package azuredevops + +import ( + "context" + "errors" + "testing" +) + +type fakeDetailClient struct { + invalidClient + detail *WorkItemDetail + comments *WorkItemCommentPage + identity *Identity +} + +func (f *fakeDetailClient) TestAuth(context.Context) (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, ID: "me", DisplayName: "Ada"}, nil +} + +func (f *fakeDetailClient) GetWorkItemDetail(context.Context, string, int) (*WorkItemDetail, error) { + return f.detail, nil +} + +func (f *fakeDetailClient) ListWorkItemComments(context.Context, string, int, string) (*WorkItemCommentPage, error) { + return f.comments, nil +} + +func (f *fakeDetailClient) GetCurrentIdentity(context.Context) (*Identity, error) { + return f.identity, nil +} + +func TestServiceGetWorkItemDetail(t *testing.T) { + client := &fakeDetailClient{ + detail: &WorkItemDetail{WorkItem: WorkItem{ID: 101, Title: "Ship it"}}, + comments: &WorkItemCommentPage{Comments: []WorkItemComment{{ID: 9, Content: "Looks good"}}}, + identity: &Identity{ID: "me", DisplayName: "Ada"}, + } + service, _, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "pat", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + detail, err := service.GetWorkItemDetailForWorkspace(t.Context(), "ws-1", "project-1", 101) + if err != nil || detail.ID != 101 { + t.Fatalf("GetWorkItemDetailForWorkspace = %+v, %v", detail, err) + } + comments, err := service.ListWorkItemCommentsForWorkspace(t.Context(), "ws-1", "project-1", 101, "opaque") + if err != nil || len(comments.Comments) != 1 { + t.Fatalf("ListWorkItemCommentsForWorkspace = %+v, %v", comments, err) + } + identity, err := service.GetCurrentIdentityForWorkspace(t.Context(), "ws-1") + if err != nil || identity.ID != "me" { + t.Fatalf("GetCurrentIdentityForWorkspace = %+v, %v", identity, err) + } +} + +func TestServiceDetailCapabilitiesReportNotConfigured(t *testing.T) { + service, _, _ := newTestService(t, func(*Config, string) Client { return &invalidClient{} }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "pat", + }); err != nil { + t.Fatalf("set config: %v", err) + } + if _, err := service.GetWorkItemDetailForWorkspace(t.Context(), "ws-1", "project-1", 101); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("GetWorkItemDetailForWorkspace error = %v, want ErrNotConfigured", err) + } + if _, err := service.ListWorkItemCommentsForWorkspace(t.Context(), "ws-1", "project-1", 101, ""); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("ListWorkItemCommentsForWorkspace error = %v, want ErrNotConfigured", err) + } + if _, err := service.GetCurrentIdentityForWorkspace(t.Context(), "ws-1"); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("GetCurrentIdentityForWorkspace error = %v, want ErrNotConfigured", err) + } +} diff --git a/apps/backend/internal/azuredevops/service_reads.go b/apps/backend/internal/azuredevops/service_reads.go index 0aefef12c1..3c49a114c8 100644 --- a/apps/backend/internal/azuredevops/service_reads.go +++ b/apps/backend/internal/azuredevops/service_reads.go @@ -55,6 +55,12 @@ type branchClient interface { ListBranches(ctx context.Context, projectID, repositoryID string) ([]Branch, error) } +type workItemDetailClient interface { + GetWorkItemDetail(ctx context.Context, projectID string, id int) (*WorkItemDetail, error) + ListWorkItemComments(ctx context.Context, projectID string, id int, continuationToken string) (*WorkItemCommentPage, error) + GetCurrentIdentity(ctx context.Context) (*Identity, error) +} + func (s *Service) ListBranchesForWorkspace( ctx context.Context, workspaceID, organization, projectID, repositoryID string, ) ([]Branch, error) { @@ -105,6 +111,48 @@ func (s *Service) GetWorkItemForWorkspace(ctx context.Context, workspaceID, proj return client.GetWorkItem(ctx, projectID, id) } +func (s *Service) GetWorkItemDetailForWorkspace(ctx context.Context, workspaceID, projectID string, id int) (*WorkItemDetail, error) { + if strings.TrimSpace(projectID) == "" || id <= 0 { + return nil, fmt.Errorf("%w: project and positive work item id required", ErrInvalidConfig) + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + reader, ok := client.(workItemDetailClient) + if !ok { + return nil, fmt.Errorf("%w: work item detail is unavailable", ErrNotConfigured) + } + return reader.GetWorkItemDetail(ctx, projectID, id) +} + +func (s *Service) ListWorkItemCommentsForWorkspace(ctx context.Context, workspaceID, projectID string, id int, continuationToken string) (*WorkItemCommentPage, error) { + if strings.TrimSpace(projectID) == "" || id <= 0 { + return nil, fmt.Errorf("%w: project and positive work item id required", ErrInvalidConfig) + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + reader, ok := client.(workItemDetailClient) + if !ok { + return nil, fmt.Errorf("%w: work item discussion is unavailable", ErrNotConfigured) + } + return reader.ListWorkItemComments(ctx, projectID, id, continuationToken) +} + +func (s *Service) GetCurrentIdentityForWorkspace(ctx context.Context, workspaceID string) (*Identity, error) { + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + reader, ok := client.(workItemDetailClient) + if !ok { + return nil, fmt.Errorf("%w: current identity is unavailable", ErrNotConfigured) + } + return reader.GetCurrentIdentity(ctx) +} + func (s *Service) ListPullRequestsForWorkspace( ctx context.Context, workspaceID string, diff --git a/apps/backend/internal/azuredevops/service_task_work_item.go b/apps/backend/internal/azuredevops/service_task_work_item.go new file mode 100644 index 0000000000..341b3a530e --- /dev/null +++ b/apps/backend/internal/azuredevops/service_task_work_item.go @@ -0,0 +1,39 @@ +package azuredevops + +import ( + "context" + "fmt" + "strings" +) + +func (s *Service) AssociateTaskWorkItem(ctx context.Context, workspaceID, taskID, projectID string, workItemID int) (*TaskWorkItem, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + if strings.TrimSpace(taskID) == "" || strings.TrimSpace(projectID) == "" || workItemID <= 0 { + return nil, fmt.Errorf("%w: task, project, and positive work item ID are required", ErrInvalidTaskPRAssociation) + } + owned, err := s.store.TaskBelongsToWorkspace(ctx, taskID, workspaceID) + if err != nil || !owned { + return nil, fmt.Errorf("%w: task belongs to another workspace", ErrInvalidTaskPRAssociation) + } + detail, err := s.GetWorkItemDetailForWorkspace(ctx, workspaceID, projectID, workItemID) + if err != nil { + return nil, fmt.Errorf("validate Azure work item: %w", err) + } + if detail.Project != "" && detail.Project != projectID { + return nil, fmt.Errorf("%w: work item belongs to another project", ErrInvalidTaskPRAssociation) + } + row := &TaskWorkItem{TaskID: taskID, WorkspaceID: workspaceID, ProjectID: projectID, WorkItemID: detail.ID, WorkItemURL: detail.WebURL, Title: detail.Title, State: detail.State, Type: detail.Type} + if err := s.store.UpsertTaskWorkItem(ctx, row); err != nil { + return nil, fmt.Errorf("upsert Azure task work item: %w", err) + } + return row, nil +} + +func (s *Service) ListTaskWorkItemsByWorkspace(ctx context.Context, workspaceID string) (map[string][]*TaskWorkItem, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + return s.store.ListTaskWorkItemsByWorkspace(ctx, workspaceID) +} diff --git a/apps/backend/internal/azuredevops/service_task_work_item_test.go b/apps/backend/internal/azuredevops/service_task_work_item_test.go new file mode 100644 index 0000000000..1650c6eef2 --- /dev/null +++ b/apps/backend/internal/azuredevops/service_task_work_item_test.go @@ -0,0 +1,31 @@ +package azuredevops + +import ( + "context" + "testing" +) + +type taskWorkItemClient struct{ fakeDetailClient } + +func TestTaskWorkItemAssociation(t *testing.T) { + client := &taskWorkItemClient{fakeDetailClient: fakeDetailClient{detail: &WorkItemDetail{WorkItem: WorkItem{ID: 101, Title: "Ship it", Project: "project-1", WebURL: "https://dev.azure.com/acme/p/_workitems/edit/101"}}}} + service, store, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := store.db.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL)`); err != nil { + t.Fatalf("create tasks table: %v", err) + } + if _, err := store.db.Exec(`INSERT INTO tasks (id, workspace_id) VALUES ('task-1', 'ws-1')`); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := service.SetConfigForWorkspace(context.Background(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + + row, err := service.AssociateTaskWorkItem(t.Context(), "ws-1", "task-1", "project-1", 101) + if err != nil || row.Title != "Ship it" { + t.Fatalf("AssociateTaskWorkItem = %+v, %v", row, err) + } + grouped, err := service.ListTaskWorkItemsByWorkspace(t.Context(), "ws-1") + if err != nil || len(grouped["task-1"]) != 1 || grouped["task-1"][0].ID != row.ID { + t.Fatalf("ListTaskWorkItemsByWorkspace = %+v, %v", grouped, err) + } +} diff --git a/apps/backend/internal/azuredevops/service_test.go b/apps/backend/internal/azuredevops/service_test.go index 510b63dbd4..33c2e091f8 100644 --- a/apps/backend/internal/azuredevops/service_test.go +++ b/apps/backend/internal/azuredevops/service_test.go @@ -126,6 +126,7 @@ func TestListBranchesRejectsOrganizationMismatch(t *testing.T) { }); err != nil { t.Fatalf("set config: %v", err) } + clientCalls = 0 _, err := service.ListBranchesForWorkspace(t.Context(), "ws-a", "other", "project", "repo") if !errors.Is(err, ErrInvalidConfig) { @@ -191,11 +192,57 @@ func TestConfigTestUsesSubmittedAndStoredCredentialsWithoutPersistence(t *testin if err != nil || !res.OK { t.Fatalf("stored probe: result=%+v err=%v", res, err) } - if len(captures) != 2 || captures[0].pat != "submitted" || captures[1].pat != "stored" { + if len(captures) != 3 || captures[0].pat != "submitted" || captures[1].pat != "stored" || captures[2].pat != "stored" { t.Fatalf("captured credentials: %+v", captures) } } +func TestSetConfigProbesImmediately(t *testing.T) { + probeCalls := 0 + svc, _, _ := newTestService(t, func(*Config, string) Client { + probeCalls++ + return &fakeClient{result: &TestConnectionResult{OK: true}} + }) + + config, err := svc.SetConfigForWorkspace(t.Context(), "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", + PAT: "pat", + }) + if err != nil { + t.Fatalf("SetConfigForWorkspace() error = %v", err) + } + if probeCalls != 1 { + t.Fatalf("auth probe calls = %d, want 1", probeCalls) + } + if config.LastCheckedAt == nil || !config.LastOK || config.LastError != "" { + t.Fatalf("returned auth health = %+v, want current successful probe", config) + } +} + +func TestSetConfigPersistsProbeFailure(t *testing.T) { + svc, store, secrets := newTestService(t, func(*Config, string) Client { + return &fakeClient{result: &TestConnectionResult{OK: false, Error: "401 unauthorized"}} + }) + + config, err := svc.SetConfigForWorkspace(t.Context(), "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", + PAT: "pat", + }) + if err != nil { + t.Fatalf("SetConfigForWorkspace() error = %v", err) + } + if config.LastCheckedAt == nil || config.LastOK || config.LastError != "401 unauthorized" { + t.Fatalf("returned auth health = %+v, want persisted probe failure", config) + } + if pat, err := secrets.Reveal(t.Context(), SecretKeyForWorkspace("ws-a")); err != nil || pat != "pat" { + t.Fatalf("stored PAT = %q, %v; want persisted credential", pat, err) + } + persisted, err := store.GetConfig(t.Context(), "ws-a") + if err != nil || persisted == nil || persisted.LastCheckedAt == nil || persisted.LastOK || persisted.LastError != "401 unauthorized" { + t.Fatalf("persisted auth health = %+v, %v", persisted, err) + } +} + func TestCopyConfigCopiesCredentialAndDeleteIsScoped(t *testing.T) { svc, store, secrets := newTestService(t, nil) ctx := context.Background() @@ -327,8 +374,12 @@ func TestSetConfigRestoresPATWhenStoreFails(t *testing.T) { } } -func TestSetConfigResetsHealthOnlyWhenCredentialsChange(t *testing.T) { - svc, store, _ := newTestService(t, nil) +func TestSetConfigProbesOnlyWhenCredentialsChange(t *testing.T) { + probeCalls := 0 + svc, store, _ := newTestService(t, func(*Config, string) Client { + probeCalls++ + return &fakeClient{result: &TestConnectionResult{OK: true}} + }) ctx := context.Background() if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ OrganizationURL: "https://dev.azure.com/acme", PAT: "old-pat", @@ -359,13 +410,19 @@ func TestSetConfigResetsHealthOnlyWhenCredentialsChange(t *testing.T) { t.Fatalf("update project: %v", err) } assertHealth(true, true) + if probeCalls != 1 { + t.Fatalf("auth probe calls after unchanged credentials = %d, want 1", probeCalls) + } if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ OrganizationURL: "https://dev.azure.com/other", }); err != nil { t.Fatalf("update organization: %v", err) } - assertHealth(false, false) + assertHealth(true, true) + if probeCalls != 2 { + t.Fatalf("auth probe calls after organization change = %d, want 2", probeCalls) + } setHealthy() if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ @@ -373,7 +430,10 @@ func TestSetConfigResetsHealthOnlyWhenCredentialsChange(t *testing.T) { }); err != nil { t.Fatalf("update PAT: %v", err) } - assertHealth(false, false) + assertHealth(true, true) + if probeCalls != 3 { + t.Fatalf("auth probe calls after PAT change = %d, want 3", probeCalls) + } } func TestDeleteConfigLeavesStateWhenSecretDeleteFails(t *testing.T) { @@ -425,6 +485,9 @@ func TestRecordAuthHealthIgnoresParentCancellation(t *testing.T) { }); err != nil { t.Fatalf("seed config: %v", err) } + if err := svc.Store().ResetAuthHealth(t.Context(), "ws-a"); err != nil { + t.Fatalf("reset health: %v", err) + } ctx, cancel := context.WithCancel(t.Context()) cancel() svc.RecordAuthHealthForWorkspace(ctx, "ws-a") diff --git a/apps/backend/internal/azuredevops/store.go b/apps/backend/internal/azuredevops/store.go index f5aad0de14..496987a527 100644 --- a/apps/backend/internal/azuredevops/store.go +++ b/apps/backend/internal/azuredevops/store.go @@ -27,6 +27,8 @@ const createConfigTableSQL = ` last_ok BOOLEAN NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', saved_views TEXT NOT NULL DEFAULT '[]', + workspace_settings TEXT NOT NULL DEFAULT '{}', + workspace_settings_version INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL )` @@ -56,7 +58,109 @@ const createTaskPRTableSQL = ` UNIQUE(task_id, repository_id, azure_repository_id, pull_request_id) ); CREATE INDEX IF NOT EXISTS idx_azure_devops_task_prs_task_id - ON azure_devops_task_prs(task_id)` + ON azure_devops_task_prs(task_id)` + +const createTaskWorkItemTableSQL = ` +CREATE TABLE IF NOT EXISTS azure_devops_task_work_items ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + project_id TEXT NOT NULL, + work_item_id INTEGER NOT NULL, + work_item_url TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + type TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE(task_id, workspace_id, project_id, work_item_id) +); +CREATE INDEX IF NOT EXISTS idx_azure_devops_task_work_items_task_id ON azure_devops_task_work_items(task_id); +CREATE INDEX IF NOT EXISTS idx_azure_devops_task_work_items_workspace_id ON azure_devops_task_work_items(workspace_id)` + +const createWatchTablesSQL = ` +CREATE TABLE IF NOT EXISTS azure_devops_work_item_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL DEFAULT '', + workflow_step_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL, + wiql TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', + base_branch TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL DEFAULT '', + executor_profile_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 1, + poll_interval_seconds INTEGER NOT NULL DEFAULT 300, + cleanup_policy TEXT NOT NULL DEFAULT 'auto', + max_inflight_tasks INTEGER, + generation INTEGER NOT NULL DEFAULT 1, + deleting BOOLEAN NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + last_error_at DATETIME, + last_polled_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_azure_devops_work_item_watches_workspace + ON azure_devops_work_item_watches(workspace_id); +CREATE TABLE IF NOT EXISTS azure_devops_pull_request_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL DEFAULT '', + workflow_step_id TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL, + azure_repository_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + creator_id TEXT NOT NULL DEFAULT '', + reviewer_id TEXT NOT NULL DEFAULT '', + repository_id TEXT NOT NULL DEFAULT '', + base_branch TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL DEFAULT '', + executor_profile_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 1, + poll_interval_seconds INTEGER NOT NULL DEFAULT 300, + cleanup_policy TEXT NOT NULL DEFAULT 'auto', + max_inflight_tasks INTEGER, + generation INTEGER NOT NULL DEFAULT 1, + deleting BOOLEAN NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + last_error_at DATETIME, + last_polled_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_azure_devops_pull_request_watches_workspace + ON azure_devops_pull_request_watches(workspace_id); +CREATE TABLE IF NOT EXISTS azure_devops_work_item_watch_tasks ( + id TEXT PRIMARY KEY, + watch_id TEXT NOT NULL, + project_id TEXT NOT NULL, + work_item_id INTEGER NOT NULL, + work_item_url TEXT NOT NULL, + task_id TEXT NOT NULL DEFAULT '', + generation INTEGER NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE(watch_id, generation, project_id, work_item_id) +); +CREATE INDEX IF NOT EXISTS idx_azure_devops_work_item_watch_tasks_watch + ON azure_devops_work_item_watch_tasks(watch_id); +CREATE TABLE IF NOT EXISTS azure_devops_pull_request_watch_tasks ( + id TEXT PRIMARY KEY, + watch_id TEXT NOT NULL, + project_id TEXT NOT NULL, + azure_repository_id TEXT NOT NULL, + pull_request_id INTEGER NOT NULL, + pull_request_url TEXT NOT NULL, + task_id TEXT NOT NULL DEFAULT '', + generation INTEGER NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE(watch_id, generation, project_id, azure_repository_id, pull_request_id) +); +CREATE INDEX IF NOT EXISTS idx_azure_devops_pull_request_watch_tasks_watch + ON azure_devops_pull_request_watch_tasks(watch_id)` const selectConfigColumns = `workspace_id, organization_url, default_project_id, default_project_name, auth_method, last_checked_at, last_ok, last_error, @@ -77,13 +181,46 @@ func NewStore(writer, reader *sqlx.DB) (*Store, error) { if err := store.ensureSavedViewsColumn(); err != nil { return nil, fmt.Errorf("azure devops saved views schema init: %w", err) } + if err := store.ensureWorkspaceSettingsColumn(); err != nil { + return nil, fmt.Errorf("azure devops workspace settings schema init: %w", err) + } + if err := store.ensureWorkspaceSettingsVersionColumn(); err != nil { + return nil, fmt.Errorf("azure devops workspace settings version schema init: %w", err) + } if _, err := store.db.Exec(createTaskPRTableSQL); err != nil { return nil, fmt.Errorf("azure devops task PR schema init: %w", err) } + if _, err := store.db.Exec(createTaskWorkItemTableSQL); err != nil { + return nil, fmt.Errorf("azure devops task work item schema init: %w", err) + } + if _, err := store.db.Exec(createWatchTablesSQL); err != nil { + return nil, fmt.Errorf("azure devops watcher schema init: %w", err) + } return store, nil } +func (s *Store) ensureWorkspaceSettingsColumn() error { + return s.ensureConfigColumn( + "workspace_settings", + `ALTER TABLE azure_devops_configs ADD COLUMN workspace_settings TEXT NOT NULL DEFAULT '{}'`, + ) +} + +func (s *Store) ensureWorkspaceSettingsVersionColumn() error { + return s.ensureConfigColumn( + "workspace_settings_version", + `ALTER TABLE azure_devops_configs ADD COLUMN workspace_settings_version INTEGER NOT NULL DEFAULT 0`, + ) +} + func (s *Store) ensureSavedViewsColumn() error { + return s.ensureConfigColumn( + "saved_views", + `ALTER TABLE azure_devops_configs ADD COLUMN saved_views TEXT NOT NULL DEFAULT '[]'`, + ) +} + +func (s *Store) ensureConfigColumn(column, statement string) error { rows, err := s.db.Query(`PRAGMA table_info(azure_devops_configs)`) if err != nil { return err @@ -97,14 +234,14 @@ func (s *Store) ensureSavedViewsColumn() error { if scanErr := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); scanErr != nil { return scanErr } - if name == "saved_views" { + if name == column { return rows.Err() } } if err := rows.Err(); err != nil { return err } - _, err = s.db.Exec(`ALTER TABLE azure_devops_configs ADD COLUMN saved_views TEXT NOT NULL DEFAULT '[]'`) + _, err = s.db.Exec(statement) return err } @@ -237,3 +374,70 @@ func (s *Store) PutSavedViewsJSON(ctx context.Context, workspaceID, raw string) } return nil } + +// WorkspaceSettingsSnapshot is the persisted settings payload and its optimistic-lock version. +type WorkspaceSettingsSnapshot struct { + JSON string + Version int64 +} + +func (s *Store) GetWorkspaceSettingsSnapshot(ctx context.Context, workspaceID string) (WorkspaceSettingsSnapshot, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return WorkspaceSettingsSnapshot{}, err + } + var snapshot WorkspaceSettingsSnapshot + err := s.ro.GetContext(ctx, &snapshot, + `SELECT workspace_settings AS json, workspace_settings_version AS version + FROM azure_devops_configs WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return WorkspaceSettingsSnapshot{}, ErrNotConfigured + } + return snapshot, err +} + +func (s *Store) GetWorkspaceSettingsJSON(ctx context.Context, workspaceID string) (string, error) { + snapshot, err := s.GetWorkspaceSettingsSnapshot(ctx, workspaceID) + return snapshot.JSON, err +} + +func (s *Store) PutWorkspaceSettingsJSON(ctx context.Context, workspaceID, raw string) error { + if err := validateWorkspaceID(workspaceID); err != nil { + return err + } + result, err := s.db.ExecContext(ctx, ` + UPDATE azure_devops_configs + SET workspace_settings = ?, workspace_settings_version = workspace_settings_version + 1, updated_at = ? + WHERE workspace_id = ?`, raw, time.Now().UTC(), workspaceID) + if err != nil { + return err + } + updated, err := workspaceSettingsRowsUpdated(result) + if err != nil { + return err + } + if !updated { + return ErrNotConfigured + } + return nil +} + +// PutWorkspaceSettingsJSONIfVersion saves settings only when the caller's +// snapshot is still current. A false result means another writer won the race. +func (s *Store) PutWorkspaceSettingsJSONIfVersion(ctx context.Context, workspaceID, raw string, version int64) (bool, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return false, err + } + result, err := s.db.ExecContext(ctx, ` + UPDATE azure_devops_configs + SET workspace_settings = ?, workspace_settings_version = workspace_settings_version + 1, updated_at = ? + WHERE workspace_id = ? AND workspace_settings_version = ?`, raw, time.Now().UTC(), workspaceID, version) + if err != nil { + return false, err + } + return workspaceSettingsRowsUpdated(result) +} + +func workspaceSettingsRowsUpdated(result sql.Result) (bool, error) { + updated, err := result.RowsAffected() + return updated > 0, err +} diff --git a/apps/backend/internal/azuredevops/store_task_work_item.go b/apps/backend/internal/azuredevops/store_task_work_item.go new file mode 100644 index 0000000000..9b07001ced --- /dev/null +++ b/apps/backend/internal/azuredevops/store_task_work_item.go @@ -0,0 +1,66 @@ +package azuredevops + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +const taskWorkItemColumns = `id, task_id, workspace_id, project_id, work_item_id, work_item_url, title, state, type, created_at, updated_at` + +func (s *Store) UpsertTaskWorkItem(ctx context.Context, row *TaskWorkItem) error { + if row == nil { + return errors.New("azure devops store: task work item is required") + } + now := time.Now().UTC() + row.UpdatedAt = now + if row.ID == "" { + row.ID = uuid.NewString() + } + if row.CreatedAt.IsZero() { + row.CreatedAt = now + } + query, args, err := sqlx.Named(`INSERT INTO azure_devops_task_work_items (`+taskWorkItemColumns+`) + VALUES (:id,:task_id,:workspace_id,:project_id,:work_item_id,:work_item_url,:title,:state,:type,:created_at,:updated_at) + ON CONFLICT(task_id, workspace_id, project_id, work_item_id) DO UPDATE SET + work_item_url=excluded.work_item_url,title=excluded.title,state=excluded.state,type=excluded.type,updated_at=excluded.updated_at + RETURNING id, created_at`, row) + if err != nil { + return err + } + return s.db.QueryRowxContext(ctx, s.db.Rebind(query), args...).Scan(&row.ID, &row.CreatedAt) +} + +func (s *Store) ListTaskWorkItemsByWorkspace(ctx context.Context, workspaceID string) (map[string][]*TaskWorkItem, error) { + var rows []TaskWorkItem + if err := s.ro.SelectContext(ctx, &rows, `SELECT `+taskWorkItemColumns+` FROM azure_devops_task_work_items WHERE workspace_id = ? ORDER BY created_at ASC`, workspaceID); err != nil { + return nil, err + } + result := make(map[string][]*TaskWorkItem) + for i := range rows { + result[rows[i].TaskID] = append(result[rows[i].TaskID], &rows[i]) + } + return result, nil +} + +func (s *Store) DeleteTaskWorkItemsByTask(ctx context.Context, taskID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM azure_devops_task_work_items WHERE task_id = ?`), taskID) + return err +} + +func (s *Store) DeleteTaskWorkItemsByWorkspace(ctx context.Context, workspaceID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM azure_devops_task_work_items WHERE workspace_id = ?`), workspaceID) + return err +} + +func (s *Store) TaskBelongsToWorkspace(ctx context.Context, taskID, workspaceID string) (bool, error) { + var found string + err := s.ro.GetContext(ctx, &found, `SELECT workspace_id FROM tasks WHERE id = ?`, taskID) + if err != nil { + return false, err + } + return found == workspaceID, nil +} diff --git a/apps/backend/internal/azuredevops/store_test.go b/apps/backend/internal/azuredevops/store_test.go index c03b464ace..9bc7498365 100644 --- a/apps/backend/internal/azuredevops/store_test.go +++ b/apps/backend/internal/azuredevops/store_test.go @@ -92,6 +92,90 @@ func TestStoreConfigReplayAndDelete(t *testing.T) { } } +func TestWorkspaceSettingsConditionalWriteRejectsStaleVersion(t *testing.T) { + db := newTestDB(t) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + ctx := context.Background() + if err := store.UpsertConfig(ctx, &Config{ + WorkspaceID: "ws-a", OrganizationURL: "https://dev.azure.com/acme", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("upsert config: %v", err) + } + + snapshot, err := store.GetWorkspaceSettingsSnapshot(ctx, "ws-a") + if err != nil { + t.Fatalf("get workspace settings snapshot: %v", err) + } + if snapshot.JSON != "{}" || snapshot.Version != 0 { + t.Fatalf("initial snapshot = %+v, want empty settings at version 0", snapshot) + } + updated, err := store.PutWorkspaceSettingsJSONIfVersion(ctx, "ws-a", `{"workItemActions":[]}`, snapshot.Version) + if err != nil || !updated { + t.Fatalf("conditional write = %t, %v, want success", updated, err) + } + updated, err = store.PutWorkspaceSettingsJSONIfVersion(ctx, "ws-a", `{"pullRequestActions":[]}`, snapshot.Version) + if err != nil { + t.Fatalf("stale conditional write: %v", err) + } + if updated { + t.Fatal("stale conditional write unexpectedly succeeded") + } + + current, err := store.GetWorkspaceSettingsSnapshot(ctx, "ws-a") + if err != nil { + t.Fatalf("get current workspace settings snapshot: %v", err) + } + if current.JSON != `{"workItemActions":[]}` || current.Version != 1 { + t.Fatalf("current snapshot = %+v, want successful write at version 1", current) + } +} + +func TestWorkspaceSettingsVersionMigratesExistingConfigTable(t *testing.T) { + db := newTestDB(t) + _, err := db.Exec(` + CREATE TABLE azure_devops_configs ( + workspace_id TEXT PRIMARY KEY, + organization_url TEXT NOT NULL, + default_project_id TEXT NOT NULL DEFAULT '', + default_project_name TEXT NOT NULL DEFAULT '', + auth_method TEXT NOT NULL DEFAULT 'pat', + last_checked_at DATETIME, + last_ok BOOLEAN NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + saved_views TEXT NOT NULL DEFAULT '[]', + workspace_settings TEXT NOT NULL DEFAULT '{}', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`) + if err != nil { + t.Fatalf("create previous config schema: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("migrate config schema: %v", err) + } + ctx := context.Background() + if err := store.UpsertConfig(ctx, &Config{ + WorkspaceID: "ws-a", OrganizationURL: "https://dev.azure.com/acme", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("upsert config: %v", err) + } + snapshot, err := store.GetWorkspaceSettingsSnapshot(ctx, "ws-a") + if err != nil { + t.Fatalf("get migrated workspace settings snapshot: %v", err) + } + if snapshot.Version != 0 { + t.Fatalf("migrated settings version = %d, want 0", snapshot.Version) + } + if _, err := NewStore(db, db); err != nil { + t.Fatalf("replay migrated config schema: %v", err) + } +} + func TestStoreTaskPRSchemaAndReplay(t *testing.T) { db := newTestDB(t) if _, err := NewStore(db, db); err != nil { @@ -116,6 +200,48 @@ type taskPRStoreContract interface { DeleteTaskPRsByTask(context.Context, string) error } +type taskWorkItemStoreContract interface { + UpsertTaskWorkItem(context.Context, *TaskWorkItem) error + ListTaskWorkItemsByWorkspace(context.Context, string) (map[string][]*TaskWorkItem, error) + DeleteTaskWorkItemsByTask(context.Context, string) error + DeleteTaskWorkItemsByWorkspace(context.Context, string) error +} + +func TestStoreDeleteTaskWorkItemsIsScoped(t *testing.T) { + store, err := NewStore(newTestDB(t), nil) + if err != nil { + t.Fatalf("new store: %v", err) + } + contract, ok := any(store).(taskWorkItemStoreContract) + if !ok { + t.Fatal("Store does not implement the task work item persistence contract") + } + ctx := context.Background() + for _, row := range []*TaskWorkItem{ + {TaskID: "task-a", WorkspaceID: "workspace-a", ProjectID: "project-a", WorkItemID: 1}, + {TaskID: "task-b", WorkspaceID: "workspace-a", ProjectID: "project-a", WorkItemID: 2}, + {TaskID: "task-c", WorkspaceID: "workspace-b", ProjectID: "project-b", WorkItemID: 3}, + } { + if err := contract.UpsertTaskWorkItem(ctx, row); err != nil { + t.Fatalf("upsert task work item: %v", err) + } + } + if err := contract.DeleteTaskWorkItemsByTask(ctx, "task-a"); err != nil { + t.Fatalf("delete task work items by task: %v", err) + } + byWorkspace, err := contract.ListTaskWorkItemsByWorkspace(ctx, "workspace-a") + if err != nil || len(byWorkspace["task-a"]) != 0 || len(byWorkspace["task-b"]) != 1 { + t.Fatalf("task-scoped deletion result = %+v, err = %v", byWorkspace, err) + } + if err := contract.DeleteTaskWorkItemsByWorkspace(ctx, "workspace-a"); err != nil { + t.Fatalf("delete task work items by workspace: %v", err) + } + byWorkspace, err = contract.ListTaskWorkItemsByWorkspace(ctx, "workspace-b") + if err != nil || len(byWorkspace["task-c"]) != 1 { + t.Fatalf("workspace-scoped deletion result = %+v, err = %v", byWorkspace, err) + } +} + func TestStoreDeleteTaskPRsByTaskIsScoped(t *testing.T) { store, err := NewStore(newTestDB(t), nil) if err != nil { diff --git a/apps/backend/internal/azuredevops/store_watch_reset.go b/apps/backend/internal/azuredevops/store_watch_reset.go new file mode 100644 index 0000000000..ec0a6413c3 --- /dev/null +++ b/apps/backend/internal/azuredevops/store_watch_reset.go @@ -0,0 +1,90 @@ +package azuredevops + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +func (s *Store) BeginWorkItemWatchReset(ctx context.Context, watchID string) (*WatchResetResult, error) { + return s.beginWatchReset(ctx, "azure_devops_work_item_watches", "azure_devops_work_item_watch_tasks", watchID) +} + +func (s *Store) BeginPullRequestWatchReset(ctx context.Context, watchID string) (*WatchResetResult, error) { + return s.beginWatchReset(ctx, "azure_devops_pull_request_watches", "azure_devops_pull_request_watch_tasks", watchID) +} + +func (s *Store) beginWatchReset(ctx context.Context, watchTable, taskTable, watchID string) (*WatchResetResult, error) { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET generation = generation + 1, last_polled_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleting = 0`, watchTable), watchID) + if err != nil { + return nil, err + } + count, err := result.RowsAffected() + if err != nil { + return nil, err + } + if count == 0 { + return nil, ErrWatchNotFound + } + var generation int64 + if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT generation FROM %s WHERE id = ?`, watchTable), watchID).Scan(&generation); err != nil { + return nil, err + } + var taskIDs []string + if err := tx.SelectContext(ctx, &taskIDs, fmt.Sprintf(`SELECT task_id FROM %s WHERE watch_id = ? AND generation < ? AND task_id <> '' ORDER BY created_at`, taskTable), watchID, generation); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &WatchResetResult{Generation: generation, TaskIDs: taskIDs}, nil +} + +func (s *Store) FinishWorkItemWatchReset(ctx context.Context, watchID string, generation int64) error { + return s.finishWatchReset(ctx, "azure_devops_work_item_watches", "azure_devops_work_item_watch_tasks", watchID, generation) +} + +func (s *Store) FinishPullRequestWatchReset(ctx context.Context, watchID string, generation int64) error { + return s.finishWatchReset(ctx, "azure_devops_pull_request_watches", "azure_devops_pull_request_watch_tasks", watchID, generation) +} + +func (s *Store) finishWatchReset(ctx context.Context, watchTable, taskTable, watchID string, generation int64) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE watch_id = ? AND generation < ?`, taskTable), watchID, generation); err != nil { + return err + } + result, err := tx.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET last_polled_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND generation = ? AND deleting = 0`, watchTable), watchID, generation) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return ErrWatchOwnershipLost + } + return tx.Commit() +} + +func (s *Store) ListWorkItemWatchTaskIDs(ctx context.Context, watchID string) ([]string, error) { + var ids []string + err := s.ro.SelectContext(ctx, &ids, `SELECT task_id FROM azure_devops_work_item_watch_tasks WHERE watch_id = ? AND task_id <> '' ORDER BY created_at`, watchID) + return ids, err +} + +func (s *Store) ListPullRequestWatchTaskIDs(ctx context.Context, watchID string) ([]string, error) { + var ids []string + err := s.ro.SelectContext(ctx, &ids, `SELECT task_id FROM azure_devops_pull_request_watch_tasks WHERE watch_id = ? AND task_id <> '' ORDER BY created_at`, watchID) + return ids, err +} diff --git a/apps/backend/internal/azuredevops/watch_cleanup.go b/apps/backend/internal/azuredevops/watch_cleanup.go new file mode 100644 index 0000000000..ce8e6d1708 --- /dev/null +++ b/apps/backend/internal/azuredevops/watch_cleanup.go @@ -0,0 +1,149 @@ +package azuredevops + +import ( + "context" + "strings" + + "go.uber.org/zap" +) + +// TaskSessionChecker reports whether a user interacted with a task beyond +// the watch's automatic start message. It is intentionally tiny so Azure can +// reuse the shared backend adapter without importing task repository types. +type TaskSessionChecker interface { + HasUserAuthoredMessage(context.Context, string) (bool, error) +} + +func (s *Service) CleanupWorkItemWatch(ctx context.Context, workspaceID, watchID string) (int, error) { + watch, err := s.authorizedWorkItemWatch(ctx, workspaceID, watchID) + if err != nil { + return 0, err + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return 0, err + } + return s.cleanupWorkItemWatchWithClient(ctx, watch, client), nil +} + +func (s *Service) CleanupPullRequestWatch(ctx context.Context, workspaceID, watchID string) (int, error) { + watch, err := s.authorizedPullRequestWatch(ctx, workspaceID, watchID) + if err != nil { + return 0, err + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return 0, err + } + return s.cleanupPullRequestWatchWithClient(ctx, watch, client), nil +} + +func (s *Service) cleanupWorkItemWatchWithClient(ctx context.Context, watch *WorkItemWatch, client Client) int { + if watch == nil || client == nil || watch.CleanupPolicy == CleanupPolicyNever { + return 0 + } + rows, err := s.store.ListWorkItemWatchTasks(ctx, watch.ID, watch.Generation) + if err != nil { + s.log.Warn("azure devops watcher: list work-item cleanup rows", zap.String("watch_id", watch.ID), zap.Error(err)) + return 0 + } + deleted := 0 + for _, row := range rows { + if row == nil || row.TaskID == "" { + continue + } + item, fetchErr := client.GetWorkItem(ctx, watch.ProjectID, row.WorkItemID) + if fetchErr != nil || item == nil || !isTerminalWorkItemState(item.State) { + continue + } + if !s.cleanupTaskAllowed(ctx, row.TaskID, watch.CleanupPolicy) { + continue + } + if s.deleteWatchTaskTree(ctx, watch.ID, row.TaskID) { + if err := s.store.DeleteWorkItemWatchTask(ctx, row.ID); err != nil { + s.log.Warn("azure devops watcher: remove work-item cleanup row", zap.String("watch_id", watch.ID), zap.String("row_id", row.ID), zap.Error(err)) + continue + } + deleted++ + } + } + return deleted +} + +func (s *Service) cleanupPullRequestWatchWithClient(ctx context.Context, watch *PullRequestWatch, client Client) int { + if watch == nil || client == nil || watch.CleanupPolicy == CleanupPolicyNever { + return 0 + } + rows, err := s.store.ListPullRequestWatchTasks(ctx, watch.ID, watch.Generation) + if err != nil { + s.log.Warn("azure devops watcher: list pull-request cleanup rows", zap.String("watch_id", watch.ID), zap.Error(err)) + return 0 + } + deleted := 0 + for _, row := range rows { + if row == nil || row.TaskID == "" { + continue + } + pullRequest, fetchErr := client.GetPullRequest(ctx, watch.ProjectID, row.AzureRepositoryID, row.PullRequestID) + if fetchErr != nil || pullRequest == nil || !isTerminalPullRequestStatus(pullRequest.Status) { + continue + } + if !s.cleanupTaskAllowed(ctx, row.TaskID, watch.CleanupPolicy) { + continue + } + if s.deleteWatchTaskTree(ctx, watch.ID, row.TaskID) { + if err := s.store.DeletePullRequestWatchTask(ctx, row.ID); err != nil { + s.log.Warn("azure devops watcher: remove pull-request cleanup row", zap.String("watch_id", watch.ID), zap.String("row_id", row.ID), zap.Error(err)) + continue + } + deleted++ + } + } + return deleted +} + +func (s *Service) cleanupTaskAllowed(ctx context.Context, taskID, policy string) bool { + if policy != CleanupPolicyAuto { + return true + } + checker := s.getTaskSessionChecker() + if checker == nil { + return true + } + authored, err := checker.HasUserAuthoredMessage(ctx, taskID) + if err != nil { + s.log.Warn("azure devops watcher: check task engagement", zap.String("task_id", taskID), zap.Error(err)) + return false + } + return !authored +} + +func (s *Service) deleteWatchTaskTree(ctx context.Context, watchID, taskID string) bool { + deleter := s.getCascadeTaskDeleter() + if deleter == nil { + return false + } + if _, err := deleter.DeleteTaskTree(ctx, taskID, true); err != nil { + s.log.Warn("azure devops watcher: terminal task cleanup failed", zap.String("watch_id", watchID), zap.String("task_id", taskID), zap.Error(err)) + return false + } + return true +} + +func isTerminalWorkItemState(state string) bool { + switch strings.ToLower(strings.TrimSpace(state)) { + case "closed", "done", "completed", "resolved", "removed": + return true + default: + return false + } +} + +func isTerminalPullRequestStatus(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "completed", "abandoned", "closed", "merged": + return true + default: + return false + } +} diff --git a/apps/backend/internal/azuredevops/watch_cleanup_test.go b/apps/backend/internal/azuredevops/watch_cleanup_test.go new file mode 100644 index 0000000000..7ae3a0b370 --- /dev/null +++ b/apps/backend/internal/azuredevops/watch_cleanup_test.go @@ -0,0 +1,112 @@ +package azuredevops + +import ( + "context" + "testing" + + taskservice "github.com/kandev/kandev/internal/task/service" +) + +type cleanupClient struct { + invalidClient + workItem *WorkItem + pullRequest *PullRequest +} + +func (c *cleanupClient) GetWorkItem(context.Context, string, int) (*WorkItem, error) { + if c.workItem == nil { + return nil, mockNotFound("work item", 0) + } + item := *c.workItem + return &item, nil +} + +func (c *cleanupClient) GetPullRequest(context.Context, string, string, int) (*PullRequest, error) { + if c.pullRequest == nil { + return nil, mockNotFound("pull request", 0) + } + pullRequest := *c.pullRequest + return &pullRequest, nil +} + +type recordingCleanupDeleter struct { + taskIDs []string +} + +func (d *recordingCleanupDeleter) DeleteTaskTree(_ context.Context, taskID string, _ bool) (*taskservice.CascadeOutcome, error) { + d.taskIDs = append(d.taskIDs, taskID) + return &taskservice.CascadeOutcome{}, nil +} + +func (d *recordingCleanupDeleter) DeleteTask(ctx context.Context, taskID string, cascade bool) (*taskservice.CascadeOutcome, error) { + d.taskIDs = append(d.taskIDs, taskID) + return &taskservice.CascadeOutcome{}, nil +} + +type recordingTaskSessionChecker struct{ authored bool } + +func (c recordingTaskSessionChecker) HasUserAuthoredMessage(context.Context, string) (bool, error) { + return c.authored, nil +} + +func TestCleanupWorkItemWatchHonorsAutoEngagementAndTerminalState(t *testing.T) { + client := &cleanupClient{workItem: &WorkItem{ID: 101, State: "Closed"}} + service, store, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + watch := &WorkItemWatch{WorkspaceID: "ws-1", ProjectID: "project-1", WIQL: "SELECT", CleanupPolicy: CleanupPolicyAuto} + if err := store.CreateWorkItemWatch(t.Context(), watch); err != nil { + t.Fatalf("create watch: %v", err) + } + if reserved, err := store.ReserveWorkItemWatchTask(t.Context(), watch.ID, watch.Generation, watch.ProjectID, 101, "https://azure/item/101"); err != nil || !reserved { + t.Fatalf("reserve: %v %v", reserved, err) + } + if err := store.AssignWorkItemWatchTaskID(t.Context(), watch.ID, watch.Generation, watch.ProjectID, 101, "task-101"); err != nil { + t.Fatalf("attach: %v", err) + } + deleter := &recordingCleanupDeleter{} + service.SetCascadeTaskDeleter(deleter) + service.SetTaskSessionChecker(recordingTaskSessionChecker{authored: true}) + if deleted, err := service.CleanupWorkItemWatch(t.Context(), "ws-1", watch.ID); err != nil || deleted != 0 { + t.Fatalf("engaged cleanup = %d, %v; want no deletion", deleted, err) + } + service.SetTaskSessionChecker(recordingTaskSessionChecker{}) + deleted, err := service.CleanupWorkItemWatch(t.Context(), "ws-1", watch.ID) + if err != nil || deleted != 1 { + t.Fatalf("terminal cleanup = %d, %v; want one deletion", deleted, err) + } + if len(deleter.taskIDs) != 1 || deleter.taskIDs[0] != "task-101" { + t.Fatalf("deleted task IDs = %v", deleter.taskIDs) + } + rows, err := store.ListWorkItemWatchTasks(t.Context(), watch.ID, watch.Generation) + if err != nil || len(rows) != 0 { + t.Fatalf("cleanup reservations = %v, %v; want empty", rows, err) + } +} + +func TestCleanupPullRequestWatchHonorsNeverPolicy(t *testing.T) { + client := &cleanupClient{pullRequest: &PullRequest{ID: 42, Status: "completed"}} + service, store, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + watch := &PullRequestWatch{WorkspaceID: "ws-1", ProjectID: "project-1", AzureRepositoryID: "azure-repo-1", CleanupPolicy: CleanupPolicyNever} + if err := store.CreatePullRequestWatch(t.Context(), watch); err != nil { + t.Fatalf("create watch: %v", err) + } + if reserved, err := store.ReservePullRequestWatchTask(t.Context(), watch.ID, watch.Generation, watch.ProjectID, watch.AzureRepositoryID, 42, "https://azure/pr/42"); err != nil || !reserved { + t.Fatalf("reserve: %v %v", reserved, err) + } + if err := store.AssignPullRequestWatchTaskID(t.Context(), watch.ID, watch.Generation, watch.ProjectID, watch.AzureRepositoryID, 42, "task-42"); err != nil { + t.Fatalf("attach: %v", err) + } + deleter := &recordingCleanupDeleter{} + service.SetCascadeTaskDeleter(deleter) + if deleted, err := service.CleanupPullRequestWatch(t.Context(), "ws-1", watch.ID); err != nil || deleted != 0 { + t.Fatalf("never cleanup = %d, %v; want no deletion", deleted, err) + } + if len(deleter.taskIDs) != 0 { + t.Fatalf("never policy deleted tasks: %v", deleter.taskIDs) + } +} diff --git a/apps/backend/internal/azuredevops/watch_dependencies.go b/apps/backend/internal/azuredevops/watch_dependencies.go new file mode 100644 index 0000000000..2f5b503783 --- /dev/null +++ b/apps/backend/internal/azuredevops/watch_dependencies.go @@ -0,0 +1,108 @@ +package azuredevops + +import ( + "context" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/common/securityutil" +) + +// WatchRepositoryLookup validates the Kandev repository selected for an +// Azure watcher and supplies its default branch when the form leaves the +// branch blank. It intentionally differs from RepositoryLookup, which +// validates task↔Azure PR associations. +type WatchRepositoryLookup interface { + GetRepository(ctx context.Context, id string) (workspaceID, defaultBranch string, ok bool) +} + +// WatchDependencyValidator validates references that are used when a watcher +// creates a Kandev task. Keeping this seam narrow lets the integration remain +// independent of workflow and settings storage while still failing closed in +// production. +type WatchDependencyValidator interface { + WorkflowStepBelongs(ctx context.Context, workspaceID, workflowID, stepID string) (bool, error) + AgentProfileBelongs(ctx context.Context, workspaceID, profileID string) (bool, error) + ExecutorProfileBelongs(ctx context.Context, workspaceID, profileID string) (bool, error) +} + +func (s *Service) SetWatchRepositoryLookup(lookup WatchRepositoryLookup) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.watchRepositoryLookup = lookup +} + +func (s *Service) SetWatchDependencyValidator(validator WatchDependencyValidator) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.watchDependencyValidator = validator +} + +func (s *Service) validateWatchDependencies( + ctx context.Context, + workspaceID, workflowID, workflowStepID, agentProfileID, executorProfileID string, +) error { + if strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(workflowID) == "" || + strings.TrimSpace(workflowStepID) == "" || strings.TrimSpace(agentProfileID) == "" || + strings.TrimSpace(executorProfileID) == "" { + return fmt.Errorf("%w: workflow, step, agent profile, and executor profile are required", ErrInvalidConfig) + } + s.mu.RLock() + validator := s.watchDependencyValidator + s.mu.RUnlock() + if validator == nil { + return nil + } + checks := []func() (bool, error){ + func() (bool, error) { + return validator.WorkflowStepBelongs(ctx, workspaceID, workflowID, workflowStepID) + }, + func() (bool, error) { return validator.AgentProfileBelongs(ctx, workspaceID, agentProfileID) }, + func() (bool, error) { + return validator.ExecutorProfileBelongs(ctx, workspaceID, executorProfileID) + }, + } + for _, check := range checks { + valid, err := check() + if err != nil { + return fmt.Errorf("validate Azure DevOps watcher dependencies: %w", err) + } + if !valid { + return fmt.Errorf("%w: watcher dependency does not belong to workspace", ErrInvalidConfig) + } + } + return nil +} + +func (s *Service) resolveWatchRepository( + ctx context.Context, workspaceID, repositoryID, baseBranch string, +) (string, string, error) { + repositoryID = strings.TrimSpace(repositoryID) + baseBranch = strings.TrimSpace(baseBranch) + if repositoryID == "" { + return "", "", fmt.Errorf("%w: Kandev repository is required", ErrInvalidConfig) + } + if baseBranch != "" && !securityutil.IsValidBaseBranchRef(baseBranch) { + return "", "", fmt.Errorf("%w: base branch %q is not a valid git ref", ErrInvalidConfig, baseBranch) + } + s.mu.RLock() + lookup := s.watchRepositoryLookup + s.mu.RUnlock() + if lookup == nil { + return repositoryID, baseBranch, nil + } + repoWorkspace, defaultBranch, ok := lookup.GetRepository(ctx, repositoryID) + if !ok || repoWorkspace != workspaceID { + return "", "", fmt.Errorf("%w: repository %q not found in workspace", ErrInvalidConfig, repositoryID) + } + if baseBranch == "" { + baseBranch = defaultBranch + } + return repositoryID, baseBranch, nil +} diff --git a/apps/backend/internal/azuredevops/watch_models.go b/apps/backend/internal/azuredevops/watch_models.go new file mode 100644 index 0000000000..0d0aa487fe --- /dev/null +++ b/apps/backend/internal/azuredevops/watch_models.go @@ -0,0 +1,253 @@ +package azuredevops + +import ( + "errors" + "time" +) + +const ( + CleanupPolicyAuto = "auto" + CleanupPolicyAlways = "always" + CleanupPolicyNever = "never" + + defaultWatchPollIntervalSeconds = 300 + minWatchPollIntervalSeconds = 60 +) + +var ( + ErrWatchNotFound = errors.New("azure devops: watcher not found") + ErrWatchOwnershipLost = errors.New("azure devops: watcher ownership lost") + ErrReservationNotFound = errors.New("azure devops: watcher reservation not found") +) + +func IsValidCleanupPolicy(value string) bool { + switch value { + case "", CleanupPolicyAuto, CleanupPolicyAlways, CleanupPolicyNever: + return true + default: + return false + } +} + +func normalizeCleanupPolicy(value string) string { + if value == "" { + return CleanupPolicyAuto + } + return value +} + +func normalizePollInterval(value int) int { + if value <= 0 { + return defaultWatchPollIntervalSeconds + } + if value < minWatchPollIntervalSeconds { + return minWatchPollIntervalSeconds + } + return value +} + +func validateMaxInflightTasks(value *int) error { + if value != nil && *value < 0 { + return errors.New("max_inflight_tasks must be zero or greater") + } + return nil +} + +// WorkItemWatch configures periodic matching of Azure Boards work items. +type WorkItemWatch struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + WorkflowID string `json:"workflowId" db:"workflow_id"` + WorkflowStepID string `json:"workflowStepId" db:"workflow_step_id"` + ProjectID string `json:"projectId" db:"project_id"` + WIQL string `json:"wiql" db:"wiql"` + RepositoryID string `json:"repositoryId" db:"repository_id"` + BaseBranch string `json:"baseBranch" db:"base_branch"` + AgentProfileID string `json:"agentProfileId" db:"agent_profile_id"` + ExecutorProfileID string `json:"executorProfileId" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + Enabled bool `json:"enabled" db:"enabled"` + PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` + CleanupPolicy string `json:"cleanupPolicy" db:"cleanup_policy"` + MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"` + Generation int64 `json:"-" db:"generation"` + Deleting bool `json:"-" db:"deleting"` + LastError string `json:"lastError,omitempty" db:"last_error"` + LastErrorAt *time.Time `json:"lastErrorAt,omitempty" db:"last_error_at"` + LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// PullRequestWatch configures periodic matching of Azure Repos pull requests. +type PullRequestWatch struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + WorkflowID string `json:"workflowId" db:"workflow_id"` + WorkflowStepID string `json:"workflowStepId" db:"workflow_step_id"` + ProjectID string `json:"projectId" db:"project_id"` + AzureRepositoryID string `json:"azureRepositoryId,omitempty" db:"azure_repository_id"` + Status string `json:"status" db:"status"` + CreatorID string `json:"creatorId,omitempty" db:"creator_id"` + ReviewerID string `json:"reviewerId,omitempty" db:"reviewer_id"` + RepositoryID string `json:"repositoryId" db:"repository_id"` + BaseBranch string `json:"baseBranch" db:"base_branch"` + AgentProfileID string `json:"agentProfileId" db:"agent_profile_id"` + ExecutorProfileID string `json:"executorProfileId" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + Enabled bool `json:"enabled" db:"enabled"` + PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` + CleanupPolicy string `json:"cleanupPolicy" db:"cleanup_policy"` + MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"` + Generation int64 `json:"-" db:"generation"` + Deleting bool `json:"-" db:"deleting"` + LastError string `json:"lastError,omitempty" db:"last_error"` + LastErrorAt *time.Time `json:"lastErrorAt,omitempty" db:"last_error_at"` + LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +type WatchResetResult struct { + Generation int64 + TaskIDs []string +} + +type CreateWorkItemWatchRequest struct { + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + ProjectID string `json:"projectId"` + WIQL string `json:"wiql"` + RepositoryID string `json:"repositoryId"` + BaseBranch string `json:"baseBranch"` + AgentProfileID string `json:"agentProfileId"` + ExecutorProfileID string `json:"executorProfileId"` + Prompt string `json:"prompt"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + CleanupPolicy string `json:"cleanupPolicy"` + MaxInflightTasks *int `json:"maxInflightTasks,omitempty"` +} + +type UpdateWorkItemWatchRequest struct { + WorkflowID *string `json:"workflowId,omitempty"` + WorkflowStepID *string `json:"workflowStepId,omitempty"` + ProjectID *string `json:"projectId,omitempty"` + WIQL *string `json:"wiql,omitempty"` + RepositoryID *string `json:"repositoryId,omitempty"` + BaseBranch *string `json:"baseBranch,omitempty"` + AgentProfileID *string `json:"agentProfileId,omitempty"` + ExecutorProfileID *string `json:"executorProfileId,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"` + CleanupPolicy *string `json:"cleanupPolicy,omitempty"` + MaxInflightTasks *int `json:"maxInflightTasks,omitempty"` +} + +type CreatePullRequestWatchRequest struct { + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + ProjectID string `json:"projectId"` + AzureRepositoryID string `json:"azureRepositoryId"` + Status string `json:"status"` + CreatorID string `json:"creatorId"` + ReviewerID string `json:"reviewerId"` + RepositoryID string `json:"repositoryId"` + BaseBranch string `json:"baseBranch"` + AgentProfileID string `json:"agentProfileId"` + ExecutorProfileID string `json:"executorProfileId"` + Prompt string `json:"prompt"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + CleanupPolicy string `json:"cleanupPolicy"` + MaxInflightTasks *int `json:"maxInflightTasks,omitempty"` +} + +type UpdatePullRequestWatchRequest struct { + WorkflowID *string `json:"workflowId,omitempty"` + WorkflowStepID *string `json:"workflowStepId,omitempty"` + ProjectID *string `json:"projectId,omitempty"` + AzureRepositoryID *string `json:"azureRepositoryId,omitempty"` + Status *string `json:"status,omitempty"` + CreatorID *string `json:"creatorId,omitempty"` + ReviewerID *string `json:"reviewerId,omitempty"` + RepositoryID *string `json:"repositoryId,omitempty"` + BaseBranch *string `json:"baseBranch,omitempty"` + AgentProfileID *string `json:"agentProfileId,omitempty"` + ExecutorProfileID *string `json:"executorProfileId,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"` + CleanupPolicy *string `json:"cleanupPolicy,omitempty"` + MaxInflightTasks *int `json:"maxInflightTasks,omitempty"` +} + +type WorkItemWatchEvent struct { + WatchID string + WatchGeneration int64 + WorkspaceID string + WorkflowID string + WorkflowStepID string + RepositoryID string + BaseBranch string + AgentProfileID string + ExecutorProfileID string + Prompt string + CleanupPolicy string + MaxInflightTasks *int + ProjectID string + // ReservationClaimed indicates that the bounded provider check already + // claimed the current-generation reservation before publishing this event. + // The orchestrator source still claims reservations for manually published + // events, but must not claim this one a second time. + ReservationClaimed bool + WorkItem WorkItem +} + +type PullRequestWatchEvent struct { + WatchID string + WatchGeneration int64 + WorkspaceID string + WorkflowID string + WorkflowStepID string + RepositoryID string + BaseBranch string + AgentProfileID string + ExecutorProfileID string + Prompt string + CleanupPolicy string + MaxInflightTasks *int + ProjectID string + AzureRepositoryID string + // ReservationClaimed has the same meaning as the work-item event field. + ReservationClaimed bool + PullRequest PullRequest +} + +// WorkItemWatchTask records a generation-scoped reservation made for an +// Azure Boards work item. A non-empty TaskID means the shared watcher +// dispatcher successfully created the Kandev task for this reservation. +type WorkItemWatchTask struct { + ID string `db:"id"` + WatchID string `db:"watch_id"` + ProjectID string `db:"project_id"` + WorkItemID int `db:"work_item_id"` + WorkItemURL string `db:"work_item_url"` + TaskID string `db:"task_id"` + Generation int64 `db:"generation"` + CreatedAt time.Time `db:"created_at"` +} + +// PullRequestWatchTask is the PR equivalent of WorkItemWatchTask. +type PullRequestWatchTask struct { + ID string `db:"id"` + WatchID string `db:"watch_id"` + ProjectID string `db:"project_id"` + AzureRepositoryID string `db:"azure_repository_id"` + PullRequestID int `db:"pull_request_id"` + PullRequestURL string `db:"pull_request_url"` + TaskID string `db:"task_id"` + Generation int64 `db:"generation"` + CreatedAt time.Time `db:"created_at"` +} diff --git a/apps/backend/internal/azuredevops/watch_service.go b/apps/backend/internal/azuredevops/watch_service.go new file mode 100644 index 0000000000..68d054f23f --- /dev/null +++ b/apps/backend/internal/azuredevops/watch_service.go @@ -0,0 +1,580 @@ +package azuredevops + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +type WatchTriggerResult struct { + Matched int `json:"matched"` +} + +// Reservation methods are intentionally exposed on Service so the +// orchestrator can keep provider-specific deduplication behind its watcher +// source interface. They do not accept a workspace because the reservation +// key is already scoped by the watch ID and generation; authorization happens +// before the event is published. +func (s *Service) ReserveWorkItemWatchTask(ctx context.Context, watchID string, generation int64, projectID string, workItemID int, workItemURL string) (bool, error) { + return s.store.ReserveWorkItemWatchTask(ctx, watchID, generation, projectID, workItemID, workItemURL) +} + +func (s *Service) AssignWorkItemWatchTaskID(ctx context.Context, watchID string, generation int64, projectID string, workItemID int, taskID string) error { + return s.store.AssignWorkItemWatchTaskID(ctx, watchID, generation, projectID, workItemID, taskID) +} + +func (s *Service) ReleaseWorkItemWatchTask(ctx context.Context, watchID string, generation int64, projectID string, workItemID int) error { + return s.store.ReleaseWorkItemWatchTask(ctx, watchID, generation, projectID, workItemID) +} + +func (s *Service) DisableWorkItemWatchWithError(ctx context.Context, watchID, cause string) error { + return s.store.DisableWorkItemWatchWithError(ctx, watchID, cause) +} + +func (s *Service) ReservePullRequestWatchTask(ctx context.Context, watchID string, generation int64, projectID, azureRepositoryID string, pullRequestID int, pullRequestURL string) (bool, error) { + return s.store.ReservePullRequestWatchTask(ctx, watchID, generation, projectID, azureRepositoryID, pullRequestID, pullRequestURL) +} + +func (s *Service) AssignPullRequestWatchTaskID(ctx context.Context, watchID string, generation int64, projectID, azureRepositoryID string, pullRequestID int, taskID string) error { + return s.store.AssignPullRequestWatchTaskID(ctx, watchID, generation, projectID, azureRepositoryID, pullRequestID, taskID) +} + +func (s *Service) ReleasePullRequestWatchTask(ctx context.Context, watchID string, generation int64, projectID, azureRepositoryID string, pullRequestID int) error { + return s.store.ReleasePullRequestWatchTask(ctx, watchID, generation, projectID, azureRepositoryID, pullRequestID) +} + +func (s *Service) DisablePullRequestWatchWithError(ctx context.Context, watchID, cause string) error { + return s.store.DisablePullRequestWatchWithError(ctx, watchID, cause) +} + +func (s *Service) TriggerWorkItemWatch(ctx context.Context, workspaceID, id string) (*WatchTriggerResult, error) { + watch, err := s.authorizedWorkItemWatch(ctx, workspaceID, id) + if err != nil { + return nil, err + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + _ = s.store.SetWorkItemWatchError(ctx, id, err.Error(), time.Now().UTC()) + return nil, err + } + result, err := client.QueryWIQL(ctx, watch.ProjectID, watch.WIQL, 100) + if err != nil { + _ = s.store.SetWorkItemWatchError(ctx, id, err.Error(), time.Now().UTC()) + return nil, err + } + matched := 0 + publishFailed := false + for _, item := range result.Items { + reserved, reserveErr := s.store.ReserveWorkItemWatchTask(ctx, watch.ID, watch.Generation, watch.ProjectID, item.ID, item.WebURL) + if reserveErr != nil { + _ = s.store.SetWorkItemWatchError(ctx, id, reserveErr.Error(), time.Now().UTC()) + return nil, reserveErr + } + if !reserved { + continue + } + if publishErr := s.publishWorkItemWatchEvent(ctx, watch, item); publishErr != nil { + publishFailed = true + _ = s.store.ReleaseWorkItemWatchTask(ctx, watch.ID, watch.Generation, watch.ProjectID, item.ID) + _ = s.store.SetWorkItemWatchError(ctx, id, publishErr.Error(), time.Now().UTC()) + continue + } + matched++ + } + if !publishFailed { + if err := s.store.ClearWorkItemWatchError(ctx, id, time.Now().UTC()); err != nil { + return nil, err + } + } else if err := s.store.SetWorkItemWatchError(ctx, id, "one or more watcher matches could not be published", time.Now().UTC()); err != nil { + return nil, err + } + return &WatchTriggerResult{Matched: matched}, nil +} + +func (s *Service) TriggerPullRequestWatch(ctx context.Context, workspaceID, id string) (*WatchTriggerResult, error) { + watch, err := s.authorizedPullRequestWatch(ctx, workspaceID, id) + if err != nil { + return nil, err + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + _ = s.store.SetPullRequestWatchError(ctx, id, err.Error(), time.Now().UTC()) + return nil, err + } + result, err := client.ListPullRequests(ctx, PullRequestFilter{ + ProjectID: watch.ProjectID, RepositoryID: watch.AzureRepositoryID, + Status: watch.Status, CreatorID: watch.CreatorID, ReviewerID: watch.ReviewerID, + Top: 100, + }) + if err != nil { + _ = s.store.SetPullRequestWatchError(ctx, id, err.Error(), time.Now().UTC()) + return nil, err + } + matched := 0 + publishFailed := false + for _, pullRequest := range result.Items { + reserved, reserveErr := s.store.ReservePullRequestWatchTask(ctx, watch.ID, watch.Generation, watch.ProjectID, pullRequest.RepositoryID, pullRequest.ID, pullRequest.WebURL) + if reserveErr != nil { + _ = s.store.SetPullRequestWatchError(ctx, id, reserveErr.Error(), time.Now().UTC()) + return nil, reserveErr + } + if !reserved { + continue + } + if publishErr := s.publishPullRequestWatchEvent(ctx, watch, pullRequest); publishErr != nil { + publishFailed = true + _ = s.store.ReleasePullRequestWatchTask(ctx, watch.ID, watch.Generation, watch.ProjectID, pullRequest.RepositoryID, pullRequest.ID) + _ = s.store.SetPullRequestWatchError(ctx, id, publishErr.Error(), time.Now().UTC()) + continue + } + matched++ + } + if !publishFailed { + if err := s.store.ClearPullRequestWatchError(ctx, id, time.Now().UTC()); err != nil { + return nil, err + } + } else if err := s.store.SetPullRequestWatchError(ctx, id, "one or more watcher matches could not be published", time.Now().UTC()); err != nil { + return nil, err + } + return &WatchTriggerResult{Matched: matched}, nil +} + +func (s *Service) publishWorkItemWatchEvent(ctx context.Context, watch *WorkItemWatch, item WorkItem) error { + if s.eventBus == nil { + return nil + } + event := &WorkItemWatchEvent{ + WatchID: watch.ID, WatchGeneration: watch.Generation, WorkspaceID: watch.WorkspaceID, + WorkflowID: watch.WorkflowID, WorkflowStepID: watch.WorkflowStepID, + RepositoryID: watch.RepositoryID, BaseBranch: watch.BaseBranch, + AgentProfileID: watch.AgentProfileID, ExecutorProfileID: watch.ExecutorProfileID, + Prompt: watch.Prompt, CleanupPolicy: watch.CleanupPolicy, + MaxInflightTasks: watch.MaxInflightTasks, ProjectID: watch.ProjectID, WorkItem: item, + ReservationClaimed: true, + } + if err := s.eventBus.Publish(ctx, events.AzureDevOpsWorkItemWatchMatch, bus.NewEvent(events.AzureDevOpsWorkItemWatchMatch, "azuredevops", event)); err != nil { + s.log.Warn("azure devops: publish work-item watch event failed") + return err + } + return nil +} + +func (s *Service) publishPullRequestWatchEvent(ctx context.Context, watch *PullRequestWatch, pullRequest PullRequest) error { + if s.eventBus == nil { + return nil + } + event := &PullRequestWatchEvent{ + WatchID: watch.ID, WatchGeneration: watch.Generation, WorkspaceID: watch.WorkspaceID, + WorkflowID: watch.WorkflowID, WorkflowStepID: watch.WorkflowStepID, + RepositoryID: watch.RepositoryID, BaseBranch: watch.BaseBranch, + AgentProfileID: watch.AgentProfileID, ExecutorProfileID: watch.ExecutorProfileID, + Prompt: watch.Prompt, CleanupPolicy: watch.CleanupPolicy, + MaxInflightTasks: watch.MaxInflightTasks, ProjectID: watch.ProjectID, + AzureRepositoryID: pullRequest.RepositoryID, PullRequest: pullRequest, + ReservationClaimed: true, + } + if err := s.eventBus.Publish(ctx, events.AzureDevOpsPullRequestWatchMatch, bus.NewEvent(events.AzureDevOpsPullRequestWatchMatch, "azuredevops", event)); err != nil { + s.log.Warn("azure devops: publish pull-request watch event failed") + return err + } + return nil +} + +func (s *Service) CreateWorkItemWatch(ctx context.Context, req *CreateWorkItemWatchRequest) (*WorkItemWatch, error) { + if req == nil { + return nil, fmt.Errorf("%w: request is required", ErrInvalidConfig) + } + if err := validateWorkspaceID(req.WorkspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil { + return nil, err + } + if err := s.validateWatchDependencies(ctx, req.WorkspaceID, req.WorkflowID, req.WorkflowStepID, req.AgentProfileID, req.ExecutorProfileID); err != nil { + return nil, err + } + repositoryID, baseBranch, err := s.resolveWatchRepository(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch) + if err != nil { + return nil, err + } + watch := &WorkItemWatch{ + WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, + ProjectID: req.ProjectID, WIQL: req.WIQL, RepositoryID: repositoryID, + BaseBranch: baseBranch, AgentProfileID: req.AgentProfileID, + ExecutorProfileID: req.ExecutorProfileID, Prompt: req.Prompt, + PollIntervalSeconds: req.PollIntervalSeconds, CleanupPolicy: req.CleanupPolicy, + MaxInflightTasks: req.MaxInflightTasks, + } + if err := s.store.CreateWorkItemWatch(ctx, watch); err != nil { + return nil, err + } + s.triggerInitialWorkItemWatch(watch) + return watch, nil +} + +func (s *Service) ListWorkItemWatches(ctx context.Context, workspaceID string) ([]*WorkItemWatch, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + return s.store.ListWorkItemWatches(ctx, workspaceID) +} + +func (s *Service) UpdateWorkItemWatch(ctx context.Context, workspaceID, id string, req *UpdateWorkItemWatchRequest) (*WorkItemWatch, error) { + watch, err := s.authorizedWorkItemWatch(ctx, workspaceID, id) + if err != nil { + return nil, err + } + if req != nil { + applyWorkItemWatchUpdate(watch, req) + } + if err := s.validateWatchDependencies(ctx, watch.WorkspaceID, watch.WorkflowID, watch.WorkflowStepID, watch.AgentProfileID, watch.ExecutorProfileID); err != nil { + return nil, err + } + watch.RepositoryID, watch.BaseBranch, err = s.resolveWatchRepository(ctx, watch.WorkspaceID, watch.RepositoryID, watch.BaseBranch) + if err != nil { + return nil, err + } + if err := s.store.UpdateWorkItemWatch(ctx, watch); err != nil { + return nil, err + } + return watch, nil +} + +func (s *Service) DeleteWorkItemWatch(ctx context.Context, workspaceID, id string) error { + if _, err := s.authorizedWorkItemWatch(ctx, workspaceID, id); err != nil { + return err + } + if err := s.cleanupWatchTaskTrees(ctx, id, s.store.ListWorkItemWatchTaskIDs); err != nil { + return err + } + return s.store.DeleteWorkItemWatch(ctx, id) +} + +func (s *Service) PreviewResetWorkItemWatch(ctx context.Context, workspaceID, id string) (int, error) { + if _, err := s.authorizedWorkItemWatch(ctx, workspaceID, id); err != nil { + return 0, err + } + ids, err := s.store.ListWorkItemWatchTaskIDs(ctx, id) + return len(ids), err +} + +func (s *Service) ResetWorkItemWatch(ctx context.Context, workspaceID, id string) (*WatchResetResult, error) { + if _, err := s.authorizedWorkItemWatch(ctx, workspaceID, id); err != nil { + return nil, err + } + reset, err := s.store.BeginWorkItemWatchReset(ctx, id) + if err != nil { + return nil, err + } + s.cleanupTaskIDs(ctx, id, reset.TaskIDs) + if err := s.store.FinishWorkItemWatchReset(ctx, id, reset.Generation); err != nil { + return nil, err + } + return reset, nil +} + +func (s *Service) CreatePullRequestWatch(ctx context.Context, req *CreatePullRequestWatchRequest) (*PullRequestWatch, error) { + if req == nil { + return nil, fmt.Errorf("%w: request is required", ErrInvalidConfig) + } + if err := validateWorkspaceID(req.WorkspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil { + return nil, err + } + if err := s.validateWatchDependencies(ctx, req.WorkspaceID, req.WorkflowID, req.WorkflowStepID, req.AgentProfileID, req.ExecutorProfileID); err != nil { + return nil, err + } + repositoryID, baseBranch, err := s.resolveWatchRepository(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch) + if err != nil { + return nil, err + } + watch := &PullRequestWatch{ + WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, + ProjectID: req.ProjectID, AzureRepositoryID: req.AzureRepositoryID, Status: req.Status, + CreatorID: req.CreatorID, ReviewerID: req.ReviewerID, RepositoryID: repositoryID, + BaseBranch: baseBranch, AgentProfileID: req.AgentProfileID, + ExecutorProfileID: req.ExecutorProfileID, Prompt: req.Prompt, + PollIntervalSeconds: req.PollIntervalSeconds, CleanupPolicy: req.CleanupPolicy, + MaxInflightTasks: req.MaxInflightTasks, + } + if err := s.store.CreatePullRequestWatch(ctx, watch); err != nil { + return nil, err + } + s.triggerInitialPullRequestWatch(watch) + return watch, nil +} + +func (s *Service) triggerInitialWorkItemWatch(watch *WorkItemWatch) { + if watch == nil { + return + } + go func() { + if _, err := s.TriggerWorkItemWatch(context.Background(), watch.WorkspaceID, watch.ID); err != nil { + s.log.Warn("azure devops watcher: initial work-item check failed", zap.String("watch_id", watch.ID), zap.Error(err)) + } + }() +} + +func (s *Service) triggerInitialPullRequestWatch(watch *PullRequestWatch) { + if watch == nil { + return + } + go func() { + if _, err := s.TriggerPullRequestWatch(context.Background(), watch.WorkspaceID, watch.ID); err != nil { + s.log.Warn("azure devops watcher: initial pull-request check failed", zap.String("watch_id", watch.ID), zap.Error(err)) + } + }() +} + +func (s *Service) ListPullRequestWatches(ctx context.Context, workspaceID string) ([]*PullRequestWatch, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + return s.store.ListPullRequestWatches(ctx, workspaceID) +} + +func (s *Service) UpdatePullRequestWatch(ctx context.Context, workspaceID, id string, req *UpdatePullRequestWatchRequest) (*PullRequestWatch, error) { + watch, err := s.authorizedPullRequestWatch(ctx, workspaceID, id) + if err != nil { + return nil, err + } + if req != nil { + applyPullRequestWatchUpdate(watch, req) + } + if err := s.validateWatchDependencies(ctx, watch.WorkspaceID, watch.WorkflowID, watch.WorkflowStepID, watch.AgentProfileID, watch.ExecutorProfileID); err != nil { + return nil, err + } + watch.RepositoryID, watch.BaseBranch, err = s.resolveWatchRepository(ctx, watch.WorkspaceID, watch.RepositoryID, watch.BaseBranch) + if err != nil { + return nil, err + } + if err := s.store.UpdatePullRequestWatch(ctx, watch); err != nil { + return nil, err + } + return watch, nil +} + +func (s *Service) DeletePullRequestWatch(ctx context.Context, workspaceID, id string) error { + if _, err := s.authorizedPullRequestWatch(ctx, workspaceID, id); err != nil { + return err + } + if err := s.cleanupWatchTaskTrees(ctx, id, s.store.ListPullRequestWatchTaskIDs); err != nil { + return err + } + return s.store.DeletePullRequestWatch(ctx, id) +} + +func (s *Service) PreviewResetPullRequestWatch(ctx context.Context, workspaceID, id string) (int, error) { + if _, err := s.authorizedPullRequestWatch(ctx, workspaceID, id); err != nil { + return 0, err + } + ids, err := s.store.ListPullRequestWatchTaskIDs(ctx, id) + return len(ids), err +} + +func (s *Service) ResetPullRequestWatch(ctx context.Context, workspaceID, id string) (*WatchResetResult, error) { + if _, err := s.authorizedPullRequestWatch(ctx, workspaceID, id); err != nil { + return nil, err + } + reset, err := s.store.BeginPullRequestWatchReset(ctx, id) + if err != nil { + return nil, err + } + s.cleanupTaskIDs(ctx, id, reset.TaskIDs) + if err := s.store.FinishPullRequestWatchReset(ctx, id, reset.Generation); err != nil { + return nil, err + } + return reset, nil +} + +func (s *Service) cleanupWatchTaskTrees(ctx context.Context, watchID string, list func(context.Context, string) ([]string, error)) error { + ids, err := list(ctx, watchID) + if err != nil { + return err + } + s.cleanupTaskIDs(ctx, watchID, ids) + return nil +} + +func (s *Service) cleanupTaskIDs(ctx context.Context, watchID string, ids []string) { + deleter := s.getCascadeTaskDeleter() + if deleter == nil { + return + } + for _, taskID := range ids { + if taskID == "" { + continue + } + if _, err := deleter.DeleteTaskTree(ctx, taskID, true); err != nil { + s.log.Warn("azure devops watcher: task cleanup failed", zap.String("watch_id", watchID), zap.String("task_id", taskID), zap.Error(err)) + } + } +} + +func (s *Service) authorizedWorkItemWatch(ctx context.Context, workspaceID, id string) (*WorkItemWatch, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + watch, err := s.store.GetWorkItemWatch(ctx, id) + if err != nil || watch == nil || watch.WorkspaceID != workspaceID || watch.Deleting { + return nil, ErrWatchNotFound + } + return watch, nil +} + +func (s *Service) authorizedPullRequestWatch(ctx context.Context, workspaceID, id string) (*PullRequestWatch, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + watch, err := s.store.GetPullRequestWatch(ctx, id) + if err != nil || watch == nil || watch.WorkspaceID != workspaceID || watch.Deleting { + return nil, ErrWatchNotFound + } + return watch, nil +} + +func applyWorkItemWatchUpdate(watch *WorkItemWatch, req *UpdateWorkItemWatchRequest) { + if req.WorkflowID != nil { + watch.WorkflowID = *req.WorkflowID + } + if req.WorkflowStepID != nil { + watch.WorkflowStepID = *req.WorkflowStepID + } + if req.ProjectID != nil { + watch.ProjectID = *req.ProjectID + } + if req.WIQL != nil { + watch.WIQL = *req.WIQL + } + if req.RepositoryID != nil { + watch.RepositoryID = *req.RepositoryID + } + if req.BaseBranch != nil { + watch.BaseBranch = *req.BaseBranch + } + if req.AgentProfileID != nil { + watch.AgentProfileID = *req.AgentProfileID + } + if req.ExecutorProfileID != nil { + watch.ExecutorProfileID = *req.ExecutorProfileID + } + if req.Prompt != nil { + watch.Prompt = *req.Prompt + } + if req.Enabled != nil { + watch.Enabled = *req.Enabled + } + if req.PollIntervalSeconds != nil { + watch.PollIntervalSeconds = *req.PollIntervalSeconds + } + if req.CleanupPolicy != nil { + watch.CleanupPolicy = *req.CleanupPolicy + } + if req.MaxInflightTasks != nil { + if *req.MaxInflightTasks == 0 { + watch.MaxInflightTasks = nil + } else { + watch.MaxInflightTasks = req.MaxInflightTasks + } + } +} + +func applyPullRequestWatchUpdate(watch *PullRequestWatch, req *UpdatePullRequestWatchRequest) { + applyPullRequestWatchFilters(watch, req) + if req.WorkflowID != nil { + watch.WorkflowID = *req.WorkflowID + } + if req.WorkflowStepID != nil { + watch.WorkflowStepID = *req.WorkflowStepID + } + if req.ProjectID != nil { + watch.ProjectID = *req.ProjectID + } + if req.RepositoryID != nil { + watch.RepositoryID = *req.RepositoryID + } + if req.BaseBranch != nil { + watch.BaseBranch = *req.BaseBranch + } + if req.AgentProfileID != nil { + watch.AgentProfileID = *req.AgentProfileID + } + if req.ExecutorProfileID != nil { + watch.ExecutorProfileID = *req.ExecutorProfileID + } + if req.Prompt != nil { + watch.Prompt = *req.Prompt + } + applyWatchRuntimeUpdate( + &watch.Enabled, + &watch.PollIntervalSeconds, + &watch.CleanupPolicy, + &watch.MaxInflightTasks, + req.Enabled, + req.PollIntervalSeconds, + req.CleanupPolicy, + req.MaxInflightTasks, + ) +} + +func applyPullRequestWatchFilters(watch *PullRequestWatch, req *UpdatePullRequestWatchRequest) { + if req.AzureRepositoryID != nil { + watch.AzureRepositoryID = *req.AzureRepositoryID + } + if req.Status != nil { + watch.Status = *req.Status + } + if req.CreatorID != nil { + watch.CreatorID = *req.CreatorID + } + if req.ReviewerID != nil { + watch.ReviewerID = *req.ReviewerID + } +} + +func applyWatchRuntimeUpdate( + enabled *bool, + pollInterval *int, + cleanupPolicy *string, + maxInflight **int, + requestedEnabled *bool, + requestedPollInterval *int, + requestedCleanupPolicy *string, + requestedMaxInflight *int, +) { + if requestedEnabled != nil { + *enabled = *requestedEnabled + } + if requestedPollInterval != nil { + *pollInterval = *requestedPollInterval + } + if requestedCleanupPolicy != nil { + *cleanupPolicy = *requestedCleanupPolicy + } + if requestedMaxInflight != nil { + if *requestedMaxInflight == 0 { + *maxInflight = nil + } else { + *maxInflight = requestedMaxInflight + } + } +} diff --git a/apps/backend/internal/azuredevops/watch_service_test.go b/apps/backend/internal/azuredevops/watch_service_test.go new file mode 100644 index 0000000000..f8d1606751 --- /dev/null +++ b/apps/backend/internal/azuredevops/watch_service_test.go @@ -0,0 +1,108 @@ +package azuredevops + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +func TestTriggerWorkItemWatchPublishesOnlyNewMatches(t *testing.T) { + client := &fakeReadClient{work: &WorkItemSearchResult{Items: []WorkItem{{ID: 101, Title: "Fix build", WebURL: "https://azure/item/101"}}}} + service, store, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + watch := &WorkItemWatch{WorkspaceID: "ws-1", ProjectID: "project-1", WIQL: "SELECT [System.Id] FROM WorkItems"} + if err := store.CreateWorkItemWatch(t.Context(), watch); err != nil { + t.Fatalf("create watch: %v", err) + } + eventBus := bus.NewMemoryEventBus(logger.Default()) + service.SetEventBus(eventBus) + seen := make(chan *WorkItemWatchEvent, 1) + _, err := eventBus.Subscribe(events.AzureDevOpsWorkItemWatchMatch, func(_ context.Context, event *bus.Event) error { + payload, ok := event.Data.(*WorkItemWatchEvent) + if !ok { + t.Fatalf("event payload = %T", event.Data) + } + seen <- payload + return nil + }) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + result, err := service.TriggerWorkItemWatch(t.Context(), "ws-1", watch.ID) + if err != nil || result.Matched != 1 { + t.Fatalf("trigger result = %+v err=%v", result, err) + } + select { + case event := <-seen: + if event.WorkItem.ID != 101 || event.WatchGeneration != watch.Generation { + t.Fatalf("watch event = %+v", event) + } + default: + t.Fatal("expected work-item watch event") + } + result, err = service.TriggerWorkItemWatch(t.Context(), "ws-1", watch.ID) + if err != nil || result.Matched != 0 { + t.Fatalf("duplicate trigger result = %+v err=%v", result, err) + } +} + +func TestTriggerPullRequestWatchPublishesOnlyNewMatches(t *testing.T) { + client := &fakeReadClient{prPage: &PullRequestPage{Items: []PullRequest{{ID: 42, ProjectID: "project-1", RepositoryID: "azure-repo-1", WebURL: "https://azure/pr/42", Title: "Ship it"}}}} + service, store, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + watch := &PullRequestWatch{WorkspaceID: "ws-1", ProjectID: "project-1", AzureRepositoryID: "azure-repo-1", Status: "active"} + if err := store.CreatePullRequestWatch(t.Context(), watch); err != nil { + t.Fatalf("create watch: %v", err) + } + eventBus := bus.NewMemoryEventBus(logger.Default()) + service.SetEventBus(eventBus) + seen := make(chan *PullRequestWatchEvent, 1) + if _, err := eventBus.Subscribe(events.AzureDevOpsPullRequestWatchMatch, func(_ context.Context, event *bus.Event) error { + seen <- event.Data.(*PullRequestWatchEvent) + return nil + }); err != nil { + t.Fatalf("subscribe: %v", err) + } + result, err := service.TriggerPullRequestWatch(t.Context(), "ws-1", watch.ID) + if err != nil || result.Matched != 1 { + t.Fatalf("trigger result = %+v err=%v", result, err) + } + select { + case event := <-seen: + if event.PullRequest.ID != 42 || event.AzureRepositoryID != "azure-repo-1" { + t.Fatalf("watch event = %+v", event) + } + default: + t.Fatal("expected pull-request watch event") + } +} + +func TestAzurePollerRunsEnabledWatchers(t *testing.T) { + client := &fakeReadClient{ + work: &WorkItemSearchResult{Items: []WorkItem{{ID: 101, WebURL: "https://azure/item/101"}}}, + prPage: &PullRequestPage{Items: []PullRequest{{ID: 42, ProjectID: "project-1", RepositoryID: "azure-repo-1", WebURL: "https://azure/pr/42"}}}, + } + service, store, _ := newTestService(t, func(*Config, string) Client { return client }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-1", &SetConfigRequest{OrganizationURL: "https://dev.azure.com/acme", PAT: "pat"}); err != nil { + t.Fatalf("set config: %v", err) + } + workWatch := &WorkItemWatch{WorkspaceID: "ws-1", ProjectID: "project-1", WIQL: "SELECT [System.Id] FROM WorkItems"} + prWatch := &PullRequestWatch{WorkspaceID: "ws-1", ProjectID: "project-1", AzureRepositoryID: "azure-repo-1"} + if err := store.CreateWorkItemWatch(t.Context(), workWatch); err != nil { + t.Fatalf("create work watch: %v", err) + } + if err := store.CreatePullRequestWatch(t.Context(), prWatch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + poller := NewPoller(service, logger.Default()) + if matched := poller.runWatchChecks(t.Context()); matched != 2 { + t.Fatalf("matched = %d, want 2", matched) + } +} diff --git a/apps/backend/internal/azuredevops/watch_store.go b/apps/backend/internal/azuredevops/watch_store.go new file mode 100644 index 0000000000..48a0bff9a4 --- /dev/null +++ b/apps/backend/internal/azuredevops/watch_store.go @@ -0,0 +1,514 @@ +package azuredevops + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +func normalizeWatchIdentity(id string) string { + if id == "" { + return uuid.NewString() + } + return id +} + +func normalizeWorkItemWatch(watch *WorkItemWatch) error { + if watch == nil || watch.WorkspaceID == "" || watch.ProjectID == "" || watch.WIQL == "" { + return fmt.Errorf("%w: workspace, project, and WIQL are required", ErrInvalidConfig) + } + if !IsValidCleanupPolicy(watch.CleanupPolicy) { + return fmt.Errorf("%w: invalid cleanup policy", ErrInvalidConfig) + } + if err := validateMaxInflightTasks(watch.MaxInflightTasks); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + watch.ID = normalizeWatchIdentity(watch.ID) + watch.PollIntervalSeconds = normalizePollInterval(watch.PollIntervalSeconds) + watch.CleanupPolicy = normalizeCleanupPolicy(watch.CleanupPolicy) + if watch.Generation <= 0 { + watch.Generation = 1 + } + return nil +} + +func normalizePullRequestWatch(watch *PullRequestWatch) error { + if watch == nil || watch.WorkspaceID == "" || watch.ProjectID == "" { + return fmt.Errorf("%w: workspace and project are required", ErrInvalidConfig) + } + if !IsValidCleanupPolicy(watch.CleanupPolicy) { + return fmt.Errorf("%w: invalid cleanup policy", ErrInvalidConfig) + } + if err := validateMaxInflightTasks(watch.MaxInflightTasks); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + watch.ID = normalizeWatchIdentity(watch.ID) + watch.PollIntervalSeconds = normalizePollInterval(watch.PollIntervalSeconds) + watch.CleanupPolicy = normalizeCleanupPolicy(watch.CleanupPolicy) + if watch.Status == "" { + watch.Status = activePullRequestState + } + if watch.Generation <= 0 { + watch.Generation = 1 + } + return nil +} + +func (s *Store) CreateWorkItemWatch(ctx context.Context, watch *WorkItemWatch) error { + if err := normalizeWorkItemWatch(watch); err != nil { + return err + } + watch.Enabled = true + now := time.Now().UTC() + if watch.CreatedAt.IsZero() { + watch.CreatedAt = now + } + watch.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO azure_devops_work_item_watches + (id, workspace_id, workflow_id, workflow_step_id, project_id, wiql, + repository_id, base_branch, agent_profile_id, executor_profile_id, prompt, + enabled, poll_interval_seconds, cleanup_policy, max_inflight_tasks, + generation, deleting, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + watch.ID, watch.WorkspaceID, watch.WorkflowID, watch.WorkflowStepID, + watch.ProjectID, watch.WIQL, watch.RepositoryID, watch.BaseBranch, + watch.AgentProfileID, watch.ExecutorProfileID, watch.Prompt, watch.Enabled, + watch.PollIntervalSeconds, watch.CleanupPolicy, watch.MaxInflightTasks, + watch.Generation, watch.Deleting, watch.LastError, watch.CreatedAt, watch.UpdatedAt) + return err +} + +func (s *Store) GetWorkItemWatch(ctx context.Context, id string) (*WorkItemWatch, error) { + var watch WorkItemWatch + err := s.ro.GetContext(ctx, &watch, `SELECT id, workspace_id, workflow_id, + workflow_step_id, project_id, wiql, repository_id, base_branch, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, cleanup_policy, max_inflight_tasks, generation, + deleting, last_error, last_error_at, last_polled_at, created_at, updated_at + FROM azure_devops_work_item_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &watch, nil +} + +func (s *Store) ListWorkItemWatches(ctx context.Context, workspaceID string) ([]*WorkItemWatch, error) { + var watches []*WorkItemWatch + err := s.ro.SelectContext(ctx, &watches, `SELECT id, workspace_id, workflow_id, + workflow_step_id, project_id, wiql, repository_id, base_branch, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, cleanup_policy, max_inflight_tasks, generation, + deleting, last_error, last_error_at, last_polled_at, created_at, updated_at + FROM azure_devops_work_item_watches WHERE workspace_id = ? AND deleting = 0 + ORDER BY created_at, id`, workspaceID) + return watches, err +} + +func (s *Store) ListEnabledWorkItemWatches(ctx context.Context) ([]*WorkItemWatch, error) { + var watches []*WorkItemWatch + err := s.ro.SelectContext(ctx, &watches, `SELECT id, workspace_id, workflow_id, + workflow_step_id, project_id, wiql, repository_id, base_branch, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, cleanup_policy, max_inflight_tasks, generation, + deleting, last_error, last_error_at, last_polled_at, created_at, updated_at + FROM azure_devops_work_item_watches WHERE enabled = 1 AND deleting = 0 + ORDER BY last_polled_at IS NOT NULL, last_polled_at, id`) + return watches, err +} + +func (s *Store) UpdateWorkItemWatch(ctx context.Context, watch *WorkItemWatch) error { + if err := normalizeWorkItemWatch(watch); err != nil { + return err + } + watch.UpdatedAt = time.Now().UTC() + result, err := s.db.ExecContext(ctx, `UPDATE azure_devops_work_item_watches SET + workflow_id = ?, workflow_step_id = ?, project_id = ?, wiql = ?, + repository_id = ?, base_branch = ?, agent_profile_id = ?, + executor_profile_id = ?, prompt = ?, enabled = ?, poll_interval_seconds = ?, + cleanup_policy = ?, max_inflight_tasks = ?, updated_at = ? + WHERE id = ? AND deleting = 0`, + watch.WorkflowID, watch.WorkflowStepID, watch.ProjectID, watch.WIQL, + watch.RepositoryID, watch.BaseBranch, watch.AgentProfileID, + watch.ExecutorProfileID, watch.Prompt, watch.Enabled, watch.PollIntervalSeconds, + watch.CleanupPolicy, watch.MaxInflightTasks, watch.UpdatedAt, watch.ID) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return ErrWatchNotFound + } + return nil +} + +func (s *Store) CreatePullRequestWatch(ctx context.Context, watch *PullRequestWatch) error { + if err := normalizePullRequestWatch(watch); err != nil { + return err + } + watch.Enabled = true + now := time.Now().UTC() + if watch.CreatedAt.IsZero() { + watch.CreatedAt = now + } + watch.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO azure_devops_pull_request_watches + (id, workspace_id, workflow_id, workflow_step_id, project_id, + azure_repository_id, status, creator_id, reviewer_id, repository_id, + base_branch, agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, cleanup_policy, max_inflight_tasks, generation, + deleting, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + watch.ID, watch.WorkspaceID, watch.WorkflowID, watch.WorkflowStepID, + watch.ProjectID, watch.AzureRepositoryID, watch.Status, watch.CreatorID, + watch.ReviewerID, watch.RepositoryID, watch.BaseBranch, watch.AgentProfileID, + watch.ExecutorProfileID, watch.Prompt, watch.Enabled, watch.PollIntervalSeconds, + watch.CleanupPolicy, watch.MaxInflightTasks, watch.Generation, watch.Deleting, + watch.LastError, watch.CreatedAt, watch.UpdatedAt) + return err +} + +func (s *Store) GetPullRequestWatch(ctx context.Context, id string) (*PullRequestWatch, error) { + var watch PullRequestWatch + err := s.ro.GetContext(ctx, &watch, `SELECT id, workspace_id, workflow_id, + workflow_step_id, project_id, azure_repository_id, status, creator_id, + reviewer_id, repository_id, base_branch, agent_profile_id, + executor_profile_id, prompt, enabled, poll_interval_seconds, cleanup_policy, + max_inflight_tasks, generation, deleting, last_error, last_error_at, + last_polled_at, created_at, updated_at + FROM azure_devops_pull_request_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &watch, nil +} + +func (s *Store) ListPullRequestWatches(ctx context.Context, workspaceID string) ([]*PullRequestWatch, error) { + var watches []*PullRequestWatch + err := s.ro.SelectContext(ctx, &watches, `SELECT id, workspace_id, workflow_id, + workflow_step_id, project_id, azure_repository_id, status, creator_id, + reviewer_id, repository_id, base_branch, agent_profile_id, + executor_profile_id, prompt, enabled, poll_interval_seconds, cleanup_policy, + max_inflight_tasks, generation, deleting, last_error, last_error_at, + last_polled_at, created_at, updated_at + FROM azure_devops_pull_request_watches WHERE workspace_id = ? AND deleting = 0 + ORDER BY created_at, id`, workspaceID) + return watches, err +} + +func (s *Store) ListEnabledPullRequestWatches(ctx context.Context) ([]*PullRequestWatch, error) { + var watches []*PullRequestWatch + err := s.ro.SelectContext(ctx, &watches, `SELECT id, workspace_id, workflow_id, + workflow_step_id, project_id, azure_repository_id, status, creator_id, + reviewer_id, repository_id, base_branch, agent_profile_id, + executor_profile_id, prompt, enabled, poll_interval_seconds, cleanup_policy, + max_inflight_tasks, generation, deleting, last_error, last_error_at, + last_polled_at, created_at, updated_at + FROM azure_devops_pull_request_watches WHERE enabled = 1 AND deleting = 0 + ORDER BY last_polled_at IS NOT NULL, last_polled_at, id`) + return watches, err +} + +func (s *Store) UpdatePullRequestWatch(ctx context.Context, watch *PullRequestWatch) error { + if err := normalizePullRequestWatch(watch); err != nil { + return err + } + watch.UpdatedAt = time.Now().UTC() + result, err := s.db.ExecContext(ctx, `UPDATE azure_devops_pull_request_watches SET + workflow_id = ?, workflow_step_id = ?, project_id = ?, azure_repository_id = ?, + status = ?, creator_id = ?, reviewer_id = ?, repository_id = ?, base_branch = ?, + agent_profile_id = ?, executor_profile_id = ?, prompt = ?, enabled = ?, + poll_interval_seconds = ?, cleanup_policy = ?, max_inflight_tasks = ?, + updated_at = ? WHERE id = ? AND deleting = 0`, + watch.WorkflowID, watch.WorkflowStepID, watch.ProjectID, watch.AzureRepositoryID, + watch.Status, watch.CreatorID, watch.ReviewerID, watch.RepositoryID, + watch.BaseBranch, watch.AgentProfileID, watch.ExecutorProfileID, watch.Prompt, + watch.Enabled, watch.PollIntervalSeconds, watch.CleanupPolicy, + watch.MaxInflightTasks, watch.UpdatedAt, watch.ID) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return ErrWatchNotFound + } + return nil +} + +func (s *Store) SetWorkItemWatchError(ctx context.Context, id, message string, checkedAt time.Time) error { + return s.setWatchError(ctx, "azure_devops_work_item_watches", id, message, checkedAt) +} + +func (s *Store) SetPullRequestWatchError(ctx context.Context, id, message string, checkedAt time.Time) error { + return s.setWatchError(ctx, "azure_devops_pull_request_watches", id, message, checkedAt) +} + +func (s *Store) setWatchError(ctx context.Context, table, id, message string, checkedAt time.Time) error { + _, err := s.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET last_error = ?, last_error_at = ?, last_polled_at = ?, updated_at = ? WHERE id = ? AND deleting = 0`, table), message, checkedAt, checkedAt, checkedAt, id) + return err +} + +func (s *Store) ClearWorkItemWatchError(ctx context.Context, id string, polledAt time.Time) error { + return s.clearWatchError(ctx, "azure_devops_work_item_watches", id, polledAt) +} + +func (s *Store) ClearPullRequestWatchError(ctx context.Context, id string, polledAt time.Time) error { + return s.clearWatchError(ctx, "azure_devops_pull_request_watches", id, polledAt) +} + +func (s *Store) clearWatchError(ctx context.Context, table, id string, polledAt time.Time) error { + _, err := s.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET last_error = '', last_error_at = NULL, last_polled_at = ?, updated_at = ? WHERE id = ? AND deleting = 0`, table), polledAt, polledAt, id) + return err +} + +func (s *Store) DisableWorkItemWatchWithError(ctx context.Context, id, cause string) error { + return s.disableWatchWithError(ctx, "azure_devops_work_item_watches", id, cause) +} + +func (s *Store) DisablePullRequestWatchWithError(ctx context.Context, id, cause string) error { + return s.disableWatchWithError(ctx, "azure_devops_pull_request_watches", id, cause) +} + +func (s *Store) disableWatchWithError(ctx context.Context, table, id, cause string) error { + result, err := s.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET enabled = 0, last_error = ?, last_error_at = ?, updated_at = ? WHERE id = ? AND deleting = 0`, table), cause, time.Now().UTC(), time.Now().UTC(), id) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return ErrWatchNotFound + } + return nil +} + +func (s *Store) DeleteWorkItemWatch(ctx context.Context, id string) error { + return s.deleteWatch(ctx, "azure_devops_work_item_watches", "azure_devops_work_item_watch_tasks", id) +} + +func (s *Store) DeletePullRequestWatch(ctx context.Context, id string) error { + return s.deleteWatch(ctx, "azure_devops_pull_request_watches", "azure_devops_pull_request_watch_tasks", id) +} + +func (s *Store) deleteWatch(ctx context.Context, watchTable, taskTable, id string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE watch_id = ?`, taskTable), id); err != nil { + return err + } + result, err := tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE id = ?`, watchTable), id) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return ErrWatchNotFound + } + return tx.Commit() +} + +func (s *Store) DeleteWorkItemWatchTasksByTask(ctx context.Context, taskID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM azure_devops_work_item_watch_tasks WHERE task_id = ?`, taskID) + return err +} + +func (s *Store) DeletePullRequestWatchTasksByTask(ctx context.Context, taskID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM azure_devops_pull_request_watch_tasks WHERE task_id = ?`, taskID) + return err +} + +func (s *Store) DeleteWatchesByWorkspace(ctx context.Context, workspaceID string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + // These tables intentionally do not rely on SQLite foreign-key pragma + // state. Delete reservations first so a workspace removal cannot leave + // orphaned provider matches that continue to block future imports. + for _, query := range []string{ + `DELETE FROM azure_devops_work_item_watch_tasks WHERE watch_id IN (SELECT id FROM azure_devops_work_item_watches WHERE workspace_id = ?)`, + `DELETE FROM azure_devops_pull_request_watch_tasks WHERE watch_id IN (SELECT id FROM azure_devops_pull_request_watches WHERE workspace_id = ?)`, + `DELETE FROM azure_devops_work_item_watches WHERE workspace_id = ?`, + `DELETE FROM azure_devops_pull_request_watches WHERE workspace_id = ?`, + } { + if _, err := tx.ExecContext(ctx, query, workspaceID); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) ListWorkItemWatchTasks(ctx context.Context, watchID string, generation int64) ([]*WorkItemWatchTask, error) { + var rows []WorkItemWatchTask + err := s.ro.SelectContext(ctx, &rows, `SELECT id, watch_id, project_id, work_item_id, + work_item_url, task_id, generation, created_at + FROM azure_devops_work_item_watch_tasks + WHERE watch_id = ? AND generation = ? ORDER BY created_at`, watchID, generation) + if err != nil { + return nil, err + } + result := make([]*WorkItemWatchTask, 0, len(rows)) + for i := range rows { + result = append(result, &rows[i]) + } + return result, nil +} + +func (s *Store) ListPullRequestWatchTasks(ctx context.Context, watchID string, generation int64) ([]*PullRequestWatchTask, error) { + var rows []PullRequestWatchTask + err := s.ro.SelectContext(ctx, &rows, `SELECT id, watch_id, project_id, + azure_repository_id, pull_request_id, pull_request_url, task_id, generation, + created_at FROM azure_devops_pull_request_watch_tasks + WHERE watch_id = ? AND generation = ? ORDER BY created_at`, watchID, generation) + if err != nil { + return nil, err + } + result := make([]*PullRequestWatchTask, 0, len(rows)) + for i := range rows { + result = append(result, &rows[i]) + } + return result, nil +} + +func (s *Store) DeleteWorkItemWatchTask(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM azure_devops_work_item_watch_tasks WHERE id = ?`, id) + return err +} + +func (s *Store) DeletePullRequestWatchTask(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM azure_devops_pull_request_watch_tasks WHERE id = ?`, id) + return err +} + +func (s *Store) ReserveWorkItemWatchTask(ctx context.Context, watchID string, generation int64, projectID string, workItemID int, workItemURL string) (bool, error) { + return s.reserveWatchTask(ctx, `azure_devops_work_item_watch_tasks`, ` + INSERT INTO azure_devops_work_item_watch_tasks + (id, watch_id, project_id, work_item_id, work_item_url, task_id, generation, created_at) + SELECT ?, ?, ?, ?, ?, '', ?, ? WHERE EXISTS ( + SELECT 1 FROM azure_devops_work_item_watches WHERE id = ? AND generation = ? AND enabled = 1 AND deleting = 0 + ) ON CONFLICT(watch_id, generation, project_id, work_item_id) DO NOTHING`, + []any{uuid.NewString(), watchID, projectID, workItemID, workItemURL, generation, time.Now().UTC(), watchID, generation}) +} + +func (s *Store) AssignWorkItemWatchTaskID(ctx context.Context, watchID string, generation int64, projectID string, workItemID int, taskID string) error { + return s.assignWatchTaskID(ctx, `azure_devops_work_item_watch_tasks`, watchID, generation, taskID, "project_id = ? AND work_item_id = ?", projectID, workItemID) +} + +func (s *Store) ReleaseWorkItemWatchTask(ctx context.Context, watchID string, generation int64, projectID string, workItemID int) error { + return s.releaseWatchTask(ctx, `azure_devops_work_item_watch_tasks`, watchID, generation, "project_id = ? AND work_item_id = ?", projectID, workItemID) +} + +func (s *Store) ReservePullRequestWatchTask(ctx context.Context, watchID string, generation int64, projectID, azureRepositoryID string, pullRequestID int, pullRequestURL string) (bool, error) { + return s.reserveWatchTask(ctx, `azure_devops_pull_request_watch_tasks`, ` + INSERT INTO azure_devops_pull_request_watch_tasks + (id, watch_id, project_id, azure_repository_id, pull_request_id, pull_request_url, task_id, generation, created_at) + SELECT ?, ?, ?, ?, ?, ?, '', ?, ? WHERE EXISTS ( + SELECT 1 FROM azure_devops_pull_request_watches WHERE id = ? AND generation = ? AND enabled = 1 AND deleting = 0 + ) ON CONFLICT(watch_id, generation, project_id, azure_repository_id, pull_request_id) DO NOTHING`, + []any{uuid.NewString(), watchID, projectID, azureRepositoryID, pullRequestID, pullRequestURL, generation, time.Now().UTC(), watchID, generation}) +} + +func (s *Store) AssignPullRequestWatchTaskID(ctx context.Context, watchID string, generation int64, projectID, azureRepositoryID string, pullRequestID int, taskID string) error { + return s.assignWatchTaskID(ctx, `azure_devops_pull_request_watch_tasks`, watchID, generation, taskID, "project_id = ? AND azure_repository_id = ? AND pull_request_id = ?", projectID, azureRepositoryID, pullRequestID) +} + +func (s *Store) ReleasePullRequestWatchTask(ctx context.Context, watchID string, generation int64, projectID, azureRepositoryID string, pullRequestID int) error { + return s.releaseWatchTask(ctx, `azure_devops_pull_request_watch_tasks`, watchID, generation, "project_id = ? AND azure_repository_id = ? AND pull_request_id = ?", projectID, azureRepositoryID, pullRequestID) +} + +func (s *Store) reserveWatchTask(ctx context.Context, table, query string, args []any) (bool, error) { + result, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return false, err + } + count, err := result.RowsAffected() + return count == 1, err +} + +func (s *Store) assignWatchTaskID(ctx context.Context, table, watchID string, generation int64, taskID, identity string, identityArgs ...any) error { + args := []any{taskID, watchID, generation} + args = append(args, identityArgs...) + result, err := s.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET task_id = ? WHERE watch_id = ? AND generation = ? AND %s`, table, identity), args...) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + watch, watchErr := s.watchGeneration(ctx, watchID) + if watchErr != nil || watch == nil || watch.Generation != generation || watch.Deleting || !watch.Enabled { + return ErrWatchOwnershipLost + } + return ErrReservationNotFound + } + return nil +} + +func (s *Store) releaseWatchTask(ctx context.Context, table, watchID string, generation int64, identity string, identityArgs ...any) error { + args := []any{watchID, generation} + args = append(args, identityArgs...) + result, err := s.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE watch_id = ? AND generation = ? AND %s`, table, identity), args...) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return ErrReservationNotFound + } + return nil +} + +type watchGenerationRow struct { + Generation int64 `db:"generation"` + Deleting bool `db:"deleting"` + Enabled bool `db:"enabled"` +} + +func (s *Store) watchGeneration(ctx context.Context, watchID string) (*watchGenerationRow, error) { + var row watchGenerationRow + queries := []string{ + `SELECT generation, deleting, enabled FROM azure_devops_work_item_watches WHERE id = ?`, + `SELECT generation, deleting, enabled FROM azure_devops_pull_request_watches WHERE id = ?`, + } + for _, query := range queries { + err := s.ro.GetContext(ctx, &row, query, watchID) + if err == nil { + return &row, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + } + return nil, nil +} diff --git a/apps/backend/internal/azuredevops/watch_store_test.go b/apps/backend/internal/azuredevops/watch_store_test.go new file mode 100644 index 0000000000..c39e95bac2 --- /dev/null +++ b/apps/backend/internal/azuredevops/watch_store_test.go @@ -0,0 +1,129 @@ +package azuredevops + +import ( + "context" + "errors" + "testing" +) + +func TestAzureWatchStoreRoundTripAndGenerationReservations(t *testing.T) { + store, err := NewStore(newTestDB(t), nil) + if err != nil { + t.Fatalf("new store: %v", err) + } + + watch := &WorkItemWatch{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + ProjectID: "project-1", + WIQL: "SELECT [System.Id] FROM WorkItems", + RepositoryID: "repo-1", + BaseBranch: "main", + AgentProfileID: "agent-1", + ExecutorProfileID: "executor-1", + Prompt: "Fix {{title}}", + Enabled: true, + CleanupPolicy: CleanupPolicyAuto, + MaxInflightTasks: intPtr(2), + PollIntervalSeconds: 10, + } + if err := store.CreateWorkItemWatch(context.Background(), watch); err != nil { + t.Fatalf("create work-item watch: %v", err) + } + if watch.ID == "" || watch.Generation != 1 || watch.PollIntervalSeconds != minWatchPollIntervalSeconds { + t.Fatalf("normalized watch = %+v", watch) + } + got, err := store.GetWorkItemWatch(context.Background(), watch.ID) + if err != nil { + t.Fatalf("get work-item watch: %v", err) + } + if got == nil || got.WorkspaceID != "ws-1" || got.WIQL != watch.WIQL || got.MaxInflightTasks == nil || *got.MaxInflightTasks != 2 { + t.Fatalf("round-trip watch = %+v", got) + } + + reserved, err := store.ReserveWorkItemWatchTask(context.Background(), watch.ID, watch.Generation, "project-1", 101, "https://azure/item/101") + if err != nil || !reserved { + t.Fatalf("first reservation: reserved=%v err=%v", reserved, err) + } + reserved, err = store.ReserveWorkItemWatchTask(context.Background(), watch.ID, watch.Generation, "project-1", 101, "https://azure/item/101") + if err != nil || reserved { + t.Fatalf("duplicate reservation: reserved=%v err=%v", reserved, err) + } + if err := store.AssignWorkItemWatchTaskID(context.Background(), watch.ID, watch.Generation, "project-1", 101, "task-1"); err != nil { + t.Fatalf("attach task: %v", err) + } + + reset, err := store.BeginWorkItemWatchReset(context.Background(), watch.ID) + if err != nil { + t.Fatalf("begin reset: %v", err) + } + if reset.Generation != watch.Generation+1 || len(reset.TaskIDs) != 1 || reset.TaskIDs[0] != "task-1" { + t.Fatalf("reset = %+v", reset) + } + if err := store.FinishWorkItemWatchReset(context.Background(), watch.ID, reset.Generation); err != nil { + t.Fatalf("finish reset: %v", err) + } + reserved, err = store.ReserveWorkItemWatchTask(context.Background(), watch.ID, reset.Generation, "project-1", 101, "https://azure/item/101") + if err != nil || !reserved { + t.Fatalf("reservation after reset: reserved=%v err=%v", reserved, err) + } +} + +func TestAzurePullRequestWatchStoreIsWorkspaceScoped(t *testing.T) { + store, err := NewStore(newTestDB(t), nil) + if err != nil { + t.Fatalf("new store: %v", err) + } + first := &PullRequestWatch{WorkspaceID: "ws-1", ProjectID: "project-1", AzureRepositoryID: "azure-repo-1", Status: "active"} + second := &PullRequestWatch{WorkspaceID: "ws-2", ProjectID: "project-2", AzureRepositoryID: "azure-repo-2", Status: "active"} + for _, watch := range []*PullRequestWatch{first, second} { + if err := store.CreatePullRequestWatch(context.Background(), watch); err != nil { + t.Fatalf("create pull-request watch: %v", err) + } + } + list, err := store.ListPullRequestWatches(context.Background(), "ws-1") + if err != nil || len(list) != 1 || list[0].ID != first.ID { + t.Fatalf("workspace watch list = %+v err=%v", list, err) + } + if _, err := store.GetPullRequestWatch(context.Background(), second.ID); err != nil { + t.Fatalf("get second watch: %v", err) + } + if err := store.AssignPullRequestWatchTaskID(context.Background(), first.ID, first.Generation, "project-1", "azure-repo-1", 42, "task-42"); !errors.Is(err, ErrReservationNotFound) { + t.Fatalf("attach without reservation error = %v, want ErrReservationNotFound", err) + } +} + +func TestAzureWatchStoreWorkspaceDeletionRemovesReservations(t *testing.T) { + store, err := NewStore(newTestDB(t), nil) + if err != nil { + t.Fatalf("new store: %v", err) + } + work := &WorkItemWatch{WorkspaceID: "ws-delete", ProjectID: "project-1", WIQL: "SELECT"} + pr := &PullRequestWatch{WorkspaceID: "ws-delete", ProjectID: "project-1", AzureRepositoryID: "repo-1"} + if err := store.CreateWorkItemWatch(t.Context(), work); err != nil { + t.Fatalf("create work watch: %v", err) + } + if err := store.CreatePullRequestWatch(t.Context(), pr); err != nil { + t.Fatalf("create PR watch: %v", err) + } + if ok, err := store.ReserveWorkItemWatchTask(t.Context(), work.ID, work.Generation, work.ProjectID, 101, "item"); err != nil || !ok { + t.Fatalf("reserve work item: %v %v", ok, err) + } + if ok, err := store.ReservePullRequestWatchTask(t.Context(), pr.ID, pr.Generation, pr.ProjectID, pr.AzureRepositoryID, 42, "pr"); err != nil || !ok { + t.Fatalf("reserve pull request: %v %v", ok, err) + } + if err := store.DeleteWatchesByWorkspace(t.Context(), "ws-delete"); err != nil { + t.Fatalf("delete workspace watches: %v", err) + } + workRows, err := store.ListWorkItemWatchTasks(t.Context(), work.ID, work.Generation) + if err != nil || len(workRows) != 0 { + t.Fatalf("work reservations after workspace deletion = %v, %v", workRows, err) + } + prRows, err := store.ListPullRequestWatchTasks(t.Context(), pr.ID, pr.Generation) + if err != nil || len(prRows) != 0 { + t.Fatalf("PR reservations after workspace deletion = %v, %v", prRows, err) + } +} + +func intPtr(value int) *int { return &value } diff --git a/apps/backend/internal/azuredevops/work_item_mutations.go b/apps/backend/internal/azuredevops/work_item_mutations.go new file mode 100644 index 0000000000..26ce8603b2 --- /dev/null +++ b/apps/backend/internal/azuredevops/work_item_mutations.go @@ -0,0 +1,76 @@ +package azuredevops + +import ( + "context" + "fmt" + "strings" +) + +// WorkItemAssignmentRequest is the intentionally narrow mutation surface for +// the detail view. Azure work-item fields stay provider-owned; Kandev only +// changes the assignee and relies on board mutations for status changes. +type WorkItemAssignmentRequest struct { + Revision int `json:"revision"` + AssigneeAction *string `json:"assigneeAction,omitempty"` + + resolvedAssignee string + hasResolvedAssignee bool +} + +type workItemMutationClient interface { + UpdateWorkItem(ctx context.Context, projectID string, id int, request WorkItemAssignmentRequest) (*WorkItem, error) +} + +func (s *Service) UpdateWorkItemForWorkspace(ctx context.Context, workspaceID, projectID string, id int, request WorkItemAssignmentRequest) (*WorkItem, error) { + if strings.TrimSpace(projectID) == "" || id <= 0 { + return nil, fmt.Errorf("%w: project and positive work item id required", ErrInvalidConfig) + } + if err := validateWorkItemAssignment(request); err != nil { + return nil, err + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + writer, ok := client.(workItemMutationClient) + if !ok { + return nil, fmt.Errorf("%w: work item assignment is unavailable", ErrNotConfigured) + } + if request.AssigneeAction == nil { + return writer.UpdateWorkItem(ctx, projectID, id, request) + } + if *request.AssigneeAction != assignCurrentUserAction { + request.hasResolvedAssignee = true + return writer.UpdateWorkItem(ctx, projectID, id, request) + } + identityReader, available := client.(interface { + GetCurrentIdentity(context.Context) (*Identity, error) + }) + if !available { + return nil, fmt.Errorf("%w: current identity is unavailable", ErrNotConfigured) + } + identity, identityErr := identityReader.GetCurrentIdentity(ctx) + if identityErr != nil { + return nil, identityErr + } + assignee := strings.TrimSpace(identity.UniqueName) + if assignee == "" { + assignee = strings.TrimSpace(identity.DisplayName) + } + if assignee == "" { + return nil, fmt.Errorf("%w: current identity has no assignable name", ErrInvalidConfig) + } + request.resolvedAssignee = assignee + request.hasResolvedAssignee = true + return writer.UpdateWorkItem(ctx, projectID, id, request) +} + +func validateWorkItemAssignment(request WorkItemAssignmentRequest) error { + if request.Revision <= 0 { + return fmt.Errorf("%w: revision required", ErrInvalidConfig) + } + if request.AssigneeAction == nil || (*request.AssigneeAction != assignCurrentUserAction && *request.AssigneeAction != unassignAction) { + return fmt.Errorf("%w: an assign_current_user or unassign action is required", ErrInvalidConfig) + } + return nil +} diff --git a/apps/backend/internal/azuredevops/workspace_settings.go b/apps/backend/internal/azuredevops/workspace_settings.go new file mode 100644 index 0000000000..e9cfcb334c --- /dev/null +++ b/apps/backend/internal/azuredevops/workspace_settings.go @@ -0,0 +1,244 @@ +package azuredevops + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// QueryPreset is a provider-native browse query offered in the Azure scope bar. +type QueryPreset struct { + ID string `json:"id"` + Label string `json:"label"` + Group string `json:"group"` + Filters map[string]any `json:"filters"` +} + +// ActionPreset is a configurable task launcher entry for an Azure item. +type ActionPreset struct { + ID string `json:"id"` + Label string `json:"label"` + Hint string `json:"hint"` + Icon string `json:"icon"` + PromptTemplate string `json:"promptTemplate"` +} + +type WorkspaceSettings struct { + WorkspaceID string `json:"workspaceId"` + WorkItemQueries []QueryPreset `json:"workItemQueries"` + PullRequestQueries []QueryPreset `json:"pullRequestQueries"` + WorkItemActions []ActionPreset `json:"workItemActions"` + PullRequestActions []ActionPreset `json:"pullRequestActions"` +} + +type UpdateWorkspaceSettingsRequest struct { + WorkspaceID string `json:"-"` + WorkItemQueries *[]QueryPreset `json:"workItemQueries,omitempty"` + PullRequestQueries *[]QueryPreset `json:"pullRequestQueries,omitempty"` + WorkItemActions *[]ActionPreset `json:"workItemActions,omitempty"` + PullRequestActions *[]ActionPreset `json:"pullRequestActions,omitempty"` + WorkItemQueriesSet bool `json:"-"` + PullRequestQueriesSet bool `json:"-"` + WorkItemActionsSet bool `json:"-"` + PullRequestActionsSet bool `json:"-"` +} + +func (r *UpdateWorkspaceSettingsRequest) UnmarshalJSON(data []byte) error { + type alias UpdateWorkspaceSettingsRequest + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + var decoded alias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = UpdateWorkspaceSettingsRequest(decoded) + if _, ok := raw["workItemQueries"]; ok { + r.WorkItemQueriesSet = true + } + if _, ok := raw["pullRequestQueries"]; ok { + r.PullRequestQueriesSet = true + } + if _, ok := raw["workItemActions"]; ok { + r.WorkItemActionsSet = true + } + if _, ok := raw["pullRequestActions"]; ok { + r.PullRequestActionsSet = true + } + return nil +} + +type workspaceSettingsOverrides map[string]json.RawMessage + +const workspaceSettingsUpdateAttempts = 3 + +func DefaultWorkItemQueryPresets() []QueryPreset { + const start = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project" + const order = " ORDER BY [System.ChangedDate] DESC" + return []QueryPreset{ + {ID: "recent", Label: "Recently updated", Group: "inbox", Filters: map[string]any{"wiql": start + order, "top": 50}}, + {ID: "assigned", Label: "Assigned to me", Group: "inbox", Filters: map[string]any{"wiql": start + " AND [System.AssignedTo] = @Me" + order, "top": 50}}, + {ID: "active", Label: "Active", Group: "inbox", Filters: map[string]any{"wiql": start + " AND [System.State] <> 'Closed' AND [System.State] <> 'Done'" + order, "top": 50}}, + {ID: "created", Label: "Created by me", Group: "created", Filters: map[string]any{"wiql": start + " AND [System.CreatedBy] = @Me" + order, "top": 50}}, + } +} +func DefaultPullRequestQueryPresets() []QueryPreset { + return []QueryPreset{ + {ID: "review-requested", Label: "Review requested", Group: "inbox", Filters: map[string]any{"status": "active", "reviewer": "@me", "creator": ""}}, + {ID: "active", Label: "Open", Group: "inbox", Filters: map[string]any{"status": "active", "reviewer": "", "creator": ""}}, + {ID: "completed", Label: "Completed", Group: "created", Filters: map[string]any{"status": "completed", "reviewer": "", "creator": ""}}, + {ID: "created", Label: "Created by me", Group: "created", Filters: map[string]any{"status": "active", "reviewer": "", "creator": "@me"}}, + } +} +func DefaultWorkItemActionPresets() []ActionPreset { + return []ActionPreset{ + {ID: "implement", Label: "Implement", Hint: "Build and open a PR", Icon: "code", PromptTemplate: "Implement the Azure DevOps work item at {{url}} (title: \"{{title}}\"). Open a pull request when complete."}, + {ID: "investigate", Label: "Investigate", Hint: "Find the root cause", Icon: "search", PromptTemplate: "Investigate the Azure DevOps work item at {{url}} (title: \"{{title}}\"). Identify the root cause and summarize findings."}, + {ID: "reproduce", Label: "Reproduce", Hint: "Document repro steps", Icon: "bug", PromptTemplate: "Reproduce the Azure DevOps work item at {{url}} (title: \"{{title}}\"). Document the reproduction steps."}, + } +} +func DefaultPullRequestActionPresets() []ActionPreset { + return []ActionPreset{ + {ID: "review", Label: "Review", Hint: "Read the diff, flag issues", Icon: "eye", PromptTemplate: "Review the Azure DevOps pull request at {{url}}. Provide feedback on code quality and correctness."}, + {ID: "address-feedback", Label: "Address feedback", Hint: "Apply review comments", Icon: "message", PromptTemplate: "Review and address the feedback on the Azure DevOps pull request at {{url}}."}, + {ID: "fix-ci", Label: "Fix CI", Hint: "Diagnose failing checks", Icon: "tool", PromptTemplate: "Investigate and fix CI failures for the Azure DevOps pull request at {{url}}."}, + } +} + +func (s *Service) GetWorkspaceSettings(ctx context.Context, workspaceID string) (*WorkspaceSettings, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return nil, err + } + raw, err := s.store.GetWorkspaceSettingsJSON(ctx, workspaceID) + if err != nil { + return nil, err + } + var overrides workspaceSettingsOverrides + if err := json.Unmarshal([]byte(raw), &overrides); err != nil { + return nil, fmt.Errorf("decode Azure workspace settings: %w", err) + } + settings := &WorkspaceSettings{WorkspaceID: workspaceID, WorkItemQueries: DefaultWorkItemQueryPresets(), PullRequestQueries: DefaultPullRequestQueryPresets(), WorkItemActions: DefaultWorkItemActionPresets(), PullRequestActions: DefaultPullRequestActionPresets()} + decodeOverride(overrides, "workItemQueries", &settings.WorkItemQueries) + decodeOverride(overrides, "pullRequestQueries", &settings.PullRequestQueries) + decodeOverride(overrides, "workItemActions", &settings.WorkItemActions) + decodeOverride(overrides, "pullRequestActions", &settings.PullRequestActions) + return settings, nil +} + +func decodeOverride[T any](overrides workspaceSettingsOverrides, key string, target *[]T) { + raw, ok := overrides[key] + if !ok || len(raw) == 0 { + return + } + var value []T + if json.Unmarshal(raw, &value) == nil && len(value) > 0 { + *target = value + } +} + +func (s *Service) UpdateWorkspaceSettings(ctx context.Context, req *UpdateWorkspaceSettingsRequest) (*WorkspaceSettings, error) { + if req == nil || req.WorkspaceID == "" { + return nil, ErrInvalidWorkspaceID + } + if err := s.authorizeWorkspaceAccess(ctx, req.WorkspaceID); err != nil { + return nil, err + } + for attempt := 0; attempt < workspaceSettingsUpdateAttempts; attempt++ { + snapshot, err := s.store.GetWorkspaceSettingsSnapshot(ctx, req.WorkspaceID) + if err != nil { + return nil, err + } + next, err := patchWorkspaceSettingsJSON(snapshot.JSON, req) + if err != nil { + return nil, err + } + updated, err := s.store.PutWorkspaceSettingsJSONIfVersion(ctx, req.WorkspaceID, next, snapshot.Version) + if err != nil { + return nil, err + } + if updated { + return s.GetWorkspaceSettings(ctx, req.WorkspaceID) + } + } + return nil, fmt.Errorf("update Azure workspace settings: concurrent update") +} + +func patchWorkspaceSettingsJSON(raw string, req *UpdateWorkspaceSettingsRequest) (string, error) { + overrides := workspaceSettingsOverrides{} + if err := json.Unmarshal([]byte(raw), &overrides); err != nil { + return "", fmt.Errorf("decode Azure workspace settings: %w", err) + } + if err := patchPresetOverride(overrides, "workItemQueries", req.WorkItemQueriesSet, req.WorkItemQueries, normalizeQueryPresets); err != nil { + return "", err + } + if err := patchPresetOverride(overrides, "pullRequestQueries", req.PullRequestQueriesSet, req.PullRequestQueries, normalizeQueryPresets); err != nil { + return "", err + } + if err := patchPresetOverride(overrides, "workItemActions", req.WorkItemActionsSet, req.WorkItemActions, normalizeActionPresets); err != nil { + return "", err + } + if err := patchPresetOverride(overrides, "pullRequestActions", req.PullRequestActionsSet, req.PullRequestActions, normalizeActionPresets); err != nil { + return "", err + } + next, err := json.Marshal(overrides) + if err != nil { + return "", err + } + return string(next), nil +} + +func patchPresetOverride[T any](overrides workspaceSettingsOverrides, key string, set bool, values *[]T, normalize func([]T) []T) error { + if !set { + return nil + } + if values == nil { + delete(overrides, key) + return nil + } + normalized := normalize(*values) + if len(normalized) == 0 { + return fmt.Errorf("%w: %s must include a valid preset", ErrInvalidConfig, key) + } + raw, err := json.Marshal(normalized) + if err != nil { + return err + } + overrides[key] = raw + return nil +} + +func normalizeQueryPresets(values []QueryPreset) []QueryPreset { + result := make([]QueryPreset, 0, len(values)) + for _, value := range values { + value.ID, value.Label, value.Group = strings.TrimSpace(value.ID), strings.TrimSpace(value.Label), strings.TrimSpace(value.Group) + if value.ID == "" { + value.ID = uuid.NewString() + } + if value.Label == "" || len(value.Filters) == 0 { + continue + } + result = append(result, value) + } + return result +} +func normalizeActionPresets(values []ActionPreset) []ActionPreset { + result := make([]ActionPreset, 0, len(values)) + for _, value := range values { + value.ID, value.Label, value.Hint, value.Icon, value.PromptTemplate = strings.TrimSpace(value.ID), strings.TrimSpace(value.Label), strings.TrimSpace(value.Hint), strings.TrimSpace(value.Icon), strings.TrimSpace(value.PromptTemplate) + if value.Label == "" || value.PromptTemplate == "" { + continue + } + if value.ID == "" { + value.ID = uuid.NewString() + } + result = append(result, value) + } + return result +} diff --git a/apps/backend/internal/azuredevops/workspace_settings_test.go b/apps/backend/internal/azuredevops/workspace_settings_test.go new file mode 100644 index 0000000000..da6a6122f5 --- /dev/null +++ b/apps/backend/internal/azuredevops/workspace_settings_test.go @@ -0,0 +1,105 @@ +package azuredevops + +import ( + "context" + "encoding/json" + "reflect" + "testing" +) + +func TestDefaultQueryPresetsMatchAzureBrowseShortcuts(t *testing.T) { + workItemIDs := make([]string, 0, len(DefaultWorkItemQueryPresets())) + for _, preset := range DefaultWorkItemQueryPresets() { + workItemIDs = append(workItemIDs, preset.ID) + } + pullRequestIDs := make([]string, 0, len(DefaultPullRequestQueryPresets())) + for _, preset := range DefaultPullRequestQueryPresets() { + pullRequestIDs = append(pullRequestIDs, preset.ID) + } + + if want := []string{"recent", "assigned", "active", "created"}; !reflect.DeepEqual(workItemIDs, want) { + t.Fatalf("work-item preset IDs = %v, want %v", workItemIDs, want) + } + if want := []string{"review-requested", "active", "completed", "created"}; !reflect.DeepEqual(pullRequestIDs, want) { + t.Fatalf("pull-request preset IDs = %v, want %v", pullRequestIDs, want) + } +} + +func TestWorkspaceSettingsDefaultsAndActionOverrides(t *testing.T) { + service, _, _ := newTestService(t, nil) + ctx := context.Background() + if _, err := service.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "pat", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + defaults, err := service.GetWorkspaceSettings(ctx, "ws-1") + if err != nil { + t.Fatalf("get defaults: %v", err) + } + if len(defaults.WorkItemActions) == 0 || len(defaults.PullRequestActions) == 0 { + t.Fatalf("expected built-in actions, got %+v", defaults) + } + + custom := []ActionPreset{{Label: "Triage", Hint: "Sort the report", PromptTemplate: "Triage {{url}}"}} + updated, err := service.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{ + WorkspaceID: "ws-1", + WorkItemActions: &custom, + WorkItemActionsSet: true, + }) + if err != nil { + t.Fatalf("update actions: %v", err) + } + if len(updated.WorkItemActions) != 1 || updated.WorkItemActions[0].ID == "" { + t.Fatalf("custom actions were not normalized: %+v", updated.WorkItemActions) + } + if len(updated.PullRequestActions) != len(DefaultPullRequestActionPresets()) { + t.Fatalf("untouched PR actions should keep defaults: %+v", updated.PullRequestActions) + } +} + +func TestWorkspaceSettingsPatchRequestPreservesFieldPresence(t *testing.T) { + service, _, _ := newTestService(t, nil) + ctx := context.Background() + if _, err := service.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "pat", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + var customRequest UpdateWorkspaceSettingsRequest + if err := json.Unmarshal([]byte(`{"workItemActions":[{"label":"Triage","promptTemplate":"Triage {{url}}"}]}`), &customRequest); err != nil { + t.Fatalf("decode custom patch: %v", err) + } + customRequest.WorkspaceID = "ws-1" + if _, err := service.UpdateWorkspaceSettings(ctx, &customRequest); err != nil { + t.Fatalf("apply custom patch: %v", err) + } + + var omittedRequest UpdateWorkspaceSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &omittedRequest); err != nil { + t.Fatalf("decode omitted patch: %v", err) + } + omittedRequest.WorkspaceID = "ws-1" + omitted, err := service.UpdateWorkspaceSettings(ctx, &omittedRequest) + if err != nil { + t.Fatalf("apply omitted patch: %v", err) + } + if len(omitted.WorkItemActions) != 1 || omitted.WorkItemActions[0].Label != "Triage" { + t.Fatalf("omitted work-item actions = %+v, want saved action", omitted.WorkItemActions) + } + + var resetRequest UpdateWorkspaceSettingsRequest + if err := json.Unmarshal([]byte(`{"workItemActions":null}`), &resetRequest); err != nil { + t.Fatalf("decode null patch: %v", err) + } + resetRequest.WorkspaceID = "ws-1" + reset, err := service.UpdateWorkspaceSettings(ctx, &resetRequest) + if err != nil { + t.Fatalf("apply null patch: %v", err) + } + if !reflect.DeepEqual(reset.WorkItemActions, DefaultWorkItemActionPresets()) { + t.Fatalf("null work-item actions = %+v, want defaults", reset.WorkItemActions) + } +} diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 55e35d3e83..1e1e269888 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -481,6 +481,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "terminalFontFamily": nullString(settings.TerminalFontFamily), "terminalFontSize": nullInt(settings.TerminalFontSize), "changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout), + "azureDevOpsBrowsePreferences": settings.AzureDevOpsBrowsePreferences, "systemMetricsDisplay": map[string]any{ "showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar, "simplified": settings.SystemMetricsDisplay.Simplified, diff --git a/apps/backend/internal/backendapp/boot_state_routes_test.go b/apps/backend/internal/backendapp/boot_state_routes_test.go index eb3c60f3d4..79bb157e09 100644 --- a/apps/backend/internal/backendapp/boot_state_routes_test.go +++ b/apps/backend/internal/backendapp/boot_state_routes_test.go @@ -1,12 +1,46 @@ package backendapp import ( + "encoding/json" "testing" userdto "github.com/kandev/kandev/internal/user/dto" usermodels "github.com/kandev/kandev/internal/user/models" ) +func TestMapUserSettingsStateIncludesAzureDevOpsBrowsePreferences(t *testing.T) { + preferences := json.RawMessage(`{"workspace-1":{"mode":"board","filters":{"projectId":"project-2"},"board":{"teamId":"team-2","boardId":"board-2","focusedColumnId":"done"}}}`) + state := mapUserSettingsState(userdto.UserSettingsResponse{ + Settings: userdto.UserSettingsDTO{AzureDevOpsBrowsePreferences: preferences}, + }, "workspace-1") + + encoded, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal boot settings: %v", err) + } + var payload struct { + Loaded bool `json:"loaded"` + Preferences map[string]struct { + Mode string `json:"mode"` + Filters struct { + ProjectID string `json:"projectId"` + } `json:"filters"` + Board struct { + TeamID string `json:"teamId"` + BoardID string `json:"boardId"` + } `json:"board"` + } `json:"azureDevOpsBrowsePreferences"` + } + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode boot settings: %v", err) + } + + preference := payload.Preferences["workspace-1"] + if !payload.Loaded || preference.Mode != "board" || preference.Filters.ProjectID != "project-2" || preference.Board.TeamID != "team-2" || preference.Board.BoardID != "board-2" { + t.Fatalf("Azure browse preferences missing from loaded boot settings: %s", encoded) + } +} + func TestMapUserSettingsStateIncludesPortableTaskAndSidebarSettings(t *testing.T) { state := mapUserSettingsState(userdto.UserSettingsResponse{ Settings: userdto.UserSettingsDTO{ diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 7e4f37dd8d..4bedf4c9d1 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -570,6 +570,9 @@ func registerRoutes(p routeParams) { if p.services.GitLab != nil { p.services.GitLab.SetCascadeTaskDeleter(handoffSvc) } + if p.services.AzureDevOps != nil { + p.services.AzureDevOps.SetCascadeTaskDeleter(handoffSvc) + } // repoLookup validates a watcher's optional repository binding (workspace // ownership + default-branch fill) on create/update. Shared across the three // repo-less watchers; one concrete adapter satisfies each package's @@ -581,6 +584,12 @@ func registerRoutes(p routeParams) { tasks: p.taskSvc, workflows: p.services.Workflow, agents: p.agentSettingsRepo, }) } + if p.services.AzureDevOps != nil { + p.services.AzureDevOps.SetWatchRepositoryLookup(repoLookup) + p.services.AzureDevOps.SetWatchDependencyValidator(&gitLabWatchDependencyValidator{ + tasks: p.taskSvc, workflows: p.services.Workflow, agents: p.agentSettingsRepo, + }) + } if p.services.Jira != nil { p.services.Jira.SetTaskDeleter(handoffSvc) p.services.Jira.SetRepositoryLookup(repoLookup) diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index fdede3e541..295d9215ce 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -559,9 +559,11 @@ func startAgentInfrastructure( // and workspace-scoped GitLab credential resolver are both configured. workspaceSourceMaterializer.SetHostRepositoryCloner(orchestratorSvc) - // Azure DevOps v1 owns only connection-health polling. PR summaries are - // refreshed explicitly through their task association routes. + // Azure DevOps owns connection-health and work-item/pull-request watcher + // polling. Watch matches flow through the shared orchestrator coordinator. if services.AzureDevOps != nil { + orchestratorSvc.SetAzureDevOpsService(services.AzureDevOps) + services.AzureDevOps.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task}) azureLifecycle, lifecycleErr := azuredevopspkg.RegisterLifecycleCleanup(eventBus, services.AzureDevOps) if lifecycleErr != nil { log.Warn("Azure DevOps lifecycle cleanup unavailable", zap.Error(lifecycleErr)) diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 7df0cb17b3..2d72b6e82d 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -124,6 +124,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories azureDevOpsSvc := initAzureDevOpsService(dbPool, eventBus, repos.Secrets, log) if azureDevOpsSvc != nil { azureDevOpsSvc.SetRepositoryLookup(&repositoryLookupAdapter{svc: taskSvc}) + azureDevOpsSvc.SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess) } jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 78638ec988..4b38632fe5 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -117,6 +117,12 @@ const ( RepositoryDeleted = "repository.deleted" ) +// Event types emitted by Azure DevOps watcher polling. +const ( + AzureDevOpsWorkItemWatchMatch = "azure_devops.work_item_watch.match" + AzureDevOpsPullRequestWatchMatch = "azure_devops.pull_request_watch.match" +) + // Event types for repository scripts const ( RepositoryScriptCreated = "repository.script.created" diff --git a/apps/backend/internal/integrations/AGENTS.md b/apps/backend/internal/integrations/AGENTS.md index 6f247327ca..d705ac4d56 100644 --- a/apps/backend/internal/integrations/AGENTS.md +++ b/apps/backend/internal/integrations/AGENTS.md @@ -53,4 +53,4 @@ not read `workspace_id`, so GitLab's `workspace_id`-keyed WS list is unscoped to - **Watch filter persistence:** Jira stores the JQL string verbatim; Linear stores the structured `SearchFilter` as JSON in `filter_json` (Linear has no JQL equivalent). The orchestrator emits `NewJiraIssueEvent` / `NewLinearIssueEvent` respectively and dedups by issue key (Jira) vs identifier (Linear). - **Health column extras:** Linear's `linear_configs` row carries an `org_slug` captured from successful probes; Jira's row does not. - **Sentry — multiple instances per workspace:** unlike the one-config-per-workspace integrations, `sentry_configs` is keyed by an instance `id` (UUID) with a `workspace_id` column + `UNIQUE(workspace_id, name)`, so a workspace holds several named instances. Secrets are keyed `sentry:instance::token`. Issue watches carry a nullable `sentry_instance_id` FK (`ON DELETE RESTRICT`); the bound instance is immutable. The HTTP surface is instance CRUD under `/api/v1/sentry/instances?workspace_id=` (no install-wide `/config`); deleting an in-use instance is 409 `SENTRY_INSTANCE_IN_USE`. See ADR-0030. -- **Azure DevOps — direct read-only REST and task PR persistence:** `internal/azuredevops` accepts only canonical Azure DevOps Services organization URLs, stores one encrypted PAT per workspace through `secretadapter`, and uses REST API 7.1 without `gh` or `az`. It reuses `healthpoll` and the standard mock-provider gate, but intentionally has no watch loop. Azure pull-request summaries are persisted against tasks in the integration package; provider-native feedback remains transient and Azure-specific. +- **Azure DevOps — REST browse, task links, and watchers:** `internal/azuredevops` accepts only canonical Azure DevOps Services organization URLs, stores one encrypted PAT per workspace through `secretadapter`, and uses REST API 7.1 without `gh` or `az`. It reuses `healthpoll` and the standard mock-provider gate, and its independent watcher poller persists generation-safe work-item/PR reservations before publishing orchestrator events. Azure pull-request summaries are persisted against tasks in the integration package; provider-native feedback remains transient and Azure-specific. diff --git a/apps/backend/internal/orchestrator/event_handlers_azuredevops.go b/apps/backend/internal/orchestrator/event_handlers_azuredevops.go new file mode 100644 index 0000000000..3bbe16a0b7 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_azuredevops.go @@ -0,0 +1,60 @@ +package orchestrator + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/azuredevops" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// SetAzureDevOpsService wires Azure's watcher dedup surface into the shared +// watcher coordinator. It is nil-safe so test and partially configured boots +// can still subscribe without creating tasks. +func (s *Service) SetAzureDevOpsService(svc AzureDevOpsWatchService) { + s.azureDevOpsService = svc + s.azureWorkItemSource = NewAzureDevOpsWorkItemWatcherSource(svc, s.logger) + s.azurePullRequestSource = NewAzureDevOpsPullRequestWatcherSource(svc, s.logger) +} + +func (s *Service) subscribeAzureDevOpsEvents() { + if s.eventBus == nil { + return + } + if _, err := s.eventBus.Subscribe(events.AzureDevOpsWorkItemWatchMatch, s.handleAzureDevOpsWorkItemWatchMatch); err != nil { + s.logger.Error("subscribe azure_devops.work_item_watch.match", zap.Error(err)) + } + if _, err := s.eventBus.Subscribe(events.AzureDevOpsPullRequestWatchMatch, s.handleAzureDevOpsPullRequestWatchMatch); err != nil { + s.logger.Error("subscribe azure_devops.pull_request_watch.match", zap.Error(err)) + } +} + +func (s *Service) handleAzureDevOpsWorkItemWatchMatch(ctx context.Context, event *bus.Event) error { + evt, ok := event.Data.(*azuredevops.WorkItemWatchEvent) + if !ok || evt == nil { + return nil + } + src := s.azureWorkItemSource + if src == nil { + src = NewAzureDevOpsWorkItemWatcherSource(nil, s.logger) + } + s.dispatchWatcherEvent(ctx, src, evt, + zap.String("work_item_watch_id", evt.WatchID), zap.String("project", evt.ProjectID), zap.Int("work_item_id", evt.WorkItem.ID)) + return nil +} + +func (s *Service) handleAzureDevOpsPullRequestWatchMatch(ctx context.Context, event *bus.Event) error { + evt, ok := event.Data.(*azuredevops.PullRequestWatchEvent) + if !ok || evt == nil { + return nil + } + src := s.azurePullRequestSource + if src == nil { + src = NewAzureDevOpsPullRequestWatcherSource(nil, s.logger) + } + s.dispatchWatcherEvent(ctx, src, evt, + zap.String("pull_request_watch_id", evt.WatchID), zap.String("project", evt.ProjectID), zap.Int("pull_request_id", evt.PullRequest.ID)) + return nil +} diff --git a/apps/backend/internal/orchestrator/event_handlers_azuredevops_test.go b/apps/backend/internal/orchestrator/event_handlers_azuredevops_test.go new file mode 100644 index 0000000000..82d4c99991 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_azuredevops_test.go @@ -0,0 +1,54 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/azuredevops" + "github.com/kandev/kandev/internal/events/bus" +) + +func TestAzureDevOpsWorkItemWatchEventDispatchesThroughIssueCreator(t *testing.T) { + creator := &channelIssueTaskCreator{requests: make(chan *IssueTaskRequest, 1)} + svc := &Service{logger: nopLogger(t)} + svc.SetIssueTaskCreator(creator) + svc.SetAzureDevOpsService(nil) + evt := &azuredevops.WorkItemWatchEvent{ + WatchID: "watch-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + Prompt: "Investigate {{work_item.title}}", ProjectID: "Platform", WorkItem: azuredevops.WorkItem{ID: 101, Title: "Fix build", WebURL: "https://dev.azure.com/acme/wi/101"}, + } + if err := svc.handleAzureDevOpsWorkItemWatchMatch(context.Background(), &bus.Event{Data: evt}); err != nil { + t.Fatalf("handle event: %v", err) + } + select { + case req := <-creator.requests: + if req.Title != "[Platform#101] Fix build" || req.Description != "Investigate Fix build" { + t.Fatalf("request = %+v", req) + } + case <-time.After(time.Second): + t.Fatal("Azure work-item watch event was not dispatched") + } +} + +func TestAzureDevOpsPullRequestWatchEventDispatchesThroughIssueCreator(t *testing.T) { + creator := &channelIssueTaskCreator{requests: make(chan *IssueTaskRequest, 1)} + svc := &Service{logger: nopLogger(t)} + svc.SetIssueTaskCreator(creator) + svc.SetAzureDevOpsService(nil) + evt := &azuredevops.PullRequestWatchEvent{ + WatchID: "watch-pr", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + Prompt: "Review {{pull_request.title}}", ProjectID: "Platform", PullRequest: azuredevops.PullRequest{ID: 42, Title: "Ship it", WebURL: "https://dev.azure.com/acme/pr/42"}, + } + if err := svc.handleAzureDevOpsPullRequestWatchMatch(context.Background(), &bus.Event{Data: evt}); err != nil { + t.Fatalf("handle event: %v", err) + } + select { + case req := <-creator.requests: + if req.Title != "[Platform!42] Ship it" || req.Description != "Review Ship it" { + t.Fatalf("request = %+v", req) + } + case <-time.After(time.Second): + t.Fatal("Azure pull-request watch event was not dispatched") + } +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index ae664cd965..d9f91761d7 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -399,6 +399,12 @@ type Service struct { gitlabService GitLabWatchService gitlabReviewSource *GitLabReviewWatcherSource gitlabIssueSource *GitLabIssueWatcherSource + // Azure DevOps watcher service + sources for work-item and pull-request + // polling events. The integration publishes provider-native matches; the + // shared watcher coordinator owns task creation and throttling. + azureDevOpsService AzureDevOpsWatchService + azureWorkItemSource *AzureDevOpsWorkItemWatcherSource + azurePullRequestSource *AzureDevOpsPullRequestWatcherSource // Repository resolver for cloning + finding/creating repos for review tasks repositoryResolver RepositoryResolver @@ -1431,6 +1437,9 @@ func (s *Service) Start(ctx context.Context) error { // Subscribe to GitLab integration events s.subscribeGitLabEvents() + // Subscribe to Azure DevOps watcher events + s.subscribeAzureDevOpsEvents() + // Subscribe to JIRA integration events s.subscribeJiraEvents() diff --git a/apps/backend/internal/orchestrator/source_azuredevops.go b/apps/backend/internal/orchestrator/source_azuredevops.go new file mode 100644 index 0000000000..e439e2f204 --- /dev/null +++ b/apps/backend/internal/orchestrator/source_azuredevops.go @@ -0,0 +1,310 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/azuredevops" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" +) + +const ( + azureDevOpsWorkItemWatchMetadataKey = "azure_devops_work_item_watch_id" + azureDevOpsPullRequestWatchMetadataKey = "azure_devops_pr_watch_id" +) + +type AzureDevOpsWatchService interface { + ReserveWorkItemWatchTask(context.Context, string, int64, string, int, string) (bool, error) + AssignWorkItemWatchTaskID(context.Context, string, int64, string, int, string) error + ReleaseWorkItemWatchTask(context.Context, string, int64, string, int) error + DisableWorkItemWatchWithError(context.Context, string, string) error + ReservePullRequestWatchTask(context.Context, string, int64, string, string, int, string) (bool, error) + AssignPullRequestWatchTaskID(context.Context, string, int64, string, string, int, string) error + ReleasePullRequestWatchTask(context.Context, string, int64, string, string, int) error + DisablePullRequestWatchWithError(context.Context, string, string) error +} + +type AzureDevOpsWorkItemWatcherSource struct { + service AzureDevOpsWatchService + logger *logger.Logger +} + +func NewAzureDevOpsWorkItemWatcherSource(svc AzureDevOpsWatchService, log *logger.Logger) *AzureDevOpsWorkItemWatcherSource { + return &AzureDevOpsWorkItemWatcherSource{service: svc, logger: log} +} + +func (s *AzureDevOpsWorkItemWatcherSource) Name() string { return "azure_devops_work_item" } + +func (s *AzureDevOpsWorkItemWatcherSource) Reserve(ctx context.Context, evt any) (bool, error) { + e, err := azureWorkItemWatchEvent(evt) + if err != nil { + return false, err + } + if s.service == nil { + return true, nil + } + if e.ReservationClaimed { + return true, nil + } + return s.service.ReserveWorkItemWatchTask(ctx, e.WatchID, e.WatchGeneration, e.ProjectID, e.WorkItem.ID, e.WorkItem.WebURL) +} + +func (s *AzureDevOpsWorkItemWatcherSource) Release(ctx context.Context, evt any) { + e, err := azureWorkItemWatchEvent(evt) + if err != nil || s.service == nil { + return + } + if releaseErr := s.service.ReleaseWorkItemWatchTask(ctx, e.WatchID, e.WatchGeneration, e.ProjectID, e.WorkItem.ID); releaseErr != nil && s.logger != nil { + s.logger.Warn("azure devops work-item source: release failed", zap.Int("work_item_id", e.WorkItem.ID), zap.Error(releaseErr)) + } +} + +func (s *AzureDevOpsWorkItemWatcherSource) BuildTaskRequest(evt any) (*IssueTaskRequest, error) { + e, err := azureWorkItemWatchEvent(evt) + if err != nil { + return nil, err + } + req := &IssueTaskRequest{ + WorkspaceID: e.WorkspaceID, WorkflowID: e.WorkflowID, WorkflowStepID: e.WorkflowStepID, + Title: fmt.Sprintf("[%s#%d] %s", e.ProjectID, e.WorkItem.ID, e.WorkItem.Title), + Description: interpolateAzureWorkItemPrompt(e.Prompt, &e.WorkItem), + Metadata: map[string]interface{}{ + azureDevOpsWorkItemWatchMetadataKey: e.WatchID, + "azure_devops_project_id": e.ProjectID, + "azure_devops_work_item_id": e.WorkItem.ID, + "azure_devops_work_item_url": e.WorkItem.WebURL, + "azure_devops_work_item_type": e.WorkItem.Type, + "azure_devops_work_item_state": e.WorkItem.State, + models.MetaKeyAgentProfileID: e.AgentProfileID, + models.MetaKeyExecutorProfileID: e.ExecutorProfileID, + }, + } + if e.RepositoryID != "" { + req.Repositories = []IssueTaskRepository{{RepositoryID: e.RepositoryID, BaseBranch: e.BaseBranch}} + } + return req, nil +} + +func (s *AzureDevOpsWorkItemWatcherSource) AttachTaskID(ctx context.Context, evt any, taskID string) error { + e, err := azureWorkItemWatchEvent(evt) + if err != nil || s.service == nil { + return err + } + return s.service.AssignWorkItemWatchTaskID(ctx, e.WatchID, e.WatchGeneration, e.ProjectID, e.WorkItem.ID, taskID) +} + +func (s *AzureDevOpsWorkItemWatcherSource) IsTerminalAttachError(err error) bool { + return errors.Is(err, azuredevops.ErrWatchOwnershipLost) +} + +func (s *AzureDevOpsWorkItemWatcherSource) AutoStartParams(evt any) AutoStartParams { + e, _ := azureWorkItemWatchEvent(evt) + if e == nil { + return AutoStartParams{} + } + return AutoStartParams{AgentProfileID: e.AgentProfileID, ExecutorProfileID: e.ExecutorProfileID, WorkflowStepID: e.WorkflowStepID} +} + +func (s *AzureDevOpsWorkItemWatcherSource) AgentProfileID(evt any) string { + e, _ := azureWorkItemWatchEvent(evt) + if e == nil { + return "" + } + return e.AgentProfileID +} + +func (s *AzureDevOpsWorkItemWatcherSource) WatchID(evt any) string { + e, _ := azureWorkItemWatchEvent(evt) + if e == nil { + return "" + } + return e.WatchID +} + +func (s *AzureDevOpsWorkItemWatcherSource) MaxInflightTasks(evt any) *int { + e, _ := azureWorkItemWatchEvent(evt) + if e == nil { + return nil + } + return e.MaxInflightTasks +} + +func (s *AzureDevOpsWorkItemWatcherSource) WatchMetadataKey() string { + return azureDevOpsWorkItemWatchMetadataKey +} + +func (s *AzureDevOpsWorkItemWatcherSource) SelfHeal(ctx context.Context, evt any, cause string) error { + e, err := azureWorkItemWatchEvent(evt) + if err != nil || s.service == nil { + return err + } + return s.service.DisableWorkItemWatchWithError(ctx, e.WatchID, cause) +} + +type AzureDevOpsPullRequestWatcherSource struct { + service AzureDevOpsWatchService + logger *logger.Logger +} + +func NewAzureDevOpsPullRequestWatcherSource(svc AzureDevOpsWatchService, log *logger.Logger) *AzureDevOpsPullRequestWatcherSource { + return &AzureDevOpsPullRequestWatcherSource{service: svc, logger: log} +} + +func (s *AzureDevOpsPullRequestWatcherSource) Name() string { return "azure_devops_pull_request" } + +func (s *AzureDevOpsPullRequestWatcherSource) Reserve(ctx context.Context, evt any) (bool, error) { + e, err := azurePullRequestWatchEvent(evt) + if err != nil { + return false, err + } + if s.service == nil { + return true, nil + } + if e.ReservationClaimed { + return true, nil + } + return s.service.ReservePullRequestWatchTask(ctx, e.WatchID, e.WatchGeneration, e.ProjectID, e.AzureRepositoryID, e.PullRequest.ID, e.PullRequest.WebURL) +} + +func (s *AzureDevOpsPullRequestWatcherSource) Release(ctx context.Context, evt any) { + e, err := azurePullRequestWatchEvent(evt) + if err != nil || s.service == nil { + return + } + if releaseErr := s.service.ReleasePullRequestWatchTask(ctx, e.WatchID, e.WatchGeneration, e.ProjectID, e.AzureRepositoryID, e.PullRequest.ID); releaseErr != nil && s.logger != nil { + s.logger.Warn("azure devops pull-request source: release failed", zap.Int("pull_request_id", e.PullRequest.ID), zap.Error(releaseErr)) + } +} + +func (s *AzureDevOpsPullRequestWatcherSource) BuildTaskRequest(evt any) (*IssueTaskRequest, error) { + e, err := azurePullRequestWatchEvent(evt) + if err != nil { + return nil, err + } + req := &IssueTaskRequest{ + WorkspaceID: e.WorkspaceID, WorkflowID: e.WorkflowID, WorkflowStepID: e.WorkflowStepID, + Title: fmt.Sprintf("[%s!%d] %s", e.ProjectID, e.PullRequest.ID, e.PullRequest.Title), + Description: interpolateAzurePullRequestPrompt(e.Prompt, &e.PullRequest), + Metadata: map[string]interface{}{ + azureDevOpsPullRequestWatchMetadataKey: e.WatchID, + "azure_devops_project_id": e.ProjectID, + "azure_devops_repository_id": e.AzureRepositoryID, + "azure_devops_pull_request_id": e.PullRequest.ID, + "azure_devops_pull_request_url": e.PullRequest.WebURL, + models.MetaKeyAgentProfileID: e.AgentProfileID, + models.MetaKeyExecutorProfileID: e.ExecutorProfileID, + }, + } + if e.RepositoryID != "" { + req.Repositories = []IssueTaskRepository{{RepositoryID: e.RepositoryID, BaseBranch: e.BaseBranch}} + } + return req, nil +} + +func (s *AzureDevOpsPullRequestWatcherSource) AttachTaskID(ctx context.Context, evt any, taskID string) error { + e, err := azurePullRequestWatchEvent(evt) + if err != nil || s.service == nil { + return err + } + return s.service.AssignPullRequestWatchTaskID(ctx, e.WatchID, e.WatchGeneration, e.ProjectID, e.AzureRepositoryID, e.PullRequest.ID, taskID) +} + +func (s *AzureDevOpsPullRequestWatcherSource) IsTerminalAttachError(err error) bool { + return errors.Is(err, azuredevops.ErrWatchOwnershipLost) +} + +func (s *AzureDevOpsPullRequestWatcherSource) AutoStartParams(evt any) AutoStartParams { + e, _ := azurePullRequestWatchEvent(evt) + if e == nil { + return AutoStartParams{} + } + return AutoStartParams{AgentProfileID: e.AgentProfileID, ExecutorProfileID: e.ExecutorProfileID, WorkflowStepID: e.WorkflowStepID} +} + +func (s *AzureDevOpsPullRequestWatcherSource) AgentProfileID(evt any) string { + e, _ := azurePullRequestWatchEvent(evt) + if e == nil { + return "" + } + return e.AgentProfileID +} + +func (s *AzureDevOpsPullRequestWatcherSource) WatchID(evt any) string { + e, _ := azurePullRequestWatchEvent(evt) + if e == nil { + return "" + } + return e.WatchID +} + +func (s *AzureDevOpsPullRequestWatcherSource) MaxInflightTasks(evt any) *int { + e, _ := azurePullRequestWatchEvent(evt) + if e == nil { + return nil + } + return e.MaxInflightTasks +} + +func (s *AzureDevOpsPullRequestWatcherSource) WatchMetadataKey() string { + return azureDevOpsPullRequestWatchMetadataKey +} + +func (s *AzureDevOpsPullRequestWatcherSource) SelfHeal(ctx context.Context, evt any, cause string) error { + e, err := azurePullRequestWatchEvent(evt) + if err != nil || s.service == nil { + return err + } + return s.service.DisablePullRequestWatchWithError(ctx, e.WatchID, cause) +} + +func azureWorkItemWatchEvent(evt any) (*azuredevops.WorkItemWatchEvent, error) { + e, ok := evt.(*azuredevops.WorkItemWatchEvent) + if !ok || e == nil { + return nil, errors.New("azure devops work-item source: event payload missing or wrong type") + } + return e, nil +} + +func azurePullRequestWatchEvent(evt any) (*azuredevops.PullRequestWatchEvent, error) { + e, ok := evt.(*azuredevops.PullRequestWatchEvent) + if !ok || e == nil { + return nil, errors.New("azure devops pull-request source: event payload missing or wrong type") + } + return e, nil +} + +func interpolateAzureWorkItemPrompt(template string, item *azuredevops.WorkItem) string { + if item == nil { + return template + } + return strings.NewReplacer( + "{{work_item.url}}", item.WebURL, + "{{work_item.title}}", item.Title, + "{{work_item.project}}", item.Project, + "{{work_item.id}}", fmt.Sprint(item.ID), + "{{work_item.description}}", item.Description, + "{{work_item.state}}", item.State, + "{{work_item.type}}", item.Type, + ).Replace(template) +} + +func interpolateAzurePullRequestPrompt(template string, pullRequest *azuredevops.PullRequest) string { + if pullRequest == nil { + return template + } + return strings.NewReplacer( + "{{pull_request.url}}", pullRequest.WebURL, + "{{pull_request.title}}", pullRequest.Title, + "{{pull_request.project}}", pullRequest.ProjectName, + "{{pull_request.id}}", fmt.Sprint(pullRequest.ID), + "{{pull_request.description}}", pullRequest.Description, + "{{pull_request.state}}", pullRequest.Status, + "{{pull_request.source_branch}}", pullRequest.SourceBranch, + "{{pull_request.target_branch}}", pullRequest.TargetBranch, + ).Replace(template) +} diff --git a/apps/backend/internal/orchestrator/source_azuredevops_test.go b/apps/backend/internal/orchestrator/source_azuredevops_test.go new file mode 100644 index 0000000000..99784ece32 --- /dev/null +++ b/apps/backend/internal/orchestrator/source_azuredevops_test.go @@ -0,0 +1,99 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/azuredevops" +) + +type azureWatchServiceFake struct { + reservedWorkItem bool + reservedPR bool + assignedTaskID string + workWatchID string + prWatchID string +} + +func (f *azureWatchServiceFake) ReserveWorkItemWatchTask(context.Context, string, int64, string, int, string) (bool, error) { + return f.reservedWorkItem, nil +} +func (f *azureWatchServiceFake) AssignWorkItemWatchTaskID(_ context.Context, watchID string, _ int64, _ string, _ int, taskID string) error { + f.workWatchID, f.assignedTaskID = watchID, taskID + return nil +} +func (f *azureWatchServiceFake) ReleaseWorkItemWatchTask(context.Context, string, int64, string, int) error { + return nil +} +func (f *azureWatchServiceFake) DisableWorkItemWatchWithError(context.Context, string, string) error { + return nil +} +func (f *azureWatchServiceFake) ReservePullRequestWatchTask(context.Context, string, int64, string, string, int, string) (bool, error) { + return f.reservedPR, nil +} +func (f *azureWatchServiceFake) AssignPullRequestWatchTaskID(_ context.Context, watchID string, _ int64, _ string, _ string, _ int, taskID string) error { + f.prWatchID, f.assignedTaskID = watchID, taskID + return nil +} +func (f *azureWatchServiceFake) ReleasePullRequestWatchTask(context.Context, string, int64, string, string, int) error { + return nil +} +func (f *azureWatchServiceFake) DisablePullRequestWatchWithError(context.Context, string, string) error { + return nil +} + +func TestAzureWorkItemWatcherSourceBuildsIssueTask(t *testing.T) { + service := &azureWatchServiceFake{reservedWorkItem: true} + source := NewAzureDevOpsWorkItemWatcherSource(service, nil) + event := &azuredevops.WorkItemWatchEvent{ + WatchID: "watch-1", WatchGeneration: 3, WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + RepositoryID: "repo-1", BaseBranch: "main", AgentProfileID: "agent-1", ExecutorProfileID: "executor-1", + Prompt: "Implement {{work_item.title}} ({{work_item.url}})", ProjectID: "project-1", + WorkItem: azuredevops.WorkItem{ID: 101, Title: "Fix build", State: "Active", Type: "Bug", WebURL: "https://dev.azure.com/acme/wi/101"}, + } + req, err := source.BuildTaskRequest(event) + if err != nil { + t.Fatalf("BuildTaskRequest: %v", err) + } + if req.Title != "[project-1#101] Fix build" || req.Description != "Implement Fix build (https://dev.azure.com/acme/wi/101)" { + t.Fatalf("request = %+v", req) + } + if req.Metadata[azureDevOpsWorkItemWatchMetadataKey] != "watch-1" || len(req.Repositories) != 1 { + t.Fatalf("metadata/repositories = %#v/%+v", req.Metadata, req.Repositories) + } + reserved, err := source.Reserve(context.Background(), event) + if err != nil || !reserved { + t.Fatalf("Reserve = %v, %v", reserved, err) + } + if err := source.AttachTaskID(context.Background(), event, "task-1"); err != nil || service.assignedTaskID != "task-1" { + t.Fatalf("AttachTaskID = %v, assigned=%q", err, service.assignedTaskID) + } +} + +func TestAzurePullRequestWatcherSourceBuildsReviewTask(t *testing.T) { + service := &azureWatchServiceFake{reservedPR: true} + source := NewAzureDevOpsPullRequestWatcherSource(service, nil) + event := &azuredevops.PullRequestWatchEvent{ + WatchID: "watch-pr", WatchGeneration: 2, WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + RepositoryID: "repo-1", BaseBranch: "main", AgentProfileID: "agent-1", ExecutorProfileID: "executor-1", + Prompt: "Review {{pull_request.title}} at {{pull_request.url}}", ProjectID: "project-1", AzureRepositoryID: "azure-repo-1", + PullRequest: azuredevops.PullRequest{ID: 42, Title: "Ship it", WebURL: "https://dev.azure.com/acme/pr/42", RepositoryID: "azure-repo-1", ProjectName: "Platform"}, + } + req, err := source.BuildTaskRequest(event) + if err != nil { + t.Fatalf("BuildTaskRequest: %v", err) + } + if req.Title != "[project-1!42] Ship it" || req.Description != "Review Ship it at https://dev.azure.com/acme/pr/42" { + t.Fatalf("request = %+v", req) + } + if req.Metadata[azureDevOpsPullRequestWatchMetadataKey] != "watch-pr" || len(req.Repositories) != 1 { + t.Fatalf("metadata/repositories = %#v/%+v", req.Metadata, req.Repositories) + } + reserved, err := source.Reserve(context.Background(), event) + if err != nil || !reserved { + t.Fatalf("Reserve = %v, %v", reserved, err) + } + if err := source.AttachTaskID(context.Background(), event, "task-pr-1"); err != nil || service.assignedTaskID != "task-pr-1" { + t.Fatalf("AttachTaskID = %v, assigned=%q", err, service.assignedTaskID) + } +} diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index f885d9b16c..e13edb98f8 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -82,6 +82,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS GitHubSavedPresets: req.GitHubSavedPresets.ServiceValue(), GitHubDefaultQueryPresets: req.GitHubDefaultQueryPresets.ServiceValue(), GitLabSavedPresets: req.GitLabSavedPresets.ServiceValue(), + AzureDevOpsBrowsePreferences: req.AzureDevOpsBrowsePreferences.ServiceValue(), DefaultUtilityAgentID: req.DefaultUtilityAgentID, DefaultUtilityModel: req.DefaultUtilityModel, KeyboardShortcuts: req.KeyboardShortcuts, diff --git a/apps/backend/internal/user/controller/controller_test.go b/apps/backend/internal/user/controller/controller_test.go index d331e932c0..028bc7a68b 100644 --- a/apps/backend/internal/user/controller/controller_test.go +++ b/apps/backend/internal/user/controller/controller_test.go @@ -2,15 +2,19 @@ package controller import ( "context" + "encoding/json" "errors" "testing" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/user/dto" "github.com/kandev/kandev/internal/user/models" "github.com/kandev/kandev/internal/user/service" + userstore "github.com/kandev/kandev/internal/user/store" ) type settingsRepository struct { @@ -90,6 +94,75 @@ func TestUpdateUserSettingsMapsStartupPage(t *testing.T) { } } +func TestAzureDevOpsBrowsePreferencesRoundTripThroughSettingsAPI(t *testing.T) { + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + repo, cleanup, err := userstore.Provide(conn, conn) + if err != nil { + t.Fatalf("create settings repository: %v", err) + } + t.Cleanup(func() { _ = cleanup() }) + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + controller := NewController(service.NewService(repo, nil, log)) + preferences := json.RawMessage(`{"workspace-a":{"mode":"board","projectId":"project-a","teamId":"team-a","workItemType":"Bug"}}`) + + var patch dto.UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"azure_devops_browse_preferences":`+string(preferences)+`}`), &patch); err != nil { + t.Fatalf("decode settings patch: %v", err) + } + updated, err := controller.UpdateUserSettings(context.Background(), patch) + if err != nil { + t.Fatalf("update user settings: %v", err) + } + if string(updated.Settings.AzureDevOpsBrowsePreferences) != string(preferences) { + t.Fatalf("updated preferences = %s, want %s", updated.Settings.AzureDevOpsBrowsePreferences, preferences) + } + readBack, err := controller.GetUserSettings(context.Background()) + if err != nil { + t.Fatalf("read settings: %v", err) + } + if string(readBack.Settings.AzureDevOpsBrowsePreferences) != string(preferences) { + t.Fatalf("read-back preferences = %s, want %s", readBack.Settings.AzureDevOpsBrowsePreferences, preferences) + } + + var omitted dto.UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil { + t.Fatalf("decode omitted settings patch: %v", err) + } + if _, err := controller.UpdateUserSettings(context.Background(), omitted); err != nil { + t.Fatalf("apply omitted settings patch: %v", err) + } + readBack, err = controller.GetUserSettings(context.Background()) + if err != nil { + t.Fatalf("read settings after omission: %v", err) + } + if string(readBack.Settings.AzureDevOpsBrowsePreferences) != string(preferences) { + t.Fatalf("omitted preferences = %s, want preserved %s", readBack.Settings.AzureDevOpsBrowsePreferences, preferences) + } + + var clear dto.UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"azure_devops_browse_preferences":null}`), &clear); err != nil { + t.Fatalf("decode null settings patch: %v", err) + } + if _, err := controller.UpdateUserSettings(context.Background(), clear); err != nil { + t.Fatalf("apply null settings patch: %v", err) + } + readBack, err = controller.GetUserSettings(context.Background()) + if err != nil { + t.Fatalf("read settings after clear: %v", err) + } + if string(readBack.Settings.AzureDevOpsBrowsePreferences) != "null" { + t.Fatalf("cleared preferences = %s, want JSON null", readBack.Settings.AzureDevOpsBrowsePreferences) + } +} + func TestSystemMetricsDisplayPatch(t *testing.T) { t.Run("nil patch stays nil", func(t *testing.T) { if got := systemMetricsDisplayPatch(nil); got != nil { diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 481d7e88d6..aae5d520de 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -54,6 +54,7 @@ type UserSettingsDTO struct { GitHubSavedPresets json.RawMessage `json:"github_saved_presets,omitempty"` GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets,omitempty"` GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets,omitempty"` + AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences,omitempty"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"` @@ -121,6 +122,7 @@ type UpdateUserSettingsRequest struct { GitHubSavedPresets NullableRawMessage `json:"github_saved_presets,omitempty"` GitHubDefaultQueryPresets NullableRawMessage `json:"github_default_query_presets,omitempty"` GitLabSavedPresets NullableRawMessage `json:"gitlab_saved_presets,omitempty"` + AzureDevOpsBrowsePreferences NullableRawMessage `json:"azure_devops_browse_preferences,omitempty"` DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"` DefaultUtilityModel *string `json:"default_utility_model,omitempty"` KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"` @@ -249,6 +251,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { GitHubSavedPresets: settings.GitHubSavedPresets, GitHubDefaultQueryPresets: settings.GitHubDefaultQueryPresets, GitLabSavedPresets: settings.GitLabSavedPresets, + AzureDevOpsBrowsePreferences: settings.AzureDevOpsBrowsePreferences, DefaultUtilityAgentID: settings.DefaultUtilityAgentID, DefaultUtilityModel: settings.DefaultUtilityModel, KeyboardShortcuts: settings.KeyboardShortcuts, diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go index 73697b6bf8..d85f91068e 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -8,6 +8,21 @@ import ( "github.com/kandev/kandev/internal/user/models" ) +func TestUpdateUserSettingsRequestExposesAzureDevOpsBrowsePreferences(t *testing.T) { + field, ok := reflect.TypeFor[UpdateUserSettingsRequest]().FieldByName("AzureDevOpsBrowsePreferences") + if !ok || field.Tag.Get("json") != "azure_devops_browse_preferences,omitempty" { + t.Fatalf("Azure DevOps browse preferences patch field = %+v, want JSON preference field", field) + } +} + +func TestFromUserSettingsMapsAzureDevOpsBrowsePreferences(t *testing.T) { + preferences := json.RawMessage(`{"project-1":{"teamId":"team-1"}}`) + settings := FromUserSettings(&models.UserSettings{AzureDevOpsBrowsePreferences: preferences}) + if string(settings.AzureDevOpsBrowsePreferences) != string(preferences) { + t.Fatalf("AzureDevOpsBrowsePreferences = %s, want %s", settings.AzureDevOpsBrowsePreferences, preferences) + } +} + func TestTasksListShowDetailsDTO(t *testing.T) { if !FromUserSettings(&models.UserSettings{TasksListShowDetails: true}).TasksListShowDetails { t.Fatal("TasksListShowDetails = false, want true") diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 72e202fece..ab22cb494b 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -94,6 +94,7 @@ type UserSettings struct { GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` + AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index bc79719f44..9177cb9477 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -73,6 +73,7 @@ type UpdateUserSettingsRequest struct { GitHubSavedPresets **json.RawMessage GitHubDefaultQueryPresets **json.RawMessage GitLabSavedPresets **json.RawMessage + AzureDevOpsBrowsePreferences **json.RawMessage DefaultUtilityAgentID *string DefaultUtilityModel *string KeyboardShortcuts *map[string]interface{} @@ -628,6 +629,9 @@ func applyUserPreferenceBlobs(settings *models.UserSettings, req *UpdateUserSett if err := applyUserPreferenceBlob("gitlab_saved_presets", req.GitLabSavedPresets, &settings.GitLabSavedPresets); err != nil { return err } + if err := applyUserPreferenceBlob("azure_devops_browse_preferences", req.AzureDevOpsBrowsePreferences, &settings.AzureDevOpsBrowsePreferences); err != nil { + return err + } return nil } @@ -706,6 +710,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "github_saved_presets": settings.GitHubSavedPresets, "github_default_query_presets": settings.GitHubDefaultQueryPresets, "gitlab_saved_presets": settings.GitLabSavedPresets, + "azure_devops_browse_preferences": settings.AzureDevOpsBrowsePreferences, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, "keyboard_shortcuts": settings.KeyboardShortcuts, diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index dd30d8d6a7..a22ff4221e 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -1316,6 +1316,46 @@ func TestApplyUserPreferenceBlobsValidation(t *testing.T) { t.Fatalf("expected explicit null to clear blob, got %s", string(settings.JiraSavedViews)) } }) + +} + +func TestAzureDevOpsBrowsePreferencesArePatched(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger: %v", err) + } + preferences := json.RawMessage(`{"workspace-a":{"mode":"board","projectId":"project-a"}}`) + updatedSettings := &models.UserSettings{ + UserID: store.DefaultUserID, + AzureDevOpsBrowsePreferences: preferences, + } + repo := &recordingUserRepository{ + getSettings: &models.UserSettings{UserID: store.DefaultUserID}, + preservingSettings: updatedSettings, + } + eventBus := &recordingEventBus{} + settings, err := NewService(repo, eventBus, log).UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{ + AzureDevOpsBrowsePreferences: rawPatch(preferences), + }) + if err != nil { + t.Fatalf("update Azure DevOps preferences: %v", err) + } + if string(settings.AzureDevOpsBrowsePreferences) != string(preferences) { + t.Fatalf("AzureDevOpsBrowsePreferences = %s, want %s", settings.AzureDevOpsBrowsePreferences, preferences) + } + if repo.preservingInput == nil || string(repo.preservingInput.AzureDevOpsBrowsePreferences) != string(preferences) { + t.Fatalf("persisted AzureDevOpsBrowsePreferences = %+v, want %s", repo.preservingInput, preferences) + } + if len(eventBus.publishedEvents) != 1 { + t.Fatalf("settings events = %d, want 1", len(eventBus.publishedEvents)) + } + eventData, ok := eventBus.publishedEvents[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("settings event data = %T, want map", eventBus.publishedEvents[0].Data) + } + if got, ok := eventData["azure_devops_browse_preferences"].(json.RawMessage); !ok || string(got) != string(preferences) { + t.Fatalf("event AzureDevOpsBrowsePreferences = %s, want %s", got, preferences) + } } type recordingUserRepository struct { @@ -1331,6 +1371,7 @@ type recordingUserRepository struct { getSettings *models.UserSettings getErr error preservingSettings *models.UserSettings + preservingInput *models.UserSettings preservingPatch *models.TaskCreateLastUsed preservingErr error closeCalls int @@ -1359,10 +1400,12 @@ func (r *recordingUserRepository) GetUserSettings(context.Context, string) (*mod func (r *recordingUserRepository) UpsertUserSettingsPreservingTaskCreateLastUsed( _ context.Context, - _ *models.UserSettings, + settings *models.UserSettings, patch *models.TaskCreateLastUsed, ) (*models.UserSettings, error) { r.upsertUserSettingsPreservingLastUsedCalls++ + settingsCopy := *settings + r.preservingInput = &settingsCopy if patch != nil { patchCopy := *patch r.preservingPatch = &patchCopy diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 6b33e4c9cc..2878ef3a1a 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -482,6 +482,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "github_saved_presets": settings.GitHubSavedPresets, "github_default_query_presets": settings.GitHubDefaultQueryPresets, "gitlab_saved_presets": settings.GitLabSavedPresets, + "azure_devops_browse_preferences": settings.AzureDevOpsBrowsePreferences, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, "keyboard_shortcuts": keyboardShortcuts, @@ -650,6 +651,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` + AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` @@ -740,6 +742,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.GitHubSavedPresets = payload.GitHubSavedPresets settings.GitHubDefaultQueryPresets = payload.GitHubDefaultQueryPresets settings.GitLabSavedPresets = payload.GitLabSavedPresets + settings.AzureDevOpsBrowsePreferences = payload.AzureDevOpsBrowsePreferences settings.DefaultUtilityAgentID = payload.DefaultUtilityAgentID settings.DefaultUtilityModel = payload.DefaultUtilityModel settings.KeyboardShortcuts = payload.KeyboardShortcuts diff --git a/apps/web/app/azure-devops/azure-devops-page-client.tsx b/apps/web/app/azure-devops/azure-devops-page-client.tsx index 54423ff049..dc42a09d84 100644 --- a/apps/web/app/azure-devops/azure-devops-page-client.tsx +++ b/apps/web/app/azure-devops/azure-devops-page-client.tsx @@ -1,8 +1,10 @@ +/* eslint-disable max-lines -- Azure DevOps page composition keeps the browse, board, and task-launch flows together. */ + "use client"; import Link from "@/components/routing/app-link"; -import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; -import { IconAdjustments, IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; +import { IconAdjustments } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; @@ -27,7 +29,14 @@ import { type AzureDevOpsScopeSelection, } from "@/components/azure-devops/azure-devops-scope-bar"; import { AzureDevOpsSaveViewDialog } from "@/components/azure-devops/azure-devops-save-view-dialog"; -import { presetsForKind } from "@/components/azure-devops/azure-devops-presets"; +import { AzureDevOpsBoard } from "@/components/azure-devops/azure-devops-board"; +import { AzureDevOpsWorkItemDetail } from "@/components/azure-devops/azure-devops-work-item-detail"; +import { AzureDevOpsPullRequestPagination } from "@/components/azure-devops/azure-devops-pull-request-pagination"; +import { AzureDevOpsModeTabs } from "@/components/azure-devops/azure-devops-mode-tabs"; +import { + presetsForKind, + type AzureDevOpsQueryPresets, +} from "@/components/azure-devops/azure-devops-presets"; import { useAzureDevOpsConnection, useAzureDevOpsPullRequestFeedback, @@ -39,12 +48,19 @@ import { useAzureDevOpsRepositories, } from "@/hooks/domains/azure-devops/use-azure-devops-projects"; import { useAzureDevOpsSavedViews } from "@/hooks/domains/azure-devops/use-azure-devops-saved-views"; +import { useAzureDevOpsPagePreferences } from "@/hooks/domains/azure-devops/use-azure-devops-preferences"; +import { useAzureDevOpsWorkspaceSettings } from "@/hooks/domains/azure-devops/use-azure-devops-workspace-actions"; import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http"; -import type { AzureDevOpsPullRequest, AzureDevOpsSavedView } from "@/lib/types/azure-devops"; +import type { + AzureDevOpsPullRequest, + AzureDevOpsSavedView, + AzureDevOpsWorkItem, +} from "@/lib/types/azure-devops"; const PAGE_SIZE = 25; const WORK_ITEMS_MODE: AzureDevOpsBrowseMode = "work-items"; const PULL_REQUESTS_MODE: AzureDevOpsBrowseMode = "pull-requests"; +const BOARD_MODE: AzureDevOpsBrowseMode = "board"; const DEFAULT_WIQL = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project ORDER BY [System.ChangedDate] DESC"; @@ -83,71 +99,21 @@ function NotConfigured({ workspaceId }: { workspaceId?: string }) { ); } -function ResultHeader({ - mode, - workItemCount, - pullRequestCount, -}: { - mode: AzureDevOpsBrowseMode; - workItemCount: number; - pullRequestCount: number; -}) { - const count = mode === WORK_ITEMS_MODE ? workItemCount : pullRequestCount; +const RESULT_LABELS: Record = { + [WORK_ITEMS_MODE]: "Work items", + [PULL_REQUESTS_MODE]: "Pull requests", + [BOARD_MODE]: "Board", +}; + +function ResultHeader({ mode, count }: { mode: AzureDevOpsBrowseMode; count: number }) { return (
-

- {mode === WORK_ITEMS_MODE ? "Work items" : "Pull requests"} -

+

{RESULT_LABELS[mode]}

{count} results
); } -function PullRequestPagination({ - skip, - count, - loading, - onPage, -}: { - skip: number; - count: number; - loading: boolean; - onPage: (skip: number) => void; -}) { - if (skip === 0 && count < PAGE_SIZE) return null; - return ( -
- - {count === 0 ? 0 : skip + 1}-{skip + count} - -
- - -
-
- ); -} - function useBrowseFilters(defaultProjectId?: string) { const [filters, setFilters] = useState(DEFAULT_FILTERS); useEffect(() => { @@ -198,12 +164,13 @@ function filtersForScope( current: AzureDevOpsFiltersState, selection: AzureDevOpsScopeSelection, views: AzureDevOpsSavedView[], + queryPresets: AzureDevOpsQueryPresets, ): AzureDevOpsFiltersState | undefined { if (selection.source === "saved") { const view = views.find((candidate) => candidate.id === selection.id); return view ? filtersFromSavedView(current, view) : undefined; } - const preset = presetsForKind(selection.kind).find( + const preset = presetsForKind(selection.kind, queryPresets).find( (candidate) => candidate.value === selection.id, ); return preset ? { ...current, ...preset.filters } : undefined; @@ -227,25 +194,31 @@ type RunAzureSearch = ( function useInitialAzureSearch({ connection, filters, + mode, runSearch, + ready, }: { connection: ReturnType; filters: AzureDevOpsFiltersState; + mode: AzureDevOpsBrowseMode; runSearch: RunAzureSearch; + ready: boolean; }) { const searchedWorkspace = useRef(""); const connectedWorkspaceId = connection.data?.hasSecret ? connection.data.workspaceId : undefined; useEffect(() => { if ( + !ready || !connectedWorkspaceId || !filters.projectId || + mode === BOARD_MODE || searchedWorkspace.current === connectedWorkspaceId ) { return; } searchedWorkspace.current = connectedWorkspaceId; runSearch(0); - }, [connectedWorkspaceId, filters.projectId, runSearch]); + }, [connectedWorkspaceId, filters.projectId, mode, ready, runSearch]); } function useAzureScopeControls({ @@ -255,6 +228,8 @@ function useAzureScopeControls({ views, setMode, setSkip, + queryPresets, + queryPresetsReady, }: { filters: AzureDevOpsFiltersState; replace: (next: AzureDevOpsFiltersState) => void; @@ -262,6 +237,8 @@ function useAzureScopeControls({ views: AzureDevOpsSavedView[]; setMode: (mode: AzureDevOpsBrowseMode) => void; setSkip: (skip: number) => void; + queryPresets: AzureDevOpsQueryPresets; + queryPresetsReady: boolean; }) { const [selection, setSelection] = useState({ kind: "work_item", @@ -274,14 +251,20 @@ function useAzureScopeControls({ const nextMode = modeForKind(next.kind); setMode(nextMode); setSkip(0); - const nextFilters = filtersForScope(filters, next, views); + const nextFilters = filtersForScope(filters, next, views, queryPresets); if (nextFilters) { replace(nextFilters); runSearch(0, { mode: nextMode, filters: nextFilters }); } }, - [filters, replace, runSearch, setMode, setSkip, views], + [filters, queryPresets, replace, runSearch, setMode, setSkip, views], ); + useEffect(() => { + if (!queryPresetsReady || selection.source !== "preset" || !selection.id) return; + const available = presetsForKind(selection.kind, queryPresets); + if (available.some((preset) => preset.value === selection.id) || !available[0]) return; + selectScope({ ...selection, id: available[0].value }); + }, [queryPresets, queryPresetsReady, selectScope, selection]); const markCustom = useCallback( () => setSelection((current) => ({ ...current, source: "preset", id: "" })), [], @@ -301,9 +284,11 @@ function useAzureSavedViewControls({ setSelection: (selection: AzureDevOpsScopeSelection) => void; }) { const [saveViewOpen, setSaveViewOpen] = useState(false); - const canSaveCurrent = Boolean( - filters.projectId && (mode === WORK_ITEMS_MODE ? filters.wiql.trim() : filters.repositoryId), - ); + const canSaveCurrent = + mode !== BOARD_MODE && + Boolean( + filters.projectId && (mode === WORK_ITEMS_MODE ? filters.wiql.trim() : filters.repositoryId), + ); const saveCurrentView = useCallback( async (label: string) => { const view: AzureDevOpsSavedView = { @@ -327,26 +312,22 @@ function useAzureSavedViewControls({ return { saveViewOpen, setSaveViewOpen, canSaveCurrent, saveCurrentView }; } -function useAzureDevOpsPageState(workspaceId?: string) { - const [mode, setMode] = useState(WORK_ITEMS_MODE); - const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); - const [launchPayload, setLaunchPayload] = useState(null); - const [feedbackOpen, setFeedbackOpen] = useState(false); - const [skip, setSkip] = useState(0); - const connection = useAzureDevOpsConnection(workspaceId); - const projectList = useAzureDevOpsProjects(workspaceId ?? "", !!connection.data?.hasSecret); - const { filters, update, replace } = useBrowseFilters( - connection.data?.defaultProjectId || projectList.data[0]?.id, - ); - const repositoryList = useAzureDevOpsRepositories(workspaceId ?? "", filters.projectId); - const workItems = useAzureDevOpsWorkItemSearch(workspaceId); - const pullRequests = useAzureDevOpsPullRequestSearch(workspaceId); - const feedback = useAzureDevOpsPullRequestFeedback(workspaceId); - const savedViews = useAzureDevOpsSavedViews(workspaceId); - - useDefaultAzureRepository(filters.repositoryId, repositoryList.data, update); - - const runSearch = useCallback( +function useAzureSearch({ + mode, + filters, + workItems, + pullRequests, + setSkip, + setMobileFiltersOpen, +}: { + mode: AzureDevOpsBrowseMode; + filters: AzureDevOpsFiltersState; + workItems: ReturnType; + pullRequests: ReturnType; + setSkip: (skip: number) => void; + setMobileFiltersOpen: (open: boolean) => void; +}) { + return useCallback( ( nextSkip: number = 0, override?: { mode: AzureDevOpsBrowseMode; filters: AzureDevOpsFiltersState }, @@ -362,6 +343,7 @@ function useAzureDevOpsPageState(workspaceId?: string) { }); return; } + if (searchMode === BOARD_MODE) return; setSkip(nextSkip); void pullRequests.search({ project: searchFilters.projectId, @@ -373,15 +355,50 @@ function useAzureDevOpsPageState(workspaceId?: string) { top: PAGE_SIZE, }); }, - [filters, mode, pullRequests, workItems], + [filters, mode, pullRequests, setMobileFiltersOpen, setSkip, workItems], + ); +} + +// eslint-disable-next-line max-lines-per-function -- this hook coordinates the page's persisted filters and browse modes. +function useAzureDevOpsPageState(workspaceId?: string) { + const [mode, setMode] = useState(BOARD_MODE); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); + const [launchPayload, setLaunchPayload] = useState(null); + const [detailItem, setDetailItem] = useState(null); + const [feedbackOpen, setFeedbackOpen] = useState(false); + const [skip, setSkip] = useState(0); + const connection = useAzureDevOpsConnection(workspaceId); + const projectList = useAzureDevOpsProjects(workspaceId ?? "", !!connection.data?.hasSecret); + const { filters, update, replace } = useBrowseFilters( + connection.data?.defaultProjectId || projectList.data[0]?.id, + ); + const repositoryList = useAzureDevOpsRepositories(workspaceId ?? "", filters.projectId); + const workItems = useAzureDevOpsWorkItemSearch(workspaceId); + const pullRequests = useAzureDevOpsPullRequestSearch(workspaceId); + const feedback = useAzureDevOpsPullRequestFeedback(workspaceId); + const savedViews = useAzureDevOpsSavedViews(workspaceId); + const workspaceSettings = useAzureDevOpsWorkspaceSettings(workspaceId); + const queryPresets = useMemo( + () => ({ + workItems: workspaceSettings.settings.workItemQueries, + pullRequests: workspaceSettings.settings.pullRequestQueries, + }), + [workspaceSettings.settings.pullRequestQueries, workspaceSettings.settings.workItemQueries], ); - useInitialAzureSearch({ connection, filters, runSearch }); + useDefaultAzureRepository(filters.repositoryId, repositoryList.data, update); - const openFeedback = (pullRequest: AzureDevOpsPullRequest) => { - setFeedbackOpen(true); - void feedback.load(pullRequest); - }; + const runSearch = useAzureSearch({ + mode, + filters, + workItems, + pullRequests, + setSkip, + setMobileFiltersOpen, + }); + + const openFeedback = (pullRequest: AzureDevOpsPullRequest) => + openAzureDevOpsFeedback(feedback, setFeedbackOpen, pullRequest); const scope = useAzureScopeControls({ filters, @@ -390,6 +407,8 @@ function useAzureDevOpsPageState(workspaceId?: string) { views: savedViews.views, setMode, setSkip, + queryPresets, + queryPresetsReady: !workspaceSettings.loading, }); const savedViewControls = useAzureSavedViewControls({ mode, @@ -398,6 +417,24 @@ function useAzureDevOpsPageState(workspaceId?: string) { setSelection: scope.setSelection, }); + const pagePreferences = useAzureDevOpsPagePreferences(workspaceId, { + mode, + setMode, + filters, + replaceFilters: replace, + selection: scope.selection, + setSelection: scope.setSelection, + defaultFilters: DEFAULT_FILTERS, + }); + + useInitialAzureSearch({ + connection, + filters, + mode, + runSearch, + ready: pagePreferences.hydrated, + }); + const updateFromUI = useCallback( (key: K, value: AzureDevOpsFiltersState[K]) => { scope.markCustom(); @@ -408,6 +445,7 @@ function useAzureDevOpsPageState(workspaceId?: string) { return { mode, + setMode, selection: scope.selection, selectScope: scope.selectScope, saveViewOpen: savedViewControls.saveViewOpen, @@ -416,6 +454,8 @@ function useAzureDevOpsPageState(workspaceId?: string) { setMobileFiltersOpen, launchPayload, setLaunchPayload, + detailItem, + setDetailItem, feedbackOpen, setFeedbackOpen, skip, @@ -432,18 +472,37 @@ function useAzureDevOpsPageState(workspaceId?: string) { savedViews, canSaveCurrent: savedViewControls.canSaveCurrent, saveCurrentView: savedViewControls.saveCurrentView, + preferencesLoaded: pagePreferences.hydrated, + boardPreference: pagePreferences.board, + setBoardPreference: pagePreferences.setBoard, + workItemActions: workspaceSettings.settings.workItemActions, + pullRequestActions: workspaceSettings.settings.pullRequestActions, + queryPresets, }; } +function openAzureDevOpsFeedback( + feedback: ReturnType, + setFeedbackOpen: (open: boolean) => void, + pullRequest: AzureDevOpsPullRequest, +) { + setFeedbackOpen(true); + void feedback.load(pullRequest); +} + type PageState = ReturnType; function BrowseResults({ state }: { state: PageState }) { + if (state.mode === BOARD_MODE) return null; return (
{state.mode === WORK_ITEMS_MODE ? ( @@ -452,6 +511,11 @@ function BrowseResults({ state }: { state: PageState }) { loading={state.workItems.loading} error={state.workItems.error} onStartTask={(item) => state.setLaunchPayload({ kind: "work-item", item })} + onOpenDetail={state.setDetailItem} + quickActions={state.workItemActions} + onQuickAction={(item, action) => + state.setLaunchPayload({ kind: "work-item", item, action }) + } /> ) : ( state.setLaunchPayload({ kind: "pull-request", pullRequest }) } + quickActions={state.pullRequestActions} + onQuickAction={(pullRequest, action) => + state.setLaunchPayload({ kind: "pull-request", pullRequest, action }) + } /> )}
{state.mode === PULL_REQUESTS_MODE && ( - )} @@ -496,9 +565,11 @@ function MobileFilters({ ); } +// eslint-disable-next-line max-lines-per-function -- the page content owns the responsive browse composition. function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }: PageProps) { const state = useAzureDevOpsPageState(workspaceId); + if (!state.preferencesLoaded) return null; if (state.connection.loading) return null; if (!state.connection.data?.hasSecret) return ; @@ -541,12 +612,30 @@ function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }: onDeleteSaved={(id) => void state.savedViews.remove(id)} canSaveCurrent={state.canSaveCurrent} onSaveCurrent={() => state.setSaveViewOpen(true)} + queryPresets={state.queryPresets} /> -
- -
+ + {state.mode === BOARD_MODE && ( + state.update("projectId", projectId)} + initialPreference={state.boardPreference} + onPreferenceChange={state.setBoardPreference} + quickActions={state.workItemActions} + onStartTask={(item, action) => + state.setLaunchPayload({ kind: "work-item", item, action }) + } + /> + )} + {state.mode !== BOARD_MODE && ( +
+ +
+ )} - + {state.mode !== BOARD_MODE && } + !open && state.setDetailItem(null)} + workspaceId={workspaceId} + projectId={state.filters.projectId} + initialItem={state.detailItem} + quickActions={state.workItemActions} + onStartTask={(item, action) => { + state.setLaunchPayload({ kind: "work-item", item, action }); + state.setDetailItem(null); + }} + /> { + it("preserves board column order and omits cards in unknown columns", () => { + const board = { + id: "board-1", + name: "Stories", + fields: { + columnField: { referenceName: "" }, + doneField: { referenceName: "" }, + rowField: { referenceName: "" }, + }, + columns: [ + { id: "todo", name: "To Do" }, + { id: "done", name: "Done" }, + ], + }; + const groups = groupAzureDevOpsBoardItems(board, [ + { + id: 2, + revision: 1, + title: "Done", + state: "", + type: "Bug", + columnId: "done", + columnDone: true, + }, + { + id: 1, + revision: 1, + title: "Todo", + state: "", + type: "Task", + columnId: "todo", + columnDone: false, + }, + { + id: 3, + revision: 1, + title: "Hidden", + state: "", + type: "Task", + columnId: "other", + columnDone: false, + }, + ]); + expect([...groups.keys()]).toEqual(["todo", "done"]); + expect(groups.get("todo")?.map((item) => item.id)).toEqual([1]); + expect(groups.get("done")?.map((item) => item.id)).toEqual([2]); + }); +}); diff --git a/apps/web/components/azure-devops/azure-devops-board-view.ts b/apps/web/components/azure-devops/azure-devops-board-view.ts new file mode 100644 index 0000000000..d1f9c78daf --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-board-view.ts @@ -0,0 +1,15 @@ +import type { AzureDevOpsBoard, AzureDevOpsBoardWorkItem } from "@/lib/types/azure-devops"; + +export function groupAzureDevOpsBoardItems( + board: AzureDevOpsBoard, + items: AzureDevOpsBoardWorkItem[], +): Map { + const groups = new Map( + board.columns.map((column) => [column.id, [] as AzureDevOpsBoardWorkItem[]]), + ); + for (const item of items) { + const group = groups.get(item.columnId); + if (group) group.push(item); + } + return groups; +} diff --git a/apps/web/components/azure-devops/azure-devops-board.tsx b/apps/web/components/azure-devops/azure-devops-board.tsx new file mode 100644 index 0000000000..d2211bfaf3 --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-board.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; +import { IconChevronLeft, IconChevronRight, IconGripVertical, IconList } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { useAzureDevOpsBoard } from "@/hooks/domains/azure-devops/use-azure-devops-board"; +import type { AzureDevOpsBoardPreference } from "@/hooks/domains/azure-devops/use-azure-devops-preferences"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import type { + AzureDevOpsActionPreset, + AzureDevOpsBoard, + AzureDevOpsBoardWorkItem, + AzureDevOpsProject, + AzureDevOpsWorkItem, +} from "@/lib/types/azure-devops"; +import { groupAzureDevOpsBoardItems } from "./azure-devops-board-view"; +import { AzureDevOpsWorkItemDetail } from "./azure-devops-work-item-detail"; + +type Props = { + workspaceId?: string; + projectId: string; + projects: AzureDevOpsProject[]; + onProjectChange: (projectId: string) => void; + initialPreference?: AzureDevOpsBoardPreference; + onPreferenceChange: (preference: AzureDevOpsBoardPreference) => void; + quickActions?: AzureDevOpsActionPreset[]; + onStartTask?: (item: AzureDevOpsWorkItem, action?: AzureDevOpsActionPreset) => void; +}; + +function BoardCard({ + item, + onOpen, + onDragStart, +}: { + item: AzureDevOpsBoardWorkItem; + onOpen: () => void; + onDragStart: () => void; +}) { + const pointerStart = useRef<{ x: number; y: number } | null>(null); + const handlePointerDown = (event: PointerEvent) => { + pointerStart.current = { x: event.clientX, y: event.clientY }; + }; + const handlePointerUp = (event: PointerEvent) => { + const start = pointerStart.current; + pointerStart.current = null; + if (!start || Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) onOpen(); + }; + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(); + } + }} + className="cursor-pointer gap-2 py-3 transition hover:border-primary/50" + data-testid={`azure-board-card-${item.id}`} + > + + + + {item.title || "Untitled work item"} + + + +
+ #{item.id} + {item.type && {item.type}} + {item.assignedTo && {item.assignedTo}} +
+ {!!item.tags?.length &&
{item.tags.join(" · ")}
} +
+
+ ); +} + +function MobileColumnNavigator({ + columns, + selectedColumn, + counts, + onChange, +}: { + columns: AzureDevOpsBoard["columns"]; + selectedColumn: string; + counts: Map; + onChange: (columnId: string) => void; +}) { + const [open, setOpen] = useState(false); + const index = Math.max( + 0, + columns.findIndex((column) => column.id === selectedColumn), + ); + const column = columns[index]; + const select = (columnId: string) => { + onChange(columnId); + setOpen(false); + }; + return ( +
+ + + + + + + Board columns + +
+ {columns.map((candidate) => ( + + ))} +
+
+
+
+ ); +} + +function BoardSelectors({ + projectId, + projects, + onProjectChange, + teamId, + teams, + onTeamChange, + boardId, + boards, + onBoardChange, +}: { + projectId: string; + projects: AzureDevOpsProject[]; + onProjectChange: (value: string) => void; + teamId: string; + teams: Array<{ id: string; name: string }>; + onTeamChange: (value: string) => void; + boardId: string; + boards: Array<{ id: string; name: string }>; + onBoardChange: (value: string) => void; +}) { + return ( +
+ + + +
+ ); +} + +function preferredColumn( + columns: AzureDevOpsBoard["columns"], + selectedColumn: string, + initialPreference: AzureDevOpsBoardPreference | undefined, +): string { + if (columns.some((column) => column.id === selectedColumn)) return selectedColumn; + if (columns.some((column) => column.id === initialPreference?.focusedColumnId)) { + return initialPreference?.focusedColumnId ?? ""; + } + return columns[0]?.id ?? ""; +} + +function useBoardPreference( + board: ReturnType, + columns: AzureDevOpsBoard["columns"], + initialPreference: AzureDevOpsBoardPreference | undefined, + onPreferenceChange: (preference: AzureDevOpsBoardPreference) => void, +) { + const [selectedColumn, setSelectedColumn] = useState(""); + const effectiveColumn = preferredColumn(columns, selectedColumn, initialPreference); + + useEffect(() => { + if (effectiveColumn !== selectedColumn) setSelectedColumn(effectiveColumn); + }, [effectiveColumn, selectedColumn]); + + useEffect(() => { + if (!board.teamId || !board.boardId || !effectiveColumn) return; + onPreferenceChange({ + teamId: board.teamId, + boardId: board.boardId, + focusedColumnId: effectiveColumn, + }); + }, [board.boardId, board.teamId, effectiveColumn, onPreferenceChange]); + + return { effectiveColumn, setSelectedColumn }; +} + +function useBoardGroups(snapshot: ReturnType["snapshot"]) { + return useMemo( + () => + snapshot + ? groupAzureDevOpsBoardItems(snapshot.board, snapshot.items) + : new Map(), + [snapshot], + ); +} + +function useBoardPresentation( + board: ReturnType, + initialPreference: AzureDevOpsBoardPreference | undefined, + onPreferenceChange: (preference: AzureDevOpsBoardPreference) => void, +) { + const groups = useBoardGroups(board.snapshot); + const columns = board.snapshot?.board.columns ?? []; + const columnPreference = useBoardPreference( + board, + columns, + initialPreference, + onPreferenceChange, + ); + return { groups, columns, columnPreference }; +} + +function BoardEmptyState() { + return ( +
Select a project to view its board.
+ ); +} + +// eslint-disable-next-line max-lines-per-function -- board rendering coordinates drag/drop, responsive columns, and detail state. +export function AzureDevOpsBoard({ + workspaceId, + projectId, + projects, + onProjectChange, + initialPreference, + onPreferenceChange, + quickActions = [], + onStartTask, +}: Props) { + const { isMobile } = useResponsiveBreakpoint(); + const board = useAzureDevOpsBoard(workspaceId, projectId, initialPreference); + const [editing, setEditing] = useState(null); + const [dragged, setDragged] = useState(null); + const presentation = useBoardPresentation(board, initialPreference, onPreferenceChange); + if (!projectId) return ; + return ( +
+ + {board.error && ( + + {board.error} + + )} + {board.loading &&
Loading board…
} + {!board.loading && board.snapshot && ( + <> + {isMobile && ( + + )} +
+ {presentation.columns + .filter( + (column) => + !isMobile || column.id === presentation.columnPreference.effectiveColumn, + ) + .map((column) => ( +
event.preventDefault()} + onDrop={() => { + if (dragged) void board.moveItem(dragged, column.id); + setDragged(null); + }} + > +
+ {column.name} + + {presentation.groups.get(column.id)?.length ?? 0} + +
+
+ {(presentation.groups.get(column.id) ?? []).map((item) => ( + setEditing(item)} + onDragStart={() => setDragged(item)} + /> + ))} +
+
+ ))} +
+ !open && setEditing(null)} + boardContext={ + editing + ? { + board: board.snapshot.board, + item: editing, + onMove: board.moveItem, + onItemUpdated: (item) => { + board.mergeItem(item); + setEditing(item); + if (isMobile) { + presentation.columnPreference.setSelectedColumn(item.columnId); + } + }, + } + : undefined + } + quickActions={quickActions} + onStartTask={onStartTask} + /> + + )} +
+ ); +} diff --git a/apps/web/components/azure-devops/azure-devops-default-queries.test.tsx b/apps/web/components/azure-devops/azure-devops-default-queries.test.tsx new file mode 100644 index 0000000000..629020658c --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-default-queries.test.tsx @@ -0,0 +1,88 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; + +const mocks = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() })); +vi.mock("@/lib/api/domains/azure-devops-api", () => ({ + getAzureDevOpsWorkspaceSettings: mocks.get, + updateAzureDevOpsWorkspaceSettings: mocks.update, +})); +vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +import { AzureDevOpsDefaultQueriesSection } from "./azure-devops-default-queries"; + +const settings = { + workspaceId: "workspace-1", + workItemQueries: [ + { + id: "recent", + label: "Recently updated", + group: "inbox", + filters: { wiql: "SELECT [System.Id] FROM WorkItems", top: 50 }, + }, + ], + pullRequestQueries: [ + { + id: "review-requested", + label: "Needs my review", + group: "inbox", + filters: { status: "active", reviewer: "@me" }, + }, + ], + workItemActions: [], + pullRequestActions: [], +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.get.mockResolvedValue(settings); + mocks.update.mockImplementation(async (_workspaceId, payload) => ({ + ...settings, + workItemQueries: payload.workItemQueries ?? settings.workItemQueries, + pullRequestQueries: payload.pullRequestQueries ?? settings.pullRequestQueries, + })); +}); +afterEach(cleanup); + +function renderSection() { + render( + + + , + ); +} + +describe("AzureDevOpsDefaultQueriesSection", () => { + it("edits provider-native queries with the shared GitHub-style save flow", async () => { + renderSection(); + const label = await screen.findByLabelText("Pull request query label 1"); + + expect(screen.getByRole("tab", { name: "Pull requests" })).toBeTruthy(); + expect(screen.getByRole("tab", { name: "Work items" })).toBeTruthy(); + fireEvent.change(label, { target: { value: "Review queue" } }); + + expect(mocks.update).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); + await waitFor(() => expect(mocks.update).toHaveBeenCalledTimes(1)); + expect(mocks.update).toHaveBeenCalledWith( + "workspace-1", + expect.objectContaining({ + pullRequestQueries: [expect.objectContaining({ label: "Review queue" })], + }), + ); + }); + + it("resets both query families through explicit null overrides", async () => { + renderSection(); + await screen.findByLabelText("Pull request query label 1"); + + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(mocks.update).toHaveBeenCalledTimes(1)); + expect(mocks.update).toHaveBeenCalledWith("workspace-1", { + pullRequestQueries: null, + workItemQueries: null, + }); + }); +}); diff --git a/apps/web/components/azure-devops/azure-devops-default-queries.tsx b/apps/web/components/azure-devops/azure-devops-default-queries.tsx new file mode 100644 index 0000000000..cdb4fb6eab --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-default-queries.tsx @@ -0,0 +1,473 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { useToast } from "@/components/toast-provider"; +import { + getAzureDevOpsWorkspaceSettings, + updateAzureDevOpsWorkspaceSettings, +} from "@/lib/api/domains/azure-devops-api"; +import type { AzureDevOpsQueryPreset } from "@/lib/types/azure-devops"; +import { + DEFAULT_AZURE_PULL_REQUEST_QUERIES, + DEFAULT_AZURE_WORK_ITEM_QUERIES, +} from "./azure-devops-workspace-defaults"; + +type QueryKind = "Work item" | "Pull request"; + +function newQuery(kind: QueryKind): AzureDevOpsQueryPreset { + return { + id: `query_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, + label: "New query", + group: "inbox", + filters: + kind === "Work item" + ? { + wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project", + top: 50, + } + : { status: "active", creator: "", reviewer: "" }, + }; +} + +function stringFilter(preset: AzureDevOpsQueryPreset, key: string, fallback = ""): string { + return typeof preset.filters[key] === "string" ? preset.filters[key] : fallback; +} + +function numberFilter(preset: AzureDevOpsQueryPreset, key: string, fallback: number): number { + return typeof preset.filters[key] === "number" ? preset.filters[key] : fallback; +} + +function QueryGroupSelect({ + kind, + index, + value, + dirty, + onChange, +}: { + kind: QueryKind; + index: number; + value: string; + dirty: boolean; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function QueryField({ + label, + className, + children, +}: { + label: string; + className?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +type QueryRowProps = { + preset: AzureDevOpsQueryPreset; + baseline?: AzureDevOpsQueryPreset; + index: number; + onPatch: (patch: Partial) => void; + onPatchFilters: (patch: Record) => void; + onRemove: () => void; +}; + +function RemoveQueryButton({ + kind, + index, + onRemove, +}: { + kind: QueryKind; + index: number; + onRemove: () => void; +}) { + return ( + + ); +} + +function WorkItemQueryRow({ + preset, + baseline, + index, + onPatch, + onPatchFilters, + onRemove, +}: QueryRowProps) { + return ( +
+ + onPatch({ label: event.target.value })} + /> + + + onPatchFilters({ wiql: event.target.value })} + /> + + + onPatchFilters({ top: Number(event.target.value) || 1 })} + /> + + + onPatch({ group })} + /> + + +
+ ); +} + +function PullRequestQueryRow({ + preset, + baseline, + index, + onPatch, + onPatchFilters, + onRemove, +}: QueryRowProps) { + return ( +
+ + onPatch({ label: event.target.value })} + /> + + + + + + onPatchFilters({ creator: event.target.value })} + /> + + + onPatchFilters({ reviewer: event.target.value })} + /> + + + onPatch({ group })} + /> + + +
+ ); +} + +function QueryEditor({ + kind, + queries, + baseline, + onChange, +}: { + kind: QueryKind; + queries: AzureDevOpsQueryPreset[]; + baseline: AzureDevOpsQueryPreset[]; + onChange: (queries: AzureDevOpsQueryPreset[]) => void; +}) { + const patch = (index: number, values: Partial) => + onChange( + queries.map((query, current) => (current === index ? { ...query, ...values } : query)), + ); + const patchFilters = (index: number, values: Record) => + patch(index, { filters: { ...queries[index].filters, ...values } }); + const Row = kind === "Work item" ? WorkItemQueryRow : PullRequestQueryRow; + return ( +
+ {queries.map((query, index) => ( + candidate.id === query.id)} + index={index} + onPatch={(values) => patch(index, values)} + onPatchFilters={(values) => patchFilters(index, values)} + onRemove={() => onChange(queries.filter((_, current) => current !== index))} + /> + ))} + +
+ ); +} + +function useQueryDrafts(workspaceId: string) { + const [workItems, setWorkItems] = useState(DEFAULT_AZURE_WORK_ITEM_QUERIES); + const [pullRequests, setPullRequests] = useState(DEFAULT_AZURE_PULL_REQUEST_QUERIES); + const [baseline, setBaseline] = useState({ workItems, pullRequests }); + const [loading, setLoading] = useState(true); + const [resetRequested, setResetRequested] = useState(false); + const dirty = useMemo( + () => + resetRequested || JSON.stringify({ workItems, pullRequests }) !== JSON.stringify(baseline), + [baseline, pullRequests, resetRequested, workItems], + ); + + useEffect(() => { + let current = true; + void getAzureDevOpsWorkspaceSettings(workspaceId) + .then((settings) => { + if (!current) return; + setWorkItems(settings.workItemQueries); + setPullRequests(settings.pullRequestQueries); + setBaseline({ + workItems: settings.workItemQueries, + pullRequests: settings.pullRequestQueries, + }); + }) + .catch(() => undefined) + .finally(() => current && setLoading(false)); + return () => { + current = false; + }; + }, [workspaceId]); + + const save = useCallback(async () => { + const response = await updateAzureDevOpsWorkspaceSettings( + workspaceId, + resetRequested + ? { pullRequestQueries: null, workItemQueries: null } + : { pullRequestQueries: pullRequests, workItemQueries: workItems }, + ); + setWorkItems(response.workItemQueries); + setPullRequests(response.pullRequestQueries); + setBaseline({ + workItems: response.workItemQueries, + pullRequests: response.pullRequestQueries, + }); + setResetRequested(false); + }, [pullRequests, resetRequested, workItems, workspaceId]); + const reset = useCallback(() => { + setWorkItems(DEFAULT_AZURE_WORK_ITEM_QUERIES); + setPullRequests(DEFAULT_AZURE_PULL_REQUEST_QUERIES); + setResetRequested(true); + }, []); + const discard = useCallback(() => { + setWorkItems(baseline.workItems); + setPullRequests(baseline.pullRequests); + setResetRequested(false); + }, [baseline]); + + return { + workItems, + pullRequests, + setWorkItems, + setPullRequests, + baseline, + loading, + dirty, + save, + reset, + discard, + }; +} + +function validQueries(queries: AzureDevOpsQueryPreset[], kind: QueryKind): boolean { + return ( + queries.length > 0 && + queries.every( + (query) => + query.label.trim() && + (kind === "Pull request" + ? stringFilter(query, "status", "active") + : stringFilter(query, "wiql")), + ) + ); +} + +export function AzureDevOpsDefaultQueriesSection({ workspaceId }: { workspaceId: string }) { + const drafts = useQueryDrafts(workspaceId); + const { toast } = useToast(); + const valid = + validQueries(drafts.pullRequests, "Pull request") && + validQueries(drafts.workItems, "Work item"); + const save = useCallback(async () => { + try { + await drafts.save(); + toast({ description: "Azure DevOps default queries saved", variant: "success" }); + } catch (error) { + toast({ + description: error instanceof Error ? error.message : "Failed to save default queries", + variant: "error", + }); + throw error; + } + }, [drafts, toast]); + + useSettingsSaveContributor({ + id: `azure-devops-default-queries:${workspaceId}`, + revision: JSON.stringify([drafts.pullRequests, drafts.workItems]), + isDirty: drafts.dirty, + canSave: !drafts.loading && valid, + invalidReason: valid ? undefined : "Every default query needs a label and valid filters.", + save, + discard: drafts.discard, + }); + + return ( + + Reset + + } + > + + +
+ + + + Pull requests + + + Work items + + + + + + + + + +
+
+
+
+ ); +} diff --git a/apps/web/components/azure-devops/azure-devops-filters.tsx b/apps/web/components/azure-devops/azure-devops-filters.tsx index a38ad10fc3..c60d2ccf18 100644 --- a/apps/web/components/azure-devops/azure-devops-filters.tsx +++ b/apps/web/components/azure-devops/azure-devops-filters.tsx @@ -11,7 +11,7 @@ import { Textarea } from "@kandev/ui/textarea"; import type { AzureDevOpsProject, AzureDevOpsRepository } from "@/lib/types/azure-devops"; import { cn } from "@/lib/utils"; -export type AzureDevOpsBrowseMode = "work-items" | "pull-requests"; +export type AzureDevOpsBrowseMode = "board" | "work-items" | "pull-requests"; export type AzureDevOpsFiltersState = { projectId: string; diff --git a/apps/web/components/azure-devops/azure-devops-mode-tabs.tsx b/apps/web/components/azure-devops/azure-devops-mode-tabs.tsx new file mode 100644 index 0000000000..2b9f2697f4 --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-mode-tabs.tsx @@ -0,0 +1,34 @@ +import { Button } from "@kandev/ui/button"; + +import type { AzureDevOpsBrowseMode } from "./azure-devops-filters"; + +type AzureDevOpsModeTabsProps = { + mode: AzureDevOpsBrowseMode; + onModeChange: (mode: AzureDevOpsBrowseMode) => void; +}; + +const modes: Array<{ mode: AzureDevOpsBrowseMode; label: string; testId: string }> = [ + { mode: "board", label: "Board", testId: "azure-devops-board-mode" }, + { mode: "work-items", label: "Work items", testId: "azure-devops-work-items-mode" }, + { mode: "pull-requests", label: "Pull requests", testId: "azure-devops-pull-requests-mode" }, +]; + +export function AzureDevOpsModeTabs({ mode, onModeChange }: AzureDevOpsModeTabsProps) { + return ( +
+ {modes.map((option) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/azure-devops/azure-devops-presets.test.ts b/apps/web/components/azure-devops/azure-devops-presets.test.ts index e41c20a4fa..ac7fa2501a 100644 --- a/apps/web/components/azure-devops/azure-devops-presets.test.ts +++ b/apps/web/components/azure-devops/azure-devops-presets.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { AZURE_PULL_REQUEST_PRESETS, AZURE_WORK_ITEM_PRESETS } from "./azure-devops-presets"; +import { + AZURE_PULL_REQUEST_PRESETS, + AZURE_WORK_ITEM_PRESETS, + presetsForKind, +} from "./azure-devops-presets"; describe("Azure DevOps presets", () => { it("uses Azure WIQL identity macros for personal work-item presets", () => { @@ -14,15 +18,31 @@ describe("Azure DevOps presets", () => { it("uses the backend @me identity sentinel for personal pull-request presets", () => { expect( AZURE_PULL_REQUEST_PRESETS.find((preset) => preset.value === "review-requested")?.filters, - ).toMatchObject({ - status: "active", - reviewer: "@me", - }); + ).toMatchObject({ status: "active", reviewer: "@me" }); expect( AZURE_PULL_REQUEST_PRESETS.find((preset) => preset.value === "created")?.filters, - ).toMatchObject({ - status: "active", - creator: "@me", + ).toMatchObject({ status: "active", creator: "@me" }); + }); + + it("maps workspace-resolved Azure queries into browse shortcuts", () => { + const presets = presetsForKind("pull_request", { + workItems: [], + pullRequests: [ + { + id: "team-review", + label: "Team review", + group: "created", + filters: { status: "active", reviewer: "team-id", creator: "@me" }, + }, + ], + }); + + expect(presets).toHaveLength(1); + expect(presets[0]).toMatchObject({ + value: "team-review", + label: "Team review", + group: "created", + filters: { status: "active", reviewer: "team-id", creator: "@me" }, }); }); }); diff --git a/apps/web/components/azure-devops/azure-devops-presets.ts b/apps/web/components/azure-devops/azure-devops-presets.ts index 18fa372ac6..eba0b00a92 100644 --- a/apps/web/components/azure-devops/azure-devops-presets.ts +++ b/apps/web/components/azure-devops/azure-devops-presets.ts @@ -4,9 +4,15 @@ import { IconChecks, IconGitPullRequest, IconInbox, + IconSearch, IconUser, } from "@tabler/icons-react"; +import type { AzureDevOpsQueryPreset } from "@/lib/types/azure-devops"; import type { AzureDevOpsFiltersState } from "./azure-devops-filters"; +import { + DEFAULT_AZURE_PULL_REQUEST_QUERIES, + DEFAULT_AZURE_WORK_ITEM_QUERIES, +} from "./azure-devops-workspace-defaults"; export type AzureDevOpsPresetKind = "work_item" | "pull_request"; @@ -18,75 +24,63 @@ export type AzureDevOpsPreset = { filters: Partial; }; -const WIQL_START = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project"; -const WIQL_ORDER = " ORDER BY [System.ChangedDate] DESC"; +const ICONS = { + recent: IconCalendarClock, + assigned: IconInbox, + active: IconActivity, + "review-requested": IconInbox, + completed: IconChecks, + created: IconUser, +}; -function wiql(condition?: string): string { - return `${WIQL_START}${condition ? ` AND ${condition}` : ""}${WIQL_ORDER}`; +function stringFilter(filters: Record, key: string, fallback = ""): string { + return typeof filters[key] === "string" ? filters[key] : fallback; } -export const AZURE_WORK_ITEM_PRESETS: AzureDevOpsPreset[] = [ - { - value: "recent", - label: "Recently updated", - icon: IconCalendarClock, - group: "inbox", - filters: { wiql: wiql(), top: 50 }, - }, - { - value: "assigned", - label: "Assigned to me", - icon: IconInbox, - group: "inbox", - filters: { wiql: wiql("[System.AssignedTo] = @Me"), top: 50 }, - }, - { - value: "active", - label: "Active", - icon: IconActivity, - group: "inbox", - filters: { wiql: wiql("[System.State] <> 'Closed' AND [System.State] <> 'Done'"), top: 50 }, - }, - { - value: "created", - label: "Created by me", - icon: IconUser, - group: "created", - filters: { wiql: wiql("[System.CreatedBy] = @Me"), top: 50 }, - }, -]; - -export const AZURE_PULL_REQUEST_PRESETS: AzureDevOpsPreset[] = [ - { - value: "review-requested", - label: "Review requested", - icon: IconInbox, - group: "inbox", - filters: { status: "active", reviewer: "@me", creator: "" }, - }, - { - value: "active", - label: "Open", - icon: IconGitPullRequest, - group: "inbox", - filters: { status: "active", creator: "", reviewer: "" }, - }, - { - value: "completed", - label: "Completed", - icon: IconChecks, - group: "created", - filters: { status: "completed", creator: "", reviewer: "" }, - }, - { - value: "created", - label: "Created by me", - icon: IconUser, - group: "created", - filters: { status: "active", creator: "@me", reviewer: "" }, - }, -]; - -export function presetsForKind(kind: AzureDevOpsPresetKind): AzureDevOpsPreset[] { - return kind === "work_item" ? AZURE_WORK_ITEM_PRESETS : AZURE_PULL_REQUEST_PRESETS; +function filtersForQuery( + kind: AzureDevOpsPresetKind, + filters: Record, +): Partial { + if (kind === "work_item") { + const top = typeof filters.top === "number" && filters.top > 0 ? filters.top : 50; + return { wiql: stringFilter(filters, "wiql"), top }; + } + return { + status: stringFilter(filters, "status", "active"), + creator: stringFilter(filters, "creator"), + reviewer: stringFilter(filters, "reviewer"), + }; } + +function toBrowsePreset( + kind: AzureDevOpsPresetKind, + preset: AzureDevOpsQueryPreset, +): AzureDevOpsPreset { + return { + value: preset.id, + label: preset.label, + icon: + ICONS[preset.id as keyof typeof ICONS] ?? + (kind === "pull_request" ? IconGitPullRequest : IconSearch), + group: preset.group === "created" ? "created" : "inbox", + filters: filtersForQuery(kind, preset.filters), + }; +} + +export type AzureDevOpsQueryPresets = { + workItems: AzureDevOpsQueryPreset[]; + pullRequests: AzureDevOpsQueryPreset[]; +}; + +export function presetsForKind( + kind: AzureDevOpsPresetKind, + configured?: AzureDevOpsQueryPresets, +): AzureDevOpsPreset[] { + const defaults = + kind === "work_item" ? DEFAULT_AZURE_WORK_ITEM_QUERIES : DEFAULT_AZURE_PULL_REQUEST_QUERIES; + const candidates = kind === "work_item" ? configured?.workItems : configured?.pullRequests; + return (candidates?.length ? candidates : defaults).map((preset) => toBrowsePreset(kind, preset)); +} + +export const AZURE_WORK_ITEM_PRESETS = presetsForKind("work_item"); +export const AZURE_PULL_REQUEST_PRESETS = presetsForKind("pull_request"); diff --git a/apps/web/components/azure-devops/azure-devops-pull-request-pagination.tsx b/apps/web/components/azure-devops/azure-devops-pull-request-pagination.tsx new file mode 100644 index 0000000000..772116a977 --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-pull-request-pagination.tsx @@ -0,0 +1,51 @@ +import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; + +type Props = { + skip: number; + count: number; + loading: boolean; + pageSize: number; + onPage: (skip: number) => void; +}; + +export function AzureDevOpsPullRequestPagination({ + skip, + count, + loading, + pageSize, + onPage, +}: Props) { + if (skip === 0 && count < pageSize) return null; + return ( +
+ + {count === 0 ? 0 : skip + 1}-{skip + count} + +
+ + +
+
+ ); +} diff --git a/apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx b/apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx new file mode 100644 index 0000000000..69791c865b --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-quick-actions.test.tsx @@ -0,0 +1,102 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; + +const mocks = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() })); +vi.mock("@/lib/api/domains/azure-devops-api", () => ({ + getAzureDevOpsWorkspaceSettings: mocks.get, + updateAzureDevOpsWorkspaceSettings: mocks.update, +})); +vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +import { AzureDevOpsQuickActionsSection } from "./azure-devops-quick-actions"; + +beforeEach(() => { + vi.clearAllMocks(); + const settings = { + workItemActions: [ + { + id: "implement", + label: "Implement", + hint: "Build it", + icon: "code", + promptTemplate: "Implement {{url}}", + }, + ], + pullRequestActions: [ + { + id: "review", + label: "Review", + hint: "Read it", + icon: "eye", + promptTemplate: "Review {{url}}", + }, + ], + }; + mocks.get.mockResolvedValue(settings); + mocks.update.mockResolvedValue(settings); +}); +afterEach(cleanup); + +function renderSection() { + render( + + + , + ); +} + +describe("AzureDevOpsQuickActionsSection", () => { + it("uses the GitHub-style tabbed action and prompt editor", async () => { + renderSection(); + await waitFor(() => + expect((screen.getByLabelText("Pull request action label 1") as HTMLInputElement).value).toBe( + "Review", + ), + ); + expect(screen.getByRole("tab", { name: "Pull requests" })).toBeTruthy(); + fireEvent.mouseDown(screen.getByRole("tab", { name: "Work items" }), { + button: 0, + ctrlKey: false, + }); + expect((screen.getByLabelText("Work item action label 1") as HTMLInputElement).value).toBe( + "Implement", + ); + fireEvent.mouseDown(screen.getByRole("tab", { name: "Pull requests" }), { + button: 0, + ctrlKey: false, + }); + expect(screen.getByRole("button", { name: "Reset" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Edit prompt" })); + expect(screen.getByText(/available placeholders/)).toBeTruthy(); + }); + + it("keeps edits local until the shared Save changes action is pressed", async () => { + renderSection(); + const label = await screen.findByLabelText("Pull request action label 1"); + + fireEvent.change(label, { target: { value: "Inspect" } }); + + expect(mocks.update).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); + await waitFor(() => expect(mocks.update).toHaveBeenCalledTimes(1)); + expect(mocks.update).toHaveBeenCalledWith( + "workspace-1", + expect.objectContaining({ + pullRequestActions: [expect.objectContaining({ label: "Inspect" })], + }), + ); + }); + + it("blocks saving when loading the existing quick actions fails", async () => { + mocks.get.mockRejectedValueOnce(new Error("Settings unavailable")); + renderSection(); + + expect((await screen.findByRole("alert")).textContent).toContain("Settings unavailable"); + expect((screen.getByRole("button", { name: "Reset" }) as HTMLButtonElement).disabled).toBe( + true, + ); + expect(screen.queryByRole("button", { name: "Save changes" })).toBeNull(); + expect(mocks.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/azure-devops/azure-devops-quick-actions.tsx b/apps/web/components/azure-devops/azure-devops-quick-actions.tsx new file mode 100644 index 0000000000..38e9d39dc4 --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-quick-actions.tsx @@ -0,0 +1,430 @@ +"use client"; + +import { createElement, useCallback, useEffect, useMemo, useState } from "react"; +import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; +import { + ScriptEditor, + computeEditorHeight, +} from "@/components/settings/profile-edit/script-editor"; +import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; +import { + ACTION_PRESET_ICON_CHOICES, + iconForActionPreset, +} from "@/components/integrations/action-preset-icons"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { useToast } from "@/components/toast-provider"; +import { + getAzureDevOpsWorkspaceSettings, + updateAzureDevOpsWorkspaceSettings, +} from "@/lib/api/domains/azure-devops-api"; +import type { AzureDevOpsActionPreset } from "@/lib/types/azure-devops"; +import { + DEFAULT_AZURE_PULL_REQUEST_ACTIONS, + DEFAULT_AZURE_WORK_ITEM_ACTIONS, +} from "./azure-devops-workspace-defaults"; + +const ACTION_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [ + { + key: "url", + description: "URL of the Azure DevOps work item or pull request", + example: "https://dev.azure.com/acme/Platform/_workitems/edit/42", + executor_types: [], + }, + { + key: "title", + description: "Title of the Azure DevOps work item or pull request", + example: "Rotate integration credentials", + executor_types: [], + }, +]; + +type ActionKind = "Work item" | "Pull request"; + +function newAction(): AzureDevOpsActionPreset { + return { + id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, + label: "New action", + hint: "", + icon: "sparkle", + promptTemplate: "", + }; +} + +function ActionIconSelect({ + value, + dirty, + onChange, +}: { + value: string; + dirty: boolean; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function Field({ + label, + className, + children, +}: { + label: string; + className?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function ActionRow({ + kind, + index, + action, + baseline, + expanded, + onToggle, + onPatch, + onRemove, +}: { + kind: ActionKind; + index: number; + action: AzureDevOpsActionPreset; + baseline?: AzureDevOpsActionPreset; + expanded: boolean; + onToggle: () => void; + onPatch: (patch: Partial) => void; + onRemove: () => void; +}) { + return ( +
+
+ + onPatch({ icon })} + /> + + + onPatch({ label: event.target.value })} + /> + + + onPatch({ hint: event.target.value })} + /> + + + +
+ {expanded && ( +
+
+ onPatch({ promptTemplate })} + language="markdown" + height={computeEditorHeight(action.promptTemplate)} + lineNumbers="off" + placeholders={ACTION_PROMPT_PLACEHOLDERS} + /> +
+

+ Type {"{{"} to see available placeholders. {"{{url}}"} and{" "} + {"{{title}}"} are substituted when the action runs. +

+
+ )} +
+ ); +} + +function ActionEditor({ + kind, + actions, + baseline, + onChange, +}: { + kind: ActionKind; + actions: AzureDevOpsActionPreset[]; + baseline: AzureDevOpsActionPreset[]; + onChange: (actions: AzureDevOpsActionPreset[]) => void; +}) { + const [expandedId, setExpandedId] = useState(null); + const patch = (index: number, values: Partial) => + onChange( + actions.map((action, current) => (current === index ? { ...action, ...values } : action)), + ); + const add = () => { + const action = newAction(); + onChange([...actions, action]); + setExpandedId(action.id); + }; + return ( +
+ {actions.map((action, index) => ( + candidate.id === action.id)} + expanded={expandedId === action.id} + onToggle={() => setExpandedId((id) => (id === action.id ? null : action.id))} + onPatch={(values) => patch(index, values)} + onRemove={() => onChange(actions.filter((_, current) => current !== index))} + /> + ))} + +
+ ); +} + +function useActionDrafts(workspaceId: string) { + const [workItems, setWorkItems] = useState(DEFAULT_AZURE_WORK_ITEM_ACTIONS); + const [pullRequests, setPullRequests] = useState(DEFAULT_AZURE_PULL_REQUEST_ACTIONS); + const [baseline, setBaseline] = useState({ workItems, pullRequests }); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [resetRequested, setResetRequested] = useState(false); + const dirty = useMemo( + () => + resetRequested || JSON.stringify({ workItems, pullRequests }) !== JSON.stringify(baseline), + [baseline, pullRequests, resetRequested, workItems], + ); + + useEffect(() => { + let current = true; + setLoading(true); + setLoadError(null); + void getAzureDevOpsWorkspaceSettings(workspaceId) + .then((settings) => { + if (!current) return; + setWorkItems(settings.workItemActions); + setPullRequests(settings.pullRequestActions); + setBaseline({ + workItems: settings.workItemActions, + pullRequests: settings.pullRequestActions, + }); + }) + .catch((error: unknown) => { + if (!current) return; + setLoadError(error instanceof Error ? error.message : "Failed to load quick actions."); + }) + .finally(() => current && setLoading(false)); + return () => { + current = false; + }; + }, [workspaceId]); + + const save = useCallback(async () => { + const response = await updateAzureDevOpsWorkspaceSettings( + workspaceId, + resetRequested + ? { workItemActions: null, pullRequestActions: null } + : { workItemActions: workItems, pullRequestActions: pullRequests }, + ); + setWorkItems(response.workItemActions); + setPullRequests(response.pullRequestActions); + setBaseline({ + workItems: response.workItemActions, + pullRequests: response.pullRequestActions, + }); + setResetRequested(false); + }, [pullRequests, resetRequested, workItems, workspaceId]); + const reset = useCallback(() => { + setWorkItems(DEFAULT_AZURE_WORK_ITEM_ACTIONS); + setPullRequests(DEFAULT_AZURE_PULL_REQUEST_ACTIONS); + setResetRequested(true); + }, []); + const discard = useCallback(() => { + setWorkItems(baseline.workItems); + setPullRequests(baseline.pullRequests); + setResetRequested(false); + }, [baseline]); + + return { + workItems, + pullRequests, + setWorkItems, + setPullRequests, + baseline, + loading, + loadError, + dirty, + save, + reset, + discard, + }; +} + +function validActions(actions: AzureDevOpsActionPreset[]): boolean { + return ( + actions.length > 0 && + actions.every((action) => action.label.trim() && action.promptTemplate.trim()) + ); +} + +export function AzureDevOpsQuickActionsSection({ workspaceId }: { workspaceId: string }) { + const drafts = useActionDrafts(workspaceId); + const { toast } = useToast(); + const valid = validActions(drafts.workItems) && validActions(drafts.pullRequests); + const save = useCallback(async () => { + try { + await drafts.save(); + toast({ description: "Azure DevOps quick actions saved", variant: "success" }); + } catch (error) { + toast({ + description: error instanceof Error ? error.message : "Failed to save quick actions", + variant: "error", + }); + throw error; + } + }, [drafts, toast]); + + useSettingsSaveContributor({ + id: `azure-devops-actions:${workspaceId}`, + revision: JSON.stringify([drafts.workItems, drafts.pullRequests]), + isDirty: drafts.dirty, + canSave: !drafts.loading && !drafts.loadError && valid, + invalidReason: + drafts.loadError ?? (valid ? undefined : "Every quick action needs a label and prompt."), + save, + discard: drafts.discard, + }); + + return ( + + Reset + + } + > + + + {drafts.loadError && ( +

+ Could not load existing quick actions: {drafts.loadError} +

+ )} +
+ + + + Pull requests + + + Work items + + + + + + + + + +
+
+
+
+ ); +} diff --git a/apps/web/components/azure-devops/azure-devops-results.test.tsx b/apps/web/components/azure-devops/azure-devops-results.test.tsx index d823f81a70..89f25bf672 100644 --- a/apps/web/components/azure-devops/azure-devops-results.test.tsx +++ b/apps/web/components/azure-devops/azure-devops-results.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AzureDevOpsPullRequest, AzureDevOpsWorkItem } from "@/lib/types/azure-devops"; import { AzureDevOpsPullRequestResults, AzureDevOpsWorkItemResults } from "./azure-devops-results"; @@ -32,4 +32,33 @@ describe("Azure DevOps results", () => { expect(screen.getByText("Loading results...")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Feedback" })).toBeNull(); }); + + it("offers configured work-item quick actions before creating a task", () => { + const onQuickAction = vi.fn(); + render( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Task actions for work item 7" })); + fireEvent.click(screen.getByRole("menuitem", { name: /Implement/ })); + expect(onQuickAction).toHaveBeenCalledWith( + expect.objectContaining({ id: 7 }), + expect.anything(), + ); + }); }); diff --git a/apps/web/components/azure-devops/azure-devops-results.tsx b/apps/web/components/azure-devops/azure-devops-results.tsx index c3f09c6eaa..3c8a92738e 100644 --- a/apps/web/components/azure-devops/azure-devops-results.tsx +++ b/apps/web/components/azure-devops/azure-devops-results.tsx @@ -1,9 +1,24 @@ "use client"; -import { IconExternalLink, IconMessageCircle, IconPlus } from "@tabler/icons-react"; +import { + IconChevronDown, + IconExternalLink, + IconMessageCircle, + IconPlus, +} from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; -import type { AzureDevOpsPullRequest, AzureDevOpsWorkItem } from "@/lib/types/azure-devops"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import type { + AzureDevOpsActionPreset, + AzureDevOpsPullRequest, + AzureDevOpsWorkItem, +} from "@/lib/types/azure-devops"; function EmptyResult({ loading, error }: { loading: boolean; error: string | null }) { if (loading) return
Loading results...
; @@ -16,16 +31,60 @@ function EmptyResult({ loading, error }: { loading: boolean; error: string | nul return
No matching results.
; } +function TaskActionsMenu({ + item, + itemLabel, + actions, + onSelect, +}: { + item: T; + itemLabel: string; + actions: AzureDevOpsActionPreset[]; + onSelect: (item: T, action: AzureDevOpsActionPreset) => void; +}) { + return ( + + + + + + {actions.map((action) => ( + onSelect(item, action)}> + + {action.label} + {action.hint && {action.hint}} + + + ))} + + + ); +} + export function AzureDevOpsWorkItemResults({ items, loading, error, onStartTask, + onOpenDetail, + quickActions = [], + onQuickAction, }: { items: AzureDevOpsWorkItem[]; loading: boolean; error: string | null; onStartTask: (item: AzureDevOpsWorkItem) => void; + onOpenDetail?: (item: AzureDevOpsWorkItem) => void; + quickActions?: AzureDevOpsActionPreset[]; + onQuickAction?: (item: AzureDevOpsWorkItem, action: AzureDevOpsActionPreset) => void; }) { if (loading || error || items.length === 0) return ; @@ -33,7 +92,19 @@ export function AzureDevOpsWorkItemResults({
{items.map((item) => (
-
+
onOpenDetail?.(item)} + onKeyDown={(event) => { + if (onOpenDetail && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + onOpenDetail(item); + } + }} + data-testid={`azure-work-item-row-${item.id}`} + >
#{item.id} {item.type} @@ -58,16 +129,25 @@ export function AzureDevOpsWorkItemResults({ )} - + {quickActions.length > 0 && onQuickAction ? ( + + ) : ( + + )}
))} @@ -85,12 +165,16 @@ export function AzureDevOpsPullRequestResults({ error, onFeedback, onStartTask, + quickActions = [], + onQuickAction, }: { items: AzureDevOpsPullRequest[]; loading: boolean; error: string | null; onFeedback: (pullRequest: AzureDevOpsPullRequest) => void; onStartTask: (pullRequest: AzureDevOpsPullRequest) => void; + quickActions?: AzureDevOpsActionPreset[]; + onQuickAction?: (pullRequest: AzureDevOpsPullRequest, action: AzureDevOpsActionPreset) => void; }) { if (loading || error || items.length === 0) return ; @@ -124,16 +208,25 @@ export function AzureDevOpsPullRequestResults({ Feedback - + {quickActions.length > 0 && onQuickAction ? ( + + ) : ( + + )} + TaskCreateDialog: ({ + initialValues, + onSuccess, + }: { + initialValues?: { description?: string }; + onSuccess?: (task: { id: string }) => void; + }) => ( + <> +

{initialValues?.description}

+ + ), })); import { AzureDevOpsTaskLauncher } from "./azure-devops-task-launcher"; const timestamp = "2026-07-18T00:00:00Z"; +const workspaceId = "workspace-1"; +const createTaskButtonName = "Create task"; const workflow: Workflow = { id: "workflow-1" as Workflow["id"], - workspace_id: "workspace-1" as Workflow["workspace_id"], + workspace_id: workspaceId as Workflow["workspace_id"], name: "Review", created_at: timestamp, updated_at: timestamp, @@ -57,7 +80,7 @@ const step: WorkflowStep = { }; const repository: Repository = { id: "repo-1" as Repository["id"], - workspace_id: "workspace-1" as Repository["workspace_id"], + workspace_id: workspaceId as Repository["workspace_id"], name: "app", source_type: "git", local_path: "/repo", @@ -94,7 +117,7 @@ const pullRequest = { function renderLauncher() { return render( { afterEach(cleanup); -describe("AzureDevOpsTaskLauncher", () => { - it("updates the cache and store after associating the created task", async () => { - const linked = { id: "link-1" }; - mocks.associate.mockResolvedValue(linked); - renderLauncher(); +it("updates the cache and store after associating the created task", async () => { + const linked = { id: "link-1" }; + mocks.associate.mockResolvedValue(linked); + renderLauncher(); - fireEvent.click(screen.getByRole("button", { name: "Create task" })); + fireEvent.click(screen.getByRole("button", { name: createTaskButtonName })); - await waitFor(() => expect(mocks.cache).toHaveBeenCalledWith("workspace-1", "task-1", linked)); - expect(mocks.setTaskPullRequest).toHaveBeenCalledWith("task-1", linked); - expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); - }); - - it("reports a pull request association failure", async () => { - mocks.associate.mockRejectedValue(new Error("Azure association failed")); - renderLauncher(); - - fireEvent.click(screen.getByRole("button", { name: "Create task" })); - - await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith("Azure association failed")); - expect(mocks.close).toHaveBeenCalled(); - expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); - }); + await waitFor(() => expect(mocks.cache).toHaveBeenCalledWith(workspaceId, "task-1", linked)); + expect(mocks.setTaskPullRequest).toHaveBeenCalledWith("task-1", linked); + expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); +}); + +it("reports a pull request association failure", async () => { + mocks.associate.mockRejectedValue(new Error("Azure association failed")); + renderLauncher(); + + fireEvent.click(screen.getByRole("button", { name: createTaskButtonName })); + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith("Azure association failed")); + expect(mocks.close).toHaveBeenCalled(); + expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); +}); + +it("associates and caches a created task for a work item", async () => { + const linked = { id: "work-item-link" }; + mocks.associateWorkItem.mockResolvedValue(linked); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: createTaskButtonName })); + + await waitFor(() => + expect(mocks.associateWorkItem).toHaveBeenCalledWith(workspaceId, "task-1", { + projectId: "project-1", + workItemId: 73, + }), + ); + expect(mocks.cacheWorkItem).toHaveBeenCalledWith(workspaceId, "task-1", linked); + expect(mocks.setTaskWorkItem).toHaveBeenCalledWith("task-1", linked); +}); + +it("uses the generated description when an action prompt is blank", () => { + render( + , + ); + + expect(screen.getByTestId("task-description").textContent).toContain( + "Azure DevOps work item: 73", + ); +}); + +it("reports a missing work-item project after creating the task", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: createTaskButtonName })); + + await waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith( + "Failed to link Azure DevOps work item: project is missing.", + ), + ); + expect(mocks.associateWorkItem).not.toHaveBeenCalled(); }); diff --git a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx index 21b815569d..c454a71c16 100644 --- a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx +++ b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx @@ -5,15 +5,23 @@ import { toast } from "sonner"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { useAppStore } from "@/components/state-provider"; import { cacheAzureDevOpsTaskPullRequest } from "@/hooks/domains/azure-devops/use-azure-devops-task-pull-requests"; +import { cacheAzureDevOpsTaskWorkItem } from "@/hooks/domains/azure-devops/use-azure-devops-task-work-items"; import { useRouter } from "@/lib/routing/client-router"; -import { associateAzureDevOpsPullRequest } from "@/lib/api/domains/azure-devops-api"; -import type { AzureDevOpsPullRequest, AzureDevOpsWorkItem } from "@/lib/types/azure-devops"; +import { + associateAzureDevOpsPullRequest, + associateAzureDevOpsWorkItem, +} from "@/lib/api/domains/azure-devops-api"; +import type { + AzureDevOpsActionPreset, + AzureDevOpsPullRequest, + AzureDevOpsWorkItem, +} from "@/lib/types/azure-devops"; import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http"; import { truncateRemoteTaskTitle } from "@/lib/task-title"; export type AzureDevOpsLaunchPayload = - | { kind: "work-item"; item: AzureDevOpsWorkItem } - | { kind: "pull-request"; pullRequest: AzureDevOpsPullRequest }; + | { kind: "work-item"; item: AzureDevOpsWorkItem; action?: AzureDevOpsActionPreset } + | { kind: "pull-request"; pullRequest: AzureDevOpsPullRequest; action?: AzureDevOpsActionPreset }; function plainText(value: string | undefined): string { if (!value) return "(no description)"; @@ -24,31 +32,61 @@ function plainText(value: string | undefined): string { function launchText(payload: AzureDevOpsLaunchPayload) { if (payload.kind === "work-item") { const item = payload.item; + const description = [ + `Azure DevOps work item: ${item.id}`, + `URL: ${item.webUrl ?? item.apiUrl ?? ""}`, + "", + item.title, + "", + plainText(item.description), + ].join("\n"); return { - title: truncateRemoteTaskTitle(`${item.type} ${item.id}: ${item.title}`), - description: [ - `Azure DevOps work item: ${item.id}`, - `URL: ${item.webUrl ?? item.apiUrl ?? ""}`, - "", + title: truncateRemoteTaskTitle( + payload.action + ? `${payload.action.label}: ${item.title}` + : `${item.type} ${item.id}: ${item.title}`, + ), + description: actionDescription( + payload.action, + item.webUrl ?? item.apiUrl ?? "", item.title, - "", - plainText(item.description), - ].join("\n"), + description, + ), }; } const pullRequest = payload.pullRequest; + const description = [ + `Azure DevOps pull request: ${pullRequest.webUrl}`, + "", + pullRequest.title, + "", + plainText(pullRequest.description), + ].join("\n"); return { - title: truncateRemoteTaskTitle(`Review PR ${pullRequest.id}: ${pullRequest.title}`), - description: [ - `Azure DevOps pull request: ${pullRequest.webUrl}`, - "", + title: truncateRemoteTaskTitle( + payload.action + ? `${payload.action.label}: ${pullRequest.title}` + : `Review PR ${pullRequest.id}: ${pullRequest.title}`, + ), + description: actionDescription( + payload.action, + pullRequest.webUrl, pullRequest.title, - "", - plainText(pullRequest.description), - ].join("\n"), + description, + ), }; } +function actionDescription( + action: AzureDevOpsActionPreset | undefined, + url: string, + title: string, + fallback: string, +): string { + if (!action?.promptTemplate.trim()) return fallback; + return action.promptTemplate.replaceAll("{{url}}", url).replaceAll("{{title}}", title); +} + function matchingRepository( payload: AzureDevOpsLaunchPayload, repositories: Repository[], @@ -79,6 +117,7 @@ export function AzureDevOpsTaskLauncher({ }) { const router = useRouter(); const setTaskPullRequest = useAppStore((state) => state.setAzureDevOpsTaskPullRequest); + const setTaskWorkItem = useAppStore((state) => state.setAzureDevOpsTaskWorkItem); const launch = useMemo(() => { if (!payload) return null; const workflow = workflows.find((candidate) => @@ -96,6 +135,24 @@ export function AzureDevOpsTaskLauncher({ }, [payload, repositories, steps, workflows]); const onSuccess = async (task: Task) => { + if (payload?.kind === "work-item" && workspaceId) { + if (!payload.item.project) { + toast.error("Failed to link Azure DevOps work item: project is missing."); + } else { + try { + const linked = await associateAzureDevOpsWorkItem(workspaceId, task.id, { + projectId: payload.item.project, + workItemId: payload.item.id, + }); + cacheAzureDevOpsTaskWorkItem(workspaceId, task.id, linked); + setTaskWorkItem(task.id, linked); + } catch (error: unknown) { + toast.error( + error instanceof Error ? error.message : "Failed to link Azure DevOps work item.", + ); + } + } + } if (payload?.kind === "pull-request" && workspaceId && launch?.repository) { try { const linked = await associateAzureDevOpsPullRequest(workspaceId, task.id, { diff --git a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx new file mode 100644 index 0000000000..2c297d4f20 --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx @@ -0,0 +1,642 @@ +/* eslint-disable max-lines, sonarjs/no-duplicate-string -- watcher settings intentionally co-locate both Azure watch forms and actions. */ + +"use client"; + +import { useState } from "react"; +import { IconPlayerPlay, IconPlus, IconRefresh, IconTrash, IconX } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useAzureDevOpsWatches } from "@/hooks/domains/azure-devops/use-azure-devops-watches"; +import type { + AzureDevOpsCleanupPolicy, + AzureDevOpsPullRequestWatch, + AzureDevOpsPullRequestWatchInput, + AzureDevOpsWorkItemWatch, + AzureDevOpsWorkItemWatchInput, +} from "@/lib/types/azure-devops"; + +type Kind = "work-item" | "pull-request"; + +function formatLastChecked(value?: string | null): string { + if (!value) return "Not checked yet"; + const date = new Date(value); + return Number.isNaN(date.valueOf()) ? "Not checked yet" : `checked ${date.toLocaleString()}`; +} + +const emptyWorkItem: AzureDevOpsWorkItemWatchInput = { + workflowId: "", + workflowStepId: "", + projectId: "", + wiql: "", + repositoryId: "", + baseBranch: "", + agentProfileId: "", + executorProfileId: "", + prompt: "Implement {{work_item.title}}", + pollIntervalSeconds: 300, + cleanupPolicy: "auto", + maxInflightTasks: undefined, +}; +const emptyPullRequest: AzureDevOpsPullRequestWatchInput = { + workflowId: "", + workflowStepId: "", + projectId: "", + azureRepositoryId: "", + status: "active", + creatorId: "", + reviewerId: "", + repositoryId: "", + baseBranch: "", + agentProfileId: "", + executorProfileId: "", + prompt: "Review {{pull_request.title}}", + pollIntervalSeconds: 300, + cleanupPolicy: "auto", + maxInflightTasks: undefined, +}; + +function WatchActions({ + kind, + watch, + onEdit, + onToggle, + onTrigger, + onReset, + onDelete, +}: { + kind: Kind; + watch: AzureDevOpsWorkItemWatch | AzureDevOpsPullRequestWatch; + onEdit: () => void; + onToggle: () => void; + onTrigger: () => void; + onReset: () => void; + onDelete: () => void; +}) { + return ( +
+ + + + + +
+ ); +} + +function WatchCard({ + kind, + watch, + onEdit, + onToggle, + onTrigger, + onReset, + onDelete, +}: { + kind: Kind; + watch: AzureDevOpsWorkItemWatch | AzureDevOpsPullRequestWatch; + onEdit: () => void; + onToggle: () => void; + onTrigger: () => void; + onReset: () => void; + onDelete: () => void; +}) { + return ( + + + + {kind === "work-item" ? "Work-item query" : "Pull-request filter"} + + {watch.enabled ? "Enabled" : "Disabled"} + + +
+ {watch.projectId} · every {watch.pollIntervalSeconds}s · cleanup {watch.cleanupPolicy} +
+
{formatLastChecked(watch.lastPolledAt)}
+
+ + {kind === "work-item" && ( +
+            {(watch as AzureDevOpsWorkItemWatch).wiql}
+          
+ )} + {kind === "pull-request" && ( +
+ Status: {(watch as AzureDevOpsPullRequestWatch).status || "any"} +
+ )} + {watch.lastError && ( + + {watch.lastError} + + )} + +
+
+ ); +} + +// eslint-disable-next-line max-lines-per-function, complexity -- the editor renders the shared responsive form for both watch kinds. +function WatchEditor({ + kind, + open, + onOpenChange, + initial, + editing, + onSubmit, +}: { + kind: Kind; + open: boolean; + onOpenChange: (open: boolean) => void; + initial?: AzureDevOpsWorkItemWatchInput | AzureDevOpsPullRequestWatchInput; + editing: boolean; + onSubmit: ( + input: AzureDevOpsWorkItemWatchInput | AzureDevOpsPullRequestWatchInput, + ) => Promise; +}) { + const { isMobile } = useResponsiveBreakpoint(); + const [workItem, setWorkItem] = useState( + kind === "work-item" && initial ? (initial as AzureDevOpsWorkItemWatchInput) : emptyWorkItem, + ); + const [pullRequest, setPullRequest] = useState( + kind === "pull-request" && initial + ? (initial as AzureDevOpsPullRequestWatchInput) + : emptyPullRequest, + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const current = kind === "work-item" ? workItem : pullRequest; + const set = (key: string, value: string | number) => { + if (kind === "work-item") setWorkItem((previous) => ({ ...previous, [key]: value })); + else setPullRequest((previous) => ({ ...previous, [key]: value })); + }; + const submit = async () => { + if ( + !current.projectId || + !current.repositoryId || + !current.workflowId || + !current.workflowStepId || + !current.agentProfileId || + !current.executorProfileId || + (kind === "work-item" && !workItem.wiql) + ) { + setError( + kind === "work-item" + ? "Project, WIQL, repository, workflow, step, agent, and executor are required." + : "Project, repository, workflow, step, agent, and executor are required.", + ); + return; + } + setSaving(true); + setError(null); + try { + await onSubmit(current); + onOpenChange(false); + } catch (cause) { + setError(String(cause)); + } finally { + setSaving(false); + } + }; + let submitLabel = editing ? "Update watch" : "Create watch"; + if (saving) submitLabel = "Saving…"; + const watchLabel = kind === "work-item" ? "work-item" : "pull-request"; + const editorTitle = `${editing ? "Edit" : "New"} ${watchLabel} watch`; + const body = ( +
+ {error && ( + + {error} + + )} +
+
+ + set("projectId", event.target.value)} + data-testid={`azure-${kind}-watch-project`} + /> +
+
+ + set("pollIntervalSeconds", Number(event.target.value))} + /> +
+
+ {kind === "work-item" ? ( +
+ +