dispatch_agents 0.9.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 (109) hide show
  1. dispatch_agents-0.9.0/.claude-plugin/marketplace.json +22 -0
  2. dispatch_agents-0.9.0/.github/scripts/change_scope.py +192 -0
  3. dispatch_agents-0.9.0/.github/scripts/ci_git.py +76 -0
  4. dispatch_agents-0.9.0/.github/scripts/version_policy.py +209 -0
  5. dispatch_agents-0.9.0/.github/tests/test_change_scope.py +159 -0
  6. dispatch_agents-0.9.0/.github/tests/test_ci_git.py +113 -0
  7. dispatch_agents-0.9.0/.github/tests/test_version_policy.py +182 -0
  8. dispatch_agents-0.9.0/.github/workflows/ci-reusable.yml +61 -0
  9. dispatch_agents-0.9.0/.github/workflows/feature-branch.yml +20 -0
  10. dispatch_agents-0.9.0/.github/workflows/release.yml +81 -0
  11. dispatch_agents-0.9.0/.github/workflows/version-policy-reusable.yml +50 -0
  12. dispatch_agents-0.9.0/.gitignore +210 -0
  13. dispatch_agents-0.9.0/CONTRIBUTING.md +26 -0
  14. dispatch_agents-0.9.0/LICENSE +191 -0
  15. dispatch_agents-0.9.0/LICENSE-3rdparty.csv +12 -0
  16. dispatch_agents-0.9.0/NOTICE +5 -0
  17. dispatch_agents-0.9.0/PKG-INFO +20 -0
  18. dispatch_agents-0.9.0/README.md +48 -0
  19. dispatch_agents-0.9.0/agentservice/__init__.py +0 -0
  20. dispatch_agents-0.9.0/agentservice/py.typed +0 -0
  21. dispatch_agents-0.9.0/agentservice/v1/__init__.py +0 -0
  22. dispatch_agents-0.9.0/agentservice/v1/message_pb2.py +41 -0
  23. dispatch_agents-0.9.0/agentservice/v1/message_pb2.pyi +22 -0
  24. dispatch_agents-0.9.0/agentservice/v1/message_pb2_grpc.py +4 -0
  25. dispatch_agents-0.9.0/agentservice/v1/request_response_pb2.py +46 -0
  26. dispatch_agents-0.9.0/agentservice/v1/request_response_pb2.pyi +54 -0
  27. dispatch_agents-0.9.0/agentservice/v1/request_response_pb2_grpc.py +4 -0
  28. dispatch_agents-0.9.0/agentservice/v1/service_pb2.py +43 -0
  29. dispatch_agents-0.9.0/agentservice/v1/service_pb2.pyi +6 -0
  30. dispatch_agents-0.9.0/agentservice/v1/service_pb2_grpc.py +129 -0
  31. dispatch_agents-0.9.0/dispatch_agents/__init__.py +281 -0
  32. dispatch_agents-0.9.0/dispatch_agents/agent_service.py +135 -0
  33. dispatch_agents-0.9.0/dispatch_agents/config.py +490 -0
  34. dispatch_agents-0.9.0/dispatch_agents/contrib/__init__.py +1 -0
  35. dispatch_agents-0.9.0/dispatch_agents/contrib/claude/__init__.py +246 -0
  36. dispatch_agents-0.9.0/dispatch_agents/contrib/openai/__init__.py +167 -0
  37. dispatch_agents-0.9.0/dispatch_agents/events.py +986 -0
  38. dispatch_agents-0.9.0/dispatch_agents/grpc_server.py +565 -0
  39. dispatch_agents-0.9.0/dispatch_agents/instrument.py +217 -0
  40. dispatch_agents-0.9.0/dispatch_agents/integrations/__init__.py +1 -0
  41. dispatch_agents-0.9.0/dispatch_agents/integrations/github/README.md +9 -0
  42. dispatch_agents-0.9.0/dispatch_agents/integrations/github/__init__.py +4268 -0
  43. dispatch_agents-0.9.0/dispatch_agents/invocation.py +25 -0
  44. dispatch_agents-0.9.0/dispatch_agents/llm.py +1017 -0
  45. dispatch_agents-0.9.0/dispatch_agents/llm_langchain.py +394 -0
  46. dispatch_agents-0.9.0/dispatch_agents/logging_config.py +133 -0
  47. dispatch_agents-0.9.0/dispatch_agents/mcp.py +266 -0
  48. dispatch_agents-0.9.0/dispatch_agents/memory.py +264 -0
  49. dispatch_agents-0.9.0/dispatch_agents/models.py +748 -0
  50. dispatch_agents-0.9.0/dispatch_agents/proxy/__init__.py +6 -0
  51. dispatch_agents-0.9.0/dispatch_agents/proxy/server.py +1137 -0
  52. dispatch_agents-0.9.0/dispatch_agents/proxy/sse_utils.py +76 -0
  53. dispatch_agents-0.9.0/dispatch_agents/py.typed +0 -0
  54. dispatch_agents-0.9.0/dispatch_agents/resources.py +68 -0
  55. dispatch_agents-0.9.0/dispatch_agents/version.py +19 -0
  56. dispatch_agents-0.9.0/examples/README.md +39 -0
  57. dispatch_agents-0.9.0/examples/hello_world/.dispatch.yaml +9 -0
  58. dispatch_agents-0.9.0/examples/hello_world/.gitignore +4 -0
  59. dispatch_agents-0.9.0/examples/hello_world/AGENTS.md +3 -0
  60. dispatch_agents-0.9.0/examples/hello_world/agent.py +112 -0
  61. dispatch_agents-0.9.0/examples/hello_world/pyproject.toml +12 -0
  62. dispatch_agents-0.9.0/examples/hello_world/test_agent.py +45 -0
  63. dispatch_agents-0.9.0/examples/hello_world/uv.lock +862 -0
  64. dispatch_agents-0.9.0/examples/pyproject.toml +56 -0
  65. dispatch_agents-0.9.0/examples/uv.lock +169 -0
  66. dispatch_agents-0.9.0/examples/weather-assistant/.dispatch.yaml +5 -0
  67. dispatch_agents-0.9.0/examples/weather-assistant/.gitignore +4 -0
  68. dispatch_agents-0.9.0/examples/weather-assistant/AGENTS.md +3 -0
  69. dispatch_agents-0.9.0/examples/weather-assistant/agent.py +46 -0
  70. dispatch_agents-0.9.0/examples/weather-assistant/pyproject.toml +10 -0
  71. dispatch_agents-0.9.0/examples/weather-assistant/uv.lock +856 -0
  72. dispatch_agents-0.9.0/examples/weather-service/.dispatch.yaml +5 -0
  73. dispatch_agents-0.9.0/examples/weather-service/.gitignore +4 -0
  74. dispatch_agents-0.9.0/examples/weather-service/AGENTS.md +3 -0
  75. dispatch_agents-0.9.0/examples/weather-service/agent.py +69 -0
  76. dispatch_agents-0.9.0/examples/weather-service/pyproject.toml +10 -0
  77. dispatch_agents-0.9.0/examples/weather-service/uv.lock +856 -0
  78. dispatch_agents-0.9.0/internal/py.typed +0 -0
  79. dispatch_agents-0.9.0/plugins/README.md +51 -0
  80. dispatch_agents-0.9.0/pyproject.toml +117 -0
  81. dispatch_agents-0.9.0/tests/__init__.py +0 -0
  82. dispatch_agents-0.9.0/tests/e2e_claude_mcp_proxy.py +386 -0
  83. dispatch_agents-0.9.0/tests/schemas/README.md +23 -0
  84. dispatch_agents-0.9.0/tests/schemas/octokit-webhooks.json +16702 -0
  85. dispatch_agents-0.9.0/tests/test.py +156 -0
  86. dispatch_agents-0.9.0/tests/test_agent_service.py +142 -0
  87. dispatch_agents-0.9.0/tests/test_agent_uid.py +147 -0
  88. dispatch_agents-0.9.0/tests/test_config.py +512 -0
  89. dispatch_agents-0.9.0/tests/test_contrib_claude.py +324 -0
  90. dispatch_agents-0.9.0/tests/test_contrib_openai.py +277 -0
  91. dispatch_agents-0.9.0/tests/test_dev_mode_isolation.py +543 -0
  92. dispatch_agents-0.9.0/tests/test_extra_headers.py +79 -0
  93. dispatch_agents-0.9.0/tests/test_fn_decorator.py +191 -0
  94. dispatch_agents-0.9.0/tests/test_github_integration.py +993 -0
  95. dispatch_agents-0.9.0/tests/test_github_schema_compliance.py +398 -0
  96. dispatch_agents-0.9.0/tests/test_grpc_server.py +548 -0
  97. dispatch_agents-0.9.0/tests/test_init.py +210 -0
  98. dispatch_agents-0.9.0/tests/test_instrument.py +156 -0
  99. dispatch_agents-0.9.0/tests/test_llm_langchain.py +316 -0
  100. dispatch_agents-0.9.0/tests/test_llm_logging.py +623 -0
  101. dispatch_agents-0.9.0/tests/test_logging_config.py +147 -0
  102. dispatch_agents-0.9.0/tests/test_mcp.py +207 -0
  103. dispatch_agents-0.9.0/tests/test_proxy_e2e.py +135 -0
  104. dispatch_agents-0.9.0/tests/test_proxy_server.py +1236 -0
  105. dispatch_agents-0.9.0/tests/test_resources.py +300 -0
  106. dispatch_agents-0.9.0/tests/test_sse_utils.py +90 -0
  107. dispatch_agents-0.9.0/tests/test_trace_context.py +459 -0
  108. dispatch_agents-0.9.0/tests/test_typed_events.py +486 -0
  109. dispatch_agents-0.9.0/uv.lock +2521 -0
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "dispatch-agents",
3
+ "owner": {
4
+ "name": "Dispatch Agents Team",
5
+ "email": "dispatch-agents@datadoghq.com"
6
+ },
7
+ "metadata": {
8
+ "description": "Official plugins for building and managing Dispatch Agents"
9
+ },
10
+ "plugins": [
11
+ {
12
+ "name": "dispatch-agents",
13
+ "source": "./plugins/dispatch-agents",
14
+ "description": "Getting started skill and Dispatch Operator MCP server for building Dispatch Agents",
15
+ "version": "0.1.0",
16
+ "author": {
17
+ "name": "Dispatch Agents Team"
18
+ },
19
+ "keywords": ["dispatch", "agents", "mcp"]
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """Classify whether the current ref includes release-relevant changes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from ci_git import (
13
+ fetch_main_branch_ref,
14
+ fetch_tags,
15
+ get_changed_files,
16
+ get_latest_tag,
17
+ get_merge_base,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ChangeScopeResult:
23
+ ref_name: str
24
+ range_label: str
25
+ pyproject_baseline_ref: str | None
26
+ changed_files: tuple[str, ...]
27
+ source_changed: bool
28
+
29
+
30
+ def parse_json_string_list(value: str) -> list[str]:
31
+ parsed = json.loads(value)
32
+ if not isinstance(parsed, list) or not all(
33
+ isinstance(item, str) for item in parsed
34
+ ):
35
+ raise ValueError("expected a JSON array of strings")
36
+ return parsed
37
+
38
+
39
+ def is_release_relevant_source_path(
40
+ path: str,
41
+ *,
42
+ ignored_paths: set[str],
43
+ ignored_prefixes: tuple[str, ...],
44
+ ) -> bool:
45
+ if path in ignored_paths:
46
+ return False
47
+ return not path.startswith(ignored_prefixes)
48
+
49
+
50
+ def classify_changed_files(
51
+ changed_files: list[str],
52
+ *,
53
+ ignored_paths: set[str],
54
+ ignored_prefixes: tuple[str, ...],
55
+ ) -> bool:
56
+ return any(
57
+ is_release_relevant_source_path(
58
+ path,
59
+ ignored_paths=ignored_paths,
60
+ ignored_prefixes=ignored_prefixes,
61
+ )
62
+ for path in changed_files
63
+ )
64
+
65
+
66
+ def determine_change_scope(
67
+ *,
68
+ ref_name: str,
69
+ changed_files: list[str],
70
+ latest_tag: str | None,
71
+ feature_branch_base_ref: str | None,
72
+ ignored_paths: set[str],
73
+ ignored_prefixes: tuple[str, ...],
74
+ ) -> ChangeScopeResult:
75
+ if ref_name != "main":
76
+ if feature_branch_base_ref is None:
77
+ raise ValueError(
78
+ "feature_branch_base_ref is required for feature-branch mode"
79
+ )
80
+ return ChangeScopeResult(
81
+ ref_name=ref_name,
82
+ range_label=f"{feature_branch_base_ref}...HEAD",
83
+ pyproject_baseline_ref=feature_branch_base_ref,
84
+ changed_files=tuple(changed_files),
85
+ source_changed=classify_changed_files(
86
+ changed_files,
87
+ ignored_paths=ignored_paths,
88
+ ignored_prefixes=ignored_prefixes,
89
+ ),
90
+ )
91
+
92
+ range_label = (
93
+ f"{latest_tag}...HEAD" if latest_tag is not None else "tracked files in HEAD"
94
+ )
95
+ return ChangeScopeResult(
96
+ ref_name=ref_name,
97
+ range_label=range_label,
98
+ pyproject_baseline_ref=latest_tag,
99
+ changed_files=tuple(changed_files),
100
+ source_changed=classify_changed_files(
101
+ changed_files,
102
+ ignored_paths=ignored_paths,
103
+ ignored_prefixes=ignored_prefixes,
104
+ ),
105
+ )
106
+
107
+
108
+ def get_feature_branch_base_ref() -> str:
109
+ fetch_main_branch_ref()
110
+ return get_merge_base("HEAD", "origin/main")
111
+
112
+
113
+ def write_github_outputs(result: ChangeScopeResult) -> None:
114
+ output_path = os.environ.get("GITHUB_OUTPUT")
115
+ if not output_path:
116
+ return
117
+
118
+ outputs = {
119
+ "source_changed": str(result.source_changed).lower(),
120
+ "pyproject_baseline_ref": result.pyproject_baseline_ref or "",
121
+ }
122
+
123
+ with Path(output_path).open("a", encoding="utf-8") as file_obj:
124
+ for key, value in outputs.items():
125
+ file_obj.write(f"{key}={value}\n")
126
+
127
+
128
+ def parse_args() -> argparse.Namespace:
129
+ parser = argparse.ArgumentParser(description=__doc__)
130
+ parser.add_argument(
131
+ "--ref-name",
132
+ required=True,
133
+ help="GitHub ref name for the current workflow run.",
134
+ )
135
+ parser.add_argument(
136
+ "--ignored-paths-json",
137
+ required=True,
138
+ help="JSON array of exact paths that do not count as release-relevant source changes.",
139
+ )
140
+ parser.add_argument(
141
+ "--ignored-prefixes-json",
142
+ required=True,
143
+ help="JSON array of path prefixes that do not count as release-relevant source changes.",
144
+ )
145
+ return parser.parse_args()
146
+
147
+
148
+ def main() -> int:
149
+ args = parse_args()
150
+ ignored_paths = set(parse_json_string_list(args.ignored_paths_json))
151
+ ignored_prefixes = tuple(parse_json_string_list(args.ignored_prefixes_json))
152
+
153
+ fetch_tags()
154
+
155
+ latest_tag = get_latest_tag()
156
+ if args.ref_name != "main":
157
+ feature_branch_base_ref = get_feature_branch_base_ref()
158
+ changed_files = get_changed_files(feature_branch_base_ref)
159
+ else:
160
+ feature_branch_base_ref = None
161
+ changed_files = get_changed_files(latest_tag)
162
+
163
+ result = determine_change_scope(
164
+ ref_name=args.ref_name,
165
+ changed_files=changed_files,
166
+ latest_tag=latest_tag,
167
+ feature_branch_base_ref=feature_branch_base_ref,
168
+ ignored_paths=ignored_paths,
169
+ ignored_prefixes=ignored_prefixes,
170
+ )
171
+ write_github_outputs(result)
172
+
173
+ print(f"Change scope ({args.ref_name})")
174
+ print(f" range: {result.range_label}")
175
+ print(
176
+ f" pyproject baseline: {result.pyproject_baseline_ref or '(latest tag baseline unavailable)'}"
177
+ )
178
+ print(f" ignored paths: {sorted(ignored_paths)}")
179
+ print(f" ignored prefixes: {list(ignored_prefixes)}")
180
+ print(f" release-relevant source changed: {str(result.source_changed).lower()}")
181
+ if result.changed_files:
182
+ print(" compared files:")
183
+ for path in result.changed_files:
184
+ print(f" - {path}")
185
+ else:
186
+ print(" compared files: (none)")
187
+
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ raise SystemExit(main())
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ """Shared git and tag helpers for CI workflow scripts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import subprocess
8
+ from collections.abc import Iterable
9
+
10
+ SEMVER_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
11
+
12
+
13
+ def run_git(*args: str, check: bool = True) -> str:
14
+ result = subprocess.run(
15
+ ["git", *args],
16
+ check=check,
17
+ capture_output=True,
18
+ text=True,
19
+ )
20
+ return result.stdout.strip()
21
+
22
+
23
+ def parse_tag(tag: str) -> tuple[int, int, int]:
24
+ match = SEMVER_TAG_RE.fullmatch(tag)
25
+ if not match:
26
+ raise ValueError(f"Unsupported tag format: {tag}")
27
+ return tuple(int(part) for part in match.groups())
28
+
29
+
30
+ def filter_semver_tags(tags: Iterable[str]) -> list[str]:
31
+ valid_tags = [tag for tag in tags if SEMVER_TAG_RE.fullmatch(tag)]
32
+ return sorted(valid_tags, key=parse_tag)
33
+
34
+
35
+ def fetch_tags() -> None:
36
+ subprocess.run(
37
+ ["git", "fetch", "--force", "--tags", "origin"],
38
+ check=True,
39
+ capture_output=True,
40
+ text=True,
41
+ )
42
+
43
+
44
+ def fetch_main_branch_ref() -> None:
45
+ subprocess.run(
46
+ ["git", "fetch", "--no-tags", "origin", "main:refs/remotes/origin/main"],
47
+ check=True,
48
+ capture_output=True,
49
+ text=True,
50
+ )
51
+
52
+
53
+ def get_latest_tag() -> str | None:
54
+ tags_output = run_git("tag", "--list", "v*")
55
+ tags = [line.strip() for line in tags_output.splitlines() if line.strip()]
56
+ valid_tags = filter_semver_tags(tags)
57
+ if not valid_tags:
58
+ return None
59
+ return valid_tags[-1]
60
+
61
+
62
+ def get_merge_base(left_ref: str, right_ref: str) -> str:
63
+ return run_git("merge-base", left_ref, right_ref)
64
+
65
+
66
+ def get_changed_files(diff_ref: str | None) -> list[str]:
67
+ if diff_ref is None:
68
+ output = run_git("ls-files")
69
+ else:
70
+ output = run_git(
71
+ "diff",
72
+ "--name-only",
73
+ "--diff-filter=ACDMRTUXB",
74
+ f"{diff_ref}...HEAD",
75
+ )
76
+ return [line.strip() for line in output.splitlines() if line.strip()]
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env python3
2
+ """Enforce release version policy for CI workflows."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import tomllib
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ SEMVER_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
17
+
18
+ from ci_git import fetch_tags, get_latest_tag, parse_tag
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class PolicyResult:
23
+ current_version: str
24
+ current_tag: str
25
+ latest_tag: str
26
+ source_changed: bool
27
+ requires_version_bump: bool
28
+ has_version_bump: bool
29
+ should_release: bool
30
+ relevant_pyproject_changed: bool
31
+ failure_reason: str | None
32
+
33
+
34
+ def parse_version(version: str) -> tuple[int, int, int]:
35
+ match = SEMVER_VERSION_RE.fullmatch(version)
36
+ if not match:
37
+ raise ValueError(f"Unsupported version format: {version}")
38
+ return tuple(int(part) for part in match.groups())
39
+
40
+
41
+ def compare_versions(current_version: str, latest_tag: str) -> int:
42
+ current = parse_version(current_version)
43
+ latest = parse_tag(latest_tag)
44
+ if current > latest:
45
+ return 1
46
+ if current < latest:
47
+ return -1
48
+ return 0
49
+
50
+
51
+ def is_relevant_pyproject_change(
52
+ current_pyproject: dict[str, Any], baseline_pyproject: dict[str, Any] | None
53
+ ) -> bool:
54
+ if baseline_pyproject is None:
55
+ return False
56
+
57
+ current_project = dict(current_pyproject.get("project", {}))
58
+ baseline_project = dict(baseline_pyproject.get("project", {}))
59
+ current_project.pop("version", None)
60
+ baseline_project.pop("version", None)
61
+
62
+ current_hatch = current_pyproject.get("tool", {}).get("hatch", {}).get("build", {})
63
+ baseline_hatch = (
64
+ baseline_pyproject.get("tool", {}).get("hatch", {}).get("build", {})
65
+ )
66
+
67
+ return (
68
+ current_pyproject.get("build-system", {})
69
+ != baseline_pyproject.get("build-system", {})
70
+ or current_project != baseline_project
71
+ or current_hatch != baseline_hatch
72
+ )
73
+
74
+
75
+ def evaluate_policy(
76
+ *,
77
+ source_changed: bool,
78
+ current_pyproject: dict[str, Any],
79
+ baseline_pyproject: dict[str, Any] | None,
80
+ latest_tag: str | None,
81
+ ) -> PolicyResult:
82
+ current_version = current_pyproject["project"]["version"]
83
+ current_tag = f"v{current_version}"
84
+ baseline_tag = latest_tag or "v0.0.0"
85
+ relevant_change = is_relevant_pyproject_change(
86
+ current_pyproject, baseline_pyproject
87
+ )
88
+ bump_required = source_changed or relevant_change
89
+
90
+ has_bump = compare_versions(current_version, baseline_tag) > 0
91
+ comparison = compare_versions(current_version, baseline_tag)
92
+ failure_reason: str | None = None
93
+
94
+ if comparison < 0:
95
+ failure_reason = (
96
+ f"Current version {current_tag} is behind latest release {baseline_tag}."
97
+ )
98
+ elif latest_tag is not None and bump_required and comparison <= 0:
99
+ failure_reason = (
100
+ "Changes require a semantic version bump, "
101
+ f"but {current_tag} is not greater than {baseline_tag}."
102
+ )
103
+
104
+ return PolicyResult(
105
+ current_version=current_version,
106
+ current_tag=current_tag,
107
+ latest_tag=baseline_tag,
108
+ source_changed=source_changed,
109
+ requires_version_bump=bump_required,
110
+ has_version_bump=has_bump,
111
+ should_release=has_bump,
112
+ relevant_pyproject_changed=relevant_change,
113
+ failure_reason=failure_reason,
114
+ )
115
+
116
+
117
+ def load_pyproject(path: Path) -> dict[str, Any]:
118
+ with path.open("rb") as file_obj:
119
+ return tomllib.load(file_obj)
120
+
121
+
122
+ def load_pyproject_from_ref(ref: str | None, repo_root: Path) -> dict[str, Any] | None:
123
+ if ref is None:
124
+ return None
125
+
126
+ result = subprocess.run(
127
+ ["git", "show", f"{ref}:pyproject.toml"],
128
+ check=False,
129
+ capture_output=True,
130
+ text=True,
131
+ cwd=repo_root,
132
+ )
133
+ if result.returncode != 0:
134
+ return None
135
+ return tomllib.loads(result.stdout)
136
+
137
+
138
+ def write_github_outputs(result: PolicyResult) -> None:
139
+ output_path = os.environ.get("GITHUB_OUTPUT")
140
+ if not output_path:
141
+ return
142
+
143
+ outputs = {
144
+ "current_version": result.current_version,
145
+ "current_tag": result.current_tag,
146
+ "latest_tag": result.latest_tag,
147
+ "requires_version_bump": str(result.requires_version_bump).lower(),
148
+ "has_version_bump": str(result.has_version_bump).lower(),
149
+ "relevant_pyproject_changed": str(result.relevant_pyproject_changed).lower(),
150
+ "should_release": str(result.should_release).lower(),
151
+ }
152
+
153
+ with Path(output_path).open("a", encoding="utf-8") as file_obj:
154
+ for key, value in outputs.items():
155
+ file_obj.write(f"{key}={value}\n")
156
+
157
+
158
+ def parse_args() -> argparse.Namespace:
159
+ parser = argparse.ArgumentParser(description=__doc__)
160
+ parser.add_argument(
161
+ "--source-changed",
162
+ choices=("true", "false"),
163
+ required=True,
164
+ help="Whether the workflow determined that release-relevant non-pyproject files changed.",
165
+ )
166
+ parser.add_argument(
167
+ "--pyproject-baseline-ref",
168
+ default="",
169
+ help="Git ref to use as the pyproject.toml comparison baseline. Defaults to the latest release tag when omitted.",
170
+ )
171
+ return parser.parse_args()
172
+
173
+
174
+ def main() -> int:
175
+ args = parse_args()
176
+ repo_root = Path.cwd()
177
+ fetch_tags()
178
+
179
+ latest_tag = get_latest_tag()
180
+ current_pyproject = load_pyproject(repo_root / "pyproject.toml")
181
+ pyproject_baseline_ref = args.pyproject_baseline_ref or latest_tag
182
+ baseline_pyproject = load_pyproject_from_ref(pyproject_baseline_ref, repo_root)
183
+ source_changed = args.source_changed == "true"
184
+
185
+ result = evaluate_policy(
186
+ source_changed=source_changed,
187
+ current_pyproject=current_pyproject,
188
+ baseline_pyproject=baseline_pyproject,
189
+ latest_tag=latest_tag,
190
+ )
191
+ write_github_outputs(result)
192
+
193
+ print("Version policy check")
194
+ print(f" latest tag: {result.latest_tag}")
195
+ print(f" current tag: {result.current_tag}")
196
+ print(f" pyproject baseline: {pyproject_baseline_ref or '(none)'}")
197
+ print(f" source changed: {str(result.source_changed).lower()}")
198
+ print(f" requires bump: {str(result.requires_version_bump).lower()}")
199
+ print(f" should release: {str(result.should_release).lower()}")
200
+
201
+ if result.failure_reason:
202
+ print(result.failure_reason, file=sys.stderr)
203
+ return 1
204
+
205
+ return 0
206
+
207
+
208
+ if __name__ == "__main__":
209
+ raise SystemExit(main())
@@ -0,0 +1,159 @@
1
+ """Tests for the CI change scope helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ MODULE_PATH = (
12
+ Path(__file__).resolve().parents[2] / ".github" / "scripts" / "change_scope.py"
13
+ )
14
+ MODULE_NAME = "dispatch_agents_ci_change_scope"
15
+ sys.path.insert(0, str(MODULE_PATH.parent))
16
+
17
+ IGNORED_PATHS = {
18
+ "README.md",
19
+ "CONTRIBUTING.md",
20
+ "NOTICE",
21
+ "uv.lock",
22
+ "pyproject.toml",
23
+ }
24
+ IGNORED_PREFIXES = (".github/", "tests/", "examples/", "plugins/", "LICENSE")
25
+
26
+ spec = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
27
+ assert spec is not None
28
+ assert spec.loader is not None
29
+ change_scope = importlib.util.module_from_spec(spec)
30
+ sys.modules[MODULE_NAME] = change_scope
31
+ spec.loader.exec_module(change_scope)
32
+
33
+
34
+ def test_docs_and_workflow_changes_are_not_release_relevant():
35
+ assert (
36
+ change_scope.classify_changed_files(
37
+ [
38
+ "README.md",
39
+ ".github/workflows/feature-branch.yml",
40
+ "tests/test_config.py",
41
+ ],
42
+ ignored_paths=set(IGNORED_PATHS),
43
+ ignored_prefixes=IGNORED_PREFIXES,
44
+ )
45
+ is False
46
+ )
47
+
48
+
49
+ def test_pyproject_change_is_deferred_to_semantic_diff():
50
+ assert (
51
+ change_scope.classify_changed_files(
52
+ ["pyproject.toml"],
53
+ ignored_paths=set(IGNORED_PATHS),
54
+ ignored_prefixes=IGNORED_PREFIXES,
55
+ )
56
+ is False
57
+ )
58
+
59
+
60
+ def test_sdk_change_is_release_relevant():
61
+ assert (
62
+ change_scope.classify_changed_files(
63
+ ["dispatch_agents/instrument.py"],
64
+ ignored_paths=set(IGNORED_PATHS),
65
+ ignored_prefixes=IGNORED_PREFIXES,
66
+ )
67
+ is True
68
+ )
69
+
70
+
71
+ def test_unknown_top_level_path_is_conservatively_relevant():
72
+ assert (
73
+ change_scope.classify_changed_files(
74
+ ["new_surface/config.json"],
75
+ ignored_paths=set(IGNORED_PATHS),
76
+ ignored_prefixes=IGNORED_PREFIXES,
77
+ )
78
+ is True
79
+ )
80
+
81
+
82
+ def test_custom_ignored_prefixes_make_policy_explicit():
83
+ assert (
84
+ change_scope.classify_changed_files(
85
+ ["docs/release-notes.md"],
86
+ ignored_paths=set(),
87
+ ignored_prefixes=("docs/",),
88
+ )
89
+ is False
90
+ )
91
+
92
+
93
+ def test_feature_branch_scope_uses_branch_base_for_pyproject():
94
+ result = change_scope.determine_change_scope(
95
+ ref_name="feature/foo",
96
+ changed_files=[".github/workflows/release.yml"],
97
+ latest_tag="v0.7.3",
98
+ feature_branch_base_ref="abc123",
99
+ ignored_paths=set(IGNORED_PATHS),
100
+ ignored_prefixes=IGNORED_PREFIXES,
101
+ )
102
+
103
+ assert result.range_label == "abc123...HEAD"
104
+ assert result.pyproject_baseline_ref == "abc123"
105
+ assert result.source_changed is False
106
+
107
+
108
+ def test_release_scope_uses_latest_tag():
109
+ result = change_scope.determine_change_scope(
110
+ ref_name="main",
111
+ changed_files=["dispatch_agents/instrument.py"],
112
+ latest_tag="v0.7.3",
113
+ feature_branch_base_ref=None,
114
+ ignored_paths=set(IGNORED_PATHS),
115
+ ignored_prefixes=IGNORED_PREFIXES,
116
+ )
117
+
118
+ assert result.range_label == "v0.7.3...HEAD"
119
+ assert result.pyproject_baseline_ref == "v0.7.3"
120
+ assert result.source_changed is True
121
+
122
+
123
+ def test_feature_branch_scope_requires_base_ref():
124
+ with pytest.raises(ValueError):
125
+ change_scope.determine_change_scope(
126
+ ref_name="feature/foo",
127
+ changed_files=[],
128
+ latest_tag="v0.7.3",
129
+ feature_branch_base_ref=None,
130
+ ignored_paths=set(IGNORED_PATHS),
131
+ ignored_prefixes=IGNORED_PREFIXES,
132
+ )
133
+
134
+
135
+ def test_parse_json_string_list_rejects_non_string_lists():
136
+ with pytest.raises(ValueError):
137
+ change_scope.parse_json_string_list('["ok", 1]')
138
+
139
+
140
+ def test_parse_args_requires_explicit_policy_values(monkeypatch):
141
+ monkeypatch.setattr(
142
+ sys,
143
+ "argv",
144
+ [
145
+ "change_scope.py",
146
+ "--ref-name",
147
+ "main",
148
+ "--ignored-paths-json",
149
+ '["README.md"]',
150
+ "--ignored-prefixes-json",
151
+ '[".github/"]',
152
+ ],
153
+ )
154
+
155
+ args = change_scope.parse_args()
156
+
157
+ assert args.ref_name == "main"
158
+ assert args.ignored_paths_json == '["README.md"]'
159
+ assert args.ignored_prefixes_json == '[".github/"]'