orchestra-cli 0.5.0__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/PKG-INFO +1 -1
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/migrate_pipeline.py +3 -1
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/run_pipeline.py +29 -4
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_import_pipeline.py +2 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_run_pipeline.py +94 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/utils/test_git.py +43 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/git.py +25 -2
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli.egg-info/PKG-INFO +1 -1
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/pyproject.toml +1 -1
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/README.md +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/__init__.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/build_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/cli.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/create_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/delete_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/fetch_pipelines.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/get_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/import_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/list_accounts.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/login.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/login_page.html +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/pipeline_upsert.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/task_logs.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/update_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/use_account.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/src/validate_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/__init__.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/conftest.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_build_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_cli.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_create_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_delete_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_fetch_pipelines.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_get_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_list_accounts.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_login.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_migrate_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_pipeline_selector.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_task_logs.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_update_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_use_account.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/test_validate_pipeline.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/utils/__init__.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/utils/test_api.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/tests/utils/test_picker.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/accounts.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/api.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/constants.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/credentials.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/picker.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/pipeline_lookup.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/pipeline_selector.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/pipeline_update.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/styling.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli/utils/yaml_loader.py +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli.egg-info/SOURCES.txt +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli.egg-info/dependency_links.txt +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli.egg-info/entry_points.txt +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli.egg-info/requires.txt +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/orchestra_cli.egg-info/top_level.txt +0 -0
- {orchestra_cli-0.5.0 → orchestra_cli-0.5.2}/setup.cfg +0 -0
|
@@ -364,7 +364,8 @@ def migrate_pipeline(
|
|
|
364
364
|
typer.echo(red("Could not detect repository URL from git"))
|
|
365
365
|
raise typer.Exit(code=1)
|
|
366
366
|
|
|
367
|
-
|
|
367
|
+
remote_url = get_remote_url(repo_root)
|
|
368
|
+
storage_provider_value = detect_storage_provider(remote_url)
|
|
368
369
|
if not storage_provider_value:
|
|
369
370
|
typer.echo(red("Could not detect storage provider - no matching host"))
|
|
370
371
|
raise typer.Exit(code=1)
|
|
@@ -437,6 +438,7 @@ def migrate_pipeline(
|
|
|
437
438
|
repository_slug,
|
|
438
439
|
default_branch,
|
|
439
440
|
pushed_branch,
|
|
441
|
+
remote_url,
|
|
440
442
|
)
|
|
441
443
|
if compare_link:
|
|
442
444
|
typer.echo(yellow(f"Open PR / compare link: {compare_link}"))
|
|
@@ -37,13 +37,14 @@ STATUS_STYLES = {
|
|
|
37
37
|
"CREATED": "cyan",
|
|
38
38
|
"QUEUED": "yellow",
|
|
39
39
|
"RUNNING": "blue",
|
|
40
|
+
"CANCELLING": "red",
|
|
40
41
|
"SUCCEEDED": "green",
|
|
41
42
|
"WARNING": "yellow",
|
|
42
43
|
"SKIPPED": "yellow",
|
|
43
44
|
"FAILED": "red",
|
|
44
45
|
"CANCELLED": "red",
|
|
45
46
|
}
|
|
46
|
-
IN_PROGRESS_RUN_STATUSES = {"RUNNING", "QUEUED", "CREATED"}
|
|
47
|
+
IN_PROGRESS_RUN_STATUSES = {"RUNNING", "QUEUED", "CREATED", "CANCELLING"}
|
|
47
48
|
TERMINAL_RUN_STATUSES = {"SUCCEEDED", "WARNING", "SKIPPED", "FAILED", "CANCELLED"}
|
|
48
49
|
|
|
49
50
|
|
|
@@ -307,13 +308,23 @@ def _format_pipeline_duration(created_at_utc: datetime, now_utc: datetime | None
|
|
|
307
308
|
return f"{seconds} {unit}"
|
|
308
309
|
|
|
309
310
|
|
|
311
|
+
def _run_message(status_body: dict[str, object]) -> str | None:
|
|
312
|
+
message = status_body.get("message")
|
|
313
|
+
if isinstance(message, str) and message.strip():
|
|
314
|
+
return message.strip()
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
310
318
|
def _build_poll_status_text(
|
|
311
319
|
status_value: str,
|
|
312
320
|
created_at_utc: datetime | None = None,
|
|
313
321
|
now_utc: datetime | None = None,
|
|
322
|
+
status_message: str | None = None,
|
|
314
323
|
) -> Text:
|
|
315
324
|
text = Text("Pipeline status: ")
|
|
316
325
|
text.append(status_value, style=f"bold {STATUS_STYLES.get(status_value, 'white')}")
|
|
326
|
+
if status_message is not None:
|
|
327
|
+
text.append(f" - {status_message}")
|
|
317
328
|
if created_at_utc is not None:
|
|
318
329
|
text.append(f" ({_format_pipeline_duration(created_at_utc, now_utc)})", style="dim")
|
|
319
330
|
return text
|
|
@@ -419,10 +430,11 @@ def _build_poll_display(
|
|
|
419
430
|
task_runs: list[dict[str, object]],
|
|
420
431
|
can_fetch_task_runs: bool,
|
|
421
432
|
now_utc: datetime | None = None,
|
|
433
|
+
status_message: str | None = None,
|
|
422
434
|
) -> Group:
|
|
423
435
|
return Group(
|
|
424
436
|
_build_live_status_text(seconds_since_check),
|
|
425
|
-
_build_poll_status_text(status_value, created_at_utc, now_utc),
|
|
437
|
+
_build_poll_status_text(status_value, created_at_utc, now_utc, status_message),
|
|
426
438
|
_build_task_runs_table(
|
|
427
439
|
task_runs,
|
|
428
440
|
can_fetch_task_runs=can_fetch_task_runs,
|
|
@@ -452,6 +464,7 @@ def _sleep_with_status_updates(
|
|
|
452
464
|
created_at_utc: datetime | None,
|
|
453
465
|
task_runs: list[dict[str, object]],
|
|
454
466
|
can_fetch_task_runs: bool,
|
|
467
|
+
status_message: str | None = None,
|
|
455
468
|
sleep_fn: Callable[[float], None] | None = None,
|
|
456
469
|
) -> None:
|
|
457
470
|
sleep_fn = sleep_fn or time.sleep
|
|
@@ -468,6 +481,7 @@ def _sleep_with_status_updates(
|
|
|
468
481
|
created_at_utc=created_at_utc,
|
|
469
482
|
task_runs=task_runs,
|
|
470
483
|
can_fetch_task_runs=can_fetch_task_runs,
|
|
484
|
+
status_message=status_message,
|
|
471
485
|
),
|
|
472
486
|
)
|
|
473
487
|
|
|
@@ -489,6 +503,7 @@ def _poll_until_terminal(
|
|
|
489
503
|
status_url = get_api_url(f"pipeline_runs/{pipeline_run_id}/status")
|
|
490
504
|
poll_status_display = _create_poll_status_display()
|
|
491
505
|
last_status_value: str | None = None
|
|
506
|
+
last_status_message: str | None = None
|
|
492
507
|
last_task_runs: list[dict[str, object]] = []
|
|
493
508
|
can_fetch_task_runs = False
|
|
494
509
|
missing_run_status_polls = 0
|
|
@@ -502,6 +517,7 @@ def _poll_until_terminal(
|
|
|
502
517
|
created_at_utc=created_at_utc,
|
|
503
518
|
task_runs=last_task_runs,
|
|
504
519
|
can_fetch_task_runs=can_fetch_task_runs,
|
|
520
|
+
status_message=last_status_message,
|
|
505
521
|
)
|
|
506
522
|
try:
|
|
507
523
|
status_resp = client.get(status_url)
|
|
@@ -530,11 +546,13 @@ def _poll_until_terminal(
|
|
|
530
546
|
status_body = {}
|
|
531
547
|
|
|
532
548
|
status_value = status_body.get("runStatus")
|
|
549
|
+
run_message = _run_message(status_body)
|
|
533
550
|
created_at_utc = created_at_utc or _parse_created_at_utc(status_body)
|
|
534
551
|
|
|
535
552
|
if isinstance(status_value, str):
|
|
536
553
|
missing_run_status_polls = 0
|
|
537
554
|
last_status_value = status_value
|
|
555
|
+
last_status_message = run_message if status_value == "QUEUED" else None
|
|
538
556
|
can_fetch_task_runs = status_value in (
|
|
539
557
|
IN_PROGRESS_RUN_STATUSES | TERMINAL_RUN_STATUSES
|
|
540
558
|
)
|
|
@@ -548,10 +566,14 @@ def _poll_until_terminal(
|
|
|
548
566
|
created_at_utc=created_at_utc,
|
|
549
567
|
task_runs=last_task_runs,
|
|
550
568
|
can_fetch_task_runs=can_fetch_task_runs,
|
|
569
|
+
status_message=last_status_message,
|
|
551
570
|
),
|
|
552
571
|
)
|
|
553
572
|
else:
|
|
554
|
-
|
|
573
|
+
status_line = f"Pipeline ({selector_name}) status: {status_value}"
|
|
574
|
+
if last_status_message is not None:
|
|
575
|
+
status_line += f" - {last_status_message}"
|
|
576
|
+
typer.echo(status_line)
|
|
555
577
|
else:
|
|
556
578
|
missing_run_status_polls += 1
|
|
557
579
|
if missing_run_status_polls <= max_missing_run_status_polls:
|
|
@@ -572,7 +594,10 @@ def _poll_until_terminal(
|
|
|
572
594
|
if status_value == "SKIPPED":
|
|
573
595
|
_stop_poll_status_display(poll_status_display)
|
|
574
596
|
poll_status_display = None
|
|
575
|
-
|
|
597
|
+
skipped_text = (
|
|
598
|
+
f"⚠ Pipeline skipped: {run_message}" if run_message else "⚠ Pipeline skipped"
|
|
599
|
+
)
|
|
600
|
+
typer.echo(yellow(skipped_text))
|
|
576
601
|
raise typer.Exit(code=0)
|
|
577
602
|
|
|
578
603
|
if status_value in {"FAILED", "CANCELLED"}:
|
|
@@ -36,6 +36,8 @@ def mock_env(monkeypatch):
|
|
|
36
36
|
[
|
|
37
37
|
("https://github.com/org/repo.git", "GITHUB", "org/repo"),
|
|
38
38
|
("git@github.com:org/repo.git", "GITHUB", "org/repo"),
|
|
39
|
+
("https://acme.ghe.com/org/repo.git", "GITHUB", "org/repo"),
|
|
40
|
+
("git@acme.ghe.com:org/repo.git", "GITHUB", "org/repo"),
|
|
39
41
|
("https://gitlab.com/org/repo.git", "GITLAB", "org/repo"),
|
|
40
42
|
("git@gitlab.com:org/repo.git", "GITLAB", "org/repo"),
|
|
41
43
|
("https://dev.azure.com/org/project/_git/repo", "AZURE_DEVOPS", "project/repo"),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import time
|
|
1
3
|
from datetime import UTC, datetime
|
|
2
4
|
from pathlib import Path
|
|
3
5
|
|
|
@@ -96,6 +98,11 @@ def test_build_poll_status_text_includes_duration() -> None:
|
|
|
96
98
|
assert text.plain == "Pipeline status: RUNNING (1:30 minutes)"
|
|
97
99
|
|
|
98
100
|
|
|
101
|
+
def test_build_poll_status_text_includes_status_message() -> None:
|
|
102
|
+
text = _build_poll_status_text("QUEUED", status_message="Waiting for a free slot")
|
|
103
|
+
assert text.plain == "Pipeline status: QUEUED - Waiting for a free slot"
|
|
104
|
+
|
|
105
|
+
|
|
99
106
|
def test_build_live_status_text_includes_update_age() -> None:
|
|
100
107
|
text = _build_live_status_text(3)
|
|
101
108
|
assert text.plain == "Live status (updated 3 seconds ago)"
|
|
@@ -1094,6 +1101,93 @@ def test_run_wait_warning(httpx_mock: HTTPXMock, monkeypatch, tmp_path: Path):
|
|
|
1094
1101
|
assert result.output.strip().splitlines()[-1] == "⚠ Pipeline completed with warnings"
|
|
1095
1102
|
|
|
1096
1103
|
|
|
1104
|
+
def _invoke_run_through_statuses(
|
|
1105
|
+
httpx_mock: HTTPXMock,
|
|
1106
|
+
monkeypatch,
|
|
1107
|
+
tmp_path: Path,
|
|
1108
|
+
status_bodies: list[dict[str, str]],
|
|
1109
|
+
):
|
|
1110
|
+
mapping = {
|
|
1111
|
+
("rev-parse", "--show-toplevel"): (0, str(tmp_path), ""),
|
|
1112
|
+
("status", "--porcelain"): (0, "", ""),
|
|
1113
|
+
("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"): (1, "", ""),
|
|
1114
|
+
}
|
|
1115
|
+
monkeypatch.setattr(time, "sleep", lambda _: None)
|
|
1116
|
+
monkeypatch.setattr(subprocess, "run", make_git_subprocess_mock(mapping))
|
|
1117
|
+
|
|
1118
|
+
base = "https://app.getorchestra.io/api/engine/public"
|
|
1119
|
+
httpx_mock.add_response(
|
|
1120
|
+
method="POST",
|
|
1121
|
+
url=f"{base}/pipelines/demo/start",
|
|
1122
|
+
json={"pipelineRunId": "run-q"},
|
|
1123
|
+
)
|
|
1124
|
+
for status_body in status_bodies:
|
|
1125
|
+
httpx_mock.add_response(
|
|
1126
|
+
method="GET",
|
|
1127
|
+
url=f"{base}/pipeline_runs/run-q/status",
|
|
1128
|
+
json={**status_body, "pipelineName": "demo"},
|
|
1129
|
+
)
|
|
1130
|
+
httpx_mock.add_response(
|
|
1131
|
+
method="GET",
|
|
1132
|
+
url=f"{base}/pipeline_runs/run-q/task_runs?page_size=50&page=1",
|
|
1133
|
+
json={"results": []},
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
return runner.invoke(app, ["pipeline", "run", "--alias", "demo", "--wait"])
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def test_run_wait_keeps_polling_while_cancelling(
|
|
1140
|
+
httpx_mock: HTTPXMock,
|
|
1141
|
+
monkeypatch,
|
|
1142
|
+
tmp_path: Path,
|
|
1143
|
+
):
|
|
1144
|
+
result = _invoke_run_through_statuses(
|
|
1145
|
+
httpx_mock,
|
|
1146
|
+
monkeypatch,
|
|
1147
|
+
tmp_path,
|
|
1148
|
+
[{"runStatus": "QUEUED"}, {"runStatus": "CANCELLING"}, {"runStatus": "CANCELLED"}],
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
assert result.exit_code == 1
|
|
1152
|
+
assert "Invalid status value" not in result.output
|
|
1153
|
+
assert "status: CANCELLING" in result.output
|
|
1154
|
+
assert "status CANCELLED" in result.output
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def test_run_wait_shows_queued_message_then_final_status(
|
|
1158
|
+
httpx_mock: HTTPXMock,
|
|
1159
|
+
monkeypatch,
|
|
1160
|
+
tmp_path: Path,
|
|
1161
|
+
):
|
|
1162
|
+
result = _invoke_run_through_statuses(
|
|
1163
|
+
httpx_mock,
|
|
1164
|
+
monkeypatch,
|
|
1165
|
+
tmp_path,
|
|
1166
|
+
[
|
|
1167
|
+
{"runStatus": "QUEUED", "message": "Waiting for a free slot"},
|
|
1168
|
+
{"runStatus": "RUNNING", "message": "Waiting for a free slot"},
|
|
1169
|
+
{"runStatus": "SUCCEEDED"},
|
|
1170
|
+
],
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
assert result.exit_code == 0
|
|
1174
|
+
assert "Pipeline (alias: demo) status: QUEUED - Waiting for a free slot" in result.output
|
|
1175
|
+
assert "Pipeline (alias: demo) status: RUNNING\n" in result.output
|
|
1176
|
+
assert result.output.strip().splitlines()[-1] == "✅ Pipeline succeeded"
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def test_run_wait_skipped_prints_reason(httpx_mock: HTTPXMock, monkeypatch, tmp_path: Path):
|
|
1180
|
+
result = _invoke_run_through_statuses(
|
|
1181
|
+
httpx_mock,
|
|
1182
|
+
monkeypatch,
|
|
1183
|
+
tmp_path,
|
|
1184
|
+
[{"runStatus": "SKIPPED", "message": "Replaced by a newer run"}],
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
assert result.exit_code == 0
|
|
1188
|
+
assert result.output.strip().splitlines()[-1] == "⚠ Pipeline skipped: Replaced by a newer run"
|
|
1189
|
+
|
|
1190
|
+
|
|
1097
1191
|
def test_run_git_backed_path_uses_prepared_branch_and_commit(
|
|
1098
1192
|
httpx_mock: HTTPXMock,
|
|
1099
1193
|
monkeypatch,
|
|
@@ -114,3 +114,46 @@ def test_require_repo_root_walks_up_from_nonexistent_parent(monkeypatch, tmp_pat
|
|
|
114
114
|
monkeypatch.setattr(subprocess, "run", make_git_subprocess_mock(mapping))
|
|
115
115
|
|
|
116
116
|
assert git_module.require_repo_root(target_file, git_module.GitAction.MIGRATE) == tmp_path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize(
|
|
120
|
+
"remote_url, expected",
|
|
121
|
+
[
|
|
122
|
+
("https://github.com/org/repo.git", "GITHUB"),
|
|
123
|
+
("git@github.com:org/repo.git", "GITHUB"),
|
|
124
|
+
("https://acme.ghe.com/org/repo.git", "GITHUB"),
|
|
125
|
+
("https://user:token@acme.ghe.com/org/repo.git", "GITHUB"),
|
|
126
|
+
("git@acme.ghe.com:org/repo.git", "GITHUB"),
|
|
127
|
+
("ssh://git@acme.ghe.com/org/repo.git", "GITHUB"),
|
|
128
|
+
("https://gitlab.com/org/repo.git", "GITLAB"),
|
|
129
|
+
("https://git.example.com/org/repo.git", None),
|
|
130
|
+
("https://example.com/ghe.com/repo.git", None),
|
|
131
|
+
("https://[malformed/org/repo.git", None),
|
|
132
|
+
(None, None),
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
def test_detect_storage_provider(remote_url: str | None, expected: str | None):
|
|
136
|
+
assert git_module.detect_storage_provider(remote_url) == expected
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
"remote_url, expected",
|
|
141
|
+
[
|
|
142
|
+
(
|
|
143
|
+
"git@github.com:org/repo.git",
|
|
144
|
+
"https://github.com/org/repo/compare/main...feature?expand=1",
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
"git@acme.ghe.com:org/repo.git",
|
|
148
|
+
"https://acme.ghe.com/org/repo/compare/main...feature?expand=1",
|
|
149
|
+
),
|
|
150
|
+
(
|
|
151
|
+
"https://user:token@acme.ghe.com/org/repo.git",
|
|
152
|
+
"https://acme.ghe.com/org/repo/compare/main...feature?expand=1",
|
|
153
|
+
),
|
|
154
|
+
(None, "https://github.com/org/repo/compare/main...feature?expand=1"),
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
def test_build_compare_link_github(remote_url: str | None, expected: str):
|
|
158
|
+
link = git_module.build_compare_link("GITHUB", "org/repo", "main", "feature", remote_url)
|
|
159
|
+
assert link == expected
|
|
@@ -4,11 +4,14 @@ import string
|
|
|
4
4
|
import subprocess
|
|
5
5
|
from enum import StrEnum
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlsplit
|
|
7
8
|
|
|
8
9
|
import typer
|
|
9
10
|
|
|
10
11
|
from .styling import bold, red, yellow
|
|
11
12
|
|
|
13
|
+
GHE_COM_HOST_SUFFIX = ".ghe.com"
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
class GitAction(StrEnum):
|
|
14
17
|
BUILD = "Build"
|
|
@@ -84,12 +87,30 @@ def detect_current_branch(repo_root: Path, allow_detached: bool = True) -> str |
|
|
|
84
87
|
return out
|
|
85
88
|
|
|
86
89
|
|
|
90
|
+
def _remote_host(remote_url: str) -> str | None:
|
|
91
|
+
"""The host of a URL remote, or of the SSH shorthand ``[user@]host:path``."""
|
|
92
|
+
remote_url = remote_url.strip()
|
|
93
|
+
if "://" in remote_url:
|
|
94
|
+
try:
|
|
95
|
+
return urlsplit(remote_url).hostname
|
|
96
|
+
except ValueError:
|
|
97
|
+
return None
|
|
98
|
+
user_and_host, separator, _ = remote_url.partition(":")
|
|
99
|
+
return user_and_host.rpartition("@")[2].lower() if separator else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ghe_com_host(remote_url: str | None) -> str | None:
|
|
103
|
+
"""The remote's host if it's on GitHub Enterprise Cloud with data residency."""
|
|
104
|
+
host = _remote_host(remote_url) if remote_url else None
|
|
105
|
+
return host if host and host.endswith(GHE_COM_HOST_SUFFIX) else None
|
|
106
|
+
|
|
107
|
+
|
|
87
108
|
def detect_storage_provider(repository_url: str | None) -> str | None:
|
|
88
109
|
if not repository_url:
|
|
89
110
|
return None
|
|
90
111
|
|
|
91
112
|
url = repository_url.lower()
|
|
92
|
-
if "github.com" in url:
|
|
113
|
+
if "github.com" in url or _ghe_com_host(repository_url):
|
|
93
114
|
return "GITHUB"
|
|
94
115
|
if "gitlab.com" in url:
|
|
95
116
|
return "GITLAB"
|
|
@@ -103,10 +124,12 @@ def build_compare_link(
|
|
|
103
124
|
repository_slug: str,
|
|
104
125
|
default_branch: str,
|
|
105
126
|
branch_name: str,
|
|
127
|
+
remote_url: str | None,
|
|
106
128
|
) -> str | None:
|
|
107
129
|
provider = storage_provider.upper()
|
|
108
130
|
if provider == "GITHUB":
|
|
109
|
-
|
|
131
|
+
host = _ghe_com_host(remote_url) or "github.com"
|
|
132
|
+
return f"https://{host}/{repository_slug}/compare/{default_branch}...{branch_name}?expand=1"
|
|
110
133
|
if provider == "GITLAB":
|
|
111
134
|
return f"https://gitlab.com/{repository_slug}/-/compare/{default_branch}...{branch_name}"
|
|
112
135
|
return None
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|