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}