diff --git a/apps/backend/internal/azuredevops/client.go b/apps/backend/internal/azuredevops/client.go new file mode 100644 index 0000000000..8046bbe218 --- /dev/null +++ b/apps/backend/internal/azuredevops/client.go @@ -0,0 +1,70 @@ +package azuredevops + +import ( + "context" + "errors" +) + +// ErrNotConfigured is returned when a workspace has no complete Azure DevOps +// connection. +var ErrNotConfigured = errors.New("azure devops: workspace not configured") + +// Client is the Azure DevOps read surface consumed by the integration service. +type Client interface { + TestAuth(ctx context.Context) (*TestConnectionResult, error) + ListProjects(ctx context.Context) ([]Project, error) + ListRepositories(ctx context.Context, projectID string) ([]Repository, error) + QueryWIQL(ctx context.Context, projectID, wiql string, top int) (*WorkItemSearchResult, error) + GetWorkItem(ctx context.Context, projectID string, id int) (*WorkItem, error) + ListPullRequests(ctx context.Context, filter PullRequestFilter) (*PullRequestPage, error) + GetPullRequest(ctx context.Context, projectID, repositoryID string, id int) (*PullRequest, error) + ListReviewers(ctx context.Context, projectID, repositoryID string, id int) ([]Reviewer, error) + ListThreads(ctx context.Context, projectID, repositoryID string, id int) ([]Thread, error) + ListLinkedWorkItems(ctx context.Context, projectID, repositoryID string, id int) ([]WorkItemRef, error) + ListPolicyEvaluations(ctx context.Context, projectID string, id int) ([]PolicyEvaluation, error) +} + +// ClientFactory constructs a client from one workspace's configuration and +// PAT. Tests inject a deterministic fake at this boundary. +type ClientFactory func(cfg *Config, pat string) Client + +// DefaultClientFactory constructs the direct REST implementation. No Azure or +// GitHub CLI is consulted. +func DefaultClientFactory(cfg *Config, pat string) Client { + if cfg == nil { + return &invalidClient{err: errors.New("azure devops: config is required")} + } + return NewRESTClient(cfg.OrganizationURL, pat, nil) +} + +type invalidClient struct{ err error } + +func (c *invalidClient) TestAuth(context.Context) (*TestConnectionResult, error) { return nil, c.err } +func (c *invalidClient) ListProjects(context.Context) ([]Project, error) { return nil, c.err } +func (c *invalidClient) ListRepositories(context.Context, string) ([]Repository, error) { + return nil, c.err +} +func (c *invalidClient) QueryWIQL(context.Context, string, string, int) (*WorkItemSearchResult, error) { + return nil, c.err +} +func (c *invalidClient) GetWorkItem(context.Context, string, int) (*WorkItem, error) { + return nil, c.err +} +func (c *invalidClient) ListPullRequests(context.Context, PullRequestFilter) (*PullRequestPage, error) { + return nil, c.err +} +func (c *invalidClient) GetPullRequest(context.Context, string, string, int) (*PullRequest, error) { + return nil, c.err +} +func (c *invalidClient) ListReviewers(context.Context, string, string, int) ([]Reviewer, error) { + return nil, c.err +} +func (c *invalidClient) ListThreads(context.Context, string, string, int) ([]Thread, error) { + return nil, c.err +} +func (c *invalidClient) ListLinkedWorkItems(context.Context, string, string, int) ([]WorkItemRef, error) { + return nil, c.err +} +func (c *invalidClient) ListPolicyEvaluations(context.Context, string, int) ([]PolicyEvaluation, error) { + return nil, c.err +} diff --git a/apps/backend/internal/azuredevops/client_models.go b/apps/backend/internal/azuredevops/client_models.go new file mode 100644 index 0000000000..77b64fa578 --- /dev/null +++ b/apps/backend/internal/azuredevops/client_models.go @@ -0,0 +1,149 @@ +package azuredevops + +import ( + "encoding/json" + "time" +) + +// Project is an Azure DevOps project visible to the authenticated user. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` +} + +// Repository is an Azure Repos Git repository. +type Repository struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + DefaultBranch string `json:"defaultBranch"` + WebURL string `json:"webUrl"` +} + +type Branch struct { + Name string `json:"name"` +} + +// Identity is the stable subset shared by Azure authors, reviewers, and +// comment authors. +type Identity struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + UniqueName string `json:"uniqueName,omitempty"` +} + +// WorkItem is a hydrated Azure Boards work item. Fields retains provider +// extensions while the named fields drive the Kandev browse UI. +type WorkItem struct { + ID int `json:"id"` + Revision int `json:"revision"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + State string `json:"state"` + Type string `json:"type"` + Project string `json:"project,omitempty"` + AreaPath string `json:"areaPath,omitempty"` + AssignedTo string `json:"assignedTo,omitempty"` + WebURL string `json:"webUrl,omitempty"` + APIURL string `json:"apiUrl,omitempty"` + Fields map[string]any `json:"fields,omitempty"` +} + +type WorkItemSearchResult struct { + Items []WorkItem `json:"items"` + Count int `json:"count"` +} + +type PullRequestFilter struct { + ProjectID string + RepositoryID string + Status string + CreatorID string + ReviewerID string + SourceBranch string + TargetBranch string + Skip int + Top int +} + +// PullRequest is the Azure-native PR summary used by list and task-link flows. +type PullRequest struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + IsDraft bool `json:"isDraft"` + SourceBranch string `json:"sourceBranch"` + TargetBranch string `json:"targetBranch"` + MergeStatus string `json:"mergeStatus,omitempty"` + CreationDate *time.Time `json:"creationDate,omitempty"` + ClosedDate *time.Time `json:"closedDate,omitempty"` + Author Identity `json:"author"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + RepositoryID string `json:"repositoryId"` + RepositoryName string `json:"repositoryName"` + WebURL string `json:"webUrl"` + APIURL string `json:"apiUrl"` +} + +type PullRequestPage struct { + Items []PullRequest `json:"items"` + Count int `json:"count"` + Skip int `json:"skip"` + Top int `json:"top"` +} + +type Reviewer struct { + Identity + Vote int `json:"vote"` + IsRequired bool `json:"isRequired"` + HasDeclined bool `json:"hasDeclined"` +} + +type Comment struct { + ID int `json:"id"` + Content string `json:"content"` + Author Identity `json:"author"` + CommentType string `json:"commentType"` + PublishedAt time.Time `json:"publishedAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +// UnmarshalJSON translates Azure's wire timestamp names while preserving +// Kandev's public publishedAt/updatedAt response contract. +func (c *Comment) UnmarshalJSON(data []byte) error { + type commentAlias Comment + var wire struct { + commentAlias + PublishedDate time.Time `json:"publishedDate"` + LastUpdatedDate time.Time `json:"lastUpdatedDate"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + *c = Comment(wire.commentAlias) + c.PublishedAt = wire.PublishedDate + c.UpdatedAt = wire.LastUpdatedDate + return nil +} + +type Thread struct { + ID int `json:"id"` + Status string `json:"status"` + Comments []Comment `json:"comments"` +} + +type WorkItemRef struct { + ID int `json:"id"` + URL string `json:"url"` +} + +type PolicyEvaluation struct { + ID string `json:"id"` + Status string `json:"status"` + Name string `json:"name"` + IsBlocking bool `json:"isBlocking"` +} diff --git a/apps/backend/internal/azuredevops/controller.go b/apps/backend/internal/azuredevops/controller.go new file mode 100644 index 0000000000..88af2db175 --- /dev/null +++ b/apps/backend/internal/azuredevops/controller.go @@ -0,0 +1,330 @@ +package azuredevops + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" +) + +const notConfiguredCode = "azure_devops_not_configured" + +type Controller struct { + service *Service + log *logger.Logger +} + +func RegisterRoutes(router *gin.Engine, service *Service, log *logger.Logger) { + controller := &Controller{service: service, log: log} + api := router.Group("/api/v1/azure-devops") + api.GET("/config", controller.getConfig) + api.POST("/config", controller.setConfig) + api.DELETE("/config", controller.deleteConfig) + api.POST("/config/test", controller.testConfig) + api.POST("/config/copy", controller.copyConfig) + api.GET("/views", controller.getSavedViews) + api.PUT("/views", controller.setSavedViews) + api.GET("/projects", controller.listProjects) + api.GET("/repositories", controller.listRepositories) + api.GET("/branches", controller.listBranches) + api.POST("/work-items/search", controller.searchWorkItems) + api.GET("/work-items/:id", controller.getWorkItem) + 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) + api.GET("/workspaces/:workspaceId/task-prs", controller.listWorkspaceTaskPRs) + api.POST("/tasks/:taskId/pull-requests", controller.associateTaskPR) + api.POST("/tasks/:taskId/pull-requests/sync", controller.syncTaskPR) +} + +func (c *Controller) getSavedViews(ctx *gin.Context) { + views, err := c.service.GetSavedViewsForWorkspace(ctx.Request.Context(), workspaceID(ctx)) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"views": views}) +} + +func (c *Controller) setSavedViews(ctx *gin.Context) { + var request struct { + Views []SavedView `json:"views"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + views, err := c.service.SetSavedViewsForWorkspace( + ctx.Request.Context(), workspaceID(ctx), request.Views, + ) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"views": views}) +} + +func (c *Controller) getConfig(ctx *gin.Context) { + cfg, err := c.service.GetConfigForWorkspace(ctx.Request.Context(), workspaceID(ctx)) + if err != nil { + c.writeError(ctx, err) + return + } + if cfg == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) setConfig(ctx *gin.Context) { + var request SetConfigRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + cfg, err := c.service.SetConfigForWorkspace(ctx.Request.Context(), workspaceID(ctx), &request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) deleteConfig(ctx *gin.Context) { + if err := c.service.DeleteConfigForWorkspace(ctx.Request.Context(), workspaceID(ctx)); err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) testConfig(ctx *gin.Context) { + var request SetConfigRequest + if ctx.Request.ContentLength != 0 { + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + } + result, err := c.service.TestConnectionForWorkspace(ctx.Request.Context(), workspaceID(ctx), &request) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) copyConfig(ctx *gin.Context) { + var request struct { + TargetWorkspaceID string `json:"targetWorkspaceId"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), workspaceID(ctx), request.TargetWorkspaceID) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) listProjects(ctx *gin.Context) { + projects, err := c.service.ListProjectsForWorkspace(ctx.Request.Context(), workspaceID(ctx)) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"projects": projects}) +} + +func (c *Controller) listRepositories(ctx *gin.Context) { + repositories, err := c.service.ListRepositoriesForWorkspace( + ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), + ) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"repositories": repositories}) +} + +func (c *Controller) listBranches(ctx *gin.Context) { + branches, err := c.service.ListBranchesForWorkspace( + ctx.Request.Context(), workspaceID(ctx), ctx.Query("organization"), ctx.Query("project"), + ctx.Query("repository"), + ) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"branches": branches}) +} + +func (c *Controller) searchWorkItems(ctx *gin.Context) { + var request struct { + Project string `json:"project"` + WIQL string `json:"wiql"` + Top int `json:"top"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + result, err := c.service.SearchWorkItemsForWorkspace( + ctx.Request.Context(), workspaceID(ctx), request.Project, request.WIQL, request.Top, + ) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) getWorkItem(ctx *gin.Context) { + id, err := strconv.Atoi(ctx.Param("id")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"}) + return + } + item, err := c.service.GetWorkItemForWorkspace( + ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), id, + ) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, item) +} + +func (c *Controller) listPullRequests(ctx *gin.Context) { + skip, _ := strconv.Atoi(ctx.Query("skip")) + top, _ := strconv.Atoi(ctx.Query("top")) + result, err := c.service.ListPullRequestsForWorkspace(ctx.Request.Context(), workspaceID(ctx), PullRequestFilter{ + ProjectID: ctx.Query("project"), RepositoryID: ctx.Query("repository"), Status: ctx.Query("status"), + CreatorID: ctx.Query("creator"), ReviewerID: ctx.Query("reviewer"), + SourceBranch: ctx.Query("source_branch"), TargetBranch: ctx.Query("target_branch"), Skip: skip, Top: top, + }) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) getPullRequest(ctx *gin.Context) { + id, ok := pullRequestID(ctx) + if !ok { + return + } + pr, err := c.service.GetPullRequestForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Param("projectId"), ctx.Param("repositoryId"), id) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, pr) +} + +func (c *Controller) getPullRequestFeedback(ctx *gin.Context) { + id, ok := pullRequestID(ctx) + if !ok { + return + } + feedback, err := c.service.GetPullRequestFeedbackForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Param("projectId"), ctx.Param("repositoryId"), id) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, feedback) +} + +type taskPRRequest struct { + RepositoryID string `json:"repositoryId" binding:"required"` + PullRequestID int `json:"pullRequestId" binding:"required"` +} + +func (c *Controller) listWorkspaceTaskPRs(ctx *gin.Context) { + rows, err := c.service.ListTaskPRsByWorkspace(ctx.Request.Context(), ctx.Param("workspaceId")) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, TaskPRsResponse{TaskPRs: rows}) +} + +func (c *Controller) associateTaskPR(ctx *gin.Context) { + c.writeTaskPRSync(ctx, c.service.AssociateTaskPR) +} + +func (c *Controller) syncTaskPR(ctx *gin.Context) { + c.writeTaskPRSync(ctx, c.service.SyncTaskPR) +} + +func (c *Controller) writeTaskPRSync( + ctx *gin.Context, + sync func(context.Context, string, string, string, int) (*TaskPR, error), +) { + var request taskPRRequest + if err := ctx.ShouldBindJSON(&request); err != nil || request.PullRequestID <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "repositoryId and positive pullRequestId are required"}) + return + } + row, err := sync( + ctx.Request.Context(), workspaceID(ctx), ctx.Param("taskId"), + request.RepositoryID, request.PullRequestID, + ) + if err != nil { + c.writeError(ctx, err) + return + } + ctx.JSON(http.StatusOK, row) +} + +func (c *Controller) writeError(ctx *gin.Context, err error) { + status := http.StatusInternalServerError + code := "" + switch { + case errors.Is(err, ErrInvalidWorkspaceID), errors.Is(err, ErrInvalidConfig), + errors.Is(err, ErrInvalidTaskPRAssociation): + status = http.StatusBadRequest + case errors.Is(err, ErrNotConfigured): + status, code = http.StatusServiceUnavailable, notConfiguredCode + default: + var apiErr *APIError + if errors.As(err, &apiErr) { + status = upstreamStatus(apiErr.StatusCode) + } + } + body := gin.H{"error": err.Error()} + if code != "" { + body["code"] = code + } + ctx.JSON(status, body) +} + +func workspaceID(ctx *gin.Context) string { return strings.TrimSpace(ctx.Query("workspace_id")) } +func pullRequestID(ctx *gin.Context) (int, bool) { + id, err := strconv.Atoi(ctx.Param("pullRequestId")) + if err != nil || id <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid pull request id"}) + return 0, false + } + return id, true +} +func upstreamStatus(status int) int { + switch status { + case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests: + return status + default: + if status >= 500 { + return http.StatusBadGateway + } + return http.StatusBadRequest + } +} diff --git a/apps/backend/internal/azuredevops/controller_test.go b/apps/backend/internal/azuredevops/controller_test.go new file mode 100644 index 0000000000..14c031569f --- /dev/null +++ b/apps/backend/internal/azuredevops/controller_test.go @@ -0,0 +1,240 @@ +package azuredevops + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" +) + +type fakeReadClient struct { + invalidClient + projects []Project + work *WorkItemSearchResult + pr *PullRequest + reviewers []Reviewer + threads []Thread + refs []WorkItemRef + policies []PolicyEvaluation + lastFilter PullRequestFilter +} + +func (f *fakeReadClient) ListPullRequests(_ context.Context, filter PullRequestFilter) (*PullRequestPage, error) { + f.lastFilter = filter + 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) QueryWIQL(context.Context, string, string, int) (*WorkItemSearchResult, error) { + return f.work, nil +} +func (f *fakeReadClient) GetPullRequest(context.Context, string, string, int) (*PullRequest, error) { + return f.pr, nil +} +func (f *fakeReadClient) ListReviewers(context.Context, string, string, int) ([]Reviewer, error) { + return f.reviewers, nil +} +func (f *fakeReadClient) ListThreads(context.Context, string, string, int) ([]Thread, error) { + return f.threads, nil +} +func (f *fakeReadClient) ListLinkedWorkItems(context.Context, string, string, int) ([]WorkItemRef, error) { + return f.refs, nil +} +func (f *fakeReadClient) ListPolicyEvaluations(context.Context, string, int) ([]PolicyEvaluation, error) { + return f.policies, nil +} + +func newControllerFixture(t *testing.T) (*gin.Engine, *Service) { + t.Helper() + gin.SetMode(gin.TestMode) + db := newTestDB(t) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + client := &fakeReadClient{ + projects: []Project{{ID: "project-1", Name: "Platform"}}, + work: &WorkItemSearchResult{Items: []WorkItem{{ID: 101, Title: "Fix build"}}, Count: 1}, + pr: &PullRequest{ID: 42, Title: "Ship it", ProjectID: "project-1", RepositoryID: "repo-1"}, + reviewers: []Reviewer{{Identity: Identity{ID: "u2", DisplayName: "Grace"}, Vote: 10, IsRequired: true}}, + threads: []Thread{{ID: 7, Status: "active"}}, + refs: []WorkItemRef{{ID: 101}}, + policies: []PolicyEvaluation{{ID: "p1", Status: "approved", IsBlocking: true}}, + } + service := NewService(store, newFakeSecretStore(), func(*Config, string) Client { return client }, logger.Default()) + 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) + } + router := gin.New() + RegisterRoutes(router, service, logger.Default()) + return router, service +} + +func TestControllerConfigAndWorkspaceScopedReads(t *testing.T) { + router, _ := newControllerFixture(t) + + config := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/config?workspace_id=ws-1", nil) + if config.Code != http.StatusOK || bytes.Contains(config.Body.Bytes(), []byte("\"pat\":")) { + t.Fatalf("config response %d: %s", config.Code, config.Body.String()) + } + projects := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/projects?workspace_id=ws-1", nil) + if projects.Code != http.StatusOK || !bytes.Contains(projects.Body.Bytes(), []byte("Platform")) { + t.Fatalf("projects response %d: %s", projects.Code, projects.Body.String()) + } + search := performRequest(t, router, http.MethodPost, "/api/v1/azure-devops/work-items/search?workspace_id=ws-1", map[string]any{ + "project": "project-1", "wiql": "SELECT [System.Id] FROM WorkItems", "top": 20, + }) + if search.Code != http.StatusOK || !bytes.Contains(search.Body.Bytes(), []byte("Fix build")) { + t.Fatalf("search response %d: %s", search.Code, search.Body.String()) + } +} + +func TestControllerPersistsWorkspaceSavedViews(t *testing.T) { + router, _ := newControllerFixture(t) + path := "/api/v1/azure-devops/views?workspace_id=ws-1" + created := performRequest(t, router, http.MethodPut, path, map[string]any{ + "views": []map[string]any{{ + "id": "mine", "kind": "work_item", "label": "Assigned to me", + "projectId": "project-1", "wiql": "SELECT [System.Id] FROM WorkItems", "top": 50, + }}, + }) + if created.Code != http.StatusOK || !bytes.Contains(created.Body.Bytes(), []byte("Assigned to me")) { + t.Fatalf("save views response %d: %s", created.Code, created.Body.String()) + } + loaded := performRequest(t, router, http.MethodGet, path, nil) + if loaded.Code != http.StatusOK || !bytes.Contains(loaded.Body.Bytes(), []byte("Assigned to me")) { + t.Fatalf("get views response %d: %s", loaded.Code, loaded.Body.String()) + } + invalid := performRequest(t, router, http.MethodPut, path, map[string]any{ + "views": []map[string]any{{ + "id": "bad", "kind": "pull_request", "label": "Bad", "projectId": "project-1", + }}, + }) + if invalid.Code != http.StatusBadRequest { + t.Fatalf("invalid views response %d: %s", invalid.Code, invalid.Body.String()) + } +} + +func TestControllerPullRequestFeedbackAggregation(t *testing.T) { + router, _ := newControllerFixture(t) + path := "/api/v1/azure-devops/pull-requests/project-1/repo-1/42/feedback?workspace_id=ws-1" + response := performRequest(t, router, http.MethodGet, path, nil) + if response.Code != http.StatusOK { + t.Fatalf("feedback response %d: %s", response.Code, response.Body.String()) + } + var feedback PullRequestFeedback + if err := json.Unmarshal(response.Body.Bytes(), &feedback); err != nil { + t.Fatalf("decode feedback: %v", err) + } + if feedback.PullRequest.ID != 42 || feedback.ReviewState != "approved" || feedback.PolicyState != "success" || len(feedback.Threads) != 1 { + t.Fatalf("feedback = %+v", feedback) + } +} + +func TestPullRequestSearchResolvesCurrentUserSentinel(t *testing.T) { + router, service := newControllerFixture(t) + client := service.clientFn(nil, "").(*fakeReadClient) + response := performRequest( + t, router, http.MethodGet, + "/api/v1/azure-devops/pull-requests?workspace_id=ws-1&project=project-1&repository=repo-1&reviewer=%40me", nil, + ) + if response.Code != http.StatusOK { + t.Fatalf("pull request response %d: %s", response.Code, response.Body.String()) + } + if client.lastFilter.ReviewerID != "me" { + t.Fatalf("reviewer filter = %q, want authenticated identity", client.lastFilter.ReviewerID) + } +} + +func TestControllerRejectsMissingAndUnconfiguredWorkspace(t *testing.T) { + router, _ := newControllerFixture(t) + missing := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/projects", nil) + if missing.Code != http.StatusBadRequest { + t.Fatalf("missing workspace status = %d: %s", missing.Code, missing.Body.String()) + } + unconfigured := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/projects?workspace_id=ws-2", nil) + if unconfigured.Code != http.StatusServiceUnavailable || !bytes.Contains(unconfigured.Body.Bytes(), []byte("azure_devops_not_configured")) { + t.Fatalf("unconfigured status = %d: %s", unconfigured.Code, unconfigured.Body.String()) + } +} + +func TestControllerTaskPullRequestAssociationRoutes(t *testing.T) { + router, service := newControllerFixture(t) + service.SetRepositoryLookup(fakeAzureRepositoryLookup{binding: &RepositoryBinding{ + WorkspaceID: "ws-1", Provider: RepositoryProvider, + ProviderOwner: "project-1", ProviderRepoID: "repo-1", + }}) + if _, err := service.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 := service.Store().db.Exec(`INSERT INTO tasks (id, workspace_id) VALUES ('task-1', 'ws-1')`); err != nil { + t.Fatalf("seed task: %v", err) + } + body := map[string]any{"repositoryId": "repository-1", "pullRequestId": 42} + associated := performRequest( + t, router, http.MethodPost, + "/api/v1/azure-devops/tasks/task-1/pull-requests?workspace_id=ws-1", body, + ) + if associated.Code != http.StatusOK || !bytes.Contains(associated.Body.Bytes(), []byte("Ship it")) { + t.Fatalf("associate response %d: %s", associated.Code, associated.Body.String()) + } + synced := performRequest( + t, router, http.MethodPost, + "/api/v1/azure-devops/tasks/task-1/pull-requests/sync?workspace_id=ws-1", body, + ) + if synced.Code != http.StatusOK { + t.Fatalf("sync response %d: %s", synced.Code, synced.Body.String()) + } + listed := performRequest( + t, router, http.MethodGet, + "/api/v1/azure-devops/workspaces/ws-1/task-prs", nil, + ) + if listed.Code != http.StatusOK || !bytes.Contains(listed.Body.Bytes(), []byte(`"taskPrs":{"task-1"`)) { + t.Fatalf("list response %d: %s", listed.Code, listed.Body.String()) + } +} + +func TestControllerTaskPullRequestAssociationRejectsInvalidInput(t *testing.T) { + router, service := newControllerFixture(t) + service.SetRepositoryLookup(fakeAzureRepositoryLookup{binding: &RepositoryBinding{ + WorkspaceID: "ws-1", Provider: "github", + ProviderOwner: "project-1", ProviderRepoID: "repo-1", + }}) + response := performRequest( + t, router, http.MethodPost, + "/api/v1/azure-devops/tasks/task-1/pull-requests?workspace_id=ws-1", + map[string]any{"repositoryId": "repository-1", "pullRequestId": 42}, + ) + if response.Code != http.StatusBadRequest { + t.Fatalf("invalid association response %d: %s", response.Code, response.Body.String()) + } +} + +func performRequest(t *testing.T, router http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var encoded []byte + if body != nil { + var err error + encoded, err = json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + } + req := httptest.NewRequest(method, path, bytes.NewReader(encoded)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response +} diff --git a/apps/backend/internal/azuredevops/copy.go b/apps/backend/internal/azuredevops/copy.go new file mode 100644 index 0000000000..3aa44f8738 --- /dev/null +++ b/apps/backend/internal/azuredevops/copy.go @@ -0,0 +1,55 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" +) + +var ( + ErrSameWorkspace = errors.New("azure devops: source and target workspaces are the same") + ErrNothingToCopy = errors.New("azure devops: source workspace has no configuration to copy") +) + +// CopyConfigToWorkspace copies connection settings and the PAT, but not health +// state, from one workspace to another. +func (s *Service) CopyConfigToWorkspace( + ctx context.Context, + sourceWorkspaceID string, + targetWorkspaceID string, +) (*Config, error) { + if err := validateWorkspaceID(sourceWorkspaceID); err != nil { + return nil, err + } + if err := validateWorkspaceID(targetWorkspaceID); err != nil { + return nil, err + } + if sourceWorkspaceID == targetWorkspaceID { + return nil, ErrSameWorkspace + } + source, err := s.store.GetConfig(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source azure devops config: %w", err) + } + if source == nil { + return nil, ErrNothingToCopy + } + pat, err := s.revealPAT(ctx, sourceWorkspaceID) + if err != nil { + return nil, fmt.Errorf("read source azure devops credential: %w", err) + } + _, err = s.SetConfigForWorkspace(ctx, targetWorkspaceID, &SetConfigRequest{ + OrganizationURL: source.OrganizationURL, + DefaultProjectID: source.DefaultProjectID, + DefaultProjectName: source.DefaultProjectName, + AuthMethod: source.AuthMethod, + PAT: pat, + }) + if err != nil { + return nil, err + } + if err := s.store.ResetAuthHealth(ctx, targetWorkspaceID); err != nil { + return nil, fmt.Errorf("reset target azure devops health: %w", err) + } + return s.GetConfigForWorkspace(ctx, targetWorkspaceID) +} diff --git a/apps/backend/internal/azuredevops/lifecycle.go b/apps/backend/internal/azuredevops/lifecycle.go new file mode 100644 index 0000000000..fceb9d3717 --- /dev/null +++ b/apps/backend/internal/azuredevops/lifecycle.go @@ -0,0 +1,86 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// LifecycleCleanup owns subscriptions that remove Azure DevOps data alongside +// the Kandev resources that own it. +type LifecycleCleanup struct { + subscriptions []bus.Subscription +} + +// RegisterLifecycleCleanup subscribes to task and workspace deletion events. +func RegisterLifecycleCleanup(eventBus bus.EventBus, service *Service) (*LifecycleCleanup, error) { + if eventBus == nil { + return nil, errors.New("azure devops lifecycle: event bus is required") + } + if service == nil || service.store == nil { + return nil, errors.New("azure devops lifecycle: service is required") + } + cleanup := &LifecycleCleanup{} + taskSub, err := eventBus.Subscribe(events.TaskDeleted, service.handleTaskDeleted) + if err != nil { + return nil, fmt.Errorf("subscribe to task deletion: %w", err) + } + cleanup.subscriptions = append(cleanup.subscriptions, taskSub) + workspaceSub, err := eventBus.Subscribe(events.WorkspaceDeleted, service.handleWorkspaceDeleted) + if err != nil { + _ = cleanup.Close() + return nil, fmt.Errorf("subscribe to workspace deletion: %w", err) + } + cleanup.subscriptions = append(cleanup.subscriptions, workspaceSub) + return cleanup, nil +} + +func (s *Service) handleTaskDeleted(ctx context.Context, event *bus.Event) error { + taskID := lifecycleResourceID(event, "task_id") + if taskID == "" { + return nil + } + if err := s.store.DeleteTaskPRsByTask(ctx, taskID); err != nil { + return fmt.Errorf("delete Azure DevOps task PRs for task %q: %w", taskID, err) + } + return nil +} + +func (s *Service) handleWorkspaceDeleted(ctx context.Context, event *bus.Event) error { + workspaceID := lifecycleResourceID(event, "id") + if workspaceID == "" { + return nil + } + if err := s.DeleteConfigForWorkspace(ctx, workspaceID); err != nil { + return fmt.Errorf("delete Azure DevOps config for workspace %q: %w", workspaceID, err) + } + return nil +} + +func lifecycleResourceID(event *bus.Event, key string) string { + if event == nil { + return "" + } + data, ok := event.Data.(map[string]interface{}) + if !ok { + return "" + } + id, _ := data[key].(string) + return id +} + +// Close releases every lifecycle subscription. +func (c *LifecycleCleanup) Close() error { + if c == nil { + return nil + } + var result error + for _, subscription := range c.subscriptions { + result = errors.Join(result, subscription.Unsubscribe()) + } + c.subscriptions = nil + return result +} diff --git a/apps/backend/internal/azuredevops/lifecycle_test.go b/apps/backend/internal/azuredevops/lifecycle_test.go new file mode 100644 index 0000000000..0d3b5efbaa --- /dev/null +++ b/apps/backend/internal/azuredevops/lifecycle_test.go @@ -0,0 +1,52 @@ +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 TestLifecycleCleanupRemovesTaskAndWorkspaceData(t *testing.T) { + service, store, secrets := newTestService(t, nil) + ctx := context.Background() + if _, err := service.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", + PAT: "pat", + }); err != nil { + t.Fatalf("set config: %v", err) + } + if err := store.UpsertTaskPR(ctx, testTaskPR("task-a", "repo-a", 42)); err != nil { + t.Fatalf("upsert task PR: %v", err) + } + + eventBus := bus.NewMemoryEventBus(logger.Default()) + cleanup, err := RegisterLifecycleCleanup(eventBus, service) + if err != nil { + t.Fatalf("register lifecycle cleanup: %v", err) + } + t.Cleanup(func() { _ = cleanup.Close() }) + + taskEvent := bus.NewEvent(events.TaskDeleted, "test", map[string]interface{}{"task_id": "task-a"}) + if err := eventBus.Publish(ctx, events.TaskDeleted, taskEvent); err != nil { + t.Fatalf("publish task deletion: %v", err) + } + rows, err := store.ListTaskPRsByTask(ctx, "task-a") + if err != nil || len(rows) != 0 { + t.Fatalf("task PR rows after deletion = %+v, err = %v", rows, err) + } + + workspaceEvent := bus.NewEvent(events.WorkspaceDeleted, "test", map[string]interface{}{"id": "ws-a"}) + if err := eventBus.Publish(ctx, events.WorkspaceDeleted, workspaceEvent); err != nil { + t.Fatalf("publish workspace deletion: %v", err) + } + config, err := store.GetConfig(ctx, "ws-a") + if err != nil || config != nil { + t.Fatalf("config after workspace deletion = %+v, err = %v", config, err) + } + if exists, err := secrets.Exists(ctx, SecretKeyForWorkspace("ws-a")); err != nil || exists { + t.Fatalf("secret exists after workspace deletion = %v, err = %v", exists, err) + } +} diff --git a/apps/backend/internal/azuredevops/mock_client.go b/apps/backend/internal/azuredevops/mock_client.go new file mode 100644 index 0000000000..60a93b8ed9 --- /dev/null +++ b/apps/backend/internal/azuredevops/mock_client.go @@ -0,0 +1,173 @@ +package azuredevops + +import ( + "context" + "fmt" + "strings" + "sync" +) + +// 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"` +} + +// MockClient implements Client with in-memory state for browser tests. +type MockClient struct { + mu sync.RWMutex + state MockState +} + +func NewMockClient() *MockClient { + client := &MockClient{} + client.Seed(MockState{Authenticated: true, User: TestConnectionResult{OK: true, ID: "mock-user", DisplayName: "Mock User"}}) + return client +} + +func (c *MockClient) Seed(state MockState) { + c.mu.Lock() + defer c.mu.Unlock() + if state.Feedback == nil { + state.Feedback = make(map[int]PullRequestFeedback) + } + c.state = state +} + +func (c *MockClient) snapshot() MockState { + c.mu.RLock() + defer c.mu.RUnlock() + return c.state +} + +func (c *MockClient) TestAuth(context.Context) (*TestConnectionResult, error) { + state := c.snapshot() + if !state.Authenticated { + return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil + } + result := state.User + result.OK = true + return &result, nil +} + +func (c *MockClient) ListProjects(context.Context) ([]Project, error) { + return c.snapshot().Projects, nil +} + +func (c *MockClient) ListRepositories(_ context.Context, projectID string) ([]Repository, error) { + state := c.snapshot() + items := make([]Repository, 0) + for _, repository := range state.Repositories { + if projectID == "" || repository.ProjectID == projectID { + items = append(items, repository) + } + } + return items, nil +} + +func (c *MockClient) ListBranches(_ context.Context, projectID, repositoryID string) ([]Branch, error) { + state := c.snapshot() + for _, repo := range state.Repositories { + if repo.ProjectID == projectID && repo.ID == repositoryID { + return []Branch{{Name: strings.TrimPrefix(repo.DefaultBranch, "refs/heads/")}}, nil + } + } + return []Branch{}, nil +} + +func (c *MockClient) QueryWIQL(_ context.Context, projectID, _ string, top int) (*WorkItemSearchResult, error) { + state := c.snapshot() + items := make([]WorkItem, 0) + for _, item := range state.WorkItems { + if projectID == "" || item.Project == "" || item.Project == projectID { + items = append(items, item) + } + if top > 0 && len(items) >= top { + break + } + } + return &WorkItemSearchResult{Items: items, Count: len(items)}, nil +} + +func (c *MockClient) GetWorkItem(_ context.Context, _ string, id int) (*WorkItem, error) { + for _, item := range c.snapshot().WorkItems { + if item.ID == id { + copy := item + return ©, nil + } + } + return nil, mockNotFound("work item", id) +} + +func (c *MockClient) ListPullRequests(_ context.Context, filter PullRequestFilter) (*PullRequestPage, error) { + items := make([]PullRequest, 0) + for _, pr := range c.snapshot().PullRequests { + if (filter.ProjectID == "" || pr.ProjectID == filter.ProjectID) && + (filter.RepositoryID == "" || pr.RepositoryID == filter.RepositoryID) && + (filter.Status == "" || pr.Status == filter.Status) { + items = append(items, pr) + } + } + return &PullRequestPage{Items: items, Count: len(items), Skip: filter.Skip, Top: filter.Top}, nil +} + +func (c *MockClient) GetPullRequest(_ context.Context, _, _ string, id int) (*PullRequest, error) { + for _, pr := range c.snapshot().PullRequests { + if pr.ID == id { + copy := pr + return ©, nil + } + } + feedback, ok := c.Feedback(id) + if ok && feedback.PullRequest != nil { + return feedback.PullRequest, nil + } + return nil, mockNotFound("pull request", id) +} + +func (c *MockClient) ListReviewers(_ context.Context, _, _ string, id int) ([]Reviewer, error) { + feedback, ok := c.Feedback(id) + if !ok { + return []Reviewer{}, nil + } + return feedback.Reviewers, nil +} + +func (c *MockClient) ListThreads(_ context.Context, _, _ string, id int) ([]Thread, error) { + feedback, ok := c.Feedback(id) + if !ok { + return []Thread{}, nil + } + return feedback.Threads, nil +} + +func (c *MockClient) ListLinkedWorkItems(_ context.Context, _, _ string, id int) ([]WorkItemRef, error) { + feedback, ok := c.Feedback(id) + if !ok { + return []WorkItemRef{}, nil + } + return feedback.LinkedWorkItems, nil +} + +func (c *MockClient) ListPolicyEvaluations(_ context.Context, _ string, id int) ([]PolicyEvaluation, error) { + feedback, ok := c.Feedback(id) + if !ok { + return []PolicyEvaluation{}, nil + } + return feedback.Policies, nil +} + +func (c *MockClient) Feedback(id int) (PullRequestFeedback, bool) { + state := c.snapshot() + feedback, ok := state.Feedback[id] + return feedback, ok +} + +func mockNotFound(kind string, id int) error { + return &APIError{StatusCode: 404, Endpoint: "mock", Body: fmt.Sprintf("%s %d not found", kind, id)} +} diff --git a/apps/backend/internal/azuredevops/mock_client_test.go b/apps/backend/internal/azuredevops/mock_client_test.go new file mode 100644 index 0000000000..7a84f074c0 --- /dev/null +++ b/apps/backend/internal/azuredevops/mock_client_test.go @@ -0,0 +1,30 @@ +package azuredevops + +import ( + "context" + "testing" +) + +func TestMockClientSeedsWorkspaceReadState(t *testing.T) { + mock := NewMockClient() + mock.Seed(MockState{ + Authenticated: true, + User: TestConnectionResult{OK: true, ID: "me", DisplayName: "Ada"}, + Projects: []Project{{ID: "p1", Name: "Platform"}}, + WorkItems: []WorkItem{{ID: 101, Title: "Fix build"}}, + PullRequests: []PullRequest{{ID: 42, ProjectID: "p1", RepositoryID: "r1", Title: "Ship it"}}, + Feedback: map[int]PullRequestFeedback{42: {ReviewState: "approved"}}, + }) + + auth, err := mock.TestAuth(context.Background()) + if err != nil || !auth.OK || auth.ID != "me" { + t.Fatalf("auth = %+v, %v", auth, err) + } + projects, _ := mock.ListProjects(context.Background()) + work, _ := mock.QueryWIQL(context.Background(), "p1", "SELECT", 20) + prs, _ := mock.ListPullRequests(context.Background(), PullRequestFilter{ProjectID: "p1", RepositoryID: "r1"}) + feedback, ok := mock.Feedback(42) + if len(projects) != 1 || len(work.Items) != 1 || len(prs.Items) != 1 || !ok || feedback.ReviewState != "approved" { + t.Fatalf("mock state projects=%+v work=%+v prs=%+v feedback=%+v", projects, work, prs, feedback) + } +} diff --git a/apps/backend/internal/azuredevops/mock_controller.go b/apps/backend/internal/azuredevops/mock_controller.go new file mode 100644 index 0000000000..780811b743 --- /dev/null +++ b/apps/backend/internal/azuredevops/mock_controller.go @@ -0,0 +1,30 @@ +package azuredevops + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" +) + +// RegisterMockRoutes mounts E2E controls only when the service owns a mock. +func RegisterMockRoutes(router *gin.Engine, service *Service, log *logger.Logger) { + if service == nil || service.MockClient() == nil { + return + } + api := router.Group("/api/v1/azure-devops/mock") + api.POST("/state", func(ctx *gin.Context) { + var state MockState + if err := ctx.ShouldBindJSON(&state); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + service.MockClient().Seed(state) + ctx.JSON(http.StatusOK, gin.H{"seeded": true}) + }) + api.DELETE("/state", func(ctx *gin.Context) { + service.MockClient().Seed(MockState{Authenticated: true, User: TestConnectionResult{OK: true, ID: "mock-user", DisplayName: "Mock User"}}) + ctx.JSON(http.StatusOK, gin.H{"reset": true}) + }) + log.Info("registered Azure DevOps mock control endpoints") +} diff --git a/apps/backend/internal/azuredevops/models.go b/apps/backend/internal/azuredevops/models.go new file mode 100644 index 0000000000..1dc8888063 --- /dev/null +++ b/apps/backend/internal/azuredevops/models.go @@ -0,0 +1,95 @@ +// Package azuredevops implements the Azure DevOps Services integration. +package azuredevops + +import "github.com/kandev/kandev/internal/integrations/cloneauth" + +import "time" + +const AuthMethodPAT = "pat" + +// Config is the workspace-scoped Azure DevOps connection configuration. The +// PAT is stored separately in the encrypted secret store. +type Config struct { + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + OrganizationURL string `json:"organizationUrl" db:"organization_url"` + DefaultProjectID string `json:"defaultProjectId,omitempty" db:"default_project_id"` + DefaultProjectName string `json:"defaultProjectName,omitempty" db:"default_project_name"` + AuthMethod string `json:"authMethod" db:"auth_method"` + HasSecret bool `json:"hasSecret" db:"-"` + LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"` + LastOK bool `json:"lastOk" db:"last_ok"` + LastError string `json:"lastError,omitempty" db:"last_error"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + SavedViewsJSON string `json:"-" db:"saved_views"` +} + +// SavedView is a workspace-scoped Azure browse query. It contains only +// provider-native filters; credentials and result data are never persisted. +type SavedView struct { + ID string `json:"id"` + Kind string `json:"kind"` + Label string `json:"label"` + ProjectID string `json:"projectId"` + RepositoryID string `json:"repositoryId,omitempty"` + WIQL string `json:"wiql,omitempty"` + Top int `json:"top,omitempty"` + Status string `json:"status,omitempty"` + Creator string `json:"creator,omitempty"` + Reviewer string `json:"reviewer,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// SetConfigRequest creates or updates a workspace connection. An empty PAT on +// update retains the currently stored credential. +type SetConfigRequest struct { + OrganizationURL string `json:"organizationUrl"` + DefaultProjectID string `json:"defaultProjectId"` + DefaultProjectName string `json:"defaultProjectName"` + AuthMethod string `json:"authMethod"` + PAT string `json:"pat"` +} + +// TestConnectionResult reports the result of an authenticated identity probe. +type TestConnectionResult struct { + OK bool `json:"ok"` + ID string `json:"id,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Error string `json:"error,omitempty"` +} + +// TaskPR is the persisted summary of an Azure pull request associated with a +// Kandev task repository. Azure-native feedback remains transient. +type TaskPR struct { + ID string `json:"id" db:"id"` + TaskID string `json:"taskId" db:"task_id"` + RepositoryID string `json:"repositoryId" db:"repository_id"` + OrganizationURL string `json:"organizationUrl" db:"organization_url"` + ProjectID string `json:"projectId" db:"project_id"` + AzureRepositoryID string `json:"azureRepositoryId" db:"azure_repository_id"` + PullRequestID int `json:"pullRequestId" db:"pull_request_id"` + PullRequestURL string `json:"pullRequestUrl" db:"pull_request_url"` + Title string `json:"title" db:"title"` + SourceBranch string `json:"sourceBranch" db:"source_branch"` + TargetBranch string `json:"targetBranch" db:"target_branch"` + AuthorID string `json:"authorId" db:"author_id"` + AuthorName string `json:"authorName" db:"author_name"` + Status string `json:"status" db:"status"` + ReviewState string `json:"reviewState,omitempty" db:"review_state"` + PolicyState string `json:"policyState,omitempty" db:"policy_state"` + IsDraft bool `json:"isDraft" db:"is_draft"` + LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty" db:"last_synced_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// TaskPRsResponse groups workspace associations by task ID. +type TaskPRsResponse struct { + TaskPRs map[string][]*TaskPR `json:"taskPrs"` +} + +// 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 new file mode 100644 index 0000000000..aab69311bb --- /dev/null +++ b/apps/backend/internal/azuredevops/poller.go @@ -0,0 +1,27 @@ +package azuredevops + +import ( + "context" + + "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) +} + +type authProber struct { + service *Service +} + +func (p authProber) HasConfig(ctx context.Context) (bool, error) { + workspaceIDs, err := p.service.store.ListConfigWorkspaceIDs(ctx) + return len(workspaceIDs) > 0, err +} + +func (p authProber) RecordAuthHealth(ctx context.Context) { + p.service.RecordAuthHealth(ctx) +} diff --git a/apps/backend/internal/azuredevops/provider.go b/apps/backend/internal/azuredevops/provider.go new file mode 100644 index 0000000000..53473f1bb2 --- /dev/null +++ b/apps/backend/internal/azuredevops/provider.go @@ -0,0 +1,36 @@ +package azuredevops + +import ( + "os" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +const mockEnvVar = "KANDEV_MOCK_AZURE_DEVOPS" + +// Provide constructs the Azure DevOps service. The event bus is accepted to +// match other integration providers; Task 01 does not publish events. +func Provide( + writer *sqlx.DB, + reader *sqlx.DB, + secrets SecretStore, + _ bus.EventBus, + log *logger.Logger, +) (*Service, func() error, error) { + store, err := NewStore(writer, reader) + if err != nil { + return nil, nil, err + } + var factory ClientFactory + var mock *MockClient + if os.Getenv(mockEnvVar) == "true" { + mock = NewMockClient() + factory = func(*Config, string) Client { return mock } + } + service := NewService(store, secrets, factory, log) + service.mock = mock + return service, func() error { return nil }, nil +} diff --git a/apps/backend/internal/azuredevops/repository_lookup.go b/apps/backend/internal/azuredevops/repository_lookup.go new file mode 100644 index 0000000000..887c7ec305 --- /dev/null +++ b/apps/backend/internal/azuredevops/repository_lookup.go @@ -0,0 +1,21 @@ +package azuredevops + +import "context" + +const RepositoryProvider = "azure_devops" + +// RepositoryBinding is the provider metadata for a repository linked to one +// task. ProviderOwner carries the Azure project ID and ProviderRepoID carries +// the Azure repository GUID. +type RepositoryBinding struct { + WorkspaceID string + Provider string + ProviderOwner string + ProviderRepoID string +} + +// RepositoryLookup resolves a repository only when it is linked to the given +// task. Backend wiring adapts the task service to this narrow contract. +type RepositoryLookup interface { + LookupTaskRepository(ctx context.Context, taskID, repositoryID string) (*RepositoryBinding, error) +} diff --git a/apps/backend/internal/azuredevops/rest_client.go b/apps/backend/internal/azuredevops/rest_client.go new file mode 100644 index 0000000000..2fe1b55954 --- /dev/null +++ b/apps/backend/internal/azuredevops/rest_client.go @@ -0,0 +1,483 @@ +package azuredevops + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + restAPIVersion = "7.1" + previewAPIVersion = "7.1-preview.1" + maxErrorBodyBytes = 4096 + maxResponseBodyBytes = 16 << 20 + workItemBatchSize = 200 + branchResultLimit = 1000 + defaultPRPageSize = 50 +) + +// APIError is a bounded, credential-redacted Azure DevOps response error. +type APIError struct { + StatusCode int + Endpoint string + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("azure devops API %s returned %d: %s", e.Endpoint, e.StatusCode, e.Body) +} + +func AsAPIError(err error, target **APIError) bool { return errors.As(err, target) } + +// RESTClient reads Azure DevOps Services directly over HTTP. +type RESTClient struct { + organization string + pat string + httpClient *http.Client + initErr error +} + +func NewRESTClient(organizationURL, pat string, httpClient *http.Client) *RESTClient { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + validatedURL, err := ValidateOrganizationURL(organizationURL) + if err != nil { + return &RESTClient{ + pat: pat, httpClient: httpClient, + initErr: fmt.Errorf("invalid azure devops organization URL: %w", err), + } + } + organization := strings.TrimPrefix(validatedURL, "https://dev.azure.com/") + return &RESTClient{ + organization: organization, + pat: pat, + httpClient: httpClient, + } +} + +func (c *RESTClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) { + var raw struct { + AuthenticatedUser struct { + ID string `json:"id"` + ProviderDisplayName string `json:"providerDisplayName"` + Properties map[string]struct { + Value string `json:"$value"` + } `json:"properties"` + } `json:"authenticatedUser"` + } + endpoint := "/_apis/connectionData?connectOptions=1&lastChangeId=-1&lastChangeId64=-1&api-version=" + previewAPIVersion + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &raw); err != nil { + return nil, err + } + user := raw.AuthenticatedUser + return &TestConnectionResult{ + OK: user.ID != "", + ID: user.ID, + DisplayName: user.ProviderDisplayName, + Email: user.Properties["Account"].Value, + }, nil +} + +func (c *RESTClient) ListProjects(ctx context.Context) ([]Project, error) { + var response struct { + Value []Project `json:"value"` + } + if err := c.doJSON(ctx, http.MethodGet, "/_apis/projects?api-version="+restAPIVersion, nil, &response); err != nil { + return nil, err + } + return response.Value, nil +} + +func (c *RESTClient) ListRepositories(ctx context.Context, projectID string) ([]Repository, error) { + var response struct { + Value []rawRepository `json:"value"` + } + endpoint := fmt.Sprintf("/%s/_apis/git/repositories?api-version=%s", pathPart(projectID), restAPIVersion) + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + result := make([]Repository, 0, len(response.Value)) + for _, repository := range response.Value { + result = append(result, convertRepository(repository)) + } + return result, nil +} + +func (c *RESTClient) ListBranches(ctx context.Context, projectID, repositoryID string) ([]Branch, error) { + var response struct { + Value []struct { + Name string `json:"name"` + } `json:"value"` + } + endpoint := fmt.Sprintf("/%s/_apis/git/repositories/%s/refs?filter=heads/&$top=%d&api-version=%s", pathPart(projectID), pathPart(repositoryID), branchResultLimit, restAPIVersion) + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + branches := make([]Branch, 0, len(response.Value)) + for _, ref := range response.Value { + branches = append(branches, Branch{Name: strings.TrimPrefix(ref.Name, "refs/heads/")}) + } + return branches, nil +} + +func (c *RESTClient) QueryWIQL(ctx context.Context, projectID, wiql string, top int) (*WorkItemSearchResult, error) { + if top <= 0 { + top = workItemBatchSize + } + endpoint := fmt.Sprintf("/%s/_apis/wit/wiql?$top=%d&api-version=%s", pathPart(projectID), top, restAPIVersion) + var response struct { + WorkItems []struct { + ID int `json:"id"` + } `json:"workItems"` + } + if err := c.doJSON(ctx, http.MethodPost, endpoint, map[string]string{"query": wiql}, &response); err != nil { + return nil, err + } + ids := make([]int, 0, len(response.WorkItems)) + for _, ref := range response.WorkItems { + ids = append(ids, ref.ID) + } + items, err := c.hydrateWorkItems(ctx, projectID, ids) + if err != nil { + return nil, err + } + return &WorkItemSearchResult{Items: items, Count: len(items)}, nil +} + +func (c *RESTClient) GetWorkItem(ctx context.Context, projectID string, id int) (*WorkItem, error) { + endpoint := fmt.Sprintf("/%s/_apis/wit/workitems/%d?$expand=all&api-version=%s", pathPart(projectID), id, restAPIVersion) + var raw rawWorkItem + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &raw); err != nil { + return nil, err + } + item := convertWorkItem(raw) + return &item, nil +} + +func (c *RESTClient) ListPullRequests(ctx context.Context, filter PullRequestFilter) (*PullRequestPage, error) { + values := url.Values{"api-version": {restAPIVersion}} + setPRFilter(values, "searchCriteria.status", filter.Status) + setPRFilter(values, "searchCriteria.creatorId", filter.CreatorID) + setPRFilter(values, "searchCriteria.reviewerId", filter.ReviewerID) + setPRFilter(values, "searchCriteria.sourceRefName", normalizeRefForAPI(filter.SourceBranch)) + setPRFilter(values, "searchCriteria.targetRefName", normalizeRefForAPI(filter.TargetBranch)) + if filter.Skip > 0 { + values.Set("$skip", strconv.Itoa(filter.Skip)) + } + top := filter.Top + if top <= 0 || top > 100 { + top = defaultPRPageSize + } + values.Set("$top", strconv.Itoa(top)) + endpoint := fmt.Sprintf("/%s/_apis/git/repositories/%s/pullrequests?%s", + pathPart(filter.ProjectID), pathPart(filter.RepositoryID), values.Encode()) + var response struct { + Count int `json:"count"` + Value []rawPullRequest `json:"value"` + } + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + items := make([]PullRequest, 0, len(response.Value)) + for _, raw := range response.Value { + items = append(items, convertPullRequest(raw)) + } + return &PullRequestPage{Items: items, Count: response.Count, Skip: filter.Skip, Top: top}, nil +} + +func (c *RESTClient) GetPullRequest(ctx context.Context, projectID, repositoryID string, id int) (*PullRequest, error) { + endpoint := pullRequestEndpoint(projectID, repositoryID, id) + "?api-version=" + restAPIVersion + var raw rawPullRequest + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &raw); err != nil { + return nil, err + } + result := convertPullRequest(raw) + return &result, nil +} + +func (c *RESTClient) ListReviewers(ctx context.Context, projectID, repositoryID string, id int) ([]Reviewer, error) { + var response struct { + Value []Reviewer `json:"value"` + } + endpoint := pullRequestEndpoint(projectID, repositoryID, id) + "/reviewers?api-version=" + restAPIVersion + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + return response.Value, nil +} + +func (c *RESTClient) ListThreads(ctx context.Context, projectID, repositoryID string, id int) ([]Thread, error) { + var response struct { + Value []Thread `json:"value"` + } + endpoint := pullRequestEndpoint(projectID, repositoryID, id) + "/threads?api-version=" + restAPIVersion + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + return response.Value, nil +} + +func (c *RESTClient) ListLinkedWorkItems(ctx context.Context, projectID, repositoryID string, id int) ([]WorkItemRef, error) { + var response struct { + Value []struct { + ID string `json:"id"` + URL string `json:"url"` + } `json:"value"` + } + endpoint := pullRequestEndpoint(projectID, repositoryID, id) + "/workitems?api-version=" + previewAPIVersion + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + refs := make([]WorkItemRef, 0, len(response.Value)) + for _, raw := range response.Value { + workItemID, err := strconv.Atoi(raw.ID) + if err == nil { + refs = append(refs, WorkItemRef{ID: workItemID, URL: raw.URL}) + } + } + return refs, nil +} + +func (c *RESTClient) ListPolicyEvaluations(ctx context.Context, projectID string, id int) ([]PolicyEvaluation, error) { + artifact := fmt.Sprintf("vstfs:///CodeReview/CodeReviewId/%s/%d", projectID, id) + values := url.Values{"artifactId": {artifact}, "api-version": {previewAPIVersion}} + endpoint := fmt.Sprintf("/%s/_apis/policy/evaluations?%s", pathPart(projectID), values.Encode()) + var response struct { + Value []rawPolicyEvaluation `json:"value"` + } + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + items := make([]PolicyEvaluation, 0, len(response.Value)) + for _, raw := range response.Value { + items = append(items, PolicyEvaluation{ + ID: raw.ID, Status: raw.Status, Name: raw.Configuration.Type.DisplayName, + IsBlocking: raw.Configuration.IsBlocking, + }) + } + return items, nil +} + +func (c *RESTClient) hydrateWorkItems(ctx context.Context, projectID string, ids []int) ([]WorkItem, error) { + byID := make(map[int]WorkItem, len(ids)) + for start := 0; start < len(ids); start += workItemBatchSize { + end := min(start+workItemBatchSize, len(ids)) + items, err := c.getWorkItemBatch(ctx, projectID, ids[start:end]) + if err != nil { + return nil, err + } + for _, item := range items { + byID[item.ID] = item + } + } + ordered := make([]WorkItem, 0, len(byID)) + for _, id := range ids { + if item, ok := byID[id]; ok { + ordered = append(ordered, item) + } + } + return ordered, nil +} + +func (c *RESTClient) getWorkItemBatch(ctx context.Context, projectID string, ids []int) ([]WorkItem, error) { + endpoint := fmt.Sprintf("/%s/_apis/wit/workitemsbatch?api-version=%s", pathPart(projectID), restAPIVersion) + body := map[string]any{ + "ids": ids, "$expand": "all", "errorPolicy": "omit", + } + var response struct { + Value []rawWorkItem `json:"value"` + } + if err := c.doJSON(ctx, http.MethodPost, endpoint, body, &response); err != nil { + return nil, err + } + items := make([]WorkItem, 0, len(response.Value)) + for _, raw := range response.Value { + items = append(items, convertWorkItem(raw)) + } + return items, nil +} + +func (c *RESTClient) doJSON(ctx context.Context, method, endpoint string, requestBody, responseBody any) error { + if c.initErr != nil { + return c.initErr + } + var body io.Reader + if requestBody != nil { + encoded, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("encode azure devops request: %w", err) + } + body = bytes.NewReader(encoded) + } + requestURL := "https://dev.azure.com/" + url.PathEscape(c.organization) + endpoint + req, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return fmt.Errorf("create azure devops request: %w", err) + } + 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") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("azure devops request %s: %w", endpointPath(endpoint), err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return c.decodeAPIError(resp, endpointPath(endpoint)) + } + limited := io.LimitReader(resp.Body, maxResponseBodyBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return fmt.Errorf("read azure devops response: %w", err) + } + if len(data) > maxResponseBodyBytes { + return errors.New("azure devops response exceeded size limit") + } + if responseBody == nil || len(bytes.TrimSpace(data)) == 0 { + return nil + } + if err := json.Unmarshal(data, responseBody); err != nil { + return fmt.Errorf("decode azure devops response: %w", err) + } + return nil +} +func (c *RESTClient) decodeAPIError(resp *http.Response, endpoint string) error { + data, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) + body := strings.ReplaceAll(string(data), c.pat, "[REDACTED]") + return &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: body} +} + +type rawRepository struct { + ID string `json:"id"` + Name string `json:"name"` + DefaultBranch string `json:"defaultBranch"` + WebURL string `json:"webUrl"` + Project struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"project"` +} + +type rawWorkItem struct { + ID int `json:"id"` + Rev int `json:"rev"` + URL string `json:"url"` + Fields map[string]any `json:"fields"` + Links struct { + HTML struct { + Href string `json:"href"` + } `json:"html"` + } `json:"_links"` +} + +type rawPullRequest struct { + PullRequestID int `json:"pullRequestId"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + IsDraft bool `json:"isDraft"` + SourceRefName string `json:"sourceRefName"` + TargetRefName string `json:"targetRefName"` + MergeStatus string `json:"mergeStatus"` + CreationDate *time.Time `json:"creationDate"` + ClosedDate *time.Time `json:"closedDate"` + URL string `json:"url"` + CreatedBy Identity `json:"createdBy"` + Repository rawRepository `json:"repository"` +} + +type rawPolicyEvaluation struct { + ID string `json:"evaluationId"` + Status string `json:"status"` + Configuration struct { + IsBlocking bool `json:"isBlocking"` + Type struct { + DisplayName string `json:"displayName"` + } `json:"type"` + } `json:"configuration"` +} + +func convertRepository(raw rawRepository) Repository { + return Repository{ + ID: raw.ID, Name: raw.Name, ProjectID: raw.Project.ID, ProjectName: raw.Project.Name, + DefaultBranch: trimBranchRef(raw.DefaultBranch), WebURL: raw.WebURL, + } +} + +func convertWorkItem(raw rawWorkItem) WorkItem { + 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, + APIURL: raw.URL, Fields: raw.Fields, + } +} + +func convertPullRequest(raw rawPullRequest) PullRequest { + webURL := "" + if raw.Repository.WebURL != "" && raw.PullRequestID > 0 { + webURL = strings.TrimRight(raw.Repository.WebURL, "/") + "/pullrequest/" + strconv.Itoa(raw.PullRequestID) + } + return PullRequest{ + ID: raw.PullRequestID, Title: raw.Title, Description: raw.Description, Status: raw.Status, + IsDraft: raw.IsDraft, SourceBranch: trimBranchRef(raw.SourceRefName), TargetBranch: trimBranchRef(raw.TargetRefName), + MergeStatus: raw.MergeStatus, CreationDate: raw.CreationDate, ClosedDate: raw.ClosedDate, + Author: raw.CreatedBy, ProjectID: raw.Repository.Project.ID, ProjectName: raw.Repository.Project.Name, + RepositoryID: raw.Repository.ID, RepositoryName: raw.Repository.Name, WebURL: webURL, APIURL: raw.URL, + } +} + +func stringField(fields map[string]any, key string) string { + value, _ := fields[key].(string) + return value +} + +func identityDisplayField(fields map[string]any, key string) string { + switch value := fields[key].(type) { + case string: + return value + case map[string]any: + display, _ := value["displayName"].(string) + return display + default: + return "" + } +} + +func pullRequestEndpoint(projectID, repositoryID string, id int) string { + return fmt.Sprintf("/%s/_apis/git/repositories/%s/pullrequests/%d", pathPart(projectID), pathPart(repositoryID), id) +} + +func pathPart(value string) string { return url.PathEscape(strings.TrimSpace(value)) } +func trimBranchRef(value string) string { return strings.TrimPrefix(value, "refs/heads/") } +func normalizeRefForAPI(value string) string { + if value == "" || strings.HasPrefix(value, "refs/") { + return value + } + return "refs/heads/" + value +} +func setPRFilter(values url.Values, key, value string) { + if value != "" { + values.Set(key, value) + } +} +func endpointPath(endpoint string) string { + if index := strings.IndexByte(endpoint, '?'); index >= 0 { + return endpoint[:index] + } + return endpoint +} diff --git a/apps/backend/internal/azuredevops/rest_client_test.go b/apps/backend/internal/azuredevops/rest_client_test.go new file mode 100644 index 0000000000..95190899c7 --- /dev/null +++ b/apps/backend/internal/azuredevops/rest_client_test.go @@ -0,0 +1,230 @@ +package azuredevops + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestRESTClientAuthAndDiscovery(t *testing.T) { + const pat = "top-secret-pat" + wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(":"+pat)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != wantAuth { + t.Errorf("Authorization = %q, want Basic PAT", got) + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/acme/_apis/connectionData": + _, _ = w.Write([]byte(`{"authenticatedUser":{"id":"user-1","providerDisplayName":"Ada","properties":{"Account":{"$value":"ada@example.com"}}}}`)) + case "/acme/_apis/projects": + _, _ = w.Write([]byte(`{"count":1,"value":[{"id":"project-1","name":"Platform","url":"https://api/project-1"}]}`)) + case "/acme/project-1/_apis/git/repositories": + _, _ = w.Write([]byte(`{"count":1,"value":[{"id":"repo-1","name":"widgets","defaultBranch":"refs/heads/main","webUrl":"https://dev.azure.com/acme/Platform/_git/widgets","project":{"id":"project-1","name":"Platform"}}]}`)) + case "/acme/project-1/_apis/git/repositories/repo-1/refs": + if got := r.URL.Query().Get("$top"); got != "1000" { + t.Errorf("branch $top = %q, want 1000", got) + } + _, _ = w.Write([]byte(`{"count":1,"value":[{"name":"refs/heads/main"}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := newTestRESTClient(t, server, pat) + auth, err := client.TestAuth(context.Background()) + if err != nil || !auth.OK || auth.ID != "user-1" || auth.Email != "ada@example.com" { + t.Fatalf("TestAuth = %+v, %v", auth, err) + } + projects, err := client.ListProjects(context.Background()) + if err != nil || len(projects) != 1 || projects[0].Name != "Platform" { + t.Fatalf("ListProjects = %+v, %v", projects, err) + } + repos, err := client.ListRepositories(context.Background(), "project-1") + if err != nil || len(repos) != 1 || repos[0].DefaultBranch != "main" { + t.Fatalf("ListRepositories = %+v, %v", repos, err) + } + branches, err := client.ListBranches(context.Background(), "project-1", "repo-1") + if err != nil || len(branches) != 1 || branches[0].Name != "main" { + t.Fatalf("ListBranches = %+v, %v", branches, err) + } +} + +func TestRESTClientQueryWIQLBatchesAndPreservesOrder(t *testing.T) { + var mu sync.Mutex + var batches [][]int + 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/_apis/wit/wiql": + refs := make([]map[string]int, 0, 205) + for id := 1; id <= 205; id++ { + refs = append(refs, map[string]int{"id": id}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"workItems": refs}) + case "/acme/project-1/_apis/wit/workitemsbatch": + var req struct { + IDs []int `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode batch: %v", err) + } + mu.Lock() + batches = append(batches, append([]int(nil), req.IDs...)) + mu.Unlock() + items := make([]map[string]any, 0, len(req.IDs)) + for i := len(req.IDs) - 1; i >= 0; i-- { + id := req.IDs[i] + if id == 3 { // Azure may omit an inaccessible or deleted item. + continue + } + items = append(items, map[string]any{ + "id": id, + "fields": map[string]any{ + "System.Title": fmt.Sprintf("Item %d", id), + "System.State": "Active", + "System.WorkItemType": "Task", + }, + }) + } + _ = json.NewEncoder(w).Encode(map[string]any{"value": items}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := newTestRESTClient(t, server, "pat") + result, err := client.QueryWIQL(context.Background(), "project-1", "SELECT [System.Id] FROM WorkItems", 205) + if err != nil { + t.Fatalf("QueryWIQL: %v", err) + } + if len(batches) != 2 || len(batches[0]) != 200 || len(batches[1]) != 5 { + t.Fatalf("batches = %v", batches) + } + if len(result.Items) != 204 || result.Items[0].ID != 1 || result.Items[1].ID != 2 || result.Items[2].ID != 4 || result.Items[len(result.Items)-1].ID != 205 { + t.Fatalf("result order/omission = first %+v, last %+v, len %d", result.Items[:3], result.Items[len(result.Items)-1], len(result.Items)) + } +} + +func TestRESTClientPullRequestReads(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/_apis/git/repositories/repo-1/pullrequests": + _, _ = w.Write([]byte(`{"count":1,"value":[{"pullRequestId":42,"title":"Ship it","status":"active","isDraft":true,"sourceRefName":"refs/heads/feature","targetRefName":"refs/heads/main","url":"https://api/pr/42","createdBy":{"id":"u1","displayName":"Ada"},"repository":{"id":"repo-1","name":"widgets","webUrl":"https://dev.azure.com/acme/Platform/_git/widgets","project":{"id":"project-1","name":"Platform"}}}]}`)) + case "/acme/project-1/_apis/git/repositories/repo-1/pullrequests/42": + _, _ = w.Write([]byte(`{"pullRequestId":42,"title":"Ship it","status":"active","sourceRefName":"refs/heads/feature","targetRefName":"refs/heads/main","createdBy":{"id":"u1","displayName":"Ada"},"repository":{"id":"repo-1","name":"widgets","webUrl":"https://dev.azure.com/acme/Platform/_git/widgets","project":{"id":"project-1","name":"Platform"}}}`)) + case "/acme/project-1/_apis/git/repositories/repo-1/pullrequests/42/reviewers": + _, _ = w.Write([]byte(`{"count":1,"value":[{"id":"u2","displayName":"Grace","vote":10,"isRequired":true}]}`)) + case "/acme/project-1/_apis/git/repositories/repo-1/pullrequests/42/threads": + _, _ = w.Write([]byte(`{"count":1,"value":[{"id":7,"status":"active","comments":[{"id":8,"content":"Please add a test","author":{"id":"u2","displayName":"Grace"},"commentType":"text","publishedDate":"2026-07-17T10:00:00Z","lastUpdatedDate":"2026-07-17T11:30:00Z"}]}]}`)) + case "/acme/project-1/_apis/git/repositories/repo-1/pullrequests/42/workitems": + _, _ = w.Write([]byte(`{"count":1,"value":[{"id":"101","url":"https://api/workitems/101"}]}`)) + case "/acme/project-1/_apis/policy/evaluations": + _, _ = w.Write([]byte(`{"count":1,"value":[{"evaluationId":"eval-1","status":"approved","configuration":{"isBlocking":true,"type":{"displayName":"Build"}}}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := newTestRESTClient(t, server, "pat") + prs, err := client.ListPullRequests(context.Background(), PullRequestFilter{ProjectID: "project-1", RepositoryID: "repo-1", Status: "active", ReviewerID: "me"}) + if err != nil || len(prs.Items) != 1 || prs.Items[0].SourceBranch != "feature" || prs.Items[0].WebURL == "" { + t.Fatalf("ListPullRequests = %+v, %v", prs, err) + } + pr, err := client.GetPullRequest(context.Background(), "project-1", "repo-1", 42) + if err != nil || pr.ID != 42 { + t.Fatalf("GetPullRequest = %+v, %v", pr, err) + } + reviewers, err := client.ListReviewers(context.Background(), "project-1", "repo-1", 42) + if err != nil || len(reviewers) != 1 || reviewers[0].Vote != 10 { + t.Fatalf("ListReviewers = %+v, %v", reviewers, err) + } + threads, err := client.ListThreads(context.Background(), "project-1", "repo-1", 42) + if err != nil || len(threads) != 1 || threads[0].Comments[0].Content != "Please add a test" { + t.Fatalf("ListThreads = %+v, %v", threads, err) + } + comment := threads[0].Comments[0] + if !comment.PublishedAt.Equal(time.Date(2026, 7, 17, 10, 0, 0, 0, time.UTC)) || + !comment.UpdatedAt.Equal(time.Date(2026, 7, 17, 11, 30, 0, 0, time.UTC)) { + t.Fatalf("comment timestamps = published %v, updated %v", comment.PublishedAt, comment.UpdatedAt) + } + refs, err := client.ListLinkedWorkItems(context.Background(), "project-1", "repo-1", 42) + if err != nil || len(refs) != 1 || refs[0].ID != 101 { + t.Fatalf("ListLinkedWorkItems = %+v, %v", refs, err) + } + policies, err := client.ListPolicyEvaluations(context.Background(), "project-1", 42) + if err != nil || len(policies) != 1 || policies[0].Status != "approved" { + t.Fatalf("ListPolicyEvaluations = %+v, %v", policies, err) + } +} + +func TestRESTClientErrorIsBoundedAndRedacted(t *testing.T) { + const pat = "never-echo-this-pat" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(pat + strings.Repeat("x", 6000))) + })) + t.Cleanup(server.Close) + + _, err := newTestRESTClient(t, server, pat).ListProjects(context.Background()) + var apiErr *APIError + if err == nil || !AsAPIError(err, &apiErr) { + t.Fatalf("error = %v, want APIError", err) + } + if strings.Contains(apiErr.Body, pat) || len(apiErr.Body) > 4096 { + t.Fatalf("unsafe API error body length=%d body=%q", len(apiErr.Body), apiErr.Body) + } +} + +func TestRESTClientSuccessResponseIsBounded(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("x", maxResponseBodyBytes+1))) + })) + t.Cleanup(server.Close) + + _, err := newTestRESTClient(t, server, "pat").ListProjects(context.Background()) + if err == nil || err.Error() != "azure devops response exceeded size limit" { + t.Fatalf("error = %v, want response size limit", err) + } +} + +func TestRESTClientRejectsNonCanonicalOrganizationURLBeforeRequest(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + t.Cleanup(server.Close) + + _, err := NewRESTClient(server.URL, "pat", server.Client()).ListProjects(context.Background()) + if err == nil || !strings.Contains(err.Error(), "invalid azure devops organization URL") { + t.Fatalf("ListProjects error = %v, want invalid organization URL", err) + } + if called { + t.Fatal("invalid organization URL reached the HTTP transport") + } +} + +func newTestRESTClient(t *testing.T, server *httptest.Server, pat string) *RESTClient { + t.Helper() + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + response := httptest.NewRecorder() + server.Config.Handler.ServeHTTP(response, req) + return response.Result(), nil + })} + return NewRESTClient("https://dev.azure.com/acme", pat, httpClient) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } diff --git a/apps/backend/internal/azuredevops/saved_views.go b/apps/backend/internal/azuredevops/saved_views.go new file mode 100644 index 0000000000..8d3e602b36 --- /dev/null +++ b/apps/backend/internal/azuredevops/saved_views.go @@ -0,0 +1,129 @@ +package azuredevops + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +const maxSavedViews = 50 + +func (s *Service) GetSavedViewsForWorkspace(ctx context.Context, workspaceID string) ([]SavedView, error) { + raw, err := s.store.GetSavedViewsJSON(ctx, workspaceID) + if err != nil { + return nil, err + } + var candidates []SavedView + if json.Unmarshal([]byte(raw), &candidates) != nil { + return []SavedView{}, nil + } + views := make([]SavedView, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + view, validationErr := validateSavedView(candidate) + if validationErr != nil { + continue + } + if _, duplicate := seen[view.ID]; duplicate { + continue + } + seen[view.ID] = struct{}{} + views = append(views, view) + if len(views) == maxSavedViews { + break + } + } + return views, nil +} + +func (s *Service) SetSavedViewsForWorkspace( + ctx context.Context, + workspaceID string, + views []SavedView, +) ([]SavedView, error) { + if len(views) > maxSavedViews { + return nil, fmt.Errorf("%w: at most %d saved views are allowed", ErrInvalidConfig, maxSavedViews) + } + normalized := make([]SavedView, 0, len(views)) + seen := make(map[string]struct{}, len(views)) + for _, candidate := range views { + view, err := validateSavedView(candidate) + if err != nil { + return nil, err + } + if _, duplicate := seen[view.ID]; duplicate { + return nil, fmt.Errorf("%w: duplicate saved view id", ErrInvalidConfig) + } + seen[view.ID] = struct{}{} + normalized = append(normalized, view) + } + raw, err := json.Marshal(normalized) + if err != nil { + return nil, fmt.Errorf("encode Azure DevOps saved views: %w", err) + } + if err := s.store.PutSavedViewsJSON(ctx, workspaceID, string(raw)); err != nil { + return nil, err + } + return normalized, nil +} + +func validateSavedView(view SavedView) (SavedView, error) { + view = normalizeSavedView(view) + if err := validateSavedViewIdentity(view); err != nil { + return SavedView{}, err + } + if err := validateSavedViewQuery(&view); err != nil { + return SavedView{}, err + } + if view.CreatedAt.IsZero() { + view.CreatedAt = time.Now().UTC() + } + return view, nil +} + +func normalizeSavedView(view SavedView) SavedView { + view.ID = strings.TrimSpace(view.ID) + view.Kind = strings.TrimSpace(view.Kind) + view.Label = strings.TrimSpace(view.Label) + view.ProjectID = strings.TrimSpace(view.ProjectID) + view.RepositoryID = strings.TrimSpace(view.RepositoryID) + view.WIQL = strings.TrimSpace(view.WIQL) + view.Status = strings.TrimSpace(view.Status) + view.Creator = strings.TrimSpace(view.Creator) + view.Reviewer = strings.TrimSpace(view.Reviewer) + return view +} + +func validateSavedViewIdentity(view SavedView) error { + if view.ID == "" || len(view.ID) > 100 || view.Label == "" || len(view.Label) > 80 { + return fmt.Errorf("%w: saved view id and label are required", ErrInvalidConfig) + } + if view.ProjectID == "" { + return fmt.Errorf("%w: saved view project is required", ErrInvalidConfig) + } + return nil +} + +func validateSavedViewQuery(view *SavedView) error { + switch view.Kind { + case "work_item": + if view.WIQL == "" || len(view.WIQL) > 10_000 { + return fmt.Errorf("%w: work-item view requires WIQL", ErrInvalidConfig) + } + if view.Top == 0 { + view.Top = 50 + } + if view.Top < 1 || view.Top > 200 { + return fmt.Errorf("%w: saved view result limit is invalid", ErrInvalidConfig) + } + case "pull_request": + if view.RepositoryID == "" { + return fmt.Errorf("%w: pull-request view requires a repository", ErrInvalidConfig) + } + default: + return fmt.Errorf("%w: saved view kind is invalid", ErrInvalidConfig) + } + return nil +} diff --git a/apps/backend/internal/azuredevops/service.go b/apps/backend/internal/azuredevops/service.go new file mode 100644 index 0000000000..eb7f0f7afe --- /dev/null +++ b/apps/backend/internal/azuredevops/service.go @@ -0,0 +1,394 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +var ( + // ErrInvalidConfig identifies user-supplied connection settings that cannot + // be persisted or tested. + ErrInvalidConfig = errors.New("azure devops: invalid configuration") + // ErrInvalidWorkspaceID prevents operations from falling back across + // workspace boundaries when the caller omits its scope. + ErrInvalidWorkspaceID = errors.New("azure devops: workspace id is required") + organizationNameRE = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,48}[A-Za-z0-9])?$`) +) + +const ( + authProbeTimeout = 15 * time.Second + authHealthWriteTimeout = 5 * time.Second +) + +// SecretStore is the encrypted secret-store surface used by this integration. +type SecretStore interface { + Reveal(ctx context.Context, id string) (string, error) + Set(ctx context.Context, id, name, value string) error + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, id string) (bool, error) +} + +// 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 +} + +// MockClient returns the E2E mock when the provider is in mock mode. +func (s *Service) MockClient() *MockClient { return s.mock } + +// NewService constructs the Azure DevOps configuration service. +func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *logger.Logger) *Service { + if clientFn == nil { + clientFn = DefaultClientFactory + } + if log == nil { + log = logger.Default() + } + return &Service{store: store, secrets: secrets, clientFn: clientFn, log: log} +} + +// Store exposes the configuration store to health-poller adapters. +func (s *Service) Store() *Store { + return s.store +} + +// ValidateOrganizationURL accepts Azure DevOps Services organization URLs and +// returns the canonical form without a trailing slash. +func ValidateOrganizationURL(raw string) (string, error) { + normalized := strings.TrimSuffix(raw, "/") + parsed, err := url.Parse(normalized) + if err != nil || parsed.Scheme != "https" || parsed.Host != "dev.azure.com" { + return "", errors.New("organizationUrl must use https://dev.azure.com") + } + if parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.RawPath != "" { + return "", errors.New("organizationUrl must be a canonical organization URL") + } + organization := strings.TrimPrefix(parsed.Path, "/") + if parsed.Path != "/"+organization || !organizationNameRE.MatchString(organization) || parsed.String() != normalized { + return "", errors.New("organizationUrl must contain exactly one valid organization name") + } + return normalized, nil +} + +// GetConfigForWorkspace returns a redacted workspace configuration. +func (s *Service) GetConfigForWorkspace(ctx context.Context, workspaceID string) (*Config, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + cfg, err := s.store.GetConfig(ctx, workspaceID) + if err != nil || cfg == nil || s.secrets == nil { + return cfg, err + } + cfg.HasSecret, err = s.secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil { + return nil, fmt.Errorf("check azure devops PAT: %w", err) + } + return cfg, nil +} + +// SetConfigForWorkspace upserts a workspace connection. An empty PAT retains +// the existing encrypted credential. +func (s *Service) SetConfigForWorkspace( + ctx context.Context, + workspaceID string, + req *SetConfigRequest, +) (*Config, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + cfg, err := configFromRequest(workspaceID, req) + if err != nil { + return nil, err + } + previousConfig, err := s.store.GetConfig(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("read existing azure devops config: %w", err) + } + organizationChanged := previousConfig != nil && previousConfig.OrganizationURL != cfg.OrganizationURL + if req.PAT == "" { + if err := s.requireStoredPAT(ctx, workspaceID); err != nil { + return nil, err + } + if err := s.store.UpsertConfig(ctx, cfg); err != nil { + return nil, fmt.Errorf("upsert azure devops config: %w", err) + } + return s.finishConfigUpdate(ctx, workspaceID, organizationChanged) + } + return s.setConfigWithPAT(ctx, workspaceID, cfg, req.PAT, organizationChanged) +} + +func (s *Service) setConfigWithPAT( + ctx context.Context, + workspaceID string, + cfg *Config, + pat string, + organizationChanged bool, +) (*Config, error) { + if s.secrets == nil { + return nil, errors.New("azure devops: no secret store configured") + } + previous, err := s.readStoredPAT(ctx, workspaceID) + if err != nil { + return nil, err + } + if err := s.secrets.Set(ctx, SecretKeyForWorkspace(workspaceID), "Azure DevOps PAT", pat); err != nil { + return nil, joinMutationError( + fmt.Errorf("store azure devops PAT: %w", err), + s.restorePAT(ctx, workspaceID, previous), + ) + } + if err := s.store.UpsertConfig(ctx, cfg); err != nil { + return nil, joinMutationError( + fmt.Errorf("upsert azure devops config: %w", err), + s.restorePAT(ctx, workspaceID, previous), + ) + } + patChanged := !previous.exists || previous.value != pat + return s.finishConfigUpdate(ctx, workspaceID, organizationChanged || patChanged) +} + +func (s *Service) finishConfigUpdate( + ctx context.Context, + workspaceID string, + credentialsChanged bool, +) (*Config, error) { + if credentialsChanged { + if err := s.store.ResetAuthHealth(ctx, workspaceID); err != nil { + return nil, fmt.Errorf("reset azure devops auth health: %w", err) + } + } + return s.GetConfigForWorkspace(ctx, workspaceID) +} + +// DeleteConfigForWorkspace removes both configuration and its encrypted PAT. +func (s *Service) DeleteConfigForWorkspace(ctx context.Context, workspaceID string) error { + if err := validateWorkspaceID(workspaceID); err != nil { + return err + } + previous, err := s.readStoredPAT(ctx, workspaceID) + if err != nil { + return err + } + if previous.exists { + if err := s.secrets.Delete(ctx, SecretKeyForWorkspace(workspaceID)); err != nil { + return joinMutationError( + fmt.Errorf("delete azure devops PAT: %w", err), + s.restorePAT(ctx, workspaceID, previous), + ) + } + } + if err := s.store.DeleteConfig(ctx, workspaceID); err != nil { + return joinMutationError(err, s.restorePAT(ctx, workspaceID, previous)) + } + return nil +} + +type storedPAT struct { + value string + exists bool +} + +func (s *Service) readStoredPAT(ctx context.Context, workspaceID string) (storedPAT, error) { + if s.secrets == nil { + return storedPAT{}, nil + } + key := SecretKeyForWorkspace(workspaceID) + exists, err := s.secrets.Exists(ctx, key) + if err != nil { + return storedPAT{}, fmt.Errorf("check azure devops PAT: %w", err) + } + if !exists { + return storedPAT{}, nil + } + value, err := s.secrets.Reveal(ctx, key) + if err != nil { + return storedPAT{}, fmt.Errorf("read azure devops PAT: %w", err) + } + return storedPAT{value: value, exists: true}, nil +} + +func (s *Service) restorePAT(ctx context.Context, workspaceID string, previous storedPAT) error { + if s.secrets == nil { + return nil + } + key := SecretKeyForWorkspace(workspaceID) + if previous.exists { + return s.secrets.Set(ctx, key, "Azure DevOps PAT", previous.value) + } + return s.secrets.Delete(ctx, key) +} + +func joinMutationError(actionErr, rollbackErr error) error { + if rollbackErr == nil { + return actionErr + } + return errors.Join(actionErr, fmt.Errorf("restore prior Azure DevOps state: %w", rollbackErr)) +} + +// TestConnectionForWorkspace probes submitted credentials without persisting +// them, or falls back to that workspace's stored config and PAT. +func (s *Service) TestConnectionForWorkspace( + ctx context.Context, + workspaceID string, + req *SetConfigRequest, +) (*TestConnectionResult, error) { + cfg, pat, err := s.resolveCredentials(ctx, workspaceID, req) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return s.clientFn(cfg, pat).TestAuth(ctx) +} + +// ProbeAuthForWorkspace tests the workspace's stored credentials. +func (s *Service) ProbeAuthForWorkspace(ctx context.Context, workspaceID string) (*TestConnectionResult, error) { + return s.TestConnectionForWorkspace(ctx, workspaceID, &SetConfigRequest{}) +} + +// RecordAuthHealth probes every configured workspace and persists each result. +func (s *Service) RecordAuthHealth(ctx context.Context) { + workspaceIDs, err := s.store.ListConfigWorkspaceIDs(ctx) + if err != nil { + s.log.Warn("azure devops: list configured workspaces failed", zap.Error(err)) + return + } + for _, workspaceID := range workspaceIDs { + s.RecordAuthHealthForWorkspace(ctx, workspaceID) + } +} + +// RecordAuthHealthForWorkspace probes and persists one workspace's status. +func (s *Service) RecordAuthHealthForWorkspace(ctx context.Context, workspaceID string) { + probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) + result, err := s.ProbeAuthForWorkspace(probeCtx, workspaceID) + cancel() + if ctx.Err() != nil { + return + } + ok, errMsg := authHealthResult(result, err) + writeCtx, writeCancel := context.WithTimeout(ctx, authHealthWriteTimeout) + defer writeCancel() + if err := s.store.UpdateAuthHealth(writeCtx, workspaceID, ok, errMsg, time.Now().UTC()); err != nil { + s.log.Warn("azure devops: update auth health failed", zap.Error(err)) + } +} + +func (s *Service) resolveCredentials( + ctx context.Context, + workspaceID string, + req *SetConfigRequest, +) (*Config, string, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, "", err + } + if req == nil { + req = &SetConfigRequest{} + } + var cfg *Config + var err error + if req.OrganizationURL != "" { + cfg, err = configFromRequest(workspaceID, req) + } else { + cfg, err = s.store.GetConfig(ctx, workspaceID) + if err == nil && cfg == nil { + err = ErrNotConfigured + } + } + if err != nil { + return nil, "", err + } + pat := req.PAT + if pat == "" { + pat, err = s.revealPAT(ctx, workspaceID) + if err != nil { + return nil, "", err + } + } + return cfg, pat, nil +} + +func (s *Service) requireStoredPAT(ctx context.Context, workspaceID string) error { + if s.secrets == nil { + return errors.New("azure devops: no secret store configured") + } + exists, err := s.secrets.Exists(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil { + return fmt.Errorf("check azure devops PAT: %w", err) + } + if !exists { + return fmt.Errorf("%w: pat required", ErrInvalidConfig) + } + return nil +} + +func (s *Service) revealPAT(ctx context.Context, workspaceID string) (string, error) { + if s.secrets == nil { + return "", ErrNotConfigured + } + pat, err := s.secrets.Reveal(ctx, SecretKeyForWorkspace(workspaceID)) + if err != nil || pat == "" { + if err != nil { + return "", fmt.Errorf("read azure devops PAT: %w", err) + } + return "", ErrNotConfigured + } + return pat, nil +} + +func configFromRequest(workspaceID string, req *SetConfigRequest) (*Config, error) { + if req == nil { + return nil, fmt.Errorf("%w: request required", ErrInvalidConfig) + } + organizationURL, err := ValidateOrganizationURL(req.OrganizationURL) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) + } + authMethod := req.AuthMethod + if authMethod == "" { + authMethod = AuthMethodPAT + } + if authMethod != AuthMethodPAT { + return nil, fmt.Errorf("%w: authMethod must be pat", ErrInvalidConfig) + } + return &Config{ + WorkspaceID: workspaceID, + OrganizationURL: organizationURL, + DefaultProjectID: req.DefaultProjectID, + DefaultProjectName: req.DefaultProjectName, + AuthMethod: authMethod, + }, nil +} + +func validateWorkspaceID(workspaceID string) error { + if strings.TrimSpace(workspaceID) == "" { + return ErrInvalidWorkspaceID + } + return nil +} + +func authHealthResult(result *TestConnectionResult, err error) (bool, string) { + if err != nil { + return false, err.Error() + } + if result == nil { + return false, "azure devops: empty authentication response" + } + if !result.OK { + return false, result.Error + } + return true, "" +} diff --git a/apps/backend/internal/azuredevops/service_reads.go b/apps/backend/internal/azuredevops/service_reads.go new file mode 100644 index 0000000000..0aefef12c1 --- /dev/null +++ b/apps/backend/internal/azuredevops/service_reads.go @@ -0,0 +1,267 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" + "strings" +) + +const ( + reviewStateApproved = "approved" + policyStateSuccess = "success" +) + +// PullRequestFeedback keeps Azure-native detail while providing normalized +// summary states for shared task UI. +type PullRequestFeedback struct { + PullRequest *PullRequest `json:"pullRequest"` + Reviewers []Reviewer `json:"reviewers"` + Threads []Thread `json:"threads"` + LinkedWorkItems []WorkItemRef `json:"linkedWorkItems"` + Policies []PolicyEvaluation `json:"policies"` + ReviewState string `json:"reviewState"` + PolicyState string `json:"policyState"` +} + +func (s *Service) clientForWorkspace(ctx context.Context, workspaceID string) (Client, error) { + cfg, pat, err := s.resolveCredentials(ctx, workspaceID, &SetConfigRequest{}) + if err != nil { + return nil, err + } + return s.clientFn(cfg, pat), nil +} + +func (s *Service) ListProjectsForWorkspace(ctx context.Context, workspaceID string) ([]Project, error) { + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.ListProjects(ctx) +} + +func (s *Service) ListRepositoriesForWorkspace(ctx context.Context, workspaceID, projectID string) ([]Repository, error) { + if strings.TrimSpace(projectID) == "" { + return nil, fmt.Errorf("%w: project required", ErrInvalidConfig) + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.ListRepositories(ctx, projectID) +} + +type branchClient interface { + ListBranches(ctx context.Context, projectID, repositoryID string) ([]Branch, error) +} + +func (s *Service) ListBranchesForWorkspace( + ctx context.Context, workspaceID, organization, projectID, repositoryID string, +) ([]Branch, error) { + if strings.TrimSpace(projectID) == "" || strings.TrimSpace(repositoryID) == "" { + return nil, fmt.Errorf("%w: project and repository required", ErrInvalidConfig) + } + cfg, pat, err := s.resolveCredentials(ctx, workspaceID, &SetConfigRequest{}) + if err != nil { + return nil, err + } + if strings.TrimSpace(organization) != "" { + requestedURL, validationErr := ValidateOrganizationURL("https://dev.azure.com/" + organization) + if validationErr != nil || !strings.EqualFold(requestedURL, cfg.OrganizationURL) { + return nil, fmt.Errorf("%w: requested organization does not match workspace configuration", ErrInvalidConfig) + } + } + client := s.clientFn(cfg, pat) + reader, ok := client.(branchClient) + if !ok { + return nil, errors.New("azure devops: branch listing is unavailable") + } + return reader.ListBranches(ctx, projectID, repositoryID) +} + +func (s *Service) SearchWorkItemsForWorkspace( + ctx context.Context, + workspaceID, projectID, wiql string, + top int, +) (*WorkItemSearchResult, error) { + if strings.TrimSpace(projectID) == "" || strings.TrimSpace(wiql) == "" { + return nil, fmt.Errorf("%w: project and wiql required", ErrInvalidConfig) + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.QueryWIQL(ctx, projectID, wiql, top) +} + +func (s *Service) GetWorkItemForWorkspace(ctx context.Context, workspaceID, projectID string, id int) (*WorkItem, 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 + } + return client.GetWorkItem(ctx, projectID, id) +} + +func (s *Service) ListPullRequestsForWorkspace( + ctx context.Context, + workspaceID string, + filter PullRequestFilter, +) (*PullRequestPage, error) { + if strings.TrimSpace(filter.ProjectID) == "" || strings.TrimSpace(filter.RepositoryID) == "" { + return nil, fmt.Errorf("%w: project and repository required", ErrInvalidConfig) + } + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + filter, err = resolveCurrentUserPullRequestFilters(ctx, client, filter) + if err != nil { + return nil, err + } + return client.ListPullRequests(ctx, filter) +} + +func resolveCurrentUserPullRequestFilters( + ctx context.Context, + client Client, + filter PullRequestFilter, +) (PullRequestFilter, error) { + creatorIsMe := strings.EqualFold(strings.TrimSpace(filter.CreatorID), "@me") + reviewerIsMe := strings.EqualFold(strings.TrimSpace(filter.ReviewerID), "@me") + if !creatorIsMe && !reviewerIsMe { + return filter, nil + } + identity, err := client.TestAuth(ctx) + if err != nil { + return filter, err + } + if identity == nil || !identity.OK || strings.TrimSpace(identity.ID) == "" { + return filter, fmt.Errorf("%w: authenticated Azure DevOps identity is unavailable", ErrInvalidConfig) + } + if creatorIsMe { + filter.CreatorID = identity.ID + } + if reviewerIsMe { + filter.ReviewerID = identity.ID + } + return filter, nil +} + +func (s *Service) GetPullRequestForWorkspace( + ctx context.Context, + workspaceID, projectID, repositoryID string, + id int, +) (*PullRequest, error) { + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return client.GetPullRequest(ctx, projectID, repositoryID, id) +} + +func (s *Service) GetPullRequestFeedbackForWorkspace( + ctx context.Context, + workspaceID, projectID, repositoryID string, + id int, +) (*PullRequestFeedback, error) { + client, err := s.clientForWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + pr, err := client.GetPullRequest(ctx, projectID, repositoryID, id) + if err != nil { + return nil, err + } + reviewers, err := client.ListReviewers(ctx, projectID, repositoryID, id) + if err != nil { + return nil, err + } + threads, err := client.ListThreads(ctx, projectID, repositoryID, id) + if err != nil { + return nil, err + } + refs, err := client.ListLinkedWorkItems(ctx, projectID, repositoryID, id) + if err != nil { + return nil, err + } + policies, err := client.ListPolicyEvaluations(ctx, projectID, id) + if err != nil { + return nil, err + } + return &PullRequestFeedback{ + PullRequest: pr, Reviewers: nonNilReviewers(reviewers), Threads: nonNilThreads(threads), + LinkedWorkItems: nonNilWorkItemRefs(refs), Policies: nonNilPolicies(policies), + ReviewState: summarizeReviewState(reviewers), PolicyState: summarizePolicyState(policies), + }, nil +} + +func summarizeReviewState(reviewers []Reviewer) string { + hasApproval := false + hasWaiting := false + for _, reviewer := range reviewers { + switch reviewer.Vote { + case -10: + return "rejected" + case -5: + hasWaiting = true + case 5, 10: + hasApproval = true + } + } + if hasWaiting { + return "waiting" + } + if hasApproval { + return reviewStateApproved + } + return "" +} + +func summarizePolicyState(policies []PolicyEvaluation) string { + if len(policies) == 0 { + return "" + } + pending := false + for _, policy := range policies { + switch strings.ToLower(policy.Status) { + case "rejected", "broken": + if policy.IsBlocking { + return "failure" + } + case "queued", "running": + pending = true + } + } + if pending { + return "pending" + } + return policyStateSuccess +} + +func nonNilReviewers(items []Reviewer) []Reviewer { + if items == nil { + return []Reviewer{} + } + return items +} +func nonNilThreads(items []Thread) []Thread { + if items == nil { + return []Thread{} + } + return items +} +func nonNilWorkItemRefs(items []WorkItemRef) []WorkItemRef { + if items == nil { + return []WorkItemRef{} + } + return items +} +func nonNilPolicies(items []PolicyEvaluation) []PolicyEvaluation { + if items == nil { + return []PolicyEvaluation{} + } + return items +} diff --git a/apps/backend/internal/azuredevops/service_task_pr.go b/apps/backend/internal/azuredevops/service_task_pr.go new file mode 100644 index 0000000000..8d7a65e3d2 --- /dev/null +++ b/apps/backend/internal/azuredevops/service_task_pr.go @@ -0,0 +1,153 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +// ErrInvalidTaskPRAssociation identifies repository/task/provider validation +// failures that controllers expose as bad requests. +var ErrInvalidTaskPRAssociation = errors.New("azure devops: invalid task PR association") + +// SetRepositoryLookup wires the task-repository ownership resolver. +func (s *Service) SetRepositoryLookup(lookup RepositoryLookup) { + s.repoLookup = lookup +} + +// AssociateTaskPR validates a task repository, fetches the Azure PR, and +// persists its current summary. +func (s *Service) AssociateTaskPR( + ctx context.Context, + workspaceID, taskID, repositoryID string, + pullRequestID int, +) (*TaskPR, error) { + return s.syncTaskPR(ctx, workspaceID, taskID, repositoryID, pullRequestID) +} + +// SyncTaskPR refreshes one persisted task PR association from Azure. +func (s *Service) SyncTaskPR( + ctx context.Context, + workspaceID, taskID, repositoryID string, + pullRequestID int, +) (*TaskPR, error) { + return s.syncTaskPR(ctx, workspaceID, taskID, repositoryID, pullRequestID) +} + +func (s *Service) syncTaskPR( + ctx context.Context, + workspaceID, taskID, repositoryID string, + pullRequestID int, +) (*TaskPR, error) { + binding, err := s.validateTaskPRRepository(ctx, workspaceID, taskID, repositoryID, pullRequestID) + if err != nil { + return nil, err + } + feedback, err := s.GetPullRequestFeedbackForWorkspace( + ctx, workspaceID, binding.ProviderOwner, binding.ProviderRepoID, pullRequestID, + ) + if err != nil { + return nil, fmt.Errorf("fetch Azure pull request feedback: %w", err) + } + if feedback == nil || feedback.PullRequest == nil || feedback.PullRequest.ID != pullRequestID { + return nil, fmt.Errorf("azure pull request %d not found", pullRequestID) + } + if err := validateAzurePRIdentity(feedback.PullRequest, binding); err != nil { + return nil, err + } + cfg, err := s.store.GetConfig(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("load Azure DevOps config: %w", err) + } + if cfg == nil { + return nil, ErrNotConfigured + } + row := taskPRFromFeedback(taskID, repositoryID, cfg.OrganizationURL, binding, feedback) + if err := s.store.UpsertTaskPR(ctx, row); err != nil { + return nil, fmt.Errorf("upsert Azure task PR: %w", err) + } + return row, nil +} + +func (s *Service) validateTaskPRRepository( + ctx context.Context, + workspaceID, taskID, repositoryID string, + pullRequestID int, +) (*RepositoryBinding, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + if strings.TrimSpace(taskID) == "" || strings.TrimSpace(repositoryID) == "" || pullRequestID <= 0 { + return nil, fmt.Errorf("%w: task, repository, and positive pull request ID are required", ErrInvalidTaskPRAssociation) + } + if s.repoLookup == nil { + return nil, fmt.Errorf("%w: repository lookup is not configured", ErrInvalidTaskPRAssociation) + } + binding, err := s.repoLookup.LookupTaskRepository(ctx, taskID, repositoryID) + if err != nil { + return nil, fmt.Errorf("%w: lookup task repository: %v", ErrInvalidTaskPRAssociation, err) + } + if binding == nil { + return nil, fmt.Errorf("%w: repository is not linked to task", ErrInvalidTaskPRAssociation) + } + if binding.WorkspaceID != workspaceID { + return nil, fmt.Errorf("%w: repository belongs to another workspace", ErrInvalidTaskPRAssociation) + } + if binding.Provider != RepositoryProvider { + return nil, fmt.Errorf("%w: repository provider must be %s", ErrInvalidTaskPRAssociation, RepositoryProvider) + } + if strings.TrimSpace(binding.ProviderOwner) == "" || strings.TrimSpace(binding.ProviderRepoID) == "" { + return nil, fmt.Errorf("%w: Azure project and repository IDs are required", ErrInvalidTaskPRAssociation) + } + return binding, nil +} + +func validateAzurePRIdentity(pr *PullRequest, binding *RepositoryBinding) error { + if pr.ProjectID != "" && pr.ProjectID != binding.ProviderOwner { + return fmt.Errorf("%w: pull request belongs to another Azure project", ErrInvalidTaskPRAssociation) + } + if pr.RepositoryID != "" && pr.RepositoryID != binding.ProviderRepoID { + return fmt.Errorf("%w: pull request belongs to another Azure repository", ErrInvalidTaskPRAssociation) + } + return nil +} + +func taskPRFromFeedback( + taskID, repositoryID, organizationURL string, + binding *RepositoryBinding, + feedback *PullRequestFeedback, +) *TaskPR { + pr := feedback.PullRequest + now := time.Now().UTC() + return &TaskPR{ + TaskID: taskID, RepositoryID: repositoryID, OrganizationURL: organizationURL, + ProjectID: binding.ProviderOwner, AzureRepositoryID: binding.ProviderRepoID, + PullRequestID: pr.ID, PullRequestURL: pr.WebURL, Title: pr.Title, + SourceBranch: normalizeBranch(pr.SourceBranch), TargetBranch: normalizeBranch(pr.TargetBranch), + AuthorID: pr.Author.ID, AuthorName: pr.Author.DisplayName, Status: pr.Status, + ReviewState: feedback.ReviewState, PolicyState: feedback.PolicyState, + IsDraft: pr.IsDraft, LastSyncedAt: &now, + } +} + +func normalizeBranch(branch string) string { + return strings.TrimPrefix(branch, "refs/heads/") +} + +// ListTaskPRsByWorkspace returns associations grouped by task ID. +func (s *Service) ListTaskPRsByWorkspace(ctx context.Context, workspaceID string) (map[string][]*TaskPR, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + return s.store.ListTaskPRsByWorkspace(ctx, workspaceID) +} + +// ListTaskPRsByTask returns all associations linked to one task. +func (s *Service) ListTaskPRsByTask(ctx context.Context, taskID string) ([]*TaskPR, error) { + if strings.TrimSpace(taskID) == "" { + return nil, fmt.Errorf("%w: task is required", ErrInvalidTaskPRAssociation) + } + return s.store.ListTaskPRsByTask(ctx, taskID) +} diff --git a/apps/backend/internal/azuredevops/service_task_pr_test.go b/apps/backend/internal/azuredevops/service_task_pr_test.go new file mode 100644 index 0000000000..e931e9ae0b --- /dev/null +++ b/apps/backend/internal/azuredevops/service_task_pr_test.go @@ -0,0 +1,243 @@ +package azuredevops + +import ( + "context" + "errors" + "testing" +) + +type taskPRServiceContract interface { + SetRepositoryLookup(RepositoryLookup) + AssociateTaskPR(context.Context, string, string, string, int) (*TaskPR, error) + SyncTaskPR(context.Context, string, string, string, int) (*TaskPR, error) +} + +type fakeAzureRepositoryLookup struct { + binding *RepositoryBinding + err error +} + +func (f fakeAzureRepositoryLookup) LookupTaskRepository( + context.Context, + string, + string, +) (*RepositoryBinding, error) { + return f.binding, f.err +} + +type taskPRClient struct { + invalidClient + pr *PullRequest + reviewers []Reviewer + policies []PolicyEvaluation + getCalls int +} + +func (c *taskPRClient) GetPullRequest(context.Context, string, string, int) (*PullRequest, error) { + c.getCalls++ + if c.pr == nil { + return nil, errors.New("pull request not found") + } + copy := *c.pr + return ©, nil +} + +func (c *taskPRClient) ListReviewers(context.Context, string, string, int) ([]Reviewer, error) { + return c.reviewers, nil +} + +func (c *taskPRClient) ListThreads(context.Context, string, string, int) ([]Thread, error) { + return []Thread{}, nil +} + +func (c *taskPRClient) ListLinkedWorkItems(context.Context, string, string, int) ([]WorkItemRef, error) { + return []WorkItemRef{}, nil +} + +func (c *taskPRClient) ListPolicyEvaluations(context.Context, string, int) ([]PolicyEvaluation, error) { + return c.policies, nil +} + +func TestServiceAssociateTaskPRValidatesRepositoryOwnershipAndProvider(t *testing.T) { + tests := []struct { + name string + workspace string + binding *RepositoryBinding + }{ + {name: "repository is not linked to task", workspace: "ws-a"}, + {name: "repository belongs to another workspace", workspace: "ws-a", binding: validAzureBinding("ws-b")}, + {name: "repository uses another provider", workspace: "ws-a", binding: &RepositoryBinding{ + WorkspaceID: "ws-a", Provider: "github", ProviderOwner: "project-1", ProviderRepoID: "azure-repo-1", + }}, + {name: "repository has no project ID", workspace: "ws-a", binding: &RepositoryBinding{ + WorkspaceID: "ws-a", Provider: RepositoryProvider, ProviderRepoID: "azure-repo-1", + }}, + {name: "repository has no Azure repository ID", workspace: "ws-a", binding: &RepositoryBinding{ + WorkspaceID: "ws-a", Provider: RepositoryProvider, ProviderOwner: "project-1", + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, client := newTaskPRServiceFixture(t) + contract, ok := any(svc).(taskPRServiceContract) + if !ok { + t.Fatal("Service does not implement the task PR association contract") + } + contract.SetRepositoryLookup(fakeAzureRepositoryLookup{binding: tt.binding}) + if _, err := contract.AssociateTaskPR(t.Context(), tt.workspace, "task-1", "repo-1", 42); err == nil { + t.Fatal("association unexpectedly succeeded") + } + if client.getCalls != 0 { + t.Fatalf("Azure API called before repository validation: %d", client.getCalls) + } + }) + } +} + +func TestServiceAssociateTaskPRValidatesAzureIdentity(t *testing.T) { + tests := []struct { + name string + projectID string + repoID string + }{ + {name: "another project", projectID: "project-2", repoID: "azure-repo-1"}, + {name: "another repository", projectID: "project-1", repoID: "azure-repo-2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, client := newTaskPRServiceFixture(t) + svc.SetRepositoryLookup(fakeAzureRepositoryLookup{binding: validAzureBinding("ws-a")}) + client.pr.ProjectID = tt.projectID + client.pr.RepositoryID = tt.repoID + + if _, err := svc.AssociateTaskPR(t.Context(), "ws-a", "task-1", "repo-1", 42); !errors.Is(err, ErrInvalidTaskPRAssociation) { + t.Fatalf("error = %v, want invalid task PR association", err) + } + rows, err := svc.Store().ListTaskPRsByTask(t.Context(), "task-1") + if err != nil || len(rows) != 0 { + t.Fatalf("persisted mismatched association: rows=%+v err=%v", rows, err) + } + }) + } +} + +func TestServiceAssociateAndSyncTaskPRRefreshesPersistedSummary(t *testing.T) { + svc, client := newTaskPRServiceFixture(t) + contract, ok := any(svc).(taskPRServiceContract) + if !ok { + t.Fatal("Service does not implement the task PR association contract") + } + contract.SetRepositoryLookup(fakeAzureRepositoryLookup{binding: validAzureBinding("ws-a")}) + + created, err := contract.AssociateTaskPR(t.Context(), "ws-a", "task-1", "repo-1", 42) + if err != nil { + t.Fatalf("associate task PR: %v", err) + } + if created.OrganizationURL != "https://dev.azure.com/acme" || created.ProjectID != "project-1" || + created.AzureRepositoryID != "azure-repo-1" || created.SourceBranch != "feature/azure" || + created.TargetBranch != "main" || created.ReviewState != "waiting" || created.PolicyState != "pending" { + t.Fatalf("created association = %+v", created) + } + + client.pr.Title = "Updated upstream title" + client.reviewers = []Reviewer{{Identity: Identity{ID: "reviewer-1"}, Vote: 10}} + client.policies = []PolicyEvaluation{{ID: "policy-1", Status: "approved", IsBlocking: true}} + refreshed, err := contract.SyncTaskPR(t.Context(), "ws-a", "task-1", "repo-1", 42) + if err != nil { + t.Fatalf("sync task PR: %v", err) + } + if refreshed.ID != created.ID || refreshed.Title != "Updated upstream title" || + refreshed.ReviewState != "approved" || refreshed.PolicyState != "success" { + t.Fatalf("refreshed association = %+v", refreshed) + } + rows, err := svc.Store().ListTaskPRsByTask(t.Context(), "task-1") + if err != nil || len(rows) != 1 || rows[0].ID != created.ID { + t.Fatalf("persisted associations: rows=%+v err=%v", rows, err) + } +} + +func TestSummarizeReviewStateUsesDeterministicPrecedence(t *testing.T) { + tests := []struct { + name string + votes []int + expected string + }{ + {name: "rejection after waiting", votes: []int{-5, -10}, expected: "rejected"}, + {name: "rejection before waiting", votes: []int{-10, -5}, expected: "rejected"}, + {name: "waiting beats approval", votes: []int{10, -5}, expected: "waiting"}, + {name: "approval", votes: []int{5, 10}, expected: "approved"}, + {name: "no vote", votes: []int{0}, expected: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reviewers := make([]Reviewer, 0, len(tt.votes)) + for _, vote := range tt.votes { + reviewers = append(reviewers, Reviewer{Vote: vote}) + } + if got := summarizeReviewState(reviewers); got != tt.expected { + t.Fatalf("summarizeReviewState(%v) = %q, want %q", tt.votes, got, tt.expected) + } + }) + } +} + +func TestSummarizePolicyStateIgnoresOptionalFailures(t *testing.T) { + tests := []struct { + name string + policies []PolicyEvaluation + expected string + }{ + {name: "no policies", expected: ""}, + {name: "blocking failure", policies: []PolicyEvaluation{{Status: "rejected", IsBlocking: true}}, expected: "failure"}, + {name: "optional failure", policies: []PolicyEvaluation{{Status: "broken"}}, expected: "success"}, + { + name: "optional failure with blocking pending", + policies: []PolicyEvaluation{ + {Status: "rejected"}, + {Status: "running", IsBlocking: true}, + }, + expected: "pending", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := summarizePolicyState(tt.policies); got != tt.expected { + t.Fatalf("summarizePolicyState(%+v) = %q, want %q", tt.policies, got, tt.expected) + } + }) + } +} + +func newTaskPRServiceFixture(t *testing.T) (*Service, *taskPRClient) { + t.Helper() + db := newTestDB(t) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + client := &taskPRClient{ + pr: &PullRequest{ + ID: 42, Title: "Initial upstream title", Status: "active", IsDraft: true, + SourceBranch: "refs/heads/feature/azure", TargetBranch: "refs/heads/main", + Author: Identity{ID: "author-1", DisplayName: "Ada"}, + ProjectID: "project-1", RepositoryID: "azure-repo-1", + WebURL: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + reviewers: []Reviewer{{Identity: Identity{ID: "reviewer-1"}, Vote: -5}}, + policies: []PolicyEvaluation{{ID: "policy-1", Status: "running", IsBlocking: true}}, + } + svc := NewService(store, newFakeSecretStore(), func(*Config, string) Client { return client }, nil) + if _, err := svc.SetConfigForWorkspace(t.Context(), "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "pat", + }); err != nil { + t.Fatalf("set config: %v", err) + } + return svc, client +} + +func validAzureBinding(workspaceID string) *RepositoryBinding { + return &RepositoryBinding{ + WorkspaceID: workspaceID, Provider: RepositoryProvider, + ProviderOwner: "project-1", ProviderRepoID: "azure-repo-1", + } +} diff --git a/apps/backend/internal/azuredevops/service_test.go b/apps/backend/internal/azuredevops/service_test.go new file mode 100644 index 0000000000..510b63dbd4 --- /dev/null +++ b/apps/backend/internal/azuredevops/service_test.go @@ -0,0 +1,435 @@ +package azuredevops + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +type fakeSecretStore struct { + mu sync.Mutex + values map[string]string + setFailures int + deleteFailures int +} + +func newFakeSecretStore() *fakeSecretStore { + return &fakeSecretStore{values: make(map[string]string)} +} + +func (f *fakeSecretStore) Reveal(_ context.Context, id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + value, ok := f.values[id] + if !ok { + return "", errors.New("secret not found") + } + return value, nil +} + +func (f *fakeSecretStore) Set(_ context.Context, id, _ string, value string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.setFailures > 0 { + f.setFailures-- + return errors.New("injected secret set failure") + } + f.values[id] = value + return nil +} + +func (f *fakeSecretStore) Delete(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.deleteFailures > 0 { + f.deleteFailures-- + return errors.New("injected secret delete failure") + } + delete(f.values, id) + return nil +} + +func (f *fakeSecretStore) Exists(_ context.Context, id string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.values[id] + return ok, nil +} + +type fakeClient struct { + invalidClient + result *TestConnectionResult + err error +} + +func (f *fakeClient) TestAuth(context.Context) (*TestConnectionResult, error) { + return f.result, f.err +} + +type capturedCredentials struct { + config Config + pat string +} + +func newTestService(t *testing.T, factory ClientFactory) (*Service, *Store, *fakeSecretStore) { + t.Helper() + db := newTestDB(t) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + secrets := newFakeSecretStore() + return NewService(store, secrets, factory, logger.Default()), store, secrets +} + +func TestOrganizationURLValidation(t *testing.T) { + valid := []string{ + "https://dev.azure.com/acme", + "https://dev.azure.com/team-42", + } + for _, raw := range valid { + if got, err := ValidateOrganizationURL(raw); err != nil || got != raw { + t.Errorf("ValidateOrganizationURL(%q) = %q, %v", raw, got, err) + } + } + if got, err := ValidateOrganizationURL("https://dev.azure.com/acme/"); err != nil || got != "https://dev.azure.com/acme" { + t.Errorf("ValidateOrganizationURL() = %q, %v, want canonical URL", got, err) + } + invalid := []string{ + "", "http://dev.azure.com/acme", "https://example.com/acme", + "https://dev.azure.com", "https://dev.azure.com/acme/project", + "https://user@dev.azure.com/acme", "https://dev.azure.com:443/acme", + "https://dev.azure.com/acme?x=1", "https://dev.azure.com/acme#fragment", + "https://dev.azure.com/-acme", "https://dev.azure.com/acme_works", + } + for _, raw := range invalid { + if _, err := ValidateOrganizationURL(raw); err == nil { + t.Errorf("ValidateOrganizationURL(%q) unexpectedly succeeded", raw) + } + } +} + +func TestListBranchesRejectsOrganizationMismatch(t *testing.T) { + t.Parallel() + clientCalls := 0 + service, _, _ := newTestService(t, func(*Config, string) Client { + clientCalls++ + return &invalidClient{} + }) + if _, err := service.SetConfigForWorkspace(t.Context(), "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", + PAT: "secret", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + _, err := service.ListBranchesForWorkspace(t.Context(), "ws-a", "other", "project", "repo") + if !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("ListBranchesForWorkspace() error = %v, want ErrInvalidConfig", err) + } + if clientCalls != 0 { + t.Fatalf("client factory called %d times for mismatched organization", clientCalls) + } +} + +func TestConfigWorkspaceIsolationAndReconstruction(t *testing.T) { + svc, store, secrets := newTestService(t, nil) + ctx := context.Background() + for _, tc := range []struct{ workspace, org, pat string }{ + {"ws-a", "https://dev.azure.com/acme", "pat-a"}, + {"ws-b", "https://dev.azure.com/other", "pat-b"}, + } { + if _, err := svc.SetConfigForWorkspace(ctx, tc.workspace, &SetConfigRequest{ + OrganizationURL: tc.org, PAT: tc.pat, + }); err != nil { + t.Fatalf("set %s: %v", tc.workspace, err) + } + } + reconstructed := NewService(store, secrets, nil, logger.Default()) + for _, tc := range []struct{ workspace, org, pat string }{ + {"ws-a", "https://dev.azure.com/acme", "pat-a"}, + {"ws-b", "https://dev.azure.com/other", "pat-b"}, + } { + cfg, err := reconstructed.GetConfigForWorkspace(ctx, tc.workspace) + if err != nil || cfg == nil || cfg.OrganizationURL != tc.org || !cfg.HasSecret { + t.Fatalf("get %s: cfg=%+v err=%v", tc.workspace, cfg, err) + } + gotPAT, err := secrets.Reveal(ctx, SecretKeyForWorkspace(tc.workspace)) + if err != nil || gotPAT != tc.pat { + t.Fatalf("secret %s: value=%q err=%v", tc.workspace, gotPAT, err) + } + } +} + +func TestConfigTestUsesSubmittedAndStoredCredentialsWithoutPersistence(t *testing.T) { + var captures []capturedCredentials + factory := func(cfg *Config, pat string) Client { + captures = append(captures, capturedCredentials{config: *cfg, pat: pat}) + return &fakeClient{result: &TestConnectionResult{OK: true, DisplayName: "Alice"}} + } + svc, _, _ := newTestService(t, factory) + ctx := context.Background() + res, err := svc.TestConnectionForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "submitted", + }) + if err != nil || !res.OK { + t.Fatalf("submitted probe: result=%+v err=%v", res, err) + } + if cfg, _ := svc.GetConfigForWorkspace(ctx, "ws-a"); cfg != nil { + t.Fatalf("test connection persisted config: %+v", cfg) + } + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "stored", + }); err != nil { + t.Fatalf("save config: %v", err) + } + res, err = svc.TestConnectionForWorkspace(ctx, "ws-a", &SetConfigRequest{}) + 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" { + t.Fatalf("captured credentials: %+v", captures) + } +} + +func TestCopyConfigCopiesCredentialAndDeleteIsScoped(t *testing.T) { + svc, store, secrets := newTestService(t, nil) + ctx := context.Background() + if _, err := svc.SetConfigForWorkspace(ctx, "source", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", DefaultProjectID: "p1", + DefaultProjectName: "Platform", PAT: "source-pat", + }); err != nil { + t.Fatalf("set source: %v", err) + } + if _, err := svc.SetConfigForWorkspace(ctx, "target", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/old", PAT: "old-pat", + }); err != nil { + t.Fatalf("set target: %v", err) + } + if err := store.UpdateAuthHealth(ctx, "target", true, "", time.Now().UTC()); err != nil { + t.Fatalf("set target health: %v", err) + } + if _, err := svc.CopyConfigToWorkspace(ctx, "source", "target"); err != nil { + t.Fatalf("copy: %v", err) + } + target, err := svc.GetConfigForWorkspace(ctx, "target") + if err != nil || target == nil || target.DefaultProjectID != "p1" || !target.HasSecret { + t.Fatalf("target config: %+v err=%v", target, err) + } + if target.LastCheckedAt != nil || target.LastOK || target.LastError != "" { + t.Fatalf("copied config inherited health state: %+v", target) + } + if pat, _ := secrets.Reveal(ctx, SecretKeyForWorkspace("target")); pat != "source-pat" { + t.Fatalf("target PAT = %q", pat) + } + if err := svc.DeleteConfigForWorkspace(ctx, "target"); err != nil { + t.Fatalf("delete target: %v", err) + } + if source, _ := svc.GetConfigForWorkspace(ctx, "source"); source == nil { + t.Fatal("deleting target removed source config") + } + if exists, _ := secrets.Exists(ctx, SecretKeyForWorkspace("target")); exists { + t.Fatal("target secret still exists after delete") + } +} + +func TestConfigRecordAuthHealthPersistsPerWorkspace(t *testing.T) { + clients := map[string]*fakeClient{ + "acme": {result: &TestConnectionResult{OK: true}}, + "other": {result: &TestConnectionResult{OK: false, Error: "401 unauthorized"}}, + } + factory := func(cfg *Config, _ string) Client { + org := cfg.OrganizationURL[len("https://dev.azure.com/"):] + return clients[org] + } + svc, _, _ := newTestService(t, factory) + ctx := context.Background() + for _, tc := range []struct{ workspace, org string }{ + {"ws-a", "acme"}, {"ws-b", "other"}, + } { + if _, err := svc.SetConfigForWorkspace(ctx, tc.workspace, &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/" + tc.org, PAT: "pat", + }); err != nil { + t.Fatalf("set %s: %v", tc.workspace, err) + } + } + svc.RecordAuthHealth(ctx) + a, _ := svc.GetConfigForWorkspace(ctx, "ws-a") + b, _ := svc.GetConfigForWorkspace(ctx, "ws-b") + if !a.LastOK || a.LastCheckedAt == nil || b.LastOK || b.LastError != "401 unauthorized" { + t.Fatalf("health rows: a=%+v b=%+v", a, b) + } +} + +func TestSetConfigCompensatesSecretFailure(t *testing.T) { + svc, store, secrets := newTestService(t, nil) + ctx := context.Background() + secrets.setFailures = 1 + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "new-pat", + }); err == nil { + t.Fatal("set config unexpectedly succeeded") + } + config, err := store.GetConfig(ctx, "ws-a") + if err != nil || config != nil { + t.Fatalf("config after secret failure = %+v, err = %v", config, err) + } + if exists, _ := secrets.Exists(ctx, SecretKeyForWorkspace("ws-a")); exists { + t.Fatal("secret remains after failed config creation") + } +} + +func TestSetConfigKeepsPreviousConfigWhenSecretReplacementFails(t *testing.T) { + svc, store, secrets := newTestService(t, nil) + ctx := context.Background() + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/old", PAT: "old-pat", + }); err != nil { + t.Fatalf("seed config: %v", err) + } + secrets.setFailures = 1 + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/new", PAT: "new-pat", + }); err == nil { + t.Fatal("replace config unexpectedly succeeded") + } + config, err := store.GetConfig(ctx, "ws-a") + if err != nil || config.OrganizationURL != "https://dev.azure.com/old" { + t.Fatalf("config after secret replacement failure = %+v, err = %v", config, err) + } + if pat, _ := secrets.Reveal(ctx, SecretKeyForWorkspace("ws-a")); pat != "old-pat" { + t.Fatalf("PAT after replacement failure = %q", pat) + } +} + +func TestSetConfigRestoresPATWhenStoreFails(t *testing.T) { + svc, store, secrets := newTestService(t, nil) + ctx := context.Background() + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "old-pat", + }); err != nil { + t.Fatalf("seed config: %v", err) + } + if err := store.db.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "new-pat", + }); err == nil { + t.Fatal("set config unexpectedly succeeded") + } + if pat, _ := secrets.Reveal(ctx, SecretKeyForWorkspace("ws-a")); pat != "old-pat" { + t.Fatalf("PAT after store failure = %q", pat) + } +} + +func TestSetConfigResetsHealthOnlyWhenCredentialsChange(t *testing.T) { + svc, store, _ := newTestService(t, nil) + ctx := context.Background() + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "old-pat", + }); err != nil { + t.Fatalf("seed config: %v", err) + } + setHealthy := func() { + t.Helper() + if err := store.UpdateAuthHealth(ctx, "ws-a", true, "", time.Now().UTC()); err != nil { + t.Fatalf("set auth health: %v", err) + } + } + assertHealth := func(wantChecked, wantOK bool) { + t.Helper() + config, err := svc.GetConfigForWorkspace(ctx, "ws-a") + if err != nil { + t.Fatalf("get config: %v", err) + } + if (config.LastCheckedAt != nil) != wantChecked || config.LastOK != wantOK { + t.Fatalf("health = %+v, want checked=%t ok=%t", config, wantChecked, wantOK) + } + } + + setHealthy() + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", DefaultProjectID: "project-a", + }); err != nil { + t.Fatalf("update project: %v", err) + } + assertHealth(true, true) + + 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) + + setHealthy() + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/other", PAT: "new-pat", + }); err != nil { + t.Fatalf("update PAT: %v", err) + } + assertHealth(false, false) +} + +func TestDeleteConfigLeavesStateWhenSecretDeleteFails(t *testing.T) { + svc, store, secrets := newTestService(t, nil) + ctx := context.Background() + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "pat", + }); err != nil { + t.Fatalf("seed config: %v", err) + } + secrets.deleteFailures = 1 + if err := svc.DeleteConfigForWorkspace(ctx, "ws-a"); err == nil { + t.Fatal("delete config unexpectedly succeeded") + } + config, err := store.GetConfig(ctx, "ws-a") + if err != nil || config == nil { + t.Fatalf("config after secret delete failure = %+v, err = %v", config, err) + } + if pat, _ := secrets.Reveal(ctx, SecretKeyForWorkspace("ws-a")); pat != "pat" { + t.Fatalf("PAT after failed deletion = %q", pat) + } +} + +func TestDeleteConfigRestoresPATWhenStoreFails(t *testing.T) { + svc, store, secrets := newTestService(t, nil) + ctx := context.Background() + if _, err := svc.SetConfigForWorkspace(ctx, "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "pat", + }); err != nil { + t.Fatalf("seed config: %v", err) + } + if err := store.db.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + if err := svc.DeleteConfigForWorkspace(ctx, "ws-a"); err == nil { + t.Fatal("delete config unexpectedly succeeded") + } + if pat, _ := secrets.Reveal(ctx, SecretKeyForWorkspace("ws-a")); pat != "pat" { + t.Fatalf("PAT after store deletion failure = %q", pat) + } +} + +func TestRecordAuthHealthIgnoresParentCancellation(t *testing.T) { + svc, _, _ := newTestService(t, func(*Config, string) Client { + return &fakeClient{err: context.Canceled} + }) + if _, err := svc.SetConfigForWorkspace(t.Context(), "ws-a", &SetConfigRequest{ + OrganizationURL: "https://dev.azure.com/acme", PAT: "pat", + }); err != nil { + t.Fatalf("seed config: %v", err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + svc.RecordAuthHealthForWorkspace(ctx, "ws-a") + config, err := svc.GetConfigForWorkspace(t.Context(), "ws-a") + if err != nil || config.LastCheckedAt != nil { + t.Fatalf("health after cancellation = %+v, err = %v", config, err) + } +} diff --git a/apps/backend/internal/azuredevops/store.go b/apps/backend/internal/azuredevops/store.go new file mode 100644 index 0000000000..f5aad0de14 --- /dev/null +++ b/apps/backend/internal/azuredevops/store.go @@ -0,0 +1,239 @@ +package azuredevops + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Store persists Azure DevOps configuration and health, never credentials. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +const createConfigTableSQL = ` + CREATE TABLE IF NOT EXISTS 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 '[]', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )` + +const createTaskPRTableSQL = ` + CREATE TABLE IF NOT EXISTS azure_devops_task_prs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + organization_url 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, + title TEXT NOT NULL, + source_branch TEXT NOT NULL, + target_branch TEXT NOT NULL, + author_id TEXT NOT NULL, + author_name TEXT NOT NULL, + status TEXT NOT NULL, + review_state TEXT NOT NULL DEFAULT '', + policy_state TEXT NOT NULL DEFAULT '', + is_draft BOOLEAN NOT NULL DEFAULT 0, + last_synced_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + 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)` + +const selectConfigColumns = `workspace_id, organization_url, default_project_id, + default_project_name, auth_method, last_checked_at, last_ok, last_error, + created_at, updated_at, saved_views` + +// NewStore creates the store and initializes its replay-safe schema. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + if writer == nil { + return nil, errors.New("azure devops store: writer is required") + } + if reader == nil { + reader = writer + } + store := &Store{db: writer, ro: reader} + if _, err := store.db.Exec(createConfigTableSQL); err != nil { + return nil, fmt.Errorf("azure devops schema init: %w", err) + } + if err := store.ensureSavedViewsColumn(); err != nil { + return nil, fmt.Errorf("azure devops saved views schema init: %w", err) + } + if _, err := store.db.Exec(createTaskPRTableSQL); err != nil { + return nil, fmt.Errorf("azure devops task PR schema init: %w", err) + } + return store, nil +} + +func (s *Store) ensureSavedViewsColumn() error { + rows, err := s.db.Query(`PRAGMA table_info(azure_devops_configs)`) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var cid int + var name, columnType string + var notNull, primaryKey int + var defaultValue any + if scanErr := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); scanErr != nil { + return scanErr + } + if name == "saved_views" { + 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 '[]'`) + return err +} + +// GetConfig returns a workspace's configuration, or nil when none exists. +func (s *Store) GetConfig(ctx context.Context, workspaceID string) (*Config, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return nil, err + } + var cfg Config + err := s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM azure_devops_configs WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfig inserts or updates non-secret workspace configuration. Existing +// health fields are preserved until the next explicit authentication probe. +func (s *Store) UpsertConfig(ctx context.Context, cfg *Config) error { + if cfg == nil { + return errors.New("azure devops store: config is required") + } + if err := validateWorkspaceID(cfg.WorkspaceID); err != nil { + return err + } + now := time.Now().UTC() + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO azure_devops_configs ( + workspace_id, organization_url, default_project_id, default_project_name, + auth_method, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + organization_url = excluded.organization_url, + default_project_id = excluded.default_project_id, + default_project_name = excluded.default_project_name, + auth_method = excluded.auth_method, + updated_at = excluded.updated_at`, + cfg.WorkspaceID, cfg.OrganizationURL, cfg.DefaultProjectID, + cfg.DefaultProjectName, cfg.AuthMethod, cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// DeleteConfig removes one workspace's configuration row. +func (s *Store) DeleteConfig(ctx context.Context, workspaceID string) error { + if err := validateWorkspaceID(workspaceID); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, + `DELETE FROM azure_devops_configs WHERE workspace_id = ?`, workspaceID) + return err +} + +// ListConfigWorkspaceIDs returns all configured workspace IDs in stable order. +func (s *Store) ListConfigWorkspaceIDs(ctx context.Context) ([]string, error) { + var workspaceIDs []string + if err := s.ro.SelectContext(ctx, &workspaceIDs, + `SELECT workspace_id FROM azure_devops_configs ORDER BY workspace_id`); err != nil { + return nil, err + } + return workspaceIDs, nil +} + +// UpdateAuthHealth persists an authentication probe outcome for one workspace. +func (s *Store) UpdateAuthHealth( + ctx context.Context, + workspaceID string, + ok bool, + errMsg string, + checkedAt time.Time, +) error { + if err := validateWorkspaceID(workspaceID); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, ` + UPDATE azure_devops_configs + SET last_checked_at = ?, last_ok = ?, last_error = ? + WHERE workspace_id = ?`, checkedAt, ok, errMsg, workspaceID) + return err +} + +// ResetAuthHealth marks a workspace configuration as not yet checked. +func (s *Store) ResetAuthHealth(ctx context.Context, workspaceID string) error { + if err := validateWorkspaceID(workspaceID); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, ` + UPDATE azure_devops_configs + SET last_checked_at = NULL, last_ok = 0, last_error = '' + WHERE workspace_id = ?`, workspaceID) + return err +} + +func (s *Store) GetSavedViewsJSON(ctx context.Context, workspaceID string) (string, error) { + if err := validateWorkspaceID(workspaceID); err != nil { + return "", err + } + var raw string + err := s.ro.GetContext(ctx, &raw, + `SELECT saved_views FROM azure_devops_configs WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotConfigured + } + return raw, err +} + +func (s *Store) PutSavedViewsJSON(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 saved_views = ?, updated_at = ? + WHERE workspace_id = ?`, raw, time.Now().UTC(), workspaceID) + if err != nil { + return err + } + updated, err := result.RowsAffected() + if err != nil { + return err + } + if updated == 0 { + return ErrNotConfigured + } + return nil +} diff --git a/apps/backend/internal/azuredevops/store_task_pr.go b/apps/backend/internal/azuredevops/store_task_pr.go new file mode 100644 index 0000000000..77100eb103 --- /dev/null +++ b/apps/backend/internal/azuredevops/store_task_pr.go @@ -0,0 +1,117 @@ +package azuredevops + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +const taskPRSelectColumns = `id, task_id, repository_id, organization_url, + project_id, azure_repository_id, pull_request_id, pull_request_url, title, + source_branch, target_branch, author_id, author_name, status, review_state, + policy_state, is_draft, last_synced_at, created_at, updated_at` + +const qualifiedTaskPRSelectColumns = `atp.id, atp.task_id, atp.repository_id, + atp.organization_url, atp.project_id, atp.azure_repository_id, + atp.pull_request_id, atp.pull_request_url, atp.title, atp.source_branch, + atp.target_branch, atp.author_id, atp.author_name, atp.status, + atp.review_state, atp.policy_state, atp.is_draft, atp.last_synced_at, + atp.created_at, atp.updated_at` + +// UpsertTaskPR persists one task-to-pull-request association while retaining +// its stable ID and creation timestamp across refreshes. +func (s *Store) UpsertTaskPR(ctx context.Context, taskPR *TaskPR) error { + if taskPR == nil { + return errors.New("azure devops store: task PR is required") + } + now := time.Now().UTC() + taskPR.UpdatedAt = now + if taskPR.ID == "" { + taskPR.ID = uuid.NewString() + } + if taskPR.CreatedAt.IsZero() { + taskPR.CreatedAt = now + } + query, args, err := sqlx.Named(` + INSERT INTO azure_devops_task_prs ( + id, task_id, repository_id, organization_url, project_id, + azure_repository_id, pull_request_id, pull_request_url, title, + source_branch, target_branch, author_id, author_name, status, + review_state, policy_state, is_draft, last_synced_at, created_at, updated_at + ) VALUES ( + :id, :task_id, :repository_id, :organization_url, :project_id, + :azure_repository_id, :pull_request_id, :pull_request_url, :title, + :source_branch, :target_branch, :author_id, :author_name, :status, + :review_state, :policy_state, :is_draft, :last_synced_at, :created_at, :updated_at + ) + ON CONFLICT(task_id, repository_id, azure_repository_id, pull_request_id) + DO UPDATE SET + organization_url = excluded.organization_url, + project_id = excluded.project_id, + pull_request_url = excluded.pull_request_url, + title = excluded.title, + source_branch = excluded.source_branch, + target_branch = excluded.target_branch, + author_id = excluded.author_id, + author_name = excluded.author_name, + status = excluded.status, + review_state = excluded.review_state, + policy_state = excluded.policy_state, + is_draft = excluded.is_draft, + last_synced_at = excluded.last_synced_at, + updated_at = excluded.updated_at + RETURNING id, created_at`, taskPR) + if err != nil { + return err + } + query = s.db.Rebind(query) + return s.db.QueryRowxContext(ctx, query, args...).Scan(&taskPR.ID, &taskPR.CreatedAt) +} + +// ListTaskPRsByTask returns all associations for one task in creation order. +func (s *Store) ListTaskPRsByTask(ctx context.Context, taskID string) ([]*TaskPR, error) { + var rows []TaskPR + if err := s.ro.SelectContext(ctx, &rows, + `SELECT `+taskPRSelectColumns+` FROM azure_devops_task_prs + WHERE task_id = ? ORDER BY created_at ASC`, taskID); err != nil { + return nil, err + } + return taskPRPointers(rows), nil +} + +// DeleteTaskPRsByTask removes every Azure pull request association owned by a task. +func (s *Store) DeleteTaskPRsByTask(ctx context.Context, taskID string) error { + if taskID == "" { + return errors.New("azure devops store: task id is required") + } + query := s.db.Rebind(`DELETE FROM azure_devops_task_prs WHERE task_id = ?`) + _, err := s.db.ExecContext(ctx, query, taskID) + return err +} + +// ListTaskPRsByWorkspace groups associations for tasks owned by one workspace. +func (s *Store) ListTaskPRsByWorkspace(ctx context.Context, workspaceID string) (map[string][]*TaskPR, error) { + var rows []TaskPR + if err := s.ro.SelectContext(ctx, &rows, + `SELECT `+qualifiedTaskPRSelectColumns+` FROM azure_devops_task_prs atp + INNER JOIN tasks t ON atp.task_id = t.id + WHERE t.workspace_id = ? ORDER BY atp.created_at ASC`, workspaceID); err != nil { + return nil, err + } + grouped := make(map[string][]*TaskPR) + for i := range rows { + grouped[rows[i].TaskID] = append(grouped[rows[i].TaskID], &rows[i]) + } + return grouped, nil +} + +func taskPRPointers(rows []TaskPR) []*TaskPR { + result := make([]*TaskPR, 0, len(rows)) + for i := range rows { + result = append(result, &rows[i]) + } + return result +} diff --git a/apps/backend/internal/azuredevops/store_test.go b/apps/backend/internal/azuredevops/store_test.go new file mode 100644 index 0000000000..c03b464ace --- /dev/null +++ b/apps/backend/internal/azuredevops/store_test.go @@ -0,0 +1,283 @@ +package azuredevops + +import ( + "context" + "database/sql" + "fmt" + "sync" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestDB(t *testing.T) *sqlx.DB { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestStoreConfigRoundTripAndHealth(t *testing.T) { + db := newTestDB(t) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + ctx := context.Background() + cfg := &Config{ + WorkspaceID: "ws-a", + OrganizationURL: "https://dev.azure.com/acme", + DefaultProjectID: "project-guid", + DefaultProjectName: "Platform", + AuthMethod: AuthMethodPAT, + } + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("upsert: %v", err) + } + checkedAt := time.Now().UTC().Truncate(time.Millisecond) + if err := store.UpdateAuthHealth(ctx, "ws-a", true, "", checkedAt); err != nil { + t.Fatalf("update health: %v", err) + } + got, err := store.GetConfig(ctx, "ws-a") + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil || got.OrganizationURL != cfg.OrganizationURL || got.DefaultProjectID != cfg.DefaultProjectID { + t.Fatalf("round trip mismatch: got %+v want %+v", got, cfg) + } + if !got.LastOK || got.LastCheckedAt == nil || got.LastError != "" { + t.Fatalf("health not persisted: %+v", got) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Fatalf("timestamps not populated: %+v", got) + } + ids, err := store.ListConfigWorkspaceIDs(ctx) + if err != nil { + t.Fatalf("list workspace ids: %v", err) + } + if len(ids) != 1 || ids[0] != "ws-a" { + t.Fatalf("workspace ids = %v", ids) + } +} + +func TestStoreConfigReplayAndDelete(t *testing.T) { + db := newTestDB(t) + first, err := NewStore(db, db) + if err != nil { + t.Fatalf("first store: %v", err) + } + if err := first.UpsertConfig(context.Background(), &Config{ + WorkspaceID: "ws-a", OrganizationURL: "https://dev.azure.com/acme", AuthMethod: AuthMethodPAT, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + second, err := NewStore(db, db) + if err != nil { + t.Fatalf("replayed store: %v", err) + } + if err := second.DeleteConfig(context.Background(), "ws-a"); err != nil { + t.Fatalf("delete: %v", err) + } + got, err := second.GetConfig(context.Background(), "ws-a") + if err != nil || got != nil { + t.Fatalf("after delete got=%+v err=%v", got, err) + } +} + +func TestStoreTaskPRSchemaAndReplay(t *testing.T) { + db := newTestDB(t) + if _, err := NewStore(db, db); err != nil { + t.Fatalf("first store: %v", err) + } + var tableName string + if err := db.Get(&tableName, `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'azure_devops_task_prs'`); err != nil { + t.Fatalf("task PR table missing: %v", err) + } + if tableName != "azure_devops_task_prs" { + t.Fatalf("task PR table = %q", tableName) + } + if _, err := NewStore(db, db); err != nil { + t.Fatalf("replayed store: %v", err) + } +} + +type taskPRStoreContract interface { + UpsertTaskPR(context.Context, *TaskPR) error + ListTaskPRsByTask(context.Context, string) ([]*TaskPR, error) + ListTaskPRsByWorkspace(context.Context, string) (map[string][]*TaskPR, error) + DeleteTaskPRsByTask(context.Context, string) error +} + +func TestStoreDeleteTaskPRsByTaskIsScoped(t *testing.T) { + store, err := NewStore(newTestDB(t), nil) + if err != nil { + t.Fatalf("new store: %v", err) + } + ctx := context.Background() + for _, row := range []*TaskPR{ + testTaskPR("task-a", "repo-a", 1), + testTaskPR("task-b", "repo-b", 2), + } { + if err := store.UpsertTaskPR(ctx, row); err != nil { + t.Fatalf("upsert task PR: %v", err) + } + } + if err := store.DeleteTaskPRsByTask(ctx, "task-a"); err != nil { + t.Fatalf("delete task PRs: %v", err) + } + deleted, err := store.ListTaskPRsByTask(ctx, "task-a") + if err != nil || len(deleted) != 0 { + t.Fatalf("deleted task rows = %+v, err = %v", deleted, err) + } + remaining, err := store.ListTaskPRsByTask(ctx, "task-b") + if err != nil || len(remaining) != 1 { + t.Fatalf("remaining task rows = %+v, err = %v", remaining, err) + } +} + +func TestStoreTaskPRUpsertAndRestartPersistence(t *testing.T) { + db := newTestDB(t) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + contract, ok := any(store).(taskPRStoreContract) + if !ok { + t.Fatal("Store does not implement the task PR persistence contract") + } + ctx := context.Background() + first := testTaskPR("task-a", "repo-a", 42) + if err := contract.UpsertTaskPR(ctx, first); err != nil { + t.Fatalf("first upsert: %v", err) + } + firstID, firstCreatedAt := first.ID, first.CreatedAt + first.Title = "Updated title" + first.ReviewState = "approved" + if err := contract.UpsertTaskPR(ctx, first); err != nil { + t.Fatalf("second upsert: %v", err) + } + + replayed, err := NewStore(db, db) + if err != nil { + t.Fatalf("replayed store: %v", err) + } + replayedContract, ok := any(replayed).(taskPRStoreContract) + if !ok { + t.Fatal("replayed Store does not implement the task PR persistence contract") + } + rows, err := replayedContract.ListTaskPRsByTask(ctx, "task-a") + if err != nil { + t.Fatalf("list by task: %v", err) + } + if len(rows) != 1 || rows[0].ID != firstID || !rows[0].CreatedAt.Equal(firstCreatedAt) { + t.Fatalf("upsert changed stable identity: %+v", rows) + } + if rows[0].Title != "Updated title" || rows[0].ReviewState != "approved" { + t.Fatalf("mutable fields were not refreshed: %+v", rows[0]) + } +} + +func TestStoreTaskPRConcurrentUpsertPreservesStableIdentity(t *testing.T) { + store, err := NewStore(newTestDB(t), nil) + if err != nil { + t.Fatalf("new store: %v", err) + } + const writers = 12 + ids := make(chan string, writers) + errs := make(chan error, writers) + var wait sync.WaitGroup + for index := 0; index < writers; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + row := testTaskPR("task-a", "repo-a", 42) + row.Title = fmt.Sprintf("Title %d", index) + if err := store.UpsertTaskPR(context.Background(), row); err != nil { + errs <- err + return + } + ids <- row.ID + }(index) + } + wait.Wait() + close(errs) + close(ids) + for err := range errs { + t.Fatalf("concurrent upsert: %v", err) + } + stableID := "" + for id := range ids { + if stableID == "" { + stableID = id + } + if id != stableID { + t.Fatalf("concurrent upsert IDs differ: %q and %q", stableID, id) + } + } + rows, err := store.ListTaskPRsByTask(context.Background(), "task-a") + if err != nil || len(rows) != 1 || rows[0].ID != stableID { + t.Fatalf("rows after concurrent upsert = %+v, err = %v", rows, err) + } +} + +func TestStoreTaskPRListsByTaskAndWorkspace(t *testing.T) { + db := newTestDB(t) + if _, err := 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 := db.Exec(`INSERT INTO tasks (id, workspace_id) VALUES ('task-a', 'ws-a'), ('task-b', 'ws-a'), ('task-c', 'ws-b')`); err != nil { + t.Fatalf("seed tasks: %v", err) + } + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + contract, ok := any(store).(taskPRStoreContract) + if !ok { + t.Fatal("Store does not implement the task PR persistence contract") + } + ctx := context.Background() + for _, row := range []*TaskPR{ + testTaskPR("task-a", "repo-a", 1), + testTaskPR("task-a", "repo-b", 2), + testTaskPR("task-b", "repo-a", 3), + testTaskPR("task-c", "repo-c", 4), + } { + if err := contract.UpsertTaskPR(ctx, row); err != nil { + t.Fatalf("upsert task PR: %v", err) + } + } + byTask, err := contract.ListTaskPRsByTask(ctx, "task-a") + if err != nil || len(byTask) != 2 { + t.Fatalf("list by task: rows=%+v err=%v", byTask, err) + } + byWorkspace, err := contract.ListTaskPRsByWorkspace(ctx, "ws-a") + if err != nil { + t.Fatalf("list by workspace: %v", err) + } + if len(byWorkspace) != 2 || len(byWorkspace["task-a"]) != 2 || len(byWorkspace["task-b"]) != 1 { + t.Fatalf("workspace grouping = %+v", byWorkspace) + } + if _, leaked := byWorkspace["task-c"]; leaked { + t.Fatalf("workspace result leaked task-c: %+v", byWorkspace) + } +} + +func testTaskPR(taskID, repositoryID string, pullRequestID int) *TaskPR { + return &TaskPR{ + TaskID: taskID, RepositoryID: repositoryID, + OrganizationURL: "https://dev.azure.com/acme", ProjectID: "project-1", + AzureRepositoryID: repositoryID + "-azure", PullRequestID: pullRequestID, + PullRequestURL: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + Title: "Initial title", SourceBranch: "feature", TargetBranch: "main", + AuthorID: "author-1", AuthorName: "Ada", Status: "active", + } +} diff --git a/apps/backend/internal/backendapp/azuredevops_wiring_test.go b/apps/backend/internal/backendapp/azuredevops_wiring_test.go new file mode 100644 index 0000000000..293bd3ed12 --- /dev/null +++ b/apps/backend/internal/backendapp/azuredevops_wiring_test.go @@ -0,0 +1,56 @@ +package backendapp + +import ( + "reflect" + "testing" + + "github.com/kandev/kandev/internal/azuredevops" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +func TestServicesExposeAzureDevOps(t *testing.T) { + field, ok := reflect.TypeOf(Services{}).FieldByName("AzureDevOps") + if !ok || field.Type != reflect.TypeOf((*azuredevops.Service)(nil)) { + t.Fatal("Services does not expose *azuredevops.Service") + } +} + +func TestRepositoryLookupAdapterResolvesOnlyTaskLinkedAzureRepository(t *testing.T) { + harness := newBootStateTestHarness(t) + workspaces, err := harness.taskSvc.ListWorkspaces(t.Context()) + if err != nil || len(workspaces) == 0 { + t.Fatalf("list workspaces: %v", err) + } + repository, err := harness.taskSvc.CreateRepository(t.Context(), &taskservice.CreateRepositoryRequest{ + WorkspaceID: workspaces[0].ID, Name: "Azure repository", SourceType: "provider", + Provider: azuredevops.RepositoryProvider, ProviderRepoID: "azure-repo-1", + ProviderOwner: "project-1", ProviderName: "platform", DefaultBranch: "main", + }) + if err != nil { + t.Fatalf("create repository: %v", err) + } + task, err := harness.taskSvc.CreateTask(t.Context(), &taskservice.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, Title: "Azure task", IsEphemeral: true, + Repositories: []taskservice.TaskRepositoryInput{{RepositoryID: repository.ID, BaseBranch: "main"}}, + }) + if err != nil { + t.Fatalf("create task: %v", err) + } + adapter, ok := any(&repositoryLookupAdapter{svc: harness.taskSvc}).(azuredevops.RepositoryLookup) + if !ok { + t.Fatal("repositoryLookupAdapter does not implement azuredevops.RepositoryLookup") + } + binding, err := adapter.LookupTaskRepository(t.Context(), task.ID, repository.ID) + if err != nil { + t.Fatalf("lookup linked repository: %v", err) + } + if binding == nil || binding.WorkspaceID != workspaces[0].ID || + binding.Provider != azuredevops.RepositoryProvider || binding.ProviderOwner != "project-1" || + binding.ProviderRepoID != "azure-repo-1" { + t.Fatalf("binding = %+v", binding) + } + unlinked, err := adapter.LookupTaskRepository(t.Context(), task.ID, "not-linked") + if err != nil || unlinked != nil { + t.Fatalf("unlinked lookup: binding=%+v err=%v", unlinked, err) + } +} diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 78c9ec63f9..3f1878be44 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -30,6 +30,7 @@ import ( analyticshandlers "github.com/kandev/kandev/internal/analytics/handlers" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/azuredevops" "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" @@ -958,6 +959,12 @@ func registerSecondaryRoutes( p.log.Debug("Registered GitLab handlers (HTTP + WebSocket)") } + if p.services.AzureDevOps != nil { + azuredevops.RegisterRoutes(p.router, p.services.AzureDevOps, p.log) + azuredevops.RegisterMockRoutes(p.router, p.services.AzureDevOps, p.log) + p.log.Debug("Registered Azure DevOps handlers (HTTP)") + } + if p.services.Jira != nil { jira.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Jira, p.log) jira.RegisterMockRoutes(p.router, p.services.Jira, p.log) diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 9b90e404fa..dd7d91e59b 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -30,6 +30,7 @@ import ( "github.com/kandev/kandev/internal/events/bus" // GitHub integration + azuredevopspkg "github.com/kandev/kandev/internal/azuredevops" githubpkg "github.com/kandev/kandev/internal/github" gitlabpkg "github.com/kandev/kandev/internal/gitlab" @@ -524,6 +525,21 @@ func startAgentInfrastructure( log.Info("GitLab poller started") } + // Azure DevOps v1 owns only connection-health polling. PR summaries are + // refreshed explicitly through their task association routes. + if services.AzureDevOps != nil { + azureLifecycle, lifecycleErr := azuredevopspkg.RegisterLifecycleCleanup(eventBus, services.AzureDevOps) + if lifecycleErr != nil { + log.Warn("Azure DevOps lifecycle cleanup unavailable", zap.Error(lifecycleErr)) + } else { + addCleanup(azureLifecycle.Close) + } + azurePoller := azuredevopspkg.NewPoller(services.AzureDevOps, log) + azurePoller.Start(ctx) + addCleanup(func() error { azurePoller.Stop(); return nil }) + log.Info("Azure DevOps auth poller started") + } + // Start JIRA poller. Drives two background loops sharing one service: an // auth-health probe (so the UI can show connect status without polling // JIRA itself) and an issue-watch loop that runs configured JQL queries diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index eca264d8fb..cc0e811e61 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -15,6 +15,7 @@ import ( agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" analyticsservice "github.com/kandev/kandev/internal/analytics/service" "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/azuredevops" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" @@ -106,6 +107,10 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories githubSvc.SetPromptResolver(promptSvc) } gitlabSvc := initGitLabService(dbPool, eventBus, repos.Secrets, log) + azureDevOpsSvc := initAzureDevOpsService(dbPool, eventBus, repos.Secrets, log) + if azureDevOpsSvc != nil { + azureDevOpsSvc.SetRepositoryLookup(&repositoryLookupAdapter{svc: taskSvc}) + } jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) sentrySvc := initSentryService(dbPool, eventBus, repos.Secrets, log) @@ -144,6 +149,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Workflow: workflowSvc, GitHub: githubSvc, GitLab: gitlabSvc, + AzureDevOps: azureDevOpsSvc, Jira: jiraSvc, Linear: linearSvc, Sentry: sentrySvc, @@ -391,6 +397,24 @@ func initJiraService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secret return svc } +// initAzureDevOpsService wires the workspace-scoped Azure integration. +// Failures are non-fatal so unrelated providers and the backend keep working. +func initAzureDevOpsService( + dbPool *db.Pool, + eventBus bus.EventBus, + secretsStore secrets.SecretStore, + log *logger.Logger, +) *azuredevops.Service { + svc, _, err := azuredevops.Provide( + dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log, + ) + if err != nil { + log.Warn("Azure DevOps service initialization failed (non-fatal)", zap.Error(err)) + return nil + } + return svc +} + // initWorkflowSyncService wires the GitHub workflow-sync service. Failures // are non-fatal; the service is nil when GitHub is unavailable. func initWorkflowSyncService(dbPool *db.Pool, githubSvc *github.Service, workflowSvc *workflowservice.Service, taskSvc *taskservice.Service, log *logger.Logger) *workflowsync.Service { diff --git a/apps/backend/internal/backendapp/turn_adapters.go b/apps/backend/internal/backendapp/turn_adapters.go index 6937257d12..1b02a64857 100644 --- a/apps/backend/internal/backendapp/turn_adapters.go +++ b/apps/backend/internal/backendapp/turn_adapters.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/azuredevops" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" @@ -161,6 +162,36 @@ func (a *repositoryLookupAdapter) GetRepository(ctx context.Context, id string) return repo.WorkspaceID, repo.DefaultBranch, true } +// LookupTaskRepository resolves provider metadata only when repositoryID is +// linked to taskID. Azure association validation fails closed on a nil result. +func (a *repositoryLookupAdapter) LookupTaskRepository( + ctx context.Context, + taskID, repositoryID string, +) (*azuredevops.RepositoryBinding, error) { + task, err := a.svc.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + linked := false + for _, taskRepository := range task.Repositories { + if taskRepository != nil && taskRepository.RepositoryID == repositoryID { + linked = true + break + } + } + if !linked { + return nil, nil + } + repository, err := a.svc.GetRepository(ctx, repositoryID) + if err != nil { + return nil, err + } + return &azuredevops.RepositoryBinding{ + WorkspaceID: repository.WorkspaceID, Provider: repository.Provider, + ProviderOwner: repository.ProviderOwner, ProviderRepoID: repository.ProviderRepoID, + }, nil +} + // RepositoryExists satisfies orchestrator.RepositoryChecker. It uses the // workspace listing (which excludes soft-deleted repos) so a definitive // "absent" is distinguishable from a transient error: a non-nil err lets the diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index a00965d4ab..cca31f5039 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -4,6 +4,7 @@ import ( settingsstore "github.com/kandev/kandev/internal/agent/settings/store" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/azuredevops" editorservice "github.com/kandev/kandev/internal/editors/service" editorstore "github.com/kandev/kandev/internal/editors/store" "github.com/kandev/kandev/internal/github" @@ -63,6 +64,7 @@ type Services struct { Workflow *workflowservice.Service GitHub *github.Service GitLab *gitlab.Service + AzureDevOps *azuredevops.Service Jira *jira.Service Linear *linear.Service Sentry *sentry.Service diff --git a/apps/backend/internal/gitlab/client_helpers.go b/apps/backend/internal/gitlab/client_helpers.go index d0935223f0..fe6328ee8e 100644 --- a/apps/backend/internal/gitlab/client_helpers.go +++ b/apps/backend/internal/gitlab/client_helpers.go @@ -49,6 +49,8 @@ type rawProject struct { Name string `json:"name"` PathWithNamespace string `json:"path_with_namespace"` Visibility string `json:"visibility"` + WebURL string `json:"web_url"` + DefaultBranch string `json:"default_branch"` Namespace struct { FullPath string `json:"full_path"` } `json:"namespace"` @@ -208,6 +210,8 @@ func convertRawProject(raw *rawProject) Project { Path: raw.Path, Name: raw.Name, Visibility: raw.Visibility, + WebURL: raw.WebURL, + DefaultBranch: raw.DefaultBranch, } } diff --git a/apps/backend/internal/gitlab/models.go b/apps/backend/internal/gitlab/models.go index 741c982088..8f2dc9dd0a 100644 --- a/apps/backend/internal/gitlab/models.go +++ b/apps/backend/internal/gitlab/models.go @@ -149,6 +149,8 @@ type Project struct { Path string `json:"path"` Name string `json:"name"` Visibility string `json:"visibility"` // private, internal, public + WebURL string `json:"web_url"` + DefaultBranch string `json:"default_branch"` } // Group represents a GitLab group the authenticated user belongs to. diff --git a/apps/backend/internal/integrations/AGENTS.md b/apps/backend/internal/integrations/AGENTS.md index 28a4341361..caf002fcfc 100644 --- a/apps/backend/internal/integrations/AGENTS.md +++ b/apps/backend/internal/integrations/AGENTS.md @@ -27,3 +27,4 @@ This file covers both halves of the playbook (backend + frontend) so the pattern - **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. diff --git a/apps/backend/internal/integrations/cloneauth/keys.go b/apps/backend/internal/integrations/cloneauth/keys.go new file mode 100644 index 0000000000..a2aa4fc903 --- /dev/null +++ b/apps/backend/internal/integrations/cloneauth/keys.go @@ -0,0 +1,6 @@ +package cloneauth + +// AzureDevOpsPATKey returns the encrypted-secret key used for a workspace PAT. +func AzureDevOpsPATKey(workspaceID string) string { + return "azure_devops:" + workspaceID + ":pat" +} diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 668bc079ca..fbd8742fd9 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "strings" "sync" "time" @@ -14,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/integrations/cloneauth" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -692,6 +694,28 @@ type RepoCloner interface { BuildCloneURL(provider, owner, name string) (string, error) } +type authenticatedRepoCloner interface { + EnsureClonedWithBasicAuth(ctx context.Context, cloneURL, owner, name, username, password string) (string, error) +} + +func (e *Executor) ensureClonedWithWorkspaceAuth( + ctx context.Context, repo *models.Repository, cloneURL string, +) (string, error) { + if repo.Provider != "azure_devops" || !strings.HasPrefix(cloneURL, "https://") { + return e.repoCloner.EnsureCloned(ctx, cloneURL, repo.ProviderOwner, repo.ProviderName) + } + authCloner, ok := e.repoCloner.(authenticatedRepoCloner) + if !ok || e.secretStore == nil { + return "", fmt.Errorf("azure DevOps repository clone authentication is unavailable") + } + pat, err := e.secretStore.Reveal(ctx, cloneauth.AzureDevOpsPATKey(repo.WorkspaceID)) + if err != nil { + return "", fmt.Errorf("read Azure DevOps clone credential: %w", err) + } + // Azure DevOps PAT authentication ignores the username; any non-empty value works. + return authCloner.EnsureClonedWithBasicAuth(ctx, cloneURL, repo.ProviderOwner, repo.ProviderName, "kandev", pat) +} + // RepoUpdater updates repository records in the database. type RepoUpdater interface { UpdateRepositoryLocalPath(ctx context.Context, repositoryID, localPath string) error diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 0f49d55aa6..274b501f59 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -492,6 +492,9 @@ const providerGitHub = "github" // repos with a real remote (or a file:// remote, used by Docker E2E tests) // participate in remote executors that clone inside the container/sandbox. func repositoryCloneURL(repo *models.Repository) string { + if strings.TrimSpace(repo.RemoteURL) != "" { + return strings.TrimSpace(repo.RemoteURL) + } if repo.ProviderOwner != "" && repo.ProviderName != "" { var host string switch strings.ToLower(repo.Provider) { diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index 74b742df54..f180260599 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -164,7 +164,7 @@ func (e *Executor) ensureRepoCloned(ctx context.Context, repo *models.Repository zap.String("repository_id", repo.ID), zap.String("repo", repo.ProviderOwner+"/"+repo.ProviderName)) - localPath, err := e.repoCloner.EnsureCloned(ctx, cloneURL, repo.ProviderOwner, repo.ProviderName) + localPath, err := e.ensureClonedWithWorkspaceAuth(ctx, repo, cloneURL) if err != nil { e.logger.Error("failed to clone repository", zap.String("repository_id", repo.ID), diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 5258005c90..34bb29eebf 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -2277,6 +2277,14 @@ func TestRepositoryCloneURL(t *testing.T) { repo *models.Repository want string }{ + { + name: "stored remote URL takes precedence", + repo: &models.Repository{ + Provider: "azure_devops", ProviderOwner: "Platform", ProviderName: "api", + RemoteURL: "https://dev.azure.com/acme/Platform/_git/api", + }, + want: "https://dev.azure.com/acme/Platform/_git/api", + }, { name: "github repo", repo: &models.Repository{Provider: "github", ProviderOwner: "acme", ProviderName: "app"}, @@ -2327,6 +2335,66 @@ func TestRepositoryCloneURL(t *testing.T) { } } +type recordingAuthenticatedCloner struct { + normalCalls int + authCalls int + password string +} + +func (c *recordingAuthenticatedCloner) EnsureCloned( + _ context.Context, _, _, _ string, +) (string, error) { + c.normalCalls++ + return "/repos/normal", nil +} + +func (c *recordingAuthenticatedCloner) BuildCloneURL(_, _, _ string) (string, error) { + return "", nil +} + +func (c *recordingAuthenticatedCloner) EnsureClonedWithBasicAuth( + _ context.Context, _, _, _, _, password string, +) (string, error) { + c.authCalls++ + c.password = password + return "/repos/azure", nil +} + +func TestEnsureClonedWithWorkspaceAuth(t *testing.T) { + t.Parallel() + cloner := &recordingAuthenticatedCloner{} + exec := &Executor{ + repoCloner: cloner, + secretStore: &mockSecretStore{secrets: map[string]string{ + "azure_devops:workspace-1:pat": "workspace-pat", + }}, + } + azure := &models.Repository{ + WorkspaceID: "workspace-1", Provider: "azure_devops", ProviderOwner: "Platform", ProviderName: "api", + } + path, err := exec.ensureClonedWithWorkspaceAuth( + context.Background(), azure, "https://dev.azure.com/acme/Platform/_git/api", + ) + if err != nil { + t.Fatal(err) + } + if path != "/repos/azure" || cloner.authCalls != 1 || cloner.password != "workspace-pat" { + t.Fatalf("authenticated clone was not used with workspace credential: %+v", cloner) + } + + github := &models.Repository{Provider: "github", ProviderOwner: "acme", ProviderName: "api"} + if _, err := exec.ensureClonedWithWorkspaceAuth(context.Background(), github, "https://github.com/acme/api.git"); err != nil { + t.Fatal(err) + } + azureSSH := "git@ssh.dev.azure.com:v3/acme/Platform/api" + if _, err := exec.ensureClonedWithWorkspaceAuth(context.Background(), azure, azureSSH); err != nil { + t.Fatal(err) + } + if cloner.normalCalls != 2 || cloner.authCalls != 1 { + t.Fatalf("non-Azure-HTTPS providers must use ordinary cloning: %+v", cloner) + } +} + // TestRepositoryCloneURL_LocalPathFallback covers the second code path // added to support Docker E2E tests: when the repository has no // ProviderOwner/ProviderName, the function shells out to diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index 250cf0fe0d..d65f86ea0d 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -71,7 +71,7 @@ mocks: dev: "" e2e: "true" - # Replace the GitHub / Jira / Linear clients with in-memory fakes + # Replace third-party clients with in-memory fakes # so e2e specs don't hit live APIs (and dev doesn't need tokens). KANDEV_MOCK_GITHUB: prod: "" @@ -89,6 +89,10 @@ mocks: prod: "" dev: "" e2e: "true" + KANDEV_MOCK_AZURE_DEVOPS: + prod: "" + dev: "" + e2e: "true" # ── Debug / observability ───────────────────────────────────────── # Production keeps these off so /debug/* endpoints and pprof aren't diff --git a/apps/backend/internal/repoclone/clone.go b/apps/backend/internal/repoclone/clone.go index a29637eb01..112f1317e0 100644 --- a/apps/backend/internal/repoclone/clone.go +++ b/apps/backend/internal/repoclone/clone.go @@ -3,6 +3,7 @@ package repoclone import ( "context" + "encoding/base64" "fmt" "os" "os/exec" @@ -80,7 +81,15 @@ func (c *Cloner) RepoPath(owner, name string) (string, error) { if err != nil { return "", err } - return filepath.Join(basePath, owner, name), nil + targetPath := filepath.Join(basePath, owner, name) + relativePath, err := filepath.Rel(basePath, targetPath) + if err != nil { + return "", fmt.Errorf("resolve repository path: %w", err) + } + if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) { + return "", fmt.Errorf("repository path %q escapes clone base", targetPath) + } + return targetPath, nil } // EnsureCloned clones the repository if it doesn't exist locally, or fetches if it does. @@ -106,6 +115,50 @@ func (c *Cloner) EnsureCloned(ctx context.Context, cloneURL, owner, name string) return targetPath, c.clone(ctx, cloneURL, targetPath, owner, name) } +// EnsureClonedWithBasicAuth clones or fetches using an ephemeral HTTP +// Authorization header. The credential is carried only in the Git child +// process environment, never in the URL, command arguments, or logs. +func (c *Cloner) EnsureClonedWithBasicAuth( + ctx context.Context, cloneURL, owner, name, username, password string, +) (string, error) { + targetPath, err := c.RepoPath(owner, name) + if err != nil { + return "", err + } + mu := c.repoMu(targetPath) + mu.Lock() + defer mu.Unlock() + header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) + gitDir := filepath.Join(targetPath, ".git") + if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() { + cmd := c.gitCmdWithHTTPHeader(ctx, header, "-C", targetPath, "fetch", "--all", "--prune", "--force", gitNoTags) + if out, fetchErr := subproc.RunGitCombinedOutput(ctx, cmd); fetchErr != nil { + c.logger.Warn("authenticated git fetch failed", zap.String("path", targetPath), zap.String("output", string(out)), zap.Error(fetchErr)) + } + return targetPath, nil + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return "", fmt.Errorf("create parent directory: %w", err) + } + c.logger.Info("cloning authenticated repository", zap.String("url", cloneURL), zap.String("target", targetPath)) + cmd := c.gitCmdWithHTTPHeader(ctx, header, "clone", "--filter=blob:none", gitNoTags, cloneURL, targetPath) + if out, cloneErr := subproc.RunGitCombinedOutput(ctx, cmd); cloneErr != nil { + return "", fmt.Errorf("git clone failed: %s: %w", string(out), cloneErr) + } + return targetPath, nil +} + +func (c *Cloner) gitCmdWithHTTPHeader(ctx context.Context, header string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Env = append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http.extraHeader", + "GIT_CONFIG_VALUE_0="+header, + ) + return cmd +} + // gitCmd creates a git command with non-interactive environment settings. // When the configured protocol is HTTPS, it adds gh CLI as the credential // helper so that git can authenticate using the user's gh auth token. diff --git a/apps/backend/internal/repoclone/clone_auth_test.go b/apps/backend/internal/repoclone/clone_auth_test.go new file mode 100644 index 0000000000..e200a59e65 --- /dev/null +++ b/apps/backend/internal/repoclone/clone_auth_test.go @@ -0,0 +1,86 @@ +package repoclone + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +func TestGitCmdWithHTTPHeaderKeepsCredentialOutOfArguments(t *testing.T) { + t.Parallel() + cloner := &Cloner{} + header := "Authorization: Basic c2VjcmV0" + cmd := cloner.gitCmdWithHTTPHeader(context.Background(), header, "clone", "https://dev.azure.com/acme/p/_git/r") + if strings.Contains(strings.Join(cmd.Args, " "), "c2VjcmV0") { + t.Fatal("credential leaked into command arguments") + } + found := false + for _, value := range cmd.Env { + if value == "GIT_CONFIG_VALUE_0="+header { + found = true + } + } + if !found { + t.Fatal("authorization header was not provided through the Git child environment") + } +} + +func TestEnsureClonedWithBasicAuthKeepsCredentialScopedToGitChild(t *testing.T) { + tests := []struct { + name string + cancel bool + }{ + {name: "git failure"}, + {name: "context cancellation", cancel: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + binDir := t.TempDir() + capturePath := filepath.Join(t.TempDir(), "git-env") + fakeGit := "#!/bin/sh\nprintf '%s' \"$GIT_CONFIG_VALUE_0\" > \"$CAPTURE_PATH\"\n" + + "if [ \"$BLOCK_GIT\" = 1 ]; then exec sleep 10; fi\nexit 1\n" + if err := os.WriteFile(filepath.Join(binDir, "git"), []byte(fakeGit), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CAPTURE_PATH", capturePath) + t.Setenv("GIT_CONFIG_VALUE_0", "") + if tc.cancel { + t.Setenv("BLOCK_GIT", "1") + } + cloner := NewCloner(Config{BasePath: t.TempDir()}, ProtocolHTTPS, "", logger.Default()) + ctx := context.Background() + var cancel context.CancelFunc + if tc.cancel { + ctx, cancel = context.WithTimeout(ctx, 20*time.Millisecond) + defer cancel() + } + _, err := cloner.EnsureClonedWithBasicAuth( + ctx, "https://dev.azure.com/acme/p/_git/r", "p", "r", "kandev", "secret-pat", + ) + if err == nil { + t.Fatal("expected git clone error") + } + if tc.cancel && ctx.Err() != context.DeadlineExceeded { + t.Fatalf("expected cancelled clone context, got %v", ctx.Err()) + } + captured, readErr := os.ReadFile(capturePath) + if readErr != nil { + t.Fatal(readErr) + } + expectedCredential := base64.StdEncoding.EncodeToString([]byte("kandev:secret-pat")) + if !strings.Contains(string(captured), expectedCredential) { + t.Fatal("credential was not passed to Git child") + } + if os.Getenv("GIT_CONFIG_VALUE_0") != "" { + t.Fatal("credential escaped into the parent process environment") + } + }) + } +} diff --git a/apps/backend/internal/repoclone/clone_test.go b/apps/backend/internal/repoclone/clone_test.go index 9fbc45652c..441670c11b 100644 --- a/apps/backend/internal/repoclone/clone_test.go +++ b/apps/backend/internal/repoclone/clone_test.go @@ -26,6 +26,37 @@ func TestClone_PreservesNonDefaultRemoteBranches(t *testing.T) { } } +func TestRepoPathConfinesRepositoryToCloneBase(t *testing.T) { + t.Parallel() + + basePath := t.TempDir() + cloner := NewCloner(Config{BasePath: basePath}, ProtocolHTTPS, "", logger.Default()) + + path, err := cloner.RepoPath("group/subgroup", "repository") + if err != nil { + t.Fatalf("RepoPath() unexpected error: %v", err) + } + want := filepath.Join(basePath, "group", "subgroup", "repository") + if path != want { + t.Fatalf("RepoPath() = %q, want %q", path, want) + } + + for _, test := range []struct { + name string + owner string + repo string + }{ + {name: "owner traversal", owner: "../../outside", repo: "repository"}, + {name: "repository traversal", owner: "group", repo: "../../../outside"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, pathErr := cloner.RepoPath(test.owner, test.repo); pathErr == nil { + t.Fatal("RepoPath() accepted a path outside the clone base") + } + }) + } +} + func initBareRepoWithReleaseBranch(t *testing.T) string { t.Helper() diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index f6ce2897fe..ffc5857cc5 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -257,6 +257,11 @@ type TaskRepositoryInput struct { Name string DefaultBranch string GitHubURL string + RemoteURL string + Provider string + ProviderRepoID string + ProviderOwner string + ProviderName string } type CreateTaskRequest struct { diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index fad86b7bcc..4ea435a36c 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -186,6 +186,11 @@ func convertToServiceRepos(repos []dto.TaskRepositoryInput) []service.TaskReposi Name: r.Name, DefaultBranch: r.DefaultBranch, GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, } } return result diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 47760909e9..8206029921 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -452,6 +452,11 @@ type httpTaskRepositoryInput struct { Name string `json:"name"` DefaultBranch string `json:"default_branch"` GitHubURL string `json:"github_url"` + RemoteURL string `json:"remote_url"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` // Fresh-branch flow (local executor only): when FreshBranch is true the // handler discards uncommitted changes in the local clone and creates @@ -854,8 +859,8 @@ func (h *TaskHandlers) respondFreshBranchError(c *gin.Context, err error) { func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryInput) ([]dto.TaskRepositoryInput, bool) { var repos []dto.TaskRepositoryInput for _, r := range inputs { - if r.RepositoryID == "" && r.LocalPath == "" && r.GitHubURL == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id, local_path, or github_url is required"}) + if r.RepositoryID == "" && r.LocalPath == "" && r.RemoteURL == "" && r.GitHubURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id, local_path, or remote_url is required"}) return nil, false } repos = append(repos, dto.TaskRepositoryInput{ @@ -867,6 +872,11 @@ func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryIn Name: r.Name, DefaultBranch: r.DefaultBranch, GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, }) } return repos, true @@ -1013,11 +1023,19 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { if body.Repositories != nil { for _, r := range body.Repositories { repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + PRNumber: r.PRNumber, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, }) } } diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 66d8b32730..33f9dbd8aa 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -116,8 +116,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M // Convert repositories var repos []dto.TaskRepositoryInput for _, r := range req.Repositories { - if r.RepositoryID == "" && r.LocalPath == "" && strings.TrimSpace(r.GitHubURL) == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id, local_path, or github_url is required", nil) + if r.RepositoryID == "" && r.LocalPath == "" && strings.TrimSpace(r.RemoteURL) == "" && strings.TrimSpace(r.GitHubURL) == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id, local_path, or remote_url is required", nil) } repos = append(repos, dto.TaskRepositoryInput{ RepositoryID: r.RepositoryID, @@ -128,6 +128,11 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M Name: r.Name, DefaultBranch: r.DefaultBranch, GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, }) } @@ -264,6 +269,11 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M Name: r.Name, DefaultBranch: r.DefaultBranch, GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, }) } } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index ca7f5c2c68..be929e923c 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -901,6 +901,7 @@ type Repository struct { ProviderRepoID string `json:"provider_repo_id"` ProviderOwner string `json:"provider_owner"` ProviderName string `json:"provider_name"` + RemoteURL string `json:"remote_url"` DefaultBranch string `json:"default_branch"` WorktreeBranchPrefix string `json:"worktree_branch_prefix"` WorktreeBranchTemplate string `json:"worktree_branch_template"` diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 713950b27f..a02e91f8dd 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -103,6 +103,7 @@ func (r *Repository) runMigrations() error { // column added earlier. r.migrate.Apply("task_sessions.name", `ALTER TABLE task_sessions ADD COLUMN name TEXT DEFAULT ''`) r.migrate.Apply("repositories.copy_files", `ALTER TABLE repositories ADD COLUMN copy_files TEXT DEFAULT ''`) + r.migrate.Apply("repositories.remote_url", `ALTER TABLE repositories ADD COLUMN remote_url TEXT DEFAULT ''`) r.migrate.Apply("repositories.worktree_branch_template", `ALTER TABLE repositories ADD COLUMN worktree_branch_template TEXT DEFAULT 'feature/{title}-{suffix}'`) r.migrate.Apply("repositories.worktree_branch_template.backfill", `UPDATE repositories SET worktree_branch_template = COALESCE(NULLIF(TRIM(worktree_branch_prefix), ''), 'feature/') || '{title}-{suffix}'`) r.migrate.Apply("task_plans.implementation_started_at", `ALTER TABLE task_plans ADD COLUMN implementation_started_at TIMESTAMP`) diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index f834b8e782..493fb36c08 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -291,6 +291,7 @@ func (r *Repository) initTaskSchema() error { provider_repo_id TEXT DEFAULT '', provider_owner TEXT DEFAULT '', provider_name TEXT DEFAULT '', + remote_url TEXT DEFAULT '', default_branch TEXT DEFAULT '', worktree_branch_prefix TEXT DEFAULT 'feature/', worktree_branch_template TEXT DEFAULT 'feature/{title}-{suffix}', diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index 2dd32ee1d2..68c99172b1 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -26,10 +26,10 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO repositories ( id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, - repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, + repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.RemoteURL, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) return err @@ -41,11 +41,11 @@ func (r *Repository) GetRepository(ctx context.Context, id string) (*models.Repo err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories WHERE id = ? AND deleted_at IS NULL `), id).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, - &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) @@ -62,10 +62,10 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE repositories SET name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, - provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, worktree_branch_template = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, copy_files = ?, updated_at = ? + provider_name = ?, remote_url = ?, default_branch = ?, worktree_branch_prefix = ?, worktree_branch_template = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, copy_files = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL `), repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, - repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), + repository.ProviderOwner, repository.ProviderName, repository.RemoteURL, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.WorktreeBranchTemplate, dialect.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CopyFiles, repository.UpdatedAt, repository.ID) if err != nil { return err @@ -129,7 +129,7 @@ func (r *Repository) DeleteRepositoryIfNoActiveTaskSessions(ctx context.Context, func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `), workspaceID) if err != nil { @@ -142,7 +142,7 @@ func (r *Repository) ListRepositories(ctx context.Context, workspaceID string) ( repository := &models.Repository{} err := rows.Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, - &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err != nil { @@ -159,12 +159,12 @@ func (r *Repository) GetRepositoryByProviderInfo(ctx context.Context, workspaceI repository := &models.Repository{} err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at FROM repositories WHERE workspace_id = ? AND provider = ? AND provider_owner = ? AND provider_name = ? AND deleted_at IS NULL `), workspaceID, provider, owner, name).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, - &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err == sql.ErrNoRows { diff --git a/apps/backend/internal/task/service/remote_repository_test.go b/apps/backend/internal/task/service/remote_repository_test.go new file mode 100644 index 0000000000..131d986d4c --- /dev/null +++ b/apps/backend/internal/task/service/remote_repository_test.go @@ -0,0 +1,40 @@ +package service + +import "testing" + +func TestParseRemoteRepositoryURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw string + provider string + owner string + repo string + canonical string + }{ + {"github", "https://github.com/acme/api/pull/12", "github", "acme", "api", "https://github.com/acme/api.git"}, + {"gitlab subgroup", "https://gitlab.com/acme/platform/api.git", "gitlab", "acme/platform", "api", "https://gitlab.com/acme/platform/api.git"}, + {"gitlab merge request", "https://gitlab.com/acme/platform/api/-/merge_requests/12", "gitlab", "acme/platform", "api", "https://gitlab.com/acme/platform/api.git"}, + {"azure devops", "https://dev.azure.com/acme/Platform/_git/api", "azure_devops", "Platform", "api", "https://dev.azure.com/acme/Platform/_git/api"}, + {"github ssh", "git@github.com:acme/api.git", "github", "acme", "api", "git@github.com:acme/api.git"}, + {"azure ssh", "git@ssh.dev.azure.com:v3/acme/Platform/api", "azure_devops", "Platform", "api", "git@ssh.dev.azure.com:v3/acme/Platform/api"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + provider, owner, repo, canonical, err := parseRemoteRepositoryURL(tc.raw) + if err != nil { + t.Fatal(err) + } + if provider != tc.provider || owner != tc.owner || repo != tc.repo || canonical != tc.canonical { + t.Fatalf("got (%q, %q, %q, %q)", provider, owner, repo, canonical) + } + }) + } +} + +func TestParseRemoteRepositoryURLRejectsUnsupportedHost(t *testing.T) { + t.Parallel() + if _, _, _, _, err := parseRemoteRepositoryURL("https://example.com/acme/api"); err == nil { + t.Fatal("expected unsupported host error") + } +} diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 15c94cd302..6eb72ff80b 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -18,6 +18,11 @@ type TaskRepositoryInput struct { Name string `json:"name,omitempty"` DefaultBranch string `json:"default_branch,omitempty"` GitHubURL string `json:"github_url,omitempty"` + RemoteURL string `json:"remote_url,omitempty"` + Provider string `json:"provider,omitempty"` + ProviderRepoID string `json:"provider_repo_id,omitempty"` + ProviderOwner string `json:"provider_owner,omitempty"` + ProviderName string `json:"provider_name,omitempty"` // ResolveProviderDefaults opts the GitHub-URL resolution path into a // synchronous default-branch probe (git ls-remote --symref) when neither @@ -106,12 +111,14 @@ type UpdateWorkspaceRequest struct { // FindOrCreateRepositoryRequest contains the data for finding or creating a repository by provider info. type FindOrCreateRepositoryRequest struct { - WorkspaceID string `json:"workspace_id"` - Provider string `json:"provider"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - LocalPath string `json:"local_path"` + WorkspaceID string `json:"workspace_id"` + Provider string `json:"provider"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + ProviderRepoID string `json:"provider_repo_id"` + RemoteURL string `json:"remote_url"` + DefaultBranch string `json:"default_branch"` + LocalPath string `json:"local_path"` } // CreateRepositoryRequest contains the data for creating a new repository @@ -124,6 +131,7 @@ type CreateRepositoryRequest struct { ProviderRepoID string `json:"provider_repo_id"` ProviderOwner string `json:"provider_owner"` ProviderName string `json:"provider_name"` + RemoteURL string `json:"remote_url"` DefaultBranch string `json:"default_branch"` WorktreeBranchPrefix string `json:"worktree_branch_prefix"` WorktreeBranchTemplate string `json:"worktree_branch_template"` diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index ed890e2c7a..95ac45c081 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -617,6 +617,7 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq ProviderRepoID: req.ProviderRepoID, ProviderOwner: req.ProviderOwner, ProviderName: req.ProviderName, + RemoteURL: req.RemoteURL, DefaultBranch: req.DefaultBranch, WorktreeBranchPrefix: prefix, WorktreeBranchTemplate: template, @@ -693,6 +694,14 @@ func (s *Service) FindOrCreateRepository(ctx context.Context, req *FindOrCreateR existing.DefaultBranch = req.DefaultBranch dirty = true } + if existing.RemoteURL == "" && req.RemoteURL != "" { + existing.RemoteURL = req.RemoteURL + dirty = true + } + if existing.ProviderRepoID == "" && req.ProviderRepoID != "" { + existing.ProviderRepoID = req.ProviderRepoID + dirty = true + } if dirty { if updateErr := s.repoEntities.UpdateRepository(ctx, existing); updateErr != nil { s.logger.Warn("failed to backfill repository fields", @@ -704,14 +713,16 @@ func (s *Service) FindOrCreateRepository(ctx context.Context, req *FindOrCreateR name := fmt.Sprintf("%s/%s", req.ProviderOwner, req.ProviderName) created, createErr := s.CreateRepository(ctx, &CreateRepositoryRequest{ - WorkspaceID: req.WorkspaceID, - Name: name, - SourceType: sourceTypeProvider, - LocalPath: req.LocalPath, - Provider: req.Provider, - ProviderOwner: req.ProviderOwner, - ProviderName: req.ProviderName, - DefaultBranch: req.DefaultBranch, + WorkspaceID: req.WorkspaceID, + Name: name, + SourceType: sourceTypeProvider, + LocalPath: req.LocalPath, + Provider: req.Provider, + ProviderRepoID: req.ProviderRepoID, + ProviderOwner: req.ProviderOwner, + ProviderName: req.ProviderName, + RemoteURL: req.RemoteURL, + DefaultBranch: req.DefaultBranch, }) if createErr != nil { return nil, false, createErr diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 9b0402c446..d306814192 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -26,6 +26,11 @@ import ( // Used when a caller omits priority so the DB CHECK constraint is satisfied. const defaultPriority = "medium" +const ( + providerAzureDevOps = "azure_devops" + providerGitHub = "github" +) + const defaultKandevTaskWorktreePathSegment = "/.kandev/tasks/" // ErrSubtaskDepthExceeded is returned when a caller tries to create a @@ -383,8 +388,8 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI // name), and finally falls back to the repositoryID so the message is never // empty. Best-effort: lookup failures degrade to the next fallback. func (s *Service) repoDisplayLabel(ctx context.Context, repoInput TaskRepositoryInput, repositoryID string) string { - if repoInput.GitHubURL != "" { - if owner, name, err := parseGitHubRepoURL(repoInput.GitHubURL); err == nil { + if remoteURL := effectiveRemoteURL(repoInput); remoteURL != "" { + if _, owner, name, _, err := parseRemoteRepositoryURL(remoteURL); err == nil { return owner + "/" + name } } @@ -439,9 +444,8 @@ func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repo return s.resolveRepoInputID(ctx, workspaceID, repositoryID, baseBranch) } - // Handle GitHub URL: parse owner/name and use FindOrCreateRepository - if repoInput.GitHubURL != "" { - return s.resolveRepoInputGitHub(ctx, workspaceID, repoInput, baseBranch) + if effectiveRemoteURL(repoInput) != "" { + return s.resolveRepoInputRemote(ctx, workspaceID, repoInput, baseBranch) } if repoInput.LocalPath == "" { @@ -676,26 +680,32 @@ func (s *Service) resolveRepoInputLocal( // FindOrCreateRepository. Extracted so resolveRepoInput stays under the // cognitive-complexity budget after adding the probe-skip and probe-error // arms. -func (s *Service) resolveRepoInputGitHub( +func (s *Service) resolveRepoInputRemote( ctx context.Context, workspaceID string, repoInput TaskRepositoryInput, baseBranch string, ) (string, string, bool, error) { - owner, name, parseErr := parseGitHubRepoURL(repoInput.GitHubURL) + provider, owner, name, canonicalURL, parseErr := parseRemoteRepositoryURL(effectiveRemoteURL(repoInput)) if parseErr != nil { return "", "", false, parseErr } - defaultBranch := repoInput.DefaultBranch - if defaultBranch == "" { - defaultBranch = repoInput.BaseBranch + if repoInput.Provider != "" && !strings.EqualFold(repoInput.Provider, provider) { + return "", "", false, fmt.Errorf("remote_url provider %q does not match provider %q", provider, repoInput.Provider) } - if defaultBranch == "" && repoInput.ResolveProviderDefaults && s.providerProber != nil { - defaultBranch = s.probeProviderDefaultBranchIfMissing(ctx, workspaceID, "github", owner, name) + owner, metadataErr := validateRemoteRepositoryMetadata(repoInput, provider, owner, name) + if metadataErr != nil { + return "", "", false, metadataErr + } + defaultBranch := firstNonEmpty(repoInput.DefaultBranch, repoInput.BaseBranch) + if defaultBranch == "" && repoInput.ResolveProviderDefaults && s.providerProber != nil && provider == providerGitHub { + defaultBranch = s.probeProviderDefaultBranchIfMissing(ctx, workspaceID, provider, owner, name) } repo, repoCreated, createErr := s.FindOrCreateRepository(ctx, &FindOrCreateRepositoryRequest{ - WorkspaceID: workspaceID, - Provider: "github", - ProviderOwner: owner, - ProviderName: name, - DefaultBranch: defaultBranch, + WorkspaceID: workspaceID, + Provider: provider, + ProviderRepoID: repoInput.ProviderRepoID, + ProviderOwner: owner, + ProviderName: name, + RemoteURL: canonicalURL, + DefaultBranch: defaultBranch, }) if createErr != nil { return "", "", false, createErr @@ -706,6 +716,129 @@ func (s *Service) resolveRepoInputGitHub( return repo.ID, baseBranch, repoCreated, nil } +func validateRemoteRepositoryMetadata( + input TaskRepositoryInput, provider, owner, name string, +) (string, error) { + if input.ProviderOwner != "" && provider != providerAzureDevOps && input.ProviderOwner != owner { + return "", fmt.Errorf("remote_url owner %q does not match provider_owner %q", owner, input.ProviderOwner) + } + if input.ProviderName != "" && input.ProviderName != name { + return "", fmt.Errorf("remote_url repository %q does not match provider_name %q", name, input.ProviderName) + } + if input.ProviderOwner != "" { + return input.ProviderOwner, nil + } + return owner, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func effectiveRemoteURL(input TaskRepositoryInput) string { + if strings.TrimSpace(input.RemoteURL) != "" { + return strings.TrimSpace(input.RemoteURL) + } + return strings.TrimSpace(input.GitHubURL) +} + +func parseRemoteRepositoryURL(raw string) (provider, owner, name, canonical string, err error) { + parsed, sshStyle, parseErr := normalizeRemoteRepositoryURL(raw) + if parseErr != nil { + return "", "", "", "", parseErr + } + host := strings.ToLower(parsed.Hostname()) + parts := strings.Split(strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/"), "/") + switch host { + case "github.com", "www.github.com": + return parseGitHubRemote(host, parts, sshStyle) + case "gitlab.com", "www.gitlab.com": + return parseGitLabRemote(host, parts, sshStyle) + case "dev.azure.com": + return parseAzureHTTPSRemote(parts) + case "ssh.dev.azure.com": + return parseAzureSSHRemote(parts) + default: + return "", "", "", "", fmt.Errorf("unsupported remote repository host: %s", host) + } +} + +func normalizeRemoteRepositoryURL(raw string) (*url.URL, bool, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, false, fmt.Errorf("empty remote repository URL") + } + sshStyle := strings.HasPrefix(raw, "git@") && strings.Contains(raw, ":") + if sshStyle { + hostAndPath := strings.TrimPrefix(raw, "git@") + separator := strings.Index(hostAndPath, ":") + raw = "ssh://git@" + hostAndPath[:separator] + "/" + hostAndPath[separator+1:] + } else if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + parsed, parseErr := url.Parse(raw) + if parseErr != nil || parsed.Host == "" { + return nil, false, fmt.Errorf("invalid remote repository URL") + } + return parsed, sshStyle, nil +} + +func parseGitHubRemote(host string, parts []string, sshStyle bool) (string, string, string, string, error) { + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", "", fmt.Errorf("invalid GitHub repository URL: expected github.com/owner/repo") + } + owner, name := parts[0], parts[1] + canonical := fmt.Sprintf("https://github.com/%s/%s.git", owner, name) + if sshStyle { + canonical = fmt.Sprintf("git@%s:%s/%s.git", host, owner, name) + } + return providerGitHub, owner, name, canonical, nil +} + +func parseGitLabRemote(host string, parts []string, sshStyle bool) (string, string, string, string, error) { + for index, part := range parts { + if part == "-" { + parts = parts[:index] + break + } + } + if len(parts) < 2 { + return "", "", "", "", fmt.Errorf("invalid GitLab repository URL: expected gitlab.com/namespace/repo") + } + owner, name := strings.Join(parts[:len(parts)-1], "/"), parts[len(parts)-1] + if owner == "" || name == "" { + return "", "", "", "", fmt.Errorf("remote repository owner and name are required") + } + canonical := fmt.Sprintf("https://gitlab.com/%s/%s.git", owner, name) + if sshStyle { + canonical = fmt.Sprintf("git@%s:%s/%s.git", host, owner, name) + } + return "gitlab", owner, name, canonical, nil +} + +func parseAzureHTTPSRemote(parts []string) (string, string, string, string, error) { + if len(parts) != 4 || parts[2] != "_git" || parts[0] == "" || parts[1] == "" || parts[3] == "" { + return "", "", "", "", fmt.Errorf("invalid Azure DevOps repository URL: expected dev.azure.com/org/project/_git/repo") + } + owner, name := parts[1], parts[3] + canonical := fmt.Sprintf("https://dev.azure.com/%s/%s/_git/%s", parts[0], owner, name) + return providerAzureDevOps, owner, name, canonical, nil +} + +func parseAzureSSHRemote(parts []string) (string, string, string, string, error) { + if len(parts) != 4 || parts[0] != "v3" || parts[1] == "" || parts[2] == "" || parts[3] == "" { + return "", "", "", "", fmt.Errorf("invalid Azure DevOps SSH URL: expected git@ssh.dev.azure.com:v3/org/project/repo") + } + owner, name := parts[2], parts[3] + canonical := fmt.Sprintf("git@ssh.dev.azure.com:v3/%s/%s/%s", parts[1], owner, name) + return providerAzureDevOps, owner, name, canonical, nil +} + // probeProviderDefaultBranchIfMissing returns a default_branch resolved via // the provider prober, but only when the workspace doesn't already hold the // repo with a non-empty default_branch (the existing value wins downstream, @@ -740,34 +873,14 @@ func (s *Service) probeProviderDefaultBranchIfMissing( // Supports: https://github.com/owner/repo, github.com/owner/repo, // https://github.com/owner/repo.git, with optional trailing slashes. func parseGitHubRepoURL(rawURL string) (owner, name string, err error) { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return "", "", fmt.Errorf("empty GitHub URL") - } - - // Add scheme if missing so url.Parse works correctly - if !strings.Contains(rawURL, "://") { - rawURL = "https://" + rawURL - } - - parsed, parseErr := url.Parse(rawURL) + provider, owner, name, _, parseErr := parseRemoteRepositoryURL(rawURL) if parseErr != nil { - return "", "", fmt.Errorf("invalid GitHub URL: %w", parseErr) + return "", "", parseErr } - - if parsed.Host != "github.com" && parsed.Host != "www.github.com" { - return "", "", fmt.Errorf("not a GitHub URL: %s", parsed.Host) + if provider != providerGitHub { + return "", "", fmt.Errorf("not a GitHub URL") } - - // Path should be /owner/name (possibly with .git suffix and trailing slash) - path := strings.Trim(parsed.Path, "/") - path = strings.TrimSuffix(path, ".git") - parts := strings.Split(path, "/") - if len(parts) < 2 || parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid GitHub repository URL: expected github.com/owner/repo") - } - - return parts[0], parts[1], nil + return owner, name, nil } // resolvePRNumber returns the GitHub PR number for a repository input. Prefers @@ -782,7 +895,7 @@ func resolvePRNumber(input TaskRepositoryInput) int { if input.PRNumber > 0 { return input.PRNumber } - rawURL := strings.TrimSpace(input.GitHubURL) + rawURL := effectiveRemoteURL(input) idx := strings.Index(rawURL, "/pull/") if idx < 0 { return 0 diff --git a/apps/web/app/azure-devops/azure-devops-page-client.tsx b/apps/web/app/azure-devops/azure-devops-page-client.tsx new file mode 100644 index 0000000000..54423ff049 --- /dev/null +++ b/apps/web/app/azure-devops/azure-devops-page-client.tsx @@ -0,0 +1,580 @@ +"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 { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Button } from "@kandev/ui/button"; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; +import { PageTopbar } from "@/components/page-topbar"; +import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon"; +import { + AzureDevOpsFilters, + type AzureDevOpsBrowseMode, + type AzureDevOpsFiltersState, +} from "@/components/azure-devops/azure-devops-filters"; +import { AzureDevOpsFeedbackDialog } from "@/components/azure-devops/azure-devops-feedback-dialog"; +import { + AzureDevOpsPullRequestResults, + AzureDevOpsWorkItemResults, +} from "@/components/azure-devops/azure-devops-results"; +import { + AzureDevOpsTaskLauncher, + type AzureDevOpsLaunchPayload, +} from "@/components/azure-devops/azure-devops-task-launcher"; +import { + AzureDevOpsScopeBar, + 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 { + useAzureDevOpsConnection, + useAzureDevOpsPullRequestFeedback, + useAzureDevOpsPullRequestSearch, + useAzureDevOpsWorkItemSearch, +} from "@/hooks/domains/azure-devops/use-azure-devops-browse"; +import { + useAzureDevOpsProjects, + useAzureDevOpsRepositories, +} from "@/hooks/domains/azure-devops/use-azure-devops-projects"; +import { useAzureDevOpsSavedViews } from "@/hooks/domains/azure-devops/use-azure-devops-saved-views"; +import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http"; +import type { AzureDevOpsPullRequest, AzureDevOpsSavedView } from "@/lib/types/azure-devops"; + +const PAGE_SIZE = 25; +const WORK_ITEMS_MODE: AzureDevOpsBrowseMode = "work-items"; +const PULL_REQUESTS_MODE: AzureDevOpsBrowseMode = "pull-requests"; +const DEFAULT_WIQL = + "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project ORDER BY [System.ChangedDate] DESC"; + +const DEFAULT_FILTERS: AzureDevOpsFiltersState = { + projectId: "", + repositoryId: "", + wiql: DEFAULT_WIQL, + top: 50, + status: "active", + creator: "", + reviewer: "", +}; + +type PageProps = { + workspaceId?: string; + workflows: Workflow[]; + steps: WorkflowStep[]; + repositories: Repository[]; +}; + +function NotConfigured({ workspaceId }: { workspaceId?: string }) { + const href = workspaceId + ? `/settings/workspace/${encodeURIComponent(workspaceId)}/integrations/azure-devops` + : "/settings/integrations/azure-devops"; + return ( +
+ + + Azure DevOps is not connected for this workspace.{" "} + + Configure Azure DevOps + + + +
+ ); +} + +function ResultHeader({ + mode, + workItemCount, + pullRequestCount, +}: { + mode: AzureDevOpsBrowseMode; + workItemCount: number; + pullRequestCount: number; +}) { + const count = mode === WORK_ITEMS_MODE ? workItemCount : pullRequestCount; + return ( +
+

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

+ {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(() => { + if (defaultProjectId) { + setFilters((current) => + current.projectId ? current : { ...current, projectId: defaultProjectId }, + ); + } + }, [defaultProjectId]); + const update = useCallback( + (key: K, value: AzureDevOpsFiltersState[K]) => + setFilters((current) => ({ + ...current, + [key]: value, + ...(key === "projectId" ? { repositoryId: "" } : {}), + })), + [], + ); + const replace = useCallback((next: AzureDevOpsFiltersState) => setFilters(next), []); + return { filters, update, replace }; +} + +function modeForKind(kind: AzureDevOpsScopeSelection["kind"]): AzureDevOpsBrowseMode { + return kind === "work_item" ? WORK_ITEMS_MODE : PULL_REQUESTS_MODE; +} + +function kindForMode(mode: AzureDevOpsBrowseMode): AzureDevOpsScopeSelection["kind"] { + return mode === WORK_ITEMS_MODE ? "work_item" : "pull_request"; +} + +function filtersFromSavedView( + current: AzureDevOpsFiltersState, + view: AzureDevOpsSavedView, +): AzureDevOpsFiltersState { + return { + ...current, + projectId: view.projectId, + repositoryId: view.repositoryId ?? "", + wiql: view.wiql ?? current.wiql, + top: view.top ?? 50, + status: view.status ?? "active", + creator: view.creator ?? "", + reviewer: view.reviewer ?? "", + }; +} + +function filtersForScope( + current: AzureDevOpsFiltersState, + selection: AzureDevOpsScopeSelection, + views: AzureDevOpsSavedView[], +): 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( + (candidate) => candidate.value === selection.id, + ); + return preset ? { ...current, ...preset.filters } : undefined; +} + +function useDefaultAzureRepository( + repositoryId: string, + repositories: Array<{ id: string }>, + update: (key: "repositoryId", value: string) => void, +) { + useEffect(() => { + if (!repositoryId && repositories[0]?.id) update("repositoryId", repositories[0].id); + }, [repositories, repositoryId, update]); +} + +type RunAzureSearch = ( + nextSkip?: number, + override?: { mode: AzureDevOpsBrowseMode; filters: AzureDevOpsFiltersState }, +) => void; + +function useInitialAzureSearch({ + connection, + filters, + runSearch, +}: { + connection: ReturnType; + filters: AzureDevOpsFiltersState; + runSearch: RunAzureSearch; +}) { + const searchedWorkspace = useRef(""); + const connectedWorkspaceId = connection.data?.hasSecret ? connection.data.workspaceId : undefined; + useEffect(() => { + if ( + !connectedWorkspaceId || + !filters.projectId || + searchedWorkspace.current === connectedWorkspaceId + ) { + return; + } + searchedWorkspace.current = connectedWorkspaceId; + runSearch(0); + }, [connectedWorkspaceId, filters.projectId, runSearch]); +} + +function useAzureScopeControls({ + filters, + replace, + runSearch, + views, + setMode, + setSkip, +}: { + filters: AzureDevOpsFiltersState; + replace: (next: AzureDevOpsFiltersState) => void; + runSearch: RunAzureSearch; + views: AzureDevOpsSavedView[]; + setMode: (mode: AzureDevOpsBrowseMode) => void; + setSkip: (skip: number) => void; +}) { + const [selection, setSelection] = useState({ + kind: "work_item", + source: "preset", + id: "recent", + }); + const selectScope = useCallback( + (next: AzureDevOpsScopeSelection) => { + setSelection(next); + const nextMode = modeForKind(next.kind); + setMode(nextMode); + setSkip(0); + const nextFilters = filtersForScope(filters, next, views); + if (nextFilters) { + replace(nextFilters); + runSearch(0, { mode: nextMode, filters: nextFilters }); + } + }, + [filters, replace, runSearch, setMode, setSkip, views], + ); + const markCustom = useCallback( + () => setSelection((current) => ({ ...current, source: "preset", id: "" })), + [], + ); + return { selection, setSelection, selectScope, markCustom }; +} + +function useAzureSavedViewControls({ + mode, + filters, + savedViews, + setSelection, +}: { + mode: AzureDevOpsBrowseMode; + filters: AzureDevOpsFiltersState; + savedViews: ReturnType; + setSelection: (selection: AzureDevOpsScopeSelection) => void; +}) { + const [saveViewOpen, setSaveViewOpen] = useState(false); + const canSaveCurrent = Boolean( + filters.projectId && (mode === WORK_ITEMS_MODE ? filters.wiql.trim() : filters.repositoryId), + ); + const saveCurrentView = useCallback( + async (label: string) => { + const view: AzureDevOpsSavedView = { + id: `az_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + kind: kindForMode(mode), + label, + projectId: filters.projectId, + repositoryId: mode === PULL_REQUESTS_MODE ? filters.repositoryId : undefined, + wiql: mode === WORK_ITEMS_MODE ? filters.wiql : undefined, + top: mode === WORK_ITEMS_MODE ? filters.top : undefined, + status: mode === PULL_REQUESTS_MODE ? filters.status : undefined, + creator: mode === PULL_REQUESTS_MODE ? filters.creator : undefined, + reviewer: mode === PULL_REQUESTS_MODE ? filters.reviewer : undefined, + createdAt: new Date().toISOString(), + }; + await savedViews.replace([...savedViews.views, view]); + setSelection({ kind: view.kind, source: "saved", id: view.id }); + }, + [filters, mode, savedViews, setSelection], + ); + 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( + ( + nextSkip: number = 0, + override?: { mode: AzureDevOpsBrowseMode; filters: AzureDevOpsFiltersState }, + ) => { + setMobileFiltersOpen(false); + const searchMode = override?.mode ?? mode; + const searchFilters = override?.filters ?? filters; + if (searchMode === WORK_ITEMS_MODE) { + void workItems.search({ + project: searchFilters.projectId, + wiql: searchFilters.wiql, + top: searchFilters.top, + }); + return; + } + setSkip(nextSkip); + void pullRequests.search({ + project: searchFilters.projectId, + repository: searchFilters.repositoryId, + status: searchFilters.status === "all" ? undefined : searchFilters.status, + creator: searchFilters.creator || undefined, + reviewer: searchFilters.reviewer || undefined, + skip: nextSkip, + top: PAGE_SIZE, + }); + }, + [filters, mode, pullRequests, workItems], + ); + + useInitialAzureSearch({ connection, filters, runSearch }); + + const openFeedback = (pullRequest: AzureDevOpsPullRequest) => { + setFeedbackOpen(true); + void feedback.load(pullRequest); + }; + + const scope = useAzureScopeControls({ + filters, + replace, + runSearch, + views: savedViews.views, + setMode, + setSkip, + }); + const savedViewControls = useAzureSavedViewControls({ + mode, + filters, + savedViews, + setSelection: scope.setSelection, + }); + + const updateFromUI = useCallback( + (key: K, value: AzureDevOpsFiltersState[K]) => { + scope.markCustom(); + update(key, value); + }, + [scope, update], + ); + + return { + mode, + selection: scope.selection, + selectScope: scope.selectScope, + saveViewOpen: savedViewControls.saveViewOpen, + setSaveViewOpen: savedViewControls.setSaveViewOpen, + mobileFiltersOpen, + setMobileFiltersOpen, + launchPayload, + setLaunchPayload, + feedbackOpen, + setFeedbackOpen, + skip, + connection, + projectList, + filters, + update: updateFromUI, + repositoryList, + workItems, + pullRequests, + feedback, + runSearch, + openFeedback, + savedViews, + canSaveCurrent: savedViewControls.canSaveCurrent, + saveCurrentView: savedViewControls.saveCurrentView, + }; +} + +type PageState = ReturnType; + +function BrowseResults({ state }: { state: PageState }) { + return ( +
+ +
+ {state.mode === WORK_ITEMS_MODE ? ( + state.setLaunchPayload({ kind: "work-item", item })} + /> + ) : ( + + state.setLaunchPayload({ kind: "pull-request", pullRequest }) + } + /> + )} +
+ {state.mode === PULL_REQUESTS_MODE && ( + + )} +
+ ); +} + +function MobileFilters({ + state, + filterProps, +}: { + state: PageState; + filterProps: Omit, "idSuffix">; +}) { + return ( + + + + Azure DevOps filters + + + + + ); +} + +function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }: PageProps) { + const state = useAzureDevOpsPageState(workspaceId); + + if (state.connection.loading) return null; + if (!state.connection.data?.hasSecret) return ; + + const searchLoading = + state.mode === WORK_ITEMS_MODE ? state.workItems.loading : state.pullRequests.loading; + const filterProps = { + mode: state.mode, + filters: state.filters, + projects: state.projectList.data, + repositories: state.repositoryList.data, + loading: searchLoading, + onChange: state.update, + onSearch: () => state.runSearch(0), + }; + + return ( +
+ } + actions={ + + } + /> + void state.savedViews.remove(id)} + canSaveCurrent={state.canSaveCurrent} + onSaveCurrent={() => state.setSaveViewOpen(true)} + /> +
+ +
+ + + { + state.setFeedbackOpen(open); + if (!open) state.feedback.clear(); + }} + /> + state.setLaunchPayload(null)} + /> + +
+ ); +} + +export function AzureDevOpsPageClient(props: PageProps) { + return ; +} diff --git a/apps/web/app/settings/integrations/azure-devops/page.tsx b/apps/web/app/settings/integrations/azure-devops/page.tsx new file mode 100644 index 0000000000..0485cc2888 --- /dev/null +++ b/apps/web/app/settings/integrations/azure-devops/page.tsx @@ -0,0 +1,7 @@ +import { AzureDevOpsIntegrationPage } from "@/components/azure-devops/azure-devops-settings"; + +export default function IntegrationsAzureDevOpsPage({ + workspaceId, +}: { workspaceId?: string } = {}) { + return ; +} diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index 8180ffaa62..b408eec883 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -2,6 +2,7 @@ import Link from "@/components/routing/app-link"; import { IconBrandGithub, IconBrandGitlab, + IconBrandAzure, IconBrandSentry, IconBrandSlack, IconHexagon, @@ -10,6 +11,12 @@ import { import { Card, CardContent } from "@kandev/ui/card"; const INTEGRATIONS = [ + { + slug: "azure-devops", + label: "Azure DevOps", + description: "Azure Boards work items and Azure Repos pull requests.", + Icon: IconBrandAzure, + }, { slug: "github", label: "GitHub", @@ -62,8 +69,8 @@ export default function IntegrationsIndexPage({ workspaceId }: IntegrationsIndex

Integrations

- Connect Kandev to third-party services. Credentials are install-wide; per-workspace - watchers and presets live inside each integration page. + Connect Kandev to third-party services. Connection scope and available settings are shown + on each integration page.

diff --git a/apps/web/components/app-sidebar/sections/integrations-section.test.tsx b/apps/web/components/app-sidebar/sections/integrations-section.test.tsx index 2639530eb5..0d0c56d2b8 100644 --- a/apps/web/components/app-sidebar/sections/integrations-section.test.tsx +++ b/apps/web/components/app-sidebar/sections/integrations-section.test.tsx @@ -139,6 +139,17 @@ describe("IntegrationsSection", () => { expect(screen.getByRole("link", { name: "Sentry" })).toBeTruthy(); }); + it("uses the Azure DevOps product mark for Azure links", () => { + storeState.appSidebar.sectionExpanded.integrations = true; + linksMock.mockReturnValue([ + { id: "azure-devops", label: "Azure DevOps", href: "/azure-devops" }, + ]); + + renderSection(); + + expect(screen.getAllByTestId("azure-devops-icon")).toHaveLength(2); + }); + const costPerModelItem = { id: "cost-per-model", label: "Cost per Model", diff --git a/apps/web/components/app-sidebar/sections/integrations-section.tsx b/apps/web/components/app-sidebar/sections/integrations-section.tsx index 44ca131a9b..9429c099dd 100644 --- a/apps/web/components/app-sidebar/sections/integrations-section.tsx +++ b/apps/web/components/app-sidebar/sections/integrations-section.tsx @@ -2,6 +2,7 @@ import Link from "@/components/routing/app-link"; import { usePathname } from "@/lib/routing/client-router"; +import type { ComponentType } from "react"; import { IconBrandGithub, IconBrandGitlab, @@ -9,7 +10,6 @@ import { IconPlugConnected, IconTicket, } from "@tabler/icons-react"; -import type { Icon as TablerIcon } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useConfiguredIntegrationLinks } from "@/components/integrations/integrations-menu"; import { useFeature } from "@/hooks/domains/features/use-feature"; @@ -22,12 +22,16 @@ import { SIDEBAR_ITEM_INACTIVE, } from "../app-sidebar-constants"; import { AppSidebarSection } from "../app-sidebar-section"; +import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon"; type IntegrationsSectionProps = { collapsed: boolean; }; -const INTEGRATION_ICONS: Record = { +type IntegrationIcon = ComponentType<{ className?: string }>; + +const INTEGRATION_ICONS: Record = { + "azure-devops": AzureDevOpsIcon, github: IconBrandGithub, gitlab: IconBrandGitlab, jira: IconTicket, @@ -66,7 +70,7 @@ function IntegrationHeaderShortcuts({ links }: { links: ConfiguredIntegrationLin type IntegrationRowProps = { href: string; label: string; - icon: TablerIcon; + icon: IntegrationIcon; active: boolean; testId?: string; }; diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx index 41e95b7e75..59c453e0e3 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx @@ -3,7 +3,7 @@ import Link from "@/components/routing/app-link"; import { IconChevronRight } from "@tabler/icons-react"; import type { Icon as TablerIcon } from "@tabler/icons-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; import { cn } from "@/lib/utils"; import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "../../app-sidebar-constants"; @@ -14,9 +14,10 @@ const INACTIVE_CLASS = SIDEBAR_ITEM_INACTIVE; type SettingsLeafProps = { href: string; label: string; - icon?: TablerIcon; + icon?: ComponentType<{ className?: string }>; /** Pre-rendered leading visual (e.g. AgentLogo). Takes precedence over `icon`. */ leadingIcon?: ReactNode; + labelSuffix?: ReactNode; isActive: boolean; /** Nesting level — used to add left padding. */ depth?: number; @@ -38,6 +39,7 @@ export function SettingsLeaf({ label, icon: Icon, leadingIcon, + labelSuffix, isActive, depth = 0, }: SettingsLeafProps) { @@ -53,6 +55,7 @@ export function SettingsLeaf({ > {leadingIcon ?? (Icon && )} {label} + {labelSuffix} ); } diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index 67efd4a2a3..8939d9f4bc 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -25,6 +25,16 @@ const state = { }, }; +const integrationAvailability = vi.hoisted(() => ({ + azureDevOps: true, + github: false, + gitlab: false, + jira: false, + linear: false, + sentry: false, + slack: false, +})); + vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), })); @@ -33,6 +43,31 @@ vi.mock("@/hooks/domains/settings/use-available-agents", () => ({ useAvailableAgents: () => undefined, })); +vi.mock("@/hooks/domains/azure-devops/use-azure-devops-availability", () => ({ + useAzureDevOpsAvailable: () => integrationAvailability.azureDevOps, +})); +vi.mock("@/hooks/domains/github/use-github-status", () => ({ + useGitHubStatus: () => ({ + status: integrationAvailability.github ? { authenticated: true } : null, + loading: false, + }), +})); +vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({ + useGitLabAvailable: () => integrationAvailability.gitlab, +})); +vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({ + useJiraAuthed: () => integrationAvailability.jira, +})); +vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({ + useLinearAuthed: () => integrationAvailability.linear, +})); +vi.mock("@/hooks/domains/sentry/use-sentry-availability", () => ({ + useSentryAvailable: () => integrationAvailability.sentry, +})); +vi.mock("@/hooks/domains/slack/use-slack-availability", () => ({ + useSlackAuthed: () => integrationAvailability.slack, +})); + vi.mock("@kandev/ui/collapsible", async () => { const React = await vi.importActual("react"); const CollapsibleContext = React.createContext(false); @@ -56,6 +91,13 @@ describe("SettingsTree rendering", () => { state.setActiveWorkspace.mockClear(); state.settingsAgents.items = []; state.executors.items = []; + integrationAvailability.azureDevOps = true; + integrationAvailability.github = false; + integrationAvailability.gitlab = false; + integrationAvailability.jira = false; + integrationAvailability.linear = false; + integrationAvailability.sentry = false; + integrationAvailability.slack = false; }); afterEach(() => cleanup()); @@ -156,6 +198,31 @@ describe("SettingsTree rendering", () => { ); expect(screen.getByRole("button", { name: "Expand Main Workspace" })).toBeTruthy(); }); +}); + +describe("SettingsTree integration status", () => { + beforeEach(() => { + state.workspaces.activeId = MAIN_WORKSPACE_ID; + state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }]; + integrationAvailability.azureDevOps = true; + integrationAvailability.github = false; + }); + + afterEach(() => cleanup()); + + it("labels configured integrations as enabled", () => { + render( + , + ); + + expect(screen.getByRole("link", { name: "Azure DevOps Enabled" })).toBeTruthy(); + expect(screen.getByTestId("azure-devops-icon")).toBeTruthy(); + expect(screen.getByRole("link", { name: "GitHub" })).toBeTruthy(); + }); +}); + +describe("SettingsTree standalone leaves", () => { + afterEach(() => cleanup()); it("keeps Voice Mode in the settings tree as a standalone active leaf", () => { render(); diff --git a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx index 0c67ba06a0..885076005f 100644 --- a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ComponentType } from "react"; import { IconArrowsShuffle, IconBolt, @@ -14,13 +14,23 @@ import { IconPlugConnected, IconTicket, } from "@tabler/icons-react"; -import type { Icon as TablerIcon } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; +import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon"; +import { useAzureDevOpsAvailable } from "@/hooks/domains/azure-devops/use-azure-devops-availability"; +import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; +import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr"; +import { useJiraAuthed } from "@/hooks/domains/jira/use-jira-availability"; +import { useLinearAuthed } from "@/hooks/domains/linear/use-linear-availability"; +import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability"; +import { useSlackAuthed } from "@/hooks/domains/slack/use-slack-availability"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; const ROOT_HREF = "/settings/workspace"; -const INTEGRATIONS: Array<{ slug: string; label: string; icon: TablerIcon }> = [ +type IntegrationIcon = ComponentType<{ className?: string }>; + +const INTEGRATIONS: Array<{ slug: string; label: string; icon: IntegrationIcon }> = [ + { slug: "azure-devops", label: "Azure DevOps", icon: AzureDevOpsIcon }, { slug: "github", label: "GitHub", icon: IconBrandGithub }, { slug: "gitlab", label: "GitLab", icon: IconBrandGitlab }, { slug: "jira", label: "Jira", icon: IconTicket }, @@ -35,6 +45,54 @@ const ACTIVE_WORKSPACE_LABEL = ( ); +const ENABLED_LABEL = ( + + Enabled + +); + +function WorkspaceIntegrationItems({ + workspaceId, + integrationsPath, + pathname, +}: { + workspaceId: string; + integrationsPath: string; + pathname: string; +}) { + const azureDevOps = useAzureDevOpsAvailable(workspaceId); + const { status: githubStatus } = useGitHubStatus(); + const gitlab = useGitLabAvailable(); + const jira = useJiraAuthed(workspaceId); + const linear = useLinearAuthed(workspaceId); + const sentry = useSentryAvailable(workspaceId); + const slack = useSlackAuthed(workspaceId); + const enabled = new Set([ + ...(azureDevOps ? ["azure-devops"] : []), + ...(githubStatus?.authenticated || githubStatus?.token_configured ? ["github"] : []), + ...(gitlab ? ["gitlab"] : []), + ...(jira ? ["jira"] : []), + ...(linear ? ["linear"] : []), + ...(sentry ? ["sentry"] : []), + ...(slack ? ["slack"] : []), + ]); + + return INTEGRATIONS.map(({ slug, label, icon }) => { + const href = `${integrationsPath}/${slug}`; + return ( + + ); + }); +} + type WorkspacesGroupProps = { pathname: string; expanded?: boolean; @@ -134,19 +192,11 @@ export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGrou defaultExpanded={integrationsActive} depth={2} > - {INTEGRATIONS.map(({ slug, label, icon }) => { - const href = `${integrationsPath}/${slug}`; - return ( - - ); - })} + = 10) return "Approved"; + if (vote >= 5) return "Approved with suggestions"; + if (vote <= -10) return "Rejected"; + if (vote <= -5) return "Waiting for author"; + return "No vote"; +} + +function Summary({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) { + return ( +
+ Review: {feedback.reviewState || "pending"} + Policies: {feedback.policyState || "none"} + {feedback.linkedWorkItems.length} linked items +
+ ); +} + +function Reviewers({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) { + return ( +
+

Reviewers

+ {feedback.reviewers.length === 0 ? ( +

No reviewers.

+ ) : ( +
+ {feedback.reviewers.map((reviewer) => ( +
+ {reviewer.displayName} + + {voteLabel(reviewer.vote)} + +
+ ))} +
+ )} +
+ ); +} + +function Policies({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) { + return ( +
+

Branch policies

+ {feedback.policies.length === 0 ? ( +

No policy evaluations.

+ ) : ( +
+ {feedback.policies.map((policy) => ( +
+ {policy.name} + + {policy.status} + +
+ ))} +
+ )} +
+ ); +} + +function Threads({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) { + const comments = feedback.threads.flatMap((thread) => + thread.comments.map((comment) => ({ ...comment, threadId: thread.id })), + ); + return ( +
+

Discussion

+ {comments.length === 0 ? ( +

No comments.

+ ) : ( +
+ {comments.map((comment) => ( +
+
{comment.author.displayName}
+

+ {comment.content} +

+
+ ))} +
+ )} +
+ ); +} + +export function AzureDevOpsFeedbackDialog({ + open, + loading, + error, + feedback, + onOpenChange, +}: { + open: boolean; + loading: boolean; + error: string | null; + feedback: AzureDevOpsPullRequestFeedback | null; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + + {feedback?.pullRequest.title ?? "Pull request feedback"} + + + {feedback + ? `${feedback.pullRequest.repositoryName} · PR ${feedback.pullRequest.id}` + : "Azure DevOps review and policy state"} + + + {loading &&

Loading feedback...

} + {error && ( +

+ {error} +

+ )} + {feedback && ( +
+ + + + + + + +
+ )} +
+
+ ); +} diff --git a/apps/web/components/azure-devops/azure-devops-filters.tsx b/apps/web/components/azure-devops/azure-devops-filters.tsx new file mode 100644 index 0000000000..a38ad10fc3 --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-filters.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState } from "react"; +import { IconAdjustments, IconChevronDown, IconSearch } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +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 AzureDevOpsFiltersState = { + projectId: string; + repositoryId: string; + wiql: string; + top: number; + status: string; + creator: string; + reviewer: string; +}; + +type FiltersProps = { + idSuffix: "" | "-mobile"; + mode: AzureDevOpsBrowseMode; + filters: AzureDevOpsFiltersState; + projects: AzureDevOpsProject[]; + repositories: AzureDevOpsRepository[]; + loading: boolean; + onChange: ( + key: K, + value: AzureDevOpsFiltersState[K], + ) => void; + onSearch: () => void; + compact?: boolean; +}; + +function Field({ compact, children }: { compact?: boolean; children: React.ReactNode }) { + return
{children}
; +} + +function ProjectFilter({ + idSuffix, + value, + projects, + onChange, + compact, +}: { + idSuffix: FiltersProps["idSuffix"]; + value: string; + projects: AzureDevOpsProject[]; + onChange: (value: string) => void; + compact?: boolean; +}) { + const controlId = `azure-devops-filter-project${idSuffix}`; + return ( + + + + + ); +} + +function PullRequestFilters({ + idSuffix, + filters, + repositories, + onChange, + compact, +}: Pick) { + const [advanced, setAdvanced] = useState(false); + return ( + <> + + + + + + + + + + + + onChange("creator", event.target.value)} + /> + + + + onChange("reviewer", event.target.value)} + /> + + + + ); +} + +function AdvancedFilters({ + open, + onOpenChange, + compact, + children, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + compact?: boolean; + children: React.ReactNode; +}) { + return ( + + + +
+ {children} +
+
+
+ ); +} + +function WorkItemFilters({ + idSuffix, + filters, + onChange, + compact, +}: Pick) { + const [advanced, setAdvanced] = useState(false); + return ( + <> + + + + + +
+ +