detached fda3c2b

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

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 2

Tags 45

↑↓ moveenter openesc close