feat: live tracker suite - WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image (#156)
Files changed: - .gitea/scripts/start-radicale.sh - .gitea/sp-live/Dockerfile - .gitea/sp-live/resolve-version.sh - .gitea/workflows/ci.yml - .gitea/workflows/sp-live-image.yml - .gitea/workflows/tracker-live.yml - .gitignore - CHANGES.md - DEVELOPMENT.md - INSTALL.md - VERSION - instructions/dev/doc-pull-through.md - instructions/dev/stack-dev/SKILL.md - instructions/dev/testing-conventions.md - instructions/dev/tracker-testing.md - kb/gtd/INDEX.md - kb/gtd/technik/Chemenu 8.0.0 freigeben.md - kb/gtd/technik/Windows nativ unterstützen.md - kb/index.md - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/review_cmd.py - tools/chemenu/commands/task_cmd.py - tools/chemenu/config.py - tools/chemenu/tasks/config.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/fixtures/sp/MANIFEST.json - tools/chemenu/tests/fixtures/sp/api/health.json - tools/chemenu/tests/fixtures/sp/api/projects.json - tools/chemenu/tests/fixtures/sp/api/tags.json - tools/chemenu/tests/fixtures/sp/api/tasks.json - tools/chemenu/tests/fixtures/sp/seed-backup.json - tools/chemenu/tests/record_sp_fixtures.py - tools/chemenu/tests/sp_headless.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_review.py - tools/chemenu/tests/test_sp_recorded.py - tools/chemenu/tests/test_task_cmd.py - tools/chemenu/tests/test_tasks_config.py - tools/chemenu/tests/test_tracker_live.py - tools/chemenu/tests/tracker_live.py - tools/pytest.ini
This commit is contained in:
1 parent
529793b255
commit
b0c64772cc
43 files changed
+2279
-32
No files matched your search
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Start a throwaway Radicale (a small CalDAV server) for the live tracker suite (Gitea #156).
|
||||||
|
#
|
||||||
|
# start-radicale.sh <python-with-radicale> <work-dir>
|
||||||
|
#
|
||||||
|
# Radicale runs as a background process, not a service container: the jobs that call this
|
||||||
|
# already run inside a job container, and a process on 127.0.0.1 needs no network wiring.
|
||||||
|
# Appends the CHEMENU_LIVE_CALDAV_* variables to $GITHUB_ENV, so the pytest step that follows
|
||||||
|
# runs the CalDAV half of the live suite - with CHEMENU_LIVE_REQUIRE=caldav set by the
|
||||||
|
# workflow, so a server that did not come up fails the run instead of skipping it.
|
||||||
|
set -eu
|
||||||
|
python="$1"
|
||||||
|
work="$2"
|
||||||
|
mkdir -p "$work"
|
||||||
|
printf 'ci:ci-live-secret\n' > "$work/users"
|
||||||
|
cat > "$work/config" <<CONF
|
||||||
|
[server]
|
||||||
|
hosts = 127.0.0.1:5232
|
||||||
|
[auth]
|
||||||
|
type = htpasswd
|
||||||
|
htpasswd_filename = $work/users
|
||||||
|
htpasswd_encryption = plain
|
||||||
|
[storage]
|
||||||
|
filesystem_folder = $work/data
|
||||||
|
CONF
|
||||||
|
"$python" -m radicale --config "$work/config" > "$work/radicale.log" 2>&1 &
|
||||||
|
i=0
|
||||||
|
until curl -fsS -o /dev/null -u ci:ci-live-secret -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/ci/; do
|
||||||
|
i=$((i + 1))
|
||||||
|
if [ "$i" -gt 30 ]; then
|
||||||
|
echo "radicale did not come up:" >&2
|
||||||
|
cat "$work/radicale.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
version="$("$python" -m radicale --version 2>/dev/null | tail -n 1)"
|
||||||
|
{
|
||||||
|
echo "CHEMENU_LIVE_CALDAV_URL=http://127.0.0.1:5232/ci/"
|
||||||
|
echo "CHEMENU_LIVE_CALDAV_USER=ci"
|
||||||
|
echo "CHEMENU_LIVE_CALDAV_PASSWORD=ci-live-secret"
|
||||||
|
echo "CHEMENU_LIVE_CALDAV_VERSION=radicale $version"
|
||||||
|
} >> "${GITHUB_ENV:?start-radicale.sh runs inside a workflow step}"
|
||||||
|
echo "radicale $version is up"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# The image the nightly `tracker-live` workflow runs in: the packaged Super Productivity
|
||||||
|
# desktop app, an X server to hold it, and what the suite itself needs (Gitea #156).
|
||||||
|
# Built by `.gitea/workflows/sp-live-image.yml`, never by hand.
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
|
ARG SP_VERSION
|
||||||
|
ARG SP_SHA512
|
||||||
|
|
||||||
|
# `nodejs` is for act_runner, which executes JavaScript actions (checkout) inside the
|
||||||
|
# job container. `libasound2t64` and `libgbm1` are the two libraries the .deb does not
|
||||||
|
# pull in and the app will not start without.
|
||||||
|
RUN set -eu; \
|
||||||
|
test -n "$SP_VERSION" && test -n "$SP_SHA512"; \
|
||||||
|
apt-get update -qq; \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl git nodejs python3 python3-venv ripgrep \
|
||||||
|
xvfb xauth libasound2t64 libgbm1; \
|
||||||
|
curl -fsSL -o /tmp/sp.deb \
|
||||||
|
"https://github.com/super-productivity/super-productivity/releases/download/v${SP_VERSION}/superProductivity-amd64.deb"; \
|
||||||
|
expected="$(printf '%s' "$SP_SHA512" | base64 -d | od -An -v -tx1 | tr -d ' \n')"; \
|
||||||
|
echo "${expected} /tmp/sp.deb" | sha512sum -c -; \
|
||||||
|
apt-get install -y --no-install-recommends /tmp/sp.deb; \
|
||||||
|
rm -rf /tmp/sp.deb /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="chemenu-sp-live" \
|
||||||
|
org.opencontainers.image.description="Packaged Super Productivity for chemenu's live tracker suite" \
|
||||||
|
chemenu.sp-version="${SP_VERSION}"
|
||||||
|
|
||||||
|
ENV CHEMENU_LIVE_SP_BINARY="/opt/Super Productivity/superproductivity" \
|
||||||
|
CHEMENU_LIVE_SP_VERSION="${SP_VERSION}"
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Which Super Productivity release, and the sha512 of its .deb, straight from the
|
||||||
|
# update channel the desktop clients themselves follow (`latest-linux.yml`).
|
||||||
|
#
|
||||||
|
# resolve-version.sh the newest release
|
||||||
|
# resolve-version.sh 19.1.0 that release
|
||||||
|
#
|
||||||
|
# Prints two lines, `version=<x>` and `sha512=<base64>`, so a workflow can append the
|
||||||
|
# output to $GITHUB_OUTPUT as it is. Gitea #156: the test image follows the channel
|
||||||
|
# rather than a pin, because installed apps update on their own and a pinned old
|
||||||
|
# version would be tested against while users run the new one.
|
||||||
|
set -eu
|
||||||
|
base=https://github.com/super-productivity/super-productivity/releases
|
||||||
|
if [ "${1:-latest}" = latest ]; then
|
||||||
|
url="$base/latest/download/latest-linux.yml"
|
||||||
|
else
|
||||||
|
url="$base/download/v$1/latest-linux.yml"
|
||||||
|
fi
|
||||||
|
yml="$(curl -fsSL "$url")"
|
||||||
|
version="$(printf '%s\n' "$yml" | sed -n 's/^version: *//p' | head -n 1)"
|
||||||
|
sha512="$(printf '%s\n' "$yml" | awk '/url: superProductivity-amd64\.deb/ {found=1; next} found && /sha512:/ {print $2; exit}')"
|
||||||
|
if [ -z "$version" ] || [ -z "$sha512" ]; then
|
||||||
|
echo "resolve-version: no version/sha512 for the amd64 .deb in $url" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'version=%s\nsha512=%s\n' "$version" "$sha512"
|
||||||
@@ -132,6 +132,21 @@ jobs:
|
|||||||
.venv/bin/python -m pytest -q \
|
.venv/bin/python -m pytest -q \
|
||||||
--cov --cov-report=term --cov-report=xml --cov-report=html
|
--cov --cov-report=term --cov-report=xml --cov-report=html
|
||||||
|
|
||||||
|
- name: Live tracker suite (CalDAV)
|
||||||
|
# The one live tracker that needs no app and no display: a throwaway Radicale on
|
||||||
|
# loopback. `CHEMENU_LIVE_REQUIRE=caldav` turns "no server" into a failure - without
|
||||||
|
# it the suite would skip and stay green, which is exactly the outcome this step
|
||||||
|
# exists to rule out (Gitea #156). The Super Productivity half is nightly, in
|
||||||
|
# `tracker-live.yml`; see instructions/dev/tracker-testing.md.
|
||||||
|
env:
|
||||||
|
CHEMENU_LIVE_REQUIRE: caldav
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
tools/.venv/bin/pip install --quiet radicale
|
||||||
|
.gitea/scripts/start-radicale.sh tools/.venv/bin/python /tmp/radicale
|
||||||
|
cd tools
|
||||||
|
.venv/bin/python -m pytest -q -m live_tracker -k caldav -s
|
||||||
|
|
||||||
- name: Coverage report
|
- name: Coverage report
|
||||||
# `always()`: a red suite is exactly when the per-module numbers are
|
# `always()`: a red suite is exactly when the per-module numbers are
|
||||||
# worth reading, and the upload must not disappear with the failure.
|
# worth reading, and the upload must not disappear with the failure.
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Builds the image the nightly `tracker-live` run executes in: Debian, the packaged Super
|
||||||
|
# Productivity, a virtual display and the tools the suite needs (Gitea #156). It lives in
|
||||||
|
# this Gitea instance's registry as `gitea.nehmer.net/torben/chemenu-sp-live`.
|
||||||
|
#
|
||||||
|
# The image follows the update channel, not a pin. Installed desktop apps update themselves,
|
||||||
|
# so a pinned old version would be tested while users already run the new one. Every day this
|
||||||
|
# workflow asks `latest-linux.yml` (`.gitea/sp-live/resolve-version.sh`) which release is
|
||||||
|
# current, and builds only when the registry does not hold that tag yet. It also rebuilds once
|
||||||
|
# a month regardless, so the Debian layers behind the app do not age unnoticed.
|
||||||
|
#
|
||||||
|
# Tags: `:<sp-version>` always, `:latest` only when that version is what the channel says.
|
||||||
|
# A manual run with `sp_version` builds an older release (to reproduce a red night against
|
||||||
|
# the version it went red on) and therefore never moves `:latest`.
|
||||||
|
#
|
||||||
|
# Runner shape follows torben/gitea-mcp, `.gitea/workflows/binford-release.yaml`: the
|
||||||
|
# `container-builder` label, a remote BuildKit on the runner host, and the registry login from
|
||||||
|
# 1Password. `OP_SERVICE_ACCOUNT_TOKEN` is a user-level secret that covers `torben/*`.
|
||||||
|
#
|
||||||
|
# After the very first push the package has to be linked to this repository once, by hand, in
|
||||||
|
# the Gitea UI - a step no workflow can do. Until then the image builds and pulls fine; only
|
||||||
|
# the package page shows no repository.
|
||||||
|
|
||||||
|
name: SP live image
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 04:10 UTC, an hour after `nightly` and well before `tracker-live` (05:00), so a new
|
||||||
|
# release is in the registry by the time the suite looks for it.
|
||||||
|
- cron: '10 4 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sp_version:
|
||||||
|
description: 'Super Productivity release to build (default: the current one)'
|
||||||
|
required: false
|
||||||
|
force:
|
||||||
|
description: 'Rebuild even if the tag already exists (true/false)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.nehmer.net/torben
|
||||||
|
IMAGE_NAME: chemenu-sp-live
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: container-builder
|
||||||
|
container:
|
||||||
|
image: debian:trixie-slim
|
||||||
|
steps:
|
||||||
|
- name: Install CI dependencies
|
||||||
|
# `nodejs` is for act_runner's JavaScript actions, not for us - see ci.yml.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
git nodejs curl docker-cli docker-buildx ca-certificates iproute2 gawk
|
||||||
|
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Resolve the Super Productivity release
|
||||||
|
id: sp
|
||||||
|
env:
|
||||||
|
REQUESTED: ${{ inputs.sp_version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
channel="$(.gitea/sp-live/resolve-version.sh latest)"
|
||||||
|
channel_version="$(printf '%s\n' "$channel" | sed -n 's/^version=//p')"
|
||||||
|
if [ -n "${REQUESTED:-}" ]; then
|
||||||
|
wanted="$(.gitea/sp-live/resolve-version.sh "$REQUESTED")"
|
||||||
|
else
|
||||||
|
wanted="$channel"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
printf '%s\n' "$wanted"
|
||||||
|
echo "channel_version=$channel_version"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
printf '%s\n' "$wanted"
|
||||||
|
|
||||||
|
- name: Load secrets from 1Password
|
||||||
|
uses: 1password/load-secrets-action@v2
|
||||||
|
with:
|
||||||
|
export-env: true
|
||||||
|
env:
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
REGISTRY_USER: op://CI-CD/gitea-package-token/username
|
||||||
|
REGISTRY_PAT: op://CI-CD/gitea-package-token/password
|
||||||
|
|
||||||
|
- name: BuildKit setup (remote builder)
|
||||||
|
run: |
|
||||||
|
HOST_IP=$(ip route | awk '/default/ { print $3 }')
|
||||||
|
docker buildx create --name remote-builder --driver remote tcp://$HOST_IP:1234 --use --bootstrap
|
||||||
|
|
||||||
|
- name: Log in to the container registry
|
||||||
|
run: |
|
||||||
|
echo "$REGISTRY_PAT" | docker login gitea.nehmer.net -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
- name: Decide whether to build
|
||||||
|
id: decide
|
||||||
|
env:
|
||||||
|
SP_VERSION: ${{ steps.sp.outputs.version }}
|
||||||
|
CHANNEL_VERSION: ${{ steps.sp.outputs.channel_version }}
|
||||||
|
FORCE: ${{ inputs.force }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
ref="$REGISTRY/$IMAGE_NAME:$SP_VERSION"
|
||||||
|
build=false
|
||||||
|
why=""
|
||||||
|
if [ "${FORCE:-false}" = true ]; then
|
||||||
|
build=true; why="forced"
|
||||||
|
elif [ "$(date -u +%d)" = 01 ]; then
|
||||||
|
build=true; why="monthly rebuild"
|
||||||
|
elif ! docker buildx imagetools inspect "$ref" > /dev/null 2>&1; then
|
||||||
|
build=true; why="$ref is not in the registry yet"
|
||||||
|
fi
|
||||||
|
tags="$ref"
|
||||||
|
if [ "$SP_VERSION" = "$CHANNEL_VERSION" ]; then
|
||||||
|
tags="$tags
|
||||||
|
$REGISTRY/$IMAGE_NAME:latest"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "build=$build"
|
||||||
|
echo "tags<<EOF"
|
||||||
|
echo "$tags"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build=$build ${why:+($why)}; tags: $tags"
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
if: steps.decide.outputs.build == 'true'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .gitea/sp-live
|
||||||
|
file: .gitea/sp-live/Dockerfile
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.decide.outputs.tags }}
|
||||||
|
build-args: |
|
||||||
|
SP_VERSION=${{ steps.sp.outputs.version }}
|
||||||
|
SP_SHA512=${{ steps.sp.outputs.sha512 }}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# The live tracker suite, nightly (Gitea #156): the documented `task` and `review` workflow
|
||||||
|
# against a real Super Productivity and a real CalDAV server, not against fakes.
|
||||||
|
#
|
||||||
|
# `ci.yml` already runs the CalDAV half on every push (Radicale is a pip install). This
|
||||||
|
# workflow adds the half that needs the desktop app, inside the prebuilt
|
||||||
|
# `chemenu-sp-live` image (`sp-live-image.yml`), and runs both, so one green night covers both
|
||||||
|
# providers.
|
||||||
|
#
|
||||||
|
# Red after a new Super Productivity release is the finding this workflow exists for, not a
|
||||||
|
# flaky night: `instructions/dev/tracker-testing.md` says what to do with it. An agent that
|
||||||
|
# touched the Super Productivity surface (`tasks/superproductivity.py`, `tasks/config.py`,
|
||||||
|
# the fixtures) dispatches it by hand instead of waiting for the clock.
|
||||||
|
#
|
||||||
|
# Runner: `linux-docker`, with the image as the job container. The image carries `nodejs`, so
|
||||||
|
# `actions/checkout` runs; see ci.yml for why that is the workflow's business.
|
||||||
|
|
||||||
|
name: Tracker live
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 5 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
live:
|
||||||
|
runs-on: linux-docker
|
||||||
|
container:
|
||||||
|
image: gitea.nehmer.net/torben/chemenu-sp-live:latest
|
||||||
|
env:
|
||||||
|
WIKITOOL_SESSION_ID: tracker-live-${{ github.run_id }}
|
||||||
|
WIKI_TRACE_DIR: /tmp/wikitool-trace
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Which Super Productivity is this
|
||||||
|
# The image tag says which release it was built for; whether `:latest` was pulled
|
||||||
|
# fresh or served from the runner's cache is not certain. So the run compares what is
|
||||||
|
# installed with what the update channel names now, and says so in the log.
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
installed="$(dpkg-query -W -f='${Version}' superproductivity)"
|
||||||
|
channel="$(.gitea/sp-live/resolve-version.sh latest | sed -n 's/^version=//p')"
|
||||||
|
echo "installed: $installed, update channel: $channel"
|
||||||
|
if [ "$installed" != "$channel" ]; then
|
||||||
|
echo "::warning::the image carries Super Productivity $installed, the update channel names $channel - this run tests an outdated app (stale runner cache or an image not rebuilt yet)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Tool environment
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
python3 -m venv tools/.venv
|
||||||
|
tools/.venv/bin/pip install --quiet --upgrade pip
|
||||||
|
tools/.venv/bin/pip install --quiet -r tools/requirements.txt
|
||||||
|
tools/.venv/bin/pip install --quiet pytest radicale
|
||||||
|
|
||||||
|
- name: Start Radicale
|
||||||
|
run: .gitea/scripts/start-radicale.sh tools/.venv/bin/python /tmp/radicale
|
||||||
|
|
||||||
|
- name: Live tracker suite
|
||||||
|
# Both kinds are required: a night in which the app or the server was not there must
|
||||||
|
# fail rather than skip.
|
||||||
|
env:
|
||||||
|
CHEMENU_LIVE_REQUIRE: sp,caldav
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
cd tools
|
||||||
|
.venv/bin/python -m pytest -q -m live_tracker -s
|
||||||
@@ -141,6 +141,13 @@ npm-debug.log*
|
|||||||
# configured; `doctor` reports which.
|
# configured; `doctor` reports which.
|
||||||
/.wikitool-tasks.json
|
/.wikitool-tasks.json
|
||||||
|
|
||||||
|
# Live-suite tracker profiles (Gitea #156): one file per tracker of the user's own that the
|
||||||
|
# live suite may be pointed at (`CHEMENU_LIVE_PROFILE=<name>`,
|
||||||
|
# instructions/dev/tracker-testing.md). They carry the same credentials as the file above
|
||||||
|
# and are per-checkout for the same reason. A directory pattern, anchored: nothing in it is
|
||||||
|
# ever tracked, so no negation has to rescue anything.
|
||||||
|
/.wikitool-tasks.d/
|
||||||
|
|
||||||
# Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived,
|
# Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived,
|
||||||
# like reports/: recomputable from any commit, and `publish` runs `git add -A`,
|
# like reports/: recomputable from any commit, and `publish` runs `git add -A`,
|
||||||
# so an unignored htmlcov/ would commit itself on the next content publish.
|
# so an unignored htmlcov/ would commit itself on the next content publish.
|
||||||
|
|||||||
+39
-1
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8.0.0-beta.2 - 2026-09-30 - Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162)
|
## 8.0.0-beta.3 - 2026-09-30 - Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- fail() prints the command's ON FAILURE lines on stderr
|
- fail() prints the command's ON FAILURE lines on stderr
|
||||||
- Budget gate and loop-breaker refusals exit without a traceback
|
- Budget gate and loop-breaker refusals exit without a traceback
|
||||||
- Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162)
|
- Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (#162)
|
||||||
|
- Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image
|
||||||
|
|
||||||
**Low impact**
|
**Low impact**
|
||||||
- version bump no longer points at version release in its output
|
- version bump no longer points at version release in its output
|
||||||
@@ -109,6 +110,43 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- new_page/type_resolver comments no longer claim only entities declare a layout:
|
- new_page/type_resolver comments no longer claim only entities declare a layout:
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
### Live tracker suite: WIKITOOL_TASKS_CONFIG override, real-tracker tests for Super Productivity and CalDAV, nightly workflow and test image (Gitea #156)
|
||||||
|
|
||||||
|
The task-tracker adapters were only ever tested against fakes, which is how #162 stayed hidden.
|
||||||
|
This adds a suite that runs the documented `task new` / `task list` / `task close` / `review`
|
||||||
|
workflow against a real tracker, and the machinery to run it on a clock.
|
||||||
|
|
||||||
|
- **`WIKITOOL_TASKS_CONFIG`** names the tracker configuration `task`, `review` and `doctor`
|
||||||
|
read instead of `.wikitool-tasks.json`, so one checkout can be run against several trackers in
|
||||||
|
turn. A set variable that names no file is an error naming the path - never "no tracker
|
||||||
|
configured". Listed in INSTALL.md's variable table and in the affected command records.
|
||||||
|
- **The `live_tracker` suite** (`tests/test_tracker_live.py`, `tests/tracker_live.py`) writes only
|
||||||
|
into a project named `Chemenu Live-Test`, prefixes every item with the run id, deletes
|
||||||
|
nothing, and aborts before the first write when the marker project is missing. It skips
|
||||||
|
without a tracker; `CHEMENU_LIVE_REQUIRE` turns a missing one into a failure. A tracker of
|
||||||
|
your own is named by `.wikitool-tasks.d/<name>.json` (gitignored, absolute paths only) and
|
||||||
|
`CHEMENU_LIVE_PROFILE`.
|
||||||
|
- **Headless Super Productivity** (`tests/sp_headless.py`) starts the packaged app on a seeded
|
||||||
|
profile, accepts its startup restore dialog over the DevTools protocol and refuses to start
|
||||||
|
when something already answers on the fixed API port.
|
||||||
|
- **Recorded fixtures** (`tests/fixtures/sp/`) hold real answers of v19.1.0 and a backup the app
|
||||||
|
wrote itself; `test_sp_recorded.py` replays them in the default run, and
|
||||||
|
`tests/record_sp_fixtures.py` records them again.
|
||||||
|
- **CI.** `ci.yml` runs the CalDAV half against a Radicale process on every push.
|
||||||
|
`tracker-live.yml` runs both providers nightly inside the `chemenu-sp-live` image, which
|
||||||
|
`sp-live-image.yml` builds daily when the update channel has a new Super Productivity version
|
||||||
|
and monthly regardless. The image follows the channel rather than a pin, because installed
|
||||||
|
desktop clients update themselves.
|
||||||
|
- **Docs.** `instructions/dev/tracker-testing.md` has the profile procedure per tracker, when an
|
||||||
|
agent dispatches the nightly run, what a red night means and how to refresh the fixtures;
|
||||||
|
`testing-conventions.md` names the live suite as the one deliberate exception to the hermetic
|
||||||
|
default. `docs verify` gained an ignore canary for `.wikitool-tasks.d/`.
|
||||||
|
- **Demo corpus.** `kb/gtd/technik/` gains two project pages taken from the Windows and release
|
||||||
|
work, matching projects in the seed backup, so `review` has something to join.
|
||||||
|
|
||||||
|
The Docker package `chemenu-sp-live` has to be linked to this repository once, by hand, after the
|
||||||
|
first image build.
|
||||||
|
|
||||||
### Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (Gitea #162)
|
### Super Productivity API path: unwrap the {ok, data} envelope, exclude the inbox project, ready-aware health (Gitea #162)
|
||||||
|
|
||||||
Preparing the live tracker tests (#156) ran the real Super Productivity v19.1.0 headless for the
|
Preparing the live tracker tests (#156) ran the real Super Productivity v19.1.0 headless for the
|
||||||
|
|||||||
@@ -94,6 +94,16 @@ führt Testsuite, `docs verify`, `instructions verify` sowie einen vollständige
|
|||||||
Nutzer tatsächlich geht. `.gitea/workflows/nightly.yml` ist der Drift-Check gegen die Zeit statt
|
Nutzer tatsächlich geht. `.gitea/workflows/nightly.yml` ist der Drift-Check gegen die Zeit statt
|
||||||
gegen einen Commit. `.gitea/workflows/release.yml` ist Schritt 6 oben.
|
gegen einen Commit. `.gitea/workflows/release.yml` ist Schritt 6 oben.
|
||||||
|
|
||||||
|
Drei weitere Workflows tragen die Live-Tests der Tracker-Adapter (Super Productivity, CalDAV):
|
||||||
|
`ci.yml` führt in einem eigenen Schritt die CalDAV-Hälfte gegen ein Radicale als Prozess aus,
|
||||||
|
`tracker-live.yml` läuft nachts und deckt beide Anbieter ab, und `sp-live-image.yml` baut täglich
|
||||||
|
(bei neuer Version) und monatlich (immer) das Image `chemenu-sp-live` mit der jeweils aktuellen
|
||||||
|
Super-Productivity-Version. Das Image folgt dem Update-Kanal der Desktop-Clients, nicht einer
|
||||||
|
festen Version. Einmalig nach dem allerersten Push muss das Paket von Hand dem Repo
|
||||||
|
`torben/chemenu` zugeordnet werden. Was die Suite schreibt, wie man sie gegen einen eigenen
|
||||||
|
Tracker laufen lässt und was ein roter Lauf bedeutet, steht in
|
||||||
|
[instructions/dev/tracker-testing.md](instructions/dev/tracker-testing.md).
|
||||||
|
|
||||||
## Stack-Entwicklung als eigener Sitzungstyp
|
## Stack-Entwicklung als eigener Sitzungstyp
|
||||||
|
|
||||||
Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die
|
Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ Ausnahmen (`kb/CONVENTIONS.md`, `kb/*/COLLECTION.md`, `.wikitool-kb.json`) in
|
|||||||
| `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Eine vom Harness selbst gesetzte Sitzungs-Variable, wo eine bekannt ist (z. B. `CLAUDE_CODE_SESSION_ID`), sonst die Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) |
|
| `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Eine vom Harness selbst gesetzte Sitzungs-Variable, wo eine bekannt ist (z. B. `CLAUDE_CODE_SESSION_ID`), sonst die Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) |
|
||||||
| `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz |
|
| `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz |
|
||||||
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) |
|
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) |
|
||||||
|
| `WIKITOOL_TASKS_CONFIG` | Pfad zu einer Tracker-Konfiguration, die `task`, `review` und `doctor` statt `.wikitool-tasks.json` lesen - um einen Checkout der Reihe nach gegen mehrere Tracker laufen zu lassen | die `.wikitool-tasks.json` im Repo-Root. Nennt die Variable eine Datei, die es nicht gibt, ist das ein Fehler und nie „kein Tracker konfiguriert" |
|
||||||
| `CHEMENU_ROOT` | Auf welchen Korpus das Paket zeigt - für einen Aufrufer, der nicht im Checkout selbst liegt | der Checkout, in dem das Paket liegt (`tools/wikitool` verhält sich ohne die Variable unverändert) |
|
| `CHEMENU_ROOT` | Auf welchen Korpus das Paket zeigt - für einen Aufrufer, der nicht im Checkout selbst liegt | der Checkout, in dem das Paket liegt (`tools/wikitool` verhält sich ohne die Variable unverändert) |
|
||||||
| `WIKI_TRACE` / `WIKI_TRACE_DIR` | Telemetrie abschalten bzw. aus dem Arbeitsbaum heraus umlenken | Hängt vom Installationsweg ab - siehe unten |
|
| `WIKI_TRACE` / `WIKI_TRACE_DIR` | Telemetrie abschalten bzw. aus dem Arbeitsbaum heraus umlenken | Hängt vom Installationsweg ab - siehe unten |
|
||||||
| `WIKI_TRACE_MAX_SESSION_BYTES` / `WIKI_TRACE_KEEP_SESSIONS` | Byte-Deckel je Session-Trace bzw. wie viele Session-Verzeichnisse die Retention behält | 5 MiB je Session, 250 Verzeichnisse |
|
| `WIKI_TRACE_MAX_SESSION_BYTES` / `WIKI_TRACE_KEEP_SESSIONS` | Byte-Deckel je Session-Trace bzw. wie viele Session-Verzeichnisse die Retention behält | 5 MiB je Session, 250 Verzeichnisse |
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ touched; a row that does not apply needs no action.
|
|||||||
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
|
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
|
||||||
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
|
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
|
||||||
| The reasoning behind a gate, boundary, or design decision | The `docs/` page that carries it, if one exists (AGENTS.md § File naming lists all six reached from AGENTS.md itself, plus a seventh reached only from CLAUDE.md). **A decision with no page yet is the gap worth closing**: reasoning that lives only in a Gitea issue never ships - `dist export` carries `docs/` and no issue tracker, so a distributed instance gets the mechanism without the why |
|
| The reasoning behind a gate, boundary, or design decision | The `docs/` page that carries it, if one exists (AGENTS.md § File naming lists all six reached from AGENTS.md itself, plus a seventh reached only from CLAUDE.md). **A decision with no page yet is the gap worth closing**: reasoning that lives only in a Gitea issue never ships - `dist export` carries `docs/` and no issue tracker, so a distributed instance gets the mechanism without the why |
|
||||||
|
| A task-tracker adapter (`tools/chemenu/tasks/`), its recorded fixtures, or the live suite | [instructions/dev/tracker-testing.md](tracker-testing.md), and `MANIFEST.json` beside the fixtures when they were re-recorded |
|
||||||
| A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/` |
|
| A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/` |
|
||||||
| A per-checkout configuration file an instance owns (`.wikitool-tasks.json`, `.wikitool-telemetry.json`, `.wikitool-remotes.json`, `.wikitool-upload.json`) | [INSTALL.md](../../INSTALL.md) § Konfiguration, where an operator looks the shape up; the [setup-instance.md](../setup-instance.md) decision point that offers it during setup; and `doctor`'s own row in [tools/CONTRACT.md](../../tools/CONTRACT.md), since `doctor` is what reports the file's state |
|
| A per-checkout configuration file an instance owns (`.wikitool-tasks.json`, `.wikitool-telemetry.json`, `.wikitool-remotes.json`, `.wikitool-upload.json`) | [INSTALL.md](../../INSTALL.md) § Konfiguration, where an operator looks the shape up; the [setup-instance.md](../setup-instance.md) decision point that offers it during setup; and `doctor`'s own row in [tools/CONTRACT.md](../../tools/CONTRACT.md), since `doctor` is what reports the file's state |
|
||||||
| A new page type the stack requires, or a new collection | Its type-spec and `COLLECTION.md` (both as the `.template` an instance adopts), the collection table in [kb/CONTRACT.md](../../kb/CONTRACT.md), and **both adoption paths**: [setup-instance.md](../setup-instance.md) for a fresh instance and [upgrade-instance.md](../upgrade-instance.md) for an existing one, where an unadopted template is what `docs verify` refuses |
|
| A new page type the stack requires, or a new collection | Its type-spec and `COLLECTION.md` (both as the `.template` an instance adopts), the collection table in [kb/CONTRACT.md](../../kb/CONTRACT.md), and **both adoption paths**: [setup-instance.md](../setup-instance.md) for a fresh instance and [upgrade-instance.md](../upgrade-instance.md) for an existing one, where an unadopted template is what `docs verify` refuses |
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
`instructions/dev/testing-conventions.md` - the suite runs against a deliberately
|
`instructions/dev/testing-conventions.md` - the suite runs against a deliberately
|
||||||
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
||||||
establish itself. Read it before adding or changing a test.
|
establish itself. Read it before adding or changing a test.
|
||||||
|
`instructions/dev/tracker-testing.md` - how the task-tracker adapters are tested against a
|
||||||
|
real Super Productivity and CalDAV server: the `live_tracker` suite, the profile procedure for
|
||||||
|
a tracker of your own, the nightly workflow (which you dispatch yourself after touching the
|
||||||
|
Super Productivity surface), what a red night means, and refreshing the recorded fixtures.
|
||||||
|
Read it before changing an adapter under `tools/chemenu/tasks/`.
|
||||||
`instructions/dev/version-parts.md` - which part a change bumps: the drop-in test, the
|
`instructions/dev/version-parts.md` - which part a change bumps: the drop-in test, the
|
||||||
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
||||||
put in front of the user before a breaking bump. Read it before step 4.
|
put in front of the user before a breaking bump. Read it before step 4.
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
|||||||
fixture creates that machine.
|
fixture creates that machine.
|
||||||
|
|
||||||
4. **Adding a new environment variable to the tool?** Add it to `_WIKITOOL_ENV` in
|
4. **Adding a new environment variable to the tool?** Add it to `_WIKITOOL_ENV` in
|
||||||
`conftest.py` in the same change. A variable the tool reads and the fixture does not clear
|
`conftest.py` in the same change (`WIKITOOL_TASKS_CONFIG` is one). A variable the tool reads and the fixture does not clear
|
||||||
is the exact hole this whole file is about, reopened.
|
is the exact hole this whole file is about, reopened.
|
||||||
|
|
||||||
5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
|
5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
|
||||||
@@ -168,6 +168,10 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
|||||||
- **A test genuinely needs the developer's real environment?** There is no such test, and a new
|
- **A test genuinely needs the developer's real environment?** There is no such test, and a new
|
||||||
one is a design problem rather than an exception: what it wants is a fixture that *builds*
|
one is a design problem rather than an exception: what it wants is a fixture that *builds*
|
||||||
the state it needs inside `tmp_path`. Building it is also the only version CI can run.
|
the state it needs inside `tmp_path`. Building it is also the only version CI can run.
|
||||||
|
The one deliberate exception is the `live_tracker` suite, which talks to a real tracker and
|
||||||
|
may start a real application; it is opt-in through `CHEMENU_LIVE_*` variables, skips without
|
||||||
|
them, and the default run needs neither network nor tracker. Its rules:
|
||||||
|
[tracker-testing.md](tracker-testing.md).
|
||||||
- **A test patches `config.default_author` directly** (as
|
- **A test patches `config.default_author` directly** (as
|
||||||
`test_new_source_fails_hard_without_any_author` does)? Keep the patch. It is not made
|
`test_new_source_fails_hard_without_any_author` does)? Keep the patch. It is not made
|
||||||
redundant by the fixture - it pins the value under test regardless of what the environment
|
redundant by the fixture - it pins the value under test regardless of what the environment
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
---
|
||||||
|
type: types/instruction.md
|
||||||
|
name: tracker-testing
|
||||||
|
description: How the task-tracker adapters (Super Productivity, CalDAV) are tested against a real tracker - the live suite, the profile procedure for a tracker of your own, the nightly workflow and when an agent triggers it, what a red night means, and how to refresh the recorded fixtures.
|
||||||
|
---
|
||||||
|
# Test a tracker adapter against a real tracker
|
||||||
|
|
||||||
|
The default `pytest` run never talks to a tracker: it reads recorded answers
|
||||||
|
(`tools/chemenu/tests/fixtures/sp/`) and fakes. That is enough to keep the parsing honest and
|
||||||
|
not enough to know that the adapter still works against the tracker as it ships today - a
|
||||||
|
tracker is software somebody else releases (Gitea #156, and the adapter defects of #162 that
|
||||||
|
only a real Super Productivity could show). So a second suite exists, marked `live_tracker`,
|
||||||
|
that runs the documented `task new` / `task list` / `task close` / `review` workflow end to end.
|
||||||
|
It skips when no tracker is configured and fails when CI says one must be there.
|
||||||
|
|
||||||
|
<!-- wikitool:toc -->
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [The three ways to name a tracker](#the-three-ways-to-name-a-tracker)
|
||||||
|
- [What the suite writes, and what it never does](#what-the-suite-writes-and-what-it-never-does)
|
||||||
|
- [Run it against a tracker of your own](#run-it-against-a-tracker-of-your-own)
|
||||||
|
- [The two workflows and the image](#the-two-workflows-and-the-image)
|
||||||
|
- [When an agent triggers the nightly run](#when-an-agent-triggers-the-nightly-run)
|
||||||
|
- [A red night](#a-red-night)
|
||||||
|
- [Refreshing the fixtures](#refreshing-the-fixtures)
|
||||||
|
- [Steps](#steps)
|
||||||
|
- [Scope](#scope)
|
||||||
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
|
## The three ways to name a tracker
|
||||||
|
|
||||||
|
All through the environment; none of them is a fixed address, and none of them is read by a
|
||||||
|
plain `pytest` run unless you set it.
|
||||||
|
|
||||||
|
| Variable | Tracker | Who owns it |
|
||||||
|
|---|---|---|
|
||||||
|
| `CHEMENU_LIVE_SP_BINARY` | The packaged Super Productivity (`.deb` layout). The suite starts its own headless copy on a fresh profile, seeded from `fixtures/sp/seed-backup.json`, and stops it again. | The suite |
|
||||||
|
| `CHEMENU_LIVE_CALDAV_URL`, `_USER`, `_PASSWORD` (optional `_VERSION` for the report line) | A CalDAV collection. In CI it is a throwaway Radicale started by `.gitea/scripts/start-radicale.sh`. | The suite - it may create the marker calendar |
|
||||||
|
| `CHEMENU_LIVE_PROFILE=<name>` | A tracker of your own, described by `.wikitool-tasks.d/<name>.json` in the checkout, used exactly as configured. | You - the suite never creates anything but items |
|
||||||
|
|
||||||
|
`CHEMENU_LIVE_REQUIRE=sp,caldav,profile` (any subset) turns "no such tracker configured" into
|
||||||
|
a failure instead of a skip. CI sets it; without it a workflow that lost its server would go
|
||||||
|
green by skipping, which is the outcome the suite exists to rule out.
|
||||||
|
|
||||||
|
The commands under test read their configuration from `.wikitool-tasks.json`, or from the file
|
||||||
|
`WIKITOOL_TASKS_CONFIG` names when that variable is set. The live suite sets the variable per
|
||||||
|
test, so it never touches the checkout's own `.wikitool-tasks.json`; you can use the same
|
||||||
|
variable to run `task` and `review` by hand against a second tracker.
|
||||||
|
|
||||||
|
## What the suite writes, and what it never does
|
||||||
|
|
||||||
|
The safety rules are code (`tools/chemenu/tests/tracker_live.py`), and
|
||||||
|
`test_tracker_live.py` proves them against fakes on every run - they are not a promise in this
|
||||||
|
file.
|
||||||
|
|
||||||
|
- It writes only into the project named **`Chemenu Live-Test`** (`MARKER_PROJECT`). The project
|
||||||
|
must already exist. The suite creates it only in a tracker it owns end to end (the CalDAV
|
||||||
|
server it started); in a profile target, or in Super Productivity, a missing marker is an
|
||||||
|
abort before the first write - Super Productivity's API has no way to create a project anyway.
|
||||||
|
- Every item it creates starts with `[live-test <run id>]`. On the way out it closes what is
|
||||||
|
left of that prefix, and only that prefix.
|
||||||
|
- It never deletes anything. The only closing write is `task close`, the one this stack has.
|
||||||
|
- The headless Super Productivity refuses to start when anything already answers on the fixed
|
||||||
|
API port 3876: an answering app is somebody's real one.
|
||||||
|
- A profile path that starts with `~` is refused. Profiles carry absolute paths, because a `~`
|
||||||
|
resolves against whichever `HOME` the run has.
|
||||||
|
|
||||||
|
## Run it against a tracker of your own
|
||||||
|
|
||||||
|
Do this before you change an adapter for a tracker you actually use, or when a user reports one
|
||||||
|
that fails against theirs. The steps differ per tracker only in the profile and the marker.
|
||||||
|
|
||||||
|
1. **Create the marker project in that tracker**, by hand, named exactly `Chemenu Live-Test`.
|
||||||
|
Nothing else in the tracker is touched, but this project's items are created and closed.
|
||||||
|
2. **Write the profile** to `.wikitool-tasks.d/<name>.json` (gitignored - it holds
|
||||||
|
credentials). It is a normal `.wikitool-tasks.json` plus one optional key,
|
||||||
|
`live_test_version`, which is stripped before use and only names the tracker's version in
|
||||||
|
the report line. Paths are absolute.
|
||||||
|
|
||||||
|
Super Productivity, API access (the app must be running with the Local REST API on):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"provider": "superproductivity",
|
||||||
|
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5},
|
||||||
|
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "<token>"},
|
||||||
|
"live_test_version": "19.1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CalDAV:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"provider": "caldav",
|
||||||
|
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5},
|
||||||
|
"caldav": {"url": "https://<server>/<user>/", "username": "<user>", "app_password": "<password>",
|
||||||
|
"inbox_list": "Inbox", "someday_list": "Someday"},
|
||||||
|
"live_test_version": "<server and version>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A Super Productivity profile with `access: "snapshot"` is accepted too, but the workflow
|
||||||
|
needs a write path, so the scenario fails on it by design: that access is read-only.
|
||||||
|
3. **Run it**, from `tools/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CHEMENU_LIVE_PROFILE=<name> CHEMENU_LIVE_REQUIRE=profile \
|
||||||
|
.venv/bin/python -m pytest -m live_tracker -k profile -s
|
||||||
|
```
|
||||||
|
|
||||||
|
`-s` shows the report line with the tracker version, which is what you paste into an issue.
|
||||||
|
4. **Read a failure as a finding about that tracker**, not as a flaky test: the scenario is
|
||||||
|
deterministic. Reproduce it with `task` and `review` under `WIKITOOL_TASKS_CONFIG`, then fix
|
||||||
|
the adapter and add a recorded fixture for the answer that broke it.
|
||||||
|
|
||||||
|
## The two workflows and the image
|
||||||
|
|
||||||
|
| Workflow | When | What |
|
||||||
|
|---|---|---|
|
||||||
|
| `ci.yml`, step "Live tracker suite (CalDAV)" | Every push and PR | Radicale as a process on loopback (`pip install radicale`), `CHEMENU_LIVE_REQUIRE=caldav`. The one half that needs no app and no display. |
|
||||||
|
| `tracker-live.yml` | Nightly 05:00 UTC, and by hand | Both kinds, `CHEMENU_LIVE_REQUIRE=sp,caldav`, inside the `chemenu-sp-live` image. It logs the installed Super Productivity version and warns when it differs from the update channel. |
|
||||||
|
| `sp-live-image.yml` | Daily 04:10 UTC, and by hand | Builds `gitea.nehmer.net/torben/chemenu-sp-live` when the registry lacks the channel's current version, and once a month regardless. |
|
||||||
|
|
||||||
|
The image follows the update channel (`latest-linux.yml`, resolved by
|
||||||
|
`.gitea/sp-live/resolve-version.sh`) and not a pin: installed desktop clients update
|
||||||
|
themselves, and a pinned old version would be tested while users run the new one. Tags are
|
||||||
|
`:<version>` and `:latest`; a manual build with an explicit `sp_version` never moves `:latest`,
|
||||||
|
which is how you reproduce a red night against the version it went red on.
|
||||||
|
|
||||||
|
**The first push of the package needs one manual step**, which no workflow can do: link the
|
||||||
|
package `chemenu-sp-live` to the repository `torben/chemenu` in the Gitea UI, once, and
|
||||||
|
dispatch `sp-live-image.yml` first - `tracker-live.yml` has nothing to pull before that. The
|
||||||
|
package is public, so the runner pulls it anonymously.
|
||||||
|
|
||||||
|
## When an agent triggers the nightly run
|
||||||
|
|
||||||
|
A session that changed the Super Productivity surface does not wait for the clock: after the
|
||||||
|
push has landed and CI is green, dispatch `tracker-live.yml` and poll it (Gitea MCP,
|
||||||
|
`actions_run_write` then `actions_run_read`; never an anonymous `curl`). The surface is
|
||||||
|
`tools/chemenu/tasks/superproductivity.py`, `tools/chemenu/tasks/config.py`,
|
||||||
|
`tools/chemenu/tests/sp_headless.py`, `tools/chemenu/tests/tracker_live.py`, the fixtures under
|
||||||
|
`tools/chemenu/tests/fixtures/sp/`, and `.gitea/sp-live/`. A change to the CalDAV adapter needs
|
||||||
|
no dispatch - `ci.yml` already ran it.
|
||||||
|
|
||||||
|
Say in the session summary which run answered and against which Super Productivity version.
|
||||||
|
|
||||||
|
## A red night
|
||||||
|
|
||||||
|
The nightly run is red for one of three reasons, and they are told apart by the log:
|
||||||
|
|
||||||
|
1. **The app changed.** The report line names a Super Productivity version newer than the last
|
||||||
|
green night. This is the finding the workflow exists for: the adapter must follow. Re-record
|
||||||
|
the fixtures (below), fix the adapter, and open an issue for it with the two version
|
||||||
|
numbers.
|
||||||
|
2. **The image is stale.** The step "Which Super Productivity is this" warned that the
|
||||||
|
installed version differs from the channel. Dispatch `sp-live-image.yml` with `force`, then
|
||||||
|
the run again.
|
||||||
|
3. **The environment.** The app did not start (the abort message carries the last lines of
|
||||||
|
the app's own log), or the runner could not pull the image. Nothing about the adapter is known yet.
|
||||||
|
|
||||||
|
Never skip the suite, never remove `CHEMENU_LIVE_REQUIRE` from a workflow and never mark the
|
||||||
|
job `continue-on-error` to get a green night: a red night with a cause is the product.
|
||||||
|
|
||||||
|
## Refreshing the fixtures
|
||||||
|
|
||||||
|
`tools/chemenu/tests/fixtures/sp/api/*.json` are the raw answers of a real Super Productivity;
|
||||||
|
`seed-backup.json` is a backup the app wrote itself, with the three projects the live suite and
|
||||||
|
the demo pages use. `MANIFEST.json` says which version and when. Refresh them when a red night
|
||||||
|
shows a new answer shape, and when the manifest's version is more than a few releases behind.
|
||||||
|
|
||||||
|
1. Get a Super Productivity to record from: the current `chemenu-sp-live` image with the
|
||||||
|
checkout mounted (`docker run --rm -v "$PWD:/work" -w /work/tools ...`, then a venv from
|
||||||
|
`requirements.txt` inside it), or any machine with the `.deb` installed and
|
||||||
|
`CHEMENU_LIVE_SP_BINARY` set.
|
||||||
|
2. From `tools/`, in an environment that has `requirements.txt`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m chemenu.tests.record_sp_fixtures chemenu/tests/fixtures/sp/api
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update `MANIFEST.json` (`sp_version`, `recorded`) by hand - it is a fixture, not a generated
|
||||||
|
page - and run `test_sp_recorded.py`. If the seed backup is stale because the app's backup
|
||||||
|
format moved, rebuild it from a backup the new version wrote and keep the three projects.
|
||||||
|
4. Do not edit the recorded answers to make a test pass. A test that fails against a fresh
|
||||||
|
recording is the adapter's problem.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Decide which kind of change this is.** A change to an adapter's parsing: extend the recorded
|
||||||
|
fixtures and their replay test first. A change that alters what the adapter sends: the live
|
||||||
|
suite has to run, so run it against a tracker (above) before publishing.
|
||||||
|
2. **Run the offline half** with the rest of the suite; it includes the safety tests for the
|
||||||
|
guard, the profile rules and the port refusal.
|
||||||
|
3. **Run the live half** for the tracker you changed - CalDAV locally with a Radicale of your
|
||||||
|
own, Super Productivity in the image or with a profile.
|
||||||
|
4. **After the push, dispatch `tracker-live.yml`** when the change is on the Super Productivity
|
||||||
|
surface (see [When an agent triggers the nightly run](#when-an-agent-triggers-the-nightly-run)).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Applies to the task-tracker adapters under `tools/chemenu/tasks/` and to the live suite
|
||||||
|
itself. It is the one place the test conventions are relaxed on purpose:
|
||||||
|
[testing-conventions.md](testing-conventions.md) describes the hermetic default run, and the
|
||||||
|
live suite is opt-in, marked `live_tracker`, and may start a real application and talk to a
|
||||||
|
real server. The default run stays free of both.
|
||||||
+8
-1
@@ -2,5 +2,12 @@
|
|||||||
|
|
||||||
# kb/gtd/ - Index
|
# kb/gtd/ - Index
|
||||||
|
|
||||||
0 page(s). Regenerated by `wikitool index rebuild`.
|
2 page(s). Regenerated by `wikitool index rebuild`.
|
||||||
|
|
||||||
|
## Technik
|
||||||
|
|
||||||
|
| Page | Type | Summary | Last Modified |
|
||||||
|
|------|------|---------|----------------|
|
||||||
|
| [[Chemenu 8.0.0 freigeben]] | technik | Die Beta-Reihe 8.0.0 wird zum Release gemacht; offen ist, was zwischen letztem Kandidaten und Freigabe noch geklärt sein muss. | 2026-09-30 |
|
||||||
|
| [[Windows nativ unterstützen]] | technik | Chemenu läuft auf Windows nativ unter PowerShell 7, ohne WSL und ohne Windows PowerShell 5.1. | 2026-09-30 |
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
type: types/project.md
|
||||||
|
state: active
|
||||||
|
responsibility: technik
|
||||||
|
created: 2026-09-30
|
||||||
|
modified: 2026-09-30
|
||||||
|
related:
|
||||||
|
- see-also: Chemenu
|
||||||
|
sources: []
|
||||||
|
provenance: general
|
||||||
|
summary: Aus dem laufenden Kandidaten 8.0.0-beta wird das Release 8.0.0.
|
||||||
|
---
|
||||||
|
# Chemenu 8.0.0 freigeben
|
||||||
|
|
||||||
|
**Status:** Active
|
||||||
|
**Bereich:** Technik
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Aus dem laufenden Kandidaten 8.0.0-beta wird das Release 8.0.0: veröffentlicht, mit vollständigem Changelog-Eintrag, und eine bestehende Instanz kann per `dist upgrade` darauf wechseln.
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Zwischen zwei Releases führt [[Chemenu]] als Stack einen einzigen laufenden Kandidaten mit `-beta.N`-Suffix statt einer neuen Version je Änderung. Der Schritt vom Kandidaten zum Release ist bewusst eine menschliche Entscheidung und kein Agentenschritt.
|
||||||
|
|
||||||
|
## Beteiligte
|
||||||
|
|
||||||
|
Torben Nehmer gibt frei; ein Agent bereitet die Kandidaten vor und schreibt den Changelog.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Der Kandidat trägt die Änderungen der Installationsüberarbeitung und der Tracker-Anbindung. Ein Release ist noch nicht gefallen.
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
- Die Freigabe selbst (`version release`) führt kein Agent aus.
|
||||||
|
|
||||||
|
## Gelerntes
|
||||||
|
|
||||||
|
Ein Test gegen aufgezeichnete Antworten hält den Parser ehrlich, sagt aber nichts darüber, ob der Tracker heute noch so antwortet - dafür braucht es einen Lauf gegen das echte Programm.
|
||||||
|
|
||||||
|
<!-- wikitool:links -->
|
||||||
|
## Beziehungen
|
||||||
|
|
||||||
|
- **see-also:** [[Chemenu]]
|
||||||
|
<!-- /wikitool:links -->
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
type: types/project.md
|
||||||
|
state: active
|
||||||
|
responsibility: technik
|
||||||
|
created: 2026-09-30
|
||||||
|
modified: 2026-09-30
|
||||||
|
related:
|
||||||
|
- see-also: Chemenu
|
||||||
|
sources: []
|
||||||
|
provenance: general
|
||||||
|
summary: Chemenu läuft auf Windows nativ unter PowerShell 7, ohne WSL und ohne Windows PowerShell 5.1.
|
||||||
|
---
|
||||||
|
# Windows nativ unterstützen
|
||||||
|
|
||||||
|
**Status:** Active
|
||||||
|
**Bereich:** Technik
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein Windows-Nutzer installiert und betreibt Chemenu nativ - unter PowerShell 7, ohne WSL und ohne Windows PowerShell 5.1 - und der Installationsweg führt ihn ohne Handarbeit an der Distribution selbst zur laufenden Instanz.
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Die Installation von [[Chemenu]] war bisher auf Linux und macOS zugeschnitten. Mit der Überarbeitung der Installation (Gitea-Issue 140) wird Windows kein Sonderfall am Rand mehr, sondern ein gleichberechtigter Zielpfad. Als Installationsweg bleibt der Release-Download (Weg A) übrig.
|
||||||
|
|
||||||
|
## Beteiligte
|
||||||
|
|
||||||
|
Torben Nehmer entscheidet über Umfang und Reihenfolge.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Der Zuschnitt ist entschieden: PowerShell 7 ist die Untergrenze, WSL und Windows PowerShell 5.1 sind ausdrücklich ausgeschlossen. Vor dem Setup prüft ein Preflight die Voraussetzungen; fehlt eine, bricht er mit Exit-Code 42 ab und der Nutzer sieht die Ausgabe, bevor irgendetwas weiterläuft.
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
- Nur Weg A, der Release-Download - eine zweite Windows-Sonderbahn wäre eine zweite Kopie der Regeln.
|
||||||
|
- Kein Agent installiert Abhängigkeiten: was fehlt, meldet der Preflight, und der Nutzer entscheidet.
|
||||||
|
- PowerShell 7 ist die einzige unterstützte Shell; Windows PowerShell 5.1 und WSL sind kein Zielpfad.
|
||||||
|
|
||||||
|
## Gelerntes
|
||||||
|
|
||||||
|
Pfade, die ein Test auf einem Linux-Rechner nie sieht - Laufwerksbuchstaben, Groß- und Kleinschreibung, Zeichen, die in einem Dateinamen verboten sind - fallen erst auf, wenn die Regel dafür im Werkzeug selbst steht. Seitentitel müssen deshalb als Dateinamen auf Windows und macOS gültig sein.
|
||||||
|
|
||||||
|
<!-- wikitool:links -->
|
||||||
|
## Beziehungen
|
||||||
|
|
||||||
|
- **see-also:** [[Chemenu]]
|
||||||
|
<!-- /wikitool:links -->
|
||||||
+10
-4
@@ -13,13 +13,13 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
|||||||
|
|
||||||
## Statistics
|
## Statistics
|
||||||
|
|
||||||
- **Total Pages:** 182
|
- **Total Pages:** 184
|
||||||
- **Comparisons:** 1
|
- **Comparisons:** 1
|
||||||
- **Concepts:** 80
|
- **Concepts:** 80
|
||||||
- **Entities:** 72
|
- **Entities:** 72
|
||||||
- **Gtd:** 0
|
- **Gtd:** 2
|
||||||
- **Sources:** 29
|
- **Sources:** 29
|
||||||
- **Last Updated:** 2026-09-26
|
- **Last Updated:** 2026-09-30
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
|||||||
| `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) |
|
| `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) |
|
||||||
| `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) |
|
| `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) |
|
||||||
| `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) |
|
| `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) |
|
||||||
| `gtd/` | 0 | [gtd/INDEX.md](gtd/INDEX.md) |
|
| `gtd/` | 2 | [gtd/INDEX.md](gtd/INDEX.md) |
|
||||||
| `sources/` | 29 | [sources/INDEX.md](sources/INDEX.md) |
|
| `sources/` | 29 | [sources/INDEX.md](sources/INDEX.md) |
|
||||||
|
|
||||||
### concepts/
|
### concepts/
|
||||||
@@ -54,6 +54,12 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
|||||||
| Technologien | 20 | [entities/INDEX.md#technologien](entities/INDEX.md#technologien) |
|
| Technologien | 20 | [entities/INDEX.md#technologien](entities/INDEX.md#technologien) |
|
||||||
| Werkzeuge | 31 | [entities/INDEX.md#werkzeuge](entities/INDEX.md#werkzeuge) |
|
| Werkzeuge | 31 | [entities/INDEX.md#werkzeuge](entities/INDEX.md#werkzeuge) |
|
||||||
|
|
||||||
|
### gtd/
|
||||||
|
|
||||||
|
| Area | Pages | Index |
|
||||||
|
|------|------:|-------|
|
||||||
|
| Technik | 2 | [gtd/INDEX.md#technik](gtd/INDEX.md#technik) |
|
||||||
|
|
||||||
### sources/
|
### sources/
|
||||||
|
|
||||||
| Area | Pages | Index |
|
| Area | Pages | Index |
|
||||||
|
|||||||
+13
-2
@@ -254,6 +254,7 @@ Create one open item in the configured task tracker - never a kb/ page.
|
|||||||
|
|
||||||
- 0 success
|
- 0 success
|
||||||
- 1 No `.wikitool-tasks.json` - no tracker configured
|
- 1 No `.wikitool-tasks.json` - no tracker configured
|
||||||
|
- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken
|
||||||
- 1 Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD`
|
- 1 Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD`
|
||||||
- 1 A `--project` name matching no tracker project
|
- 1 A `--project` name matching no tracker project
|
||||||
- 1 `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist)
|
- 1 `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist)
|
||||||
@@ -262,6 +263,7 @@ Create one open item in the configured task tracker - never a kb/ page.
|
|||||||
**ON FAILURE**
|
**ON FAILURE**
|
||||||
|
|
||||||
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first
|
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first
|
||||||
|
- `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken -> Not transient - fix the path or unset the variable
|
||||||
- Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD` -> Fix the argument and retry once
|
- Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD` -> Fix the argument and retry once
|
||||||
- A `--project` name matching no tracker project -> Create the tracker project first, or fix the name, then retry once
|
- A `--project` name matching no tracker project -> Create the tracker project first, or fix the name, then retry once
|
||||||
- `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist) -> Create the tag in the tracker first, then retry once
|
- `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist) -> Create the tag in the tracker first, then retry once
|
||||||
@@ -279,6 +281,7 @@ Create one open item in the configured task tracker - never a kb/ page.
|
|||||||
- `--inbox` files into the tracker's own inbox. An item filed there never appears in `review`, since every one of its checks reaches items through a project name.
|
- `--inbox` files into the tracker's own inbox. An item filed there never appears in `review`, since every one of its checks reaches items through a project name.
|
||||||
- `--waiting` sets the WAITING status `review`'s waiting-overdue check reads. `--follow-up-at` is refused without `--waiting` - it is never a due date on its own.
|
- `--waiting` sets the WAITING status `review`'s waiting-overdue check reads. `--follow-up-at` is refused without `--waiting` - it is never a due date on its own.
|
||||||
- `--notes` carries a freetext backref (e.g. to the `kb/` source page the item came from), stored verbatim, never parsed.
|
- `--notes` carries a freetext backref (e.g. to the `kb/` source page the item came from), stored verbatim, never parsed.
|
||||||
|
- The tracker configuration is read from `.wikitool-tasks.json`, or from the file `WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be run against several trackers in turn). A set variable that names no file is an error, never "no tracker configured".
|
||||||
- No `.wikitool-tasks.json` fails immediately with a "no tracker configured" message.
|
- No `.wikitool-tasks.json` fails immediately with a "no tracker configured" message.
|
||||||
- A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses entirely with exit 1, naming the `access: "api"` instance to use instead.
|
- A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses entirely with exit 1, naming the `access: "api"` instance to use instead.
|
||||||
- **Never exits 42.** A `--project` matching no tracker project, or `--waiting` against a provider that cannot represent it right now (Super Productivity: the `waiting` tag does not exist yet, and its API cannot create tags), are ordinary exit-1 refusals that create nothing.
|
- **Never exits 42.** A `--project` matching no tracker project, or `--waiting` against a provider that cannot represent it right now (Super Productivity: the `waiting` tag does not exist yet, and its API cannot create tags), are ordinary exit-1 refusals that create nothing.
|
||||||
@@ -316,10 +319,12 @@ List a project's open items - id, title, and whether each carries the WAITING st
|
|||||||
- 0 success
|
- 0 success
|
||||||
- 0 A `--project` matching no tracker project - prints "No open items", not an error
|
- 0 A `--project` matching no tracker project - prints "No open items", not an error
|
||||||
- 1 No `.wikitool-tasks.json` - no tracker configured
|
- 1 No `.wikitool-tasks.json` - no tracker configured
|
||||||
|
- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken
|
||||||
|
|
||||||
**ON FAILURE**
|
**ON FAILURE**
|
||||||
|
|
||||||
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first, then retry once
|
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first, then retry once
|
||||||
|
- `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken -> Not transient - fix the path or unset the variable
|
||||||
|
|
||||||
**NOTES**
|
**NOTES**
|
||||||
|
|
||||||
@@ -358,12 +363,14 @@ Mark one tracker item done - never delete it.
|
|||||||
|
|
||||||
- 0 success
|
- 0 success
|
||||||
- 1 No `.wikitool-tasks.json` - no tracker configured
|
- 1 No `.wikitool-tasks.json` - no tracker configured
|
||||||
|
- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken
|
||||||
- 1 An `--id` matching no tracker item right now
|
- 1 An `--id` matching no tracker item right now
|
||||||
- 1 A read-only access path (Super Productivity's `access: "snapshot"`)
|
- 1 A read-only access path (Super Productivity's `access: "snapshot"`)
|
||||||
|
|
||||||
**ON FAILURE**
|
**ON FAILURE**
|
||||||
|
|
||||||
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first
|
- No `.wikitool-tasks.json` - no tracker configured -> Not transient - configure a tracker first
|
||||||
|
- `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken -> Not transient - fix the path or unset the variable
|
||||||
- An `--id` matching no tracker item right now -> Get a current id from `task list` or `review`, then retry once
|
- An `--id` matching no tracker item right now -> Get a current id from `task list` or `review`, then retry once
|
||||||
- A read-only access path (Super Productivity's `access: "snapshot"`) -> Point at the `access: "api"` instance the error names, then retry once
|
- A read-only access path (Super Productivity's `access: "snapshot"`) -> Point at the `access: "api"` instance the error names, then retry once
|
||||||
|
|
||||||
@@ -375,6 +382,7 @@ Mark one tracker item done - never delete it.
|
|||||||
|
|
||||||
- Marks one tracker item done. It never deletes or moves an item - the only closing write this stack makes.
|
- Marks one tracker item done. It never deletes or moves an item - the only closing write this stack makes.
|
||||||
- `--id` is the provider's own item id, from `task list` or a `review` finding - never a title.
|
- `--id` is the provider's own item id, from `task list` or a `review` finding - never a title.
|
||||||
|
- The tracker configuration is read from `.wikitool-tasks.json`, or from the file `WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be run against several trackers in turn). A set variable that names no file is an error, never "no tracker configured".
|
||||||
- No `.wikitool-tasks.json` fails with a "no tracker configured" message.
|
- No `.wikitool-tasks.json` fails with a "no tracker configured" message.
|
||||||
- A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses entirely with exit 1, naming the `access: "api"` instance to use instead.
|
- A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses entirely with exit 1, naming the `access: "api"` instance to use instead.
|
||||||
- **Never exits 42.**
|
- **Never exits 42.**
|
||||||
@@ -1204,11 +1212,13 @@ The GTD weekly review.
|
|||||||
|
|
||||||
- 0 success
|
- 0 success
|
||||||
- 1 No `.wikitool-tasks.json`, or a malformed one - a clear "no tracker configured" message
|
- 1 No `.wikitool-tasks.json`, or a malformed one - a clear "no tracker configured" message
|
||||||
|
- 1 `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken
|
||||||
- 1 The provider was reachable at config-parse time but a read call failed mid-run; the full report (findings plus which checks ran) was printed first
|
- 1 The provider was reachable at config-parse time but a read call failed mid-run; the full report (findings plus which checks ran) was printed first
|
||||||
|
|
||||||
**ON FAILURE**
|
**ON FAILURE**
|
||||||
|
|
||||||
- No `.wikitool-tasks.json`, or a malformed one - a clear "no tracker configured" message -> Not fixed by retrying unchanged - configure or repair `.wikitool-tasks.json` first
|
- No `.wikitool-tasks.json`, or a malformed one - a clear "no tracker configured" message -> Not fixed by retrying unchanged - configure or repair `.wikitool-tasks.json` first
|
||||||
|
- `WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken -> Not transient - fix the path or unset the variable
|
||||||
- The provider was reachable at config-parse time but a read call failed mid-run; the full report (findings plus which checks ran) was printed first -> Start the unreachable provider (e.g. the tracker app), then retry plainly
|
- The provider was reachable at config-parse time but a read call failed mid-run; the full report (findings plus which checks ran) was printed first -> Start the unreachable provider (e.g. the tracker app), then retry plainly
|
||||||
|
|
||||||
**NEVER**
|
**NEVER**
|
||||||
@@ -1224,7 +1234,8 @@ The GTD weekly review.
|
|||||||
- **no-open-loop**: a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of unpaged-project, so a rename on either side surfaces on both.
|
- **no-open-loop**: a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of unpaged-project, so a rename on either side surfaces on both.
|
||||||
- **someday-stale**: a someday/maybe item untouched for longer than `thresholds.someday_stale_months`.
|
- **someday-stale**: a someday/maybe item untouched for longer than `thresholds.someday_stale_months`.
|
||||||
- A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a tracker project with no determinable creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip.
|
- A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a tracker project with no determinable creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip.
|
||||||
- Thresholds come from `.wikitool-tasks.json`, never from the schema.
|
- Thresholds come from the tracker configuration, never from the schema.
|
||||||
|
- The tracker configuration is read from `.wikitool-tasks.json`, or from the file `WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be run against several trackers in turn). A set variable that names no file is an error, never "no tracker configured".
|
||||||
- Text output is one `[check] project: message` line per finding, preceded by a `Source:` line naming which access path answered and, for `superproductivity`'s `access: "snapshot"`, the snapshot's age. `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`).
|
- Text output is one `[check] project: message` line per finding, preceded by a `Source:` line naming which access path answered and, for `superproductivity`'s `access: "snapshot"`, the snapshot's age. `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`).
|
||||||
- A provider that cannot be reached mid-run degrades only the checks that needed the failing call; the report is printed in full, then exit 1 follows - never rendered as if it were complete.
|
- A provider that cannot be reached mid-run degrades only the checks that needed the failing call; the report is printed in full, then exit 1 follows - never rendered as if it were complete.
|
||||||
- Re-reads everything fresh on every call, so nothing is ever stale to re-fetch.
|
- Re-reads everything fresh on every call, so nothing is ever stale to re-fetch.
|
||||||
@@ -3205,7 +3216,7 @@ Check that this instance is correctly configured.
|
|||||||
- KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three.
|
- KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three.
|
||||||
- Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`.
|
- Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`.
|
||||||
- MCP `submit` tool: whether `.wikitool-upload.json` is present, absent or malformed, its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means the write path does not exist at all; malformed is a `FAIL`.
|
- MCP `submit` tool: whether `.wikitool-upload.json` is present, absent or malformed, its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means the write path does not exist at all; malformed is a `FAIL`.
|
||||||
- Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`.
|
- Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` (no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, that file is read instead and the finding names it; a set variable that names no file is a `FAIL`, never `OK`.
|
||||||
- For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`.
|
- For a configured `superproductivity` provider, the configured `access` path's own state: `access: "api"` reports whether its local REST API answers `GET /health` with a ready renderer right now, `access: "snapshot"` whether a backup file is ready. The other access path is never attempted, and neither state is ever a `FAIL`.
|
||||||
- For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is.
|
- For a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - never a `FAIL`; only a broken config block is.
|
||||||
- Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback.
|
- Session id source: `OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback.
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ REQUIRED_IGNORE_CANARIES = (
|
|||||||
# The task-tracker provider opt-in (Gitea #124) - same shape again:
|
# The task-tracker provider opt-in (Gitea #124) - same shape again:
|
||||||
# per-checkout, never committed, once a credential lands in it.
|
# per-checkout, never committed, once a credential lands in it.
|
||||||
".wikitool-tasks.json",
|
".wikitool-tasks.json",
|
||||||
|
# The live suite's tracker profiles (Gitea #156) - credentials again, one file per tracker.
|
||||||
|
".wikitool-tasks.d/probe.json",
|
||||||
)
|
)
|
||||||
REQUIRED_TRACKED_PATHS = (
|
REQUIRED_TRACKED_PATHS = (
|
||||||
"reports/CONTRACT.md",
|
"reports/CONTRACT.md",
|
||||||
|
|||||||
@@ -443,8 +443,8 @@ def check_tasks_provider() -> Check:
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
return Check(
|
return Check(
|
||||||
"tasks-provider", "FAIL", str(exc),
|
"tasks-provider", "FAIL", str(exc),
|
||||||
f"Fix or delete {config.TASKS_CONFIG_FILENAME} - a broken one is not treated as "
|
f"Fix or delete {config.tasks_config_label(config.ROOT)} - a broken one is not "
|
||||||
"'no tracker configured'",
|
"treated as 'no tracker configured'",
|
||||||
)
|
)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
return Check(
|
return Check(
|
||||||
@@ -453,6 +453,9 @@ def check_tasks_provider() -> Check:
|
|||||||
"review needs one, everything else does not)",
|
"review needs one, everything else does not)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override = config.tasks_config_override()
|
||||||
|
via = f" [config: {override} via {config.ENV_TASKS_CONFIG}]" if override is not None else ""
|
||||||
|
|
||||||
if cfg.provider == "superproductivity":
|
if cfg.provider == "superproductivity":
|
||||||
from chemenu.tasks import superproductivity as sp
|
from chemenu.tasks import superproductivity as sp
|
||||||
|
|
||||||
@@ -461,14 +464,14 @@ def check_tasks_provider() -> Check:
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
return Check(
|
return Check(
|
||||||
"tasks-provider", "FAIL", str(exc),
|
"tasks-provider", "FAIL", str(exc),
|
||||||
f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}",
|
f"Fix the 'superproductivity' section of {config.tasks_config_label(config.ROOT)}",
|
||||||
)
|
)
|
||||||
# Only the configured access path is a finding (Gitea #133) - the
|
# Only the configured access path is a finding (Gitea #133) - the
|
||||||
# other one is not attempted at all, so it has nothing to report.
|
# other one is not attempted at all, so it has nothing to report.
|
||||||
if sp_cfg.access == sp.ACCESS_API:
|
if sp_cfg.access == sp.ACCESS_API:
|
||||||
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
|
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
|
||||||
return Check(
|
return Check(
|
||||||
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}",
|
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}{via}",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
snapshot_path = sp.latest_snapshot_path(sp_cfg)
|
snapshot_path = sp.latest_snapshot_path(sp_cfg)
|
||||||
@@ -476,7 +479,7 @@ def check_tasks_provider() -> Check:
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
read_state = f"read path not ready ({exc})"
|
read_state = f"read path not ready ({exc})"
|
||||||
return Check(
|
return Check(
|
||||||
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}",
|
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}{via}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if cfg.provider == "caldav":
|
if cfg.provider == "caldav":
|
||||||
@@ -487,12 +490,12 @@ def check_tasks_provider() -> Check:
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
return Check(
|
return Check(
|
||||||
"tasks-provider", "FAIL", str(exc),
|
"tasks-provider", "FAIL", str(exc),
|
||||||
f"Fix the 'caldav' section of {config.TASKS_CONFIG_FILENAME}",
|
f"Fix the 'caldav' section of {config.tasks_config_label(config.ROOT)}",
|
||||||
)
|
)
|
||||||
state = cd.probe(cd_cfg)
|
state = cd.probe(cd_cfg)
|
||||||
return Check("tasks-provider", "OK", f"caldav: {state}")
|
return Check("tasks-provider", "OK", f"caldav: {state}{via}")
|
||||||
|
|
||||||
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured")
|
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured{via}")
|
||||||
|
|
||||||
|
|
||||||
def check_session_id() -> Check:
|
def check_session_id() -> Check:
|
||||||
@@ -642,7 +645,9 @@ def run_doctor() -> list[Check]:
|
|||||||
"its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means "
|
"its limits, and how many submissions wait in `mcp-upload/`. Absent is `OK` and means "
|
||||||
"the write path does not exist at all; malformed is a `FAIL`.",
|
"the write path does not exist at all; malformed is a `FAIL`.",
|
||||||
"Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` "
|
"Task tracker: `.wikitool-tasks.json` present, absent or malformed - absent is `OK` "
|
||||||
"(no tracker configured), malformed is a `FAIL`.",
|
"(no tracker configured), malformed is a `FAIL`. When `WIKITOOL_TASKS_CONFIG` is set, "
|
||||||
|
"that file is read instead and the finding names it; a set variable that names no file "
|
||||||
|
"is a `FAIL`, never `OK`.",
|
||||||
"For a configured `superproductivity` provider, the configured `access` path's own "
|
"For a configured `superproductivity` provider, the configured `access` path's own "
|
||||||
"state: `access: \"api\"` reports whether its local REST API answers `GET /health` "
|
"state: `access: \"api\"` reports whether its local REST API answers `GET /health` "
|
||||||
"with a ready renderer right now, `access: \"snapshot\"` whether a backup file is ready. The other access "
|
"with a ready renderer right now, `access: \"snapshot\"` whether a backup file is ready. The other access "
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ def report_to_dict(report: ReviewReport) -> dict:
|
|||||||
"A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a "
|
"A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at`, a "
|
||||||
"tracker project with no determinable creation date - is its own finding "
|
"tracker project with no determinable creation date - is its own finding "
|
||||||
"(`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip.",
|
"(`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip.",
|
||||||
"Thresholds come from `.wikitool-tasks.json`, never from the schema.",
|
"Thresholds come from the tracker configuration, never from the schema.",
|
||||||
|
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
|
||||||
|
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
|
||||||
|
"run against several trackers in turn). A set variable that names no file is an "
|
||||||
|
"error, never \"no tracker configured\".",
|
||||||
"Text output is one `[check] project: message` line per finding, preceded by a "
|
"Text output is one `[check] project: message` line per finding, preceded by a "
|
||||||
"`Source:` line naming which access path answered and, for `superproductivity`'s "
|
"`Source:` line naming which access path answered and, for `superproductivity`'s "
|
||||||
"`access: \"snapshot\"`, the snapshot's age. `--json` carries the same findings plus "
|
"`access: \"snapshot\"`, the snapshot's age. `--json` carries the same findings plus "
|
||||||
@@ -123,6 +127,10 @@ def report_to_dict(report: ReviewReport) -> dict:
|
|||||||
reaction="Not fixed by retrying unchanged - configure or repair "
|
reaction="Not fixed by retrying unchanged - configure or repair "
|
||||||
"`.wikitool-tasks.json` first",
|
"`.wikitool-tasks.json` first",
|
||||||
),
|
),
|
||||||
|
cli_contract.Failure(
|
||||||
|
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
|
||||||
|
reaction="Not transient - fix the path or unset the variable",
|
||||||
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
cause="The provider was reachable at config-parse time but a read call failed "
|
cause="The provider was reachable at config-parse time but a read call failed "
|
||||||
"mid-run; the full report (findings plus which checks ran) was printed first",
|
"mid-run; the full report (findings plus which checks ran) was printed first",
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ def _parse_follow_up_at(text: str) -> datetime.date:
|
|||||||
fail(f"--follow-up-at {text!r} must be YYYY-MM-DD.")
|
fail(f"--follow-up-at {text!r} must be YYYY-MM-DD.")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_config():
|
||||||
|
try:
|
||||||
|
return tasks_config.read_config(config.ROOT)
|
||||||
|
except ValidationError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
|
||||||
|
|
||||||
@app.command("new")
|
@app.command("new")
|
||||||
@cli_contract.record(cli_contract.CommandRecord(
|
@cli_contract.record(cli_contract.CommandRecord(
|
||||||
path="task new",
|
path="task new",
|
||||||
@@ -87,6 +94,10 @@ def _parse_follow_up_at(text: str) -> datetime.date:
|
|||||||
"`--follow-up-at` is refused without `--waiting` - it is never a due date on its own.",
|
"`--follow-up-at` is refused without `--waiting` - it is never a due date on its own.",
|
||||||
"`--notes` carries a freetext backref (e.g. to the `kb/` source page the item came "
|
"`--notes` carries a freetext backref (e.g. to the `kb/` source page the item came "
|
||||||
"from), stored verbatim, never parsed.",
|
"from), stored verbatim, never parsed.",
|
||||||
|
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
|
||||||
|
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
|
||||||
|
"run against several trackers in turn). A set variable that names no file is an "
|
||||||
|
"error, never \"no tracker configured\".",
|
||||||
"No `.wikitool-tasks.json` fails immediately with a \"no tracker configured\" message.",
|
"No `.wikitool-tasks.json` fails immediately with a \"no tracker configured\" message.",
|
||||||
"A provider whose configured access path has no write path (Super Productivity's "
|
"A provider whose configured access path has no write path (Super Productivity's "
|
||||||
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
|
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
|
||||||
@@ -102,6 +113,10 @@ def _parse_follow_up_at(text: str) -> datetime.date:
|
|||||||
cause="No `.wikitool-tasks.json` - no tracker configured",
|
cause="No `.wikitool-tasks.json` - no tracker configured",
|
||||||
reaction="Not transient - configure a tracker first",
|
reaction="Not transient - configure a tracker first",
|
||||||
),
|
),
|
||||||
|
cli_contract.Failure(
|
||||||
|
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
|
||||||
|
reaction="Not transient - fix the path or unset the variable",
|
||||||
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
cause="Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without "
|
cause="Neither or both of `--project`/`--inbox`, or a `--follow-up-at` without "
|
||||||
"`--waiting` or not `YYYY-MM-DD`",
|
"`--waiting` or not `YYYY-MM-DD`",
|
||||||
@@ -191,7 +206,7 @@ def task_new_command(
|
|||||||
)
|
)
|
||||||
follow_up_date = _parse_follow_up_at(follow_up_at) if follow_up_at is not None else None
|
follow_up_date = _parse_follow_up_at(follow_up_at) if follow_up_at is not None else None
|
||||||
|
|
||||||
cfg = tasks_config.read_config(config.ROOT)
|
cfg = _read_config()
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
fail(
|
fail(
|
||||||
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
|
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
|
||||||
@@ -252,6 +267,10 @@ def task_new_command(
|
|||||||
cause="No `.wikitool-tasks.json` - no tracker configured",
|
cause="No `.wikitool-tasks.json` - no tracker configured",
|
||||||
reaction="Not transient - configure a tracker first, then retry once",
|
reaction="Not transient - configure a tracker first, then retry once",
|
||||||
),
|
),
|
||||||
|
cli_contract.Failure(
|
||||||
|
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
|
||||||
|
reaction="Not transient - fix the path or unset the variable",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
examples=(
|
examples=(
|
||||||
'tools/wikitool task list --project "Homelab migration"',
|
'tools/wikitool task list --project "Homelab migration"',
|
||||||
@@ -271,7 +290,7 @@ def task_list_command(
|
|||||||
So a caller can get an item's id for `task close` without first running
|
So a caller can get an item's id for `task close` without first running
|
||||||
`wikitool review` (Gitea #138). Read-only; works against either access
|
`wikitool review` (Gitea #138). Read-only; works against either access
|
||||||
mode a provider offers."""
|
mode a provider offers."""
|
||||||
cfg = tasks_config.read_config(config.ROOT)
|
cfg = _read_config()
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
fail(
|
fail(
|
||||||
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
|
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
|
||||||
@@ -311,6 +330,10 @@ def task_list_command(
|
|||||||
"write this stack makes.",
|
"write this stack makes.",
|
||||||
"`--id` is the provider's own item id, from `task list` or a `review` finding - never a "
|
"`--id` is the provider's own item id, from `task list` or a `review` finding - never a "
|
||||||
"title.",
|
"title.",
|
||||||
|
"The tracker configuration is read from `.wikitool-tasks.json`, or from the file "
|
||||||
|
"`WIKITOOL_TASKS_CONFIG` names when that variable is set (so one checkout can be "
|
||||||
|
"run against several trackers in turn). A set variable that names no file is an "
|
||||||
|
"error, never \"no tracker configured\".",
|
||||||
"No `.wikitool-tasks.json` fails with a \"no tracker configured\" message.",
|
"No `.wikitool-tasks.json` fails with a \"no tracker configured\" message.",
|
||||||
"A provider whose configured access path has no write path (Super Productivity's "
|
"A provider whose configured access path has no write path (Super Productivity's "
|
||||||
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
|
"`access: \"snapshot\"`) refuses entirely with exit 1, naming the `access: \"api\"` "
|
||||||
@@ -322,6 +345,10 @@ def task_list_command(
|
|||||||
cause="No `.wikitool-tasks.json` - no tracker configured",
|
cause="No `.wikitool-tasks.json` - no tracker configured",
|
||||||
reaction="Not transient - configure a tracker first",
|
reaction="Not transient - configure a tracker first",
|
||||||
),
|
),
|
||||||
|
cli_contract.Failure(
|
||||||
|
cause="`WIKITOOL_TASKS_CONFIG` names a file that does not exist or is broken",
|
||||||
|
reaction="Not transient - fix the path or unset the variable",
|
||||||
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
cause="An `--id` matching no tracker item right now",
|
cause="An `--id` matching no tracker item right now",
|
||||||
reaction="Get a current id from `task list` or `review`, then retry once",
|
reaction="Get a current id from `task list` or `review`, then retry once",
|
||||||
@@ -355,7 +382,7 @@ def task_close_command(
|
|||||||
The only closing write this stack makes (Gitea #138); see
|
The only closing write this stack makes (Gitea #138); see
|
||||||
`chemenu.tasks.protocol.TaskWriter.close_item` and
|
`chemenu.tasks.protocol.TaskWriter.close_item` and
|
||||||
`docs/knowledge-and-commitment.md` for why."""
|
`docs/knowledge-and-commitment.md` for why."""
|
||||||
cfg = tasks_config.read_config(config.ROOT)
|
cfg = _read_config()
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
fail(
|
fail(
|
||||||
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
|
f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is "
|
||||||
|
|||||||
@@ -257,6 +257,35 @@ UPLOAD_CONFIG_FILENAME = ".wikitool-upload.json"
|
|||||||
# else just fine, it only can't run the weekly review (#125, not yet built).
|
# else just fine, it only can't run the weekly review (#125, not yet built).
|
||||||
TASKS_CONFIG_FILENAME = ".wikitool-tasks.json"
|
TASKS_CONFIG_FILENAME = ".wikitool-tasks.json"
|
||||||
|
|
||||||
|
# Reads a tracker configuration from another file than `<root>/.wikitool-tasks.json`
|
||||||
|
# (Gitea #156), so one checkout can be run against several trackers one after the
|
||||||
|
# other without swapping a file. Registered in `_WIKITOOL_ENV`
|
||||||
|
# (tools/chemenu/tests/conftest.py). A relative value is taken from the working
|
||||||
|
# directory. Set but pointing at nothing is an error, never "no tracker".
|
||||||
|
ENV_TASKS_CONFIG = "WIKITOOL_TASKS_CONFIG"
|
||||||
|
|
||||||
|
|
||||||
|
def tasks_config_override() -> "Path | None":
|
||||||
|
"""The file `$WIKITOOL_TASKS_CONFIG` names, or `None` when it is unset or blank."""
|
||||||
|
value = os.environ.get(ENV_TASKS_CONFIG, "").strip()
|
||||||
|
return Path(value).expanduser().resolve() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def tasks_config_path(root: "Path | str") -> Path:
|
||||||
|
"""The tracker configuration file this process reads: the override when set,
|
||||||
|
else `<root>/.wikitool-tasks.json`."""
|
||||||
|
override = tasks_config_override()
|
||||||
|
return override if override is not None else Path(root) / TASKS_CONFIG_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def tasks_config_label(root: "Path | str") -> str:
|
||||||
|
"""How a message names the file `tasks_config_path` reads - the bare file
|
||||||
|
name by default, the actual path and the variable when the override is on."""
|
||||||
|
override = tasks_config_override()
|
||||||
|
if override is None:
|
||||||
|
return TASKS_CONFIG_FILENAME
|
||||||
|
return f"{override} (from {ENV_TASKS_CONFIG})"
|
||||||
|
|
||||||
|
|
||||||
def default_author() -> str | None:
|
def default_author() -> str | None:
|
||||||
"""The author to stamp a new source page with, per instance.
|
"""The author to stamp a new source page with, per instance.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Reads `.wikitool-tasks.json` (Gitea #124) - `config.TASKS_CONFIG_FILENAME`.
|
"""Reads `.wikitool-tasks.json` (Gitea #124) - `config.TASKS_CONFIG_FILENAME`, or the
|
||||||
|
file `$WIKITOOL_TASKS_CONFIG` names instead (Gitea #156, `config.tasks_config_path`).
|
||||||
|
|
||||||
Same posture as `chemenu.upload.read_config`: absent means "no tracker
|
Same posture as `chemenu.upload.read_config`: absent means "no tracker
|
||||||
configured for this instance", a legitimate state that `doctor` reports as OK,
|
configured for this instance", a legitimate state that `doctor` reports as OK,
|
||||||
@@ -46,22 +47,28 @@ def read_config(root: "Any") -> "TasksConfig | None":
|
|||||||
"""The tracker configuration for `root`, or `None` when the file is
|
"""The tracker configuration for `root`, or `None` when the file is
|
||||||
absent - which means no tracker is configured for this instance, not that
|
absent - which means no tracker is configured for this instance, not that
|
||||||
one failed to load."""
|
one failed to load."""
|
||||||
from pathlib import Path
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
path = Path(root) / config.TASKS_CONFIG_FILENAME
|
path = config.tasks_config_path(root)
|
||||||
|
label = config.tasks_config_label(root)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
|
if config.tasks_config_override() is not None:
|
||||||
|
raise ValidationError(
|
||||||
|
f"{config.ENV_TASKS_CONFIG} points at {path}, which is not a file. An override "
|
||||||
|
"that names nothing is never read as 'no tracker configured' - fix the path or "
|
||||||
|
"unset the variable."
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{config.TASKS_CONFIG_FILENAME} is unreadable ({exc}). It decides which task "
|
f"{label} is unreadable ({exc}). It decides which task "
|
||||||
"tracker the weekly review talks to, so a broken file is not treated as 'no "
|
"tracker the weekly review talks to, so a broken file is not treated as 'no "
|
||||||
"tracker configured' - fix it or delete it deliberately."
|
"tracker configured' - fix it or delete it deliberately."
|
||||||
) from exc
|
) from exc
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValidationError(f"{config.TASKS_CONFIG_FILENAME} must contain a JSON object.")
|
raise ValidationError(f"{label} must contain a JSON object.")
|
||||||
|
|
||||||
expected = (
|
expected = (
|
||||||
'{"schema": 1, "provider": "superproductivity", '
|
'{"schema": 1, "provider": "superproductivity", '
|
||||||
@@ -78,13 +85,13 @@ def read_config(root: "Any") -> "TasksConfig | None":
|
|||||||
someday_stale_months = int(raw_thresholds["someday_stale_months"])
|
someday_stale_months = int(raw_thresholds["someday_stale_months"])
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{config.TASKS_CONFIG_FILENAME} is missing or misshapes a required field ({exc}). "
|
f"{label} is missing or misshapes a required field ({exc}). "
|
||||||
f"Expected: {expected}"
|
f"Expected: {expected}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
if provider not in KNOWN_PROVIDERS:
|
if provider not in KNOWN_PROVIDERS:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{config.TASKS_CONFIG_FILENAME}: unknown provider {provider!r}. "
|
f"{label}: unknown provider {provider!r}. "
|
||||||
f"Known: {', '.join(KNOWN_PROVIDERS)}"
|
f"Known: {', '.join(KNOWN_PROVIDERS)}"
|
||||||
)
|
)
|
||||||
for field_name, value in (
|
for field_name, value in (
|
||||||
@@ -94,13 +101,13 @@ def read_config(root: "Any") -> "TasksConfig | None":
|
|||||||
):
|
):
|
||||||
if value <= 0:
|
if value <= 0:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{config.TASKS_CONFIG_FILENAME}: thresholds.{field_name} must be positive."
|
f"{label}: thresholds.{field_name} must be positive."
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_config = data.get(provider)
|
provider_config = data.get(provider)
|
||||||
if not isinstance(provider_config, dict):
|
if not isinstance(provider_config, dict):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{config.TASKS_CONFIG_FILENAME}: missing or non-object {provider!r} section "
|
f"{label}: missing or non-object {provider!r} section "
|
||||||
f"holding that provider's own connection settings. Expected: {expected}"
|
f"holding that provider's own connection settings. Expected: {expected}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ _WIKITOOL_ENV = (
|
|||||||
"WIKITOOL_UPDATE_URL",
|
"WIKITOOL_UPDATE_URL",
|
||||||
"WIKITOOL_UPDATE_TOKEN",
|
"WIKITOOL_UPDATE_TOKEN",
|
||||||
"CHEMENU_ROOT",
|
"CHEMENU_ROOT",
|
||||||
|
"WIKITOOL_TASKS_CONFIG",
|
||||||
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||||
|
|
||||||
# Environment git reads for identity or for where its repo lives. A stray
|
# Environment git reads for identity or for where its repo lives. A stray
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"sp_version": "19.1.0",
|
||||||
|
"recorded": "2026-09-30",
|
||||||
|
"how": "Packaged Super Productivity (.deb) headless in the chemenu-sp-live image, seeded from seed-backup.json; api/*.json are the raw answers of GET /health, /projects, /tasks and /tags after creating four items and closing one through wikitool's own writer.",
|
||||||
|
"seed-backup.json": "A backup the app wrote itself, plus three projects ('Chemenu Live-Test', 'Chemenu 8.0.0 freigeben', 'Windows nativ unterstützen' as demo-2) and the tag 'waiting', cloned from entities the app produced.",
|
||||||
|
"refresh": "See instructions/dev/tracker-testing.md, section Refreshing the fixtures."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"rendererReady": true,
|
||||||
|
"server": "up"
|
||||||
|
},
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"advancedCfg": {
|
||||||
|
"worklogExportSettings": {
|
||||||
|
"cols": [
|
||||||
|
"DATE",
|
||||||
|
"START",
|
||||||
|
"END",
|
||||||
|
"TIME_CLOCK",
|
||||||
|
"TITLES_INCLUDING_SUB"
|
||||||
|
],
|
||||||
|
"groupBy": "DATE",
|
||||||
|
"roundEndTimeTo": null,
|
||||||
|
"roundStartTimeTo": null,
|
||||||
|
"roundWorkTimeTo": null,
|
||||||
|
"separateTasksBy": " | "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backlogTaskIds": [],
|
||||||
|
"doneOn": null,
|
||||||
|
"icon": "inbox",
|
||||||
|
"id": "INBOX_PROJECT",
|
||||||
|
"isArchived": false,
|
||||||
|
"isDone": false,
|
||||||
|
"isEnableBacklog": false,
|
||||||
|
"isHiddenFromMenu": false,
|
||||||
|
"noteIds": [],
|
||||||
|
"taskIds": [
|
||||||
|
"cbN7F3EMo67k9RrfMjS26",
|
||||||
|
"KfvaOD_bl7mIn-Fq0W9NE"
|
||||||
|
],
|
||||||
|
"theme": {
|
||||||
|
"accent": "#ff4081",
|
||||||
|
"backgroundImageBlur": 0,
|
||||||
|
"backgroundImageDark": "",
|
||||||
|
"backgroundImageLight": null,
|
||||||
|
"backgroundOverlayOpacity": 20,
|
||||||
|
"hueAccent": "500",
|
||||||
|
"huePrimary": "500",
|
||||||
|
"hueWarn": "500",
|
||||||
|
"isAutoContrast": true,
|
||||||
|
"isDisableBackgroundTint": false,
|
||||||
|
"primary": "rgb(144, 187, 165)",
|
||||||
|
"warn": "#e11826"
|
||||||
|
},
|
||||||
|
"title": "Inbox"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"advancedCfg": {
|
||||||
|
"worklogExportSettings": {
|
||||||
|
"cols": [
|
||||||
|
"DATE",
|
||||||
|
"START",
|
||||||
|
"END",
|
||||||
|
"TIME_CLOCK",
|
||||||
|
"TITLES_INCLUDING_SUB"
|
||||||
|
],
|
||||||
|
"groupBy": "DATE",
|
||||||
|
"roundEndTimeTo": null,
|
||||||
|
"roundStartTimeTo": null,
|
||||||
|
"roundWorkTimeTo": null,
|
||||||
|
"separateTasksBy": " | "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backlogTaskIds": [],
|
||||||
|
"created": 1790745002886,
|
||||||
|
"doneOn": null,
|
||||||
|
"icon": null,
|
||||||
|
"id": "chemenu-live-test",
|
||||||
|
"isArchived": false,
|
||||||
|
"isDone": false,
|
||||||
|
"isEnableBacklog": false,
|
||||||
|
"isHiddenFromMenu": false,
|
||||||
|
"noteIds": [],
|
||||||
|
"taskIds": [
|
||||||
|
"gwedT3TNeJm35AaKMj_Ov",
|
||||||
|
"dCf43Dr1Ei9Gc1DxysY5q",
|
||||||
|
"EBm18ZV1F1LrVV7hsauGu"
|
||||||
|
],
|
||||||
|
"theme": {
|
||||||
|
"accent": "#ff4081",
|
||||||
|
"backgroundImageBlur": 0,
|
||||||
|
"backgroundImageDark": "",
|
||||||
|
"backgroundImageLight": null,
|
||||||
|
"backgroundOverlayOpacity": 20,
|
||||||
|
"hueAccent": "500",
|
||||||
|
"huePrimary": "500",
|
||||||
|
"hueWarn": "500",
|
||||||
|
"isAutoContrast": true,
|
||||||
|
"isDisableBackgroundTint": false,
|
||||||
|
"primary": "rgb(144, 187, 165)",
|
||||||
|
"warn": "#e11826"
|
||||||
|
},
|
||||||
|
"title": "Chemenu Live-Test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"advancedCfg": {
|
||||||
|
"worklogExportSettings": {
|
||||||
|
"cols": [
|
||||||
|
"DATE",
|
||||||
|
"START",
|
||||||
|
"END",
|
||||||
|
"TIME_CLOCK",
|
||||||
|
"TITLES_INCLUDING_SUB"
|
||||||
|
],
|
||||||
|
"groupBy": "DATE",
|
||||||
|
"roundEndTimeTo": null,
|
||||||
|
"roundStartTimeTo": null,
|
||||||
|
"roundWorkTimeTo": null,
|
||||||
|
"separateTasksBy": " | "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backlogTaskIds": [],
|
||||||
|
"created": 1790745002886,
|
||||||
|
"doneOn": null,
|
||||||
|
"icon": null,
|
||||||
|
"id": "demo-1",
|
||||||
|
"isArchived": false,
|
||||||
|
"isDone": false,
|
||||||
|
"isEnableBacklog": false,
|
||||||
|
"isHiddenFromMenu": false,
|
||||||
|
"noteIds": [],
|
||||||
|
"taskIds": [],
|
||||||
|
"theme": {
|
||||||
|
"accent": "#ff4081",
|
||||||
|
"backgroundImageBlur": 0,
|
||||||
|
"backgroundImageDark": "",
|
||||||
|
"backgroundImageLight": null,
|
||||||
|
"backgroundOverlayOpacity": 20,
|
||||||
|
"hueAccent": "500",
|
||||||
|
"huePrimary": "500",
|
||||||
|
"hueWarn": "500",
|
||||||
|
"isAutoContrast": true,
|
||||||
|
"isDisableBackgroundTint": false,
|
||||||
|
"primary": "rgb(144, 187, 165)",
|
||||||
|
"warn": "#e11826"
|
||||||
|
},
|
||||||
|
"title": "Chemenu 8.0.0 freigeben"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"advancedCfg": {
|
||||||
|
"worklogExportSettings": {
|
||||||
|
"cols": [
|
||||||
|
"DATE",
|
||||||
|
"START",
|
||||||
|
"END",
|
||||||
|
"TIME_CLOCK",
|
||||||
|
"TITLES_INCLUDING_SUB"
|
||||||
|
],
|
||||||
|
"groupBy": "DATE",
|
||||||
|
"roundEndTimeTo": null,
|
||||||
|
"roundStartTimeTo": null,
|
||||||
|
"roundWorkTimeTo": null,
|
||||||
|
"separateTasksBy": " | "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backlogTaskIds": [],
|
||||||
|
"created": 1790745002886,
|
||||||
|
"doneOn": null,
|
||||||
|
"icon": null,
|
||||||
|
"id": "demo-2",
|
||||||
|
"isArchived": false,
|
||||||
|
"isDone": false,
|
||||||
|
"isEnableBacklog": false,
|
||||||
|
"isHiddenFromMenu": false,
|
||||||
|
"noteIds": [],
|
||||||
|
"taskIds": [],
|
||||||
|
"theme": {
|
||||||
|
"accent": "#ff4081",
|
||||||
|
"backgroundImageBlur": 0,
|
||||||
|
"backgroundImageDark": "",
|
||||||
|
"backgroundImageLight": null,
|
||||||
|
"backgroundOverlayOpacity": 20,
|
||||||
|
"hueAccent": "500",
|
||||||
|
"huePrimary": "500",
|
||||||
|
"hueWarn": "500",
|
||||||
|
"isAutoContrast": true,
|
||||||
|
"isDisableBackgroundTint": false,
|
||||||
|
"primary": "rgb(144, 187, 165)",
|
||||||
|
"warn": "#e11826"
|
||||||
|
},
|
||||||
|
"title": "Windows nativ unterst\u00fctzen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"advancedCfg": {
|
||||||
|
"worklogExportSettings": {
|
||||||
|
"cols": [
|
||||||
|
"DATE",
|
||||||
|
"START",
|
||||||
|
"END",
|
||||||
|
"TIME_CLOCK",
|
||||||
|
"TITLES_INCLUDING_SUB"
|
||||||
|
],
|
||||||
|
"groupBy": "DATE",
|
||||||
|
"roundEndTimeTo": null,
|
||||||
|
"roundStartTimeTo": null,
|
||||||
|
"roundWorkTimeTo": null,
|
||||||
|
"separateTasksBy": " | "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"color": null,
|
||||||
|
"created": 1790744929943,
|
||||||
|
"icon": "wb_sunny",
|
||||||
|
"id": "TODAY",
|
||||||
|
"taskIds": [
|
||||||
|
"cbN7F3EMo67k9RrfMjS26",
|
||||||
|
"gwedT3TNeJm35AaKMj_Ov",
|
||||||
|
"EBm18ZV1F1LrVV7hsauGu",
|
||||||
|
"KfvaOD_bl7mIn-Fq0W9NE"
|
||||||
|
],
|
||||||
|
"theme": {
|
||||||
|
"accent": "#ff4081",
|
||||||
|
"backgroundImageBlur": 0,
|
||||||
|
"backgroundImageDark": "",
|
||||||
|
"backgroundImageLight": null,
|
||||||
|
"backgroundOverlayOpacity": 20,
|
||||||
|
"hueAccent": "500",
|
||||||
|
"huePrimary": "400",
|
||||||
|
"hueWarn": "500",
|
||||||
|
"isAutoContrast": true,
|
||||||
|
"isDisableBackgroundTint": true,
|
||||||
|
"primary": "#6495ED",
|
||||||
|
"warn": "#e11826"
|
||||||
|
},
|
||||||
|
"title": "Today"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"advancedCfg": {
|
||||||
|
"worklogExportSettings": {
|
||||||
|
"cols": [
|
||||||
|
"DATE",
|
||||||
|
"START",
|
||||||
|
"END",
|
||||||
|
"TIME_CLOCK",
|
||||||
|
"TITLES_INCLUDING_SUB"
|
||||||
|
],
|
||||||
|
"groupBy": "DATE",
|
||||||
|
"roundEndTimeTo": null,
|
||||||
|
"roundStartTimeTo": null,
|
||||||
|
"roundWorkTimeTo": null,
|
||||||
|
"separateTasksBy": " | "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"color": null,
|
||||||
|
"created": 1790745002886,
|
||||||
|
"icon": null,
|
||||||
|
"id": "tag-waiting",
|
||||||
|
"taskIds": [
|
||||||
|
"dCf43Dr1Ei9Gc1DxysY5q"
|
||||||
|
],
|
||||||
|
"theme": {
|
||||||
|
"accent": "#ff4081",
|
||||||
|
"backgroundImageBlur": 0,
|
||||||
|
"backgroundImageDark": "",
|
||||||
|
"backgroundImageLight": null,
|
||||||
|
"backgroundOverlayOpacity": 20,
|
||||||
|
"hueAccent": "500",
|
||||||
|
"huePrimary": "400",
|
||||||
|
"hueWarn": "500",
|
||||||
|
"isAutoContrast": true,
|
||||||
|
"isDisableBackgroundTint": true,
|
||||||
|
"primary": "#6495ED",
|
||||||
|
"warn": "#e11826"
|
||||||
|
},
|
||||||
|
"title": "waiting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"attachments": [],
|
||||||
|
"created": 1790744930824,
|
||||||
|
"dueDay": "2026-09-30",
|
||||||
|
"id": "KfvaOD_bl7mIn-Fq0W9NE",
|
||||||
|
"isDone": false,
|
||||||
|
"projectId": "INBOX_PROJECT",
|
||||||
|
"subTaskIds": [],
|
||||||
|
"tagIds": [],
|
||||||
|
"timeEstimate": 0,
|
||||||
|
"timeSpent": 0,
|
||||||
|
"timeSpentOnDay": {},
|
||||||
|
"title": "seed marker task"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attachments": [],
|
||||||
|
"created": 1790764434509,
|
||||||
|
"dueDay": "2026-09-30",
|
||||||
|
"id": "EBm18ZV1F1LrVV7hsauGu",
|
||||||
|
"isDone": false,
|
||||||
|
"notes": "kb/gtd/technik/Chemenu Live-Test.md",
|
||||||
|
"projectId": "chemenu-live-test",
|
||||||
|
"subTaskIds": [],
|
||||||
|
"tagIds": [],
|
||||||
|
"timeEstimate": 0,
|
||||||
|
"timeSpent": 0,
|
||||||
|
"timeSpentOnDay": {},
|
||||||
|
"title": "Recorded: offene Aufgabe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attachments": [],
|
||||||
|
"created": 1790764434554,
|
||||||
|
"dueDay": "2026-08-01",
|
||||||
|
"id": "dCf43Dr1Ei9Gc1DxysY5q",
|
||||||
|
"isDone": false,
|
||||||
|
"projectId": "chemenu-live-test",
|
||||||
|
"subTaskIds": [],
|
||||||
|
"tagIds": [
|
||||||
|
"tag-waiting"
|
||||||
|
],
|
||||||
|
"timeEstimate": 0,
|
||||||
|
"timeSpent": 0,
|
||||||
|
"timeSpentOnDay": {},
|
||||||
|
"title": "Recorded: warte auf Antwort"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attachments": [],
|
||||||
|
"created": 1790764434974,
|
||||||
|
"dueDay": "2026-09-30",
|
||||||
|
"id": "cbN7F3EMo67k9RrfMjS26",
|
||||||
|
"isDone": false,
|
||||||
|
"projectId": "INBOX_PROJECT",
|
||||||
|
"subTaskIds": [],
|
||||||
|
"tagIds": [],
|
||||||
|
"timeEstimate": 0,
|
||||||
|
"timeSpent": 0,
|
||||||
|
"timeSpentOnDay": {},
|
||||||
|
"title": "Recorded: Posteingang"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"task": {"ids": ["KfvaOD_bl7mIn-Fq0W9NE"], "entities": {"KfvaOD_bl7mIn-Fq0W9NE": {"id": "KfvaOD_bl7mIn-Fq0W9NE", "subTaskIds": [], "timeSpentOnDay": {}, "timeSpent": 0, "timeEstimate": 0, "isDone": false, "title": "seed marker task", "tagIds": [], "created": 1790744930824, "attachments": [], "projectId": "INBOX_PROJECT", "dueDay": "2026-09-30"}}, "currentTaskId": null, "selectedTaskId": null, "taskDetailTargetPanel": null, "lastCurrentTaskId": null, "isDataLoaded": false, "dismissedCalendarAutoImportEventIdsByProvider": {}}, "project": {"ids": ["INBOX_PROJECT", "chemenu-live-test", "demo-1", "demo-2"], "entities": {"INBOX_PROJECT": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": ["KfvaOD_bl7mIn-Fq0W9NE"], "icon": "inbox", "id": "INBOX_PROJECT", "title": "Inbox"}, "chemenu-live-test": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "chemenu-live-test", "title": "Chemenu Live-Test", "created": 1790745002886}, "demo-1": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "demo-1", "title": "Chemenu 8.0.0 freigeben", "created": 1790745002886}, "demo-2": {"isHiddenFromMenu": false, "isArchived": false, "isDone": false, "doneOn": null, "isEnableBacklog": false, "backlogTaskIds": [], "noteIds": [], "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": false, "primary": "rgb(144, 187, 165)", "huePrimary": "500", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": [], "icon": null, "id": "demo-2", "title": "Windows nativ unterst\u00fctzen", "created": 1790745002886}}}, "tag": {"ids": ["TODAY", "tag-waiting"], "entities": {"TODAY": {"color": null, "created": 1790744929943, "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": true, "primary": "#6495ED", "huePrimary": "400", "accent": "#ff4081", "hueAccent": "500", "warn": "#e11826", "hueWarn": "500", "backgroundImageDark": "", "backgroundImageLight": null, "backgroundOverlayOpacity": 20, "backgroundImageBlur": 0}, "taskIds": ["KfvaOD_bl7mIn-Fq0W9NE"], "icon": "wb_sunny", "id": "TODAY", "title": "Today"}, "tag-waiting": {"color": null, "created": 1790745002886, "advancedCfg": {"worklogExportSettings": {"cols": ["DATE", "START", "END", "TIME_CLOCK", "TITLES_INCLUDING_SUB"], "roundWorkTimeTo": null, "roundStartTimeTo": null, "roundEndTimeTo": null, "separateTasksBy": " | ", "groupBy": "DATE"}}, "theme": {"isAutoContrast": true, "isDisableBackgroundTint": trLine truncated
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Re-record `fixtures/sp/api/*.json` from a real Super Productivity (Gitea #156).
|
||||||
|
|
||||||
|
Not a test module. Run it where the packaged app is installed - the `chemenu-sp-live`
|
||||||
|
image, or any machine with `CHEMENU_LIVE_SP_BINARY` set - from `tools/`:
|
||||||
|
|
||||||
|
CHEMENU_LIVE_SP_BINARY=... python -m chemenu.tests.record_sp_fixtures <out-dir>
|
||||||
|
|
||||||
|
It starts the seeded headless app, creates four items and closes one through wikitool's own
|
||||||
|
writer, then stores the raw answers of `GET /health`, `/projects`, `/tasks` and `/tags`.
|
||||||
|
`instructions/dev/tracker-testing.md` says when to run it and what to update beside it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from chemenu.tasks import superproductivity as sp
|
||||||
|
from chemenu.tests import sp_headless
|
||||||
|
|
||||||
|
PROJECT = "Chemenu Live-Test"
|
||||||
|
|
||||||
|
|
||||||
|
def main(out: Path) -> None:
|
||||||
|
binary = os.environ.get("CHEMENU_LIVE_SP_BINARY")
|
||||||
|
if not binary:
|
||||||
|
sys.exit("CHEMENU_LIVE_SP_BINARY names no Super Productivity binary")
|
||||||
|
executable = Path(binary)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="chemenu-record-sp-"))
|
||||||
|
process = sp_headless.start(executable, work / "profile", marker_project=PROJECT)
|
||||||
|
try:
|
||||||
|
cfg = sp.SuperProductivityConfig.from_dict(
|
||||||
|
{"access": "api", "api_base_url": process.base_url, "api_token": process.token}
|
||||||
|
)
|
||||||
|
reader = sp.SuperProductivityApiReader(cfg)
|
||||||
|
writer = sp.SuperProductivityWriter(cfg, reader)
|
||||||
|
writer.create_item(
|
||||||
|
"Recorded: offene Aufgabe", project_name=PROJECT,
|
||||||
|
notes="kb/gtd/technik/Chemenu Live-Test.md",
|
||||||
|
)
|
||||||
|
writer.create_item(
|
||||||
|
"Recorded: warte auf Antwort", project_name=PROJECT,
|
||||||
|
waiting=True, follow_up_at=date(2026, 8, 1),
|
||||||
|
)
|
||||||
|
writer.create_item("Recorded: erledigt", project_name=PROJECT)
|
||||||
|
writer.create_item("Recorded: Posteingang", project_name=None)
|
||||||
|
done = next(i for i in reader.open_items(PROJECT).items if i.title == "Recorded: erledigt")
|
||||||
|
writer.close_item(done.id)
|
||||||
|
|
||||||
|
def get(path: str, auth: bool) -> object:
|
||||||
|
headers = {"Authorization": f"Bearer {process.token}"} if auth else {}
|
||||||
|
request = urllib.request.Request(process.base_url + path, headers=headers)
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
return json.loads(response.read())
|
||||||
|
|
||||||
|
for name, path, auth in (
|
||||||
|
("health", "/health", False), ("projects", "/projects", True),
|
||||||
|
("tasks", "/tasks", True), ("tags", "/tags", True),
|
||||||
|
):
|
||||||
|
(out / f"{name}.json").write_text(
|
||||||
|
json.dumps(get(path, auth), indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
print("recorded against Super Productivity", sp_headless.installed_version(executable))
|
||||||
|
finally:
|
||||||
|
process.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
sys.exit("usage: python -m chemenu.tests.record_sp_fixtures <out-dir>")
|
||||||
|
main(Path(sys.argv[1]))
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""A throwaway Super Productivity for the live tracker suite (Gitea #156).
|
||||||
|
|
||||||
|
Not a test module. `tracker_live` uses it to start the packaged desktop app with no
|
||||||
|
visible display, seeded from `fixtures/sp/seed-backup.json`, and to stop it again.
|
||||||
|
Everything here was found out by running the real app (v19.1.0, see
|
||||||
|
`instructions/dev/tracker-testing.md`):
|
||||||
|
|
||||||
|
- the Local REST API exists only when `SP_FORCE_LOCAL_REST_API=1` is set, listens on
|
||||||
|
the fixed port 3876 and takes the token from `SP_FORCE_LOCAL_REST_API_TOKEN`;
|
||||||
|
- a packaged app needs `NODE_ENV=DEV` plus `--custom-url` at its own bundled
|
||||||
|
`index.html`, or it opens a remote page;
|
||||||
|
- data comes in through a backup file in `<profile>/backups/`, and the app asks
|
||||||
|
"restore the newest backup?" with a `window.confirm` at startup, which has to be
|
||||||
|
accepted over the Chromium DevTools protocol - there is no flag for it.
|
||||||
|
|
||||||
|
Standard library only: the suite must run wherever `tools/requirements.txt` does.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
API_PORT = 3876
|
||||||
|
CDP_PORT = 9222
|
||||||
|
SEED_BACKUP = Path(__file__).parent / "fixtures" / "sp" / "seed-backup.json"
|
||||||
|
|
||||||
|
|
||||||
|
class LiveAbort(RuntimeError):
|
||||||
|
"""The live suite refuses to touch a tracker. Raised before the first write."""
|
||||||
|
|
||||||
|
|
||||||
|
def health(port: int = API_PORT, timeout: float = 2.0) -> Optional[dict]:
|
||||||
|
"""`GET /health` unwrapped, or `None` when nothing (Super Productivity) answers."""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=timeout) as resp:
|
||||||
|
payload = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except (OSError, ValueError, urllib.error.URLError):
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict) or not payload.get("ok"):
|
||||||
|
return None
|
||||||
|
data = payload.get("data")
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
# --- a minimal WebSocket client, just enough for the DevTools protocol -----------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_connect(host: str, port: int, path: str, timeout: float) -> socket.socket:
|
||||||
|
sock = socket.create_connection((host, port), timeout=timeout)
|
||||||
|
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
||||||
|
sock.sendall(
|
||||||
|
(
|
||||||
|
f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
|
||||||
|
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
|
||||||
|
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||||
|
).encode("ascii")
|
||||||
|
)
|
||||||
|
buffer = b""
|
||||||
|
while b"\r\n\r\n" not in buffer:
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
raise OSError("DevTools closed the connection during the handshake")
|
||||||
|
buffer += chunk
|
||||||
|
if b" 101 " not in buffer.split(b"\r\n", 1)[0]:
|
||||||
|
raise OSError(f"DevTools refused the WebSocket upgrade: {buffer[:80]!r}")
|
||||||
|
return sock
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_send(sock: socket.socket, text: str) -> None:
|
||||||
|
payload = text.encode("utf-8")
|
||||||
|
mask = os.urandom(4)
|
||||||
|
length = len(payload)
|
||||||
|
if length < 126:
|
||||||
|
header = struct.pack("!BB", 0x81, 0x80 | length)
|
||||||
|
elif length < 65536:
|
||||||
|
header = struct.pack("!BBH", 0x81, 0x80 | 126, length)
|
||||||
|
else:
|
||||||
|
header = struct.pack("!BBQ", 0x81, 0x80 | 127, length)
|
||||||
|
masked = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
|
||||||
|
sock.sendall(header + mask + masked)
|
||||||
|
|
||||||
|
|
||||||
|
def _recv_exact(sock: socket.socket, count: int) -> bytes:
|
||||||
|
data = b""
|
||||||
|
while len(data) < count:
|
||||||
|
chunk = sock.recv(count - len(data))
|
||||||
|
if not chunk:
|
||||||
|
raise OSError("DevTools closed the connection")
|
||||||
|
data += chunk
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_recv(sock: socket.socket) -> Optional[str]:
|
||||||
|
"""The next text message, or `None` on a close frame. Server frames are unmasked."""
|
||||||
|
while True:
|
||||||
|
first, second = _recv_exact(sock, 2)
|
||||||
|
opcode = first & 0x0F
|
||||||
|
length = second & 0x7F
|
||||||
|
if length == 126:
|
||||||
|
(length,) = struct.unpack("!H", _recv_exact(sock, 2))
|
||||||
|
elif length == 127:
|
||||||
|
(length,) = struct.unpack("!Q", _recv_exact(sock, 8))
|
||||||
|
payload = _recv_exact(sock, length)
|
||||||
|
if opcode == 0x8:
|
||||||
|
return None
|
||||||
|
if opcode in (0x1, 0x0):
|
||||||
|
return payload.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _accept_dialogs(cdp_port: int, stop: threading.Event, log: list[str]) -> None:
|
||||||
|
"""Accept every JavaScript dialog the renderer opens until `stop` is set."""
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:{cdp_port}/json", timeout=2) as resp:
|
||||||
|
targets = [t for t in json.loads(resp.read()) if t.get("type") == "page"]
|
||||||
|
if not targets:
|
||||||
|
raise OSError("no page target yet")
|
||||||
|
ws_url = targets[0]["webSocketDebuggerUrl"]
|
||||||
|
path = "/" + ws_url.split("/", 3)[3]
|
||||||
|
sock = _ws_connect("127.0.0.1", cdp_port, path, timeout=2)
|
||||||
|
except (OSError, ValueError, KeyError, IndexError):
|
||||||
|
stop.wait(0.5)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_ws_send(sock, json.dumps({"id": 1, "method": "Page.enable"}))
|
||||||
|
sock.settimeout(1.0)
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
message = _ws_recv(sock)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
if message is None:
|
||||||
|
break
|
||||||
|
event = json.loads(message)
|
||||||
|
if event.get("method") == "Page.javascriptDialogOpening":
|
||||||
|
params = event.get("params", {})
|
||||||
|
log.append(f"{params.get('type')}: {str(params.get('message'))[:120]}")
|
||||||
|
_ws_send(sock, json.dumps({
|
||||||
|
"id": 2, "method": "Page.handleJavaScriptDialog",
|
||||||
|
"params": {"accept": True},
|
||||||
|
}))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
stop.wait(0.5)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --- the app ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpProcess:
|
||||||
|
process: "subprocess.Popen[bytes]"
|
||||||
|
profile_dir: Path
|
||||||
|
token: str
|
||||||
|
port: int
|
||||||
|
log_path: Path
|
||||||
|
dialogs: list[str]
|
||||||
|
_stop: threading.Event
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
return f"http://127.0.0.1:{self.port}"
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self.process.poll() is None:
|
||||||
|
try:
|
||||||
|
os.killpg(self.process.pid, signal.SIGTERM)
|
||||||
|
self.process.wait(timeout=15)
|
||||||
|
except (ProcessLookupError, subprocess.TimeoutExpired):
|
||||||
|
try:
|
||||||
|
os.killpg(self.process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def installed_version(executable: Path) -> str:
|
||||||
|
"""The packaged version, for the report line - `unknown` rather than a guess."""
|
||||||
|
override = os.environ.get("CHEMENU_LIVE_SP_VERSION")
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
if shutil.which("dpkg-query"):
|
||||||
|
result = subprocess.run(
|
||||||
|
["dpkg-query", "-W", "-f=${Version}", "superproductivity"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip()
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _api_projects(base_url: str, token: str) -> list[str]:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{base_url}/projects", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=5) as resp:
|
||||||
|
payload = json.loads(resp.read().decode("utf-8"))
|
||||||
|
return [str(p.get("title")) for p in payload.get("data", [])]
|
||||||
|
|
||||||
|
|
||||||
|
def start(
|
||||||
|
executable: Path,
|
||||||
|
profile_dir: Path,
|
||||||
|
*,
|
||||||
|
marker_project: str,
|
||||||
|
port: int = API_PORT,
|
||||||
|
cdp_port: int = CDP_PORT,
|
||||||
|
startup_timeout: float = 240.0,
|
||||||
|
) -> SpProcess:
|
||||||
|
"""Start the packaged app on a fresh, seeded profile and wait until it is ready.
|
||||||
|
|
||||||
|
Refuses when anything already answers on `port`: the port is fixed, so an answering
|
||||||
|
app is somebody's real Super Productivity, and seeding "our" one would either fail
|
||||||
|
or, worse, write into theirs.
|
||||||
|
"""
|
||||||
|
if health(port) is not None:
|
||||||
|
raise LiveAbort(
|
||||||
|
f"something already answers on 127.0.0.1:{port} - a running Super Productivity "
|
||||||
|
"owns the fixed API port, so the live suite will not start a second one next to "
|
||||||
|
"it. Quit that app, or point CHEMENU_LIVE_PROFILE at it deliberately."
|
||||||
|
)
|
||||||
|
if not executable.is_file():
|
||||||
|
raise LiveAbort(f"CHEMENU_LIVE_SP_BINARY does not name a file: {executable}")
|
||||||
|
if not os.environ.get("DISPLAY") and not shutil.which("xvfb-run"):
|
||||||
|
raise LiveAbort("no DISPLAY and no xvfb-run on PATH - the app needs a display to start.")
|
||||||
|
|
||||||
|
backups = profile_dir / "backups"
|
||||||
|
backups.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(SEED_BACKUP, backups / "2026-01-01_000000.json")
|
||||||
|
|
||||||
|
app_dir = executable.parent
|
||||||
|
token = secrets.token_urlsafe(24)
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"NODE_ENV": "DEV",
|
||||||
|
"SP_FORCE_LOCAL_REST_API": "1",
|
||||||
|
"SP_FORCE_LOCAL_REST_API_TOKEN": token,
|
||||||
|
}
|
||||||
|
command = [
|
||||||
|
str(executable),
|
||||||
|
f"--custom-url=file://{app_dir}/resources/app.asar/.tmp/angular-dist/browser/index.html",
|
||||||
|
f"--user-data-dir={profile_dir}",
|
||||||
|
"--disable-tray", "--no-sandbox",
|
||||||
|
f"--remote-debugging-port={cdp_port}",
|
||||||
|
]
|
||||||
|
if not os.environ.get("DISPLAY"):
|
||||||
|
command = ["xvfb-run", "-a", *command]
|
||||||
|
|
||||||
|
log_path = profile_dir.parent / f"{profile_dir.name}-sp.log"
|
||||||
|
with log_path.open("wb") as log:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True
|
||||||
|
)
|
||||||
|
stop = threading.Event()
|
||||||
|
dialogs: list[str] = []
|
||||||
|
threading.Thread(
|
||||||
|
target=_accept_dialogs, args=(cdp_port, stop, dialogs), daemon=True
|
||||||
|
).start()
|
||||||
|
handle = SpProcess(process, profile_dir, token, port, log_path, dialogs, stop)
|
||||||
|
|
||||||
|
def log_tail() -> str:
|
||||||
|
try:
|
||||||
|
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return "(no log)"
|
||||||
|
return "\n".join(lines[-25:])
|
||||||
|
|
||||||
|
base_url = handle.base_url
|
||||||
|
deadline = time.monotonic() + startup_timeout
|
||||||
|
try:
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
raise LiveAbort(
|
||||||
|
f"Super Productivity exited during startup; log {log_path}:\n{log_tail()}"
|
||||||
|
)
|
||||||
|
state = health(port)
|
||||||
|
if state and state.get("rendererReady") is True:
|
||||||
|
try:
|
||||||
|
if marker_project in _api_projects(base_url, token):
|
||||||
|
return handle
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
raise LiveAbort(
|
||||||
|
f"Super Productivity was not ready with project '{marker_project}' within "
|
||||||
|
f"{startup_timeout:.0f}s; log {log_path}:\n{log_tail()}"
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
handle.stop()
|
||||||
|
raise
|
||||||
@@ -533,3 +533,28 @@ def test_doctor_json_is_machine_readable(instance, capsys):
|
|||||||
rows = json.loads(capsys.readouterr().out)
|
rows = json.loads(capsys.readouterr().out)
|
||||||
assert any(row["name"] == "structure" for row in rows)
|
assert any(row["name"] == "structure" for row in rows)
|
||||||
assert all({"name", "status", "detail", "fix"} <= row.keys() for row in rows)
|
assert all({"name", "status", "detail", "fix"} <= row.keys() for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_provider_fails_when_the_override_names_a_missing_file(instance, monkeypatch):
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(config.ROOT / "gone.json"))
|
||||||
|
checks = doctor.run_doctor()
|
||||||
|
assert _status(checks, "tasks-provider") == "FAIL"
|
||||||
|
assert "gone.json" in _detail(checks, "tasks-provider")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_provider_names_an_active_override(instance, monkeypatch):
|
||||||
|
profile = config.ROOT / "profile.json"
|
||||||
|
profile.write_text(
|
||||||
|
json.dumps({
|
||||||
|
"schema": 1, "provider": "superproductivity",
|
||||||
|
"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
|
||||||
|
"someday_stale_months": 5},
|
||||||
|
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1",
|
||||||
|
"api_token": "t"},
|
||||||
|
}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
|
||||||
|
checks = doctor.run_doctor()
|
||||||
|
assert _status(checks, "tasks-provider") == "OK"
|
||||||
|
assert "WIKITOOL_TASKS_CONFIG" in _detail(checks, "tasks-provider")
|
||||||
@@ -591,3 +591,14 @@ def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch):
|
|||||||
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True
|
["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True
|
||||||
).stdout
|
).stdout
|
||||||
assert before == after == ""
|
assert before == after == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_override_naming_a_missing_file_is_an_error_naming_the_path(tmp_path, monkeypatch):
|
||||||
|
from chemenu import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(tmp_path / "gone.json"))
|
||||||
|
result = runner.invoke(app, ["review"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "gone.json" in result.output
|
||||||
|
assert "no task tracker is configured" not in result.output
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Super Productivity's real answers, replayed without the app (Gitea #156, E4).
|
||||||
|
|
||||||
|
`fixtures/sp/` holds what the packaged app itself produced - a backup it wrote, and the raw
|
||||||
|
answers of its Local REST API after a fixed scenario (`MANIFEST.json` names the version and
|
||||||
|
the day). Every push checks the adapter against them, so a change to the adapter that the
|
||||||
|
hand-written fakes in `test_superproductivity.py` would let through fails here; whether the
|
||||||
|
*app* has changed since is what the nightly live suite answers (`test_tracker_live.py`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chemenu.tasks import superproductivity as sp
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures" / "sp"
|
||||||
|
MARKER = "Chemenu Live-Test"
|
||||||
|
|
||||||
|
|
||||||
|
def _recorded(name: str) -> bytes:
|
||||||
|
return (FIXTURES / "api" / f"{name}.json").read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _replay_server():
|
||||||
|
routes = {
|
||||||
|
"/health": _recorded("health"), "/projects": _recorded("projects"),
|
||||||
|
"/tasks": _recorded("tasks"), "/tags": _recorded("tags"),
|
||||||
|
}
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self): # noqa: N802
|
||||||
|
body = routes.get(self.path.split("?", 1)[0])
|
||||||
|
if body is None:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield f"http://127.0.0.1:{server.server_address[1]}"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_manifest_names_the_version_and_the_day():
|
||||||
|
manifest = json.loads((FIXTURES / "MANIFEST.json").read_text(encoding="utf-8"))
|
||||||
|
assert manifest["sp_version"].count(".") == 2
|
||||||
|
date.fromisoformat(manifest["recorded"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_api_reader_reads_the_apps_own_answers():
|
||||||
|
with _replay_server() as base_url:
|
||||||
|
cfg = sp.SuperProductivityConfig.from_dict(
|
||||||
|
{"access": "api", "api_base_url": base_url, "api_token": "irrelevant"}
|
||||||
|
)
|
||||||
|
reader = sp.SuperProductivityApiReader(cfg)
|
||||||
|
|
||||||
|
names = {p.name for p in reader.projects()}
|
||||||
|
assert names == {MARKER, "Chemenu 8.0.0 freigeben", "Windows nativ unterstützen"}
|
||||||
|
|
||||||
|
items = reader.open_items(MARKER)
|
||||||
|
assert {i.title for i in items.items} == {
|
||||||
|
"Recorded: offene Aufgabe", "Recorded: warte auf Antwort",
|
||||||
|
}
|
||||||
|
assert items.count == 2
|
||||||
|
assert [(w.title, w.follow_up_at) for w in items.waiting] == [
|
||||||
|
("Recorded: warte auf Antwort", date(2026, 8, 1)),
|
||||||
|
]
|
||||||
|
assert reader.open_items("Windows nativ unterstützen").count == 0
|
||||||
|
assert sp.health(cfg) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_inbox_item_is_invisible_to_every_project():
|
||||||
|
with _replay_server() as base_url:
|
||||||
|
cfg = sp.SuperProductivityConfig.from_dict(
|
||||||
|
{"access": "api", "api_base_url": base_url, "api_token": "irrelevant"}
|
||||||
|
)
|
||||||
|
reader = sp.SuperProductivityApiReader(cfg)
|
||||||
|
seen = {i.title for p in reader.projects() for i in reader.open_items(p.name).items}
|
||||||
|
assert "Recorded: Posteingang" not in seen and "seed marker task" not in seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_snapshot_reader_reads_a_backup_the_app_wrote(tmp_path):
|
||||||
|
backups = tmp_path / "backups"
|
||||||
|
backups.mkdir()
|
||||||
|
shutil.copyfile(FIXTURES / "seed-backup.json", backups / "2026-01-01_000000.json")
|
||||||
|
cfg = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": str(backups)})
|
||||||
|
reader = sp.SuperProductivityReader(cfg)
|
||||||
|
|
||||||
|
assert {p.name for p in reader.projects()} == {
|
||||||
|
MARKER, "Chemenu 8.0.0 freigeben", "Windows nativ unterstützen",
|
||||||
|
}
|
||||||
|
assert reader.open_items(MARKER).count == 0
|
||||||
|
assert reader.someday_items() == []
|
||||||
@@ -387,3 +387,38 @@ def test_close_on_snapshot_access_is_refused_the_same_way_as_task_new(monkeypatc
|
|||||||
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"])
|
result = _invoke(monkeypatch, kb_dir, ["task", "close", "--id", "t1"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "access: 'api'" in result.output
|
assert "access: 'api'" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# --- WIKITOOL_TASKS_CONFIG (Gitea #156) ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_override_naming_a_missing_file_fails_naming_the_path(monkeypatch, kb_dir):
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(kb_dir.parent / "no-such-profile.json"))
|
||||||
|
for args in (
|
||||||
|
["task", "list", "--project", "x"],
|
||||||
|
["task", "close", "--id", "t1"],
|
||||||
|
["task", "new", "--title", "x", "--inbox"],
|
||||||
|
):
|
||||||
|
result = _invoke(monkeypatch, kb_dir, args)
|
||||||
|
assert result.exit_code == 1, args
|
||||||
|
flat = "".join(result.output.split())
|
||||||
|
assert "no-such-profile.json" in flat, args
|
||||||
|
assert "notasktrackerisconfigured" not in flat, args
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_override_points_a_command_at_another_tracker_without_touching_the_root_file(
|
||||||
|
monkeypatch, kb_dir, tmp_path,
|
||||||
|
):
|
||||||
|
root = kb_dir.parent
|
||||||
|
state = {"projects": [{**_project_record(), "taskIds": ["t1"]}],
|
||||||
|
"tasks": [{"id": "t1", "title": "Ueber das Profil", "isDone": False, "tagIds": []}]}
|
||||||
|
with _api_server(state) as server:
|
||||||
|
profile_dir = tmp_path / "profiles"
|
||||||
|
profile_dir.mkdir()
|
||||||
|
_write_tasks_config(profile_dir, _base_url(server))
|
||||||
|
profile = profile_dir / ".wikitool-tasks.json"
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
|
||||||
|
result = _invoke(monkeypatch, kb_dir, ["task", "list", "--project", "Ship Chemenu 7.0"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "Ueber das Profil" in result.output
|
||||||
|
assert not (root / ".wikitool-tasks.json").exists()
|
||||||
@@ -79,3 +79,50 @@ def test_missing_provider_section_is_rejected(tmp_path):
|
|||||||
_write(tmp_path, data)
|
_write(tmp_path, data)
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
tasks_config.read_config(tmp_path)
|
tasks_config.read_config(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the WIKITOOL_TASKS_CONFIG override (Gitea #156) --------------------------------
|
||||||
|
|
||||||
|
def test_override_is_read_instead_of_the_root_file(tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
_write(root, {**VALID, "provider": "asana"}) # would be rejected if it were read
|
||||||
|
profile = tmp_path / "profile.json"
|
||||||
|
profile.write_text(json.dumps(VALID), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
|
||||||
|
assert tasks_config.read_config(root).provider == "superproductivity"
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_works_with_no_root_file_at_all(tmp_path, monkeypatch):
|
||||||
|
profile = tmp_path / "profile.json"
|
||||||
|
profile.write_text(json.dumps(VALID), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
|
||||||
|
assert tasks_config.read_config(tmp_path / "elsewhere") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_relative_override_is_taken_from_the_working_directory(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "profile.json").write_text(json.dumps(VALID), encoding="utf-8")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", "profile.json")
|
||||||
|
assert tasks_config.read_config(tmp_path / "elsewhere") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_override_naming_a_missing_file_is_an_error_never_no_tracker(tmp_path, monkeypatch):
|
||||||
|
_write(tmp_path, VALID) # a root file must not be a fallback for a broken override
|
||||||
|
missing = tmp_path / "gone.json"
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(missing))
|
||||||
|
with pytest.raises(ValidationError, match="gone.json"):
|
||||||
|
tasks_config.read_config(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blank_override_is_no_override(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", " ")
|
||||||
|
assert tasks_config.read_config(tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_override_file_names_its_own_path(tmp_path, monkeypatch):
|
||||||
|
profile = tmp_path / "profile.json"
|
||||||
|
profile.write_text("{not json", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
|
||||||
|
with pytest.raises(ValidationError, match="profile.json"):
|
||||||
|
tasks_config.read_config(tmp_path)
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""The live tracker suite (Gitea #156) and the proof that its safety rules hold.
|
||||||
|
|
||||||
|
The `live_tracker` tests need a tracker and skip without one; the rest of this file
|
||||||
|
never does. Which tracker, how to start one, and what to do with a red run:
|
||||||
|
`instructions/dev/tracker-testing.md`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from chemenu.cli import app
|
||||||
|
from chemenu.review import CHECK_WAITING_OVERDUE, run_review
|
||||||
|
from chemenu.frontmatter_io import write_page
|
||||||
|
from chemenu.tasks.protocol import (
|
||||||
|
OpenItems, ProjectSummary, ReadSource, SomedayItem,
|
||||||
|
)
|
||||||
|
from chemenu.tests import sp_headless
|
||||||
|
from chemenu.tests import tracker_live as live
|
||||||
|
from chemenu.tests.tracker_live import LiveAbort, LiveTarget
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
# --- the live scenario ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def live_pool():
|
||||||
|
pool: dict[str, "LiveTarget | None"] = {}
|
||||||
|
yield pool
|
||||||
|
for target in pool.values():
|
||||||
|
if target is not None:
|
||||||
|
target.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=live.KINDS)
|
||||||
|
def target(request, live_pool) -> LiveTarget:
|
||||||
|
kind = request.param
|
||||||
|
if kind not in live_pool:
|
||||||
|
try:
|
||||||
|
live_pool[kind] = live.build_target(kind)
|
||||||
|
except LiveAbort as exc:
|
||||||
|
pytest.fail(f"live tracker '{kind}' refused to start: {exc}")
|
||||||
|
found = live_pool[kind]
|
||||||
|
if found is None:
|
||||||
|
if kind in live.required_kinds():
|
||||||
|
pytest.fail(f"CHEMENU_LIVE_REQUIRE names '{kind}' but no such tracker is configured")
|
||||||
|
pytest.skip(f"no live '{kind}' tracker configured (instructions/dev/tracker-testing.md)")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _project_page(root: Path, name: str) -> None:
|
||||||
|
write_page(
|
||||||
|
root / "kb" / "gtd" / "technik" / f"{name}.md",
|
||||||
|
{
|
||||||
|
"type": "types/project.md", "state": "active", "responsibility": "technik",
|
||||||
|
"created": "2026-01-01", "modified": "2026-01-01", "provenance": "general",
|
||||||
|
"summary": f"Live-test project {name}.",
|
||||||
|
},
|
||||||
|
f"\n# {name}\n\n## Ziel\n\nLive test.\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _provisioner(target: LiveTarget):
|
||||||
|
"""Only a tracker the suite owns end to end gets its marker created for it."""
|
||||||
|
if target.kind != "caldav":
|
||||||
|
return None
|
||||||
|
def provision() -> None:
|
||||||
|
reader = live.reader_for(target.config)
|
||||||
|
from chemenu.tasks import caldav as cd
|
||||||
|
|
||||||
|
cfg = cd.CalDAVConfig.from_dict(target.config["caldav"])
|
||||||
|
writer = cd.CalDAVWriter(cfg, reader)
|
||||||
|
for name in (live.MARKER_PROJECT, cfg.inbox_list, cfg.someday_list):
|
||||||
|
if name not in {p.name for p in reader.projects()}:
|
||||||
|
writer.create_project(name)
|
||||||
|
|
||||||
|
return provision
|
||||||
|
|
||||||
|
|
||||||
|
def _open_titles(reader) -> dict[str, str]:
|
||||||
|
return {item.title: item.id for item in reader.open_items(live.MARKER_PROJECT).items}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.live_tracker
|
||||||
|
def test_the_documented_workflow_runs_against_the_real_tracker(
|
||||||
|
target, kb_dir, tmp_path, monkeypatch, record_property, capsys,
|
||||||
|
):
|
||||||
|
root = kb_dir.parent
|
||||||
|
profile = tmp_path / "live-profile.json"
|
||||||
|
profile.write_text(json.dumps(target.config), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("WIKITOOL_TASKS_CONFIG", str(profile))
|
||||||
|
with capsys.disabled():
|
||||||
|
print(f"\n[tracker-live] {target.label} - version {target.version} - run {live.RUN_ID}")
|
||||||
|
record_property("tracker_kind", target.kind)
|
||||||
|
record_property("tracker_version", target.version)
|
||||||
|
|
||||||
|
reader = live.reader_for(target.config)
|
||||||
|
live.guard(reader, provision=_provisioner(target))
|
||||||
|
_project_page(root, live.MARKER_PROJECT)
|
||||||
|
|
||||||
|
open_title, waiting_title = live.run_id_titles()
|
||||||
|
overdue = live.overdue_day(date.today())
|
||||||
|
try:
|
||||||
|
created = runner.invoke(app, ["task", "new", "--project", live.MARKER_PROJECT,
|
||||||
|
"--title", open_title])
|
||||||
|
assert created.exit_code == 0, created.output
|
||||||
|
created = runner.invoke(app, ["task", "new", "--project", live.MARKER_PROJECT,
|
||||||
|
"--title", waiting_title, "--waiting",
|
||||||
|
"--follow-up-at", overdue.isoformat()])
|
||||||
|
assert created.exit_code == 0, created.output
|
||||||
|
|
||||||
|
listing = runner.invoke(app, ["task", "list", "--project", live.MARKER_PROJECT])
|
||||||
|
assert listing.exit_code == 0, listing.output
|
||||||
|
assert open_title in listing.output and waiting_title in listing.output
|
||||||
|
|
||||||
|
titles = _open_titles(reader)
|
||||||
|
assert open_title in titles and waiting_title in titles
|
||||||
|
waiting = {w.title: w for w in reader.open_items(live.MARKER_PROJECT).waiting}
|
||||||
|
assert waiting_title in waiting and open_title not in waiting
|
||||||
|
assert waiting[waiting_title].follow_up_at == overdue
|
||||||
|
|
||||||
|
report = run_review(root, today=date.today())
|
||||||
|
assert any(
|
||||||
|
f.check == CHECK_WAITING_OVERDUE and f.item_id == titles[waiting_title]
|
||||||
|
for f in report.findings
|
||||||
|
), [f for f in report.findings]
|
||||||
|
|
||||||
|
for title in (open_title, waiting_title):
|
||||||
|
closed = runner.invoke(app, ["task", "close", "--id", titles[title]])
|
||||||
|
assert closed.exit_code == 0, closed.output
|
||||||
|
assert not {open_title, waiting_title} & set(_open_titles(reader))
|
||||||
|
finally:
|
||||||
|
for title, item_id in _open_titles(reader).items():
|
||||||
|
if title.startswith(live.ITEM_PREFIX):
|
||||||
|
runner.invoke(app, ["task", "close", "--id", item_id])
|
||||||
|
|
||||||
|
|
||||||
|
# --- the safety rules, against fakes -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingReader:
|
||||||
|
"""A tracker that knows the listed projects and counts every call that could write."""
|
||||||
|
|
||||||
|
def __init__(self, names: list[str]):
|
||||||
|
self._names = names
|
||||||
|
self.writes = 0
|
||||||
|
|
||||||
|
def projects(self):
|
||||||
|
return [ProjectSummary(name=n, created=None) for n in self._names]
|
||||||
|
|
||||||
|
def open_items(self, project_name):
|
||||||
|
return OpenItems(count=0, waiting=(), items=())
|
||||||
|
|
||||||
|
def someday_items(self) -> list[SomedayItem]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def source(self):
|
||||||
|
return ReadSource(kind="fake", detail="fake")
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_aborts_when_the_marker_project_is_missing_and_nothing_was_written():
|
||||||
|
reader = _RecordingReader(["Inbox", "Somebody's real project"])
|
||||||
|
with pytest.raises(LiveAbort, match="Chemenu Live-Test"):
|
||||||
|
live.guard(reader)
|
||||||
|
assert reader.writes == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_accepts_the_marker_in_any_case_and_spacing():
|
||||||
|
live.guard(_RecordingReader([" chemenu LIVE-test "]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_provisions_only_when_a_provisioner_is_given():
|
||||||
|
names: list[str] = []
|
||||||
|
reader = _RecordingReader(names)
|
||||||
|
|
||||||
|
def provision():
|
||||||
|
names.append(live.MARKER_PROJECT)
|
||||||
|
|
||||||
|
live.guard(reader, provision=provision)
|
||||||
|
assert names == [live.MARKER_PROJECT]
|
||||||
|
with pytest.raises(LiveAbort):
|
||||||
|
live.guard(_RecordingReader([]))
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _answering_health_server():
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self): # noqa: N802
|
||||||
|
body = json.dumps({"ok": True, "data": {"server": "up", "rendererReady": True}}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args): # noqa: D401
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield server.server_address[1]
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_refuses_when_the_api_port_already_answers(tmp_path):
|
||||||
|
executable = tmp_path / "superproductivity"
|
||||||
|
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||||
|
executable.chmod(0o755)
|
||||||
|
with _answering_health_server() as port:
|
||||||
|
with pytest.raises(LiveAbort, match="already answers"):
|
||||||
|
sp_headless.start(executable, tmp_path / "profile", marker_project="x", port=port)
|
||||||
|
assert not (tmp_path / "profile").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_profile_with_a_home_relative_path_is_refused(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(live, "REPO_ROOT", tmp_path)
|
||||||
|
(tmp_path / live.PROFILE_DIR).mkdir()
|
||||||
|
(tmp_path / live.PROFILE_DIR / "mine.json").write_text(json.dumps({
|
||||||
|
"schema": 1, "provider": "superproductivity", "thresholds": live.THRESHOLDS,
|
||||||
|
"superproductivity": {"access": "snapshot", "backups_dir": "~/.config/sp/backups"},
|
||||||
|
}))
|
||||||
|
with pytest.raises(LiveAbort, match="absolute"):
|
||||||
|
live.load_profile("mine")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["", "../escape", "a/b", ".hidden"])
|
||||||
|
def test_a_profile_name_cannot_leave_the_profile_directory(name):
|
||||||
|
with pytest.raises(LiveAbort, match="bare profile name"):
|
||||||
|
live.profile_path(name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_profile_is_read_exactly_as_written(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(live, "REPO_ROOT", tmp_path)
|
||||||
|
(tmp_path / live.PROFILE_DIR).mkdir()
|
||||||
|
(tmp_path / live.PROFILE_DIR / "mine.json").write_text(json.dumps({
|
||||||
|
"schema": 1, "provider": "superproductivity", "thresholds": live.THRESHOLDS,
|
||||||
|
"superproductivity": {"access": "snapshot", "backups_dir": str(tmp_path)},
|
||||||
|
"live_test_version": "19.1.0",
|
||||||
|
}))
|
||||||
|
target = live.load_profile("mine")
|
||||||
|
assert target.version == "19.1.0"
|
||||||
|
assert "live_test_version" not in target.config
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_kinds_turn_a_missing_tracker_into_a_failure():
|
||||||
|
assert live.required_kinds({"CHEMENU_LIVE_REQUIRE": "caldav, sp"}) == {"caldav", "sp"}
|
||||||
|
assert live.required_kinds({}) == set()
|
||||||
|
assert live.build_target("caldav", {}) is None
|
||||||
|
assert live.build_target("sp", {}) is None
|
||||||
|
assert live.build_target("profile", {}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_caldav_target_needs_both_credentials():
|
||||||
|
with pytest.raises(LiveAbort, match="USER"):
|
||||||
|
live.caldav_target_from_env({"CHEMENU_LIVE_CALDAV_URL": "http://127.0.0.1:5232/u/"})
|
||||||
|
target = live.caldav_target_from_env({
|
||||||
|
"CHEMENU_LIVE_CALDAV_URL": "http://127.0.0.1:5232/u/",
|
||||||
|
"CHEMENU_LIVE_CALDAV_USER": "u", "CHEMENU_LIVE_CALDAV_PASSWORD": "p",
|
||||||
|
})
|
||||||
|
assert target is not None and target.config["provider"] == "caldav"
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_titles_carry_the_run_prefix():
|
||||||
|
for title in live.run_id_titles():
|
||||||
|
assert title.startswith(live.ITEM_PREFIX)
|
||||||
|
assert live.ITEM_PREFIX.startswith("[live-test ")
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""The live tracker suite's targets, guard and scenario (Gitea #156).
|
||||||
|
|
||||||
|
Not a test module - `test_tracker_live.py` runs it. The suite is the one place this
|
||||||
|
repo talks to a real tracker, so its safety rules live in code here rather than in a
|
||||||
|
comment:
|
||||||
|
|
||||||
|
1. Only the project named `MARKER_PROJECT` is ever written to. It must already exist;
|
||||||
|
this module never creates it in a tracker somebody else owns (`guard`).
|
||||||
|
2. Every item it creates carries `ITEM_PREFIX`, which names this run.
|
||||||
|
3. Nothing is ever deleted - the only closing write is `task close`.
|
||||||
|
4. Any violated precondition raises `LiveAbort` *before* the first write.
|
||||||
|
|
||||||
|
A target is one tracker the suite may run against. Three ways to name one, all through
|
||||||
|
the environment, none of them a fixed address:
|
||||||
|
|
||||||
|
- `CHEMENU_LIVE_SP_BINARY`: the packaged Super Productivity; the suite starts its own
|
||||||
|
seeded, headless copy (`sp_headless`).
|
||||||
|
- `CHEMENU_LIVE_CALDAV_URL` + `_USER` + `_PASSWORD`: a CalDAV server the suite may
|
||||||
|
provision the marker calendar in (Radicale in CI).
|
||||||
|
- `CHEMENU_LIVE_PROFILE`: a profile file `.wikitool-tasks.d/<name>.json` in the
|
||||||
|
checkout - a tracker of the user's own, used exactly as configured.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
from chemenu import tasks
|
||||||
|
from chemenu.tasks import config as tasks_config
|
||||||
|
from chemenu.tasks.protocol import TaskReader, find_project, normalize_project_name
|
||||||
|
from chemenu.tests.sp_headless import LiveAbort
|
||||||
|
|
||||||
|
MARKER_PROJECT = "Chemenu Live-Test"
|
||||||
|
RUN_ID = uuid.uuid4().hex[:8]
|
||||||
|
ITEM_PREFIX = f"[live-test {RUN_ID}]"
|
||||||
|
PROFILE_DIR = ".wikitool-tasks.d"
|
||||||
|
KINDS = ("sp", "caldav", "profile")
|
||||||
|
THRESHOLDS = {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LiveTarget:
|
||||||
|
kind: str
|
||||||
|
label: str
|
||||||
|
version: str
|
||||||
|
config: dict[str, Any]
|
||||||
|
cleanup: Callable[[], None] = field(default=lambda: None)
|
||||||
|
|
||||||
|
|
||||||
|
def required_kinds(environ: "os._Environ[str] | dict[str, str]" = os.environ) -> set[str]:
|
||||||
|
"""Kinds that must run rather than skip (`CHEMENU_LIVE_REQUIRE=caldav,sp`) - CI's way of
|
||||||
|
making a skipped live suite a red one."""
|
||||||
|
raw = environ.get("CHEMENU_LIVE_REQUIRE", "")
|
||||||
|
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_home_relative(value: Any, where: str) -> None:
|
||||||
|
if isinstance(value, str) and value.startswith("~"):
|
||||||
|
raise LiveAbort(
|
||||||
|
f"{where}: '{value}' starts with '~'. Profiles carry absolute paths only - a "
|
||||||
|
"`~` would resolve against whichever HOME the run happens to have."
|
||||||
|
)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, inner in value.items():
|
||||||
|
_reject_home_relative(inner, f"{where}.{key}")
|
||||||
|
if isinstance(value, list):
|
||||||
|
for index, inner in enumerate(value):
|
||||||
|
_reject_home_relative(inner, f"{where}[{index}]")
|
||||||
|
|
||||||
|
|
||||||
|
def profile_path(name: str) -> Path:
|
||||||
|
if not name or "/" in name or name.startswith("."):
|
||||||
|
raise LiveAbort(f"CHEMENU_LIVE_PROFILE must be a bare profile name, got {name!r}.")
|
||||||
|
return REPO_ROOT / PROFILE_DIR / f"{name}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_profile(name: str) -> LiveTarget:
|
||||||
|
path = profile_path(name)
|
||||||
|
if not path.is_file():
|
||||||
|
raise LiveAbort(f"no profile file {path} - see instructions/dev/tracker-testing.md.")
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
_reject_home_relative(data, path.name)
|
||||||
|
return LiveTarget(
|
||||||
|
kind="profile",
|
||||||
|
label=f"profile {name}",
|
||||||
|
version=str(data.get("live_test_version", "unknown")),
|
||||||
|
config={key: value for key, value in data.items() if key != "live_test_version"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def caldav_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
|
||||||
|
url = environ.get("CHEMENU_LIVE_CALDAV_URL")
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
user = environ.get("CHEMENU_LIVE_CALDAV_USER", "")
|
||||||
|
password = environ.get("CHEMENU_LIVE_CALDAV_PASSWORD", "")
|
||||||
|
if not user or not password:
|
||||||
|
raise LiveAbort("CHEMENU_LIVE_CALDAV_URL needs CHEMENU_LIVE_CALDAV_USER and _PASSWORD.")
|
||||||
|
return LiveTarget(
|
||||||
|
kind="caldav",
|
||||||
|
label="CalDAV " + url,
|
||||||
|
version=environ.get("CHEMENU_LIVE_CALDAV_VERSION", "unknown"),
|
||||||
|
config={
|
||||||
|
"schema": 1,
|
||||||
|
"provider": "caldav",
|
||||||
|
"thresholds": THRESHOLDS,
|
||||||
|
"caldav": {
|
||||||
|
"url": url, "username": user, "app_password": password,
|
||||||
|
"inbox_list": "Inbox", "someday_list": "Someday",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sp_target_from_env(environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
|
||||||
|
binary = environ.get("CHEMENU_LIVE_SP_BINARY")
|
||||||
|
if not binary:
|
||||||
|
return None
|
||||||
|
from chemenu.tests import sp_headless
|
||||||
|
|
||||||
|
executable = Path(binary)
|
||||||
|
workdir = Path(tempfile.mkdtemp(prefix="chemenu-live-sp-"))
|
||||||
|
process = sp_headless.start(executable, workdir / "profile", marker_project=MARKER_PROJECT)
|
||||||
|
|
||||||
|
def cleanup() -> None:
|
||||||
|
process.stop()
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
return LiveTarget(
|
||||||
|
kind="sp",
|
||||||
|
label="Super Productivity (headless)",
|
||||||
|
version=sp_headless.installed_version(executable),
|
||||||
|
config={
|
||||||
|
"schema": 1,
|
||||||
|
"provider": "superproductivity",
|
||||||
|
"thresholds": THRESHOLDS,
|
||||||
|
"superproductivity": {
|
||||||
|
"access": "api", "api_base_url": process.base_url, "api_token": process.token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cleanup=cleanup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_target(kind: str, environ: "dict[str, str] | os._Environ[str]" = os.environ) -> Optional[LiveTarget]:
|
||||||
|
if kind == "sp":
|
||||||
|
return sp_target_from_env(environ)
|
||||||
|
if kind == "caldav":
|
||||||
|
return caldav_target_from_env(environ)
|
||||||
|
if kind == "profile":
|
||||||
|
name = environ.get("CHEMENU_LIVE_PROFILE")
|
||||||
|
return load_profile(name) if name else None
|
||||||
|
raise ValueError(f"unknown live tracker kind {kind!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- guard and scenario ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def reader_for(target_config: dict[str, Any]) -> TaskReader:
|
||||||
|
"""The reader `target_config` describes, built the way the commands build it."""
|
||||||
|
provider = target_config["provider"]
|
||||||
|
cfg = tasks_config.TasksConfig(
|
||||||
|
provider=provider,
|
||||||
|
thresholds=tasks_config.Thresholds(**target_config["thresholds"]),
|
||||||
|
provider_config=target_config[provider],
|
||||||
|
)
|
||||||
|
return tasks.build_reader(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def guard(reader: TaskReader, *, provision: Optional[Callable[[], None]] = None) -> None:
|
||||||
|
"""Refuse before the first write unless the marker project exists.
|
||||||
|
|
||||||
|
`provision`, given only for a target the suite owns end to end (Radicale in CI), creates
|
||||||
|
the marker first; a profile target never gets one, so a missing marker there is an abort.
|
||||||
|
"""
|
||||||
|
if find_project(reader, MARKER_PROJECT) is None and provision is not None:
|
||||||
|
provision()
|
||||||
|
if find_project(reader, MARKER_PROJECT) is None:
|
||||||
|
raise LiveAbort(
|
||||||
|
f"the tracker has no project named '{MARKER_PROJECT}'. The live suite writes "
|
||||||
|
"only into that project and never creates it in a tracker it does not own - "
|
||||||
|
"create it there first (instructions/dev/tracker-testing.md)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_id_titles() -> tuple[str, str]:
|
||||||
|
return f"{ITEM_PREFIX} offen", f"{ITEM_PREFIX} warte auf Antwort"
|
||||||
|
|
||||||
|
|
||||||
|
def overdue_day(today: date) -> date:
|
||||||
|
return today - timedelta(days=30)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ITEM_PREFIX", "KINDS", "LiveAbort", "LiveTarget", "MARKER_PROJECT", "THRESHOLDS",
|
||||||
|
"build_target", "guard", "normalize_project_name", "overdue_day", "reader_for",
|
||||||
|
"required_kinds", "run_id_titles",
|
||||||
|
]
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
pythonpath = .
|
pythonpath = .
|
||||||
testpaths = chemenu/tests
|
testpaths = chemenu/tests
|
||||||
|
markers =
|
||||||
|
live_tracker: talks to a real tracker (Super Productivity, CalDAV); skips without one - instructions/dev/tracker-testing.md
|
||||||
Reference in new issue
Block a user