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:
sid 2026-04-19 23:04:33 -06:00
parent d15deaf064
commit 2d69873f3e
7 changed files with 462 additions and 7 deletions

View file

@ -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
View 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
}

View file

@ -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()

View file

@ -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
View 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
View file

@ -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")

View file

@ -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