phable-cli 0.1.1__tar.gz → 0.1.3__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.1 → phable_cli-0.1.3}/PKG-INFO +10 -8
- {phable_cli-0.1.1 → phable_cli-0.1.3}/README.md +9 -7
- phable_cli-0.1.3/phable_cli/cache.py +89 -0
- {phable_cli-0.1.1 → phable_cli-0.1.3}/phable_cli/cli.py +114 -33
- phable_cli-0.1.3/phable_cli/config.py +28 -0
- {phable_cli-0.1.1 → phable_cli-0.1.3}/phable_cli/phabricator.py +75 -22
- {phable_cli-0.1.1 → phable_cli-0.1.3}/pyproject.toml +1 -1
- {phable_cli-0.1.1 → phable_cli-0.1.3}/LICENSE +0 -0
- {phable_cli-0.1.1 → phable_cli-0.1.3}/phable_cli/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: phable-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Manage Phabricator tasks from the comfort of your terminal
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Balthazar Rouberol
|
|
@@ -44,14 +44,16 @@ Usage: phable [OPTIONS] COMMAND [ARGS]...
|
|
|
44
44
|
Manage Phabricator tasks from the comfort of your terminal
|
|
45
45
|
|
|
46
46
|
Options:
|
|
47
|
-
--
|
|
47
|
+
--version Show the version and exit.
|
|
48
|
+
--help Show this message and exit.
|
|
48
49
|
|
|
49
50
|
Commands:
|
|
50
|
-
assign
|
|
51
|
-
comment
|
|
52
|
-
create
|
|
53
|
-
move
|
|
54
|
-
show
|
|
51
|
+
assign Assign one or multiple task ids to a username
|
|
52
|
+
comment Add a comment to a task
|
|
53
|
+
create Create a new task
|
|
54
|
+
move Move one or several task on their current project board
|
|
55
|
+
show Show task details
|
|
56
|
+
subscribe Subscribe to one or multiple task ids
|
|
55
57
|
```
|
|
56
58
|
|
|
57
59
|
## Setup
|
|
@@ -59,7 +61,7 @@ Commands:
|
|
|
59
61
|
For `phable` to work, you need to define the following environment variables:
|
|
60
62
|
|
|
61
63
|
- `PHABRICATOR_URL`: URL to your phabricator instance. Ex: `PHABRICATOR_URL=https://phabricator.wikimedia.org`
|
|
62
|
-
- `
|
|
64
|
+
- `PHABRICATOR_TOKEN`: Generate a token from ${PHABRICATOR_URL}/settings/user/${YOUR_USERNAME}/page/apitokens/
|
|
63
65
|
- `PHABRICATOR_DEFAULT_PROJECT_PHID`: id for the Phabricator project to be used by default when creating tasks.
|
|
64
66
|
|
|
65
67
|
Note: to get `PHABRICATOR_DEFAULT_PROJECT_PHID`, define the first 2 environment variables, and run the following command, where T123456 is a task id belonging to your project.
|
|
@@ -25,14 +25,16 @@ Usage: phable [OPTIONS] COMMAND [ARGS]...
|
|
|
25
25
|
Manage Phabricator tasks from the comfort of your terminal
|
|
26
26
|
|
|
27
27
|
Options:
|
|
28
|
-
--
|
|
28
|
+
--version Show the version and exit.
|
|
29
|
+
--help Show this message and exit.
|
|
29
30
|
|
|
30
31
|
Commands:
|
|
31
|
-
assign
|
|
32
|
-
comment
|
|
33
|
-
create
|
|
34
|
-
move
|
|
35
|
-
show
|
|
32
|
+
assign Assign one or multiple task ids to a username
|
|
33
|
+
comment Add a comment to a task
|
|
34
|
+
create Create a new task
|
|
35
|
+
move Move one or several task on their current project board
|
|
36
|
+
show Show task details
|
|
37
|
+
subscribe Subscribe to one or multiple task ids
|
|
36
38
|
```
|
|
37
39
|
|
|
38
40
|
## Setup
|
|
@@ -40,7 +42,7 @@ Commands:
|
|
|
40
42
|
For `phable` to work, you need to define the following environment variables:
|
|
41
43
|
|
|
42
44
|
- `PHABRICATOR_URL`: URL to your phabricator instance. Ex: `PHABRICATOR_URL=https://phabricator.wikimedia.org`
|
|
43
|
-
- `
|
|
45
|
+
- `PHABRICATOR_TOKEN`: Generate a token from ${PHABRICATOR_URL}/settings/user/${YOUR_USERNAME}/page/apitokens/
|
|
44
46
|
- `PHABRICATOR_DEFAULT_PROJECT_PHID`: id for the Phabricator project to be used by default when creating tasks.
|
|
45
47
|
|
|
46
48
|
Note: to get `PHABRICATOR_DEFAULT_PROJECT_PHID`, define the first 2 environment variables, and run the following command, where T123456 is a task id belonging to your project.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CACHE_HOME_PER_PLATFORM = {
|
|
11
|
+
"darwin": Path.home() / "Library" / "Caches",
|
|
12
|
+
"linux": Path(os.getenv("XDG_CACHE_HOME", f"{Path.home()}/.config")),
|
|
13
|
+
"windows": Path("c:/", "Users", os.getlogin(), "AppData", "Local", "Temp"),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cache:
|
|
18
|
+
"""A persistent cache for long-lived data, such as user or project"""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.cache_dir = CACHE_HOME_PER_PLATFORM[sys.platform] / "phind"
|
|
22
|
+
if not self.cache_dir.exists():
|
|
23
|
+
self.cache_dir.mkdir()
|
|
24
|
+
self.cache_filepath = self.cache_dir / "cache.json"
|
|
25
|
+
if self.cache_filepath.exists():
|
|
26
|
+
self.data = json.load(open(self.cache_filepath))
|
|
27
|
+
else:
|
|
28
|
+
self.data = {}
|
|
29
|
+
|
|
30
|
+
def dump(self):
|
|
31
|
+
json.dump(self.data, open(self.cache_filepath, "w"), indent=2)
|
|
32
|
+
|
|
33
|
+
def __setitem__(self, key, value):
|
|
34
|
+
self.data[key] = value
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, key):
|
|
37
|
+
return self.data[key]
|
|
38
|
+
|
|
39
|
+
def __contains__(self, key):
|
|
40
|
+
return key in self.data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
cache = Cache()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cached(*cached_args, **cached_kwargs):
|
|
47
|
+
"""Cache the return value of the decorated function in memory.
|
|
48
|
+
|
|
49
|
+
If a `ttl` keyword argment (of type typedelta) is passed to the decorator,
|
|
50
|
+
the cached value will only be valid for the provided duration.
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def decorator(f):
|
|
55
|
+
@wraps(f)
|
|
56
|
+
def wrapper(*args, **kwargs):
|
|
57
|
+
# Check if the decorated function takes a `self` parameter, which is then omitted
|
|
58
|
+
# from the cache key, as we just care about the other arguments, not the class itself.
|
|
59
|
+
if list(inspect.signature(f).parameters.keys())[0] == "self":
|
|
60
|
+
cache_args = args[1:]
|
|
61
|
+
else:
|
|
62
|
+
cache_args = args
|
|
63
|
+
cache_key = "__".join(map(str, cache_args))
|
|
64
|
+
cache_key += "__".join([f"{k}={v}" for k, v in kwargs.items()])
|
|
65
|
+
section = f.__name__
|
|
66
|
+
if section not in cache:
|
|
67
|
+
cache[section] = {}
|
|
68
|
+
if cache_hit := cache[section].get(cache_key):
|
|
69
|
+
if (
|
|
70
|
+
cache_hit["valid_until"] is None
|
|
71
|
+
or cache_hit["valid_until"] > time.time()
|
|
72
|
+
):
|
|
73
|
+
return cache_hit["data"]
|
|
74
|
+
data = f(*args, **kwargs)
|
|
75
|
+
cache[section][cache_key] = {
|
|
76
|
+
"data": data,
|
|
77
|
+
"valid_until": (
|
|
78
|
+
(datetime.now() + cached_kwargs["ttl"]).timestamp()
|
|
79
|
+
if cached_kwargs.get("ttl")
|
|
80
|
+
else None
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
return wrapper
|
|
86
|
+
|
|
87
|
+
if cached_args and callable(cached_args[0]):
|
|
88
|
+
return decorator(cached_args[0])
|
|
89
|
+
return decorator
|
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import
|
|
1
|
+
import atexit
|
|
2
2
|
import json
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
3
5
|
|
|
4
6
|
import click
|
|
7
|
+
from click import Context
|
|
5
8
|
|
|
9
|
+
from .cache import cache
|
|
10
|
+
from .config import config
|
|
6
11
|
from .phabricator import PhabricatorClient
|
|
7
12
|
from .utils import text_from_cli_arg_or_fs_or_editor
|
|
8
13
|
|
|
9
14
|
VARIADIC = -1 # Used for click variadic arguments
|
|
10
15
|
|
|
16
|
+
# Dump the in-memory cache to disk when existing the CLI
|
|
17
|
+
atexit.register(cache.dump)
|
|
18
|
+
|
|
11
19
|
|
|
12
20
|
@click.group()
|
|
21
|
+
@click.version_option()
|
|
13
22
|
def cli():
|
|
14
23
|
"""Manage Phabricator tasks from the comfort of your terminal"""
|
|
15
24
|
pass
|
|
@@ -110,41 +119,91 @@ def show_task(task_id: int, format: str = "plain"):
|
|
|
110
119
|
default="normal",
|
|
111
120
|
)
|
|
112
121
|
@click.option("--parent-id", type=Task.from_str, help="ID of parent task")
|
|
122
|
+
@click.option("--tags", multiple=True, help="Tags to associate to the task")
|
|
123
|
+
@click.option("--owner", help="The username of the task owner")
|
|
113
124
|
@click.pass_context
|
|
114
125
|
def create_task(
|
|
115
126
|
ctx,
|
|
116
127
|
title: str,
|
|
117
|
-
description: str,
|
|
128
|
+
description: Optional[str],
|
|
118
129
|
priority: str,
|
|
119
|
-
parent_id: str
|
|
130
|
+
parent_id: Optional[str],
|
|
131
|
+
tags: list[str],
|
|
132
|
+
owner: Optional[str],
|
|
120
133
|
):
|
|
121
134
|
"""Create a new task
|
|
122
135
|
|
|
123
136
|
\b
|
|
124
137
|
Examples:
|
|
138
|
+
\b
|
|
139
|
+
# Create a task with a long description by writing it in your favorite text editor
|
|
140
|
+
$ phable create --title 'A task'
|
|
141
|
+
\b
|
|
142
|
+
# Create a task with a long description by pointing it to a description file
|
|
143
|
+
$ phable create --title 'A task' --description path/to/description.txt
|
|
144
|
+
\b
|
|
125
145
|
# Create a task with associated title, priority and desription
|
|
126
146
|
$ phable create --title 'Do the thing!' --priority high --description 'Address the thing right now'
|
|
127
147
|
\b
|
|
128
148
|
# Create a task with a given parent
|
|
129
149
|
$ phable create --title 'A subtask' --description 'Subtask description' --parent-id T123456
|
|
130
150
|
\b
|
|
131
|
-
# Create a task with
|
|
132
|
-
$ phable create --title 'A task' --
|
|
151
|
+
# Create a task with an associated top-level project tag
|
|
152
|
+
$ phable create --title 'A task' --tags 'Data-Platform-SRE'
|
|
133
153
|
\b
|
|
134
|
-
# Create a task with
|
|
135
|
-
$ phable create --title 'A task'
|
|
154
|
+
# Create a task with an associated sub-project tag
|
|
155
|
+
$ phable create --title 'A task' --tags 'Data-Platform-SRE (2025.03.22 - 2025.04.11)
|
|
156
|
+
\b
|
|
157
|
+
# Create a task with an associated owner
|
|
158
|
+
$ phable create --title 'A task' --owner brouberol
|
|
159
|
+
|
|
136
160
|
"""
|
|
137
161
|
client = PhabricatorClient()
|
|
138
162
|
description = text_from_cli_arg_or_fs_or_editor(description)
|
|
139
163
|
task_params = {
|
|
140
164
|
"title": title,
|
|
141
165
|
"description": description,
|
|
142
|
-
"projects.add": [
|
|
166
|
+
"projects.add": [config.phabricator_default_project_phid],
|
|
143
167
|
"priority": priority,
|
|
144
168
|
}
|
|
169
|
+
|
|
170
|
+
tag_projects_phids = []
|
|
171
|
+
for tag in tags:
|
|
172
|
+
# The tag name can be a simple string, or "parent name (subproject name)"
|
|
173
|
+
# In the case of the latter, we need to fetch details for both projects
|
|
174
|
+
if match := re.match(
|
|
175
|
+
r"(?P<parent>[\w\s\.-]+) \((?P<subproject>[\w\s+\.-]+)\)", tag
|
|
176
|
+
):
|
|
177
|
+
parent_title = match.group("parent").strip()
|
|
178
|
+
if parent_project := client.find_project_by_title(title=parent_title):
|
|
179
|
+
parent_project_phid = parent_project["phid"]
|
|
180
|
+
else:
|
|
181
|
+
ctx.fail(f"Project {parent_project} not found")
|
|
182
|
+
project_title = match.group("subproject").strip()
|
|
183
|
+
if project := client.find_project_by_title(
|
|
184
|
+
title=project_title, parent_phid=parent_project_phid
|
|
185
|
+
):
|
|
186
|
+
tag_projects_phids.append(project["phid"])
|
|
187
|
+
else:
|
|
188
|
+
ctx.fail(f"Project {project_title} not found")
|
|
189
|
+
# Simple project name with no subproject
|
|
190
|
+
elif project := client.find_project_by_title(title=tag):
|
|
191
|
+
tag_projects_phids.append(project["phid"])
|
|
192
|
+
else:
|
|
193
|
+
ctx.fail(f"Project {tag} not found")
|
|
194
|
+
|
|
195
|
+
if tag_projects_phids:
|
|
196
|
+
task_params["projects.add"] = tag_projects_phids
|
|
197
|
+
|
|
198
|
+
if owner:
|
|
199
|
+
if owner_user := client.find_user_by_username(username=owner):
|
|
200
|
+
task_params["owner"] = owner_user["phid"]
|
|
201
|
+
else:
|
|
202
|
+
ctx.fail(f"User {owner} not found")
|
|
203
|
+
|
|
145
204
|
if parent_id:
|
|
146
205
|
parent = client.show_task(parent_id)
|
|
147
|
-
task_params["parents.
|
|
206
|
+
task_params["parents.set"] = [parent["phid"]]
|
|
148
207
|
|
|
149
208
|
task = client.create_or_edit_task(task_params)
|
|
150
209
|
ctx.invoke(show_task, task_id=task["result"]["object"]["id"])
|
|
@@ -158,7 +217,7 @@ def create_task(
|
|
|
158
217
|
)
|
|
159
218
|
@click.argument("task-ids", type=Task.from_str, nargs=VARIADIC)
|
|
160
219
|
@click.pass_context
|
|
161
|
-
def assign_task(ctx, task_ids: list[int], username: str
|
|
220
|
+
def assign_task(ctx, task_ids: list[int], username: Optional[str]):
|
|
162
221
|
"""Assign one or multiple task ids to a username
|
|
163
222
|
|
|
164
223
|
\b
|
|
@@ -185,9 +244,19 @@ def assign_task(ctx, task_ids: list[int], username: str | None):
|
|
|
185
244
|
required=True,
|
|
186
245
|
help="Name of destination column on the current project board",
|
|
187
246
|
)
|
|
247
|
+
@click.option(
|
|
248
|
+
"--milestone/--no-milestone",
|
|
249
|
+
default=False,
|
|
250
|
+
help=(
|
|
251
|
+
"If --milestone is passed, the task will be moved onto the current project's associated "
|
|
252
|
+
"milestone board, instead of the project board itself"
|
|
253
|
+
),
|
|
254
|
+
)
|
|
188
255
|
@click.argument("task-ids", type=Task.from_str, nargs=VARIADIC)
|
|
189
256
|
@click.pass_context
|
|
190
|
-
def move_task(
|
|
257
|
+
def move_task(
|
|
258
|
+
ctx: Context, task_ids: list[int], column: Optional[str], milestone: bool
|
|
259
|
+
) -> None:
|
|
191
260
|
"""Move one or several task on their current project board
|
|
192
261
|
|
|
193
262
|
If the task is moved to a 'Done' column, it will be automatically
|
|
@@ -199,28 +268,19 @@ def move_task(ctx, task_ids: list[int], column: str | None):
|
|
|
199
268
|
$ phable move T123456 T234567 --column 'Done'
|
|
200
269
|
|
|
201
270
|
"""
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
)
|
|
207
|
-
):
|
|
208
|
-
ctx.fail("Current milestone not found")
|
|
209
|
-
current_milestone_columns = client.list_project_columns(
|
|
210
|
-
project_phid=current_milestone["fields"]["proxyPHID"]
|
|
211
|
-
)
|
|
212
|
-
for col in current_milestone_columns:
|
|
213
|
-
if col["fields"]["name"].lower() == column.lower():
|
|
214
|
-
column_phid = col["phid"]
|
|
215
|
-
break
|
|
216
|
-
else:
|
|
217
|
-
ctx.fail(
|
|
218
|
-
f"Column {column} not found in milestone {current_milestone['fields']['name']}"
|
|
271
|
+
try:
|
|
272
|
+
client = PhabricatorClient()
|
|
273
|
+
target_project_phid = client.get_main_project_or_milestone(
|
|
274
|
+
milestone, config.phabricator_default_project_phid
|
|
219
275
|
)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
client.
|
|
276
|
+
target_column_phid = client.find_column_in_project(target_project_phid, column)
|
|
277
|
+
|
|
278
|
+
for task_id in task_ids:
|
|
279
|
+
client.move_task_to_column(task_id=task_id, column_phid=target_column_phid)
|
|
280
|
+
if column.lower() == "done":
|
|
281
|
+
client.mark_task_as_resolved(task_id)
|
|
282
|
+
except ValueError as ve:
|
|
283
|
+
ctx.fail(ve)
|
|
224
284
|
|
|
225
285
|
|
|
226
286
|
@cli.command(name="comment")
|
|
@@ -230,7 +290,7 @@ def move_task(ctx, task_ids: list[int], column: str | None):
|
|
|
230
290
|
help="Comment text or path to a text file containing the comment body. If not provided, an editor will be opened.",
|
|
231
291
|
)
|
|
232
292
|
@click.argument("task-id", type=Task.from_str)
|
|
233
|
-
def comment_on_task(task_id: int, comment: str
|
|
293
|
+
def comment_on_task(task_id: int, comment: Optional[str]):
|
|
234
294
|
"""Add a comment to a task
|
|
235
295
|
|
|
236
296
|
\b
|
|
@@ -238,11 +298,32 @@ def comment_on_task(task_id: int, comment: str | None):
|
|
|
238
298
|
$ phable comment T123456 --comment 'hello' # set comment body from the cli itself
|
|
239
299
|
$ phable comment T123456 --comment path/to/comment.txt # set comment body from a text file
|
|
240
300
|
$ phable comment T123456 # set comment body from your own text editor
|
|
301
|
+
|
|
241
302
|
"""
|
|
242
303
|
client = PhabricatorClient()
|
|
243
304
|
comment = text_from_cli_arg_or_fs_or_editor(comment)
|
|
244
305
|
client.create_or_edit_task(task_id=task_id, params={"comment": comment})
|
|
245
306
|
|
|
246
307
|
|
|
308
|
+
@cli.command(name="subscribe")
|
|
309
|
+
@click.argument("task-ids", type=Task.from_str, nargs=VARIADIC)
|
|
310
|
+
@click.pass_context
|
|
311
|
+
def subscribe_to_task(ctx, task_ids: list[int]):
|
|
312
|
+
"""Subscribe to one or multiple task ids
|
|
313
|
+
|
|
314
|
+
\b
|
|
315
|
+
Examples:
|
|
316
|
+
$ phable subscribe T123456
|
|
317
|
+
$ phable subscribe T123456 T234567
|
|
318
|
+
|
|
319
|
+
"""
|
|
320
|
+
client = PhabricatorClient()
|
|
321
|
+
user = client.current_user()
|
|
322
|
+
if not user:
|
|
323
|
+
ctx.fail("Current user was not found")
|
|
324
|
+
for task_id in task_ids:
|
|
325
|
+
client.add_user_to_task_subscribers(task_id=task_id, user_phid=user["phid"])
|
|
326
|
+
|
|
327
|
+
|
|
247
328
|
if __name__ == "__main__":
|
|
248
329
|
cli()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from functools import partial
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def os_getenv_or_raise(env_var_name: str):
|
|
8
|
+
if val := os.getenv(env_var_name):
|
|
9
|
+
return val
|
|
10
|
+
sys.stderr.write(f"{env_var_name} is not set and is required\n")
|
|
11
|
+
sys.stderr.flush()
|
|
12
|
+
sys.exit(1)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def field_with_default_from_env(env_var_name):
|
|
16
|
+
return field(default_factory=partial(os_getenv_or_raise, env_var_name))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
phabricator_url: str = field_with_default_from_env("PHABRICATOR_URL")
|
|
22
|
+
phabricator_token: str = field_with_default_from_env("PHABRICATOR_TOKEN")
|
|
23
|
+
phabricator_default_project_phid: str = field_with_default_from_env(
|
|
24
|
+
"PHABRICATOR_DEFAULT_PROJECT_PHID"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
config = Config()
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
from typing import Any, Optional, TypeVar
|
|
2
|
+
from datetime import timedelta
|
|
2
3
|
import requests
|
|
3
4
|
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
|
|
5
|
+
from .cache import cached
|
|
6
|
+
from .config import config
|
|
7
7
|
|
|
8
8
|
T = TypeVar("T")
|
|
9
9
|
|
|
@@ -16,16 +16,11 @@ class PhabricatorClient:
|
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
def __init__(self):
|
|
19
|
-
self.base_url =
|
|
20
|
-
self.token =
|
|
19
|
+
self.base_url = config.phabricator_url.rstrip("/")
|
|
20
|
+
self.token = config.phabricator_token
|
|
21
21
|
self.session = requests.Session()
|
|
22
22
|
self.timeout = 5
|
|
23
23
|
|
|
24
|
-
if not self.base_url or not self.token:
|
|
25
|
-
raise ValueError(
|
|
26
|
-
"PHABRICATOR_URL and PHABRICATOR_TOKEN must be set in your envionment"
|
|
27
|
-
)
|
|
28
|
-
|
|
29
24
|
def _first(self, result_set: list[T]) -> T:
|
|
30
25
|
if result_set:
|
|
31
26
|
return result_set[0]
|
|
@@ -64,7 +59,7 @@ class PhabricatorClient:
|
|
|
64
59
|
raise Exception(f"API request failed: {str(e)}")
|
|
65
60
|
|
|
66
61
|
def create_or_edit_task(
|
|
67
|
-
self, params: dict[str, Any], task_id: int
|
|
62
|
+
self, params: dict[str, Any], task_id: Optional[int] = None
|
|
68
63
|
) -> dict[str, Any]:
|
|
69
64
|
"""Create or edit (if a task_id is provided) a Maniphest task."""
|
|
70
65
|
raw_params = {}
|
|
@@ -97,7 +92,8 @@ class PhabricatorClient:
|
|
|
97
92
|
"maniphest.search", params={"constraints[parentIDs][0]": parent_id}
|
|
98
93
|
)["result"]["data"]
|
|
99
94
|
|
|
100
|
-
|
|
95
|
+
@cached
|
|
96
|
+
def find_parent_task(self, subtask_id: int) -> Optional[dict[str, Any]]:
|
|
101
97
|
"""Return details of the parent Maniphest task for the provided task id"""
|
|
102
98
|
return self._first(
|
|
103
99
|
self._make_request(
|
|
@@ -111,16 +107,25 @@ class PhabricatorClient:
|
|
|
111
107
|
|
|
112
108
|
def mark_task_as_resolved(self, task_id: int) -> dict[str, Any]:
|
|
113
109
|
"""Set the status of the argument task to Resolved"""
|
|
114
|
-
return self.create_or_edit_task(task_id=task_id, params={"status": "
|
|
110
|
+
return self.create_or_edit_task(task_id=task_id, params={"status": "resolved"})
|
|
111
|
+
|
|
112
|
+
def add_user_to_task_subscribers(
|
|
113
|
+
self, task_id: int, user_phid: str
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""Add the user to the list of the task subscribers"""
|
|
116
|
+
return self.create_or_edit_task(
|
|
117
|
+
task_id=task_id, params={"subscribers.add": [user_phid]}
|
|
118
|
+
)
|
|
115
119
|
|
|
116
|
-
@
|
|
117
|
-
def show_user(self, phid: str) -> dict[str, Any]
|
|
120
|
+
@cached
|
|
121
|
+
def show_user(self, phid: str) -> Optional[dict[str, Any]]:
|
|
118
122
|
"""Show details of a Maniphest user"""
|
|
119
123
|
user = self._make_request(
|
|
120
124
|
"user.search", params={"constraints[phids][0]": phid}
|
|
121
125
|
)["result"]["data"]
|
|
122
126
|
return self._first(user)
|
|
123
127
|
|
|
128
|
+
@cached
|
|
124
129
|
def show_projects(self, phids: list[str]) -> dict[str, Any]:
|
|
125
130
|
"""Show details of the provided Maniphest projects"""
|
|
126
131
|
params = {}
|
|
@@ -128,11 +133,13 @@ class PhabricatorClient:
|
|
|
128
133
|
params[f"constraints[phids][{i}]"] = phid
|
|
129
134
|
return self._make_request("project.search", params=params)["result"]["data"]
|
|
130
135
|
|
|
136
|
+
@cached
|
|
131
137
|
def current_user(self) -> dict[str, Any]:
|
|
132
138
|
"""Return details of the user associated with the phabricator API token"""
|
|
133
139
|
return self._make_request("user.whoami")["result"]
|
|
134
140
|
|
|
135
|
-
|
|
141
|
+
@cached
|
|
142
|
+
def find_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
|
136
143
|
"""Return user details of the user with the provided username"""
|
|
137
144
|
user = self._make_request(
|
|
138
145
|
"user.search", params={"constraints[usernames][0]": username}
|
|
@@ -143,6 +150,7 @@ class PhabricatorClient:
|
|
|
143
150
|
"""Set the owner of the argument task to the argument user id"""
|
|
144
151
|
return self.create_or_edit_task(task_id=task_id, params={"owner": user_phid})
|
|
145
152
|
|
|
153
|
+
@cached(ttl=timedelta(days=1))
|
|
146
154
|
def list_project_columns(
|
|
147
155
|
self,
|
|
148
156
|
project_phid: str,
|
|
@@ -152,13 +160,58 @@ class PhabricatorClient:
|
|
|
152
160
|
"project.column.search", params={"constraints[projects][0]": project_phid}
|
|
153
161
|
)["result"]["data"]
|
|
154
162
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
We assume it to be associated with the current milestone.
|
|
163
|
+
@cached(ttl=timedelta(days=1))
|
|
164
|
+
def get_project_current_milestone_phid(self, project_phid: str) -> Optional[str]:
|
|
165
|
+
"""Return the PHID of the current milestone associated with the given project.
|
|
159
166
|
|
|
167
|
+
We assume that the current milestone is displayed on the project's
|
|
168
|
+
board as the first non-hidden column.
|
|
160
169
|
"""
|
|
161
170
|
columns = self.list_project_columns(project_phid)
|
|
162
171
|
for column in columns:
|
|
163
172
|
if column["fields"]["proxyPHID"] and not column["fields"]["isHidden"]:
|
|
164
|
-
return column
|
|
173
|
+
return column["fields"]["proxyPHID"]
|
|
174
|
+
|
|
175
|
+
def get_main_project_or_milestone(self, milestone: bool, project_phid: str) -> str:
|
|
176
|
+
"""Returns either the given project, or the current milestone of the given project."""
|
|
177
|
+
if not milestone:
|
|
178
|
+
return project_phid
|
|
179
|
+
|
|
180
|
+
target_project_phid = self.get_project_current_milestone_phid(
|
|
181
|
+
project_phid=(project_phid)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if not target_project_phid:
|
|
185
|
+
raise ValueError(f"Could not find a milestone in {project_phid}")
|
|
186
|
+
|
|
187
|
+
return target_project_phid
|
|
188
|
+
|
|
189
|
+
@cached
|
|
190
|
+
def find_column_in_project(self, project_phid: str, column_name: str) -> str:
|
|
191
|
+
"""Finds a column in a project.
|
|
192
|
+
|
|
193
|
+
:raises ValueError if the column isn't found"""
|
|
194
|
+
potential_target_columns = self.list_project_columns(project_phid=project_phid)
|
|
195
|
+
|
|
196
|
+
for col in potential_target_columns:
|
|
197
|
+
if col["fields"]["name"].lower() == column_name.lower():
|
|
198
|
+
column_phid = col["phid"]
|
|
199
|
+
break
|
|
200
|
+
else:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Column {column_name} not found in milestone {project_phid}"
|
|
203
|
+
)
|
|
204
|
+
return column_phid
|
|
205
|
+
|
|
206
|
+
@cached
|
|
207
|
+
def find_project_by_title(
|
|
208
|
+
self, title: str, parent_phid: Optional[str] = None
|
|
209
|
+
) -> Optional[dict[str, Any]]:
|
|
210
|
+
params = {"constraints[query]": f'title:"{title}"'}
|
|
211
|
+
if parent_phid:
|
|
212
|
+
params["constraints[parents][0]"] = parent_phid
|
|
213
|
+
else:
|
|
214
|
+
params["constraints[maxDepth]"] = "0" # search for top-level project
|
|
215
|
+
return self._first(
|
|
216
|
+
self._make_request("project.search", params=params)["result"]["data"]
|
|
217
|
+
)
|
|
File without changes
|
|
File without changes
|