package store import ( "context" "database/sql" "strings" "time" ) // Event kinds from chapter 19.1, without run.succeeded, because a green build is not news. const ( ProposalOpened = "proposal.opened" ProposalUpdated = "proposal.updated" ProposalMerged = "proposal.merged" ThreadOpened = "thread.opened" ThreadReplied = "thread.replied" ThreadClosed = "thread.closed" Pushed = "push" RunFailed = "run.failed" RepoTransferred = "repo.transferred" ) // Kinds is chapter 19.1's list, complete, so a name outside it is a typo and not a future event. var Kinds = []string{ ProposalOpened, ProposalUpdated, ProposalMerged, ThreadOpened, ThreadReplied, ThreadClosed, Pushed, RunFailed, RepoTransferred, } // KnownKind reports whether a name is one barerepo will ever send. Chapter 23.2 lets a file name any. func KnownKind(name string) bool { for _, k := range Kinds { if k == name { return true } } return false } // EventLife bounds the inbox, which is a feed, because the repository is the archive. 19.3. const EventLife = 90 * 24 * time.Hour type Event struct { ID int64 Kind string Actor string Repo string Ref string Number int Title string // Detail is the row's second line where Title is spoken for, as a failed build's is. 19.3. Detail string Created time.Time } // Record writes an event, and a failure must never fail the push, so callers log and carry on. func (db *DB) Record(ctx context.Context, e Event) error { _, err := db.ExecContext(ctx, `INSERT INTO events (kind, actor, repo, ref, number, title, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, e.Kind, e.Actor, e.Repo, e.Ref, e.Number, e.Title, e.Detail, now().Unix()) return err } // TookPart is the subscription: there is no watch button, only touching a thread. 19.2. func (db *DB) TookPart(ctx context.Context, account, repo string, number int) error { if account == "" || number == 0 { return nil } _, err := db.ExecContext(ctx, `INSERT INTO participation (account, repo, number) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`, account, repo, number) return err } // Inbox is what this account owns, plus every thread it took part in. func (db *DB) Inbox(ctx context.Context, account string, limit int) ([]Event, error) { cutoff := now().Add(-EventLife).Unix() rows, err := db.QueryContext(ctx, `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events WHERE created_at > ? AND (repo LIKE ? OR EXISTS ( SELECT 1 FROM participation p WHERE p.account = ? AND p.repo = events.repo AND p.number = events.number)) ORDER BY id DESC LIMIT ?`, cutoff, account+"/%", account, limit) if err != nil { return nil, err } defer rows.Close() return scanEvents(rows) } // EventsFor returns one repository's events, for its feed. func (db *DB) EventsFor(ctx context.Context, repo string, limit int) ([]Event, error) { rows, err := db.QueryContext(ctx, `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events WHERE repo = ? ORDER BY id DESC LIMIT ?`, repo, limit) if err != nil { return nil, err } defer rows.Close() return scanEvents(rows) } // EventsBy returns one account's activity, for their feed. func (db *DB) EventsBy(ctx context.Context, account string, limit int) ([]Event, error) { rows, err := db.QueryContext(ctx, `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events WHERE actor = ? ORDER BY id DESC LIMIT ?`, account, limit) if err != nil { return nil, err } defer rows.Close() return scanEvents(rows) } func scanEvents(rows *sql.Rows) ([]Event, error) { var out []Event for rows.Next() { var e Event var created int64 if err := rows.Scan(&e.ID, &e.Kind, &e.Actor, &e.Repo, &e.Ref, &e.Number, &e.Title, &e.Detail, &created); err != nil { return nil, err } e.Created = time.Unix(created, 0) out = append(out, e) } return out, rows.Err() } // LastVisited places the inbox rule, and losing it costs a reader one line. 19.4. func (db *DB) LastVisited(ctx context.Context, account string) (time.Time, error) { var at sql.NullInt64 if err := db.QueryRowContext(ctx, `SELECT last_visited FROM accounts WHERE name = ?`, account).Scan(&at); err != nil { return time.Time{}, err } if !at.Valid { return time.Time{}, nil } return time.Unix(at.Int64, 0), nil } func (db *DB) Visit(ctx context.Context, account string) error { _, err := db.ExecContext(ctx, `UPDATE accounts SET last_visited = ? WHERE name = ?`, now().Unix(), account) return err } // DropOldEvents throws away everything past the window. func (db *DB) DropOldEvents(ctx context.Context) error { _, err := db.ExecContext(ctx, `DELETE FROM events WHERE created_at < ?`, now().Add(-EventLife).Unix()) return err } // Owner reads the account a repository belongs to. func Owner(repo string) string { owner, _, _ := strings.Cut(repo, "/") return owner } // ThreadKinds is what the threads page shows, so the threads feed carries the same. 19.5. var ThreadKinds = []string{ProposalOpened, ProposalUpdated, ProposalMerged, ThreadOpened, ThreadReplied, ThreadClosed} // ThreadEventsFor is one repository's discussion, for ///threads.atom. func (db *DB) ThreadEventsFor(ctx context.Context, repo string, limit int) ([]Event, error) { args := []any{repo} for _, k := range ThreadKinds { args = append(args, k) } args = append(args, limit) q := `SELECT id, kind, actor, repo, ref, number, title, detail, created_at FROM events WHERE repo = ? AND kind IN (?` + strings.Repeat(", ?", len(ThreadKinds)-1) + `) ORDER BY id DESC LIMIT ?` rows, err := db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() return scanEvents(rows) }