feat(ci): disable, enable and list workflows per repository
- commit
- 00396122d39c11f2d7ecd211a94f823937ff76c0
- author
- brice · · 2026-10-18 19:44 UTC
- parent
- dfd6f0b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email - refs
- tag: v0.27.0
40 files · +2840 -966
M docs/ci-events.md +1 -1
| @@ -39,7 +39,7 @@ Its cache is read-only and restores only default-branch entries. | |||
| 39 | 39 | ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …] | |
| 40 | 40 | ``` | |
| 41 | 41 | ||
| 42 | - | - Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch. | |
| 42 | + | - Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch. A disabled workflow (`ermite workflow disable`) is refused with `failed_precondition`. | |
| 43 | 43 | - Inputs are checked against the workflow (`required`, `boolean`, `number`, `choice`, `environment`). Up to 25 inputs. | |
| 44 | 44 | - A branch-limited token can dispatch only on its branches, and never gets a trusted run. | |
| 45 | 45 | - A job can dispatch with its own token when its `permissions:` give `actions: write`. | |
| @@ -39,7 +39,7 @@ Its cache is read-only and restores only default-branch entries. | |||||
| 39 | ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …] | 39 | ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …] | ||
| 40 | ``` | 40 | ``` | ||
| 41 | 41 | ||||
| 42 | - | - Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch. | 42 | + | - Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch. A disabled workflow (`ermite workflow disable`) is refused with `failed_precondition`. |
| 43 | - Inputs are checked against the workflow (`required`, `boolean`, `number`, `choice`, `environment`). Up to 25 inputs. | 43 | - Inputs are checked against the workflow (`required`, `boolean`, `number`, `choice`, `environment`). Up to 25 inputs. | ||
| 44 | - A branch-limited token can dispatch only on its branches, and never gets a trusted run. | 44 | - A branch-limited token can dispatch only on its branches, and never gets a trusted run. | ||
| 45 | - A job can dispatch with its own token when its `permissions:` give `actions: write`. | 45 | - A job can dispatch with its own token when its `permissions:` give `actions: write`. | ||
M docs/ci.md +16 -0
| @@ -28,6 +28,22 @@ ermite run cancel <repo> <id> # needs ci:write | |||
| 28 | 28 | ermite run rerun <repo> <id> [--failed] | |
| 29 | 29 | ``` | |
| 30 | 30 | ||
| 31 | + | ### Disable a workflow | |
| 32 | + | ||
| 33 | + | ```sh | |
| 34 | + | ermite workflow list <repo> [--json] | |
| 35 | + | ermite workflow disable <repo> <file> # needs repo:admin | |
| 36 | + | ermite workflow enable <repo> <file> | |
| 37 | + | ``` | |
| 38 | + | ||
| 39 | + | Like `gh workflow disable`: a disabled workflow starts no run for any event (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_run`, `create`, `delete`, `workflow_dispatch`). Runs already started go on, and events missed while it was disabled are not replayed. | |
| 40 | + | ||
| 41 | + | - `<file>` is `release.yml` or `.github/workflows/release.yml`. The state belongs to the repository and the path, not to a branch, and the file need not exist: disable a release workflow right after `ermite repo create`, before the first push, so pushing tags starts nothing. The files stay identical to their GitHub copies. | |
| 42 | + | - `list` shows the files of the default branch with their name and state, and the disabled files that branch lacks. Needs `repo:read` for a private repository. | |
| 43 | + | - The Actions tab names the disabled workflows, and the page of a run says when its workflow is disabled. | |
| 44 | + | - A disabled **reusable** workflow (`on: workflow_call`) can still be called with `uses:` from an enabled workflow: it then runs inside the caller's run, not as a run of its own, as on GitHub. Disable the caller to stop it. | |
| 45 | + | - The state is checked again in the transaction that stores a run, so a disable takes effect for every run not yet stored, even one being planned at that moment. | |
| 46 | + | ||
| 31 | 47 | The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`. | |
| 32 | 48 | ||
| 33 | 49 | ## Trust | |
| @@ -28,6 +28,22 @@ ermite run cancel <repo> <id> # needs ci:write | |||||
| 28 | ermite run rerun <repo> <id> [--failed] | 28 | ermite run rerun <repo> <id> [--failed] | ||
| 29 | ``` | 29 | ``` | ||
| 30 | 30 | ||||
| 31 | + | ### Disable a workflow | |||
| 32 | + | ||||
| 33 | + | ```sh | |||
| 34 | + | ermite workflow list <repo> [--json] | |||
| 35 | + | ermite workflow disable <repo> <file> # needs repo:admin | |||
| 36 | + | ermite workflow enable <repo> <file> | |||
| 37 | + | ``` | |||
| 38 | + | ||||
| 39 | + | Like `gh workflow disable`: a disabled workflow starts no run for any event (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_run`, `create`, `delete`, `workflow_dispatch`). Runs already started go on, and events missed while it was disabled are not replayed. | |||
| 40 | + | ||||
| 41 | + | - `<file>` is `release.yml` or `.github/workflows/release.yml`. The state belongs to the repository and the path, not to a branch, and the file need not exist: disable a release workflow right after `ermite repo create`, before the first push, so pushing tags starts nothing. The files stay identical to their GitHub copies. | |||
| 42 | + | - `list` shows the files of the default branch with their name and state, and the disabled files that branch lacks. Needs `repo:read` for a private repository. | |||
| 43 | + | - The Actions tab names the disabled workflows, and the page of a run says when its workflow is disabled. | |||
| 44 | + | - A disabled **reusable** workflow (`on: workflow_call`) can still be called with `uses:` from an enabled workflow: it then runs inside the caller's run, not as a run of its own, as on GitHub. Disable the caller to stop it. | |||
| 45 | + | - The state is checked again in the transaction that stores a run, so a disable takes effect for every run not yet stored, even one being planned at that moment. | |||
| 46 | + | ||||
| 31 | The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`. | 47 | The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`. | ||
| 32 | 48 | ||||
| 33 | ## Trust | 49 | ## Trust | ||
M docs/cli.md +3 -0
| @@ -63,6 +63,9 @@ ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary] | |||
| 63 | 63 | ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed] | |
| 64 | 64 | ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D] | |
| 65 | 65 | ermite workflow run <file> --repo <repo> [--ref …] [-f name=value …] | |
| 66 | + | ermite workflow list <repo> [--json] | |
| 67 | + | ermite workflow disable <repo> <file> # no event starts a run of it; the file need not exist yet | |
| 68 | + | ermite workflow enable <repo> <file> | |
| 66 | 69 | ermite runner list | remove <id> | |
| 67 | 70 | ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY) | |
| 68 | 71 | ermite variable set|get|list|delete … | |
| @@ -63,6 +63,9 @@ ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary] | |||||
| 63 | ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed] | 63 | ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed] | ||
| 64 | ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D] | 64 | ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D] | ||
| 65 | ermite workflow run <file> --repo <repo> [--ref …] [-f name=value …] | 65 | ermite workflow run <file> --repo <repo> [--ref …] [-f name=value …] | ||
| 66 | + | ermite workflow list <repo> [--json] | |||
| 67 | + | ermite workflow disable <repo> <file> # no event starts a run of it; the file need not exist yet | |||
| 68 | + | ermite workflow enable <repo> <file> | |||
| 66 | ermite runner list | remove <id> | 69 | ermite runner list | remove <id> | ||
| 67 | ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY) | 70 | ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY) | ||
| 68 | ermite variable set|get|list|delete … | 71 | ermite variable set|get|list|delete … | ||
M gen/ermite/storage/ci.pb.go +89 -37 generated file: collapsed
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.6.0 | |
| 16 | + | bnema.dev/ermite-api v0.7.0 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.1.0 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| @@ -13,7 +13,7 @@ tool ( | |||||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | bnema.dev/ermite-actions v0.2.0 | 15 | bnema.dev/ermite-actions v0.2.0 | ||
| 16 | - | bnema.dev/ermite-api v0.6.0 | 16 | + | bnema.dev/ermite-api v0.7.0 |
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.1.0 | 18 | bnema.dev/ermite-kv v0.1.0 | ||
| 19 | connectrpc.com/connect v1.21.0 | 19 | connectrpc.com/connect v1.21.0 | ||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client_test.go +3 -3
| @@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 146 | - | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, | |
| 146 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, | |
| 147 | 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | |
| 148 | 148 | })) | |
| 149 | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 150 | 150 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | |
| 151 | - | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | |
| 151 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | |
| 152 | 152 | })) | |
| 153 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 154 | 154 | } | |
| @@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||||
| 139 | func TestMissingFeatures(t *testing.T) { | 139 | func TestMissingFeatures(t *testing.T) { | ||
| 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | ||
| 141 | assert.Equal(t, | 141 | assert.Equal(t, | ||
| 142 | - | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, | 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures}, |
| 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | ||
| 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | ||
| 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | ||
| 146 | - | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, | 146 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, |
| 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | 147 | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | ||
| 148 | })) | 148 | })) | ||
| 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | ||
| 150 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | 150 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession, | ||
| 151 | - | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures, | 151 | + | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, |
| 152 | })) | 152 | })) | ||
| 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | ||
| 154 | } | 154 | } | ||
M internal/adapters/client/features.go +4 -1
| @@ -42,6 +42,9 @@ const ( | |||
| 42 | 42 | FeatureCISecrets = "ci.secrets" | |
| 43 | 43 | // FeatureCIDispatch is RunService.DispatchWorkflow (`ermite workflow run`). | |
| 44 | 44 | FeatureCIDispatch = "ci.dispatch" | |
| 45 | + | // FeatureCIWorkflows is RunService.ListWorkflows, DisableWorkflow and | |
| 46 | + | // EnableWorkflow (`ermite workflow list|disable|enable`). | |
| 47 | + | FeatureCIWorkflows = "ci.workflows" | |
| 45 | 48 | // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`, | |
| 46 | 49 | // `run download`). | |
| 47 | 50 | FeatureCIArtifacts = "ci.artifacts" | |
| @@ -52,7 +55,7 @@ const ( | |||
| 52 | 55 | ||
| 53 | 56 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 54 | 57 | func UsedFeatures() []string { | |
| 55 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | |
| 58 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | |
| 56 | 59 | } | |
| 57 | 60 | ||
| 58 | 61 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
| @@ -42,6 +42,9 @@ const ( | |||||
| 42 | FeatureCISecrets = "ci.secrets" | 42 | FeatureCISecrets = "ci.secrets" | ||
| 43 | // FeatureCIDispatch is RunService.DispatchWorkflow (`ermite workflow run`). | 43 | // FeatureCIDispatch is RunService.DispatchWorkflow (`ermite workflow run`). | ||
| 44 | FeatureCIDispatch = "ci.dispatch" | 44 | FeatureCIDispatch = "ci.dispatch" | ||
| 45 | + | // FeatureCIWorkflows is RunService.ListWorkflows, DisableWorkflow and | |||
| 46 | + | // EnableWorkflow (`ermite workflow list|disable|enable`). | |||
| 47 | + | FeatureCIWorkflows = "ci.workflows" | |||
| 45 | // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`, | 48 | // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`, | ||
| 46 | // `run download`). | 49 | // `run download`). | ||
| 47 | FeatureCIArtifacts = "ci.artifacts" | 50 | FeatureCIArtifacts = "ci.artifacts" | ||
| @@ -52,7 +55,7 @@ const ( | |||||
| 52 | 55 | ||||
| 53 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | 56 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | ||
| 54 | func UsedFeatures() []string { | 57 | func UsedFeatures() []string { | ||
| 55 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} | 58 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures} |
| 56 | } | 59 | } | ||
| 57 | 60 | ||||
| 58 | // MissingFeatures returns the features the CLI uses that have is lacking. | 61 | // MissingFeatures returns the features the CLI uses that have is lacking. | ||
M internal/adapters/kvstore/ci.go +9 -1
| @@ -580,7 +580,14 @@ const createRunTries = 20 | |||
| 580 | 580 | // | |
| 581 | 581 | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy | |
| 582 | 582 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | |
| 583 | + | offKey := workflowOffKey(repo, workflowPath) | |
| 583 | 584 | for range createRunTries { | |
| 585 | + | // A workflow the repository disabled gets no run, whatever started it. | |
| 586 | + | // The key is also required absent by the batch below, so a disable that | |
| 587 | + | // lands after this read fails the batch, and the next try sees it. | |
| 588 | + | if _, off := c.db.Get(ctx, offKey); off { | |
| 589 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |
| 590 | + | } | |
| 584 | 591 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | |
| 585 | 592 | if err != nil { | |
| 586 | 593 | return ports.CIRunDraft{}, err | |
| @@ -605,6 +612,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||
| 605 | 612 | }) | |
| 606 | 613 | b.RequireAbsent(runSrcKey(draft.Run.ID)) | |
| 607 | 614 | b.Put(runSrcKey(draft.Run.ID), src) | |
| 615 | + | b.RequireAbsent(offKey) | |
| 608 | 616 | for r := range guarded { | |
| 609 | 617 | if err := c.guardRepo(ctx, &b, r); err != nil { | |
| 610 | 618 | return ports.CIRunDraft{}, err | |
| @@ -887,7 +895,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by | |||
| 887 | 895 | } | |
| 888 | 896 | keys = append(keys, repoReaderKeys(ctx, snap, repo)...) | |
| 889 | 897 | keys = append(keys, artifactKeys(ctx, snap, repo)...) | |
| 890 | - | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo)} { | |
| 898 | + | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo), workflowOffPrefixOf(repo)} { | |
| 891 | 899 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 892 | 900 | keys = append(keys, slices.Clone(k)) | |
| 893 | 901 | } | |
| @@ -580,7 +580,14 @@ const createRunTries = 20 | |||||
| 580 | // | 580 | // | ||
| 581 | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy | 581 | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy | ||
| 582 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | 582 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | ||
| 583 | + | offKey := workflowOffKey(repo, workflowPath) | |||
| 583 | for range createRunTries { | 584 | for range createRunTries { | ||
| 585 | + | // A workflow the repository disabled gets no run, whatever started it. | |||
| 586 | + | // The key is also required absent by the batch below, so a disable that | |||
| 587 | + | // lands after this read fails the batch, and the next try sees it. | |||
| 588 | + | if _, off := c.db.Get(ctx, offKey); off { | |||
| 589 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |||
| 590 | + | } | |||
| 584 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | 591 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | ||
| 585 | if err != nil { | 592 | if err != nil { | ||
| 586 | return ports.CIRunDraft{}, err | 593 | return ports.CIRunDraft{}, err | ||
| @@ -605,6 +612,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||||
| 605 | }) | 612 | }) | ||
| 606 | b.RequireAbsent(runSrcKey(draft.Run.ID)) | 613 | b.RequireAbsent(runSrcKey(draft.Run.ID)) | ||
| 607 | b.Put(runSrcKey(draft.Run.ID), src) | 614 | b.Put(runSrcKey(draft.Run.ID), src) | ||
| 615 | + | b.RequireAbsent(offKey) | |||
| 608 | for r := range guarded { | 616 | for r := range guarded { | ||
| 609 | if err := c.guardRepo(ctx, &b, r); err != nil { | 617 | if err := c.guardRepo(ctx, &b, r); err != nil { | ||
| 610 | return ports.CIRunDraft{}, err | 618 | return ports.CIRunDraft{}, err | ||
| @@ -887,7 +895,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by | |||||
| 887 | } | 895 | } | ||
| 888 | keys = append(keys, repoReaderKeys(ctx, snap, repo)...) | 896 | keys = append(keys, repoReaderKeys(ctx, snap, repo)...) | ||
| 889 | keys = append(keys, artifactKeys(ctx, snap, repo)...) | 897 | keys = append(keys, artifactKeys(ctx, snap, repo)...) | ||
| 890 | - | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo)} { | 898 | + | for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo), workflowOffPrefixOf(repo)} { |
| 891 | for k := range snap.Prefix(ctx, []byte(p)) { | 899 | for k := range snap.Prefix(ctx, []byte(p)) { | ||
| 892 | keys = append(keys, slices.Clone(k)) | 900 | keys = append(keys, slices.Clone(k)) | ||
| 893 | } | 901 | } | ||
A internal/adapters/kvstore/ci_workflow.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | kv "bnema.dev/ermite-kv" | |
| 14 | + | "google.golang.org/protobuf/proto" | |
| 15 | + | "google.golang.org/protobuf/types/known/timestamppb" | |
| 16 | + | ||
| 17 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // The workflows a repository disabled (`ermite workflow disable`): | |
| 23 | + | // | |
| 24 | + | // ciwfoff/<repo>/<workflow path> CIWorkflowDisabled | |
| 25 | + | // | |
| 26 | + | // <workflow path> is ".github/workflows/<file>", whether or not the file exists. | |
| 27 | + | // Written under the guard of the repository key, deleted with the repository | |
| 28 | + | // (ciKeys). | |
| 29 | + | const workflowOffPrefix = "ciwfoff/" | |
| 30 | + | ||
| 31 | + | var _ ports.CIWorkflowStore = (*CI)(nil) | |
| 32 | + | ||
| 33 | + | func workflowOffKey(repo domain.RepoName, path string) []byte { | |
| 34 | + | return []byte(workflowOffPrefix + repo.String() + "/" + path) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func workflowOffPrefixOf(repo domain.RepoName) string { return workflowOffPrefix + repo.String() + "/" } | |
| 38 | + | ||
| 39 | + | // DisableWorkflow records the path as disabled. One already disabled keeps its | |
| 40 | + | // date. A repository that does not exist is domain.ErrNotFound. | |
| 41 | + | func (c *CI) DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error { | |
| 42 | + | val, err := proto.Marshal(&storagepb.CIWorkflowDisabled{DisabledAt: timestamppb.New(at)}) | |
| 43 | + | if err != nil { | |
| 44 | + | return fmt.Errorf("encode disabled workflow: %w", err) | |
| 45 | + | } | |
| 46 | + | key := workflowOffKey(repo, path) | |
| 47 | + | for range updateAttemptsTip { | |
| 48 | + | if _, ok := c.db.Get(ctx, key); ok { | |
| 49 | + | if _, ok := c.db.Get(ctx, repoKey(repo)); !ok { | |
| 50 | + | return domain.ErrNotFound | |
| 51 | + | } | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | var b kv.Batch | |
| 55 | + | if err := c.guardRepo(ctx, &b, repo); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | b.RequireAbsent(key) | |
| 59 | + | b.Put(key, val) | |
| 60 | + | _, err := c.db.Apply(ctx, &b) | |
| 61 | + | if errors.Is(err, kv.ErrConflict) { | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | if err != nil { | |
| 65 | + | return fmt.Errorf("store disabled workflow: %w", err) | |
| 66 | + | } | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | return fmt.Errorf("store disabled workflow: %w", domain.ErrConflict) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // EnableWorkflow removes the record of the path. | |
| 73 | + | func (c *CI) EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error { | |
| 74 | + | var b kv.Batch | |
| 75 | + | b.Delete(workflowOffKey(repo, path)) | |
| 76 | + | if _, err := c.db.Apply(ctx, &b); err != nil { | |
| 77 | + | return fmt.Errorf("delete disabled workflow: %w", err) | |
| 78 | + | } | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // DisabledWorkflows returns the disabled paths of the repository, sorted. | |
| 83 | + | func (c *CI) DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) { | |
| 84 | + | prefix := workflowOffPrefixOf(repo) | |
| 85 | + | var out []string | |
| 86 | + | for k := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) { | |
| 87 | + | out = append(out, strings.TrimPrefix(string(k), prefix)) | |
| 88 | + | } | |
| 89 | + | sort.Strings(out) | |
| 90 | + | return out, nil | |
| 91 | + | } | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "sort" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | kv "bnema.dev/ermite-kv" | |||
| 14 | + | "google.golang.org/protobuf/proto" | |||
| 15 | + | "google.golang.org/protobuf/types/known/timestamppb" | |||
| 16 | + | ||||
| 17 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | "bnema.dev/ermite/internal/ports" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // The workflows a repository disabled (`ermite workflow disable`): | |||
| 23 | + | // | |||
| 24 | + | // ciwfoff/<repo>/<workflow path> CIWorkflowDisabled | |||
| 25 | + | // | |||
| 26 | + | // <workflow path> is ".github/workflows/<file>", whether or not the file exists. | |||
| 27 | + | // Written under the guard of the repository key, deleted with the repository | |||
| 28 | + | // (ciKeys). | |||
| 29 | + | const workflowOffPrefix = "ciwfoff/" | |||
| 30 | + | ||||
| 31 | + | var _ ports.CIWorkflowStore = (*CI)(nil) | |||
| 32 | + | ||||
| 33 | + | func workflowOffKey(repo domain.RepoName, path string) []byte { | |||
| 34 | + | return []byte(workflowOffPrefix + repo.String() + "/" + path) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func workflowOffPrefixOf(repo domain.RepoName) string { return workflowOffPrefix + repo.String() + "/" } | |||
| 38 | + | ||||
| 39 | + | // DisableWorkflow records the path as disabled. One already disabled keeps its | |||
| 40 | + | // date. A repository that does not exist is domain.ErrNotFound. | |||
| 41 | + | func (c *CI) DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error { | |||
| 42 | + | val, err := proto.Marshal(&storagepb.CIWorkflowDisabled{DisabledAt: timestamppb.New(at)}) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return fmt.Errorf("encode disabled workflow: %w", err) | |||
| 45 | + | } | |||
| 46 | + | key := workflowOffKey(repo, path) | |||
| 47 | + | for range updateAttemptsTip { | |||
| 48 | + | if _, ok := c.db.Get(ctx, key); ok { | |||
| 49 | + | if _, ok := c.db.Get(ctx, repoKey(repo)); !ok { | |||
| 50 | + | return domain.ErrNotFound | |||
| 51 | + | } | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | var b kv.Batch | |||
| 55 | + | if err := c.guardRepo(ctx, &b, repo); err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | b.RequireAbsent(key) | |||
| 59 | + | b.Put(key, val) | |||
| 60 | + | _, err := c.db.Apply(ctx, &b) | |||
| 61 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 64 | + | if err != nil { | |||
| 65 | + | return fmt.Errorf("store disabled workflow: %w", err) | |||
| 66 | + | } | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | return fmt.Errorf("store disabled workflow: %w", domain.ErrConflict) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // EnableWorkflow removes the record of the path. | |||
| 73 | + | func (c *CI) EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error { | |||
| 74 | + | var b kv.Batch | |||
| 75 | + | b.Delete(workflowOffKey(repo, path)) | |||
| 76 | + | if _, err := c.db.Apply(ctx, &b); err != nil { | |||
| 77 | + | return fmt.Errorf("delete disabled workflow: %w", err) | |||
| 78 | + | } | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // DisabledWorkflows returns the disabled paths of the repository, sorted. | |||
| 83 | + | func (c *CI) DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) { | |||
| 84 | + | prefix := workflowOffPrefixOf(repo) | |||
| 85 | + | var out []string | |||
| 86 | + | for k := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) { | |||
| 87 | + | out = append(out, strings.TrimPrefix(string(k), prefix)) | |||
| 88 | + | } | |||
| 89 | + | sort.Strings(out) | |||
| 90 | + | return out, nil | |||
| 91 | + | } | |||
A internal/adapters/kvstore/ci_workflow_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const wfRelease = ".github/workflows/release.yml" | |
| 17 | + | ||
| 18 | + | func TestCIWorkflowDisabledState(t *testing.T) { | |
| 19 | + | ctx := context.Background() | |
| 20 | + | s := openStore(t, t.TempDir()) | |
| 21 | + | ci := s.CI() | |
| 22 | + | a, b := name(t, "a"), name(t, "b") | |
| 23 | + | for _, n := range []domain.RepoName{a, b} { | |
| 24 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()})) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | got, err := ci.DisabledWorkflows(ctx, a) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | assert.Empty(t, got) | |
| 30 | + | ||
| 31 | + | require.NoError(t, ci.DisableWorkflow(ctx, a, wfRelease, created)) | |
| 32 | + | require.NoError(t, ci.DisableWorkflow(ctx, a, wfRelease, created), "idempotent") | |
| 33 | + | require.NoError(t, ci.DisableWorkflow(ctx, a, ".github/workflows/aur.yml", created)) | |
| 34 | + | got, err = ci.DisabledWorkflows(ctx, a) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | assert.Equal(t, []string{".github/workflows/aur.yml", wfRelease}, got) | |
| 37 | + | got, _ = ci.DisabledWorkflows(ctx, b) | |
| 38 | + | assert.Empty(t, got, "per repository") | |
| 39 | + | ||
| 40 | + | require.NoError(t, ci.EnableWorkflow(ctx, a, wfRelease)) | |
| 41 | + | require.NoError(t, ci.EnableWorkflow(ctx, a, wfRelease), "idempotent") | |
| 42 | + | got, _ = ci.DisabledWorkflows(ctx, a) | |
| 43 | + | assert.Equal(t, []string{".github/workflows/aur.yml"}, got) | |
| 44 | + | ||
| 45 | + | assert.ErrorIs(t, ci.DisableWorkflow(ctx, name(t, "gone"), wfRelease, created), domain.ErrNotFound) | |
| 46 | + | ||
| 47 | + | // The state goes with the repository: a new one under the same name starts enabled. | |
| 48 | + | require.NoError(t, s.Delete(ctx, a)) | |
| 49 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: a, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()})) | |
| 50 | + | got, _ = ci.DisabledWorkflows(ctx, a) | |
| 51 | + | assert.Empty(t, got) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // A run is refused for a disabled workflow in the very transaction that stores | |
| 55 | + | // it: a disable that lands between the read of the state and the write of the | |
| 56 | + | // run (here, from inside build) cannot be overtaken, and no run is left. | |
| 57 | + | func TestCreateRunRefusesAWorkflowDisabledMeanwhile(t *testing.T) { | |
| 58 | + | ctx := context.Background() | |
| 59 | + | s := openStore(t, t.TempDir()) | |
| 60 | + | ci := s.CI() | |
| 61 | + | n := name(t, "a") | |
| 62 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()})) | |
| 63 | + | draft := func(id, number int64) ports.CIRunDraft { | |
| 64 | + | return ports.CIRunDraft{Run: domain.CIRun{Repo: n, ID: id, Number: number, Attempt: 1, WorkflowPath: wfRelease, Status: domain.CIQueued, CreatedAt: created}} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Enabled: a run is created. | |
| 68 | + | _, err := ci.CreateRun(ctx, n, wfRelease, draft) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | ||
| 71 | + | // Disabled from inside build, after the state was read. | |
| 72 | + | calls := 0 | |
| 73 | + | _, err = ci.CreateRun(ctx, n, wfRelease, func(id, number int64) ports.CIRunDraft { | |
| 74 | + | calls++ | |
| 75 | + | if calls == 1 { | |
| 76 | + | require.NoError(t, ci.DisableWorkflow(ctx, n, wfRelease, created)) | |
| 77 | + | } | |
| 78 | + | return draft(id, number) | |
| 79 | + | }) | |
| 80 | + | assert.ErrorIs(t, err, domain.ErrWorkflowDisabled) | |
| 81 | + | assert.Equal(t, 1, calls, "refused on the retry, before build") | |
| 82 | + | runs, err := ci.ListRuns(ctx, n, 0, 10) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | assert.Len(t, runs, 1, "no run was stored by the refused call") | |
| 85 | + | ||
| 86 | + | // Other workflows are not affected, and enabling restores it. | |
| 87 | + | _, err = ci.CreateRun(ctx, n, ".github/workflows/other.yml", draft) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | require.NoError(t, ci.EnableWorkflow(ctx, n, wfRelease)) | |
| 90 | + | _, err = ci.CreateRun(ctx, n, wfRelease, draft) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const wfRelease = ".github/workflows/release.yml" | |||
| 17 | + | ||||
| 18 | + | func TestCIWorkflowDisabledState(t *testing.T) { | |||
| 19 | + | ctx := context.Background() | |||
| 20 | + | s := openStore(t, t.TempDir()) | |||
| 21 | + | ci := s.CI() | |||
| 22 | + | a, b := name(t, "a"), name(t, "b") | |||
| 23 | + | for _, n := range []domain.RepoName{a, b} { | |||
| 24 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()})) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | got, err := ci.DisabledWorkflows(ctx, a) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | assert.Empty(t, got) | |||
| 30 | + | ||||
| 31 | + | require.NoError(t, ci.DisableWorkflow(ctx, a, wfRelease, created)) | |||
| 32 | + | require.NoError(t, ci.DisableWorkflow(ctx, a, wfRelease, created), "idempotent") | |||
| 33 | + | require.NoError(t, ci.DisableWorkflow(ctx, a, ".github/workflows/aur.yml", created)) | |||
| 34 | + | got, err = ci.DisabledWorkflows(ctx, a) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | assert.Equal(t, []string{".github/workflows/aur.yml", wfRelease}, got) | |||
| 37 | + | got, _ = ci.DisabledWorkflows(ctx, b) | |||
| 38 | + | assert.Empty(t, got, "per repository") | |||
| 39 | + | ||||
| 40 | + | require.NoError(t, ci.EnableWorkflow(ctx, a, wfRelease)) | |||
| 41 | + | require.NoError(t, ci.EnableWorkflow(ctx, a, wfRelease), "idempotent") | |||
| 42 | + | got, _ = ci.DisabledWorkflows(ctx, a) | |||
| 43 | + | assert.Equal(t, []string{".github/workflows/aur.yml"}, got) | |||
| 44 | + | ||||
| 45 | + | assert.ErrorIs(t, ci.DisableWorkflow(ctx, name(t, "gone"), wfRelease, created), domain.ErrNotFound) | |||
| 46 | + | ||||
| 47 | + | // The state goes with the repository: a new one under the same name starts enabled. | |||
| 48 | + | require.NoError(t, s.Delete(ctx, a)) | |||
| 49 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: a, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()})) | |||
| 50 | + | got, _ = ci.DisabledWorkflows(ctx, a) | |||
| 51 | + | assert.Empty(t, got) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // A run is refused for a disabled workflow in the very transaction that stores | |||
| 55 | + | // it: a disable that lands between the read of the state and the write of the | |||
| 56 | + | // run (here, from inside build) cannot be overtaken, and no run is left. | |||
| 57 | + | func TestCreateRunRefusesAWorkflowDisabledMeanwhile(t *testing.T) { | |||
| 58 | + | ctx := context.Background() | |||
| 59 | + | s := openStore(t, t.TempDir()) | |||
| 60 | + | ci := s.CI() | |||
| 61 | + | n := name(t, "a") | |||
| 62 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()})) | |||
| 63 | + | draft := func(id, number int64) ports.CIRunDraft { | |||
| 64 | + | return ports.CIRunDraft{Run: domain.CIRun{Repo: n, ID: id, Number: number, Attempt: 1, WorkflowPath: wfRelease, Status: domain.CIQueued, CreatedAt: created}} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Enabled: a run is created. | |||
| 68 | + | _, err := ci.CreateRun(ctx, n, wfRelease, draft) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | ||||
| 71 | + | // Disabled from inside build, after the state was read. | |||
| 72 | + | calls := 0 | |||
| 73 | + | _, err = ci.CreateRun(ctx, n, wfRelease, func(id, number int64) ports.CIRunDraft { | |||
| 74 | + | calls++ | |||
| 75 | + | if calls == 1 { | |||
| 76 | + | require.NoError(t, ci.DisableWorkflow(ctx, n, wfRelease, created)) | |||
| 77 | + | } | |||
| 78 | + | return draft(id, number) | |||
| 79 | + | }) | |||
| 80 | + | assert.ErrorIs(t, err, domain.ErrWorkflowDisabled) | |||
| 81 | + | assert.Equal(t, 1, calls, "refused on the retry, before build") | |||
| 82 | + | runs, err := ci.ListRuns(ctx, n, 0, 10) | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | assert.Len(t, runs, 1, "no run was stored by the refused call") | |||
| 85 | + | ||||
| 86 | + | // Other workflows are not affected, and enabling restores it. | |||
| 87 | + | _, err = ci.CreateRun(ctx, n, ".github/workflows/other.yml", draft) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | require.NoError(t, ci.EnableWorkflow(ctx, n, wfRelease)) | |||
| 90 | + | _, err = ci.CreateRun(ctx, n, wfRelease, draft) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | } | |||
M internal/adapters/rpc/api.go +1 -1
| @@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | |
| 38 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache"} | |
| 38 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"} | |
| 39 | 39 | ||
| 40 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 41 | 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | |
| @@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10 | |||||
| 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | ||
| 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | 37 | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | ||
| 38 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache"} | 38 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"} |
| 39 | 39 | ||||
| 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | ||
| 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | 41 | // FeatureCIArtifacts (artifact.go) when the artifact services are served. | ||
M internal/adapters/rpc/ci.go +48 -0
| @@ -334,6 +334,54 @@ func (s *runService) DispatchWorkflow(ctx context.Context, req *connect.Request[ | |||
| 334 | 334 | return connect.NewResponse(&pb.DispatchWorkflowResponse{Run: runToPB(run)}), nil | |
| 335 | 335 | } | |
| 336 | 336 | ||
| 337 | + | func workflowToPB(w ports.WorkflowInfo) *pb.Workflow { | |
| 338 | + | state := pb.WorkflowState_WORKFLOW_STATE_ENABLED | |
| 339 | + | if w.Disabled { | |
| 340 | + | state = pb.WorkflowState_WORKFLOW_STATE_DISABLED | |
| 341 | + | } | |
| 342 | + | return &pb.Workflow{Path: w.Path, Name: w.Name, State: state} | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func (s *runService) ListWorkflows(ctx context.Context, req *connect.Request[pb.ListWorkflowsRequest]) (*connect.Response[pb.ListWorkflowsResponse], error) { | |
| 346 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 347 | + | if err != nil { | |
| 348 | + | return nil, ciError(ctx, err) | |
| 349 | + | } | |
| 350 | + | list, err := s.runs.ListWorkflows(ctx, caller(ctx), name) | |
| 351 | + | if err != nil { | |
| 352 | + | return nil, ciError(ctx, err) | |
| 353 | + | } | |
| 354 | + | out := &pb.ListWorkflowsResponse{Workflows: make([]*pb.Workflow, 0, len(list))} | |
| 355 | + | for _, w := range list { | |
| 356 | + | out.Workflows = append(out.Workflows, workflowToPB(w)) | |
| 357 | + | } | |
| 358 | + | return connect.NewResponse(out), nil | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | func (s *runService) DisableWorkflow(ctx context.Context, req *connect.Request[pb.DisableWorkflowRequest]) (*connect.Response[pb.DisableWorkflowResponse], error) { | |
| 362 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 363 | + | if err != nil { | |
| 364 | + | return nil, ciError(ctx, err) | |
| 365 | + | } | |
| 366 | + | w, err := s.runs.DisableWorkflow(ctx, caller(ctx), name, req.Msg.GetWorkflow()) | |
| 367 | + | if err != nil { | |
| 368 | + | return nil, ciError(ctx, err) | |
| 369 | + | } | |
| 370 | + | return connect.NewResponse(&pb.DisableWorkflowResponse{Workflow: workflowToPB(w)}), nil | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func (s *runService) EnableWorkflow(ctx context.Context, req *connect.Request[pb.EnableWorkflowRequest]) (*connect.Response[pb.EnableWorkflowResponse], error) { | |
| 374 | + | name, err := repoOf(req.Msg.GetRepo()) | |
| 375 | + | if err != nil { | |
| 376 | + | return nil, ciError(ctx, err) | |
| 377 | + | } | |
| 378 | + | w, err := s.runs.EnableWorkflow(ctx, caller(ctx), name, req.Msg.GetWorkflow()) | |
| 379 | + | if err != nil { | |
| 380 | + | return nil, ciError(ctx, err) | |
| 381 | + | } | |
| 382 | + | return connect.NewResponse(&pb.EnableWorkflowResponse{Workflow: workflowToPB(w)}), nil | |
| 383 | + | } | |
| 384 | + | ||
| 337 | 385 | func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) { | |
| 338 | 386 | name, err := repoOf(req.Msg.GetRepo()) | |
| 339 | 387 | if err != nil { | |
| @@ -334,6 +334,54 @@ func (s *runService) DispatchWorkflow(ctx context.Context, req *connect.Request[ | |||||
| 334 | return connect.NewResponse(&pb.DispatchWorkflowResponse{Run: runToPB(run)}), nil | 334 | return connect.NewResponse(&pb.DispatchWorkflowResponse{Run: runToPB(run)}), nil | ||
| 335 | } | 335 | } | ||
| 336 | 336 | ||||
| 337 | + | func workflowToPB(w ports.WorkflowInfo) *pb.Workflow { | |||
| 338 | + | state := pb.WorkflowState_WORKFLOW_STATE_ENABLED | |||
| 339 | + | if w.Disabled { | |||
| 340 | + | state = pb.WorkflowState_WORKFLOW_STATE_DISABLED | |||
| 341 | + | } | |||
| 342 | + | return &pb.Workflow{Path: w.Path, Name: w.Name, State: state} | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func (s *runService) ListWorkflows(ctx context.Context, req *connect.Request[pb.ListWorkflowsRequest]) (*connect.Response[pb.ListWorkflowsResponse], error) { | |||
| 346 | + | name, err := repoOf(req.Msg.GetRepo()) | |||
| 347 | + | if err != nil { | |||
| 348 | + | return nil, ciError(ctx, err) | |||
| 349 | + | } | |||
| 350 | + | list, err := s.runs.ListWorkflows(ctx, caller(ctx), name) | |||
| 351 | + | if err != nil { | |||
| 352 | + | return nil, ciError(ctx, err) | |||
| 353 | + | } | |||
| 354 | + | out := &pb.ListWorkflowsResponse{Workflows: make([]*pb.Workflow, 0, len(list))} | |||
| 355 | + | for _, w := range list { | |||
| 356 | + | out.Workflows = append(out.Workflows, workflowToPB(w)) | |||
| 357 | + | } | |||
| 358 | + | return connect.NewResponse(out), nil | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | func (s *runService) DisableWorkflow(ctx context.Context, req *connect.Request[pb.DisableWorkflowRequest]) (*connect.Response[pb.DisableWorkflowResponse], error) { | |||
| 362 | + | name, err := repoOf(req.Msg.GetRepo()) | |||
| 363 | + | if err != nil { | |||
| 364 | + | return nil, ciError(ctx, err) | |||
| 365 | + | } | |||
| 366 | + | w, err := s.runs.DisableWorkflow(ctx, caller(ctx), name, req.Msg.GetWorkflow()) | |||
| 367 | + | if err != nil { | |||
| 368 | + | return nil, ciError(ctx, err) | |||
| 369 | + | } | |||
| 370 | + | return connect.NewResponse(&pb.DisableWorkflowResponse{Workflow: workflowToPB(w)}), nil | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func (s *runService) EnableWorkflow(ctx context.Context, req *connect.Request[pb.EnableWorkflowRequest]) (*connect.Response[pb.EnableWorkflowResponse], error) { | |||
| 374 | + | name, err := repoOf(req.Msg.GetRepo()) | |||
| 375 | + | if err != nil { | |||
| 376 | + | return nil, ciError(ctx, err) | |||
| 377 | + | } | |||
| 378 | + | w, err := s.runs.EnableWorkflow(ctx, caller(ctx), name, req.Msg.GetWorkflow()) | |||
| 379 | + | if err != nil { | |||
| 380 | + | return nil, ciError(ctx, err) | |||
| 381 | + | } | |||
| 382 | + | return connect.NewResponse(&pb.EnableWorkflowResponse{Workflow: workflowToPB(w)}), nil | |||
| 383 | + | } | |||
| 384 | + | ||||
| 337 | func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) { | 385 | func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) { | ||
| 338 | name, err := repoOf(req.Msg.GetRepo()) | 386 | name, err := repoOf(req.Msg.GetRepo()) | ||
| 339 | if err != nil { | 387 | if err != nil { | ||
M internal/adapters/web/actions.templ +10 -0
| @@ -12,6 +12,13 @@ templ CheckIcon(kind, state string) { | |||
| 12 | 12 | ||
| 13 | 13 | templ Actions(l Layout, p ActionsPage) { | |
| 14 | 14 | @Shell(l, nil) { | |
| 15 | + | if len(p.Disabled) > 0 { | |
| 16 | + | <p class="dim wf-off">disabled workflows (they start no run): | |
| 17 | + | for _, f := range p.Disabled { | |
| 18 | + | <span class="pill">{ f }</span>{ " " } | |
| 19 | + | } | |
| 20 | + | </p> | |
| 21 | + | } | |
| 15 | 22 | if len(p.Runs) == 0 { | |
| 16 | 23 | <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p> | |
| 17 | 24 | } else { | |
| @@ -83,6 +90,9 @@ templ RecentActionsBlock(a RecentActions) { | |||
| 83 | 90 | templ RunDetail(l Layout, p RunPage) { | |
| 84 | 91 | @Shell(l, runRail(p)) { | |
| 85 | 92 | @LiveSlot("run-notice") | |
| 93 | + | if p.WorkflowDisabled { | |
| 94 | + | <p class="dim wf-off"><span class="pill">disabled</span> this workflow is disabled: no event starts a run of it (<code>ermite workflow enable</code>).</p> | |
| 95 | + | } | |
| 86 | 96 | <div id="run-body"> | |
| 87 | 97 | @RunBody(p) | |
| 88 | 98 | </div> | |
| @@ -12,6 +12,13 @@ templ CheckIcon(kind, state string) { | |||||
| 12 | 12 | ||||
| 13 | templ Actions(l Layout, p ActionsPage) { | 13 | templ Actions(l Layout, p ActionsPage) { | ||
| 14 | @Shell(l, nil) { | 14 | @Shell(l, nil) { | ||
| 15 | + | if len(p.Disabled) > 0 { | |||
| 16 | + | <p class="dim wf-off">disabled workflows (they start no run): | |||
| 17 | + | for _, f := range p.Disabled { | |||
| 18 | + | <span class="pill">{ f }</span>{ " " } | |||
| 19 | + | } | |||
| 20 | + | </p> | |||
| 21 | + | } | |||
| 15 | if len(p.Runs) == 0 { | 22 | if len(p.Runs) == 0 { | ||
| 16 | <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p> | 23 | <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p> | ||
| 17 | } else { | 24 | } else { | ||
| @@ -83,6 +90,9 @@ templ RecentActionsBlock(a RecentActions) { | |||||
| 83 | templ RunDetail(l Layout, p RunPage) { | 90 | templ RunDetail(l Layout, p RunPage) { | ||
| 84 | @Shell(l, runRail(p)) { | 91 | @Shell(l, runRail(p)) { | ||
| 85 | @LiveSlot("run-notice") | 92 | @LiveSlot("run-notice") | ||
| 93 | + | if p.WorkflowDisabled { | |||
| 94 | + | <p class="dim wf-off"><span class="pill">disabled</span> this workflow is disabled: no event starts a run of it (<code>ermite workflow enable</code>).</p> | |||
| 95 | + | } | |||
| 86 | <div id="run-body"> | 96 | <div id="run-body"> | ||
| 87 | @RunBody(p) | 97 | @RunBody(p) | ||
| 88 | </div> | 98 | </div> | ||
M internal/adapters/web/actions_artifacts_test.go +1 -0
| @@ -108,6 +108,7 @@ func artifactsServer(t *testing.T, arts *fakeArtifacts) *passkeyFixture { | |||
| 108 | 108 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | |
| 109 | 109 | }, nil).Maybe() | |
| 110 | 110 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil).Maybe() | |
| 111 | + | runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe() | |
| 111 | 112 | runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(ports.SummaryList{}, nil).Maybe() | |
| 112 | 113 | browse := mocks.NewBrowseService(t) | |
| 113 | 114 | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| @@ -108,6 +108,7 @@ func artifactsServer(t *testing.T, arts *fakeArtifacts) *passkeyFixture { | |||||
| 108 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | 108 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | ||
| 109 | }, nil).Maybe() | 109 | }, nil).Maybe() | ||
| 110 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil).Maybe() | 110 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil).Maybe() | ||
| 111 | + | runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe() | |||
| 111 | runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(ports.SummaryList{}, nil).Maybe() | 112 | runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(ports.SummaryList{}, nil).Maybe() | ||
| 112 | browse := mocks.NewBrowseService(t) | 113 | browse := mocks.NewBrowseService(t) | ||
| 113 | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | 114 | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | ||
M internal/adapters/web/actions_pages.go +19 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "net/http" | |
| 9 | + | "slices" | |
| 9 | 10 | "strconv" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "time" | |
| @@ -143,6 +144,9 @@ type ActionsPage struct { | |||
| 143 | 144 | More string | |
| 144 | 145 | // NoRunner says that CI has no runner to run on yet. | |
| 145 | 146 | NoRunner bool | |
| 147 | + | // Disabled are the workflow files that start no run (`ermite workflow | |
| 148 | + | // disable`), as paths. | |
| 149 | + | Disabled []string | |
| 146 | 150 | } | |
| 147 | 151 | ||
| 148 | 152 | // StepView is a step of a job. | |
| @@ -193,6 +197,9 @@ type RunPage struct { | |||
| 193 | 197 | Active bool | |
| 194 | 198 | // detail is what the page was built from. | |
| 195 | 199 | detail ports.RunDetail | |
| 200 | + | // WorkflowDisabled: the workflow file of the run is disabled now, so a new | |
| 201 | + | // event starts no run of it. | |
| 202 | + | WorkflowDisabled bool | |
| 196 | 203 | // Forms are the owner's actions; nil/empty when the visitor cannot. | |
| 197 | 204 | CancelAction, RerunAction, RerunFailedAction string | |
| 198 | 205 | CanRerunFailed bool | |
| @@ -223,6 +230,7 @@ func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, | |||
| 223 | 230 | if page.NextPageToken != "" { | |
| 224 | 231 | p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken | |
| 225 | 232 | } | |
| 233 | + | p.Disabled = s.disabledWorkflows(ctx, d) | |
| 226 | 234 | l := s.repoLayout(ctx, d, "actions") | |
| 227 | 235 | l.Title = "actions · " + d.name() + " · " + s.instance | |
| 228 | 236 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions") | |
| @@ -231,6 +239,16 @@ func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, | |||
| 231 | 239 | return Actions(l, p), http.StatusOK, nil | |
| 232 | 240 | } | |
| 233 | 241 | ||
| 242 | + | // disabledWorkflows are the disabled workflow paths of the repository; the | |
| 243 | + | // pages show them as a note, so a failure to read them is not one of the page. | |
| 244 | + | func (s *Server) disabledWorkflows(ctx context.Context, d *repoData) []string { | |
| 245 | + | off, err := s.cfg.Runs.DisabledWorkflows(ctx, principal(ctx), d.repo.Name) | |
| 246 | + | if err != nil { | |
| 247 | + | return nil | |
| 248 | + | } | |
| 249 | + | return off | |
| 250 | + | } | |
| 251 | + | ||
| 234 | 252 | func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow { | |
| 235 | 253 | row := RunRow{ | |
| 236 | 254 | ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID), | |
| @@ -318,6 +336,7 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int | |||
| 318 | 336 | return nil, 0, err | |
| 319 | 337 | } | |
| 320 | 338 | p.Artifacts = s.runArtifacts(ctx, d.repo.Name, p) | |
| 339 | + | p.WorkflowDisabled = slices.Contains(s.disabledWorkflows(ctx, d), p.detail.Run.WorkflowPath) | |
| 321 | 340 | l := s.repoLayout(ctx, d, "actions") | |
| 322 | 341 | l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance | |
| 323 | 342 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | + | "slices" | |||
| 9 | "strconv" | 10 | "strconv" | ||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "time" | 12 | "time" | ||
| @@ -143,6 +144,9 @@ type ActionsPage struct { | |||||
| 143 | More string | 144 | More string | ||
| 144 | // NoRunner says that CI has no runner to run on yet. | 145 | // NoRunner says that CI has no runner to run on yet. | ||
| 145 | NoRunner bool | 146 | NoRunner bool | ||
| 147 | + | // Disabled are the workflow files that start no run (`ermite workflow | |||
| 148 | + | // disable`), as paths. | |||
| 149 | + | Disabled []string | |||
| 146 | } | 150 | } | ||
| 147 | 151 | ||||
| 148 | // StepView is a step of a job. | 152 | // StepView is a step of a job. | ||
| @@ -193,6 +197,9 @@ type RunPage struct { | |||||
| 193 | Active bool | 197 | Active bool | ||
| 194 | // detail is what the page was built from. | 198 | // detail is what the page was built from. | ||
| 195 | detail ports.RunDetail | 199 | detail ports.RunDetail | ||
| 200 | + | // WorkflowDisabled: the workflow file of the run is disabled now, so a new | |||
| 201 | + | // event starts no run of it. | |||
| 202 | + | WorkflowDisabled bool | |||
| 196 | // Forms are the owner's actions; nil/empty when the visitor cannot. | 203 | // Forms are the owner's actions; nil/empty when the visitor cannot. | ||
| 197 | CancelAction, RerunAction, RerunFailedAction string | 204 | CancelAction, RerunAction, RerunFailedAction string | ||
| 198 | CanRerunFailed bool | 205 | CanRerunFailed bool | ||
| @@ -223,6 +230,7 @@ func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 223 | if page.NextPageToken != "" { | 230 | if page.NextPageToken != "" { | ||
| 224 | p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken | 231 | p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken | ||
| 225 | } | 232 | } | ||
| 233 | + | p.Disabled = s.disabledWorkflows(ctx, d) | |||
| 226 | l := s.repoLayout(ctx, d, "actions") | 234 | l := s.repoLayout(ctx, d, "actions") | ||
| 227 | l.Title = "actions · " + d.name() + " · " + s.instance | 235 | l.Title = "actions · " + d.name() + " · " + s.instance | ||
| 228 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions") | 236 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions") | ||
| @@ -231,6 +239,16 @@ func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 231 | return Actions(l, p), http.StatusOK, nil | 239 | return Actions(l, p), http.StatusOK, nil | ||
| 232 | } | 240 | } | ||
| 233 | 241 | ||||
| 242 | + | // disabledWorkflows are the disabled workflow paths of the repository; the | |||
| 243 | + | // pages show them as a note, so a failure to read them is not one of the page. | |||
| 244 | + | func (s *Server) disabledWorkflows(ctx context.Context, d *repoData) []string { | |||
| 245 | + | off, err := s.cfg.Runs.DisabledWorkflows(ctx, principal(ctx), d.repo.Name) | |||
| 246 | + | if err != nil { | |||
| 247 | + | return nil | |||
| 248 | + | } | |||
| 249 | + | return off | |||
| 250 | + | } | |||
| 251 | + | ||||
| 234 | func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow { | 252 | func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow { | ||
| 235 | row := RunRow{ | 253 | row := RunRow{ | ||
| 236 | ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID), | 254 | ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID), | ||
| @@ -318,6 +336,7 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int | |||||
| 318 | return nil, 0, err | 336 | return nil, 0, err | ||
| 319 | } | 337 | } | ||
| 320 | p.Artifacts = s.runArtifacts(ctx, d.repo.Name, p) | 338 | p.Artifacts = s.runArtifacts(ctx, d.repo.Name, p) | ||
| 339 | + | p.WorkflowDisabled = slices.Contains(s.disabledWorkflows(ctx, d), p.detail.Run.WorkflowPath) | |||
| 321 | l := s.repoLayout(ctx, d, "actions") | 340 | l := s.repoLayout(ctx, d, "actions") | ||
| 322 | l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance | 341 | l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance | ||
| 323 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID) | 342 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID) | ||
M internal/adapters/web/actions_results_test.go +33 -0
| @@ -43,6 +43,7 @@ func TestSummariesAreRenderedSafely(t *testing.T) { | |||
| 43 | 43 | }, "\n") | |
| 44 | 44 | ||
| 45 | 45 | runs := mocks.NewRunService(t) | |
| 46 | + | runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe() | |
| 46 | 47 | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | |
| 47 | 48 | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | |
| 48 | 49 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | |
| @@ -143,6 +144,7 @@ func runPageServer(t *testing.T, repo domain.RepoName, jobs []ports.JobView, run | |||
| 143 | 144 | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | |
| 144 | 145 | Jobs: jobs, LatestAttempt: 1, | |
| 145 | 146 | }, nil) | |
| 147 | + | runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe() | |
| 146 | 148 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil) | |
| 147 | 149 | browse := mocks.NewBrowseService(t) | |
| 148 | 150 | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| @@ -235,3 +237,34 @@ func TestJobStateDoesNotReadTheRun(t *testing.T) { | |||
| 235 | 237 | require.NoError(t, err) | |
| 236 | 238 | assert.Equal(t, gap, frags) | |
| 237 | 239 | } | |
| 240 | + | ||
| 241 | + | // A disabled workflow is said on the list of runs and on the page of its runs | |
| 242 | + | // (`ermite workflow disable`). | |
| 243 | + | func TestRunPagesSayWhenAWorkflowIsDisabled(t *testing.T) { | |
| 244 | + | t.Parallel() | |
| 245 | + | repo := repoName(t, "proj") | |
| 246 | + | const path = ".github/workflows/release.yml" | |
| 247 | + | runs := mocks.NewRunService(t) | |
| 248 | + | runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return([]string{path}, nil) | |
| 249 | + | runs.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RunPage{}, nil) | |
| 250 | + | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | |
| 251 | + | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "release", WorkflowPath: path, Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | |
| 252 | + | LatestAttempt: 1, | |
| 253 | + | }, nil) | |
| 254 | + | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil) | |
| 255 | + | browse := mocks.NewBrowseService(t) | |
| 256 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| 257 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs}) | |
| 258 | + | get := func(target string) string { | |
| 259 | + | rec := httptest.NewRecorder() | |
| 260 | + | srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, target, nil)) | |
| 261 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 262 | + | return rec.Body.String() | |
| 263 | + | } | |
| 264 | + | list := get("/proj/actions") | |
| 265 | + | assert.Contains(t, list, "disabled workflows") | |
| 266 | + | assert.Contains(t, list, path) | |
| 267 | + | run := get("/proj/actions/runs/1") | |
| 268 | + | assert.Contains(t, run, "this workflow is disabled") | |
| 269 | + | assert.NotContains(t, run, "onclick", "no inline script") | |
| 270 | + | } | |
| @@ -43,6 +43,7 @@ func TestSummariesAreRenderedSafely(t *testing.T) { | |||||
| 43 | }, "\n") | 43 | }, "\n") | ||
| 44 | 44 | ||||
| 45 | runs := mocks.NewRunService(t) | 45 | runs := mocks.NewRunService(t) | ||
| 46 | + | runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe() | |||
| 46 | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | 47 | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | ||
| 47 | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | 48 | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | ||
| 48 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | 49 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | ||
| @@ -143,6 +144,7 @@ func runPageServer(t *testing.T, repo domain.RepoName, jobs []ports.JobView, run | |||||
| 143 | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | 144 | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | ||
| 144 | Jobs: jobs, LatestAttempt: 1, | 145 | Jobs: jobs, LatestAttempt: 1, | ||
| 145 | }, nil) | 146 | }, nil) | ||
| 147 | + | runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe() | |||
| 146 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil) | 148 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil) | ||
| 147 | browse := mocks.NewBrowseService(t) | 149 | browse := mocks.NewBrowseService(t) | ||
| 148 | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | 150 | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | ||
| @@ -235,3 +237,34 @@ func TestJobStateDoesNotReadTheRun(t *testing.T) { | |||||
| 235 | require.NoError(t, err) | 237 | require.NoError(t, err) | ||
| 236 | assert.Equal(t, gap, frags) | 238 | assert.Equal(t, gap, frags) | ||
| 237 | } | 239 | } | ||
| 240 | + | ||||
| 241 | + | // A disabled workflow is said on the list of runs and on the page of its runs | |||
| 242 | + | // (`ermite workflow disable`). | |||
| 243 | + | func TestRunPagesSayWhenAWorkflowIsDisabled(t *testing.T) { | |||
| 244 | + | t.Parallel() | |||
| 245 | + | repo := repoName(t, "proj") | |||
| 246 | + | const path = ".github/workflows/release.yml" | |||
| 247 | + | runs := mocks.NewRunService(t) | |||
| 248 | + | runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return([]string{path}, nil) | |||
| 249 | + | runs.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RunPage{}, nil) | |||
| 250 | + | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | |||
| 251 | + | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "release", WorkflowPath: path, Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | |||
| 252 | + | LatestAttempt: 1, | |||
| 253 | + | }, nil) | |||
| 254 | + | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil) | |||
| 255 | + | browse := mocks.NewBrowseService(t) | |||
| 256 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |||
| 257 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs}) | |||
| 258 | + | get := func(target string) string { | |||
| 259 | + | rec := httptest.NewRecorder() | |||
| 260 | + | srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, target, nil)) | |||
| 261 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 262 | + | return rec.Body.String() | |||
| 263 | + | } | |||
| 264 | + | list := get("/proj/actions") | |||
| 265 | + | assert.Contains(t, list, "disabled workflows") | |||
| 266 | + | assert.Contains(t, list, path) | |||
| 267 | + | run := get("/proj/actions/runs/1") | |||
| 268 | + | assert.Contains(t, run, "this workflow is disabled") | |||
| 269 | + | assert.NotContains(t, run, "onclick", "no inline script") | |||
| 270 | + | } | |||
M internal/adapters/web/actions_templ.go +932 -880 generated file: collapsed
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) | 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who) |
| 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 201 | } | 201 | } | ||
| 202 | }) | 202 | }) | ||
A internal/app/ci_workflow_test.go +359 -0
| @@ -0,0 +1,359 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/usecase" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // `ermite workflow disable|enable|list` (P8, D5): a disabled workflow starts no | |
| 19 | + | // run for any event, and enabling it restores it. | |
| 20 | + | ||
| 21 | + | const releaseWF = ".github/workflows/release.yml" | |
| 22 | + | ||
| 23 | + | // wfOn is a workflow file with the given "on:" block (indented under on:). | |
| 24 | + | func wfOn(name, on string) string { | |
| 25 | + | return "name: " + name + "\non:\n" + on + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n" | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (c *ciHarness) setWorkflow(api *clients, disable bool, file string) (*pb.Workflow, error) { | |
| 29 | + | c.t.Helper() | |
| 30 | + | if disable { | |
| 31 | + | res, err := api.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: c.repo, Workflow: file})) | |
| 32 | + | if err != nil { | |
| 33 | + | return nil, err | |
| 34 | + | } | |
| 35 | + | return res.Msg.GetWorkflow(), nil | |
| 36 | + | } | |
| 37 | + | res, err := api.run.EnableWorkflow(c.ctx, connect.NewRequest(&pb.EnableWorkflowRequest{Repo: c.repo, Workflow: file})) | |
| 38 | + | if err != nil { | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | return res.Msg.GetWorkflow(), nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (c *ciHarness) listWorkflows(api *clients) map[string]*pb.Workflow { | |
| 45 | + | c.t.Helper() | |
| 46 | + | res, err := api.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo})) | |
| 47 | + | require.NoError(c.t, err) | |
| 48 | + | out := map[string]*pb.Workflow{} | |
| 49 | + | for _, w := range res.Msg.GetWorkflows() { | |
| 50 | + | out[w.GetPath()] = w | |
| 51 | + | } | |
| 52 | + | return out | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func runsOfWorkflow(runs []*pb.Run, path string) []*pb.Run { | |
| 56 | + | var out []*pb.Run | |
| 57 | + | for _, r := range runs { | |
| 58 | + | if r.GetWorkflowPath() == path { | |
| 59 | + | out = append(out, r) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return out | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Disabled before the first push, on an empty repository where the file does | |
| 66 | + | // not exist yet: the push of many refs starts nothing of it, the other | |
| 67 | + | // workflows still run, and enabling restores it. | |
| 68 | + | func TestWorkflowDisabledBeforeFirstPushStartsNoRun(t *testing.T) { | |
| 69 | + | c := newCIHarness(t, nil) | |
| 70 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 71 | + | ||
| 72 | + | w, err := c.setWorkflow(c.root, true, "release.yml") | |
| 73 | + | require.NoError(t, err, "the repository is empty and has no such file") | |
| 74 | + | assert.Equal(t, releaseWF, w.GetPath(), "stored by its normalized path") | |
| 75 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, w.GetState()) | |
| 76 | + | _, err = c.setWorkflow(c.root, true, releaseWF) | |
| 77 | + | require.NoError(t, err, "idempotent, by path too") | |
| 78 | + | ||
| 79 | + | // The list shows it though no branch holds the file. | |
| 80 | + | listed := c.listWorkflows(c.root) | |
| 81 | + | require.Len(t, listed, 1) | |
| 82 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState()) | |
| 83 | + | assert.Empty(t, listed[releaseWF].GetName(), "no file, no name") | |
| 84 | + | ||
| 85 | + | push := wfOn("release", " push:\n tags: ['v*']\n branches: [main]\n") | |
| 86 | + | c.push("main", map[string]string{ | |
| 87 | + | releaseWF: push, | |
| 88 | + | ".github/workflows/z-ctrl.yml": wfOn("control", " push:\n branches: [main]\n"), | |
| 89 | + | }, "") | |
| 90 | + | c.waitFor("the control run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 1 }) | |
| 91 | + | assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF), "a disabled workflow starts no push run") | |
| 92 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ctrl") | |
| 93 | + | ||
| 94 | + | // Tags: many at once. | |
| 95 | + | for _, tag := range []string{"v1", "v2", "v3"} { | |
| 96 | + | c.mustGit(c.wt, "tag", tag) | |
| 97 | + | } | |
| 98 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), "v1", "v2", "v3") | |
| 99 | + | c.push("main", map[string]string{"x.txt": "x"}, "") | |
| 100 | + | c.waitFor("the control run of the next push", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 2 }) | |
| 101 | + | assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF)) | |
| 102 | + | ||
| 103 | + | listed = c.listWorkflows(c.root) | |
| 104 | + | assert.Equal(t, "release", listed[releaseWF].GetName(), "the file now exists and has a name") | |
| 105 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState()) | |
| 106 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, listed[".github/workflows/z-ctrl.yml"].GetState()) | |
| 107 | + | ||
| 108 | + | // Enabling restores it. | |
| 109 | + | w, err = c.setWorkflow(c.root, false, "release.yml") | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, w.GetState()) | |
| 112 | + | _, err = c.setWorkflow(c.root, false, "release.yml") | |
| 113 | + | require.NoError(t, err, "idempotent") | |
| 114 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, c.listWorkflows(c.root)[releaseWF].GetState()) | |
| 115 | + | c.push("main", map[string]string{"y.txt": "y"}, "") | |
| 116 | + | c.waitFor("the release run", func() bool { return len(runsOfWorkflow(c.runs(), releaseWF)) == 1 }) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // A disabled workflow does not fire on its schedule, and fires again once | |
| 120 | + | // enabled, with no reload of the schedules. | |
| 121 | + | func TestWorkflowDisabledStartsNoScheduledRun(t *testing.T) { | |
| 122 | + | c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30") | |
| 123 | + | c.push("main", scheduleWorkflow("*/5 * * * *"), "") | |
| 124 | + | ci := c.forge.CI | |
| 125 | + | tick := func(at string) { | |
| 126 | + | clk.set(at) | |
| 127 | + | ci.ScheduleNow(c.ctx) | |
| 128 | + | } | |
| 129 | + | tick("2026-03-04 10:05:00") | |
| 130 | + | require.Len(t, c.scheduled(), 1, "enabled: fires") | |
| 131 | + | assert.Equal(t, 1, ci.CachedSchedules(c.name("proj"))) | |
| 132 | + | ||
| 133 | + | _, err := c.setWorkflow(c.root, true, "nightly.yml") | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the cache holds every workflow: the state is read when it fires") | |
| 136 | + | tick("2026-03-04 10:10:00") | |
| 137 | + | tick("2026-03-04 10:15:00") | |
| 138 | + | assert.Len(t, c.scheduled(), 1, "disabled: no run for the minutes 10:10 and 10:15") | |
| 139 | + | ||
| 140 | + | // A push of the file while disabled changes nothing. | |
| 141 | + | c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "again") | |
| 142 | + | tick("2026-03-04 10:20:00") | |
| 143 | + | assert.Len(t, c.scheduled(), 1) | |
| 144 | + | ||
| 145 | + | _, err = c.setWorkflow(c.root, false, "nightly.yml") | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | tick("2026-03-04 10:25:00") | |
| 148 | + | assert.Len(t, c.scheduled(), 2, "enabled again: fires, without catching up the missed minutes") | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // workflow_dispatch and pull_request are refused/skipped too, per file. | |
| 152 | + | func TestWorkflowDisabledStartsNoDispatchOrPullRequestRun(t *testing.T) { | |
| 153 | + | c := newCIHarness(t, nil) | |
| 154 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 155 | + | c.push("main", map[string]string{ | |
| 156 | + | ".github/workflows/deploy.yml": dispatchWorkflow, | |
| 157 | + | ".github/workflows/pr.yml": wfOn("pr", " pull_request:\n"), | |
| 158 | + | ".github/workflows/other.yml": wfOn("other", " pull_request:\n"), | |
| 159 | + | }, "") | |
| 160 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "feat") | |
| 161 | + | c.push("feat", map[string]string{"f.txt": "f"}, "") | |
| 162 | + | ||
| 163 | + | _, err := c.setWorkflow(c.root, true, "deploy.yml") | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | _, err = c.setWorkflow(c.root, true, ".github/workflows/pr.yml") | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | ||
| 168 | + | _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"}) | |
| 169 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a disabled workflow is not dispatched") | |
| 170 | + | assert.Contains(t, err.Error(), "disabled") | |
| 171 | + | ||
| 172 | + | c.openMR(c.root, "feat") | |
| 173 | + | c.waitFor("the other workflow's run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/other.yml")) == 1 }) | |
| 174 | + | assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/pr.yml"), "no pull_request run") | |
| 175 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "other") | |
| 176 | + | ||
| 177 | + | _, err = c.setWorkflow(c.root, false, "deploy.yml") | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | run, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"}) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | assert.Equal(t, "workflow_dispatch", run.GetEvent()) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // A disabled workflow does not start a workflow_run workflow. | |
| 185 | + | func TestWorkflowDisabledStartsNoWorkflowRunRun(t *testing.T) { | |
| 186 | + | c := newCIHarness(t, nil) | |
| 187 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 188 | + | c.push("main", map[string]string{ | |
| 189 | + | ".github/workflows/ci.yml": wfOn("ci", " push:\n"), | |
| 190 | + | ".github/workflows/after.yml": wfOn("after", " workflow_run:\n workflows: [ci]\n types: [completed]\n"), | |
| 191 | + | ".github/workflows/after2.yml": wfOn("after2", " workflow_run:\n workflows: [ci]\n types: [completed]\n"), | |
| 192 | + | }, "") | |
| 193 | + | _, err := c.setWorkflow(c.root, true, "after.yml") | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | // Pushed before the disable took effect? The first push ran ci: finish it. | |
| 196 | + | job := runner.await() | |
| 197 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ci") | |
| 198 | + | c.waitFor("after2 runs", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/after2.yml")) == 1 }) | |
| 199 | + | assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/after.yml")) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // Scopes: repo:admin changes the state, repo:read lists, ci:write and | |
| 203 | + | // repo:write do not change it; the shape is checked, not the existence. | |
| 204 | + | func TestWorkflowStateAccessAndValidation(t *testing.T) { | |
| 205 | + | c := newCIHarness(t, nil) | |
| 206 | + | admin := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), "proj")) | |
| 207 | + | writer := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), "proj")) | |
| 208 | + | reader := c.api(c.token(scopes(domain.ScopeRepoRead), "proj")) | |
| 209 | + | stranger := c.api(c.token(scopes(domain.ScopeRepoAdmin), "other")) | |
| 210 | + | limited, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/"}, "proj") | |
| 211 | + | ||
| 212 | + | _, err := c.setWorkflow(admin, true, "release.yml") | |
| 213 | + | require.NoError(t, err) | |
| 214 | + | _, err = c.setWorkflow(writer, true, "aur.yml") | |
| 215 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "repo:write and ci:write are not enough") | |
| 216 | + | _, err = c.setWorkflow(writer, false, "release.yml") | |
| 217 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 218 | + | _, err = c.setWorkflow(reader, true, "aur.yml") | |
| 219 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 220 | + | _, err = c.setWorkflow(c.api(limited), true, "aur.yml") | |
| 221 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "a token limited to branches may not") | |
| 222 | + | _, err = c.setWorkflow(stranger, true, "aur.yml") | |
| 223 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a repository the token cannot read") | |
| 224 | + | ||
| 225 | + | for _, bad := range []string{"", "release", "release.txt", "sub/release.yml", ".github/workflows/sub/release.yml", "../release.yml", ".github/other/release.yml"} { | |
| 226 | + | _, err = c.setWorkflow(admin, true, bad) | |
| 227 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "%q", bad) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, c.listWorkflows(reader)[releaseWF].GetState(), "repo:read lists") | |
| 231 | + | assert.Len(t, c.listWorkflows(reader), 1, "the refused changes left nothing") | |
| 232 | + | _, err = stranger.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo})) | |
| 233 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 234 | + | _, err = c.setWorkflow(admin, true, ".github/workflows/Odd Name.YAML") | |
| 235 | + | require.NoError(t, err, "only the shape is checked") | |
| 236 | + | ||
| 237 | + | _, err = admin.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: "nope", Workflow: "a.yml"})) | |
| 238 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // GO-001: the state is enforced where the run is stored. A dispatch that passed | |
| 242 | + | // the check and is paused after resolving its ref (DispatchResolved) while the | |
| 243 | + | // workflow is disabled creates no run. | |
| 244 | + | func TestWorkflowDisabledBetweenDispatchCheckAndCreation(t *testing.T) { | |
| 245 | + | var c *ciHarness | |
| 246 | + | var disable func() | |
| 247 | + | c = newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 248 | + | cfg.DispatchResolved = func() { | |
| 249 | + | if disable != nil { | |
| 250 | + | disable() | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | }) | |
| 254 | + | c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "") | |
| 255 | + | before := len(c.runs()) | |
| 256 | + | ||
| 257 | + | disable = func() { | |
| 258 | + | disable = nil | |
| 259 | + | _, err := c.setWorkflow(c.root, true, "deploy.yml") | |
| 260 | + | require.NoError(t, err) | |
| 261 | + | } | |
| 262 | + | _, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"}) | |
| 263 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "the disable landed after the check") | |
| 264 | + | assert.Contains(t, err.Error(), "disabled") | |
| 265 | + | assert.Len(t, c.runs(), before, "no run was created") | |
| 266 | + | ||
| 267 | + | _, err = c.setWorkflow(c.root, false, "deploy.yml") | |
| 268 | + | require.NoError(t, err) | |
| 269 | + | _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"}) | |
| 270 | + | require.NoError(t, err, "enabled again") | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // GO-001: an invalid workflow file gets no failed run either while it is | |
| 274 | + | // disabled, on a push and on the failed-run path of a disable that races. | |
| 275 | + | func TestWorkflowDisabledInvalidFileStartsNoFailedRun(t *testing.T) { | |
| 276 | + | c := newCIHarness(t, nil) | |
| 277 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 278 | + | _, err := c.setWorkflow(c.root, true, "broken.yml") | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | c.push("main", map[string]string{ | |
| 281 | + | ".github/workflows/broken.yml": "on: push\njobs: [this is not a workflow\n", | |
| 282 | + | ".github/workflows/bad2.yml": "on: push\njobs: [this is not a workflow\n", | |
| 283 | + | }, "") | |
| 284 | + | c.waitFor("the invalid enabled file fails", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/bad2.yml")) == 1 }) | |
| 285 | + | assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/broken.yml"), "a disabled invalid file reports nothing") | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // GO-002: a schedule load that is overtaken by an enable (or a disable) neither | |
| 289 | + | // loses the workflow nor fires a disabled one: the cache holds every workflow | |
| 290 | + | // and the state is read when the schedule fires. | |
| 291 | + | func TestScheduleLoadRacingWithEnableAndDisable(t *testing.T) { | |
| 292 | + | clk := &testClock{} | |
| 293 | + | clk.set("2026-03-04 10:00:30") | |
| 294 | + | var c *ciHarness | |
| 295 | + | var inLoad func() | |
| 296 | + | c = newCIHarness(t, func(cfg *usecase.CIConfig) { | |
| 297 | + | cfg.Clock = clk | |
| 298 | + | cfg.ScheduleTick = time.Hour | |
| 299 | + | cfg.ScheduleLoaded = func(domain.RepoName) { | |
| 300 | + | if f := inLoad; f != nil { | |
| 301 | + | inLoad = nil | |
| 302 | + | f() | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | }) | |
| 306 | + | c.registerRunner("alpha", "ubuntu-latest") | |
| 307 | + | ci := c.forge.CI | |
| 308 | + | tick := func(at string) { | |
| 309 | + | clk.set(at) | |
| 310 | + | ci.ScheduleNow(c.ctx) | |
| 311 | + | } | |
| 312 | + | // Disabled before the first push; the load of the schedules sees the file, | |
| 313 | + | // and an enable lands while it is being cached. | |
| 314 | + | _, err := c.setWorkflow(c.root, true, "nightly.yml") | |
| 315 | + | require.NoError(t, err) | |
| 316 | + | c.push("main", scheduleWorkflow("*/5 * * * *"), "") | |
| 317 | + | inLoad = func() { | |
| 318 | + | _, err := c.setWorkflow(c.root, false, "nightly.yml") | |
| 319 | + | require.NoError(t, err) | |
| 320 | + | } | |
| 321 | + | tick("2026-03-04 10:05:00") | |
| 322 | + | require.Len(t, c.scheduled(), 1, "enabled while the load was running: the workflow is there") | |
| 323 | + | assert.Equal(t, 1, ci.CachedSchedules(c.name("proj"))) | |
| 324 | + | ||
| 325 | + | // And the other way: disabled while a load is running. | |
| 326 | + | require.NoError(t, func() error { _, err := c.setWorkflow(c.root, false, "nightly.yml"); return err }()) | |
| 327 | + | c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "second") // the tip moves: a new load | |
| 328 | + | inLoad = func() { | |
| 329 | + | _, err := c.setWorkflow(c.root, true, "nightly.yml") | |
| 330 | + | require.NoError(t, err) | |
| 331 | + | } | |
| 332 | + | tick("2026-03-04 10:10:00") | |
| 333 | + | assert.Len(t, c.scheduled(), 1, "disabled while the load was running: it does not fire") | |
| 334 | + | _, err = c.setWorkflow(c.root, false, "nightly.yml") | |
| 335 | + | require.NoError(t, err) | |
| 336 | + | tick("2026-03-04 10:15:00") | |
| 337 | + | assert.Len(t, c.scheduled(), 2, "enabled again: fires with the cached entry, nothing missing") | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // GO-003: one file has one key. Non-canonical spellings are refused by the API, | |
| 341 | + | // for disable, enable and dispatch alike. | |
| 342 | + | func TestWorkflowNonCanonicalPathsAreRefused(t *testing.T) { | |
| 343 | + | c := newCIHarness(t, nil) | |
| 344 | + | c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "") | |
| 345 | + | for _, bad := range []string{ | |
| 346 | + | ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml", | |
| 347 | + | ".github/workflows/./release.yml", | |
| 348 | + | } { | |
| 349 | + | _, err := c.setWorkflow(c.root, true, bad) | |
| 350 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "disable %q", bad) | |
| 351 | + | _, err = c.setWorkflow(c.root, false, bad) | |
| 352 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "enable %q", bad) | |
| 353 | + | _, err = c.dispatch(c.root, bad, "", nil) | |
| 354 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "dispatch %q", bad) | |
| 355 | + | } | |
| 356 | + | assert.Empty(t, c.listWorkflows(c.root)[".github/workflows/release.yml"], "nothing was stored") | |
| 357 | + | _, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "", map[string]string{"version": "1"}) | |
| 358 | + | require.NoError(t, err, "the canonical path still dispatches") | |
| 359 | + | } | |
| @@ -0,0 +1,359 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/usecase" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // `ermite workflow disable|enable|list` (P8, D5): a disabled workflow starts no | |||
| 19 | + | // run for any event, and enabling it restores it. | |||
| 20 | + | ||||
| 21 | + | const releaseWF = ".github/workflows/release.yml" | |||
| 22 | + | ||||
| 23 | + | // wfOn is a workflow file with the given "on:" block (indented under on:). | |||
| 24 | + | func wfOn(name, on string) string { | |||
| 25 | + | return "name: " + name + "\non:\n" + on + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n" | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (c *ciHarness) setWorkflow(api *clients, disable bool, file string) (*pb.Workflow, error) { | |||
| 29 | + | c.t.Helper() | |||
| 30 | + | if disable { | |||
| 31 | + | res, err := api.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: c.repo, Workflow: file})) | |||
| 32 | + | if err != nil { | |||
| 33 | + | return nil, err | |||
| 34 | + | } | |||
| 35 | + | return res.Msg.GetWorkflow(), nil | |||
| 36 | + | } | |||
| 37 | + | res, err := api.run.EnableWorkflow(c.ctx, connect.NewRequest(&pb.EnableWorkflowRequest{Repo: c.repo, Workflow: file})) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return nil, err | |||
| 40 | + | } | |||
| 41 | + | return res.Msg.GetWorkflow(), nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (c *ciHarness) listWorkflows(api *clients) map[string]*pb.Workflow { | |||
| 45 | + | c.t.Helper() | |||
| 46 | + | res, err := api.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo})) | |||
| 47 | + | require.NoError(c.t, err) | |||
| 48 | + | out := map[string]*pb.Workflow{} | |||
| 49 | + | for _, w := range res.Msg.GetWorkflows() { | |||
| 50 | + | out[w.GetPath()] = w | |||
| 51 | + | } | |||
| 52 | + | return out | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func runsOfWorkflow(runs []*pb.Run, path string) []*pb.Run { | |||
| 56 | + | var out []*pb.Run | |||
| 57 | + | for _, r := range runs { | |||
| 58 | + | if r.GetWorkflowPath() == path { | |||
| 59 | + | out = append(out, r) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | return out | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Disabled before the first push, on an empty repository where the file does | |||
| 66 | + | // not exist yet: the push of many refs starts nothing of it, the other | |||
| 67 | + | // workflows still run, and enabling restores it. | |||
| 68 | + | func TestWorkflowDisabledBeforeFirstPushStartsNoRun(t *testing.T) { | |||
| 69 | + | c := newCIHarness(t, nil) | |||
| 70 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 71 | + | ||||
| 72 | + | w, err := c.setWorkflow(c.root, true, "release.yml") | |||
| 73 | + | require.NoError(t, err, "the repository is empty and has no such file") | |||
| 74 | + | assert.Equal(t, releaseWF, w.GetPath(), "stored by its normalized path") | |||
| 75 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, w.GetState()) | |||
| 76 | + | _, err = c.setWorkflow(c.root, true, releaseWF) | |||
| 77 | + | require.NoError(t, err, "idempotent, by path too") | |||
| 78 | + | ||||
| 79 | + | // The list shows it though no branch holds the file. | |||
| 80 | + | listed := c.listWorkflows(c.root) | |||
| 81 | + | require.Len(t, listed, 1) | |||
| 82 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState()) | |||
| 83 | + | assert.Empty(t, listed[releaseWF].GetName(), "no file, no name") | |||
| 84 | + | ||||
| 85 | + | push := wfOn("release", " push:\n tags: ['v*']\n branches: [main]\n") | |||
| 86 | + | c.push("main", map[string]string{ | |||
| 87 | + | releaseWF: push, | |||
| 88 | + | ".github/workflows/z-ctrl.yml": wfOn("control", " push:\n branches: [main]\n"), | |||
| 89 | + | }, "") | |||
| 90 | + | c.waitFor("the control run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 1 }) | |||
| 91 | + | assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF), "a disabled workflow starts no push run") | |||
| 92 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ctrl") | |||
| 93 | + | ||||
| 94 | + | // Tags: many at once. | |||
| 95 | + | for _, tag := range []string{"v1", "v2", "v3"} { | |||
| 96 | + | c.mustGit(c.wt, "tag", tag) | |||
| 97 | + | } | |||
| 98 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), "v1", "v2", "v3") | |||
| 99 | + | c.push("main", map[string]string{"x.txt": "x"}, "") | |||
| 100 | + | c.waitFor("the control run of the next push", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 2 }) | |||
| 101 | + | assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF)) | |||
| 102 | + | ||||
| 103 | + | listed = c.listWorkflows(c.root) | |||
| 104 | + | assert.Equal(t, "release", listed[releaseWF].GetName(), "the file now exists and has a name") | |||
| 105 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState()) | |||
| 106 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, listed[".github/workflows/z-ctrl.yml"].GetState()) | |||
| 107 | + | ||||
| 108 | + | // Enabling restores it. | |||
| 109 | + | w, err = c.setWorkflow(c.root, false, "release.yml") | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, w.GetState()) | |||
| 112 | + | _, err = c.setWorkflow(c.root, false, "release.yml") | |||
| 113 | + | require.NoError(t, err, "idempotent") | |||
| 114 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, c.listWorkflows(c.root)[releaseWF].GetState()) | |||
| 115 | + | c.push("main", map[string]string{"y.txt": "y"}, "") | |||
| 116 | + | c.waitFor("the release run", func() bool { return len(runsOfWorkflow(c.runs(), releaseWF)) == 1 }) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // A disabled workflow does not fire on its schedule, and fires again once | |||
| 120 | + | // enabled, with no reload of the schedules. | |||
| 121 | + | func TestWorkflowDisabledStartsNoScheduledRun(t *testing.T) { | |||
| 122 | + | c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30") | |||
| 123 | + | c.push("main", scheduleWorkflow("*/5 * * * *"), "") | |||
| 124 | + | ci := c.forge.CI | |||
| 125 | + | tick := func(at string) { | |||
| 126 | + | clk.set(at) | |||
| 127 | + | ci.ScheduleNow(c.ctx) | |||
| 128 | + | } | |||
| 129 | + | tick("2026-03-04 10:05:00") | |||
| 130 | + | require.Len(t, c.scheduled(), 1, "enabled: fires") | |||
| 131 | + | assert.Equal(t, 1, ci.CachedSchedules(c.name("proj"))) | |||
| 132 | + | ||||
| 133 | + | _, err := c.setWorkflow(c.root, true, "nightly.yml") | |||
| 134 | + | require.NoError(t, err) | |||
| 135 | + | assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the cache holds every workflow: the state is read when it fires") | |||
| 136 | + | tick("2026-03-04 10:10:00") | |||
| 137 | + | tick("2026-03-04 10:15:00") | |||
| 138 | + | assert.Len(t, c.scheduled(), 1, "disabled: no run for the minutes 10:10 and 10:15") | |||
| 139 | + | ||||
| 140 | + | // A push of the file while disabled changes nothing. | |||
| 141 | + | c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "again") | |||
| 142 | + | tick("2026-03-04 10:20:00") | |||
| 143 | + | assert.Len(t, c.scheduled(), 1) | |||
| 144 | + | ||||
| 145 | + | _, err = c.setWorkflow(c.root, false, "nightly.yml") | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | tick("2026-03-04 10:25:00") | |||
| 148 | + | assert.Len(t, c.scheduled(), 2, "enabled again: fires, without catching up the missed minutes") | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // workflow_dispatch and pull_request are refused/skipped too, per file. | |||
| 152 | + | func TestWorkflowDisabledStartsNoDispatchOrPullRequestRun(t *testing.T) { | |||
| 153 | + | c := newCIHarness(t, nil) | |||
| 154 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 155 | + | c.push("main", map[string]string{ | |||
| 156 | + | ".github/workflows/deploy.yml": dispatchWorkflow, | |||
| 157 | + | ".github/workflows/pr.yml": wfOn("pr", " pull_request:\n"), | |||
| 158 | + | ".github/workflows/other.yml": wfOn("other", " pull_request:\n"), | |||
| 159 | + | }, "") | |||
| 160 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "feat") | |||
| 161 | + | c.push("feat", map[string]string{"f.txt": "f"}, "") | |||
| 162 | + | ||||
| 163 | + | _, err := c.setWorkflow(c.root, true, "deploy.yml") | |||
| 164 | + | require.NoError(t, err) | |||
| 165 | + | _, err = c.setWorkflow(c.root, true, ".github/workflows/pr.yml") | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | ||||
| 168 | + | _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"}) | |||
| 169 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a disabled workflow is not dispatched") | |||
| 170 | + | assert.Contains(t, err.Error(), "disabled") | |||
| 171 | + | ||||
| 172 | + | c.openMR(c.root, "feat") | |||
| 173 | + | c.waitFor("the other workflow's run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/other.yml")) == 1 }) | |||
| 174 | + | assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/pr.yml"), "no pull_request run") | |||
| 175 | + | runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "other") | |||
| 176 | + | ||||
| 177 | + | _, err = c.setWorkflow(c.root, false, "deploy.yml") | |||
| 178 | + | require.NoError(t, err) | |||
| 179 | + | run, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"}) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | assert.Equal(t, "workflow_dispatch", run.GetEvent()) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // A disabled workflow does not start a workflow_run workflow. | |||
| 185 | + | func TestWorkflowDisabledStartsNoWorkflowRunRun(t *testing.T) { | |||
| 186 | + | c := newCIHarness(t, nil) | |||
| 187 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 188 | + | c.push("main", map[string]string{ | |||
| 189 | + | ".github/workflows/ci.yml": wfOn("ci", " push:\n"), | |||
| 190 | + | ".github/workflows/after.yml": wfOn("after", " workflow_run:\n workflows: [ci]\n types: [completed]\n"), | |||
| 191 | + | ".github/workflows/after2.yml": wfOn("after2", " workflow_run:\n workflows: [ci]\n types: [completed]\n"), | |||
| 192 | + | }, "") | |||
| 193 | + | _, err := c.setWorkflow(c.root, true, "after.yml") | |||
| 194 | + | require.NoError(t, err) | |||
| 195 | + | // Pushed before the disable took effect? The first push ran ci: finish it. | |||
| 196 | + | job := runner.await() | |||
| 197 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ci") | |||
| 198 | + | c.waitFor("after2 runs", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/after2.yml")) == 1 }) | |||
| 199 | + | assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/after.yml")) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // Scopes: repo:admin changes the state, repo:read lists, ci:write and | |||
| 203 | + | // repo:write do not change it; the shape is checked, not the existence. | |||
| 204 | + | func TestWorkflowStateAccessAndValidation(t *testing.T) { | |||
| 205 | + | c := newCIHarness(t, nil) | |||
| 206 | + | admin := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), "proj")) | |||
| 207 | + | writer := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), "proj")) | |||
| 208 | + | reader := c.api(c.token(scopes(domain.ScopeRepoRead), "proj")) | |||
| 209 | + | stranger := c.api(c.token(scopes(domain.ScopeRepoAdmin), "other")) | |||
| 210 | + | limited, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/"}, "proj") | |||
| 211 | + | ||||
| 212 | + | _, err := c.setWorkflow(admin, true, "release.yml") | |||
| 213 | + | require.NoError(t, err) | |||
| 214 | + | _, err = c.setWorkflow(writer, true, "aur.yml") | |||
| 215 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "repo:write and ci:write are not enough") | |||
| 216 | + | _, err = c.setWorkflow(writer, false, "release.yml") | |||
| 217 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |||
| 218 | + | _, err = c.setWorkflow(reader, true, "aur.yml") | |||
| 219 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |||
| 220 | + | _, err = c.setWorkflow(c.api(limited), true, "aur.yml") | |||
| 221 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "a token limited to branches may not") | |||
| 222 | + | _, err = c.setWorkflow(stranger, true, "aur.yml") | |||
| 223 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a repository the token cannot read") | |||
| 224 | + | ||||
| 225 | + | for _, bad := range []string{"", "release", "release.txt", "sub/release.yml", ".github/workflows/sub/release.yml", "../release.yml", ".github/other/release.yml"} { | |||
| 226 | + | _, err = c.setWorkflow(admin, true, bad) | |||
| 227 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "%q", bad) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, c.listWorkflows(reader)[releaseWF].GetState(), "repo:read lists") | |||
| 231 | + | assert.Len(t, c.listWorkflows(reader), 1, "the refused changes left nothing") | |||
| 232 | + | _, err = stranger.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo})) | |||
| 233 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |||
| 234 | + | _, err = c.setWorkflow(admin, true, ".github/workflows/Odd Name.YAML") | |||
| 235 | + | require.NoError(t, err, "only the shape is checked") | |||
| 236 | + | ||||
| 237 | + | _, err = admin.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: "nope", Workflow: "a.yml"})) | |||
| 238 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // GO-001: the state is enforced where the run is stored. A dispatch that passed | |||
| 242 | + | // the check and is paused after resolving its ref (DispatchResolved) while the | |||
| 243 | + | // workflow is disabled creates no run. | |||
| 244 | + | func TestWorkflowDisabledBetweenDispatchCheckAndCreation(t *testing.T) { | |||
| 245 | + | var c *ciHarness | |||
| 246 | + | var disable func() | |||
| 247 | + | c = newCIHarness(t, func(cfg *usecase.CIConfig) { | |||
| 248 | + | cfg.DispatchResolved = func() { | |||
| 249 | + | if disable != nil { | |||
| 250 | + | disable() | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | }) | |||
| 254 | + | c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "") | |||
| 255 | + | before := len(c.runs()) | |||
| 256 | + | ||||
| 257 | + | disable = func() { | |||
| 258 | + | disable = nil | |||
| 259 | + | _, err := c.setWorkflow(c.root, true, "deploy.yml") | |||
| 260 | + | require.NoError(t, err) | |||
| 261 | + | } | |||
| 262 | + | _, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"}) | |||
| 263 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "the disable landed after the check") | |||
| 264 | + | assert.Contains(t, err.Error(), "disabled") | |||
| 265 | + | assert.Len(t, c.runs(), before, "no run was created") | |||
| 266 | + | ||||
| 267 | + | _, err = c.setWorkflow(c.root, false, "deploy.yml") | |||
| 268 | + | require.NoError(t, err) | |||
| 269 | + | _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"}) | |||
| 270 | + | require.NoError(t, err, "enabled again") | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // GO-001: an invalid workflow file gets no failed run either while it is | |||
| 274 | + | // disabled, on a push and on the failed-run path of a disable that races. | |||
| 275 | + | func TestWorkflowDisabledInvalidFileStartsNoFailedRun(t *testing.T) { | |||
| 276 | + | c := newCIHarness(t, nil) | |||
| 277 | + | c.registerRunner("alpha", "ubuntu-latest") | |||
| 278 | + | _, err := c.setWorkflow(c.root, true, "broken.yml") | |||
| 279 | + | require.NoError(t, err) | |||
| 280 | + | c.push("main", map[string]string{ | |||
| 281 | + | ".github/workflows/broken.yml": "on: push\njobs: [this is not a workflow\n", | |||
| 282 | + | ".github/workflows/bad2.yml": "on: push\njobs: [this is not a workflow\n", | |||
| 283 | + | }, "") | |||
| 284 | + | c.waitFor("the invalid enabled file fails", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/bad2.yml")) == 1 }) | |||
| 285 | + | assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/broken.yml"), "a disabled invalid file reports nothing") | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // GO-002: a schedule load that is overtaken by an enable (or a disable) neither | |||
| 289 | + | // loses the workflow nor fires a disabled one: the cache holds every workflow | |||
| 290 | + | // and the state is read when the schedule fires. | |||
| 291 | + | func TestScheduleLoadRacingWithEnableAndDisable(t *testing.T) { | |||
| 292 | + | clk := &testClock{} | |||
| 293 | + | clk.set("2026-03-04 10:00:30") | |||
| 294 | + | var c *ciHarness | |||
| 295 | + | var inLoad func() | |||
| 296 | + | c = newCIHarness(t, func(cfg *usecase.CIConfig) { | |||
| 297 | + | cfg.Clock = clk | |||
| 298 | + | cfg.ScheduleTick = time.Hour | |||
| 299 | + | cfg.ScheduleLoaded = func(domain.RepoName) { | |||
| 300 | + | if f := inLoad; f != nil { | |||
| 301 | + | inLoad = nil | |||
| 302 | + | f() | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | }) | |||
| 306 | + | c.registerRunner("alpha", "ubuntu-latest") | |||
| 307 | + | ci := c.forge.CI | |||
| 308 | + | tick := func(at string) { | |||
| 309 | + | clk.set(at) | |||
| 310 | + | ci.ScheduleNow(c.ctx) | |||
| 311 | + | } | |||
| 312 | + | // Disabled before the first push; the load of the schedules sees the file, | |||
| 313 | + | // and an enable lands while it is being cached. | |||
| 314 | + | _, err := c.setWorkflow(c.root, true, "nightly.yml") | |||
| 315 | + | require.NoError(t, err) | |||
| 316 | + | c.push("main", scheduleWorkflow("*/5 * * * *"), "") | |||
| 317 | + | inLoad = func() { | |||
| 318 | + | _, err := c.setWorkflow(c.root, false, "nightly.yml") | |||
| 319 | + | require.NoError(t, err) | |||
| 320 | + | } | |||
| 321 | + | tick("2026-03-04 10:05:00") | |||
| 322 | + | require.Len(t, c.scheduled(), 1, "enabled while the load was running: the workflow is there") | |||
| 323 | + | assert.Equal(t, 1, ci.CachedSchedules(c.name("proj"))) | |||
| 324 | + | ||||
| 325 | + | // And the other way: disabled while a load is running. | |||
| 326 | + | require.NoError(t, func() error { _, err := c.setWorkflow(c.root, false, "nightly.yml"); return err }()) | |||
| 327 | + | c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "second") // the tip moves: a new load | |||
| 328 | + | inLoad = func() { | |||
| 329 | + | _, err := c.setWorkflow(c.root, true, "nightly.yml") | |||
| 330 | + | require.NoError(t, err) | |||
| 331 | + | } | |||
| 332 | + | tick("2026-03-04 10:10:00") | |||
| 333 | + | assert.Len(t, c.scheduled(), 1, "disabled while the load was running: it does not fire") | |||
| 334 | + | _, err = c.setWorkflow(c.root, false, "nightly.yml") | |||
| 335 | + | require.NoError(t, err) | |||
| 336 | + | tick("2026-03-04 10:15:00") | |||
| 337 | + | assert.Len(t, c.scheduled(), 2, "enabled again: fires with the cached entry, nothing missing") | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // GO-003: one file has one key. Non-canonical spellings are refused by the API, | |||
| 341 | + | // for disable, enable and dispatch alike. | |||
| 342 | + | func TestWorkflowNonCanonicalPathsAreRefused(t *testing.T) { | |||
| 343 | + | c := newCIHarness(t, nil) | |||
| 344 | + | c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "") | |||
| 345 | + | for _, bad := range []string{ | |||
| 346 | + | ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml", | |||
| 347 | + | ".github/workflows/./release.yml", | |||
| 348 | + | } { | |||
| 349 | + | _, err := c.setWorkflow(c.root, true, bad) | |||
| 350 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "disable %q", bad) | |||
| 351 | + | _, err = c.setWorkflow(c.root, false, bad) | |||
| 352 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "enable %q", bad) | |||
| 353 | + | _, err = c.dispatch(c.root, bad, "", nil) | |||
| 354 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "dispatch %q", bad) | |||
| 355 | + | } | |||
| 356 | + | assert.Empty(t, c.listWorkflows(c.root)[".github/workflows/release.yml"], "nothing was stored") | |||
| 357 | + | _, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "", map[string]string{"version": "1"}) | |||
| 358 | + | require.NoError(t, err, "the canonical path still dispatches") | |||
| 359 | + | } | |||
M internal/app/cli_e2e_test.go +1 -1
| @@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 338 | 338 | ver := decode[versionOut](t, res.stdout) | |
| 339 | 339 | assert.Equal(t, c.url, ver.Server.URL) | |
| 340 | 340 | assert.NotEmpty(t, ver.Server.Version) | |
| 341 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) | |
| 341 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) | |
| 342 | 342 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 343 | 343 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 344 | 344 | require.Equal(t, 0, code, stderr) | |
| @@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 338 | ver := decode[versionOut](t, res.stdout) | 338 | ver := decode[versionOut](t, res.stdout) | ||
| 339 | assert.Equal(t, c.url, ver.Server.URL) | 339 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 340 | assert.NotEmpty(t, ver.Server.Version) | 340 | assert.NotEmpty(t, ver.Server.Version) | ||
| 341 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) | 341 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features) |
| 342 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | 342 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | ||
| 343 | stdout, stderr, code = c.bin(t, secret, "version") | 343 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 344 | require.Equal(t, 0, code, stderr) | 344 | require.Equal(t, 0, code, stderr) | ||
M internal/app/cli_run_test.go +51 -0
| @@ -136,3 +136,54 @@ func TestRefTextShowsMergeRequests(t *testing.T) { | |||
| 136 | 136 | assert.Equal(t, "main", refText("refs/heads/main")) | |
| 137 | 137 | assert.Equal(t, "v1", refText("refs/tags/v1")) | |
| 138 | 138 | } | |
| 139 | + | ||
| 140 | + | // `ermite workflow disable|enable|list`, over a real server: right after | |
| 141 | + | // `repo create`, before any push, then once the file exists. | |
| 142 | + | func TestCLIWorkflowDisableEnableList(t *testing.T) { | |
| 143 | + | ci := newCIHarness(t, nil) | |
| 144 | + | h := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |
| 145 | + | ||
| 146 | + | dis := h.cli(t, adminToken, "workflow", "disable", "proj", "release.yml") | |
| 147 | + | require.NoError(t, dis.err, dis.stderr) | |
| 148 | + | assert.Contains(t, dis.stderr, "disabled .github/workflows/release.yml in proj") | |
| 149 | + | ||
| 150 | + | empty := h.cli(t, adminToken, "workflow", "list", "proj") | |
| 151 | + | require.NoError(t, empty.err, empty.stderr) | |
| 152 | + | assert.Contains(t, empty.stdout, "FILE") | |
| 153 | + | assert.Contains(t, empty.stdout, ".github/workflows/release.yml") | |
| 154 | + | assert.Contains(t, empty.stdout, "disabled") | |
| 155 | + | ||
| 156 | + | ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow, releaseWF: wfOn("release", " push:\n")}, "") | |
| 157 | + | js := h.cli(t, adminToken, "workflow", "list", "proj", "--json") | |
| 158 | + | require.NoError(t, js.err, js.stderr) | |
| 159 | + | list := decode[[]jsonWorkflow](t, js.stdout) | |
| 160 | + | assert.Equal(t, []jsonWorkflow{ | |
| 161 | + | {Repo: "proj", File: ".github/workflows/ci.yml", Name: "ci", State: "enabled"}, | |
| 162 | + | {Repo: "proj", File: releaseWF, Name: "release", State: "disabled"}, | |
| 163 | + | }, list) | |
| 164 | + | raw := decode[[]map[string]any](t, js.stdout) | |
| 165 | + | assert.Len(t, raw[0], 4, "repo, file, name, state") | |
| 166 | + | for _, k := range []string{"repo", "file", "name", "state"} { | |
| 167 | + | assert.Contains(t, raw[0], k) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | en := h.cli(t, adminToken, "workflow", "enable", "proj", "release.yml", "--json") | |
| 171 | + | require.NoError(t, en.err, en.stderr) | |
| 172 | + | assert.Equal(t, jsonWorkflow{Repo: "proj", File: releaseWF, State: "enabled"}, decode[jsonWorkflow](t, en.stdout)) | |
| 173 | + | after := decode[[]jsonWorkflow](t, h.cli(t, adminToken, "workflow", "list", "proj", "--json").stdout) | |
| 174 | + | assert.Equal(t, "enabled", after[1].State) | |
| 175 | + | ||
| 176 | + | bad := h.cli(t, adminToken, "workflow", "disable", "proj", "sub/x.yml") | |
| 177 | + | require.Error(t, bad.err) | |
| 178 | + | missing := h.cli(t, adminToken, "workflow", "disable", "nope", "x.yml") | |
| 179 | + | require.Error(t, missing.err) | |
| 180 | + | ||
| 181 | + | reader := h.cli(t, ci.token(scopes(domain.ScopeRepoRead), "proj"), "workflow", "disable", "proj", "release.yml") | |
| 182 | + | require.Error(t, reader.err) | |
| 183 | + | assert.Contains(t, reader.err.Error(), "permission denied") | |
| 184 | + | ||
| 185 | + | help := h.cli(t, adminToken, "workflow", "disable", "--help") | |
| 186 | + | require.NoError(t, help.err) | |
| 187 | + | assert.Contains(t, help.stdout, "JSON fields of a workflow (--json): repo, file") | |
| 188 | + | assert.Contains(t, help.stdout, "repo:admin") | |
| 189 | + | } | |
| @@ -136,3 +136,54 @@ func TestRefTextShowsMergeRequests(t *testing.T) { | |||||
| 136 | assert.Equal(t, "main", refText("refs/heads/main")) | 136 | assert.Equal(t, "main", refText("refs/heads/main")) | ||
| 137 | assert.Equal(t, "v1", refText("refs/tags/v1")) | 137 | assert.Equal(t, "v1", refText("refs/tags/v1")) | ||
| 138 | } | 138 | } | ||
| 139 | + | ||||
| 140 | + | // `ermite workflow disable|enable|list`, over a real server: right after | |||
| 141 | + | // `repo create`, before any push, then once the file exists. | |||
| 142 | + | func TestCLIWorkflowDisableEnableList(t *testing.T) { | |||
| 143 | + | ci := newCIHarness(t, nil) | |||
| 144 | + | h := &cliHarness{harness: ci.harness, url: ci.srv.URL} | |||
| 145 | + | ||||
| 146 | + | dis := h.cli(t, adminToken, "workflow", "disable", "proj", "release.yml") | |||
| 147 | + | require.NoError(t, dis.err, dis.stderr) | |||
| 148 | + | assert.Contains(t, dis.stderr, "disabled .github/workflows/release.yml in proj") | |||
| 149 | + | ||||
| 150 | + | empty := h.cli(t, adminToken, "workflow", "list", "proj") | |||
| 151 | + | require.NoError(t, empty.err, empty.stderr) | |||
| 152 | + | assert.Contains(t, empty.stdout, "FILE") | |||
| 153 | + | assert.Contains(t, empty.stdout, ".github/workflows/release.yml") | |||
| 154 | + | assert.Contains(t, empty.stdout, "disabled") | |||
| 155 | + | ||||
| 156 | + | ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow, releaseWF: wfOn("release", " push:\n")}, "") | |||
| 157 | + | js := h.cli(t, adminToken, "workflow", "list", "proj", "--json") | |||
| 158 | + | require.NoError(t, js.err, js.stderr) | |||
| 159 | + | list := decode[[]jsonWorkflow](t, js.stdout) | |||
| 160 | + | assert.Equal(t, []jsonWorkflow{ | |||
| 161 | + | {Repo: "proj", File: ".github/workflows/ci.yml", Name: "ci", State: "enabled"}, | |||
| 162 | + | {Repo: "proj", File: releaseWF, Name: "release", State: "disabled"}, | |||
| 163 | + | }, list) | |||
| 164 | + | raw := decode[[]map[string]any](t, js.stdout) | |||
| 165 | + | assert.Len(t, raw[0], 4, "repo, file, name, state") | |||
| 166 | + | for _, k := range []string{"repo", "file", "name", "state"} { | |||
| 167 | + | assert.Contains(t, raw[0], k) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | en := h.cli(t, adminToken, "workflow", "enable", "proj", "release.yml", "--json") | |||
| 171 | + | require.NoError(t, en.err, en.stderr) | |||
| 172 | + | assert.Equal(t, jsonWorkflow{Repo: "proj", File: releaseWF, State: "enabled"}, decode[jsonWorkflow](t, en.stdout)) | |||
| 173 | + | after := decode[[]jsonWorkflow](t, h.cli(t, adminToken, "workflow", "list", "proj", "--json").stdout) | |||
| 174 | + | assert.Equal(t, "enabled", after[1].State) | |||
| 175 | + | ||||
| 176 | + | bad := h.cli(t, adminToken, "workflow", "disable", "proj", "sub/x.yml") | |||
| 177 | + | require.Error(t, bad.err) | |||
| 178 | + | missing := h.cli(t, adminToken, "workflow", "disable", "nope", "x.yml") | |||
| 179 | + | require.Error(t, missing.err) | |||
| 180 | + | ||||
| 181 | + | reader := h.cli(t, ci.token(scopes(domain.ScopeRepoRead), "proj"), "workflow", "disable", "proj", "release.yml") | |||
| 182 | + | require.Error(t, reader.err) | |||
| 183 | + | assert.Contains(t, reader.err.Error(), "permission denied") | |||
| 184 | + | ||||
| 185 | + | help := h.cli(t, adminToken, "workflow", "disable", "--help") | |||
| 186 | + | require.NoError(t, help.err) | |||
| 187 | + | assert.Contains(t, help.stdout, "JSON fields of a workflow (--json): repo, file") | |||
| 188 | + | assert.Contains(t, help.stdout, "repo:admin") | |||
| 189 | + | } | |||
M internal/app/cli_version.go +1 -0
| @@ -120,6 +120,7 @@ var featureUse = map[string]string{ | |||
| 120 | 120 | client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server", | |
| 121 | 121 | client.FeatureCISecrets: "`ermite secret`, `variable` and `environment` need: the commands will fail on this server", | |
| 122 | 122 | client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server", | |
| 123 | + | client.FeatureCIWorkflows: "`ermite workflow list`, `disable` and `enable` need: the commands will fail on this server", | |
| 123 | 124 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | |
| 124 | 125 | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |
| 125 | 126 | client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server", | |
| @@ -120,6 +120,7 @@ var featureUse = map[string]string{ | |||||
| 120 | client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server", | 120 | client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server", | ||
| 121 | client.FeatureCISecrets: "`ermite secret`, `variable` and `environment` need: the commands will fail on this server", | 121 | client.FeatureCISecrets: "`ermite secret`, `variable` and `environment` need: the commands will fail on this server", | ||
| 122 | client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server", | 122 | client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server", | ||
| 123 | + | client.FeatureCIWorkflows: "`ermite workflow list`, `disable` and `enable` need: the commands will fail on this server", | |||
| 123 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | 124 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | ||
| 124 | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | 125 | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | ||
| 125 | client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server", | 126 | client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server", | ||
M internal/app/cli_version_test.go +5 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // allFeatures are the features this client uses. | |
| 20 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys"} | |
| 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} | |
| 21 | 21 | ||
| 22 | 22 | // Every feature the client uses needs a sentence for the version warning. | |
| 23 | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | |
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 45 | 45 | f := versionFake(allFeatures...) | |
| 46 | 46 | url := f.serve(t) | |
| 47 | 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 48 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | |
| 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | |
| 49 | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 50 | 50 | ||
| 51 | 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 52 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys") | |
| 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") | |
| 57 | 57 | url := f.serve(t) | |
| 58 | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 59 | 59 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 61 | 61 | assert.NotContains(t, res.stderr, "repo.update") | |
| 62 | 62 | ||
| 63 | 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 64 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 65 | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 66 | 66 | ||
| 67 | 67 | f = versionFake() | |
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // allFeatures are the features this client uses. | 19 | // allFeatures are the features this client uses. | ||
| 20 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys"} | 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} |
| 21 | 21 | ||||
| 22 | // Every feature the client uses needs a sentence for the version warning. | 22 | // Every feature the client uses needs a sentence for the version warning. | ||
| 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | ||
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||||
| 45 | f := versionFake(allFeatures...) | 45 | f := versionFake(allFeatures...) | ||
| 46 | url := f.serve(t) | 46 | url := f.serve(t) | ||
| 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 48 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) |
| 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 50 | 50 | ||||
| 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 52 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) |
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | ||
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys") | 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") |
| 57 | url := f.serve(t) | 57 | url := f.serve(t) | ||
| 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 59 | assert.Contains(t, res.stdout, "v0.4.0") | 59 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 61 | assert.NotContains(t, res.stderr, "repo.update") | 61 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 62 | 62 | ||||
| 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 64 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) |
| 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 66 | 66 | ||||
| 67 | f = versionFake() | 67 | f = versionFake() | ||
M internal/app/cli_workflow.go +132 -3
| @@ -13,14 +13,15 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // `ermite workflow run`: start a workflow that has a workflow_dispatch trigger, | |
| 16 | - | // like `gh workflow run`. | |
| 16 | + | // like `gh workflow run`. `workflow list|disable|enable` show and set the state | |
| 17 | + | // of the workflow files of a repository, like `gh workflow list|disable|enable`. | |
| 17 | 18 | ||
| 18 | 19 | func newWorkflowCmd(env *cliEnv) *cobra.Command { | |
| 19 | 20 | wf := &cobra.Command{ | |
| 20 | 21 | Use: "workflow", | |
| 21 | - | Short: "Start workflows by hand", | |
| 22 | + | Short: "Start, list, disable and enable workflows", | |
| 22 | 23 | } | |
| 23 | - | wf.AddCommand(newWorkflowRunCmd(env)) | |
| 24 | + | wf.AddCommand(newWorkflowRunCmd(env), newWorkflowListCmd(env), newWorkflowDisableCmd(env), newWorkflowEnableCmd(env)) | |
| 24 | 25 | return wf | |
| 25 | 26 | } | |
| 26 | 27 | ||
| @@ -83,3 +84,131 @@ func newWorkflowRunCmd(env *cliEnv) *cobra.Command { | |||
| 83 | 84 | } | |
| 84 | 85 | return cmd | |
| 85 | 86 | } | |
| 87 | + | ||
| 88 | + | type jsonWorkflow struct { | |
| 89 | + | Repo string `json:"repo"` | |
| 90 | + | File string `json:"file"` | |
| 91 | + | Name string `json:"name"` | |
| 92 | + | State string `json:"state"` | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | const jsonWorkflowHelp = ` | |
| 96 | + | JSON fields of a workflow (--json): repo, file (the path, always .github/workflows/<file>), name (the | |
| 97 | + | "name:" of the workflow on the default branch, the path when it has none, empty when the default branch | |
| 98 | + | has no such file) and state (enabled or disabled).` | |
| 99 | + | ||
| 100 | + | func workflowStateText(s ermite.WorkflowState) string { | |
| 101 | + | if s == ermite.WorkflowState_WORKFLOW_STATE_DISABLED { | |
| 102 | + | return "disabled" | |
| 103 | + | } | |
| 104 | + | return "enabled" | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func toJSONWorkflow(repo string, w *ermite.Workflow) jsonWorkflow { | |
| 108 | + | return jsonWorkflow{Repo: repo, File: w.GetPath(), Name: w.GetName(), State: workflowStateText(w.GetState())} | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func newWorkflowListCmd(env *cliEnv) *cobra.Command { | |
| 112 | + | cmd := &cobra.Command{ | |
| 113 | + | Use: "list <repo>", | |
| 114 | + | Short: "List the workflows of a repository and whether they are enabled", | |
| 115 | + | Long: "List the workflow files of .github/workflows on the default branch with their state, and the disabled\n" + | |
| 116 | + | "workflows whose file is absent there (a disabled workflow is listed even in an empty repository), ordered\n" + | |
| 117 | + | "by file. Needs the repo:read scope for a private repository." + jsonWorkflowHelp + "\n--json prints an array of workflows.", | |
| 118 | + | Example: " ermite workflow list app\n ermite workflow list app --json", | |
| 119 | + | Args: cobra.ExactArgs(1), | |
| 120 | + | ValidArgsFunction: completeRepoNames(env), | |
| 121 | + | } | |
| 122 | + | asJSON := addJSONFlag(cmd) | |
| 123 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 124 | + | c, err := env.client(cmd) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | res, err := c.Runs().ListWorkflows(env.ctx(cmd), connect.NewRequest(&ermite.ListWorkflowsRequest{Repo: args[0]})) | |
| 129 | + | if err != nil { | |
| 130 | + | return c.Describe(err) | |
| 131 | + | } | |
| 132 | + | list := res.Msg.GetWorkflows() | |
| 133 | + | if *asJSON { | |
| 134 | + | out := make([]jsonWorkflow, 0, len(list)) | |
| 135 | + | for _, w := range list { | |
| 136 | + | out = append(out, toJSONWorkflow(args[0], w)) | |
| 137 | + | } | |
| 138 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 139 | + | } | |
| 140 | + | if len(list) == 0 { | |
| 141 | + | noticef(cmd, "no workflows") | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | rows := make([][]string, 0, len(list)) | |
| 145 | + | for _, w := range list { | |
| 146 | + | rows = append(rows, []string{w.GetPath(), w.GetName(), workflowStateText(w.GetState())}) | |
| 147 | + | } | |
| 148 | + | return table(cmd.OutOrStdout(), []string{"FILE", "NAME", "STATE"}, rows) | |
| 149 | + | } | |
| 150 | + | return cmd | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func newWorkflowDisableCmd(env *cliEnv) *cobra.Command { | |
| 154 | + | cmd := &cobra.Command{ | |
| 155 | + | Use: "disable <repo> <file>", | |
| 156 | + | Short: "Stop a workflow from starting runs", | |
| 157 | + | Long: "Disable a workflow file of a repository, like `gh workflow disable`: no event (push, pull_request,\n" + | |
| 158 | + | "pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it any more.\n" + | |
| 159 | + | "Runs already started go on. The state belongs to the repository and to the file path, not to a branch. The\n" + | |
| 160 | + | "file is its path or its file name (release.yml) and does not have to exist: disable it right after\n" + | |
| 161 | + | "`ermite repo create`, before the first push. Disabling a disabled workflow changes nothing. Undo with\n" + | |
| 162 | + | "`ermite workflow enable`. Needs the repo:admin scope." + jsonWorkflowHelp, | |
| 163 | + | Example: " ermite workflow disable app release.yml\n ermite workflow disable app .github/workflows/codeql.yml", | |
| 164 | + | Args: cobra.ExactArgs(2), | |
| 165 | + | ValidArgsFunction: completeRepoNames(env), | |
| 166 | + | } | |
| 167 | + | asJSON := addJSONFlag(cmd) | |
| 168 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 169 | + | c, err := env.client(cmd) | |
| 170 | + | if err != nil { | |
| 171 | + | return err | |
| 172 | + | } | |
| 173 | + | res, err := c.Runs().DisableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.DisableWorkflowRequest{Repo: args[0], Workflow: args[1]})) | |
| 174 | + | if err != nil { | |
| 175 | + | return c.Describe(err) | |
| 176 | + | } | |
| 177 | + | return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "disabled") | |
| 178 | + | } | |
| 179 | + | return cmd | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func newWorkflowEnableCmd(env *cliEnv) *cobra.Command { | |
| 183 | + | cmd := &cobra.Command{ | |
| 184 | + | Use: "enable <repo> <file>", | |
| 185 | + | Short: "Let a disabled workflow start runs again", | |
| 186 | + | Long: "Enable a workflow file of a repository, like `gh workflow enable`. The events that happened while it was\n" + | |
| 187 | + | "disabled are not replayed. Enabling a workflow that is not disabled changes nothing. The file is its path\n" + | |
| 188 | + | "or its file name. Needs the repo:admin scope." + jsonWorkflowHelp, | |
| 189 | + | Example: " ermite workflow enable app release.yml", | |
| 190 | + | Args: cobra.ExactArgs(2), | |
| 191 | + | ValidArgsFunction: completeRepoNames(env), | |
| 192 | + | } | |
| 193 | + | asJSON := addJSONFlag(cmd) | |
| 194 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 195 | + | c, err := env.client(cmd) | |
| 196 | + | if err != nil { | |
| 197 | + | return err | |
| 198 | + | } | |
| 199 | + | res, err := c.Runs().EnableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.EnableWorkflowRequest{Repo: args[0], Workflow: args[1]})) | |
| 200 | + | if err != nil { | |
| 201 | + | return c.Describe(err) | |
| 202 | + | } | |
| 203 | + | return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "enabled") | |
| 204 | + | } | |
| 205 | + | return cmd | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func printWorkflowState(cmd *cobra.Command, asJSON bool, repo string, w *ermite.Workflow, state string) error { | |
| 209 | + | if asJSON { | |
| 210 | + | return writeJSON(cmd.OutOrStdout(), toJSONWorkflow(repo, w)) | |
| 211 | + | } | |
| 212 | + | noticef(cmd, "%s %s in %s", state, clean(w.GetPath()), clean(repo)) | |
| 213 | + | return nil | |
| 214 | + | } | |
| @@ -13,14 +13,15 @@ import ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // `ermite workflow run`: start a workflow that has a workflow_dispatch trigger, | 15 | // `ermite workflow run`: start a workflow that has a workflow_dispatch trigger, | ||
| 16 | - | // like `gh workflow run`. | 16 | + | // like `gh workflow run`. `workflow list|disable|enable` show and set the state |
| 17 | + | // of the workflow files of a repository, like `gh workflow list|disable|enable`. | |||
| 17 | 18 | ||||
| 18 | func newWorkflowCmd(env *cliEnv) *cobra.Command { | 19 | func newWorkflowCmd(env *cliEnv) *cobra.Command { | ||
| 19 | wf := &cobra.Command{ | 20 | wf := &cobra.Command{ | ||
| 20 | Use: "workflow", | 21 | Use: "workflow", | ||
| 21 | - | Short: "Start workflows by hand", | 22 | + | Short: "Start, list, disable and enable workflows", |
| 22 | } | 23 | } | ||
| 23 | - | wf.AddCommand(newWorkflowRunCmd(env)) | 24 | + | wf.AddCommand(newWorkflowRunCmd(env), newWorkflowListCmd(env), newWorkflowDisableCmd(env), newWorkflowEnableCmd(env)) |
| 24 | return wf | 25 | return wf | ||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| @@ -83,3 +84,131 @@ func newWorkflowRunCmd(env *cliEnv) *cobra.Command { | |||||
| 83 | } | 84 | } | ||
| 84 | return cmd | 85 | return cmd | ||
| 85 | } | 86 | } | ||
| 87 | + | ||||
| 88 | + | type jsonWorkflow struct { | |||
| 89 | + | Repo string `json:"repo"` | |||
| 90 | + | File string `json:"file"` | |||
| 91 | + | Name string `json:"name"` | |||
| 92 | + | State string `json:"state"` | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | const jsonWorkflowHelp = ` | |||
| 96 | + | JSON fields of a workflow (--json): repo, file (the path, always .github/workflows/<file>), name (the | |||
| 97 | + | "name:" of the workflow on the default branch, the path when it has none, empty when the default branch | |||
| 98 | + | has no such file) and state (enabled or disabled).` | |||
| 99 | + | ||||
| 100 | + | func workflowStateText(s ermite.WorkflowState) string { | |||
| 101 | + | if s == ermite.WorkflowState_WORKFLOW_STATE_DISABLED { | |||
| 102 | + | return "disabled" | |||
| 103 | + | } | |||
| 104 | + | return "enabled" | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func toJSONWorkflow(repo string, w *ermite.Workflow) jsonWorkflow { | |||
| 108 | + | return jsonWorkflow{Repo: repo, File: w.GetPath(), Name: w.GetName(), State: workflowStateText(w.GetState())} | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func newWorkflowListCmd(env *cliEnv) *cobra.Command { | |||
| 112 | + | cmd := &cobra.Command{ | |||
| 113 | + | Use: "list <repo>", | |||
| 114 | + | Short: "List the workflows of a repository and whether they are enabled", | |||
| 115 | + | Long: "List the workflow files of .github/workflows on the default branch with their state, and the disabled\n" + | |||
| 116 | + | "workflows whose file is absent there (a disabled workflow is listed even in an empty repository), ordered\n" + | |||
| 117 | + | "by file. Needs the repo:read scope for a private repository." + jsonWorkflowHelp + "\n--json prints an array of workflows.", | |||
| 118 | + | Example: " ermite workflow list app\n ermite workflow list app --json", | |||
| 119 | + | Args: cobra.ExactArgs(1), | |||
| 120 | + | ValidArgsFunction: completeRepoNames(env), | |||
| 121 | + | } | |||
| 122 | + | asJSON := addJSONFlag(cmd) | |||
| 123 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 124 | + | c, err := env.client(cmd) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | res, err := c.Runs().ListWorkflows(env.ctx(cmd), connect.NewRequest(&ermite.ListWorkflowsRequest{Repo: args[0]})) | |||
| 129 | + | if err != nil { | |||
| 130 | + | return c.Describe(err) | |||
| 131 | + | } | |||
| 132 | + | list := res.Msg.GetWorkflows() | |||
| 133 | + | if *asJSON { | |||
| 134 | + | out := make([]jsonWorkflow, 0, len(list)) | |||
| 135 | + | for _, w := range list { | |||
| 136 | + | out = append(out, toJSONWorkflow(args[0], w)) | |||
| 137 | + | } | |||
| 138 | + | return writeJSON(cmd.OutOrStdout(), out) | |||
| 139 | + | } | |||
| 140 | + | if len(list) == 0 { | |||
| 141 | + | noticef(cmd, "no workflows") | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | rows := make([][]string, 0, len(list)) | |||
| 145 | + | for _, w := range list { | |||
| 146 | + | rows = append(rows, []string{w.GetPath(), w.GetName(), workflowStateText(w.GetState())}) | |||
| 147 | + | } | |||
| 148 | + | return table(cmd.OutOrStdout(), []string{"FILE", "NAME", "STATE"}, rows) | |||
| 149 | + | } | |||
| 150 | + | return cmd | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func newWorkflowDisableCmd(env *cliEnv) *cobra.Command { | |||
| 154 | + | cmd := &cobra.Command{ | |||
| 155 | + | Use: "disable <repo> <file>", | |||
| 156 | + | Short: "Stop a workflow from starting runs", | |||
| 157 | + | Long: "Disable a workflow file of a repository, like `gh workflow disable`: no event (push, pull_request,\n" + | |||
| 158 | + | "pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it any more.\n" + | |||
| 159 | + | "Runs already started go on. The state belongs to the repository and to the file path, not to a branch. The\n" + | |||
| 160 | + | "file is its path or its file name (release.yml) and does not have to exist: disable it right after\n" + | |||
| 161 | + | "`ermite repo create`, before the first push. Disabling a disabled workflow changes nothing. Undo with\n" + | |||
| 162 | + | "`ermite workflow enable`. Needs the repo:admin scope." + jsonWorkflowHelp, | |||
| 163 | + | Example: " ermite workflow disable app release.yml\n ermite workflow disable app .github/workflows/codeql.yml", | |||
| 164 | + | Args: cobra.ExactArgs(2), | |||
| 165 | + | ValidArgsFunction: completeRepoNames(env), | |||
| 166 | + | } | |||
| 167 | + | asJSON := addJSONFlag(cmd) | |||
| 168 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 169 | + | c, err := env.client(cmd) | |||
| 170 | + | if err != nil { | |||
| 171 | + | return err | |||
| 172 | + | } | |||
| 173 | + | res, err := c.Runs().DisableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.DisableWorkflowRequest{Repo: args[0], Workflow: args[1]})) | |||
| 174 | + | if err != nil { | |||
| 175 | + | return c.Describe(err) | |||
| 176 | + | } | |||
| 177 | + | return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "disabled") | |||
| 178 | + | } | |||
| 179 | + | return cmd | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func newWorkflowEnableCmd(env *cliEnv) *cobra.Command { | |||
| 183 | + | cmd := &cobra.Command{ | |||
| 184 | + | Use: "enable <repo> <file>", | |||
| 185 | + | Short: "Let a disabled workflow start runs again", | |||
| 186 | + | Long: "Enable a workflow file of a repository, like `gh workflow enable`. The events that happened while it was\n" + | |||
| 187 | + | "disabled are not replayed. Enabling a workflow that is not disabled changes nothing. The file is its path\n" + | |||
| 188 | + | "or its file name. Needs the repo:admin scope." + jsonWorkflowHelp, | |||
| 189 | + | Example: " ermite workflow enable app release.yml", | |||
| 190 | + | Args: cobra.ExactArgs(2), | |||
| 191 | + | ValidArgsFunction: completeRepoNames(env), | |||
| 192 | + | } | |||
| 193 | + | asJSON := addJSONFlag(cmd) | |||
| 194 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 195 | + | c, err := env.client(cmd) | |||
| 196 | + | if err != nil { | |||
| 197 | + | return err | |||
| 198 | + | } | |||
| 199 | + | res, err := c.Runs().EnableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.EnableWorkflowRequest{Repo: args[0], Workflow: args[1]})) | |||
| 200 | + | if err != nil { | |||
| 201 | + | return c.Describe(err) | |||
| 202 | + | } | |||
| 203 | + | return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "enabled") | |||
| 204 | + | } | |||
| 205 | + | return cmd | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func printWorkflowState(cmd *cobra.Command, asJSON bool, repo string, w *ermite.Workflow, state string) error { | |||
| 209 | + | if asJSON { | |||
| 210 | + | return writeJSON(cmd.OutOrStdout(), toJSONWorkflow(repo, w)) | |||
| 211 | + | } | |||
| 212 | + | noticef(cmd, "%s %s in %s", state, clean(w.GetPath()), clean(repo)) | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
M internal/app/forge.go +6 -5
| @@ -277,11 +277,12 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 277 | 277 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | |
| 278 | 278 | Proxy: gitremote.ProxyEnv(os.Getenv), | |
| 279 | 279 | })), | |
| 280 | - | Tokens: store.Tokens(), | |
| 281 | - | RefTips: store.CI(), | |
| 282 | - | Settings: f.Settings, | |
| 283 | - | Workflows: opts.workflows(githubraw.New(githubraw.Config{})), | |
| 284 | - | OIDC: f.OIDC, | |
| 280 | + | Tokens: store.Tokens(), | |
| 281 | + | RefTips: store.CI(), | |
| 282 | + | WorkflowState: store.CI(), | |
| 283 | + | Settings: f.Settings, | |
| 284 | + | Workflows: opts.workflows(githubraw.New(githubraw.Config{})), | |
| 285 | + | OIDC: f.OIDC, | |
| 285 | 286 | } | |
| 286 | 287 | artifacts(&ciCfg, cfg, store) | |
| 287 | 288 | f.pullRequests(&ciCfg, store, git, committer) | |
| @@ -277,11 +277,12 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 277 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | 277 | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | ||
| 278 | Proxy: gitremote.ProxyEnv(os.Getenv), | 278 | Proxy: gitremote.ProxyEnv(os.Getenv), | ||
| 279 | })), | 279 | })), | ||
| 280 | - | Tokens: store.Tokens(), | 280 | + | Tokens: store.Tokens(), |
| 281 | - | RefTips: store.CI(), | 281 | + | RefTips: store.CI(), |
| 282 | - | Settings: f.Settings, | 282 | + | WorkflowState: store.CI(), |
| 283 | - | Workflows: opts.workflows(githubraw.New(githubraw.Config{})), | 283 | + | Settings: f.Settings, |
| 284 | - | OIDC: f.OIDC, | 284 | + | Workflows: opts.workflows(githubraw.New(githubraw.Config{})), |
| 285 | + | OIDC: f.OIDC, | |||
| 285 | } | 286 | } | ||
| 286 | artifacts(&ciCfg, cfg, store) | 287 | artifacts(&ciCfg, cfg, store) | ||
| 287 | f.pullRequests(&ciCfg, store, git, committer) | 288 | f.pullRequests(&ciCfg, store, git, committer) | ||
M internal/app/main_test.go +13 -1
| @@ -116,8 +116,20 @@ func baseEnv() []string { | |||
| 116 | 116 | return []string{"PATH=" + os.Getenv("PATH")} | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | + | // isolatedEnv is the environment of a CLI run that must not see the user's | |
| 120 | + | // ERMITE_URL, ERMITE_TOKEN or config file: a version command with a server URL | |
| 121 | + | // also asks the server and prints its version. | |
| 122 | + | func isolatedEnv(t *testing.T) []string { | |
| 123 | + | t.Helper() | |
| 124 | + | cfg := filepath.Join(t.TempDir(), "config") | |
| 125 | + | require.NoError(t, os.WriteFile(cfg, nil, 0o600)) | |
| 126 | + | return append(baseEnv(), "ERMITE_CONFIG="+cfg, "HOME="+t.TempDir(), "XDG_CONFIG_HOME="+t.TempDir()) | |
| 127 | + | } | |
| 128 | + | ||
| 119 | 129 | func TestVersion(t *testing.T) { | |
| 120 | - | out, err := exec.Command(binary(t), "version").Output() | |
| 130 | + | cmd := exec.Command(binary(t), "version") | |
| 131 | + | cmd.Env = isolatedEnv(t) | |
| 132 | + | out, err := cmd.Output() | |
| 121 | 133 | require.NoError(t, err) | |
| 122 | 134 | assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out)) | |
| 123 | 135 | assert.Equal(t, 1, strings.Count(string(out), "\n")) | |
| @@ -116,8 +116,20 @@ func baseEnv() []string { | |||||
| 116 | return []string{"PATH=" + os.Getenv("PATH")} | 116 | return []string{"PATH=" + os.Getenv("PATH")} | ||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | + | // isolatedEnv is the environment of a CLI run that must not see the user's | |||
| 120 | + | // ERMITE_URL, ERMITE_TOKEN or config file: a version command with a server URL | |||
| 121 | + | // also asks the server and prints its version. | |||
| 122 | + | func isolatedEnv(t *testing.T) []string { | |||
| 123 | + | t.Helper() | |||
| 124 | + | cfg := filepath.Join(t.TempDir(), "config") | |||
| 125 | + | require.NoError(t, os.WriteFile(cfg, nil, 0o600)) | |||
| 126 | + | return append(baseEnv(), "ERMITE_CONFIG="+cfg, "HOME="+t.TempDir(), "XDG_CONFIG_HOME="+t.TempDir()) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 119 | func TestVersion(t *testing.T) { | 129 | func TestVersion(t *testing.T) { | ||
| 120 | - | out, err := exec.Command(binary(t), "version").Output() | 130 | + | cmd := exec.Command(binary(t), "version") |
| 131 | + | cmd.Env = isolatedEnv(t) | |||
| 132 | + | out, err := cmd.Output() | |||
| 121 | require.NoError(t, err) | 133 | require.NoError(t, err) | ||
| 122 | assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out)) | 134 | assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out)) | ||
| 123 | assert.Equal(t, 1, strings.Count(string(out), "\n")) | 135 | assert.Equal(t, 1, strings.Count(string(out), "\n")) | ||
A internal/domain/ci_workflow.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "path" | |
| 9 | + | "strings" | |
| 10 | + | "unicode" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // WorkflowDir is the only directory the workflows of a repository are read | |
| 14 | + | // from (ADR 0019: no ermite dialect). | |
| 15 | + | const WorkflowDir = ".github/workflows" | |
| 16 | + | ||
| 17 | + | // maxWorkflowFileName bounds the file name of a workflow, as a file name of | |
| 18 | + | // Git trees is bounded on the usual file systems. | |
| 19 | + | const maxWorkflowFileName = 255 | |
| 20 | + | ||
| 21 | + | // ErrWorkflowDisabled is returned by the store when a run is created for a | |
| 22 | + | // workflow the repository disabled. It is checked in the very transaction that | |
| 23 | + | // creates the run, so no disable can slip in between a check and the creation. | |
| 24 | + | var ErrWorkflowDisabled = errors.New("workflow is disabled") | |
| 25 | + | ||
| 26 | + | // ParseWorkflowPath normalizes the name of a workflow file, as `gh workflow` | |
| 27 | + | // takes it: either its file name ("release.yml") or its path | |
| 28 | + | // (".github/workflows/release.yml"). It returns the canonical path, and | |
| 29 | + | // refuses every other spelling of it ("./.github/workflows/x.yml", | |
| 30 | + | // ".github/workflows//x.yml", ".github/workflows/sub/../x.yml"), so one file has | |
| 31 | + | // one key. Only the shape is checked, never whether the file exists: the state | |
| 32 | + | // of a workflow can be set on a repository that is empty or that does not hold | |
| 33 | + | // the file yet. A file in a subdirectory, a name that does not end in .yml or | |
| 34 | + | // .yaml, and a name with a control character or a backslash are ErrInvalid. | |
| 35 | + | func ParseWorkflowPath(s string) (string, error) { | |
| 36 | + | s = strings.TrimSpace(s) | |
| 37 | + | if s == "" { | |
| 38 | + | return "", fmt.Errorf("%w: the workflow file is required", ErrInvalid) | |
| 39 | + | } | |
| 40 | + | if !strings.Contains(s, "/") { | |
| 41 | + | s = WorkflowDir + "/" + s | |
| 42 | + | } | |
| 43 | + | name, ok := strings.CutPrefix(s, WorkflowDir+"/") | |
| 44 | + | ext := strings.ToLower(path.Ext(name)) | |
| 45 | + | switch { | |
| 46 | + | case !ok, | |
| 47 | + | strings.Contains(name, "/"), | |
| 48 | + | ext != ".yml" && ext != ".yaml", | |
| 49 | + | strings.TrimSuffix(name, path.Ext(name)) == "", | |
| 50 | + | len(name) > maxWorkflowFileName, | |
| 51 | + | strings.ContainsFunc(name, func(r rune) bool { return unicode.IsControl(r) || r == '\\' }): | |
| 52 | + | return "", fmt.Errorf("%w: %q is not a workflow file of %s (a file name or the path %s/<file>, ending in .yml or .yaml)", ErrInvalid, s, WorkflowDir, WorkflowDir) | |
| 53 | + | } | |
| 54 | + | return s, nil | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "path" | |||
| 9 | + | "strings" | |||
| 10 | + | "unicode" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // WorkflowDir is the only directory the workflows of a repository are read | |||
| 14 | + | // from (ADR 0019: no ermite dialect). | |||
| 15 | + | const WorkflowDir = ".github/workflows" | |||
| 16 | + | ||||
| 17 | + | // maxWorkflowFileName bounds the file name of a workflow, as a file name of | |||
| 18 | + | // Git trees is bounded on the usual file systems. | |||
| 19 | + | const maxWorkflowFileName = 255 | |||
| 20 | + | ||||
| 21 | + | // ErrWorkflowDisabled is returned by the store when a run is created for a | |||
| 22 | + | // workflow the repository disabled. It is checked in the very transaction that | |||
| 23 | + | // creates the run, so no disable can slip in between a check and the creation. | |||
| 24 | + | var ErrWorkflowDisabled = errors.New("workflow is disabled") | |||
| 25 | + | ||||
| 26 | + | // ParseWorkflowPath normalizes the name of a workflow file, as `gh workflow` | |||
| 27 | + | // takes it: either its file name ("release.yml") or its path | |||
| 28 | + | // (".github/workflows/release.yml"). It returns the canonical path, and | |||
| 29 | + | // refuses every other spelling of it ("./.github/workflows/x.yml", | |||
| 30 | + | // ".github/workflows//x.yml", ".github/workflows/sub/../x.yml"), so one file has | |||
| 31 | + | // one key. Only the shape is checked, never whether the file exists: the state | |||
| 32 | + | // of a workflow can be set on a repository that is empty or that does not hold | |||
| 33 | + | // the file yet. A file in a subdirectory, a name that does not end in .yml or | |||
| 34 | + | // .yaml, and a name with a control character or a backslash are ErrInvalid. | |||
| 35 | + | func ParseWorkflowPath(s string) (string, error) { | |||
| 36 | + | s = strings.TrimSpace(s) | |||
| 37 | + | if s == "" { | |||
| 38 | + | return "", fmt.Errorf("%w: the workflow file is required", ErrInvalid) | |||
| 39 | + | } | |||
| 40 | + | if !strings.Contains(s, "/") { | |||
| 41 | + | s = WorkflowDir + "/" + s | |||
| 42 | + | } | |||
| 43 | + | name, ok := strings.CutPrefix(s, WorkflowDir+"/") | |||
| 44 | + | ext := strings.ToLower(path.Ext(name)) | |||
| 45 | + | switch { | |||
| 46 | + | case !ok, | |||
| 47 | + | strings.Contains(name, "/"), | |||
| 48 | + | ext != ".yml" && ext != ".yaml", | |||
| 49 | + | strings.TrimSuffix(name, path.Ext(name)) == "", | |||
| 50 | + | len(name) > maxWorkflowFileName, | |||
| 51 | + | strings.ContainsFunc(name, func(r rune) bool { return unicode.IsControl(r) || r == '\\' }): | |||
| 52 | + | return "", fmt.Errorf("%w: %q is not a workflow file of %s (a file name or the path %s/<file>, ending in .yml or .yaml)", ErrInvalid, s, WorkflowDir, WorkflowDir) | |||
| 53 | + | } | |||
| 54 | + | return s, nil | |||
| 55 | + | } | |||
A internal/domain/ci_workflow_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestParseWorkflowPath(t *testing.T) { | |
| 13 | + | for in, want := range map[string]string{ | |
| 14 | + | "release.yml": ".github/workflows/release.yml", | |
| 15 | + | " release.yaml ": ".github/workflows/release.yaml", | |
| 16 | + | ".github/workflows/release.yml": ".github/workflows/release.yml", | |
| 17 | + | ".github/workflows/a.b-c_d.yaml": ".github/workflows/a.b-c_d.yaml", | |
| 18 | + | } { | |
| 19 | + | got, err := ParseWorkflowPath(in) | |
| 20 | + | require.NoError(t, err, in) | |
| 21 | + | assert.Equal(t, want, got, in) | |
| 22 | + | } | |
| 23 | + | for _, in := range []string{ | |
| 24 | + | "", " ", "release", "release.txt", ".yml", ".github/workflows/.yml", | |
| 25 | + | "sub/release.yml", ".github/workflows/sub/release.yml", ".github/workflows/../release.yml", | |
| 26 | + | "/.github/workflows/release.yml", ".github/other/release.yml", | |
| 27 | + | ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml", | |
| 28 | + | ".github/workflows/./release.yml", ".github//workflows/release.yml", "github/workflows/release.yml", | |
| 29 | + | ".github/workflows/release.yml/", "..", "../release.yml", ".github/workflows/..", ".github/workflows/.", "x\\y.yml", "a\x00.yml", ".github/workflows/", | |
| 30 | + | } { | |
| 31 | + | _, err := ParseWorkflowPath(in) | |
| 32 | + | assert.ErrorIs(t, err, ErrInvalid, "%q", in) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestParseWorkflowPath(t *testing.T) { | |||
| 13 | + | for in, want := range map[string]string{ | |||
| 14 | + | "release.yml": ".github/workflows/release.yml", | |||
| 15 | + | " release.yaml ": ".github/workflows/release.yaml", | |||
| 16 | + | ".github/workflows/release.yml": ".github/workflows/release.yml", | |||
| 17 | + | ".github/workflows/a.b-c_d.yaml": ".github/workflows/a.b-c_d.yaml", | |||
| 18 | + | } { | |||
| 19 | + | got, err := ParseWorkflowPath(in) | |||
| 20 | + | require.NoError(t, err, in) | |||
| 21 | + | assert.Equal(t, want, got, in) | |||
| 22 | + | } | |||
| 23 | + | for _, in := range []string{ | |||
| 24 | + | "", " ", "release", "release.txt", ".yml", ".github/workflows/.yml", | |||
| 25 | + | "sub/release.yml", ".github/workflows/sub/release.yml", ".github/workflows/../release.yml", | |||
| 26 | + | "/.github/workflows/release.yml", ".github/other/release.yml", | |||
| 27 | + | ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml", | |||
| 28 | + | ".github/workflows/./release.yml", ".github//workflows/release.yml", "github/workflows/release.yml", | |||
| 29 | + | ".github/workflows/release.yml/", "..", "../release.yml", ".github/workflows/..", ".github/workflows/.", "x\\y.yml", "a\x00.yml", ".github/workflows/", | |||
| 30 | + | } { | |||
| 31 | + | _, err := ParseWorkflowPath(in) | |||
| 32 | + | assert.ErrorIs(t, err, ErrInvalid, "%q", in) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/ports/ci.go +43 -2
| @@ -95,6 +95,21 @@ type CIRefTipStore interface { | |||
| 95 | 95 | DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | + | // CIWorkflowStore keeps, per repository, the workflow files that are disabled | |
| 99 | + | // (`ermite workflow disable`). Paths are normalized (domain.ParseWorkflowPath); | |
| 100 | + | // the file does not have to exist. The records go with the repository. | |
| 101 | + | // *kvstore.CI implements it. | |
| 102 | + | type CIWorkflowStore interface { | |
| 103 | + | // DisableWorkflow records the path as disabled; one already disabled is | |
| 104 | + | // left as it is. A repository that does not exist is domain.ErrNotFound. | |
| 105 | + | DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error | |
| 106 | + | // EnableWorkflow removes the record; a path that is not disabled is not an | |
| 107 | + | // error. | |
| 108 | + | EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error | |
| 109 | + | // DisabledWorkflows returns the disabled paths of the repository, sorted. | |
| 110 | + | DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) | |
| 111 | + | } | |
| 112 | + | ||
| 98 | 113 | // CIStore keeps CI runs, jobs and checks. The store derives its own indexes | |
| 99 | 114 | // (the queue of ready jobs, the active jobs, the open runs, the job tokens) | |
| 100 | 115 | // from the records it writes. | |
| @@ -102,8 +117,12 @@ type CIStore interface { | |||
| 102 | 117 | // CreateRun takes the next run id and the next number of the workflow in | |
| 103 | 118 | // the repository (both never reused), calls build with them and stores | |
| 104 | 119 | // the draft it returns, in one batch that fails with domain.ErrNotFound | |
| 105 | - | // once the repository is deleted. The records come back with their | |
| 106 | - | // versions. | |
| 120 | + | // once the repository is deleted, and with domain.ErrWorkflowDisabled when | |
| 121 | + | // the repository disabled workflowPath (CIWorkflowStore) when the batch | |
| 122 | + | // applies: the state is checked in that transaction, not before it, so a | |
| 123 | + | // disable can never be overtaken, whatever event started the run and | |
| 124 | + | // whether the run is a failed one for an invalid file or not. The records | |
| 125 | + | // come back with their versions. | |
| 107 | 126 | CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) CIRunDraft) (CIRunDraft, error) | |
| 108 | 127 | // Apply writes the records in one batch and returns the version they all | |
| 109 | 128 | // have afterwards. | |
| @@ -285,6 +304,16 @@ type DispatchParams struct { | |||
| 285 | 304 | Inputs map[string]string | |
| 286 | 305 | } | |
| 287 | 306 | ||
| 307 | + | // WorkflowInfo is a workflow file of a repository and whether it starts runs. | |
| 308 | + | type WorkflowInfo struct { | |
| 309 | + | // Path is ".github/workflows/<file>". | |
| 310 | + | Path string | |
| 311 | + | // Name is the "name:" of the workflow at the tip of the default branch (the | |
| 312 | + | // path when it has none); empty when that file is absent or not a workflow. | |
| 313 | + | Name string | |
| 314 | + | Disabled bool | |
| 315 | + | } | |
| 316 | + | ||
| 288 | 317 | // WorkflowTrigger starts the workflows that match pushed refs. OnPush only | |
| 289 | 318 | // queues the work and returns: planning happens in the background and its | |
| 290 | 319 | // errors never reach the push. | |
| @@ -721,6 +750,18 @@ type RunService interface { | |||
| 721 | 750 | // permission actions: write. The run is returned queued. | |
| 722 | 751 | DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params DispatchParams) (domain.CIRun, error) | |
| 723 | 752 | ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error) | |
| 753 | + | // ListWorkflows returns the workflow files at the tip of the default branch | |
| 754 | + | // and the disabled ones whatever the branch holds, ordered by path. Reading | |
| 755 | + | // the repository is enough. | |
| 756 | + | ListWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]WorkflowInfo, error) | |
| 757 | + | // DisabledWorkflows returns the paths of the disabled workflows, sorted, | |
| 758 | + | // without reading any file: the cheap form of ListWorkflows for the pages. | |
| 759 | + | DisabledWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error) | |
| 760 | + | // DisableWorkflow stops the workflow (its file name or path; it does not | |
| 761 | + | // have to exist) from starting runs for any event. EnableWorkflow undoes it. | |
| 762 | + | // Both need repo:admin on the repository and are idempotent. | |
| 763 | + | DisableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (WorkflowInfo, error) | |
| 764 | + | EnableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (WorkflowInfo, error) | |
| 724 | 765 | // ListAnnotations returns the annotations of a run attempt (0 for the | |
| 725 | 766 | // latest), of one job when jobID is not empty. Each job gives those of the | |
| 726 | 767 | // attempt in which it executed. | |
| @@ -95,6 +95,21 @@ type CIRefTipStore interface { | |||||
| 95 | DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error | 95 | DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | + | // CIWorkflowStore keeps, per repository, the workflow files that are disabled | |||
| 99 | + | // (`ermite workflow disable`). Paths are normalized (domain.ParseWorkflowPath); | |||
| 100 | + | // the file does not have to exist. The records go with the repository. | |||
| 101 | + | // *kvstore.CI implements it. | |||
| 102 | + | type CIWorkflowStore interface { | |||
| 103 | + | // DisableWorkflow records the path as disabled; one already disabled is | |||
| 104 | + | // left as it is. A repository that does not exist is domain.ErrNotFound. | |||
| 105 | + | DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error | |||
| 106 | + | // EnableWorkflow removes the record; a path that is not disabled is not an | |||
| 107 | + | // error. | |||
| 108 | + | EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error | |||
| 109 | + | // DisabledWorkflows returns the disabled paths of the repository, sorted. | |||
| 110 | + | DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 98 | // CIStore keeps CI runs, jobs and checks. The store derives its own indexes | 113 | // CIStore keeps CI runs, jobs and checks. The store derives its own indexes | ||
| 99 | // (the queue of ready jobs, the active jobs, the open runs, the job tokens) | 114 | // (the queue of ready jobs, the active jobs, the open runs, the job tokens) | ||
| 100 | // from the records it writes. | 115 | // from the records it writes. | ||
| @@ -102,8 +117,12 @@ type CIStore interface { | |||||
| 102 | // CreateRun takes the next run id and the next number of the workflow in | 117 | // CreateRun takes the next run id and the next number of the workflow in | ||
| 103 | // the repository (both never reused), calls build with them and stores | 118 | // the repository (both never reused), calls build with them and stores | ||
| 104 | // the draft it returns, in one batch that fails with domain.ErrNotFound | 119 | // the draft it returns, in one batch that fails with domain.ErrNotFound | ||
| 105 | - | // once the repository is deleted. The records come back with their | 120 | + | // once the repository is deleted, and with domain.ErrWorkflowDisabled when |
| 106 | - | // versions. | 121 | + | // the repository disabled workflowPath (CIWorkflowStore) when the batch |
| 122 | + | // applies: the state is checked in that transaction, not before it, so a | |||
| 123 | + | // disable can never be overtaken, whatever event started the run and | |||
| 124 | + | // whether the run is a failed one for an invalid file or not. The records | |||
| 125 | + | // come back with their versions. | |||
| 107 | CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) CIRunDraft) (CIRunDraft, error) | 126 | CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) CIRunDraft) (CIRunDraft, error) | ||
| 108 | // Apply writes the records in one batch and returns the version they all | 127 | // Apply writes the records in one batch and returns the version they all | ||
| 109 | // have afterwards. | 128 | // have afterwards. | ||
| @@ -285,6 +304,16 @@ type DispatchParams struct { | |||||
| 285 | Inputs map[string]string | 304 | Inputs map[string]string | ||
| 286 | } | 305 | } | ||
| 287 | 306 | ||||
| 307 | + | // WorkflowInfo is a workflow file of a repository and whether it starts runs. | |||
| 308 | + | type WorkflowInfo struct { | |||
| 309 | + | // Path is ".github/workflows/<file>". | |||
| 310 | + | Path string | |||
| 311 | + | // Name is the "name:" of the workflow at the tip of the default branch (the | |||
| 312 | + | // path when it has none); empty when that file is absent or not a workflow. | |||
| 313 | + | Name string | |||
| 314 | + | Disabled bool | |||
| 315 | + | } | |||
| 316 | + | ||||
| 288 | // WorkflowTrigger starts the workflows that match pushed refs. OnPush only | 317 | // WorkflowTrigger starts the workflows that match pushed refs. OnPush only | ||
| 289 | // queues the work and returns: planning happens in the background and its | 318 | // queues the work and returns: planning happens in the background and its | ||
| 290 | // errors never reach the push. | 319 | // errors never reach the push. | ||
| @@ -721,6 +750,18 @@ type RunService interface { | |||||
| 721 | // permission actions: write. The run is returned queued. | 750 | // permission actions: write. The run is returned queued. | ||
| 722 | DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params DispatchParams) (domain.CIRun, error) | 751 | DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params DispatchParams) (domain.CIRun, error) | ||
| 723 | ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error) | 752 | ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error) | ||
| 753 | + | // ListWorkflows returns the workflow files at the tip of the default branch | |||
| 754 | + | // and the disabled ones whatever the branch holds, ordered by path. Reading | |||
| 755 | + | // the repository is enough. | |||
| 756 | + | ListWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]WorkflowInfo, error) | |||
| 757 | + | // DisabledWorkflows returns the paths of the disabled workflows, sorted, | |||
| 758 | + | // without reading any file: the cheap form of ListWorkflows for the pages. | |||
| 759 | + | DisabledWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error) | |||
| 760 | + | // DisableWorkflow stops the workflow (its file name or path; it does not | |||
| 761 | + | // have to exist) from starting runs for any event. EnableWorkflow undoes it. | |||
| 762 | + | // Both need repo:admin on the repository and are idempotent. | |||
| 763 | + | DisableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (WorkflowInfo, error) | |||
| 764 | + | EnableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (WorkflowInfo, error) | |||
| 724 | // ListAnnotations returns the annotations of a run attempt (0 for the | 765 | // ListAnnotations returns the annotations of a run attempt (0 for the | ||
| 725 | // latest), of one job when jobID is not empty. Each job gives those of the | 766 | // latest), of one job when jobID is not empty. Each job gives those of the | ||
| 726 | // attempt in which it executed. | 767 | // attempt in which it executed. | ||
A internal/ports/mocks/CIWorkflowStore.go +249 -0
| @@ -0,0 +1,249 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewCIWorkflowStore creates a new instance of CIWorkflowStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewCIWorkflowStore(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *CIWorkflowStore { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &CIWorkflowStore{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // CIWorkflowStore is an autogenerated mock type for the CIWorkflowStore type | |
| 39 | + | type CIWorkflowStore struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type CIWorkflowStore_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *CIWorkflowStore) EXPECT() *CIWorkflowStore_Expecter { | |
| 48 | + | return &CIWorkflowStore_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // DisableWorkflow provides a mock function for the type CIWorkflowStore | |
| 52 | + | func (_mock *CIWorkflowStore) DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error { | |
| 53 | + | ret := _mock.Called(ctx, repo, path, at) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for DisableWorkflow") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, time.Time) error); ok { | |
| 61 | + | r0 = returnFunc(ctx, repo, path, at) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Error(0) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // CIWorkflowStore_DisableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisableWorkflow' | |
| 69 | + | type CIWorkflowStore_DisableWorkflow_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // DisableWorkflow is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | // - repo domain.RepoName | |
| 76 | + | // - path string | |
| 77 | + | // - at time.Time | |
| 78 | + | func (_e *CIWorkflowStore_Expecter) DisableWorkflow(ctx any, repo any, path any, at any) *CIWorkflowStore_DisableWorkflow_Call { | |
| 79 | + | return &CIWorkflowStore_DisableWorkflow_Call{Call: _e.mock.On("DisableWorkflow", ctx, repo, path, at)} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *CIWorkflowStore_DisableWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string, at time.Time)) *CIWorkflowStore_DisableWorkflow_Call { | |
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 84 | + | var arg0 context.Context | |
| 85 | + | if args[0] != nil { | |
| 86 | + | arg0 = args[0].(context.Context) | |
| 87 | + | } | |
| 88 | + | var arg1 domain.RepoName | |
| 89 | + | if args[1] != nil { | |
| 90 | + | arg1 = args[1].(domain.RepoName) | |
| 91 | + | } | |
| 92 | + | var arg2 string | |
| 93 | + | if args[2] != nil { | |
| 94 | + | arg2 = args[2].(string) | |
| 95 | + | } | |
| 96 | + | var arg3 time.Time | |
| 97 | + | if args[3] != nil { | |
| 98 | + | arg3 = args[3].(time.Time) | |
| 99 | + | } | |
| 100 | + | run( | |
| 101 | + | arg0, | |
| 102 | + | arg1, | |
| 103 | + | arg2, | |
| 104 | + | arg3, | |
| 105 | + | ) | |
| 106 | + | }) | |
| 107 | + | return _c | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (_c *CIWorkflowStore_DisableWorkflow_Call) Return(err error) *CIWorkflowStore_DisableWorkflow_Call { | |
| 111 | + | _c.Call.Return(err) | |
| 112 | + | return _c | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (_c *CIWorkflowStore_DisableWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string, at time.Time) error) *CIWorkflowStore_DisableWorkflow_Call { | |
| 116 | + | _c.Call.Return(run) | |
| 117 | + | return _c | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // DisabledWorkflows provides a mock function for the type CIWorkflowStore | |
| 121 | + | func (_mock *CIWorkflowStore) DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) { | |
| 122 | + | ret := _mock.Called(ctx, repo) | |
| 123 | + | ||
| 124 | + | if len(ret) == 0 { | |
| 125 | + | panic("no return value specified for DisabledWorkflows") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | var r0 []string | |
| 129 | + | var r1 error | |
| 130 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]string, error)); ok { | |
| 131 | + | return returnFunc(ctx, repo) | |
| 132 | + | } | |
| 133 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []string); ok { | |
| 134 | + | r0 = returnFunc(ctx, repo) | |
| 135 | + | } else { | |
| 136 | + | if ret.Get(0) != nil { | |
| 137 | + | r0 = ret.Get(0).([]string) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok { | |
| 141 | + | r1 = returnFunc(ctx, repo) | |
| 142 | + | } else { | |
| 143 | + | r1 = ret.Error(1) | |
| 144 | + | } | |
| 145 | + | return r0, r1 | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // CIWorkflowStore_DisabledWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisabledWorkflows' | |
| 149 | + | type CIWorkflowStore_DisabledWorkflows_Call struct { | |
| 150 | + | *mock.Call | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // DisabledWorkflows is a helper method to define mock.On call | |
| 154 | + | // - ctx context.Context | |
| 155 | + | // - repo domain.RepoName | |
| 156 | + | func (_e *CIWorkflowStore_Expecter) DisabledWorkflows(ctx any, repo any) *CIWorkflowStore_DisabledWorkflows_Call { | |
| 157 | + | return &CIWorkflowStore_DisabledWorkflows_Call{Call: _e.mock.On("DisabledWorkflows", ctx, repo)} | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (_c *CIWorkflowStore_DisabledWorkflows_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CIWorkflowStore_DisabledWorkflows_Call { | |
| 161 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 162 | + | var arg0 context.Context | |
| 163 | + | if args[0] != nil { | |
| 164 | + | arg0 = args[0].(context.Context) | |
| 165 | + | } | |
| 166 | + | var arg1 domain.RepoName | |
| 167 | + | if args[1] != nil { | |
| 168 | + | arg1 = args[1].(domain.RepoName) | |
| 169 | + | } | |
| 170 | + | run( | |
| 171 | + | arg0, | |
| 172 | + | arg1, | |
| 173 | + | ) | |
| 174 | + | }) | |
| 175 | + | return _c | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (_c *CIWorkflowStore_DisabledWorkflows_Call) Return(strings []string, err error) *CIWorkflowStore_DisabledWorkflows_Call { | |
| 179 | + | _c.Call.Return(strings, err) | |
| 180 | + | return _c | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (_c *CIWorkflowStore_DisabledWorkflows_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]string, error)) *CIWorkflowStore_DisabledWorkflows_Call { | |
| 184 | + | _c.Call.Return(run) | |
| 185 | + | return _c | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // EnableWorkflow provides a mock function for the type CIWorkflowStore | |
| 189 | + | func (_mock *CIWorkflowStore) EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error { | |
| 190 | + | ret := _mock.Called(ctx, repo, path) | |
| 191 | + | ||
| 192 | + | if len(ret) == 0 { | |
| 193 | + | panic("no return value specified for EnableWorkflow") | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | var r0 error | |
| 197 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) error); ok { | |
| 198 | + | r0 = returnFunc(ctx, repo, path) | |
| 199 | + | } else { | |
| 200 | + | r0 = ret.Error(0) | |
| 201 | + | } | |
| 202 | + | return r0 | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // CIWorkflowStore_EnableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableWorkflow' | |
| 206 | + | type CIWorkflowStore_EnableWorkflow_Call struct { | |
| 207 | + | *mock.Call | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // EnableWorkflow is a helper method to define mock.On call | |
| 211 | + | // - ctx context.Context | |
| 212 | + | // - repo domain.RepoName | |
| 213 | + | // - path string | |
| 214 | + | func (_e *CIWorkflowStore_Expecter) EnableWorkflow(ctx any, repo any, path any) *CIWorkflowStore_EnableWorkflow_Call { | |
| 215 | + | return &CIWorkflowStore_EnableWorkflow_Call{Call: _e.mock.On("EnableWorkflow", ctx, repo, path)} | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func (_c *CIWorkflowStore_EnableWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string)) *CIWorkflowStore_EnableWorkflow_Call { | |
| 219 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 220 | + | var arg0 context.Context | |
| 221 | + | if args[0] != nil { | |
| 222 | + | arg0 = args[0].(context.Context) | |
| 223 | + | } | |
| 224 | + | var arg1 domain.RepoName | |
| 225 | + | if args[1] != nil { | |
| 226 | + | arg1 = args[1].(domain.RepoName) | |
| 227 | + | } | |
| 228 | + | var arg2 string | |
| 229 | + | if args[2] != nil { | |
| 230 | + | arg2 = args[2].(string) | |
| 231 | + | } | |
| 232 | + | run( | |
| 233 | + | arg0, | |
| 234 | + | arg1, | |
| 235 | + | arg2, | |
| 236 | + | ) | |
| 237 | + | }) | |
| 238 | + | return _c | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (_c *CIWorkflowStore_EnableWorkflow_Call) Return(err error) *CIWorkflowStore_EnableWorkflow_Call { | |
| 242 | + | _c.Call.Return(err) | |
| 243 | + | return _c | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func (_c *CIWorkflowStore_EnableWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string) error) *CIWorkflowStore_EnableWorkflow_Call { | |
| 247 | + | _c.Call.Return(run) | |
| 248 | + | return _c | |
| 249 | + | } | |
| @@ -0,0 +1,249 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/domain" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewCIWorkflowStore creates a new instance of CIWorkflowStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewCIWorkflowStore(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *CIWorkflowStore { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &CIWorkflowStore{} | |||
| 26 | + | mock.Mock.Test(t) | |||
| 27 | + | ||||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 30 | + | helper.Helper() | |||
| 31 | + | } | |||
| 32 | + | mock.AssertExpectations(t) | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | return mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // CIWorkflowStore is an autogenerated mock type for the CIWorkflowStore type | |||
| 39 | + | type CIWorkflowStore struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type CIWorkflowStore_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *CIWorkflowStore) EXPECT() *CIWorkflowStore_Expecter { | |||
| 48 | + | return &CIWorkflowStore_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // DisableWorkflow provides a mock function for the type CIWorkflowStore | |||
| 52 | + | func (_mock *CIWorkflowStore) DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error { | |||
| 53 | + | ret := _mock.Called(ctx, repo, path, at) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for DisableWorkflow") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, time.Time) error); ok { | |||
| 61 | + | r0 = returnFunc(ctx, repo, path, at) | |||
| 62 | + | } else { | |||
| 63 | + | r0 = ret.Error(0) | |||
| 64 | + | } | |||
| 65 | + | return r0 | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // CIWorkflowStore_DisableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisableWorkflow' | |||
| 69 | + | type CIWorkflowStore_DisableWorkflow_Call struct { | |||
| 70 | + | *mock.Call | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // DisableWorkflow is a helper method to define mock.On call | |||
| 74 | + | // - ctx context.Context | |||
| 75 | + | // - repo domain.RepoName | |||
| 76 | + | // - path string | |||
| 77 | + | // - at time.Time | |||
| 78 | + | func (_e *CIWorkflowStore_Expecter) DisableWorkflow(ctx any, repo any, path any, at any) *CIWorkflowStore_DisableWorkflow_Call { | |||
| 79 | + | return &CIWorkflowStore_DisableWorkflow_Call{Call: _e.mock.On("DisableWorkflow", ctx, repo, path, at)} | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *CIWorkflowStore_DisableWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string, at time.Time)) *CIWorkflowStore_DisableWorkflow_Call { | |||
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 84 | + | var arg0 context.Context | |||
| 85 | + | if args[0] != nil { | |||
| 86 | + | arg0 = args[0].(context.Context) | |||
| 87 | + | } | |||
| 88 | + | var arg1 domain.RepoName | |||
| 89 | + | if args[1] != nil { | |||
| 90 | + | arg1 = args[1].(domain.RepoName) | |||
| 91 | + | } | |||
| 92 | + | var arg2 string | |||
| 93 | + | if args[2] != nil { | |||
| 94 | + | arg2 = args[2].(string) | |||
| 95 | + | } | |||
| 96 | + | var arg3 time.Time | |||
| 97 | + | if args[3] != nil { | |||
| 98 | + | arg3 = args[3].(time.Time) | |||
| 99 | + | } | |||
| 100 | + | run( | |||
| 101 | + | arg0, | |||
| 102 | + | arg1, | |||
| 103 | + | arg2, | |||
| 104 | + | arg3, | |||
| 105 | + | ) | |||
| 106 | + | }) | |||
| 107 | + | return _c | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (_c *CIWorkflowStore_DisableWorkflow_Call) Return(err error) *CIWorkflowStore_DisableWorkflow_Call { | |||
| 111 | + | _c.Call.Return(err) | |||
| 112 | + | return _c | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (_c *CIWorkflowStore_DisableWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string, at time.Time) error) *CIWorkflowStore_DisableWorkflow_Call { | |||
| 116 | + | _c.Call.Return(run) | |||
| 117 | + | return _c | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // DisabledWorkflows provides a mock function for the type CIWorkflowStore | |||
| 121 | + | func (_mock *CIWorkflowStore) DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) { | |||
| 122 | + | ret := _mock.Called(ctx, repo) | |||
| 123 | + | ||||
| 124 | + | if len(ret) == 0 { | |||
| 125 | + | panic("no return value specified for DisabledWorkflows") | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | var r0 []string | |||
| 129 | + | var r1 error | |||
| 130 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]string, error)); ok { | |||
| 131 | + | return returnFunc(ctx, repo) | |||
| 132 | + | } | |||
| 133 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []string); ok { | |||
| 134 | + | r0 = returnFunc(ctx, repo) | |||
| 135 | + | } else { | |||
| 136 | + | if ret.Get(0) != nil { | |||
| 137 | + | r0 = ret.Get(0).([]string) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok { | |||
| 141 | + | r1 = returnFunc(ctx, repo) | |||
| 142 | + | } else { | |||
| 143 | + | r1 = ret.Error(1) | |||
| 144 | + | } | |||
| 145 | + | return r0, r1 | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // CIWorkflowStore_DisabledWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisabledWorkflows' | |||
| 149 | + | type CIWorkflowStore_DisabledWorkflows_Call struct { | |||
| 150 | + | *mock.Call | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // DisabledWorkflows is a helper method to define mock.On call | |||
| 154 | + | // - ctx context.Context | |||
| 155 | + | // - repo domain.RepoName | |||
| 156 | + | func (_e *CIWorkflowStore_Expecter) DisabledWorkflows(ctx any, repo any) *CIWorkflowStore_DisabledWorkflows_Call { | |||
| 157 | + | return &CIWorkflowStore_DisabledWorkflows_Call{Call: _e.mock.On("DisabledWorkflows", ctx, repo)} | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (_c *CIWorkflowStore_DisabledWorkflows_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CIWorkflowStore_DisabledWorkflows_Call { | |||
| 161 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 162 | + | var arg0 context.Context | |||
| 163 | + | if args[0] != nil { | |||
| 164 | + | arg0 = args[0].(context.Context) | |||
| 165 | + | } | |||
| 166 | + | var arg1 domain.RepoName | |||
| 167 | + | if args[1] != nil { | |||
| 168 | + | arg1 = args[1].(domain.RepoName) | |||
| 169 | + | } | |||
| 170 | + | run( | |||
| 171 | + | arg0, | |||
| 172 | + | arg1, | |||
| 173 | + | ) | |||
| 174 | + | }) | |||
| 175 | + | return _c | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (_c *CIWorkflowStore_DisabledWorkflows_Call) Return(strings []string, err error) *CIWorkflowStore_DisabledWorkflows_Call { | |||
| 179 | + | _c.Call.Return(strings, err) | |||
| 180 | + | return _c | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (_c *CIWorkflowStore_DisabledWorkflows_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]string, error)) *CIWorkflowStore_DisabledWorkflows_Call { | |||
| 184 | + | _c.Call.Return(run) | |||
| 185 | + | return _c | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // EnableWorkflow provides a mock function for the type CIWorkflowStore | |||
| 189 | + | func (_mock *CIWorkflowStore) EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error { | |||
| 190 | + | ret := _mock.Called(ctx, repo, path) | |||
| 191 | + | ||||
| 192 | + | if len(ret) == 0 { | |||
| 193 | + | panic("no return value specified for EnableWorkflow") | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | var r0 error | |||
| 197 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) error); ok { | |||
| 198 | + | r0 = returnFunc(ctx, repo, path) | |||
| 199 | + | } else { | |||
| 200 | + | r0 = ret.Error(0) | |||
| 201 | + | } | |||
| 202 | + | return r0 | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // CIWorkflowStore_EnableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableWorkflow' | |||
| 206 | + | type CIWorkflowStore_EnableWorkflow_Call struct { | |||
| 207 | + | *mock.Call | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // EnableWorkflow is a helper method to define mock.On call | |||
| 211 | + | // - ctx context.Context | |||
| 212 | + | // - repo domain.RepoName | |||
| 213 | + | // - path string | |||
| 214 | + | func (_e *CIWorkflowStore_Expecter) EnableWorkflow(ctx any, repo any, path any) *CIWorkflowStore_EnableWorkflow_Call { | |||
| 215 | + | return &CIWorkflowStore_EnableWorkflow_Call{Call: _e.mock.On("EnableWorkflow", ctx, repo, path)} | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (_c *CIWorkflowStore_EnableWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string)) *CIWorkflowStore_EnableWorkflow_Call { | |||
| 219 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 220 | + | var arg0 context.Context | |||
| 221 | + | if args[0] != nil { | |||
| 222 | + | arg0 = args[0].(context.Context) | |||
| 223 | + | } | |||
| 224 | + | var arg1 domain.RepoName | |||
| 225 | + | if args[1] != nil { | |||
| 226 | + | arg1 = args[1].(domain.RepoName) | |||
| 227 | + | } | |||
| 228 | + | var arg2 string | |||
| 229 | + | if args[2] != nil { | |||
| 230 | + | arg2 = args[2].(string) | |||
| 231 | + | } | |||
| 232 | + | run( | |||
| 233 | + | arg0, | |||
| 234 | + | arg1, | |||
| 235 | + | arg2, | |||
| 236 | + | ) | |||
| 237 | + | }) | |||
| 238 | + | return _c | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func (_c *CIWorkflowStore_EnableWorkflow_Call) Return(err error) *CIWorkflowStore_EnableWorkflow_Call { | |||
| 242 | + | _c.Call.Return(err) | |||
| 243 | + | return _c | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func (_c *CIWorkflowStore_EnableWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string) error) *CIWorkflowStore_EnableWorkflow_Call { | |||
| 247 | + | _c.Call.Return(run) | |||
| 248 | + | return _c | |||
| 249 | + | } | |||
M internal/ports/mocks/RunService.go +304 -0
| @@ -126,6 +126,158 @@ func (_c *RunService_CancelRun_Call) RunAndReturn(run func(ctx context.Context, | |||
| 126 | 126 | return _c | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | + | // DisableWorkflow provides a mock function for the type RunService | |
| 130 | + | func (_mock *RunService) DisableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error) { | |
| 131 | + | ret := _mock.Called(ctx, p, repo, file) | |
| 132 | + | ||
| 133 | + | if len(ret) == 0 { | |
| 134 | + | panic("no return value specified for DisableWorkflow") | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | var r0 ports.WorkflowInfo | |
| 138 | + | var r1 error | |
| 139 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.WorkflowInfo, error)); ok { | |
| 140 | + | return returnFunc(ctx, p, repo, file) | |
| 141 | + | } | |
| 142 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.WorkflowInfo); ok { | |
| 143 | + | r0 = returnFunc(ctx, p, repo, file) | |
| 144 | + | } else { | |
| 145 | + | r0 = ret.Get(0).(ports.WorkflowInfo) | |
| 146 | + | } | |
| 147 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok { | |
| 148 | + | r1 = returnFunc(ctx, p, repo, file) | |
| 149 | + | } else { | |
| 150 | + | r1 = ret.Error(1) | |
| 151 | + | } | |
| 152 | + | return r0, r1 | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // RunService_DisableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisableWorkflow' | |
| 156 | + | type RunService_DisableWorkflow_Call struct { | |
| 157 | + | *mock.Call | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // DisableWorkflow is a helper method to define mock.On call | |
| 161 | + | // - ctx context.Context | |
| 162 | + | // - p domain.Principal | |
| 163 | + | // - repo domain.RepoName | |
| 164 | + | // - file string | |
| 165 | + | func (_e *RunService_Expecter) DisableWorkflow(ctx any, p any, repo any, file any) *RunService_DisableWorkflow_Call { | |
| 166 | + | return &RunService_DisableWorkflow_Call{Call: _e.mock.On("DisableWorkflow", ctx, p, repo, file)} | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (_c *RunService_DisableWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string)) *RunService_DisableWorkflow_Call { | |
| 170 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 171 | + | var arg0 context.Context | |
| 172 | + | if args[0] != nil { | |
| 173 | + | arg0 = args[0].(context.Context) | |
| 174 | + | } | |
| 175 | + | var arg1 domain.Principal | |
| 176 | + | if args[1] != nil { | |
| 177 | + | arg1 = args[1].(domain.Principal) | |
| 178 | + | } | |
| 179 | + | var arg2 domain.RepoName | |
| 180 | + | if args[2] != nil { | |
| 181 | + | arg2 = args[2].(domain.RepoName) | |
| 182 | + | } | |
| 183 | + | var arg3 string | |
| 184 | + | if args[3] != nil { | |
| 185 | + | arg3 = args[3].(string) | |
| 186 | + | } | |
| 187 | + | run( | |
| 188 | + | arg0, | |
| 189 | + | arg1, | |
| 190 | + | arg2, | |
| 191 | + | arg3, | |
| 192 | + | ) | |
| 193 | + | }) | |
| 194 | + | return _c | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (_c *RunService_DisableWorkflow_Call) Return(workflowInfo ports.WorkflowInfo, err error) *RunService_DisableWorkflow_Call { | |
| 198 | + | _c.Call.Return(workflowInfo, err) | |
| 199 | + | return _c | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (_c *RunService_DisableWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error)) *RunService_DisableWorkflow_Call { | |
| 203 | + | _c.Call.Return(run) | |
| 204 | + | return _c | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // DisabledWorkflows provides a mock function for the type RunService | |
| 208 | + | func (_mock *RunService) DisabledWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error) { | |
| 209 | + | ret := _mock.Called(ctx, p, repo) | |
| 210 | + | ||
| 211 | + | if len(ret) == 0 { | |
| 212 | + | panic("no return value specified for DisabledWorkflows") | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | var r0 []string | |
| 216 | + | var r1 error | |
| 217 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ([]string, error)); ok { | |
| 218 | + | return returnFunc(ctx, p, repo) | |
| 219 | + | } | |
| 220 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) []string); ok { | |
| 221 | + | r0 = returnFunc(ctx, p, repo) | |
| 222 | + | } else { | |
| 223 | + | if ret.Get(0) != nil { | |
| 224 | + | r0 = ret.Get(0).([]string) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 228 | + | r1 = returnFunc(ctx, p, repo) | |
| 229 | + | } else { | |
| 230 | + | r1 = ret.Error(1) | |
| 231 | + | } | |
| 232 | + | return r0, r1 | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // RunService_DisabledWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisabledWorkflows' | |
| 236 | + | type RunService_DisabledWorkflows_Call struct { | |
| 237 | + | *mock.Call | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // DisabledWorkflows is a helper method to define mock.On call | |
| 241 | + | // - ctx context.Context | |
| 242 | + | // - p domain.Principal | |
| 243 | + | // - repo domain.RepoName | |
| 244 | + | func (_e *RunService_Expecter) DisabledWorkflows(ctx any, p any, repo any) *RunService_DisabledWorkflows_Call { | |
| 245 | + | return &RunService_DisabledWorkflows_Call{Call: _e.mock.On("DisabledWorkflows", ctx, p, repo)} | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (_c *RunService_DisabledWorkflows_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *RunService_DisabledWorkflows_Call { | |
| 249 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 250 | + | var arg0 context.Context | |
| 251 | + | if args[0] != nil { | |
| 252 | + | arg0 = args[0].(context.Context) | |
| 253 | + | } | |
| 254 | + | var arg1 domain.Principal | |
| 255 | + | if args[1] != nil { | |
| 256 | + | arg1 = args[1].(domain.Principal) | |
| 257 | + | } | |
| 258 | + | var arg2 domain.RepoName | |
| 259 | + | if args[2] != nil { | |
| 260 | + | arg2 = args[2].(domain.RepoName) | |
| 261 | + | } | |
| 262 | + | run( | |
| 263 | + | arg0, | |
| 264 | + | arg1, | |
| 265 | + | arg2, | |
| 266 | + | ) | |
| 267 | + | }) | |
| 268 | + | return _c | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func (_c *RunService_DisabledWorkflows_Call) Return(strings []string, err error) *RunService_DisabledWorkflows_Call { | |
| 272 | + | _c.Call.Return(strings, err) | |
| 273 | + | return _c | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (_c *RunService_DisabledWorkflows_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error)) *RunService_DisabledWorkflows_Call { | |
| 277 | + | _c.Call.Return(run) | |
| 278 | + | return _c | |
| 279 | + | } | |
| 280 | + | ||
| 129 | 281 | // DispatchWorkflow provides a mock function for the type RunService | |
| 130 | 282 | func (_mock *RunService) DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) { | |
| 131 | 283 | ret := _mock.Called(ctx, p, repo, params) | |
| @@ -204,6 +356,84 @@ func (_c *RunService_DispatchWorkflow_Call) RunAndReturn(run func(ctx context.Co | |||
| 204 | 356 | return _c | |
| 205 | 357 | } | |
| 206 | 358 | ||
| 359 | + | // EnableWorkflow provides a mock function for the type RunService | |
| 360 | + | func (_mock *RunService) EnableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error) { | |
| 361 | + | ret := _mock.Called(ctx, p, repo, file) | |
| 362 | + | ||
| 363 | + | if len(ret) == 0 { | |
| 364 | + | panic("no return value specified for EnableWorkflow") | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | var r0 ports.WorkflowInfo | |
| 368 | + | var r1 error | |
| 369 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.WorkflowInfo, error)); ok { | |
| 370 | + | return returnFunc(ctx, p, repo, file) | |
| 371 | + | } | |
| 372 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.WorkflowInfo); ok { | |
| 373 | + | r0 = returnFunc(ctx, p, repo, file) | |
| 374 | + | } else { | |
| 375 | + | r0 = ret.Get(0).(ports.WorkflowInfo) | |
| 376 | + | } | |
| 377 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok { | |
| 378 | + | r1 = returnFunc(ctx, p, repo, file) | |
| 379 | + | } else { | |
| 380 | + | r1 = ret.Error(1) | |
| 381 | + | } | |
| 382 | + | return r0, r1 | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // RunService_EnableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableWorkflow' | |
| 386 | + | type RunService_EnableWorkflow_Call struct { | |
| 387 | + | *mock.Call | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | // EnableWorkflow is a helper method to define mock.On call | |
| 391 | + | // - ctx context.Context | |
| 392 | + | // - p domain.Principal | |
| 393 | + | // - repo domain.RepoName | |
| 394 | + | // - file string | |
| 395 | + | func (_e *RunService_Expecter) EnableWorkflow(ctx any, p any, repo any, file any) *RunService_EnableWorkflow_Call { | |
| 396 | + | return &RunService_EnableWorkflow_Call{Call: _e.mock.On("EnableWorkflow", ctx, p, repo, file)} | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func (_c *RunService_EnableWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string)) *RunService_EnableWorkflow_Call { | |
| 400 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 401 | + | var arg0 context.Context | |
| 402 | + | if args[0] != nil { | |
| 403 | + | arg0 = args[0].(context.Context) | |
| 404 | + | } | |
| 405 | + | var arg1 domain.Principal | |
| 406 | + | if args[1] != nil { | |
| 407 | + | arg1 = args[1].(domain.Principal) | |
| 408 | + | } | |
| 409 | + | var arg2 domain.RepoName | |
| 410 | + | if args[2] != nil { | |
| 411 | + | arg2 = args[2].(domain.RepoName) | |
| 412 | + | } | |
| 413 | + | var arg3 string | |
| 414 | + | if args[3] != nil { | |
| 415 | + | arg3 = args[3].(string) | |
| 416 | + | } | |
| 417 | + | run( | |
| 418 | + | arg0, | |
| 419 | + | arg1, | |
| 420 | + | arg2, | |
| 421 | + | arg3, | |
| 422 | + | ) | |
| 423 | + | }) | |
| 424 | + | return _c | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func (_c *RunService_EnableWorkflow_Call) Return(workflowInfo ports.WorkflowInfo, err error) *RunService_EnableWorkflow_Call { | |
| 428 | + | _c.Call.Return(workflowInfo, err) | |
| 429 | + | return _c | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func (_c *RunService_EnableWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error)) *RunService_EnableWorkflow_Call { | |
| 433 | + | _c.Call.Return(run) | |
| 434 | + | return _c | |
| 435 | + | } | |
| 436 | + | ||
| 207 | 437 | // GetJob provides a mock function for the type RunService | |
| 208 | 438 | func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) { | |
| 209 | 439 | ret := _mock.Called(ctx, p, repo, jobID, attempt) | |
| @@ -861,6 +1091,80 @@ func (_c *RunService_ListRuns_Call) RunAndReturn(run func(ctx context.Context, p | |||
| 861 | 1091 | return _c | |
| 862 | 1092 | } | |
| 863 | 1093 | ||
| 1094 | + | // ListWorkflows provides a mock function for the type RunService | |
| 1095 | + | func (_mock *RunService) ListWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]ports.WorkflowInfo, error) { | |
| 1096 | + | ret := _mock.Called(ctx, p, repo) | |
| 1097 | + | ||
| 1098 | + | if len(ret) == 0 { | |
| 1099 | + | panic("no return value specified for ListWorkflows") | |
| 1100 | + | } | |
| 1101 | + | ||
| 1102 | + | var r0 []ports.WorkflowInfo | |
| 1103 | + | var r1 error | |
| 1104 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ([]ports.WorkflowInfo, error)); ok { | |
| 1105 | + | return returnFunc(ctx, p, repo) | |
| 1106 | + | } | |
| 1107 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) []ports.WorkflowInfo); ok { | |
| 1108 | + | r0 = returnFunc(ctx, p, repo) | |
| 1109 | + | } else { | |
| 1110 | + | if ret.Get(0) != nil { | |
| 1111 | + | r0 = ret.Get(0).([]ports.WorkflowInfo) | |
| 1112 | + | } | |
| 1113 | + | } | |
| 1114 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 1115 | + | r1 = returnFunc(ctx, p, repo) | |
| 1116 | + | } else { | |
| 1117 | + | r1 = ret.Error(1) | |
| 1118 | + | } | |
| 1119 | + | return r0, r1 | |
| 1120 | + | } | |
| 1121 | + | ||
| 1122 | + | // RunService_ListWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListWorkflows' | |
| 1123 | + | type RunService_ListWorkflows_Call struct { | |
| 1124 | + | *mock.Call | |
| 1125 | + | } | |
| 1126 | + | ||
| 1127 | + | // ListWorkflows is a helper method to define mock.On call | |
| 1128 | + | // - ctx context.Context | |
| 1129 | + | // - p domain.Principal | |
| 1130 | + | // - repo domain.RepoName | |
| 1131 | + | func (_e *RunService_Expecter) ListWorkflows(ctx any, p any, repo any) *RunService_ListWorkflows_Call { | |
| 1132 | + | return &RunService_ListWorkflows_Call{Call: _e.mock.On("ListWorkflows", ctx, p, repo)} | |
| 1133 | + | } | |
| 1134 | + | ||
| 1135 | + | func (_c *RunService_ListWorkflows_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *RunService_ListWorkflows_Call { | |
| 1136 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1137 | + | var arg0 context.Context | |
| 1138 | + | if args[0] != nil { | |
| 1139 | + | arg0 = args[0].(context.Context) | |
| 1140 | + | } | |
| 1141 | + | var arg1 domain.Principal | |
| 1142 | + | if args[1] != nil { | |
| 1143 | + | arg1 = args[1].(domain.Principal) | |
| 1144 | + | } | |
| 1145 | + | var arg2 domain.RepoName | |
| 1146 | + | if args[2] != nil { | |
| 1147 | + | arg2 = args[2].(domain.RepoName) | |
| 1148 | + | } | |
| 1149 | + | run( | |
| 1150 | + | arg0, | |
| 1151 | + | arg1, | |
| 1152 | + | arg2, | |
| 1153 | + | ) | |
| 1154 | + | }) | |
| 1155 | + | return _c | |
| 1156 | + | } | |
| 1157 | + | ||
| 1158 | + | func (_c *RunService_ListWorkflows_Call) Return(workflowInfos []ports.WorkflowInfo, err error) *RunService_ListWorkflows_Call { | |
| 1159 | + | _c.Call.Return(workflowInfos, err) | |
| 1160 | + | return _c | |
| 1161 | + | } | |
| 1162 | + | ||
| 1163 | + | func (_c *RunService_ListWorkflows_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]ports.WorkflowInfo, error)) *RunService_ListWorkflows_Call { | |
| 1164 | + | _c.Call.Return(run) | |
| 1165 | + | return _c | |
| 1166 | + | } | |
| 1167 | + | ||
| 864 | 1168 | // RemoveRunner provides a mock function for the type RunService | |
| 865 | 1169 | func (_mock *RunService) RemoveRunner(ctx context.Context, p domain.Principal, id string) error { | |
| 866 | 1170 | ret := _mock.Called(ctx, p, id) | |
| @@ -126,6 +126,158 @@ func (_c *RunService_CancelRun_Call) RunAndReturn(run func(ctx context.Context, | |||||
| 126 | return _c | 126 | return _c | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | + | // DisableWorkflow provides a mock function for the type RunService | |||
| 130 | + | func (_mock *RunService) DisableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error) { | |||
| 131 | + | ret := _mock.Called(ctx, p, repo, file) | |||
| 132 | + | ||||
| 133 | + | if len(ret) == 0 { | |||
| 134 | + | panic("no return value specified for DisableWorkflow") | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | var r0 ports.WorkflowInfo | |||
| 138 | + | var r1 error | |||
| 139 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.WorkflowInfo, error)); ok { | |||
| 140 | + | return returnFunc(ctx, p, repo, file) | |||
| 141 | + | } | |||
| 142 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.WorkflowInfo); ok { | |||
| 143 | + | r0 = returnFunc(ctx, p, repo, file) | |||
| 144 | + | } else { | |||
| 145 | + | r0 = ret.Get(0).(ports.WorkflowInfo) | |||
| 146 | + | } | |||
| 147 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok { | |||
| 148 | + | r1 = returnFunc(ctx, p, repo, file) | |||
| 149 | + | } else { | |||
| 150 | + | r1 = ret.Error(1) | |||
| 151 | + | } | |||
| 152 | + | return r0, r1 | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // RunService_DisableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisableWorkflow' | |||
| 156 | + | type RunService_DisableWorkflow_Call struct { | |||
| 157 | + | *mock.Call | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // DisableWorkflow is a helper method to define mock.On call | |||
| 161 | + | // - ctx context.Context | |||
| 162 | + | // - p domain.Principal | |||
| 163 | + | // - repo domain.RepoName | |||
| 164 | + | // - file string | |||
| 165 | + | func (_e *RunService_Expecter) DisableWorkflow(ctx any, p any, repo any, file any) *RunService_DisableWorkflow_Call { | |||
| 166 | + | return &RunService_DisableWorkflow_Call{Call: _e.mock.On("DisableWorkflow", ctx, p, repo, file)} | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (_c *RunService_DisableWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string)) *RunService_DisableWorkflow_Call { | |||
| 170 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 171 | + | var arg0 context.Context | |||
| 172 | + | if args[0] != nil { | |||
| 173 | + | arg0 = args[0].(context.Context) | |||
| 174 | + | } | |||
| 175 | + | var arg1 domain.Principal | |||
| 176 | + | if args[1] != nil { | |||
| 177 | + | arg1 = args[1].(domain.Principal) | |||
| 178 | + | } | |||
| 179 | + | var arg2 domain.RepoName | |||
| 180 | + | if args[2] != nil { | |||
| 181 | + | arg2 = args[2].(domain.RepoName) | |||
| 182 | + | } | |||
| 183 | + | var arg3 string | |||
| 184 | + | if args[3] != nil { | |||
| 185 | + | arg3 = args[3].(string) | |||
| 186 | + | } | |||
| 187 | + | run( | |||
| 188 | + | arg0, | |||
| 189 | + | arg1, | |||
| 190 | + | arg2, | |||
| 191 | + | arg3, | |||
| 192 | + | ) | |||
| 193 | + | }) | |||
| 194 | + | return _c | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (_c *RunService_DisableWorkflow_Call) Return(workflowInfo ports.WorkflowInfo, err error) *RunService_DisableWorkflow_Call { | |||
| 198 | + | _c.Call.Return(workflowInfo, err) | |||
| 199 | + | return _c | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (_c *RunService_DisableWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error)) *RunService_DisableWorkflow_Call { | |||
| 203 | + | _c.Call.Return(run) | |||
| 204 | + | return _c | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // DisabledWorkflows provides a mock function for the type RunService | |||
| 208 | + | func (_mock *RunService) DisabledWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error) { | |||
| 209 | + | ret := _mock.Called(ctx, p, repo) | |||
| 210 | + | ||||
| 211 | + | if len(ret) == 0 { | |||
| 212 | + | panic("no return value specified for DisabledWorkflows") | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | var r0 []string | |||
| 216 | + | var r1 error | |||
| 217 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ([]string, error)); ok { | |||
| 218 | + | return returnFunc(ctx, p, repo) | |||
| 219 | + | } | |||
| 220 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) []string); ok { | |||
| 221 | + | r0 = returnFunc(ctx, p, repo) | |||
| 222 | + | } else { | |||
| 223 | + | if ret.Get(0) != nil { | |||
| 224 | + | r0 = ret.Get(0).([]string) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |||
| 228 | + | r1 = returnFunc(ctx, p, repo) | |||
| 229 | + | } else { | |||
| 230 | + | r1 = ret.Error(1) | |||
| 231 | + | } | |||
| 232 | + | return r0, r1 | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // RunService_DisabledWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisabledWorkflows' | |||
| 236 | + | type RunService_DisabledWorkflows_Call struct { | |||
| 237 | + | *mock.Call | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // DisabledWorkflows is a helper method to define mock.On call | |||
| 241 | + | // - ctx context.Context | |||
| 242 | + | // - p domain.Principal | |||
| 243 | + | // - repo domain.RepoName | |||
| 244 | + | func (_e *RunService_Expecter) DisabledWorkflows(ctx any, p any, repo any) *RunService_DisabledWorkflows_Call { | |||
| 245 | + | return &RunService_DisabledWorkflows_Call{Call: _e.mock.On("DisabledWorkflows", ctx, p, repo)} | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (_c *RunService_DisabledWorkflows_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *RunService_DisabledWorkflows_Call { | |||
| 249 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 250 | + | var arg0 context.Context | |||
| 251 | + | if args[0] != nil { | |||
| 252 | + | arg0 = args[0].(context.Context) | |||
| 253 | + | } | |||
| 254 | + | var arg1 domain.Principal | |||
| 255 | + | if args[1] != nil { | |||
| 256 | + | arg1 = args[1].(domain.Principal) | |||
| 257 | + | } | |||
| 258 | + | var arg2 domain.RepoName | |||
| 259 | + | if args[2] != nil { | |||
| 260 | + | arg2 = args[2].(domain.RepoName) | |||
| 261 | + | } | |||
| 262 | + | run( | |||
| 263 | + | arg0, | |||
| 264 | + | arg1, | |||
| 265 | + | arg2, | |||
| 266 | + | ) | |||
| 267 | + | }) | |||
| 268 | + | return _c | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (_c *RunService_DisabledWorkflows_Call) Return(strings []string, err error) *RunService_DisabledWorkflows_Call { | |||
| 272 | + | _c.Call.Return(strings, err) | |||
| 273 | + | return _c | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func (_c *RunService_DisabledWorkflows_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error)) *RunService_DisabledWorkflows_Call { | |||
| 277 | + | _c.Call.Return(run) | |||
| 278 | + | return _c | |||
| 279 | + | } | |||
| 280 | + | ||||
| 129 | // DispatchWorkflow provides a mock function for the type RunService | 281 | // DispatchWorkflow provides a mock function for the type RunService | ||
| 130 | func (_mock *RunService) DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) { | 282 | func (_mock *RunService) DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) { | ||
| 131 | ret := _mock.Called(ctx, p, repo, params) | 283 | ret := _mock.Called(ctx, p, repo, params) | ||
| @@ -204,6 +356,84 @@ func (_c *RunService_DispatchWorkflow_Call) RunAndReturn(run func(ctx context.Co | |||||
| 204 | return _c | 356 | return _c | ||
| 205 | } | 357 | } | ||
| 206 | 358 | ||||
| 359 | + | // EnableWorkflow provides a mock function for the type RunService | |||
| 360 | + | func (_mock *RunService) EnableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error) { | |||
| 361 | + | ret := _mock.Called(ctx, p, repo, file) | |||
| 362 | + | ||||
| 363 | + | if len(ret) == 0 { | |||
| 364 | + | panic("no return value specified for EnableWorkflow") | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | var r0 ports.WorkflowInfo | |||
| 368 | + | var r1 error | |||
| 369 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.WorkflowInfo, error)); ok { | |||
| 370 | + | return returnFunc(ctx, p, repo, file) | |||
| 371 | + | } | |||
| 372 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.WorkflowInfo); ok { | |||
| 373 | + | r0 = returnFunc(ctx, p, repo, file) | |||
| 374 | + | } else { | |||
| 375 | + | r0 = ret.Get(0).(ports.WorkflowInfo) | |||
| 376 | + | } | |||
| 377 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok { | |||
| 378 | + | r1 = returnFunc(ctx, p, repo, file) | |||
| 379 | + | } else { | |||
| 380 | + | r1 = ret.Error(1) | |||
| 381 | + | } | |||
| 382 | + | return r0, r1 | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | // RunService_EnableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableWorkflow' | |||
| 386 | + | type RunService_EnableWorkflow_Call struct { | |||
| 387 | + | *mock.Call | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // EnableWorkflow is a helper method to define mock.On call | |||
| 391 | + | // - ctx context.Context | |||
| 392 | + | // - p domain.Principal | |||
| 393 | + | // - repo domain.RepoName | |||
| 394 | + | // - file string | |||
| 395 | + | func (_e *RunService_Expecter) EnableWorkflow(ctx any, p any, repo any, file any) *RunService_EnableWorkflow_Call { | |||
| 396 | + | return &RunService_EnableWorkflow_Call{Call: _e.mock.On("EnableWorkflow", ctx, p, repo, file)} | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func (_c *RunService_EnableWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string)) *RunService_EnableWorkflow_Call { | |||
| 400 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 401 | + | var arg0 context.Context | |||
| 402 | + | if args[0] != nil { | |||
| 403 | + | arg0 = args[0].(context.Context) | |||
| 404 | + | } | |||
| 405 | + | var arg1 domain.Principal | |||
| 406 | + | if args[1] != nil { | |||
| 407 | + | arg1 = args[1].(domain.Principal) | |||
| 408 | + | } | |||
| 409 | + | var arg2 domain.RepoName | |||
| 410 | + | if args[2] != nil { | |||
| 411 | + | arg2 = args[2].(domain.RepoName) | |||
| 412 | + | } | |||
| 413 | + | var arg3 string | |||
| 414 | + | if args[3] != nil { | |||
| 415 | + | arg3 = args[3].(string) | |||
| 416 | + | } | |||
| 417 | + | run( | |||
| 418 | + | arg0, | |||
| 419 | + | arg1, | |||
| 420 | + | arg2, | |||
| 421 | + | arg3, | |||
| 422 | + | ) | |||
| 423 | + | }) | |||
| 424 | + | return _c | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | func (_c *RunService_EnableWorkflow_Call) Return(workflowInfo ports.WorkflowInfo, err error) *RunService_EnableWorkflow_Call { | |||
| 428 | + | _c.Call.Return(workflowInfo, err) | |||
| 429 | + | return _c | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func (_c *RunService_EnableWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error)) *RunService_EnableWorkflow_Call { | |||
| 433 | + | _c.Call.Return(run) | |||
| 434 | + | return _c | |||
| 435 | + | } | |||
| 436 | + | ||||
| 207 | // GetJob provides a mock function for the type RunService | 437 | // GetJob provides a mock function for the type RunService | ||
| 208 | func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) { | 438 | func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) { | ||
| 209 | ret := _mock.Called(ctx, p, repo, jobID, attempt) | 439 | ret := _mock.Called(ctx, p, repo, jobID, attempt) | ||
| @@ -861,6 +1091,80 @@ func (_c *RunService_ListRuns_Call) RunAndReturn(run func(ctx context.Context, p | |||||
| 861 | return _c | 1091 | return _c | ||
| 862 | } | 1092 | } | ||
| 863 | 1093 | ||||
| 1094 | + | // ListWorkflows provides a mock function for the type RunService | |||
| 1095 | + | func (_mock *RunService) ListWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]ports.WorkflowInfo, error) { | |||
| 1096 | + | ret := _mock.Called(ctx, p, repo) | |||
| 1097 | + | ||||
| 1098 | + | if len(ret) == 0 { | |||
| 1099 | + | panic("no return value specified for ListWorkflows") | |||
| 1100 | + | } | |||
| 1101 | + | ||||
| 1102 | + | var r0 []ports.WorkflowInfo | |||
| 1103 | + | var r1 error | |||
| 1104 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ([]ports.WorkflowInfo, error)); ok { | |||
| 1105 | + | return returnFunc(ctx, p, repo) | |||
| 1106 | + | } | |||
| 1107 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) []ports.WorkflowInfo); ok { | |||
| 1108 | + | r0 = returnFunc(ctx, p, repo) | |||
| 1109 | + | } else { | |||
| 1110 | + | if ret.Get(0) != nil { | |||
| 1111 | + | r0 = ret.Get(0).([]ports.WorkflowInfo) | |||
| 1112 | + | } | |||
| 1113 | + | } | |||
| 1114 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |||
| 1115 | + | r1 = returnFunc(ctx, p, repo) | |||
| 1116 | + | } else { | |||
| 1117 | + | r1 = ret.Error(1) | |||
| 1118 | + | } | |||
| 1119 | + | return r0, r1 | |||
| 1120 | + | } | |||
| 1121 | + | ||||
| 1122 | + | // RunService_ListWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListWorkflows' | |||
| 1123 | + | type RunService_ListWorkflows_Call struct { | |||
| 1124 | + | *mock.Call | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 1127 | + | // ListWorkflows is a helper method to define mock.On call | |||
| 1128 | + | // - ctx context.Context | |||
| 1129 | + | // - p domain.Principal | |||
| 1130 | + | // - repo domain.RepoName | |||
| 1131 | + | func (_e *RunService_Expecter) ListWorkflows(ctx any, p any, repo any) *RunService_ListWorkflows_Call { | |||
| 1132 | + | return &RunService_ListWorkflows_Call{Call: _e.mock.On("ListWorkflows", ctx, p, repo)} | |||
| 1133 | + | } | |||
| 1134 | + | ||||
| 1135 | + | func (_c *RunService_ListWorkflows_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *RunService_ListWorkflows_Call { | |||
| 1136 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1137 | + | var arg0 context.Context | |||
| 1138 | + | if args[0] != nil { | |||
| 1139 | + | arg0 = args[0].(context.Context) | |||
| 1140 | + | } | |||
| 1141 | + | var arg1 domain.Principal | |||
| 1142 | + | if args[1] != nil { | |||
| 1143 | + | arg1 = args[1].(domain.Principal) | |||
| 1144 | + | } | |||
| 1145 | + | var arg2 domain.RepoName | |||
| 1146 | + | if args[2] != nil { | |||
| 1147 | + | arg2 = args[2].(domain.RepoName) | |||
| 1148 | + | } | |||
| 1149 | + | run( | |||
| 1150 | + | arg0, | |||
| 1151 | + | arg1, | |||
| 1152 | + | arg2, | |||
| 1153 | + | ) | |||
| 1154 | + | }) | |||
| 1155 | + | return _c | |||
| 1156 | + | } | |||
| 1157 | + | ||||
| 1158 | + | func (_c *RunService_ListWorkflows_Call) Return(workflowInfos []ports.WorkflowInfo, err error) *RunService_ListWorkflows_Call { | |||
| 1159 | + | _c.Call.Return(workflowInfos, err) | |||
| 1160 | + | return _c | |||
| 1161 | + | } | |||
| 1162 | + | ||||
| 1163 | + | func (_c *RunService_ListWorkflows_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]ports.WorkflowInfo, error)) *RunService_ListWorkflows_Call { | |||
| 1164 | + | _c.Call.Return(run) | |||
| 1165 | + | return _c | |||
| 1166 | + | } | |||
| 1167 | + | ||||
| 864 | // RemoveRunner provides a mock function for the type RunService | 1168 | // RemoveRunner provides a mock function for the type RunService | ||
| 865 | func (_mock *RunService) RemoveRunner(ctx context.Context, p domain.Principal, id string) error { | 1169 | func (_mock *RunService) RemoveRunner(ctx context.Context, p domain.Principal, id string) error { | ||
| 866 | ret := _mock.Called(ctx, p, id) | 1170 | ret := _mock.Called(ctx, p, id) | ||
M internal/usecase/ci.go +7 -0
| @@ -167,6 +167,9 @@ type CIConfig struct { | |||
| 167 | 167 | // runs and the trust of dispatched ones (ci_tips.go). Nil: they are never | |
| 168 | 168 | // trusted. Leave it unset rather than assigning a typed nil pointer. | |
| 169 | 169 | RefTips ports.CIRefTipStore | |
| 170 | + | // WorkflowState keeps the workflows a repository disabled (ci_workflows.go). | |
| 171 | + | // Nil: none is. Leave it unset rather than assigning a typed nil pointer. | |
| 172 | + | WorkflowState ports.CIWorkflowStore | |
| 170 | 173 | // DispatchResolved, when set, is called by DispatchWorkflow after it resolved | |
| 171 | 174 | // its ref and before it judges the trust of the run. Tests use it to move the | |
| 172 | 175 | // ref in between. | |
| @@ -175,6 +178,10 @@ type CIConfig struct { | |||
| 175 | 178 | // "requested" or "completed" event of a run were started (or none matched). | |
| 176 | 179 | // Tests use it as a barrier. | |
| 177 | 180 | RunEventHandled func(runID int64, action string) | |
| 181 | + | // ScheduleLoaded, when set, is called after the scheduler read the workflows | |
| 182 | + | // of a repository and before it caches and fires them. Tests use it to | |
| 183 | + | // change the disabled state in that window. | |
| 184 | + | ScheduleLoaded func(repo domain.RepoName) | |
| 178 | 185 | // ScheduleVisit, when set, is called with each repository the scheduler | |
| 179 | 186 | // looks at. Tests use it. | |
| 180 | 187 | ScheduleVisit func(repo domain.RepoName) | |
| @@ -167,6 +167,9 @@ type CIConfig struct { | |||||
| 167 | // runs and the trust of dispatched ones (ci_tips.go). Nil: they are never | 167 | // runs and the trust of dispatched ones (ci_tips.go). Nil: they are never | ||
| 168 | // trusted. Leave it unset rather than assigning a typed nil pointer. | 168 | // trusted. Leave it unset rather than assigning a typed nil pointer. | ||
| 169 | RefTips ports.CIRefTipStore | 169 | RefTips ports.CIRefTipStore | ||
| 170 | + | // WorkflowState keeps the workflows a repository disabled (ci_workflows.go). | |||
| 171 | + | // Nil: none is. Leave it unset rather than assigning a typed nil pointer. | |||
| 172 | + | WorkflowState ports.CIWorkflowStore | |||
| 170 | // DispatchResolved, when set, is called by DispatchWorkflow after it resolved | 173 | // DispatchResolved, when set, is called by DispatchWorkflow after it resolved | ||
| 171 | // its ref and before it judges the trust of the run. Tests use it to move the | 174 | // its ref and before it judges the trust of the run. Tests use it to move the | ||
| 172 | // ref in between. | 175 | // ref in between. | ||
| @@ -175,6 +178,10 @@ type CIConfig struct { | |||||
| 175 | // "requested" or "completed" event of a run were started (or none matched). | 178 | // "requested" or "completed" event of a run were started (or none matched). | ||
| 176 | // Tests use it as a barrier. | 179 | // Tests use it as a barrier. | ||
| 177 | RunEventHandled func(runID int64, action string) | 180 | RunEventHandled func(runID int64, action string) | ||
| 181 | + | // ScheduleLoaded, when set, is called after the scheduler read the workflows | |||
| 182 | + | // of a repository and before it caches and fires them. Tests use it to | |||
| 183 | + | // change the disabled state in that window. | |||
| 184 | + | ScheduleLoaded func(repo domain.RepoName) | |||
| 178 | // ScheduleVisit, when set, is called with each repository the scheduler | 185 | // ScheduleVisit, when set, is called with each repository the scheduler | ||
| 179 | // looks at. Tests use it. | 186 | // looks at. Tests use it. | ||
| 180 | ScheduleVisit func(repo domain.RepoName) | 187 | ScheduleVisit func(repo domain.RepoName) | ||
M internal/usecase/ci_dispatch.go +12 -16
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | - | "path" | |
| 11 | 10 | "strings" | |
| 12 | 11 | ||
| 13 | 12 | "bnema.dev/ermite/internal/domain" | |
| @@ -41,6 +40,11 @@ func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name doma | |||
| 41 | 40 | if err != nil { | |
| 42 | 41 | return domain.CIRun{}, err | |
| 43 | 42 | } | |
| 43 | + | if off, err := c.workflowDisabled(ctx, name, wfPath); err != nil { | |
| 44 | + | return domain.CIRun{}, err | |
| 45 | + | } else if off { | |
| 46 | + | return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath) | |
| 47 | + | } | |
| 44 | 48 | pin, err := c.dispatchRef(ctx, name, params.Ref) | |
| 45 | 49 | if err != nil { | |
| 46 | 50 | return domain.CIRun{}, err | |
| @@ -91,6 +95,10 @@ func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name doma | |||
| 91 | 95 | return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text) | |
| 92 | 96 | } | |
| 93 | 97 | run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf) | |
| 98 | + | if errors.Is(err, domain.ErrWorkflowDisabled) { | |
| 99 | + | // Disabled after the check above: the store refused the run. | |
| 100 | + | return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath) | |
| 101 | + | } | |
| 94 | 102 | if err != nil { | |
| 95 | 103 | return domain.CIRun{}, err | |
| 96 | 104 | } | |
| @@ -131,21 +139,9 @@ func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain | |||
| 131 | 139 | } | |
| 132 | 140 | ||
| 133 | 141 | // dispatchPath checks the workflow file of a dispatch: a file of | |
| 134 | - | // .github/workflows, named by its path or by its file name. | |
| 135 | - | func dispatchPath(s string) (string, error) { | |
| 136 | - | s = strings.TrimSpace(s) | |
| 137 | - | if s == "" { | |
| 138 | - | return "", fmt.Errorf("%w: the workflow file is required", domain.ErrInvalid) | |
| 139 | - | } | |
| 140 | - | if !strings.Contains(s, "/") { | |
| 141 | - | s = workflowDir + "/" + s | |
| 142 | - | } | |
| 143 | - | ext := strings.ToLower(path.Ext(s)) | |
| 144 | - | if path.Dir(s) != workflowDir || (ext != ".yml" && ext != ".yaml") { | |
| 145 | - | return "", fmt.Errorf("%w: %q is not a workflow file of %s", domain.ErrInvalid, s, workflowDir) | |
| 146 | - | } | |
| 147 | - | return s, nil | |
| 148 | - | } | |
| 142 | + | // .github/workflows, named by its path or by its file name, in its canonical | |
| 143 | + | // spelling (the one the disabled state is keyed by). | |
| 144 | + | func dispatchPath(s string) (string, error) { return domain.ParseWorkflowPath(s) } | |
| 149 | 145 | ||
| 150 | 146 | // pinnedRef is what a dispatch resolved its ref to, once: the full name, the | |
| 151 | 147 | // commit the run executes and the object the ref points to (the commit for a | |
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | - | "path" | |||
| 11 | "strings" | 10 | "strings" | ||
| 12 | 11 | ||||
| 13 | "bnema.dev/ermite/internal/domain" | 12 | "bnema.dev/ermite/internal/domain" | ||
| @@ -41,6 +40,11 @@ func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name doma | |||||
| 41 | if err != nil { | 40 | if err != nil { | ||
| 42 | return domain.CIRun{}, err | 41 | return domain.CIRun{}, err | ||
| 43 | } | 42 | } | ||
| 43 | + | if off, err := c.workflowDisabled(ctx, name, wfPath); err != nil { | |||
| 44 | + | return domain.CIRun{}, err | |||
| 45 | + | } else if off { | |||
| 46 | + | return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath) | |||
| 47 | + | } | |||
| 44 | pin, err := c.dispatchRef(ctx, name, params.Ref) | 48 | pin, err := c.dispatchRef(ctx, name, params.Ref) | ||
| 45 | if err != nil { | 49 | if err != nil { | ||
| 46 | return domain.CIRun{}, err | 50 | return domain.CIRun{}, err | ||
| @@ -91,6 +95,10 @@ func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name doma | |||||
| 91 | return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text) | 95 | return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text) | ||
| 92 | } | 96 | } | ||
| 93 | run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf) | 97 | run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf) | ||
| 98 | + | if errors.Is(err, domain.ErrWorkflowDisabled) { | |||
| 99 | + | // Disabled after the check above: the store refused the run. | |||
| 100 | + | return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath) | |||
| 101 | + | } | |||
| 94 | if err != nil { | 102 | if err != nil { | ||
| 95 | return domain.CIRun{}, err | 103 | return domain.CIRun{}, err | ||
| 96 | } | 104 | } | ||
| @@ -131,21 +139,9 @@ func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain | |||||
| 131 | } | 139 | } | ||
| 132 | 140 | ||||
| 133 | // dispatchPath checks the workflow file of a dispatch: a file of | 141 | // dispatchPath checks the workflow file of a dispatch: a file of | ||
| 134 | - | // .github/workflows, named by its path or by its file name. | 142 | + | // .github/workflows, named by its path or by its file name, in its canonical |
| 135 | - | func dispatchPath(s string) (string, error) { | 143 | + | // spelling (the one the disabled state is keyed by). |
| 136 | - | s = strings.TrimSpace(s) | 144 | + | func dispatchPath(s string) (string, error) { return domain.ParseWorkflowPath(s) } |
| 137 | - | if s == "" { | |||
| 138 | - | return "", fmt.Errorf("%w: the workflow file is required", domain.ErrInvalid) | |||
| 139 | - | } | |||
| 140 | - | if !strings.Contains(s, "/") { | |||
| 141 | - | s = workflowDir + "/" + s | |||
| 142 | - | } | |||
| 143 | - | ext := strings.ToLower(path.Ext(s)) | |||
| 144 | - | if path.Dir(s) != workflowDir || (ext != ".yml" && ext != ".yaml") { | |||
| 145 | - | return "", fmt.Errorf("%w: %q is not a workflow file of %s", domain.ErrInvalid, s, workflowDir) | |||
| 146 | - | } | |||
| 147 | - | return s, nil | |||
| 148 | - | } | |||
| 149 | 145 | ||||
| 150 | // pinnedRef is what a dispatch resolved its ref to, once: the full name, the | 146 | // pinnedRef is what a dispatch resolved its ref to, once: the full name, the | ||
| 151 | // commit the run executes and the object the ref points to (the commit for a | 147 | // commit the run executes and the object the ref points to (the commit for a | ||
M internal/usecase/ci_schedule.go +16 -1
| @@ -329,7 +329,11 @@ func (c *CI) loadSchedules(ctx context.Context, repo domain.RepoName) (*schedule | |||
| 329 | 329 | return &scheduleEntry{}, nil // no repository, no default branch, nothing to schedule | |
| 330 | 330 | } | |
| 331 | 331 | e := &scheduleEntry{tip: tip} | |
| 332 | - | files, err := c.workflowFiles(ctx, repo, tip) | |
| 332 | + | // Every workflow, disabled or not: the cache is of the commit, not of the | |
| 333 | + | // state, which is read when a schedule fires (fireSchedule). A load that | |
| 334 | + | // raced with a disable or an enable would otherwise keep the wrong set of | |
| 335 | + | // workflows for as long as the tip does not move. | |
| 336 | + | files, err := c.listWorkflowFiles(ctx, repo, tip) | |
| 333 | 337 | if err != nil { | |
| 334 | 338 | return nil, err | |
| 335 | 339 | } | |
| @@ -394,6 +398,9 @@ func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time | |||
| 394 | 398 | if e, err = c.loadSchedules(ctx, repo); err != nil { | |
| 395 | 399 | return fmt.Errorf("read the schedules: %w", err) | |
| 396 | 400 | } | |
| 401 | + | if c.cfg.ScheduleLoaded != nil { | |
| 402 | + | c.cfg.ScheduleLoaded(repo) | |
| 403 | + | } | |
| 397 | 404 | c.sched.put(repo, e) | |
| 398 | 405 | } | |
| 399 | 406 | idx, specs := dueFor(e, minute) | |
| @@ -422,6 +429,11 @@ func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time | |||
| 422 | 429 | ||
| 423 | 430 | // fireSchedule starts the run of one scheduled workflow. | |
| 424 | 431 | func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *scheduleEntry, head domain.Commit, sw scheduledWorkflow, spec string) error { | |
| 432 | + | // The cache holds the disabled workflows too; the state is read now (and the | |
| 433 | + | // store checks it again when the run is stored). | |
| 434 | + | if off, err := c.workflowDisabled(ctx, repo.Name, sw.path); err != nil || off { | |
| 435 | + | return err | |
| 436 | + | } | |
| 425 | 437 | wf, perrs := parseWorkflow(sw.path, sw.yaml) | |
| 426 | 438 | if len(perrs) > 0 { | |
| 427 | 439 | return nil | |
| @@ -442,5 +454,8 @@ func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e * | |||
| 442 | 454 | return c.startInvalid(ctx, repo, t, facts, sw.path, name, problem.text) | |
| 443 | 455 | } | |
| 444 | 456 | _, err := c.launchRun(ctx, repo, t, facts, sw.path, string(sw.yaml), wf) | |
| 457 | + | if errors.Is(err, domain.ErrWorkflowDisabled) { | |
| 458 | + | return nil | |
| 459 | + | } | |
| 445 | 460 | return err | |
| 446 | 461 | } | |
| @@ -329,7 +329,11 @@ func (c *CI) loadSchedules(ctx context.Context, repo domain.RepoName) (*schedule | |||||
| 329 | return &scheduleEntry{}, nil // no repository, no default branch, nothing to schedule | 329 | return &scheduleEntry{}, nil // no repository, no default branch, nothing to schedule | ||
| 330 | } | 330 | } | ||
| 331 | e := &scheduleEntry{tip: tip} | 331 | e := &scheduleEntry{tip: tip} | ||
| 332 | - | files, err := c.workflowFiles(ctx, repo, tip) | 332 | + | // Every workflow, disabled or not: the cache is of the commit, not of the |
| 333 | + | // state, which is read when a schedule fires (fireSchedule). A load that | |||
| 334 | + | // raced with a disable or an enable would otherwise keep the wrong set of | |||
| 335 | + | // workflows for as long as the tip does not move. | |||
| 336 | + | files, err := c.listWorkflowFiles(ctx, repo, tip) | |||
| 333 | if err != nil { | 337 | if err != nil { | ||
| 334 | return nil, err | 338 | return nil, err | ||
| 335 | } | 339 | } | ||
| @@ -394,6 +398,9 @@ func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time | |||||
| 394 | if e, err = c.loadSchedules(ctx, repo); err != nil { | 398 | if e, err = c.loadSchedules(ctx, repo); err != nil { | ||
| 395 | return fmt.Errorf("read the schedules: %w", err) | 399 | return fmt.Errorf("read the schedules: %w", err) | ||
| 396 | } | 400 | } | ||
| 401 | + | if c.cfg.ScheduleLoaded != nil { | |||
| 402 | + | c.cfg.ScheduleLoaded(repo) | |||
| 403 | + | } | |||
| 397 | c.sched.put(repo, e) | 404 | c.sched.put(repo, e) | ||
| 398 | } | 405 | } | ||
| 399 | idx, specs := dueFor(e, minute) | 406 | idx, specs := dueFor(e, minute) | ||
| @@ -422,6 +429,11 @@ func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time | |||||
| 422 | 429 | ||||
| 423 | // fireSchedule starts the run of one scheduled workflow. | 430 | // fireSchedule starts the run of one scheduled workflow. | ||
| 424 | func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *scheduleEntry, head domain.Commit, sw scheduledWorkflow, spec string) error { | 431 | func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *scheduleEntry, head domain.Commit, sw scheduledWorkflow, spec string) error { | ||
| 432 | + | // The cache holds the disabled workflows too; the state is read now (and the | |||
| 433 | + | // store checks it again when the run is stored). | |||
| 434 | + | if off, err := c.workflowDisabled(ctx, repo.Name, sw.path); err != nil || off { | |||
| 435 | + | return err | |||
| 436 | + | } | |||
| 425 | wf, perrs := parseWorkflow(sw.path, sw.yaml) | 437 | wf, perrs := parseWorkflow(sw.path, sw.yaml) | ||
| 426 | if len(perrs) > 0 { | 438 | if len(perrs) > 0 { | ||
| 427 | return nil | 439 | return nil | ||
| @@ -442,5 +454,8 @@ func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e * | |||||
| 442 | return c.startInvalid(ctx, repo, t, facts, sw.path, name, problem.text) | 454 | return c.startInvalid(ctx, repo, t, facts, sw.path, name, problem.text) | ||
| 443 | } | 455 | } | ||
| 444 | _, err := c.launchRun(ctx, repo, t, facts, sw.path, string(sw.yaml), wf) | 456 | _, err := c.launchRun(ctx, repo, t, facts, sw.path, string(sw.yaml), wf) | ||
| 457 | + | if errors.Is(err, domain.ErrWorkflowDisabled) { | |||
| 458 | + | return nil | |||
| 459 | + | } | |||
| 445 | return err | 460 | return err | ||
| 446 | } | 461 | } | ||
M internal/usecase/ci_trigger.go +25 -3
| @@ -430,8 +430,25 @@ type workflowFile struct { | |||
| 430 | 430 | problem string | |
| 431 | 431 | } | |
| 432 | 432 | ||
| 433 | - | // workflowFiles reads .github/workflows/*.yml|*.yaml at the commit. | |
| 433 | + | // workflowFiles reads .github/workflows/*.yml|*.yaml at the commit, without the | |
| 434 | + | // workflows the repository disabled: every event that starts a run reads its | |
| 435 | + | // workflows here, so a disabled one starts none (ci_workflows.go). | |
| 434 | 436 | func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) { | |
| 437 | + | files, err := c.listWorkflowFiles(ctx, repo, commit) | |
| 438 | + | if err != nil || len(files) == 0 { | |
| 439 | + | return files, err | |
| 440 | + | } | |
| 441 | + | off, err := c.disabledPaths(ctx, repo) | |
| 442 | + | if err != nil || len(off) == 0 { | |
| 443 | + | return files, err | |
| 444 | + | } | |
| 445 | + | return slices.DeleteFunc(files, func(f workflowFile) bool { return slices.Contains(off, f.path) }), nil | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // listWorkflowFiles is workflowFiles with the disabled ones. The disabled state | |
| 449 | + | // is also enforced where a run is stored (CIStore.CreateRun), which is what | |
| 450 | + | // holds against a change made while a push is being planned. | |
| 451 | + | func (c *CI) listWorkflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) { | |
| 435 | 452 | dir, err := domain.ParseTreePath(workflowDir) | |
| 436 | 453 | if err != nil { | |
| 437 | 454 | return nil, err | |
| @@ -512,7 +529,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f | |||
| 512 | 529 | }}, | |
| 513 | 530 | } | |
| 514 | 531 | }) | |
| 515 | - | if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) { | |
| 532 | + | if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) || errors.Is(err, domain.ErrWorkflowDisabled) { | |
| 516 | 533 | return nil | |
| 517 | 534 | } | |
| 518 | 535 | if err != nil { | |
| @@ -534,11 +551,16 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun) | |||
| 534 | 551 | // job of the file, then plans the jobs that can start. | |
| 535 | 552 | func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error { | |
| 536 | 553 | _, err := c.launchRun(ctx, repo, t, f, wfPath, yaml, wf) | |
| 554 | + | if errors.Is(err, domain.ErrWorkflowDisabled) { | |
| 555 | + | return nil // disabled since it was read: no run (the store decides, atomically) | |
| 556 | + | } | |
| 537 | 557 | return err | |
| 538 | 558 | } | |
| 539 | 559 | ||
| 540 | 560 | // launchRun is startRun that also returns the run it created, as it was when | |
| 541 | - | // created (the zero run when the repository or the merge request is gone). | |
| 561 | + | // created (the zero run when the repository or the merge request is gone). A | |
| 562 | + | // workflow that is disabled when the run is stored gets none, whatever was | |
| 563 | + | // checked before: the error is domain.ErrWorkflowDisabled. | |
| 542 | 564 | func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) (domain.CIRun, error) { | |
| 543 | 565 | now := c.now() | |
| 544 | 566 | name := workflowName(wf, wfPath) | |
| @@ -430,8 +430,25 @@ type workflowFile struct { | |||||
| 430 | problem string | 430 | problem string | ||
| 431 | } | 431 | } | ||
| 432 | 432 | ||||
| 433 | - | // workflowFiles reads .github/workflows/*.yml|*.yaml at the commit. | 433 | + | // workflowFiles reads .github/workflows/*.yml|*.yaml at the commit, without the |
| 434 | + | // workflows the repository disabled: every event that starts a run reads its | |||
| 435 | + | // workflows here, so a disabled one starts none (ci_workflows.go). | |||
| 434 | func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) { | 436 | func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) { | ||
| 437 | + | files, err := c.listWorkflowFiles(ctx, repo, commit) | |||
| 438 | + | if err != nil || len(files) == 0 { | |||
| 439 | + | return files, err | |||
| 440 | + | } | |||
| 441 | + | off, err := c.disabledPaths(ctx, repo) | |||
| 442 | + | if err != nil || len(off) == 0 { | |||
| 443 | + | return files, err | |||
| 444 | + | } | |||
| 445 | + | return slices.DeleteFunc(files, func(f workflowFile) bool { return slices.Contains(off, f.path) }), nil | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // listWorkflowFiles is workflowFiles with the disabled ones. The disabled state | |||
| 449 | + | // is also enforced where a run is stored (CIStore.CreateRun), which is what | |||
| 450 | + | // holds against a change made while a push is being planned. | |||
| 451 | + | func (c *CI) listWorkflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) { | |||
| 435 | dir, err := domain.ParseTreePath(workflowDir) | 452 | dir, err := domain.ParseTreePath(workflowDir) | ||
| 436 | if err != nil { | 453 | if err != nil { | ||
| 437 | return nil, err | 454 | return nil, err | ||
| @@ -512,7 +529,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f | |||||
| 512 | }}, | 529 | }}, | ||
| 513 | } | 530 | } | ||
| 514 | }) | 531 | }) | ||
| 515 | - | if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) { | 532 | + | if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) || errors.Is(err, domain.ErrWorkflowDisabled) { |
| 516 | return nil | 533 | return nil | ||
| 517 | } | 534 | } | ||
| 518 | if err != nil { | 535 | if err != nil { | ||
| @@ -534,11 +551,16 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun) | |||||
| 534 | // job of the file, then plans the jobs that can start. | 551 | // job of the file, then plans the jobs that can start. | ||
| 535 | func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error { | 552 | func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error { | ||
| 536 | _, err := c.launchRun(ctx, repo, t, f, wfPath, yaml, wf) | 553 | _, err := c.launchRun(ctx, repo, t, f, wfPath, yaml, wf) | ||
| 554 | + | if errors.Is(err, domain.ErrWorkflowDisabled) { | |||
| 555 | + | return nil // disabled since it was read: no run (the store decides, atomically) | |||
| 556 | + | } | |||
| 537 | return err | 557 | return err | ||
| 538 | } | 558 | } | ||
| 539 | 559 | ||||
| 540 | // launchRun is startRun that also returns the run it created, as it was when | 560 | // launchRun is startRun that also returns the run it created, as it was when | ||
| 541 | - | // created (the zero run when the repository or the merge request is gone). | 561 | + | // created (the zero run when the repository or the merge request is gone). A |
| 562 | + | // workflow that is disabled when the run is stored gets none, whatever was | |||
| 563 | + | // checked before: the error is domain.ErrWorkflowDisabled. | |||
| 542 | func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) (domain.CIRun, error) { | 564 | func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) (domain.CIRun, error) { | ||
| 543 | now := c.now() | 565 | now := c.now() | ||
| 544 | name := workflowName(wf, wfPath) | 566 | name := workflowName(wf, wfPath) | ||
M internal/usecase/ci_workflowrun.go +1 -1
| @@ -244,7 +244,7 @@ func (c *CI) handleRunEvent(ctx context.Context, e runEvent) error { | |||
| 244 | 244 | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text)) | |
| 245 | 245 | continue | |
| 246 | 246 | } | |
| 247 | - | if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) { | |
| 247 | + | if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, domain.ErrWorkflowDisabled) { | |
| 248 | 248 | errs = append(errs, err) | |
| 249 | 249 | } | |
| 250 | 250 | } | |
| @@ -244,7 +244,7 @@ func (c *CI) handleRunEvent(ctx context.Context, e runEvent) error { | |||||
| 244 | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text)) | 244 | errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text)) | ||
| 245 | continue | 245 | continue | ||
| 246 | } | 246 | } | ||
| 247 | - | if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) { | 247 | + | if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, domain.ErrWorkflowDisabled) { |
| 248 | errs = append(errs, err) | 248 | errs = append(errs, err) | ||
| 249 | } | 249 | } | ||
| 250 | } | 250 | } | ||
A internal/usecase/ci_workflows.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 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. | |
| 33 | + | func (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. | |
| 42 | + | func (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. | |
| 56 | + | func (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. | |
| 104 | + | func (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. | |
| 114 | + | func (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. | |
| 119 | + | func (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 | + | ||
| 123 | + | func (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 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 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. | |||
| 33 | + | func (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. | |||
| 42 | + | func (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. | |||
| 56 | + | func (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. | |||
| 104 | + | func (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. | |||
| 114 | + | func (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. | |||
| 119 | + | func (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 | + | ||||
| 123 | + | func (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 | + | } | |||
M proto/ermite/storage/ci.proto +7 -0
| @@ -466,3 +466,10 @@ message CIConcurrencyMark { | |||
| 466 | 466 | // that a binary that predates it still cancels the entry. | |
| 467 | 467 | bool queue_full = 4; | |
| 468 | 468 | } | |
| 469 | + | ||
| 470 | + | // CIWorkflowDisabled is stored under "ciwfoff/<repo>/<workflow path>" (the path | |
| 471 | + | // is ".github/workflows/<file>"): the workflow is disabled and starts no run. | |
| 472 | + | // The file does not have to exist. It goes with the repository. | |
| 473 | + | message CIWorkflowDisabled { | |
| 474 | + | google.protobuf.Timestamp disabled_at = 1; | |
| 475 | + | } | |
| @@ -466,3 +466,10 @@ message CIConcurrencyMark { | |||||
| 466 | // that a binary that predates it still cancels the entry. | 466 | // that a binary that predates it still cancels the entry. | ||
| 467 | bool queue_full = 4; | 467 | bool queue_full = 4; | ||
| 468 | } | 468 | } | ||
| 469 | + | ||||
| 470 | + | // CIWorkflowDisabled is stored under "ciwfoff/<repo>/<workflow path>" (the path | |||
| 471 | + | // is ".github/workflows/<file>"): the workflow is disabled and starts no run. | |||
| 472 | + | // The file does not have to exist. It goes with the repository. | |||
| 473 | + | message CIWorkflowDisabled { | |||
| 474 | + | google.protobuf.Timestamp disabled_at = 1; | |||
| 475 | + | } | |||
M skills/ermite/CLI.md +11 -0
| @@ -62,6 +62,17 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||
| 62 | 62 | ||
| 63 | 63 | Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`. | |
| 64 | 64 | ||
| 65 | + | ## Workflows | |
| 66 | + | ||
| 67 | + | | Command | Scope | Notes | | |
| 68 | + | |---|---|---| | |
| 69 | + | | `workflow list <repo> [--json]` | `repo:read` for a private repository | the files of `.github/workflows` on the default branch with their state, and the disabled files that branch lacks | | |
| 70 | + | | `workflow disable <repo> <file> [--json]` | `repo:admin` | like `gh workflow disable`: no event (push, pull_request, pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it; runs already started go on; `<file>` is `release.yml` or `.github/workflows/release.yml` and need not exist, so it works on an empty repository before the first push; doing it twice is not an error | | |
| 71 | + | | `workflow enable <repo> <file> [--json]` | `repo:admin` | undoes `disable`; missed events are not replayed | | |
| 72 | + | | `workflow run <file> --repo R [--ref REF] [-f name=value]` | `ci:write` | starts a `workflow_dispatch` workflow; refused while the workflow is disabled | | |
| 73 | + | ||
| 74 | + | Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `name:` on the default branch, empty when the file is absent there), `state` (`enabled` or `disabled`). A token limited to branch prefixes cannot disable or enable. | |
| 75 | + | ||
| 65 | 76 | ## Events | |
| 66 | 77 | ||
| 67 | 78 | `ermite watch [repo] [--json]` runs until interrupted, reconnects and resumes after the last event. With `--json` it prints one object per line with `id`, `time`, `type` and type-specific fields. | |
| @@ -62,6 +62,17 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl | |||||
| 62 | 62 | ||||
| 63 | Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`. | 63 | Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`. | ||
| 64 | 64 | ||||
| 65 | + | ## Workflows | |||
| 66 | + | ||||
| 67 | + | | Command | Scope | Notes | | |||
| 68 | + | |---|---|---| | |||
| 69 | + | | `workflow list <repo> [--json]` | `repo:read` for a private repository | the files of `.github/workflows` on the default branch with their state, and the disabled files that branch lacks | | |||
| 70 | + | | `workflow disable <repo> <file> [--json]` | `repo:admin` | like `gh workflow disable`: no event (push, pull_request, pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it; runs already started go on; `<file>` is `release.yml` or `.github/workflows/release.yml` and need not exist, so it works on an empty repository before the first push; doing it twice is not an error | | |||
| 71 | + | | `workflow enable <repo> <file> [--json]` | `repo:admin` | undoes `disable`; missed events are not replayed | | |||
| 72 | + | | `workflow run <file> --repo R [--ref REF] [-f name=value]` | `ci:write` | starts a `workflow_dispatch` workflow; refused while the workflow is disabled | | |||
| 73 | + | ||||
| 74 | + | Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `name:` on the default branch, empty when the file is absent there), `state` (`enabled` or `disabled`). A token limited to branch prefixes cannot disable or enable. | |||
| 75 | + | ||||
| 65 | ## Events | 76 | ## Events | ||
| 66 | 77 | ||||
| 67 | `ermite watch [repo] [--json]` runs until interrupted, reconnects and resumes after the last event. With `--json` it prints one object per line with `id`, `time`, `type` and type-specific fields. | 78 | `ermite watch [repo] [--json]` runs until interrupted, reconnects and resumes after the last event. With `--json` it prints one object per line with `id`, `time`, `type` and type-specific fields. | ||