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.
Files changed (70) hide show
  1. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/CHANGELOG.md +18 -0
  2. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/PKG-INFO +1 -1
  3. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/pyproject.toml +9 -2
  4. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/__init__.py +2 -1
  5. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/auth.py +11 -4
  6. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/client.py +6 -2
  7. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/comments.py +21 -9
  8. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/content.py +71 -5
  9. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/context.py +14 -9
  10. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/discovery.py +11 -4
  11. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/iterations.py +11 -4
  12. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/lifecycle.py +5 -2
  13. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/models.py +28 -14
  14. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/parsing.py +8 -3
  15. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/pr.py +15 -7
  16. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/review.py +16 -6
  17. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/votes.py +7 -3
  18. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/__init__.py +1 -0
  19. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_auth.py +2 -1
  20. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_client.py +2 -1
  21. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_comment_positioning.py +3 -4
  22. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_comments.py +2 -1
  23. ado_workflows-0.3.0/tests/test_content.py +802 -0
  24. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_context.py +2 -1
  25. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_discovery.py +2 -1
  26. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_identity.py +2 -1
  27. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_iterations.py +2 -1
  28. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_lifecycle.py +2 -1
  29. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_models.py +2 -1
  30. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_pending_review.py +4 -2
  31. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_pr.py +2 -1
  32. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_review.py +8 -4
  33. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_votes.py +4 -2
  34. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/git/models.pyi +1 -8
  35. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/uv.lock +1 -1
  36. ado_workflows-0.2.2/tests/test_content.py +0 -373
  37. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.copilot/README.md +0 -0
  38. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.envrc +0 -0
  39. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  40. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  41. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/pull_request_template.md +0 -0
  42. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/secret_scanning.yml +0 -0
  43. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/workflows/ci.yml +0 -0
  44. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.github/workflows/release.yml +0 -0
  45. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.gitignore +0 -0
  46. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/.pre-commit-config.yaml +0 -0
  47. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  48. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/CONTRIBUTING.md +0 -0
  49. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/LICENSE +0 -0
  50. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/README.md +0 -0
  51. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/SECURITY.md +0 -0
  52. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/docs/ARCHITECTURE.md +0 -0
  53. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/docs/PUBLISHING.md +0 -0
  54. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/src/ado_workflows/py.typed +0 -0
  55. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/tests/test_parsing.py +0 -0
  56. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/__init__.pyi +0 -0
  57. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/__init__.pyi +0 -0
  58. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/connection.pyi +0 -0
  59. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/__init__.pyi +0 -0
  60. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/core/__init__.pyi +0 -0
  61. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/core/core_client.pyi +0 -0
  62. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/git/__init__.pyi +0 -0
  63. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/git/git_client.pyi +0 -0
  64. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/location/__init__.pyi +0 -0
  65. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/location/location_client.pyi +0 -0
  66. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/policy/__init__.pyi +0 -0
  67. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/policy/models.pyi +0 -0
  68. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/policy/policy_client.pyi +0 -0
  69. {ado_workflows-0.2.2 → ado_workflows-0.3.0}/typings/azure/devops/v7_1/work_item_tracking/__init__.pyi +0 -0
  70. {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.2.2
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.2.2"
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", # line length (handled by formatter)
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
- """ado-workflows: Azure DevOps workflow automation library.
1
+ """
2
+ ado-workflows: Azure DevOps workflow automation library.
2
3
 
3
4
  Three-layer API for Azure DevOps operations:
4
5
  - Layer 1 — Primitives: pure functions (URL parsing, git inspection, date parsing)
@@ -1,4 +1,5 @@
1
- """Azure DevOps authentication — DefaultAzureCredential → Connection bridge.
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
- """Creates and caches Azure DevOps SDK connections per organization URL.
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
- """Get or create a cached connection for *org_url*.
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
- """Return the identity of the authenticated user.
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
- """Typed accessors for Azure DevOps SDK clients.
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
- """Typed, lazy accessor for Azure DevOps SDK clients.
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
- """Comment analysis, response sanitization, and write operations for Azure DevOps PRs.
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
- """Fix Windows-1252 smart-quote corruption in ADO responses.
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
- """Analyze all comment threads on a pull request.
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
- """Create a new comment thread on a pull request.
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
- """Batch-post comment threads with per-comment file/line positioning.
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
- """Add a reply to an existing comment thread.
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
- """Batch-resolve comment threads with partial-success reporting.
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
- """File content retrieval from Azure DevOps repositories.
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
- """Fetch a single file's content from a repository ref.
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
- """Fetch file contents for files changed in a PR.
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 source branch for fetching content
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(client, repository, path, project, version=branch)
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
- """Layer 2 — Repository context management with caching and thread-safety.
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
- """Thread-safe, session-level repository context manager.
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
- """Set the active repository context for subsequent tool operations.
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
- """Get repository info — cached, overridden, or via intelligent discovery.
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
- """Run git discovery via Layer 1 primitives.
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
- """Convenience wrapper for :meth:`RepositoryContext.set`."""
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
- """Convenience wrapper for :meth:`RepositoryContext.get`."""
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
- """Convenience wrapper for :meth:`RepositoryContext.status`."""
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
- """Convenience wrapper for :meth:`RepositoryContext.clear`."""
260
+ """Delegate to :meth:`RepositoryContext.clear`."""
256
261
  return RepositoryContext.clear()
@@ -1,4 +1,5 @@
1
- """Layer 1 — Git repository discovery primitives for Azure DevOps.
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
- """Extract Azure DevOps metadata from a local git repository.
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
- """Find all Azure DevOps git repositories under *search_root*.
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
- """Select the most likely target repository from a list.
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
- """PR iteration tracking and change context resolution.
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
- """Return all iterations for a PR, ordered by creation date.
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
- """Return file changes for a specific iteration with changeTrackingId.
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
- """Convenience: latest iteration ID + file path to FileChange map.
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
- """PR lifecycle operations — create, (future: update, complete).
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
- """Create a pull request via the Azure DevOps SDK.
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),