diff --git a/pkg/agentic/issue.go b/pkg/agentic/issue.go index a51becd..4a88dd5 100644 --- a/pkg/agentic/issue.go +++ b/pkg/agentic/issue.go @@ -122,6 +122,22 @@ type IssueCommentOutput struct { Comment IssueComment `json:"comment"` } +// input := agentic.IssueReportInput{Slug: "fix-auth", Report: map[string]any{"summary": "Build failed"}} +type IssueReportInput struct { + ID string `json:"id,omitempty"` + IssueID string `json:"issue_id,omitempty"` + Slug string `json:"slug,omitempty"` + Report any `json:"report,omitempty"` + Author string `json:"author,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// out := agentic.IssueReportOutput{Success: true, Comment: agentic.IssueComment{Author: "codex"}} +type IssueReportOutput struct { + Success bool `json:"success"` + Comment IssueComment `json:"comment"` +} + // out := agentic.IssueArchiveOutput{Success: true, Archived: "fix-auth"} type IssueArchiveOutput struct { Success bool `json:"success"` @@ -229,6 +245,27 @@ func (s *PrepSubsystem) handleIssueRecordComment(ctx context.Context, options co return core.Result{Value: output, OK: true} } +// result := c.Action("issue.report").Run(ctx, core.NewOptions( +// +// core.Option{Key: "slug", Value: "fix-auth"}, +// core.Option{Key: "report", Value: map[string]any{"summary": "Build failed"}}, +// +// )) +func (s *PrepSubsystem) handleIssueRecordReport(ctx context.Context, options core.Options) core.Result { + _, output, err := s.issueReport(ctx, nil, IssueReportInput{ + ID: optionStringValue(options, "id", "_arg"), + IssueID: optionStringValue(options, "issue_id", "issue-id"), + Slug: optionStringValue(options, "slug"), + Report: optionAnyValue(options, "report", "body"), + Author: optionStringValue(options, "author"), + Metadata: optionAnyMapValue(options, "metadata"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + // result := c.Action("issue.archive").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "fix-auth"})) func (s *PrepSubsystem) handleIssueRecordArchive(ctx context.Context, options core.Options) core.Result { _, output, err := s.issueArchive(ctx, nil, IssueArchiveInput{ @@ -272,6 +309,11 @@ func (s *PrepSubsystem) registerIssueTools(server *mcp.Server) { Description: "Add a comment to a tracked platform issue.", }, s.issueComment) + mcp.AddTool(server, &mcp.Tool{ + Name: "issue_report", + Description: "Post a structured report comment to a tracked platform issue.", + }, s.issueReport) + mcp.AddTool(server, &mcp.Tool{ Name: "issue_archive", Description: "Archive a tracked platform issue by slug.", @@ -454,6 +496,38 @@ func (s *PrepSubsystem) issueComment(ctx context.Context, _ *mcp.CallToolRequest }, nil } +func (s *PrepSubsystem) issueReport(ctx context.Context, _ *mcp.CallToolRequest, input IssueReportInput) (*mcp.CallToolResult, IssueReportOutput, error) { + identifier := issueRecordIdentifier(input.Slug, input.IssueID, input.ID) + if identifier == "" { + return nil, IssueReportOutput{}, core.E("issueReport", "issue_id, id, or slug is required", nil) + } + + body, err := issueReportBody(input.Report) + if err != nil { + return nil, IssueReportOutput{}, err + } + if body == "" { + return nil, IssueReportOutput{}, core.E("issueReport", "report is required", nil) + } + + _, commentOutput, err := s.issueComment(ctx, nil, IssueCommentInput{ + ID: input.ID, + IssueID: input.IssueID, + Slug: input.Slug, + Body: body, + Author: input.Author, + Metadata: input.Metadata, + }) + if err != nil { + return nil, IssueReportOutput{}, err + } + + return nil, IssueReportOutput{ + Success: true, + Comment: commentOutput.Comment, + }, nil +} + func (s *PrepSubsystem) issueArchive(ctx context.Context, _ *mcp.CallToolRequest, input IssueArchiveInput) (*mcp.CallToolResult, IssueArchiveOutput, error) { identifier := issueRecordIdentifier(input.Slug, input.ID) if identifier == "" { @@ -519,6 +593,25 @@ func parseIssueComment(values map[string]any) IssueComment { } } +func issueReportBody(report any) (string, error) { + switch value := report.(type) { + case nil: + return "", nil + case string: + return core.Trim(value), nil + } + + if text := stringValue(report); text != "" { + return text, nil + } + + if jsonText := core.JSONMarshalString(report); core.Trim(jsonText) != "" { + return core.Concat("```json\n", jsonText, "\n```"), nil + } + + return "", nil +} + func parseIssueListOutput(payload map[string]any) IssueListOutput { issuesData := payloadDataSlice(payload, "issues") issues := make([]Issue, 0, len(issuesData)) diff --git a/pkg/agentic/issue_test.go b/pkg/agentic/issue_test.go index 165771a..e7762e5 100644 --- a/pkg/agentic/issue_test.go +++ b/pkg/agentic/issue_test.go @@ -129,6 +129,61 @@ func TestIssue_HandleIssueRecordAssign_Ugly_MissingIdentifier(t *testing.T) { assert.EqualError(t, result.Value.(error), "issueAssign: id or slug is required") } +func TestIssue_HandleIssueRecordReport_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/issues/fix-auth/comments", r.URL.Path) + require.Equal(t, http.MethodPost, 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) + assert.Equal(t, "QA failed: build output changed", payload["body"]) + assert.Equal(t, "codex", payload["author"]) + + _, _ = w.Write([]byte(`{"data":{"comment":{"id":88,"issue_id":42,"author":"codex","body":"QA failed: build output changed","metadata":{"source":"qa"}}}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleIssueRecordReport(context.Background(), core.NewOptions( + core.Option{Key: "slug", Value: "fix-auth"}, + core.Option{Key: "report", Value: "QA failed: build output changed"}, + core.Option{Key: "author", Value: "codex"}, + core.Option{Key: "metadata", Value: map[string]any{"source": "qa"}}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(IssueReportOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, 88, output.Comment.ID) + assert.Equal(t, "QA failed: build output changed", output.Comment.Body) + assert.Equal(t, "codex", output.Comment.Author) + assert.Equal(t, map[string]any{"source": "qa"}, output.Comment.Metadata) +} + +func TestIssue_HandleIssueRecordReport_Bad_MissingReport(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + + result := subsystem.handleIssueRecordReport(context.Background(), core.NewOptions( + core.Option{Key: "slug", Value: "fix-auth"}, + )) + assert.False(t, result.OK) + assert.EqualError(t, result.Value.(error), "issueReport: report is required") +} + +func TestIssue_HandleIssueRecordReport_Ugly_MissingIdentifier(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + + result := subsystem.handleIssueRecordReport(context.Background(), core.NewOptions( + core.Option{Key: "report", Value: "QA failed: build output changed"}, + )) + assert.False(t, result.OK) + assert.EqualError(t, result.Value.(error), "issueReport: issue_id, 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 1944c95..f928409 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -223,8 +223,10 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { 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.report", s.handleIssueRecordReport).Description = "Post a structured report 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("agentic.issue.report", s.handleIssueRecordReport).Description = "Post a structured report comment 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 7014fbd..652864a 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -514,10 +514,12 @@ func TestPrep_OnStartup_Good_RegistersSessionActions(t *testing.T) { 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.report").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.report").Exists()) assert.True(t, c.Action("agentic.issue.archive").Exists()) assert.True(t, c.Action("sprint.create").Exists()) assert.True(t, c.Action("sprint.get").Exists())