v0.16.0 924eaca

scopes.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package client
4
5import (
6	"fmt"
7	"slices"
8	"strconv"
9	"strings"
10	"time"
11
12	"bnema.dev/ermite-api/gen/ermite"
13)
14
15// Token presets (ADR 0006). The API knows scopes only; a preset is a client
16// shortcut that expands to scopes.
17const (
18	presetCLI   = "cli"
19	presetAgent = "agent"
20	presetCI    = "ci"
21	// presetRunner is for a CI runner host: it only registers runners.
22	presetRunner = "runner"
23)
24
25// PresetNames lists the token presets, see PresetScopes.
26var PresetNames = []string{presetCLI, presetAgent, presetCI, presetRunner}
27
28// ScopeName is the name of a scope as users type it and as the docs write
29// it: SCOPE_REPO_READ is "repo:read".
30func ScopeName(s ermite.Scope) string {
31	name, ok := ermite.Scope_name[int32(s)]
32	if !ok {
33		return strconv.Itoa(int(s))
34	}
35	return strings.Replace(strings.ToLower(strings.TrimPrefix(name, "SCOPE_")), "_", ":", 1)
36}
37
38// AllScopes lists every scope the contract defines, in enum order. Deriving it
39// keeps the `cli` preset ("every scope") true when the contract grows.
40func AllScopes() []ermite.Scope {
41	var scopes []ermite.Scope
42	for n := range ermite.Scope_name {
43		if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED {
44			scopes = append(scopes, s)
45		}
46	}
47	slices.Sort(scopes)
48	return scopes
49}
50
51// ParseScope reads a scope as users type it, for example "repo:read".
52func ParseScope(name string) (ermite.Scope, error) {
53	want := strings.ToLower(strings.TrimSpace(name))
54	for _, s := range AllScopes() {
55		if ScopeName(s) == want {
56			return s, nil
57		}
58	}
59	return 0, fmt.Errorf("unknown scope %q: use one of %s", name, strings.Join(ScopeNames(AllScopes()), ", "))
60}
61
62// ScopeNames maps ScopeName over scopes.
63func ScopeNames(scopes []ermite.Scope) []string {
64	names := make([]string, len(scopes))
65	for i, s := range scopes {
66		names[i] = ScopeName(s)
67	}
68	return names
69}
70
71// PresetScopes expands a preset to its scopes.
72func PresetScopes(name string) ([]ermite.Scope, error) {
73	switch strings.ToLower(name) {
74	case presetCLI:
75		return AllScopes(), nil
76	case presetAgent:
77		return []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, nil
78	case presetCI:
79		return []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, nil
80	case presetRunner:
81		return []ermite.Scope{ermite.Scope_SCOPE_RUNNER_REGISTER}, nil
82	default:
83		return nil, fmt.Errorf("unknown preset %q: use one of %s", name, strings.Join(PresetNames, ", "))
84	}
85}
86
87// MergeScopes merges a preset and explicit scopes into a sorted, unique list.
88// Neither given is an error: a token's power is never chosen by default.
89func MergeScopes(preset string, scopeFlags []string) ([]ermite.Scope, error) {
90	var scopes []ermite.Scope
91	if preset != "" {
92		s, err := PresetScopes(preset)
93		if err != nil {
94			return nil, err
95		}
96		scopes = append(scopes, s...)
97	}
98	for _, f := range scopeFlags {
99		s, err := ParseScope(f)
100		if err != nil {
101			return nil, err
102		}
103		scopes = append(scopes, s)
104	}
105	if len(scopes) == 0 {
106		return nil, fmt.Errorf("choose what the token may do: --preset %s, or one or more --scope", strings.Join(PresetNames, "|"))
107	}
108	slices.Sort(scopes)
109	return slices.Compact(scopes), nil
110}
111
112// ParseExpiry reads a lifetime such as "30d", "2w", "12h", "90m" or "1h30m"
113// (days and weeks are 24 h and 7 d), or an absolute date, "2027-01-31" (end of
114// that day, UTC) or an RFC 3339 instant. The result must lie in the future.
115func ParseExpiry(s string, now time.Time) (time.Time, error) {
116	s = strings.TrimSpace(s)
117	bad := fmt.Errorf("invalid --expires %q: use a duration like 30d, 12h or 2w, or a date like 2027-01-31", s)
118	var at time.Time
119	switch {
120	case s == "":
121		return time.Time{}, bad
122	case strings.HasSuffix(s, "d") || strings.HasSuffix(s, "w"):
123		n, err := strconv.Atoi(s[:len(s)-1])
124		if err != nil || n <= 0 {
125			return time.Time{}, bad
126		}
127		unit := 24 * time.Hour
128		if strings.HasSuffix(s, "w") {
129			unit *= 7
130		}
131		if n > 100*365 {
132			return time.Time{}, bad
133		}
134		at = now.Add(time.Duration(n) * unit)
135	default:
136		if d, err := time.ParseDuration(s); err == nil {
137			at = now.Add(d)
138		} else if t, err := time.Parse(time.RFC3339, s); err == nil {
139			at = t
140		} else if t, err := time.Parse(time.DateOnly, s); err == nil {
141			at = t.Add(24*time.Hour - time.Second)
142		} else {
143			return time.Time{}, bad
144		}
145	}
146	if !at.After(now) {
147		return time.Time{}, fmt.Errorf("invalid --expires %q: it is in the past", s)
148	}
149	return at.UTC().Truncate(time.Second), nil
150}

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 4

Tags 45

↑↓ moveenter openesc close