Go · 13858 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package social
4
5 import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "strconv"
11 "strings"
12 "time"
13
14 "github.com/jackc/pgx/v5"
15 "github.com/jackc/pgx/v5/pgtype"
16
17 socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
18 )
19
20 const (
21 defaultFeedLimit int32 = 30
22 defaultTrendingLimit int32 = 10
23 )
24
25 type TrendingScope string
26
27 const (
28 TrendingScopeDay TrendingScope = "day"
29 TrendingScopeWeek TrendingScope = "week"
30 TrendingScopeMonth TrendingScope = "month"
31 )
32
33 // FeedCursor is S42's keyset cursor over (created_at, event_id).
34 type FeedCursor struct {
35 BeforeCreatedAt time.Time
36 BeforeID int64
37 }
38
39 type FeedRepo struct {
40 ID int64
41 Owner string
42 Name string
43 Description string
44 PrimaryLanguage string
45 StarCount int64
46 ForkCount int64
47 }
48
49 type FeedItem struct {
50 ID int64
51 Kind string
52 Verb string
53 ActorUsername string
54 ActorDisplayName string
55 CreatedAt time.Time
56 Repo *FeedRepo
57 RepoFullName string
58 RepoURL string
59 SourceName string
60 SourceURL string
61 ItemTitle string
62 ItemURL string
63 }
64
65 type DashboardRepo struct {
66 ID int64
67 Name string
68 Description string
69 Visibility string
70 PrimaryLanguage string
71 StarCount int64
72 ForkCount int64
73 UpdatedAt time.Time
74 }
75
76 type TrendingRepo struct {
77 RepoID int64 `json:"repo_id"`
78 Owner string `json:"owner"`
79 Name string `json:"name"`
80 Description string `json:"description"`
81 PrimaryLanguage string `json:"primary_language,omitempty"`
82 StarCount int64 `json:"star_count"`
83 ForkCount int64 `json:"fork_count"`
84 Score int64 `json:"score"`
85 }
86
87 type TrendingUser struct {
88 UserID int64 `json:"user_id"`
89 Username string `json:"username"`
90 DisplayName string `json:"display_name"`
91 Score int64 `json:"score"`
92 FollowerDelta int64 `json:"follower_delta"`
93 EventCount int64 `json:"event_count"`
94 }
95
96 // DashboardFeed returns public feed rows from followed users, followed
97 // orgs, watched repos, and the viewer's own public activity.
98 func DashboardFeed(ctx context.Context, deps Deps, viewerUserID int64, cursor FeedCursor, limit int32) ([]FeedItem, error) {
99 if viewerUserID == 0 {
100 return nil, ErrNotLoggedIn
101 }
102 if limit <= 0 || limit > 100 {
103 limit = defaultFeedLimit
104 }
105 params := socialdb.ListDashboardFeedEventsParams{
106 ViewerUserID: viewerUserID,
107 LimitCount: limit,
108 }
109 if !cursor.BeforeCreatedAt.IsZero() && cursor.BeforeID > 0 {
110 params.BeforeCreatedAt = pgtype.Timestamptz{Time: cursor.BeforeCreatedAt, Valid: true}
111 params.BeforeID = pgtype.Int8{Int64: cursor.BeforeID, Valid: true}
112 }
113 rows, err := socialdb.New().ListDashboardFeedEvents(ctx, deps.Pool, params)
114 if err != nil {
115 return nil, fmt.Errorf("dashboard feed: %w", err)
116 }
117 out := make([]FeedItem, 0, len(rows))
118 for _, row := range rows {
119 out = append(out, feedItemFromDashboardRow(row))
120 }
121 return out, nil
122 }
123
124 // PublicFeed returns the global public activity feed used by Explore.
125 func PublicFeed(ctx context.Context, deps Deps, cursor FeedCursor, limit int32) ([]FeedItem, error) {
126 if limit <= 0 || limit > 100 {
127 limit = defaultFeedLimit
128 }
129 params := socialdb.ListPublicFeedEventsParams{LimitCount: limit}
130 if !cursor.BeforeCreatedAt.IsZero() && cursor.BeforeID > 0 {
131 params.BeforeCreatedAt = pgtype.Timestamptz{Time: cursor.BeforeCreatedAt, Valid: true}
132 params.BeforeID = pgtype.Int8{Int64: cursor.BeforeID, Valid: true}
133 }
134 rows, err := socialdb.New().ListPublicFeedEvents(ctx, deps.Pool, params)
135 if err != nil {
136 return nil, fmt.Errorf("public feed: %w", err)
137 }
138 out := make([]FeedItem, 0, len(rows))
139 for _, row := range rows {
140 out = append(out, feedItemFromPublicRow(row))
141 }
142 return out, nil
143 }
144
145 func DashboardRepos(ctx context.Context, deps Deps, viewerUserID int64, limit int32) ([]DashboardRepo, error) {
146 if limit <= 0 || limit > 20 {
147 limit = 8
148 }
149 rows, err := socialdb.New().ListDashboardReposForUser(ctx, deps.Pool, socialdb.ListDashboardReposForUserParams{
150 OwnerUserID: pgtype.Int8{Int64: viewerUserID, Valid: true},
151 Limit: limit,
152 })
153 if err != nil {
154 return nil, fmt.Errorf("dashboard repos: %w", err)
155 }
156 out := make([]DashboardRepo, 0, len(rows))
157 for _, row := range rows {
158 out = append(out, DashboardRepo{
159 ID: row.RepoID, Name: row.Name, Description: row.Description,
160 Visibility: string(row.Visibility), PrimaryLanguage: row.PrimaryLanguage,
161 StarCount: row.StarCount, ForkCount: row.ForkCount,
162 UpdatedAt: timeFromPG(row.UpdatedAt),
163 })
164 }
165 return out, nil
166 }
167
168 func TrendingRepos(ctx context.Context, deps Deps, windowDays, limit int32) ([]TrendingRepo, error) {
169 if windowDays <= 0 {
170 windowDays = 7
171 }
172 if limit <= 0 || limit > 50 {
173 limit = defaultTrendingLimit
174 }
175 rows, err := socialdb.New().ListTrendingRepos(ctx, deps.Pool, socialdb.ListTrendingReposParams{
176 LimitCount: limit,
177 WindowDays: windowDays,
178 })
179 if err != nil {
180 return nil, fmt.Errorf("trending repos: %w", err)
181 }
182 out := make([]TrendingRepo, 0, len(rows))
183 for _, row := range rows {
184 out = append(out, TrendingRepo{
185 RepoID: row.RepoID, Owner: row.Owner, Name: row.Name,
186 Description: row.Description, PrimaryLanguage: row.PrimaryLanguage,
187 StarCount: row.StarCount, ForkCount: row.ForkCount, Score: row.Score,
188 })
189 }
190 return out, nil
191 }
192
193 func CachedTrendingRepos(ctx context.Context, deps Deps, scope TrendingScope, windowDays, limit int32) ([]TrendingRepo, error) {
194 if limit <= 0 || limit > 50 {
195 limit = defaultTrendingLimit
196 }
197 row, err := socialdb.New().LatestTrendingSnapshot(ctx, deps.Pool, socialdb.LatestTrendingSnapshotParams{
198 Scope: dbTrendingScope(scope),
199 Kind: socialdb.TrendingKindRepos,
200 })
201 if errors.Is(err, pgx.ErrNoRows) {
202 return TrendingRepos(ctx, deps, windowDays, limit)
203 }
204 if err != nil {
205 return nil, fmt.Errorf("latest trending repos snapshot: %w", err)
206 }
207 var repos []TrendingRepo
208 if err := json.Unmarshal(row.Payload, &repos); err != nil {
209 return nil, fmt.Errorf("decode trending repos snapshot: %w", err)
210 }
211 if int32(len(repos)) > limit {
212 repos = repos[:limit]
213 }
214 return repos, nil
215 }
216
217 func TrendingUsers(ctx context.Context, deps Deps, windowDays, limit int32) ([]TrendingUser, error) {
218 if windowDays <= 0 {
219 windowDays = 7
220 }
221 if limit <= 0 || limit > 50 {
222 limit = defaultTrendingLimit
223 }
224 rows, err := socialdb.New().ListTrendingUsers(ctx, deps.Pool, socialdb.ListTrendingUsersParams{
225 LimitCount: limit,
226 WindowDays: windowDays,
227 })
228 if err != nil {
229 return nil, fmt.Errorf("trending users: %w", err)
230 }
231 out := make([]TrendingUser, 0, len(rows))
232 for _, row := range rows {
233 out = append(out, TrendingUser{
234 UserID: row.UserID, Username: row.Username, DisplayName: row.DisplayName,
235 Score: row.Score, FollowerDelta: row.FollowerDelta, EventCount: row.EventCount,
236 })
237 }
238 return out, nil
239 }
240
241 func CachedTrendingUsers(ctx context.Context, deps Deps, scope TrendingScope, windowDays, limit int32) ([]TrendingUser, error) {
242 if limit <= 0 || limit > 50 {
243 limit = defaultTrendingLimit
244 }
245 row, err := socialdb.New().LatestTrendingSnapshot(ctx, deps.Pool, socialdb.LatestTrendingSnapshotParams{
246 Scope: dbTrendingScope(scope),
247 Kind: socialdb.TrendingKindUsers,
248 })
249 if errors.Is(err, pgx.ErrNoRows) {
250 return TrendingUsers(ctx, deps, windowDays, limit)
251 }
252 if err != nil {
253 return nil, fmt.Errorf("latest trending users snapshot: %w", err)
254 }
255 var users []TrendingUser
256 if err := json.Unmarshal(row.Payload, &users); err != nil {
257 return nil, fmt.Errorf("decode trending users snapshot: %w", err)
258 }
259 if int32(len(users)) > limit {
260 users = users[:limit]
261 }
262 return users, nil
263 }
264
265 // CaptureTrendingSnapshots computes the S42 denormalized rankings for
266 // day/week/month windows. It is idempotent in behavior: inserting a new
267 // snapshot never mutates prior rows, so stale readers still have a valid
268 // last-known ranking.
269 func CaptureTrendingSnapshots(ctx context.Context, deps Deps) error {
270 q := socialdb.New()
271 windows := []struct {
272 scope socialdb.TrendingScope
273 days int32
274 }{
275 {scope: socialdb.TrendingScopeDay, days: 1},
276 {scope: socialdb.TrendingScopeWeek, days: 7},
277 {scope: socialdb.TrendingScopeMonth, days: 30},
278 }
279 for _, window := range windows {
280 repos, err := TrendingRepos(ctx, deps, window.days, 50)
281 if err != nil {
282 return err
283 }
284 body, err := json.Marshal(repos)
285 if err != nil {
286 return fmt.Errorf("marshal trending repos: %w", err)
287 }
288 if _, err := q.InsertTrendingSnapshot(ctx, deps.Pool, socialdb.InsertTrendingSnapshotParams{
289 Scope: window.scope, Kind: socialdb.TrendingKindRepos, Payload: body,
290 }); err != nil {
291 return fmt.Errorf("insert trending repos snapshot: %w", err)
292 }
293
294 users, err := TrendingUsers(ctx, deps, window.days, 50)
295 if err != nil {
296 return err
297 }
298 body, err = json.Marshal(users)
299 if err != nil {
300 return fmt.Errorf("marshal trending users: %w", err)
301 }
302 if _, err := q.InsertTrendingSnapshot(ctx, deps.Pool, socialdb.InsertTrendingSnapshotParams{
303 Scope: window.scope, Kind: socialdb.TrendingKindUsers, Payload: body,
304 }); err != nil {
305 return fmt.Errorf("insert trending users snapshot: %w", err)
306 }
307 }
308 return nil
309 }
310
311 func feedItemFromDashboardRow(row socialdb.ListDashboardFeedEventsRow) FeedItem {
312 return feedItemFromParts(feedParts{
313 id: row.ID, kind: row.Kind, actorUsername: row.ActorUsername,
314 actorDisplayName: row.ActorDisplayName, createdAt: row.CreatedAt,
315 repoID: row.RepoID, repoOwner: row.RepoOwner, repoName: row.RepoName,
316 repoDescription: row.RepoDescription, repoPrimaryLanguage: row.RepoPrimaryLanguage,
317 repoStarCount: row.RepoStarCount, repoForkCount: row.RepoForkCount,
318 sourceName: row.SourceName, payload: row.Payload,
319 })
320 }
321
322 func feedItemFromPublicRow(row socialdb.ListPublicFeedEventsRow) FeedItem {
323 return feedItemFromParts(feedParts{
324 id: row.ID, kind: row.Kind, actorUsername: row.ActorUsername,
325 actorDisplayName: row.ActorDisplayName, createdAt: row.CreatedAt,
326 repoID: row.RepoID, repoOwner: row.RepoOwner, repoName: row.RepoName,
327 repoDescription: row.RepoDescription, repoPrimaryLanguage: row.RepoPrimaryLanguage,
328 repoStarCount: row.RepoStarCount, repoForkCount: row.RepoForkCount,
329 sourceName: row.SourceName, payload: row.Payload,
330 })
331 }
332
333 type feedParts struct {
334 id int64
335 kind string
336 actorUsername string
337 actorDisplayName string
338 createdAt pgtype.Timestamptz
339 repoID pgtype.Int8
340 repoOwner string
341 repoName string
342 repoDescription string
343 repoPrimaryLanguage string
344 repoStarCount int64
345 repoForkCount int64
346 sourceName string
347 payload []byte
348 }
349
350 func feedItemFromParts(p feedParts) FeedItem {
351 item := FeedItem{
352 ID: p.id, Kind: p.kind, Verb: feedVerb(p.kind),
353 ActorUsername: p.actorUsername, ActorDisplayName: p.actorDisplayName,
354 CreatedAt: timeFromPG(p.createdAt), SourceName: p.sourceName,
355 }
356 if p.repoID.Valid && p.repoOwner != "" && p.repoName != "" {
357 item.Repo = &FeedRepo{
358 ID: p.repoID.Int64, Owner: p.repoOwner, Name: p.repoName,
359 Description: p.repoDescription, PrimaryLanguage: p.repoPrimaryLanguage,
360 StarCount: p.repoStarCount, ForkCount: p.repoForkCount,
361 }
362 item.RepoFullName = p.repoOwner + "/" + p.repoName
363 item.RepoURL = "/" + item.RepoFullName
364 }
365 item.ItemTitle, item.ItemURL = feedItemTarget(p.kind, p.payload, item)
366 if item.SourceName != "" {
367 item.SourceURL = "/" + item.SourceName
368 }
369 return item
370 }
371
372 func feedVerb(kind string) string {
373 switch kind {
374 case "star":
375 return "starred"
376 case "unstar":
377 return "unstarred"
378 case "forked":
379 return "forked"
380 case "push":
381 return "pushed to"
382 case "repo_created":
383 return "created"
384 case "issue_created":
385 return "opened an issue in"
386 case "issue_comment_created":
387 return "commented on an issue in"
388 case "issue_closed":
389 return "closed an issue in"
390 case "issue_reopened":
391 return "reopened an issue in"
392 case "pr_opened":
393 return "opened a pull request in"
394 case "pr_comment_created":
395 return "commented on a pull request in"
396 case "pr_closed":
397 return "closed a pull request in"
398 case "pr_reopened":
399 return "reopened a pull request in"
400 case "pr_merged":
401 return "merged a pull request in"
402 case "followed_user", "followed_org":
403 return "followed"
404 default:
405 return strings.ReplaceAll(kind, "_", " ")
406 }
407 }
408
409 func feedItemTarget(kind string, payload []byte, item FeedItem) (string, string) {
410 switch kind {
411 case "followed_user", "followed_org":
412 if item.SourceName != "" {
413 return item.SourceName, "/" + item.SourceName
414 }
415 return "a profile", ""
416 }
417 data := map[string]any{}
418 _ = json.Unmarshal(payload, &data)
419 if title, _ := data["issue_title"].(string); title != "" {
420 if item.RepoURL == "" {
421 return title, ""
422 }
423 if n, ok := jsonNumberToInt(data["issue_number"]); ok {
424 section := "issues"
425 if strings.HasPrefix(kind, "pr_") {
426 section = "pulls"
427 }
428 return title, item.RepoURL + "/" + section + "/" + strconv.FormatInt(n, 10)
429 }
430 return title, item.RepoURL
431 }
432 if item.RepoFullName != "" {
433 return item.RepoFullName, item.RepoURL
434 }
435 return "", ""
436 }
437
438 func jsonNumberToInt(v any) (int64, bool) {
439 switch n := v.(type) {
440 case float64:
441 return int64(n), true
442 case int64:
443 return n, true
444 case string:
445 i, err := strconv.ParseInt(n, 10, 64)
446 return i, err == nil
447 default:
448 return 0, false
449 }
450 }
451
452 func timeFromPG(t pgtype.Timestamptz) time.Time {
453 if !t.Valid {
454 return time.Time{}
455 }
456 return t.Time
457 }
458
459 func dbTrendingScope(scope TrendingScope) socialdb.TrendingScope {
460 switch scope {
461 case TrendingScopeDay:
462 return socialdb.TrendingScopeDay
463 case TrendingScopeMonth:
464 return socialdb.TrendingScopeMonth
465 default:
466 return socialdb.TrendingScopeWeek
467 }
468 }
469