detached 5278df2

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	// PullNumber is the merge request of a pull_request or pull_request_target
180	// run, 0 for the other events (and for runs stored before it existed).
181	PullNumber        int64
182	Actor             string
183	Status            CIStatus
184	Conclusion        CIConclusion
185	Error             string
186	CreatedAt         time.Time
187	StartedAt         time.Time
188	CompletedAt       time.Time
189	CancelRequested   bool
190	CancelRequestedAt time.Time
191	// Trusted is set when the actor of the run is the owner (TrustedActor):
192	// only then do its jobs receive secrets and a write permission. A re-run
193	// keeps or lowers it, never raises it (RerunTrusted). False for a run
194	// stored before the field existed: the safe reading.
195	Trusted bool
196	// Concurrency is the workflow-level concurrency of the latest attempt.
197	Concurrency CIConcurrency
198	// Version is the version of the stored record, which grows at every
199	// change and never goes back.
200	Version uint64
201}
202
203// CheckSha is the commit the checks of the run attach to: the head of the merge
204// request for a pull_request run (as on GitHub, where the checks of the test
205// merge show on the head), the commit of the run otherwise.
206func (r CIRun) CheckSha() string {
207	if r.HeadSha != "" {
208		return r.HeadSha
209	}
210	return r.Sha
211}
212
213// CIRunSource is what a run was planned from: the workflow file at the sha of
214// the run, and the "github" context without the parts that depend on the job
215// and the attempt.
216type CIRunSource struct {
217	RunID        int64
218	Repo         RepoName
219	WorkflowYAML string
220	GithubJSON   string
221	// Pusher is who pushed the commit: what the actions of other repositories
222	// of the server may be resolved on behalf of.
223	Pusher CIPusher
224}
225
226// CIStep is a step of a job with where its lines are in the job log.
227type CIStep struct {
228	Number     int32
229	Name       string
230	State      CIStepState
231	Conclusion CIConclusion
232	// Outcome is how the step ended before continue-on-error was applied
233	// ("steps.<id>.outcome"). Empty for a step that was not completed by a
234	// runner that reports it: read it as Conclusion.
235	Outcome     CIConclusion
236	StartedAt   time.Time
237	CompletedAt time.Time
238	LogStart    int64
239	// HasLines is set once a line of the step reached the log; LogStart is
240	// then the number of its first line. LogEnd is not stored: see StepRanges.
241	HasLines bool
242}
243
244// CIJob is one job instance (one matrix combination) in one attempt.
245type CIJob struct {
246	ID    string
247	RunID int64
248	// Attempt is the attempt in which the job executed, RunAttempt the attempt
249	// of the run the record belongs to: they differ for a job kept by a re-run
250	// of the failed jobs.
251	Attempt    int32
252	RunAttempt int32
253	Repo       RepoName
254	Key        string
255	Name       string
256	Phase      CIJobPhase
257	Conclusion CIConclusion
258	Error      string
259	Labels     []string
260	Needs      []string
261	RunnerID   string
262	RunnerName string
263	Isolation  CIIsolation
264	Steps      []CIStep
265
266	CreatedAt   time.Time
267	StartedAt   time.Time
268	CompletedAt time.Time
269
270	// Index is the position of the job in the workflow file, Combo the
271	// position of the instance in its matrix.
272	Index int
273	Combo int
274	// Planned is set once runs-on, the name and the contexts are evaluated.
275	// A job that is not planned has not been expanded yet.
276	Planned    bool
277	MatrixJSON string
278	// ContextsJSON is the JSON object of contexts given to the runner, computed
279	// when the job is planned.
280	ContextsJSON string
281	// ComboTotal is the number of instances of the job's matrix.
282	ComboTotal int
283	// TimeoutMinutes is the timeout of the job, at least 1.
284	TimeoutMinutes int32
285	FailFast       bool
286	// ContinueOnError is the job's continue-on-error, evaluated when the job is
287	// planned: its failure leaves the run, its siblings and the jobs that
288	// need it unaffected (needs.<job>.result is success). The conclusion of
289	// the job itself stays failure.
290	ContinueOnError bool
291	// Environment is the name of the environment of the job (its evaluated
292	// "environment:"), "" for none. Set when the job is planned; the secrets
293	// and variables of the environment are read when the job is assigned.
294	Environment string
295	// Permissions are the permissions of the job's repository token as the
296	// workflow and the job ask for them (ResolvePermissions), key to level;
297	// set when the job is planned. Empty: the default (a job planned before
298	// they were stored). The trust rule of the run is applied when the token
299	// is issued (TokenPermissions), not here.
300	Permissions map[string]string
301	// MaxParallel is the max-parallel of the strategy, 0 for no limit.
302	MaxParallel int
303	Outputs     map[string]string
304	// Concurrency is the concurrency group of the job: its own, evaluated when
305	// the job is planned, else the one of its run (FromRun).
306	Concurrency CIConcurrency
307
308	// Scope is the id of the record of the job that called the reusable workflow
309	// this job belongs to, "" for a job of the run's own workflow. CallID is the
310	// id of the job in that called workflow (Key is "<caller key>/<call id>").
311	Scope  string
312	CallID string
313	// Call is set on the record of a job that calls a reusable workflow: it never
314	// runs, it ends when the jobs of its scope have.
315	Call *CICall
316
317	// TokenHash is the hash of the job token of the current assignment; zero
318	// when there is none.
319	TokenHash       TokenHash
320	TokenValidUntil time.Time
321	ReadyAt         time.Time
322	AssignedAt      time.Time
323	AckedAt         time.Time
324	// AssignCancels counts the assignments cancelled in a row.
325	AssignCancels int
326
327	CancelReason      CICancelReason
328	CancelMessage     string
329	CancelRequestedAt time.Time
330
331	Version uint64
332}
333
334// CICall is what a job calling a reusable workflow resolved (CI-3, D7).
335type CICall struct {
336	Uses string
337	// Path is the file in its repository, Repository "owner/repo" for github.com
338	// or the name on this server, Ref what the workflow wrote ("" for a local
339	// call), Sha the commit the call resolved to.
340	Path, Repository, Ref, Sha string
341	YAML                       string
342	// InputsJSON is the inputs context of the called workflow.
343	InputsJSON string
344	// Secrets holds the names (upper case) of the secrets of the called workflow
345	// that the calling workflow's "secrets:" gives, with empty values: the
346	// expressions are read from the pinned calling workflow when a job is
347	// assigned. Inherit: "secrets: inherit".
348	Secrets map[string]string
349	Inherit bool
350	// Depth is 1 for a call from the run's own workflow.
351	Depth int
352	// Expanded: the jobs of the called workflow exist.
353	Expanded bool
354	// Concurrency is the workflow-level group of the called workflow. The call
355	// holds it, with the group of the calling job (CIJob.Concurrency), until it
356	// ends; its jobs wait for both.
357	Concurrency CIConcurrency
358	// Admitted: the call may start, the max-parallel of the calling job's
359	// strategy allows it.
360	Admitted bool
361	// SecretCtx is the JSON of the other contexts the Secrets expressions read.
362	SecretCtx string
363}
364
365// Status is the status owners see: a job is queued until its runner
366// acknowledged it.
367func (j CIJob) Status() CIStatus {
368	switch j.Phase {
369	case CIPhaseRunning:
370		return CIInProgress
371	case CIPhaseEnded:
372		return CICompleted
373	case CIPhaseWaiting:
374		if j.Concurrency.State == CIConcurrencyPending {
375			return CIPending
376		}
377		if j.Call != nil && j.Call.Concurrency.State == CIConcurrencyPending {
378			return CIPending
379		}
380		if j.Call != nil && j.Call.Expanded && j.Call.Admitted {
381			return CIInProgress
382		}
383	}
384	return CIQueued
385}
386
387// Active reports whether the job is given to a runner and not over.
388func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning }
389
390// Ended reports whether the job is over.
391func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded }
392
393// HasToken reports whether a job token is set.
394func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} }
395
396// CICheck is the result of one job on one commit.
397type CICheck struct {
398	ID           string
399	Repo         RepoName
400	Sha          string
401	Name         string
402	Status       CIStatus
403	Conclusion   CIConclusion
404	RunID        int64
405	JobID        string
406	Attempt      int32
407	WorkflowName string
408	CreatedAt    time.Time
409	StartedAt    time.Time
410	CompletedAt  time.Time
411	Version      uint64
412}
413
414// CIRunner is a registered CI runner. TokenHash is never sent to clients.
415type CIRunner struct {
416	ID           string
417	Name         string
418	Labels       []string
419	Isolation    CIIsolation
420	Version      string
421	Arch         string
422	OS           string
423	RegisteredAt time.Time
424	TokenHash    TokenHash
425}
426
427// RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs.
428const (
429	RunnerTokenPrefix = "ermiterun_"
430	JobTokenPrefix    = "ermitejob_"
431	// CITokenRandomLen is the length of the base64url part of both.
432	CITokenRandomLen = 43
433)
434
435// IsRunnerToken and IsJobToken report whether s has the shape of the secret.
436func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) }
437func IsJobToken(s string) bool    { return hasTokenShape(s, JobTokenPrefix) }
438
439func hasTokenShape(s, prefix string) bool {
440	rest, ok := strings.CutPrefix(s, prefix)
441	if !ok || len(rest) != CITokenRandomLen {
442		return false
443	}
444	for _, r := range rest {
445		switch {
446		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
447		default:
448			return false
449		}
450	}
451	return true
452}
453
454// NewJobID names a job instance: "<run>-<index>-<combo>". It is the same in
455// every attempt of the run.
456func NewJobID(runID int64, index, combo int) string {
457	return fmt.Sprintf("%d-%d-%d", runID, index, combo)
458}
459
460// ParseJobID returns the run of a job id, or ok false when s is not one.
461func ParseJobID(s string) (runID int64, index, combo int, ok bool) {
462	parts := strings.Split(s, "-")
463	if len(parts) != 3 {
464		return 0, 0, 0, false
465	}
466	r, err1 := strconv.ParseInt(parts[0], 10, 64)
467	i, err2 := strconv.Atoi(parts[1])
468	c, err3 := strconv.Atoi(parts[2])
469	if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s {
470		return 0, 0, 0, false
471	}
472	return r, i, c, true
473}
474
475var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
476
477// ParseRunnerName validates a runner name.
478func ParseRunnerName(s string) error {
479	if !labelRE.MatchString(s) {
480		return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid)
481	}
482	return nil
483}
484
485// ParseRunnerLabels validates labels and returns them lowercased, without
486// duplicates, in order of first appearance.
487func ParseRunnerLabels(labels []string) ([]string, error) {
488	if len(labels) > CIMaxLabels {
489		return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels)
490	}
491	var out []string
492	for _, l := range labels {
493		if !labelRE.MatchString(l) {
494			return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen)
495		}
496		l = strings.ToLower(l)
497		if !slices.Contains(out, l) {
498			out = append(out, l)
499		}
500	}
501	return out, nil
502}
503
504// RunnerLabels returns the labels a runner matches: the ones it chose plus
505// the implicit "self-hosted", its os and its arch, lowercase and sorted.
506func RunnerLabels(chosen []string, os, arch string) []string {
507	out := slices.Clone(chosen)
508	for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} {
509		if !slices.Contains(out, l) {
510			out = append(out, l)
511		}
512	}
513	slices.Sort(out)
514	return out
515}
516
517// LabelsMatch reports whether every wanted label is among have, ignoring case.
518func LabelsMatch(want, have []string) bool {
519	for _, w := range want {
520		if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) {
521			return false
522		}
523	}
524	return true
525}
526
527var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`)
528
529// CheckOutputs validates the outputs of a job: names, case-insensitive
530// uniqueness and the 1 MiB limits.
531func CheckOutputs(outputs map[string]string) error {
532	seen := make(map[string]bool, len(outputs))
533	total := 0
534	for k, v := range outputs {
535		if !outputNameRE.MatchString(k) {
536			return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k)
537		}
538		lk := strings.ToLower(k)
539		if seen[lk] {
540			return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k)
541		}
542		seen[lk] = true
543		if len(v) > CIMaxOutputBytes {
544			return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k)
545		}
546		total += len(k) + len(v)
547	}
548	if total > CIMaxOutputBytes {
549		return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid)
550	}
551	return nil
552}
553
554// JobEndedError is returned for a call that needs a running job when the job
555// has ended. It matches ErrPreconditionFailed; the API adds the JobEnded
556// detail.
557type JobEndedError struct {
558	Conclusion CIConclusion
559	Message    string
560}
561
562// Error implements error.
563func (e *JobEndedError) Error() string {
564	return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion)
565}
566
567// Is makes errors.Is(err, ErrPreconditionFailed) true.
568func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed }
569
570// IsJobEnded returns the JobEndedError in err.
571func IsJobEnded(err error) (*JobEndedError, bool) {
572	var e *JobEndedError
573	if errors.As(err, &e) {
574		return e, true
575	}
576	return nil, false
577}
578
579// ErrOutOfRange means an offset is beyond what the server holds.
580var ErrOutOfRange = errors.New("out of range")
581
582// CILogLine is one line of a job log.
583type CILogLine struct {
584	Time time.Time
585	// Text has no line feed.
586	Text string
587	// Continued marks a piece of a line that was too long to send whole.
588	Continued bool
589}
590
591// StepRange is where the lines of a step are in the job log.
592type StepRange struct {
593	Start, End int64
594}
595
596// StepRanges returns the range of every step of the job for a log of lines
597// lines: a step with lines ends where the next step with lines begins (the
598// last ends at the end of the log); a step without lines is empty, placed
599// where the previous step ends.
600func StepRanges(steps []CIStep, lines int64) []StepRange {
601	out := make([]StepRange, len(steps))
602	var pos int64
603	for i, s := range steps {
604		if !s.HasLines {
605			out[i] = StepRange{Start: pos, End: pos}
606			continue
607		}
608		end := lines
609		for _, n := range steps[i+1:] {
610			if n.HasLines {
611				end = n.LogStart
612				break
613			}
614		}
615		out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)}
616		pos = out[i].End
617	}
618	return out
619}
620
621// StepOfLine returns the number of the step a log line belongs to, 0 when no
622// step has that line.
623func StepOfLine(steps []CIStep, line int64) int32 {
624	var found int32
625	for _, s := range steps {
626		if s.HasLines && s.LogStart <= line {
627			found = s.Number
628		}
629	}
630	return found
631}
632
633// RunUpdated is published when a CI run is created or changes. It carries the
634// visibility and the creation time of the repository, like PushEvent.
635type RunUpdated struct {
636	Run        CIRun
637	At         time.Time
638	Visibility Visibility
639	CreatedAt  time.Time
640}
641
642// EventKind implements Event.
643func (RunUpdated) EventKind() string { return "ci.run_updated" }
644
645// JobUpdated is published when a CI job is created or changes (status,
646// conclusion, runner or steps; never per log line). Lines is the number of
647// lines of the job log at that time, for the end of the step in progress.
648type JobUpdated struct {
649	Job        CIJob
650	Lines      int64
651	At         time.Time
652	Visibility Visibility
653	CreatedAt  time.Time
654}
655
656// EventKind implements Event.
657func (JobUpdated) EventKind() string { return "ci.job_updated" }
658
659// CheckState is the combined state of the checks of a commit.
660type CheckState string
661
662// Combined check states.
663const (
664	// CheckStateNone: the commit has no check.
665	CheckStateNone CheckState = ""
666	// CheckStatePending: a check is queued or in progress, none has failed.
667	CheckStatePending CheckState = "pending"
668	// CheckStateFailure: a check failed (or needs an action), whatever the others.
669	CheckStateFailure CheckState = "failure"
670	// CheckStateSuccess: every check passed.
671	CheckStateSuccess CheckState = "success"
672)
673
674// CombineChecks reduces the checks of a commit to one state, the one the
675// merge rule uses too: a failed check makes the commit failing even while
676// others are pending; else a pending check makes it pending; else it is a
677// success.
678func CombineChecks(checks []CICheck) CheckState {
679	if len(checks) == 0 {
680		return CheckStateNone
681	}
682	pending := false
683	for _, c := range checks {
684		switch {
685		case c.Status != CICompleted:
686			pending = true
687		case !c.Conclusion.PassesMerge():
688			return CheckStateFailure
689		}
690	}
691	if pending {
692		return CheckStatePending
693	}
694	return CheckStateSuccess
695}

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 45

↑↓ moveenter openesc close