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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: phable-cli
3
- Version: 0.1.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
- --help Show this message and exit.
47
+ --version Show the version and exit.
48
+ --help Show this message and exit.
48
49
 
49
50
  Commands:
50
- assign Assign one or multiple task ids to a username
51
- comment Add a comment to a task
52
- create Create a new task
53
- move Move one or several task on their current project board
54
- show Show task details
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
- - `PHABRICATOR_API_TOKEN`: Generate a token from ${PHABRICATOR_URL}/settings/user/${YOUR_USERNAME}/page/apitokens/
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
- --help Show this message and exit.
28
+ --version Show the version and exit.
29
+ --help Show this message and exit.
29
30
 
30
31
  Commands:
31
- assign Assign one or multiple task ids to a username
32
- comment Add a comment to a task
33
- create Create a new task
34
- move Move one or several task on their current project board
35
- show Show task details
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
- - `PHABRICATOR_API_TOKEN`: Generate a token from ${PHABRICATOR_URL}/settings/user/${YOUR_USERNAME}/page/apitokens/
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 os
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 | None,
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 a long description by pointing it to a description file
132
- $ phable create --title 'A task' --description path/to/description.txt
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 a long description by writing it in your favorite text editor
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": [os.environ["PHABRICATOR_DEFAULT_PROJECT_PHID"]],
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.add"] = [parent["phid"]]
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 | None):
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(ctx, task_ids: list[int], column: str | None):
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
- client = PhabricatorClient()
203
- if not (
204
- current_milestone := client.get_project_current_milestone(
205
- project_phid=os.environ["PHABRICATOR_DEFAULT_PROJECT_PHID"]
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
- for task_id in task_ids:
221
- client.move_task_to_column(task_id=task_id, column_phid=column_phid)
222
- if column.lower() == "done":
223
- client.mark_task_as_resolved(task_id)
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 | None):
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 os
1
+ from typing import Any, Optional, TypeVar
2
+ from datetime import timedelta
2
3
  import requests
3
4
 
4
- from typing import Any, TypeVar
5
- from functools import cache
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 = os.environ["PHABRICATOR_URL"].rstrip("/")
20
- self.token = os.environ["PHABRICATOR_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 | None = None
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
- def find_parent_task(self, subtask_id: int) -> dict[str, Any] | None:
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": "Resolved"})
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
- @cache
117
- def show_user(self, phid: str) -> dict[str, Any] | None:
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
- def find_user_by_username(self, username: str) -> dict[str, Any] | None:
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
- def get_project_current_milestone(self, project_phid: str) -> dict[str, Any] | None:
156
- """Return the first non hidden column associated with a subproject.
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
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "phable-cli"
3
- version = "0.1.1"
3
+ version = "0.1.3"
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