Merge branch 'main' into feat-copy-task-as-markdown
This commit is contained in:
commit
6701f841e4
|
|
@ -0,0 +1,6 @@
|
|||
self-hosted-runner:
|
||||
# Custom labels from third-party runner providers used in our workflows.
|
||||
# Listed here so actionlint doesn't flag them as unknown.
|
||||
labels:
|
||||
- namespace-profile-default
|
||||
- blacksmith-8vcpu-ubuntu-2204
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
name: Release binaries
|
||||
description: |
|
||||
Build, sign, and publish release binaries for a Vikunja sub-project.
|
||||
|
||||
Derives every per-project path, cache key, artifact name, and S3 target
|
||||
from the `project` input. Callers only need to provide the project name,
|
||||
the raw `git describe` value, and pass through the GPG/S3 secrets as
|
||||
inputs (composite actions can't read the `secrets` context directly).
|
||||
|
||||
inputs:
|
||||
project:
|
||||
description: 'Which project to build: "vikunja" or "veans".'
|
||||
required: true
|
||||
release-version:
|
||||
description: |
|
||||
Raw git describe value (e.g. v1.2.3 or v2.3.0-408-ge053d317). Always
|
||||
passed through to the build so the binary embeds the precise commit.
|
||||
Filenames and the S3 directory use "unstable" instead whenever
|
||||
github.ref_type isn't "tag".
|
||||
required: true
|
||||
# Secrets — composite actions can't read the `secrets` context directly, so
|
||||
# the caller threads them through as inputs.
|
||||
gpg-passphrase:
|
||||
required: true
|
||||
gpg-sign-key:
|
||||
required: true
|
||||
s3-access-key-id:
|
||||
required: true
|
||||
s3-secret-access-key:
|
||||
required: true
|
||||
s3-endpoint:
|
||||
required: true
|
||||
s3-bucket:
|
||||
required: true
|
||||
s3-region:
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set project paths
|
||||
shell: bash
|
||||
env:
|
||||
PROJECT: ${{ inputs.project }}
|
||||
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
|
||||
VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$PROJECT" in
|
||||
vikunja|veans) ;;
|
||||
*)
|
||||
echo "::error::Unknown project '$PROJECT'. Expected 'vikunja' or 'veans'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$PROJECT" in
|
||||
vikunja)
|
||||
output_dir="."
|
||||
dist_prefix="dist"
|
||||
;;
|
||||
veans)
|
||||
output_dir="veans"
|
||||
dist_prefix="veans/dist"
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo "PROJECT=$PROJECT"
|
||||
echo "RELEASE_VERSION=$RELEASE_VERSION_INPUT"
|
||||
echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE"
|
||||
echo "XGO_OUT_NAME=${PROJECT}-${VERSION_OR_UNSTABLE}"
|
||||
echo "OUTPUT_DIR=$output_dir"
|
||||
echo "DIST_PREFIX=$dist_prefix"
|
||||
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}"
|
||||
echo "ARTIFACT_BINARIES_NAME=${PROJECT}_bins"
|
||||
echo "ARTIFACT_ZIPS_NAME=${PROJECT}_bin_packages"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download Mage binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
|
||||
- name: Make mage-static executable
|
||||
shell: bash
|
||||
run: chmod +x ./mage-static
|
||||
|
||||
- name: Download frontend dist (vikunja only)
|
||||
if: inputs.project == 'vikunja'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
|
||||
- name: Generate config.yml.sample (vikunja only)
|
||||
if: inputs.project == 'vikunja'
|
||||
shell: bash
|
||||
run: ./mage-static generate:config-yaml 1
|
||||
|
||||
- name: Install upx
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
wget -q https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz
|
||||
echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||
sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||
tar xf upx-5.0.0-amd64_linux.tar.xz
|
||||
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
|
||||
|
||||
- name: Setup xgo cache
|
||||
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
|
||||
with:
|
||||
path: /home/runner/.xgo-cache
|
||||
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
xgo-${{ inputs.project }}-
|
||||
|
||||
- name: Install mage for the build module
|
||||
shell: bash
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
|
||||
- name: Build release artifacts
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
XGO_OUT_NAME: ${{ env.XGO_OUT_NAME }}
|
||||
PROJECT: ${{ env.PROJECT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||
cd build && mage release:build "$PROJECT"
|
||||
|
||||
- name: GPG setup
|
||||
uses: kolaente/action-gpg@main
|
||||
with:
|
||||
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||
|
||||
- name: Sign zips
|
||||
shell: bash
|
||||
env:
|
||||
DIST_PREFIX: ${{ env.DIST_PREFIX }}
|
||||
RELEASE_GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zip_dir="${DIST_PREFIX}/zip"
|
||||
echo "=== GPG agent status ==="
|
||||
gpg-connect-agent 'keyinfo --list' /bye || true
|
||||
echo "=== GPG secret keys ==="
|
||||
gpg -K --with-keygrip
|
||||
echo "=== GPG public keys ==="
|
||||
gpg --list-keys
|
||||
echo "=== Signing files in $zip_dir ==="
|
||||
ls -hal "$zip_dir"/*
|
||||
for file in "$zip_dir"/*; do
|
||||
gpg -v \
|
||||
--default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
|
||||
-b --batch --yes \
|
||||
--passphrase "$RELEASE_GPG_PASSPHRASE" \
|
||||
--pinentry-mode loopback \
|
||||
--sign "$file"
|
||||
done
|
||||
|
||||
- name: Upload zips to S3
|
||||
uses: kolaente/s3-action@main
|
||||
with:
|
||||
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||
s3-endpoint: ${{ inputs.s3-endpoint }}
|
||||
s3-bucket: ${{ inputs.s3-bucket }}
|
||||
s3-region: ${{ inputs.s3-region }}
|
||||
target-path: ${{ env.S3_TARGET_PATH }}
|
||||
files: ${{ env.DIST_PREFIX }}/zip/*
|
||||
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
|
||||
|
||||
- name: Store binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_BINARIES_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/binaries/*
|
||||
|
||||
- name: Store binary packages
|
||||
if: github.ref_type == 'tag'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_ZIPS_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/zip/*
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
name: Release OS package
|
||||
description: >
|
||||
Build a single deb/rpm/apk/archlinux package for the given project + arch
|
||||
via nfpm, optionally GPG-sign it (archlinux is signed inline; rpm is signed
|
||||
by nfpm itself), upload it to S3, and store it as a workflow artifact.
|
||||
|
||||
Most paths and names are derived from `project`; the matrix only needs to
|
||||
supply the per-arch and per-format inputs.
|
||||
|
||||
inputs:
|
||||
project:
|
||||
description: 'Project name (vikunja | veans). Drives all derived paths.'
|
||||
required: true
|
||||
release-version:
|
||||
description: |
|
||||
RELEASE_VERSION env value — the same version that ended up in the
|
||||
binaries artifact. Always embedded in the package metadata via
|
||||
nfpm; filenames and the S3 directory use "unstable" instead
|
||||
whenever github.ref_type isn't "tag".
|
||||
required: true
|
||||
packager:
|
||||
description: 'nfpm packager: rpm | deb | apk | archlinux.'
|
||||
required: true
|
||||
nfpm-arch:
|
||||
description: 'nfpm arch field (amd64 | arm64 | arm7).'
|
||||
required: true
|
||||
pkg-arch:
|
||||
description: 'Package-format arch used in the output filename (x86_64 | aarch64 | armv7).'
|
||||
required: true
|
||||
go-name:
|
||||
description: 'Go-style arch token used in the binary filename (linux-amd64 | linux-arm64 | linux-arm-7).'
|
||||
required: true
|
||||
# Secrets — composite actions can't read `${{ secrets.* }}` directly, so the
|
||||
# caller threads them through as inputs.
|
||||
gpg-passphrase:
|
||||
required: true
|
||||
gpg-sign-key:
|
||||
required: true
|
||||
s3-access-key-id:
|
||||
required: true
|
||||
s3-secret-access-key:
|
||||
required: true
|
||||
s3-endpoint:
|
||||
required: true
|
||||
s3-bucket:
|
||||
required: true
|
||||
s3-region:
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set project paths
|
||||
shell: bash
|
||||
env:
|
||||
PROJECT: ${{ inputs.project }}
|
||||
RELEASE_VERSION: ${{ inputs.release-version }}
|
||||
VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }}
|
||||
PACKAGER: ${{ inputs.packager }}
|
||||
PKG_ARCH: ${{ inputs.pkg-arch }}
|
||||
GO_NAME: ${{ inputs.go-name }}
|
||||
run: |
|
||||
case "$PROJECT" in
|
||||
vikunja)
|
||||
echo "BINARIES_DOWNLOAD_PATH=." >> "$GITHUB_ENV"
|
||||
echo "STAGED_BINARY_PATH=./vikunja" >> "$GITHUB_ENV"
|
||||
echo "NFPM_BIN_PATH=" >> "$GITHUB_ENV"
|
||||
echo "NFPM_CONFIG_PATH=./nfpm.yaml" >> "$GITHUB_ENV"
|
||||
# No leading "./" — the s3-action's strip-path-prefix must
|
||||
# match the glob output exactly, and the glob doesn't emit it.
|
||||
echo "PACKAGE_OUTPUT_DIR=dist/os-packages" >> "$GITHUB_ENV"
|
||||
;;
|
||||
veans)
|
||||
echo "BINARIES_DOWNLOAD_PATH=./veans-binaries" >> "$GITHUB_ENV"
|
||||
echo "STAGED_BINARY_PATH=./veans/veans-bin" >> "$GITHUB_ENV"
|
||||
echo "NFPM_BIN_PATH=./veans/veans-bin" >> "$GITHUB_ENV"
|
||||
echo "NFPM_CONFIG_PATH=./veans/nfpm.yaml" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_OUTPUT_DIR=veans/dist/os-packages" >> "$GITHUB_ENV"
|
||||
;;
|
||||
*)
|
||||
echo "::error::unknown project '$PROJECT' (expected vikunja|veans)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE" >> "$GITHUB_ENV"
|
||||
echo "BINARIES_ARTIFACT_NAME=${PROJECT}_bins" >> "$GITHUB_ENV"
|
||||
echo "BINARY_GLOB=${PROJECT}-*-${GO_NAME}" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_FILENAME=${PROJECT}-${VERSION_OR_UNSTABLE}-${PKG_ARCH}.${PACKAGER}" >> "$GITHUB_ENV"
|
||||
echo "ARTIFACT_NAME=${PROJECT}_os_package_${PACKAGER}_${PKG_ARCH}" >> "$GITHUB_ENV"
|
||||
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download project binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: ${{ env.BINARIES_ARTIFACT_NAME }}
|
||||
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
|
||||
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Install mage
|
||||
shell: bash
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
|
||||
- name: Generate config.yml.sample (vikunja only)
|
||||
# vikunja's nfpm.yaml ships ./config.yml.sample as /etc/vikunja/config.yml.
|
||||
# release-binaries generates it for the zip bundles, but this job runs on a
|
||||
# fresh runner, so we regenerate it here before nfpm packs it.
|
||||
if: inputs.project == 'vikunja'
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH=$PATH:$GOPATH/bin
|
||||
mage generate:config-yaml 1
|
||||
|
||||
- name: Write GPG key for nfpm
|
||||
if: inputs.packager == 'rpm'
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_GPG_SIGN_KEY: ${{ inputs.gpg-sign-key }}
|
||||
run: printf '%s' "$RELEASE_GPG_SIGN_KEY" > /tmp/nfpm-signing-key.gpg
|
||||
|
||||
- name: GPG setup for archlinux signing
|
||||
if: inputs.packager == 'archlinux'
|
||||
uses: kolaente/action-gpg@main
|
||||
with:
|
||||
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||
|
||||
- name: Prepare nfpm config
|
||||
shell: bash
|
||||
working-directory: build
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.release-version }}
|
||||
NFPM_ARCH: ${{ inputs.nfpm-arch }}
|
||||
NFPM_BIN_PATH: ${{ env.NFPM_BIN_PATH }}
|
||||
PROJECT: ${{ inputs.project }}
|
||||
run: |
|
||||
export PATH=$PATH:$GOPATH/bin
|
||||
mage release:prepare-nfpm-config "$PROJECT" "$NFPM_ARCH"
|
||||
|
||||
- name: Stage binary
|
||||
shell: bash
|
||||
run: |
|
||||
# Resolve the single matching binary and mv it into place.
|
||||
matched=()
|
||||
for f in $BINARIES_DOWNLOAD_PATH/$BINARY_GLOB; do
|
||||
[ -e "$f" ] || continue
|
||||
matched+=("$f")
|
||||
done
|
||||
if [ ${#matched[@]} -ne 1 ]; then
|
||||
echo "::error::expected exactly 1 binary matching '$BINARIES_DOWNLOAD_PATH/$BINARY_GLOB', found ${#matched[@]}"
|
||||
ls -la "$BINARIES_DOWNLOAD_PATH" || true
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$STAGED_BINARY_PATH")"
|
||||
mv "${matched[0]}" "$STAGED_BINARY_PATH"
|
||||
chmod +x "$STAGED_BINARY_PATH"
|
||||
|
||||
- name: Ensure package output dir exists
|
||||
shell: bash
|
||||
run: mkdir -p "$PACKAGE_OUTPUT_DIR"
|
||||
|
||||
- name: Create package
|
||||
uses: kolaente/action-gh-nfpm@master
|
||||
with:
|
||||
packager: ${{ inputs.packager }}
|
||||
target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }}
|
||||
config: ${{ env.NFPM_CONFIG_PATH }}
|
||||
env:
|
||||
NFPM_GPG_KEY_FILE: ${{ inputs.packager == 'rpm' && '/tmp/nfpm-signing-key.gpg' || '' }}
|
||||
NFPM_PASSPHRASE: ${{ inputs.packager == 'rpm' && inputs.gpg-passphrase || '' }}
|
||||
|
||||
- name: Sign archlinux package
|
||||
if: inputs.packager == 'archlinux'
|
||||
shell: bash
|
||||
env:
|
||||
GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
|
||||
run: |
|
||||
gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
|
||||
--batch --yes \
|
||||
--passphrase "$GPG_PASSPHRASE" \
|
||||
--pinentry-mode loopback \
|
||||
--detach-sign \
|
||||
"$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME"
|
||||
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@main
|
||||
with:
|
||||
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||
s3-endpoint: ${{ inputs.s3-endpoint }}
|
||||
s3-bucket: ${{ inputs.s3-bucket }}
|
||||
s3-region: ${{ inputs.s3-region }}
|
||||
target-path: ${{ env.S3_TARGET_PATH }}
|
||||
files: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||
strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/
|
||||
|
||||
- name: Store OS package
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||
|
|
@ -4,6 +4,40 @@ on:
|
|||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-mage:
|
||||
runs-on: ubuntu-latest
|
||||
name: prepare-build-mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache build mage
|
||||
id: cache-build-mage
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
with:
|
||||
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
|
||||
path: |
|
||||
./build/build-mage-static
|
||||
# Statically compile build/magefile.go so publish-repos can run repo
|
||||
# metadata targets inside ubuntu/fedora/archlinux containers without
|
||||
# needing a Go toolchain available there.
|
||||
- name: Install mage
|
||||
if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }}
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
- name: Compile build mage
|
||||
if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }}
|
||||
working-directory: build
|
||||
run: |
|
||||
export PATH=$PATH:$GOPATH/bin
|
||||
mage -compile ./build-mage-static
|
||||
- name: Store build mage binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: ./build/build-mage-static
|
||||
|
||||
docker:
|
||||
runs-on: namespace-profile-default
|
||||
steps:
|
||||
|
|
@ -63,83 +97,36 @@ jobs:
|
|||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
- uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: get frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
- run: chmod +x ./mage-static
|
||||
- name: install upx
|
||||
run: |
|
||||
wget https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz
|
||||
echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||
sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||
tar xf upx-5.0.0-amd64_linux.tar.xz
|
||||
mv upx-5.0.0-amd64_linux/upx /usr/local/bin
|
||||
- name: setup xgo cache
|
||||
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
|
||||
with:
|
||||
path: /home/runner/.xgo-cache
|
||||
key: ${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: build and release
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
|
||||
XGO_OUT_NAME: vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
||||
run: |
|
||||
export PATH=$PATH:$GOPATH/bin
|
||||
./mage-static release
|
||||
- name: GPG setup
|
||||
uses: kolaente/action-gpg@main
|
||||
with:
|
||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
||||
- name: sign
|
||||
run: |
|
||||
echo "=== GPG agent status ==="
|
||||
gpg-connect-agent 'keyinfo --list' /bye || true
|
||||
echo "=== GPG secret keys ==="
|
||||
gpg -K --with-keygrip
|
||||
echo "=== GPG public keys ==="
|
||||
gpg --list-keys
|
||||
echo "=== GNUPG directory contents ==="
|
||||
ls -la ~/.gnupg/
|
||||
ls -la ~/.gnupg/private-keys-v1.d/ || true
|
||||
echo "=== Signing files ==="
|
||||
ls -hal dist/zip/*
|
||||
for file in dist/zip/*; do
|
||||
gpg -v --default-key 7D061A4AA61436B40713D42EFF054DACD908493A -b --batch --yes --passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" --pinentry-mode loopback --sign "$file"
|
||||
done
|
||||
- name: Upload
|
||||
uses: kolaente/s3-action@main
|
||||
- uses: ./.github/actions/release-binaries
|
||||
with:
|
||||
project: vikunja
|
||||
release-version: ${{ steps.ghd.outputs.describe }}
|
||||
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
||||
files: "dist/zip/*"
|
||||
strip-path-prefix: dist/zip/
|
||||
- name: Store Binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
|
||||
veans-binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
- uses: ./.github/actions/release-binaries
|
||||
with:
|
||||
name: vikunja_bins
|
||||
path: ./dist/binaries/*
|
||||
- name: Store Binary Packages
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
with:
|
||||
name: vikunja_bin_packages
|
||||
path: ./dist/zip/*
|
||||
project: veans
|
||||
release-version: ${{ steps.ghd.outputs.describe }}
|
||||
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
|
||||
os-package:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -147,11 +134,7 @@ jobs:
|
|||
- binaries
|
||||
strategy:
|
||||
matrix:
|
||||
package:
|
||||
- rpm
|
||||
- deb
|
||||
- apk
|
||||
- archlinux
|
||||
package: [rpm, deb, apk, archlinux]
|
||||
arch:
|
||||
- go_name: linux-amd64
|
||||
nfpm: amd64
|
||||
|
|
@ -165,76 +148,70 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bins
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Write GPG key for nfpm
|
||||
if: matrix.package == 'rpm'
|
||||
run: echo -n "${{ secrets.RELEASE_GPG_SIGN_KEY }}" > /tmp/nfpm-signing-key.gpg
|
||||
- name: GPG setup for package signing
|
||||
if: matrix.package == 'archlinux'
|
||||
uses: kolaente/action-gpg@main
|
||||
with:
|
||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
||||
- name: Prepare
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
|
||||
NFPM_ARCH: ${{ matrix.arch.nfpm }}
|
||||
run: |
|
||||
chmod +x ./mage-static
|
||||
./mage-static release:prepare-nfpm-config
|
||||
mkdir -p ./dist/os-packages
|
||||
mv ./vikunja-*-${{ matrix.arch.go_name }} ./vikunja
|
||||
chmod +x ./vikunja
|
||||
- name: Create package
|
||||
id: nfpm
|
||||
uses: kolaente/action-gh-nfpm@master
|
||||
- uses: ./.github/actions/release-os-package
|
||||
with:
|
||||
project: vikunja
|
||||
release-version: ${{ steps.ghd.outputs.describe }}
|
||||
packager: ${{ matrix.package }}
|
||||
target: ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
|
||||
config: ./nfpm.yaml
|
||||
env:
|
||||
NFPM_GPG_KEY_FILE: ${{ (matrix.package == 'rpm') && '/tmp/nfpm-signing-key.gpg' || '' }}
|
||||
NFPM_PASSPHRASE: ${{ (matrix.package == 'rpm') && secrets.RELEASE_GPG_PASSPHRASE || '' }}
|
||||
- name: Sign package
|
||||
if: matrix.package == 'archlinux'
|
||||
run: |
|
||||
gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
|
||||
--batch --yes \
|
||||
--passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" \
|
||||
--pinentry-mode loopback \
|
||||
--detach-sign \
|
||||
./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
|
||||
- name: Upload
|
||||
uses: kolaente/s3-action@main
|
||||
with:
|
||||
nfpm-arch: ${{ matrix.arch.nfpm }}
|
||||
pkg-arch: ${{ matrix.arch.pkg }}
|
||||
go-name: ${{ matrix.arch.go_name }}
|
||||
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
||||
files: "dist/os-packages/*"
|
||||
strip-path-prefix: dist/os-packages/
|
||||
- name: Store OS Packages
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
|
||||
veans-os-package:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- veans-binaries
|
||||
strategy:
|
||||
matrix:
|
||||
package: [rpm, deb, apk, archlinux]
|
||||
arch:
|
||||
- go_name: linux-amd64
|
||||
nfpm: amd64
|
||||
pkg: x86_64
|
||||
- go_name: linux-arm64
|
||||
nfpm: arm64
|
||||
pkg: aarch64
|
||||
- go_name: linux-arm-7
|
||||
nfpm: arm7
|
||||
pkg: armv7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
- uses: ./.github/actions/release-os-package
|
||||
with:
|
||||
name: vikunja_os_package_${{ matrix.package }}_${{ matrix.arch.pkg }}
|
||||
path: ./dist/os-packages/*
|
||||
project: veans
|
||||
release-version: ${{ steps.ghd.outputs.describe }}
|
||||
packager: ${{ matrix.package }}
|
||||
nfpm-arch: ${{ matrix.arch.nfpm }}
|
||||
pkg-arch: ${{ matrix.arch.pkg }}
|
||||
go-name: ${{ matrix.arch.go_name }}
|
||||
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||
s3-region: ${{ secrets.S3_REGION }}
|
||||
|
||||
publish-repos:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-mage
|
||||
- os-package
|
||||
- veans-os-package
|
||||
- desktop
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -260,10 +237,14 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Download Mage Binary
|
||||
- name: Download build mage binary
|
||||
# Statically compiled in test.yml's build-mage job so it runs inside
|
||||
# ubuntu/fedora/archlinux containers without a Go toolchain.
|
||||
if: matrix.format != 'apk'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: mage_bin
|
||||
name: build_mage_bin
|
||||
path: build
|
||||
|
||||
- name: Download all server OS packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
|
|
@ -272,6 +253,16 @@ jobs:
|
|||
merge-multiple: true
|
||||
path: dist/repo-work/incoming
|
||||
|
||||
- name: Download all veans OS packages
|
||||
# Merged into the same incoming dir so reprepro / createrepo_c /
|
||||
# repo-add / the apk loop pick them up alongside vikunja's packages
|
||||
# — same suite, same arch fan-out, no extra source entry for users.
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
path: dist/repo-work/incoming
|
||||
|
||||
- name: Download desktop packages (Linux)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
|
|
@ -338,12 +329,13 @@ jobs:
|
|||
|
||||
- name: Generate repo metadata
|
||||
if: matrix.format != 'apk'
|
||||
working-directory: build
|
||||
env:
|
||||
RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A
|
||||
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||
run: |
|
||||
chmod +x ./mage-static
|
||||
./mage-static ${{ matrix.mage_target }}
|
||||
chmod +x ./build-mage-static
|
||||
./build-mage-static ${{ matrix.mage_target }}
|
||||
|
||||
- name: Generate APK repo metadata
|
||||
if: matrix.format == 'apk'
|
||||
|
|
@ -538,6 +530,8 @@ jobs:
|
|||
needs:
|
||||
- binaries
|
||||
- os-package
|
||||
- veans-binaries
|
||||
- veans-os-package
|
||||
- desktop
|
||||
- publish-repos
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
|
|
@ -555,6 +549,17 @@ jobs:
|
|||
pattern: vikunja_os_package_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Veans Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: veans_bin_packages
|
||||
|
||||
- name: Download Veans OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Desktop Package Linux
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
|
|
@ -581,4 +586,9 @@ jobs:
|
|||
vikunja*.deb
|
||||
vikunja*.apk
|
||||
vikunja*.archlinux
|
||||
veans*.zip
|
||||
veans*.rpm
|
||||
veans*.deb
|
||||
veans*.apk
|
||||
veans*.archlinux
|
||||
Vikunja Desktop*
|
||||
|
|
|
|||
|
|
@ -78,6 +78,39 @@ jobs:
|
|||
with:
|
||||
version: v2.10.1
|
||||
|
||||
veans-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
working-directory: veans
|
||||
|
||||
veans-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install mage
|
||||
# The cached mage-static artifact has the parent magefile compiled
|
||||
# in — we need a generic mage binary to pick up veans/magefile.go.
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
- name: Run unit tests
|
||||
# `mage test` is the Aliases entry for Test.All which passes
|
||||
# `-short` — the e2e package's TestMain skips under -short,
|
||||
# mirroring the parent monorepo's pkg/webtests convention. The
|
||||
# heavier test-veans-e2e job runs the full suite against the
|
||||
# api-build artifact.
|
||||
working-directory: veans
|
||||
run: mage test
|
||||
|
||||
check-translations:
|
||||
runs-on: ubuntu-latest
|
||||
needs: mage
|
||||
|
|
@ -404,6 +437,76 @@ jobs:
|
|||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
||||
test-veans-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- api-build
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install mage
|
||||
# The cached mage-static artifact has the parent magefile compiled
|
||||
# in — we need a generic mage binary to pick up veans/magefile.go.
|
||||
run: go install github.com/magefile/mage@v1.17.2
|
||||
- run: chmod +x ./vikunja
|
||||
- name: Run veans e2e against ephemeral Vikunja
|
||||
env:
|
||||
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
|
||||
VIKUNJA_MAILER_ENABLED: "false"
|
||||
VIKUNJA_REDIS_ENABLED: "false"
|
||||
VIKUNJA_RATELIMIT_NOAUTHLIMIT: "1000"
|
||||
VEANS_E2E_API_URL: http://127.0.0.1:3456
|
||||
# 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
|
||||
# processes don't survive step boundaries on GH runners.
|
||||
nohup ./vikunja web > /tmp/vikunja.log 2>&1 &
|
||||
API_PID=$!
|
||||
trap "kill $API_PID 2>/dev/null || true" EXIT
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null 2>&1; then
|
||||
echo "API ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null; then
|
||||
echo "::error::API failed to start; log:"
|
||||
cat /tmp/vikunja.log
|
||||
exit 1
|
||||
fi
|
||||
# `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
|
||||
with:
|
||||
name: veans-e2e-vikunja-log
|
||||
path: /tmp/vikunja.log
|
||||
retention-days: 7
|
||||
|
||||
test-frontend-e2e-playwright:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION
|
|||
|
||||
RUN export PATH=$PATH:$GOPATH/bin && \
|
||||
mage build:clean && \
|
||||
mage release:xgo "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}"
|
||||
(cd build && mage release:xgo vikunja "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}")
|
||||
|
||||
RUN mkdir -p /tmp && chmod 1777 /tmp
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
module code.vikunja.io/build
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/magefile/mage v1.17.2
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
|
||||
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
|
|
@ -0,0 +1,757 @@
|
|||
// 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 mage
|
||||
|
||||
// Centralized release pipeline for every Go binary in this monorepo.
|
||||
//
|
||||
// Both vikunja and veans cross-compile through the same code: xgo for the full
|
||||
// OS/arch matrix, upx where the binary supports it, sha256 alongside each
|
||||
// artifact, per-target zip bundle, and nfpm.yaml templating for deb/rpm/apk/
|
||||
// archlinux packaging. Repository-metadata targets (apt/rpm/pacman) consume
|
||||
// the merged ../dist/repo-work/incoming/ tree the CI populates from both
|
||||
// projects' packages.
|
||||
//
|
||||
// The module is intentionally separate from the project magefiles so the
|
||||
// release tooling can evolve without touching them. The small filesystem
|
||||
// helpers (copyFile, moveFile, sha256File) are duplicated rather than
|
||||
// imported — this magefile depends on nothing but stdlib + mage.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/magefile/mage/mg"
|
||||
"github.com/magefile/mage/sh"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// project definitions
|
||||
|
||||
// project describes one releasable Go binary in this monorepo. Adding a new
|
||||
// project means adding an entry to projectByName plus a constructor below.
|
||||
type project struct {
|
||||
// Name is the short identifier used on the CLI: `mage release:build <name>`.
|
||||
Name string
|
||||
// Root is the project root, relative to this build/ directory.
|
||||
Root string
|
||||
// BuildPath is the Go package to build, relative to Root (e.g. "." or "./cmd/foo").
|
||||
BuildPath string
|
||||
// Executable is the output binary name (sans -<os>-<arch> suffix).
|
||||
Executable string
|
||||
// BuildTags are the base build tags applied to every cross-compile.
|
||||
BuildTags string
|
||||
// Ldflags returns the full -X flag string for the given version.
|
||||
Ldflags func(version string) string
|
||||
// NfpmConfigPath is the nfpm.yaml location, relative to Root.
|
||||
NfpmConfigPath string
|
||||
// NfpmBinPathDefault is the default <binlocation> substitution. Empty
|
||||
// means use the Executable name as-is.
|
||||
NfpmBinPathDefault string
|
||||
// OsPackageExtras hook copies any extra files (LICENSE, sample config…)
|
||||
// into each per-target bundle folder. Called once per binary.
|
||||
OsPackageExtras func(folder string, p *project) error
|
||||
}
|
||||
|
||||
func projectByName(name string) (*project, error) {
|
||||
switch name {
|
||||
case "vikunja":
|
||||
return vikunjaProject(), nil
|
||||
case "veans":
|
||||
return veansProject(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown project %q (known: vikunja, veans)", name)
|
||||
}
|
||||
}
|
||||
|
||||
func vikunjaProject() *project {
|
||||
return &project{
|
||||
Name: "vikunja",
|
||||
Root: "../",
|
||||
BuildPath: ".",
|
||||
Executable: "vikunja",
|
||||
BuildTags: "osusergo netgo",
|
||||
Ldflags: func(v string) string {
|
||||
// Matches the parent magefile's pre-refactor ldflags. The
|
||||
// main.Tags value is the literal build-tag string baked in
|
||||
// for `vikunja info` to report.
|
||||
return fmt.Sprintf(`-X "code.vikunja.io/api/pkg/version.Version=%s" -X "main.Tags=osusergo netgo"`, v)
|
||||
},
|
||||
NfpmConfigPath: "nfpm.yaml",
|
||||
NfpmBinPathDefault: "vikunja",
|
||||
OsPackageExtras: func(folder string, p *project) error {
|
||||
// config.yml.sample must be generated by the CI (or local dev)
|
||||
// before this runs — we don't want to vendor the
|
||||
// config-raw.json→YAML logic. The workflow does
|
||||
// `mage generate:config-yaml 1` in the project root before
|
||||
// invoking release:build.
|
||||
if err := copyFile(filepath.Join(p.Root, "config.yml.sample"), filepath.Join(folder, "config.yml.sample")); err != nil {
|
||||
return fmt.Errorf("copy config.yml.sample (run `mage generate:config-yaml 1` first): %w", err)
|
||||
}
|
||||
return copyFile(filepath.Join(p.Root, "LICENSE"), filepath.Join(folder, "LICENSE"))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func veansProject() *project {
|
||||
return &project{
|
||||
Name: "veans",
|
||||
Root: "../veans/",
|
||||
BuildPath: "./cmd/veans",
|
||||
Executable: "veans",
|
||||
BuildTags: "osusergo netgo",
|
||||
Ldflags: func(v string) string {
|
||||
return fmt.Sprintf(`-X main.version=%s`, v)
|
||||
},
|
||||
NfpmConfigPath: "nfpm.yaml",
|
||||
NfpmBinPathDefault: "./veans",
|
||||
OsPackageExtras: func(folder string, _ *project) error {
|
||||
// veans intentionally doesn't carry its own LICENSE — the
|
||||
// AGPLv3 at the repo root applies to both.
|
||||
return copyFile("../LICENSE", filepath.Join(folder, "LICENSE"))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// version resolution
|
||||
|
||||
func releaseVersion(ctx context.Context) (string, error) {
|
||||
if v := os.Getenv("RELEASE_VERSION"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--always", "--abbrev=10").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git describe: %w", err)
|
||||
}
|
||||
return strings.Replace(strings.TrimSpace(string(out)), "-g", "-", 1), nil
|
||||
}
|
||||
|
||||
func versionTagOrUnstable(v string) string {
|
||||
switch v {
|
||||
case "", "main":
|
||||
return "unstable"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Release namespace
|
||||
|
||||
type Release mg.Namespace
|
||||
|
||||
// Build runs the full release pipeline for the named project: dirs → xgo
|
||||
// (windows/linux/darwin in parallel) → upx → copy → sha256 → per-target
|
||||
// bundle dir → zip.
|
||||
func (Release) Build(ctx context.Context, name string) error {
|
||||
p, err := projectByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, err := releaseVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseDirs(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := prepareXgo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := xgoAllOS(ctx, p, version); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := compressBinaries(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyBinaries(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeChecksums(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bundleOsPackages(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return zipBundles(ctx, p)
|
||||
}
|
||||
|
||||
// Xgo cross-compiles a single os/arch[/variant] target for the named project.
|
||||
// Variant follows the parent magefile convention: `linux/arm/7` → arm-7.
|
||||
//
|
||||
// Unlike Release.Build, this skips prepareXgo on purpose: the only caller
|
||||
// that hits this path in CI is the Dockerfile, which runs inside the xgo
|
||||
// image (xgo binary already present, docker daemon not available). Local
|
||||
// users invoking `mage release:xgo` need to install xgo themselves.
|
||||
func (Release) Xgo(ctx context.Context, name, target string) error {
|
||||
p, err := projectByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, err := releaseVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts := strings.Split(target, "/")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid target %q (expected os/arch[/variant])", target)
|
||||
}
|
||||
variant := ""
|
||||
if len(parts) > 2 && parts[2] != "" {
|
||||
variant = "-" + strings.ReplaceAll(parts[2], "v", "")
|
||||
}
|
||||
return runXgo(ctx, p, version, parts[0]+"/"+parts[1]+variant)
|
||||
}
|
||||
|
||||
// PrepareNFPMConfig templates the named project's nfpm.yaml in place for the
|
||||
// given nfpm arch (amd64|arm64|arm7|386). Destructive — CI checks out a fresh
|
||||
// copy per matrix shard so the trampling is fine.
|
||||
func (Release) PrepareNFPMConfig(ctx context.Context, name, arch string) error {
|
||||
p, err := projectByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, err := releaseVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgPath := filepath.Join(p.Root, p.NfpmConfigPath)
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binLocation := os.Getenv("NFPM_BIN_PATH")
|
||||
if binLocation == "" {
|
||||
binLocation = p.NfpmBinPathDefault
|
||||
if binLocation == "" {
|
||||
binLocation = p.Executable
|
||||
}
|
||||
}
|
||||
out := strings.ReplaceAll(string(raw), "<version>", version)
|
||||
out = strings.ReplaceAll(out, "<arch>", arch)
|
||||
out = strings.ReplaceAll(out, "<binlocation>", binLocation)
|
||||
return os.WriteFile(cfgPath, []byte(out), 0o600)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Repo-metadata targets — project-agnostic; operate on the merged tree at
|
||||
// ../dist/repo-work/incoming and ../dist/repo-output.
|
||||
|
||||
// RepoApt generates an APT repository (reprepro) for every .deb in the
|
||||
// incoming tree. REPO_SUITE (stable|unstable) selects the target suite;
|
||||
// RELEASE_GPG_KEY + RELEASE_GPG_PASSPHRASE drive the Release file signing.
|
||||
func (Release) RepoApt(ctx context.Context) error {
|
||||
suite := repoSuite()
|
||||
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(repoRootDist, "repo-output", "apt")
|
||||
confDir := filepath.Join(outputBase, "conf")
|
||||
if err := os.MkdirAll(confDir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating reprepro conf dir: %w", err)
|
||||
}
|
||||
distConf, err := os.ReadFile("reprepro-dist-conf")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading reprepro-dist-conf: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil {
|
||||
return fmt.Errorf("writing distributions config: %w", err)
|
||||
}
|
||||
|
||||
debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, deb := range debs {
|
||||
abs, _ := filepath.Abs(deb)
|
||||
if err := sh.RunV("reprepro", "-b", outputBase, "includedeb", suite, abs); err != nil {
|
||||
return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err)
|
||||
}
|
||||
}
|
||||
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
releaseFile := filepath.Join(outputBase, "dists", suite, "Release")
|
||||
if _, err := os.Stat(releaseFile); err == nil {
|
||||
if err := sh.RunV("gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign", "--armor",
|
||||
"-o", releaseFile+".gpg",
|
||||
releaseFile,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing Release (detached): %w", err)
|
||||
}
|
||||
if err := sh.RunV("gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--clearsign",
|
||||
"-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"),
|
||||
releaseFile,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing Release (clearsign): %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Println("APT repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepoRpm generates an RPM repository (createrepo_c) per arch in
|
||||
// ../dist/repo-work/incoming/.
|
||||
func (Release) RepoRpm(ctx context.Context) error {
|
||||
suite := repoSuite()
|
||||
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(repoRootDist, "repo-output", "rpm", suite)
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
|
||||
for _, arch := range []string{"x86_64", "aarch64", "armv7"} {
|
||||
repoDir := filepath.Join(outputBase, arch)
|
||||
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
rpms, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".rpm"))
|
||||
if len(rpms) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, rpm := range rpms {
|
||||
abs, _ := filepath.Abs(rpm)
|
||||
dst := filepath.Join(repoDir, filepath.Base(rpm))
|
||||
_ = os.Remove(dst)
|
||||
if err := os.Symlink(abs, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
args := []string{repoDir}
|
||||
if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil {
|
||||
args = []string{"--update", repoDir}
|
||||
}
|
||||
if err := sh.RunV("createrepo_c", args...); err != nil {
|
||||
return fmt.Errorf("createrepo_c for %s: %w", arch, err)
|
||||
}
|
||||
if err := sh.RunV("gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign", "--armor",
|
||||
"-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"),
|
||||
filepath.Join(repoDir, "repodata", "repomd.xml"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing repomd.xml for %s: %w", arch, err)
|
||||
}
|
||||
}
|
||||
fmt.Println("RPM repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepoPacman generates a Pacman repository (repo-add) per arch.
|
||||
func (Release) RepoPacman(ctx context.Context) error {
|
||||
suite := repoSuite()
|
||||
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(repoRootDist, "repo-output", "pacman", suite)
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
|
||||
for _, arch := range []string{"x86_64", "aarch64", "armv7"} {
|
||||
repoDir := filepath.Join(outputBase, arch)
|
||||
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
pkgs, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".archlinux"))
|
||||
if len(pkgs) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, pkg := range pkgs {
|
||||
abs, _ := filepath.Abs(pkg)
|
||||
dst := filepath.Join(repoDir, filepath.Base(pkg))
|
||||
_ = os.Remove(dst)
|
||||
if err := os.Symlink(abs, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz")
|
||||
repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux"))
|
||||
repoAddArgs := append([]string{dbPath}, repoPkgs...)
|
||||
if err := sh.RunV("repo-add", repoAddArgs...); err != nil {
|
||||
return fmt.Errorf("repo-add for %s: %w", arch, err)
|
||||
}
|
||||
for _, name := range []string{"vikunja.db", "vikunja.files"} {
|
||||
link := filepath.Join(repoDir, name)
|
||||
_ = os.Remove(link)
|
||||
if err := os.Symlink(name+".tar.gz", link); err != nil {
|
||||
return fmt.Errorf("creating symlink %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := sh.RunV("gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign",
|
||||
"-o", filepath.Join(repoDir, "vikunja.db.sig"),
|
||||
dbPath,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing db for %s: %w", arch, err)
|
||||
}
|
||||
}
|
||||
fmt.Println("Pacman repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// pipeline internals
|
||||
|
||||
const (
|
||||
distSubdir = "dist"
|
||||
subBin = "binaries"
|
||||
subRelease = "release"
|
||||
subZip = "zip"
|
||||
|
||||
// repoRootDist is where the repo-publish targets read and write — it's
|
||||
// the dist/ directory at the repo root, not under build/. The CI
|
||||
// populates dist/repo-work/incoming with packages from every project.
|
||||
repoRootDist = "../dist"
|
||||
)
|
||||
|
||||
func projectDist(p *project, sub string) string {
|
||||
return filepath.Join(p.Root, distSubdir, sub)
|
||||
}
|
||||
|
||||
func releaseDirs(p *project) error {
|
||||
for _, d := range []string{subBin, subRelease, subZip} {
|
||||
if err := os.MkdirAll(projectDist(p, d), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareXgo(_ context.Context) error {
|
||||
if _, err := exec.LookPath("xgo"); err != nil {
|
||||
fmt.Println("xgo not found, installing src.techknowlogick.com/xgo...")
|
||||
if err := sh.RunV("go", "install", "src.techknowlogick.com/xgo@latest"); err != nil {
|
||||
return fmt.Errorf("installing xgo: %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Println("Pulling latest xgo docker image...")
|
||||
return sh.RunV("docker", "pull", "ghcr.io/techknowlogick/xgo:latest")
|
||||
}
|
||||
|
||||
func xgoOutName(p *project, version string) string {
|
||||
if v := os.Getenv("XGO_OUT_NAME"); v != "" {
|
||||
return v
|
||||
}
|
||||
return p.Executable + "-" + versionTagOrUnstable(version)
|
||||
}
|
||||
|
||||
func runXgo(ctx context.Context, p *project, version, targets string) error {
|
||||
extraLdflags := `-linkmode external -extldflags "-static" `
|
||||
// xgo's darwin builds can't use the static external linker.
|
||||
if strings.HasPrefix(targets, "darwin") {
|
||||
extraLdflags = ""
|
||||
}
|
||||
// xgo resolves its last arg as a Go package path. Running it from build/
|
||||
// with `../` confuses the module resolution (it tries to find a package
|
||||
// inside this build module). Invoke xgo from the project root so we can
|
||||
// pass p.BuildPath ("." or "./cmd/veans") just like the original
|
||||
// per-project magefiles did.
|
||||
absRoot, err := filepath.Abs(p.Root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve project root: %w", err)
|
||||
}
|
||||
absDest, err := filepath.Abs(projectDist(p, subBin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve dest dir: %w", err)
|
||||
}
|
||||
//nolint:gosec // mage helper; args are derived from the static project table above.
|
||||
cmd := exec.CommandContext(ctx, "xgo",
|
||||
"-dest", absDest,
|
||||
"-tags", p.BuildTags,
|
||||
"-ldflags", extraLdflags+p.Ldflags(version),
|
||||
"-targets", targets,
|
||||
"-out", xgoOutName(p, version),
|
||||
p.BuildPath,
|
||||
)
|
||||
cmd.Dir = absRoot
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func xgoAllOS(ctx context.Context, p *project, version string) error {
|
||||
groups := []string{
|
||||
"windows/*",
|
||||
strings.Join([]string{
|
||||
"linux/amd64",
|
||||
"linux/arm-5",
|
||||
"linux/arm-6",
|
||||
"linux/arm-7",
|
||||
"linux/arm64",
|
||||
"linux/mips",
|
||||
"linux/mipsle",
|
||||
"linux/mips64",
|
||||
"linux/mips64le",
|
||||
"linux/riscv64",
|
||||
}, ","),
|
||||
"darwin-10.15/*",
|
||||
}
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
record := func(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
for _, targets := range groups {
|
||||
wg.Add(1)
|
||||
go func(t string) {
|
||||
defer wg.Done()
|
||||
record(runXgo(ctx, p, version, t))
|
||||
}(targets)
|
||||
}
|
||||
wg.Wait()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// compressBinaries runs upx -9 over each binary that upx can handle. The skip
|
||||
// list matches the parent magefile's behavior.
|
||||
func compressBinaries(p *project) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
record := func(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
walkErr := filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
name := info.Name()
|
||||
if !strings.Contains(name, p.Executable) {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(name, "mips") ||
|
||||
strings.Contains(name, "s390x") ||
|
||||
strings.Contains(name, "riscv64") ||
|
||||
strings.Contains(name, "darwin") ||
|
||||
(strings.Contains(name, "windows") && strings.Contains(name, "arm64")) {
|
||||
return nil
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(pp string) {
|
||||
defer wg.Done()
|
||||
if err := sh.RunV("chmod", "+x", pp); err != nil {
|
||||
record(err)
|
||||
return
|
||||
}
|
||||
record(sh.RunV("upx", "-9", pp))
|
||||
}(path)
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
wg.Wait()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func copyBinaries(p *project) error {
|
||||
return filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(info.Name(), p.Executable) {
|
||||
return nil
|
||||
}
|
||||
return copyFile(path, filepath.Join(projectDist(p, subRelease), info.Name()))
|
||||
})
|
||||
}
|
||||
|
||||
func writeChecksums(p *project) error {
|
||||
release := projectDist(p, subRelease)
|
||||
return filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(info.Name(), ".sha256") {
|
||||
return nil
|
||||
}
|
||||
sum, err := sha256File(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path+".sha256", []byte(sum+" "+info.Name()+"\n"), 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
func bundleOsPackages(p *project) error {
|
||||
release := projectDist(p, subRelease)
|
||||
bins := map[string]os.FileInfo{}
|
||||
if err := filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(info.Name(), ".sha256") {
|
||||
return nil
|
||||
}
|
||||
bins[path] = info
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for binPath, info := range bins {
|
||||
folder := filepath.Join(release, info.Name()+"-full")
|
||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := moveFile(binPath+".sha256", filepath.Join(folder, info.Name()+".sha256")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := moveFile(binPath, filepath.Join(folder, info.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.OsPackageExtras != nil {
|
||||
if err := p.OsPackageExtras(folder, p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zipBundles(ctx context.Context, p *project) error {
|
||||
zipDirAbs, err := filepath.Abs(projectDist(p, subZip))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release := projectDist(p, subRelease)
|
||||
return filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() || filepath.Base(path) == subRelease {
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Zipping %s...\n", info.Name())
|
||||
zipFile := filepath.Join(zipDirAbs, info.Name()+".zip")
|
||||
//nolint:gosec // mage helper; args derive from the local filesystem walk above.
|
||||
c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*")
|
||||
c.Dir = path
|
||||
c.Stdout, c.Stderr = os.Stdout, os.Stderr
|
||||
return c.Run()
|
||||
})
|
||||
}
|
||||
|
||||
// repoSuite validates the REPO_SUITE env var; defaults to "stable". Limiting
|
||||
// the values prevents path traversal via the suite name flowing into a
|
||||
// filesystem path.
|
||||
func repoSuite() string {
|
||||
switch os.Getenv("REPO_SUITE") {
|
||||
case "stable", "unstable":
|
||||
return os.Getenv("REPO_SUITE")
|
||||
default:
|
||||
return "stable"
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// helpers — duplicated from the project magefiles so this module depends on
|
||||
// nothing but stdlib + mage. Don't import these from elsewhere; rewrite them
|
||||
// here if they need to change.
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
si, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dst, si.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func moveFile(src, dst string) error {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
func sha256File(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// Aliases for kebab-case spelling at the CLI.
|
||||
var Aliases = map[string]any{
|
||||
"release": Release.Build,
|
||||
"release:build": Release.Build,
|
||||
"release:xgo": Release.Xgo,
|
||||
"release:prepare-nfpm-config": Release.PrepareNFPMConfig,
|
||||
"release:repo-apt": Release.RepoApt,
|
||||
"release:repo-rpm": Release.RepoRpm,
|
||||
"release:repo-pacman": Release.RepoPacman,
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "40.10.0",
|
||||
"electron": "40.10.2",
|
||||
"electron-builder": "26.8.1",
|
||||
"unzipper": "0.12.3"
|
||||
},
|
||||
|
|
@ -76,7 +76,9 @@
|
|||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"@tootallnate/once": "^3.0.1",
|
||||
"picomatch": ">=4.0.4"
|
||||
"picomatch": ">=4.0.4",
|
||||
"tmp": ">=0.2.6",
|
||||
"ip-address": ">=10.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ overrides:
|
|||
tar: ^7.5.11
|
||||
'@tootallnate/once': ^3.0.1
|
||||
picomatch: '>=4.0.4'
|
||||
tmp: '>=0.2.6'
|
||||
ip-address: '>=10.1.1'
|
||||
|
||||
importers:
|
||||
|
||||
|
|
@ -19,8 +21,8 @@ importers:
|
|||
version: 5.2.1
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 40.10.0
|
||||
version: 40.10.0
|
||||
specifier: 40.10.2
|
||||
version: 40.10.2
|
||||
electron-builder:
|
||||
specifier: 26.8.1
|
||||
version: 26.8.1(electron-builder-squirrel-windows@24.13.3)
|
||||
|
|
@ -567,8 +569,8 @@ packages:
|
|||
electron-publish@26.8.1:
|
||||
resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==}
|
||||
|
||||
electron@40.10.0:
|
||||
resolution: {integrity: sha512-e7XVcAfyWoFQGS7ZhgxeNn0AijHaqgRCa6uA6TYOrvBWv8smI6JILvMR/8DYBIn07oqvxDLRC90tu/xa2cJCow==}
|
||||
electron@40.10.2:
|
||||
resolution: {integrity: sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==}
|
||||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -840,8 +842,8 @@ packages:
|
|||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ip-address@10.1.0:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
|
|
@ -1210,8 +1212,8 @@ packages:
|
|||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.15.0:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
qs@6.15.2:
|
||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
quick-lru@5.1.1:
|
||||
|
|
@ -1318,6 +1320,11 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@1.2.0:
|
||||
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
|
@ -1461,8 +1468,8 @@ packages:
|
|||
tmp-promise@3.0.3:
|
||||
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
||||
|
||||
tmp@0.2.3:
|
||||
resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==}
|
||||
tmp@0.2.6:
|
||||
resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
|
|
@ -1757,7 +1764,7 @@ snapshots:
|
|||
|
||||
'@npmcli/fs@4.0.0':
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
|
@ -1893,7 +1900,7 @@ snapshots:
|
|||
minimatch: 10.2.5
|
||||
read-config-file: 6.3.2
|
||||
sanitize-filename: 1.6.4
|
||||
semver: 7.8.0
|
||||
semver: 7.8.1
|
||||
tar: 7.5.15
|
||||
temp-file: 3.4.0
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -2018,7 +2025,7 @@ snapshots:
|
|||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.0
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.0
|
||||
qs: 6.15.2
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -2386,7 +2393,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@40.10.0:
|
||||
electron@40.10.2:
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@types/node': 24.10.9
|
||||
|
|
@ -2464,7 +2471,7 @@ snapshots:
|
|||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.0
|
||||
qs: 6.15.2
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.2.0
|
||||
|
|
@ -2621,7 +2628,7 @@ snapshots:
|
|||
es6-error: 4.1.1
|
||||
matcher: 3.0.0
|
||||
roarr: 2.15.4
|
||||
semver: 7.7.4
|
||||
semver: 7.8.0
|
||||
serialize-error: 7.0.1
|
||||
optional: true
|
||||
|
||||
|
|
@ -2739,7 +2746,7 @@ snapshots:
|
|||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
ip-address@10.2.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
|
|
@ -2943,14 +2950,14 @@ snapshots:
|
|||
|
||||
node-abi@4.24.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
|
||||
node-addon-api@1.7.2:
|
||||
optional: true
|
||||
|
||||
node-api-version@0.2.1:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
|
||||
node-gyp@11.5.0:
|
||||
dependencies:
|
||||
|
|
@ -2960,7 +2967,7 @@ snapshots:
|
|||
make-fetch-happen: 14.0.3
|
||||
nopt: 8.1.0
|
||||
proc-log: 5.0.0
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
tar: 7.5.11
|
||||
tinyglobby: 0.2.15
|
||||
which: 5.0.0
|
||||
|
|
@ -3076,7 +3083,7 @@ snapshots:
|
|||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.0:
|
||||
qs@6.15.2:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
|
|
@ -3192,7 +3199,10 @@ snapshots:
|
|||
|
||||
semver@7.7.4: {}
|
||||
|
||||
semver@7.8.0: {}
|
||||
semver@7.8.0:
|
||||
optional: true
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
send@1.2.0:
|
||||
dependencies:
|
||||
|
|
@ -3287,7 +3297,7 @@ snapshots:
|
|||
|
||||
socks@2.8.7:
|
||||
dependencies:
|
||||
ip-address: 10.1.0
|
||||
ip-address: 10.2.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
|
@ -3386,9 +3396,9 @@ snapshots:
|
|||
|
||||
tmp-promise@3.0.3:
|
||||
dependencies:
|
||||
tmp: 0.2.3
|
||||
tmp: 0.2.6
|
||||
|
||||
tmp@0.2.3: {}
|
||||
tmp@0.2.6: {}
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,8 +117,8 @@
|
|||
"@types/node": "24.12.4",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.0",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/test-utils": "2.4.10",
|
||||
|
|
@ -135,14 +135,14 @@
|
|||
"happy-dom": "20.9.0",
|
||||
"histoire": "1.0.0-beta.1",
|
||||
"otplib": "12.0.1",
|
||||
"postcss": "8.5.14",
|
||||
"postcss": "8.5.15",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-html": "1.8.1",
|
||||
"postcss-preset-env": "11.3.0",
|
||||
"rollup": "4.60.4",
|
||||
"rollup-plugin-visualizer": "6.0.11",
|
||||
"sass-embedded": "1.99.0",
|
||||
"stylelint": "17.11.1",
|
||||
"sass-embedded": "1.100.0",
|
||||
"stylelint": "17.12.0",
|
||||
"stylelint-config-property-sort-order-smacss": "10.0.0",
|
||||
"stylelint-config-recommended-vue": "1.6.1",
|
||||
"stylelint-config-standard-scss": "17.0.0",
|
||||
|
|
@ -154,11 +154,11 @@
|
|||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-vue-devtools": "8.1.2",
|
||||
"vite-svg-loader": "5.1.1",
|
||||
"vitest": "4.1.6",
|
||||
"vue-tsc": "3.3.0",
|
||||
"vitest": "4.1.7",
|
||||
"vue-tsc": "3.3.2",
|
||||
"wait-on": "9.0.10",
|
||||
"workbox-cli": "7.4.1",
|
||||
"ws": "8.20.1"
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
@ -175,7 +175,8 @@
|
|||
"serialize-javascript": "^7.0.5",
|
||||
"flatted": "^3.4.1",
|
||||
"ip-address": ">=10.1.1",
|
||||
"postcss": ">=8.5.10"
|
||||
"postcss": ">=8.5.10",
|
||||
"tmp": ">=0.2.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,9 +5,36 @@
|
|||
},
|
||||
"home": {
|
||||
"welcomeNight": "おやすみなさい、{username}さん",
|
||||
"welcomeNightOwl": "夜更かしですか、{username}さん",
|
||||
"welcomeNightBurning": "夜更かししていますね、{username}さん?",
|
||||
"welcomeNightQuiet": "静かな時間ですね、{username}さん",
|
||||
"welcomeNightLate": "もう遅い時間ですよ、{username}さん",
|
||||
"welcomeNightMoonlit": "月明かりの下で計画ですか、{username}さん?",
|
||||
"welcomeMorning": "おはようございます、{username}さん",
|
||||
"welcomeMorningHey": "やあ {username}さん、準備はいいですか?",
|
||||
"welcomeMorningFresh": "さわやかな朝ですね、{username}さん",
|
||||
"welcomeMorningCoffee": "コーヒーとタスクはいかがですか、{username}さん?",
|
||||
"welcomeMorningRise": "さあ、計画を立てましょう、{username}さん",
|
||||
"welcomeMorningBack": "おかえりなさい、{username}さん",
|
||||
"welcomeMondayFresh": "新しい一週間の始まりです、{username}さん",
|
||||
"welcomeTuesday": "よい火曜日を、{username}さん",
|
||||
"welcomeWednesdayMid": "もう週の半ばですね、{username}さん",
|
||||
"welcomeThursday": "あと少しです、{username}さん",
|
||||
"welcomeFridayPush": "金曜日、もうひと踏ん張りですね、{username}さん?",
|
||||
"welcomeSaturday": "週末モードですね、{username}さん",
|
||||
"welcomeSundaySession": "日曜日の作業ですか、{username}さん?",
|
||||
"welcomeDay": "こんにちは、{username}さん",
|
||||
"welcomeDayBack": "作業再開ですね、{username}さん",
|
||||
"welcomeDayFocus": "集中していきましょう、{username}さん",
|
||||
"welcomeDayKeepGoing": "その調子、{username}さん",
|
||||
"welcomeDayWhatsNext": "次は何ですか、{username}さん?",
|
||||
"welcomeDayGood": "こんにちは、{username}さん",
|
||||
"welcomeEvening": "こんばんは、{username}さん",
|
||||
"welcomeEveningWind": "そろそろ一段落ですか、{username}さん?",
|
||||
"welcomeEveningReturns": "{username}さんのお戻りですね",
|
||||
"welcomeEveningWrap": "そろそろ終わりにしませんか、{username}さん?",
|
||||
"welcomeEveningOneMore": "あと一つだけいかがですか、{username}さん?",
|
||||
"welcomeEveningStill": "まだ頑張っていますね、{username}さん?",
|
||||
"lastViewed": "最近の表示",
|
||||
"addToHomeScreen": "ホーム画面に追加すると、すぐにアクセスできて使いやすくなります。",
|
||||
"goToOverview": "概要に移動",
|
||||
|
|
@ -53,6 +80,15 @@
|
|||
"authenticating": "認証中…",
|
||||
"openIdStateError": "stateパラメータが一致しないため処理を中断しました。",
|
||||
"openIdGeneralError": "認証中にエラーが発生しました。",
|
||||
"openIdTotpRequired": "このアカウントには2要素認証が必要です。TOTPコードを入力して再度サインインしてください。",
|
||||
"openIdTotpSubmit": "続ける",
|
||||
"oauthMissingParams": "必要なOAuthパラメータがありません: {params}",
|
||||
"oauthRedirectedToApp": "アプリにリダイレクトされました。このタブは閉じて構いません。",
|
||||
"desktopTryDemo": "デモを試す",
|
||||
"desktopCustomServer": "カスタムサーバーURL",
|
||||
"desktopCustomServerDescription": "使用するVikunjaサーバーのURLを入力して始めましょう。",
|
||||
"desktopWaitingForAuth": "認証を待機中…",
|
||||
"desktopOAuthError": "認証に失敗しました: {error}",
|
||||
"logout": "ログアウト",
|
||||
"emailInvalid": "有効なメールアドレスを入力してください。",
|
||||
"usernameRequired": "ユーザー名を入力してください。",
|
||||
|
|
@ -71,6 +107,19 @@
|
|||
"registrationFailed": "登録中にエラーが発生しました。入力内容を確認して、もう一度お試しください。"
|
||||
},
|
||||
"settings": {
|
||||
"bots": {
|
||||
"title": "ボットユーザー",
|
||||
"description": "ボットユーザーは、あなたが所有する API 専用のユーザーです。プロジェクトに追加したり、タスクを割り当てたり、API トークンで認証したりできます。対話的にログインすることはできません。",
|
||||
"namePlaceholder": "マイアシスタント",
|
||||
"create": "ボットを作成",
|
||||
"enable": "有効にする",
|
||||
"badge": "ボット",
|
||||
"delete": {
|
||||
"header": "このボットユーザーを削除",
|
||||
"text1": "ボットユーザー「{username}」を削除してもよろしいですか?",
|
||||
"text2": "この操作は取り消せません。このボットに紐付く API トークンはすべて失効します。"
|
||||
}
|
||||
},
|
||||
"title": "設定",
|
||||
"newPasswordTitle": "パスワードの更新",
|
||||
"newPassword": "新しいパスワード",
|
||||
|
|
@ -96,12 +145,21 @@
|
|||
"weekStart": "週の始まり",
|
||||
"weekStartSunday": "日曜日",
|
||||
"weekStartMonday": "月曜日",
|
||||
"weekStartTuesday": "火曜日",
|
||||
"weekStartWednesday": "水曜日",
|
||||
"weekStartThursday": "木曜日",
|
||||
"weekStartFriday": "金曜日",
|
||||
"weekStartSaturday": "土曜日",
|
||||
"language": "言語",
|
||||
"defaultProject": "デフォルトのプロジェクト",
|
||||
"defaultView": "デフォルトのビュー",
|
||||
"timezone": "タイムゾーン",
|
||||
"overdueTasksRemindersTime": "期限切れタスクのリマインダー送信時間",
|
||||
"quickAddDefaultReminders": "クイック追加のデフォルトリマインダー",
|
||||
"quickAddDefaultRemindersDescription": "期限日を持つクイック追加マジックで作成されたすべてのタスクに、これらのリマインダーが自動的に追加されます。",
|
||||
"quickAddDefaultRemindersHint": "タスクの期限日を基準としたリマインダーを1つ以上追加してください。空にすると無効化されます。",
|
||||
"filterUsedOnOverview": "概要ページの絞り込み条件",
|
||||
"showLastViewed": "概要ページに最近表示したプロジェクトを表示",
|
||||
"minimumPriority": "表示タスク優先度の最小値",
|
||||
"dateDisplay": "日付表示形式",
|
||||
"dateDisplayOptions": {
|
||||
|
|
@ -125,7 +183,13 @@
|
|||
"taskAndNotifications": "プロジェクトとタスク",
|
||||
"privacy": "プライバシー設定",
|
||||
"localization": "ローカライズ",
|
||||
"appearance": "外観と動作"
|
||||
"appearance": "外観と動作",
|
||||
"desktop": "デスクトップアプリ"
|
||||
},
|
||||
"desktop": {
|
||||
"quickEntryShortcut": "クイック入力ショートカット",
|
||||
"shortcutRecorderPlaceholder": "クリックしてショートカットを設定",
|
||||
"shortcutRecorderRecording": "キーの組み合わせを押してください…"
|
||||
},
|
||||
"totp": {
|
||||
"title": "2要素認証",
|
||||
|
|
@ -135,15 +199,32 @@
|
|||
"scanQR": "あるいは、TOTPアプリでこのQRコードをスキャンして認証コードを入力してください:",
|
||||
"passcode": "認証コード",
|
||||
"passcodePlaceholder": "TOTPアプリで生成された認証コード",
|
||||
"confirmNotice": "2要素認証を有効化すると、すべてのセッションからログアウトされ、再度ログインが必要になります。",
|
||||
"setupSuccess": "2要素認証は正常に設定されました。",
|
||||
"enterPassword": "パスワードを入力してください",
|
||||
"disable": "2要素認証の無効化",
|
||||
"confirmSuccess": "2要素認証を有効化しました!",
|
||||
"disableSuccess": "2要素認証は無効化されました。"
|
||||
},
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"howTo": "VikunjaをCalDAVクライアントに接続すると、さまざまなクライアントからすべてのタスクを表示・管理できます。以下のURLをクライアントに入力してください:",
|
||||
"more": "VikunjaのCalDAVに関する詳細情報",
|
||||
"tokens": "CalDAVトークン"
|
||||
"tokens": "CalDAVトークン",
|
||||
"tokensHowTo": "CalDAV認証には、通常のアカウントパスワードかCalDAV専用トークンのいずれかを利用できます。",
|
||||
"createToken": "CalDAVトークンを作成",
|
||||
"tokenCreated": "新しいトークンはこちらです: {token}",
|
||||
"wontSeeItAgain": "書き留めるか安全に保管してください — 再度表示することはできません。",
|
||||
"mustUseToken": "サードパーティ製クライアントでCalDAVを利用するには、CalDAVトークンを作成する必要があります。作成したトークンをクライアントのパスワード欄に入力してください。",
|
||||
"usernameIs": "CalDAV用のユーザー名: {0}",
|
||||
"apiTokenHint": "CalDAV権限を持つAPIトークンも利用できます。{link} で作成してください。"
|
||||
},
|
||||
"feeds": {
|
||||
"title": "Atom フィード",
|
||||
"howTo": "Atom 対応のフィードリーダーから Vikunja の通知を購読できます。以下の URL を使用してください:",
|
||||
"usernameIs": "フィード用のユーザー名: {0}",
|
||||
"apiTokenHint": "{scope} 権限を持つ API トークンで認証してください。{link} で作成できます。",
|
||||
"tokenTitle": "Atom フィード"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "プロフィール画像",
|
||||
|
|
@ -174,6 +255,10 @@
|
|||
"backgroundBrightness": {
|
||||
"title": "背景の明るさ"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhook通知",
|
||||
"description": "リマインダーや期限切れイベント発生時にPOSTリクエストを受信するWebhook URLを設定します。これらのWebhookはすべてのプロジェクトからイベントを受信します。"
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "APIトークン",
|
||||
"general": "APIトークンを使うとログインせずにVikunjaのAPIを利用できます。",
|
||||
|
|
@ -189,6 +274,13 @@
|
|||
"expired": "このトークンは {ago} に期限切れしています。",
|
||||
"tokenCreatedSuccess": "新しい API トークンはこちらです: {token}",
|
||||
"tokenCreatedNotSeeAgain": "このトークンは二度と表示されません。安全な場所に保管してください。",
|
||||
"presets": {
|
||||
"title": "クイックプリセット",
|
||||
"readOnly": "読み取り専用",
|
||||
"tasks": "タスク管理",
|
||||
"projects": "プロジェクト管理",
|
||||
"fullAccess": "フルアクセス"
|
||||
},
|
||||
"delete": {
|
||||
"header": "トークンの削除",
|
||||
"text1": "トークン \"{token}\" を削除してよろしいですか?",
|
||||
|
|
@ -368,7 +460,8 @@
|
|||
"addPlaceholder": "タスクを追加…",
|
||||
"empty": "このプロジェクトにはタスクが存在しません。",
|
||||
"newTaskCta": "タスクを作成してください。",
|
||||
"editTask": "タスクの編集"
|
||||
"editTask": "タスクの編集",
|
||||
"sort": "並べ替え"
|
||||
},
|
||||
"gantt": {
|
||||
"title": "ガント",
|
||||
|
|
@ -427,7 +520,8 @@
|
|||
"bucketTitleSavedSuccess": "バケットのタイトルは正常に保存されました。",
|
||||
"bucketLimitSavedSuccess": "バケットの上限は正常に保存されました。",
|
||||
"collapse": "このバケットを折りたたむ",
|
||||
"bucketLimitReached": "バケットの上限に達しました。新しいタスクを追加するには、既存のタスクを削除するか、上限を緩和してください。"
|
||||
"bucketLimitReached": "バケットの上限に達しました。新しいタスクを追加するには、既存のタスクを削除するか、上限を緩和してください。",
|
||||
"bucketOptions": "バケットオプション"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
@ -550,6 +644,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sorting": {
|
||||
"manually": "手動",
|
||||
"apply": "並べ替えを適用",
|
||||
"description": "このリスト内のタスクの並び順を選択します。手動並び替えを選ぶと、ドラッグ&ドロップでタスクの順序を変更できます。",
|
||||
"options": {
|
||||
"titleAsc": "タイトル (A–Z)",
|
||||
"titleDesc": "タイトル (Z–A)",
|
||||
"priorityDesc": "優先度 (高い順)",
|
||||
"priorityAsc": "優先度 (低い順)",
|
||||
"dueDateAsc": "期限日 (早い順)",
|
||||
"dueDateDesc": "期限日 (遅い順)",
|
||||
"startDateAsc": "開始日 (早い順)",
|
||||
"startDateDesc": "開始日 (遅い順)",
|
||||
"endDateAsc": "終了日 (早い順)",
|
||||
"endDateDesc": "終了日 (遅い順)",
|
||||
"percentDoneDesc": "完了率 (高い順)",
|
||||
"percentDoneAsc": "完了率 (低い順)",
|
||||
"createdDesc": "作成日時 (新しい順)",
|
||||
"createdAsc": "作成日時 (古い順)",
|
||||
"updatedDesc": "更新日時 (新しい順)",
|
||||
"updatedAsc": "更新日時 (古い順)"
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
"title": "他のサービスからのインポート",
|
||||
"titleService": "{name}からVikunjaへのデータのインポート",
|
||||
|
|
@ -564,7 +681,30 @@
|
|||
"importUpload": "{name}からVikunjaにデータをインポートするには、以下のボタンをクリックしてファイルを選択してください。",
|
||||
"upload": "ファイルのアップロード",
|
||||
"migrationStartedWillReciveEmail": "{service}のリスト、タスク、メモ、リマインダー、ファイルをすべてVikunjaにインポートします。完了までしばらくお待ちください。メールでお知らせします。このウィンドウは閉じても構いません。",
|
||||
"migrationInProgress": "現在移行中です。完了するまでしばらくお待ちください。"
|
||||
"migrationInProgress": "現在移行中です。完了するまでしばらくお待ちください。",
|
||||
"csv": {
|
||||
"description": "カスタム列マッピングでCSVファイルからタスクをインポートします。",
|
||||
"uploadDescription": "インポートするCSVファイルを選択してください。ファイルの1行目はヘッダーで、タスクデータを含んでいる必要があります。",
|
||||
"selectFile": "CSVファイルを選択",
|
||||
"columnMappingDescription": "CSVファイルの各列をタスク属性にマッピングします。Vikunjaが最も可能性の高いマッピングを自動検出しています。設定を変更すると、下部のプレビューが自動更新されます。",
|
||||
"parsingOptions": "解析オプション",
|
||||
"delimiter": "区切り文字",
|
||||
"dateFormat": "日付形式",
|
||||
"skipRows": "スキップする行数",
|
||||
"mapColumns": "列のマッピング",
|
||||
"example": "例:",
|
||||
"preview": "プレビュー",
|
||||
"previewDescription": "インポートされる {count} 件のタスクのうち先頭5件を表示しています。",
|
||||
"import": "タスクをインポート",
|
||||
"untitled": "無題のタスク",
|
||||
"ignore": "無視",
|
||||
"delimiters": {
|
||||
"comma": "カンマ (,)",
|
||||
"semicolon": "セミコロン (;)",
|
||||
"tab": "タブ",
|
||||
"pipe": "パイプ (|)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"title": "ラベル",
|
||||
|
|
@ -604,7 +744,9 @@
|
|||
"upcoming": "今後の予定",
|
||||
"settings": "設定",
|
||||
"imprint": "運営情報",
|
||||
"privacy": "プライバシーポリシー"
|
||||
"privacy": "プライバシーポリシー",
|
||||
"closeSidebar": "サイドバーを閉じる",
|
||||
"home": "Vikunja ホーム"
|
||||
},
|
||||
"misc": {
|
||||
"loading": "読み込み中…",
|
||||
|
|
@ -636,9 +778,15 @@
|
|||
"createdBy": "{0} によって作成",
|
||||
"actions": "アクション",
|
||||
"cannotBeUndone": "この操作は元に戻せません!",
|
||||
"avatarOfUser": "{user} のプロフィール画像"
|
||||
"avatarOfUser": "{user} のプロフィール画像",
|
||||
"closeBanner": "バナーを閉じる",
|
||||
"closeDialog": "ダイアログを閉じる",
|
||||
"closeQuickActions": "クイックアクションを閉じる",
|
||||
"skipToContent": "メインコンテンツへスキップ",
|
||||
"sortBy": "並び替え"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "プロジェクトの色",
|
||||
"resetColor": "色のリセット",
|
||||
"datepicker": {
|
||||
"today": "今日",
|
||||
|
|
@ -698,6 +846,9 @@
|
|||
"toggleHeaderCell": "選択中のセルのタイトル セル指定の有無の切り替え",
|
||||
"mergeOrSplit": "結合または分割",
|
||||
"fixTables": "テーブルの修正"
|
||||
},
|
||||
"emoji": {
|
||||
"empty": "絵文字が見つかりません"
|
||||
}
|
||||
},
|
||||
"multiselect": {
|
||||
|
|
@ -711,6 +862,7 @@
|
|||
"date": "日付",
|
||||
"ranges": {
|
||||
"today": "今日",
|
||||
"tomorrow": "明日",
|
||||
"thisWeek": "今週",
|
||||
"restOfThisWeek": "今から週末まで",
|
||||
"nextWeek": "来週",
|
||||
|
|
@ -784,6 +936,7 @@
|
|||
"addReminder": "リマイダーを作成…",
|
||||
"doneSuccess": "タスクを完了にしました。",
|
||||
"undoneSuccess": "タスクを未完了に戻しました。",
|
||||
"readOnlyCheckbox": "このタスクには読み取り権限しかないため、完了にすることはできません。",
|
||||
"movedToProject": "タスクは {project} に移動しました。",
|
||||
"undo": "元に戻す",
|
||||
"checklistTotal": "{total}件中{checked}件のタスク",
|
||||
|
|
@ -796,7 +949,8 @@
|
|||
"select": "期間の選択",
|
||||
"noTasks": "タスクはありません — よい一日を!",
|
||||
"filterByLabel": "ラベル {label} での絞り込み",
|
||||
"clearLabelFilter": "ラベルでの絞り込みの解除"
|
||||
"clearLabelFilter": "ラベルでの絞り込みの解除",
|
||||
"savedFilterIgnored": "ラベルごとのタスク表示中は、保存されたホーム画面のフィルターは適用されません。"
|
||||
},
|
||||
"detail": {
|
||||
"chooseDueDate": "期日を設定…",
|
||||
|
|
@ -811,9 +965,14 @@
|
|||
"updateSuccess": "タスクは正常に保存されました。",
|
||||
"deleteSuccess": "タスクは正常に削除されました。",
|
||||
"duplicateSuccess": "タスクは正常に複製されました。",
|
||||
"noBucket": "バケットなし",
|
||||
"bucketChangedSuccess": "タスクのバケットを変更しました。",
|
||||
"belongsToProject": "このタスクはプロジェクト「{project}」に含まれています。",
|
||||
"back": "プロジェクトに戻る",
|
||||
"due": "期限: {at}",
|
||||
"closeTaskDetail": "タスク詳細を閉じる",
|
||||
"title": "タスクの詳細",
|
||||
"markAsDone": "「{task}」を完了にする",
|
||||
"scrollToBottom": "一番下まで移動",
|
||||
"organization": "組織",
|
||||
"management": "管理",
|
||||
|
|
@ -907,7 +1066,10 @@
|
|||
"addedSuccess": "コメントは正常に追加されました。",
|
||||
"permalink": "コメントへのリンクをコピー",
|
||||
"sortNewestFirst": "新しい順",
|
||||
"sortOldestFirst": "古い順"
|
||||
"sortOldestFirst": "古い順",
|
||||
"reply": "返信",
|
||||
"jumpToOriginal": "元のコメントへ移動",
|
||||
"deletedComment": "削除されたコメント"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "ユーザーが見つかりません"
|
||||
|
|
@ -990,6 +1152,7 @@
|
|||
"mode": "繰り返しモード",
|
||||
"monthly": "毎月",
|
||||
"fromCurrentDate": "完了からの間隔",
|
||||
"each": "毎",
|
||||
"specifyAmount": "数字を入力…",
|
||||
"hours": "時間ごと",
|
||||
"days": "日ごと",
|
||||
|
|
@ -998,6 +1161,7 @@
|
|||
},
|
||||
"quickAddMagic": {
|
||||
"hint": "期日、担当者、その他の項目を追加するキーワードが使用できます。",
|
||||
"quickEntryHint": "日付やラベルなどに使えるマジックプレフィックスを利用できます。詳細はVikunja本体のアプリを開き、タスク入力欄のツールチップをご確認ください。",
|
||||
"title": "クイック追加",
|
||||
"intro": "タスクを作成する際に特定のキーワードを使うことで項目を直接追加できます。よく使う項目とともにタスクをすぐ追加できます。",
|
||||
"multiple": "複数使用できます。",
|
||||
|
|
@ -1170,9 +1334,11 @@
|
|||
"none": "通知はありません。よい一日を!",
|
||||
"explainer": "購読中のアクション、プロジェクト、タスクへの変更が発生すると、通知がここに表示されます。",
|
||||
"markAllRead": "通知をすべて既読にする",
|
||||
"markAllReadSuccess": "通知をすべて既読にしました。"
|
||||
"markAllReadSuccess": "通知をすべて既読にしました。",
|
||||
"subscribeFeed": "Atom フィードで通知を購読"
|
||||
},
|
||||
"quickActions": {
|
||||
"notLoggedIn": "まずVikunja本体のウィンドウにログインしてください。",
|
||||
"commands": "コマンド",
|
||||
"placeholder": "コマンドまたはキーワードを入力…",
|
||||
"hint": "{project} を使うとプロジェクトを検索対象にできます。{project} または {label} (ラベル) を検索条件と組み合わせて使うとプロジェクト内のタスクやラベルの付いたタスクを検索できます。{assignee} を使うとチームを検索対象にできます。",
|
||||
|
|
@ -1305,5 +1471,66 @@
|
|||
"weeks": "週間",
|
||||
"years": "年"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理",
|
||||
"labels": {
|
||||
"users": "ユーザー",
|
||||
"tasks": "タスク"
|
||||
},
|
||||
"overview": {
|
||||
"shares": "共有",
|
||||
"linkSharesShort": "リンク",
|
||||
"teamSharesShort": "チーム",
|
||||
"userSharesShort": "ユーザー",
|
||||
"version": "バージョン",
|
||||
"license": "ライセンス",
|
||||
"licenseValidUntil": "有効期限",
|
||||
"licenseExpiresIn": "あと {days} 日",
|
||||
"licenseLastVerified": "最終検証日",
|
||||
"licenseNever": "なし",
|
||||
"licenseLastCheckFailed": "直近のチェックに失敗しました",
|
||||
"licenseFeatures": "機能",
|
||||
"licenseInstance": "インスタンスID",
|
||||
"licenseManage": "管理"
|
||||
},
|
||||
"searchUsersPlaceholder": "ユーザー名またはメールアドレスで検索…",
|
||||
"users": {
|
||||
"status": "ステータス",
|
||||
"details": "詳細",
|
||||
"detailsTitle": "ユーザー: {username}",
|
||||
"issuer": "発行者",
|
||||
"issuerLocal": "ローカル",
|
||||
"issuerUrl": "発行者URL",
|
||||
"subject": "サブジェクト",
|
||||
"statusActive": "有効",
|
||||
"statusEmailConfirmation": "メール確認待ち",
|
||||
"statusDisabled": "無効化済み",
|
||||
"statusLocked": "アカウントロック中",
|
||||
"isAdminLabel": "管理者",
|
||||
"addUser": "ユーザーを追加",
|
||||
"createTitle": "ユーザーを作成",
|
||||
"nameLabel": "名前",
|
||||
"skipEmailConfirm": "メール確認をスキップ",
|
||||
"createSubmit": "ユーザーを作成",
|
||||
"saveButton": "変更を保存",
|
||||
"createdSuccess": "ユーザー {username} を作成しました。",
|
||||
"updatedSuccess": "ユーザー {username} を更新しました。",
|
||||
"deletedSuccess": "ユーザー {username} を削除しました。",
|
||||
"deleteScheduledSuccess": "ユーザー {username} に削除スケジュール確認メールが送信されます。",
|
||||
"confirmDeleteTitle": "ユーザーを削除しますか?",
|
||||
"confirmDeleteIntro": "ユーザー {username} をどのように削除しますか?",
|
||||
"deleteModeScheduled": "削除をスケジュール",
|
||||
"deleteModeScheduledHelp": "削除のスケジュールでは、ユーザー自身によるアカウント削除と同様に、確認メールをユーザーに送信します。",
|
||||
"deleteModeNow": "今すぐ削除",
|
||||
"deleteModeNowHelp": "今すぐ削除は、ユーザーとそのすべてのデータを即時に削除します。この操作は取り消せません。"
|
||||
},
|
||||
"projects": {
|
||||
"ownerLabel": "所有者",
|
||||
"reassignOwner": "所有者を再割り当て",
|
||||
"reassignTitle": "{title} を再割り当て",
|
||||
"reassignedSuccess": "プロジェクトの所有者を再割り当てしました。",
|
||||
"newOwnerLabel": "新しい所有者"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,36 @@
|
|||
},
|
||||
"home": {
|
||||
"welcomeNight": "Goedenacht {username}!",
|
||||
"welcomeNightOwl": "Hey {username}, nachtuil",
|
||||
"welcomeNightBurning": "Tot diep in de nacht doorwerken {username}?",
|
||||
"welcomeNightQuiet": "Stille uurtjes, {username}",
|
||||
"welcomeNightLate": "Het is laat, {username}",
|
||||
"welcomeNightMoonlit": "Maanverlichte planning, {username}?",
|
||||
"welcomeMorning": "Goedemorgen {username}!",
|
||||
"welcomeMorningHey": "Hey {username}, klaar om te gaan?",
|
||||
"welcomeMorningFresh": "Verse start, {username}",
|
||||
"welcomeMorningCoffee": "Koffie en taken, {username}?",
|
||||
"welcomeMorningRise": "Opstaan en plannen, {username}",
|
||||
"welcomeMorningBack": "Welkom terug, {username}",
|
||||
"welcomeMondayFresh": "Verse week, {username}",
|
||||
"welcomeTuesday": "Fijne dinsdag, {username}",
|
||||
"welcomeWednesdayMid": "Midden van de week alweer, {username}",
|
||||
"welcomeThursday": "Bijna klaar, {username}",
|
||||
"welcomeFridayPush": "Vrijdag nog even door, {username}?",
|
||||
"welcomeSaturday": "Weekendstand, {username}",
|
||||
"welcomeSundaySession": "Zondagse sessie, {username}?",
|
||||
"welcomeDay": "Hallo {username}!",
|
||||
"welcomeDayBack": "Weer aan de slag, {username}",
|
||||
"welcomeDayFocus": "Nu met focus, {username}",
|
||||
"welcomeDayKeepGoing": "Blijf doorgaan, {username}",
|
||||
"welcomeDayWhatsNext": "Wat komt er nu, {username}?",
|
||||
"welcomeDayGood": "Goedemiddag {username}",
|
||||
"welcomeEvening": "Goedenavond {username}!",
|
||||
"welcomeEveningWind": "Nu tot rust komen, {username}?",
|
||||
"welcomeEveningReturns": "{username} keert terug",
|
||||
"welcomeEveningWrap": "Tijd om af te ronden, {username}?",
|
||||
"welcomeEveningOneMore": "Nog één ding, {username}?",
|
||||
"welcomeEveningStill": "Nog steeds bezig, {username}?",
|
||||
"lastViewed": "Laatst bekeken",
|
||||
"addToHomeScreen": "Voeg deze app toe aan je startscherm voor snellere toegang en verbeterde ervaring.",
|
||||
"goToOverview": "Ga naar overzicht",
|
||||
|
|
@ -54,6 +80,15 @@
|
|||
"authenticating": "Authenticeren…",
|
||||
"openIdStateError": "Status komt niet overeen, weigert door te gaan!",
|
||||
"openIdGeneralError": "Er was een fout tijdens het authenticeren bij de externe applicatie.",
|
||||
"openIdTotpRequired": "Je account vereist tweestapsverificatie. Voer je TOTP-code in en log opnieuw in.",
|
||||
"openIdTotpSubmit": "Doorgaan",
|
||||
"oauthMissingParams": "Ontbrekende OAuth parameters: {params}",
|
||||
"oauthRedirectedToApp": "Je bent doorgestuurd naar de app. Je kunt dit tabblad nu sluiten.",
|
||||
"desktopTryDemo": "Probeer de demo",
|
||||
"desktopCustomServer": "Aangepaste server-URL",
|
||||
"desktopCustomServerDescription": "Voer de URL in van je Vikunja server om te beginnen.",
|
||||
"desktopWaitingForAuth": "Wachten op authenticatie…",
|
||||
"desktopOAuthError": "Authenticatie mislukt: {error}",
|
||||
"logout": "Uitloggen",
|
||||
"emailInvalid": "Vul een geldig e-mailadres in.",
|
||||
"usernameRequired": "Geef een gebruikersnaam op.",
|
||||
|
|
@ -68,9 +103,23 @@
|
|||
"alreadyHaveAnAccount": "Heb je al een account?",
|
||||
"remember": "Ingelogd blijven",
|
||||
"registrationDisabled": "Registratie is uitgeschakeld.",
|
||||
"passwordResetTokenMissing": "Wachtwoord reset token ontbreekt."
|
||||
"passwordResetTokenMissing": "Wachtwoord reset token ontbreekt.",
|
||||
"registrationFailed": "Er is een fout opgetreden tijdens de registratie. Controleer je invoer en probeer opnieuw."
|
||||
},
|
||||
"settings": {
|
||||
"bots": {
|
||||
"title": "Bot-gebruikers",
|
||||
"description": "Bot-gebruikers zijn API-only gebruikers waar jij eigenaar van bent. Ze kunnen worden toegevoegd aan projecten, taken krijgen en authenticeren met API-tokens. Ze kunnen niet interactief inloggen.",
|
||||
"namePlaceholder": "Mijn Assistent",
|
||||
"create": "Bot aanmaken",
|
||||
"enable": "Inschakelen",
|
||||
"badge": "Bot",
|
||||
"delete": {
|
||||
"header": "Verwijder deze bot-gebruiker",
|
||||
"text1": "Weet je zeker dat je bot-gebruiker \"{username}\" wilt verwijderen?",
|
||||
"text2": "Dit is onomkeerbaar. Alle API-tokens die bij deze bot horen, worden ingetrokken."
|
||||
}
|
||||
},
|
||||
"title": "Instellingen",
|
||||
"newPasswordTitle": "Je wachtwoord bijwerken",
|
||||
"newPassword": "Nieuw wachtwoord",
|
||||
|
|
@ -96,12 +145,21 @@
|
|||
"weekStart": "Week begint op",
|
||||
"weekStartSunday": "Zondag",
|
||||
"weekStartMonday": "Maandag",
|
||||
"weekStartTuesday": "Dinsdag",
|
||||
"weekStartWednesday": "Woensdag",
|
||||
"weekStartThursday": "Donderdag",
|
||||
"weekStartFriday": "Vrijdag",
|
||||
"weekStartSaturday": "Zaterdag",
|
||||
"language": "Taal",
|
||||
"defaultProject": "Standaardproject",
|
||||
"defaultView": "Standaardweergave",
|
||||
"timezone": "Tijdzone",
|
||||
"overdueTasksRemindersTime": "Tijdstip herinneringsmail voor achterstallige taken",
|
||||
"quickAddDefaultReminders": "Standaardherinneringen voor Snel Toevoegen",
|
||||
"quickAddDefaultRemindersDescription": "Deze herinneringen worden automatisch toegevoegd aan elke taak die is gemaakt via Magisch Snel Toevoegen met een vervaldatum.",
|
||||
"quickAddDefaultRemindersHint": "Voeg herinnering(en) toe ten opzichte van de vervaldatum van de taak. Laat leeg om uit te schakelen.",
|
||||
"filterUsedOnOverview": "Opgeslagen filter toegepast op de overzichtspagina",
|
||||
"showLastViewed": "Toon laatst bekeken projecten op de overzichtspagina",
|
||||
"minimumPriority": "Minimale zichtbare taakprioriteit",
|
||||
"dateDisplay": "Datumweergave",
|
||||
"dateDisplayOptions": {
|
||||
|
|
@ -125,7 +183,13 @@
|
|||
"taskAndNotifications": "Projecten & taken",
|
||||
"privacy": "Privacy",
|
||||
"localization": "Lokalisatie",
|
||||
"appearance": "Uiterlijk & gedrag"
|
||||
"appearance": "Uiterlijk & gedrag",
|
||||
"desktop": "Desktop app"
|
||||
},
|
||||
"desktop": {
|
||||
"quickEntryShortcut": "Sneltoets voor snelle invoer",
|
||||
"shortcutRecorderPlaceholder": "Klik om sneltoets in te stellen",
|
||||
"shortcutRecorderRecording": "Druk op een toetscombinatie…"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Tweestapsverificatie",
|
||||
|
|
@ -135,15 +199,32 @@
|
|||
"scanQR": "Als alternatief kan je ook deze QR code scannen:",
|
||||
"passcode": "Je toegangscode",
|
||||
"passcodePlaceholder": "Een code gegenereerd door je TOTP-app",
|
||||
"confirmNotice": "Na het inschakelen van tweestapsverificatie, wordt je uitgelogd uit alle sessies en moet je opnieuw inloggen.",
|
||||
"setupSuccess": "Je hebt tweestapsverificatie succesvol ingesteld!",
|
||||
"enterPassword": "Voer alsjeblieft je wachtwoord in",
|
||||
"disable": "Tweestapsverificatie uitschakelen",
|
||||
"confirmSuccess": "Je hebt tweestapsverificatie succesvol ingeschakeld!",
|
||||
"disableSuccess": "Uitschakelen tweestapsverificatie is geslaagd."
|
||||
},
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"howTo": "Je kunt Vikunja verbinden met CalDAV-clients om taken te bekijken en beheren vanuit verschillende clients. Voer deze url in bij je client:",
|
||||
"more": "Meer informatie over CalDAV in Vikunja",
|
||||
"tokens": "CalDAV tokens"
|
||||
"tokens": "CalDAV tokens",
|
||||
"tokensHowTo": "Voor CalDAV-authenticatie gebruik je jouw normale accountwachtwoord of een speciaal CalDAV-token.",
|
||||
"createToken": "Maak een CalDAV-token",
|
||||
"tokenCreated": "Hier is je nieuwe token: {token}",
|
||||
"wontSeeItAgain": "Schrijf het op of bewaar het veilig - je kunt het hierna niet meer inzien.",
|
||||
"mustUseToken": "Je moet een CalDAV-token aanmaken om CalDAV te gebruiken met een externe client. Gebruik het token in het wachtwoordveld van uw client.",
|
||||
"usernameIs": "Je gebruikersnaam voor CalDAV is: {0}",
|
||||
"apiTokenHint": "Je kunt ook een API-token gebruiken met CalDAV-permissie. Maak er een aan in {link}."
|
||||
},
|
||||
"feeds": {
|
||||
"title": "Atom Feed",
|
||||
"howTo": "Je kunt je abonneren op je Vikunja meldingen met elke Atom-compatibele feedlezer. Gebruik deze URL:",
|
||||
"usernameIs": "Je gebruikersnaam voor de feed is: {0}",
|
||||
"apiTokenHint": "Authenticeer met een API-token die {scope} permissies heeft. Maak er een aan in {link}.",
|
||||
"tokenTitle": "Atom feed"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Avatar",
|
||||
|
|
@ -174,6 +255,10 @@
|
|||
"backgroundBrightness": {
|
||||
"title": "Achtergrond helderheid"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhook notificaties",
|
||||
"description": "Configureer webhook-URL's om POST aanvragen te ontvangen wanneer herinneringen of achterstallige gebeurtenissen worden geactiveerd. Deze webhooks ontvangen gebeurtenissen van al je projecten."
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API tokens",
|
||||
"general": "Met API tokens kun je Vikunja's API gebruiken zonder gebruikersnaam en wachtwoord.",
|
||||
|
|
@ -189,6 +274,13 @@
|
|||
"expired": "Dit token is verlopen {ago}.",
|
||||
"tokenCreatedSuccess": "Hier is je API-token: {token}",
|
||||
"tokenCreatedNotSeeAgain": "Bewaar het op een veilige locatie, het wordt slechts één keer getoond!",
|
||||
"presets": {
|
||||
"title": "Snelle voorkeursinstellingen",
|
||||
"readOnly": "Alleen-lezen",
|
||||
"tasks": "Taakbeheer",
|
||||
"projects": "Projectmanagement",
|
||||
"fullAccess": "Volledige toegang"
|
||||
},
|
||||
"delete": {
|
||||
"header": "Dit token verwijderen",
|
||||
"text1": "Weet je zeker dat je token \"{token}\" wilt verwijderen?",
|
||||
|
|
@ -200,6 +292,20 @@
|
|||
"expiresAt": "Verloopt op",
|
||||
"permissions": "Machtigingen"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessies",
|
||||
"description": "Dit zijn alle apparaten die momenteel zijn ingelogd op je account. Je kunt elke sessie intrekken om dat apparaat uit te loggen. Het kan tot 10 minuten duren voordat de intrekking volledig van kracht is.",
|
||||
"deviceInfo": "Apparaat",
|
||||
"ipAddress": "IP-adres",
|
||||
"lastActive": "Laatst actief",
|
||||
"current": "Huidige sessie",
|
||||
"delete": {
|
||||
"header": "Sessie intrekken",
|
||||
"text": "Weet je zeker dat je deze sessie wilt intrekken? Het apparaat wordt uitgelogd. Het kan tot 10 minuten duren voordat de sessie volledig is verlopen."
|
||||
},
|
||||
"deleteSuccess": "De sessie is ingetrokken. Het kan tot 10 minuten duren voordat de sessie volledig is verlopen.",
|
||||
"noOtherSessions": "Geen andere actieve sessies."
|
||||
}
|
||||
},
|
||||
"deletion": {
|
||||
|
|
@ -354,7 +460,8 @@
|
|||
"addPlaceholder": "Taak toevoegen…",
|
||||
"empty": "Dit project is momenteel leeg.",
|
||||
"newTaskCta": "Taak aanmaken.",
|
||||
"editTask": "Taak bewerken"
|
||||
"editTask": "Taak bewerken",
|
||||
"sort": "Sorteren"
|
||||
},
|
||||
"gantt": {
|
||||
"title": "Gantt",
|
||||
|
|
@ -380,7 +487,10 @@
|
|||
"taskAriaLabel": "Taak: {task}",
|
||||
"taskAriaLabelById": "Taak {id}",
|
||||
"partialDatesStart": "Alleen startdatum (open-einde)",
|
||||
"partialDatesEnd": "Alleen einddatum (open-einde)"
|
||||
"partialDatesEnd": "Alleen einddatum (open-einde)",
|
||||
"expandGroup": "Groep uitklappen: {task}",
|
||||
"collapseGroup": "Groep inklappen: {task}",
|
||||
"toggleRelationArrows": "Relatiepijlen wisselen"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabel",
|
||||
|
|
@ -410,7 +520,8 @@
|
|||
"bucketTitleSavedSuccess": "De categorietitel is succesvol opgeslagen.",
|
||||
"bucketLimitSavedSuccess": "De categorielimiet is succesvol opgeslagen.",
|
||||
"collapse": "Deze categorie inklappen",
|
||||
"bucketLimitReached": "U heeft de categorielimiet bereikt. Verwijder taken of verhoog de limiet om nieuwe taken toe te voegen."
|
||||
"bucketLimitReached": "U heeft de categorielimiet bereikt. Verwijder taken of verhoog de limiet om nieuwe taken toe te voegen.",
|
||||
"bucketOptions": "Categorie-opties"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
@ -533,6 +644,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sorting": {
|
||||
"manually": "Handmatig",
|
||||
"apply": "Sortering toepassen",
|
||||
"description": "Kies hoe taken in deze lijst worden gesorteerd. Bij handmatig sorteren kun je taken verslepen om ze anders te ordenen.",
|
||||
"options": {
|
||||
"titleAsc": "Titel (A–Z)",
|
||||
"titleDesc": "Titel (Z–A)",
|
||||
"priorityDesc": "Prioriteit (hoogste eerst)",
|
||||
"priorityAsc": "Prioriteit (laagste eerst)",
|
||||
"dueDateAsc": "Vervaldatum (vroegste eerst)",
|
||||
"dueDateDesc": "Vervaldatum (laatste eerst)",
|
||||
"startDateAsc": "Startdatum (vroegste eerst)",
|
||||
"startDateDesc": "Startdatum (laatste eerst)",
|
||||
"endDateAsc": "Einddatum (vroegste eerst)",
|
||||
"endDateDesc": "Einddatum (laatste eerst)",
|
||||
"percentDoneDesc": "% gereed (meest gereed eerst)",
|
||||
"percentDoneAsc": "% gereed (minst gereed eerst)",
|
||||
"createdDesc": "Aangemaakt (nieuwste eerst)",
|
||||
"createdAsc": "Aangemaakt (oudste eerst)",
|
||||
"updatedDesc": "Bijgewerkt (nieuwste eerst)",
|
||||
"updatedAsc": "Bijgewerkt (oudste eerst)"
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
"title": "Importeer vanuit een andere dienst",
|
||||
"titleService": "Importeer je gegevens van {name} naar Vikunja",
|
||||
|
|
@ -547,7 +681,30 @@
|
|||
"importUpload": "Om gegevens van {name} te importeren in Vikunja, klik je op de knop hieronder om een bestand te kiezen.",
|
||||
"upload": "Bestand uploaden",
|
||||
"migrationStartedWillReciveEmail": "Vikunja gaat nu je lijsten/projecten, taken, notities, herinneringen en bestanden van {service} importeren. Omdat dit een tijdje zal duren, sturen we je een e-mail zodra het klaar is. Je kunt dit venster nu sluiten.",
|
||||
"migrationInProgress": "Er is momenteel een migratie aan de gang. Wacht tot dit voltooid is."
|
||||
"migrationInProgress": "Er is momenteel een migratie aan de gang. Wacht tot dit voltooid is.",
|
||||
"csv": {
|
||||
"description": "Importeer taken uit een CSV-bestand met aangepaste kolomtoewijzing.",
|
||||
"uploadDescription": "Kies een CSV-bestand om te importeren. Het bestand moet taakgegevens bevatten met kolomkoppen in de eerste rij.",
|
||||
"selectFile": "Kies CSV-bestand",
|
||||
"columnMappingDescription": "Wijs elke kolom in je CSV-bestand toe aan een taakattribuut. Vikunja heeft de meest waarschijnlijke toewijzingen automatisch gedetecteerd. Het voorbeeld hieronder wordt automatisch bijgewerkt wanneer je de instellingen aanpast.",
|
||||
"parsingOptions": "Opties voor verwerking",
|
||||
"delimiter": "Scheidingsteken",
|
||||
"dateFormat": "Datumnotatie",
|
||||
"skipRows": "Rijen overslaan",
|
||||
"mapColumns": "Kolommen toewijzen",
|
||||
"example": "bijv.",
|
||||
"preview": "Voorbeeld",
|
||||
"previewDescription": "Toont de eerste 5 van {count} taken die zullen worden geïmporteerd.",
|
||||
"import": "Taken importeren",
|
||||
"untitled": "Naamloze taak",
|
||||
"ignore": "Negeren",
|
||||
"delimiters": {
|
||||
"comma": "Komma (,)",
|
||||
"semicolon": "Puntkomma (;)",
|
||||
"tab": "Tab",
|
||||
"pipe": "Pijp (|)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"title": "Labels",
|
||||
|
|
@ -587,7 +744,9 @@
|
|||
"upcoming": "Aankomend",
|
||||
"settings": "Instellingen",
|
||||
"imprint": "Imprint",
|
||||
"privacy": "Privacybeleid"
|
||||
"privacy": "Privacybeleid",
|
||||
"closeSidebar": "Zijbalk sluiten",
|
||||
"home": "Vikunja home"
|
||||
},
|
||||
"misc": {
|
||||
"loading": "Bezig met laden…",
|
||||
|
|
@ -619,9 +778,15 @@
|
|||
"createdBy": "Aangemaakt door {0}",
|
||||
"actions": "Acties",
|
||||
"cannotBeUndone": "Dit kan niet ongedaan gemaakt worden!",
|
||||
"avatarOfUser": "{user}'s profielfoto"
|
||||
"avatarOfUser": "{user}'s profielfoto",
|
||||
"closeBanner": "Banner sluiten",
|
||||
"closeDialog": "Dialoogvenster sluiten",
|
||||
"closeQuickActions": "Snelle acties sluiten",
|
||||
"skipToContent": "Direct naar hoofdinhoud",
|
||||
"sortBy": "Sorteren op"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projectkleur",
|
||||
"resetColor": "Kleur resetten",
|
||||
"datepicker": {
|
||||
"today": "Vandaag",
|
||||
|
|
@ -681,6 +846,9 @@
|
|||
"toggleHeaderCell": "Celkopteksten in-/uitschakelen",
|
||||
"mergeOrSplit": "Samenvoegen of splitsen",
|
||||
"fixTables": "Tabellen repareren"
|
||||
},
|
||||
"emoji": {
|
||||
"empty": "Geen emoji gevonden"
|
||||
}
|
||||
},
|
||||
"multiselect": {
|
||||
|
|
@ -694,6 +862,7 @@
|
|||
"date": "Datum",
|
||||
"ranges": {
|
||||
"today": "Vandaag",
|
||||
"tomorrow": "Morgen",
|
||||
"thisWeek": "Deze week",
|
||||
"restOfThisWeek": "De rest van deze week",
|
||||
"nextWeek": "Volgende week",
|
||||
|
|
@ -767,6 +936,7 @@
|
|||
"addReminder": "Herinnering toevoegen…",
|
||||
"doneSuccess": "De taak is succesvol aangemerkt als voltooid.",
|
||||
"undoneSuccess": "Het voltooien van de taak is succesvol teruggedraaid.",
|
||||
"readOnlyCheckbox": "Je hebt alleen leestoegang tot deze taak en kunt deze niet als voltooid markeren.",
|
||||
"movedToProject": "De taak werd verplaatst naar {project}.",
|
||||
"undo": "Ongedaan maken",
|
||||
"checklistTotal": "{checked} van {total} taken",
|
||||
|
|
@ -779,7 +949,8 @@
|
|||
"select": "Selecteer datumbereik",
|
||||
"noTasks": "Niets te doen - fijne dag!",
|
||||
"filterByLabel": "Gefilterd op label {label}",
|
||||
"clearLabelFilter": "Wis labelfilter"
|
||||
"clearLabelFilter": "Wis labelfilter",
|
||||
"savedFilterIgnored": "Je opgeslagen startpagina-filter wordt niet toegepast als je taken bekijkt per label."
|
||||
},
|
||||
"detail": {
|
||||
"chooseDueDate": "Klik hier om een vervaldatum in te stellen",
|
||||
|
|
@ -793,9 +964,15 @@
|
|||
"doneAt": "{0} voltooid",
|
||||
"updateSuccess": "De taak is succesvol opgeslagen.",
|
||||
"deleteSuccess": "De taak is succesvol verwijderd.",
|
||||
"duplicateSuccess": "De taak is succesvol gedupliceerd.",
|
||||
"noBucket": "Geen categorie",
|
||||
"bucketChangedSuccess": "De taakcategorie is succesvol gewijzigd.",
|
||||
"belongsToProject": "Deze taak hoort bij project '{project}'",
|
||||
"back": "Terug naar project",
|
||||
"due": "Vervalt {at}",
|
||||
"closeTaskDetail": "Sluit taakdetails",
|
||||
"title": "Taakdetails",
|
||||
"markAsDone": "Markeer '{task}' als gereed",
|
||||
"scrollToBottom": "Scroll naar beneden",
|
||||
"organization": "Organisatie",
|
||||
"management": "Beheer",
|
||||
|
|
@ -818,6 +995,7 @@
|
|||
"attachments": "Bijlagen toevoegen",
|
||||
"relatedTasks": "Relatie toevoegen",
|
||||
"moveProject": "Verplaatsen",
|
||||
"duplicate": "Dupliceer",
|
||||
"color": "Kleur instellen",
|
||||
"delete": "Verwijder",
|
||||
"favorite": "Toevoegen aan favorieten",
|
||||
|
|
@ -840,8 +1018,8 @@
|
|||
"relatedTasks": "Verwante Taken",
|
||||
"reminders": "Herinneringen",
|
||||
"repeat": "Herhalen",
|
||||
"comment": "{count} opmerking | {count} opmerkingen",
|
||||
"commentCount": "Aantal opmerkingen",
|
||||
"comment": "{count} reactie | {count} reacties",
|
||||
"commentCount": "Aantal reacties",
|
||||
"startDate": "Begindatum",
|
||||
"title": "Titel",
|
||||
"updated": "Bijgewerkt",
|
||||
|
|
@ -877,7 +1055,7 @@
|
|||
},
|
||||
"comment": {
|
||||
"title": "Reacties",
|
||||
"loading": "Bezig met laden van reacties…",
|
||||
"loading": "Reacties laden…",
|
||||
"edited": "bewerkt op {date}",
|
||||
"creating": "Opmerking maken…",
|
||||
"placeholder": "Voeg je reactie toe, druk op '/' voor meer opties…",
|
||||
|
|
@ -888,7 +1066,10 @@
|
|||
"addedSuccess": "De reactie is succesvol toegevoegd.",
|
||||
"permalink": "Kopieer permalink naar deze reactie",
|
||||
"sortNewestFirst": "Nieuwste eerst",
|
||||
"sortOldestFirst": "Oudste eerst"
|
||||
"sortOldestFirst": "Oudste eerst",
|
||||
"reply": "Beantwoorden",
|
||||
"jumpToOriginal": "Ga naar originele reactie",
|
||||
"deletedComment": "verwijderde reactie"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "Geen gebruikers gevonden"
|
||||
|
|
@ -980,6 +1161,7 @@
|
|||
},
|
||||
"quickAddMagic": {
|
||||
"hint": "Gebruik magische prefixes om vervaldata, toegewezen personen en andere taakeigenschappen te definiëren.",
|
||||
"quickEntryHint": "Gebruik magische voorvoegsels voor datums, labels en meer. Open de Vikunja hoofd-app en bekijk de tooltip op de taakinvoer voor meer details.",
|
||||
"title": "Snel-toevoegen magie",
|
||||
"intro": "Bij het aanmaken van een taak kun je speciale trefwoorden gebruiken om direct kenmerken toe te voegen aan de nieuwe taak. Hiermee kun je veelgebruikte kenmerken veel sneller toevoegen aan taken.",
|
||||
"multiple": "Je kan dit meerdere keren gebruiken.",
|
||||
|
|
@ -1152,9 +1334,11 @@
|
|||
"none": "Je hebt geen meldingen. Fijne dag!",
|
||||
"explainer": "Hier verschijnen meldingen wanneer acties, projecten of taken gebeuren waarop u bent geabonneerd.",
|
||||
"markAllRead": "Markeer alle meldingen als gelezen",
|
||||
"markAllReadSuccess": "Alle meldingen zijn als gelezen gemarkeerd."
|
||||
"markAllReadSuccess": "Alle meldingen zijn als gelezen gemarkeerd.",
|
||||
"subscribeFeed": "Abonneren op meldingen via Atom feed"
|
||||
},
|
||||
"quickActions": {
|
||||
"notLoggedIn": "Log eerst in op het Vikunja hoofdscherm.",
|
||||
"commands": "Opdrachten",
|
||||
"placeholder": "Typ een opdracht of zoek…",
|
||||
"hint": "Je kunt {project} gebruiken om het zoeken te beperken tot een project. Combineer {project} of {label} (labels) met een zoekopdracht om te zoeken naar een taak met deze labels of op dat project. Gebruik {assignee} om alleen te zoeken naar teams.",
|
||||
|
|
@ -1287,5 +1471,66 @@
|
|||
"weeks": "week|weken",
|
||||
"years": "jaar|jaren"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Beheer",
|
||||
"labels": {
|
||||
"users": "Gebruikers",
|
||||
"tasks": "Taken"
|
||||
},
|
||||
"overview": {
|
||||
"shares": "Deelbare koppelingen",
|
||||
"linkSharesShort": "link",
|
||||
"teamSharesShort": "team",
|
||||
"userSharesShort": "gebruiker",
|
||||
"version": "Versie",
|
||||
"license": "Licentie",
|
||||
"licenseValidUntil": "Geldig tot",
|
||||
"licenseExpiresIn": "over {days} dagen",
|
||||
"licenseLastVerified": "Laatst geverifieerd",
|
||||
"licenseNever": "nooit",
|
||||
"licenseLastCheckFailed": "laatste controle mislukt",
|
||||
"licenseFeatures": "Functionaliteiten",
|
||||
"licenseInstance": "Instance ID",
|
||||
"licenseManage": "Beheren"
|
||||
},
|
||||
"searchUsersPlaceholder": "Zoek op gebruikersnaam of e-mail…",
|
||||
"users": {
|
||||
"status": "Status",
|
||||
"details": "Details",
|
||||
"detailsTitle": "Gebruiker: {username}",
|
||||
"issuer": "Uitgever",
|
||||
"issuerLocal": "Lokaal",
|
||||
"issuerUrl": "Uitgever URL",
|
||||
"subject": "Onderwerp",
|
||||
"statusActive": "Actief",
|
||||
"statusEmailConfirmation": "E-mailbevestiging vereist",
|
||||
"statusDisabled": "Uitgeschakeld",
|
||||
"statusLocked": "Account vergrendeld",
|
||||
"isAdminLabel": "Beheerder",
|
||||
"addUser": "Gebruiker toevoegen",
|
||||
"createTitle": "Gebruiker aanmaken",
|
||||
"nameLabel": "Naam",
|
||||
"skipEmailConfirm": "E-mailbevestiging overslaan",
|
||||
"createSubmit": "Gebruiker aanmaken",
|
||||
"saveButton": "Wijzigingen opslaan",
|
||||
"createdSuccess": "Gebruiker {username} aangemaakt.",
|
||||
"updatedSuccess": "Gebruiker {username} bijgewerkt.",
|
||||
"deletedSuccess": "Gebruiker {username} verwijderd.",
|
||||
"deleteScheduledSuccess": "Gebruiker {username} ontvangt een bevestigingsmail om de verwijdering te plannen.",
|
||||
"confirmDeleteTitle": "Gebruiker verwijderen?",
|
||||
"confirmDeleteIntro": "Hoe moet gebruiker {username} worden verwijderd?",
|
||||
"deleteModeScheduled": "Verwijdering plannen",
|
||||
"deleteModeScheduledHelp": "Bij 'verwijdering plannen' ontvangt de gebruiker een bevestigingsmail, lijkend op een zelfgestarte accountverwijdering.",
|
||||
"deleteModeNow": "Nu verwijderen",
|
||||
"deleteModeNowHelp": "'Nu verwijderen' verwijdert de gebruiker en hun data onmiddellijk. Dit kan niet ongedaan worden gemaakt."
|
||||
},
|
||||
"projects": {
|
||||
"ownerLabel": "Eigenaar",
|
||||
"reassignOwner": "Nieuwe eigenaar toewijzen",
|
||||
"reassignTitle": "Opnieuw toewijzen {title}",
|
||||
"reassignedSuccess": "Projecteigenaar opnieuw toegewezen.",
|
||||
"newOwnerLabel": "Nieuwe eigenaar"
|
||||
}
|
||||
}
|
||||
}
|
||||
694
magefile.go
694
magefile.go
|
|
@ -43,7 +43,6 @@ import (
|
|||
|
||||
"github.com/iancoleman/strcase"
|
||||
"github.com/magefile/mage/mg"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -62,27 +61,21 @@ var (
|
|||
|
||||
// Aliases are mage aliases of targets
|
||||
Aliases = map[string]any{
|
||||
"build": Build.Build,
|
||||
"check:got-swag": Check.GotSwag,
|
||||
"release": Release.Release,
|
||||
"release:os-package": Release.OsPackage,
|
||||
"release:prepare-nfpm-config": Release.PrepareNFPMConfig,
|
||||
"release:repo-apt": Release.RepoApt,
|
||||
"release:repo-rpm": Release.RepoRpm,
|
||||
"release:repo-pacman": Release.RepoPacman,
|
||||
"dev:make-migration": Dev.MakeMigration,
|
||||
"dev:make-event": Dev.MakeEvent,
|
||||
"dev:make-listener": Dev.MakeListener,
|
||||
"dev:make-notification": Dev.MakeNotification,
|
||||
"dev:prepare-worktree": Dev.PrepareWorktree,
|
||||
"dev:tag-release": Dev.TagRelease,
|
||||
"test:e2e": Test.E2E,
|
||||
"test:e2e-api": Test.E2EApi,
|
||||
"plugins:build": Plugins.Build,
|
||||
"lint": Check.Golangci,
|
||||
"lint:fix": Check.GolangciFix,
|
||||
"generate:config-yaml": Generate.ConfigYAML,
|
||||
"generate:swagger-docs": Generate.SwaggerDocs,
|
||||
"build": Build.Build,
|
||||
"check:got-swag": Check.GotSwag,
|
||||
"dev:make-migration": Dev.MakeMigration,
|
||||
"dev:make-event": Dev.MakeEvent,
|
||||
"dev:make-listener": Dev.MakeListener,
|
||||
"dev:make-notification": Dev.MakeNotification,
|
||||
"dev:prepare-worktree": Dev.PrepareWorktree,
|
||||
"dev:tag-release": Dev.TagRelease,
|
||||
"test:e2e": Test.E2E,
|
||||
"test:e2e-api": Test.E2EApi,
|
||||
"plugins:build": Plugins.Build,
|
||||
"lint": Check.Golangci,
|
||||
"lint:fix": Check.GolangciFix,
|
||||
"generate:config-yaml": Generate.ConfigYAML,
|
||||
"generate:swagger-docs": Generate.SwaggerDocs,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -268,45 +261,6 @@ func copyFile(src, dst string) error {
|
|||
return out.Close()
|
||||
}
|
||||
|
||||
// os.Rename has issues with moving files between docker volumes.
|
||||
// Because of this limitation, it fails in drone.
|
||||
// Source: https://gist.github.com/var23rav/23ae5d0d4d830aff886c3c970b8f6c6b
|
||||
func moveFile(src, dst string) error {
|
||||
inputFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't open source file: %w", err)
|
||||
}
|
||||
defer inputFile.Close()
|
||||
|
||||
outputFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't open dest file: %w", err)
|
||||
}
|
||||
defer outputFile.Close()
|
||||
|
||||
_, err = io.Copy(outputFile, inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing to output file failed: %w", err)
|
||||
}
|
||||
|
||||
// Make sure to copy copy the permissions of the original file as well
|
||||
si, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Chmod(dst, si.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The copy was successful, so now delete the original file
|
||||
err = os.Remove(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed removing original file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendToFile(filename, content string) error {
|
||||
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
|
|
@ -1180,624 +1134,6 @@ func (Build) SaveVersionToFile() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
type Release mg.Namespace
|
||||
|
||||
// Release runs all steps in the right order to create release packages for various platforms
|
||||
func (Release) Release(ctx context.Context) error {
|
||||
mg.Deps(initVars)
|
||||
mg.Deps(Release.Dirs, prepareXgo)
|
||||
|
||||
// Run compiling in parallel to speed it up
|
||||
errs, _ := errgroup.WithContext(ctx)
|
||||
errgroupGoWithContext(ctx, errs, (Release{}).Windows)
|
||||
errgroupGoWithContext(ctx, errs, (Release{}).Linux)
|
||||
errgroupGoWithContext(ctx, errs, (Release{}).Darwin)
|
||||
if err := errs.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := (Release{}).Compress(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := (Release{}).Copy(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := (Release{}).Check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := (Release{}).OsPackage(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := (Release{}).Zip(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func errgroupGoWithContext(ctx context.Context, errs *errgroup.Group, do func(context.Context) error) {
|
||||
errs.Go(func() error {
|
||||
return do(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// Dirs creates all directories needed to release vikunja
|
||||
func (Release) Dirs() error {
|
||||
for _, d := range []string{"binaries", "release", "zip"} {
|
||||
if err := os.MkdirAll("./"+DIST+"/"+d, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareXgo(ctx context.Context) error {
|
||||
mg.Deps(initVars)
|
||||
if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Pulling latest xgo docker image...")
|
||||
return runAndStreamOutput(ctx, "docker", "pull", "ghcr.io/techknowlogick/xgo:latest")
|
||||
}
|
||||
|
||||
func runXgo(ctx context.Context, targets string) error {
|
||||
mg.Deps(initVars)
|
||||
if err := checkAndInstallGoTool(ctx, "xgo", "src.techknowlogick.com/xgo"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extraLdflags := `-linkmode external -extldflags "-static" `
|
||||
|
||||
// See https://github.com/techknowlogick/xgo/issues/79
|
||||
if strings.HasPrefix(targets, "darwin") {
|
||||
extraLdflags = ""
|
||||
}
|
||||
outName := os.Getenv("XGO_OUT_NAME")
|
||||
if outName == "" {
|
||||
outName = Executable + "-" + Version
|
||||
}
|
||||
|
||||
if err := runAndStreamOutput(ctx, "xgo",
|
||||
"-dest", "./"+DIST+"/binaries",
|
||||
"-tags", "netgo "+Tags,
|
||||
"-ldflags", extraLdflags+Ldflags,
|
||||
"-targets", targets,
|
||||
"-out", outName,
|
||||
"."); err != nil {
|
||||
return err
|
||||
}
|
||||
if os.Getenv("DRONE_WORKSPACE") != "" {
|
||||
return filepath.Walk("/build/", func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return moveFile(path, "./"+DIST+"/binaries/"+info.Name())
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Windows builds binaries for windows
|
||||
func (Release) Windows(ctx context.Context) error {
|
||||
return runXgo(ctx, "windows/*")
|
||||
}
|
||||
|
||||
// Linux builds binaries for linux
|
||||
func (Release) Linux(ctx context.Context) error {
|
||||
targets := []string{
|
||||
"linux/amd64",
|
||||
"linux/arm-5",
|
||||
"linux/arm-6",
|
||||
"linux/arm-7",
|
||||
"linux/arm64",
|
||||
"linux/mips",
|
||||
"linux/mipsle",
|
||||
"linux/mips64",
|
||||
"linux/mips64le",
|
||||
"linux/riscv64",
|
||||
}
|
||||
return runXgo(ctx, strings.Join(targets, ","))
|
||||
}
|
||||
|
||||
// Darwin builds binaries for darwin
|
||||
func (Release) Darwin(ctx context.Context) error {
|
||||
return runXgo(ctx, "darwin-10.15/*")
|
||||
}
|
||||
|
||||
func (Release) Xgo(ctx context.Context, target string) error {
|
||||
parts := strings.Split(target, "/")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid target")
|
||||
}
|
||||
|
||||
variant := ""
|
||||
if len(parts) > 2 && parts[2] != "" {
|
||||
variant = "-" + strings.ReplaceAll(parts[2], "v", "")
|
||||
}
|
||||
|
||||
return runXgo(ctx, parts[0]+"/"+parts[1]+variant)
|
||||
}
|
||||
|
||||
// Compress compresses the built binaries in dist/binaries/ to reduce their filesize
|
||||
func (Release) Compress(ctx context.Context) error {
|
||||
// $(foreach file,$(filter-out $(wildcard $(wildcard $(DIST)/binaries/$(EXECUTABLE)-*mips*)),$(wildcard $(DIST)/binaries/$(EXECUTABLE)-*)), upx -9 $(file);)
|
||||
|
||||
errs, _ := errgroup.WithContext(ctx)
|
||||
|
||||
walkErr := filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only executable files
|
||||
if !strings.Contains(info.Name(), Executable) {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(info.Name(), "mips") ||
|
||||
strings.Contains(info.Name(), "s390x") ||
|
||||
strings.Contains(info.Name(), "riscv64") ||
|
||||
strings.Contains(info.Name(), "darwin") ||
|
||||
(strings.Contains(info.Name(), "windows") && strings.Contains(info.Name(), "arm64")) {
|
||||
// not supported by upx
|
||||
return nil
|
||||
}
|
||||
|
||||
// Runs compressing in parallel since upx is single-threaded
|
||||
errs.Go(func() error {
|
||||
if err := runAndStreamOutput(ctx, "chmod", "+x", path); err != nil { // Make sure all binaries are executable. Sometimes the CI does weird things and they're not.
|
||||
return err
|
||||
}
|
||||
return runAndStreamOutput(ctx, "upx", "-9", path)
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
return errs.Wait()
|
||||
}
|
||||
|
||||
// Copy copies all built binaries to dist/release/ in preparation for creating the os packages
|
||||
func (Release) Copy() error {
|
||||
return filepath.Walk("./"+DIST+"/binaries/", func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only executable files
|
||||
if !strings.Contains(info.Name(), Executable) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return copyFile(path, "./"+DIST+"/release/"+info.Name())
|
||||
})
|
||||
}
|
||||
|
||||
// Check creates sha256 checksum files for each binary in dist/release/
|
||||
func (Release) Check() error {
|
||||
p := "./" + DIST + "/release/"
|
||||
return filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Create(p + info.Name() + ".sha256")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := calculateSha256FileHash(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = f.WriteString(hash + " " + info.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// OsPackage creates a folder for each
|
||||
func (Release) OsPackage() error {
|
||||
p := "./" + DIST + "/release/"
|
||||
|
||||
// We first put all files in a map to then iterate over it since the walk function would otherwise also iterate
|
||||
// over the newly created files, creating some kind of endless loop.
|
||||
bins := make(map[string]os.FileInfo)
|
||||
if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(info.Name(), ".sha256") || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
bins[path] = info
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
generateConfigYAMLFromJSON("./"+DefaultConfigYAMLSamplePath, true)
|
||||
|
||||
for path, info := range bins {
|
||||
folder := p + info.Name() + "-full/"
|
||||
if err := os.Mkdir(folder, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := moveFile(p+info.Name()+".sha256", folder+info.Name()+".sha256"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := moveFile(path, folder+info.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyFile("./"+DefaultConfigYAMLSamplePath, folder+DefaultConfigYAMLSamplePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyFile("./LICENSE", folder+"LICENSE"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Zip creates a zip file from all os-package folders in dist/release
|
||||
func (Release) Zip(ctx context.Context) error {
|
||||
rootDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get working directory: %w", err)
|
||||
}
|
||||
|
||||
p := "./" + DIST + "/release/"
|
||||
if err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() || info.Name() == "release" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Zipping %s...\n", info.Name())
|
||||
|
||||
zipFile := filepath.Join(rootDir, DIST, "zip", info.Name()+".zip")
|
||||
c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*") //nolint:gosec // This mage task creates zips of every directory recursively, it must use the directory name in the resulting file path to distinguish output files.
|
||||
c.Dir = path
|
||||
out, err := c.Output()
|
||||
fmt.Print(string(out))
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// repoSuite returns a validated suite name from the REPO_SUITE env var.
|
||||
// Only "stable" and "unstable" are allowed to prevent path traversal.
|
||||
func repoSuite() string {
|
||||
suite := os.Getenv("REPO_SUITE")
|
||||
switch suite {
|
||||
case "stable", "unstable":
|
||||
return suite
|
||||
default:
|
||||
return "stable"
|
||||
}
|
||||
}
|
||||
|
||||
// RepoApt generates APT repository metadata using reprepro.
|
||||
// It expects .deb files in <DIST>/repo-work/incoming/ and outputs to <DIST>/repo-output/apt/.
|
||||
// The reprepro config is read from build/reprepro-dist-conf.
|
||||
// Signing is done manually after reprepro finishes to avoid gpgme pinentry issues in CI.
|
||||
// Environment: REPO_SUITE controls the target suite (default: "stable").
|
||||
// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing.
|
||||
func (Release) RepoApt(ctx context.Context) error {
|
||||
mg.Deps(initVars)
|
||||
|
||||
suite := repoSuite()
|
||||
|
||||
incomingDir := filepath.Join(DIST, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(DIST, "repo-output", "apt")
|
||||
|
||||
// Set up reprepro conf directory
|
||||
confDir := filepath.Join(outputBase, "conf")
|
||||
if err := os.MkdirAll(confDir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating reprepro conf dir: %w", err)
|
||||
}
|
||||
|
||||
// Copy distributions config
|
||||
distConf, err := os.ReadFile("build/reprepro-dist-conf")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading reprepro-dist-conf: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil {
|
||||
return fmt.Errorf("writing distributions config: %w", err)
|
||||
}
|
||||
|
||||
// Include all .deb files into the target suite
|
||||
debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, deb := range debs {
|
||||
abs, _ := filepath.Abs(deb)
|
||||
if err := runAndStreamOutput(ctx, "reprepro",
|
||||
"-b", outputBase,
|
||||
"includedeb", suite,
|
||||
abs,
|
||||
); err != nil {
|
||||
return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sign Release files manually (reprepro's gpgme signing doesn't work in CI)
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
|
||||
releaseFile := filepath.Join(outputBase, "dists", suite, "Release")
|
||||
if _, err := os.Stat(releaseFile); err == nil {
|
||||
// Generate Release.gpg (detached signature)
|
||||
if err := runAndStreamOutput(ctx, "gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign", "--armor",
|
||||
"-o", releaseFile+".gpg",
|
||||
releaseFile,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing Release (detached): %w", err)
|
||||
}
|
||||
|
||||
// Generate InRelease (clearsigned)
|
||||
if err := runAndStreamOutput(ctx, "gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--clearsign",
|
||||
"-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"),
|
||||
releaseFile,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing Release (clearsign): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("APT repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepoRpm generates RPM repository metadata for all .rpm files in the work directory.
|
||||
// Expects .rpm files in <DIST>/repo-work/incoming/ and outputs to <DIST>/repo-output/rpm/<suite>/.
|
||||
// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing.
|
||||
// Environment: REPO_SUITE controls the target suite (default: "stable").
|
||||
func (Release) RepoRpm(ctx context.Context) error {
|
||||
mg.Deps(initVars)
|
||||
|
||||
suite := repoSuite()
|
||||
|
||||
incomingDir := filepath.Join(DIST, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(DIST, "repo-output", "rpm", suite)
|
||||
|
||||
archMap := map[string]string{
|
||||
"x86_64": "x86_64",
|
||||
"aarch64": "aarch64",
|
||||
"armv7": "armv7",
|
||||
}
|
||||
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
|
||||
for pkgArch, repoArch := range archMap {
|
||||
repoDir := filepath.Join(outputBase, repoArch)
|
||||
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Symlink matching RPMs
|
||||
pattern := filepath.Join(incomingDir, "*-"+pkgArch+".rpm")
|
||||
rpms, _ := filepath.Glob(pattern)
|
||||
if len(rpms) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, rpm := range rpms {
|
||||
abs, _ := filepath.Abs(rpm)
|
||||
dst := filepath.Join(repoDir, filepath.Base(rpm))
|
||||
os.Remove(dst)
|
||||
if err := os.Symlink(abs, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// createrepo_c (--update if repodata already exists)
|
||||
args := []string{repoDir}
|
||||
if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil {
|
||||
args = []string{"--update", repoDir}
|
||||
}
|
||||
if err := runAndStreamOutput(ctx, "createrepo_c", args...); err != nil {
|
||||
return fmt.Errorf("createrepo_c for %s: %w", repoArch, err)
|
||||
}
|
||||
|
||||
// Sign repomd.xml
|
||||
if err := runAndStreamOutput(ctx, "gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign", "--armor",
|
||||
"-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"),
|
||||
filepath.Join(repoDir, "repodata", "repomd.xml"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing repomd.xml for %s: %w", repoArch, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("RPM repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepoPacman generates Pacman repository database for all .archlinux files in the work directory.
|
||||
// Expects .archlinux files in <DIST>/repo-work/incoming/ and outputs to <DIST>/repo-output/pacman/<suite>/.
|
||||
// Environment: RELEASE_GPG_KEY, RELEASE_GPG_PASSPHRASE must be set for signing.
|
||||
// Environment: REPO_SUITE controls the target suite (default: "stable").
|
||||
func (Release) RepoPacman(ctx context.Context) error {
|
||||
mg.Deps(initVars)
|
||||
|
||||
suite := repoSuite()
|
||||
|
||||
incomingDir := filepath.Join(DIST, "repo-work", "incoming")
|
||||
outputBase := filepath.Join(DIST, "repo-output", "pacman", suite)
|
||||
|
||||
archMap := map[string]string{
|
||||
"x86_64": "x86_64",
|
||||
"aarch64": "aarch64",
|
||||
"armv7": "armv7",
|
||||
}
|
||||
|
||||
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||
|
||||
for pkgArch, repoArch := range archMap {
|
||||
repoDir := filepath.Join(outputBase, repoArch)
|
||||
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pattern := filepath.Join(incomingDir, "*-"+pkgArch+".archlinux")
|
||||
pkgs, _ := filepath.Glob(pattern)
|
||||
if len(pkgs) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, pkg := range pkgs {
|
||||
abs, _ := filepath.Abs(pkg)
|
||||
dst := filepath.Join(repoDir, filepath.Base(pkg))
|
||||
os.Remove(dst)
|
||||
if err := os.Symlink(abs, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// repo-add creates vikunja.db.tar.gz and vikunja.files.tar.gz
|
||||
dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz")
|
||||
repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux"))
|
||||
repoAddArgs := append([]string{dbPath}, repoPkgs...)
|
||||
if err := runAndStreamOutput(ctx, "repo-add", repoAddArgs...); err != nil {
|
||||
return fmt.Errorf("repo-add for %s: %w", repoArch, err)
|
||||
}
|
||||
|
||||
// Create conventional symlinks (vikunja.db -> vikunja.db.tar.gz)
|
||||
for _, name := range []string{"vikunja.db", "vikunja.files"} {
|
||||
link := filepath.Join(repoDir, name)
|
||||
os.Remove(link)
|
||||
if err := os.Symlink(name+".tar.gz", link); err != nil {
|
||||
return fmt.Errorf("creating symlink %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sign the database
|
||||
if err := runAndStreamOutput(ctx, "gpg",
|
||||
"--default-key", gpgKey,
|
||||
"--batch", "--yes",
|
||||
"--passphrase", gpgPassphrase,
|
||||
"--pinentry-mode", "loopback",
|
||||
"--detach-sign",
|
||||
"-o", filepath.Join(repoDir, "vikunja.db.sig"),
|
||||
dbPath,
|
||||
); err != nil {
|
||||
return fmt.Errorf("signing db for %s: %w", repoArch, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Pacman repo metadata generated in", outputBase)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrepareNFPMConfig prepares the nfpm config
|
||||
func (Release) PrepareNFPMConfig() error {
|
||||
mg.Deps(initVars)
|
||||
var err error
|
||||
|
||||
// Because nfpm does not support templating, we replace the values in the config file and restore it after running
|
||||
nfpmConfigPath := "./nfpm.yaml"
|
||||
nfpmconfig, err := os.ReadFile(nfpmConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var nfpmArch string
|
||||
switch os.Getenv("NFPM_ARCH") {
|
||||
case "arm64":
|
||||
nfpmArch = "arm64"
|
||||
case "arm7":
|
||||
nfpmArch = "arm7"
|
||||
case "386":
|
||||
nfpmArch = "386"
|
||||
default:
|
||||
nfpmArch = "amd64"
|
||||
}
|
||||
|
||||
fixedConfig := strings.ReplaceAll(string(nfpmconfig), "<version>", VersionNumber)
|
||||
fixedConfig = strings.ReplaceAll(fixedConfig, "<binlocation>", BinLocation)
|
||||
fixedConfig = strings.ReplaceAll(fixedConfig, "<arch>", nfpmArch)
|
||||
if err := os.WriteFile(nfpmConfigPath, []byte(fixedConfig), 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Packages creates deb, rpm and apk packages
|
||||
func (Release) Packages(ctx context.Context) error {
|
||||
mg.Deps(initVars)
|
||||
|
||||
var err error
|
||||
binpath := os.Getenv("NFPM_BIN_PATH")
|
||||
if binpath == "" {
|
||||
binpath = "nfpm"
|
||||
}
|
||||
err = exec.CommandContext(ctx, binpath).Run()
|
||||
if err != nil && strings.Contains(err.Error(), "executable file not found") {
|
||||
binpath = "/usr/bin/nfpm"
|
||||
err = exec.CommandContext(ctx, binpath).Run()
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "executable file not found") {
|
||||
return fmt.Errorf("executable %s not found: please manually install nfpm by running the command: curl -sfL https://install.goreleaser.com/github.com/goreleaser/nfpm.sh | sh -s -- -b $(go env GOPATH)/bin", binpath)
|
||||
}
|
||||
|
||||
err = (Release{}).PrepareNFPMConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
releasePath := "./" + DIST + "/os-packages/"
|
||||
if err := os.MkdirAll(releasePath, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "deb", "--target", releasePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "rpm", "--target", releasePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runAndStreamOutput(ctx, binpath, "pkg", "--packager", "apk", "--target", releasePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Dev mg.Namespace
|
||||
|
||||
// MakeMigration creates a new bare db migration skeleton in pkg/migration.
|
||||
|
|
|
|||
|
|
@ -173,5 +173,10 @@
|
|||
"since_hours": "%[1]d 時間",
|
||||
"since_minutes": "%[1]d 分",
|
||||
"list_last_separator": ", "
|
||||
},
|
||||
"feeds": {
|
||||
"notifications": {
|
||||
"title": "%[1]s の Vikunja 通知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -334,10 +334,12 @@ func (la *TaskAssginee) ReadAll(s *xorm.Session, a web.Auth, search string, page
|
|||
}
|
||||
|
||||
numberOfTotalItems, err = s.Table("task_assignees").
|
||||
Select("users.*").
|
||||
Join("INNER", "users", "task_assignees.user_id = users.id").
|
||||
Where("task_id = ? AND users.username LIKE ?", la.TaskID, "%"+search+"%").
|
||||
Count(&user.User{})
|
||||
Where(builder.And(
|
||||
builder.Eq{"task_id": la.TaskID},
|
||||
db.ILIKE("users.username", search),
|
||||
)).
|
||||
Count(&TaskAssginee{})
|
||||
return taskAssignees, len(taskAssignees), numberOfTotalItems, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,8 @@ func getProjectFromParam(c *echo.Context) (project *models.ProjectWithTasksAndBu
|
|||
|
||||
intParam, err := strconv.ParseInt(param, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// The project ID given is not an integer, it cannot exist
|
||||
return nil, models.ErrProjectDoesNotExist{}
|
||||
}
|
||||
|
||||
if intParam == models.FavoritesPseudoProjectID {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ func TestCaldav(t *testing.T) {
|
|||
assert.Contains(t, rec.Body.String(), "END:VTODO")
|
||||
assert.Contains(t, rec.Body.String(), "END:VCALENDAR")
|
||||
})
|
||||
t.Run("Returns 404 for non-integer project param", func(t *testing.T) {
|
||||
e, _ := setupTestEnv()
|
||||
rec, err := newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "E6948AA3-86CC-40A1-874D-3B0A02FAA781"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Result().StatusCode)
|
||||
})
|
||||
t.Run("Import VTODO", func(t *testing.T) {
|
||||
const vtodo = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
|
|
|
|||
|
|
@ -563,3 +563,36 @@ func TestTaskDuplicate(t *testing.T) {
|
|||
assert.Contains(t, getHTTPErrorMessage(err), "Forbidden")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskAssignees(t *testing.T) {
|
||||
testHandler := webHandlerTest{
|
||||
user: &testuser1,
|
||||
strFunc: func() handler.CObject {
|
||||
return &models.TaskAssginee{}
|
||||
},
|
||||
t: t,
|
||||
}
|
||||
|
||||
t.Run("ReadAll", func(t *testing.T) {
|
||||
t.Run("With assignees", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(nil, map[string]string{"projecttask": "30"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), `"id":1`)
|
||||
assert.Contains(t, rec.Body.String(), `"id":2`)
|
||||
})
|
||||
|
||||
t.Run("Without assignees", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(nil, map[string]string{"projecttask": "1"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), `[]`)
|
||||
})
|
||||
|
||||
t.Run("No permission", func(t *testing.T) {
|
||||
_, err := testHandler.testReadAllWithUser(nil, map[string]string{"projecttask": "14"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
/veans
|
||||
/veans.exe
|
||||
/dist/
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
version: "2"
|
||||
run:
|
||||
tests: true
|
||||
build-tags:
|
||||
- mage
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- contextcheck
|
||||
- err113
|
||||
- errchkjson
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- gocheckcompilerdirectives
|
||||
- gochecksumtype
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- goheader
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- loggercheck
|
||||
- makezero
|
||||
- misspell
|
||||
- nilerr
|
||||
- nilnesserr
|
||||
- noctx
|
||||
- protogetter
|
||||
- reassign
|
||||
- recvcheck
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- testifylint
|
||||
- unparam
|
||||
disable:
|
||||
- durationcheck
|
||||
- goconst
|
||||
- musttag
|
||||
settings:
|
||||
goheader:
|
||||
template-path: code-header-template.txt
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
# Tests compose dynamic error messages and exercise edge cases — let
|
||||
# them. Mirrors the parent repo's _test.go carve-outs.
|
||||
- linters:
|
||||
- err113
|
||||
- errorlint
|
||||
- gocyclo
|
||||
path: _test\.go
|
||||
- linters:
|
||||
- gocritic
|
||||
text: 'commentFormatting: put a space between `//` and comment text'
|
||||
# The veans CLI uses fmt.Errorf and output.New/Wrap intentionally —
|
||||
# err113's "no dynamic errors" rule isn't a fit for user-facing CLI
|
||||
# errors that are routinely templated with parameters.
|
||||
- linters:
|
||||
- err113
|
||||
path: ".*"
|
||||
text: 'do not define dynamic errors, use wrapped static errors instead:'
|
||||
# mage build tooling is internal — gosec subprocess flags don't apply.
|
||||
- linters:
|
||||
- err113
|
||||
- gosec
|
||||
path: magefile.go
|
||||
# term.ReadPassword takes int(*os.File.Fd()) — canonical Go idiom.
|
||||
- linters:
|
||||
- gosec
|
||||
text: 'G115: integer overflow conversion uintptr -> int'
|
||||
# Password / AccessToken / RefreshToken are intentional API model
|
||||
# fields, mirroring the parent repo's exclusion.
|
||||
- linters:
|
||||
- gosec
|
||||
text: 'G117:'
|
||||
# veans is an HTTP CLI: G704 (SSRF) and G705 (XSS via Fprintf to a
|
||||
# terminal) are categorically false positives for this codebase.
|
||||
- linters:
|
||||
- gosec
|
||||
text: 'G70[45]:'
|
||||
# E2E helpers run subprocesses with controlled inputs (git, the
|
||||
# built veans binary). G204 (subprocess) and G703 (path traversal)
|
||||
# don't apply to test infrastructure.
|
||||
- linters:
|
||||
- gosec
|
||||
path: e2e/
|
||||
text: 'G(204|306|703):'
|
||||
# .veans.yml + agent hook config files are committed to the repo
|
||||
# and intentionally world-readable; 0o644 is correct.
|
||||
- linters:
|
||||
- gosec
|
||||
path: internal/(config|bootstrap)/.*\.go
|
||||
text: 'G306:'
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
# AGENT Instructions for veans
|
||||
|
||||
Things to know before touching this submodule that aren't obvious from
|
||||
reading the code. The parent repo's `CLAUDE.md` covers the rest of Vikunja;
|
||||
this file is veans-specific.
|
||||
|
||||
## Module layout
|
||||
|
||||
- `veans/` is its own Go module (`code.vikunja.io/veans`), separate from
|
||||
the parent. Don't try to import `code.vikunja.io/api/...` — that pulls
|
||||
XORM into the CLI binary. Wire types live in `internal/client/types.go`
|
||||
as plain JSON-tagged structs that mirror the parent models.
|
||||
- License headers are enforced by `goheader` in `veans/.golangci.yml`.
|
||||
Every new `.go` file needs the AGPLv3 banner from
|
||||
`veans/code-header-template.txt` (a copy of the parent's, kept local
|
||||
so the linter resolves the path relative to this module).
|
||||
|
||||
## Building and testing
|
||||
|
||||
- `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` (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 + `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
|
||||
with `-short` for fast feedback, independent of `api-build`.
|
||||
|
||||
## Vikunja wire-format gotchas
|
||||
|
||||
Most failures surface when crossing the JSON boundary. The list below is
|
||||
what's bitten me; if a new endpoint behaves oddly, suspect one of these:
|
||||
|
||||
- **`ProjectView.view_kind` and `bucket_configuration_mode` are
|
||||
strings**, not ints. The parent enums (`ProjectViewKind`,
|
||||
`BucketConfigurationModeKind`) have custom `MarshalJSON` that emits
|
||||
`"kanban"` / `"manual"` etc. Use the string constants in
|
||||
`internal/client/types.go`.
|
||||
- **`Task.BucketID` is always 0** in `GET /tasks/:id`. The model has
|
||||
`xorm:"-"` on it — the actual bucket lives in a separate
|
||||
`task_buckets` table. Fetch with `?expand=buckets` and use
|
||||
`task.CurrentBucketID(viewID)` to read it.
|
||||
- **`POST /tasks/{id}` does NOT move tasks between buckets.** The
|
||||
task↔bucket relation is row-shaped; use `client.MoveTaskToBucket()`
|
||||
which hits `POST /projects/{p}/views/{v}/buckets/{b}/tasks`. The
|
||||
Update path on the server only auto-moves on `done` flips.
|
||||
- **Bot user creation is `PUT /user/bots`**, not `/bots` — the routes
|
||||
are registered under the `/user` subgroup. Same prefix for
|
||||
`GET /user/bots`.
|
||||
- **`APIToken.expires_at` is required.** The struct field has
|
||||
`valid:"required"` upstream; sending it omitted or zero fails
|
||||
validation. Use `client.FarFuture` (year 9999) when you mean "no
|
||||
expiry" — the frontend does the same.
|
||||
- **Task descriptions and comments are HTML, not markdown.** The
|
||||
Vikunja web UI uses TipTap, which calls `getHTML()` on save. The
|
||||
stored field is therefore HTML. The agent prompt template
|
||||
(`internal/commands/prompt.tmpl`) teaches agents the canonical
|
||||
TipTap shapes — most importantly `<ul data-type="taskList">` +
|
||||
`<li data-type="taskItem" data-checked="false"><p>…</p></li>` for
|
||||
interactive checkboxes. We deliberately do **not** convert
|
||||
markdown↔HTML in the CLI; the agent writes HTML directly, which
|
||||
avoids lossy roundtrips on `--description-replace-old/new`. `veans
|
||||
show` displays the raw HTML; humans skim it fine.
|
||||
|
||||
## API token permissions
|
||||
|
||||
- Vikunja validates token `permissions` against `apiTokenRoutes`, a map
|
||||
built dynamically from registered routes. Group names are derived
|
||||
from the URL path (params stripped, joined by `_`). Examples:
|
||||
- `/projects/:project/views/:view/buckets/:bucket/tasks` →
|
||||
group `projects`, action `views_buckets_tasks`
|
||||
- `/tasks/:task/comments` → group `tasks_comments`, action `create`
|
||||
- `client.PermissionsForBot()` calls `GET /routes` at runtime and
|
||||
grants only the intersection of what we want and what the server
|
||||
exposes. **Don't hard-code permission group names** — they drift
|
||||
across Vikunja versions, and discovery keeps the bot's grant valid
|
||||
across upgrades.
|
||||
|
||||
## Bot ownership and token minting
|
||||
|
||||
- Creating a bot via `PUT /user/bots` automatically sets the bot's
|
||||
`bot_owner_id` to the calling user. Only the owner can mint tokens
|
||||
for the bot via `PUT /tokens` with `owner_id=<bot_id>`. The init
|
||||
flow does these as a single human-JWT-authenticated batch.
|
||||
- Bots have no password and **cannot** authenticate via `POST /login`.
|
||||
After init, `veans login` re-authenticates as the human (not the
|
||||
bot) and mints a fresh bot token.
|
||||
|
||||
## OAuth flow
|
||||
|
||||
- Vikunja's authorization server requires PKCE/S256 and accepts either
|
||||
`vikunja-…://` custom schemes or RFC 8252 loopback URIs
|
||||
(`http://127.0.0.1:NNN/`, `http://localhost:NNN/`, `http://[::1]:NNN/`).
|
||||
No client registration needed — `client_id` can be any consistent
|
||||
string (we use `veans-cli`).
|
||||
- `internal/auth/oauth.go` binds a free port on 127.0.0.1, opens the
|
||||
browser, and captures the callback. The `Shutdown` defer uses
|
||||
`context.WithoutCancel(ctx)` so cancellation at the outer scope
|
||||
still drains the loopback server cleanly.
|
||||
- Token exchange is **JSON only**. Form-encoded POSTs to `/oauth/token`
|
||||
fail; the standard `golang.org/x/oauth2` client speaks form encoding,
|
||||
which is why we have a hand-rolled `client.ExchangeOAuthCode`.
|
||||
|
||||
## Credential store
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- Project `Identifier` is `runelength(0|10)`, can be empty. When empty,
|
||||
`Config.FormatTaskID` renders `#NN`; otherwise `PROJ-NN`. Both are
|
||||
accepted by `runtime.resolveTaskID` along with bare integers.
|
||||
- Bot username must start with `bot-`; the server enforces it. Hyphens,
|
||||
digits, lowercase letters allowed; no spaces, no commas, no
|
||||
`link-share-N` pattern. `config.SuggestedBotUsername` does the
|
||||
folding for repo names.
|
||||
- E2e tests deriving identifiers from a unique suffix should use the
|
||||
trailing chars of `strconv.FormatInt(time.Now().UnixNano(), 36)`.
|
||||
The leading chars barely change between consecutive runs and will
|
||||
collide if you take `[:N]`.
|
||||
|
||||
## Audience split
|
||||
|
||||
The CLI is agent-only at runtime; humans never use it for day-to-day
|
||||
work (they use Vikunja's web UI). Two commands serve a human running
|
||||
one-off setup:
|
||||
|
||||
- **`init`** — bootstrap a repo: pick project + view, create bot,
|
||||
share, mint token, write `.veans.yml`, install hooks.
|
||||
- **`login`** — rotate the bot's token.
|
||||
|
||||
Everything else (`list`, `show`, `create`, `update`, `claim`, `api`,
|
||||
`prime`, `version`) is **agent-only**:
|
||||
|
||||
- **Emits JSON on stdout unconditionally.** No `--json` flag, no
|
||||
human-formatted variant. `list` is a raw array; `show` / `create` /
|
||||
`update` / `claim` return a single task object.
|
||||
- **Errors are JSON on stderr** with non-zero exit — same envelope
|
||||
everywhere (`{"code": "...", "error": "..."}`), regardless of which
|
||||
command ran. Stable codes in `internal/output/errors.go`:
|
||||
`NOT_FOUND`, `CONFLICT`, `VALIDATION_ERROR`, `AUTH_ERROR`,
|
||||
`RATE_LIMITED`, `BOT_USERS_UNAVAILABLE`, `NOT_CONFIGURED`,
|
||||
`UNKNOWN`. Don't add ad-hoc strings — wrap with `output.New` /
|
||||
`output.Wrap`.
|
||||
- **No `globals.JSON`, no dual rendering paths.** If you find yourself
|
||||
reaching for "if interactive, do X" on an agent-facing command,
|
||||
stop — it's not interactive, an agent is on the other end.
|
||||
|
||||
## Cobra surface conventions
|
||||
|
||||
- `RunE` handlers that don't use `args []string` should rename it to
|
||||
`_` to satisfy revive's `unused-parameter` rule.
|
||||
- The bucket-move dance (`MoveTaskToBucket`) runs **after** the field
|
||||
update on `update`, so a status transition can't clobber freshly
|
||||
attached labels. Comments for `--status scrapped` post **before**
|
||||
the bucket move so the audit trail reads in chronological order.
|
||||
- Agent-facing commands return the task via `json.NewEncoder(...).Encode(task)`.
|
||||
Adding new top-level keys to `client.Task` is an implicit API
|
||||
change — bump `prime`'s "useful fields" note alongside.
|
||||
|
||||
## Things to *not* do
|
||||
|
||||
- **Don't add an `os/exec.Command`** without ctx — `noctx` is enabled.
|
||||
Use `exec.CommandContext(ctx, …)` and thread the context through.
|
||||
- **Don't commit the built binary.** `veans/.gitignore` covers
|
||||
`./veans` and `./veans.exe`.
|
||||
- **Don't write to stdout from `prime` when no `.veans.yml` is found.**
|
||||
The hook contract is silent + exit 0 so the snippet is safe to install
|
||||
globally in `~/.claude/settings.json`.
|
||||
- **Don't rename canonical bucket titles** without updating
|
||||
`BucketTitleAliases[s][0]` in `internal/status/status.go`, the
|
||||
prompt template (`internal/commands/prompt.tmpl`), and the e2e
|
||||
assertions in lockstep — agents and humans both treat them as
|
||||
fixed strings.
|
||||
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
# veans
|
||||
|
||||
A beans-shaped CLI for Vikunja. Drop it into a repo, run `veans init`, paste a
|
||||
hook snippet into your coding agent's settings, and the agent immediately
|
||||
knows to track its work in Vikunja instead of in `TodoWrite` or `.beans/`.
|
||||
|
||||
veans is a thin Go binary that wraps Vikunja's REST API with an opinionated
|
||||
agent-friendly surface and emits a system prompt teaching agents the workflow
|
||||
(claim → work → in-review → human closes). The agent prompt is re-emitted on
|
||||
every `SessionStart` and `PreCompact`, so context never goes stale.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
# 1. Build (or download) the binary
|
||||
cd veans && mage build && sudo install ./veans /usr/local/bin/
|
||||
|
||||
# 2. In a repo with a Vikunja instance reachable
|
||||
veans init --server https://vikunja.example.com
|
||||
|
||||
# 3. Wire it into Claude Code (.claude/settings.json):
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }],
|
||||
"PreCompact": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }]
|
||||
}
|
||||
}
|
||||
|
||||
# OpenCode (.opencode/plugin/veans-prime.ts):
|
||||
export const VeansPrime = {
|
||||
event: ["session.start", "compact.before"],
|
||||
handler: async ({ exec }) => exec("veans prime"),
|
||||
}
|
||||
```
|
||||
|
||||
`veans prime` exits silently with status 0 when no `.veans.yml` is reachable
|
||||
upward from cwd, so the hook is safe to install in a global `~/.claude/`
|
||||
without breaking sessions in unrelated repos.
|
||||
|
||||
## What `veans init` does
|
||||
|
||||
1. Authenticates as you. Default is OAuth 2.0 Authorization Code + PKCE
|
||||
against Vikunja's built-in authorization server (Vikunja 2.3+ — no
|
||||
client registration needed). veans prints an authorize URL; you open
|
||||
it in your browser, sign in, and paste the resulting
|
||||
`vikunja-veans-cli://callback?code=...` URL back into the CLI. The
|
||||
browser will fail to open the custom scheme — that's expected; the
|
||||
address bar still has what we need.
|
||||
|
||||
Alternative auth modes:
|
||||
- `--token <jwt-or-personal-api-token>` — paste-in, useful for SSO/OIDC
|
||||
- `--use-password` — fall back to `POST /login` (local accounts only)
|
||||
- `--username` + `--password` (non-interactive; implies `--use-password`)
|
||||
2. Asks you to pick a project and a Kanban view.
|
||||
3. Bootstraps the canonical buckets if missing: `Todo`, `In Progress`,
|
||||
`In Review`, `Done`, `Scrapped`.
|
||||
4. Creates a `bot-<repo-name>` user (Vikunja bot user — no password, no
|
||||
email, can't log in interactively).
|
||||
5. Shares the project with the bot at read+write.
|
||||
6. Mints a long-lived API token for the bot via `PUT /tokens` with
|
||||
`owner_id`, scoped to the discovered route groups (tasks, comments,
|
||||
labels, relations, assignees, etc.) the server actually exposes.
|
||||
7. Stores the token in your OS keychain (or
|
||||
`~/.config/veans/credentials.yml` if no keychain is available).
|
||||
8. Writes `.veans.yml` to the repo root.
|
||||
|
||||
The token stored is the bot's, not yours. The human's transient session is
|
||||
discarded as soon as init finishes — rotate or revoke the bot independently
|
||||
without affecting your own session.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
veans init OAuth/login → create bot → mint token → write .veans.yml
|
||||
veans prime emit system prompt for agents (silent if no .veans.yml)
|
||||
veans list filtered list (--ready, --mine, --branch, --filter, --status); emits JSON
|
||||
veans show <id> view a task (JSON)
|
||||
veans create "title" --description, --label, --status, --priority, --parent, --blocked-by
|
||||
veans update <id> --status, --title, --priority, --label-add/remove,
|
||||
--description, --description-replace-old/new, --description-append,
|
||||
--comment, --reason, --if-unchanged-since
|
||||
veans claim <id> assign the bot, move to In Progress, tag with current branch label
|
||||
veans api METHOD PATH raw REST passthrough — escape hatch for endpoints not wrapped here
|
||||
veans login re-mint the bot's token (rotation)
|
||||
veans version
|
||||
```
|
||||
|
||||
Task IDs accept `PROJ-NN` (when the project has an identifier), `#NN`
|
||||
(when it doesn't), or a bare integer.
|
||||
|
||||
## `.veans.yml`
|
||||
|
||||
Committed to the repo root. The numeric IDs are the source of truth; cached
|
||||
identifiers and bot username are for human-readable output.
|
||||
|
||||
```yaml
|
||||
server: https://vikunja.example.com
|
||||
project_id: 42
|
||||
project_identifier: PROJ # may be "" — task IDs render as #NN then
|
||||
view_id: 7
|
||||
buckets:
|
||||
todo: 11
|
||||
in_progress: 12
|
||||
in_review: 13
|
||||
done: 14
|
||||
scrapped: 15
|
||||
bot:
|
||||
username: bot-myrepo
|
||||
user_id: 99
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
Resolved in order on every command:
|
||||
|
||||
1. **OS keychain** (macOS Keychain, Windows Credential Manager,
|
||||
libsecret/gnome-keyring on Linux), via `github.com/zalando/go-keyring`.
|
||||
2. **`VEANS_TOKEN`** env var (read-only). Optionally pin to a server with
|
||||
`VEANS_SERVER`. Intended for CI / containers.
|
||||
3. **`~/.config/veans/credentials.yml`** (mode 0600) — automatic fallback
|
||||
when the keychain is unavailable. Honors `XDG_CONFIG_HOME`.
|
||||
|
||||
## Mage targets
|
||||
|
||||
```
|
||||
mage build # go build -o ./veans ./cmd/veans
|
||||
mage test # unit tests across the module
|
||||
mage test:filter EXPR # go test -run EXPR ./...
|
||||
mage test:e2e # e2e suite (needs VEANS_E2E_API_URL)
|
||||
mage lint / lint:fix # golangci-lint
|
||||
mage fmt # go fmt ./...
|
||||
mage clean # remove built binary
|
||||
```
|
||||
|
||||
## End-to-end tests
|
||||
|
||||
The suite in `e2e/` assumes a running Vikunja API. Locally, point it at any
|
||||
dev instance:
|
||||
|
||||
```sh
|
||||
export VEANS_E2E_API_URL=http://localhost:3456
|
||||
export VEANS_E2E_ADMIN_USER=user1
|
||||
export VEANS_E2E_ADMIN_PASS=12345678 # canonical fixture password
|
||||
mage test:e2e
|
||||
```
|
||||
|
||||
CI spins Vikunja up the same way the frontend Playwright suite does — see
|
||||
`.github/workflows/veans-e2e.yml`. The workflow builds the parent API
|
||||
binary, starts it with `VIKUNJA_DATABASE_TYPE=sqlite`,
|
||||
`VIKUNJA_DATABASE_PATH=memory`, fixtures from `pkg/db/fixtures/`, and runs
|
||||
`mage test:e2e` from this directory.
|
||||
|
||||
E2E tests never touch the developer's keychain — they override `HOME` and
|
||||
`XDG_CONFIG_HOME` per test, which forces the credential store to fall
|
||||
through to its file backend.
|
||||
|
||||
## Status model
|
||||
|
||||
| Status | Bucket name | Done flag | Who moves there? |
|
||||
| ------------- | -------------- | --------- | ---------------------------------------- |
|
||||
| `todo` | Todo | false | created here by default |
|
||||
| `in-progress` | In Progress | false | `veans claim` / `update -s in-progress` |
|
||||
| `in-review` | In Review | false | the agent, when work is finished |
|
||||
| `completed` | Done | true | humans / merge hook only |
|
||||
| `scrapped` | Scrapped | true | the agent, with `--reason` |
|
||||
|
||||
The agent never moves tasks to `completed` itself — it parks them in
|
||||
`In Review` and a human (or the future merge hook) closes them once the
|
||||
PR lands.
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
- OAuth 2.0 device flow (RFC 8628) — would let SSH'd / headless setups
|
||||
authenticate without a browser-on-the-same-machine; not implemented
|
||||
upstream yet.
|
||||
- Project-scoped API tokens — Vikunja doesn't ship them yet. The
|
||||
credential schema's `scope` field is forward-compatible for when it does.
|
||||
- Auto-installing hook snippets. We print them; you paste them.
|
||||
- Merge-hook GitHub Action that auto-closes tasks on PR merge — separate
|
||||
repo, future work.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// 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/>.
|
||||
|
||||
// veans — a beans-shaped CLI for Vikunja.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/commands"
|
||||
)
|
||||
|
||||
// version is overwritten via -ldflags at release time.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
client.UserAgent = fmt.Sprintf("veans/%s (%s/%s)", version, runtime.GOOS, runtime.GOARCH)
|
||||
os.Exit(commands.Execute(version))
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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/>.
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
// TestClaim_AssignsBotMovesToInProgressTagsBranch exercises the full claim
|
||||
// flow: assignment, bucket transition, and branch label application.
|
||||
func TestClaim_AssignsBotMovesToInProgressTagsBranch(t *testing.T) {
|
||||
ws, h := provisionWorkspace(t)
|
||||
|
||||
out, _, code := h.Run(t, ws, "create", "claim me")
|
||||
if code != 0 {
|
||||
t.Fatalf("create exit %d\n%s", code, out)
|
||||
}
|
||||
var created client.Task
|
||||
if err := json.Unmarshal([]byte(out), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := fmt.Sprintf("%d", created.Index)
|
||||
|
||||
// Switch the workspace's git branch so claim has something to label with.
|
||||
gitInWorkspace(t, ws, "checkout", "-q", "-b", "feat-claim-test")
|
||||
|
||||
_, errOut, code := h.Run(t, ws, "claim", id)
|
||||
if code != 0 {
|
||||
t.Fatalf("claim exit %d\n%s", code, errOut)
|
||||
}
|
||||
|
||||
server := h.GetTask(t, created.ID)
|
||||
|
||||
// Verify bucket transition by reading the workspace's .veans.yml — the
|
||||
// bot's expected In Progress bucket is stored there.
|
||||
cfg := loadConfig(t, ws)
|
||||
bucket := server.CurrentBucketID(cfg.ViewID)
|
||||
if bucket != cfg.Buckets.InProgress {
|
||||
t.Fatalf("task not in In Progress bucket: got %d, want %d", bucket, cfg.Buckets.InProgress)
|
||||
}
|
||||
|
||||
// Bot assigned.
|
||||
assigned := false
|
||||
for _, a := range server.Assignees {
|
||||
if a != nil && a.ID == cfg.Bot.UserID {
|
||||
assigned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assigned {
|
||||
t.Fatalf("bot %d not in assignees: %+v", cfg.Bot.UserID, server.Assignees)
|
||||
}
|
||||
|
||||
// Branch label attached.
|
||||
branchLabel := "veans:branch:feat-claim-test"
|
||||
if !taskHasLabelTitle(server, branchLabel) {
|
||||
t.Fatalf("expected label %q on task; got %+v", branchLabel, server.Labels)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
// 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 is the integration suite for veans. It assumes a running
|
||||
// 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 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 {
|
||||
Binary string
|
||||
APIURL string
|
||||
AdminToken string
|
||||
AdminClient *client.Client
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
tok := os.Getenv("VEANS_E2E_ADMIN_TOKEN")
|
||||
if tok == "" {
|
||||
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(t.Context(), &client.LoginRequest{
|
||||
Username: seedAdminUsername,
|
||||
Password: seedAdminPassword,
|
||||
LongToken: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin login: %v", err)
|
||||
}
|
||||
tok = resp.Token
|
||||
}
|
||||
|
||||
return &Harness{
|
||||
Binary: binary,
|
||||
APIURL: apiURL,
|
||||
AdminToken: tok,
|
||||
AdminClient: client.New(apiURL, tok),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
ConfigPath string
|
||||
BotUsername string
|
||||
envOverrides map[string]string
|
||||
}
|
||||
|
||||
// NewWorkspace initializes a fresh repo with `git init` + a single commit so
|
||||
// `git rev-parse --abbrev-ref HEAD` returns a real branch name.
|
||||
func (h *Harness) NewWorkspace(t *testing.T) *Workspace {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
home := t.TempDir()
|
||||
|
||||
for _, c := range [][]string{
|
||||
{"git", "init", "-q", "-b", "main"},
|
||||
{"git", "config", "user.email", "veans-e2e@example.com"},
|
||||
{"git", "config", "user.name", "veans-e2e"},
|
||||
// Disable any inherited commit signing; the test commit doesn't
|
||||
// need provenance and signing brokers can fail in dev containers.
|
||||
{"git", "config", "commit.gpgsign", "false"},
|
||||
{"git", "config", "tag.gpgsign", "false"},
|
||||
} {
|
||||
cmd := exec.CommandContext(t.Context(), c[0], c[1:]...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "README"), []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range [][]string{
|
||||
{"git", "add", "."},
|
||||
{"git", "commit", "-q", "-m", "initial"},
|
||||
} {
|
||||
cmd := exec.CommandContext(t.Context(), c[0], c[1:]...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out)
|
||||
}
|
||||
}
|
||||
|
||||
return &Workspace{
|
||||
Dir: dir,
|
||||
Home: home,
|
||||
ConfigPath: filepath.Join(dir, ".veans.yml"),
|
||||
envOverrides: map[string]string{
|
||||
"HOME": home,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the veans binary against this workspace, returning stdout,
|
||||
// stderr, and exit code.
|
||||
func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stderr string, exitCode int) {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(t.Context(), h.Binary, args...)
|
||||
cmd.Dir = ws.Dir
|
||||
// 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
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return so.String(), se.String(), ee.ExitCode()
|
||||
}
|
||||
t.Fatalf("run veans %v: %v", args, err)
|
||||
}
|
||||
return so.String(), se.String(), 0
|
||||
}
|
||||
|
||||
// CreateProject creates a fresh project owned by the admin user and returns
|
||||
// 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()
|
||||
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
|
||||
}
|
||||
|
||||
// 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(t.Context(), projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("list views: %v", err)
|
||||
}
|
||||
for _, v := range views {
|
||||
if v.ViewKind == client.ViewKindKanban {
|
||||
return v
|
||||
}
|
||||
}
|
||||
t.Fatalf("no Kanban view on project %d", projectID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("get task %d: %v", id, err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func buildOrLocate() (string, error) {
|
||||
if env := os.Getenv("VEANS_BINARY"); env != "" {
|
||||
if abs, err := filepath.Abs(env); err == nil {
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
tmp, err := os.MkdirTemp("", "veans-bin-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
bin := filepath.Join(tmp, "veans")
|
||||
if runtime.GOOS == "windows" {
|
||||
bin += ".exe"
|
||||
}
|
||||
cmd := exec.CommandContext(context.Background(), "go", "build", "-o", bin, "./cmd/veans")
|
||||
cmd.Dir = repoRoot()
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build veans: %w (output: %s)", err, out)
|
||||
}
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
func repoRoot() string {
|
||||
// e2e/helpers.go lives at <repo>/veans/e2e/helpers.go, so go up two.
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
|
||||
}
|
||||
|
||||
func envSlice(overrides map[string]string) []string {
|
||||
out := make([]string, 0, len(overrides))
|
||||
for k, v := range overrides {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
// 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 (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
"code.vikunja.io/veans/internal/credentials"
|
||||
)
|
||||
|
||||
// TestInit_HappyPath exercises the full bootstrap: pick project + view,
|
||||
// create canonical buckets, create the bot user, share the project, mint
|
||||
// the bot's token, and write .veans.yml. Verifies side effects via the
|
||||
// admin client.
|
||||
func TestInit_HappyPath(t *testing.T) {
|
||||
h := New(t)
|
||||
|
||||
// Use a unique title and identifier per run so parallel jobs don't
|
||||
// collide on the bot username.
|
||||
suffix := uniqueSuffix()
|
||||
project := h.CreateProject(t, "veans-e2e-"+suffix, identifier(suffix))
|
||||
view := h.FindKanbanView(t, project.ID)
|
||||
|
||||
ws := h.NewWorkspace(t)
|
||||
// Rename the workspace dir so the auto-generated bot username is unique.
|
||||
ws.BotUsername = "bot-veans-e2e-" + suffix
|
||||
|
||||
stdout, stderr, code := h.Run(t, ws,
|
||||
"init",
|
||||
"--server", h.APIURL,
|
||||
"--token", h.AdminToken,
|
||||
"--project", fmt.Sprintf("%d", project.ID),
|
||||
"--view", fmt.Sprintf("%d", view.ID),
|
||||
"--bot-username", ws.BotUsername,
|
||||
"--yes-buckets",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("init exit %d\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr)
|
||||
}
|
||||
|
||||
// Config written?
|
||||
cfg, err := config.Load(ws.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load .veans.yml: %v", err)
|
||||
}
|
||||
if cfg.ProjectID != project.ID || cfg.ViewID != view.ID {
|
||||
t.Fatalf("unexpected ids in config: %+v", cfg)
|
||||
}
|
||||
if cfg.Bot.Username != ws.BotUsername {
|
||||
t.Fatalf("bot username = %q, want %q", cfg.Bot.Username, ws.BotUsername)
|
||||
}
|
||||
if cfg.Buckets.Todo == 0 || cfg.Buckets.InProgress == 0 || cfg.Buckets.InReview == 0 || cfg.Buckets.Done == 0 || cfg.Buckets.Scrapped == 0 {
|
||||
t.Fatalf("buckets not fully populated: %+v", cfg.Buckets)
|
||||
}
|
||||
|
||||
// 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.Home + "/.config/veans/credentials.yml")
|
||||
tok, err := store.Get(h.APIURL, ws.BotUsername)
|
||||
if err != nil {
|
||||
t.Fatalf("token not persisted: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(tok, "tk_") {
|
||||
t.Fatalf("bot token doesn't look like a Vikunja API token: %q", tok)
|
||||
}
|
||||
|
||||
// Bot exists on the server with the right username.
|
||||
bots, err := h.AdminClient.ListBotUsers(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("list bots: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, b := range bots {
|
||||
if b.Username == ws.BotUsername {
|
||||
found = true
|
||||
if b.ID != cfg.Bot.UserID {
|
||||
t.Fatalf("bot user_id mismatch: server=%d cfg=%d", b.ID, cfg.Bot.UserID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("bot %q not found on server", ws.BotUsername)
|
||||
}
|
||||
|
||||
// Project shared with the bot at write permission.
|
||||
var shares []map[string]any
|
||||
_ = 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 {
|
||||
if p, _ := s["permission"].(float64); int(p) >= 1 {
|
||||
shareFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shareFound {
|
||||
t.Fatalf("project not shared with bot at write permission: %v", shares)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_NoIdentifierFallsBackToHashNN(t *testing.T) {
|
||||
h := New(t)
|
||||
|
||||
suffix := uniqueSuffix()
|
||||
project := h.CreateProject(t, "veans-e2e-noid-"+suffix, "")
|
||||
view := h.FindKanbanView(t, project.ID)
|
||||
|
||||
ws := h.NewWorkspace(t)
|
||||
ws.BotUsername = "bot-veans-noid-" + suffix
|
||||
|
||||
_, stderr, code := h.Run(t, ws,
|
||||
"init",
|
||||
"--server", h.APIURL,
|
||||
"--token", h.AdminToken,
|
||||
"--project", fmt.Sprintf("%d", project.ID),
|
||||
"--view", fmt.Sprintf("%d", view.ID),
|
||||
"--bot-username", ws.BotUsername,
|
||||
"--yes-buckets",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("init exit %d\n%s", code, stderr)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(ws.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ProjectIdentifier != "" {
|
||||
t.Fatalf("expected empty identifier, got %q", cfg.ProjectIdentifier)
|
||||
}
|
||||
if got := cfg.FormatTaskID(7); got != "#7" {
|
||||
t.Fatalf("expected #7, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// uniqueSuffix returns a short slug derived from the current nanosecond
|
||||
// timestamp, base-36-encoded so every character is alphanumeric. Tests
|
||||
// also use this slug as a project identifier, which Vikunja caps at 10
|
||||
// chars, so the encoding has to be compact and free of separators.
|
||||
func uniqueSuffix() string {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
|
||||
// identifier returns a stable 10-char-or-fewer slug for use as a Vikunja
|
||||
// project identifier. The base-36 timestamp's most-significant chars
|
||||
// barely change across consecutive runs, so we use the trailing chars
|
||||
// (which carry the nanosecond entropy) and uppercase them.
|
||||
func identifier(suffix string) string {
|
||||
if len(suffix) > 8 {
|
||||
suffix = suffix[len(suffix)-8:]
|
||||
}
|
||||
return strings.ToUpper(suffix)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
// 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 (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPrime_RendersWithProjectAndBot pins the literal anchors hooks depend
|
||||
// on. Mirrors plan e2e test 12.
|
||||
func TestPrime_RendersWithProjectAndBot(t *testing.T) {
|
||||
ws, h := provisionWorkspace(t)
|
||||
cfg := loadConfig(t, ws)
|
||||
|
||||
out, _, code := h.Run(t, ws, "prime")
|
||||
if code != 0 {
|
||||
t.Fatalf("prime exit %d", code)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"<EXTREMELY_IMPORTANT>",
|
||||
cfg.Bot.Username,
|
||||
"Refs:",
|
||||
"veans claim",
|
||||
"Todo",
|
||||
"In Progress",
|
||||
"In Review",
|
||||
"Done",
|
||||
"Scrapped",
|
||||
}
|
||||
for _, s := range mustContain {
|
||||
if !strings.Contains(out, s) {
|
||||
t.Errorf("prime output missing %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrime_SilentOutsideWorkspace verifies the safe-globally-installed
|
||||
// hook contract: no .veans.yml ⇒ silent + exit 0.
|
||||
func TestPrime_SilentOutsideWorkspace(t *testing.T) {
|
||||
h := New(t)
|
||||
|
||||
// A workspace with no .veans.yml — just a temp dir.
|
||||
ws := h.NewWorkspace(t)
|
||||
|
||||
stdout, stderr, code := h.Run(t, ws, "prime")
|
||||
if code != 0 {
|
||||
t.Fatalf("prime exit %d (expected 0)\n%s\n%s", code, stdout, stderr)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Fatalf("expected silent stdout outside workspace, got: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// 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 (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
)
|
||||
|
||||
// provisionWorkspace runs `veans init` against a fresh project and returns
|
||||
// the workspace + harness primed for command-level e2e tests. Each test that
|
||||
// needs a working .veans.yml calls this at the top.
|
||||
func provisionWorkspace(t *testing.T) (*Workspace, *Harness) {
|
||||
t.Helper()
|
||||
h := New(t)
|
||||
suffix := uniqueSuffix()
|
||||
project := h.CreateProject(t, "veans-e2e-"+suffix, identifier(suffix))
|
||||
view := h.FindKanbanView(t, project.ID)
|
||||
|
||||
ws := h.NewWorkspace(t)
|
||||
ws.BotUsername = "bot-veans-e2e-" + suffix
|
||||
|
||||
_, stderr, code := h.Run(t, ws,
|
||||
"init",
|
||||
"--server", h.APIURL,
|
||||
"--token", h.AdminToken,
|
||||
"--project", strconv.FormatInt(project.ID, 10),
|
||||
"--view", strconv.FormatInt(view.ID, 10),
|
||||
"--bot-username", ws.BotUsername,
|
||||
"--yes-buckets",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("provision init failed: %s", stderr)
|
||||
}
|
||||
return ws, h
|
||||
}
|
||||
|
||||
// loadConfig reads .veans.yml out of a workspace.
|
||||
func loadConfig(t *testing.T, ws *Workspace) *config.Config {
|
||||
t.Helper()
|
||||
c, err := config.Load(ws.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// gitInWorkspace runs git inside the workspace and fails the test on error.
|
||||
func gitInWorkspace(t *testing.T, ws *Workspace, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(t.Context(), "git", args...)
|
||||
cmd.Dir = ws.Dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
// TestCreateShowList_RoundTrip verifies the read+write path against a real
|
||||
// Vikunja: provision a workspace via init, create a task, show it, list it
|
||||
// (with --filter), and confirm the JSON shapes are unwrapped raw object/array.
|
||||
func TestCreateShowList_RoundTrip(t *testing.T) {
|
||||
ws, h := provisionWorkspace(t)
|
||||
|
||||
// Create a task with a description and a label.
|
||||
out, errOut, code := h.Run(t, ws,
|
||||
"create", "Test bug fix",
|
||||
"-d", "## Repro\n- [ ] step 1\n- [ ] step 2",
|
||||
"--label", "bug",
|
||||
"--priority", "3",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("create exit %d\n%s\n%s", code, out, errOut)
|
||||
}
|
||||
var created client.Task
|
||||
if err := json.Unmarshal([]byte(out), &created); err != nil {
|
||||
t.Fatalf("decode create: %v\n%s", err, out)
|
||||
}
|
||||
if created.Title != "Test bug fix" {
|
||||
t.Fatalf("created title = %q", created.Title)
|
||||
}
|
||||
if created.Priority != 3 {
|
||||
t.Fatalf("priority = %d", created.Priority)
|
||||
}
|
||||
if !taskHasLabelTitle(&created, "veans:bug") {
|
||||
t.Fatalf("expected veans:bug label on created task; got %+v", created.Labels)
|
||||
}
|
||||
|
||||
// Show with --json — should be a raw object, not enveloped.
|
||||
id := fmt.Sprintf("%d", created.Index)
|
||||
showOut, _, code := h.Run(t, ws, "show", id)
|
||||
if code != 0 {
|
||||
t.Fatalf("show exit %d", code)
|
||||
}
|
||||
var shown client.Task
|
||||
if err := json.Unmarshal([]byte(showOut), &shown); err != nil {
|
||||
t.Fatalf("decode show: %v\n%s", err, showOut)
|
||||
}
|
||||
if shown.ID != created.ID {
|
||||
t.Fatalf("show returned wrong task: %d vs %d", shown.ID, created.ID)
|
||||
}
|
||||
|
||||
// List with --json — should be a raw array.
|
||||
listOut, _, code := h.Run(t, ws, "list")
|
||||
if code != 0 {
|
||||
t.Fatalf("list exit %d", code)
|
||||
}
|
||||
var listed []*client.Task
|
||||
if err := json.Unmarshal([]byte(listOut), &listed); err != nil {
|
||||
t.Fatalf("decode list: %v\n%s", err, listOut)
|
||||
}
|
||||
if len(listed) == 0 {
|
||||
t.Fatalf("list returned empty array; expected at least our created task")
|
||||
}
|
||||
|
||||
// --filter passthrough: only items with priority > 2.
|
||||
filterOut, _, code := h.Run(t, ws, "list", "--filter", "priority > 2")
|
||||
if code != 0 {
|
||||
t.Fatalf("list --filter exit %d\n%s", code, filterOut)
|
||||
}
|
||||
var filtered []*client.Task
|
||||
if err := json.Unmarshal([]byte(filterOut), &filtered); err != nil {
|
||||
t.Fatalf("decode filter list: %v", err)
|
||||
}
|
||||
for _, ft := range filtered {
|
||||
if ft.Priority <= 2 {
|
||||
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
|
||||
// behavior: the "old" string must match exactly once, otherwise the update
|
||||
// errors and nothing changes.
|
||||
func TestUpdate_DescriptionReplaceUniqueness(t *testing.T) {
|
||||
ws, h := provisionWorkspace(t)
|
||||
|
||||
out, errOut, code := h.Run(t, ws, "create", "checkbox task",
|
||||
"-d", "- [ ] step 1\n- [ ] step 1 (again)",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("create exit %d\n%s\n%s", code, out, errOut)
|
||||
}
|
||||
var created client.Task
|
||||
if err := json.Unmarshal([]byte(out), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := fmt.Sprintf("%d", created.Index)
|
||||
|
||||
// Non-unique replace should fail with a validation error and a
|
||||
// non-zero exit code.
|
||||
_, stderr, code := h.Run(t, ws,
|
||||
"update", id,
|
||||
"--description-replace-old", "step 1",
|
||||
"--description-replace-new", "step 1 [x]",
|
||||
)
|
||||
if code == 0 {
|
||||
t.Fatalf("expected non-zero exit on non-unique replace")
|
||||
}
|
||||
if !strings.Contains(stderr, "VALIDATION_ERROR") {
|
||||
t.Fatalf("expected VALIDATION_ERROR in stderr, got: %s", stderr)
|
||||
}
|
||||
|
||||
// Disambiguate and try again — should succeed.
|
||||
_, _, code = h.Run(t, ws,
|
||||
"update", id,
|
||||
"--description-replace-old", "- [ ] step 1\n",
|
||||
"--description-replace-new", "- [x] step 1\n",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("disambiguated update should succeed; exit %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func taskHasLabelTitle(t *client.Task, title string) bool {
|
||||
for _, l := range t.Labels {
|
||||
if l != nil && l.Title == title {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
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/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ=
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
|
||||
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
// 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 auth handles the human's transient authentication during init and
|
||||
// login. The default interactive flow is OAuth 2.0 Authorization Code + PKCE
|
||||
// against Vikunja's built-in authorization server. The OAuth dance opens a
|
||||
// browser at the authorize URL; the user signs in and lands on a localhost
|
||||
// callback this CLI ran. --token / --use-password / --username + --password
|
||||
// are escape hatches for non-interactive contexts.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// Prompter abstracts stdin / TTY reads so tests can inject scripted answers.
|
||||
type Prompter interface {
|
||||
ReadLine(prompt string) (string, error)
|
||||
ReadPassword(prompt string) (string, error)
|
||||
}
|
||||
|
||||
// StdPrompter reads from os.Stdin and writes prompts to os.Stderr; uses
|
||||
// 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{stdin: bufio.NewReader(os.Stdin)}
|
||||
}
|
||||
|
||||
func (p *StdPrompter) ReadLine(prompt string) (string, error) {
|
||||
if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err := p.stdin.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
|
||||
func (p *StdPrompter) ReadPassword(prompt string) (string, error) {
|
||||
if _, err := fmt.Fprint(os.Stderr, prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
buf, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
// Non-TTY (CI, scripted test) — read a plain line.
|
||||
return p.ReadLine("")
|
||||
}
|
||||
|
||||
// LoginOptions controls how AcquireHumanToken obtains a JWT.
|
||||
type LoginOptions struct {
|
||||
// Token short-circuits all flows. May be a JWT or a personal API token.
|
||||
Token string
|
||||
// UsePassword forces the legacy POST /login flow even when no password
|
||||
// is set yet (the prompter will ask for it). Useful on instances where
|
||||
// OAuth is disabled or the user prefers entering a password.
|
||||
UsePassword bool
|
||||
// Username / Password / TOTP feed POST /login. If both Username and
|
||||
// Password are non-empty, AcquireHumanToken uses /login non-interactively
|
||||
// regardless of UsePassword.
|
||||
Username string
|
||||
Password string
|
||||
TOTP string
|
||||
// Out is where progress / OAuth instructions are written. Defaults to
|
||||
// os.Stderr in production via NewStdPrompter; tests can pass any writer.
|
||||
Out io.Writer
|
||||
}
|
||||
|
||||
// AcquireHumanToken returns a bearer token to act as the human during init.
|
||||
// Resolution order:
|
||||
// 1. opts.Token (paste-in or --token flag)
|
||||
// 2. POST /login with Username + Password (used non-interactively when both
|
||||
// are set, or when --use-password is passed)
|
||||
// 3. OAuth Authorization Code + PKCE flow with manual callback paste-back
|
||||
// (the default for interactive use)
|
||||
func AcquireHumanToken(ctx context.Context, c *client.Client, opts LoginOptions, p Prompter) (string, error) {
|
||||
if opts.Token != "" {
|
||||
return opts.Token, nil
|
||||
}
|
||||
if p == nil {
|
||||
p = NewStdPrompter()
|
||||
}
|
||||
w := opts.Out
|
||||
if w == nil {
|
||||
w = os.Stderr
|
||||
}
|
||||
|
||||
usePassword := opts.UsePassword || (opts.Username != "" && opts.Password != "")
|
||||
if usePassword {
|
||||
return loginWithPassword(ctx, c, opts, p)
|
||||
}
|
||||
|
||||
return runOAuthFlow(ctx, c, p, w)
|
||||
}
|
||||
|
||||
// loginWithPassword runs the legacy POST /login path. Kept for instances
|
||||
// that have OAuth disabled or for non-interactive `--username` + `--password`
|
||||
// invocations in CI.
|
||||
func loginWithPassword(ctx context.Context, c *client.Client, opts LoginOptions, p Prompter) (string, error) {
|
||||
if opts.Username == "" {
|
||||
u, err := p.ReadLine("Vikunja username: ")
|
||||
if err != nil {
|
||||
return "", output.Wrap(output.CodeAuth, err, "read username: %v", err)
|
||||
}
|
||||
opts.Username = strings.TrimSpace(u)
|
||||
}
|
||||
if opts.Password == "" {
|
||||
pw, err := p.ReadPassword("Vikunja password: ")
|
||||
if err != nil {
|
||||
return "", output.Wrap(output.CodeAuth, err, "read password: %v", err)
|
||||
}
|
||||
opts.Password = pw
|
||||
}
|
||||
if opts.Username == "" || opts.Password == "" {
|
||||
return "", output.New(output.CodeAuth, "username and password are required for password login")
|
||||
}
|
||||
resp, err := c.Login(ctx, &client.LoginRequest{
|
||||
Username: opts.Username,
|
||||
Password: opts.Password,
|
||||
TOTPPasscode: opts.TOTP,
|
||||
LongToken: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Token == "" {
|
||||
return "", output.New(output.CodeAuth, "login returned empty token")
|
||||
}
|
||||
return resp.Token, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
func TestAcquireHumanToken_TokenShortCircuit(t *testing.T) {
|
||||
// When opts.Token is set, no prompts and no HTTP calls happen — the
|
||||
// nil client confirms that nothing tries to dial out.
|
||||
tok, err := AcquireHumanToken(context.Background(), (*client.Client)(nil), LoginOptions{Token: "abc"}, &recordingPrompter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != "abc" {
|
||||
t.Fatalf("got %q, want abc", tok)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingPrompter struct {
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (r *recordingPrompter) ReadLine(p string) (string, error) {
|
||||
r.calls = append(r.calls, "line:"+p)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *recordingPrompter) ReadPassword(p string) (string, error) {
|
||||
r.calls = append(r.calls, "pw:"+p)
|
||||
return "", nil
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// oauthClientID is what veans presents to Vikunja's authorization server.
|
||||
// Vikunja's OAuth provider doesn't require client registration — the value
|
||||
// just needs to be consistent across the authorize and token-exchange steps.
|
||||
const oauthClientID = "veans-cli"
|
||||
|
||||
// loopbackTimeout caps how long we wait for the user to complete the
|
||||
// browser-side handshake before giving up.
|
||||
const loopbackTimeout = 5 * time.Minute
|
||||
|
||||
// PKCEPair holds the challenge sent to /oauth/authorize and the verifier
|
||||
// kept locally until token exchange.
|
||||
type PKCEPair struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
}
|
||||
|
||||
// generatePKCE produces a fresh (verifier, challenge) pair per RFC 7636.
|
||||
// The verifier is 64 random bytes, base64url-encoded without padding (~86
|
||||
// characters — comfortably inside the 43–128 range Vikunja accepts). The
|
||||
// challenge is the SHA-256 of the verifier, also base64url-no-pad.
|
||||
func generatePKCE() (PKCEPair, error) {
|
||||
buf := make([]byte, 64)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return PKCEPair{}, err
|
||||
}
|
||||
verifier := base64.RawURLEncoding.EncodeToString(buf)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return PKCEPair{Verifier: verifier, Challenge: challenge}, nil
|
||||
}
|
||||
|
||||
// generateState returns a random opaque string for CSRF protection.
|
||||
func generateState() (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// buildAuthorizeURL renders the browser-side redirect target.
|
||||
func buildAuthorizeURL(server, redirectURI string, pkce PKCEPair, state string) string {
|
||||
q := url.Values{}
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", oauthClientID)
|
||||
q.Set("redirect_uri", redirectURI)
|
||||
q.Set("code_challenge", pkce.Challenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
q.Set("state", state)
|
||||
return strings.TrimRight(server, "/") + "/oauth/authorize?" + q.Encode()
|
||||
}
|
||||
|
||||
// callbackResult carries the parsed query parameters from the loopback
|
||||
// callback request, or any error that prevented a clean handshake.
|
||||
type callbackResult struct {
|
||||
code string
|
||||
state string
|
||||
err error
|
||||
}
|
||||
|
||||
// runOAuthFlow drives an OAuth Authorization Code + PKCE handshake against
|
||||
// Vikunja's server using a localhost loopback listener (RFC 8252):
|
||||
// bind 127.0.0.1:0, open the authorize URL in the browser, capture the
|
||||
// callback, exchange the code for a token.
|
||||
//
|
||||
// The prompter is retained on the signature for symmetry with the
|
||||
// password flow but isn't called — the loopback handshake completes
|
||||
// without further user input beyond the in-browser sign-in.
|
||||
func runOAuthFlow(ctx context.Context, c *client.Client, _ Prompter, w io.Writer) (string, error) {
|
||||
pkce, err := generatePKCE()
|
||||
if err != nil {
|
||||
return "", output.Wrap(output.CodeUnknown, err, "generate PKCE: %v", err)
|
||||
}
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
return "", output.Wrap(output.CodeUnknown, err, "generate state: %v", err)
|
||||
}
|
||||
|
||||
listener, redirectURI, err := bindLoopbackListener(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
server, resultCh := newCallbackServer(listener)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
// Shutdown uses a detached context derived from ctx so cancellation
|
||||
// at the outer scope still allows the graceful-stop to drain.
|
||||
shutdownParent := context.WithoutCancel(ctx)
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(shutdownParent, 2*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
authURL := buildAuthorizeURL(c.BaseURL, redirectURI, pkce, state)
|
||||
announceBrowserStep(w, authURL)
|
||||
// Best-effort browser launch — the URL is also printed so the user
|
||||
// can paste it manually if their environment can't auto-open one
|
||||
// (SSH session, container without DISPLAY, etc.).
|
||||
_ = browser.OpenURL(authURL)
|
||||
|
||||
result, err := waitForCallback(ctx, resultCh)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.state != state {
|
||||
return "", output.New(output.CodeAuth,
|
||||
"state mismatch on OAuth callback (possible CSRF)")
|
||||
}
|
||||
|
||||
resp, err := c.ExchangeOAuthCode(ctx, &client.OAuthTokenRequest{
|
||||
GrantType: "authorization_code",
|
||||
Code: result.code,
|
||||
ClientID: oauthClientID,
|
||||
RedirectURI: redirectURI,
|
||||
CodeVerifier: pkce.Verifier,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
return "", output.New(output.CodeAuth, "OAuth token exchange returned empty access_token")
|
||||
}
|
||||
return resp.AccessToken, nil
|
||||
}
|
||||
|
||||
// bindLoopbackListener picks a free port on 127.0.0.1 and returns a
|
||||
// listener + the corresponding `http://127.0.0.1:NNN/callback` URI for
|
||||
// the OAuth `redirect_uri` parameter.
|
||||
func bindLoopbackListener(ctx context.Context) (net.Listener, string, error) {
|
||||
var lc net.ListenConfig
|
||||
listener, err := lc.Listen(ctx, "tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, "", output.Wrap(output.CodeUnknown, err,
|
||||
"bind loopback port for OAuth callback: %v", err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
return listener, fmt.Sprintf("http://127.0.0.1:%d/callback", port), nil
|
||||
}
|
||||
|
||||
// newCallbackServer returns an http.Server bound to `listener` whose
|
||||
// /callback handler parses the authorization-server redirect query and
|
||||
// pushes the result onto the returned channel.
|
||||
func newCallbackServer(listener net.Listener) (*http.Server, <-chan callbackResult) {
|
||||
resultCh := make(chan callbackResult, 1)
|
||||
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 != "" {
|
||||
desc := q.Get("error_description")
|
||||
if desc == "" {
|
||||
desc = errCode
|
||||
}
|
||||
res.err = fmt.Errorf("authorization failed: %s", desc)
|
||||
}
|
||||
renderCallbackPage(rw, res.err)
|
||||
select {
|
||||
case resultCh <- res:
|
||||
default:
|
||||
}
|
||||
}),
|
||||
}
|
||||
return server, resultCh
|
||||
}
|
||||
|
||||
// waitForCallback blocks until the loopback handler fires, ctx cancels,
|
||||
// or loopbackTimeout elapses.
|
||||
func waitForCallback(ctx context.Context, resultCh <-chan callbackResult) (callbackResult, error) {
|
||||
timer := time.NewTimer(loopbackTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
if result.err != nil {
|
||||
return result, output.Wrap(output.CodeAuth, result.err, "%v", result.err)
|
||||
}
|
||||
if result.code == "" {
|
||||
return result, output.New(output.CodeAuth, "no `code` returned from OAuth callback")
|
||||
}
|
||||
return result, nil
|
||||
case <-timer.C:
|
||||
return callbackResult{}, output.New(output.CodeAuth,
|
||||
"OAuth flow timed out after %s — re-run init with --use-password or --token", loopbackTimeout)
|
||||
case <-ctx.Done():
|
||||
return callbackResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func announceBrowserStep(w io.Writer, authURL string) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Opening your browser to authorize veans:")
|
||||
fmt.Fprintln(w, " "+authURL)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "If the browser doesn't open, paste the URL above manually.")
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// renderCallbackPage writes a minimal HTML response to the user's browser
|
||||
// after the loopback callback fires. We don't ship any framework — a few
|
||||
// lines of inlined HTML are enough to confirm completion.
|
||||
func renderCallbackPage(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
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>`, 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">
|
||||
<h1>veans is authorized</h1>
|
||||
<p>You can close this tab and return to the terminal.</p>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
// silence the unused-import linter when errors isn't referenced elsewhere.
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratePKCE_VerifierShape(t *testing.T) {
|
||||
pair, err := generatePKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// RFC 7636 §4.1: verifier is 43–128 chars, [A-Za-z0-9-._~].
|
||||
if len(pair.Verifier) < 43 || len(pair.Verifier) > 128 {
|
||||
t.Fatalf("verifier length %d out of [43,128]", len(pair.Verifier))
|
||||
}
|
||||
for _, r := range pair.Verifier {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z',
|
||||
r >= 'a' && r <= 'z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-', r == '.', r == '_', r == '~':
|
||||
default:
|
||||
t.Fatalf("verifier contains illegal rune %q", r)
|
||||
}
|
||||
}
|
||||
// Challenge must be SHA256(verifier) base64url-no-pad.
|
||||
want := sha256.Sum256([]byte(pair.Verifier))
|
||||
got, err := base64.RawURLEncoding.DecodeString(pair.Challenge)
|
||||
if err != nil {
|
||||
t.Fatalf("challenge isn't base64url-no-pad: %v", err)
|
||||
}
|
||||
if string(got) != string(want[:]) {
|
||||
t.Fatal("challenge != SHA256(verifier)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePKCE_Unique(t *testing.T) {
|
||||
a, _ := generatePKCE()
|
||||
b, _ := generatePKCE()
|
||||
if a.Verifier == b.Verifier {
|
||||
t.Fatal("two consecutive verifiers are identical — entropy is broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizeURL(t *testing.T) {
|
||||
u := buildAuthorizeURL(
|
||||
"https://vikunja.example.com",
|
||||
"http://127.0.0.1:54321/callback",
|
||||
PKCEPair{Challenge: "CHL"},
|
||||
"S",
|
||||
)
|
||||
if !strings.HasPrefix(u, "https://vikunja.example.com/oauth/authorize?") {
|
||||
t.Fatalf("unexpected prefix: %s", u)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"response_type=code",
|
||||
"client_id=" + oauthClientID,
|
||||
"code_challenge=CHL",
|
||||
"code_challenge_method=S256",
|
||||
"state=S",
|
||||
// redirect_uri carried through (URL-encoded)
|
||||
"redirect_uri=http%3A%2F%2F127.0.0.1%3A54321%2Fcallback",
|
||||
} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Errorf("authorize URL missing %q: %s", want, u)
|
||||
}
|
||||
}
|
||||
// Server URL with trailing slash should still produce a single slash
|
||||
// before the path.
|
||||
u2 := buildAuthorizeURL("https://vikunja.example.com/", "", PKCEPair{}, "")
|
||||
if strings.Contains(u2, "//oauth") {
|
||||
t.Errorf("trailing slash leaked into URL: %s", u2)
|
||||
}
|
||||
}
|
||||
|
||||
// newCallbackHandler returns just the http.Handler portion of
|
||||
// newCallbackServer so tests can drive it directly with httptest.NewRecorder
|
||||
// without binding a real loopback socket.
|
||||
func newCallbackHandler(t *testing.T) (http.Handler, <-chan callbackResult) {
|
||||
t.Helper()
|
||||
var lc net.ListenConfig
|
||||
listener, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
server, ch := newCallbackServer(listener)
|
||||
return server.Handler, ch
|
||||
}
|
||||
|
||||
func TestNewCallbackServer_HappyPath(t *testing.T) {
|
||||
handler, ch := newCallbackHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/callback?code=abc&state=xyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res.code != "abc" {
|
||||
t.Errorf("code = %q, want abc", res.code)
|
||||
}
|
||||
if res.state != "xyz" {
|
||||
t.Errorf("state = %q, want xyz", res.state)
|
||||
}
|
||||
if res.err != nil {
|
||||
t.Errorf("err = %v, want nil", res.err)
|
||||
}
|
||||
default:
|
||||
t.Fatal("no result pushed to channel")
|
||||
}
|
||||
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html…", ct)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCallbackServer_AuthzServerError(t *testing.T) {
|
||||
handler, ch := newCallbackHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/callback?error=access_denied&error_description=user+declined", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res.err == nil {
|
||||
t.Fatal("err = nil, want non-nil")
|
||||
}
|
||||
// renderCallbackPage uses error_description when present; the
|
||||
// handler also stuffs it into res.err. "user declined" comes
|
||||
// straight from error_description.
|
||||
if !strings.Contains(res.err.Error(), "user declined") {
|
||||
t.Errorf("err = %q, want it to mention error_description", res.err.Error())
|
||||
}
|
||||
default:
|
||||
t.Fatal("no result pushed to channel")
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCallbackServer_AuthzServerErrorOnlyCode(t *testing.T) {
|
||||
// When error_description is empty, the handler falls back to the
|
||||
// `error` code in the user-visible message.
|
||||
handler, ch := newCallbackHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/callback?error=access_denied", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res.err == nil {
|
||||
t.Fatal("err = nil, want non-nil")
|
||||
}
|
||||
if !strings.Contains(res.err.Error(), "access_denied") {
|
||||
t.Errorf("err = %q, want it to mention error code", res.err.Error())
|
||||
}
|
||||
default:
|
||||
t.Fatal("no result pushed to channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCallbackServer_EmptyCode(t *testing.T) {
|
||||
// Empty `code` without an `error` parameter is the handler's job
|
||||
// only to forward verbatim — waitForCallback is the one that
|
||||
// upgrades that to an error.
|
||||
handler, ch := newCallbackHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/callback?state=xyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res.code != "" {
|
||||
t.Errorf("code = %q, want empty", res.code)
|
||||
}
|
||||
if res.state != "xyz" {
|
||||
t.Errorf("state = %q, want xyz", res.state)
|
||||
}
|
||||
if res.err != nil {
|
||||
t.Errorf("err = %v, want nil", res.err)
|
||||
}
|
||||
default:
|
||||
t.Fatal("no result pushed to channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCallbackServer_MethodNotAllowed(t *testing.T) {
|
||||
handler, ch := newCallbackHandler(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/callback?code=abc&state=xyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
if a := rec.Header().Get("Allow"); a != "GET" {
|
||||
t.Errorf("Allow header = %q, want GET", a)
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
t.Fatalf("nothing should be pushed for a rejected method, got %+v", res)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCallbackServer_WrongPath(t *testing.T) {
|
||||
handler, ch := newCallbackHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/something-else", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
t.Fatalf("nothing should be pushed for a 404, got %+v", res)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCallbackPage_HTMLEscapesError(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
renderCallbackPage(rec, &fakeError{msg: `<script>alert(1)</script>`})
|
||||
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Errorf("body contains raw <script>: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Errorf("body missing escaped script tag: %s", body)
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400 on error", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCallbackPage_SuccessNoErrorPath(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
renderCallbackPage(rec, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "veans is authorized") {
|
||||
t.Errorf("missing success message: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeError struct{ msg string }
|
||||
|
||||
func (f *fakeError) Error() string { return f.msg }
|
||||
|
|
@ -0,0 +1,627 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
"code.vikunja.io/veans/internal/status"
|
||||
)
|
||||
|
||||
func TestValidateBotUsername(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantValid bool
|
||||
}{
|
||||
// Valid names.
|
||||
{"valid simple", "bot-foo", true},
|
||||
{"valid multi-hyphen", "bot-foo-bar", true},
|
||||
{"valid digits", "bot-foo123", true},
|
||||
{"valid underscore", "bot-foo_bar", true},
|
||||
{"valid dot", "bot-foo.bar", true},
|
||||
{"valid single letter", "bot-a", true},
|
||||
|
||||
// Invalid: missing/malformed bot- prefix.
|
||||
{"missing prefix", "foo", false},
|
||||
{"uppercase prefix", "Bot-foo", false},
|
||||
{"empty", "", false},
|
||||
|
||||
// Invalid: forbidden characters in the body.
|
||||
{"space after prefix", "bot- foo", false},
|
||||
{"comma", "bot-foo,bar", false},
|
||||
{"uppercase body", "bot-FOO", false},
|
||||
{"bang", "bot-foo!", false},
|
||||
{"space in body", "bot-foo bar", false},
|
||||
|
||||
// Invalid: reserved link-share pattern.
|
||||
{"link-share-0", "bot-link-share-0", false},
|
||||
{"link-share-1", "bot-link-share-1", false},
|
||||
{"link-share-42", "bot-link-share-42", false},
|
||||
|
||||
// Edge: regex `^bot-[a-z0-9][a-z0-9._-]*$` requires at least one char
|
||||
// after the `bot-` prefix, so a bare prefix is rejected.
|
||||
{"bare prefix", "bot-", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateBotUsername(tc.input)
|
||||
if tc.wantValid {
|
||||
if err != nil {
|
||||
t.Fatalf("validateBotUsername(%q) = %v, want nil", tc.input, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("validateBotUsername(%q) = nil, want error", tc.input)
|
||||
}
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("validateBotUsername(%q): expected *output.Error, got %T", tc.input, err)
|
||||
}
|
||||
if oe.Code != output.CodeValidation {
|
||||
t.Errorf("validateBotUsername(%q): code = %q, want %q", tc.input, oe.Code, output.CodeValidation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// queuePrompter is a richer scriptedPrompter that can also return an error
|
||||
// on a chosen call (to simulate stdin read failures) and tracks how many
|
||||
// times ReadLine was invoked. Defined locally because the existing
|
||||
// scriptedPrompter in botuser_test.go can't inject errors.
|
||||
type queuePrompter struct {
|
||||
answers []string
|
||||
err error // returned on every call once exhausted, or immediately if no answers
|
||||
calls int
|
||||
}
|
||||
|
||||
func (q *queuePrompter) ReadLine(_ string) (string, error) {
|
||||
q.calls++
|
||||
if q.err != nil {
|
||||
return "", q.err
|
||||
}
|
||||
if q.calls-1 >= len(q.answers) {
|
||||
return "", nil
|
||||
}
|
||||
return q.answers[q.calls-1], nil
|
||||
}
|
||||
|
||||
func (q *queuePrompter) ReadPassword(_ string) (string, error) { return "", nil }
|
||||
|
||||
// errReadFailure is a sentinel used to simulate a stdin read failure
|
||||
// inside the prompter. Kept at package level to satisfy err113's
|
||||
// preference for static errors (test files are exempt, but using a
|
||||
// named value reads more clearly than fmt.Errorf at the call site).
|
||||
var errReadFailure = errors.New("simulated stdin read failure")
|
||||
|
||||
func TestConfirmOverwriteExistingConfig(t *testing.T) {
|
||||
t.Run("file missing — no prompt", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := &queuePrompter{}
|
||||
opts := &Options{ConfigPath: filepath.Join(dir, "does-not-exist.yml")}
|
||||
if err := confirmOverwriteExistingConfig(opts, p); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if p.calls != 0 {
|
||||
t.Errorf("prompter called %d times, want 0", p.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OverwriteExistingConfig=true — no prompt", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &queuePrompter{}
|
||||
opts := &Options{ConfigPath: path, OverwriteExistingConfig: true}
|
||||
if err := confirmOverwriteExistingConfig(opts, p); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if p.calls != 0 {
|
||||
t.Errorf("prompter called %d times, want 0", p.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("answers — yes/no/error", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
yesAnswers := []string{"y", "yes", "Y", "Yes", " yes "}
|
||||
for _, ans := range yesAnswers {
|
||||
p := &queuePrompter{answers: []string{ans}}
|
||||
opts := &Options{ConfigPath: path}
|
||||
if err := confirmOverwriteExistingConfig(opts, p); err != nil {
|
||||
t.Errorf("answer %q: unexpected error: %v", ans, err)
|
||||
}
|
||||
}
|
||||
|
||||
// "n", "", and any other input → conflict.
|
||||
noAnswers := []string{"n", "", "no", "garbage"}
|
||||
for _, ans := range noAnswers {
|
||||
p := &queuePrompter{answers: []string{ans}}
|
||||
opts := &Options{ConfigPath: path}
|
||||
err := confirmOverwriteExistingConfig(opts, p)
|
||||
if err == nil {
|
||||
t.Errorf("answer %q: expected error, got nil", ans)
|
||||
continue
|
||||
}
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Errorf("answer %q: want *output.Error, got %T", ans, err)
|
||||
continue
|
||||
}
|
||||
if oe.Code != output.CodeConflict {
|
||||
t.Errorf("answer %q: code = %q, want %q", ans, oe.Code, output.CodeConflict)
|
||||
}
|
||||
if !strings.Contains(oe.Message, path) {
|
||||
t.Errorf("answer %q: message %q should contain config path %q", ans, oe.Message, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Prompter read failure → wrapped as CodeUnknown.
|
||||
p := &queuePrompter{err: errReadFailure}
|
||||
opts := &Options{ConfigPath: path}
|
||||
err := confirmOverwriteExistingConfig(opts, p)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from prompter failure, got nil")
|
||||
}
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("want *output.Error, got %T", err)
|
||||
}
|
||||
if oe.Code != output.CodeUnknown {
|
||||
t.Errorf("code = %q, want %q", oe.Code, output.CodeUnknown)
|
||||
}
|
||||
if !errors.Is(err, errReadFailure) {
|
||||
t.Errorf("wrapped error should unwrap to errReadFailure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// bucketServer is a minimal httptest server modelling
|
||||
// GET/PUT /api/v1/projects/{p}/views/{v}/buckets. The caller pre-seeds
|
||||
// existing buckets; PUT requests append to that list with a synthetic ID.
|
||||
type bucketServer struct {
|
||||
mu sync.Mutex
|
||||
existing []*client.Bucket
|
||||
creates []*client.Bucket // recorded create payloads (in order)
|
||||
nextID int64
|
||||
}
|
||||
|
||||
func newBucketServer(seed []*client.Bucket) *bucketServer {
|
||||
s := &bucketServer{existing: seed, nextID: 1000}
|
||||
for _, b := range seed {
|
||||
if b.ID >= s.nextID {
|
||||
s.nextID = b.ID + 1
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *bucketServer) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Path is /api/v1/projects/{p}/views/{v}/buckets.
|
||||
if !strings.HasSuffix(r.URL.Path, "/buckets") || !strings.Contains(r.URL.Path, "/views/") {
|
||||
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(s.existing)
|
||||
case http.MethodPut:
|
||||
var b client.Bucket
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
b.ID = s.nextID
|
||||
s.nextID++
|
||||
created := &client.Bucket{ID: b.ID, Title: b.Title, ProjectViewID: b.ProjectViewID}
|
||||
s.existing = append(s.existing, created)
|
||||
s.creates = append(s.creates, created)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(created)
|
||||
default:
|
||||
http.Error(w, "method not allowed: "+r.Method, http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// allBucketsSeed returns one bucket per canonical status (using the
|
||||
// canonical title from BucketTitle).
|
||||
func allBucketsSeed() []*client.Bucket {
|
||||
var out []*client.Bucket
|
||||
id := int64(10)
|
||||
for _, s := range status.All() {
|
||||
out = append(out, &client.Bucket{ID: id, Title: s.BucketTitle()})
|
||||
id++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBootstrapBuckets_AllPresent_NoPrompt(t *testing.T) {
|
||||
srv := newBucketServer(allBucketsSeed())
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := client.New(ts.URL, "token")
|
||||
p := &queuePrompter{} // any call would still return "" but we'll assert calls==0
|
||||
var buf bytes.Buffer
|
||||
opts := &Options{Out: &buf}
|
||||
|
||||
buckets, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if p.calls != 0 {
|
||||
t.Errorf("prompter called %d times, want 0 (no missing buckets means no prompt)", p.calls)
|
||||
}
|
||||
if len(srv.creates) != 0 {
|
||||
t.Errorf("CreateBucket called %d times, want 0", len(srv.creates))
|
||||
}
|
||||
if buckets.Todo == 0 || buckets.InProgress == 0 || buckets.InReview == 0 || buckets.Done == 0 || buckets.Scrapped == 0 {
|
||||
t.Errorf("expected all bucket IDs populated, got %+v", buckets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapBuckets_AutoApprove_CreatesMissing(t *testing.T) {
|
||||
// Seed only Todo; the other four are missing.
|
||||
srv := newBucketServer([]*client.Bucket{
|
||||
{ID: 10, Title: status.Todo.BucketTitle()},
|
||||
})
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := client.New(ts.URL, "token")
|
||||
p := &queuePrompter{}
|
||||
var buf bytes.Buffer
|
||||
opts := &Options{Out: &buf, AutoApproveBuckets: true}
|
||||
|
||||
buckets, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if p.calls != 0 {
|
||||
t.Errorf("AutoApprove should skip prompt; got %d calls", p.calls)
|
||||
}
|
||||
if len(srv.creates) != 4 {
|
||||
t.Errorf("expected 4 buckets created (the missing ones), got %d", len(srv.creates))
|
||||
}
|
||||
if buckets.Todo != 10 {
|
||||
t.Errorf("existing Todo bucket should be reused, got id=%d", buckets.Todo)
|
||||
}
|
||||
if buckets.InProgress == 0 || buckets.InReview == 0 || buckets.Done == 0 || buckets.Scrapped == 0 {
|
||||
t.Errorf("missing buckets not populated: %+v", buckets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapBuckets_PromptDeclined(t *testing.T) {
|
||||
srv := newBucketServer([]*client.Bucket{
|
||||
{ID: 10, Title: status.Todo.BucketTitle()},
|
||||
})
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := client.New(ts.URL, "token")
|
||||
p := &queuePrompter{answers: []string{"n"}}
|
||||
var buf bytes.Buffer
|
||||
opts := &Options{Out: &buf}
|
||||
|
||||
_, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on declined prompt, got nil")
|
||||
}
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("want *output.Error, got %T", err)
|
||||
}
|
||||
if oe.Code != output.CodeValidation {
|
||||
t.Errorf("code = %q, want %q", oe.Code, output.CodeValidation)
|
||||
}
|
||||
// Message should mention at least one of the missing canonical titles.
|
||||
mentionsMissing := false
|
||||
for _, s := range status.All() {
|
||||
if s == status.Todo {
|
||||
continue // not missing
|
||||
}
|
||||
if strings.Contains(oe.Message, s.BucketTitle()) {
|
||||
mentionsMissing = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !mentionsMissing {
|
||||
t.Errorf("error message %q should mention missing bucket titles", oe.Message)
|
||||
}
|
||||
if len(srv.creates) != 0 {
|
||||
t.Errorf("no buckets should be created on decline; got %d", len(srv.creates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapBuckets_PromptAborted(t *testing.T) {
|
||||
srv := newBucketServer([]*client.Bucket{
|
||||
{ID: 10, Title: status.Todo.BucketTitle()},
|
||||
})
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := client.New(ts.URL, "token")
|
||||
// Five garbage answers (each within the unknown limit), then "a" → abort.
|
||||
p := &queuePrompter{answers: []string{"huh", "what", "?", "??", "???", "a"}}
|
||||
var buf bytes.Buffer
|
||||
opts := &Options{Out: &buf}
|
||||
|
||||
_, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
|
||||
if err == nil {
|
||||
t.Fatal("expected abort error, got nil")
|
||||
}
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("want *output.Error, got %T", err)
|
||||
}
|
||||
if oe.Code != output.CodeValidation {
|
||||
t.Errorf("code = %q, want %q", oe.Code, output.CodeValidation)
|
||||
}
|
||||
if !strings.Contains(oe.Message, "abort") {
|
||||
t.Errorf("message %q should mention user abort", oe.Message)
|
||||
}
|
||||
if len(srv.creates) != 0 {
|
||||
t.Errorf("no buckets should be created on abort; got %d", len(srv.creates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapBuckets_PromptUnknownCap(t *testing.T) {
|
||||
srv := newBucketServer([]*client.Bucket{
|
||||
{ID: 10, Title: status.Todo.BucketTitle()},
|
||||
})
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := client.New(ts.URL, "token")
|
||||
// Six garbage answers — exceeds maxUnknownAnswers (5).
|
||||
p := &queuePrompter{answers: []string{"huh", "what", "?", "??", "???", "still no"}}
|
||||
var buf bytes.Buffer
|
||||
opts := &Options{Out: &buf}
|
||||
|
||||
_, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
|
||||
if err == nil {
|
||||
t.Fatal("expected cap error, got nil")
|
||||
}
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("want *output.Error, got %T", err)
|
||||
}
|
||||
if oe.Code != output.CodeValidation {
|
||||
t.Errorf("code = %q, want %q", oe.Code, output.CodeValidation)
|
||||
}
|
||||
if !strings.Contains(oe.Message, fmt.Sprintf("%d attempts", 5)) {
|
||||
t.Errorf("message %q should mention 5 attempts", oe.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapBuckets_PromptAccepted(t *testing.T) {
|
||||
srv := newBucketServer([]*client.Bucket{
|
||||
{ID: 10, Title: status.Todo.BucketTitle()},
|
||||
})
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
|
||||
c := client.New(ts.URL, "token")
|
||||
p := &queuePrompter{answers: []string{"y"}}
|
||||
var buf bytes.Buffer
|
||||
opts := &Options{Out: &buf}
|
||||
|
||||
buckets, err := bootstrapBuckets(context.Background(), c, 1, 2, opts, p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(srv.creates) != 4 {
|
||||
t.Errorf("expected 4 missing buckets created, got %d", len(srv.creates))
|
||||
}
|
||||
if buckets.Todo != 10 || buckets.InProgress == 0 || buckets.InReview == 0 || buckets.Done == 0 || buckets.Scrapped == 0 {
|
||||
t.Errorf("buckets not populated: %+v", buckets)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
petname "github.com/dustinkirkland/golang-petname"
|
||||
|
||||
"code.vikunja.io/veans/internal/auth"
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// resolveBotUser settles the bot identity for `veans init`:
|
||||
//
|
||||
// 1. If a bot we already own with this username exists, ask whether to
|
||||
// reuse it. Reuse skips creation; the rest of init continues with
|
||||
// the existing bot's ID.
|
||||
// 2. If the username is taken by someone else, propose a petname-based
|
||||
// alternative (e.g. "bot-clever-otter") and loop on rejection.
|
||||
// 3. Otherwise, create the bot fresh.
|
||||
//
|
||||
// The flow is best-effort transparent: in non-interactive contexts
|
||||
// (--bot-username collides with someone else's bot and no TTY), we
|
||||
// surface a clear CONFLICT error pointing at --bot-username.
|
||||
func resolveBotUser(ctx context.Context, c *client.Client, username, projectTitle string, p auth.Prompter, w io.Writer) (*client.BotUser, error) {
|
||||
for {
|
||||
// Step 1 + 2: see if anyone is using this name.
|
||||
mine, err := c.FindMyBotByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, output.Wrap(output.CodeUnknown, err, "look up existing bots: %v", err)
|
||||
}
|
||||
if mine != nil {
|
||||
ok, err := confirmReuse(p, w, mine.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
progress(w, "Reusing existing bot user %q (id=%d)", mine.Username, mine.ID)
|
||||
return mine, nil
|
||||
}
|
||||
// User declined; fall through to prompt for a new name.
|
||||
username, err = promptForReplacementName(p, w, username, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 3: try creating.
|
||||
bot, err := c.CreateBotUser(ctx, username, "veans bot for "+projectTitle)
|
||||
if err == nil {
|
||||
progress(w, "Created bot user %q (id=%d)", bot.Username, bot.ID)
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// On "username already exists" we know it's owned by someone
|
||||
// other than us (we just checked FindMyBotByUsername). Anything
|
||||
// else is a real failure — surface it.
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) || !isUsernameTakenErr(oe) {
|
||||
return nil, err
|
||||
}
|
||||
username, err = promptForReplacementName(p, w, username, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// confirmReuse asks whether to reuse a bot user this caller already owns.
|
||||
// Default is yes — re-running init in a worktree that's already onboarded
|
||||
// is the common path.
|
||||
func confirmReuse(p auth.Prompter, w io.Writer, username string) (bool, error) {
|
||||
fmt.Fprintf(w, "Bot user %q already exists and is owned by you.\n", username)
|
||||
ans, err := p.ReadLine("Reuse it? [Y/n]: ")
|
||||
if err != nil {
|
||||
return false, output.Wrap(output.CodeUnknown, err, "read confirmation: %v", err)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(ans)) {
|
||||
case "", "y", "yes":
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// promptForReplacementName asks for an alternate bot username, suggesting
|
||||
// a petname-based default. ownedByOther=true means the previous name
|
||||
// collided with someone else's bot; we phrase the prompt accordingly.
|
||||
func promptForReplacementName(p auth.Prompter, w io.Writer, previous string, ownedByOther bool) (string, error) {
|
||||
suggested := suggestPetname()
|
||||
if ownedByOther {
|
||||
fmt.Fprintf(w, "Bot username %q is taken by another user.\n", previous)
|
||||
} else {
|
||||
fmt.Fprintln(w, "Pick a different bot username.")
|
||||
}
|
||||
fmt.Fprintf(w, "Suggestion: %s\n", suggested)
|
||||
ans, err := p.ReadLine(fmt.Sprintf("New bot username [%s]: ", suggested))
|
||||
if err != nil {
|
||||
return "", output.Wrap(output.CodeUnknown, err, "read username: %v", err)
|
||||
}
|
||||
name := strings.TrimSpace(ans)
|
||||
if name == "" {
|
||||
name = suggested
|
||||
}
|
||||
if !strings.HasPrefix(name, "bot-") {
|
||||
name = "bot-" + name
|
||||
}
|
||||
if name == previous {
|
||||
return "", output.New(output.CodeValidation, "new bot username must differ from %q", previous)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// suggestPetname proposes a memorable bot- name like "bot-clever-otter".
|
||||
// Two words keeps the username short enough for Vikunja's 250-char limit
|
||||
// while still giving plenty of namespace.
|
||||
func suggestPetname() string {
|
||||
return "bot-" + petname.Generate(2, "-")
|
||||
}
|
||||
|
||||
// isUsernameTakenErr returns true when the wrapped HTTP error from
|
||||
// CreateBotUser indicates a username collision. Vikunja replies 400 with
|
||||
// the canonical "user with this username already exists" message.
|
||||
func isUsernameTakenErr(e *output.Error) bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
if e.Code != output.CodeValidation {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(e.Message)
|
||||
return strings.Contains(msg, "username already exists") ||
|
||||
strings.Contains(msg, "user with this username")
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
func TestSuggestPetname_ShapeAndPrefix(t *testing.T) {
|
||||
got := suggestPetname()
|
||||
if !strings.HasPrefix(got, "bot-") {
|
||||
t.Fatalf("petname missing bot- prefix: %q", got)
|
||||
}
|
||||
// Two adjective-animal words separated by hyphens means at least
|
||||
// three hyphen-delimited segments (bot, word1, word2).
|
||||
if parts := strings.Split(got, "-"); len(parts) < 3 {
|
||||
t.Fatalf("petname looks malformed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUsernameTakenErr(t *testing.T) {
|
||||
cases := []struct {
|
||||
err *output.Error
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{output.New(output.CodeNotFound, "Not found"), false},
|
||||
{output.New(output.CodeValidation, "Some other validation issue"), false},
|
||||
{output.New(output.CodeValidation, "A user with this username already exists."), true},
|
||||
{output.New(output.CodeValidation, "username already exists"), true},
|
||||
{output.New(output.CodeValidation, "USERNAME ALREADY EXISTS"), true}, // case-insensitive
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isUsernameTakenErr(c.err); got != c.want {
|
||||
msg := "<nil>"
|
||||
if c.err != nil {
|
||||
msg = c.err.Message
|
||||
}
|
||||
t.Errorf("isUsernameTakenErr(%q) = %v, want %v", msg, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scriptedPrompter returns canned answers to ReadLine, in order.
|
||||
type scriptedPrompter struct {
|
||||
answers []string
|
||||
pos int
|
||||
}
|
||||
|
||||
func (s *scriptedPrompter) ReadLine(_ string) (string, error) {
|
||||
if s.pos >= len(s.answers) {
|
||||
return "", nil
|
||||
}
|
||||
a := s.answers[s.pos]
|
||||
s.pos++
|
||||
return a, nil
|
||||
}
|
||||
func (s *scriptedPrompter) ReadPassword(_ string) (string, error) { return "", nil }
|
||||
|
||||
func TestConfirmReuse(t *testing.T) {
|
||||
yes := []string{"", "y", "Y", "yes", "Yes", "YES", " yes "}
|
||||
no := []string{"n", "no", "N", "nope", "anything else"}
|
||||
var buf bytes.Buffer
|
||||
|
||||
for _, ans := range yes {
|
||||
p := &scriptedPrompter{answers: []string{ans}}
|
||||
ok, err := confirmReuse(p, &buf, "bot-x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("answer %q should be treated as yes", ans)
|
||||
}
|
||||
}
|
||||
for _, ans := range no {
|
||||
p := &scriptedPrompter{answers: []string{ans}}
|
||||
ok, err := confirmReuse(p, &buf, "bot-x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Errorf("answer %q should be treated as no", ans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptForReplacementName_AcceptsDefault(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := &scriptedPrompter{answers: []string{""}} // accept default
|
||||
name, err := promptForReplacementName(p, &buf, "bot-old", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(name, "bot-") {
|
||||
t.Errorf("default name missing bot- prefix: %q", name)
|
||||
}
|
||||
if name == "bot-old" {
|
||||
t.Errorf("default name shouldn't equal previous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptForReplacementName_AddsPrefix(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := &scriptedPrompter{answers: []string{"my-choice"}}
|
||||
name, err := promptForReplacementName(p, &buf, "bot-old", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "bot-my-choice" {
|
||||
t.Errorf("got %q, want bot-my-choice", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptForReplacementName_RejectsSameAsPrevious(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
p := &scriptedPrompter{answers: []string{"bot-old"}}
|
||||
_, err := promptForReplacementName(p, &buf, "bot-old", true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when new name equals previous")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/veans/internal/auth"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// veansPrimeCommand is the literal command line every hook ends up invoking.
|
||||
// Centralising it here keeps the install logic and the duplicate-detection
|
||||
// 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.
|
||||
type AgentHookChoice struct {
|
||||
ClaudeCode bool
|
||||
OpenCode bool
|
||||
}
|
||||
|
||||
// offerAgentHooks asks the user — one yes/no per agent — which integrations
|
||||
// they want veans to wire up. Callers pre-populate `choices` from CLI flags
|
||||
// (--install-claude / --install-opencode); only the unset slots get
|
||||
// prompted. When `noHooks` is true we skip everything and return the empty
|
||||
// choice, mirroring the old "just print the snippets" behaviour.
|
||||
func offerAgentHooks(p auth.Prompter, w io.Writer, choices AgentHookChoice, claudeFlagSet, opencodeFlagSet, noHooks bool) (AgentHookChoice, error) {
|
||||
if noHooks {
|
||||
return AgentHookChoice{}, nil
|
||||
}
|
||||
if !claudeFlagSet {
|
||||
yes, err := promptYesNo(p, w,
|
||||
"Wire `veans prime` into Claude Code (.claude/settings.json)?", true)
|
||||
if err != nil {
|
||||
return choices, err
|
||||
}
|
||||
choices.ClaudeCode = yes
|
||||
}
|
||||
if !opencodeFlagSet {
|
||||
yes, err := promptYesNo(p, w,
|
||||
"Wire `veans prime` into OpenCode (.opencode/plugin/veans-prime.ts)?", false)
|
||||
if err != nil {
|
||||
return choices, err
|
||||
}
|
||||
choices.OpenCode = yes
|
||||
}
|
||||
return choices, nil
|
||||
}
|
||||
|
||||
// installAgentHooks writes the requested integrations to disk relative to
|
||||
// repoRoot. Each install is idempotent: if the hook entry is already there,
|
||||
// it's left alone; if the settings file is missing, it's created with a
|
||||
// fresh skeleton.
|
||||
func installAgentHooks(repoRoot string, choices AgentHookChoice, w io.Writer) error {
|
||||
if choices.ClaudeCode {
|
||||
path, action, err := installClaudeCodeHook(repoRoot)
|
||||
if err != nil {
|
||||
return output.Wrap(output.CodeUnknown, err, "install Claude Code hook: %v", err)
|
||||
}
|
||||
progress(w, "%s Claude Code hook in %s", action, path)
|
||||
}
|
||||
if choices.OpenCode {
|
||||
path, action, err := installOpenCodeHook(repoRoot)
|
||||
if err != nil {
|
||||
return output.Wrap(output.CodeUnknown, err, "install OpenCode hook: %v", err)
|
||||
}
|
||||
progress(w, "%s OpenCode hook in %s", action, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installClaudeCodeHook merges (or creates) `<repoRoot>/.claude/settings.json`
|
||||
// so SessionStart and PreCompact invoke `veans prime`. Returns the path,
|
||||
// a human verb describing what happened ("Wrote", "Updated", "Already
|
||||
// configured"), and any error.
|
||||
func installClaudeCodeHook(repoRoot string) (string, string, error) {
|
||||
path := filepath.Join(repoRoot, filepath.FromSlash(ClaudeCodeSettingsRelPath))
|
||||
settings, existed, err := readJSONOrEmpty(path)
|
||||
if err != nil {
|
||||
return path, "", err
|
||||
}
|
||||
changed := false
|
||||
for _, event := range ClaudeCodeHookEvents {
|
||||
if ensureClaudeHook(settings, event) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return path, "Already configured", nil
|
||||
}
|
||||
if err := writeJSON(path, settings); err != nil {
|
||||
return path, "", err
|
||||
}
|
||||
if existed {
|
||||
return path, "Updated", nil
|
||||
}
|
||||
return path, "Wrote", nil
|
||||
}
|
||||
|
||||
// ensureClaudeHook walks the settings object and appends a `veans prime`
|
||||
// command entry under hooks.<event> if one isn't already present. Returns
|
||||
// true iff the structure was modified.
|
||||
//
|
||||
// Claude Code's settings shape:
|
||||
//
|
||||
// {
|
||||
// "hooks": {
|
||||
// "SessionStart": [
|
||||
// { "hooks": [ { "type": "command", "command": "veans prime" } ] }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
func ensureClaudeHook(settings map[string]any, event string) bool {
|
||||
hooks := mapAt(settings, "hooks")
|
||||
entries, _ := hooks[event].([]any)
|
||||
|
||||
for _, entry := range entries {
|
||||
entryMap, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
inner, _ := entryMap["hooks"].([]any)
|
||||
for _, h := range inner {
|
||||
hmap, ok := h.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if str(hmap, "type") == "command" && str(hmap, "command") == veansPrimeCommand {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, map[string]any{
|
||||
"hooks": []any{
|
||||
map[string]any{"type": "command", "command": veansPrimeCommand},
|
||||
},
|
||||
})
|
||||
hooks[event] = entries
|
||||
settings["hooks"] = hooks
|
||||
return true
|
||||
}
|
||||
|
||||
// installOpenCodeHook writes `<repoRoot>/.opencode/plugin/veans-prime.ts`
|
||||
// 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, filepath.FromSlash(OpenCodePluginRelPath))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, "Already configured", nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return path, "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return path, "", err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(OpenCodePluginSnippet), 0o644); err != nil {
|
||||
return path, "", err
|
||||
}
|
||||
return path, "Wrote", nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// "Wrote" and "Updated").
|
||||
func readJSONOrEmpty(path string) (out map[string]any, existed bool, err error) {
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return map[string]any{}, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
out = map[string]any{}
|
||||
if len(buf) == 0 {
|
||||
return out, true, nil
|
||||
}
|
||||
if err := json.Unmarshal(buf, &out); err != nil {
|
||||
return nil, true, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
buf, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf = append(buf, '\n')
|
||||
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
|
||||
// the existing value is the wrong type. Lets ensureClaudeHook treat the
|
||||
// JSON object tree as if it were always well-shaped.
|
||||
func mapAt(m map[string]any, k string) map[string]any {
|
||||
if v, ok := m[k].(map[string]any); ok {
|
||||
return v
|
||||
}
|
||||
v := map[string]any{}
|
||||
m[k] = v
|
||||
return v
|
||||
}
|
||||
|
||||
func str(m map[string]any, k string) string {
|
||||
s, _ := m[k].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// promptYesNo reads a Y/n (or y/N) answer with the given default.
|
||||
func promptYesNo(p auth.Prompter, w io.Writer, question string, defaultYes bool) (bool, error) {
|
||||
tag := "[Y/n]"
|
||||
if !defaultYes {
|
||||
tag = "[y/N]"
|
||||
}
|
||||
fmt.Fprintln(w, question)
|
||||
ans, err := p.ReadLine(tag + " ")
|
||||
if err != nil {
|
||||
return defaultYes, err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(ans)) {
|
||||
case "":
|
||||
return defaultYes, nil
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
case "n", "no":
|
||||
return false, nil
|
||||
}
|
||||
return defaultYes, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureClaudeHook_FreshFile(t *testing.T) {
|
||||
s := map[string]any{}
|
||||
if !ensureClaudeHook(s, "SessionStart") {
|
||||
t.Fatal("expected change on empty settings")
|
||||
}
|
||||
hooks, ok := s["hooks"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("hooks key missing or wrong type: %v", s)
|
||||
}
|
||||
ss, ok := hooks["SessionStart"].([]any)
|
||||
if !ok || len(ss) != 1 {
|
||||
t.Fatalf("SessionStart shape: %v", hooks["SessionStart"])
|
||||
}
|
||||
entry := ss[0].(map[string]any)
|
||||
inner := entry["hooks"].([]any)
|
||||
if len(inner) != 1 {
|
||||
t.Fatalf("inner hooks: %v", inner)
|
||||
}
|
||||
h := inner[0].(map[string]any)
|
||||
if h["command"] != "veans prime" || h["type"] != "command" {
|
||||
t.Fatalf("hook shape: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClaudeHook_Idempotent(t *testing.T) {
|
||||
s := map[string]any{}
|
||||
if !ensureClaudeHook(s, "SessionStart") {
|
||||
t.Fatal("first call should change")
|
||||
}
|
||||
if ensureClaudeHook(s, "SessionStart") {
|
||||
t.Fatal("second call should NOT change")
|
||||
}
|
||||
ss := s["hooks"].(map[string]any)["SessionStart"].([]any)
|
||||
if len(ss) != 1 {
|
||||
t.Fatalf("expected exactly one entry, got %d: %v", len(ss), ss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClaudeHook_PreservesOtherHooks(t *testing.T) {
|
||||
// Existing settings have an unrelated PreToolUse hook and a SessionStart
|
||||
// entry running a different command. The veans entry should be appended,
|
||||
// not replace the existing structure.
|
||||
raw := []byte(`{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "echo hi" } ] }
|
||||
],
|
||||
"SessionStart": [
|
||||
{ "hooks": [ { "type": "command", "command": "other-tool init" } ] }
|
||||
]
|
||||
},
|
||||
"permissions": { "allow": ["Bash"] }
|
||||
}`)
|
||||
var s map[string]any
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ensureClaudeHook(s, "SessionStart") {
|
||||
t.Fatal("expected change")
|
||||
}
|
||||
// PreToolUse + permissions untouched.
|
||||
if _, ok := s["permissions"]; !ok {
|
||||
t.Error("permissions key dropped")
|
||||
}
|
||||
if pt := s["hooks"].(map[string]any)["PreToolUse"].([]any); len(pt) != 1 {
|
||||
t.Errorf("PreToolUse perturbed: %v", pt)
|
||||
}
|
||||
// SessionStart now has BOTH the original and the veans entry.
|
||||
ss := s["hooks"].(map[string]any)["SessionStart"].([]any)
|
||||
if len(ss) != 2 {
|
||||
t.Fatalf("SessionStart should have 2 entries, got %d", len(ss))
|
||||
}
|
||||
gotVeans := false
|
||||
for _, e := range ss {
|
||||
inner := e.(map[string]any)["hooks"].([]any)
|
||||
for _, h := range inner {
|
||||
if h.(map[string]any)["command"] == "veans prime" {
|
||||
gotVeans = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !gotVeans {
|
||||
t.Errorf("veans prime not found in merged SessionStart: %v", ss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeCodeHook_CreatesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path, action, err := installClaudeCodeHook(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action != "Wrote" {
|
||||
t.Errorf("first install should say Wrote, got %q", action)
|
||||
}
|
||||
if !strings.HasSuffix(path, ".claude/settings.json") {
|
||||
t.Errorf("unexpected path: %s", path)
|
||||
}
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(buf), `"veans prime"`) {
|
||||
t.Errorf("written file missing veans prime command:\n%s", buf)
|
||||
}
|
||||
// Two-space indent + trailing newline.
|
||||
if !strings.HasSuffix(string(buf), "\n") {
|
||||
t.Error("written file missing trailing newline")
|
||||
}
|
||||
if !strings.Contains(string(buf), " \"hooks\"") {
|
||||
t.Error("expected 2-space indent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeCodeHook_IdempotentRerun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, _, err := installClaudeCodeHook(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, action, err := installClaudeCodeHook(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action != "Already configured" {
|
||||
t.Errorf("second install should report Already configured, got %q", action)
|
||||
}
|
||||
// File hasn't grown duplicate entries.
|
||||
buf, _ := os.ReadFile(path)
|
||||
if c := strings.Count(string(buf), `"veans prime"`); c != 2 {
|
||||
// 2 because both SessionStart and PreCompact reference it once.
|
||||
t.Errorf("expected exactly 2 references to veans prime, got %d:\n%s", c, buf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeCodeHook_MergesWithUserSettings(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settingsPath := filepath.Join(dir, ".claude", "settings.json")
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := `{
|
||||
"model": "claude-opus-4-7",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{ "hooks": [ { "type": "command", "command": "other-tool" } ] }
|
||||
]
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, action, err := installClaudeCodeHook(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action != "Updated" {
|
||||
t.Errorf("merging into existing file should say Updated, got %q", action)
|
||||
}
|
||||
buf, _ := os.ReadFile(settingsPath)
|
||||
out := string(buf)
|
||||
for _, want := range []string{`"model": "claude-opus-4-7"`, `"other-tool"`, `"veans prime"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("merged file missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallOpenCodeHook_CreatesAndIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path, action, err := installOpenCodeHook(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action != "Wrote" {
|
||||
t.Errorf("first install should say Wrote, got %q", action)
|
||||
}
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"VeansPrime", "veans prime", "session.start", "compact.before"} {
|
||||
if !strings.Contains(string(buf), want) {
|
||||
t.Errorf("opencode file missing %q:\n%s", want, buf)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run leaves the file alone — we don't merge TS by hand.
|
||||
_, action2, err := installOpenCodeHook(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action2 != "Already configured" {
|
||||
t.Errorf("rerun should say Already configured, got %q", action2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferAgentHooks_NoHooks(t *testing.T) {
|
||||
choices, err := offerAgentHooks(nil, nil, AgentHookChoice{}, false, false, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if choices.ClaudeCode || choices.OpenCode {
|
||||
t.Errorf("NoHooks should return empty: %+v", choices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferAgentHooks_FlagsBypassPrompt(t *testing.T) {
|
||||
// Both flags set explicitly — no prompts.
|
||||
p := &scriptedPrompter{} // would panic with out-of-range on any ReadLine
|
||||
choices, err := offerAgentHooks(p, nopWriter{}, AgentHookChoice{ClaudeCode: true, OpenCode: false}, true, true, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !choices.ClaudeCode || choices.OpenCode {
|
||||
t.Errorf("expected ClaudeCode=true, OpenCode=false; got %+v", choices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferAgentHooks_PromptsWhenFlagsUnset(t *testing.T) {
|
||||
// User accepts Claude default (Y), declines OpenCode.
|
||||
p := &scriptedPrompter{answers: []string{"", "n"}}
|
||||
choices, err := offerAgentHooks(p, nopWriter{}, AgentHookChoice{}, false, false, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !choices.ClaudeCode || choices.OpenCode {
|
||||
t.Errorf("expected ClaudeCode=true OpenCode=false, got %+v", choices)
|
||||
}
|
||||
}
|
||||
|
||||
// nopWriter discards everything; lets tests run prompts without console noise.
|
||||
type nopWriter struct{}
|
||||
|
||||
func (nopWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AddAssignee assigns a user (typically the bot) to a task.
|
||||
func (c *Client) AddAssignee(ctx context.Context, taskID, userID int64) error {
|
||||
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// 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 client
|
||||
|
||||
import "context"
|
||||
|
||||
// Login posts to /login and returns the JWT bundle. The returned token is a
|
||||
// JWT good for the user's normal API calls; we use it transiently during init.
|
||||
func (c *Client) Login(ctx context.Context, req *LoginRequest) (*LoginResponse, error) {
|
||||
var out LoginResponse
|
||||
if err := c.Do(ctx, "POST", "/login", nil, req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CurrentUser fetches /user — handy for resolving the bot's own user_id from
|
||||
// its API token without poking the human's data.
|
||||
func (c *Client) CurrentUser(ctx context.Context) (*User, error) {
|
||||
var out User
|
||||
if err := c.Do(ctx, "GET", "/user", nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ExchangeOAuthCode swaps an authorization code (with the matching PKCE
|
||||
// verifier) for an access + refresh token pair via POST /oauth/token.
|
||||
// Vikunja requires JSON, not form-encoded — the standard OAuth library
|
||||
// helpers don't apply.
|
||||
func (c *Client) ExchangeOAuthCode(ctx context.Context, req *OAuthTokenRequest) (*OAuthTokenResponse, error) {
|
||||
var out OAuthTokenResponse
|
||||
if err := c.Do(ctx, "POST", "/oauth/token", nil, req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ListBuckets returns the buckets configured on a Kanban view.
|
||||
func (c *Client) ListBuckets(ctx context.Context, projectID, viewID int64) ([]*Bucket, error) {
|
||||
var out []*Bucket
|
||||
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
|
||||
if err := c.Do(ctx, "GET", path, nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateBucket inserts a new bucket into a Kanban view.
|
||||
func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *Bucket) (*Bucket, error) {
|
||||
var out Bucket
|
||||
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
|
||||
if b == nil {
|
||||
b = &Bucket{}
|
||||
}
|
||||
b.ProjectViewID = viewID
|
||||
if err := c.Do(ctx, "PUT", path, nil, b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// MoveTaskToBucket positions an existing task in `bucketID` on the
|
||||
// project's view. Vikunja stores task↔bucket relations in a separate
|
||||
// table (`task_buckets`), so POST /tasks/{id} with bucket_id does not
|
||||
// reliably move tasks — this dedicated endpoint is the one the Kanban
|
||||
// UI's drag-and-drop uses.
|
||||
func (c *Client) MoveTaskToBucket(ctx context.Context, projectID, viewID, bucketID, taskID int64) error {
|
||||
path := fmt.Sprintf("/projects/%d/views/%d/buckets/%d/tasks",
|
||||
projectID, viewID, bucketID)
|
||||
body := map[string]int64{
|
||||
"task_id": taskID,
|
||||
"project_view_id": viewID,
|
||||
"bucket_id": bucketID,
|
||||
"project_id": projectID,
|
||||
}
|
||||
return c.Do(ctx, "POST", path, nil, body, nil)
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// Client is a thin JSON wrapper around the Vikunja REST API. It holds the
|
||||
// server base URL and a bearer token (either a JWT from POST /login or an
|
||||
// API token minted via PUT /tokens). Every method in this package is a thin
|
||||
// shim over Do.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// UserAgent is the value sent in the User-Agent header on every request.
|
||||
// main sets this at startup with the linker-injected version + the
|
||||
// runtime os/arch (e.g. "veans/0.3.1 (linux/amd64)"). Tests get the
|
||||
// default "veans/dev". Vikunja admins see this in their access logs.
|
||||
var UserAgent = "veans/dev"
|
||||
|
||||
// defaultHTTPTimeout is the timeout applied to the HTTP client returned by
|
||||
// New. Callers that need a different value (e.g. the runtime loader honoring
|
||||
// `http_timeout` from .veans.yml) can overwrite HTTPClient.Timeout after
|
||||
// construction.
|
||||
const defaultHTTPTimeout = 30 * time.Second
|
||||
|
||||
func New(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{Timeout: defaultHTTPTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// vikunjaError matches `web.HTTPError` on the wire.
|
||||
type vikunjaError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Do performs a single JSON request against /api/v1<path>. body, if non-nil,
|
||||
// is JSON-marshalled. out, if non-nil, is JSON-unmarshalled. query is appended
|
||||
// as URL-encoded params.
|
||||
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
||||
full := c.BaseURL + "/api/v1" + path
|
||||
if len(query) > 0 {
|
||||
full += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "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 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 fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return 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 fmt.Errorf("decode %s %s: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
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, 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()
|
||||
}
|
||||
var br io.Reader
|
||||
if len(body) > 0 {
|
||||
br = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, br)
|
||||
if err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if len(body) > 0 {
|
||||
req.Header.Set("Content-Type", "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, nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err = io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
return resp.StatusCode, respBody, parseRetryAfter(resp.Header.Get("Retry-After")), err
|
||||
}
|
||||
|
||||
// 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)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(string(body))
|
||||
if msg == "" {
|
||||
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 {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
code = output.CodeAuth
|
||||
case status == http.StatusNotFound:
|
||||
code = output.CodeNotFound
|
||||
case status == http.StatusConflict:
|
||||
code = output.CodeConflict
|
||||
case status == http.StatusTooManyRequests:
|
||||
code = output.CodeRateLimited
|
||||
case status >= 400 && status < 500:
|
||||
code = output.CodeValidation
|
||||
default:
|
||||
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: 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
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
func TestMapHTTPError_StatusCodeMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
want output.Code
|
||||
}{
|
||||
{"401 unauthorized -> auth", http.StatusUnauthorized, output.CodeAuth},
|
||||
{"403 forbidden -> auth", http.StatusForbidden, output.CodeAuth},
|
||||
{"404 not found -> not found", http.StatusNotFound, output.CodeNotFound},
|
||||
{"409 conflict -> conflict", http.StatusConflict, output.CodeConflict},
|
||||
{"429 too many requests -> rate limited", http.StatusTooManyRequests, output.CodeRateLimited},
|
||||
{"400 bad request -> validation", http.StatusBadRequest, output.CodeValidation},
|
||||
{"422 unprocessable -> validation", http.StatusUnprocessableEntity, output.CodeValidation},
|
||||
{"500 internal -> unknown", http.StatusInternalServerError, output.CodeUnknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := mapHTTPError("GET", "/foo", tc.status, []byte(`{"message":"boom"}`), 0)
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("expected *output.Error, got %T", err)
|
||||
}
|
||||
if oe.Code != tc.want {
|
||||
t.Errorf("status %d: got code %q, want %q", tc.status, oe.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapHTTPError_RetryAfterAppendedToMessage(t *testing.T) {
|
||||
retry := 7 * time.Second
|
||||
err := mapHTTPError("GET", "/foo", http.StatusTooManyRequests, []byte(`{"message":"slow down"}`), retry)
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("expected *output.Error, got %T", err)
|
||||
}
|
||||
if !strings.Contains(oe.Message, "retry-after") {
|
||||
t.Errorf("expected message to contain %q, got %q", "retry-after", oe.Message)
|
||||
}
|
||||
if !strings.Contains(oe.Message, retry.String()) {
|
||||
t.Errorf("expected message to contain duration %q, got %q", retry.String(), oe.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapHTTPError_BodyTruncation(t *testing.T) {
|
||||
// Build a > maxErrorMessageBytes (512) raw body that isn't valid JSON so
|
||||
// the message falls through to the raw-body branch.
|
||||
body := []byte(strings.Repeat("a", 600))
|
||||
err := mapHTTPError("GET", "/foo", http.StatusInternalServerError, body, 0)
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("expected *output.Error, got %T", err)
|
||||
}
|
||||
if !strings.HasSuffix(oe.Message, "…(truncated)") {
|
||||
t.Errorf("expected message to end with truncation marker, got %q", oe.Message)
|
||||
}
|
||||
if oe.Cause != nil {
|
||||
t.Errorf("expected Cause to be nil, got %v", oe.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapHTTPError_VikunjaJSONTakesPrecedenceOverRawBody(t *testing.T) {
|
||||
body := []byte(`{"code":404,"message":"x"}`)
|
||||
err := mapHTTPError("GET", "/foo", http.StatusNotFound, body, 0)
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("expected *output.Error, got %T", err)
|
||||
}
|
||||
// The formatted message is "METHOD PATH: STATUS MSG"; assert it carries
|
||||
// the decoded message and not the raw JSON envelope.
|
||||
if !strings.HasSuffix(oe.Message, ": 404 x") {
|
||||
t.Errorf("expected formatted message to end with %q, got %q", ": 404 x", oe.Message)
|
||||
}
|
||||
if strings.Contains(oe.Message, `"code":404`) {
|
||||
t.Errorf("expected raw JSON body to be replaced by decoded message, got %q", oe.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRetryAfter(t *testing.T) {
|
||||
future := time.Now().Add(30 * time.Second).UTC().Format(http.TimeFormat)
|
||||
past := time.Now().Add(-30 * time.Second).UTC().Format(http.TimeFormat)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want time.Duration
|
||||
// For HTTP-date inputs, the result is computed via time.Until; allow
|
||||
// a tolerance window.
|
||||
tolerance time.Duration
|
||||
}{
|
||||
{"empty", "", 0, 0},
|
||||
{"five seconds", "5", 5 * time.Second, 0},
|
||||
{"zero", "0", 0, 0},
|
||||
{"negative invalid", "-1", 0, 0},
|
||||
{"unparseable", "not a number", 0, 0},
|
||||
{"past http date", past, 0, 0},
|
||||
{"future http date ~30s", future, 30 * time.Second, 3 * time.Second},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseRetryAfter(tc.in)
|
||||
if tc.tolerance == 0 {
|
||||
if got != tc.want {
|
||||
t.Errorf("parseRetryAfter(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
diff := got - tc.want
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > tc.tolerance {
|
||||
t.Errorf("parseRetryAfter(%q) = %v, want %v ± %v", tc.in, got, tc.want, tc.tolerance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationDone(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
page int
|
||||
batchLen int
|
||||
perPage int
|
||||
totalPages int
|
||||
want bool
|
||||
}{
|
||||
{"server says single page complete", 1, 50, 50, 1, true},
|
||||
{"server says more pages remain", 1, 50, 50, 2, false},
|
||||
{"server says we're on the last page", 2, 10, 50, 2, true},
|
||||
{"no header, full page -> not done", 1, 50, 50, 0, false},
|
||||
{"no header, short page -> done", 1, 10, 50, 0, true},
|
||||
{"no header, empty page -> done", 1, 0, 50, 0, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := paginationDone(tc.page, tc.batchLen, tc.perPage, tc.totalPages)
|
||||
if got != tc.want {
|
||||
t.Errorf("paginationDone(page=%d, batch=%d, per=%d, total=%d) = %v, want %v",
|
||||
tc.page, tc.batchLen, tc.perPage, tc.totalPages, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBotUser_404TranslatesToBotUsersUnavailable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/bots" {
|
||||
http.Error(w, "unexpected route", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "test-token")
|
||||
_, err := c.CreateBotUser(context.Background(), "bot-test", "Test Bot")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) {
|
||||
t.Fatalf("expected *output.Error, got %T (%v)", err, err)
|
||||
}
|
||||
if oe.Code != output.CodeBotUsersUnavailable {
|
||||
t.Errorf("got code %q, want %q", oe.Code, output.CodeBotUsersUnavailable)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AddTaskComment posts a new comment on a task.
|
||||
func (c *Client) AddTaskComment(ctx context.Context, taskID int64, body string) (*TaskComment, error) {
|
||||
var out TaskComment
|
||||
if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/comments", taskID), nil, &TaskComment{Comment: body}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListTaskComments returns all comments on a task.
|
||||
func (c *Client) ListTaskComments(ctx context.Context, taskID int64) ([]*TaskComment, error) {
|
||||
var out []*TaskComment
|
||||
if err := c.Do(ctx, "GET", fmt.Sprintf("/tasks/%d/comments", taskID), nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// defaultAPIPort is what `VIKUNJA_SERVICE_INTERFACE` ships with — handy
|
||||
// when the user types just `myhost.example.com` for a default install
|
||||
// running on an unusual port.
|
||||
const defaultAPIPort = "3456"
|
||||
|
||||
// DiscoverServer normalizes `input` and probes a small set of plausible
|
||||
// URLs for /api/v1/info, returning the canonical base URL (without the
|
||||
// /api/v1 suffix — that's what client.New expects) and the parsed Info.
|
||||
//
|
||||
// Mirrors the discovery the Vikunja web frontend does in
|
||||
// helpers/checkAndSetApiUrl.ts: try the URL as-given, with /api/v1
|
||||
// appended, and with the default :3456 port — across http / https. The
|
||||
// first response that parses as Info wins.
|
||||
func DiscoverServer(ctx context.Context, input string) (string, *Info, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", nil, output.New(output.CodeValidation, "server URL is required")
|
||||
}
|
||||
|
||||
candidates, err := serverCandidates(input)
|
||||
if err != nil {
|
||||
return "", nil, output.Wrap(output.CodeValidation, err,
|
||||
"can't parse server URL %q: %v", input, err)
|
||||
}
|
||||
|
||||
var attempts []string
|
||||
var lastErr error
|
||||
for _, base := range candidates {
|
||||
attempts = append(attempts, base+"/api/v1/info")
|
||||
info, err := New(base, "").Info(ctx)
|
||||
if err == nil && info != nil {
|
||||
return base, info, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return "", nil, output.New(output.CodeValidation,
|
||||
"couldn't find a Vikunja instance reachable from %q — tried:\n - %s\nlast error: %v",
|
||||
input, strings.Join(attempts, "\n - "), lastErr)
|
||||
}
|
||||
|
||||
// serverCandidates expands `input` into the ordered list of base URLs
|
||||
// to probe for /api/v1/info. A "base URL" here is what client.New wants:
|
||||
// the origin + the path that should sit BEFORE /api/v1 (typically empty
|
||||
// or a reverse-proxy prefix). The probe itself adds /api/v1/info.
|
||||
func serverCandidates(input string) ([]string, error) {
|
||||
// Strip a trailing /api/v1[/] the user might have copied from a
|
||||
// curl example. We add it back in the probe, and otherwise we'd
|
||||
// end up calling /api/v1/api/v1/info.
|
||||
trimmed := strings.TrimRight(input, "/")
|
||||
trimmed = strings.TrimSuffix(trimmed, "/api/v1")
|
||||
trimmed = strings.TrimRight(trimmed, "/")
|
||||
|
||||
withScheme := trimmed
|
||||
if !strings.HasPrefix(withScheme, "http://") && !strings.HasPrefix(withScheme, "https://") {
|
||||
withScheme = defaultScheme(trimmed) + "://" + trimmed
|
||||
}
|
||||
|
||||
u, err := url.Parse(withScheme)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, errors.New("missing host")
|
||||
}
|
||||
|
||||
// Build the candidate set, dedup-preserving-order. The order here
|
||||
// is the search policy: as-given, with default port, then the
|
||||
// opposite scheme for each. Stops on the first one that responds
|
||||
// with a parseable Info.
|
||||
var bases []string
|
||||
add := func(scheme, host, path string) {
|
||||
base := scheme + "://" + host + strings.TrimRight(path, "/")
|
||||
base = strings.TrimRight(base, "/")
|
||||
for _, existing := range bases {
|
||||
if existing == base {
|
||||
return
|
||||
}
|
||||
}
|
||||
bases = append(bases, base)
|
||||
}
|
||||
|
||||
hosts := []string{u.Host}
|
||||
if u.Port() == "" {
|
||||
hosts = append(hosts, u.Hostname()+":"+defaultAPIPort)
|
||||
}
|
||||
schemes := []string{u.Scheme}
|
||||
if u.Scheme == "https" {
|
||||
schemes = append(schemes, "http")
|
||||
} else {
|
||||
schemes = append(schemes, "https")
|
||||
}
|
||||
for _, s := range schemes {
|
||||
for _, h := range hosts {
|
||||
add(s, h, u.Path)
|
||||
}
|
||||
}
|
||||
return bases, nil
|
||||
}
|
||||
|
||||
// defaultScheme picks http for loopback hosts and https for everything
|
||||
// else — matches the heuristic most CLIs use when a scheme isn't typed.
|
||||
func defaultScheme(input string) string {
|
||||
host := input
|
||||
if i := strings.IndexByte(host, '/'); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
switch host {
|
||||
case "localhost", "127.0.0.1", "[::1]", "::1":
|
||||
return "http"
|
||||
}
|
||||
return "https"
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerCandidates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "bare hostname → https first, then http, with default-port fallbacks",
|
||||
input: "vikunja.example.com",
|
||||
want: []string{
|
||||
"https://vikunja.example.com",
|
||||
"https://vikunja.example.com:3456",
|
||||
"http://vikunja.example.com",
|
||||
"http://vikunja.example.com:3456",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "localhost defaults to http",
|
||||
input: "localhost",
|
||||
want: []string{
|
||||
"http://localhost",
|
||||
"http://localhost:3456",
|
||||
"https://localhost",
|
||||
"https://localhost:3456",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user-supplied /api/v1 suffix is trimmed (so the probe doesn't double it up)",
|
||||
input: "https://vikunja.example.com/api/v1",
|
||||
want: []string{
|
||||
"https://vikunja.example.com",
|
||||
"https://vikunja.example.com:3456",
|
||||
"http://vikunja.example.com",
|
||||
"http://vikunja.example.com:3456",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit port is respected — no default-port fallback added",
|
||||
input: "https://vikunja.example.com:8443",
|
||||
want: []string{
|
||||
"https://vikunja.example.com:8443",
|
||||
"http://vikunja.example.com:8443",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subpath install keeps the prefix",
|
||||
input: "https://example.com/vikunja",
|
||||
want: []string{
|
||||
"https://example.com/vikunja",
|
||||
"https://example.com:3456/vikunja",
|
||||
"http://example.com/vikunja",
|
||||
"http://example.com:3456/vikunja",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "127.0.0.1 with default port (common dev setup)",
|
||||
input: "127.0.0.1:3456",
|
||||
want: []string{
|
||||
"http://127.0.0.1:3456",
|
||||
"https://127.0.0.1:3456",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trailing slash trimmed",
|
||||
input: "https://vikunja.example.com/",
|
||||
want: []string{
|
||||
"https://vikunja.example.com",
|
||||
"https://vikunja.example.com:3456",
|
||||
"http://vikunja.example.com",
|
||||
"http://vikunja.example.com:3456",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := serverCandidates(c.input)
|
||||
if err != nil {
|
||||
t.Fatalf("serverCandidates(%q): %v", c.input, err)
|
||||
}
|
||||
if !slices.Equal(got, c.want) {
|
||||
t.Errorf("serverCandidates(%q):\n got %v\n want %v", c.input, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerCandidates_EmptyInput(t *testing.T) {
|
||||
// "" is the only input shape DiscoverServer rejects at the entry
|
||||
// (before reaching serverCandidates). The lower-level helper itself
|
||||
// reports "missing host" through the url.Parse path.
|
||||
if _, err := serverCandidates(""); err == nil {
|
||||
t.Error("empty input should error")
|
||||
}
|
||||
}
|
||||
|
|
@ -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/>.
|
||||
|
||||
package client
|
||||
|
||||
import "context"
|
||||
|
||||
// Info fetches GET /info. No auth required.
|
||||
func (c *Client) Info(ctx context.Context) (*Info, error) {
|
||||
var out Info
|
||||
if err := c.Do(ctx, "GET", "/info", nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ListLabels paginates GET /labels and returns every label visible to the
|
||||
// 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
|
||||
page := 1
|
||||
for {
|
||||
q := url.Values{}
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("per_page", "50")
|
||||
if search != "" {
|
||||
q.Set("s", search)
|
||||
}
|
||||
var batch []*Label
|
||||
total, err := c.DoPaginated(ctx, "GET", "/labels", q, &batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if paginationDone(page, len(batch), 50, total) {
|
||||
return all, nil
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
// CreateLabel creates a new label owned by the authenticated user.
|
||||
func (c *Client) CreateLabel(ctx context.Context, l *Label) (*Label, error) {
|
||||
var out Label
|
||||
if err := c.Do(ctx, "PUT", "/labels", nil, l, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// AddLabelToTask attaches an existing label to a task.
|
||||
func (c *Client) AddLabelToTask(ctx context.Context, taskID, labelID int64) error {
|
||||
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/labels", taskID), nil, &LabelTask{LabelID: labelID}, nil)
|
||||
}
|
||||
|
||||
// RemoveLabelFromTask detaches a label.
|
||||
func (c *Client) RemoveLabelFromTask(ctx context.Context, taskID, labelID int64) error {
|
||||
return c.Do(ctx, "DELETE", fmt.Sprintf("/tasks/%d/labels/%d", taskID, labelID), nil, nil, nil)
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 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
|
||||
page := 1
|
||||
for {
|
||||
q := url.Values{}
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("per_page", "50")
|
||||
var batch []*Project
|
||||
total, err := c.DoPaginated(ctx, "GET", "/projects", q, &batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
if paginationDone(page, len(batch), 50, total) {
|
||||
return all, nil
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
// GetProject fetches a single project by ID.
|
||||
func (c *Client) GetProject(ctx context.Context, id int64) (*Project, error) {
|
||||
var out Project
|
||||
if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d", id), nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/users", projectID), nil, share, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListProjectViews returns saved views (Kanban, List, …) on a project.
|
||||
func (c *Client) ListProjectViews(ctx context.Context, projectID int64) ([]*ProjectView, error) {
|
||||
var out []*ProjectView
|
||||
if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d/views", projectID), nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CreateRelation links two tasks. relationKind is "subtask", "parenttask",
|
||||
// "blocking", "blocked", "related", etc.
|
||||
func (c *Client) CreateRelation(ctx context.Context, taskID int64, otherTaskID int64, relationKind string) (*TaskRelation, error) {
|
||||
var out TaskRelation
|
||||
body := &TaskRelation{OtherTaskID: otherTaskID, RelationKind: relationKind}
|
||||
if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/relations", taskID), nil, body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// 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 client
|
||||
|
||||
import "context"
|
||||
|
||||
// RouteGroup mirrors models.APITokenRoute on the wire — the per-action
|
||||
// detail object is opaque to us.
|
||||
type RouteGroup map[string]struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// Routes returns the API token route map. Used during bootstrap to
|
||||
// negotiate exactly which permission groups+actions exist on this Vikunja
|
||||
// instance, so the bot's API token only requests scopes the server knows
|
||||
// about — avoiding hard-coding a permission list that could drift.
|
||||
func (c *Client) Routes(ctx context.Context) (map[string]RouteGroup, error) {
|
||||
out := map[string]RouteGroup{}
|
||||
if err := c.Do(ctx, "GET", "/routes", nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PermissionsForBot picks a curated subset of route groups the veans bot
|
||||
// needs and projects the available actions of each. Groups not present on
|
||||
// the server are silently dropped, so the resulting permission map is
|
||||
// always valid for PUT /tokens regardless of Vikunja version.
|
||||
//
|
||||
// The action names reflect Vikunja's actual route map (see GET /routes):
|
||||
// bucket CRUD and the bucket-task move endpoint live under the `projects`
|
||||
// group as `views_buckets*` and `views_buckets_tasks`, not a separate
|
||||
// `buckets` group.
|
||||
func PermissionsForBot(routes map[string]RouteGroup) map[string][]string {
|
||||
wanted := map[string][]string{
|
||||
// Read + write tasks across the project. The bot creates, updates,
|
||||
// and reads tasks; it doesn't delete (humans/merge hook close).
|
||||
"tasks": {
|
||||
"read_one", "read_all", "create", "update", "position",
|
||||
"read", "update_bulk",
|
||||
},
|
||||
// Project access: read project metadata, manage buckets & move
|
||||
// tasks between them. tasks_by-index resolves #NN / PROJ-NN.
|
||||
"projects": {
|
||||
"read_one", "read_all", "tasks_by-index",
|
||||
"views_buckets", "views_buckets_put", "views_buckets_post",
|
||||
"views_buckets_delete", "views_buckets_tasks",
|
||||
},
|
||||
"projects_views": {"read_one", "read_all"},
|
||||
"labels": {"read_one", "read_all", "create", "update", "delete"},
|
||||
"tasks_comments": {"read_one", "read_all", "create", "update", "delete"},
|
||||
"tasks_relations": {"create", "delete"},
|
||||
"tasks_assignees": {"read_all", "create", "delete", "update_bulk"},
|
||||
"tasks_labels": {"create", "delete", "read_all", "update_bulk"},
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for group, actions := range wanted {
|
||||
avail, ok := routes[group]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var picked []string
|
||||
for _, a := range actions {
|
||||
if _, has := avail[a]; has {
|
||||
picked = append(picked, a)
|
||||
}
|
||||
}
|
||||
if len(picked) > 0 {
|
||||
out[group] = picked
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// 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 client
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPermissionsForBot_DropsUnknownGroups(t *testing.T) {
|
||||
// Server only exposes a subset of what we ask for.
|
||||
server := map[string]RouteGroup{
|
||||
"tasks": {
|
||||
"read_one": {},
|
||||
"read_all": {},
|
||||
"create": {},
|
||||
"update": {},
|
||||
// "delete" intentionally absent
|
||||
},
|
||||
"projects": {
|
||||
"read_one": {},
|
||||
"read_all": {},
|
||||
},
|
||||
// no "labels", no "comments", etc.
|
||||
}
|
||||
got := PermissionsForBot(server)
|
||||
|
||||
if _, ok := got["tasks"]; !ok {
|
||||
t.Fatalf("expected tasks group in result")
|
||||
}
|
||||
for _, a := range got["tasks"] {
|
||||
if a == "delete" {
|
||||
t.Errorf("delete should have been dropped")
|
||||
}
|
||||
}
|
||||
if _, ok := got["projects"]; !ok {
|
||||
t.Fatalf("expected projects group")
|
||||
}
|
||||
if _, ok := got["labels"]; ok {
|
||||
t.Errorf("labels was not on server, should not appear in result")
|
||||
}
|
||||
if _, ok := got["nonexistent_group"]; ok {
|
||||
t.Errorf("phantom group leaked into result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionsForBot_EmptyWhenServerIsEmpty(t *testing.T) {
|
||||
got := PermissionsForBot(map[string]RouteGroup{})
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty map, got %v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// TaskListOptions selects which tasks to return from ListProjectTasks.
|
||||
type TaskListOptions struct {
|
||||
Filter string
|
||||
Page int
|
||||
PerPage int
|
||||
Expand []string
|
||||
}
|
||||
|
||||
func (o *TaskListOptions) values() url.Values {
|
||||
q := url.Values{}
|
||||
if o == nil {
|
||||
return q
|
||||
}
|
||||
if o.Filter != "" {
|
||||
q.Set("filter", o.Filter)
|
||||
}
|
||||
if o.Page > 0 {
|
||||
q.Set("page", strconv.Itoa(o.Page))
|
||||
}
|
||||
if o.PerPage > 0 {
|
||||
q.Set("per_page", strconv.Itoa(o.PerPage))
|
||||
}
|
||||
for _, e := range o.Expand {
|
||||
q.Add("expand", e)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
per := opts.PerPage
|
||||
if per <= 0 {
|
||||
per = 50
|
||||
}
|
||||
var all []*Task
|
||||
page := 1
|
||||
for {
|
||||
o := *opts
|
||||
o.Page = page
|
||||
o.PerPage = per
|
||||
var batch []*Task
|
||||
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 paginationDone(page, len(batch), per, total) {
|
||||
return all, nil
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
// GetTask fetches a single task by numeric ID. expand=buckets is requested
|
||||
// because Vikunja's bare GET returns bucket_id=0 — the per-view bucket
|
||||
// memberships only surface under the Buckets slice.
|
||||
func (c *Client) GetTask(ctx context.Context, id int64) (*Task, error) {
|
||||
var out Task
|
||||
q := url.Values{}
|
||||
q.Add("expand", "buckets")
|
||||
if err := c.Do(ctx, "GET", fmt.Sprintf("/tasks/%d", id), q, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CurrentBucketID returns the task's bucket id on the given project view,
|
||||
// or 0 if no bucket entry is present (which happens when buckets aren't
|
||||
// expanded, or the task is in no view-bound bucket yet).
|
||||
func (t *Task) CurrentBucketID(viewID int64) int64 {
|
||||
if t.BucketID != 0 {
|
||||
return t.BucketID
|
||||
}
|
||||
for _, b := range t.Buckets {
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
// Buckets returned via expand=buckets are scoped to the requesting
|
||||
// view; without view scoping the slice can include entries from
|
||||
// every view this task belongs to.
|
||||
if viewID == 0 || b.ProjectViewID == viewID || b.ProjectViewID == 0 {
|
||||
return b.ID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CreateTask inserts a task into a project (PUT /projects/{id}/tasks).
|
||||
func (c *Client) CreateTask(ctx context.Context, projectID int64, t *Task) (*Task, error) {
|
||||
var out Task
|
||||
if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/tasks", projectID), nil, t, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// 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 client
|
||||
|
||||
import "context"
|
||||
|
||||
// CreateToken mints an API token. If t.OwnerID is non-zero, the token is
|
||||
// minted FOR that user — the caller must be the bot's owner (i.e. created
|
||||
// the bot in step 8 of init).
|
||||
func (c *Client) CreateToken(ctx context.Context, t *APIToken) (*APIToken, error) {
|
||||
var out APIToken
|
||||
if err := c.Do(ctx, "PUT", "/tokens", nil, t, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
// 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 client is a hand-rolled JSON client for the Vikunja REST API. It
|
||||
// mirrors the wire types as plain Go structs so we don't pull XORM into the
|
||||
// CLI binary.
|
||||
package client
|
||||
|
||||
import "time"
|
||||
|
||||
// User mirrors the public fields of pkg/user.User on the wire.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// BotUser is what `PUT /bots` returns.
|
||||
type BotUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
}
|
||||
|
||||
// BotUserCreate is the request body for PUT /bots.
|
||||
type BotUserCreate struct {
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Project mirrors pkg/models/project.Project.
|
||||
type Project struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
IsArchived bool `json:"is_archived,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectView is a saved view (Kanban/List/Gantt/Table) on a project.
|
||||
// view_kind is serialized as a string on the wire ("list" / "gantt" /
|
||||
// "table" / "kanban"), not an int — Vikunja's ProjectViewKind has a
|
||||
// custom MarshalJSON.
|
||||
type ProjectView struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
// view_kind / bucket_configuration_mode are serialized as strings on
|
||||
// the wire (custom MarshalJSON on the parent enums), not ints.
|
||||
ViewKind string `json:"view_kind"`
|
||||
BucketConfMode string `json:"bucket_configuration_mode,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
ViewKindList = "list"
|
||||
ViewKindGantt = "gantt"
|
||||
ViewKindTable = "table"
|
||||
ViewKindKanban = "kanban"
|
||||
)
|
||||
|
||||
// Bucket is a kanban bucket bound to a single project view.
|
||||
type Bucket struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ProjectViewID int64 `json:"project_view_id"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
Position float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
// Task mirrors the on-the-wire task representation. Many fields are omitted —
|
||||
// veans only consumes what its commands print or filter on.
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
DoneAt *time.Time `json:"done_at,omitempty"`
|
||||
Priority int64 `json:"priority,omitempty"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
Index int64 `json:"index,omitempty"`
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
Position float64 `json:"position,omitempty"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
Updated time.Time `json:"updated,omitempty"`
|
||||
// BucketID is only set by Vikunja when sending a task to a server-
|
||||
// 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"`
|
||||
// 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.
|
||||
type TaskComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Comment string `json:"comment"`
|
||||
Author *User `json:"author,omitempty"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
Updated time.Time `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
// Label is a global (per-user) label.
|
||||
type Label struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
HexColor string `json:"hex_color,omitempty"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
Updated time.Time `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
// LabelTask is the body for `PUT /tasks/{id}/labels`.
|
||||
type LabelTask struct {
|
||||
LabelID int64 `json:"label_id"`
|
||||
}
|
||||
|
||||
// TaskRelation is the body for `PUT /tasks/{id}/relations` and the row
|
||||
// returned. RelationKind is one of: subtask, parenttask, related, duplicates,
|
||||
// duplicateof, blocking, blocked, precedes, follows, copiedfrom, copiedto.
|
||||
type TaskRelation struct {
|
||||
TaskID int64 `json:"task_id,omitempty"`
|
||||
OtherTaskID int64 `json:"other_task_id"`
|
||||
RelationKind string `json:"relation_kind"`
|
||||
}
|
||||
|
||||
// TaskAssignee is the body for `PUT /tasks/{id}/assignees`.
|
||||
type TaskAssignee struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
// ProjectUser is the body and response for `PUT /projects/{id}/users`.
|
||||
type ProjectUser struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Permission int `json:"permission"`
|
||||
}
|
||||
|
||||
// Permission constants for project sharing.
|
||||
const (
|
||||
PermissionRead = 0
|
||||
PermissionReadWrite = 1
|
||||
PermissionAdmin = 2
|
||||
)
|
||||
|
||||
// APIToken is the request and response shape for `PUT /tokens`. The plaintext
|
||||
// `Token` field is only populated on creation. Vikunja requires ExpiresAt;
|
||||
// callers that want a long-lived token use FarFuture (year 9999).
|
||||
type APIToken struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Permissions map[string][]string `json:"permissions"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
OwnerID int64 `json:"owner_id,omitempty"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
}
|
||||
|
||||
// FarFuture is what veans uses for "no expiry" since Vikunja's API token
|
||||
// model marks expires_at as required. Year 9999 is well past any reasonable
|
||||
// rotation horizon and is what the frontend uses for its "never" option.
|
||||
var FarFuture = time.Date(9999, time.December, 31, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Info is the parsed shape of `GET /info`.
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
FrontendURL string `json:"frontend_url"`
|
||||
MOTD string `json:"motd,omitempty"`
|
||||
LinkSharingEnabled bool `json:"link_sharing_enabled"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
Auth struct {
|
||||
Local struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"local"`
|
||||
OpenIDConnect struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Providers []struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
} `json:"providers"`
|
||||
} `json:"openid_connect"`
|
||||
} `json:"auth"`
|
||||
}
|
||||
|
||||
// LoginRequest is the body for `POST /login`.
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TOTPPasscode string `json:"totp_passcode,omitempty"`
|
||||
LongToken bool `json:"long_token,omitempty"`
|
||||
}
|
||||
|
||||
// LoginResponse is the JWT bundle.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// OAuthTokenRequest is the JSON body for POST /api/v1/oauth/token. Vikunja's
|
||||
// OAuth server explicitly rejects form-encoded requests; everything is JSON.
|
||||
type OAuthTokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
CodeVerifier string `json:"code_verifier,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthTokenResponse mirrors the standard RFC 6749 response.
|
||||
type OAuthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
// 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 client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// CreateBotUser provisions a bot user via PUT /user/bots. The username must
|
||||
// be prefixed `bot-` (Vikunja enforces this). The caller becomes the bot's
|
||||
// owner, which is what allows them to mint API tokens for the bot via
|
||||
// PUT /tokens with owner_id.
|
||||
//
|
||||
// On Vikunja versions that predate the /user/bots endpoint, the server
|
||||
// returns 404, which we surface as BOT_USERS_UNAVAILABLE so init can fail
|
||||
// fast with a clear message.
|
||||
func (c *Client) CreateBotUser(ctx context.Context, username, name string) (*BotUser, error) {
|
||||
var out BotUser
|
||||
err := c.Do(ctx, "PUT", "/user/bots", nil, &BotUserCreate{Username: username, Name: name}, &out)
|
||||
if err != nil {
|
||||
var oe *output.Error
|
||||
if errors.As(err, &oe) && oe.Code == output.CodeNotFound {
|
||||
return nil, output.Wrap(output.CodeBotUsersUnavailable, err,
|
||||
"this Vikunja instance does not expose /user/bots — upgrade to a newer version")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListBotUsers returns all bot users owned by the authenticated user.
|
||||
func (c *Client) ListBotUsers(ctx context.Context) ([]*BotUser, error) {
|
||||
var out []*BotUser
|
||||
if err := c.Do(ctx, "GET", "/user/bots", nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FindMyBotByUsername scans the caller's owned bots for one with the given
|
||||
// username and returns it, or nil if no match. Useful for distinguishing
|
||||
// "name is taken by someone else" from "name is taken by me" before
|
||||
// attempting creation.
|
||||
func (c *Client) FindMyBotByUsername(ctx context.Context, username string) (*BotUser, error) {
|
||||
bots, err := c.ListBotUsers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range bots {
|
||||
if b != nil && b.Username == username {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
// 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 (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
func newAPICmd() *cobra.Command {
|
||||
var (
|
||||
dataFlag string
|
||||
queryFlag []string
|
||||
dataFile string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <METHOD> <PATH>",
|
||||
Short: "Raw REST passthrough — escape hatch for endpoints veans doesn't wrap",
|
||||
Long: `Sends a request to /api/v1<PATH> as the bot. Use this when curated
|
||||
commands don't shape the data the way you need. The response body is
|
||||
written to stdout verbatim.
|
||||
|
||||
Examples:
|
||||
veans api GET /projects
|
||||
veans api GET /tasks/123
|
||||
veans api POST /tasks/123 --data '{"description":"updated"}'
|
||||
veans api GET /tasks --query expand=reactions --query per_page=100`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rt, err := loadRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
method := strings.ToUpper(args[0])
|
||||
path := args[1]
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
for _, kv := range queryFlag {
|
||||
eq := strings.Index(kv, "=")
|
||||
if eq < 0 {
|
||||
return output.New(output.CodeValidation, "--query must be key=value: %q", kv)
|
||||
}
|
||||
query.Add(kv[:eq], kv[eq+1:])
|
||||
}
|
||||
|
||||
var body []byte
|
||||
switch {
|
||||
case dataFile == "-":
|
||||
b, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = b
|
||||
case dataFile != "":
|
||||
b, err := os.ReadFile(dataFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = b
|
||||
case dataFlag != "":
|
||||
body = []byte(dataFlag)
|
||||
}
|
||||
|
||||
status, respBody, retryAfter, err := rt.client.DoRaw(cmd.Context(), method, path, query, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
snippet := strings.TrimSpace(string(respBody))
|
||||
if len(snippet) > maxAPIErrorSnippet {
|
||||
snippet = snippet[:maxAPIErrorSnippet] + "…(truncated)"
|
||||
}
|
||||
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
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&dataFlag, "data", "", "request body (raw)")
|
||||
cmd.Flags().StringVar(&dataFile, "data-file", "", "read request body from file (`-` = stdin)")
|
||||
cmd.Flags().StringSliceVar(&queryFlag, "query", nil, "query parameter, key=value (repeatable)")
|
||||
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:
|
||||
return output.CodeAuth
|
||||
case status == 404:
|
||||
return output.CodeNotFound
|
||||
case status == 409:
|
||||
return output.CodeConflict
|
||||
case status == 429:
|
||||
return output.CodeRateLimited
|
||||
case status >= 400 && status < 500:
|
||||
return output.CodeValidation
|
||||
default:
|
||||
return output.CodeUnknown
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// 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 (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
"code.vikunja.io/veans/internal/status"
|
||||
)
|
||||
|
||||
func newClaimCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "claim <id>",
|
||||
Short: "Claim a task: assign the bot, move to In Progress, tag with branch",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rt, err := loadRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := rt.resolveTaskID(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move to In Progress. Vikunja's task↔bucket relation lives
|
||||
// in a separate table; POST /tasks doesn't move buckets, so
|
||||
// use the dedicated endpoint.
|
||||
bid, err := status.BucketID(status.InProgress, rt.cfg.Buckets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rt.client.MoveTaskToBucket(cmd.Context(),
|
||||
rt.cfg.ProjectID, rt.cfg.ViewID, bid, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Assign the bot. Idempotent on repeat — Vikunja returns 409 if
|
||||
// already assigned, which we map to a soft-skip.
|
||||
if err := rt.client.AddAssignee(cmd.Context(), id, rt.cfg.Bot.UserID); err != nil {
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) || oe.Code != output.CodeConflict {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Tag with the current branch label, if there is one.
|
||||
if branch := currentGitBranch(cmd.Context()); branch != "" {
|
||||
labelTitle := branchLabel(branch)
|
||||
l, err := getOrCreateLabelByTitle(cmd.Context(), rt.client, labelTitle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rt.client.AddLabelToTask(cmd.Context(), id, l.ID); err != nil {
|
||||
var oe *output.Error
|
||||
if !errors.As(err, &oe) || oe.Code != output.CodeConflict {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task, err := rt.client.GetTask(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
"code.vikunja.io/veans/internal/status"
|
||||
)
|
||||
|
||||
type createFlags struct {
|
||||
description string
|
||||
statusName string
|
||||
priority int64
|
||||
labels []string
|
||||
parent string
|
||||
blockedBy []string
|
||||
}
|
||||
|
||||
func newCreateCmd() *cobra.Command {
|
||||
f := &createFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <title>",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Create a new task",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rt, err := loadRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, err := runCreate(cmd.Context(), rt, args[0], f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
|
||||
},
|
||||
}
|
||||
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)")
|
||||
cmd.Flags().StringVar(&f.parent, "parent", "", "parent task ID (creates parenttask relation)")
|
||||
cmd.Flags().StringSliceVar(&f.blockedBy, "blocked-by", nil, "task IDs that block this one (repeatable)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCreate(ctx context.Context, rt *runtime, title string, f *createFlags) (*client.Task, error) {
|
||||
st, err := status.Parse(f.statusName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucketID, err := status.BucketID(st, rt.cfg.Buckets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err := rt.client.CreateTask(ctx, rt.cfg.ProjectID, &client.Task{
|
||||
Title: strings.TrimSpace(title),
|
||||
Description: f.description,
|
||||
Priority: f.priority,
|
||||
ProjectID: rt.cfg.ProjectID,
|
||||
BucketID: bucketID,
|
||||
Done: st.Done(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Vikunja places newly-created tasks in the view's default bucket
|
||||
// regardless of bucket_id in the create payload — move it explicitly
|
||||
// when the requested status isn't Todo.
|
||||
if st != status.Todo {
|
||||
if err := rt.client.MoveTaskToBucket(ctx,
|
||||
rt.cfg.ProjectID, rt.cfg.ViewID, bucketID, created.ID); err != nil {
|
||||
return nil, output.Wrap(output.CodeUnknown, err, "set initial bucket: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Attach labels (lazily creating them under veans: namespace).
|
||||
for _, raw := range f.labels {
|
||||
title := normalizeLabelTitle(raw)
|
||||
l, err := getOrCreateLabelByTitle(ctx, rt.client, title)
|
||||
if err != nil {
|
||||
return nil, output.Wrap(output.CodeUnknown, err, "label %q: %v", title, err)
|
||||
}
|
||||
if err := rt.client.AddLabelToTask(ctx, created.ID, l.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Parent relation.
|
||||
if f.parent != "" {
|
||||
parentID, err := rt.resolveTaskID(ctx, f.parent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := rt.client.CreateRelation(ctx, created.ID, parentID, "parenttask"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked-by relations.
|
||||
for _, ref := range f.blockedBy {
|
||||
blockerID, err := rt.resolveTaskID(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := rt.client.CreateRelation(ctx, created.ID, blockerID, "blocked"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch so the response reflects the labels and any post-create state.
|
||||
// The refetch is best-effort: if it fails (token expired mid-operation,
|
||||
// transient network blip), we still return the create result so callers
|
||||
// see the new task ID rather than a confusing error.
|
||||
final, err := rt.client.GetTask(ctx, created.ID)
|
||||
if err != nil {
|
||||
return created, nil //nolint:nilerr // intentional best-effort refetch
|
||||
}
|
||||
return final, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
// 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 (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"code.vikunja.io/veans/internal/bootstrap"
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
)
|
||||
|
||||
type initFlags struct {
|
||||
server string
|
||||
token string
|
||||
username string
|
||||
password string
|
||||
totp string
|
||||
usePassword bool
|
||||
botUsername string
|
||||
projectID int64
|
||||
viewID int64
|
||||
yesBuckets bool
|
||||
skipBuckets bool
|
||||
configPath string
|
||||
installClaude bool
|
||||
installOpenCode bool
|
||||
noHooks bool
|
||||
}
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
f := &initFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Provision a Vikunja bot user and write .veans.yml",
|
||||
Long: `Onboards veans into the current repository:
|
||||
|
||||
1. Authenticate as you (--token, or username/password)
|
||||
2. Pick a Vikunja project and Kanban view
|
||||
3. Bootstrap canonical buckets (Todo / In Progress / In Review / Done / Scrapped)
|
||||
4. Create a 'bot-<repo>' user, share the project with it, mint its API token
|
||||
5. Store the bot's token in your keychain (or ~/.config/veans/credentials.yml)
|
||||
6. Write .veans.yml to the repository root
|
||||
|
||||
The token stored locally belongs to the bot, not to you — you can rotate or
|
||||
revoke it at any time without affecting your own session.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
path := f.configPath
|
||||
if path == "" {
|
||||
root, err := config.RepoRoot(cmd.Context(), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = filepath.Join(root, config.Filename)
|
||||
}
|
||||
res, err := bootstrap.Init(cmd.Context(), &bootstrap.Options{
|
||||
ConfigPath: path,
|
||||
Server: f.server,
|
||||
HumanToken: f.token,
|
||||
HumanUsePassword: f.usePassword,
|
||||
HumanUsername: f.username,
|
||||
HumanPassword: f.password,
|
||||
HumanTOTP: f.totp,
|
||||
BotUsername: f.botUsername,
|
||||
ProjectID: f.projectID,
|
||||
ViewID: f.viewID,
|
||||
AutoApproveBuckets: f.yesBuckets,
|
||||
SkipBucketBootstrap: f.skipBuckets,
|
||||
InstallClaudeCode: f.installClaude,
|
||||
InstallOpenCode: f.installOpenCode,
|
||||
ClaudeCodeFlagSet: cmd.Flags().Changed("install-claude"),
|
||||
OpenCodeFlagSet: cmd.Flags().Changed("install-opencode"),
|
||||
NoHooks: f.noHooks,
|
||||
Out: os.Stderr,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printPostInitSummary(cmd.OutOrStdout(), res)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&f.server, "server", "", "Vikunja server URL")
|
||||
cmd.Flags().StringVar(&f.token, "token", "", "JWT or personal API token (skips OAuth/password; useful for SSO/OIDC instances)")
|
||||
cmd.Flags().BoolVar(&f.usePassword, "use-password", false, "use POST /login (username+password) instead of the default OAuth flow")
|
||||
cmd.Flags().StringVar(&f.username, "username", "", "Vikunja username (implies --use-password)")
|
||||
cmd.Flags().StringVar(&f.password, "password", "", "Vikunja password (implies --use-password; prompted if empty)")
|
||||
cmd.Flags().StringVar(&f.totp, "totp", "", "TOTP code if your account requires 2FA")
|
||||
cmd.Flags().StringVar(&f.botUsername, "bot-username", "", "override the bot-<repo> default")
|
||||
cmd.Flags().Int64Var(&f.projectID, "project", 0, "skip the interactive project picker")
|
||||
cmd.Flags().Int64Var(&f.viewID, "view", 0, "skip the interactive view picker")
|
||||
cmd.Flags().BoolVar(&f.yesBuckets, "yes-buckets", false, "auto-approve canonical bucket bootstrap")
|
||||
cmd.Flags().BoolVar(&f.skipBuckets, "skip-buckets", false, "do not prompt or create buckets (assumes they exist)")
|
||||
cmd.Flags().StringVar(&f.configPath, "config", "", "where to write .veans.yml (defaults to the repo root)")
|
||||
cmd.Flags().BoolVar(&f.installClaude, "install-claude", false, "wire `veans prime` into .claude/settings.json (skip prompt)")
|
||||
cmd.Flags().BoolVar(&f.installOpenCode, "install-opencode", false, "wire `veans prime` into .opencode/plugin/veans-prime.ts (skip prompt)")
|
||||
cmd.Flags().BoolVar(&f.noHooks, "no-hooks", false, "don't offer to install agent hooks; just print the snippets")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printPostInitSummary(w io.Writer, res *bootstrap.Result) {
|
||||
fmt.Fprintf(w, "\nveans is ready. Bot user: %s\n", res.BotUser.Username)
|
||||
fmt.Fprintf(w, "Config: %s\n", res.Config.Path())
|
||||
fmt.Fprintf(w, "Project: #%d %s\n", res.Config.ProjectID, identOrFallback(res.Config.ProjectIdentifier))
|
||||
|
||||
// Only fall back to printing the snippets when the user declined or
|
||||
// skipped the install offer. When at least one hook was installed, the
|
||||
// install routine already logged what it did to stderr.
|
||||
if res.AgentChoices.ClaudeCode || res.AgentChoices.OpenCode {
|
||||
return
|
||||
}
|
||||
// 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 (%s):
|
||||
%s
|
||||
|
||||
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 {
|
||||
if s == "" {
|
||||
return "(no identifier — task IDs render as #NN)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
)
|
||||
|
||||
// labelNamespace is auto-prepended to label names that don't already have it,
|
||||
// so the agent's labels live in their own corner of the user's global label
|
||||
// list and don't pollute manually-curated labels.
|
||||
const labelNamespace = "veans:"
|
||||
|
||||
func normalizeLabelTitle(raw string) string {
|
||||
t := strings.TrimSpace(raw)
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(t, labelNamespace) {
|
||||
return t
|
||||
}
|
||||
return labelNamespace + t
|
||||
}
|
||||
|
||||
// getOrCreateLabelByTitle returns the ID of the label with the given title,
|
||||
// creating it under the current user if it doesn't exist. Labels are global
|
||||
// per user in Vikunja, so this only finds labels visible to whoever the
|
||||
// `c` client is authenticated as (i.e. the bot when called from veans).
|
||||
func getOrCreateLabelByTitle(ctx context.Context, c *client.Client, title string) (*client.Label, error) {
|
||||
existing, err := c.ListLabels(ctx, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range existing {
|
||||
if l.Title == title {
|
||||
return l, nil
|
||||
}
|
||||
}
|
||||
created, err := c.CreateLabel(ctx, &client.Label{Title: title})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// findLabelOnTask returns the label with the given (already-normalized)
|
||||
// title attached to the task, or nil. Used by --label-remove to know which
|
||||
// label ID to detach.
|
||||
func findLabelOnTask(t *client.Task, title string) *client.Label {
|
||||
for _, l := range t.Labels {
|
||||
if l != nil && l.Title == title {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
"code.vikunja.io/veans/internal/status"
|
||||
)
|
||||
|
||||
type listFlags struct {
|
||||
ready bool
|
||||
mine bool
|
||||
branch string
|
||||
filter string
|
||||
statuses []string
|
||||
}
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
f := &listFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List tasks in the configured project",
|
||||
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, 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)
|
||||
--filter <expr> raw Vikunja filter expression (see Vikunja docs); applied
|
||||
server-side
|
||||
--status <s> filter by status (todo|in-progress|in-review|completed|scrapped),
|
||||
may be repeated`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
rt, err := loadRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks, err := runList(cmd, rt, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(tasks)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&f.ready, "ready", false, "only ready-to-start tasks (Todo bucket, not done)")
|
||||
cmd.Flags().BoolVar(&f.mine, "mine", false, "only tasks assigned to the veans bot")
|
||||
cmd.Flags().StringVar(&f.branch, "branch", "", "only tasks tagged 'veans:branch:<name>' (omit value for current branch)")
|
||||
cmd.Flags().Lookup("branch").NoOptDefVal = "__auto__"
|
||||
cmd.Flags().StringVar(&f.filter, "filter", "", "raw Vikunja filter expression, applied server-side")
|
||||
cmd.Flags().StringSliceVar(&f.statuses, "status", nil, "filter by status (repeatable)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(cmd *cobra.Command, rt *runtime, f *listFlags) ([]*client.Task, error) {
|
||||
opts := &client.TaskListOptions{
|
||||
Filter: f.filter,
|
||||
// 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. 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 && !isReady(t, rt.cfg.Buckets.Todo, rt.cfg.ViewID) {
|
||||
continue
|
||||
}
|
||||
if f.mine {
|
||||
if !taskAssignedTo(t, rt.cfg.Bot.UserID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if f.branch != "" {
|
||||
want := f.branch
|
||||
if want == "__auto__" {
|
||||
want = currentGitBranch(cmd.Context())
|
||||
if want == "" {
|
||||
return nil, output.New(output.CodeValidation,
|
||||
"--branch given without a value but no current git branch detected")
|
||||
}
|
||||
}
|
||||
label := branchLabel(want)
|
||||
if !taskHasLabel(t, label) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(f.statuses) > 0 {
|
||||
ok := false
|
||||
for _, raw := range f.statuses {
|
||||
s, err := status.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wantBucket, _ := status.BucketID(s, rt.cfg.Buckets)
|
||||
if taskBucket == wantBucket {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
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 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func taskHasLabel(t *client.Task, title string) bool {
|
||||
for _, l := range t.Labels {
|
||||
if l != nil && l.Title == title {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func branchLabel(branch string) string {
|
||||
return "veans:branch:" + branch
|
||||
}
|
||||
|
||||
// currentGitBranch returns the current git branch as reported by
|
||||
// `git rev-parse --abbrev-ref HEAD`, or "" if we're not in a git repo or
|
||||
// HEAD is detached. Failures are silent so callers can decide.
|
||||
func currentGitBranch(ctx context.Context) string {
|
||||
out, err := runGit(ctx, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "HEAD" {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// 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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func newLoginCmd() *cobra.Command {
|
||||
var (
|
||||
token string
|
||||
username string
|
||||
password string
|
||||
totp string
|
||||
usePassword bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Mint a fresh API token for the bot user (rotation)",
|
||||
Long: `Re-authenticates as you (the bot's owner) and mints a new API token
|
||||
for the bot configured in .veans.yml. The new token replaces the
|
||||
existing one in the credential store.
|
||||
|
||||
The default flow is OAuth 2.0 Authorization Code + PKCE — open the
|
||||
URL veans prints, sign in, and paste the callback URL back. Use
|
||||
--token to paste in a personal API token, or --use-password / --username
|
||||
to force POST /login instead.
|
||||
|
||||
Use this after revoking the bot's token in Vikunja's UI, or any time
|
||||
you want to rotate.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
path, err := config.Find("")
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrNotFound) {
|
||||
return output.Wrap(output.CodeNotConfigured, err,
|
||||
"no .veans.yml found — run `veans init` first")
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
human := client.New(cfg.Server, "")
|
||||
tok, err := auth.AcquireHumanToken(cmd.Context(), human, auth.LoginOptions{
|
||||
Token: token,
|
||||
UsePassword: usePassword,
|
||||
Username: username,
|
||||
Password: password,
|
||||
TOTP: totp,
|
||||
Out: os.Stderr,
|
||||
}, auth.NewStdPrompter())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
human.Token = tok
|
||||
|
||||
routes, err := human.Routes(cmd.Context())
|
||||
if err != nil {
|
||||
return output.Wrap(output.CodeUnknown, err, "fetch /routes: %v", err)
|
||||
}
|
||||
perms := client.PermissionsForBot(routes)
|
||||
if len(perms) == 0 {
|
||||
return output.New(output.CodeUnknown, "no API token permissions available")
|
||||
}
|
||||
|
||||
minted, err := human.CreateToken(cmd.Context(), &client.APIToken{
|
||||
Title: "veans (rotated)",
|
||||
Permissions: perms,
|
||||
ExpiresAt: client.FarFuture,
|
||||
OwnerID: cfg.Bot.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if minted.Token == "" {
|
||||
return output.New(output.CodeUnknown, "PUT /tokens did not return token plaintext")
|
||||
}
|
||||
|
||||
if err := credentials.Default().Set(cfg.Server, cfg.Bot.Username, minted.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Rotated token for %s on %s\n", cfg.Bot.Username, cfg.Server)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&token, "token", "", "JWT or personal API token (skips OAuth/password)")
|
||||
cmd.Flags().BoolVar(&usePassword, "use-password", false, "use POST /login instead of the default OAuth flow")
|
||||
cmd.Flags().StringVar(&username, "username", "", "your Vikunja username (implies --use-password)")
|
||||
cmd.Flags().StringVar(&password, "password", "", "your Vikunja password (implies --use-password; prompted if empty)")
|
||||
cmd.Flags().StringVar(&totp, "totp", "", "TOTP code if your account requires 2FA")
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// 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 (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
)
|
||||
|
||||
//go:embed prompt.tmpl
|
||||
var promptTemplate string
|
||||
|
||||
// primeContext is the data passed into the agent prompt template.
|
||||
type primeContext struct {
|
||||
Server string
|
||||
ProjectID int64
|
||||
ProjectTitle string
|
||||
ProjectIdentifier string
|
||||
ViewID int64
|
||||
Buckets config.Buckets
|
||||
BotUsername string
|
||||
TaskIDExample string
|
||||
}
|
||||
|
||||
func newPrimeCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "prime",
|
||||
Short: "Emit the agent system prompt for this project",
|
||||
Long: `Renders the embedded prompt template against this repo's .veans.yml and
|
||||
prints it to stdout. Designed to be wired into Claude Code's SessionStart
|
||||
and PreCompact hooks (or the OpenCode equivalent) so coding agents always
|
||||
have an up-to-date Vikunja cheat sheet in context.
|
||||
|
||||
If no .veans.yml is found upward from the current directory, prime exits
|
||||
silently with status 0 — that makes the hook safe to install globally.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
path, err := config.Find("")
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrNotFound) {
|
||||
return nil // silent — globally-installed hook safety
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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{
|
||||
Server: cfg.Server,
|
||||
ProjectID: cfg.ProjectID,
|
||||
ProjectTitle: projectTitle,
|
||||
ProjectIdentifier: cfg.ProjectIdentifier,
|
||||
ViewID: cfg.ViewID,
|
||||
Buckets: cfg.Buckets,
|
||||
BotUsername: cfg.Bot.Username,
|
||||
TaskIDExample: cfg.FormatTaskID(1),
|
||||
}
|
||||
|
||||
tpl, err := template.New("prime").Parse(promptTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse prompt template: %w", err)
|
||||
}
|
||||
return tpl.Execute(cmd.OutOrStdout(), data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
// 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 (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
)
|
||||
|
||||
func TestPrimeTemplate_RendersAnchors(t *testing.T) {
|
||||
data := primeContext{
|
||||
Server: "https://vikunja.example.com",
|
||||
ProjectID: 42,
|
||||
ProjectTitle: "Test Project",
|
||||
ProjectIdentifier: "PROJ",
|
||||
ViewID: 7,
|
||||
Buckets: config.Buckets{Todo: 11, InProgress: 12, InReview: 13, Done: 14, Scrapped: 15},
|
||||
BotUsername: "bot-myrepo",
|
||||
TaskIDExample: "PROJ-1",
|
||||
}
|
||||
tpl, err := template.New("prime").Parse(promptTemplate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
mustContain := []string{
|
||||
"<EXTREMELY_IMPORTANT>",
|
||||
"</EXTREMELY_IMPORTANT>",
|
||||
"bot-myrepo",
|
||||
"Test Project",
|
||||
"PROJ-1",
|
||||
"Refs:",
|
||||
"veans claim",
|
||||
"veans list --ready",
|
||||
"--description-replace-old",
|
||||
"Todo",
|
||||
"In Progress",
|
||||
"In Review",
|
||||
"Done",
|
||||
"Scrapped",
|
||||
// HTML format guidance the agent depends on:
|
||||
"Description format",
|
||||
"Titles are plaintext",
|
||||
`data-type="taskList"`,
|
||||
`data-checked="false"`,
|
||||
"<h2>",
|
||||
"<pre><code",
|
||||
}
|
||||
for _, s := range mustContain {
|
||||
if !strings.Contains(out, s) {
|
||||
t.Errorf("rendered prompt missing %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Buckets show concrete IDs.
|
||||
for _, want := range []string{"`11`", "`12`", "`13`", "`14`", "`15`"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("bucket id %s not present in output", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimeTemplate_NoIdentifierFallback(t *testing.T) {
|
||||
data := primeContext{
|
||||
ProjectTitle: "No Ident",
|
||||
ProjectIdentifier: "",
|
||||
BotUsername: "bot-x",
|
||||
TaskIDExample: "#1",
|
||||
Server: "https://vikunja.example.com",
|
||||
ProjectID: 1,
|
||||
ViewID: 1,
|
||||
}
|
||||
tpl, _ := template.New("prime").Parse(promptTemplate)
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "no identifier") {
|
||||
t.Errorf("expected fallback copy when project has no identifier; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "#NN") {
|
||||
t.Errorf("expected #NN format mention in fallback")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
<EXTREMELY_IMPORTANT>
|
||||
You are working in a repository configured to track tasks in Vikunja via the
|
||||
`veans` CLI. **You MUST use veans for all task tracking instead of TodoWrite.**
|
||||
|
||||
Project: {{ .ProjectTitle }}{{ if .ProjectIdentifier }} ({{ .ProjectIdentifier }}){{ end }}
|
||||
Bot identity: `{{ .BotUsername }}` — your actions in Vikunja appear as this user.
|
||||
Server: {{ .Server }}
|
||||
</EXTREMELY_IMPORTANT>
|
||||
|
||||
# Workflow
|
||||
|
||||
## BEFORE you start work
|
||||
- If a task already exists, claim it: `veans claim {{ .TaskIDExample }}`
|
||||
- Otherwise, create one and start it in one step:
|
||||
`veans create "<short title>" -s in-progress -d "<short description>"`
|
||||
- Use `veans list --ready` to find tasks ready to start (Todo + not blocked).
|
||||
|
||||
## WHILE you work
|
||||
- Keep the task's description in sync with what you're doing. Use HTML
|
||||
(not markdown) — see the "Description format" section below for why
|
||||
and the canonical TipTap shapes Vikunja's web UI renders nicely:
|
||||
`veans update {{ .TaskIDExample }} --description-append '<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>step 1</p></li></ul>'`
|
||||
- For surgical edits, prefer `--description-replace-old` /
|
||||
`--description-replace-new`. To check off a task item, replace
|
||||
`data-checked="false"><p>step 1</p>` with `data-checked="true"><p>step 1</p>`
|
||||
(errors if the old text isn't unique — same semantics as the Edit tool).
|
||||
- Post a comment on significant decisions, discoveries, or course-changes:
|
||||
`veans update {{ .TaskIDExample }} --comment '<p>Discovered Y; pivoting to Z because …</p>'`
|
||||
- For sub-work that could be assigned separately, create real subtasks
|
||||
via `--parent`. For incremental check-off lists, use task-list items
|
||||
in the description instead.
|
||||
|
||||
## AFTER you finish work
|
||||
- Move to `in-review` and post a summary comment. **Never close tasks
|
||||
yourself** — the human (or the merge hook) closes them.
|
||||
```
|
||||
veans update {{ .TaskIDExample }} -s in-review \
|
||||
--comment '<h3>Summary of Changes</h3><ul><li>first thing</li><li>second thing</li></ul>'
|
||||
```
|
||||
- If you abandon work, scrap the task with a reason:
|
||||
`veans update {{ .TaskIDExample }} -s scrapped --reason "obsolete: <why>"`
|
||||
|
||||
## Commit messages
|
||||
Include the task identifier on a `Refs:` line so the merge hook can close
|
||||
tasks automatically when the PR lands:
|
||||
|
||||
```
|
||||
fix: handle empty project identifiers
|
||||
|
||||
Refs: {{ .TaskIDExample }}
|
||||
```
|
||||
|
||||
# Description format
|
||||
|
||||
**Descriptions and comments are HTML, not markdown.** Vikunja's web UI
|
||||
renders them through the TipTap rich-text editor, which interprets the
|
||||
stored field as HTML. Plain markdown saves as literal text and shows up
|
||||
ugly in the UI; write HTML directly. Tokens are cheap, conversion bugs
|
||||
aren't.
|
||||
|
||||
**Titles are plaintext — not HTML, not markdown.** They show up in
|
||||
list views, breadcrumbs, and notification subjects; tags would leak
|
||||
through as `<p>…</p>` everywhere. Write
|
||||
`fix the bug` not `<p>fix the bug</p>` or `**fix** the bug`.
|
||||
|
||||
Canonical TipTap shapes the web UI renders cleanly:
|
||||
|
||||
```html
|
||||
<h2>Summary</h2>
|
||||
<p>Two short paragraphs explaining the task.</p>
|
||||
|
||||
<h3>Steps</h3>
|
||||
<ul data-type="taskList">
|
||||
<li data-type="taskItem" data-checked="false"><p>find the bug</p></li>
|
||||
<li data-type="taskItem" data-checked="false"><p>write the fix</p></li>
|
||||
<li data-type="taskItem" data-checked="true"><p>write the test</p></li>
|
||||
</ul>
|
||||
|
||||
<h3>Notes</h3>
|
||||
<ul>
|
||||
<li>regular bullet point</li>
|
||||
<li>another one</li>
|
||||
</ul>
|
||||
|
||||
<p>Inline <code>code</code>, <strong>bold</strong>, <em>italic</em>,
|
||||
and a <a href="https://example.com">link</a>.</p>
|
||||
|
||||
<pre><code class="language-go">if err != nil { return err }</code></pre>
|
||||
|
||||
<blockquote><p>A quote, e.g. from a linked issue.</p></blockquote>
|
||||
|
||||
<hr>
|
||||
```
|
||||
|
||||
Important details:
|
||||
- Task-list items use `<ul data-type="taskList">` and
|
||||
`<li data-type="taskItem" data-checked="true|false">` — that's what
|
||||
gives users an interactive checkbox. Plain `<ul><li>` lists render as
|
||||
static bullets.
|
||||
- Inner text of task items goes inside `<p>` — the editor expects block
|
||||
content in the `<li>`.
|
||||
- Don't add `data-task-id` attributes manually; the editor auto-fills
|
||||
them on first save.
|
||||
- Escape `<`, `>`, `&` when they appear in literal text (`<`, `>`,
|
||||
`&`). Inside `<pre><code>` blocks you only need to escape these
|
||||
three; line breaks and indentation are preserved.
|
||||
- `--description-replace-old/new` matches raw HTML byte-for-byte. Make
|
||||
the `old` string unique (include surrounding tags to disambiguate).
|
||||
|
||||
# Output
|
||||
|
||||
Every `list`, `show`, `create`, `update`, `claim`, `api` call emits JSON
|
||||
on stdout — no flag needed, no human-formatted variant. Errors land on
|
||||
stderr as `{"code":"...","error":"..."}` with non-zero exit; branch on
|
||||
`code` (NOT_FOUND, CONFLICT, VALIDATION_ERROR, AUTH_ERROR,
|
||||
RATE_LIMITED, NOT_CONFIGURED, BOT_USERS_UNAVAILABLE, UNKNOWN).
|
||||
|
||||
`show`/`create`/`update`/`claim` return a single task; `list` returns
|
||||
an array. Useful fields:
|
||||
|
||||
- `id` (numeric, internal) — pass to `api` calls
|
||||
- `index` (per-project) — what `{{ .TaskIDExample }}` resolves to
|
||||
- `done`, `priority`, `title`, `description` (HTML)
|
||||
- `buckets[]` — current bucket per view; match `project_view_id` to
|
||||
yours from `.veans.yml` and read `id` to know the status
|
||||
- `assignees[]`, `labels[]` — `[]` if absent
|
||||
|
||||
Trust your JSON parser; we won't add new fields without notice.
|
||||
|
||||
# Status model
|
||||
|
||||
| Status | Bucket name | Done flag | Who moves there? |
|
||||
| ------------- | -------------- | --------- | ---------------------------------------- |
|
||||
| `todo` | Todo | false | created here by default |
|
||||
| `in-progress` | In Progress | false | `veans claim` or `update -s in-progress` |
|
||||
| `in-review` | In Review | false | you, when work is finished |
|
||||
| `completed` | Done | true | humans / merge hook only |
|
||||
| `scrapped` | Scrapped | true | you, with --reason |
|
||||
|
||||
# Common commands
|
||||
|
||||
```
|
||||
veans list # all tasks, tree view
|
||||
veans list --ready # ready to start (Todo + not blocked)
|
||||
veans list --mine # tasks assigned to you
|
||||
veans list --branch # tasks tagged with the current git branch
|
||||
veans list --filter "priority > 3" # raw Vikunja filter expression
|
||||
veans show {{ .TaskIDExample }} # full task detail (JSON)
|
||||
|
||||
veans create "title" -s in-progress -d "<p>HTML body — see Description format.</p>"
|
||||
veans create "title" --label bug --priority 4 --parent {{ .TaskIDExample }}
|
||||
veans create "title" --blocked-by {{ .TaskIDExample }}
|
||||
|
||||
veans update {{ .TaskIDExample }} -s in-review --comment '<p>Summary of changes…</p>'
|
||||
veans update {{ .TaskIDExample }} --description-append '<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>new step</p></li></ul>'
|
||||
veans update {{ .TaskIDExample }} --description-replace-old 'data-checked="false"><p>step 1</p>' --description-replace-new 'data-checked="true"><p>step 1</p>'
|
||||
veans update {{ .TaskIDExample }} --label-add bug --label-remove flaky
|
||||
veans update {{ .TaskIDExample }} -s scrapped --reason "obsolete: replaced by {{ .TaskIDExample }}"
|
||||
|
||||
veans claim {{ .TaskIDExample }} # assign yourself + In Progress + branch label
|
||||
veans api GET /tasks/... # escape hatch for raw REST when curated cmds don't fit
|
||||
```
|
||||
|
||||
# Project quick reference
|
||||
|
||||
- Project ID: `{{ .ProjectID }}`{{ if .ProjectIdentifier }}, identifier: `{{ .ProjectIdentifier }}` (task IDs render as `{{ .ProjectIdentifier }}-NN`){{ else }} (no identifier; task IDs render as `#NN`){{ end }}
|
||||
- Kanban view: `{{ .ViewID }}`
|
||||
- Buckets: Todo=`{{ .Buckets.Todo }}`, In Progress=`{{ .Buckets.InProgress }}`, In Review=`{{ .Buckets.InReview }}`, Done=`{{ .Buckets.Done }}`, Scrapped=`{{ .Buckets.Scrapped }}`
|
||||
- Label namespace: `veans:` (auto-prepended; e.g. `--label bug` becomes `veans:bug`)
|
||||
- Branch labels: `veans:branch:<branch-name>` — `veans claim` adds the current branch automatically
|
||||
|
||||
The agent prompt above overrides TodoWrite. Anything you would have tracked
|
||||
there should live in Vikunja instead, where it stays visible across sessions
|
||||
and to humans collaborating on the project.
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// 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 wires the cobra command tree. Each subcommand lives in a
|
||||
// sibling file. The agent-facing commands (list/show/create/update/claim/api)
|
||||
// emit JSON unconditionally; only init and login speak human prose.
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// Root builds the cobra command tree.
|
||||
func Root(version string) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "veans",
|
||||
Short: "veans — a beans-shaped CLI for Vikunja",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version,
|
||||
}
|
||||
|
||||
root.AddCommand(newVersionCmd(version))
|
||||
root.AddCommand(newInitCmd())
|
||||
root.AddCommand(newListCmd())
|
||||
root.AddCommand(newShowCmd())
|
||||
root.AddCommand(newCreateCmd())
|
||||
root.AddCommand(newUpdateCmd())
|
||||
root.AddCommand(newClaimCmd())
|
||||
root.AddCommand(newPrimeCmd())
|
||||
root.AddCommand(newAPICmd())
|
||||
root.AddCommand(newLoginCmd())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// Execute runs the cobra tree and converts errors into the structured output
|
||||
// envelope. Errors land on stderr as JSON `{code, error}` and the process
|
||||
// exits non-zero — both agent-facing and human-facing commands share this
|
||||
// shape so callers can branch on `code` regardless of which command they ran.
|
||||
func Execute(version string) int {
|
||||
cmd := Root(version)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
output.EmitError(err, os.Stderr)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func newVersionCmd(version string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the veans version",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Println(version)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
"code.vikunja.io/veans/internal/credentials"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// runtime bundles the artifacts every non-init command needs: parsed config,
|
||||
// credential store, and an authed HTTP client. Loaded lazily by loadRuntime
|
||||
// at command start.
|
||||
type runtime struct {
|
||||
cfg *config.Config
|
||||
store credentials.Store
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
func loadRuntime() (*runtime, error) {
|
||||
path, err := config.Find("")
|
||||
if err != nil {
|
||||
if errors.Is(err, config.ErrNotFound) {
|
||||
return nil, output.Wrap(output.CodeNotConfigured, err,
|
||||
"no .veans.yml found — run `veans init` in your repo first")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := credentials.Default()
|
||||
tok, err := store.Get(cfg.Server, cfg.Bot.Username)
|
||||
if err != nil {
|
||||
return nil, output.Wrap(output.CodeAuth, err,
|
||||
"no token for %s on %s — run `veans login` to mint a fresh one",
|
||||
cfg.Bot.Username, cfg.Server)
|
||||
}
|
||||
c := client.New(cfg.Server, tok)
|
||||
if cfg.HTTPTimeout > 0 {
|
||||
c.HTTPClient.Timeout = cfg.HTTPTimeout
|
||||
}
|
||||
return &runtime{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
client: c,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveTaskID accepts PROJ-NN, #NN, or a bare integer and returns the
|
||||
// numeric task ID. The project identifier from .veans.yml is used to verify
|
||||
// the prefix matches; mismatches error out so an agent can't accidentally
|
||||
// poke a task in the wrong project.
|
||||
func (r *runtime) resolveTaskID(ctx context.Context, raw string) (int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, output.New(output.CodeValidation, "empty task ID")
|
||||
}
|
||||
|
||||
// #NN form
|
||||
if strings.HasPrefix(raw, "#") {
|
||||
n, err := strconv.ParseInt(raw[1:], 10, 64)
|
||||
if err != nil {
|
||||
return 0, output.Wrap(output.CodeValidation, err, "invalid task ID %q", raw)
|
||||
}
|
||||
return r.lookupByIndex(ctx, n)
|
||||
}
|
||||
|
||||
// Bare integer — treat as task index in the configured project.
|
||||
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return r.lookupByIndex(ctx, n)
|
||||
}
|
||||
|
||||
// PROJ-NN form
|
||||
idx := strings.LastIndex(raw, "-")
|
||||
if idx > 0 && idx < len(raw)-1 {
|
||||
prefix := raw[:idx]
|
||||
num := raw[idx+1:]
|
||||
if r.cfg.ProjectIdentifier != "" && !strings.EqualFold(prefix, r.cfg.ProjectIdentifier) {
|
||||
return 0, output.New(output.CodeValidation,
|
||||
"task %q has identifier %q, but this repo's .veans.yml uses %q",
|
||||
raw, prefix, r.cfg.ProjectIdentifier)
|
||||
}
|
||||
n, err := strconv.ParseInt(num, 10, 64)
|
||||
if err != nil {
|
||||
return 0, output.Wrap(output.CodeValidation, err, "invalid task ID %q", raw)
|
||||
}
|
||||
return r.lookupByIndex(ctx, n)
|
||||
}
|
||||
|
||||
return 0, output.New(output.CodeValidation, "invalid task ID %q (expected PROJ-NN, #NN, or NN)", raw)
|
||||
}
|
||||
|
||||
// lookupByIndex resolves a 1-based per-project task index (the NN in
|
||||
// PROJ-NN / #NN) to a numeric task ID by listing the project's tasks and
|
||||
// matching on Index. The cost is one paged GET; we tolerate it because
|
||||
// resolving by index without a dedicated endpoint is the only stable path.
|
||||
func (r *runtime) lookupByIndex(ctx context.Context, index int64) (int64, error) {
|
||||
tasks, err := r.client.ListProjectTasks(ctx, r.cfg.ProjectID, &client.TaskListOptions{
|
||||
Filter: fmt.Sprintf("index = %d", index),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if t.Index == index {
|
||||
return t.ID, nil
|
||||
}
|
||||
}
|
||||
return 0, output.New(output.CodeNotFound, "task %s not found in project %d",
|
||||
r.cfg.FormatTaskID(index), r.cfg.ProjectID)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// 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 (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newShowCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show <id>",
|
||||
Short: "Show a task by PROJ-NN, #NN, or numeric ID",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rt, err := loadRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := rt.resolveTaskID(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, err := rt.client.GetTask(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
"code.vikunja.io/veans/internal/status"
|
||||
)
|
||||
|
||||
type updateFlags struct {
|
||||
statusName string
|
||||
title string
|
||||
priority int64
|
||||
priorityIsSet bool
|
||||
addLabels []string
|
||||
removeLabels []string
|
||||
description string
|
||||
descriptionIsSet bool
|
||||
replaceOld string
|
||||
replaceNew string
|
||||
descriptionApp string
|
||||
comment string
|
||||
reason string
|
||||
ifUnchangedSince string
|
||||
}
|
||||
|
||||
func newUpdateCmd() *cobra.Command {
|
||||
f := &updateFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Aliases: []string{"u"},
|
||||
Short: "Update a task by PROJ-NN, #NN, or numeric ID",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rt, err := loadRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.descriptionIsSet = cmd.Flags().Changed("description")
|
||||
f.priorityIsSet = cmd.Flags().Changed("priority")
|
||||
|
||||
id, err := rt.resolveTaskID(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, err := runUpdate(cmd.Context(), rt, id, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(task)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&f.statusName, "status", "s", "", "transition to a status")
|
||||
cmd.Flags().StringVarP(&f.title, "title", "t", "", "new title")
|
||||
cmd.Flags().Int64Var(&f.priority, "priority", 0, "new priority")
|
||||
cmd.Flags().StringSliceVar(&f.addLabels, "label-add", nil, "labels to attach (repeatable; veans: prefix added if missing)")
|
||||
cmd.Flags().StringSliceVar(&f.removeLabels, "label-remove", nil, "labels to detach (repeatable)")
|
||||
cmd.Flags().StringVar(&f.description, "description", "", "replace the entire description")
|
||||
cmd.Flags().StringVar(&f.replaceOld, "description-replace-old", "", "exact-match string to replace in description (must be unique)")
|
||||
cmd.Flags().StringVar(&f.replaceNew, "description-replace-new", "", "replacement for --description-replace-old")
|
||||
cmd.Flags().StringVar(&f.descriptionApp, "description-append", "", "append text to the existing description")
|
||||
cmd.Flags().StringVarP(&f.comment, "comment", "c", "", "post a comment as part of this update")
|
||||
cmd.Flags().StringVar(&f.reason, "reason", "", "rationale (required when --status scrapped)")
|
||||
cmd.Flags().StringVar(&f.ifUnchangedSince, "if-unchanged-since", "", "RFC3339 timestamp; abort if the task has changed since")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runUpdate is intentionally a single linear flow — the steps it performs
|
||||
// (concurrency check → status → field changes → comments → field POST →
|
||||
// bucket move → label add/remove → refetch) all share the same task,
|
||||
// flag set, and error-handling shape. Splitting them produces five tiny
|
||||
// functions that each take the same five arguments.
|
||||
//
|
||||
//nolint:gocyclo // single-pass orchestration; each branch is one short stanza
|
||||
func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*client.Task, error) {
|
||||
current, err := rt.client.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Optimistic concurrency.
|
||||
if f.ifUnchangedSince != "" {
|
||||
ts, err := time.Parse(time.RFC3339, f.ifUnchangedSince)
|
||||
if err != nil {
|
||||
return nil, output.Wrap(output.CodeValidation, err, "parse --if-unchanged-since: %v", err)
|
||||
}
|
||||
if current.Updated.After(ts) {
|
||||
return nil, output.New(output.CodeConflict,
|
||||
"task %s changed at %s, after --if-unchanged-since %s",
|
||||
rt.cfg.FormatTaskID(current.Index), current.Updated.Format(time.RFC3339), ts.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve new status / done flag if --status is set.
|
||||
var newStatus status.Status
|
||||
if f.statusName != "" {
|
||||
s, err := status.Parse(f.statusName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newStatus = s
|
||||
if s == status.Scrapped && strings.TrimSpace(f.reason) == "" {
|
||||
return nil, output.New(output.CodeValidation, "--reason is required when --status scrapped")
|
||||
}
|
||||
}
|
||||
|
||||
// Build the update payload incrementally so we don't clobber unmentioned
|
||||
// fields. The base must include the ID; bucket/done are conditional.
|
||||
body := &client.Task{ID: id}
|
||||
dirty := false
|
||||
|
||||
if f.title != "" {
|
||||
body.Title = f.title
|
||||
dirty = true
|
||||
}
|
||||
if f.priorityIsSet {
|
||||
body.Priority = f.priority
|
||||
dirty = true
|
||||
}
|
||||
|
||||
// Description ops are mutually-exclusive layers; --description wins
|
||||
// outright, otherwise replace-old/new + append run on the current body.
|
||||
newDesc, descChanged, err := composeDescription(current.Description, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if descChanged {
|
||||
body.Description = newDesc
|
||||
dirty = true
|
||||
}
|
||||
|
||||
// Status transitions: `done` is set on the task body (Update processes
|
||||
// it natively), but the bucket move uses the dedicated TaskBucket
|
||||
// endpoint after the field update so the change is visible on the
|
||||
// Kanban view.
|
||||
var bucketTransitionTarget int64
|
||||
if newStatus != "" {
|
||||
bid, err := status.BucketID(newStatus, rt.cfg.Buckets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucketTransitionTarget = bid
|
||||
body.Done = newStatus.Done()
|
||||
dirty = true
|
||||
}
|
||||
|
||||
// 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, "<strong>Scrapped:</strong> "+strings.TrimSpace(f.reason)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if f.comment != "" {
|
||||
if _, err := rt.client.AddTaskComment(ctx, id, f.comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the field update if anything changed.
|
||||
updated := current
|
||||
if dirty {
|
||||
u, err := rt.client.UpdateTask(ctx, id, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated = u
|
||||
}
|
||||
|
||||
// Move the task between buckets after the field update.
|
||||
if bucketTransitionTarget != 0 {
|
||||
if err := rt.client.MoveTaskToBucket(ctx,
|
||||
rt.cfg.ProjectID, rt.cfg.ViewID, bucketTransitionTarget, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Label add/remove run after the field update so a status transition
|
||||
// can't clobber freshly-attached labels.
|
||||
for _, raw := range f.addLabels {
|
||||
title := normalizeLabelTitle(raw)
|
||||
l, err := getOrCreateLabelByTitle(ctx, rt.client, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rt.client.AddLabelToTask(ctx, id, l.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, raw := range f.removeLabels {
|
||||
title := normalizeLabelTitle(raw)
|
||||
if l := findLabelOnTask(updated, title); l != nil {
|
||||
if err := rt.client.RemoveLabelFromTask(ctx, id, l.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(f.addLabels) > 0 || len(f.removeLabels) > 0 || bucketTransitionTarget != 0 {
|
||||
fresh, err := rt.client.GetTask(ctx, id)
|
||||
if err == nil {
|
||||
updated = fresh
|
||||
}
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// composeDescription folds --description / --description-replace-* / --description-append
|
||||
// into the existing body. Returns (new, changed, error).
|
||||
func composeDescription(existing string, f *updateFlags) (string, bool, error) {
|
||||
if f.descriptionIsSet {
|
||||
// --description replaces wholesale.
|
||||
return f.description, true, nil
|
||||
}
|
||||
|
||||
out := existing
|
||||
changed := false
|
||||
|
||||
if f.replaceOld != "" || f.replaceNew != "" {
|
||||
if f.replaceOld == "" {
|
||||
return "", false, output.New(output.CodeValidation, "--description-replace-new requires --description-replace-old")
|
||||
}
|
||||
count := strings.Count(out, f.replaceOld)
|
||||
switch {
|
||||
case count == 0:
|
||||
return "", false, output.New(output.CodeValidation,
|
||||
"--description-replace-old not found in description")
|
||||
case count > 1:
|
||||
return "", false, output.New(output.CodeValidation,
|
||||
"--description-replace-old matched %d times — make it unique", count)
|
||||
}
|
||||
out = strings.Replace(out, f.replaceOld, f.replaceNew, 1)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if f.descriptionApp != "" {
|
||||
if out != "" && !strings.HasSuffix(out, "\n") {
|
||||
out += "\n"
|
||||
}
|
||||
out += f.descriptionApp
|
||||
changed = true
|
||||
}
|
||||
|
||||
return out, changed, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/client"
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
)
|
||||
|
||||
func TestComposeDescription_FullReplace(t *testing.T) {
|
||||
f := &updateFlags{description: "new body", descriptionIsSet: true}
|
||||
got, changed, err := composeDescription("old body", f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || got != "new body" {
|
||||
t.Fatalf("got %q changed=%v", got, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDescription_SurgicalReplace(t *testing.T) {
|
||||
f := &updateFlags{
|
||||
replaceOld: "TODO",
|
||||
replaceNew: "DONE",
|
||||
}
|
||||
got, changed, err := composeDescription("- [ ] TODO part 1\n- [ ] something else", f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || !strings.Contains(got, "DONE part 1") {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDescription_ReplaceNotUnique(t *testing.T) {
|
||||
f := &updateFlags{
|
||||
replaceOld: "x",
|
||||
replaceNew: "y",
|
||||
}
|
||||
if _, _, err := composeDescription("xxx", f); err == nil {
|
||||
t.Fatal("expected error on non-unique match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDescription_ReplaceNotFound(t *testing.T) {
|
||||
f := &updateFlags{
|
||||
replaceOld: "missing",
|
||||
replaceNew: "y",
|
||||
}
|
||||
if _, _, err := composeDescription("hello", f); err == nil {
|
||||
t.Fatal("expected error on no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDescription_Append(t *testing.T) {
|
||||
f := &updateFlags{descriptionApp: "## Notes"}
|
||||
got, changed, err := composeDescription("body", f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || got != "body\n## Notes" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDescription_AppendOnEmpty(t *testing.T) {
|
||||
f := &updateFlags{descriptionApp: "first line"}
|
||||
got, changed, err := composeDescription("", f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || got != "first line" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDescription_NoOp(t *testing.T) {
|
||||
f := &updateFlags{}
|
||||
got, changed, err := composeDescription("body", f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed || got != "body" {
|
||||
t.Fatalf("expected no-op, got %q changed=%v", got, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDescription_ReplaceNewWithoutOld(t *testing.T) {
|
||||
f := &updateFlags{replaceNew: "y"}
|
||||
if _, _, err := composeDescription("body", f); err == nil {
|
||||
t.Fatal("expected error: --description-replace-new without --description-replace-old")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLabelTitle(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"foo": "veans:foo",
|
||||
"veans:bar": "veans:bar",
|
||||
" baz ": "veans:baz",
|
||||
"veans:already-prefixed": "veans:already-prefixed",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeLabelTitle(in); got != want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordedCall captures one HTTP request made during a runUpdate invocation.
|
||||
// The fake server appends these in order; tests assert against the sequence.
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
}
|
||||
|
||||
// startRecordingServer spins up an httptest.Server that answers every
|
||||
// Vikunja endpoint runUpdate touches with the minimum payload needed to
|
||||
// keep the call chain alive, while appending each (method, path) to the
|
||||
// returned slice. The server intentionally does NOT validate request
|
||||
// bodies — the goal here is to pin call ORDER, not wire shape (which the
|
||||
// e2e suite already covers).
|
||||
func startRecordingServer(t *testing.T) (*httptest.Server, *[]recordedCall) {
|
||||
t.Helper()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
calls = append(calls, recordedCall{method: r.Method, path: r.URL.Path})
|
||||
mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/tasks/42":
|
||||
// Initial fetch + the final refetch both land here. Return a
|
||||
// fixed task with an empty label set — labels.go's
|
||||
// findLabelOnTask only iterates t.Labels.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 42, "title": "t", "updated": "2026-01-01T00:00:00Z",
|
||||
})
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/tasks/42/comments":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "comment": ""})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/tasks/42":
|
||||
// UpdateTask. Echo back the id so the encoder downstream is
|
||||
// happy with a non-nil Task.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42})
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/projects/") && strings.HasSuffix(r.URL.Path, "/tasks"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/labels":
|
||||
// getOrCreateLabelByTitle's lookup. Empty array → falls through
|
||||
// to label creation.
|
||||
_ = json.NewEncoder(w).Encode([]any{})
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/labels":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 99, "title": "veans:bug"})
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/tasks/42/labels":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 99})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
http.Error(w, "unexpected", http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &calls
|
||||
}
|
||||
|
||||
// newTestRuntime returns a *runtime suitable for runUpdate tests. The
|
||||
// bucket IDs match the order the canonical statuses appear in
|
||||
// status.BucketTitleAliases — Todo=10, InProgress=11, etc. — so test
|
||||
// assertions can name the moved-to bucket by id.
|
||||
func newTestRuntime(serverURL string) *runtime {
|
||||
return &runtime{
|
||||
cfg: &config.Config{
|
||||
Server: serverURL,
|
||||
ProjectID: 7,
|
||||
ViewID: 1,
|
||||
Buckets: config.Buckets{
|
||||
Todo: 10, InProgress: 11, InReview: 12, Done: 13, Scrapped: 14,
|
||||
},
|
||||
},
|
||||
client: client.New(serverURL, "tk"),
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunUpdate_ScrappedOrdersCommentUpdateMove pins the audit-trail
|
||||
// ordering invariant from CLAUDE.md ("Comments for `--status scrapped`
|
||||
// post BEFORE the bucket move so the audit trail reads in chronological
|
||||
// order"). A refactor that hoists the bucket move ahead of the comment
|
||||
// would silently swap the timeline; this test fails if that happens.
|
||||
func TestRunUpdate_ScrappedOrdersCommentUpdateMove(t *testing.T) {
|
||||
srv, calls := startRecordingServer(t)
|
||||
rt := newTestRuntime(srv.URL)
|
||||
|
||||
if _, err := runUpdate(context.Background(), rt, 42, &updateFlags{
|
||||
statusName: "scrapped",
|
||||
reason: "obsolete",
|
||||
}); err != nil {
|
||||
t.Fatalf("runUpdate: %v", err)
|
||||
}
|
||||
|
||||
want := []recordedCall{
|
||||
{http.MethodGet, "/api/v1/tasks/42"}, // current task fetch
|
||||
{http.MethodPut, "/api/v1/tasks/42/comments"}, // "Scrapped: obsolete"
|
||||
{http.MethodPost, "/api/v1/tasks/42"}, // field update (done=true)
|
||||
{http.MethodPost, "/api/v1/projects/7/views/1/buckets/14/tasks"}, // bucket move to Scrapped
|
||||
{http.MethodGet, "/api/v1/tasks/42"}, // refetch with new bucket
|
||||
}
|
||||
if !reflect.DeepEqual(*calls, want) {
|
||||
t.Fatalf("call order mismatch:\nwant: %#v\ngot: %#v", want, *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunUpdate_BucketMoveBeforeLabelAdd pins the second ordering
|
||||
// invariant from CLAUDE.md ("MoveTaskToBucket runs AFTER the field
|
||||
// update so a status transition can't clobber freshly attached labels").
|
||||
// Equivalently — and what this test asserts — labels are attached AFTER
|
||||
// the bucket move, so the post-move state is the one we then refetch.
|
||||
// A refactor that consolidates "all bucket-related work" by moving the
|
||||
// labels-add loop ahead of MoveTaskToBucket would compile and silently
|
||||
// regress label visibility; this test catches that.
|
||||
func TestRunUpdate_BucketMoveBeforeLabelAdd(t *testing.T) {
|
||||
srv, calls := startRecordingServer(t)
|
||||
rt := newTestRuntime(srv.URL)
|
||||
|
||||
if _, err := runUpdate(context.Background(), rt, 42, &updateFlags{
|
||||
statusName: "in-progress",
|
||||
addLabels: []string{"bug"},
|
||||
}); err != nil {
|
||||
t.Fatalf("runUpdate: %v", err)
|
||||
}
|
||||
|
||||
want := []recordedCall{
|
||||
{http.MethodGet, "/api/v1/tasks/42"}, // current task fetch
|
||||
{http.MethodPost, "/api/v1/tasks/42"}, // field update (done=false)
|
||||
{http.MethodPost, "/api/v1/projects/7/views/1/buckets/11/tasks"}, // bucket move to In Progress
|
||||
{http.MethodGet, "/api/v1/labels"}, // getOrCreateLabelByTitle lookup
|
||||
{http.MethodPut, "/api/v1/labels"}, // create veans:bug
|
||||
{http.MethodPut, "/api/v1/tasks/42/labels"}, // attach label
|
||||
{http.MethodGet, "/api/v1/tasks/42"}, // refetch
|
||||
}
|
||||
if !reflect.DeepEqual(*calls, want) {
|
||||
t.Fatalf("call order mismatch:\nwant: %#v\ngot: %#v", want, *calls)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
// 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 config reads and writes the per-repo .veans.yml file. The schema
|
||||
// pins the project, view, canonical buckets, and bot identity so subsequent
|
||||
// veans calls have everything they need without round-tripping to the server.
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// Filename is the canonical config name. Walked upward from cwd by Find.
|
||||
const Filename = ".veans.yml"
|
||||
|
||||
// Config is the on-disk shape of .veans.yml.
|
||||
type Config struct {
|
||||
Server string `yaml:"server"`
|
||||
ProjectID int64 `yaml:"project_id"`
|
||||
ProjectIdentifier string `yaml:"project_identifier,omitempty"`
|
||||
ViewID int64 `yaml:"view_id"`
|
||||
Buckets Buckets `yaml:"buckets"`
|
||||
Bot Bot `yaml:"bot"`
|
||||
|
||||
// HTTPTimeout overrides the default 30s HTTP client timeout when
|
||||
// non-zero. Accepts Go duration syntax in YAML ("60s", "5m", "1h30m").
|
||||
// Omitted from a freshly-written .veans.yml via omitempty so init flows
|
||||
// don't surface this knob unless the operator opts in.
|
||||
HTTPTimeout time.Duration `yaml:"http_timeout,omitempty"`
|
||||
|
||||
path string `yaml:"-"`
|
||||
}
|
||||
|
||||
// Buckets maps the five canonical statuses to bucket IDs.
|
||||
type Buckets struct {
|
||||
Todo int64 `yaml:"todo"`
|
||||
InProgress int64 `yaml:"in_progress"`
|
||||
InReview int64 `yaml:"in_review"`
|
||||
Done int64 `yaml:"done"`
|
||||
Scrapped int64 `yaml:"scrapped"`
|
||||
}
|
||||
|
||||
// Bot identifies the Vikunja bot user veans operates as.
|
||||
type Bot struct {
|
||||
Username string `yaml:"username"`
|
||||
UserID int64 `yaml:"user_id"`
|
||||
}
|
||||
|
||||
// Path returns the absolute path the config was loaded from (or written to).
|
||||
func (c *Config) Path() string { return c.path }
|
||||
|
||||
// FormatTaskID renders a numeric task index in the project's preferred form:
|
||||
// PROJ-NN if the project has an identifier, #NN otherwise.
|
||||
func (c *Config) FormatTaskID(index int64) string {
|
||||
if c.ProjectIdentifier != "" {
|
||||
return fmt.Sprintf("%s-%d", c.ProjectIdentifier, index)
|
||||
}
|
||||
return fmt.Sprintf("#%d", index)
|
||||
}
|
||||
|
||||
// Find walks upward from cwd looking for .veans.yml. Returns ErrNotFound if
|
||||
// none is reachable.
|
||||
func Find(start string) (string, error) {
|
||||
if start == "" {
|
||||
var err error
|
||||
start, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
dir := start
|
||||
for {
|
||||
candidate := filepath.Join(dir, Filename)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// ErrNotFound is returned by Find when no .veans.yml is reachable.
|
||||
var ErrNotFound = errors.New(".veans.yml not found in any parent directory")
|
||||
|
||||
// Load reads .veans.yml from `path`. Use Find to locate it first.
|
||||
func Load(path string) (*Config, error) {
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, output.Wrap(output.CodeNotConfigured, err, "no .veans.yml at %s", path)
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(buf, &c); err != nil {
|
||||
return nil, output.Wrap(output.CodeValidation, err, "parse %s: %v", path, err)
|
||||
}
|
||||
c.path = path
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// SaveAs writes the config to `path` and remembers it as c.path.
|
||||
func (c *Config) SaveAs(path string) error {
|
||||
c.path = path
|
||||
buf, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, buf, 0o644)
|
||||
}
|
||||
|
||||
// RepoRoot returns the root of the git repo containing `start` (defaulting
|
||||
// to cwd). When `start` is not in a git repo, RepoRoot returns the absolute
|
||||
// `start` so callers can still derive a sensible bot username.
|
||||
func RepoRoot(ctx context.Context, start string) (string, error) {
|
||||
if start == "" {
|
||||
var err error
|
||||
start, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
|
||||
cmd.Dir = start
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
abs, _ := filepath.Abs(start)
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// SuggestedBotUsername proposes `bot-<reponame>` from a repo root path.
|
||||
// Vikunja's username validator allows lowercase, digits, hyphens — we fold
|
||||
// the basename to a safe shape.
|
||||
func SuggestedBotUsername(root string) string {
|
||||
base := filepath.Base(root)
|
||||
var b strings.Builder
|
||||
b.WriteString("bot-")
|
||||
for _, r := range strings.ToLower(base) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == ' ' || r == '.':
|
||||
b.WriteRune('-')
|
||||
default:
|
||||
// drop other characters silently
|
||||
}
|
||||
}
|
||||
// Collapse runs of hyphens.
|
||||
out := b.String()
|
||||
for strings.Contains(out, "--") {
|
||||
out = strings.ReplaceAll(out, "--", "-")
|
||||
}
|
||||
return strings.TrimRight(out, "-")
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// 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 config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSuggestedBotUsername(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/home/user/myrepo": "bot-myrepo",
|
||||
"/tmp/My Project": "bot-my-project",
|
||||
"/x/Hello_World": "bot-hello-world",
|
||||
"/x/CRAZY---Name!!": "bot-crazy-name",
|
||||
"/x/.dotted": "bot-dotted",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := SuggestedBotUsername(in); got != want {
|
||||
t.Errorf("%s: got %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTaskID(t *testing.T) {
|
||||
withIdent := &Config{ProjectIdentifier: "PROJ"}
|
||||
if got := withIdent.FormatTaskID(7); got != "PROJ-7" {
|
||||
t.Errorf("got %q want PROJ-7", got)
|
||||
}
|
||||
noIdent := &Config{}
|
||||
if got := noIdent.FormatTaskID(7); got != "#7" {
|
||||
t.Errorf("got %q want #7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAndLoadRoundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
deeper := filepath.Join(dir, "a", "b", "c")
|
||||
if err := os.MkdirAll(deeper, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &Config{
|
||||
Server: "https://example.com",
|
||||
ProjectID: 42,
|
||||
ProjectIdentifier: "PROJ",
|
||||
ViewID: 7,
|
||||
Buckets: Buckets{Todo: 1, InProgress: 2, InReview: 3, Done: 4, Scrapped: 5},
|
||||
Bot: Bot{Username: "bot-test", UserID: 99},
|
||||
}
|
||||
if err := cfg.SaveAs(filepath.Join(dir, Filename)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Find from the deeper directory should walk up.
|
||||
found, err := Find(deeper)
|
||||
if err != nil {
|
||||
t.Fatalf("Find: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(found, Filename) {
|
||||
t.Fatalf("found path %q does not end in %s", found, Filename)
|
||||
}
|
||||
loaded, err := Load(found)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded.ProjectID != 42 || loaded.Bot.Username != "bot-test" {
|
||||
t.Fatalf("unexpected reload shape: %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Find(dir); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// 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 credentials
|
||||
|
||||
import "os"
|
||||
|
||||
// EnvBackend is read-only. VEANS_TOKEN, when set, satisfies any
|
||||
// (server, account) lookup — intended for CI / containers where the
|
||||
// keychain is unavailable and writing a credentials file is undesirable.
|
||||
type EnvBackend struct{}
|
||||
|
||||
func NewEnvBackend() *EnvBackend { return &EnvBackend{} }
|
||||
func (*EnvBackend) Name() string { return "env" }
|
||||
|
||||
func (*EnvBackend) Get(_, _ string) (string, error) {
|
||||
tok := os.Getenv("VEANS_TOKEN")
|
||||
if tok == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (*EnvBackend) Set(_, _, _ string) error { return errReadOnly }
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
// 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 credentials
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
Server string `yaml:"server"`
|
||||
Account string `yaml:"account"`
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
type fileSchema struct {
|
||||
Credentials []fileEntry `yaml:"credentials"`
|
||||
}
|
||||
|
||||
// NewFileBackend builds a FileBackend rooted at `path`, or
|
||||
// ~/.config/veans/credentials.yml when path is "".
|
||||
func NewFileBackend(path string) *FileBackend {
|
||||
if path == "" {
|
||||
path = defaultCredsPath()
|
||||
}
|
||||
return &FileBackend{path: path}
|
||||
}
|
||||
|
||||
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 {
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil || h == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(h, ".config", "veans", "credentials.yml")
|
||||
}
|
||||
|
||||
func (b *FileBackend) load() (*fileSchema, error) {
|
||||
buf, err := os.ReadFile(b.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return &fileSchema{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var s fileSchema
|
||||
if err := yaml.Unmarshal(buf, &s); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", b.path, err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
buf, err := yaml.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
for _, e := range s.Credentials {
|
||||
if e.Server == server && e.Account == account {
|
||||
return e.Token, nil
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
for i, e := range s.Credentials {
|
||||
if e.Server == server && e.Account == account {
|
||||
s.Credentials[i].Token = token
|
||||
return b.save(s)
|
||||
}
|
||||
}
|
||||
s.Credentials = append(s.Credentials, fileEntry{
|
||||
Server: server,
|
||||
Account: account,
|
||||
Token: token,
|
||||
})
|
||||
return b.save(s)
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
// 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 credentials
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileBackend_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "credentials.yml")
|
||||
b := NewFileBackend(path)
|
||||
|
||||
if _, err := b.Get("https://example.com", "bot-foo"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
if err := b.Set("https://example.com", "bot-foo", "tok-123"); err != nil {
|
||||
t.Fatalf("Set failed: %v", err)
|
||||
}
|
||||
|
||||
tok, err := b.Get("https://example.com", "bot-foo")
|
||||
if err != nil {
|
||||
t.Fatalf("Get after Set: %v", err)
|
||||
}
|
||||
if tok != "tok-123" {
|
||||
t.Fatalf("got %q, want tok-123", tok)
|
||||
}
|
||||
|
||||
// Update in place.
|
||||
if err := b.Set("https://example.com", "bot-foo", "tok-456"); err != nil {
|
||||
t.Fatalf("Set update: %v", err)
|
||||
}
|
||||
tok, _ = b.Get("https://example.com", "bot-foo")
|
||||
if tok != "tok-456" {
|
||||
t.Fatalf("update lost: got %q", tok)
|
||||
}
|
||||
|
||||
// Different account — separate row.
|
||||
if err := b.Set("https://example.com", "bot-bar", "tok-789"); err != nil {
|
||||
t.Fatalf("Set bar: %v", err)
|
||||
}
|
||||
tokBar, _ := b.Get("https://example.com", "bot-bar")
|
||||
if tokBar != "tok-789" {
|
||||
t.Fatalf("bar got %q", tokBar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChain_FallsThroughOnNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := NewFileBackend(filepath.Join(dir, "credentials.yml"))
|
||||
stub := &stubBackend{store: map[string]string{}}
|
||||
c := &Chain{Backends: []Store{stub, file}}
|
||||
|
||||
// First backend has nothing; second is empty too.
|
||||
if _, err := c.Get("s", "a"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// Set should write to the first writable backend (stub here).
|
||||
if err := c.Set("s", "a", "tok"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if stub.store["s::a"] != "tok" {
|
||||
t.Fatalf("expected stub to receive write")
|
||||
}
|
||||
|
||||
// Get should now find it via stub.
|
||||
if got, _ := c.Get("s", "a"); got != "tok" {
|
||||
t.Fatalf("got %q want tok", got)
|
||||
}
|
||||
}
|
||||
|
||||
type stubBackend struct {
|
||||
store map[string]string
|
||||
}
|
||||
|
||||
func (s *stubBackend) Name() string { return "stub" }
|
||||
func (s *stubBackend) Get(server, account string) (string, error) {
|
||||
if v, ok := s.store[server+"::"+account]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
func (s *stubBackend) Set(server, account, token string) error {
|
||||
s.store[server+"::"+account] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
// failingBackend always errors on Set with a non-readonly, non-NotFound error,
|
||||
// simulating e.g. a keyring with no dbus available. Get always reports
|
||||
// ErrNotFound so the chain's Get path stays uninteresting for these tests.
|
||||
type failingBackend struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *failingBackend) Name() string { return f.name }
|
||||
func (f *failingBackend) Get(_, _ string) (string, error) { return "", ErrNotFound }
|
||||
func (f *failingBackend) Set(_, _, _ string) error { return f.err }
|
||||
|
||||
// TestFileBackend_SetReassertsMode covers the os.Chmod(path, 0o600) at the
|
||||
// end of save: a pre-existing file at a wider mode must be narrowed.
|
||||
func TestFileBackend_SetReassertsMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "credentials.yml")
|
||||
// Pre-create the file at 0o644 so Rename onto the existing inode can
|
||||
// (on some filesystems) preserve the wider mode.
|
||||
if err := os.WriteFile(path, []byte("credentials: []\n"), 0o644); err != nil { //nolint:gosec // test fixture: intentionally wider than 0600
|
||||
t.Fatalf("seed file: %v", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Fatalf("chmod seed: %v", err)
|
||||
}
|
||||
|
||||
b := NewFileBackend(path)
|
||||
if err := b.Set("https://example.com", "bot-foo", "tok-123"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("mode after Set: got %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileBackend_SetCleansUpTmpFile asserts that the atomic-write tmp file
|
||||
// (.credentials-*.tmp) is renamed away — no stray tmp should remain after a
|
||||
// successful Set.
|
||||
func TestFileBackend_SetCleansUpTmpFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "credentials.yml")
|
||||
b := NewFileBackend(path)
|
||||
|
||||
if err := b.Set("https://example.com", "bot-foo", "tok-123"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".credentials-") && strings.HasSuffix(name, ".tmp") {
|
||||
t.Fatalf("leftover tmp file after Set: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileBackend_ConcurrentWritersSerialize fans two goroutines into Set
|
||||
// with different (server, account) keys. The flock should serialize the
|
||||
// load → mutate → save sequence so both entries are persisted, even
|
||||
// though either could otherwise stomp on the other's load snapshot.
|
||||
func TestFileBackend_ConcurrentWritersSerialize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "credentials.yml")
|
||||
b := NewFileBackend(path)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := b.Set("https://a.example.com", "bot-a", "tok-a"); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := b.Set("https://b.example.com", "bot-b", "tok-b"); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Fatalf("concurrent Set: %v", err)
|
||||
}
|
||||
|
||||
gotA, err := b.Get("https://a.example.com", "bot-a")
|
||||
if err != nil || gotA != "tok-a" {
|
||||
t.Fatalf("a: got %q err=%v, want tok-a", gotA, err)
|
||||
}
|
||||
gotB, err := b.Get("https://b.example.com", "bot-b")
|
||||
if err != nil || gotB != "tok-b" {
|
||||
t.Fatalf("b: got %q err=%v, want tok-b", gotB, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_SetWarnsOnFallback asserts that when an earlier writable backend
|
||||
// errors and a later one succeeds, the chain writes a one-line warning to
|
||||
// ChainStderr naming both backends. Set itself still returns nil because
|
||||
// the write landed durably on the later backend.
|
||||
func TestChain_SetWarnsOnFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := NewFileBackend(filepath.Join(dir, "credentials.yml"))
|
||||
failing := &failingBackend{name: "keyring-stub", err: errors.New("dbus unavailable")}
|
||||
|
||||
var buf bytes.Buffer
|
||||
origStderr := ChainStderr
|
||||
ChainStderr = &buf
|
||||
t.Cleanup(func() { ChainStderr = origStderr })
|
||||
|
||||
c := &Chain{Backends: []Store{failing, file}}
|
||||
if err := c.Set("https://example.com", "bot-foo", "tok-xyz"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
// The token must have landed in the file backend.
|
||||
if got, err := file.Get("https://example.com", "bot-foo"); err != nil || got != "tok-xyz" {
|
||||
t.Fatalf("file Get: got %q err=%v, want tok-xyz", got, err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if out == "" {
|
||||
t.Fatalf("expected warning on ChainStderr, got nothing")
|
||||
}
|
||||
if !strings.Contains(out, failing.Name()) {
|
||||
t.Fatalf("warning missing failing backend name %q: %s", failing.Name(), out)
|
||||
}
|
||||
if !strings.Contains(out, file.Name()) {
|
||||
t.Fatalf("warning missing fallback backend name %q: %s", file.Name(), out)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// 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 credentials
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// service is the keyring service name. Per-host accounts are encoded as
|
||||
// `<server>::<account>` since OS keychains key on (service, account) pairs.
|
||||
const service = "veans"
|
||||
|
||||
// KeyringBackend persists tokens in the OS keychain (macOS Keychain,
|
||||
// Windows Credential Manager, libsecret on Linux). On systems without a
|
||||
// usable keychain (e.g. headless CI containers), Get/Set return errors that
|
||||
// the chain treats as NotFound, allowing the file backend to take over.
|
||||
type KeyringBackend struct{}
|
||||
|
||||
func NewKeyringBackend() *KeyringBackend { return &KeyringBackend{} }
|
||||
func (*KeyringBackend) Name() string { return "keyring" }
|
||||
|
||||
func (*KeyringBackend) Get(server, account string) (string, error) {
|
||||
tok, err := keyring.Get(service, key(server, account))
|
||||
if err != nil {
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
// Treat any keyring backend error (no daemon, etc) as NotFound so
|
||||
// the chain falls through to the file backend transparently.
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (*KeyringBackend) Set(server, account, token string) error {
|
||||
if err := keyring.Set(service, key(server, account), token); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func key(server, account string) string {
|
||||
return server + "::" + account
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
// 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 credentials handles bot-token storage with a keychain → env → file
|
||||
// fallback chain. The store is keyed by (server, account); `account` is the
|
||||
// bot's username — the human's token is never persisted.
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ChainStderr is the writer the Chain uses for operator-visible warnings
|
||||
// (currently: backend-fallthrough notices on Set). Tests override it; in
|
||||
// production it points at os.Stderr.
|
||||
var ChainStderr io.Writer = os.Stderr
|
||||
|
||||
// ErrNotFound is returned when no backend has the requested credential.
|
||||
var ErrNotFound = errors.New("credential not found")
|
||||
|
||||
// Store is the read/write contract every backend implements.
|
||||
type Store interface {
|
||||
Get(server, account string) (string, error)
|
||||
Set(server, account, token string) error
|
||||
// Name is used in error messages.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Chain queries each backend in order on Get; writes go to the first writable
|
||||
// backend. Env (read-only) is skipped on writes. The order is keychain →
|
||||
// env → file, matching the plan.
|
||||
type Chain struct {
|
||||
Backends []Store
|
||||
}
|
||||
|
||||
func (c *Chain) Name() string { return "chain" }
|
||||
|
||||
// Get returns the first non-NotFound result from any backend.
|
||||
func (c *Chain) Get(server, account string) (string, error) {
|
||||
var lastErr error
|
||||
for _, b := range c.Backends {
|
||||
tok, err := b.Get(server, account)
|
||||
if err == nil {
|
||||
return tok, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
lastErr = fmt.Errorf("%s: %w", b.Name(), err)
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// Set writes to the first backend that accepts a write. Env is read-only.
|
||||
// Backends that error out (e.g. keyring on a host with no dbus) are skipped
|
||||
// transparently, falling through to the next — the file backend is the
|
||||
// reliable last-resort. Only if every writable backend fails do we surface
|
||||
// the last error.
|
||||
//
|
||||
// When a write fails on one writable backend and a later one succeeds, a
|
||||
// single-line warning is printed to ChainStderr naming both backends.
|
||||
// This is observability for the silent-shadow case: a stale keyring entry
|
||||
// from a prior successful write can mask the freshly-written file token if
|
||||
// keyring transiently rejects the new Set. The warning gives the operator
|
||||
// a breadcrumb; Set itself still returns nil because the write landed
|
||||
// somewhere durable.
|
||||
func (c *Chain) Set(server, account, token string) error {
|
||||
var (
|
||||
lastErr error
|
||||
failedName string
|
||||
failedErr error
|
||||
)
|
||||
for _, b := range c.Backends {
|
||||
if _, ok := b.(*EnvBackend); ok {
|
||||
continue
|
||||
}
|
||||
err := b.Set(server, account, token)
|
||||
if err == nil {
|
||||
if failedName != "" {
|
||||
fmt.Fprintf(ChainStderr,
|
||||
"veans: credential store: %s rejected write (%v); falling back to %s\n",
|
||||
failedName, failedErr, b.Name())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, errReadOnly) {
|
||||
continue
|
||||
}
|
||||
// Remember the most recent non-readonly failure so a later success
|
||||
// can surface it, or so we can return it if every backend fails.
|
||||
failedName = b.Name()
|
||||
failedErr = err
|
||||
lastErr = fmt.Errorf("%s: %w", b.Name(), err)
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("no writable backend available")
|
||||
}
|
||||
|
||||
// errReadOnly is sentinel for backends that refuse writes (env).
|
||||
var errReadOnly = errors.New("read-only backend")
|
||||
|
||||
// Default builds the standard keychain → env → file chain.
|
||||
func Default() *Chain {
|
||||
return &Chain{
|
||||
Backends: []Store{
|
||||
NewKeyringBackend(),
|
||||
NewEnvBackend(),
|
||||
NewFileBackend(""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// 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 output defines stable error codes and the JSON envelope
|
||||
// veans uses for non-zero exits.
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Code string
|
||||
|
||||
const (
|
||||
CodeNotFound Code = "NOT_FOUND"
|
||||
CodeConflict Code = "CONFLICT"
|
||||
CodeValidation Code = "VALIDATION_ERROR"
|
||||
CodeAuth Code = "AUTH_ERROR"
|
||||
CodeRateLimited Code = "RATE_LIMITED"
|
||||
CodeBotUsersUnavailable Code = "BOT_USERS_UNAVAILABLE"
|
||||
CodeNotConfigured Code = "NOT_CONFIGURED"
|
||||
CodeUnknown Code = "UNKNOWN"
|
||||
)
|
||||
|
||||
// Error is the structured error type used for both internal flow and the
|
||||
// `--json` mutation envelope. Callers wrap underlying errors with codes via
|
||||
// Wrap; the cobra runner converts unmapped errors to CodeUnknown.
|
||||
type Error struct {
|
||||
Code Code `json:"code"`
|
||||
Message string `json:"error"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Message }
|
||||
func (e *Error) Unwrap() error { return e.Cause }
|
||||
|
||||
func New(code Code, format string, args ...any) *Error {
|
||||
return &Error{Code: code, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func Wrap(code Code, cause error, format string, args ...any) *Error {
|
||||
return &Error{Code: code, Message: fmt.Sprintf(format, args...), Cause: cause}
|
||||
}
|
||||
|
||||
// AsError extracts an *Error from any error chain, returning a CodeUnknown
|
||||
// wrapper for plain errors.
|
||||
func AsError(err error) *Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
return &Error{Code: CodeUnknown, Message: err.Error(), Cause: err}
|
||||
}
|
||||
|
||||
// EmitError encodes the error as a JSON envelope `{code, error}` to w
|
||||
// (default stderr). All veans commands share this shape so callers can
|
||||
// branch on `code` without sniffing the output format.
|
||||
func EmitError(err error, w io.Writer) {
|
||||
if w == nil {
|
||||
w = os.Stderr
|
||||
}
|
||||
e := AsError(err)
|
||||
if encErr := json.NewEncoder(w).Encode(e); encErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "{\"code\":%q,\"error\":%q}\n", string(CodeUnknown), "failed to encode error envelope: "+encErr.Error())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
// 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 output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAsError_Nil(t *testing.T) {
|
||||
if got := AsError(nil); got != nil {
|
||||
t.Fatalf("AsError(nil) = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsError_PreservesKnownCode(t *testing.T) {
|
||||
orig := New(CodeValidation, "bad input")
|
||||
got := AsError(orig)
|
||||
if got != orig {
|
||||
t.Fatalf("AsError returned a different pointer; want the original *Error to be preserved via errors.As")
|
||||
}
|
||||
if got.Code != CodeValidation {
|
||||
t.Fatalf("Code = %q, want %q", got.Code, CodeValidation)
|
||||
}
|
||||
if got.Message != "bad input" {
|
||||
t.Fatalf("Message = %q, want %q", got.Message, "bad input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsError_UnwrapsThroughFmtErrorf(t *testing.T) {
|
||||
inner := New(CodeNotFound, "missing")
|
||||
wrapped := fmt.Errorf("context: %w", inner)
|
||||
got := AsError(wrapped)
|
||||
if got != inner {
|
||||
t.Fatalf("AsError did not return the inner *Error through fmt.Errorf wrapping")
|
||||
}
|
||||
if got.Code != CodeNotFound {
|
||||
t.Fatalf("Code = %q, want %q", got.Code, CodeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsError_PlainErrorBecomesUnknown(t *testing.T) {
|
||||
plain := errors.New("kaboom")
|
||||
got := AsError(plain)
|
||||
if got == nil {
|
||||
t.Fatal("AsError(plain) = nil, want a CodeUnknown wrapper")
|
||||
}
|
||||
if got.Code != CodeUnknown {
|
||||
t.Fatalf("Code = %q, want %q", got.Code, CodeUnknown)
|
||||
}
|
||||
if got.Message != "kaboom" {
|
||||
t.Fatalf("Message = %q, want %q", got.Message, "kaboom")
|
||||
}
|
||||
if !errors.Is(got, plain) {
|
||||
t.Fatal("CodeUnknown wrapper does not preserve the original cause via Unwrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitError_EnvelopeShape(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
EmitError(&Error{Code: CodeValidation, Message: "x"}, &buf)
|
||||
|
||||
out := buf.Bytes()
|
||||
// json.Encoder.Encode appends a trailing newline; assert and tolerate it.
|
||||
if len(out) == 0 || out[len(out)-1] != '\n' {
|
||||
t.Fatalf("expected trailing newline from json.Encoder.Encode, got %q", string(out))
|
||||
}
|
||||
|
||||
// Decode into a generic map first to assert the exact key set.
|
||||
var asAny map[string]any
|
||||
if err := json.Unmarshal(out, &asAny); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v (%q)", err, string(out))
|
||||
}
|
||||
if len(asAny) != 2 {
|
||||
t.Fatalf("envelope has %d keys, want exactly 2 (code, error); got %v", len(asAny), asAny)
|
||||
}
|
||||
if _, ok := asAny["code"]; !ok {
|
||||
t.Fatalf("envelope missing %q key; got %v", "code", asAny)
|
||||
}
|
||||
if _, ok := asAny["error"]; !ok {
|
||||
t.Fatalf("envelope missing %q key; got %v", "error", asAny)
|
||||
}
|
||||
|
||||
// And confirm both fields decode as strings with the expected values.
|
||||
var asStrings map[string]string
|
||||
if err := json.Unmarshal(out, &asStrings); err != nil {
|
||||
t.Fatalf("envelope fields are not all strings: %v (%q)", err, string(out))
|
||||
}
|
||||
if asStrings["code"] != string(CodeValidation) {
|
||||
t.Fatalf("code = %q, want %q", asStrings["code"], string(CodeValidation))
|
||||
}
|
||||
if asStrings["error"] != "x" {
|
||||
t.Fatalf("error = %q, want %q", asStrings["error"], "x")
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: EmitError's fallback path (the fmt.Fprintf to os.Stderr when
|
||||
// json.Encoder.Encode fails) is intentionally not unit-tested. The encoded
|
||||
// value is an *Error with two string fields and no custom MarshalJSON, so
|
||||
// json.Marshal cannot fail on it from outside the package — there is no
|
||||
// stdlib-reachable input that trips the encoder. The fallback's contract
|
||||
// ("preserve the {code,error} envelope shape even on encode failure") is
|
||||
// covered by inspection of the source: the format string emits the same two
|
||||
// keys with CodeUnknown and a descriptive message.
|
||||
|
||||
func TestWrap_PreservesCauseForErrorsIs(t *testing.T) {
|
||||
sentinel := errors.New("sentinel cause")
|
||||
wrapped := Wrap(CodeConflict, sentinel, "while doing thing %d", 42)
|
||||
|
||||
if !errors.Is(wrapped, sentinel) {
|
||||
t.Fatal("errors.Is(Wrap(...), sentinel) = false, want true; Wrap must preserve the cause through Unwrap")
|
||||
}
|
||||
|
||||
// errors.As against the sentinel's concrete type should also walk the
|
||||
// chain; use a custom type to make this meaningful.
|
||||
custom := &causeType{msg: "custom"}
|
||||
wrapped2 := Wrap(CodeConflict, custom, "wrap")
|
||||
var target *causeType
|
||||
if !errors.As(wrapped2, &target) {
|
||||
t.Fatal("errors.As did not find the wrapped cause through the *Error chain")
|
||||
}
|
||||
if target != custom {
|
||||
t.Fatalf("errors.As returned a different pointer than the original cause")
|
||||
}
|
||||
|
||||
// And the wrapped *Error itself still carries the supplied code and
|
||||
// formatted message.
|
||||
if wrapped.Code != CodeConflict {
|
||||
t.Fatalf("Code = %q, want %q", wrapped.Code, CodeConflict)
|
||||
}
|
||||
if wrapped.Message != "while doing thing 42" {
|
||||
t.Fatalf("Message = %q, want %q", wrapped.Message, "while doing thing 42")
|
||||
}
|
||||
}
|
||||
|
||||
type causeType struct{ msg string }
|
||||
|
||||
func (c *causeType) Error() string { return c.msg }
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
// 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 status maps the five canonical veans statuses to Vikunja bucket
|
||||
// IDs and the `done` flag. The mapping is canonical and reflected verbatim
|
||||
// in the agent prompt (see internal/commands/prompt.tmpl).
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
"code.vikunja.io/veans/internal/output"
|
||||
)
|
||||
|
||||
// Status is the agent-facing state name.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
Todo Status = "todo"
|
||||
InProgress Status = "in-progress"
|
||||
InReview Status = "in-review"
|
||||
Completed Status = "completed"
|
||||
Scrapped Status = "scrapped"
|
||||
)
|
||||
|
||||
// All returns the canonical statuses in display order.
|
||||
func All() []Status {
|
||||
return []Status{Todo, InProgress, InReview, Completed, Scrapped}
|
||||
}
|
||||
|
||||
// BucketTitleAliases lists titles that count as the canonical bucket for
|
||||
// each status. Vikunja's default Kanban view ships with "To-Do", "Doing"
|
||||
// and "Done" buckets — we accept those so a vanilla project doesn't grow
|
||||
// parallel buckets when veans init runs against it. The first entry is
|
||||
// the canonical title returned by BucketTitle().
|
||||
var BucketTitleAliases = map[Status][]string{
|
||||
Todo: {"Todo", "To-Do", "ToDo", "To Do", "To do", "Backlog"},
|
||||
InProgress: {"In Progress", "In-Progress", "Doing", "WIP", "In progress"},
|
||||
InReview: {"In Review", "In-Review", "Review", "In review"},
|
||||
Completed: {"Done", "Completed", "Complete"},
|
||||
Scrapped: {"Scrapped", "Cancelled", "Canceled", "Won't Do", "Wontfix"},
|
||||
}
|
||||
|
||||
// MatchBucketTitle reports whether `title` matches `s` either as the
|
||||
// canonical title or one of its aliases. Comparison is case-insensitive
|
||||
// and tolerant of stray whitespace.
|
||||
func MatchBucketTitle(s Status, title string) bool {
|
||||
want := normalizeBucketTitle(title)
|
||||
for _, alias := range BucketTitleAliases[s] {
|
||||
if normalizeBucketTitle(alias) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeBucketTitle(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// BucketTitle returns the bucket name that backs each status. The canonical
|
||||
// title is the first entry in BucketTitleAliases — single source of truth.
|
||||
func (s Status) BucketTitle() string {
|
||||
if aliases, ok := BucketTitleAliases[s]; ok && len(aliases) > 0 {
|
||||
return aliases[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Done reports whether tasks in this status should have done=true.
|
||||
func (s Status) Done() bool {
|
||||
return s == Completed || s == Scrapped
|
||||
}
|
||||
|
||||
// Parse normalizes user input. Accepts the canonical hyphenated form, plus
|
||||
// underscored/snake variants and a couple of natural-language synonyms.
|
||||
func Parse(raw string) (Status, error) {
|
||||
n := strings.TrimSpace(strings.ToLower(raw))
|
||||
n = strings.ReplaceAll(n, "_", "-")
|
||||
n = strings.ReplaceAll(n, " ", "-")
|
||||
switch n {
|
||||
case "todo":
|
||||
return Todo, nil
|
||||
case "in-progress", "wip", "doing":
|
||||
return InProgress, nil
|
||||
case "in-review", "review":
|
||||
return InReview, nil
|
||||
case "completed", "done":
|
||||
return Completed, nil
|
||||
case "scrapped", "cancelled", "canceled":
|
||||
return Scrapped, nil
|
||||
}
|
||||
return "", output.New(output.CodeValidation,
|
||||
"unknown status %q (expected one of: %s)",
|
||||
raw, strings.Join(allStrings(), ", "))
|
||||
}
|
||||
|
||||
// BucketID resolves a status to the bucket ID stored in .veans.yml.
|
||||
func BucketID(s Status, b config.Buckets) (int64, error) {
|
||||
switch s {
|
||||
case Todo:
|
||||
return b.Todo, nil
|
||||
case InProgress:
|
||||
return b.InProgress, nil
|
||||
case InReview:
|
||||
return b.InReview, nil
|
||||
case Completed:
|
||||
return b.Done, nil
|
||||
case Scrapped:
|
||||
return b.Scrapped, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown status %q", s)
|
||||
}
|
||||
|
||||
// FromBucketID is the inverse of BucketID — used by `list` to render the
|
||||
// status of a task fetched from the API.
|
||||
func FromBucketID(id int64, b config.Buckets) Status {
|
||||
switch id {
|
||||
case b.Todo:
|
||||
return Todo
|
||||
case b.InProgress:
|
||||
return InProgress
|
||||
case b.InReview:
|
||||
return InReview
|
||||
case b.Done:
|
||||
return Completed
|
||||
case b.Scrapped:
|
||||
return Scrapped
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func allStrings() []string {
|
||||
out := make([]string, 0, 5)
|
||||
for _, s := range All() {
|
||||
out = append(out, string(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
// 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 status
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/veans/internal/config"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := map[string]Status{
|
||||
"todo": Todo,
|
||||
"TODO": Todo,
|
||||
"in-progress": InProgress,
|
||||
"in_progress": InProgress,
|
||||
"in progress": InProgress,
|
||||
"WIP": InProgress,
|
||||
"doing": InProgress,
|
||||
"in-review": InReview,
|
||||
"review": InReview,
|
||||
"completed": Completed,
|
||||
"done": Completed,
|
||||
"scrapped": Scrapped,
|
||||
"cancelled": Scrapped,
|
||||
"canceled": Scrapped,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := Parse(in)
|
||||
if err != nil {
|
||||
t.Errorf("Parse(%q): %v", in, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("Parse(%q): got %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := Parse("nope"); err == nil {
|
||||
t.Errorf("Parse(\"nope\"): expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoneFlag(t *testing.T) {
|
||||
if !Completed.Done() || !Scrapped.Done() {
|
||||
t.Fatal("Completed/Scrapped should be done")
|
||||
}
|
||||
if Todo.Done() || InProgress.Done() || InReview.Done() {
|
||||
t.Fatal("Todo/InProgress/InReview should not be done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchBucketTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
title string
|
||||
want Status
|
||||
}{
|
||||
// Vikunja defaults
|
||||
{"To-Do", Todo},
|
||||
{"Doing", InProgress},
|
||||
{"Done", Completed},
|
||||
// Canonical titles
|
||||
{"Todo", Todo},
|
||||
{"In Progress", InProgress},
|
||||
{"In Review", InReview},
|
||||
{"Scrapped", Scrapped},
|
||||
// Case-insensitive + whitespace tolerant
|
||||
{" todo ", Todo},
|
||||
{"DOING", InProgress},
|
||||
// A few common aliases
|
||||
{"WIP", InProgress},
|
||||
{"Backlog", Todo},
|
||||
{"Cancelled", Scrapped},
|
||||
{"Won't Do", Scrapped},
|
||||
}
|
||||
for _, c := range cases {
|
||||
matched := false
|
||||
for _, s := range All() {
|
||||
if MatchBucketTitle(s, c.title) {
|
||||
if s != c.want {
|
||||
t.Errorf("MatchBucketTitle(%q): matched %q, want %q", c.title, s, c.want)
|
||||
}
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Errorf("MatchBucketTitle(%q): no status matched, want %q", c.title, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Negative: a non-canonical name shouldn't match anything.
|
||||
for _, s := range All() {
|
||||
if MatchBucketTitle(s, "random-bucket-name") {
|
||||
t.Errorf("MatchBucketTitle(%q, \"random\") unexpectedly true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketIDRoundTrip(t *testing.T) {
|
||||
b := config.Buckets{Todo: 11, InProgress: 12, InReview: 13, Done: 14, Scrapped: 15}
|
||||
for _, s := range All() {
|
||||
id, err := BucketID(s, b)
|
||||
if err != nil {
|
||||
t.Fatalf("BucketID(%q): %v", s, err)
|
||||
}
|
||||
if got := FromBucketID(id, b); got != s {
|
||||
t.Errorf("FromBucketID(%d) = %q, want %q", id, got, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
// 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 mage
|
||||
|
||||
// Mage targets for the veans CLI: dev build, tests, lint.
|
||||
//
|
||||
// Release tooling (xgo cross-compile, packaging, nfpm templating) lives in
|
||||
// the centralized build/ module — run "mage release:build veans" from there.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/magefile/mage/mg"
|
||||
"github.com/magefile/mage/sh"
|
||||
)
|
||||
|
||||
// Build compiles the veans binary into ./veans (or ./veans.exe on Windows).
|
||||
func Build() error {
|
||||
out := "./veans"
|
||||
if runtime.GOOS == "windows" {
|
||||
out = "./veans.exe"
|
||||
}
|
||||
return sh.RunV("go", "build", "-o", out, "./cmd/veans")
|
||||
}
|
||||
|
||||
// Clean removes built artifacts.
|
||||
func Clean() error {
|
||||
for _, p := range []string{"./veans", "./veans.exe"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
if err := os.Remove(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fmt runs goimports across the module.
|
||||
func Fmt() error {
|
||||
return sh.RunV("go", "fmt", "./...")
|
||||
}
|
||||
|
||||
// Test namespace.
|
||||
type Test mg.Namespace
|
||||
|
||||
// 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", "-short", "./...")
|
||||
}
|
||||
|
||||
// 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", "-short", "-run", expr, "./...")
|
||||
}
|
||||
|
||||
// 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_TESTING_TOKEN (matching the API's VIKUNJA_SERVICE_TESTINGTOKEN
|
||||
// — the harness will seed its own admin via /api/v1/test/users) or
|
||||
// VEANS_E2E_ADMIN_TOKEN (a pre-existing JWT for the admin to use as-is).
|
||||
//
|
||||
// Set VEANS_E2E_SKIP_BUILD=true to reuse a previously-built binary.
|
||||
func (Test) E2E() error {
|
||||
if os.Getenv("VEANS_E2E_API_URL") == "" {
|
||||
return fmt.Errorf("VEANS_E2E_API_URL is not set — start a Vikunja instance and export the URL")
|
||||
}
|
||||
if os.Getenv("VEANS_E2E_ADMIN_TOKEN") == "" && os.Getenv("VEANS_E2E_TESTING_TOKEN") == "" {
|
||||
return fmt.Errorf("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")
|
||||
}
|
||||
if os.Getenv("VEANS_E2E_SKIP_BUILD") == "" {
|
||||
if err := Build(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
abs, err := filepath.Abs("./veans")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sh.RunWithV(map[string]string{"VEANS_BINARY": abs}, "go", "test", "-count=1", "./e2e/...")
|
||||
}
|
||||
|
||||
// Lint namespace.
|
||||
type Lint mg.Namespace
|
||||
|
||||
// All runs golangci-lint over the module.
|
||||
func (Lint) All() error {
|
||||
if _, err := exec.LookPath("golangci-lint"); err != nil {
|
||||
return fmt.Errorf("golangci-lint not installed: %w", err)
|
||||
}
|
||||
return sh.RunV("golangci-lint", "run", "./...")
|
||||
}
|
||||
|
||||
// Fix runs golangci-lint with --fix.
|
||||
func (Lint) Fix() error {
|
||||
if _, err := exec.LookPath("golangci-lint"); err != nil {
|
||||
return fmt.Errorf("golangci-lint not installed: %w", err)
|
||||
}
|
||||
return sh.RunV("golangci-lint", "run", "--fix", "./...")
|
||||
}
|
||||
|
||||
// Aliases lets `mage test` resolve to `Test.All` (and the others) without
|
||||
// having to spell out the namespace. Mirrors the parent magefile's pattern.
|
||||
var Aliases = map[string]any{
|
||||
"test": Test.All,
|
||||
"test:filter": Test.Filter,
|
||||
"test:e2e": Test.E2E,
|
||||
"lint": Lint.All,
|
||||
"lint:fix": Lint.Fix,
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
name: "veans"
|
||||
arch: "<arch>"
|
||||
platform: "linux"
|
||||
version: "<version>"
|
||||
description: "veans is an agent-friendly CLI wrapper for the Vikunja REST API."
|
||||
maintainer: "Vikunja Maintainers <maintainers@vikunja.io>"
|
||||
homepage: "https://vikunja.io"
|
||||
section: "default"
|
||||
priority: "extra"
|
||||
license: "AGPLv3"
|
||||
rpm:
|
||||
signature:
|
||||
key_file: ${NFPM_GPG_KEY_FILE}
|
||||
contents:
|
||||
- src: <binlocation>
|
||||
dst: /usr/local/bin/veans
|
||||
Loading…
Reference in New Issue