feat(mcp): handle task change links (#3506)

Co-authored-by: Carlos Florêncio <carlosmflorencio@gmail.com>
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
This commit is contained in:
Alassane
2026-09-14 10:00:35 -04:00
committed by GitHub
parent bf0e2853fb
commit 262ecdf270
45 changed files with 2259 additions and 85 deletions
+4 -1
View File
@@ -747,7 +747,10 @@ jobs:
# a self-hosting Go helper (see testmain_test.go) so they run on
# every platform.
- name: Test windows-sensitive packages
run: go test -race -v ./internal/agentctl/server/process/... ./internal/agent/runtime/agentctl/launcher/... ./internal/common/ptyexec/... ./internal/backendapp/ownershiplock/... ./internal/system/storage/workspaces/...
# The process package is large and the race detector is slow on
# windows-latest. Go's default package timeout is 10 minutes, which can
# terminate a healthy suite before the workflow's 30-minute job cap.
run: go test -race -v -timeout 25m ./internal/agentctl/server/process/... ./internal/agent/runtime/agentctl/launcher/... ./internal/common/ptyexec/... ./internal/backendapp/ownershiplock/... ./internal/system/storage/workspaces/...
- name: Test Windows managed runtime cache repair
run: go test -race -v ./internal/agent/managedruntime -run TestRemoveNpxExecutionTreeTreatsMissingWindowsChildAsAbsent
+60 -7
View File
@@ -1845,11 +1845,60 @@ func (a mcpTaskPRListerAdapter) ListTaskPRsByTaskIDs(
continue
}
infos = append(infos, mcphandlers.TaskPRInfo{
Number: pr.PRNumber,
URL: pr.PRURL,
Title: pr.PRTitle,
State: pr.State,
MergedAt: pr.MergedAt,
RepositoryID: pr.RepositoryID,
Number: pr.PRNumber,
URL: pr.PRURL,
Title: pr.PRTitle,
State: pr.State,
Draft: pr.IsDraft,
BaseRef: pr.BaseBranch,
HeadRef: pr.HeadBranch,
HeadSHA: pr.HeadSHA,
MergedAt: pr.MergedAt,
ClosedAt: pr.ClosedAt,
})
}
if len(infos) > 0 {
out[taskID] = infos
}
}
return out, nil
}
type mcpTaskMRListerAdapter struct {
gl *gitlab.Service
}
func (a mcpTaskMRListerAdapter) ListTaskMRsByTaskIDs(
ctx context.Context, taskIDs []string,
) (map[string][]mcphandlers.TaskMRInfo, error) {
out := make(map[string][]mcphandlers.TaskMRInfo)
if a.gl == nil || len(taskIDs) == 0 {
return out, nil
}
byTask, err := a.gl.ListTaskMRsByTaskIDs(ctx, taskIDs)
if err != nil {
return nil, err
}
for taskID, mrs := range byTask {
infos := make([]mcphandlers.TaskMRInfo, 0, len(mrs))
for _, mr := range mrs {
if mr == nil {
continue
}
infos = append(infos, mcphandlers.TaskMRInfo{
RepositoryID: mr.RepositoryID,
Number: mr.MRIID,
URL: mr.MRURL,
Title: mr.MRTitle,
State: mr.State,
Draft: mr.Draft,
BaseRef: mr.BaseBranch,
BaseSHA: mr.BaseSHA,
HeadRef: mr.HeadBranch,
HeadSHA: mr.HeadSHA,
MergedAt: mr.MergedAt,
ClosedAt: mr.ClosedAt,
})
}
if len(infos) > 0 {
@@ -1936,8 +1985,8 @@ func registerMCPAndDebugRoutes(
mcpHandlers.SetDiagnosticBundleServices(p.systemSvc.LogBundles, p.lifecycleMgr)
}
// Enrich list_tasks responses with associated GitHub PRs (link, title,
// number, state) when the github service is available.
// Enrich task-listing responses with associated change requests when
// provider services are available.
if p.services.GitHub != nil {
mcpHandlers.SetTaskPRLister(mcpTaskPRListerAdapter{gh: p.services.GitHub})
mcpHandlers.SetTaskPRAutomationService(p.services.GitHub)
@@ -1946,8 +1995,12 @@ func registerMCPAndDebugRoutes(
mcpHandlers.SetTaskPRAutoFixOutcomeService(p.orchestratorSvc)
}
if p.services.GitLab != nil {
mcpHandlers.SetTaskMRLister(mcpTaskMRListerAdapter{gl: p.services.GitLab})
mcpHandlers.SetTaskMRAutomationService(p.services.GitLab)
}
mcpHandlers.SetTaskChangeLinkService(taskChangeLinkCoordinator{
tasks: p.taskSvc, github: p.services.GitHub, gitlab: p.services.GitLab,
})
// Reuse the cross-task handoff service constructed in registerRoutes —
// the same instance backs the MCP path and the HTTP Kanban path so
// workspace-group state stays consistent across both surfaces.
@@ -22,6 +22,7 @@ import (
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/quickterminal"
quickterminalrepo "github.com/kandev/kandev/internal/quickterminal/repository"
systemsvc "github.com/kandev/kandev/internal/system"
@@ -153,6 +154,48 @@ func TestBuildGitStatusNotificationIncludesAncestryEvidence(t *testing.T) {
}
}
func TestMCPTaskPRListerAdapterPreservesGitHubChangeFacts(t *testing.T) {
ctx := context.Background()
store := newStatusSummaryTestStore(t)
draft := true
pr := &github.TaskPR{
TaskID: "task-change-facts",
RepositoryID: "repo-change-facts",
PRNumber: 42,
PRURL: "https://github.com/acme/api/pull/42",
PRTitle: "Preserve exact facts",
State: "open",
BaseBranch: "main",
HeadBranch: "feature/facts",
HeadSHA: "head-sha-42",
IsDraft: &draft,
CreatedAt: time.Now().UTC(),
}
if err := store.CreateTaskPR(ctx, pr); err != nil {
t.Fatalf("CreateTaskPR: %v", err)
}
adapter := mcpTaskPRListerAdapter{gh: github.NewService(nil, "", nil, store, nil, nil)}
byTask, err := adapter.ListTaskPRsByTaskIDs(ctx, []string{pr.TaskID})
if err != nil {
t.Fatalf("ListTaskPRsByTaskIDs: %v", err)
}
infos := byTask[pr.TaskID]
if len(infos) != 1 {
t.Fatalf("infos = %#v, want one association", infos)
}
got := infos[0]
if got.RepositoryID != pr.RepositoryID || got.Number != pr.PRNumber || got.URL != pr.PRURL || got.Title != pr.PRTitle || got.State != pr.State || got.BaseRef != pr.BaseBranch || got.HeadRef != pr.HeadBranch {
t.Fatalf("adapter facts = %#v", got)
}
if got.Draft == nil || !*got.Draft {
t.Fatalf("Draft = %v, want true", got.Draft)
}
if got.HeadSHA != pr.HeadSHA {
t.Fatalf("HeadSHA = %q, want %q", got.HeadSHA, pr.HeadSHA)
}
}
// TestAppendSessionStateMessage_IncludesTaskEnvironmentID asserts the snapshot
// the WS hub sends on `session.subscribe` carries `task_environment_id`.
//
@@ -0,0 +1,290 @@
package backendapp
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
mcp "github.com/kandev/kandev/internal/mcp/handlers"
"github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
)
// taskChangeLinkCoordinator is the provider-neutral MCP seam. It keeps task
// reach checks and repository identity resolution at the host boundary, then
// delegates persistence and provider reads to the established services.
type taskChangeLinkCoordinator struct {
tasks *taskservice.Service
github githubChangeLinkProvider
gitlab gitlabChangeLinkProvider
}
const (
taskChangeProviderGitHub = "github"
taskChangeProviderGitLab = "gitlab"
)
type githubChangeLinkProvider interface {
AssociateExistingPRByURLForWorkspace(context.Context, string, string, string, string, string) (*github.TaskPR, error)
DetachTaskPR(context.Context, string, string) (*github.TaskPR, error)
ListTaskPRs(context.Context, []string) (map[string][]*github.TaskPR, error)
}
type gitlabChangeLinkProvider interface {
AssociateExistingMRByURL(context.Context, string, string, string, string) (*gitlab.TaskMR, error)
ListTaskMRsByTask(context.Context, string) ([]*gitlab.TaskMR, error)
UnlinkTaskMR(context.Context, string, string) error
}
func (c taskChangeLinkCoordinator) LinkTaskChange(ctx context.Context, req mcp.TaskChangeLinkRequest) ([]mcp.TaskChangeLink, error) {
if err := c.link(ctx, req.TaskID, req.Link); err != nil {
return nil, err
}
return c.list(ctx, req.TaskID)
}
func (c taskChangeLinkCoordinator) UnlinkTaskChange(ctx context.Context, req mcp.TaskChangeLinkRequest) ([]mcp.TaskChangeLink, error) {
if err := c.unlink(ctx, req.TaskID, req.Link); err != nil {
return nil, err
}
return c.list(ctx, req.TaskID)
}
func (c taskChangeLinkCoordinator) ReplaceTaskChange(ctx context.Context, req mcp.TaskChangeLinkRequest) ([]mcp.TaskChangeLink, error) {
if req.Old == nil {
return nil, fmt.Errorf("current task change identity is required")
}
// Replacing an association with the exact same identity is already the
// requested final state. Do not detach it after the idempotent link.
if req.Link == *req.Old {
return c.list(ctx, req.TaskID)
}
if req.Link.Provider != req.Old.Provider {
return nil, fmt.Errorf("cross-provider task change replacement is not supported")
}
before, err := c.list(ctx, req.TaskID)
if err != nil {
return nil, err
}
newAlreadyLinked := taskChangeLinksContain(before, req.Link)
// Resolve and persist the incoming association first. A failed provider
// fetch therefore leaves the current association untouched.
if err := c.link(ctx, req.TaskID, req.Link); err != nil {
return nil, err
}
if err := c.unlink(ctx, req.TaskID, *req.Old); err != nil {
if newAlreadyLinked {
return nil, err
}
return c.rollbackReplacement(ctx, req.TaskID, req.Link, err)
}
return c.list(ctx, req.TaskID)
}
func (c taskChangeLinkCoordinator) rollbackReplacement(
ctx context.Context, taskID string, newLink mcp.TaskChangeLink, originalErr error,
) ([]mcp.TaskChangeLink, error) {
rollbackErr := c.unlink(ctx, taskID, newLink)
if rollbackErr == nil {
return nil, originalErr
}
activeLinks, stateErr := c.list(ctx, taskID)
state := fmt.Errorf("active task change links after failed replacement: %v", activeLinks)
if stateErr != nil {
state = fmt.Errorf("active task change links unavailable after failed replacement: %w", stateErr)
}
return activeLinks, errors.Join(originalErr, fmt.Errorf("rollback new task change link: %w", rollbackErr), state)
}
func taskChangeLinksContain(links []mcp.TaskChangeLink, target mcp.TaskChangeLink) bool {
for _, link := range links {
if link.Provider == target.Provider && link.RepositoryID == target.RepositoryID && link.Number == target.Number {
return true
}
}
return false
}
func (c taskChangeLinkCoordinator) link(ctx context.Context, taskID string, link mcp.TaskChangeLink) error {
task, repo, err := c.taskRepository(ctx, taskID, link.RepositoryID)
if err != nil {
return err
}
switch link.Provider {
case taskChangeProviderGitHub:
if c.github == nil {
return fmt.Errorf("GitHub PR links are not available")
}
url, err := githubChangeURL(repo.ProviderHost, repo.ProviderOwner, repo.ProviderName, link.Number)
if err != nil {
return err
}
identity, ok := authn.IdentityFromContext(ctx)
if !ok || strings.TrimSpace(identity.UserID) == "" {
return fmt.Errorf("authenticated user identity is required for GitHub PR links")
}
_, err = c.github.AssociateExistingPRByURLForWorkspace(ctx, task.WorkspaceID, identity.UserID, task.ID, repo.ID, url)
return err
case taskChangeProviderGitLab:
if c.gitlab == nil {
return fmt.Errorf("GitLab MR links are not available")
}
url, err := gitlabChangeURL(repo.ProviderHost, repo.ProviderOwner, repo.ProviderName, link.Number)
if err != nil {
return err
}
_, err = c.gitlab.AssociateExistingMRByURL(ctx, task.WorkspaceID, task.ID, repo.ID, url)
return err
default:
return fmt.Errorf("unsupported provider %q", link.Provider)
}
}
func (c taskChangeLinkCoordinator) unlink(ctx context.Context, taskID string, link mcp.TaskChangeLink) error {
task, _, err := c.taskRepository(ctx, taskID, "")
if err != nil {
return err
}
switch link.Provider {
case taskChangeProviderGitHub:
if c.github == nil {
return fmt.Errorf("GitHub PR links are not available")
}
prs, err := c.githubLinks(ctx, task.ID)
if err != nil {
return err
}
for _, pr := range prs {
if pr.RepositoryID == link.RepositoryID && pr.PRNumber == link.Number {
_, err = c.github.DetachTaskPR(ctx, task.WorkspaceID, pr.ID)
return err
}
}
return nil
case taskChangeProviderGitLab:
if c.gitlab == nil {
return fmt.Errorf("GitLab MR links are not available")
}
mrs, err := c.gitlab.ListTaskMRsByTask(ctx, task.ID)
if err != nil {
return err
}
for _, mr := range mrs {
if mr.RepositoryID == link.RepositoryID && mr.MRIID == link.Number {
return c.gitlab.UnlinkTaskMR(ctx, task.WorkspaceID, mr.ID)
}
}
return nil
default:
return fmt.Errorf("unsupported provider %q", link.Provider)
}
}
func (c taskChangeLinkCoordinator) list(ctx context.Context, taskID string) ([]mcp.TaskChangeLink, error) {
if _, _, err := c.taskRepository(ctx, taskID, ""); err != nil {
return nil, err
}
links := []mcp.TaskChangeLink{}
if c.github != nil {
prs, err := c.githubLinks(ctx, taskID)
if err != nil {
return nil, err
}
for _, pr := range prs {
links = append(links, mcp.TaskChangeLink{Provider: taskChangeProviderGitHub, RepositoryID: pr.RepositoryID, Number: pr.PRNumber})
}
}
if c.gitlab != nil {
mrs, err := c.gitlab.ListTaskMRsByTask(ctx, taskID)
if err != nil {
return nil, err
}
for _, mr := range mrs {
links = append(links, mcp.TaskChangeLink{Provider: taskChangeProviderGitLab, RepositoryID: mr.RepositoryID, Number: mr.MRIID})
}
}
return links, nil
}
func (c taskChangeLinkCoordinator) githubLinks(ctx context.Context, taskID string) ([]*github.TaskPR, error) {
byTask, err := c.github.ListTaskPRs(ctx, []string{taskID})
if err != nil {
return nil, err
}
return byTask[taskID], nil
}
func (c taskChangeLinkCoordinator) taskRepository(ctx context.Context, taskID, repositoryID string) (*models.Task, *models.Repository, error) {
if c.tasks == nil {
return nil, nil, fmt.Errorf("task service is not available")
}
task, err := c.tasks.GetTask(ctx, taskID)
if err != nil || task == nil {
return nil, nil, fmt.Errorf("task not found")
}
if repositoryID == "" {
return task, nil, nil
}
for _, taskRepo := range task.Repositories {
if taskRepo.RepositoryID != repositoryID {
continue
}
repo, getErr := c.tasks.GetRepository(ctx, repositoryID)
if getErr != nil || repo == nil || repo.WorkspaceID != task.WorkspaceID {
return nil, nil, fmt.Errorf("repository not found for task")
}
return task, repo, nil
}
return nil, nil, fmt.Errorf("repository not found for task")
}
func githubChangeURL(host, owner, name string, number int) (string, error) {
normalizedHost, err := normalizedGitHubHost(host)
if err != nil {
return "", err
}
owner = strings.TrimSpace(owner)
name = strings.TrimSpace(name)
if owner == "" || name == "" {
return "", fmt.Errorf("repository has no canonical GitHub identity")
}
return fmt.Sprintf("https://%s/%s/%s/pull/%d", normalizedHost, owner, name, number), nil
}
func normalizedGitHubHost(host string) (string, error) {
rawHost := strings.TrimSpace(host)
if rawHost == "" {
return "", fmt.Errorf("repository has no canonical GitHub host")
}
if !strings.Contains(rawHost, "://") {
rawHost = "https://" + rawHost
}
parsed, err := url.Parse(rawHost)
if err != nil || parsed.User != nil || parsed.Host == "" || parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("repository has invalid GitHub host")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" || parsed.Port() != "" {
return "", fmt.Errorf("repository has invalid GitHub host")
}
normalizedHost := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
if normalizedHost != "github.com" && !strings.HasSuffix(normalizedHost, ".github.com") {
return "", fmt.Errorf("repository is not a GitHub repository")
}
return normalizedHost, nil
}
func gitlabChangeURL(host, owner, name string, number int) (string, error) {
host = strings.TrimRight(strings.TrimSpace(host), "/")
if host == "" || strings.TrimSpace(owner) == "" || strings.TrimSpace(name) == "" {
return "", fmt.Errorf("repository has no canonical GitLab identity")
}
if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") {
return "", fmt.Errorf("repository has invalid GitLab host")
}
return fmt.Sprintf("%s/%s/%s/-/merge_requests/%d", host, owner, name, number), nil
}
@@ -0,0 +1,433 @@
package backendapp
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
mcphandlers "github.com/kandev/kandev/internal/mcp/handlers"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type fakeGitLabChangeLinks struct {
mrs []*gitlab.TaskMR
linkedURL string
unlinkErr map[string]error
unlinked []string
}
type fakeGitHubChangeLinks struct {
prs []*github.TaskPR
linkedURL string
linkedWorkspaceID string
linkedUserID string
unlinked []string
}
func (f *fakeGitHubChangeLinks) AssociateExistingPRByURLForWorkspace(_ context.Context, workspaceID, userID, taskID, repositoryID, prURL string) (*github.TaskPR, error) {
f.linkedURL = prURL
f.linkedWorkspaceID = workspaceID
f.linkedUserID = userID
pr := &github.TaskPR{ID: "linked-github", TaskID: taskID, RepositoryID: repositoryID, PRNumber: 42, PRURL: prURL}
f.prs = append(f.prs, pr)
return pr, nil
}
func (f *fakeGitHubChangeLinks) DetachTaskPR(_ context.Context, _ string, associationID string) (*github.TaskPR, error) {
f.unlinked = append(f.unlinked, associationID)
return nil, nil
}
func (f *fakeGitHubChangeLinks) ListTaskPRs(_ context.Context, taskIDs []string) (map[string][]*github.TaskPR, error) {
out := make(map[string][]*github.TaskPR)
for _, taskID := range taskIDs {
for _, pr := range f.prs {
if pr.TaskID == taskID {
out[taskID] = append(out[taskID], pr)
}
}
}
return out, nil
}
func (f *fakeGitLabChangeLinks) AssociateExistingMRByURL(
_ context.Context, _ string, taskID string, repositoryID string, mrURL string,
) (*gitlab.TaskMR, error) {
f.linkedURL = mrURL
for _, mr := range f.mrs {
if mr.TaskID == taskID && mr.RepositoryID == repositoryID && mr.MRURL == mrURL {
return mr, nil
}
}
mr := &gitlab.TaskMR{
ID: "linked-new", TaskID: taskID, RepositoryID: repositoryID,
Host: "https://gitlab.example.test", ProjectPath: "group/project", MRIID: 42,
MRURL: mrURL, State: "open",
}
f.mrs = append(f.mrs, mr)
return mr, nil
}
func (f *fakeGitLabChangeLinks) ListTaskMRsByTask(_ context.Context, taskID string) ([]*gitlab.TaskMR, error) {
out := make([]*gitlab.TaskMR, 0, len(f.mrs))
for _, mr := range f.mrs {
if mr.TaskID == taskID {
out = append(out, mr)
}
}
return out, nil
}
func (f *fakeGitLabChangeLinks) UnlinkTaskMR(_ context.Context, _ string, associationID string) error {
f.unlinked = append(f.unlinked, associationID)
if err := f.unlinkErr[associationID]; err != nil {
return err
}
filtered := f.mrs[:0]
for _, mr := range f.mrs {
if mr.ID != associationID {
filtered = append(filtered, mr)
}
}
f.mrs = filtered
return nil
}
func newTaskChangeCoordinatorHarness(t *testing.T) (*taskservice.Service, *sqliterepo.Repository) {
t.Helper()
dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "task-change-links.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
database := sqlx.NewDb(dbConn, "sqlite3")
t.Cleanup(func() { _ = database.Close() })
repos, cleanup, err := repository.Provide(database, database, nil)
if err != nil {
t.Fatalf("task repository: %v", err)
}
t.Cleanup(func() { _ = cleanup() })
if _, err := worktree.NewSQLiteStore(database, database); err != nil {
t.Fatalf("worktree store: %v", err)
}
log := newTestLogger()
taskSvc := taskservice.NewService(taskservice.Repos{
Workspaces: repos,
Tasks: repos,
TaskRepos: repos,
Workflows: repos,
Messages: repos,
Turns: repos,
Sessions: repos,
GitSnapshots: repos,
RepoEntities: repos,
Executors: repos,
Environments: repos,
Reviews: repos,
}, bus.NewMemoryEventBus(log), log, taskservice.RepositoryDiscoveryConfig{})
return taskSvc, repos
}
func seedTaskChangeCoordinatorTask(
t *testing.T, repos *sqliterepo.Repository, workspaceID, taskID, repositoryID, host, owner, name string,
) {
t.Helper()
ctx := context.Background()
now := time.Now().UTC()
if err := repos.CreateWorkspace(ctx, &models.Workspace{ID: workspaceID, Name: workspaceID, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := repos.CreateRepository(ctx, &models.Repository{
ID: repositoryID, WorkspaceID: workspaceID, Name: name, ProviderHost: host,
ProviderOwner: owner, ProviderName: name, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("create repository: %v", err)
}
if err := repos.CreateTask(ctx, &models.Task{ID: taskID, WorkspaceID: workspaceID, Title: taskID, State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create task: %v", err)
}
if err := repos.CreateTaskRepository(ctx, &models.TaskRepository{
ID: "tr-" + taskID + "-" + repositoryID, TaskID: taskID, RepositoryID: repositoryID,
CreatedAt: now, UpdatedAt: now, Metadata: map[string]interface{}{},
}); err != nil {
t.Fatalf("create task repository: %v", err)
}
}
func TestTaskChangeURLsRequireCanonicalRepositoryIdentity(t *testing.T) {
githubURL, err := githubChangeURL("https://github.com", "acme", "api", 7)
if err != nil || githubURL != "https://github.com/acme/api/pull/7" {
t.Fatalf("githubChangeURL() = %q, %v", githubURL, err)
}
gitlabURL, err := gitlabChangeURL("https://gitlab.example.test/", "group", "api", 9)
if err != nil || gitlabURL != "https://gitlab.example.test/group/api/-/merge_requests/9" {
t.Fatalf("gitlabChangeURL() = %q, %v", gitlabURL, err)
}
if _, err := githubChangeURL("https://gitlab.example.test", "acme", "api", 7); err == nil {
t.Fatal("githubChangeURL accepted a non-GitHub host")
}
if _, err := gitlabChangeURL("", "group", "api", 9); err == nil {
t.Fatal("gitlabChangeURL accepted an incomplete repository identity")
}
}
func TestGitHubChangeURLRejectsSubstringHostSpoofing(t *testing.T) {
for _, host := range []string{
"https://github.com.evil.test",
"https://mygithub.com",
"github.com.evil.test",
} {
if _, err := githubChangeURL(host, "acme", "api", 7); err == nil {
t.Fatalf("githubChangeURL accepted spoofed host %q", host)
}
}
if _, err := githubChangeURL("", "acme", "api", 7); err == nil {
t.Fatal("githubChangeURL accepted an unknown empty host")
}
url, err := githubChangeURL("https://enterprise.github.com", "acme", "api", 7)
if err != nil || url != "https://enterprise.github.com/acme/api/pull/7" {
t.Fatalf("githubChangeURL alternate origin = %q, %v", url, err)
}
}
func TestTaskChangeCoordinatorDispatchesGitHubLinkWithWorkspaceIdentity(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
seedTaskChangeCoordinatorTask(t, repos, "ws-1", "task-1", "repo-1", "https://github.com", "acme", "api")
githubLinks := &fakeGitHubChangeLinks{}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, github: githubLinks}
ctx := authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-1"})
links, err := coordinator.LinkTaskChange(ctx, mcphandlers.TaskChangeLinkRequest{
TaskID: "task-1",
Link: mcphandlers.TaskChangeLink{Provider: "github", RepositoryID: "repo-1", Number: 42},
})
if err != nil {
t.Fatalf("LinkTaskChange: %v", err)
}
if githubLinks.linkedWorkspaceID != "ws-1" || githubLinks.linkedUserID != "user-1" {
t.Fatalf("GitHub identity = workspace %q user %q", githubLinks.linkedWorkspaceID, githubLinks.linkedUserID)
}
if githubLinks.linkedURL != "https://github.com/acme/api/pull/42" {
t.Fatalf("linked URL = %q", githubLinks.linkedURL)
}
if len(links) != 1 || links[0].Provider != "github" {
t.Fatalf("links = %#v, want one GitHub link", links)
}
}
func TestTaskChangeCoordinatorDispatchesGitLabLinkAndReturnsLinkedSet(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
seedTaskChangeCoordinatorTask(t, repos, "ws-1", "task-1", "repo-1", "https://gitlab.example.test", "group", "project")
gitlabLinks := &fakeGitLabChangeLinks{}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, gitlab: gitlabLinks}
links, err := coordinator.LinkTaskChange(context.Background(), mcphandlers.TaskChangeLinkRequest{
TaskID: "task-1",
Link: mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 42},
})
if err != nil {
t.Fatalf("LinkTaskChange: %v", err)
}
if gitlabLinks.linkedURL != "https://gitlab.example.test/group/project/-/merge_requests/42" {
t.Fatalf("linked URL = %q", gitlabLinks.linkedURL)
}
if len(links) != 1 || links[0].Provider != "gitlab" || links[0].RepositoryID != "repo-1" || links[0].Number != 42 {
t.Fatalf("links = %#v, want gitlab repo-1 !42", links)
}
}
func TestTaskChangeCoordinatorUnlinkAbsentIsIdempotent(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
seedTaskChangeCoordinatorTask(t, repos, "ws-1", "task-1", "repo-1", "https://gitlab.example.test", "group", "project")
gitlabLinks := &fakeGitLabChangeLinks{}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, gitlab: gitlabLinks}
links, err := coordinator.UnlinkTaskChange(context.Background(), mcphandlers.TaskChangeLinkRequest{
TaskID: "task-1",
Link: mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 42},
})
if err != nil {
t.Fatalf("UnlinkTaskChange absent: %v", err)
}
if len(links) != 0 {
t.Fatalf("links after absent unlink = %#v, want none", links)
}
}
func TestTaskChangeCoordinatorReplaceSameIdentityIsANoOp(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
seedTaskChangeCoordinatorTask(t, repos, "ws-1", "task-1", "repo-1", "https://gitlab.example.test", "group", "project")
links := &fakeGitLabChangeLinks{mrs: []*gitlab.TaskMR{{ID: "current", TaskID: "task-1", RepositoryID: "repo-1", MRIID: 42}}}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, gitlab: links}
identity := mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 42}
got, err := coordinator.ReplaceTaskChange(context.Background(), mcphandlers.TaskChangeLinkRequest{TaskID: "task-1", Link: identity, Old: &identity})
if err != nil || len(got) != 1 || got[0] != identity {
t.Fatalf("ReplaceTaskChange() = %#v, %v", got, err)
}
if len(links.unlinked) != 0 || links.linkedURL != "" {
t.Fatalf("same-identity replace mutated provider: unlinked=%#v linked=%q", links.unlinked, links.linkedURL)
}
}
func TestTaskChangeCoordinatorRejectsCrossProviderReplaceWithoutMutation(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
seedTaskChangeCoordinatorTask(t, repos, "ws-1", "task-1", "repo-1", "https://gitlab.example.test", "group", "project")
now := time.Now().UTC()
if err := repos.CreateRepository(context.Background(), &models.Repository{
ID: "repo-gh", WorkspaceID: "ws-1", Name: "api", ProviderHost: "https://github.com", ProviderOwner: "acme", ProviderName: "api", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("create GitHub repository: %v", err)
}
if err := repos.CreateTaskRepository(context.Background(), &models.TaskRepository{
ID: "tr-task-1-repo-gh", TaskID: "task-1", RepositoryID: "repo-gh", CreatedAt: now, UpdatedAt: now, Metadata: map[string]interface{}{},
}); err != nil {
t.Fatalf("attach GitHub repository: %v", err)
}
gitlabLinks := &fakeGitLabChangeLinks{mrs: []*gitlab.TaskMR{{
ID: "old", TaskID: "task-1", RepositoryID: "repo-1", MRIID: 7,
MRURL: "https://gitlab.example.test/group/project/-/merge_requests/7",
}}}
githubLinks := &fakeGitHubChangeLinks{}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, github: githubLinks, gitlab: gitlabLinks}
_, err := coordinator.ReplaceTaskChange(context.Background(), mcphandlers.TaskChangeLinkRequest{
TaskID: "task-1",
Link: mcphandlers.TaskChangeLink{Provider: "github", RepositoryID: "repo-gh", Number: 42},
Old: &mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 7},
})
if err == nil {
t.Fatal("ReplaceTaskChange accepted a cross-provider replacement")
}
if len(gitlabLinks.mrs) != 1 || gitlabLinks.mrs[0].ID != "old" || githubLinks.linkedURL != "" || len(gitlabLinks.unlinked) != 0 {
t.Fatalf("cross-provider replace mutated links: mrs=%#v github=%q unlinked=%#v", gitlabLinks.mrs, githubLinks.linkedURL, gitlabLinks.unlinked)
}
}
func TestTaskChangeCoordinatorReplaceRollsBackNewLinkWhenOldUnlinkFails(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
seedTaskChangeCoordinatorTask(t, repos, "ws-1", "task-1", "repo-1", "https://gitlab.example.test", "group", "project")
gitlabLinks := &fakeGitLabChangeLinks{
mrs: []*gitlab.TaskMR{{
ID: "old", TaskID: "task-1", RepositoryID: "repo-1", MRIID: 7,
MRURL: "https://gitlab.example.test/group/project/-/merge_requests/7",
}},
unlinkErr: map[string]error{"old": errors.New("delete failed")},
}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, gitlab: gitlabLinks}
_, err := coordinator.ReplaceTaskChange(context.Background(), mcphandlers.TaskChangeLinkRequest{
TaskID: "task-1",
Link: mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 42},
Old: &mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 7},
})
if err == nil {
t.Fatal("ReplaceTaskChange succeeded despite old unlink failure")
}
if len(gitlabLinks.mrs) != 1 || gitlabLinks.mrs[0].ID != "old" {
t.Fatalf("MRs after failed replace = %#v, want only old association", gitlabLinks.mrs)
}
if len(gitlabLinks.unlinked) != 2 || gitlabLinks.unlinked[0] != "old" || gitlabLinks.unlinked[1] != "linked-new" {
t.Fatalf("unlink order = %#v, want old then rollback of new", gitlabLinks.unlinked)
}
}
func TestTaskChangeCoordinatorReportsRollbackFailure(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
seedTaskChangeCoordinatorTask(t, repos, "ws-1", "task-1", "repo-1", "https://gitlab.example.test", "group", "project")
gitlabLinks := &fakeGitLabChangeLinks{
mrs: []*gitlab.TaskMR{{
ID: "old", TaskID: "task-1", RepositoryID: "repo-1", MRIID: 7,
MRURL: "https://gitlab.example.test/group/project/-/merge_requests/7",
}},
unlinkErr: map[string]error{
"old": errors.New("delete failed"),
"linked-new": errors.New("rollback failed"),
},
}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, gitlab: gitlabLinks}
_, err := coordinator.ReplaceTaskChange(context.Background(), mcphandlers.TaskChangeLinkRequest{
TaskID: "task-1",
Link: mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 42},
Old: &mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 7},
})
if err == nil || !strings.Contains(err.Error(), "delete failed") || !strings.Contains(err.Error(), "rollback failed") ||
!strings.Contains(err.Error(), "active task change links") || !strings.Contains(err.Error(), "42") {
t.Fatalf("ReplaceTaskChange error = %v, want both failures and active links", err)
}
}
func TestTaskChangeCoordinatorUnlinksStaleAssociationWithoutCurrentRepository(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
seedTaskChangeCoordinatorTask(t, repos, "ws-1", "task-1", "repo-1", "https://gitlab.example.test", "group", "project")
requireErr := repos.DeleteTaskRepository(context.Background(), "tr-task-1-repo-1")
if requireErr != nil {
t.Fatalf("remove current task repository: %v", requireErr)
}
gitlabLinks := &fakeGitLabChangeLinks{mrs: []*gitlab.TaskMR{{
ID: "stale", TaskID: "task-1", RepositoryID: "repo-1", MRIID: 42,
}}}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, gitlab: gitlabLinks}
links, err := coordinator.UnlinkTaskChange(context.Background(), mcphandlers.TaskChangeLinkRequest{
TaskID: "task-1",
Link: mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-1", Number: 42},
})
if err != nil {
t.Fatalf("UnlinkTaskChange stale association: %v", err)
}
if len(links) != 0 || len(gitlabLinks.unlinked) != 1 || gitlabLinks.unlinked[0] != "stale" {
t.Fatalf("stale unlink result links=%#v unlinked=%#v", links, gitlabLinks.unlinked)
}
}
func TestTaskChangeCoordinatorRejectsTaskRepositoryFromAnotherWorkspace(t *testing.T) {
taskSvc, repos := newTaskChangeCoordinatorHarness(t)
ctx := context.Background()
now := time.Now().UTC()
if err := repos.CreateWorkspace(ctx, &models.Workspace{ID: "ws-task", Name: "Task", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create task workspace: %v", err)
}
if err := repos.CreateWorkspace(ctx, &models.Workspace{ID: "ws-repo", Name: "Repo", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create repo workspace: %v", err)
}
if err := repos.CreateRepository(ctx, &models.Repository{
ID: "repo-cross", WorkspaceID: "ws-repo", Name: "project",
ProviderHost: "https://gitlab.example.test", ProviderOwner: "group", ProviderName: "project",
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("create repository: %v", err)
}
if err := repos.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-task", Title: "Task", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create task: %v", err)
}
if err := repos.CreateTaskRepository(ctx, &models.TaskRepository{
ID: "tr-cross", TaskID: "task-1", RepositoryID: "repo-cross", CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("create task repository: %v", err)
}
coordinator := taskChangeLinkCoordinator{tasks: taskSvc, gitlab: &fakeGitLabChangeLinks{}}
_, err := coordinator.LinkTaskChange(ctx, mcphandlers.TaskChangeLinkRequest{
TaskID: "task-1",
Link: mcphandlers.TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-cross", Number: 1},
})
if err == nil {
t.Fatal("LinkTaskChange accepted a repository from another workspace")
}
}
+1
View File
@@ -354,6 +354,7 @@ const (
GitLabNewReviewMR = "gitlab.new_mr_to_review" // New MR found needing review
GitLabNewIssue = "gitlab.new_issue" // New issue found matching issue watch
GitLabTaskMRUpdated = "gitlab.task_mr.updated" // TaskMR record updated (for UI refresh)
GitLabTaskMRDeleted = "gitlab.task_mr.deleted" // TaskMR association detached (for UI refresh)
GitLabWatchEvent = "gitlab.watch.event" // Watch created/deleted
// GitLabTaskMROptionsUpdated fires after a task's MR lifecycle
@@ -102,6 +102,7 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub *
b.subscribe(eventBus, events.GitHubRateLimitUpdated, ws.ActionGitHubRateLimitUpdated)
b.subscribe(eventBus, events.GitHubPRDiscoveryHealthUpdated, ws.ActionGitHubPRDiscoveryHealthUpdated)
b.subscribe(eventBus, events.GitLabTaskMRUpdated, ws.ActionGitLabTaskMRUpdated)
b.subscribe(eventBus, events.GitLabTaskMRDeleted, ws.ActionGitLabTaskMRDeleted)
b.subscribe(eventBus, events.GitLabTaskMROptionsUpdated, ws.ActionGitLabTaskMRAutomationUpdated)
go func() {
@@ -314,7 +315,7 @@ func (b *TaskEventBroadcaster) routeBroadcast(
return nil
case ws.ActionGitHubTaskPRUpdated, ws.ActionGitHubTaskPRDeleted,
ws.ActionGitHubTaskCIOptionsUpdated, ws.ActionGitHubPRDiscoveryHealthUpdated,
ws.ActionGitLabTaskMRUpdated, ws.ActionGitLabTaskMRAutomationUpdated:
ws.ActionGitLabTaskMRUpdated, ws.ActionGitLabTaskMRDeleted, ws.ActionGitLabTaskMRAutomationUpdated:
// These payloads carry per-task PR/MR automation and lifecycle state. Fail closed
// (drop, don't fall back to a global broadcast) when workspace
// resolution came back empty and auth is enforced — an unattributed
@@ -112,7 +112,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) {
//
// Update this number when adding or removing event subscriptions in
// RegisterTaskNotifications — it is intentionally exact.
const wantSubscriptions = 74
const wantSubscriptions = 75
if got := len(b.subscriptions); got != wantSubscriptions {
t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+
"did an event get subscribed twice?", got, wantSubscriptions)
@@ -130,6 +130,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) {
events.GitHubTaskPRUpdated,
events.GitHubTaskPRDeleted,
events.GitLabTaskMRUpdated,
events.GitLabTaskMRDeleted,
} {
subject := subject
t.Run(subject, func(t *testing.T) {
+34 -3
View File
@@ -2260,7 +2260,25 @@ func (s *Store) ListTaskPRsByTaskIncludingDetached(ctx context.Context, taskID s
// The bool return reports whether this call performed the transition.
func (s *Store) DetachTaskPR(ctx context.Context, associationID string) (*TaskPR, bool, error) {
now := time.Now().UTC()
result, err := s.db.ExecContext(ctx, s.db.Rebind(
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, false, err
}
defer func() { _ = tx.Rollback() }()
var identity struct {
TaskID string `db:"task_id"`
RepositoryID string `db:"repository_id"`
PRNumber int `db:"pr_number"`
}
if err := tx.GetContext(ctx, &identity, tx.Rebind(`SELECT task_id, repository_id, pr_number FROM github_task_prs WHERE id = ? AND detached_at IS NULL`), associationID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
tp, getErr := s.GetTaskPRByID(ctx, associationID)
return tp, false, getErr
}
return nil, false, err
}
result, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE github_task_prs SET detached_at = ?, updated_at = ?
WHERE id = ? AND detached_at IS NULL`), now, now, associationID)
if err != nil {
@@ -2270,8 +2288,21 @@ func (s *Store) DetachTaskPR(ctx context.Context, associationID string) (*TaskPR
if err != nil {
return nil, false, err
}
tp, err := s.GetTaskPRByID(ctx, associationID)
return tp, count > 0, err
if count > 0 {
for _, table := range []string{"github_task_pr_automation_options", "github_task_ci_pr_state"} {
if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM `+table+` WHERE task_id = ? AND repository_id = ? AND pr_number = ?`), identity.TaskID, identity.RepositoryID, identity.PRNumber); err != nil {
return nil, false, err
}
}
}
var tp TaskPR
if err := tx.GetContext(ctx, &tp, tx.Rebind(`SELECT `+taskPRColumns+` FROM github_task_prs WHERE id = ? LIMIT 1`), associationID); err != nil {
return nil, false, err
}
if err := tx.Commit(); err != nil {
return nil, false, err
}
return &tp, count > 0, nil
}
// RestoreTaskPR clears a detached tombstone for an explicit link action and
@@ -80,6 +80,50 @@ func TestTaskPRDetachFiltersActiveRowsAndPersistsTombstone(t *testing.T) {
}
}
func TestTaskPRDetachRetiresOnlyExactAutomationAndCIState(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC()
if _, err := store.db.Exec(`INSERT INTO tasks (id, workspace_id) VALUES ('task-1', 'ws-1')`); err != nil {
t.Fatalf("seed task: %v", err)
}
var firstID string
for _, number := range []int{1, 2} {
pr := &TaskPR{
WorkspaceID: "ws-1", TaskID: "task-1", RepositoryID: "repo-1", Owner: "acme", Repo: "demo",
PRNumber: number, PRURL: "https://github.com/acme/demo/pull/1", PRTitle: "PR", HeadBranch: "feature", BaseBranch: "main", State: "open", CreatedAt: now,
}
if err := store.CreateTaskPR(ctx, pr); err != nil {
t.Fatalf("create PR %d: %v", number, err)
}
if number == 1 {
firstID = pr.ID
}
if _, err := store.db.ExecContext(ctx, `INSERT INTO github_task_pr_automation_options (task_id, repository_id, pr_number, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, "task-1", "repo-1", number, now, now); err != nil {
t.Fatalf("seed automation options for PR %d: %v", number, err)
}
if _, err := store.db.ExecContext(ctx, `INSERT INTO github_task_ci_pr_state (task_id, repository_id, pr_number, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, "task-1", "repo-1", number, now, now); err != nil {
t.Fatalf("seed CI state for PR %d: %v", number, err)
}
}
if _, changed, err := store.DetachTaskPR(ctx, firstID); err != nil || !changed {
t.Fatalf("detach first PR: changed=%v err=%v", changed, err)
}
for _, table := range []string{"github_task_pr_automation_options", "github_task_ci_pr_state"} {
var removed, retained int
if err := store.db.GetContext(ctx, &removed, `SELECT COUNT(*) FROM `+table+` WHERE task_id = ? AND repository_id = ? AND pr_number = ?`, "task-1", "repo-1", 1); err != nil {
t.Fatalf("count removed %s rows: %v", table, err)
}
if err := store.db.GetContext(ctx, &retained, `SELECT COUNT(*) FROM `+table+` WHERE task_id = ? AND repository_id = ? AND pr_number = ?`, "task-1", "repo-1", 2); err != nil {
t.Fatalf("count retained %s rows: %v", table, err)
}
if removed != 0 || retained != 1 {
t.Errorf("%s rows after detach: removed=%d retained=%d, want 0 and 1", table, removed, retained)
}
}
}
func TestLegacyTaskPRRebuildPreservesDetachedAt(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
+11 -2
View File
@@ -27,7 +27,11 @@ type rawMR struct {
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
SHA string `json:"sha"`
References struct {
DiffRefs struct {
BaseSHA string `json:"base_sha"`
HeadSHA string `json:"head_sha"`
} `json:"diff_refs"`
References struct {
Full string `json:"full"`
} `json:"references"`
Author rawUser `json:"author"`
@@ -149,6 +153,10 @@ func convertRawMR(raw *rawMR) *MR {
if targetProjectID == 0 {
targetProjectID = raw.ProjectID
}
headSHA := raw.SHA
if raw.DiffRefs.HeadSHA != "" {
headSHA = raw.DiffRefs.HeadSHA
}
mr := &MR{
ID: raw.ID,
IID: raw.IID,
@@ -158,8 +166,9 @@ func convertRawMR(raw *rawMR) *MR {
WebURL: raw.WebURL,
State: state,
HeadBranch: raw.SourceBranch,
HeadSHA: raw.SHA,
HeadSHA: headSHA,
BaseBranch: raw.TargetBranch,
BaseSHA: raw.DiffRefs.BaseSHA,
AuthorUsername: raw.Author.Username,
ProjectNamespace: namespace,
ProjectPath: projectPath,
@@ -28,6 +28,19 @@ func TestConvertRawMRHydratesLabels(t *testing.T) {
}
}
func TestConvertRawMRHydratesCommitSHAs(t *testing.T) {
raw := &rawMR{SHA: "legacy-head-sha"}
raw.DiffRefs.BaseSHA = "base-sha"
raw.DiffRefs.HeadSHA = "head-sha"
mr := convertRawMR(raw)
if got, want := mr.HeadSHA, "head-sha"; got != want {
t.Fatalf("HeadSHA = %q, want %q", got, want)
}
if got, want := mr.BaseSHA, "base-sha"; got != want {
t.Fatalf("BaseSHA = %q, want %q", got, want)
}
}
func TestConvertRawMR_DetailedMergeStatus(t *testing.T) {
// Present (GitLab 15.6+).
mr := convertRawMR(&rawMR{DetailedMergeStatus: "mergeable", BlockingDiscussionsResolved: true})
+3
View File
@@ -53,6 +53,7 @@ type MR struct {
HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"`
BaseBranch string `json:"base_branch"`
BaseSHA string `json:"base_sha"`
AuthorUsername string `json:"author_username"`
ProjectNamespace string `json:"project_namespace"`
ProjectPath string `json:"project_path"`
@@ -380,7 +381,9 @@ type TaskMR struct {
MRURL string `json:"mr_url" db:"mr_url"`
MRTitle string `json:"mr_title" db:"mr_title"`
HeadBranch string `json:"head_branch" db:"head_branch"`
HeadSHA string `json:"head_sha" db:"head_sha"`
BaseBranch string `json:"base_branch" db:"base_branch"`
BaseSHA string `json:"base_sha" db:"base_sha"`
AuthorUsername string `json:"author_username" db:"author_username"`
State string `json:"state" db:"state"` // open, closed, merged, locked
ApprovalState string `json:"approval_state" db:"approval_state"` // approved, pending, ""
@@ -62,6 +62,7 @@ func TestWebSocketCatalogContainsOnlyAuthorizedGitLabActions(t *testing.T) {
authorized := map[string]bool{
"ActionGitLabCheckSessionMR": true,
"ActionGitLabTaskMRUpdated": true,
"ActionGitLabTaskMRDeleted": true,
"ActionGitLabTaskMRAutomationUpdated": true,
}
_, sourceFile, _, ok := runtime.Caller(0)
+13
View File
@@ -696,7 +696,9 @@ func (s *Service) syncTaskMRWithClient(
MRURL: mr.WebURL,
MRTitle: mr.Title,
HeadBranch: mr.HeadBranch,
HeadSHA: mr.HeadSHA,
BaseBranch: mr.BaseBranch,
BaseSHA: mr.BaseSHA,
AuthorUsername: mr.AuthorUsername,
State: mr.State,
ApprovalState: status.ApprovalState,
@@ -748,3 +750,14 @@ func (s *Service) ListTaskMRsByTask(ctx context.Context, taskID string) ([]*Task
}
return store.ListTaskMRsByTask(ctx, taskID)
}
// ListTaskMRsByTaskIDs surfaces GitLab MR associations grouped by task ID.
func (s *Service) ListTaskMRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*TaskMR, error) {
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
return map[string][]*TaskMR{}, nil
}
return store.ListTaskMRsByTaskIDs(ctx, taskIDs)
}
@@ -315,6 +315,14 @@ func (s *Service) UnlinkTaskMR(ctx context.Context, workspaceID, associationID s
if err := store.DeleteTaskMRForWorkspace(ctx, workspaceID, associationID); err != nil {
return err
}
if s.eventBus != nil && association != nil {
event := bus.NewEvent(events.GitLabTaskMRDeleted, eventSource, &TaskMRDeletedEvent{
WorkspaceID: workspaceID, TaskID: association.TaskID, AssociationID: association.ID,
})
if err := s.eventBus.Publish(ctx, events.GitLabTaskMRDeleted, event); err != nil {
s.logger.Debug("failed to publish GitLab task MR deletion event", zap.Error(err))
}
}
return nil
}
@@ -324,7 +332,7 @@ func taskMRFromStatus(taskID, repositoryID, host, projectPath string, status *MR
return &TaskMR{
TaskID: taskID, RepositoryID: repositoryID, Host: host,
ProjectPath: projectPath, MRIID: mr.IID, MRURL: mr.WebURL, MRTitle: mr.Title,
HeadBranch: mr.HeadBranch, BaseBranch: mr.BaseBranch, AuthorUsername: mr.AuthorUsername,
HeadBranch: mr.HeadBranch, HeadSHA: mr.HeadSHA, BaseBranch: mr.BaseBranch, BaseSHA: mr.BaseSHA, AuthorUsername: mr.AuthorUsername,
State: mr.State, ApprovalState: status.ApprovalState, PipelineState: status.PipelineState,
MergeStatus: status.MergeStatus, Draft: mr.Draft, ApprovalCount: status.ApprovalCount,
RequiredApprovals: status.RequiredApprovals, PipelineJobsTotal: status.PipelineJobsTotal,
@@ -363,6 +371,18 @@ func (e *TaskMRUpdatedEvent) GetWorkspaceID() string {
return e.WorkspaceID
}
// TaskMRDeletedEvent identifies one task-MR association removed from active
// task surfaces. The upstream merge request remains unchanged.
type TaskMRDeletedEvent struct {
WorkspaceID string `json:"workspace_id"`
TaskID string `json:"task_id"`
AssociationID string `json:"association_id"`
}
// GetWorkspaceID lets the websocket broadcaster route the deletion to the
// owning workspace.
func (e TaskMRDeletedEvent) GetWorkspaceID() string { return e.WorkspaceID }
// publishTaskMRLifecycleSyncEvent publishes a TaskMRUpdatedEvent after the
// poller's lifecycle sync pass refreshes a linked MR (AC22). Unlike
// publishTaskMRUpdated (an HTTP/link-flow caller that already has a trusted
@@ -8,6 +8,9 @@ import (
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
func seedTaskMRLinkFixture(t *testing.T, store *Store, workspaceID, taskID, repositoryID string) {
@@ -214,6 +217,45 @@ func TestAssociateExistingMRByURLCreatesIdempotentWorkspaceScopedLink(t *testing
}
}
func TestUnlinkTaskMRPublishesWorkspaceScopedDeletedEvent(t *testing.T) {
const host = "https://gitlab.acme.test"
svc, store, _ := newTaskMRLinkService(t, host)
seedTaskMRLinkFixture(t, store, "ws-1", "task-1", "repo-1")
association := &TaskMR{
ID: "mr-1", TaskID: "task-1", RepositoryID: "repo-1", Host: host,
ProjectPath: "group/project", MRIID: 17,
MRURL: host + "/group/project/-/merge_requests/17",
}
if err := store.UpsertTaskMR(context.Background(), association); err != nil {
t.Fatalf("seed task MR: %v", err)
}
eventBus := bus.NewMemoryEventBus(newTestLogger(t))
svc.SetEventBus(eventBus)
received := make(chan *bus.Event, 1)
if _, err := eventBus.Subscribe(events.GitLabTaskMRDeleted, func(_ context.Context, event *bus.Event) error {
received <- event
return nil
}); err != nil {
t.Fatalf("subscribe: %v", err)
}
if err := svc.UnlinkTaskMR(context.Background(), "ws-1", association.ID); err != nil {
t.Fatalf("UnlinkTaskMR: %v", err)
}
select {
case event := <-received:
payload, ok := event.Data.(*TaskMRDeletedEvent)
if !ok {
t.Fatalf("deleted event payload = %T, want *TaskMRDeletedEvent", event.Data)
}
if payload.WorkspaceID != "ws-1" || payload.TaskID != "task-1" || payload.AssociationID != association.ID {
t.Fatalf("deleted event payload = %+v", payload)
}
default:
t.Fatal("expected gitlab.task_mr.deleted event")
}
}
// TestAssociateExistingMRByURLForSessionEnsuresWatch pins the gap the
// gateway wiring used to have: the Create-MR action and manual URL linking
// triggered from a session both have a concrete sessionID available, and
+3 -1
View File
@@ -407,6 +407,7 @@ func TestService_SyncTaskMR_UpsertsFromMockClient(t *testing.T) {
State: mrStateOpen,
WebURL: "https://gitlab.example/acme/api/-/merge_requests/42",
HeadSHA: "abc123",
BaseSHA: "base456",
CreatedAt: time.Now().UTC(),
})
@@ -414,7 +415,8 @@ func TestService_SyncTaskMR_UpsertsFromMockClient(t *testing.T) {
if err != nil {
t.Fatalf("SyncTaskMR err = %v", err)
}
if row.MRTitle != "feat: thing" || row.HeadBranch != "feat/x" || row.BaseBranch != "main" {
if row.MRTitle != "feat: thing" || row.HeadBranch != "feat/x" || row.HeadSHA != "abc123" ||
row.BaseBranch != "main" || row.BaseSHA != "base456" {
t.Errorf("unexpected upserted row: %+v", row)
}
if row.LastSyncedAt == nil {
+52 -5
View File
@@ -80,7 +80,9 @@ const createTablesSQL = `
mr_url TEXT NOT NULL,
mr_title TEXT NOT NULL,
head_branch TEXT NOT NULL,
head_sha TEXT NOT NULL DEFAULT '',
base_branch TEXT NOT NULL,
base_sha TEXT NOT NULL DEFAULT '',
author_username TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'open',
approval_state TEXT NOT NULL DEFAULT '',
@@ -240,6 +242,9 @@ func (s *Store) createTables() error {
if err := s.migrateTaskMRAutomationFields(); err != nil {
return err
}
if err := s.migrateTaskMRSHAColumns(); err != nil {
return err
}
if err := s.migrateMRWatchUniqueKey(); err != nil {
return err
}
@@ -475,6 +480,23 @@ func (s *Store) migrateConfigRevision() error {
return nil
}
func (s *Store) migrateTaskMRSHAColumns() error {
columns, err := s.tableColumns("gitlab_task_mrs")
if err != nil {
return err
}
for _, column := range []string{"head_sha", "base_sha"} {
if _, ok := columns[column]; ok {
continue
}
statement := fmt.Sprintf("ALTER TABLE gitlab_task_mrs ADD COLUMN %s TEXT NOT NULL DEFAULT ''", column)
if _, err := s.db.Exec(gitlabSchemaSQLForDriver(statement, s.db.DriverName())); err != nil {
return fmt.Errorf("migrate gitlab_task_mrs.%s: %w", column, err)
}
}
return nil
}
func (s *Store) migrateWatchColumns() error {
columns := []struct {
name string
@@ -585,7 +607,7 @@ func (s *Store) GetMentionScope(ctx context.Context, workspaceID string) (*Menti
// still have dropped columns (older revisions defined `unresolved_threads`
// and `discussion_count` here) don't break sqlx struct binding.
const taskMRSelectCols = `id, task_id, repository_id, host, project_path, mr_iid,
mr_url, mr_title, head_branch, base_branch, author_username, state,
mr_url, mr_title, head_branch, head_sha, base_branch, base_sha, author_username, state,
approval_state, pipeline_state, merge_status, draft, approval_count,
required_approvals, pipeline_jobs_total, pipeline_jobs_pass,
detailed_merge_status, reviewer_count, unapproved_reviewers, unresolved_discussions,
@@ -596,7 +618,7 @@ const taskMRSelectCols = `id, task_id, repository_id, host, project_path, mr_iid
// updated_at).
const taskMRSelectColsQualified = `gtm.id, gtm.task_id, gtm.repository_id,
gtm.host, gtm.project_path, gtm.mr_iid, gtm.mr_url, gtm.mr_title,
gtm.head_branch, gtm.base_branch, gtm.author_username, gtm.state,
gtm.head_branch, gtm.head_sha, gtm.base_branch, gtm.base_sha, gtm.author_username, gtm.state,
gtm.approval_state, gtm.pipeline_state, gtm.merge_status, gtm.draft,
gtm.approval_count, gtm.required_approvals, gtm.pipeline_jobs_total,
gtm.pipeline_jobs_pass, gtm.detailed_merge_status, gtm.reviewer_count,
@@ -619,14 +641,14 @@ func (s *Store) UpsertTaskMR(ctx context.Context, tm *TaskMR) error {
rows, err := s.db.NamedQueryContext(ctx, `
INSERT INTO gitlab_task_mrs (
id, task_id, repository_id, host, project_path, mr_iid, mr_url, mr_title,
head_branch, base_branch, author_username, state, approval_state, pipeline_state,
head_branch, head_sha, base_branch, base_sha, author_username, state, approval_state, pipeline_state,
merge_status, draft, approval_count, required_approvals,
pipeline_jobs_total, pipeline_jobs_pass,
detailed_merge_status, reviewer_count, unapproved_reviewers, unresolved_discussions,
created_at, merged_at, closed_at, last_synced_at, updated_at
) VALUES (
:id, :task_id, :repository_id, :host, :project_path, :mr_iid, :mr_url, :mr_title,
:head_branch, :base_branch, :author_username, :state, :approval_state, :pipeline_state,
:head_branch, :head_sha, :base_branch, :base_sha, :author_username, :state, :approval_state, :pipeline_state,
:merge_status, CASE WHEN :draft THEN 1 ELSE 0 END, :approval_count, :required_approvals,
:pipeline_jobs_total, :pipeline_jobs_pass,
:detailed_merge_status, :reviewer_count, :unapproved_reviewers, :unresolved_discussions,
@@ -634,7 +656,7 @@ func (s *Store) UpsertTaskMR(ctx context.Context, tm *TaskMR) error {
)
ON CONFLICT(task_id, repository_id, project_path, mr_iid) DO UPDATE SET
host = excluded.host, mr_url = excluded.mr_url, mr_title = excluded.mr_title,
head_branch = excluded.head_branch, base_branch = excluded.base_branch,
head_branch = excluded.head_branch, head_sha = excluded.head_sha, base_branch = excluded.base_branch, base_sha = excluded.base_sha,
author_username = excluded.author_username, state = excluded.state,
approval_state = excluded.approval_state, pipeline_state = excluded.pipeline_state,
merge_status = excluded.merge_status, draft = excluded.draft,
@@ -728,6 +750,31 @@ func (s *Store) ListTaskMRsByTask(ctx context.Context, taskID string) ([]*TaskMR
return out, nil
}
// ListTaskMRsByTaskIDs returns every MR association for the supplied tasks,
// grouped by task_id and ordered by created_at within each task.
func (s *Store) ListTaskMRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*TaskMR, error) {
if len(taskIDs) == 0 {
return map[string][]*TaskMR{}, nil
}
query, args, err := sqlx.In(
`SELECT `+taskMRSelectCols+` FROM gitlab_task_mrs
WHERE task_id IN (?) ORDER BY created_at ASC`, taskIDs,
)
if err != nil {
return nil, err
}
query = s.ro.Rebind(query)
var mrs []TaskMR
if err := s.ro.SelectContext(ctx, &mrs, query, args...); err != nil {
return nil, err
}
out := make(map[string][]*TaskMR)
for i := range mrs {
out[mrs[i].TaskID] = append(out[mrs[i].TaskID], &mrs[i])
}
return out, nil
}
// ListTaskMRsByWorkspaceID returns every MR association for tasks inside the
// given workspace, grouped by task_id. Empty map when the workspace has no
// GitLab MRs.
@@ -0,0 +1,31 @@
package gitlab
import (
"context"
"testing"
)
func TestUpsertTaskMRRoundTripsCommitSHAs(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
mr := newTestMR("task-1", "repo-1", "group/project", 224)
mr.HeadSHA = "head-sha"
mr.BaseSHA = "base-sha"
if err := store.UpsertTaskMR(ctx, mr); err != nil {
t.Fatalf("upsert task MR: %v", err)
}
got, err := store.GetTaskMR(ctx, "task-1", "repo-1", "group/project", 224)
if err != nil {
t.Fatalf("get task MR: %v", err)
}
if got == nil {
t.Fatal("task MR is missing")
}
if got.HeadSHA != "head-sha" {
t.Errorf("HeadSHA = %q, want head-sha", got.HeadSHA)
}
if got.BaseSHA != "base-sha" {
t.Errorf("BaseSHA = %q, want base-sha", got.BaseSHA)
}
}
@@ -274,6 +274,34 @@ func TestStore_ListTaskMRsByTask_ReturnsEmptyForUnknownTask(t *testing.T) {
}
}
func TestStore_ListTaskMRsByTaskIDs_GroupsByTaskAndPreservesRepositoryIdentity(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
seedWorkspace(t, store, "ws-1")
seedTask(t, store, "task-1", "ws-1")
seedTask(t, store, "task-2", "ws-1")
if err := store.UpsertTaskMR(ctx, newTestMR("task-1", "repo-canonical", "group/project", 224)); err != nil {
t.Fatalf("upsert task-1 MR: %v", err)
}
if err := store.UpsertTaskMR(ctx, newTestMR("task-2", "repo-fork", "fork/project", 224)); err != nil {
t.Fatalf("upsert task-2 MR: %v", err)
}
got, err := store.ListTaskMRsByTaskIDs(ctx, []string{"task-1", "task-2", "missing"})
if err != nil {
t.Fatalf("ListTaskMRsByTaskIDs: %v", err)
}
if len(got["task-1"]) != 1 || got["task-1"][0].RepositoryID != "repo-canonical" {
t.Fatalf("task-1 MRs = %#v, want canonical repo row", got["task-1"])
}
if len(got["task-2"]) != 1 || got["task-2"][0].RepositoryID != "repo-fork" {
t.Fatalf("task-2 MRs = %#v, want fork repo row", got["task-2"])
}
if _, ok := got["missing"]; ok {
t.Fatal("missing task unexpectedly present in grouped results")
}
}
func TestStore_DeleteTaskMR_RemovesByID(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
@@ -293,6 +293,7 @@ type Handlers struct {
// Optional PR lister (set via SetTaskPRLister) used to enrich
// task-listing responses with associated pull requests.
taskPRLister TaskPRLister
taskMRLister TaskMRLister
// Native code review (optional, set via SetReviewService /
// SetReviewRunner). Without them the review actions are simply not
// registered — see registerReviewHandlers.
@@ -303,6 +304,7 @@ type Handlers struct {
// Optional task-bound GitHub PR automation controls.
taskPRAutomation TaskPRAutomationService
taskChangeLinks TaskChangeLinkService
taskPRAutoFixOutcome TaskPRAutoFixOutcomeService
remoteContributionSvc RemoteContributionService
diagnosticBundles DiagnosticBundleProvider
@@ -331,6 +333,11 @@ func (h *Handlers) releaseWorkspacePolicyAfterCreateRollback(ctx context.Context
}
}
// SetTaskChangeLinkService wires provider-neutral PR/MR association changes.
func (h *Handlers) SetTaskChangeLinkService(links TaskChangeLinkService) {
h.taskChangeLinks = links
}
// NewHandlers creates new MCP handlers.
func NewHandlers(
taskSvc *service.Service,
@@ -488,6 +495,13 @@ func (h *Handlers) registerTaskMutationHandlers(d *guardedMCPDispatcher) {
d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask)
d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask)
d.RegisterFunc(ws.ActionMCPSetTaskTitle, h.handleSetTaskTitle)
d.RegisterFunc(ws.ActionMCPGetTaskPRAutomation, h.handleGetTaskPRAutomation)
d.RegisterFunc(ws.ActionMCPUpdateTaskPRAutomation, h.handleUpdateTaskPRAutomation)
d.RegisterFunc(ws.ActionMCPGetTaskMRAutomation, h.handleGetTaskMRAutomation)
d.RegisterFunc(ws.ActionMCPUpdateTaskMRAutomation, h.handleUpdateTaskMRAutomation)
d.RegisterFunc(ws.ActionMCPLinkTaskPR, h.handleLinkTaskPR)
d.RegisterFunc(ws.ActionMCPUnlinkTaskPR, h.handleUnlinkTaskPR)
d.RegisterFunc(ws.ActionMCPReplaceTaskPR, h.handleReplaceTaskPR)
d.RegisterFunc(ws.ActionMCPAddTaskDependency, h.handleAddTaskDependency)
d.RegisterFunc(ws.ActionMCPRemoveTaskDependency, h.handleRemoveTaskDependency)
d.RegisterFunc(ws.ActionMCPAddBranchToTask, h.handleAddBranchToTask)
@@ -0,0 +1,142 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"strings"
mcpscope "github.com/kandev/kandev/internal/mcp/scope"
ws "github.com/kandev/kandev/pkg/websocket"
)
// TaskChangeLink identifies an active GitHub pull request or GitLab merge
// request. RepositoryID and Number deliberately travel together so same-number
// changes in forks and canonical repositories remain distinct.
type TaskChangeLink struct {
Provider string `json:"provider"`
RepositoryID string `json:"repository_id"`
Number int `json:"number"`
}
// TaskChangeLinkRequest is one provider-neutral change-request operation.
type TaskChangeLinkRequest struct {
TaskID string
Link TaskChangeLink
Old *TaskChangeLink
}
// TaskChangeLinkService owns provider dispatch and returns the resulting
// active links after each mutation.
type TaskChangeLinkService interface {
LinkTaskChange(context.Context, TaskChangeLinkRequest) ([]TaskChangeLink, error)
UnlinkTaskChange(context.Context, TaskChangeLinkRequest) ([]TaskChangeLink, error)
ReplaceTaskChange(context.Context, TaskChangeLinkRequest) ([]TaskChangeLink, error)
}
func (h *Handlers) handleLinkTaskPR(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
req, response, err := h.taskChangeLinkRequest(ctx, msg, false)
if response != nil || err != nil {
return response, err
}
if h.taskChangeLinks == nil {
return taskChangeLinksUnavailable(msg)
}
return h.applyTaskChangeLink(ctx, msg, req, h.taskChangeLinks.LinkTaskChange)
}
func (h *Handlers) handleUnlinkTaskPR(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
req, response, err := h.taskChangeLinkRequest(ctx, msg, false)
if response != nil || err != nil {
return response, err
}
if h.taskChangeLinks == nil {
return taskChangeLinksUnavailable(msg)
}
return h.applyTaskChangeLink(ctx, msg, req, h.taskChangeLinks.UnlinkTaskChange)
}
func (h *Handlers) handleReplaceTaskPR(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
req, response, err := h.taskChangeLinkRequest(ctx, msg, true)
if response != nil || err != nil {
return response, err
}
if h.taskChangeLinks == nil {
return taskChangeLinksUnavailable(msg)
}
return h.applyTaskChangeLink(ctx, msg, req, h.taskChangeLinks.ReplaceTaskChange)
}
func taskChangeLinksUnavailable(msg *ws.Message) (*ws.Message, error) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "task PR link management is not available", nil)
}
func (h *Handlers) applyTaskChangeLink(ctx context.Context, msg *ws.Message, req TaskChangeLinkRequest, apply func(context.Context, TaskChangeLinkRequest) ([]TaskChangeLink, error)) (*ws.Message, error) {
links, err := apply(ctx, req)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]any{"task_id": req.TaskID, "links": links})
}
func (h *Handlers) taskChangeLinkRequest(ctx context.Context, msg *ws.Message, replace bool) (TaskChangeLinkRequest, *ws.Message, error) {
var payload struct {
TaskID string `json:"task_id"`
CallerTaskID string `json:"caller_task_id"`
Provider string `json:"provider"`
RepositoryID string `json:"repository_id"`
Number int `json:"number"`
OldProvider string `json:"old_provider"`
OldRepositoryID string `json:"old_repository_id"`
OldNumber int `json:"old_number"`
}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
return TaskChangeLinkRequest{}, response, responseErr
}
link, err := validateTaskChangeLink(payload.Provider, payload.RepositoryID, payload.Number)
if err != nil || strings.TrimSpace(payload.TaskID) == "" {
response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id, provider, repository_id, and positive number are required", nil)
return TaskChangeLinkRequest{}, response, responseErr
}
if h.taskSvc == nil {
response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "task service is not available", nil)
return TaskChangeLinkRequest{}, response, responseErr
}
target, targetErr := h.taskSvc.GetTask(ctx, payload.TaskID)
if targetErr != nil || target == nil {
response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "task not found", nil)
return TaskChangeLinkRequest{}, response, responseErr
}
if strings.TrimSpace(payload.CallerTaskID) == "" {
response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "caller_task_id is required", nil)
return TaskChangeLinkRequest{}, response, responseErr
}
principal, hasPrincipal := mcpscope.PrincipalFromContext(ctx)
if !hasPrincipal || strings.TrimSpace(principal.WorkspaceID) == "" {
response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, "trusted MCP caller identity is required", nil)
return TaskChangeLinkRequest{}, response, responseErr
}
if payload.CallerTaskID != principal.CallerTaskID || target.WorkspaceID != principal.WorkspaceID {
response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, "task is outside the caller workspace", nil)
return TaskChangeLinkRequest{}, response, responseErr
}
req := TaskChangeLinkRequest{TaskID: target.ID, Link: link}
if replace {
old, oldErr := validateTaskChangeLink(payload.OldProvider, payload.OldRepositoryID, payload.OldNumber)
if oldErr != nil {
response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "old_provider, old_repository_id, and positive old_number are required", nil)
return TaskChangeLinkRequest{}, response, responseErr
}
req.Old = &old
}
return req, nil, nil
}
func validateTaskChangeLink(provider, repositoryID string, number int) (TaskChangeLink, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if (provider != "github" && provider != "gitlab") || strings.TrimSpace(repositoryID) == "" || number <= 0 {
return TaskChangeLink{}, errors.New("invalid task change identity")
}
return TaskChangeLink{Provider: provider, RepositoryID: repositoryID, Number: number}, nil
}
@@ -0,0 +1,168 @@
package handlers
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/mcp/scope"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateTaskChangeLinkRequiresProviderRepositoryAndNumber(t *testing.T) {
valid, err := validateTaskChangeLink("GitLab", "repo-1", 42)
if err != nil || valid.Provider != "gitlab" {
t.Fatalf("validateTaskChangeLink() = %#v, %v", valid, err)
}
for _, input := range []TaskChangeLink{
{Provider: "", RepositoryID: "repo-1", Number: 1},
{Provider: "github", RepositoryID: "", Number: 1},
{Provider: "github", RepositoryID: "repo-1", Number: 0},
{Provider: "other", RepositoryID: "repo-1", Number: 1},
} {
if _, err := validateTaskChangeLink(input.Provider, input.RepositoryID, input.Number); err == nil {
t.Fatalf("validateTaskChangeLink(%#v) succeeded", input)
}
}
}
func TestTaskChangeLinkRequestRejectsCrossWorkspaceCaller(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-target", Name: "Target", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-caller", Name: "Caller", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-target", WorkspaceID: "ws-target", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-caller", WorkspaceID: "ws-caller", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
h := &Handlers{taskSvc: svc}
ctx = scope.WithPrincipal(ctx, scope.Principal{
WorkspaceID: "ws-caller", CallerTaskID: "task-caller", CallerSessionID: "session-caller",
})
msg := makeWSMessage(t, ws.ActionMCPLinkTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-caller",
"provider": "github", "repository_id": "repo-1", "number": 1,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, false)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeForbidden)
}
func TestTaskChangeLinkRequestRejectsMissingCallerTask(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-target", Name: "Target", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-target", WorkspaceID: "ws-target", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
h := &Handlers{taskSvc: svc}
msg := makeWSMessage(t, ws.ActionMCPLinkTaskPR, map[string]any{
"task_id": "task-target",
"provider": "github", "repository_id": "repo-1", "number": 1,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, false)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
func TestTaskChangeLinkRequestParsesReplacementIdentity(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-target", WorkspaceID: "ws-1", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-caller", WorkspaceID: "ws-1", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
h := &Handlers{taskSvc: svc}
ctx = scope.WithPrincipal(ctx, scope.Principal{
WorkspaceID: "ws-1", CallerTaskID: "task-caller", CallerSessionID: "session-caller",
})
msg := makeWSMessage(t, ws.ActionMCPReplaceTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-caller",
"provider": "gitlab", "repository_id": "repo-new", "number": 42,
"old_provider": "github", "old_repository_id": "repo-old", "old_number": 7,
})
req, resp, err := h.taskChangeLinkRequest(ctx, msg, true)
require.NoError(t, err)
require.Nil(t, resp)
assert.Equal(t, TaskChangeLink{Provider: "gitlab", RepositoryID: "repo-new", Number: 42}, req.Link)
require.NotNil(t, req.Old)
assert.Equal(t, TaskChangeLink{Provider: "github", RepositoryID: "repo-old", Number: 7}, *req.Old)
}
func TestTaskChangeLinkRequestRejectsInvalidReplacementIdentity(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace", CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-target", WorkspaceID: "ws-1", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task-caller", WorkspaceID: "ws-1", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now}))
h := &Handlers{taskSvc: svc}
ctx = scope.WithPrincipal(ctx, scope.Principal{
WorkspaceID: "ws-1", CallerTaskID: "task-caller", CallerSessionID: "session-caller",
})
msg := makeWSMessage(t, ws.ActionMCPReplaceTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-caller",
"provider": "gitlab", "repository_id": "repo-new", "number": 42,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, true)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
func TestTaskChangeLinkRequestRejectsPayloadCallerMismatch(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace", CreatedAt: now, UpdatedAt: now}))
for _, task := range []*models.Task{
{ID: "task-target", WorkspaceID: "ws-1", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
{ID: "task-caller", WorkspaceID: "ws-1", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
{ID: "task-other", WorkspaceID: "ws-1", Title: "Other", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
} {
require.NoError(t, repo.CreateTask(ctx, task))
}
ctx = scope.WithPrincipal(ctx, scope.Principal{
WorkspaceID: "ws-1", CallerTaskID: "task-caller", CallerSessionID: "session-caller",
})
h := &Handlers{taskSvc: svc}
msg := makeWSMessage(t, ws.ActionMCPLinkTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-other",
"provider": "github", "repository_id": "repo-1", "number": 1,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, false)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeForbidden)
}
func TestTaskChangeLinkRequestRequiresTrustedPrincipal(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace", CreatedAt: now, UpdatedAt: now}))
for _, task := range []*models.Task{
{ID: "task-target", WorkspaceID: "ws-1", Title: "Target", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
{ID: "task-caller", WorkspaceID: "ws-1", Title: "Caller", State: v1.TaskStateTODO, CreatedAt: now, UpdatedAt: now},
} {
require.NoError(t, repo.CreateTask(ctx, task))
}
h := &Handlers{taskSvc: svc}
msg := makeWSMessage(t, ws.ActionMCPLinkTaskPR, map[string]any{
"task_id": "task-target", "caller_task_id": "task-caller",
"provider": "github", "repository_id": "repo-1", "number": 1,
})
_, resp, err := h.taskChangeLinkRequest(ctx, msg, false)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeForbidden)
}
@@ -11,15 +11,39 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// TaskPRInfo is the decoupled view of a task↔PR association consumed by the
// TaskPRInfo is the decoupled view of a task<->PR association consumed by the
// MCP task-listing handlers. The cmd wiring adapts *github.Service.TaskPR into
// this shape so this package does not depend on internal/github.
type TaskPRInfo struct {
Number int
URL string
Title string
State string // open, closed, merged
MergedAt *time.Time
RepositoryID string
Number int
URL string
Title string
State string // open, closed, merged
Draft *bool
BaseRef string
BaseSHA string
HeadRef string
HeadSHA string
MergedAt *time.Time
ClosedAt *time.Time
}
// TaskMRInfo is the decoupled view of a GitLab task<->MR association consumed
// by the MCP task-listing handlers.
type TaskMRInfo struct {
RepositoryID string
Number int
URL string
Title string
State string
Draft bool
BaseRef string
BaseSHA string
HeadRef string
HeadSHA string
MergedAt *time.Time
ClosedAt *time.Time
}
// TaskPRLister returns PR associations grouped by task ID. Backed by
@@ -29,68 +53,132 @@ type TaskPRLister interface {
ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]TaskPRInfo, error)
}
// TaskMRLister returns MR associations grouped by task ID. Backed by
// *gitlab.Service in production; left nil in contexts without GitLab.
type TaskMRLister interface {
ListTaskMRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]TaskMRInfo, error)
}
// SetTaskPRLister wires the optional PR lister used to enrich task-listing
// responses with associated pull requests.
func (h *Handlers) SetTaskPRLister(l TaskPRLister) {
h.taskPRLister = l
}
// prsByTaskID looks up PR associations for the given task IDs via the wired
// lister, mapping each to the shared v1.TaskPRSummary shape. Returns nil when
// the lister is unset, there are no IDs, or the lookup fails (logged) — callers
// treat a nil map as "no PRs to attach".
func (h *Handlers) prsByTaskID(ctx context.Context, taskIDs []string) map[string][]v1.TaskPRSummary {
if h.taskPRLister == nil || len(taskIDs) == 0 {
return nil
// SetTaskMRLister wires the optional MR lister used to enrich task-listing
// responses with associated merge requests.
func (h *Handlers) SetTaskMRLister(l TaskMRLister) {
h.taskMRLister = l
}
type taskChangeRequestsByID struct {
prs map[string][]v1.TaskPRSummary
changes map[string][]v1.TaskChangeRequestSummary
}
func (h *Handlers) changeRequestsByTaskID(ctx context.Context, taskIDs []string) taskChangeRequestsByID {
out := taskChangeRequestsByID{
prs: map[string][]v1.TaskPRSummary{},
changes: map[string][]v1.TaskChangeRequestSummary{},
}
if len(taskIDs) == 0 {
return out
}
if h.taskPRLister != nil {
h.addPRSummaries(ctx, taskIDs, out)
}
if h.taskMRLister != nil {
h.addMRSummaries(ctx, taskIDs, out)
}
return out
}
func (h *Handlers) addPRSummaries(ctx context.Context, taskIDs []string, out taskChangeRequestsByID) {
byTask, err := h.taskPRLister.ListTaskPRsByTaskIDs(ctx, taskIDs)
if err != nil {
h.logger.Warn("failed to list task PRs for MCP response", zap.Error(err))
return nil
return
}
out := make(map[string][]v1.TaskPRSummary, len(byTask))
for taskID, prs := range byTask {
if len(prs) == 0 {
continue
}
summaries := make([]v1.TaskPRSummary, 0, len(prs))
for _, pr := range prs {
summaries = append(summaries, v1.TaskPRSummary{
out.prs[taskID] = append(out.prs[taskID], v1.TaskPRSummary{
Number: pr.Number,
URL: pr.URL,
Title: pr.Title,
State: pr.State,
MergedAt: pr.MergedAt,
})
out.changes[taskID] = append(out.changes[taskID], v1.TaskChangeRequestSummary{
Provider: "github",
RepositoryID: pr.RepositoryID,
Number: pr.Number,
URL: pr.URL,
Title: pr.Title,
State: pr.State,
Draft: pr.Draft,
BaseRef: pr.BaseRef,
HeadRef: pr.HeadRef,
HeadSHA: pr.HeadSHA,
MergedAt: pr.MergedAt,
ClosedAt: pr.ClosedAt,
})
}
out[taskID] = summaries
}
return out
}
// enrichTasksWithPRs populates dto.TaskDTO.PRs for each task from the wired
// TaskPRLister. No-op when the lister is unset or there are no tasks.
func (h *Handlers) addMRSummaries(ctx context.Context, taskIDs []string, out taskChangeRequestsByID) {
byTask, err := h.taskMRLister.ListTaskMRsByTaskIDs(ctx, taskIDs)
if err != nil {
h.logger.Warn("failed to list task MRs for MCP response", zap.Error(err))
return
}
for taskID, mrs := range byTask {
for _, mr := range mrs {
draft := mr.Draft
out.changes[taskID] = append(out.changes[taskID], v1.TaskChangeRequestSummary{
Provider: "gitlab",
RepositoryID: mr.RepositoryID,
Number: mr.Number,
URL: mr.URL,
Title: mr.Title,
State: mr.State,
Draft: &draft,
BaseRef: mr.BaseRef,
BaseSHA: mr.BaseSHA,
HeadRef: mr.HeadRef,
HeadSHA: mr.HeadSHA,
MergedAt: mr.MergedAt,
ClosedAt: mr.ClosedAt,
})
}
}
}
// enrichTasksWithPRs populates dto.TaskDTO.PRs for compatibility and
// dto.TaskDTO.ChangeRequests with provider-neutral GitHub/GitLab links.
func (h *Handlers) enrichTasksWithPRs(ctx context.Context, tasks []dto.TaskDTO) {
if h.taskPRLister == nil || len(tasks) == 0 {
if (h.taskPRLister == nil && h.taskMRLister == nil) || len(tasks) == 0 {
return
}
ids := make([]string, len(tasks))
for i := range tasks {
ids[i] = tasks[i].ID
}
byTask := h.prsByTaskID(ctx, ids)
byTask := h.changeRequestsByTaskID(ctx, ids)
for i := range tasks {
if prs := byTask[tasks[i].ID]; len(prs) > 0 {
if prs := byTask.prs[tasks[i].ID]; len(prs) > 0 {
tasks[i].PRs = prs
}
if changes := byTask.changes[tasks[i].ID]; len(changes) > 0 {
tasks[i].ChangeRequests = changes
}
}
}
// enrichRelatedTasksWithPRs populates RelatedTask.PRs across every relation
// surface (the task itself, parent, children, siblings, blockers, blocked-by)
// in a single batched lookup. No-op when the lister is unset.
// enrichRelatedTasksWithPRs populates related-task PR compatibility data and
// provider-neutral GitHub/GitLab change requests in one batched lookup.
func (h *Handlers) enrichRelatedTasksWithPRs(ctx context.Context, related *service.RelatedTasks) {
if h.taskPRLister == nil || related == nil {
if (h.taskPRLister == nil && h.taskMRLister == nil) || related == nil {
return
}
nodes := []*service.RelatedTask{&related.Task, related.Parent}
@@ -99,9 +187,6 @@ func (h *Handlers) enrichRelatedTasksWithPRs(ctx context.Context, related *servi
nodes = append(nodes, related.Blockers...)
nodes = append(nodes, related.BlockedBy...)
// A task can appear in more than one relation group (e.g. both a sibling
// and a blocker), so dedup IDs before the lookup to avoid sending the same
// value twice to the lister's WHERE id IN (...) query.
ids := make([]string, 0, len(nodes))
seen := make(map[string]struct{}, len(nodes))
for _, n := range nodes {
@@ -114,16 +199,16 @@ func (h *Handlers) enrichRelatedTasksWithPRs(ctx context.Context, related *servi
seen[n.ID] = struct{}{}
ids = append(ids, n.ID)
}
byTask := h.prsByTaskID(ctx, ids)
if byTask == nil {
return
}
byTask := h.changeRequestsByTaskID(ctx, ids)
for _, n := range nodes {
if n == nil {
continue
}
if prs := byTask[n.ID]; len(prs) > 0 {
if prs := byTask.prs[n.ID]; len(prs) > 0 {
n.PRs = prs
}
if changes := byTask.changes[n.ID]; len(changes) > 0 {
n.ChangeRequests = changes
}
}
}
@@ -30,11 +30,26 @@ func (f *fakeTaskPRLister) ListTaskPRsByTaskIDs(_ context.Context, taskIDs []str
return f.byTask, nil
}
type fakeTaskMRLister struct {
byTask map[string][]TaskMRInfo
err error
gotIDs []string
}
func (f *fakeTaskMRLister) ListTaskMRsByTaskIDs(_ context.Context, taskIDs []string) (map[string][]TaskMRInfo, error) {
f.gotIDs = taskIDs
if f.err != nil {
return nil, f.err
}
return f.byTask, nil
}
func TestEnrichTasksWithPRs(t *testing.T) {
draft := false
lister := &fakeTaskPRLister{
byTask: map[string][]TaskPRInfo{
"task-1": {
{Number: 42, URL: "https://github.com/o/r/pull/42", Title: "Fix bug", State: "merged"},
{RepositoryID: "repo-gh", Number: 42, URL: "https://github.com/o/r/pull/42", Title: "Fix bug", State: "merged", Draft: &draft, BaseRef: "main", HeadRef: "fix", HeadSHA: "head-sha"},
{Number: 43, URL: "https://github.com/o/r/pull/43", Title: "Add feature", State: "open"},
},
},
@@ -50,9 +65,45 @@ func TestEnrichTasksWithPRs(t *testing.T) {
Number: 42, URL: "https://github.com/o/r/pull/42", Title: "Fix bug", State: "merged",
}, dtos[0].PRs[0])
assert.Equal(t, "open", dtos[0].PRs[1].State)
require.Len(t, dtos[0].ChangeRequests, 2)
assert.Equal(t, v1.TaskChangeRequestSummary{
Provider: "github", RepositoryID: "repo-gh", Number: 42,
URL: "https://github.com/o/r/pull/42", Title: "Fix bug", State: "merged",
Draft: &draft, BaseRef: "main", HeadRef: "fix", HeadSHA: "head-sha",
}, dtos[0].ChangeRequests[0])
assert.Nil(t, dtos[1].PRs, "tasks without PRs stay nil")
}
func TestEnrichTasksWithGitLabMRsAddsProviderNeutralChangeRequests(t *testing.T) {
merged := time.Now().UTC()
lister := &fakeTaskMRLister{byTask: map[string][]TaskMRInfo{
"task-1": {{
RepositoryID: "repo-gl", Number: 224, URL: "https://gitlab.example.test/group/project/-/merge_requests/224",
Title: "Ship MR", State: "merged", Draft: true, BaseRef: "main", BaseSHA: "base-sha", HeadRef: "feature", HeadSHA: "head-sha", MergedAt: &merged,
}},
}}
h := &Handlers{taskMRLister: lister, logger: testLogger(t).WithFields()}
dtos := []dto.TaskDTO{{ID: "task-1"}, {ID: "task-2"}}
h.enrichTasksWithPRs(context.Background(), dtos)
assert.ElementsMatch(t, []string{"task-1", "task-2"}, lister.gotIDs)
assert.Nil(t, dtos[0].PRs, "GitLab MRs do not populate the legacy GitHub-only prs field")
require.Len(t, dtos[0].ChangeRequests, 1)
got := dtos[0].ChangeRequests[0]
assert.Equal(t, "gitlab", got.Provider)
assert.Equal(t, "repo-gl", got.RepositoryID)
assert.Equal(t, 224, got.Number)
assert.Equal(t, "merged", got.State)
require.NotNil(t, got.Draft)
assert.True(t, *got.Draft)
assert.Equal(t, "main", got.BaseRef)
assert.Equal(t, "base-sha", got.BaseSHA)
assert.Equal(t, "feature", got.HeadRef)
assert.Equal(t, "head-sha", got.HeadSHA)
require.NotNil(t, got.MergedAt)
}
func TestEnrichTasksWithPRs_NilListerIsNoop(t *testing.T) {
h := &Handlers{logger: testLogger(t).WithFields()}
dtos := []dto.TaskDTO{{ID: "task-1"}}
@@ -97,6 +148,43 @@ func TestHandleListTasks_IncludesAssociatedPRs(t *testing.T) {
assert.Equal(t, "https://github.com/o/r/pull/7", payload.Tasks[0].PRs[0].URL)
}
func TestHandleListTasks_IncludesProviderNeutralGitLabMRs(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{
ID: "ws-mr", Name: "MR WS", CreatedAt: now, UpdatedAt: now,
}))
require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{
ID: "wf-mr", WorkspaceID: "ws-mr", Name: "MR Workflow", CreatedAt: now, UpdatedAt: now,
}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{
ID: "task-mr", WorkspaceID: "ws-mr", WorkflowID: "wf-mr",
Title: "Has an MR", State: v1.TaskStateReview, CreatedAt: now, UpdatedAt: now,
}))
h := &Handlers{
taskSvc: svc,
logger: testLogger(t).WithFields(),
taskMRLister: &fakeTaskMRLister{byTask: map[string][]TaskMRInfo{
"task-mr": {{RepositoryID: "repo-gl", Number: 224, URL: "https://gitlab.example.test/g/p/-/merge_requests/224", Title: "Ship it", State: "open"}},
}},
}
msg := makeWSMessage(t, ws.ActionMCPListTasks, map[string]any{"workflow_id": "wf-mr"})
resp, err := h.handleListTasks(ctx, msg)
require.NoError(t, err)
var payload dto.ListTasksResponse
require.NoError(t, json.Unmarshal(resp.Payload, &payload))
require.Len(t, payload.Tasks, 1)
require.Empty(t, payload.Tasks[0].PRs)
require.Len(t, payload.Tasks[0].ChangeRequests, 1)
assert.Equal(t, "gitlab", payload.Tasks[0].ChangeRequests[0].Provider)
assert.Equal(t, 224, payload.Tasks[0].ChangeRequests[0].Number)
}
func TestHandleListRelatedTasks_IncludesAssociatedPRs(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
@@ -132,13 +220,51 @@ func TestHandleListRelatedTasks_IncludesAssociatedPRs(t *testing.T) {
require.NotNil(t, payload.Task.PRs[0].MergedAt, "merged_at should be surfaced")
}
func TestHandleListRelatedTasks_IncludesProviderNeutralGitLabMRs(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{
ID: "ws-rel-mr", Name: "Rel MR WS", CreatedAt: now, UpdatedAt: now,
}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{
ID: "task-rel-mr", WorkspaceID: "ws-rel-mr", WorkflowID: "wf-rel-mr",
Title: "Self", State: v1.TaskStateReview, CreatedAt: now, UpdatedAt: now,
}))
h := &Handlers{
taskSvc: svc,
handoffSvc: service.NewHandoffService(repo, nil, nil, nil, nil, testLogger(t)),
logger: testLogger(t).WithFields(),
taskMRLister: &fakeTaskMRLister{byTask: map[string][]TaskMRInfo{
"task-rel-mr": {{RepositoryID: "repo-gl", Number: 224, URL: "https://gitlab.example.test/g/p/-/merge_requests/224", Title: "Fix", State: "open"}},
}},
}
msg := makeWSMessage(t, ws.ActionMCPListRelatedTasks, map[string]any{"task_id": "task-rel-mr"})
resp, err := h.handleListRelatedTasks(ctx, msg)
require.NoError(t, err)
var payload service.RelatedTasks
require.NoError(t, json.Unmarshal(resp.Payload, &payload))
require.Empty(t, payload.Task.PRs)
require.Len(t, payload.Task.ChangeRequests, 1)
assert.Equal(t, "gitlab", payload.Task.ChangeRequests[0].Provider)
assert.Equal(t, 224, payload.Task.ChangeRequests[0].Number)
}
func TestEnrichRelatedTasksWithPRs(t *testing.T) {
draft := false
lister := &fakeTaskPRLister{byTask: map[string][]TaskPRInfo{
"self": {{Number: 1, URL: "u1", State: "open"}},
"self": {{RepositoryID: "repo-gh", Number: 1, URL: "u1", State: "open", Draft: &draft}},
"parent": {{Number: 3, URL: "u3", State: "closed"}},
"child": {{Number: 2, URL: "u2", State: "merged"}},
}}
h := &Handlers{taskPRLister: lister, logger: testLogger(t).WithFields()}
mrLister := &fakeTaskMRLister{byTask: map[string][]TaskMRInfo{
"self": {{RepositoryID: "repo-gl", Number: 4, URL: "m1", State: "open"}},
}}
h := &Handlers{taskPRLister: lister, taskMRLister: mrLister, logger: testLogger(t).WithFields()}
related := &service.RelatedTasks{
Task: service.RelatedTask{ID: "self"},
@@ -154,6 +280,9 @@ func TestEnrichRelatedTasksWithPRs(t *testing.T) {
lister.gotIDs)
require.Len(t, related.Task.PRs, 1)
assert.Equal(t, "open", related.Task.PRs[0].State)
require.Len(t, related.Task.ChangeRequests, 2)
assert.Equal(t, "github", related.Task.ChangeRequests[0].Provider)
assert.Equal(t, "gitlab", related.Task.ChangeRequests[1].Provider)
require.Len(t, related.Parent.PRs, 1)
assert.Equal(t, "closed", related.Parent.PRs[0].State)
require.Len(t, related.Children[0].PRs, 1)
@@ -320,6 +320,39 @@ func (s *Server) getTaskPRAutomationHandler() server.ToolHandlerFunc {
}
}
func (s *Server) taskPRLinkHandler(name string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
actions := map[string]string{
"link_task_pr_kandev": ws.ActionMCPLinkTaskPR,
"unlink_task_pr_kandev": ws.ActionMCPUnlinkTaskPR,
"replace_task_pr_kandev": ws.ActionMCPReplaceTaskPR,
}
action, ok := actions[name]
if !ok {
return mcp.NewToolResultError("unsupported task PR link operation"), nil
}
taskID, err := req.RequireString(mcpKeyTaskID)
if err != nil {
return mcp.NewToolResultError("task_id is required"), nil
}
payload := map[string]interface{}{
mcpKeyTaskID: taskID,
"caller_task_id": s.taskID,
}
for _, key := range []string{"provider", "repository_id", "number", "old_provider", "old_repository_id", "old_number"} {
if value, ok := req.GetArguments()[key]; ok {
payload[key] = value
}
}
var result map[string]interface{}
if err := s.backend.RequestPayload(ctx, action, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) updateTaskPRAutomationHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
payload := map[string]interface{}{"task_id": s.taskID}
@@ -1052,6 +1052,53 @@ func TestTaskMRAutomationToolsNoTaskIDArgument(t *testing.T) {
assert.NotContains(t, properties, "task_id")
}
func TestTaskPRLinkToolsRequireTaskAndCanonicalIdentity(t *testing.T) {
backend := &testBackend{response: map[string]interface{}{"task_id": "task-current"}}
s := newTaskModeServer(t, backend, "task-current")
result := callTool(t, s, "link_task_pr_kandev", map[string]interface{}{
"task_id": "task-target", "provider": "gitlab", "repository_id": "repo-1", "number": 42,
})
assert.False(t, result.IsError)
assert.Equal(t, ws.ActionMCPLinkTaskPR, backend.lastAction)
payload, ok := backend.lastPayload.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "task-target", payload["task_id"])
assert.Equal(t, "task-current", payload["caller_task_id"])
assert.Equal(t, "gitlab", payload["provider"])
assert.Equal(t, "repo-1", payload["repository_id"])
assert.Equal(t, float64(42), payload["number"])
properties := toolInputProperties(t, s, "link_task_pr_kandev")
assert.Contains(t, properties, "task_id")
assert.Contains(t, properties, "provider")
assert.Contains(t, properties, "repository_id")
assert.Contains(t, properties, "number")
assert.NotContains(t, properties, "caller_task_id")
}
func TestReplaceTaskPRToolRequiresOldCanonicalIdentity(t *testing.T) {
backend := &testBackend{response: map[string]interface{}{"task_id": "task-target"}}
s := newTaskModeServer(t, backend, "task-current")
result := callTool(t, s, "replace_task_pr_kandev", map[string]interface{}{
"task_id": "task-target", "provider": "gitlab", "repository_id": "repo-new", "number": 42,
"old_provider": "github", "old_repository_id": "repo-old", "old_number": 7,
})
assert.False(t, result.IsError)
payload, ok := backend.lastPayload.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "github", payload["old_provider"])
assert.Equal(t, "repo-old", payload["old_repository_id"])
assert.Equal(t, float64(7), payload["old_number"])
properties := toolInputProperties(t, s, "replace_task_pr_kandev")
assert.Contains(t, properties, "old_provider")
assert.Contains(t, properties, "old_repository_id")
assert.Contains(t, properties, "old_number")
assert.Contains(t, s.mcpServer.ListTools()["replace_task_pr_kandev"].Tool.Description, "same provider")
}
// TestUpdateTaskMRAutomationToolForwardsMRIdentityAndAutoFixFields covers
// AC31: repository_id/project_path/mr_iid must reach the backend payload
// unchanged (including an explicit empty repository_id, R6) so the WS
@@ -1085,6 +1085,10 @@ func (s *Server) profileToolGroups() []profileToolGroup {
// Dependency edges are manageable wherever a task can be created.
{name: "task-dependencies", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || external(ctx) }, register: func(s *Server) { s.registerTaskDependencyTools() }},
{name: "kanban-task", enabled: kanban, register: func(s *Server) { s.registerKanbanTools() }},
{name: "task-pr-links", enabled: andProfilePredicates(kanban, func(ctx mcpprofile.Context) bool {
return mcpproviders.Contains(ctx.Providers, mcpproviders.GitHub) ||
mcpproviders.Contains(ctx.Providers, mcpproviders.GitLab)
}), register: func(s *Server) { s.registerTaskPRLinkTools() }},
{name: "github-pr", enabled: andProfilePredicates(kanban, func(ctx mcpprofile.Context) bool { return mcpproviders.Contains(ctx.Providers, mcpproviders.GitHub) }), register: func(s *Server) { s.registerPRAutomationTools() }},
{name: "gitlab-mr", enabled: andProfilePredicates(kanban, func(ctx mcpprofile.Context) bool { return mcpproviders.Contains(ctx.Providers, mcpproviders.GitLab) }), register: func(s *Server) { s.registerMRAutomationTools() }},
{name: "user-question", enabled: capabilityEnabled(mcpprofile.CapabilityUserQuestion), register: func(s *Server) { s.registerInteractionTools() }},
@@ -1388,6 +1392,40 @@ func (s *Server) registerPRAutomationTools() {
)
}
func (s *Server) registerTaskPRLinkTools() {
s.mcpServer.AddTool(
mcp.NewTool("link_task_pr_kandev", taskPRLinkToolOptions(false)...),
s.wrapHandler("link_task_pr_kandev", s.taskPRLinkHandler("link_task_pr_kandev")),
)
s.mcpServer.AddTool(
mcp.NewTool("unlink_task_pr_kandev", taskPRLinkToolOptions(false)...),
s.wrapHandler("unlink_task_pr_kandev", s.taskPRLinkHandler("unlink_task_pr_kandev")),
)
s.mcpServer.AddTool(
mcp.NewTool("replace_task_pr_kandev", taskPRLinkToolOptions(true)...),
s.wrapHandler("replace_task_pr_kandev", s.taskPRLinkHandler("replace_task_pr_kandev")),
)
}
func taskPRLinkToolOptions(replace bool) []mcp.ToolOption {
options := []mcp.ToolOption{
mcp.WithDescription("Manage an explicit GitHub PR or GitLab MR association. Provide task_id, provider, canonical repository_id, and pull-request or merge-request number."),
mcp.WithString(mcpKeyTaskID, mcp.Required(), mcp.Description("Target task identifier")),
mcp.WithString("provider", mcp.Required(), mcp.Description("Provider identity: github or gitlab")),
mcp.WithString("repository_id", mcp.Required(), mcp.Description("Canonical task repository identity")),
mcp.WithNumber("number", mcp.Required(), mcp.Description("Pull request or merge request number")),
}
if replace {
options[0] = mcp.WithDescription("Replace an explicit GitHub PR or GitLab MR association. Provide task_id, provider, canonical repository_id, and pull-request or merge-request number. The old and new associations must use the same provider.")
options = append(options,
mcp.WithString("old_provider", mcp.Required(), mcp.Description("Current association provider identity: github or gitlab")),
mcp.WithString("old_repository_id", mcp.Required(), mcp.Description("Current association canonical repository identity")),
mcp.WithNumber("old_number", mcp.Required(), mcp.Description("Current pull request or merge request number")),
)
}
return options
}
func (s *Server) registerMRAutomationTools() {
s.mcpServer.AddTool(
mcp.NewToolWithRawSchema("get_task_mr_automation_kandev",
@@ -449,6 +449,9 @@ func TestServerModeTask_RegistersCorrectTools(t *testing.T) {
assert.Contains(t, tools, "get_diagnostic_bundle_kandev")
assert.Contains(t, tools, "get_task_mr_automation_kandev")
assert.Contains(t, tools, "update_task_mr_automation_kandev")
assert.Contains(t, tools, "link_task_pr_kandev")
assert.Contains(t, tools, "unlink_task_pr_kandev")
assert.Contains(t, tools, "replace_task_pr_kandev")
// Task mode should have plan tools
assert.Contains(t, tools, "create_task_plan_kandev")
@@ -657,6 +660,9 @@ func TestServerModeTask_AbsentProvidersFailClosedForReviewAutomation(t *testing.
assert.NotContains(t, tools, "update_task_pr_automation_kandev")
assert.NotContains(t, tools, "get_task_mr_automation_kandev")
assert.NotContains(t, tools, "update_task_mr_automation_kandev")
assert.NotContains(t, tools, "link_task_pr_kandev")
assert.NotContains(t, tools, "unlink_task_pr_kandev")
assert.NotContains(t, tools, "replace_task_pr_kandev")
}
func TestServerModeTask_ProviderMembership(t *testing.T) {
@@ -681,11 +687,15 @@ func TestServerModeTask_ProviderMembership(t *testing.T) {
t.Cleanup(backend.Close)
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask, tt.providers)
tools := getRegisteredToolNames(s)
wantLinkTools := tt.wantPR || tt.wantMR
assert.Equal(t, tt.wantPR, containsTool(tools, "get_task_pr_automation_kandev"))
assert.Equal(t, tt.wantPR, containsTool(tools, "update_task_pr_automation_kandev"))
assert.Equal(t, tt.wantPR, containsTool(tools, "report_pr_auto_fix_outcome_kandev"))
assert.Equal(t, tt.wantMR, containsTool(tools, "get_task_mr_automation_kandev"))
assert.Equal(t, tt.wantMR, containsTool(tools, "update_task_mr_automation_kandev"))
assert.Equal(t, wantLinkTools, containsTool(tools, "link_task_pr_kandev"))
assert.Equal(t, wantLinkTools, containsTool(tools, "unlink_task_pr_kandev"))
assert.Equal(t, wantLinkTools, containsTool(tools, "replace_task_pr_kandev"))
assert.Contains(t, tools, "stop_task_kandev")
})
}
@@ -704,6 +714,9 @@ func TestServerSetProvidersPreservesModeAndRebuildsTools(t *testing.T) {
assert.Contains(t, tools, "set_task_title_kandev")
assert.NotContains(t, tools, "get_task_pr_automation_kandev")
assert.Contains(t, tools, "get_task_mr_automation_kandev")
assert.Contains(t, tools, "link_task_pr_kandev")
assert.Contains(t, tools, "unlink_task_pr_kandev")
assert.Contains(t, tools, "replace_task_pr_kandev")
}
type providerRefreshTestSession struct {
@@ -755,7 +768,7 @@ drained:
// as in TestServerModeTask_ToolCount and
// TestRegisterTools_LoggedCountMatchesRegisteredTools (list_task_sessions_test.go),
// which pin the per-mode registration rather than this SetProviders rebuild.
require.Len(t, tools, 36, "final registry should contain the complete GitLab-only task tool set")
require.Len(t, tools, 39, "final registry should contain the complete GitLab-only task tool set")
assert.Contains(t, tools, "get_task_mr_automation_kandev")
assert.NotContains(t, tools, "get_task_pr_automation_kandev")
}
@@ -924,7 +937,7 @@ func TestServerModeTask_ToolCount(t *testing.T) {
// 1 add_workspace_sources + 1 update_repository_base_branch +
// 1 step_complete (ADR 0015) + 1 interaction + 4 plan + 3 walkthrough +
// 1 publish_review_findings + 1 related-tasks + 1 diagnostic bundle
// + 2 task-dependency (add/remove) + 1 rich-output = 39.
// + 2 task-dependency (add/remove) + 3 task change-link operations + 1 rich-output = 42.
// Task-document tools (list/get/write) are office-only.
assert.Contains(t, tools, "step_complete_kandev", "ADR 0015 explicit-completion signal must be registered in task mode")
assert.Contains(t, tools, "show_walkthrough_kandev", "walkthrough tool must be registered in task mode")
@@ -935,7 +948,7 @@ func TestServerModeTask_ToolCount(t *testing.T) {
assert.Contains(t, tools, "add_task_dependency_kandev", "dependency edges must be manageable in task mode")
assert.Contains(t, tools, "remove_task_dependency_kandev")
assert.Contains(t, tools, "show_rich_output_kandev", "native rich output must be registered in task mode")
assert.Equal(t, 39, len(tools))
assert.Equal(t, 42, len(tools))
}
func TestServerStepCompleteTool_TaskAndOfficeOnlyAndDiscoverable(t *testing.T) {
+3
View File
@@ -296,6 +296,9 @@ type TaskDTO struct {
// task-listing MCP tools so agents can reason about PR status (e.g. find
// tasks whose PRs are merged). Omitted when empty.
PRs []v1.TaskPRSummary `json:"prs,omitempty"`
// ChangeRequests lists the provider-neutral GitHub PR and GitLab MR
// associations for this task. Omitted when no associations exist.
ChangeRequests []v1.TaskChangeRequestSummary `json:"change_requests,omitempty"`
// StatusSummary is the bounded task-level projection consumed by task rows.
// It is loaded in batches and is absent when no projection exists yet; the
@@ -220,16 +220,17 @@ func (p WorkspacePolicy) NeedsAttachment() bool {
// graph itself is the bound: only parent/children/siblings/blockers are ever
// projected, so descriptions never leak from unrelated tasks.
type RelatedTask struct {
ID string `json:"id"`
Identifier string `json:"identifier,omitempty"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
State string `json:"state"`
WorkspaceID string `json:"workspace_id"`
ParentID string `json:"parent_id,omitempty"`
AssigneeLabel string `json:"assignee_label,omitempty"`
DocumentKeys []string `json:"document_keys,omitempty"`
PRs []v1.TaskPRSummary `json:"prs,omitempty"`
ID string `json:"id"`
Identifier string `json:"identifier,omitempty"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
State string `json:"state"`
WorkspaceID string `json:"workspace_id"`
ParentID string `json:"parent_id,omitempty"`
AssigneeLabel string `json:"assignee_label,omitempty"`
DocumentKeys []string `json:"document_keys,omitempty"`
PRs []v1.TaskPRSummary `json:"prs,omitempty"`
ChangeRequests []v1.TaskChangeRequestSummary `json:"change_requests,omitempty"`
}
// RelatedTasks bundles every relation surface for a single task.
+21 -3
View File
@@ -18,10 +18,28 @@ const (
TaskStateCancelled TaskState = "CANCELLED"
)
// TaskChangeRequestSummary is a compact provider-neutral view of a pull or
// merge request associated with a task.
type TaskChangeRequestSummary struct {
Provider string `json:"provider"`
RepositoryID string `json:"repository_id,omitempty"`
Number int `json:"number"`
URL string `json:"url"`
Title string `json:"title,omitempty"`
State string `json:"state"`
Draft *bool `json:"draft,omitempty"`
BaseRef string `json:"base_ref,omitempty"`
BaseSHA string `json:"base_sha"`
HeadRef string `json:"head_ref,omitempty"`
HeadSHA string `json:"head_sha"`
MergedAt *time.Time `json:"merged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// TaskPRSummary is a compact view of a GitHub pull request associated with a
// task. Surfaced through the task-listing MCP tools so agents can reason about
// PR status. State is one of "open", "closed", "merged"; MergedAt is set only
// when the PR has merged, so agents can report when the work landed.
// task. Surfaced through the task-listing MCP tools for compatibility with
// existing agents. State is one of "open", "closed", "merged"; MergedAt is set
// only when the PR has merged, so agents can report when the work landed.
type TaskPRSummary struct {
Number int `json:"number"`
URL string `json:"url"`
+4
View File
@@ -447,6 +447,9 @@ const (
ActionMCPReportPRAutoFixOutcome = "mcp.report_pr_auto_fix_outcome"
ActionMCPGetTaskMRAutomation = "mcp.get_task_mr_automation"
ActionMCPUpdateTaskMRAutomation = "mcp.update_task_mr_automation"
ActionMCPLinkTaskPR = "mcp.link_task_pr"
ActionMCPUnlinkTaskPR = "mcp.unlink_task_pr"
ActionMCPReplaceTaskPR = "mcp.replace_task_pr"
ActionMCPAddTaskDependency = "mcp.add_task_dependency"
ActionMCPRemoveTaskDependency = "mcp.remove_task_dependency"
ActionMCPAddBranchToTask = "mcp.add_branch_to_task"
@@ -590,6 +593,7 @@ const (
// GitLab integration notifications
const (
ActionGitLabTaskMRUpdated = "gitlab.task_mr.updated" // Notification
ActionGitLabTaskMRDeleted = "gitlab.task_mr.deleted" // Notification
ActionGitLabTaskMRAutomationUpdated = "gitlab.task_mr_options.updated" // Notification
)
@@ -88,8 +88,11 @@ test.describe("Fork pull-request comparison target", () => {
session.changes.locator('[data-changes-file="local-auth-recovery.txt"]'),
).toBeVisible();
// Changes is a right-column panel and stays active while the chat session
// tab changes. Leave it explicitly so returning to Changes fires the
// activation refresh that recovers the comparison target.
await session.clickTab("Files");
comparisonTargetFixture?.setAvailable(true);
await session.clickSessionChatTab();
await session.clickTab("Changes");
await expect(testPage.getByTestId("comparison-target-notice")).toHaveCount(0, {
timeout: 30_000,
+2 -2
View File
@@ -42,9 +42,8 @@ import type {
TaskPR,
TaskPRDeletedEvent,
} from "@/lib/types/github";
import type { TaskMR } from "@/lib/types/gitlab";
import type { TaskMR, TaskMRDeletedEvent, TaskMRAutomationOptions } from "@/lib/types/gitlab";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import type { TaskMRAutomationOptions } from "@/lib/types/gitlab";
import type { AgentProfileRecentUseApiRecord } from "@/lib/types/http-agent-profile-recent-use";
import type { SystemMetricsSnapshot, StorageAnalysisUpdatedPayload } from "./system";
import type { AgentRuntimeAvailability } from "./agent-runtime";
@@ -581,6 +580,7 @@ export type BackendMessageMap = SessionBackendMessageMap &
"gitlab.task_mr.updated",
TaskMR & { workspace_id: string }
>;
"gitlab.task_mr.deleted": BackendMessage<"gitlab.task_mr.deleted", TaskMRDeletedEvent>;
"gitlab.task_mr_options.updated": BackendMessage<
"gitlab.task_mr_options.updated",
TaskMRAutomationOptions
+7
View File
@@ -94,6 +94,13 @@ export type TaskMR = {
updated_at: string;
};
/** Workspace-scoped websocket payload emitted when a task MR association is removed. */
export type TaskMRDeletedEvent = {
workspace_id: string;
task_id: string;
association_id: string;
};
/** Response shape for `GET /api/v1/gitlab/workspaces/:id/task-mrs`. */
export type TaskMRsResponse = {
task_mrs: Record<string, TaskMR[]>;
+33
View File
@@ -8,18 +8,21 @@ const WORKSPACE_A = "workspace-a";
function makeStore(activeWorkspaceId: string | null, options?: TaskMRAutomationOptions) {
const setTaskMR = vi.fn();
const removeTaskMR = vi.fn();
const setTaskMRAutomationOptions = vi.fn();
const markTaskMRAutomationExternalUpdate = vi.fn();
const state = {
workspaces: { activeId: activeWorkspaceId },
taskMRAutomation: { byTaskId: options ? { [options.task_id]: options } : {} },
setTaskMR,
removeTaskMR,
setTaskMRAutomationOptions,
markTaskMRAutomationExternalUpdate,
} as unknown as AppState;
return {
store: { getState: () => state } as StoreApi<AppState>,
setTaskMR,
removeTaskMR,
setTaskMRAutomationOptions,
markTaskMRAutomationExternalUpdate,
};
@@ -68,6 +71,36 @@ describe("GitLab WebSocket handlers", () => {
expect(setTaskMR).toHaveBeenCalledWith(WORKSPACE_A, "task-1", mr);
});
it("removes a task MR for the active workspace", () => {
const { store, removeTaskMR } = makeStore(WORKSPACE_A);
const handler = (registerGitLabHandlers(store) as Record<string, (message: never) => void>)[
"gitlab.task_mr.deleted"
]!;
handler({
type: "notification",
action: "gitlab.task_mr.deleted",
payload: { workspace_id: WORKSPACE_A, task_id: "task-1", association_id: "mr-1" },
} as never);
expect(removeTaskMR).toHaveBeenCalledWith(WORKSPACE_A, "mr-1");
});
it("ignores task MR deletion from another workspace", () => {
const { store, removeTaskMR } = makeStore("workspace-b");
const handler = (registerGitLabHandlers(store) as Record<string, (message: never) => void>)[
"gitlab.task_mr.deleted"
]!;
handler({
type: "notification",
action: "gitlab.task_mr.deleted",
payload: { workspace_id: WORKSPACE_A, task_id: "task-1", association_id: "mr-1" },
} as never);
expect(removeTaskMR).not.toHaveBeenCalled();
});
it("ignores task MRs from another workspace", () => {
const { store, setTaskMR } = makeStore("workspace-b");
const handler = registerGitLabHandlers(store)["gitlab.task_mr.updated"]!;
+13
View File
@@ -11,6 +11,19 @@ export function registerGitLabHandlers(store: StoreApi<AppState>): WsHandlers {
if (!mr.task_id || !mr.workspace_id || mr.workspace_id !== activeWorkspaceId) return;
store.getState().setTaskMR(mr.workspace_id, mr.task_id, mr);
},
"gitlab.task_mr.deleted": (message) => {
const deleted = message.payload;
const activeWorkspaceId = store.getState().workspaces.activeId;
if (
!deleted.task_id ||
!deleted.association_id ||
!deleted.workspace_id ||
deleted.workspace_id !== activeWorkspaceId
) {
return;
}
store.getState().removeTaskMR(deleted.workspace_id, deleted.association_id);
},
"gitlab.task_mr_options.updated": (message) => {
const options = message.payload as TaskMRAutomationOptions;
if (options.task_id) {
+49
View File
@@ -0,0 +1,49 @@
---
status: implemented
requirements:
- REQ-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001
system_design:
- ../../specs/integrations/system-design/task-change-link-mcp.md
created: 2026-09-14
---
# Implementation Plan: Manage Task Pull Requests and Merge Requests
## Overview
Expose one provider-neutral MCP link contract,
`link_task_pr_kandev` / `unlink_task_pr_kandev` / `replace_task_pr_kandev`,
that manages an existing task's GitHub pull request and GitLab merge request
associations. The backend routes all three operations through one coordinator
so validation, workspace authorization, and store dispatch stay identical for
both providers, and each mutation returns the resulting canonical linked set.
## Delivery packages
- [Task 01: Provider-neutral task change-request link
coordinator](task-01-coordinator.md)
- Backend coordinator, MCP handler and server registration, provider service
entry points, and GitLab deletion-event publication. This package is the
only work order: the merged diff is backend-only and public documentation
was part of the same delivery.
## Requirements and design
- Requirements: `docs/specs/integrations/requirements/task-change-link-mcp.md`.
- System design: `docs/specs/integrations/system-design/task-change-link-mcp.md`.
## Risks
- Fork and canonical repositories can share a number; identity is anchored to
the canonical repository identity, not the number.
- Legacy repositories may have an empty provider host; link operations fail
closed instead of guessing `github.com`.
- Replacement failure must not strand two active associations; compensation is
reported when it also fails.
## Verification strategy
Focused Go suites for the coordinator, MCP handler/server, GitHub, and GitLab
packages, plus race-enabled focused tests for identity, authorization,
idempotency, rollback, detach, and projection behavior. Public documentation
validation via `Validate public docs`.
@@ -0,0 +1,65 @@
---
id: "01-coordinator"
title: "Provider-neutral task change-request link coordinator"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001
acceptance_criteria:
- AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.1
- AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.2
- AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.3
- AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.4
- AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.5
- AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.6
system_design:
- ../../specs/integrations/system-design/task-change-link-mcp.md
---
# Task 01: Provider-neutral task change-request link coordinator
## Summary
Add the backend MCP link contract for GitHub pull requests and GitLab merge
requests behind one provider-neutral coordinator: request validation,
workspace-scoped resolution, provider dispatch, and the resulting canonical
linked set returned to the caller.
## In scope
- `apps/backend/internal/mcp/handlers/task_change_link.go` and its tests.
- `apps/backend/internal/backendapp/task_change_link_coordinator.go` and its
tests.
- Provider service entry points used by the coordinator in
`apps/backend/internal/github` and `apps/backend/internal/gitlab`.
- GitLab unlink deletion-event publication consumed by
`apps/backend/pkg/websocket/actions.go`.
## Out of scope
- Frontend rendering of the linked set beyond existing provider stores.
- Automation redesign or workflow moves.
- Direct database editing outside product migrations.
## Acceptance
- A bare pull-request or merge-request number is rejected before any store
mutation, and fork versus canonical same-number identities stay distinct.
- A failed replacement removes the newly created association and reports both
failures without leaving two active links.
- Unlinks emit the provider deletion event after persistence succeeds.
## Verification
```bash
cd apps/backend && go test ./internal/mcp/handlers ./internal/mcp/server ./internal/backendapp ./internal/github ./internal/gitlab ./internal/task/dto ./pkg/api/v1
```
## Results
Implemented and verified on branch `feature/manage-task-pr-and-m-e6i` at
`b1ada7b95dd471752799fbbe78ed6ce8b2794564`. Focused coordinator, handler,
server, GitHub, and GitLab suites passed, including race-enabled focused
identity, authorization, idempotency, rollback, detach, and projection tests.
+23
View File
@@ -232,6 +232,29 @@ The same data is available directly over REST for scripting: `GET /api/v1/worksp
Kandev automatically injects a task-aware MCP server into supported agent sessions. You do not need to add it to the profile. It lets the active agent use current IDs and structured operations instead of inferring board state from text.
### Link an existing pull or merge request
Task MCP provides `link_task_pr_kandev`, `unlink_task_pr_kandev`, and
`replace_task_pr_kandev` for GitHub pull requests and GitLab merge requests.
Each request needs `task_id`, `provider` (`github` or `gitlab`), the canonical
`repository_id`, and a positive request number. A number by itself is rejected,
so a fork and its canonical repository can safely have the same number.
For example, link GitLab merge request 42 to its target task with
`{ "task_id": "…", "provider": "gitlab", "repository_id": "…", "number": 42 }`.
`replace_task_pr_kandev` also requires `old_provider`, `old_repository_id`, and
`old_number`. Replacement supports associations from the same provider only;
to switch between GitHub and GitLab, unlink the current association and then
link the new one. Every successful mutation returns the resulting active link set.
The target task must be reachable from the calling task's workspace.
`list_tasks_kandev` and `list_related_tasks_kandev` expose active GitHub PR and
GitLab MR associations in the provider-neutral `change_requests` field; the
legacy `prs` field remains GitHub-only for compatibility.
Unlinking changes only the active association and its matching automation
state. It does not delete conversation history, terminal receipts, or the
upstream pull request or merge request.
Names ending in `_kandev` are the canonical MCP protocol tool names. Some agent clients show or register a server-qualified alias instead. For example, a client may expose canonical `step_complete_kandev` as `mcp__kandev__step_complete_kandev`. That qualified form is client-specific, not a second tool or a universal name; use the form exposed by the active client.
Task tools use normal client discovery. When `step_complete_kandev` is required but is not already visible, the agent should search the active tool catalog for its canonical name. Kandev does not request eager loading through client-specific metadata.
+9 -2
View File
@@ -444,7 +444,9 @@
"apps/backend/internal/github/app_registration_lifecycle_service.go",
"apps/backend/internal/github/app_registration_import.go",
"apps/web/lib/api/domains/github-auth-api.ts",
"apps/backend/internal/mcp/server/server.go"
"apps/backend/internal/mcp/server/server.go",
"apps/backend/internal/mcp/handlers/task_change_link.go",
"apps/backend/internal/backendapp/task_change_link_coordinator.go"
],
"tests": [
"apps/web/components/app-sidebar/sections/integrations-section.test.tsx",
@@ -454,7 +456,9 @@
"apps/backend/internal/github/controller_app_registration_test.go",
"apps/backend/internal/github/app_registration_import_test.go",
"apps/web/e2e/tests/settings/github-app-registration.spec.ts",
"apps/backend/internal/mcp/server/handlers_test.go"
"apps/backend/internal/mcp/server/handlers_test.go",
"apps/backend/internal/mcp/handlers/task_change_link_test.go",
"apps/backend/internal/backendapp/task_change_link_coordinator_test.go"
],
"settingsRoutes": [
"/settings/integrations",
@@ -468,6 +472,9 @@
"mcpTools": [
"get_task_mr_automation_kandev",
"get_task_pr_automation_kandev",
"link_task_pr_kandev",
"replace_task_pr_kandev",
"unlink_task_pr_kandev",
"report_pr_auto_fix_outcome_kandev",
"update_task_mr_automation_kandev",
"update_task_pr_automation_kandev"
@@ -0,0 +1,79 @@
---
status: active
system: integrations
created: 2026-09-14
owners:
- kandev
---
# Manage Task Pull Requests and Merge Requests Requirements
## Overview
Agents coordinating task delivery need to attach, swap, or remove the external
change request (a GitHub pull request or a GitLab merge request) that carries
the task's delivery after a task already exists. The MCP surface exposes three
provider-neutral operations so an agent can state intent without learning
provider-specific store mechanics, and every mutation answers with the
resulting canonical linked set so the caller can verify immediately.
This requirement is owned by the integration system because the contract
spans both provider stacks and one shared task-facing tool surface, while the
task system owns the task identity and the workspace system owns repository
attachment.
## Terminology
- **Change request (CR):** A GitHub pull request or a GitLab merge request.
- **Canonical repository identity:** The persisted repository identity
(`repository_id`, resolved through the task's workspace) that distinguishes
a fork from the canonical repository even when both contain the same
change-request number.
- **Active association:** The stored link between the task and one
provider-specific change request; historical receipts (terminal transcripts,
conversation history) are unaffected by unlinking.
## Requirements
### REQ-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001: Manage task change requests through MCP
**Intent:** Give agents safe, verifiable control over the external delivery
record of an existing task.
**User story:** As an agent coordinating a task, I want to link, unlink, or
replace a GitHub pull request or GitLab merge request on that task through
MCP, so that the task's delivery trail points at the real change request
without me editing task records by hand.
#### Acceptance criteria
- **AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.1:** When a mutation request is
authorized for the calling task's workspace and supplies the task ID,
provider (`github` or `gitlab`), canonical repository identity, and the
change-request number, the system applies the link, unlink, or replacement
and returns the resulting canonical linked set in the same response.
A bare number without repository identity is rejected, and same-number
change requests in a fork and a canonical repository remain distinct
associations.
- **AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.2:** When a caller requests an
operation for a task outside its reachable workspace set, the system
rejects the request without mutating any store.
- **AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.3:** When a link targets an
already-active association, the system succeeds without duplicating it;
when an unlink targets an absent association, the system succeeds.
Unlink removes only automation and settings scoped to that exact
provider, repository, and number, and leaves other linked change requests
and task metadata untouched.
- **AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.4:** When a replacement's new
target cannot be established, the previous association remains active; when
the established new association also cannot be removed during rollback, the
response reports both failures so the caller can reconcile the partial
state.
- **AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.5:** When a repository cannot be
resolved to a verified provider host (including a persisted identity with
no provider host), link operations fail closed instead of assuming a
default provider host.
- **AC-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001.6:** When a mutation succeeds,
query surfaces and connected clients observe the new linked state after a
restart, and unlinking changes only the active association, never
conversation or terminal-receipt history.
@@ -0,0 +1,91 @@
---
status: current
system: integrations
requirements:
- REQ-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001
created: 2026-09-14
owners:
- kandev
---
# Manage task change requests through MCP System Design
## Purpose and boundaries
This design gives agents one provider-neutral MCP surface for managing a
task's GitHub pull request and GitLab merge request associations. The MCP
tool surface and the task-facing handler are owned here; provider stores,
webhook discovery, and automation handling stay owned by their existing
GitHub and GitLab designs, and task identity, workspace reach authorization,
and list/serialization surfaces stay owned by the task system.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-INTEGRATIONS-TASK-CHANGE-LINK-MCP-001` | [Components and responsibilities](#components-and-responsibilities), [Control flow](#control-flow) |
## Components and responsibilities
- `apps/backend/internal/mcp/handlers/task_change_link.go` validates the
request shape, binds the caller task identity through the ordinary task
reach path, and forwards one provider-neutral request to the coordinator.
- `apps/backend/internal/backendapp/task_change_link_coordinator.go`
resolves the canonical repository from the task's workspace, rejects
unknown or unverified provider hosts, dispatches to the provider service,
and returns the resulting linked set. Replacement compensates a failed
post-establishment exchange by removing the newly created association and
reports both errors when compensation itself fails.
- `apps/backend/internal/github` and `apps/backend/internal/gitlab` own
association persistence, idempotent link/removal, and exact automation
retirement scoped to one provider, repository, and change-request number.
GitHub detach keeps durable tombstones; GitLab unlink publishes a
workspace-scoped deletion event after persistence.
- `apps/backend/internal/mcp/server/server.go` registers
`link_task_pr_kandev`, `unlink_task_pr_kandev`, and
`replace_task_pr_kandev` with provider-aware availability.
## Data and contracts
Every mutation requires explicit `task_id`, a provider discriminator
(`github` or `gitlab`), the canonical repository identity (`repository_id`),
and the change-request number. A bare number is invalid. Replacement adds the
old association triple (`old_provider`, `old_repository_id`,
`old_number`). Responses return the current canonical linked set for the
task.
Unlinking changes only the active association row. Conversation history and
terminal receipts remain untouched, and re-linking the same identity restores
an active association.
## Control flow
1. The MCP server receives the tool call and resolves the caller task.
2. The handler authorizes workspace reach for that task.
3. The handler validates provider, repository identity, and number, rejecting
a bare number or unresolved repository before any store mutation.
4. The coordinator loads the task's repositories, verifies the requested
canonical repository belongs to the task workspace and carries a verified
provider host, and dispatches link, unlink, or replace.
5. The provider service applies idempotent persistence, scoped automation
cleanup, and deletion-event publication.
6. The coordinator assembles the resulting linked set and the handler returns
it. List/query surfaces read the same stores, so no extra invalidation is
required for restart-consistent state.
## Examples
```json
{
"task_id": "<task-id>",
"provider": "github",
"repository_id": "<repository-id>",
"number": 3506
}
```
`link_task_pr_kandev` and `unlink_task_pr_kandev` accept this shape;
`replace_task_pr_kandev` additionally requires the old association triple
(`old_provider`, `old_repository_id`, `old_number`). See
[docs/public/automation-and-mcp.md](../../../public/automation-and-mcp.md)
for the operator-facing contract.