diff --git a/README.md b/README.md index 63be3fd..ba7e8c7 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Hooks. git-open-pull provides the ability to modify an issue template (preProces git-open-pull will also look for the following environment variables, which will take precendence over values found in the config file. ``` -GITOPENPULL_TOKEN +GITOPENPULL_TOKEN || GITHUB_TOKEN GITOPENPULL_USER GITOPENPULL_BASE_ACCOUNT GITOPENPULL_BASE_REPO diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..7909e95 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,99 @@ +--- +name: git-open-pull +description: "Create a GitHub Pull Request using git-open-pull. Use when: opening a PR, submitting code for review, creating a draft PR, publishing a branch as a pull request. git-open-pull creates an issue, renames the branch to include the issue number, pushes the branch, and converts the issue to a PR." +argument-hint: "Optionally specify a title, labels, description file, or whether to create as a draft" +--- + +# git-open-pull Skill + +`git-open-pull` converts the current git branch into a GitHub pull request by: +1. Opening or creating a GitHub issue (with title, description, and labels) +2. Renaming the local branch to embed the issue number (e.g. `my-feature` → `my-feature_42`) +3. Pushing the renamed branch and converting the issue into a pull request + +Use `git-open-pull` instead of `gh pr create` when working in a repository that tracks work via GitHub Issues and encodes the issue number in the branch name. + +## When to Use + +- The user wants to open a PR for their current branch +- The user has finished a feature or fix and wants to submit it for review +- The user wants to create a draft PR to share work in progress +- The repository uses the convention of embedding issue numbers in branch names + +## Procedure + +### 1. Check Configuration + +Run `git-open-pull --help` first. If required configuration is missing, the help output will list any pre-req information needed (e.g. GitHub username, token, destination account/repo). Set these values via `git config` or environment variables as described in the help output before proceeding. + +### 2. Check for Uncommitted Changes + +Before running, ensure all changes are committed. `git-open-pull` does not commit changes — it only renames the branch, pushes it, and opens the PR. + +### 3. Do Not Push the Branch First + +**Do not run `git push` before `git-open-pull`.** The tool renames the local branch to include the issue number before pushing. If you push manually beforehand the remote branch name will not match and the tool will fail. + +### 4. Inspect Labels (if using --labels) + +Run `git-open-pull --list-labels` to see the exact label names valid for this repository before passing `--labels`. + +### 5. Prepare PR Details + +Write a good title and description: + +**Title**: Use imperative mood, keep it under 72 characters, and describe *what* the PR does (e.g. `Add retry logic for failed API requests`). + +**Description file**: Write to a temp file and pass via `--description-file`. Include: +- A short summary of what changed and why +- Any relevant issue references (e.g. `Closes #123` — note: `git-open-pull` converts the issue to a PR, so the issue number is already linked) +- Notable implementation decisions useful for the reviewer + +### 6. Run git-open-pull + +```sh +git-open-pull \ + --interactive=false \ + --title "Fix null pointer in login flow" \ + --description-file /tmp/pr-description.txt \ + --labels bug \ + --draft +``` + +### 7. Confirm Result + +On success the tool prints the GitHub issue/PR URL: +``` +https://github.com/owner/repo/issues/42 +``` + +Report this URL to the user. + +## Flags + +| Flag | Description | +|------|-------------| +| `--title` | PR / issue title (required with `--interactive=false`) | +| `--description-file` | Path to a file whose contents become the PR description | +| `--labels` | Comma-separated label names (use `--list-labels` to enumerate valid values) | +| `--draft` | Open the PR in draft mode (default: true) | +| `--interactive` | Set to `false` for non-interactive/agent use | +| `--list-labels` | Print all repository labels and exit | +| `--skill` | Print this skill document and exit | +| `--version` | Print the version and exit | + +## Best Practices + +### Titles +- Use the imperative mood: `Fix`, `Add`, `Update`, `Remove`, `Refactor` — not `Fixed`, `Adding`, etc. +- Be specific: `Fix null pointer in user login flow` beats `Fix bug`. +- Keep it under 72 characters so it displays cleanly in GitHub and email notifications. + +### Descriptions +- Start with a one-sentence summary. +- Explain *why* the change is needed, not just *what* it does — reviewers benefit from context. +- If the change is large, add a brief list of the main files or components touched. + +### Draft PRs +- Use `--draft` when the code is not yet ready for formal review (e.g. work in progress, awaiting feedback on approach, CI not yet passing). +- Draft PRs are visible to collaborators but will not show as review-requested until marked ready. diff --git a/git-open-pull.go b/git-open-pull.go index 32cdb13..0d16f28 100644 --- a/git-open-pull.go +++ b/git-open-pull.go @@ -2,6 +2,7 @@ package main import ( "context" + _ "embed" "flag" "fmt" "log" @@ -13,10 +14,14 @@ import ( "time" "github.com/google/go-github/v60/github" + "github.com/jehiah/agentdetection" "github.com/jehiah/git-open-pull/internal/input" "golang.org/x/oauth2" ) +//go:embed SKILL.md +var skillDoc string + func RenameBranch(ctx context.Context, branch string, issueNumber int) (string, error) { branch = fmt.Sprintf("%s_%d", branch, issueNumber) _, err := RunGit(ctx, "branch", "-m", branch) @@ -73,13 +78,49 @@ func GetIssueNumber(ctx context.Context, client *github.Client, settings *Settin return detected, nil } +func printUsage(settings *Settings) { + out := flag.CommandLine.Output() + fmt.Fprintln(out, "git-open-pull opens a or creates an issue, renames the local branch to include that issue number, pushes the renamed branch and finally converts the issue into a pull request.") + fmt.Fprintln(out, "Equivalent to 'gh pr create'.") + if settings != nil && settings.User != "" && settings.BaseAccount != "" && settings.BaseRepo != "" { + fmt.Fprintf(out, "By default, code is pushed to %s/%s and the pull request targets %s/%s branch %s.\n", settings.User, settings.BaseRepo, settings.BaseAccount, settings.BaseRepo, settings.BaseBranch) + } + fmt.Fprintln(out) + if agentdetection.IsAgent() { + fmt.Fprintln(out, "Hint: run --list-labels first to inspect the valid repository labels before passing --labels.") + fmt.Fprintln(out, "Do not push code prior to running this command, as the branch may be renamed to include the issue number.") + fmt.Fprintln(out, "Run --skill for full agent usage documentation, or redirect to a file: git-open-pull --skill > SKILL.md") + if settings != nil { + if hints := settings.RequiredHints(); len(hints) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Required configuration missing:") + for _, h := range hints { + fmt.Fprintf(out, " - %s\n", h) + } + } + } + fmt.Fprintln(out) + } + fmt.Fprintf(out, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() +} + func main() { + ctx := context.Background() + preSettings, _ := readSettingsConfig(ctx) + + flag.Usage = func() { + printUsage(preSettings) + } + description := flag.String("description-file", "", "Path to PR description file") + listLabels := flag.Bool("list-labels", false, "List available issue labels and exit") + skill := flag.Bool("skill", false, "Print agent skill documentation and exit") labels := flag.String("labels", "", "Comma separated PR Labels") title := flag.String("title", "", "PR Title") interactive := flag.Bool("interactive", true, "Toggles interactive mode") version := flag.Bool("version", false, "Prints current version") - draft := flag.Bool("draft", false, "Open PR in draft mode") + draft := flag.Bool("draft", true, "Open PR in draft mode") flag.Parse() @@ -88,12 +129,42 @@ func main() { os.Exit(0) } - ctx := context.Background() + if *skill { + fmt.Print(skillDoc) + return + } - // Load and initialize settings - settings, err := LoadSettings(ctx) - if err != nil { - log.Fatal(err) + var settings *Settings + var err error + if *interactive { + settings, err = LoadSettings(ctx) + if err != nil { + log.Fatal(err) + } + } else { + settings, err = readSettingsConfig(ctx) + if err != nil { + log.Fatal(err) + } + if hints := settings.RequiredHints(); len(hints) > 0 { + for _, h := range hints { + fmt.Fprintln(os.Stderr, h) + } + os.Exit(1) + } + } + + client := SetupClient(ctx, settings) + + if *listLabels { + labels, err := Labels(ctx, client, settings) + if err != nil { + log.Fatal(err) + } + for _, label := range labels { + fmt.Println(label) + } + return } var labelSlice []string @@ -134,8 +205,6 @@ func main() { } detectedIssueNumber := DetectIssueNumber(branch) - client := SetupClient(ctx, settings) - // create issue if needed issueNumber, err := GetIssueNumber(ctx, client, settings, detectedIssueNumber, *interactive, *title, descriptionString, labelSlice) if err != nil { @@ -226,13 +295,18 @@ func main() { if strings.ToLower(yn) != "y" { log.Fatal("exiting") } - yn, err = input.Ask("Open as draft [Y/n]", "") + } + + if *interactive { + yn, err := input.Ask("Open as draft [Y/n]", "") if err != nil { log.Fatal(err) } switch yn { case "", "Y", "y": *draft = true + case "n", "N": + *draft = false } } diff --git a/go.mod b/go.mod index 9d8b4aa..ade1066 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module github.com/jehiah/git-open-pull -go 1.23.0 +go 1.26.2 require ( github.com/google/go-github/v60 v60.0.0 + github.com/jehiah/agentdetection v0.0.0-20260504175341-4747d1e40782 golang.org/x/oauth2 v0.30.0 ) diff --git a/go.sum b/go.sum index cc4936a..f982abf 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCy github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/jehiah/agentdetection v0.0.0-20260504175341-4747d1e40782 h1:EV4iRaivD9whutiTtYjj3EAYAyojBPMXdskIcEpKeYk= +github.com/jehiah/agentdetection v0.0.0-20260504175341-4747d1e40782/go.mod h1:Mc3j1GxXS1r1HJ42lBPejePFX5Xjz+5mVo/7DYXyhDc= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/settings.go b/settings.go index 4124a70..beb9934 100644 --- a/settings.go +++ b/settings.go @@ -38,11 +38,17 @@ type Settings struct { PostProcess string // callback is called after a PR is created. It's first argument is a filename that contains the PR json Callback string + + // inferred from remote URLs if gitOpenPull.baseRepo is not set + DefaultBaseRepo string } // this function tries to get settings from the environment variables func GetEnvSettings(s *Settings) error { token := os.Getenv("GITOPENPULL_TOKEN") + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } if token != "" { s.Token = token } @@ -93,18 +99,32 @@ func GetEnvSettings(s *Settings) error { return nil } -// LoadSettings extracts the git and gitOpenPull sections from $HOME/.gitconfig and .git/config -func LoadSettings(ctx context.Context) (*Settings, error) { +func detectDefaultBaseBranch(ctx context.Context) string { + // Check for local branch names 'main' or 'master' + if _, err := RunGit(ctx, "show-ref", "--verify", "--quiet", "refs/heads/main"); err == nil { + return "main" + } + if _, err := RunGit(ctx, "show-ref", "--verify", "--quiet", "refs/heads/master"); err == nil { + return "master" + } + + // Final fallback preserves previous behavior. + return "master" +} +// readSettingsConfig reads settings from git config and environment variables without prompting. +// It returns the settings (possibly with empty required fields) and any hard error. +// Settings.DefaultBaseRepo is set if a base repo can be inferred from remote URLs. +func readSettingsConfig(ctx context.Context) (*Settings, error) { body, err := RunGit(ctx, "config", "--list") if err != nil { return nil, err } s := Settings{ - BaseBranch: "master", + BaseBranch: detectDefaultBaseBranch(ctx), Editor: "/usr/bin/vi", } - var defaultBaseRepo, maintainersCanModify string + var maintainersCanModify string scanner := bufio.NewScanner(bytes.NewBuffer(body)) for scanner.Scan() { line := strings.SplitN(strings.TrimSpace(scanner.Text()), "=", 2) @@ -137,10 +157,10 @@ func LoadSettings(ctx context.Context) (*Settings, error) { case "core.editor": s.Editor = line[1] default: - if strings.HasSuffix(line[0], ".url") && strings.HasSuffix(line[1], ".git") && defaultBaseRepo == "" { + if strings.HasSuffix(line[0], ".url") && strings.HasSuffix(line[1], ".git") && s.DefaultBaseRepo == "" { chunks := strings.Split(line[1], "/") - defaultBaseRepo = chunks[len(chunks)-1] - defaultBaseRepo = defaultBaseRepo[:len(defaultBaseRepo)-4] + s.DefaultBaseRepo = chunks[len(chunks)-1] + s.DefaultBaseRepo = s.DefaultBaseRepo[:len(s.DefaultBaseRepo)-4] } } } @@ -150,11 +170,20 @@ func LoadSettings(ctx context.Context) (*Settings, error) { if err := scanner.Err(); err != nil { return nil, err } - err = GetEnvSettings(&s) if err != nil { return nil, err } + return &s, nil +} + +// LoadSettings extracts settings from $HOME/.gitconfig and .git/config, prompting +// interactively for any missing required values and persisting them to git config. +func LoadSettings(ctx context.Context) (*Settings, error) { + s, err := readSettingsConfig(ctx) + if err != nil { + return nil, err + } // https://github.com/settings/tokens if s.User == "" { @@ -186,7 +215,7 @@ func LoadSettings(ctx context.Context) (*Settings, error) { } if s.BaseRepo == "" { - s.BaseRepo, err = input.Ask(fmt.Sprintf("GitHub repository name (ie: github.com/%s/___)", s.BaseAccount), defaultBaseRepo) + s.BaseRepo, err = input.Ask(fmt.Sprintf("GitHub repository name (ie: github.com/%s/___)", s.BaseAccount), s.DefaultBaseRepo) if err != nil { return nil, err } @@ -200,7 +229,7 @@ func LoadSettings(ctx context.Context) (*Settings, error) { } if s.Token == "" { - s.Token, err = input.Ask("Github access token (You can genrate a token from https://github.com/settings/tokens)", "") + s.Token, err = input.Ask("Github access token (You can generate a token from https://github.com/settings/tokens)", "") if err != nil { return nil, err } @@ -213,6 +242,26 @@ func LoadSettings(ctx context.Context) (*Settings, error) { } } - return &s, nil + return s, nil } + +// RequiredHints returns a list of human-readable instructions for each required +// setting that is currently missing. An empty slice means all required settings +// are present. +func (s Settings) RequiredHints() []string { + var hints []string + if s.User == "" { + hints = append(hints, "GitHub username required. Set `git config --global github.user $USER`") + } + if s.BaseAccount == "" { + hints = append(hints, "Destination GitHub username required. Set `git config gitOpenPull.baseAccount $ACCOUNT`") + } + if s.BaseRepo == "" { + hints = append(hints, "GitHub repository name required. Set `git config gitOpenPull.baseRepo $PROJECT`") + } + if s.Token == "" { + hints = append(hints, "GitHub token required. Set `git config --global gitOpenPull.token $TOKEN` or set GITHUB_TOKEN env variable") + } + return hints +}