blob: f83abfdd8726f01b45f5bd8e77f596f3c9166d75 [file] [log] [blame] [edit]
// 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
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"golang.org/x/oscar/internal/github"
"golang.org/x/oscar/internal/github/wrap"
"golang.org/x/oscar/internal/storage"
)
// an action is a post or update action to be taken by a [poster].
// It has all the information needed to post or update a comment
// (and its corresponding link in the issue body) to a GitHub issue.
type action struct {
Issue *github.Issue // the issue to modify
LastComment int64 // the ID of the last comment considered in generating the overview
Changes *github.IssueCommentChanges // the comment to post
// If the following is nil, this a first post.
// Otherwise, it is an update.
IssueComment *github.IssueComment // the comment to modify
}
// isPost reports whether this action is a first post action.
// (If not, it is an update action).
func (a *action) isPost() bool {
return a.IssueComment == nil
}
// result is the result of applying an [action].
type result struct {
URL string // URL of the poster's comment
}
// getAction returns the action to take on the issue.
// It returns a post action if there is no existing overview comment,
// and an update action otherwise.
func (p *poster) getAction(ctx context.Context, iss *github.Issue, getOverview overviewFunc) (*action, error) {
oc, err := p.findOverviewComment(iss)
if err != nil {
return nil, err
}
r, err := getOverview(ctx, iss)
if err != nil {
return nil, err
}
comment, err := comment(r.Overview.Response, p.w)
if err != nil {
return nil, err
}
changes := &github.IssueCommentChanges{
Body: comment,
}
return &action{
Issue: iss,
LastComment: r.LastComment,
Changes: changes,
IssueComment: oc,
}, nil
}
// actioner implements [actions.Actioner].
type actioner struct {
p *poster
}
// Implements [actions.Actioner.Run].
func (ar *actioner) Run(ctx context.Context, data []byte) ([]byte, error) {
return runFromActionLog(ctx, data, ar.p.runAction)
}
// Implements [actions.Actioner.ForDisplay].
func (ar *actioner) ForDisplay(data []byte) string {
a, err := decodeAction(data)
if err != nil {
return fmt.Sprintf("ERROR: %v", err)
}
if a.isPost() {
return "post issue comment (and add link) to: " + a.Issue.HTMLURL + "\nnew comment:\n" + a.Changes.Body
}
return "update issue comment: " + a.IssueComment.HTMLURL + "\nupdated comment:\n" + a.Changes.Body
}
// decodeAction unmarshals the JSON into an action.
func decodeAction(b []byte) (*action, error) {
var action action
if err := json.Unmarshal(b, &action); err != nil {
return nil, err
}
return &action, nil
}
// encode marshals an action into JSON.
func (a *action) encode() []byte {
return storage.JSON(a)
}
// runFromActionLog is called by [actioner.Run] to execute an action.
// It decodes the action, calls [runAction], then encodes the result.
func runFromActionLog(ctx context.Context, data []byte,
runAction func(context.Context, *action) (*result, error)) ([]byte, error) {
a, err := decodeAction(data)
if err != nil {
return nil, err
}
res, err := runAction(ctx, a)
if err != nil {
return nil, err
}
return storage.JSON(res), nil
}
var (
errStaleAction = errors.New("stale update action")
errEditIssueCommentFailed = errors.New("edit issue comment failed")
errPostIssueCommentFailed = errors.New("post issue comment failed")
errDownloadIssueFailed = errors.New("download issue failed")
errEditIssueFailed = errors.New("edit issue failed")
errWrapFailed = errors.New("wrap failed")
)
// runAction runs the given action.
//
// If GitHub returns an error, add it to the action log for this action.
// It is unclear what the right behavior is, but at least at present all
// failed actions are available to the program and could be re-run.
func (p *poster) runAction(ctx context.Context, a *action) (*result, error) {
if a.isPost() {
return p.runPostAction(ctx, a)
}
return p.runUpdateAction(ctx, a)
}
// runPostAction runs a post action (post new comment and add link to it from the issue body).
// It returns an error if posting the comment fails.
// If adding the link to issue body fails, it logs the error but does not return it.
// This part of the action will be re-tried on the next update.
func (p *poster) runPostAction(ctx context.Context, a *action) (*result, error) {
p.slog.Info("overview: running post action", "action", a)
_, url, err := p.gh.PostIssueComment(ctx, a.Issue, a.Changes)
if err != nil {
return nil, fmt.Errorf("%w issue=%d: %v", errPostIssueCommentFailed, a.Issue.Number, err)
}
if err := p.addLinkToComment(ctx, a.Issue, url); err != nil {
// A failure here is not fatal, as it will be re-tried when the overview is updated.
p.slog.Error("overview: could not add link to comment", "error", err)
}
return &result{URL: url}, nil
}
// addLinkToComment adds a message linking to the given url in the body of the given
// issue (if not already present).
func (p *poster) addLinkToComment(ctx context.Context, iss *github.Issue, url string) error {
// Avoid downloading the issue if our version already has a link to the comment.
// If the link happens to be deleted in the intervening time,
// we will try again next time the overview is updated.
if p.containsCommentURL(iss, url) {
p.slog.Info("overview: cached issue already has link to comment", "issue", iss.Number)
return nil
}
liveIss, err := p.gh.DownloadIssue(ctx, iss.URL)
if err != nil {
return fmt.Errorf("%w issue=%d: %v", errDownloadIssueFailed, iss.Number, err)
}
// Check the live version.
if p.containsCommentURL(liveIss, url) {
p.slog.Info("overview: live issue already has link to comment", "issue", iss.Number)
return nil
}
// Unfortunately, if the issue body is edited between the call to
// DownloadIssue and EditIssue, we will overwrite the edits.
// There's no clear way to prevent this.
body, err := appendCommentURL(iss, p.bot, url, p.w)
if err != nil {
return fmt.Errorf("%w issue=%d: %v", errWrapFailed, liveIss.Number, err)
}
if err := p.gh.EditIssue(ctx, liveIss, &github.IssueChanges{Body: body}); err != nil {
return fmt.Errorf("%w issue=%d: %v", errEditIssueFailed, liveIss.Number, err)
}
p.slog.Info("overview: added link to overview comment to issue body", "issue", iss.Number, "url", url)
return nil
}
// runUpdateAction runs the update action (edit comment and update link in issue
// body if needed).
// It returns an error if the update action is stale (there is newer update action for this
// issue), or if the update fails.
// If adding/updating the link to issue body fails, it logs the error but does not return it.
// This part of the action will be re-tried on the next update.
func (p *poster) runUpdateAction(ctx context.Context, a *action) (*result, error) {
p.slog.Info("overview: running update action", "action", a)
// Check if the update action is stale.
// This happens if a newer update action is created while an older update action is
// still waiting for approval.
project, issue := a.Issue.Project(), a.Issue.Number
k := string(p.issueStateKey(project, issue))
p.db.Lock(k)
if lc := p.lastComment(project, issue); lc > a.LastComment {
p.db.Unlock(k)
return nil, fmt.Errorf("%w issue=%d, (last comment in action = %d, last comment in db = %d)", errStaleAction,
issue, a.LastComment, lc)
}
p.db.Unlock(k)
// Update the issue comment.
// Note that we don't need to check whether the live version of
// the issue comment matches our version.
// This poster "owns" this comment and may freely overwrite edits
// from itself or other entities.
err := p.gh.EditIssueComment(ctx, a.IssueComment, a.Changes)
if err != nil {
return nil, fmt.Errorf("%w issue=%d, comment=%s: %v", errEditIssueCommentFailed,
a.IssueComment.Issue(), a.IssueComment.HTMLURL, err)
}
if err := p.addLinkToComment(ctx, a.Issue, a.IssueComment.HTMLURL); err != nil {
// A failure here is not fatal, as it will be re-tried next time the overview is updated.
p.slog.Error("overview: could not add link to comment", "error", err)
}
return &result{URL: a.IssueComment.HTMLURL}, nil
}
// containsCommentURL reports whether this issue contains an edit made by
// this [poster] which contains the given url.
func (p *poster) containsCommentURL(iss *github.Issue, url string) bool {
for _, uw := range wrap.ParseAll(iss.Body) {
if uw.Bot == p.bot && uw.Kind == p.name && strings.Contains(uw.Body, url) {
return true
}
}
return false
}
const sparkle = "✨"
// appendCommentURL returns the issue changes which, when passed to [github.EditIssueComment]
// will add a message linking to the given url.
func appendCommentURL(iss *github.Issue, bot, url string, w *wrap.Wrapper) (string, error) {
// OK to modify editText (though it must contain url).
editText := fmt.Sprintf("\n%s @%s's overview of this issue: %s\n", sparkle, bot, url)
// DO NOT REMOVE this wrapping call.
wrapped, err := w.Wrap(editText, nil)
if err != nil {
return "", err
}
return strings.Join([]string{
iss.Body,
wrapped,
}, "\n"), nil
}