detached a9ff59d

feat: add human-friendly duration parser for token expiry

Extend Go's time.ParseDuration to support human-friendly units:
- d (days) = 24h
- w (weeks) = 7d
- M (months) = 30d (approximate)
- y (years) = 365d (approximate)

Compound durations like "1y6M" or "2w3d" work as expected.
Standard Go units (ns, us, ms, s, m, h) remain fully supported.

Updates CLI default from 720h to 30d for better readability.
commit
a9ff59d6bc577188d2b89c97199767a06fb6c1df
author
brice · · 2026-01-17 08:35 UTC
parent
7c3b292
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +316 -19
M docs/cli/commands.md +23 -5
@@ -386,23 +386,41 @@ gordon auth token generate --subject <name> [options]
386386 |--------|---------|-------------|
387387 | `--subject` | (required) | Username/subject for the token |
388388 | `--scopes` | `push,pull` | Comma-separated scopes |
389-| `--expiry` | `720h` | Duration until expiry (0 = never) |
389+| `--expiry` | `30d` | Duration until expiry (0 = never) |
390390 | `-c, --config` | Auto | Path to config file |
391391
392+**Duration format:**
393+
394+Supports human-friendly duration units:
395+
396+| Unit | Description | Example |
397+|------|-------------|---------|
398+| `d` | Days (24 hours) | `30d` |
399+| `w` | Weeks (7 days) | `2w` |
400+| `M` | Months (30 days) | `6M` |
401+| `y` | Years (365 days) | `1y` |
402+
403+Compound durations are also supported: `1y6M`, `2w3d`, `1d12h`
404+
405+Standard Go durations also work: `24h`, `30m`, `1h30m`
406+
392407 **Examples:**
393408
394409 ```bash
395410 # CI token that never expires
396411 gordon auth token generate --subject github-actions --expiry 0
397412
398-# Read-only token
399-gordon auth token generate --subject reader --scopes pull --expiry 720h
413+# Read-only token for 30 days
414+gordon auth token generate --subject reader --scopes pull --expiry 30d
400415
401-# Push-only token for build system
402-gordon auth token generate --subject builder --scopes push --expiry 0
416+# Push-only token for 1 year
417+gordon auth token generate --subject builder --scopes push --expiry 1y
403418
404419 # Temporary token (24 hours)
405420 gordon auth token generate --subject temp --expiry 24h
421+
422+# Token for 6 months
423+gordon auth token generate --subject deploy --expiry 6M
406424 ```
407425
408426 **Output:**
@@ -386,23 +386,41 @@ gordon auth token generate --subject <name> [options]
386 |--------|---------|-------------|386 |--------|---------|-------------|
387 | `--subject` | (required) | Username/subject for the token |387 | `--subject` | (required) | Username/subject for the token |
388 | `--scopes` | `push,pull` | Comma-separated scopes |388 | `--scopes` | `push,pull` | Comma-separated scopes |
389-| `--expiry` | `720h` | Duration until expiry (0 = never) |389+| `--expiry` | `30d` | Duration until expiry (0 = never) |
390 | `-c, --config` | Auto | Path to config file |390 | `-c, --config` | Auto | Path to config file |
391 391
392+**Duration format:**
393+
394+Supports human-friendly duration units:
395+
396+| Unit | Description | Example |
397+|------|-------------|---------|
398+| `d` | Days (24 hours) | `30d` |
399+| `w` | Weeks (7 days) | `2w` |
400+| `M` | Months (30 days) | `6M` |
401+| `y` | Years (365 days) | `1y` |
402+
403+Compound durations are also supported: `1y6M`, `2w3d`, `1d12h`
404+
405+Standard Go durations also work: `24h`, `30m`, `1h30m`
406+
392 **Examples:**407 **Examples:**
393 408
394 ```bash409 ```bash
395 # CI token that never expires410 # CI token that never expires
396 gordon auth token generate --subject github-actions --expiry 0411 gordon auth token generate --subject github-actions --expiry 0
397 412
398-# Read-only token413+# Read-only token for 30 days
399-gordon auth token generate --subject reader --scopes pull --expiry 720h414+gordon auth token generate --subject reader --scopes pull --expiry 30d
400 415
401-# Push-only token for build system416+# Push-only token for 1 year
402-gordon auth token generate --subject builder --scopes push --expiry 0417+gordon auth token generate --subject builder --scopes push --expiry 1y
403 418
404 # Temporary token (24 hours)419 # Temporary token (24 hours)
405 gordon auth token generate --subject temp --expiry 24h420 gordon auth token generate --subject temp --expiry 24h
421+
422+# Token for 6 months
423+gordon auth token generate --subject deploy --expiry 6M
406 ```424 ```
407 425
408 **Output:**426 **Output:**
M docs/config/index.md +2 -2
@@ -46,7 +46,7 @@ type = "token" # "password" or "token"
4646 # password_hash = "gordon/registry/password_hash"
4747 # Token auth:
4848 token_secret = "gordon/registry/token_secret"
49-token_expiry = "720h" # Duration or 0 for never
49+token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never
5050
5151 # Deploy behavior
5252 [deploy]
@@ -134,7 +134,7 @@ enabled = false # Auto-create routes from image names
134134 | `secrets.backend` | `"unsafe"` |
135135 | `registry_auth.enabled` | `false` |
136136 | `registry_auth.type` | `"password"` |
137-| `registry_auth.token_expiry` | `"720h"` |
137+| `registry_auth.token_expiry` | `"30d"` |
138138 | `deploy.pull_policy` | `"if-tag-changed"` |
139139 | `logging.level` | `"info"` |
140140 | `logging.format` | `"console"` |
@@ -46,7 +46,7 @@ type = "token" # "password" or "token"
46 # password_hash = "gordon/registry/password_hash"46 # password_hash = "gordon/registry/password_hash"
47 # Token auth:47 # Token auth:
48 token_secret = "gordon/registry/token_secret"48 token_secret = "gordon/registry/token_secret"
49-token_expiry = "720h" # Duration or 0 for never49+token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never
50 50
51 # Deploy behavior51 # Deploy behavior
52 [deploy]52 [deploy]
@@ -134,7 +134,7 @@ enabled = false # Auto-create routes from image names
134 | `secrets.backend` | `"unsafe"` |134 | `secrets.backend` | `"unsafe"` |
135 | `registry_auth.enabled` | `false` |135 | `registry_auth.enabled` | `false` |
136 | `registry_auth.type` | `"password"` |136 | `registry_auth.type` | `"password"` |
137-| `registry_auth.token_expiry` | `"720h"` |137+| `registry_auth.token_expiry` | `"30d"` |
138 | `deploy.pull_policy` | `"if-tag-changed"` |138 | `deploy.pull_policy` | `"if-tag-changed"` |
139 | `logging.level` | `"info"` |139 | `logging.level` | `"info"` |
140 | `logging.format` | `"console"` |140 | `logging.format` | `"console"` |
M docs/config/registry-auth.md +16 -7
@@ -27,7 +27,7 @@ token_expiry = "720h" # Duration or 0 for never
2727 | `username` | string | - | Username for password auth |
2828 | `password_hash` | string | - | Path to bcrypt hash in secrets backend |
2929 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |
30-| `token_expiry` | string | `"720h"` | Token validity duration (0 = never expires) |
30+| `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires). Supports: d (days), w (weeks), M (months), y (years) |
3131
3232 ## Authentication Types
3333
@@ -43,7 +43,7 @@ backend = "pass"
4343 enabled = true
4444 type = "token"
4545 token_secret = "gordon/registry/token_secret"
46-token_expiry = "720h" # 30 days, or "0" for never
46+token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M
4747 ```
4848
4949 **Setup:**
@@ -113,7 +113,7 @@ Tokens can have different permission levels:
113113
114114 ```bash
115115 # Pull-only token (for read-only access)
116-gordon auth token generate --subject reader --scopes pull --expiry 720h
116+gordon auth token generate --subject reader --scopes pull --expiry 30d
117117
118118 # Push-only token (for CI builds)
119119 gordon auth token generate --subject builder --scopes push --expiry 0
@@ -121,17 +121,26 @@ gordon auth token generate --subject builder --scopes push --expiry 0
121121
122122 ## Token Expiry
123123
124-Control how long tokens remain valid:
124+Control how long tokens remain valid. Supports human-friendly units:
125+- `d` - days (24 hours)
126+- `w` - weeks (7 days)
127+- `M` - months (30 days)
128+- `y` - years (365 days)
129+
130+Compound durations work too: `1y6M`, `2w3d`
125131
126132 ```bash
127133 # Never expires (for CI/CD)
128134 gordon auth token generate --subject ci --expiry 0
129135
136+# Expires in 1 year
137+gordon auth token generate --subject deploy --expiry 1y
138+
130139 # Expires in 30 days
131-gordon auth token generate --subject temp --expiry 720h
140+gordon auth token generate --subject temp --expiry 30d
132141
133-# Expires in 24 hours
134-gordon auth token generate --subject short --expiry 24h
142+# Expires in 2 weeks
143+gordon auth token generate --subject short --expiry 2w
135144 ```
136145
137146 ## Instance-Specific Tokens
@@ -27,7 +27,7 @@ token_expiry = "720h" # Duration or 0 for never
27 | `username` | string | - | Username for password auth |27 | `username` | string | - | Username for password auth |
28 | `password_hash` | string | - | Path to bcrypt hash in secrets backend |28 | `password_hash` | string | - | Path to bcrypt hash in secrets backend |
29 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |29 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |
30-| `token_expiry` | string | `"720h"` | Token validity duration (0 = never expires) |30+| `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires). Supports: d (days), w (weeks), M (months), y (years) |
31 31
32 ## Authentication Types32 ## Authentication Types
33 33
@@ -43,7 +43,7 @@ backend = "pass"
43 enabled = true43 enabled = true
44 type = "token"44 type = "token"
45 token_secret = "gordon/registry/token_secret"45 token_secret = "gordon/registry/token_secret"
46-token_expiry = "720h" # 30 days, or "0" for never46+token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M
47 ```47 ```
48 48
49 **Setup:**49 **Setup:**
@@ -113,7 +113,7 @@ Tokens can have different permission levels:
113 113
114 ```bash114 ```bash
115 # Pull-only token (for read-only access)115 # Pull-only token (for read-only access)
116-gordon auth token generate --subject reader --scopes pull --expiry 720h116+gordon auth token generate --subject reader --scopes pull --expiry 30d
117 117
118 # Push-only token (for CI builds)118 # Push-only token (for CI builds)
119 gordon auth token generate --subject builder --scopes push --expiry 0119 gordon auth token generate --subject builder --scopes push --expiry 0
@@ -121,17 +121,26 @@ gordon auth token generate --subject builder --scopes push --expiry 0
121 121
122 ## Token Expiry122 ## Token Expiry
123 123
124-Control how long tokens remain valid:124+Control how long tokens remain valid. Supports human-friendly units:
125+- `d` - days (24 hours)
126+- `w` - weeks (7 days)
127+- `M` - months (30 days)
128+- `y` - years (365 days)
129+
130+Compound durations work too: `1y6M`, `2w3d`
125 131
126 ```bash132 ```bash
127 # Never expires (for CI/CD)133 # Never expires (for CI/CD)
128 gordon auth token generate --subject ci --expiry 0134 gordon auth token generate --subject ci --expiry 0
129 135
136+# Expires in 1 year
137+gordon auth token generate --subject deploy --expiry 1y
138+
130 # Expires in 30 days139 # Expires in 30 days
131-gordon auth token generate --subject temp --expiry 720h140+gordon auth token generate --subject temp --expiry 30d
132 141
133-# Expires in 24 hours142+# Expires in 2 weeks
134-gordon auth token generate --subject short --expiry 24h143+gordon auth token generate --subject short --expiry 2w
135 ```144 ```
136 145
137 ## Instance-Specific Tokens146 ## Instance-Specific Tokens
M internal/adapters/in/cli/auth.go +12 -4
@@ -18,6 +18,7 @@ import (
1818 "gordon/internal/app"
1919 "gordon/internal/domain"
2020 "gordon/internal/usecase/auth"
21+ "gordon/pkg/duration"
2122 )
2223
2324 // newAuthCmd creates the auth command group.
@@ -67,7 +68,14 @@ func newTokenGenerateCmd() *cobra.Command {
6768 The token will be stored in the configured secrets backend and can be used
6869 for docker login as: docker login -u <subject> -p <token>
6970
70-Use --expiry=0 to generate a token that never expires (useful for CI).`,
71+Expiry supports human-friendly durations:
72+ - d (days): 1d = 24 hours
73+ - w (weeks): 1w = 7 days
74+ - M (months): 1M = 30 days
75+ - y (years): 1y = 365 days
76+
77+Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
78+Use --expiry=0 for a token that never expires (useful for CI).`,
7179 RunE: func(cmd *cobra.Command, args []string) error {
7280 return runTokenGenerate(subject, scopes, expiry, configPath)
7381 },
@@ -75,7 +83,7 @@ Use --expiry=0 to generate a token that never expires (useful for CI).`,
7583
7684 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
7785 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
78- cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)")
86+ cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
7987 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
8088
8189 _ = cmd.MarkFlagRequired("subject")
@@ -196,10 +204,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
196204 return err
197205 }
198206
199- // Parse expiry
207+ // Parse expiry (supports human-friendly units: d, w, M, y)
200208 var expiry time.Duration
201209 if expiryStr != "0" {
202- expiry, err = time.ParseDuration(expiryStr)
210+ expiry, err = duration.Parse(expiryStr)
203211 if err != nil {
204212 return fmt.Errorf("invalid expiry duration: %w", err)
205213 }
@@ -18,6 +18,7 @@ import (
18 "gordon/internal/app"18 "gordon/internal/app"
19 "gordon/internal/domain"19 "gordon/internal/domain"
20 "gordon/internal/usecase/auth"20 "gordon/internal/usecase/auth"
21+ "gordon/pkg/duration"
21 )22 )
22 23
23 // newAuthCmd creates the auth command group.24 // newAuthCmd creates the auth command group.
@@ -67,7 +68,14 @@ func newTokenGenerateCmd() *cobra.Command {
67 The token will be stored in the configured secrets backend and can be used68 The token will be stored in the configured secrets backend and can be used
68 for docker login as: docker login -u <subject> -p <token>69 for docker login as: docker login -u <subject> -p <token>
69 70
70-Use --expiry=0 to generate a token that never expires (useful for CI).`,71+Expiry supports human-friendly durations:
72+ - d (days): 1d = 24 hours
73+ - w (weeks): 1w = 7 days
74+ - M (months): 1M = 30 days
75+ - y (years): 1y = 365 days
76+
77+Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
78+Use --expiry=0 for a token that never expires (useful for CI).`,
71 RunE: func(cmd *cobra.Command, args []string) error {79 RunE: func(cmd *cobra.Command, args []string) error {
72 return runTokenGenerate(subject, scopes, expiry, configPath)80 return runTokenGenerate(subject, scopes, expiry, configPath)
73 },81 },
@@ -75,7 +83,7 @@ Use --expiry=0 to generate a token that never expires (useful for CI).`,
75 83
76 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")84 cmd.Flags().StringVar(&subject, "subject", "", "Subject/username for the token (required)")
77 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")85 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
78- cmd.Flags().StringVar(&expiry, "expiry", "720h", "Token expiry duration (e.g., 720h, 24h, 0 for never)")86+ cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
79 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")87 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
80 88
81 _ = cmd.MarkFlagRequired("subject")89 _ = cmd.MarkFlagRequired("subject")
@@ -196,10 +204,10 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
196 return err204 return err
197 }205 }
198 206
199- // Parse expiry207+ // Parse expiry (supports human-friendly units: d, w, M, y)
200 var expiry time.Duration208 var expiry time.Duration
201 if expiryStr != "0" {209 if expiryStr != "0" {
202- expiry, err = time.ParseDuration(expiryStr)210+ expiry, err = duration.Parse(expiryStr)
203 if err != nil {211 if err != nil {
204 return fmt.Errorf("invalid expiry duration: %w", err)212 return fmt.Errorf("invalid expiry duration: %w", err)
205 }213 }
M internal/app/run.go +4 -1
@@ -52,6 +52,9 @@ import (
5252 "gordon/internal/usecase/proxy"
5353 registrySvc "gordon/internal/usecase/registry"
5454 secretsSvc "gordon/internal/usecase/secrets"
55+
56+ // Pkg
57+ "gordon/pkg/duration"
5558 )
5659
5760 // Config holds the application configuration.
@@ -640,7 +643,7 @@ func parseTokenExpiry(expiry string) (time.Duration, error) {
640643 return 0, nil
641644 }
642645
643- parsed, err := time.ParseDuration(expiry)
646+ parsed, err := duration.Parse(expiry)
644647 if err != nil {
645648 return 0, fmt.Errorf("invalid token_expiry: %w", err)
646649 }
@@ -52,6 +52,9 @@ import (
52 "gordon/internal/usecase/proxy"52 "gordon/internal/usecase/proxy"
53 registrySvc "gordon/internal/usecase/registry"53 registrySvc "gordon/internal/usecase/registry"
54 secretsSvc "gordon/internal/usecase/secrets"54 secretsSvc "gordon/internal/usecase/secrets"
55+
56+ // Pkg
57+ "gordon/pkg/duration"
55 )58 )
56 59
57 // Config holds the application configuration.60 // Config holds the application configuration.
@@ -640,7 +643,7 @@ func parseTokenExpiry(expiry string) (time.Duration, error) {
640 return 0, nil643 return 0, nil
641 }644 }
642 645
643- parsed, err := time.ParseDuration(expiry)646+ parsed, err := duration.Parse(expiry)
644 if err != nil {647 if err != nil {
645 return 0, fmt.Errorf("invalid token_expiry: %w", err)648 return 0, fmt.Errorf("invalid token_expiry: %w", err)
646 }649 }
A pkg/duration/parse.go +127 -0
@@ -0,0 +1,127 @@
1+// Package duration provides human-friendly duration parsing.
2+package duration
3+
4+import (
5+ "fmt"
6+ "regexp"
7+ "strconv"
8+ "strings"
9+ "time"
10+)
11+
12+// Common duration constants for human-friendly units.
13+const (
14+ Day = 24 * time.Hour
15+ Week = 7 * Day
16+ Month = 30 * Day // Approximate
17+ Year = 365 * Day // Approximate
18+)
19+
20+// unitMultipliers maps human-friendly unit suffixes to their duration values.
21+var unitMultipliers = map[string]time.Duration{
22+ "d": Day,
23+ "w": Week,
24+ "M": Month, // Capital M for months to distinguish from minutes
25+ "y": Year,
26+}
27+
28+// durationPattern matches duration components like "1y", "6M", "2w", "3d".
29+var durationPattern = regexp.MustCompile(`(\d+)([ywMd])`)
30+
31+// Parse extends time.ParseDuration to support human-friendly units:
32+// - d (days) = 24h
33+// - w (weeks) = 7d
34+// - M (months) = 30d (approximate)
35+// - y (years) = 365d (approximate)
36+//
37+// Standard Go duration units (ns, us, ms, s, m, h) are also supported.
38+// Compound durations like "1y6M" or "2w3d" or "1d12h" work as expected.
39+//
40+// Special case: "0" returns 0 duration (useful for never-expiring tokens).
41+//
42+// Examples:
43+//
44+// Parse("1d") // 24 hours
45+// Parse("2w") // 14 days
46+// Parse("1M") // 30 days
47+// Parse("1y") // 365 days
48+// Parse("1y6M") // 1 year and 6 months
49+// Parse("2w3d") // 2 weeks and 3 days
50+// Parse("1d12h") // 1 day and 12 hours
51+// Parse("24h") // standard Go duration
52+// Parse("0") // zero duration
53+func Parse(s string) (time.Duration, error) {
54+ s = strings.TrimSpace(s)
55+
56+ if s == "" {
57+ return 0, fmt.Errorf("empty duration string")
58+ }
59+
60+ // Special case: "0" means no expiry
61+ if s == "0" {
62+ return 0, nil
63+ }
64+
65+ // Check if the string contains any human-friendly units
66+ if !containsHumanUnits(s) {
67+ // Fall back to standard Go parsing
68+ d, err := time.ParseDuration(s)
69+ if err != nil {
70+ return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err)
71+ }
72+ return d, nil
73+ }
74+
75+ // Parse human-friendly units
76+ return parseHumanDuration(s)
77+}
78+
79+// containsHumanUnits checks if the string contains any human-friendly unit suffixes.
80+func containsHumanUnits(s string) bool {
81+ for unit := range unitMultipliers {
82+ if strings.Contains(s, unit) {
83+ return true
84+ }
85+ }
86+ return false
87+}
88+
89+// parseHumanDuration parses a duration string containing human-friendly units.
90+func parseHumanDuration(s string) (time.Duration, error) {
91+ var total time.Duration
92+ remaining := s
93+
94+ // First, extract all human-friendly units (y, M, w, d)
95+ matches := durationPattern.FindAllStringSubmatch(remaining, -1)
96+ if len(matches) > 0 {
97+ for _, match := range matches {
98+ value, err := strconv.ParseInt(match[1], 10, 64)
99+ if err != nil {
100+ return 0, fmt.Errorf("invalid duration value %q in %q", match[1], s)
101+ }
102+
103+ unit := match[2]
104+ multiplier, ok := unitMultipliers[unit]
105+ if !ok {
106+ return 0, fmt.Errorf("unknown duration unit %q in %q", unit, s)
107+ }
108+
109+ total += time.Duration(value) * multiplier
110+ }
111+
112+ // Remove the matched human-friendly parts
113+ remaining = durationPattern.ReplaceAllString(remaining, "")
114+ }
115+
116+ // If there's remaining content, try to parse it as standard Go duration
117+ remaining = strings.TrimSpace(remaining)
118+ if remaining != "" {
119+ d, err := time.ParseDuration(remaining)
120+ if err != nil {
121+ return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err)
122+ }
123+ total += d
124+ }
125+
126+ return total, nil
127+}
@@ -0,0 +1,127 @@
1+// Package duration provides human-friendly duration parsing.
2+package duration
3+
4+import (
5+ "fmt"
6+ "regexp"
7+ "strconv"
8+ "strings"
9+ "time"
10+)
11+
12+// Common duration constants for human-friendly units.
13+const (
14+ Day = 24 * time.Hour
15+ Week = 7 * Day
16+ Month = 30 * Day // Approximate
17+ Year = 365 * Day // Approximate
18+)
19+
20+// unitMultipliers maps human-friendly unit suffixes to their duration values.
21+var unitMultipliers = map[string]time.Duration{
22+ "d": Day,
23+ "w": Week,
24+ "M": Month, // Capital M for months to distinguish from minutes
25+ "y": Year,
26+}
27+
28+// durationPattern matches duration components like "1y", "6M", "2w", "3d".
29+var durationPattern = regexp.MustCompile(`(\d+)([ywMd])`)
30+
31+// Parse extends time.ParseDuration to support human-friendly units:
32+// - d (days) = 24h
33+// - w (weeks) = 7d
34+// - M (months) = 30d (approximate)
35+// - y (years) = 365d (approximate)
36+//
37+// Standard Go duration units (ns, us, ms, s, m, h) are also supported.
38+// Compound durations like "1y6M" or "2w3d" or "1d12h" work as expected.
39+//
40+// Special case: "0" returns 0 duration (useful for never-expiring tokens).
41+//
42+// Examples:
43+//
44+// Parse("1d") // 24 hours
45+// Parse("2w") // 14 days
46+// Parse("1M") // 30 days
47+// Parse("1y") // 365 days
48+// Parse("1y6M") // 1 year and 6 months
49+// Parse("2w3d") // 2 weeks and 3 days
50+// Parse("1d12h") // 1 day and 12 hours
51+// Parse("24h") // standard Go duration
52+// Parse("0") // zero duration
53+func Parse(s string) (time.Duration, error) {
54+ s = strings.TrimSpace(s)
55+
56+ if s == "" {
57+ return 0, fmt.Errorf("empty duration string")
58+ }
59+
60+ // Special case: "0" means no expiry
61+ if s == "0" {
62+ return 0, nil
63+ }
64+
65+ // Check if the string contains any human-friendly units
66+ if !containsHumanUnits(s) {
67+ // Fall back to standard Go parsing
68+ d, err := time.ParseDuration(s)
69+ if err != nil {
70+ return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err)
71+ }
72+ return d, nil
73+ }
74+
75+ // Parse human-friendly units
76+ return parseHumanDuration(s)
77+}
78+
79+// containsHumanUnits checks if the string contains any human-friendly unit suffixes.
80+func containsHumanUnits(s string) bool {
81+ for unit := range unitMultipliers {
82+ if strings.Contains(s, unit) {
83+ return true
84+ }
85+ }
86+ return false
87+}
88+
89+// parseHumanDuration parses a duration string containing human-friendly units.
90+func parseHumanDuration(s string) (time.Duration, error) {
91+ var total time.Duration
92+ remaining := s
93+
94+ // First, extract all human-friendly units (y, M, w, d)
95+ matches := durationPattern.FindAllStringSubmatch(remaining, -1)
96+ if len(matches) > 0 {
97+ for _, match := range matches {
98+ value, err := strconv.ParseInt(match[1], 10, 64)
99+ if err != nil {
100+ return 0, fmt.Errorf("invalid duration value %q in %q", match[1], s)
101+ }
102+
103+ unit := match[2]
104+ multiplier, ok := unitMultipliers[unit]
105+ if !ok {
106+ return 0, fmt.Errorf("unknown duration unit %q in %q", unit, s)
107+ }
108+
109+ total += time.Duration(value) * multiplier
110+ }
111+
112+ // Remove the matched human-friendly parts
113+ remaining = durationPattern.ReplaceAllString(remaining, "")
114+ }
115+
116+ // If there's remaining content, try to parse it as standard Go duration
117+ remaining = strings.TrimSpace(remaining)
118+ if remaining != "" {
119+ d, err := time.ParseDuration(remaining)
120+ if err != nil {
121+ return 0, fmt.Errorf("invalid duration %q: %w (supported units: ns, us, ms, s, m, h, d, w, M, y)", s, err)
122+ }
123+ total += d
124+ }
125+
126+ return total, nil
127+}
A pkg/duration/parse_test.go +132 -0
@@ -0,0 +1,132 @@
1+package duration
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestParse(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ input string
12+ want time.Duration
13+ wantErr bool
14+ }{
15+ // Single human-friendly units
16+ {name: "1 day", input: "1d", want: Day},
17+ {name: "2 days", input: "2d", want: 2 * Day},
18+ {name: "1 week", input: "1w", want: Week},
19+ {name: "2 weeks", input: "2w", want: 2 * Week},
20+ {name: "1 month", input: "1M", want: Month},
21+ {name: "3 months", input: "3M", want: 3 * Month},
22+ {name: "1 year", input: "1y", want: Year},
23+ {name: "2 years", input: "2y", want: 2 * Year},
24+
25+ // Compound human-friendly units
26+ {name: "1 year 6 months", input: "1y6M", want: Year + 6*Month},
27+ {name: "2 weeks 3 days", input: "2w3d", want: 2*Week + 3*Day},
28+ {name: "1 year 2 months 1 week", input: "1y2M1w", want: Year + 2*Month + Week},
29+
30+ // Mixed with standard Go units
31+ {name: "1 day 12 hours", input: "1d12h", want: Day + 12*time.Hour},
32+ {name: "2 weeks 6 hours", input: "2w6h", want: 2*Week + 6*time.Hour},
33+ {name: "1 month 1 day 1 hour", input: "1M1d1h", want: Month + Day + time.Hour},
34+ {name: "1 year 30 minutes", input: "1y30m", want: Year + 30*time.Minute},
35+
36+ // Standard Go duration units (fallback)
37+ {name: "24 hours", input: "24h", want: 24 * time.Hour},
38+ {name: "30 minutes", input: "30m", want: 30 * time.Minute},
39+ {name: "1 hour 30 minutes", input: "1h30m", want: time.Hour + 30*time.Minute},
40+ {name: "1 second", input: "1s", want: time.Second},
41+ {name: "500 milliseconds", input: "500ms", want: 500 * time.Millisecond},
42+ {name: "1000 microseconds", input: "1000us", want: 1000 * time.Microsecond},
43+ {name: "1000000 nanoseconds", input: "1000000ns", want: 1000000 * time.Nanosecond},
44+
45+ // Special cases
46+ {name: "zero duration", input: "0", want: 0},
47+ {name: "zero with unit", input: "0d", want: 0},
48+ {name: "zero hours", input: "0h", want: 0},
49+
50+ // Large values
51+ {name: "10 years", input: "10y", want: 10 * Year},
52+ {name: "52 weeks", input: "52w", want: 52 * Week},
53+ {name: "365 days", input: "365d", want: 365 * Day},
54+
55+ // Edge cases with whitespace (should be trimmed)
56+ {name: "whitespace around", input: " 1d ", want: Day},
57+
58+ // Error cases
59+ {name: "empty string", input: "", wantErr: true},
60+ {name: "invalid format", input: "abc", wantErr: true},
61+ {name: "invalid unit", input: "1x", wantErr: true},
62+ {name: "missing value", input: "d", wantErr: true},
63+ {name: "negative not supported by Go", input: "-1d", wantErr: true},
64+ }
65+
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ got, err := Parse(tt.input)
69+ if (err != nil) != tt.wantErr {
70+ t.Errorf("Parse(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
71+ return
72+ }
73+ if !tt.wantErr && got != tt.want {
74+ t.Errorf("Parse(%q) = %v, want %v", tt.input, got, tt.want)
75+ }
76+ })
77+ }
78+}
79+
80+func TestParseConstants(t *testing.T) {
81+ // Verify our constants are correct
82+ if Day != 24*time.Hour {
83+ t.Errorf("Day = %v, want %v", Day, 24*time.Hour)
84+ }
85+ if Week != 7*24*time.Hour {
86+ t.Errorf("Week = %v, want %v", Week, 7*24*time.Hour)
87+ }
88+ if Month != 30*24*time.Hour {
89+ t.Errorf("Month = %v, want %v", Month, 30*24*time.Hour)
90+ }
91+ if Year != 365*24*time.Hour {
92+ t.Errorf("Year = %v, want %v", Year, 365*24*time.Hour)
93+ }
94+}
95+
96+func TestParseEquivalences(t *testing.T) {
97+ // Test that our human-friendly units produce expected equivalences
98+ tests := []struct {
99+ name string
100+ human string
101+ goStd string
102+ hours int
103+ }{
104+ {name: "1 day = 24 hours", human: "1d", goStd: "24h", hours: 24},
105+ {name: "1 week = 168 hours", human: "1w", goStd: "168h", hours: 168},
106+ {name: "1 month = 720 hours", human: "1M", goStd: "720h", hours: 720},
107+ {name: "1 year = 8760 hours", human: "1y", goStd: "8760h", hours: 8760},
108+ }
109+
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ humanDur, err := Parse(tt.human)
113+ if err != nil {
114+ t.Fatalf("Parse(%q) error = %v", tt.human, err)
115+ }
116+
117+ goDur, err := time.ParseDuration(tt.goStd)
118+ if err != nil {
119+ t.Fatalf("time.ParseDuration(%q) error = %v", tt.goStd, err)
120+ }
121+
122+ if humanDur != goDur {
123+ t.Errorf("Parse(%q) = %v, want %v (from %q)", tt.human, humanDur, goDur, tt.goStd)
124+ }
125+
126+ expectedHours := time.Duration(tt.hours) * time.Hour
127+ if humanDur != expectedHours {
128+ t.Errorf("Parse(%q) = %v, want %d hours", tt.human, humanDur, tt.hours)
129+ }
130+ })
131+ }
132+}
@@ -0,0 +1,132 @@
1+package duration
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestParse(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ input string
12+ want time.Duration
13+ wantErr bool
14+ }{
15+ // Single human-friendly units
16+ {name: "1 day", input: "1d", want: Day},
17+ {name: "2 days", input: "2d", want: 2 * Day},
18+ {name: "1 week", input: "1w", want: Week},
19+ {name: "2 weeks", input: "2w", want: 2 * Week},
20+ {name: "1 month", input: "1M", want: Month},
21+ {name: "3 months", input: "3M", want: 3 * Month},
22+ {name: "1 year", input: "1y", want: Year},
23+ {name: "2 years", input: "2y", want: 2 * Year},
24+
25+ // Compound human-friendly units
26+ {name: "1 year 6 months", input: "1y6M", want: Year + 6*Month},
27+ {name: "2 weeks 3 days", input: "2w3d", want: 2*Week + 3*Day},
28+ {name: "1 year 2 months 1 week", input: "1y2M1w", want: Year + 2*Month + Week},
29+
30+ // Mixed with standard Go units
31+ {name: "1 day 12 hours", input: "1d12h", want: Day + 12*time.Hour},
32+ {name: "2 weeks 6 hours", input: "2w6h", want: 2*Week + 6*time.Hour},
33+ {name: "1 month 1 day 1 hour", input: "1M1d1h", want: Month + Day + time.Hour},
34+ {name: "1 year 30 minutes", input: "1y30m", want: Year + 30*time.Minute},
35+
36+ // Standard Go duration units (fallback)
37+ {name: "24 hours", input: "24h", want: 24 * time.Hour},
38+ {name: "30 minutes", input: "30m", want: 30 * time.Minute},
39+ {name: "1 hour 30 minutes", input: "1h30m", want: time.Hour + 30*time.Minute},
40+ {name: "1 second", input: "1s", want: time.Second},
41+ {name: "500 milliseconds", input: "500ms", want: 500 * time.Millisecond},
42+ {name: "1000 microseconds", input: "1000us", want: 1000 * time.Microsecond},
43+ {name: "1000000 nanoseconds", input: "1000000ns", want: 1000000 * time.Nanosecond},
44+
45+ // Special cases
46+ {name: "zero duration", input: "0", want: 0},
47+ {name: "zero with unit", input: "0d", want: 0},
48+ {name: "zero hours", input: "0h", want: 0},
49+
50+ // Large values
51+ {name: "10 years", input: "10y", want: 10 * Year},
52+ {name: "52 weeks", input: "52w", want: 52 * Week},
53+ {name: "365 days", input: "365d", want: 365 * Day},
54+
55+ // Edge cases with whitespace (should be trimmed)
56+ {name: "whitespace around", input: " 1d ", want: Day},
57+
58+ // Error cases
59+ {name: "empty string", input: "", wantErr: true},
60+ {name: "invalid format", input: "abc", wantErr: true},
61+ {name: "invalid unit", input: "1x", wantErr: true},
62+ {name: "missing value", input: "d", wantErr: true},
63+ {name: "negative not supported by Go", input: "-1d", wantErr: true},
64+ }
65+
66+ for _, tt := range tests {
67+ t.Run(tt.name, func(t *testing.T) {
68+ got, err := Parse(tt.input)
69+ if (err != nil) != tt.wantErr {
70+ t.Errorf("Parse(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
71+ return
72+ }
73+ if !tt.wantErr && got != tt.want {
74+ t.Errorf("Parse(%q) = %v, want %v", tt.input, got, tt.want)
75+ }
76+ })
77+ }
78+}
79+
80+func TestParseConstants(t *testing.T) {
81+ // Verify our constants are correct
82+ if Day != 24*time.Hour {
83+ t.Errorf("Day = %v, want %v", Day, 24*time.Hour)
84+ }
85+ if Week != 7*24*time.Hour {
86+ t.Errorf("Week = %v, want %v", Week, 7*24*time.Hour)
87+ }
88+ if Month != 30*24*time.Hour {
89+ t.Errorf("Month = %v, want %v", Month, 30*24*time.Hour)
90+ }
91+ if Year != 365*24*time.Hour {
92+ t.Errorf("Year = %v, want %v", Year, 365*24*time.Hour)
93+ }
94+}
95+
96+func TestParseEquivalences(t *testing.T) {
97+ // Test that our human-friendly units produce expected equivalences
98+ tests := []struct {
99+ name string
100+ human string
101+ goStd string
102+ hours int
103+ }{
104+ {name: "1 day = 24 hours", human: "1d", goStd: "24h", hours: 24},
105+ {name: "1 week = 168 hours", human: "1w", goStd: "168h", hours: 168},
106+ {name: "1 month = 720 hours", human: "1M", goStd: "720h", hours: 720},
107+ {name: "1 year = 8760 hours", human: "1y", goStd: "8760h", hours: 8760},
108+ }
109+
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ humanDur, err := Parse(tt.human)
113+ if err != nil {
114+ t.Fatalf("Parse(%q) error = %v", tt.human, err)
115+ }
116+
117+ goDur, err := time.ParseDuration(tt.goStd)
118+ if err != nil {
119+ t.Fatalf("time.ParseDuration(%q) error = %v", tt.goStd, err)
120+ }
121+
122+ if humanDur != goDur {
123+ t.Errorf("Parse(%q) = %v, want %v (from %q)", tt.human, humanDur, goDur, tt.goStd)
124+ }
125+
126+ expectedHours := time.Duration(tt.hours) * time.Hour
127+ if humanDur != expectedHours {
128+ t.Errorf("Parse(%q) = %v, want %d hours", tt.human, humanDur, tt.hours)
129+ }
130+ })
131+ }
132+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close