Go · 5631 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package issues
4
5 import (
6 "context"
7 "errors"
8 "regexp"
9 "strconv"
10 "strings"
11
12 "github.com/jackc/pgx/v5/pgconn"
13 "github.com/jackc/pgx/v5/pgtype"
14
15 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
16 )
17
18 // DefaultLabels is the seeded set applied to every new repo. Names +
19 // colors are GitHub-aligned so users feel at home.
20 var DefaultLabels = []LabelSeed{
21 {"bug", "d73a4a", "Something isn't working"},
22 {"documentation", "0075ca", "Improvements or additions to documentation"},
23 {"duplicate", "cfd3d7", "This issue or pull request already exists"},
24 {"enhancement", "a2eeef", "New feature or request"},
25 {"good first issue", "7057ff", "Good for newcomers"},
26 {"help wanted", "008672", "Extra attention is needed"},
27 {"invalid", "e4e669", "This doesn't seem right"},
28 {"question", "d876e3", "Further information is requested"},
29 {"wontfix", "ffffff", "This will not be worked on"},
30 }
31
32 type LabelSeed struct{ Name, Color, Description string }
33
34 var reHexColor = regexp.MustCompile(`^[0-9a-fA-F]{6}$`)
35
36 // LabelCreateParams is input for CreateLabel.
37 type LabelCreateParams struct {
38 RepoID int64
39 Name string
40 Color string
41 Description string
42 }
43
44 // CreateLabel validates and inserts a label. Color must be 6 hex chars.
45 func CreateLabel(ctx context.Context, deps Deps, p LabelCreateParams) (issuesdb.Label, error) {
46 name := strings.TrimSpace(p.Name)
47 if name == "" || len(name) > 50 {
48 return issuesdb.Label{}, errors.New("issues: label name length 1–50")
49 }
50 color := normalizeColor(p.Color)
51 if !reHexColor.MatchString(color) {
52 return issuesdb.Label{}, ErrLabelInvalidColor
53 }
54 q := issuesdb.New()
55 row, err := q.CreateLabel(ctx, deps.Pool, issuesdb.CreateLabelParams{
56 RepoID: p.RepoID,
57 Name: name,
58 Color: strings.ToLower(color),
59 Description: p.Description,
60 })
61 if err != nil {
62 if isUniqueViolation(err) {
63 return issuesdb.Label{}, ErrLabelExists
64 }
65 return issuesdb.Label{}, err
66 }
67 return row, nil
68 }
69
70 // SeedDefaultLabels writes the DefaultLabels into the given repo on
71 // the supplied DBTX (typically the repo-create transaction). Existing
72 // rows are left alone — caller can re-run safely.
73 func SeedDefaultLabels(ctx context.Context, db issuesdb.DBTX, repoID int64) error {
74 q := issuesdb.New()
75 for _, l := range DefaultLabels {
76 _, err := q.CreateLabel(ctx, db, issuesdb.CreateLabelParams{
77 RepoID: repoID,
78 Name: l.Name,
79 Color: l.Color,
80 Description: l.Description,
81 })
82 if err != nil && !isUniqueViolation(err) {
83 return err
84 }
85 }
86 return nil
87 }
88
89 // LabelUpdateParams is input for UpdateLabel.
90 type LabelUpdateParams struct {
91 ID int64
92 Name string
93 Color string
94 Description string
95 }
96
97 func UpdateLabel(ctx context.Context, deps Deps, p LabelUpdateParams) error {
98 name := strings.TrimSpace(p.Name)
99 if name == "" || len(name) > 50 {
100 return errors.New("issues: label name length 1–50")
101 }
102 color := normalizeColor(p.Color)
103 if !reHexColor.MatchString(color) {
104 return ErrLabelInvalidColor
105 }
106 q := issuesdb.New()
107 if err := q.UpdateLabel(ctx, deps.Pool, issuesdb.UpdateLabelParams{
108 ID: p.ID,
109 Name: name,
110 Color: strings.ToLower(color),
111 Description: p.Description,
112 }); err != nil {
113 if isUniqueViolation(err) {
114 return ErrLabelExists
115 }
116 return err
117 }
118 return nil
119 }
120
121 // DeleteLabel removes a label.
122 func DeleteLabel(ctx context.Context, deps Deps, id int64) error {
123 q := issuesdb.New()
124 return q.DeleteLabel(ctx, deps.Pool, id)
125 }
126
127 // ApplyLabels replaces the issue's label set with `labelIDs`. Emits a
128 // `labeled` / `unlabeled` event for each delta. Caller is the actor.
129 func ApplyLabels(ctx context.Context, deps Deps, actorUserID, issueID int64, labelIDs []int64) error {
130 q := issuesdb.New()
131 tx, err := deps.Pool.Begin(ctx)
132 if err != nil {
133 return err
134 }
135 committed := false
136 defer func() {
137 if !committed {
138 _ = tx.Rollback(ctx)
139 }
140 }()
141 current, err := q.ListLabelsOnIssue(ctx, tx, issueID)
142 if err != nil {
143 return err
144 }
145 want := map[int64]struct{}{}
146 for _, id := range labelIDs {
147 want[id] = struct{}{}
148 }
149 have := map[int64]struct{}{}
150 for _, l := range current {
151 have[l.ID] = struct{}{}
152 }
153 actor := pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0}
154 for id := range want {
155 if _, ok := have[id]; ok {
156 continue
157 }
158 if err := q.AddIssueLabel(ctx, tx, issuesdb.AddIssueLabelParams{
159 IssueID: issueID, LabelID: id, AppliedByUserID: actor,
160 }); err != nil {
161 return err
162 }
163 if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
164 IssueID: issueID, ActorUserID: actor, Kind: "labeled",
165 Meta: []byte(`{"label_id":` + strconv.FormatInt(id, 10) + `}`),
166 }); err != nil {
167 return err
168 }
169 }
170 for id := range have {
171 if _, ok := want[id]; ok {
172 continue
173 }
174 if err := q.RemoveIssueLabel(ctx, tx, issuesdb.RemoveIssueLabelParams{
175 IssueID: issueID, LabelID: id,
176 }); err != nil {
177 return err
178 }
179 if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
180 IssueID: issueID, ActorUserID: actor, Kind: "unlabeled",
181 Meta: []byte(`{"label_id":` + strconv.FormatInt(id, 10) + `}`),
182 }); err != nil {
183 return err
184 }
185 }
186 if err := tx.Commit(ctx); err != nil {
187 return err
188 }
189 committed = true
190 return nil
191 }
192
193 func normalizeColor(c string) string {
194 return strings.TrimPrefix(strings.TrimSpace(c), "#")
195 }
196
197 // isUniqueViolation maps Postgres SQLSTATE 23505 (unique_violation).
198 func isUniqueViolation(err error) bool {
199 var pgErr *pgconn.PgError
200 if errors.As(err, &pgErr) {
201 return pgErr.Code == "23505"
202 }
203 return false
204 }
205