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

- 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:
Hermes
2026-08-16 09:18:15 +05:00
parent 7be75fe14f
commit bd3d825738
14 changed files with 346 additions and 71 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -41,7 +42,7 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta
ChatID: "tg://worker-test",
Title: title,
Goal: "сделать " + title,
Repo: "test/" + title,
Repos: []string{title},
Why: "для теста",
AC: "работает",
TaskTag: "test-" + title,
@@ -63,6 +64,16 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta
return task
}
// seedFakeRepo создаёт в worktree/<repo>/ папку с .git, чтобы prepareRepos
// прошёл без реального git clone.
func seedFakeRepo(t *testing.T, worktree, repo string) {
t.Helper()
dir := filepath.Join(worktree, repo)
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("seed repo %s: %v", repo, err)
}
}
func TestWorkerHappyPath(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "calc")
@@ -73,6 +84,7 @@ func TestWorkerHappyPath(t *testing.T) {
Worktree: t.TempDir(),
Agent: "dev",
}
seedFakeRepo(t, w.Worktree, "calc")
ctx := context.Background()
if err := w.runTask(ctx, task); err != nil {
@@ -114,6 +126,7 @@ func TestWorkerTimeout(t *testing.T) {
Runner: &mockRunnerWorker{result: &opencode.Result{RC: -1, Stdout: ""}},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "slow")
ctx := context.Background()
_ = w.runTask(ctx, task)
@@ -147,6 +160,7 @@ func TestWorkerSpawnError(t *testing.T) {
Runner: &mockRunnerWorker{err: errors.New("opencode not found")},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "spawn-fail")
ctx := context.Background()
_ = w.runTask(ctx, task)
@@ -180,6 +194,7 @@ func TestWorkerNonZeroExit(t *testing.T) {
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 7, Stdout: "error"}},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "fail")
ctx := context.Background()
_ = w.runTask(ctx, task)
@@ -236,6 +251,7 @@ func TestWorkerPromptRendered(t *testing.T) {
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "ok", SessionID: "s"}},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "prompt-test")
ctx := context.Background()
_ = w.runTask(ctx, task)
@@ -254,20 +270,74 @@ func TestWorkerPromptRendered(t *testing.T) {
if !strings.Contains(tr.Prompt, "prompt-test") {
t.Error("prompt не содержит название задачи")
}
if !strings.Contains(tr.Prompt, "test/prompt-test") {
t.Error("prompt не содержит repo")
if !strings.Contains(tr.Prompt, " - prompt-test") {
t.Error("prompt не содержит репозиторий prompt-test")
}
}
func TestWorkerResolveCwd(t *testing.T) {
base := "/opt/data/src"
w := &Worker{Worktree: base}
if got := w.resolveCwd(""); got != base {
t.Errorf("empty repo: got %q, want %q", got, base)
func TestValidateRepoName(t *testing.T) {
valid := []string{"calc", "proj-a", "my.repo", "node_2"}
for _, r := range valid {
if err := validateRepoName(r); err != nil {
t.Errorf("validateRepoName(%q) = %v, want nil", r, err)
}
}
if got := w.resolveCwd("tools/calc"); got != filepath.Join(base, "tools/calc") {
t.Errorf("repo: got %q, want %q", got, filepath.Join(base, "tools/calc"))
invalid := []string{"", "../etc", "a/b", "a/../b", ".."}
for _, r := range invalid {
if err := validateRepoName(r); err == nil {
t.Errorf("validateRepoName(%q) = nil, want E3", r)
} else if !errors.Is(err, ErrRepoPathHint) {
t.Errorf("validateRepoName(%q) err = %v, want E3", r, err)
}
}
}
func TestBuildCloneURL(t *testing.T) {
if got := buildCloneURL("http://gitea.hal9000.home", "proj-a"); got != "http://gitea.hal9000.home/proj-a.git" {
t.Errorf("buildCloneURL = %q", got)
}
if got := buildCloneURL("http://gitea.hal9000.home/", "proj-b"); got != "http://gitea.hal9000.home/proj-b.git" {
t.Errorf("buildCloneURL trailing slash = %q", got)
}
}
func TestPrepareRepos(t *testing.T) {
s := setupWorkerDB(t)
_ = s
wt := t.TempDir()
w := &Worker{Worktree: wt, GitBaseURL: "http://gitea.hal9000.home"}
// seedFakeRepo уже создал .git — prepareRepos должен пройти без клона.
seedFakeRepo(t, wt, "proj-a")
if err := w.prepareRepos(context.Background(), []string{"proj-a"}); err != nil {
t.Fatalf("prepareRepos existing: %v", err)
}
// отсутствующий репо без git в PATH → E2 (clone упал), но не паника.
err := w.prepareRepos(context.Background(), []string{"missing"})
if err == nil {
t.Fatal("prepareRepos missing: expected error")
}
if !errors.Is(err, ErrClone) {
t.Errorf("prepareRepos missing err = %v, want E2", err)
}
}
func TestPrepareReposNonGitDir(t *testing.T) {
wt := t.TempDir()
w := &Worker{Worktree: wt}
// папка есть, но без .git → E4.
if err := os.MkdirAll(filepath.Join(wt, "plain"), 0o755); err != nil {
t.Fatal(err)
}
err := w.prepareRepos(context.Background(), []string{"plain"})
if err == nil {
t.Fatal("expected E4 error")
}
if !errors.Is(err, ErrRepoNotGit) {
t.Errorf("err = %v, want E4", err)
}
}
@@ -303,6 +373,8 @@ func TestWorkerSemaphore(t *testing.T) {
MaxJobs: 1,
Interval: 50 * time.Millisecond,
}
seedFakeRepo(t, w.Worktree, "task-0")
seedFakeRepo(t, w.Worktree, "task-1")
w.sem = make(chan struct{}, 1)
w.sem <- struct{}{}