submit: Add --assign flag and assignees configuration (#951)

Add support for assigning change requests to users during submission
with an `-a`/`--asign` flag for all submit commands.

The accompanying `spice.submit.assignees` configuration option
allows setting default assignees.

LLM assistance:
The first version of this change was made with ChatGPT Codex.
The result was iterated upon significantly locally,
both manually and with the help of Claude.
This commit is contained in:
Abhinav Gupta
2025-11-30 19:58:43 -08:00
committed by GitHub
parent f36a9e5898
commit 7828edacd0
49 changed files with 1829 additions and 239 deletions
@@ -0,0 +1,3 @@
kind: Added
body: 'submit: Add --assign flag and ''spice.submit.assignees'' configuration option to assign CRs to users upon submission.'
time: 2025-11-29T18:46:32.289707-08:00
-3
View File
@@ -16,10 +16,7 @@ type submitOptions struct {
NoWeb bool `help:"Alias for --web=false."`
// TODO: Other creation options e.g.:
// - assignees
// - labels
// - milestone
// - reviewers
}
const _submitHelp = `
+8 -4
View File
@@ -266,9 +266,10 @@ only if there are multiple CRs in the stack.
* `-u`, `--[no-]update-only`: Only update existing change requests, do not create new ones
* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas.
* `-r`, `--reviewer=REVIEWER,...`: Add reviewers to the change request. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>
* `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>
* `--no-web`: Alias for --web=false.
**Configuration**: [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb)
**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb)
### gs stack restack
@@ -387,10 +388,11 @@ only if there are multiple CRs in the stack.
* `-u`, `--[no-]update-only`: Only update existing change requests, do not create new ones
* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas.
* `-r`, `--reviewer=REVIEWER,...`: Add reviewers to the change request. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>
* `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>
* `--no-web`: Alias for --web=false.
* `--branch=NAME`: Branch to start at
**Configuration**: [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb)
**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb)
### gs upstack restack
@@ -541,10 +543,11 @@ only if there are multiple CRs in the stack.
* `-u`, `--[no-]update-only`: Only update existing change requests, do not create new ones
* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas.
* `-r`, `--reviewer=REVIEWER,...`: Add reviewers to the change request. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>
* `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>
* `--no-web`: Alias for --web=false.
* `--branch=NAME`: Branch to start at
**Configuration**: [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb)
**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb)
### gs downstack edit
@@ -995,12 +998,13 @@ only if there are multiple CRs in the stack.
* `-u`, `--[no-]update-only`: Only update existing change requests, do not create new ones
* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas.
* `-r`, `--reviewer=REVIEWER,...`: Add reviewers to the change request. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>
* `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>
* `--no-web`: Alias for --web=false.
* `--title=TITLE`: Title of the change request
* `--body=BODY`: Body of the change request
* `--branch=NAME`: Branch to submit
**Configuration**: [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb)
**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb)
## Commit
+12
View File
@@ -328,6 +328,18 @@ This option affects both interactive and non-interactive modes:
- `true`: create CRs as drafts by default
- `false` (default): create CRs as ready for review by default
### spice.submit.assignees
<!-- gs:version unreleased -->
Assign the configured users to all submitted and updated change requests
when using $$gs branch submit$$ and friends.
The value must be a comma-separated list of usernames.
Assignees specified with the `--assign` flag
will be combined with the configured assignees.
### spice.submit.label
<!-- gs:version v0.16.0 -->
+24
View File
@@ -261,6 +261,30 @@ will be combined with configured reviewers.
When updating existing change requests,
new reviewers are added to any existing reviewers on the CR.
## Assigning change requests
<!-- gs:version unreleased -->
You can assign change requests to specific users
when submitting them with the `-a`/`--assign` flag.
```freeze language="terminal"
{green}${reset} gs branch submit --assign alice --assign bob
{green}INF{reset} Created #123: https://github.com/abhinav/git-spice/pull/123
```
You can also configure default assignees
with the $$spice.submit.assignees$$ configuration option.
Assignees specified with the `--assign` flag
will be combined with configured assignees.
```freeze language="terminal"
{green}${reset} git config {red}spice.submit.assignees{reset} {mag}'alice,bob'{reset}
```
When updating existing change requests,
new assignees are added to any existing assignees on the CR.
## Importing open CRs
You can import an existing open CR into git-spice
+10
View File
@@ -305,6 +305,9 @@ type SubmitChangeRequest struct {
// Reviewers are optional reviewers to request reviews from.
Reviewers []string
// Assignees are optional users to assign to the change.
Assignees []string
}
// SubmitChangeResult is the result of creating a new change in a repository.
@@ -332,6 +335,9 @@ type EditChangeOptions struct {
// Reviewers are the reviewers to request reviews from.
// Existing reviewers associated with the change will not be modified.
Reviewers []string
// Assignees are optional users to assign to the change.
Assignees []string
}
// FindChangeItem is a single result from searching for changes in the
@@ -365,6 +371,10 @@ type FindChangeItem struct {
// Reviewers are the usernames of users
// who have been requested to review the change.
Reviewers []string
// Assignees are the usernames of users
// who are assigned to the change.
Assignees []string
}
// ChangeTemplate is a template for a new change proposal.
+76
View File
@@ -0,0 +1,76 @@
package github
import (
"context"
"fmt"
"github.com/shurcooL/githubv4"
)
func (r *Repository) addAssigneesToPullRequest(ctx context.Context, assignees []string, prGraphQLID githubv4.ID) error {
if len(assignees) == 0 {
return nil
}
assigneeIDs, err := r.assigneeIDs(ctx, assignees)
if err != nil {
return fmt.Errorf("get assignee IDs: %w", err)
}
var m struct {
AddAssigneesToAssignable struct {
ClientMutationID githubv4.String `graphql:"clientMutationId"`
} `graphql:"addAssigneesToAssignable(input: $input)"`
}
input := githubv4.AddAssigneesToAssignableInput{
AssignableID: prGraphQLID,
AssigneeIDs: assigneeIDs,
}
if err := r.client.Mutate(ctx, &m, input, nil); err != nil {
return fmt.Errorf("add assignees to assignable: %w", err)
}
return nil
}
// assigneeIDs resolves assignee logins to GitHub user IDs.
// The returned slice may be shorter than the input
// because duplicate logins are automatically deduplicated.
func (r *Repository) assigneeIDs(ctx context.Context, assignees []string) ([]githubv4.ID, error) {
ids := make([]githubv4.ID, 0, len(assignees))
seen := make(map[string]struct{}, len(assignees))
for _, assignee := range assignees {
if _, ok := seen[assignee]; ok {
continue
}
seen[assignee] = struct{}{}
id, err := r.assigneeID(ctx, assignee)
if err != nil {
return nil, fmt.Errorf("resolve assignee %q: %w", assignee, err)
}
ids = append(ids, id)
}
return ids, nil
}
func (r *Repository) assigneeID(ctx context.Context, login string) (githubv4.ID, error) {
var q struct {
User struct {
ID githubv4.ID `graphql:"id"`
} `graphql:"user(login: $login)"`
}
vars := map[string]any{
"login": githubv4.String(login),
}
if err := r.client.Query(ctx, &q, vars); err != nil {
return "", fmt.Errorf("query user: %w", err)
}
if q.User.ID == nil || q.User.ID == "" {
return "", fmt.Errorf("user %q not found", login)
}
return q.User.ID, nil
}
+15 -5
View File
@@ -11,7 +11,11 @@ import (
// EditChange edits an existing change in a repository.
func (r *Repository) EditChange(ctx context.Context, fid forge.ChangeID, opts forge.EditChangeOptions) error {
if cmputil.Zero(opts.Base) && cmputil.Zero(opts.Draft) && len(opts.Labels) == 0 && len(opts.Reviewers) == 0 {
if cmputil.Zero(opts.Base) &&
cmputil.Zero(opts.Draft) &&
len(opts.Labels) == 0 &&
len(opts.Reviewers) == 0 &&
len(opts.Assignees) == 0 {
return nil // nothing to do
}
pr := mustPR(fid)
@@ -85,15 +89,21 @@ func (r *Repository) EditChange(ctx context.Context, fid forge.ChangeID, opts fo
r.log.Debug(logMsg, "pr", pr.Number)
}
err = r.addLabelsToPullRequest(ctx, opts.Labels, graphQLID)
if err != nil {
// TODO:
// perform in parallel, share resolved user IDs, etc.
// maybe even cache and persist resolved IDs in store.
if err := r.addLabelsToPullRequest(ctx, opts.Labels, graphQLID); err != nil {
return fmt.Errorf("add labels to PR: %w", err)
}
err = r.addReviewersToPullRequest(ctx, opts.Reviewers, graphQLID)
if err != nil {
if err := r.addReviewersToPullRequest(ctx, opts.Reviewers, graphQLID); err != nil {
return fmt.Errorf("add reviewers to PR: %w", err)
}
if err := r.addAssigneesToPullRequest(ctx, opts.Assignees, graphQLID); err != nil {
return fmt.Errorf("add assignees to PR: %w", err)
}
return nil
}
+14
View File
@@ -33,6 +33,11 @@ type findPRNode struct {
} `graphql:"requestedReviewer"`
} `graphql:"nodes"`
} `graphql:"reviewRequests(first: 100)"`
Assignees struct {
Nodes []struct {
Login githubv4.String `graphql:"login"`
} `graphql:"nodes"`
} `graphql:"assignees(first: 100)"`
}
func (n *findPRNode) toFindChangeItem() *forge.FindChangeItem {
@@ -52,6 +57,14 @@ func (n *findPRNode) toFindChangeItem() *forge.FindChangeItem {
}
}
var assignees []string
if len(n.Assignees.Nodes) > 0 {
assignees = make([]string, len(n.Assignees.Nodes))
for i, node := range n.Assignees.Nodes {
assignees[i] = string(node.Login)
}
}
return &forge.FindChangeItem{
ID: &PR{
Number: int(n.Number),
@@ -65,6 +78,7 @@ func (n *findPRNode) toFindChangeItem() *forge.FindChangeItem {
Draft: bool(n.IsDraft),
Labels: labels,
Reviewers: reviewers,
Assignees: assignees,
}
}
+127
View File
@@ -1149,6 +1149,133 @@ func TestIntegration_Repository_FindChangeItem_WithReviewers(t *testing.T) {
})
}
func TestIntegration_Repository_FindChangeItem_WithAssignees(t *testing.T) {
branchFixture1 := fixturetest.New(_fixtures, "branch-no-assignees", func() string {
return randomString(8)
})
branchFixture2 := fixturetest.New(_fixtures, "branch-with-assignees", func() string {
return randomString(8)
})
branchNoAssignees := branchFixture1.Get(t)
branchWithAssignees := branchFixture2.Get(t)
t.Logf("Creating branches: %s, %s", branchNoAssignees, branchWithAssignees)
var gitWork *git.Worktree
if github.UpdateFixtures() {
t.Setenv("GIT_AUTHOR_EMAIL", "bot@example.com")
t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]")
t.Setenv("GIT_COMMITTER_EMAIL", "bot@example.com")
t.Setenv("GIT_COMMITTER_NAME", "gs-test[bot]")
output := t.Output()
t.Logf("Cloning test-repo...")
repoDir := t.TempDir()
cmd := exec.Command("git", "clone", "https://github.com/abhinav/test-repo", repoDir)
cmd.Stdout = output
cmd.Stderr = output
require.NoError(t, cmd.Run(), "failed to clone test-repo")
ctx := t.Context()
var err error
gitWork, err = git.OpenWorktree(ctx, repoDir, git.OpenOptions{
Log: silogtest.New(t),
})
require.NoError(t, err, "failed to open git repo")
gitRepo := gitWork.Repository()
branches := []string{branchNoAssignees, branchWithAssignees}
for _, branchName := range branches {
require.NoError(t, gitRepo.CreateBranch(ctx, git.CreateBranchRequest{
Name: branchName,
}), "could not create branch: %s", branchName)
require.NoError(t, gitWork.CheckoutBranch(ctx, branchName),
"could not checkout branch: %s", branchName)
require.NoError(t, os.WriteFile(
filepath.Join(repoDir, branchName+".txt"),
[]byte(randomString(32)),
0o644,
), "could not write file to branch")
cmd = exec.Command("git", "add", ".")
cmd.Dir = repoDir
cmd.Stdout = output
cmd.Stderr = output
require.NoError(t, cmd.Run(), "git add failed")
require.NoError(t, gitWork.Commit(ctx, git.CommitRequest{
Message: "commit from test",
}), "could not commit changes")
t.Logf("Pushing to origin")
require.NoError(t,
gitWork.Push(ctx, git.PushOptions{
Remote: "origin",
Refspec: git.Refspec(branchName),
}), "error pushing branch")
t.Cleanup(func() {
t.Logf("Deleting remote branch: %s", branchName)
assert.NoError(t,
gitWork.Push(context.WithoutCancel(ctx), git.PushOptions{
Remote: "origin",
Refspec: git.Refspec(":" + branchName),
}), "error deleting branch")
})
}
}
rec := newRecorder(t, t.Name())
ghc := newGitHubClient(rec.GetDefaultClient())
repo, err := github.NewRepository(
t.Context(), new(github.Forge), "abhinav", "test-repo", silogtest.New(t), ghc, _testRepoID,
)
require.NoError(t, err)
// Create PR without assignees.
changeNoAssignees, err := repo.SubmitChange(t.Context(), forge.SubmitChangeRequest{
Subject: branchNoAssignees,
Body: "Test PR without assignees",
Base: "main",
Head: branchNoAssignees,
})
require.NoError(t, err, "error creating PR without assignees")
// Create PR with assignees.
changeWithAssignees, err := repo.SubmitChange(t.Context(), forge.SubmitChangeRequest{
Subject: branchWithAssignees,
Body: "Test PR with assignees",
Base: "main",
Head: branchWithAssignees,
Assignees: []string{"abhinav-robot"},
})
require.NoError(t, err, "error creating PR with assignees")
t.Run("NoAssignees", func(t *testing.T) {
change, err := repo.FindChangeByID(t.Context(), changeNoAssignees.ID)
require.NoError(t, err)
assert.Nil(t, change.Assignees)
t.Run("AddAssignees", func(t *testing.T) {
require.NoError(t,
repo.EditChange(t.Context(), changeNoAssignees.ID, forge.EditChangeOptions{
Assignees: []string{"abhinav-robot"},
}), "could not add assignees to PR")
change, err := repo.FindChangeByID(t.Context(), changeNoAssignees.ID)
require.NoError(t, err)
assert.Equal(t, []string{"abhinav-robot"}, change.Assignees)
})
})
t.Run("WithAssignees", func(t *testing.T) {
change, err := repo.FindChangeByID(t.Context(), changeWithAssignees.ID)
require.NoError(t, err)
assert.Equal(t, []string{"abhinav-robot"}, change.Assignees)
})
}
const _alnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// randomString generates a random alphanumeric string of length n.
+5 -2
View File
@@ -66,11 +66,14 @@ func (r *Repository) SubmitChange(ctx context.Context, req forge.SubmitChangeReq
return forge.SubmitChangeResult{}, fmt.Errorf("add labels to PR: %w", err)
}
err = r.addReviewersToPullRequest(ctx, req.Reviewers, m.CreatePullRequest.PullRequest.ID)
if err != nil {
if err := r.addReviewersToPullRequest(ctx, req.Reviewers, m.CreatePullRequest.PullRequest.ID); err != nil {
return forge.SubmitChangeResult{}, fmt.Errorf("add reviewers to PR: %w", err)
}
if err := r.addAssigneesToPullRequest(ctx, req.Assignees, m.CreatePullRequest.PullRequest.ID); err != nil {
return forge.SubmitChangeResult{}, fmt.Errorf("add assignees to PR: %w", err)
}
return forge.SubmitChangeResult{
ID: &PR{
Number: int(m.CreatePullRequest.PullRequest.Number),
@@ -1 +1 @@
"mjTKCPmd"
"ANT1ebx1"
@@ -1 +1 @@
"T2aNl7RF"
"zSQCqDRF"
@@ -1 +1 @@
"jh4ZQk3A"
"wM4FFhyP"
@@ -1 +1 @@
"nzW86hKz"
"O4Le0gYV"
@@ -1 +1 @@
"BZOypxxU"
"9ce88Fjf"
@@ -1 +1 @@
"3oqpKcZs"
"enu5hKhb"
@@ -1 +1 @@
"R1U4QZgt"
"tZr8vasu"
@@ -6,9 +6,9 @@ interactions:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 357
content_length: 393
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}\",\"variables\":{\"number\":141,\"owner\":\"abhinav\",\"repo\":\"git-spice\"}}\n"
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":141,\"owner\":\"abhinav\",\"repo\":\"git-spice\"}}\n"
headers:
Content-Type:
- application/json
@@ -20,21 +20,21 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOJ2BQKs5xNT-u\",\"number\":141,\"url\":\"https://github.com/abhinav/git-spice/pull/141\",\"title\":\"branch submit: Heal from external PR submissions\",\"state\":\"MERGED\",\"headRefOid\":\"df0289d83ffae816105947875db01c992224913d\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]}}}}}"
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOJ2BQKs5xNT-u\",\"number\":141,\"url\":\"https://github.com/abhinav/git-spice/pull/141\",\"title\":\"branch submit: Heal from external PR submissions\",\"state\":\"MERGED\",\"headRefOid\":\"df0289d83ffae816105947875db01c992224913d\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 603.244875ms
duration: 493.643792ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 357
content_length: 393
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}\",\"variables\":{\"number\":999,\"owner\":\"abhinav\",\"repo\":\"git-spice\"}}\n"
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":999,\"owner\":\"abhinav\",\"repo\":\"git-spice\"}}\n"
headers:
Content-Type:
- application/json
@@ -52,4 +52,4 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 404.702708ms
duration: 304.567458ms
@@ -0,0 +1,237 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 269
host: api.github.com
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"wGXqQzcL\",\"title\":\"wGXqQzcL\",\"body\":\"Test PR without assignees\"}}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5qF\",\"number\":29,\"url\":\"https://github.com/abhinav/test-repo/pull/29\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 1.112183375s
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 266
host: api.github.com
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"eoRKB9gT\",\"title\":\"eoRKB9gT\",\"body\":\"Test PR with assignees\"}}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5qO\",\"number\":30,\"url\":\"https://github.com/abhinav/test-repo/pull/30\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 856.579917ms
- id: 2
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 97
host: api.github.com
body: "{\"query\":\"query($login:String!){user(login: $login){id}}\",\"variables\":{\"login\":\"abhinav-robot\"}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"user\":{\"id\":\"U_kgDOCzNVWQ\"}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 275.551458ms
- id: 3
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 211
host: api.github.com
body: "{\"query\":\"mutation($input:AddAssigneesToAssignableInput!){addAssigneesToAssignable(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"assignableId\":\"PR_kwDOMVd0xs62J5qO\",\"assigneeIds\":[\"U_kgDOCzNVWQ\"]}}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"addAssigneesToAssignable\":{\"clientMutationId\":null}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 752.591917ms
- id: 4
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":29,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5qF\",\"number\":29,\"url\":\"https://github.com/abhinav/test-repo/pull/29\",\"title\":\"wGXqQzcL\",\"state\":\"OPEN\",\"headRefOid\":\"afbc9f52aa456ecddc669e303c7986cc28822815\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 291.6925ms
- id: 5
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 97
host: api.github.com
body: "{\"query\":\"query($login:String!){user(login: $login){id}}\",\"variables\":{\"login\":\"abhinav-robot\"}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"user\":{\"id\":\"U_kgDOCzNVWQ\"}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 190.5495ms
- id: 6
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 211
host: api.github.com
body: "{\"query\":\"mutation($input:AddAssigneesToAssignableInput!){addAssigneesToAssignable(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"assignableId\":\"PR_kwDOMVd0xs62J5qF\",\"assigneeIds\":[\"U_kgDOCzNVWQ\"]}}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"addAssigneesToAssignable\":{\"clientMutationId\":null}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 1.006603292s
- id: 7
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":29,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5qF\",\"number\":29,\"url\":\"https://github.com/abhinav/test-repo/pull/29\",\"title\":\"wGXqQzcL\",\"state\":\"OPEN\",\"headRefOid\":\"afbc9f52aa456ecddc669e303c7986cc28822815\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[{\"login\":\"abhinav-robot\"}]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 305.664584ms
- id: 8
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":30,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5qO\",\"number\":30,\"url\":\"https://github.com/abhinav/test-repo/pull/30\",\"title\":\"eoRKB9gT\",\"state\":\"OPEN\",\"headRefOid\":\"3d086751e721e905e66c6cdb03204bef3cebf79a\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[{\"login\":\"abhinav-robot\"}]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 305.994708ms
@@ -8,7 +8,7 @@ interactions:
proto_minor: 1
content_length: 269
host: api.github.com
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"mjTKCPmd\",\"title\":\"mjTKCPmd\",\"body\":\"Test PR without reviewers\"}}}\n"
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"ANT1ebx1\",\"title\":\"ANT1ebx1\",\"body\":\"Test PR without reviewers\"}}}\n"
headers:
Content-Type:
- application/json
@@ -20,13 +20,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6177e-\",\"number\":23,\"url\":\"https://github.com/abhinav/test-repo/pull/23\"}}}}"
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5lY\",\"number\":27,\"url\":\"https://github.com/abhinav/test-repo/pull/27\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 1.2415795s
duration: 1.303241083s
- id: 1
request:
proto: HTTP/1.1
@@ -34,7 +34,7 @@ interactions:
proto_minor: 1
content_length: 266
host: api.github.com
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"T2aNl7RF\",\"title\":\"T2aNl7RF\",\"body\":\"Test PR with reviewers\"}}}\n"
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"zSQCqDRF\",\"title\":\"zSQCqDRF\",\"body\":\"Test PR with reviewers\"}}}\n"
headers:
Content-Type:
- application/json
@@ -46,13 +46,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6177fW\",\"number\":24,\"url\":\"https://github.com/abhinav/test-repo/pull/24\"}}}}"
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5ln\",\"number\":28,\"url\":\"https://github.com/abhinav/test-repo/pull/28\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 1.022038042s
duration: 925.301375ms
- id: 2
request:
proto: HTTP/1.1
@@ -78,7 +78,7 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 211.413708ms
duration: 224.945458ms
- id: 3
request:
proto: HTTP/1.1
@@ -86,7 +86,7 @@ interactions:
proto_minor: 1
content_length: 201
host: api.github.com
body: "{\"query\":\"mutation($input:RequestReviewsInput!){requestReviews(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs6177fW\",\"userIds\":[\"U_kgDOCzNVWQ\"],\"union\":true}}}\n"
body: "{\"query\":\"mutation($input:RequestReviewsInput!){requestReviews(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs62J5ln\",\"userIds\":[\"U_kgDOCzNVWQ\"],\"union\":true}}}\n"
headers:
Content-Type:
- application/json
@@ -104,15 +104,15 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 703.874875ms
duration: 592.370917ms
- id: 4
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 356
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}\",\"variables\":{\"number\":23,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":27,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
@@ -124,13 +124,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6177e-\",\"number\":23,\"url\":\"https://github.com/abhinav/test-repo/pull/23\",\"title\":\"mjTKCPmd\",\"state\":\"OPEN\",\"headRefOid\":\"90706d61e9f37e34fbc2a6025fce678c21f80e36\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]}}}}}"
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5lY\",\"number\":27,\"url\":\"https://github.com/abhinav/test-repo/pull/27\",\"title\":\"ANT1ebx1\",\"state\":\"OPEN\",\"headRefOid\":\"1ccdb06986c11880298493465086f7fb3798cfd0\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 254.5145ms
duration: 306.913417ms
- id: 5
request:
proto: HTTP/1.1
@@ -156,7 +156,7 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 282.598583ms
duration: 219.291542ms
- id: 6
request:
proto: HTTP/1.1
@@ -164,7 +164,7 @@ interactions:
proto_minor: 1
content_length: 201
host: api.github.com
body: "{\"query\":\"mutation($input:RequestReviewsInput!){requestReviews(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs6177e-\",\"userIds\":[\"U_kgDOCzNVWQ\"],\"union\":true}}}\n"
body: "{\"query\":\"mutation($input:RequestReviewsInput!){requestReviews(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs62J5lY\",\"userIds\":[\"U_kgDOCzNVWQ\"],\"union\":true}}}\n"
headers:
Content-Type:
- application/json
@@ -182,15 +182,15 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 504.517292ms
duration: 804.302333ms
- id: 7
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 356
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}\",\"variables\":{\"number\":23,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":27,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
@@ -202,21 +202,21 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6177e-\",\"number\":23,\"url\":\"https://github.com/abhinav/test-repo/pull/23\",\"title\":\"mjTKCPmd\",\"state\":\"OPEN\",\"headRefOid\":\"90706d61e9f37e34fbc2a6025fce678c21f80e36\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[{\"requestedReviewer\":{\"login\":\"abhinav-robot\"}}]}}}}}"
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5lY\",\"number\":27,\"url\":\"https://github.com/abhinav/test-repo/pull/27\",\"title\":\"ANT1ebx1\",\"state\":\"OPEN\",\"headRefOid\":\"1ccdb06986c11880298493465086f7fb3798cfd0\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[{\"requestedReviewer\":{\"login\":\"abhinav-robot\"}}]},\"assignees\":{\"nodes\":[]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 342.989083ms
duration: 499.657083ms
- id: 8
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 356
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}\",\"variables\":{\"number\":24,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":28,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
@@ -228,10 +228,10 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6177fW\",\"number\":24,\"url\":\"https://github.com/abhinav/test-repo/pull/24\",\"title\":\"T2aNl7RF\",\"state\":\"OPEN\",\"headRefOid\":\"1f30682766458fb27448d9269d38de3d5bd6e5c8\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[{\"requestedReviewer\":{\"login\":\"abhinav-robot\"}}]}}}}}"
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5ln\",\"number\":28,\"url\":\"https://github.com/abhinav/test-repo/pull/28\",\"title\":\"zSQCqDRF\",\"state\":\"OPEN\",\"headRefOid\":\"0db58548d85dc0ab939dd7fcb6504631b272f5aa\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[{\"requestedReviewer\":{\"login\":\"abhinav-robot\"}}]},\"assignees\":{\"nodes\":[]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 247.697291ms
duration: 318.846583ms
@@ -8,7 +8,7 @@ interactions:
proto_minor: 1
content_length: 263
host: api.github.com
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"jh4ZQk3A\",\"title\":\"jh4ZQk3A\",\"body\":\"Test PR with labels\"}}}\n"
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"wM4FFhyP\",\"title\":\"wM4FFhyP\",\"body\":\"Test PR with labels\"}}}\n"
headers:
Content-Type:
- application/json
@@ -20,13 +20,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6172xJ\",\"number\":22,\"url\":\"https://github.com/abhinav/test-repo/pull/22\"}}}}"
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5kl\",\"number\":26,\"url\":\"https://github.com/abhinav/test-repo/pull/26\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 941.031625ms
duration: 957.111625ms
- id: 1
request:
proto: HTTP/1.1
@@ -34,7 +34,7 @@ interactions:
proto_minor: 1
content_length: 196
host: api.github.com
body: "{\"query\":\"query($label:String!$name:String!$owner:String!){repository(owner: $owner, name: $name){label(name: $label){id}}}\",\"variables\":{\"label\":\"BZOypxxU\",\"name\":\"test-repo\",\"owner\":\"abhinav\"}}\n"
body: "{\"query\":\"query($label:String!$name:String!$owner:String!){repository(owner: $owner, name: $name){label(name: $label){id}}}\",\"variables\":{\"label\":\"O4Le0gYV\",\"name\":\"test-repo\",\"owner\":\"abhinav\"}}\n"
headers:
Content-Type:
- application/json
@@ -52,41 +52,15 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 223.778292ms
duration: 237.938459ms
- id: 2
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 175
host: api.github.com
body: "{\"query\":\"mutation($input:CreateLabelInput!){createLabel(input: $input){label{id}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"color\":\"EDEDED\",\"name\":\"BZOypxxU\"}}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createLabel\":{\"label\":{\"id\":\"LA_kwDOMVd0xs8AAAACQ_E5eA\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 388.993833ms
- id: 3
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 196
host: api.github.com
body: "{\"query\":\"query($label:String!$name:String!$owner:String!){repository(owner: $owner, name: $name){label(name: $label){id}}}\",\"variables\":{\"label\":\"nzW86hKz\",\"name\":\"test-repo\",\"owner\":\"abhinav\"}}\n"
body: "{\"query\":\"query($label:String!$name:String!$owner:String!){repository(owner: $owner, name: $name){label(name: $label){id}}}\",\"variables\":{\"label\":\"9ce88Fjf\",\"name\":\"test-repo\",\"owner\":\"abhinav\"}}\n"
headers:
Content-Type:
- application/json
@@ -104,7 +78,33 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 716.4375ms
duration: 261.647083ms
- id: 3
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 175
host: api.github.com
body: "{\"query\":\"mutation($input:CreateLabelInput!){createLabel(input: $input){label{id}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"color\":\"EDEDED\",\"name\":\"9ce88Fjf\"}}}\n"
headers:
Content-Type:
- application/json
url: https://api.github.com/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createLabel\":{\"label\":{\"id\":\"LA_kwDOMVd0xs8AAAACRJ-tgQ\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 352.845ms
- id: 4
request:
proto: HTTP/1.1
@@ -112,7 +112,7 @@ interactions:
proto_minor: 1
content_length: 175
host: api.github.com
body: "{\"query\":\"mutation($input:CreateLabelInput!){createLabel(input: $input){label{id}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"color\":\"EDEDED\",\"name\":\"nzW86hKz\"}}}\n"
body: "{\"query\":\"mutation($input:CreateLabelInput!){createLabel(input: $input){label{id}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"color\":\"EDEDED\",\"name\":\"O4Le0gYV\"}}}\n"
headers:
Content-Type:
- application/json
@@ -124,13 +124,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createLabel\":{\"label\":{\"id\":\"LA_kwDOMVd0xs8AAAACQ_E5kw\"}}}}"
body: "{\"data\":{\"createLabel\":{\"label\":{\"id\":\"LA_kwDOMVd0xs8AAAACRJ-thA\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 326.201875ms
duration: 508.449625ms
- id: 5
request:
proto: HTTP/1.1
@@ -138,7 +138,7 @@ interactions:
proto_minor: 1
content_length: 240
host: api.github.com
body: "{\"query\":\"mutation($input:AddLabelsToLabelableInput!){addLabelsToLabelable(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"labelableId\":\"PR_kwDOMVd0xs6172xJ\",\"labelIds\":[\"LA_kwDOMVd0xs8AAAACQ_E5kw\",\"LA_kwDOMVd0xs8AAAACQ_E5eA\"]}}}\n"
body: "{\"query\":\"mutation($input:AddLabelsToLabelableInput!){addLabelsToLabelable(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"labelableId\":\"PR_kwDOMVd0xs62J5kl\",\"labelIds\":[\"LA_kwDOMVd0xs8AAAACRJ-thA\",\"LA_kwDOMVd0xs8AAAACRJ-tgQ\"]}}}\n"
headers:
Content-Type:
- application/json
@@ -156,15 +156,15 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 900.272375ms
duration: 787.813667ms
- id: 6
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 356
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}\",\"variables\":{\"number\":22,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":26,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
@@ -176,21 +176,21 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6172xJ\",\"number\":22,\"url\":\"https://github.com/abhinav/test-repo/pull/22\",\"title\":\"jh4ZQk3A\",\"state\":\"OPEN\",\"headRefOid\":\"e9951fa08320f2712a76da385d3452d50703eee3\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[{\"name\":\"BZOypxxU\"},{\"name\":\"nzW86hKz\"}]},\"reviewRequests\":{\"nodes\":[]}}}}}"
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5kl\",\"number\":26,\"url\":\"https://github.com/abhinav/test-repo/pull/26\",\"title\":\"wM4FFhyP\",\"state\":\"OPEN\",\"headRefOid\":\"5b6b9dfe4526f7dfbb98bc9f68a4143b3df2ee6e\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[{\"name\":\"9ce88Fjf\"},{\"name\":\"O4Le0gYV\"}]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 307.5605ms
duration: 305.513958ms
- id: 7
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 545
content_length: 581
host: api.github.com
body: "{\"query\":\"query($branch:String!$limit:Int!$owner:String!$repo:String!$states:[PullRequestState!]!){repository(owner: $owner, name: $repo){pullRequests(first: $limit, headRefName: $branch, states: $states, orderBy: {field: UPDATED_AT, direction: DESC}){nodes{id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}}\",\"variables\":{\"branch\":\"jh4ZQk3A\",\"limit\":10,\"owner\":\"abhinav\",\"repo\":\"test-repo\",\"states\":[\"OPEN\",\"CLOSED\",\"MERGED\"]}}\n"
body: "{\"query\":\"query($branch:String!$limit:Int!$owner:String!$repo:String!$states:[PullRequestState!]!){repository(owner: $owner, name: $repo){pullRequests(first: $limit, headRefName: $branch, states: $states, orderBy: {field: UPDATED_AT, direction: DESC}){nodes{id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}}\",\"variables\":{\"branch\":\"wM4FFhyP\",\"limit\":10,\"owner\":\"abhinav\",\"repo\":\"test-repo\",\"states\":[\"OPEN\",\"CLOSED\",\"MERGED\"]}}\n"
headers:
Content-Type:
- application/json
@@ -202,13 +202,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequests\":{\"nodes\":[{\"id\":\"PR_kwDOMVd0xs6172xJ\",\"number\":22,\"url\":\"https://github.com/abhinav/test-repo/pull/22\",\"title\":\"jh4ZQk3A\",\"state\":\"OPEN\",\"headRefOid\":\"e9951fa08320f2712a76da385d3452d50703eee3\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[{\"name\":\"BZOypxxU\"},{\"name\":\"nzW86hKz\"}]},\"reviewRequests\":{\"nodes\":[]}}]}}}}"
body: "{\"data\":{\"repository\":{\"pullRequests\":{\"nodes\":[{\"id\":\"PR_kwDOMVd0xs62J5kl\",\"number\":26,\"url\":\"https://github.com/abhinav/test-repo/pull/26\",\"title\":\"wM4FFhyP\",\"state\":\"OPEN\",\"headRefOid\":\"5b6b9dfe4526f7dfbb98bc9f68a4143b3df2ee6e\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[{\"name\":\"9ce88Fjf\"},{\"name\":\"O4Le0gYV\"}]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[]}}]}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 299.002ms
duration: 306.049042ms
- id: 8
request:
proto: HTTP/1.1
@@ -216,7 +216,7 @@ interactions:
proto_minor: 1
content_length: 133
host: api.github.com
body: "{\"query\":\"mutation($input:DeleteLabelInput!){deleteLabel(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"id\":\"nzW86hKz\"}}}\n"
body: "{\"query\":\"mutation($input:DeleteLabelInput!){deleteLabel(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"id\":\"O4Le0gYV\"}}}\n"
headers:
Content-Type:
- application/json
@@ -228,13 +228,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"deleteLabel\":null},\"errors\":[{\"type\":\"NOT_FOUND\",\"path\":[\"deleteLabel\"],\"locations\":[{\"line\":1,\"column\":36}],\"message\":\"Could not resolve to a node with the global id of 'nzW86hKz'\"}]}"
body: "{\"data\":{\"deleteLabel\":null},\"errors\":[{\"type\":\"NOT_FOUND\",\"path\":[\"deleteLabel\"],\"locations\":[{\"line\":1,\"column\":36}],\"message\":\"Could not resolve to a node with the global id of 'O4Le0gYV'\"}]}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 257.160584ms
duration: 193.504583ms
- id: 9
request:
proto: HTTP/1.1
@@ -242,7 +242,7 @@ interactions:
proto_minor: 1
content_length: 133
host: api.github.com
body: "{\"query\":\"mutation($input:DeleteLabelInput!){deleteLabel(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"id\":\"BZOypxxU\"}}}\n"
body: "{\"query\":\"mutation($input:DeleteLabelInput!){deleteLabel(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"id\":\"9ce88Fjf\"}}}\n"
headers:
Content-Type:
- application/json
@@ -254,10 +254,10 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"deleteLabel\":null},\"errors\":[{\"type\":\"NOT_FOUND\",\"path\":[\"deleteLabel\"],\"locations\":[{\"line\":1,\"column\":36}],\"message\":\"Could not resolve to a node with the global id of 'BZOypxxU'\"}]}"
body: "{\"data\":{\"deleteLabel\":null},\"errors\":[{\"type\":\"NOT_FOUND\",\"path\":[\"deleteLabel\"],\"locations\":[{\"line\":1,\"column\":36}],\"message\":\"Could not resolve to a node with the global id of '9ce88Fjf'\"}]}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 244.761625ms
duration: 193.423083ms
@@ -6,9 +6,9 @@ interactions:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 547
content_length: 583
host: api.github.com
body: "{\"query\":\"query($branch:String!$limit:Int!$owner:String!$repo:String!$states:[PullRequestState!]!){repository(owner: $owner, name: $repo){pullRequests(first: $limit, headRefName: $branch, states: $states, orderBy: {field: UPDATED_AT, direction: DESC}){nodes{id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}}\",\"variables\":{\"branch\":\"gh-graphql\",\"limit\":10,\"owner\":\"abhinav\",\"repo\":\"git-spice\",\"states\":[\"OPEN\",\"CLOSED\",\"MERGED\"]}}\n"
body: "{\"query\":\"query($branch:String!$limit:Int!$owner:String!$repo:String!$states:[PullRequestState!]!){repository(owner: $owner, name: $repo){pullRequests(first: $limit, headRefName: $branch, states: $states, orderBy: {field: UPDATED_AT, direction: DESC}){nodes{id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}}\",\"variables\":{\"branch\":\"gh-graphql\",\"limit\":10,\"owner\":\"abhinav\",\"repo\":\"git-spice\",\"states\":[\"OPEN\",\"CLOSED\",\"MERGED\"]}}\n"
headers:
Content-Type:
- application/json
@@ -20,21 +20,21 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequests\":{\"nodes\":[{\"id\":\"PR_kwDOJ2BQKs5xNeqO\",\"number\":144,\"url\":\"https://github.com/abhinav/git-spice/pull/144\",\"title\":\"GitHub: Use GraphQL API\",\"state\":\"MERGED\",\"headRefOid\":\"5d74cecfe3cb066044d129232229e07f5d04e194\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]}}]}}}}"
body: "{\"data\":{\"repository\":{\"pullRequests\":{\"nodes\":[{\"id\":\"PR_kwDOJ2BQKs5xNeqO\",\"number\":144,\"url\":\"https://github.com/abhinav/git-spice/pull/144\",\"title\":\"GitHub: Use GraphQL API\",\"state\":\"MERGED\",\"headRefOid\":\"5d74cecfe3cb066044d129232229e07f5d04e194\",\"baseRefName\":\"main\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[]}}]}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 308.087875ms
duration: 301.692167ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 551
content_length: 587
host: api.github.com
body: "{\"query\":\"query($branch:String!$limit:Int!$owner:String!$repo:String!$states:[PullRequestState!]!){repository(owner: $owner, name: $repo){pullRequests(first: $limit, headRefName: $branch, states: $states, orderBy: {field: UPDATED_AT, direction: DESC}){nodes{id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}}\",\"variables\":{\"branch\":\"does-not-exist\",\"limit\":10,\"owner\":\"abhinav\",\"repo\":\"git-spice\",\"states\":[\"OPEN\",\"CLOSED\",\"MERGED\"]}}\n"
body: "{\"query\":\"query($branch:String!$limit:Int!$owner:String!$repo:String!$states:[PullRequestState!]!){repository(owner: $owner, name: $repo){pullRequests(first: $limit, headRefName: $branch, states: $states, orderBy: {field: UPDATED_AT, direction: DESC}){nodes{id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}}\",\"variables\":{\"branch\":\"does-not-exist\",\"limit\":10,\"owner\":\"abhinav\",\"repo\":\"git-spice\",\"states\":[\"OPEN\",\"CLOSED\",\"MERGED\"]}}\n"
headers:
Content-Type:
- application/json
@@ -52,4 +52,4 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 283.615917ms
duration: 306.654ms
@@ -8,7 +8,7 @@ interactions:
proto_minor: 1
content_length: 251
host: api.github.com
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"R1U4QZgt\",\"title\":\"R1U4QZgt\",\"body\":\"Test PR\"}}}\n"
body: "{\"query\":\"mutation($input:CreatePullRequestInput!){createPullRequest(input: $input){pullRequest{id,number,url}}}\",\"variables\":{\"input\":{\"repositoryId\":\"R_kgDOMVd0xg\",\"baseRefName\":\"main\",\"headRefName\":\"tZr8vasu\",\"title\":\"tZr8vasu\",\"body\":\"Test PR\"}}}\n"
headers:
Content-Type:
- application/json
@@ -20,13 +20,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6172q_\",\"number\":21,\"url\":\"https://github.com/abhinav/test-repo/pull/21\"}}}}"
body: "{\"data\":{\"createPullRequest\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5js\",\"number\":25,\"url\":\"https://github.com/abhinav/test-repo/pull/25\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 1.1529645s
duration: 803.254792ms
- id: 1
request:
proto: HTTP/1.1
@@ -34,7 +34,7 @@ interactions:
proto_minor: 1
content_length: 192
host: api.github.com
body: "{\"query\":\"mutation($input:UpdatePullRequestInput!){updatePullRequest(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs6172q_\",\"baseRefName\":\"3oqpKcZs\"}}}\n"
body: "{\"query\":\"mutation($input:UpdatePullRequestInput!){updatePullRequest(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs62J5js\",\"baseRefName\":\"enu5hKhb\"}}}\n"
headers:
Content-Type:
- application/json
@@ -52,15 +52,15 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 910.070583ms
duration: 1.4191675s
- id: 2
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 356
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}\",\"variables\":{\"number\":21,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":25,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
@@ -72,13 +72,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6172q_\",\"number\":21,\"url\":\"https://github.com/abhinav/test-repo/pull/21\",\"title\":\"R1U4QZgt\",\"state\":\"OPEN\",\"headRefOid\":\"3b96e8fcb67c26d4dfbdc208bcfc782a70b8cb11\",\"baseRefName\":\"3oqpKcZs\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]}}}}}"
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5js\",\"number\":25,\"url\":\"https://github.com/abhinav/test-repo/pull/25\",\"title\":\"tZr8vasu\",\"state\":\"OPEN\",\"headRefOid\":\"a12069ffae274a48b74b7007cab875063a117f21\",\"baseRefName\":\"enu5hKhb\",\"isDraft\":false,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 305.829042ms
duration: 305.763167ms
- id: 3
request:
proto: HTTP/1.1
@@ -86,7 +86,7 @@ interactions:
proto_minor: 1
content_length: 188
host: api.github.com
body: "{\"query\":\"mutation($input:UpdatePullRequestInput!){updatePullRequest(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs6172q_\",\"baseRefName\":\"main\"}}}\n"
body: "{\"query\":\"mutation($input:UpdatePullRequestInput!){updatePullRequest(input: $input){clientMutationId}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs62J5js\",\"baseRefName\":\"main\"}}}\n"
headers:
Content-Type:
- application/json
@@ -104,7 +104,7 @@ interactions:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 818.162625ms
duration: 818.21425ms
- id: 4
request:
proto: HTTP/1.1
@@ -112,7 +112,7 @@ interactions:
proto_minor: 1
content_length: 182
host: api.github.com
body: "{\"query\":\"mutation($input:ConvertPullRequestToDraftInput!){convertPullRequestToDraft(input: $input){pullRequest{id}}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs6172q_\"}}}\n"
body: "{\"query\":\"mutation($input:ConvertPullRequestToDraftInput!){convertPullRequestToDraft(input: $input){pullRequest{id}}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs62J5js\"}}}\n"
headers:
Content-Type:
- application/json
@@ -124,21 +124,21 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"convertPullRequestToDraft\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6172q_\"}}}}"
body: "{\"data\":{\"convertPullRequestToDraft\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5js\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 1.628361541s
duration: 502.142334ms
- id: 5
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 356
content_length: 392
host: api.github.com
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}}}}}\",\"variables\":{\"number\":21,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
body: "{\"query\":\"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){pullRequest(number: $number){id,number,url,title,state,headRefOid,baseRefName,isDraft,labels(first: 100){nodes{name}},reviewRequests(first: 100){nodes{requestedReviewer{... on Actor{login}}}},assignees(first: 100){nodes{login}}}}}\",\"variables\":{\"number\":25,\"owner\":\"abhinav\",\"repo\":\"test-repo\"}}\n"
headers:
Content-Type:
- application/json
@@ -150,13 +150,13 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6172q_\",\"number\":21,\"url\":\"https://github.com/abhinav/test-repo/pull/21\",\"title\":\"R1U4QZgt\",\"state\":\"OPEN\",\"headRefOid\":\"3b96e8fcb67c26d4dfbdc208bcfc782a70b8cb11\",\"baseRefName\":\"main\",\"isDraft\":true,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]}}}}}"
body: "{\"data\":{\"repository\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5js\",\"number\":25,\"url\":\"https://github.com/abhinav/test-repo/pull/25\",\"title\":\"tZr8vasu\",\"state\":\"OPEN\",\"headRefOid\":\"a12069ffae274a48b74b7007cab875063a117f21\",\"baseRefName\":\"main\",\"isDraft\":true,\"labels\":{\"nodes\":[]},\"reviewRequests\":{\"nodes\":[]},\"assignees\":{\"nodes\":[]}}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 305.79875ms
duration: 306.089416ms
- id: 6
request:
proto: HTTP/1.1
@@ -164,7 +164,7 @@ interactions:
proto_minor: 1
content_length: 190
host: api.github.com
body: "{\"query\":\"mutation($input:MarkPullRequestReadyForReviewInput!){markPullRequestReadyForReview(input: $input){pullRequest{id}}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs6172q_\"}}}\n"
body: "{\"query\":\"mutation($input:MarkPullRequestReadyForReviewInput!){markPullRequestReadyForReview(input: $input){pullRequest{id}}}\",\"variables\":{\"input\":{\"pullRequestId\":\"PR_kwDOMVd0xs62J5js\"}}}\n"
headers:
Content-Type:
- application/json
@@ -176,10 +176,10 @@ interactions:
proto_minor: 0
content_length: -1
uncompressed: true
body: "{\"data\":{\"markPullRequestReadyForReview\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs6172q_\"}}}}"
body: "{\"data\":{\"markPullRequestReadyForReview\":{\"pullRequest\":{\"id\":\"PR_kwDOMVd0xs62J5js\"}}}}"
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 818.4605ms
duration: 613.043625ms
+44
View File
@@ -0,0 +1,44 @@
package gitlab
import (
"context"
"fmt"
gitlab "gitlab.com/gitlab-org/api/client-go"
)
// assigneeIDs resolves assignee usernames to GitLab user IDs.
// The returned slice may be shorter than the input
// because duplicate usernames are automatically deduplicated.
func (r *Repository) assigneeIDs(ctx context.Context, assignees []string) ([]int, error) {
ids := make([]int, 0, len(assignees))
seen := make(map[string]struct{}, len(assignees))
for _, assignee := range assignees {
if _, ok := seen[assignee]; ok {
continue
}
seen[assignee] = struct{}{}
id, err := r.assigneeID(ctx, assignee)
if err != nil {
return nil, fmt.Errorf("resolve assignee %q: %w", assignee, err)
}
ids = append(ids, id)
}
return ids, nil
}
func (r *Repository) assigneeID(ctx context.Context, username string) (int, error) {
users, _, err := r.client.Users.ListUsers(&gitlab.ListUsersOptions{
Username: gitlab.Ptr(username),
}, gitlab.WithContext(ctx))
if err != nil {
return 0, fmt.Errorf("list users: %w", err)
}
if len(users) == 0 {
return 0, fmt.Errorf("user %q not found", username)
}
id := users[0].ID
return id, nil
}
+71 -11
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"log/slog"
"maps"
"regexp"
"slices"
gitlab "gitlab.com/gitlab-org/api/client-go"
"go.abhg.dev/gs/internal/cmputil"
@@ -19,28 +21,52 @@ var _draftRegex = regexp.MustCompile(`(?i)^\s*(\[Draft]|Draft:|\(Draft\))\s*`)
// EditChange edits an existing change in a repository.
func (r *Repository) EditChange(ctx context.Context, id forge.ChangeID, opts forge.EditChangeOptions) error {
if cmputil.Zero(opts.Base) && cmputil.Zero(opts.Draft) && len(opts.Labels) == 0 && len(opts.Reviewers) == 0 {
if cmputil.Zero(opts.Base) &&
cmputil.Zero(opts.Draft) &&
len(opts.Labels) == 0 &&
len(opts.Reviewers) == 0 &&
len(opts.Assignees) == 0 {
return nil // nothing to do
}
mr := mustMR(id)
mrID := mustMR(id)
var (
updateOptions gitlab.UpdateMergeRequestOptions
logUpdates []slog.Attr
mergeRequest *gitlab.MergeRequest
)
// Used if we need to fetch the current MR status.
getMergeRequest := func() (*gitlab.MergeRequest, error) {
if mergeRequest != nil {
return mergeRequest, nil
}
var err error
mergeRequest, _, err = r.client.MergeRequests.GetMergeRequest(
r.repoID, mrID.Number, nil,
gitlab.WithContext(ctx),
)
if err != nil {
return nil, fmt.Errorf("get merge request for update: %w", err)
}
return mergeRequest, nil
}
if opts.Base != "" {
updateOptions.TargetBranch = &opts.Base
logUpdates = append(logUpdates, slog.String("base", opts.Base))
}
// TODO:
// As part of submit, we've likely already fetched this information.
// Cache it in memory.
if opts.Draft != nil {
mr, _, err := r.client.MergeRequests.GetMergeRequest(
r.repoID, mr.Number, nil,
gitlab.WithContext(ctx),
)
mr, err := getMergeRequest()
if err != nil {
return fmt.Errorf("get merge request for update: %w", err)
return err
}
if *opts.Draft {
@@ -65,12 +91,35 @@ func (r *Repository) EditChange(ctx context.Context, id forge.ChangeID, opts for
if err != nil {
return fmt.Errorf("resolve reviewer IDs: %w", err)
}
updateOptions.ReviewerIDs = &reviewerIDs
logUpdates = append(logUpdates, slog.Any("reviewers", opts.Reviewers))
mr, err := getMergeRequest()
if err != nil {
return err
}
merged := mergeAssigneeIDs(mr.Reviewers, reviewerIDs)
updateOptions.ReviewerIDs = &merged
logUpdates = append(logUpdates, slog.Any("reviewers", merged))
}
if len(opts.Assignees) > 0 {
assigneeIDs, err := r.assigneeIDs(ctx, opts.Assignees)
if err != nil {
return fmt.Errorf("resolve assignees: %w", err)
}
mr, err := getMergeRequest()
if err != nil {
return err
}
merged := mergeAssigneeIDs(mr.Assignees, assigneeIDs)
updateOptions.AssigneeIDs = &merged
logUpdates = append(logUpdates, slog.Any("assignees", merged))
}
_, _, err := r.client.MergeRequests.UpdateMergeRequest(
r.repoID, mr.Number, &updateOptions,
r.repoID, mrID.Number, &updateOptions,
gitlab.WithContext(ctx),
)
if err != nil {
@@ -78,10 +127,21 @@ func (r *Repository) EditChange(ctx context.Context, id forge.ChangeID, opts for
}
if len(logUpdates) > 0 {
r.log.Debug("Updated merge request",
"mr", mr.Number,
"mr", mrID.Number,
"new", slog.GroupValue(logUpdates...),
)
}
return nil
}
func mergeAssigneeIDs(existing []*gitlab.BasicUser, assignees []int) []int {
seen := make(map[int]struct{}, len(existing)+len(assignees))
for _, user := range existing {
seen[user.ID] = struct{}{}
}
for _, id := range assignees {
seen[id] = struct{}{}
}
return slices.Sorted(maps.Keys(seen))
}
+18
View File
@@ -23,6 +23,14 @@ func basicMergeRequestToFindChangeItem(mr *gitlab.BasicMergeRequest) *forge.Find
}
}
var assignees []string
if len(mr.Assignees) > 0 {
assignees = make([]string, len(mr.Assignees))
for i, assignee := range mr.Assignees {
assignees[i] = assignee.Username
}
}
return &forge.FindChangeItem{
ID: &MR{
Number: mr.IID,
@@ -35,6 +43,7 @@ func basicMergeRequestToFindChangeItem(mr *gitlab.BasicMergeRequest) *forge.Find
Draft: mr.Draft,
Labels: labels,
Reviewers: reviewers,
Assignees: assignees,
}
}
@@ -52,6 +61,14 @@ func mergeRequestToFindChangeItem(mr *gitlab.MergeRequest) *forge.FindChangeItem
}
}
var assignees []string
if len(mr.Assignees) > 0 {
assignees = make([]string, len(mr.Assignees))
for i, assignee := range mr.Assignees {
assignees[i] = assignee.Username
}
}
return &forge.FindChangeItem{
ID: &MR{
Number: mr.IID,
@@ -64,6 +81,7 @@ func mergeRequestToFindChangeItem(mr *gitlab.MergeRequest) *forge.FindChangeItem
Draft: mr.Draft,
Labels: labels,
Reviewers: reviewers,
Assignees: assignees,
}
}
+163
View File
@@ -1082,6 +1082,169 @@ func TestIntegration_Repository_notFoundError(t *testing.T) {
assert.ErrorContains(t, err, "404 Not Found")
}
func TestIntegration_Repository_FindChangeItem_WithAssignees(t *testing.T) {
branchFixture1 := fixturetest.New(_fixtures, "branch-no-assignees", func() string {
return randomString(8)
})
branchFixture2 := fixturetest.New(_fixtures, "branch-with-assignees", func() string {
return randomString(8)
})
branchNoAssignees := branchFixture1.Get(t)
branchWithAssignees := branchFixture2.Get(t)
t.Logf("Creating branches: %s, %s", branchNoAssignees, branchWithAssignees)
var (
gitRepo *git.Repository // only when _update is true
gitWork *git.Worktree
)
if gitlab.UpdateFixtures() {
t.Setenv("GIT_AUTHOR_EMAIL", "bot@example.com")
t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]")
t.Setenv("GIT_COMMITTER_EMAIL", "bot@example.com")
t.Setenv("GIT_COMMITTER_NAME", "gs-test[bot]")
output := t.Output()
ctx := t.Context()
t.Logf("Cloning test-repo...")
repoDir := t.TempDir()
cmd := exec.Command("git", "clone", "git@gitlab.com:abg/test-repo.git", repoDir)
cmd.Stdout = output
cmd.Stderr = output
require.NoError(t, cmd.Run(), "failed to clone test-repo")
var err error
gitWork, err = git.OpenWorktree(ctx, repoDir, git.OpenOptions{
Log: silogtest.New(t),
})
require.NoError(t, err, "failed to open git repo")
gitRepo = gitWork.Repository()
// Create and push first branch (no assignees).
require.NoError(t, gitRepo.CreateBranch(ctx, git.CreateBranchRequest{
Name: branchNoAssignees,
}), "could not create branch: %s", branchNoAssignees)
require.NoError(t, gitWork.CheckoutBranch(ctx, branchNoAssignees),
"could not checkout branch: %s", branchNoAssignees)
require.NoError(t, os.WriteFile(
filepath.Join(repoDir, branchNoAssignees+".txt"),
[]byte(randomString(32)),
0o644,
), "could not write file to branch")
cmd = exec.Command("git", "add", ".")
cmd.Dir = repoDir
cmd.Stdout = output
cmd.Stderr = output
require.NoError(t, cmd.Run(), "git add failed")
require.NoError(t, gitWork.Commit(ctx, git.CommitRequest{
Message: "commit from test",
}), "could not commit changes")
t.Logf("Pushing to origin: %s", branchNoAssignees)
require.NoError(t,
gitWork.Push(ctx, git.PushOptions{
Remote: "origin",
Refspec: git.Refspec(branchNoAssignees),
}), "error pushing branch")
// Create and push second branch (with assignees).
require.NoError(t, gitWork.CheckoutBranch(ctx, "main"),
"could not checkout main branch")
require.NoError(t, gitRepo.CreateBranch(ctx, git.CreateBranchRequest{
Name: branchWithAssignees,
}), "could not create branch: %s", branchWithAssignees)
require.NoError(t, gitWork.CheckoutBranch(ctx, branchWithAssignees),
"could not checkout branch: %s", branchWithAssignees)
require.NoError(t, os.WriteFile(
filepath.Join(repoDir, branchWithAssignees+".txt"),
[]byte(randomString(32)),
0o644,
), "could not write file to branch")
cmd = exec.Command("git", "add", ".")
cmd.Dir = repoDir
cmd.Stdout = output
cmd.Stderr = output
require.NoError(t, cmd.Run(), "git add failed")
require.NoError(t, gitWork.Commit(ctx, git.CommitRequest{
Message: "commit from test",
}), "could not commit changes")
t.Logf("Pushing to origin: %s", branchWithAssignees)
require.NoError(t,
gitWork.Push(ctx, git.PushOptions{
Remote: "origin",
Refspec: git.Refspec(branchWithAssignees),
}), "error pushing branch")
t.Cleanup(func() {
ctx := context.WithoutCancel(t.Context())
t.Logf("Deleting remote branches: %s, %s", branchNoAssignees, branchWithAssignees)
assert.NoError(t,
gitWork.Push(ctx, git.PushOptions{
Remote: "origin",
Refspec: git.Refspec(":" + branchNoAssignees),
}), "error deleting branch")
assert.NoError(t,
gitWork.Push(ctx, git.PushOptions{
Remote: "origin",
Refspec: git.Refspec(":" + branchWithAssignees),
}), "error deleting branch")
})
}
ctx := t.Context()
rec := newRecorder(t, t.Name())
glc := newGitLabClient(rec.GetDefaultClient())
repo, err := gitlab.NewRepository(
ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), glc, &gitlab.RepositoryOptions{RepositoryID: _testRepoID},
)
require.NoError(t, err)
changeNoAssignees, err := repo.SubmitChange(ctx, forge.SubmitChangeRequest{
Subject: branchNoAssignees,
Body: "Test MR without assignees",
Base: "main",
Head: branchNoAssignees,
})
require.NoError(t, err, "error creating MR without assignees")
changeWithAssignees, err := repo.SubmitChange(ctx, forge.SubmitChangeRequest{
Subject: branchWithAssignees,
Body: "Test MR with assignees",
Base: "main",
Head: branchWithAssignees,
Assignees: []string{"abg"},
})
require.NoError(t, err, "error creating MR with assignees")
t.Run("NoAssignees", func(t *testing.T) {
change, err := repo.FindChangeByID(t.Context(), changeNoAssignees.ID)
require.NoError(t, err)
assert.Empty(t, change.Assignees)
t.Run("AddAssignees", func(t *testing.T) {
require.NoError(t,
repo.EditChange(t.Context(), changeNoAssignees.ID, forge.EditChangeOptions{
Assignees: []string{"abg"},
}), "could not add assignees to MR")
change, err := repo.FindChangeByID(t.Context(), changeNoAssignees.ID)
require.NoError(t, err)
assert.Equal(t, []string{"abg"}, change.Assignees)
})
})
t.Run("WithAssignees", func(t *testing.T) {
change, err := repo.FindChangeByID(t.Context(), changeWithAssignees.ID)
require.NoError(t, err)
assert.Equal(t, []string{"abg"}, change.Assignees)
})
}
func TestIntegration_Repository_SubmitChange_removeSourceBranch(t *testing.T) {
ctx := t.Context()
branchFixture := fixturetest.New(_fixtures, "branch", func() string {
+10
View File
@@ -31,6 +31,7 @@ func (r *Repository) SubmitChange(ctx context.Context, req forge.SubmitChangeReq
if len(req.Labels) > 0 {
input.Labels = (*gitlab.LabelOptions)(&req.Labels)
}
if len(req.Reviewers) > 0 {
reviewerIDs, err := r.resolveReviewerIDs(ctx, req.Reviewers)
if err != nil {
@@ -38,6 +39,15 @@ func (r *Repository) SubmitChange(ctx context.Context, req forge.SubmitChangeReq
}
input.ReviewerIDs = &reviewerIDs
}
if len(req.Assignees) > 0 {
assigneeIDs, err := r.assigneeIDs(ctx, req.Assignees)
if err != nil {
return forge.SubmitChangeResult{}, fmt.Errorf("resolve assignees: %w", err)
}
input.AssigneeIDs = &assigneeIDs
}
request, _, err := r.client.MergeRequests.CreateMergeRequest(
r.repoID, input,
gitlab.WithContext(ctx),
@@ -1 +1 @@
"gz2WHyJk"
"wmgjGcz8"
@@ -1 +1 @@
"crSh9OsF"
"OuNox4Bt"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10
View File
@@ -98,6 +98,9 @@ type shamChange struct {
// RequestedReviewers are the usernames of users
// from whom reviews have been requested.
RequestedReviewers []string
// Assignees are users assigned to the change.
Assignees []string
}
// Change is a change proposal against a repository.
@@ -142,6 +145,9 @@ type Change struct {
// RequestedReviewers are the usernames of users
// from whom reviews have been requested.
RequestedReviewers []string `json:"requested_reviewers,omitempty"`
// Assignees are users assigned to the change.
Assignees []string `json:"assignees,omitempty"`
}
// toChange converts an internal shamChange
@@ -161,6 +167,9 @@ func (sh *ShamHub) toChange(c shamChange) (*Change, error) {
requestedReviewers := slices.Clone(c.RequestedReviewers)
slices.Sort(requestedReviewers)
assignees := slices.Clone(c.Assignees)
slices.Sort(assignees)
change := &Change{
Number: c.Number,
URL: sh.changeURL(c.Base.Owner, c.Base.Repo, c.Number),
@@ -171,6 +180,7 @@ func (sh *ShamHub) toChange(c shamChange) (*Change, error) {
Head: head,
Labels: c.Labels,
RequestedReviewers: requestedReviewers,
Assignees: assignees,
}
switch c.State {
case shamChangeOpen:
+12
View File
@@ -18,6 +18,7 @@ type editChangeRequest struct {
Draft *bool `json:"draft,omitempty"`
Labels []string `json:"labels,omitempty"`
Reviewers []string `json:"reviewers,omitempty"`
Assignees []string `json:"assignees,omitempty"`
}
type editChangeResponse struct{}
@@ -79,6 +80,16 @@ func (sh *ShamHub) handleEditChange(_ context.Context, req *editChangeRequest) (
sh.changes[changeIdx].RequestedReviewers = reviewers
}
if len(req.Assignees) > 0 {
assignees := sh.changes[changeIdx].Assignees
for _, assignee := range req.Assignees {
if !slices.Contains(assignees, assignee) {
assignees = append(assignees, assignee)
}
}
sh.changes[changeIdx].Assignees = assignees
}
return &editChangeResponse{}, nil // empty for now
}
@@ -92,6 +103,7 @@ func (r *forgeRepository) EditChange(ctx context.Context, fid forge.ChangeID, op
}
req.Labels = opts.Labels
req.Reviewers = opts.Reviewers
req.Assignees = opts.Assignees
id := fid.(ChangeID)
u := r.apiURL.JoinPath(r.owner, r.repo, "change", strconv.Itoa(int(id)))
+16 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"go.abhg.dev/gs/internal/forge"
@@ -21,6 +22,7 @@ type submitChangeRequest struct {
Draft bool `json:"draft,omitempty"`
Labels []string `json:"labels,omitempty"`
Reviewers []string `json:"reviewers,omitempty"`
Assignees []string `json:"assignees,omitempty"`
}
type submitChangeResponse struct {
@@ -56,18 +58,22 @@ func (sh *ShamHub) handleSubmitChange(ctx context.Context, req *submitChangeRequ
// Validate that all requested reviewers are registered users.
for _, reviewer := range req.Reviewers {
found := false
for _, u := range sh.users {
if u.Username == reviewer {
found = true
break
}
}
if !found {
if !slices.ContainsFunc(sh.users, func(u shamUser) bool {
return u.Username == reviewer
}) {
return nil, badRequestErrorf("reviewer %q is not a registered user", reviewer)
}
}
// Validate that all assignees are registered users.
for _, assignee := range req.Assignees {
if !slices.ContainsFunc(sh.users, func(u shamUser) bool {
return u.Username == assignee
}) {
return nil, badRequestErrorf("assignee %q is not a registered user", assignee)
}
}
change := shamChange{
// We'll just use a global counter for the change number for now.
// We can scope it by owner/repo if needed.
@@ -79,6 +85,7 @@ func (sh *ShamHub) handleSubmitChange(ctx context.Context, req *submitChangeRequ
Head: &shamBranch{Owner: headOwner, Repo: headRepo, Name: req.Head},
Labels: req.Labels,
RequestedReviewers: req.Reviewers,
Assignees: req.Assignees,
}
sh.changes = append(sh.changes, change)
@@ -99,6 +106,7 @@ func (r *forgeRepository) SubmitChange(ctx context.Context, req forge.SubmitChan
Draft: req.Draft,
Labels: req.Labels,
Reviewers: req.Reviewers,
Assignees: req.Assignees,
}
// For now, fork functionality is handled at the ShamHub server level
+38 -40
View File
@@ -18,6 +18,7 @@ import (
"go.abhg.dev/gs/internal/browser"
"go.abhg.dev/gs/internal/forge"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/iterutil"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
@@ -151,7 +152,6 @@ type Options struct {
DraftDefault bool `config:"submit.draft" help:"Default value for --draft when creating change requests." hidden:"" default:"false"`
// TODO: Other creation options e.g.:
// - assignees
// - milestone
// - reviewers
@@ -161,6 +161,9 @@ type Options struct {
Reviewers []string `short:"r" name:"reviewer" help:"Add reviewers to the change request. Pass multiple times or separate with commas." released:"unreleased"`
ConfiguredReviewers []string `name:"configured-reviewers" help:"Default reviewers to add to change requests." hidden:"" config:"submit.reviewers" released:"unreleased"` // merged with Reviewers
Assignees []string `short:"a" name:"assign" placeholder:"ASSIGNEE" help:"Assign the change request to these users. Pass multiple times or separate with commas." released:"unreleased"`
ConfiguredAssignees []string `name:"configured-assignees" help:"Default assignees to add to change requests." hidden:"" config:"submit.assignees" released:"unreleased"` // merged with Assignees
// ListTemplatesTimeout controls the timeout for listing CR templates.
ListTemplatesTimeout time.Duration `hidden:"" config:"submit.listTemplatesTimeout" help:"Timeout for listing CR templates" default:"1s"`
@@ -170,6 +173,16 @@ type Options struct {
Template string `hidden:"" config:"submit.template" help:"Default template to use when multiple templates are available"`
}
func mergeConfiguredValues(values []string, configured []string) []string {
return slices.Collect(iterutil.Uniq(values, configured))
}
func mergeConfiguredOptions(opts *Options) {
opts.Labels = mergeConfiguredValues(opts.Labels, opts.ConfiguredLabels)
opts.Reviewers = mergeConfiguredValues(opts.Reviewers, opts.ConfiguredReviewers)
opts.Assignees = mergeConfiguredValues(opts.Assignees, opts.ConfiguredAssignees)
}
// NavCommentSync specifies the scope of navigation comment updates.
type NavCommentSync int
@@ -273,32 +286,7 @@ type BatchRequest struct {
// creating or updating change requests as needed.
func (h *Handler) SubmitBatch(ctx context.Context, req *BatchRequest) error {
opts := cmp.Or(req.Options, &Options{})
if len(opts.ConfiguredLabels) > 0 {
seen := make(map[string]struct{}, len(opts.Labels))
for _, label := range opts.Labels {
seen[label] = struct{}{}
}
for _, label := range opts.ConfiguredLabels {
if _, ok := seen[label]; !ok {
opts.Labels = append(opts.Labels, label)
}
}
}
if len(opts.ConfiguredReviewers) > 0 {
seen := make(map[string]struct{}, len(opts.Reviewers))
for _, reviewer := range opts.Reviewers {
seen[reviewer] = struct{}{}
}
for _, reviewer := range opts.ConfiguredReviewers {
if _, ok := seen[reviewer]; !ok {
opts.Reviewers = append(opts.Reviewers, reviewer)
}
}
}
mergeConfiguredOptions(opts)
batchOpts := cmp.Or(req.BatchOptions, &BatchOptions{})
if batchOpts.UpdateOnlyDefault && opts.UpdateOnly == nil {
@@ -357,19 +345,7 @@ type Request struct {
// creating or updating a change request as needed.
func (h *Handler) Submit(ctx context.Context, req *Request) error {
opts := cmp.Or(req.Options, &Options{})
if len(opts.ConfiguredReviewers) > 0 {
seen := make(map[string]struct{}, len(opts.Reviewers))
for _, reviewer := range opts.Reviewers {
seen[reviewer] = struct{}{}
}
for _, reviewer := range opts.ConfiguredReviewers {
if _, ok := seen[reviewer]; !ok {
opts.Reviewers = append(opts.Reviewers, reviewer)
}
}
}
mergeConfiguredOptions(opts)
status, err := h.submitBranch(
ctx,
req.Branch,
@@ -796,6 +772,24 @@ func (h *Handler) submitBranch(
updates = append(updates, "set draft to "+strconv.FormatBool(*opts.Draft))
}
if len(opts.Assignees) > 0 {
existingAssigneeSet := make(map[string]struct{}, len(pull.Assignees))
for _, assignee := range pull.Assignees {
existingAssigneeSet[assignee] = struct{}{}
}
var assigneesToAdd []string
for _, assignee := range opts.Assignees {
if _, exists := existingAssigneeSet[assignee]; !exists {
assigneesToAdd = append(assigneesToAdd, assignee)
}
}
if len(assigneesToAdd) > 0 {
sort.Strings(assigneesToAdd)
updates = append(updates, "add assignees: "+strings.Join(assigneesToAdd, ", "))
}
}
// Check for labels that would be added.
if len(opts.Labels) > 0 {
existingLabelSet := make(map[string]struct{}, len(pull.Labels))
@@ -875,6 +869,7 @@ func (h *Handler) submitBranch(
Draft: opts.Draft,
Labels: opts.Labels,
Reviewers: opts.Reviewers,
Assignees: opts.Assignees,
}
// remoteRepo is guaranteed to be available at this point.
@@ -1078,6 +1073,7 @@ func (h *Handler) prepareBranch(
log: h.Log,
labels: opts.Labels,
reviewers: opts.Reviewers,
assignees: opts.Assignees,
}, nil
}
@@ -1118,6 +1114,7 @@ type preparedBranch struct {
draft bool
labels []string
reviewers []string
assignees []string
remoteRepo forge.Repository
store Store
@@ -1133,6 +1130,7 @@ func (b *preparedBranch) Publish(ctx context.Context) (forge.ChangeID, string, e
Draft: b.draft,
Labels: b.labels,
Reviewers: b.reviewers,
Assignees: b.assignees,
})
if err != nil {
// If the branch could not be submitted because the base branch
+20
View File
@@ -15,3 +15,23 @@ func Enumerate[T any](seq iter.Seq[T]) iter.Seq2[int, T] {
}
}
}
// Uniq returns an iterator that yields unique values
// from all provided slices in order,
// skipping duplicates.
func Uniq[T comparable](slices ...[]T) iter.Seq[T] {
return func(yield func(T) bool) {
seen := make(map[T]struct{})
for _, slice := range slices {
for _, item := range slice {
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
if !yield(item) {
return
}
}
}
}
}
+68
View File
@@ -84,6 +84,74 @@ func TestEnumerate(t *testing.T) {
})
}
func TestUniq(t *testing.T) {
tests := []struct {
name string
give [][]string
want []string
}{
{
name: "SingleSliceNoDuplicates",
give: [][]string{{"foo", "bar", "baz"}},
want: []string{"foo", "bar", "baz"},
},
{
name: "SingleSliceWithDuplicates",
give: [][]string{{"foo", "bar", "foo", "baz", "bar"}},
want: []string{"foo", "bar", "baz"},
},
{
name: "MultipleSlicesNoDuplicates",
give: [][]string{{"a", "b"}, {"c", "d"}},
want: []string{"a", "b", "c", "d"},
},
{
name: "MultipleSlicesWithDuplicatesAcrossSlices",
give: [][]string{{"a", "b", "c"}, {"b", "c", "d"}, {"c", "d", "e"}},
want: []string{"a", "b", "c", "d", "e"},
},
{
name: "MultipleSlicesWithDuplicatesWithinAndAcross",
give: [][]string{{"a", "b", "b", "c"}, {"b", "c", "d", "d"}, {"c", "d", "e"}},
want: []string{"a", "b", "c", "d", "e"},
},
{
name: "EmptySlicesMixed",
give: [][]string{{"a"}, {}, {"b", "a"}, {}},
want: []string{"a", "b"},
},
{
name: "PreserveOrder",
give: [][]string{{"z", "a", "m"}, {"a", "b", "z"}},
want: []string{"z", "a", "m", "b"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := slices.Collect(Uniq(tt.give...))
assert.Equal(t, tt.want, got)
})
}
t.Run("EmptySlices", func(t *testing.T) {
got := slices.Collect(Uniq[string]())
assert.Empty(t, got)
})
t.Run("EarlyTermination", func(t *testing.T) {
var got []int
for val := range Uniq([]int{1, 2, 3, 4, 5}, []int{6, 7, 8}) {
got = append(got, val)
if val == 3 {
break
}
}
want := []int{1, 2, 3}
assert.Equal(t, want, got)
})
}
type indexed[T any] struct {
Index int
Value T
+3
View File
@@ -46,6 +46,8 @@ Flags:
times or separate with commas.
-r, --reviewer=REVIEWER,... Add reviewers to the change request. Pass
multiple times or separate with commas.
-a, --assign=ASSIGNEE,... Assign the change request to these users.
Pass multiple times or separate with commas.
--no-web Alias for --web=false.
--title=TITLE Title of the change request
--body=BODY Body of the change request
@@ -59,6 +61,7 @@ Global Flags:
--[no-]prompt Whether to prompt for missing information
Configuration (🔧):
spice.submit.assignees Default assignees to add to change requests.
spice.submit.draft Default value for --draft when creating change
requests.
spice.submit.label Default labels to add to change requests.
+3
View File
@@ -47,6 +47,8 @@ Flags:
times or separate with commas.
-r, --reviewer=REVIEWER,... Add reviewers to the change request. Pass
multiple times or separate with commas.
-a, --assign=ASSIGNEE,... Assign the change request to these users.
Pass multiple times or separate with commas.
--no-web Alias for --web=false.
--branch=NAME Branch to start at
@@ -58,6 +60,7 @@ Global Flags:
--[no-]prompt Whether to prompt for missing information
Configuration (🔧):
spice.submit.assignees Default assignees to add to change requests.
spice.submit.draft Default value for --draft when creating change
requests.
spice.submit.label Default labels to add to change requests.
+3
View File
@@ -46,6 +46,8 @@ Flags:
times or separate with commas.
-r, --reviewer=REVIEWER,... Add reviewers to the change request. Pass
multiple times or separate with commas.
-a, --assign=ASSIGNEE,... Assign the change request to these users.
Pass multiple times or separate with commas.
--no-web Alias for --web=false.
Global Flags:
@@ -56,6 +58,7 @@ Global Flags:
--[no-]prompt Whether to prompt for missing information
Configuration (🔧):
spice.submit.assignees Default assignees to add to change requests.
spice.submit.draft Default value for --draft when creating change
requests.
spice.submit.label Default labels to add to change requests.
+3
View File
@@ -49,6 +49,8 @@ Flags:
times or separate with commas.
-r, --reviewer=REVIEWER,... Add reviewers to the change request. Pass
multiple times or separate with commas.
-a, --assign=ASSIGNEE,... Assign the change request to these users.
Pass multiple times or separate with commas.
--no-web Alias for --web=false.
--branch=NAME Branch to start at
@@ -60,6 +62,7 @@ Global Flags:
--[no-]prompt Whether to prompt for missing information
Configuration (🔧):
spice.submit.assignees Default assignees to add to change requests.
spice.submit.draft Default value for --draft when creating change
requests.
spice.submit.label Default labels to add to change requests.
+94
View File
@@ -0,0 +1,94 @@
# Submit a single branch with assignees.
as 'Test <test@example.com>'
at '2024-04-05T16:40:32Z'
# setup
cd repo
git init
git commit --allow-empty -m 'Initial commit'
# set up a fake GitHub remote
shamhub init
shamhub new origin alice/example.git
shamhub register alice
shamhub register bob
shamhub register charlie
git push origin main
# create a branch
git add feature.txt
gs branch create feature -m 'Add feature'
env SHAMHUB_USERNAME=alice
gs auth login
git config spice.submit.assignees bob
# Submit the branch with an additional assignee.
gs branch submit --fill --assign charlie
shamhub dump changes
cmpenvJSON stdout $WORK/golden/submitted.json
# Update the branch with a new assignee.
gs branch submit --assign dave
shamhub dump changes
cmpenvJSON stdout $WORK/golden/updated.json
-- repo/feature.txt --
This is a feature
-- golden/submitted.json --
[
{
"number": 1,
"state": "open",
"title": "Add feature",
"body": "",
"html_url": "$SHAMHUB_URL/alice/example/change/1",
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature",
"sha": "79b9cc53bc9d9c20db08dd3f9b3d969a06c9d049"
},
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "ece8ed7bb81d74cb6787309fa41b7deb2e0558a3"
},
"assignees": ["bob", "charlie"]
}
]
-- golden/updated.json --
[
{
"number": 1,
"state": "open",
"title": "Add feature",
"body": "",
"html_url": "$SHAMHUB_URL/alice/example/change/1",
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature",
"sha": "79b9cc53bc9d9c20db08dd3f9b3d969a06c9d049"
},
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "ece8ed7bb81d74cb6787309fa41b7deb2e0558a3"
},
"assignees": ["bob", "charlie", "dave"]
}
]
+220
View File
@@ -0,0 +1,220 @@
# submit a stack of PRs with 'stack submit'.
as 'Test <test@example.com>'
at '2024-04-05T16:40:32Z'
# setup
cd repo
git init
git commit --allow-empty -m 'Initial commit'
# set up a fake GitHub remote
shamhub init
shamhub new origin alice/example.git
shamhub register alice
shamhub register alex
shamhub register beatrice
shamhub register cameron
shamhub register dorian
shamhub register spice
git push origin main
# create a stack:
# main -> feature1 -> feature2 -> feature3
git add feature1.txt
gs branch create feature1 -m 'Add feature 1'
git add feature2.txt
gs branch create feature2 -m 'Add feature 2'
git add feature3.txt
gs branch create feature3 -m 'Add feature 3'
env SHAMHUB_USERNAME=alice
gs auth login
git config spice.submit.assignees spice
# submit the entire stack from the middle.
git checkout feature1
gs stack submit --fill --assign alex --assign beatrice,cameron
cmpenv stderr $WORK/golden/submit-log.txt
shamhub dump changes
cmpenvJSON stdout $WORK/golden/start.json
# Merge the bottom PR, sync, restack, and submit with new assignees.
shamhub merge alice/example 1
gs rs
stderr '#1 was merged'
gs sr # stack restack
gs ss --assign dorian # stack submit
! stderr 'Updated #1'
stderr 'Updated #2'
stderr 'Updated #3'
shamhub dump changes
cmpenvJSON stdout $WORK/golden/updated.json
-- repo/feature1.txt --
This is feature 1
-- repo/feature2.txt --
This is feature 2
-- repo/feature3.txt --
This is feature 3
-- golden/submit-log.txt --
INF Created #1: $SHAMHUB_URL/alice/example/change/1
INF Created #2: $SHAMHUB_URL/alice/example/change/2
INF Created #3: $SHAMHUB_URL/alice/example/change/3
-- golden/start.json --
[
{
"number": 1,
"state": "open",
"title": "Add feature 1",
"body": "",
"html_url": "$SHAMHUB_URL/alice/example/change/1",
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature1",
"sha": "8526d1a7195abb635f28bc93155b9155b76f3881"
},
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "ece8ed7bb81d74cb6787309fa41b7deb2e0558a3"
},
"assignees": ["alex", "beatrice", "cameron", "spice"]
},
{
"number": 2,
"state": "open",
"title": "Add feature 2",
"body": "",
"html_url": "$SHAMHUB_URL/alice/example/change/2",
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature2",
"sha": "9806160f3b27dbff81e496260d26fc32f5ee5cf0"
},
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature1",
"sha": "8526d1a7195abb635f28bc93155b9155b76f3881"
},
"assignees": ["alex", "beatrice", "cameron", "spice"]
},
{
"number": 3,
"state": "open",
"title": "Add feature 3",
"body": "",
"html_url": "$SHAMHUB_URL/alice/example/change/3",
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature3",
"sha": "63b2d337c8172c9f79aec9c760efc95e3c0c8472"
},
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature2",
"sha": "9806160f3b27dbff81e496260d26fc32f5ee5cf0"
},
"assignees": ["alex", "beatrice", "cameron", "spice"]
}
]
-- golden/updated.json --
[
{
"number": 1,
"state": "closed",
"merged": true,
"title": "Add feature 1",
"body": "",
"html_url": "$SHAMHUB_URL/alice/example/change/1",
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature1",
"sha": "8526d1a7195abb635f28bc93155b9155b76f3881"
},
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "59d3064874a8d0a003755556a982d1fe8adb59dc"
},
"assignees": ["alex", "beatrice", "cameron", "spice"]
},
{
"number": 2,
"state": "open",
"title": "Add feature 2",
"body": "",
"html_url": "$SHAMHUB_URL/alice/example/change/2",
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature2",
"sha": "18e5a5208175cd409480c2ab89588877726c1d57"
},
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "59d3064874a8d0a003755556a982d1fe8adb59dc"
},
"assignees": ["alex", "beatrice", "cameron", "dorian", "spice"]
},
{
"number": 3,
"state": "open",
"title": "Add feature 3",
"body": "",
"html_url": "$SHAMHUB_URL/alice/example/change/3",
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature3",
"sha": "aa7889fb8c0037d05573a0d27cacfcc78c5f1ba4"
},
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature2",
"sha": "18e5a5208175cd409480c2ab89588877726c1d57"
},
"assignees": ["alex", "beatrice", "cameron", "dorian", "spice"]
}
]