diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cc43a..b3592a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `fgj branch {list,rename,delete}` — list branches with protection status, rename branches, delete (protected branches are refused). +- `fgj branch {protect,unprotect}` — create, replace, or remove branch + protection rules with `--require-approvals`, + `--dismiss-stale-approvals`, `--require-signed-commits`, + `--block-on-rejected-reviews`, `--block-on-outdated-branch`, + `--push-whitelist`, `--merge-whitelist`, `--require-status-checks`. + `protect` is idempotent (create-or-edit). - `fgj repo delete` — type-to-confirm deletion; `--yes` for scripts. - `fgj repo search` — search repositories on the current host by query, topic, or description; filter by `--type`, `--owner`, @@ -40,12 +46,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `fgj notification list [--all]` / `fgj notification read ` — list unread (default) or all notifications; mark individual threads read. +- `fgj notification {unread,pin,unpin}` — flip thread state + (complements `read`). Uses the Gitea `NotifyStatus` enum. - `fgj org {list,create,delete}` — list your orgs, create with visibility/description, delete with confirmation. - `fgj webhook {list,create,update,delete}` — full CRUD on repo webhooks: gitea/slack/discord/etc. hook types, event selection, content type, secret, branch filter, auth header. +### Added — Releases, Actions, Milestones, Time + +- `fgj release asset {list,create,delete}` — granular release + attachment management. `delete` accepts numeric IDs or filenames. +- `fgj actions run delete` — delete a completed workflow run. Refuses + non-terminal runs unless `--force`; suggests `actions run cancel` + for those. +- `fgj milestone issues {add,remove}` — associate or disassociate + issues with a milestone. Milestone accepted as title or id. +- `fgj time {list,add,delete,reset}` — tracked-time management. Accepts + Go duration strings (`30m`, `1h30m`). `list` with no arg shows the + authenticated user's times across all repos. + ### Added — Misc - `fgj open [number] [--url]` — launch the repo / issue / PR page in @@ -53,6 +74,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 non-TTY stdout or with `--url`. - `fgj whoami` — show the authenticated user and host. - `fgj admin user list` — admin-gated user enumeration. +- `fgj logins {list,default}` — complement to `fgj auth`. `list` shows + all configured hosts in a table, highlighting the default. `default` + gets/sets which hostname wins when no other signal is present. +- `pr list` / `issue list` gain `--since` and `--before` flags + accepting `YYYY-MM-DD`, RFC 3339, `YYYY-MM-DD HH:MM:SS`, or relative + deltas (`7d`, `24h`, `2w`, `1m`). Server-side filter for issues, + client-side for PRs (SDK lacks a PR-side filter). +- `fgj label update` added as an alias for `fgj label edit`. ### Changed @@ -61,6 +90,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 prompts now use hidden input via `term.ReadPassword`; piped stdin is read whole (prior `fmt.Scanln` broke on spaces/newlines and echoed the typed value). Empty values are rejected. +- `HostConfig` gains an optional `default: true` field. When no other + signal selects a host (flag, `FGJ_HOST`, git remote, `match_dirs`), + the host marked default wins before the `codeberg.org` fallback. + Multiple `default: true` entries are tolerated with a stderr + warning; alphabetical-first wins. - Gitea SDK bumped `v0.22.1` → `v0.23.2` (last release compatible with Go 1.24; `v0.24+` requires Go 1.26). diff --git a/cmd/actions_run_delete.go b/cmd/actions_run_delete.go new file mode 100644 index 0000000..75a27d1 --- /dev/null +++ b/cmd/actions_run_delete.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/spf13/cobra" + + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" +) + +var runDeleteCmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete a workflow run", + Long: `Delete a completed workflow run. + +By default, the run is fetched first and deletion is refused if the run +is still pending, running, or waiting. Use --force to override this and +delete a non-terminal run. To stop an in-progress run, use +'fgj actions run cancel' instead.`, + Example: ` # Delete a completed run (with confirmation) + fgj actions run delete 123 + + # Delete without confirmation + fgj actions run delete 123 -y + + # Force delete a non-terminal run + fgj actions run delete 123 --force -y`, + Args: cobra.ExactArgs(1), + RunE: runRunDelete, +} + +func init() { + runCmd.AddCommand(runDeleteCmd) + addRepoFlags(runDeleteCmd) + runDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + runDeleteCmd.Flags().Bool("force", false, "Allow deleting a non-terminal (pending/running/waiting) run") +} + +func runRunDelete(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(), getCwd()) + 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) + } + + skipConfirm, _ := cmd.Flags().GetBool("yes") + force, _ := cmd.Flags().GetBool("force") + + // Fetch the run to check state and to display status in the confirmation prompt. + runEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID) + var run ActionRun + if err := client.GetJSON(runEndpoint, &run); err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + + if !isRunComplete(run.Status) && !force { + return fmt.Errorf("run %d is %s; refusing to delete a non-terminal run. Use 'fgj actions run cancel %d' to stop it, or pass --force to delete anyway", + runID, run.Status, runID) + } + + if !skipConfirm && ios.IsStdinTTY() { + answer, err := promptLine(fmt.Sprintf("Delete run %d (%s) in %s/%s? [y/N]: ", runID, run.Status, owner, name)) + if err != nil { + return err + } + if answer != "y" && answer != "Y" && answer != "yes" { + fmt.Fprintln(ios.ErrOut, "Cancelled.") + return nil + } + } + + deleteEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID) + if _, err := client.DoJSON(http.MethodDelete, deleteEndpoint, nil, nil); err != nil { + return fmt.Errorf("failed to delete run %d: %w", runID, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Deleted run %d\n", cs.SuccessIcon(), runID) + return nil +} diff --git a/cmd/issue.go b/cmd/issue.go index f053b1a..d29f4fe 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strings" + "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/public/fgj-sid/internal/api" @@ -29,7 +30,13 @@ var issueListCmd = &cobra.Command{ fgj issue list -s closed -R owner/repo # Output as JSON - fgj issue list --json`, + fgj issue list --json + + # PRs updated in the last 7 days + fgj pr list --since 7d + + # Issues touched between two dates + fgj issue list --since 2026-04-01 --before 2026-04-15`, RunE: runIssueList, } @@ -152,6 +159,8 @@ func init() { issueListCmd.Flags().String("author", "", "Filter by author username") issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names") issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter") + issueListCmd.Flags().String("since", "", "Only items updated at or after this date (YYYY-MM-DD, RFC 3339, or relative like 7d)") + issueListCmd.Flags().String("before", "", "Only items updated strictly before this date (YYYY-MM-DD, RFC 3339, or relative like 1d)") addJSONFlags(issueListCmd, "Output issues as JSON") issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") @@ -192,6 +201,24 @@ func runIssueList(cmd *cobra.Command, args []string) error { author, _ := cmd.Flags().GetString("author") labels, _ := cmd.Flags().GetStringSlice("label") search, _ := cmd.Flags().GetString("search") + sinceStr, _ := cmd.Flags().GetString("since") + beforeStr, _ := cmd.Flags().GetString("before") + + var sinceTime, beforeTime time.Time + if sinceStr != "" { + t, err := parseDateArg(sinceStr) + if err != nil { + return fmt.Errorf("invalid --since: %w", err) + } + sinceTime = t + } + if beforeStr != "" { + t, err := parseDateArg(beforeStr) + if err != nil { + return fmt.Errorf("invalid --before: %w", err) + } + beforeTime = t + } owner, name, err := parseRepo(repo) if err != nil { @@ -227,6 +254,8 @@ func runIssueList(cmd *cobra.Command, args []string) error { KeyWord: search, CreatedBy: author, AssignedBy: assignee, + Since: sinceTime, + Before: beforeTime, ListOptions: gitea.ListOptions{PageSize: limit}, }) ios.StopSpinner() diff --git a/cmd/label.go b/cmd/label.go index 87cea75..f496711 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -48,9 +48,10 @@ var labelCreateCmd = &cobra.Command{ } var labelEditCmd = &cobra.Command{ - Use: "edit ", - Short: "Edit a label", - Long: "Edit an existing label in a repository.", + Use: "edit ", + Aliases: []string{"update"}, + Short: "Edit a label", + Long: "Edit an existing label in a repository.", Example: ` # Rename a label fgj label edit bug --name bugfix diff --git a/cmd/logins.go b/cmd/logins.go new file mode 100644 index 0000000..73dea59 --- /dev/null +++ b/cmd/logins.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "forgejo.zerova.net/public/fgj-sid/internal/config" + "github.com/spf13/cobra" +) + +var loginsCmd = &cobra.Command{ + Use: "logins", + Short: "Manage configured Forgejo/Gitea logins", + Long: `Manage configured Forgejo/Gitea logins. + +This is a complementary command to 'fgj auth' using the noun vocabulary +familiar to users of tea. Use 'fgj auth login' to add a new login and +'fgj auth logout' to remove one.`, +} + +var loginsListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List all configured logins", + Long: "List all configured Forgejo/Gitea logins with their hostname, user, protocol, default flag, and match_dirs.", + RunE: runLoginsList, +} + +var loginsDefaultCmd = &cobra.Command{ + Use: "default [hostname]", + Short: "Get or set the default login", + Long: `Get or set the default login. + +With no argument, prints the currently-configured default hostname +(or "no default set" if none is configured). + +With a hostname argument, marks that login as the default and unsets +the default flag on all other logins.`, + Args: cobra.MaximumNArgs(1), + RunE: runLoginsDefault, +} + +func init() { + rootCmd.AddCommand(loginsCmd) + loginsCmd.AddCommand(loginsListCmd) + loginsCmd.AddCommand(loginsDefaultCmd) + + addJSONFlags(loginsListCmd, "Output logins as JSON") +} + +// loginEntry is the JSON representation of a configured login. +type loginEntry struct { + Hostname string `json:"hostname"` + User string `json:"user"` + GitProtocol string `json:"git_protocol"` + Default bool `json:"default"` + MatchDirs []string `json:"match_dirs"` +} + +func runLoginsList(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + hostnames := make([]string, 0, len(cfg.Hosts)) + for hostname := range cfg.Hosts { + hostnames = append(hostnames, hostname) + } + + // Sort by config-file order; fall back to alphabetical when Order is + // equal (e.g., both zero because the config was constructed in-memory + // rather than loaded from disk). + sort.Slice(hostnames, func(i, j int) bool { + a, b := cfg.Hosts[hostnames[i]], cfg.Hosts[hostnames[j]] + if a.Order != b.Order { + return a.Order < b.Order + } + return hostnames[i] < hostnames[j] + }) + + if wantJSON(cmd) { + entries := make([]loginEntry, 0, len(hostnames)) + for _, hostname := range hostnames { + h := cfg.Hosts[hostname] + dirs := h.MatchDirs + if dirs == nil { + dirs = []string{} + } + entries = append(entries, loginEntry{ + Hostname: hostname, + User: h.User, + GitProtocol: h.GitProtocol, + Default: h.Default, + MatchDirs: dirs, + }) + } + return outputJSON(cmd, entries) + } + + if len(hostnames) == 0 { + fmt.Fprintln(ios.Out, "No logins configured") + fmt.Fprintln(ios.Out, "Run 'fgj auth login' to add a login") + return nil + } + + tp := ios.NewTablePrinter() + tp.AddHeader("HOSTNAME", "USER", "PROTOCOL", "DEFAULT", "MATCH_DIRS") + for _, hostname := range hostnames { + h := cfg.Hosts[hostname] + defaultMark := "" + if h.Default { + defaultMark = "*" + } + tp.AddRow( + hostname, + h.User, + h.GitProtocol, + defaultMark, + strings.Join(h.MatchDirs, ", "), + ) + } + return tp.Render() +} + +func runLoginsDefault(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if len(args) == 0 { + current := cfg.DefaultHost() + if current == "" { + fmt.Fprintln(ios.Out, "no default set") + return nil + } + fmt.Fprintln(ios.Out, current) + return nil + } + + target := args[0] + if _, ok := cfg.Hosts[target]; !ok { + return fmt.Errorf("no configuration found for host %s", target) + } + + for hostname, h := range cfg.Hosts { + h.Default = (hostname == target) + cfg.Hosts[hostname] = h + } + + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Default host set to %s\n", cs.SuccessIcon(), target) + return nil +} diff --git a/cmd/pr.go b/cmd/pr.go index 3ffbfad..d3137c2 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -4,7 +4,9 @@ import ( "fmt" "net/http" "os/exec" + "strconv" "strings" + "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/public/fgj-sid/internal/api" @@ -14,6 +16,54 @@ import ( "github.com/spf13/cobra" ) +// parseDateArg parses a --since / --before argument. +// Accepted forms: "2006-01-02", RFC 3339, "2006-01-02 15:04:05" (local), +// or a relative delta like "7d", "24h", "2w", "1m" (months treated as 30 days). +func parseDateArg(s string) (time.Time, error) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, fmt.Errorf("empty date") + } + + // Relative delta: + if last := s[len(s)-1]; last == 'h' || last == 'd' || last == 'w' || last == 'm' { + numPart := s[:len(s)-1] + if n, err := strconv.Atoi(numPart); err == nil && n >= 0 { + var d time.Duration + switch last { + case 'h': + d = time.Duration(n) * time.Hour + case 'd': + d = time.Duration(n) * 24 * time.Hour + case 'w': + d = time.Duration(n) * 7 * 24 * time.Hour + case 'm': + // Months treated as 30 days (crude but documented). + d = time.Duration(n) * 30 * 24 * time.Hour + } + return time.Now().Add(-d), nil + } + } + + layouts := []string{ + "2006-01-02", + time.RFC3339, + "2006-01-02 15:04:05", + } + for _, layout := range layouts { + if layout == "2006-01-02" || layout == "2006-01-02 15:04:05" { + if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { + return t, nil + } + } else { + if t, err := time.Parse(layout, s); err == nil { + return t, nil + } + } + } + return time.Time{}, fmt.Errorf("unrecognized date format: %q (expected YYYY-MM-DD, RFC 3339, 'YYYY-MM-DD HH:MM:SS', or a relative delta like 7d/24h/2w/1m)", s) +} + var prCmd = &cobra.Command{ Use: "pr", Aliases: []string{"pull-request"}, @@ -32,7 +82,13 @@ var prListCmd = &cobra.Command{ fgj pr list -s all -R owner/repo # Output as JSON - fgj pr list --json`, + fgj pr list --json + + # PRs updated in the last 7 days + fgj pr list --since 7d + + # Issues touched between two dates + fgj issue list --since 2026-04-01 --before 2026-04-15`, RunE: runPRList, } @@ -167,6 +223,8 @@ func init() { prListCmd.Flags().Bool("draft", false, "Filter by draft status") prListCmd.Flags().String("head", "", "Filter by head branch") prListCmd.Flags().String("base", "", "Filter by base branch") + prListCmd.Flags().String("since", "", "Only items updated at or after this date (YYYY-MM-DD, RFC 3339, or relative like 7d)") + prListCmd.Flags().String("before", "", "Only items updated strictly before this date (YYYY-MM-DD, RFC 3339, or relative like 1d)") prListCmd.Flags().BoolP("web", "w", false, "Open list in web browser") addJSONFlags(prListCmd, "Output pull requests as JSON") @@ -217,6 +275,27 @@ func runPRList(cmd *cobra.Command, args []string) error { draft, _ := cmd.Flags().GetBool("draft") head, _ := cmd.Flags().GetString("head") base, _ := cmd.Flags().GetString("base") + sinceStr, _ := cmd.Flags().GetString("since") + beforeStr, _ := cmd.Flags().GetString("before") + + var sinceTime, beforeTime time.Time + var hasSince, hasBefore bool + if sinceStr != "" { + t, err := parseDateArg(sinceStr) + if err != nil { + return fmt.Errorf("invalid --since: %w", err) + } + sinceTime = t + hasSince = true + } + if beforeStr != "" { + t, err := parseDateArg(beforeStr) + if err != nil { + return fmt.Errorf("invalid --before: %w", err) + } + beforeTime = t + hasBefore = true + } owner, name, err := parseRepo(repo) if err != nil { @@ -249,7 +328,8 @@ func runPRList(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid state: %s", state) } - needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != "" + // server-side since/before unsupported for pulls; filtering client-side + needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != "" || hasSince || hasBefore ios.StartSpinner("Fetching pull requests...") var prs []*gitea.PullRequest @@ -271,6 +351,9 @@ func runPRList(cmd *cobra.Command, args []string) error { page++ } prs = filterPRs(prs, author, assignee, labels, search, draft, head, base) + if hasSince || hasBefore { + prs = filterPRsByDate(prs, sinceTime, hasSince, beforeTime, hasBefore) + } if len(prs) > limit { prs = prs[:limit] } @@ -354,6 +437,27 @@ func filterPRs(prs []*gitea.PullRequest, author, assignee string, labels []strin return result } +// filterPRsByDate applies the --since / --before range against pr.Updated. +func filterPRsByDate(prs []*gitea.PullRequest, since time.Time, hasSince bool, before time.Time, hasBefore bool) []*gitea.PullRequest { + if !hasSince && !hasBefore { + return prs + } + result := make([]*gitea.PullRequest, 0, len(prs)) + for _, pr := range prs { + if pr.Updated == nil { + continue + } + if hasSince && pr.Updated.Before(since) { + continue + } + if hasBefore && !pr.Updated.Before(before) { + continue + } + result = append(result, pr) + } + return result +} + func runPRView(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") diff --git a/internal/config/config.go b/internal/config/config.go index e60583d..f326613 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/spf13/viper" @@ -20,6 +21,7 @@ type HostConfig struct { User string `yaml:"user,omitempty"` GitProtocol string `yaml:"git_protocol,omitempty"` MatchDirs []string `yaml:"match_dirs,omitempty"` + Default bool `yaml:"default,omitempty"` Order int `yaml:"-"` // config file order, set at load time } @@ -134,7 +136,8 @@ func (c *Config) SaveToPath(path string) error { // 3. Environment variable (FGJ_HOST) // 4. Auto-detected hostname from git remote // 5. match_dirs lookup (longest prefix match) -// 6. Default to codeberg.org +// 6. Configured default host (HostConfig.Default == true) +// 7. Default to codeberg.org func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) { if hostname == "" { hostname = viper.GetString("hostname") @@ -152,6 +155,10 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host hostname = c.ResolveHostByPath(cwd) } + if hostname == "" { + hostname = c.DefaultHost() + } + if hostname == "" { hostname = "codeberg.org" } @@ -164,6 +171,27 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host return host, nil } +// DefaultHost returns the hostname of the host marked Default == true. +// If no host is marked default, returns "". If multiple hosts are marked +// default (a user-error case), the one that sorts first alphabetically is +// returned and a warning is printed to stderr. +func (c *Config) DefaultHost() string { + var matches []string + for hostname, host := range c.Hosts { + if host.Default { + matches = append(matches, hostname) + } + } + if len(matches) == 0 { + return "" + } + sort.Strings(matches) + if len(matches) > 1 { + fmt.Fprintf(os.Stderr, "warning: multiple hosts marked default (%s); using %s\n", strings.Join(matches, ", "), matches[0]) + } + return matches[0] +} + // ResolveHostByPath finds the host whose match_dirs entry is the longest // prefix of cwd. Returns "" if no match is found. // Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks