phable-cli 0.1.4__tar.gz → 0.1.6__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.
- {phable_cli-0.1.4 → phable_cli-0.1.6}/PKG-INFO +1 -1
- {phable_cli-0.1.4 → phable_cli-0.1.6}/phable_cli/cache.py +4 -1
- {phable_cli-0.1.4 → phable_cli-0.1.6}/phable_cli/cli.py +119 -52
- {phable_cli-0.1.4 → phable_cli-0.1.6}/phable_cli/phabricator.py +104 -2
- {phable_cli-0.1.4 → phable_cli-0.1.6}/phable_cli/utils.py +3 -1
- {phable_cli-0.1.4 → phable_cli-0.1.6}/pyproject.toml +1 -1
- {phable_cli-0.1.4 → phable_cli-0.1.6}/LICENSE +0 -0
- {phable_cli-0.1.4 → phable_cli-0.1.6}/README.md +0 -0
- {phable_cli-0.1.4 → phable_cli-0.1.6}/phable_cli/config.py +0 -0
|
@@ -23,7 +23,10 @@ class Cache:
|
|
|
23
23
|
self.cache_dir.mkdir()
|
|
24
24
|
self.cache_filepath = self.cache_dir / "cache.json"
|
|
25
25
|
if self.cache_filepath.exists():
|
|
26
|
-
|
|
26
|
+
try:
|
|
27
|
+
self.data = json.load(open(self.cache_filepath))
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
self.data = {}
|
|
27
30
|
else:
|
|
28
31
|
self.data = {}
|
|
29
32
|
|
|
@@ -2,7 +2,7 @@ import atexit
|
|
|
2
2
|
import json
|
|
3
3
|
import re
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Optional
|
|
5
|
+
from typing import Optional, Any, Callable
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
8
|
from click import Context
|
|
@@ -25,6 +25,11 @@ def cli():
|
|
|
25
25
|
pass
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
@cli.group(name="cache")
|
|
29
|
+
def _cache():
|
|
30
|
+
"""Manage internal cache"""
|
|
31
|
+
|
|
32
|
+
|
|
28
33
|
class Task(int):
|
|
29
34
|
@classmethod
|
|
30
35
|
def from_str(cls, value: str) -> int:
|
|
@@ -54,59 +59,56 @@ def show_task(task_id: int, format: str = "plain"):
|
|
|
54
59
|
"""
|
|
55
60
|
client = PhabricatorClient()
|
|
56
61
|
if task := client.show_task(task_id):
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
f"{project['fields']['parent']['name']} - {project['fields']['name']}"
|
|
66
|
-
if project["fields"]["parent"]
|
|
67
|
-
else project["fields"]["name"]
|
|
68
|
-
)
|
|
69
|
-
for project in client.show_projects(phids=project_ids)
|
|
70
|
-
]
|
|
71
|
-
else:
|
|
72
|
-
tags = []
|
|
73
|
-
subtasks = client.find_subtasks(parent_id=task_id)
|
|
74
|
-
task["subtasks"] = subtasks
|
|
75
|
-
parent = client.find_parent_task(subtask_id=task_id)
|
|
76
|
-
task["parent"] = parent
|
|
77
|
-
if format == "json":
|
|
78
|
-
click.echo(json.dumps(task))
|
|
79
|
-
else:
|
|
80
|
-
parent_str = (
|
|
81
|
-
f"{Task.from_int(parent['id'])} - {parent['fields']['name']}"
|
|
82
|
-
if parent
|
|
83
|
-
else ""
|
|
84
|
-
)
|
|
85
|
-
click.echo(f"URL: {client.base_url}/{Task.from_int(task_id)}")
|
|
86
|
-
click.echo(f"Task: {Task.from_int(task_id)}")
|
|
87
|
-
click.echo(f"Title: {task['fields']['name']}")
|
|
88
|
-
click.echo(f"Author: {author['fields']['username']}")
|
|
89
|
-
click.echo(f"Owner: {owner}")
|
|
90
|
-
click.echo(f"Tags: {', '.join(tags)}")
|
|
91
|
-
click.echo(f"Status: {task['fields']['status']['name']}")
|
|
92
|
-
click.echo(f"Priority: {task['fields']['priority']['name']}")
|
|
93
|
-
click.echo(f"Description: {task['fields']['description']['raw']}")
|
|
94
|
-
click.echo(f"Parent: {parent_str}")
|
|
95
|
-
click.echo("Subtasks:")
|
|
96
|
-
if subtasks:
|
|
97
|
-
for subtask in subtasks:
|
|
98
|
-
status = f"{'[x]' if subtask['fields']['status']['value'] == 'resolved' else '[ ]'}"
|
|
99
|
-
if subtask_owner_id := subtask["fields"]["ownerPHID"]:
|
|
100
|
-
owner = client.show_user(subtask_owner_id)["fields"]["username"]
|
|
101
|
-
else:
|
|
102
|
-
owner = ""
|
|
103
|
-
click.echo(
|
|
104
|
-
f"{status} - {Task.from_int(subtask['id'])} - @{owner:<10} - {subtask['fields']['name']}"
|
|
105
|
-
)
|
|
62
|
+
task = client.enrich_task(
|
|
63
|
+
task,
|
|
64
|
+
with_author_owner=True,
|
|
65
|
+
with_tags=True,
|
|
66
|
+
with_subtasks=True,
|
|
67
|
+
with_parent=True,
|
|
68
|
+
)
|
|
69
|
+
echo_task(click.echo, format, task)
|
|
106
70
|
else:
|
|
107
71
|
click.echo(f"Task {Task.from_int(task_id)} not found")
|
|
108
72
|
|
|
109
73
|
|
|
74
|
+
def echo_task(echo: Callable[[str], None], format: str, task: dict[str, Any]) -> None:
|
|
75
|
+
"""Print a task.
|
|
76
|
+
|
|
77
|
+
Print a task in a text or json format. The task needs to be enriched first.
|
|
78
|
+
|
|
79
|
+
To generalize the implementation and not couple it to the click library,
|
|
80
|
+
the user must pass an `echo` function that will be used to print the task.
|
|
81
|
+
"""
|
|
82
|
+
if format == "json":
|
|
83
|
+
echo(json.dumps(task))
|
|
84
|
+
else:
|
|
85
|
+
parent_str = (
|
|
86
|
+
f"{Task.from_int(task["parent"]['id'])} - {task["parent"]['fields']['name']}"
|
|
87
|
+
if task.get("parent")
|
|
88
|
+
else ""
|
|
89
|
+
)
|
|
90
|
+
echo(f"URL: {task['url']}")
|
|
91
|
+
echo(f"Task: {Task.from_int(task['id'])}")
|
|
92
|
+
echo(f"Title: {task['fields']['name']}")
|
|
93
|
+
if task.get("author"):
|
|
94
|
+
echo(f"Author: {task['author']['fields']['username']}")
|
|
95
|
+
if task.get("owner"):
|
|
96
|
+
echo(f"Owner: {task['owner']}")
|
|
97
|
+
if task.get("tags"):
|
|
98
|
+
echo(f"Tags: {', '.join(task['tags'])}")
|
|
99
|
+
echo(f"Status: {task['fields']['status']['name']}")
|
|
100
|
+
echo(f"Priority: {task['fields']['priority']['name']}")
|
|
101
|
+
echo(f"Description: {task['fields']['description']['raw']}")
|
|
102
|
+
echo(f"Parent: {parent_str}")
|
|
103
|
+
echo("Subtasks:")
|
|
104
|
+
if task.get("subtasks"):
|
|
105
|
+
for subtask in task["subtasks"]:
|
|
106
|
+
status = f"{'[x]' if subtask['fields']['status']['value'] == 'resolved' else '[ ]'}"
|
|
107
|
+
echo(
|
|
108
|
+
f"{status} - {Task.from_int(subtask['id'])} - @{subtask['owner']:<10} - {subtask['fields']['name']}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
110
112
|
@cli.command(name="create")
|
|
111
113
|
@click.option("--title", required=True, help="Title of the task")
|
|
112
114
|
@click.option(
|
|
@@ -192,7 +194,6 @@ def create_task(
|
|
|
192
194
|
task_params = {
|
|
193
195
|
"title": title,
|
|
194
196
|
"description": description,
|
|
195
|
-
"projects.add": [config.phabricator_default_project_phid],
|
|
196
197
|
"priority": priority,
|
|
197
198
|
}
|
|
198
199
|
|
|
@@ -222,6 +223,8 @@ def create_task(
|
|
|
222
223
|
ctx.fail(f"Project {tag} not found")
|
|
223
224
|
if tag_projects_phids:
|
|
224
225
|
task_params["projects.add"] = tag_projects_phids
|
|
226
|
+
else:
|
|
227
|
+
task_params["projects.add"] = [config.phabricator_default_project_phid]
|
|
225
228
|
|
|
226
229
|
if owner:
|
|
227
230
|
if owner_user := client.find_user_by_username(username=owner):
|
|
@@ -236,7 +239,7 @@ def create_task(
|
|
|
236
239
|
cc_phids = []
|
|
237
240
|
for username in cc:
|
|
238
241
|
if user := client.find_user_by_username(username=username):
|
|
239
|
-
cc_phids.
|
|
242
|
+
cc_phids.append(user["phid"])
|
|
240
243
|
else:
|
|
241
244
|
ctx.fail(f"User {owner} not found")
|
|
242
245
|
if cc_phids:
|
|
@@ -314,6 +317,8 @@ def move_task(
|
|
|
314
317
|
|
|
315
318
|
for task_id in task_ids:
|
|
316
319
|
client.move_task_to_column(task_id=task_id, column_phid=target_column_phid)
|
|
320
|
+
if column.lower() in ("in progress", "needs review"):
|
|
321
|
+
client.mark_task_as_in_progress(task_id)
|
|
317
322
|
if column.lower() == "done":
|
|
318
323
|
client.mark_task_as_resolved(task_id)
|
|
319
324
|
except ValueError as ve:
|
|
@@ -362,6 +367,68 @@ def subscribe_to_task(ctx, task_ids: list[int]):
|
|
|
362
367
|
client.add_user_to_task_subscribers(task_id=task_id, user_phid=user["phid"])
|
|
363
368
|
|
|
364
369
|
|
|
370
|
+
@_cache.command()
|
|
371
|
+
def show():
|
|
372
|
+
"""Display the location of the internal phable cache"""
|
|
373
|
+
click.echo(cache.cache_filepath)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@_cache.command()
|
|
377
|
+
def clear():
|
|
378
|
+
"""Delete the phable internal cache file"""
|
|
379
|
+
cache.cache_filepath.unlink(missing_ok=True)
|
|
380
|
+
atexit.unregister(cache.dump) # avoid re-dumping the in-memory cache back to disk
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@cli.command(name="report-done-tasks")
|
|
384
|
+
@click.option(
|
|
385
|
+
"--milestone/--no-milestone",
|
|
386
|
+
default=False,
|
|
387
|
+
help=(
|
|
388
|
+
"If --milestone is passed, the task will be moved onto the current project's associated "
|
|
389
|
+
"milestone board, instead of the project board itself"
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
@click.option(
|
|
393
|
+
"--format",
|
|
394
|
+
type=click.Choice(("plain", "json")),
|
|
395
|
+
default="plain",
|
|
396
|
+
help="Output format",
|
|
397
|
+
)
|
|
398
|
+
@click.option(
|
|
399
|
+
"--source",
|
|
400
|
+
type=str,
|
|
401
|
+
default="Done",
|
|
402
|
+
help="",
|
|
403
|
+
)
|
|
404
|
+
@click.option(
|
|
405
|
+
"--destination",
|
|
406
|
+
type=str,
|
|
407
|
+
default="Reported",
|
|
408
|
+
help="",
|
|
409
|
+
)
|
|
410
|
+
def report_done_tasks(milestone: bool, format: str, source: str, destination: str):
|
|
411
|
+
"""Print the details of all tasks in the `from` column and move them to the `to` column.
|
|
412
|
+
|
|
413
|
+
This is used to produce the weekly reports, and document the tasks as reported once the report is done.
|
|
414
|
+
"""
|
|
415
|
+
client = PhabricatorClient()
|
|
416
|
+
target_project_phid = client.get_main_project_or_milestone(
|
|
417
|
+
milestone, config.phabricator_default_project_phid
|
|
418
|
+
)
|
|
419
|
+
column_source_phid = client.find_column_in_project(target_project_phid, source)
|
|
420
|
+
column_destination_phid = client.find_column_in_project(
|
|
421
|
+
target_project_phid, destination
|
|
422
|
+
)
|
|
423
|
+
tasks = client.find_tasks_in_column(column_source_phid)
|
|
424
|
+
for task in tasks:
|
|
425
|
+
task = client.enrich_task(task)
|
|
426
|
+
if format == "plain":
|
|
427
|
+
click.echo("=" * 50)
|
|
428
|
+
echo_task(click.echo, format, task)
|
|
429
|
+
client.move_task_to_column(task["id"], column_destination_phid)
|
|
430
|
+
|
|
431
|
+
|
|
365
432
|
def runcli():
|
|
366
433
|
cli(max_content_width=120)
|
|
367
434
|
|
|
@@ -8,6 +8,16 @@ from .config import config
|
|
|
8
8
|
T = TypeVar("T")
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
class Task(int):
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_str(cls, value: str) -> int:
|
|
14
|
+
return int(value.lstrip("T"))
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_int(cls, value: int) -> str:
|
|
18
|
+
return f"T{value}"
|
|
19
|
+
|
|
20
|
+
|
|
11
21
|
class PhabricatorClient:
|
|
12
22
|
"""Phabricator API HTTP client.
|
|
13
23
|
|
|
@@ -86,6 +96,84 @@ class PhabricatorClient:
|
|
|
86
96
|
},
|
|
87
97
|
)["result"]["data"][0]
|
|
88
98
|
|
|
99
|
+
def enrich_task(
|
|
100
|
+
self,
|
|
101
|
+
task: dict[str, Any],
|
|
102
|
+
with_author_owner: bool = False,
|
|
103
|
+
with_tags: bool = False,
|
|
104
|
+
with_subtasks: bool = False,
|
|
105
|
+
with_parent: bool = False,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Load additional data about a task.
|
|
108
|
+
|
|
109
|
+
The given task is enriched AND returned.
|
|
110
|
+
|
|
111
|
+
Some of the additional info that is loaded:
|
|
112
|
+
* projects
|
|
113
|
+
* subtasks
|
|
114
|
+
* parent tasks
|
|
115
|
+
"""
|
|
116
|
+
task["url"] = f"{self.base_url}/{Task.from_int(task['id'])}"
|
|
117
|
+
|
|
118
|
+
if with_author_owner:
|
|
119
|
+
self.enrich_task_with_author_owner(task)
|
|
120
|
+
if with_tags:
|
|
121
|
+
self.enrich_task_with_tags(task)
|
|
122
|
+
if with_subtasks:
|
|
123
|
+
self.enrich_task_with_subtasks(task)
|
|
124
|
+
if with_parent:
|
|
125
|
+
self.enrich_task_with_parent(task)
|
|
126
|
+
return task
|
|
127
|
+
|
|
128
|
+
def enrich_task_with_author_owner(self, task: dict[str, Any]) -> None:
|
|
129
|
+
task["author"] = self.show_user(phid=task["fields"]["authorPHID"])
|
|
130
|
+
if owner_id := task["fields"]["ownerPHID"]:
|
|
131
|
+
owner = self.show_user(phid=owner_id)["fields"]["username"]
|
|
132
|
+
else:
|
|
133
|
+
owner = "Unassigned"
|
|
134
|
+
task["owner"] = owner
|
|
135
|
+
|
|
136
|
+
def enrich_task_with_tags(self, task: dict[str, Any]) -> None:
|
|
137
|
+
if project_ids := task["attachments"]["projects"]["projectPHIDs"]:
|
|
138
|
+
tags = [
|
|
139
|
+
(
|
|
140
|
+
f"{project['fields']['parent']['name']} - {project['fields']['name']}"
|
|
141
|
+
if project["fields"]["parent"]
|
|
142
|
+
else project["fields"]["name"]
|
|
143
|
+
)
|
|
144
|
+
for project in self.show_projects(phids=project_ids)
|
|
145
|
+
]
|
|
146
|
+
else:
|
|
147
|
+
tags = []
|
|
148
|
+
task["tags"] = tags
|
|
149
|
+
|
|
150
|
+
def enrich_task_with_subtasks(self, task: dict[str, Any]) -> None:
|
|
151
|
+
subtasks = self.find_subtasks(parent_id=task["id"])
|
|
152
|
+
if not subtasks:
|
|
153
|
+
subtasks = []
|
|
154
|
+
for subtask in subtasks:
|
|
155
|
+
if subtask_owner_id := subtask["fields"]["ownerPHID"]:
|
|
156
|
+
owner = self.show_user(subtask_owner_id)["fields"]["username"]
|
|
157
|
+
else:
|
|
158
|
+
owner = ""
|
|
159
|
+
subtask["owner"] = owner
|
|
160
|
+
task["subtasks"] = subtasks
|
|
161
|
+
|
|
162
|
+
def enrich_task_with_parent(self, task: dict[str, Any]) -> None:
|
|
163
|
+
parent = self.find_parent_task(subtask_id=task["id"])
|
|
164
|
+
task["parent"] = parent
|
|
165
|
+
|
|
166
|
+
def find_tasks_in_column(self, column_phid: str) -> list[dict[str, Any]]:
|
|
167
|
+
return self._make_request(
|
|
168
|
+
"maniphest.search",
|
|
169
|
+
params={
|
|
170
|
+
"constraints[columnPHIDs][0]": column_phid,
|
|
171
|
+
"attachments[subscribers]": "true",
|
|
172
|
+
"attachments[projects]": "true",
|
|
173
|
+
"attachments[columns]": "true",
|
|
174
|
+
},
|
|
175
|
+
)["result"]["data"]
|
|
176
|
+
|
|
89
177
|
def find_subtasks(self, parent_id: int) -> list[dict[str, Any]]:
|
|
90
178
|
"""Return details of all Maniphest subtasks of the provided task id"""
|
|
91
179
|
return self._make_request(
|
|
@@ -109,6 +197,10 @@ class PhabricatorClient:
|
|
|
109
197
|
"""Set the status of the argument task to Resolved"""
|
|
110
198
|
return self.create_or_edit_task(task_id=task_id, params={"status": "resolved"})
|
|
111
199
|
|
|
200
|
+
def mark_task_as_in_progress(self, task_id: int) -> dict[str, Any]:
|
|
201
|
+
"""Set the status of the argument task to in progress"""
|
|
202
|
+
return self.create_or_edit_task(task_id=task_id, params={"status": "progress"})
|
|
203
|
+
|
|
112
204
|
def add_user_to_task_subscribers(
|
|
113
205
|
self, task_id: int, user_phid: str
|
|
114
206
|
) -> dict[str, Any]:
|
|
@@ -182,10 +274,19 @@ class PhabricatorClient:
|
|
|
182
274
|
)
|
|
183
275
|
|
|
184
276
|
if not target_project_phid:
|
|
185
|
-
|
|
277
|
+
project = self.format_project_name(project_phid=project_phid)
|
|
278
|
+
raise ValueError(f"Could not find a milestone in {project}")
|
|
186
279
|
|
|
187
280
|
return target_project_phid
|
|
188
281
|
|
|
282
|
+
def format_project_name(self, project_phid: str) -> str:
|
|
283
|
+
project = self.show_projects(phids=[project_phid])[0]
|
|
284
|
+
if project["fields"].get("parent"):
|
|
285
|
+
parent_project_name = project["fields"]["parent"]["name"]
|
|
286
|
+
return f"{parent_project_name} ({project['fields']['name']})"
|
|
287
|
+
else:
|
|
288
|
+
return project["fields"]["name"]
|
|
289
|
+
|
|
189
290
|
@cached
|
|
190
291
|
def find_column_in_project(self, project_phid: str, column_name: str) -> str:
|
|
191
292
|
"""Finds a column in a project.
|
|
@@ -198,8 +299,9 @@ class PhabricatorClient:
|
|
|
198
299
|
column_phid = col["phid"]
|
|
199
300
|
break
|
|
200
301
|
else:
|
|
302
|
+
project_name = self.format_project_name(project_phid=project_phid)
|
|
201
303
|
raise ValueError(
|
|
202
|
-
f"Column {column_name} not found in milestone {
|
|
304
|
+
f"Column {column_name} not found in milestone {project_name}"
|
|
203
305
|
)
|
|
204
306
|
return column_phid
|
|
205
307
|
|
|
@@ -4,7 +4,9 @@ import os
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def text_from_cli_arg_or_fs_or_editor(
|
|
7
|
+
def text_from_cli_arg_or_fs_or_editor(
|
|
8
|
+
body_or_path: str, force_editor: bool = False
|
|
9
|
+
) -> str:
|
|
8
10
|
"""Return argument text/file content, or return prompted input text.
|
|
9
11
|
|
|
10
12
|
If some argument text is passed, and it matches a file path, return the file content.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|