devops-bot-sdk 1.4.6__tar.gz → 1.4.8__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 (38) hide show
  1. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/client.py +1 -1
  6. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/git_ops.py +58 -14
  7. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/ipc/handlers.py +202 -8
  8. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/README.md +0 -0
  9. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  10. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  11. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  12. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/requires.txt +0 -0
  13. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  14. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/collectors/__init__.py +0 -0
  15. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/collectors/files.py +0 -0
  16. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/collectors/process.py +0 -0
  17. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/collectors/screenshot.py +0 -0
  18. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/config.py +0 -0
  19. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/crucial.py +0 -0
  20. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/exceptions.py +0 -0
  21. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/local_exec.py +0 -0
  27. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.6
3
+ Version: 1.4.8
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.6
3
+ Version: 1.4.8
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.6"
7
+ version = "1.4.8"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.6
3
+ Version: 1.4.8
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.6"
33
+ __version__ = "1.4.8"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -36,7 +36,7 @@ from sdk.sse import _check_status, stream_with_reconnect
36
36
 
37
37
  logger = logging.getLogger(__name__)
38
38
 
39
- SDK_VERSION = "1.4.6"
39
+ SDK_VERSION = "1.4.8"
40
40
  _POLL_INTERVAL = 3.0
41
41
  _POLL_TIMEOUT = 600.0
42
42
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -143,34 +143,78 @@ async def ensure_repo(
143
143
 
144
144
 
145
145
  async def start_branch(
146
- project_path: str, jira_key: str | None, summary: str | None, github_token: str | None,
146
+ project_path: str,
147
+ jira_key: str | None,
148
+ summary: str | None,
149
+ github_token: str | None,
150
+ base: str | None = None,
147
151
  ) -> dict | None:
148
- """Create + check out `feature/<jira_key>-<slug>` off the default branch.
152
+ """Create + check out `feature/<jira_key>-<slug>` off the resolved base.
153
+
154
+ Base resolution (per repo):
155
+ - `base` given (named in the Jira humanInstructions, possibly per-repo) →
156
+ branch off the FRESH `origin/<base>` (fetched), falling back to a local
157
+ `<base>` ref. This is the explicit-override path.
158
+ - `base` omitted → the repo's CURRENT checked-out branch (the branch the
159
+ user is actually working on); the feature branch is cut from local HEAD
160
+ so the user's working state is preserved. If HEAD is detached or already
161
+ one of OUR `feature/*` branches (a stale checkout from a prior re-run in
162
+ a shared working tree), fall back to `detect_default_branch` so we never
163
+ chain a feature branch onto another feature branch.
149
164
 
150
165
  Returns {"branch", "base"} or None when the path isn't a git repo (e.g. a
151
- fresh scratch dir) or the checkout failed.
166
+ fresh scratch dir) or the checkout failed. The returned `base` is the branch
167
+ the feature was actually cut from — `finish_pr` uses it as the PR base.
152
168
  """
153
169
  path = str(Path(project_path).expanduser())
154
170
  env = _git_env(github_token)
155
171
  if not await _is_git_repo(path, env):
156
172
  return None
157
- # Fetch first so origin/HEAD and origin/main refs are present/fresh before we
158
- # detect the base (avoids resolving the base to the current feature branch).
159
- await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
160
- base = await detect_default_branch(path, env)
173
+
161
174
  key = slugify(jira_key or "task", max_len=24)
162
175
  branch = f"feature/{key}-{slugify(summary)}"
163
- if base == branch:
164
- # Defensive: never base a PR on the branch we're about to create.
165
- base = "main"
166
- # Cut the feature branch from the FRESH remote base so it never chains off a
167
- # previous ticket's feature branch left checked out in a shared working tree.
168
- rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{base}"], path, env)
176
+ # Fetch so any remote base ref (origin/HEAD, origin/<base>) is present/fresh.
177
+ await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
178
+
179
+ explicit = (base or "").strip()
180
+ if explicit:
181
+ # Explicit base from instructions branch off the FRESH remote tip
182
+ # (satisfies "pull latest from <base>, then branch"). Fall back to a
183
+ # local ref of that name, then to a bare branch, if the remote lacks it.
184
+ await _run(["git", "fetch", "origin", explicit], path, env, timeout=120.0)
185
+ resolved = "main" if explicit == branch else explicit
186
+ rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{resolved}"], path, env)
187
+ if rc != 0:
188
+ rc, _, _ = await _run(["git", "checkout", "-B", branch, resolved], path, env)
189
+ if rc != 0:
190
+ rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
191
+ if rc != 0:
192
+ return None
193
+ logger.info("git_ops.branch | %s ← explicit base %s", branch, resolved)
194
+ return {"branch": branch, "base": resolved}
195
+
196
+ # Default: base = the CURRENT checked-out branch (the user's working branch).
197
+ rc, cur, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
198
+ cur = cur.strip() if rc == 0 else ""
199
+ if cur and cur != "HEAD" and not cur.startswith("feature/"):
200
+ # Cut from local HEAD — preserve the user's working state, do not pull.
201
+ rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
202
+ if rc != 0:
203
+ return None
204
+ logger.info("git_ops.branch | %s ← current branch %s", branch, cur)
205
+ return {"branch": branch, "base": cur}
206
+
207
+ # HEAD detached or a stale feature/* checkout → fall back to repo default.
208
+ resolved = await detect_default_branch(path, env)
209
+ if resolved == branch:
210
+ resolved = "main"
211
+ rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{resolved}"], path, env)
169
212
  if rc != 0:
170
213
  rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
171
214
  if rc != 0:
172
215
  return None
173
- return {"branch": branch, "base": base}
216
+ logger.info("git_ops.branch | %s ← default base %s (HEAD was %r)", branch, resolved, cur or "detached")
217
+ return {"branch": branch, "base": resolved}
174
218
 
175
219
 
176
220
  async def _remote_owner_repo(path: str, env: dict) -> tuple[str, str] | None:
@@ -117,6 +117,32 @@ _POLL_INTERVAL_S = 1.5
117
117
  _PLAN_DEADLINE_S = 180.0 # planning + approval gate should be quick
118
118
 
119
119
 
120
+ # Raw task-description budget (chars). The structured plan already distills the
121
+ # task into acceptance criteria + steps, so the raw description is REFERENCE: a
122
+ # very large one is bounded so it cannot bury the operator instructions or the
123
+ # plan (the "follows my instructions sometimes, not others" failure mode). ~6k
124
+ # tokens at ~4 chars/token.
125
+ _DESC_MAX_CHARS = 24_000
126
+
127
+
128
+ def _clip_text(text: str, max_chars: int = _DESC_MAX_CHARS) -> str:
129
+ """Bound a reference string to a char budget, keeping head + tail.
130
+
131
+ Pure-python (no tiktoken in the thin SDK). Keeps the start and end and elides
132
+ the middle with a marker pointing back at the distilled plan.
133
+ """
134
+ if not text or len(text) <= max_chars:
135
+ return text
136
+ head = max_chars // 2
137
+ tail = max_chars - head
138
+ elided = len(text) - head - tail
139
+ return (
140
+ f"{text[:head]}\n\n[... {elided} chars elided — the acceptance criteria "
141
+ f"and implementation steps above are the distilled, authoritative "
142
+ f"requirements ...]\n\n{text[-tail:]}"
143
+ )
144
+
145
+
120
146
  def _build_local_prompt(
121
147
  user_input: str | None,
122
148
  structured_plan: dict | None,
@@ -125,6 +151,12 @@ def _build_local_prompt(
125
151
  ) -> str:
126
152
  """Compose the coding prompt for the local claude CLI from the server plan.
127
153
 
154
+ Prompt order matters: a model attends most to the start (and end) of a long
155
+ prompt, so the MUST-FOLLOW operator instructions LEAD, the distilled plan
156
+ (acceptance criteria + steps) comes next, and the raw task description is
157
+ DEMOTED to bounded reference material at the end — never first, never
158
+ unbounded. This stops a large Jira description from burying the instructions.
159
+
128
160
  `user_input` may be empty when the desktop relied on backend hydration
129
161
  (jira_task_id-only run) — the structured plan then carries the task detail.
130
162
 
@@ -135,6 +167,7 @@ def _build_local_prompt(
135
167
  locally itself.
136
168
  """
137
169
  parts: list[str] = []
170
+ # 1. Operator instructions — FIRST, verbatim, authoritative.
138
171
  if human_instructions and human_instructions.strip():
139
172
  parts.append(
140
173
  "=== Operator instructions (Jira humanInstructions) — AUTHORITATIVE ===\n"
@@ -149,8 +182,8 @@ def _build_local_prompt(
149
182
  "before changing it.\n\n"
150
183
  + human_instructions.strip()
151
184
  )
152
- if user_input and user_input.strip():
153
- parts.append(user_input.strip())
185
+ # 2. Distilled plan (acceptance criteria + steps) — the compact, actionable
186
+ # content the backend planner derived from the full description.
154
187
  sp = structured_plan or {}
155
188
  criteria = sp.get("acceptance_criteria") or []
156
189
  steps = sp.get("task_steps") or []
@@ -163,6 +196,13 @@ def _build_local_prompt(
163
196
  lines.append(f"- {s.get('title', '')}: {s.get('description', '')}".rstrip(": "))
164
197
  if lines:
165
198
  parts.append("Implementation steps:\n" + "\n".join(lines))
199
+ # 3. Raw task description — DEMOTED to bounded reference (plan above wins).
200
+ if user_input and user_input.strip():
201
+ parts.append(
202
+ "=== Task description (reference — the operator instructions and plan "
203
+ "above are authoritative if anything conflicts) ===\n"
204
+ + _clip_text(user_input.strip())
205
+ )
166
206
  parts.append(
167
207
  "Implement all of the above in the current project directory. "
168
208
  "Create and edit every file needed for a complete, working result.\n"
@@ -313,6 +353,45 @@ async def _run_local_pipeline(
313
353
  await send(_error_envelope(thread_id, RuntimeError("Planning timed out before approval gate.")).model_dump())
314
354
 
315
355
 
356
+ def _tool_detail(tool_name: str, inp: dict) -> str:
357
+ """A short, human-readable detail for a tool_use event, for the step timeline.
358
+
359
+ Turns a bare "Bash" into the actual command, "Read" into the file, "Grep"
360
+ into the pattern, etc. — so the progress log says what the agent is doing.
361
+ """
362
+ inp = inp or {}
363
+
364
+ def _short(s, n=140):
365
+ return " ".join(str(s).split())[:n]
366
+
367
+ if tool_name == "Bash":
368
+ # Prefer claude's own one-line description, then the command itself.
369
+ return _short(inp.get("description") or inp.get("command") or "")
370
+ if tool_name in ("Read", "Write", "Edit", "MultiEdit", "NotebookEdit"):
371
+ fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
372
+ return fp.split("/")[-1] if fp else ""
373
+ if tool_name == "Grep":
374
+ pat = inp.get("pattern", "")
375
+ where = inp.get("path") or inp.get("glob") or ""
376
+ return _short(f"{pat}" + (f" in {where}" if where else ""))
377
+ if tool_name == "Glob":
378
+ return _short(inp.get("pattern", ""))
379
+ if tool_name in ("WebFetch", "WebSearch"):
380
+ return _short(inp.get("url") or inp.get("query") or "")
381
+ if tool_name == "Task":
382
+ return _short(inp.get("description") or inp.get("subagent_type") or "")
383
+ if tool_name == "TodoWrite":
384
+ todos = inp.get("todos") or []
385
+ active = next((t.get("content") for t in todos if t.get("status") == "in_progress"), None)
386
+ return _short(active) if active else f"{len(todos)} item(s)"
387
+ # Generic fallback: first useful string field.
388
+ for k in ("description", "prompt", "query", "command", "path", "url", "file_path"):
389
+ v = inp.get(k)
390
+ if isinstance(v, str) and v.strip():
391
+ return _short(v)
392
+ return ""
393
+
394
+
316
395
  async def _code_one_repo(
317
396
  client: BackendClient,
318
397
  *,
@@ -326,6 +405,7 @@ async def _code_one_repo(
326
405
  send: Callable,
327
406
  thread_id: str,
328
407
  task_id: str,
408
+ base: str | None = None,
329
409
  ) -> dict:
330
410
  """Clone-if-needed → branch → run claude → open a PR, for ONE repo.
331
411
 
@@ -347,8 +427,8 @@ async def _code_one_repo(
347
427
  for c in event.get("message", {}).get("content", []):
348
428
  if c.get("type") == "tool_use":
349
429
  tname = c.get("name", "tool")
350
- fp = (c.get("input", {}) or {}).get("file_path", "")
351
- label = f"Local claude [{name}]: {tname}" + (f" → {fp.split('/')[-1]}" if fp else "")
430
+ detail = _tool_detail(tname, c.get("input") or {})
431
+ label = f"Local claude [{name}]: {tname}" + (f" → {detail}" if detail else "")
352
432
  await send(Envelope(
353
433
  type="step_update", thread_id=thread_id,
354
434
  data={"status": "running", "current_step": label},
@@ -371,11 +451,13 @@ async def _code_one_repo(
371
451
  except Exception: # noqa: BLE001
372
452
  pass
373
453
 
374
- # 2. feature branch off the repo's real default
454
+ # 2. feature branch off the resolved base (explicit base from instructions,
455
+ # else the repo's current checked-out branch — see git_ops.start_branch).
375
456
  branch_info = None
376
457
  try:
377
458
  branch_info = await git_ops.start_branch(
378
459
  repo_path, pending.get("jira_key"), pending.get("summary"), gh_token,
460
+ base=base,
379
461
  )
380
462
  if branch_info:
381
463
  await send(Envelope(
@@ -574,7 +656,11 @@ async def handle_pipeline_approve(
574
656
  # Branch + code + PR each independently; fall back to the single resolved path.
575
657
  targets = _extract_repo_targets(pending.get("human_instructions"))
576
658
  if not targets:
577
- targets = [{"path": pending["project_path"], "url": None}]
659
+ # No repos named in the instructions → the single resolved project path.
660
+ # It still honours a global base mentioned in the instructions; None →
661
+ # start_branch falls back to the repo's current checked-out branch.
662
+ _, _global_base = _extract_base_branches(pending.get("human_instructions"))
663
+ targets = [{"path": pending["project_path"], "url": None, "base": _global_base}]
578
664
  await send(Envelope(
579
665
  type="step_update", thread_id=thread_id,
580
666
  data={"current_step": f"{len(targets)} repo(s) to update: "
@@ -587,6 +673,7 @@ async def handle_pipeline_approve(
587
673
  try:
588
674
  runs.append(await _code_one_repo(
589
675
  client, repo_path=tgt["path"], repo_url=tgt.get("url"),
676
+ base=tgt.get("base"),
590
677
  pending=pending, prompt=prompt, gh_token=gh_token,
591
678
  agent_mode=agent_mode, approval_dir=approval_dir,
592
679
  send=send, thread_id=thread_id, task_id=task_id,
@@ -1171,13 +1258,113 @@ def _extract_all_paths(text: str | None) -> list[str]:
1171
1258
  return out
1172
1259
 
1173
1260
 
1261
+ # Base-branch directives in free-text humanInstructions. Order matters — the
1262
+ # more explicit "branch from X" / "base branch: X" forms are tried before the
1263
+ # looser "X branch" form so "create a feature branch from main" resolves to
1264
+ # `main`, not `feature`. Noise words are filtered out so "the/new/feature/this
1265
+ # branch" never resolves to a base.
1266
+ import re as _re # noqa: E402
1267
+
1268
+ _BASE_NOISE = {
1269
+ "the", "a", "an", "this", "that", "new", "feature", "features",
1270
+ "your", "same", "current", "default", "its", "please", "create",
1271
+ }
1272
+ _BASE_DIRECTIVE_RES = [
1273
+ # "branch from/off <base>" — most explicit, tried first. Greedy so a full
1274
+ # name like "release/2.0" is captured, not truncated to "release".
1275
+ _re.compile(r"branch(?:es|ed|ing)?\s+(?:from|off(?:\s+of)?)\s+(?:the\s+)?[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?(?:\s+branch)?\b", _re.I),
1276
+ # "base branch: <base>" / "base is <base>"
1277
+ _re.compile(r"base(?:\s+branch)?\s*(?:[:=]|\bis\b|\bshould\s+be\b)\s*[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?\b", _re.I),
1278
+ # "from the <base> branch"
1279
+ _re.compile(r"from\s+(?:the\s+)?[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?\s+branch\b", _re.I),
1280
+ # loose "<base> branch" (e.g. "main branch") — last, most permissive.
1281
+ _re.compile(r"[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?\s+branch\b", _re.I),
1282
+ ]
1283
+
1284
+ # Repo-role keywords so a base named alongside "FE"/"backend"/… maps to the
1285
+ # matching target even when the path basename doesn't literally contain it.
1286
+ _REPO_ROLE_KEYWORDS = (
1287
+ "frontend", "front-end", "fe", "backend", "back-end", "be", "desktop",
1288
+ "mobile", "api", "web", "server", "client",
1289
+ )
1290
+
1291
+
1292
+ def _find_base_in(text: str | None) -> str | None:
1293
+ """First plausible base-branch name in `text`, or None. Filters noise words."""
1294
+ if not text:
1295
+ return None
1296
+ for rx in _BASE_DIRECTIVE_RES:
1297
+ for m in rx.finditer(text):
1298
+ cand = (m.group(1) or "").strip().strip("./")
1299
+ if cand and cand.lower() not in _BASE_NOISE and not cand.startswith("feature/"):
1300
+ return cand
1301
+ return None
1302
+
1303
+
1304
+ def _extract_base_branches(text: str | None) -> tuple[dict[str, str], str | None]:
1305
+ """Parse base-branch directives from humanInstructions.
1306
+
1307
+ Returns (per_repo_bases, global_base):
1308
+ - per_repo_bases: {repo_token: base} where repo_token is a repo name /
1309
+ path basename / role keyword (fe/be/desktop/…) that appeared on the SAME
1310
+ line as a base directive — supports "FE from develop, BE from main".
1311
+ - global_base: a base directive found anywhere (applies to any repo with no
1312
+ more-specific per-repo base). None when no base is mentioned at all.
1313
+ """
1314
+ if not text:
1315
+ return {}, None
1316
+ from pathlib import Path
1317
+
1318
+ def _repo_name(u: str) -> str:
1319
+ n = u.rstrip("/").rsplit("/", 1)[-1]
1320
+ return (n[:-4] if n.lower().endswith(".git") else n).lower()
1321
+
1322
+ per_repo: dict[str, str] = {}
1323
+ for line in text.splitlines():
1324
+ b = _find_base_in(line)
1325
+ if not b:
1326
+ continue
1327
+ tokens: set[str] = set()
1328
+ for u in _GITHUB_URL_RE.findall(line):
1329
+ tokens.add(_repo_name(u))
1330
+ for p in _extract_all_paths(line):
1331
+ tokens.add(Path(p).name.lower())
1332
+ low = line.lower()
1333
+ for kw in _REPO_ROLE_KEYWORDS:
1334
+ if _re.search(rf"\b{_re.escape(kw)}\b", low):
1335
+ tokens.add(kw)
1336
+ for t in tokens:
1337
+ per_repo.setdefault(t, b)
1338
+ return per_repo, _find_base_in(text)
1339
+
1340
+
1341
+ def _resolve_base_for(name: str, url: str | None,
1342
+ per_repo: dict[str, str], global_base: str | None) -> str | None:
1343
+ """Pick the base for one repo: per-repo match (name / url / role keyword) → global."""
1344
+ candidates: list[str] = [name.lower()]
1345
+ if url:
1346
+ n = url.rstrip("/").rsplit("/", 1)[-1]
1347
+ candidates.append((n[:-4] if n.lower().endswith(".git") else n).lower())
1348
+ low = name.lower()
1349
+ for kw in _REPO_ROLE_KEYWORDS:
1350
+ if kw in low:
1351
+ candidates.append(kw)
1352
+ for c in candidates:
1353
+ if c in per_repo:
1354
+ return per_repo[c]
1355
+ return global_base
1356
+
1357
+
1174
1358
  def _extract_repo_targets(text: str | None) -> list[dict]:
1175
- """All repo work-targets named in `text`: ``[{"path", "url"}]``.
1359
+ """All repo work-targets named in `text`: ``[{"path", "url", "base"}]``.
1176
1360
 
1177
1361
  A single ticket may name several repos (e.g. FE + BE). Each local path is
1178
1362
  paired with a GitHub URL whose repo name matches the path's last segment
1179
1363
  (FE path ↔ FE url, BE path ↔ BE url) so a missing folder can be cloned. A
1180
1364
  path with no matching URL still appears (url=None) — it must already exist.
1365
+ Each target also carries its resolved `base` branch (per-repo override from
1366
+ the instructions, else the global mentioned base, else None → the SDK uses
1367
+ the repo's current checked-out branch).
1181
1368
  """
1182
1369
  if not text:
1183
1370
  return []
@@ -1187,10 +1374,17 @@ def _extract_repo_targets(text: str | None) -> list[dict]:
1187
1374
  n = u.rstrip("/").rsplit("/", 1)[-1]
1188
1375
  return (n[:-4] if n.lower().endswith(".git") else n).lower()
1189
1376
 
1377
+ per_repo_bases, global_base = _extract_base_branches(text)
1190
1378
  by_name = {_repo_name(u): u for u in _GITHUB_URL_RE.findall(text)}
1191
1379
  targets: list[dict] = []
1192
1380
  for p in _extract_all_paths(text):
1193
- targets.append({"path": p, "url": by_name.get(Path(p).name.lower())})
1381
+ name = Path(p).name
1382
+ url = by_name.get(name.lower())
1383
+ targets.append({
1384
+ "path": p,
1385
+ "url": url,
1386
+ "base": _resolve_base_for(name, url, per_repo_bases, global_base),
1387
+ })
1194
1388
  return targets
1195
1389
 
1196
1390
 
File without changes
File without changes