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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arch-insight-cli
3
- Version: 0.2.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`,
@@ -1,3 +1,3 @@
1
1
  """ARCH-Insight command-line client."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arch-insight-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Command-line client for ARCH-Insight tasks, factsheets, secrets, and developer platform."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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