feat(plugins): expose archived tasks, pull requests and board fields to plugins (#3044)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-08-26 15:56:30 +00:00
committed by GitHub
parent a6896bca9a
commit 2209734a09
12 changed files with 1602 additions and 495 deletions
@@ -232,6 +232,12 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
// build passes "dev", which the service treats as "don't enforce".
pluginsSvc.SetKandevVersion(version)
pluginsSvc.SetDataSources(taskSvc, taskSvc, workflowSvc, agentSettingsController, analyticsservice.New(repos.Analytics), taskSvc, taskSvc, pluginsTaskWriterAdapter{svc: taskSvc})
// Separate from SetDataSources: githubSvc is optional (nil when github
// is unconfigured), and a nil source leaves tasks with no PullRequests
// rather than failing every task read.
if githubSvc != nil {
pluginsSvc.SetTaskPRSource(githubSvc)
}
}
gitCredentialBroker := newGitCredentialBroker(githubSvc, pluginsSvc, repos.Task, cfg.GitHubCredentialBroker.ReissueSigningKey)
if pluginsSvc != nil {
+4
View File
@@ -68,6 +68,10 @@ type pluginHost struct {
sessionCodeStats sessionCodeStatsSource
messageData messageDataSource
interactionData interactionDataSource
taskPRs taskPRSource
// taskPRsDep resolves the source at read time so a host created before
// SetTaskPRSource still observes the late wiring.
taskPRsDep func() taskPRSource
// taskWriter backs the CreateTask/UpdateTask write RPCs (ADR 0043
// phase 2, capability api_write:tasks). Wired via SetDataSources like the
+51 -3
View File
@@ -28,6 +28,7 @@ import (
analyticsmodels "github.com/kandev/kandev/internal/analytics/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
githubsvc "github.com/kandev/kandev/internal/github"
taskmodels "github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/pkg/pluginsdk"
@@ -164,6 +165,19 @@ type agentProfileDataSource interface {
ListAgents(ctx context.Context) (*agentsettingsdto.ListAgentsResponse, error)
}
// taskPRSource is the narrow slice of internal/github.Service the Tasks reader
// needs to populate Task.PullRequests.
//
// SEPARATE FROM taskDataSource ON PURPOSE. A task's pull requests live in
// github_task_prs, owned by internal/github, not on the task row — so the link
// is not derivable from anything taskDataSource returns. It is wired separately
// (SetTaskPRSource) because GitHub is optional and may be available after a
// host is created; a nil source simply yields tasks with no PullRequests rather
// than failing the read.
type taskPRSource interface {
ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*githubsvc.TaskPR, error)
}
// sessionCodeStatsSource is the narrow slice of
// internal/analytics/service.Service the Sessions().CodeStats RPC needs.
type sessionCodeStatsSource interface {
@@ -339,16 +353,49 @@ func (r taskReader) List(ctx context.Context, filter pluginsdk.TaskFilter, page
if err != nil {
return nil, nil, err
}
tasks, err := r.host.fetchTasksForWorkspaces(ctx, workspaceIDs, filter.IncludeEphemeral, false)
tasks, err := r.host.fetchTasksForWorkspaces(ctx, workspaceIDs, filter.IncludeEphemeral, filter.IncludeArchived)
if err != nil {
return nil, nil, err
}
tasks = filterTasks(tasks, filter)
sortTasksNewestFirst(tasks)
items, info := paginate(tasksToDTOs(tasks), page)
// Attached AFTER pagination so the PR lookup covers one page, not the whole
// workspace: a campaign-sized read would otherwise fan out over every task
// kandev holds to fill a list the caller already bounded.
r.host.attachPullRequests(ctx, items)
return items, info, nil
}
// attachPullRequests fills Task.PullRequests in place. A missing source or a
// failed lookup leaves them empty and logs nothing: pull requests are
// supplementary to a task read, and refusing the whole list over them would
// take away more than it protects.
func (h *pluginHost) attachPullRequests(ctx context.Context, tasks []pluginsdk.Task) {
source := h.taskPRs
if h.taskPRsDep != nil {
source = h.taskPRsDep()
}
if source == nil || len(tasks) == 0 {
return
}
ids := make([]string, 0, len(tasks))
for i := range tasks {
ids = append(ids, tasks[i].ID)
}
byTask, err := source.ListTaskPRsByTaskIDs(ctx, ids)
if err != nil {
return
}
for i := range tasks {
prs := byTask[tasks[i].ID]
if len(prs) == 0 {
continue
}
tasks[i].PullRequests = taskPRsToDTOs(prs)
}
}
// Get returns a gRPC NotFound error (not a (nil, nil) success) when id
// doesn't resolve to a task, so the in-process contract matches exactly what
// a real plugin observes over the wire via grpcHostServer.GetTask.
@@ -366,8 +413,9 @@ func (r taskReader) Get(ctx context.Context, id string) (*pluginsdk.Task, error)
}
return nil, err
}
dto := taskModelToDTO(task)
return &dto, nil
items := []pluginsdk.Task{taskModelToDTO(task)}
r.host.attachPullRequests(ctx, items)
return &items[0], nil
}
type sessionReader struct{ host *pluginHost }
@@ -2,11 +2,15 @@ package plugins
import (
"context"
"encoding/json"
"net/url"
"sort"
"strings"
"time"
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
analyticsmodels "github.com/kandev/kandev/internal/analytics/models"
githubsvc "github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/sysprompt"
taskmodels "github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
@@ -79,14 +83,6 @@ func sessionSnapshotModel(snapshot map[string]any) string {
return ""
}
func timePtrToRFC3339(t *time.Time) *string {
if t == nil {
return nil
}
s := t.UTC().Format(time.RFC3339)
return &s
}
func stringPtrOrNil(s string) *string {
if s == "" {
return nil
@@ -128,9 +124,92 @@ func taskModelToDTO(t *taskmodels.Task) pluginsdk.Task {
IsEphemeral: t.IsEphemeral,
Repositories: repos,
Metadata: t.Metadata,
ArchivedAt: timePtrToRFC3339(t.ArchivedAt),
WorkflowStepID: t.WorkflowStepID,
Position: int32(t.Position),
AssigneeAgentProfileID: t.AssigneeAgentProfileID,
Labels: decodeTaskLabels(t.Labels),
Autopilot: t.Autopilot,
WIPAdmitted: t.WIPAdmitted,
QueuedForStepID: t.QueuedForStepID,
QueuedAt: timePtrToRFC3339(t.QueuedAt),
ProjectID: t.ProjectID,
ExternalID: t.ExternalID,
}
}
func timePtrToRFC3339(t *time.Time) *string {
if t == nil {
return nil
}
s := t.UTC().Format(time.RFC3339)
return &s
}
// decodeTaskLabels turns the model's JSON-array-string into a real slice.
//
// A MALFORMED VALUE YIELDS NO LABELS, NOT AN ERROR. Labels are decoration on a
// read that must not fail because one row holds something unparseable; the
// alternative is a whole task list refused over a cosmetic field.
func decodeTaskLabels(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return nil
}
var out []string
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil
}
return out
}
// taskPRsToDTOs maps kandev's own PR rows onto the plugin contract. Review and
// check state come along because kandev's PR watcher already syncs them: a
// plugin asking "what can merge" would otherwise re-query the forge per pull
// request, which is slower and rate-limited.
func taskPRsToDTOs(prs []*githubsvc.TaskPR) []pluginsdk.TaskPullRequest {
ordered := make([]*githubsvc.TaskPR, 0, len(prs))
for _, pr := range prs {
if pr != nil {
ordered = append(ordered, pr)
}
}
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].CreatedAt.After(ordered[j].CreatedAt)
})
out := make([]pluginsdk.TaskPullRequest, 0, len(ordered))
for _, pr := range ordered {
out = append(out, pluginsdk.TaskPullRequest{
Number: int64(pr.PRNumber),
URL: pr.PRURL,
Title: pr.PRTitle,
State: pr.State,
HeadBranch: pr.HeadBranch,
BaseBranch: pr.BaseBranch,
// IsDraft is nullable on the row (unknown before the first sync);
// nil reads as not-draft, matching how kandev's own UI treats it.
IsDraft: pr.IsDraft != nil && *pr.IsDraft,
// Only github writes these rows today; the field exists so a future
// gitlab-backed row is distinguishable rather than mislabelled.
Provider: "github",
MergedAt: timePtrToRFC3339(pr.MergedAt),
ClosedAt: timePtrToRFC3339(pr.ClosedAt),
ReviewState: pr.ReviewState,
ChecksState: pr.ChecksState,
MergeableState: pr.MergeableState,
UnresolvedReviewThreads: int32(pr.UnresolvedReviewThreads),
ChecksTotal: int32(pr.ChecksTotal),
ChecksPassing: int32(pr.ChecksPassing),
Additions: int32(pr.Additions),
Deletions: int32(pr.Deletions),
AuthorLogin: pr.AuthorLogin,
})
}
return out
}
func workspaceModelToDTO(w *taskmodels.Workspace) pluginsdk.Workspace {
return pluginsdk.Workspace{
ID: w.ID,
@@ -158,11 +237,15 @@ func workflowModelToDTO(w *taskmodels.Workflow) pluginsdk.Workflow {
func workflowStepModelToDTO(s *wfmodels.WorkflowStep) pluginsdk.WorkflowStep {
return pluginsdk.WorkflowStep{
ID: s.ID,
WorkflowID: s.WorkflowID,
Name: s.Name,
Position: int32(s.Position),
StageType: string(s.StageType),
ID: s.ID,
WorkflowID: s.WorkflowID,
Name: s.Name,
Position: int32(s.Position),
StageType: string(s.StageType),
Color: s.Color,
IsStartStep: s.IsStartStep,
WIPLimit: int32(s.WIPLimit),
AgentProfileID: s.AgentProfileID,
}
}
@@ -0,0 +1,240 @@
package plugins
import (
"context"
"testing"
"time"
githubsvc "github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/pkg/pluginsdk"
"github.com/stretchr/testify/require"
)
// The step a task stands on, its labels, and whether it is archived were all
// unreadable from a plugin before this. A plugin could see a task's State
// (created / in progress / review / completed) but not WHERE on the board it
// was, so it could not render or group by a workflow at all.
func TestTaskModelToDTOCarriesBoardAndPlanningFields(t *testing.T) {
archived := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
queued := time.Date(2026, 8, 19, 9, 0, 0, 0, time.UTC)
task := &taskmodels.Task{
ID: "t1",
WorkflowStepID: "step-pr-review",
Position: 3,
AssigneeAgentProfileID: "agent-opus",
Labels: `["office","unblock"]`,
Autopilot: true,
WIPAdmitted: true,
QueuedForStepID: "step-build",
QueuedAt: &queued,
ProjectID: "proj-1",
ExternalID: "WO-101",
ArchivedAt: &archived,
}
dto := taskModelToDTO(task)
require.Equal(t, "step-pr-review", dto.WorkflowStepID)
require.Equal(t, int32(3), dto.Position)
require.Equal(t, "agent-opus", dto.AssigneeAgentProfileID)
require.Equal(t, []string{"office", "unblock"}, dto.Labels)
require.True(t, dto.Autopilot)
require.True(t, dto.WIPAdmitted)
require.Equal(t, "step-build", dto.QueuedForStepID)
require.NotNil(t, dto.QueuedAt)
require.Equal(t, "2026-08-19T09:00:00Z", *dto.QueuedAt)
require.Equal(t, "proj-1", dto.ProjectID)
require.Equal(t, "WO-101", dto.ExternalID)
require.NotNil(t, dto.ArchivedAt)
require.Equal(t, "2026-08-20T10:00:00Z", *dto.ArchivedAt)
}
// A live task must report no archive timestamp at all, not a zero time — the
// difference is exactly how a plugin tells delivered work from in-flight work.
func TestTaskModelToDTOLeavesArchivedAtNilForALiveTask(t *testing.T) {
dto := taskModelToDTO(&taskmodels.Task{ID: "t1"})
require.Nil(t, dto.ArchivedAt)
require.Nil(t, dto.QueuedAt)
}
// Labels are a JSON-array string on the model. A row holding something
// unparseable must cost that row its labels, never the whole task read.
func TestDecodeTaskLabelsToleratesEmptyAndMalformedValues(t *testing.T) {
require.Equal(t, []string{"a", "b"}, decodeTaskLabels(`["a","b"]`))
require.Nil(t, decodeTaskLabels(""))
require.Nil(t, decodeTaskLabels("[]"))
require.Nil(t, decodeTaskLabels(" "))
require.Nil(t, decodeTaskLabels("not json"))
require.Nil(t, decodeTaskLabels(`{"a":1}`))
}
// Review and check state ride along because kandev's PR watcher already syncs
// them; a plugin asking "what can merge" would otherwise re-query the forge
// once per pull request.
func TestTaskPRsToDTOsCarriesReadinessNotJustIdentity(t *testing.T) {
merged := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC)
draft := true
prs := []*githubsvc.TaskPR{{
PRNumber: 3019, PRURL: "https://example/3019", PRTitle: "expose comments",
State: "open", HeadBranch: "feature/x", BaseBranch: "main",
IsDraft: &draft, AuthorLogin: "nova28",
ReviewState: "changes_requested", ChecksState: "failure", MergeableState: "blocked",
UnresolvedReviewThreads: 3, ChecksTotal: 12, ChecksPassing: 10,
Additions: 400, Deletions: 25, MergedAt: &merged,
}}
out := taskPRsToDTOs(prs)
require.Len(t, out, 1)
require.Equal(t, int64(3019), out[0].Number)
require.Equal(t, "github", out[0].Provider)
require.True(t, out[0].IsDraft)
require.Equal(t, "changes_requested", out[0].ReviewState)
require.Equal(t, "failure", out[0].ChecksState)
require.Equal(t, int32(3), out[0].UnresolvedReviewThreads)
require.Equal(t, int32(10), out[0].ChecksPassing)
require.NotNil(t, out[0].MergedAt)
require.Equal(t, "2026-08-21T12:00:00Z", *out[0].MergedAt)
}
func TestTaskPRsToDTOsReturnsNewestFirst(t *testing.T) {
prs := []*githubsvc.TaskPR{
{PRNumber: 100, PRTitle: "older", CreatedAt: time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC)},
{PRNumber: 200, PRTitle: "newer", CreatedAt: time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)},
}
out := taskPRsToDTOs(prs)
require.Len(t, out, 2)
require.Equal(t, int64(200), out[0].Number)
require.Equal(t, "newer", out[0].Title)
require.Equal(t, int64(100), out[1].Number)
}
// IsDraft is nullable on the row: unknown before the first sync. Nil must read
// as not-draft rather than panicking the whole task list.
func TestTaskPRsToDTOsTreatsAnUnknownDraftFlagAsNotDraft(t *testing.T) {
out := taskPRsToDTOs([]*githubsvc.TaskPR{{PRNumber: 1}, nil})
require.Len(t, out, 1, "a nil row is skipped, not mapped")
require.False(t, out[0].IsDraft)
}
type stubPRSource struct {
byTask map[string][]*githubsvc.TaskPR
err error
calls int
}
func (s *stubPRSource) ListTaskPRsByTaskIDs(_ context.Context, ids []string) (map[string][]*githubsvc.TaskPR, error) {
s.calls++
if s.err != nil {
return nil, s.err
}
return s.byTask, nil
}
func TestAttachPullRequestsFillsOnlyTheTasksThatHaveThem(t *testing.T) {
src := &stubPRSource{byTask: map[string][]*githubsvc.TaskPR{
"t1": {{PRNumber: 2907, State: "open"}},
}}
h := &pluginHost{taskPRs: src}
tasks := []pluginsdk.Task{{ID: "t1"}, {ID: "t2"}}
h.attachPullRequests(context.Background(), tasks)
require.Len(t, tasks[0].PullRequests, 1)
require.Equal(t, int64(2907), tasks[0].PullRequests[0].Number)
require.Empty(t, tasks[1].PullRequests)
require.Equal(t, 1, src.calls, "one batched lookup, not one per task")
}
// Pull requests are supplementary to a task read. Refusing the whole list when
// the lookup fails would take away more than it protects.
func TestAttachPullRequestsIsSilentWhenTheSourceIsMissingOrFailing(t *testing.T) {
tasks := []pluginsdk.Task{{ID: "t1"}}
(&pluginHost{}).attachPullRequests(context.Background(), tasks)
require.Empty(t, tasks[0].PullRequests)
failing := &stubPRSource{err: context.DeadlineExceeded}
(&pluginHost{taskPRs: failing}).attachPullRequests(context.Background(), tasks)
require.Empty(t, tasks[0].PullRequests)
require.Equal(t, 1, failing.calls)
}
func TestTaskReaderGetAttachesPullRequests(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
task := &taskmodels.Task{ID: "task-1", Title: "with a PR"}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task}
src := &stubPRSource{byTask: map[string][]*githubsvc.TaskPR{
"task-1": {{PRNumber: 42, PRTitle: "fix"}},
}}
d.host.taskPRs = src
got, err := d.host.Tasks().Get(context.Background(), "task-1")
require.NoError(t, err)
require.Len(t, got.PullRequests, 1)
require.Equal(t, int64(42), got.PullRequests[0].Number)
}
func TestAttachPullRequestsUsesSourceWiredAfterHostCreation(t *testing.T) {
var source taskPRSource
h := &pluginHost{taskPRsDep: func() taskPRSource { return source }}
tasks := []pluginsdk.Task{{ID: "task-1"}}
h.attachPullRequests(context.Background(), tasks)
require.Empty(t, tasks[0].PullRequests)
source = &stubPRSource{byTask: map[string][]*githubsvc.TaskPR{
"task-1": {{PRNumber: 99}},
}}
h.attachPullRequests(context.Background(), tasks)
require.Len(t, tasks[0].PullRequests, 1)
require.Equal(t, int64(99), tasks[0].PullRequests[0].Number)
}
// A board plugin reads the step's own colour rather than inventing a palette,
// so its columns match what the operator already sees in kandev.
func TestWorkflowStepModelToDTOCarriesPresentationAndCapacity(t *testing.T) {
step := wfmodels.WorkflowStep{
ID: "s1", Name: "PR Review", Position: 8, Color: "bg-indigo-500",
IsStartStep: true, WIPLimit: 3, AgentProfileID: "agent-1",
}
dto := workflowStepModelToDTO(&step)
require.Equal(t, "bg-indigo-500", dto.Color)
require.True(t, dto.IsStartStep)
require.Equal(t, int32(3), dto.WIPLimit)
require.Equal(t, "agent-1", dto.AgentProfileID)
}
// Archived tasks were unreachable from a plugin: the fetch hardcoded
// includeArchived=false and TaskFilter had no way to ask. A plugin reporting on
// delivery needs them — an archived task is usually a delivered one, so leaving
// them out makes finished work look like it never happened.
func TestTasksListHonoursIncludeArchivedFromTheFilter(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
_, _, err := d.host.Tasks().List(context.Background(),
pluginsdk.TaskFilter{IncludeArchived: true}, pluginsdk.Page{})
require.NoError(t, err)
require.Equal(t, []bool{true}, d.tasks.gotIncludeArchived)
}
// Not asking still means live tasks only, so a plugin written before the field
// existed keeps exactly the visibility it had.
func TestTasksListDefaultsToLiveTasksOnly(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
_, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
require.NoError(t, err)
require.Equal(t, []bool{false}, d.tasks.gotIncludeArchived)
}
+6 -3
View File
@@ -213,8 +213,9 @@ func (r taskReader) Update(ctx context.Context, in pluginsdk.UpdateTaskInput) (*
}
return nil, err
}
dto := taskModelToDTO(updated)
return &dto, nil
items := []pluginsdk.Task{taskModelToDTO(updated)}
r.host.attachPullRequests(ctx, items)
return &items[0], nil
}
// resolveCreatePlacement fills the workspace and workflow a created task lands
@@ -446,7 +447,9 @@ func (m pluginOwnedTaskTreeManager) Preview(ctx context.Context, rootTaskID stri
if err != nil {
return nil, err
}
return tasksToDTOs(tasks), nil
dtos := tasksToDTOs(tasks)
m.host.attachPullRequests(ctx, dtos)
return dtos, nil
}
func (m pluginOwnedTaskTreeManager) Delete(ctx context.Context, rootTaskID string) ([]string, error) {
@@ -6,6 +6,7 @@ import (
"testing"
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
githubsvc "github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
@@ -14,6 +15,23 @@ import (
"google.golang.org/grpc/status"
)
func TestPluginHost_Tasks_UpdateAttachesPullRequests(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "updated"}
d.host.taskPRs = &stubPRSource{byTask: map[string][]*githubsvc.TaskPR{
"task-1": {{PRNumber: 43}},
}}
got, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1"})
if err != nil {
t.Fatalf("Update() unexpected error: %v", err)
}
if len(got.PullRequests) != 1 || got.PullRequests[0].Number != 43 {
t.Fatalf("Update() pull requests = %+v, want PR #43", got.PullRequests)
}
}
// ── Write gating: denied without api_write:<resource> ───────────────────
func TestPluginHost_Tasks_CreateDeniedWithoutWriteCapability(t *testing.T) {
@@ -287,6 +305,28 @@ func TestPluginHost_PluginOwnedTaskTreeRejectsUserOwnedRoot(t *testing.T) {
}
}
func TestPluginHost_PluginOwnedTaskTreePreviewAttachesPullRequests(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
root := &taskmodels.Task{
ID: "root", WorkspaceID: "ws-1",
Metadata: map[string]any{taskSourceMetadataKey: "plugin:p1"},
}
d.tasks.tasksByID = map[string]*taskmodels.Task{"root": root}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {root}}
d.host.taskPRs = &stubPRSource{byTask: map[string][]*githubsvc.TaskPR{
"root": {{PRNumber: 44}},
}}
preview, err := d.host.PluginOwnedTaskTrees().Preview(context.Background(), "root")
if err != nil {
t.Fatalf("Preview() unexpected error: %v", err)
}
if len(preview) != 1 || len(preview[0].PullRequests) != 1 || preview[0].PullRequests[0].Number != 44 {
t.Fatalf("Preview() pull requests = %+v, want PR #44", preview)
}
}
func TestPluginHost_PluginOwnedTaskTreeDeleteIsIdempotentAfterRootDeletion(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{}
+23 -1
View File
@@ -123,7 +123,10 @@ type Service struct {
sessionCodeStats sessionCodeStatsSource
messageData messageDataSource
interactionData interactionDataSource
taskWriter taskWriter
// taskPRs is guarded by mu and read through taskPRSourceDep, because hosts can
// outlive the late SetTaskPRSource wiring.
taskPRs taskPRSource
taskWriter taskWriter
// Utility agent invocation (ADR 0048), wired via SetUtilityAgent.
utilityAgents utilityAgentSource
@@ -428,6 +431,24 @@ func (s *Service) SetDataSources(
// StartActivePlugins has spawned boot-active plugins — so hosts read it live
// via interactionResponderDep rather than snapshotting it. A nil responder
// leaves the write RPCs returning Unimplemented; the reads are unaffected.
// SetTaskPRSource wires the optional pull-request lookup behind
// Task.PullRequests. It is separate from SetDataSources because GitHub is an
// optional service; nil leaves tasks with no PullRequests rather than failing
// the read.
func (s *Service) SetTaskPRSource(src taskPRSource) {
s.mu.Lock()
defer s.mu.Unlock()
s.taskPRs = src
}
// taskPRSourceDep returns the currently wired pull-request source. Hosts call
// this accessor at read time so late wiring reaches already-running plugins.
func (s *Service) taskPRSourceDep() taskPRSource {
s.mu.Lock()
defer s.mu.Unlock()
return s.taskPRs
}
func (s *Service) SetInteractionResponder(responder interactionResponder) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -630,6 +651,7 @@ func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host {
sessionCodeStats: s.sessionCodeStats,
messageData: s.messageData,
interactionData: s.interactionData,
taskPRsDep: s.taskPRSourceDep,
taskWriter: s.taskWriter,
utilityDeps: s.utilityAgentDeps,
writeDeps: s.writeDependencies,
+153 -10
View File
@@ -110,6 +110,78 @@ type Task struct {
IsEphemeral bool
Repositories []TaskRepository
Metadata map[string]any
// ArchivedAt is nil for a live task. Only ever populated when the read asked
// for archived tasks (TaskFilter.IncludeArchived).
ArchivedAt *string
// PullRequests are the changes opened for this task, newest first. The link
// is not derivable from any other field on Task: CheckoutBranch is the BASE
// branch, and a PR title is rewritten to conventional-commit form.
PullRequests []TaskPullRequest
// WorkflowStepID is where on the board the task stands. State says how it is
// going; this says where it is.
WorkflowStepID string
Position int32
AssigneeAgentProfileID string
Labels []string
Autopilot bool
WIPAdmitted bool
QueuedForStepID string
QueuedAt *string
ProjectID string
ExternalID string
}
// TaskPullRequest is one change opened for a task. Provider-neutral by design:
// review metadata specific to one forge stays out of the plugin contract.
type TaskPullRequest struct {
Number int64
URL string
Title string
State string // "open" | "closed" | "merged"
HeadBranch string
BaseBranch string
IsDraft bool
Provider string // "github" | "gitlab"
MergedAt *string
ClosedAt *string
// Readiness, already synced by kandev's PR watcher. Carried so a plugin
// asking "what can merge" does not re-fetch all of it from the forge.
ReviewState string
ChecksState string
MergeableState string
UnresolvedReviewThreads int32
ChecksTotal int32
ChecksPassing int32
Additions int32
Deletions int32
AuthorLogin string
}
func (r TaskPullRequest) toProto() *pluginv1.TaskPullRequest {
return &pluginv1.TaskPullRequest{
Number: r.Number, Url: r.URL, Title: r.Title, State: r.State,
HeadBranch: r.HeadBranch, BaseBranch: r.BaseBranch, IsDraft: r.IsDraft,
Provider: r.Provider, MergedAt: r.MergedAt, ClosedAt: r.ClosedAt,
ReviewState: r.ReviewState, ChecksState: r.ChecksState,
MergeableState: r.MergeableState, UnresolvedReviewThreads: r.UnresolvedReviewThreads,
ChecksTotal: r.ChecksTotal, ChecksPassing: r.ChecksPassing,
Additions: r.Additions, Deletions: r.Deletions, AuthorLogin: r.AuthorLogin,
}
}
func taskPullRequestFromProto(p *pluginv1.TaskPullRequest) TaskPullRequest {
if p == nil {
return TaskPullRequest{}
}
return TaskPullRequest{
Number: p.GetNumber(), URL: p.GetUrl(), Title: p.GetTitle(), State: p.GetState(),
HeadBranch: p.GetHeadBranch(), BaseBranch: p.GetBaseBranch(), IsDraft: p.GetIsDraft(),
Provider: p.GetProvider(), MergedAt: p.MergedAt, ClosedAt: p.ClosedAt,
ReviewState: p.GetReviewState(), ChecksState: p.GetChecksState(),
MergeableState: p.GetMergeableState(), UnresolvedReviewThreads: p.GetUnresolvedReviewThreads(),
ChecksTotal: p.GetChecksTotal(), ChecksPassing: p.GetChecksPassing(),
Additions: p.GetAdditions(), Deletions: p.GetDeletions(), AuthorLogin: p.GetAuthorLogin(),
}
}
func (t Task) toProto() (*pluginv1.Task, error) {
@@ -142,9 +214,44 @@ func (t Task) toProto() (*pluginv1.Task, error) {
IsEphemeral: t.IsEphemeral,
Repositories: repos,
Metadata: metadata,
ArchivedAt: t.ArchivedAt,
PullRequests: taskPullRequestsToProto(t.PullRequests),
WorkflowStepId: t.WorkflowStepID,
Position: t.Position,
AssigneeAgentProfileId: t.AssigneeAgentProfileID,
Labels: t.Labels,
Autopilot: t.Autopilot,
WipAdmitted: t.WIPAdmitted,
QueuedForStepId: t.QueuedForStepID,
QueuedAt: t.QueuedAt,
ProjectId: t.ProjectID,
ExternalId: t.ExternalID,
}, nil
}
func taskPullRequestsToProto(in []TaskPullRequest) []*pluginv1.TaskPullRequest {
if len(in) == 0 {
return nil
}
out := make([]*pluginv1.TaskPullRequest, len(in))
for i := range in {
out[i] = in[i].toProto()
}
return out
}
func taskPullRequestsFromProto(in []*pluginv1.TaskPullRequest) []TaskPullRequest {
if len(in) == 0 {
return nil
}
out := make([]TaskPullRequest, len(in))
for i, r := range in {
out[i] = taskPullRequestFromProto(r)
}
return out
}
func taskFromProto(p *pluginv1.Task) (Task, error) {
if p == nil {
return Task{}, nil
@@ -178,6 +285,19 @@ func taskFromProto(p *pluginv1.Task) (Task, error) {
IsEphemeral: p.GetIsEphemeral(),
Repositories: repos,
Metadata: metadata,
ArchivedAt: p.ArchivedAt,
PullRequests: taskPullRequestsFromProto(p.GetPullRequests()),
WorkflowStepID: p.GetWorkflowStepId(),
Position: p.GetPosition(),
AssigneeAgentProfileID: p.GetAssigneeAgentProfileId(),
Labels: p.GetLabels(),
Autopilot: p.GetAutopilot(),
WIPAdmitted: p.GetWipAdmitted(),
QueuedForStepID: p.GetQueuedForStepId(),
QueuedAt: p.QueuedAt,
ProjectID: p.GetProjectId(),
ExternalID: p.GetExternalId(),
}, nil
}
@@ -218,6 +338,12 @@ type TaskFilter struct {
States []string
ParentID *string
IncludeEphemeral bool
// IncludeArchived defaults false, matching the behaviour before this field
// existed: a plugin that does not ask still sees only live tasks. A plugin
// reporting on delivery needs true — an archived task is usually a
// delivered one, and omitting it makes finished work look like it never
// happened.
IncludeArchived bool
}
func (f TaskFilter) toProto() *pluginv1.TaskFilter {
@@ -227,6 +353,7 @@ func (f TaskFilter) toProto() *pluginv1.TaskFilter {
States: f.States,
ParentId: f.ParentID,
IncludeEphemeral: f.IncludeEphemeral,
IncludeArchived: f.IncludeArchived,
}
}
@@ -240,6 +367,7 @@ func taskFilterFromProto(p *pluginv1.TaskFilter) TaskFilter {
States: p.GetStates(),
ParentID: p.ParentId,
IncludeEphemeral: p.GetIncludeEphemeral(),
IncludeArchived: p.GetIncludeArchived(),
}
}
@@ -373,15 +501,26 @@ type WorkflowStep struct {
Name string
Position int32
StageType string
// Color is the step's own presentation colour. A plugin rendering a board
// reads it rather than inventing a palette, so its columns match the ones
// the operator already sees in kandev.
Color string
IsStartStep bool
WIPLimit int32 // 0 means unlimited
AgentProfileID string
}
func (s WorkflowStep) toProto() *pluginv1.WorkflowStep {
return &pluginv1.WorkflowStep{
Id: s.ID,
WorkflowId: s.WorkflowID,
Name: s.Name,
Position: s.Position,
StageType: s.StageType,
Id: s.ID,
WorkflowId: s.WorkflowID,
Name: s.Name,
Position: s.Position,
StageType: s.StageType,
Color: s.Color,
IsStartStep: s.IsStartStep,
WipLimit: s.WIPLimit,
AgentProfileId: s.AgentProfileID,
}
}
@@ -390,11 +529,15 @@ func workflowStepFromProto(p *pluginv1.WorkflowStep) WorkflowStep {
return WorkflowStep{}
}
return WorkflowStep{
ID: p.GetId(),
WorkflowID: p.GetWorkflowId(),
Name: p.GetName(),
Position: p.GetPosition(),
StageType: p.GetStageType(),
ID: p.GetId(),
WorkflowID: p.GetWorkflowId(),
Name: p.GetName(),
Position: p.GetPosition(),
StageType: p.GetStageType(),
Color: p.GetColor(),
IsStartStep: p.GetIsStartStep(),
WIPLimit: p.GetWipLimit(),
AgentProfileID: p.GetAgentProfileId(),
}
}
+43 -6
View File
@@ -50,7 +50,20 @@ func TestTaskProtoRoundTrip(t *testing.T) {
{ID: "tr-1", RepositoryID: "repo-1", BaseBranch: "main", Position: 0, CheckoutBranch: "feature/fix"},
{ID: "tr-2", RepositoryID: "repo-2", BaseBranch: "develop", Position: 1},
},
Metadata: map[string]any{"source": "plugin:agent-stats", "count": float64(3)},
Metadata: map[string]any{"source": "plugin:agent-stats", "count": float64(3)},
ArchivedAt: strPtr("2026-07-16T12:00:00Z"),
PullRequests: []TaskPullRequest{{
Number: 42, URL: "https://example.test/pulls/42", Title: "Fix the bug",
State: "open", HeadBranch: "feature/fix", BaseBranch: "main", IsDraft: true,
Provider: "github", MergedAt: strPtr("2026-07-16T11:00:00Z"),
ClosedAt: strPtr("2026-07-16T11:30:00Z"), ReviewState: "approved",
ChecksState: "success", MergeableState: "clean", UnresolvedReviewThreads: 1,
ChecksTotal: 5, ChecksPassing: 5, Additions: 12, Deletions: 3, AuthorLogin: "nova28",
}},
WorkflowStepID: "step-review", Position: 4, AssigneeAgentProfileID: "agent-1",
Labels: []string{"plugin", "ready"}, Autopilot: true, WIPAdmitted: true,
QueuedForStepID: "step-build", QueuedAt: strPtr("2026-07-16T10:00:00Z"),
ProjectID: "project-1", ExternalID: "external-1",
}
proto, err := task.toProto()
@@ -60,6 +73,10 @@ func TestTaskProtoRoundTrip(t *testing.T) {
require.Nil(t, proto.CompletedAt)
require.Equal(t, "feature/fix", proto.GetRepositories()[0].GetCheckoutBranch())
require.Empty(t, proto.GetRepositories()[1].GetCheckoutBranch(), "empty checkout branches remain wire-compatible")
require.Equal(t, "step-review", proto.GetWorkflowStepId())
require.True(t, proto.GetAutopilot())
require.Len(t, proto.GetPullRequests(), 1)
require.Equal(t, int64(42), proto.GetPullRequests()[0].GetNumber())
back, err := taskFromProto(proto)
require.NoError(t, err)
@@ -82,6 +99,21 @@ func TestTaskProtoRoundTrip_NilOptionalsAndEmptyMetadata(t *testing.T) {
require.Equal(t, task, back)
}
func TestTaskPullRequestProtoRoundTrip(t *testing.T) {
pr := TaskPullRequest{
Number: 42, URL: "https://example.test/pulls/42", Title: "Fix the bug",
State: "merged", HeadBranch: "feature/fix", BaseBranch: "main", IsDraft: false,
Provider: "github", MergedAt: strPtr("2026-07-16T11:00:00Z"), ClosedAt: nil,
ReviewState: "approved", ChecksState: "success", MergeableState: "clean",
UnresolvedReviewThreads: 0, ChecksTotal: 5, ChecksPassing: 5,
Additions: 12, Deletions: 3, AuthorLogin: "nova28",
}
proto := pr.toProto()
require.Equal(t, pr, taskPullRequestFromProto(proto))
require.Nil(t, proto.ClosedAt)
}
func TestTaskFilterProtoRoundTrip(t *testing.T) {
filter := TaskFilter{
WorkspaceIDs: []string{"ws-1", "ws-2"},
@@ -89,6 +121,7 @@ func TestTaskFilterProtoRoundTrip(t *testing.T) {
States: []string{"todo", "in_progress"},
ParentID: strPtr("task-0"),
IncludeEphemeral: true,
IncludeArchived: true,
}
proto := filter.toProto()
require.Equal(t, filter, taskFilterFromProto(proto))
@@ -129,11 +162,15 @@ func TestWorkflowProtoRoundTrip(t *testing.T) {
func TestWorkflowStepProtoRoundTrip(t *testing.T) {
step := WorkflowStep{
ID: "step-1",
WorkflowID: "wf-1",
Name: "Review",
Position: 1,
StageType: "review",
ID: "step-1",
WorkflowID: "wf-1",
Name: "Review",
Position: 1,
StageType: "review",
Color: "bg-indigo-500",
IsStartStep: true,
WIPLimit: 3,
AgentProfileID: "agent-1",
}
proto := step.toProto()
require.Equal(t, step, workflowStepFromProto(proto))
File diff suppressed because it is too large Load Diff
@@ -370,6 +370,39 @@ message Task {
bool is_ephemeral = 15;
repeated TaskRepository repositories = 16;
google.protobuf.Struct metadata = 17; // free-form; plugin-tolerant
// RFC3339, set only when the task is archived. Present so a plugin reading
// with include_archived can tell a live task from a retired one; absent is
// the normal case, not an unknown.
optional string archived_at = 18;
// Pull requests opened for this task, newest first. A plugin that reports on
// delivery cannot otherwise get from a task to its change: the link lives in
// github_task_prs, and neither the checkout branch (which is the BASE branch)
// nor the title (rewritten to conventional-commit form on the PR) matches.
repeated TaskPullRequest pull_requests = 19;
// The step the task currently stands on, and its order within that step.
// Without these a plugin can read a task's State (created/in progress/review/
// completed) but not WHERE on the board it is, so it cannot render, group by,
// or reason about a workflow at all.
string workflow_step_id = 20;
int32 position = 21;
// The agent profile running this task, empty when unassigned. Canonically a
// `runner` row in workflow_step_participants (ADR 0005 Wave F); this is the
// same read-time projection kandev's own callers consume.
string assignee_agent_profile_id = 22;
// Task labels. Stored as a JSON array string on the model; carried here as a
// real repeated field so a plugin does not have to re-parse it.
repeated string labels = 23;
bool autopilot = 24;
// Whether the task consumes a slot in its current step's WIP capacity. A
// queued task stays visible but admits nothing, so a capacity report that
// counted rows rather than this would over-count.
bool wip_admitted = 25;
string queued_for_step_id = 26;
optional string queued_at = 27; // RFC3339
// Office project this task belongs to, empty for kanban-board tasks.
string project_id = 28;
// Caller-supplied create-idempotency key, empty when the task holds none.
string external_id = 29;
}
message TaskRepository {
string id = 1;
@@ -378,6 +411,33 @@ message TaskRepository {
int32 position = 4;
string checkout_branch = 5;
}
// TaskPullRequest is the read-side view of a task's linked pull request. It
// carries provider-neutral fields only; provider-specific review metadata stays
// out of the plugin contract.
message TaskPullRequest {
int64 number = 1;
string url = 2;
string title = 3;
string state = 4; // "open" | "closed" | "merged"
string head_branch = 5;
string base_branch = 6;
bool is_draft = 7;
string provider = 8; // "github" | "gitlab"
optional string merged_at = 9; // RFC3339
// Readiness, already synced by kandev's own PR watcher. A plugin reporting on
// what can merge would otherwise re-fetch every one of these from the forge
// per pull request, which is both slower and rate-limited.
string review_state = 10; // approved | changes_requested | pending | ""
string checks_state = 11; // success | failure | pending | ""
string mergeable_state = 12; // clean | blocked | behind | dirty | unstable | draft | unknown | ""
int32 unresolved_review_threads = 13;
int32 checks_total = 14;
int32 checks_passing = 15;
int32 additions = 16;
int32 deletions = 17;
string author_login = 18;
optional string closed_at = 19; // RFC3339
}
message TaskFilter {
repeated string workspace_ids = 1; // empty → all the plugin may see
@@ -385,6 +445,9 @@ message TaskFilter {
repeated string states = 3;
optional string parent_id = 4;
bool include_ephemeral = 5; // default false (kanban-visible only)
// default false, matching the previous hardcoded behaviour: a plugin that
// does not ask still sees only live tasks.
bool include_archived = 6;
}
message ListTasksRequest {
TaskFilter filter = 1;
@@ -444,6 +507,14 @@ message WorkflowStep {
string name = 3;
int32 position = 4;
string stage_type = 5; // work | review | approval | custom
// Presentation and capacity, so a plugin rendering a board reads the same
// step colour the operator already sees rather than inventing its own, and
// can report a step against its own WIP limit.
string color = 6;
bool is_start_step = 7;
int32 wip_limit = 8; // 0 means unlimited
// The agent profile this step runs work under, empty when it inherits.
string agent_profile_id = 9;
}
message ListWorkflowStepsRequest {
string workflow_id = 1;