Setting the file. One moment.
Testmo Create Runs · Creating Test Runs · bitwarden/ai-plugins · Skills Docs
ContentsBack to the top of the page def call
— line 203
This file
Number 73.4
Position 4 of 5
Type Python
Size 36 KB
Lines 724 scripts/ testmo_create_runs.py
Python · 724 lines · 36 KB
15
in-process; on a network with TLS interception, where OpenSSL cannot verify the chain, they fall
16 back to curl with the header passed on stdin.
17
18 Filter model (all keys under "filters" are optional; a case must match every key present):
19 tags list of Testmo tag names or ids. Applied server-side via the /cases API
20 (?tags=...), then combined with any other filters below. e.g. ["oldnew"].
21 folder_paths list of folder paths, e.g. "Web > Password Manager". Matched against the
22 project folder tree; each expands to that folder AND all descendants
23 (unless include_subfolders is false). Paths are OR'd together.
24 folder_ids list of folder ids, same subtree expansion as folder_paths. OR'd with them.
25 include_subfolders default true. When false, folder_paths/folder_ids match that exact folder only.
26 test_type_ids case matches if its Test Type set intersects this list (multiselect field).
27 team_ids case matches if its Team set intersects this list (multiselect field).
28 case_state_ids case state_id must be in this list (e.g. [4] = Active).
29 automation_type_ids case automation type must be in this list. Use null in the list to include
30 cases with no automation type set.
31 exclude_automation_type_ids case is dropped if its automation type is in this list
32 (e.g. [10, 23, 24] = Automated / Automated-Android / Automated-iOS).
33 has_automation bool; case has_automation flag must equal this.
34 """
35 import argparse, json, os, shutil, ssl, subprocess, sys, tempfile
36 from pathlib import Path
37 import http.client, urllib.error, urllib.parse, urllib.request
38
39 BASE = "https://bitwarden.testmo.net/api/v1"
40 KEY = os.environ.get( "TESTMO_API_KEY" )
41 TIMEOUT = 60
42 # Paging guard: project 1 holds ~13.7k cases, ~137 pages at the 100/page the API allows.
43 MAX_PAGES = 1000
44
45 # Set once a TLS verification failure proves this Python cannot validate the chain (see _curl_call).
46 _USE_CURL = False
47
48 class TestmoAPIError ( Exception ):
49 """A Testmo API request did not return a usable JSON response.
50
51 Raised rather than exiting so callers can decide: a read failure aborts the batch, while a
52 failed write is recorded and the create loop carries on to report a full tally.
53 Messages are built from the request line and the response body only, never the API key.
54 """
55
56 class _TLSVerificationError ( Exception ):
57 """Internal: the certificate chain did not verify. Triggers the curl fallback."""
58
59 def _redact (text):
60 """Strip the API key from anything before it is printed or raised."""
61 return text.replace( KEY , "<redacted>" ) if KEY else text
62
63 def _decode (method, path, raw):
64 try :
65 return json.loads(raw)
66 except json.JSONDecodeError as e:
67 # A proxy or login-wall HTML page can arrive with a 200; don't let it become a traceback.
68 raise TestmoAPIError(
69 f " { method } { path } returned a non-JSON body ( { e } ). First 200 characters: \n{ raw[: 200 ] !r} "
70 )
71
72 def _auth_hint (code):
73 return " (is TESTMO_API_KEY current?)" if code in ( 401 , 403 ) else ""
74
75 def _unknown_outcome_hint (method):
76 """A write that timed out or lost its connection after sending may still have been applied."""
77 if method == "GET" :
78 return ""
79 return ( " The request was sent, so the server may still have applied it — check the milestone "
80 "in Testmo before re-running." )
81
82 # curl exit codes for a failure after the request was sent: 18 partial transfer, 28 timeout,
83 # 52 empty reply, 56 receive error. A connect failure (6, 7) means nothing reached the server.
84 _CURL_AFTER_SEND_FAILURES = ( 18 , 28 , 52 , 56 )
85
86 class _NoRedirect ( urllib . request . HTTPRedirectHandler ):
87 """Refuse every redirect, so a 3xx surfaces as an HTTPError instead of being followed.
88
89 urllib's default handler copies the request headers — Authorization included — onto the
90 redirected request, even when it points at another host. The Testmo API has no reason to
91 redirect, so a 3xx here means a proxy or misconfiguration, and following it would hand the key
92 to whatever host it names. curl, the fallback transport, does not follow redirects without -L.
93 """
94 def redirect_request (self, req, fp, code, msg, headers, newurl):
95 return None
96
97 _OPENER = urllib.request.build_opener(_NoRedirect)
98
99 def _urllib_call (method, path, body):
100 """Preferred transport: the Authorization header never leaves this process."""
101 data = json.dumps(body).encode() if body is not None else None
102 req = urllib.request.Request( BASE + path, data = data, method = method)
103 req.add_header( "Authorization" , f "Bearer { KEY } " )
104 req.add_header( "Accept" , "application/json" )
105 if data is not None :
106 req.add_header( "Content-Type" , "application/json" )
107 try :
108 with _OPENER .open(req, timeout = TIMEOUT ) as resp:
109 raw = resp.read().decode( errors = "replace" )
110 except urllib.error.HTTPError as e:
111 # e.read() is the API's error body; it echoes the request but not the auth header.
112 detail = e.read().decode( errors = "replace" ).strip()
113 raise TestmoAPIError(
114 f " { method } { path } failed: HTTP { e.code } { e.reason }{ _auth_hint(e.code) }\n{ detail[: 500 ] } "
115 )
116 except urllib.error.URLError as e:
117 if isinstance (e.reason, ssl.SSLCertVerificationError):
118 raise _TLSVerificationError( str (e.reason))
119 raise TestmoAPIError( f " { method } { path } failed: { e.reason } " )
120 except ( OSError , http.client.HTTPException) as e:
121 # urllib wraps only connect/send errors in URLError. A read timeout or a reset raised by
122 # getresponse() or resp.read() arrives bare (TimeoutError, RemoteDisconnected,
123 # IncompleteRead), and must still become TestmoAPIError so the create loop records
124 # the failure and carries on to its tally instead of dying with a traceback.
125 raise TestmoAPIError(
126 f " { method } { path } failed: { type (e). __name__ } : { e } . { _unknown_outcome_hint(method) } "
127 )
128 return _decode(method, path, raw)
129
130 def _curl_call (method, path, body):
131 """Fallback transport for networks running TLS interception.
132
133 A corporate proxy (Zscaler and friends) re-signs traffic with a root that is installed in the
134 OS trust store but is absent from certifi, and whose Basic Constraints are not marked
135 critical — which Python 3.13+ rejects outright, since it enables VERIFY_X509_STRICT by
136 default. curl uses the platform trust store and is not strict, so it succeeds where urllib
137 cannot.
138
139 The key is still kept out of argv: the Authorization header is fed to `curl --config -` on
140 stdin. Any request body goes to a 0600 temp file, referenced by path — the body is not
141 secret, and this keeps stdin free for the config.
142
143 *Only* the Authorization header goes through the config; every other directive is an argv
144 element. curl's config format is one directive per line, so a value carrying a `"` and a
145 newline would inject further directives onto the same config that holds the key — a second
146 `url =` would send the authenticated request to an attacker-chosen host. On argv each value is
147 one token no matter what it contains, so nothing spec-derived can reach the config parser. The
148 remaining interpolation is the key itself, which `_check_key` rejects if it holds a quote or a
149 newline.
150 """
151 marker = "__TESTMO_HTTP_STATUS__"
152 argv = [
153 "curl" , "--silent" , "--show-error" , "--max-time" , str ( TIMEOUT ),
154 "--request" , method,
155 "--header" , "Accept: application/json" ,
156 "--write-out" , f " \n{ marker } % {{ http_code }} " ,
157 "--url" , BASE + path,
158 ]
159 config = [ f 'header = "Authorization: Bearer { KEY } "' ]
160 body_file = None
161 try :
162 if body is not None :
163 fd, body_file = tempfile.mkstemp( prefix = "testmo-body-" , suffix = ".json" )
164 with os.fdopen(fd, "w" ) as fh:
165 json.dump(body, fh)
166 argv += [ "--header" , "Content-Type: application/json" ,
167 "--data-binary" , f "@ { body_file } " ]
168 proc = subprocess.run(argv + [ "--config" , "-" ], input = " \n " .join(config) + " \n " ,
169 capture_output = True , text = True )
170 finally :
171 if body_file:
172 os.unlink(body_file)
173 if proc.returncode != 0 :
174 # Redact: a config parse error can echo the offending line back on stderr.
175 hint = _unknown_outcome_hint(method) if proc.returncode in _CURL_AFTER_SEND_FAILURES else ""
176 raise TestmoAPIError(
177 f " { method } { path } failed: curl exited { proc.returncode } . { hint }\n "
178 f " { _redact(proc.stderr.strip()) } "
179 )
180 raw, found, status = proc.stdout.rpartition(marker)
181 if not found:
182 raise TestmoAPIError( f " { method } { path } failed: curl returned no status code" )
183 try :
184 code = int (status.strip())
185 except ValueError :
186 raise TestmoAPIError( f " { method } { path } failed: unreadable status { status.strip() !r} " )
187 if not 200 <= code < 300 :
188 raise TestmoAPIError(
189 f " { method } { path } failed: HTTP { code }{ _auth_hint(code) }\n{ raw.strip()[: 500 ] } "
190 )
191 return _decode(method, path, raw)
192
193 def _check_key ():
194 """Exit unless TESTMO_API_KEY is set and safe to interpolate into a curl config line."""
195 if not KEY :
196 sys.exit( "TESTMO_API_KEY not set" )
197 # A backslash cannot inject a directive (that needs a raw newline), but curl processes escapes
198 # inside a quoted config value, so it would silently alter the header that is sent.
199 if any (c in KEY for c in '" \\\r\n ' ):
200 sys.exit( "TESTMO_API_KEY contains a quote, a backslash, or a newline, which cannot be carried "
201 "safely in an HTTP header. Re-copy the key from Testmo (no surrounding quotes)." )
202
203 def call (method, path, body = None ):
204 """Issue a Testmo API request and return the decoded JSON body.
205
206 Prefers urllib, so the Authorization header stays in this process — passing it as a curl
207 argument would expose the key to any local user for the life of the request (`ps auxww`,
208 /proc/<pid>/cmdline). Falls back to curl only when the TLS chain will not verify, which on a
209 corporate network is not a fixable condition; that path keeps the key off argv too.
210
211 Any non-2xx status raises, so an expired key (401) or a rejected write (403) surfaces as an
212 error instead of an empty case list or a run "created" with id None.
213 """
214 global _USE_CURL
215 _check_key()
216 if _USE_CURL :
217 return _curl_call(method, path, body)
218 try :
219 return _urllib_call(method, path, body)
220 except _TLSVerificationError as e:
221 if not shutil.which( "curl" ):
222 raise TestmoAPIError(
223 f " { method } { path } failed: TLS certificate verification failed ( { e } ), and curl is "
224 f "not on PATH to fall back to. \n This usually means a TLS-inspecting proxy whose "
225 f "root CA this Python does not trust. Try the system python3, or install curl."
226 )
227 print ( f "NOTE: TLS verification failed ( { e } ). \n "
228 f " A TLS-inspecting proxy is likely in the path. Falling back to curl, which "
229 f "uses the OS trust store, for this and all later requests." , file = sys.stderr)
230 _USE_CURL = True
231 return _curl_call(method, path, body)
232
233 def project_path_id (value):
234 """Return `value` as an int, for interpolation into a request path.
235
236 Every path is built as f"/projects/{project}/…" from the spec's `project_id`, so a non-integer
237 value would let a spec steer the URL itself (`1/../..`, a whole `https://…`) rather than just
238 name a project. Reject anything that is not an integer id — including `True` and `1.9`, which
239 int() would otherwise quietly turn into project 1.
240 """
241 if isinstance (value, bool ):
242 raise TestmoAPIError( f '"project_id" must be an integer project id, got { value !r} ' )
243 if isinstance (value, int ):
244 return value
245 if isinstance (value, str ) and value.strip().isdigit():
246 return int (value)
247 raise TestmoAPIError( f '"project_id" must be an integer project id, got { value !r} ' )
248
249 def fetch_all (project, resource, query = "" ):
250 project_id = project_path_id(project)
251 out, page = [], 1
252 sep = "&" if query else ""
253 while True :
254 d = call( "GET" , f "/projects/ { project_id } / { resource } ? { query }{ sep } page= { page :d} " )
255 out += d.get( "result" , [])
256 if not d.get( "next_page" ):
257 return out
258 page += 1
259 if page > MAX_PAGES :
260 raise TestmoAPIError(
261 f "GET /projects/ { project_id } / { resource } kept reporting next_page past { MAX_PAGES } "
262 f "pages ( { len (out) } records so far). Aborting rather than paging forever — check "
263 f "the query above for an unencoded character that truncated it."
264 )
265
266 # A tag name no case can carry. verify_tag_filter() asks /cases for it and expects nothing back.
267 _SENTINEL_TAG = "testmo-tools-probe-no-such-tag"
268 _TAG_FILTER_VERIFIED = set ()
269
270 def verify_tag_filter (project):
271 """Prove /cases actually applies `?tags=` before any tag filter is trusted.
272
273 This API ignores query parameters it does not recognize — 0.6.1 found `?milestone=240` returning
274 every run in the project. A tag-only spec has no other constraint, so were `?tags=` renamed or
275 dropped, /cases would return all ~13.7k project-1 cases, every one would pass matches(), and
276 --create would post a 13k-case run. Asking for a tag that cannot exist separates the two:
277 an honored parameter yields zero cases, an ignored one yields the whole repository.
278 """
279 project = project_path_id(project)
280 if project in _TAG_FILTER_VERIFIED :
281 return
282 probe = f "/projects/ { project } /cases?tags= { _SENTINEL_TAG } &page=1"
283 try :
284 result = call( "GET" , probe).get( "result" ) or []
285 except TestmoAPIError:
286 # The API rejected the value outright, which still shows it parses `?tags=`.
287 _TAG_FILTER_VERIFIED .add(project)
288 return
289 if result:
290 raise TestmoAPIError(
291 f "Refusing to trust filters.tags: GET { probe } returned { len (result) } case(s) for a tag "
292 f "no case can carry, so /cases is not applying `?tags=`. A tag filter would therefore "
293 f "select every case in project { project } . Confirm the current tag-filter parameter name "
294 f "against the Testmo API before creating any tag-driven run."
295 )
296 _TAG_FILTER_VERIFIED .add(project)
297
298 def fetch_cases (project, filters):
299 """Fetch repository cases, applying any tag filter server-side."""
300 tags = filters.get( "tags" )
301 if not tags:
302 return fetch_all(project, "cases" )
303 verify_tag_filter(project)
304 # Percent-encode each tag but keep the commas that separate them: Testmo tag names are free
305 # text, and a raw space raises http.client.InvalidURL, a non-ASCII character raises
306 # UnicodeEncodeError, `&` appends a parameter, and `#` truncates the URL at the fragment —
307 # dropping `page=` so fetch_all would re-request page 1 forever.
308 query = "tags=" + "," .join(urllib.parse.quote( str (t), safe = "" ) for t in tags)
309 return fetch_all(project, "cases" , query)
310
311 def build_folder_index (folders):
312 by_id = {f[ "id" ]: f for f in folders}
313 children = {}
314 for f in folders:
315 children.setdefault(f.get( "parent_id" ), []).append(f[ "id" ])
316 def path (fid):
317 parts, f = [], by_id.get(fid)
318 while f:
319 parts.append(f[ "name" ])
320 f = by_id.get(f.get( "parent_id" ))
321 return " > " .join( reversed (parts))
322 paths = {fid: path(fid) for fid in by_id}
323 return by_id, children, paths
324
325 def subtree (root, children):
326 seen, stack = set (), [root]
327 while stack:
328 n = stack.pop()
329 seen.add(n)
330 stack += children.get(n, [])
331 return seen
332
333 def resolve_folders (spec_filters, project, folders = None ):
334 """Return (folder_id_set, notes). Empty set means 'no folder filter'.
335
336 Pass a prefetched `folders` list to avoid refetching (e.g. when resolving many specs)."""
337 want_paths = spec_filters.get( "folder_paths" ) or []
338 want_ids = spec_filters.get( "folder_ids" ) or []
339 if not want_paths and not want_ids:
340 return None , []
341 include_sub = spec_filters.get( "include_subfolders" , True )
342 if folders is None :
343 folders = fetch_all(project, "folders" )
344 by_id, children, paths = build_folder_index(folders)
345 norm = {p.strip().lower(): fid for fid, p in paths.items()}
346 roots, notes = [], []
347 for p in want_paths:
348 fid = norm.get(p.strip().lower())
349 if fid is None :
350 notes.append( f "UNMATCHED folder_path: { p !r} " )
351 else :
352 roots.append(fid)
353 notes.append( f "ok { p } -> id= { fid } " )
354 for fid in want_ids:
355 if fid in by_id:
356 roots.append(fid)
357 notes.append( f "ok id= { fid } -> { paths[fid] } " )
358 else :
359 notes.append( f "UNKNOWN folder_id: { fid } " )
360 result = set ()
361 for r in roots:
362 result |= subtree(r, children) if include_sub else {r}
363 return result, notes
364
365 def matches (case, spec, folder_ids):
366 if folder_ids is not None and case.get( "folder_id" ) not in folder_ids:
367 return False
368 if spec.get( "test_type_ids" ):
369 ids = {t[ "id" ] for t in (case.get( "custom_test_type" ) or [])}
370 if not ids & set (spec[ "test_type_ids" ]):
371 return False
372 if spec.get( "team_ids" ):
373 ids = {t[ "id" ] for t in (case.get( "custom_team" ) or [])}
374 if not ids & set (spec[ "team_ids" ]):
375 return False
376 # custom_automation_type is a single {"id", "name"} object, or null when unset.
377 # Compare on the id so specs can list plain ints (and null for "no type set").
378 auto = case.get( "custom_automation_type" )
379 auto_id = auto[ "id" ] if auto else None
380 if "automation_type_ids" in spec and auto_id not in spec[ "automation_type_ids" ]:
381 return False
382 if spec.get( "exclude_automation_type_ids" ) and auto_id in spec[ "exclude_automation_type_ids" ]:
383 return False
384 if spec.get( "case_state_ids" ) and case.get( "state_id" ) not in spec[ "case_state_ids" ]:
385 return False
386 if "has_automation" in spec and bool (case.get( "has_automation" )) != bool (spec[ "has_automation" ]):
387 return False
388 return True
389
390 # Keys main() indexes directly. Checked when a spec is loaded, before any API call: a KeyError
391 # raised later — mid-way through the create loop — fires after earlier runs have already been
392 # POSTed, producing a half-populated milestone.
393 REQUIRED_SPEC_KEYS = ( "project_id" , "run_name" )
394
395 class SpecError ( Exception ):
396 """A spec file is missing, is not valid JSON, or lacks a required key."""
397
398 def load_spec (path):
399 """Load and validate one spec file. Raises SpecError naming the file and the problem."""
400 try :
401 with open (path) as fh:
402 spec = json.load(fh)
403 except FileNotFoundError :
404 raise SpecError( f "Spec file not found: { path } " )
405 except json.JSONDecodeError as e:
406 raise SpecError( f " { path } is not valid JSON: { e } " )
407 if not isinstance (spec, dict ):
408 raise SpecError( f " { path } must contain a JSON object, got { type (spec). __name__ } " )
409 absent = [k for k in REQUIRED_SPEC_KEYS if spec.get(k) is None ]
410 if absent:
411 raise SpecError( f " { path } is missing { ', ' .join( repr (k) for k in absent) } "
412 f "(every spec needs { ', ' .join( REQUIRED_SPEC_KEYS ) } )" )
413 return spec
414
415 def existing_runs_for_milestone (project, milestone_id):
416 """Find runs already linked to this milestone, for the duplicate-run guards.
417
418 Returns (runs, checked). `checked` is False when the runs payload carries no milestone_id
419 field at all: the guard then cannot tell "no duplicates" from "cannot see the link", and the
420 caller must say so rather than report a clean result — a guardrail that quietly no-ops is
421 worse than none.
422 """
423 # ?milestone_id= is honoured server-side (verified 2026-09-02: project 1 pages from 20 down to 4
424 # for a busy milestone). The client-side filter below is kept regardless, because the API ignores
425 # query parameters it does not recognize — were the name ever to change, that would silently return
426 # every run in the project, and this guard must not widen into a false positive.
427 runs = fetch_all(project, "runs" , f "milestone_id= { milestone_id :d} " )
428 if runs and not any ( "milestone_id" in r for r in runs):
429 return [], False
430 return [r for r in runs if r.get( "milestone_id" ) == milestone_id], True
431
432 def same_run (existing, spec):
433 """True when an existing run is (or cannot be told apart from) the run this spec creates.
434
435 Multi-configuration runs share a run name by design, so the name alone is not enough: a run
436 with a different config_id is a sibling, not a duplicate. When the run object exposes no
437 config_id, the two cannot be told apart, so it counts — the guard errs toward refusing.
438 """
439 if existing.get( "name" ) != spec[ "run_name" ]:
440 return False
441 if spec.get( "config_id" ) is None or "config_id" not in existing:
442 return True
443 return existing.get( "config_id" ) == spec[ "config_id" ]
444
445
446 def load_specs (paths):
447 """Load every spec up front, so a bad file fails before any API call is made.
448
449 Problems are collected rather than raised one at a time, so several bad specs are reported in a
450 single pass. Each spec is labelled by its file name, since multi-configuration runs share a
451 run_name by design and the name alone would not say which spec a row belongs to.
452 """
453 specs, invalid = [], []
454 for path in paths:
455 try :
456 specs.append((Path(path).stem, load_spec(path)))
457 except SpecError as e:
458 invalid.append( str (e))
459 if invalid:
460 detail = " \n " .join( f " { line } " for line in invalid)
461 sys.exit( f " { len (invalid) } spec(s) are invalid: \n{ detail } " )
462 return specs
463
464 def resolve_project (specs):
465 """Return the single project id every spec targets.
466
467 The spec is the only source of truth for this — there is deliberately no --project override,
468 since a flag that outranked each spec's project_id could create a spec into a project it was
469 never written for. One invocation fetches one project's cases, so the specs must agree.
470 """
471 by_project = {}
472 for name, spec in specs:
473 by_project.setdefault(spec[ "project_id" ], []).append(name)
474 if len (by_project) > 1 :
475 lines = " \n " .join( f " { pid } : { ', ' .join(names) } " for pid, names in by_project.items())
476 sys.exit( f "Specs disagree on project_id: \n{ lines }\n "
477 f "Every spec in one invocation must target the same project." )
478 try :
479 # Request paths interpolate this, so it has to be an integer id and nothing else.
480 return project_path_id( next ( iter (by_project)))
481 except TestmoAPIError as e:
482 sys.exit( str (e))
483
484 def resolve_milestone (project, name):
485 """Return the id of the one milestone with this exact name. The API cannot create milestones."""
486 hits = [m for m in fetch_all(project, "milestones" ) if m.get( "name" ) == name]
487 if not hits:
488 sys.exit( f "No milestone named { name !r} in project { project } . "
489 f "Create it in the Testmo UI first (the API cannot create milestones)." )
490 if len (hits) > 1 :
491 ids = ", " .join( str (m[ "id" ]) for m in hits)
492 sys.exit( f " { len (hits) } milestones named { name !r} (ids: { ids } ). Rename or use a unique name." )
493 return hits[ 0 ][ "id" ]
494
495 def needs_manual_step_2 (spec):
496 """True when this run cannot be finished by the API alone.
497
498 A `config_id` marks a run that is one of several same-named variants distinguished only by
499 Testmo Configuration. /cases cannot filter by configuration and exposes no per-case config
500 assignment, so the script only ever reproduces step 1 — removing the other configurations'
501 cases is a manual UI pass. Each spec's `_comment` spells out its own step 2, and `_comment` is
502 never displayed, so the reminder has to come from here.
503 """
504 return spec.get( "config_id" ) is not None
505
506 def print_step_2_reminder (labels, created):
507 """Name every run that still needs its manual Testmo UI pass."""
508 if not labels:
509 return
510 verb = "need" if created else "will need"
511 print ( f " \n MANUAL STEP 2 REQUIRED — { len (labels) } run(s) { verb } a Testmo UI pass:" )
512 for label in labels:
513 print ( f " { label } " )
514 print ( " These are multi-configuration runs: the API reproduced step 1 only. In the Testmo UI, "
515 "remove \n the cases belonging to the other configurations. Each spec's \" _comment \" gives "
516 "its own step 2. \n Until that pass is done these runs hold the wrong case set." )
517
518 def build_payload (spec, ids):
519 payload = { "name" : spec[ "run_name" ], "state_id" : spec.get( "run_state_id" , 7 ),
520 "include_all" : False , "cases" : ids}
521 for k in ( "milestone_id" , "config_id" , "tags" , "note" ):
522 if k in spec and spec[k] is not None :
523 payload[k] = spec[k]
524 return payload
525
526 def print_details (notes, cases, selected, payload):
527 """Single-spec dry-run detail: folder resolution, a sample of the matched cases, the payload."""
528 if notes:
529 print ( "--- folder resolution ---" )
530 for n in notes:
531 print ( f " { n } " )
532 print ()
533 print ( f " { len (cases) } cases fetched -> { len (selected) } match filter" )
534 for c in selected[: 10 ]:
535 tt = "," .join(t[ "name" ] for t in (c.get( "custom_test_type" ) or [])) or "-"
536 print ( f ' id= { c[ "id" ] } folder= { c.get( "folder_id" ) } type= { tt } '
537 f 'auto= { c.get( "custom_automation_type" ) } state= { c.get( "state_id" ) } ' )
538 if len (selected) > 10 :
539 print ( f " ... + { len (selected) - 10 } more" )
540 print ( " \n --- run payload (cases truncated) ---" )
541 print (json.dumps({ ** payload, "cases" : f "[ { len (payload[ 'cases' ]) } ids]" }, indent = 2 ))
542 print ()
543
544 # Why each non-ready status blocks --create, keyed by the status text before any " — " detail.
545 _BLOCKED_HINTS = {
546 "NEEDS CONFIG_ID" : "these runs are distinguished from their same-named siblings only by "
547 "Configuration — look the ids up via GET /projects/ {project} /configs and set "
548 "them in the specs" ,
549 "EMPTY" : "0 cases is almost always a renamed tag or folder — check the spec's filters against "
550 "the live repository" ,
551 "DUPLICATE" : "the run already exists in the milestone, or another spec in this batch creates the "
552 "same run — drop that spec, or pass --allow-existing if the duplicate is intended" ,
553 }
554
555 def main ():
556 ap = argparse.ArgumentParser(
557 description = "Create Testmo runs from JSON filter specs. Dry-run unless --create is passed." )
558 ap.add_argument( "--spec" , action = "append" , required = True , metavar = "PATH" ,
559 help = "path to a JSON filter spec. Repeat to create several runs in one pass; the "
560 "folder tree and case list are fetched once for all of them." )
561 milestone = ap.add_mutually_exclusive_group()
562 milestone.add_argument( "--milestone-id" , type = int , default = None ,
563 help = "link every run to this existing milestone id (overrides the specs)" )
564 milestone.add_argument( "--milestone-name" , default = None ,
565 help = "link every run to the existing milestone with this exact name. "
566 "Milestones must be created in the Testmo UI first." )
567 ap.add_argument( "--create" , action = "store_true" , help = "actually POST the runs (omit for dry-run)" )
568 ap.add_argument( "--allow-existing" , action = "store_true" ,
569 help = "create runs even when a run with the same name (and config) is already "
570 "linked to the milestone. Without this, --create refuses." )
571 args = ap.parse_args()
572
573 specs = load_specs(args.spec)
574 project = resolve_project(specs)
575 override = args.milestone_id
576 if args.milestone_name is not None :
577 override = resolve_milestone(project, args.milestone_name)
578 for name, spec in specs:
579 if override is not None :
580 spec[ "milestone_id" ] = override
581 mid = spec.get( "milestone_id" )
582 # bool is a subclass of int, so `true` would otherwise pass as milestone 1.
583 if mid is not None and ( not isinstance (mid, int ) or isinstance (mid, bool )):
584 sys.exit( f ' { name } : "milestone_id" must be an integer milestone id, got { mid !r} ' )
585
586 print ( f "Specs : { len (specs) } " )
587 print ( f "Project : { project } (from the specs)" )
588 if args.milestone_name is not None :
589 print ( f "Milestone : { args.milestone_name !r} -> id { override } " )
590 elif override is not None :
591 print ( f "Milestone : id { override } " )
592 print ( f "Mode : { 'CREATE' if args.create else 'DRY RUN' } " )
593
594 # Idempotency is checked per spec, against the runs already linked to its milestone. It needs a
595 # milestone to scope the lookup — run names like "Mobile" recur every cycle, so a project-wide
596 # name match means nothing.
597 linked = {}
598 for mid in dict .fromkeys(s.get( "milestone_id" ) for _, s in specs):
599 if mid is None :
600 print ( "Existing : NOT CHECKED for specs with no milestone — pass --milestone-id or "
601 "--milestone-name to check for duplicates" )
602 continue
603 runs, checked = existing_runs_for_milestone(project, mid)
604 linked[mid] = (runs, checked)
605 if not checked:
606 print ( f "Existing : CANNOT CHECK milestone { mid } — the runs API returned no "
607 f "milestone_id field, so duplicates cannot be ruled out" )
608 else :
609 print ( f "Existing : { len (runs) } run(s) already linked to milestone { mid } " )
610 print ()
611
612 # Fetch shared data once so N specs don't trigger N full downloads. Tag-driven specs each issue
613 # their own server-side filtered fetch instead of using the full case list.
614 all_filters = [s.get( "filters" ) or {} for _, s in specs]
615 folders = (fetch_all(project, "folders" )
616 if any (f.get( "folder_paths" ) or f.get( "folder_ids" ) for f in all_filters) else None )
617 all_cases = fetch_all(project, "cases" ) if any ( not f.get( "tags" ) for f in all_filters) else None
618
619 rows, results, batch = [], [], {}
620 for i, (name, spec) in enumerate (specs):
621 filters = spec.get( "filters" ) or {}
622 label = f " { spec[ 'run_name' ] } [ { name } ]"
623 folder_ids, notes = resolve_folders(filters, project, folders = folders)
624 bad = [n for n in notes if n.startswith(( "UNMATCHED" , "UNKNOWN" ))]
625 if bad:
626 rows.append((label, "FOLDER ERROR" , "; " .join(bad)))
627 continue
628 cases = fetch_cases(project, filters) if filters.get( "tags" ) else all_cases
629 selected = [c for c in cases if matches(c, filters, folder_ids)]
630 ids = [c[ "id" ] for c in selected]
631
632 mid = spec.get( "milestone_id" )
633 existing = [r for r in linked.get(mid, ([], None ))[ 0 ] if same_run(r, spec)]
634 # Index by position, not file name: the same spec passed twice is still a duplicate.
635 twin = batch.setdefault((mid, spec[ "run_name" ], spec.get( "config_id" )), i)
636 # "config_id": null is an explicit placeholder — the run needs a Testmo Configuration that
637 # has not been looked up yet, and creating it would produce indistinguishable runs.
638 if "config_id" in spec and spec[ "config_id" ] is None :
639 status = "NEEDS CONFIG_ID"
640 elif not ids:
641 status = "EMPTY — 0 cases"
642 elif existing and not args.allow_existing:
643 status = f "DUPLICATE — run { ', ' .join( str (r.get( 'id' )) for r in existing) } exists"
644 elif twin != i and not args.allow_existing:
645 status = f "DUPLICATE — same run as [ { specs[twin][ 0 ] } ]"
646 else :
647 status = "ready"
648 rows.append((label, str ( len (ids)), status))
649 results.append((name, spec, ids))
650 if len (specs) == 1 :
651 print_details(notes, cases, selected, build_payload(spec, ids))
652
653 print ( f " { 'RUN NAME [SPEC]' :<60}{ 'CASES' :>7} STATUS" )
654 print ( "-" * 86 )
655 for label, count, status in rows:
656 print ( f " { label[: 59 ] :<60}{ count :>7} { status } " )
657 ready = sum ( 1 for r in rows if r[ 2 ] == "ready" )
658 print ( "-" * 86 )
659 print ( f " { 'TOTAL' :<60}{ sum ( len (ids) for _, _, ids in results) :>7} "
660 f " { ready } / { len (specs) } runs ready" )
661
662 errors = [r for r in rows if r[ 1 ] == "FOLDER ERROR" ]
663 if errors:
664 sys.exit( f " \n{ len (errors) } spec(s) reference folders that did not resolve — fix them before "
665 f "continuing." )
666 blocked = [r for r in rows if r[ 2 ] != "ready" ]
667 step_2 = [ f " { spec[ 'run_name' ] } [ { name } ]" for name, spec, _ in results if needs_manual_step_2(spec)]
668
669 if not args.create:
670 print ( " \n DRY RUN — no runs created. Re-run with --create to POST the above." )
671 print_step_2_reminder(step_2, created = False )
672 if blocked:
673 print ( f "NOTE: { len (blocked) } run(s) are not ready (see STATUS above); --create would "
674 f "refuse until they are fixed or dropped." )
675 return
676
677 if blocked:
678 # Refuse the whole batch rather than create the ready runs and skip the rest: a skipped run
679 # leaves the milestone incomplete, and the dry-run is the place to have noticed.
680 detail = " \n " .join( f " { label } — { status } " for label, _, status in blocked)
681 kinds = dict .fromkeys(status.split( " — " )[ 0 ] for _, _, status in blocked)
682 reasons = " \n " .join( f " { k } : { _BLOCKED_HINTS [k].format( project = project) } " for k in kinds)
683 sys.exit( f " \n Refusing to create: { len (blocked) } run(s) are not ready: \n{ detail }\n{ reasons } " )
684 if any (checked is False for _, checked in linked.values()):
685 print ( "WARNING: could not verify whether these runs already exist — the runs API returned no "
686 "milestone_id field. Check the Testmo UI before continuing." )
687
688 # Create every run we can rather than aborting on the first failure: stopping midway through a
689 # batch leaves a half-populated milestone and no record of which runs made it.
690 print ()
691 created, failed, step_2 = [], [], []
692 for name, spec, ids in results:
693 label = f " { spec[ 'run_name' ] } [ { name } ]"
694 try :
695 res = call( "POST" , f "/projects/ { project } /runs" , build_payload(spec, ids))
696 except TestmoAPIError as e:
697 reason = str (e).splitlines()[ 0 ]
698 print ( f " FAILED: { label }\n { reason } " )
699 failed.append((label, reason))
700 continue
701 rid = (res.get( "result" ) or {}).get( "id" )
702 if rid is None :
703 print ( f " FAILED: { label }\n POST succeeded but the response carried no run id" )
704 failed.append((label, "response carried no run id" ))
705 continue
706 created.append(rid)
707 if needs_manual_step_2(spec):
708 step_2.append( f " { label } -> https://bitwarden.testmo.net/run/ { rid } " )
709 print ( f " CREATED run { rid } ( { len (ids) } cases) { label } https://bitwarden.testmo.net/run/ { rid } " )
710
711 print ( f " \n{ len (created) } created, { len (failed) } failed — of { len (results) } run(s) attempted." )
712 print_step_2_reminder(step_2, created = True )
713 if failed:
714 print ( " \n NOT created:" )
715 for label, reason in failed:
716 print ( f " { label } — { reason } " )
717 sys.exit( f " \n{ len (failed) } run(s) were not created. Fix the cause and re-run with only those "
718 f "specs — the { len (created) } run(s) above already exist." )
719
720 if __name__ == "__main__" :
721 try :
722 main()
723 except TestmoAPIError as e:
724 sys.exit( str (e))