tenseleyflow/shithub / 32f7d5e

Browse files

S32: add repos.NormalizeTopics + ReplaceTopics orchestrator

Authored by espadonne
SHA
32f7d5e2cd81a561c7434fb4561288c01e305214
Parents
e19cf78
Tree
f7b53b4

1 changed file

StatusFile+-
A internal/repos/topics.go 92 0
internal/repos/topics.goadded
@@ -0,0 +1,92 @@
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
+}