arch-insight-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/PKG-INFO +24 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/README.md +23 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_cli/__init__.py +1 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_cli/main.py +118 -2
- arch_insight_cli-0.3.0/arch_cli/work.py +134 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_insight_cli.egg-info/PKG-INFO +24 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_insight_cli.egg-info/SOURCES.txt +3 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/pyproject.toml +1 -1
- arch_insight_cli-0.3.0/tests/test_work.py +80 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_cli/auth.py +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_cli/comms.py +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_insight_cli.egg-info/dependency_links.txt +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_insight_cli.egg-info/entry_points.txt +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_insight_cli.egg-info/requires.txt +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_insight_cli.egg-info/top_level.txt +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/setup.cfg +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/tests/test_comms.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arch-insight-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line client for ARCH-Insight tasks, factsheets, secrets, and developer platform.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -68,6 +68,29 @@ JSON imports create rows one at a time through the same API used by the web
|
|
|
68
68
|
app. If an item fails, earlier successful items remain created. ZIP imports use
|
|
69
69
|
the application's import endpoint and report imported and skipped entries.
|
|
70
70
|
|
|
71
|
+
## Projects, tasks, tickets and notes
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
arch project list
|
|
75
|
+
arch project create "Webshop" --priority high
|
|
76
|
+
arch task create "Release Stripe production account" --project-id 12 --assignee st@example.com --due-date 2026-09-14
|
|
77
|
+
arch task update 41 --status in_review
|
|
78
|
+
arch ticket create "Alfen box offline" --message "Since 2026-09-14 after reset" --priority high
|
|
79
|
+
arch ticket reply 7 --message "2026-09-15: back online after factory reset"
|
|
80
|
+
arch document create "Meeting 2026-10-05" --file minutes.md --folder "Change Control Board"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Move a whole list in at once -- projects, their tasks, a dated status log
|
|
84
|
+
kept as a ticket, and notes -- from one JSON file. Re-running it creates
|
|
85
|
+
nothing twice:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
arch project import plan.json --dry-run
|
|
89
|
+
arch project import plan.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
See `arch_cli/work.py` for the file format.
|
|
93
|
+
|
|
71
94
|
## Marketing e-mail
|
|
72
95
|
|
|
73
96
|
Contact lists drop malformed addresses and reserved domains (`example.com`,
|
|
@@ -60,6 +60,29 @@ JSON imports create rows one at a time through the same API used by the web
|
|
|
60
60
|
app. If an item fails, earlier successful items remain created. ZIP imports use
|
|
61
61
|
the application's import endpoint and report imported and skipped entries.
|
|
62
62
|
|
|
63
|
+
## Projects, tasks, tickets and notes
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
arch project list
|
|
67
|
+
arch project create "Webshop" --priority high
|
|
68
|
+
arch task create "Release Stripe production account" --project-id 12 --assignee st@example.com --due-date 2026-09-14
|
|
69
|
+
arch task update 41 --status in_review
|
|
70
|
+
arch ticket create "Alfen box offline" --message "Since 2026-09-14 after reset" --priority high
|
|
71
|
+
arch ticket reply 7 --message "2026-09-15: back online after factory reset"
|
|
72
|
+
arch document create "Meeting 2026-10-05" --file minutes.md --folder "Change Control Board"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Move a whole list in at once -- projects, their tasks, a dated status log
|
|
76
|
+
kept as a ticket, and notes -- from one JSON file. Re-running it creates
|
|
77
|
+
nothing twice:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
arch project import plan.json --dry-run
|
|
81
|
+
arch project import plan.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
See `arch_cli/work.py` for the file format.
|
|
85
|
+
|
|
63
86
|
## Marketing e-mail
|
|
64
87
|
|
|
65
88
|
Contact lists drop malformed addresses and reserved domains (`example.com`,
|
|
@@ -18,7 +18,7 @@ import urllib.parse
|
|
|
18
18
|
import urllib.request
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
-
from arch_cli import auth, comms
|
|
21
|
+
from arch_cli import auth, comms, work
|
|
22
22
|
|
|
23
23
|
DEFAULT_API = "https://arch-insight.com"
|
|
24
24
|
TIMEOUT = 60
|
|
@@ -402,6 +402,112 @@ def org_logo(args):
|
|
|
402
402
|
emit(request(args, "POST", "/api/orgs/me/logo", body, content_type=content_type))
|
|
403
403
|
|
|
404
404
|
|
|
405
|
+
# --- projects, tasks, tickets -------------------------------------------------
|
|
406
|
+
def project_list(args):
|
|
407
|
+
emit([{k: r.get(k) for k in ("id", "name", "status", "priority")}
|
|
408
|
+
for r in request(args, "GET", "/api/projects")])
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def project_create(args):
|
|
412
|
+
emit(request(args, "POST", "/api/projects", {
|
|
413
|
+
"name": args.name, "status": args.status, "priority": args.priority,
|
|
414
|
+
"description": comms.read_text(args.description_file) if args.description_file else args.description}))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def project_import(args):
|
|
418
|
+
report = work.import_plan(request, args, work.load_plan(args.file), args.dry_run)
|
|
419
|
+
report.update(work.import_documents(request, args, work.load_documents(args.file), args.dry_run))
|
|
420
|
+
emit(report)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def document_list(args):
|
|
424
|
+
emit([{k: r.get(k) for k in ("id", "title", "folder", "template", "project_id")}
|
|
425
|
+
for r in request(args, "GET", "/api/docs/requirements")])
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def document_create(args):
|
|
429
|
+
body = {"title": args.title, "template": "note", "body": comms.read_text(args.file),
|
|
430
|
+
"body_format": "markdown"}
|
|
431
|
+
if args.folder:
|
|
432
|
+
body["folder"] = args.folder
|
|
433
|
+
if args.project_id:
|
|
434
|
+
body["project_id"] = args.project_id
|
|
435
|
+
emit(request(args, "POST", "/api/docs/requirements", body))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def task_update(args):
|
|
439
|
+
body = {k: v for k, v in (("status", args.status), ("priority", args.priority),
|
|
440
|
+
("due_date", args.due_date), ("title", args.title)) if v is not None}
|
|
441
|
+
if args.assignee:
|
|
442
|
+
body["assignees"] = args.assignee
|
|
443
|
+
if not body:
|
|
444
|
+
raise CliError("Nothing to change: pass --status, --assignee, --due-date, --priority or --title.")
|
|
445
|
+
emit(request(args, "PUT", f"/api/agile/tasks/{args.task_id}", body))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def ticket_list(args):
|
|
449
|
+
page = request(args, "GET", "/api/tickets")
|
|
450
|
+
emit([{k: r.get(k) for k in ("id", "title", "status", "priority", "due_date")}
|
|
451
|
+
for r in (page.get("items") or [])])
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def ticket_create(args):
|
|
455
|
+
body = {"title": args.title, "message": args.message, "priority": args.priority}
|
|
456
|
+
if args.due_date:
|
|
457
|
+
body["due_date"] = args.due_date
|
|
458
|
+
emit(request(args, "POST", "/api/tickets", body))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def ticket_reply(args):
|
|
462
|
+
emit(request(args, "POST", f"/api/tickets/{args.ticket_id}/reply", {"message": args.message}))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def ticket_status(args):
|
|
466
|
+
emit(request(args, "POST", f"/api/tickets/{args.ticket_id}/status", {"status": args.status}))
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _work_parsers(areas) -> None:
|
|
470
|
+
pr = areas.add_parser("project", help="Projects").add_subparsers(dest="action", required=True)
|
|
471
|
+
pr.add_parser("list").set_defaults(func=project_list)
|
|
472
|
+
pc = pr.add_parser("create")
|
|
473
|
+
pc.add_argument("name")
|
|
474
|
+
pc.add_argument("--status", default="active")
|
|
475
|
+
pc.add_argument("--priority", choices=work.PRIORITIES, default="medium")
|
|
476
|
+
pc.add_argument("--description")
|
|
477
|
+
pc.add_argument("--description-file")
|
|
478
|
+
pc.set_defaults(func=project_create)
|
|
479
|
+
pi = pr.add_parser("import", help="Projects with their tasks and status logs from one JSON file")
|
|
480
|
+
pi.add_argument("file")
|
|
481
|
+
pi.add_argument("--dry-run", action="store_true")
|
|
482
|
+
pi.set_defaults(func=project_import)
|
|
483
|
+
|
|
484
|
+
dc = areas.add_parser("document", help="Notes and documents").add_subparsers(dest="action", required=True)
|
|
485
|
+
dc.add_parser("list").set_defaults(func=document_list)
|
|
486
|
+
dcc = dc.add_parser("create", help="A note from a Markdown file")
|
|
487
|
+
dcc.add_argument("title")
|
|
488
|
+
dcc.add_argument("--file", required=True, help="Markdown body")
|
|
489
|
+
dcc.add_argument("--folder")
|
|
490
|
+
dcc.add_argument("--project-id", type=int)
|
|
491
|
+
dcc.set_defaults(func=document_create)
|
|
492
|
+
|
|
493
|
+
tk = areas.add_parser("ticket", help="Helpdesk tickets").add_subparsers(dest="action", required=True)
|
|
494
|
+
tk.add_parser("list").set_defaults(func=ticket_list)
|
|
495
|
+
tc = tk.add_parser("create")
|
|
496
|
+
tc.add_argument("title")
|
|
497
|
+
tc.add_argument("--message", required=True)
|
|
498
|
+
tc.add_argument("--priority", choices=("low", "medium", "high", "urgent"), default="medium")
|
|
499
|
+
tc.add_argument("--due-date")
|
|
500
|
+
tc.set_defaults(func=ticket_create)
|
|
501
|
+
trp = tk.add_parser("reply")
|
|
502
|
+
trp.add_argument("ticket_id", type=int)
|
|
503
|
+
trp.add_argument("--message", required=True)
|
|
504
|
+
trp.set_defaults(func=ticket_reply)
|
|
505
|
+
ts = tk.add_parser("status")
|
|
506
|
+
ts.add_argument("ticket_id", type=int)
|
|
507
|
+
ts.add_argument("status", choices=("open", "pending_customer", "closed"))
|
|
508
|
+
ts.set_defaults(func=ticket_status)
|
|
509
|
+
|
|
510
|
+
|
|
405
511
|
def _comms_parsers(areas) -> None:
|
|
406
512
|
lg = areas.add_parser("login", help="Sign in (browser, or --email for unattended use)")
|
|
407
513
|
lg.add_argument("--email", help="Sign in with a password instead of the browser; "
|
|
@@ -527,6 +633,16 @@ def build_parser():
|
|
|
527
633
|
dl = deploy.add_parser("list"); dl.add_argument("environment", nargs="?", default=""); dl.set_defaults(func=deploy_list)
|
|
528
634
|
|
|
529
635
|
_comms_parsers(areas)
|
|
636
|
+
_work_parsers(areas)
|
|
637
|
+
tu = [a for a in areas.choices["task"]._subparsers._group_actions][0].add_parser(
|
|
638
|
+
"update", help="Change a task's status, assignees, due date, priority or title")
|
|
639
|
+
tu.add_argument("task_id", type=int)
|
|
640
|
+
tu.add_argument("--status", choices=work.TASK_STATUSES)
|
|
641
|
+
tu.add_argument("--priority", choices=work.PRIORITIES)
|
|
642
|
+
tu.add_argument("--due-date")
|
|
643
|
+
tu.add_argument("--title")
|
|
644
|
+
tu.add_argument("--assignee", action="append")
|
|
645
|
+
tu.set_defaults(func=task_update)
|
|
530
646
|
return parser
|
|
531
647
|
|
|
532
648
|
|
|
@@ -536,7 +652,7 @@ def main(argv=None):
|
|
|
536
652
|
try:
|
|
537
653
|
result = args.func(args)
|
|
538
654
|
return result if isinstance(result, int) else 0
|
|
539
|
-
except (CliError, comms.CommsError) as exc:
|
|
655
|
+
except (CliError, comms.CommsError, work.WorkError) as exc:
|
|
540
656
|
print(f"arch: {exc}", file=sys.stderr)
|
|
541
657
|
return 2
|
|
542
658
|
except KeyboardInterrupt:
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Projects, tasks and helpdesk tickets from the command line.
|
|
2
|
+
|
|
3
|
+
`arch project import` takes a whole plan in one JSON file -- projects, their
|
|
4
|
+
tasks, and a status log kept as a ticket -- so a list kept somewhere else
|
|
5
|
+
(a OneNote page, a spreadsheet) comes over in one command instead of
|
|
6
|
+
hundreds of clicks. It is re-runnable: a project that already exists by name
|
|
7
|
+
is reported and left alone, so a second run creates nothing twice.
|
|
8
|
+
|
|
9
|
+
{"projects": [{
|
|
10
|
+
"name": "Webshop", "priority": "high", "status": "active",
|
|
11
|
+
"description": "...",
|
|
12
|
+
"tasks": [{"title": "Release Stripe production account",
|
|
13
|
+
"assignees": ["someone@example.com"], "due_date": "2026-09-14",
|
|
14
|
+
"status": "done", "priority": "medium", "description": "..."}],
|
|
15
|
+
"log": {"title": "Webshop - status log",
|
|
16
|
+
"entries": ["2026-09-05: URL redirected by nginx ...", "..."]}
|
|
17
|
+
}],
|
|
18
|
+
"documents": [{"title": "CCB 2026-10-05", "folder": "Change Control Board",
|
|
19
|
+
"body": "# Markdown ...", "project": "Webshop"}]}
|
|
20
|
+
|
|
21
|
+
Documents are notes (Markdown) -- meeting minutes, a dated snapshot -- in a
|
|
22
|
+
folder, optionally attached to one of the projects by name.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
PRIORITIES = ("low", "medium", "high")
|
|
30
|
+
TASK_STATUSES = ("todo", "in_progress", "in_review", "done")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WorkError(RuntimeError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_plan(path: str) -> list[dict]:
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, ValueError) as exc:
|
|
41
|
+
raise WorkError(f"Cannot read {path}: {exc}") from exc
|
|
42
|
+
projects = data.get("projects") if isinstance(data, dict) else None
|
|
43
|
+
if not isinstance(projects, list) or not all(isinstance(p, dict) and p.get("name") for p in projects):
|
|
44
|
+
raise WorkError('Expected {"projects": [{"name": ..., "tasks": [...]}, ...]}.')
|
|
45
|
+
for p in projects:
|
|
46
|
+
if p.get("priority") and p["priority"] not in PRIORITIES:
|
|
47
|
+
raise WorkError(f"{p['name']}: priority must be one of {', '.join(PRIORITIES)}.")
|
|
48
|
+
for t in p.get("tasks") or []:
|
|
49
|
+
if not t.get("title"):
|
|
50
|
+
raise WorkError(f"{p['name']}: every task needs a title.")
|
|
51
|
+
if t.get("status") and t["status"] not in TASK_STATUSES:
|
|
52
|
+
raise WorkError(f"{p['name']} / {t['title']}: status must be one of {', '.join(TASK_STATUSES)}.")
|
|
53
|
+
return projects
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_documents(path: str) -> list[dict]:
|
|
57
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
58
|
+
docs = data.get("documents") or [] if isinstance(data, dict) else []
|
|
59
|
+
if not all(isinstance(d, dict) and d.get("title") for d in docs):
|
|
60
|
+
raise WorkError("Every document needs a title.")
|
|
61
|
+
return docs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def import_documents(request, args, docs: list[dict], dry_run: bool) -> dict:
|
|
65
|
+
"""Create the notes that do not exist yet (same title in the same folder)."""
|
|
66
|
+
have = {((d.get("folder") or ""), (d.get("title") or "").strip().casefold())
|
|
67
|
+
for d in request(args, "GET", "/api/docs/requirements")}
|
|
68
|
+
projects = {(p.get("name") or "").strip().casefold(): p["id"]
|
|
69
|
+
for p in request(args, "GET", "/api/projects")}
|
|
70
|
+
made, skipped = 0, 0
|
|
71
|
+
for d in docs:
|
|
72
|
+
key = ((d.get("folder") or ""), d["title"].strip().casefold())
|
|
73
|
+
if key in have:
|
|
74
|
+
skipped += 1
|
|
75
|
+
continue
|
|
76
|
+
if not dry_run:
|
|
77
|
+
body = {"title": d["title"], "template": "note", "body": d.get("body") or "",
|
|
78
|
+
"body_format": "markdown"}
|
|
79
|
+
if d.get("folder"):
|
|
80
|
+
body["folder"] = d["folder"]
|
|
81
|
+
pid = projects.get((d.get("project") or "").strip().casefold())
|
|
82
|
+
if pid:
|
|
83
|
+
body["project_id"] = pid
|
|
84
|
+
request(args, "POST", "/api/docs/requirements", body)
|
|
85
|
+
made += 1
|
|
86
|
+
return {"documents_created": made, "documents_skipped": skipped}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def import_plan(request, args, projects: list[dict], dry_run: bool) -> dict:
|
|
90
|
+
"""Create what does not exist yet; return what was done."""
|
|
91
|
+
existing = {(p.get("name") or "").strip().casefold(): p
|
|
92
|
+
for p in request(args, "GET", "/api/projects")}
|
|
93
|
+
report = {"projects_created": 0, "projects_skipped": [], "tasks_created": 0,
|
|
94
|
+
"tickets_created": 0, "log_entries": 0}
|
|
95
|
+
for spec in projects:
|
|
96
|
+
name = spec["name"].strip()
|
|
97
|
+
if name.casefold() in existing:
|
|
98
|
+
report["projects_skipped"].append(name)
|
|
99
|
+
continue
|
|
100
|
+
if dry_run:
|
|
101
|
+
report["projects_created"] += 1
|
|
102
|
+
report["tasks_created"] += len(spec.get("tasks") or [])
|
|
103
|
+
continue
|
|
104
|
+
ticket_note = ""
|
|
105
|
+
log = spec.get("log") or {}
|
|
106
|
+
entries = [e for e in (log.get("entries") or []) if str(e).strip()]
|
|
107
|
+
if entries:
|
|
108
|
+
ticket = request(args, "POST", "/api/tickets", {
|
|
109
|
+
"title": log.get("title") or f"{name} - status log",
|
|
110
|
+
"message": entries[0],
|
|
111
|
+
"priority": spec.get("priority") or "medium",
|
|
112
|
+
})
|
|
113
|
+
for entry in entries[1:]:
|
|
114
|
+
request(args, "POST", f"/api/tickets/{ticket['id']}/reply", {"message": entry})
|
|
115
|
+
report["tickets_created"] += 1
|
|
116
|
+
report["log_entries"] += len(entries)
|
|
117
|
+
ticket_note = f"\n\nStatus log: helpdesk ticket #{ticket['id']}."
|
|
118
|
+
project = request(args, "POST", "/api/projects", {
|
|
119
|
+
"name": name,
|
|
120
|
+
"status": spec.get("status") or "active",
|
|
121
|
+
"priority": spec.get("priority") or "medium",
|
|
122
|
+
"description": (spec.get("description") or "") + ticket_note,
|
|
123
|
+
})
|
|
124
|
+
report["projects_created"] += 1
|
|
125
|
+
for t in spec.get("tasks") or []:
|
|
126
|
+
body = {"title": t["title"], "project_id": project["id"],
|
|
127
|
+
"status": t.get("status") or "todo",
|
|
128
|
+
"priority": t.get("priority") or spec.get("priority") or "medium"}
|
|
129
|
+
for key in ("description", "due_date", "assignees"):
|
|
130
|
+
if t.get(key):
|
|
131
|
+
body[key] = t[key]
|
|
132
|
+
request(args, "POST", "/api/agile/tasks", body)
|
|
133
|
+
report["tasks_created"] += 1
|
|
134
|
+
return report
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arch-insight-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line client for ARCH-Insight tasks, factsheets, secrets, and developer platform.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -68,6 +68,29 @@ JSON imports create rows one at a time through the same API used by the web
|
|
|
68
68
|
app. If an item fails, earlier successful items remain created. ZIP imports use
|
|
69
69
|
the application's import endpoint and report imported and skipped entries.
|
|
70
70
|
|
|
71
|
+
## Projects, tasks, tickets and notes
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
arch project list
|
|
75
|
+
arch project create "Webshop" --priority high
|
|
76
|
+
arch task create "Release Stripe production account" --project-id 12 --assignee st@example.com --due-date 2026-09-14
|
|
77
|
+
arch task update 41 --status in_review
|
|
78
|
+
arch ticket create "Alfen box offline" --message "Since 2026-09-14 after reset" --priority high
|
|
79
|
+
arch ticket reply 7 --message "2026-09-15: back online after factory reset"
|
|
80
|
+
arch document create "Meeting 2026-10-05" --file minutes.md --folder "Change Control Board"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Move a whole list in at once -- projects, their tasks, a dated status log
|
|
84
|
+
kept as a ticket, and notes -- from one JSON file. Re-running it creates
|
|
85
|
+
nothing twice:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
arch project import plan.json --dry-run
|
|
89
|
+
arch project import plan.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
See `arch_cli/work.py` for the file format.
|
|
93
|
+
|
|
71
94
|
## Marketing e-mail
|
|
72
95
|
|
|
73
96
|
Contact lists drop malformed addresses and reserved domains (`example.com`,
|
|
@@ -4,10 +4,12 @@ arch_cli/__init__.py
|
|
|
4
4
|
arch_cli/auth.py
|
|
5
5
|
arch_cli/comms.py
|
|
6
6
|
arch_cli/main.py
|
|
7
|
+
arch_cli/work.py
|
|
7
8
|
arch_insight_cli.egg-info/PKG-INFO
|
|
8
9
|
arch_insight_cli.egg-info/SOURCES.txt
|
|
9
10
|
arch_insight_cli.egg-info/dependency_links.txt
|
|
10
11
|
arch_insight_cli.egg-info/entry_points.txt
|
|
11
12
|
arch_insight_cli.egg-info/requires.txt
|
|
12
13
|
arch_insight_cli.egg-info/top_level.txt
|
|
13
|
-
tests/test_comms.py
|
|
14
|
+
tests/test_comms.py
|
|
15
|
+
tests/test_work.py
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""`arch project import`: a whole plan in one command, re-runnable."""
|
|
2
|
+
import json
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from arch_cli import main, work
|
|
8
|
+
|
|
9
|
+
PLAN = {"projects": [{"name": "Webshop", "priority": "high",
|
|
10
|
+
"tasks": [{"title": "Release Stripe", "assignees": ["st@x.com"], "due_date": "2026-09-14"},
|
|
11
|
+
{"title": "Go live", "status": "done"}],
|
|
12
|
+
"log": {"entries": ["2026-09-05: redirected", "2026-09-14: terms updated"]}},
|
|
13
|
+
{"name": "Existing"}],
|
|
14
|
+
"documents": [{"title": "CCB 2026-10-05", "folder": "CCB", "body": "# x", "project": "Webshop"},
|
|
15
|
+
{"title": "Old", "folder": "CCB", "body": "y"}]}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fake_server():
|
|
19
|
+
calls = []
|
|
20
|
+
state = {"projects": [{"id": 1, "name": "Existing"}], "docs": [{"title": "Old", "folder": "CCB"}]}
|
|
21
|
+
|
|
22
|
+
def request(args, method, path, body=None, **kw):
|
|
23
|
+
calls.append((method, path, body))
|
|
24
|
+
if (method, path) == ("GET", "/api/projects"):
|
|
25
|
+
return state["projects"]
|
|
26
|
+
if (method, path) == ("GET", "/api/docs/requirements"):
|
|
27
|
+
return state["docs"]
|
|
28
|
+
if (method, path) == ("POST", "/api/projects"):
|
|
29
|
+
row = {"id": 10 + len(state["projects"]), **body}
|
|
30
|
+
state["projects"].append(row)
|
|
31
|
+
return row
|
|
32
|
+
if (method, path) == ("POST", "/api/tickets"):
|
|
33
|
+
return {"id": 77}
|
|
34
|
+
return {}
|
|
35
|
+
return request, calls
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_a_plan_creates_projects_tasks_and_the_status_log(tmp_path):
|
|
39
|
+
f = tmp_path / "plan.json"
|
|
40
|
+
f.write_text(json.dumps(PLAN), encoding="utf-8")
|
|
41
|
+
request, calls = fake_server()
|
|
42
|
+
report = work.import_plan(request, SimpleNamespace(), work.load_plan(str(f)), dry_run=False)
|
|
43
|
+
assert report == {"projects_created": 1, "projects_skipped": ["Existing"], "tasks_created": 2,
|
|
44
|
+
"tickets_created": 1, "log_entries": 2}
|
|
45
|
+
tasks = [b for m, p, b in calls if (m, p) == ("POST", "/api/agile/tasks")]
|
|
46
|
+
assert tasks[0]["assignees"] == ["st@x.com"] and tasks[0]["due_date"] == "2026-09-14"
|
|
47
|
+
assert tasks[1]["status"] == "done"
|
|
48
|
+
project = next(b for m, p, b in calls if (m, p) == ("POST", "/api/projects"))
|
|
49
|
+
assert "ticket #77" in project["description"]
|
|
50
|
+
assert [b["message"] for m, p, b in calls if p == "/api/tickets/77/reply"] == ["2026-09-14: terms updated"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_documents_are_notes_and_not_created_twice(tmp_path):
|
|
54
|
+
f = tmp_path / "plan.json"
|
|
55
|
+
f.write_text(json.dumps(PLAN), encoding="utf-8")
|
|
56
|
+
request, calls = fake_server()
|
|
57
|
+
work.import_plan(request, SimpleNamespace(), work.load_plan(str(f)), dry_run=False)
|
|
58
|
+
report = work.import_documents(request, SimpleNamespace(), work.load_documents(str(f)), dry_run=False)
|
|
59
|
+
assert report == {"documents_created": 1, "documents_skipped": 1}
|
|
60
|
+
doc = next(b for m, p, b in calls if (m, p) == ("POST", "/api/docs/requirements"))
|
|
61
|
+
assert doc["template"] == "note" and doc["folder"] == "CCB" and doc["project_id"] == 11
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_a_bad_plan_is_refused_before_anything_is_sent(tmp_path):
|
|
65
|
+
f = tmp_path / "plan.json"
|
|
66
|
+
f.write_text(json.dumps({"projects": [{"name": "X", "tasks": [{"title": "t", "status": "nope"}]}]}),
|
|
67
|
+
encoding="utf-8")
|
|
68
|
+
with pytest.raises(work.WorkError):
|
|
69
|
+
work.load_plan(str(f))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_the_new_commands_parse():
|
|
73
|
+
parser = main.build_parser()
|
|
74
|
+
for argv in (["project", "list"], ["project", "create", "Webshop", "--priority", "high"],
|
|
75
|
+
["project", "import", "plan.json", "--dry-run"],
|
|
76
|
+
["task", "update", "5", "--status", "in_review", "--assignee", "a@b.c"],
|
|
77
|
+
["ticket", "create", "T", "--message", "m"], ["ticket", "reply", "3", "--message", "m"],
|
|
78
|
+
["ticket", "status", "3", "pending_customer"],
|
|
79
|
+
["document", "create", "Minutes", "--file", "m.md", "--folder", "CCB"]):
|
|
80
|
+
assert callable(parser.parse_args(argv).func), argv
|
|
File without changes
|
|
File without changes
|
{arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_insight_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{arch_insight_cli-0.2.0 → arch_insight_cli-0.3.0}/arch_insight_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|