diff --git a/README.md b/README.md index b387b61f15..440fa3b2c4 100644 --- a/README.md +++ b/README.md @@ -1079,6 +1079,7 @@ The following sets of tools are available: - `body`: PR description (string, optional) - `draft`: Create as draft PR (boolean, optional) - `head`: Branch containing changes (string, required) + - `labels`: Labels to apply to this pull request (string[], optional) - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index a8a94ce690..edc9c575af 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -30,6 +30,13 @@ "description": "Branch containing changes", "type": "string" }, + "labels": { + "description": "Labels to apply to this pull request (must already exist in the repository)", + "items": { + "type": "string" + }, + "type": "array" + }, "maintainer_can_modify": { "description": "Allow maintainer edits", "type": "boolean" diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index fdac78ce3f..d150c73080 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -61,6 +61,7 @@ const ( PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues" PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments" PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}" + PostReposIssuesLabelsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/labels" GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue" diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 02309db45b..5cab79ab64 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -178,8 +178,9 @@ type MinimalTag struct { // Success is implicit in the HTTP response status, and all other information // can be derived from the URL or fetched separately if needed. type MinimalResponse struct { - ID string `json:"id"` - URL string `json:"url"` + ID string `json:"id"` + URL string `json:"url"` + Warning string `json:"warning,omitempty"` } // MinimalCollaborator is the trimmed output type for repository collaborators. diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index c298d875a1..2e81cc66a4 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -596,6 +596,11 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Type: "boolean", Description: "Allow maintainer edits", }, + "labels": { + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + Description: "Labels to apply to this pull request (must already exist in the repository)", + }, }, Required: []string{"owner", "repo", "title", "head", "base"}, }, @@ -658,6 +663,11 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + labels, err := OptionalStringArrayParam(args, "labels") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + newPR := &github.NewPullRequest{ Title: github.Ptr(title), Head: github.Ptr(head), @@ -693,10 +703,23 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create pull request", resp, bodyBytes), nil, nil } + var warningMsg string + // Add labels if provided + if len(labels) > 0 { + _, labelsResp, labelsErr := client.Issues.AddLabelsToIssue(ctx, owner, repo, pr.GetNumber(), labels) + if labelsErr != nil { + warningMsg = fmt.Sprintf("pull request created (#%d) but failed to add labels: %s", pr.GetNumber(), labelsErr.Error()) + } + if labelsResp != nil { + _ = labelsResp.Body.Close() + } + } + // Return minimal response with just essential information minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", pr.GetID()), - URL: pr.GetHTMLURL(), + ID: fmt.Sprintf("%d", pr.GetID()), + URL: pr.GetHTMLURL(), + Warning: warningMsg, } r, err := json.Marshal(minimalResponse) diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 097651b66e..d177a7cd70 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2260,6 +2260,7 @@ func Test_CreatePullRequest(t *testing.T) { assert.Contains(t, schema.Properties, "base") assert.Contains(t, schema.Properties, "draft") assert.Contains(t, schema.Properties, "maintainer_can_modify") + assert.Contains(t, schema.Properties, "labels") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "title", "head", "base"}) // Setup mock PR for success case @@ -2285,12 +2286,13 @@ func Test_CreatePullRequest(t *testing.T) { } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedPR *github.PullRequest - expectedErrMsg string + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedPR *github.PullRequest + expectedErrMsg string + expectedWarning string }{ { name: "successful PR creation", @@ -2348,6 +2350,47 @@ func Test_CreatePullRequest(t *testing.T) { expectError: true, expectedErrMsg: "failed to create pull request", }, + { + name: "successful PR creation with labels", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR), + PostReposIssuesLabelsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Label{ + {Name: github.Ptr("bug")}, + {Name: github.Ptr("enhancement")}, + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature-branch", + "base": "main", + "labels": []any{"bug", "enhancement"}, + }, + expectError: false, + expectedPR: mockPR, + }, + { + name: "labels addition fails after PR creation", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR), + PostReposIssuesLabelsByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Label does not exist"}`)) + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature-branch", + "base": "main", + "labels": []any{"nonexistent-label"}, + }, + expectError: false, + expectedPR: mockPR, + expectedWarning: "pull request created (#42) but failed to add labels", + }, } for _, tc := range tests { @@ -2389,6 +2432,12 @@ func Test_CreatePullRequest(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedPR) require.NoError(t, err) assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL) + + if tc.expectedWarning != "" { + assert.Contains(t, returnedPR.Warning, tc.expectedWarning) + } else { + assert.Empty(t, returnedPR.Warning) + } }) } } diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index f5ddbdf29d..53fc8149fa 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -119,6 +119,7 @@ function SuccessView({ function CreatePRApp() { const [title, setTitle] = useState(""); const [body, setBody] = useState(""); + const [labels, setLabels] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [successPR, setSuccessPR] = useState(null); @@ -140,6 +141,10 @@ function CreatePRApp() { useEffect(() => { if (toolInput?.title) setTitle(toolInput.title as string); if (toolInput?.body) setBody(toolInput.body as string); + if (toolInput?.labels) { + const labelsArr = toolInput.labels as string[]; + setLabels(labelsArr.join(", ")); + } if (toolInput?.draft) setIsDraft(toolInput.draft as boolean); if (toolInput?.maintainer_can_modify !== undefined) { setMaintainerCanModify(toolInput.maintainer_can_modify as boolean); @@ -154,6 +159,8 @@ function CreatePRApp() { setError(null); setSubmittedTitle(title); + const labelsArray = labels.split(",").map(l => l.trim()).filter(l => l !== ""); + try { const result = await callTool("create_pull_request", { owner, repo, @@ -163,6 +170,7 @@ function CreatePRApp() { base, draft: isDraft, maintainer_can_modify: maintainerCanModify, + labels: labelsArray, _ui_submitted: true }); @@ -182,7 +190,7 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool]); + }, [title, body, labels, owner, repo, head, base, isDraft, maintainerCanModify, callTool]); if (successPR) { return ( @@ -260,6 +268,18 @@ function CreatePRApp() { /> + {/* Labels */} + + Labels + setLabels(e.target.value)} + placeholder="bug, enhancement, help wanted (comma separated)" + block + contrast + /> + + {/* Description */}