diff --git a/README.md b/README.md index 4e216d5..8807ff8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ A summary of the currently supported features: - **Diffusion** - List repositories, get branches, clone URIs, add repositories, manage URIs - **Paste** - List, get, and add pastes - **User** - Get information about the logged-in user -- **Maniphest** - Add comments, show task details, create tasks from templates +- **Maniphest** - Add comments, show task details, create tasks from templates, search with advanced filters +- **Edit** - Edit tasks with auto-detection, batch operations, and smart board/column navigation For complete documentation, see [Read the Docs](https://phabfive.readthedocs.io/). @@ -79,10 +80,17 @@ EOF ### 3. Use phabfive ```bash +# Get secrets and list pastes phabfive passphrase K123 phabfive paste list + +# Search tasks phabfive maniphest search "migration tasks" --tag myproject -phabfive maniphest search --tag myproject --updated-after=1w +phabfive maniphest search --tag myproject --updated-after=3d + +# Edit tasks individually or in batch +phabfive edit T123 --priority=high --status=resolved +phabfive maniphest search --assigned=@me | phabfive edit --column=Done ``` ## Documentation diff --git a/docs/edit-cli.md b/docs/edit-cli.md new file mode 100644 index 0000000..4eed720 --- /dev/null +++ b/docs/edit-cli.md @@ -0,0 +1,591 @@ +# Edit CLI + +The Edit CLI provides a unified interface for editing Phabricator/Phorge objects with intelligent auto-detection and batch processing capabilities. + +## Overview + +The `phabfive edit` command automatically detects object types from their monograms (T for tasks, K for passphrases, P for pastes) and routes to the appropriate editor. It supports both single object editing and batch operations through piped YAML input. + +**Current Support:** +- ✅ **Maniphest Tasks (T)** - Full editing support +- 🔜 **Passphrases (K)** - Planned for future release +- 🔜 **Pastes (P)** - Planned for future release + +**Key Features:** +- **Auto-detection**: Automatically detects object type from monogram (T123, K456, P789) +- **Piped input support**: Seamlessly process output from `search` and `show` commands +- **Batch operations**: Edit multiple objects atomically with validation +- **Smart board/column handling**: Auto-detect board context or specify explicitly +- **Directional navigation**: Move priority/columns up/down without knowing exact values +- **Change detection**: Only applies changes that differ from current state + +## Quick Start + +```bash +# Edit a single task +phabfive edit T123 --priority=high --status=resolved + +# Pipe search results to edit multiple tasks +phabfive maniphest search --assigned=@me | phabfive edit --status=resolved + +# Navigate columns directionally +phabfive edit T123 --tag="Sprint" --column=forward + +# Raise priority (with smart Triage skip) +phabfive edit T123 --priority=raise +``` + +## Command Syntax + +```bash +phabfive edit [] [options] + +Options: + --priority=PRIORITY Set priority (unbreak|high|normal|low|wish|raise|lower) + --status=STATUS Set status (open|resolved|wontfix|invalid|duplicate) + --tag=BOARD Specify board context for --column (also adds task to board) + --column=COLUMN Set column on board (or use forward/backward) + --assign=USER Set assignee + --comment=TEXT Add comment with changes + --dry-run Show changes without applying + --format=FORMAT Output format (auto|strict|rich) [default: auto] +``` + +## Input Modes + +The edit command automatically detects how you're providing input: + +### Single Object Mode + +Provide an object monogram directly: + +```bash +# Edit task T123 +phabfive edit T123 --priority=high + +# Edit from URL (extracts T123) +phabfive edit "https://phorge.example.com/T123" --status=resolved +``` + +### Batch Mode (Piped Input) + +Pipe YAML output from `search` or `show` commands: + +```bash +# Edit all tasks assigned to you +phabfive maniphest search --assigned=@me | phabfive edit --priority=lower + +# Edit tasks from complex search +phabfive maniphest search --tag="Backend" --status=open | \ + phabfive edit --column=Done --status=resolved --comment="Batch resolved" +``` + +The command automatically detects piped input using stdin detection - no flags needed! + +## Task Editing + +### Priority Management + +#### Set Explicit Priority + +```bash +phabfive edit T123 --priority=high +phabfive edit T123 --priority=unbreak +``` + +**Available priorities:** `unbreak`, `high`, `triage`, `normal`, `low`, `wish` + +#### Directional Priority Navigation + +Navigate priority ladder without knowing exact values: + +```bash +# Raise priority one level +phabfive edit T123 --priority=raise + +# Lower priority one level +phabfive edit T123 --priority=lower +``` + +**Priority Ladder:** +``` +Wish (0) → Low (25) → Normal (50) → High (80) → Unbreak (100) + ↑ + Triage (90) [skipped during raise/lower] +``` + +**Special Triage Handling:** + +The Triage priority (90) is automatically **skipped** during directional navigation: + +- **Raise from High → Unbreak** (skips Triage) +- **Lower from Unbreak → High** (skips Triage) +- **Raise from Triage → Unbreak** (special case) +- **Lower from Triage → High** (special case) + +To explicitly set Triage, use: `--priority=triage` + +**Examples:** + +```bash +# Task at Normal priority +phabfive edit T123 --priority=raise # → High +phabfive edit T123 --priority=raise # → Unbreak (skips Triage!) + +# Task at Unbreak priority +phabfive edit T123 --priority=lower # → High (skips Triage!) + +# Task at Triage priority (from manual set) +phabfive edit T123 --priority=raise # → Unbreak +phabfive edit T123 --priority=lower # → High +``` + +### Status Management + +Set task status: + +```bash +phabfive edit T123 --status=resolved +phabfive edit T123 --status=wontfix +phabfive edit T123 --status=open +``` + +**Common statuses:** `open`, `resolved`, `wontfix`, `invalid`, `duplicate` + +### Workboard Column Management + +#### Board/Column Context + +When moving tasks between columns, you can specify the board explicitly or let phabfive auto-detect: + +**Auto-detection (task on single board):** +```bash +# Automatically detects which board +phabfive edit T123 --column=Done +``` + +**Explicit board specification:** +```bash +# Specify board when task is on multiple boards +phabfive edit T123 --tag="Sprint 42" --column=Done + +# Add task to new board and set column +phabfive edit T123 --tag="Backend Team" --column=Backlog +``` + +#### Set Column by Name + +Move to a specific column: + +```bash +phabfive edit T123 --tag="Sprint" --column="In Progress" +phabfive edit T123 --tag="Backend" --column=Done +``` + +Column names are **case-insensitive**. + +#### Directional Column Navigation + +Navigate columns by sequence without knowing column names: + +```bash +# Move forward one column +phabfive edit T123 --tag="Sprint" --column=forward + +# Move backward one column +phabfive edit T123 --tag="Sprint" --column=backward +``` + +**Column Navigation Behavior:** +- Columns are ordered by their `sequence` field +- `forward` moves to the next column in sequence +- `backward` moves to the previous column in sequence +- At boundaries (first/last column), the task stays in place + +**Example workflow:** +``` +Backlog (seq: 0) → In Progress (seq: 1) → Review (seq: 2) → Done (seq: 3) + +# Task in "In Progress" +phabfive edit T123 --column=forward # → Review +phabfive edit T123 --column=forward # → Done +phabfive edit T123 --column=forward # → Done (stays at end) +phabfive edit T123 --column=backward # → Review +``` + +### Assignment Management + +Assign tasks to users: + +```bash +# Assign to specific user +phabfive edit T123 --assign=alice + +# Assign to yourself +phabfive edit T123 --assign=@me +``` + +### Adding Comments + +Add a comment along with your changes: + +```bash +phabfive edit T123 --status=resolved --comment="Fixed in commit abc123" +``` + +## Batch Operations + +### Basic Batch Editing + +Edit multiple tasks by piping search results: + +```bash +# Resolve all tasks assigned to you +phabfive maniphest search --assigned=@me | \ + phabfive edit --status=resolved --comment="Completed" + +# Lower priority for all tasks in a project +phabfive maniphest search --tag="Backlog" | \ + phabfive edit --priority=lower +``` + +### Atomic Validation + +Batch operations use **atomic validation**: ALL tasks are validated before ANY are modified. + +If any task fails validation, the entire batch fails with detailed error messages: + +``` +ERROR: Validation failed for 2 task(s): + - T123: Task T123 is on multiple boards [Backend, Sprint 42]. Use --tag=BOARD to specify which board. + - T124: Task T124 is on multiple boards [Backend, Sprint 42]. Use --tag=BOARD to specify which board. + +No tasks were modified (atomic batch failure). +``` + +### Handling Multiple Boards + +When tasks are on multiple boards and you're using `--column`, you must specify which board: + +```bash +# This will fail if tasks are on multiple boards +phabfive maniphest search --assigned=@me | phabfive edit --column=Done + +# Specify the board +phabfive maniphest search --assigned=@me | \ + phabfive edit --tag="Sprint 42" --column=Done +``` + +### Partition Suggestions + +When batch operations fail due to tasks on different boards, phabfive provides **partition suggestions** to help you split the work: + +``` +ERROR: Validation failed for 3 task(s): + - T123: Task T123 is on multiple boards [Backend Team, Sprint 42]. Use --tag=BOARD to specify which board. + - T124: Task T124 is on multiple boards [Backend Team, Sprint 42]. Use --tag=BOARD to specify which board. + - T125: Task T125 is on multiple boards [Backend Team, Frontend Team]. Use --tag=BOARD to specify which board. + +Suggested partition commands: + +# Tasks on Backend Team + Sprint 42: +echo "T123\nT124" | phabfive edit --tag="Backend Team" --column=Done + +# Task on Backend Team + Frontend Team: +echo "T125" | phabfive edit --tag="Backend Team" --column=Done + +No tasks were modified (atomic batch failure). +``` + +## Advanced Workflows + +### Complex Multi-Field Updates + +Combine multiple field updates in one operation: + +```bash +phabfive edit T123 \ + --priority=high \ + --status=open \ + --tag="Sprint 42" \ + --column="In Progress" \ + --assign=alice \ + --comment="Moving to current sprint" +``` + +### Pipeline with Search Filters + +Use powerful search filters and pipe to edit: + +```bash +# Resolve all tasks created before a date +phabfive maniphest search --created-before="2024-01-01" --status=open | \ + phabfive edit --status=wontfix --comment="Closed old tasks" + +# Move tasks updated in last week to Done +phabfive maniphest search --updated-after=7 --tag="Sprint" | \ + phabfive edit --column=Done +``` + +### Dry Run Mode + +Preview changes without applying them: + +```bash +phabfive edit T123 --priority=high --status=resolved --dry-run +``` + +Useful for: +- Testing batch operations before applying +- Verifying change detection works correctly +- Debugging complex workflows + +### Change Detection + +The edit command automatically detects which fields have changed and only applies necessary transactions: + +```bash +# Task T123 already has priority=high +phabfive edit T123 --priority=high --status=resolved +# Only applies status change (priority unchanged) +``` + +Benefits: +- Cleaner audit trails (no redundant transactions) +- Faster API calls +- Better performance for batch operations + +## Examples by Use Case + +### Sprint Management + +```bash +# Move all completed tasks to Done +phabfive maniphest search --tag="Sprint 42" --status=resolved | \ + phabfive edit --column=Done + +# Triage new tasks +phabfive maniphest search --tag="Sprint 42" --column="Backlog" | \ + phabfive edit --priority=triage --column="Triage" + +# Bump priority for P1 tasks +phabfive maniphest search --tag="Sprint 42" --priority=high | \ + phabfive edit --priority=raise +``` + +### Bulk Task Management + +```bash +# Reassign tasks from leaving team member +phabfive maniphest search --assigned=bob | \ + phabfive edit --assign=alice --comment="Reassigned during transition" + +# Close stale tasks +phabfive maniphest search --updated-before=90 --status=open | \ + phabfive edit --status=wontfix --comment="Closed due to inactivity" + +# Add all backend tasks to new board +phabfive maniphest search --tag="Backend" | \ + phabfive edit --tag="Q1 Planning" --column=Backlog +``` + +### Personal Workflow + +```bash +# Mark your tasks as done +phabfive maniphest search --assigned=@me --status=resolved | \ + phabfive edit --column=Done --comment="Completed" + +# Deprioritize all your low-priority tasks +phabfive maniphest search --assigned=@me --priority=normal | \ + phabfive edit --priority=lower + +# Move your in-progress tasks forward +phabfive maniphest search --assigned=@me --column="In Progress" | \ + phabfive edit --column=forward +``` + +## Board/Column Validation Rules + +Understanding when board context is required: + +| Scenario | Board Context | Behavior | +|----------|---------------|----------| +| `--column` only, task on single board | ✅ Auto-detected | Moves to column | +| `--column` only, task on multiple boards | ❌ Required | Error with suggestions | +| `--tag` + `--column`, task on specified board | ✅ Explicit | Moves to column | +| `--tag` + `--column`, task NOT on board | ✅ Explicit | Adds to board + sets column | +| No `--column` specified | N/A | No validation needed | + +## Output Formats + +Control output format for piping and display: + +```bash +# Auto-detect (rich for terminal, strict for pipes) +phabfive edit T123 --priority=high + +# Force strict YAML for piping +phabfive edit T123 --priority=high --format=strict + +# Force rich formatting +phabfive edit T123 --priority=high --format=rich +``` + +**Format auto-detection:** +- Terminal (TTY): Rich formatting with colors +- Piped/redirected: Strict YAML for machine parsing + +## Error Handling + +### Validation Errors + +The edit command validates inputs before making API calls: + +```bash +# Invalid priority +phabfive edit T123 --priority=invalid +# ERROR: Invalid priority: invalid + +# Invalid status +phabfive edit T123 --status=invalid +# ERROR: Invalid status: invalid + +# Column without board context (multiple boards) +phabfive edit T123 --column=Done +# ERROR: Task T123 is on multiple boards [...]. Use --tag=BOARD to specify which board. +``` + +### API Errors + +API errors are reported clearly: + +```bash +# Task doesn't exist +phabfive edit T99999 --priority=high +# ERROR: Task not found + +# Permission denied +phabfive edit T123 --assign=alice +# ERROR: You don't have permission to edit this task +``` + +### Batch Operation Failures + +Batch operations fail atomically with clear error messages and partition suggestions when applicable. + +## Tips and Best Practices + +### 1. Always Preview Batch Operations + +Use `--dry-run` to preview changes before applying: + +```bash +phabfive maniphest search --tag="Cleanup" | \ + phabfive edit --status=wontfix --dry-run +``` + +### 2. Use Descriptive Comments + +Add context to bulk changes: + +```bash +phabfive maniphest search --tag="Migration" | \ + phabfive edit --status=resolved --comment="Migration completed in release 2.0" +``` + +### 3. Leverage Auto-Detection + +Let phabfive auto-detect board context when possible: + +```bash +# Good (auto-detects board) +phabfive edit T123 --column=Done + +# Unnecessary (if task on single board) +phabfive edit T123 --tag="MyBoard" --column=Done +``` + +### 4. Use Directional Navigation + +Prefer directional navigation for workflows: + +```bash +# Good (works regardless of current priority) +phabfive edit T123 --priority=raise + +# Less flexible (requires knowing current state) +phabfive edit T123 --priority=high +``` + +### 5. Combine with Search Templates + +Use search templates for complex recurring batch operations: + +```bash +phabfive maniphest search --with templates/weekly-sprint-tasks.yaml | \ + phabfive edit --column=Done --status=resolved +``` + +## Troubleshooting + +### Task on Multiple Boards + +**Problem:** Error when using `--column` without `--tag` + +**Solution:** Specify board with `--tag`: +```bash +phabfive edit T123 --tag="Sprint 42" --column=Done +``` + +### Column Not Found + +**Problem:** Error "Column 'XYZ' not found on board" + +**Solution:** Check column names are spelled correctly (case-insensitive): +```bash +phabfive maniphest show T123 --all +# Look at "Boards" section for column names +``` + +### Batch Operation Fails for Some Tasks + +**Problem:** Some tasks in batch have different board configurations + +**Solution:** Use partition suggestions from error message to split the batch: +```bash +# Original (fails) +echo "T123\nT124\nT125" | phabfive edit --column=Done + +# Split by board (from suggestions) +echo "T123\nT124" | phabfive edit --tag="Backend" --column=Done +echo "T125" | phabfive edit --tag="Frontend" --column=Done +``` + +### Priority Not Changing + +**Problem:** Priority appears unchanged after edit + +**Solution:** Check change detection - priority may already be at target: +```bash +# Check current priority +phabfive maniphest show T123 + +# Verify with --dry-run +phabfive edit T123 --priority=high --dry-run +``` + +## See Also + +- [Maniphest CLI](maniphest-cli.md) - Complete task management guide +- [Search Templates](search-templates.md) - Reusable search queries +- [Create Templates](create-templates.md) - Bulk task creation + +## Future Support + +Planned object types for future releases: + +- **Passphrases (K)** - Edit secrets and credentials +- **Pastes (P)** - Edit code pastes + +The architecture supports easy extension to new object types through the monogram detection system. diff --git a/docs/index.md b/docs/index.md index 19739ab..fdc6e57 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,8 @@ Phabfive currently supports the following Phabricator/Phorge applications: - **Diffusion** - List repositories, get branches, clone URIs, add repositories, manage URIs - **Paste** - List, get, and add code pastes - **User** - Get information about the logged-in user -- **Maniphest** - Add comments, show task details, create tasks from templates, and search with advanced project filtering and transition filtering +- **Maniphest** - Add comments, show task details, create tasks from templates, search with advanced project filtering and transition filtering, and edit tasks with batch operations +- **Edit** - Unified interface for editing objects with auto-detection, batch processing, and smart board/column handling ## Getting Started @@ -44,6 +45,7 @@ For detailed setup instructions, see the [README](https://github.com/dynamist/ph ### CLI Reference - **[Maniphest CLI](maniphest-cli.md)** - Complete guide to task management, including advanced transition filtering +- **[Edit CLI](edit-cli.md)** - Unified editing interface with auto-detection, batch operations, and directional navigation ### Development diff --git a/phabfive/cli.py b/phabfive/cli.py index bc76bd5..1439789 100644 --- a/phabfive/cli.py +++ b/phabfive/cli.py @@ -18,6 +18,7 @@ phabfive [options] [ ...] Available phabfive commands are: + edit Edit Phabricator objects (auto-detects type from monogram) passphrase The passphrase app diffusion The diffusion app maniphest The maniphest app @@ -52,6 +53,44 @@ """ # nosec-B105 +sub_edit_args = """ +Usage: + phabfive edit [] [options] + +Arguments: + Object to edit (e.g., T123, K456, P789). + Auto-detects type from monogram. + If omitted, reads YAML from stdin (auto-detected when piped). + +Options: + --priority=PRIORITY Set priority: unbreak, high, normal, low, wish, raise, lower + --status=STATUS Set status: open, resolved, wontfix, invalid, duplicate, etc. + --tag=BOARD Specify board context for --column (also adds task to board if needed) + --column=COLUMN Set column on board, or use forward/backward for directional navigation + --assign=USER Set assignee (username) + --comment=TEXT Add comment with changes + --dry-run Show changes without applying them + -h, --help Show this help message and exit + +Input Modes (auto-detected): + - If provided: Edit single object + - If stdin is piped: Edit multiple objects from YAML stream (no flag needed!) + - If neither: Error + +Examples: + # Edit single task + phabfive edit T123 --priority=raise --status=resolved + + # Pipe search results (auto-detects stdin, no flag needed!) + phabfive maniphest search --tag "Backend" | phabfive edit --column=Done + + # Multi-board handling + phabfive maniphest search --assigned=@me | phabfive edit --tag="Sprint 42" --column=Done + + # Directional navigation + phabfive edit T123 --tag="Sprint" --column=forward --comment="Moving forward" +""" + sub_diffusion_args = """ Usage: phabfive diffusion repo list [(active || inactive || all)] [options] @@ -343,6 +382,8 @@ def parse_cli(): sub_args = docopt(sub_maniphest_show_args, argv=argv) else: sub_args = docopt(eval("sub_{app}_args".format(app=app)), argv=argv) # nosec-B307 + elif cli_args[""] == "edit": + sub_args = docopt(sub_edit_args, argv=argv) elif cli_args[""] == "passphrase": sub_args = docopt(sub_passphrase_args, argv=argv) elif cli_args[""] == "diffusion": @@ -398,7 +439,7 @@ def run(cli_args, sub_args): Execute the CLI """ # Local imports required due to logging limitation - from phabfive import diffusion, maniphest, passphrase, paste, repl, user + from phabfive import diffusion, edit, maniphest, passphrase, paste, repl, user from phabfive.constants import REPO_STATUS_CHOICES from phabfive.core import Phabfive from phabfive.exceptions import PhabfiveException @@ -444,6 +485,20 @@ def run(cli_args, sub_args): retcode = 0 try: + if cli_args[""] == "edit": + edit_app = edit.Edit() + retcode = edit_app.edit_objects( + object_id=sub_args.get(""), + priority=sub_args.get("--priority"), + status=sub_args.get("--status"), + tag=sub_args.get("--tag"), + column=sub_args.get("--column"), + assign=sub_args.get("--assign"), + comment=sub_args.get("--comment"), + dry_run=sub_args.get("--dry-run", False) + ) + return retcode + if cli_args[""] == "passphrase": passphrase_app = passphrase.Passphrase() passphrase_app.print_secret(sub_args[""]) diff --git a/phabfive/edit.py b/phabfive/edit.py new file mode 100644 index 0000000..33ef25f --- /dev/null +++ b/phabfive/edit.py @@ -0,0 +1,451 @@ +# -*- coding: utf-8 -*- + +""" +Edit module for phabfive. + +Provides a unified edit command that auto-detects object type from monogram +and routes to the appropriate editor. +""" + +# python std lib +import logging +import re +import sys +from collections import defaultdict + +# 3rd party imports +from ruamel.yaml import YAML + +# phabfive imports +from phabfive.core import Phabfive +from phabfive.maniphest import Maniphest + +log = logging.getLogger(__name__) + + +class Edit(Phabfive): + """Edit handler for Phabricator objects.""" + + # Monogram patterns + TASK_PATTERN = re.compile(r'[Tt](\d+)') + PASSPHRASE_PATTERN = re.compile(r'[Kk](\d+)') + PASTE_PATTERN = re.compile(r'[Pp](\d+)') + + def __init__(self): + """Initialize the Edit handler.""" + super().__init__() + self.maniphest = Maniphest() + + def _parse_monogram(self, text): + """Parse a monogram from text and return object type and ID. + + Args: + text (str): Text containing a monogram (e.g., "T123", "https://phorge.example.com/T456") + + Returns: + tuple: (object_type, object_id) where object_type is "task"|"passphrase"|"paste"|None + and object_id is the numeric ID + + Raises: + ValueError: If no valid monogram is found + """ + # Try task monogram + match = self.TASK_PATTERN.search(text) + if match: + return ("task", match.group(1)) + + # Try passphrase monogram + match = self.PASSPHRASE_PATTERN.search(text) + if match: + return ("passphrase", match.group(1)) + + # Try paste monogram + match = self.PASTE_PATTERN.search(text) + if match: + return ("paste", match.group(1)) + + raise ValueError(f"No valid monogram found in: {text}") + + def _parse_yaml_from_stdin(self): + """Parse YAML documents from stdin. + + Returns: + list: List of dicts, each containing: + - object_type: str ("task"|"passphrase"|"paste") + - object_id: str (numeric ID) + - data: dict (parsed YAML data) + + Raises: + ValueError: If YAML is invalid or missing required fields + """ + yaml = YAML() + yaml.preserve_quotes = True + yaml.default_flow_style = False + + objects = [] + + # Read all YAML documents from stdin + try: + for doc in yaml.load_all(sys.stdin): + if doc is None: + continue + + # Extract monogram from Link field + if "Link" not in doc: + raise ValueError("YAML document missing 'Link' field") + + link = doc["Link"] + object_type, object_id = self._parse_monogram(link) + + objects.append({ + "object_type": object_type, + "object_id": object_id, + "data": doc + }) + + except Exception as e: + raise ValueError(f"Failed to parse YAML from stdin: {e}") + + return objects + + def _group_objects_by_type(self, objects): + """Group objects by their type. + + Args: + objects (list): List of object dicts from _parse_yaml_from_stdin + + Returns: + dict: Dict mapping object_type -> list of objects + """ + grouped = defaultdict(list) + for obj in objects: + grouped[obj["object_type"]].append(obj) + return dict(grouped) + + def _validate_board_column_context(self, task_id, task_data, column_arg, tag_arg): + """Validate board/column context for a single task. + + Args: + task_id (str): Task ID (e.g., "123") + task_data (dict): Current task data from API (with attachments) + column_arg (str): Value of --column flag (e.g., "Done", "forward", "backward") + tag_arg (str): Value of --tag flag (board name) or None + + Returns: + tuple: (board_phid, error_message) + board_phid is None if error, error_message is None if success + + """ + if column_arg is None: + # No column change requested, no validation needed + return (None, None) + + # Get list of boards this task is on + task_boards = self._get_task_boards(task_data) + + if tag_arg: + # User specified a board, resolve it + board_phids = self.maniphest._resolve_project_phids([tag_arg]) + if not board_phids: + return (None, f"Board not found: {tag_arg}") + board_phid = board_phids[0] + return (board_phid, None) + + # No board specified, try to auto-detect + if len(task_boards) == 0: + return (None, f"Task T{task_id} is not on any boards. Use --tag=BOARD to specify which board to add it to.") + elif len(task_boards) == 1: + # Single board, auto-detect + return (task_boards[0], None) + else: + # Multiple boards, cannot auto-detect + board_names = self._get_board_names(task_boards) + return (None, f"Task T{task_id} is on multiple boards {board_names}. Use --tag=BOARD to specify which board.") + + def _get_task_boards(self, task_data): + """Extract board PHIDs from task data. + + Args: + task_data (dict): Task data from maniphest.search with attachments + + Returns: + list: List of board PHIDs the task is on + """ + try: + boards = task_data.get("attachments", {}).get("columns", {}).get("boards", {}) + return list(boards.keys()) + except Exception: + return [] + + def _get_board_names(self, board_phids): + """Get display names for board PHIDs. + + Args: + board_phids (list): List of board PHIDs + + Returns: + list: List of board names + """ + # TODO: Could cache this or use existing project cache + try: + results = self.phab.project.search(constraints={"phids": board_phids}) + names = [proj["fields"]["name"] for proj in results["data"]] + return names + except Exception: + # Fallback to PHIDs if we can't resolve names + return board_phids + + def _generate_partition_suggestions(self, errors_by_boards): + """Generate suggested commands to partition tasks by board membership. + + Args: + errors_by_boards (dict): Dict mapping frozenset of board names -> list of task IDs + + Returns: + str: Multi-line string with suggested partition commands + """ + suggestions = [] + suggestions.append("\nSuggested partition commands:") + suggestions.append("") + + for boards, task_ids in errors_by_boards.items(): + board_list = sorted(boards) + board_str = " + ".join(board_list) + task_str = "\\n".join([f"T{tid}" for tid in task_ids]) + + suggestions.append(f"# Tasks on {board_str}:") + # Pick the first board as the target (user can adjust) + target_board = board_list[0] + suggestions.append(f'echo "{task_str}" | phabfive edit --tag="{target_board}" --column=COLUMN') + suggestions.append("") + + return "\n".join(suggestions) + + def edit_objects(self, object_id=None, priority=None, status=None, + tag=None, column=None, assign=None, comment=None, dry_run=False): + """Edit one or more Phabricator objects. + + Args: + object_id (str): Single object ID (e.g., "T123") or None if from stdin + priority (str): Priority to set (or "raise"/"lower") + status (str): Status to set + tag (str): Board name for column context + column (str): Column name (or "forward"/"backward") + assign (str): Username to assign + comment (str): Comment to add + dry_run (bool): Show changes without applying + + Returns: + int: Return code (0 for success, 1 for failure) + """ + try: + # Auto-detect piped input + has_piped_input = not sys.stdin.isatty() + + if object_id: + # Single object mode (CLI argument takes priority) + object_type, oid = self._parse_monogram(object_id) + + if object_type == "task": + return self._edit_task_single( + oid, + priority=priority, + status=status, + tag=tag, + column=column, + assign=assign, + comment=comment, + dry_run=dry_run + ) + elif object_type == "passphrase": + sys.stderr.write("ERROR: Passphrase editing not yet implemented\n") + return 1 + elif object_type == "paste": + sys.stderr.write("ERROR: Paste editing not yet implemented\n") + return 1 + + elif has_piped_input: + # Batch mode (auto-detected from pipe) + objects = self._parse_yaml_from_stdin() + if not objects: + sys.stderr.write("ERROR: No objects found in stdin\n") + return 1 + + # Group by object type + grouped = self._group_objects_by_type(objects) + + # Process tasks + if "task" in grouped: + retcode = self._edit_tasks_batch( + grouped["task"], + priority=priority, + status=status, + tag=tag, + column=column, + assign=assign, + comment=comment, + dry_run=dry_run + ) + if retcode != 0: + return retcode + + # Passphrases and pastes not yet implemented + if "passphrase" in grouped: + sys.stderr.write("ERROR: Passphrase editing not yet implemented\n") + return 1 + if "paste" in grouped: + sys.stderr.write("ERROR: Paste editing not yet implemented\n") + return 1 + + return 0 + + else: + # Error: no input provided + sys.stderr.write("ERROR: Object ID required (e.g., T123) or pipe YAML from stdin\n") + return 1 + + except ValueError as e: + sys.stderr.write(f"ERROR: {e}\n") + return 1 + except Exception as e: + log.exception("Unexpected error during edit") + sys.stderr.write(f"ERROR: {e}\n") + return 1 + + def _edit_task_single(self, task_id, priority=None, status=None, tag=None, + column=None, assign=None, comment=None, dry_run=False): + """Edit a single task. + + Args: + task_id (str): Task ID (numeric, e.g., "123") + priority (str): Priority to set (or "raise"/"lower") + status (str): Status to set + tag (str): Board name for column context + column (str): Column name (or "forward"/"backward") + assign (str): Username to assign + comment (str): Comment to add + dry_run (bool): Show changes without applying + + Returns: + int: Return code (0 for success, 1 for failure) + """ + try: + # Fetch current task state + task_data = self.maniphest._get_task_data(task_id) + + # Validate board/column context + board_phid, error = self._validate_board_column_context(task_id, task_data, column, tag) + if error: + sys.stderr.write(f"ERROR: {error}\n") + return 1 + + # Delegate to maniphest module + self.maniphest.edit_task_by_id( + task_id=task_id, + priority=priority, + status=status, + board_phid=board_phid, + column=column, + assign=assign, + comment=comment, + dry_run=dry_run + ) + + print(f"Successfully edited T{task_id}") + return 0 + + except Exception as e: + log.exception(f"Failed to edit task T{task_id}") + sys.stderr.write(f"ERROR editing T{task_id}: {e}\n") + return 1 + + def _edit_tasks_batch(self, tasks, priority=None, status=None, tag=None, + column=None, assign=None, comment=None, dry_run=False): + """Edit multiple tasks in batch (atomic validation). + + Args: + tasks (list): List of task dicts from _parse_yaml_from_stdin + priority (str): Priority to set (or "raise"/"lower") + status (str): Status to set + tag (str): Board name for column context + column (str): Column name (or "forward"/"backward") + assign (str): Username to assign + comment (str): Comment to add + dry_run (bool): Show changes without applying + + Returns: + int: Return code (0 for success, 1 for failure) + """ + # Phase 1: Validate ALL tasks before processing ANY (atomic batch) + validation_errors = [] + errors_by_boards = defaultdict(list) + validated_tasks = [] + + for task in tasks: + task_id = task["object_id"] + + try: + # Fetch current task state + task_data = self.maniphest._get_task_data(task_id) + + # Validate board/column context + board_phid, error = self._validate_board_column_context(task_id, task_data, column, tag) + + if error: + validation_errors.append(f"T{task_id}: {error}") + + # Track for partition suggestions + if "multiple boards" in error: + boards = self._get_task_boards(task_data) + board_names = self._get_board_names(boards) + errors_by_boards[frozenset(board_names)].append(task_id) + + else: + validated_tasks.append({ + "task_id": task_id, + "task_data": task_data, + "board_phid": board_phid + }) + + except Exception as e: + validation_errors.append(f"T{task_id}: {e}") + + # If any validation errors, fail atomically + if validation_errors: + sys.stderr.write(f"ERROR: Validation failed for {len(validation_errors)} task(s):\n") + for error in validation_errors: + sys.stderr.write(f" - {error}\n") + + # Generate partition suggestions if applicable + if errors_by_boards: + suggestions = self._generate_partition_suggestions(errors_by_boards) + sys.stderr.write(suggestions) + sys.stderr.write("\n") + + sys.stderr.write("\nNo tasks were modified (atomic batch failure).\n") + return 1 + + # Phase 2: Process all validated tasks + success_count = 0 + for task in validated_tasks: + try: + self.maniphest.edit_task_by_id( + task_id=task["task_id"], + priority=priority, + status=status, + board_phid=task["board_phid"], + column=column, + assign=assign, + comment=comment, + dry_run=dry_run + ) + success_count += 1 + print(f"Successfully edited T{task['task_id']}") + + except Exception as e: + log.exception(f"Failed to edit task T{task['task_id']}") + sys.stderr.write(f"ERROR editing T{task['task_id']}: {e}\n") + # Continue processing other tasks + + print(f"\nEdited {success_count}/{len(validated_tasks)} tasks") + return 0 if success_count == len(validated_tasks) else 1 diff --git a/phabfive/maniphest.py b/phabfive/maniphest.py index 9fe2d1f..d117134 100644 --- a/phabfive/maniphest.py +++ b/phabfive/maniphest.py @@ -3658,6 +3658,231 @@ def create_task_cli( except Exception as e: raise PhabfiveRemoteException(f"Failed to create task: {e}") + def _get_task_data(self, task_id): + """Fetch task data with all necessary attachments for editing. + + Args: + task_id (str): Numeric task ID (e.g., "123") + + Returns: + dict: Task data from API with attachments + + Raises: + Exception: If task not found or API error + """ + result = self.phab.maniphest.search( + constraints={"ids": [int(task_id)]}, + attachments={"columns": True, "projects": True} + ) + + if not result["data"]: + raise ValueError(f"Task T{task_id} not found") + + return result["data"][0] + + def edit_task_by_id( + self, + task_id, + priority=None, + status=None, + board_phid=None, + column=None, + assign=None, + comment=None, + dry_run=False, + ): + """Edit a task by ID. + + Args: + task_id (str): Numeric task ID (e.g., "123") + priority (str): Priority to set or "raise"/"lower" + status (str): Status to set + board_phid (str): Board PHID for column context + column (str): Column name or "forward"/"backward" + assign (str): Username to assign + comment (str): Comment to add + dry_run (bool): Show changes without applying + + Raises: + Exception: On validation or API errors + """ + # Fetch current task state + task_data = self._get_task_data(task_id) + current_priority = task_data["fields"]["priority"]["value"] + current_status = task_data["fields"]["status"]["value"] + + transactions = [] + + # Handle priority + if priority: + if priority.lower() in ("raise", "lower"): + new_priority = self._navigate_priority(current_priority, priority.lower()) + else: + new_priority = self._validate_priority(priority) + + if new_priority != current_priority: + transactions.append({"type": "priority", "value": new_priority}) + + # Handle status + if status: + validated_status = self._validate_status(status) + if validated_status != current_status: + transactions.append({"type": "status", "value": validated_status}) + + # Handle column + if column and board_phid: + column_phid = self._navigate_column(task_id, task_data, column, board_phid) + if column_phid: + # Also need to add task to board if not already on it + task_projects = [p["phid"] for p in task_data["attachments"]["projects"]["projectPHIDs"]] + if board_phid not in task_projects: + transactions.append({"type": "projects.add", "value": [board_phid]}) + transactions.append({"type": "column", "value": [column_phid]}) + + # Handle assignee + if assign: + user_phid = self._resolve_user_phid(assign) + if not user_phid: + raise ValueError(f"User not found: {assign}") + current_owner = task_data["fields"]["ownerPHID"] + if user_phid != current_owner: + transactions.append({"type": "owner", "value": user_phid}) + + # Handle comment + if comment: + transactions.append({"type": "comment", "value": comment}) + + if not transactions: + log.info(f"No changes to apply for T{task_id}") + return + + if dry_run: + print(f"[DRY RUN] Would apply to T{task_id}:") + for txn in transactions: + print(f" - {txn['type']}: {txn['value']}") + return + + # Apply transactions + self.phab.maniphest.edit( + objectIdentifier=f"T{task_id}", transactions=transactions + ) + + def _navigate_priority(self, current_priority, direction): + """Navigate priority up or down, skipping Triage. + + Priority ladder (skipping Triage): + Wish(0) → Low(25) → Normal(50) → High(80) → Unbreak(100) + Triage(90) is excluded and can only be set explicitly. + + Args: + current_priority (int): Current priority value + direction (str): "raise" or "lower" + + Returns: + str: New priority key (e.g., "high") + """ + # Priority ladder excluding Triage + ladder = [ + (0, "wish"), + (25, "low"), + (50, "normal"), + (80, "high"), + (100, "unbreak"), + ] + + # Special handling for Triage (90) - it sits between High and Unbreak + # but is excluded from raise/lower navigation + if current_priority == 90: # Triage + if direction == "raise": + return "unbreak" # Skip directly to Unbreak + else: # lower + return "high" # Skip directly to High + + current_idx = None + for idx, (val, key) in enumerate(ladder): + if val == current_priority: + current_idx = idx + break + + if current_idx is None: + # Unknown priority, default to normal + return "normal" + + if direction == "raise": + new_idx = min(current_idx + 1, len(ladder) - 1) + else: # lower + new_idx = max(current_idx - 1, 0) + + return ladder[new_idx][1] + + def _navigate_column(self, task_id, task_data, column_name, board_phid): + """Navigate to a column by name or direction. + + Args: + task_id (str): Task ID for error messages + task_data (dict): Current task data + column_name (str): Column name or "forward"/"backward" + board_phid (str): Board PHID + + Returns: + str: Column PHID or None if no change + + Raises: + ValueError: If column not found or navigation invalid + """ + # Get column info for the board + column_info = self._get_column_info(board_phid) + + if column_name.lower() == "forward" or column_name.lower() == "backward": + # Get current column on this board + current_column_phid = None + boards = task_data.get("attachments", {}).get("columns", {}).get("boards", {}) + if board_phid in boards: + columns = boards[board_phid].get("columns", []) + if columns: + current_column_phid = columns[0]["phid"] + + if not current_column_phid: + raise ValueError( + f"Task T{task_id} is not currently in any column on this board" + ) + + # Find current position + current_info = column_info.get(current_column_phid) + if not current_info: + raise ValueError("Could not determine current column position") + + current_seq = current_info["sequence"] + + # Navigate by sorting columns by sequence + sorted_cols = sorted(column_info.items(), key=lambda x: x[1]["sequence"]) + + if column_name.lower() == "forward": + for col_phid, col_data in sorted_cols: + if col_data["sequence"] > current_seq: + return col_phid + # Already at end, stay + return current_column_phid + + else: # backward + for col_phid, col_data in reversed(sorted_cols): + if col_data["sequence"] < current_seq: + return col_phid + # Already at start, stay + return current_column_phid + + else: + # Exact column name - search by name + for col_phid, col_data in column_info.items(): + if col_data["name"].lower() == column_name.lower(): + return col_phid + + # Not found + available = [col_data["name"] for col_data in column_info.values()] + raise ValueError( + f"Column '{column_name}' not found on board. Available: {available}" + ) + def parse_time_with_unit(time_value): """ diff --git a/tests/test_edit.py b/tests/test_edit.py new file mode 100644 index 0000000..5f79dfa --- /dev/null +++ b/tests/test_edit.py @@ -0,0 +1,545 @@ +# -*- coding: utf-8 -*- + +"""Tests for phabfive edit command.""" + +# python std lib +from unittest import mock + +# 3rd party imports +import pytest + + +class TestMonogramDetection: + """Tests for monogram parsing and detection.""" + + def test_parse_task_monogram_uppercase(self): + """Test parsing T123 monogram.""" + from phabfive.edit import Edit + + edit_app = Edit() + obj_type, obj_id = edit_app._parse_monogram("T123") + assert obj_type == "task" + assert obj_id == "123" + + def test_parse_task_monogram_lowercase(self): + """Test parsing t456 monogram.""" + from phabfive.edit import Edit + + edit_app = Edit() + obj_type, obj_id = edit_app._parse_monogram("t456") + assert obj_type == "task" + assert obj_id == "456" + + def test_parse_task_monogram_from_url(self): + """Test extracting T789 from URL.""" + from phabfive.edit import Edit + + edit_app = Edit() + obj_type, obj_id = edit_app._parse_monogram("https://phorge.example.com/T789") + assert obj_type == "task" + assert obj_id == "789" + + def test_parse_passphrase_monogram(self): + """Test parsing K123 monogram.""" + from phabfive.edit import Edit + + edit_app = Edit() + obj_type, obj_id = edit_app._parse_monogram("K123") + assert obj_type == "passphrase" + assert obj_id == "123" + + def test_parse_paste_monogram(self): + """Test parsing P456 monogram.""" + from phabfive.edit import Edit + + edit_app = Edit() + obj_type, obj_id = edit_app._parse_monogram("P456") + assert obj_type == "paste" + assert obj_id == "456" + + def test_parse_invalid_monogram(self): + """Test parsing invalid monogram raises ValueError.""" + from phabfive.edit import Edit + + edit_app = Edit() + with pytest.raises(ValueError, match="No valid monogram found"): + edit_app._parse_monogram("invalid") + + +class TestPriorityNavigation: + """Tests for priority raise/lower navigation.""" + + def test_raise_from_wish_to_low(self): + """Test raising priority from Wish to Low.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(0, "raise") + assert result == "low" + + def test_raise_from_low_to_normal(self): + """Test raising priority from Low to Normal.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(25, "raise") + assert result == "normal" + + def test_raise_from_normal_to_high(self): + """Test raising priority from Normal to High.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(50, "raise") + assert result == "high" + + def test_raise_from_high_to_unbreak_skips_triage(self): + """Test raising from High skips Triage and goes to Unbreak.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(80, "raise") + assert result == "unbreak" + + def test_raise_from_unbreak_stays_at_unbreak(self): + """Test raising from Unbreak stays at Unbreak (edge case).""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(100, "raise") + assert result == "unbreak" + + def test_lower_from_unbreak_to_high_skips_triage(self): + """Test lowering from Unbreak skips Triage and goes to High.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(100, "lower") + assert result == "high" + + def test_lower_from_high_to_normal(self): + """Test lowering priority from High to Normal.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(80, "lower") + assert result == "normal" + + def test_lower_from_normal_to_low(self): + """Test lowering priority from Normal to Low.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(50, "lower") + assert result == "low" + + def test_lower_from_low_to_wish(self): + """Test lowering priority from Low to Wish.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(25, "lower") + assert result == "wish" + + def test_lower_from_wish_stays_at_wish(self): + """Test lowering from Wish stays at Wish (edge case).""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(0, "lower") + assert result == "wish" + + def test_raise_from_triage_goes_to_unbreak(self): + """Test raising from Triage skips to Unbreak.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(90, "raise") + assert result == "unbreak" + + def test_lower_from_triage_goes_to_high(self): + """Test lowering from Triage goes to High (Triage sits between High and Unbreak).""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + result = m._navigate_priority(90, "lower") + assert result == "high" + + +class TestColumnNavigation: + """Tests for column forward/backward navigation.""" + + def test_navigate_forward_to_next_column(self): + """Test navigating forward to next column.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + + # Mock column info + columns = { + "PHID-PCOL-1": {"name": "Backlog", "sequence": 0}, + "PHID-PCOL-2": {"name": "In Progress", "sequence": 1}, + "PHID-PCOL-3": {"name": "Done", "sequence": 2}, + } + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board": { + "columns": [{"phid": "PHID-PCOL-1"}] + } + } + } + } + } + + with mock.patch.object(m, '_get_column_info', return_value=columns): + result = m._navigate_column("123", task_data, "forward", "PHID-PROJ-board") + assert result == "PHID-PCOL-2" + + def test_navigate_backward_to_previous_column(self): + """Test navigating backward to previous column.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + + columns = { + "PHID-PCOL-1": {"name": "Backlog", "sequence": 0}, + "PHID-PCOL-2": {"name": "In Progress", "sequence": 1}, + "PHID-PCOL-3": {"name": "Done", "sequence": 2}, + } + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board": { + "columns": [{"phid": "PHID-PCOL-3"}] + } + } + } + } + } + + with mock.patch.object(m, '_get_column_info', return_value=columns): + result = m._navigate_column("123", task_data, "backward", "PHID-PROJ-board") + assert result == "PHID-PCOL-2" + + def test_navigate_forward_at_end_stays(self): + """Test navigating forward at end column stays in place.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + + columns = { + "PHID-PCOL-1": {"name": "Backlog", "sequence": 0}, + "PHID-PCOL-2": {"name": "Done", "sequence": 1}, + } + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board": { + "columns": [{"phid": "PHID-PCOL-2"}] + } + } + } + } + } + + with mock.patch.object(m, '_get_column_info', return_value=columns): + result = m._navigate_column("123", task_data, "forward", "PHID-PROJ-board") + assert result == "PHID-PCOL-2" + + def test_navigate_backward_at_start_stays(self): + """Test navigating backward at start column stays in place.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + + columns = { + "PHID-PCOL-1": {"name": "Backlog", "sequence": 0}, + "PHID-PCOL-2": {"name": "Done", "sequence": 1}, + } + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board": { + "columns": [{"phid": "PHID-PCOL-1"}] + } + } + } + } + } + + with mock.patch.object(m, '_get_column_info', return_value=columns): + result = m._navigate_column("123", task_data, "backward", "PHID-PROJ-board") + assert result == "PHID-PCOL-1" + + def test_navigate_by_exact_column_name(self): + """Test navigating to column by exact name.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + + columns = { + "PHID-PCOL-1": {"name": "Backlog", "sequence": 0}, + "PHID-PCOL-2": {"name": "In Progress", "sequence": 1}, + "PHID-PCOL-3": {"name": "Done", "sequence": 2}, + } + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board": { + "columns": [{"phid": "PHID-PCOL-1"}] + } + } + } + } + } + + with mock.patch.object(m, '_get_column_info', return_value=columns): + result = m._navigate_column("123", task_data, "Done", "PHID-PROJ-board") + assert result == "PHID-PCOL-3" + + def test_navigate_by_column_name_case_insensitive(self): + """Test column name matching is case-insensitive.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + + columns = { + "PHID-PCOL-1": {"name": "In Progress", "sequence": 0}, + } + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board": { + "columns": [{"phid": "PHID-PCOL-1"}] + } + } + } + } + } + + with mock.patch.object(m, '_get_column_info', return_value=columns): + result = m._navigate_column("123", task_data, "in progress", "PHID-PROJ-board") + assert result == "PHID-PCOL-1" + + def test_navigate_invalid_column_name_raises_error(self): + """Test navigating to non-existent column raises ValueError.""" + from phabfive.maniphest import Maniphest + + m = Maniphest() + + columns = { + "PHID-PCOL-1": {"name": "Backlog", "sequence": 0}, + } + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board": { + "columns": [{"phid": "PHID-PCOL-1"}] + } + } + } + } + } + + with mock.patch.object(m, '_get_column_info', return_value=columns): + with pytest.raises(ValueError, match="Column 'Invalid' not found"): + m._navigate_column("123", task_data, "Invalid", "PHID-PROJ-board") + + +class TestBoardColumnValidation: + """Tests for board/column validation logic.""" + + def test_single_board_auto_detect(self): + """Test auto-detection when task is on single board.""" + from phabfive.edit import Edit + + edit_app = Edit() + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board1": {"columns": [{"phid": "PHID-PCOL-1"}]} + } + } + } + } + + board_phid, error = edit_app._validate_board_column_context( + "123", task_data, "Done", None + ) + + assert board_phid == "PHID-PROJ-board1" + assert error is None + + def test_multiple_boards_without_tag_errors(self): + """Test error when task on multiple boards without --tag.""" + from phabfive.edit import Edit + + edit_app = Edit() + + task_data = { + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-board1": {"columns": [{"phid": "PHID-PCOL-1"}]}, + "PHID-PROJ-board2": {"columns": [{"phid": "PHID-PCOL-2"}]}, + } + } + } + } + + with mock.patch.object(edit_app, '_get_board_names', return_value=["Board1", "Board2"]): + board_phid, error = edit_app._validate_board_column_context( + "123", task_data, "Done", None + ) + + assert board_phid is None + assert "multiple boards" in error + assert "Board1" in error + assert "Board2" in error + + def test_no_column_arg_no_validation(self): + """Test no validation when --column not specified.""" + from phabfive.edit import Edit + + edit_app = Edit() + + task_data = {"attachments": {"columns": {"boards": {}}}} + + board_phid, error = edit_app._validate_board_column_context( + "123", task_data, None, None + ) + + assert board_phid is None + assert error is None + + +class TestStdinAutoDetection: + """Tests for stdin auto-detection.""" + + def test_stdin_piped_detected(self): + """Test piped stdin is auto-detected.""" + from phabfive.edit import Edit + + edit_app = Edit() + + # Mock stdin as not a TTY (piped) + with mock.patch('sys.stdin.isatty', return_value=False): + with mock.patch('sys.stdin', mock.MagicMock()): + with mock.patch.object(edit_app, '_parse_yaml_from_stdin', return_value=[]): + # Should try to read from stdin + result = edit_app.edit_objects() + # Returns error code 1 because no objects found + assert result == 1 + + def test_no_stdin_no_object_id_errors(self): + """Test error when no stdin and no object_id.""" + from phabfive.edit import Edit + + edit_app = Edit() + + # Mock stdin as TTY (not piped) + with mock.patch('sys.stdin.isatty', return_value=True): + result = edit_app.edit_objects() + assert result == 1 + + +class TestYAMLParsing: + """Tests for YAML parsing from stdin.""" + + def test_parse_single_task_from_yaml(self): + """Test parsing single task from YAML.""" + from phabfive.edit import Edit + from io import StringIO + + edit_app = Edit() + + yaml_data = """Link: https://example.com/T123 +Task: + Name: Test Task + Status: Open +""" + + with mock.patch('sys.stdin', StringIO(yaml_data)): + objects = edit_app._parse_yaml_from_stdin() + + assert len(objects) == 1 + assert objects[0]["object_type"] == "task" + assert objects[0]["object_id"] == "123" + + def test_parse_multiple_tasks_from_yaml(self): + """Test parsing multiple tasks from YAML stream.""" + from phabfive.edit import Edit + from io import StringIO + + edit_app = Edit() + + yaml_data = """Link: https://example.com/T123 +Task: + Name: Task 1 +--- +Link: https://example.com/T456 +Task: + Name: Task 2 +""" + + with mock.patch('sys.stdin', StringIO(yaml_data)): + objects = edit_app._parse_yaml_from_stdin() + + assert len(objects) == 2 + assert objects[0]["object_id"] == "123" + assert objects[1]["object_id"] == "456" + + def test_parse_yaml_missing_link_raises_error(self): + """Test parsing YAML without Link field raises error.""" + from phabfive.edit import Edit + from io import StringIO + + edit_app = Edit() + + yaml_data = """Task: + Name: Test Task +""" + + with mock.patch('sys.stdin', StringIO(yaml_data)): + with pytest.raises(ValueError, match="missing 'Link' field"): + edit_app._parse_yaml_from_stdin() + + +class TestGroupObjectsByType: + """Tests for grouping objects by type.""" + + def test_group_mixed_objects(self): + """Test grouping mixed object types.""" + from phabfive.edit import Edit + + edit_app = Edit() + + objects = [ + {"object_type": "task", "object_id": "123", "data": {}}, + {"object_type": "task", "object_id": "456", "data": {}}, + {"object_type": "passphrase", "object_id": "789", "data": {}}, + ] + + grouped = edit_app._group_objects_by_type(objects) + + assert "task" in grouped + assert "passphrase" in grouped + assert len(grouped["task"]) == 2 + assert len(grouped["passphrase"]) == 1