Skip to content

Commit 18f8f97

Browse files
carmoooabhinav
andauthored
feat: add labels to github PRs (#773)
Adds -l/--label flags to set labels on GitHub PRs. Can also be set with the spice.submit.label configuration option. Includes integration tests at GitHub and git-spice script test levels. DOES NOT include GitLab support. Resolves #414 --------- Co-authored-by: Abhinav Gupta <[email protected]>
1 parent 1445645 commit 18f8f97

File tree

25 files changed

+1548
-20
lines changed

25 files changed

+1548
-20
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: Added
2+
body: >-
3+
submit: Add -l/--label flag and accompanying spice.submit.label option
4+
to add labels to created/updated CRs.
5+
time: 2025-07-23T16:43:25.552204+01:00

doc/includes/cli-reference.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,10 @@ or --nav-comment=multiple to post those comments only if there are multiple CRs
236236
* `--force`: Force push, bypassing safety checks
237237
* `--no-verify`: Bypass pre-push hooks when pushing to the remote. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.15.0](/changelog.md#v0.15.0)</span>
238238
* `-u`, `--update-only`: Only update existing change requests, do not create new ones
239+
* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas.
239240
* `--no-web`: Alias for --web=false.
240241

241-
**Configuration**: [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.web](/cli/config.md#spicesubmitweb)
242+
**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.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.web](/cli/config.md#spicesubmitweb)
242243

243244
### gs stack restack
244245

@@ -346,10 +347,11 @@ or --nav-comment=multiple to post those comments only if there are multiple CRs
346347
* `--force`: Force push, bypassing safety checks
347348
* `--no-verify`: Bypass pre-push hooks when pushing to the remote. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.15.0](/changelog.md#v0.15.0)</span>
348349
* `-u`, `--update-only`: Only update existing change requests, do not create new ones
350+
* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas.
349351
* `--no-web`: Alias for --web=false.
350352
* `--branch=NAME`: Branch to start at
351353

352-
**Configuration**: [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.web](/cli/config.md#spicesubmitweb)
354+
**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.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.web](/cli/config.md#spicesubmitweb)
353355

354356
### gs upstack restack
355357

@@ -478,10 +480,11 @@ or --nav-comment=multiple to post those comments only if there are multiple CRs
478480
* `--force`: Force push, bypassing safety checks
479481
* `--no-verify`: Bypass pre-push hooks when pushing to the remote. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.15.0](/changelog.md#v0.15.0)</span>
480482
* `-u`, `--update-only`: Only update existing change requests, do not create new ones
483+
* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas.
481484
* `--no-web`: Alias for --web=false.
482485
* `--branch=NAME`: Branch to start at
483486

484-
**Configuration**: [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.web](/cli/config.md#spicesubmitweb)
487+
**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.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.web](/cli/config.md#spicesubmitweb)
485488

486489
### gs downstack edit
487490

@@ -896,12 +899,13 @@ or --nav-comment=multiple to post those comments only if there are multiple CRs
896899
* `--force`: Force push, bypassing safety checks
897900
* `--no-verify`: Bypass pre-push hooks when pushing to the remote. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag:{ title="Released in version" }</span><span class="mdx-badge__text">[v0.15.0](/changelog.md#v0.15.0)</span>
898901
* `-u`, `--update-only`: Only update existing change requests, do not create new ones
902+
* `-l`, `--label=LABEL,...`: Add labels to the change request. Pass multiple times or separate with commas.
899903
* `--no-web`: Alias for --web=false.
900904
* `--title=TITLE`: Title of the change request
901905
* `--body=BODY`: Body of the change request
902906
* `--branch=NAME`: Branch to submit
903907

904-
**Configuration**: [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.web](/cli/config.md#spicesubmitweb)
908+
**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.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.web](/cli/config.md#spicesubmitweb)
905909

906910
## Commit
907911

doc/mise.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

doc/src/cli/config.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,18 @@ This option affects both interactive and non-interactive modes:
280280
- `true`: create CRs as drafts by default
281281
- `false` (default): create CRs as ready for review by default
282282

283+
### spice.submit.label
284+
285+
<!-- gs:version unreleased -->
286+
287+
Add the configured labels to all submitted and updated change requests
288+
when using $$gs branch submit$$ and friends.
289+
290+
The value must be a comma-separated list of labels.
291+
292+
Labels specified with the `-l`/`--label` flags
293+
will be combined with the configured labels.
294+
283295
### spice.submit.listTemplatesTimeout
284296

285297
<!-- gs:version v0.8.0 -->

internal/forge/forge.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ type SubmitChangeRequest struct {
298298

299299
// Draft specifies whether the change should be marked as a draft.
300300
Draft bool
301+
302+
// Labels are optional labels to apply to the change.
303+
Labels []string
301304
}
302305

303306
// SubmitChangeResult is the result of creating a new change in a repository.
@@ -317,6 +320,8 @@ type EditChangeOptions struct {
317320
// Draft specifies whether the change should be marked as a draft.
318321
// If unset, the draft status is not changed.
319322
Draft *bool
323+
324+
Labels []string
320325
}
321326

322327
// FindChangeItem is a single result from searching for changes in the

internal/forge/github/edit.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
// EditChange edits an existing change in a repository.
1313
func (r *Repository) EditChange(ctx context.Context, fid forge.ChangeID, opts forge.EditChangeOptions) error {
14-
if cmputil.Zero(opts) {
14+
if cmputil.Zero(opts.Base) && cmputil.Zero(opts.Draft) && len(opts.Labels) == 0 {
1515
return nil // nothing to do
1616
}
1717
pr := mustPR(fid)
@@ -85,5 +85,10 @@ func (r *Repository) EditChange(ctx context.Context, fid forge.ChangeID, opts fo
8585
r.log.Debug(logMsg, "pr", pr.Number)
8686
}
8787

88+
err = r.addLabelsToPullRequest(ctx, opts.Labels, graphQLID)
89+
if err != nil {
90+
return fmt.Errorf("add labels to PR: %w", err)
91+
}
92+
8893
return nil
8994
}

internal/forge/github/integration_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,163 @@ func TestIntegration_Repository_SubmitEditChange(t *testing.T) {
393393
})
394394
}
395395

396+
func TestIntegration_Repository_SubmitEditChange_labels(t *testing.T) {
397+
label1 := fixturetest.New(_fixtures, "label1", func() string { return randomString(8) }).Get(t)
398+
label2 := fixturetest.New(_fixtures, "label2", func() string { return randomString(8) }).Get(t)
399+
label3 := fixturetest.New(_fixtures, "label3", func() string { return randomString(8) }).Get(t)
400+
401+
branchFixture := fixturetest.New(_fixtures, "branch", func() string {
402+
return randomString(8)
403+
})
404+
405+
branchName := branchFixture.Get(t)
406+
t.Logf("Creating branch: %s", branchName)
407+
408+
var (
409+
gitRepo *git.Repository // only when _update is true
410+
gitWork *git.Worktree
411+
)
412+
if *_update {
413+
t.Setenv("GIT_AUTHOR_EMAIL", "[email protected]")
414+
t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]")
415+
t.Setenv("GIT_COMMITTER_EMAIL", "[email protected]")
416+
t.Setenv("GIT_COMMITTER_NAME", "gs-test[bot]")
417+
418+
output := ioutil.TestLogWriter(t, "[git] ")
419+
420+
t.Logf("Cloning test-repo...")
421+
repoDir := t.TempDir()
422+
cmd := exec.Command("git", "clone", "https://github.com/abhinav/test-repo", repoDir)
423+
cmd.Stdout = output
424+
cmd.Stdout = output
425+
require.NoError(t, cmd.Run(), "failed to clone test-repo")
426+
427+
ctx := t.Context()
428+
429+
var err error
430+
gitWork, err = git.OpenWorktree(ctx, repoDir, git.OpenOptions{
431+
Log: silogtest.New(t),
432+
})
433+
require.NoError(t, err, "failed to open git repo")
434+
gitRepo = gitWork.Repository()
435+
436+
require.NoError(t, gitRepo.CreateBranch(ctx, git.CreateBranchRequest{
437+
Name: branchName,
438+
}), "could not create branch: %s", branchName)
439+
require.NoError(t, gitWork.Checkout(ctx, branchName),
440+
"could not checkout branch: %s", branchName)
441+
require.NoError(t, os.WriteFile(
442+
filepath.Join(repoDir, branchName+".txt"),
443+
[]byte(randomString(32)),
444+
0o644,
445+
), "could not write file to branch")
446+
447+
cmd = exec.Command("git", "add", ".")
448+
cmd.Dir = repoDir
449+
cmd.Stdout = output
450+
cmd.Stderr = output
451+
require.NoError(t, cmd.Run(), "git add failed")
452+
require.NoError(t, gitWork.Commit(ctx, git.CommitRequest{
453+
Message: "commit from test",
454+
}), "could not commit changes")
455+
456+
t.Logf("Pushing to origin")
457+
require.NoError(t,
458+
gitWork.Push(ctx, git.PushOptions{
459+
Remote: "origin",
460+
Refspec: git.Refspec(branchName),
461+
}), "error pushing branch")
462+
463+
t.Cleanup(func() {
464+
t.Logf("Deleting remote branch: %s", branchName)
465+
assert.NoError(t,
466+
gitWork.Push(context.WithoutCancel(ctx), git.PushOptions{
467+
Remote: "origin",
468+
Refspec: git.Refspec(":" + branchName),
469+
}), "error deleting branch")
470+
})
471+
}
472+
473+
rec := newRecorder(t, t.Name())
474+
ghc := newGitHubClient(rec.GetDefaultClient())
475+
repo, err := github.NewRepository(
476+
t.Context(), new(github.Forge), "abhinav", "test-repo", silogtest.New(t), ghc, _testRepoID,
477+
)
478+
require.NoError(t, err)
479+
480+
t.Cleanup(func() {
481+
labels := []string{label1, label2, label3}
482+
ctx := context.WithoutCancel(t.Context())
483+
for _, label := range labels {
484+
assert.NoError(t,
485+
repo.DeleteLabel(ctx, label), "could not delete label: %s", label)
486+
}
487+
})
488+
489+
change, err := repo.SubmitChange(t.Context(), forge.SubmitChangeRequest{
490+
Subject: branchName,
491+
Body: "Test PR",
492+
Base: "main",
493+
Head: branchName,
494+
Labels: []string{label1},
495+
})
496+
require.NoError(t, err, "error creating PR")
497+
changeID := change.ID
498+
499+
t.Run("AddNewLabel", func(t *testing.T) {
500+
require.NoError(t,
501+
repo.EditChange(t.Context(), changeID, forge.EditChangeOptions{
502+
Labels: []string{label2},
503+
}), "could not add labels to PR")
504+
})
505+
506+
t.Run("AddExistingLabel", func(t *testing.T) {
507+
require.NoError(t,
508+
repo.EditChange(t.Context(), changeID, forge.EditChangeOptions{
509+
Labels: []string{label2, label3},
510+
}), "could not add existing label to PR")
511+
})
512+
}
513+
514+
func TestIntegration_Repository_LabelCreateDelete(t *testing.T) {
515+
label := fixturetest.New(_fixtures, "label1", func() string { return randomString(8) }).Get(t)
516+
517+
rec := newRecorder(t, t.Name())
518+
ghc := newGitHubClient(rec.GetDefaultClient())
519+
repo, err := github.NewRepository(
520+
t.Context(), new(github.Forge), "abhinav", "test-repo", silogtest.New(t), ghc, _testRepoID,
521+
)
522+
require.NoError(t, err)
523+
524+
t.Run("DoesNotExist", func(t *testing.T) {
525+
_, err := repo.LabelID(t.Context(), label)
526+
require.Error(t, err, "expected error for non-existent label")
527+
assert.ErrorIs(t, err, github.ErrLabelNotFound)
528+
})
529+
530+
id, err := repo.CreateLabel(t.Context(), label)
531+
require.NoError(t, err, "could not create label")
532+
t.Cleanup(func() {
533+
t.Logf("Deleting label: %s", label)
534+
ctx := context.WithoutCancel(t.Context())
535+
assert.NoError(t,
536+
repo.DeleteLabel(ctx, label), "could not delete label")
537+
})
538+
539+
t.Run("LabelID", func(t *testing.T) {
540+
gotID, err := repo.LabelID(t.Context(), label)
541+
require.NoError(t, err, "could not get label ID")
542+
assert.Equal(t, id, gotID, "label ID does not match")
543+
})
544+
545+
t.Run("createIsIdempotent", func(t *testing.T) {
546+
newID, err := repo.CreateLabel(t.Context(), label)
547+
require.NoError(t, err, "could not create label again")
548+
549+
assert.Equal(t, id, newID, "label ID should be the same on idempotent create")
550+
})
551+
}
552+
396553
func TestIntegration_Repository_SubmitChange_baseBranchDoesNotExist(t *testing.T) {
397554
branchFixture := fixturetest.New(_fixtures, "branch", func() string {
398555
return randomString(8)

0 commit comments

Comments
 (0)