feat: link tasks to github references (#1471)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user