agentic-devtools 0.2.0__py3-none-any.whl

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 (92) hide show
  1. agdt_ai_helpers/__init__.py +34 -0
  2. agentic_devtools/__init__.py +8 -0
  3. agentic_devtools/background_tasks.py +598 -0
  4. agentic_devtools/cli/__init__.py +1 -0
  5. agentic_devtools/cli/azure_devops/__init__.py +222 -0
  6. agentic_devtools/cli/azure_devops/async_commands.py +1218 -0
  7. agentic_devtools/cli/azure_devops/auth.py +34 -0
  8. agentic_devtools/cli/azure_devops/commands.py +728 -0
  9. agentic_devtools/cli/azure_devops/config.py +49 -0
  10. agentic_devtools/cli/azure_devops/file_review_commands.py +1038 -0
  11. agentic_devtools/cli/azure_devops/helpers.py +561 -0
  12. agentic_devtools/cli/azure_devops/mark_reviewed.py +756 -0
  13. agentic_devtools/cli/azure_devops/pipeline_commands.py +724 -0
  14. agentic_devtools/cli/azure_devops/pr_summary_commands.py +579 -0
  15. agentic_devtools/cli/azure_devops/pull_request_details_commands.py +596 -0
  16. agentic_devtools/cli/azure_devops/review_commands.py +700 -0
  17. agentic_devtools/cli/azure_devops/review_helpers.py +191 -0
  18. agentic_devtools/cli/azure_devops/review_jira.py +308 -0
  19. agentic_devtools/cli/azure_devops/review_prompts.py +263 -0
  20. agentic_devtools/cli/azure_devops/run_details_commands.py +935 -0
  21. agentic_devtools/cli/azure_devops/vpn_toggle.py +1220 -0
  22. agentic_devtools/cli/git/__init__.py +91 -0
  23. agentic_devtools/cli/git/async_commands.py +294 -0
  24. agentic_devtools/cli/git/commands.py +399 -0
  25. agentic_devtools/cli/git/core.py +152 -0
  26. agentic_devtools/cli/git/diff.py +210 -0
  27. agentic_devtools/cli/git/operations.py +737 -0
  28. agentic_devtools/cli/jira/__init__.py +114 -0
  29. agentic_devtools/cli/jira/adf.py +105 -0
  30. agentic_devtools/cli/jira/async_commands.py +439 -0
  31. agentic_devtools/cli/jira/async_status.py +27 -0
  32. agentic_devtools/cli/jira/commands.py +28 -0
  33. agentic_devtools/cli/jira/comment_commands.py +141 -0
  34. agentic_devtools/cli/jira/config.py +69 -0
  35. agentic_devtools/cli/jira/create_commands.py +293 -0
  36. agentic_devtools/cli/jira/formatting.py +131 -0
  37. agentic_devtools/cli/jira/get_commands.py +287 -0
  38. agentic_devtools/cli/jira/helpers.py +278 -0
  39. agentic_devtools/cli/jira/parse_error_report.py +352 -0
  40. agentic_devtools/cli/jira/role_commands.py +560 -0
  41. agentic_devtools/cli/jira/state_helpers.py +39 -0
  42. agentic_devtools/cli/jira/update_commands.py +222 -0
  43. agentic_devtools/cli/jira/vpn_wrapper.py +58 -0
  44. agentic_devtools/cli/release/__init__.py +5 -0
  45. agentic_devtools/cli/release/commands.py +113 -0
  46. agentic_devtools/cli/release/helpers.py +113 -0
  47. agentic_devtools/cli/runner.py +318 -0
  48. agentic_devtools/cli/state.py +174 -0
  49. agentic_devtools/cli/subprocess_utils.py +109 -0
  50. agentic_devtools/cli/tasks/__init__.py +28 -0
  51. agentic_devtools/cli/tasks/commands.py +851 -0
  52. agentic_devtools/cli/testing.py +442 -0
  53. agentic_devtools/cli/workflows/__init__.py +80 -0
  54. agentic_devtools/cli/workflows/advancement.py +204 -0
  55. agentic_devtools/cli/workflows/base.py +240 -0
  56. agentic_devtools/cli/workflows/checklist.py +278 -0
  57. agentic_devtools/cli/workflows/commands.py +1610 -0
  58. agentic_devtools/cli/workflows/manager.py +802 -0
  59. agentic_devtools/cli/workflows/preflight.py +323 -0
  60. agentic_devtools/cli/workflows/worktree_setup.py +1110 -0
  61. agentic_devtools/dispatcher.py +704 -0
  62. agentic_devtools/file_locking.py +203 -0
  63. agentic_devtools/prompts/__init__.py +38 -0
  64. agentic_devtools/prompts/apply-pull-request-review-suggestions/default-initiate-prompt.md +82 -0
  65. agentic_devtools/prompts/create-jira-epic/default-initiate-prompt.md +63 -0
  66. agentic_devtools/prompts/create-jira-issue/default-initiate-prompt.md +306 -0
  67. agentic_devtools/prompts/create-jira-subtask/default-initiate-prompt.md +57 -0
  68. agentic_devtools/prompts/loader.py +377 -0
  69. agentic_devtools/prompts/pull-request-review/default-completion-prompt.md +45 -0
  70. agentic_devtools/prompts/pull-request-review/default-decision-prompt.md +63 -0
  71. agentic_devtools/prompts/pull-request-review/default-file-review-prompt.md +69 -0
  72. agentic_devtools/prompts/pull-request-review/default-initiate-prompt.md +50 -0
  73. agentic_devtools/prompts/pull-request-review/default-summary-prompt.md +40 -0
  74. agentic_devtools/prompts/update-jira-issue/default-initiate-prompt.md +78 -0
  75. agentic_devtools/prompts/work-on-jira-issue/default-checklist-creation-prompt.md +58 -0
  76. agentic_devtools/prompts/work-on-jira-issue/default-commit-prompt.md +47 -0
  77. agentic_devtools/prompts/work-on-jira-issue/default-completion-prompt.md +65 -0
  78. agentic_devtools/prompts/work-on-jira-issue/default-implementation-prompt.md +66 -0
  79. agentic_devtools/prompts/work-on-jira-issue/default-implementation-review-prompt.md +60 -0
  80. agentic_devtools/prompts/work-on-jira-issue/default-initiate-prompt.md +67 -0
  81. agentic_devtools/prompts/work-on-jira-issue/default-planning-prompt.md +50 -0
  82. agentic_devtools/prompts/work-on-jira-issue/default-pull-request-prompt.md +56 -0
  83. agentic_devtools/prompts/work-on-jira-issue/default-retrieve-prompt.md +29 -0
  84. agentic_devtools/prompts/work-on-jira-issue/default-setup-prompt.md +19 -0
  85. agentic_devtools/prompts/work-on-jira-issue/default-verification-prompt.md +73 -0
  86. agentic_devtools/state.py +754 -0
  87. agentic_devtools/task_state.py +902 -0
  88. agentic_devtools-0.2.0.dist-info/METADATA +544 -0
  89. agentic_devtools-0.2.0.dist-info/RECORD +92 -0
  90. agentic_devtools-0.2.0.dist-info/WHEEL +4 -0
  91. agentic_devtools-0.2.0.dist-info/entry_points.txt +79 -0
  92. agentic_devtools-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,579 @@
1
+ """
2
+ Pull Request Summary Commands.
3
+
4
+ Generates overarching PR review comments after all files have been reviewed.
5
+ This mirrors the generate-overarching-pr-comments.ps1 functionality.
6
+ """
7
+
8
+ import json
9
+ import sys
10
+ import urllib.parse
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ from ...state import get_pull_request_id, is_dry_run, set_value
17
+ from .auth import get_auth_headers, get_pat
18
+ from .config import APPROVAL_SENTINEL, AzureDevOpsConfig
19
+ from .helpers import get_repository_id, require_requests
20
+
21
+
22
+ @dataclass
23
+ class FileSummary:
24
+ """Summary of a file's review status."""
25
+
26
+ normalized_path: str
27
+ path: str
28
+ root_folder: str
29
+ threads: List[Dict]
30
+ status: str # 'Approved' or 'NeedsWork'
31
+
32
+
33
+ @dataclass
34
+ class FolderSummary:
35
+ """Summary of a folder's review status."""
36
+
37
+ name: str
38
+ status: str
39
+ thread_id: Optional[int] = None
40
+ comment_id: Optional[int] = None
41
+ comment_url: Optional[str] = None
42
+
43
+
44
+ def _normalize_repo_path(path: Optional[str]) -> Optional[str]:
45
+ """Normalize a file path to repository format (/path/to/file)."""
46
+ if not path or not path.strip():
47
+ return None
48
+ clean = path.strip().replace("\\", "/").strip("/")
49
+ if not clean:
50
+ return None
51
+ return f"/{clean}"
52
+
53
+
54
+ def _get_root_folder(file_path: str) -> str:
55
+ """Get the root folder from a file path."""
56
+ if not file_path:
57
+ return "root"
58
+ normalized = file_path.replace("\\", "/")
59
+ if "/" not in normalized:
60
+ return "root"
61
+ return normalized.split("/")[0]
62
+
63
+
64
+ def _get_thread_file_path(thread: dict) -> Optional[str]:
65
+ """Extract the file path from a thread's context."""
66
+ context = thread.get("threadContext")
67
+ if not context:
68
+ return None
69
+
70
+ raw_path = (
71
+ context.get("filePath")
72
+ or (context.get("leftFileStart") or {}).get("filePath")
73
+ or (context.get("rightFileStart") or {}).get("filePath")
74
+ )
75
+
76
+ if not raw_path:
77
+ return None
78
+ return raw_path.replace("\\", "/").lstrip("/")
79
+
80
+
81
+ def _filter_threads(threads: List[Dict]) -> List[Dict]:
82
+ """Filter out deleted threads and comments."""
83
+ if not threads:
84
+ return []
85
+
86
+ filtered = []
87
+ for thread in threads:
88
+ if not thread:
89
+ continue
90
+ if thread.get("isDeleted"):
91
+ continue
92
+
93
+ comments = thread.get("comments", [])
94
+ filtered_comments = [c for c in comments if c and not c.get("isDeleted")]
95
+
96
+ if not filtered_comments:
97
+ continue
98
+
99
+ thread_copy = dict(thread)
100
+ thread_copy["comments"] = filtered_comments
101
+ filtered.append(thread_copy)
102
+
103
+ return filtered
104
+
105
+
106
+ def _get_file_thread_status(threads: List[Dict]) -> str:
107
+ """Determine if file needs work based on thread status."""
108
+ for thread in threads:
109
+ status = thread.get("status")
110
+ if status in ("active", "pending"):
111
+ return "NeedsWork"
112
+ return "Approved"
113
+
114
+
115
+ def _get_azure_devops_sort_key(path: str) -> str:
116
+ """Get sort key for Azure DevOps file ordering."""
117
+ if not path:
118
+ return "1|"
119
+
120
+ normalized = path.lstrip("/")
121
+ if not normalized:
122
+ return "1|"
123
+
124
+ segments = normalized.split("/")
125
+ if not segments:
126
+ return "1|"
127
+
128
+ result = ""
129
+ for i, segment in enumerate(segments):
130
+ is_last = i == len(segments) - 1
131
+ type_indicator = "1" if is_last else "0"
132
+ result += f"{type_indicator}|{segment.lower()}|"
133
+
134
+ return result
135
+
136
+
137
+ def _sort_entries_by_path(entries: List[FileSummary]) -> List[FileSummary]:
138
+ """Sort file entries by Azure DevOps path ordering."""
139
+ return sorted(entries, key=lambda e: _get_azure_devops_sort_key(e.path))
140
+
141
+
142
+ def _sort_folders(folders: List[FolderSummary]) -> List[FolderSummary]:
143
+ """Sort folder summaries (root last, then alphabetically)."""
144
+ return sorted(
145
+ folders,
146
+ key=lambda f: (1 if f.name.lower() == "root" else 0, f.name.lower()),
147
+ )
148
+
149
+
150
+ def _build_comment_link(
151
+ config: AzureDevOpsConfig,
152
+ pull_request_id: int,
153
+ thread_id: Optional[int] = None,
154
+ comment_id: Optional[int] = None,
155
+ ) -> str:
156
+ """Build a link to a PR comment."""
157
+ base = config.organization.rstrip("/")
158
+ encoded_project = urllib.parse.quote(config.project, safe="")
159
+ encoded_repo = urllib.parse.quote(config.repository, safe="")
160
+ pr_root = f"{base}/{encoded_project}/_git/{encoded_repo}/pullRequest/{pull_request_id}"
161
+
162
+ if thread_id and comment_id:
163
+ url = f"{pr_root}?discussionId={thread_id}&commentId={comment_id}"
164
+ elif thread_id:
165
+ url = f"{pr_root}?discussionId={thread_id}"
166
+ elif comment_id:
167
+ url = f"{pr_root}#{comment_id}"
168
+ else:
169
+ url = pr_root
170
+
171
+ # Escape ampersands for markdown
172
+ return url.replace("&", "&")
173
+
174
+
175
+ def _get_latest_comment_context(threads: List[Dict]) -> Optional[Tuple[Dict, Optional[Dict]]]:
176
+ """Get the most recently updated thread and comment."""
177
+ if not threads:
178
+ return None
179
+
180
+ candidates = []
181
+
182
+ for thread in threads:
183
+ if not thread:
184
+ continue
185
+
186
+ comments = thread.get("comments", [])
187
+ if comments:
188
+ for comment in comments:
189
+ if not comment:
190
+ continue
191
+ if comment.get("commentType") == "codePosition":
192
+ continue
193
+
194
+ timestamp_str = (
195
+ comment.get("lastUpdatedDate")
196
+ or comment.get("lastContentUpdatedDate")
197
+ or comment.get("publishedDate")
198
+ )
199
+
200
+ timestamp = datetime.min.replace(tzinfo=timezone.utc)
201
+ if timestamp_str:
202
+ try:
203
+ timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
204
+ except ValueError:
205
+ pass
206
+
207
+ candidates.append((timestamp, thread, comment))
208
+ else:
209
+ timestamp_str = thread.get("lastUpdatedDate") or thread.get("publishedDate")
210
+
211
+ timestamp = datetime.min.replace(tzinfo=timezone.utc)
212
+ if timestamp_str:
213
+ try:
214
+ timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
215
+ except ValueError:
216
+ pass
217
+
218
+ candidates.append((timestamp, thread, None))
219
+
220
+ if not candidates:
221
+ return None
222
+
223
+ # Sort by timestamp descending and get the latest
224
+ candidates.sort(key=lambda x: x[0], reverse=True)
225
+ _, thread, comment = candidates[0]
226
+ return (thread, comment)
227
+
228
+
229
+ def _build_file_link(
230
+ file_path: str,
231
+ threads: List[Dict],
232
+ config: AzureDevOpsConfig,
233
+ pull_request_id: int,
234
+ ) -> str:
235
+ """Build a markdown link to a file's comment thread."""
236
+ display_path = file_path
237
+ if not display_path:
238
+ display_path = "root"
239
+ elif not display_path.startswith("/"):
240
+ display_path = f"/{display_path.lstrip('/')}"
241
+
242
+ context = _get_latest_comment_context(threads)
243
+ if not context:
244
+ # Try first thread with first comment
245
+ if threads:
246
+ thread = threads[0]
247
+ comments = thread.get("comments", [])
248
+ comment = None
249
+ if comments:
250
+ for c in comments:
251
+ if c.get("commentType") != "codePosition":
252
+ comment = c
253
+ break
254
+ if not comment:
255
+ comment = comments[0] if comments else None
256
+ context = (thread, comment)
257
+
258
+ if not context:
259
+ return display_path
260
+
261
+ thread, comment = context
262
+ thread_id = thread.get("id")
263
+ comment_id = comment.get("id") if comment else None
264
+
265
+ if not thread_id:
266
+ return display_path
267
+
268
+ link = _build_comment_link(config, pull_request_id, thread_id, comment_id)
269
+ return f"[{display_path}]({link})"
270
+
271
+
272
+ def _build_folder_comment(
273
+ folder_name: str,
274
+ file_summaries: List[FileSummary],
275
+ config: AzureDevOpsConfig,
276
+ pull_request_id: int,
277
+ ) -> Tuple[str, str]:
278
+ """Build a folder summary comment body."""
279
+ needs_work = [f for f in file_summaries if f.status == "NeedsWork"]
280
+ approved = [f for f in file_summaries if f.status == "Approved"]
281
+
282
+ needs_work_sorted = _sort_entries_by_path(needs_work)
283
+ approved_sorted = _sort_entries_by_path(approved)
284
+
285
+ status = "Needs Work" if needs_work else "Approved"
286
+
287
+ lines = [
288
+ f"## Folder Review Summary: {folder_name}",
289
+ "",
290
+ f"*Status:* {status}",
291
+ ]
292
+
293
+ if needs_work_sorted:
294
+ lines.extend(["", "### Needs Work"])
295
+ for entry in needs_work_sorted:
296
+ file_link = _build_file_link(entry.path, entry.threads, config, pull_request_id)
297
+ lines.append(f"- {file_link}")
298
+
299
+ if approved_sorted:
300
+ lines.extend(["", "### Approved"])
301
+ for entry in approved_sorted:
302
+ file_link = _build_file_link(entry.path, entry.threads, config, pull_request_id)
303
+ lines.append(f"- {file_link}")
304
+
305
+ return "\n".join(lines), status
306
+
307
+
308
+ def _post_comment(
309
+ requests,
310
+ headers: Dict,
311
+ config: AzureDevOpsConfig,
312
+ repo_id: str,
313
+ pull_request_id: int,
314
+ content: str,
315
+ leave_active: bool = False,
316
+ ) -> Optional[Dict[str, Any]]:
317
+ """Post a comment to the PR and optionally resolve it."""
318
+ thread_url = config.build_api_url(repo_id, "pullRequests", pull_request_id, "threads")
319
+
320
+ # Wrap with approval sentinel banner
321
+ formatted_content = f"{APPROVAL_SENTINEL}\n\n{content.strip()}\n\n{APPROVAL_SENTINEL}\n\n"
322
+
323
+ thread_body = {
324
+ "comments": [{"content": formatted_content, "commentType": "text"}],
325
+ "status": "active",
326
+ }
327
+
328
+ try:
329
+ response = requests.post(thread_url, headers=headers, json=thread_body, timeout=30)
330
+ response.raise_for_status()
331
+ result = response.json()
332
+ except Exception as e:
333
+ print(f"Warning: Failed to post comment: {e}", file=sys.stderr)
334
+ return None
335
+
336
+ thread_id = result.get("id")
337
+ comments = result.get("comments", [])
338
+ comment_id = comments[0].get("id") if comments else None
339
+
340
+ # Resolve thread unless leave_active
341
+ if not leave_active and thread_id:
342
+ try:
343
+ resolve_url = config.build_api_url(repo_id, "pullRequests", pull_request_id, "threads", thread_id)
344
+ requests.patch(resolve_url, headers=headers, json={"status": "closed"}, timeout=30)
345
+ except Exception as e:
346
+ print(f"Warning: Failed to resolve thread {thread_id}: {e}", file=sys.stderr)
347
+
348
+ return {"thread_id": thread_id, "comment_id": comment_id}
349
+
350
+
351
+ def generate_overarching_pr_comments() -> bool:
352
+ """
353
+ Generate overarching review comments for each folder and overall PR summary.
354
+
355
+ This function:
356
+ 1. Fetches PR details (files and threads)
357
+ 2. Groups files by root folder
358
+ 3. Posts a summary comment for each folder
359
+ 4. Posts an overall PR summary with links to folder summaries
360
+
361
+ State keys read:
362
+ - pull_request_id (required): Pull request ID
363
+ - dry_run: If true, only print what would be done
364
+
365
+ Returns:
366
+ True if summary comments were generated successfully (or nothing to do),
367
+ False if an error occurred.
368
+ """
369
+ requests = require_requests()
370
+ config = AzureDevOpsConfig.from_state()
371
+ dry_run = is_dry_run()
372
+ pull_request_id = get_pull_request_id(required=True)
373
+
374
+ print(f"Generating overarching review comments for PR {pull_request_id}")
375
+ print("=" * 59)
376
+ print("")
377
+
378
+ # Step 1: Fetch PR details
379
+ from .pull_request_details_commands import get_pull_request_details
380
+
381
+ set_value("pull_request_id", pull_request_id)
382
+ get_pull_request_details()
383
+
384
+ # Load details from temp file
385
+ scripts_dir = Path(__file__).parent.parent.parent.parent.parent
386
+ temp_dir = scripts_dir / "temp"
387
+ details_path = temp_dir / "temp-get-pull-request-details-response.json"
388
+
389
+ if not details_path.exists():
390
+ print("ERROR: PR details file not found after fetch.", file=sys.stderr)
391
+ sys.exit(1)
392
+
393
+ with open(details_path, encoding="utf-8") as f:
394
+ pr_details = json.load(f)
395
+
396
+ if pr_details.get("error"):
397
+ print(f"Failed to retrieve PR details: {pr_details['error']}", file=sys.stderr)
398
+ sys.exit(1)
399
+
400
+ # Extract files and threads
401
+ files_payload = pr_details.get("files", [])
402
+ if not files_payload:
403
+ print("No file metadata found in PR details; nothing to summarize.")
404
+ return True # Nothing to do is a successful state
405
+
406
+ threads_payload = pr_details.get("threads", [])
407
+ if not threads_payload:
408
+ print("No discussion threads detected. Skipping overarching comments.")
409
+ return True # Nothing to do is a successful state
410
+
411
+ threads_payload = _filter_threads(threads_payload)
412
+ if not threads_payload:
413
+ print("No discussion threads detected after filtering. Skipping overarching comments.")
414
+ return True # Nothing to do is a successful state
415
+
416
+ # Step 2: Build map of files to threads
417
+ files_by_path: Dict[str, List[Dict]] = {}
418
+ for thread in threads_payload:
419
+ file_path = _get_thread_file_path(thread)
420
+ if not file_path:
421
+ continue
422
+
423
+ normalized = _normalize_repo_path(file_path)
424
+ if not normalized:
425
+ continue
426
+
427
+ if normalized not in files_by_path:
428
+ files_by_path[normalized] = []
429
+ files_by_path[normalized].append(thread)
430
+
431
+ if not files_by_path:
432
+ print("No file-scoped threads detected. Overarching comments are not required.")
433
+ return True # Nothing to do is a successful state
434
+
435
+ # Step 3: Build file summaries
436
+ file_summaries: List[FileSummary] = []
437
+
438
+ # Process in file order from the PR
439
+ ordered_paths: List[str] = []
440
+ for file_detail in files_payload:
441
+ if not file_detail:
442
+ continue
443
+ for candidate_key in ("path", "originalPath"):
444
+ candidate = file_detail.get(candidate_key)
445
+ if candidate:
446
+ normalized = _normalize_repo_path(candidate)
447
+ if normalized and normalized in files_by_path and normalized not in ordered_paths:
448
+ ordered_paths.append(normalized)
449
+
450
+ # Add any remaining paths not in file list
451
+ for path in files_by_path:
452
+ if path not in ordered_paths:
453
+ ordered_paths.append(path)
454
+
455
+ for normalized_path in ordered_paths:
456
+ threads_for_file = files_by_path.get(normalized_path, [])
457
+ if not threads_for_file:
458
+ continue
459
+
460
+ # Find matching file detail
461
+ matching_file = None
462
+ for f in files_payload:
463
+ if f:
464
+ f_normalized = _normalize_repo_path(f.get("path"))
465
+ orig_normalized = _normalize_repo_path(f.get("originalPath"))
466
+ if f_normalized == normalized_path or orig_normalized == normalized_path:
467
+ matching_file = f
468
+ break
469
+
470
+ if not matching_file:
471
+ continue
472
+
473
+ original_path = matching_file.get("path") or matching_file.get("originalPath") or normalized_path.lstrip("/")
474
+ root_folder = _get_root_folder(original_path)
475
+ status = _get_file_thread_status(threads_for_file)
476
+
477
+ file_summaries.append(
478
+ FileSummary(
479
+ normalized_path=normalized_path,
480
+ path=original_path,
481
+ root_folder=root_folder,
482
+ threads=threads_for_file,
483
+ status=status,
484
+ )
485
+ )
486
+
487
+ if not file_summaries:
488
+ print("No file summaries produced; nothing to post.")
489
+ return True # Nothing to do is a successful state
490
+
491
+ # Step 4: Group by folder and post comments
492
+ if not dry_run:
493
+ pat = get_pat()
494
+ headers = get_auth_headers(pat)
495
+ repo_id = get_repository_id(config.organization, config.project, config.repository)
496
+
497
+ # Group by root folder
498
+ folders_map: Dict[str, List[FileSummary]] = {}
499
+ for summary in file_summaries:
500
+ folder = summary.root_folder
501
+ if folder not in folders_map:
502
+ folders_map[folder] = []
503
+ folders_map[folder].append(summary)
504
+
505
+ folder_results: List[FolderSummary] = []
506
+
507
+ for folder_name, folder_files in folders_map.items():
508
+ comment_body, folder_status = _build_folder_comment(folder_name, folder_files, config, pull_request_id)
509
+
510
+ print(f"Preparing summary for folder '{folder_name}' (Status: {folder_status}).")
511
+
512
+ if dry_run:
513
+ print(comment_body)
514
+ folder_results.append(FolderSummary(name=folder_name, status=folder_status))
515
+ continue
516
+
517
+ leave_active = folder_status == "Needs Work"
518
+ result = _post_comment(requests, headers, config, repo_id, pull_request_id, comment_body, leave_active)
519
+
520
+ link = None
521
+ if result and result.get("thread_id"):
522
+ link = _build_comment_link(config, pull_request_id, result["thread_id"], result.get("comment_id"))
523
+
524
+ folder_results.append(
525
+ FolderSummary(
526
+ name=folder_name,
527
+ status=folder_status,
528
+ thread_id=result.get("thread_id") if result else None,
529
+ comment_id=result.get("comment_id") if result else None,
530
+ comment_url=link,
531
+ )
532
+ )
533
+
534
+ if dry_run:
535
+ print("Dry run complete – no comments were posted.")
536
+ return True # Dry run completed successfully
537
+
538
+ # Step 5: Post overall PR summary
539
+ needs_work_folders = [f for f in folder_results if f.status == "Needs Work"]
540
+ approved_folders = [f for f in folder_results if f.status == "Approved"]
541
+ final_status = "Needs Work" if needs_work_folders else "Approved"
542
+
543
+ sorted_needs_work = _sort_folders(needs_work_folders)
544
+ sorted_approved = _sort_folders(approved_folders)
545
+
546
+ lines = [
547
+ "## Overall PR Review Summary",
548
+ "",
549
+ f"*Status:* {final_status}",
550
+ ]
551
+
552
+ if sorted_needs_work:
553
+ lines.extend(["", "### Folders Needing Work"])
554
+ for folder in sorted_needs_work:
555
+ label = f"[{folder.name}]({folder.comment_url})" if folder.comment_url else folder.name
556
+ lines.append(f"- {label}")
557
+
558
+ if sorted_approved:
559
+ lines.extend(["", "### Approved Folders"])
560
+ for folder in sorted_approved:
561
+ label = f"[{folder.name}]({folder.comment_url})" if folder.comment_url else folder.name
562
+ lines.append(f"- {label}")
563
+
564
+ final_comment = "\n".join(lines)
565
+
566
+ leave_active = final_status == "Needs Work"
567
+ _post_comment(requests, headers, config, repo_id, pull_request_id, final_comment, leave_active)
568
+
569
+ if final_status == "Approved":
570
+ print("Posted final PR summary (approved).")
571
+ else:
572
+ print("Posted final PR summary (needs work).")
573
+
574
+ return True # Successfully posted summary comments
575
+
576
+
577
+ def generate_overarching_pr_comments_cli() -> None:
578
+ """CLI entry point for generate_overarching_pr_comments."""
579
+ generate_overarching_pr_comments()