feat(veans): offer "create a new project" from init's picker
The project picker used to require at least one pre-existing project and would otherwise hard-error: "no projects visible to this user — create one in the Vikunja UI first". Now it always offers an extra numbered entry "Create a new project" and, when the user picks it, prompts for a title (required) + identifier (optional). Empty-list case routes straight to creation. Backed by a new client.CreateProject(ctx, *Project) method (`PUT /projects`); the e2e harness now uses that instead of the raw c.Do call it did before. Also fixed a latent bufio bug in StdPrompter.ReadLine that this work surfaced: every call created a fresh bufio.Reader, which read-ahead a buffer and threw it away on return. Second+ prompts read empty. Reuse one buffered reader on the StdPrompter instance.
This commit is contained in:
parent
9b8ad4d027
commit
c4a0575305
|
|
@ -455,6 +455,10 @@ jobs:
|
|||
VIKUNJA_SERVICE_INTERFACE: ":3456"
|
||||
VIKUNJA_SERVICE_PUBLICURL: "http://127.0.0.1:3456/"
|
||||
VIKUNJA_SERVICE_JWTSECRET: "veans-e2e-jwt-secret-do-not-use-in-production"
|
||||
# Enables PATCH /api/v1/test/{table} — the e2e suite seeds its
|
||||
# own admin via this endpoint (see veans/e2e/helpers.go), same
|
||||
# mechanism the playwright suite uses.
|
||||
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
|
||||
VIKUNJA_DATABASE_TYPE: sqlite
|
||||
VIKUNJA_DATABASE_PATH: memory
|
||||
VIKUNJA_LOG_LEVEL: WARNING
|
||||
|
|
@ -462,8 +466,9 @@ jobs:
|
|||
VIKUNJA_REDIS_ENABLED: "false"
|
||||
VIKUNJA_RATELIMIT_NOAUTHLIMIT: "1000"
|
||||
VEANS_E2E_API_URL: http://127.0.0.1:3456
|
||||
VEANS_E2E_ADMIN_USER: e2eadmin
|
||||
VEANS_E2E_ADMIN_PASS: e2etestpassword
|
||||
# Same value as VIKUNJA_SERVICE_TESTINGTOKEN above — pass-through
|
||||
# so the test harness can authenticate against /api/v1/test/.
|
||||
VEANS_E2E_TESTING_TOKEN: averyLongSecretToSe33dtheDB
|
||||
run: |
|
||||
set -e
|
||||
# Boot the prebuilt API and tests in one shell — backgrounded
|
||||
|
|
@ -483,16 +488,11 @@ jobs:
|
|||
cat /tmp/vikunja.log
|
||||
exit 1
|
||||
fi
|
||||
# The sqlite-memory DB has no fixtures loaded — register the
|
||||
# admin the suite logs in as. service.enableregistration
|
||||
# defaults to true.
|
||||
curl -sf -X POST http://127.0.0.1:3456/api/v1/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$VEANS_E2E_ADMIN_USER\",\"password\":\"$VEANS_E2E_ADMIN_PASS\",\"email\":\"e2e@example.com\"}" \
|
||||
> /dev/null
|
||||
# `mage test` runs unit + e2e packages; the e2e suite self-skips
|
||||
# when VEANS_E2E_API_URL is unset so the same target works locally.
|
||||
(cd veans && mage test)
|
||||
# `mage test:e2e` builds the binary once and exports VEANS_BINARY
|
||||
# so each subtest reuses it (plain `mage test` would rebuild per
|
||||
# test via buildOrLocate()). The suite seeds its own admin
|
||||
# internally — no curl seeding here.
|
||||
(cd veans && mage test:e2e)
|
||||
- name: Upload API log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
|
|
|
|||
|
|
@ -20,20 +20,29 @@ this file is veans-specific.
|
|||
- `mage build` → `./veans` binary. The `Aliases` map in `magefile.go`
|
||||
routes bare names like `mage test` to `Test.All` — without aliases,
|
||||
mage rejects namespace invocations ("Unknown target specified").
|
||||
- Unit tests: `mage test` or `go test ./...`.
|
||||
- E2e tests: assume an externally-running Vikunja at `VEANS_E2E_API_URL`
|
||||
and admin creds in env (`VEANS_E2E_ADMIN_TOKEN`, or
|
||||
`VEANS_E2E_ADMIN_USER` + `VEANS_E2E_ADMIN_PASS`). The package
|
||||
self-skips when `VEANS_E2E_API_URL` is empty, so plain `go test` is
|
||||
safe locally.
|
||||
- Unit tests: `mage test` (passes `-short`) or `go test -short ./...`.
|
||||
The e2e package's `TestMain` gates the suite on `-short`, mirroring
|
||||
the parent monorepo's `pkg/webtests` convention. Without `-short`
|
||||
and without `VEANS_E2E_API_URL` set, the e2e tests fail loudly with
|
||||
a "configure or pass -short" hint.
|
||||
- E2e tests: `mage test:e2e` (no `-short`). Assumes an externally-
|
||||
running Vikunja at `VEANS_E2E_API_URL`. The harness seeds its own
|
||||
admin user via `PATCH /api/v1/test/users` — same mechanism the
|
||||
playwright suite uses — so the API must be booted with
|
||||
`VIKUNJA_SERVICE_TESTINGTOKEN=<token>` and the same value passed in
|
||||
via `VEANS_E2E_TESTING_TOKEN`. Alternative path:
|
||||
`VEANS_E2E_ADMIN_TOKEN=<jwt>` skips the seed and uses the given
|
||||
token as-is, for driving a long-lived Vikunja the suite shouldn't
|
||||
mutate user rows on.
|
||||
- Local e2e loop: from the parent repo root, build the API
|
||||
(`mage build:build`), run it with sqlite-memory + a known JWT secret,
|
||||
register an admin user via `POST /register`, then
|
||||
`go test ./e2e/...` from `veans/` with the env vars above.
|
||||
(`mage build:build`), run it with sqlite-memory + a known JWT
|
||||
secret + `VIKUNJA_SERVICE_TESTINGTOKEN`, then `mage test:e2e` from
|
||||
`veans/` with `VEANS_E2E_API_URL` + `VEANS_E2E_TESTING_TOKEN`. No
|
||||
manual seeding step — the test harness handles it.
|
||||
- CI: the `test-veans-e2e` job in `.github/workflows/test.yml` consumes
|
||||
the existing `vikunja_bin` artifact from `api-build`; don't recompile
|
||||
the API in a parallel workflow. The `veans-test` job runs unit tests
|
||||
independently and gives fast feedback.
|
||||
with `-short` for fast feedback, independent of `api-build`.
|
||||
|
||||
## Vikunja wire-format gotchas
|
||||
|
||||
|
|
@ -112,15 +121,23 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
|
|||
|
||||
## Credential store
|
||||
|
||||
- Lookup chain: keychain → env (`VEANS_TOKEN`, optionally pinned by
|
||||
`VEANS_SERVER`) → file (`~/.config/veans/credentials.yml`, mode 0600,
|
||||
honors `XDG_CONFIG_HOME`).
|
||||
- Lookup chain: keychain → env (`VEANS_TOKEN`) → file
|
||||
(`~/.config/veans/credentials.yml`, mode 0600, atomic-write + flock
|
||||
serialization). `XDG_CONFIG_HOME` is deliberately not honored —
|
||||
agent-only audience runs in a known environment, and the env var
|
||||
was a path-traversal seam for no real benefit.
|
||||
- `Chain.Set` falls through to the next backend on error so a missing
|
||||
dbus on a CI runner doesn't block writes — the file backend is the
|
||||
reliable last-resort.
|
||||
- E2e tests override `HOME` and `XDG_CONFIG_HOME` per test to keep the
|
||||
developer's keyring untouched. Don't bypass the credentials package
|
||||
in tests — leaks between tests will surface as the wrong bot token.
|
||||
- File writes go through a tmp file + `Rename`, with `Chmod 0o600`
|
||||
re-asserted on the destination inode so a pre-existing wider mode
|
||||
is narrowed. Concurrent writers (e.g. two `veans login` runs) are
|
||||
serialized via `flock` on `<path>.lock` (Unix only; Windows is a
|
||||
no-op stub since the audience is Linux/macOS).
|
||||
- E2e tests override `HOME` per test and `filterEnv(..., "VEANS_")`
|
||||
strips any inherited `VEANS_TOKEN` so the developer's keyring
|
||||
stays untouched. Don't bypass the credentials package in tests —
|
||||
leaks between tests will surface as the wrong bot token.
|
||||
|
||||
## Project identifiers and bot usernames
|
||||
|
||||
|
|
|
|||
|
|
@ -15,29 +15,47 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package e2e is the integration suite for veans. It assumes a running
|
||||
// Vikunja API at VEANS_E2E_API_URL and admin/seed credentials in
|
||||
// VEANS_E2E_ADMIN_TOKEN (or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS).
|
||||
// Vikunja API at VEANS_E2E_API_URL with VIKUNJA_SERVICE_TESTINGTOKEN set
|
||||
// (passed in via VEANS_E2E_TESTING_TOKEN) so the suite can seed its own
|
||||
// admin via PATCH /api/v1/test/users — the same `/test/{table}` endpoint
|
||||
// the frontend playwright suite uses.
|
||||
//
|
||||
// The suite never provisions Vikunja itself — locally, point it at a dev
|
||||
// instance; in CI, the workflow spins one up the same way the frontend
|
||||
// Playwright suite does.
|
||||
// The alternative path — VEANS_E2E_ADMIN_TOKEN — is a JWT against a
|
||||
// long-lived Vikunja the user wants to drive without touching its data;
|
||||
// in that mode the suite skips the seed.
|
||||
//
|
||||
// The suite never provisions Vikunja itself.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
// Hard-coded seed credentials. The hash is the bcrypt of "1234" and
|
||||
// matches frontend/tests/support/constants.ts so the whole e2e infra
|
||||
// shares one well-known password — tests themselves never need to read
|
||||
// these from env.
|
||||
const (
|
||||
seedAdminUsername = "e2eadmin"
|
||||
seedAdminPassword = "1234"
|
||||
seedAdminBcrypt = "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To." //nolint:gosec // G101: deterministic test fixture, not a credential
|
||||
)
|
||||
|
||||
// Harness bundles a built veans binary and an authenticated admin client
|
||||
// for verifying side effects on the server.
|
||||
type Harness struct {
|
||||
|
|
@ -47,23 +65,24 @@ type Harness struct {
|
|||
AdminClient *client.Client
|
||||
}
|
||||
|
||||
// SkipIfNotConfigured calls t.Skip if the suite hasn't been pointed at a
|
||||
// Vikunja instance. Intended for the top of TestMain / TestXxx so plain
|
||||
// `go test ./...` doesn't fail on contributors who haven't set up the env.
|
||||
func SkipIfNotConfigured(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("VEANS_E2E_API_URL") == "" {
|
||||
t.Skip("VEANS_E2E_API_URL not set — skipping e2e")
|
||||
}
|
||||
}
|
||||
|
||||
// New builds (or reuses) the veans binary, mints/loads an admin token via
|
||||
// the env, and returns a Harness ready to drive tests.
|
||||
// New builds (or reuses) the veans binary, seeds the admin user via
|
||||
// PATCH /api/v1/test/users (using VEANS_E2E_TESTING_TOKEN), logs in as
|
||||
// that admin, and returns a Harness ready to drive tests.
|
||||
//
|
||||
// If VEANS_E2E_ADMIN_TOKEN is set, the seed is skipped and that token
|
||||
// is used directly — useful for running against a long-lived Vikunja
|
||||
// the caller doesn't want this suite to mutate user rows on.
|
||||
//
|
||||
// Tests rely on the `-short` skip in TestMain to opt out when a Vikunja
|
||||
// instance isn't available; if `-short` is *not* set and env is missing,
|
||||
// we fail loudly with a "configure or pass -short" hint.
|
||||
func New(t *testing.T) *Harness {
|
||||
t.Helper()
|
||||
SkipIfNotConfigured(t)
|
||||
|
||||
apiURL := strings.TrimRight(os.Getenv("VEANS_E2E_API_URL"), "/")
|
||||
if apiURL == "" {
|
||||
t.Fatal("VEANS_E2E_API_URL is not set — point it at a Vikunja instance, or pass -short to skip the e2e suite")
|
||||
}
|
||||
binary, err := buildOrLocate()
|
||||
if err != nil {
|
||||
t.Fatalf("locate veans binary: %v", err)
|
||||
|
|
@ -71,13 +90,16 @@ func New(t *testing.T) *Harness {
|
|||
|
||||
tok := os.Getenv("VEANS_E2E_ADMIN_TOKEN")
|
||||
if tok == "" {
|
||||
user, pass := os.Getenv("VEANS_E2E_ADMIN_USER"), os.Getenv("VEANS_E2E_ADMIN_PASS")
|
||||
if user == "" || pass == "" {
|
||||
t.Fatal("set VEANS_E2E_ADMIN_TOKEN or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS")
|
||||
testingToken := os.Getenv("VEANS_E2E_TESTING_TOKEN")
|
||||
if testingToken == "" {
|
||||
t.Fatal("set VEANS_E2E_ADMIN_TOKEN, or VEANS_E2E_TESTING_TOKEN (matching the API's VIKUNJA_SERVICE_TESTINGTOKEN) so the suite can seed its own admin")
|
||||
}
|
||||
seedAdmin(t, apiURL, testingToken)
|
||||
c := client.New(apiURL, "")
|
||||
resp, err := c.Login(context.Background(), &client.LoginRequest{
|
||||
Username: user, Password: pass, LongToken: true,
|
||||
resp, err := c.Login(t.Context(), &client.LoginRequest{
|
||||
Username: seedAdminUsername,
|
||||
Password: seedAdminPassword,
|
||||
LongToken: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin login: %v", err)
|
||||
|
|
@ -93,13 +115,52 @@ func New(t *testing.T) *Harness {
|
|||
}
|
||||
}
|
||||
|
||||
// Workspace creates a per-test git repo in a TempDir, with HOME and
|
||||
// XDG_CONFIG_HOME pointed at TempDirs so the credential store falls back
|
||||
// to its file backend rather than touching the developer's keychain.
|
||||
// seedAdmin PATCHes a single admin user row into the users table via
|
||||
// the testing endpoint. truncate=true wipes any prior users from
|
||||
// previous tests so each New(t) starts from a known state.
|
||||
func seedAdmin(t *testing.T, apiURL, testingToken string) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
body, err := json.Marshal([]map[string]any{{
|
||||
"id": 1,
|
||||
"username": seedAdminUsername,
|
||||
"password": seedAdminBcrypt,
|
||||
"email": "e2e@example.com",
|
||||
"status": 0,
|
||||
"issuer": "local",
|
||||
"language": "en",
|
||||
"created": now,
|
||||
"updated": now,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal seed payload: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
|
||||
apiURL+"/api/v1/test/users?truncate=true", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build seed request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", testingToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("seed admin: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
buf, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("seed admin: HTTP %d: %s", resp.StatusCode, string(buf))
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace creates a per-test git repo in a TempDir with HOME pointed at
|
||||
// a TempDir so the credential store writes under the test's own directory
|
||||
// rather than touching the developer's keychain.
|
||||
type Workspace struct {
|
||||
Dir string
|
||||
Home string
|
||||
XDGConfig string
|
||||
ConfigPath string
|
||||
BotUsername string
|
||||
envOverrides map[string]string
|
||||
|
|
@ -111,7 +172,6 @@ func (h *Harness) NewWorkspace(t *testing.T) *Workspace {
|
|||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
home := t.TempDir()
|
||||
xdg := t.TempDir()
|
||||
|
||||
for _, c := range [][]string{
|
||||
{"git", "init", "-q", "-b", "main"},
|
||||
|
|
@ -145,11 +205,9 @@ func (h *Harness) NewWorkspace(t *testing.T) *Workspace {
|
|||
return &Workspace{
|
||||
Dir: dir,
|
||||
Home: home,
|
||||
XDGConfig: xdg,
|
||||
ConfigPath: filepath.Join(dir, ".veans.yml"),
|
||||
envOverrides: map[string]string{
|
||||
"HOME": home,
|
||||
"XDG_CONFIG_HOME": xdg,
|
||||
"HOME": home,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -160,7 +218,10 @@ func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stde
|
|||
t.Helper()
|
||||
cmd := exec.CommandContext(t.Context(), h.Binary, args...)
|
||||
cmd.Dir = ws.Dir
|
||||
cmd.Env = append(os.Environ(), envSlice(ws.envOverrides)...)
|
||||
// Filter VEANS_* out of the inherited env before applying our
|
||||
// overrides — a developer's VEANS_TOKEN would otherwise mask the
|
||||
// per-test bot token via the env backend.
|
||||
cmd.Env = append(filterEnv(os.Environ(), "VEANS_"), envSlice(ws.envOverrides)...)
|
||||
var so, se bytes.Buffer
|
||||
cmd.Stdout = &so
|
||||
cmd.Stderr = &se
|
||||
|
|
@ -179,22 +240,19 @@ func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stde
|
|||
// it. Tests use a unique title to keep results isolated across parallel runs.
|
||||
func (h *Harness) CreateProject(t *testing.T, title, identifier string) *client.Project {
|
||||
t.Helper()
|
||||
body := map[string]any{"title": title}
|
||||
if identifier != "" {
|
||||
body["identifier"] = identifier
|
||||
}
|
||||
var out client.Project
|
||||
if err := h.AdminClient.Do(context.Background(), "PUT", "/projects", nil, body, &out); err != nil {
|
||||
out, err := h.AdminClient.CreateProject(t.Context(),
|
||||
&client.Project{Title: title, Identifier: identifier})
|
||||
if err != nil {
|
||||
t.Fatalf("create project %q: %v", title, err)
|
||||
}
|
||||
return &out
|
||||
return out
|
||||
}
|
||||
|
||||
// FindKanbanView returns the first Kanban view of the project (Vikunja
|
||||
// auto-creates one).
|
||||
func (h *Harness) FindKanbanView(t *testing.T, projectID int64) *client.ProjectView {
|
||||
t.Helper()
|
||||
views, err := h.AdminClient.ListProjectViews(context.Background(), projectID)
|
||||
views, err := h.AdminClient.ListProjectViews(t.Context(), projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("list views: %v", err)
|
||||
}
|
||||
|
|
@ -210,7 +268,7 @@ func (h *Harness) FindKanbanView(t *testing.T, projectID int64) *client.ProjectV
|
|||
// GetTask fetches a task by ID for verification.
|
||||
func (h *Harness) GetTask(t *testing.T, id int64) *client.Task {
|
||||
t.Helper()
|
||||
task, err := h.AdminClient.GetTask(context.Background(), id)
|
||||
task, err := h.AdminClient.GetTask(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("get task %d: %v", id, err)
|
||||
}
|
||||
|
|
@ -255,3 +313,14 @@ func envSlice(overrides map[string]string) []string {
|
|||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filterEnv returns env entries whose keys do NOT start with prefix.
|
||||
func filterEnv(env []string, prefix string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
if !strings.HasPrefix(kv, prefix) {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -75,7 +74,7 @@ func TestInit_HappyPath(t *testing.T) {
|
|||
|
||||
// Bot token persisted in the file backend (since HOME points at a
|
||||
// fresh tmpdir, the file backend takes over from the missing keyring).
|
||||
store := credentials.NewFileBackend(ws.XDGConfig + "/veans/credentials.yml")
|
||||
store := credentials.NewFileBackend(ws.Home + "/.config/veans/credentials.yml")
|
||||
tok, err := store.Get(h.APIURL, ws.BotUsername)
|
||||
if err != nil {
|
||||
t.Fatalf("token not persisted: %v", err)
|
||||
|
|
@ -85,7 +84,7 @@ func TestInit_HappyPath(t *testing.T) {
|
|||
}
|
||||
|
||||
// Bot exists on the server with the right username.
|
||||
bots, err := h.AdminClient.ListBotUsers(context.Background())
|
||||
bots, err := h.AdminClient.ListBotUsers(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("list bots: %v", err)
|
||||
}
|
||||
|
|
@ -105,7 +104,7 @@ func TestInit_HappyPath(t *testing.T) {
|
|||
|
||||
// Project shared with the bot at write permission.
|
||||
var shares []map[string]any
|
||||
_ = h.AdminClient.Do(context.Background(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares)
|
||||
_ = h.AdminClient.Do(t.Context(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares)
|
||||
shareFound := false
|
||||
for _, s := range shares {
|
||||
if u, _ := s["username"].(string); u == ws.BotUsername {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain follows the parent monorepo's `pkg/webtests` convention:
|
||||
// `-short` skips the whole package so a plain `go test ./...` from the
|
||||
// repo root (or `mage test`) doesn't try to drive a live Vikunja. Run
|
||||
// `mage test:e2e` to execute it.
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Short() {
|
||||
println("-short requested, skipping veans e2e tests")
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ package e2e
|
|||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -41,8 +42,8 @@ func provisionWorkspace(t *testing.T) (*Workspace, *Harness) {
|
|||
"init",
|
||||
"--server", h.APIURL,
|
||||
"--token", h.AdminToken,
|
||||
"--project", iToS(project.ID),
|
||||
"--view", iToS(view.ID),
|
||||
"--project", strconv.FormatInt(project.ID, 10),
|
||||
"--view", strconv.FormatInt(view.ID, 10),
|
||||
"--bot-username", ws.BotUsername,
|
||||
"--yes-buckets",
|
||||
)
|
||||
|
|
@ -71,27 +72,3 @@ func gitInWorkspace(t *testing.T, ws *Workspace, args ...string) {
|
|||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func iToS(n int64) string {
|
||||
const digits = "0123456789"
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
negative := false
|
||||
if n < 0 {
|
||||
negative = true
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = digits[n%10]
|
||||
n /= 10
|
||||
}
|
||||
if negative {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,16 @@ func TestCreateShowList_RoundTrip(t *testing.T) {
|
|||
t.Fatalf("filter leaked priority=%d task into result", ft.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty result set must encode as `[]`, not `null` — JSON-parsing agents
|
||||
// can't reliably branch on the latter. Use a filter that matches nothing.
|
||||
emptyOut, _, code := h.Run(t, ws, "list", "--filter", "priority > 10")
|
||||
if code != 0 {
|
||||
t.Fatalf("list (empty) exit %d\n%s", code, emptyOut)
|
||||
}
|
||||
if got := strings.TrimSpace(emptyOut); got != "[]" {
|
||||
t.Fatalf("empty list should print []; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdate_DescriptionReplaceUniqueness pins the agent-friendly Edit-tool
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@ module code.vikunja.io/veans
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
|
||||
github.com/magefile/mage v1.17.2
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/term v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,17 +44,22 @@ type Prompter interface {
|
|||
}
|
||||
|
||||
// StdPrompter reads from os.Stdin and writes prompts to os.Stderr; uses
|
||||
// term.ReadPassword for masked input when on a TTY.
|
||||
type StdPrompter struct{}
|
||||
// term.ReadPassword for masked input when on a TTY. The bufio.Reader is
|
||||
// reused across ReadLine calls — a new reader on each call would read-
|
||||
// ahead a buffer, discard the rest on return, and starve later prompts.
|
||||
type StdPrompter struct {
|
||||
stdin *bufio.Reader
|
||||
}
|
||||
|
||||
func NewStdPrompter() *StdPrompter { return &StdPrompter{} }
|
||||
func NewStdPrompter() *StdPrompter {
|
||||
return &StdPrompter{stdin: bufio.NewReader(os.Stdin)}
|
||||
}
|
||||
|
||||
func (*StdPrompter) ReadLine(prompt string) (string, error) {
|
||||
func (p *StdPrompter) ReadLine(prompt string) (string, error) {
|
||||
if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
line, err := r.ReadString('\n')
|
||||
line, err := p.stdin.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
|
@ -183,11 +184,21 @@ func newCallbackServer(listener net.Listener) (*http.Server, <-chan callbackResu
|
|||
server := &http.Server{
|
||||
Addr: listener.Addr().String(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 10 * time.Second,
|
||||
Handler: http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(rw, r)
|
||||
return
|
||||
}
|
||||
// Pin to GET so a third-party page can't POST a forged
|
||||
// (code, state) into the loopback handler. State binding
|
||||
// already defends, but cheap belt-and-braces.
|
||||
if r.Method != http.MethodGet {
|
||||
rw.Header().Set("Allow", "GET")
|
||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
res := callbackResult{code: q.Get("code"), state: q.Get("state")}
|
||||
if errCode := q.Get("error"); errCode != "" {
|
||||
|
|
@ -249,11 +260,14 @@ func renderCallbackPage(w http.ResponseWriter, err error) {
|
|||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
// HTML-escape the authorization-server's error_description — it
|
||||
// arrives unsanitized from a remote source and we render it
|
||||
// straight into the loopback page.
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html><html><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem">
|
||||
<h1>veans: authorization failed</h1>
|
||||
<p>%s</p>
|
||||
<p>You can close this tab and re-run <code>veans init</code>.</p>
|
||||
</body></html>`, err.Error())
|
||||
</body></html>`, html.EscapeString(err.Error()))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`<!doctype html><html><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem">
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -90,6 +92,16 @@ type Options struct {
|
|||
|
||||
// RepoRoot, if empty, is detected via git rev-parse from cwd.
|
||||
RepoRoot string
|
||||
|
||||
// Prompter is the seam tests use to script prompt answers. Defaults
|
||||
// to auth.NewStdPrompter() (reads stdin, writes prompts to stderr)
|
||||
// when nil.
|
||||
Prompter auth.Prompter
|
||||
|
||||
// OverwriteExistingConfig, when true, allows Init to clobber an
|
||||
// existing .veans.yml without prompting. Mostly for tests; the
|
||||
// interactive flow asks the user.
|
||||
OverwriteExistingConfig bool
|
||||
}
|
||||
|
||||
// Result is returned on success — just the bits printPostInitSummary reads.
|
||||
|
|
@ -113,9 +125,26 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
|
|||
return nil, output.New(output.CodeValidation, "ConfigPath is required")
|
||||
}
|
||||
|
||||
prompter := auth.NewStdPrompter()
|
||||
prompter := opts.Prompter
|
||||
if prompter == nil {
|
||||
prompter = auth.NewStdPrompter()
|
||||
}
|
||||
store := credentials.Default()
|
||||
|
||||
if err := confirmOverwriteExistingConfig(opts, prompter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate the bot-username override (if any) against server-side
|
||||
// rules now, so we fail fast before steps 4–7 do real work that
|
||||
// we'd then have to undo. SuggestedBotUsername's output is
|
||||
// always valid, so we only need to validate user input.
|
||||
if opts.BotUsername != "" {
|
||||
if err := validateBotUsername(normalizeBotUsername(opts.BotUsername, "")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Repo root + suggested bot username.
|
||||
repoRoot := opts.RepoRoot
|
||||
if repoRoot == "" {
|
||||
|
|
@ -278,6 +307,35 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// confirmOverwriteExistingConfig refuses to silently clobber an existing
|
||||
// .veans.yml. The bot token in the credentials store is keyed on
|
||||
// (server, bot-username); a blind re-init can swap the project under
|
||||
// the agent's feet AND stomp the previous token in the keyring.
|
||||
func confirmOverwriteExistingConfig(opts *Options, p auth.Prompter) error {
|
||||
if opts.OverwriteExistingConfig {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(opts.ConfigPath); err != nil {
|
||||
// File doesn't exist (or we can't stat it — let SaveAs surface
|
||||
// that error later). Either way, no overwrite confirmation is
|
||||
// needed.
|
||||
return nil //nolint:nilerr // intentional: any Stat error means "no existing file to overwrite"
|
||||
}
|
||||
ans, err := p.ReadLine(fmt.Sprintf(
|
||||
"%s already exists. Overwrite (token + project + view get replaced)? [y/N]: ",
|
||||
opts.ConfigPath))
|
||||
if err != nil {
|
||||
return output.Wrap(output.CodeUnknown, err, "read overwrite confirmation: %v", err)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(ans)) {
|
||||
case "y", "yes":
|
||||
return nil
|
||||
}
|
||||
return output.New(output.CodeConflict,
|
||||
"refusing to overwrite %s without confirmation (delete the file to re-init)",
|
||||
opts.ConfigPath)
|
||||
}
|
||||
|
||||
func normalizeBotUsername(override, suggested string) string {
|
||||
if override == "" {
|
||||
return suggested
|
||||
|
|
@ -288,6 +346,32 @@ func normalizeBotUsername(override, suggested string) string {
|
|||
return override
|
||||
}
|
||||
|
||||
// botUsernamePattern mirrors the server's username regex closely enough
|
||||
// to catch the rejections that would otherwise blow up steps 4–7 mid-init.
|
||||
// The server allows lowercase letters, digits, hyphens, underscores, and
|
||||
// dots; we additionally require the `bot-` prefix and forbid the
|
||||
// `link-share-N` shape Vikunja reserves for share-links.
|
||||
var botUsernamePattern = regexp.MustCompile(`^bot-[a-z0-9][a-z0-9._-]*$`)
|
||||
|
||||
var linkShareSuffix = regexp.MustCompile(`^bot-link-share-\d+$`)
|
||||
|
||||
// validateBotUsername mirrors the server-side rules so a bad
|
||||
// `--bot-username` override (or interactive prompt answer) fails fast
|
||||
// instead of dying with a 400 deep in step 8.
|
||||
func validateBotUsername(name string) error {
|
||||
if !botUsernamePattern.MatchString(name) {
|
||||
return output.New(output.CodeValidation,
|
||||
"invalid bot username %q: must start with `bot-` and contain only lowercase letters, digits, hyphens, underscores, and dots",
|
||||
name)
|
||||
}
|
||||
if linkShareSuffix.MatchString(name) {
|
||||
return output.New(output.CodeValidation,
|
||||
"invalid bot username %q: `link-share-N` is reserved by Vikunja for share-link users",
|
||||
name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompter, out io.Writer) (*client.Project, error) {
|
||||
if id != 0 {
|
||||
return c.GetProject(ctx, id)
|
||||
|
|
@ -304,11 +388,17 @@ func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompte
|
|||
}
|
||||
active = append(active, pr)
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return nil, output.New(output.CodeNotFound, "no projects visible to this user — create one in the Vikunja UI first")
|
||||
}
|
||||
sort.Slice(active, func(i, j int) bool { return active[i].Title < active[j].Title })
|
||||
|
||||
// The "create a new project" option sits at len(active)+1 in the menu;
|
||||
// when the user has nothing to pick from, it's the only choice.
|
||||
createIdx := len(active) + 1
|
||||
|
||||
if len(active) == 0 {
|
||||
fmt.Fprintln(out, "No projects yet — let's create one.")
|
||||
return createProject(ctx, c, p, out)
|
||||
}
|
||||
|
||||
fmt.Fprintln(out, "Available projects:")
|
||||
for i, pr := range active {
|
||||
ident := pr.Identifier
|
||||
|
|
@ -317,6 +407,8 @@ func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompte
|
|||
}
|
||||
fmt.Fprintf(out, " [%d] #%d %s — %s\n", i+1, pr.ID, pr.Title, ident)
|
||||
}
|
||||
fmt.Fprintf(out, " [%d] Create a new project\n", createIdx)
|
||||
|
||||
choice, err := p.ReadLine("Pick a project [1]: ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -325,14 +417,44 @@ func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompte
|
|||
idx := 1
|
||||
if choice != "" {
|
||||
v, err := strconv.Atoi(choice)
|
||||
if err != nil || v < 1 || v > len(active) {
|
||||
if err != nil || v < 1 || v > createIdx {
|
||||
return nil, output.New(output.CodeValidation, "invalid project choice %q", choice)
|
||||
}
|
||||
idx = v
|
||||
}
|
||||
if idx == createIdx {
|
||||
return createProject(ctx, c, p, out)
|
||||
}
|
||||
return active[idx-1], nil
|
||||
}
|
||||
|
||||
// createProject prompts for the new project's title and identifier and
|
||||
// PUTs it. Title is required; identifier is optional (Vikunja caps it at
|
||||
// 10 chars). The fresh project comes with the default views — including
|
||||
// the Kanban view pickKanbanView is about to grab.
|
||||
func createProject(ctx context.Context, c *client.Client, p auth.Prompter, out io.Writer) (*client.Project, error) {
|
||||
title, err := p.ReadLine("New project title: ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, output.New(output.CodeValidation, "project title is required")
|
||||
}
|
||||
ident, err := p.ReadLine("Identifier (optional, ≤10 letters/digits, used for task IDs like PROJ-NN): ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ident = strings.TrimSpace(ident)
|
||||
|
||||
created, err := c.CreateProject(ctx, &client.Project{Title: title, Identifier: ident})
|
||||
if err != nil {
|
||||
return nil, output.Wrap(output.CodeUnknown, err, "create project %q: %v", title, err)
|
||||
}
|
||||
progress(out, "Created project #%d %q", created.ID, created.Title)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func pickKanbanView(ctx context.Context, c *client.Client, projectID int64, viewID int64, p auth.Prompter, out io.Writer) (*client.ProjectView, error) {
|
||||
views, err := c.ListProjectViews(ctx, projectID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,60 @@ import (
|
|||
// reading the same string.
|
||||
const veansPrimeCommand = "veans prime"
|
||||
|
||||
// ClaudeCodeHookEvents enumerates every Claude Code lifecycle event that
|
||||
// veans wires `veans prime` into. Both the auto-installer in this file and
|
||||
// the manual-install snippet rendered by the `init` command iterate this
|
||||
// list so the two paths can never drift.
|
||||
var ClaudeCodeHookEvents = []string{"SessionStart", "PreCompact"}
|
||||
|
||||
// ClaudeCodeSettingsRelPath is the per-repo Claude Code settings file that
|
||||
// installClaudeCodeHook merges into. Exported so the `init` command can name
|
||||
// it in the manual-install blurb without re-typing the literal.
|
||||
const ClaudeCodeSettingsRelPath = ".claude/settings.json"
|
||||
|
||||
// OpenCodePluginRelPath is the per-repo path the OpenCode plugin is written
|
||||
// to. Same rationale as ClaudeCodeSettingsRelPath.
|
||||
const OpenCodePluginRelPath = ".opencode/plugin/veans-prime.ts"
|
||||
|
||||
// OpenCodePluginSnippet is the exact TypeScript plugin written to disk by
|
||||
// installOpenCodeHook. Re-exported verbatim for the manual-install path so a
|
||||
// copy-pasted snippet is byte-for-byte what the installer would have
|
||||
// produced.
|
||||
const OpenCodePluginSnippet = `// Auto-generated by 'veans init'. Re-emits the veans agent prompt at the
|
||||
// start of every OpenCode session and before every compaction. See
|
||||
// https://github.com/go-vikunja/vikunja/tree/main/veans for context.
|
||||
export const VeansPrime = {
|
||||
event: ["session.start", "compact.before"],
|
||||
handler: async ({ exec }: { exec: (cmd: string) => Promise<unknown> }) =>
|
||||
exec("veans prime"),
|
||||
}
|
||||
`
|
||||
|
||||
// ClaudeCodeHookSnippet renders the JSON fragment a user would paste into
|
||||
// .claude/settings.json to get the same wiring the auto-installer performs.
|
||||
// Generated from ClaudeCodeHookEvents + veansPrimeCommand so a new event
|
||||
// added to the install list automatically shows up in the manual snippet
|
||||
// too.
|
||||
func ClaudeCodeHookSnippet() string {
|
||||
hooks := map[string]any{}
|
||||
for _, event := range ClaudeCodeHookEvents {
|
||||
hooks[event] = []any{
|
||||
map[string]any{
|
||||
"hooks": []any{
|
||||
map[string]any{"type": "command", "command": veansPrimeCommand},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
buf, err := json.MarshalIndent(map[string]any{"hooks": hooks}, "", " ")
|
||||
if err != nil {
|
||||
// MarshalIndent on a hand-built map[string]any can't realistically
|
||||
// fail; fall back so callers never see an empty snippet.
|
||||
return fmt.Sprintf(`{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": %q}]}]}}`, veansPrimeCommand)
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// AgentHookChoice captures the user's per-agent install decision so the
|
||||
// orchestration in bootstrap.Init can hand the per-repo set of choices
|
||||
// off to the install routines below.
|
||||
|
|
@ -97,13 +151,13 @@ func installAgentHooks(repoRoot string, choices AgentHookChoice, w io.Writer) er
|
|||
// a human verb describing what happened ("Wrote", "Updated", "Already
|
||||
// configured"), and any error.
|
||||
func installClaudeCodeHook(repoRoot string) (string, string, error) {
|
||||
path := filepath.Join(repoRoot, ".claude", "settings.json")
|
||||
path := filepath.Join(repoRoot, filepath.FromSlash(ClaudeCodeSettingsRelPath))
|
||||
settings, existed, err := readJSONOrEmpty(path)
|
||||
if err != nil {
|
||||
return path, "", err
|
||||
}
|
||||
changed := false
|
||||
for _, event := range []string{"SessionStart", "PreCompact"} {
|
||||
for _, event := range ClaudeCodeHookEvents {
|
||||
if ensureClaudeHook(settings, event) {
|
||||
changed = true
|
||||
}
|
||||
|
|
@ -168,7 +222,7 @@ func ensureClaudeHook(settings map[string]any, event string) bool {
|
|||
// if missing. Existing files are left alone (TypeScript merging is out of
|
||||
// scope; the user can edit by hand).
|
||||
func installOpenCodeHook(repoRoot string) (string, string, error) {
|
||||
path := filepath.Join(repoRoot, ".opencode", "plugin", "veans-prime.ts")
|
||||
path := filepath.Join(repoRoot, filepath.FromSlash(OpenCodePluginRelPath))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, "Already configured", nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
|
|
@ -177,22 +231,12 @@ func installOpenCodeHook(repoRoot string) (string, string, error) {
|
|||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return path, "", err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(openCodeHookSource), 0o644); err != nil {
|
||||
if err := os.WriteFile(path, []byte(OpenCodePluginSnippet), 0o644); err != nil {
|
||||
return path, "", err
|
||||
}
|
||||
return path, "Wrote", nil
|
||||
}
|
||||
|
||||
const openCodeHookSource = `// Auto-generated by 'veans init'. Re-emits the veans agent prompt at the
|
||||
// start of every OpenCode session and before every compaction. See
|
||||
// https://github.com/go-vikunja/vikunja/tree/main/veans for context.
|
||||
export const VeansPrime = {
|
||||
event: ["session.start", "compact.before"],
|
||||
handler: async ({ exec }: { exec: (cmd: string) => Promise<unknown> }) =>
|
||||
exec("veans prime"),
|
||||
}
|
||||
`
|
||||
|
||||
// readJSONOrEmpty reads `path` as JSON or returns an empty object if the
|
||||
// file doesn't exist. The `existed` flag tells the caller whether the
|
||||
// resulting object was loaded from disk (so it can decide between
|
||||
|
|
@ -217,6 +261,10 @@ func readJSONOrEmpty(path string) (out map[string]any, existed bool, err error)
|
|||
|
||||
// writeJSON encodes `data` with two-space indent (Claude Code's house
|
||||
// style) and a trailing newline, creating parent directories as needed.
|
||||
// Settings files written here may end up holding provider API keys, so we
|
||||
// default new files to 0o600 and preserve the existing mode on update so a
|
||||
// user who has tightened the file (e.g. to 0o600 explicitly, or chmod'd it
|
||||
// further) doesn't see their permissions widened on the next write.
|
||||
func writeJSON(path string, data map[string]any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
|
|
@ -226,7 +274,11 @@ func writeJSON(path string, data map[string]any) error {
|
|||
return err
|
||||
}
|
||||
buf = append(buf, '\n')
|
||||
return os.WriteFile(path, buf, 0o644)
|
||||
mode := os.FileMode(0o600)
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
return os.WriteFile(path, buf, mode)
|
||||
}
|
||||
|
||||
// mapAt returns the map at key `k` on `m`, creating it if missing or if
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -98,13 +98,14 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return mapHTTPError(method, path, resp.StatusCode, respBody)
|
||||
return mapHTTPError(method, path, resp.StatusCode, respBody,
|
||||
parseRetryAfter(resp.Header.Get("Retry-After")))
|
||||
}
|
||||
|
||||
if out != nil && len(respBody) > 0 {
|
||||
|
|
@ -115,9 +116,60 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
|||
return nil
|
||||
}
|
||||
|
||||
// DoPaginated is like Do but also returns the total page count parsed from
|
||||
// the `x-pagination-total-pages` response header (0 if the header is
|
||||
// missing or unparseable). Used by the list endpoints so paging terminates
|
||||
// against the authoritative server count, not a `len(batch) < per_page`
|
||||
// heuristic that loops one extra time on exact-multiple totals.
|
||||
func (c *Client) DoPaginated(ctx context.Context, method, path string, query url.Values, out any) (totalPages int, err error) {
|
||||
full := c.BaseURL + "/api/v1" + path
|
||||
if len(query) > 0 {
|
||||
full += "?" + query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, output.Wrap(output.CodeUnknown, err, "%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return 0, mapHTTPError(method, path, resp.StatusCode, respBody,
|
||||
parseRetryAfter(resp.Header.Get("Retry-After")))
|
||||
}
|
||||
if out != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return 0, fmt.Errorf("decode %s %s: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
if v := resp.Header.Get("x-pagination-total-pages"); v != "" {
|
||||
if n, perr := strconv.Atoi(v); perr == nil {
|
||||
totalPages = n
|
||||
}
|
||||
}
|
||||
return totalPages, nil
|
||||
}
|
||||
|
||||
// DoRaw is the escape hatch used by `veans api`. It returns the raw response
|
||||
// body and status. Auth + base URL handling matches Do.
|
||||
func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Values, body []byte) (status int, respBody []byte, err error) {
|
||||
// body, status, and the parsed Retry-After (if any). Auth + base URL handling
|
||||
// matches Do. The caller is responsible for deciding whether to surface the
|
||||
// body to stdout — non-2xx bodies should NOT be written there (the contract is
|
||||
// "stdout is for the success payload; errors go through the envelope on
|
||||
// stderr"); see commands/api.go for the canonical handling.
|
||||
func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Values, body []byte) (status int, respBody []byte, retryAfter time.Duration, err error) {
|
||||
full := c.BaseURL + "/api/v1" + path
|
||||
if len(query) > 0 {
|
||||
full += "?" + query.Encode()
|
||||
|
|
@ -128,7 +180,7 @@ func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Value
|
|||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, br)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if len(body) > 0 {
|
||||
|
|
@ -140,14 +192,50 @@ func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Value
|
|||
req.Header.Set("User-Agent", UserAgent)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err = io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, respBody, err
|
||||
respBody, err = io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
return resp.StatusCode, respBody, parseRetryAfter(resp.Header.Get("Retry-After")), err
|
||||
}
|
||||
|
||||
func mapHTTPError(method, path string, status int, body []byte) error {
|
||||
// paginationDone reports whether a paged GET has consumed every page,
|
||||
// preferring the server's x-pagination-total-pages count when present and
|
||||
// falling back to the len(batch) < per_page heuristic when the header is
|
||||
// missing (older server / proxy stripped). Centralized so all list
|
||||
// endpoints terminate identically.
|
||||
func paginationDone(page, batchLen, perPage, totalPages int) bool {
|
||||
if totalPages > 0 {
|
||||
return page >= totalPages
|
||||
}
|
||||
return batchLen < perPage
|
||||
}
|
||||
|
||||
// maxBodyBytes caps the size of any response body we'll read into memory.
|
||||
// Vikunja JSON payloads are far smaller; the cap exists so a misbehaving
|
||||
// proxy can't OOM the CLI by streaming an unbounded body.
|
||||
const maxBodyBytes = 32 * 1024 * 1024 // 32 MiB
|
||||
|
||||
// parseRetryAfter parses an HTTP Retry-After header value. Supports both
|
||||
// the delta-seconds form and the HTTP-date form; returns 0 on unparseable
|
||||
// or empty input.
|
||||
func parseRetryAfter(v string) time.Duration {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
if t, err := http.ParseTime(v); err == nil {
|
||||
if d := time.Until(t); d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mapHTTPError(method, path string, status int, body []byte, retryAfter time.Duration) error {
|
||||
var ve vikunjaError
|
||||
_ = json.Unmarshal(body, &ve)
|
||||
msg := strings.TrimSpace(ve.Message)
|
||||
|
|
@ -157,6 +245,11 @@ func mapHTTPError(method, path string, status int, body []byte) error {
|
|||
msg = http.StatusText(status)
|
||||
}
|
||||
}
|
||||
// Truncate so an HTML error page (e.g. from a reverse proxy) doesn't
|
||||
// dump several KB into the agent's stderr envelope.
|
||||
if len(msg) > maxErrorMessageBytes {
|
||||
msg = msg[:maxErrorMessageBytes] + "…(truncated)"
|
||||
}
|
||||
|
||||
var code output.Code
|
||||
switch {
|
||||
|
|
@ -174,9 +267,17 @@ func mapHTTPError(method, path string, status int, body []byte) error {
|
|||
code = output.CodeUnknown
|
||||
}
|
||||
|
||||
formatted := fmt.Sprintf("%s %s: %d %s", method, path, status, msg)
|
||||
if retryAfter > 0 {
|
||||
formatted += fmt.Sprintf(" (retry-after %s)", retryAfter)
|
||||
}
|
||||
return &output.Error{
|
||||
Code: code,
|
||||
Message: fmt.Sprintf("%s %s: %d %s", method, path, status, msg),
|
||||
Cause: errors.New(msg),
|
||||
Message: formatted,
|
||||
}
|
||||
}
|
||||
|
||||
// maxErrorMessageBytes caps how much upstream-error text we embed in the
|
||||
// envelope's `error` field. Anything longer is almost always an HTML page
|
||||
// from a proxy and useless for the agent to read.
|
||||
const maxErrorMessageBytes = 512
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ import (
|
|||
// authenticated user (labels are global per user, not scoped to a project).
|
||||
func (c *Client) ListLabels(ctx context.Context, search string) ([]*Label, error) {
|
||||
var all []*Label
|
||||
for page := 1; ; page++ {
|
||||
page := 1
|
||||
for {
|
||||
q := url.Values{}
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("per_page", "50")
|
||||
|
|
@ -35,13 +36,15 @@ func (c *Client) ListLabels(ctx context.Context, search string) ([]*Label, error
|
|||
q.Set("s", search)
|
||||
}
|
||||
var batch []*Label
|
||||
if err := c.Do(ctx, "GET", "/labels", q, nil, &batch); err != nil {
|
||||
total, err := c.DoPaginated(ctx, "GET", "/labels", q, &batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if len(batch) < 50 {
|
||||
if paginationDone(page, len(batch), 50, total) {
|
||||
return all, nil
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,21 +23,25 @@ import (
|
|||
"strconv"
|
||||
)
|
||||
|
||||
// ListProjects pages through GET /projects, accumulating until exhausted.
|
||||
// ListProjects pages through GET /projects, accumulating until the server's
|
||||
// x-pagination-total-pages header says we're done.
|
||||
func (c *Client) ListProjects(ctx context.Context) ([]*Project, error) {
|
||||
var all []*Project
|
||||
for page := 1; ; page++ {
|
||||
page := 1
|
||||
for {
|
||||
q := url.Values{}
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("per_page", "50")
|
||||
var batch []*Project
|
||||
if err := c.Do(ctx, "GET", "/projects", q, nil, &batch); err != nil {
|
||||
total, err := c.DoPaginated(ctx, "GET", "/projects", q, &batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if len(batch) < 50 {
|
||||
if paginationDone(page, len(batch), 50, total) {
|
||||
return all, nil
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +54,16 @@ func (c *Client) GetProject(ctx context.Context, id int64) (*Project, error) {
|
|||
return &out, nil
|
||||
}
|
||||
|
||||
// CreateProject creates a new project owned by the calling user. Vikunja
|
||||
// auto-creates the default views (List, Gantt, Table, Kanban) on insert.
|
||||
func (c *Client) CreateProject(ctx context.Context, p *Project) (*Project, error) {
|
||||
var out Project
|
||||
if err := c.Do(ctx, "PUT", "/projects", nil, p, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ShareProjectWithUser grants `username` `permission` on project `id`.
|
||||
func (c *Client) ShareProjectWithUser(ctx context.Context, projectID int64, share *ProjectUser) (*ProjectUser, error) {
|
||||
var out ProjectUser
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ func (o *TaskListOptions) values() url.Values {
|
|||
return q
|
||||
}
|
||||
|
||||
// ListProjectTasks paginates `GET /projects/{id}/tasks` exhaustively.
|
||||
// ListProjectTasks paginates `GET /projects/{id}/tasks` exhaustively,
|
||||
// terminating against the server's x-pagination-total-pages header.
|
||||
func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *TaskListOptions) ([]*Task, error) {
|
||||
if opts == nil {
|
||||
opts = &TaskListOptions{}
|
||||
|
|
@ -61,18 +62,21 @@ func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *Ta
|
|||
per = 50
|
||||
}
|
||||
var all []*Task
|
||||
for page := 1; ; page++ {
|
||||
page := 1
|
||||
for {
|
||||
o := *opts
|
||||
o.Page = page
|
||||
o.PerPage = per
|
||||
var batch []*Task
|
||||
if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d/tasks", projectID), o.values(), nil, &batch); err != nil {
|
||||
total, err := c.DoPaginated(ctx, "GET", fmt.Sprintf("/projects/%d/tasks", projectID), o.values(), &batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if len(batch) < per {
|
||||
if paginationDone(page, len(batch), per, total) {
|
||||
return all, nil
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,8 +123,12 @@ func (c *Client) CreateTask(ctx context.Context, projectID int64, t *Task) (*Tas
|
|||
return &out, nil
|
||||
}
|
||||
|
||||
// UpdateTask updates a task (POST /tasks/{id}). bucket_id moves the task
|
||||
// between buckets in the same view.
|
||||
// UpdateTask updates a task (POST /tasks/{id}). This endpoint does NOT
|
||||
// move tasks between buckets — the task↔bucket relation is row-shaped in
|
||||
// task_buckets, and bucket_id on the request body is ignored. Use
|
||||
// MoveTaskToBucket() for that. The server does auto-flip the bucket
|
||||
// when `done` toggles, but only between the canonical "todo" and "done"
|
||||
// buckets the project view is configured with.
|
||||
func (c *Client) UpdateTask(ctx context.Context, id int64, t *Task) (*Task, error) {
|
||||
var out Task
|
||||
if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d", id), nil, t, &out); err != nil {
|
||||
|
|
|
|||
|
|
@ -102,14 +102,19 @@ type Task struct {
|
|||
// side endpoint (e.g. the bucket-move POST); reads return it as 0.
|
||||
// The current bucket(s) — one per Kanban view — are exposed via
|
||||
// ?expand=buckets in the Buckets slice.
|
||||
BucketID int64 `json:"bucket_id,omitempty"`
|
||||
Buckets []*Bucket `json:"buckets,omitempty"`
|
||||
Assignees []*User `json:"assignees,omitempty"`
|
||||
Labels []*Label `json:"labels,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
PercentDone float64 `json:"percent_done,omitempty"`
|
||||
BucketID int64 `json:"bucket_id,omitempty"`
|
||||
Buckets []*Bucket `json:"buckets,omitempty"`
|
||||
Assignees []*User `json:"assignees,omitempty"`
|
||||
Labels []*Label `json:"labels,omitempty"`
|
||||
// RelatedTasks groups other tasks by relation kind ("blocking",
|
||||
// "blocked", "parenttask", "subtask", "related", ...). Vikunja
|
||||
// populates this on every task read; the nested tasks have their
|
||||
// own RelatedTasks nil'd out server-side to avoid cycles.
|
||||
RelatedTasks map[string][]*Task `json:"related_tasks,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
PercentDone float64 `json:"percent_done,omitempty"`
|
||||
}
|
||||
|
||||
// TaskComment matches pkg/models/task_comments.TaskComment.
|
||||
|
|
|
|||
|
|
@ -85,18 +85,28 @@ Examples:
|
|||
body = []byte(dataFlag)
|
||||
}
|
||||
|
||||
status, respBody, err := rt.client.DoRaw(cmd.Context(), method, path, query, body)
|
||||
status, respBody, retryAfter, err := rt.client.DoRaw(cmd.Context(), method, path, query, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// On non-2xx, write the body to stderr and exit non-zero so
|
||||
// shell pipelines see the failure clearly.
|
||||
// On non-2xx, do NOT write the body to stdout — the agent
|
||||
// contract is "stdout is the success payload". Fold a short
|
||||
// snippet of the upstream error into the envelope message so
|
||||
// the agent gets actionable context without a separate channel
|
||||
// to parse.
|
||||
if status >= 400 {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "HTTP %d %s %s\n", status, method, path)
|
||||
if _, werr := cmd.OutOrStdout().Write(respBody); werr != nil {
|
||||
return werr
|
||||
snippet := strings.TrimSpace(string(respBody))
|
||||
if len(snippet) > maxAPIErrorSnippet {
|
||||
snippet = snippet[:maxAPIErrorSnippet] + "…(truncated)"
|
||||
}
|
||||
return output.New(mapStatusToCode(status), "HTTP %d", status)
|
||||
msg := fmt.Sprintf("HTTP %d %s %s", status, method, path)
|
||||
if snippet != "" {
|
||||
msg = fmt.Sprintf("%s: %s", msg, snippet)
|
||||
}
|
||||
if retryAfter > 0 {
|
||||
msg = fmt.Sprintf("%s (retry-after %s)", msg, retryAfter)
|
||||
}
|
||||
return output.New(mapStatusToCode(status), "%s", msg)
|
||||
}
|
||||
if _, werr := cmd.OutOrStdout().Write(respBody); werr != nil {
|
||||
return werr
|
||||
|
|
@ -110,6 +120,10 @@ Examples:
|
|||
return cmd
|
||||
}
|
||||
|
||||
// maxAPIErrorSnippet caps how much upstream-error body we fold into the
|
||||
// `error` envelope field. Anything longer is almost always an HTML page.
|
||||
const maxAPIErrorSnippet = 512
|
||||
|
||||
func mapStatusToCode(status int) output.Code {
|
||||
switch {
|
||||
case status == 401, status == 403:
|
||||
|
|
|
|||
|
|
@ -52,10 +52,6 @@ func newClaimCmd() *cobra.Command {
|
|||
rt.cfg.ProjectID, rt.cfg.ViewID, bid, id); err != nil {
|
||||
return err
|
||||
}
|
||||
task, err := rt.client.GetTask(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Assign the bot. Idempotent on repeat — Vikunja returns 409 if
|
||||
// already assigned, which we map to a soft-skip.
|
||||
|
|
@ -81,9 +77,9 @@ func newClaimCmd() *cobra.Command {
|
|||
}
|
||||
}
|
||||
|
||||
fresh, err := rt.client.GetTask(cmd.Context(), id)
|
||||
if err == nil {
|
||||
task = fresh
|
||||
task, err := rt.client.GetTask(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ func newCreateCmd() *cobra.Command {
|
|||
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&f.description, "description", "d", "", "task description (markdown)")
|
||||
cmd.Flags().StringVarP(&f.description, "description", "d", "", "task description (HTML; see `veans prime` for canonical TipTap shapes)")
|
||||
cmd.Flags().StringVarP(&f.statusName, "status", "s", "todo", "initial status (defaults to todo)")
|
||||
cmd.Flags().Int64Var(&f.priority, "priority", 0, "priority (0=unset, 1=low, 5=DO_NOW)")
|
||||
cmd.Flags().StringSliceVar(&f.labels, "label", nil, "labels to attach (repeatable; veans: prefix added if missing)")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package commands
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
|
@ -25,10 +26,34 @@ import (
|
|||
// runGit runs `git <args...>` in the current working directory and returns
|
||||
// trimmed stdout. Errors are returned to the caller so they can decide
|
||||
// whether silence or escalation is appropriate.
|
||||
//
|
||||
// The inherited environment is scrubbed of all GIT_* variables before
|
||||
// invocation. Defense-in-depth: a stray GIT_DIR / GIT_WORK_TREE /
|
||||
// GIT_INDEX_FILE in the caller's environment could redirect git to a
|
||||
// different repository and cause downstream commands (e.g. `claim`
|
||||
// attaching `veans:branch:<name>`) to act on the wrong branch.
|
||||
// GIT_OPTIONAL_LOCKS=0 is set so a concurrent git process holding the
|
||||
// index lock can't block veans.
|
||||
func runGit(ctx context.Context, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, "git", args...).Output()
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Env = append(scrubGitEnv(os.Environ()), "GIT_OPTIONAL_LOCKS=0")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(string(out), "\r\n"), nil
|
||||
}
|
||||
|
||||
// scrubGitEnv returns env entries whose keys do not start with "GIT_".
|
||||
// PATH and other essentials are preserved so git can still be located
|
||||
// and configured normally (e.g. SSH_AUTH_SOCK, HOME, USER).
|
||||
func scrubGitEnv(env []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
if strings.HasPrefix(kv, "GIT_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
|
|
@ -129,18 +130,35 @@ func printPostInitSummary(w io.Writer, res *bootstrap.Result) {
|
|||
if res.AgentChoices.ClaudeCode || res.AgentChoices.OpenCode {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, `
|
||||
// Snippets are sourced from the bootstrap package so manual installs
|
||||
// stay byte-for-byte equivalent to what `installAgentHooks` would have
|
||||
// written — if a new hook event is added there, it shows up here too.
|
||||
fmt.Fprintf(w, `
|
||||
To wire veans into your coding agent later, paste one of these snippets:
|
||||
|
||||
Claude Code (.claude/settings.json):
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }],
|
||||
"PreCompact": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }]
|
||||
}
|
||||
}
|
||||
Claude Code (%s):
|
||||
%s
|
||||
|
||||
OpenCode (.opencode/plugin/veans-prime.ts): see veans/README.md`)
|
||||
OpenCode (%s):
|
||||
%s`, bootstrap.ClaudeCodeSettingsRelPath, indent(bootstrap.ClaudeCodeHookSnippet(), " "),
|
||||
bootstrap.OpenCodePluginRelPath, indent(bootstrap.OpenCodePluginSnippet, " "))
|
||||
}
|
||||
|
||||
// indent prefixes every line of s with prefix. Used to inset the embedded
|
||||
// snippets under their "Claude Code:" / "OpenCode:" headings without
|
||||
// hard-coding the indent inside the bootstrap package's snippet strings.
|
||||
func indent(s, prefix string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
for i, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lines[i] = prefix + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func identOrFallback(s string) string {
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ func newListCmd() *cobra.Command {
|
|||
Long: `List tasks in the project configured in .veans.yml.
|
||||
|
||||
Filters can be combined; they're AND-ed together:
|
||||
--ready ready to start: in Todo with done=false (incomplete-blocker
|
||||
detection is best-effort, see veans/README.md)
|
||||
--ready ready to start: in Todo, not done, and no incomplete
|
||||
"blocked" relation
|
||||
--mine only tasks assigned to the veans bot
|
||||
--branch [name] only tasks tagged 'veans:branch:<name>' (defaults to the
|
||||
current git branch when used without a value)
|
||||
|
|
@ -78,21 +78,23 @@ Filters can be combined; they're AND-ed together:
|
|||
func runList(cmd *cobra.Command, rt *runtime, f *listFlags) ([]*client.Task, error) {
|
||||
opts := &client.TaskListOptions{
|
||||
Filter: f.filter,
|
||||
Expand: []string{"reactions"},
|
||||
// expand=buckets is required for CurrentBucketID() to resolve;
|
||||
// the default GET returns bucket_id=0 (xorm:"-" on the model).
|
||||
Expand: []string{"buckets"},
|
||||
}
|
||||
tasks, err := rt.client.ListProjectTasks(cmd.Context(), rt.cfg.ProjectID, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply client-side filters AND-style.
|
||||
var out []*client.Task
|
||||
// Apply client-side filters AND-style. Pre-allocate as an empty
|
||||
// (non-nil) slice so an empty result still encodes as `[]`, not `null` —
|
||||
// the agent contract is "raw array".
|
||||
out := make([]*client.Task, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
taskBucket := t.CurrentBucketID(rt.cfg.ViewID)
|
||||
if f.ready {
|
||||
if t.Done || taskBucket != rt.cfg.Buckets.Todo {
|
||||
continue
|
||||
}
|
||||
if f.ready && !isReady(t, rt.cfg.Buckets.Todo, rt.cfg.ViewID) {
|
||||
continue
|
||||
}
|
||||
if f.mine {
|
||||
if !taskAssignedTo(t, rt.cfg.Bot.UserID) {
|
||||
|
|
@ -135,6 +137,21 @@ func runList(cmd *cobra.Command, rt *runtime, f *listFlags) ([]*client.Task, err
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// isReady reports whether t is ready to start: in the Todo bucket, not done,
|
||||
// and not blocked by any incomplete task. "blocked" is the relation kind on
|
||||
// the dependent task — parenttask / subtask have no bearing on readiness.
|
||||
func isReady(t *client.Task, todoBucket, viewID int64) bool {
|
||||
if t.Done || t.CurrentBucketID(viewID) != todoBucket {
|
||||
return false
|
||||
}
|
||||
for _, b := range t.RelatedTasks["blocked"] {
|
||||
if b != nil && !b.Done {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func taskAssignedTo(t *client.Task, userID int64) bool {
|
||||
for _, a := range t.Assignees {
|
||||
if a != nil && a.ID == userID {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
func TestIsReady(t *testing.T) {
|
||||
const todoBucket int64 = 11
|
||||
const viewID int64 = 7
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
task *client.Task
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "in todo, no relations -> ready",
|
||||
task: &client.Task{
|
||||
ID: 1,
|
||||
Buckets: []*client.Bucket{{ID: todoBucket, ProjectViewID: viewID}},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "subtask with only parenttask + blocking -> ready",
|
||||
task: &client.Task{
|
||||
ID: 2,
|
||||
Buckets: []*client.Bucket{{ID: todoBucket, ProjectViewID: viewID}},
|
||||
RelatedTasks: map[string][]*client.Task{
|
||||
"parenttask": {{ID: 1, Done: false}},
|
||||
"blocking": {{ID: 3, Done: false}},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "blocked by incomplete task -> not ready",
|
||||
task: &client.Task{
|
||||
ID: 3,
|
||||
Buckets: []*client.Bucket{{ID: todoBucket, ProjectViewID: viewID}},
|
||||
RelatedTasks: map[string][]*client.Task{
|
||||
"blocked": {{ID: 2, Done: false}},
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "blocked by completed task -> ready",
|
||||
task: &client.Task{
|
||||
ID: 3,
|
||||
Buckets: []*client.Bucket{{ID: todoBucket, ProjectViewID: viewID}},
|
||||
RelatedTasks: map[string][]*client.Task{
|
||||
"blocked": {{ID: 2, Done: true}},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "done task -> not ready",
|
||||
task: &client.Task{
|
||||
ID: 4,
|
||||
Done: true,
|
||||
Buckets: []*client.Bucket{{ID: todoBucket, ProjectViewID: viewID}},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "in another bucket -> not ready",
|
||||
task: &client.Task{
|
||||
ID: 5,
|
||||
Buckets: []*client.Bucket{{ID: 99, ProjectViewID: viewID}},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "blocked by mix of done and incomplete -> not ready",
|
||||
task: &client.Task{
|
||||
ID: 6,
|
||||
Buckets: []*client.Bucket{{ID: todoBucket, ProjectViewID: viewID}},
|
||||
RelatedTasks: map[string][]*client.Task{
|
||||
"blocked": {{ID: 7, Done: true}, {ID: 8, Done: false}},
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isReady(tc.task, todoBucket, viewID); got != tc.want {
|
||||
t.Fatalf("isReady = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsReady_SubtaskWithBlockedSibling pins the scenario from the bug
|
||||
// report: two tasks share a parent; one has no "blocked" entry and is
|
||||
// ready; the other is blocked by the first and is not ready. Once the
|
||||
// first completes, the second becomes ready.
|
||||
func TestIsReady_SubtaskWithBlockedSibling(t *testing.T) {
|
||||
const todoBucket int64 = 11
|
||||
const viewID int64 = 7
|
||||
bucket := []*client.Bucket{{ID: todoBucket, ProjectViewID: viewID}}
|
||||
|
||||
// Task #2: subtask of #1, blocks #3. No "blocked" relations of its own.
|
||||
taskTwo := &client.Task{
|
||||
ID: 2, Done: false, Buckets: bucket,
|
||||
RelatedTasks: map[string][]*client.Task{
|
||||
"parenttask": {{ID: 1, Done: false}},
|
||||
"blocking": {{ID: 3, Done: false}},
|
||||
},
|
||||
}
|
||||
// Task #3: subtask of #1, blocked by #2.
|
||||
taskThree := &client.Task{
|
||||
ID: 3, Done: false, Buckets: bucket,
|
||||
RelatedTasks: map[string][]*client.Task{
|
||||
"parenttask": {{ID: 1, Done: false}},
|
||||
"blocked": {{ID: 2, Done: false}},
|
||||
},
|
||||
}
|
||||
|
||||
if !isReady(taskTwo, todoBucket, viewID) {
|
||||
t.Fatal("task #2 should be ready (no incomplete blockers)")
|
||||
}
|
||||
if isReady(taskThree, todoBucket, viewID) {
|
||||
t.Fatal("task #3 should not be ready (blocked by incomplete #2)")
|
||||
}
|
||||
|
||||
// Now complete #2 and reflect that in #3's relation snapshot.
|
||||
taskThree.RelatedTasks["blocked"][0].Done = true
|
||||
if !isReady(taskThree, todoBucket, viewID) {
|
||||
t.Fatal("task #3 should be ready once #2 is done")
|
||||
}
|
||||
}
|
||||
|
|
@ -17,10 +17,12 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
|
|
@ -69,11 +71,15 @@ silently with status 0 — that makes the hook safe to install globally.`,
|
|||
// Fetch the project title for nicer prompt copy. Best-effort —
|
||||
// if the API call fails (network blip, expired token), we fall
|
||||
// back to "(unknown)" rather than aborting the prompt render.
|
||||
// Cap the lookup at 10s so a wedged server can't hold the
|
||||
// SessionStart hook hostage.
|
||||
projectTitle := "(unknown)"
|
||||
if rt, err := loadRuntime(); err == nil {
|
||||
if p, err := rt.client.GetProject(cmd.Context(), cfg.ProjectID); err == nil {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
if p, err := rt.client.GetProject(ctx, cfg.ProjectID); err == nil {
|
||||
projectTitle = p.Title
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
data := primeContext{
|
||||
|
|
@ -95,5 +101,3 @@ silently with status 0 — that makes the hook safe to install globally.`,
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
// silence linter noise on unused symbols when wiring hooks.
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*cli
|
|||
// Comment first when transitioning to scrapped — the reason is part of
|
||||
// the audit trail and should appear before the bucket move in the log.
|
||||
if newStatus == status.Scrapped {
|
||||
if _, err := rt.client.AddTaskComment(ctx, id, "**Scrapped:** "+strings.TrimSpace(f.reason)); err != nil {
|
||||
if _, err := rt.client.AddTaskComment(ctx, id, "<strong>Scrapped:</strong> "+strings.TrimSpace(f.reason)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
|
@ -218,7 +218,7 @@ func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*cli
|
|||
}
|
||||
}
|
||||
|
||||
if len(f.addLabels) > 0 || len(f.removeLabels) > 0 {
|
||||
if len(f.addLabels) > 0 || len(f.removeLabels) > 0 || bucketTransitionTarget != 0 {
|
||||
fresh, err := rt.client.GetTask(ctx, id)
|
||||
if err == nil {
|
||||
updated = fresh
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import (
|
|||
// FileBackend persists credentials to ~/.config/veans/credentials.yml at
|
||||
// mode 0600. It's the fallback when no keychain is available (CI, Docker,
|
||||
// headless servers) and is the implicit backend e2e tests use.
|
||||
//
|
||||
// Writes are atomic (tmp file + rename) and serialized by an advisory
|
||||
// flock on a sibling .lock file so two concurrent `veans login` runs can
|
||||
// each install their token without losing the other's.
|
||||
type FileBackend struct {
|
||||
path string
|
||||
}
|
||||
|
|
@ -42,9 +46,8 @@ type fileSchema struct {
|
|||
Credentials []fileEntry `yaml:"credentials"`
|
||||
}
|
||||
|
||||
// NewFileBackend builds a FileBackend rooted at `path`, or the platform
|
||||
// default (~/.config/veans/credentials.yml, honoring XDG_CONFIG_HOME) when
|
||||
// path is "".
|
||||
// NewFileBackend builds a FileBackend rooted at `path`, or
|
||||
// ~/.config/veans/credentials.yml when path is "".
|
||||
func NewFileBackend(path string) *FileBackend {
|
||||
if path == "" {
|
||||
path = defaultCredsPath()
|
||||
|
|
@ -55,14 +58,17 @@ func NewFileBackend(path string) *FileBackend {
|
|||
func (b *FileBackend) Name() string { return "file" }
|
||||
func (b *FileBackend) Path() string { return b.path }
|
||||
|
||||
// defaultCredsPath returns ~/.config/veans/credentials.yml, falling back to
|
||||
// "" (which signals an error to NewFileBackend's caller) when there's no
|
||||
// resolvable home directory. We deliberately do not honor XDG_CONFIG_HOME
|
||||
// — it gave us a path-traversal seam for no real benefit, since the
|
||||
// agent-only audience runs in a known environment.
|
||||
func defaultCredsPath() string {
|
||||
if c := os.Getenv("XDG_CONFIG_HOME"); c != "" {
|
||||
return filepath.Join(c, "veans", "credentials.yml")
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil || h == "" {
|
||||
return ""
|
||||
}
|
||||
if h, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(h, ".config", "veans", "credentials.yml")
|
||||
}
|
||||
return filepath.Join(".", "credentials.yml")
|
||||
return filepath.Join(h, ".config", "veans", "credentials.yml")
|
||||
}
|
||||
|
||||
func (b *FileBackend) load() (*fileSchema, error) {
|
||||
|
|
@ -80,7 +86,10 @@ func (b *FileBackend) load() (*fileSchema, error) {
|
|||
return &s, nil
|
||||
}
|
||||
|
||||
func (b *FileBackend) save(s *fileSchema) error {
|
||||
// save writes the schema atomically (tmpfile + rename) at mode 0600 and
|
||||
// re-asserts the mode on the destination inode in case an earlier write
|
||||
// left a wider mode behind.
|
||||
func (b *FileBackend) save(s *fileSchema) (rerr error) {
|
||||
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -88,10 +97,50 @@ func (b *FileBackend) save(s *fileSchema) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(b.path, buf, 0o600)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(b.path), ".credentials-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
// On any error before the rename completes, drop the half-written
|
||||
// temp file. tmpPath is the return of CreateTemp on a directory we
|
||||
// own, so gosec's path-traversal warning doesn't apply.
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath) //nolint:gosec // G703: tmpPath came from os.CreateTemp on a dir we control
|
||||
}
|
||||
}()
|
||||
// CreateTemp opens at 0600 already, but be defensive: an inherited
|
||||
// umask shouldn't matter for CreateTemp on POSIX, but explicit is
|
||||
// cheaper than debugging later.
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// gosec/G703: both paths come from FileBackend state (b.path is set
|
||||
// from defaultCredsPath or an explicit constructor arg, tmpPath from
|
||||
// CreateTemp on the same dir); neither is runtime user-influenceable.
|
||||
if err := os.Rename(tmpPath, b.path); err != nil { //nolint:gosec
|
||||
return err
|
||||
}
|
||||
// Belt-and-braces: a pre-existing destination at 0644 keeps its mode
|
||||
// across Rename on some filesystems. Narrow it.
|
||||
return os.Chmod(b.path, 0o600)
|
||||
}
|
||||
|
||||
func (b *FileBackend) Get(server, account string) (string, error) {
|
||||
if b.path == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
s, err := b.load()
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -104,7 +153,26 @@ func (b *FileBackend) Get(server, account string) (string, error) {
|
|||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// Set serializes load → mutate → atomic save under an advisory flock on
|
||||
// `<path>.lock` so two concurrent `veans login` runs don't clobber each
|
||||
// other's tokens.
|
||||
func (b *FileBackend) Set(server, account, token string) error {
|
||||
if b.path == "" {
|
||||
return errors.New("no credentials path: $HOME is unset and no explicit path was given")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
lockF, err := os.OpenFile(b.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open lock file: %w", err)
|
||||
}
|
||||
defer lockF.Close()
|
||||
if err := flockExclusive(lockF); err != nil {
|
||||
return fmt.Errorf("acquire lock: %w", err)
|
||||
}
|
||||
defer flockUnlock(lockF) //nolint:errcheck // unlock-on-close is sufficient
|
||||
|
||||
s, err := b.load()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//go:build !unix
|
||||
|
||||
package credentials
|
||||
|
||||
import "os"
|
||||
|
||||
// Non-Unix platforms (Windows) don't get advisory file locking. Two
|
||||
// concurrent `veans login` runs on the same Windows machine can race and
|
||||
// lose a token; in practice veans runs from agent-driven shells on Linux
|
||||
// or macOS, so this trade-off is acceptable.
|
||||
func flockExclusive(*os.File) error { return nil }
|
||||
func flockUnlock(*os.File) error { return nil }
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//go:build unix
|
||||
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// flockExclusive takes an exclusive advisory lock on the open file. The lock
|
||||
// is released when the file is closed (or via flockUnlock). Blocks until the
|
||||
// lock is available.
|
||||
func flockExclusive(f *os.File) error {
|
||||
return unix.Flock(int(f.Fd()), unix.LOCK_EX)
|
||||
}
|
||||
|
||||
func flockUnlock(f *os.File) error {
|
||||
return unix.Flock(int(f.Fd()), unix.LOCK_UN)
|
||||
}
|
||||
|
|
@ -81,6 +81,6 @@ func EmitError(err error, w io.Writer) {
|
|||
}
|
||||
e := AsError(err)
|
||||
if encErr := json.NewEncoder(w).Encode(e); encErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "veans: failed to encode error envelope: %v\n", encErr)
|
||||
fmt.Fprintf(os.Stderr, "{\"code\":%q,\"error\":%q}\n", string(CodeUnknown), "failed to encode error envelope: "+encErr.Error())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// Status is the agent-facing state name.
|
||||
|
|
@ -122,7 +123,8 @@ func Parse(raw string) (Status, error) {
|
|||
case "scrapped", "cancelled", "canceled":
|
||||
return Scrapped, nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown status %q (expected one of: %s)",
|
||||
return "", output.New(output.CodeValidation,
|
||||
"unknown status %q (expected one of: %s)",
|
||||
raw, strings.Join(allStrings(), ", "))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,22 +60,27 @@ func Fmt() error {
|
|||
// Test namespace.
|
||||
type Test mg.Namespace
|
||||
|
||||
// All runs unit tests across the module.
|
||||
// All runs unit tests across the module. Passes `-short` so the e2e
|
||||
// package self-skips via its TestMain — the parent monorepo's
|
||||
// pkg/webtests follows the same convention.
|
||||
func (Test) All() error {
|
||||
return sh.RunV("go", "test", "./...")
|
||||
return sh.RunV("go", "test", "-short", "./...")
|
||||
}
|
||||
|
||||
// Filter runs `go test -run <expr> ./...` — pass the expression as an argument.
|
||||
// Filter runs `go test -short -run <expr> ./...` — pass the expression as
|
||||
// an argument. `-short` is included so e2e doesn't run accidentally; use
|
||||
// `mage test:e2e` for those.
|
||||
func (Test) Filter(expr string) error {
|
||||
if expr == "" {
|
||||
return fmt.Errorf("test:filter requires a regexp argument")
|
||||
}
|
||||
return sh.RunV("go", "test", "-run", expr, "./...")
|
||||
return sh.RunV("go", "test", "-short", "-run", expr, "./...")
|
||||
}
|
||||
|
||||
// E2E runs the e2e suite. Requires VEANS_E2E_API_URL to point at a running
|
||||
// Vikunja instance and either VEANS_E2E_ADMIN_TOKEN or
|
||||
// VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS for the admin/seed identity.
|
||||
// E2E runs the e2e suite without `-short` so TestMain lets it through.
|
||||
// Requires VEANS_E2E_API_URL to point at a running Vikunja instance and
|
||||
// either VEANS_E2E_ADMIN_TOKEN or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS
|
||||
// for the admin/seed identity.
|
||||
//
|
||||
// Set VEANS_E2E_SKIP_BUILD=true to reuse a previously-built binary.
|
||||
func (Test) E2E() error {
|
||||
|
|
|
|||
Loading…
Reference in New Issue