329 lines
14 KiB
Go
329 lines
14 KiB
Go
package plugins
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/instances"
|
|
"github.com/kandev/kandev/internal/plugins/webapp"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
type webAppPageInfo struct {
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
HasMore bool `json:"has_more"`
|
|
}
|
|
|
|
type webAppPage[T any] struct {
|
|
Items []T `json:"items"`
|
|
PageInfo webAppPageInfo `json:"page_info"`
|
|
}
|
|
|
|
func webAppPageFromSDK[T any](items []T, info *pluginsdk.PageInfo) webAppPage[T] {
|
|
page := webAppPage[T]{Items: items}
|
|
if info != nil {
|
|
page.PageInfo = webAppPageInfo{NextCursor: info.NextCursor, HasMore: info.HasMore}
|
|
}
|
|
return page
|
|
}
|
|
|
|
func writeWebAppJSON(w http.ResponseWriter, r *http.Request, status int, value any) {
|
|
body, err := json.Marshal(value)
|
|
if err != nil {
|
|
writeWebAppError(w, http.StatusInternalServerError, webAppRuntimeUnavailable)
|
|
return
|
|
}
|
|
if len(body) > webAppResponseLimit {
|
|
writeWebAppError(w, http.StatusInternalServerError, "response_too_large")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
if r == nil || r.Method != http.MethodHead {
|
|
_, _ = w.Write(body)
|
|
}
|
|
}
|
|
|
|
func writeWebAppError(w http.ResponseWriter, status int, code string) {
|
|
body, err := json.Marshal(struct {
|
|
Error string `json:"error"`
|
|
}{Error: code})
|
|
if err != nil {
|
|
body = []byte(`{"error":"runtime_unavailable"}`)
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
type webAppTaskRepository struct {
|
|
ID string `json:"id"`
|
|
RepositoryID string `json:"repository_id"`
|
|
BaseBranch string `json:"base_branch"`
|
|
Position int32 `json:"position"`
|
|
CheckoutBranch string `json:"checkout_branch"`
|
|
}
|
|
|
|
type webAppPullRequest struct {
|
|
Number int64 `json:"number"`
|
|
URL string `json:"url"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"`
|
|
HeadBranch string `json:"head_branch"`
|
|
BaseBranch string `json:"base_branch"`
|
|
IsDraft bool `json:"is_draft"`
|
|
Provider string `json:"provider"`
|
|
MergedAt *string `json:"merged_at,omitempty"`
|
|
ClosedAt *string `json:"closed_at,omitempty"`
|
|
ReviewState string `json:"review_state"`
|
|
ChecksState string `json:"checks_state"`
|
|
MergeableState string `json:"mergeable_state"`
|
|
UnresolvedReviewThreads int32 `json:"unresolved_review_threads"`
|
|
ChecksTotal int32 `json:"checks_total"`
|
|
ChecksPassing int32 `json:"checks_passing"`
|
|
Additions int32 `json:"additions"`
|
|
Deletions int32 `json:"deletions"`
|
|
AuthorLogin string `json:"author_login"`
|
|
}
|
|
|
|
// Title and State are never omitempty: a redacted entry blanks them to ""
|
|
// rather than dropping the key, per AC-PLUGINS-TASK-DEPS-003.2. Status stays
|
|
// omitempty because a blocks entry legitimately carries none.
|
|
type webAppTaskDependencyRef struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
// dependencyRedactionScope carries what AC-PLUGINS-TASK-DEPS-003.8's
|
|
// admission test needs to decide, for one canvas response, whether an edge
|
|
// end may be disclosed as a directly readable task. It is built once per
|
|
// response from the canvas binding plus the set of task IDs that response
|
|
// already serializes as directly readable tasks (directlyReadableIDs) --
|
|
// never from an additional read.
|
|
type dependencyRedactionScope struct {
|
|
kind string
|
|
workspaceID string
|
|
directlyReadableIDs map[string]struct{}
|
|
}
|
|
|
|
// dependencyRedactionScopeFromBinding builds the scope for one canvas
|
|
// response. directlyReadableIDs must list every task ID that response
|
|
// serializes as a directly readable task (e.g. the single task a GET
|
|
// returns, or the page a list route returns after its own scope narrowing);
|
|
// it is ignored for scope kinds whose admission test doesn't consult it.
|
|
func dependencyRedactionScopeFromBinding(binding webapp.CapabilityBinding, directlyReadableIDs []string) dependencyRedactionScope {
|
|
ids := make(map[string]struct{}, len(directlyReadableIDs))
|
|
for _, id := range directlyReadableIDs {
|
|
ids[id] = struct{}{}
|
|
}
|
|
return dependencyRedactionScope{
|
|
kind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind),
|
|
workspaceID: binding.WorkspaceID,
|
|
directlyReadableIDs: ids,
|
|
}
|
|
}
|
|
|
|
// admits reports whether ref may be disclosed as a directly readable task
|
|
// under s, resolving by scope kind exactly as AC-PLUGINS-TASK-DEPS-003.8
|
|
// requires: instance scope admits every end; workspace scope admits an end
|
|
// whose workspace is known equal to the bound workspace; task scope admits
|
|
// no end; repository and session scope admit an end only when it is also
|
|
// returned in the same response as a directly readable task. An end whose
|
|
// predicate inputs are unavailable (e.g. an empty workspace on either side)
|
|
// is never admitted, per AC-PLUGINS-TASK-DEPS-003.3's fail-closed rule.
|
|
func (s dependencyRedactionScope) admits(ref pluginsdk.TaskDependencyRef) bool {
|
|
switch s.kind {
|
|
case instances.ScopeInstance:
|
|
return true
|
|
case instances.ScopeWorkspace:
|
|
return s.workspaceID != "" && ref.WorkspaceID != "" && ref.WorkspaceID == s.workspaceID
|
|
case instances.ScopeRepository, instances.ScopeSession:
|
|
_, ok := s.directlyReadableIDs[ref.ID]
|
|
return ok
|
|
default:
|
|
// instances.ScopeTask, and any unrecognized scope kind: admit nothing.
|
|
return false
|
|
}
|
|
}
|
|
|
|
// webAppTaskDependencyRefFromSDK redacts Title and State when scope does not
|
|
// admit ref (REQ-PLUGINS-TASK-DEPS-003).
|
|
func webAppTaskDependencyRefFromSDK(ref pluginsdk.TaskDependencyRef, scope dependencyRedactionScope) webAppTaskDependencyRef {
|
|
out := webAppTaskDependencyRef{ID: ref.ID, Title: ref.Title, State: ref.State, Status: ref.Status}
|
|
if !scope.admits(ref) {
|
|
out.Title = ""
|
|
out.State = ""
|
|
}
|
|
return out
|
|
}
|
|
|
|
// webAppTaskDependencyRefsFromSDK never returns nil, even for an empty or
|
|
// nil input: depends_on/blocks always serialize as [], never as null or an
|
|
// absent field.
|
|
func webAppTaskDependencyRefsFromSDK(refs []pluginsdk.TaskDependencyRef, scope dependencyRedactionScope) []webAppTaskDependencyRef {
|
|
out := make([]webAppTaskDependencyRef, len(refs))
|
|
for i, ref := range refs {
|
|
out[i] = webAppTaskDependencyRefFromSDK(ref, scope)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type webAppTask struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
State string `json:"state"`
|
|
Priority string `json:"priority"`
|
|
CreatedBy string `json:"created_by"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
StartedAt *string `json:"started_at,omitempty"`
|
|
CompletedAt *string `json:"completed_at,omitempty"`
|
|
ParentID *string `json:"parent_id,omitempty"`
|
|
Identifier string `json:"identifier"`
|
|
IsEphemeral bool `json:"is_ephemeral"`
|
|
Repositories []webAppTaskRepository `json:"repositories,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
ArchivedAt *string `json:"archived_at,omitempty"`
|
|
PullRequests []webAppPullRequest `json:"pull_requests,omitempty"`
|
|
WorkflowStepID string `json:"workflow_step_id"`
|
|
Position int32 `json:"position"`
|
|
AssigneeAgentProfileID string `json:"assignee_agent_profile_id"`
|
|
Labels []string `json:"labels,omitempty"`
|
|
Autopilot bool `json:"autopilot"`
|
|
WIPAdmitted bool `json:"wip_admitted"`
|
|
QueuedForStepID string `json:"queued_for_step_id,omitempty"`
|
|
QueuedAt *string `json:"queued_at,omitempty"`
|
|
ProjectID string `json:"project_id,omitempty"`
|
|
ExternalID string `json:"external_id,omitempty"`
|
|
|
|
Blocked bool `json:"blocked"`
|
|
// BlockedReason never carries omitempty: it is "" on an unblocked task, and
|
|
// the key must stay present so absence is never a third state alongside the
|
|
// other seven projection fields.
|
|
BlockedReason string `json:"blocked_reason"`
|
|
// DependsOn/Blocks never carry omitempty: an empty dependency graph still
|
|
// reports [], not an absent or null field.
|
|
DependsOn []webAppTaskDependencyRef `json:"depends_on"`
|
|
Blocks []webAppTaskDependencyRef `json:"blocks"`
|
|
DependsOnTruncated bool `json:"depends_on_truncated"`
|
|
BlocksTruncated bool `json:"blocks_truncated"`
|
|
StartWhenUnblocked bool `json:"start_when_unblocked"`
|
|
}
|
|
|
|
// webAppTaskFromSDK converts task to its canvas wire form. scope is the
|
|
// requesting canvas response's dependency redaction scope, used only to
|
|
// redact out-of-scope dependency edge ends.
|
|
func webAppTaskFromSDK(task pluginsdk.Task, scope dependencyRedactionScope) webAppTask {
|
|
result := webAppTask{
|
|
ID: task.ID,
|
|
WorkspaceID: task.WorkspaceID,
|
|
WorkflowID: task.WorkflowID,
|
|
Title: task.Title,
|
|
Description: task.Description,
|
|
State: task.State,
|
|
Priority: task.Priority,
|
|
CreatedBy: task.CreatedBy,
|
|
CreatedAt: task.CreatedAt,
|
|
UpdatedAt: task.UpdatedAt,
|
|
StartedAt: task.StartedAt,
|
|
CompletedAt: task.CompletedAt,
|
|
ParentID: task.ParentID,
|
|
Identifier: task.Identifier,
|
|
IsEphemeral: task.IsEphemeral,
|
|
Metadata: task.Metadata,
|
|
ArchivedAt: task.ArchivedAt,
|
|
WorkflowStepID: task.WorkflowStepID,
|
|
Position: task.Position,
|
|
AssigneeAgentProfileID: task.AssigneeAgentProfileID,
|
|
Labels: task.Labels, //nolint:staticcheck // retain API v1 label compatibility
|
|
Autopilot: task.Autopilot,
|
|
WIPAdmitted: task.WIPAdmitted,
|
|
QueuedForStepID: task.QueuedForStepID,
|
|
QueuedAt: task.QueuedAt,
|
|
ProjectID: task.ProjectID,
|
|
ExternalID: task.ExternalID,
|
|
|
|
Blocked: task.Blocked,
|
|
BlockedReason: task.BlockedReason,
|
|
DependsOn: webAppTaskDependencyRefsFromSDK(task.DependsOn, scope),
|
|
Blocks: webAppTaskDependencyRefsFromSDK(task.Blocks, scope),
|
|
DependsOnTruncated: task.DependsOnTruncated,
|
|
BlocksTruncated: task.BlocksTruncated,
|
|
StartWhenUnblocked: task.StartWhenUnblocked,
|
|
}
|
|
if len(task.Repositories) > 0 {
|
|
result.Repositories = make([]webAppTaskRepository, len(task.Repositories))
|
|
for i, repository := range task.Repositories {
|
|
result.Repositories[i] = webAppTaskRepository{
|
|
ID: repository.ID, RepositoryID: repository.RepositoryID,
|
|
BaseBranch: repository.BaseBranch, Position: repository.Position,
|
|
CheckoutBranch: repository.CheckoutBranch,
|
|
}
|
|
}
|
|
}
|
|
if len(task.PullRequests) > 0 {
|
|
result.PullRequests = make([]webAppPullRequest, len(task.PullRequests))
|
|
for i, pullRequest := range task.PullRequests {
|
|
result.PullRequests[i] = webAppPullRequest{
|
|
Number: pullRequest.Number, URL: pullRequest.URL, Title: pullRequest.Title,
|
|
State: pullRequest.State, HeadBranch: pullRequest.HeadBranch,
|
|
BaseBranch: pullRequest.BaseBranch, IsDraft: pullRequest.IsDraft,
|
|
Provider: pullRequest.Provider, MergedAt: pullRequest.MergedAt,
|
|
ClosedAt: pullRequest.ClosedAt, ReviewState: pullRequest.ReviewState,
|
|
ChecksState: pullRequest.ChecksState, MergeableState: pullRequest.MergeableState,
|
|
UnresolvedReviewThreads: pullRequest.UnresolvedReviewThreads,
|
|
ChecksTotal: pullRequest.ChecksTotal, ChecksPassing: pullRequest.ChecksPassing,
|
|
Additions: pullRequest.Additions, Deletions: pullRequest.Deletions,
|
|
AuthorLogin: pullRequest.AuthorLogin,
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
type webAppWorkflow struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Description *string `json:"description,omitempty"`
|
|
SortOrder int32 `json:"sort_order"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func webAppWorkflowFromSDK(workflow pluginsdk.Workflow) webAppWorkflow {
|
|
return webAppWorkflow{
|
|
ID: workflow.ID, WorkspaceID: workflow.WorkspaceID, Name: workflow.Name,
|
|
Description: workflow.Description, SortOrder: workflow.SortOrder,
|
|
CreatedAt: workflow.CreatedAt, UpdatedAt: workflow.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
type webAppWorkflowStep struct {
|
|
ID string `json:"id"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
Name string `json:"name"`
|
|
Position int32 `json:"position"`
|
|
StageType string `json:"stage_type"`
|
|
Color string `json:"color"`
|
|
IsStartStep bool `json:"is_start_step"`
|
|
WIPLimit int32 `json:"wip_limit"`
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
OnEnterActionTypes []string `json:"on_enter_action_types,omitempty"`
|
|
}
|
|
|
|
func webAppWorkflowStepFromSDK(step pluginsdk.WorkflowStep) webAppWorkflowStep {
|
|
return webAppWorkflowStep{
|
|
ID: step.ID, WorkflowID: step.WorkflowID, Name: step.Name,
|
|
Position: step.Position, StageType: step.StageType, Color: step.Color,
|
|
IsStartStep: step.IsStartStep, WIPLimit: step.WIPLimit,
|
|
AgentProfileID: step.AgentProfileID, OnEnterActionTypes: step.OnEnterActionTypes,
|
|
}
|
|
}
|