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.
- dispatch_agents-0.9.0/.claude-plugin/marketplace.json +22 -0
- dispatch_agents-0.9.0/.github/scripts/change_scope.py +192 -0
- dispatch_agents-0.9.0/.github/scripts/ci_git.py +76 -0
- dispatch_agents-0.9.0/.github/scripts/version_policy.py +209 -0
- dispatch_agents-0.9.0/.github/tests/test_change_scope.py +159 -0
- dispatch_agents-0.9.0/.github/tests/test_ci_git.py +113 -0
- dispatch_agents-0.9.0/.github/tests/test_version_policy.py +182 -0
- dispatch_agents-0.9.0/.github/workflows/ci-reusable.yml +61 -0
- dispatch_agents-0.9.0/.github/workflows/feature-branch.yml +20 -0
- dispatch_agents-0.9.0/.github/workflows/release.yml +81 -0
- dispatch_agents-0.9.0/.github/workflows/version-policy-reusable.yml +50 -0
- dispatch_agents-0.9.0/.gitignore +210 -0
- dispatch_agents-0.9.0/CONTRIBUTING.md +26 -0
- dispatch_agents-0.9.0/LICENSE +191 -0
- dispatch_agents-0.9.0/LICENSE-3rdparty.csv +12 -0
- dispatch_agents-0.9.0/NOTICE +5 -0
- dispatch_agents-0.9.0/PKG-INFO +20 -0
- dispatch_agents-0.9.0/README.md +48 -0
- dispatch_agents-0.9.0/agentservice/__init__.py +0 -0
- dispatch_agents-0.9.0/agentservice/py.typed +0 -0
- dispatch_agents-0.9.0/agentservice/v1/__init__.py +0 -0
- dispatch_agents-0.9.0/agentservice/v1/message_pb2.py +41 -0
- dispatch_agents-0.9.0/agentservice/v1/message_pb2.pyi +22 -0
- dispatch_agents-0.9.0/agentservice/v1/message_pb2_grpc.py +4 -0
- dispatch_agents-0.9.0/agentservice/v1/request_response_pb2.py +46 -0
- dispatch_agents-0.9.0/agentservice/v1/request_response_pb2.pyi +54 -0
- dispatch_agents-0.9.0/agentservice/v1/request_response_pb2_grpc.py +4 -0
- dispatch_agents-0.9.0/agentservice/v1/service_pb2.py +43 -0
- dispatch_agents-0.9.0/agentservice/v1/service_pb2.pyi +6 -0
- dispatch_agents-0.9.0/agentservice/v1/service_pb2_grpc.py +129 -0
- dispatch_agents-0.9.0/dispatch_agents/__init__.py +281 -0
- dispatch_agents-0.9.0/dispatch_agents/agent_service.py +135 -0
- dispatch_agents-0.9.0/dispatch_agents/config.py +490 -0
- dispatch_agents-0.9.0/dispatch_agents/contrib/__init__.py +1 -0
- dispatch_agents-0.9.0/dispatch_agents/contrib/claude/__init__.py +246 -0
- dispatch_agents-0.9.0/dispatch_agents/contrib/openai/__init__.py +167 -0
- dispatch_agents-0.9.0/dispatch_agents/events.py +986 -0
- dispatch_agents-0.9.0/dispatch_agents/grpc_server.py +565 -0
- dispatch_agents-0.9.0/dispatch_agents/instrument.py +217 -0
- dispatch_agents-0.9.0/dispatch_agents/integrations/__init__.py +1 -0
- dispatch_agents-0.9.0/dispatch_agents/integrations/github/README.md +9 -0
- dispatch_agents-0.9.0/dispatch_agents/integrations/github/__init__.py +4268 -0
- dispatch_agents-0.9.0/dispatch_agents/invocation.py +25 -0
- dispatch_agents-0.9.0/dispatch_agents/llm.py +1017 -0
- dispatch_agents-0.9.0/dispatch_agents/llm_langchain.py +394 -0
- dispatch_agents-0.9.0/dispatch_agents/logging_config.py +133 -0
- dispatch_agents-0.9.0/dispatch_agents/mcp.py +266 -0
- dispatch_agents-0.9.0/dispatch_agents/memory.py +264 -0
- dispatch_agents-0.9.0/dispatch_agents/models.py +748 -0
- dispatch_agents-0.9.0/dispatch_agents/proxy/__init__.py +6 -0
- dispatch_agents-0.9.0/dispatch_agents/proxy/server.py +1137 -0
- dispatch_agents-0.9.0/dispatch_agents/proxy/sse_utils.py +76 -0
- dispatch_agents-0.9.0/dispatch_agents/py.typed +0 -0
- dispatch_agents-0.9.0/dispatch_agents/resources.py +68 -0
- dispatch_agents-0.9.0/dispatch_agents/version.py +19 -0
- dispatch_agents-0.9.0/examples/README.md +39 -0
- dispatch_agents-0.9.0/examples/hello_world/.dispatch.yaml +9 -0
- dispatch_agents-0.9.0/examples/hello_world/.gitignore +4 -0
- dispatch_agents-0.9.0/examples/hello_world/AGENTS.md +3 -0
- dispatch_agents-0.9.0/examples/hello_world/agent.py +112 -0
- dispatch_agents-0.9.0/examples/hello_world/pyproject.toml +12 -0
- dispatch_agents-0.9.0/examples/hello_world/test_agent.py +45 -0
- dispatch_agents-0.9.0/examples/hello_world/uv.lock +862 -0
- dispatch_agents-0.9.0/examples/pyproject.toml +56 -0
- dispatch_agents-0.9.0/examples/uv.lock +169 -0
- dispatch_agents-0.9.0/examples/weather-assistant/.dispatch.yaml +5 -0
- dispatch_agents-0.9.0/examples/weather-assistant/.gitignore +4 -0
- dispatch_agents-0.9.0/examples/weather-assistant/AGENTS.md +3 -0
- dispatch_agents-0.9.0/examples/weather-assistant/agent.py +46 -0
- dispatch_agents-0.9.0/examples/weather-assistant/pyproject.toml +10 -0
- dispatch_agents-0.9.0/examples/weather-assistant/uv.lock +856 -0
- dispatch_agents-0.9.0/examples/weather-service/.dispatch.yaml +5 -0
- dispatch_agents-0.9.0/examples/weather-service/.gitignore +4 -0
- dispatch_agents-0.9.0/examples/weather-service/AGENTS.md +3 -0
- dispatch_agents-0.9.0/examples/weather-service/agent.py +69 -0
- dispatch_agents-0.9.0/examples/weather-service/pyproject.toml +10 -0
- dispatch_agents-0.9.0/examples/weather-service/uv.lock +856 -0
- dispatch_agents-0.9.0/internal/py.typed +0 -0
- dispatch_agents-0.9.0/plugins/README.md +51 -0
- dispatch_agents-0.9.0/pyproject.toml +117 -0
- dispatch_agents-0.9.0/tests/__init__.py +0 -0
- dispatch_agents-0.9.0/tests/e2e_claude_mcp_proxy.py +386 -0
- dispatch_agents-0.9.0/tests/schemas/README.md +23 -0
- dispatch_agents-0.9.0/tests/schemas/octokit-webhooks.json +16702 -0
- dispatch_agents-0.9.0/tests/test.py +156 -0
- dispatch_agents-0.9.0/tests/test_agent_service.py +142 -0
- dispatch_agents-0.9.0/tests/test_agent_uid.py +147 -0
- dispatch_agents-0.9.0/tests/test_config.py +512 -0
- dispatch_agents-0.9.0/tests/test_contrib_claude.py +324 -0
- dispatch_agents-0.9.0/tests/test_contrib_openai.py +277 -0
- dispatch_agents-0.9.0/tests/test_dev_mode_isolation.py +543 -0
- dispatch_agents-0.9.0/tests/test_extra_headers.py +79 -0
- dispatch_agents-0.9.0/tests/test_fn_decorator.py +191 -0
- dispatch_agents-0.9.0/tests/test_github_integration.py +993 -0
- dispatch_agents-0.9.0/tests/test_github_schema_compliance.py +398 -0
- dispatch_agents-0.9.0/tests/test_grpc_server.py +548 -0
- dispatch_agents-0.9.0/tests/test_init.py +210 -0
- dispatch_agents-0.9.0/tests/test_instrument.py +156 -0
- dispatch_agents-0.9.0/tests/test_llm_langchain.py +316 -0
- dispatch_agents-0.9.0/tests/test_llm_logging.py +623 -0
- dispatch_agents-0.9.0/tests/test_logging_config.py +147 -0
- dispatch_agents-0.9.0/tests/test_mcp.py +207 -0
- dispatch_agents-0.9.0/tests/test_proxy_e2e.py +135 -0
- dispatch_agents-0.9.0/tests/test_proxy_server.py +1236 -0
- dispatch_agents-0.9.0/tests/test_resources.py +300 -0
- dispatch_agents-0.9.0/tests/test_sse_utils.py +90 -0
- dispatch_agents-0.9.0/tests/test_trace_context.py +459 -0
- dispatch_agents-0.9.0/tests/test_typed_events.py +486 -0
- 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/"]'
|