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.
- cli/__init__.py +53 -0
- cli/db.py +67 -0
- cli/flow.py +187 -0
- cli/main.py +44 -0
- cli/project.py +166 -0
- cli/server.py +127 -0
- cli/utils.py +70 -0
- cli/worker.py +124 -0
- github_app/__init__.py +13 -0
- github_app/app.py +224 -0
- github_app/auth.py +137 -0
- github_app/config.py +38 -0
- github_app/installation_manager.py +194 -0
- github_app/label_mapper.py +112 -0
- github_app/models.py +112 -0
- github_app/webhook_handler.py +217 -0
- persistence/__init__.py +5 -0
- persistence/config.py +12 -0
- persistence/postgres.py +346 -0
- persistence/registry.py +111 -0
- persistence/tasks.py +178 -0
- polycoding-0.1.0.dist-info/METADATA +225 -0
- polycoding-0.1.0.dist-info/RECORD +41 -0
- polycoding-0.1.0.dist-info/WHEEL +4 -0
- polycoding-0.1.0.dist-info/entry_points.txt +3 -0
- polycoding-0.1.0.dist-info/licenses/LICENSE +20 -0
- project_manager/README.md +668 -0
- project_manager/__init__.py +29 -0
- project_manager/base.py +202 -0
- project_manager/config.py +36 -0
- project_manager/conversation/__init__.py +19 -0
- project_manager/conversation/flow.py +233 -0
- project_manager/conversation/types.py +64 -0
- project_manager/flow_runner.py +160 -0
- project_manager/git_utils.py +30 -0
- project_manager/github.py +367 -0
- project_manager/github_conversation.py +144 -0
- project_manager/github_projects_client.py +329 -0
- project_manager/hooks.py +377 -0
- project_manager/module.py +66 -0
- project_manager/types.py +79 -0
|
@@ -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
|
project_manager/hooks.py
ADDED
|
@@ -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}")
|