blob: c0dddd23a18e83940b88eb2f8ed8ac8f40fe7ba2 [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 overview generates and posts overviews of discussions.
// For now, it only works with GitHub issues and their comments.
// TODO(tatianabradley): Add comment explaining design.
package overview
import (
"context"
"encoding/json"
"log/slog"
"time"
"golang.org/x/oscar/internal/github"
"golang.org/x/oscar/internal/llmapp"
"golang.org/x/oscar/internal/storage"
"rsc.io/ordered"
)
// A Client is used to generate, post, and update AI-generated overviews
// of GitHub issues and their comments.
type Client struct {
slog *slog.Logger
db storage.DB // the database to use to store state
minTimeBetweenUpdates time.Duration // the minimum time between calls to [poster.run]
g *generator // for generating overviews
p *poster // for modifying GitHub
}
// New returns a new Client used to generate and post overviews to GitHub.
// Name is a string used to identify the Client, and bot is the login of the
// GitHub user that will modify GitHub.
// Clients with the same name and bot use the same state.
func New(lg *slog.Logger, db storage.DB, gh *github.Client, lc *llmapp.Client, name, bot string) *Client {
c := &Client{
slog: lg,
db: db,
minTimeBetweenUpdates: defaultMinTimeBetweenUpdates,
g: newGenerator(gh, lc),
p: newPoster(name, bot),
}
c.g.skipCommentsBy(bot)
return c
}
var defaultMinTimeBetweenUpdates = 24 * time.Hour
// Run posts and updates AI-generated overviews of GitHub issues.
//
// TODO(tatianabradley): Detailed comment.
func (c *Client) Run(ctx context.Context) error {
c.slog.Info("overview.Run start")
defer func() {
c.slog.Info("overview.Run end")
}()
// Check if we should run or not.
c.db.Lock(string(c.runKey()))
defer c.db.Unlock(string(c.runKey()))
lr, err := c.lastRun()
if err != nil {
return err
}
if time.Since(lr) < c.minTimeBetweenUpdates {
c.slog.Info("overview.Run: skipped (last successful run happened too recently)", "last run", lr, "min time", c.minTimeBetweenUpdates)
return nil
}
if err := c.p.run(); err != nil {
return err
}
c.setLastRun(time.Now())
return nil
}
// ForIssue returns an LLM-generated overview of the issue and its comments.
// It does not make any requests to, or modify, GitHub; the issue and comment data must already
// be stored in the database.
func (c *Client) ForIssue(ctx context.Context, iss *github.Issue) (*IssueResult, error) {
return c.g.issue(ctx, iss)
}
// ForIssueUpdate returns an LLM-generated overview of the issue and its
// comments, separating the comments into "old" and "new" groups broken
// by the specifed lastRead comment id. (The lastRead comment itself is
// considered "old", and must be present in the database).
//
// ForIssueUpdate does not make any requests to, or modify, GitHub; the issue and comment data must already
// be stored in db.
func (c *Client) ForIssueUpdate(ctx context.Context, iss *github.Issue, lastRead int64) (*IssueUpdateResult, error) {
return c.g.issueUpdate(ctx, iss, lastRead)
}
// EnableProject enables the Client to post on and update issues in the given
// GitHub project (for example "golang/go").
func (c *Client) EnableProject(project string) {
c.p.projects[project] = true
}
// RequireApproval configures the poster to require approval for all actions.
func (c *Client) RequireApproval() {
c.p.requireApproval = true
}
// AutoApprove configures the Client to auto-approve all its actions.
func (c *Client) AutoApprove() {
c.p.requireApproval = false
}
// SetMinComments sets the minimum number of comments a post needs to get an
// overview comment.
func (c *Client) SetMinComments(n int) {
c.p.minComments = n
}
type state struct {
LastRun string // the time of the last sucessful (non-skipped) call to [Client.Run]
}
// lastRun returns the time of the last successful (non-skipped)
// call to [Client.Run].
func (c *Client) lastRun() (time.Time, error) {
b, ok := c.db.Get(c.runKey())
if !ok {
return time.Time{}, nil
}
var s state
if err := json.Unmarshal(b, &s); err != nil {
return time.Time{}, err
}
return time.Parse(time.RFC3339, s.LastRun)
}
// setLastRun sets the time of the last successful (non-skipped)
// call to [Client.Run].
func (c *Client) setLastRun(t time.Time) {
c.db.Set(c.runKey(), storage.JSON(&state{
LastRun: t.Format(time.RFC3339),
}))
}
// runKey returns the key to use to store the state for this Client.
func (c *Client) runKey() []byte {
return ordered.Encode(runKind, c.p.name, c.p.bot)
}
const runKind = "overview.Run"