diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index d3c7b11eaa..9497e3fabc 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -2,6 +2,7 @@ package backendapp import ( "context" + "errors" "fmt" "sync" "time" @@ -15,9 +16,11 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/common/logger" + githubsvc "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/models" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/pkg/api/v1" ) @@ -536,6 +539,45 @@ func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, s return err } +type githubTaskIssueStoreAdapter struct { + svc *taskservice.Service +} + +func (a githubTaskIssueStoreAdapter) GetTask(ctx context.Context, taskID string) (*models.Task, error) { + task, err := a.svc.GetTask(ctx, taskID) + if err != nil { + return nil, wrapGitHubTaskIssueStoreError(err) + } + return task, nil +} + +func (a githubTaskIssueStoreAdapter) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { + task, err := a.svc.GetTask(ctx, taskID) + if err != nil { + return nil, wrapGitHubTaskIssueStoreError(err) + } + return task.Repositories, nil +} + +func (a githubTaskIssueStoreAdapter) GetRepository(ctx context.Context, repositoryID string) (*models.Repository, error) { + return a.svc.GetRepository(ctx, repositoryID) +} + +func (a githubTaskIssueStoreAdapter) UpdateTaskMetadata(ctx context.Context, taskID string, metadata map[string]interface{}) (*models.Task, error) { + task, err := a.svc.UpdateTask(ctx, taskID, &taskservice.UpdateTaskRequest{Metadata: metadata}) + if err != nil { + return nil, wrapGitHubTaskIssueStoreError(err) + } + return task, nil +} + +func wrapGitHubTaskIssueStoreError(err error) error { + if errors.Is(err, taskrepo.ErrTaskNotFound) { + return fmt.Errorf("%w: %w", githubsvc.ErrTaskNotFound, err) + } + return err +} + // ProcessOnTurnStart forwards to the orchestrator service. func (w *orchestratorWrapper) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { return w.svc.ProcessOnTurnStart(ctx, taskID, sessionID) diff --git a/apps/backend/internal/backendapp/adapters_test.go b/apps/backend/internal/backendapp/adapters_test.go index aabc7e9e66..45aa924fd3 100644 --- a/apps/backend/internal/backendapp/adapters_test.go +++ b/apps/backend/internal/backendapp/adapters_test.go @@ -2,10 +2,14 @@ package backendapp import ( "context" + "errors" + "fmt" "sync" "testing" "github.com/kandev/kandev/internal/common/logger" + githubsvc "github.com/kandev/kandev/internal/github" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" ) @@ -46,7 +50,7 @@ func TestGetSessionModel_Caching(t *testing.T) { // Test cache miss for unknown session returns "" // (svc is nil, so DB lookup would fail gracefully) - // We need a non-nil svc to avoid panic — use a minimal mock approach + // We need a non-nil svc to avoid panic - use a minimal mock approach // Instead, verify the cache was populated for existing entries adapter.sessionModelMu.RLock() if len(adapter.sessionModelCache) != 2 { @@ -109,6 +113,22 @@ func TestMessageCreatorAdapter_StructFields(t *testing.T) { } } +func TestWrapGitHubTaskIssueStoreError(t *testing.T) { + taskErr := fmt.Errorf("load task: %w", taskrepo.ErrTaskNotFound) + wrapped := wrapGitHubTaskIssueStoreError(taskErr) + if !errors.Is(wrapped, githubsvc.ErrTaskNotFound) { + t.Fatalf("wrapped error should match github ErrTaskNotFound: %v", wrapped) + } + if !errors.Is(wrapped, taskrepo.ErrTaskNotFound) { + t.Fatalf("wrapped error should preserve task repo ErrTaskNotFound: %v", wrapped) + } + + otherErr := errors.New("database unavailable") + if got := wrapGitHubTaskIssueStoreError(otherErr); got != otherErr { + t.Fatalf("non-not-found error changed: got %v, want %v", got, otherErr) + } +} + // jiraSecretAdapter Set/Exists branching is tested in // internal/integrations/secretadapter/secretadapter_test.go now that the // upsert helper lives there. diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index ce93cd3825..767031cc1d 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -491,6 +491,7 @@ func startAgentInfrastructure( if services.GitHub != nil { orchestratorSvc.SetGitHubService(services.GitHub) services.GitHub.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task}) + services.GitHub.SetTaskIssueStore(githubTaskIssueStoreAdapter{svc: services.Task}) services.GitHub.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task}) log.Info("GitHub service configured for orchestrator (PR auto-detection enabled)") diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index 226d7330bf..cf49347e56 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -41,6 +41,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/task-prs", c.httpListTaskPRs) api.POST("/task-prs", c.httpCreateTaskPR) api.GET("/task-prs/:taskId", c.httpGetTaskPR) + api.PUT("/tasks/:taskId/issue", c.httpLinkTaskIssue) + api.DELETE("/tasks/:taskId/issue", c.httpUnlinkTaskIssue) api.GET("/tasks/:taskId/ci-options", c.httpGetTaskCIOptions) api.PATCH("/tasks/:taskId/ci-options", c.httpPatchTaskCIOptions) @@ -201,6 +203,62 @@ func (c *Controller) httpGetTaskPR(ctx *gin.Context) { ctx.JSON(http.StatusOK, tp) } +func (c *Controller) httpLinkTaskIssue(ctx *gin.Context) { + var req LinkTaskIssueRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := c.service.LinkTaskIssue(ctx.Request.Context(), ctx.Param("taskId"), req) + if err != nil { + c.handleTaskIssueLinkError(ctx, err) + return + } + ctx.JSON(http.StatusOK, resp) +} + +func (c *Controller) httpUnlinkTaskIssue(ctx *gin.Context) { + if err := c.service.UnlinkTaskIssue(ctx.Request.Context(), ctx.Param("taskId")); err != nil { + c.handleTaskIssueLinkError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"unlinked": true}) +} + +func (c *Controller) handleTaskIssueLinkError(ctx *gin.Context, err error) { + switch { + case errors.Is(err, ErrNoClient): + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "GitHub is not configured. Connect GitHub in Settings > Integrations.", + "code": "github_not_configured", + }) + case errors.Is(err, ErrInvalidIssueReference): + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, ErrIssueRepositoryMismatch): + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + case errors.Is(err, errStoreUnavailable): + c.logger.Error("task issue store unavailable", zap.Error(err)) + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "GitHub task issue linking is temporarily unavailable. Please try again.", + "code": "github_task_issue_unavailable", + }) + case errors.Is(err, ErrTaskNotFound): + ctx.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) + default: + var apiErr *GitHubAPIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden: + c.logger.Debug("GitHub issue fetch failed", zap.Int("status", apiErr.StatusCode), zap.Error(err)) + ctx.JSON(apiErr.StatusCode, gin.H{"error": "failed to fetch GitHub issue"}) + return + } + } + c.logger.Error("task issue link request failed", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to link GitHub issue"}) + } +} + func (c *Controller) httpGetTaskCIOptions(ctx *gin.Context) { resp, err := c.service.GetTaskCIOptionsResponse(ctx.Request.Context(), ctx.Param("taskId")) if err != nil { diff --git a/apps/backend/internal/github/controller_test.go b/apps/backend/internal/github/controller_test.go index 55166f7773..05bbccf1d3 100644 --- a/apps/backend/internal/github/controller_test.go +++ b/apps/backend/internal/github/controller_test.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" + taskmodels "github.com/kandev/kandev/internal/task/models" ) // stubClient implements Client with no-op defaults; override fields as needed. @@ -177,6 +178,234 @@ func setupControllerStoreTest(t *testing.T) (*gin.Engine, *Store) { return router, store } +func TestHttpLinkTaskIssue_SuccessAndInvalidJSON(t *testing.T) { + router, ctrl := setupControllerTest(&stubClient{ + getIssueFunc: func(_ context.Context, owner, repo string, number int) (*Issue, error) { + return &Issue{ + Number: number, + Title: "Link existing task", + HTMLURL: fmt.Sprintf("https://github.com/%s/%s/issues/%d", owner, repo, number), + RepoOwner: owner, + RepoName: repo, + }, nil + }, + }) + ctrl.service.SetTaskIssueStore(&fakeTaskIssueStore{ + task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{"keep": "me"}}, + repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}}, + entities: map[string]*taskmodels.Repository{ + "repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"}, + }, + }) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/github/tasks/task-1/issue", bytes.NewBufferString(`{"issue":`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected invalid JSON status 400, got %d: %s", w.Code, w.Body.String()) + } + assertJSONError(t, w.Body.Bytes(), "invalid payload") + + req = httptest.NewRequest(http.MethodPut, "/api/v1/github/tasks/task-1/issue", bytes.NewBufferString(`{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected link status 200, got %d: %s", w.Code, w.Body.String()) + } + var got TaskIssueLinkResponse + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.TaskID != "task-1" || got.Owner != "kdlbs" || got.Repo != "kandev" || got.IssueNumber != 1470 { + t.Fatalf("unexpected response: %+v", got) + } +} + +func TestHttpUnlinkTaskIssue_Success(t *testing.T) { + router, ctrl := setupControllerTest(&stubClient{}) + store := &fakeTaskIssueStore{ + task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{ + taskMetaIssueURL: "https://github.com/kdlbs/kandev/issues/1470", + taskMetaIssueNumber: 1470, + "keep": "me", + }}, + } + ctrl.service.SetTaskIssueStore(store) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/github/tasks/task-1/issue", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected unlink status 200, got %d: %s", w.Code, w.Body.String()) + } + var got map[string]bool + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if !got["unlinked"] { + t.Fatalf("unexpected response: %+v", got) + } + if _, ok := store.updated[taskMetaIssueURL]; ok { + t.Fatalf("issue metadata should be removed: %+v", store.updated) + } +} + +func TestHttpUnlinkTaskIssue_TaskNotFound(t *testing.T) { + router, ctrl := setupControllerTest(&stubClient{}) + ctrl.service.SetTaskIssueStore(&fakeTaskIssueStore{ + taskErr: fmt.Errorf("task lookup failed: %w", ErrTaskNotFound), + }) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/github/tasks/missing-task/issue", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected unlink task-not-found status 404, got %d: %s", w.Code, w.Body.String()) + } + assertJSONError(t, w.Body.Bytes(), "task not found") +} + +func TestHttpLinkTaskIssue_ErrorMapping(t *testing.T) { + tests := []struct { + name string + client Client + store TaskIssueStore + body string + wantStatus int + wantError string + wantCode string + }{ + { + name: "no client", + client: nil, + store: &fakeTaskIssueStore{}, + body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, + wantStatus: http.StatusServiceUnavailable, + wantError: "GitHub is not configured. Connect GitHub in Settings > Integrations.", + wantCode: "github_not_configured", + }, + { + name: "store unavailable", + client: &stubClient{}, + body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, + wantStatus: http.StatusServiceUnavailable, + wantError: "GitHub task issue linking is temporarily unavailable. Please try again.", + wantCode: "github_task_issue_unavailable", + }, + { + name: "invalid reference", + client: &stubClient{}, + store: &fakeTaskIssueStore{}, + body: `{"issue":"#1470"}`, + wantStatus: http.StatusBadRequest, + wantError: "invalid GitHub issue reference: owner and repo are required for issue numbers", + }, + { + name: "repository mismatch", + client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { + return &Issue{Number: 1, HTMLURL: "https://github.com/other/repo/issues/1", RepoOwner: "other", RepoName: "repo"}, nil + }}, + store: &fakeTaskIssueStore{ + task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}, + repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}}, + entities: map[string]*taskmodels.Repository{ + "repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"}, + }, + }, + body: `{"issue":"https://github.com/other/repo/issues/1"}`, + wantStatus: http.StatusUnprocessableEntity, + wantError: "GitHub issue repository is not attached to task", + }, + { + name: "upstream not found", + client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { + return nil, &GitHubAPIError{StatusCode: http.StatusNotFound, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"} + }}, + store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}}, + body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, + wantStatus: http.StatusNotFound, + wantError: "failed to fetch GitHub issue", + }, + { + name: "upstream unauthorized", + client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { + return nil, &GitHubAPIError{StatusCode: http.StatusUnauthorized, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"} + }}, + store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}}, + body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, + wantStatus: http.StatusUnauthorized, + wantError: "failed to fetch GitHub issue", + }, + { + name: "upstream forbidden", + client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { + return nil, &GitHubAPIError{StatusCode: http.StatusForbidden, Endpoint: "/repos/kdlbs/kandev/issues/1470", Body: "private detail"} + }}, + store: &fakeTaskIssueStore{task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}}, + body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, + wantStatus: http.StatusForbidden, + wantError: "failed to fetch GitHub issue", + }, + { + name: "task not found", + client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { + return &Issue{Number: 1470, HTMLURL: "https://github.com/kdlbs/kandev/issues/1470", RepoOwner: "kdlbs", RepoName: "kandev"}, nil + }}, + store: &fakeTaskIssueStore{taskErr: fmt.Errorf("task lookup failed: %w", ErrTaskNotFound)}, + body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, + wantStatus: http.StatusNotFound, + wantError: "task not found", + }, + { + name: "default error", + client: &stubClient{getIssueFunc: func(context.Context, string, string, int) (*Issue, error) { + return &Issue{Number: 1470, HTMLURL: "https://github.com/kdlbs/kandev/issues/1470", RepoOwner: "kdlbs", RepoName: "kandev"}, nil + }}, + store: &fakeTaskIssueStore{ + task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}, + updateErr: errors.New("database unavailable"), + }, + body: `{"issue":"https://github.com/kdlbs/kandev/issues/1470"}`, + wantStatus: http.StatusInternalServerError, + wantError: "failed to link GitHub issue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router, ctrl := setupControllerTest(tt.client) + if tt.store != nil { + ctrl.service.SetTaskIssueStore(tt.store) + } + req := httptest.NewRequest(http.MethodPut, "/api/v1/github/tasks/task-1/issue", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d: %s", w.Code, tt.wantStatus, w.Body.String()) + } + got := assertJSONError(t, w.Body.Bytes(), tt.wantError) + if tt.wantCode != "" && got["code"] != tt.wantCode { + t.Fatalf("code = %v, want %s; body=%+v", got["code"], tt.wantCode, got) + } + }) + } +} + +func assertJSONError(t *testing.T, body []byte, want string) map[string]string { + t.Helper() + var got map[string]string + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode error body: %v; body=%s", err, string(body)) + } + if got["error"] != want { + t.Fatalf("error = %q, want %q; body=%+v", got["error"], want, got) + } + return got +} + func TestHttpTaskCIOptions_DefaultAndPatch(t *testing.T) { router, store := setupControllerStoreTest(t) ctx := context.Background() diff --git a/apps/backend/internal/github/errors.go b/apps/backend/internal/github/errors.go index ca74c08e82..6c128a8fd9 100644 --- a/apps/backend/internal/github/errors.go +++ b/apps/backend/internal/github/errors.go @@ -10,6 +10,14 @@ import ( // into a 400 instead of a generic 500. var ErrInvalidPRURL = errors.New("invalid PR URL") +// ErrInvalidIssueReference signals that a caller-supplied GitHub issue URL or +// number could not be resolved to a concrete issue. +var ErrInvalidIssueReference = errors.New("invalid GitHub issue reference") + +// ErrIssueRepositoryMismatch signals that the issue belongs to a repository +// that is not attached to the target task. +var ErrIssueRepositoryMismatch = errors.New("GitHub issue repository is not attached to task") + // ErrTaskNotFound is the sentinel that cleanup paths check to distinguish // "the task is already gone — fine, mop up the dedup row" from a real // upstream failure. Adapter implementations of TaskDeleter wrap this when diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index c0435a5abd..df46d64ca8 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -78,15 +78,16 @@ type PromptResolver interface { // Service coordinates GitHub integration operations. type Service struct { - mu sync.Mutex - client Client - authMethod string - secrets SecretProvider - secretManager SecretManager - store *Store - eventBus bus.EventBus - logger *logger.Logger - taskDeleter TaskDeleter + mu sync.Mutex + client Client + authMethod string + secrets SecretProvider + secretManager SecretManager + store *Store + eventBus bus.EventBus + logger *logger.Logger + taskDeleter TaskDeleter + taskIssueStore TaskIssueStore // cascadeTaskDeleter is the cascade-delete entry point used by the // watch reset flow. It is distinct from taskDeleter (which only deletes // a single task by ID) because reset must walk the task tree and clean diff --git a/apps/backend/internal/github/service_pr_watch.go b/apps/backend/internal/github/service_pr_watch.go index bafa2a7fa9..ce24096e67 100644 --- a/apps/backend/internal/github/service_pr_watch.go +++ b/apps/backend/internal/github/service_pr_watch.go @@ -327,6 +327,9 @@ func parsePRURL(prURL string) (owner, repo string, number int, err error) { if err != nil { return "", "", 0, fmt.Errorf("invalid PR number in URL %s: %w", prURL, err) } + if number <= 0 { + return "", "", 0, fmt.Errorf("invalid PR number in URL %s: must be greater than zero", prURL) + } // Parse owner/repo from path before /pull/ pathBefore := prURL[:idx] diff --git a/apps/backend/internal/github/service_task_issue.go b/apps/backend/internal/github/service_task_issue.go new file mode 100644 index 0000000000..df76284c75 --- /dev/null +++ b/apps/backend/internal/github/service_task_issue.go @@ -0,0 +1,197 @@ +package github + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +const ( + taskMetaIssueURL = "issue_url" + taskMetaIssueNumber = "issue_number" + taskMetaIssueOwner = "issue_owner" + taskMetaIssueRepo = "issue_repo" + taskMetaIssueLinked = "github_issue_linked" + githubProviderName = "github" + githubIssuePathMarker = "/issues/" +) + +// TaskIssueStore is the task-facing dependency needed to link GitHub issues. +// The backendapp adapter routes metadata writes through task.Service so the +// normal task.updated event is published. +type TaskIssueStore interface { + GetTask(ctx context.Context, taskID string) (*taskmodels.Task, error) + ListTaskRepositories(ctx context.Context, taskID string) ([]*taskmodels.TaskRepository, error) + GetRepository(ctx context.Context, repositoryID string) (*taskmodels.Repository, error) + UpdateTaskMetadata(ctx context.Context, taskID string, metadata map[string]interface{}) (*taskmodels.Task, error) +} + +type LinkTaskIssueRequest struct { + Issue string `json:"issue"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` +} + +type TaskIssueLinkResponse struct { + TaskID string `json:"task_id"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + IssueURL string `json:"issue_url"` + IssueTitle string `json:"issue_title"` +} + +func (s *Service) SetTaskIssueStore(store TaskIssueStore) { + s.mu.Lock() + defer s.mu.Unlock() + s.taskIssueStore = store +} + +func (s *Service) getTaskIssueStore() TaskIssueStore { + s.mu.Lock() + defer s.mu.Unlock() + return s.taskIssueStore +} + +func (s *Service) LinkTaskIssue(ctx context.Context, taskID string, req LinkTaskIssueRequest) (*TaskIssueLinkResponse, error) { + if s.client == nil { + return nil, ErrNoClient + } + store := s.getTaskIssueStore() + if store == nil { + return nil, errStoreUnavailable + } + owner, repo, number, err := resolveIssueReference(req) + if err != nil { + return nil, err + } + task, err := store.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + issue, err := s.client.GetIssue(ctx, owner, repo, number) + if err != nil { + return nil, err + } + if err := s.validateIssueTaskRepository(ctx, store, taskID, issue.RepoOwner, issue.RepoName); err != nil { + return nil, err + } + metadata := copyMetadata(task.Metadata) + metadata[taskMetaIssueURL] = issue.HTMLURL + metadata[taskMetaIssueNumber] = issue.Number + metadata[taskMetaIssueOwner] = issue.RepoOwner + metadata[taskMetaIssueRepo] = issue.RepoName + metadata[taskMetaIssueLinked] = true + if _, err := store.UpdateTaskMetadata(context.WithoutCancel(ctx), taskID, metadata); err != nil { + return nil, err + } + return taskIssueResponse(taskID, issue), nil +} + +func (s *Service) UnlinkTaskIssue(ctx context.Context, taskID string) error { + store := s.getTaskIssueStore() + if store == nil { + return errStoreUnavailable + } + task, err := store.GetTask(ctx, taskID) + if err != nil { + return err + } + metadata := copyMetadata(task.Metadata) + delete(metadata, taskMetaIssueURL) + delete(metadata, taskMetaIssueNumber) + delete(metadata, taskMetaIssueOwner) + delete(metadata, taskMetaIssueRepo) + delete(metadata, taskMetaIssueLinked) + _, err = store.UpdateTaskMetadata(context.WithoutCancel(ctx), taskID, metadata) + return err +} + +func resolveIssueReference(req LinkTaskIssueRequest) (string, string, int, error) { + if req.Issue != "" { + return parseIssueReference(req.Issue, req.Owner, req.Repo) + } + if req.Owner == "" || req.Repo == "" || req.Number <= 0 { + return "", "", 0, ErrInvalidIssueReference + } + return req.Owner, req.Repo, req.Number, nil +} + +func parseIssueReference(input, defaultOwner, defaultRepo string) (string, string, int, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", "", 0, ErrInvalidIssueReference + } + if n, err := strconv.Atoi(strings.TrimPrefix(trimmed, "#")); err == nil && n > 0 { + if defaultOwner == "" || defaultRepo == "" { + return "", "", 0, fmt.Errorf("%w: owner and repo are required for issue numbers", ErrInvalidIssueReference) + } + return defaultOwner, defaultRepo, n, nil + } + u, err := url.Parse(trimmed) + if err != nil || u.Host == "" { + return "", "", 0, fmt.Errorf("%w: expected a GitHub issue URL", ErrInvalidIssueReference) + } + host := strings.TrimPrefix(strings.ToLower(u.Host), "www.") + if host != "github.com" || !strings.Contains(u.Path, githubIssuePathMarker) { + return "", "", 0, fmt.Errorf("%w: expected a GitHub issue URL", ErrInvalidIssueReference) + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 4 || parts[2] != "issues" { + return "", "", 0, fmt.Errorf("%w: expected /owner/repo/issues/number", ErrInvalidIssueReference) + } + number, err := strconv.Atoi(parts[3]) + if err != nil || number <= 0 { + return "", "", 0, fmt.Errorf("%w: invalid issue number", ErrInvalidIssueReference) + } + return parts[0], parts[1], number, nil +} + +func (s *Service) validateIssueTaskRepository(ctx context.Context, store TaskIssueStore, taskID, owner, repo string) error { + taskRepos, err := store.ListTaskRepositories(ctx, taskID) + if err != nil { + return err + } + if len(taskRepos) == 0 { + return nil + } + for _, taskRepo := range taskRepos { + entity, err := store.GetRepository(ctx, taskRepo.RepositoryID) + if err != nil { + return err + } + if entity == nil { + continue + } + if strings.EqualFold(entity.Provider, githubProviderName) && + strings.EqualFold(entity.ProviderOwner, owner) && + strings.EqualFold(entity.ProviderName, repo) { + return nil + } + } + return ErrIssueRepositoryMismatch +} + +func copyMetadata(metadata map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(metadata)+5) + for key, value := range metadata { + out[key] = value + } + return out +} + +func taskIssueResponse(taskID string, issue *Issue) *TaskIssueLinkResponse { + return &TaskIssueLinkResponse{ + TaskID: taskID, + Owner: issue.RepoOwner, + Repo: issue.RepoName, + IssueNumber: issue.Number, + IssueURL: issue.HTMLURL, + IssueTitle: issue.Title, + } +} diff --git a/apps/backend/internal/github/service_task_issue_test.go b/apps/backend/internal/github/service_task_issue_test.go new file mode 100644 index 0000000000..56effe873f --- /dev/null +++ b/apps/backend/internal/github/service_task_issue_test.go @@ -0,0 +1,287 @@ +package github + +import ( + "context" + "errors" + "testing" + + taskmodels "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeTaskIssueStore struct { + task *taskmodels.Task + repos []*taskmodels.TaskRepository + entities map[string]*taskmodels.Repository + repositoryErrs map[string]error + failOnCanceledUpdate bool + // taskErr takes priority over the task-not-set fallback. Set it to an + // error wrapping ErrTaskNotFound when a test needs the controller's 404 path. + taskErr error + updateErr error + updated map[string]interface{} +} + +type countingIssueClient struct { + *MockClient + getIssueCalls int +} + +func (c *countingIssueClient) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) { + c.getIssueCalls++ + return c.MockClient.GetIssue(ctx, owner, repo, number) +} + +func (f *fakeTaskIssueStore) GetTask(_ context.Context, taskID string) (*taskmodels.Task, error) { + if f.taskErr != nil { + return nil, f.taskErr + } + if f.task == nil || f.task.ID != taskID { + return nil, errors.New("task not found") + } + return f.task, nil +} + +func (f *fakeTaskIssueStore) ListTaskRepositories(_ context.Context, taskID string) ([]*taskmodels.TaskRepository, error) { + if f.taskErr != nil { + return nil, f.taskErr + } + if f.task == nil || f.task.ID != taskID { + return nil, errors.New("task not found") + } + return f.repos, nil +} + +func (f *fakeTaskIssueStore) GetRepository(_ context.Context, repositoryID string) (*taskmodels.Repository, error) { + if err := f.repositoryErrs[repositoryID]; err != nil { + return nil, err + } + if repo := f.entities[repositoryID]; repo != nil { + return repo, nil + } + return nil, errors.New("repository not found") +} + +func (f *fakeTaskIssueStore) UpdateTaskMetadata(ctx context.Context, taskID string, metadata map[string]interface{}) (*taskmodels.Task, error) { + if f.taskErr != nil { + return nil, f.taskErr + } + if f.task == nil || f.task.ID != taskID { + return nil, errors.New("task not found") + } + if f.failOnCanceledUpdate && ctx.Err() != nil { + return nil, ctx.Err() + } + if f.updateErr != nil { + return nil, f.updateErr + } + f.updated = metadata + f.task.Metadata = metadata + return f.task, nil +} + +func TestLinkTaskIssue_MergesIssueMetadataAndPreservesState(t *testing.T) { + client := NewMockClient() + client.AddIssue(&Issue{ + Number: 1470, + Title: "Link existing task", + HTMLURL: "https://github.com/kdlbs/kandev/issues/1470", + RepoOwner: "kdlbs", + RepoName: "kandev", + State: "open", + }) + store := &fakeTaskIssueStore{ + failOnCanceledUpdate: true, + task: &taskmodels.Task{ + ID: "task-1", + State: v1.TaskStateInProgress, + Metadata: map[string]interface{}{"keep": "me"}, + }, + repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}}, + entities: map[string]*taskmodels.Repository{ + "repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"}, + }, + } + svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t)) + svc.SetTaskIssueStore(store) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + resp, err := svc.LinkTaskIssue(ctx, "task-1", LinkTaskIssueRequest{ + Issue: "https://github.com/kdlbs/kandev/issues/1470", + }) + if err != nil { + t.Fatalf("LinkTaskIssue: %v", err) + } + + if resp.IssueNumber != 1470 || resp.IssueURL == "" { + t.Fatalf("unexpected response: %+v", resp) + } + if store.task.State != v1.TaskStateInProgress { + t.Fatalf("state changed: %s", store.task.State) + } + if store.updated["keep"] != "me" { + t.Fatalf("existing metadata was not preserved: %+v", store.updated) + } + if store.updated[taskMetaIssueURL] != "https://github.com/kdlbs/kandev/issues/1470" { + t.Fatalf("issue url not written: %+v", store.updated) + } + if store.updated[taskMetaIssueNumber] != 1470 { + t.Fatalf("issue number not written: %+v", store.updated) + } +} + +func TestLinkTaskIssue_PropagatesRepositoryLookupError(t *testing.T) { + client := NewMockClient() + client.AddIssue(&Issue{ + Number: 1470, + Title: "Link existing task", + HTMLURL: "https://github.com/kdlbs/kandev/issues/1470", + RepoOwner: "kdlbs", + RepoName: "kandev", + }) + repoErr := errors.New("repository store unavailable") + store := &fakeTaskIssueStore{ + task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}, + repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}}, + repositoryErrs: map[string]error{"repo-1": repoErr}, + } + svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t)) + svc.SetTaskIssueStore(store) + + _, err := svc.LinkTaskIssue(context.Background(), "task-1", LinkTaskIssueRequest{ + Issue: "https://github.com/kdlbs/kandev/issues/1470", + }) + if !errors.Is(err, repoErr) { + t.Fatalf("err = %v, want repository lookup error", err) + } + if errors.Is(err, ErrIssueRepositoryMismatch) { + t.Fatalf("repository lookup error should not be masked as mismatch: %v", err) + } + if store.updated != nil { + t.Fatalf("metadata should not be updated: %+v", store.updated) + } +} + +func TestLinkTaskIssue_ChecksTaskBeforeFetchingIssue(t *testing.T) { + client := &countingIssueClient{MockClient: NewMockClient()} + taskErr := errors.New("task lookup failed") + store := &fakeTaskIssueStore{taskErr: taskErr} + svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t)) + svc.SetTaskIssueStore(store) + + _, err := svc.LinkTaskIssue(context.Background(), "missing-task", LinkTaskIssueRequest{ + Issue: "https://github.com/kdlbs/kandev/issues/1470", + }) + if !errors.Is(err, taskErr) { + t.Fatalf("expected task lookup error before GitHub fetch, got %v", err) + } + if client.getIssueCalls != 0 { + t.Fatalf("GetIssue called %d times before task lookup succeeded", client.getIssueCalls) + } +} + +func TestLinkTaskIssue_RejectsIssueFromDifferentTaskRepository(t *testing.T) { + client := NewMockClient() + client.AddIssue(&Issue{ + Number: 1, + Title: "Wrong repo", + HTMLURL: "https://github.com/other/repo/issues/1", + RepoOwner: "other", + RepoName: "repo", + }) + store := &fakeTaskIssueStore{ + task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}}, + repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}}, + entities: map[string]*taskmodels.Repository{ + "repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"}, + }, + } + svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t)) + svc.SetTaskIssueStore(store) + + _, err := svc.LinkTaskIssue(context.Background(), "task-1", LinkTaskIssueRequest{ + Issue: "https://github.com/other/repo/issues/1", + }) + if !errors.Is(err, ErrIssueRepositoryMismatch) { + t.Fatalf("err = %v, want ErrIssueRepositoryMismatch", err) + } + if store.updated != nil { + t.Fatalf("metadata should not be updated: %+v", store.updated) + } +} + +func TestUnlinkTaskIssue_RemovesIssueMetadataOnly(t *testing.T) { + store := &fakeTaskIssueStore{ + failOnCanceledUpdate: true, + task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{ + taskMetaIssueURL: "https://github.com/kdlbs/kandev/issues/1470", + taskMetaIssueNumber: 1470, + taskMetaIssueOwner: "kdlbs", + taskMetaIssueRepo: "kandev", + taskMetaIssueLinked: true, + "issue_watch_id": "watch-1", + "keep": "me", + }}, + } + svc := NewService(NewMockClient(), AuthMethodPAT, nil, nil, nil, testLogger(t)) + svc.SetTaskIssueStore(store) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := svc.UnlinkTaskIssue(ctx, "task-1"); err != nil { + t.Fatalf("UnlinkTaskIssue: %v", err) + } + if _, ok := store.updated[taskMetaIssueURL]; ok { + t.Fatalf("issue url should be removed: %+v", store.updated) + } + if store.updated["issue_watch_id"] != "watch-1" { + t.Fatalf("watch metadata should be preserved: %+v", store.updated) + } + if store.updated["keep"] != "me" { + t.Fatalf("unrelated metadata should be preserved: %+v", store.updated) + } +} + +func TestParseIssueReference(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + defaultOwner string + defaultRepo string + wantOwner string + wantRepo string + wantNumber int + wantErr bool + }{ + {name: "empty", input: " ", wantErr: true}, + {name: "hash number with default repo", input: "#1470", defaultOwner: "kdlbs", defaultRepo: "kandev", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470}, + {name: "bare number with default repo", input: "1470", defaultOwner: "kdlbs", defaultRepo: "kandev", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470}, + {name: "number without default repo", input: "#1470", defaultOwner: "kdlbs", wantErr: true}, + {name: "valid github url", input: "https://github.com/kdlbs/kandev/issues/1470", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470}, + {name: "www github url", input: "https://www.github.com/kdlbs/kandev/issues/1470?foo=bar", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470}, + {name: "non github host", input: "https://gitlab.com/kdlbs/kandev/issues/1470", wantErr: true}, + {name: "pull request url", input: "https://github.com/kdlbs/kandev/pull/1470", wantErr: true}, + {name: "missing issue number", input: "https://github.com/kdlbs/kandev/issues", wantErr: true}, + {name: "invalid issue number", input: "https://github.com/kdlbs/kandev/issues/nope", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, number, err := parseIssueReference(tt.input, tt.defaultOwner, tt.defaultRepo) + if tt.wantErr { + if !errors.Is(err, ErrInvalidIssueReference) { + t.Fatalf("err = %v, want ErrInvalidIssueReference", err) + } + return + } + if err != nil { + t.Fatalf("parseIssueReference: %v", err) + } + if owner != tt.wantOwner || repo != tt.wantRepo || number != tt.wantNumber { + t.Fatalf("got %s/%s#%d, want %s/%s#%d", owner, repo, number, tt.wantOwner, tt.wantRepo, tt.wantNumber) + } + }) + } +} diff --git a/apps/backend/internal/github/service_test.go b/apps/backend/internal/github/service_test.go index 9b69db1e4b..2abf0ba470 100644 --- a/apps/backend/internal/github/service_test.go +++ b/apps/backend/internal/github/service_test.go @@ -256,6 +256,16 @@ func TestParsePRURL(t *testing.T) { url: "https://github.com/owner/repo/pull/abc", wantErr: true, }, + { + name: "zero PR number", + url: "https://github.com/owner/repo/pull/0", + wantErr: true, + }, + { + name: "negative PR number", + url: "https://github.com/owner/repo/pull/-1", + wantErr: true, + }, { name: "too short path", url: "/pull/1", diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index ea535a1ad0..7b130aa589 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -4,6 +4,9 @@ import { useMemo, type ReactNode } from "react"; import { IconArchive, IconArrowRight, + IconCircleDot, + IconGitPullRequest, + IconLink, IconLoader, IconLogicBuffer, IconPencil, @@ -78,6 +81,8 @@ type BuildKanbanCardMenuEntriesArgs = { onEdit?: () => void; onArchive?: () => void; onDelete?: () => void; + onLinkPullRequest?: () => void; + onLinkIssue?: () => void; onMoveToStep?: (stepId: string) => void; onSendToWorkflow?: (workflowId: string, stepId: string) => void; }; @@ -213,6 +218,47 @@ function buildSendToWorkflowSubmenu({ }; } +function buildLinkSubmenu({ + disabled, + onLinkPullRequest, + onLinkIssue, +}: { + disabled?: boolean; + onLinkPullRequest?: () => void; + onLinkIssue?: () => void; +}): KanbanCardMenuEntry | null { + if (!onLinkPullRequest && !onLinkIssue) return null; + return { + kind: "submenu", + key: "link", + testId: "task-context-link", + icon: , + label: "Link", + disabled, + className: "w-56", + children: [ + { + kind: "item", + key: "link-github-pull-request", + testId: "task-context-link-github-pull-request", + icon: , + label: "GitHub Pull Request", + disabled: disabled || !onLinkPullRequest, + onSelect: onLinkPullRequest, + }, + { + kind: "item", + key: "link-github-issue", + testId: "task-context-link-github-issue", + icon: , + label: "GitHub Issue", + disabled: disabled || !onLinkIssue, + onSelect: onLinkIssue, + }, + ], + }; +} + export function buildKanbanCardMenuEntries({ currentWorkflowId, currentStepId, @@ -224,6 +270,8 @@ export function buildKanbanCardMenuEntries({ onEdit, onArchive, onDelete, + onLinkPullRequest, + onLinkIssue, onMoveToStep, onSendToWorkflow, }: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] { @@ -258,6 +306,13 @@ export function buildKanbanCardMenuEntries({ }); if (sendToEntry) entries.push(sendToEntry); + const linkEntry = buildLinkSubmenu({ + disabled: isProcessing, + onLinkPullRequest, + onLinkIssue, + }); + if (linkEntry) entries.push(linkEntry); + entries.push({ kind: "item", key: "archive", diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index d0b7f6875d..999861d6b9 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -11,6 +11,8 @@ import { import { useAppStore } from "@/components/state-provider"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; +import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog"; +import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; import { getRepositoryDisplayName } from "@/lib/utils"; import { repositoryId as toRepositoryId, type Repository, type TaskState } from "@/lib/types/http"; @@ -41,6 +43,8 @@ export interface Task { parentTaskId?: string | null; updatedAt?: string; createdAt?: string; + issueUrl?: string; + issueNumber?: number; } export interface WorkflowStep { @@ -103,6 +107,8 @@ function useKanbanCardMenus({ const moveTasks = useTaskWorkflowMove(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); + const [showPRDialog, setShowPRDialog] = useState(false); + const [showIssueDialog, setShowIssueDialog] = useState(false); const disabled = Boolean(isDeleting || isArchiving); const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => { @@ -142,6 +148,8 @@ function useKanbanCardMenus({ onEdit: onEdit ? () => onEdit(task) : undefined, onArchive: onArchive ? () => setShowArchiveConfirm(true) : undefined, onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined, + onLinkPullRequest: () => setShowPRDialog(true), + onLinkIssue: () => setShowIssueDialog(true), }; return { @@ -163,9 +171,87 @@ function useKanbanCardMenus({ setShowDeleteConfirm, showArchiveConfirm, setShowArchiveConfirm, + showPRDialog, + setShowPRDialog, + showIssueDialog, + setShowIssueDialog, }; } +function KanbanCardDialogs({ + task, + repositories, + showDeleteConfirm, + setShowDeleteConfirm, + showArchiveConfirm, + setShowArchiveConfirm, + showPRDialog, + setShowPRDialog, + showIssueDialog, + setShowIssueDialog, + isDeleting, + isArchiving, + onDelete, + onArchive, +}: { + task: Task; + repositories: Repository[]; + showDeleteConfirm: boolean; + setShowDeleteConfirm: (open: boolean) => void; + showArchiveConfirm: boolean; + setShowArchiveConfirm: (open: boolean) => void; + showPRDialog: boolean; + setShowPRDialog: (open: boolean) => void; + showIssueDialog: boolean; + setShowIssueDialog: (open: boolean) => void; + isDeleting?: boolean; + isArchiving?: boolean; + onDelete?: KanbanCardProps["onDelete"]; + onArchive?: KanbanCardProps["onArchive"]; +}) { + return ( + <> + onDelete?.(task, { cascade })} + /> + onArchive?.(task, { cascade })} + /> + + + + ); +} + +function useActiveWorkspaceRepositories() { + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + return useAppStore((state) => + activeWorkspaceId ? (state.repositories.itemsByWorkspaceId[activeWorkspaceId] ?? []) : [], + ); +} + export function KanbanCard({ task, repositoryNames, @@ -189,6 +275,7 @@ export function KanbanCard({ disabled: isMultiSelectMode, }); const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id); + const repositories = useActiveWorkspaceRepositories(); const { dropdownMenuEntries, contextMenuEntries, @@ -196,6 +283,10 @@ export function KanbanCard({ setShowDeleteConfirm, showArchiveConfirm, setShowArchiveConfirm, + showPRDialog, + setShowPRDialog, + showIssueDialog, + setShowIssueDialog, } = useKanbanCardMenus({ task, steps, @@ -245,23 +336,21 @@ export function KanbanCard({ onOpenFullPage={onOpenFullPage} /> - onDelete?.(task, { cascade })} - /> - onArchive?.(task, { cascade })} + onDelete={onDelete} + onArchive={onArchive} /> ); diff --git a/apps/web/components/task/task-github-issue-dialog.tsx b/apps/web/components/task/task-github-issue-dialog.tsx new file mode 100644 index 0000000000..ec293035fb --- /dev/null +++ b/apps/web/components/task/task-github-issue-dialog.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { useToast } from "@/components/toast-provider"; +import { linkTaskIssue, unlinkTaskIssue } from "@/lib/api/domains/github-api"; +import type { Repository } from "@/lib/types/http"; + +type TaskIssue = { + id: string; + title: string; + repositoryId?: string; + issueUrl?: string; + issueNumber?: number; + repositories?: Array<{ repository_id: string }>; +}; + +type GitHubRepoRef = { + owner: string; + repo: string; +}; + +type TaskGitHubIssueDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + task: TaskIssue; + repositories: Repository[]; +}; + +type DialogFieldsProps = { + input: string; + setInput: (input: string) => void; + inferredRepo: GitHubRepoRef | null; + submitting: boolean; + error: string | null; +}; + +type DialogActionsProps = { + currentLabel: string | null; + submitting: boolean; + onCancel: () => void; + onSubmit: () => void; + onUnlink: () => void; +}; + +function githubReposForTask(task: TaskIssue, repositories: Repository[]): GitHubRepoRef[] { + const ids = new Set((task.repositories ?? []).map((repo) => repo.repository_id)); + if (task.repositoryId) ids.add(task.repositoryId); + return repositories + .filter((repo) => ids.has(repo.id) && repo.provider === "github") + .map((repo) => ({ owner: repo.provider_owner, repo: repo.provider_name })) + .filter((repo) => repo.owner && repo.repo); +} + +function issuePayload(input: string, inferredRepo: GitHubRepoRef | null) { + const trimmed = input.trim(); + if (/^#?\d+$/.test(trimmed) && inferredRepo) { + return { + issue: trimmed, + owner: inferredRepo.owner, + repo: inferredRepo.repo, + number: Number(trimmed.replace(/^#/, "")), + }; + } + return { issue: trimmed }; +} + +function TaskGitHubIssueFields({ + input, + setInput, + inferredRepo, + submitting, + error, +}: DialogFieldsProps) { + const placeholder = inferredRepo + ? "#1470 or github.com/owner/repo/issues/1470" + : "github.com/owner/repo/issues/1470"; + return ( +
+ + setInput(event.target.value)} + placeholder={placeholder} + disabled={submitting} + /> + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function TaskGitHubIssueActions({ + currentLabel, + submitting, + onCancel, + onSubmit, + onUnlink, +}: DialogActionsProps) { + return ( + + {currentLabel ? ( + + ) : ( + + )} +
+ + +
+
+ ); +} + +export function TaskGitHubIssueDialog({ + open, + onOpenChange, + task, + repositories, +}: TaskGitHubIssueDialogProps) { + const { toast } = useToast(); + const [input, setInput] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + let currentLabel: string | null = null; + if (task.issueNumber) { + currentLabel = `#${task.issueNumber}`; + } else if (task.issueUrl) { + currentLabel = "Linked issue"; + } + const githubRepos = useMemo(() => githubReposForTask(task, repositories), [task, repositories]); + const inferredRepo = githubRepos.length === 1 ? githubRepos[0] : null; + + useEffect(() => { + if (open) { + setInput(task.issueUrl ?? ""); + setError(null); + } + }, [open, task.issueUrl]); + + const submit = async () => { + if (!input.trim()) { + setError("Enter a GitHub issue URL or number."); + return; + } + setSubmitting(true); + setError(null); + try { + await linkTaskIssue(task.id, issuePayload(input, inferredRepo)); + toast({ description: "GitHub issue linked", variant: "success" }); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to link GitHub issue."); + } finally { + setSubmitting(false); + } + }; + + const unlink = async () => { + setSubmitting(true); + setError(null); + try { + await unlinkTaskIssue(task.id); + toast({ description: "GitHub issue unlinked", variant: "success" }); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to unlink GitHub issue."); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {currentLabel ? "Change GitHub issue" : "Link GitHub issue"} + + {inferredRepo + ? `Use a full issue URL or number for ${inferredRepo.owner}/${inferredRepo.repo}.` + : "Use a full GitHub issue URL for this task."} + + + + onOpenChange(false)} + onSubmit={submit} + onUnlink={unlink} + /> + + + ); +} diff --git a/apps/web/components/task/task-github-pr-dialog.tsx b/apps/web/components/task/task-github-pr-dialog.tsx new file mode 100644 index 0000000000..158bd40a2b --- /dev/null +++ b/apps/web/components/task/task-github-pr-dialog.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { useToast } from "@/components/toast-provider"; +import { createTaskPR } from "@/lib/api/domains/github-api"; +import type { Repository } from "@/lib/types/http"; +import { + githubReposForTask, + pullRequestPayload, + type TaskPullRequestLinkTarget, +} from "./task-github-pr-url"; + +type TaskGitHubPRDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + task: TaskPullRequestLinkTarget; + repositories: Repository[]; +}; + +export function TaskGitHubPRDialog({ + open, + onOpenChange, + task, + repositories, +}: TaskGitHubPRDialogProps) { + const { toast } = useToast(); + const [input, setInput] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const githubRepos = useMemo(() => githubReposForTask(task, repositories), [task, repositories]); + const inferredRepo = githubRepos.length === 1 ? githubRepos[0] : null; + const placeholder = inferredRepo + ? "#1471 or github.com/owner/repo/pull/1471" + : "github.com/owner/repo/pull/1471"; + + useEffect(() => { + if (open) { + setInput(""); + setError(null); + } + }, [open]); + + const submit = async () => { + if (!input.trim()) { + setError("Enter a GitHub pull request URL or number."); + return; + } + setSubmitting(true); + setError(null); + try { + const payload = pullRequestPayload(input, githubRepos); + await createTaskPR({ + task_id: task.id, + pr_url: payload.pr_url, + ...(payload.repository_id ? { repository_id: payload.repository_id } : {}), + }); + toast({ description: "GitHub pull request linked", variant: "success" }); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to link GitHub pull request."); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + Link GitHub pull request + + {inferredRepo + ? `Use a full pull request URL or number for ${inferredRepo.owner}/${inferredRepo.repo}.` + : "Use a full GitHub pull request URL for this task."} + + +
+ + setInput(event.target.value)} + placeholder={placeholder} + disabled={submitting} + /> + {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/web/components/task/task-github-pr-url.test.ts b/apps/web/components/task/task-github-pr-url.test.ts new file mode 100644 index 0000000000..3dc5b13041 --- /dev/null +++ b/apps/web/components/task/task-github-pr-url.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + githubReposForTask, + parseGitHubPullRequestURL, + pullRequestPayload, + type GitHubPRRepoRef, +} from "./task-github-pr-url"; +import { repositoryId, workspaceId, type Repository } from "@/lib/types/http"; + +type RepoOverrides = Partial> & { id: string }; + +function repo(overrides: RepoOverrides): Repository { + const { id, ...rest } = overrides; + return { + ...rest, + id: repositoryId(id), + workspace_id: workspaceId("workspace-1"), + name: overrides.name ?? "Repo", + source_type: "remote", + local_path: "", + provider: overrides.provider ?? "github", + provider_repo_id: "", + provider_owner: overrides.provider_owner ?? "kdlbs", + provider_name: overrides.provider_name ?? "kandev", + default_branch: "main", + worktree_branch_prefix: "", + pull_before_worktree: false, + setup_script: "", + cleanup_script: "", + dev_script: "", + copy_files: "", + created_at: "2026-06-23T00:00:00Z", + updated_at: "2026-06-23T00:00:00Z", + }; +} + +const KANDEV_PR_URL = "https://github.com/kdlbs/kandev/pull/1471"; + +describe("githubReposForTask", () => { + it("maps task repositories to GitHub repository ids, not task-repository row ids", () => { + const refs = githubReposForTask( + { + id: "task-1", + title: "Task", + repositories: [{ repository_id: repositoryId("repo-1") }], + }, + [ + repo({ id: "repo-1", provider_owner: "kdlbs", provider_name: "kandev" }), + repo({ id: "repo-2", provider_owner: "acme", provider_name: "site" }), + ], + ); + + expect(refs).toEqual([{ owner: "kdlbs", repo: "kandev", repositoryId: "repo-1" }]); + }); + + it("uses the task repositoryId fallback and ignores non-GitHub repositories", () => { + const refs = githubReposForTask( + { id: "task-1", title: "Task", repositoryId: repositoryId("repo-2") }, + [ + repo({ id: "repo-1", provider_owner: "kdlbs", provider_name: "kandev" }), + repo({ id: "repo-2", provider: "gitlab", provider_owner: "acme", provider_name: "site" }), + ], + ); + + expect(refs).toEqual([]); + }); +}); + +describe("parseGitHubPullRequestURL", () => { + it("normalizes full GitHub pull request URLs", () => { + expect(parseGitHubPullRequestURL(KANDEV_PR_URL)).toEqual({ + owner: "kdlbs", + repo: "kandev", + number: 1471, + url: KANDEV_PR_URL, + }); + expect(parseGitHubPullRequestURL("github.com/kdlbs/kandev/pull/1471/")).toEqual({ + owner: "kdlbs", + repo: "kandev", + number: 1471, + url: KANDEV_PR_URL, + }); + expect( + parseGitHubPullRequestURL("https://github.com/kdlbs/kandev/pull/1471?tab=files#diff"), + ).toEqual({ + owner: "kdlbs", + repo: "kandev", + number: 1471, + url: KANDEV_PR_URL, + }); + }); + + it("rejects malformed or non-GitHub pull request inputs", () => { + expect(parseGitHubPullRequestURL("https://gitlab.com/kdlbs/kandev/pull/1471")).toBeNull(); + expect(parseGitHubPullRequestURL("https://github.com/kdlbs/kandev/issues/1471")).toBeNull(); + expect(parseGitHubPullRequestURL("https://github.com/kdlbs/kandev/pull/0")).toBeNull(); + expect(parseGitHubPullRequestURL("https://github.com/kdlbs/kandev/pull/00")).toBeNull(); + expect(parseGitHubPullRequestURL("not a url")).toBeNull(); + }); +}); + +describe("pullRequestPayload", () => { + const githubRepos: GitHubPRRepoRef[] = [ + { owner: "kdlbs", repo: "kandev", repositoryId: "repo-1" }, + { owner: "acme", repo: "site", repositoryId: "repo-2" }, + ]; + + it("infers a bare PR number when exactly one GitHub repo is attached", () => { + expect(pullRequestPayload("#1471", [githubRepos[0]])).toEqual({ + pr_url: KANDEV_PR_URL, + repository_id: "repo-1", + }); + }); + + it("does not infer bare numbers for multi-repo tasks", () => { + expect(pullRequestPayload("1471", githubRepos)).toEqual({ + pr_url: "1471", + repository_id: undefined, + }); + }); + + it("does not infer non-positive PR numbers", () => { + expect(pullRequestPayload("#0", [githubRepos[0]])).toEqual({ + pr_url: "#0", + repository_id: undefined, + }); + expect(pullRequestPayload("00", [githubRepos[0]])).toEqual({ + pr_url: "00", + repository_id: undefined, + }); + }); + + it("attaches the repository id when a URL matches a task GitHub repo", () => { + expect(pullRequestPayload("https://github.com/ACME/site/pull/5", githubRepos)).toEqual({ + pr_url: "https://github.com/ACME/site/pull/5", + repository_id: "repo-2", + }); + }); + + it("keeps unmatched and malformed inputs without a repository id", () => { + expect(pullRequestPayload("https://github.com/other/repo/pull/5", githubRepos)).toEqual({ + pr_url: "https://github.com/other/repo/pull/5", + repository_id: undefined, + }); + expect(pullRequestPayload(" not a url ", [githubRepos[0]])).toEqual({ + pr_url: "not a url", + repository_id: undefined, + }); + }); +}); diff --git a/apps/web/components/task/task-github-pr-url.ts b/apps/web/components/task/task-github-pr-url.ts new file mode 100644 index 0000000000..1ffbe531a4 --- /dev/null +++ b/apps/web/components/task/task-github-pr-url.ts @@ -0,0 +1,88 @@ +import type { Repository } from "@/lib/types/http"; + +export type TaskPullRequestLinkTarget = { + id: string; + title: string; + repositoryId?: string; + repositories?: Array<{ repository_id: string }>; +}; + +export type GitHubPRRepoRef = { + owner: string; + repo: string; + repositoryId: string; +}; + +export type ParsedPullRequestURL = { + owner: string; + repo: string; + number: number; + url: string; +}; + +export function githubReposForTask( + task: TaskPullRequestLinkTarget, + repositories: Repository[], +): GitHubPRRepoRef[] { + const repoIds = new Set((task.repositories ?? []).map((repo) => repo.repository_id)); + if (task.repositoryId) repoIds.add(task.repositoryId); + + return repositories + .filter((repo) => repoIds.has(repo.id) && repo.provider === "github") + .map((repo) => ({ + owner: repo.provider_owner, + repo: repo.provider_name, + repositoryId: repo.id, + })) + .filter((repo) => repo.owner && repo.repo); +} + +export function parseGitHubPullRequestURL(input: string): ParsedPullRequestURL | null { + const match = input + .trim() + .match(/^(?:https?:\/\/)?github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i); + if (!match) return null; + const [, owner, repo, number] = match; + const parsedNumber = Number(number); + if (!Number.isSafeInteger(parsedNumber) || parsedNumber <= 0) return null; + return { + owner, + repo, + number: parsedNumber, + url: `https://github.com/${owner}/${repo}/pull/${parsedNumber}`, + }; +} + +function positivePullRequestNumber(input: string): string | null { + const number = input.replace(/^#/, ""); + if (!/^\d+$/.test(number)) return null; + const parsedNumber = Number(number); + if (!Number.isSafeInteger(parsedNumber) || parsedNumber <= 0) return null; + return String(parsedNumber); +} + +export function pullRequestPayload(input: string, githubRepos: GitHubPRRepoRef[]) { + const trimmed = input.trim(); + const inferredNumber = positivePullRequestNumber(trimmed); + const inferredRepo = inferredNumber && githubRepos.length === 1 ? githubRepos[0] : null; + if (inferredRepo && inferredNumber) { + return { + pr_url: `https://github.com/${inferredRepo.owner}/${inferredRepo.repo}/pull/${inferredNumber}`, + repository_id: inferredRepo.repositoryId, + }; + } + + const parsed = parseGitHubPullRequestURL(trimmed); + const matchingRepo = parsed + ? githubRepos.find( + (repo) => + repo.owner.toLowerCase() === parsed.owner.toLowerCase() && + repo.repo.toLowerCase() === parsed.repo.toLowerCase(), + ) + : null; + + return { + pr_url: parsed?.url ?? trimmed, + repository_id: matchingRepo?.repositoryId, + }; +} diff --git a/apps/web/components/task/task-page-content-helpers.test.ts b/apps/web/components/task/task-page-content-helpers.test.ts index 3c2f4c01dc..0e72f4a574 100644 --- a/apps/web/components/task/task-page-content-helpers.test.ts +++ b/apps/web/components/task/task-page-content-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildDebugEntries } from "./task-page-content-helpers"; +import type { Task } from "@/lib/types/http"; +import { buildDebugEntries, resolveTaskProps } from "./task-page-content-helpers"; function baseParams(overrides: Partial[0]> = {}) { return { @@ -41,3 +42,22 @@ describe("buildDebugEntries", () => { expect(entries.acp_meta).toEqual({ cursor: { requestId: "req-1" } }); }); }); + +describe("resolveTaskProps", () => { + it("exposes linked GitHub issue metadata for the top bar", () => { + const props = resolveTaskProps( + { + id: "task-1", + title: "Link issue", + metadata: { + issue_url: "https://github.com/kdlbs/kandev/issues/1470", + issue_number: 1470, + }, + } as unknown as Task, + null, + ); + + expect(props.issueUrl).toBe("https://github.com/kdlbs/kandev/issues/1470"); + expect(props.issueNumber).toBe(1470); + }); +}); diff --git a/apps/web/components/task/task-page-content-helpers.ts b/apps/web/components/task/task-page-content-helpers.ts index 515efca331..b1bdcea16a 100644 --- a/apps/web/components/task/task-page-content-helpers.ts +++ b/apps/web/components/task/task-page-content-helpers.ts @@ -1,4 +1,5 @@ import type { Repository, Task } from "@/lib/types/http"; +import { issueFieldsFromMetadata } from "@/lib/metadata-utils"; type ACPDebugInfo = { sessionId: unknown; @@ -116,10 +117,13 @@ export function resolveTaskIds(task: Task | null) { export function resolveTaskProps(task: Task | null, repository: Repository | null) { const ids = resolveTaskIds(task); + const issue = issueFieldsFromMetadata(task?.metadata); return { ...ids, taskTitle: task?.title, taskDescription: task?.description, + issueUrl: issue.issueUrl, + issueNumber: issue.issueNumber, repositoryPath: repository?.local_path ?? null, repositoryName: repository?.name ?? null, /** diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index f1038f13db..ba1bac8577 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -116,6 +116,8 @@ function buildTaskTopBarProps(params: { currentStepId: resolveCurrentStepId(params.sessionWorkflowStepId, taskProps.workflowStepId), workflowId: taskProps.workflowId, workspaceId: taskProps.workspaceId, + issueUrl: taskProps.issueUrl, + issueNumber: taskProps.issueNumber, isArchived: taskProps.isArchived, isRemoteExecutor: params.remote.isRemoteExecutor, isAgentctlReady: params.agentctlReady, diff --git a/apps/web/components/task/task-session-sidebar-dialogs.tsx b/apps/web/components/task/task-session-sidebar-dialogs.tsx index f5b6f991f0..f6cc9ab985 100644 --- a/apps/web/components/task/task-session-sidebar-dialogs.tsx +++ b/apps/web/components/task/task-session-sidebar-dialogs.tsx @@ -3,8 +3,13 @@ import { TaskRenameDialog } from "./task-rename-dialog"; import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog"; +import { TaskGitHubIssueDialog } from "./task-github-issue-dialog"; +import { TaskGitHubPRDialog } from "./task-github-pr-dialog"; +import type { Repository } from "@/lib/types/http"; +import type { SidebarLinkTarget } from "./task-session-sidebar-link-actions"; type Target = { id: string; title: string; executorType?: string | null } | null; +type LinkTarget = SidebarLinkTarget | null; export type SidebarDialogsActions = { renamingTask: Target; @@ -19,9 +24,19 @@ export type SidebarDialogsActions = { setDeletingTask: (next: Target) => void; isDeleting: boolean; handleDeleteConfirm: (opts: { cascade: boolean }) => Promise | void; + linkingPullRequestTask: LinkTarget; + setLinkingPullRequestTask: (next: LinkTarget) => void; + linkingIssueTask: LinkTarget; + setLinkingIssueTask: (next: LinkTarget) => void; }; -export function SidebarDialogs({ actions }: { actions: SidebarDialogsActions }) { +export function SidebarDialogs({ + actions, + repositories, +}: { + actions: SidebarDialogsActions; + repositories: Repository[]; +}) { const { renamingTask, setRenamingTask, @@ -35,6 +50,10 @@ export function SidebarDialogs({ actions }: { actions: SidebarDialogsActions }) setDeletingTask, isDeleting, handleDeleteConfirm, + linkingPullRequestTask, + setLinkingPullRequestTask, + linkingIssueTask, + setLinkingIssueTask, } = actions; return ( <> @@ -68,6 +87,26 @@ export function SidebarDialogs({ actions }: { actions: SidebarDialogsActions }) isDeleting={isDeleting} onConfirm={handleDeleteConfirm} /> + {linkingPullRequestTask && ( + { + if (!open) setLinkingPullRequestTask(null); + }} + task={linkingPullRequestTask} + repositories={repositories} + /> + )} + {linkingIssueTask && ( + { + if (!open) setLinkingIssueTask(null); + }} + task={linkingIssueTask} + repositories={repositories} + /> + )} ); } diff --git a/apps/web/components/task/task-session-sidebar-grouped-view.ts b/apps/web/components/task/task-session-sidebar-grouped-view.ts new file mode 100644 index 0000000000..4a980ab193 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-grouped-view.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useMemo } from "react"; +import { applyView } from "@/lib/sidebar/apply-view"; +import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view"; +import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs"; +import type { TaskSwitcherItem } from "./task-switcher"; + +export function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) { + const prefs = useSidebarTaskPrefs(); + const effectiveView = useEffectiveSidebarView(); + const { pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId } = prefs; + const grouped = useMemo( + () => + applyView(displayTasks, effectiveView, { + pinnedTaskIds, + orderedTaskIds, + subtaskOrderByParentId, + }), + [displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId], + ); + return { grouped, effectiveView, prefs }; +} diff --git a/apps/web/components/task/task-session-sidebar-link-actions.ts b/apps/web/components/task/task-session-sidebar-link-actions.ts new file mode 100644 index 0000000000..46248ba8aa --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-link-actions.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { KanbanState } from "@/lib/state/slices"; +import { findTaskInSnapshots } from "@/lib/kanban/find-task"; + +type StoreApi = { + getState: () => { + kanbanMulti: { snapshots: Record }; + kanban: { tasks: KanbanState["tasks"] }; + }; +}; + +export type SidebarLinkTarget = { + id: string; + title: string; + repositoryId?: string; + issueUrl?: string; + issueNumber?: number; + repositories?: Array<{ id?: string; repository_id: string; position?: number }>; +}; + +export function useSidebarLinkActions(store: StoreApi) { + const [linkingPullRequestTask, setLinkingPullRequestTask] = useState( + null, + ); + const [linkingIssueTask, setLinkingIssueTask] = useState(null); + + const getLinkTarget = useCallback( + (taskId: string): SidebarLinkTarget => { + const state = store.getState(); + const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + return { + id: taskId, + title: task?.title ?? "this task", + repositoryId: task?.repositoryId, + issueUrl: task?.issueUrl, + issueNumber: task?.issueNumber, + repositories: task?.repositories, + }; + }, + [store], + ); + + const handleLinkPullRequestTask = useCallback( + (taskId: string) => { + setLinkingPullRequestTask(getLinkTarget(taskId)); + }, + [getLinkTarget], + ); + + const handleLinkIssueTask = useCallback( + (taskId: string) => { + setLinkingIssueTask(getLinkTarget(taskId)); + }, + [getLinkTarget], + ); + + return { + linkingPullRequestTask, + setLinkingPullRequestTask, + handleLinkPullRequestTask, + linkingIssueTask, + setLinkingIssueTask, + handleLinkIssueTask, + }; +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 4df2db262d..1e939af95d 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -7,16 +7,13 @@ import type { Repository, TaskSession, TaskSessionState, TaskState } from "@/lib import type { TaskPR } from "@/lib/types/github"; import type { KanbanState } from "@/lib/state/slices"; import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; -import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher"; -import { applyView } from "@/lib/sidebar/apply-view"; +import { TaskSwitcher } from "./task-switcher"; import { SidebarFilterBar } from "./sidebar-filter/sidebar-filter-bar"; import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data"; import { SidebarDialogs } from "./task-session-sidebar-dialogs"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks"; -import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view"; -import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs"; import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; @@ -28,6 +25,8 @@ import { useArchivedTaskState } from "./task-archived-context"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; import { buildPendingFlags, readPendingFlags } from "./task-session-sidebar-aggregate"; +import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view"; +import { useSidebarLinkActions } from "./task-session-sidebar-link-actions"; import { useShallow } from "zustand/react/shallow"; import { type AgentErrorOptions, agentErrorMessageForTask } from "@/lib/task-agent-error"; @@ -514,6 +513,7 @@ function useSidebarActions(store: StoreApi) { const archiveActions = useArchiveActions(store); const deleteActions = useDeleteActions(store, removeTaskFromBoard); + const linkActions = useSidebarLinkActions(store); const [renamingTask, setRenamingTask] = useState<{ id: string; title: string } | null>(null); @@ -544,6 +544,7 @@ function useSidebarActions(store: StoreApi) { setRenamingTask, handleRenameTask, handleRenameSubmit, + ...linkActions, ...archiveActions, ...deleteActions, }; @@ -565,22 +566,6 @@ function useBulkGitStatusSubscription(primarySessionIds: string[]) { }, [primarySessionIds, connectionStatus, activeSessionId]); } -function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) { - const prefs = useSidebarTaskPrefs(); - const effectiveView = useEffectiveSidebarView(); - const { pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId } = prefs; - const grouped = useMemo( - () => - applyView(displayTasks, effectiveView, { - pinnedTaskIds, - orderedTaskIds, - subtaskOrderByParentId, - }), - [displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId], - ); - return { grouped, effectiveView, prefs }; -} - export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId, hideFilterBar, @@ -619,7 +604,13 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ handleDeleteTask, handleMoveToStep, handleRenameTask, + handleLinkPullRequestTask, + handleLinkIssueTask, } = sidebarActions; + const repositories = + useAppStore((state) => + workspaceId ? state.repositories.itemsByWorkspaceId[workspaceId] : undefined, + ) ?? []; const displayTasks = useMemo(() => { if (MOCK_SIDEBAR) return MOCK_ITEMS; @@ -635,8 +626,6 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ const toggleSubtaskCollapsed = useAppStore((state) => state.toggleSubtaskCollapsed); const { grouped, effectiveView, prefs } = useGroupedSidebarView(displayTasks); const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs; - // Stable ref: an inline arrow here would defeat TaskSwitcher's memo() and - // re-render every task row whenever this component renders. const handleToggleGroup = useCallback( (groupKey: string) => toggleSidebarGroupCollapsed(effectiveView.id, groupKey), [toggleSidebarGroupCollapsed, effectiveView.id], @@ -659,6 +648,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ onRenameTask={handleRenameTask} onArchiveTask={handleArchiveTask} onDeleteTask={handleDeleteTask} + onLinkPullRequest={handleLinkPullRequestTask} + onLinkIssue={handleLinkIssueTask} onMoveToStep={handleMoveToStep} onTogglePin={togglePinnedTask} onReorderGroup={handleReorderGroup} @@ -669,7 +660,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ totalTaskCount={displayTasks.length} /> - + ); }); diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index e301a9b0f6..7dea9afcf1 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -5,6 +5,9 @@ import { IconArchive, IconCheck, IconCopy, + IconCircleDot, + IconGitPullRequest, + IconLink, IconLoader, IconPalette, IconPencil, @@ -53,6 +56,8 @@ type ContextMenuProps = { onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; + onLinkPullRequest?: (taskId: string) => void; + onLinkIssue?: (taskId: string) => void; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; isPinned?: boolean; @@ -68,6 +73,8 @@ export function TaskItemWithContextMenu({ onRenameTask, onArchiveTask, onDeleteTask, + onLinkPullRequest, + onLinkIssue, onMoveToStep, onTogglePin, isPinned, @@ -88,78 +95,97 @@ export function TaskItemWithContextMenu({
{cloneWithMenuOpen(children, menuOpen)}
- {onTogglePin && ( - onTogglePin(task.id)}> - {isPinned ? ( - - ) : ( - - )} - {isPinned ? "Unpin" : "Pin"} - - )} - {onRenameTask && ( - onRenameTask(task.id, task.title)}> - - Rename - - )} - - - Duplicate - - {onArchiveTask && ( - onArchiveTask(task.id)}> - - Archive - - )} - - {task.workflowId && ( - { - closeMenu(); - onMoveToStep(task.id, task.workflowId!, stepId); - } - : undefined - } - onSendToWorkflow={(workflowId, stepId) => { - closeMenu(); - void moveTasks([task.id], workflowId, stepId).catch(() => { - // useTaskWorkflowMove already shows the failure toast. - }); - }} - /> - )} - {onDeleteTask && ( - <> - - onDeleteTask(task.id)} - > - {isDeleting ? ( - - ) : ( - - )} - Delete - - - )} + ); } +type TaskContextMenuItemsProps = Omit & { + closeMenu: () => void; + moveTasks: ReturnType; +}; + +function TaskContextMenuItems({ + task, + workflows, + stepsByWorkflowId, + steps, + onRenameTask, + onArchiveTask, + onDeleteTask, + onLinkPullRequest, + onLinkIssue, + onMoveToStep, + onTogglePin, + isPinned, + isDeleting, + closeMenu, + moveTasks, +}: TaskContextMenuItemsProps) { + return ( + <> + + + + + Duplicate + + + + + + + + ); +} + +function selectTaskAction( + taskId: string, + handler: ((taskId: string) => void) | undefined, + closeMenu: () => void, +) { + if (!handler) return undefined; + return () => { + closeMenu(); + handler(taskId); + }; +} + function cloneWithMenuOpen( children: React.ReactElement<{ menuOpen?: boolean }>, menuOpen: boolean, @@ -168,6 +194,163 @@ function cloneWithMenuOpen( return children; } +function TaskPinItem({ + taskId, + isPinned, + disabled, + onTogglePin, +}: { + taskId: string; + isPinned?: boolean; + disabled?: boolean; + onTogglePin?: (taskId: string) => void; +}) { + if (!onTogglePin) return null; + return ( + onTogglePin(taskId)}> + {isPinned ? : } + {isPinned ? "Unpin" : "Pin"} + + ); +} + +function TaskRenameItem({ + task, + disabled, + onRenameTask, +}: { + task: TaskSwitcherItem; + disabled?: boolean; + onRenameTask?: (taskId: string, currentTitle: string) => void; +}) { + if (!onRenameTask) return null; + return ( + onRenameTask(task.id, task.title)}> + + Rename + + ); +} + +function TaskArchiveItem({ + taskId, + disabled, + onArchiveTask, +}: { + taskId: string; + disabled?: boolean; + onArchiveTask?: (taskId: string) => void; +}) { + if (!onArchiveTask) return null; + return ( + onArchiveTask(taskId)}> + + Archive + + ); +} + +function TaskMoveItems({ + task, + workflows, + stepsByWorkflowId, + steps, + isDeleting, + onMoveToStep, + closeMenu, + moveTasks, +}: Omit) { + if (!task.workflowId) return null; + return ( + { + closeMenu(); + void moveTasks([task.id], workflowId, stepId).catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); + }} + /> + ); +} + +function selectMoveAction( + taskId: string, + workflowId: string, + handler: ((taskId: string, workflowId: string, targetStepId: string) => void) | undefined, + closeMenu: () => void, +) { + if (!handler) return undefined; + return (stepId: string) => { + closeMenu(); + handler(taskId, workflowId, stepId); + }; +} + +function TaskDeleteItem({ + taskId, + isDeleting, + onDeleteTask, +}: { + taskId: string; + isDeleting?: boolean; + onDeleteTask?: (taskId: string) => void; +}) { + if (!onDeleteTask) return null; + return ( + <> + + onDeleteTask(taskId)} + > + {isDeleting ? ( + + ) : ( + + )} + Delete + + + ); +} + +function TaskLinkMenu({ + disabled, + onLinkPullRequest, + onLinkIssue, +}: { + disabled?: boolean; + onLinkPullRequest?: () => void; + onLinkIssue?: () => void; +}) { + if (!onLinkPullRequest && !onLinkIssue) return null; + return ( + + + + Link + + + + + GitHub Pull Request + + + + GitHub Issue + + + + ); +} + function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) { const currentColor = useTaskColor(taskId); const setColor = useSetTaskColor(); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index e69d6eef17..e4052f9a6c 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -59,6 +59,8 @@ type TaskSwitcherProps = { onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; + onLinkPullRequest?: (taskId: string) => void; + onLinkIssue?: (taskId: string) => void; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; onReorderGroup?: (groupTaskIds: string[]) => void; @@ -135,6 +137,8 @@ type TaskRowProps = { onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; + onLinkPullRequest?: (taskId: string) => void; + onLinkIssue?: (taskId: string) => void; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; isPinned?: boolean; @@ -154,6 +158,8 @@ function TaskRow({ onRenameTask, onArchiveTask, onDeleteTask, + onLinkPullRequest, + onLinkIssue, onMoveToStep, onTogglePin, isPinned, @@ -170,6 +176,8 @@ function TaskRow({ onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} + onLinkPullRequest={onLinkPullRequest} + onLinkIssue={onLinkIssue} onMoveToStep={onMoveToStep} onTogglePin={onTogglePin} isPinned={isPinned} @@ -327,6 +335,8 @@ type GroupSectionProps = { onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; + onLinkPullRequest?: (taskId: string) => void; + onLinkIssue?: (taskId: string) => void; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; onReorderGroup?: (groupTaskIds: string[]) => void; @@ -351,6 +361,8 @@ function GroupSection({ onRenameTask, onArchiveTask, onDeleteTask, + onLinkPullRequest, + onLinkIssue, onMoveToStep, onTogglePin, onReorderGroup, @@ -373,6 +385,8 @@ function GroupSection({ onRenameTask, onArchiveTask, onDeleteTask, + onLinkPullRequest, + onLinkIssue, onMoveToStep, onTogglePin, deletingTaskId, @@ -413,6 +427,8 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onRenameTask, onArchiveTask, onDeleteTask, + onLinkPullRequest, + onLinkIssue, onMoveToStep, onTogglePin, onReorderGroup, @@ -454,6 +470,8 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} + onLinkPullRequest={onLinkPullRequest} + onLinkIssue={onLinkIssue} onMoveToStep={onMoveToStep} onTogglePin={onTogglePin} onReorderGroup={onReorderGroup} diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx index edaddfb4fb..1e7dd61a47 100644 --- a/apps/web/components/task/task-top-bar.test.tsx +++ b/apps/web/components/task/task-top-bar.test.tsx @@ -32,7 +32,7 @@ vi.mock("@/components/vcs-split-button", () => ({ })); vi.mock("@/components/github/pr-topbar-button", () => ({ - PRTopbarButton: () => null, + PRTopbarButton: () => , })); vi.mock("@/components/gitlab/mr-topbar-button", () => ({ @@ -103,6 +103,28 @@ describe("TaskTopBar executor environment controls", () => { }); }); +describe("TaskTopBar GitHub issue link", () => { + it("shows the linked issue before linked pull requests", () => { + renderTopBar( + , + ); + + const issue = screen.getByTestId("issue-topbar-button"); + const pr = screen.getByTestId("pr-topbar-button"); + + expect(issue.textContent).toContain("#1470"); + expect(screen.getByLabelText("Task status and attention").className).toContain( + "[&_[data-testid=issue-topbar-button]]:h-7", + ); + expect(screen.getByLabelText("Task status and attention").className).not.toContain("[&_a]"); + expect(issue.compareDocumentPosition(pr) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); + function renderTopBar(ui: React.ReactNode) { return render({ui}); } diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index b8c54e4ebf..9772fd31fe 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -2,7 +2,7 @@ import { memo, type ReactNode } from "react"; import Link from "@/components/routing/app-link"; -import { IconBug } from "@tabler/icons-react"; +import { IconBug, IconCircleDot } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@kandev/ui/breadcrumb"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -37,6 +37,8 @@ type TaskTopBarProps = { currentStepId?: string | null; workflowId?: string | null; workspaceId?: string | null; + issueUrl?: string; + issueNumber?: number; isArchived?: boolean; isRemoteExecutor?: boolean; isAgentctlReady?: boolean; @@ -65,6 +67,8 @@ const TaskTopBar = memo(function TaskTopBar({ isArchived, isRemoteExecutor, isAgentctlReady, + issueUrl, + issueNumber, remoteExecutorType, officeTaskHref, }: TaskTopBarProps) { @@ -101,6 +105,8 @@ const TaskTopBar = memo(function TaskTopBar({ isRemoteExecutor={isRemoteExecutor} isAgentctlReady={isAgentctlReady} taskTitle={taskTitle} + issueUrl={issueUrl} + issueNumber={issueNumber} officeTaskHref={officeTaskHref} /> @@ -228,6 +234,8 @@ function AttentionStatusGroup({ isRemoteExecutor, isAgentctlReady, taskTitle, + issueUrl, + issueNumber, }: { taskId?: string | null; activeSessionId?: string | null; @@ -236,9 +244,21 @@ function AttentionStatusGroup({ isRemoteExecutor?: boolean; isAgentctlReady?: boolean; taskTitle?: string; + issueUrl?: string; + issueNumber?: number; }) { return ( - + {!isArchived && ( <> @@ -250,6 +270,7 @@ function AttentionStatusGroup({ {/* PR (GitHub) and MR (GitLab) buttons each render nothing when no rows match, so showing both covers GitHub-only, GitLab-only, and multi-repo tasks without needing an explicit provider switch. */} + @@ -259,6 +280,38 @@ function AttentionStatusGroup({ ); } +function GitHubIssueTopbarButton({ + issueUrl, + issueNumber, +}: { + issueUrl?: string; + issueNumber?: number; +}) { + if (!issueUrl) return null; + const label = issueNumber ? `#${issueNumber}` : "Issue"; + const tooltip = issueNumber ? `GitHub issue #${issueNumber}` : "GitHub issue"; + return ( + + + + + {tooltip} + + ); +} + function TopbarToolsGroup({ activeSessionId, showDebugOverlay, @@ -303,6 +356,8 @@ function TopBarRight({ isRemoteExecutor, isAgentctlReady, taskTitle, + issueUrl, + issueNumber, officeTaskHref, }: { taskId?: string | null; @@ -314,6 +369,8 @@ function TopBarRight({ isRemoteExecutor?: boolean; isAgentctlReady?: boolean; taskTitle?: string; + issueUrl?: string; + issueNumber?: number; officeTaskHref?: string | null; }) { return ( @@ -334,6 +391,8 @@ function TopBarRight({ isRemoteExecutor={isRemoteExecutor} isAgentctlReady={isAgentctlReady} taskTitle={taskTitle} + issueUrl={issueUrl} + issueNumber={issueNumber} /> ({ })); import { + createTaskPR, fetchAccessibleRepos, fetchIssueInfo, getTaskCIAutomationOptions, GitHubUnavailableError, + linkTaskIssue, + unlinkTaskIssue, updateTaskCIAutomationOptions, type AccessibleRepo, } from "./github-api"; @@ -128,6 +131,78 @@ describe("fetchIssueInfo", () => { }); }); +describe("task issue link helpers", () => { + it("links a GitHub pull request to a task", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + id: "task-pr-1", + task_id: "task-1", + repository_id: "task-repo-1", + owner: "kdlbs", + repo: "kandev", + pr_number: 1471, + pr_url: "https://github.com/kdlbs/kandev/pull/1471", + pr_title: "Link references", + }), + ); + + await createTaskPR({ + task_id: "task-1", + repository_id: "task-repo-1", + pr_url: "https://github.com/kdlbs/kandev/pull/1471", + }); + + const call = fetchSpy.mock.calls.at(-1); + expect(String(call?.[0])).toBe("http://api.test/api/v1/github/task-prs"); + expect(call?.[1]?.method).toBe("POST"); + expect(JSON.parse(String(call?.[1]?.body))).toEqual({ + task_id: "task-1", + repository_id: "task-repo-1", + pr_url: "https://github.com/kdlbs/kandev/pull/1471", + }); + }); + + it("links a GitHub issue to a task", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + task_id: "task-1", + owner: "kdlbs", + repo: "kandev", + issue_number: 1470, + issue_url: "https://github.com/kdlbs/kandev/issues/1470", + issue_title: "Link issue", + }), + ); + + await linkTaskIssue("task-1", { + issue: "#1470", + owner: "kdlbs", + repo: "kandev", + number: 1470, + }); + + const call = fetchSpy.mock.calls.at(-1); + expect(String(call?.[0])).toBe("http://api.test/api/v1/github/tasks/task-1/issue"); + expect(call?.[1]?.method).toBe("PUT"); + expect(JSON.parse(String(call?.[1]?.body))).toEqual({ + issue: "#1470", + owner: "kdlbs", + repo: "kandev", + number: 1470, + }); + }); + + it("unlinks a GitHub issue from a task", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ unlinked: true })); + + await unlinkTaskIssue("task-1"); + + const call = fetchSpy.mock.calls.at(-1); + expect(String(call?.[0])).toBe("http://api.test/api/v1/github/tasks/task-1/issue"); + expect(call?.[1]?.method).toBe("DELETE"); + }); +}); + describe("fetchAccessibleRepos — errors & signal", () => { it("throws GitHubUnavailableError on 503 with code: github_not_configured", async () => { fetchSpy.mockResolvedValueOnce( diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index 1d1ff92195..84d93c0abe 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -20,6 +20,7 @@ import type { UpdateIssueWatchRequest, TriggerIssueResponse, GitHubIssue, + TaskIssueLink, SearchPRsResponse, SearchIssuesResponse, GitHubPRStatus, @@ -85,6 +86,34 @@ export async function createTaskPR( }); } +export async function linkTaskIssue( + taskId: string, + data: { issue: string; owner?: string; repo?: string; number?: number }, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/github/tasks/${encodeURIComponent(taskId)}/issue`, { + ...options, + init: { + ...(options?.init ?? {}), + method: "PUT", + body: JSON.stringify(data), + }, + }); +} + +export async function unlinkTaskIssue(taskId: string, options?: ApiRequestOptions) { + return fetchJson<{ unlinked: boolean }>( + `/api/v1/github/tasks/${encodeURIComponent(taskId)}/issue`, + { + ...options, + init: { + ...(options?.init ?? {}), + method: "DELETE", + }, + }, + ); +} + export async function getTaskCIAutomationOptions(taskId: string, options?: ApiRequestOptions) { return fetchJson( `/api/v1/github/tasks/${encodeURIComponent(taskId)}/ci-options`, diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index 98cda9cc63..755f39b6e0 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -425,6 +425,15 @@ export type GitHubIssue = { closed_at: string | null; }; +export type TaskIssueLink = { + task_id: string; + owner: string; + repo: string; + issue_number: number; + issue_url: string; + issue_title: string; +}; + export type SearchPRsResponse = { prs: GitHubPR[]; total_count: number; diff --git a/docs/plans/link-existing-task-github-issue/plan.md b/docs/plans/link-existing-task-github-issue/plan.md new file mode 100644 index 0000000000..b15c65594a --- /dev/null +++ b/docs/plans/link-existing-task-github-issue/plan.md @@ -0,0 +1,33 @@ +# Link Existing Task to GitHub References Plan + +## Scope + +Implement issue #1470 by linking GitHub references to existing tasks: + +- GitHub issues through task metadata and the existing GitHub issue fetch path. +- GitHub pull requests through the existing task PR association model. +- A shared **Link** submenu in task menus with separate **GitHub Pull Request** and **GitHub Issue** actions. + +## Backend + +- Add `PUT /api/v1/github/tasks/:taskId/issue` and `DELETE /api/v1/github/tasks/:taskId/issue`. +- Add GitHub service methods to parse URL/number input, fetch the issue, validate repository ownership, merge or remove issue metadata, and rely on task service update publishing. +- Reuse existing `POST /api/v1/github/task-prs` support for manual PR association. +- Add service tests for successful link, repository mismatch, and unlink. + +## Frontend + +- Add typed GitHub API helpers for link/unlink. +- Add responsive dialogs for GitHub issue and GitHub pull request URL/number input. +- Replace flat issue actions with a **Link** submenu containing **GitHub Pull Request** and **GitHub Issue**. +- Add that submenu to task card menus and sidebar task menus. +- Reuse existing issue metadata and task PR rendering on task cards and detail/sidebar surfaces. +- Add API helper unit tests. + +## Verification + +- `cd apps/backend && go test ./internal/github/...` +- `cd apps && pnpm --filter @kandev/web test -- --run lib/api/domains/github-api.test.ts lib/kanban/map-task.test.ts` +- `cd apps && pnpm --filter @kandev/web typecheck` +- `cd apps && pnpm --filter @kandev/web lint` +- Run format, typecheck, test, and lint before opening the PR. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 0df2e9c9c7..fe5332505a 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -47,6 +47,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [subtree-controls](tasks/subtree-controls.md) | shipped | | [blocked-task-escalation](tasks/blocked-task-escalation.md) | draft | | [runtime-cleanup](tasks/runtime-cleanup.md) | draft | +| [link-existing-task-github-issue](tasks/link-existing-task-github-issue.md) | building | | [multi-branch](tasks/multi-branch/spec.md) | shipped | ## agents/ — agent governance diff --git a/docs/specs/tasks/link-existing-task-github-issue.md b/docs/specs/tasks/link-existing-task-github-issue.md new file mode 100644 index 0000000000..272918d5b4 --- /dev/null +++ b/docs/specs/tasks/link-existing-task-github-issue.md @@ -0,0 +1,67 @@ +--- +status: building +created: 2026-06-23 +owner: product +--- + +# Link Existing Task to GitHub References + +## Why + +Users can create a task from GitHub, but they also need to attach GitHub references to a task that already exists. That breaks traceability when a task is started first and the GitHub pull request or issue is created later. + +## What + +- Any existing task can open a **Link** submenu from task menus. +- The submenu contains **GitHub Pull Request** and **GitHub Issue** actions. +- The actions are available from task card context/dropdown menus and sidebar task context menus. +- The issue action accepts a GitHub issue URL. If the task has exactly one GitHub repository, an issue number such as `#1470` is also accepted. +- The pull request action accepts a GitHub PR URL. If the task has exactly one GitHub repository, a PR number such as `#1471` is also accepted. +- The backend fetches the issue through the configured GitHub integration and only links it when the issue belongs to a GitHub repository attached to the task. +- The link is stored in task metadata using the existing `issue_url` and `issue_number` fields, so kanban cards and task detail surfaces render it through the existing issue indicator. +- Pull request linking reuses the existing task PR association model and rendering. +- A linked issue can be explicitly changed or unlinked from the same dialog. + +## Out of Scope + +- Creating GitHub issues from Kandev. +- Creating GitHub pull requests from Kandev. +- New issue synchronization beyond the existing metadata-backed reference. + +## Scenarios + +### Link a GitHub issue to an existing task + +GIVEN an existing task with a GitHub repository attached +WHEN the user opens Link > GitHub Issue and enters an issue URL from that repository +THEN Kandev stores the issue URL and issue number on the task without changing task state, session history, repositories, or unrelated metadata + +### Reject an issue from a different repository + +GIVEN an existing task with a GitHub repository attached +WHEN the user attempts to link an issue from another GitHub repository +THEN Kandev rejects the request with a clear repository mismatch error and leaves task metadata unchanged + +### Link a pull request to an existing task + +GIVEN an existing task with a GitHub repository attached +WHEN the user opens Link > GitHub Pull Request and enters a pull request URL from that repository +THEN Kandev creates the task pull request association using the attached repository ID so existing PR rendering surfaces can show the link + +### Infer a reference number for a single-repository task + +GIVEN an existing task with exactly one GitHub repository attached +WHEN the user enters a bare number or hash-prefixed number in the GitHub Issue or GitHub Pull Request dialog +THEN Kandev resolves the number against that single repository before linking the reference + +### Unlink an existing issue + +GIVEN an existing task that already has GitHub issue metadata +WHEN the user opens Link > GitHub Issue and chooses Unlink +THEN Kandev removes only the issue metadata keys and preserves unrelated task metadata + +## Success Criteria + +- Linking does not change task state, session history, repositories, or unrelated metadata. +- Invalid issue references and repository mismatches return clear errors. +- Right-click context menu, sidebar menu, and touch/dropdown menu users can reach the Link submenu.