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.
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/client.py +1 -1
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/git_ops.py +58 -14
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/ipc/handlers.py +202 -8
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/README.md +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/run_auto.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.8}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devops-bot-sdk"
|
|
7
|
-
version = "1.4.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
351
|
-
label = f"Local claude [{name}]: {tname}" + (f" → {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|