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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user