phable-cli 0.1.7__tar.gz → 0.1.9__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.7
3
+ Version: 0.1.9
4
4
  Summary: Manage Phabricator tasks from the comfort of your terminal
5
5
  License: MIT
6
6
  Author: Balthazar Rouberol
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Dist: click (>=8.1.8,<9.0.0)
17
+ Requires-Dist: pytest (>=8.3.5,<9.0.0)
17
18
  Requires-Dist: requests (>=2.32.3,<3.0.0)
18
19
  Description-Content-Type: text/markdown
19
20
 
@@ -58,20 +59,45 @@ Commands:
58
59
 
59
60
  ## Setup
60
61
 
61
- For `phable` to work, you need to define the following environment variables:
62
+ For `phable` to work, you need to define the follwoing configuration, by running `$EDITOR $(phable config show)`:
62
63
 
63
- - `PHABRICATOR_URL`: URL to your phabricator instance. Ex: `PHABRICATOR_URL=https://phabricator.wikimedia.org`
64
- - `PHABRICATOR_TOKEN`: Generate a token from ${PHABRICATOR_URL}/settings/user/${YOUR_USERNAME}/page/apitokens/
65
- - `PHABRICATOR_DEFAULT_PROJECT_PHID`: id for the Phabricator project to be used by default when creating tasks.
64
+ ```ini
65
+ [phabricator]
66
+ url = # URL to your phabricator instance. Ex: `url = https://phabricator.wikimedia.org`
67
+ token = # API token. Generate a token from ${PHABRICATOR_URL}/settings/user/${YOUR_USERNAME}/page/apitokens/
68
+ default_project_phid = # id for the Phabricator project to be used by default when creating tasks.
69
+ ```
66
70
 
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.
71
+ To get `default_project_phid`, define the first 2 configurations, and run the following command, where `T123456` is a task id belonging to your project.
68
72
 
69
73
  ```console
70
74
  $ phable show T123456 --format=json | jq -r '.attachments.projects.projectPHIDs[]'
71
75
  ```
72
76
 
77
+ Note: you can also expose these confriguration through the following environment variables, for backwards compatibility:
78
+ - `PHABRICATOR_URL`
79
+ - `PHABRICATOR_TOKEN`
80
+ - `PHABRICATOR_DEFAULT_PROJECT_PHID`
81
+
73
82
  ## Tips and tricks
74
83
 
84
+ ### Setting up aliases
85
+ You can define command aliases. For example, instead of typing `phable move --column 'Done' --milestone T123456`, you might want to type `phable done T123456`. To do this, open the phable configuration file, with `$EDITOR $(phable config show)` and define an alias:
86
+
87
+ ```ini
88
+ [aliases]
89
+ done = move --column 'Done' --milestone
90
+ ```
91
+
92
+ I personally currently have the following aliases:
93
+ ```console
94
+ $ phable config aliases list
95
+ done = move --column 'Done' --milestone
96
+ review = move --column 'Needs Review' --milestone
97
+ wip = move --column 'In Progress' --milestone
98
+ ```
99
+
100
+ ### Phabricator task IDs as clickable links in iTerm2
75
101
  If you're using iTerm2, you can turn the task IDs into clickable links, by going to iTerm2 > Settings > Profiles > Advanced > Smart Selection > Edit > [+]:
76
102
  - Title: Phabricator Task Id
77
103
  - Action: Open URL
@@ -39,20 +39,45 @@ Commands:
39
39
 
40
40
  ## Setup
41
41
 
42
- For `phable` to work, you need to define the following environment variables:
42
+ For `phable` to work, you need to define the follwoing configuration, by running `$EDITOR $(phable config show)`:
43
43
 
44
- - `PHABRICATOR_URL`: URL to your phabricator instance. Ex: `PHABRICATOR_URL=https://phabricator.wikimedia.org`
45
- - `PHABRICATOR_TOKEN`: Generate a token from ${PHABRICATOR_URL}/settings/user/${YOUR_USERNAME}/page/apitokens/
46
- - `PHABRICATOR_DEFAULT_PROJECT_PHID`: id for the Phabricator project to be used by default when creating tasks.
44
+ ```ini
45
+ [phabricator]
46
+ url = # URL to your phabricator instance. Ex: `url = https://phabricator.wikimedia.org`
47
+ token = # API token. Generate a token from ${PHABRICATOR_URL}/settings/user/${YOUR_USERNAME}/page/apitokens/
48
+ default_project_phid = # id for the Phabricator project to be used by default when creating tasks.
49
+ ```
47
50
 
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.
51
+ To get `default_project_phid`, define the first 2 configurations, and run the following command, where `T123456` is a task id belonging to your project.
49
52
 
50
53
  ```console
51
54
  $ phable show T123456 --format=json | jq -r '.attachments.projects.projectPHIDs[]'
52
55
  ```
53
56
 
57
+ Note: you can also expose these confriguration through the following environment variables, for backwards compatibility:
58
+ - `PHABRICATOR_URL`
59
+ - `PHABRICATOR_TOKEN`
60
+ - `PHABRICATOR_DEFAULT_PROJECT_PHID`
61
+
54
62
  ## Tips and tricks
55
63
 
64
+ ### Setting up aliases
65
+ You can define command aliases. For example, instead of typing `phable move --column 'Done' --milestone T123456`, you might want to type `phable done T123456`. To do this, open the phable configuration file, with `$EDITOR $(phable config show)` and define an alias:
66
+
67
+ ```ini
68
+ [aliases]
69
+ done = move --column 'Done' --milestone
70
+ ```
71
+
72
+ I personally currently have the following aliases:
73
+ ```console
74
+ $ phable config aliases list
75
+ done = move --column 'Done' --milestone
76
+ review = move --column 'Needs Review' --milestone
77
+ wip = move --column 'In Progress' --milestone
78
+ ```
79
+
80
+ ### Phabricator task IDs as clickable links in iTerm2
56
81
  If you're using iTerm2, you can turn the task IDs into clickable links, by going to iTerm2 > Settings > Profiles > Advanced > Smart Selection > Edit > [+]:
57
82
  - Title: Phabricator Task Id
58
83
  - Action: Open URL
@@ -2,23 +2,30 @@ import inspect
2
2
  import json
3
3
  import os
4
4
  import sys
5
+ import tempfile
5
6
  import time
6
7
  from datetime import datetime
7
8
  from functools import wraps
8
9
  from pathlib import Path
9
10
 
10
- CACHE_HOME_PER_PLATFORM = {
11
- "darwin": Path.home() / "Library" / "Caches",
12
- "linux": Path(os.getenv("XDG_CACHE_HOME", f"{Path.home()}/.cache")),
13
- "windows": Path("c:/", "Users", os.getlogin(), "AppData", "Local", "Temp"),
14
- }
11
+ if not os.getenv("GITHUB_ACTIONS"):
12
+ CACHE_HOME_PER_PLATFORM = {
13
+ "darwin": Path.home() / "Library" / "Caches",
14
+ "linux": Path(os.getenv("XDG_CACHE_HOME", f"{Path.home()}/.config")),
15
+ "windows": Path("c:/", "Users", os.getlogin(), "AppData", "Local", "Temp"),
16
+ }
17
+ else:
18
+ CACHE_HOME_PER_PLATFORM = {}
15
19
 
16
20
 
17
21
  class Cache:
18
22
  """A persistent cache for long-lived data, such as user or project"""
19
23
 
20
24
  def __init__(self):
21
- self.cache_dir = CACHE_HOME_PER_PLATFORM[sys.platform] / "phind"
25
+ cache_parent_dir = CACHE_HOME_PER_PLATFORM.get(sys.platform) or Path(
26
+ tempfile.mkdtemp()
27
+ )
28
+ self.cache_dir = cache_parent_dir / "phind"
22
29
  if not self.cache_dir.exists():
23
30
  self.cache_dir.mkdir()
24
31
  self.cache_filepath = self.cache_dir / "cache.json"
File without changes
@@ -0,0 +1,39 @@
1
+ import click
2
+ from typing import Optional
3
+
4
+ from phable.phabricator import PhabricatorClient
5
+ from phable.utils import Task
6
+ from phable.cli.utils import VARIADIC
7
+
8
+
9
+ @click.command(name="assign")
10
+ @click.option(
11
+ "--username",
12
+ required=False,
13
+ help="The username to assign the task to. Self-assign the task if not provided.",
14
+ )
15
+ @click.argument("task-ids", type=Task.from_str, nargs=VARIADIC, required=True)
16
+ @click.pass_context
17
+ @click.pass_obj
18
+ def assign_task(
19
+ client: PhabricatorClient,
20
+ ctx: click.Context,
21
+ task_ids: list[int],
22
+ username: Optional[str],
23
+ ):
24
+ """Assign one or multiple task ids to a username
25
+
26
+ \b
27
+ Examples:
28
+ $ phable assign T123456 # self assign task
29
+ $ phable assign T123456 brouberol # asign to username
30
+
31
+ """
32
+ if not username:
33
+ user = client.current_user()
34
+ else:
35
+ user = client.find_user_by_username(username)
36
+ if not user:
37
+ ctx.fail(f"User {username} was not found")
38
+ for task_id in task_ids:
39
+ client.assign_task_to_user(task_id=task_id, user_phid=user["phid"])
@@ -0,0 +1,22 @@
1
+ import click
2
+ import atexit
3
+
4
+ from phable.cache import cache
5
+
6
+
7
+ @click.group(name="cache")
8
+ def _cache():
9
+ """Manage internal cache"""
10
+
11
+
12
+ @_cache.command(name="show")
13
+ def show_cache():
14
+ """Display the location of the internal phable cache"""
15
+ click.echo(cache.cache_filepath)
16
+
17
+
18
+ @_cache.command()
19
+ def clear():
20
+ """Delete the phable internal cache file"""
21
+ cache.cache_filepath.unlink(missing_ok=True)
22
+ atexit.unregister(cache.dump) # avoid re-dumping the in-memory cache back to disk
@@ -0,0 +1,26 @@
1
+ import click
2
+ from typing import Optional
3
+ from phable.utils import Task, text_from_cli_arg_or_fs_or_editor
4
+ from phable.phabricator import PhabricatorClient
5
+
6
+
7
+ @click.command(name="comment")
8
+ @click.option(
9
+ "--comment",
10
+ type=str,
11
+ help="Comment text or path to a text file containing the comment body. If not provided, an editor will be opened.",
12
+ )
13
+ @click.argument("task-id", type=Task.from_str)
14
+ @click.pass_obj
15
+ def comment_on_task(client: PhabricatorClient, task_id: int, comment: Optional[str]):
16
+ """Add a comment to a task
17
+
18
+ \b
19
+ Example:
20
+ $ phable comment T123456 --comment 'hello' # set comment body from the cli itself
21
+ $ phable comment T123456 --comment path/to/comment.txt # set comment body from a text file
22
+ $ phable comment T123456 # set comment body from your own text editor
23
+
24
+ """
25
+ comment = text_from_cli_arg_or_fs_or_editor(comment)
26
+ client.create_or_edit_task(task_id=task_id, params={"comment": comment})
@@ -0,0 +1,25 @@
1
+ import click
2
+ from phable.config import config
3
+
4
+
5
+ @click.group(name="config")
6
+ def _config():
7
+ """Manage phable config"""
8
+
9
+
10
+ @_config.command(name="show")
11
+ def show_config():
12
+ """Display the location of the phable config"""
13
+ click.echo(config.filepath)
14
+
15
+
16
+ @_config.group
17
+ def aliases():
18
+ """Manage aliases"""
19
+
20
+
21
+ @aliases.command()
22
+ def list():
23
+ """List configured aliases"""
24
+ for name, alias in config.data.get("aliases", {}).items():
25
+ click.echo(f"{name} = {alias}")
@@ -0,0 +1,149 @@
1
+ import click
2
+ import re
3
+ from typing import Optional
4
+ from pathlib import Path
5
+ from phable.phabricator import PhabricatorClient
6
+ from phable.utils import text_from_cli_arg_or_fs_or_editor, Task
7
+ from phable.config import config
8
+ from phable.cli.show import show_task
9
+
10
+
11
+ @click.command(name="create")
12
+ @click.option("--title", required=True, help="Title of the task")
13
+ @click.option(
14
+ "--description",
15
+ help="Task description or path to a file containing the description body. If not provided, an editor will be opened.",
16
+ )
17
+ @click.option(
18
+ "--template",
19
+ type=Path,
20
+ help=(
21
+ "Task description template file. If provided, the --description flag will be ignored "
22
+ "and an editor will be opened, pre-filled with the template file content"
23
+ ),
24
+ )
25
+ @click.option(
26
+ "--priority",
27
+ type=click.Choice(["unbreaknow", "high", "normal", "low", "needs-triage"]),
28
+ help="Priority level of the task",
29
+ default="normal",
30
+ )
31
+ @click.option("--parent-id", type=Task.from_str, help="ID of parent task")
32
+ @click.option("--tags", multiple=True, help="Tags to associate to the task")
33
+ @click.option("--cc", multiple=True, help="Subscribers to associate to the task")
34
+ @click.option("--owner", help="The username of the task owner")
35
+ @click.pass_context
36
+ @click.pass_obj
37
+ def create_task(
38
+ client: PhabricatorClient,
39
+ ctx: click.Context,
40
+ title: str,
41
+ description: Optional[str],
42
+ template: Path,
43
+ priority: str,
44
+ parent_id: Optional[str],
45
+ tags: list[str],
46
+ cc: list[str],
47
+ owner: Optional[str],
48
+ ):
49
+ """Create a new task
50
+
51
+ \b
52
+ Examples:
53
+ \b
54
+ # Create a task with a long description by writing it in your favorite text editor
55
+ $ phable create --title 'A task'
56
+ \b
57
+ # Create a task with a long description by pointing it to a description file
58
+ $ phable create --title 'A task' --description path/to/description.txt
59
+ \b
60
+ # Create a task with associated title, priority and desription
61
+ $ phable create --title 'Do the thing!' --priority high --description 'Address the thing right now'
62
+ \b
63
+ # Create a task with associated description template
64
+ $ phable create --title 'Do the thing!' --template ./template.md
65
+ \b
66
+ # Create a task with a given parent
67
+ $ phable create --title 'A subtask' --description 'Subtask description' --parent-id T123456
68
+ \b
69
+ # Create a task with an associated top-level project tag
70
+ $ phable create --title 'A task' --tags 'Data-Platform-SRE'
71
+ \b
72
+ # Create a task with an associated sub-project tag
73
+ $ phable create --title 'A task' --tags 'Data-Platform-SRE (2025.03.22 - 2025.04.11)
74
+ \b
75
+ # Create a task with an associated owner
76
+ $ phable create --title 'A task' --owner brouberol
77
+ \b
78
+ # Create a task with an associated subscriber
79
+ $ phable create --title 'A task' --cc brouberol
80
+
81
+ """
82
+ if template:
83
+ if template.exists():
84
+ description = template
85
+ force_editor = True
86
+ else:
87
+ ctx.fail(f"Template file {template} does not exist")
88
+ else:
89
+ force_editor = False
90
+ description = text_from_cli_arg_or_fs_or_editor(
91
+ description, force_editor=force_editor
92
+ )
93
+
94
+ task_params = {
95
+ "title": title,
96
+ "description": description,
97
+ "priority": priority,
98
+ }
99
+
100
+ tag_projects_phids = []
101
+ for tag in tags:
102
+ # The tag name can be a simple string, or "parent name (subproject name)"
103
+ # In the case of the latter, we need to fetch details for both projects
104
+ if match := re.match(
105
+ r"(?P<parent>[\w\s\.-]+) \((?P<subproject>[\w\s+\.-]+)\)", tag
106
+ ):
107
+ parent_title = match.group("parent").strip()
108
+ if parent_project := client.find_project_by_title(title=parent_title):
109
+ parent_project_phid = parent_project["phid"]
110
+ else:
111
+ ctx.fail(f"Project {parent_project} not found")
112
+ project_title = match.group("subproject").strip()
113
+ if project := client.find_project_by_title(
114
+ title=project_title, parent_phid=parent_project_phid
115
+ ):
116
+ tag_projects_phids.append(project["phid"])
117
+ else:
118
+ ctx.fail(f"Project {project_title} not found")
119
+ # Simple project name with no subproject
120
+ elif project := client.find_project_by_title(title=tag):
121
+ tag_projects_phids.append(project["phid"])
122
+ else:
123
+ ctx.fail(f"Project {tag} not found")
124
+ if tag_projects_phids:
125
+ task_params["projects.add"] = tag_projects_phids
126
+ else:
127
+ task_params["projects.add"] = [config.phabricator_default_project_phid]
128
+
129
+ if owner:
130
+ if owner_user := client.find_user_by_username(username=owner):
131
+ task_params["owner"] = owner_user["phid"]
132
+ else:
133
+ ctx.fail(f"User {owner} not found")
134
+
135
+ if parent_id:
136
+ parent = client.show_task(parent_id)
137
+ task_params["parents.set"] = [parent["phid"]]
138
+
139
+ cc_phids = []
140
+ for username in cc:
141
+ if user := client.find_user_by_username(username=username):
142
+ cc_phids.append(user["phid"])
143
+ else:
144
+ ctx.fail(f"User {owner} not found")
145
+ if cc_phids:
146
+ task_params["subscribers.set"] = cc_phids
147
+
148
+ task = client.create_or_edit_task(task_params)
149
+ ctx.invoke(show_task, task_id=task["result"]["object"]["id"])
@@ -0,0 +1,81 @@
1
+ import click
2
+ from typing import Optional
3
+
4
+ from phable.phabricator import PhabricatorClient
5
+ from phable.config import config
6
+ from phable.display import display_tasks
7
+
8
+
9
+ @click.command(name="list")
10
+ @click.option(
11
+ "--column",
12
+ "columns",
13
+ required=False,
14
+ help="The columns the tasks should be located in",
15
+ multiple=True,
16
+ )
17
+ @click.option(
18
+ "--owner",
19
+ required=False,
20
+ help="The username the tasks should be assigned to",
21
+ )
22
+ @click.option(
23
+ "--milestone/--no-milestone",
24
+ default=False,
25
+ help=(
26
+ "If --milestone is passed, the task will be moved onto the current project's associated "
27
+ "milestone board, instead of the project board itself"
28
+ ),
29
+ )
30
+ @click.option(
31
+ "--format",
32
+ required=False,
33
+ type=click.Choice(("html", "plain", "json", "markdown")),
34
+ default="plain",
35
+ help="The output format of the task list",
36
+ )
37
+ @click.pass_context
38
+ @click.pass_obj
39
+ def list_tasks(
40
+ client: PhabricatorClient,
41
+ ctx: click.Context,
42
+ columns: list[str],
43
+ owner: Optional[str] = None,
44
+ milestone: bool = False,
45
+ format: str = "plain",
46
+ ):
47
+ """Lists and filter tasks
48
+
49
+ \b
50
+ Examples:
51
+ # List all tasks in the default board
52
+ $ phable list
53
+ \b
54
+ # List all tasks in the default board latest milestone
55
+ $ phable list --milestone
56
+ \b
57
+ # List all tasks owner by brouberol in the Done column of the default board latest milestone
58
+ $ phable list --milestone --owner brouberol --column Done
59
+
60
+ """
61
+ if owner:
62
+ owner_user = client.find_user_by_username(owner)["phid"]
63
+ if not owner_user:
64
+ ctx.fail(f"User {owner} was not found")
65
+ else:
66
+ owner_user = None
67
+ project_phid = client.get_main_project_or_milestone(
68
+ project_phid=config.phabricator_default_project_phid, milestone=milestone
69
+ )
70
+ if columns:
71
+ column_phids = [
72
+ client.find_column_in_project(project_phid=project_phid, column_name=column)
73
+ for column in columns
74
+ ]
75
+ else:
76
+ column_phids = None
77
+ tasks = client.find_tasks(
78
+ column_phids=column_phids, owner_phid=owner_user, project_phid=project_phid
79
+ )
80
+ tasks = [client.enrich_task(task) for task in tasks]
81
+ display_tasks(tasks=tasks, format=format, separator="")
@@ -0,0 +1,97 @@
1
+ import atexit
2
+
3
+ import click
4
+ from click import Context
5
+
6
+
7
+ from phable.cache import cache
8
+ from phable.config import config
9
+ from phable.phabricator import PhabricatorClient
10
+ from phable.cli.assign import assign_task
11
+ from phable.cli.cache import _cache
12
+ from phable.cli.comment import comment_on_task
13
+ from phable.cli.config import _config
14
+ from phable.cli.create import create_task
15
+ from phable.cli.move import move_task
16
+ from phable.cli.report import report_done_tasks
17
+ from phable.cli.show import show_task
18
+ from phable.cli.subscribe import subscribe_to_task
19
+ from phable.cli.list import list_tasks
20
+
21
+
22
+ class AliasedCommandGroup(click.Group):
23
+ """Custom CLI group allowing the replaement of aliases commands on the fly
24
+
25
+ For example if we have the following configuraion:
26
+ [aliases]
27
+ done = move --column 'Done' --milestone
28
+
29
+ then calling `phable done T123456` will actually call
30
+ `phable move --column Done --milestone T123456` under the hood.
31
+
32
+ """
33
+
34
+ def __init__(self, *args, **kwargs):
35
+ super().__init__(*args, **kwargs)
36
+ self._aliases = config.data.get("aliases", {})
37
+
38
+ def make_context(self, info_name, args, parent=None, **extra):
39
+ # First, let's parse the command and handle aliases
40
+ parsed_args = self.parse_command(info_name, args)
41
+
42
+ # Then create the context with the possibly modified args
43
+ ctx = super().make_context(
44
+ info_name=parsed_args[0], args=parsed_args[1:], parent=parent, **extra
45
+ )
46
+ return ctx
47
+
48
+ def parse_command(self, ctx_name, args):
49
+ """Parse command line arguments and handle aliases"""
50
+ if not args:
51
+ return [ctx_name]
52
+
53
+ # Check if the first argument is an alias
54
+ if args[0] in self._aliases:
55
+ pattern = self._aliases[args[0]]
56
+ # Split the pattern and combine with remaining args
57
+ pattern_parts = click.parser.split_arg_string(pattern)
58
+ # Replace the alias with the pattern parts
59
+ args = pattern_parts + args[1:]
60
+
61
+ return [ctx_name] + args
62
+
63
+ def get_command(self, ctx, cmd_name):
64
+ """Override to handle aliases in command lookup"""
65
+ if cmd_name in self._aliases:
66
+ return super().get_command(ctx, self._aliases[cmd_name].split()[0])
67
+ return super().get_command(ctx, cmd_name)
68
+
69
+
70
+ @click.group(cls=AliasedCommandGroup)
71
+ @click.version_option(package_name="phable-cli")
72
+ @click.pass_context
73
+ def cli(ctx: Context):
74
+ """Manage Phabricator tasks from the comfort of your terminal"""
75
+ ctx.obj = PhabricatorClient(config.phabricator_url, config.phabricator_token)
76
+
77
+
78
+ cli.add_command(assign_task)
79
+ cli.add_command(_cache)
80
+ cli.add_command(comment_on_task)
81
+ cli.add_command(_config)
82
+ cli.add_command(create_task)
83
+ cli.add_command(move_task)
84
+ cli.add_command(report_done_tasks)
85
+ cli.add_command(show_task)
86
+ cli.add_command(subscribe_to_task)
87
+ cli.add_command(list_tasks)
88
+
89
+
90
+ def runcli():
91
+ # Dump the in-memory cache to disk when existing the CLI
92
+ atexit.register(cache.dump)
93
+ cli(max_content_width=120)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ runcli()
@@ -0,0 +1,58 @@
1
+ import click
2
+ from typing import Optional
3
+ from phable.config import config
4
+ from phable.cli.utils import VARIADIC
5
+ from phable.phabricator import PhabricatorClient
6
+ from phable.utils import Task
7
+
8
+
9
+ @click.command(name="move")
10
+ @click.option(
11
+ "--column",
12
+ type=str,
13
+ required=True,
14
+ help="Name of destination column on the current project board",
15
+ )
16
+ @click.option(
17
+ "--milestone/--no-milestone",
18
+ default=False,
19
+ help=(
20
+ "If --milestone is passed, the task will be moved onto the current project's associated "
21
+ "milestone board, instead of the project board itself"
22
+ ),
23
+ )
24
+ @click.argument("task-ids", type=Task.from_str, nargs=VARIADIC, required=True)
25
+ @click.pass_context
26
+ @click.pass_obj
27
+ def move_task(
28
+ client: PhabricatorClient,
29
+ ctx: click.Context,
30
+ task_ids: list[int],
31
+ column: Optional[str],
32
+ milestone: bool,
33
+ ) -> None:
34
+ """Move one or several task on their current project board
35
+
36
+ If the task is moved to a 'Done' column, it will be automatically
37
+ marked as 'Resolved' as well.
38
+
39
+ \b
40
+ Example:
41
+ $ phable move T123456 --column 'In Progress'
42
+ $ phable move T123456 T234567 --column 'Done'
43
+
44
+ """
45
+ try:
46
+ target_project_phid = client.get_main_project_or_milestone(
47
+ milestone, config.phabricator_default_project_phid
48
+ )
49
+ target_column_phid = client.find_column_in_project(target_project_phid, column)
50
+
51
+ for task_id in task_ids:
52
+ client.move_task_to_column(task_id=task_id, column_phid=target_column_phid)
53
+ if column.lower() in ("in progress", "needs review"):
54
+ client.mark_task_as_in_progress(task_id)
55
+ if column.lower() == "done":
56
+ client.mark_task_as_resolved(task_id)
57
+ except ValueError as ve:
58
+ ctx.fail(ve)