f8fc3bf506
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
255 lines
9.8 KiB
Go
255 lines
9.8 KiB
Go
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 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",
|
|
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.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)
|
|
}
|
|
|
|
// The session-ceiling replay payload carries the launch-scoped environment
|
|
// map, the composed prompt, its attachments and entity references. A plugin
|
|
// or agent-authored canvas is a lower-trust surface than the host itself, so
|
|
// it must never see that payload even though it is free to see that a
|
|
// deferral is pending, of what kind, and why.
|
|
func TestTaskModelToDTORedactsCeilingLaunchPayload(t *testing.T) {
|
|
task := &taskmodels.Task{
|
|
ID: "t1",
|
|
Metadata: map[string]interface{}{
|
|
taskmodels.MetaKeyDeferredLaunch: map[string]interface{}{
|
|
taskmodels.CeilingLaunchPayloadKey: map[string]interface{}{
|
|
"prompt": "do the secret thing",
|
|
},
|
|
taskmodels.CeilingReasonCodeKey: "ceiling_reached",
|
|
},
|
|
},
|
|
}
|
|
|
|
dto := taskModelToDTO(task)
|
|
|
|
deferred, ok := dto.Metadata[taskmodels.MetaKeyDeferredLaunch].(map[string]interface{})
|
|
require.True(t, ok, "the discriminators must still be visible")
|
|
require.Equal(t, "ceiling_reached", deferred[taskmodels.CeilingReasonCodeKey])
|
|
_, leaked := deferred[taskmodels.CeilingLaunchPayloadKey]
|
|
require.False(t, leaked, "ceiling_launch_payload must be redacted from the plugin host-data path")
|
|
}
|
|
|
|
// 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)
|
|
}
|