feat: авто-уведомления о статусах и хендоффах задачи
Some checks failed
CI / test (pull_request) Failing after 35s
CI / build-and-package (amd64, linux) (pull_request) Successful in 40s
CI / build-and-package (amd64, windows) (pull_request) Successful in 43s

This commit is contained in:
ki.sagidullin
2026-08-17 23:32:26 +05:00
parent 43266ea04f
commit 07bae203c3
4 changed files with 372 additions and 4 deletions

View File

@@ -154,6 +154,7 @@ func New(configPath, version, updateToken string) (*App, error) {
GitBaseURL: cfg.Git.BaseURL,
GitToken: cfg.Git.Token,
Live: live,
Notify: a, // авто-уведомления владельцу задачи через Router
}
a.Router = router
a.Worker = w
@@ -326,6 +327,16 @@ func (a *App) send(ctx context.Context, uid chat.UserID, text string) {
}
}
// Notify реализует worker.Notifier: авто-уведомление владельцу задачи через
// chat.Router.Send (переходы статусов и хендоффы dev↔reviewer со стороны воркера).
// Router nil (тесты без Router / ранняя инициализация) — тихо пропускаем.
func (a *App) Notify(ctx context.Context, taskID int64, chatID, text string) error {
if a.Router == nil {
return nil
}
return a.Router.Send(ctx, chat.UserID(chatID), chat.Message{Text: text})
}
// cmdName извлекает команду (первое слово до пробела, нижний регистр).
func cmdName(text string) string {
s := strings.TrimSpace(text)

View File

@@ -18,6 +18,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -129,6 +130,7 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
Interval: 30 * time.Millisecond,
MaxJobs: 1,
Live: opencode.NewLiveRegistry(),
Notify: a, // авто-уведомления владельцу через Router (как в app.New)
// GitToken зададим пустым: origin в seed-репо локальный (file path),
// http.extraHeader не нужен для локального пуша.
}
@@ -139,13 +141,13 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
// e2eChannel — минимальный fake-канал для перехвата исходящих
// и доставки входящих через роутер (как реальный канал).
type e2eChannel struct {
onMsg func(chat.Incoming)
onMsg chat.Handler
sent []chat.Message
}
func (c *e2eChannel) Run(_ context.Context) error { return nil }
func (c *e2eChannel) OnMessage(f func(chat.Incoming)) {
c.onMsg = f
func (c *e2eChannel) OnMessage(h chat.Handler) {
c.onMsg = h
}
func (c *e2eChannel) Send(_ context.Context, _ chat.Address, m chat.Message) error {
c.sent = append(c.sent, m)
@@ -719,4 +721,108 @@ func TestE2EWorkerDoesNotTakeUnconfirmed(t *testing.T) {
}
t.Logf("WORKER-CONSENT OK: в ready воркер не трогал, после «создавай» → success, ветка %s", branch)
}
// TestE2ENotificationsOnTransitions — авто-уведомления владельцу на каждый
// переход статуса задачи и отсутствие задвоения на approved.
//
// Проверяет сквозной поток сообщений через chat.Router:
// - ready → approved («создавай»): ровно ОДНО сообщение (ответ «одобрена»),
// отдельное уведомление "Задача #N: approved" НЕ дублируется;
// - со стороны воркера: уведомления running → dev→reviewer → success
// доходят владельцу через Router.Send.
func TestE2ENotificationsOnTransitions(t *testing.T) {
a, worktree, fake := e2eAssemble(t)
a.seedFakeRepo(t, worktree, "calc")
ctx := context.Background()
uid := chat.UserID("u-notif")
// --- 1. постановка: /start → draft→collecting (приветствие) ---
fake.deliver(uid, "/start")
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
if err != nil {
t.Fatalf("get task after /start: %v", err)
}
taskID := task.ID
// --- 2. текст → collecting→ready (сводка черновика) ---
fake.deliver(uid, "Сделай калькулятор в calc")
task, err = a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusReady {
t.Fatalf("status = %q, want ready", task.Status)
}
// --- 3. «создавай» → ready→approved: один ответ, без дубля-уведомления ---
fake.deliver(uid, "создавай")
task, err = a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("get task after создавай: %v", err)
}
if task.Status != storage.StatusApproved {
t.Fatalf("status = %q, want approved", task.Status)
}
approvedReplies := 0
var approvedText string
approvedNotifs := 0
for _, m := range fake.sent {
if strings.Contains(m.Text, "одобрена") {
approvedReplies++
approvedText = m.Text
}
if strings.Contains(m.Text, fmt.Sprintf("Задача #%d: approved", taskID)) {
approvedNotifs++
}
}
if approvedReplies != 1 {
t.Errorf("сообщений об одобрении = %d, want ровно 1 (нет задвоения)", approvedReplies)
}
if approvedNotifs != 0 {
t.Errorf("отдельное уведомление 'Задача #%d: approved' продублировано (%d раз)", taskID, approvedNotifs)
}
if !strings.Contains(approvedText, "одобрена") {
t.Errorf("ответ об одобрении = %q", approvedText)
}
// --- 4. воркер: running → dev→reviewer → success через Router ---
wkCtx, wkCancel := context.WithCancel(ctx)
defer wkCancel()
a.Worker.Start(wkCtx)
deadline := time.Now().Add(30 * time.Second)
for {
tk, err := a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("таймаут ожидания success, последний статус %q", tk.Status)
}
time.Sleep(50 * time.Millisecond)
}
// финальные статус-уведомления воркера в правильном порядке
wantOrder := []string{
fmt.Sprintf("Задача #%d: running", taskID),
fmt.Sprintf("Задача #%d: dev → reviewer (итерация 1)", taskID),
fmt.Sprintf("Задача #%d: success", taskID),
}
var gotOrder []string
for _, m := range fake.sent {
if strings.HasPrefix(m.Text, fmt.Sprintf("Задача #%d:", taskID)) {
gotOrder = append(gotOrder, m.Text)
}
}
if !reflect.DeepEqual(gotOrder, wantOrder) {
t.Errorf("уведомления воркера = %#v, want %#v", gotOrder, wantOrder)
}
t.Logf("NOTIFICATIONS OK: задача #%d, сообщений одобрения=%d, уведомления=%v", taskID, approvedReplies, gotOrder)
}