From dfaf14f061c6a7b1a7bd3d5d1bd5d6fb28c1546c Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 16:50:26 +0000 Subject: [PATCH] feat(agentic): add issue assignment action Co-Authored-By: Virgil --- pkg/agentic/issue.go | 58 +++++++++++++++++++++++++++++++++++++++ pkg/agentic/issue_test.go | 56 ++++++++++++++++++++++++++++++++++++- pkg/agentic/prep.go | 4 ++- pkg/agentic/prep_test.go | 2 ++ 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/pkg/agentic/issue.go b/pkg/agentic/issue.go index 68dedbc..a51becd 100644 --- a/pkg/agentic/issue.go +++ b/pkg/agentic/issue.go @@ -20,6 +20,7 @@ type Issue struct { Type string `json:"type,omitempty"` Status string `json:"status,omitempty"` Priority string `json:"priority,omitempty"` + Assignee string `json:"assignee,omitempty"` Labels []string `json:"labels,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` CreatedAt string `json:"created_at,omitempty"` @@ -43,6 +44,7 @@ type IssueCreateInput struct { Type string `json:"type,omitempty"` Status string `json:"status,omitempty"` Priority string `json:"priority,omitempty"` + Assignee string `json:"assignee,omitempty"` Labels []string `json:"labels,omitempty"` SprintID int `json:"sprint_id,omitempty"` SprintSlug string `json:"sprint_slug,omitempty"` @@ -72,11 +74,19 @@ type IssueUpdateInput struct { Type string `json:"type,omitempty"` Status string `json:"status,omitempty"` Priority string `json:"priority,omitempty"` + Assignee string `json:"assignee,omitempty"` Labels []string `json:"labels,omitempty"` SprintID int `json:"sprint_id,omitempty"` SprintSlug string `json:"sprint_slug,omitempty"` } +// input := agentic.IssueAssignInput{Slug: "fix-auth", Assignee: "codex"} +type IssueAssignInput struct { + ID string `json:"id,omitempty"` + Slug string `json:"slug,omitempty"` + Assignee string `json:"assignee,omitempty"` +} + // input := agentic.IssueCommentInput{Slug: "fix-auth", Body: "Ready for review"} type IssueCommentInput struct { ID string `json:"id,omitempty"` @@ -126,6 +136,7 @@ func (s *PrepSubsystem) handleIssueRecordCreate(ctx context.Context, options cor Type: optionStringValue(options, "type"), Status: optionStringValue(options, "status"), Priority: optionStringValue(options, "priority"), + Assignee: optionStringValue(options, "assignee"), Labels: optionStringSliceValue(options, "labels"), SprintID: optionIntValue(options, "sprint_id", "sprint-id"), SprintSlug: optionStringValue(options, "sprint_slug", "sprint-slug"), @@ -173,6 +184,7 @@ func (s *PrepSubsystem) handleIssueRecordUpdate(ctx context.Context, options cor Type: optionStringValue(options, "type"), Status: optionStringValue(options, "status"), Priority: optionStringValue(options, "priority"), + Assignee: optionStringValue(options, "assignee"), Labels: optionStringSliceValue(options, "labels"), SprintID: optionIntValue(options, "sprint_id", "sprint-id"), SprintSlug: optionStringValue(options, "sprint_slug", "sprint-slug"), @@ -183,6 +195,24 @@ func (s *PrepSubsystem) handleIssueRecordUpdate(ctx context.Context, options cor return core.Result{Value: output, OK: true} } +// result := c.Action("issue.assign").Run(ctx, core.NewOptions( +// +// core.Option{Key: "slug", Value: "fix-auth"}, +// core.Option{Key: "assignee", Value: "codex"}, +// +// )) +func (s *PrepSubsystem) handleIssueRecordAssign(ctx context.Context, options core.Options) core.Result { + _, output, err := s.issueAssign(ctx, nil, IssueAssignInput{ + ID: optionStringValue(options, "id", "_arg"), + Slug: optionStringValue(options, "slug"), + Assignee: optionStringValue(options, "assignee", "agent", "agent_type"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + // result := c.Action("issue.comment").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"})) func (s *PrepSubsystem) handleIssueRecordComment(ctx context.Context, options core.Options) core.Result { _, output, err := s.issueComment(ctx, nil, IssueCommentInput{ @@ -232,6 +262,11 @@ func (s *PrepSubsystem) registerIssueTools(server *mcp.Server) { Description: "Update fields on a tracked platform issue by slug.", }, s.issueUpdate) + mcp.AddTool(server, &mcp.Tool{ + Name: "issue_assign", + Description: "Assign an agent or user to a tracked platform issue by slug.", + }, s.issueAssign) + mcp.AddTool(server, &mcp.Tool{ Name: "issue_comment", Description: "Add a comment to a tracked platform issue.", @@ -263,6 +298,9 @@ func (s *PrepSubsystem) issueCreate(ctx context.Context, _ *mcp.CallToolRequest, if input.Priority != "" { body["priority"] = input.Priority } + if input.Assignee != "" { + body["assignee"] = input.Assignee + } if len(input.Labels) > 0 { body["labels"] = input.Labels } @@ -343,6 +381,9 @@ func (s *PrepSubsystem) issueUpdate(ctx context.Context, _ *mcp.CallToolRequest, if input.Priority != "" { body["priority"] = input.Priority } + if input.Assignee != "" { + body["assignee"] = input.Assignee + } if len(input.Labels) > 0 { body["labels"] = input.Labels } @@ -367,6 +408,22 @@ func (s *PrepSubsystem) issueUpdate(ctx context.Context, _ *mcp.CallToolRequest, }, nil } +func (s *PrepSubsystem) issueAssign(ctx context.Context, _ *mcp.CallToolRequest, input IssueAssignInput) (*mcp.CallToolResult, IssueOutput, error) { + identifier := issueRecordIdentifier(input.Slug, input.ID) + if identifier == "" { + return nil, IssueOutput{}, core.E("issueAssign", "id or slug is required", nil) + } + if input.Assignee == "" { + return nil, IssueOutput{}, core.E("issueAssign", "assignee is required", nil) + } + + return s.issueUpdate(ctx, nil, IssueUpdateInput{ + ID: input.ID, + Slug: input.Slug, + Assignee: input.Assignee, + }) +} + func (s *PrepSubsystem) issueComment(ctx context.Context, _ *mcp.CallToolRequest, input IssueCommentInput) (*mcp.CallToolResult, IssueCommentOutput, error) { identifier := issueRecordIdentifier(input.Slug, input.IssueID, input.ID) if identifier == "" { @@ -434,6 +491,7 @@ func parseIssue(values map[string]any) Issue { Type: stringValue(values["type"]), Status: stringValue(values["status"]), Priority: stringValue(values["priority"]), + Assignee: stringValue(values["assignee"]), Labels: listValue(values["labels"]), Metadata: anyMapValue(values["metadata"]), CreatedAt: stringValue(values["created_at"]), diff --git a/pkg/agentic/issue_test.go b/pkg/agentic/issue_test.go index 855026c..165771a 100644 --- a/pkg/agentic/issue_test.go +++ b/pkg/agentic/issue_test.go @@ -26,8 +26,9 @@ func TestIssue_HandleIssueRecordCreate_Good(t *testing.T) { require.True(t, parseResult.OK) require.Equal(t, "Fix auth", payload["title"]) require.Equal(t, "bug", payload["type"]) + require.Equal(t, "codex", payload["assignee"]) - _, _ = w.Write([]byte(`{"data":{"slug":"fix-auth","title":"Fix auth","type":"bug","status":"open","priority":"high","labels":["auth"]}}`)) + _, _ = w.Write([]byte(`{"data":{"slug":"fix-auth","title":"Fix auth","type":"bug","status":"open","priority":"high","assignee":"codex","labels":["auth"]}}`)) })) defer server.Close() @@ -36,6 +37,7 @@ func TestIssue_HandleIssueRecordCreate_Good(t *testing.T) { core.Option{Key: "title", Value: "Fix auth"}, core.Option{Key: "type", Value: "bug"}, core.Option{Key: "priority", Value: "high"}, + core.Option{Key: "assignee", Value: "codex"}, core.Option{Key: "labels", Value: "auth"}, )) require.True(t, result.OK) @@ -45,6 +47,7 @@ func TestIssue_HandleIssueRecordCreate_Good(t *testing.T) { assert.True(t, output.Success) assert.Equal(t, "fix-auth", output.Issue.Slug) assert.Equal(t, "open", output.Issue.Status) + assert.Equal(t, "codex", output.Issue.Assignee) assert.Equal(t, []string{"auth"}, output.Issue.Labels) } @@ -75,6 +78,57 @@ func TestIssue_HandleIssueRecordGet_Good_IDAlias(t *testing.T) { assert.Equal(t, "fix-auth", output.Issue.Slug) } +func TestIssue_HandleIssueRecordAssign_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/issues/fix-auth", r.URL.Path) + require.Equal(t, http.MethodPatch, r.Method) + + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + + var payload map[string]any + parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload) + require.True(t, parseResult.OK) + require.Equal(t, "codex", payload["assignee"]) + + _, _ = w.Write([]byte(`{"data":{"issue":{"slug":"fix-auth","title":"Fix auth","status":"assigned","assignee":"codex"}}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleIssueRecordAssign(context.Background(), core.NewOptions( + core.Option{Key: "slug", Value: "fix-auth"}, + core.Option{Key: "assignee", Value: "codex"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(IssueOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "codex", output.Issue.Assignee) + assert.Equal(t, "assigned", output.Issue.Status) +} + +func TestIssue_HandleIssueRecordAssign_Bad_MissingAssignee(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + + result := subsystem.handleIssueRecordAssign(context.Background(), core.NewOptions( + core.Option{Key: "slug", Value: "fix-auth"}, + )) + assert.False(t, result.OK) + assert.EqualError(t, result.Value.(error), "issueAssign: assignee is required") +} + +func TestIssue_HandleIssueRecordAssign_Ugly_MissingIdentifier(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + + result := subsystem.handleIssueRecordAssign(context.Background(), core.NewOptions( + core.Option{Key: "assignee", Value: "codex"}, + )) + assert.False(t, result.OK) + assert.EqualError(t, result.Value.(error), "issueAssign: id or slug is required") +} + func TestIssue_HandleIssueRecordList_Ugly_NestedEnvelope(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/v1/issues", r.URL.Path) diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index c8dca36..57119f6 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -86,7 +86,7 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { } switch action { case "agentic.status", "agentic.scan", "agentic.watch", - "agentic.issue.get", "agentic.issue.list", "agentic.pr.get", "agentic.pr.list", + "agentic.issue.get", "agentic.issue.list", "agentic.issue.assign", "agentic.pr.get", "agentic.pr.list", "agentic.prompt", "agentic.task", "agentic.flow", "agentic.persona", "agentic.sync.status", "agentic.fleet.nodes", "agentic.fleet.stats", "agentic.fleet.events", "agentic.credits.balance", "agentic.credits.history", @@ -219,8 +219,10 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("issue.get", s.handleIssueRecordGet).Description = "Read a tracked platform issue by slug" c.Action("issue.list", s.handleIssueRecordList).Description = "List tracked platform issues with optional filters" c.Action("issue.update", s.handleIssueRecordUpdate).Description = "Update a tracked platform issue by slug" + c.Action("issue.assign", s.handleIssueRecordAssign).Description = "Assign an agent or user to a tracked platform issue" c.Action("issue.comment", s.handleIssueRecordComment).Description = "Add a comment to a tracked platform issue" c.Action("issue.archive", s.handleIssueRecordArchive).Description = "Archive a tracked platform issue by slug" + c.Action("agentic.issue.assign", s.handleIssueRecordAssign).Description = "Assign an agent or user to a tracked platform issue" c.Action("sprint.create", s.handleSprintCreate).Description = "Create a tracked platform sprint" c.Action("sprint.get", s.handleSprintGet).Description = "Read a tracked platform sprint by slug" c.Action("sprint.list", s.handleSprintList).Description = "List tracked platform sprints with optional filters" diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 358194e..7014fbd 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -512,9 +512,11 @@ func TestPrep_OnStartup_Good_RegistersSessionActions(t *testing.T) { assert.True(t, c.Action("issue.get").Exists()) assert.True(t, c.Action("issue.list").Exists()) assert.True(t, c.Action("issue.update").Exists()) + assert.True(t, c.Action("issue.assign").Exists()) assert.True(t, c.Action("issue.comment").Exists()) assert.True(t, c.Action("issue.archive").Exists()) assert.True(t, c.Action("agentic.issue.update").Exists()) + assert.True(t, c.Action("agentic.issue.assign").Exists()) assert.True(t, c.Action("agentic.issue.comment").Exists()) assert.True(t, c.Action("agentic.issue.archive").Exists()) assert.True(t, c.Action("sprint.create").Exists())