feat: initial version of the project
This commit is contained in:
commit
5b67d39aba
13 changed files with 1538 additions and 0 deletions
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
214
README.md
Normal file
214
README.md
Normal file
|
|
@ -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 <<EOF
|
||||
## Summary
|
||||
- Added new feature X
|
||||
- Fixed bug Y
|
||||
|
||||
Generated with AI assistance
|
||||
EOF
|
||||
)"
|
||||
|
||||
# Check PR status during development
|
||||
fgj pr list -R owner/repo --state open
|
||||
|
||||
# View PR details for review
|
||||
fgj pr view 123 -R owner/repo
|
||||
```
|
||||
|
||||
## Supported Forgejo Instances
|
||||
|
||||
`fgj` works with any Forgejo instance, including:
|
||||
|
||||
- [Codeberg.org](https://codeberg.org) (default)
|
||||
- Self-hosted Forgejo instances
|
||||
- Gitea instances (compatible API)
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
go build -o fgj
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
fgj/
|
||||
├── cmd/ # Command implementations
|
||||
│ ├── root.go # Root command and config
|
||||
│ ├── auth.go # Authentication commands
|
||||
│ ├── pr.go # Pull request commands
|
||||
│ ├── issue.go # Issue commands
|
||||
│ └── repo.go # Repository commands
|
||||
├── internal/
|
||||
│ ├── api/ # API client wrapper
|
||||
│ └── config/ # Configuration management
|
||||
├── main.go # Entry point
|
||||
└── go.mod # Dependencies
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Inspired by GitHub's `gh` CLI tool
|
||||
- Built using the [Gitea SDK](https://code.gitea.io/sdk) (compatible with Forgejo)
|
||||
- Uses [Cobra](https://github.com/spf13/cobra) for CLI framework
|
||||
123
cmd/auth.go
Normal file
123
cmd/auth.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"codeberg.org/romaintb/fgj/internal/api"
|
||||
"codeberg.org/romaintb/fgj/internal/config"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Authenticate fgj with a Forgejo instance",
|
||||
Long: "Manage authentication state for Forgejo instances.",
|
||||
}
|
||||
|
||||
var authLoginCmd = &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate with a Forgejo instance",
|
||||
Long: "Authenticate with a Forgejo instance using a personal access token.",
|
||||
RunE: runAuthLogin,
|
||||
}
|
||||
|
||||
var authStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "View authentication status",
|
||||
Long: "Display the authentication status for configured Forgejo instances.",
|
||||
RunE: runAuthStatus,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(authCmd)
|
||||
authCmd.AddCommand(authLoginCmd)
|
||||
authCmd.AddCommand(authStatusCmd)
|
||||
|
||||
authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
||||
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
|
||||
}
|
||||
|
||||
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||
hostname, _ := cmd.Flags().GetString("hostname")
|
||||
token, _ := cmd.Flags().GetString("token")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
if hostname == "" {
|
||||
fmt.Print("Forgejo instance hostname (default: codeberg.org): ")
|
||||
input, _ := reader.ReadString('\n')
|
||||
hostname = strings.TrimSpace(input)
|
||||
if hostname == "" {
|
||||
hostname = "codeberg.org"
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
fmt.Print("Personal access token: ")
|
||||
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read token: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
token = strings.TrimSpace(string(tokenBytes))
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return fmt.Errorf("token is required")
|
||||
}
|
||||
|
||||
client, err := api.NewClient(hostname, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
user, _, err := client.GetMyUserInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
cfg.SetHost(hostname, config.HostConfig{
|
||||
Hostname: hostname,
|
||||
Token: token,
|
||||
User: user.UserName,
|
||||
GitProtocol: "https",
|
||||
})
|
||||
|
||||
if err := cfg.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Authenticated as %s on %s\n", user.UserName, hostname)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAuthStatus(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if len(cfg.Hosts) == 0 {
|
||||
fmt.Println("Not authenticated with any Forgejo instances")
|
||||
fmt.Println("Run 'fgj auth login' to authenticate")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Authenticated instances:")
|
||||
for hostname, host := range cfg.Hosts {
|
||||
fmt.Printf(" • %s (user: %s)\n", hostname, host.User)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
301
cmd/issue.go
Normal file
301
cmd/issue.go
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
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 issueCmd = &cobra.Command{
|
||||
Use: "issue",
|
||||
Short: "Manage issues",
|
||||
Long: "Create, view, list, and manage issues.",
|
||||
}
|
||||
|
||||
var issueListCmd = &cobra.Command{
|
||||
Use: "list [flags]",
|
||||
Short: "List issues",
|
||||
Long: "List issues in a repository.",
|
||||
RunE: runIssueList,
|
||||
}
|
||||
|
||||
var issueViewCmd = &cobra.Command{
|
||||
Use: "view <number>",
|
||||
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 <number>",
|
||||
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 <number>",
|
||||
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
|
||||
}
|
||||
277
cmd/pr.go
Normal file
277
cmd/pr.go
Normal file
|
|
@ -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 <number>",
|
||||
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 <number>",
|
||||
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
|
||||
}
|
||||
212
cmd/repo.go
Normal file
212
cmd/repo.go
Normal file
|
|
@ -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 <owner/name>",
|
||||
Short: "Clone a repository",
|
||||
Long: "Clone a repository locally.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRepoClone,
|
||||
}
|
||||
|
||||
var repoForkCmd = &cobra.Command{
|
||||
Use: "fork <owner/name>",
|
||||
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
|
||||
}
|
||||
57
cmd/root.go
Normal file
57
cmd/root.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
37
go.mod
Normal file
37
go.mod
Normal file
|
|
@ -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
|
||||
)
|
||||
101
go.sum
Normal file
101
go.sum
Normal file
|
|
@ -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=
|
||||
40
internal/api/client.go
Normal file
40
internal/api/client.go
Normal file
|
|
@ -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
|
||||
}
|
||||
110
internal/config/config.go
Normal file
110
internal/config/config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
15
main.go
Normal file
15
main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue