phable-cli 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Balthazar Rouberol
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.3
2
+ Name: phable-cli
3
+ Version: 0.1.0
4
+ Summary: Manage Phabricator tasks from the comfort of your terminal
5
+ License: MIT
6
+ Author: Balthazar Rouberol
7
+ Author-email: br@imap.cc
8
+ Requires-Python: >=3.9,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: click (>=8.1.8,<9.0.0)
17
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # phable
21
+ Manage Phabricator tasks from the comfort of your terminal.
22
+
23
+ `phable` is a CLI allowing you to manage your [Phorge/Phabricator](https://we.forge.it) tasks.
24
+
25
+ It tries to be very simple and not go overboard with features. You can:
26
+ - create a new task
27
+ - display a task details
28
+ - move a task to a column on its current board
29
+ - assign a task to a user
30
+ - add a comment to a task
31
+
32
+ ## Installation
33
+
34
+ ```console
35
+ $ pip install phable-cli
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```console
41
+ $ phable --help
42
+ Usage: phable [OPTIONS] COMMAND [ARGS]...
43
+
44
+ Manage Phabricator tasks from the comfort of your terminal
45
+
46
+ Options:
47
+ --help Show this message and exit.
48
+
49
+ 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
55
+ ```
56
+
@@ -0,0 +1,36 @@
1
+ # phable
2
+ Manage Phabricator tasks from the comfort of your terminal.
3
+
4
+ `phable` is a CLI allowing you to manage your [Phorge/Phabricator](https://we.forge.it) tasks.
5
+
6
+ It tries to be very simple and not go overboard with features. You can:
7
+ - create a new task
8
+ - display a task details
9
+ - move a task to a column on its current board
10
+ - assign a task to a user
11
+ - add a comment to a task
12
+
13
+ ## Installation
14
+
15
+ ```console
16
+ $ pip install phable-cli
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```console
22
+ $ phable --help
23
+ Usage: phable [OPTIONS] COMMAND [ARGS]...
24
+
25
+ Manage Phabricator tasks from the comfort of your terminal
26
+
27
+ Options:
28
+ --help Show this message and exit.
29
+
30
+ 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
36
+ ```
@@ -0,0 +1,246 @@
1
+ import os
2
+ import json
3
+
4
+ import click
5
+
6
+ from .phabricator import PhabricatorClient
7
+ from .utils import text_from_cli_arg_or_fs_or_editor
8
+
9
+
10
+ @click.group()
11
+ def cli():
12
+ """Manage Phabricator tasks from the comfort of your terminal"""
13
+ pass
14
+
15
+
16
+ class Task(int):
17
+ @classmethod
18
+ def from_str(cls, value: str) -> int:
19
+ return int(value.lstrip("T"))
20
+
21
+ @classmethod
22
+ def from_int(cls, value: int) -> str:
23
+ return f"T{value}"
24
+
25
+
26
+ @cli.command(name="show")
27
+ @click.option(
28
+ "--format",
29
+ type=click.Choice(("plain", "json")),
30
+ default="plain",
31
+ help="Output format",
32
+ )
33
+ @click.argument("task-id", type=Task.from_str)
34
+ def show_task(task_id: int, format: str = "plain"):
35
+ """Show task details
36
+
37
+ \b
38
+ Examples:
39
+ $ phable show T123456 # show task details as plaintext
40
+ $ phable show T123456 --format=json # show task details as json
41
+
42
+ """
43
+ client = PhabricatorClient()
44
+ if task := client.show_task(task_id):
45
+ author = client.show_user(phid=task["fields"]["authorPHID"])
46
+ if owner_id := task["fields"]["ownerPHID"]:
47
+ owner = client.show_user(phid=owner_id)["fields"]["username"]
48
+ else:
49
+ owner = "Unassigned"
50
+ if project_ids := task["attachments"]["projects"]["projectPHIDs"]:
51
+ tags = [
52
+ (
53
+ f"{project['fields']['parent']['name']} - {project['fields']['name']}"
54
+ if project["fields"]["parent"]
55
+ else project["fields"]["name"]
56
+ )
57
+ for project in client.show_projects(phids=project_ids)
58
+ ]
59
+ else:
60
+ tags = []
61
+ subtasks = client.find_subtasks(parent_id=task_id)
62
+ task["subtasks"] = subtasks
63
+ parent = client.find_parent_task(subtask_id=task_id)
64
+ task["parent"] = parent
65
+ if format == "json":
66
+ click.echo(json.dumps(task))
67
+ else:
68
+ parent_str = (
69
+ f"{Task.from_int(parent['id'])} - {parent['fields']['name']}"
70
+ if parent
71
+ else ""
72
+ )
73
+ click.echo(f"URL: {client.base_url}/{Task.from_int(task_id)}")
74
+ click.echo(f"Task: {Task.from_int(task_id)}")
75
+ click.echo(f"Title: {task['fields']['name']}")
76
+ click.echo(f"Author: {author['fields']['username']}")
77
+ click.echo(f"Owner: {owner}")
78
+ click.echo(f"Tags: {', '.join(tags)}")
79
+ click.echo(f"Status: {task['fields']['status']['name']}")
80
+ click.echo(f"Priority: {task['fields']['priority']['name']}")
81
+ click.echo(f"Description: {task['fields']['description']['raw']}")
82
+ click.echo(f"Parent: {parent_str}")
83
+ click.echo("Subtasks:")
84
+ if subtasks:
85
+ for subtask in subtasks:
86
+ status = f"{'[x]' if subtask['fields']['status']['value'] == 'resolved' else '[ ]'}"
87
+ if subtask_owner_id := subtask["fields"]["ownerPHID"]:
88
+ owner = client.show_user(subtask_owner_id)["fields"]["username"]
89
+ else:
90
+ owner = ""
91
+ click.echo(
92
+ f"{status} - {Task.from_int(subtask['id'])} - @{owner:<10} - {subtask['fields']['name']}"
93
+ )
94
+ else:
95
+ click.echo(f"Task {Task.from_int(task_id)} not found")
96
+
97
+
98
+ @cli.command(name="create")
99
+ @click.option("--title", required=True, help="Title of the task")
100
+ @click.option(
101
+ "--description",
102
+ help="Task description or path to a file containing the description body. If not provided, an editor will be opened.",
103
+ )
104
+ @click.option(
105
+ "--priority",
106
+ type=click.Choice(["unbreaknow", "high", "normal", "low", "needs-triage"]),
107
+ help="Priority level of the task",
108
+ default="normal",
109
+ )
110
+ @click.option("--parent-id", type=int, help="ID of parent task")
111
+ @click.pass_context
112
+ def create_task(
113
+ ctx,
114
+ title: str,
115
+ description: str,
116
+ priority: str,
117
+ parent_id: str | None,
118
+ ):
119
+ """Create a new task
120
+
121
+ \b
122
+ Examples:
123
+ # Create a task with associated title, priority and desription
124
+ $ phable create --title 'Do the thing!' --priority high --description 'Address the thing right now'
125
+ \b
126
+ # Create a task with a given parent
127
+ $ phable create --title 'A subtask' --description 'Subtask description' --parent-id T123456
128
+ \b
129
+ # Create a task with a long description by pointing it to a description file
130
+ $ phable create --title 'A task' --description path/to/description.txt
131
+ \b
132
+ # Create a task with a long description by writing it in your favorite text editor
133
+ $ phable create --title 'A task'
134
+ """
135
+ client = PhabricatorClient()
136
+ description = text_from_cli_arg_or_fs_or_editor(description)
137
+ task_params = {
138
+ "title": title,
139
+ "description": description,
140
+ "projects.add": [os.environ["PHABRICATOR_DEFAULT_PROJECT_PHID"]],
141
+ "priority": priority,
142
+ }
143
+ if parent_id:
144
+ parent = client.show_task(parent_id)
145
+ task_params["parents.add"] = [parent["phid"]]
146
+
147
+ task = client.create_or_edit_task(task_params)
148
+ ctx.invoke(show_task, task_id=task["result"]["object"]["id"])
149
+
150
+
151
+ @cli.command(name="assign")
152
+ @click.option(
153
+ "--username",
154
+ required=False,
155
+ help="The username to assign the task to. Self-assign the task if not provided.",
156
+ )
157
+ @click.argument("task-ids", type=Task.from_str, nargs=-1)
158
+ @click.pass_context
159
+ def assign_task(ctx, task_ids: list[int], username: str | None):
160
+ """Assign one or multiple task ids to a username
161
+
162
+ \b
163
+ Examples:
164
+ $ phable assign T123456 # self assign task
165
+ $ phable assign T123456 brouberol # asign to username
166
+
167
+ """
168
+ client = PhabricatorClient()
169
+ if not username:
170
+ user = client.current_user()
171
+ else:
172
+ user = client.find_user_by_username(username)
173
+ if not user:
174
+ ctx.fail(f"User {username} was not found")
175
+ for task_id in task_ids:
176
+ client.assign_task_to_user(task_id=task_id, user_phid=user["phid"])
177
+
178
+
179
+ @cli.command(name="move")
180
+ @click.option(
181
+ "--column",
182
+ type=str,
183
+ required=True,
184
+ help="Name of destination column on the current project board",
185
+ )
186
+ @click.argument("task-ids", type=Task.from_str, nargs=1)
187
+ @click.pass_context
188
+ def move_task(ctx, task_ids: list[int], column: str | None):
189
+ """Move one or several task on their current project board
190
+
191
+ If the task is moved to a 'Done' column, it will be automatically
192
+ marked as 'Resolved' as well.
193
+
194
+ \b
195
+ Example:
196
+ $ phable move T123456 --column 'In Progress'
197
+ $ phable move T123456 T234567 --column 'Done'
198
+
199
+ """
200
+ client = PhabricatorClient()
201
+ if not (
202
+ current_milestone := client.get_project_current_milestone(
203
+ project_phid=os.environ["PHABRICATOR_DEFAULT_PROJECT_PHID"]
204
+ )
205
+ ):
206
+ ctx.fail("Current milestone not found")
207
+ current_milestone_columns = client.list_project_columns(
208
+ project_phid=current_milestone["fields"]["proxyPHID"]
209
+ )
210
+ for col in current_milestone_columns:
211
+ if col["fields"]["name"].lower() == column:
212
+ column_phid = col["phid"]
213
+ break
214
+ else:
215
+ ctx.fail(
216
+ f"Column {column} not found in milestone {current_milestone['fields']['name']}"
217
+ )
218
+ for task_id in task_ids:
219
+ client.move_task_to_column(task_id=task_id, column_phid=column_phid)
220
+ if column["fields"]["name"].lower() == "done":
221
+ client.mark_task_as_resolved(task_id)
222
+
223
+
224
+ @cli.command(name="comment")
225
+ @click.option(
226
+ "--comment",
227
+ type=str,
228
+ help="Comment text or path to a text file containing the comment body. If not provided, an editor will be opened.",
229
+ )
230
+ @click.argument("task-id", type=Task.from_str)
231
+ def comment_on_task(task_id: int, comment: str | None):
232
+ """Add a comment to a task
233
+
234
+ \b
235
+ Example:
236
+ $ phable comment T123456 --comment 'hello' # set comment body from the cli itself
237
+ $ phable comment T123456 --comment path/to/comment.txt # set comment body from a text file
238
+ $ phable comment T123456 # set comment body from your own text editor
239
+ """
240
+ client = PhabricatorClient()
241
+ comment = text_from_cli_arg_or_fs_or_editor(comment)
242
+ client.create_or_edit_task(task_id=task_id, params={"comment": comment})
243
+
244
+
245
+ if __name__ == "__main__":
246
+ cli()
@@ -0,0 +1,164 @@
1
+ import os
2
+ import requests
3
+
4
+ from typing import Any, TypeVar
5
+ from functools import cache
6
+
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class PhabricatorClient:
12
+ """Phabricator API HTTP client.
13
+
14
+ See https://phabricator.wikimedia.org/conduit for the API capability and details.
15
+
16
+ """
17
+
18
+ def __init__(self):
19
+ self.base_url = os.environ["PHABRICATOR_URL"].rstrip("/")
20
+ self.token = os.environ["PHABRICATOR_TOKEN"]
21
+ self.session = requests.Session()
22
+ self.timeout = 5
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
+ def _first(self, result_set: list[T]) -> T:
30
+ if result_set:
31
+ return result_set[0]
32
+
33
+ def _make_request(
34
+ self,
35
+ path: str,
36
+ params: dict[str, Any] = None,
37
+ headers: dict[str, str] = None,
38
+ ) -> dict[str, Any]:
39
+ """Helper method to make API requests"""
40
+ headers = headers or {}
41
+ headers |= {
42
+ "Content-Type": "application/x-www-form-urlencoded",
43
+ }
44
+ params = params or {}
45
+ data = {}
46
+ data["api.token"] = self.token
47
+ data["output"] = "json"
48
+ data |= params
49
+
50
+ try:
51
+ response = self.session.post(
52
+ f"{self.base_url}/api/{path}",
53
+ headers=headers,
54
+ data=data,
55
+ timeout=self.timeout,
56
+ )
57
+
58
+ response.raise_for_status()
59
+ resp_json = response.json()
60
+ if resp_json["error_code"]:
61
+ raise Exception(f"API request failed: {resp_json}")
62
+ return response.json()
63
+ except requests.RequestException as e:
64
+ raise Exception(f"API request failed: {str(e)}")
65
+
66
+ def create_or_edit_task(
67
+ self, params: dict[str, Any], task_id: int | None = None
68
+ ) -> dict[str, Any]:
69
+ """Create or edit (if a task_id is provided) a Maniphest task."""
70
+ raw_params = {}
71
+ for i, (key, value) in enumerate(params.items()):
72
+ raw_params[f"transactions[{i}][type]"] = key
73
+ if isinstance(value, list):
74
+ for j, subvalue in enumerate(value):
75
+ raw_params[f"transactions[{i}][value][{j}]"] = subvalue
76
+ else:
77
+ raw_params[f"transactions[{i}][value]"] = value
78
+ if task_id:
79
+ raw_params["objectIdentifier"] = task_id
80
+ return self._make_request("maniphest.edit", params=raw_params)
81
+
82
+ def show_task(self, task_id: int) -> dict[str, Any]:
83
+ """Show a Maniphest task"""
84
+ return self._make_request(
85
+ "maniphest.search",
86
+ params={
87
+ "constraints[ids][0]": task_id,
88
+ "attachments[subscribers]": "true",
89
+ "attachments[projects]": "true",
90
+ "attachments[columns]": "true",
91
+ },
92
+ )["result"]["data"][0]
93
+
94
+ def find_subtasks(self, parent_id: int) -> list[dict[str, Any]]:
95
+ """Return details of all Maniphest subtasks of the provided task id"""
96
+ return self._make_request(
97
+ "maniphest.search", params={"constraints[parentIDs][0]": parent_id}
98
+ )["result"]["data"]
99
+
100
+ def find_parent_task(self, subtask_id: int) -> dict[str, Any] | None:
101
+ """Return details of the parent Maniphest task for the provided task id"""
102
+ return self._first(
103
+ self._make_request(
104
+ "maniphest.search", params={"constraints[subtaskIDs][0]": subtask_id}
105
+ )["result"]["data"]
106
+ )
107
+
108
+ def move_task_to_column(self, task_id: int, column_phid: str) -> dict[str, Any]:
109
+ """Move the argument task to column of associated column id"""
110
+ return self.create_or_edit_task(task_id=task_id, params={"column": column_phid})
111
+
112
+ def mark_task_as_resolved(self, task_id: int) -> dict[str, Any]:
113
+ """Set the status of the argument task to Resolved"""
114
+ return self.create_or_edit_task(task_id=task_id, params={"status": "Resolved"})
115
+
116
+ @cache
117
+ def show_user(self, phid: str) -> dict[str, Any] | None:
118
+ """Show details of a Maniphest user"""
119
+ user = self._make_request(
120
+ "user.search", params={"constraints[phids][0]": phid}
121
+ )["result"]["data"]
122
+ return self._first(user)
123
+
124
+ def show_projects(self, phids: list[str]) -> dict[str, Any]:
125
+ """Show details of the provided Maniphest projects"""
126
+ params = {}
127
+ for i, phid in enumerate(phids):
128
+ params[f"constraints[phids][{i}]"] = phid
129
+ return self._make_request("project.search", params=params)["result"]["data"]
130
+
131
+ def current_user(self) -> dict[str, Any]:
132
+ """Return details of the user associated with the phabricator API token"""
133
+ return self._make_request("user.whoami")["result"]
134
+
135
+ def find_user_by_username(self, username: str) -> dict[str, Any] | None:
136
+ """Return user details of the user with the provided username"""
137
+ user = self._make_request(
138
+ "user.search", params={"constraints[usernames][0]": username}
139
+ )["result"]["data"]
140
+ return self._first(user)
141
+
142
+ def assign_task_to_user(self, task_id: int, user_phid: int) -> dict[str, Any]:
143
+ """Set the owner of the argument task to the argument user id"""
144
+ return self.create_or_edit_task(task_id=task_id, params={"owner": user_phid})
145
+
146
+ def list_project_columns(
147
+ self,
148
+ project_phid: str,
149
+ ) -> list[dict[str, Any]]:
150
+ """Return the details of each column in a given project"""
151
+ return self._make_request(
152
+ "project.column.search", params={"constraints[projects][0]": project_phid}
153
+ )["result"]["data"]
154
+
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.
159
+
160
+ """
161
+ columns = self.list_project_columns(project_phid)
162
+ for column in columns:
163
+ if column["fields"]["proxyPHID"] and not column["fields"]["isHidden"]:
164
+ return column
@@ -0,0 +1,28 @@
1
+ import tempfile
2
+ import subprocess
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def text_from_cli_arg_or_fs_or_editor(body_or_path: str) -> str:
8
+ """Return argument text/file content, or return prompted input text.
9
+
10
+ If some argument text is passed, and it matches a file path, return the file content.
11
+ If it does not match a file path, return the text itself.
12
+ Finally, if no argument is passed, open an editor and return the text written by the
13
+ user.
14
+
15
+ """
16
+ try:
17
+ if body_or_path is not None and (local_file := Path(body_or_path)).exists():
18
+ return local_file.read_text()
19
+ except OSError:
20
+ pass
21
+
22
+ if not body_or_path:
23
+ txt_tmpfile = tempfile.NamedTemporaryFile(
24
+ encoding="utf-8", mode="w", suffix=".md"
25
+ )
26
+ subprocess.run([os.environ["EDITOR"], txt_tmpfile.name])
27
+ return Path(txt_tmpfile.name).read_text()
28
+ return body_or_path
@@ -0,0 +1,19 @@
1
+ [tool.poetry]
2
+ name = "phable-cli"
3
+ version = "0.1.0"
4
+ description = "Manage Phabricator tasks from the comfort of your terminal"
5
+ authors = ["Balthazar Rouberol <br@imap.cc>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.9"
11
+ requests = "^2.32.3"
12
+ click = "^8.1.8"
13
+
14
+ [tool.poetry.scripts]
15
+ phable = 'phable_cli.cli:cli'
16
+
17
+ [build-system]
18
+ requires = ["poetry-core"]
19
+ build-backend = "poetry.core.masonry.api"