feat: link tasks to github references (#1471)

This commit is contained in:
Carlos Florêncio
2026-06-23 17:28:52 +01:00
committed by GitHub
parent 9b00a16bbe
commit 4d19fb32f1
34 changed files with 2367 additions and 120 deletions
@@ -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)
@@ -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.
+1
View File
@@ -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)")
@@ -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 {
@@ -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()
+8
View File
@@ -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
+10 -9
View File
@@ -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
@@ -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]
@@ -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,
}
}
@@ -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)
}
})
}
}
@@ -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",
@@ -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: <IconLink className="mr-2 h-4 w-4" />,
label: "Link",
disabled,
className: "w-56",
children: [
{
kind: "item",
key: "link-github-pull-request",
testId: "task-context-link-github-pull-request",
icon: <IconGitPullRequest className="mr-2 h-4 w-4" />,
label: "GitHub Pull Request",
disabled: disabled || !onLinkPullRequest,
onSelect: onLinkPullRequest,
},
{
kind: "item",
key: "link-github-issue",
testId: "task-context-link-github-issue",
icon: <IconCircleDot className="mr-2 h-4 w-4" />,
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",
+104 -15
View File
@@ -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 (
<>
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
isDeleting={isDeleting}
onConfirm={({ cascade }) => onDelete?.(task, { cascade })}
/>
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
isArchiving={isArchiving}
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
/>
<TaskGitHubPRDialog
open={showPRDialog}
onOpenChange={setShowPRDialog}
task={task}
repositories={repositories}
/>
<TaskGitHubIssueDialog
open={showIssueDialog}
onOpenChange={setShowIssueDialog}
task={task}
repositories={repositories}
/>
</>
);
}
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}
/>
</KanbanCardContextMenu>
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
<KanbanCardDialogs
task={task}
repositories={repositories}
showDeleteConfirm={showDeleteConfirm}
setShowDeleteConfirm={setShowDeleteConfirm}
showArchiveConfirm={showArchiveConfirm}
setShowArchiveConfirm={setShowArchiveConfirm}
showPRDialog={showPRDialog}
setShowPRDialog={setShowPRDialog}
showIssueDialog={showIssueDialog}
setShowIssueDialog={setShowIssueDialog}
isDeleting={isDeleting}
onConfirm={({ cascade }) => onDelete?.(task, { cascade })}
/>
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
isArchiving={isArchiving}
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
onDelete={onDelete}
onArchive={onArchive}
/>
</>
);
@@ -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 (
<div className="space-y-2">
<Label htmlFor="task-github-issue-input">Issue</Label>
<Input
id="task-github-issue-input"
data-testid="task-github-issue-input"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={placeholder}
disabled={submitting}
/>
{error && (
<p className="text-xs text-destructive" data-testid="task-github-issue-error">
{error}
</p>
)}
</div>
);
}
function TaskGitHubIssueActions({
currentLabel,
submitting,
onCancel,
onSubmit,
onUnlink,
}: DialogActionsProps) {
return (
<DialogFooter className="gap-2 sm:justify-between">
{currentLabel ? (
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={onUnlink}
disabled={submitting}
>
Unlink
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={onCancel}
disabled={submitting}
>
Cancel
</Button>
<Button
type="button"
className="cursor-pointer"
onClick={onSubmit}
disabled={submitting}
data-testid="task-github-issue-submit"
>
{submitting ? "Saving" : "Save"}
</Button>
</div>
</DialogFooter>
);
}
export function TaskGitHubIssueDialog({
open,
onOpenChange,
task,
repositories,
}: TaskGitHubIssueDialogProps) {
const { toast } = useToast();
const [input, setInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle>{currentLabel ? "Change GitHub issue" : "Link GitHub issue"}</DialogTitle>
<DialogDescription>
{inferredRepo
? `Use a full issue URL or number for ${inferredRepo.owner}/${inferredRepo.repo}.`
: "Use a full GitHub issue URL for this task."}
</DialogDescription>
</DialogHeader>
<TaskGitHubIssueFields
input={input}
setInput={setInput}
inferredRepo={inferredRepo}
submitting={submitting}
error={error}
/>
<TaskGitHubIssueActions
currentLabel={currentLabel}
submitting={submitting}
onCancel={() => onOpenChange(false)}
onSubmit={submit}
onUnlink={unlink}
/>
</DialogContent>
</Dialog>
);
}
@@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle>Link GitHub pull request</DialogTitle>
<DialogDescription>
{inferredRepo
? `Use a full pull request URL or number for ${inferredRepo.owner}/${inferredRepo.repo}.`
: "Use a full GitHub pull request URL for this task."}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="task-github-pr-input">Pull request</Label>
<Input
id="task-github-pr-input"
data-testid="task-github-pr-input"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={placeholder}
disabled={submitting}
/>
{error && (
<p className="text-xs text-destructive" data-testid="task-github-pr-error">
{error}
</p>
)}
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Cancel
</Button>
<Button
type="button"
className="cursor-pointer"
onClick={submit}
disabled={submitting}
data-testid="task-github-pr-submit"
>
{submitting ? "Saving" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<Omit<Repository, "id" | "workspace_id">> & { 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,
});
});
});
@@ -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,
};
}
@@ -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<Parameters<typeof buildDebugEntries>[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);
});
});
@@ -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,
/**
@@ -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,
@@ -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> | 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 && (
<TaskGitHubPRDialog
open={true}
onOpenChange={(open) => {
if (!open) setLinkingPullRequestTask(null);
}}
task={linkingPullRequestTask}
repositories={repositories}
/>
)}
{linkingIssueTask && (
<TaskGitHubIssueDialog
open={true}
onOpenChange={(open) => {
if (!open) setLinkingIssueTask(null);
}}
task={linkingIssueTask}
repositories={repositories}
/>
)}
</>
);
}
@@ -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 };
}
@@ -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<string, { tasks: KanbanState["tasks"] }> };
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<SidebarLinkTarget | null>(
null,
);
const [linkingIssueTask, setLinkingIssueTask] = useState<SidebarLinkTarget | null>(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,
};
}
@@ -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}
/>
</PanelBody>
<SidebarDialogs actions={sidebarActions} />
<SidebarDialogs actions={sidebarActions} repositories={repositories} />
</PanelRoot>
);
});
@@ -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({
<div>{cloneWithMenuOpen(children, menuOpen)}</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{onTogglePin && (
<ContextMenuItem disabled={isDeleting} onSelect={() => onTogglePin(task.id)}>
{isPinned ? (
<IconPinFilled className="mr-2 h-4 w-4" />
) : (
<IconPin className="mr-2 h-4 w-4" />
)}
{isPinned ? "Unpin" : "Pin"}
</ContextMenuItem>
)}
{onRenameTask && (
<ContextMenuItem disabled={isDeleting} onSelect={() => onRenameTask(task.id, task.title)}>
<IconPencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuItem disabled>
<IconCopy className="mr-2 h-4 w-4" />
Duplicate
</ContextMenuItem>
{onArchiveTask && (
<ContextMenuItem disabled={isDeleting} onSelect={() => onArchiveTask(task.id)}>
<IconArchive className="mr-2 h-4 w-4" />
Archive
</ContextMenuItem>
)}
<TaskColorMenu taskId={task.id} disabled={isDeleting} />
{task.workflowId && (
<TaskMoveContextMenuItems
currentWorkflowId={task.workflowId}
currentStepId={task.workflowStepId}
workflows={workflows ?? []}
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})}
disabled={isDeleting || task.isArchived}
onMoveToStep={
onMoveToStep
? (stepId) => {
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 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={isDeleting}
onSelect={() => onDeleteTask(task.id)}
>
{isDeleting ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconTrash className="mr-2 h-4 w-4" />
)}
Delete
</ContextMenuItem>
</>
)}
<TaskContextMenuItems
task={task}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
steps={steps}
onRenameTask={onRenameTask}
onArchiveTask={onArchiveTask}
onDeleteTask={onDeleteTask}
onLinkPullRequest={onLinkPullRequest}
onLinkIssue={onLinkIssue}
onMoveToStep={onMoveToStep}
onTogglePin={onTogglePin}
isPinned={isPinned}
isDeleting={isDeleting}
closeMenu={closeMenu}
moveTasks={moveTasks}
/>
</ContextMenuContent>
</ContextMenu>
);
}
type TaskContextMenuItemsProps = Omit<ContextMenuProps, "children"> & {
closeMenu: () => void;
moveTasks: ReturnType<typeof useTaskWorkflowMove>;
};
function TaskContextMenuItems({
task,
workflows,
stepsByWorkflowId,
steps,
onRenameTask,
onArchiveTask,
onDeleteTask,
onLinkPullRequest,
onLinkIssue,
onMoveToStep,
onTogglePin,
isPinned,
isDeleting,
closeMenu,
moveTasks,
}: TaskContextMenuItemsProps) {
return (
<>
<TaskPinItem
taskId={task.id}
isPinned={isPinned}
disabled={isDeleting}
onTogglePin={onTogglePin}
/>
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
<ContextMenuItem disabled>
<IconCopy className="mr-2 h-4 w-4" />
Duplicate
</ContextMenuItem>
<TaskArchiveItem taskId={task.id} disabled={isDeleting} onArchiveTask={onArchiveTask} />
<TaskColorMenu taskId={task.id} disabled={isDeleting} />
<TaskLinkMenu
disabled={isDeleting}
onLinkPullRequest={selectTaskAction(task.id, onLinkPullRequest, closeMenu)}
onLinkIssue={selectTaskAction(task.id, onLinkIssue, closeMenu)}
/>
<TaskMoveItems
task={task}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
steps={steps}
isDeleting={isDeleting}
onMoveToStep={onMoveToStep}
closeMenu={closeMenu}
moveTasks={moveTasks}
/>
<TaskDeleteItem taskId={task.id} isDeleting={isDeleting} onDeleteTask={onDeleteTask} />
</>
);
}
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 (
<ContextMenuItem disabled={disabled} onSelect={() => onTogglePin(taskId)}>
{isPinned ? <IconPinFilled className="mr-2 h-4 w-4" /> : <IconPin className="mr-2 h-4 w-4" />}
{isPinned ? "Unpin" : "Pin"}
</ContextMenuItem>
);
}
function TaskRenameItem({
task,
disabled,
onRenameTask,
}: {
task: TaskSwitcherItem;
disabled?: boolean;
onRenameTask?: (taskId: string, currentTitle: string) => void;
}) {
if (!onRenameTask) return null;
return (
<ContextMenuItem disabled={disabled} onSelect={() => onRenameTask(task.id, task.title)}>
<IconPencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
);
}
function TaskArchiveItem({
taskId,
disabled,
onArchiveTask,
}: {
taskId: string;
disabled?: boolean;
onArchiveTask?: (taskId: string) => void;
}) {
if (!onArchiveTask) return null;
return (
<ContextMenuItem disabled={disabled} onSelect={() => onArchiveTask(taskId)}>
<IconArchive className="mr-2 h-4 w-4" />
Archive
</ContextMenuItem>
);
}
function TaskMoveItems({
task,
workflows,
stepsByWorkflowId,
steps,
isDeleting,
onMoveToStep,
closeMenu,
moveTasks,
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask">) {
if (!task.workflowId) return null;
return (
<TaskMoveContextMenuItems
currentWorkflowId={task.workflowId}
currentStepId={task.workflowStepId}
workflows={workflows ?? []}
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})}
disabled={isDeleting || task.isArchived}
onMoveToStep={selectMoveAction(task.id, task.workflowId, onMoveToStep, closeMenu)}
onSendToWorkflow={(workflowId, stepId) => {
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 (
<>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={isDeleting}
onSelect={() => onDeleteTask(taskId)}
>
{isDeleting ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconTrash className="mr-2 h-4 w-4" />
)}
Delete
</ContextMenuItem>
</>
);
}
function TaskLinkMenu({
disabled,
onLinkPullRequest,
onLinkIssue,
}: {
disabled?: boolean;
onLinkPullRequest?: () => void;
onLinkIssue?: () => void;
}) {
if (!onLinkPullRequest && !onLinkIssue) return null;
return (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={disabled}>
<IconLink className="mr-2 h-4 w-4" />
Link
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-56">
<ContextMenuItem disabled={disabled || !onLinkPullRequest} onSelect={onLinkPullRequest}>
<IconGitPullRequest className="mr-2 h-4 w-4" />
GitHub Pull Request
</ContextMenuItem>
<ContextMenuItem disabled={disabled || !onLinkIssue} onSelect={onLinkIssue}>
<IconCircleDot className="mr-2 h-4 w-4" />
GitHub Issue
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) {
const currentColor = useTaskColor(taskId);
const setColor = useSetTaskColor();
@@ -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}
+23 -1
View File
@@ -32,7 +32,7 @@ vi.mock("@/components/vcs-split-button", () => ({
}));
vi.mock("@/components/github/pr-topbar-button", () => ({
PRTopbarButton: () => null,
PRTopbarButton: () => <button data-testid="pr-topbar-button">#1472</button>,
}));
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(
<TaskTopBar
taskId="task-1"
issueUrl="https://github.com/kdlbs/kandev/issues/1470"
issueNumber={1470}
/>,
);
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(<StateProvider>{ui}</StateProvider>);
}
+61 -2
View File
@@ -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}
/>
</header>
@@ -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 (
<TopbarCluster label="Task status and attention" className="[&_button]:h-7 [&_button]:text-xs">
<TopbarCluster
label="Task status and attention"
className={[
"[&_button]:h-7",
"[&_button]:text-xs",
"[&_[data-testid=issue-topbar-button]]:h-7",
"[&_[data-testid=issue-topbar-button]]:text-xs",
"[&_[data-testid=mr-topbar-button]]:h-7",
"[&_[data-testid=mr-topbar-button]]:text-xs",
].join(" ")}
>
<DocumentControls activeSessionId={activeSessionId ?? null} />
{!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. */}
<GitHubIssueTopbarButton issueUrl={issueUrl} issueNumber={issueNumber} />
<PRTopbarButton />
<MRTopbarButton />
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
@@ -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>
<TooltipTrigger asChild>
<Button
asChild
data-testid="issue-topbar-button"
data-issue-number={issueNumber}
size="sm"
variant="outline"
className="cursor-pointer gap-1.5 px-2"
>
<Link href={issueUrl} target="_blank" rel="noopener noreferrer">
<IconCircleDot className="h-4 w-4 text-green-500" />
<span className="text-xs font-medium">{label}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</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}
/>
<TopbarToolsGroup
activeSessionId={activeSessionId}
@@ -5,10 +5,13 @@ vi.mock("@/lib/config", () => ({
}));
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(
+29
View File
@@ -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<TaskIssueLink>(`/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<TaskCIAutomationOptions>(
`/api/v1/github/tasks/${encodeURIComponent(taskId)}/ci-options`,
+9
View File
@@ -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;
@@ -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.
+1
View File
@@ -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
@@ -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.