feat: множественные репозитории (Repos) и клонирование в воркере
All checks were successful
CI / test (push) Successful in 46s
CI / build-and-package (amd64, darwin) (push) Successful in 36s
CI / build-and-package (amd64, linux) (push) Successful in 37s
CI / build-and-package (amd64, windows) (push) Successful in 44s
CI / build-and-package (arm64, darwin) (push) Successful in 35s
CI / build-and-package (arm64, linux) (push) Successful in 36s
All checks were successful
CI / test (push) Successful in 46s
CI / build-and-package (amd64, darwin) (push) Successful in 36s
CI / build-and-package (amd64, linux) (push) Successful in 37s
CI / build-and-package (amd64, windows) (push) Successful in 44s
CI / build-and-package (arm64, darwin) (push) Successful in 35s
CI / build-and-package (arm64, linux) (push) Successful in 36s
- Task.Repos []string (XML-колонка repos, обратная совместимость с repo)
- config: блок git {base_url, token}
- аналитик: ответ repos[], шаблон показывает список
- core: propose без repos → возврат в сбор (E1)
- worker вариант A: один dev из общего cwd, prepareRepos клонирует
недостающие репо (git clone), validateRepoName (E3), ErrRepoNotGit (E4)
- ошибки E1-E4 в worker/errors.go
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package storage
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Status — статус задачи (state machine).
|
||||
type Status string
|
||||
|
||||
@@ -63,15 +65,48 @@ type Task struct {
|
||||
ChatID string `json:"chat_id"` // tg://<id>
|
||||
Title string `json:"title"`
|
||||
Goal string `json:"goal"`
|
||||
Repo string `json:"repo"`
|
||||
Repo string `json:"repo"` // обратная совместимость: одиночный репозиторий
|
||||
Repos []string `json:"repos"` // список репозиториев (основной)
|
||||
Why string `json:"why"`
|
||||
AC string `json:"ac"` // acceptance criteria
|
||||
TaskTag string `json:"task_tag"` // UUID, стабильный на всю жизнь
|
||||
AC string `json:"ac"` // acceptance criteria
|
||||
TaskTag string `json:"task_tag"` // UUID, стабильный на всю жизнь
|
||||
Status Status `json:"status"`
|
||||
CreatedAt SQLiteTime `json:"created_at"`
|
||||
UpdatedAt SQLiteTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ReposJoined возвращает repos как одну строку (JSON-массив) для хранения в БД.
|
||||
// Пустой список → пустая строка.
|
||||
func (t *Task) ReposJoined() string {
|
||||
if len(t.Repos) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(t.Repos)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// EffectiveRepos возвращает сам репо: если Repos пуст, но Repo задан —
|
||||
// подтягивает одиночный (обратная совместимость).
|
||||
func (t *Task) EffectiveRepos() []string {
|
||||
if len(t.Repos) > 0 {
|
||||
return t.Repos
|
||||
}
|
||||
if t.Repo != "" {
|
||||
return []string{t.Repo}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReposFromDB заполняет Repos из сохранённой строки (JSON), либо
|
||||
// из одиночного repo (обратная совместимость).
|
||||
func (t *Task) SetReposFromDB(repos string) {
|
||||
if repos == "" {
|
||||
t.Repos = nil
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal([]byte(repos), &t.Repos)
|
||||
}
|
||||
|
||||
// Trace — запись трассировки выполнения.
|
||||
type TraceStatus string
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -127,6 +128,7 @@ func (s *Storage) migrate(ctx context.Context) error {
|
||||
why TEXT NOT NULL DEFAULT '',
|
||||
ac TEXT NOT NULL DEFAULT '',
|
||||
task_tag TEXT NOT NULL DEFAULT '',
|
||||
repos TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
@@ -162,9 +164,24 @@ func (s *Storage) migrate(ctx context.Context) error {
|
||||
if _, err := s.db.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("%w: migrate: %w", ErrDB, err)
|
||||
}
|
||||
// Доп. колонка repos (множественные репозитории). Idempotent: если колонка
|
||||
// уже есть, ALTER вернёт ошибку duplicate column — её игнорируем.
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`ALTER TABLE tasks ADD COLUMN repos TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
// SQLite 3.35+ выдаёт "duplicate column name"; ранние версии — "duplicate column".
|
||||
if !isDuplicateColumn(err) {
|
||||
return fmt.Errorf("%w: migrate add repos: %w", ErrDB, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDuplicateColumn распознаёт ошибку SQLite «duplicate column name».
|
||||
func isDuplicateColumn(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "duplicate column")
|
||||
}
|
||||
|
||||
// Now возвращает текущее время UTC как SQLiteTime.
|
||||
func Now() SQLiteTime {
|
||||
return SQLiteTime(time.Now().UTC())
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) {
|
||||
now := Now()
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO tasks (chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ChatID, t.Title, t.Goal, t.Repo, t.Why, t.AC,
|
||||
INSERT INTO tasks (chat_id, title, goal, repo, repos, why, ac, task_tag, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ChatID, t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC,
|
||||
t.TaskTag, StatusDraft, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -33,10 +33,11 @@ func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) {
|
||||
// GetTask возвращает задачу по ID.
|
||||
func (s *Storage) GetTask(ctx context.Context, id int64) (*Task, error) {
|
||||
t := &Task{}
|
||||
var reposStr string
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at
|
||||
SELECT id, chat_id, title, goal, repo, repos, why, ac, task_tag, status, created_at, updated_at
|
||||
FROM tasks WHERE id = ?`, id).Scan(
|
||||
&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo,
|
||||
&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
|
||||
&t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -45,6 +46,7 @@ func (s *Storage) GetTask(ctx context.Context, id int64) (*Task, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: get task %d: %w", ErrDB, id, err)
|
||||
}
|
||||
t.SetReposFromDB(reposStr)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -72,9 +74,9 @@ func (s *Storage) UpdateTask(ctx context.Context, t *Task) error {
|
||||
now := Now()
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE tasks
|
||||
SET title=?, goal=?, repo=?, why=?, ac=?, status=?, updated_at=?
|
||||
SET title=?, goal=?, repo=?, repos=?, why=?, ac=?, status=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
t.Title, t.Goal, t.Repo, t.Why, t.AC, t.Status, now, t.ID,
|
||||
t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC, t.Status, now, t.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: update task %d: %w", ErrDB, t.ID, err)
|
||||
@@ -91,12 +93,13 @@ func (s *Storage) UpdateTask(ctx context.Context, t *Task) error {
|
||||
// Терминальные статусы: success, cancelled, aborted, closed.
|
||||
func (s *Storage) GetActiveTaskByChatID(ctx context.Context, chatID string) (*Task, error) {
|
||||
t := &Task{}
|
||||
var reposStr string
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at
|
||||
SELECT id, chat_id, title, goal, repo, repos, why, ac, 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,
|
||||
&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
|
||||
&t.Why, &t.AC, &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)
|
||||
@@ -104,6 +107,7 @@ func (s *Storage) GetActiveTaskByChatID(ctx context.Context, chatID string) (*Ta
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: get active task %s: %w", ErrDB, chatID, err)
|
||||
}
|
||||
t.SetReposFromDB(reposStr)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -125,7 +129,7 @@ func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, er
|
||||
args = append(args, filter.Limit, filter.Offset)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at
|
||||
SELECT id, chat_id, title, goal, repo, repos, why, ac, 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)
|
||||
@@ -135,10 +139,12 @@ func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, er
|
||||
var tasks []*Task
|
||||
for rows.Next() {
|
||||
t := &Task{}
|
||||
if err := rows.Scan(&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo,
|
||||
var reposStr string
|
||||
if err := rows.Scan(&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr,
|
||||
&t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("%w: scan task: %w", ErrDB, err)
|
||||
}
|
||||
t.SetReposFromDB(reposStr)
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
|
||||
Reference in New Issue
Block a user