Files
sm-feedback-plugin/classes/sync_g15office.go
Jakub Zych e52b1ecd2c fix(14): WR-06 never file a second G15Office task on a job retry
A retry after a failed attachment or a failed CompleteJob created another
task for the same report. The task id is now stored as soon as the task
is created, a retry with a stored id only re-attempts the attachment, a
sent submission with a task id is skipped, and a CompleteJob failure is
logged instead of returned.
2026-10-04 08:10:30 +02:00

198 lines
6.5 KiB
Go

package classes
import (
"context"
"errors"
"regexp"
"strings"
"time"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// The G15Office sync job's contract: SubmitFeedback queues one per stored
// submission, the worker files it (SyncFeedbackToG15Office).
const (
// SyncG15OfficeKind is the River kind of the job.
SyncG15OfficeKind = "golem15.feedback.sync_g15office"
// SyncG15OfficeQueue is the job's queue.
SyncG15OfficeQueue = "feedback"
// SyncG15OfficeLabel is the summer_jobs label, the PHP job class.
SyncG15OfficeLabel = `Golem15\Feedback\Jobs\SyncFeedbackToG15Office`
// SyncG15OfficeAttempts is SyncFeedbackToG15Office::$tries.
SyncG15OfficeAttempts = 3
)
// SyncG15OfficeArgs is the job's argument: the submission to file.
type SyncG15OfficeArgs struct {
SubmissionID uint `json:"submission_id"`
}
// Kind is the River job kind.
func (SyncG15OfficeArgs) Kind() string { return SyncG15OfficeKind }
// SyncDeps are what one run of the job needs, resolved per run.
type SyncDeps struct {
DB *gorm.DB
Bucket *blob.Bucket
Client *G15OfficeClient
}
// SyncG15Office is SyncFeedbackToG15Office::handle: it files the submission
// as a G15Office task (title, Markdown description, type, the settings'
// task status and priority) and attaches its screenshot. Success stores
// status sent and the task id; any failure stores status failed and the
// error and returns the error, so the job is retried. A submission that is
// gone is skipped. An unconfigured client fails before any request, so
// nothing leaves the application.
//
// Unlike PHP, a retry never files a second task: a submission already sent
// with a task id is skipped, the task id is stored as soon as CreateTask
// returns it, and a retry of a submission that has one only re-attempts
// the screenshot attachment.
func SyncG15Office(ctx context.Context, deps SyncDeps, args SyncG15OfficeArgs) error {
if deps.DB == nil {
return errors.New("feedback: database is not configured")
}
db := deps.DB.WithContext(ctx)
var rows []models.Submission
if err := db.Where("id = ?", args.SubmissionID).Limit(1).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
sub := rows[0]
if sub.Status == models.StatusSent && hasTaskID(&sub) {
return nil
}
id, err := fileTask(ctx, deps, &sub)
if err != nil {
msg := err.Error()
if uerr := db.Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
"status": models.StatusFailed, "g15office_error": msg, "updated_at": time.Now(),
}).Error; uerr != nil {
return errors.Join(err, uerr)
}
return err
}
return db.Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
"status": models.StatusSent, "g15office_task_id": id, "g15office_error": nil, "updated_at": time.Now(),
}).Error
}
// hasTaskID reports whether an earlier attempt already filed the task.
func hasTaskID(sub *models.Submission) bool {
return sub.G15OfficeTaskID != nil && *sub.G15OfficeTaskID != ""
}
func fileTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (string, error) {
var id string
if hasTaskID(sub) {
id = *sub.G15OfficeTaskID
} else {
created, err := createTask(ctx, deps, sub)
if err != nil {
return "", err
}
id = created
}
if err := attachScreenshot(ctx, deps, sub, id); err != nil {
return "", err
}
return id, nil
}
// createTask files the submission as a new G15Office task and stores the
// task id at once, so a later failure (the attachment) is retried against
// this task rather than filing another.
func createTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (string, error) {
settings, err := models.Instance(ctx, deps.DB)
if err != nil {
return "", err
}
fields := TaskFields{
Title: TaskTitle(sub.Message),
Description: TaskDescription(sub),
Type: sub.G15OfficeTaskType(),
Priority: "normal",
}
if s := settings.G15OfficeTaskStatus; s != nil && phpTruthy(*s) {
fields.Status = *s
}
if p := settings.G15OfficeTaskPriority; p != nil && phpTruthy(*p) {
fields.Priority = *p
}
task, err := deps.Client.CreateTask(ctx, fields)
if err != nil {
return "", err
}
id := taskID(task)
if id == "" {
return "", errors.New("g15office did not return a task id.")
}
if err := deps.DB.WithContext(ctx).Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
"g15office_task_id": id, "updated_at": time.Now(),
}).Error; err != nil {
return "", err
}
sub.G15OfficeTaskID = &id
return id, nil
}
// attachScreenshot uploads the submission's screenshot, when it has one,
// to the task.
func attachScreenshot(ctx context.Context, deps SyncDeps, sub *models.Submission, id string) error {
file, err := ScreenshotOf(ctx, deps.DB, sub.ID)
if err != nil {
return err
}
if file == nil {
return nil
}
if deps.Bucket == nil {
return errors.New("feedback: upload bucket is not configured")
}
r, err := deps.Bucket.NewReader(ctx, attach.BlobKey(file.DiskName), nil)
if err != nil {
return err
}
_, err = deps.Client.AttachFile(ctx, id, file.FileName, file.ContentType, r)
_ = r.Close()
return err
}
// whitespaceRun is PCRE's \s+ without the u flag: ASCII space, tab, LF, VT, FF
// and CR (Go's \s leaves out VT).
var whitespaceRun = regexp.MustCompile(`[\t\n\v\f\r ]+`)
// TaskTitle is SyncFeedbackToG15Office::buildTitle: "Feedback: " and the
// message with whitespace runs collapsed, trimmed and cut to 80 columns.
func TaskTitle(message string) string {
collapsed := strings.Trim(whitespaceRun.ReplaceAllString(message, " "), " \t\n\r\x00\x0B")
return "Feedback: " + StrLimit(collapsed, 80, "...")
}
// TaskDescription is SyncFeedbackToG15Office::buildDescription: the message,
// a rule, one bullet per metadata line (page, then the reporter email and
// the user agent when set) and the console log in a code fence, cut to
// 4000 columns, blocks separated by blank lines.
func TaskDescription(sub *models.Submission) string {
blocks := []string{sub.Message, "---"}
meta := []string{"- **Page:** " + sub.PageURL}
if sub.Email != nil && phpTruthy(*sub.Email) {
meta = append(meta, "- **Reporter email:** "+*sub.Email)
}
if sub.UserAgent != nil && phpTruthy(*sub.UserAgent) {
meta = append(meta, "- **User agent:** "+*sub.UserAgent)
}
blocks = append(blocks, strings.Join(meta, "\n"))
if sub.ConsoleLog != nil && phpTruthy(*sub.ConsoleLog) {
blocks = append(blocks, "**Console log:**\n```\n"+StrLimit(*sub.ConsoleLog, 4000, "… (truncated)")+"\n```")
}
return strings.Join(blocks, "\n\n")
}