From 2115d34c7e1a6279e916e3e92c1cac575c638be5 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Mon, 9 Nov 2026 11:04:16 +0100 Subject: [PATCH 1/3] Add --proof-requirement to tasks create and update --- cli/src/commands/tasks.mjs | 30 +++++++++++++++++++++ cli/test/tasks-proof-requirement.test.mjs | 33 +++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 cli/test/tasks-proof-requirement.test.mjs diff --git a/cli/src/commands/tasks.mjs b/cli/src/commands/tasks.mjs index 6effb5fc..fbe69e65 100644 --- a/cli/src/commands/tasks.mjs +++ b/cli/src/commands/tasks.mjs @@ -20,6 +20,7 @@ Usage [--subject-type sponsor-deal|sponsor-company|sponsor-person|tuition-agreement --subject-id ID] [--comment C] [--link URL] [--tags a,b] [--required-link-name NAME] [--instruction-doc DOC-ID] [--checklist KB-CHECKLIST-ID] [--item "text"]... [--requires youtube,luma] [--browser required|headless] [--depends-on ID,ID] + [--proof-requirement url|file|artifact|comment|external-status [--proof-label L] [--proof-optional]] dataops tasks update [--description D] [--date D] [--comment C] [--link URL] [--subject-type sponsor-deal|sponsor-company|sponsor-person|tuition-agreement --subject-id ID | --clear-subject] [--at "2026-10-23 19:00" --tz Europe/Berlin | --at +7d | --at none] @@ -115,6 +116,10 @@ Proof Every write reads the Task's current version first (or pass --expected-version N); a 409 means someone else changed it: re-read and retry. + create/update --proof-requirement TYPE sets what proof completing the Task needs + (same types as the API and web); --proof-label names it, --proof-optional makes it + non-blocking. + Task checklists --checklist copies the ordered items from a KB checklist; repeat --item to add Task-specific rows. Use check/uncheck with a 1-based row number or item id. @@ -665,6 +670,30 @@ async function resolveAssignee(api, value) { return value; } +const PROOF_REQUIREMENT_TYPES = ["url", "file", "artifact", "comment", "external-status"]; + +/** The writable `proofRequirement` from --proof-requirement TYPE [--proof-label L] [--proof-optional]. */ +function proofRequirementField(args) { + if (args.proofRequirement === undefined) { + if (args.proofLabel !== undefined || args.proofOptional !== undefined) { + throw new UsageError("--proof-label and --proof-optional need --proof-requirement TYPE."); + } + return {}; + } + const type = optionalString(args, "proofRequirement"); + if (!PROOF_REQUIREMENT_TYPES.includes(type)) { + throw new UsageError(`--proof-requirement must be one of: ${PROOF_REQUIREMENT_TYPES.join(", ")}.`); + } + const label = optionalString(args, "proofLabel"); + return { + proofRequirement: { + type, + ...(label === undefined ? {} : { label }), + ...(args.proofOptional === true ? { required: false } : {}), + }, + }; +} + function writableFields(args) { const body = {}; if (args.clearSubject) { @@ -715,6 +744,7 @@ function writableFields(args) { body.requires = requires === "none" ? [] : csv(requires); } Object.assign(body, browserRequirementField(args)); + Object.assign(body, proofRequirementField(args)); if (args.dependsOn !== undefined) { const dependsOn = required(args, "dependsOn", "depends-on"); body.dependsOn = dependsOn === "none" ? [] : csv(dependsOn); diff --git a/cli/test/tasks-proof-requirement.test.mjs b/cli/test/tasks-proof-requirement.test.mjs new file mode 100644 index 00000000..ff90b109 --- /dev/null +++ b/cli/test/tasks-proof-requirement.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, test } from "node:test"; + +import { captureIo, fakePortal, isolateConfig } from "./helpers.mjs"; + +isolateConfig(); +const realFetch = globalThis.fetch; +const { run } = await import("../src/cli.mjs"); + +afterEach(() => { + globalThis.fetch = realFetch; + process.env.DATAOPS_TOKEN = "dops_test"; +}); + +describe("tasks --proof-requirement", () => { + test("create sends the proofRequirement with label and optional flag", async () => { + const calls = fakePortal({ "POST /api/tasks": ({ body }) => ({ id: "t1", date: "2026-11-09", ...body }) }); + assert.equal(await run([ + "tasks", "create", "--description", "Publish", "--proof-requirement", "url", + "--proof-label", "Published post", "--proof-optional", "--json", + ], captureIo()), 0); + assert.deepEqual(calls[0].body.proofRequirement, { type: "url", label: "Published post", required: false }); + }); + + test("a bare type sends only the type; an unknown type is a usage error", async () => { + const calls = fakePortal({ "POST /api/tasks": ({ body }) => ({ id: "t1", date: "2026-11-09", ...body }) }); + assert.equal(await run(["tasks", "create", "--description", "Publish", "--proof-requirement", "file", "--json"], captureIo()), 0); + assert.deepEqual(calls[0].body.proofRequirement, { type: "file" }); + assert.equal(await run(["tasks", "create", "--description", "Publish", "--proof-requirement", "photo"], captureIo()), 2); + assert.equal(await run(["tasks", "create", "--description", "Publish", "--proof-label", "x"], captureIo()), 2); + assert.equal(calls.length, 1); + }); +}); From 62cc13fea6ac027a272b7a288f4f40baf0247d86 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Mon, 9 Nov 2026 11:04:16 +0100 Subject: [PATCH 2/3] Add templates add-task-refs: append new Task definitions without a full update --- cli/src/commands/templates.mjs | 55 ++++++++++++++++++++++++++++++++-- cli/test/templates.test.mjs | 34 +++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/templates.mjs b/cli/src/commands/templates.mjs index be2c016a..1af303c0 100644 --- a/cli/src/commands/templates.mjs +++ b/cli/src/commands/templates.mjs @@ -16,6 +16,7 @@ Usage dataops templates update (--from-file | --task --offset-days [--anchor start|end] [--task-name ] [--name ]) [--expected-version N] [--revision R] [--dry-run] + dataops templates add-task-refs --from-file [--expected-version N] [--revision R] [--dry-run] dataops templates reset [--expected-version N] [--revision R] [--dry-run] dataops templates delete --yes [--expected-version N] [--revision R] dataops templates sync @@ -25,6 +26,10 @@ Templates; it needs the knowledge repository's CI identity and runs in its publish workflow, so a push to main refreshes them without a deploy. Pinned (edited) Templates keep their pin and are reported. +add-task-refs appends Task definitions (a list, or an object with taskDefinitions) +whose refId the Template does not have yet, keeping order and the new definitions' +own fields; refIds already present are skipped and nothing is ever removed or edited. + Create a Card from one with: dataops cards create --title T --date D --template Writes pin (PUT) or restore (reset) the whole effective definition: Git stays @@ -118,7 +123,7 @@ async function loadYaml() { } } -async function definitionFile(args) { +async function definitionFile(args, { allowList = false } = {}) { const path = optionalString(args, "fromFile"); if (path === undefined) return undefined; let text; @@ -136,7 +141,7 @@ async function definitionFile(args) { } else { parsed = JSON.parse(text); } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!parsed || typeof parsed !== "object" || (Array.isArray(parsed) && !allowList)) { throw new UsageError(`--from-file ${path} must hold a template definition object.`); } return parsed; @@ -337,6 +342,50 @@ export async function update(args, io) { return result; } +/** Append Task definitions whose refId the Template lacks; existing ones are never changed or removed. */ +export async function addTaskRefs(args, io) { + const ref = positional(args, 0, "dataops templates add-task-refs --from-file "); + const api = apiFor(args); + const parsed = await definitionFile(args, { allowList: true }); + if (parsed === undefined) throw new UsageError("--from-file is required: a list of Task definitions, or an object with taskDefinitions."); + const incoming = Array.isArray(parsed) ? parsed : parsed.taskDefinitions; + if (!Array.isArray(incoming) || incoming.length === 0) throw new UsageError("--from-file must hold a non-empty list of Task definitions."); + const template = await findTemplate(api, ref); + const { expectedVersion, revision } = versionPair(args, template, undefined); + const base = stripServerOwned(structuredClone(template)); + const definitions = [...(base.taskDefinitions || [])]; + const known = new Set(definitions.map((definition) => definition.refId)); + const added = []; + const skipped = []; + for (const definition of incoming) { + if (!definition || typeof definition.refId !== "string" || !definition.refId) { + throw new UsageError("Every Task definition needs a refId."); + } + if (known.has(definition.refId)) skipped.push(definition.refId); + else { + known.add(definition.refId); + definitions.push(definition); + added.push(definition.refId); + } + } + const summary = { template: template.id, added, skipped }; + if (!added.length) { + if (args.json) return { ...summary, unchanged: true }; + io.print(`Nothing to add: ${template.id} already has ${skipped.join(", ")}.`); + return { ...summary, unchanged: true }; + } + const query = args.dryRun ? { dryRun: "true" } : undefined; + let result; + try { + result = await api.put(`/api/templates/${enc(template.id)}`, { expectedVersion, revision, definition: { ...base, taskDefinitions: definitions } }, { query }); + } catch (error) { + describeTemplateWriteError(error, ref); + } + if (args.json) return { ...summary, result }; + io.print(`${result.dryRun ? "Dry run: would add" : "Added"} ${added.join(", ")} to ${template.id}${skipped.length ? ` (already present: ${skipped.join(", ")})` : ""}.`); + return { ...summary, result }; +} + export async function reset(args, io) { const ref = positional(args, 0, "dataops templates reset [--expected-version N] [--revision R] [--dry-run]"); const api = apiFor(args); @@ -391,4 +440,4 @@ export async function sync(args, io) { return result; } -export const commands = { list, show, create, update, reset, delete: remove, sync }; +export const commands = { list, show, create, update, "add-task-refs": addTaskRefs, reset, delete: remove, sync }; diff --git a/cli/test/templates.test.mjs b/cli/test/templates.test.mjs index 67f4c551..210950e8 100644 --- a/cli/test/templates.test.mjs +++ b/cli/test/templates.test.mjs @@ -143,6 +143,40 @@ describe("templates write commands", () => { assert.match(io.text(), /version 4/); }); + test("add-task-refs appends only new refIds in order and never edits or removes", async () => { + const calls = fakePortal({ + ...findRoutes, + "PUT /api/templates/tpl-1": { template: { ...template, version: 4 } }, + }); + const file = path.join(dir, "tasks.json"); + writeFileSync(file, JSON.stringify([ + { refId: "b", description: "Changed B", offsetDays: 99 }, + { refId: "c", description: "Task C", offsetDays: 9 }, + { refId: "d", description: "Task D", offsetDays: 10 }, + { refId: "c", description: "Duplicate C", offsetDays: 1 }, + ])); + const io = captureIo(); + assert.equal(await run(["templates", "add-task-refs", "tpl-1", "--from-file", file], io), 0); + const put = calls.at(-1); + assert.deepEqual(put.body.definition.taskDefinitions, [ + ...template.taskDefinitions, + { refId: "c", description: "Task C", offsetDays: 9 }, + { refId: "d", description: "Task D", offsetDays: 10 }, + ]); + assert.deepEqual({ expectedVersion: put.body.expectedVersion, revision: put.body.revision }, { expectedVersion: 3, revision: "rev-1" }); + assert.match(io.text(), /Added c, d .*already present: b/); + }); + + test("add-task-refs with only known refIds writes nothing", async () => { + const calls = fakePortal(findRoutes); + const file = path.join(dir, "tasks.json"); + writeFileSync(file, JSON.stringify({ taskDefinitions: [{ refId: "a", description: "again", offsetDays: 5 }] })); + const io = captureIo(); + assert.equal(await run(["templates", "add-task-refs", "tpl-1", "--from-file", file], io), 0); + assert.ok(calls.every((call) => call.method === "GET")); + assert.match(io.text(), /Nothing to add/); + }); + test("explicit --expected-version/--revision win over a fresh fetch", async () => { const calls = fakePortal({ ...findRoutes, From a0f28773d281e1a33f337dc75af8597e6272a425 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Mon, 9 Nov 2026 11:04:16 +0100 Subject: [PATCH 3/3] Accept