v0.34.0 7c1886f

ci_workflows.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"fmt"
8	"slices"
9
10	"bnema.dev/ermite/internal/domain"
11	"bnema.dev/ermite/internal/ports"
12)
13
14// Disabled workflows (`ermite workflow disable|enable|list`, the equivalent of
15// `gh workflow disable`). The state is per repository and per workflow file
16// path, kept in the store apart from Git: it holds for every branch and every
17// commit, and the file does not have to exist, so it can be set on an empty
18// repository before the first push.
19//
20// A disabled workflow starts no run for any event. The workflow files are read
21// through workflowFiles for push, pull_request, pull_request_target, create,
22// delete, schedule and workflow_run, and that one place leaves the disabled
23// ones out; workflow_dispatch and the firing of a cached schedule check the
24// state themselves (the cache holds every workflow). Whatever was checked
25// before, the store refuses to create a run for a disabled workflow in the very
26// transaction that creates it (CIStore.CreateRun, domain.ErrWorkflowDisabled),
27// so a disable that lands while a push, a dispatch or a schedule is being
28// planned cannot be overtaken. A run already started is not touched, and the events that
29// happened while a workflow was disabled are not replayed when it is enabled.
30
31// workflowDisabled reports whether the path is disabled; nothing is when no
32// store is configured.
33func (c *CI) workflowDisabled(ctx context.Context, repo domain.RepoName, wfPath string) (bool, error) {
34	off, err := c.disabledPaths(ctx, repo)
35	if err != nil {
36		return false, err
37	}
38	return slices.Contains(off, wfPath), nil
39}
40
41// disabledPaths are the disabled paths of a repository, sorted.
42func (c *CI) disabledPaths(ctx context.Context, repo domain.RepoName) ([]string, error) {
43	if c.cfg.WorkflowState == nil {
44		return nil, nil
45	}
46	off, err := c.cfg.WorkflowState.DisabledWorkflows(ctx, repo)
47	if err != nil {
48		return nil, fmt.Errorf("read the disabled workflows: %w", err)
49	}
50	return off, nil
51}
52
53// ListWorkflows returns the workflow files at the tip of the default branch and
54// the disabled ones whatever the branch holds, ordered by path. Reading the
55// repository is enough.
56func (c *CI) ListWorkflows(ctx context.Context, p domain.Principal, name domain.RepoName) ([]ports.WorkflowInfo, error) {
57	if _, err := c.readableRepo(ctx, p, name); err != nil {
58		return nil, err
59	}
60	off, err := c.disabledPaths(ctx, name)
61	if err != nil {
62		return nil, err
63	}
64	byPath := map[string]*ports.WorkflowInfo{}
65	if tip, ok := c.defaultTip(ctx, name); ok {
66		files, err := c.listWorkflowFiles(ctx, name, tip)
67		if err != nil {
68			return nil, err
69		}
70		for _, f := range files {
71			info := &ports.WorkflowInfo{Path: f.path}
72			if f.problem == "" {
73				if wf, perrs := parseWorkflow(f.path, f.content); len(perrs) == 0 && wf != nil {
74					info.Name = workflowName(wf, f.path)
75				}
76			}
77			byPath[f.path] = info
78		}
79	}
80	for _, path := range off {
81		if byPath[path] == nil {
82			byPath[path] = &ports.WorkflowInfo{Path: path}
83		}
84		byPath[path].Disabled = true
85	}
86	out := make([]ports.WorkflowInfo, 0, len(byPath))
87	for _, info := range byPath {
88		out = append(out, *info)
89	}
90	slices.SortFunc(out, func(a, b ports.WorkflowInfo) int {
91		switch {
92		case a.Path < b.Path:
93			return -1
94		case a.Path > b.Path:
95			return 1
96		}
97		return 0
98	})
99	return out, nil
100}
101
102// DisabledWorkflows returns the disabled paths of a repository, sorted, without
103// reading any file.
104func (c *CI) DisabledWorkflows(ctx context.Context, p domain.Principal, name domain.RepoName) ([]string, error) {
105	if _, err := c.readableRepo(ctx, p, name); err != nil {
106		return nil, err
107	}
108	return c.disabledPaths(ctx, name)
109}
110
111// DisableWorkflow stops a workflow file from starting runs. The file is named by
112// its path or its file name and does not have to exist. It needs repo:admin on
113// the repository (adminAccess), and is idempotent.
114func (c *CI) DisableWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, file string) (ports.WorkflowInfo, error) {
115	return c.setWorkflowState(ctx, p, name, file, true)
116}
117
118// EnableWorkflow undoes DisableWorkflow. Same access; idempotent.
119func (c *CI) EnableWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, file string) (ports.WorkflowInfo, error) {
120	return c.setWorkflowState(ctx, p, name, file, false)
121}
122
123func (c *CI) setWorkflowState(ctx context.Context, p domain.Principal, name domain.RepoName, file string, disable bool) (ports.WorkflowInfo, error) {
124	repo, err := lookupRepo(ctx, c.cfg.Meta, name)
125	if err != nil {
126		return ports.WorkflowInfo{}, err
127	}
128	if err := adminAccess(p, repo); err != nil {
129		return ports.WorkflowInfo{}, err
130	}
131	wfPath, err := domain.ParseWorkflowPath(file)
132	if err != nil {
133		return ports.WorkflowInfo{}, err
134	}
135	if c.cfg.WorkflowState == nil {
136		return ports.WorkflowInfo{}, fmt.Errorf("%w: this server keeps no workflow state", domain.ErrPreconditionFailed)
137	}
138	if disable {
139		err = c.cfg.WorkflowState.DisableWorkflow(ctx, name, wfPath, c.now())
140	} else {
141		err = c.cfg.WorkflowState.EnableWorkflow(ctx, name, wfPath)
142	}
143	if err != nil {
144		return ports.WorkflowInfo{}, err
145	}
146	state := "enabled"
147	if disable {
148		state = "disabled"
149	}
150	ctxLog(ctx).Info().Str("repo", name.String()).Str("workflow", wfPath).Str("principal", p.Label()).Msg("ci workflow " + state)
151	return ports.WorkflowInfo{Path: wfPath, Disabled: disable}, nil
152}

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 44

↑↓ moveenter openesc close