Go · 4566 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package checks
4
5 import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "strings"
11 "time"
12
13 "github.com/jackc/pgx/v5"
14 "github.com/jackc/pgx/v5/pgtype"
15
16 checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
17 )
18
19 // CreateParams describes a check-run create request. Defaults
20 // (per spec): status='queued', app_slug='external'.
21 type CreateParams struct {
22 RepoID int64
23 HeadSHA string
24 AppSlug string // "" → "external"
25 Name string
26 Status string // "" → "queued"
27 Conclusion string // optional
28 StartedAt time.Time
29 CompletedAt time.Time
30 DetailsURL string
31 Output Output
32 ExternalID string // optional; enables idempotent retry-create
33 }
34
35 // Create inserts (or returns existing-by-external-id) a check run,
36 // auto-creating the matching suite. Suite rollup is recomputed inside
37 // the same tx so the API response reflects the latest derived state.
38 //
39 // `external_id` semantics:
40 // - When empty: always insert a new run.
41 // - When non-empty: lookup by (repo, head_sha, name, external_id);
42 // if found, return existing without insert. This makes retries
43 // by external CI safe.
44 func Create(ctx context.Context, deps Deps, p CreateParams) (checksdb.CheckRun, error) {
45 if err := validateCreate(&p); err != nil {
46 return checksdb.CheckRun{}, err
47 }
48 q := checksdb.New()
49
50 // Idempotency check.
51 if p.ExternalID != "" {
52 existing, err := q.GetCheckRunByExternalID(ctx, deps.Pool, checksdb.GetCheckRunByExternalIDParams{
53 RepoID: p.RepoID,
54 HeadSha: p.HeadSHA,
55 Name: p.Name,
56 ExternalID: pgtype.Text{String: p.ExternalID, Valid: true},
57 })
58 if err == nil {
59 return existing, nil
60 }
61 if !errors.Is(err, pgx.ErrNoRows) {
62 return checksdb.CheckRun{}, err
63 }
64 }
65
66 tx, err := deps.Pool.Begin(ctx)
67 if err != nil {
68 return checksdb.CheckRun{}, err
69 }
70 committed := false
71 defer func() {
72 if !committed {
73 _ = tx.Rollback(ctx)
74 }
75 }()
76
77 suite, err := q.GetOrCreateCheckSuite(ctx, tx, checksdb.GetOrCreateCheckSuiteParams{
78 RepoID: p.RepoID,
79 HeadSha: p.HeadSHA,
80 AppSlug: p.AppSlug,
81 })
82 if err != nil {
83 return checksdb.CheckRun{}, fmt.Errorf("suite: %w", err)
84 }
85
86 outputJSON, err := json.Marshal(p.Output)
87 if err != nil {
88 return checksdb.CheckRun{}, fmt.Errorf("output marshal: %w", err)
89 }
90
91 startedAt := pgtype.Timestamptz{}
92 if !p.StartedAt.IsZero() {
93 startedAt = pgtype.Timestamptz{Time: p.StartedAt, Valid: true}
94 }
95 completedAt := pgtype.Timestamptz{}
96 if !p.CompletedAt.IsZero() {
97 completedAt = pgtype.Timestamptz{Time: p.CompletedAt, Valid: true}
98 }
99 conclusion := checksdb.NullCheckConclusion{}
100 if p.Conclusion != "" {
101 conclusion = checksdb.NullCheckConclusion{
102 CheckConclusion: checksdb.CheckConclusion(p.Conclusion),
103 Valid: true,
104 }
105 }
106 extID := pgtype.Text{}
107 if p.ExternalID != "" {
108 extID = pgtype.Text{String: p.ExternalID, Valid: true}
109 }
110
111 run, err := q.CreateCheckRun(ctx, tx, checksdb.CreateCheckRunParams{
112 SuiteID: suite.ID,
113 RepoID: p.RepoID,
114 HeadSha: p.HeadSHA,
115 Name: p.Name,
116 Status: checksdb.CheckStatus(p.Status),
117 Conclusion: conclusion,
118 StartedAt: startedAt,
119 CompletedAt: completedAt,
120 DetailsUrl: p.DetailsURL,
121 Output: outputJSON,
122 ExternalID: extID,
123 })
124 if err != nil {
125 return checksdb.CheckRun{}, fmt.Errorf("insert run: %w", err)
126 }
127
128 if err := rollupSuiteInTx(ctx, tx, suite.ID); err != nil {
129 return checksdb.CheckRun{}, err
130 }
131
132 if err := tx.Commit(ctx); err != nil {
133 return checksdb.CheckRun{}, err
134 }
135 committed = true
136 return run, nil
137 }
138
139 // validateCreate normalizes defaults + applies per-field bounds.
140 func validateCreate(p *CreateParams) error {
141 p.Name = strings.TrimSpace(p.Name)
142 if p.Name == "" {
143 return ErrEmptyName
144 }
145 if len(p.Name) > 200 {
146 return ErrNameTooLong
147 }
148 p.HeadSHA = strings.TrimSpace(p.HeadSHA)
149 if len(p.HeadSHA) < 7 || len(p.HeadSHA) > 64 {
150 return ErrShortHeadSHA
151 }
152 p.AppSlug = strings.TrimSpace(p.AppSlug)
153 if p.AppSlug == "" {
154 p.AppSlug = "external"
155 }
156 p.Status = strings.TrimSpace(p.Status)
157 if p.Status == "" {
158 p.Status = "queued"
159 }
160 if !validStatus(p.Status) {
161 return ErrInvalidStatus
162 }
163 if p.Conclusion != "" && !validConclusion(p.Conclusion) {
164 return ErrInvalidConclusion
165 }
166 if p.Status == "completed" && p.Conclusion == "" {
167 return ErrCompletedNeedsConclusion
168 }
169 if len(p.Output.Text) > MaxOutputTextBytes {
170 return ErrOutputTextTooLarge
171 }
172 if len(p.Output.Summary) > MaxOutputSummaryBytes {
173 return ErrOutputSummaryTooLarge
174 }
175 return nil
176 }
177