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
46 changes: 33 additions & 13 deletions devflow/cli/commands/link_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Implementation of 'daf link' command."""

import logging
import subprocess
import sys
from typing import Optional
Expand All @@ -14,6 +15,7 @@
from devflow.session.manager import SessionManager

console = Console()
logger = logging.getLogger(__name__)


def _fetch_issue_metadata_dict(issue_key: str) -> Optional[dict]:
Expand Down Expand Up @@ -48,18 +50,19 @@ def _fetch_issue_metadata_dict(issue_key: str) -> Optional[dict]:
raise RuntimeError(f"Timeout validating issue tracker ticket {issue_key}")


@require_outside_claude
def link_jira(
name: str,
issue_key: str,
force: bool = False,
rename_prefix: Optional[str] = None,
) -> None:
"""Link a issue tracker ticket to a session group.

Args:
name: Session group name
issue_key: issue tracker key to link
force: Skip confirmation prompts
rename_prefix: If provided, rename the session to {prefix}-{issue_key_slug}
"""
config_loader = ConfigLoader()
session_manager = SessionManager(config_loader)
Expand Down Expand Up @@ -125,23 +128,40 @@ def link_jira(

session_manager.update_session(session)

# Rename session if --rename-prefix provided
renamed_to = None
if rename_prefix:
from devflow.cli.commands.sync_command import issue_key_to_session_name
base_slug = issue_key_to_session_name(issue_key)
new_name = f"{rename_prefix}-{base_slug}"

try:
session_manager.rename_session(name, new_name)
renamed_to = new_name
console_print(f"[green]✓[/green] Renamed session to: [bold]{new_name}[/bold]")
except ValueError as e:
console_print(f"[yellow]⚠[/yellow] Could not rename session: {e}")
logger.warning(f"Rename failed for session '{name}' to '{new_name}': {e}")

display_name = renamed_to or name

if is_json_mode():
output_json(
success=True,
data={
"session_group": name,
"issue_key": issue_key,
"sessions_updated": len(sessions),
"replaced": existing_jira,
"metadata": issue_metadata_dict
}
)
data = {
"session_group": display_name,
"issue_key": issue_key,
"sessions_updated": len(sessions),
"replaced": existing_jira,
"metadata": issue_metadata_dict,
}
if renamed_to:
data["renamed_from"] = name
output_json(success=True, data=data)
else:
console.print(f"[green]✓[/green] Linked session group '{name}' to {issue_key}")
console.print(f"[green]✓[/green] Linked session group '{display_name}' to {issue_key}")
console.print(f"[dim]All {len(sessions)} session(s) in group now associated with {issue_key}[/dim]")
console.print()
console.print(f"[dim]You can now use either identifier:[/dim]")
console.print(f" daf open {name}")
console.print(f" daf open {display_name}")
console.print(f" daf open {issue_key}")


Expand Down
5 changes: 3 additions & 2 deletions devflow/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1309,16 +1309,17 @@ def import_session_cmd(ctx: click.Context, uuid: str, jira: str, goal: str, goal
@click.argument("name", shell_complete=complete_session_identifiers)
@click.option("--jira", "issue_key", required=True, help="issue tracker key to link")
@click.option("--force", is_flag=True, help="Skip confirmation prompts (auto-replace existing links)")
@click.option("--rename-prefix", help="Rename session to {prefix}-{issue_key_slug} after linking")
@json_option
def link(ctx: click.Context, name: str, issue_key: str, force: bool) -> None:
def link(ctx: click.Context, name: str, issue_key: str, force: bool, rename_prefix: str) -> None:
"""Link a issue tracker ticket to a session group.

Associates a issue tracker ticket with all sessions in the specified session group.
After linking, you can use either the session name or issue key to access the sessions.
"""
from devflow.cli.commands.link_command import link_jira

link_jira(name, issue_key, force)
link_jira(name, issue_key, force, rename_prefix=rename_prefix)


@cli.command()
Expand Down
157 changes: 157 additions & 0 deletions tests/test_link_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,3 +550,160 @@ def test_link_json_error_for_invalid_jira(mock_jira_cli, temp_daf_home):
assert output_data["success"] is False
assert "error" in output_data
assert output_data["error"]["code"] == "VALIDATION_ERROR"


def test_link_with_rename_prefix_jira_key(mock_jira_cli, temp_daf_home):
"""Test --rename-prefix renames session using JIRA-style issue key."""
mock_jira_cli.set_ticket("PROJ-456", {
"key": "PROJ-456",
"fields": {"summary": "Add caching", "status": {"name": "New"}}
})

runner = CliRunner()

# Create a session
result = runner.invoke(cli, [
"new", "--name", "my-goal-abc123", "--goal", "Add caching",
"--path", str(temp_daf_home / "test-project")
], input="n\n")
assert result.exit_code == 0

# Link with --rename-prefix
result = runner.invoke(cli, [
"link", "my-goal-abc123", "--jira", "PROJ-456", "--rename-prefix", "creation"
])
assert result.exit_code == 0
assert "creation-PROJ-456" in result.output

# Verify: session accessible by new name
config_loader = ConfigLoader()
sessions = config_loader.load_sessions().get_sessions("creation-PROJ-456")
assert sessions is not None
assert len(sessions) > 0
assert sessions[0].issue_key == "PROJ-456"

# Verify: old name no longer exists
old_sessions = config_loader.load_sessions().get_sessions("my-goal-abc123")
assert len(old_sessions) == 0


def test_link_rename_prefix_slug_generation():
"""Test that issue_key_to_session_name produces correct slugs for rename-prefix usage."""
from devflow.cli.commands.sync_command import issue_key_to_session_name

# JIRA-style keys
assert issue_key_to_session_name("PROJ-456") == "PROJ-456"

# GitHub-style keys (owner/repo#123)
assert issue_key_to_session_name("itdove/devaiflow#456") == "itdove-devaiflow-456"

# Enterprise hostname keys
assert issue_key_to_session_name("itdove/devaiflow#60", "github.enterprise.com") == "github-enterprise-com-itdove-devaiflow-60"

# Default hostnames omit hostname
assert issue_key_to_session_name("itdove/devaiflow#60", "github.com") == "itdove-devaiflow-60"


def test_link_with_rename_prefix_conflict(mock_jira_cli, temp_daf_home):
"""Test --rename-prefix warns but doesn't fail when target name exists."""
mock_jira_cli.set_ticket("PROJ-111", {
"key": "PROJ-111",
"fields": {"summary": "First", "status": {"name": "New"}}
})
mock_jira_cli.set_ticket("PROJ-222", {
"key": "PROJ-222",
"fields": {"summary": "Second", "status": {"name": "New"}}
})

runner = CliRunner()

# Create two sessions — one with the name that the rename would produce
result = runner.invoke(cli, [
"new", "--name", "creation-PROJ-222", "--goal", "Existing",
"--path", str(temp_daf_home / "project1")
], input="n\n")
assert result.exit_code == 0

result = runner.invoke(cli, [
"new", "--name", "my-session", "--goal", "New work",
"--path", str(temp_daf_home / "project2")
], input="n\n")
assert result.exit_code == 0

# Link with --rename-prefix — target name already exists
result = runner.invoke(cli, [
"link", "my-session", "--jira", "PROJ-222", "--rename-prefix", "creation"
])

# Should succeed (link works) but warn about rename failure
assert result.exit_code == 0
assert "Could not rename" in result.output or "already exists" in result.output

# Verify: session is still linked (even though rename failed)
config_loader = ConfigLoader()
sessions = config_loader.load_sessions().get_sessions("my-session")
assert len(sessions) > 0
assert sessions[0].issue_key == "PROJ-222"


def test_link_works_inside_claude_session(mock_jira_cli, temp_daf_home, monkeypatch):
"""Test that link_jira works when called from inside a Claude session (no @require_outside_claude)."""
mock_jira_cli.set_ticket("PROJ-789", {
"key": "PROJ-789",
"fields": {"summary": "Inside session", "status": {"name": "New"}}
})

runner = CliRunner()

# Create a session
result = runner.invoke(cli, [
"new", "--name", "in-agent-test", "--goal", "Test inside agent",
"--path", str(temp_daf_home / "test-project")
], input="n\n")
assert result.exit_code == 0

# Simulate being inside a Claude session
monkeypatch.setenv("DEVAIFLOW_IN_SESSION", "in-agent-test")

# Link should work (not be blocked by require_outside_claude)
result = runner.invoke(cli, [
"link", "in-agent-test", "--jira", "PROJ-789"
])
assert result.exit_code == 0
assert "Linked" in result.output or "PROJ-789" in result.output

# Verify: session is linked
config_loader = ConfigLoader()
sessions = config_loader.load_sessions().get_sessions("in-agent-test")
assert len(sessions) > 0
assert sessions[0].issue_key == "PROJ-789"


def test_link_with_rename_prefix_json_output(mock_jira_cli, temp_daf_home):
"""Test --rename-prefix includes renamed_from in JSON output."""
mock_jira_cli.set_ticket("PROJ-999", {
"key": "PROJ-999",
"fields": {"summary": "JSON test", "status": {"name": "New"}}
})

runner = CliRunner()

# Create a session
result = runner.invoke(cli, [
"new", "--name", "json-rename-test", "--goal", "Test JSON",
"--path", str(temp_daf_home / "test-project")
], input="n\n")
assert result.exit_code == 0

# Link with --rename-prefix and --json
result = runner.invoke(cli, [
"link", "json-rename-test", "--jira", "PROJ-999",
"--rename-prefix", "creation", "--json"
])
assert result.exit_code == 0

output_data = json.loads(result.output)
assert output_data["success"] is True
assert output_data["data"]["session_group"] == "creation-PROJ-999"
assert output_data["data"]["renamed_from"] == "json-rename-test"
assert output_data["data"]["issue_key"] == "PROJ-999"
Loading