polycoding 0.1.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.
@@ -0,0 +1,329 @@
1
+ """GitHub Projects V2 GraphQL API client with full type annotations.
2
+
3
+ Moved from github_issues/github_project.py as part of refactoring.
4
+ """
5
+
6
+ import logging
7
+
8
+ import httpx
9
+ from pydantic import BaseModel
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class GraphQLResponse(BaseModel):
15
+ """Generic GraphQL response structure."""
16
+
17
+ data: dict
18
+
19
+
20
+ class StatusFieldOption(BaseModel):
21
+ """Status option with ID and name."""
22
+
23
+ id: str
24
+ name: str
25
+
26
+
27
+ class StatusFieldData(BaseModel):
28
+ """Status field with ID and options."""
29
+
30
+ id: str
31
+ options: list[StatusFieldOption]
32
+
33
+
34
+ class ProjectItemContent(BaseModel):
35
+ """Issue content in a project item."""
36
+
37
+ number: int
38
+ title: str
39
+ body: str | None
40
+
41
+
42
+ class ProjectItemStatus(BaseModel):
43
+ """Status field value for a project item."""
44
+
45
+ name: str
46
+
47
+
48
+ class ProjectItem(BaseModel):
49
+ """Item in a GitHub project."""
50
+
51
+ project_item_id: str
52
+ issue_number: int
53
+ title: str
54
+ body: str | None
55
+ status: str | None
56
+
57
+ class Config:
58
+ """Pydantic config for field aliases."""
59
+
60
+ populate_by_name = True
61
+
62
+
63
+ class GitHubProjectsClient:
64
+ """Client for GitHub Projects V2 GraphQL API."""
65
+
66
+ def __init__(self, token: str, repo_name: str) -> None:
67
+ """Initialize the GitHub Projects client.
68
+
69
+ Args:
70
+ token: GitHub personal access token.
71
+ repo_name: Name of the repository.
72
+ """
73
+ self.repo_name = repo_name
74
+ self.token = token
75
+ self.headers = {
76
+ "Authorization": f"Bearer {token}",
77
+ "Content-Type": "application/json",
78
+ }
79
+ self.endpoint = "https://api.github.com/graphql"
80
+
81
+ def _query(self, query: str, variables: dict | None = None) -> GraphQLResponse:
82
+ """Execute a GraphQL query.
83
+
84
+ Args:
85
+ query: GraphQL query string.
86
+ variables: Optional variables for the query.
87
+
88
+ Returns:
89
+ GraphQL response with data.
90
+
91
+ Raises:
92
+ httpx.HTTPStatusError: If the request fails.
93
+ """
94
+ with httpx.Client() as client:
95
+ response = client.post(
96
+ self.endpoint,
97
+ headers=self.headers,
98
+ json={"query": query, "variables": variables or {}},
99
+ timeout=30,
100
+ )
101
+ response.raise_for_status()
102
+ return GraphQLResponse(**response.json())
103
+
104
+ def get_project_id(self, owner: str, project_number: int | None = None) -> str:
105
+ """Get the global ID for a project.
106
+ Args:
107
+ owner: Repository owner.
108
+ project_number: Project number. If None, returns the first project found.
109
+ Returns:
110
+ Global ID of the project.
111
+ """
112
+ project_id: str | None = None
113
+ if project_number is not None:
114
+ query = (
115
+ """
116
+ query($owner: String!, $projectNumber: Int!) {
117
+ repository(owner: $owner, name: "%s") {
118
+ projectV2(number: $projectNumber) {
119
+ id
120
+ }
121
+ }
122
+ }
123
+ """
124
+ % self.repo_name
125
+ )
126
+ result = self._query(query, {"owner": owner, "projectNumber": project_number})
127
+ node = result.data["repository"].get("projectV2")
128
+ if node is not None:
129
+ project_id = node["id"]
130
+
131
+ if project_id is None:
132
+ # Try as organization first, then fall back to user
133
+ query = """
134
+ query($owner: String!) {
135
+ organization(login: $owner) {
136
+ projectsV2(first: 1) {
137
+ nodes {
138
+ id
139
+ }
140
+ }
141
+ }
142
+ }
143
+ """
144
+ result = self._query(query, {"owner": owner})
145
+ if result.data.get("organization", {}):
146
+ nodes = result.data.get("organization", {}).get("projectsV2", {}).get("nodes")
147
+ if nodes:
148
+ project_id = nodes[0]["id"]
149
+
150
+ if project_id is None:
151
+ # Fall back to user
152
+ query = """
153
+ query($owner: String!) {
154
+ user(login: $owner) {
155
+ projectsV2(first: 1) {
156
+ nodes {
157
+ id
158
+ }
159
+ }
160
+ }
161
+ }
162
+ """
163
+ result = self._query(query, {"owner": owner})
164
+ if result.data.get("user", {}):
165
+ nodes = result.data.get("user", {}).get("projectsV2", {}).get("nodes")
166
+ if nodes:
167
+ project_id = nodes[0]["id"]
168
+
169
+ if project_id is None:
170
+ raise ValueError("No projects found for this owner!")
171
+
172
+ return project_id
173
+
174
+ def get_project_items(self, project_id: str) -> list[ProjectItem]:
175
+ """Get all items in a project with their status.
176
+
177
+ Args:
178
+ project_id: Global ID of the project.
179
+
180
+ Returns:
181
+ List of project items with issue data and status.
182
+ """
183
+ query = """
184
+ query($projectId: ID!, $cursor: String) {
185
+ node(id: $projectId) {
186
+ ... on ProjectV2 {
187
+ items(first: 100, after: $cursor) {
188
+ pageInfo {
189
+ hasNextPage
190
+ endCursor
191
+ }
192
+ nodes {
193
+ id
194
+ content {
195
+ ... on Issue {
196
+ number
197
+ title
198
+ body
199
+ }
200
+ }
201
+ fieldValueByName(name: "Status") {
202
+ ... on ProjectV2ItemFieldSingleSelectValue {
203
+ name
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ """
212
+ items: list[ProjectItem] = []
213
+ cursor: str | None = None
214
+ while True:
215
+ result = self._query(query, {"projectId": project_id, "cursor": cursor})
216
+ data = result.data["node"]["items"]
217
+ for node in data["nodes"]:
218
+ content = node.get("content")
219
+ if content:
220
+ status_field = node.get("fieldValueByName")
221
+ items.append(
222
+ ProjectItem(
223
+ project_item_id=node["id"],
224
+ issue_number=content["number"],
225
+ title=content["title"],
226
+ body=content["body"],
227
+ status=status_field["name"] if status_field else None,
228
+ )
229
+ )
230
+ if not data["pageInfo"]["hasNextPage"]:
231
+ break
232
+ cursor = data["pageInfo"]["endCursor"]
233
+ return items
234
+
235
+ def add_issue_to_project(self, project_id: str, issue_node_id: str) -> str | None:
236
+ """Add an issue to a project.
237
+
238
+ Args:
239
+ project_id: Global ID of the project.
240
+ issue_node_id: Global ID of the issue to add.
241
+
242
+ Returns:
243
+ Project item ID if successful, None otherwise.
244
+ """
245
+ query = """
246
+ mutation($projectId: ID!, $contentId: ID!) {
247
+ addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
248
+ item {
249
+ id
250
+ }
251
+ }
252
+ }
253
+ """
254
+ result = self._query(query, {"projectId": project_id, "contentId": issue_node_id})
255
+ return result.data["addProjectV2ItemById"]["item"]["id"]
256
+
257
+ def get_status_field_id(self, project_id: str) -> tuple[str, dict[str, str]]:
258
+ """Get the ID and options of the Status field in a project.
259
+
260
+ Args:
261
+ project_id: Global ID of the project.
262
+
263
+ Returns:
264
+ Tuple of (field_id, dict mapping status names to option IDs).
265
+ """
266
+ query = """
267
+ query($projectId: ID!) {
268
+ node(id: $projectId) {
269
+ ... on ProjectV2 {
270
+ field(name: "Status") {
271
+ ... on ProjectV2SingleSelectField {
272
+ id
273
+ options {
274
+ id
275
+ name
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+ """
283
+ result = self._query(query, {"projectId": project_id})
284
+ field = result.data["node"]["field"]
285
+ options = {opt["name"]: opt["id"] for opt in field["options"]}
286
+ return field["id"], options
287
+
288
+ def update_item_status(
289
+ self,
290
+ project_id: str,
291
+ item_id: str,
292
+ field_id: str,
293
+ option_id: str,
294
+ ) -> bool:
295
+ """Update the status of a project item.
296
+
297
+ Args:
298
+ project_id: Global ID of the project.
299
+ item_id: ID of the project item.
300
+ field_id: ID of the status field.
301
+ option_id: ID of the status option to set.
302
+
303
+ Returns:
304
+ True if successful, False otherwise.
305
+ """
306
+ query = """
307
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
308
+ updateProjectV2ItemFieldValue(input: {
309
+ projectId: $projectId,
310
+ itemId: $itemId,
311
+ fieldId: $fieldId,
312
+ value: { singleSelectOptionId: $optionId }
313
+ }) {
314
+ projectV2Item {
315
+ id
316
+ }
317
+ }
318
+ }
319
+ """
320
+ result = self._query(
321
+ query,
322
+ {
323
+ "projectId": project_id,
324
+ "itemId": item_id,
325
+ "fieldId": field_id,
326
+ "optionId": option_id,
327
+ },
328
+ )
329
+ return "updateProjectV2ItemFieldValue" in result.data
@@ -0,0 +1,377 @@
1
+ """Hook implementations for project management operations.
2
+
3
+ This module contains hook implementations that handle GitHub-specific
4
+ operations like PR creation, merging, and issue management.
5
+ """
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING, Any, Callable
9
+
10
+ from modules.hooks import FlowEvent, hookimpl
11
+ from project_manager import IssueStatus
12
+
13
+ if TYPE_CHECKING:
14
+ from project_manager.base import ProjectManager
15
+ from project_manager.types import ProjectConfig
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ class ProjectManagerHooks:
21
+ """Hook implementations for GitHub project management.
22
+
23
+ This class handles all GitHub-specific operations triggered by flow orchestration events.
24
+ It creates PRs, handles merges, posts comments, and updates issue status.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ project_manager_factory: Callable[["ProjectConfig"], "ProjectManager"],
30
+ ):
31
+ """Initialize hooks with a factory function.
32
+
33
+ Args:
34
+ project_manager_factory: Callable that creates a ProjectManager from ProjectConfig
35
+ """
36
+ self._pm_factory = project_manager_factory
37
+
38
+ def _get_pm(self, config: "ProjectConfig") -> "ProjectManager":
39
+ """Get project manager instance for the given config."""
40
+ return self._pm_factory(config)
41
+
42
+ @hookimpl
43
+ def on_flow_event(
44
+ self,
45
+ event: FlowEvent,
46
+ flow_id: str,
47
+ state: Any,
48
+ result: Any | None = None,
49
+ label: str = "",
50
+ ) -> None:
51
+ """Handle flow orchestration events for project management operations.
52
+
53
+ This is the main hook that dispatches to specific handlers based on event type.
54
+
55
+ Args:
56
+ event: Current flow event
57
+ flow_id: Unique flow identifier
58
+ state: Flow state model (mutable)
59
+ result: Event-specific result (e.g., commit sha, pr url)
60
+ label: Context label (e.g., "plan", "implement", "review")
61
+ """
62
+ log.info(f"šŸŽ£ Hook called in {__name__}")
63
+ if not hasattr(state, "project_config") or not state.project_config:
64
+ log.debug("No project_config in state, skipping project manager hooks")
65
+ return
66
+
67
+ pm = self._get_pm(state.project_config)
68
+
69
+ if event == FlowEvent.FLOW_STARTED:
70
+ self._handle_flow_started(state, pm)
71
+ elif event == FlowEvent.STORIES_PLANNED:
72
+ self._handle_planning_comment(state, pm)
73
+ elif event == FlowEvent.STORY_COMPLETED:
74
+ self._handle_story_completed(state, pm, result)
75
+ elif event == FlowEvent.FLOW_FINISHED:
76
+ self._handle_flow_finished(state, pm)
77
+
78
+ def _handle_review_start(self, state: Any, pm: "ProjectManager") -> None:
79
+ """Update issue status to 'In review' when review phase starts.
80
+
81
+ Args:
82
+ state: Flow state
83
+ pm: Project manager instance
84
+ """
85
+ issue_id = getattr(state, "issue_id", 0)
86
+ if not issue_id:
87
+ return
88
+
89
+ try:
90
+ pm.update_issue_status(issue_id, "In review")
91
+ log.info(f"šŸ¹ Updated issue #{issue_id} status to In review")
92
+ except Exception as e:
93
+ log.warning(f"🚨 Failed to update project status to In review: {e}")
94
+
95
+ def _handle_create_pr(self, state: Any, pm: "ProjectManager") -> None:
96
+ """Create pull request.
97
+
98
+ Args:
99
+ state: Flow state (mutated with pr_number, pr_url)
100
+ pm: Project manager instance
101
+ """
102
+ if getattr(state, "pr_number", None):
103
+ log.info("PR already exists, skipping creation")
104
+ return
105
+
106
+ branch = getattr(state, "branch", "")
107
+ if not branch:
108
+ log.warning("No branch in state, cannot create PR")
109
+ return
110
+
111
+ title = getattr(state, "commit_title", None) or getattr(state, "task", "")
112
+ body = f"{getattr(state, 'commit_message', '') or ''}\n\n{getattr(state, 'commit_footer', '') or ''}"
113
+ base_branch = "develop"
114
+
115
+ result = pm.create_pull_request(
116
+ title=title,
117
+ body=body.strip(),
118
+ head=branch,
119
+ base=base_branch,
120
+ )
121
+
122
+ if not result:
123
+ log.warning("Failed to create PR")
124
+ return
125
+
126
+ state.pr_number, state.pr_url = result
127
+ log.info(f"šŸ¹ PR {state.pr_number} created: {state.pr_url}")
128
+
129
+ issue_id = getattr(state, "issue_id", 0)
130
+ if issue_id:
131
+ pm.add_comment(
132
+ issue_id,
133
+ f"## šŸ” Review Started\n\n"
134
+ f"Pull request #{state.pr_number} is now under review.\n"
135
+ f"[View PR]({state.pr_url})",
136
+ )
137
+
138
+ def _handle_merge(self, state: Any, pm: "ProjectManager") -> None:
139
+ """Handle PR merge with label check.
140
+
141
+ Args:
142
+ state: Flow state
143
+ pm: Project manager instance
144
+ """
145
+ from project_manager.config import settings as project_settings
146
+
147
+ pr_number = getattr(state, "pr_number", None)
148
+ issue_id = getattr(state, "issue_id", 0)
149
+
150
+ if not pr_number:
151
+ log.warning("No PR number set, skipping merge")
152
+ return
153
+
154
+ required_label = project_settings.MERGE_REQUIRED_LABEL
155
+
156
+ if not pm.has_label(issue_id, required_label):
157
+ log.warning(f"āš ļø Issue #{issue_id} does not have required label '{required_label}'. Merge aborted.")
158
+ pm.add_comment(
159
+ issue_id,
160
+ f"## āš ļø Merge Blocked\n\n"
161
+ f"Pull request #{pr_number} cannot be merged. "
162
+ f"Issue {issue_id} does not have the required label: `{required_label}`.\n\n"
163
+ f"Please add the label and try again.",
164
+ )
165
+ return
166
+
167
+ log.info(f"āœ… PR #{pr_number} has required label '{required_label}', proceeding with merge")
168
+
169
+ success = pm.merge_pull_request(pr_number)
170
+
171
+ if success:
172
+ pr_url = getattr(state, "pr_url", "")
173
+ pm.add_comment(
174
+ issue_id,
175
+ f"## āœ… Task Completed\n\nPull request #{pr_number} has been merged.\n[View merged PR]({pr_url})",
176
+ )
177
+
178
+ def _handle_cleanup(self, state: Any, pm: "ProjectManager") -> None:
179
+ """Update issue status after cleanup.
180
+
181
+ Args:
182
+ state: Flow state
183
+ pm: Project manager instance
184
+ """
185
+ issue_id = getattr(state, "issue_id", 0)
186
+ if not issue_id:
187
+ return
188
+
189
+ try:
190
+ pm.update_issue_status(issue_id, "Done")
191
+ log.info(f"šŸ¹ Updated issue #{issue_id} status to Done")
192
+ except Exception as e:
193
+ log.info(f"🚨 Failed to update project status to Done: {e}")
194
+
195
+ def _handle_planning_comment(self, state: Any, pm: "ProjectManager") -> None:
196
+ """Post planning checklist to issue.
197
+
198
+ Args:
199
+ state: Flow state (mutated with planning_comment_id)
200
+ pm: Project manager instance
201
+ stories: List of stories from planning phase
202
+ """
203
+ if getattr(state, "planning_comment_id"):
204
+ log.debug("Already have a reference comment id for planning")
205
+ return
206
+
207
+ stories = getattr(state, "stories", None)
208
+ if not stories:
209
+ log.debug("No stories to post in planning checklist")
210
+ return
211
+
212
+ issue_id = getattr(state, "issue_id", 0)
213
+ if not issue_id:
214
+ return
215
+
216
+ checklist_items = "\n".join(f"- [ ] {getattr(story, 'description', str(story))}" for story in stories)
217
+ comment = (
218
+ f"## šŸ“‹ Implementation Plan\n\n{checklist_items}\n\n_Progress will be updated as stories are implemented._"
219
+ )
220
+ pm.add_comment(issue_id, comment)
221
+
222
+ comment_id = pm.get_last_comment_by_user(issue_id, pm.bot_username)
223
+ if comment_id:
224
+ state.planning_comment_id = comment_id
225
+ log.info(f"šŸ¹ Posted planning checklist, comment ID: {comment_id}")
226
+
227
+ def _handle_update_checklist(self, state: Any, pm: "ProjectManager", data: Any | None) -> None:
228
+ """Update planning checklist with progress.
229
+
230
+ Args:
231
+ state: Flow state
232
+ pm: Project manager instance
233
+ data: Tuple of (stories, completed_story_ids, pr_url, merged) or dict
234
+ """
235
+ planning_comment_id = getattr(state, "planning_comment_id", None)
236
+ if not planning_comment_id:
237
+ log.warning("No planning comment ID, cannot update checklist")
238
+ return
239
+
240
+ issue_id = getattr(state, "issue_id", 0)
241
+ if not issue_id:
242
+ return
243
+
244
+ if data is None:
245
+ return
246
+
247
+ if isinstance(data, dict):
248
+ stories = data.get("stories", [])
249
+ completed_ids = data.get("completed_ids", [])
250
+ pr_url = data.get("pr_url")
251
+ merged = data.get("merged", False)
252
+ elif isinstance(data, tuple) and len(data) >= 2:
253
+ stories, completed_ids = data[0], data[1]
254
+ pr_url = data[2] if len(data) > 2 else None
255
+ merged = data[3] if len(data) > 3 else False
256
+ else:
257
+ return
258
+
259
+ commit_urls = getattr(state, "commit_urls", {})
260
+ pr_number = getattr(state, "pr_number")
261
+
262
+ checklist_lines = []
263
+ for story in stories:
264
+ story_id = getattr(story, "id", None)
265
+ story_desc = getattr(story, "description", str(story))
266
+
267
+ if story_id in completed_ids:
268
+ commit_url = commit_urls.get(story_id)
269
+ if commit_url:
270
+ checklist_lines.append(f"- [x] {story_desc} ([commit]({commit_url}))")
271
+ else:
272
+ checklist_lines.append(f"- [x] {story_desc}")
273
+ else:
274
+ checklist_lines.append(f"- [ ] {story_desc}")
275
+
276
+ body = "## šŸ“‹ Implementation Plan\n\n" + "\n".join(checklist_lines)
277
+
278
+ if pr_url:
279
+ if merged:
280
+ body += f"\n\n---\n\nāœ… **Merged** - [PR #{pr_number}]({pr_url})"
281
+ else:
282
+ body += f"\n\n---\n\nšŸ” **Review in progress** - [PR #{pr_number}]({pr_url})"
283
+ else:
284
+ body += "\n\n_Progress will be updated as stories are implemented._"
285
+
286
+ pm.update_comment(issue_id, planning_comment_id, body)
287
+ log.info(f"šŸ¹ Updated planning checklist for issue #{issue_id}")
288
+
289
+ def _handle_flow_started(self, state: Any, pm: "ProjectManager") -> None:
290
+ """Handle flow start - post initial planning checklist.
291
+
292
+ Args:
293
+ state: Flow state
294
+ pm: Project manager instance
295
+ """
296
+ issue_id = getattr(state, "issue_id", 0)
297
+ if not issue_id:
298
+ return
299
+
300
+ pm.update_issue_status(issue_id, pm.config.status_mapping.to_provider_status(IssueStatus.IN_PROGRESS))
301
+
302
+ log.info(f"šŸš€ Flow started for issue #{issue_id}")
303
+
304
+ def _handle_story_completed(self, state: Any, pm: "ProjectManager", story: Any) -> None:
305
+ """Handle story completion - update checklist item.
306
+
307
+ Args:
308
+ state: Flow state
309
+ pm: Project manager instance
310
+ story: Completed story object
311
+ """
312
+ issue_id = getattr(state, "issue_id", 0)
313
+ planning_comment_id = getattr(state, "planning_comment_id", None)
314
+
315
+ if not issue_id or not planning_comment_id:
316
+ self._handle_planning_comment(state, pm)
317
+
318
+ stories = getattr(state, "stories", [])
319
+ completed_ids = [s.id for s in stories if s.completed]
320
+
321
+ # Update checklist
322
+ self._handle_update_checklist(
323
+ state,
324
+ pm,
325
+ {
326
+ "stories": stories,
327
+ "completed_ids": completed_ids,
328
+ "pr_url": None,
329
+ "merged": False,
330
+ },
331
+ )
332
+
333
+ if story:
334
+ log.info(f"āœ… Updated checklist for story: {story.title if hasattr(story, 'title') else 'unknown'}")
335
+ else:
336
+ log.info(f"āœ… New checklist for all ({len(stories)})")
337
+
338
+ def _handle_flow_finished(self, state: Any, pm: "ProjectManager") -> None:
339
+ """Handle flow finish - create PR, merge, update final checklist, cleanup.
340
+
341
+ Args:
342
+ state: Flow state
343
+ pm: Project manager instance
344
+ """
345
+ issue_id = getattr(state, "issue_id", 0)
346
+ if not issue_id:
347
+ return
348
+
349
+ # Create PR
350
+ self._handle_create_pr(state, pm)
351
+
352
+ # Merge PR (if approved)
353
+ self._handle_merge(state, pm)
354
+
355
+ # Final checklist update with PR link
356
+ stories = getattr(state, "stories", [])
357
+ completed_ids = [s.id for s in stories if s.completed]
358
+ pr_url = getattr(state, "pr_url", None)
359
+
360
+ if pr_url:
361
+ self._handle_update_checklist(
362
+ state,
363
+ pm,
364
+ {
365
+ "stories": stories,
366
+ "completed_ids": completed_ids,
367
+ "pr_url": pr_url,
368
+ "merged": True,
369
+ },
370
+ )
371
+
372
+ # Update issue status to Done
373
+ self._handle_cleanup(state, pm)
374
+
375
+ pm.update_issue_status(issue_id, pm.config.status_mapping.to_provider_status(IssueStatus.DONE))
376
+
377
+ log.info(f"šŸ Flow finished for issue #{issue_id}")