Go · 2332 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repos
4
5 import (
6 "context"
7 "errors"
8 "regexp"
9 "strings"
10
11 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
12 )
13
14 // MaxTopicsPerRepo and MaxTopicLength match the spec's day-1 lean
15 // (20 topics, 50 chars each). The shape regex covers GitHub's topic
16 // rules: lowercase letters/digits/hyphens, can't start/end with a
17 // hyphen, 1–50 chars.
18 const (
19 MaxTopicsPerRepo = 20
20 MaxTopicLength = 50
21 )
22
23 // Errors surfaced by topic mutation.
24 var (
25 ErrTooManyTopics = errors.New("repos: too many topics (max 20)")
26 ErrInvalidTopic = errors.New("repos: topic must be lowercase letters, digits, and hyphens; 1-50 chars")
27 )
28
29 var topicRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$`)
30
31 // NormalizeTopics dedups + lowercases an incoming list and validates
32 // each entry. Returns the cleaned slice (stable-ordered, dedup'd) or
33 // the first validation error.
34 func NormalizeTopics(raw []string) ([]string, error) {
35 seen := map[string]struct{}{}
36 out := make([]string, 0, len(raw))
37 for _, t := range raw {
38 t = strings.ToLower(strings.TrimSpace(t))
39 if t == "" {
40 continue
41 }
42 if !topicRE.MatchString(t) {
43 return nil, ErrInvalidTopic
44 }
45 if _, dup := seen[t]; dup {
46 continue
47 }
48 seen[t] = struct{}{}
49 out = append(out, t)
50 }
51 if len(out) > MaxTopicsPerRepo {
52 return nil, ErrTooManyTopics
53 }
54 return out, nil
55 }
56
57 // ReplaceTopics atomically swaps the topic set for a repo. The
58 // orchestrator owns tx ordering: DELETE + per-row INSERT in one tx
59 // so a partial failure rolls back. The unique index on (repo_id,
60 // topic) makes the inserts idempotent against a concurrent run.
61 func ReplaceTopics(ctx context.Context, deps Deps, repoID int64, topics []string) error {
62 clean, err := NormalizeTopics(topics)
63 if err != nil {
64 return err
65 }
66 tx, err := deps.Pool.Begin(ctx)
67 if err != nil {
68 return err
69 }
70 committed := false
71 defer func() {
72 if !committed {
73 _ = tx.Rollback(ctx)
74 }
75 }()
76 q := reposdb.New()
77 if err := q.ReplaceRepoTopics(ctx, tx, repoID); err != nil {
78 return err
79 }
80 for _, t := range clean {
81 if err := q.InsertRepoTopic(ctx, tx, reposdb.InsertRepoTopicParams{
82 RepoID: repoID, Topic: t,
83 }); err != nil {
84 return err
85 }
86 }
87 if err := tx.Commit(ctx); err != nil {
88 return err
89 }
90 committed = true
91 return nil
92 }
93