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}