diff --git a/README.md b/README.md index 14230cb..49a1a32 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - Pull request management (create, list, view, merge) - Issue tracking (create, list, view, comment, close) - Repository operations (view, list, clone, fork) +- Automatic repository detection from git context - Secure authentication with personal access tokens - AI coding agent friendly @@ -56,39 +57,68 @@ fgj auth status ## Usage +### Repository Detection + +`fgj` automatically detects the repository from your git context, similar to `gh`: + +```bash +# When inside a git repository, no -R flag needed! +cd /path/to/your/repo +fgj pr list # Automatically uses current repo +fgj issue list # Automatically uses current repo +fgj pr view 123 # Automatically uses current repo + +# Or explicitly specify a repository with -R +fgj pr list -R owner/repo +``` + +The tool reads `.git/config` to find the origin remote and extract the owner/repo information. If you're not in a git repository, you'll need to use the `-R` flag. + ### Pull Requests ```bash -# List pull requests +# List pull requests (auto-detects repo from git) +fgj pr list + +# Or specify explicitly fgj pr list -R owner/repo +# Filter by state +fgj pr list --state closed + # View a specific pull request -fgj pr view 123 -R owner/repo +fgj pr view 123 # Create a pull request -fgj pr create -R owner/repo -t "PR Title" -b "PR Description" -H feature-branch -B main +fgj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main # Merge a pull request -fgj pr merge 123 -R owner/repo --merge-method squash +fgj pr merge 123 --merge-method squash ``` ### Issues ```bash -# List issues +# List issues (auto-detects repo from git) +fgj issue list + +# Or specify explicitly fgj issue list -R owner/repo +# Filter by state +fgj issue list --state all + # View an issue -fgj issue view 456 -R owner/repo +fgj issue view 456 # Create an issue -fgj issue create -R owner/repo -t "Issue Title" -b "Issue Description" +fgj issue create -t "Issue Title" -b "Issue Description" # Comment on an issue -fgj issue comment 456 -R owner/repo -b "My comment" +fgj issue comment 456 -b "My comment" # Close an issue -fgj issue close 456 -R owner/repo +fgj issue close 456 ``` ### Repositories @@ -194,7 +224,8 @@ fgj/ │ └── repo.go # Repository commands ├── internal/ │ ├── api/ # API client wrapper -│ └── config/ # Configuration management +│ ├── config/ # Configuration management +│ └── git/ # Git repository detection ├── main.go # Entry point └── go.mod # Dependencies ``` diff --git a/cmd/pr.go b/cmd/pr.go index 8091937..e6d75b2 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "codeberg.org/romaintb/fgj/internal/git" ) var prCmd = &cobra.Command{ @@ -264,14 +265,20 @@ func runPRMerge(cmd *cobra.Command, args []string) error { } func parseRepo(repo string) (string, string, error) { - if repo == "" { - return "", "", fmt.Errorf("repository flag is required (use -R owner/name)") + // If repo flag is provided, use it + if repo != "" { + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid repository format: %s (expected: owner/name)", repo) + } + return parts[0], parts[1], nil } - parts := strings.Split(repo, "/") - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid repository format: %s (expected: owner/name)", repo) + // Try to auto-detect from git + owner, name, err := git.DetectRepo() + if err != nil { + return "", "", fmt.Errorf("repository flag is required (use -R owner/name) or run from a git repository: %w", err) } - return parts[0], parts[1], nil + return owner, name, nil } diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..1f0282b --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,125 @@ +package git + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// DetectRepo attempts to detect the repository owner/name from the current git directory. +// It reads .git/config and parses the origin remote URL. +func DetectRepo() (owner, name string, err error) { + // Find .git/config file + gitConfigPath, err := findGitConfig() + if err != nil { + return "", "", err + } + + // Parse .git/config + remoteURL, err := parseGitConfig(gitConfigPath) + if err != nil { + return "", "", err + } + + // Extract owner/name from URL + return parseRemoteURL(remoteURL) +} + +// findGitConfig searches for .git/config starting from the current directory +func findGitConfig() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + dir := cwd + for { + gitConfigPath := filepath.Join(dir, ".git", "config") + if _, err := os.Stat(gitConfigPath); err == nil { + return gitConfigPath, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + // Reached root directory + return "", fmt.Errorf("not in a git repository (or any parent up to mount point)") + } + dir = parent + } +} + +// parseGitConfig reads .git/config and extracts the origin remote URL +func parseGitConfig(configPath string) (string, error) { + file, err := os.Open(configPath) + if err != nil { + return "", fmt.Errorf("failed to open git config: %w", err) + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + inOriginSection := false + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Check if we're entering the [remote "origin"] section + if strings.HasPrefix(line, "[remote") && strings.Contains(line, "origin") { + inOriginSection = true + continue + } + + // Check if we're leaving the section + if inOriginSection && strings.HasPrefix(line, "[") { + inOriginSection = false + continue + } + + // Extract URL from the origin section + if inOriginSection && strings.HasPrefix(line, "url") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]), nil + } + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error reading git config: %w", err) + } + + return "", fmt.Errorf("no origin remote found in git config") +} + +// parseRemoteURL extracts owner/name from various git URL formats: +// - https://codeberg.org/owner/name.git +// - git@codeberg.org:owner/name.git +// - ssh://git@codeberg.org/owner/name.git +func parseRemoteURL(url string) (owner, name string, err error) { + url = strings.TrimSpace(url) + + // Remove .git suffix + url = strings.TrimSuffix(url, ".git") + + // Pattern for HTTPS URLs: https://host/owner/name + httpsRegex := regexp.MustCompile(`https?://[^/]+/([^/]+)/([^/]+)`) + if matches := httpsRegex.FindStringSubmatch(url); len(matches) == 3 { + return matches[1], matches[2], nil + } + + // Pattern for SSH URLs: git@host:owner/name + sshRegex := regexp.MustCompile(`git@[^:]+:([^/]+)/(.+)`) + if matches := sshRegex.FindStringSubmatch(url); len(matches) == 3 { + return matches[1], matches[2], nil + } + + // Pattern for SSH URLs with protocol: ssh://git@host/owner/name + sshProtocolRegex := regexp.MustCompile(`ssh://git@[^/]+/([^/]+)/(.+)`) + if matches := sshProtocolRegex.FindStringSubmatch(url); len(matches) == 3 { + return matches[1], matches[2], nil + } + + return "", "", fmt.Errorf("unable to parse repository from URL: %s", url) +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..645fb89 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,79 @@ +package git + +import "testing" + +func TestParseRemoteURL(t *testing.T) { + tests := []struct { + name string + url string + wantOwner string + wantName string + wantErr bool + }{ + { + name: "HTTPS URL with .git", + url: "https://codeberg.org/romaintb/fgj.git", + wantOwner: "romaintb", + wantName: "fgj", + wantErr: false, + }, + { + name: "HTTPS URL without .git", + url: "https://codeberg.org/romaintb/fgj", + wantOwner: "romaintb", + wantName: "fgj", + wantErr: false, + }, + { + name: "SSH URL with .git", + url: "git@codeberg.org:romaintb/fgj.git", + wantOwner: "romaintb", + wantName: "fgj", + wantErr: false, + }, + { + name: "SSH URL without .git", + url: "git@codeberg.org:romaintb/fgj", + wantOwner: "romaintb", + wantName: "fgj", + wantErr: false, + }, + { + name: "SSH protocol URL", + url: "ssh://git@codeberg.org/romaintb/fgj.git", + wantOwner: "romaintb", + wantName: "fgj", + wantErr: false, + }, + { + name: "GitHub HTTPS URL", + url: "https://github.com/user/repo.git", + wantOwner: "user", + wantName: "repo", + wantErr: false, + }, + { + name: "Invalid URL", + url: "invalid-url", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, name, err := parseRemoteURL(tt.url) + if (err != nil) != tt.wantErr { + t.Errorf("parseRemoteURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if owner != tt.wantOwner { + t.Errorf("parseRemoteURL() owner = %v, want %v", owner, tt.wantOwner) + } + if name != tt.wantName { + t.Errorf("parseRemoteURL() name = %v, want %v", name, tt.wantName) + } + } + }) + } +}