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;