ticket2pr 0.3.0__tar.gz → 0.3.2__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.
Files changed (43) hide show
  1. {ticket2pr-0.3.0/ticket2pr.egg-info → ticket2pr-0.3.2}/PKG-INFO +1 -1
  2. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/pyproject.toml +1 -1
  3. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/setup.py +1 -1
  4. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/agents/base.py +26 -16
  5. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/agents/pre_commit_fixer.py +11 -4
  6. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/agents/ticket_solver.py +5 -6
  7. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/branch_creator.py +19 -1
  8. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/cli.py +75 -24
  9. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/clients/github_client.py +4 -0
  10. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/enhanced_git.py +32 -2
  11. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/exceptions.py +5 -0
  12. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/settings.py +1 -1
  13. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/settings_init.py +3 -16
  14. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/workflow.py +13 -3
  15. {ticket2pr-0.3.0 → ticket2pr-0.3.2/ticket2pr.egg-info}/PKG-INFO +1 -1
  16. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/LICENSE +0 -0
  17. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/README.md +0 -0
  18. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/setup.cfg +0 -0
  19. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/__init__.py +0 -0
  20. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/agents/__init__.py +0 -0
  21. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/agents/commit_message.py +0 -0
  22. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/agents/pr_generator.py +0 -0
  23. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/clients/__init__.py +0 -0
  24. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/clients/jira_client.py +0 -0
  25. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/console_utils.py +0 -0
  26. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/logging_setup.py +0 -0
  27. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/main.py +0 -0
  28. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/pr_content.py +0 -0
  29. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/shell/__init__.py +0 -0
  30. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/shell/base.py +0 -0
  31. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/shell/pre_commit_runner.py +0 -0
  32. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/src/validators.py +0 -0
  33. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/tests/__init__.py +0 -0
  34. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/tests/integration/__init__.py +0 -0
  35. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/tests/integration/test_find_first_toml.py +0 -0
  36. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/tests/unit/__init__.py +0 -0
  37. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/tests/unit/test_nothing.py +0 -0
  38. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/ticket2pr.egg-info/SOURCES.txt +0 -0
  39. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/ticket2pr.egg-info/dependency_links.txt +0 -0
  40. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/ticket2pr.egg-info/entry_points.txt +0 -0
  41. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/ticket2pr.egg-info/not-zip-safe +0 -0
  42. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/ticket2pr.egg-info/requires.txt +0 -0
  43. {ticket2pr-0.3.0 → ticket2pr-0.3.2}/ticket2pr.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ticket2pr
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Automate Jira ticket to GitHub PR workflow
5
5
  Home-page: https://github.com/bengabay11/ticket2pr
6
6
  Author: Ben Gabay
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ticket2pr"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Automate Jira ticket to GitHub PR workflow"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -5,7 +5,7 @@ with open("README.md", encoding="utf-8") as f:
5
5
 
6
6
  setup(
7
7
  name="ticket2pr",
8
- version="0.3.0",
8
+ version="0.3.2",
9
9
  author="Ben Gabay",
10
10
  author_email="ben.gabay38@gmail.com",
11
11
  description="Automate Jira ticket to GitHub PR workflow",
@@ -1,3 +1,4 @@
1
+ from collections.abc import AsyncGenerator
1
2
  from pathlib import Path
2
3
 
3
4
  from claude_agent_sdk import (
@@ -5,6 +6,7 @@ from claude_agent_sdk import (
5
6
  ClaudeAgentOptions,
6
7
  ContentBlock,
7
8
  Message,
9
+ PermissionMode,
8
10
  ResultMessage,
9
11
  SystemMessage,
10
12
  TextBlock,
@@ -18,6 +20,18 @@ from claude_agent_sdk import (
18
20
  from src.exceptions import AgentQueryUnknownError
19
21
 
20
22
 
23
+ def extract_session_id(message: Message) -> str | None:
24
+ """
25
+ Extract session_id from an init message.
26
+
27
+ Returns the session_id if the message is an init SystemMessage, None otherwise.
28
+ """
29
+ if isinstance(message, SystemMessage) and message.subtype == "init":
30
+ session_id: str | None = message.data.get("session_id")
31
+ return session_id
32
+ return None
33
+
34
+
21
35
  def _format_content_blocks(content: list[ContentBlock]) -> list[str]:
22
36
  output_parts = []
23
37
 
@@ -59,10 +73,12 @@ def _format_content_blocks(content: list[ContentBlock]) -> list[str]:
59
73
 
60
74
  elif tool_name == "Bash":
61
75
  command = tool_input.get("command", "unknown")
62
- # Truncate long commands
63
- if len(command) > 80:
64
- command = command[:77] + "..."
65
- output_parts.append(f"💻 Running: {command}")
76
+ description = tool_input.get("description")
77
+ if description:
78
+ output_parts.append(f"💻 Bash: {description}")
79
+ output_parts.append(f" {command}")
80
+ else:
81
+ output_parts.append(f"💻 Bash: {command}")
66
82
 
67
83
  else:
68
84
  output_parts.append(f"🔧 Using tool: {tool_name}")
@@ -126,11 +142,11 @@ async def run_agent_query(
126
142
  prompt: str,
127
143
  system_prompt: str,
128
144
  allowed_tools: list[str],
129
- permission_mode: str | None = None,
145
+ permission_mode: PermissionMode = "bypassPermissions",
130
146
  cwd: Path | None = None,
131
147
  mcp_config_path: Path | None = None,
132
148
  session_id: str | None = None,
133
- ) -> Message:
149
+ ) -> AsyncGenerator[Message]:
134
150
  """
135
151
  Execute a Claude Agent SDK query with standardized message handling.
136
152
 
@@ -141,34 +157,28 @@ async def run_agent_query(
141
157
  prompt: The user prompt to send to the agent
142
158
  system_prompt: The system prompt defining the agent's role and behavior
143
159
  allowed_tools: List of tool names the agent is allowed to use
144
- permission_mode: Optional permission mode (e.g., "acceptEdits").
145
- If None, uses default permission handling.
160
+ permission_mode: Permission mode for the agent. Defaults to bypassPermissions
161
+ to allow full access without prompts.
146
162
  cwd: Optional current working directory for the agent to run from.
147
163
  mcp_config_path: Optional path to mcp.json configuration file for MCP servers.
148
164
  """
149
165
  options_kwargs = {
150
166
  "allowed_tools": allowed_tools,
151
167
  "system_prompt": system_prompt,
168
+ "permission_mode": permission_mode,
152
169
  }
153
- if permission_mode is not None:
154
- options_kwargs["permission_mode"] = permission_mode
155
170
 
156
171
  if cwd is not None:
157
172
  options_kwargs["cwd"] = str(cwd)
158
173
 
159
174
  if mcp_config_path is not None:
160
- options_kwargs["mcp_config_path"] = str(mcp_config_path)
175
+ options_kwargs["mcp_servers"] = mcp_config_path
161
176
 
162
177
  if session_id is not None:
163
178
  options_kwargs["resume"] = session_id
164
-
165
179
  options = ClaudeAgentOptions(**options_kwargs)
166
180
  try:
167
181
  async for message in query(prompt=prompt, options=options):
168
- # The first message is a system init message with the session ID
169
- if hasattr(message, "subtype") and message.subtype == "init":
170
- session_id = message.data.get("session_id")
171
- yield session_id
172
182
  yield message
173
183
  except Exception as e:
174
184
  raise AgentQueryUnknownError(str(e)) from e
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
 
3
- from src.agents.base import print_agent_message, run_agent_query
3
+ from src.agents.base import extract_session_id, print_agent_message, run_agent_query
4
4
  from src.shell.pre_commit_runner import run_pre_commit
5
5
 
6
6
  SYSTEM_PROMPT = """
@@ -21,6 +21,7 @@ CRITICAL RULES:
21
21
  type checking)
22
22
  - Apply fixes to the files that failed the pre-commit checks
23
23
  - Be precise and minimal - only change what's necessary to pass the hooks
24
+ - NEVER run pre-commit with --all-files flag. ONLY run on staged files.
24
25
 
25
26
  WORKFLOW:
26
27
  1. Read the error messages carefully to understand what needs to be fixed
@@ -31,6 +32,7 @@ black expects)
31
32
  you modified
32
33
  5. Only stage the files you actually fixed - do not stage unrelated changes
33
34
  6. After staging fixes, run `pre-commit run` again to verify your fixes worked
35
+ (NEVER use --all-files)
34
36
  7. If pre-commit still fails, analyze the new errors and fix them
35
37
  8. Retry this fix-and-verify cycle up to {max_retries} times total
36
38
  9. If pre-commit passes, you're done - stop retrying
@@ -47,17 +49,17 @@ async def verify_pre_commit_and_fix(
47
49
  workspace_path: Path, max_retries: int = 5, mcp_config_path: Path | None = None
48
50
  ) -> bool:
49
51
  """
50
- Verify pre-commit hooks pass, fixing issues if needed.
52
+ Verify pre-commit hooks pass on STAGED FILES ONLY, fixing issues if needed.
51
53
 
52
54
  This function:
53
- 1. Runs pre-commit hooks on staged files
55
+ 1. Runs pre-commit hooks on staged files ONLY (never --all-files)
54
56
  2. If successful, returns True
55
57
  3. If failed, uses AI to fix the issues (AI will stage its own fixes and retry)
56
58
 
57
59
  Args:
58
- git: EnhancedGit instance for staging changes
59
60
  workspace_path: Path to the workspace root
60
61
  max_retries: Maximum number of retry attempts to tell the AI (default: 5)
62
+ mcp_config_path: Optional path to MCP config file
61
63
 
62
64
  Returns:
63
65
  True if pre-commit passes, False otherwise
@@ -73,6 +75,7 @@ async def verify_pre_commit_and_fix(
73
75
  pre_commit_output=result.output,
74
76
  )
75
77
 
78
+ session_id = None
76
79
  async for message in run_agent_query(
77
80
  prompt=prompt,
78
81
  system_prompt=system_prompt,
@@ -81,6 +84,10 @@ async def verify_pre_commit_and_fix(
81
84
  cwd=workspace_path,
82
85
  mcp_config_path=mcp_config_path,
83
86
  ):
87
+ if session_id is None:
88
+ session_id = extract_session_id(message)
89
+ if session_id:
90
+ continue
84
91
  print_agent_message(message)
85
92
 
86
93
  final_result = run_pre_commit(workspace_path)
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from pathlib import Path
3
3
 
4
- from src.agents.base import print_agent_message, run_agent_query
4
+ from src.agents.base import extract_session_id, print_agent_message, run_agent_query
5
5
  from src.clients.jira_client import JiraIssue
6
6
  from src.exceptions import PlanNotFoundError
7
7
 
@@ -112,11 +112,11 @@ async def plan_ticket(
112
112
  cwd=workspace_path,
113
113
  mcp_config_path=mcp_config_path,
114
114
  ):
115
- # First message is the session ID
116
115
  if session_id is None:
117
- session_id = message
118
- else:
119
- print_agent_message(message)
116
+ session_id = extract_session_id(message)
117
+ if session_id:
118
+ continue
119
+ print_agent_message(message)
120
120
 
121
121
  if not plan_path.exists():
122
122
  raise PlanNotFoundError(plan_path)
@@ -153,7 +153,6 @@ async def execute_plan(
153
153
  prompt=execution_prompt,
154
154
  system_prompt=EXECUTION_PHASE_SYSTEM_PROMPT,
155
155
  allowed_tools=["Glob", "Bash", "Read", "Grep", "Write"], # Full access
156
- permission_mode="acceptEdits", # Auto-approve edits without asking
157
156
  cwd=workspace_path,
158
157
  mcp_config_path=mcp_config_path,
159
158
  session_id=session_id,
@@ -1,7 +1,12 @@
1
+ import logging
1
2
  import re
3
+ from datetime import datetime
2
4
 
3
5
  from src.clients.github_client import GitHubClient
4
6
  from src.clients.jira_client import JiraClient, JiraIssue
7
+ from src.exceptions import GithubBranchAlreadyExistsError
8
+
9
+ logger = logging.getLogger(__name__)
5
10
 
6
11
 
7
12
  def sanitize_branch_name(name: str, max_length: int = 100) -> str:
@@ -42,7 +47,20 @@ def create_branch_from_jira_issue(
42
47
  branch_name = generate_branch_name(jira_issue.key, jira_issue.summary, jira_issue.type)
43
48
  # TODO: Consider replacing the 2 next lines with local git client
44
49
  base_ref = github_client.get_base_branch_ref(base_branch)
45
- branch_url = github_client.create_branch(branch_name, base_ref)
50
+
51
+ try:
52
+ branch_url = github_client.create_branch(branch_name, base_ref)
53
+ except GithubBranchAlreadyExistsError:
54
+ # Branch already exists, add timestamp suffix and retry
55
+ original_branch_name = branch_name
56
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
57
+ branch_name = f"{branch_name}-{timestamp}"
58
+ logger.info(
59
+ "Branch '%s' already exists. Retrying with new name: '%s'",
60
+ original_branch_name,
61
+ branch_name,
62
+ )
63
+ branch_url = github_client.create_branch(branch_name, base_ref)
46
64
 
47
65
  jira_client.link_branch(jira_issue.key, branch_url, branch_name)
48
66
 
@@ -1,7 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import logging
5
+ import shutil
4
6
  import sys
7
+ import tempfile
8
+ from collections.abc import Generator
9
+ from contextlib import contextmanager
5
10
  from pathlib import Path
6
11
  from typing import TYPE_CHECKING
7
12
 
@@ -24,6 +29,8 @@ from src.enhanced_git import EnhancedGit
24
29
  from src.logging_setup import LoggerHandlerType, SetupLoggerParams, setup_logger
25
30
  from src.settings import AppSettings
26
31
 
32
+ logger = logging.getLogger(__name__)
33
+
27
34
  if TYPE_CHECKING:
28
35
  from src.clients.github_client import GitHubClient
29
36
  from src.clients.jira_client import JiraClient
@@ -48,10 +55,9 @@ def _load_settings() -> AppSettings:
48
55
  sys.exit(1)
49
56
 
50
57
 
51
- def _initialize_clients(settings: AppSettings) -> tuple[GitHubClient, JiraClient, EnhancedGit]:
58
+ def _initialize_clients(settings: AppSettings) -> tuple[GitHubClient, JiraClient]:
52
59
  from src.clients.github_client import GitHubClient
53
60
  from src.clients.jira_client import JiraClient
54
- from src.enhanced_git import EnhancedGit
55
61
 
56
62
  github_client = GitHubClient(
57
63
  github_token=settings.github.api_token,
@@ -62,8 +68,7 @@ def _initialize_clients(settings: AppSettings) -> tuple[GitHubClient, JiraClient
62
68
  username=settings.jira.username,
63
69
  password=settings.jira.api_token,
64
70
  )
65
- git = EnhancedGit(settings.core.workspace_path)
66
- return github_client, jira_client, git
71
+ return github_client, jira_client
67
72
 
68
73
 
69
74
  def _init() -> None:
@@ -74,6 +79,40 @@ def _init() -> None:
74
79
  initialize_settings(config_path)
75
80
 
76
81
 
82
+ @contextmanager
83
+ def _setup_workspace(
84
+ workspace_path_arg: Path | None,
85
+ workspace_path_settings: Path | None,
86
+ github_client: GitHubClient,
87
+ ) -> Generator[tuple[EnhancedGit, Path]]:
88
+ """
89
+ Set up the workspace for the workflow.
90
+
91
+ If no workspace_path is provided (neither arg nor settings), clones the repository
92
+ to a temp directory and cleans it up when done.
93
+
94
+ Yields:
95
+ A tuple of (EnhancedGit instance, workspace_path)
96
+ """
97
+ workspace_path = workspace_path_arg or workspace_path_settings
98
+ temp_dir: Path | None = None
99
+ try:
100
+ if workspace_path is None:
101
+ temp_dir = Path(tempfile.mkdtemp(prefix="ticket2pr_"))
102
+ logger.info(
103
+ "No workspace path provided, cloning repository to temp directory: %s", temp_dir
104
+ )
105
+ local_git = EnhancedGit.clone_repo(github_client.clone_url, temp_dir)
106
+ logger.info("Repository cloned successfully")
107
+ yield local_git, temp_dir
108
+ else:
109
+ yield EnhancedGit(workspace_path), workspace_path
110
+ finally:
111
+ if temp_dir and temp_dir.exists():
112
+ logger.info("Cleaning up temp directory: %s", temp_dir)
113
+ shutil.rmtree(temp_dir)
114
+
115
+
77
116
  async def workflow_with_prints(
78
117
  jira_issue_key: str,
79
118
  workspace_path: Path,
@@ -82,6 +121,7 @@ async def workflow_with_prints(
82
121
  jira_client: JiraClient,
83
122
  local_git: EnhancedGit,
84
123
  mcp_config_path: Path | None = None,
124
+ commit_no_verify: bool = False,
85
125
  ) -> None:
86
126
  header_msg = f"Running workflow for {format_yellow(jira_issue_key)}"
87
127
  print_info(header_msg)
@@ -99,6 +139,7 @@ async def workflow_with_prints(
99
139
  git=local_git,
100
140
  base_branch=base_branch,
101
141
  mcp_config_path=mcp_config_path,
142
+ commit_no_verify=commit_no_verify,
102
143
  )
103
144
 
104
145
  success_msg = format_success_with_checkmark("Workflow completed successfully!")
@@ -123,12 +164,17 @@ def run(
123
164
  mcp_config_path: Path | None = typer.Option( # noqa: B008
124
165
  None, "--mcp-config-path", "-m", help="Path to mcp.json config file for Claude agents"
125
166
  ),
167
+ commit_no_verify: bool = typer.Option(
168
+ False,
169
+ "--commit-no-verify",
170
+ "-c",
171
+ help="bypass pre-commit and commit-msg hooks when committing (git commit --no-verify)",
172
+ ),
126
173
  ) -> None:
127
174
  """Execute the workflow for a specific Jira ticket."""
128
175
 
129
176
  settings = _load_settings()
130
177
 
131
- final_workspace_path = workspace_path or settings.core.workspace_path
132
178
  final_base_branch = base_branch or settings.core.base_branch
133
179
 
134
180
  setup_logger(
@@ -141,30 +187,35 @@ def run(
141
187
 
142
188
  with get_status("Initializing clients...", spinner="dots"):
143
189
  try:
144
- github_client, jira_client, local_git = _initialize_clients(settings)
190
+ github_client, jira_client = _initialize_clients(settings)
145
191
  except Exception as e:
146
192
  print_error(str(e))
147
193
  sys.exit(1)
148
194
 
149
- try:
150
- asyncio.run(
151
- workflow_with_prints(
152
- jira_issue_key,
153
- final_workspace_path,
154
- final_base_branch,
155
- github_client,
156
- jira_client,
157
- local_git,
158
- mcp_config_path,
195
+ with _setup_workspace(workspace_path, settings.core.workspace_path, github_client) as (
196
+ local_git,
197
+ final_workspace_path,
198
+ ):
199
+ try:
200
+ asyncio.run(
201
+ workflow_with_prints(
202
+ jira_issue_key,
203
+ final_workspace_path,
204
+ final_base_branch,
205
+ github_client,
206
+ jira_client,
207
+ local_git,
208
+ mcp_config_path,
209
+ commit_no_verify,
210
+ )
159
211
  )
160
- )
161
- except KeyboardInterrupt:
162
- print_empty_line()
163
- print_warning("Workflow interrupted by user")
164
- sys.exit(1)
165
- except Exception as e:
166
- print_error(str(e), title="Error")
167
- sys.exit(1)
212
+ except KeyboardInterrupt:
213
+ print_empty_line()
214
+ print_warning("Workflow interrupted by user")
215
+ sys.exit(1)
216
+ except Exception as e:
217
+ print_error(str(e), title="Error")
218
+ sys.exit(1)
168
219
 
169
220
 
170
221
  @app.command()
@@ -43,6 +43,10 @@ class GitHubClient:
43
43
  self.client = Github(github_token)
44
44
  self.repo = self.client.get_repo(repo_full_name)
45
45
 
46
+ @property
47
+ def clone_url(self) -> str:
48
+ return self.repo.clone_url # type: ignore[no-any-return]
49
+
46
50
  def get_base_branch_ref(self, base_branch: str) -> GitRef:
47
51
  try:
48
52
  return self.repo.get_git_ref(f"heads/{base_branch}")
@@ -1,8 +1,10 @@
1
1
  from pathlib import Path
2
+ from typing import Self
2
3
 
3
4
  import git
4
5
 
5
6
  from src.exceptions import (
7
+ GitCloneError,
6
8
  GitFetchCheckoutError,
7
9
  GitPushError,
8
10
  GitWorkspacePathNotExistsError,
@@ -31,6 +33,27 @@ class EnhancedGit:
31
33
  self.repo_path = repo_path.expanduser()
32
34
  self._repo = None
33
35
 
36
+ @classmethod
37
+ def clone_repo(cls, clone_url: str, target_path: Path) -> Self:
38
+ """
39
+ Clone a repository to the specified path and return an EnhancedGit instance.
40
+
41
+ Args:
42
+ clone_url: The URL of the repository to clone
43
+ target_path: The path where the repository will be cloned
44
+
45
+ Returns:
46
+ An EnhancedGit instance for the cloned repository
47
+
48
+ Raises:
49
+ GitCloneError: If cloning fails
50
+ """
51
+ try:
52
+ git.Repo.clone_from(clone_url, target_path)
53
+ except Exception as e:
54
+ raise GitCloneError(clone_url, str(e)) from e
55
+ return cls(target_path)
56
+
34
57
  @property
35
58
  def repo(self) -> git.Repo:
36
59
  """Lazy-load the git repository."""
@@ -73,7 +96,9 @@ class EnhancedGit:
73
96
  """
74
97
  self.repo.git.add(A=True)
75
98
 
76
- def commit_and_push(self, message: str, remote: str = "origin") -> git.Commit | None:
99
+ def commit_and_push(
100
+ self, message: str, remote: str = "origin", no_verify: bool = False
101
+ ) -> git.Commit | None:
77
102
  """
78
103
  Combined operation: Stage all changes, commit, and push to remote.
79
104
 
@@ -86,6 +111,7 @@ class EnhancedGit:
86
111
  Args:
87
112
  message: Commit message
88
113
  remote: Remote name to push to (default: "origin")
114
+ no_verify: If True, bypasses pre-commit hooks with --no-verify (default: False)
89
115
 
90
116
  Returns:
91
117
  The created commit, or None if there were no changes
@@ -99,7 +125,11 @@ class EnhancedGit:
99
125
  print("No changes detected. Nothing to commit.")
100
126
  return None
101
127
 
102
- commit = self.repo.index.commit(message)
128
+ if no_verify:
129
+ commit = self.repo.git.commit("-m", message, "--no-verify")
130
+ commit = self.repo.head.commit
131
+ else:
132
+ commit = self.repo.index.commit(message)
103
133
 
104
134
  # Push to remote
105
135
  remote_obj = self.repo.remote(name=remote)
@@ -31,6 +31,11 @@ class GitPushError(EnhancedGitError):
31
31
  super().__init__("Failed to commit and push changes")
32
32
 
33
33
 
34
+ class GitCloneError(EnhancedGitError):
35
+ def __init__(self, clone_url: str, error_msg: str) -> None:
36
+ super().__init__(f"Failed to clone repository from '{clone_url}': {error_msg}")
37
+
38
+
34
39
  class InvalidGitRepositoryError(EnhancedGitError):
35
40
  def __init__(self) -> None:
36
41
  super().__init__("Error: The directory provided is not a valid Git repository.")
@@ -49,7 +49,7 @@ class LoggingSettings(BaseModel):
49
49
 
50
50
 
51
51
  class AppCoreSettings(BaseModel):
52
- workspace_path: Path
52
+ workspace_path: Path | None = None
53
53
  base_branch: str
54
54
 
55
55
  model_config = ConfigDict(extra="forbid")
@@ -22,7 +22,6 @@ from src.validators import (
22
22
  validate_non_empty,
23
23
  validate_repo_format,
24
24
  validate_url,
25
- validate_workspace_path,
26
25
  )
27
26
 
28
27
 
@@ -33,7 +32,6 @@ def _show_welcome() -> None:
33
32
 
34
33
 
35
34
  def _show_summary(
36
- workspace_path: Path,
37
35
  base_branch: str,
38
36
  jira_base_url: str,
39
37
  jira_username: str,
@@ -43,7 +41,6 @@ def _show_summary(
43
41
  [
44
42
  format_bold("Configuration Summary"),
45
43
  "",
46
- f"{format_dim('Workspace:')} {workspace_path!s}",
47
44
  f"{format_dim('Base branch:')} {base_branch!s}",
48
45
  f"{format_dim('Jira URL:')} {jira_base_url!s}",
49
46
  f"{format_dim('Jira username:')} {jira_username!s}",
@@ -108,22 +105,16 @@ def section_decorator(section_name: str) -> Callable[[Callable[..., Any]], Calla
108
105
 
109
106
 
110
107
  @section_decorator("Core Settings")
111
- def _collect_core_settings() -> tuple[Path, str]:
108
+ def _collect_core_settings() -> str:
112
109
  from src.validators import validate_branch_name
113
110
 
114
- workspace_path = _prompt_with_validation(
115
- "Workspace path",
116
- validate_workspace_path,
117
- default=str(Path.cwd()),
118
- )
119
-
120
111
  base_branch = _prompt_with_validation(
121
112
  "Base branch",
122
113
  validate_branch_name,
123
114
  default="main",
124
115
  )
125
116
 
126
- return workspace_path, base_branch
117
+ return base_branch
127
118
 
128
119
 
129
120
  @section_decorator("Jira Settings")
@@ -167,7 +158,6 @@ def _collect_github_settings() -> tuple[str, str]:
167
158
 
168
159
  def _write_toml_config(
169
160
  config_path: Path,
170
- workspace_path: Path,
171
161
  base_branch: str,
172
162
  jira_base_url: str,
173
163
  jira_username: str,
@@ -180,7 +170,6 @@ def _write_toml_config(
180
170
 
181
171
  config = {
182
172
  "core": {
183
- "workspace_path": str(workspace_path),
184
173
  "base_branch": base_branch,
185
174
  },
186
175
  "jira": {
@@ -202,12 +191,11 @@ def initialize_settings(config_path: Path) -> None:
202
191
  try:
203
192
  _show_welcome()
204
193
 
205
- workspace_path, base_branch = _collect_core_settings()
194
+ base_branch = _collect_core_settings()
206
195
  jira_base_url, jira_username, jira_api_token = _collect_jira_settings()
207
196
  github_api_token, repo_full_name = _collect_github_settings()
208
197
 
209
198
  _show_summary(
210
- workspace_path,
211
199
  base_branch,
212
200
  jira_base_url,
213
201
  jira_username,
@@ -216,7 +204,6 @@ def initialize_settings(config_path: Path) -> None:
216
204
  if _confirm_save():
217
205
  _write_toml_config(
218
206
  config_path,
219
- workspace_path,
220
207
  base_branch,
221
208
  jira_base_url,
222
209
  jira_username,
@@ -30,6 +30,7 @@ async def workflow(
30
30
  git: EnhancedGit,
31
31
  base_branch: str,
32
32
  mcp_config_path: Path | None = None,
33
+ commit_no_verify: bool = False,
33
34
  ) -> WorkflowResult:
34
35
  logger.info("Fetching Jira issue: %s", jira_issue_key)
35
36
  issue = jira_client.fetch_issue(jira_issue_key)
@@ -46,12 +47,21 @@ async def workflow(
46
47
  session_id = await try_solve_ticket(
47
48
  issue, workspace_path=git.repo_path, mcp_config_path=mcp_config_path
48
49
  )
49
- if is_pre_commit_installed():
50
+ if commit_no_verify:
51
+ logger.info("Skipping pre-commit verification: --commit-no-verify flag is set")
52
+ elif is_pre_commit_installed():
50
53
  logger.info("pre-commit is installed. Trying to run it")
51
54
  result = run_pre_commit(git.repo_path)
52
55
  if not result.success:
53
56
  logger.info("pre-commit failed. trying to fix it (workspace: %s)", git.repo_path)
54
- await verify_pre_commit_and_fix(git.repo_path, mcp_config_path=mcp_config_path)
57
+ commit_no_verify = not await verify_pre_commit_and_fix(
58
+ git.repo_path, mcp_config_path=mcp_config_path
59
+ )
60
+ if commit_no_verify:
61
+ logger.warning(
62
+ "pre-commit verification failed after fix attempts. "
63
+ "Will commit with --no-verify"
64
+ )
55
65
  else:
56
66
  logger.info("Skipping pre-commit fix: pre-commit is is already passing")
57
67
  else:
@@ -65,7 +75,7 @@ async def workflow(
65
75
  branch_name,
66
76
  commit_message.split("\n")[0] if commit_message else "N/A",
67
77
  )
68
- git.commit_and_push(commit_message)
78
+ git.commit_and_push(commit_message, no_verify=commit_no_verify)
69
79
  logger.info("Generating PR title for issue: %s", issue.key)
70
80
  pr_title = generate_pr_title_from_jira_issue(issue)
71
81
  logger.info("Creating PR: title='%s', head=%s, base=%s", pr_title, branch_name, base_branch)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ticket2pr
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Automate Jira ticket to GitHub PR workflow
5
5
  Home-page: https://github.com/bengabay11/ticket2pr
6
6
  Author: Ben Gabay
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes