feat(agentic): add issue assignment action

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 16:50:26 +00:00
parent ae910099c8
commit dfaf14f061
4 changed files with 118 additions and 2 deletions

View file

@ -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"]),

View file

@ -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)

View file

@ -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"

View file

@ -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())