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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: phable-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Manage Phabricator tasks from the comfort of your terminal
5
5
  License: MIT
6
6
  Author: Balthazar Rouberol
@@ -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
- self.data = json.load(open(self.cache_filepath))
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
- author = client.show_user(phid=task["fields"]["authorPHID"])
58
- if owner_id := task["fields"]["ownerPHID"]:
59
- owner = client.show_user(phid=owner_id)["fields"]["username"]
60
- else:
61
- owner = "Unassigned"
62
- if project_ids := task["attachments"]["projects"]["projectPHIDs"]:
63
- tags = [
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.appedn(user["phid"])
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
- raise ValueError(f"Could not find a milestone in {project_phid}")
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 {project_phid}"
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(body_or_path: str, force_editor: bool) -> str:
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.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "phable-cli"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Manage Phabricator tasks from the comfort of your terminal"
5
5
  authors = ["Balthazar Rouberol <br@imap.cc>"]
6
6
  license = "MIT"
File without changes
File without changes