From 900bd4ea97276054041cdeb57511a9a8293e439c Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 16 Jan 2026 10:03:49 +0100 Subject: [PATCH 01/41] 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 02/41] 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 03/41] 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 04/41] 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 05/41] 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 06/41] 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 07/41] 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 08/41] 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 09/41] 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 10/41] 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 11/41] 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 12/41] 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 13/41] 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 14/41] 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 15/41] 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 16/41] 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 17/41] 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