feat(mcp): include associated PRs in task-listing tools (#1236)

This commit is contained in:
Carlos Florêncio
2026-06-02 16:46:50 +01:00
committed by GitHub
parent a1143aa525
commit 2f12c680b8
11 changed files with 392 additions and 12 deletions
+45
View File
@@ -895,6 +895,45 @@ func (a githubRateLimitAdapter) ExhaustedRateLimits() []health.GitHubRateLimitSt
return out
}
// mcpTaskPRListerAdapter adapts *github.Service to the MCP handlers'
// TaskPRLister interface so list_tasks responses can carry per-task PR
// summaries. Returns an empty map when the github service is nil.
type mcpTaskPRListerAdapter struct {
gh *github.Service
}
func (a mcpTaskPRListerAdapter) ListTaskPRsByTaskIDs(
ctx context.Context, taskIDs []string,
) (map[string][]mcphandlers.TaskPRInfo, error) {
out := make(map[string][]mcphandlers.TaskPRInfo)
if a.gh == nil || len(taskIDs) == 0 {
return out, nil
}
prs, err := a.gh.ListTaskPRsByTaskIDs(ctx, taskIDs)
if err != nil {
return nil, err
}
for taskID, list := range prs {
infos := make([]mcphandlers.TaskPRInfo, 0, len(list))
for _, pr := range list {
if pr == nil {
continue
}
infos = append(infos, mcphandlers.TaskPRInfo{
Number: pr.PRNumber,
URL: pr.PRURL,
Title: pr.PRTitle,
State: pr.State,
MergedAt: pr.MergedAt,
})
}
if len(infos) > 0 {
out[taskID] = infos
}
}
return out, nil
}
// registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler.
func registerMCPAndDebugRoutes(
p routeParams,
@@ -911,6 +950,12 @@ func registerMCPAndDebugRoutes(
// Wire config-mode dependencies for agent-native configuration
mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc)
// Enrich list_tasks responses with associated GitHub PRs (link, title,
// number, state) when the github service is available.
if p.services.GitHub != nil {
mcpHandlers.SetTaskPRLister(mcpTaskPRListerAdapter{gh: p.services.GitHub})
}
// 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.
@@ -110,6 +110,10 @@ type Handlers struct {
// Wires the list_related_tasks_kandev / *_task_document_kandev
// MCP tools introduced in office task handoffs phase 2.
handoffSvc *service.HandoffService
// Optional PR lister (set via SetTaskPRLister) used to enrich
// task-listing responses with associated pull requests.
taskPRLister TaskPRLister
}
// NewHandlers creates new MCP handlers.
@@ -352,6 +356,7 @@ func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Me
for _, t := range tasks {
dtos = append(dtos, dto.FromTask(t))
}
h.enrichTasksWithPRs(ctx, dtos)
return dto.ListTasksResponse{Tasks: dtos, Total: len(dtos)}, nil
})
}
@@ -40,6 +40,7 @@ func (h *Handlers) handleListRelatedTasks(ctx context.Context, msg *ws.Message)
h.logger.Error("list related tasks", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
h.enrichRelatedTasksWithPRs(ctx, related)
return ws.NewResponse(msg.ID, msg.Action, related)
}
@@ -0,0 +1,129 @@
package handlers
import (
"context"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// 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
}
// TaskPRLister returns PR associations grouped by task ID. Backed by
// *github.Service in production; left nil in contexts without GitHub, in which
// case PRs are simply omitted from task responses.
type TaskPRLister interface {
ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]TaskPRInfo, 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
}
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
}
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{
Number: pr.Number,
URL: pr.URL,
Title: pr.Title,
State: pr.State,
MergedAt: pr.MergedAt,
})
}
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) enrichTasksWithPRs(ctx context.Context, tasks []dto.TaskDTO) {
if h.taskPRLister == nil || len(tasks) == 0 {
return
}
ids := make([]string, len(tasks))
for i := range tasks {
ids[i] = tasks[i].ID
}
byTask := h.prsByTaskID(ctx, ids)
for i := range tasks {
if prs := byTask[tasks[i].ID]; len(prs) > 0 {
tasks[i].PRs = prs
}
}
}
// 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.
func (h *Handlers) enrichRelatedTasksWithPRs(ctx context.Context, related *service.RelatedTasks) {
if h.taskPRLister == nil || related == nil {
return
}
nodes := []*service.RelatedTask{&related.Task, related.Parent}
nodes = append(nodes, related.Children...)
nodes = append(nodes, related.Siblings...)
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 {
if n == nil {
continue
}
if _, dup := seen[n.ID]; dup {
continue
}
seen[n.ID] = struct{}{}
ids = append(ids, n.ID)
}
byTask := h.prsByTaskID(ctx, ids)
if byTask == nil {
return
}
for _, n := range nodes {
if n == nil {
continue
}
if prs := byTask[n.ID]; len(prs) > 0 {
n.PRs = prs
}
}
}
@@ -0,0 +1,179 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
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"
)
type fakeTaskPRLister struct {
byTask map[string][]TaskPRInfo
err error
gotIDs []string
}
func (f *fakeTaskPRLister) ListTaskPRsByTaskIDs(_ context.Context, taskIDs []string) (map[string][]TaskPRInfo, error) {
f.gotIDs = taskIDs
if f.err != nil {
return nil, f.err
}
return f.byTask, nil
}
func TestEnrichTasksWithPRs(t *testing.T) {
lister := &fakeTaskPRLister{
byTask: map[string][]TaskPRInfo{
"task-1": {
{Number: 42, URL: "https://github.com/o/r/pull/42", Title: "Fix bug", State: "merged"},
{Number: 43, URL: "https://github.com/o/r/pull/43", Title: "Add feature", State: "open"},
},
},
}
h := &Handlers{taskPRLister: 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)
require.Len(t, dtos[0].PRs, 2)
assert.Equal(t, v1.TaskPRSummary{
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)
assert.Nil(t, dtos[1].PRs, "tasks without PRs stay nil")
}
func TestEnrichTasksWithPRs_NilListerIsNoop(t *testing.T) {
h := &Handlers{logger: testLogger(t).WithFields()}
dtos := []dto.TaskDTO{{ID: "task-1"}}
h.enrichTasksWithPRs(context.Background(), dtos)
assert.Nil(t, dtos[0].PRs)
}
func TestHandleListTasks_IncludesAssociatedPRs(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{
ID: "ws-pr", Name: "PR WS", CreatedAt: now, UpdatedAt: now,
}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{
ID: "task-pr", WorkspaceID: "ws-pr", WorkflowID: "wf-pr",
Title: "Has a PR", State: v1.TaskStateReview, CreatedAt: now, UpdatedAt: now,
}))
h := &Handlers{
taskSvc: svc,
logger: testLogger(t).WithFields(),
taskPRLister: &fakeTaskPRLister{byTask: map[string][]TaskPRInfo{
"task-pr": {{Number: 7, URL: "https://github.com/o/r/pull/7", Title: "Ship it", State: "merged"}},
}},
}
msg := makeWSMessage(t, ws.ActionMCPListTasks, map[string]any{"workflow_id": "wf-pr"})
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.Len(t, payload.Tasks[0].PRs, 1)
assert.Equal(t, "merged", payload.Tasks[0].PRs[0].State)
assert.Equal(t, 7, payload.Tasks[0].PRs[0].Number)
assert.Equal(t, "https://github.com/o/r/pull/7", payload.Tasks[0].PRs[0].URL)
}
func TestHandleListRelatedTasks_IncludesAssociatedPRs(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", Name: "Rel WS", CreatedAt: now, UpdatedAt: now,
}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{
ID: "task-rel", WorkspaceID: "ws-rel", WorkflowID: "wf-rel",
Title: "Self", State: v1.TaskStateReview, CreatedAt: now, UpdatedAt: now,
}))
merged := now
h := &Handlers{
taskSvc: svc,
handoffSvc: service.NewHandoffService(repo, nil, nil, nil, nil, testLogger(t)),
logger: testLogger(t).WithFields(),
taskPRLister: &fakeTaskPRLister{byTask: map[string][]TaskPRInfo{
"task-rel": {{Number: 99, URL: "https://github.com/o/r/pull/99", Title: "Fix", State: "merged", MergedAt: &merged}},
}},
}
msg := makeWSMessage(t, ws.ActionMCPListRelatedTasks, map[string]any{"task_id": "task-rel"})
resp, err := h.handleListRelatedTasks(ctx, msg)
require.NoError(t, err)
var payload service.RelatedTasks
require.NoError(t, json.Unmarshal(resp.Payload, &payload))
require.Len(t, payload.Task.PRs, 1)
assert.Equal(t, 99, payload.Task.PRs[0].Number)
assert.Equal(t, "merged", payload.Task.PRs[0].State)
require.NotNil(t, payload.Task.PRs[0].MergedAt, "merged_at should be surfaced")
}
func TestEnrichRelatedTasksWithPRs(t *testing.T) {
lister := &fakeTaskPRLister{byTask: map[string][]TaskPRInfo{
"self": {{Number: 1, URL: "u1", State: "open"}},
"parent": {{Number: 3, URL: "u3", State: "closed"}},
"child": {{Number: 2, URL: "u2", State: "merged"}},
}}
h := &Handlers{taskPRLister: lister, logger: testLogger(t).WithFields()}
related := &service.RelatedTasks{
Task: service.RelatedTask{ID: "self"},
Parent: &service.RelatedTask{ID: "parent"},
Children: []*service.RelatedTask{{ID: "child"}, {ID: "child-no-pr"}},
Siblings: []*service.RelatedTask{{ID: "sibling"}},
BlockedBy: []*service.RelatedTask{{ID: "blocker"}},
}
h.enrichRelatedTasksWithPRs(context.Background(), related)
assert.ElementsMatch(t,
[]string{"self", "parent", "child", "child-no-pr", "sibling", "blocker"},
lister.gotIDs)
require.Len(t, related.Task.PRs, 1)
assert.Equal(t, "open", related.Task.PRs[0].State)
require.Len(t, related.Parent.PRs, 1)
assert.Equal(t, "closed", related.Parent.PRs[0].State)
require.Len(t, related.Children[0].PRs, 1)
assert.Equal(t, "merged", related.Children[0].PRs[0].State)
assert.Nil(t, related.Children[1].PRs, "child without PRs stays nil")
assert.Nil(t, related.Siblings[0].PRs)
}
func TestEnrichRelatedTasksWithPRs_NilSafe(t *testing.T) {
// nil lister: no-op, no panic.
h := &Handlers{logger: testLogger(t).WithFields()}
h.enrichRelatedTasksWithPRs(context.Background(), &service.RelatedTasks{})
// lister set but nil related: no panic.
h2 := &Handlers{taskPRLister: &fakeTaskPRLister{}, logger: testLogger(t).WithFields()}
h2.enrichRelatedTasksWithPRs(context.Background(), nil)
}
func TestEnrichTasksWithPRs_ListerErrorIsSwallowed(t *testing.T) {
h := &Handlers{
taskPRLister: &fakeTaskPRLister{err: errors.New("boom")},
logger: testLogger(t).WithFields(),
}
dtos := []dto.TaskDTO{{ID: "task-1"}}
h.enrichTasksWithPRs(context.Background(), dtos)
assert.Nil(t, dtos[0].PRs, "PRs left empty when the lister errors")
}
@@ -255,7 +255,7 @@ func (s *Server) registerConfigExecutorTools() {
func (s *Server) registerConfigTaskTools() {
s.mcpServer.AddTool(
mcp.NewTool("list_tasks_kandev",
mcp.WithDescription("List all tasks in a workflow."),
mcp.WithDescription("List all tasks in a workflow. Each task includes its associated GitHub pull requests (number, url, title, state) under the \"prs\" field when any exist — use the PR state (open/closed/merged) to find tasks whose work has landed."),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("The workflow ID")),
),
s.wrapHandler("list_tasks_kandev", s.listTasksHandler()),
@@ -18,8 +18,9 @@ func (s *Server) registerRelatedTasksTool() {
mcp.NewTool("list_related_tasks_kandev",
mcp.WithDescription(
`List parent, children, siblings, blockers, and blocked tasks for the current task.
Use this to discover task IDs you can reach via message_task_kandev. In office mode, document keys are
also included so you can fetch documents with get_task_document_kandev.
Use this to discover task IDs you can reach via message_task_kandev. Each related task includes its
associated GitHub pull requests (number, url, title, state) under the "prs" field when any exist. In
office mode, document keys are also included so you can fetch documents with get_task_document_kandev.
Pass task_id to inspect a different task in the same workspace.`,
),
mcp.WithString("task_id", mcp.Description("Defaults to the current task.")),
+1 -1
View File
@@ -392,7 +392,7 @@ func (s *Server) registerKanbanTools() {
)
s.mcpServer.AddTool(
mcp.NewTool("list_tasks_kandev",
mcp.WithDescription("List all tasks in a workflow."),
mcp.WithDescription("List all tasks in a workflow. Each task includes its associated GitHub pull requests (number, url, title, state) under the \"prs\" field when any exist — use the PR state (open/closed/merged) to find tasks whose work has landed."),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("The workflow ID")),
),
s.wrapHandler("list_tasks_kandev", s.listTasksHandler()),
+6
View File
@@ -157,6 +157,12 @@ type TaskDTO struct {
// kanban-board tasks. Use this to gate office-only UI (e.g. the
// "Open in office view" topbar link).
IsFromOffice bool `json:"is_from_office,omitempty"`
// PRs lists the GitHub pull requests associated with this task, when the
// github service is wired and any association exists. Surfaced through the
// 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"`
}
type TaskRepositoryDTO struct {
@@ -11,6 +11,7 @@ import (
orchmodels "github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// Workspace-mode and ordering constants used across handoff plumbing.
@@ -137,14 +138,15 @@ func (p WorkspacePolicy) NeedsAttachment() bool {
// list_related_tasks_kandev MCP tool. Document keys are precomputed so an
// agent can decide what to fetch without a follow-up list call.
type RelatedTask struct {
ID string `json:"id"`
Identifier string `json:"identifier,omitempty"`
Title string `json:"title"`
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"`
ID string `json:"id"`
Identifier string `json:"identifier,omitempty"`
Title string `json:"title"`
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"`
}
// RelatedTasks bundles every relation surface for a single task.
+12
View File
@@ -18,6 +18,18 @@ const (
TaskStateCancelled TaskState = "CANCELLED"
)
// 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.
type TaskPRSummary struct {
Number int `json:"number"`
URL string `json:"url"`
Title string `json:"title,omitempty"`
State string `json:"state"`
MergedAt *time.Time `json:"merged_at,omitempty"`
}
// TaskSessionState represents the state of an agent session.
type TaskSessionState string