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.
- agdt_ai_helpers/__init__.py +34 -0
- agentic_devtools/__init__.py +8 -0
- agentic_devtools/background_tasks.py +598 -0
- agentic_devtools/cli/__init__.py +1 -0
- agentic_devtools/cli/azure_devops/__init__.py +222 -0
- agentic_devtools/cli/azure_devops/async_commands.py +1218 -0
- agentic_devtools/cli/azure_devops/auth.py +34 -0
- agentic_devtools/cli/azure_devops/commands.py +728 -0
- agentic_devtools/cli/azure_devops/config.py +49 -0
- agentic_devtools/cli/azure_devops/file_review_commands.py +1038 -0
- agentic_devtools/cli/azure_devops/helpers.py +561 -0
- agentic_devtools/cli/azure_devops/mark_reviewed.py +756 -0
- agentic_devtools/cli/azure_devops/pipeline_commands.py +724 -0
- agentic_devtools/cli/azure_devops/pr_summary_commands.py +579 -0
- agentic_devtools/cli/azure_devops/pull_request_details_commands.py +596 -0
- agentic_devtools/cli/azure_devops/review_commands.py +700 -0
- agentic_devtools/cli/azure_devops/review_helpers.py +191 -0
- agentic_devtools/cli/azure_devops/review_jira.py +308 -0
- agentic_devtools/cli/azure_devops/review_prompts.py +263 -0
- agentic_devtools/cli/azure_devops/run_details_commands.py +935 -0
- agentic_devtools/cli/azure_devops/vpn_toggle.py +1220 -0
- agentic_devtools/cli/git/__init__.py +91 -0
- agentic_devtools/cli/git/async_commands.py +294 -0
- agentic_devtools/cli/git/commands.py +399 -0
- agentic_devtools/cli/git/core.py +152 -0
- agentic_devtools/cli/git/diff.py +210 -0
- agentic_devtools/cli/git/operations.py +737 -0
- agentic_devtools/cli/jira/__init__.py +114 -0
- agentic_devtools/cli/jira/adf.py +105 -0
- agentic_devtools/cli/jira/async_commands.py +439 -0
- agentic_devtools/cli/jira/async_status.py +27 -0
- agentic_devtools/cli/jira/commands.py +28 -0
- agentic_devtools/cli/jira/comment_commands.py +141 -0
- agentic_devtools/cli/jira/config.py +69 -0
- agentic_devtools/cli/jira/create_commands.py +293 -0
- agentic_devtools/cli/jira/formatting.py +131 -0
- agentic_devtools/cli/jira/get_commands.py +287 -0
- agentic_devtools/cli/jira/helpers.py +278 -0
- agentic_devtools/cli/jira/parse_error_report.py +352 -0
- agentic_devtools/cli/jira/role_commands.py +560 -0
- agentic_devtools/cli/jira/state_helpers.py +39 -0
- agentic_devtools/cli/jira/update_commands.py +222 -0
- agentic_devtools/cli/jira/vpn_wrapper.py +58 -0
- agentic_devtools/cli/release/__init__.py +5 -0
- agentic_devtools/cli/release/commands.py +113 -0
- agentic_devtools/cli/release/helpers.py +113 -0
- agentic_devtools/cli/runner.py +318 -0
- agentic_devtools/cli/state.py +174 -0
- agentic_devtools/cli/subprocess_utils.py +109 -0
- agentic_devtools/cli/tasks/__init__.py +28 -0
- agentic_devtools/cli/tasks/commands.py +851 -0
- agentic_devtools/cli/testing.py +442 -0
- agentic_devtools/cli/workflows/__init__.py +80 -0
- agentic_devtools/cli/workflows/advancement.py +204 -0
- agentic_devtools/cli/workflows/base.py +240 -0
- agentic_devtools/cli/workflows/checklist.py +278 -0
- agentic_devtools/cli/workflows/commands.py +1610 -0
- agentic_devtools/cli/workflows/manager.py +802 -0
- agentic_devtools/cli/workflows/preflight.py +323 -0
- agentic_devtools/cli/workflows/worktree_setup.py +1110 -0
- agentic_devtools/dispatcher.py +704 -0
- agentic_devtools/file_locking.py +203 -0
- agentic_devtools/prompts/__init__.py +38 -0
- agentic_devtools/prompts/apply-pull-request-review-suggestions/default-initiate-prompt.md +82 -0
- agentic_devtools/prompts/create-jira-epic/default-initiate-prompt.md +63 -0
- agentic_devtools/prompts/create-jira-issue/default-initiate-prompt.md +306 -0
- agentic_devtools/prompts/create-jira-subtask/default-initiate-prompt.md +57 -0
- agentic_devtools/prompts/loader.py +377 -0
- agentic_devtools/prompts/pull-request-review/default-completion-prompt.md +45 -0
- agentic_devtools/prompts/pull-request-review/default-decision-prompt.md +63 -0
- agentic_devtools/prompts/pull-request-review/default-file-review-prompt.md +69 -0
- agentic_devtools/prompts/pull-request-review/default-initiate-prompt.md +50 -0
- agentic_devtools/prompts/pull-request-review/default-summary-prompt.md +40 -0
- agentic_devtools/prompts/update-jira-issue/default-initiate-prompt.md +78 -0
- agentic_devtools/prompts/work-on-jira-issue/default-checklist-creation-prompt.md +58 -0
- agentic_devtools/prompts/work-on-jira-issue/default-commit-prompt.md +47 -0
- agentic_devtools/prompts/work-on-jira-issue/default-completion-prompt.md +65 -0
- agentic_devtools/prompts/work-on-jira-issue/default-implementation-prompt.md +66 -0
- agentic_devtools/prompts/work-on-jira-issue/default-implementation-review-prompt.md +60 -0
- agentic_devtools/prompts/work-on-jira-issue/default-initiate-prompt.md +67 -0
- agentic_devtools/prompts/work-on-jira-issue/default-planning-prompt.md +50 -0
- agentic_devtools/prompts/work-on-jira-issue/default-pull-request-prompt.md +56 -0
- agentic_devtools/prompts/work-on-jira-issue/default-retrieve-prompt.md +29 -0
- agentic_devtools/prompts/work-on-jira-issue/default-setup-prompt.md +19 -0
- agentic_devtools/prompts/work-on-jira-issue/default-verification-prompt.md +73 -0
- agentic_devtools/state.py +754 -0
- agentic_devtools/task_state.py +902 -0
- agentic_devtools-0.2.0.dist-info/METADATA +544 -0
- agentic_devtools-0.2.0.dist-info/RECORD +92 -0
- agentic_devtools-0.2.0.dist-info/WHEEL +4 -0
- agentic_devtools-0.2.0.dist-info/entry_points.txt +79 -0
- 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()
|