feat: logins list/default, actions run delete, date filters, label update alias
- fgj logins {list,default}: complementary UI to 'fgj auth'. 'list'
shows all configured hosts (hostname, user, protocol, default flag,
match_dirs) with --json. 'default [hostname]' gets or sets which
host wins in resolution when no other signal is present.
Adds 'Default bool' field to HostConfig; GetHost consults it between
match_dirs and the codeberg.org fallback. Multiple defaults tolerated
with a stderr warning; alphabetical-first wins.
- fgj actions run delete: delete a completed workflow run via raw
DELETE (SDK v0.23.2 has no DeleteRepoActionRun). Fetches run first
and refuses to delete non-terminal states unless --force; suggests
'actions run cancel' for those. Confirmation prompt unless --yes.
- pr list / issue list gain --since and --before date filter flags.
Accepts YYYY-MM-DD, RFC 3339, YYYY-MM-DD HH:MM:SS, and relative
deltas (7d, 24h, 2w, 1m — months=30 days). Issues use server-side
filter via ListIssueOption.Since/Before; PRs fall back to client-side
(SDK lacks Since/Before on ListPullRequestsOptions).
- fgj label update added as alias for 'fgj label edit' (tea-compat).
All changes:
cmd/logins.go (new, 140 LOC)
cmd/actions_run_delete.go (new, ~85 LOC)
cmd/pr.go, cmd/issue.go (+parseDateArg helper, filter wiring)
cmd/label.go (1-line alias)
internal/config/config.go (Default field + DefaultHost method)
CHANGELOG.md
Built in parallel by three sub-agents; plus the label alias done
serially. go build / go vet / go test -race all clean.
This commit is contained in:
parent
d15deaf064
commit
2d69873f3e
7 changed files with 462 additions and 7 deletions
34
CHANGELOG.md
34
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
|
- `fgj branch {list,rename,delete}` — list branches with protection
|
||||||
status, rename branches, delete (protected branches are refused).
|
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 delete` — type-to-confirm deletion; `--yes` for scripts.
|
||||||
- `fgj repo search` — search repositories on the current host by
|
- `fgj repo search` — search repositories on the current host by
|
||||||
query, topic, or description; filter by `--type`, `--owner`,
|
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 <id>` —
|
- `fgj notification list [--all]` / `fgj notification read <id>` —
|
||||||
list unread (default) or all notifications; mark individual
|
list unread (default) or all notifications; mark individual
|
||||||
threads read.
|
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
|
- `fgj org {list,create,delete}` — list your orgs, create with
|
||||||
visibility/description, delete with confirmation.
|
visibility/description, delete with confirmation.
|
||||||
- `fgj webhook {list,create,update,delete}` — full CRUD on repo
|
- `fgj webhook {list,create,update,delete}` — full CRUD on repo
|
||||||
webhooks: gitea/slack/discord/etc. hook types, event selection,
|
webhooks: gitea/slack/discord/etc. hook types, event selection,
|
||||||
content type, secret, branch filter, auth header.
|
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
|
### Added — Misc
|
||||||
|
|
||||||
- `fgj open [number] [--url]` — launch the repo / issue / PR page in
|
- `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`.
|
non-TTY stdout or with `--url`.
|
||||||
- `fgj whoami` — show the authenticated user and host.
|
- `fgj whoami` — show the authenticated user and host.
|
||||||
- `fgj admin user list` — admin-gated user enumeration.
|
- `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
|
### 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
|
prompts now use hidden input via `term.ReadPassword`; piped stdin is
|
||||||
read whole (prior `fmt.Scanln` broke on spaces/newlines and echoed
|
read whole (prior `fmt.Scanln` broke on spaces/newlines and echoed
|
||||||
the typed value). Empty values are rejected.
|
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
|
- Gitea SDK bumped `v0.22.1` → `v0.23.2` (last release compatible with
|
||||||
Go 1.24; `v0.24+` requires Go 1.26).
|
Go 1.24; `v0.24+` requires Go 1.26).
|
||||||
|
|
||||||
|
|
|
||||||
99
cmd/actions_run_delete.go
Normal file
99
cmd/actions_run_delete.go
Normal file
|
|
@ -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 <run-id>",
|
||||||
|
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
|
||||||
|
}
|
||||||
31
cmd/issue.go
31
cmd/issue.go
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
||||||
|
|
@ -29,7 +30,13 @@ var issueListCmd = &cobra.Command{
|
||||||
fgj issue list -s closed -R owner/repo
|
fgj issue list -s closed -R owner/repo
|
||||||
|
|
||||||
# Output as JSON
|
# 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,
|
RunE: runIssueList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,6 +159,8 @@ func init() {
|
||||||
issueListCmd.Flags().String("author", "", "Filter by author username")
|
issueListCmd.Flags().String("author", "", "Filter by author username")
|
||||||
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
|
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
|
||||||
issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter")
|
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")
|
addJSONFlags(issueListCmd, "Output issues as JSON")
|
||||||
|
|
||||||
issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
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")
|
author, _ := cmd.Flags().GetString("author")
|
||||||
labels, _ := cmd.Flags().GetStringSlice("label")
|
labels, _ := cmd.Flags().GetStringSlice("label")
|
||||||
search, _ := cmd.Flags().GetString("search")
|
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)
|
owner, name, err := parseRepo(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -227,6 +254,8 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
KeyWord: search,
|
KeyWord: search,
|
||||||
CreatedBy: author,
|
CreatedBy: author,
|
||||||
AssignedBy: assignee,
|
AssignedBy: assignee,
|
||||||
|
Since: sinceTime,
|
||||||
|
Before: beforeTime,
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
ListOptions: gitea.ListOptions{PageSize: limit},
|
||||||
})
|
})
|
||||||
ios.StopSpinner()
|
ios.StopSpinner()
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ var labelCreateCmd = &cobra.Command{
|
||||||
|
|
||||||
var labelEditCmd = &cobra.Command{
|
var labelEditCmd = &cobra.Command{
|
||||||
Use: "edit <name>",
|
Use: "edit <name>",
|
||||||
|
Aliases: []string{"update"},
|
||||||
Short: "Edit a label",
|
Short: "Edit a label",
|
||||||
Long: "Edit an existing label in a repository.",
|
Long: "Edit an existing label in a repository.",
|
||||||
Example: ` # Rename a label
|
Example: ` # Rename a label
|
||||||
|
|
|
||||||
160
cmd/logins.go
Normal file
160
cmd/logins.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
108
cmd/pr.go
108
cmd/pr.go
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
||||||
|
|
@ -14,6 +16,54 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"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: <N><unit>
|
||||||
|
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{
|
var prCmd = &cobra.Command{
|
||||||
Use: "pr",
|
Use: "pr",
|
||||||
Aliases: []string{"pull-request"},
|
Aliases: []string{"pull-request"},
|
||||||
|
|
@ -32,7 +82,13 @@ var prListCmd = &cobra.Command{
|
||||||
fgj pr list -s all -R owner/repo
|
fgj pr list -s all -R owner/repo
|
||||||
|
|
||||||
# Output as JSON
|
# 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,
|
RunE: runPRList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,6 +223,8 @@ func init() {
|
||||||
prListCmd.Flags().Bool("draft", false, "Filter by draft status")
|
prListCmd.Flags().Bool("draft", false, "Filter by draft status")
|
||||||
prListCmd.Flags().String("head", "", "Filter by head branch")
|
prListCmd.Flags().String("head", "", "Filter by head branch")
|
||||||
prListCmd.Flags().String("base", "", "Filter by base 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")
|
prListCmd.Flags().BoolP("web", "w", false, "Open list in web browser")
|
||||||
addJSONFlags(prListCmd, "Output pull requests as JSON")
|
addJSONFlags(prListCmd, "Output pull requests as JSON")
|
||||||
|
|
||||||
|
|
@ -217,6 +275,27 @@ func runPRList(cmd *cobra.Command, args []string) error {
|
||||||
draft, _ := cmd.Flags().GetBool("draft")
|
draft, _ := cmd.Flags().GetBool("draft")
|
||||||
head, _ := cmd.Flags().GetString("head")
|
head, _ := cmd.Flags().GetString("head")
|
||||||
base, _ := cmd.Flags().GetString("base")
|
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)
|
owner, name, err := parseRepo(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -249,7 +328,8 @@ func runPRList(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("invalid state: %s", state)
|
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...")
|
ios.StartSpinner("Fetching pull requests...")
|
||||||
var prs []*gitea.PullRequest
|
var prs []*gitea.PullRequest
|
||||||
|
|
@ -271,6 +351,9 @@ func runPRList(cmd *cobra.Command, args []string) error {
|
||||||
page++
|
page++
|
||||||
}
|
}
|
||||||
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
|
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
|
||||||
|
if hasSince || hasBefore {
|
||||||
|
prs = filterPRsByDate(prs, sinceTime, hasSince, beforeTime, hasBefore)
|
||||||
|
}
|
||||||
if len(prs) > limit {
|
if len(prs) > limit {
|
||||||
prs = prs[:limit]
|
prs = prs[:limit]
|
||||||
}
|
}
|
||||||
|
|
@ -354,6 +437,27 @@ func filterPRs(prs []*gitea.PullRequest, author, assignee string, labels []strin
|
||||||
return result
|
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 {
|
func runPRView(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
@ -20,6 +21,7 @@ type HostConfig struct {
|
||||||
User string `yaml:"user,omitempty"`
|
User string `yaml:"user,omitempty"`
|
||||||
GitProtocol string `yaml:"git_protocol,omitempty"`
|
GitProtocol string `yaml:"git_protocol,omitempty"`
|
||||||
MatchDirs []string `yaml:"match_dirs,omitempty"`
|
MatchDirs []string `yaml:"match_dirs,omitempty"`
|
||||||
|
Default bool `yaml:"default,omitempty"`
|
||||||
Order int `yaml:"-"` // config file order, set at load time
|
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)
|
// 3. Environment variable (FGJ_HOST)
|
||||||
// 4. Auto-detected hostname from git remote
|
// 4. Auto-detected hostname from git remote
|
||||||
// 5. match_dirs lookup (longest prefix match)
|
// 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) {
|
func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) {
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = viper.GetString("hostname")
|
hostname = viper.GetString("hostname")
|
||||||
|
|
@ -152,6 +155,10 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
|
||||||
hostname = c.ResolveHostByPath(cwd)
|
hostname = c.ResolveHostByPath(cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = c.DefaultHost()
|
||||||
|
}
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = "codeberg.org"
|
hostname = "codeberg.org"
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +171,27 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
|
||||||
return host, nil
|
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
|
// ResolveHostByPath finds the host whose match_dirs entry is the longest
|
||||||
// prefix of cwd. Returns "" if no match is found.
|
// prefix of cwd. Returns "" if no match is found.
|
||||||
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks
|
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue