仰望星辰工作室

gitcat

gitcat/ internal/store/repos.go 9.7 KB · 317 行 原始文件
1package store
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10)
11
12// Repo 表示一个 Git 仓库。
13type Repo struct {
14 ID int64
15 Name string
16 Description string
17 OwnerID *int64
18 DefaultBranch string
19 IsArchived bool
20 AIEnabled bool
21 CreatedAt time.Time
22 UpdatedAt time.Time
23}
24
25// RepoCard 是首页 / 后台列表用的仓库条目(附带展示统计)。
26type RepoCard struct {
27 Repo
28 OwnerName string
29 PushCount int
30 LastPushAt time.Time
31 LastWhatsNew string
32 LastPusher string
33}
34
35// CreateRepo 新建仓库记录。
36func (s *Store) CreateRepo(name, description string, ownerID int64) (*Repo, error) {
37 ts := nowString()
38 res, err := s.db.Exec(`INSERT INTO repos(name, description, owner_id, default_branch, created_at, updated_at)
39 VALUES(?, ?, ?, 'main', ?, ?)`, name, description, ownerID, ts, ts)
40 if err != nil {
41 if isUniqueErr(err) {
42 return nil, fmt.Errorf("项目 %q 已存在", name)
43 }
44 return nil, err
45 }
46 id, _ := res.LastInsertId()
47 return s.RepoByID(id)
48}
49
50const repoCols = `id, name, description, owner_id, default_branch, is_archived, ai_enabled, created_at, updated_at`
51
52func scanRepo(row interface {
53 Scan(dest ...any) error
54}) (*Repo, error) {
55 var r Repo
56 var owner sql.NullInt64
57 var archived, ai int
58 var created, updated string
59 if err := row.Scan(&r.ID, &r.Name, &r.Description, &owner, &r.DefaultBranch, &archived, &ai, &created, &updated); err != nil {
60 if errors.Is(err, sql.ErrNoRows) {
61 return nil, ErrNotFound
62 }
63 return nil, err
64 }
65 if owner.Valid {
66 id := owner.Int64
67 r.OwnerID = &id
68 }
69 r.IsArchived = archived == 1
70 r.AIEnabled = ai == 1
71 r.CreatedAt = parseTime(created)
72 r.UpdatedAt = parseTime(updated)
73 return &r, nil
74}
75
76// RepoByID 按主键查询仓库。
77func (s *Store) RepoByID(id int64) (*Repo, error) {
78 return scanRepo(s.db.QueryRow(`SELECT `+repoCols+` FROM repos WHERE id = ?`, id))
79}
80
81// RepoByName 按名称查询仓库(不区分大小写)。
82func (s *Store) RepoByName(name string) (*Repo, error) {
83 return scanRepo(s.db.QueryRow(`SELECT `+repoCols+` FROM repos WHERE name = ? COLLATE NOCASE`, name))
84}
85
86// ListRepos 返回仓库列表(含统计),q 非空时按名称/描述模糊搜索。
87func (s *Store) ListRepos(q string) ([]*RepoCard, error) {
88 query := `SELECT ` + repoCols + `,
89 COALESCE((SELECT username FROM users u WHERE u.id = repos.owner_id), ''),
90 (SELECT COUNT(*) FROM pushes p WHERE p.repo_id = repos.id),
91 COALESCE((SELECT MAX(created_at) FROM pushes p WHERE p.repo_id = repos.id), repos.updated_at),
92 COALESCE((SELECT whats_new FROM pushes p WHERE p.repo_id = repos.id ORDER BY id DESC LIMIT 1), ''),
93 COALESCE((SELECT actor_display FROM pushes p WHERE p.repo_id = repos.id ORDER BY id DESC LIMIT 1), '')
94 FROM repos`
95 var args []any
96 if strings.TrimSpace(q) != "" {
97 like := "%" + strings.TrimSpace(q) + "%"
98 query += ` WHERE name LIKE ? OR description LIKE ?`
99 args = append(args, like, like)
100 }
101 query += ` ORDER BY updated_at DESC, name COLLATE NOCASE ASC`
102 rows, err := s.db.Query(query, args...)
103 if err != nil {
104 return nil, err
105 }
106 defer rows.Close()
107 var out []*RepoCard
108 for rows.Next() {
109 var c RepoCard
110 var owner sql.NullInt64
111 var archived, ai int
112 var created, updated, lastPush string
113 if err := rows.Scan(&c.ID, &c.Name, &c.Description, &owner, &c.DefaultBranch, &archived, &ai,
114 &created, &updated, &c.OwnerName, &c.PushCount, &lastPush, &c.LastWhatsNew, &c.LastPusher); err != nil {
115 return nil, err
116 }
117 if owner.Valid {
118 id := owner.Int64
119 c.OwnerID = &id
120 }
121 c.IsArchived = archived == 1
122 c.AIEnabled = ai == 1
123 c.CreatedAt = parseTime(created)
124 c.UpdatedAt = parseTime(updated)
125 c.LastPushAt = parseTime(lastPush)
126 out = append(out, &c)
127 }
128 return out, rows.Err()
129}
130
131// UpdateRepo 保存仓库的可编辑字段。
132//
133// 注意:这里刻意不写 name。仓库名同时决定了磁盘上的目录
134// (<data>/repos/<name>.git),如果这里允许改名而调用方忘了同步重命名
135// 目录,数据库与磁盘就会脱钩,仓库直接变成"数据缺失"。
136// 改名请走 RenameRepo —— 它把"改名字"和"改目录"作为同一个操作。
137func (s *Store) UpdateRepo(r *Repo) error {
138 b2i := func(b bool) int {
139 if b {
140 return 1
141 }
142 return 0
143 }
144 _, err := s.db.Exec(`UPDATE repos SET description = ?, default_branch = ?, is_archived = ?, ai_enabled = ?, updated_at = ? WHERE id = ?`,
145 r.Description, r.DefaultBranch, b2i(r.IsArchived), b2i(r.AIEnabled), nowString(), r.ID)
146 return err
147}
148
149// RenameRepo 修改仓库名。
150//
151// 调用方负责保证磁盘目录已经(或即将)同步到新名字;本方法只负责数据层。
152func (s *Store) RenameRepo(id int64, newName string) error {
153 res, err := s.db.Exec(`UPDATE repos SET name = ?, updated_at = ? WHERE id = ?`, newName, nowString(), id)
154 if err != nil {
155 if isUniqueErr(err) {
156 return fmt.Errorf("项目 %q 已存在", newName)
157 }
158 return err
159 }
160 if n, _ := res.RowsAffected(); n == 0 {
161 return ErrNotFound
162 }
163 return nil
164}
165
166// SetRepoOwner 单独更新仓库的所属者。
167func (s *Store) SetRepoOwner(id int64, ownerID *int64) error {
168 if ownerID == nil {
169 _, err := s.db.Exec(`UPDATE repos SET owner_id = NULL WHERE id = ?`, id)
170 return err
171 }
172 _, err := s.db.Exec(`UPDATE repos SET owner_id = ? WHERE id = ?`, *ownerID, id)
173 return err
174}
175
176// TouchRepo 更新仓库的最近活跃时间。
177func (s *Store) TouchRepo(id int64) error {
178 _, err := s.db.Exec(`UPDATE repos SET updated_at = ? WHERE id = ?`, nowString(), id)
179 return err
180}
181
182// DeleteRepo 删除仓库记录(git 目录由调用方清理)。
183func (s *Store) DeleteRepo(id int64) error {
184 _, err := s.db.Exec(`DELETE FROM repos WHERE id = ?`, id)
185 return err
186}
187
188// PushCommit 是推送记录中单个提交的摘要(用于留痕展示)。
189type PushCommit struct {
190 SHA string `json:"sha"`
191 Subject string `json:"subject"`
192 Author string `json:"author"`
193 When int64 `json:"when"`
194 Additions int `json:"additions"`
195 Deletions int `json:"deletions"`
196 Files int `json:"files"`
197}
198
199// Push 表示一次推送事件。
200type Push struct {
201 ID int64
202 RepoID int64
203 RepoName string
204 UserID *int64
205 ActorUsername string
206 ActorDisplay string
207 Ref string
208 OldSHA string
209 NewSHA string
210 CommitCount int
211 Details []PushCommit
212 WhatsNew string
213 AIStatus string
214 CreatedAt time.Time
215}
216
217// CreatePush 记录一次推送。
218func (s *Store) CreatePush(repoID int64, userID *int64, actorUsername, actorDisplay, ref, oldSHA, newSHA string, details []PushCommit) (*Push, error) {
219 raw, _ := json.Marshal(details)
220 res, err := s.db.Exec(`INSERT INTO pushes(repo_id, user_id, actor_username, actor_display, ref, old_sha, new_sha, commit_count, details, ai_status, created_at)
221 VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
222 repoID, userID, actorUsername, actorDisplay, ref, oldSHA, newSHA, len(details), string(raw), nowString())
223 if err != nil {
224 return nil, err
225 }
226 id, _ := res.LastInsertId()
227 return s.PushByID(id)
228}
229
230const pushCols = `p.id, p.repo_id, COALESCE(r.name, ''), p.user_id, p.actor_username, p.actor_display, p.ref, p.old_sha, p.new_sha,
231 p.commit_count, p.details, p.whats_new, p.ai_status, p.created_at`
232
233func scanPush(scan func(dest ...any) error) (*Push, error) {
234 var p Push
235 var userID sql.NullInt64
236 var details, created string
237 if err := scan(&p.ID, &p.RepoID, &p.RepoName, &userID, &p.ActorUsername, &p.ActorDisplay, &p.Ref,
238 &p.OldSHA, &p.NewSHA, &p.CommitCount, &details, &p.WhatsNew, &p.AIStatus, &created); err != nil {
239 if errors.Is(err, sql.ErrNoRows) {
240 return nil, ErrNotFound
241 }
242 return nil, err
243 }
244 if userID.Valid {
245 id := userID.Int64
246 p.UserID = &id
247 }
248 _ = json.Unmarshal([]byte(details), &p.Details)
249 p.CreatedAt = parseTime(created)
250 return &p, nil
251}
252
253// PushByID 按主键查询推送记录。
254func (s *Store) PushByID(id int64) (*Push, error) {
255 return scanPush(s.db.QueryRow(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id WHERE p.id = ?`, id).Scan)
256}
257
258// PushesByRepo 返回某个仓库的推送记录。
259func (s *Store) PushesByRepo(repoID int64, limit int) ([]*Push, error) {
260 rows, err := s.db.Query(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id
261 WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT ?`, repoID, limit)
262 if err != nil {
263 return nil, err
264 }
265 defer rows.Close()
266 var out []*Push
267 for rows.Next() {
268 p, err := scanPush(rows.Scan)
269 if err != nil {
270 return nil, err
271 }
272 out = append(out, p)
273 }
274 return out, rows.Err()
275}
276
277// LatestPush 返回仓库最近一次推送,没有则返回 nil。
278func (s *Store) LatestPush(repoID int64) (*Push, error) {
279 p, err := scanPush(s.db.QueryRow(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id
280 WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT 1`, repoID).Scan)
281 if errors.Is(err, ErrNotFound) {
282 return nil, nil
283 }
284 return p, err
285}
286
287// RecentPushes 返回全站最近的推送动态。
288func (s *Store) RecentPushes(limit int) ([]*Push, error) {
289 rows, err := s.db.Query(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id
290 ORDER BY p.id DESC LIMIT ?`, limit)
291 if err != nil {
292 return nil, err
293 }
294 defer rows.Close()
295 var out []*Push
296 for rows.Next() {
297 p, err := scanPush(rows.Scan)
298 if err != nil {
299 return nil, err
300 }
301 out = append(out, p)
302 }
303 return out, rows.Err()
304}
305
306// SetPushSummary 写入 AI 生成的 whats new(或降级摘要)。
307func (s *Store) SetPushSummary(id int64, whatsNew, status string) error {
308 _, err := s.db.Exec(`UPDATE pushes SET whats_new = ?, ai_status = ? WHERE id = ?`, whatsNew, status, id)
309 return err
310}
311
312// PushesByUser 返回某用户的推送数。
313func (s *Store) PushesByUser(userID int64) (int, error) {
314 var n int
315 err := s.db.QueryRow(`SELECT COUNT(*) FROM pushes WHERE user_id = ?`, userID).Scan(&n)
316 return n, err
317}