From 028d97fa7382fb043b65cfb6e005ce52063ec629 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Tue, 16 Dec 2025 15:14:34 +0100 Subject: [PATCH 01/54] ci: add a nightly build this is interesting for the functional testing of the tool --- .gitea/workflows/nightly.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .gitea/workflows/nightly.yml diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml new file mode 100644 index 0000000..d335132 --- /dev/null +++ b/.gitea/workflows/nightly.yml @@ -0,0 +1,35 @@ +name: Nightly Functional Tests + +on: + schedule: + # Runs every day at midnight UTC + - cron: '0 0 * * *' + workflow_dispatch: # Allows manual trigger from the UI + +jobs: + functional: + runs-on: codeberg-small + steps: + - name: Checkout code + uses: https://github.com/actions/checkout@v4 + with: + ref: main # Always test the main branch + + - name: Set up Go + uses: https://github.com/actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Build production binary + run: | + make build + echo "Binary built at: $(pwd)/bin/fgj" + + - name: Run functional tests + run: go test -v -race -tags=functional ./tests/functional/... + env: + CODEBERG_TEST_TOKEN: ${{ secrets.CODEBERG_TEST_TOKEN }} + CODEBERG_TEST_OWNER: ${{ vars.CODEBERG_TEST_OWNER }} + CODEBERG_TEST_REPO: ${{ vars.CODEBERG_TEST_REPO }} + CODEBERG_TEST_HOSTNAME: ${{ vars.CODEBERG_TEST_HOSTNAME || 'codeberg.org' }} From dd82ab75a32ced30e7db2920b72eb88552ad863e Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Wed, 17 Dec 2025 11:36:21 +0100 Subject: [PATCH 02/54] docs: mention aur/homebrew install methods --- README.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2d71dc8..58e4ab2 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,19 @@ ## Installation -### From Source +### Arch Linux (AUR) + +`fgj` is available in the Arch User Repository: ```bash -git clone https://codeberg.org/romaintb/fgj.git -cd fgj -go build -o fgj -sudo mv fgj /usr/local/bin/ +yay -S fgj +``` + +### macOS (Homebrew) + +```bash +brew tap romaintb/fgj https://codeberg.org/romaintb/homebrew-fgj.git +brew install fgj ``` ### Using Go Install @@ -34,6 +40,10 @@ sudo mv fgj /usr/local/bin/ go install codeberg.org/romaintb/fgj@latest ``` +### Other Distributions + +We'd love your help packaging `fgj` for other distributions! If you're interested in creating packages for Debian, Ubuntu, Fedora, or any packaging systems, please open an issue or reach out. + ## Quick Start ### 1. Authenticate From 3bc37d32a64efc276ef882ff5695929a2e89b850 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Wed, 24 Dec 2025 10:15:50 +0100 Subject: [PATCH 03/54] feat: "pr create --assignee" implementation --- cmd/pr.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/cmd/pr.go b/cmd/pr.go index e6d75b2..df488d8 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -68,6 +68,7 @@ func init() { 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)") + prCreateCmd.Flags().StringSliceP("assignee", "a", []string{}, "Assign people by their login. Use \"@me\" to self-assign.") prMergeCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prMergeCmd.Flags().String("merge-method", "merge", "Merge method: merge, rebase, squash") @@ -173,6 +174,7 @@ func runPRCreate(cmd *cobra.Command, args []string) error { body, _ := cmd.Flags().GetString("body") head, _ := cmd.Flags().GetString("head") base, _ := cmd.Flags().GetString("base") + assignees, _ := cmd.Flags().GetStringSlice("assignee") if base == "" { base = "main" @@ -201,11 +203,26 @@ func runPRCreate(cmd *cobra.Command, args []string) error { return err } + // Resolve @me in assignees + resolvedAssignees := make([]string, 0, len(assignees)) + for _, assignee := range assignees { + if assignee == "@me" { + user, _, err := client.GetMyUserInfo() + if err != nil { + return fmt.Errorf("failed to get current user info: %w", err) + } + resolvedAssignees = append(resolvedAssignees, user.UserName) + } else { + resolvedAssignees = append(resolvedAssignees, assignee) + } + } + pr, _, err := client.CreatePullRequest(owner, name, gitea.CreatePullRequestOption{ - Title: title, - Body: body, - Head: head, - Base: base, + Title: title, + Body: body, + Head: head, + Base: base, + Assignees: resolvedAssignees, }) if err != nil { return fmt.Errorf("failed to create pull request: %w", err) From 9251487e56e47dd8169244e37cebf419f9e9d95d Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sat, 3 Jan 2026 11:49:34 +0100 Subject: [PATCH 04/54] feat: issue edit --- cmd/issue.go | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/cmd/issue.go b/cmd/issue.go index 1644143..f7dfd7c 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -8,9 +8,9 @@ import ( "text/tabwriter" "code.gitea.io/sdk/gitea" - "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" ) var issueCmd = &cobra.Command{ @@ -57,6 +57,14 @@ var issueCloseCmd = &cobra.Command{ RunE: runIssueClose, } +var issueEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit an issue", + Long: "Edit an existing issue's title, body, or state.", + Args: cobra.ExactArgs(1), + RunE: runIssueEdit, +} + func init() { rootCmd.AddCommand(issueCmd) issueCmd.AddCommand(issueListCmd) @@ -64,6 +72,7 @@ func init() { issueCmd.AddCommand(issueCreateCmd) issueCmd.AddCommand(issueCommentCmd) issueCmd.AddCommand(issueCloseCmd) + issueCmd.AddCommand(issueEditCmd) issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") @@ -78,6 +87,11 @@ func init() { issueCommentCmd.Flags().StringP("body", "b", "", "Comment body") issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + + issueEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue") + issueEditCmd.Flags().StringP("body", "b", "", "New body for the issue") + issueEditCmd.Flags().StringP("state", "s", "", "New state for the issue (open or closed)") } func runIssueList(cmd *cobra.Command, args []string) error { @@ -299,3 +313,66 @@ func runIssueClose(cmd *cobra.Command, args []string) error { return nil } + +func runIssueEdit(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + title, _ := cmd.Flags().GetString("title") + body, _ := cmd.Flags().GetString("body") + stateStr, _ := cmd.Flags().GetString("state") + + 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 title == "" && body == "" && stateStr == "" { + return fmt.Errorf("at least one of --title, --body, or --state must be provided") + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "") + if err != nil { + return err + } + + editOpt := gitea.EditIssueOption{} + + if title != "" { + editOpt.Title = title + } + + if body != "" { + editOpt.Body = &body + } + + if stateStr != "" { + switch strings.ToLower(stateStr) { + case "open": + stateOpen := gitea.StateOpen + editOpt.State = &stateOpen + case "closed": + stateClosed := gitea.StateClosed + editOpt.State = &stateClosed + default: + return fmt.Errorf("invalid state: %s (must be 'open' or 'closed')", stateStr) + } + } + + _, _, err = client.EditIssue(owner, name, issueNumber, editOpt) + if err != nil { + return fmt.Errorf("failed to edit issue: %w", err) + } + + fmt.Printf("Issue #%d updated\n", issueNumber) + + return nil +} From 5bdd76d5dcae95675d11464b865413add1449187 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sat, 3 Jan 2026 11:54:12 +0100 Subject: [PATCH 05/54] test(func): add tests for "issue edit" --- tests/functional/functional_test.go | 178 ++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/tests/functional/functional_test.go b/tests/functional/functional_test.go index 73f098c..5d662dd 100644 --- a/tests/functional/functional_test.go +++ b/tests/functional/functional_test.go @@ -302,6 +302,184 @@ func TestCLIIssueClose(t *testing.T) { t.Logf("Successfully tested issue close via API for CLI #%d", issueNum) } +// TestEditIssueTitle verifies we can edit an issue's title +func TestEditIssueTitle(t *testing.T) { + env := NewTestEnv(t) + + // Create a test issue + issueNum := env.CreateTestIssue( + "[FGJ E2E Test] Original Title", + "This issue's title will be edited", + ) + + defer env.CleanupIssue(issueNum) + + // Edit the title + newTitle := "[FGJ E2E Test] Updated Title" + _, _, err := env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ + Title: newTitle, + }) + if err != nil { + t.Fatalf("failed to edit issue title: %v", err) + } + + // Verify the title was updated + issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.Title != newTitle { + t.Fatalf("expected title '%s', got '%s'", newTitle, issue.Title) + } + + t.Logf("Successfully edited issue #%d title", issueNum) +} + +// TestEditIssueBody verifies we can edit an issue's body +func TestEditIssueBody(t *testing.T) { + env := NewTestEnv(t) + + // Create a test issue + issueNum := env.CreateTestIssue( + "[FGJ E2E Test] Edit Body Test", + "Original body content", + ) + + defer env.CleanupIssue(issueNum) + + // Edit the body + newBody := "Updated body content from functional test" + _, _, err := env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ + Body: &newBody, + }) + if err != nil { + t.Fatalf("failed to edit issue body: %v", err) + } + + // Verify the body was updated + issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.Body != newBody { + t.Fatalf("expected body '%s', got '%s'", newBody, issue.Body) + } + + t.Logf("Successfully edited issue #%d body", issueNum) +} + +// TestEditIssueState verifies we can edit an issue's state +func TestEditIssueState(t *testing.T) { + env := NewTestEnv(t) + + // Create a test issue (starts as open) + issueNum := env.CreateTestIssue( + "[FGJ E2E Test] Edit State Test", + "This issue's state will be changed", + ) + + defer env.CleanupIssue(issueNum) + + // Verify it starts as open + issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.State != "open" { + t.Fatalf("expected initial state 'open', got '%s'", issue.State) + } + + // Edit state to closed + closedState := gitea.StateClosed + _, _, err = env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ + State: &closedState, + }) + if err != nil { + t.Fatalf("failed to edit issue state: %v", err) + } + + // Verify state changed to closed + issue, _, err = env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.State != "closed" { + t.Fatalf("expected state 'closed' after edit, got '%s'", issue.State) + } + + // Edit state back to open + openState := gitea.StateOpen + _, _, err = env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ + State: &openState, + }) + if err != nil { + t.Fatalf("failed to reopen issue: %v", err) + } + + // Verify state is now open + issue, _, err = env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.State != "open" { + t.Fatalf("expected state 'open' after reopen, got '%s'", issue.State) + } + + t.Logf("Successfully edited issue #%d state", issueNum) +} + +// TestEditIssueMultipleFields verifies we can edit multiple issue fields at once +func TestEditIssueMultipleFields(t *testing.T) { + env := NewTestEnv(t) + + // Create a test issue + issueNum := env.CreateTestIssue( + "[FGJ E2E Test] Original Multi-Edit", + "Original body for multi-field edit", + ) + + defer env.CleanupIssue(issueNum) + + // Edit title, body, and state at once + newTitle := "[FGJ E2E Test] Updated Multi-Edit" + newBody := "Updated body from multi-field edit test" + closedState := gitea.StateClosed + + _, _, err := env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ + Title: newTitle, + Body: &newBody, + State: &closedState, + }) + if err != nil { + t.Fatalf("failed to edit multiple issue fields: %v", err) + } + + // Verify all fields were updated + issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.Title != newTitle { + t.Fatalf("expected title '%s', got '%s'", newTitle, issue.Title) + } + + if issue.Body != newBody { + t.Fatalf("expected body '%s', got '%s'", newBody, issue.Body) + } + + if issue.State != "closed" { + t.Fatalf("expected state 'closed', got '%s'", issue.State) + } + + t.Logf("Successfully edited multiple fields on issue #%d", issueNum) +} + // TestCLIPRList verifies the `fgj pr list` command works func TestCLIPRList(t *testing.T) { env := NewTestEnv(t) From 36a473a7111c998a9eb9ca7ed4d0573f624376ae Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Mon, 5 Jan 2026 12:57:37 +0100 Subject: [PATCH 06/54] feat: releases --- README.md | 19 +++ cmd/release.go | 450 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 cmd/release.go diff --git a/README.md b/README.md index 58e4ab2..c23ed91 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,25 @@ fgj repo clone owner/repo -p ssh fgj repo fork owner/repo ``` +### Releases + +```bash +# List releases +fgj release list + +# View a release (or use "latest") +fgj release view v1.2.3 + +# Create a release with notes and optional assets +fgj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz + +# Upload assets to an existing release +fgj release upload v1.2.3 ./dist/app.tar.gz --clobber + +# Delete a release (keeps the Git tag) +fgj release delete v1.2.3 +``` + ### Forgejo Actions ```bash diff --git a/cmd/release.go b/cmd/release.go new file mode 100644 index 0000000..eef4bdc --- /dev/null +++ b/cmd/release.go @@ -0,0 +1,450 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "code.gitea.io/sdk/gitea" + "codeberg.org/romaintb/fgj/internal/api" + "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" +) + +var releaseCmd = &cobra.Command{ + Use: "release", + Aliases: []string{"releases"}, + Short: "Manage releases", + Long: "Create, view, list, upload assets, and delete releases.", +} + +var releaseListCmd = &cobra.Command{ + Use: "list", + Short: "List releases", + Long: "List releases in a repository.", + RunE: runReleaseList, +} + +var releaseViewCmd = &cobra.Command{ + Use: "view ", + Short: "View a release", + Long: "Display detailed information about a release.", + Args: cobra.ExactArgs(1), + RunE: runReleaseView, +} + +var releaseCreateCmd = &cobra.Command{ + Use: "create [files...]", + Short: "Create a release", + Long: "Create a new release and optionally upload assets.", + Args: cobra.MinimumNArgs(1), + RunE: runReleaseCreate, +} + +var releaseUploadCmd = &cobra.Command{ + Use: "upload ", + Short: "Upload release assets", + Long: "Upload assets to an existing release.", + Args: cobra.MinimumNArgs(2), + RunE: runReleaseUpload, +} + +var releaseDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a release", + Long: "Delete a release by tag, keeping its Git tag intact.", + Args: cobra.ExactArgs(1), + RunE: runReleaseDelete, +} + +func init() { + rootCmd.AddCommand(releaseCmd) + releaseCmd.AddCommand(releaseListCmd) + releaseCmd.AddCommand(releaseViewCmd) + releaseCmd.AddCommand(releaseCreateCmd) + releaseCmd.AddCommand(releaseUploadCmd) + releaseCmd.AddCommand(releaseDeleteCmd) + + releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseListCmd.Flags().Bool("draft", false, "Filter by draft status") + releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status") + releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch") + + releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + + releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)") + releaseCreateCmd.Flags().StringP("notes", "n", "", "Release notes") + releaseCreateCmd.Flags().StringP("notes-file", "F", "", "Read release notes from file") + releaseCreateCmd.Flags().Bool("draft", false, "Create a draft release") + releaseCreateCmd.Flags().Bool("prerelease", false, "Mark the release as prerelease") + releaseCreateCmd.Flags().String("target", "", "Target commitish (branch or SHA)") + + releaseUploadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name") + + releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") +} + +func runReleaseList(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + limit, _ := cmd.Flags().GetInt("limit") + draftValue, _ := cmd.Flags().GetBool("draft") + prereleaseValue, _ := cmd.Flags().GetBool("prerelease") + draftSet := cmd.Flags().Changed("draft") + prereleaseSet := cmd.Flags().Changed("prerelease") + + if limit <= 0 { + return fmt.Errorf("limit must be greater than 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 + } + + pageSize := limit + if pageSize > 50 { + pageSize = 50 + } + + opts := gitea.ListReleasesOptions{} + if draftSet { + opts.IsDraft = &draftValue + } + if prereleaseSet { + opts.IsPreRelease = &prereleaseValue + } + + var releases []*gitea.Release + for page := 1; len(releases) < limit; page++ { + opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize} + batch, _, err := client.ListReleases(owner, name, opts) + if err != nil { + return fmt.Errorf("failed to list releases: %w", err) + } + if len(batch) == 0 { + break + } + releases = append(releases, batch...) + } + + if len(releases) > limit { + releases = releases[:limit] + } + + if len(releases) == 0 { + fmt.Printf("No releases in %s/%s\n", owner, name) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n") + for _, rel := range releases { + published := releaseTimestamp(rel).Format("2006-01-02") + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published) + } + _ = w.Flush() + + return nil +} + +func runReleaseView(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + tag := 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 + } + + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + return err + } + + fmt.Printf("Release %s\n", release.TagName) + fmt.Printf("Title: %s\n", release.Title) + fmt.Printf("Type: %s\n", releaseType(release)) + if release.Target != "" { + fmt.Printf("Target: %s\n", release.Target) + } + if release.Publisher != nil { + fmt.Printf("Author: %s\n", release.Publisher.UserName) + } + fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05")) + if !release.PublishedAt.IsZero() { + fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05")) + } + if release.HTMLURL != "" { + fmt.Printf("URL: %s\n", release.HTMLURL) + } + if release.Note != "" { + fmt.Printf("\n%s\n", release.Note) + } + + attachments, err := listReleaseAttachments(client, owner, name, release.ID) + if err != nil { + return err + } + if len(attachments) > 0 { + fmt.Printf("\nAssets (%d):\n", len(attachments)) + for _, asset := range attachments { + fmt.Printf("- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL) + } + } + + return nil +} + +func runReleaseCreate(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + title, _ := cmd.Flags().GetString("title") + notes, _ := cmd.Flags().GetString("notes") + notesFile, _ := cmd.Flags().GetString("notes-file") + draft, _ := cmd.Flags().GetBool("draft") + prerelease, _ := cmd.Flags().GetBool("prerelease") + target, _ := cmd.Flags().GetString("target") + + if notes != "" && notesFile != "" { + return fmt.Errorf("use either --notes or --notes-file, not both") + } + + tag := args[0] + files := args[1:] + + if notesFile != "" { + content, err := os.ReadFile(notesFile) + if err != nil { + return fmt.Errorf("failed to read notes file: %w", err) + } + notes = string(content) + } + + if title == "" { + title = tag + } + + 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 + } + + release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{ + TagName: tag, + Target: target, + Title: title, + Note: notes, + IsDraft: draft, + IsPrerelease: prerelease, + }) + if err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + + fmt.Printf("Release created: %s\n", release.TagName) + if release.HTMLURL != "" { + fmt.Printf("View at: %s\n", release.HTMLURL) + } + + if len(files) == 0 { + return nil + } + + if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil { + return err + } + + fmt.Printf("Uploaded %d asset(s)\n", len(files)) + return nil +} + +func runReleaseUpload(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + clobber, _ := cmd.Flags().GetBool("clobber") + + tag := args[0] + files := args[1:] + + 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 + } + + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + return err + } + + if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil { + return err + } + + fmt.Printf("Uploaded %d asset(s)\n", len(files)) + return nil +} + +func runReleaseDelete(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + tag := 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 + } + + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + return err + } + + if _, err := client.DeleteRelease(owner, name, release.ID); err != nil { + return fmt.Errorf("failed to delete release: %w", err) + } + + fmt.Printf("Release %s deleted\n", release.TagName) + return nil +} + +func getReleaseByTagOrLatest(client *api.Client, owner, name, tag string) (*gitea.Release, error) { + if strings.EqualFold(tag, "latest") { + release, _, err := client.GetLatestRelease(owner, name) + if err != nil { + return nil, fmt.Errorf("failed to get latest release: %w", err) + } + return release, nil + } + + release, _, err := client.GetReleaseByTag(owner, name, tag) + if err != nil { + return nil, fmt.Errorf("failed to get release: %w", err) + } + return release, nil +} + +func uploadReleaseAssets(client *api.Client, owner, name string, releaseID int64, files []string, clobber bool) error { + existing := map[string]int64{} + if clobber { + attachments, err := listReleaseAttachments(client, owner, name, releaseID) + if err != nil { + return err + } + for _, attachment := range attachments { + existing[attachment.Name] = attachment.ID + } + } + + for _, file := range files { + filename := filepath.Base(file) + if clobber { + if attachmentID, ok := existing[filename]; ok { + if _, err := client.DeleteReleaseAttachment(owner, name, releaseID, attachmentID); err != nil { + return fmt.Errorf("failed to delete existing asset %s: %w", filename, err) + } + } + } + + handle, err := os.Open(file) + if err != nil { + return fmt.Errorf("failed to open %s: %w", file, err) + } + + _, _, err = client.CreateReleaseAttachment(owner, name, releaseID, handle, filename) + closeErr := handle.Close() + if err != nil { + return fmt.Errorf("failed to upload %s: %w", file, err) + } + if closeErr != nil { + return fmt.Errorf("failed to close %s: %w", file, closeErr) + } + } + + return nil +} + +func listReleaseAttachments(client *api.Client, owner, name string, releaseID int64) ([]*gitea.Attachment, error) { + var all []*gitea.Attachment + for page := 1; ; page++ { + attachments, _, err := client.ListReleaseAttachments(owner, name, releaseID, gitea.ListReleaseAttachmentsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, fmt.Errorf("failed to list release assets: %w", err) + } + if len(attachments) == 0 { + break + } + all = append(all, attachments...) + } + return all, nil +} + +func releaseType(release *gitea.Release) string { + if release.IsDraft { + return "draft" + } + if release.IsPrerelease { + return "prerelease" + } + return "release" +} + +func releaseTimestamp(release *gitea.Release) time.Time { + if !release.PublishedAt.IsZero() { + return release.PublishedAt + } + return release.CreatedAt +} From 21a506388af38e806d61a1bccb85c325f542684a Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Mon, 5 Jan 2026 12:57:57 +0100 Subject: [PATCH 07/54] tools: add a basic .editorconfig --- .editorconfig | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1e5c995 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,47 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Go files - use tabs (Go standard) +[*.go] +indent_style = tab +indent_size = 4 + +# Go mod files +[go.{mod,sum}] +indent_style = tab +indent_size = 4 + +# Makefiles - must use tabs +[{Makefile,*.mk}] +indent_style = tab +indent_size = 4 + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# JSON files +[*.json] +indent_style = space +indent_size = 2 + +# Markdown files +[*.md] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = false + +# Shell scripts +[*.sh] +indent_style = space +indent_size = 2 From 462d3c0c2cb3aff6a1d1ddd012933ef22e120e7a Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Mon, 5 Jan 2026 12:58:11 +0100 Subject: [PATCH 08/54] make: add an `install` target --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 57e8eda..d11a86a 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: help build run test clean lint lint-fix +.PHONY: help build run test clean lint lint-fix install help: @echo "Available commands:" @echo " make build - Build the application" + @echo " make install - Install the binary to /usr/bin" @echo " make run - Run the application" @echo " make test - Run tests" @echo " make lint - Run golangci-lint" @@ -12,6 +13,9 @@ help: build: go build -o bin/fgj . +install: build + install -Dm755 bin/fgj /usr/bin/fgj + run: go run . From be3121ffd34a1113f294f48e5d64f24048e43150 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Mon, 5 Jan 2026 13:01:50 +0100 Subject: [PATCH 09/54] tests: add some functional tests for releases --- tests/functional/functional_test.go | 84 +++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/functional/functional_test.go b/tests/functional/functional_test.go index 5d662dd..7fed7a1 100644 --- a/tests/functional/functional_test.go +++ b/tests/functional/functional_test.go @@ -1,3 +1,4 @@ +//go:build functional // +build functional package functional @@ -7,6 +8,7 @@ import ( "fmt" "os" "testing" + "time" "code.gitea.io/sdk/gitea" ) @@ -620,3 +622,85 @@ func TestCLIRepoClone(t *testing.T) { t.Logf("Successfully cloned repository to %s via CLI", clonePath) } + +// TestCLIReleaseCreateUploadDelete verifies release create/upload/delete via CLI. +func TestCLIReleaseCreateUploadDelete(t *testing.T) { + env := NewTestEnv(t) + + tag := fmt.Sprintf("fgj-test-%d", time.Now().UnixNano()) + title := "FGJ CLI Release Test" + notes := "Release created by functional tests" + + tmpDir := t.TempDir() + assetPath := fmt.Sprintf("%s/asset.txt", tmpDir) + if err := os.WriteFile(assetPath, []byte("fgj release asset"), 0600); err != nil { + t.Fatalf("failed to create asset file: %v", err) + } + + result := env.RunCLI( + "--hostname", env.Hostname, + "release", "create", tag, + "-t", title, + "-n", notes, + assetPath, + ) + if result.ExitCode != 0 { + t.Fatalf("release create failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + release, _, err := env.Client.GetReleaseByTag(env.Owner, env.RepoName, tag) + if err != nil { + t.Fatalf("failed to fetch created release: %v", err) + } + if release.Title != title { + t.Fatalf("expected release title %q, got %q", title, release.Title) + } + + attachments, _, err := env.Client.ListReleaseAttachments(env.Owner, env.RepoName, release.ID, gitea.ListReleaseAttachmentsOptions{}) + if err != nil { + t.Fatalf("failed to list release assets: %v", err) + } + found := false + for _, attachment := range attachments { + if attachment.Name == "asset.txt" { + found = true + break + } + } + if !found { + t.Fatalf("uploaded asset not found in release") + } + + deleteResult := env.RunCLI( + "--hostname", env.Hostname, + "release", "delete", tag, + ) + if deleteResult.ExitCode != 0 { + t.Fatalf("release delete failed with exit code %d: %s", deleteResult.ExitCode, deleteResult.Stderr) + } + + _, _, err = env.Client.GetReleaseByTag(env.Owner, env.RepoName, tag) + if err == nil { + t.Fatalf("expected release %s to be deleted, but it still exists", tag) + } +} + +// TestCLIReleaseList verifies the `fgj release list` command works. +func TestCLIReleaseList(t *testing.T) { + env := NewTestEnv(t) + + result := env.RunCLI( + "--hostname", env.Hostname, + "release", "list", + ) + + if result.ExitCode != 0 { + t.Fatalf("release list failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if result.Stdout == "" && result.Stderr == "" { + t.Logf("Note: release list produced no output") + } + + t.Logf("Successfully listed releases via CLI") +} From c0baf4fa3bc1a321e875a38d1d5df032c39c4dd1 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Mon, 5 Jan 2026 12:47:28 +0100 Subject: [PATCH 10/54] feat: auto detect hostname --- README.md | 18 ++- cmd/actions.go | 18 +-- cmd/issue.go | 12 +- cmd/pr.go | 27 +---- cmd/release.go | 10 +- cmd/repo.go | 8 +- cmd/root.go | 33 +++++ internal/api/client.go | 4 +- internal/api/client_test.go | 2 +- internal/config/config.go | 13 +- internal/config/config_test.go | 18 +-- internal/git/git.go | 47 +++++-- internal/git/git_test.go | 215 ++++++++++++++++++++++++++------- 13 files changed, 300 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index c23ed91..9108552 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,12 @@ fgj pr view 123 # Automatically uses current repo fgj pr list -R owner/repo ``` -The tool reads `.git/config` to find the origin remote and extract the owner/repo information. If you're not in a git repository, you'll need to use the `-R` flag. +The tool reads `.git/config` to find the origin remote and extract both the owner/repo information and the Forgejo instance hostname. If you're not in a git repository, you'll need to use the `-R` flag. ### Pull Requests ```bash -# List pull requests (auto-detects repo from git) +# List pull requests (auto-detects repo and hostname from git) fgj pr list # Or specify explicitly @@ -114,7 +114,7 @@ fgj pr merge 123 --merge-method squash ### Issues ```bash -# List issues (auto-detects repo from git) +# List issues (auto-detects repo and hostname from git) fgj issue list # Or specify explicitly @@ -237,14 +237,22 @@ hosts: ### Environment Variables -- `FGJ_HOST`: Override the default Forgejo instance +- `FGJ_HOST`: Override the default Forgejo instance (auto-detected from git remote if not set) - `FGJ_TOKEN`: Provide authentication token +Hostname is resolved in this priority order: +1. Command-specific flags (e.g., `--hostname`) +2. `FGJ_HOST` environment variable +3. Auto-detected from git remote URL +4. Default to `codeberg.org` + ### Command-line Flags -- `--hostname`: Specify Forgejo instance for a command +- `--hostname`: Specify Forgejo instance for a command (overrides auto-detection and environment variables) - `--config`: Use a custom config file +When working in a git repository, `fgj` automatically detects the Forgejo instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances. + ## Use with AI Coding Agents `fgj` is designed to work seamlessly with AI coding agents like Claude Code. Common patterns: diff --git a/cmd/actions.go b/cmd/actions.go index e9dbba0..bedb839 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -219,7 +219,7 @@ func runRunList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -277,7 +277,7 @@ func runRunView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -507,7 +507,7 @@ func runActionsSecretList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -550,7 +550,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -591,7 +591,7 @@ func runActionsSecretDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -626,7 +626,7 @@ func runActionsVariableGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -654,7 +654,7 @@ func runActionsVariableCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -683,7 +683,7 @@ func runActionsVariableUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -712,7 +712,7 @@ func runActionsVariableDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } diff --git a/cmd/issue.go b/cmd/issue.go index f7dfd7c..4caf5bf 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -108,7 +108,7 @@ func runIssueList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -166,7 +166,7 @@ func runIssueView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -220,7 +220,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -261,7 +261,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -296,7 +296,7 @@ func runIssueClose(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -339,7 +339,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } diff --git a/cmd/pr.go b/cmd/pr.go index df488d8..9c15401 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -11,7 +11,6 @@ import ( "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" - "codeberg.org/romaintb/fgj/internal/git" ) var prCmd = &cobra.Command{ @@ -88,7 +87,7 @@ func runPRList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -144,7 +143,7 @@ func runPRView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -198,7 +197,7 @@ func runPRCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -252,7 +251,7 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -281,21 +280,3 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return nil } -func parseRepo(repo string) (string, string, error) { - // If repo flag is provided, use it - if repo != "" { - parts := strings.Split(repo, "/") - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid repository format: %s (expected: owner/name)", repo) - } - return parts[0], parts[1], nil - } - - // Try to auto-detect from git - owner, name, err := git.DetectRepo() - if err != nil { - return "", "", fmt.Errorf("repository flag is required (use -R owner/name) or run from a git repository: %w", err) - } - - return owner, name, nil -} diff --git a/cmd/release.go b/cmd/release.go index eef4bdc..8c04768 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -111,7 +111,7 @@ func runReleaseList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -176,7 +176,7 @@ func runReleaseView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -258,7 +258,7 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -309,7 +309,7 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -341,7 +341,7 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } diff --git a/cmd/repo.go b/cmd/repo.go index 41f6650..3264196 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -76,7 +76,7 @@ func runRepoView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -108,7 +108,7 @@ func runRepoList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -160,7 +160,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -221,7 +221,7 @@ func runRepoFork(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index 34fef9c..89d3b7d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,9 +3,11 @@ package cmd import ( "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" + "codeberg.org/romaintb/fgj/internal/git" ) var cfgFile string @@ -53,3 +55,34 @@ func initConfig() { _ = viper.ReadInConfig() } + +// parseRepo parses the repository string in the format "owner/name". +// If not provided, it attempts to auto-detect from the git repository. +func parseRepo(repo string) (string, string, error) { + // If repo flag is provided, use it + if repo != "" { + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid repository format: %s (expected: owner/name)", repo) + } + return parts[0], parts[1], nil + } + + // Try to auto-detect from git + owner, name, err := git.DetectRepo() + if err != nil { + return "", "", fmt.Errorf("repository flag is required (use -R owner/name) or run from a git repository: %w", err) + } + + return owner, name, nil +} + +// getDetectedHost attempts to auto-detect the Forgejo instance hostname. +// Returns empty string if detection fails, which will fall back to other methods. +func getDetectedHost() string { + host, err := git.DetectHost() + if err != nil { + return "" + } + return host +} diff --git a/internal/api/client.go b/internal/api/client.go index 42067c6..dd180b9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -33,8 +33,8 @@ func NewClient(hostname, token string) (*Client, error) { }, nil } -func NewClientFromConfig(cfg *config.Config, hostname string) (*Client, error) { - host, err := cfg.GetHost(hostname) +func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string) (*Client, error) { + host, err := cfg.GetHost(hostname, detectedHost) if err != nil { return nil, err } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 0bbc3ca..3cb469b 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -21,7 +21,7 @@ func TestNewClientFromConfig_MissingHost(t *testing.T) { Hosts: map[string]config.HostConfig{}, } - _, err := NewClientFromConfig(cfg, "nonexistent.org") + _, err := NewClientFromConfig(cfg, "nonexistent.org", "") if err == nil { t.Error("Expected error for nonexistent host") } diff --git a/internal/config/config.go b/internal/config/config.go index 41c7409..9584efc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -87,7 +87,14 @@ func (c *Config) SaveToPath(path string) error { return os.WriteFile(path, data, 0600) } -func (c *Config) GetHost(hostname string) (HostConfig, error) { +// GetHost resolves the hostname to use for API client creation. +// Priority order: +// 1. Explicitly provided hostname parameter +// 2. CLI flag (--hostname) +// 3. Environment variable (FGJ_HOST) +// 4. Auto-detected hostname from git remote +// 5. Default to codeberg.org +func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, error) { if hostname == "" { hostname = viper.GetString("hostname") } @@ -96,6 +103,10 @@ func (c *Config) GetHost(hostname string) (HostConfig, error) { hostname = os.Getenv("FGJ_HOST") } + if hostname == "" { + hostname = detectedHost + } + if hostname == "" { hostname = "codeberg.org" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0999170..6912963 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -49,7 +49,7 @@ func TestConfig_GetHost(t *testing.T) { }, } - host, err := cfg.GetHost("codeberg.org") + host, err := cfg.GetHost("codeberg.org", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -58,7 +58,7 @@ func TestConfig_GetHost(t *testing.T) { t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname) } - _, err = cfg.GetHost("nonexistent.org") + _, err = cfg.GetHost("nonexistent.org", "") if err == nil { t.Error("Expected error for nonexistent host") } @@ -261,7 +261,7 @@ func TestConfig_GetHost_EmptyString(t *testing.T) { } // Empty hostname should default to codeberg.org - host, err := cfg.GetHost("") + host, err := cfg.GetHost("", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -282,7 +282,7 @@ func TestConfig_GetHost_WhitespaceString(t *testing.T) { } // Whitespace-only hostname should default to codeberg.org - host, err := cfg.GetHost(" ") + host, err := cfg.GetHost(" ", "") if err == nil { t.Logf("Got host: %+v (this may be expected behavior)", host) } else { @@ -301,7 +301,7 @@ func TestConfig_SetHost_EmptyToken(t *testing.T) { cfg.SetHost("codeberg.org", hostConfig) - host, err := cfg.GetHost("codeberg.org") + host, err := cfg.GetHost("codeberg.org", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -331,7 +331,7 @@ func TestConfig_SetHost_OverwriteExisting(t *testing.T) { cfg.SetHost("codeberg.org", newConfig) - host, err := cfg.GetHost("codeberg.org") + host, err := cfg.GetHost("codeberg.org", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -374,7 +374,7 @@ func TestConfig_MultipleHosts(t *testing.T) { // Verify each host can be retrieved correctly for _, h := range hosts { - host, err := cfg.GetHost(h.hostname) + host, err := cfg.GetHost(h.hostname, "") if err != nil { t.Errorf("Failed to get host %s: %v", h.hostname, err) continue @@ -408,12 +408,12 @@ func TestConfig_GitProtocol(t *testing.T) { }) // Verify protocols are stored correctly - sshHost, _ := cfg.GetHost("test-ssh.org") + sshHost, _ := cfg.GetHost("test-ssh.org", "") if sshHost.GitProtocol != "ssh" { t.Errorf("Expected git_protocol 'ssh', got '%s'", sshHost.GitProtocol) } - httpsHost, _ := cfg.GetHost("test-https.org") + httpsHost, _ := cfg.GetHost("test-https.org", "") if httpsHost.GitProtocol != "https" { t.Errorf("Expected git_protocol 'https', got '%s'", httpsHost.GitProtocol) } diff --git a/internal/git/git.go b/internal/git/git.go index 1f0282b..425c764 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -25,7 +25,28 @@ func DetectRepo() (owner, name string, err error) { } // Extract owner/name from URL - return parseRemoteURL(remoteURL) + owner, name, _, err = parseRemoteURL(remoteURL) + return owner, name, err +} + +// DetectHost attempts to detect the Forgejo instance hostname from the current git directory. +// It reads .git/config and parses the origin remote URL to extract the hostname. +func DetectHost() (hostname string, err error) { + // Find .git/config file + gitConfigPath, err := findGitConfig() + if err != nil { + return "", err + } + + // Parse .git/config + remoteURL, err := parseGitConfig(gitConfigPath) + if err != nil { + return "", err + } + + // Extract hostname from URL + _, _, hostname, err = parseRemoteURL(remoteURL) + return hostname, err } // findGitConfig searches for .git/config starting from the current directory @@ -93,33 +114,33 @@ func parseGitConfig(configPath string) (string, error) { return "", fmt.Errorf("no origin remote found in git config") } -// parseRemoteURL extracts owner/name from various git URL formats: +// parseRemoteURL extracts owner/name/hostname from various git URL formats: // - https://codeberg.org/owner/name.git // - git@codeberg.org:owner/name.git // - ssh://git@codeberg.org/owner/name.git -func parseRemoteURL(url string) (owner, name string, err error) { +func parseRemoteURL(url string) (owner, name, hostname string, err error) { url = strings.TrimSpace(url) // Remove .git suffix url = strings.TrimSuffix(url, ".git") // Pattern for HTTPS URLs: https://host/owner/name - httpsRegex := regexp.MustCompile(`https?://[^/]+/([^/]+)/([^/]+)`) - if matches := httpsRegex.FindStringSubmatch(url); len(matches) == 3 { - return matches[1], matches[2], nil + httpsRegex := regexp.MustCompile(`https?://([^/]+)/([^/]+)/([^/]+)`) + if matches := httpsRegex.FindStringSubmatch(url); len(matches) == 4 { + return matches[2], matches[3], matches[1], nil } // Pattern for SSH URLs: git@host:owner/name - sshRegex := regexp.MustCompile(`git@[^:]+:([^/]+)/(.+)`) - if matches := sshRegex.FindStringSubmatch(url); len(matches) == 3 { - return matches[1], matches[2], nil + sshRegex := regexp.MustCompile(`git@([^:]+):([^/]+)/(.+)`) + if matches := sshRegex.FindStringSubmatch(url); len(matches) == 4 { + return matches[2], matches[3], matches[1], nil } // Pattern for SSH URLs with protocol: ssh://git@host/owner/name - sshProtocolRegex := regexp.MustCompile(`ssh://git@[^/]+/([^/]+)/(.+)`) - if matches := sshProtocolRegex.FindStringSubmatch(url); len(matches) == 3 { - return matches[1], matches[2], nil + sshProtocolRegex := regexp.MustCompile(`ssh://(?:git@)?([^/]+)/([^/]+)/(.+)`) + if matches := sshProtocolRegex.FindStringSubmatch(url); len(matches) == 4 { + return matches[2], matches[3], matches[1], nil } - return "", "", fmt.Errorf("unable to parse repository from URL: %s", url) + return "", "", "", fmt.Errorf("unable to parse repository from URL: %s", url) } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index da7b90a..76d8764 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,56 +1,67 @@ package git -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestParseRemoteURL(t *testing.T) { tests := []struct { - name string - url string + name string + url string wantOwner string wantName string - wantErr bool + wantHost string + wantErr bool }{ { - name: "HTTPS URL with .git", - url: "https://codeberg.org/romaintb/fgj.git", + name: "HTTPS URL with .git", + url: "https://codeberg.org/romaintb/fgj.git", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "HTTPS URL without .git", - url: "https://codeberg.org/romaintb/fgj", + name: "HTTPS URL without .git", + url: "https://codeberg.org/romaintb/fgj", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "SSH URL with .git", - url: "git@codeberg.org:romaintb/fgj.git", + name: "SSH URL with .git", + url: "git@codeberg.org:romaintb/fgj.git", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "SSH URL without .git", - url: "git@codeberg.org:romaintb/fgj", + name: "SSH URL without .git", + url: "git@codeberg.org:romaintb/fgj", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "SSH protocol URL", - url: "ssh://git@codeberg.org/romaintb/fgj.git", + name: "SSH protocol URL", + url: "ssh://git@codeberg.org/romaintb/fgj.git", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "GitHub HTTPS URL", - url: "https://github.com/user/repo.git", + name: "GitHub HTTPS URL", + url: "https://github.com/user/repo.git", wantOwner: "user", wantName: "repo", - wantErr: false, + wantHost: "github.com", + wantErr: false, }, { name: "Invalid URL", @@ -63,56 +74,60 @@ func TestParseRemoteURL(t *testing.T) { wantErr: true, }, { - name: "URL with trailing whitespace", - url: " https://codeberg.org/owner/repo.git ", + name: "URL with trailing whitespace", + url: " https://codeberg.org/owner/repo.git ", wantOwner: "owner", wantName: "repo", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "URL with port number", - url: "https://git.example.com:443/owner/repo.git", + name: "URL with port number", + url: "https://git.example.com:443/owner/repo.git", wantOwner: "owner", wantName: "repo", - wantErr: false, + wantHost: "git.example.com:443", + wantErr: false, }, { - name: "SSH URL with port parses incorrectly", - url: "ssh://git@git.example.com:22/owner/repo.git", - // Note: This currently parses as owner="22" name="owner/repo" - // which is incorrect but the regex matches. We document this - // limitation rather than make the test fail. + name: "SSH URL with port parses incorrectly", + url: "ssh://git@git.example.com:22/owner/repo.git", wantOwner: "22", wantName: "owner/repo", - wantErr: false, + wantHost: "git.example.com", + wantErr: false, }, { - name: "HTTP URL (not HTTPS)", - url: "http://codeberg.org/owner/repo", + name: "HTTP URL (not HTTPS)", + url: "http://codeberg.org/owner/repo", wantOwner: "owner", wantName: "repo", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "Repo name with dashes", - url: "https://codeberg.org/owner/my-cool-repo.git", + name: "Repo name with dashes", + url: "https://codeberg.org/owner/my-cool-repo.git", wantOwner: "owner", wantName: "my-cool-repo", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "Repo name with dots", - url: "https://codeberg.org/owner/my.repo.name.git", + name: "Repo name with dots", + url: "https://codeberg.org/owner/my.repo.name.git", wantOwner: "owner", wantName: "my.repo.name", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "Owner with dots", - url: "https://codeberg.org/owner.name/repo.git", + name: "Owner with dots", + url: "https://codeberg.org/owner.name/repo.git", wantOwner: "owner.name", wantName: "repo", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { name: "Missing owner/repo", @@ -128,7 +143,7 @@ func TestParseRemoteURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - owner, name, err := parseRemoteURL(tt.url) + owner, name, host, err := parseRemoteURL(tt.url) if (err != nil) != tt.wantErr { t.Errorf("parseRemoteURL() error = %v, wantErr %v", err, tt.wantErr) return @@ -140,6 +155,112 @@ func TestParseRemoteURL(t *testing.T) { if name != tt.wantName { t.Errorf("parseRemoteURL() name = %v, want %v", name, tt.wantName) } + if host != tt.wantHost { + t.Errorf("parseRemoteURL() host = %v, want %v", host, tt.wantHost) + } + } + }) + } +} + +func TestDetectHost(t *testing.T) { + tests := []struct { + name string + gitConfig string + wantHost string + wantErr bool + }{ + { + name: "HTTPS URL", + gitConfig: `[core] + repositoryformatversion = 0 +[remote "origin"] + url = https://codeberg.org/owner/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`, + wantHost: "codeberg.org", + wantErr: false, + }, + { + name: "SSH URL", + gitConfig: `[core] + repositoryformatversion = 0 +[remote "origin"] + url = git@codeberg.org:owner/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`, + wantHost: "codeberg.org", + wantErr: false, + }, + { + name: "HTTPS URL with port", + gitConfig: `[core] + repositoryformatversion = 0 +[remote "origin"] + url = https://git.example.com:443/owner/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`, + wantHost: "git.example.com:443", + wantErr: false, + }, + { + name: "SSH protocol URL", + gitConfig: `[core] + repositoryformatversion = 0 +[remote "origin"] + url = ssh://git@codeberg.org/owner/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`, + wantHost: "codeberg.org", + wantErr: false, + }, + { + name: "No origin remote", + gitConfig: `[core] + repositoryformatversion = 0 +[remote "upstream"] + url = https://codeberg.org/owner/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`, + wantHost: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatalf("Failed to create .git directory: %v", err) + } + + configPath := filepath.Join(gitDir, "config") + if err := os.WriteFile(configPath, []byte(tt.gitConfig), 0644); err != nil { + t.Fatalf("Failed to write git config: %v", err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Logf("Failed to change directory back: %v", err) + } + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + host, err := DetectHost() + if (err != nil) != tt.wantErr { + t.Errorf("DetectHost() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && host != tt.wantHost { + t.Errorf("DetectHost() host = %v, want %v", host, tt.wantHost) } }) } From a8cd5c99165805003e767e8d1c44e18675f571c6 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 9 Jan 2026 13:41:41 +0100 Subject: [PATCH 11/54] docs: try repology --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 9108552..437b8e2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,18 @@ go install codeberg.org/romaintb/fgj@latest We'd love your help packaging `fgj` for other distributions! If you're interested in creating packages for Debian, Ubuntu, Fedora, or any packaging systems, please open an issue or reach out. +## Package Version Matrix + +Track `fgj` versions across different package repositories: + +| Repository | Version | Status | +|-----------|---------|--------| +| Arch (AUR) | [![Arch](https://repology.org/badge/version-for-repo/arch/fgj.svg?header=)](https://repology.org/project/fgj/versions) | Maintained | +| Homebrew | [![Homebrew](https://repology.org/badge/version-for-repo/homebrew/fgj.svg?header=)](https://repology.org/project/fgj/versions) | Maintained | +| Void Linux | [![Void](https://repology.org/badge/version-for-repo/void_linux/fgj.svg?header=)](https://repology.org/project/fgj/versions) | In Development | + +For a complete list of all distributions and versions, see [fgj on Repology](https://repology.org/project/fgj/versions). + ## Quick Start ### 1. Authenticate From 52177d9f9e07ce98d429887488ea93c3b4bcaf4a Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 9 Jan 2026 13:44:13 +0100 Subject: [PATCH 12/54] docs: enhance the repology "widget" --- README.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 437b8e2..c421d3c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ ## Installation +[![Packaging status](https://repology.org/badge/vertical-allrepos/fgj.svg)](https://repology.org/project/fgj/versions) + ### Arch Linux (AUR) `fgj` is available in the Arch User Repository: @@ -44,18 +46,6 @@ go install codeberg.org/romaintb/fgj@latest We'd love your help packaging `fgj` for other distributions! If you're interested in creating packages for Debian, Ubuntu, Fedora, or any packaging systems, please open an issue or reach out. -## Package Version Matrix - -Track `fgj` versions across different package repositories: - -| Repository | Version | Status | -|-----------|---------|--------| -| Arch (AUR) | [![Arch](https://repology.org/badge/version-for-repo/arch/fgj.svg?header=)](https://repology.org/project/fgj/versions) | Maintained | -| Homebrew | [![Homebrew](https://repology.org/badge/version-for-repo/homebrew/fgj.svg?header=)](https://repology.org/project/fgj/versions) | Maintained | -| Void Linux | [![Void](https://repology.org/badge/version-for-repo/void_linux/fgj.svg?header=)](https://repology.org/project/fgj/versions) | In Development | - -For a complete list of all distributions and versions, see [fgj on Repology](https://repology.org/project/fgj/versions). - ## Quick Start ### 1. Authenticate From fc699e9718005c5d2da68e17584e434607294e32 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 9 Jan 2026 13:49:41 +0100 Subject: [PATCH 13/54] release: prepare for v0.2.0 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ cmd/root.go | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6edd83..75ad76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-01-09 + +### Added + +#### Release Management +- `fgj release list` - List releases for a repository +- `fgj release view` - View details of a specific release (supports "latest" keyword) +- `fgj release create` - Create new releases with optional asset uploads +- `fgj release upload` - Upload assets to existing releases with optional clobber support +- `fgj release delete` - Delete releases (preserves Git tags) + +#### Issue Management +- `fgj issue edit` - Edit existing issues with support for updating title, body, and labels + +#### Pull Request Management +- `fgj pr create --assignee` - Assign users when creating pull requests + +#### Repository Detection +- Automatic hostname detection from git remote URLs +- Improved multi-instance support with auto-detection from git context + +### Improved +- Enhanced documentation with AUR and Homebrew installation instructions +- Added functional tests for release management and issue editing +- Added Makefile `install` target for easier local installation +- Added `.editorconfig` for consistent code formatting + +### Development +- CI: Added nightly builds for continuous testing +- Expanded functional test coverage for new features + ## [0.1.0] - 2025-12-16 ### Added @@ -65,4 +96,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cobra framework for CLI structure - Viper for configuration management +[0.2.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.2.0 [0.1.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.1.0 diff --git a/cmd/root.go b/cmd/root.go index 89d3b7d..c1a373b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,7 +17,7 @@ var rootCmd = &cobra.Command{ 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", + Version: "0.2.0", } func Execute() error { From 900bd4ea97276054041cdeb57511a9a8293e439c Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 16 Jan 2026 10:03:49 +0100 Subject: [PATCH 14/54] feat: allow passing a comment when closing an issue --- README.md | 3 ++ cmd/issue.go | 11 ++++++ tests/functional/functional_test.go | 60 +++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/README.md b/README.md index c421d3c..6d50d40 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,9 @@ fgj issue comment 456 -b "My comment" # Close an issue fgj issue close 456 + +# Close an issue with a comment +fgj issue close 456 -c "Fixed in v2.0" ``` ### Repositories diff --git a/cmd/issue.go b/cmd/issue.go index 4caf5bf..5537105 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -87,6 +87,7 @@ func init() { issueCommentCmd.Flags().StringP("body", "b", "", "Comment body") issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing") issueEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue") @@ -281,6 +282,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error { func runIssueClose(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") + commentBody, _ := cmd.Flags().GetString("comment") issueNumber, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return fmt.Errorf("invalid issue number: %w", err) @@ -301,6 +303,15 @@ func runIssueClose(cmd *cobra.Command, args []string) error { return err } + if commentBody != "" { + _, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ + Body: commentBody, + }) + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + } + stateClosed := gitea.StateClosed _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ State: &stateClosed, diff --git a/tests/functional/functional_test.go b/tests/functional/functional_test.go index 7fed7a1..6dee28b 100644 --- a/tests/functional/functional_test.go +++ b/tests/functional/functional_test.go @@ -304,6 +304,66 @@ func TestCLIIssueClose(t *testing.T) { t.Logf("Successfully tested issue close via API for CLI #%d", issueNum) } +// TestCLIIssueCloseWithComment verifies the `fgj issue close -c` command works +func TestCLIIssueCloseWithComment(t *testing.T) { + env := NewTestEnv(t) + + // Create an issue via API + issueNum := env.CreateTestIssue( + "[FGJ CLI Test] Close with comment", + "This issue will be closed with a comment in one command", + ) + + commentText := "Fixed in v2.0 - closing via functional test" + + // Close with comment via CLI + result := env.RunCLI( + "--hostname", env.Hostname, + "issue", "close", + fmt.Sprintf("%d", issueNum), + "-c", commentText, + ) + + if result.ExitCode != 0 { + t.Fatalf("issue close -c failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + // Verify the issue was closed + issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.State != "closed" { + t.Fatalf("expected issue state 'closed', got '%s'", issue.State) + } + + // Verify the comment was added + comments, _, err := env.Client.ListIssueComments(env.Owner, env.RepoName, issueNum, gitea.ListIssueCommentOptions{}) + if err != nil { + t.Fatalf("failed to list issue comments: %v", err) + } + + if len(comments) == 0 { + t.Fatalf("expected at least one comment, got none") + } + + // Find our comment + found := false + for _, comment := range comments { + if comment.Body == commentText { + found = true + break + } + } + + if !found { + t.Fatalf("comment '%s' not found in issue comments", commentText) + } + + t.Logf("Successfully tested issue close with comment via CLI #%d", issueNum) +} + // TestEditIssueTitle verifies we can edit an issue's title func TestEditIssueTitle(t *testing.T) { env := NewTestEnv(t) From 79df4eb7805f06c06e25c8dda6323e5b8de6cfb5 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 16 Jan 2026 10:51:37 +0100 Subject: [PATCH 15/54] feat: implement workflow list/view/run --- README.md | 54 +++++++ cmd/actions.go | 313 +++++++++++++++++++++++++++++++++++++++++ cmd/actions_test.go | 122 ++++++++++++++++ internal/api/client.go | 52 +++++++ 4 files changed, 541 insertions(+) create mode 100644 cmd/actions_test.go diff --git a/README.md b/README.md index 6d50d40..e4f43a7 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,21 @@ fgj release delete v1.2.3 ### Forgejo Actions ```bash +# List workflows +fgj actions workflow list + +# View a workflow +fgj actions workflow view ci.yml + +# Run a workflow (trigger workflow_dispatch) +fgj actions workflow run deploy.yml + +# Run a workflow with inputs +fgj actions workflow run deploy.yml -f environment=production -f version=1.2.3 + +# Run a workflow on a specific branch +fgj actions workflow run deploy.yml -r feature-branch + # List workflow runs fgj actions run list @@ -324,6 +339,45 @@ fgj/ Contributions are welcome! Please feel free to submit a Pull Request. +## Missing Features / Roadmap + +`fgj` aims to be a drop-in replacement for `gh` when working with Forgejo instances. While we've implemented the core features, some `gh` commands are not yet available: + +### Forgejo Actions / Workflows + +**Implemented:** +- ✅ `workflow list` - List all workflows +- ✅ `workflow view` - View workflow details and latest run +- ✅ `workflow run` - Trigger workflow_dispatch with inputs and ref support +- ✅ `run list` - List workflow runs +- ✅ `run view` - View run details, jobs, and logs +- ✅ `secret list/create/delete` - Manage repository secrets +- ✅ `variable list/get/create/update/delete` - Manage repository variables + +**Not Yet Implemented:** +- ❌ `workflow enable/disable` - Enable or disable workflows +- ❌ `run watch` - Follow a workflow run in real-time +- ❌ `run rerun` - Rerun entire run, failed jobs, or specific jobs +- ❌ `run cancel` - Cancel a running workflow +- ❌ `run delete` - Delete a workflow run +- ❌ `run download` - Download workflow run artifacts +- ❌ Organization-level secrets and variables + +### Other GitHub CLI Features + +Some other `gh` features that could be added in the future: +- ❌ `gh gist` - Gist management (if Forgejo adds gist support) +- ❌ `gh project` - Project board management +- ❌ `gh label` - Label management +- ❌ `gh milestone` - Milestone management +- ❌ `gh ssh-key` - SSH key management +- ❌ `gh gpg-key` - GPG key management +- ❌ `gh org` - Organization management +- ❌ `gh codespace` - Codespace management (N/A for Forgejo) +- ❌ `gh extension` - Extension management + +We welcome contributions to implement any of these features! Please check the issues or create a new one to discuss implementation before starting work. + ## License MIT License diff --git a/cmd/actions.go b/cmd/actions.go index bedb839..53f5a58 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -58,6 +58,28 @@ type ActionTaskList struct { TotalCount int `json:"total_count"` } +// Workflow represents a workflow definition +type Workflow struct { + ID int64 `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + State string `json:"state"` +} + +// WorkflowList represents a list of workflows +type WorkflowList struct { + Workflows []Workflow `json:"workflows"` + TotalCount int `json:"total_count"` +} + +// ContentsResponse represents a file/directory in the repository +type ContentsResponse struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Size int64 `json:"size"` +} + var actionsCmd = &cobra.Command{ Use: "actions", Aliases: []string{"action"}, @@ -87,6 +109,36 @@ var runViewCmd = &cobra.Command{ RunE: runRunView, } +// Workflow commands +var workflowCmd = &cobra.Command{ + Use: "workflow", + Short: "Manage workflows", + Long: "List, view, and run workflows.", +} + +var workflowListCmd = &cobra.Command{ + Use: "list", + Short: "List workflows", + Long: "List all workflows in a repository.", + RunE: runWorkflowList, +} + +var workflowViewCmd = &cobra.Command{ + Use: "view ", + Short: "View a workflow", + Long: "View details about a specific workflow. You can specify the workflow by name or filename.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowView, +} + +var workflowRunCmd = &cobra.Command{ + Use: "run ", + Short: "Run a workflow", + Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowRun, +} + // Secret commands var actionsSecretCmd = &cobra.Command{ Use: "secret", @@ -171,6 +223,12 @@ func init() { runCmd.AddCommand(runListCmd) runCmd.AddCommand(runViewCmd) + // Add workflow commands (gh workflow compatible) + actionsCmd.AddCommand(workflowCmd) + workflowCmd.AddCommand(workflowListCmd) + workflowCmd.AddCommand(workflowViewCmd) + workflowCmd.AddCommand(workflowRunCmd) + // Add secret commands actionsCmd.AddCommand(actionsSecretCmd) actionsSecretCmd.AddCommand(actionsSecretListCmd) @@ -194,6 +252,15 @@ func init() { runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run") runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job") + // Add flags for workflow commands + addRepoFlags(workflowListCmd) + workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") + addRepoFlags(workflowViewCmd) + addRepoFlags(workflowRunCmd) + workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") + workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") + workflowRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)") + // Add flags for secret commands addRepoFlags(actionsSecretListCmd) addRepoFlags(actionsSecretCreateCmd) @@ -499,6 +566,252 @@ func formatTimeSince(t time.Time) string { return fmt.Sprintf("%d days ago", days) } +// Workflow command implementations + +func runWorkflowList(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + limit, _ := cmd.Flags().GetInt("limit") + + // List workflows from both .gitea/workflows and .forgejo/workflows + var allWorkflows []Workflow + + for _, dir := range []string{".gitea/workflows", ".forgejo/workflows"} { + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, name, dir) + + var contents []ContentsResponse + if err := client.GetJSON(endpoint, &contents); err != nil { + // Directory might not exist, continue + continue + } + + for _, content := range contents { + if content.Type == "file" && (len(content.Name) > 4 && (content.Name[len(content.Name)-4:] == ".yml" || content.Name[len(content.Name)-5:] == ".yaml")) { + workflow := Workflow{ + Name: content.Name, + Path: content.Path, + State: "active", + } + allWorkflows = append(allWorkflows, workflow) + + if len(allWorkflows) >= limit { + break + } + } + } + + if len(allWorkflows) >= limit { + break + } + } + + if len(allWorkflows) == 0 { + fmt.Println("No workflows found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + + for _, workflow := range allWorkflows { + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", + workflow.Name, workflow.State, workflow.Path); err != nil { + return fmt.Errorf("failed to write workflow: %w", err) + } + } + + if err := w.Flush(); err != nil { + return fmt.Errorf("failed to flush output: %w", err) + } + + return nil +} + +func runWorkflowView(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + workflowIdentifier := args[0] + + // Find the workflow by listing from both .gitea/workflows and .forgejo/workflows + var workflow *Workflow + + for _, dir := range []string{".gitea/workflows", ".forgejo/workflows"} { + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, name, dir) + + var contents []ContentsResponse + if err := client.GetJSON(endpoint, &contents); err != nil { + // Directory might not exist, continue + continue + } + + for _, content := range contents { + if content.Type == "file" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) { + workflow = &Workflow{ + Name: content.Name, + Path: content.Path, + State: "active", + } + break + } + } + + if workflow != nil { + break + } + } + + if workflow == nil { + return fmt.Errorf("workflow '%s' not found", workflowIdentifier) + } + + // Display workflow information + fmt.Printf("Name: %s\n", workflow.Name) + fmt.Printf("Path: %s\n", workflow.Path) + fmt.Printf("State: %s\n", workflow.State) + + // Get the latest run for this workflow + runsEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?workflow_id=%s&limit=1", owner, name, workflow.Path) + var runList ActionRunList + if err := client.GetJSON(runsEndpoint, &runList); err != nil { + // If we can't get runs, just display workflow info without latest run + return nil + } + + if len(runList.WorkflowRuns) > 0 { + run := runList.WorkflowRuns[0] + fmt.Printf("\nLatest run:\n") + fmt.Printf(" Status: %s\n", formatStatus(run.Status)) + fmt.Printf(" Event: %s\n", run.Event) + fmt.Printf(" Ref: %s\n", run.PrettyRef) + if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { + fmt.Printf(" Created: %s\n", formatTimeSince(createdTime)) + } + } + + return nil +} + +func runWorkflowRun(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + workflowIdentifier := args[0] + ref, _ := cmd.Flags().GetString("ref") + fields, _ := cmd.Flags().GetStringSlice("field") + rawFields, _ := cmd.Flags().GetStringSlice("raw-field") + + // If no ref is specified, get the repository's default branch + if ref == "" { + repoInfo, _, err := client.GetRepo(owner, name) + if err != nil { + return fmt.Errorf("failed to get repository info: %w", err) + } + ref = repoInfo.DefaultBranch + } + + // Build the inputs map + inputs := make(map[string]string) + + // Process -f/--field flags + for _, field := range fields { + parts := splitKeyValue(field) + if len(parts) == 2 { + inputs[parts[0]] = parts[1] + } + } + + // Process -F/--raw-field flags (same as field for now, file reading can be added later) + for _, field := range rawFields { + parts := splitKeyValue(field) + if len(parts) == 2 { + inputs[parts[0]] = parts[1] + } + } + + // Prepare the dispatch request + dispatchReq := map[string]any{ + "ref": ref, + } + if len(inputs) > 0 { + dispatchReq["inputs"] = inputs + } + + // Trigger the workflow + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, name, workflowIdentifier) + + if err := client.PostJSON(endpoint, dispatchReq, nil); err != nil { + return fmt.Errorf("failed to trigger workflow: %w", err) + } + + fmt.Printf("✓ Workflow '%s' triggered successfully\n", workflowIdentifier) + fmt.Printf(" Branch/Tag: %s\n", ref) + if len(inputs) > 0 { + fmt.Println(" Inputs:") + for key, value := range inputs { + fmt.Printf(" %s: %s\n", key, value) + } + } + + return nil +} + +func splitKeyValue(s string) []string { + idx := -1 + for i, c := range s { + if c == '=' { + idx = i + break + } + } + if idx == -1 { + return []string{s} + } + return []string{s[:idx], s[idx+1:]} +} + // Secret command implementations func runActionsSecretList(cmd *cobra.Command, args []string) error { diff --git a/cmd/actions_test.go b/cmd/actions_test.go new file mode 100644 index 0000000..f7d404e --- /dev/null +++ b/cmd/actions_test.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "reflect" + "testing" +) + +func TestSplitKeyValue(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "valid key=value", + input: "environment=production", + expected: []string{"environment", "production"}, + }, + { + name: "key with empty value", + input: "key=", + expected: []string{"key", ""}, + }, + { + name: "value with equals sign", + input: "url=https://example.com?foo=bar", + expected: []string{"url", "https://example.com?foo=bar"}, + }, + { + name: "no equals sign", + input: "invalid", + expected: []string{"invalid"}, + }, + { + name: "multiple equals signs", + input: "a=b=c=d", + expected: []string{"a", "b=c=d"}, + }, + { + name: "empty string", + input: "", + expected: []string{""}, + }, + { + name: "just equals sign", + input: "=", + expected: []string{"", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := splitKeyValue(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("splitKeyValue(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestFormatStatus(t *testing.T) { + tests := []struct { + name string + status string + expected string + }{ + { + name: "success status", + status: "success", + expected: "✓ success", + }, + { + name: "failure status", + status: "failure", + expected: "✗ failure", + }, + { + name: "cancelled status", + status: "cancelled", + expected: "- cancelled", + }, + { + name: "skipped status", + status: "skipped", + expected: "○ skipped", + }, + { + name: "in_progress status", + status: "in_progress", + expected: "● in progress", + }, + { + name: "running status", + status: "running", + expected: "● in progress", + }, + { + name: "queued status", + status: "queued", + expected: "○ queued", + }, + { + name: "waiting status", + status: "waiting", + expected: "○ queued", + }, + { + name: "unknown status", + status: "unknown", + expected: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatStatus(tt.status) + if result != tt.expected { + t.Errorf("formatStatus(%q) = %q, want %q", tt.status, result, tt.expected) + } + }) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index dd180b9..93904c9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/json" "fmt" "io" @@ -85,6 +86,57 @@ func (c *Client) GetJSON(path string, result any) error { return nil } +// PostJSON performs a POST request to the specified path with JSON body +func (c *Client) PostJSON(path string, body any, result any) error { + baseURL := "https://" + c.hostname + url := baseURL + path + + var bodyReader io.Reader + if body != nil { + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequest(http.MethodPost, url, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set authentication header + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to perform request: %w", err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close response body: %w", closeErr) + } + }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + if result != nil && resp.StatusCode != http.StatusNoContent { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} + // GetRawLog performs a GET request and returns the raw response body as string func (c *Client) GetRawLog(url string) (string, error) { req, err := http.NewRequest(http.MethodGet, url, nil) From d4e76e193f09865e04286f8df87d4c31ee34c548 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 16 Jan 2026 10:56:05 +0100 Subject: [PATCH 16/54] docs: less verbose todo list in README.md --- README.md | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e4f43a7..f16663e 100644 --- a/README.md +++ b/README.md @@ -345,36 +345,13 @@ Contributions are welcome! Please feel free to submit a Pull Request. ### Forgejo Actions / Workflows -**Implemented:** -- ✅ `workflow list` - List all workflows -- ✅ `workflow view` - View workflow details and latest run -- ✅ `workflow run` - Trigger workflow_dispatch with inputs and ref support -- ✅ `run list` - List workflow runs -- ✅ `run view` - View run details, jobs, and logs -- ✅ `secret list/create/delete` - Manage repository secrets -- ✅ `variable list/get/create/update/delete` - Manage repository variables - **Not Yet Implemented:** -- ❌ `workflow enable/disable` - Enable or disable workflows -- ❌ `run watch` - Follow a workflow run in real-time -- ❌ `run rerun` - Rerun entire run, failed jobs, or specific jobs -- ❌ `run cancel` - Cancel a running workflow -- ❌ `run delete` - Delete a workflow run -- ❌ `run download` - Download workflow run artifacts -- ❌ Organization-level secrets and variables - -### Other GitHub CLI Features - -Some other `gh` features that could be added in the future: -- ❌ `gh gist` - Gist management (if Forgejo adds gist support) -- ❌ `gh project` - Project board management -- ❌ `gh label` - Label management -- ❌ `gh milestone` - Milestone management -- ❌ `gh ssh-key` - SSH key management -- ❌ `gh gpg-key` - GPG key management -- ❌ `gh org` - Organization management -- ❌ `gh codespace` - Codespace management (N/A for Forgejo) -- ❌ `gh extension` - Extension management +- `workflow enable/disable` - Enable or disable workflows +- `run watch` - Follow a workflow run in real-time +- `run rerun` - Rerun entire run, failed jobs, or specific jobs +- `run cancel` - Cancel a running workflow +- `run delete` - Delete a workflow run +- `run download` - Download workflow run artifacts We welcome contributions to implement any of these features! Please check the issues or create a new one to discuss implementation before starting work. From 7bb540bd11125ba9894ead0690a57aafe43f6279 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sun, 18 Jan 2026 11:45:01 +0100 Subject: [PATCH 17/54] feat: add completion and manpage commands --- cmd/completion.go | 37 +++++++++++++++++++++++++++++++++++ cmd/manpages.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 cmd/completion.go create mode 100644 cmd/manpages.go diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..6ead4b8 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: "Generate shell completion scripts for fgj.", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + var out io.Writer = os.Stdout + switch args[0] { + case "bash": + return rootCmd.GenBashCompletion(out) + case "zsh": + return rootCmd.GenZshCompletion(out) + case "fish": + return rootCmd.GenFishCompletion(out, true) + case "powershell": + return rootCmd.GenPowerShellCompletionWithDesc(out) + default: + return fmt.Errorf("unsupported shell: %s", args[0]) + } + }, +} + +func init() { + rootCmd.AddCommand(completionCmd) +} diff --git a/cmd/manpages.go b/cmd/manpages.go new file mode 100644 index 0000000..5fc7e10 --- /dev/null +++ b/cmd/manpages.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" +) + +var manpagesCmd = &cobra.Command{ + Use: "manpages", + Short: "Generate manpages", + Long: "Generate manpages for fgj commands.", + RunE: func(cmd *cobra.Command, args []string) error { + dir, _ := cmd.Flags().GetString("dir") + if dir == "" { + return fmt.Errorf("directory is required") + } + + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create %s: %w", dir, err) + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", dir, err) + } + + header := &doc.GenManHeader{ + Title: "FGJ", + Section: "1", + } + + if err := doc.GenManTree(rootCmd, header, absDir); err != nil { + return fmt.Errorf("failed to generate manpages: %w", err) + } + + fmt.Printf("Manpages generated in %s\n", absDir) + return nil + }, +} + +func init() { + rootCmd.AddCommand(manpagesCmd) + manpagesCmd.Flags().String("dir", "", "Output directory for manpages") + _ = manpagesCmd.MarkFlagRequired("dir") +} From fe23f2fce39ca30410fac5576305ad800fcc25c3 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sun, 18 Jan 2026 11:45:46 +0100 Subject: [PATCH 18/54] feat: add auth logout and token helpers --- cmd/auth.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index d52732f..0b2ef06 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -7,9 +7,10 @@ import ( "strings" "syscall" - "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" + "github.com/spf13/viper" "golang.org/x/term" ) @@ -33,13 +34,31 @@ var authStatusCmd = &cobra.Command{ RunE: runAuthStatus, } +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove authentication for a Forgejo instance", + Long: "Remove authentication for a configured Forgejo instance.", + RunE: runAuthLogout, +} + +var authTokenCmd = &cobra.Command{ + Use: "token", + Short: "Print the stored authentication token", + Long: "Print the stored authentication token for a configured Forgejo instance.", + RunE: runAuthToken, +} + func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authStatusCmd) + authCmd.AddCommand(authLogoutCmd) + authCmd.AddCommand(authTokenCmd) authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") authLoginCmd.Flags().StringP("token", "t", "", "Personal access token") + authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") + authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") } func runAuthLogin(cmd *cobra.Command, args []string) error { @@ -87,9 +106,9 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { } cfg.SetHost(hostname, config.HostConfig{ - Hostname: hostname, - Token: token, - User: user.UserName, + Hostname: hostname, + Token: token, + User: user.UserName, GitProtocol: "https", }) @@ -121,3 +140,66 @@ func runAuthStatus(cmd *cobra.Command, args []string) error { return nil } + +func runAuthLogout(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + hostname, _ := cmd.Flags().GetString("hostname") + resolved, err := resolveAuthHostname(cfg, hostname) + if err != nil { + return err + } + + delete(cfg.Hosts, resolved) + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("✓ Logged out from %s\n", resolved) + return nil +} + +func runAuthToken(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + hostname, _ := cmd.Flags().GetString("hostname") + resolved, err := resolveAuthHostname(cfg, hostname) + if err != nil { + return err + } + + fmt.Println(cfg.Hosts[resolved].Token) + return nil +} + +func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) { + if hostname == "" { + hostname = viper.GetString("hostname") + } + if hostname == "" { + hostname = os.Getenv("FGJ_HOST") + } + if hostname == "" { + hostname = getDetectedHost() + } + if hostname == "" && len(cfg.Hosts) == 1 { + for host := range cfg.Hosts { + hostname = host + } + } + if hostname == "" { + hostname = "codeberg.org" + } + + if _, ok := cfg.Hosts[hostname]; !ok { + return "", fmt.Errorf("no configuration found for host %s", hostname) + } + + return hostname, nil +} From 3ccef4e1c6857d23a3bb4a778066c362fe6f2e25 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sun, 18 Jan 2026 11:48:08 +0100 Subject: [PATCH 19/54] feat: add json output for list and view commands --- cmd/actions.go | 152 ++++++++++++++++++++++++++++++++++++------------- cmd/issue.go | 41 ++++++++++--- cmd/json.go | 12 ++++ cmd/pr.go | 13 ++++- cmd/release.go | 26 +++++++-- 5 files changed, 192 insertions(+), 52 deletions(-) create mode 100644 cmd/json.go diff --git a/cmd/actions.go b/cmd/actions.go index 53f5a58..8d7aa28 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -16,17 +16,17 @@ import ( // ActionRun represents a workflow run type ActionRun struct { - ID int64 `json:"id"` - Title string `json:"title"` - WorkflowID string `json:"workflow_id"` - IndexInRepo int64 `json:"index_in_repo"` - Event string `json:"event"` - Status string `json:"status"` - CommitSHA string `json:"commit_sha"` - PrettyRef string `json:"prettyref"` - Created string `json:"created"` - Updated string `json:"updated"` - Started string `json:"started"` + ID int64 `json:"id"` + Title string `json:"title"` + WorkflowID string `json:"workflow_id"` + IndexInRepo int64 `json:"index_in_repo"` + Event string `json:"event"` + Status string `json:"status"` + CommitSHA string `json:"commit_sha"` + PrettyRef string `json:"prettyref"` + Created string `json:"created"` + Updated string `json:"updated"` + Started string `json:"started"` } // ActionRunList represents a list of workflow runs @@ -37,19 +37,19 @@ type ActionRunList struct { // ActionTask represents a job/task within a workflow run type ActionTask struct { - ID int64 `json:"id"` - Name string `json:"name"` - HeadBranch string `json:"head_branch"` - HeadSHA string `json:"head_sha"` - RunNumber int64 `json:"run_number"` - Event string `json:"event"` - DisplayTitle string `json:"display_title"` - Status string `json:"status"` - WorkflowID string `json:"workflow_id"` - URL string `json:"url"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - RunStartedAt string `json:"run_started_at"` + ID int64 `json:"id"` + Name string `json:"name"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + RunNumber int64 `json:"run_number"` + Event string `json:"event"` + DisplayTitle string `json:"display_title"` + Status string `json:"status"` + WorkflowID string `json:"workflow_id"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + RunStartedAt string `json:"run_started_at"` } // ActionTaskList represents a list of tasks/jobs @@ -246,16 +246,20 @@ func init() { // Add flags for run commands addRepoFlags(runListCmd) runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") + runListCmd.Flags().Bool("json", false, "Output workflow runs as JSON") addRepoFlags(runViewCmd) runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps") runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job") runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run") runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job") + runViewCmd.Flags().Bool("json", false, "Output workflow run as JSON") // Add flags for workflow commands addRepoFlags(workflowListCmd) workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") + workflowListCmd.Flags().Bool("json", false, "Output workflows as JSON") addRepoFlags(workflowViewCmd) + workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") addRepoFlags(workflowRunCmd) workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") @@ -307,6 +311,10 @@ func runRunList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list runs: %w", err) } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(runList.WorkflowRuns) + } + if len(runList.WorkflowRuns) == 0 { fmt.Println("No workflow runs found") return nil @@ -364,6 +372,7 @@ func runRunView(cmd *cobra.Command, args []string) error { showLog, _ := cmd.Flags().GetBool("log") jobIDStr, _ := cmd.Flags().GetString("job") showLogFailed, _ := cmd.Flags().GetBool("log-failed") + jsonOutput, _ := cmd.Flags().GetBool("json") var jobID int64 if jobIDStr != "" { @@ -374,6 +383,10 @@ func runRunView(cmd *cobra.Command, args []string) error { } } + if jsonOutput && (showLog || showLogFailed) { + return fmt.Errorf("--json cannot be used with --log or --log-failed") + } + // Call the API endpoint directly since SDK doesn't have it yet endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID) @@ -382,6 +395,48 @@ func runRunView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get run: %w", err) } + needsJobs := verbose || showLog || showLogFailed || jobID > 0 + + if jsonOutput { + var runTasks []ActionTask + if needsJobs { + tasksEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", owner, name) + var taskList ActionTaskList + if err := client.GetJSON(tasksEndpoint, &taskList); err != nil { + return fmt.Errorf("failed to get tasks: %w", err) + } + + for _, task := range taskList.WorkflowRuns { + if task.RunNumber == run.IndexInRepo { + runTasks = append(runTasks, task) + } + } + + if jobID > 0 { + var filtered []ActionTask + for _, task := range runTasks { + if task.ID == jobID { + filtered = append(filtered, task) + break + } + } + if len(filtered) == 0 { + return fmt.Errorf("job %d not found in this run", jobID) + } + runTasks = filtered + } + } + + payload := struct { + Run ActionRun `json:"run"` + Tasks []ActionTask `json:"tasks,omitempty"` + }{ + Run: run, + Tasks: runTasks, + } + return writeJSON(payload) + } + // Display run information fmt.Printf("Title: %s\n", run.Title) fmt.Printf("Workflow: %s\n", run.WorkflowID) @@ -409,7 +464,6 @@ func runRunView(cmd *cobra.Command, args []string) error { } // Fetch jobs if needed for verbose, log, or job-specific views - needsJobs := verbose || showLog || showLogFailed || jobID > 0 if !needsJobs { return nil } @@ -620,10 +674,17 @@ func runWorkflowList(cmd *cobra.Command, args []string) error { } if len(allWorkflows) == 0 { + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(allWorkflows) + } fmt.Println("No workflows found") return nil } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(allWorkflows) + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil { return fmt.Errorf("failed to write header: %w", err) @@ -694,26 +755,39 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { return fmt.Errorf("workflow '%s' not found", workflowIdentifier) } + jsonOutput, _ := cmd.Flags().GetBool("json") + + var latestRun *ActionRun + + // Get the latest run for this workflow + runsEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?workflow_id=%s&limit=1", owner, name, workflow.Path) + var runList ActionRunList + if err := client.GetJSON(runsEndpoint, &runList); err == nil && len(runList.WorkflowRuns) > 0 { + latestRun = &runList.WorkflowRuns[0] + } + + if jsonOutput { + payload := struct { + Workflow *Workflow `json:"workflow"` + LatestRun *ActionRun `json:"latest_run,omitempty"` + }{ + Workflow: workflow, + LatestRun: latestRun, + } + return writeJSON(payload) + } + // Display workflow information fmt.Printf("Name: %s\n", workflow.Name) fmt.Printf("Path: %s\n", workflow.Path) fmt.Printf("State: %s\n", workflow.State) - // Get the latest run for this workflow - runsEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?workflow_id=%s&limit=1", owner, name, workflow.Path) - var runList ActionRunList - if err := client.GetJSON(runsEndpoint, &runList); err != nil { - // If we can't get runs, just display workflow info without latest run - return nil - } - - if len(runList.WorkflowRuns) > 0 { - run := runList.WorkflowRuns[0] + if latestRun != nil { fmt.Printf("\nLatest run:\n") - fmt.Printf(" Status: %s\n", formatStatus(run.Status)) - fmt.Printf(" Event: %s\n", run.Event) - fmt.Printf(" Ref: %s\n", run.PrettyRef) - if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { + fmt.Printf(" Status: %s\n", formatStatus(latestRun.Status)) + fmt.Printf(" Event: %s\n", latestRun.Event) + fmt.Printf(" Ref: %s\n", latestRun.PrettyRef) + if createdTime, err := time.Parse(time.RFC3339, latestRun.Created); err == nil { fmt.Printf(" Created: %s\n", formatTimeSince(createdTime)) } } diff --git a/cmd/issue.go b/cmd/issue.go index 5537105..d582d01 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -76,8 +76,10 @@ func init() { issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") + issueListCmd.Flags().Bool("json", false, "Output issues as JSON") issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueViewCmd.Flags().Bool("json", false, "Output issue as JSON") issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue") @@ -133,17 +135,26 @@ func runIssueList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list issues: %w", err) } - if len(issues) == 0 { + nonPRIssues := make([]*gitea.Issue, 0, len(issues)) + for _, issue := range issues { + if issue.PullRequest == nil { + nonPRIssues = append(nonPRIssues, issue) + } + } + + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(nonPRIssues) + } + + if len(nonPRIssues) == 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) - } + for _, issue := range nonPRIssues { + _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) } _ = w.Flush() @@ -177,6 +188,23 @@ func runIssueView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get issue: %w", err) } + var comments []*gitea.Comment + comments, _, err = client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{}) + if err != nil { + comments = nil + } + + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + payload := struct { + Issue *gitea.Issue `json:"issue"` + Comments []*gitea.Comment `json:"comments,omitempty"` + }{ + Issue: issue, + Comments: comments, + } + return writeJSON(payload) + } + fmt.Printf("Issue #%d\n", issue.Index) fmt.Printf("Title: %s\n", issue.Title) fmt.Printf("State: %s\n", issue.State) @@ -187,8 +215,7 @@ func runIssueView(cmd *cobra.Command, args []string) error { fmt.Printf("\n%s\n", issue.Body) } - comments, _, err := client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{}) - if err == nil && len(comments) > 0 { + if len(comments) > 0 { fmt.Printf("\nComments (%d):\n", len(comments)) for _, comment := range comments { fmt.Printf("\n---\n%s (@%s) - %s\n%s\n", diff --git a/cmd/json.go b/cmd/json.go new file mode 100644 index 0000000..21f1b25 --- /dev/null +++ b/cmd/json.go @@ -0,0 +1,12 @@ +package cmd + +import ( + "encoding/json" + "os" +) + +func writeJSON(value any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(value) +} diff --git a/cmd/pr.go b/cmd/pr.go index 9c15401..1f35638 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -8,9 +8,9 @@ import ( "text/tabwriter" "code.gitea.io/sdk/gitea" - "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" ) var prCmd = &cobra.Command{ @@ -59,8 +59,10 @@ func init() { prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") + prListCmd.Flags().Bool("json", false, "Output pull requests as JSON") prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prViewCmd.Flags().Bool("json", false, "Output pull request as JSON") prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request") @@ -111,6 +113,10 @@ func runPRList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list pull requests: %w", err) } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(prs) + } + if len(prs) == 0 { fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name) return nil @@ -153,6 +159,10 @@ func runPRView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get pull request: %w", err) } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(pr) + } + fmt.Printf("Pull Request #%d\n", pr.Index) fmt.Printf("Title: %s\n", pr.Title) fmt.Printf("State: %s\n", pr.State) @@ -279,4 +289,3 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return nil } - diff --git a/cmd/release.go b/cmd/release.go index 8c04768..8ad9413 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -72,8 +72,10 @@ func init() { releaseListCmd.Flags().Bool("draft", false, "Filter by draft status") releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status") releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch") + releaseListCmd.Flags().Bool("json", false, "Output releases as JSON") releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseViewCmd.Flags().Bool("json", false, "Output release as JSON") releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)") @@ -146,6 +148,10 @@ func runReleaseList(cmd *cobra.Command, args []string) error { releases = releases[:limit] } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(releases) + } + if len(releases) == 0 { fmt.Printf("No releases in %s/%s\n", owner, name) return nil @@ -186,6 +192,22 @@ func runReleaseView(cmd *cobra.Command, args []string) error { return err } + attachments, err := listReleaseAttachments(client, owner, name, release.ID) + if err != nil { + return err + } + + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + payload := struct { + Release *gitea.Release `json:"release"` + Assets []*gitea.Attachment `json:"assets,omitempty"` + }{ + Release: release, + Assets: attachments, + } + return writeJSON(payload) + } + fmt.Printf("Release %s\n", release.TagName) fmt.Printf("Title: %s\n", release.Title) fmt.Printf("Type: %s\n", releaseType(release)) @@ -206,10 +228,6 @@ func runReleaseView(cmd *cobra.Command, args []string) error { fmt.Printf("\n%s\n", release.Note) } - attachments, err := listReleaseAttachments(client, owner, name, release.ID) - if err != nil { - return err - } if len(attachments) > 0 { fmt.Printf("\nAssets (%d):\n", len(attachments)) for _, asset := range attachments { From c2ee338f1c4f448f5ff3f2deff0c1a3ef5c9560a Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sun, 18 Jan 2026 11:49:03 +0100 Subject: [PATCH 20/54] feat: add actions run watch rerun and cancel --- cmd/actions.go | 152 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/cmd/actions.go b/cmd/actions.go index 8d7aa28..1e48823 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -109,6 +109,30 @@ var runViewCmd = &cobra.Command{ RunE: runRunView, } +var runWatchCmd = &cobra.Command{ + Use: "watch ", + Short: "Watch a workflow run", + Long: "Poll a workflow run until it completes.", + Args: cobra.ExactArgs(1), + RunE: runRunWatch, +} + +var runRerunCmd = &cobra.Command{ + Use: "rerun ", + Short: "Rerun a workflow run", + Long: "Trigger a rerun for a specific workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunRerun, +} + +var runCancelCmd = &cobra.Command{ + Use: "cancel ", + Short: "Cancel a workflow run", + Long: "Cancel a running workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunCancel, +} + // Workflow commands var workflowCmd = &cobra.Command{ Use: "workflow", @@ -222,6 +246,9 @@ func init() { actionsCmd.AddCommand(runCmd) runCmd.AddCommand(runListCmd) runCmd.AddCommand(runViewCmd) + runCmd.AddCommand(runWatchCmd) + runCmd.AddCommand(runRerunCmd) + runCmd.AddCommand(runCancelCmd) // Add workflow commands (gh workflow compatible) actionsCmd.AddCommand(workflowCmd) @@ -253,6 +280,10 @@ func init() { runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run") runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job") runViewCmd.Flags().Bool("json", false, "Output workflow run as JSON") + addRepoFlags(runWatchCmd) + runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval") + addRepoFlags(runRerunCmd) + addRepoFlags(runCancelCmd) // Add flags for workflow commands addRepoFlags(workflowListCmd) @@ -545,6 +576,118 @@ func runRunView(cmd *cobra.Command, args []string) error { return nil } +func runRunWatch(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + runID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %w", err) + } + + interval, _ := cmd.Flags().GetDuration("interval") + if interval <= 0 { + return fmt.Errorf("interval must be greater than 0") + } + + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID) + + var lastStatus string + for { + var run ActionRun + if err := client.GetJSON(endpoint, &run); err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + + if run.Status != lastStatus { + fmt.Printf("Status: %s\n", formatStatus(run.Status)) + lastStatus = run.Status + } + + if isRunComplete(run.Status) { + fmt.Printf("Run #%d completed with status %s\n", run.IndexInRepo, formatStatus(run.Status)) + return nil + } + + time.Sleep(interval) + } +} + +func runRunRerun(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + runID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %w", err) + } + + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun", owner, name, runID) + if err := client.PostJSON(endpoint, nil, nil); err != nil { + return fmt.Errorf("failed to rerun workflow: %w", err) + } + + fmt.Printf("✓ Rerun requested for run %d\n", runID) + return nil +} + +func runRunCancel(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + runID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %w", err) + } + + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/cancel", owner, name, runID) + if err := client.PostJSON(endpoint, nil, nil); err != nil { + return fmt.Errorf("failed to cancel workflow run: %w", err) + } + + fmt.Printf("✓ Cancel requested for run %d\n", runID) + return nil +} + func showJobLog(client *api.Client, owner, name string, runNumber int64, task ActionTask, logFailed bool) error { // Fetch log from /repos/{owner}/{repo}/actions/runs/{run_number}/jobs/{job_id}/logs logURL := fmt.Sprintf("https://%s/%s/%s/actions/runs/%d/jobs/%d/logs", @@ -594,6 +737,15 @@ func formatStatus(status string) string { } } +func isRunComplete(status string) bool { + switch status { + case "success", "failure", "cancelled", "skipped": + return true + default: + return false + } +} + func formatTimeSince(t time.Time) string { duration := time.Since(t) From 4c6de3ad2e7bee6cd8309789fdc503acd0dc25b9 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sun, 18 Jan 2026 11:50:02 +0100 Subject: [PATCH 21/54] feat: add actions workflow enable and disable --- cmd/actions.go | 169 +++++++++++++++++++++++++++++++++-------- go.mod | 2 + go.sum | 2 + internal/api/client.go | 25 ++++-- 4 files changed, 159 insertions(+), 39 deletions(-) diff --git a/cmd/actions.go b/cmd/actions.go index 1e48823..f9d4685 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -2,8 +2,10 @@ package cmd import ( "fmt" + "net/http" "os" "strconv" + "strings" "text/tabwriter" "time" @@ -163,6 +165,22 @@ var workflowRunCmd = &cobra.Command{ RunE: runWorkflowRun, } +var workflowEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a workflow", + Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowEnable, +} + +var workflowDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a workflow", + Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowDisable, +} + // Secret commands var actionsSecretCmd = &cobra.Command{ Use: "secret", @@ -255,6 +273,8 @@ func init() { workflowCmd.AddCommand(workflowListCmd) workflowCmd.AddCommand(workflowViewCmd) workflowCmd.AddCommand(workflowRunCmd) + workflowCmd.AddCommand(workflowEnableCmd) + workflowCmd.AddCommand(workflowDisableCmd) // Add secret commands actionsCmd.AddCommand(actionsSecretCmd) @@ -292,6 +312,8 @@ func init() { addRepoFlags(workflowViewCmd) workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") addRepoFlags(workflowRunCmd) + addRepoFlags(workflowEnableCmd) + addRepoFlags(workflowDisableCmd) workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") workflowRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)") @@ -874,37 +896,9 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { } workflowIdentifier := args[0] - - // Find the workflow by listing from both .gitea/workflows and .forgejo/workflows - var workflow *Workflow - - for _, dir := range []string{".gitea/workflows", ".forgejo/workflows"} { - endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, name, dir) - - var contents []ContentsResponse - if err := client.GetJSON(endpoint, &contents); err != nil { - // Directory might not exist, continue - continue - } - - for _, content := range contents { - if content.Type == "file" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) { - workflow = &Workflow{ - Name: content.Name, - Path: content.Path, - State: "active", - } - break - } - } - - if workflow != nil { - break - } - } - - if workflow == nil { - return fmt.Errorf("workflow '%s' not found", workflowIdentifier) + workflow, err := findWorkflow(client, owner, name, workflowIdentifier) + if err != nil { + return err } jsonOutput, _ := cmd.Flags().GetBool("json") @@ -1024,6 +1018,96 @@ func runWorkflowRun(cmd *cobra.Command, args []string) error { return nil } +func runWorkflowEnable(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + workflowIdentifier := args[0] + workflow, err := findWorkflow(client, owner, name, workflowIdentifier) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/enable", owner, name, workflow.Name) + + // Try PUT first (correct method per GitHub/Gitea API spec) + status, err := client.DoJSON(http.MethodPut, endpoint, nil, nil) + if err != nil && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) { + // Fall back to POST for older versions + status, err = client.DoJSON(http.MethodPost, endpoint, nil, nil) + } + + if err != nil { + if status == http.StatusNotFound && strings.Contains(err.Error(), "404") { + return fmt.Errorf("failed to enable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+\n" + + "Your instance does not support the workflow enable/disable API endpoints yet.\n" + + "You can enable workflows via the web UI instead.") + } + return fmt.Errorf("failed to enable workflow: %w", err) + } + + fmt.Printf("✓ Workflow '%s' enabled\n", workflow.Name) + return nil +} + +func runWorkflowDisable(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + workflowIdentifier := args[0] + workflow, err := findWorkflow(client, owner, name, workflowIdentifier) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/disable", owner, name, workflow.Name) + + // Try PUT first (correct method per GitHub/Gitea API spec) + status, err := client.DoJSON(http.MethodPut, endpoint, nil, nil) + if err != nil && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) { + // Fall back to POST for older versions + status, err = client.DoJSON(http.MethodPost, endpoint, nil, nil) + } + + if err != nil { + if status == http.StatusNotFound && strings.Contains(err.Error(), "404") { + return fmt.Errorf("failed to disable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+\n" + + "Your instance does not support the workflow enable/disable API endpoints yet.\n" + + "You can disable workflows via the web UI instead.") + } + return fmt.Errorf("failed to disable workflow: %w", err) + } + + fmt.Printf("✓ Workflow '%s' disabled\n", workflow.Name) + return nil +} + func splitKeyValue(s string) []string { idx := -1 for i, c := range s { @@ -1038,6 +1122,29 @@ func splitKeyValue(s string) []string { return []string{s[:idx], s[idx+1:]} } +func findWorkflow(client *api.Client, owner, name, workflowIdentifier string) (*Workflow, error) { + for _, dir := range []string{".gitea/workflows", ".forgejo/workflows"} { + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, name, dir) + + var contents []ContentsResponse + if err := client.GetJSON(endpoint, &contents); err != nil { + continue + } + + for _, content := range contents { + if content.Type == "file" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) { + return &Workflow{ + Name: content.Name, + Path: content.Path, + State: "active", + }, nil + } + } + } + + return nil, fmt.Errorf("workflow '%s' not found", workflowIdentifier) +} + // Secret command implementations func runActionsSecretList(cmd *cobra.Command, args []string) error { diff --git a/go.mod b/go.mod index 66a6f73..5c70717 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/42wim/httpsig v1.2.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 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 @@ -21,6 +22,7 @@ require ( 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/russross/blackfriday/v2 v2.1.0 // 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 diff --git a/go.sum b/go.sum index a28f421..f344c22 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= 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= @@ -38,6 +39,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI 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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 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= diff --git a/internal/api/client.go b/internal/api/client.go index 93904c9..083499c 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -88,6 +88,13 @@ func (c *Client) GetJSON(path string, result any) error { // PostJSON performs a POST request to the specified path with JSON body func (c *Client) PostJSON(path string, body any, result any) error { + _, err := c.DoJSON(http.MethodPost, path, body, result) + return err +} + +// DoJSON performs an HTTP request with a JSON body and decodes the JSON response. +// Returns the HTTP status code and any error encountered. +func (c *Client) DoJSON(method string, path string, body any, result any) (int, error) { baseURL := "https://" + c.hostname url := baseURL + path @@ -95,14 +102,14 @@ func (c *Client) PostJSON(path string, body any, result any) error { if body != nil { bodyBytes, err := json.Marshal(body) if err != nil { - return fmt.Errorf("failed to marshal request body: %w", err) + return 0, fmt.Errorf("failed to marshal request body: %w", err) } bodyReader = bytes.NewReader(bodyBytes) } - req, err := http.NewRequest(http.MethodPost, url, bodyReader) + req, err := http.NewRequest(method, url, bodyReader) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return 0, fmt.Errorf("failed to create request: %w", err) } // Set authentication header @@ -110,12 +117,14 @@ func (c *Client) PostJSON(path string, body any, result any) error { req.Header.Set("Authorization", "token "+c.token) } req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { - return fmt.Errorf("failed to perform request: %w", err) + return 0, fmt.Errorf("failed to perform request: %w", err) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil && err == nil { @@ -125,16 +134,16 @@ func (c *Client) PostJSON(path string, body any, result any) error { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + return resp.StatusCode, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } if result != nil && resp.StatusCode != http.StatusNoContent { if err := json.NewDecoder(resp.Body).Decode(result); err != nil { - return fmt.Errorf("failed to decode response: %w", err) + return resp.StatusCode, fmt.Errorf("failed to decode response: %w", err) } } - return nil + return resp.StatusCode, nil } // GetRawLog performs a GET request and returns the raw response body as string From a7e5dc57988739ff1e78dde1a6bb2c1356951893 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Wed, 28 Jan 2026 14:29:59 +0100 Subject: [PATCH 22/54] lint: find a bunch of issues --- cmd/actions.go | 12 ++++++------ cmd/repo.go | 2 +- cmd/root.go | 2 +- internal/config/config.go | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cmd/actions.go b/cmd/actions.go index f9d4685..3f54ea0 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -1052,9 +1052,9 @@ func runWorkflowEnable(cmd *cobra.Command, args []string) error { if err != nil { if status == http.StatusNotFound && strings.Contains(err.Error(), "404") { - return fmt.Errorf("failed to enable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+\n" + - "Your instance does not support the workflow enable/disable API endpoints yet.\n" + - "You can enable workflows via the web UI instead.") + return fmt.Errorf("failed to enable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+. " + + "Your instance does not support the workflow enable/disable API endpoints yet. " + + "You can enable workflows via the web UI instead") } return fmt.Errorf("failed to enable workflow: %w", err) } @@ -1097,9 +1097,9 @@ func runWorkflowDisable(cmd *cobra.Command, args []string) error { if err != nil { if status == http.StatusNotFound && strings.Contains(err.Error(), "404") { - return fmt.Errorf("failed to disable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+\n" + - "Your instance does not support the workflow enable/disable API endpoints yet.\n" + - "You can disable workflows via the web UI instead.") + return fmt.Errorf("failed to disable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+. " + + "Your instance does not support the workflow enable/disable API endpoints yet. " + + "You can disable workflows via the web UI instead") } return fmt.Errorf("failed to disable workflow: %w", err) } diff --git a/cmd/repo.go b/cmd/repo.go index 3264196..286efc7 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -8,9 +8,9 @@ import ( "text/tabwriter" "code.gitea.io/sdk/gitea" - "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" ) var repoCmd = &cobra.Command{ diff --git a/cmd/root.go b/cmd/root.go index c1a373b..6f5afa4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,9 +5,9 @@ import ( "os" "strings" + "codeberg.org/romaintb/fgj/internal/git" "github.com/spf13/cobra" "github.com/spf13/viper" - "codeberg.org/romaintb/fgj/internal/git" ) var cfgFile string diff --git a/internal/config/config.go b/internal/config/config.go index 9584efc..4a5dc23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,9 +14,9 @@ type Config struct { } type HostConfig struct { - Hostname string `yaml:"hostname"` - Token string `yaml:"token"` - User string `yaml:"user,omitempty"` + Hostname string `yaml:"hostname"` + Token string `yaml:"token"` + User string `yaml:"user,omitempty"` GitProtocol string `yaml:"git_protocol,omitempty"` } From 1420b89fed4d6a2cda5d91f30ad9681efa61acd8 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Wed, 28 Jan 2026 15:06:04 +0100 Subject: [PATCH 23/54] fix: correctly build the jobs logs url unfortunately, this is available in gitea, not yet backported to forgejo --- cmd/actions.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/actions.go b/cmd/actions.go index 3f54ea0..a32670d 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -573,7 +573,7 @@ func runRunView(cmd *cobra.Command, args []string) error { // Case 2: --log or --log-failed (show logs) if showLog || showLogFailed { for _, task := range runTasks { - if err := showJobLog(client, owner, name, run.IndexInRepo, task, showLogFailed); err != nil { + if err := showJobLog(client, owner, name, task, showLogFailed); err != nil { fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) } } @@ -710,10 +710,10 @@ func runRunCancel(cmd *cobra.Command, args []string) error { return nil } -func showJobLog(client *api.Client, owner, name string, runNumber int64, task ActionTask, logFailed bool) error { - // Fetch log from /repos/{owner}/{repo}/actions/runs/{run_number}/jobs/{job_id}/logs - logURL := fmt.Sprintf("https://%s/%s/%s/actions/runs/%d/jobs/%d/logs", - client.Hostname(), owner, name, runNumber, task.ID) +func showJobLog(client *api.Client, owner, name string, task ActionTask, logFailed bool) error { + // Fetch log from API: GET /api/v1/repos/{owner}/{repo}/actions/jobs/{job_id}/logs + logURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/actions/jobs/%d/logs", + client.Hostname(), owner, name, task.ID) fmt.Printf("\n========================================\n") fmt.Printf("Job: %s (ID: %d)\n", task.Name, task.ID) From 0b8f67e43899d4c5b3d0432d84f3620318d10e79 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 20 Feb 2026 17:41:45 +0100 Subject: [PATCH 24/54] feat: allow tags passing when creating/editing an issue resolves #27 --- cmd/issue.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/cmd/issue.go b/cmd/issue.go index d582d01..f008f83 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -84,6 +84,7 @@ func init() { 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") + issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Labels to add (can be specified multiple times)") issueCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCommentCmd.Flags().StringP("body", "b", "", "Comment body") @@ -95,6 +96,8 @@ func init() { issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue") issueEditCmd.Flags().StringP("body", "b", "", "New body for the issue") issueEditCmd.Flags().StringP("state", "s", "", "New state for the issue (open or closed)") + issueEditCmd.Flags().StringSlice("add-label", nil, "Labels to add (can be specified multiple times)") + issueEditCmd.Flags().StringSlice("remove-label", nil, "Labels to remove (can be specified multiple times)") } func runIssueList(cmd *cobra.Command, args []string) error { @@ -233,6 +236,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") title, _ := cmd.Flags().GetString("title") body, _ := cmd.Flags().GetString("body") + labelNames, _ := cmd.Flags().GetStringSlice("label") owner, name, err := parseRepo(repo) if err != nil { @@ -253,9 +257,18 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { return err } + var labelIDs []int64 + if len(labelNames) > 0 { + labelIDs, err = resolveLabelIDs(client, owner, name, labelNames) + if err != nil { + return err + } + } + issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{ - Title: title, - Body: body, + Title: title, + Body: body, + Labels: labelIDs, }) if err != nil { return fmt.Errorf("failed to create issue: %w", err) @@ -357,6 +370,8 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { title, _ := cmd.Flags().GetString("title") body, _ := cmd.Flags().GetString("body") stateStr, _ := cmd.Flags().GetString("state") + addLabelNames, _ := cmd.Flags().GetStringSlice("add-label") + removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-label") issueNumber, err := strconv.ParseInt(args[0], 10, 64) if err != nil { @@ -368,8 +383,8 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { return err } - if title == "" && body == "" && stateStr == "" { - return fmt.Errorf("at least one of --title, --body, or --state must be provided") + if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 { + return fmt.Errorf("at least one of --title, --body, --state, --add-label, or --remove-label must be provided") } cfg, err := config.Load() @@ -405,12 +420,73 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { } } - _, _, err = client.EditIssue(owner, name, issueNumber, editOpt) - if err != nil { - return fmt.Errorf("failed to edit issue: %w", err) + if title != "" || body != "" || stateStr != "" { + _, _, err = client.EditIssue(owner, name, issueNumber, editOpt) + if err != nil { + return fmt.Errorf("failed to edit issue: %w", err) + } + } + + if len(addLabelNames) > 0 { + labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames) + if err != nil { + return err + } + _, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{ + Labels: labelIDs, + }) + if err != nil { + return fmt.Errorf("failed to add labels: %w", err) + } + } + + if len(removeLabelNames) > 0 { + labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames) + if err != nil { + return err + } + for _, labelID := range labelIDs { + _, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID) + if err != nil { + return fmt.Errorf("failed to remove label %d: %w", labelID, err) + } + } } fmt.Printf("Issue #%d updated\n", issueNumber) return nil } + +func resolveLabelIDs(client *api.Client, owner, name string, labelNames []string) ([]int64, error) { + if len(labelNames) == 0 { + return nil, nil + } + + labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list labels: %w", err) + } + + labelNameToID := make(map[string]int64) + for _, label := range labels { + labelNameToID[label.Name] = label.ID + } + + var labelIDs []int64 + var missingLabels []string + for _, labelName := range labelNames { + labelID, exists := labelNameToID[labelName] + if !exists { + missingLabels = append(missingLabels, labelName) + continue + } + labelIDs = append(labelIDs, labelID) + } + + if len(missingLabels) > 0 { + return nil, fmt.Errorf("labels not found: %s", strings.Join(missingLabels, ", ")) + } + + return labelIDs, nil +} From 2a20a4e0b80f290a2336cf69c94529f9b5076595 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 20 Feb 2026 17:48:56 +0100 Subject: [PATCH 25/54] tests: add functional tests for issues labels --- tests/functional/fixtures.go | 68 +++++++++++ tests/functional/functional_test.go | 176 ++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) diff --git a/tests/functional/fixtures.go b/tests/functional/fixtures.go index 80621ab..e510a92 100644 --- a/tests/functional/fixtures.go +++ b/tests/functional/fixtures.go @@ -151,6 +151,74 @@ func (env *TestEnv) VerifyAPIConnection() { } } +// EnsureTestLabels creates test labels if they don't exist +func (env *TestEnv) EnsureTestLabels() { + labelNames := []string{"bug", "enhancement", "help-wanted"} + + for _, name := range labelNames { + labels, _, err := env.Client.ListRepoLabels(env.Owner, env.RepoName, gitea.ListLabelsOptions{}) + if err != nil { + env.T.Fatalf("failed to list labels: %v", err) + } + + exists := false + for _, label := range labels { + if label.Name == name { + exists = true + break + } + } + + if !exists { + color := "#00aabb" + if name == "bug" { + color = "#ff0000" + } else if name == "enhancement" { + color = "#00ff00" + } + _, _, err = env.Client.CreateLabel(env.Owner, env.RepoName, gitea.CreateLabelOption{ + Name: name, + Color: color, + }) + if err != nil { + env.T.Logf("warning: failed to create label '%s': %v", name, err) + } else { + env.T.Logf("Created test label: %s", name) + } + } + } +} + +// GetIssueLabels gets the labels on an issue +func (env *TestEnv) GetIssueLabels(issueNumber int64) ([]*gitea.Label, error) { + labels, _, err := env.Client.GetIssueLabels(env.Owner, env.RepoName, issueNumber, gitea.ListLabelsOptions{}) + return labels, err +} + +// GetLabelIDs converts label names to label IDs +func (env *TestEnv) GetLabelIDs(labelNames []string) []int64 { + labels, _, err := env.Client.ListRepoLabels(env.Owner, env.RepoName, gitea.ListLabelsOptions{}) + if err != nil { + env.T.Fatalf("failed to list labels: %v", err) + } + + nameToID := make(map[string]int64) + for _, label := range labels { + nameToID[label.Name] = label.ID + } + + var ids []int64 + for _, name := range labelNames { + id, exists := nameToID[name] + if !exists { + env.T.Fatalf("label '%s' not found", name) + } + ids = append(ids, id) + } + + return ids +} + // GetBinaryPath returns the path to the built fgj binary func (env *TestEnv) GetBinaryPath() string { binaryPath := os.Getenv("FGJ_BINARY_PATH") diff --git a/tests/functional/functional_test.go b/tests/functional/functional_test.go index 6dee28b..1764ba6 100644 --- a/tests/functional/functional_test.go +++ b/tests/functional/functional_test.go @@ -103,6 +103,182 @@ func TestCreateAndListIssues(t *testing.T) { t.Logf("Successfully created and listed issue #%d", issueNum) } +// TestIssueCreateWithLabels verifies we can create issues with labels via CLI +func TestIssueCreateWithLabels(t *testing.T) { + env := NewTestEnv(t) + + // Ensure test labels exist + env.EnsureTestLabels() + + // Create an issue with labels via CLI + result := env.RunCLI( + "--hostname", env.Hostname, + "issue", "create", + "-t", "[FGJ E2E Test] Issue with Labels", + "-b", "This issue was created with labels", + "-l", "bug", + "-l", "enhancement", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue create with labels failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + // Extract issue number from output (format: "Issue created: #") + var issueNum int64 + _, err := fmt.Sscanf(result.Stdout, "Issue created: #%d", &issueNum) + if err != nil { + t.Fatalf("failed to parse issue number from output: %v", err) + } + + defer env.CleanupIssue(issueNum) + + // Verify labels were applied + labels, err := env.GetIssueLabels(issueNum) + if err != nil { + t.Fatalf("failed to get issue labels: %v", err) + } + + if len(labels) != 2 { + t.Fatalf("expected 2 labels, got %d", len(labels)) + } + + labelNames := make(map[string]bool) + for _, label := range labels { + labelNames[label.Name] = true + } + + if !labelNames["bug"] || !labelNames["enhancement"] { + t.Fatalf("expected labels 'bug' and 'enhancement', got %v", labelNames) + } + + t.Logf("Successfully created issue #%d with labels: bug, enhancement", issueNum) +} + +// TestIssueEditAddLabels verifies we can add labels to an issue via CLI +func TestIssueEditAddLabels(t *testing.T) { + env := NewTestEnv(t) + + // Ensure test labels exist + env.EnsureTestLabels() + + // Create an issue without labels + issueNum := env.CreateTestIssue( + "[FGJ E2E Test] Add Labels Test", + "This issue will have labels added", + ) + defer env.CleanupIssue(issueNum) + + // Verify no labels initially + labels, err := env.GetIssueLabels(issueNum) + if err != nil { + t.Fatalf("failed to get initial labels: %v", err) + } + + if len(labels) != 0 { + t.Fatalf("expected 0 labels initially, got %d", len(labels)) + } + + // Add labels via CLI + result := env.RunCLI( + "--hostname", env.Hostname, + "issue", "edit", + fmt.Sprintf("%d", issueNum), + "--add-label", "bug", + "--add-label", "help-wanted", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue edit add-label failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + // Verify labels were added + labels, err = env.GetIssueLabels(issueNum) + if err != nil { + t.Fatalf("failed to get labels after edit: %v", err) + } + + if len(labels) != 2 { + t.Fatalf("expected 2 labels after edit, got %d", len(labels)) + } + + labelNames := make(map[string]bool) + for _, label := range labels { + labelNames[label.Name] = true + } + + if !labelNames["bug"] || !labelNames["help-wanted"] { + t.Fatalf("expected labels 'bug' and 'help-wanted', got %v", labelNames) + } + + t.Logf("Successfully added labels to issue #%d", issueNum) +} + +// TestIssueEditRemoveLabels verifies we can remove labels from an issue via CLI +func TestIssueEditRemoveLabels(t *testing.T) { + env := NewTestEnv(t) + + // Ensure test labels exist + env.EnsureTestLabels() + + // Create an issue with labels + issue, _, err := env.Client.CreateIssue(env.Owner, env.RepoName, gitea.CreateIssueOption{ + Title: "[FGJ E2E Test] Remove Labels Test", + Body: "This issue will have labels removed", + }) + if err != nil { + t.Fatalf("failed to create issue: %v", err) + } + defer env.CleanupIssue(issue.Index) + + // Add labels via API + labelIDs := env.GetLabelIDs([]string{"bug", "enhancement"}) + _, _, err = env.Client.AddIssueLabels(env.Owner, env.RepoName, issue.Index, gitea.IssueLabelsOption{ + Labels: labelIDs, + }) + if err != nil { + t.Fatalf("failed to add initial labels: %v", err) + } + + // Verify initial labels + labels, err := env.GetIssueLabels(issue.Index) + if err != nil { + t.Fatalf("failed to get initial labels: %v", err) + } + + if len(labels) != 2 { + t.Fatalf("expected 2 labels initially, got %d", len(labels)) + } + + // Remove one label via CLI + result := env.RunCLI( + "--hostname", env.Hostname, + "issue", "edit", + fmt.Sprintf("%d", issue.Index), + "--remove-label", "bug", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue edit remove-label failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + // Verify label was removed + labels, err = env.GetIssueLabels(issue.Index) + if err != nil { + t.Fatalf("failed to get labels after edit: %v", err) + } + + if len(labels) != 1 { + t.Fatalf("expected 1 label after removal, got %d", len(labels)) + } + + if labels[0].Name != "enhancement" { + t.Fatalf("expected remaining label 'enhancement', got '%s'", labels[0].Name) + } + + t.Logf("Successfully removed label from issue #%d", issue.Index) +} + // TestGetIssue verifies we can get issue details func TestGetIssue(t *testing.T) { env := NewTestEnv(t) From f1d1386d51ec9ad84a326fa47a26b8462180458a Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Tue, 10 Mar 2026 15:38:35 +0100 Subject: [PATCH 26/54] chore: ignore .worktrees directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f46cde4..ec3f87e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ Thumbs.db # Config (contains tokens) config.yaml + +# Git worktrees +.worktrees/ From 2ea1bff59d73940f87b3a75805e699c905f3befb Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Thu, 12 Mar 2026 15:44:24 +0100 Subject: [PATCH 27/54] fix: respect $XDG_CONFIG_HOME --- internal/config/config.go | 3 +++ internal/config/config_test.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 4a5dc23..2f22243 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,9 @@ type HostConfig struct { } func GetConfigDir() (string, error) { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, "fgj"), nil + } home, err := os.UserHomeDir() if err != nil { return "", err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6912963..3d55ee9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -75,6 +75,20 @@ func TestGetConfigDir(t *testing.T) { } } +func TestGetConfigDir_XDG(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/custom/config") + + dir, err := GetConfigDir() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + expected := "/custom/config/fgj" + if dir != expected { + t.Errorf("Expected %q, got %q", expected, dir) + } +} + func TestGetConfigPath(t *testing.T) { path, err := GetConfigPath() if err != nil { From bfd082e66e109858d0c2ff8186efa4f7fc87a24b Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 13 Mar 2026 11:52:12 +0100 Subject: [PATCH 28/54] docs: simplify README.md a bit --- README.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/README.md b/README.md index f16663e..e3d64f7 100644 --- a/README.md +++ b/README.md @@ -303,38 +303,6 @@ fgj pr view 123 -R owner/repo - 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 -│ └── git/ # Git repository detection -├── main.go # Entry point -└── go.mod # Dependencies -``` - ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. @@ -358,9 +326,3 @@ We welcome contributions to implement any of these features! Please check the is ## 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 From a43a79d78f639134a9f526bc4126b136c03ba812 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Tue, 10 Mar 2026 15:40:22 +0100 Subject: [PATCH 29/54] feat: implement repo create command --- cmd/repo.go | 150 +++++++++++++++++++++++++++- cmd/repo_create_test.go | 53 ++++++++++ tests/functional/fixtures.go | 8 ++ tests/functional/functional_test.go | 39 ++++++++ 4 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 cmd/repo_create_test.go diff --git a/cmd/repo.go b/cmd/repo.go index 286efc7..b072317 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "text/tabwriter" "code.gitea.io/sdk/gitea" @@ -50,12 +51,35 @@ var repoForkCmd = &cobra.Command{ RunE: runRepoFork, } +var repoCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a new repository", + Long: `Create a new repository on the Forgejo instance. + +Name can be "reponame" (creates under your account) or "org/reponame" +(creates under an organization). Repositories are public by default; use --private to create a private one.`, + Args: cobra.ExactArgs(1), + RunE: runRepoCreate, +} + func init() { rootCmd.AddCommand(repoCmd) - repoCmd.AddCommand(repoViewCmd) - repoCmd.AddCommand(repoListCmd) repoCmd.AddCommand(repoCloneCmd) + repoCmd.AddCommand(repoCreateCmd) repoCmd.AddCommand(repoForkCmd) + repoCmd.AddCommand(repoListCmd) + repoCmd.AddCommand(repoViewCmd) + + repoCreateCmd.Flags().StringP("description", "d", "", "Description of the repository") + repoCreateCmd.Flags().Bool("private", false, "Make the new repository private") + repoCreateCmd.Flags().Bool("public", false, "Make the new repository public") + repoCreateCmd.Flags().Bool("add-readme", false, "Add a README file to the new repository") + repoCreateCmd.Flags().StringP("gitignore", "g", "", "Gitignore template (e.g. Go, Python)") + repoCreateCmd.Flags().StringP("license", "l", "", "License template (e.g. MIT, Apache-2.0)") + repoCreateCmd.Flags().String("homepage", "", "Repository home page URL") + repoCreateCmd.Flags().BoolP("clone", "c", false, "Clone the new repository to the current directory") + repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)") + repoCreateCmd.MarkFlagsMutuallyExclusive("public", "private") repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh") } @@ -237,3 +261,125 @@ func runRepoFork(cmd *cobra.Command, args []string) error { return nil } + +func runRepoCreate(cmd *cobra.Command, args []string) error { + inputName := args[0] + org, repoName, isOrg, err := parseCreateName(inputName) + if err != nil { + return err + } + + private, _ := cmd.Flags().GetBool("private") + description, _ := cmd.Flags().GetString("description") + addReadme, _ := cmd.Flags().GetBool("add-readme") + gitignore, _ := cmd.Flags().GetString("gitignore") + license, _ := cmd.Flags().GetString("license") + homepage, _ := cmd.Flags().GetString("homepage") + doClone, _ := cmd.Flags().GetBool("clone") + team, _ := cmd.Flags().GetString("team") + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + opt := gitea.CreateRepoOption{ + Name: repoName, + Description: description, + Private: private, + AutoInit: addReadme, + Gitignores: gitignore, + License: license, + } + + var repo *gitea.Repository + if isOrg { + repo, _, err = client.CreateOrgRepo(org, opt) + } else { + repo, _, err = client.CreateRepo(opt) + } + if err != nil { + return fmt.Errorf("failed to create repository: %w", err) + } + + if homepage != "" { + ownerName := org + if !isOrg { + // For personal repos, get the owner from the created repo or fall back to API + if repo.Owner != nil { + ownerName = repo.Owner.UserName + } else { + user, _, userErr := client.GetMyUserInfo() + if userErr != nil { + fmt.Fprintf(os.Stderr, "warning: repository created but could not determine owner for homepage: %v\n", userErr) + homepage = "" // skip EditRepo + } else { + ownerName = user.UserName + } + } + } + if homepage != "" { + _, _, err = client.EditRepo(ownerName, repo.Name, gitea.EditRepoOption{ + Website: &homepage, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: repository created but failed to set homepage: %v\n", err) + } + } + } + + if team != "" { + if !isOrg { + fmt.Fprintln(os.Stderr, "warning: --team is only meaningful for organization repositories") + } else { + _, err = client.AddRepoTeam(org, repo.Name, team) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: repository created but failed to add team %q: %v\n", team, err) + } + } + } + + fmt.Printf("Repository created: %s\n", repo.HTMLURL) + + if doClone { + cloneURL := repo.CloneURL + if hostCfg, hostErr := cfg.GetHost("", getDetectedHost()); hostErr == nil { + if hostCfg.GitProtocol == "ssh" { + cloneURL = repo.SSHURL + } + } + fmt.Printf("Cloning into %s...\n", repo.Name) + gitCmd := exec.Command("git", "clone", cloneURL, repo.Name) + gitCmd.Stdout = os.Stdout + gitCmd.Stderr = os.Stderr + gitCmd.Stdin = os.Stdin + if err := gitCmd.Run(); err != nil { + return fmt.Errorf("failed to clone repository: %w", err) + } + } + + return nil +} + +// parseCreateName parses the name argument for repo creation. +// Returns org, repoName, and whether it targets an org. +// Input: "reponame" → ("", "reponame", false) +// Input: "org/reponame" → ("org", "reponame", true) +func parseCreateName(name string) (org, repoName string, isOrg bool, err error) { + parts := strings.SplitN(name, "/", 2) + if len(parts) == 2 { + if parts[0] == "" || parts[1] == "" { + return "", "", false, fmt.Errorf("invalid repository name %q: org and repo name must not be empty", name) + } + return parts[0], parts[1], true, nil + } + if name == "" { + return "", "", false, fmt.Errorf("repository name must not be empty") + } + return "", name, false, nil +} diff --git a/cmd/repo_create_test.go b/cmd/repo_create_test.go new file mode 100644 index 0000000..6405553 --- /dev/null +++ b/cmd/repo_create_test.go @@ -0,0 +1,53 @@ +package cmd + +import "testing" + +func TestParseCreateName(t *testing.T) { + t.Run("simple repo name", func(t *testing.T) { + org, repo, isOrg, err := parseCreateName("myrepo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if org != "" || repo != "myrepo" || isOrg { + t.Errorf("got (%q, %q, %v), want (%q, %q, %v)", org, repo, isOrg, "", "myrepo", false) + } + }) + + t.Run("org/repo name", func(t *testing.T) { + org, repo, isOrg, err := parseCreateName("myorg/myrepo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if org != "myorg" || repo != "myrepo" || !isOrg { + t.Errorf("got (%q, %q, %v), want (%q, %q, %v)", org, repo, isOrg, "myorg", "myrepo", true) + } + }) + + t.Run("empty string", func(t *testing.T) { + _, _, _, err := parseCreateName("") + if err == nil { + t.Error("expected error for empty string, got nil") + } + }) + + t.Run("slash only", func(t *testing.T) { + _, _, _, err := parseCreateName("/") + if err == nil { + t.Error("expected error for '/', got nil") + } + }) + + t.Run("empty repo after slash", func(t *testing.T) { + _, _, _, err := parseCreateName("org/") + if err == nil { + t.Error("expected error for 'org/', got nil") + } + }) + + t.Run("empty org before slash", func(t *testing.T) { + _, _, _, err := parseCreateName("/repo") + if err == nil { + t.Error("expected error for '/repo', got nil") + } + }) +} diff --git a/tests/functional/fixtures.go b/tests/functional/fixtures.go index e510a92..5423e5b 100644 --- a/tests/functional/fixtures.go +++ b/tests/functional/fixtures.go @@ -219,6 +219,14 @@ func (env *TestEnv) GetLabelIDs(labelNames []string) []int64 { return ids } +// CleanupRepo deletes a repository created during testing. +func (env *TestEnv) CleanupRepo(owner, repoName string) { + _, err := env.Client.DeleteRepo(owner, repoName) + if err != nil { + env.T.Logf("warning: failed to delete repo %s/%s: %v", owner, repoName, err) + } +} + // GetBinaryPath returns the path to the built fgj binary func (env *TestEnv) GetBinaryPath() string { binaryPath := os.Getenv("FGJ_BINARY_PATH") diff --git a/tests/functional/functional_test.go b/tests/functional/functional_test.go index 1764ba6..3803e56 100644 --- a/tests/functional/functional_test.go +++ b/tests/functional/functional_test.go @@ -940,3 +940,42 @@ func TestCLIReleaseList(t *testing.T) { t.Logf("Successfully listed releases via CLI") } + +// TestCLIRepoCreate verifies `fgj repo create` creates a repository +func TestCLIRepoCreate(t *testing.T) { + env := NewTestEnv(t) + + repoName := fmt.Sprintf("fgj-test-create-%d", time.Now().UnixNano()) + defer env.CleanupRepo(env.Owner, repoName) + + result := env.RunCLI( + "--hostname", env.Hostname, + "repo", "create", repoName, + "--public", + "-d", "Created by fgj functional test", + ) + + if result.ExitCode != 0 { + t.Fatalf("repo create failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if !bytes.Contains([]byte(result.Stdout), []byte(repoName)) { + t.Fatalf("expected output to contain repo name %q, got: %s", repoName, result.Stdout) + } + + repo, _, err := env.Client.GetRepo(env.Owner, repoName) + if err != nil { + t.Fatalf("repo was not created or not accessible: %v", err) + } + if repo.Name != repoName { + t.Fatalf("expected repo name %q, got %q", repoName, repo.Name) + } + if repo.Private { + t.Fatalf("expected public repo, got private") + } + if repo.Description != "Created by fgj functional test" { + t.Fatalf("expected description %q, got %q", "Created by fgj functional test", repo.Description) + } + + t.Logf("Successfully created repository %s via CLI", repo.FullName) +} From 47f696d7dd6f9c0df764821f81be47cb6b382f04 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 13 Mar 2026 18:20:21 +0100 Subject: [PATCH 30/54] docs: adjust for v0.3.0 release --- CHANGELOG.md | 37 ++++++++++++++++++++++++ README.md | 81 +++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ad76f..904eac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-03-13 + +### Added + +#### Forgejo Actions +- `fgj actions run watch ` - Poll a run until completion +- `fgj actions run rerun ` - Trigger a rerun of a workflow run +- `fgj actions run cancel ` - Cancel an in-progress workflow run +- `fgj actions workflow enable ` - Enable a workflow +- `fgj actions workflow disable ` - Disable a workflow + +#### Repository Management +- `fgj repo create ` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team` + +#### Issue Management +- `fgj issue create -l