devin-cli 1.3.3__tar.gz → 1.3.5__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.
- {devin_cli-1.3.3 → devin_cli-1.3.5}/CHANGELOG.md +5 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/PKG-INFO +1 -1
- {devin_cli-1.3.3 → devin_cli-1.3.5}/pyproject.toml +1 -1
- devin_cli-1.3.5/src/devin_cli/__init__.py +1 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/schedules.py +7 -7
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/cli.py +322 -14
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_api/test_schedules.py +2 -1
- devin_cli-1.3.5/tests/test_cli_extended.py +121 -0
- devin_cli-1.3.3/src/devin_cli/__init__.py +0 -1
- {devin_cli-1.3.3 → devin_cli-1.3.5}/.github/workflows/pypi-publish.yml +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/.gitignore +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/LICENSE +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/README.md +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/assets/logo.png +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/generate_facades.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/setup.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/__init__.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/attachments.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/client.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/consumption.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/knowledge.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/members.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/organizations.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/playbooks.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/repositories.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/schedules.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/secrets.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/sessions.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v1/__init__.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v1/attachments.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v1/knowledge.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v1/playbooks.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v1/secrets.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v1/sessions.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/__init__.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/attachments.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/consumption.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/knowledge.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/members.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/organizations.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/playbooks.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/repositories.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/secrets.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/api/v3/sessions.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/src/devin_cli/config.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/__init__.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/conftest.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_api/test_attachments.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_api/test_knowledge.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_api/test_playbooks.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_api/test_repositories.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_api/test_secrets.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_api/test_sessions.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_cli.py +0 -0
- {devin_cli-1.3.3 → devin_cli-1.3.5}/tests/test_config.py +0 -0
|
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.3.4] - 2026-05-03
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Scheduled Sessions API**: Updated the `schedules create` and `schedules update` commands to map the old `cron` and `title` fields to the new `frequency` and `name` properties required by the API. Added `schedule_type="recurring"` and fixed the update method to correctly use `PATCH`.
|
|
11
|
+
- CLI commands now properly read `name`, `frequency`, and `scheduled_session_id` from the API response instead of erroring out.
|
|
12
|
+
|
|
8
13
|
## [1.3.3] - 2026-04-21
|
|
9
14
|
### Fixed
|
|
10
15
|
- **ModuleNotFoundError: requests**: Replaced the leftover `requests` import and exception handling in `cli.py` with `httpx` to match project dependencies. This fixes installation errors via Homebrew and pipx.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devin-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.5
|
|
4
4
|
Summary: Unofficial CLI for Devin AI - The first AI Software Engineer
|
|
5
5
|
Project-URL: Homepage, https://github.com/revanthpobala/devin-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/revanthpobala/devin-cli.git
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.5"
|
|
@@ -19,12 +19,12 @@ def create_schedule(
|
|
|
19
19
|
):
|
|
20
20
|
data = {
|
|
21
21
|
"prompt": prompt,
|
|
22
|
-
"
|
|
22
|
+
"frequency": cron,
|
|
23
|
+
"schedule_type": "recurring",
|
|
24
|
+
"agent": "advanced" if advanced_mode else "devin",
|
|
23
25
|
}
|
|
24
26
|
if title:
|
|
25
|
-
data["
|
|
26
|
-
if advanced_mode:
|
|
27
|
-
data["advanced_mode"] = advanced_mode
|
|
27
|
+
data["name"] = title
|
|
28
28
|
if playbook_id:
|
|
29
29
|
data["playbook_id"] = playbook_id
|
|
30
30
|
if repos:
|
|
@@ -48,13 +48,13 @@ def update_schedule(
|
|
|
48
48
|
if prompt:
|
|
49
49
|
data["prompt"] = prompt
|
|
50
50
|
if cron:
|
|
51
|
-
data["
|
|
51
|
+
data["frequency"] = cron
|
|
52
52
|
if title:
|
|
53
|
-
data["
|
|
53
|
+
data["name"] = title
|
|
54
54
|
if enabled is not None:
|
|
55
55
|
data["enabled"] = enabled
|
|
56
56
|
|
|
57
|
-
return client.
|
|
57
|
+
return client.patch(f"schedules/{schedule_id}", json=data)
|
|
58
58
|
|
|
59
59
|
def delete_schedule(schedule_id: str):
|
|
60
60
|
return client.delete(f"schedules/{schedule_id}")
|
|
@@ -311,6 +311,20 @@ def create_session_cmd(
|
|
|
311
311
|
console.print("[yellow]Session created, but no ID returned immediately (awaiting advanced mode setup).[/yellow]")
|
|
312
312
|
|
|
313
313
|
return resp
|
|
314
|
+
def get_stable_terminal_state(status_enum: Optional[str]) -> str:
|
|
315
|
+
if not status_enum:
|
|
316
|
+
return "running"
|
|
317
|
+
status = status_enum.lower()
|
|
318
|
+
if status == "finished":
|
|
319
|
+
return "completed"
|
|
320
|
+
elif status in ("failed", "error"):
|
|
321
|
+
return "failed"
|
|
322
|
+
elif status in ("stopped", "cancelled", "terminated"):
|
|
323
|
+
return "terminated"
|
|
324
|
+
elif status == "blocked":
|
|
325
|
+
return "blocked"
|
|
326
|
+
else:
|
|
327
|
+
return "running"
|
|
314
328
|
|
|
315
329
|
|
|
316
330
|
@session_app.command("list")
|
|
@@ -325,6 +339,10 @@ def list_sessions_cmd(
|
|
|
325
339
|
resp = sessions.list_sessions(limit=limit)
|
|
326
340
|
sess_list = resp.get("items", resp.get("sessions", []))
|
|
327
341
|
|
|
342
|
+
for s in sess_list:
|
|
343
|
+
if isinstance(s, dict):
|
|
344
|
+
s["terminal_state"] = get_stable_terminal_state(s.get("status_enum"))
|
|
345
|
+
|
|
328
346
|
if not sess_list and config.api_token and config.api_token.startswith("cog_"):
|
|
329
347
|
console.print("[yellow]Warning: Service tokens (cog_) may only have visibility into sessions they explicitly created, not all organization sessions.[/yellow]")
|
|
330
348
|
|
|
@@ -334,13 +352,20 @@ def list_sessions_cmd(
|
|
|
334
352
|
table = Table(title="Devin Sessions")
|
|
335
353
|
table.add_column("ID", style="cyan")
|
|
336
354
|
table.add_column("Status", style="magenta")
|
|
355
|
+
table.add_column("Terminal State", style="blue")
|
|
337
356
|
table.add_column("Title")
|
|
338
357
|
for s in sess_list:
|
|
339
|
-
table.add_row(
|
|
358
|
+
table.add_row(
|
|
359
|
+
s.get("session_id"),
|
|
360
|
+
s.get("status_enum"),
|
|
361
|
+
s.get("terminal_state"),
|
|
362
|
+
s.get("title") or s.get("prompt", "")[:50]
|
|
363
|
+
)
|
|
340
364
|
console.print(table)
|
|
341
365
|
|
|
342
366
|
return resp
|
|
343
367
|
|
|
368
|
+
|
|
344
369
|
@session_app.command("get")
|
|
345
370
|
@handle_api_error
|
|
346
371
|
def get_session_cmd(
|
|
@@ -351,6 +376,8 @@ def get_session_cmd(
|
|
|
351
376
|
if org: config.temporary_org_id = org
|
|
352
377
|
sid = session_id or get_current_session_id()
|
|
353
378
|
resp = sessions.get_session(sid)
|
|
379
|
+
if isinstance(resp, dict):
|
|
380
|
+
resp["terminal_state"] = get_stable_terminal_state(resp.get("status_enum"))
|
|
354
381
|
console.print(Panel(json.dumps(resp, indent=2), title=f"Session {sid}"))
|
|
355
382
|
|
|
356
383
|
return resp
|
|
@@ -456,6 +483,98 @@ def terminate_session_cmd(
|
|
|
456
483
|
console.print(f"[green]Session {sid} terminated.[/green]")
|
|
457
484
|
return None
|
|
458
485
|
|
|
486
|
+
|
|
487
|
+
def parse_duration(duration_str: str) -> float:
|
|
488
|
+
if not duration_str:
|
|
489
|
+
return 0.0
|
|
490
|
+
duration_str = duration_str.strip().lower()
|
|
491
|
+
val = 0.0
|
|
492
|
+
if duration_str.endswith("s"):
|
|
493
|
+
val = float(duration_str[:-1])
|
|
494
|
+
elif duration_str.endswith("m"):
|
|
495
|
+
val = float(duration_str[:-1]) * 60
|
|
496
|
+
elif duration_str.endswith("h"):
|
|
497
|
+
val = float(duration_str[:-1]) * 3600
|
|
498
|
+
else:
|
|
499
|
+
try:
|
|
500
|
+
val = float(duration_str)
|
|
501
|
+
except ValueError:
|
|
502
|
+
raise ValueError(f"Invalid duration format: '{duration_str}'. Use format like 10s, 5m, 1h.")
|
|
503
|
+
if val < 0:
|
|
504
|
+
raise ValueError("Timeout duration cannot be negative.")
|
|
505
|
+
return val
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@session_app.command("wait")
|
|
509
|
+
@handle_api_error
|
|
510
|
+
def wait_session_cmd(
|
|
511
|
+
session_id: Optional[str] = typer.Argument(None, help="Session ID to wait for (defaults to active session)"),
|
|
512
|
+
until: str = typer.Option("completed", "--until", help="Terminal state to wait for (completed | terminal)"),
|
|
513
|
+
timeout: str = typer.Option("1h", "--timeout", help="Timeout duration (e.g. 10s, 5m, 1h)"),
|
|
514
|
+
interval: int = typer.Option(5, "--interval", help="Polling interval in seconds"),
|
|
515
|
+
org: Optional[str] = typer.Option(None, "--org"),
|
|
516
|
+
):
|
|
517
|
+
"""Block until the session reaches a terminal state or the desired state."""
|
|
518
|
+
if org: config.temporary_org_id = org
|
|
519
|
+
sid = session_id or get_current_session_id()
|
|
520
|
+
|
|
521
|
+
until = until.lower()
|
|
522
|
+
if until not in ("completed", "terminal"):
|
|
523
|
+
console.print(f"[bold red]Error:[/bold red] --until must be 'completed' or 'terminal'.")
|
|
524
|
+
raise typer.Exit(1)
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
timeout_seconds = parse_duration(timeout)
|
|
528
|
+
except ValueError as e:
|
|
529
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
530
|
+
raise typer.Exit(1)
|
|
531
|
+
|
|
532
|
+
start_time = time.time()
|
|
533
|
+
|
|
534
|
+
console.print(f"[dim]Waiting for session {sid} to reach '{until}' (timeout {timeout}, polling every {interval}s)...[/dim]")
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
with console.status(f"[bold green]Waiting for session {sid}...[/bold green]") as status:
|
|
538
|
+
while True:
|
|
539
|
+
elapsed = time.time() - start_time
|
|
540
|
+
if timeout_seconds > 0 and elapsed >= timeout_seconds:
|
|
541
|
+
console.print(f"\n[bold red]Timeout:[/bold red] Timed out waiting for session {sid} after {timeout}")
|
|
542
|
+
raise typer.Exit(1)
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
resp = sessions.get_session(sid)
|
|
546
|
+
except Exception as e:
|
|
547
|
+
console.print(f"\n[bold red]Error fetching session:[/bold red] {e}")
|
|
548
|
+
raise typer.Exit(1)
|
|
549
|
+
|
|
550
|
+
status_enum = resp.get("status_enum")
|
|
551
|
+
term_state = get_stable_terminal_state(status_enum)
|
|
552
|
+
|
|
553
|
+
status.update(f"[bold green]Current Status: {status_enum} (Terminal State: {term_state})[/bold green]")
|
|
554
|
+
|
|
555
|
+
if term_state != "running" and term_state != "blocked":
|
|
556
|
+
if until == "completed" and term_state == "completed":
|
|
557
|
+
console.print(f"\n[bold green]Success:[/bold green] Session {sid} completed successfully.")
|
|
558
|
+
raise typer.Exit(0)
|
|
559
|
+
elif until == "completed" and term_state != "completed":
|
|
560
|
+
console.print(f"\n[bold red]Failed:[/bold red] Session {sid} finished but reached non-completed terminal state '{term_state}'")
|
|
561
|
+
raise typer.Exit(1)
|
|
562
|
+
elif until == "terminal":
|
|
563
|
+
console.print(f"\n[bold green]Terminal State Reached:[/bold green] Session {sid} reached state '{term_state}'.")
|
|
564
|
+
if term_state == "completed":
|
|
565
|
+
raise typer.Exit(0)
|
|
566
|
+
else:
|
|
567
|
+
raise typer.Exit(1)
|
|
568
|
+
|
|
569
|
+
time_to_wait = min(interval, timeout_seconds - elapsed) if timeout_seconds > 0 else interval
|
|
570
|
+
if time_to_wait <= 0:
|
|
571
|
+
continue
|
|
572
|
+
time.sleep(time_to_wait)
|
|
573
|
+
except KeyboardInterrupt:
|
|
574
|
+
console.print("\n[dim]Wait interrupted.[/dim]")
|
|
575
|
+
raise typer.Exit(130)
|
|
576
|
+
|
|
577
|
+
|
|
459
578
|
# --- Knowledge ---
|
|
460
579
|
@knowledge_app.command("list")
|
|
461
580
|
@handle_api_error
|
|
@@ -664,7 +783,10 @@ def list_schedules_cmd(org: Optional[str] = typer.Option(None, "--org")):
|
|
|
664
783
|
table.add_column("Title")
|
|
665
784
|
table.add_column("Cron")
|
|
666
785
|
for item in items:
|
|
667
|
-
|
|
786
|
+
schedule_id = str(item.get("id") or item.get("scheduled_session_id") or "")
|
|
787
|
+
title = str(item.get("name") or item.get("title") or "")
|
|
788
|
+
cron = str(item.get("frequency") or item.get("cron") or "")
|
|
789
|
+
table.add_row(schedule_id, title, cron)
|
|
668
790
|
console.print(table)
|
|
669
791
|
|
|
670
792
|
|
|
@@ -680,7 +802,8 @@ def create_schedule_cmd(
|
|
|
680
802
|
"""Create a recurring schedule."""
|
|
681
803
|
if org: config.temporary_org_id = org
|
|
682
804
|
resp = schedules.create_schedule(prompt=prompt, cron=cron, title=title)
|
|
683
|
-
|
|
805
|
+
schedule_id = resp.get('id') or resp.get('scheduled_session_id')
|
|
806
|
+
console.print(f"[green]Created schedule:[/green] {schedule_id}")
|
|
684
807
|
|
|
685
808
|
return resp
|
|
686
809
|
|
|
@@ -690,19 +813,51 @@ def create_schedule_cmd(
|
|
|
690
813
|
def list_repos_cmd(
|
|
691
814
|
org: Optional[str] = typer.Option(None, "--org"),
|
|
692
815
|
json_output: bool = typer.Option(False, "--json"),
|
|
816
|
+
after: Optional[str] = typer.Option(None, "--after", help="Cursor for pagination"),
|
|
817
|
+
all_pages: bool = typer.Option(False, "--all", help="Fetch all pages sequentially"),
|
|
693
818
|
):
|
|
694
819
|
"""List repositories indexed for Devin."""
|
|
695
820
|
if org: config.temporary_org_id = org
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
821
|
+
|
|
822
|
+
items = []
|
|
823
|
+
current_after = after
|
|
824
|
+
has_next = False
|
|
825
|
+
next_cursor = None
|
|
826
|
+
|
|
827
|
+
while True:
|
|
828
|
+
resp = repositories.list_repositories(after=current_after)
|
|
829
|
+
page_items = []
|
|
830
|
+
if isinstance(resp, list):
|
|
831
|
+
page_items = resp
|
|
832
|
+
has_next = False
|
|
833
|
+
next_cursor = None
|
|
834
|
+
elif isinstance(resp, dict):
|
|
835
|
+
page_items = resp.get("repositories") or resp.get("items") or resp.get("data") or []
|
|
836
|
+
has_next = resp.get("has_next_page", False)
|
|
837
|
+
next_cursor = resp.get("end_cursor") or resp.get("next_cursor")
|
|
838
|
+
else:
|
|
839
|
+
page_items = []
|
|
840
|
+
has_next = False
|
|
841
|
+
next_cursor = None
|
|
842
|
+
|
|
843
|
+
items.extend(page_items)
|
|
844
|
+
|
|
845
|
+
if not all_pages or not has_next or not next_cursor:
|
|
846
|
+
break
|
|
847
|
+
|
|
848
|
+
current_after = next_cursor
|
|
849
|
+
|
|
850
|
+
final_resp = {
|
|
851
|
+
"repositories": items,
|
|
852
|
+
"end_cursor": next_cursor if not all_pages else None,
|
|
853
|
+
"has_next_page": has_next if not all_pages else False
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
|
|
857
|
+
if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
|
|
858
|
+
print(json.dumps(final_resp, indent=2))
|
|
859
|
+
return final_resp
|
|
860
|
+
|
|
706
861
|
table = Table(title="Repositories")
|
|
707
862
|
table.add_column("Path", style="cyan")
|
|
708
863
|
table.add_column("Indexed", style="green")
|
|
@@ -724,7 +879,160 @@ def list_repos_cmd(
|
|
|
724
879
|
)
|
|
725
880
|
table.add_row(path, "Yes" if indexed else "No")
|
|
726
881
|
console.print(table)
|
|
727
|
-
|
|
882
|
+
|
|
883
|
+
if has_next and not all_pages:
|
|
884
|
+
console.print(f"[dim]More pages available. Use --after {next_cursor} to fetch next page, or --all to fetch all.[/dim]")
|
|
885
|
+
|
|
886
|
+
return final_resp
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
@repo_app.command("status")
|
|
890
|
+
@handle_api_error
|
|
891
|
+
def status_repo_cmd(
|
|
892
|
+
paths: List[str] = typer.Argument(..., help="Repository paths (owner/repo)"),
|
|
893
|
+
org: Optional[str] = typer.Option(None, "--org"),
|
|
894
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
895
|
+
):
|
|
896
|
+
"""Get indexing status for one or more repositories."""
|
|
897
|
+
if org: config.temporary_org_id = org
|
|
898
|
+
|
|
899
|
+
results = []
|
|
900
|
+
exit_code = 0
|
|
901
|
+
|
|
902
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
903
|
+
|
|
904
|
+
def fetch_status(path):
|
|
905
|
+
try:
|
|
906
|
+
resp = repositories.get_indexing_status(path)
|
|
907
|
+
if isinstance(resp, dict):
|
|
908
|
+
if "repo_path" not in resp:
|
|
909
|
+
resp["repo_path"] = path
|
|
910
|
+
indexing_enabled = resp.get("indexing_enabled", False)
|
|
911
|
+
item_exit = 0 if indexing_enabled else 1
|
|
912
|
+
else:
|
|
913
|
+
resp = {"repo_path": path, "indexing_enabled": False, "raw": resp}
|
|
914
|
+
item_exit = 1
|
|
915
|
+
return (path, resp, item_exit, None)
|
|
916
|
+
except Exception as e:
|
|
917
|
+
return (path, None, None, e)
|
|
918
|
+
|
|
919
|
+
results_map = {}
|
|
920
|
+
errors_map = {}
|
|
921
|
+
exit_codes_map = {}
|
|
922
|
+
|
|
923
|
+
max_workers = min(10, len(paths))
|
|
924
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
925
|
+
futures = {executor.submit(fetch_status, path): path for path in paths}
|
|
926
|
+
for future in futures:
|
|
927
|
+
path = futures[future]
|
|
928
|
+
try:
|
|
929
|
+
p, resp, item_exit, err = future.result()
|
|
930
|
+
if err:
|
|
931
|
+
errors_map[path] = err
|
|
932
|
+
else:
|
|
933
|
+
results_map[path] = resp
|
|
934
|
+
exit_codes_map[path] = item_exit
|
|
935
|
+
except Exception as ex:
|
|
936
|
+
errors_map[path] = ex
|
|
937
|
+
|
|
938
|
+
for path in paths:
|
|
939
|
+
if path in errors_map:
|
|
940
|
+
e = errors_map[path]
|
|
941
|
+
if isinstance(e, APIError):
|
|
942
|
+
if e.status_code == 404:
|
|
943
|
+
resp = {
|
|
944
|
+
"repo_path": path,
|
|
945
|
+
"indexing_enabled": False,
|
|
946
|
+
"error": "Not registered in Devin (404)"
|
|
947
|
+
}
|
|
948
|
+
results.append(resp)
|
|
949
|
+
if len(paths) == 1:
|
|
950
|
+
exit_code = 2
|
|
951
|
+
else:
|
|
952
|
+
if len(paths) == 1:
|
|
953
|
+
is_json = os.environ.get("DEVIN_OUTPUT_FORMAT") == "json" or json_output
|
|
954
|
+
if is_json:
|
|
955
|
+
try:
|
|
956
|
+
err_json = e.response.json()
|
|
957
|
+
except Exception:
|
|
958
|
+
err_json = {"error": str(e)}
|
|
959
|
+
print(json.dumps(err_json, indent=2))
|
|
960
|
+
else:
|
|
961
|
+
console.print(f"[bold red]API Error:[/bold red] {e}")
|
|
962
|
+
raise typer.Exit(3)
|
|
963
|
+
else:
|
|
964
|
+
results.append({
|
|
965
|
+
"repo_path": path,
|
|
966
|
+
"indexing_enabled": False,
|
|
967
|
+
"error": str(e)
|
|
968
|
+
})
|
|
969
|
+
exit_code = 3
|
|
970
|
+
else:
|
|
971
|
+
if len(paths) == 1:
|
|
972
|
+
is_json = os.environ.get("DEVIN_OUTPUT_FORMAT") == "json" or json_output
|
|
973
|
+
if is_json:
|
|
974
|
+
print(json.dumps({"error": str(e)}, indent=2))
|
|
975
|
+
else:
|
|
976
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
977
|
+
raise typer.Exit(3)
|
|
978
|
+
else:
|
|
979
|
+
results.append({
|
|
980
|
+
"repo_path": path,
|
|
981
|
+
"indexing_enabled": False,
|
|
982
|
+
"error": str(e)
|
|
983
|
+
})
|
|
984
|
+
exit_code = 3
|
|
985
|
+
else:
|
|
986
|
+
resp = results_map[path]
|
|
987
|
+
item_exit = exit_codes_map[path]
|
|
988
|
+
results.append(resp)
|
|
989
|
+
if len(paths) == 1:
|
|
990
|
+
exit_code = item_exit
|
|
991
|
+
|
|
992
|
+
if len(paths) == 1:
|
|
993
|
+
final_result = results[0]
|
|
994
|
+
else:
|
|
995
|
+
final_result = {"results": results}
|
|
996
|
+
|
|
997
|
+
if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
|
|
998
|
+
if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
|
|
999
|
+
print(json.dumps(final_result, indent=2))
|
|
1000
|
+
return final_result
|
|
1001
|
+
|
|
1002
|
+
if len(paths) == 1:
|
|
1003
|
+
res = results[0]
|
|
1004
|
+
if "error" in res:
|
|
1005
|
+
console.print(f"[bold red]Error:[/bold red] {res['error']}")
|
|
1006
|
+
else:
|
|
1007
|
+
console.print(Panel(json.dumps(res, indent=2), title=f"Status for {paths[0]}"))
|
|
1008
|
+
else:
|
|
1009
|
+
table = Table(title="Repository Indexing Status")
|
|
1010
|
+
table.add_column("Path", style="cyan")
|
|
1011
|
+
table.add_column("Indexing Enabled", style="magenta")
|
|
1012
|
+
table.add_column("Status / Details")
|
|
1013
|
+
|
|
1014
|
+
for res in results:
|
|
1015
|
+
path = res.get("repo_path", "")
|
|
1016
|
+
enabled = "Yes" if res.get("indexing_enabled") else "No"
|
|
1017
|
+
|
|
1018
|
+
if "error" in res:
|
|
1019
|
+
details = f"[red]{res['error']}[/red]"
|
|
1020
|
+
else:
|
|
1021
|
+
latest = res.get("latest_index") or {}
|
|
1022
|
+
status = latest.get("status", "unknown") if latest else "N/A"
|
|
1023
|
+
branch = latest.get("branch_name", "") if latest else ""
|
|
1024
|
+
details = f"Status: {status}" + (f" ({branch})" if branch else "")
|
|
1025
|
+
|
|
1026
|
+
table.add_row(path, enabled, details)
|
|
1027
|
+
console.print(table)
|
|
1028
|
+
|
|
1029
|
+
if len(paths) == 1:
|
|
1030
|
+
raise typer.Exit(exit_code)
|
|
1031
|
+
else:
|
|
1032
|
+
if exit_code >= 3:
|
|
1033
|
+
raise typer.Exit(exit_code)
|
|
1034
|
+
return final_result
|
|
1035
|
+
|
|
728
1036
|
|
|
729
1037
|
@repo_app.command("index")
|
|
730
1038
|
@handle_api_error
|
|
@@ -27,7 +27,8 @@ def test_create_schedule():
|
|
|
27
27
|
import json
|
|
28
28
|
body = json.loads(route.calls.last.request.read())
|
|
29
29
|
assert body["prompt"] == "test prompt"
|
|
30
|
-
assert body["
|
|
30
|
+
assert body["frequency"] == "0 0 * * *"
|
|
31
|
+
assert body["schedule_type"] == "recurring"
|
|
31
32
|
|
|
32
33
|
@respx.mock
|
|
33
34
|
def test_delete_schedule():
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from typer.testing import CliRunner
|
|
3
|
+
from unittest.mock import patch, MagicMock
|
|
4
|
+
from devin_cli.cli import app
|
|
5
|
+
from devin_cli.api.client import APIError
|
|
6
|
+
|
|
7
|
+
runner = CliRunner()
|
|
8
|
+
|
|
9
|
+
@patch("devin_cli.api.repositories.list_repositories")
|
|
10
|
+
def test_repos_list_pagination(mock_list):
|
|
11
|
+
# First call returns a page with has_next_page=True and end_cursor
|
|
12
|
+
# Second call returns a page with has_next_page=False
|
|
13
|
+
mock_list.side_effect = [
|
|
14
|
+
{
|
|
15
|
+
"repositories": [{"repo_path": "owner/repo1"}],
|
|
16
|
+
"has_next_page": True,
|
|
17
|
+
"end_cursor": "cursor123"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"repositories": [{"repo_path": "owner/repo2"}],
|
|
21
|
+
"has_next_page": False,
|
|
22
|
+
"end_cursor": None
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
result = runner.invoke(app, ["repos", "list", "--all"])
|
|
27
|
+
assert result.exit_code == 0
|
|
28
|
+
assert "owner/repo1" in result.stdout
|
|
29
|
+
assert "owner/repo2" in result.stdout
|
|
30
|
+
assert mock_list.call_count == 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@patch("devin_cli.api.repositories.get_indexing_status")
|
|
34
|
+
def test_repos_status_enabled(mock_status):
|
|
35
|
+
mock_status.return_value = {
|
|
36
|
+
"repo_path": "owner/repo",
|
|
37
|
+
"indexing_enabled": True,
|
|
38
|
+
"latest_index": {"status": "completed"}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
result = runner.invoke(app, ["repos", "status", "owner/repo"])
|
|
42
|
+
assert result.exit_code == 0
|
|
43
|
+
assert "indexing_enabled" in result.stdout
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@patch("devin_cli.api.repositories.get_indexing_status")
|
|
47
|
+
def test_repos_status_disabled(mock_status):
|
|
48
|
+
mock_status.return_value = {
|
|
49
|
+
"repo_path": "owner/repo",
|
|
50
|
+
"indexing_enabled": False
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
result = runner.invoke(app, ["repos", "status", "owner/repo"])
|
|
54
|
+
assert result.exit_code == 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@patch("devin_cli.api.repositories.get_indexing_status")
|
|
58
|
+
def test_repos_status_not_registered(mock_status):
|
|
59
|
+
# Raise a 404 APIError
|
|
60
|
+
mock_status.side_effect = APIError("Not found", 404)
|
|
61
|
+
|
|
62
|
+
result = runner.invoke(app, ["repos", "status", "owner/repo"])
|
|
63
|
+
assert result.exit_code == 2
|
|
64
|
+
assert "Not registered in Devin" in result.stdout
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@patch("devin_cli.api.repositories.get_indexing_status")
|
|
68
|
+
def test_repos_status_multi(mock_status):
|
|
69
|
+
mock_status.side_effect = [
|
|
70
|
+
{"repo_path": "owner/repo1", "indexing_enabled": True},
|
|
71
|
+
APIError("Not found", 404),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
result = runner.invoke(app, ["repos", "status", "owner/repo1", "owner/repo2"])
|
|
75
|
+
assert result.exit_code == 0 # Multi-path doesn't fail exit code on 404
|
|
76
|
+
assert "Repository Indexing Status" in result.stdout
|
|
77
|
+
assert "owner/repo1" in result.stdout
|
|
78
|
+
assert "owner/repo2" in result.stdout
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@patch("devin_cli.api.sessions.get_session")
|
|
82
|
+
def test_sessions_get_terminal_state(mock_get):
|
|
83
|
+
mock_get.return_value = {
|
|
84
|
+
"session_id": "sess_123",
|
|
85
|
+
"status_enum": "finished",
|
|
86
|
+
"title": "Test"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
result = runner.invoke(app, ["sessions", "get", "sess_123"])
|
|
90
|
+
assert result.exit_code == 0
|
|
91
|
+
assert '"terminal_state": "completed"' in result.stdout
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@patch("devin_cli.api.sessions.get_session")
|
|
95
|
+
def test_sessions_wait(mock_get):
|
|
96
|
+
mock_get.side_effect = [
|
|
97
|
+
{"session_id": "sess_123", "status_enum": "running"},
|
|
98
|
+
{"session_id": "sess_123", "status_enum": "finished"}
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
result = runner.invoke(app, ["sessions", "wait", "sess_123", "--interval", "0"])
|
|
102
|
+
assert result.exit_code == 0
|
|
103
|
+
assert "completed successfully" in result.stdout
|
|
104
|
+
assert mock_get.call_count == 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@patch("devin_cli.api.repositories.get_indexing_status")
|
|
108
|
+
def test_repos_status_other_error(mock_status):
|
|
109
|
+
# Raise a non-404 APIError (e.g., 403 Forbidden)
|
|
110
|
+
mock_status.side_effect = APIError("Forbidden", 403)
|
|
111
|
+
|
|
112
|
+
result = runner.invoke(app, ["repos", "status", "owner/repo"])
|
|
113
|
+
assert result.exit_code == 3
|
|
114
|
+
assert "API Error" in result.stdout or "Forbidden" in result.stdout
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_sessions_wait_negative_timeout():
|
|
118
|
+
result = runner.invoke(app, ["sessions", "wait", "sess_123", "--timeout", "-10s"])
|
|
119
|
+
assert result.exit_code == 1
|
|
120
|
+
assert "cannot be negative" in result.stdout
|
|
121
|
+
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
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
|
|
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
|