From 8837f51539f468cc0d91187410e7763326271b06 Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Mon, 4 May 2026 14:08:27 -0400 Subject: [PATCH 1/5] expand --help usage instructions --- git-open-pull.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/git-open-pull.go b/git-open-pull.go index 32cdb13..008c73e 100644 --- a/git-open-pull.go +++ b/git-open-pull.go @@ -74,6 +74,13 @@ func GetIssueNumber(ctx context.Context, client *github.Client, settings *Settin } func main() { + flag.Usage = func() { + fmt.Fprintln(flag.CommandLine.Output(), "git-open-pull opens or creates an issue, renames the local branch to include that issue number, pushes the renamed branch, and converts the issue into a pull request.") + fmt.Fprintln(flag.CommandLine.Output()) + fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + } + description := flag.String("description-file", "", "Path to PR description file") labels := flag.String("labels", "", "Comma separated PR Labels") title := flag.String("title", "", "PR Title") From 55f5f8f82ffdf69768c7c93c345fb4a2c08d3cf0 Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Mon, 4 May 2026 14:30:25 -0400 Subject: [PATCH 2/5] add --list-labels command; relevant docstring --- git-open-pull.go | 46 +++++++++++++++++++++++++++++++++++----------- go.mod | 3 ++- go.sum | 2 ++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/git-open-pull.go b/git-open-pull.go index 008c73e..e51a6dd 100644 --- a/git-open-pull.go +++ b/git-open-pull.go @@ -13,6 +13,7 @@ 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" ) @@ -73,15 +74,31 @@ 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 or creates an issue, renames the local branch to include that issue number, pushes the renamed branch, and converts the issue into a pull request.") + if settings != nil { + 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) + } + fmt.Fprintf(out, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() +} + func main() { + ctx := context.Background() + settings, settingsErr := LoadSettings(ctx) + flag.Usage = func() { - fmt.Fprintln(flag.CommandLine.Output(), "git-open-pull opens or creates an issue, renames the local branch to include that issue number, pushes the renamed branch, and converts the issue into a pull request.") - fmt.Fprintln(flag.CommandLine.Output()) - fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) - flag.PrintDefaults() + printUsage(settings) } description := flag.String("description-file", "", "Path to PR description file") + listLabels := flag.Bool("list-labels", false, "List available issue labels and exit") labels := flag.String("labels", "", "Comma separated PR Labels") title := flag.String("title", "", "PR Title") interactive := flag.Bool("interactive", true, "Toggles interactive mode") @@ -95,12 +112,21 @@ func main() { os.Exit(0) } - ctx := context.Background() + if settingsErr != nil { + log.Fatal(settingsErr) + } - // Load and initialize settings - settings, err := LoadSettings(ctx) - if err != nil { - log.Fatal(err) + 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 @@ -141,8 +167,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 { 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= From 239c544f11f7c598c42d4fa47105774d67eb9769 Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Mon, 4 May 2026 16:22:45 -0400 Subject: [PATCH 3/5] better detect default branch (main|master) --- README.md | 2 +- git-open-pull.go | 36 ++++++++++++++++++++---- settings.go | 71 ++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 92 insertions(+), 17 deletions(-) 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/git-open-pull.go b/git-open-pull.go index e51a6dd..9b6e9a3 100644 --- a/git-open-pull.go +++ b/git-open-pull.go @@ -77,12 +77,22 @@ func GetIssueNumber(ctx context.Context, client *github.Client, settings *Settin func printUsage(settings *Settings) { out := flag.CommandLine.Output() fmt.Fprintln(out, "git-open-pull opens or creates an issue, renames the local branch to include that issue number, pushes the renamed branch, and converts the issue into a pull request.") - if settings != nil { + 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.") + 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]) @@ -91,10 +101,10 @@ func printUsage(settings *Settings) { func main() { ctx := context.Background() - settings, settingsErr := LoadSettings(ctx) + preSettings, _ := readSettingsConfig(ctx) flag.Usage = func() { - printUsage(settings) + printUsage(preSettings) } description := flag.String("description-file", "", "Path to PR description file") @@ -112,8 +122,24 @@ func main() { os.Exit(0) } - if settingsErr != nil { - log.Fatal(settingsErr) + 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) 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 +} From 80b243fb27fc3a08ec2ec26e8b2b4da413e25f55 Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Tue, 5 May 2026 13:32:27 -0400 Subject: [PATCH 4/5] git-open-pull change default to draft=true --- git-open-pull.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/git-open-pull.go b/git-open-pull.go index 9b6e9a3..2284f95 100644 --- a/git-open-pull.go +++ b/git-open-pull.go @@ -76,7 +76,8 @@ func GetIssueNumber(ctx context.Context, client *github.Client, settings *Settin func printUsage(settings *Settings) { out := flag.CommandLine.Output() - fmt.Fprintln(out, "git-open-pull opens or creates an issue, renames the local branch to include that issue number, pushes the renamed branch, and converts the issue into a pull request.") + 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) } @@ -113,7 +114,7 @@ func main() { 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() @@ -283,13 +284,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 } } From 7b2c949b6f82eaeb78325696a8bb7ca0b47ce8f2 Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Tue, 5 May 2026 13:41:17 -0400 Subject: [PATCH 5/5] add /git-open-pull skill --- SKILL.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++++ git-open-pull.go | 11 ++++++ 2 files changed, 110 insertions(+) create mode 100644 SKILL.md 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 2284f95..0d16f28 100644 --- a/git-open-pull.go +++ b/git-open-pull.go @@ -2,6 +2,7 @@ package main import ( "context" + _ "embed" "flag" "fmt" "log" @@ -18,6 +19,9 @@ import ( "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) @@ -85,6 +89,7 @@ func printUsage(settings *Settings) { 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) @@ -110,6 +115,7 @@ func main() { 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") @@ -123,6 +129,11 @@ func main() { os.Exit(0) } + if *skill { + fmt.Print(skillDoc) + return + } + var settings *Settings var err error if *interactive {