The image is published to a public registry, and the skill named the employer as its Vikunja root project and pointed at tasks.mroberts.dev. Neither is a secret, but neither belongs in an artefact anyone can pull. It is a host-side planning skill with nothing to do inside a sandbox, so removing it costs the sandbox no capability.
This commit is contained in:
@@ -1,119 +0,0 @@
|
|||||||
---
|
|
||||||
name: vikunja-backlog
|
|
||||||
description: Use when you have a brainstorming, design, discussion, meeting-notes, or specification document (any format — md, txt, pdf, docx, odt) and want it turned into Vikunja backlog tasks under the "sliders, not checkboxes" feature-category planning model at tasks.mroberts.dev.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Vikunja Backlog From a Document
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Turn a discussion/design/brainstorming document into Vikunja tasks using the
|
|
||||||
**"Sliders, Not Checkboxes"** framework: work is organized by **feature
|
|
||||||
category** (the project), each item is restated as the **need** it addresses
|
|
||||||
(the task title), the proposed solution lives in the description, and value is
|
|
||||||
expressed as **priority** (rank within the category).
|
|
||||||
|
|
||||||
The mapping and the 8 categories are the contract. Read
|
|
||||||
[reference/sliders-framework.md](reference/sliders-framework.md) before
|
|
||||||
classifying — it defines each category, the need-vs-solution discipline, and
|
|
||||||
the priority/label rules.
|
|
||||||
|
|
||||||
**Core principle:** the title is the *need*, never the solution. "Limit a
|
|
||||||
compromised container's blast radius", not "add read-only rootfs".
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
- You have a doc (any format) describing work, decisions, or a backlog and want
|
|
||||||
it in Vikunja.
|
|
||||||
- Symptoms: a design spec, meeting notes, a brainstorm, a PR/backlog writeup, a
|
|
||||||
Slack/email dump pasted into a file.
|
|
||||||
|
|
||||||
Not for: editing tasks one-off (use the API directly), or non-planning docs.
|
|
||||||
|
|
||||||
## Object Mapping (the contract)
|
|
||||||
|
|
||||||
| Framework concept | Vikunja object |
|
|
||||||
|---|---|
|
|
||||||
| Feature category | **Project** (child of the root `CareEvolution` project) |
|
|
||||||
| Candidate item | **Task** |
|
|
||||||
| Need | Task **title** (phrased as the goal) |
|
|
||||||
| Solution(s) + source | Task **description** |
|
|
||||||
| Value rank within category | Task **priority** (1–4) |
|
|
||||||
| Urgency / returns | Label `acute` / `diminishing` |
|
|
||||||
| Platform | Label (e.g. `Orchestrate`) |
|
|
||||||
| Deployment / repo | Label (e.g. `Rosetta`) |
|
|
||||||
|
|
||||||
The same task viewed by **project** = cross-deployment value lens; filtered by
|
|
||||||
**deployment label** = per-repo execution lens (one dataset, many views).
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. **Extract** the doc text:
|
|
||||||
`scripts/extract.py <path>` (handles md/txt/pdf/docx/odt → stdout).
|
|
||||||
2. **Analyze** the text. Pull out candidate items. For each, restate the
|
|
||||||
**need** (what the user/admin/dev is trying to accomplish), and capture the
|
|
||||||
proposed **solution(s)** separately. Discard pure discussion that isn't a
|
|
||||||
candidate. See the framework reference for the need-restatement discipline.
|
|
||||||
3. **Classify** each need into exactly one of the 8 categories. Borderline
|
|
||||||
items: pick the category of the *primary value*, note the alternative in the
|
|
||||||
description.
|
|
||||||
4. **Rank** — assign a priority (4 = most acute/frequent/broad … 1 = small/late
|
|
||||||
on the curve) reflecting value *within its category*, not build effort. Tag
|
|
||||||
`acute` / `diminishing` per the reference rules.
|
|
||||||
5. **Tag platform + deployment.** Infer the platform (tag) and deployment(s)
|
|
||||||
(label) from the doc, then **confirm/override with the user** before
|
|
||||||
creating. If a doc spans several deployments and granularity is "per
|
|
||||||
deployment", emit one task per (need × deployment).
|
|
||||||
6. **Propose, then create.** Present the full breakdown as a table
|
|
||||||
(need | category | priority | labels | done?). Get the user's approval/edits.
|
|
||||||
**Do not create anything before approval.**
|
|
||||||
7. On approval, write the approved tasks to a JSON file and run
|
|
||||||
`scripts/vikunja.py create --file <tasks.json>`. It idempotently ensures the
|
|
||||||
8 category projects + labels exist, creates missing labels, skips duplicates,
|
|
||||||
and handles the done-priority gotcha.
|
|
||||||
8. **Report** created task IDs and the view hints above.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
- `scripts/extract.py <path>` — extract plain text from md/txt/pdf/docx/odt.
|
|
||||||
- `scripts/vikunja.py ensure` — idempotently create the 8 category projects +
|
|
||||||
`acute`/`diminishing` labels under `CareEvolution`. Prints name→id maps.
|
|
||||||
- `scripts/vikunja.py projects` / `labels` — list current ids.
|
|
||||||
- `scripts/vikunja.py create --file <tasks.json>` — create tasks. Run `--help`
|
|
||||||
for the JSON schema and all flags.
|
|
||||||
|
|
||||||
Auth: reads `VIKUNJA_API_KEY` from the environment. Base URL defaults to
|
|
||||||
`https://tasks.mroberts.dev/api/v1` (`VIKUNJA_BASE_URL` overrides); root project
|
|
||||||
defaults to `CareEvolution` (`VIKUNJA_ROOT_PROJECT` overrides).
|
|
||||||
|
|
||||||
## Tasks JSON schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"category": "Security, privacy & compliance",
|
|
||||||
"title": "Limit a compromised container's runtime blast radius",
|
|
||||||
"description": "**Need:** ...\n\n**Solution:** ...\n\n**Source:** doc.md",
|
|
||||||
"priority": 4,
|
|
||||||
"labels": ["Orchestrate", "Rosetta", "acute"],
|
|
||||||
"done": false,
|
|
||||||
"percent": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
`category` must be one of the 8 exact names (see reference). `labels` are
|
|
||||||
created if missing. `done`/`percent` optional.
|
|
||||||
|
|
||||||
## Common Mistakes
|
|
||||||
|
|
||||||
- **Title is the solution, not the need.** "Add Dependabot auto-merge" → wrong.
|
|
||||||
"Stay patched with less manual toil" → right; Dependabot goes in the body.
|
|
||||||
- **Priority = build effort.** No. Priority = value of the need within its
|
|
||||||
category. A cheap rank-1 outranks an expensive rank-6.
|
|
||||||
- **Creating before approval.** The framework's whole point is the judgment
|
|
||||||
call on need + category + rank. Always propose first.
|
|
||||||
- **Re-running creates duplicates.** `create` dedupes on (title + category +
|
|
||||||
deployment labels); don't bypass it with ad-hoc API calls.
|
|
||||||
- **Mark-done zeros priority.** The Vikunja update replaces the task; `create`
|
|
||||||
re-sends priority on the done update. Don't hand-roll the done call.
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Sliders, Not Checkboxes — classification reference
|
|
||||||
|
|
||||||
The framework models work as a small set of **feature categories**. A backlog
|
|
||||||
item is restated as the **need** it addresses (not the proposed solution), and
|
|
||||||
needs are value-ranked within each category. Source: the "Sliders, Not
|
|
||||||
Checkboxes" paper (CareEvolution).
|
|
||||||
|
|
||||||
## The need-restatement discipline
|
|
||||||
|
|
||||||
Before an item enters a category, restate it as the user/admin/developer
|
|
||||||
**need** — what they are trying to accomplish — and treat the proposed solution
|
|
||||||
as *one way* to address it.
|
|
||||||
|
|
||||||
- A doc says "build a self-serve provisioning UI." The **need** is "add/remove a
|
|
||||||
user without filing a support ticket." The UI is one solution; SCIM or a
|
|
||||||
declarative API are others. Title = the need; list the candidate solutions in
|
|
||||||
the description.
|
|
||||||
- Ranking reflects what addressing the need means for the people who hold it:
|
|
||||||
**how often** it's hit, **how acute** the pain, **how broadly** held. Not what
|
|
||||||
is easiest to build or most recently requested.
|
|
||||||
- Discussion, debate, and context that isn't a candidate item → do not create a
|
|
||||||
task. Capture only genuine candidate needs.
|
|
||||||
|
|
||||||
## The 8 feature categories
|
|
||||||
|
|
||||||
Classify each need into exactly one. Pick the category of the **primary value**;
|
|
||||||
note a close alternative in the description.
|
|
||||||
|
|
||||||
| Category (use this exact name) | What it covers | Whose need |
|
|
||||||
|---|---|---|
|
|
||||||
| **End-user experience** | User-facing product: features, flows, learnability, polish | People using the front-end to do their own tasks |
|
|
||||||
| **Admin & operator experience** | Configuration, governance, monitoring, operational tooling | Customer-side admins / IT staff deploying & overseeing the product |
|
|
||||||
| **Developer experience & adoption** | API design, SDKs, docs, sandbox, time-to-first-call; community, mindshare | Developers integrating against or building on the product |
|
|
||||||
| **Data, integrations & ecosystem** | Data quality, lineage, connectors, standards conformance (FHIR, HL7, OAuth, OpenAPI), marketplace, partners | Integrating systems and partner platforms |
|
|
||||||
| **Performance & scale** | Latency, throughput, behavior under load | Anyone depending on the product being fast and handling their volume |
|
|
||||||
| **Reliability & observability** | Uptime, fault tolerance, graceful degradation, recovery; logs, metrics, traces, alerting, runbooks | Customers needing it up; operators keeping it up |
|
|
||||||
| **Security, privacy & compliance** | Authn/authz, encryption, audit logging, certifications, regulatory posture, attack-surface reduction, vulnerability/CVE management, supply-chain integrity, runtime threat detection | Security & compliance teams; regulators; users trusting the platform |
|
|
||||||
| **Cost efficiency** | Cost per unit work — per record, per API call, per active user; compute/arch cost (e.g. ARM64/Graviton) | Internal P&L; customers indirectly via pricing |
|
|
||||||
|
|
||||||
### Classification hints / common overlaps
|
|
||||||
|
|
||||||
- **Hardening** (read-only rootfs, drop caps, minimal/chiseled base images, image
|
|
||||||
CVE scanning, SHA-pinning CI actions, dependency patch currency, supply-chain
|
|
||||||
inventory, runtime threat detection) → **Security, privacy & compliance**.
|
|
||||||
- **ARM64/Graviton migration, smaller images for cost** → **Cost efficiency**
|
|
||||||
(its perf side can be **Performance & scale** — pick the primary driver named
|
|
||||||
in the doc).
|
|
||||||
- **Perf vs cost** are often two sides of one coin — categorize by the value the
|
|
||||||
doc emphasizes (latency/throughput → Performance; $/unit → Cost).
|
|
||||||
- **A correctness/data-race bug surfaced by infra work** → **Reliability &
|
|
||||||
observability** (it's about the service being correct/up), not Security.
|
|
||||||
- **CI/CD integrity** (pinning actions, blocking tampering) → Security; **CI
|
|
||||||
ergonomics/speed for devs** → Developer experience. Pick by the value.
|
|
||||||
- **Runtime anomaly detection** (GuardDuty etc.) → Security (threat detection)
|
|
||||||
even though it touches observability.
|
|
||||||
|
|
||||||
## Value rank → priority
|
|
||||||
|
|
||||||
Vikunja priority is 1–5. Use **1–4** for value tiers (reserve 5 for true
|
|
||||||
DO-NOW). Rank within the category, by value of the need:
|
|
||||||
|
|
||||||
| Priority | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| **4** | Rank-1 tier: most acute / frequent / broadly held; foundational. Everything else in the category is downstream of it. |
|
|
||||||
| **3** | High value, clearly worth doing, but not the keystone. |
|
|
||||||
| **2** | Medium: real need, smaller delta over the status quo. |
|
|
||||||
| **1** | Small / late-on-the-curve: refinement, niche, or largely satisfied by another item. |
|
|
||||||
|
|
||||||
Priority reflects **value, not build effort**. A cheap rank-1 outranks an
|
|
||||||
expensive rank-6.
|
|
||||||
|
|
||||||
## Urgency / returns labels
|
|
||||||
|
|
||||||
- **`acute`** — the need is frequent, painful, and/or broadly held *right now*.
|
|
||||||
Apply to the rank-1/keystone needs and anything externally forced (regulatory
|
|
||||||
deadline, audit finding).
|
|
||||||
- **`diminishing`** — the next investment in this area has tipped into
|
|
||||||
diminishing returns, or the need is largely satisfied by another item already
|
|
||||||
in the plan. Apply sparingly; it's a signal to deprioritize.
|
|
||||||
|
|
||||||
A task can have neither. Most have neither.
|
|
||||||
|
|
||||||
## Platform & deployment labels
|
|
||||||
|
|
||||||
- **Platform** = the product/platform the work belongs to (e.g. `Orchestrate`,
|
|
||||||
`HBNG`). One label.
|
|
||||||
- **Deployment / repo** = the specific service or repo (e.g. `Rosetta`,
|
|
||||||
`Hendrix`, `Insight`, `Kong`). One per task when granularity is per-deployment.
|
|
||||||
|
|
||||||
Categories are consistent **across** platforms and deployments (that's the point
|
|
||||||
— cross-deployment comparability). Platform/deployment are tags, never top-level
|
|
||||||
projects.
|
|
||||||
|
|
||||||
## Done / in-progress
|
|
||||||
|
|
||||||
- If the doc says an item is already shipped for a deployment, set `done: true`.
|
|
||||||
- Partial progress: leave `done: false` and set `percent` (e.g. 50) — note what
|
|
||||||
remains in the description.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Extract plain text from a document for backlog analysis.
|
|
||||||
|
|
||||||
Supports: .md .markdown .rst .txt (read), .odt / .docx (zip + XML strip),
|
|
||||||
.pdf (pdftotext if available, else pypdf/pdfplumber). Writes text to stdout.
|
|
||||||
|
|
||||||
Usage: extract.py <path>
|
|
||||||
"""
|
|
||||||
import sys, os, re, html, zipfile, shutil, subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_xml(xml: str, para_close=("</text:p>", "</text:h>", "</w:p>")) -> str:
|
|
||||||
for tag in para_close:
|
|
||||||
xml = xml.replace(tag, "\n")
|
|
||||||
xml = re.sub(r"<[^>]+>", "", xml)
|
|
||||||
xml = html.unescape(xml)
|
|
||||||
lines = [ln.strip() for ln in xml.split("\n")]
|
|
||||||
return "\n".join(ln for ln in lines if ln)
|
|
||||||
|
|
||||||
|
|
||||||
def from_zip_xml(path: str, inner: str) -> str:
|
|
||||||
with zipfile.ZipFile(path) as z:
|
|
||||||
return _strip_xml(z.read(inner).decode("utf-8", "ignore"))
|
|
||||||
|
|
||||||
|
|
||||||
def from_pdf(path: str) -> str:
|
|
||||||
if shutil.which("pdftotext"):
|
|
||||||
out = subprocess.run(["pdftotext", "-layout", path, "-"],
|
|
||||||
capture_output=True, text=True)
|
|
||||||
if out.returncode == 0 and out.stdout.strip():
|
|
||||||
return out.stdout
|
|
||||||
try:
|
|
||||||
import pypdf
|
|
||||||
return "\n".join(p.extract_text() or "" for p in pypdf.PdfReader(path).pages)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
import pdfplumber
|
|
||||||
with pdfplumber.open(path) as pdf:
|
|
||||||
return "\n".join(pg.extract_text() or "" for pg in pdf.pages)
|
|
||||||
except Exception:
|
|
||||||
sys.exit("PDF extraction needs `pdftotext` (poppler) or `pip install pypdf`.")
|
|
||||||
|
|
||||||
|
|
||||||
def extract(path: str) -> str:
|
|
||||||
ext = os.path.splitext(path)[1].lower()
|
|
||||||
if ext in (".md", ".markdown", ".rst", ".txt", ".text", ""):
|
|
||||||
with open(path, encoding="utf-8", errors="replace") as f:
|
|
||||||
return f.read()
|
|
||||||
if ext == ".odt":
|
|
||||||
return from_zip_xml(path, "content.xml")
|
|
||||||
if ext == ".docx":
|
|
||||||
return from_zip_xml(path, "word/document.xml")
|
|
||||||
if ext == ".pdf":
|
|
||||||
return from_pdf(path)
|
|
||||||
# last resort: try as text
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
return f.read()
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
sys.exit(f"Unsupported binary format: {ext}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) != 2:
|
|
||||||
sys.exit("usage: extract.py <path>")
|
|
||||||
if not os.path.isfile(sys.argv[1]):
|
|
||||||
sys.exit(f"no such file: {sys.argv[1]}")
|
|
||||||
sys.stdout.write(extract(sys.argv[1]))
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Vikunja backlog helper for the sliders/feature-category model.
|
|
||||||
|
|
||||||
Idempotently ensures the 8 feature-category projects + framework labels exist
|
|
||||||
under the root project, and creates tasks from an approved JSON breakdown
|
|
||||||
(deduping, and working around the mark-done-zeros-priority quirk).
|
|
||||||
|
|
||||||
Auth/config (environment):
|
|
||||||
VIKUNJA_API_KEY required — long-lived API token
|
|
||||||
VIKUNJA_BASE_URL default https://tasks.mroberts.dev/api/v1
|
|
||||||
VIKUNJA_ROOT_PROJECT default CareEvolution
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
ensure create missing category projects + acute/diminishing labels
|
|
||||||
projects list category projects (name -> id)
|
|
||||||
labels list labels (name -> id)
|
|
||||||
create --file T.json create tasks from an approved breakdown (see --help)
|
|
||||||
|
|
||||||
Tasks JSON: a list of objects:
|
|
||||||
{category, title, description, priority, labels:[...], done?, percent?}
|
|
||||||
`category` must be one of the 8 exact names. Missing labels are created.
|
|
||||||
Duplicates (same title + category + deployment labels) are skipped.
|
|
||||||
"""
|
|
||||||
import sys, os, json, argparse, urllib.request, urllib.error
|
|
||||||
|
|
||||||
BASE = os.environ.get("VIKUNJA_BASE_URL", "https://tasks.mroberts.dev/api/v1")
|
|
||||||
ROOT_NAME = os.environ.get("VIKUNJA_ROOT_PROJECT", "CareEvolution")
|
|
||||||
|
|
||||||
CATEGORIES = [
|
|
||||||
"End-user experience",
|
|
||||||
"Admin & operator experience",
|
|
||||||
"Developer experience & adoption",
|
|
||||||
"Data, integrations & ecosystem",
|
|
||||||
"Performance & scale",
|
|
||||||
"Reliability & observability",
|
|
||||||
"Security, privacy & compliance",
|
|
||||||
"Cost efficiency",
|
|
||||||
]
|
|
||||||
FRAMEWORK_LABELS = ["acute", "diminishing"]
|
|
||||||
|
|
||||||
|
|
||||||
def _key():
|
|
||||||
k = os.environ.get("VIKUNJA_API_KEY")
|
|
||||||
if not k:
|
|
||||||
sys.exit("VIKUNJA_API_KEY not set")
|
|
||||||
return k
|
|
||||||
|
|
||||||
|
|
||||||
def call(method, path, body=None):
|
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
|
||||||
req = urllib.request.Request(
|
|
||||||
BASE + path, data=data, method=method,
|
|
||||||
headers={"Authorization": "Bearer " + _key(),
|
|
||||||
"Content-Type": "application/json"})
|
|
||||||
try:
|
|
||||||
resp = urllib.request.urlopen(req)
|
|
||||||
return resp.status, json.loads(resp.read() or "null")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
return e.code, e.read().decode()[:300]
|
|
||||||
|
|
||||||
|
|
||||||
def get(path):
|
|
||||||
return call("GET", path)[1]
|
|
||||||
|
|
||||||
|
|
||||||
def root_id():
|
|
||||||
for p in get("/projects") or []:
|
|
||||||
if p["title"] == ROOT_NAME and p.get("parent_project_id") in (0, None):
|
|
||||||
return p["id"]
|
|
||||||
sys.exit(f"root project {ROOT_NAME!r} not found")
|
|
||||||
|
|
||||||
|
|
||||||
def all_labels():
|
|
||||||
return {l["title"]: l["id"] for l in (get("/labels") or [])}
|
|
||||||
|
|
||||||
|
|
||||||
def category_projects():
|
|
||||||
rid = root_id()
|
|
||||||
return {p["title"]: p["id"] for p in (get("/projects") or [])
|
|
||||||
if p.get("parent_project_id") == rid and p["title"] in CATEGORIES}
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_label(name, cache):
|
|
||||||
if name in cache:
|
|
||||||
return cache[name]
|
|
||||||
s, l = call("PUT", "/labels", {"title": name})
|
|
||||||
if not isinstance(l, dict):
|
|
||||||
sys.exit(f"failed creating label {name!r}: {l}")
|
|
||||||
cache[name] = l["id"]
|
|
||||||
return l["id"]
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_ensure(_):
|
|
||||||
rid = root_id()
|
|
||||||
existing = category_projects()
|
|
||||||
cats = dict(existing)
|
|
||||||
for c in CATEGORIES:
|
|
||||||
if c not in cats:
|
|
||||||
s, p = call("PUT", "/projects", {"title": c, "parent_project_id": rid})
|
|
||||||
if not isinstance(p, dict):
|
|
||||||
sys.exit(f"failed creating project {c!r}: {p}")
|
|
||||||
cats[c] = p["id"]
|
|
||||||
print(f"created project {c!r} -> {p['id']}", file=sys.stderr)
|
|
||||||
labels = all_labels()
|
|
||||||
for n in FRAMEWORK_LABELS:
|
|
||||||
ensure_label(n, labels)
|
|
||||||
print(json.dumps({"root": rid, "categories": cats, "labels": labels}, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_projects(_):
|
|
||||||
print(json.dumps(category_projects(), indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_labels(_):
|
|
||||||
print(json.dumps(all_labels(), indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def _deployment_labels(labels):
|
|
||||||
return {l for l in labels if l not in FRAMEWORK_LABELS}
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_create(args):
|
|
||||||
tasks = json.load(open(args.file))
|
|
||||||
if not isinstance(tasks, list):
|
|
||||||
sys.exit("tasks JSON must be a list")
|
|
||||||
rid = root_id()
|
|
||||||
cats = category_projects()
|
|
||||||
# ensure all referenced categories exist
|
|
||||||
for t in tasks:
|
|
||||||
c = t["category"]
|
|
||||||
if c not in CATEGORIES:
|
|
||||||
sys.exit(f"unknown category {c!r} (must be one of the 8)")
|
|
||||||
if c not in cats:
|
|
||||||
s, p = call("PUT", "/projects", {"title": c, "parent_project_id": rid})
|
|
||||||
cats[c] = p["id"]
|
|
||||||
label_cache = all_labels()
|
|
||||||
# cache existing tasks per category for dedup
|
|
||||||
existing = {}
|
|
||||||
for c, pid in cats.items():
|
|
||||||
existing[c] = get(f"/projects/{pid}/tasks") or []
|
|
||||||
|
|
||||||
created, skipped = [], []
|
|
||||||
for t in tasks:
|
|
||||||
c = t["category"]
|
|
||||||
pid = cats[c]
|
|
||||||
title = t["title"]
|
|
||||||
want_dep = _deployment_labels(t.get("labels", []))
|
|
||||||
dup = False
|
|
||||||
for ex in existing[c]:
|
|
||||||
if ex["title"].strip().lower() != title.strip().lower():
|
|
||||||
continue
|
|
||||||
ex_labels = {l["title"] for l in (ex.get("labels") or [])}
|
|
||||||
if want_dep.issubset(ex_labels):
|
|
||||||
dup = True
|
|
||||||
break
|
|
||||||
if dup:
|
|
||||||
skipped.append((title, c, sorted(want_dep)))
|
|
||||||
continue
|
|
||||||
prio = int(t.get("priority", 0))
|
|
||||||
s, task = call("PUT", f"/projects/{pid}/tasks", {
|
|
||||||
"title": title,
|
|
||||||
"description": (t.get("description") or "").replace("\n", "<br>"),
|
|
||||||
"priority": prio,
|
|
||||||
})
|
|
||||||
if not isinstance(task, dict):
|
|
||||||
sys.exit(f"create failed for {title!r}: {task}")
|
|
||||||
tid = task["id"]
|
|
||||||
for ln in t.get("labels", []):
|
|
||||||
call("PUT", f"/tasks/{tid}/labels", {"label_id": ensure_label(ln, label_cache)})
|
|
||||||
done = bool(t.get("done"))
|
|
||||||
pct = int(t.get("percent", 0))
|
|
||||||
if done or pct:
|
|
||||||
# re-send priority: the update replaces the task and would zero it
|
|
||||||
call("POST", f"/tasks/{tid}", {
|
|
||||||
"done": done, "percent_done": pct / 100, "priority": prio})
|
|
||||||
existing[c].append({"title": title,
|
|
||||||
"labels": [{"title": x} for x in t.get("labels", [])]})
|
|
||||||
created.append((tid, title, c, prio, sorted(want_dep), "done" if done else (f"{pct}%" if pct else "")))
|
|
||||||
|
|
||||||
print(f"created {len(created)} tasks, skipped {len(skipped)} duplicate(s)", file=sys.stderr)
|
|
||||||
for r in created:
|
|
||||||
print(f" + #{r[0]} [{r[3]}] {r[2]}: {r[1]} {r[4]} {r[5]}", file=sys.stderr)
|
|
||||||
for r in skipped:
|
|
||||||
print(f" = skip {r[1]}: {r[0]} {r[2]}", file=sys.stderr)
|
|
||||||
print(json.dumps({"created": [r[0] for r in created],
|
|
||||||
"skipped": len(skipped)}))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
||||||
sub.add_parser("ensure", help="create missing category projects + labels")
|
|
||||||
sub.add_parser("projects", help="list category projects")
|
|
||||||
sub.add_parser("labels", help="list labels")
|
|
||||||
c = sub.add_parser("create", help="create tasks from approved JSON")
|
|
||||||
c.add_argument("--file", required=True, help="path to tasks JSON (list)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
{"ensure": cmd_ensure, "projects": cmd_projects,
|
|
||||||
"labels": cmd_labels, "create": cmd_create}[args.cmd](args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user