diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index 08d1e53c..28c521fb 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -51,7 +51,7 @@ jobs: make integration-test services: gitea: - image: docker.gitea.com/gitea:1.26.2 + image: docker.gitea.com/gitea:1.27.0 cmd: - bash - -c diff --git a/cmd/flags/issue_pr.go b/cmd/flags/issue_pr.go index 31a65c65..65d96da2 100644 --- a/cmd/flags/issue_pr.go +++ b/cmd/flags/issue_pr.go @@ -177,10 +177,18 @@ func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) ( // IssuePREditFlags defines flags for editing properties of issues and PRs var IssuePREditFlags = append([]cli.Flag{ + &cli.StringFlag{ + Name: "set-assignees", + Usage: "Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees", + }, &cli.StringFlag{ Name: "add-assignees", Aliases: []string{"a"}, - Usage: "Comma-separated list of usernames to assign", + Usage: "Comma-separated list of usernames to assign. Takes precedence over --remove-assignees", + }, + &cli.StringFlag{ + Name: "remove-assignees", + Usage: "Comma-separated list of usernames to remove", }, &cli.StringFlag{ Name: "add-labels", @@ -224,17 +232,25 @@ func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error) opts.Deadline = &t } } + if ctx.IsSet("set-assignees") { + val := ctx.String("set-assignees") + opts.SetAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") + } if ctx.IsSet("add-assignees") { val := ctx.String("add-assignees") - opts.AddAssignees = strings.Split(val, ",") + opts.AddAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") + } + if ctx.IsSet("remove-assignees") { + val := ctx.String("remove-assignees") + opts.RemoveAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") } if ctx.IsSet("add-labels") { val := ctx.String("add-labels") - opts.AddLabels = strings.Split(val, ",") + opts.AddLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",") } if ctx.IsSet("remove-labels") { val := ctx.String("remove-labels") - opts.RemoveLabels = strings.Split(val, ",") + opts.RemoveLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",") } return &opts, nil } diff --git a/docs/CLI.md b/docs/CLI.md index 6946c5e2..3f74a38e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -239,7 +239,7 @@ Create an issue on repository Edit one or more issues -**--add-assignees, -a**="": Comma-separated list of usernames to assign +**--add-assignees, -a**="": Comma-separated list of usernames to assign. Takes precedence over --remove-assignees **--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels @@ -255,10 +255,14 @@ Edit one or more issues **--remote, -R**="": Discover Gitea login from remote. Optional +**--remove-assignees**="": Comma-separated list of usernames to remove + **--remove-labels**="": Comma-separated list of labels to remove **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +**--set-assignees**="": Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees + **--title, -t**="": ### reopen, open @@ -423,7 +427,7 @@ Change state of one or more pull requests to 'open' Edit one or more pull requests -**--add-assignees, -a**="": Comma-separated list of usernames to assign +**--add-assignees, -a**="": Comma-separated list of usernames to assign. Takes precedence over --remove-assignees **--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels @@ -445,12 +449,16 @@ Edit one or more pull requests **--remote, -R**="": Discover Gitea login from remote. Optional +**--remove-assignees**="": Comma-separated list of usernames to remove + **--remove-labels**="": Comma-separated list of labels to remove **--remove-reviewers**="": Comma-separated list of usernames to remove from reviewers **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +**--set-assignees**="": Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees + **--title, -t**="": ### review diff --git a/modules/interact/issue_edit.go b/modules/interact/issue_edit.go index 3e61e9c6..4fdd4378 100644 --- a/modules/interact/issue_edit.go +++ b/modules/interact/issue_edit.go @@ -8,6 +8,7 @@ import ( "slices" "strings" + gitea "gitea.dev/sdk" "gitea.dev/tea/modules/config" "gitea.dev/tea/modules/context" "gitea.dev/tea/modules/task" @@ -40,9 +41,10 @@ func EditIssue(requestCtx stdctx.Context, ctx context.TeaContext, index int64) ( Deadline: i.Deadline, } + i.Assignees = cleanAssignees(i.Assignees) if len(i.Assignees) != 0 { for _, a := range i.Assignees { - opts.AddAssignees = append(opts.AddAssignees, a.UserName) + opts.SetAssignees = append(opts.SetAssignees, a.UserName) } } @@ -109,7 +111,7 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex return nil } - currAssignees := o.AddAssignees + currAssignees := o.SetAssignees newAssignees := selectables.Assignees for _, c := range currAssignees { @@ -119,10 +121,14 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex } // assignees - if o.AddAssignees, err = promptMultiSelect("Add Assignees:", newAssignees, "[other]"); err != nil { + if currAssignees, err = promptMultiSelectWithPreselect("Set Assignees:", currAssignees, newAssignees, "[other]"); err != nil { return err } - printTitleAndContent("Assignees:", strings.Join(o.AddAssignees, "\n")) + if len(currAssignees) == 0 && len(o.SetAssignees) > 0 { + o.RemoveAssignees = o.SetAssignees + } + o.SetAssignees = currAssignees + printTitleAndContent("Assignees:", strings.Join(o.SetAssignees, "\n")) // milestone if len(selectables.MilestoneList) != 0 { @@ -175,3 +181,13 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex return nil } + +func cleanAssignees(list []*gitea.User) []*gitea.User { + out := make([]*gitea.User, 0, len(list)) + for _, a := range list { + if strings.TrimSpace(a.UserName) != "" { + out = append(out, a) + } + } + return out +} diff --git a/modules/interact/prompts.go b/modules/interact/prompts.go index c2601983..fabe915f 100644 --- a/modules/interact/prompts.go +++ b/modules/interact/prompts.go @@ -92,10 +92,26 @@ func promptDatetime(prompt string) (val *time.Time, err error) { // promptSelect creates a generic multiselect prompt, with processing of custom values. func promptMultiSelect(prompt string, options []string, customVal string) ([]string, error) { + opts := huh.NewOptions(makeSelectOpts(options, customVal, "")...) + return runMultiSelect(prompt, opts, customVal) +} + +// promptMultiSelectWithPreselect creates a generic multiselect prompt with preselected values and processing of custom values. +func promptMultiSelectWithPreselect(prompt string, selected []string, options []string, customVal string) ([]string, error) { + opts := make([]huh.Option[string], 0, len(selected)+len(options)+1) + for _, name := range selected { + opts = append(opts, huh.NewOption(name, name).Selected(true)) + } + opts = append(opts, huh.NewOptions(makeSelectOpts(options, customVal, "")...)...) + + return runMultiSelect(prompt, opts, customVal) +} + +func runMultiSelect(prompt string, opts []huh.Option[string], customVal string) ([]string, error) { var selection []string if err := huh.NewMultiSelect[string](). Title(prompt). - Options(huh.NewOptions(makeSelectOpts(options, customVal, "")...)...). + Options(opts...). Value(&selection). WithTheme(theme.GetTheme()). Run(); err != nil { diff --git a/modules/task/assignees.go b/modules/task/assignees.go new file mode 100644 index 00000000..24695735 --- /dev/null +++ b/modules/task/assignees.go @@ -0,0 +1,49 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + stdctx "context" + "fmt" + "strings" + + gitea "gitea.dev/sdk" +) + +// ResolveAssigneeOpts resolves assignee names to IssueAssigneesOption. Returns nil if names is empty. +func ResolveAssigneeOpts(names []string) *gitea.IssueAssigneesOption { + names = cleanAssignees(names) + if len(names) == 0 { + return nil + } + + return &gitea.IssueAssigneesOption{Assignees: names} +} + +// ApplyAssigneeChanges adds and removes assignees on an issue or pull request. +func ApplyAssigneeChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueAssigneesOption) error { + if rm != nil { + _, _, err := client.Issues.DeleteIssueAssignees(requestCtx, owner, repo, index, *rm) + if err != nil { + return fmt.Errorf("could not remove assignees: %s", err) + } + } + if add != nil { + _, _, err := client.Issues.AddIssueAssignees(requestCtx, owner, repo, index, *add) + if err != nil { + return fmt.Errorf("could not add assignees: %s", err) + } + } + return nil +} + +func cleanAssignees(list []string) []string { + out := make([]string, 0, len(list)) + for _, a := range list { + if strings.TrimSpace(a) != "" { + out = append(out, a) + } + } + return out +} diff --git a/modules/task/issue_edit.go b/modules/task/issue_edit.go index 51711720..c6edb91a 100644 --- a/modules/task/issue_edit.go +++ b/modules/task/issue_edit.go @@ -23,23 +23,26 @@ type EditIssueOption struct { Deadline *time.Time AddLabels []string RemoveLabels []string + SetAssignees []string AddAssignees []string + RemoveAssignees []string AddReviewers []string RemoveReviewers []string - // RemoveAssignees []string // NOTE: with the current go-sdk, clearing assignees is not possible. } // Normalizes the options into parameters that can be passed to the sdk. // the returned value will be nil, when no change to this part of the issue is requested. -func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, error) { +func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, *gitea.IssueAssigneesOption, *gitea.IssueAssigneesOption, error) { addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.AddLabels) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.RemoveLabels) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } + addAssigneeOpts := ResolveAssigneeOpts(o.AddAssignees) + rmAssigneeOpts := ResolveAssigneeOpts(o.RemoveAssignees) issueOpts := gitea.EditIssueOption{} var issueOptsDirty bool @@ -58,7 +61,7 @@ func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.Te if o.Milestone != nil { id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *o.Milestone) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } issueOpts.Milestone = gitea.OptionalInt64(id) issueOptsDirty = true @@ -70,15 +73,16 @@ func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.Te issueOpts.RemoveDeadline = gitea.OptionalBool(true) } } - if len(o.AddAssignees) != 0 { - issueOpts.Assignees = o.AddAssignees + o.SetAssignees = cleanAssignees(o.SetAssignees) + if len(o.SetAssignees) != 0 { + issueOpts.Assignees = o.SetAssignees issueOptsDirty = true } if issueOptsDirty { - return &issueOpts, addLabelOpts, rmLabelOpts, nil + return &issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil } - return nil, addLabelOpts, rmLabelOpts, nil + return nil, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil } // EditIssue edits an issue and returns the updated issue. @@ -87,7 +91,7 @@ func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea client = ctx.Login.Client() } - issueOpts, addLabelOpts, rmLabelOpts, err := opts.toSdkOptions(requestCtx, ctx, client) + issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, err := opts.toSdkOptions(requestCtx, ctx, client) if err != nil { return nil, err } @@ -96,6 +100,10 @@ func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea return nil, err } + if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil { + return nil, err + } + var issue *gitea.Issue if issueOpts != nil { issue, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index, *issueOpts) diff --git a/modules/task/pull_edit.go b/modules/task/pull_edit.go index c53dc39f..4606fc78 100644 --- a/modules/task/pull_edit.go +++ b/modules/task/pull_edit.go @@ -25,6 +25,8 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea. if err != nil { return nil, err } + addAssigneeOpts := ResolveAssigneeOpts(opts.AddAssignees) + rmAssigneeOpts := ResolveAssigneeOpts(opts.RemoveAssignees) prOpts := gitea.EditPullRequestOption{} var prOptsDirty bool @@ -51,8 +53,9 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea. prOpts.RemoveDeadline = gitea.OptionalBool(true) } } - if len(opts.AddAssignees) != 0 { - prOpts.Assignees = opts.AddAssignees + opts.SetAssignees = cleanAssignees(opts.SetAssignees) + if len(opts.SetAssignees) != 0 { + prOpts.Assignees = opts.SetAssignees prOptsDirty = true } @@ -60,6 +63,10 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea. return nil, err } + if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil { + return nil, err + } + if err := ApplyReviewerChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil { return nil, err } diff --git a/tests/integration/task_issue_test.go b/tests/integration/task_issue_test.go new file mode 100644 index 00000000..f75443bb --- /dev/null +++ b/tests/integration/task_issue_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "testing" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/cmd" + "github.com/stretchr/testify/require" +) + +func TestEditIssue_ModifiesAssignees(t *testing.T) { + // This test verifies that EditIssue correctly modifies assignees of an issue via set, add, and remove. + // It sets up a test repository and organization with known users, then calls EditIssue and checks the results. + login := createIntegrationLogin(t) + client := login.Client() + orgName := fmt.Sprintf("issue-org-%d", time.Now().UnixNano()%1_000_000) + orgRepoName := fmt.Sprintf("issue-repo-%d", time.Now().UnixNano()%1_000_000) + ctx := context.Background() + + // Clean up any existing test data that might interfere with the test. + _, _ = client.Repositories.DeleteRepo(ctx, orgName, orgRepoName) + _, _ = client.Organizations.DeleteOrg(ctx, orgName) + _, _ = client.Admin.DeleteUser(ctx, "user1") + _, _ = client.Admin.DeleteUser(ctx, "user2") + + _, _, err := client.Admin.CreateOrg(ctx, integrationUsername, gitea.CreateOrgOption{Name: orgName}) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.Organizations.DeleteOrg(ctx, orgName); delErr != nil { + t.Logf("failed to delete integration test org %q: %v", orgName, delErr) + } + }) + + orgRepo, _, err := client.Repositories.CreateOrgRepo(ctx, orgName, gitea.CreateRepoOption{Name: orgRepoName}) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.Repositories.DeleteRepo(ctx, orgName, orgRepoName); delErr != nil { + t.Logf("failed to delete integration test repo %q: %v", orgRepoName, delErr) + } + }) + + user1, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user1", Password: "user1!1234", Email: "user1@test.com"}) + require.NoError(t, err) + user2, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user2", Password: "user2!1234", Email: "user2@test.com"}) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = client.Admin.DeleteUser(ctx, "user1") + _, _ = client.Admin.DeleteUser(ctx, "user2") + }) + + permission := gitea.AccessModeOwner + team, _, err := client.Organizations.CreateTeam(ctx, orgName, gitea.CreateTeamOption{Name: "writers", Permission: permission}) + require.NoError(t, err) + _, err = client.Organizations.AddTeamMember(ctx, team.ID, "user1") + require.NoError(t, err) + _, err = client.Organizations.AddTeamMember(ctx, team.ID, "user2") + require.NoError(t, err) + _, err = client.Organizations.AddTeamRepository(ctx, team.ID, orgName, orgRepoName) + require.NoError(t, err) + assigneeValid, _, err := client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user1.UserName) + require.NoError(t, err) + require.True(t, assigneeValid) + assigneeValid, _, err = client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user2.UserName) + require.NoError(t, err) + require.True(t, assigneeValid) + + orgIssue, _, err := client.Issues.CreateIssue(ctx, orgName, orgRepoName, gitea.CreateIssueOption{Title: "issue_integration_test", Assignees: []string{integrationUsername}, Closed: false}) + require.NoError(t, err) + require.Equal(t, integrationUsername, orgIssue.Assignees[0].UserName) + curUser, _, err := client.Users.GetMyUserInfo(ctx) + require.NoError(t, err) + + getNames := func(issue *gitea.Issue) []string { + names := make([]string, len(issue.Assignees)) + for i, u := range issue.Assignees { + names[i] = u.UserName + } + return names + } + + checkAssignees := func(expected []string) { + updatedIssue, _, err := client.Issues.GetIssue(ctx, orgName, orgRepoName, orgIssue.Index) + require.NoError(t, err) + require.ElementsMatch(t, getNames(updatedIssue), expected) + } + + app := cmd.App() + // test set overwrites add and remove + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user1", "--add-assignees", "user2", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{user1.UserName}) + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user2," + integrationUsername, "--add-assignees", "user1", "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{user2.UserName, curUser.UserName}) + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user1", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{user1.UserName}) + + // test remove one assignee + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user1", "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{}) + + // test add multiple assignees and overwrites remove + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user1," + integrationUsername, "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{curUser.UserName, user1.UserName}) + + // test add one assignee + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user2", "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{curUser.UserName, user1.UserName, user2.UserName}) + + // test remove multiple assignees + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user1,user2," + integrationUsername, "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{}) + + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user_not_exists"}) + require.Error(t, err) + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user_not_exists"}) + require.Error(t, err) + err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user_not_exists"}) + require.Error(t, err) +} diff --git a/tests/integration/task_labels_test.go b/tests/integration/task_labels_test.go index 746febf9..5c3dc37a 100644 --- a/tests/integration/task_labels_test.go +++ b/tests/integration/task_labels_test.go @@ -80,7 +80,7 @@ func TestResolveLabelNames_ReturnsRepoAndOrgLabels(t *testing.T) { runGit("commit", "--allow-empty", "-m", "Initial commit") runGit("push", "-u", "origin", "HEAD:branch-with-labels") - waitForBranches(t, orgRepo.FullName) + waitForBranches(t, orgRepo.FullName, "branch-with-labels") _ = runTeaCommand( t, "pr", "create", "--repo", orgRepo.FullName, "--login", login.Name, "--base", "main", "--head", "branch-with-labels", @@ -94,7 +94,7 @@ func TestResolveLabelNames_ReturnsRepoAndOrgLabels(t *testing.T) { require.ElementsMatch(t, labels, []*gitea.Label{orgLabel, repoLabel}) } -func waitForBranches(t *testing.T, repoFullName string) { +func waitForBranches(t *testing.T, repoFullName string, branchName string) { t.Helper() url := fmt.Sprintf("%s/api/v1/repos/%s/branches", os.Getenv("GITEA_TEA_TEST_URL"), repoFullName) @@ -112,7 +112,7 @@ func waitForBranches(t *testing.T, repoFullName string) { for _, b := range branches { have[b.Name] = true } - if have["main"] && have["branch-with-labels"] { + if have["main"] && have[branchName] { return } } diff --git a/tests/integration/task_pull_test.go b/tests/integration/task_pull_test.go new file mode 100644 index 00000000..50312e5c --- /dev/null +++ b/tests/integration/task_pull_test.go @@ -0,0 +1,156 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/cmd" + "github.com/stretchr/testify/require" +) + +func TestEditPull_ModifiesAssignees(t *testing.T) { + // This test verifies that EditPull correctly modifies assignees of an pull request via set, add, and remove. + // It sets up a test repository and organization with known users, then calls EditPull and checks the results. + login := createIntegrationLogin(t) + client := login.Client() + orgName := fmt.Sprintf("pull-org-%d", time.Now().UnixNano()%1_000_000) + orgRepoName := fmt.Sprintf("pull-repo-%d", time.Now().UnixNano()%1_000_000) + ctx := context.Background() + + // Clean up any existing test data that might interfere with the test. + _, _ = client.Repositories.DeleteRepo(ctx, orgName, orgRepoName) + _, _ = client.Organizations.DeleteOrg(ctx, orgName) + _, _ = client.Admin.DeleteUser(ctx, "user1") + _, _ = client.Admin.DeleteUser(ctx, "user2") + + _, _, err := client.Admin.CreateOrg(ctx, integrationUsername, gitea.CreateOrgOption{Name: orgName}) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.Organizations.DeleteOrg(ctx, orgName); delErr != nil { + t.Logf("failed to delete integration test org %q: %v", orgName, delErr) + } + }) + + orgRepo, _, err := client.Repositories.CreateOrgRepo(ctx, orgName, gitea.CreateRepoOption{Name: orgRepoName}) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.Repositories.DeleteRepo(ctx, orgName, orgRepoName); delErr != nil { + t.Logf("failed to delete integration test repo %q: %v", orgRepoName, delErr) + } + }) + + user1, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user1", Password: "user1!1234", Email: "user1@test.com"}) + require.NoError(t, err) + user2, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user2", Password: "user2!1234", Email: "user2@test.com"}) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = client.Admin.DeleteUser(ctx, "user1") + _, _ = client.Admin.DeleteUser(ctx, "user2") + }) + + permission := gitea.AccessModeOwner + team, _, err := client.Organizations.CreateTeam(ctx, orgName, gitea.CreateTeamOption{Name: "writers", Permission: permission}) + require.NoError(t, err) + _, err = client.Organizations.AddTeamMember(ctx, team.ID, "user1") + require.NoError(t, err) + _, err = client.Organizations.AddTeamMember(ctx, team.ID, "user2") + require.NoError(t, err) + _, err = client.Organizations.AddTeamRepository(ctx, team.ID, orgName, orgRepoName) + require.NoError(t, err) + assigneeValid, _, err := client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user1.UserName) + require.NoError(t, err) + require.True(t, assigneeValid) + assigneeValid, _, err = client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user2.UserName) + require.NoError(t, err) + require.True(t, assigneeValid) + + tmpDir := t.TempDir() + runGit := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = tmpDir + require.NoError(t, cmd.Run()) + } + + runGit("init") + runGit("config", "user.email", "test@test.com") + runGit("config", "user.name", "test") + httpsURL := fmt.Sprintf("%s/%s.git", login.URL, orgRepo.FullName) + httpsURL = strings.Replace(httpsURL, "://", fmt.Sprintf("://%s:%s@", login.Name, login.Token), 1) + + runGit("remote", "add", "origin", httpsURL) + + runGit("checkout", "-b", "main") + runGit("commit", "--allow-empty", "-m", "Initial commit") + runGit("push", "-u", "origin", "HEAD:main") + + runGit("checkout", "-b", "branch-with-assignees") + runGit("commit", "--allow-empty", "-m", "Initial commit") + runGit("push", "-u", "origin", "HEAD:branch-with-assignees") + + waitForBranches(t, orgRepo.FullName, "branch-with-assignees") + getNames := func(pr *gitea.PullRequest) []string { + names := make([]string, len(pr.Assignees)) + for i, u := range pr.Assignees { + names[i] = u.UserName + } + return names + } + checkAssignees := func(expected []string) { + updatedPr, _, err := client.PullRequests.GetPullRequest(ctx, orgName, orgRepoName, 1) + require.NoError(t, err) + require.ElementsMatch(t, getNames(updatedPr), expected) + } + + app := cmd.App() + err = app.Run(ctx, []string{"tea", "pr", "create", "--repo", orgRepo.FullName, "--base", "main", "--head", "branch-with-assignees", "--a", integrationUsername}) + require.NoError(t, err) + checkAssignees([]string{integrationUsername}) + curUser, _, err := client.Users.GetMyUserInfo(ctx) + require.NoError(t, err) + + // test set overwrites add and remove + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user1", "--add-assignees", "user2", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{user1.UserName}) + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user2," + integrationUsername, "--add-assignees", "user1", "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{user2.UserName, curUser.UserName}) + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user1", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{user1.UserName}) + + // test remove one assignee + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user1", "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{}) + + // test add multiple assignees and overwrites remove + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user1," + integrationUsername, "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{curUser.UserName, user1.UserName}) + + // test add one assignee + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user2", "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{curUser.UserName, user1.UserName, user2.UserName}) + + // test remove multiple assignees + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user1,user2," + integrationUsername, "--repo", orgRepo.FullName}) + require.NoError(t, err) + checkAssignees([]string{}) + + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user_not_exists"}) + require.Error(t, err) + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user_not_exists"}) + require.Error(t, err) + err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user_not_exists"}) + require.Error(t, err) +}