Files
ki.sagidullin 6c0b903794
Some checks failed
CI / test (push) Failing after 1m52s
CI / build-and-package (amd64, linux) (push) Failing after 1m3s
CI / build-and-package (amd64, windows) (push) Successful in 30s
feat(analyst): этапы задачи — аналитик раскладывает на steps с критериями готовности, dev идёт по ним
2026-08-24 18:05:06 +05:00

191 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storage
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"log"
"time"
)
// newTaskTag генерирует стабильный UUID задачи (task_tag) — он же ложится в
// feature-ветку git (feat/<taskTag>). Генерится при создании и НЕ меняется.
func newTaskTag() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// крипто-rand недоступен — fallback по времени
return hex.EncodeToString([]byte(time.Now().Format("150405.000000000")))
}
return hex.EncodeToString(b)
}
// CreateTask создаёт задачу со статусом draft. Возвращает её ID.
// chat_id и task_tag передаются извне; если task_tag не задан — генерится уник
// UUID (стабильный на всё время жизни, нужен для feature-ветки feat/<tag>).
func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) {
if t.TaskTag == "" {
t.TaskTag = newTaskTag()
}
now := Now()
res, err := s.db.ExecContext(ctx, `
INSERT INTO tasks (chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ChatID, t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC,
t.StepsJoined(), t.TaskTag, StatusDraft, now, now,
)
if err != nil {
return 0, fmt.Errorf("%w: create task: %w", ErrDB, err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("%w: last insert id: %w", ErrDB, err)
}
t.ID = id
t.Status = StatusDraft
t.CreatedAt = now
t.UpdatedAt = now
return id, nil
}
// GetTask возвращает задачу по ID.
func (s *Storage) GetTask(ctx context.Context, id int64) (*Task, error) {
t := &Task{}
var reposStr string
var stepsStr string
err := s.db.QueryRowContext(ctx, `
SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at
FROM tasks WHERE id = ?`, id).Scan(
&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
&t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: task %d", ErrNotFound, id)
}
if err != nil {
return nil, fmt.Errorf("%w: get task %d: %w", ErrDB, id, err)
}
t.SetReposFromDB(reposStr)
t.SetStepsFromDB(stepsStr)
return t, nil
}
// UpdateTask обновляет поля задачи. Если newStatus указан и отличен от текущего —
// проверяет валидность перехода. Возвращает ошибку ErrInvalidStatus при недопустимом переходе.
func (s *Storage) UpdateTask(ctx context.Context, t *Task) error {
// получаем текущий статус для валидации перехода
var oldStatus Status
err := s.db.QueryRowContext(ctx, `SELECT status FROM tasks WHERE id = ?`, t.ID).Scan(&oldStatus)
if err == sql.ErrNoRows {
return fmt.Errorf("%w: task %d", ErrNotFound, t.ID)
}
if err != nil {
return fmt.Errorf("%w: get status %d: %w", ErrDB, t.ID, err)
}
if t.Status != "" && t.Status != oldStatus {
if !IsValidTransition(oldStatus, t.Status) {
return fmt.Errorf("%w: %s → %s for task %d", ErrInvalidStatus, oldStatus, t.Status, t.ID)
}
log.Printf("task %d: status %q → %q", t.ID, oldStatus, t.Status)
} else if t.Status == "" {
t.Status = oldStatus
}
now := Now()
res, err := s.db.ExecContext(ctx, `
UPDATE tasks
SET title=?, goal=?, repo=?, repos=?, why=?, ac=?, steps=?, status=?, updated_at=?
WHERE id=?`,
t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC, t.StepsJoined(), t.Status, now, t.ID,
)
if err != nil {
return fmt.Errorf("%w: update task %d: %w", ErrDB, t.ID, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("%w: task %d", ErrNotFound, t.ID)
}
t.UpdatedAt = now
return nil
}
// GetActiveTaskByChatID возвращает последнюю не-терминальную задачу чата.
// Терминальные статусы: success, cancelled, aborted, closed.
func (s *Storage) GetActiveTaskByChatID(ctx context.Context, chatID string) (*Task, error) {
t := &Task{}
var reposStr string
var stepsStr string
err := s.db.QueryRowContext(ctx, `
SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at
FROM tasks
WHERE chat_id = ? AND status NOT IN ('success','cancelled','aborted','closed')
ORDER BY updated_at DESC LIMIT 1`, chatID).Scan(
&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
&t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: no active task for chat %s", ErrNotFound, chatID)
}
if err != nil {
return nil, fmt.Errorf("%w: get active task %s: %w", ErrDB, chatID, err)
}
t.SetReposFromDB(reposStr)
t.SetStepsFromDB(stepsStr)
return t, nil
}
// ListTasks возвращает задачи по фильтру.
func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
where := "1=1"
args := []any{}
if filter.ChatID != "" {
where += " AND chat_id = ?"
args = append(args, filter.ChatID)
}
if filter.Status != "" {
where += " AND status = ?"
args = append(args, string(filter.Status))
}
args = append(args, filter.Limit, filter.Offset)
rows, err := s.db.QueryContext(ctx, `
SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at
FROM tasks WHERE `+where+` ORDER BY updated_at DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, fmt.Errorf("%w: list tasks: %w", ErrDB, err)
}
defer rows.Close()
var tasks []*Task
for rows.Next() {
t := &Task{}
var reposStr string
var stepsStr string
if err := rows.Scan(&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
&t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("%w: scan task: %w", ErrDB, err)
}
t.SetReposFromDB(reposStr)
t.SetStepsFromDB(stepsStr)
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
// DeleteTask удаляет задачу (каскадно — трассы). Только для тестов/админки.
func (s *Storage) DeleteTask(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("%w: delete task %d: %w", ErrDB, id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("%w: task %d", ErrNotFound, id)
}
return nil
}