Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,11 @@ isq issue list --tree # Tree view with indentation
isq issue list --flat # Flat list including all sub-issues
isq issue list --children-of 42 # Children of issue #42

# Create, comment, close
# Create, edit, comment, close
isq issue create --title "Fix login bug"
isq issue edit 423 --title "Fix login bug (network timeout)"
isq issue edit 423 --body - < findings.md # Read body from stdin
isq issue update 423 --title "..." # Alias for edit
isq issue comment 423 "Fixed in abc123"
isq issue close 423

Expand Down Expand Up @@ -180,6 +183,7 @@ Cleared issue #891 association
| `isq issue list` | List issues (`--label`, `--state`, `--mine`, `--tree`, `--flat`, `--children-of`) |
| `isq issue show <id>` | Show issue details |
| `isq issue create --title "..."` | Create new issue |
| `isq issue edit <id> [--title] [--body] [--priority]` | Edit mutable fields (`update` alias) |
| `isq issue comment <id> "..."` | Add comment |
| `isq issue close <id>` | Close issue |
| `isq issue reopen <id>` | Reopen issue |
Expand All @@ -199,6 +203,11 @@ Cleared issue #891 association

Add `--json` to any command for machine-readable output.

`isq issue edit` notes:
- `--body -` reads from stdin (same pattern as create/comment piping)
- `--priority` uses `0=urgent, 1=high, 2=medium, 3=low, 4=none`
- Priority updates are supported on Linear; JIRA supports `0..3`; GitHub priority should be managed via labels

## Views (Saved Filters)

Create named filter combinations to avoid typing the same flags repeatedly:
Expand Down
586 changes: 0 additions & 586 deletions V1_TEST_PLAN.md

This file was deleted.

2 changes: 1 addition & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ isq close 423
```bash
isq issue list
isq issue show 423
isq issue edit 423 --title "Refined title"
isq issue close 423

# Future
Expand Down Expand Up @@ -348,4 +349,3 @@ Both supported. Number for current repo, full ref for cross-repo.
Don't pollute repo dirs. Don't pollute `~/` with dotfiles.

**Status:** ✅ Decided

23 changes: 23 additions & 0 deletions skills/isq/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,28 @@ isq issue create --title "Add feature" --body "Description here"
isq issue create --title "Bug" --label=bug
```

### Edit Issue Fields

```bash
isq issue edit 423 --title "Refined issue title"
isq issue edit 423 --body "Updated description"
cat summary.md | isq issue edit 423 --body -
isq issue edit 423 --priority 2
isq issue update 423 --title "..." # Alias for edit
```

Priority scale:
- `0` urgent
- `1` high
- `2` medium
- `3` low
- `4` none

Forge notes:
- Linear supports priority updates directly.
- JIRA supports priority updates for `0..3` (no explicit `none` equivalent).
- GitHub has no native issue priority update via API; use labels/config mapping.

### Comment on Issues

```bash
Expand Down Expand Up @@ -399,6 +421,7 @@ json = true # All commands output JSON by default
| `isq issue list --children-of 42` | Show only children of issue #42 |
| `isq issue show <id>` | Show issue details |
| `isq issue create --title "..."` | Create new issue |
| `isq issue edit <id> [--title] [--body] [--priority]` | Edit mutable issue fields (`update` alias) |
| `isq issue comment <id> "..."` | Add comment |
| `isq issue close <id>` | Close issue |
| `isq issue reopen <id>` | Reopen issue |
Expand Down
80 changes: 80 additions & 0 deletions src/cli/args/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@ Examples:
json: bool,
},

/// Edit issue fields (alias: update)
#[command(alias = "update")]
Edit {
/// Issue ID (e.g., 123 or DEV-123)
id: String,

/// New title
#[arg(long)]
title: Option<String>,

/// New body text (use "-" to read from stdin)
#[arg(long)]
body: Option<String>,

/// Priority: 0=urgent, 1=high, 2=medium, 3=low, 4=none
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=4))]
priority: Option<u8>,

/// Output as JSON
#[arg(long)]
json: bool,
},

/// Manage labels on an issue
Label {
/// Issue ID (e.g., 123 or DEV-123)
Expand Down Expand Up @@ -182,3 +205,60 @@ Examples:
json: bool,
},
}

#[cfg(test)]
mod tests {
use clap::Parser;

use crate::cli::args::{Cli, Commands, IssueCommands};

#[test]
fn parses_issue_edit_command() {
let cli = Cli::try_parse_from([
"isq",
"issue",
"edit",
"WRK-123",
"--title",
"Updated title",
"--priority",
"2",
])
.expect("expected issue edit command to parse");

let Some(Commands::Issue { command }) = cli.command else {
panic!("expected issue command");
};
let IssueCommands::Edit {
id,
title,
body,
priority,
..
} = command
else {
panic!("expected edit subcommand");
};

assert_eq!(id, "WRK-123");
assert_eq!(title, Some("Updated title".to_string()));
assert_eq!(body, None);
assert_eq!(priority, Some(2));
}

#[test]
fn parses_issue_update_alias_as_edit() {
let cli = Cli::try_parse_from(["isq", "issue", "update", "123", "--body", "-"])
.expect("expected issue update alias to parse");

let Some(Commands::Issue { command }) = cli.command else {
panic!("expected issue command");
};
let IssueCommands::Edit { id, body, .. } = command else {
panic!("expected edit subcommand");
};

assert_eq!(id, "123");
assert_eq!(body, Some("-".to_string()));
}
}
4 changes: 3 additions & 1 deletion src/cli/issues/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ use crate::repo;

// Re-export all public commands
pub use list::cmd_list;
pub use write_ops::{cmd_assign, cmd_close, cmd_comment, cmd_create, cmd_label, cmd_reopen};
pub use write_ops::{
cmd_assign, cmd_close, cmd_comment, cmd_create, cmd_edit, cmd_label, cmd_reopen,
};

pub fn cmd_show(id: &str, json_output: bool) -> Result<()> {
// Apply json default from user config (CLI flag overrides)
Expand Down
146 changes: 146 additions & 0 deletions src/cli/issues/write_ops/edit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Issue edit command

use std::time::Instant;

use anyhow::Result;

use crate::db;
use crate::display;
use crate::forges::{UpdateIssueRequest, get_forge_for_repo};
use crate::repo;

use crate::cli::utils::{
WriteResult, is_offline_error, parse_forge_repo, read_stdin_if_piped,
validate_jira_issue_prefix,
};

pub async fn cmd_edit(
id: &str,
title: Option<String>,
body: Option<String>,
priority: Option<u8>,
json: bool,
cli_quiet: bool,
) -> Result<()> {
// Apply json default from user config (CLI flag overrides)
let json = crate::user_config::resolve_json_default(json)?;
// Resolve quiet setting (CLI flag overrides config)
let quiet = crate::user_config::resolve_quiet_default(cli_quiet)?;

let body = resolve_body_input(body)?;
validate_update_fields(&title, &body, priority)?;

let start = Instant::now();

let repo_path = repo::detect_repo_path()?;
let (forge, link) = get_forge_for_repo(&repo_path)?;

let repo_struct = parse_forge_repo(&link.forge_repo)?;
validate_jira_issue_prefix(id, &repo_struct.name, &link.forge_type)?;

let issue_display = display::format_issue_id(id);
let req = UpdateIssueRequest {
title: title.clone(),
body: body.clone(),
priority,
};

match forge.update_issue(&repo_struct, id, req).await {
Ok(()) => {
let elapsed = start.elapsed();
if json {
let result = WriteResult {
success: true,
queued: false,
issue_id: Some(id.to_string()),
message: format!("Updated {}", issue_display),
elapsed_ms: elapsed.as_millis() as u64,
};
println!("{}", serde_json::to_string_pretty(&result)?);
} else if !quiet {
println!("✓ Updated {} ({:.0}ms)", issue_display, elapsed.as_millis());
}
}
Err(e) if is_offline_error(&e) => {
let elapsed = start.elapsed();
let payload = serde_json::json!({
"issue_id": id,
"title": title,
"body": body,
"priority": priority,
});
let conn = db::open()?;
db::queue_op(&conn, &link.forge_repo, "edit", &payload.to_string())?;
if json {
let result = WriteResult {
success: true,
queued: true,
issue_id: Some(id.to_string()),
message: format!("Queued: edit {}", issue_display),
elapsed_ms: elapsed.as_millis() as u64,
};
println!("{}", serde_json::to_string_pretty(&result)?);
} else if !quiet {
println!(
"✓ Queued: edit {} (offline, {:.0}ms)",
issue_display,
elapsed.as_millis()
);
}
}
Err(e) => return Err(e),
}

Ok(())
}

fn resolve_body_input(body: Option<String>) -> Result<Option<String>> {
match body {
Some(value) if value == "-" => {
let piped = read_stdin_if_piped()?.ok_or_else(|| {
anyhow::anyhow!(
"--body - requires stdin input.\n\
Usage: echo \"text\" | isq issue edit <ID> --body -"
)
})?;
Ok(Some(piped))
}
Some(value) => Ok(Some(value)),
None => Ok(None),
}
}

fn validate_update_fields(
title: &Option<String>,
body: &Option<String>,
priority: Option<u8>,
) -> Result<()> {
if title.is_none() && body.is_none() && priority.is_none() {
anyhow::bail!("No fields to edit. Provide at least one of --title, --body, or --priority.");
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::{resolve_body_input, validate_update_fields};

#[test]
fn validate_update_fields_requires_at_least_one_field() {
let err = validate_update_fields(&None, &None, None).unwrap_err();
assert!(err.to_string().contains("No fields to edit"));
}

#[test]
fn validate_update_fields_accepts_any_field() {
assert!(validate_update_fields(&Some("x".to_string()), &None, None).is_ok());
assert!(validate_update_fields(&None, &Some("x".to_string()), None).is_ok());
assert!(validate_update_fields(&None, &None, Some(2)).is_ok());
}

#[test]
fn resolve_body_input_keeps_literal_body() {
let body = resolve_body_input(Some("hello".to_string())).expect("body should parse");
assert_eq!(body, Some("hello".to_string()));
}
}
4 changes: 3 additions & 1 deletion src/cli/issues/write_ops/mod.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
//! Issue write operation commands (create, comment, close, reopen, label, assign)
//! Issue write operation commands (create, edit, comment, close, reopen, label, assign)

mod assign;
mod comment;
mod create;
mod edit;
mod labels;
mod status;

// Re-export public commands
pub use assign::cmd_assign;
pub use comment::cmd_comment;
pub use create::cmd_create;
pub use edit::cmd_edit;
pub use labels::cmd_label;
pub use status::{cmd_close, cmd_reopen};
20 changes: 19 additions & 1 deletion src/daemon/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::collections::HashMap;
use tracing::{debug, info, warn};

use crate::db;
use crate::forges::{CreateIssueRequest, Forge};
use crate::forges::{CreateIssueRequest, Forge, UpdateIssueRequest};
use crate::repo::Repo;

/// Classification of operation errors for conflict resolution.
Expand Down Expand Up @@ -91,6 +91,7 @@ pub fn classify_error(err: &anyhow::Error) -> Option<ConflictKind> {
if err_str.contains("failed to create comment")
|| err_str.contains("failed to close issue")
|| err_str.contains("failed to reopen issue")
|| err_str.contains("failed to update issue")
|| err_str.contains("failed to add label")
|| err_str.contains("failed to remove label")
|| err_str.contains("failed to assign issue")
Expand Down Expand Up @@ -209,6 +210,23 @@ async fn execute_pending_op(forge: &dyn Forge, repo: &Repo, op: &db::PendingOp)
forge.reopen_issue(repo, &issue_id).await?;
info!(issue_id = %issue_id, "Reopened issue");
}
"edit" => {
let issue_id = payload["issue_id"]
.as_str()
.map(|s| s.to_string())
.or_else(|| payload["issue_number"].as_u64().map(|n| n.to_string()))
.unwrap_or_default();
let req = UpdateIssueRequest {
title: payload["title"].as_str().map(|s| s.to_string()),
body: payload["body"].as_str().map(|s| s.to_string()),
priority: payload["priority"]
.as_u64()
.and_then(|p| u8::try_from(p).ok())
.filter(|p| *p <= 4),
};
forge.update_issue(repo, &issue_id, req).await?;
info!(issue_id = %issue_id, "Updated issue");
}
"label_add" => {
let issue_id = payload["issue_id"]
.as_str()
Expand Down
1 change: 1 addition & 0 deletions src/daemon/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ fn test_classify_error_linear_mutation_failures() {
"Failed to create comment",
"Failed to close issue",
"Failed to reopen issue",
"Failed to update issue",
"Failed to add label",
"Failed to remove label",
"Failed to assign issue",
Expand Down
Loading