diff --git a/gitlab/issues.go b/gitlab/issues.go index 481381d..5c170f4 100644 --- a/gitlab/issues.go +++ b/gitlab/issues.go @@ -187,7 +187,11 @@ func (s *gitLabIssueService) Create(ctx context.Context, owner, repo string, opt Description: gitlab.Ptr(opts.Body), } if len(opts.Assignees) > 0 { - return nil, fmt.Errorf("GitLab requires assignee IDs, not usernames; assignees cannot be set by username on create") + assigneeIDs, err := resolveUserIDs(s.client, opts.Assignees) + if err != nil { + return nil, fmt.Errorf("resolving assignees: %w", err) + } + glOpts.AssigneeIDs = &assigneeIDs } if len(opts.Labels) > 0 { lbls := gitlab.LabelOptions(opts.Labels) @@ -218,6 +222,19 @@ func (s *gitLabIssueService) Update(ctx context.Context, owner, repo string, num glOpts.Description = opts.Body changed = true } + if opts.Assignees != nil { + if len(opts.Assignees) == 0 { + empty := []int64{} + glOpts.AssigneeIDs = &empty + } else { + assigneeIDs, err := resolveUserIDs(s.client, opts.Assignees) + if err != nil { + return nil, fmt.Errorf("resolving assignees: %w", err) + } + glOpts.AssigneeIDs = &assigneeIDs + } + changed = true + } if opts.Labels != nil { lbls := gitlab.LabelOptions(opts.Labels) glOpts.Labels = &lbls diff --git a/gitlab/issues_test.go b/gitlab/issues_test.go index ccf7eb1..c4ee084 100644 --- a/gitlab/issues_test.go +++ b/gitlab/issues_test.go @@ -5,28 +5,57 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "strings" "testing" forge "github.com/git-pkgs/forge" ) -func TestGitLabCreateIssueRejectsAssignees(t *testing.T) { - srv := httptest.NewServer(nil) +func TestGitLabCreateIssueWithAssignees(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/users", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("username") == "alice" { + _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 42, "username": "alice"}}) + } else { + _ = json.NewEncoder(w).Encode([]map[string]any{}) + } + }) + var gotAssigneeIDs []int64 + mux.HandleFunc("POST /api/v4/projects/mygroup%2Fmyrepo/issues", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Title string `json:"title"` + AssigneeIDs []int64 `json:"assignee_ids"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + gotAssigneeIDs = req.AssigneeIDs + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "iid": 1, + "title": req.Title, + "state": "opened", + "assignees": []map[string]any{ + {"id": 42, "username": "alice"}, + }, + }) + }) + + srv := httptest.NewServer(mux) defer srv.Close() f := New(srv.URL, "test-token", nil) - _, err := f.Issues().Create(context.Background(), "mygroup", "myrepo", forge.CreateIssueOpts{ + iss, err := f.Issues().Create(context.Background(), "mygroup", "myrepo", forge.CreateIssueOpts{ Title: "Test issue", Body: "Body text", - Assignees: []string{"someuser"}, + Assignees: []string{"alice"}, }) - if err == nil { - t.Fatal("expected error when assignees are set, got nil") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(gotAssigneeIDs) != 1 || gotAssigneeIDs[0] != 42 { + t.Fatalf("expected assignee_ids [42], got %v", gotAssigneeIDs) } - if !strings.Contains(err.Error(), "assignee IDs") { - t.Fatalf("expected error to mention 'assignee IDs', got: %v", err) + if len(iss.Assignees) != 1 || iss.Assignees[0].Login != "alice" { + t.Fatalf("expected 1 assignee alice, got %v", iss.Assignees) } } diff --git a/gitlab/prs.go b/gitlab/prs.go index 1a1b9eb..be8d0a9 100644 --- a/gitlab/prs.go +++ b/gitlab/prs.go @@ -277,6 +277,22 @@ func (s *gitLabPRService) Create(ctx context.Context, owner, repo string, opts f glOpts.Title = gitlab.Ptr("Draft: " + opts.Title) } + if len(opts.Assignees) > 0 { + assigneeIDs, err := resolveUserIDs(s.client, opts.Assignees) + if err != nil { + return nil, fmt.Errorf("resolving assignees: %w", err) + } + glOpts.AssigneeIDs = &assigneeIDs + } + + if len(opts.Reviewers) > 0 { + reviewerIDs, err := resolveUserIDs(s.client, opts.Reviewers) + if err != nil { + return nil, fmt.Errorf("resolving reviewers: %w", err) + } + glOpts.ReviewerIDs = &reviewerIDs + } + if len(opts.Labels) > 0 { lbls := gitlab.LabelOptions(opts.Labels) glOpts.Labels = &lbls @@ -310,6 +326,32 @@ func (s *gitLabPRService) Update(ctx context.Context, owner, repo string, number glOpts.TargetBranch = opts.Base changed = true } + if opts.Assignees != nil { + if len(opts.Assignees) == 0 { + empty := []int64{} + glOpts.AssigneeIDs = &empty + } else { + assigneeIDs, err := resolveUserIDs(s.client, opts.Assignees) + if err != nil { + return nil, fmt.Errorf("resolving assignees: %w", err) + } + glOpts.AssigneeIDs = &assigneeIDs + } + changed = true + } + if opts.Reviewers != nil { + if len(opts.Reviewers) == 0 { + empty := []int64{} + glOpts.ReviewerIDs = &empty + } else { + reviewerIDs, err := resolveUserIDs(s.client, opts.Reviewers) + if err != nil { + return nil, fmt.Errorf("resolving reviewers: %w", err) + } + glOpts.ReviewerIDs = &reviewerIDs + } + changed = true + } if opts.Labels != nil { lbls := gitlab.LabelOptions(opts.Labels) glOpts.Labels = &lbls diff --git a/gitlab/prs_test.go b/gitlab/prs_test.go new file mode 100644 index 0000000..94102a8 --- /dev/null +++ b/gitlab/prs_test.go @@ -0,0 +1,142 @@ +package gitlab + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestGitLabCreatePRWithAssigneesAndReviewers(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/users", func(w http.ResponseWriter, r *http.Request) { + uname := r.URL.Query().Get("username") + switch uname { + case "alice": + _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 101, "username": "alice"}}) + case "bob": + _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 202, "username": "bob"}}) + default: + _ = json.NewEncoder(w).Encode([]map[string]any{}) + } + }) + + var gotAssigneeIDs, gotReviewerIDs []int64 + mux.HandleFunc("POST /api/v4/projects/mygroup%2Fmyrepo/merge_requests", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Title string `json:"title"` + AssigneeIDs []int64 `json:"assignee_ids"` + ReviewerIDs []int64 `json:"reviewer_ids"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + gotAssigneeIDs = req.AssigneeIDs + gotReviewerIDs = req.ReviewerIDs + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "iid": 1, + "title": req.Title, + "state": "opened", + "source_branch": req.SourceBranch, + "target_branch": req.TargetBranch, + "assignees": []map[string]any{ + {"id": 101, "username": "alice"}, + }, + "reviewers": []map[string]any{ + {"id": 202, "username": "bob"}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + + pr, err := f.PullRequests().Create(context.Background(), "mygroup", "myrepo", forge.CreatePROpts{ + Title: "New Feature", + Head: "feature", + Base: "main", + Assignees: []string{"alice"}, + Reviewers: []string{"bob"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(gotAssigneeIDs) != 1 || gotAssigneeIDs[0] != 101 { + t.Fatalf("expected assignee_ids [101], got %v", gotAssigneeIDs) + } + if len(gotReviewerIDs) != 1 || gotReviewerIDs[0] != 202 { + t.Fatalf("expected reviewer_ids [202], got %v", gotReviewerIDs) + } + if len(pr.Assignees) != 1 || pr.Assignees[0].Login != "alice" { + t.Fatalf("expected 1 assignee alice, got %v", pr.Assignees) + } + if len(pr.Reviewers) != 1 || pr.Reviewers[0].Login != "bob" { + t.Fatalf("expected 1 reviewer bob, got %v", pr.Reviewers) + } +} + +func TestGitLabUpdatePRWithAssigneesAndReviewers(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/users", func(w http.ResponseWriter, r *http.Request) { + uname := r.URL.Query().Get("username") + switch uname { + case "alice": + _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 101, "username": "alice"}}) + case "bob": + _ = json.NewEncoder(w).Encode([]map[string]any{{"id": 202, "username": "bob"}}) + default: + _ = json.NewEncoder(w).Encode([]map[string]any{}) + } + }) + + var gotAssigneeIDs, gotReviewerIDs []int64 + mux.HandleFunc("PUT /api/v4/projects/mygroup%2Fmyrepo/merge_requests/1", func(w http.ResponseWriter, r *http.Request) { + var req struct { + AssigneeIDs []int64 `json:"assignee_ids"` + ReviewerIDs []int64 `json:"reviewer_ids"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + gotAssigneeIDs = req.AssigneeIDs + gotReviewerIDs = req.ReviewerIDs + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "iid": 1, + "title": "Updated MR", + "state": "opened", + "assignees": []map[string]any{ + {"id": 101, "username": "alice"}, + }, + "reviewers": []map[string]any{ + {"id": 202, "username": "bob"}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + + pr, err := f.PullRequests().Update(context.Background(), "mygroup", "myrepo", 1, forge.UpdatePROpts{ + Assignees: []string{"alice"}, + Reviewers: []string{"bob"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(gotAssigneeIDs) != 1 || gotAssigneeIDs[0] != 101 { + t.Fatalf("expected assignee_ids [101], got %v", gotAssigneeIDs) + } + if len(gotReviewerIDs) != 1 || gotReviewerIDs[0] != 202 { + t.Fatalf("expected reviewer_ids [202], got %v", gotReviewerIDs) + } + if len(pr.Assignees) != 1 || pr.Assignees[0].Login != "alice" { + t.Fatalf("expected 1 assignee alice, got %v", pr.Assignees) + } +} diff --git a/gitlab/reviews.go b/gitlab/reviews.go index 6e33b2d..a710cc2 100644 --- a/gitlab/reviews.go +++ b/gitlab/reviews.go @@ -98,7 +98,7 @@ func (s *gitLabReviewService) RequestReviewers(ctx context.Context, owner, repo pid := owner + "/" + repo // GitLab requires user IDs, not usernames. Resolve them. - ids, err := s.resolveUserIDs(users) + ids, err := resolveUserIDs(s.client, users) if err != nil { return err } @@ -150,20 +150,3 @@ func (s *gitLabReviewService) RemoveReviewers(ctx context.Context, owner, repo s } return nil } - -func (s *gitLabReviewService) resolveUserIDs(usernames []string) ([]int64, error) { - ids := make([]int64, 0, len(usernames)) - for _, username := range usernames { - users, _, err := s.client.Users.ListUsers(&gitlab.ListUsersOptions{ - Username: gitlab.Ptr(username), - }) - if err != nil { - return nil, fmt.Errorf("looking up user %q: %w", username, err) - } - if len(users) == 0 { - return nil, fmt.Errorf("user %q not found", username) - } - ids = append(ids, int64(users[0].ID)) - } - return ids, nil -} diff --git a/gitlab/users.go b/gitlab/users.go new file mode 100644 index 0000000..07fd029 --- /dev/null +++ b/gitlab/users.go @@ -0,0 +1,24 @@ +package gitlab + +import ( + "fmt" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +func resolveUserIDs(client *gitlab.Client, usernames []string) ([]int64, error) { + ids := make([]int64, 0, len(usernames)) + for _, username := range usernames { + users, _, err := client.Users.ListUsers(&gitlab.ListUsersOptions{ + Username: gitlab.Ptr(username), + }) + if err != nil { + return nil, fmt.Errorf("looking up user %q: %w", username, err) + } + if len(users) == 0 { + return nil, fmt.Errorf("user %q not found", username) + } + ids = append(ids, int64(users[0].ID)) + } + return ids, nil +}