From 5b67d39aba91206a6533fb49cad904c3fd9fd97f Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Mon, 8 Dec 2025 09:49:07 +0100 Subject: [PATCH] feat: initial version of the project --- .gitignore | 30 ++++ LICENSE | 21 +++ README.md | 214 +++++++++++++++++++++++++++ cmd/auth.go | 123 ++++++++++++++++ cmd/issue.go | 301 ++++++++++++++++++++++++++++++++++++++ cmd/pr.go | 277 +++++++++++++++++++++++++++++++++++ cmd/repo.go | 212 +++++++++++++++++++++++++++ cmd/root.go | 57 ++++++++ go.mod | 37 +++++ go.sum | 101 +++++++++++++ internal/api/client.go | 40 +++++ internal/config/config.go | 110 ++++++++++++++ main.go | 15 ++ 13 files changed, 1538 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/auth.go create mode 100644 cmd/issue.go create mode 100644 cmd/pr.go create mode 100644 cmd/repo.go create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/client.go create mode 100644 internal/config/config.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f46cde4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Binaries +fgj +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Config (contains tokens) +config.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..915d870 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Romain TB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..14230cb --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +# fgj - Forgejo CLI Tool + +`fgj` is a command-line tool for working with Forgejo instances (including Codeberg.org). It brings pull requests, issues, and other Forgejo concepts to the terminal, similar to what `gh` does for GitHub. + +## Features + +- Multi-instance support (works with any Forgejo instance) +- Pull request management (create, list, view, merge) +- Issue tracking (create, list, view, comment, close) +- Repository operations (view, list, clone, fork) +- Secure authentication with personal access tokens +- AI coding agent friendly + +## Installation + +### From Source + +```bash +git clone https://codeberg.org/romaintb/fgj.git +cd fgj +go build -o fgj +sudo mv fgj /usr/local/bin/ +``` + +### Using Go Install + +```bash +go install codeberg.org/romaintb/fgj@latest +``` + +## Quick Start + +### 1. Authenticate + +First, authenticate with your Forgejo instance: + +```bash +fgj auth login +``` + +You'll be prompted for: +- Forgejo instance hostname (default: codeberg.org) +- Personal access token + +To create a personal access token: +1. Go to your Forgejo instance (e.g., https://codeberg.org) +2. Navigate to Settings > Applications > Generate New Token +3. Give it appropriate permissions (repo, issue, etc.) +4. Copy the token and paste it when prompted + +### 2. Check Authentication Status + +```bash +fgj auth status +``` + +## Usage + +### Pull Requests + +```bash +# List pull requests +fgj pr list -R owner/repo + +# View a specific pull request +fgj pr view 123 -R owner/repo + +# Create a pull request +fgj pr create -R owner/repo -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 +``` + +### Issues + +```bash +# List issues +fgj issue list -R owner/repo + +# View an issue +fgj issue view 456 -R owner/repo + +# Create an issue +fgj issue create -R owner/repo -t "Issue Title" -b "Issue Description" + +# Comment on an issue +fgj issue comment 456 -R owner/repo -b "My comment" + +# Close an issue +fgj issue close 456 -R owner/repo +``` + +### Repositories + +```bash +# View repository details +fgj repo view owner/repo + +# List your repositories +fgj repo list + +# Clone a repository +fgj repo clone owner/repo + +# Clone via SSH +fgj repo clone owner/repo -p ssh + +# Fork a repository +fgj repo fork owner/repo +``` + +## Configuration + +Configuration is stored in `~/.config/fgj/config.yaml`: + +```yaml +hosts: + codeberg.org: + hostname: codeberg.org + token: your_token_here + user: your_username + git_protocol: https + my-forgejo.com: + hostname: my-forgejo.com + token: another_token + user: another_username + git_protocol: ssh +``` + +### Environment Variables + +- `FGJ_HOST`: Override the default Forgejo instance +- `FGJ_TOKEN`: Provide authentication token + +### Command-line Flags + +- `--hostname`: Specify Forgejo instance for a command +- `--config`: Use a custom config file + +## Use with AI Coding Agents + +`fgj` is designed to work seamlessly with AI coding agents like Claude Code. Common patterns: + +```bash +# Create PR from agent's changes +fgj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <", + Short: "View an issue", + Long: "Display detailed information about an issue.", + Args: cobra.ExactArgs(1), + RunE: runIssueView, +} + +var issueCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an issue", + Long: "Create a new issue.", + RunE: runIssueCreate, +} + +var issueCommentCmd = &cobra.Command{ + Use: "comment ", + Short: "Add a comment to an issue", + Long: "Add a comment to an existing issue.", + Args: cobra.ExactArgs(1), + RunE: runIssueComment, +} + +var issueCloseCmd = &cobra.Command{ + Use: "close ", + Short: "Close an issue", + Long: "Close an existing issue.", + Args: cobra.ExactArgs(1), + RunE: runIssueClose, +} + +func init() { + rootCmd.AddCommand(issueCmd) + issueCmd.AddCommand(issueListCmd) + issueCmd.AddCommand(issueViewCmd) + issueCmd.AddCommand(issueCreateCmd) + issueCmd.AddCommand(issueCommentCmd) + issueCmd.AddCommand(issueCloseCmd) + + issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") + + issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + + issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue") + issueCreateCmd.Flags().StringP("body", "b", "", "Body for the issue") + + issueCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueCommentCmd.Flags().StringP("body", "b", "", "Comment body") + + issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") +} + +func runIssueList(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + state, _ := cmd.Flags().GetString("state") + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + var stateType gitea.StateType + switch strings.ToLower(state) { + case "open": + stateType = gitea.StateOpen + case "closed": + stateType = gitea.StateClosed + case "all": + stateType = gitea.StateAll + default: + return fmt.Errorf("invalid state: %s", state) + } + + issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{ + State: stateType, + }) + if err != nil { + return fmt.Errorf("failed to list issues: %w", err) + } + + if len(issues) == 0 { + fmt.Printf("No %s issues in %s/%s\n", state, owner, name) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") + for _, issue := range issues { + if issue.PullRequest == nil { + fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) + } + } + w.Flush() + + return nil +} + +func runIssueView(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + issueNumber, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid issue number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + issue, _, err := client.GetIssue(owner, name, issueNumber) + if err != nil { + return fmt.Errorf("failed to get issue: %w", err) + } + + fmt.Printf("Issue #%d\n", issue.Index) + fmt.Printf("Title: %s\n", issue.Title) + fmt.Printf("State: %s\n", issue.State) + fmt.Printf("Author: %s\n", issue.Poster.UserName) + fmt.Printf("Created: %s\n", issue.Created.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", issue.Updated.Format("2006-01-02 15:04:05")) + if issue.Body != "" { + fmt.Printf("\n%s\n", issue.Body) + } + + comments, _, err := client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{}) + if err == nil && len(comments) > 0 { + fmt.Printf("\nComments (%d):\n", len(comments)) + for _, comment := range comments { + fmt.Printf("\n---\n%s (@%s) - %s\n%s\n", + comment.Poster.FullName, + comment.Poster.UserName, + comment.Created.Format("2006-01-02 15:04:05"), + comment.Body) + } + } + + return nil +} + +func runIssueCreate(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + title, _ := cmd.Flags().GetString("title") + body, _ := cmd.Flags().GetString("body") + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + if title == "" { + return fmt.Errorf("title is required") + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{ + Title: title, + Body: body, + }) + if err != nil { + return fmt.Errorf("failed to create issue: %w", err) + } + + fmt.Printf("Issue created: #%d\n", issue.Index) + fmt.Printf("View at: %s\n", issue.HTMLURL) + + return nil +} + +func runIssueComment(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + body, _ := cmd.Flags().GetString("body") + issueNumber, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid issue number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + if body == "" { + return fmt.Errorf("comment body is required") + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ + Body: body, + }) + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + + fmt.Printf("Comment added to issue #%d\n", issueNumber) + fmt.Printf("View at: %s\n", comment.HTMLURL) + + return nil +} + +func runIssueClose(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + issueNumber, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid issue number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + stateClosed := gitea.StateClosed + _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ + State: &stateClosed, + }) + if err != nil { + return fmt.Errorf("failed to close issue: %w", err) + } + + fmt.Printf("Issue #%d closed\n", issueNumber) + + return nil +} diff --git a/cmd/pr.go b/cmd/pr.go new file mode 100644 index 0000000..cb0071d --- /dev/null +++ b/cmd/pr.go @@ -0,0 +1,277 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + "strings" + "text/tabwriter" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" + "codeberg.org/romaintb/fgj/internal/api" + "codeberg.org/romaintb/fgj/internal/config" +) + +var prCmd = &cobra.Command{ + Use: "pr", + Aliases: []string{"pull-request"}, + Short: "Manage pull requests", + Long: "Create, view, list, and manage pull requests.", +} + +var prListCmd = &cobra.Command{ + Use: "list [flags]", + Short: "List pull requests", + Long: "List pull requests in a repository.", + RunE: runPRList, +} + +var prViewCmd = &cobra.Command{ + Use: "view ", + Short: "View a pull request", + Long: "Display detailed information about a pull request.", + Args: cobra.ExactArgs(1), + RunE: runPRView, +} + +var prCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a pull request", + Long: "Create a new pull request.", + RunE: runPRCreate, +} + +var prMergeCmd = &cobra.Command{ + Use: "merge ", + Short: "Merge a pull request", + Long: "Merge a pull request.", + Args: cobra.ExactArgs(1), + RunE: runPRMerge, +} + +func init() { + rootCmd.AddCommand(prCmd) + prCmd.AddCommand(prListCmd) + prCmd.AddCommand(prViewCmd) + prCmd.AddCommand(prCreateCmd) + prCmd.AddCommand(prMergeCmd) + + prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") + + prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + + prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request") + prCreateCmd.Flags().StringP("body", "b", "", "Body for the pull request") + prCreateCmd.Flags().StringP("head", "H", "", "Head branch") + prCreateCmd.Flags().StringP("base", "B", "", "Base branch (default: main)") + + prMergeCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prMergeCmd.Flags().String("merge-method", "merge", "Merge method: merge, rebase, squash") +} + +func runPRList(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + state, _ := cmd.Flags().GetString("state") + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + var stateType gitea.StateType + switch strings.ToLower(state) { + case "open": + stateType = gitea.StateOpen + case "closed": + stateType = gitea.StateClosed + case "all": + stateType = gitea.StateAll + default: + return fmt.Errorf("invalid state: %s", state) + } + + prs, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ + State: stateType, + }) + if err != nil { + return fmt.Errorf("failed to list pull requests: %w", err) + } + + if len(prs) == 0 { + fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "NUMBER\tTITLE\tBRANCH\tSTATE\n") + for _, pr := range prs { + fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", pr.Index, pr.Title, pr.Head.Ref, pr.State) + } + w.Flush() + + return nil +} + +func runPRView(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + prNumber, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + pr, _, err := client.GetPullRequest(owner, name, prNumber) + if err != nil { + return fmt.Errorf("failed to get pull request: %w", err) + } + + fmt.Printf("Pull Request #%d\n", pr.Index) + fmt.Printf("Title: %s\n", pr.Title) + fmt.Printf("State: %s\n", pr.State) + fmt.Printf("Author: %s\n", pr.Poster.UserName) + fmt.Printf("Branch: %s -> %s\n", pr.Head.Ref, pr.Base.Ref) + fmt.Printf("Created: %s\n", pr.Created.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", pr.Updated.Format("2006-01-02 15:04:05")) + if pr.Body != "" { + fmt.Printf("\n%s\n", pr.Body) + } + + return nil +} + +func runPRCreate(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + title, _ := cmd.Flags().GetString("title") + body, _ := cmd.Flags().GetString("body") + head, _ := cmd.Flags().GetString("head") + base, _ := cmd.Flags().GetString("base") + + if base == "" { + base = "main" + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + if title == "" { + return fmt.Errorf("title is required") + } + + if head == "" { + return fmt.Errorf("head branch is required") + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + pr, _, err := client.CreatePullRequest(owner, name, gitea.CreatePullRequestOption{ + Title: title, + Body: body, + Head: head, + Base: base, + }) + if err != nil { + return fmt.Errorf("failed to create pull request: %w", err) + } + + fmt.Printf("Pull request created: #%d\n", pr.Index) + fmt.Printf("View at: %s\n", pr.HTMLURL) + + return nil +} + +func runPRMerge(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + mergeMethod, _ := cmd.Flags().GetString("merge-method") + prNumber, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + var method gitea.MergeStyle + switch strings.ToLower(mergeMethod) { + case "merge": + method = gitea.MergeStyleMerge + case "rebase": + method = gitea.MergeStyleRebase + case "squash": + method = gitea.MergeStyleSquash + default: + return fmt.Errorf("invalid merge method: %s", mergeMethod) + } + + _, _, err = client.MergePullRequest(owner, name, prNumber, gitea.MergePullRequestOption{ + Style: method, + }) + if err != nil { + return fmt.Errorf("failed to merge pull request: %w", err) + } + + fmt.Printf("Pull request #%d merged successfully\n", prNumber) + + return nil +} + +func parseRepo(repo string) (string, string, error) { + if repo == "" { + return "", "", fmt.Errorf("repository flag is required (use -R owner/name)") + } + + 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 +} diff --git a/cmd/repo.go b/cmd/repo.go new file mode 100644 index 0000000..2b9bcfc --- /dev/null +++ b/cmd/repo.go @@ -0,0 +1,212 @@ +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" + "codeberg.org/romaintb/fgj/internal/api" + "codeberg.org/romaintb/fgj/internal/config" +) + +var repoCmd = &cobra.Command{ + Use: "repo", + Short: "Manage repositories", + Long: "View and manage repositories.", +} + +var repoViewCmd = &cobra.Command{ + Use: "view [owner/name]", + Short: "View repository details", + Long: "Display detailed information about a repository.", + Args: cobra.MaximumNArgs(1), + RunE: runRepoView, +} + +var repoListCmd = &cobra.Command{ + Use: "list", + Short: "List your repositories", + Long: "List repositories owned by the authenticated user.", + RunE: runRepoList, +} + +var repoCloneCmd = &cobra.Command{ + Use: "clone ", + Short: "Clone a repository", + Long: "Clone a repository locally.", + Args: cobra.ExactArgs(1), + RunE: runRepoClone, +} + +var repoForkCmd = &cobra.Command{ + Use: "fork ", + Short: "Fork a repository", + Long: "Create a fork of a repository.", + Args: cobra.ExactArgs(1), + RunE: runRepoFork, +} + +func init() { + rootCmd.AddCommand(repoCmd) + repoCmd.AddCommand(repoViewCmd) + repoCmd.AddCommand(repoListCmd) + repoCmd.AddCommand(repoCloneCmd) + repoCmd.AddCommand(repoForkCmd) + + repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh") +} + +func runRepoView(cmd *cobra.Command, args []string) error { + var repo string + if len(args) > 0 { + repo = args[0] + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + repository, _, err := client.GetRepo(owner, name) + if err != nil { + return fmt.Errorf("failed to get repository: %w", err) + } + + fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name) + fmt.Printf("Description: %s\n", repository.Description) + fmt.Printf("URL: %s\n", repository.HTMLURL) + fmt.Printf("Clone URL (HTTPS): %s\n", repository.CloneURL) + fmt.Printf("Clone URL (SSH): %s\n", repository.SSHURL) + fmt.Printf("Default Branch: %s\n", repository.DefaultBranch) + fmt.Printf("Stars: %d\n", repository.Stars) + fmt.Printf("Forks: %d\n", repository.Forks) + fmt.Printf("Open Issues: %d\n", repository.OpenIssues) + fmt.Printf("Private: %v\n", repository.Private) + fmt.Printf("Created: %s\n", repository.Created.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", repository.Updated.Format("2006-01-02 15:04:05")) + + return nil +} + +func runRepoList(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + user, _, err := client.GetMyUserInfo() + if err != nil { + return fmt.Errorf("failed to get user info: %w", err) + } + + repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{}) + if err != nil { + return fmt.Errorf("failed to list repositories: %w", err) + } + + if len(repos) == 0 { + fmt.Println("No repositories found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n") + for _, repo := range repos { + visibility := "public" + if repo.Private { + visibility = "private" + } + desc := repo.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc) + } + w.Flush() + + return nil +} + +func runRepoClone(cmd *cobra.Command, args []string) error { + repo := args[0] + protocol, _ := cmd.Flags().GetString("protocol") + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + repository, _, err := client.GetRepo(owner, name) + if err != nil { + return fmt.Errorf("failed to get repository: %w", err) + } + + var cloneURL string + if protocol == "ssh" { + cloneURL = repository.SSHURL + } else { + cloneURL = repository.CloneURL + } + + fmt.Printf("Cloning %s/%s...\n", owner, name) + fmt.Printf("git clone %s\n", cloneURL) + + return nil +} + +func runRepoFork(cmd *cobra.Command, args []string) error { + repo := args[0] + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{}) + if err != nil { + return fmt.Errorf("failed to fork repository: %w", err) + } + + fmt.Printf("Repository forked successfully\n") + fmt.Printf("View at: %s\n", fork.HTMLURL) + fmt.Printf("Clone URL: %s\n", fork.CloneURL) + + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..f7b1581 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +var rootCmd = &cobra.Command{ + Use: "fgj", + Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line", + Long: `fgj is a command line tool for Forgejo instances (including Codeberg). +It brings pull requests, issues, and other Forgejo concepts to the terminal.`, + Version: "0.1.0", +} + +func Execute() error { + return rootCmd.Execute() +} + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/config.yaml)") + rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname") + viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname")) +} + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + configDir := home + "/.config/fgj" + os.MkdirAll(configDir, 0755) + + viper.AddConfigPath(configDir) + viper.SetConfigType("yaml") + viper.SetConfigName("config") + } + + viper.AutomaticEnv() + viper.SetEnvPrefix("FGJ") + + if err := viper.ReadInConfig(); err == nil { + // Config file found and successfully parsed + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9ba4c15 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module codeberg.org/romaintb/fgj + +go 1.21 + +require ( + code.gitea.io/sdk/gitea v0.18.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 + golang.org/x/term v0.19.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6bd830 --- /dev/null +++ b/go.sum @@ -0,0 +1,101 @@ +code.gitea.io/sdk/gitea v0.18.0 h1:+zZrwVmujIrgobt6wVBWCqITz6bn1aBjnCUHmpZrerI= +code.gitea.io/sdk/gitea v0.18.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..82c97da --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,40 @@ +package api + +import ( + "code.gitea.io/sdk/gitea" + "codeberg.org/romaintb/fgj/internal/config" +) + +type Client struct { + *gitea.Client + hostname string +} + +func NewClient(hostname, token string) (*Client, error) { + if hostname == "" { + hostname = "codeberg.org" + } + + client, err := gitea.NewClient("https://"+hostname, gitea.SetToken(token)) + if err != nil { + return nil, err + } + + return &Client{ + Client: client, + hostname: hostname, + }, nil +} + +func NewClientFromConfig(cfg *config.Config, hostname string) (*Client, error) { + host, err := cfg.GetHost(hostname) + if err != nil { + return nil, err + } + + return NewClient(host.Hostname, host.Token) +} + +func (c *Client) Hostname() string { + return c.hostname +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..32f80ae --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,110 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +type Config struct { + Hosts map[string]HostConfig `yaml:"hosts"` +} + +type HostConfig struct { + Hostname string `yaml:"hostname"` + Token string `yaml:"token"` + User string `yaml:"user,omitempty"` + GitProtocol string `yaml:"git_protocol,omitempty"` +} + +func GetConfigDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "fgj"), nil +} + +func GetConfigPath() (string, error) { + dir, err := GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.yaml"), nil +} + +func Load() (*Config, error) { + path, err := GetConfigPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Config{Hosts: make(map[string]HostConfig)}, nil + } + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + if cfg.Hosts == nil { + cfg.Hosts = make(map[string]HostConfig) + } + + return &cfg, nil +} + +func (c *Config) Save() error { + path, err := GetConfigPath() + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := yaml.Marshal(c) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} + +func (c *Config) GetHost(hostname string) (HostConfig, error) { + if hostname == "" { + hostname = viper.GetString("hostname") + } + + if hostname == "" { + hostname = os.Getenv("FGJ_HOST") + } + + if hostname == "" { + hostname = "codeberg.org" + } + + host, ok := c.Hosts[hostname] + if !ok { + return HostConfig{}, fmt.Errorf("no configuration found for host %s", hostname) + } + + return host, nil +} + +func (c *Config) SetHost(hostname string, host HostConfig) { + if c.Hosts == nil { + c.Hosts = make(map[string]HostConfig) + } + c.Hosts[hostname] = host +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4e596a3 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "codeberg.org/romaintb/fgj/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +}