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