v0.4.0 3027a40

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

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