detached 3483340

ci.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"slices"
10	"strconv"
11	"strings"
12	"time"
13
14	kv "bnema.dev/ermite-kv"
15	"google.golang.org/protobuf/proto"
16
17	storagepb "bnema.dev/ermite/gen/ermite/storage"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22// Key layout of CI. Repository names hold no '/', so a prefix ending in "/"
23// never matches another repository; ids are zero-padded so that keys sort
24// numerically. Job records are keyed by run, attempt of the run and job id.
25//
26//	run/<repo>/<id:020d>                 CIRun
27//	runsrc/<id:020d>                     CIRunSource
28//	job/<run:020d>/<attempt:010d>/<id>   CIJob
29//	check/<repo>/<sha>/<check id>        CICheck
30//	runseq                               Sequence: last run id
31//	runnum/<repo>/<workflow path>        Sequence: last run number
32//	runner/<id>, runnername/<name>, runnertoken/<hash>
33//
34// Indexes, derived from the job and run records by every write:
35//
36//	ciopen/<id:020d>                     run not completed (value: repo)
37//	ciready/<ready_at:020d>/<job key>    job waiting for a runner
38//	ciactive/<job key>                   job assigned or running
39//	jobtoken/<hash>                      job token (value: job key)
40//	concur/<repo>/<key(group)>           concurrency group (ci_concurrency.go)
41//	cireftip/<repo>/<ref>                who moves a branch or tag to its object (ci_tip.go)
42//
43// The job repository tokens and the action resolutions are in ci_token.go, the
44// annotations in ci_annotation.go.
45const (
46	runPrefix     = "run/"
47	runSrcPrefix  = "runsrc/"
48	jobPrefix     = "job/"
49	checkPrefix   = "check/"
50	runSeqKey     = "runseq"
51	runNumPrefix  = "runnum/"
52	runnerPrefix  = "runner/"
53	runnerName    = "runnername/"
54	runnerTokPref = "runnertoken/"
55	openPrefix    = "ciopen/"
56	readyPrefix   = "ciready/"
57	activePrefix  = "ciactive/"
58	jobTokPrefix  = "jobtoken/"
59	idFormat      = "%020d"
60)
61
62// CI is the ermite-kv backed ports.CIStore. It shares the database of the
63// Store it comes from.
64type CI struct {
65	db *kv.DB
66}
67
68var _ ports.CIStore = (*CI)(nil)
69
70// CI returns the CI store over the same database. It is valid until the Store
71// is closed.
72func (s *Store) CI() *CI { return &CI{db: s.db} }
73
74func runKey(repo domain.RepoName, id int64) []byte {
75	return []byte(runPrefix + repo.String() + "/" + fmt.Sprintf(idFormat, id))
76}
77func runsPrefix(repo domain.RepoName) string { return runPrefix + repo.String() + "/" }
78func runSrcKey(id int64) []byte              { return []byte(runSrcPrefix + fmt.Sprintf(idFormat, id)) }
79func openKey(id int64) []byte                { return []byte(openPrefix + fmt.Sprintf(idFormat, id)) }
80func jobsPrefix(runID int64) string          { return jobPrefix + fmt.Sprintf(idFormat, runID) + "/" }
81func jobAttemptPrefix(runID int64, attempt int32) string {
82	return jobsPrefix(runID) + fmt.Sprintf("%010d", attempt) + "/"
83}
84func jobRef(runID int64, attempt int32, id string) string {
85	return jobAttemptPrefix(runID, attempt) + id
86}
87func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) }
88func checksPrefix(repo domain.RepoName, sha string) string {
89	return checkPrefix + repo.String() + "/" + sha + "/"
90}
91func checkKey(repo domain.RepoName, sha, id string) []byte {
92	return []byte(checksPrefix(repo, sha) + id)
93}
94func runNumKey(repo domain.RepoName, workflow string) []byte {
95	return []byte(runNumPrefix + repo.String() + "/" + workflow)
96}
97func readyKey(at time.Time, ref string) []byte {
98	return []byte(readyPrefix + fmt.Sprintf(idFormat, at.UnixNano()) + "/" + ref)
99}
100func activeKey(ref string) []byte { return []byte(activePrefix + ref) }
101func jobTokenKey(h domain.TokenHash) []byte {
102	return []byte(jobTokPrefix + h.String())
103}
104
105// --- encoding ---
106
107func encodeStatus(s domain.CIStatus) storagepb.CIStatus {
108	switch s {
109	case domain.CIQueued:
110		return storagepb.CIStatus_CI_STATUS_QUEUED
111	case domain.CIInProgress:
112		return storagepb.CIStatus_CI_STATUS_IN_PROGRESS
113	case domain.CICompleted:
114		return storagepb.CIStatus_CI_STATUS_COMPLETED
115	case domain.CIPending:
116		return storagepb.CIStatus_CI_STATUS_PENDING
117	}
118	return storagepb.CIStatus_CI_STATUS_UNSPECIFIED
119}
120
121func decodeStatus(s storagepb.CIStatus) domain.CIStatus {
122	switch s {
123	case storagepb.CIStatus_CI_STATUS_IN_PROGRESS:
124		return domain.CIInProgress
125	case storagepb.CIStatus_CI_STATUS_COMPLETED:
126		return domain.CICompleted
127	case storagepb.CIStatus_CI_STATUS_PENDING:
128		return domain.CIPending
129	}
130	return domain.CIQueued
131}
132
133var conclusions = []struct {
134	d domain.CIConclusion
135	p storagepb.CIConclusion
136}{
137	{domain.CISuccess, storagepb.CIConclusion_CI_CONCLUSION_SUCCESS},
138	{domain.CIFailure, storagepb.CIConclusion_CI_CONCLUSION_FAILURE},
139	{domain.CICancelled, storagepb.CIConclusion_CI_CONCLUSION_CANCELLED},
140	{domain.CISkipped, storagepb.CIConclusion_CI_CONCLUSION_SKIPPED},
141	{domain.CITimedOut, storagepb.CIConclusion_CI_CONCLUSION_TIMED_OUT},
142	{domain.CINeutral, storagepb.CIConclusion_CI_CONCLUSION_NEUTRAL},
143	{domain.CIActionRequired, storagepb.CIConclusion_CI_CONCLUSION_ACTION_REQUIRED},
144}
145
146func encodeConclusion(c domain.CIConclusion) storagepb.CIConclusion {
147	for _, e := range conclusions {
148		if e.d == c {
149			return e.p
150		}
151	}
152	return storagepb.CIConclusion_CI_CONCLUSION_UNSPECIFIED
153}
154
155func decodeConclusion(c storagepb.CIConclusion) domain.CIConclusion {
156	for _, e := range conclusions {
157		if e.p == c {
158			return e.d
159		}
160	}
161	return domain.CINoConclusion
162}
163
164var phases = []struct {
165	d domain.CIJobPhase
166	p storagepb.CIJobPhase
167}{
168	{domain.CIPhaseWaiting, storagepb.CIJobPhase_CI_JOB_PHASE_WAITING},
169	{domain.CIPhaseReady, storagepb.CIJobPhase_CI_JOB_PHASE_READY},
170	{domain.CIPhaseAssigned, storagepb.CIJobPhase_CI_JOB_PHASE_ASSIGNED},
171	{domain.CIPhaseRunning, storagepb.CIJobPhase_CI_JOB_PHASE_RUNNING},
172	{domain.CIPhaseEnded, storagepb.CIJobPhase_CI_JOB_PHASE_ENDED},
173}
174
175func encodePhase(p domain.CIJobPhase) (storagepb.CIJobPhase, error) {
176	for _, e := range phases {
177		if e.d == p {
178			return e.p, nil
179		}
180	}
181	return 0, fmt.Errorf("invalid job phase %q", p)
182}
183
184func decodePhase(p storagepb.CIJobPhase) domain.CIJobPhase {
185	for _, e := range phases {
186		if e.p == p {
187			return e.d
188		}
189	}
190	return domain.CIPhaseWaiting
191}
192
193var isolations = []struct {
194	d domain.CIIsolation
195	p storagepb.CIIsolation
196}{
197	{domain.CIIsolationVM, storagepb.CIIsolation_CI_ISOLATION_VM},
198	{domain.CIIsolationContainerSysbox, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_SYSBOX},
199	{domain.CIIsolationContainerPrivileged, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_PRIVILEGED},
200}
201
202func encodeIsolation(i domain.CIIsolation) storagepb.CIIsolation {
203	for _, e := range isolations {
204		if e.d == i {
205			return e.p
206		}
207	}
208	return storagepb.CIIsolation_CI_ISOLATION_UNSPECIFIED
209}
210
211func decodeIsolation(i storagepb.CIIsolation) domain.CIIsolation {
212	for _, e := range isolations {
213		if e.p == i {
214			return e.d
215		}
216	}
217	return domain.CIIsolationNone
218}
219
220var stepStates = []struct {
221	d domain.CIStepState
222	p storagepb.CIStepState
223}{
224	{domain.CIStepPending, storagepb.CIStepState_CI_STEP_STATE_PENDING},
225	{domain.CIStepInProgress, storagepb.CIStepState_CI_STEP_STATE_IN_PROGRESS},
226	{domain.CIStepCompleted, storagepb.CIStepState_CI_STEP_STATE_COMPLETED},
227}
228
229func encodeStepState(s domain.CIStepState) storagepb.CIStepState {
230	for _, e := range stepStates {
231		if e.d == s {
232			return e.p
233		}
234	}
235	return storagepb.CIStepState_CI_STEP_STATE_UNSPECIFIED
236}
237
238func decodeStepState(s storagepb.CIStepState) domain.CIStepState {
239	for _, e := range stepStates {
240		if e.p == s {
241			return e.d
242		}
243	}
244	return domain.CIStepPending
245}
246
247var cancelReasons = []struct {
248	d domain.CICancelReason
249	p storagepb.CICancelReason
250}{
251	{domain.CICancelUser, storagepb.CICancelReason_CI_CANCEL_REASON_USER},
252	{domain.CICancelFailFast, storagepb.CICancelReason_CI_CANCEL_REASON_FAIL_FAST},
253	{domain.CICancelTimeout, storagepb.CICancelReason_CI_CANCEL_REASON_TIMEOUT},
254}
255
256func encodeCancelReason(r domain.CICancelReason) storagepb.CICancelReason {
257	for _, e := range cancelReasons {
258		if e.d == r {
259			return e.p
260		}
261	}
262	return storagepb.CICancelReason_CI_CANCEL_REASON_UNSPECIFIED
263}
264
265func decodeCancelReason(r storagepb.CICancelReason) domain.CICancelReason {
266	for _, e := range cancelReasons {
267		if e.p == r {
268			return e.d
269		}
270	}
271	return domain.CICancelNone
272}
273
274func encodeRun(r domain.CIRun) ([]byte, error) {
275	if r.Repo.IsZero() {
276		return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
277	}
278	return mustMarshal(&storagepb.CIRun{
279		Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
280		WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: r.Name, Title: r.Title,
281		Event: r.Event, Ref: r.Ref, Sha: r.Sha, HeadSha: r.HeadSha, PullNumber: r.PullNumber, Actor: r.Actor,
282		Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
283		CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
284		CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt), Trusted: r.Trusted,
285		Concurrency: encodeConcurrency(r.Concurrency),
286	}), nil
287}
288
289func decodeRun(item kv.Item) (domain.CIRun, error) {
290	var pb storagepb.CIRun
291	if err := proto.Unmarshal(item.Value, &pb); err != nil {
292		return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
293	}
294	repo, err := domain.ParseRepoName(pb.GetRepo())
295	if err != nil {
296		return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
297	}
298	name := pb.GetName()
299	if name == "" {
300		name = pb.GetWorkflowName() // stored before the field existed
301	}
302	return domain.CIRun{
303		Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),
304		WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Name: name, Title: pb.GetTitle(),
305		Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), HeadSha: pb.GetHeadSha(), PullNumber: pb.GetPullNumber(), Actor: pb.GetActor(),
306		Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
307		CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
308		CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
309		Trusted: pb.GetTrusted(), Concurrency: decodeConcurrency(pb.GetConcurrency()), Version: item.Version,
310	}, nil
311}
312
313// encodeJob serializes a job for storage.
314//
315//hexcheck:ignore suspicious-business-logic-in-adapter encodes a job as protobuf, rejecting an empty repository and omitting an absent token hash
316func encodeJob(j domain.CIJob) ([]byte, error) {
317	if j.Repo.IsZero() {
318		return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
319	}
320	phase, err := encodePhase(j.Phase)
321	if err != nil {
322		return nil, err
323	}
324	steps := make([]*storagepb.CIStep, 0, len(j.Steps))
325	for _, s := range j.Steps {
326		steps = append(steps, &storagepb.CIStep{
327			Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion),
328			StartedAt: encodeTime(s.StartedAt), CompletedAt: encodeTime(s.CompletedAt),
329			LogStart: s.LogStart, HasLines: s.HasLines, Outcome: encodeConclusion(s.Outcome),
330		})
331	}
332	pb := &storagepb.CIJob{
333		Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(),
334		Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error,
335		Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName,
336		Isolation: encodeIsolation(j.Isolation), Steps: steps,
337		CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),
338		Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
339		TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
340		Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
341		AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
342		TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
343		CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment, Permissions: j.Permissions,
344		Concurrency: encodeConcurrency(j.Concurrency), Scope: j.Scope, CallId: j.CallID, Call: encodeCall(j.Call),
345	}
346	if j.HasToken() {
347		pb.TokenHash = j.TokenHash[:]
348	}
349	return mustMarshal(pb), nil
350}
351
352func decodeJob(item kv.Item) (domain.CIJob, error) {
353	var pb storagepb.CIJob
354	if err := proto.Unmarshal(item.Value, &pb); err != nil {
355		return domain.CIJob{}, fmt.Errorf("decode job: %w", err)
356	}
357	repo, err := domain.ParseRepoName(pb.GetRepo())
358	if err != nil {
359		return domain.CIJob{}, fmt.Errorf("decode job: %w", err)
360	}
361	j := domain.CIJob{
362		ID: pb.GetId(), RunID: pb.GetRunId(), Attempt: pb.GetAttempt(), RunAttempt: pb.GetRunAttempt(), Repo: repo,
363		Key: pb.GetKey(), Name: pb.GetName(), Phase: decodePhase(pb.GetPhase()), Conclusion: decodeConclusion(pb.GetConclusion()),
364		Error: pb.GetError(), Labels: pb.GetLabels(), Needs: pb.GetNeeds(), RunnerID: pb.GetRunnerId(), RunnerName: pb.GetRunnerName(),
365		Isolation: decodeIsolation(pb.GetIsolation()),
366		CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
367		Index: int(pb.GetIndex()), Combo: int(pb.GetCombo()), Planned: pb.GetPlanned(), MatrixJSON: pb.GetMatrixJson(), ContextsJSON: pb.GetContextsJson(),
368		ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(), ContinueOnError: pb.GetContinueOnError(),
369		MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(),
370		TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
371		AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
372		CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
373		Environment: pb.GetEnvironment(), Permissions: pb.GetPermissions(), Concurrency: decodeConcurrency(pb.GetConcurrency()), Version: item.Version,
374		Scope: pb.GetScope(), CallID: pb.GetCallId(), Call: decodeCall(pb.GetCall()),
375	}
376	j.CancelReason = decodeCancelReason(pb.GetCancelReason())
377	copy(j.TokenHash[:], pb.GetTokenHash())
378	for _, s := range pb.GetSteps() {
379		j.Steps = append(j.Steps, domain.CIStep{
380			Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()),
381			StartedAt: decodeTime(s.GetStartedAt()), CompletedAt: decodeTime(s.GetCompletedAt()),
382			LogStart: s.GetLogStart(), HasLines: s.GetHasLines(), Outcome: decodeConclusion(s.GetOutcome()),
383		})
384	}
385	return j, nil
386}
387
388func encodeCheck(c domain.CICheck) []byte {
389	return mustMarshal(&storagepb.CICheck{
390		Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status),
391		Conclusion: encodeConclusion(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt,
392		WorkflowName: c.WorkflowName, CreatedAt: encodeTime(c.CreatedAt), StartedAt: encodeTime(c.StartedAt),
393		CompletedAt: encodeTime(c.CompletedAt),
394	})
395}
396
397func decodeCheck(item kv.Item) (domain.CICheck, error) {
398	var pb storagepb.CICheck
399	if err := proto.Unmarshal(item.Value, &pb); err != nil {
400		return domain.CICheck{}, fmt.Errorf("decode check: %w", err)
401	}
402	repo, err := domain.ParseRepoName(pb.GetRepo())
403	if err != nil {
404		return domain.CICheck{}, fmt.Errorf("decode check: %w", err)
405	}
406	return domain.CICheck{
407		ID: pb.GetId(), Repo: repo, Sha: pb.GetSha(), Name: pb.GetName(), Status: decodeStatus(pb.GetStatus()),
408		Conclusion: decodeConclusion(pb.GetConclusion()), RunID: pb.GetRunId(), JobID: pb.GetJobId(), Attempt: pb.GetAttempt(),
409		WorkflowName: pb.GetWorkflowName(), CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()),
410		CompletedAt: decodeTime(pb.GetCompletedAt()), Version: item.Version,
411	}, nil
412}
413
414func encodeRunner(r domain.CIRunner) []byte {
415	return mustMarshal(&storagepb.CIRunner{
416		Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: encodeIsolation(r.Isolation), Version: r.Version,
417		Arch: r.Arch, Os: r.OS, RegisteredAt: encodeTime(r.RegisteredAt), TokenHash: r.TokenHash[:],
418	})
419}
420
421func decodeRunner(val []byte) (domain.CIRunner, error) {
422	var pb storagepb.CIRunner
423	if err := proto.Unmarshal(val, &pb); err != nil {
424		return domain.CIRunner{}, fmt.Errorf("decode runner: %w", err)
425	}
426	r := domain.CIRunner{
427		ID: pb.GetId(), Name: pb.GetName(), Labels: pb.GetLabels(), Isolation: decodeIsolation(pb.GetIsolation()),
428		Version: pb.GetVersion(), Arch: pb.GetArch(), OS: pb.GetOs(), RegisteredAt: decodeTime(pb.GetRegisteredAt()),
429	}
430	copy(r.TokenHash[:], pb.GetTokenHash())
431	return r, nil
432}
433
434// --- writes ---
435
436// stage adds the preconditions and writes of one record to the batch.
437func stage(b *kv.Batch, key, val []byte, version uint64) {
438	if version == 0 {
439		b.RequireAbsent(key)
440	} else {
441		b.RequireVersion(key, version)
442	}
443	b.Put(key, val)
444}
445
446// runIndexes stages the open-run index of the run.
447func runIndexes(b *kv.Batch, r domain.CIRun) {
448	if r.Status == domain.CICompleted {
449		b.Delete(openKey(r.ID))
450		return
451	}
452	b.Put(openKey(r.ID), []byte(r.Repo.String()))
453}
454
455// jobIndexes stages the ready, active and token indexes of the job. old is
456// the job as stored before, if any: its token index goes when the token
457// changed.
458func (c *CI) jobIndexes(b *kv.Batch, j domain.CIJob, old *domain.CIJob) {
459	ref := jobRef(j.RunID, j.RunAttempt, j.ID)
460	if j.Phase == domain.CIPhaseReady {
461		b.Put(readyKey(j.ReadyAt, ref), nil)
462	} else {
463		b.Delete(readyKey(j.ReadyAt, ref))
464	}
465	if old != nil && old.Phase == domain.CIPhaseReady && (j.Phase != domain.CIPhaseReady || !old.ReadyAt.Equal(j.ReadyAt)) {
466		b.Delete(readyKey(old.ReadyAt, ref))
467	}
468	if j.Active() {
469		b.Put(activeKey(ref), nil)
470	} else {
471		b.Delete(activeKey(ref))
472	}
473	if old != nil && old.HasToken() && old.TokenHash != j.TokenHash {
474		b.Delete(jobTokenKey(old.TokenHash))
475	}
476	if j.HasToken() {
477		b.Put(jobTokenKey(j.TokenHash), []byte(ref))
478	}
479}
480
481// guardRepo is guardRepoKey on the database of the CI store, for the writes that
482// create keys under a repository.
483func (c *CI) guardRepo(ctx context.Context, b *kv.Batch, repo domain.RepoName) error {
484	return guardRepoKey(ctx, c.db, b, repo)
485}
486
487// stageRecords stages the writes of w. guarded collects the repositories that
488// need guarding.
489func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (guarded map[domain.RepoName]bool, err error) {
490	guarded = map[domain.RepoName]bool{}
491	for _, r := range w.Runs {
492		val, err := encodeRun(r)
493		if err != nil {
494			return nil, err
495		}
496		stage(b, runKey(r.Repo, r.ID), val, r.Version)
497		runIndexes(b, r)
498		if r.Version == 0 {
499			guarded[r.Repo] = true
500		}
501	}
502	for _, j := range w.Jobs {
503		val, err := encodeJob(j)
504		if err != nil {
505			return nil, err
506		}
507		key := jobKey(j.RunID, j.RunAttempt, j.ID)
508		var old *domain.CIJob
509		if j.Version != 0 {
510			item, ok := c.db.Get(ctx, key)
511			if !ok {
512				return nil, domain.ErrConflict
513			}
514			if o, err := decodeJob(item); err == nil {
515				old = &o
516			}
517		}
518		stage(b, key, val, j.Version)
519		c.jobIndexes(b, j, old)
520		if j.Version == 0 {
521			guarded[j.Repo] = true
522		}
523	}
524	c.stageRepoTokens(ctx, b, w, guarded)
525	c.stageAnnotations(ctx, b, w, guarded)
526	// Checks are derived from the jobs, written in the same batch and under
527	// the lock of their run: they are upserted without a precondition.
528	for _, ck := range w.Checks {
529		b.Put(checkKey(ck.Repo, ck.Sha, ck.ID), encodeCheck(ck))
530	}
531	return guarded, nil
532}
533
534// Apply writes the records in one batch.
535func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) {
536	var b kv.Batch
537	guarded, err := c.stageRecords(ctx, &b, w)
538	if err != nil {
539		return 0, err
540	}
541	for repo := range guarded {
542		if err := c.guardRepo(ctx, &b, repo); err != nil {
543			return 0, err
544		}
545	}
546	v, err := c.db.Apply(ctx, &b)
547	if errors.Is(err, kv.ErrConflict) {
548		return 0, domain.ErrConflict
549	}
550	if err != nil {
551		return 0, fmt.Errorf("write CI records: %w", err)
552	}
553	return v, nil
554}
555
556func (c *CI) readSeq(ctx context.Context, key []byte) (last int64, item kv.Item, ok bool, err error) {
557	item, ok = c.db.Get(ctx, key)
558	if !ok {
559		return 0, kv.Item{}, false, nil
560	}
561	var seq storagepb.Sequence
562	if err := proto.Unmarshal(item.Value, &seq); err != nil {
563		return 0, kv.Item{}, false, fmt.Errorf("decode sequence %s: %w", key, err)
564	}
565	return seq.GetLast(), item, true, nil
566}
567
568func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) {
569	if ok {
570		b.RequireVersion(key, item.Version)
571	} else {
572		b.RequireAbsent(key)
573	}
574	b.Put(key, mustMarshal(&storagepb.Sequence{Last: last}))
575}
576
577const createRunTries = 20
578
579// CreateRun takes the next run id and number and stores the draft. The batch
580// requires the workflow's disabled key absent, so a disable that races the
581// caller's check fails the batch and is reported as domain.ErrWorkflowDisabled.
582//
583//hexcheck:ignore suspicious-business-logic-in-adapter atomic precondition only: maps a conflict on the disabled-workflow key to its domain error; the usecase decides
584func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
585	offKey := workflowOffKey(repo, workflowPath)
586	for range createRunTries {
587		lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
588		if err != nil {
589			return ports.CIRunDraft{}, err
590		}
591		numKey := runNumKey(repo, workflowPath)
592		lastNum, numItem, numOK, err := c.readSeq(ctx, numKey)
593		if err != nil {
594			return ports.CIRunDraft{}, err
595		}
596		draft := build(lastID+1, lastNum+1)
597		var b kv.Batch
598		stageSeq(&b, []byte(runSeqKey), idItem, idOK, lastID+1)
599		stageSeq(&b, numKey, numItem, numOK, lastNum+1)
600		w := ports.CIWrite{Runs: []domain.CIRun{draft.Run}, Jobs: draft.Jobs, Checks: draft.Checks}
601		guarded, err := c.stageRecords(ctx, &b, w)
602		if err != nil {
603			return ports.CIRunDraft{}, err
604		}
605		src := mustMarshal(&storagepb.CIRunSource{
606			RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,
607			PusherKind: string(draft.Source.Pusher.Kind), PusherTokenId: draft.Source.Pusher.TokenID,
608		})
609		b.RequireAbsent(runSrcKey(draft.Run.ID))
610		b.Put(runSrcKey(draft.Run.ID), src)
611		b.RequireAbsent(offKey)
612		for r := range guarded {
613			if err := c.guardRepo(ctx, &b, r); err != nil {
614				return ports.CIRunDraft{}, err
615			}
616		}
617		v, err := c.db.Apply(ctx, &b)
618		if errors.Is(err, kv.ErrConflict) {
619			// Translate a failed disabled-workflow precondition; other conflicts retry.
620			if _, off := c.db.Get(ctx, offKey); off {
621				return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
622			}
623			continue
624		}
625		if err != nil {
626			return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, err)
627		}
628		draft.Run.Version = v
629		for i := range draft.Jobs {
630			draft.Jobs[i].Version = v
631		}
632		for i := range draft.Checks {
633			draft.Checks[i].Version = v
634		}
635		return draft, nil
636	}
637	return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, domain.ErrConflict)
638}
639
640// --- reads ---
641
642// GetRun reads run/<repo>/<id>.
643func (c *CI) GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) {
644	item, ok := c.db.Get(ctx, runKey(repo, id))
645	if !ok || id < 1 {
646		return domain.CIRun{}, domain.ErrNotFound
647	}
648	return decodeRun(item)
649}
650
651// GetRunSource reads runsrc/<id>.
652func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) {
653	item, ok := c.db.Get(ctx, runSrcKey(runID))
654	if !ok || runID < 1 {
655		return domain.CIRunSource{}, domain.ErrNotFound
656	}
657	var pb storagepb.CIRunSource
658	if err := proto.Unmarshal(item.Value, &pb); err != nil {
659		return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
660	}
661	repo, err := domain.ParseRepoName(pb.GetRepo())
662	if err != nil {
663		return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
664	}
665	return domain.CIRunSource{
666		RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson(),
667		Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()},
668	}, nil
669}
670
671// ListRuns returns the runs of the repository, newest first.
672func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) {
673	start := []byte(runsPrefix(repo))
674	end := []byte(runPrefix + repo.String() + "0") // '/' + 1
675	if before > 0 {
676		end = runKey(repo, before)
677	}
678	// The snapshot iterates in key order only: keep the last limit items in a
679	// ring and decode only those, so the cost of a page does not grow with the
680	// decoded history of the repository.
681	type entry struct {
682		key  []byte
683		item kv.Item
684	}
685	var ring []entry
686	next := 0
687	for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {
688		if limit <= 0 || len(ring) < limit {
689			ring = append(ring, entry{key, item})
690			continue
691		}
692		ring[next] = entry{key, item}
693		next = (next + 1) % limit
694	}
695	ordered := append(ring[next:len(ring):len(ring)], ring[:next]...)
696	all := make([]domain.CIRun, 0, len(ordered))
697	for i := len(ordered) - 1; i >= 0; i-- {
698		r, err := decodeRun(ordered[i].item)
699		if err != nil {
700			return nil, fmt.Errorf("decode %s: %w", ordered[i].key, err)
701		}
702		all = append(all, r)
703	}
704	return all, nil
705}
706
707// RunIDs returns the ids of the runs of the repository.
708func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) {
709	var ids []int64
710	prefix := runsPrefix(repo)
711	for key := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
712		id, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
713		if err != nil {
714			return nil, fmt.Errorf("decode %s: %w", key, err)
715		}
716		ids = append(ids, id)
717	}
718	return ids, nil
719}
720
721// ListOpenRuns returns the runs that have not completed.
722func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) {
723	snap := c.db.Snapshot(ctx)
724	var out []domain.CIRun
725	for key, item := range snap.Prefix(ctx, []byte(openPrefix)) {
726		id, err := strconv.ParseInt(strings.TrimPrefix(string(key), openPrefix), 10, 64)
727		if err != nil {
728			return nil, fmt.Errorf("decode %s: %w", key, err)
729		}
730		repo, err := domain.ParseRepoName(string(item.Value))
731		if err != nil {
732			return nil, err
733		}
734		it, ok := snap.Get(ctx, runKey(repo, id))
735		if !ok {
736			continue
737		}
738		r, err := decodeRun(it)
739		if err != nil {
740			return nil, err
741		}
742		out = append(out, r)
743	}
744	return out, nil
745}
746
747// GetJob reads one job record.
748func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
749	item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID))
750	if !ok {
751		return domain.CIJob{}, domain.ErrNotFound
752	}
753	return decodeJob(item)
754}
755
756// ListJobs returns the jobs of a run at an attempt, ordered by index then
757// combination.
758func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) {
759	var out []domain.CIJob
760	for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) {
761		j, err := decodeJob(item)
762		if err != nil {
763			return nil, fmt.Errorf("decode %s: %w", key, err)
764		}
765		out = append(out, j)
766	}
767	slices.SortFunc(out, func(a, b domain.CIJob) int {
768		if a.Index != b.Index {
769			return a.Index - b.Index
770		}
771		return a.Combo - b.Combo
772	})
773	return out, nil
774}
775
776func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domain.CIJob, error) {
777	item, ok := snap.Get(ctx, []byte(ref))
778	if !ok {
779		return domain.CIJob{}, domain.ErrNotFound
780	}
781	return decodeJob(item)
782}
783
784// FindJobByToken resolves the token index, then reads the job.
785func (c *CI) FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error) {
786	snap := c.db.Snapshot(ctx)
787	idx, ok := snap.Get(ctx, jobTokenKey(hash))
788	if !ok {
789		return domain.CIJob{}, domain.ErrNotFound
790	}
791	j, err := c.jobByRef(snap, ctx, string(idx.Value))
792	if err != nil {
793		return domain.CIJob{}, err
794	}
795	if j.TokenHash != hash || (!j.TokenValidUntil.IsZero() && !now.Before(j.TokenValidUntil)) {
796		return domain.CIJob{}, domain.ErrNotFound
797	}
798	return j, nil
799}
800
801// ListReadyJobs returns the jobs waiting for a runner, oldest first.
802func (c *CI) ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error) {
803	snap := c.db.Snapshot(ctx)
804	var out []domain.CIJob
805	for key := range snap.Prefix(ctx, []byte(readyPrefix)) {
806		ref := string(key)[len(readyPrefix)+len(fmt.Sprintf(idFormat, 0))+1:]
807		j, err := c.jobByRef(snap, ctx, ref)
808		if errors.Is(err, domain.ErrNotFound) {
809			continue
810		}
811		if err != nil {
812			return nil, err
813		}
814		out = append(out, j)
815		if limit > 0 && len(out) >= limit {
816			break
817		}
818	}
819	return out, nil
820}
821
822// ListActiveJobs returns the jobs given to a runner and not over.
823func (c *CI) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) {
824	snap := c.db.Snapshot(ctx)
825	var out []domain.CIJob
826	for key := range snap.Prefix(ctx, []byte(activePrefix)) {
827		j, err := c.jobByRef(snap, ctx, strings.TrimPrefix(string(key), activePrefix))
828		if errors.Is(err, domain.ErrNotFound) {
829			continue
830		}
831		if err != nil {
832			return nil, err
833		}
834		out = append(out, j)
835	}
836	return out, nil
837}
838
839// ListChecks returns the checks of a commit ordered by creation time then
840// name.
841func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error) {
842	var out []domain.CICheck
843	for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(checksPrefix(repo, sha))) {
844		ck, err := decodeCheck(item)
845		if err != nil {
846			return nil, false, fmt.Errorf("decode %s: %w", key, err)
847		}
848		out = append(out, ck)
849	}
850	slices.SortStableFunc(out, func(a, b domain.CICheck) int {
851		if d := a.CreatedAt.Compare(b.CreatedAt); d != 0 {
852			return d
853		}
854		return strings.Compare(a.Name, b.Name)
855	})
856	more := limit > 0 && len(out) > limit
857	if more {
858		out = out[:limit]
859	}
860	return out, more, nil
861}
862
863// --- retention and deletion ---
864
865// runKeys lists every key of a run except its record: source, jobs, indexes.
866func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
867	keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
868	keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
869	keys = append(keys, annotationKeys(ctx, snap, runID)...)
870	for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
871		keys = append(keys, slices.Clone(key))
872		if j, err := decodeJob(item); err == nil {
873			ref := jobRef(j.RunID, j.RunAttempt, j.ID)
874			keys = append(keys, readyKey(j.ReadyAt, ref), activeKey(ref))
875			if j.HasToken() {
876				keys = append(keys, jobTokenKey(j.TokenHash))
877			}
878		}
879	}
880	return keys
881}
882
883// ciKeys lists every key of the snapshot that belongs to the repository's CI
884// runs, checks, numbers and artifacts.
885func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
886	c := &CI{}
887	var keys [][]byte
888	for key, item := range snap.Prefix(ctx, []byte(runsPrefix(repo))) {
889		if r, err := decodeRun(item); err == nil {
890			keys = append(keys, c.runKeys(ctx, snap, repo, r.ID)...)
891		}
892		keys = append(keys, slices.Clone(key))
893	}
894	keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
895	keys = append(keys, artifactKeys(ctx, snap, repo)...)
896	for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo), workflowOffPrefixOf(repo)} {
897		for k := range snap.Prefix(ctx, []byte(p)) {
898			keys = append(keys, slices.Clone(k))
899		}
900	}
901	return keys
902}
903
904const sweepChunk = 500
905
906// Sweep removes the runs completed before the time.
907func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) {
908	snap := c.db.Snapshot(ctx)
909	var ids []int64
910	var keys [][]byte
911	for _, rp := range []string{runPrefix} {
912		for _, item := range snap.Prefix(ctx, []byte(rp)) {
913			r, err := decodeRun(item)
914			if err != nil {
915				return nil, err
916			}
917			if r.Status != domain.CICompleted || r.CompletedAt.IsZero() || !r.CompletedAt.Before(before) {
918				continue
919			}
920			ids = append(ids, r.ID)
921			keys = append(keys, c.runKeys(ctx, snap, r.Repo, r.ID)...)
922			for ck := range snap.Prefix(ctx, []byte(checkPrefix+r.Repo.String()+"/"+r.CheckSha()+"/")) {
923				// A check belongs to the run only while it is the latest one
924				// for its job: leave it when another run replaced it.
925				if it, ok := snap.Get(ctx, ck); ok {
926					if chk, err := decodeCheck(it); err == nil && chk.RunID == r.ID {
927						keys = append(keys, slices.Clone(ck))
928					}
929				}
930			}
931		}
932	}
933	for len(keys) > 0 {
934		n := min(sweepChunk, len(keys))
935		var b kv.Batch
936		for _, k := range keys[:n] {
937			b.Delete(k)
938		}
939		if _, err := c.db.Apply(ctx, &b); err != nil {
940			return nil, fmt.Errorf("sweep CI runs: %w", err)
941		}
942		keys = keys[n:]
943	}
944	return ids, nil
945}
946
947// --- runners ---
948
949func runnerKey(id string) []byte               { return []byte(runnerPrefix + id) }
950func runnerNameKey(n string) []byte            { return []byte(runnerName + strings.ToLower(n)) }
951func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) }
952
953// CreateRunner stores the runner, its name index and its token index.
954func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) {
955	for range createRunTries {
956		var b kv.Batch
957		var removed []domain.CIRunner
958		if idx, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok {
959			if !replace {
960				return nil, domain.ErrAlreadyExists
961			}
962			old, err := c.GetRunner(ctx, string(idx.Value))
963			if err != nil && !errors.Is(err, domain.ErrNotFound) {
964				return nil, err
965			}
966			b.RequireVersion(runnerNameKey(r.Name), idx.Version)
967			if err == nil {
968				removed = append(removed, old)
969				b.Delete(runnerKey(old.ID))
970				b.Delete(runnerTokenKey(old.TokenHash))
971			}
972		} else {
973			b.RequireAbsent(runnerNameKey(r.Name))
974		}
975		b.RequireAbsent(runnerKey(r.ID))
976		b.RequireAbsent(runnerTokenKey(r.TokenHash))
977		b.Put(runnerKey(r.ID), encodeRunner(r))
978		b.Put(runnerNameKey(r.Name), []byte(r.ID))
979		b.Put(runnerTokenKey(r.TokenHash), []byte(r.ID))
980		_, err := c.db.Apply(ctx, &b)
981		if errors.Is(err, kv.ErrConflict) {
982			if !replace {
983				if _, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok {
984					return nil, domain.ErrAlreadyExists
985				}
986			}
987			continue
988		}
989		if err != nil {
990			return nil, fmt.Errorf("create runner %q: %w", r.Name, err)
991		}
992		return removed, nil
993	}
994	return nil, fmt.Errorf("create runner %q: %w", r.Name, domain.ErrConflict)
995}
996
997// GetRunner reads runner/<id>.
998func (c *CI) GetRunner(ctx context.Context, id string) (domain.CIRunner, error) {
999	item, ok := c.db.Get(ctx, runnerKey(id))
1000	if !ok || id == "" || strings.Contains(id, "/") {
1001		return domain.CIRunner{}, domain.ErrNotFound
1002	}
1003	return decodeRunner(item.Value)
1004}
1005
1006// FindRunnerByToken resolves the token index, then reads the runner.
1007func (c *CI) FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) {
1008	snap := c.db.Snapshot(ctx)
1009	idx, ok := snap.Get(ctx, runnerTokenKey(hash))
1010	if !ok {
1011		return domain.CIRunner{}, domain.ErrNotFound
1012	}
1013	item, ok := snap.Get(ctx, runnerKey(string(idx.Value)))
1014	if !ok {
1015		return domain.CIRunner{}, domain.ErrNotFound
1016	}
1017	r, err := decodeRunner(item.Value)
1018	if err != nil {
1019		return domain.CIRunner{}, err
1020	}
1021	if r.TokenHash != hash {
1022		return domain.CIRunner{}, fmt.Errorf("runner %q: token index does not match the stored hash", r.ID)
1023	}
1024	return r, nil
1025}
1026
1027// ListRunners returns every runner ordered by name.
1028func (c *CI) ListRunners(ctx context.Context) ([]domain.CIRunner, error) {
1029	var out []domain.CIRunner
1030	for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(runnerPrefix)) {
1031		r, err := decodeRunner(item.Value)
1032		if err != nil {
1033			return nil, fmt.Errorf("decode %s: %w", key, err)
1034		}
1035		out = append(out, r)
1036	}
1037	slices.SortFunc(out, func(a, b domain.CIRunner) int {
1038		return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
1039	})
1040	return out, nil
1041}
1042
1043// SetRunnerVersion records the version a runner runs now; an unchanged version
1044// is not written.
1045func (c *CI) SetRunnerVersion(ctx context.Context, id, version string) error {
1046	for range createRunTries {
1047		item, ok := c.db.Get(ctx, runnerKey(id))
1048		if !ok || id == "" || strings.Contains(id, "/") {
1049			return domain.ErrNotFound
1050		}
1051		r, err := decodeRunner(item.Value)
1052		if err != nil {
1053			return err
1054		}
1055		if r.Version == version {
1056			return nil
1057		}
1058		r.Version = version
1059		var b kv.Batch
1060		b.RequireVersion(runnerKey(id), item.Version)
1061		b.Put(runnerKey(id), encodeRunner(r))
1062		_, err = c.db.Apply(ctx, &b)
1063		if errors.Is(err, kv.ErrConflict) {
1064			continue
1065		}
1066		if err != nil {
1067			return fmt.Errorf("set runner %q version: %w", id, err)
1068		}
1069		return nil
1070	}
1071	return fmt.Errorf("set runner %q version: %w", id, domain.ErrConflict)
1072}
1073
1074// RemoveRunner removes the runner, its name and its token.
1075func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) {
1076	for range createRunTries {
1077		item, ok := c.db.Get(ctx, runnerKey(id))
1078		if !ok || id == "" || strings.Contains(id, "/") {
1079			return domain.CIRunner{}, domain.ErrNotFound
1080		}
1081		r, err := decodeRunner(item.Value)
1082		if err != nil {
1083			return domain.CIRunner{}, err
1084		}
1085		var b kv.Batch
1086		b.RequireVersion(runnerKey(id), item.Version)
1087		b.Delete(runnerKey(id))
1088		b.Delete(runnerNameKey(r.Name))
1089		b.Delete(runnerTokenKey(r.TokenHash))
1090		_, err = c.db.Apply(ctx, &b)
1091		if errors.Is(err, kv.ErrConflict) {
1092			continue
1093		}
1094		if err != nil {
1095			return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, err)
1096		}
1097		return r, nil
1098	}
1099	return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, domain.ErrConflict)
1100}
1101
1102func encodeCall(c *domain.CICall) *storagepb.CICall {
1103	if c == nil {
1104		return nil
1105	}
1106	return &storagepb.CICall{
1107		Uses: c.Uses, Path: c.Path, Repository: c.Repository, Ref: c.Ref, Sha: c.Sha, Yaml: c.YAML, InputsJson: c.InputsJSON,
1108		Secrets: c.Secrets, Inherit: c.Inherit, Depth: int32(c.Depth), Expanded: c.Expanded, Concurrency: encodeConcurrency(c.Concurrency), Admitted: c.Admitted, SecretCtx: c.SecretCtx, //nolint:gosec // at most 4
1109	}
1110}
1111
1112func decodeCall(pb *storagepb.CICall) *domain.CICall {
1113	if pb == nil {
1114		return nil
1115	}
1116	return &domain.CICall{
1117		Uses: pb.GetUses(), Path: pb.GetPath(), Repository: pb.GetRepository(), Ref: pb.GetRef(), Sha: pb.GetSha(), YAML: pb.GetYaml(),
1118		InputsJSON: pb.GetInputsJson(), Secrets: pb.GetSecrets(), Inherit: pb.GetInherit(), Depth: int(pb.GetDepth()), Expanded: pb.GetExpanded(), Concurrency: decodeConcurrency(pb.GetConcurrency()),
1119		Admitted: pb.GetAdmitted(), SecretCtx: pb.GetSecretCtx(),
1120	}
1121}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close