mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
- issueworkflow.Brief is the only renderer: the claim carries it into every run's per-turn prompt; the editor preview and timeline show the same text. - Workflow handoffs stop writing the brief into handoff_note, so a person's note no longer drops the brief and the handoff framing fits again. - Agents that are not the issue's assignee get an observer brief without the step's commands. - Clearer rule override and narrower wakeup guidance; user text is stripped of control characters. - multica-platform skill documents project workflows. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
1067 lines
51 KiB
Go
1067 lines
51 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/featureflags"
|
|
"github.com/multica-ai/multica/server/internal/issueworkflow"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Project workflow tests (MUL-7420).
|
|
|
|
// workflowTestSuffix keeps names unique across tests sharing the workspace.
|
|
func workflowTestSuffix() string {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
|
|
func workflowRequest(method, path string, body any) *http.Request {
|
|
return newRequest(method, path+"?workspace_id="+testWorkspaceID, body)
|
|
}
|
|
|
|
// statusSteps names a workflow's own steps by status key.
|
|
func statusSteps(keys ...string) []map[string]any {
|
|
out := make([]map[string]any, len(keys))
|
|
for i, key := range keys {
|
|
out[i] = map[string]any{"status_key": key}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// createTestWorkflow creates a workflow with backlog and blocked through the
|
|
// API and deletes it after the test, detaching any project still using it
|
|
// first.
|
|
func createTestWorkflow(t *testing.T, name string, middle ...string) IssueWorkflowResponse {
|
|
t.Helper()
|
|
return createTestWorkflowSteps(t, name, statusSteps(append(append([]string{"backlog"}, middle...), "blocked")...))
|
|
}
|
|
|
|
func createTestWorkflowSteps(t *testing.T, name string, steps []map[string]any) IssueWorkflowResponse {
|
|
t.Helper()
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, true)
|
|
var created IssueWorkflowResponse
|
|
testutil.Call(t, testHandler.CreateIssueWorkflow, workflowRequest("POST", "/api/issue-workflows", map[string]any{
|
|
"name": name,
|
|
"steps": steps,
|
|
})).Want(http.StatusCreated).JSON(&created)
|
|
t.Cleanup(func() {
|
|
ctx := context.Background()
|
|
testPool.Exec(ctx, `UPDATE project SET workflow_id = NULL WHERE workflow_id = $1`, created.ID)
|
|
testPool.Exec(ctx, `DELETE FROM issue_workflow WHERE id = $1`, created.ID)
|
|
})
|
|
return created
|
|
}
|
|
|
|
// resetDefaultWorkflow drops the shared workspace's stored Default after the
|
|
// test, so the next test snapshots its own.
|
|
func resetDefaultWorkflow(t *testing.T) {
|
|
t.Helper()
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_workflow WHERE workspace_id = $1 AND is_default`, testWorkspaceID)
|
|
})
|
|
}
|
|
|
|
func createWorkflowTestProject(t *testing.T, title string) string {
|
|
t.Helper()
|
|
projectID := dbfx.Project(t, title)
|
|
t.Cleanup(func() {
|
|
ctx := context.Background()
|
|
testPool.Exec(ctx, `DELETE FROM issue_workflow WHERE id = (SELECT workflow_id FROM project WHERE id = $1)
|
|
AND NOT EXISTS (SELECT 1 FROM project p WHERE p.workflow_id = issue_workflow.id AND p.id <> $1)`, projectID)
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE project_id = $1`, projectID)
|
|
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectID)
|
|
})
|
|
return projectID
|
|
}
|
|
|
|
func setProjectWorkflow(t *testing.T, projectID string, body map[string]any) *testutil.Response {
|
|
t.Helper()
|
|
req := withURLParam(workflowRequest("POST", "/api/projects/"+projectID+"/workflow", body), "id", projectID)
|
|
return testutil.Call(t, testHandler.SetProjectWorkflow, req)
|
|
}
|
|
|
|
// projectWorkflowID is the workflow a project uses. For a project on Default
|
|
// it stores Default and drops it after the test, since Default's handoffs
|
|
// apply to every test project on it.
|
|
func projectWorkflowID(t *testing.T, projectID string) string {
|
|
t.Helper()
|
|
var id *string
|
|
dbfx.QueryRow(t, `SELECT workflow_id::text FROM project WHERE id = $1`, projectID).Scan(&id)
|
|
if id != nil {
|
|
return *id
|
|
}
|
|
resetDefaultWorkflow(t)
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, true)
|
|
var list struct {
|
|
Workflows []IssueWorkflowResponse `json:"workflows"`
|
|
}
|
|
testutil.Call(t, testHandler.ListIssueWorkflows, workflowRequest("GET", "/api/issue-workflows", nil)).Want(http.StatusOK).JSON(&list)
|
|
return list.Workflows[0].ID
|
|
}
|
|
|
|
func setWorkflowHandoff(t *testing.T, workflowID, statusKey string, body map[string]any) *testutil.Response {
|
|
t.Helper()
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, true)
|
|
req := withURLParams(workflowRequest("PUT", "/api/issue-workflows/"+workflowID+"/handoffs/"+statusKey, body),
|
|
"id", workflowID, "status", statusKey)
|
|
return testutil.Call(t, testHandler.SetIssueWorkflowHandoff, req)
|
|
}
|
|
|
|
// setColumnHandoff sets a column's handoff on the workflow a project uses.
|
|
func setColumnHandoff(t *testing.T, projectID, statusKey string, body map[string]any) *testutil.Response {
|
|
t.Helper()
|
|
return setWorkflowHandoff(t, projectWorkflowID(t, projectID), statusKey, body)
|
|
}
|
|
|
|
func handToAgent(agentID, instructions string) map[string]any {
|
|
return map[string]any{"handler": map[string]any{"type": "agent", "id": agentID}, "instructions": instructions}
|
|
}
|
|
|
|
func updateIssueForTest(t *testing.T, issueID string, body map[string]any) *testutil.Response {
|
|
t.Helper()
|
|
req := withURLParam(newRequest("PUT", "/api/issues/"+issueID, body), "id", issueID)
|
|
return testutil.Call(t, testHandler.UpdateIssue, req)
|
|
}
|
|
|
|
func issueStatusAndAssignee(t *testing.T, issueID string) (status, assigneeType, assigneeID string) {
|
|
t.Helper()
|
|
var at, aid *string
|
|
dbfx.QueryRow(t, `SELECT status, assignee_type, assignee_id::text FROM issue WHERE id = $1`, issueID).Scan(&status, &at, &aid)
|
|
if at != nil {
|
|
assigneeType = *at
|
|
}
|
|
if aid != nil {
|
|
assigneeID = *aid
|
|
}
|
|
return status, assigneeType, assigneeID
|
|
}
|
|
|
|
// deliveryProject is the design's simplest case: a project on a Default that
|
|
// hands in_progress to an agent and keeps everything else as is.
|
|
func deliveryProject(t *testing.T, title, agentID string) string {
|
|
t.Helper()
|
|
projectID := createWorkflowTestProject(t, title)
|
|
setColumnHandoff(t, projectID, "in_progress", map[string]any{
|
|
"handler": map[string]any{"type": "agent", "id": agentID}, "instructions": "Implement it and open a PR.",
|
|
}).Want(http.StatusOK)
|
|
return projectID
|
|
}
|
|
|
|
func TestCreateIssueWorkflowRequiresFlag(t *testing.T) {
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, false)
|
|
testutil.Call(t, testHandler.CreateIssueWorkflow, workflowRequest("POST", "/api/issue-workflows", map[string]any{
|
|
"name": "Flag off " + workflowTestSuffix(),
|
|
"steps": statusSteps(),
|
|
})).Want(http.StatusForbidden)
|
|
}
|
|
|
|
func TestCreateIssueWorkflowPlacesFixedStatusesAndValidates(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
name := "Validate " + workflowTestSuffix()
|
|
created := createTestWorkflow(t, name, "done", "in_review")
|
|
keys := make([]string, len(created.Steps))
|
|
for i, s := range created.Steps {
|
|
keys[i] = s.StatusKey
|
|
}
|
|
if got := strings.Join(keys, ","); got != "backlog,todo,in_review,blocked,done,cancelled" {
|
|
t.Fatalf("steps = %s, want the fixed statuses around in_review", got)
|
|
}
|
|
|
|
for label, body := range map[string]map[string]any{
|
|
"unknown status": {"name": "x" + workflowTestSuffix(), "steps": statusSteps("no_such_status")},
|
|
"reserved name": {"name": "Default", "steps": statusSteps()},
|
|
"blank new": {"name": "z" + workflowTestSuffix(), "steps": []map[string]any{{"new_status": map[string]any{"name": " "}}}},
|
|
} {
|
|
t.Run(label, func(t *testing.T) {
|
|
testutil.Call(t, testHandler.CreateIssueWorkflow, workflowRequest("POST", "/api/issue-workflows", body)).
|
|
Want(http.StatusBadRequest)
|
|
})
|
|
}
|
|
testutil.Call(t, testHandler.CreateIssueWorkflow, workflowRequest("POST", "/api/issue-workflows", map[string]any{
|
|
"name": strings.ToUpper(name), "steps": statusSteps(),
|
|
})).Want(http.StatusConflict)
|
|
}
|
|
|
|
func TestCreateIssueWorkflowCreatesNewStatusesOutsideDefault(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
statusName := "Spec " + workflowTestSuffix()[10:]
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, true)
|
|
var created IssueWorkflowResponse
|
|
testutil.Call(t, testHandler.CreateIssueWorkflow, workflowRequest("POST", "/api/issue-workflows", map[string]any{
|
|
"name": "Template " + workflowTestSuffix(),
|
|
"steps": []map[string]any{{"new_status": map[string]any{"name": statusName, "category": "started"}}, {"status_key": "in_review"}},
|
|
})).Want(http.StatusCreated).JSON(&created)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_workflow WHERE id = $1`, created.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE workspace_id = $1 AND name = $2`, testWorkspaceID, statusName)
|
|
})
|
|
newKey := created.Steps[1].StatusKey
|
|
if issuestatusName := lookupStatusName(t, newKey); issuestatusName != statusName {
|
|
t.Fatalf("step 2 = %q (%q), want the new status", newKey, issuestatusName)
|
|
}
|
|
|
|
var list struct {
|
|
Workflows []IssueWorkflowResponse `json:"workflows"`
|
|
}
|
|
testutil.Call(t, testHandler.ListIssueWorkflows, workflowRequest("GET", "/api/issue-workflows", nil)).Want(http.StatusOK).JSON(&list)
|
|
if len(list.Workflows) == 0 || !list.Workflows[0].IsDefault {
|
|
t.Fatalf("list = %+v, want Default first", list.Workflows)
|
|
}
|
|
for _, s := range list.Workflows[0].Steps {
|
|
if s.StatusKey == newKey {
|
|
t.Fatalf("a status made for a workflow joined Default: %+v", list.Workflows[0].Steps)
|
|
}
|
|
}
|
|
}
|
|
|
|
func lookupStatusName(t *testing.T, key string) string {
|
|
t.Helper()
|
|
var name string
|
|
dbfx.QueryRow(t, `SELECT name FROM issue_status WHERE workspace_id = $1 AND key = $2`, testWorkspaceID, key).Scan(&name)
|
|
return name
|
|
}
|
|
|
|
func TestDefaultWorkflowIsEditableButNotDeletable(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, true)
|
|
var list struct {
|
|
Workflows []IssueWorkflowResponse `json:"workflows"`
|
|
}
|
|
testutil.Call(t, testHandler.ListIssueWorkflows, workflowRequest("GET", "/api/issue-workflows", nil)).Want(http.StatusOK).JSON(&list)
|
|
def := list.Workflows[0]
|
|
if !def.IsDefault || def.Name != "Default" {
|
|
t.Fatalf("first workflow = %+v, want Default", def)
|
|
}
|
|
projectID := createWorkflowTestProject(t, "Default usage project")
|
|
issueID := dbfx.Issue(t, "stays on in_review", testutil.Cols{"project_id": projectID, "status": "in_review"})
|
|
|
|
var updated IssueWorkflowResponse
|
|
req := withURLParam(workflowRequest("PATCH", "/api/issue-workflows/"+def.ID, map[string]any{
|
|
"name": "Renamed", "steps": statusSteps("backlog", "in_progress", "blocked"),
|
|
}), "id", def.ID)
|
|
testutil.Call(t, testHandler.UpdateIssueWorkflow, req).Want(http.StatusOK).JSON(&updated)
|
|
if updated.Name != "Default" || len(updated.Steps) != 6 {
|
|
t.Fatalf("updated Default = %+v, want its name kept and in_review removed", updated)
|
|
}
|
|
// Default accepts every status, so dropping one moves no issue.
|
|
if status, _, _ := issueStatusAndAssignee(t, issueID); status != "in_review" {
|
|
t.Fatalf("editing Default moved an issue to %q", status)
|
|
}
|
|
updateIssueForTest(t, issueID, map[string]any{"status": "in_review"}).Want(http.StatusOK)
|
|
|
|
del := withURLParam(workflowRequest("DELETE", "/api/issue-workflows/"+def.ID, nil), "id", def.ID)
|
|
testutil.Call(t, testHandler.DeleteIssueWorkflow, del).Want(http.StatusBadRequest)
|
|
}
|
|
|
|
func TestSetProjectWorkflowMapsOnlyUnlistedStatuses(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
agentID := seededReadyAgentID(t)
|
|
wf := createTestWorkflow(t, "Map "+workflowTestSuffix(), "in_review")
|
|
projectID := createWorkflowTestProject(t, "Workflow map project")
|
|
setWorkflowHandoff(t, wf.ID, "in_review", handToAgent(agentID, "")).Want(http.StatusOK)
|
|
keep := dbfx.Issue(t, "stays on todo", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
moved := dbfx.Issue(t, "leaves in_progress", testutil.Cols{"project_id": projectID, "status": "in_progress"})
|
|
|
|
var dry struct {
|
|
Plan workflowMappingPlan `json:"plan"`
|
|
}
|
|
setProjectWorkflow(t, projectID, map[string]any{"workflow_id": wf.ID, "dry_run": true}).Want(http.StatusOK).JSON(&dry)
|
|
if len(dry.Plan.Required) != 1 || dry.Plan.Required[0].StatusKey != "in_progress" || dry.Plan.Required[0].IssueCount != 1 {
|
|
t.Fatalf("plan = %+v, want only in_progress with one issue", dry.Plan)
|
|
}
|
|
if len(dry.Plan.Unchanged) != 1 || dry.Plan.Unchanged[0].StatusKey != "todo" {
|
|
t.Fatalf("unchanged = %+v, want todo kept", dry.Plan.Unchanged)
|
|
}
|
|
if got := dry.Plan.Required[0].SuggestedStatusKey; got != "in_review" {
|
|
t.Fatalf("suggested target = %q, want the same-category step in_review", got)
|
|
}
|
|
|
|
setProjectWorkflow(t, projectID, map[string]any{"workflow_id": wf.ID}).Want(http.StatusConflict)
|
|
setProjectWorkflow(t, projectID, map[string]any{
|
|
"workflow_id": wf.ID,
|
|
"status_mapping": map[string]string{"in_progress": "code_review_nope"},
|
|
}).Want(http.StatusConflict)
|
|
|
|
setProjectWorkflow(t, projectID, map[string]any{
|
|
"workflow_id": wf.ID,
|
|
"status_mapping": map[string]string{"in_progress": "in_review"},
|
|
}).Want(http.StatusOK)
|
|
|
|
if status, _, _ := issueStatusAndAssignee(t, keep); status != "todo" {
|
|
t.Fatalf("an issue on a listed status moved to %q", status)
|
|
}
|
|
status, assigneeType, _ := issueStatusAndAssignee(t, moved)
|
|
if status != "in_review" {
|
|
t.Fatalf("mapped issue status = %q, want in_review", status)
|
|
}
|
|
// Mapping is administrative: entering a handoff column this way neither
|
|
// reassigns the issue nor starts a run.
|
|
if assigneeType != "" || taskCountFor(t, moved, agentID) != 0 {
|
|
t.Fatalf("a workflow switch handed the issue off (assignee type %q)", assigneeType)
|
|
}
|
|
|
|
// Switching back to Default needs no mapping and works with the flag off.
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, false)
|
|
setProjectWorkflow(t, projectID, map[string]any{"workflow_id": nil}).Want(http.StatusOK)
|
|
}
|
|
|
|
func TestUpdateIssueRejectsStatusOutsideNamedWorkflow(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
wf := createTestWorkflow(t, "Reject "+workflowTestSuffix())
|
|
projectID := createWorkflowTestProject(t, "Workflow reject project")
|
|
setProjectWorkflow(t, projectID, map[string]any{"workflow_id": wf.ID}).Want(http.StatusOK)
|
|
issueID := dbfx.Issue(t, "reject in_progress", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
|
|
body := updateIssueForTest(t, issueID, map[string]any{"status": "in_progress"}).Want(http.StatusBadRequest).Map()
|
|
if body["code"] != "status_not_in_workflow" {
|
|
t.Fatalf("code = %v, want status_not_in_workflow", body["code"])
|
|
}
|
|
if msg, _ := body["error"].(string); !strings.Contains(msg, "backlog, todo, blocked, done, cancelled") {
|
|
t.Fatalf("error %q should list the allowed keys", msg)
|
|
}
|
|
updateIssueForTest(t, issueID, map[string]any{"status": "blocked"}).Want(http.StatusOK)
|
|
}
|
|
|
|
func TestStatusChangeHandsOffOnDefaultProject(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := deliveryProject(t, "Workflow handoff project", agentID)
|
|
issueID := dbfx.Issue(t, "hand me off", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
|
|
updateIssueForTest(t, issueID, map[string]any{"status": "in_progress"}).Want(http.StatusOK)
|
|
|
|
_, assigneeType, assigneeID := issueStatusAndAssignee(t, issueID)
|
|
if assigneeType != "agent" || assigneeID != agentID {
|
|
t.Fatalf("assignee = %s/%s, want the column's agent %s", assigneeType, assigneeID, agentID)
|
|
}
|
|
tasks, err := testHandler.Queries.ListTasksByIssue(context.Background(), util.MustParseUUID(issueID))
|
|
if err != nil || len(tasks) != 1 {
|
|
t.Fatalf("tasks = %d (%v), want one handoff run", len(tasks), err)
|
|
}
|
|
// The run reads the step's brief from its claim; the handoff note is
|
|
// left for a caller's own instruction.
|
|
if tasks[0].HandoffNote.String != "" {
|
|
t.Fatalf("handoff note = %q, want none: the brief rides the claim", tasks[0].HandoffNote.String)
|
|
}
|
|
brief := claimBriefForTest(t, issueID, tasks[0])
|
|
for _, want := range []string{"Implement it and open a PR.", "This project's workflow — Default (Workflow handoff project)", "Step done → multica issue status"} {
|
|
if !strings.Contains(brief, want) {
|
|
t.Fatalf("claim brief is missing %q:\n%s", want, brief)
|
|
}
|
|
}
|
|
|
|
// A caller-written note rides beside the step's brief.
|
|
back := dbfx.Issue(t, "explicit note", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
updateIssueForTest(t, back, map[string]any{"status": "in_progress", "handoff_note": "Only the login flow."}).Want(http.StatusOK)
|
|
backTasks, _ := testHandler.Queries.ListTasksByIssue(context.Background(), util.MustParseUUID(back))
|
|
if len(backTasks) != 1 || backTasks[0].HandoffNote.String != "Only the login flow." {
|
|
t.Fatalf("explicit handoff note was not kept: %+v", backTasks)
|
|
}
|
|
if !strings.Contains(claimBriefForTest(t, back, backTasks[0]), "Implement it and open a PR.") {
|
|
t.Fatal("a caller-written note must not cost the run its step brief")
|
|
}
|
|
|
|
// A project on a workflow without handoffs hands nothing off.
|
|
plainProject := createWorkflowTestProject(t, "Workflow no handoff project")
|
|
setProjectWorkflow(t, plainProject, map[string]any{"workflow_id": createTestWorkflow(t, "Plain "+workflowTestSuffix(), "in_progress").ID}).Want(http.StatusOK)
|
|
plain := dbfx.Issue(t, "no handoff", testutil.Cols{"project_id": plainProject, "status": "todo"})
|
|
updateIssueForTest(t, plain, map[string]any{"status": "in_progress"}).Want(http.StatusOK)
|
|
if _, at, _ := issueStatusAndAssignee(t, plain); at != "" {
|
|
t.Fatalf("a project without handoffs reassigned the issue to a %s", at)
|
|
}
|
|
}
|
|
|
|
func TestSetWorkflowHandoffValidatesAndClears(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
wf := createTestWorkflow(t, "Handoff validation "+workflowTestSuffix(), "in_progress")
|
|
setWorkflowHandoff(t, wf.ID, "done", map[string]any{"handler": map[string]any{"type": "project_lead"}}).Want(http.StatusBadRequest)
|
|
setWorkflowHandoff(t, wf.ID, "code_review_nope", handToAgent(agentID, "")).Want(http.StatusBadRequest)
|
|
setWorkflowHandoff(t, wf.ID, "in_progress", map[string]any{"handler": map[string]any{"type": "creator"}}).Want(http.StatusBadRequest)
|
|
setWorkflowHandoff(t, wf.ID, "in_progress", map[string]any{
|
|
"handler": map[string]any{"type": "agent", "id": agentID}, "next_status_key": "in_progress",
|
|
}).Want(http.StatusBadRequest)
|
|
|
|
step := func(resp IssueWorkflowResponse) IssueWorkflowStepPayload {
|
|
for _, s := range resp.Steps {
|
|
if s.StatusKey == "in_progress" {
|
|
return s
|
|
}
|
|
}
|
|
t.Fatalf("in_progress missing from %+v", resp.Steps)
|
|
return IssueWorkflowStepPayload{}
|
|
}
|
|
var saved IssueWorkflowResponse
|
|
setWorkflowHandoff(t, wf.ID, "in_progress", handToAgent(agentID, "Build it")).Want(http.StatusOK).JSON(&saved)
|
|
if got := step(saved); got.Handler.ID != agentID || got.Instructions != "Build it" || len(saved.Steps) != 6 {
|
|
t.Fatalf("saved = %+v", saved.Steps)
|
|
}
|
|
setWorkflowHandoff(t, wf.ID, "in_progress", map[string]any{"handler": map[string]any{"type": "none"}}).Want(http.StatusOK).JSON(&saved)
|
|
if got := step(saved); got.Handler.Type != "none" || got.Instructions != "" {
|
|
t.Fatalf("clearing left %+v", got)
|
|
}
|
|
var stored string
|
|
dbfx.QueryRow(t, `SELECT steps::text FROM issue_workflow WHERE id = $1`, wf.ID).Scan(&stored)
|
|
if strings.Contains(stored, "handler") {
|
|
t.Fatalf("cleared handoff still stored: %s", stored)
|
|
}
|
|
}
|
|
|
|
// A workflow's handoffs apply to every project using it, so only workspace
|
|
// admins change them; a project's lead may still switch its workflow.
|
|
func TestWorkflowHandoffsNeedAdmin(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, true)
|
|
suffix := workflowTestSuffix()
|
|
userID := dbfx.User(t, "Workflow member "+suffix, "workflow-member-"+suffix+"@example.com")
|
|
dbfx.Member(t, testWorkspaceID, userID, "member")
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM member WHERE user_id = $1`, userID)
|
|
testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID)
|
|
})
|
|
wf := createTestWorkflow(t, "Permission "+suffix, "in_progress")
|
|
projectID := createWorkflowTestProject(t, "Workflow permission project")
|
|
dbfx.Exec(t, `UPDATE project SET lead_type = 'member', lead_id = $1 WHERE id = $2`, userID, projectID)
|
|
asMember := func(r *http.Request) *http.Request {
|
|
r.Header.Set("X-User-ID", userID)
|
|
return r
|
|
}
|
|
put := withURLParams(asMember(workflowRequest("PUT", "/api/issue-workflows/"+wf.ID+"/handoffs/in_progress", handToAgent(agentID, ""))),
|
|
"id", wf.ID, "status", "in_progress")
|
|
testutil.Call(t, testHandler.SetIssueWorkflowHandoff, put).Want(http.StatusForbidden)
|
|
patch := withURLParam(asMember(workflowRequest("PATCH", "/api/issue-workflows/"+wf.ID, map[string]any{"name": "Taken"})), "id", wf.ID)
|
|
testutil.Call(t, testHandler.UpdateIssueWorkflow, patch).Want(http.StatusForbidden)
|
|
|
|
sw := withURLParam(asMember(workflowRequest("POST", "/api/projects/"+projectID+"/workflow", map[string]any{"workflow_id": wf.ID})), "id", projectID)
|
|
testutil.Call(t, testHandler.SetProjectWorkflow, sw).Want(http.StatusOK)
|
|
}
|
|
|
|
// Backlog and blocked are optional: a workflow without them rejects them, and
|
|
// dropping one maps the issues still on it.
|
|
func TestWorkflowWithoutBacklogOrBlocked(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
lean := createTestWorkflowSteps(t, "Lean "+workflowTestSuffix(), statusSteps("in_progress"))
|
|
keys := make([]string, len(lean.Steps))
|
|
for i, s := range lean.Steps {
|
|
keys[i] = s.StatusKey
|
|
}
|
|
if got := strings.Join(keys, ","); got != "todo,in_progress,done,cancelled" {
|
|
t.Fatalf("lean steps = %s", got)
|
|
}
|
|
projectID := createWorkflowTestProject(t, "Workflow lean project")
|
|
setProjectWorkflow(t, projectID, map[string]any{"workflow_id": lean.ID}).Want(http.StatusOK)
|
|
issueID := dbfx.Issue(t, "lean", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
updateIssueForTest(t, issueID, map[string]any{"status": "blocked"}).Want(http.StatusBadRequest)
|
|
|
|
full := createTestWorkflow(t, "Full "+workflowTestSuffix(), "in_progress")
|
|
other := createWorkflowTestProject(t, "Workflow drop blocked project")
|
|
setProjectWorkflow(t, other, map[string]any{"workflow_id": full.ID}).Want(http.StatusOK)
|
|
stuck := dbfx.Issue(t, "stuck", testutil.Cols{"project_id": other, "status": "blocked"})
|
|
edit := func(body map[string]any) *testutil.Response {
|
|
return testutil.Call(t, testHandler.UpdateIssueWorkflow, withURLParam(workflowRequest("PATCH", "/api/issue-workflows/"+full.ID, body), "id", full.ID))
|
|
}
|
|
var conflict struct {
|
|
Plan workflowMappingPlan `json:"plan"`
|
|
}
|
|
edit(map[string]any{"steps": statusSteps("backlog", "in_progress")}).Want(http.StatusConflict).JSON(&conflict)
|
|
if len(conflict.Plan.Required) != 1 || conflict.Plan.Required[0].StatusKey != "blocked" || conflict.Plan.Required[0].SuggestedStatusKey != "in_progress" {
|
|
t.Fatalf("plan = %+v, want blocked mapped to the same-category in_progress", conflict.Plan)
|
|
}
|
|
edit(map[string]any{"steps": statusSteps("backlog", "in_progress"), "status_mapping": map[string]string{"blocked": "in_progress"}}).Want(http.StatusOK)
|
|
if status, _, _ := issueStatusAndAssignee(t, stuck); status != "in_progress" {
|
|
t.Fatalf("issue on the dropped blocked moved to %q", status)
|
|
}
|
|
}
|
|
|
|
func TestExplicitAssigneeWinsOverStepHandler(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := deliveryProject(t, "Workflow explicit project", agentID)
|
|
issueID := dbfx.Issue(t, "keep my assignee", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
|
|
updateIssueForTest(t, issueID, map[string]any{
|
|
"status": "in_progress", "assignee_type": "member", "assignee_id": testUserID,
|
|
}).Want(http.StatusOK)
|
|
_, assigneeType, assigneeID := issueStatusAndAssignee(t, issueID)
|
|
if assigneeType != "member" || assigneeID != testUserID {
|
|
t.Fatalf("assignee = %s/%s, want the explicit member", assigneeType, assigneeID)
|
|
}
|
|
if n := taskCountFor(t, issueID, agentID); n != 0 {
|
|
t.Fatalf("the step handler ran %d time(s) although the write chose an assignee", n)
|
|
}
|
|
}
|
|
|
|
func TestMoveIntoWorkflowProjectKeepsEquivalentStatusWithoutHandoff(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
agentID := seededReadyAgentID(t)
|
|
wf := createTestWorkflow(t, "Move "+workflowTestSuffix(), "in_review")
|
|
projectID := createWorkflowTestProject(t, "Workflow move project")
|
|
setProjectWorkflow(t, projectID, map[string]any{"workflow_id": wf.ID}).Want(http.StatusOK)
|
|
setColumnHandoff(t, projectID, "in_review", handToAgent(agentID, "")).Want(http.StatusOK)
|
|
issueID := dbfx.Issue(t, "moving in", testutil.Cols{"status": "in_progress"})
|
|
|
|
updateIssueForTest(t, issueID, map[string]any{"project_id": projectID}).Want(http.StatusOK)
|
|
status, assigneeType, _ := issueStatusAndAssignee(t, issueID)
|
|
if status != "in_review" {
|
|
t.Fatalf("status after move = %q, want the same-category step in_review", status)
|
|
}
|
|
if assigneeType != "" || taskCountFor(t, issueID, agentID) != 0 {
|
|
t.Fatal("moving an issue into a project must not hand it off")
|
|
}
|
|
|
|
// A move confirmed in the UI enters the column it lands on.
|
|
entering := dbfx.Issue(t, "entering", testutil.Cols{"status": "in_progress"})
|
|
var preview WorkflowHandoffPreviewResponse
|
|
req := withURLParam(newRequest("GET", "/api/issues/"+entering+"/workflow-handoff?project_id="+projectID, nil), "id", entering)
|
|
testutil.Call(t, testHandler.PreviewWorkflowHandoff, req).Want(http.StatusOK).JSON(&preview)
|
|
if !preview.Handoff || preview.ToStatus != "in_review" || preview.HandlerID != agentID {
|
|
t.Fatalf("move preview = %+v, want a handoff to in_review's agent", preview)
|
|
}
|
|
updateIssueForTest(t, entering, map[string]any{"project_id": projectID, "enter_workflow_step": true}).Want(http.StatusOK)
|
|
status, assigneeType, assigneeID := issueStatusAndAssignee(t, entering)
|
|
if status != "in_review" || assigneeType != "agent" || assigneeID != agentID || taskCountFor(t, entering, agentID) != 1 {
|
|
t.Fatalf("entering move = %s %s/%s, want in_review handed to the agent with one run", status, assigneeType, assigneeID)
|
|
}
|
|
}
|
|
|
|
// Saving a shared workflow's handoff for one project copies the workflow for
|
|
// that project; the other projects keep the original.
|
|
func TestWorkflowHandoffCopyForProject(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
withFeatureFlag(t, testHandler, featureflags.ProjectWorkflowsV1, true)
|
|
wf := createTestWorkflow(t, "Shared "+workflowTestSuffix(), "in_progress")
|
|
mine := createWorkflowTestProject(t, "Workflow copy mine")
|
|
theirs := createWorkflowTestProject(t, "Workflow copy theirs")
|
|
elsewhere := createWorkflowTestProject(t, "Workflow copy elsewhere")
|
|
setProjectWorkflow(t, mine, map[string]any{"workflow_id": wf.ID}).Want(http.StatusOK)
|
|
setProjectWorkflow(t, theirs, map[string]any{"workflow_id": wf.ID}).Want(http.StatusOK)
|
|
|
|
body := handToAgent(agentID, "Only here")
|
|
body["copy_for_project_id"] = elsewhere
|
|
setWorkflowHandoff(t, wf.ID, "in_progress", body).Want(http.StatusConflict)
|
|
|
|
body["copy_for_project_id"] = mine
|
|
var copied IssueWorkflowResponse
|
|
setWorkflowHandoff(t, wf.ID, "in_progress", body).Want(http.StatusCreated).JSON(&copied)
|
|
if copied.ID == wf.ID || !strings.HasPrefix(copied.Name, wf.Name+" (") || len(copied.ProjectIDs) != 1 || copied.ProjectIDs[0] != mine {
|
|
t.Fatalf("copy = %+v, want a new workflow used only by the project", copied)
|
|
}
|
|
handlerOf := func(id string) string {
|
|
var steps string
|
|
dbfx.QueryRow(t, `SELECT steps::text FROM issue_workflow WHERE id = $1`, id).Scan(&steps)
|
|
return steps
|
|
}
|
|
if !strings.Contains(handlerOf(copied.ID), agentID) || strings.Contains(handlerOf(wf.ID), agentID) {
|
|
t.Fatal("the handoff must land on the copy only")
|
|
}
|
|
var theirsWorkflow string
|
|
dbfx.QueryRow(t, `SELECT workflow_id::text FROM project WHERE id = $1`, theirs).Scan(&theirsWorkflow)
|
|
if theirsWorkflow != wf.ID {
|
|
t.Fatalf("the other project moved to %s", theirsWorkflow)
|
|
}
|
|
}
|
|
|
|
func TestProjectCopyName(t *testing.T) {
|
|
taken := map[string]bool{"delivery (payments)": true}
|
|
if got := projectCopyName("Delivery", "Payments", taken); got != "Delivery (Payments 2)" {
|
|
t.Fatalf("name = %q", got)
|
|
}
|
|
long := strings.Repeat("x", 70)
|
|
if got := projectCopyName(long, "P", nil); utf8.RuneCountInString(got) > issueworkflow.MaxNameRunes || !strings.HasSuffix(got, " (P)") {
|
|
t.Fatalf("long name = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCreateIssueHandsOffItsStartingColumn(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := createWorkflowTestProject(t, "Workflow create project")
|
|
setColumnHandoff(t, projectID, "todo", map[string]any{
|
|
"handler": map[string]any{"type": "agent", "id": agentID}, "instructions": "Triage it.",
|
|
}).Want(http.StatusOK)
|
|
|
|
created := createIssueForTest(t, map[string]any{"title": "starts in todo", "project_id": projectID})
|
|
if created.Status != "todo" {
|
|
t.Fatalf("status = %q, want todo", created.Status)
|
|
}
|
|
if created.AssigneeID == nil || *created.AssigneeID != agentID {
|
|
t.Fatalf("assignee = %v, want the todo column's agent", created.AssigneeID)
|
|
}
|
|
tasks, _ := testHandler.Queries.ListTasksByIssue(context.Background(), util.MustParseUUID(created.ID))
|
|
if len(tasks) != 1 || !strings.Contains(claimBriefForTest(t, created.ID, tasks[0]), "Triage it.") {
|
|
t.Fatalf("create did not start the column's run with its brief: %+v", tasks)
|
|
}
|
|
|
|
wf := createTestWorkflow(t, "Create "+workflowTestSuffix())
|
|
named := createWorkflowTestProject(t, "Workflow create named project")
|
|
setProjectWorkflow(t, named, map[string]any{"workflow_id": wf.ID}).Want(http.StatusOK)
|
|
testutil.Call(t, testHandler.CreateIssue, newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "outside the workflow", "project_id": named, "status": "in_progress",
|
|
})).Want(http.StatusBadRequest)
|
|
}
|
|
|
|
func TestArchiveStatusGuardsNamedWorkflowsOnly(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
entry := createTestCustomStatus(t, "wf_guard_"+workflowTestSuffix()[10:], "started")
|
|
createTestWorkflow(t, "Guard "+workflowTestSuffix(), entry.Key)
|
|
|
|
req := withURLParam(newRequest("DELETE", "/api/issue-statuses/"+util.UUIDToString(entry.ID), nil), "id", util.UUIDToString(entry.ID))
|
|
body := testutil.Call(t, testHandler.ArchiveIssueStatus, req).Want(http.StatusConflict).Map()
|
|
if body["code"] != "issue_status_in_workflow" {
|
|
t.Fatalf("code = %v, want issue_status_in_workflow", body["code"])
|
|
}
|
|
|
|
// A status only Default lists archives and leaves Default.
|
|
loose := createTestCustomStatus(t, "wf_loose_"+workflowTestSuffix()[10:], "started")
|
|
var list struct {
|
|
Workflows []IssueWorkflowResponse `json:"workflows"`
|
|
}
|
|
testutil.Call(t, testHandler.ListIssueWorkflows, workflowRequest("GET", "/api/issue-workflows", nil)).Want(http.StatusOK).JSON(&list)
|
|
def := list.Workflows[0]
|
|
patch := withURLParam(workflowRequest("PATCH", "/api/issue-workflows/"+def.ID, map[string]any{
|
|
"steps": statusSteps("in_progress", loose.Key),
|
|
}), "id", def.ID)
|
|
testutil.Call(t, testHandler.UpdateIssueWorkflow, patch).Want(http.StatusOK)
|
|
archive := withURLParam(newRequest("DELETE", "/api/issue-statuses/"+util.UUIDToString(loose.ID), nil), "id", util.UUIDToString(loose.ID))
|
|
testutil.Call(t, testHandler.ArchiveIssueStatus, archive).Want(http.StatusOK)
|
|
var steps string
|
|
dbfx.QueryRow(t, `SELECT steps::text FROM issue_workflow WHERE id = $1`, def.ID).Scan(&steps)
|
|
if strings.Contains(steps, loose.Key) {
|
|
t.Fatalf("Default still lists the archived status: %s", steps)
|
|
}
|
|
}
|
|
|
|
func TestClaimCarriesProjectWorkflow(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
agentID := seededReadyAgentID(t)
|
|
quietProject := createWorkflowTestProject(t, "Workflow quiet project")
|
|
quiet := dbfx.Issue(t, "no handoffs", testutil.Cols{"project_id": quietProject, "status": "todo"})
|
|
quietIssue, _ := testHandler.Queries.GetIssue(context.Background(), util.MustParseUUID(quiet))
|
|
if testHandler.claimProjectWorkflow(context.Background(), quietIssue, nil) != nil {
|
|
t.Fatal("a Default project without handoffs must not carry a workflow")
|
|
}
|
|
|
|
projectID := deliveryProject(t, "Workflow claim project", agentID)
|
|
issueID := dbfx.Issue(t, "claim context", testutil.Cols{
|
|
"project_id": projectID, "status": "in_progress", "assignee_type": "agent", "assignee_id": agentID,
|
|
})
|
|
|
|
// The assignee's run gets the step's instructions and its commands.
|
|
brief := claimBriefForTest(t, issueID, db.AgentTaskQueue{AgentID: util.MustParseUUID(agentID)})
|
|
for _, want := range []string{
|
|
"This project's workflow — Default (Workflow claim project)",
|
|
"Implement it and open a PR.",
|
|
" ← current",
|
|
"Step done → multica issue status",
|
|
} {
|
|
if !strings.Contains(brief, want) {
|
|
t.Fatalf("assignee brief is missing %q:\n%s", want, brief)
|
|
}
|
|
}
|
|
|
|
// Any other agent on the issue — mentioned in a comment, say — gets the
|
|
// statuses and is told to leave them to the assignee.
|
|
other := claimBriefForTest(t, issueID, db.AgentTaskQueue{AgentID: util.MustParseUUID(dbfx.Agent(t, "Workflow observer", handlerTestRuntimeID(t)))})
|
|
if !strings.Contains(other, "You are not its assignee") || strings.Contains(other, "Step done") || strings.Contains(other, "Implement it and open a PR.") {
|
|
t.Fatalf("observer brief must leave the step to its assignee:\n%s", other)
|
|
}
|
|
|
|
plain := dbfx.Issue(t, "no workflow", testutil.Cols{"status": "todo"})
|
|
plainIssue, _ := testHandler.Queries.GetIssue(context.Background(), util.MustParseUUID(plain))
|
|
if testHandler.claimProjectWorkflow(context.Background(), plainIssue, nil) != nil {
|
|
t.Fatal("an issue outside a project must not carry a workflow")
|
|
}
|
|
}
|
|
|
|
// claimBriefForTest renders the workflow brief a claim of task would carry
|
|
// for the issue as it is now.
|
|
func claimBriefForTest(t *testing.T, issueID string, task db.AgentTaskQueue) string {
|
|
t.Helper()
|
|
issue, err := testHandler.Queries.GetIssue(context.Background(), util.MustParseUUID(issueID))
|
|
if err != nil {
|
|
t.Fatalf("load issue: %v", err)
|
|
}
|
|
got := testHandler.claimProjectWorkflow(context.Background(), issue, &task)
|
|
if got == nil {
|
|
t.Fatalf("claim of %s carries no workflow", issueID)
|
|
}
|
|
return got.Brief
|
|
}
|
|
|
|
func TestPreviewWorkflowHandoffNamesHandlerRunsAndBrief(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := deliveryProject(t, "Workflow preview project", agentID)
|
|
issueID := dbfx.Issue(t, "preview me", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
|
|
call := func(status string) *testutil.Response {
|
|
req := withURLParam(newRequest("GET", "/api/issues/"+issueID+"/workflow-handoff?status="+status, nil), "id", issueID)
|
|
return testutil.Call(t, testHandler.PreviewWorkflowHandoff, req)
|
|
}
|
|
var got WorkflowHandoffPreviewResponse
|
|
call("in_progress").Want(http.StatusOK).JSON(&got)
|
|
if !got.Handoff || got.HandlerType != "agent" || got.HandlerID != agentID || got.WorkflowName != "Default" {
|
|
t.Fatalf("preview = %+v, want a handoff to the column's agent", got)
|
|
}
|
|
if !strings.Contains(got.Brief, "Implement it and open a PR.") {
|
|
t.Fatalf("preview brief is missing the step instructions:\n%s", got.Brief)
|
|
}
|
|
if status, _, _ := issueStatusAndAssignee(t, issueID); status != "todo" {
|
|
t.Fatalf("a preview moved the issue to %q", status)
|
|
}
|
|
|
|
var manual WorkflowHandoffPreviewResponse
|
|
call("done").Want(http.StatusOK).JSON(&manual)
|
|
if manual.Handoff {
|
|
t.Fatalf("a column without a handoff previewed one: %+v", manual)
|
|
}
|
|
}
|
|
|
|
func TestPreviewIssueWorkflowBriefUsesTheDraft(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := createWorkflowTestProject(t, "Workflow brief project")
|
|
workflowID := projectWorkflowID(t, projectID)
|
|
var got struct {
|
|
Brief string `json:"brief"`
|
|
}
|
|
preview := func(body map[string]any) *testutil.Response {
|
|
req := withURLParam(workflowRequest("POST", "/api/issue-workflows/"+workflowID+"/brief", body), "id", workflowID)
|
|
return testutil.Call(t, testHandler.PreviewIssueWorkflowBrief, req)
|
|
}
|
|
preview(map[string]any{
|
|
"status_key": "in_progress", "handler": map[string]any{"type": "agent", "id": agentID}, "instructions": "Draft step.",
|
|
"project_id": projectID,
|
|
}).Want(http.StatusOK).JSON(&got)
|
|
for _, want := range []string{"Default (Workflow brief project)", "Draft step.", "← current", "in_review\nCan't proceed"} {
|
|
if !strings.Contains(got.Brief, want) {
|
|
t.Fatalf("draft brief is missing %q:\n%s", want, got.Brief)
|
|
}
|
|
}
|
|
preview(map[string]any{"status_key": "no_such_column"}).Want(http.StatusBadRequest)
|
|
}
|
|
|
|
func TestPreviewIssueWorkflowBriefRejectsMemberOutsideWorkspace(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
projectID := createWorkflowTestProject(t, "Workflow brief member project")
|
|
workflowID := projectWorkflowID(t, projectID)
|
|
preview := func(userID string) *testutil.Response {
|
|
body := map[string]any{"status_key": "in_review", "handler": map[string]any{"type": "member", "id": userID}}
|
|
req := withURLParam(workflowRequest("POST", "/api/issue-workflows/"+workflowID+"/brief", body), "id", workflowID)
|
|
return testutil.Call(t, testHandler.PreviewIssueWorkflowBrief, req)
|
|
}
|
|
|
|
const outsiderName = "Workflow Brief Outsider"
|
|
outsiderID := dbfx.User(t, outsiderName, fmt.Sprintf("brief-outsider-%[email protected]", time.Now().UnixNano()))
|
|
resp := preview(outsiderID).Want(http.StatusBadRequest)
|
|
if strings.Contains(resp.Body.String(), outsiderName) {
|
|
t.Fatalf("preview leaked the name of a user outside the workspace: %s", resp.Body.String())
|
|
}
|
|
|
|
const memberName = "Workflow Brief Member"
|
|
memberID := dbfx.User(t, memberName, fmt.Sprintf("brief-member-%[email protected]", time.Now().UnixNano()))
|
|
dbfx.Member(t, testWorkspaceID, memberID, "member")
|
|
var got struct {
|
|
Brief string `json:"brief"`
|
|
}
|
|
preview(memberID).Want(http.StatusOK).JSON(&got)
|
|
if !strings.Contains(got.Brief, memberName) {
|
|
t.Fatalf("brief does not name the workspace member:\n%s", got.Brief)
|
|
}
|
|
}
|
|
|
|
// agentUpdateIssue changes an issue as the agent running taskID, the way a
|
|
// task token reaches the handler.
|
|
func agentUpdateIssue(t *testing.T, issueID, agentID, taskID string, body map[string]any) *testutil.Response {
|
|
t.Helper()
|
|
req := withURLParam(newRequest("PUT", "/api/issues/"+issueID, body), "id", issueID)
|
|
req.Header.Set("X-Actor-Source", "task_token")
|
|
req.Header.Set("X-Agent-ID", agentID)
|
|
req.Header.Set("X-Task-ID", taskID)
|
|
return testutil.Call(t, testHandler.UpdateIssue, req)
|
|
}
|
|
|
|
func taskWorkflowStep(t *testing.T, taskID string) string {
|
|
t.Helper()
|
|
var step *string
|
|
dbfx.QueryRow(t, `SELECT workflow_step FROM agent_task_queue WHERE id = $1`, taskID).Scan(&step)
|
|
if step == nil {
|
|
return ""
|
|
}
|
|
return *step
|
|
}
|
|
|
|
// The validation report: an agent handling two adjacent columns moves the
|
|
// issue into the second from its own run. Entering the step is new work, so a
|
|
// run for it is queued although the agent is still running.
|
|
func TestSameAgentNextStepQueuesARun(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := deliveryProject(t, "Workflow same agent project", agentID)
|
|
setColumnHandoff(t, projectID, "in_review", handToAgent(agentID, "Review your own PR.")).Want(http.StatusOK)
|
|
issueID := dbfx.Issue(t, "same agent twice", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
|
|
updateIssueForTest(t, issueID, map[string]any{"status": "in_progress"}).Want(http.StatusOK)
|
|
var taskID string
|
|
dbfx.QueryRow(t, `SELECT id::text FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2`, issueID, agentID).Scan(&taskID)
|
|
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'running', started_at = now() WHERE id = $1`, taskID)
|
|
|
|
agentUpdateIssue(t, issueID, agentID, taskID, map[string]any{"status": "in_review"}).Want(http.StatusOK)
|
|
var queued int
|
|
dbfx.QueryRow(t, `SELECT count(*) FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`, issueID, agentID).Scan(&queued)
|
|
if queued != 1 {
|
|
t.Fatalf("queued runs for the next step = %d, want one", queued)
|
|
}
|
|
if brief := claimBriefForTest(t, issueID, db.AgentTaskQueue{AgentID: util.MustParseUUID(agentID)}); !strings.Contains(brief, "Review your own PR.") {
|
|
t.Fatalf("the next step's run would not read its instructions:\n%s", brief)
|
|
}
|
|
}
|
|
|
|
// A run works on the step the issue was at when it was queued. Once someone
|
|
// else moves the issue, the run's status changes are refused — they were
|
|
// decided for a step the issue has left — and the refusal names the move.
|
|
func TestAgentStatusChangeRefusedAfterIssueMoved(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := deliveryProject(t, "Workflow stale step project", agentID)
|
|
issueID := dbfx.Issue(t, "stale step", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
|
|
updateIssueForTest(t, issueID, map[string]any{"status": "in_progress"}).Want(http.StatusOK)
|
|
var taskID string
|
|
dbfx.QueryRow(t, `SELECT id::text FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2`, issueID, agentID).Scan(&taskID)
|
|
if got := taskWorkflowStep(t, taskID); got != "in_progress" {
|
|
t.Fatalf("handoff run step = %q, want in_progress", got)
|
|
}
|
|
|
|
// The run's own move is allowed and carries its step along.
|
|
agentUpdateIssue(t, issueID, agentID, taskID, map[string]any{"status": "in_review"}).Want(http.StatusOK)
|
|
if got := taskWorkflowStep(t, taskID); got != "in_review" {
|
|
t.Fatalf("step after the run's own move = %q, want in_review", got)
|
|
}
|
|
|
|
// A member parks the issue while the run is still going. The activity row
|
|
// is what the server's listener records for that move; handler tests run
|
|
// without the listeners.
|
|
updateIssueForTest(t, issueID, map[string]any{"status": "blocked"}).Want(http.StatusOK)
|
|
dbfx.Exec(t, `INSERT INTO activity_log (workspace_id, issue_id, actor_type, actor_id, action, details)
|
|
VALUES ($1, $2, 'member', $3, 'status_changed', '{"from":"in_review","to":"blocked"}')`, testWorkspaceID, issueID, testUserID)
|
|
|
|
body := agentUpdateIssue(t, issueID, agentID, taskID, map[string]any{"status": "done"}).Want(http.StatusConflict).Map()
|
|
if body["code"] != "workflow_step_moved" || body["step"] != "in_review" || body["current_status"] != "blocked" {
|
|
t.Fatalf("refusal = %v", body)
|
|
}
|
|
var memberName string
|
|
dbfx.QueryRow(t, `SELECT name FROM "user" WHERE id = $1`, testUserID).Scan(&memberName)
|
|
msg, _ := body["error"].(string)
|
|
for _, want := range []string{memberName + " moved it to", `"blocked"`, "not applied"} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Fatalf("refusal %q is missing %q", msg, want)
|
|
}
|
|
}
|
|
if status, _, _ := issueStatusAndAssignee(t, issueID); status != "blocked" {
|
|
t.Fatalf("status = %q, the refused change must not apply", status)
|
|
}
|
|
|
|
// Anything but a status change still goes through.
|
|
agentUpdateIssue(t, issueID, agentID, taskID, map[string]any{"title": "stale step, noted"}).Want(http.StatusOK)
|
|
agentUpdateIssue(t, issueID, agentID, taskID, map[string]any{"status": "blocked"}).Want(http.StatusOK)
|
|
|
|
// A run queued after the move works on the new step.
|
|
var runtimeID string
|
|
dbfx.QueryRow(t, `SELECT runtime_id::text FROM agent WHERE id = $1`, agentID).Scan(&runtimeID)
|
|
fresh := dbfx.Task(t, agentID, testutil.Cols{
|
|
"issue_id": issueID, "runtime_id": runtimeID, "status": "running", "started_at": time.Now(), "workflow_step": "blocked",
|
|
})
|
|
agentUpdateIssue(t, issueID, agentID, fresh, map[string]any{"status": "in_review"}).Want(http.StatusOK)
|
|
}
|
|
|
|
// A project on Default with no handoffs keeps the pre-workflow behavior: a run
|
|
// may still change the status after someone else moved the issue.
|
|
func TestAgentStatusChangeOnDefaultIgnoresStep(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := createWorkflowTestProject(t, "Workflow default step project")
|
|
issueID := dbfx.Issue(t, "default step", testutil.Cols{"project_id": projectID, "status": "blocked"})
|
|
var runtimeID string
|
|
dbfx.QueryRow(t, `SELECT runtime_id::text FROM agent WHERE id = $1`, agentID).Scan(&runtimeID)
|
|
taskID := dbfx.Task(t, agentID, testutil.Cols{
|
|
"issue_id": issueID, "runtime_id": runtimeID, "status": "running", "started_at": time.Now(), "workflow_step": "todo",
|
|
})
|
|
|
|
agentUpdateIssue(t, issueID, agentID, taskID, map[string]any{"status": "in_review"}).Want(http.StatusOK)
|
|
}
|
|
|
|
// Squads can't be handed to, but a handoff away from an issue a squad owns
|
|
// offers to stop — and stops — the runs made on
|
|
// the squad's behalf, recording who stopped them.
|
|
func TestHandoffStopsPreviousSquadRuns(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
agentID := seededReadyAgentID(t)
|
|
squadID := dbfx.Squad(t, "Workflow review squad "+workflowTestSuffix(), agentID)
|
|
projectID := createWorkflowTestProject(t, "Workflow squad stop project")
|
|
setColumnHandoff(t, projectID, "in_progress", map[string]any{"handler": map[string]any{"type": "member", "id": testUserID}}).Want(http.StatusOK)
|
|
setColumnHandoff(t, projectID, "in_review", map[string]any{"handler": map[string]any{"type": "squad", "id": squadID}}).Want(http.StatusBadRequest)
|
|
issueID := dbfx.Issue(t, "squad reviewing", testutil.Cols{
|
|
"project_id": projectID, "status": "in_review", "assignee_type": "squad", "assignee_id": squadID,
|
|
})
|
|
var runtimeID string
|
|
dbfx.QueryRow(t, `SELECT runtime_id::text FROM agent WHERE id = $1`, agentID).Scan(&runtimeID)
|
|
running := dbfx.Task(t, agentID, testutil.Cols{
|
|
"issue_id": issueID, "squad_id": squadID, "runtime_id": runtimeID, "status": "running", "started_at": time.Now(),
|
|
})
|
|
|
|
var preview WorkflowHandoffPreviewResponse
|
|
req := withURLParam(newRequest("GET", "/api/issues/"+issueID+"/workflow-handoff?status=in_progress", nil), "id", issueID)
|
|
testutil.Call(t, testHandler.PreviewWorkflowHandoff, req).Want(http.StatusOK).JSON(&preview)
|
|
if len(preview.PreviousRuns) != 1 || preview.PreviousRuns[0].TaskID != running {
|
|
t.Fatalf("preview runs = %+v, want the squad's run", preview.PreviousRuns)
|
|
}
|
|
|
|
updateIssueForTest(t, issueID, map[string]any{"status": "in_progress", "stop_previous_assignee_runs": true}).Want(http.StatusOK)
|
|
var status string
|
|
var stoppedBy *string
|
|
dbfx.QueryRow(t, `SELECT status, cancelled_by_name FROM agent_task_queue WHERE id = $1`, running).Scan(&status, &stoppedBy)
|
|
var memberName string
|
|
dbfx.QueryRow(t, `SELECT name FROM "user" WHERE id = $1`, testUserID).Scan(&memberName)
|
|
if status != "cancelled" || stoppedBy == nil || *stoppedBy != memberName {
|
|
t.Fatalf("squad run = %s stopped by %v, want cancelled by %s", status, stoppedBy, memberName)
|
|
}
|
|
}
|
|
|
|
// Remapping an issue into a done status on a workflow switch ends its wakeups,
|
|
// as any status write into done does.
|
|
func TestWorkflowSwitchIntoDoneEndsWakeups(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
resetDefaultWorkflow(t)
|
|
agentID := seededReadyAgentID(t)
|
|
projectID := createWorkflowTestProject(t, "Workflow switch wakeups project")
|
|
issueID := dbfx.Issue(t, "wakeups end with the issue", testutil.Cols{"project_id": projectID, "status": "in_review"})
|
|
svc := service.IssueWakeupService{Tasks: testHandler.TaskService}
|
|
wakeup, err := svc.Create(context.Background(), parseUUID(issueID), parseUUID(testUserID), pgtype.UUID{},
|
|
service.WakeupInput{AgentID: agentID, Kind: "at", AfterSeconds: 600, Instruction: "check the PR"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue_wakeup WHERE issue_id = $1`, issueID) })
|
|
var runtimeID string
|
|
dbfx.QueryRow(t, `SELECT runtime_id::text FROM agent WHERE id = $1`, agentID).Scan(&runtimeID)
|
|
pending := dbfx.Task(t, agentID, testutil.Cols{
|
|
"issue_id": issueID, "runtime_id": runtimeID, "status": "queued",
|
|
"context": testutil.Raw("jsonb_build_object('wakeup_id', '" + uuidToString(wakeup.ID) + "')"),
|
|
})
|
|
|
|
wf := createTestWorkflow(t, "Wakeup switch "+workflowTestSuffix())
|
|
setProjectWorkflow(t, projectID, map[string]any{
|
|
"workflow_id": wf.ID, "status_mapping": map[string]string{"in_review": "done"},
|
|
}).Want(http.StatusOK)
|
|
|
|
var enabled bool
|
|
var taskStatus string
|
|
dbfx.QueryRow(t, `SELECT enabled FROM issue_wakeup WHERE id = $1`, uuidToString(wakeup.ID)).Scan(&enabled)
|
|
dbfx.QueryRow(t, `SELECT status FROM agent_task_queue WHERE id = $1`, pending).Scan(&taskStatus)
|
|
if enabled || taskStatus != "cancelled" {
|
|
t.Fatalf("after the switch into done: wakeup enabled=%v, pending wakeup run %s", enabled, taskStatus)
|
|
}
|
|
}
|
|
|
|
// A write that hands an issue to an agent delivers the change to that agent
|
|
// as its run, so the agent's event wakeups skip it; other agents' wakeups and
|
|
// the platform's own rules (which have no agent) still capture it. Holds for
|
|
// workflow handoffs and plain assignment alike.
|
|
func TestHandoffDoesNotAlsoWakeTheHandler(t *testing.T) {
|
|
seedTestCatalog(t)
|
|
handler := seededReadyAgentID(t)
|
|
var runtimeID string
|
|
dbfx.QueryRow(t, `SELECT runtime_id::text FROM agent WHERE id = $1`, handler).Scan(&runtimeID)
|
|
watcher := dbfx.Agent(t, "Workflow wakeup watcher "+workflowTestSuffix(), runtimeID)
|
|
projectID := deliveryProject(t, "Workflow wakeup dedupe project", handler)
|
|
svc := service.IssueWakeupService{Tasks: testHandler.TaskService}
|
|
subscribe := func(issueID, agentID string) string {
|
|
t.Helper()
|
|
w, err := svc.Create(context.Background(), parseUUID(issueID), parseUUID(testUserID), pgtype.UUID{}, service.WakeupInput{
|
|
AgentID: agentID, Kind: "event", EventTypes: []string{"issue.status_changed", "issue.assignee_changed"}, Instruction: "look again",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_wakeup_receipt WHERE wakeup_id = $1`, w.ID)
|
|
})
|
|
return uuidToString(w.ID)
|
|
}
|
|
pending := func(wakeupID string) int {
|
|
t.Helper()
|
|
var n int
|
|
dbfx.QueryRow(t, `SELECT count(*) FROM issue_wakeup_receipt WHERE wakeup_id = $1 AND processed_at IS NULL`, wakeupID).Scan(&n)
|
|
return n
|
|
}
|
|
|
|
handedOff := dbfx.Issue(t, "handed off", testutil.Cols{"project_id": projectID, "status": "todo"})
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue_wakeup WHERE issue_id = $1`, handedOff) })
|
|
handlerWakeup, watcherWakeup := subscribe(handedOff, handler), subscribe(handedOff, watcher)
|
|
platformRule := func(issueID string) string {
|
|
t.Helper()
|
|
var id string
|
|
dbfx.QueryRow(t, `INSERT INTO issue_wakeup (id, workspace_id, issue_id, instruction, kind, mode, event_types, system_rule)
|
|
VALUES (gen_random_uuid(), $1, $2, 'stage closed', 'event', 'continuous', ARRAY['issue.status_changed'], 'child_done')
|
|
RETURNING id::text`, testWorkspaceID, issueID).Scan(&id)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue_wakeup_receipt WHERE wakeup_id = $1`, id)
|
|
})
|
|
return id
|
|
}
|
|
handedOffRule := platformRule(handedOff)
|
|
updateIssueForTest(t, handedOff, map[string]any{"status": "in_progress"}).Want(http.StatusOK)
|
|
if n := pending(handlerWakeup); n != 0 {
|
|
t.Fatalf("the handoff also woke its handler: %d receipt(s)", n)
|
|
}
|
|
if n := pending(watcherWakeup); n == 0 {
|
|
t.Fatal("another agent's wakeup missed the handoff")
|
|
}
|
|
if n := pending(handedOffRule); n == 0 {
|
|
t.Fatal("the platform rule missed the handoff")
|
|
}
|
|
|
|
parked := dbfx.Issue(t, "parked without a handoff", testutil.Cols{"status": "todo"})
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue_wakeup WHERE issue_id = $1`, parked) })
|
|
parkedRule := platformRule(parked)
|
|
updateIssueForTest(t, parked, map[string]any{"status": "backlog"}).Want(http.StatusOK)
|
|
if n := pending(parkedRule); n == 0 {
|
|
t.Fatal("the platform rule missed a status change that handed the issue to nobody")
|
|
}
|
|
|
|
assigned := dbfx.Issue(t, "assigned", testutil.Cols{"status": "todo"})
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue_wakeup WHERE issue_id = $1`, assigned) })
|
|
handlerWakeup, watcherWakeup = subscribe(assigned, handler), subscribe(assigned, watcher)
|
|
updateIssueForTest(t, assigned, map[string]any{"assignee_type": "agent", "assignee_id": handler}).Want(http.StatusOK)
|
|
if n := pending(handlerWakeup); n != 0 {
|
|
t.Fatalf("assignment also woke the assignee: %d receipt(s)", n)
|
|
}
|
|
if n := pending(watcherWakeup); n == 0 {
|
|
t.Fatal("another agent's wakeup missed the assignment")
|
|
}
|
|
}
|