Skip to content
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Repository Guidelines

`README.md` is the source of truth for installation, usage, and development. Use these anchors: Philosophy, Basic usage, Detailed usage, Caveats, Local development, Available Development Tasks.

- Tooling: pinned via `.mise.toml` (Go 1.23, golangci-lint v2.1.5, goreleaser v2.12.7). Run `mise install`; `mise run dev` prepares deps + lint; `mise run test` runs lint + race/cover; `mise run fix` applies gofmt/golangci-lint fixes.
- Project structure: CLI entrypoint `main.go`; commands in `cmd/`; shared logic under `internal/<domain>`; tests co-located as `_test.go`; demo asset `docs/demo.gif`; release artifacts in `dist/` (clean with `mise run clean`); fakes for git/GitHub/filesystem interactions in `internal/testsupport`.
- Style: standard Go formatting; package names lower_snakecase; include the Apache 2.0 header from `CONTRIBUTING.md` when creating files.
- Usage workflow: follow README sections noted above; when operating turbolift across many repos, use `repos.txt` subsets and `turbolift foreach --failed/--successful` to reduce blast radius (see Caveats).
- Operational notes: authenticate GitHub CLI (`gh auth login`; set `GH_HOST` for GHE). Use `mise run build`/`mise run install` for local binaries; `mise run release` drives GoReleaser via the managed binary.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ Next, to push and raise PRs against changed repos, run:

Use `turbolift create-prs --sleep 30s` to, for example, force a 30s pause between creation of each PR. This can be helpful in reducing load on shared infrastructure.

By default Turbolift applies a `turbolift` label to each PR it creates. Opt out with `turbolift create-prs --no-apply-labels`.

> Important: if raising many PRs, you may generate load on shared infrastructure such as CI. It is *highly* recommended that you:
> * slow the rate of PR creation by making Turbolift sleep in between PRs
> * create PRs in batches, for example by commenting out repositories in `repos.txt`
Expand Down Expand Up @@ -309,18 +311,21 @@ This project uses [mise-en-place](https://mise.jdx.dev/) for development environ

1. Clone the repository and navigate to the project directory
2. Run `mise install` to install the required Go version and tools (defined in `.mise.toml`)
3. Run `mise run dev` to set up the development environment
3. Run `mise run dev` to run the basic setup (downloads modules and lints)
4. Use mise tasks with `mise run <task>` to perform development operations

### Available Development Tasks

- `mise run mod` - Download and tidy Go modules
- `mise run lint` - Run linting
- `mise run fix` - Auto-fix linting issues
- `mise run fmt` - Alias for `fix`
- `mise run build` - Build the application
- `mise run install` - Install the application locally
- `mise run test` - Run tests
- `mise run clean` - Clean build artifacts
- `mise run dev` - Run development setup
- `mise run dev` - Run development setup (runs `mod` then `lint`)
- `mise run release` - Build release artifacts with GoReleaser

All tools and dependencies are automatically managed by mise-en-place based on the `.mise.toml` configuration.
Note: `mise run test` depends on `lint`, so linting will run automatically before tests.
8 changes: 8 additions & 0 deletions cmd/create_prs/create_prs.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ var (
repoFile string
prDescriptionFile string
sleep time.Duration
noApplyLabels bool
)

func NewCreatePRsCmd() *cobra.Command {
Expand All @@ -55,6 +56,7 @@ func NewCreatePRsCmd() *cobra.Command {

cmd.Flags().DurationVar(&sleep, "sleep", 0, "Fixed sleep in between PR creations (to spread load on CI infrastructure)")
cmd.Flags().BoolVar(&isDraft, "draft", false, "Creates the Pull Request as Draft PR")
cmd.Flags().BoolVar(&noApplyLabels, "no-apply-labels", false, "Do not apply the default turbolift label to created PRs")
cmd.Flags().StringVar(&repoFile, "repos", "repos.txt", "A file containing a list of repositories to clone.")
cmd.Flags().StringVar(&prDescriptionFile, "description", "README.md", "A file containing the title and description for the PRs.")

Expand Down Expand Up @@ -116,11 +118,17 @@ func run(c *cobra.Command, _ []string) {
createPrActivity = logger.StartActivity("Creating PR in %s", repo.FullRepoName)
}

labels := []string{github.TurboliftLabel}
if noApplyLabels {
labels = nil
}

pullRequest := github.PullRequest{
Title: dir.PrTitle,
Body: dir.PrBody,
UpstreamRepo: repo.FullRepoName,
IsDraft: isDraft,
Labels: labels,
}

didCreate, err := gh.CreatePullRequest(createPrActivity.Writer(), repoDirPath, pullRequest)
Expand Down
48 changes: 38 additions & 10 deletions cmd/create_prs/create_prs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ func TestItLogsCreatePrErrorsButContinuesToTryAll(t *testing.T) {
assert.Contains(t, out, "2 errored")

fakeGitHub.AssertCalledWith(t, [][]string{
{"create_pull_request", "work/org/repo1", "PR title"},
{"create_pull_request", "work/org/repo2", "PR title"},
{"create_pull_request", "work/org/repo1", "PR title", "turbolift"},
{"create_pull_request", "work/org/repo2", "PR title", "turbolift"},
})
}

Expand All @@ -150,8 +150,8 @@ func TestItLogsCreatePrSkippedButContinuesToTryAll(t *testing.T) {
assert.Contains(t, out, "0 OK, 2 skipped")

fakeGitHub.AssertCalledWith(t, [][]string{
{"create_pull_request", "work/org/repo1", "PR title"},
{"create_pull_request", "work/org/repo2", "PR title"},
{"create_pull_request", "work/org/repo1", "PR title", "turbolift"},
{"create_pull_request", "work/org/repo2", "PR title", "turbolift"},
})
}

Expand All @@ -169,8 +169,8 @@ func TestItLogsCreatePrsSucceeds(t *testing.T) {
assert.Contains(t, out, "2 OK, 0 skipped")

fakeGitHub.AssertCalledWith(t, [][]string{
{"create_pull_request", "work/org/repo1", "PR title"},
{"create_pull_request", "work/org/repo2", "PR title"},
{"create_pull_request", "work/org/repo1", "PR title", "turbolift"},
{"create_pull_request", "work/org/repo2", "PR title", "turbolift"},
})
}

Expand All @@ -190,8 +190,8 @@ func TestItLogsCreateDraftPr(t *testing.T) {
assert.Contains(t, out, "2 OK, 0 skipped")

fakeGitHub.AssertCalledWith(t, [][]string{
{"create_pull_request", "work/org/repo1", "PR title"},
{"create_pull_request", "work/org/repo2", "PR title"},
{"create_pull_request", "work/org/repo1", "PR title", "turbolift"},
{"create_pull_request", "work/org/repo2", "PR title", "turbolift"},
})
}

Expand All @@ -215,8 +215,27 @@ func TestItCreatesPrsFromAlternativeDescriptionFile(t *testing.T) {
assert.Contains(t, out, "2 OK, 0 skipped")

fakeGitHub.AssertCalledWith(t, [][]string{
{"create_pull_request", "work/org/repo1", "custom PR title"},
{"create_pull_request", "work/org/repo2", "custom PR title"},
{"create_pull_request", "work/org/repo1", "custom PR title", "turbolift"},
{"create_pull_request", "work/org/repo2", "custom PR title", "turbolift"},
})
}

func TestItCanSkipApplyingLabels(t *testing.T) {
fakeGitHub := github.NewAlwaysSucceedsFakeGitHub()
gh = fakeGitHub
fakeGit := git.NewAlwaysSucceedsFakeGit()
g = fakeGit

testsupport.PrepareTempCampaign(true, "org/repo1", "org/repo2")

out, err := runCommandNoLabels()
assert.NoError(t, err)
assert.Contains(t, out, "turbolift create-prs completed")
assert.Contains(t, out, "2 OK, 0 skipped")

fakeGitHub.AssertCalledWith(t, [][]string{
{"create_pull_request", "work/org/repo1", "PR title", ""},
{"create_pull_request", "work/org/repo2", "PR title", ""},
})
}

Expand All @@ -228,6 +247,15 @@ func runCommand() (string, error) {
return outBuffer.String(), err
}

func runCommandNoLabels() (string, error) {
cmd := NewCreatePRsCmd()
cmd.SetArgs([]string{"--no-apply-labels"})
outBuffer := bytes.NewBufferString("")
cmd.SetOut(outBuffer)
err := cmd.Execute()
return outBuffer.String(), err
}

func runCommandWithAlternativeDescriptionFile(fileName string) (string, error) {
cmd := NewCreatePRsCmd()
prDescriptionFile = fileName
Expand Down
4 changes: 3 additions & 1 deletion internal/github/fake_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package github
import (
"errors"
"io"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -42,7 +43,8 @@ type FakeGitHub struct {
}

func (f *FakeGitHub) CreatePullRequest(_ io.Writer, workingDir string, metadata PullRequest) (didCreate bool, err error) {
args := []string{"create_pull_request", workingDir, metadata.Title}
labelList := strings.Join(metadata.Labels, ",")
args := []string{"create_pull_request", workingDir, metadata.Title, labelList}
f.calls = append(f.calls, args)
return f.handler(CreatePullRequest, args)
}
Expand Down
7 changes: 7 additions & 0 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ import (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

funny, github doesn't show any syntax highlighting on this one?

var execInstance executor.Executor = executor.NewRealExecutor()

const TurboliftLabel = "turbolift"

type PullRequest struct {
Title string
Body string
UpstreamRepo string
IsDraft bool
Labels []string
ReviewDecision string
}

Expand Down Expand Up @@ -60,6 +63,10 @@ func (r *RealGitHub) CreatePullRequest(output io.Writer, workingDir string, pr P
pr.UpstreamRepo,
}

for _, label := range pr.Labels {
gh_args = append(gh_args, "--label", label)
}

if pr.IsDraft {
gh_args = append(gh_args, "--draft")
}
Expand Down
32 changes: 29 additions & 3 deletions internal/github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func TestItReturnsErrorOnFailedCreatePr(t *testing.T) {
assert.False(t, didCreatePr)

fakeExecutor.AssertCalledWith(t, [][]string{
{"work/org/repo1", "gh", "pr", "create", "--title", "some title", "--body", "some body", "--repo", "org/repo1"},
{"work/org/repo1", "gh", "pr", "create", "--title", "some title", "--body", "some body", "--repo", "org/repo1", "--label", "turbolift"},
})
}

Expand All @@ -99,7 +99,7 @@ func TestItReturnsFalseAndNilErrorOnNoOpCreatePr(t *testing.T) {
assert.False(t, didCreatePr)

fakeExecutor.AssertCalledWith(t, [][]string{
{"work/org/repo1", "gh", "pr", "create", "--title", "some title", "--body", "some body", "--repo", "org/repo1"},
{"work/org/repo1", "gh", "pr", "create", "--title", "some title", "--body", "some body", "--repo", "org/repo1", "--label", "turbolift"},
})
}

Expand All @@ -112,7 +112,7 @@ func TestItSuccessfulCreatesADraftPr(t *testing.T) {
assert.True(t, didCreatePr)

fakeExecutor.AssertCalledWith(t, [][]string{
{"work/org/repo1", "gh", "pr", "create", "--title", "some title", "--body", "some body", "--repo", "org/repo1", "--draft"},
{"work/org/repo1", "gh", "pr", "create", "--title", "some title", "--body", "some body", "--repo", "org/repo1", "--label", "turbolift", "--draft"},
})
}

Expand All @@ -124,6 +124,19 @@ func TestItReturnsTrueAndNilErrorOnSuccessfulCreatePr(t *testing.T) {
assert.NoError(t, err)
assert.True(t, didCreatePr)

fakeExecutor.AssertCalledWith(t, [][]string{
{"work/org/repo1", "gh", "pr", "create", "--title", "some title", "--body", "some body", "--repo", "org/repo1", "--label", "turbolift"},
})
}

func TestItCreatesPrWithoutLabelsWhenNoneProvided(t *testing.T) {
fakeExecutor := executor.NewAlwaysSucceedsFakeExecutor()
execInstance = fakeExecutor

didCreatePr, _, err := runCreatePrWithoutLabelsAndCaptureOutput()
assert.NoError(t, err)
assert.True(t, didCreatePr)

fakeExecutor.AssertCalledWith(t, [][]string{
{"work/org/repo1", "gh", "pr", "create", "--title", "some title", "--body", "some body", "--repo", "org/repo1"},
})
Expand Down Expand Up @@ -197,6 +210,7 @@ func runCreatePrAndCaptureOutput() (bool, string, error) {
Title: "some title",
Body: "some body",
UpstreamRepo: "org/repo1",
Labels: []string{TurboliftLabel},
})

return didCreatePr, sb.String(), err
Expand All @@ -209,6 +223,18 @@ func runCreateDraftPrAndCaptureOutput() (bool, string, error) {
Body: "some body",
UpstreamRepo: "org/repo1",
IsDraft: true,
Labels: []string{TurboliftLabel},
})

return didCreatePr, sb.String(), err
}

func runCreatePrWithoutLabelsAndCaptureOutput() (bool, string, error) {
sb := strings.Builder{}
didCreatePr, err := NewRealGitHub().CreatePullRequest(&sb, "work/org/repo1", PullRequest{
Title: "some title",
Body: "some body",
UpstreamRepo: "org/repo1",
})

return didCreatePr, sb.String(), err
Expand Down
Loading