arch-insight-cli 0.2.0__tar.gz → 0.4.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.4.0}/PKG-INFO +41 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/README.md +40 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_cli/__init__.py +1 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_cli/main.py +169 -5
- arch_insight_cli-0.4.0/arch_cli/work.py +134 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_insight_cli.egg-info/PKG-INFO +41 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_insight_cli.egg-info/SOURCES.txt +3 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/pyproject.toml +1 -1
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/tests/test_comms.py +1 -1
- arch_insight_cli-0.4.0/tests/test_work.py +103 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_cli/auth.py +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_cli/comms.py +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_insight_cli.egg-info/dependency_links.txt +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_insight_cli.egg-info/entry_points.txt +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_insight_cli.egg-info/requires.txt +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_insight_cli.egg-info/top_level.txt +0 -0
- {arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arch-insight-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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,46 @@ 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
|
+
|
|
94
|
+
## Organisation
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
arch org show # name, members, whether Entra sign-in is on
|
|
98
|
+
arch org entra show # the Redirect URI to register in Entra
|
|
99
|
+
ARCH_ENTRA_CLIENT_SECRET=... arch org entra set --tenant-id <Directory ID> --client-id <Application ID>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
With Entra sign-in on, members choose "Sign in with your organization" on the
|
|
103
|
+
login screen and type the organisation's name. `--domain` (a domain proven
|
|
104
|
+
under Organization -> Access) also sends that domain's addresses to Entra by
|
|
105
|
+
themselves. The secret is read from `ARCH_ENTRA_CLIENT_SECRET` or asked for,
|
|
106
|
+
never taken as an argument.
|
|
107
|
+
|
|
108
|
+
`arch login` prints the organisation it signed in to. A browser sign-in uses
|
|
109
|
+
the account that browser is signed in to; check it before importing.
|
|
110
|
+
|
|
71
111
|
## Marketing e-mail
|
|
72
112
|
|
|
73
113
|
Contact lists drop malformed addresses and reserved domains (`example.com`,
|
|
@@ -60,6 +60,46 @@ 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
|
+
|
|
86
|
+
## Organisation
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
arch org show # name, members, whether Entra sign-in is on
|
|
90
|
+
arch org entra show # the Redirect URI to register in Entra
|
|
91
|
+
ARCH_ENTRA_CLIENT_SECRET=... arch org entra set --tenant-id <Directory ID> --client-id <Application ID>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
With Entra sign-in on, members choose "Sign in with your organization" on the
|
|
95
|
+
login screen and type the organisation's name. `--domain` (a domain proven
|
|
96
|
+
under Organization -> Access) also sends that domain's addresses to Entra by
|
|
97
|
+
themselves. The secret is read from `ARCH_ENTRA_CLIENT_SECRET` or asked for,
|
|
98
|
+
never taken as an argument.
|
|
99
|
+
|
|
100
|
+
`arch login` prints the organisation it signed in to. A browser sign-in uses
|
|
101
|
+
the account that browser is signed in to; check it before importing.
|
|
102
|
+
|
|
63
103
|
## Marketing e-mail
|
|
64
104
|
|
|
65
105
|
Contact lists drop malformed addresses and reserved domains (`example.com`,
|
|
@@ -9,6 +9,7 @@ package.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
|
+
import getpass
|
|
12
13
|
import json
|
|
13
14
|
import os
|
|
14
15
|
import secrets
|
|
@@ -18,7 +19,7 @@ import urllib.parse
|
|
|
18
19
|
import urllib.request
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
|
-
from arch_cli import auth, comms
|
|
22
|
+
from arch_cli import auth, comms, work
|
|
22
23
|
|
|
23
24
|
DEFAULT_API = "https://arch-insight.com"
|
|
24
25
|
TIMEOUT = 60
|
|
@@ -231,8 +232,13 @@ def login(args):
|
|
|
231
232
|
raise CliError(str(exc)) from exc
|
|
232
233
|
args.token = record["access_token"]
|
|
233
234
|
me = request(args, "GET", "/api/auth/me")
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
# The organisation by name, not only its id: a browser sign-in reuses
|
|
236
|
+
# whatever account that browser is signed in to, and "org_id": 7 does not
|
|
237
|
+
# tell anyone they are about to import into the wrong organisation.
|
|
238
|
+
org = request(args, "GET", "/api/orgs/me") if me.get("org_id") else {}
|
|
239
|
+
emit({"signed_in_as": me.get("email"), "organization": org.get("name"),
|
|
240
|
+
"org_id": me.get("org_id"), "roles": me.get("roles"),
|
|
241
|
+
"stored_in": str(auth.credentials_path())})
|
|
236
242
|
|
|
237
243
|
|
|
238
244
|
def logout(args):
|
|
@@ -390,8 +396,42 @@ def campaign_delete(args):
|
|
|
390
396
|
|
|
391
397
|
|
|
392
398
|
# --- organisation (#1072) ---------------------------------------------------------
|
|
399
|
+
ORG_FIELDS = ("id", "name", "website", "industry", "timezone", "subdomain", "member_count")
|
|
400
|
+
|
|
401
|
+
|
|
393
402
|
def org_show(args):
|
|
394
|
-
|
|
403
|
+
# What a person asks "which organisation am I in" for -- not the billing
|
|
404
|
+
# identifiers, purchase tokens and join codes the same row also carries.
|
|
405
|
+
org = request(args, "GET", "/api/orgs/me")
|
|
406
|
+
out = {k: org.get(k) for k in ORG_FIELDS}
|
|
407
|
+
out["entra_sign_in"] = bool(org.get("entra_sign_in"))
|
|
408
|
+
out["members"] = [{k: m.get(k) for k in ("name", "email", "roles")} for m in org.get("members") or []]
|
|
409
|
+
emit(out)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def org_entra_show(args):
|
|
413
|
+
emit(request(args, "GET", "/api/orgs/me/entra"))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def org_entra_set(args):
|
|
417
|
+
"""Sign-in with the organisation's own Microsoft Entra ID tenant.
|
|
418
|
+
|
|
419
|
+
The client secret is read from ARCH_ENTRA_CLIENT_SECRET or asked for, never
|
|
420
|
+
taken as an argument (it would land in shell history). Leaving it empty on
|
|
421
|
+
a change keeps the secret already stored."""
|
|
422
|
+
secret = os.environ.get("ARCH_ENTRA_CLIENT_SECRET")
|
|
423
|
+
if secret is None and sys.stdin.isatty():
|
|
424
|
+
secret = getpass.getpass("Client secret (Value; empty keeps the stored one): ")
|
|
425
|
+
body = {"tenant_id": args.tenant_id, "client_id": args.client_id}
|
|
426
|
+
if secret:
|
|
427
|
+
body["client_secret"] = secret
|
|
428
|
+
if args.domain:
|
|
429
|
+
body["domain"] = args.domain
|
|
430
|
+
emit(request(args, "PUT", "/api/orgs/me/entra", body))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def org_entra_disable(args):
|
|
434
|
+
emit(request(args, "DELETE", "/api/orgs/me/entra"))
|
|
395
435
|
|
|
396
436
|
|
|
397
437
|
def org_logo(args):
|
|
@@ -402,6 +442,112 @@ def org_logo(args):
|
|
|
402
442
|
emit(request(args, "POST", "/api/orgs/me/logo", body, content_type=content_type))
|
|
403
443
|
|
|
404
444
|
|
|
445
|
+
# --- projects, tasks, tickets -------------------------------------------------
|
|
446
|
+
def project_list(args):
|
|
447
|
+
emit([{k: r.get(k) for k in ("id", "name", "status", "priority")}
|
|
448
|
+
for r in request(args, "GET", "/api/projects")])
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def project_create(args):
|
|
452
|
+
emit(request(args, "POST", "/api/projects", {
|
|
453
|
+
"name": args.name, "status": args.status, "priority": args.priority,
|
|
454
|
+
"description": comms.read_text(args.description_file) if args.description_file else args.description}))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def project_import(args):
|
|
458
|
+
report = work.import_plan(request, args, work.load_plan(args.file), args.dry_run)
|
|
459
|
+
report.update(work.import_documents(request, args, work.load_documents(args.file), args.dry_run))
|
|
460
|
+
emit(report)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def document_list(args):
|
|
464
|
+
emit([{k: r.get(k) for k in ("id", "title", "folder", "template", "project_id")}
|
|
465
|
+
for r in request(args, "GET", "/api/docs/requirements")])
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def document_create(args):
|
|
469
|
+
body = {"title": args.title, "template": "note", "body": comms.read_text(args.file),
|
|
470
|
+
"body_format": "markdown"}
|
|
471
|
+
if args.folder:
|
|
472
|
+
body["folder"] = args.folder
|
|
473
|
+
if args.project_id:
|
|
474
|
+
body["project_id"] = args.project_id
|
|
475
|
+
emit(request(args, "POST", "/api/docs/requirements", body))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def task_update(args):
|
|
479
|
+
body = {k: v for k, v in (("status", args.status), ("priority", args.priority),
|
|
480
|
+
("due_date", args.due_date), ("title", args.title)) if v is not None}
|
|
481
|
+
if args.assignee:
|
|
482
|
+
body["assignees"] = args.assignee
|
|
483
|
+
if not body:
|
|
484
|
+
raise CliError("Nothing to change: pass --status, --assignee, --due-date, --priority or --title.")
|
|
485
|
+
emit(request(args, "PUT", f"/api/agile/tasks/{args.task_id}", body))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def ticket_list(args):
|
|
489
|
+
page = request(args, "GET", "/api/tickets")
|
|
490
|
+
emit([{k: r.get(k) for k in ("id", "title", "status", "priority", "due_date")}
|
|
491
|
+
for r in (page.get("items") or [])])
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def ticket_create(args):
|
|
495
|
+
body = {"title": args.title, "message": args.message, "priority": args.priority}
|
|
496
|
+
if args.due_date:
|
|
497
|
+
body["due_date"] = args.due_date
|
|
498
|
+
emit(request(args, "POST", "/api/tickets", body))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def ticket_reply(args):
|
|
502
|
+
emit(request(args, "POST", f"/api/tickets/{args.ticket_id}/reply", {"message": args.message}))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def ticket_status(args):
|
|
506
|
+
emit(request(args, "POST", f"/api/tickets/{args.ticket_id}/status", {"status": args.status}))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _work_parsers(areas) -> None:
|
|
510
|
+
pr = areas.add_parser("project", help="Projects").add_subparsers(dest="action", required=True)
|
|
511
|
+
pr.add_parser("list").set_defaults(func=project_list)
|
|
512
|
+
pc = pr.add_parser("create")
|
|
513
|
+
pc.add_argument("name")
|
|
514
|
+
pc.add_argument("--status", default="active")
|
|
515
|
+
pc.add_argument("--priority", choices=work.PRIORITIES, default="medium")
|
|
516
|
+
pc.add_argument("--description")
|
|
517
|
+
pc.add_argument("--description-file")
|
|
518
|
+
pc.set_defaults(func=project_create)
|
|
519
|
+
pi = pr.add_parser("import", help="Projects with their tasks and status logs from one JSON file")
|
|
520
|
+
pi.add_argument("file")
|
|
521
|
+
pi.add_argument("--dry-run", action="store_true")
|
|
522
|
+
pi.set_defaults(func=project_import)
|
|
523
|
+
|
|
524
|
+
dc = areas.add_parser("document", help="Notes and documents").add_subparsers(dest="action", required=True)
|
|
525
|
+
dc.add_parser("list").set_defaults(func=document_list)
|
|
526
|
+
dcc = dc.add_parser("create", help="A note from a Markdown file")
|
|
527
|
+
dcc.add_argument("title")
|
|
528
|
+
dcc.add_argument("--file", required=True, help="Markdown body")
|
|
529
|
+
dcc.add_argument("--folder")
|
|
530
|
+
dcc.add_argument("--project-id", type=int)
|
|
531
|
+
dcc.set_defaults(func=document_create)
|
|
532
|
+
|
|
533
|
+
tk = areas.add_parser("ticket", help="Helpdesk tickets").add_subparsers(dest="action", required=True)
|
|
534
|
+
tk.add_parser("list").set_defaults(func=ticket_list)
|
|
535
|
+
tc = tk.add_parser("create")
|
|
536
|
+
tc.add_argument("title")
|
|
537
|
+
tc.add_argument("--message", required=True)
|
|
538
|
+
tc.add_argument("--priority", choices=("low", "medium", "high", "urgent"), default="medium")
|
|
539
|
+
tc.add_argument("--due-date")
|
|
540
|
+
tc.set_defaults(func=ticket_create)
|
|
541
|
+
trp = tk.add_parser("reply")
|
|
542
|
+
trp.add_argument("ticket_id", type=int)
|
|
543
|
+
trp.add_argument("--message", required=True)
|
|
544
|
+
trp.set_defaults(func=ticket_reply)
|
|
545
|
+
ts = tk.add_parser("status")
|
|
546
|
+
ts.add_argument("ticket_id", type=int)
|
|
547
|
+
ts.add_argument("status", choices=("open", "pending_customer", "closed"))
|
|
548
|
+
ts.set_defaults(func=ticket_status)
|
|
549
|
+
|
|
550
|
+
|
|
405
551
|
def _comms_parsers(areas) -> None:
|
|
406
552
|
lg = areas.add_parser("login", help="Sign in (browser, or --email for unattended use)")
|
|
407
553
|
lg.add_argument("--email", help="Sign in with a password instead of the browser; "
|
|
@@ -468,6 +614,14 @@ def _comms_parsers(areas) -> None:
|
|
|
468
614
|
og.add_parser("show").set_defaults(func=org_show)
|
|
469
615
|
lo = og.add_parser("logo", help="Set the logo shown in e-mail and the app")
|
|
470
616
|
lo.add_argument("file"); lo.set_defaults(func=org_logo)
|
|
617
|
+
en = og.add_parser("entra", help="Sign-in with your Microsoft Entra ID (by organisation name)") .add_subparsers(dest="entra_action", required=True)
|
|
618
|
+
en.add_parser("show", help="The setup, and the Redirect URI to register in Entra").set_defaults(func=org_entra_show)
|
|
619
|
+
es = en.add_parser("set", help="Turn it on or change it; secret from ARCH_ENTRA_CLIENT_SECRET or asked for")
|
|
620
|
+
es.add_argument("--tenant-id", required=True, help="Directory (tenant) ID")
|
|
621
|
+
es.add_argument("--client-id", required=True, help="Application (client) ID")
|
|
622
|
+
es.add_argument("--domain", help="Optional proven domain whose addresses go to Entra by themselves")
|
|
623
|
+
es.set_defaults(func=org_entra_set)
|
|
624
|
+
en.add_parser("disable").set_defaults(func=org_entra_disable)
|
|
471
625
|
|
|
472
626
|
|
|
473
627
|
def build_parser():
|
|
@@ -527,6 +681,16 @@ def build_parser():
|
|
|
527
681
|
dl = deploy.add_parser("list"); dl.add_argument("environment", nargs="?", default=""); dl.set_defaults(func=deploy_list)
|
|
528
682
|
|
|
529
683
|
_comms_parsers(areas)
|
|
684
|
+
_work_parsers(areas)
|
|
685
|
+
tu = [a for a in areas.choices["task"]._subparsers._group_actions][0].add_parser(
|
|
686
|
+
"update", help="Change a task's status, assignees, due date, priority or title")
|
|
687
|
+
tu.add_argument("task_id", type=int)
|
|
688
|
+
tu.add_argument("--status", choices=work.TASK_STATUSES)
|
|
689
|
+
tu.add_argument("--priority", choices=work.PRIORITIES)
|
|
690
|
+
tu.add_argument("--due-date")
|
|
691
|
+
tu.add_argument("--title")
|
|
692
|
+
tu.add_argument("--assignee", action="append")
|
|
693
|
+
tu.set_defaults(func=task_update)
|
|
530
694
|
return parser
|
|
531
695
|
|
|
532
696
|
|
|
@@ -536,7 +700,7 @@ def main(argv=None):
|
|
|
536
700
|
try:
|
|
537
701
|
result = args.func(args)
|
|
538
702
|
return result if isinstance(result, int) else 0
|
|
539
|
-
except (CliError, comms.CommsError) as exc:
|
|
703
|
+
except (CliError, comms.CommsError, work.WorkError) as exc:
|
|
540
704
|
print(f"arch: {exc}", file=sys.stderr)
|
|
541
705
|
return 2
|
|
542
706
|
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.4.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,46 @@ 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
|
+
|
|
94
|
+
## Organisation
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
arch org show # name, members, whether Entra sign-in is on
|
|
98
|
+
arch org entra show # the Redirect URI to register in Entra
|
|
99
|
+
ARCH_ENTRA_CLIENT_SECRET=... arch org entra set --tenant-id <Directory ID> --client-id <Application ID>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
With Entra sign-in on, members choose "Sign in with your organization" on the
|
|
103
|
+
login screen and type the organisation's name. `--domain` (a domain proven
|
|
104
|
+
under Organization -> Access) also sends that domain's addresses to Entra by
|
|
105
|
+
themselves. The secret is read from `ARCH_ENTRA_CLIENT_SECRET` or asked for,
|
|
106
|
+
never taken as an argument.
|
|
107
|
+
|
|
108
|
+
`arch login` prints the organisation it signed in to. A browser sign-in uses
|
|
109
|
+
the account that browser is signed in to; check it before importing.
|
|
110
|
+
|
|
71
111
|
## Marketing e-mail
|
|
72
112
|
|
|
73
113
|
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
|
|
@@ -14,7 +14,7 @@ def test_reserved_and_malformed_addresses_are_not_usable():
|
|
|
14
14
|
|
|
15
15
|
def test_an_excluded_domain_takes_its_subdomains_with_it():
|
|
16
16
|
assert comms.excluded_by("a@arch-insight.com", ["arch-insight.com"])
|
|
17
|
-
assert comms.excluded_by("a@
|
|
17
|
+
assert comms.excluded_by("a@example.arch-insight.com", ["arch-insight.com"])
|
|
18
18
|
assert not comms.excluded_by("a@notarch-insight.com", ["arch-insight.com"])
|
|
19
19
|
|
|
20
20
|
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_org_entra_sends_the_secret_only_from_the_environment(monkeypatch):
|
|
84
|
+
sent = []
|
|
85
|
+
monkeypatch.setattr(main, "request", lambda args, method, path, body=None, **kw: sent.append((method, path, body)) or {})
|
|
86
|
+
monkeypatch.setattr(main, "emit", lambda value: None)
|
|
87
|
+
monkeypatch.setenv("ARCH_ENTRA_CLIENT_SECRET", "s3cret")
|
|
88
|
+
args = main.build_parser().parse_args(["org", "entra", "set", "--tenant-id", "t", "--client-id", "c"])
|
|
89
|
+
args.func(args)
|
|
90
|
+
assert sent == [("PUT", "/api/orgs/me/entra", {"tenant_id": "t", "client_id": "c", "client_secret": "s3cret"})]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_org_show_leaves_out_billing_identifiers(monkeypatch):
|
|
94
|
+
shown = []
|
|
95
|
+
monkeypatch.setattr(main, "request", lambda *a, **kw: {"id": 7, "name": "X", "stripe_customer_id": "cus_1",
|
|
96
|
+
"play_purchase_token": "tok", "join_code": "J",
|
|
97
|
+
"members": [{"name": "a", "email": "a@x", "roles": ["Admin"], "bio": "b"}]})
|
|
98
|
+
monkeypatch.setattr(main, "emit", shown.append)
|
|
99
|
+
args = main.build_parser().parse_args(["org", "show"])
|
|
100
|
+
args.func(args)
|
|
101
|
+
text = str(shown[0])
|
|
102
|
+
assert "cus_1" not in text and "tok" not in text and "'J'" not in text
|
|
103
|
+
assert shown[0]["members"] == [{"name": "a", "email": "a@x", "roles": ["Admin"]}]
|
|
File without changes
|
|
File without changes
|
{arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_insight_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{arch_insight_cli-0.2.0 → arch_insight_cli-0.4.0}/arch_insight_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|