plugsync-cli 0.4.0__tar.gz → 0.5.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.
Files changed (43) hide show
  1. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/PKG-INFO +3 -1
  2. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/README.md +2 -0
  3. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/__init__.py +1 -1
  4. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/client.py +25 -0
  5. plugsync_cli-0.5.0/plugsync_cli/commands/connector.py +253 -0
  6. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/main.py +2 -0
  7. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/PKG-INFO +3 -1
  8. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/SOURCES.txt +2 -0
  9. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/pyproject.toml +1 -1
  10. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_api_contract.py +4 -0
  11. plugsync_cli-0.5.0/tests/test_connector_commands.py +285 -0
  12. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/bundler.py +0 -0
  13. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/__init__.py +0 -0
  14. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/auth.py +0 -0
  15. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/diff.py +0 -0
  16. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/log.py +0 -0
  17. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/plugin.py +0 -0
  18. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/preview.py +0 -0
  19. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/pull.py +0 -0
  20. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/push.py +0 -0
  21. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/rollback.py +0 -0
  22. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/commands/validate.py +0 -0
  23. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/compat.py +0 -0
  24. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/config.py +0 -0
  25. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/context.py +0 -0
  26. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/findings.py +0 -0
  27. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/schema_defaults.py +0 -0
  28. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli/serializer.py +0 -0
  29. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/dependency_links.txt +0 -0
  30. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/entry_points.txt +0 -0
  31. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/requires.txt +0 -0
  32. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/plugsync_cli.egg-info/top_level.txt +0 -0
  33. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/setup.cfg +0 -0
  34. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_auth_commands.py +0 -0
  35. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_client.py +0 -0
  36. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_commands.py +0 -0
  37. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_compat.py +0 -0
  38. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_config.py +0 -0
  39. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_e2e_draft_api.py +0 -0
  40. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_hand_edited_yaml.py +0 -0
  41. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_plugin_commands.py +0 -0
  42. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_schema_defaults.py +0 -0
  43. {plugsync_cli-0.4.0 → plugsync_cli-0.5.0}/tests/test_serializer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
5
5
  Author-email: Exelab <hello@plugsync.com>
6
6
  License: Proprietary
@@ -75,6 +75,8 @@ override the config file, handy for CI.
75
75
  | `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
76
76
  | `plugsync log <name>` | Show a connector's published version history |
77
77
  | `plugsync rollback <name>` | Restore a published version into the draft |
78
+ | `plugsync connector breakers <name>` | List the connector's flows whose circuit breaker is open |
79
+ | `plugsync connector flow retry <name> <flow>` | Retry one blocked flow now, instead of waiting for its automatic attempt |
78
80
  | `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
79
81
 
80
82
  Run `plugsync --help` or `plugsync <command> --help` for the full option list.
@@ -44,6 +44,8 @@ override the config file, handy for CI.
44
44
  | `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
45
45
  | `plugsync log <name>` | Show a connector's published version history |
46
46
  | `plugsync rollback <name>` | Restore a published version into the draft |
47
+ | `plugsync connector breakers <name>` | List the connector's flows whose circuit breaker is open |
48
+ | `plugsync connector flow retry <name> <flow>` | Retry one blocked flow now, instead of waiting for its automatic attempt |
47
49
  | `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
48
50
 
49
51
  Run `plugsync --help` or `plugsync <command> --help` for the full option list.
@@ -1,2 +1,2 @@
1
1
  """PluSync CLI — git-like connector management."""
2
- __version__ = "0.4.0"
2
+ __version__ = "0.5.0"
@@ -1,5 +1,6 @@
1
1
  """HTTP client wrapper for PluSync REST API."""
2
2
  import json
3
+ from urllib.parse import quote
3
4
 
4
5
  import httpx
5
6
 
@@ -120,6 +121,30 @@ class PlugSyncClient:
120
121
  r.raise_for_status()
121
122
  return r.json()
122
123
 
124
+ def retry_flow(self, connector_id: str, flow_name: str) -> dict:
125
+ """Retry one flow whose circuit breaker is open, returning the
126
+ refreshed connector.
127
+
128
+ The API moves the breaker to `half_open`, which makes the next message
129
+ through that flow the recovery probe: it is not a close, and a failed
130
+ probe re-opens the breaker with a longer cooldown. Idempotent when the
131
+ breaker is already half-open; 404 when the flow has no open breaker at
132
+ all, which the command translates into "nothing to retry".
133
+
134
+ The flow name is a config value, not a URL-safe token, so it is quoted
135
+ with no safe characters: a flow called `orders/eu` has to reach the API
136
+ as one path segment rather than two.
137
+ """
138
+ with self._client() as c:
139
+ r = c.post(
140
+ self._url(
141
+ f"/connectors/{connector_id}/flows/"
142
+ f"{quote(flow_name, safe='')}/retry"
143
+ )
144
+ )
145
+ r.raise_for_status()
146
+ return r.json()
147
+
123
148
  def get_draft(self, connector_id: str) -> dict:
124
149
  """The connector's editable config (backend DraftResponse).
125
150
 
@@ -0,0 +1,253 @@
1
+ """plugsync connector -- read a connector's flow circuit breakers, retry one.
2
+
3
+ Self-service only, with the same org-scoped auth as every other command: the
4
+ owner of a connector lists the flows whose circuit breaker is open and retries
5
+ one of them, from a script or from CI, without opening the dashboard. The
6
+ platform-side force-close is a different operation with a different
7
+ authentication contract (it refuses an org API key) and deliberately has no
8
+ command here.
9
+ """
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+
13
+ import click
14
+ import httpx
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from plugsync_cli.client import PlugSyncClient
19
+ from plugsync_cli.context import build_client, fail
20
+
21
+ console = Console()
22
+
23
+ _CONNECTOR_NOT_FOUND = (
24
+ "Connector '{label}' not found, or it does not belong to this organization."
25
+ )
26
+
27
+
28
+ def _looks_like_a_connector_id(reference: str) -> bool:
29
+ try:
30
+ uuid.UUID(reference)
31
+ except ValueError:
32
+ return False
33
+ return True
34
+
35
+
36
+ def _resolve_connector(client: PlugSyncClient, reference: str) -> tuple[str, str]:
37
+ """A connector reference (name or id) as `(connector_id, label)`.
38
+
39
+ A UUID is taken as the id and used as-is: resolving it through the
40
+ connector listing would cost a round trip to learn a name the endpoints
41
+ already return in their own response.
42
+ """
43
+ if _looks_like_a_connector_id(reference):
44
+ return reference, reference
45
+ connector = client.find_connector(reference)
46
+ if not connector:
47
+ fail(f"Connector '{reference}' not found")
48
+ return connector["id"], connector.get("name") or reference
49
+
50
+
51
+ def _detail(exc: httpx.HTTPStatusError) -> str:
52
+ """The API's `detail` string, or "" when the body carries none."""
53
+ try:
54
+ payload = exc.response.json()
55
+ except ValueError:
56
+ return ""
57
+ detail = payload.get("detail") if isinstance(payload, dict) else None
58
+ return detail if isinstance(detail, str) else ""
59
+
60
+
61
+ def _open_breakers(connector: dict) -> list[dict]:
62
+ """The connector's non-closed flow breakers, one shape for two payloads.
63
+
64
+ `health.flows` is the richer form and the one to read. An API that predates
65
+ it answers only the legacy list, which carries the same flows under other
66
+ key names: normalizing it here keeps such a backend from reading as a
67
+ connector with nothing blocked.
68
+ """
69
+ flows = (connector.get("health") or {}).get("flows")
70
+ if flows:
71
+ return flows
72
+ return [
73
+ {
74
+ "name": flow.get("flow_name"),
75
+ "state": flow.get("state"),
76
+ "detail": flow.get("reason"),
77
+ "opened_at": flow.get("tripped_at"),
78
+ }
79
+ for flow in connector.get("tripped_flows") or []
80
+ ]
81
+
82
+
83
+ def _breaker_for(connector: dict, flow_name: str) -> dict | None:
84
+ for breaker in _open_breakers(connector):
85
+ if breaker.get("name") == flow_name:
86
+ return breaker
87
+ return None
88
+
89
+
90
+ def _reason(breaker: dict) -> str:
91
+ """The human-readable diagnostic, falling back to the machine code."""
92
+ return str(breaker.get("detail") or breaker.get("reason") or "")
93
+
94
+
95
+ def _relative(value, *, now: datetime | None = None) -> str:
96
+ """An ISO timestamp as a short relative time, past or future.
97
+
98
+ Both directions matter in one table: a breaker opened in the past, and a
99
+ cooldown that expires in the future. An unparseable value is printed as it
100
+ arrived rather than dropped.
101
+ """
102
+ if not value:
103
+ return ""
104
+ try:
105
+ moment = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
106
+ except ValueError:
107
+ return str(value)
108
+ if moment.tzinfo is None:
109
+ moment = moment.replace(tzinfo=timezone.utc)
110
+
111
+ delta = (moment - (now or datetime.now(timezone.utc))).total_seconds()
112
+ seconds = int(abs(delta))
113
+ if seconds < 60:
114
+ return "now"
115
+ if seconds < 3600:
116
+ magnitude = f"{seconds // 60}m"
117
+ elif seconds < 86400:
118
+ magnitude = f"{seconds // 3600}h"
119
+ else:
120
+ magnitude = f"{seconds // 86400}d"
121
+ return f"in {magnitude}" if delta > 0 else f"{magnitude} ago"
122
+
123
+
124
+ @click.group()
125
+ def connector():
126
+ """Inspect a connector's operational state."""
127
+ pass
128
+
129
+
130
+ @connector.group()
131
+ def flow():
132
+ """Act on one event flow of a connector."""
133
+ pass
134
+
135
+
136
+ @flow.command("retry")
137
+ @click.argument("connector_reference", metavar="CONNECTOR")
138
+ @click.argument("flow_name", metavar="FLOW")
139
+ def flow_retry(connector_reference: str, flow_name: str):
140
+ """Retry a flow whose circuit breaker is open.
141
+
142
+ CONNECTOR is a connector name or id, FLOW the event flow name. The breaker
143
+ moves to half-open: the next message through the flow is the recovery
144
+ probe. It is not a force-close, and a failed probe re-opens the breaker
145
+ with a longer cooldown.
146
+
147
+ Examples:
148
+ plugsync connector flow retry my-connector shop-orders
149
+ """
150
+ client = build_client()
151
+ connector_id, label = _resolve_connector(client, connector_reference)
152
+
153
+ try:
154
+ updated = client.retry_flow(connector_id, flow_name)
155
+ except httpx.HTTPStatusError as exc:
156
+ if exc.response.status_code != 404:
157
+ raise
158
+ if "connector not found" in _detail(exc).lower():
159
+ fail(_CONNECTOR_NOT_FOUND.format(label=label))
160
+ fail(
161
+ f"Flow '{flow_name}' has no open circuit breaker on '{label}': "
162
+ f"nothing to retry. Run 'plugsync connector breakers {label}' to "
163
+ "list the flows that are blocked."
164
+ )
165
+
166
+ name = updated.get("name") or label
167
+ breaker = _breaker_for(updated, flow_name)
168
+ if breaker is None:
169
+ console.print(
170
+ f"Retry accepted for flow '{flow_name}' on connector '{name}', but "
171
+ "the flow no longer appears among its open circuit breakers: there "
172
+ "is no breaker state left to report.",
173
+ soft_wrap=True,
174
+ )
175
+ return
176
+
177
+ state = breaker.get("state") or "unknown"
178
+ console.print(
179
+ f"[green]Flow '{flow_name}' on connector '{name}': breaker is now "
180
+ f"{state}.[/green]",
181
+ soft_wrap=True,
182
+ )
183
+ if state == "half_open":
184
+ console.print(
185
+ "The next message through this flow is the recovery probe: if it "
186
+ "succeeds the breaker closes, if it fails the breaker re-opens "
187
+ "with a longer cooldown.",
188
+ soft_wrap=True,
189
+ )
190
+
191
+
192
+ @connector.command("breakers")
193
+ @click.argument("connector_reference", metavar="CONNECTOR")
194
+ def connector_breakers(connector_reference: str):
195
+ """List the flows of a connector whose circuit breaker is open.
196
+
197
+ CONNECTOR is a connector name or id. Read-only, and always exits 0 when it
198
+ could read the connector: a blocked flow is not a command failure.
199
+
200
+ Examples:
201
+ plugsync connector breakers my-connector
202
+ """
203
+ client = build_client()
204
+ connector_id, label = _resolve_connector(client, connector_reference)
205
+
206
+ try:
207
+ data = client.get_connector(connector_id)
208
+ except httpx.HTTPStatusError as exc:
209
+ if exc.response.status_code != 404:
210
+ raise
211
+ fail(_CONNECTOR_NOT_FOUND.format(label=label))
212
+
213
+ name = data.get("name") or label
214
+ breakers = _open_breakers(data)
215
+ if not breakers:
216
+ console.print(f"[green]No open circuit breaker on '{name}'.[/green]")
217
+ return
218
+
219
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
220
+ # `overflow="fold"` on the flow name: rich shrinks the widest column when
221
+ # the table outgrows the terminal, and the default is an ellipsis. The
222
+ # name is exactly what the operator has to retype into `flow retry`, so it
223
+ # wraps rather than being cut. `Reason` comes last for the same reason --
224
+ # it is the column the wrapping should land on.
225
+ table.add_column("Flow", overflow="fold")
226
+ table.add_column("State")
227
+ table.add_column("Opened")
228
+ table.add_column("Retry after")
229
+ table.add_column("Opens", justify="right")
230
+ table.add_column("Reason", overflow="fold")
231
+
232
+ for breaker in breakers:
233
+ opens = breaker.get("opens")
234
+ table.add_row(
235
+ str(breaker.get("name") or ""),
236
+ str(breaker.get("state") or ""),
237
+ _relative(breaker.get("opened_at")),
238
+ _relative(breaker.get("retry_after_at")),
239
+ "" if opens is None else str(opens),
240
+ _reason(breaker),
241
+ )
242
+
243
+ console.print(f"\n[bold]Open circuit breakers: {name}[/bold]\n")
244
+ console.print(table)
245
+ console.print(
246
+ f"\nRetry one now with: plugsync connector flow retry {name} <flow>",
247
+ soft_wrap=True,
248
+ )
249
+ console.print(
250
+ "A flow whose retry window has already passed is probed automatically "
251
+ "by the next message it receives.\n",
252
+ soft_wrap=True,
253
+ )
@@ -115,6 +115,7 @@ from plugsync_cli.commands.log import log_cmd
115
115
  from plugsync_cli.commands.rollback import rollback
116
116
  from plugsync_cli.commands.plugin import plugin
117
117
  from plugsync_cli.commands.auth import auth
118
+ from plugsync_cli.commands.connector import connector
118
119
 
119
120
  cli.add_command(pull)
120
121
  cli.add_command(push)
@@ -125,3 +126,4 @@ cli.add_command(log_cmd)
125
126
  cli.add_command(rollback)
126
127
  cli.add_command(plugin)
127
128
  cli.add_command(auth)
129
+ cli.add_command(connector)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plugsync-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required
5
5
  Author-email: Exelab <hello@plugsync.com>
6
6
  License: Proprietary
@@ -75,6 +75,8 @@ override the config file, handy for CI.
75
75
  | `plugsync preview <name>` | Preview a publish's effects and its preflight findings |
76
76
  | `plugsync log <name>` | Show a connector's published version history |
77
77
  | `plugsync rollback <name>` | Restore a published version into the draft |
78
+ | `plugsync connector breakers <name>` | List the connector's flows whose circuit breaker is open |
79
+ | `plugsync connector flow retry <name> <flow>` | Retry one blocked flow now, instead of waiting for its automatic attempt |
78
80
  | `plugsync plugin init/push/list/status/logs/metrics/invoke/promote/publish/rollback` | Author and manage plugins (Enterprise tier) |
79
81
 
80
82
  Run `plugsync --help` or `plugsync <command> --help` for the full option list.
@@ -18,6 +18,7 @@ plugsync_cli.egg-info/requires.txt
18
18
  plugsync_cli.egg-info/top_level.txt
19
19
  plugsync_cli/commands/__init__.py
20
20
  plugsync_cli/commands/auth.py
21
+ plugsync_cli/commands/connector.py
21
22
  plugsync_cli/commands/diff.py
22
23
  plugsync_cli/commands/log.py
23
24
  plugsync_cli/commands/plugin.py
@@ -32,6 +33,7 @@ tests/test_client.py
32
33
  tests/test_commands.py
33
34
  tests/test_compat.py
34
35
  tests/test_config.py
36
+ tests/test_connector_commands.py
35
37
  tests/test_e2e_draft_api.py
36
38
  tests/test_hand_edited_yaml.py
37
39
  tests/test_plugin_commands.py
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
11
11
 
12
12
  [project]
13
13
  name = "plugsync-cli"
14
- version = "0.4.0"
14
+ version = "0.5.0"
15
15
  description = "Command-line client for plugsync HubSpot connectors -- manage connectors and plugins like code, no repo checkout required"
16
16
  readme = "README.md"
17
17
  license = { text = "Proprietary" }
@@ -64,6 +64,10 @@ DELEGATING_CALLS: dict[str, tuple] = {
64
64
  CLIENT_CALLS: dict[str, tuple] = {
65
65
  "list_connectors": (),
66
66
  "get_connector": (CONNECTOR_ID,),
67
+ # A flow name with no character the path has to escape: the guard resolves
68
+ # a concrete path against the OpenAPI templates segment by segment, so a
69
+ # percent-encoded sentinel would only make the match harder to read.
70
+ "retry_flow": (CONNECTOR_ID, "shop-orders"),
67
71
  "get_draft": (CONNECTOR_ID,),
68
72
  "put_draft": (CONNECTOR_ID, {"version": "3.1", "entities": [], "flows": []}),
69
73
  "config_surface": (),
@@ -0,0 +1,285 @@
1
+ """Tests for `plugsync connector` -- flow circuit breakers over the org key.
2
+
3
+ The client is mocked here, as in test_commands.py: these cover argument
4
+ handling, the rendering of a `ConnectorResponse` and the 404 branches. That
5
+ the mocked calls hit endpoints which actually exist is
6
+ `test_api_contract.py`'s job.
7
+ """
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import httpx
11
+ from click.testing import CliRunner
12
+
13
+ from plugsync_cli.main import cli
14
+
15
+ CONNECTOR_ID = "11111111-1111-1111-1111-111111111111"
16
+ ORG_ID = "22222222-2222-2222-2222-222222222222"
17
+
18
+
19
+ def _flat(output: str) -> str:
20
+ """Output with wrapping collapsed: rich hard-wraps at the console width,
21
+ so a message is asserted on its words, not on where the line broke."""
22
+ return " ".join(output.split())
23
+
24
+
25
+ def _breaker(name: str = "shop-orders", state: str = "open", **overrides) -> dict:
26
+ """One `health.flows` entry (backend `FlowBreakerState`)."""
27
+ row = {
28
+ "name": name,
29
+ "state": state,
30
+ "reason": "fatal_streak",
31
+ "detail": "upsert refused",
32
+ "opened_at": "2026-09-10T08:00:00Z",
33
+ "retry_after_at": "2026-09-10T08:05:00Z",
34
+ "cooldown_seconds": 300,
35
+ "opens": 2,
36
+ }
37
+ row.update(overrides)
38
+ return row
39
+
40
+
41
+ def _connector(
42
+ name: str = "shop",
43
+ breakers: list[dict] | None = None,
44
+ tripped_flows: list[dict] | None = None,
45
+ health_state: str = "degraded",
46
+ ) -> dict:
47
+ """What the connector endpoints answer (backend `ConnectorResponse`)."""
48
+ return {
49
+ "id": CONNECTOR_ID,
50
+ "name": name,
51
+ "status": "active",
52
+ "org_id": ORG_ID,
53
+ "direction": "inbound",
54
+ "settings": {},
55
+ "mode": "live",
56
+ "tripped_flows": tripped_flows or [],
57
+ "health": {"state": health_state, "flows": breakers or []},
58
+ "created_at": "2026-09-01T10:00:00Z",
59
+ "updated_at": "2026-09-10T08:00:00Z",
60
+ }
61
+
62
+
63
+ def _client(**attrs) -> MagicMock:
64
+ client = MagicMock()
65
+ client.find_connector.return_value = {"id": CONNECTOR_ID, "name": "shop"}
66
+ client.get_connector.return_value = _connector()
67
+ client.retry_flow.return_value = _connector()
68
+ for key, value in attrs.items():
69
+ setattr(client, key, value)
70
+ return client
71
+
72
+
73
+ def _http_error(status: int, payload, method: str = "POST", path: str = "/api/x"):
74
+ """The httpx exception a non-2xx raises inside the client."""
75
+ request = httpx.Request(method, f"https://api.test{path}")
76
+ response = httpx.Response(status, json=payload, request=request)
77
+ return httpx.HTTPStatusError("boom", request=request, response=response)
78
+
79
+
80
+ def _run(client: MagicMock, args: list[str]):
81
+ runner = CliRunner()
82
+ with patch("plugsync_cli.context.PlugSyncClient", return_value=client):
83
+ return runner.invoke(cli, args)
84
+
85
+
86
+ class TestFlowRetry:
87
+ def test_retry_reports_the_half_open_probe(self):
88
+ client = _client(
89
+ retry_flow=MagicMock(
90
+ return_value=_connector(breakers=[_breaker(state="half_open")])
91
+ )
92
+ )
93
+
94
+ result = _run(client, ["connector", "flow", "retry", "shop", "shop-orders"])
95
+
96
+ assert result.exit_code == 0, result.output
97
+ client.retry_flow.assert_called_once_with(CONNECTOR_ID, "shop-orders")
98
+ output = _flat(result.output)
99
+ assert "shop-orders" in output
100
+ assert "half_open" in output
101
+ assert "recovery probe" in output
102
+
103
+ def test_retry_accepts_a_connector_id_without_a_name_lookup(self):
104
+ """A UUID argument is the connector id: no listing round trip for it."""
105
+ client = _client(
106
+ retry_flow=MagicMock(
107
+ return_value=_connector(breakers=[_breaker(state="half_open")])
108
+ )
109
+ )
110
+
111
+ result = _run(
112
+ client, ["connector", "flow", "retry", CONNECTOR_ID, "shop-orders"]
113
+ )
114
+
115
+ assert result.exit_code == 0, result.output
116
+ client.find_connector.assert_not_called()
117
+ client.retry_flow.assert_called_once_with(CONNECTOR_ID, "shop-orders")
118
+
119
+ def test_a_flow_with_no_open_breaker_is_reported_not_raised(self):
120
+ """The API's 404 for "nothing to retry", said in the CLI's own words."""
121
+ client = _client(
122
+ retry_flow=MagicMock(
123
+ side_effect=_http_error(
124
+ 404,
125
+ {"detail": "Flow 'shop-orders' has no open circuit breaker to retry"},
126
+ )
127
+ )
128
+ )
129
+
130
+ result = _run(client, ["connector", "flow", "retry", "shop", "shop-orders"])
131
+
132
+ assert result.exit_code == 1
133
+ assert result.exception is None or isinstance(result.exception, SystemExit)
134
+ output = _flat(result.output)
135
+ assert "no open circuit breaker" in output
136
+ assert "nothing to retry" in output
137
+ assert "Traceback" not in result.output
138
+
139
+ def test_a_connector_of_another_org_is_reported(self):
140
+ client = _client(
141
+ retry_flow=MagicMock(
142
+ side_effect=_http_error(404, {"detail": "Connector not found"})
143
+ )
144
+ )
145
+
146
+ result = _run(
147
+ client, ["connector", "flow", "retry", CONNECTOR_ID, "shop-orders"]
148
+ )
149
+
150
+ assert result.exit_code == 1
151
+ output = _flat(result.output)
152
+ assert "not found" in output
153
+ assert "nothing to retry" not in output
154
+
155
+ def test_an_unknown_connector_name_is_reported(self):
156
+ client = _client(find_connector=MagicMock(return_value=None))
157
+
158
+ result = _run(client, ["connector", "flow", "retry", "nope", "shop-orders"])
159
+
160
+ assert result.exit_code == 1
161
+ assert "Connector 'nope' not found" in _flat(result.output)
162
+ client.retry_flow.assert_not_called()
163
+
164
+ def test_a_response_without_the_flow_does_not_invent_a_state(self):
165
+ """The retry went through, but the answer carries no breaker for the
166
+ flow: report that rather than claiming a state nobody sent."""
167
+ client = _client(retry_flow=MagicMock(return_value=_connector(breakers=[])))
168
+
169
+ result = _run(client, ["connector", "flow", "retry", "shop", "shop-orders"])
170
+
171
+ assert result.exit_code == 0, result.output
172
+ output = _flat(result.output)
173
+ assert "half_open" not in output
174
+ assert "no longer appears" in output
175
+
176
+ def test_a_non_404_reaches_the_generic_handler(self):
177
+ """Only the 404 is special-cased; the rest keeps the group's rendering
178
+ (message from the response body, no traceback)."""
179
+ client = _client(
180
+ retry_flow=MagicMock(
181
+ side_effect=_http_error(
182
+ 503, {"detail": "Service Unavailable"}, path="/api/connectors/x"
183
+ )
184
+ )
185
+ )
186
+
187
+ result = _run(client, ["connector", "flow", "retry", "shop", "shop-orders"])
188
+
189
+ assert result.exit_code == 1
190
+ assert result.exception is None or isinstance(result.exception, SystemExit)
191
+ output = _flat(result.output)
192
+ assert "503" in output
193
+ assert "Service Unavailable" in output
194
+
195
+
196
+ class TestBreakers:
197
+ def test_the_open_breakers_are_listed(self):
198
+ client = _client(
199
+ get_connector=MagicMock(
200
+ return_value=_connector(
201
+ breakers=[
202
+ _breaker(name="shop-orders", state="open"),
203
+ _breaker(name="crm-sync", state="half_open", opens=5),
204
+ ]
205
+ )
206
+ )
207
+ )
208
+
209
+ result = _run(client, ["connector", "breakers", "shop"])
210
+
211
+ assert result.exit_code == 0, result.output
212
+ client.get_connector.assert_called_once_with(CONNECTOR_ID)
213
+ output = _flat(result.output)
214
+ assert "shop-orders" in output
215
+ assert "crm-sync" in output
216
+ assert "open" in output
217
+ assert "half_open" in output
218
+ assert "upsert refused" in output
219
+ # The footer points at the command that acts on what was just listed.
220
+ assert "plugsync connector flow retry" in output
221
+
222
+ def test_no_open_breaker_is_said_plainly(self):
223
+ client = _client(get_connector=MagicMock(return_value=_connector()))
224
+
225
+ result = _run(client, ["connector", "breakers", "shop"])
226
+
227
+ assert result.exit_code == 0, result.output
228
+ assert "No open circuit breaker on 'shop'" in _flat(result.output)
229
+
230
+ def test_an_api_without_the_health_rollup_still_lists_the_flows(self):
231
+ """An older API carries only the legacy list; render that instead of
232
+ reporting a healthy connector."""
233
+ client = _client(
234
+ get_connector=MagicMock(
235
+ return_value={
236
+ "id": CONNECTOR_ID,
237
+ "name": "shop",
238
+ "status": "active",
239
+ "org_id": ORG_ID,
240
+ "settings": {},
241
+ "mode": "live",
242
+ "tripped_flows": [
243
+ {
244
+ "flow_name": "shop-orders",
245
+ "reason": "upsert refused",
246
+ "state": "open",
247
+ "tripped_at": "2026-09-10T08:00:00Z",
248
+ }
249
+ ],
250
+ "created_at": "2026-09-01T10:00:00Z",
251
+ "updated_at": "2026-09-10T08:00:00Z",
252
+ }
253
+ )
254
+ )
255
+
256
+ result = _run(client, ["connector", "breakers", "shop"])
257
+
258
+ assert result.exit_code == 0, result.output
259
+ output = _flat(result.output)
260
+ assert "shop-orders" in output
261
+ assert "upsert refused" in output
262
+
263
+ def test_an_unknown_connector_name_is_reported(self):
264
+ client = _client(find_connector=MagicMock(return_value=None))
265
+
266
+ result = _run(client, ["connector", "breakers", "nope"])
267
+
268
+ assert result.exit_code == 1
269
+ assert "Connector 'nope' not found" in _flat(result.output)
270
+ client.get_connector.assert_not_called()
271
+
272
+ def test_a_connector_of_another_org_is_reported(self):
273
+ client = _client(
274
+ get_connector=MagicMock(
275
+ side_effect=_http_error(
276
+ 404, {"detail": "Connector not found"}, method="GET"
277
+ )
278
+ )
279
+ )
280
+
281
+ result = _run(client, ["connector", "breakers", CONNECTOR_ID])
282
+
283
+ assert result.exit_code == 1
284
+ assert result.exception is None or isinstance(result.exception, SystemExit)
285
+ assert "not found" in _flat(result.output)
File without changes