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
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"
|
||||
"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()
|
||||
|
|
|
|||
|
|
@ -48,9 +48,10 @@ var labelCreateCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
var labelEditCmd = &cobra.Command{
|
||||
Use: "edit <name>",
|
||||
Short: "Edit a label",
|
||||
Long: "Edit an existing label in a repository.",
|
||||
Use: "edit <name>",
|
||||
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
|
||||
|
||||
|
|
|
|||
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"
|
||||
"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: <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{
|
||||
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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue