bob-dev 0.1.0__tar.gz → 0.2.1__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 (30) hide show
  1. {bob_dev-0.1.0/src/bob_dev.egg-info → bob_dev-0.2.1}/PKG-INFO +34 -13
  2. {bob_dev-0.1.0 → bob_dev-0.2.1}/README.md +33 -12
  3. {bob_dev-0.1.0 → bob_dev-0.2.1}/pyproject.toml +1 -1
  4. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev/cli.py +100 -34
  5. bob_dev-0.2.1/src/bob_dev/services/claude.py +27 -0
  6. bob_dev-0.1.0/src/bob_dev/helpers/config_helper.py → bob_dev-0.2.1/src/bob_dev/services/config.py +28 -12
  7. bob_dev-0.2.1/src/bob_dev/services/gitlab.py +42 -0
  8. bob_dev-0.1.0/src/bob_dev/helpers/jira_helper.py → bob_dev-0.2.1/src/bob_dev/services/jira.py +1 -1
  9. bob_dev-0.1.0/src/bob_dev/helpers/llm_helper.py → bob_dev-0.2.1/src/bob_dev/services/llm.py +1 -1
  10. bob_dev-0.1.0/src/bob_dev/helpers/project_helper.py → bob_dev-0.2.1/src/bob_dev/services/project.py +1 -1
  11. {bob_dev-0.1.0 → bob_dev-0.2.1/src/bob_dev.egg-info}/PKG-INFO +34 -13
  12. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev.egg-info/SOURCES.txt +12 -10
  13. bob_dev-0.1.0/tests/test_config_helper.py → bob_dev-0.2.1/tests/test_config.py +2 -2
  14. bob_dev-0.1.0/tests/test_jira_helper.py → bob_dev-0.2.1/tests/test_jira.py +11 -11
  15. bob_dev-0.1.0/tests/test_llm_helper.py → bob_dev-0.2.1/tests/test_llm.py +16 -16
  16. bob_dev-0.1.0/tests/test_project_helper.py → bob_dev-0.2.1/tests/test_project.py +5 -5
  17. {bob_dev-0.1.0 → bob_dev-0.2.1}/tests/test_terminal.py +2 -2
  18. {bob_dev-0.1.0 → bob_dev-0.2.1}/LICENSE +0 -0
  19. {bob_dev-0.1.0 → bob_dev-0.2.1}/setup.cfg +0 -0
  20. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/__init__.py +0 -0
  21. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev/__init__.py +0 -0
  22. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev/constants/__init__.py +0 -0
  23. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev/constants/frameworks.py +0 -0
  24. {bob_dev-0.1.0/src/bob_dev/helpers → bob_dev-0.2.1/src/bob_dev/services}/__init__.py +0 -0
  25. {bob_dev-0.1.0/src/bob_dev/helpers → bob_dev-0.2.1/src/bob_dev/services}/terminal.py +0 -0
  26. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev.egg-info/dependency_links.txt +0 -0
  27. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev.egg-info/entry_points.txt +0 -0
  28. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev.egg-info/requires.txt +0 -0
  29. {bob_dev-0.1.0 → bob_dev-0.2.1}/src/bob_dev.egg-info/top_level.txt +0 -0
  30. {bob_dev-0.1.0 → bob_dev-0.2.1}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bob-dev
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: An AI developer to write code for you
5
5
  Author-email: Samuel Pereira dos Santos <samuelsantosdev@gmail.com>
6
6
  Requires-Python: >=3.14
@@ -17,21 +17,22 @@ Dynamic: license-file
17
17
 
18
18
  ![BOB-Dev Banner](https://github.com/samuelsantosdev/bob-dev/blob/main/assets/banner.png)
19
19
 
20
- Given a Jira task ID it will:
20
+ Given a task ID (Jira or GitLab) it will:
21
21
 
22
- 1. **Fetch** the task title, description, and fix versions from Jira.
22
+ 1. **Fetch** the task title, description, and fix versions from Jira or GitLab.
23
23
  2. **Read** your project's Markdown documentation to build rich LLM context.
24
24
  3. **Generate** a precise Claude Code prompt (via GROK or OpenAI), including project-framework context, implementation steps, and test scenarios.
25
25
  4. **Analyse** the prompt for ambiguities and security concerns.
26
- 5. **Execute** the prompt with the Claude Code CLI (optional you can review first).
26
+ 5. **Select** (optionally) a Claude Code agent to run the implementation.
27
+ 6. **Execute** the prompt with the Claude Code CLI (optional – you can review first).
27
28
 
28
29
  ---
29
30
 
30
31
  ## Requirements
31
32
 
32
- - Python 3.11+
33
+ - Python 3.14+
33
34
  - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and available on `$PATH` as `claude`
34
- - A Jira Cloud account with an [API token](https://id.atlassian.com/manage-profile/security/api-tokens)
35
+ - A **Jira Cloud** account with an [API token](https://id.atlassian.com/manage-profile/security/api-tokens) **or** a **GitLab** account with a [personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html)
35
36
  - An [xAI / GROK](https://console.x.ai/) **or** [OpenAI](https://platform.openai.com/) API key
36
37
 
37
38
  ---
@@ -52,19 +53,37 @@ Run the interactive setup wizard the first time:
52
53
  bob-dev --configure
53
54
  ```
54
55
 
55
- This will prompt for your API keys, Jira credentials, and Claude Code API key.
56
+ This will prompt for your LLM API key, task manager (Jira or GitLab) credentials, and Claude Code API key.
57
+
58
+ ### Environment variables
59
+
60
+ | Variable | Description |
61
+ |----------|-------------|
62
+ | `AGENT` | LLM backend: `GROK` (default) or `OPENAI` |
63
+ | `GROK_API_KEY` | xAI / GROK secret key |
64
+ | `OPENAI_API_KEY` | OpenAI secret key |
65
+ | `TASK_MANAGER` | Task manager: `JIRA` (default) or `GITLAB` |
66
+ | `JIRA_URL` | e.g. `https://your-org.atlassian.net` |
67
+ | `JIRA_EMAIL` | Atlassian account e-mail |
68
+ | `JIRA_API_TOKEN` | Atlassian API token |
69
+ | `GITLAB_URL` | e.g. `https://gitlab.com` |
70
+ | `GITLAB_API_TOKEN` | GitLab personal access token |
56
71
 
57
72
  ---
58
73
 
59
74
  ## Usage
60
75
 
61
76
  ```bash
77
+ # Jira
62
78
  bob-dev --task_id PROJ-123 --path /path/to/your/repo
79
+
80
+ # GitLab (issue IID)
81
+ bob-dev --task_id 42 --path /path/to/your/repo
63
82
  ```
64
83
 
65
84
  | Flag | Description |
66
85
  |------|-------------|
67
- | `--task_id` | Jira task ID to process (required for normal workflow) |
86
+ | `--task_id` | Task ID to process — Jira key (`PROJ-123`) or GitLab issue IID (`42`) |
68
87
  | `--path` | Path to the target repository (default: current directory) |
69
88
  | `--agent` | LLM backend: `GROK` or `OPENAI` (default: value of `AGENT` in `.env`) |
70
89
  | `--configure` | Run the interactive configuration wizard |
@@ -76,12 +95,14 @@ bob-dev --task_id PROJ-123 --path /path/to/your/repo
76
95
  ```
77
96
  src/bob_dev/
78
97
  ├── cli.py # Entry point & main workflow orchestration
79
- ├── helpers/
98
+ ├── services/
80
99
  │ ├── terminal.py # ANSI colours, print helpers, spinner animation
81
- │ ├── jira_helper.py # Jira API connection + ADF-to-text parsing
82
- │ ├── llm_helper.py # LLM client, model selection, prompt generation
83
- │ ├── project_helper.py # Markdown context collection + framework detection
84
- └── config_helper.py # .env management + credential validation
100
+ │ ├── jira.py # Jira API connection + ADF-to-text parsing
101
+ │ ├── gitlab.py # GitLab API connection via python-gitlab
102
+ │ ├── claude.py # Claude Code CLI utilities (agent listing)
103
+ ├── llm.py # LLM client, model selection, prompt generation
104
+ │ ├── project.py # Markdown context collection + framework detection
105
+ │ └── config.py # .env management + credential validation
85
106
  └── constants/
86
107
  └── frameworks.py # Known framework names used for auto-detection
87
108
  ```
@@ -4,21 +4,22 @@
4
4
 
5
5
  ![BOB-Dev Banner](https://github.com/samuelsantosdev/bob-dev/blob/main/assets/banner.png)
6
6
 
7
- Given a Jira task ID it will:
7
+ Given a task ID (Jira or GitLab) it will:
8
8
 
9
- 1. **Fetch** the task title, description, and fix versions from Jira.
9
+ 1. **Fetch** the task title, description, and fix versions from Jira or GitLab.
10
10
  2. **Read** your project's Markdown documentation to build rich LLM context.
11
11
  3. **Generate** a precise Claude Code prompt (via GROK or OpenAI), including project-framework context, implementation steps, and test scenarios.
12
12
  4. **Analyse** the prompt for ambiguities and security concerns.
13
- 5. **Execute** the prompt with the Claude Code CLI (optional you can review first).
13
+ 5. **Select** (optionally) a Claude Code agent to run the implementation.
14
+ 6. **Execute** the prompt with the Claude Code CLI (optional – you can review first).
14
15
 
15
16
  ---
16
17
 
17
18
  ## Requirements
18
19
 
19
- - Python 3.11+
20
+ - Python 3.14+
20
21
  - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and available on `$PATH` as `claude`
21
- - A Jira Cloud account with an [API token](https://id.atlassian.com/manage-profile/security/api-tokens)
22
+ - A **Jira Cloud** account with an [API token](https://id.atlassian.com/manage-profile/security/api-tokens) **or** a **GitLab** account with a [personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html)
22
23
  - An [xAI / GROK](https://console.x.ai/) **or** [OpenAI](https://platform.openai.com/) API key
23
24
 
24
25
  ---
@@ -39,19 +40,37 @@ Run the interactive setup wizard the first time:
39
40
  bob-dev --configure
40
41
  ```
41
42
 
42
- This will prompt for your API keys, Jira credentials, and Claude Code API key.
43
+ This will prompt for your LLM API key, task manager (Jira or GitLab) credentials, and Claude Code API key.
44
+
45
+ ### Environment variables
46
+
47
+ | Variable | Description |
48
+ |----------|-------------|
49
+ | `AGENT` | LLM backend: `GROK` (default) or `OPENAI` |
50
+ | `GROK_API_KEY` | xAI / GROK secret key |
51
+ | `OPENAI_API_KEY` | OpenAI secret key |
52
+ | `TASK_MANAGER` | Task manager: `JIRA` (default) or `GITLAB` |
53
+ | `JIRA_URL` | e.g. `https://your-org.atlassian.net` |
54
+ | `JIRA_EMAIL` | Atlassian account e-mail |
55
+ | `JIRA_API_TOKEN` | Atlassian API token |
56
+ | `GITLAB_URL` | e.g. `https://gitlab.com` |
57
+ | `GITLAB_API_TOKEN` | GitLab personal access token |
43
58
 
44
59
  ---
45
60
 
46
61
  ## Usage
47
62
 
48
63
  ```bash
64
+ # Jira
49
65
  bob-dev --task_id PROJ-123 --path /path/to/your/repo
66
+
67
+ # GitLab (issue IID)
68
+ bob-dev --task_id 42 --path /path/to/your/repo
50
69
  ```
51
70
 
52
71
  | Flag | Description |
53
72
  |------|-------------|
54
- | `--task_id` | Jira task ID to process (required for normal workflow) |
73
+ | `--task_id` | Task ID to process — Jira key (`PROJ-123`) or GitLab issue IID (`42`) |
55
74
  | `--path` | Path to the target repository (default: current directory) |
56
75
  | `--agent` | LLM backend: `GROK` or `OPENAI` (default: value of `AGENT` in `.env`) |
57
76
  | `--configure` | Run the interactive configuration wizard |
@@ -63,12 +82,14 @@ bob-dev --task_id PROJ-123 --path /path/to/your/repo
63
82
  ```
64
83
  src/bob_dev/
65
84
  ├── cli.py # Entry point & main workflow orchestration
66
- ├── helpers/
85
+ ├── services/
67
86
  │ ├── terminal.py # ANSI colours, print helpers, spinner animation
68
- │ ├── jira_helper.py # Jira API connection + ADF-to-text parsing
69
- │ ├── llm_helper.py # LLM client, model selection, prompt generation
70
- │ ├── project_helper.py # Markdown context collection + framework detection
71
- └── config_helper.py # .env management + credential validation
87
+ │ ├── jira.py # Jira API connection + ADF-to-text parsing
88
+ │ ├── gitlab.py # GitLab API connection via python-gitlab
89
+ │ ├── claude.py # Claude Code CLI utilities (agent listing)
90
+ ├── llm.py # LLM client, model selection, prompt generation
91
+ │ ├── project.py # Markdown context collection + framework detection
92
+ │ └── config.py # .env management + credential validation
72
93
  └── constants/
73
94
  └── frameworks.py # Known framework names used for auto-detection
74
95
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bob-dev"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "An AI developer to write code for you"
9
9
  readme = "README.md"
10
10
  authors = [{ name="Samuel Pereira dos Santos", email="samuelsantosdev@gmail.com" }]
@@ -23,6 +23,7 @@ Environment variables (.env next to this file or exported):
23
23
  JIRA_URL – https://your-org.atlassian.net
24
24
  JIRA_EMAIL – Atlassian account e-mail
25
25
  JIRA_API_TOKEN – Atlassian API token
26
+ TASK_MANAGER – "JIRA" (default) or "GITLAB"
26
27
  """
27
28
 
28
29
  from __future__ import annotations
@@ -36,7 +37,7 @@ from pathlib import Path
36
37
  from InquirerPy import inquirer
37
38
  from dotenv import load_dotenv
38
39
 
39
- from .helpers.terminal import (
40
+ from .services.terminal import (
40
41
  BOLD,
41
42
  RESET,
42
43
  print_error,
@@ -47,10 +48,12 @@ from .helpers.terminal import (
47
48
  run_subprocess,
48
49
  run_with_spinner,
49
50
  )
50
- from .helpers.jira_helper import get_jira_task
51
- from .helpers.llm_helper import analyse_prompt, llm_model, prompt_claude_code
52
- from .helpers.project_helper import build_md_context, identify_framework
53
- from .helpers.config_helper import check_configuration, update_env_file
51
+ from .services.jira import get_jira_task
52
+ from .services.gitlab import get_gitlab_task
53
+ from .services.claude import read_agents_from_claude
54
+ from .services.llm import analyse_prompt, llm_model, prompt_claude_code
55
+ from .services.project import build_md_context, identify_framework
56
+ from .services.config import check_configuration, update_env_file
54
57
 
55
58
  # ---------------------------------------------------------------------------
56
59
  # Module-level configuration
@@ -58,15 +61,20 @@ from .helpers.config_helper import check_configuration, update_env_file
58
61
 
59
62
  SCRIPT_DIR = Path(__file__).resolve().parent
60
63
  load_dotenv(SCRIPT_DIR / ".env")
64
+ ENV_PATH = Path.home() / ".bob_dev" / ".env"
61
65
 
62
66
  OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
63
67
  GROK_API_KEY = os.environ.get("GROK_API_KEY", "")
64
68
  AGENT = os.environ.get("AGENT", "GROK").upper() # "GROK" or "OPENAI"
69
+ TASK_MANAGER = os.environ.get("TASK_MANAGER", "JIRA").upper() # "JIRA" or "GITLAB"
65
70
 
66
71
  JIRA_URL = os.environ.get("JIRA_URL", "")
67
72
  JIRA_EMAIL = os.environ.get("JIRA_EMAIL", "")
68
73
  JIRA_API_TOKEN = os.environ.get("JIRA_API_TOKEN", "")
69
74
 
75
+ GITLAB_URL = os.environ.get("GITLAB_URL", "")
76
+ GITLAB_API_TOKEN = os.environ.get("GITLAB_API_TOKEN", "")
77
+
70
78
  REPO_BASE_PATH = Path("./") # Overridden by --path at runtime.
71
79
  CLAUDE_CODE_CMD = "claude" # Must be on $PATH.
72
80
 
@@ -84,7 +92,7 @@ def main() -> None:
84
92
  )
85
93
  parser.add_argument(
86
94
  "--task_id", type=str,
87
- help="Jira task ID to process (e.g. PROJ-123).",
95
+ help="Task ID to process (e.g. PROJ-123 for Jira or 42 for GitLab).",
88
96
  )
89
97
  parser.add_argument(
90
98
  "--path", type=str, default="./",
@@ -107,17 +115,21 @@ def main() -> None:
107
115
 
108
116
  # ── Require task_id for normal workflow ──────────────────────────────────
109
117
  if not args.task_id:
110
- print_error("Jira task ID is required. Use --task_id PROJ-123.")
118
+ print_error("Task ID is required. Use --task_id PROJ-123 for Jira or 42 for GitLab.")
111
119
  sys.exit(1)
112
120
 
113
121
  task_id = args.task_id.strip().upper()
114
122
  agent = args.agent.upper()
115
123
 
116
124
  # ── Validate credentials before making any API calls ────────────────────
117
- if not all([JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN]):
125
+ if TASK_MANAGER == "JIRA" and not all([JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN]):
118
126
  print_error("Jira credentials are not configured. Run `bob-dev --configure`.")
119
127
  sys.exit(1)
120
128
 
129
+ if TASK_MANAGER == "GITLAB" and not all([GITLAB_URL, GITLAB_API_TOKEN]):
130
+ print_error("GitLab credentials are not configured. Run `bob-dev --configure`.")
131
+ sys.exit(1)
132
+
121
133
  if agent == "GROK" and not GROK_API_KEY:
122
134
  print_error("GROK_API_KEY is not set. Run `bob-dev --configure`.")
123
135
  sys.exit(1)
@@ -135,18 +147,26 @@ def main() -> None:
135
147
  sys.exit(1)
136
148
 
137
149
  print_info(f"Project path : {REPO_BASE_PATH}")
138
- print_info(f"Jira task ID : {task_id}")
150
+ print_info(f"{TASK_MANAGER} task ID : {task_id}")
139
151
  print_info(f"LLM backend : {agent} ({llm_model(agent)})")
140
152
  print()
141
153
 
142
- # ── Step 1 – Fetch Jira task ─────────────────────────────────────────────
143
- print_step("[1/4]", f"Fetching Jira task {task_id} …")
154
+ # ── Step 1 – Fetch task ─────────────────────────────────────────────
155
+ print_step("[1/4]", f"Fetching {TASK_MANAGER} task {task_id} …")
144
156
 
145
- task = asyncio.run(run_with_spinner(
146
- get_jira_task,
147
- task_id, JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN,
148
- label="Fetching Jira task",
149
- ))
157
+ if TASK_MANAGER == "JIRA":
158
+ task = asyncio.run(run_with_spinner(
159
+ get_jira_task,
160
+ task_id, JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN,
161
+ label="Fetching Jira task",
162
+ ))
163
+
164
+ if TASK_MANAGER == "GITLAB":
165
+ task = asyncio.run(run_with_spinner(
166
+ get_gitlab_task,
167
+ task_id, GITLAB_URL, GITLAB_API_TOKEN,
168
+ label="Fetching GitLab task",
169
+ ))
150
170
 
151
171
  print_success(f"Title : {task['title']}")
152
172
  print_success(f"Fix versions : {', '.join(task['fix_versions']) or 'N/A'}")
@@ -154,9 +174,10 @@ def main() -> None:
154
174
 
155
175
  acceptance_criteria = task["description"]
156
176
  if not acceptance_criteria.strip():
157
- print_error("The Jira task has no description / acceptance criteria.")
177
+ print_error(f"The {TASK_MANAGER} task has no description / acceptance criteria.")
158
178
  sys.exit(1)
159
179
 
180
+
160
181
  # ── Step 2 – Read project docs & generate prompt ─────────────────────────
161
182
  print_step("[2/4]", f"Generating Claude Code prompt via {agent} ({llm_model(agent)}) …")
162
183
 
@@ -211,17 +232,35 @@ def main() -> None:
211
232
  print_info(f"Aborted. Prompt saved to {prompt_file}")
212
233
  sys.exit(0)
213
234
 
235
+ print_info("Prompt preview:")
236
+ print("-" * 68)
237
+ print(prompt_md)
238
+ print("-" * 68)
239
+ answer = input("\nAre you sure? This will run the Claude Code CLI with the generated prompt. [y/N] ").strip().lower()
240
+ if answer != "y":
241
+ print_info("Aborted by user.")
242
+ sys.exit(0)
243
+
244
+ answer = input("Do you want select a agent to do this development? [y/N] ").strip().upper()
245
+ agent_claude = ""
246
+ if answer == "Y":
247
+ agents_of_claude = read_agents_from_claude(CLAUDE_CODE_CMD)
248
+ agent_claude = inquirer.select(
249
+ message="Select the agent to do the development:",
250
+ choices=agents_of_claude,
251
+ ).execute()
252
+
214
253
  # ── Step 4 – Pass prompt to Claude Code ──────────────────────────────────
215
254
  print_step("[4/4]", "Passing prompt to Claude Code …")
216
255
  print()
217
- asyncio.run(_pass_to_claude_code(prompt_md, task_id))
256
+ asyncio.run(_pass_to_claude_code(prompt_md, task_id, agent_claude))
218
257
 
219
258
 
220
259
  # ---------------------------------------------------------------------------
221
260
  # Internal helpers
222
261
  # ---------------------------------------------------------------------------
223
262
 
224
- async def _pass_to_claude_code(prompt_md: str, task_id: str) -> None:
263
+ async def _pass_to_claude_code(prompt_md: str, task_id: str, agent_claude: str=None) -> None:
225
264
  """Write the prompt to a temp file, then run Claude Code non-interactively."""
226
265
 
227
266
  # Persist the prompt so the user can review it regardless of outcome.
@@ -231,6 +270,8 @@ async def _pass_to_claude_code(prompt_md: str, task_id: str) -> None:
231
270
  print_info(f"Prompt saved to: {prompt_file}")
232
271
 
233
272
  cmd = [CLAUDE_CODE_CMD, "--dangerously-skip-permissions", "--print", prompt_md]
273
+ if agent_claude:
274
+ cmd.extend(["--agent", agent_claude])
234
275
  print_info(f"Running: {' '.join(cmd[:2])} <prompt>")
235
276
  print()
236
277
 
@@ -242,7 +283,9 @@ async def _pass_to_claude_code(prompt_md: str, task_id: str) -> None:
242
283
 
243
284
  def _run_configure() -> None:
244
285
  """Interactive wizard to write API keys and Jira credentials to .env."""
245
- env_path = SCRIPT_DIR / ".env"
286
+ env_path = ENV_PATH
287
+ env_path.parent.mkdir(parents=True, exist_ok=True)
288
+ print(f"Configuration file will be saved to: {env_path}")
246
289
  print_step("[CONFIGURE]", "Running initial configuration …")
247
290
 
248
291
  # Choose the LLM backend.
@@ -258,20 +301,40 @@ def _run_configure() -> None:
258
301
  update_env_file(env_key, api_key, env_path)
259
302
  print_success(f"{system_choice} API key saved.")
260
303
 
261
- # Jira credentials.
262
- print("\nJira configuration:")
263
- jira_url = input("JIRA_URL (e.g. https://your-org.atlassian.net): ").strip()
264
- jira_email = input("JIRA_EMAIL (your Atlassian account e-mail) : ").strip()
265
- jira_token = input("JIRA_API_TOKEN (Atlassian API token) : ").strip()
266
-
267
- for key, val in [
268
- ("JIRA_URL", jira_url),
269
- ("JIRA_EMAIL", jira_email),
270
- ("JIRA_API_TOKEN", jira_token),
271
- ]:
272
- update_env_file(key, val, env_path)
273
-
274
- print_success("Jira configuration saved.")
304
+ print("\n Tasks manager configuration:")
305
+ task_manager = inquirer.select(
306
+ message="Select the default task manager:",
307
+ choices=["JIRA", "GITLAB"],
308
+ ).execute()
309
+ update_env_file("TASK_MANAGER", task_manager, env_path)
310
+ print_success(f"Task manager set to {task_manager}.")
311
+
312
+ print("\nNote: The rest of the configuration depends on the selected task manager. You can run `bob-dev --configure` again to set it up later.")
313
+
314
+ if task_manager == "JIRA":
315
+ # Jira credentials.
316
+ print("\nJira configuration:")
317
+ jira_url = input("JIRA_URL (e.g. https://your-org.atlassian.net): ").strip()
318
+ jira_email = input("JIRA_EMAIL (your Atlassian account e-mail) : ").strip()
319
+ jira_token = input("JIRA_API_TOKEN (Atlassian API token) : ").strip()
320
+ for key, val in [
321
+ ("JIRA_URL", jira_url),
322
+ ("JIRA_EMAIL", jira_email),
323
+ ("JIRA_API_TOKEN", jira_token),
324
+ ]:
325
+ update_env_file(key, val, env_path)
326
+ if task_manager == "GITLAB":
327
+ # GitLab credentials.
328
+ print("\nGitLab configuration:")
329
+ gitlab_url = input("GITLAB_URL (e.g. https://gitlab.com) : ").strip()
330
+ gitlab_token = input("GITLAB_API_TOKEN (GitLab API token) : ").strip()
331
+ for key, val in [
332
+ ("GITLAB_URL", gitlab_url),
333
+ ("GITLAB_API_TOKEN", gitlab_token),
334
+ ]:
335
+ update_env_file(key, val, env_path)
336
+
337
+ print_success(f"{task_manager} configuration saved.")
275
338
 
276
339
  # Claude Code API key.
277
340
  print("\nClaude Code API key:")
@@ -286,6 +349,9 @@ def _run_configure() -> None:
286
349
  agent = os.environ.get("AGENT", "GROK").upper(),
287
350
  grok_api_key = os.environ.get("GROK_API_KEY", ""),
288
351
  openai_api_key = os.environ.get("OPENAI_API_KEY", ""),
352
+ gitlab_url = os.environ.get("GITLAB_URL", ""),
353
+ gitlab_api_token = os.environ.get("GITLAB_API_TOKEN", ""),
354
+ task_manager = os.environ.get("TASK_MANAGER", "JIRA").upper(),
289
355
  jira_url = jira_url,
290
356
  jira_email = jira_email,
291
357
  jira_api_token = jira_token,
@@ -0,0 +1,27 @@
1
+ """claude.py
2
+
3
+ Claude Code utilities for bob_dev:
4
+ - List available agents from the Claude Code CLI.
5
+ - Run Claude Code commands and handle errors.
6
+ """
7
+
8
+ import subprocess
9
+
10
+ from ..services.terminal import print_error
11
+
12
+
13
+ def read_agents_from_claude(claude_cmd: str) -> list[str]:
14
+ """Run *claude_cmd* with --list-agents and parse the output into a list."""
15
+ try:
16
+ result = subprocess.run(
17
+ [claude_cmd, "--list-agents"],
18
+ stdout=subprocess.PIPE,
19
+ stderr=subprocess.PIPE,
20
+ text=True,
21
+ check=True,
22
+ )
23
+ agents = [line.strip() for line in result.stdout.splitlines() if line.strip()]
24
+ return agents
25
+ except Exception as exc:
26
+ print_error(f"Failed to list agents from Claude Code CLI: {exc}")
27
+ return []
@@ -1,4 +1,4 @@
1
- """config_helper.py
1
+ """config.py
2
2
 
3
3
  Configuration utilities for bob_dev:
4
4
  - Write / update key-value pairs in the .env file.
@@ -53,6 +53,9 @@ def check_configuration(
53
53
  agent: str,
54
54
  grok_api_key: str,
55
55
  openai_api_key: str,
56
+ gitlab_url: str,
57
+ gitlab_api_token: str,
58
+ task_manager: str,
56
59
  jira_url: str,
57
60
  jira_email: str,
58
61
  jira_api_token: str,
@@ -65,7 +68,7 @@ def check_configuration(
65
68
  # ── LLM API key ───────────────────────────────────────────────────────
66
69
  print_info(f"Checking {agent} API key …")
67
70
  try:
68
- from .llm_helper import build_llm_client, llm_model
71
+ from .llm import build_llm_client, llm_model
69
72
 
70
73
  client = build_llm_client(agent, grok_api_key, openai_api_key)
71
74
  model = llm_model(agent)
@@ -79,16 +82,29 @@ def check_configuration(
79
82
  except Exception as exc:
80
83
  print_error(f"Failed to connect to {agent} API: {exc}")
81
84
 
82
- # ── Jira credentials ──────────────────────────────────────────────────
83
- print_info("Checking Jira credentials …")
84
- try:
85
- from atlassian import Jira
86
-
87
- jira = Jira(url=jira_url, username=jira_email, password=jira_api_token, cloud=True)
88
- user = jira.myself()
89
- print_success(f"Jira credentials OK. Authenticated as: {user.get('displayName')}")
90
- except Exception as exc:
91
- print_error(f"Failed to connect to Jira: {exc}")
85
+ if task_manager == "GITLAB":
86
+ # ── GitLab credentials ───────────────────────────────────────────────
87
+ print_info("Checking GitLab credentials …")
88
+ try:
89
+ from gitlab import Gitlab
90
+
91
+ gl = Gitlab(url=gitlab_url, private_token=gitlab_api_token)
92
+ user = gl.user
93
+ print_success(f"GitLab credentials OK. Authenticated as: {user['name']}")
94
+ except Exception as exc:
95
+ print_error(f"Failed to connect to GitLab: {exc}")
96
+
97
+ if task_manager == "JIRA":
98
+ # ── Jira credentials ──────────────────────────────────────────────────
99
+ print_info("Checking Jira credentials …")
100
+ try:
101
+ from atlassian import Jira
102
+
103
+ jira = Jira(url=jira_url, username=jira_email, password=jira_api_token, cloud=True)
104
+ user = jira.myself()
105
+ print_success(f"Jira credentials OK. Authenticated as: {user.get('displayName')}")
106
+ except Exception as exc:
107
+ print_error(f"Failed to connect to Jira: {exc}")
92
108
 
93
109
  # ── Claude Code CLI ───────────────────────────────────────────────────
94
110
  print_info(f"Checking Claude Code CLI ({claude_cmd}) …")
@@ -0,0 +1,42 @@
1
+ """gitlab.py
2
+
3
+ GitLab integration utilities for bob_dev:
4
+ - Fetch a GitLab issue and normalise its fields into a plain dict.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import gitlab
10
+
11
+
12
+ def get_gitlab_task(
13
+ task_id: int | str,
14
+ gitlab_url: str,
15
+ gitlab_token: str,
16
+ project_id: int | str,
17
+ ) -> dict:
18
+ """Connect to a GitLab instance and return normalised fields for *task_id*.
19
+
20
+ *task_id* is the issue IID (the number shown in the GitLab UI, e.g. #42).
21
+ *project_id* can be the numeric ID or the path namespace, e.g. "group/repo".
22
+
23
+ Returns a dict with keys:
24
+ task_id (str)
25
+ title (str)
26
+ description (str)
27
+ fix_versions (list[str] – milestone title wrapped in a list, or empty)
28
+ """
29
+ gl = gitlab.Gitlab(url=gitlab_url, private_token=gitlab_token)
30
+
31
+ project = gl.projects.get(project_id)
32
+ issue = project.issues.get(int(task_id))
33
+
34
+ milestone = issue.milestone or {}
35
+ fix_versions = [milestone["title"]] if milestone.get("title") else []
36
+
37
+ return {
38
+ "task_id": str(task_id),
39
+ "title": issue.title or "",
40
+ "description": issue.description or "",
41
+ "fix_versions": fix_versions,
42
+ }
@@ -1,4 +1,4 @@
1
- """jira_helper.py
1
+ """jira.py
2
2
 
3
3
  Jira integration utilities for bob_dev:
4
4
  - Fetch a Jira issue and normalise its fields into a plain dict.
@@ -1,4 +1,4 @@
1
- """llm_helper.py
1
+ """llm.py
2
2
 
3
3
  LLM integration for bob_dev:
4
4
  - Build an OpenAI-compatible client for GROK or OpenAI backends.
@@ -1,4 +1,4 @@
1
- """project_helper.py
1
+ """project.py
2
2
 
3
3
  Project context utilities for bob_dev:
4
4
  - Collect Markdown files from the repository for LLM context.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bob-dev
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: An AI developer to write code for you
5
5
  Author-email: Samuel Pereira dos Santos <samuelsantosdev@gmail.com>
6
6
  Requires-Python: >=3.14
@@ -17,21 +17,22 @@ Dynamic: license-file
17
17
 
18
18
  ![BOB-Dev Banner](https://github.com/samuelsantosdev/bob-dev/blob/main/assets/banner.png)
19
19
 
20
- Given a Jira task ID it will:
20
+ Given a task ID (Jira or GitLab) it will:
21
21
 
22
- 1. **Fetch** the task title, description, and fix versions from Jira.
22
+ 1. **Fetch** the task title, description, and fix versions from Jira or GitLab.
23
23
  2. **Read** your project's Markdown documentation to build rich LLM context.
24
24
  3. **Generate** a precise Claude Code prompt (via GROK or OpenAI), including project-framework context, implementation steps, and test scenarios.
25
25
  4. **Analyse** the prompt for ambiguities and security concerns.
26
- 5. **Execute** the prompt with the Claude Code CLI (optional you can review first).
26
+ 5. **Select** (optionally) a Claude Code agent to run the implementation.
27
+ 6. **Execute** the prompt with the Claude Code CLI (optional – you can review first).
27
28
 
28
29
  ---
29
30
 
30
31
  ## Requirements
31
32
 
32
- - Python 3.11+
33
+ - Python 3.14+
33
34
  - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and available on `$PATH` as `claude`
34
- - A Jira Cloud account with an [API token](https://id.atlassian.com/manage-profile/security/api-tokens)
35
+ - A **Jira Cloud** account with an [API token](https://id.atlassian.com/manage-profile/security/api-tokens) **or** a **GitLab** account with a [personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html)
35
36
  - An [xAI / GROK](https://console.x.ai/) **or** [OpenAI](https://platform.openai.com/) API key
36
37
 
37
38
  ---
@@ -52,19 +53,37 @@ Run the interactive setup wizard the first time:
52
53
  bob-dev --configure
53
54
  ```
54
55
 
55
- This will prompt for your API keys, Jira credentials, and Claude Code API key.
56
+ This will prompt for your LLM API key, task manager (Jira or GitLab) credentials, and Claude Code API key.
57
+
58
+ ### Environment variables
59
+
60
+ | Variable | Description |
61
+ |----------|-------------|
62
+ | `AGENT` | LLM backend: `GROK` (default) or `OPENAI` |
63
+ | `GROK_API_KEY` | xAI / GROK secret key |
64
+ | `OPENAI_API_KEY` | OpenAI secret key |
65
+ | `TASK_MANAGER` | Task manager: `JIRA` (default) or `GITLAB` |
66
+ | `JIRA_URL` | e.g. `https://your-org.atlassian.net` |
67
+ | `JIRA_EMAIL` | Atlassian account e-mail |
68
+ | `JIRA_API_TOKEN` | Atlassian API token |
69
+ | `GITLAB_URL` | e.g. `https://gitlab.com` |
70
+ | `GITLAB_API_TOKEN` | GitLab personal access token |
56
71
 
57
72
  ---
58
73
 
59
74
  ## Usage
60
75
 
61
76
  ```bash
77
+ # Jira
62
78
  bob-dev --task_id PROJ-123 --path /path/to/your/repo
79
+
80
+ # GitLab (issue IID)
81
+ bob-dev --task_id 42 --path /path/to/your/repo
63
82
  ```
64
83
 
65
84
  | Flag | Description |
66
85
  |------|-------------|
67
- | `--task_id` | Jira task ID to process (required for normal workflow) |
86
+ | `--task_id` | Task ID to process — Jira key (`PROJ-123`) or GitLab issue IID (`42`) |
68
87
  | `--path` | Path to the target repository (default: current directory) |
69
88
  | `--agent` | LLM backend: `GROK` or `OPENAI` (default: value of `AGENT` in `.env`) |
70
89
  | `--configure` | Run the interactive configuration wizard |
@@ -76,12 +95,14 @@ bob-dev --task_id PROJ-123 --path /path/to/your/repo
76
95
  ```
77
96
  src/bob_dev/
78
97
  ├── cli.py # Entry point & main workflow orchestration
79
- ├── helpers/
98
+ ├── services/
80
99
  │ ├── terminal.py # ANSI colours, print helpers, spinner animation
81
- │ ├── jira_helper.py # Jira API connection + ADF-to-text parsing
82
- │ ├── llm_helper.py # LLM client, model selection, prompt generation
83
- │ ├── project_helper.py # Markdown context collection + framework detection
84
- └── config_helper.py # .env management + credential validation
100
+ │ ├── jira.py # Jira API connection + ADF-to-text parsing
101
+ │ ├── gitlab.py # GitLab API connection via python-gitlab
102
+ │ ├── claude.py # Claude Code CLI utilities (agent listing)
103
+ ├── llm.py # LLM client, model selection, prompt generation
104
+ │ ├── project.py # Markdown context collection + framework detection
105
+ │ └── config.py # .env management + credential validation
85
106
  └── constants/
86
107
  └── frameworks.py # Known framework names used for auto-detection
87
108
  ```
@@ -12,15 +12,17 @@ src/bob_dev.egg-info/requires.txt
12
12
  src/bob_dev.egg-info/top_level.txt
13
13
  src/bob_dev/constants/__init__.py
14
14
  src/bob_dev/constants/frameworks.py
15
- src/bob_dev/helpers/__init__.py
16
- src/bob_dev/helpers/config_helper.py
17
- src/bob_dev/helpers/jira_helper.py
18
- src/bob_dev/helpers/llm_helper.py
19
- src/bob_dev/helpers/project_helper.py
20
- src/bob_dev/helpers/terminal.py
15
+ src/bob_dev/services/__init__.py
16
+ src/bob_dev/services/claude.py
17
+ src/bob_dev/services/config.py
18
+ src/bob_dev/services/gitlab.py
19
+ src/bob_dev/services/jira.py
20
+ src/bob_dev/services/llm.py
21
+ src/bob_dev/services/project.py
22
+ src/bob_dev/services/terminal.py
21
23
  tests/test_cli.py
22
- tests/test_config_helper.py
23
- tests/test_jira_helper.py
24
- tests/test_llm_helper.py
25
- tests/test_project_helper.py
24
+ tests/test_config.py
25
+ tests/test_jira.py
26
+ tests/test_llm.py
27
+ tests/test_project.py
26
28
  tests/test_terminal.py
@@ -1,4 +1,4 @@
1
- """Tests for bob_dev.helpers.config_helper."""
1
+ """Tests for bob_dev.services.config."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -6,7 +6,7 @@ import os
6
6
 
7
7
  import pytest
8
8
 
9
- from bob_dev.helpers.config_helper import update_env_file
9
+ from bob_dev.services.config import update_env_file
10
10
 
11
11
 
12
12
  class TestUpdateEnvFile:
@@ -1,4 +1,4 @@
1
- """Tests for bob_dev.helpers.jira_helper."""
1
+ """Tests for bob_dev.services.jira."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
6
6
 
7
7
  import pytest
8
8
 
9
- from bob_dev.helpers.jira_helper import _adf_to_text, get_jira_task
9
+ from bob_dev.services.jira import _adf_to_text, get_jira_task
10
10
 
11
11
 
12
12
  class TestAdfToText:
@@ -128,32 +128,32 @@ class TestGetJiraTask:
128
128
  }
129
129
  return mock_jira
130
130
 
131
- @patch("bob_dev.helpers.jira_helper.Jira")
131
+ @patch("bob_dev.services.jira.Jira")
132
132
  def test_returns_required_keys(self, mock_jira_cls):
133
133
  mock_jira_cls.return_value = self._build_mock_jira()
134
134
  result = get_jira_task("PROJ-1", "https://example.atlassian.net", "u@e.com", "tok")
135
135
  for key in ("task_id", "title", "description", "fix_versions"):
136
136
  assert key in result
137
137
 
138
- @patch("bob_dev.helpers.jira_helper.Jira")
138
+ @patch("bob_dev.services.jira.Jira")
139
139
  def test_task_id_preserved(self, mock_jira_cls):
140
140
  mock_jira_cls.return_value = self._build_mock_jira()
141
141
  result = get_jira_task("PROJ-42", "https://example.atlassian.net", "u@e.com", "tok")
142
142
  assert result["task_id"] == "PROJ-42"
143
143
 
144
- @patch("bob_dev.helpers.jira_helper.Jira")
144
+ @patch("bob_dev.services.jira.Jira")
145
145
  def test_title_extracted(self, mock_jira_cls):
146
146
  mock_jira_cls.return_value = self._build_mock_jira(summary="My Task Title")
147
147
  result = get_jira_task("PROJ-1", "https://example.atlassian.net", "u@e.com", "tok")
148
148
  assert result["title"] == "My Task Title"
149
149
 
150
- @patch("bob_dev.helpers.jira_helper.Jira")
150
+ @patch("bob_dev.services.jira.Jira")
151
151
  def test_plain_text_description_passed_through(self, mock_jira_cls):
152
152
  mock_jira_cls.return_value = self._build_mock_jira(description="Plain description")
153
153
  result = get_jira_task("PROJ-1", "https://example.atlassian.net", "u@e.com", "tok")
154
154
  assert result["description"] == "Plain description"
155
155
 
156
- @patch("bob_dev.helpers.jira_helper.Jira")
156
+ @patch("bob_dev.services.jira.Jira")
157
157
  def test_adf_description_parsed_to_text(self, mock_jira_cls):
158
158
  adf = {
159
159
  "type": "doc",
@@ -168,7 +168,7 @@ class TestGetJiraTask:
168
168
  result = get_jira_task("PROJ-1", "https://example.atlassian.net", "u@e.com", "tok")
169
169
  assert "ADF content" in result["description"]
170
170
 
171
- @patch("bob_dev.helpers.jira_helper.Jira")
171
+ @patch("bob_dev.services.jira.Jira")
172
172
  def test_fix_versions_extracted(self, mock_jira_cls):
173
173
  mock_jira_cls.return_value = self._build_mock_jira(
174
174
  fix_versions=[{"name": "v1.0"}, {"name": "v1.1"}]
@@ -176,13 +176,13 @@ class TestGetJiraTask:
176
176
  result = get_jira_task("PROJ-1", "https://example.atlassian.net", "u@e.com", "tok")
177
177
  assert result["fix_versions"] == ["v1.0", "v1.1"]
178
178
 
179
- @patch("bob_dev.helpers.jira_helper.Jira")
179
+ @patch("bob_dev.services.jira.Jira")
180
180
  def test_empty_fix_versions(self, mock_jira_cls):
181
181
  mock_jira_cls.return_value = self._build_mock_jira(fix_versions=[])
182
182
  result = get_jira_task("PROJ-1", "https://example.atlassian.net", "u@e.com", "tok")
183
183
  assert result["fix_versions"] == []
184
184
 
185
- @patch("bob_dev.helpers.jira_helper.Jira")
185
+ @patch("bob_dev.services.jira.Jira")
186
186
  def test_none_description_becomes_empty_string(self, mock_jira_cls):
187
187
  mock_jira = MagicMock()
188
188
  mock_jira.issue.return_value = {
@@ -196,7 +196,7 @@ class TestGetJiraTask:
196
196
  result = get_jira_task("PROJ-1", "https://example.atlassian.net", "u@e.com", "tok")
197
197
  assert result["description"] == ""
198
198
 
199
- @patch("bob_dev.helpers.jira_helper.Jira")
199
+ @patch("bob_dev.services.jira.Jira")
200
200
  def test_jira_instantiated_with_cloud_true(self, mock_jira_cls):
201
201
  mock_jira_cls.return_value = self._build_mock_jira()
202
202
  get_jira_task("PROJ-1", "https://org.atlassian.net", "user@test.com", "token123")
@@ -1,4 +1,4 @@
1
- """Tests for bob_dev.helpers.llm_helper."""
1
+ """Tests for bob_dev.services.llm."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
6
6
 
7
7
  import pytest
8
8
 
9
- from bob_dev.helpers.llm_helper import (
9
+ from bob_dev.services.llm import (
10
10
  analyse_prompt,
11
11
  build_llm_client,
12
12
  llm_model,
@@ -35,25 +35,25 @@ class TestBuildLlmClient:
35
35
  with pytest.raises(EnvironmentError, match="OPENAI_API_KEY"):
36
36
  build_llm_client("OPENAI", "", "")
37
37
 
38
- @patch("bob_dev.helpers.llm_helper.OpenAI")
38
+ @patch("bob_dev.services.llm.OpenAI")
39
39
  def test_grok_sets_xai_base_url(self, mock_openai_cls):
40
40
  build_llm_client("GROK", "grok-key-123", "")
41
41
  _, kwargs = mock_openai_cls.call_args
42
42
  assert "x.ai" in kwargs.get("base_url", "")
43
43
 
44
- @patch("bob_dev.helpers.llm_helper.OpenAI")
44
+ @patch("bob_dev.services.llm.OpenAI")
45
45
  def test_grok_passes_api_key(self, mock_openai_cls):
46
46
  build_llm_client("GROK", "my-grok-key", "")
47
47
  _, kwargs = mock_openai_cls.call_args
48
48
  assert kwargs.get("api_key") == "my-grok-key"
49
49
 
50
- @patch("bob_dev.helpers.llm_helper.OpenAI")
50
+ @patch("bob_dev.services.llm.OpenAI")
51
51
  def test_openai_no_base_url(self, mock_openai_cls):
52
52
  build_llm_client("OPENAI", "", "openai-key-123")
53
53
  _, kwargs = mock_openai_cls.call_args
54
54
  assert "base_url" not in kwargs
55
55
 
56
- @patch("bob_dev.helpers.llm_helper.OpenAI")
56
+ @patch("bob_dev.services.llm.OpenAI")
57
57
  def test_openai_passes_api_key(self, mock_openai_cls):
58
58
  build_llm_client("OPENAI", "", "openai-secret")
59
59
  _, kwargs = mock_openai_cls.call_args
@@ -70,7 +70,7 @@ def _make_mock_client(content: str) -> MagicMock:
70
70
 
71
71
 
72
72
  class TestPromptClaudeCode:
73
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
73
+ @patch("bob_dev.services.llm.build_llm_client")
74
74
  def test_returns_llm_content(self, mock_build):
75
75
  mock_build.return_value = _make_mock_client("My generated prompt")
76
76
  result = prompt_claude_code(
@@ -83,20 +83,20 @@ class TestPromptClaudeCode:
83
83
  )
84
84
  assert result == "My generated prompt"
85
85
 
86
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
86
+ @patch("bob_dev.services.llm.build_llm_client")
87
87
  def test_returns_empty_string_when_content_is_none(self, mock_build):
88
88
  mock_build.return_value = _make_mock_client(None)
89
89
  result = prompt_claude_code("AC", "docs", "Django", "GROK", "key", "")
90
90
  assert result == ""
91
91
 
92
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
92
+ @patch("bob_dev.services.llm.build_llm_client")
93
93
  def test_calls_create_with_temperature(self, mock_build):
94
94
  mock_build.return_value = _make_mock_client("ok")
95
95
  prompt_claude_code("AC", "docs", "FastAPI", "OPENAI", "", "key")
96
96
  create_kwargs = mock_build.return_value.chat.completions.create.call_args[1]
97
97
  assert "temperature" in create_kwargs
98
98
 
99
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
99
+ @patch("bob_dev.services.llm.build_llm_client")
100
100
  def test_task_meta_injected_into_user_message(self, mock_build):
101
101
  mock_client = _make_mock_client("prompt with meta")
102
102
  mock_build.return_value = mock_client
@@ -115,14 +115,14 @@ class TestPromptClaudeCode:
115
115
  assert "PROJ-1" in user_content
116
116
  assert "Test task" in user_content
117
117
 
118
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
118
+ @patch("bob_dev.services.llm.build_llm_client")
119
119
  def test_task_meta_none_does_not_raise(self, mock_build):
120
120
  mock_build.return_value = _make_mock_client("ok")
121
121
  # Should not raise when task_meta is omitted.
122
122
  result = prompt_claude_code("AC", "docs", "Django", "GROK", "key", "")
123
123
  assert isinstance(result, str)
124
124
 
125
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
125
+ @patch("bob_dev.services.llm.build_llm_client")
126
126
  def test_framework_included_in_system_prompt(self, mock_build):
127
127
  mock_client = _make_mock_client("ok")
128
128
  mock_build.return_value = mock_client
@@ -133,19 +133,19 @@ class TestPromptClaudeCode:
133
133
 
134
134
 
135
135
  class TestAnalysePrompt:
136
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
136
+ @patch("bob_dev.services.llm.build_llm_client")
137
137
  def test_returns_analysis_string(self, mock_build):
138
138
  mock_build.return_value = _make_mock_client("- No issues found")
139
139
  result = analyse_prompt("prompt md", "AC text", "GROK", "key", "")
140
140
  assert result == "- No issues found"
141
141
 
142
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
142
+ @patch("bob_dev.services.llm.build_llm_client")
143
143
  def test_returns_empty_string_when_content_is_none(self, mock_build):
144
144
  mock_build.return_value = _make_mock_client(None)
145
145
  result = analyse_prompt("prompt", "AC", "GROK", "key", "")
146
146
  assert result == ""
147
147
 
148
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
148
+ @patch("bob_dev.services.llm.build_llm_client")
149
149
  def test_passes_prompt_and_ac_to_user_message(self, mock_build):
150
150
  mock_client = _make_mock_client("analysis")
151
151
  mock_build.return_value = mock_client
@@ -155,7 +155,7 @@ class TestAnalysePrompt:
155
155
  assert "the prompt content" in user_content
156
156
  assert "the AC content" in user_content
157
157
 
158
- @patch("bob_dev.helpers.llm_helper.build_llm_client")
158
+ @patch("bob_dev.services.llm.build_llm_client")
159
159
  def test_calls_create_with_temperature(self, mock_build):
160
160
  mock_build.return_value = _make_mock_client("ok")
161
161
  analyse_prompt("p", "ac", "GROK", "key", "")
@@ -1,4 +1,4 @@
1
- """Tests for bob_dev.helpers.project_helper."""
1
+ """Tests for bob_dev.services.project."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -7,7 +7,7 @@ from unittest.mock import patch
7
7
 
8
8
  import pytest
9
9
 
10
- from bob_dev.helpers.project_helper import (
10
+ from bob_dev.services.project import (
11
11
  build_md_context,
12
12
  collect_md_context,
13
13
  identify_framework,
@@ -102,14 +102,14 @@ class TestBuildMdContext:
102
102
  result = build_md_context(tmp_path)
103
103
  assert "API documentation" in result
104
104
 
105
- @patch("bob_dev.helpers.project_helper.summarize")
105
+ @patch("bob_dev.services.project.summarize")
106
106
  def test_readme_summary_included(self, mock_summarize, tmp_path):
107
107
  (tmp_path / "README.md").write_text("# Django REST Framework")
108
108
  mock_summarize.return_value = "Summary text"
109
109
  result = build_md_context(tmp_path)
110
110
  assert "Summary text" in result
111
111
 
112
- @patch("bob_dev.helpers.project_helper.summarize")
112
+ @patch("bob_dev.services.project.summarize")
113
113
  def test_summarize_called_with_readme_content(self, mock_summarize, tmp_path):
114
114
  (tmp_path / "README.md").write_text("readme body")
115
115
  mock_summarize.return_value = ""
@@ -119,7 +119,7 @@ class TestBuildMdContext:
119
119
  assert "readme body" in args[0]
120
120
 
121
121
  def test_empty_readme_does_not_call_summarize(self, tmp_path):
122
- with patch("bob_dev.helpers.project_helper.summarize") as mock_summarize:
122
+ with patch("bob_dev.services.project.summarize") as mock_summarize:
123
123
  build_md_context(tmp_path)
124
124
  mock_summarize.assert_not_called()
125
125
 
@@ -1,4 +1,4 @@
1
- """Tests for bob_dev.helpers.terminal."""
1
+ """Tests for bob_dev.services.terminal."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -6,7 +6,7 @@ import asyncio
6
6
 
7
7
  import pytest
8
8
 
9
- from bob_dev.helpers.terminal import (
9
+ from bob_dev.services.terminal import (
10
10
  BOLD,
11
11
  GREEN,
12
12
  RED,
File without changes
File without changes
File without changes
File without changes
File without changes