628 lines
21 KiB
Go
628 lines
21 KiB
Go
// 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 bootstrap orchestrates `veans init`. It chains together the steps
|
||
// outlined in the plan: probe /info, acquire the human's transient token,
|
||
// pick or create a project, designate a Kanban view, bootstrap canonical
|
||
// buckets, create the bot user, share the project with the bot, mint the
|
||
// bot's API token, and write .veans.yml.
|
||
//
|
||
// The flow is split into small functions so e2e tests can drive it with
|
||
// scripted answers without going through the cobra command surface.
|
||
package bootstrap
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"code.vikunja.io/veans/internal/auth"
|
||
"code.vikunja.io/veans/internal/client"
|
||
"code.vikunja.io/veans/internal/config"
|
||
"code.vikunja.io/veans/internal/credentials"
|
||
"code.vikunja.io/veans/internal/output"
|
||
"code.vikunja.io/veans/internal/status"
|
||
)
|
||
|
||
// Options configures Init. All fields are optional unless noted; missing
|
||
// values are filled in interactively from Prompter.
|
||
type Options struct {
|
||
// ConfigPath is where .veans.yml will be written. Required.
|
||
ConfigPath string
|
||
|
||
// Server is the Vikunja base URL (e.g. https://vikunja.example.com).
|
||
// If empty, the prompter asks.
|
||
Server string
|
||
|
||
// HumanToken short-circuits all auth when set.
|
||
HumanToken string
|
||
// HumanUsePassword forces POST /login instead of the default OAuth flow.
|
||
HumanUsePassword bool
|
||
// HumanUsername / HumanPassword feed POST /login (used when set).
|
||
HumanUsername string
|
||
HumanPassword string
|
||
HumanTOTP string
|
||
|
||
// BotUsername overrides the bot-<reponame> default. The "bot-" prefix is
|
||
// auto-prepended if missing — Vikunja will reject otherwise.
|
||
BotUsername string
|
||
|
||
// ProjectID, when non-zero, skips the interactive project picker.
|
||
ProjectID int64
|
||
|
||
// ViewID, when non-zero, skips the interactive view picker.
|
||
ViewID int64
|
||
|
||
// Bucket bootstrap behavior:
|
||
// AutoApproveBuckets — skip the prompt, create missing canonical buckets.
|
||
// SkipBucketBootstrap — neither prompt nor create.
|
||
AutoApproveBuckets bool
|
||
SkipBucketBootstrap bool
|
||
|
||
// Agent hook installation. If neither flag is set, the user is prompted
|
||
// per-agent at the end of init. NoHooks skips the offering entirely
|
||
// and falls back to printing the snippets.
|
||
InstallClaudeCode bool
|
||
InstallOpenCode bool
|
||
ClaudeCodeFlagSet bool
|
||
OpenCodeFlagSet bool
|
||
NoHooks bool
|
||
|
||
// Out is where progress is written.
|
||
Out io.Writer
|
||
|
||
// 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.
|
||
type Result struct {
|
||
Config *config.Config
|
||
BotUser *client.BotUser
|
||
AgentChoices AgentHookChoice
|
||
}
|
||
|
||
// Init runs the full onboarding flow. Steps are deliberately sequential and
|
||
// each prints a one-line progress note to opts.Out; failures are wrapped
|
||
// with output.Error codes so cobra's error handler renders them cleanly.
|
||
func Init(ctx context.Context, opts *Options) (*Result, error) {
|
||
if opts == nil {
|
||
opts = &Options{}
|
||
}
|
||
if opts.Out == nil {
|
||
opts.Out = io.Discard
|
||
}
|
||
if opts.ConfigPath == "" {
|
||
return nil, output.New(output.CodeValidation, "ConfigPath is required")
|
||
}
|
||
|
||
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 == "" {
|
||
var err error
|
||
repoRoot, err = config.RepoRoot(ctx, "")
|
||
if err != nil {
|
||
return nil, output.Wrap(output.CodeUnknown, err, "detect repo root: %v", err)
|
||
}
|
||
}
|
||
suggested := config.SuggestedBotUsername(repoRoot)
|
||
botUsername := normalizeBotUsername(opts.BotUsername, suggested)
|
||
progress(opts.Out, "Bot username will be %q", botUsername)
|
||
|
||
// 2. Server URL.
|
||
if opts.Server == "" {
|
||
v, err := prompter.ReadLine("Vikunja server URL: ")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
opts.Server = strings.TrimSpace(v)
|
||
}
|
||
|
||
// 3. Discover the actual API URL: the user might have typed bare
|
||
// "vikunja.example.com", or pasted the URL with /api/v1 already in
|
||
// it, or be on a default-port localhost install. DiscoverServer
|
||
// probes the plausible variants and returns the canonical base.
|
||
canonical, info, err := client.DiscoverServer(ctx, opts.Server)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
opts.Server = canonical
|
||
human := client.New(canonical, "")
|
||
progress(opts.Out, "Connected to Vikunja %s at %s", info.Version, canonical)
|
||
|
||
// 4. Acquire human JWT (transient — used until step 11). Default is the
|
||
// OAuth flow; --token / --use-password / --username+--password override.
|
||
tok, err := auth.AcquireHumanToken(ctx, human, auth.LoginOptions{
|
||
Token: opts.HumanToken,
|
||
UsePassword: opts.HumanUsePassword,
|
||
Username: opts.HumanUsername,
|
||
Password: opts.HumanPassword,
|
||
TOTP: opts.HumanTOTP,
|
||
Out: opts.Out,
|
||
}, prompter)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
human.Token = tok
|
||
|
||
// 5. Pick (or accept passed) project.
|
||
project, err := pickProject(ctx, human, opts.ProjectID, prompter, opts.Out)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
progress(opts.Out, "Using project #%d %q (identifier=%q)", project.ID, project.Title, project.Identifier)
|
||
|
||
// 6. Pick (or accept passed) Kanban view.
|
||
view, err := pickKanbanView(ctx, human, project.ID, opts.ViewID, prompter, opts.Out)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
progress(opts.Out, "Using view #%d %q", view.ID, view.Title)
|
||
|
||
// 7. Bucket bootstrap (with strict-with-override prompt).
|
||
buckets, err := bootstrapBuckets(ctx, human, project.ID, view.ID, opts, prompter)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 8. Resolve the bot user: reuse one we already own if the name is
|
||
// taken by us, prompt for a fresh name (with a petname suggestion)
|
||
// if the name is taken by someone else, otherwise create new.
|
||
bot, err := resolveBotUser(ctx, human, botUsername, project.Title, prompter, opts.Out)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 9. Share the project with the bot. 409 ("user already has access")
|
||
// is the expected response when reusing a bot that was set up by a
|
||
// previous init run — treat it as a soft-success.
|
||
_, shareErr := human.ShareProjectWithUser(ctx, project.ID, &client.ProjectUser{
|
||
Username: bot.Username,
|
||
Permission: client.PermissionReadWrite,
|
||
})
|
||
switch {
|
||
case shareErr == nil:
|
||
progress(opts.Out, "Shared project with %q (read+write)", bot.Username)
|
||
case isConflictErr(shareErr):
|
||
progress(opts.Out, "Project already shared with %q", bot.Username)
|
||
default:
|
||
return nil, output.Wrap(output.CodeUnknown, shareErr, "share project with bot: %v", shareErr)
|
||
}
|
||
|
||
// 10. Discover available API permission scopes, mint the bot's token.
|
||
routes, err := human.Routes(ctx)
|
||
if err != nil {
|
||
return nil, output.Wrap(output.CodeUnknown, err, "fetch /routes: %v", err)
|
||
}
|
||
perms := client.PermissionsForBot(routes)
|
||
if len(perms) == 0 {
|
||
return nil, output.New(output.CodeUnknown, "no API token permissions available — Vikunja /routes returned no matching groups")
|
||
}
|
||
mintedToken, err := human.CreateToken(ctx, &client.APIToken{
|
||
Title: "veans for " + project.Title,
|
||
Permissions: perms,
|
||
ExpiresAt: client.FarFuture,
|
||
OwnerID: bot.ID,
|
||
})
|
||
if err != nil {
|
||
return nil, output.Wrap(output.CodeUnknown, err, "mint bot token: %v", err)
|
||
}
|
||
if mintedToken.Token == "" {
|
||
return nil, output.New(output.CodeUnknown, "PUT /tokens did not return a token plaintext — cannot continue")
|
||
}
|
||
|
||
// 11. Persist credentials. Discard human JWT immediately after.
|
||
if err := store.Set(opts.Server, bot.Username, mintedToken.Token); err != nil {
|
||
return nil, output.Wrap(output.CodeUnknown, err, "store bot token: %v", err)
|
||
}
|
||
human.Token = ""
|
||
|
||
// 12. Write .veans.yml.
|
||
cfg := &config.Config{
|
||
Server: opts.Server,
|
||
ProjectID: project.ID,
|
||
ProjectIdentifier: project.Identifier,
|
||
ViewID: view.ID,
|
||
Buckets: buckets,
|
||
Bot: config.Bot{
|
||
Username: bot.Username,
|
||
UserID: bot.ID,
|
||
},
|
||
}
|
||
if err := cfg.SaveAs(opts.ConfigPath); err != nil {
|
||
return nil, output.Wrap(output.CodeUnknown, err, "write %s: %v", opts.ConfigPath, err)
|
||
}
|
||
progress(opts.Out, "Wrote %s", opts.ConfigPath)
|
||
|
||
// 13. Offer to install agent hooks. Pre-seeded from flags; the rest
|
||
// is prompted unless --no-hooks. Failures here are non-fatal — the
|
||
// repo is already configured; the user can install hooks by hand.
|
||
choices := AgentHookChoice{
|
||
ClaudeCode: opts.InstallClaudeCode,
|
||
OpenCode: opts.InstallOpenCode,
|
||
}
|
||
choices, err = offerAgentHooks(prompter, opts.Out, choices,
|
||
opts.ClaudeCodeFlagSet, opts.OpenCodeFlagSet, opts.NoHooks)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := installAgentHooks(repoRoot, choices, opts.Out); err != nil {
|
||
// Log but don't abort — the repo is configured.
|
||
fmt.Fprintf(opts.Out, " ! hook install failed: %v (you can paste the snippets manually)\n", err)
|
||
}
|
||
|
||
return &Result{
|
||
Config: cfg,
|
||
BotUser: bot,
|
||
AgentChoices: choices,
|
||
}, 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
|
||
}
|
||
if !strings.HasPrefix(override, "bot-") {
|
||
return "bot-" + override
|
||
}
|
||
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)
|
||
}
|
||
projects, err := c.ListProjects(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Filter out archived projects to keep the list short.
|
||
var active []*client.Project
|
||
for _, pr := range projects {
|
||
if pr.IsArchived {
|
||
continue
|
||
}
|
||
active = append(active, pr)
|
||
}
|
||
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
|
||
if ident == "" {
|
||
ident = "(no identifier)"
|
||
}
|
||
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
|
||
}
|
||
choice = strings.TrimSpace(choice)
|
||
idx := 1
|
||
if choice != "" {
|
||
v, err := strconv.Atoi(choice)
|
||
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 {
|
||
return nil, err
|
||
}
|
||
var kanban []*client.ProjectView
|
||
for _, v := range views {
|
||
if v.ViewKind == client.ViewKindKanban {
|
||
kanban = append(kanban, v)
|
||
}
|
||
}
|
||
if len(kanban) == 0 {
|
||
return nil, output.New(output.CodeNotFound, "no Kanban views on this project — create one in the Vikunja UI first")
|
||
}
|
||
if viewID != 0 {
|
||
for _, v := range kanban {
|
||
if v.ID == viewID {
|
||
return v, nil
|
||
}
|
||
}
|
||
return nil, output.New(output.CodeNotFound, "view %d is not a Kanban view on this project", viewID)
|
||
}
|
||
if len(kanban) == 1 {
|
||
return kanban[0], nil
|
||
}
|
||
fmt.Fprintln(out, "Available Kanban views:")
|
||
for i, v := range kanban {
|
||
fmt.Fprintf(out, " [%d] #%d %s\n", i+1, v.ID, v.Title)
|
||
}
|
||
choice, err := p.ReadLine("Pick a view [1]: ")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
choice = strings.TrimSpace(choice)
|
||
idx := 1
|
||
if choice != "" {
|
||
v, err := strconv.Atoi(choice)
|
||
if err != nil || v < 1 || v > len(kanban) {
|
||
return nil, output.New(output.CodeValidation, "invalid view choice %q", choice)
|
||
}
|
||
idx = v
|
||
}
|
||
return kanban[idx-1], nil
|
||
}
|
||
|
||
func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID int64, opts *Options, p auth.Prompter) (config.Buckets, error) {
|
||
existing, err := c.ListBuckets(ctx, projectID, viewID)
|
||
if err != nil {
|
||
return config.Buckets{}, err
|
||
}
|
||
|
||
// Resolve canonical statuses to existing buckets via the alias table.
|
||
// Vikunja's default Kanban view ships with "To-Do" / "Doing" / "Done";
|
||
// matching them as Todo / InProgress / Done avoids creating a parallel
|
||
// set of buckets every time veans runs against a vanilla project.
|
||
matched := map[status.Status]*client.Bucket{}
|
||
for _, s := range status.All() {
|
||
for _, b := range existing {
|
||
if b == nil {
|
||
continue
|
||
}
|
||
if status.MatchBucketTitle(s, b.Title) {
|
||
matched[s] = b
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
var missing []string
|
||
for _, s := range status.All() {
|
||
if _, ok := matched[s]; !ok {
|
||
missing = append(missing, s.BucketTitle())
|
||
}
|
||
}
|
||
|
||
if len(missing) > 0 && !opts.SkipBucketBootstrap {
|
||
approve := opts.AutoApproveBuckets
|
||
if !approve {
|
||
fmt.Fprintf(opts.Out, "Missing canonical buckets: %s\n", strings.Join(missing, ", "))
|
||
const maxUnknownAnswers = 5
|
||
prompt := "Bootstrap missing buckets? [Y/n/abort]: "
|
||
unknown := 0
|
||
promptLoop:
|
||
for {
|
||
ans, err := p.ReadLine(prompt)
|
||
if err != nil {
|
||
return config.Buckets{}, err
|
||
}
|
||
normalized := strings.ToLower(strings.TrimSpace(ans))
|
||
switch normalized {
|
||
case "", "y", "yes":
|
||
approve = true
|
||
break promptLoop
|
||
case "n", "no":
|
||
return config.Buckets{}, output.New(output.CodeValidation,
|
||
"canonical buckets missing — either re-run `veans init` and answer Y to let veans bootstrap them, "+
|
||
"or create the missing buckets (%s) manually in Vikunja's UI and re-run `veans init`",
|
||
strings.Join(missing, ", "))
|
||
case "a", "abort":
|
||
return config.Buckets{}, output.New(output.CodeValidation, "user aborted bucket bootstrap")
|
||
default:
|
||
unknown++
|
||
if unknown > maxUnknownAnswers {
|
||
return config.Buckets{}, output.New(output.CodeValidation,
|
||
"could not understand bucket bootstrap answer after %d attempts — aborting; "+
|
||
"re-run `veans init` and answer y, n, or abort",
|
||
maxUnknownAnswers)
|
||
}
|
||
fmt.Fprintf(opts.Out, "didn't understand %q, please answer y or n (or abort)\n", ans)
|
||
}
|
||
}
|
||
}
|
||
if approve {
|
||
for _, s := range status.All() {
|
||
if _, ok := matched[s]; ok {
|
||
continue
|
||
}
|
||
title := s.BucketTitle()
|
||
b, err := c.CreateBucket(ctx, projectID, viewID, &client.Bucket{Title: title})
|
||
if err != nil {
|
||
return config.Buckets{}, output.Wrap(output.CodeUnknown, err, "create bucket %q: %v", title, err)
|
||
}
|
||
matched[s] = b
|
||
progress(opts.Out, "Created bucket %q (id=%d)", title, b.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, s := range status.All() {
|
||
if b, ok := matched[s]; ok && b != nil && b.Title != s.BucketTitle() {
|
||
progress(opts.Out, "Reusing existing bucket %q as %s (id=%d)", b.Title, s.BucketTitle(), b.ID)
|
||
}
|
||
}
|
||
|
||
out := config.Buckets{
|
||
Todo: bucketID(matched, status.Todo),
|
||
InProgress: bucketID(matched, status.InProgress),
|
||
InReview: bucketID(matched, status.InReview),
|
||
Done: bucketID(matched, status.Completed),
|
||
Scrapped: bucketID(matched, status.Scrapped),
|
||
}
|
||
if out.Todo == 0 || out.InProgress == 0 || out.InReview == 0 || out.Done == 0 || out.Scrapped == 0 {
|
||
return config.Buckets{}, output.New(output.CodeValidation,
|
||
"canonical buckets missing — either re-run `veans init` and let veans bootstrap them, "+
|
||
"or create the missing canonical buckets (Todo / In Progress / In Review / Done / Scrapped) manually in Vikunja's UI and re-run `veans init`")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func bucketID(m map[status.Status]*client.Bucket, s status.Status) int64 {
|
||
if b, ok := m[s]; ok && b != nil {
|
||
return b.ID
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func progress(w io.Writer, format string, args ...any) {
|
||
if w == nil {
|
||
return
|
||
}
|
||
fmt.Fprintf(w, " → "+format+"\n", args...)
|
||
}
|
||
|
||
// isConflictErr reports whether the wrapped HTTP error is a 409 — used by
|
||
// init's "share project with bot" step, which legitimately gets one when
|
||
// the bot is being reused from an earlier run.
|
||
func isConflictErr(err error) bool {
|
||
var oe *output.Error
|
||
return errors.As(err, &oe) && oe.Code == output.CodeConflict
|
||
}
|