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
+
+ }
+ >
+
+
+
+
+
+
+ );
+}
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}
+
+ )}
+
+
+
+
+ );
+}
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 ? (
+
+ ) : (
+
+ )}