feat(plugins): expose task dependencies in plugin and canvas data API (#3794)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-18 22:55:51 +08:00
committed by GitHub
parent f92877b4be
commit 277677f330
38 changed files with 5545 additions and 656 deletions
@@ -12,6 +12,7 @@ import (
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/state"
"github.com/kandev/kandev/internal/plugins/webapp"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/pkg/pluginsdk"
)
@@ -40,31 +41,76 @@ func (s *Service) listWebAppTasks(ctx context.Context, w http.ResponseWriter, r
IncludeEphemeral: true,
}
if binding.ScopeKind == instances.ScopeTask {
task, err := host.Tasks().Get(ctx, binding.TaskID)
if err != nil || task == nil {
// Resolved via the non-attaching fetchTask, and dependencies attached
// only below, after the discard checks: matching the attachment rule
// the non-task-scope branch below already documents for itself.
dto, model, err := host.fetchTask(ctx, binding.TaskID)
if err != nil || dto == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
if !webAppTaskMatches(ctx, host, binding, *task) || !webAppTaskMatchesFilter(*task, filter) || (!includeArchived && task.ArchivedAt != nil) {
if !webAppTaskMatches(ctx, host, binding, *dto) || !webAppTaskMatchesFilter(*dto, filter) || (!includeArchived && dto.ArchivedAt != nil) {
writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppTask]{Items: []webAppTask{}, PageInfo: webAppPageInfo{}})
return
}
items, info := paginate([]webAppTask{webAppTaskFromSDK(*task)}, page)
fetched := []pluginsdk.Task{*dto}
host.attachPullRequests(ctx, fetched)
var models []*taskmodels.Task
if model != nil {
models = []*taskmodels.Task{model}
}
if err := host.attachDependencies(ctx, fetched, models, false, "ListWebAppTasks"); err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
scope := dependencyRedactionScopeFromBinding(binding, nil)
items, info := paginate([]webAppTask{webAppTaskFromSDK(fetched[0], scope)}, page)
writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(items, info))
return
}
items, info, err := host.Tasks().List(ctx, filter, page)
// Fetches without deriving dependencies: the binding scope (repository or
// session) narrows the fetched page further below, and deriving before
// that narrowing would both waste work on tasks the caller never sees and
// risk tripping the fan-out cap on a page the actual response never
// approaches.
if !host.capabilities.CanRead(resourceTasks) {
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
return
}
if host.taskData == nil {
_, _, err := host.UnimplementedHostData.Tasks().List(ctx, filter, page)
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
models, info, err := host.fetchTaskPage(ctx, filter, page)
if err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
filtered := make([]webAppTask, 0, len(items))
for _, item := range items {
if webAppTaskMatches(ctx, host, binding, item) {
filtered = append(filtered, webAppTaskFromSDK(item))
dtos := tasksToDTOs(models)
host.attachPullRequests(ctx, dtos)
survivingModels := make([]*taskmodels.Task, 0, len(dtos))
survivingDTOs := make([]pluginsdk.Task, 0, len(dtos))
for i, dto := range dtos {
if webAppTaskMatches(ctx, host, binding, dto) {
survivingDTOs = append(survivingDTOs, dto)
survivingModels = append(survivingModels, models[i])
}
}
if err := host.attachDependencies(ctx, survivingDTOs, survivingModels, true, "ListWebAppTasks"); err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
directlyReadableIDs := make([]string, len(survivingDTOs))
for i, dto := range survivingDTOs {
directlyReadableIDs[i] = dto.ID
}
scope := dependencyRedactionScopeFromBinding(binding, directlyReadableIDs)
filtered := make([]webAppTask, len(survivingDTOs))
for i, dto := range survivingDTOs {
filtered[i] = webAppTaskFromSDK(dto, scope)
}
writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(filtered, info))
}
@@ -73,16 +119,29 @@ func (s *Service) getWebAppTask(ctx context.Context, w http.ResponseWriter, r *h
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
task, err := host.Tasks().Get(ctx, taskID)
if err != nil || task == nil {
// Resolved via the non-attaching fetchTask, and dependencies attached only
// below, after the scope check that can discard the result and 404.
dto, model, err := host.fetchTask(ctx, taskID)
if err != nil || dto == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
if !webAppTaskMatches(ctx, host, binding, *task) {
if !webAppTaskMatches(ctx, host, binding, *dto) {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*task))
fetched := []pluginsdk.Task{*dto}
host.attachPullRequests(ctx, fetched)
var models []*taskmodels.Task
if model != nil {
models = []*taskmodels.Task{model}
}
if err := host.attachDependencies(ctx, fetched, models, false, "GetWebAppTask"); err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
scope := dependencyRedactionScopeFromBinding(binding, []string{fetched[0].ID})
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(fetched[0], scope))
}
type webAppTaskPatch struct {
@@ -105,7 +164,7 @@ func (s *Service) updateWebAppTask(ctx context.Context, w http.ResponseWriter, r
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
task, err := host.Tasks().Get(ctx, taskID)
task, err := host.fetchTaskForScopeCheck(ctx, taskID)
if err != nil || task == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
@@ -121,12 +180,25 @@ func (s *Service) updateWebAppTask(ctx context.Context, w http.ResponseWriter, r
}
var updated *pluginsdk.Task
if patch.hasTaskFields() {
updated, err = host.Tasks().Update(ctx, pluginsdk.UpdateTaskInput{
updateInput := pluginsdk.UpdateTaskInput{
ID: taskID, Title: patch.Title, Description: patch.Description, State: patch.State,
})
if err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
if patch.WorkflowStepID != nil {
// A body naming both a task field and workflow_step_id takes both
// this branch and the Move branch below, but only Move's result is
// serialized -- so this write must not attach dependencies on a
// result nobody sees. The one derivation the response makes runs
// on Move's result, below.
if _, err = host.writeTaskUpdate(ctx, updateInput); err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
} else {
updated, err = host.Tasks().Update(ctx, updateInput)
if err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
}
}
if patch.WorkflowStepID != nil {
@@ -150,7 +222,8 @@ func (s *Service) updateWebAppTask(ctx context.Context, w http.ResponseWriter, r
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
return
}
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*updated))
scope := dependencyRedactionScopeFromBinding(binding, []string{updated.ID})
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*updated, scope))
}
type webAppMessageRequest struct {
@@ -163,7 +236,7 @@ func (s *Service) sendWebAppMessage(ctx context.Context, w http.ResponseWriter,
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
task, err := host.Tasks().Get(ctx, taskID)
task, err := host.fetchTaskForScopeCheck(ctx, taskID)
if err != nil || task == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
@@ -206,7 +279,7 @@ func (s *Service) listWebAppWorkflows(ctx context.Context, w http.ResponseWriter
workflows = append(workflows, items...)
}
if binding.ScopeKind == instances.ScopeTask {
task, err := host.Tasks().Get(ctx, binding.TaskID)
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
if err != nil || task == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
@@ -246,7 +319,7 @@ func (s *Service) listWebAppWorkflowSteps(ctx context.Context, w http.ResponseWr
}
}
if binding.ScopeKind == instances.ScopeTask {
task, err := host.Tasks().Get(ctx, binding.TaskID)
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
if err != nil || task == nil || task.WorkflowID != workflowID {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
@@ -403,6 +476,8 @@ func webAppErrorCode(err error) string {
return "plugin_state_conflict"
case codes.Unimplemented:
return webAppRuntimeUnavailable
case codes.ResourceExhausted:
return "response_too_large"
default:
return webAppRuntimeUnavailable
}