pipecatcloud 1.1.0__tar.gz → 1.2.0__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.
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/CHANGELOG.md +120 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/PKG-INFO +1 -1
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/deploy_utils.py +326 -0
- pipecatcloud-1.2.0/src/pipecatcloud/_utils/github_utils.py +129 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/regions.py +21 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/api.py +408 -5
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/agent.py +311 -2
- pipecatcloud-1.2.0/src/pipecatcloud/cli/commands/agent_profiles.py +232 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/build.py +26 -1
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/deploy.py +199 -7
- pipecatcloud-1.2.0/src/pipecatcloud/cli/commands/github.py +307 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/organizations.py +2 -0
- pipecatcloud-1.2.0/src/pipecatcloud/cli/commands/regions.py +301 -0
- pipecatcloud-1.2.0/src/pipecatcloud/cli/commands/registry_keys.py +161 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/secrets.py +86 -14
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/entry_point.py +7 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/config.py +12 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/PKG-INFO +1 -1
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/SOURCES.txt +12 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/scm_file_list.json +67 -55
- pipecatcloud-1.2.0/src/pipecatcloud.egg-info/scm_version.json +8 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/conftest.py +12 -0
- pipecatcloud-1.2.0/tests/test_agent_profiles_and_resources.py +281 -0
- pipecatcloud-1.2.0/tests/test_agent_status_output.py +64 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_api_client.py +62 -0
- pipecatcloud-1.2.0/tests/test_deploy_architecture.py +63 -0
- pipecatcloud-1.2.0/tests/test_deploy_referenced_secret_set.py +118 -0
- pipecatcloud-1.2.0/tests/test_github_integration.py +934 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_regions.py +3 -1
- pipecatcloud-1.2.0/tests/test_regions_lifecycle.py +274 -0
- pipecatcloud-1.2.0/tests/test_secrets_reference.py +91 -0
- pipecatcloud-1.2.0/tests/test_secrets_set_referenced.py +120 -0
- pipecatcloud-1.1.0/src/pipecatcloud/cli/commands/regions.py +0 -52
- pipecatcloud-1.1.0/src/pipecatcloud.egg-info/scm_version.json +0 -8
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.claude/settings.json +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/format.yml +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/publish-pypi.yml +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/publish-test.yml +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/python-compatibility.yml +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/tests.yml +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.gitignore +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.pre-commit-config.yaml +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/CLAUDE.md +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/LICENSE +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/README.md +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/pipecat-cloud.png +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/pyproject.toml +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/pytest.ini +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/scripts/fix-ruff.sh +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/setup.cfg +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/__init__.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/__version__.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/__init__.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/async_utils.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/auth_utils.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/build_utils.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/console_utils.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/agent.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/__init__.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/api.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/__init__.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/auth.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/docker.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/spend_limit.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/config.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/constants.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/exception.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/py.typed +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/session.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/requires.txt +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/top_level.txt +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/__init__.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_agent_args.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_agent_sessions.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_agent_stop.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_auth.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_build_id_display.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_build_utils.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_config_file.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_deploy_image_pull_secret.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_deploy_poll.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_deployment_status.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_docker_commands.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_exit_codes.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_json_output.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_krisp_viva.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_max_session_duration.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_organizations.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_output_modes.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_pat_auth.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_spend_limit.py +0 -0
- {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/uv.lock +0 -0
|
@@ -5,6 +5,126 @@ All notable changes to **Pipecat Cloud** 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.2.0] - 2026-08-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **GitHub integration**, at parity with the dashboard. Connect an
|
|
13
|
+
organization with `pipecat cloud github connect`: it opens the GitHub App
|
|
14
|
+
install page and waits while the install completes, so there is nothing to
|
|
15
|
+
copy back into the terminal. `github status` reports the connected account
|
|
16
|
+
and whether the App is suspended, `github disconnect` removes the
|
|
17
|
+
installation and every repository link under it, and `github repos` /
|
|
18
|
+
`github branches <owner/repo>` list what the App can see (`--query` runs the
|
|
19
|
+
server-side branch search for repositories with more branches than the list
|
|
20
|
+
returns).
|
|
21
|
+
|
|
22
|
+
- Agents can be built from a repository instead of an image. `pipecat cloud
|
|
23
|
+
deploy --repo owner/repo --branch main` creates a GitHub-sourced agent,
|
|
24
|
+
skipping the image build and push entirely, and follows the first deploy
|
|
25
|
+
through its build; a `[git]` section in `pcc-deploy.toml` (`repo`, `branch`,
|
|
26
|
+
`dockerfile_path`, `subdirectory`) does the same, with flags taking
|
|
27
|
+
precedence. An existing agent's repository is changed with `pipecat cloud
|
|
28
|
+
agent link <name> --repo owner/repo --branch main` (also `--dockerfile-path`,
|
|
29
|
+
`--subdirectory` and `--no-auto-deploy`), and removed with `agent unlink`.
|
|
30
|
+
Linking never changes what is running.
|
|
31
|
+
|
|
32
|
+
- `pipecat cloud agent deploy <name> --github` builds and deploys the linked
|
|
33
|
+
branch's current HEAD without waiting for a push, printing the deploy intent
|
|
34
|
+
and commit. `--wait` follows the deploy and reports one of four outcomes: it
|
|
35
|
+
succeeded, it failed, a newer push superseded it (naming the newer commit),
|
|
36
|
+
or it was still building when the wait ran out. Only an observed failure and
|
|
37
|
+
a deploy that was never visible at all exit non-zero, so a green `--wait`
|
|
38
|
+
means the deploy was observed and did not fail, never merely that the wait
|
|
39
|
+
finished. In `--output json` the outcome is reported as `waitOutcome`.
|
|
40
|
+
|
|
41
|
+
- `pipecat cloud agent list` gains a GitHub column, and `agent status` reports
|
|
42
|
+
the linked repository and branch, the Dockerfile path, whether pushes
|
|
43
|
+
auto-deploy, the commit actually running, and the latest deploy attempt with
|
|
44
|
+
its failure reason. A running commit that did not come from the current link
|
|
45
|
+
(a freshly linked agent, or one whose repository or branch was re-pointed)
|
|
46
|
+
says so and names where it did come from. `pipecat cloud build status` and
|
|
47
|
+
`build list` show the repository, ref and commit a build came from.
|
|
48
|
+
|
|
49
|
+
- Architecture selection on deploy: `pipecat cloud deploy --architecture
|
|
50
|
+
{amd64,arm64}` or an `architecture` key in `pcc-deploy.toml` (the flag
|
|
51
|
+
wins). Omitted, the region's default applies — set it when your image is
|
|
52
|
+
built for one architecture, so it schedules on matching nodes instead of
|
|
53
|
+
failing at startup. The choice is validated against the target region's
|
|
54
|
+
supported architectures before anything uploads, when the API advertises
|
|
55
|
+
them. `pipecat cloud regions list` shows each region's supported and
|
|
56
|
+
default architectures (Daily-hosted regions are arm64-only today), and
|
|
57
|
+
`pipecat cloud agent status` shows the deployment's architecture.
|
|
58
|
+
|
|
59
|
+
- Region lifecycle commands for **self-hosted regions**. `pipecat cloud
|
|
60
|
+
regions register <key>` registers or updates a region (an omit-preserves
|
|
61
|
+
upsert: options you leave out keep their stored values), with a one-question
|
|
62
|
+
display-name prompt so region pickers show a friendly name instead of the
|
|
63
|
+
uppercased key. `regions show <key>` prints the full record, including the
|
|
64
|
+
enrollment status and intermediate certificate expiry. `regions delete
|
|
65
|
+
<key>` revokes a region, surfacing the server's refusal while live sessions
|
|
66
|
+
or deployed services exist; that guard has no bypass, so delete the region's
|
|
67
|
+
agents first, and `--yes` only skips the confirmation prompt.
|
|
68
|
+
`regions enroll-token <key>` mints the one-time enrollment token and
|
|
69
|
+
prints the ready-to-paste `kubectl create secret` command.
|
|
70
|
+
|
|
71
|
+
- New `pipecat cloud organizations registry-keys` command group for the
|
|
72
|
+
pull-only registry credentials that fetch the region package chart:
|
|
73
|
+
`mint` (the key is shown exactly once, with the matching
|
|
74
|
+
`helm registry login` command), `list` (lifecycle metadata only, never key
|
|
75
|
+
material), and `revoke`. With these plus `regions register` and
|
|
76
|
+
`regions enroll-token`, a self-hosted region's control-plane setup needs no
|
|
77
|
+
raw API calls.
|
|
78
|
+
|
|
79
|
+
- Explicit agent sizing for **enterprise (self-hosted) regions**. Deploys can
|
|
80
|
+
now state resources directly instead of selecting an agent profile:
|
|
81
|
+
`pipecat cloud deploy --resources cpu=2,memory=4Gi`, or a `[resources]`
|
|
82
|
+
section in `pcc-deploy.toml` with `cpu` and `memory` keys. Explicit
|
|
83
|
+
resources and `--profile` are mutually exclusive, and the API accepts
|
|
84
|
+
explicit resources only for agents in self-hosted regions.
|
|
85
|
+
|
|
86
|
+
- `pipecat cloud agent status` now shows the agent's resolved resources
|
|
87
|
+
(cpu/memory) from the deployment. For agents deployed with explicit
|
|
88
|
+
resources rather than a profile, this is the sizing surface.
|
|
89
|
+
|
|
90
|
+
- New `pipecat cloud agent profiles` command group. `list` shows the platform
|
|
91
|
+
catalog plus any profiles your organization has defined; `create`, `update`,
|
|
92
|
+
`disable`, and `enable` manage organization-defined profiles for self-hosted
|
|
93
|
+
regions. Profile edits apply to future deploys only: running agents keep the
|
|
94
|
+
resources their deployment captured. Disabling is reversible: a disabled
|
|
95
|
+
profile can't be selected for new deploys until re-enabled.
|
|
96
|
+
|
|
97
|
+
- New `pipecat cloud secrets reference <name> --region <region>` command for
|
|
98
|
+
**self-hosted regions**. It registers an existing Kubernetes Secret from your
|
|
99
|
+
workloads namespace as a secret set, so agents can use it by set name exactly
|
|
100
|
+
as they use managed sets, and the Kubernetes Secret's name becomes the secret
|
|
101
|
+
set name. The secret stays in your cluster: Pipecat Cloud records only its
|
|
102
|
+
name, region, and readiness, never its values or keys, and you keep managing
|
|
103
|
+
its contents with your own tooling such as `kubectl`. `--region` is required
|
|
104
|
+
and must name a self-hosted region; cloud regions are rejected.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- `pipecat cloud deploy` no longer rejects a referenced secret set as "not
|
|
109
|
+
found". The existence check read the set's array of key names and tested it
|
|
110
|
+
for truthiness, but referenced sets carry no key names by design (Daily
|
|
111
|
+
stores name, region and readiness, never the contents), so every healthy
|
|
112
|
+
referenced set failed the check and no self-hosted deploy could use one. The
|
|
113
|
+
check now fetches the set itself; readiness is still enforced server-side.
|
|
114
|
+
|
|
115
|
+
- `pipecat cloud secrets set` no longer treats an existing referenced secret
|
|
116
|
+
set as nonexistent. It had the same key-names misread as the deploy guard,
|
|
117
|
+
so it announced "Creating secret set" and then failed on the server's
|
|
118
|
+
name-uniqueness check. It now refuses before asking for confirmation,
|
|
119
|
+
pointing at the cluster as the place a referenced set's contents are
|
|
120
|
+
managed.
|
|
121
|
+
|
|
122
|
+
- `pipecat cloud agent status` plain output (`--output plain`, or any piped
|
|
123
|
+
invocation) now includes the Architecture and Resources rows. Both were
|
|
124
|
+
rendered only in the rich table, so scripted output silently omitted the
|
|
125
|
+
deployment's architecture and, for agents sized with explicit resources,
|
|
126
|
+
their only visible sizing.
|
|
127
|
+
|
|
8
128
|
## [1.1.0] - 2026-08-05
|
|
9
129
|
|
|
10
130
|
### Added
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
# SPDX-License-Identifier: BSD 2-Clause License
|
|
5
5
|
#
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import functools
|
|
8
9
|
import os
|
|
10
|
+
import time
|
|
9
11
|
from collections.abc import Callable
|
|
10
12
|
from datetime import UTC, datetime
|
|
11
13
|
from enum import Enum
|
|
@@ -301,6 +303,120 @@ class KrispVivaConfig:
|
|
|
301
303
|
return {"audio_filter": self.audio_filter}
|
|
302
304
|
|
|
303
305
|
|
|
306
|
+
# Mirrors the server-side k8s quantity validation (positive decimal + optional
|
|
307
|
+
# suffix). Kept client-side so a typo fails before any network round-trip.
|
|
308
|
+
K8S_QUANTITY_PATTERN = r"^[0-9]+(\.[0-9]+)?(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass
|
|
312
|
+
class ResourcesConfig:
|
|
313
|
+
"""Explicit sizing for agents in enterprise (self-hosted) regions.
|
|
314
|
+
|
|
315
|
+
Mutually exclusive with agent_profile: a deploy either references a named
|
|
316
|
+
profile or states cpu/memory directly. Only accepted by the API for
|
|
317
|
+
services in self-hosted regions.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
cpu: str | None = None
|
|
321
|
+
memory: str | None = None
|
|
322
|
+
|
|
323
|
+
def __attrs_post_init__(self):
|
|
324
|
+
import re
|
|
325
|
+
|
|
326
|
+
if (self.cpu is None) != (self.memory is None):
|
|
327
|
+
raise ValueError("resources requires both 'cpu' and 'memory'")
|
|
328
|
+
for name, value in (("cpu", self.cpu), ("memory", self.memory)):
|
|
329
|
+
if value is not None and not re.match(K8S_QUANTITY_PATTERN, str(value)):
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"Invalid {name} quantity '{value}' (expected e.g. '500m', '2', '4Gi')"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def is_set(self) -> bool:
|
|
335
|
+
return self.cpu is not None
|
|
336
|
+
|
|
337
|
+
def to_dict(self):
|
|
338
|
+
return {"cpu": self.cpu, "memory": self.memory}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def parse_resources_option(value: str) -> "ResourcesConfig":
|
|
342
|
+
"""Parse the --resources CLI value ("cpu=2,memory=4Gi") into a ResourcesConfig.
|
|
343
|
+
|
|
344
|
+
Raises ValueError with a specific message on malformed input (unknown keys,
|
|
345
|
+
missing cpu/memory, bad quantities) so the caller can surface exactly what
|
|
346
|
+
was wrong rather than a generic usage error.
|
|
347
|
+
"""
|
|
348
|
+
parts: dict[str, str] = {}
|
|
349
|
+
for chunk in value.split(","):
|
|
350
|
+
key, sep, val = chunk.partition("=")
|
|
351
|
+
if not sep or not val.strip():
|
|
352
|
+
raise ValueError(
|
|
353
|
+
f"Malformed --resources segment '{chunk.strip()}'. "
|
|
354
|
+
"Expected key=value pairs, e.g. cpu=2,memory=4Gi"
|
|
355
|
+
)
|
|
356
|
+
parts[key.strip()] = val.strip()
|
|
357
|
+
if set(parts.keys()) != {"cpu", "memory"}:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
"--resources requires exactly 'cpu' and 'memory', "
|
|
360
|
+
f"got: {', '.join(sorted(parts.keys())) or 'nothing'}"
|
|
361
|
+
)
|
|
362
|
+
# ResourcesConfig raises its own specific ValueError for bad quantities.
|
|
363
|
+
return ResourcesConfig(cpu=parts["cpu"], memory=parts["memory"])
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@dataclass
|
|
367
|
+
class GitSourceConfig:
|
|
368
|
+
"""A GitHub repo/branch to build the agent from, instead of an image.
|
|
369
|
+
|
|
370
|
+
Only valid when creating an agent: the API accepts `git` on create and
|
|
371
|
+
ignores it on update, so a binding change on an existing agent goes
|
|
372
|
+
through `agent link` rather than silently doing nothing here.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
repo: str | None = None
|
|
376
|
+
branch: str | None = None
|
|
377
|
+
dockerfile_path: str | None = None
|
|
378
|
+
subdirectory: str | None = None
|
|
379
|
+
|
|
380
|
+
def __attrs_post_init__(self):
|
|
381
|
+
# Same rules the API applies, so a typo fails before the round-trip.
|
|
382
|
+
from pipecatcloud._utils.github_utils import (
|
|
383
|
+
is_valid_branch_name,
|
|
384
|
+
is_valid_repo_full_name,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if self.repo is not None and not is_valid_repo_full_name(self.repo):
|
|
388
|
+
raise ValueError(f"Invalid repo '{self.repo}'. Expected the form 'owner/repo'.")
|
|
389
|
+
if self.branch is not None and not is_valid_branch_name(self.branch):
|
|
390
|
+
raise ValueError(
|
|
391
|
+
f"Invalid branch '{self.branch}'. A branch must be a valid git ref with no "
|
|
392
|
+
"empty or dot-only segments."
|
|
393
|
+
)
|
|
394
|
+
# A half-specified source would otherwise reach the API as a plain
|
|
395
|
+
# image deploy, which is a confusing way to learn the branch is missing.
|
|
396
|
+
if (self.repo is None) != (self.branch is None):
|
|
397
|
+
raise ValueError("A GitHub source requires both 'repo' and 'branch'")
|
|
398
|
+
|
|
399
|
+
def is_set(self) -> bool:
|
|
400
|
+
return self.repo is not None
|
|
401
|
+
|
|
402
|
+
def to_payload(self) -> dict:
|
|
403
|
+
"""The `git` object for the create-service request."""
|
|
404
|
+
payload = {"repoFullName": self.repo, "branch": self.branch}
|
|
405
|
+
if self.dockerfile_path:
|
|
406
|
+
payload["dockerfilePath"] = self.dockerfile_path
|
|
407
|
+
if self.subdirectory:
|
|
408
|
+
payload["subdirectory"] = self.subdirectory
|
|
409
|
+
return payload
|
|
410
|
+
|
|
411
|
+
def to_dict(self):
|
|
412
|
+
return {
|
|
413
|
+
"repo": self.repo,
|
|
414
|
+
"branch": self.branch,
|
|
415
|
+
"dockerfile_path": self.dockerfile_path,
|
|
416
|
+
"subdirectory": self.subdirectory,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
304
420
|
@dataclass
|
|
305
421
|
class BuildConfig:
|
|
306
422
|
"""Configuration for cloud builds."""
|
|
@@ -329,10 +445,15 @@ class DeployConfigParams:
|
|
|
329
445
|
docker_config: dict = field(factory=dict)
|
|
330
446
|
build_config: BuildConfig = field(factory=BuildConfig) # Cloud build configuration
|
|
331
447
|
agent_profile: str | None = None
|
|
448
|
+
resources: ResourcesConfig = field(factory=ResourcesConfig)
|
|
332
449
|
krisp_viva: KrispVivaConfig = field(factory=KrispVivaConfig)
|
|
450
|
+
git: GitSourceConfig = field(factory=GitSourceConfig)
|
|
333
451
|
force_redeploy: bool = False
|
|
334
452
|
websocket_auth: str | None = None
|
|
335
453
|
max_session_duration: int | None = None
|
|
454
|
+
# CPU architecture the agent image requires (PCC-1105). Exactly the
|
|
455
|
+
# kubernetes.io/arch vocabulary; omitted = the region's default.
|
|
456
|
+
architecture: str | None = None
|
|
336
457
|
|
|
337
458
|
def __attrs_post_init__(self):
|
|
338
459
|
if self.image is not None and ":" not in self.image:
|
|
@@ -340,8 +461,19 @@ class DeployConfigParams:
|
|
|
340
461
|
# Cannot specify both image and build_id
|
|
341
462
|
if self.image is not None and self.build_id is not None:
|
|
342
463
|
raise ValueError("Cannot specify both 'image' and 'build_id'")
|
|
464
|
+
# A git-sourced agent's image is produced by the build its first deploy
|
|
465
|
+
# triggers, so there is nothing to supply up front. The API rejects the
|
|
466
|
+
# combination too; failing here just does it sooner.
|
|
467
|
+
if self.git.is_set() and (self.image is not None or self.build_id is not None):
|
|
468
|
+
raise ValueError("Cannot specify a GitHub source together with 'image' or 'build_id'")
|
|
343
469
|
if self.max_session_duration is not None and not 60 <= self.max_session_duration <= 14400:
|
|
344
470
|
raise ValueError("max_session_duration must be between 60 and 14400 seconds")
|
|
471
|
+
# Sizing is one of: a named profile, or explicit resources (enterprise
|
|
472
|
+
# regions). The API enforces this too; failing here is just faster.
|
|
473
|
+
if self.agent_profile is not None and self.resources.is_set():
|
|
474
|
+
raise ValueError("Cannot specify both 'agent_profile' and 'resources'")
|
|
475
|
+
if self.architecture is not None and self.architecture not in ("amd64", "arm64"):
|
|
476
|
+
raise ValueError("architecture must be 'amd64' or 'arm64'")
|
|
345
477
|
|
|
346
478
|
def to_dict(self):
|
|
347
479
|
return {
|
|
@@ -355,12 +487,40 @@ class DeployConfigParams:
|
|
|
355
487
|
"docker_config": self.docker_config,
|
|
356
488
|
"build_config": self.build_config.to_dict() if self.build_config else None,
|
|
357
489
|
"agent_profile": self.agent_profile,
|
|
490
|
+
"resources": self.resources.to_dict() if self.resources.is_set() else None,
|
|
358
491
|
"krisp_viva": self.krisp_viva.to_dict() if self.krisp_viva else None,
|
|
492
|
+
"git": self.git.to_dict() if self.git.is_set() else None,
|
|
359
493
|
"websocket_auth": self.websocket_auth,
|
|
360
494
|
"max_session_duration": self.max_session_duration,
|
|
495
|
+
"architecture": self.architecture,
|
|
361
496
|
}
|
|
362
497
|
|
|
363
498
|
|
|
499
|
+
def validate_git_source_combination(config: DeployConfigParams) -> str | None:
|
|
500
|
+
"""What a GitHub source cannot be combined with, or None when it is fine.
|
|
501
|
+
|
|
502
|
+
A separate check from DeployConfigParams' own validation because the deploy
|
|
503
|
+
command mutates the config field by field after constructing it, so
|
|
504
|
+
__attrs_post_init__ never sees the merged result.
|
|
505
|
+
"""
|
|
506
|
+
if not config.git.is_set():
|
|
507
|
+
return None
|
|
508
|
+
if config.image or config.build_id:
|
|
509
|
+
return (
|
|
510
|
+
"Cannot deploy from a GitHub repository and an image or build at the same "
|
|
511
|
+
"time. Drop --image/--build-id, or drop --repo."
|
|
512
|
+
)
|
|
513
|
+
# A git agent's first-deploy config is stashed on its binding, and explicit
|
|
514
|
+
# resources are not plumbed through that path yet, so the API refuses the
|
|
515
|
+
# pair. Saying so here saves the round-trip.
|
|
516
|
+
if config.resources.is_set():
|
|
517
|
+
return (
|
|
518
|
+
"Explicit resources are not yet supported for GitHub-sourced agents. "
|
|
519
|
+
"Use [bold]--profile[/bold] instead."
|
|
520
|
+
)
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
364
524
|
def load_deploy_config_file() -> DeployConfigParams | None:
|
|
365
525
|
from pipecatcloud.cli.config import deploy_config_path
|
|
366
526
|
|
|
@@ -385,6 +545,14 @@ def load_deploy_config_file() -> DeployConfigParams | None:
|
|
|
385
545
|
krisp_viva_data = config_data.pop("krisp_viva", {})
|
|
386
546
|
krisp_viva_config = KrispVivaConfig(**krisp_viva_data)
|
|
387
547
|
|
|
548
|
+
# Extract explicit resources if present (enterprise regions)
|
|
549
|
+
resources_data = config_data.pop("resources", {})
|
|
550
|
+
resources_config = ResourcesConfig(**resources_data)
|
|
551
|
+
|
|
552
|
+
# Extract GitHub source if present (PCC-933)
|
|
553
|
+
git_data = config_data.pop("git", {})
|
|
554
|
+
git_config = GitSourceConfig(**git_data)
|
|
555
|
+
|
|
388
556
|
# Extract build configuration if present
|
|
389
557
|
build_data = config_data.pop("build", {})
|
|
390
558
|
exclude_data = build_data.pop("exclude", {})
|
|
@@ -408,8 +576,11 @@ def load_deploy_config_file() -> DeployConfigParams | None:
|
|
|
408
576
|
"build",
|
|
409
577
|
"agent_profile",
|
|
410
578
|
"krisp_viva",
|
|
579
|
+
"git",
|
|
411
580
|
"websocket_auth",
|
|
412
581
|
"max_session_duration",
|
|
582
|
+
"resources",
|
|
583
|
+
"architecture",
|
|
413
584
|
}
|
|
414
585
|
|
|
415
586
|
# TODO: Remove this enable_krisp migration hint in the 2.0.0 release.
|
|
@@ -429,6 +600,8 @@ def load_deploy_config_file() -> DeployConfigParams | None:
|
|
|
429
600
|
docker_config=docker_data,
|
|
430
601
|
build_config=build_config,
|
|
431
602
|
krisp_viva=krisp_viva_config,
|
|
603
|
+
resources=resources_config,
|
|
604
|
+
git=git_config,
|
|
432
605
|
)
|
|
433
606
|
|
|
434
607
|
return validated_config
|
|
@@ -477,3 +650,156 @@ def with_deploy_config(func: Callable) -> Callable:
|
|
|
477
650
|
return func(*args, **kwargs)
|
|
478
651
|
|
|
479
652
|
return wrapper
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# How long `--wait` follows a GitHub deploy before handing it back. A GitHub
|
|
656
|
+
# deploy builds an image first, so it is legitimately slower than an image
|
|
657
|
+
# deploy; the cap exists so a wedged build can't hang a CI job forever, not to
|
|
658
|
+
# express an expected duration.
|
|
659
|
+
GIT_DEPLOY_WAIT_SECONDS = 20 * 60
|
|
660
|
+
GIT_DEPLOY_POLL_SECONDS = 5
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
class GitDeployWait(Enum):
|
|
664
|
+
"""How a `--wait` ended. Four outcomes, because three of them are not
|
|
665
|
+
"succeeded" and collapsing them loses the distinction that matters."""
|
|
666
|
+
|
|
667
|
+
# A status we actually observed reach a terminal state.
|
|
668
|
+
TERMINAL = "terminal"
|
|
669
|
+
# Observed, still moving when the budget ran out. Says nothing bad about
|
|
670
|
+
# the deploy.
|
|
671
|
+
IN_FLIGHT = "in_flight"
|
|
672
|
+
# A newer attempt became the agent's latest, so ours is no longer
|
|
673
|
+
# observable (and, unless it had already reached `deploying`, was
|
|
674
|
+
# cancelled by the supersede).
|
|
675
|
+
SUPERSEDED = "superseded"
|
|
676
|
+
# Never saw our attempt at all: every poll failed, or no attempt was ever
|
|
677
|
+
# enqueued. We know nothing, which is different from knowing it is fine.
|
|
678
|
+
UNOBSERVED = "unobserved"
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@dataclass
|
|
682
|
+
class GitDeployResult:
|
|
683
|
+
outcome: GitDeployWait
|
|
684
|
+
deploy: dict | None = None
|
|
685
|
+
superseded_by: str | None = None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
async def follow_git_deploy(agent_name: str, org: str | None, commit_sha: str) -> GitDeployResult:
|
|
689
|
+
"""Poll an agent's latest deploy attempt until it resolves.
|
|
690
|
+
|
|
691
|
+
Polls the service read endpoint rather than a deploy-intent endpoint: the
|
|
692
|
+
API surfaces the attempt as `latestDeploy` (PCC-978), which is also what
|
|
693
|
+
`agent status` reads, so both agree on what a deploy is doing.
|
|
694
|
+
|
|
695
|
+
`commit_sha` is the attempt we are entitled to report on. Empty means
|
|
696
|
+
"whatever attempt exists", which is only correct for an agent's first
|
|
697
|
+
deploy, where there is nothing that could have superseded it.
|
|
698
|
+
"""
|
|
699
|
+
# Imported at call time: api.py imports this module for DeployConfigParams,
|
|
700
|
+
# so a module-level import of the API client would close that cycle.
|
|
701
|
+
from pipecatcloud._utils.console_utils import console
|
|
702
|
+
from pipecatcloud._utils.github_utils import is_deploy_in_flight
|
|
703
|
+
from pipecatcloud.cli.api import API
|
|
704
|
+
|
|
705
|
+
deadline = time.monotonic() + GIT_DEPLOY_WAIT_SECONDS
|
|
706
|
+
latest: dict | None = None
|
|
707
|
+
last_status: str | None = None
|
|
708
|
+
|
|
709
|
+
with console.status("[dim]Waiting for the deploy...[/dim]", spinner="bouncingBar") as live:
|
|
710
|
+
while time.monotonic() < deadline:
|
|
711
|
+
# A blip mid-deploy should not abort a wait that is otherwise
|
|
712
|
+
# healthy; keep polling and let the deadline decide.
|
|
713
|
+
data, error = await API.bubble_error().agent(agent_name=agent_name, org=org)
|
|
714
|
+
if not error and data:
|
|
715
|
+
candidate = data.get("latestDeploy")
|
|
716
|
+
if candidate:
|
|
717
|
+
found_sha = candidate.get("commitSha")
|
|
718
|
+
# A different commit is positive evidence, not an absence
|
|
719
|
+
# of it. The API reads this from the primary ordered by
|
|
720
|
+
# created_at DESC, and our own intent was already
|
|
721
|
+
# committed when the trigger answered 202 — so anything
|
|
722
|
+
# else holding "latest" is strictly newer, and ours can
|
|
723
|
+
# never reclaim the spot. Waiting out the budget here
|
|
724
|
+
# would report a stale snapshot twenty minutes later.
|
|
725
|
+
if commit_sha and found_sha != commit_sha:
|
|
726
|
+
return GitDeployResult(
|
|
727
|
+
GitDeployWait.SUPERSEDED,
|
|
728
|
+
deploy=latest,
|
|
729
|
+
superseded_by=found_sha,
|
|
730
|
+
)
|
|
731
|
+
latest = candidate
|
|
732
|
+
status_value = candidate.get("status")
|
|
733
|
+
if status_value != last_status:
|
|
734
|
+
last_status = status_value
|
|
735
|
+
live.update(f"[dim]Deploy {status_value}...[/dim]")
|
|
736
|
+
if not is_deploy_in_flight(candidate):
|
|
737
|
+
return GitDeployResult(GitDeployWait.TERMINAL, deploy=latest)
|
|
738
|
+
await asyncio.sleep(GIT_DEPLOY_POLL_SECONDS)
|
|
739
|
+
|
|
740
|
+
if latest is None:
|
|
741
|
+
return GitDeployResult(GitDeployWait.UNOBSERVED)
|
|
742
|
+
return GitDeployResult(GitDeployWait.IN_FLIGHT, deploy=latest)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def report_git_deploy_result(
|
|
746
|
+
result: GitDeployResult, agent_name: str, *, first_deploy: bool = False
|
|
747
|
+
) -> None:
|
|
748
|
+
"""Render a finished `--wait` and set the exit code.
|
|
749
|
+
|
|
750
|
+
Shared by both wait call sites so the four outcomes can't be interpreted
|
|
751
|
+
differently in two places.
|
|
752
|
+
|
|
753
|
+
On exit codes: only an observed failure and a never-observed deploy exit
|
|
754
|
+
non-zero. A deploy still building when the budget runs out has not failed,
|
|
755
|
+
and neither has one that a newer push took over. But a wait that never saw
|
|
756
|
+
anything cannot tell a healthy build from an API that was down the whole
|
|
757
|
+
time, and reporting that as success is what would make `--wait` unsafe as
|
|
758
|
+
a deploy gate.
|
|
759
|
+
"""
|
|
760
|
+
from pipecatcloud._utils.console_utils import console
|
|
761
|
+
from pipecatcloud._utils.github_utils import short_sha
|
|
762
|
+
from pipecatcloud.cli import PIPECAT_CLI_NAME
|
|
763
|
+
|
|
764
|
+
check_hint = (
|
|
765
|
+
f"[dim]Check it with [bold]{PIPECAT_CLI_NAME} agent status {agent_name}[/bold].[/dim]"
|
|
766
|
+
)
|
|
767
|
+
label = "First deploy" if first_deploy else "Deploy"
|
|
768
|
+
|
|
769
|
+
if result.outcome is GitDeployWait.TERMINAL:
|
|
770
|
+
deploy = result.deploy or {}
|
|
771
|
+
status_value = deploy.get("status")
|
|
772
|
+
commit = deploy.get("commitSha")
|
|
773
|
+
if status_value == "succeeded":
|
|
774
|
+
console.success(
|
|
775
|
+
f"Deployed '{agent_name}'" + (f" from commit {short_sha(commit)}" if commit else "")
|
|
776
|
+
)
|
|
777
|
+
return
|
|
778
|
+
reason = deploy.get("reason") or "No reason reported."
|
|
779
|
+
console.error(f"{label} of '{agent_name}' {status_value}.\n{reason}")
|
|
780
|
+
raise typer.Exit(1)
|
|
781
|
+
|
|
782
|
+
if result.outcome is GitDeployWait.SUPERSEDED:
|
|
783
|
+
newer = short_sha(result.superseded_by) if result.superseded_by else "a newer commit"
|
|
784
|
+
console.print(
|
|
785
|
+
f"[yellow]Superseded: a newer deploy ({newer}) is now the latest attempt for "
|
|
786
|
+
f"'{agent_name}', so this one's result is no longer reported.[/yellow]\n" + check_hint
|
|
787
|
+
)
|
|
788
|
+
return
|
|
789
|
+
|
|
790
|
+
if result.outcome is GitDeployWait.IN_FLIGHT:
|
|
791
|
+
status_value = (result.deploy or {}).get("status") or "in progress"
|
|
792
|
+
console.print(
|
|
793
|
+
f"[yellow]Still {status_value} after waiting. The deploy continues "
|
|
794
|
+
f"server-side.[/yellow]\n" + check_hint
|
|
795
|
+
)
|
|
796
|
+
return
|
|
797
|
+
|
|
798
|
+
# UNOBSERVED. Never assert progress we did not see.
|
|
799
|
+
console.error(
|
|
800
|
+
f"Could not confirm the deploy of '{agent_name}': no deploy attempt was visible "
|
|
801
|
+
"while waiting.\n"
|
|
802
|
+
"[dim]The API may have been unreachable, or the attempt may never have been "
|
|
803
|
+
f"queued.[/dim]\n" + check_hint
|
|
804
|
+
)
|
|
805
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2025, Daily
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: BSD 2-Clause License
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
"""Shared helpers for the GitHub integration commands (PCC-933).
|
|
8
|
+
|
|
9
|
+
Reading an agent's binding: the API's `git` object is the *configured* intent,
|
|
10
|
+
while `deployedCommit` is the provenance of what is actually running. The two
|
|
11
|
+
can legitimately disagree — a freshly linked agent still serves its pre-link
|
|
12
|
+
image until the next deploy — so they are reported separately rather than
|
|
13
|
+
collapsed into one "GitHub" status.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
# Mirrors the API's isValidRepoFullName. Encoding alone is not enough on the
|
|
19
|
+
# wire: a pure-dot segment still retargets the path after normalization, so the
|
|
20
|
+
# shape is checked rather than escaped.
|
|
21
|
+
_REPO_SEGMENT = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
22
|
+
|
|
23
|
+
# Matches the git_configs.dockerfile_path column default.
|
|
24
|
+
DEFAULT_DOCKERFILE_PATH = "Dockerfile"
|
|
25
|
+
|
|
26
|
+
_REF_BRANCH_PREFIX = "refs/heads/"
|
|
27
|
+
|
|
28
|
+
# Deploy-intent states that mean an attempt is still in flight. Anything else
|
|
29
|
+
# is terminal, which is what the --wait polling keys off.
|
|
30
|
+
IN_FLIGHT_DEPLOY_STATUSES = ("pending", "building", "deploying")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_valid_repo_full_name(repo_full_name: str) -> bool:
|
|
34
|
+
"""True for exactly two URL-safe `owner/repo` segments, no pure-dot segment."""
|
|
35
|
+
segments = repo_full_name.split("/")
|
|
36
|
+
return len(segments) == 2 and all(
|
|
37
|
+
_REPO_SEGMENT.match(segment) and not re.fullmatch(r"\.+", segment) for segment in segments
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_valid_branch_name(branch: str) -> bool:
|
|
42
|
+
"""True for a git ref with no empty or pure-dot segment.
|
|
43
|
+
|
|
44
|
+
Rejecting these at bind time is what stops a branch that can never be
|
|
45
|
+
resolved from failing on the first deploy instead.
|
|
46
|
+
"""
|
|
47
|
+
if not branch:
|
|
48
|
+
return False
|
|
49
|
+
return all(segment and not re.fullmatch(r"\.+", segment) for segment in branch.split("/"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def short_sha(sha: str) -> str:
|
|
53
|
+
return sha[:7]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ref_to_branch(ref: str) -> str:
|
|
57
|
+
"""`refs/heads/main` -> `main`. Anything else is returned unchanged."""
|
|
58
|
+
return ref[len(_REF_BRANCH_PREFIX) :] if ref.startswith(_REF_BRANCH_PREFIX) else ref
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_deploy_in_flight(deploy: dict | None) -> bool:
|
|
62
|
+
return bool(deploy) and (deploy or {}).get("status") in IN_FLIGHT_DEPLOY_STATUSES
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def installation_settings_url(installation: dict) -> str:
|
|
66
|
+
"""The GitHub page for managing this installation's repo access.
|
|
67
|
+
|
|
68
|
+
The path differs for organization and user accounts; the API normalizes
|
|
69
|
+
`githubAccountType` to lowercase.
|
|
70
|
+
"""
|
|
71
|
+
login = installation.get("githubAccountLogin", "")
|
|
72
|
+
installation_id = installation.get("githubInstallationId", "")
|
|
73
|
+
if str(installation.get("githubAccountType", "")).lower() == "organization":
|
|
74
|
+
return f"https://github.com/organizations/{login}/settings/installations/{installation_id}"
|
|
75
|
+
return f"https://github.com/settings/installations/{installation_id}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def repo_url(repo_full_name: str) -> str:
|
|
79
|
+
return f"https://github.com/{repo_full_name}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def commit_url(repo_full_name: str, sha: str) -> str:
|
|
83
|
+
return f"https://github.com/{repo_full_name}/commit/{sha}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def binding_summary(git: dict | None) -> str:
|
|
87
|
+
"""One-line `owner/repo@branch` for a binding, or a dash when unlinked."""
|
|
88
|
+
if not git:
|
|
89
|
+
return "—"
|
|
90
|
+
return f"{git.get('repoFullName', '?')}@{git.get('branch', '?')}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def describe_deploy(deploy: dict | None) -> str:
|
|
94
|
+
"""A deploy attempt as `status (abc1234)`, with its reason when it failed."""
|
|
95
|
+
if not deploy:
|
|
96
|
+
return "—"
|
|
97
|
+
status = deploy.get("status", "unknown")
|
|
98
|
+
sha = deploy.get("commitSha")
|
|
99
|
+
text = f"{status} ({short_sha(sha)})" if sha else status
|
|
100
|
+
reason = deploy.get("reason")
|
|
101
|
+
if reason and status in ("failed", "cancelled"):
|
|
102
|
+
text += f": {reason}"
|
|
103
|
+
return text
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_running_linked_binding(git: dict | None, deployed_commit: dict | None) -> bool:
|
|
107
|
+
"""Whether what is running actually came from the linked repo and branch.
|
|
108
|
+
|
|
109
|
+
False for the three states worth calling out: an agent just linked and
|
|
110
|
+
still serving its pre-link image, one whose repo was re-pointed, and one
|
|
111
|
+
whose branch was changed. All are legitimate, and all mean nothing from
|
|
112
|
+
this binding is live yet.
|
|
113
|
+
|
|
114
|
+
Repo names compare case-insensitively (GitHub treats owner/repo that way);
|
|
115
|
+
branches compare exactly, since git refs are case-sensitive. A missing
|
|
116
|
+
`ref` means "unknown", not "mismatched" — an older build recorded without
|
|
117
|
+
one says nothing about the branch.
|
|
118
|
+
"""
|
|
119
|
+
if not git or not deployed_commit:
|
|
120
|
+
return False
|
|
121
|
+
deployed_repo = deployed_commit.get("repoFullName")
|
|
122
|
+
if not deployed_repo:
|
|
123
|
+
return False
|
|
124
|
+
if deployed_repo.lower() != str(git.get("repoFullName", "")).lower():
|
|
125
|
+
return False
|
|
126
|
+
ref = deployed_commit.get("ref")
|
|
127
|
+
if not ref:
|
|
128
|
+
return True
|
|
129
|
+
return ref_to_branch(ref) == git.get("branch")
|