2025-12-08 09:49:07 +01:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
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.
2026-04-19 23:04:33 -06:00
|
|
|
"sort"
|
2026-03-23 12:39:51 -06:00
|
|
|
"strings"
|
2025-12-08 09:49:07 +01:00
|
|
|
|
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Config struct {
|
|
|
|
|
Hosts map[string]HostConfig `yaml:"hosts"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type HostConfig struct {
|
2026-03-23 12:39:51 -06:00
|
|
|
Hostname string `yaml:"hostname"`
|
|
|
|
|
Token string `yaml:"token"`
|
|
|
|
|
User string `yaml:"user,omitempty"`
|
|
|
|
|
GitProtocol string `yaml:"git_protocol,omitempty"`
|
|
|
|
|
MatchDirs []string `yaml:"match_dirs,omitempty"`
|
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.
2026-04-19 23:04:33 -06:00
|
|
|
Default bool `yaml:"default,omitempty"`
|
2026-03-23 12:55:49 -06:00
|
|
|
Order int `yaml:"-"` // config file order, set at load time
|
2025-12-08 09:49:07 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func GetConfigDir() (string, error) {
|
2026-03-12 15:44:24 +01:00
|
|
|
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
|
|
|
|
|
return filepath.Join(xdgConfigHome, "fgj"), nil
|
|
|
|
|
}
|
2025-12-08 09:49:07 +01:00
|
|
|
home, err := os.UserHomeDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return filepath.Join(home, ".config", "fgj"), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func GetConfigPath() (string, error) {
|
|
|
|
|
dir, err := GetConfigDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return filepath.Join(dir, "config.yaml"), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func Load() (*Config, error) {
|
|
|
|
|
path, err := GetConfigPath()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2025-12-08 10:14:47 +01:00
|
|
|
return LoadFromPath(path)
|
|
|
|
|
}
|
2025-12-08 09:49:07 +01:00
|
|
|
|
2025-12-08 10:14:47 +01:00
|
|
|
func LoadFromPath(path string) (*Config, error) {
|
2025-12-08 09:49:07 +01:00
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
|
return &Config{Hosts: make(map[string]HostConfig)}, nil
|
|
|
|
|
}
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var cfg Config
|
|
|
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cfg.Hosts == nil {
|
|
|
|
|
cfg.Hosts = make(map[string]HostConfig)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:55:49 -06:00
|
|
|
// Parse again with yaml.Node to capture config file order for hosts
|
|
|
|
|
assignHostOrder(&cfg, data)
|
|
|
|
|
|
2025-12-08 09:49:07 +01:00
|
|
|
return &cfg, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:55:49 -06:00
|
|
|
// assignHostOrder walks the YAML document tree to find the "hosts" mapping
|
|
|
|
|
// and stamps each HostConfig.Order with its position in the file.
|
|
|
|
|
func assignHostOrder(cfg *Config, data []byte) {
|
|
|
|
|
var doc yaml.Node
|
|
|
|
|
if err := yaml.Unmarshal(data, &doc); err != nil || len(doc.Content) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
root := doc.Content[0] // mapping node
|
|
|
|
|
if root.Kind != yaml.MappingNode {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for i := 0; i+1 < len(root.Content); i += 2 {
|
|
|
|
|
if root.Content[i].Value == "hosts" {
|
|
|
|
|
hostsNode := root.Content[i+1]
|
|
|
|
|
if hostsNode.Kind != yaml.MappingNode {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
order := 0
|
|
|
|
|
for j := 0; j+1 < len(hostsNode.Content); j += 2 {
|
|
|
|
|
key := hostsNode.Content[j].Value
|
|
|
|
|
if h, ok := cfg.Hosts[key]; ok {
|
|
|
|
|
h.Order = order
|
|
|
|
|
cfg.Hosts[key] = h
|
|
|
|
|
order++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 09:49:07 +01:00
|
|
|
func (c *Config) Save() error {
|
|
|
|
|
path, err := GetConfigPath()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2025-12-08 10:14:47 +01:00
|
|
|
return c.SaveToPath(path)
|
|
|
|
|
}
|
2025-12-08 09:49:07 +01:00
|
|
|
|
2025-12-08 10:14:47 +01:00
|
|
|
func (c *Config) SaveToPath(path string) error {
|
2025-12-08 09:49:07 +01:00
|
|
|
dir := filepath.Dir(path)
|
|
|
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, err := yaml.Marshal(c)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return os.WriteFile(path, data, 0600)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 12:47:28 +01:00
|
|
|
// GetHost resolves the hostname to use for API client creation.
|
|
|
|
|
// Priority order:
|
|
|
|
|
// 1. Explicitly provided hostname parameter
|
|
|
|
|
// 2. CLI flag (--hostname)
|
|
|
|
|
// 3. Environment variable (FGJ_HOST)
|
|
|
|
|
// 4. Auto-detected hostname from git remote
|
2026-03-23 12:39:51 -06:00
|
|
|
// 5. match_dirs lookup (longest prefix match)
|
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.
2026-04-19 23:04:33 -06:00
|
|
|
// 6. Configured default host (HostConfig.Default == true)
|
|
|
|
|
// 7. Default to codeberg.org
|
2026-03-23 12:39:51 -06:00
|
|
|
func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) {
|
2025-12-08 09:49:07 +01:00
|
|
|
if hostname == "" {
|
|
|
|
|
hostname = viper.GetString("hostname")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hostname == "" {
|
|
|
|
|
hostname = os.Getenv("FGJ_HOST")
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 12:47:28 +01:00
|
|
|
if hostname == "" {
|
|
|
|
|
hostname = detectedHost
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:39:51 -06:00
|
|
|
if hostname == "" {
|
|
|
|
|
hostname = c.ResolveHostByPath(cwd)
|
|
|
|
|
}
|
|
|
|
|
|
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.
2026-04-19 23:04:33 -06:00
|
|
|
if hostname == "" {
|
|
|
|
|
hostname = c.DefaultHost()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 09:49:07 +01:00
|
|
|
if hostname == "" {
|
|
|
|
|
hostname = "codeberg.org"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
host, ok := c.Hosts[hostname]
|
|
|
|
|
if !ok {
|
|
|
|
|
return HostConfig{}, fmt.Errorf("no configuration found for host %s", hostname)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return host, nil
|
|
|
|
|
}
|
|
|
|
|
|
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.
2026-04-19 23:04:33 -06:00
|
|
|
// 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]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:39:51 -06:00
|
|
|
// 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
|
|
|
|
|
// to handle symlinks (e.g. macOS /tmp → /private/tmp).
|
2026-03-23 12:55:49 -06:00
|
|
|
// On ties (same prefix length from multiple hosts), the host appearing first
|
|
|
|
|
// in the config file wins and a warning is printed to stderr.
|
2026-03-23 12:39:51 -06:00
|
|
|
func (c *Config) ResolveHostByPath(cwd string) string {
|
|
|
|
|
if cwd == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve symlinks in cwd so /tmp becomes /private/tmp on macOS, etc.
|
|
|
|
|
if resolved, err := filepath.EvalSymlinks(cwd); err == nil {
|
|
|
|
|
cwd = resolved
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bestHost := ""
|
|
|
|
|
bestLen := 0
|
2026-03-23 12:55:49 -06:00
|
|
|
bestOrder := 0
|
|
|
|
|
tied := false
|
2026-03-23 12:39:51 -06:00
|
|
|
|
|
|
|
|
for hostname, host := range c.Hosts {
|
|
|
|
|
for _, dir := range host.MatchDirs {
|
|
|
|
|
if dir == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-23 12:50:49 -06:00
|
|
|
// Expand ~ to home directory
|
|
|
|
|
dir = expandHome(dir)
|
2026-03-23 12:39:51 -06:00
|
|
|
// Resolve symlinks in the configured dir as well
|
|
|
|
|
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
|
|
|
|
|
dir = resolved
|
|
|
|
|
}
|
|
|
|
|
// Normalize: ensure trailing slash for prefix matching
|
|
|
|
|
prefix := dir
|
|
|
|
|
if !strings.HasSuffix(prefix, "/") {
|
|
|
|
|
prefix += "/"
|
|
|
|
|
}
|
|
|
|
|
// Match if cwd equals dir exactly or is under it
|
|
|
|
|
if cwd == dir || strings.HasPrefix(cwd, prefix) {
|
|
|
|
|
if len(dir) > bestLen {
|
|
|
|
|
bestLen = len(dir)
|
|
|
|
|
bestHost = hostname
|
2026-03-23 12:55:49 -06:00
|
|
|
bestOrder = host.Order
|
|
|
|
|
tied = false
|
|
|
|
|
} else if len(dir) == bestLen && hostname != bestHost {
|
|
|
|
|
// Tie — pick the host with the lower Order (earlier in config)
|
|
|
|
|
if host.Order < bestOrder {
|
|
|
|
|
bestHost = hostname
|
|
|
|
|
bestOrder = host.Order
|
|
|
|
|
}
|
|
|
|
|
tied = true
|
2026-03-23 12:39:51 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:55:49 -06:00
|
|
|
if tied {
|
|
|
|
|
fmt.Fprintf(os.Stderr, "warning: multiple hosts match directory %q with the same specificity; using %s (first in config)\n", cwd, bestHost)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:39:51 -06:00
|
|
|
return bestHost
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:50:49 -06:00
|
|
|
// expandHome replaces a leading ~ with the user's home directory.
|
|
|
|
|
func expandHome(path string) string {
|
|
|
|
|
if path == "~" || strings.HasPrefix(path, "~/") {
|
|
|
|
|
home, err := os.UserHomeDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
return filepath.Join(home, path[1:])
|
|
|
|
|
}
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 09:49:07 +01:00
|
|
|
func (c *Config) SetHost(hostname string, host HostConfig) {
|
|
|
|
|
if c.Hosts == nil {
|
|
|
|
|
c.Hosts = make(map[string]HostConfig)
|
|
|
|
|
}
|
|
|
|
|
c.Hosts[hostname] = host
|
|
|
|
|
}
|