ado-workflows 0.2.2__tar.gz → 0.3.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.
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/CHANGELOG.md +18 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/PKG-INFO +1 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/pyproject.toml +9 -2
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/__init__.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/auth.py +11 -4
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/client.py +6 -2
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/comments.py +21 -9
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/content.py +71 -5
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/context.py +14 -9
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/discovery.py +11 -4
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/iterations.py +11 -4
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/lifecycle.py +5 -2
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/models.py +28 -14
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/parsing.py +8 -3
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/pr.py +15 -7
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/review.py +16 -6
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/votes.py +7 -3
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/__init__.py +1 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_auth.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_client.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_comment_positioning.py +3 -4
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_comments.py +2 -1
- ado_workflows-0.3.0/tests/test_content.py +802 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_context.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_discovery.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_identity.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_iterations.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_lifecycle.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_models.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_pending_review.py +4 -2
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_pr.py +2 -1
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_review.py +8 -4
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_votes.py +4 -2
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/git/models.pyi +1 -8
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/uv.lock +1 -1
- ado_workflows-0.2.2/tests/test_content.py +0 -373
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.copilot/README.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.envrc +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/pull_request_template.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/secret_scanning.yml +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/workflows/ci.yml +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/workflows/release.yml +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.gitignore +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.pre-commit-config.yaml +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/CONTRIBUTING.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/LICENSE +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/README.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/SECURITY.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/docs/ARCHITECTURE.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/docs/PUBLISHING.md +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/py.typed +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_parsing.py +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/__init__.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/__init__.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/connection.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/__init__.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/core/__init__.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/core/core_client.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/git/__init__.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/git/git_client.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/location/__init__.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/location/location_client.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/policy/__init__.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/policy/models.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/policy/policy_client.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/work_item_tracking/__init__.pyi +0 -0
- {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/work_item_tracking/work_item_tracking_client.pyi +0 -0
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v0.3.0 (2026-03-27)
|
|
6
|
+
|
|
7
|
+
### Chores
|
|
8
|
+
|
|
9
|
+
- **lint**: Add pydocstyle rules and fix docstring issues
|
|
10
|
+
([`4438527`](https://github.com/grimlor/ado-workflows/commit/4438527cc66f000e84b6ef183514f2c12efafe79))
|
|
11
|
+
|
|
12
|
+
### Code Style
|
|
13
|
+
|
|
14
|
+
- `task format` changes only
|
|
15
|
+
([`36082cd`](https://github.com/grimlor/ado-workflows/commit/36082cd2f94b3d179d7db20a9be0cce9d913c822))
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **content**: Add exclude_extensions filtering and completed-PR fallback
|
|
20
|
+
([`87926d0`](https://github.com/grimlor/ado-workflows/commit/87926d09cd3a30ac5fbcb6050086c970b7eb5591))
|
|
21
|
+
|
|
22
|
+
|
|
5
23
|
## v0.2.2 (2026-03-19)
|
|
6
24
|
|
|
7
25
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ado-workflows
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Azure DevOps workflow automation library — PR context, repository discovery, and SDK client wrappers
|
|
5
5
|
Project-URL: Repository, https://github.com/grimlor/ado-workflows
|
|
6
6
|
Author: Jack Pines
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ado-workflows"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Azure DevOps workflow automation library — PR context, repository discovery, and SDK client wrappers"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12,<3.14"
|
|
@@ -77,11 +77,14 @@ select = [
|
|
|
77
77
|
"SIM", # flake8-simplify
|
|
78
78
|
"TCH", # flake8-type-checking
|
|
79
79
|
"RUF", # ruff-specific rules
|
|
80
|
+
"D", # pydocstyle
|
|
80
81
|
"PLC0415", # import-outside-top-level
|
|
81
82
|
"PLC2701", # import-private-name (preview)
|
|
82
83
|
]
|
|
83
84
|
ignore = [
|
|
84
|
-
"E501",
|
|
85
|
+
"E501", # line length (handled by formatter)
|
|
86
|
+
"D212", # multi-line-summary-first-line (conflicts with D213)
|
|
87
|
+
"D203", # one-blank-line-before-class (conflicts with D211)
|
|
85
88
|
]
|
|
86
89
|
|
|
87
90
|
[tool.ruff.lint.isort]
|
|
@@ -92,6 +95,10 @@ combine-as-imports = true
|
|
|
92
95
|
"tests/**/*.py" = [
|
|
93
96
|
"PLC0415", # import-outside-top-level — tests may import inside functions
|
|
94
97
|
"PLC2701", # import-private-name — testing internal state is intentional
|
|
98
|
+
"D205", # BDD Given/When/Then blocks aren't summary+body
|
|
99
|
+
"D400", # BDD steps don't end with periods
|
|
100
|
+
"D415", # same as D400
|
|
101
|
+
"D401", # fixture docstrings are descriptive, not imperative
|
|
95
102
|
]
|
|
96
103
|
|
|
97
104
|
[tool.ruff.format]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Azure DevOps authentication — DefaultAzureCredential → Connection bridge.
|
|
2
3
|
|
|
3
4
|
Bridges azure-identity's ``DefaultAzureCredential`` into the azure-devops SDK's
|
|
4
5
|
msrest-based auth layer. ``ConnectionFactory`` handles per-org connection caching
|
|
@@ -35,7 +36,8 @@ _TOKEN_REFRESH_BUFFER_SECONDS: int = 300 # refresh 5 min before expiry
|
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
class ConnectionFactory:
|
|
38
|
-
"""
|
|
39
|
+
"""
|
|
40
|
+
Creates and caches Azure DevOps SDK connections per organization URL.
|
|
39
41
|
|
|
40
42
|
Bridges ``azure-identity``'s :class:`DefaultAzureCredential` into the
|
|
41
43
|
azure-devops SDK's msrest-based auth layer, handling token refresh and
|
|
@@ -46,15 +48,18 @@ class ConnectionFactory:
|
|
|
46
48
|
credential:
|
|
47
49
|
Optional :class:`TokenCredential` for dependency injection / testing.
|
|
48
50
|
Defaults to :class:`DefaultAzureCredential` when *None*.
|
|
51
|
+
|
|
49
52
|
"""
|
|
50
53
|
|
|
51
54
|
def __init__(self, credential: TokenCredential | None = None) -> None:
|
|
55
|
+
"""Initialize with an optional credential; defaults to DefaultAzureCredential."""
|
|
52
56
|
self._credential: TokenCredential = credential or DefaultAzureCredential()
|
|
53
57
|
self._connections: dict[str, Connection] = {}
|
|
54
58
|
self._token_expiry: dict[str, float] = {}
|
|
55
59
|
|
|
56
60
|
def get_connection(self, org_url: str) -> Connection:
|
|
57
|
-
"""
|
|
61
|
+
"""
|
|
62
|
+
Get or create a cached connection for *org_url*.
|
|
58
63
|
|
|
59
64
|
Returns a cached :class:`Connection` if the token is still valid
|
|
60
65
|
(more than 5 minutes until expiry). Otherwise acquires a fresh
|
|
@@ -94,7 +99,8 @@ def _normalize_org_url(org_url: str) -> str:
|
|
|
94
99
|
|
|
95
100
|
|
|
96
101
|
def get_current_user(client: AdoClient) -> UserIdentity:
|
|
97
|
-
"""
|
|
102
|
+
"""
|
|
103
|
+
Return the identity of the authenticated user.
|
|
98
104
|
|
|
99
105
|
Uses :class:`LocationClient`'s ``get_connection_data()`` to resolve
|
|
100
106
|
the authenticated user from the active connection.
|
|
@@ -108,6 +114,7 @@ def get_current_user(client: AdoClient) -> UserIdentity:
|
|
|
108
114
|
|
|
109
115
|
Raises:
|
|
110
116
|
ActionableError: When credentials are invalid or the call fails.
|
|
117
|
+
|
|
111
118
|
"""
|
|
112
119
|
try:
|
|
113
120
|
conn_data = client.location.get_connection_data()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Typed accessors for Azure DevOps SDK clients.
|
|
2
3
|
|
|
3
4
|
Wraps :meth:`Connection.get_client` with lazy, cached properties that
|
|
4
5
|
provide type-safe access to the Git, Core, and Work Item Tracking clients.
|
|
@@ -40,7 +41,8 @@ _WIT_CLIENT_PATH = (
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
class AdoClient:
|
|
43
|
-
"""
|
|
44
|
+
"""
|
|
45
|
+
Typed, lazy accessor for Azure DevOps SDK clients.
|
|
44
46
|
|
|
45
47
|
Each client property is initialized on first access and cached for
|
|
46
48
|
the lifetime of this instance. If the underlying connection's token
|
|
@@ -51,9 +53,11 @@ class AdoClient:
|
|
|
51
53
|
connection:
|
|
52
54
|
An authenticated :class:`Connection` (typically from
|
|
53
55
|
:meth:`ConnectionFactory.get_connection`).
|
|
56
|
+
|
|
54
57
|
"""
|
|
55
58
|
|
|
56
59
|
def __init__(self, connection: Connection) -> None:
|
|
60
|
+
"""Initialize the client wrapper with an authenticated connection."""
|
|
57
61
|
self._connection: Connection = connection
|
|
58
62
|
|
|
59
63
|
@cached_property
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Comment analysis, response sanitization, and write operations for Azure DevOps PRs.
|
|
2
3
|
|
|
3
4
|
Provides :func:`sanitize_ado_response` (pure utility for Windows-1252
|
|
4
5
|
smart-quote fix), :func:`analyze_pr_comments` (SDK-based thread
|
|
@@ -40,7 +41,8 @@ from azure.devops.v7_1.git.models import (
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def sanitize_ado_response(raw_data: bytes | str) -> str:
|
|
43
|
-
"""
|
|
44
|
+
"""
|
|
45
|
+
Fix Windows-1252 smart-quote corruption in ADO responses.
|
|
44
46
|
|
|
45
47
|
Azure DevOps REST API sometimes returns Windows-1252 smart quotes
|
|
46
48
|
(0x91-0x94) which are invalid UTF-8. This function replaces those
|
|
@@ -53,6 +55,7 @@ def sanitize_ado_response(raw_data: bytes | str) -> str:
|
|
|
53
55
|
|
|
54
56
|
Returns:
|
|
55
57
|
A properly decoded UTF-8 string.
|
|
58
|
+
|
|
56
59
|
"""
|
|
57
60
|
if isinstance(raw_data, str):
|
|
58
61
|
return raw_data
|
|
@@ -78,7 +81,8 @@ def analyze_pr_comments(
|
|
|
78
81
|
project: str,
|
|
79
82
|
repository: str,
|
|
80
83
|
) -> CommentAnalysis:
|
|
81
|
-
"""
|
|
84
|
+
"""
|
|
85
|
+
Analyze all comment threads on a pull request.
|
|
82
86
|
|
|
83
87
|
Fetches threads via ``client.git.get_threads()``, categorizes by
|
|
84
88
|
status, extracts author statistics, and identifies active (unresolved)
|
|
@@ -94,6 +98,7 @@ def analyze_pr_comments(
|
|
|
94
98
|
Returns:
|
|
95
99
|
A :class:`~models.CommentAnalysis` with thread statistics,
|
|
96
100
|
author breakdowns, and active comments.
|
|
101
|
+
|
|
97
102
|
"""
|
|
98
103
|
threads = client.git.get_threads(repository, pr_id, project=project)
|
|
99
104
|
|
|
@@ -199,7 +204,8 @@ def post_comment(
|
|
|
199
204
|
line_number: int | None = None,
|
|
200
205
|
iteration_context: IterationContext | None = None,
|
|
201
206
|
) -> int:
|
|
202
|
-
"""
|
|
207
|
+
"""
|
|
208
|
+
Create a new comment thread on a pull request.
|
|
203
209
|
|
|
204
210
|
When *file_path* and *line_number* are provided, the comment is anchored
|
|
205
211
|
to that line in the PR diff. If *iteration_context* is also provided,
|
|
@@ -224,6 +230,7 @@ def post_comment(
|
|
|
224
230
|
Raises:
|
|
225
231
|
ActionableError: When *content* is empty/whitespace, when
|
|
226
232
|
*file_path*/*line_number* are inconsistent, or when the SDK fails.
|
|
233
|
+
|
|
227
234
|
"""
|
|
228
235
|
if not content or not content.strip():
|
|
229
236
|
raise ActionableError.validation(
|
|
@@ -300,7 +307,8 @@ def post_comments(
|
|
|
300
307
|
*,
|
|
301
308
|
dry_run: bool = False,
|
|
302
309
|
) -> PostingResult:
|
|
303
|
-
"""
|
|
310
|
+
"""
|
|
311
|
+
Batch-post comment threads with per-comment file/line positioning.
|
|
304
312
|
|
|
305
313
|
Each :class:`~models.CommentPayload` carries its own content, file_path,
|
|
306
314
|
and line_number. Iteration context is resolved once and shared across
|
|
@@ -322,6 +330,7 @@ def post_comments(
|
|
|
322
330
|
|
|
323
331
|
Returns:
|
|
324
332
|
:class:`~models.PostingResult` with successes, failures, and skipped.
|
|
333
|
+
|
|
325
334
|
"""
|
|
326
335
|
if not comments:
|
|
327
336
|
return PostingResult(posted=[], failures=[], skipped=[], dry_run=dry_run)
|
|
@@ -364,8 +373,7 @@ def post_comments(
|
|
|
364
373
|
operation="post_comment",
|
|
365
374
|
raw_error=str(exc),
|
|
366
375
|
suggestion=(
|
|
367
|
-
f"Comment {i} failed to post. "
|
|
368
|
-
"Check content, file path, and line number."
|
|
376
|
+
f"Comment {i} failed to post. Check content, file path, and line number."
|
|
369
377
|
),
|
|
370
378
|
)
|
|
371
379
|
err.context = {
|
|
@@ -390,7 +398,8 @@ def reply_to_comment(
|
|
|
390
398
|
content: str,
|
|
391
399
|
project: str,
|
|
392
400
|
) -> int:
|
|
393
|
-
"""
|
|
401
|
+
"""
|
|
402
|
+
Add a reply to an existing comment thread.
|
|
394
403
|
|
|
395
404
|
Args:
|
|
396
405
|
client: An authenticated :class:`~client.AdoClient`.
|
|
@@ -405,6 +414,7 @@ def reply_to_comment(
|
|
|
405
414
|
|
|
406
415
|
Raises:
|
|
407
416
|
ActionableError: When *content* is empty/whitespace or the SDK fails.
|
|
417
|
+
|
|
408
418
|
"""
|
|
409
419
|
if not content or not content.strip():
|
|
410
420
|
raise ActionableError.validation(
|
|
@@ -442,7 +452,8 @@ def resolve_comments(
|
|
|
442
452
|
*,
|
|
443
453
|
status: str = "fixed",
|
|
444
454
|
) -> ResolveResult:
|
|
445
|
-
"""
|
|
455
|
+
"""
|
|
456
|
+
Batch-resolve comment threads with partial-success reporting.
|
|
446
457
|
|
|
447
458
|
Iterates *thread_ids* and calls ``client.git.update_thread()`` for
|
|
448
459
|
each. Threads already in the target status are skipped. Individual
|
|
@@ -460,6 +471,7 @@ def resolve_comments(
|
|
|
460
471
|
Returns:
|
|
461
472
|
A :class:`~models.ResolveResult` partitioning threads into
|
|
462
473
|
*resolved*, *errors*, and *skipped*.
|
|
474
|
+
|
|
463
475
|
"""
|
|
464
476
|
resolved: list[int] = []
|
|
465
477
|
errors: list[ActionableError] = []
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
File content retrieval from Azure DevOps repositories.
|
|
2
3
|
|
|
3
4
|
Provides single-file retrieval (:func:`get_file_content`) and batch
|
|
4
5
|
PR-scoped retrieval (:func:`get_changed_file_contents`) with
|
|
@@ -28,7 +29,8 @@ def get_file_content(
|
|
|
28
29
|
version: str | None = None,
|
|
29
30
|
version_type: str = "branch",
|
|
30
31
|
) -> FileContent:
|
|
31
|
-
"""
|
|
32
|
+
"""
|
|
33
|
+
Fetch a single file's content from a repository ref.
|
|
32
34
|
|
|
33
35
|
Args:
|
|
34
36
|
client: An authenticated :class:`~client.AdoClient`.
|
|
@@ -43,6 +45,7 @@ def get_file_content(
|
|
|
43
45
|
|
|
44
46
|
Raises:
|
|
45
47
|
ActionableError: When the file does not exist or cannot be fetched.
|
|
48
|
+
|
|
46
49
|
"""
|
|
47
50
|
try:
|
|
48
51
|
version_descriptor = None
|
|
@@ -101,12 +104,17 @@ def get_changed_file_contents(
|
|
|
101
104
|
project: str,
|
|
102
105
|
*,
|
|
103
106
|
file_paths: list[str] | None = None,
|
|
107
|
+
exclude_extensions: list[str] | None = None,
|
|
104
108
|
) -> ContentResult:
|
|
105
|
-
"""
|
|
109
|
+
"""
|
|
110
|
+
Fetch file contents for files changed in a PR.
|
|
106
111
|
|
|
107
112
|
Uses the PR's source branch ref. If *file_paths* is ``None``, discovers
|
|
108
113
|
changed files from the latest iteration and fetches all of them.
|
|
109
114
|
|
|
115
|
+
For completed PRs whose source branch has been deleted, falls back to
|
|
116
|
+
the ``last_merge_source_commit`` SHA.
|
|
117
|
+
|
|
110
118
|
Uses partial-success semantics: files that fail to fetch are collected
|
|
111
119
|
in :attr:`ContentResult.failures` with the path and error, not raised.
|
|
112
120
|
|
|
@@ -117,11 +125,15 @@ def get_changed_file_contents(
|
|
|
117
125
|
project: Azure DevOps project name or GUID.
|
|
118
126
|
file_paths: Optional list of specific file paths to fetch.
|
|
119
127
|
If ``None``, fetches all changed files.
|
|
128
|
+
exclude_extensions: Optional list of file extensions to skip
|
|
129
|
+
(e.g. ``[".lock", ".json"]``). Matched case-insensitively.
|
|
130
|
+
A leading dot is added if missing.
|
|
120
131
|
|
|
121
132
|
Returns:
|
|
122
133
|
:class:`~models.ContentResult` with files and failures.
|
|
134
|
+
|
|
123
135
|
"""
|
|
124
|
-
# Get the
|
|
136
|
+
# Get the PR metadata for branch/commit resolution
|
|
125
137
|
try:
|
|
126
138
|
pr = client.git.get_pull_request_by_id(pr_id, project=project)
|
|
127
139
|
branch = pr.source_ref_name.replace("refs/heads/", "")
|
|
@@ -141,18 +153,72 @@ def get_changed_file_contents(
|
|
|
141
153
|
except Exception:
|
|
142
154
|
file_paths = []
|
|
143
155
|
|
|
156
|
+
# Apply extension filtering before fetching
|
|
157
|
+
if exclude_extensions:
|
|
158
|
+
normalized = [
|
|
159
|
+
ext.lower() if ext.startswith(".") else f".{ext.lower()}" for ext in exclude_extensions
|
|
160
|
+
]
|
|
161
|
+
file_paths = [
|
|
162
|
+
p for p in file_paths if not any(p.lower().endswith(ext) for ext in normalized)
|
|
163
|
+
]
|
|
164
|
+
|
|
144
165
|
if not file_paths:
|
|
145
166
|
return ContentResult(files=[], failures=[])
|
|
146
167
|
|
|
168
|
+
# Determine version reference: branch first, commit SHA fallback for completed PRs
|
|
169
|
+
version = branch
|
|
170
|
+
version_type = "branch"
|
|
171
|
+
|
|
147
172
|
# Fetch each file with partial-success
|
|
148
173
|
files: list[FileContent] = []
|
|
149
174
|
failures: list[ActionableError] = []
|
|
150
175
|
|
|
151
176
|
for path in file_paths:
|
|
152
177
|
try:
|
|
153
|
-
fc = get_file_content(
|
|
178
|
+
fc = get_file_content(
|
|
179
|
+
client, repository, path, project, version=version, version_type=version_type
|
|
180
|
+
)
|
|
154
181
|
files.append(fc)
|
|
155
182
|
except Exception as exc:
|
|
183
|
+
# For completed PRs, try fallback to merge commit SHA
|
|
184
|
+
merge_commit = pr.last_merge_source_commit
|
|
185
|
+
if (
|
|
186
|
+
pr.status == "completed"
|
|
187
|
+
and version_type == "branch"
|
|
188
|
+
and merge_commit is not None
|
|
189
|
+
and merge_commit.commit_id is not None
|
|
190
|
+
):
|
|
191
|
+
try:
|
|
192
|
+
fc = get_file_content(
|
|
193
|
+
client,
|
|
194
|
+
repository,
|
|
195
|
+
path,
|
|
196
|
+
project,
|
|
197
|
+
version=merge_commit.commit_id,
|
|
198
|
+
version_type="commit",
|
|
199
|
+
)
|
|
200
|
+
files.append(fc)
|
|
201
|
+
continue
|
|
202
|
+
except Exception:
|
|
203
|
+
pass # Fall through to original error handling
|
|
204
|
+
|
|
205
|
+
# For completed PRs with deleted branch and no merge commit, raise
|
|
206
|
+
if (
|
|
207
|
+
pr.status == "completed"
|
|
208
|
+
and ("TF401174" in str(exc) or "does not exist" in str(exc).lower())
|
|
209
|
+
and (merge_commit is None or merge_commit.commit_id is None)
|
|
210
|
+
):
|
|
211
|
+
raise ActionableError.not_found(
|
|
212
|
+
service="AzureDevOps",
|
|
213
|
+
resource_type="PR source",
|
|
214
|
+
resource_id=f"PR {pr_id}",
|
|
215
|
+
raw_error=str(exc),
|
|
216
|
+
suggestion=(
|
|
217
|
+
f"Source branch for PR {pr_id} has been deleted and no "
|
|
218
|
+
f"merge commit is available. The PR source code cannot be retrieved."
|
|
219
|
+
),
|
|
220
|
+
) from exc
|
|
221
|
+
|
|
156
222
|
err = ActionableError.internal(
|
|
157
223
|
service="ado-workflows",
|
|
158
224
|
operation="get_file_content",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Layer 2 — Repository context management with caching and thread-safety.
|
|
2
3
|
|
|
3
4
|
Provides session-level repository context so that multiple tool calls within
|
|
4
5
|
a single MCP session share the same discovered repository information without
|
|
@@ -29,7 +30,8 @@ _SERVICE = "Azure DevOps"
|
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
class RepositoryContext:
|
|
32
|
-
"""
|
|
33
|
+
"""
|
|
34
|
+
Thread-safe, session-level repository context manager.
|
|
33
35
|
|
|
34
36
|
All public methods are classmethods operating on class-level state
|
|
35
37
|
(effectively a singleton). Every method acquires ``_lock`` before
|
|
@@ -53,7 +55,8 @@ class RepositoryContext:
|
|
|
53
55
|
|
|
54
56
|
@classmethod
|
|
55
57
|
def set(cls, working_directory: str) -> dict[str, Any]:
|
|
56
|
-
"""
|
|
58
|
+
"""
|
|
59
|
+
Set the active repository context for subsequent tool operations.
|
|
57
60
|
|
|
58
61
|
Validates the path, runs discovery, and caches the result. On
|
|
59
62
|
failure the previous context is cleared so callers never operate
|
|
@@ -117,7 +120,8 @@ class RepositoryContext:
|
|
|
117
120
|
|
|
118
121
|
@classmethod
|
|
119
122
|
def get(cls, working_directory: str | None = None) -> dict[str, Any]:
|
|
120
|
-
"""
|
|
123
|
+
"""
|
|
124
|
+
Get repository info — cached, overridden, or via intelligent discovery.
|
|
121
125
|
|
|
122
126
|
* No args + cache → return cached (source ``"cached"``)
|
|
123
127
|
* No args + no cache → attempt intelligent discovery (source ``"intelligent_discovery"``)
|
|
@@ -199,7 +203,8 @@ class RepositoryContext:
|
|
|
199
203
|
|
|
200
204
|
@classmethod
|
|
201
205
|
def _discover(cls, working_directory: str | None) -> dict[str, Any]:
|
|
202
|
-
"""
|
|
206
|
+
"""
|
|
207
|
+
Run git discovery via Layer 1 primitives.
|
|
203
208
|
|
|
204
209
|
Uses :func:`discover_repositories` + :func:`infer_target_repository`
|
|
205
210
|
to find and select a repository. If *working_directory* is ``None``,
|
|
@@ -237,20 +242,20 @@ class RepositoryContext:
|
|
|
237
242
|
|
|
238
243
|
|
|
239
244
|
def set_repository_context(working_directory: str) -> dict[str, Any]:
|
|
240
|
-
"""
|
|
245
|
+
"""Delegate to :meth:`RepositoryContext.set`."""
|
|
241
246
|
return RepositoryContext.set(working_directory)
|
|
242
247
|
|
|
243
248
|
|
|
244
249
|
def get_repository_context(working_directory: str | None = None) -> dict[str, Any]:
|
|
245
|
-
"""
|
|
250
|
+
"""Delegate to :meth:`RepositoryContext.get`."""
|
|
246
251
|
return RepositoryContext.get(working_directory)
|
|
247
252
|
|
|
248
253
|
|
|
249
254
|
def get_context_status() -> dict[str, Any]:
|
|
250
|
-
"""
|
|
255
|
+
"""Delegate to :meth:`RepositoryContext.status`."""
|
|
251
256
|
return RepositoryContext.status()
|
|
252
257
|
|
|
253
258
|
|
|
254
259
|
def clear_repository_context() -> dict[str, Any]:
|
|
255
|
-
"""
|
|
260
|
+
"""Delegate to :meth:`RepositoryContext.clear`."""
|
|
256
261
|
return RepositoryContext.clear()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Layer 1 — Git repository discovery primitives for Azure DevOps.
|
|
2
3
|
|
|
3
4
|
Pure functions (except for subprocess calls to ``git``), no state, no SDK
|
|
4
5
|
dependency. Designed for single- and multi-repo workspaces.
|
|
@@ -14,7 +15,8 @@ from ado_workflows.parsing import parse_ado_url
|
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def inspect_git_repository(repo_path: str) -> dict[str, Any] | None:
|
|
17
|
-
"""
|
|
18
|
+
"""
|
|
19
|
+
Extract Azure DevOps metadata from a local git repository.
|
|
18
20
|
|
|
19
21
|
Runs ``git config --get remote.origin.url`` and parses the result.
|
|
20
22
|
|
|
@@ -25,6 +27,7 @@ def inspect_git_repository(repo_path: str) -> dict[str, Any] | None:
|
|
|
25
27
|
A dict with keys ``path``, ``name``, ``organization``, ``project``,
|
|
26
28
|
``remote_url``, ``org_url``, and ``workspace_context`` — or ``None``
|
|
27
29
|
if the directory is not a valid Azure DevOps git repo.
|
|
30
|
+
|
|
28
31
|
"""
|
|
29
32
|
try:
|
|
30
33
|
result = subprocess.run(
|
|
@@ -77,7 +80,8 @@ def inspect_git_repository(repo_path: str) -> dict[str, Any] | None:
|
|
|
77
80
|
|
|
78
81
|
|
|
79
82
|
def discover_repositories(search_root: str) -> list[dict[str, Any]]:
|
|
80
|
-
"""
|
|
83
|
+
"""
|
|
84
|
+
Find all Azure DevOps git repositories under *search_root*.
|
|
81
85
|
|
|
82
86
|
If *search_root* itself is a git repository, returns a single-element
|
|
83
87
|
list. Otherwise scans its immediate children for ``.git`` folders.
|
|
@@ -87,6 +91,7 @@ def discover_repositories(search_root: str) -> list[dict[str, Any]]:
|
|
|
87
91
|
|
|
88
92
|
Returns:
|
|
89
93
|
A (possibly empty) list of repository info dicts.
|
|
94
|
+
|
|
90
95
|
"""
|
|
91
96
|
repositories: list[dict[str, Any]] = []
|
|
92
97
|
|
|
@@ -115,7 +120,8 @@ def infer_target_repository(
|
|
|
115
120
|
repositories: list[dict[str, Any]],
|
|
116
121
|
working_directory: str | None = None,
|
|
117
122
|
) -> dict[str, Any] | None:
|
|
118
|
-
"""
|
|
123
|
+
"""
|
|
124
|
+
Select the most likely target repository from a list.
|
|
119
125
|
|
|
120
126
|
Selection strategy (first match wins):
|
|
121
127
|
|
|
@@ -132,6 +138,7 @@ def infer_target_repository(
|
|
|
132
138
|
|
|
133
139
|
Returns:
|
|
134
140
|
The best-guess repository, or ``None`` when the choice is ambiguous.
|
|
141
|
+
|
|
135
142
|
"""
|
|
136
143
|
if not repositories:
|
|
137
144
|
return None
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
PR iteration tracking and change context resolution.
|
|
2
3
|
|
|
3
4
|
Provides iteration metadata and per-file change tracking IDs needed for
|
|
4
5
|
anchoring comment threads to the correct PR iteration.
|
|
@@ -27,10 +28,12 @@ def get_pr_iterations(
|
|
|
27
28
|
pr_id: int,
|
|
28
29
|
project: str,
|
|
29
30
|
) -> list[IterationInfo]:
|
|
30
|
-
"""
|
|
31
|
+
"""
|
|
32
|
+
Return all iterations for a PR, ordered by creation date.
|
|
31
33
|
|
|
32
34
|
Raises:
|
|
33
35
|
ActionableError: When the SDK call fails (connection factory).
|
|
36
|
+
|
|
34
37
|
"""
|
|
35
38
|
try:
|
|
36
39
|
raw_iterations = client.git.get_pull_request_iterations(repository, pr_id, project=project)
|
|
@@ -62,7 +65,8 @@ def get_iteration_changes(
|
|
|
62
65
|
iteration_id: int,
|
|
63
66
|
project: str,
|
|
64
67
|
) -> list[FileChange]:
|
|
65
|
-
"""
|
|
68
|
+
"""
|
|
69
|
+
Return file changes for a specific iteration with changeTrackingId.
|
|
66
70
|
|
|
67
71
|
Uses ``compare_to=0`` (default) which returns all files changed in the
|
|
68
72
|
PR relative to the merge base, not just files changed in this specific
|
|
@@ -70,6 +74,7 @@ def get_iteration_changes(
|
|
|
70
74
|
|
|
71
75
|
Raises:
|
|
72
76
|
ActionableError: When the SDK call fails (connection factory).
|
|
77
|
+
|
|
73
78
|
"""
|
|
74
79
|
try:
|
|
75
80
|
raw_changes = client.git.get_pull_request_iteration_changes(
|
|
@@ -109,7 +114,8 @@ def get_latest_iteration_context(
|
|
|
109
114
|
pr_id: int,
|
|
110
115
|
project: str,
|
|
111
116
|
) -> IterationContext:
|
|
112
|
-
"""
|
|
117
|
+
"""
|
|
118
|
+
Return the latest iteration context: iteration ID + file path to FileChange map.
|
|
113
119
|
|
|
114
120
|
Calls :func:`get_pr_iterations` then :func:`get_iteration_changes`
|
|
115
121
|
for the latest iteration. The returned :class:`IterationContext`
|
|
@@ -119,6 +125,7 @@ def get_latest_iteration_context(
|
|
|
119
125
|
Raises:
|
|
120
126
|
ActionableError: When the PR has no iterations (validation) or
|
|
121
127
|
when SDK calls fail (connection).
|
|
128
|
+
|
|
122
129
|
"""
|
|
123
130
|
iterations = get_pr_iterations(client, repository, pr_id, project)
|
|
124
131
|
if not iterations:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
PR lifecycle operations — create, (future: update, complete).
|
|
2
3
|
|
|
3
4
|
Provides :func:`create_pull_request` which constructs a
|
|
4
5
|
:class:`~azure.devops.v7_1.git.models.GitPullRequest` and delegates to
|
|
@@ -41,7 +42,8 @@ def create_pull_request(
|
|
|
41
42
|
description: str | None = None,
|
|
42
43
|
is_draft: bool = False,
|
|
43
44
|
) -> CreatedPR:
|
|
44
|
-
"""
|
|
45
|
+
"""
|
|
46
|
+
Create a pull request via the Azure DevOps SDK.
|
|
45
47
|
|
|
46
48
|
Branch names are normalised to include ``refs/heads/`` if missing.
|
|
47
49
|
|
|
@@ -60,6 +62,7 @@ def create_pull_request(
|
|
|
60
62
|
|
|
61
63
|
Raises:
|
|
62
64
|
ActionableError: When the SDK call fails.
|
|
65
|
+
|
|
63
66
|
"""
|
|
64
67
|
pr_model = GitPullRequest(
|
|
65
68
|
source_ref_name=_normalize_branch(source_branch),
|