v0.14.0 815fb98

ci.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"errors"
7	"fmt"
8	"regexp"
9	"slices"
10	"strconv"
11	"strings"
12	"time"
13)
14
15// CIStatus is where a run, a job or a check is in its life (GitHub's status).
16type CIStatus string
17
18// Statuses.
19const (
20	CIQueued     CIStatus = "queued"
21	CIInProgress CIStatus = "in_progress"
22	CICompleted  CIStatus = "completed"
23	// CIPending is a job or a check held back by a concurrency group: another
24	// entry of the group runs, or a newer one is waiting. Never a run's status.
25	CIPending CIStatus = "pending"
26)
27
28// CIConclusion is how a run, a job, a step or a check ended, with GitHub's
29// values. It is empty until the status is completed.
30type CIConclusion string
31
32// Conclusions.
33const (
34	CINoConclusion   CIConclusion = ""
35	CISuccess        CIConclusion = "success"
36	CIFailure        CIConclusion = "failure"
37	CICancelled      CIConclusion = "cancelled"
38	CISkipped        CIConclusion = "skipped"
39	CITimedOut       CIConclusion = "timed_out"
40	CINeutral        CIConclusion = "neutral"
41	CIActionRequired CIConclusion = "action_required"
42)
43
44// PassesMerge reports whether a check that ended this way lets a merge go
45// through under FailingChecksBlock: success, skipped and neutral do.
46func (c CIConclusion) PassesMerge() bool {
47	return c == CISuccess || c == CISkipped || c == CINeutral
48}
49
50// IsFailure reports whether the conclusion is a failure or a timeout.
51func (c CIConclusion) IsFailure() bool { return c == CIFailure || c == CITimedOut }
52
53// CIStepState is where a step is in its life.
54type CIStepState string
55
56// Step states.
57const (
58	CIStepPending    CIStepState = "pending"
59	CIStepInProgress CIStepState = "in_progress"
60	CIStepCompleted  CIStepState = "completed"
61)
62
63// CIJobPhase is where a job is for the server, finer than the status owners
64// see.
65type CIJobPhase string
66
67// Job phases.
68const (
69	// CIPhaseWaiting: waits for the jobs it needs or for a max-parallel slot.
70	CIPhaseWaiting CIJobPhase = "waiting"
71	// CIPhaseReady: planned, waits for a runner.
72	CIPhaseReady CIJobPhase = "ready"
73	// CIPhaseAssigned: given to a runner that has not acknowledged it.
74	CIPhaseAssigned CIJobPhase = "assigned"
75	// CIPhaseRunning: acknowledged by its runner.
76	CIPhaseRunning CIJobPhase = "running"
77	// CIPhaseEnded: over.
78	CIPhaseEnded CIJobPhase = "ended"
79)
80
81// CIIsolation is how a runner isolates jobs.
82type CIIsolation string
83
84// Isolations.
85const (
86	CIIsolationNone                CIIsolation = ""
87	CIIsolationVM                  CIIsolation = "vm"
88	CIIsolationContainerSysbox     CIIsolation = "container-sysbox"
89	CIIsolationContainerPrivileged CIIsolation = "container-privileged"
90)
91
92// CICancelReason says why a job is asked to stop.
93type CICancelReason string
94
95// Cancel reasons.
96const (
97	CICancelNone     CICancelReason = ""
98	CICancelUser     CICancelReason = "user"
99	CICancelFailFast CICancelReason = "fail-fast"
100	CICancelTimeout  CICancelReason = "timeout"
101)
102
103// Limits and durations of the CI protocol (runner.proto).
104const (
105	// CIMaxLogBytes is the size of a job log, as stored (512 MiB).
106	CIMaxLogBytes = 512 << 20
107	// CIMaxOutputBytes bounds one output and all outputs together (1 MiB).
108	CIMaxOutputBytes = 1 << 20
109	// CIMaxLogLines and CIMaxLogChunkBytes bound one AppendLog request.
110	CIMaxLogLines      = 1000
111	CIMaxLogChunkBytes = 64 << 10
112	// CIMaxSteps bounds the steps of a job.
113	CIMaxSteps = 1000
114	// CIMaxStepName bounds a step name, in characters.
115	CIMaxStepName = 1024
116	// CIMaxLabels and CIMaxLabelLen bound the labels of a runner.
117	CIMaxLabels   = 64
118	CIMaxLabelLen = 64
119	// CIDefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
120	CIDefaultTimeoutMinutes = 360
121	// CIMaxAssignCancels is how many times in a row an assignment may be
122	// cancelled before the job fails.
123	CIMaxAssignCancels = 3
124	// CIRetentionDays is the retention of runs, jobs, checks and logs.
125	CIRetentionDays = 90
126)
127
128// Durations of the CI protocol.
129const (
130	// CIAckTimeout is how long a runner has to send its first heartbeat.
131	CIAckTimeout = 30 * time.Second
132	// CIHeartbeatDeadline is how long a running job may go without heartbeat.
133	CIHeartbeatDeadline = 2 * time.Minute
134	// CICancelGrace is how long a runner has to conclude after a cancel request.
135	CICancelGrace = 60 * time.Second
136	// CITimeoutSlack is added to timeout-minutes by the server.
137	CITimeoutSlack = 60 * time.Second
138	// CIQueueTimeout is how long a job waits for a runner.
139	CIQueueTimeout = 24 * time.Hour
140	// CITokenGrace is how long a job token is recognised after the job ended.
141	CITokenGrace = 24 * time.Hour
142)
143
144// Messages of jobs ended by the server, GitHub's where it has them.
145const (
146	// CIQueueTimeoutMessage is GitHub's message for a job that no runner took.
147	CIQueueTimeoutMessage = "The job was not acquired by Runner of type self-hosted even after multiple attempts"
148	// CIShutdownMessage is GitHub's message for a runner that stops.
149	CIShutdownMessage = "The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled."
150	// CILogLimitMessage ends a log that reached its limit.
151	CILogLimitMessage = "The log of this job reached the limit of 512 MiB; the job is stopped."
152)
153
154// CILostMessage is GitHub's message for a runner that stopped sending heartbeats.
155func CILostMessage(runner string) string {
156	return "The self-hosted runner: " + runner + " lost communication with the server. Verify the machine is running and has a healthy network connection. Anything in your workflow that terminates the runner process, starves it for CPU/Memory, or blocks its network access can cause this error."
157}
158
159// CIRun is one execution of one workflow file for one event.
160type CIRun struct {
161	Repo         RepoName
162	ID           int64
163	Number       int64
164	Attempt      int32
165	WorkflowPath string
166	WorkflowName string
167	// Name is GitHub's name of the run: the evaluated run-name when the
168	// workflow has one, else WorkflowName. WorkflowName stays the name of the
169	// workflow (github.workflow, the checks).
170	Name  string
171	Title string
172	Event string
173	Ref   string
174	Sha   string
175	// HeadSha is the head of the merge request of a pull_request run, whose Sha
176	// is the test merge commit github.sha shows; empty for the other events. The
177	// checks of the run attach to it (CheckSha).
178	HeadSha           string
179	Actor             string
180	Status            CIStatus
181	Conclusion        CIConclusion
182	Error             string
183	CreatedAt         time.Time
184	StartedAt         time.Time
185	CompletedAt       time.Time
186	CancelRequested   bool
187	CancelRequestedAt time.Time
188	// Trusted is set when the actor of the run is the owner (TrustedActor):
189	// only then do its jobs receive secrets and a write permission. A re-run
190	// keeps or lowers it, never raises it (RerunTrusted). False for a run
191	// stored before the field existed: the safe reading.
192	Trusted bool
193	// Concurrency is the workflow-level concurrency of the latest attempt.
194	Concurrency CIConcurrency
195	// Version is the version of the stored record, which grows at every
196	// change and never goes back.
197	Version uint64
198}
199
200// CheckSha is the commit the checks of the run attach to: the head of the merge
201// request for a pull_request run (as on GitHub, where the checks of the test
202// merge show on the head), the commit of the run otherwise.
203func (r CIRun) CheckSha() string {
204	if r.HeadSha != "" {
205		return r.HeadSha
206	}
207	return r.Sha
208}
209
210// CIRunSource is what a run was planned from: the workflow file at the sha of
211// the run, and the "github" context without the parts that depend on the job
212// and the attempt.
213type CIRunSource struct {
214	RunID        int64
215	Repo         RepoName
216	WorkflowYAML string
217	GithubJSON   string
218	// Pusher is who pushed the commit: what the actions of other repositories
219	// of the server may be resolved on behalf of.
220	Pusher CIPusher
221}
222
223// CIStep is a step of a job with where its lines are in the job log.
224type CIStep struct {
225	Number     int32
226	Name       string
227	State      CIStepState
228	Conclusion CIConclusion
229	// Outcome is how the step ended before continue-on-error was applied
230	// ("steps.<id>.outcome"). Empty for a step that was not completed by a
231	// runner that reports it: read it as Conclusion.
232	Outcome     CIConclusion
233	StartedAt   time.Time
234	CompletedAt time.Time
235	LogStart    int64
236	// HasLines is set once a line of the step reached the log; LogStart is
237	// then the number of its first line. LogEnd is not stored: see StepRanges.
238	HasLines bool
239}
240
241// CIJob is one job instance (one matrix combination) in one attempt.
242type CIJob struct {
243	ID    string
244	RunID int64
245	// Attempt is the attempt in which the job executed, RunAttempt the attempt
246	// of the run the record belongs to: they differ for a job kept by a re-run
247	// of the failed jobs.
248	Attempt    int32
249	RunAttempt int32
250	Repo       RepoName
251	Key        string
252	Name       string
253	Phase      CIJobPhase
254	Conclusion CIConclusion
255	Error      string
256	Labels     []string
257	Needs      []string
258	RunnerID   string
259	RunnerName string
260	Isolation  CIIsolation
261	Steps      []CIStep
262
263	CreatedAt   time.Time
264	StartedAt   time.Time
265	CompletedAt time.Time
266
267	// Index is the position of the job in the workflow file, Combo the
268	// position of the instance in its matrix.
269	Index int
270	Combo int
271	// Planned is set once runs-on, the name and the contexts are evaluated.
272	// A job that is not planned has not been expanded yet.
273	Planned    bool
274	MatrixJSON string
275	// ContextsJSON is the JSON object of contexts given to the runner, computed
276	// when the job is planned.
277	ContextsJSON string
278	// ComboTotal is the number of instances of the job's matrix.
279	ComboTotal int
280	// TimeoutMinutes is the timeout of the job, at least 1.
281	TimeoutMinutes int32
282	FailFast       bool
283	// ContinueOnError is the job's continue-on-error, evaluated when the job is
284	// planned: its failure leaves the run, its siblings and the jobs that
285	// need it unaffected (needs.<job>.result is success). The conclusion of
286	// the job itself stays failure.
287	ContinueOnError bool
288	// Environment is the name of the environment of the job (its evaluated
289	// "environment:"), "" for none. Set when the job is planned; the secrets
290	// and variables of the environment are read when the job is assigned.
291	Environment string
292	// Permissions are the permissions of the job's repository token as the
293	// workflow and the job ask for them (ResolvePermissions), key to level;
294	// set when the job is planned. Empty: the default (a job planned before
295	// they were stored). The trust rule of the run is applied when the token
296	// is issued (TokenPermissions), not here.
297	Permissions map[string]string
298	// MaxParallel is the max-parallel of the strategy, 0 for no limit.
299	MaxParallel int
300	Outputs     map[string]string
301	// Concurrency is the concurrency group of the job: its own, evaluated when
302	// the job is planned, else the one of its run (FromRun).
303	Concurrency CIConcurrency
304
305	// Scope is the id of the record of the job that called the reusable workflow
306	// this job belongs to, "" for a job of the run's own workflow. CallID is the
307	// id of the job in that called workflow (Key is "<caller key>/<call id>").
308	Scope  string
309	CallID string
310	// Call is set on the record of a job that calls a reusable workflow: it never
311	// runs, it ends when the jobs of its scope have.
312	Call *CICall
313
314	// TokenHash is the hash of the job token of the current assignment; zero
315	// when there is none.
316	TokenHash       TokenHash
317	TokenValidUntil time.Time
318	ReadyAt         time.Time
319	AssignedAt      time.Time
320	AckedAt         time.Time
321	// AssignCancels counts the assignments cancelled in a row.
322	AssignCancels int
323
324	CancelReason      CICancelReason
325	CancelMessage     string
326	CancelRequestedAt time.Time
327
328	Version uint64
329}
330
331// CICall is what a job calling a reusable workflow resolved (CI-3, D7).
332type CICall struct {
333	Uses string
334	// Path is the file in its repository, Repository "owner/repo" for github.com
335	// or the name on this server, Ref what the workflow wrote ("" for a local
336	// call), Sha the commit the call resolved to.
337	Path, Repository, Ref, Sha string
338	YAML                       string
339	// InputsJSON is the inputs context of the called workflow.
340	InputsJSON string
341	// Secrets holds the names (upper case) of the secrets of the called workflow
342	// that the calling workflow's "secrets:" gives, with empty values: the
343	// expressions are read from the pinned calling workflow when a job is
344	// assigned. Inherit: "secrets: inherit".
345	Secrets map[string]string
346	Inherit bool
347	// Depth is 1 for a call from the run's own workflow.
348	Depth int
349	// Expanded: the jobs of the called workflow exist.
350	Expanded bool
351	// Concurrency is the workflow-level group of the called workflow. The call
352	// holds it, with the group of the calling job (CIJob.Concurrency), until it
353	// ends; its jobs wait for both.
354	Concurrency CIConcurrency
355	// Admitted: the call may start, the max-parallel of the calling job's
356	// strategy allows it.
357	Admitted bool
358	// SecretCtx is the JSON of the other contexts the Secrets expressions read.
359	SecretCtx string
360}
361
362// Status is the status owners see: a job is queued until its runner
363// acknowledged it.
364func (j CIJob) Status() CIStatus {
365	switch j.Phase {
366	case CIPhaseRunning:
367		return CIInProgress
368	case CIPhaseEnded:
369		return CICompleted
370	case CIPhaseWaiting:
371		if j.Concurrency.State == CIConcurrencyPending {
372			return CIPending
373		}
374		if j.Call != nil && j.Call.Concurrency.State == CIConcurrencyPending {
375			return CIPending
376		}
377		if j.Call != nil && j.Call.Expanded && j.Call.Admitted {
378			return CIInProgress
379		}
380	}
381	return CIQueued
382}
383
384// Active reports whether the job is given to a runner and not over.
385func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning }
386
387// Ended reports whether the job is over.
388func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded }
389
390// HasToken reports whether a job token is set.
391func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} }
392
393// CICheck is the result of one job on one commit.
394type CICheck struct {
395	ID           string
396	Repo         RepoName
397	Sha          string
398	Name         string
399	Status       CIStatus
400	Conclusion   CIConclusion
401	RunID        int64
402	JobID        string
403	Attempt      int32
404	WorkflowName string
405	CreatedAt    time.Time
406	StartedAt    time.Time
407	CompletedAt  time.Time
408	Version      uint64
409}
410
411// CIRunner is a registered CI runner. TokenHash is never sent to clients.
412type CIRunner struct {
413	ID           string
414	Name         string
415	Labels       []string
416	Isolation    CIIsolation
417	Version      string
418	Arch         string
419	OS           string
420	RegisteredAt time.Time
421	TokenHash    TokenHash
422}
423
424// RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs.
425const (
426	RunnerTokenPrefix = "ermiterun_"
427	JobTokenPrefix    = "ermitejob_"
428	// CITokenRandomLen is the length of the base64url part of both.
429	CITokenRandomLen = 43
430)
431
432// IsRunnerToken and IsJobToken report whether s has the shape of the secret.
433func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) }
434func IsJobToken(s string) bool    { return hasTokenShape(s, JobTokenPrefix) }
435
436func hasTokenShape(s, prefix string) bool {
437	rest, ok := strings.CutPrefix(s, prefix)
438	if !ok || len(rest) != CITokenRandomLen {
439		return false
440	}
441	for _, r := range rest {
442		switch {
443		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
444		default:
445			return false
446		}
447	}
448	return true
449}
450
451// NewJobID names a job instance: "<run>-<index>-<combo>". It is the same in
452// every attempt of the run.
453func NewJobID(runID int64, index, combo int) string {
454	return fmt.Sprintf("%d-%d-%d", runID, index, combo)
455}
456
457// ParseJobID returns the run of a job id, or ok false when s is not one.
458func ParseJobID(s string) (runID int64, index, combo int, ok bool) {
459	parts := strings.Split(s, "-")
460	if len(parts) != 3 {
461		return 0, 0, 0, false
462	}
463	r, err1 := strconv.ParseInt(parts[0], 10, 64)
464	i, err2 := strconv.Atoi(parts[1])
465	c, err3 := strconv.Atoi(parts[2])
466	if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s {
467		return 0, 0, 0, false
468	}
469	return r, i, c, true
470}
471
472var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
473
474// ParseRunnerName validates a runner name.
475func ParseRunnerName(s string) error {
476	if !labelRE.MatchString(s) {
477		return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid)
478	}
479	return nil
480}
481
482// ParseRunnerLabels validates labels and returns them lowercased, without
483// duplicates, in order of first appearance.
484func ParseRunnerLabels(labels []string) ([]string, error) {
485	if len(labels) > CIMaxLabels {
486		return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels)
487	}
488	var out []string
489	for _, l := range labels {
490		if !labelRE.MatchString(l) {
491			return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen)
492		}
493		l = strings.ToLower(l)
494		if !slices.Contains(out, l) {
495			out = append(out, l)
496		}
497	}
498	return out, nil
499}
500
501// RunnerLabels returns the labels a runner matches: the ones it chose plus
502// the implicit "self-hosted", its os and its arch, lowercase and sorted.
503func RunnerLabels(chosen []string, os, arch string) []string {
504	out := slices.Clone(chosen)
505	for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} {
506		if !slices.Contains(out, l) {
507			out = append(out, l)
508		}
509	}
510	slices.Sort(out)
511	return out
512}
513
514// LabelsMatch reports whether every wanted label is among have, ignoring case.
515func LabelsMatch(want, have []string) bool {
516	for _, w := range want {
517		if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) {
518			return false
519		}
520	}
521	return true
522}
523
524var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`)
525
526// CheckOutputs validates the outputs of a job: names, case-insensitive
527// uniqueness and the 1 MiB limits.
528func CheckOutputs(outputs map[string]string) error {
529	seen := make(map[string]bool, len(outputs))
530	total := 0
531	for k, v := range outputs {
532		if !outputNameRE.MatchString(k) {
533			return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k)
534		}
535		lk := strings.ToLower(k)
536		if seen[lk] {
537			return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k)
538		}
539		seen[lk] = true
540		if len(v) > CIMaxOutputBytes {
541			return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k)
542		}
543		total += len(k) + len(v)
544	}
545	if total > CIMaxOutputBytes {
546		return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid)
547	}
548	return nil
549}
550
551// JobEndedError is returned for a call that needs a running job when the job
552// has ended. It matches ErrPreconditionFailed; the API adds the JobEnded
553// detail.
554type JobEndedError struct {
555	Conclusion CIConclusion
556	Message    string
557}
558
559// Error implements error.
560func (e *JobEndedError) Error() string {
561	return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion)
562}
563
564// Is makes errors.Is(err, ErrPreconditionFailed) true.
565func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed }
566
567// IsJobEnded returns the JobEndedError in err.
568func IsJobEnded(err error) (*JobEndedError, bool) {
569	var e *JobEndedError
570	if errors.As(err, &e) {
571		return e, true
572	}
573	return nil, false
574}
575
576// ErrOutOfRange means an offset is beyond what the server holds.
577var ErrOutOfRange = errors.New("out of range")
578
579// CILogLine is one line of a job log.
580type CILogLine struct {
581	Time time.Time
582	// Text has no line feed.
583	Text string
584	// Continued marks a piece of a line that was too long to send whole.
585	Continued bool
586}
587
588// StepRange is where the lines of a step are in the job log.
589type StepRange struct {
590	Start, End int64
591}
592
593// StepRanges returns the range of every step of the job for a log of lines
594// lines: a step with lines ends where the next step with lines begins (the
595// last ends at the end of the log); a step without lines is empty, placed
596// where the previous step ends.
597func StepRanges(steps []CIStep, lines int64) []StepRange {
598	out := make([]StepRange, len(steps))
599	var pos int64
600	for i, s := range steps {
601		if !s.HasLines {
602			out[i] = StepRange{Start: pos, End: pos}
603			continue
604		}
605		end := lines
606		for _, n := range steps[i+1:] {
607			if n.HasLines {
608				end = n.LogStart
609				break
610			}
611		}
612		out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)}
613		pos = out[i].End
614	}
615	return out
616}
617
618// StepOfLine returns the number of the step a log line belongs to, 0 when no
619// step has that line.
620func StepOfLine(steps []CIStep, line int64) int32 {
621	var found int32
622	for _, s := range steps {
623		if s.HasLines && s.LogStart <= line {
624			found = s.Number
625		}
626	}
627	return found
628}
629
630// RunUpdated is published when a CI run is created or changes. It carries the
631// visibility and the creation time of the repository, like PushEvent.
632type RunUpdated struct {
633	Run        CIRun
634	At         time.Time
635	Visibility Visibility
636	CreatedAt  time.Time
637}
638
639// EventKind implements Event.
640func (RunUpdated) EventKind() string { return "ci.run_updated" }
641
642// JobUpdated is published when a CI job is created or changes (status,
643// conclusion, runner or steps; never per log line). Lines is the number of
644// lines of the job log at that time, for the end of the step in progress.
645type JobUpdated struct {
646	Job        CIJob
647	Lines      int64
648	At         time.Time
649	Visibility Visibility
650	CreatedAt  time.Time
651}
652
653// EventKind implements Event.
654func (JobUpdated) EventKind() string { return "ci.job_updated" }
655
656// CheckState is the combined state of the checks of a commit.
657type CheckState string
658
659// Combined check states.
660const (
661	// CheckStateNone: the commit has no check.
662	CheckStateNone CheckState = ""
663	// CheckStatePending: a check is queued or in progress, none has failed.
664	CheckStatePending CheckState = "pending"
665	// CheckStateFailure: a check failed (or needs an action), whatever the others.
666	CheckStateFailure CheckState = "failure"
667	// CheckStateSuccess: every check passed.
668	CheckStateSuccess CheckState = "success"
669)
670
671// CombineChecks reduces the checks of a commit to one state, the one the
672// merge rule uses too: a failed check makes the commit failing even while
673// others are pending; else a pending check makes it pending; else it is a
674// success.
675func CombineChecks(checks []CICheck) CheckState {
676	if len(checks) == 0 {
677		return CheckStateNone
678	}
679	pending := false
680	for _, c := range checks {
681		switch {
682		case c.Status != CICompleted:
683			pending = true
684		case !c.Conclusion.PassesMerge():
685			return CheckStateFailure
686		}
687	}
688	if pending {
689		return CheckStatePending
690	}
691	return CheckStateSuccess
692}

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close