blob: 6ad11a0630221e5c95d528e88af8a80c17d35427 [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package discussion
import (
"encoding/json"
"iter"
"math"
"time"
"golang.org/x/oscar/internal/docs"
"golang.org/x/oscar/internal/storage"
"golang.org/x/oscar/internal/storage/timed"
"rsc.io/ordered"
)
// NOTE: the code in this file is very similar to the internal/github
// package and can probably be merged once we're confident the discussion
// sync is working.
// For now, they are separate to avoid accidental interaction between
// the two packages's database entries.
// EventWatcher returns a new [timed.Watcher] with the given name.
// It picks up where any previous Watcher of the same name left off.
func (c *Client) EventWatcher(name string) *timed.Watcher[*Event] {
return timed.NewWatcher(c.slog, c.db, name, eventKind, c.decodeEvent)
}
// An Event is a single GitHub discussion event stored in the database.
type Event struct {
DBTime timed.DBTime // when event was last written
Project string // project (e.g. "golang/go")
Discussion int64 // discussion number
API string // the event kind ("API" for consistency with the [github.Event.API] field)
ID int64 // ID of event; each API has a different ID space. (Project, Discussion, API, ID) is assumed unique
JSON []byte // JSON for the event data
Typed any // Typed unmarshaling of the event data, of type [*Discussion], [*Comment]
Updated time.Time // when the event was last updated (according to GitHub)
}
var _ docs.Entry = (*Event)(nil)
// LastWritten implements [docs.Entry.LastWritten].
func (e *Event) LastWritten() timed.DBTime {
return e.DBTime
}
// The recognized event kinds.
// The events are fetched from the GrapQL API, which
// uses queries instead of API endpoints, so these "endpoints"
// are merely for identification purposes.
// We use the term API for consistency with the [github.Event.API] field.
const (
DiscussionAPI string = "/discussions"
CommentAPI string = "/discussions/comments" // both comments and replies
)
// decodeEvent decodes the key, val pair into an Event.
// It calls c.db.Panic for malformed data.
func (c *Client) decodeEvent(t *timed.Entry) *Event {
var e Event
e.DBTime = t.ModTime
if err := ordered.Decode(t.Key, &e.Project, &e.Discussion, &e.API, &e.ID); err != nil {
c.db.Panic("discussion event decode", "key", storage.Fmt(t.Key), "err", err)
}
var js ordered.Raw
if err := ordered.Decode(t.Val, &js); err != nil {
c.db.Panic("discussion event val decode", "key", storage.Fmt(t.Key), "val", storage.Fmt(t.Val), "err", err)
}
e.JSON = js
switch e.API {
default:
c.db.Panic("discussion event invalid kind", "kind", e.API)
case DiscussionAPI:
e.Typed = new(Discussion)
case CommentAPI:
e.Typed = new(Comment)
}
if err := json.Unmarshal(js, e.Typed); err != nil {
c.db.Panic("discussion event json", "js", string(js), "err", err)
}
return &e
}
// Events returns an iterator over discussion events for the given project,
// limited to discussions in the range discMin ≤ discussion ≤ discMax.
// If discMax < 0, there is no upper limit.
// The events are iterated over in (Project, Discussion, Kind, ID) order,
// so "/discussions" events come first, then "/discussions/comments"
// events.
// Within an event kind, the events are ordered by increasing ID,
// which corresponds to increasing event time on GitHub.
func (c *Client) Events(project string, discMin, discMax int64) iter.Seq[*Event] {
return func(yield func(*Event) bool) {
start := o(project, discMin)
if discMax < 0 {
discMax = math.MaxInt64
}
end := o(project, discMax, ordered.Inf)
for t := range timed.Scan(c.db, eventKind, start, end) {
if !yield(c.decodeEvent(t)) {
return
}
}
}
}
// EventsAfter returns an iterator over discussion events in the given project after DBTime t,
// which should be e.DBTime from the most recent processed event.
// The events are iterated over in DBTime order, so the DBTime of the last
// successfully processed event can be used in a future call to EventsAfter.
// If project is the empty string, then events from all projects are returned.
func (c *Client) EventsAfter(t timed.DBTime, project string) iter.Seq[*Event] {
filter := func(key []byte) bool {
if project == "" {
return true
}
var p string
if _, err := ordered.DecodePrefix(key, &p); err != nil {
c.db.Panic("discussion.EventsAfter decode", "key", storage.Fmt(key), "err", err)
}
return p == project
}
return func(yield func(*Event) bool) {
for e := range timed.ScanAfter(c.slog, c.db, eventKind, t, filter) {
if !yield(c.decodeEvent(e)) {
return
}
}
}
}