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,160 @@
1
+ """Flow runner for managing issue processing."""
2
+
3
+ import logging
4
+ from typing import Callable
5
+
6
+ from .base import ProjectManager
7
+ from .types import IssueStatus, ProjectItem
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ class FlowRunner:
13
+ """Manages flow execution using project manager as source of truth."""
14
+
15
+ def __init__(
16
+ self,
17
+ manager: ProjectManager,
18
+ on_issue_ready: Callable[[ProjectItem], None] | None = None,
19
+ ) -> None:
20
+ """Initialize flow runner.
21
+
22
+ Args:
23
+ manager: Project manager instance
24
+ on_issue_ready: Callback when an issue is ready to process
25
+ """
26
+ self.manager = manager
27
+ self.on_issue_ready = on_issue_ready
28
+
29
+ def is_flow_running(self) -> bool:
30
+ """Check if a flow is currently running.
31
+
32
+ Uses project manager as single source of truth - checks if any
33
+ item has "In Progress" status.
34
+
35
+ Returns:
36
+ True if a flow is running, False otherwise
37
+ """
38
+ if not self.manager.has_project:
39
+ return False # No project configured = no flow running
40
+
41
+ items = self.manager.get_project_items()
42
+ in_progress_status = self.manager.config.status_mapping.to_provider_status(IssueStatus.IN_PROGRESS)
43
+
44
+ in_progress_items = [item for item in items if item.status == in_progress_status]
45
+ return len(in_progress_items) > 0
46
+
47
+ def get_running_flow(self) -> ProjectItem | None:
48
+ """Get the currently running flow.
49
+
50
+ Returns:
51
+ ProjectItem if a flow is running, None otherwise
52
+ """
53
+ if not self.manager.has_project:
54
+ return None # No project = no running flow
55
+
56
+ items = self.manager.get_project_items()
57
+ in_progress_status = self.manager.config.status_mapping.to_provider_status(IssueStatus.IN_PROGRESS)
58
+
59
+ for item in items:
60
+ if item.status == in_progress_status:
61
+ return item
62
+
63
+ return None
64
+
65
+ def trigger_flow(self, issue_number: int | None = None) -> bool | str:
66
+ """Trigger a flow for an issue.
67
+
68
+ If issue_number is provided, processes that specific issue.
69
+ Otherwise, finds the next ready issue.
70
+
71
+ When Celery is available, returns task ID for async processing.
72
+ When Celery is not available, returns bool for sync processing.
73
+
74
+ Args:
75
+ issue_number: Optional specific issue to process
76
+ None for finding next ready issue
77
+
78
+ Returns:
79
+ True/task_id if flow was triggered, False if already running or no issue found
80
+ """
81
+ if not self.manager.has_project:
82
+ log.warning("trigger_flow called without project configuration")
83
+ return False
84
+
85
+ if self.is_flow_running():
86
+ current = self.get_running_flow()
87
+ if current:
88
+ log.info(f"Flow already running for issue #{current.issue_number}")
89
+ return False
90
+
91
+ if issue_number:
92
+ return self._process_specific_issue(issue_number)
93
+ else:
94
+ return self._process_next_ready_issue()
95
+
96
+ def _process_specific_issue(self, issue_number: int) -> bool | str:
97
+ """Process a specific issue.
98
+
99
+ Args:
100
+ issue_number: Issue number to process
101
+
102
+ Returns:
103
+ True/task_id if flow was triggered, False otherwise
104
+ """
105
+ item = self.manager.find_project_item(issue_number)
106
+ if not item:
107
+ log.warning(f"Issue #{issue_number} not found in project")
108
+ return False
109
+
110
+ ready_status = self.manager.config.status_mapping.to_provider_status(IssueStatus.READY)
111
+ in_progress_status = self.manager.config.status_mapping.to_provider_status(IssueStatus.IN_PROGRESS)
112
+
113
+ if item.status != ready_status:
114
+ log.info(f"Issue #{issue_number} not ready (status: {item.status}), skipping")
115
+ return False
116
+
117
+ success = self.manager.update_issue_status(issue_number, in_progress_status)
118
+ if not success:
119
+ log.error(f"Failed to move issue #{issue_number} to {in_progress_status}")
120
+ return False
121
+
122
+ log.info(f"Started flow for issue #{issue_number}: {item.title}")
123
+
124
+ try:
125
+ from tasks.tasks import kickoff_task
126
+
127
+ task_result = kickoff_task.apply_async(args=[self.manager.config.model_dump(), issue_number]) # type: ignore
128
+ log.info(f"Queued Celery task for issue #{issue_number}: {task_result.id}")
129
+ return task_result.id
130
+ except Exception as e:
131
+ log.error(f"Failed to queue Celery task: {e}")
132
+ log.warning("Falling back to synchronous processing")
133
+
134
+ if self.on_issue_ready:
135
+ try:
136
+ self.on_issue_ready(item)
137
+ except Exception as e:
138
+ log.error(f"Error processing issue #{issue_number}: {e}")
139
+ raise
140
+ else:
141
+ log.info(f"No callback configured for issue #{issue_number}")
142
+
143
+ return True
144
+
145
+ def _process_next_ready_issue(self) -> bool:
146
+ """Process the next ready issue.
147
+
148
+ Returns:
149
+ True if flow was triggered, False if no ready issues
150
+ """
151
+ items = self.manager.get_project_items()
152
+ ready_status = self.manager.config.status_mapping.to_provider_status(IssueStatus.READY)
153
+
154
+ ready_items = [item for item in items if item.status == ready_status]
155
+ if not ready_items:
156
+ log.info("No ready items to process")
157
+ return False
158
+
159
+ top_item = ready_items[0]
160
+ return bool(self._process_specific_issue(top_item.issue_number))
@@ -0,0 +1,30 @@
1
+ """Git utilities for GitHub operations.
2
+
3
+ Moved from github_issues/utils.py as part of refactoring.
4
+ """
5
+
6
+ import os
7
+ import re
8
+
9
+ import git
10
+ import github
11
+
12
+
13
+ def get_github_repo_from_local(local_path):
14
+ """Auto-detect GitHub repo from local git checkout."""
15
+ repo = git.Repo(local_path)
16
+ origin_url = repo.remotes.origin.url
17
+
18
+ # Parse owner/repo
19
+ url = origin_url.rstrip("/").removesuffix(".git")
20
+ match = re.search(r"[:/](.+)/(.+)", url)
21
+ if not match:
22
+ raise ValueError(f"Not a GitHub remote: {origin_url}")
23
+
24
+ owner, repo_name = match.group(1), match.group(2)
25
+
26
+ # Connect to GitHub
27
+ g = github.Github(auth=github.Auth.Token(os.environ["GITHUB_TOKEN"]))
28
+ github_repo = g.get_repo(f"{owner}/{repo_name}")
29
+
30
+ return repo, github_repo, g
@@ -0,0 +1,367 @@
1
+ """GitHub-specific project manager implementation."""
2
+
3
+ import logging
4
+ from typing import cast
5
+
6
+ import github
7
+ from github.Repository import Repository
8
+
9
+ from .base import ProjectManager
10
+ from .config import settings
11
+ from .github_projects_client import GitHubProjectsClient
12
+ from .types import Issue, ProjectConfig, ProjectItem
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ class GitHubProjectManager(ProjectManager):
18
+ """GitHub Projects V2 implementation of ProjectManager."""
19
+
20
+ github_client: github.Github
21
+ repo: Repository
22
+
23
+ def __repr__(self):
24
+ return f"ProjectManager(github, repo={self.repo.url})"
25
+
26
+ def __init__(self, config: ProjectConfig) -> None:
27
+ """Initialize GitHub project manager.
28
+
29
+ Args:
30
+ config: Project configuration
31
+
32
+ Raises:
33
+ ValueError: If token is not provided
34
+ """
35
+ super().__init__(config)
36
+
37
+ token = config.token or settings.GITHUB_TOKEN
38
+ if not token:
39
+ raise ValueError("GitHub token must be provided via config or GITHUB_TOKEN env var")
40
+
41
+ self.token = token
42
+ self.github_client = github.Github(auth=github.Auth.Token(token))
43
+ self.projects_client = GitHubProjectsClient(token, config.repo_name)
44
+ self.repo = self.github_client.get_repo(f"{self.config.repo_owner}/{self.config.repo_name}")
45
+ self._project_id: str | None = None
46
+ self._status_field_id: str | None = None
47
+ self._status_options: dict[str, str] | None = None
48
+ self._bot_username: str | None = None
49
+
50
+ @property
51
+ def has_project(self) -> bool:
52
+ """Check if project integration is enabled for this manager."""
53
+ return self.config.project_identifier is not None
54
+
55
+ @property
56
+ def bot_username(self) -> str:
57
+ """Get the authenticated bot username."""
58
+ if self._bot_username is None:
59
+ self._bot_username = self.github_client.get_user().login
60
+ return self._bot_username
61
+
62
+ def get_comments(self, issue_number: int) -> list:
63
+ """Get all comments for an issue.
64
+
65
+ Args:
66
+ issue_number: Issue number
67
+
68
+ Returns:
69
+ List of comments
70
+ """
71
+ try:
72
+ issue = self.repo.get_issue(issue_number)
73
+ return list(issue.get_comments())
74
+ except Exception as e:
75
+ log.error(f"Failed to get comments for issue #{issue_number}: {e}")
76
+ return []
77
+
78
+ def get_last_comment_by_user(self, issue_number: int, username: str) -> int | None:
79
+ """Get the last comment ID by a specific user.
80
+
81
+ Args:
82
+ issue_number: Issue number
83
+ username: GitHub username
84
+
85
+ Returns:
86
+ Comment ID if found, None otherwise
87
+ """
88
+ try:
89
+ comments = self.get_comments(issue_number)
90
+ for comment in reversed(comments):
91
+ if comment.user and comment.user.login == username:
92
+ return comment.id
93
+ return None
94
+ except Exception as e:
95
+ log.error(f"Failed to get last comment by {username} on issue #{issue_number}: {e}")
96
+ return None
97
+
98
+ def update_comment(self, issue_number: int, comment_id: int, body: str) -> bool:
99
+ """Update an existing comment.
100
+
101
+ Args:
102
+ issue_number: Issue number
103
+ comment_id: Comment ID to update
104
+ body: New comment body
105
+
106
+ Returns:
107
+ True if successful, False otherwise
108
+ """
109
+ try:
110
+ issue = self.repo.get_issue(issue_number)
111
+ for comment in issue.get_comments():
112
+ if comment.id == comment_id:
113
+ comment.edit(body)
114
+ log.info(f"Updated comment {comment_id} on issue #{issue_number}")
115
+ return True
116
+ log.warning(f"Comment {comment_id} not found on issue #{issue_number}")
117
+ return False
118
+ except Exception as e:
119
+ log.error(f"Failed to update comment {comment_id} on issue #{issue_number}: {e}")
120
+ return False
121
+
122
+ @property
123
+ def project_id(self) -> str | None:
124
+ """Lazy-load project ID. Returns None if not configured."""
125
+ if self.config.project_identifier is None:
126
+ return None
127
+
128
+ if self._project_id is None:
129
+ project_number = int(self.config.project_identifier)
130
+ self._project_id = self.projects_client.get_project_id(self.config.repo_owner, project_number)
131
+ return self._project_id
132
+
133
+ @property
134
+ def status_field_info(self) -> tuple[str, dict[str, str]]:
135
+ """Lazy-load status field ID and options."""
136
+ if not self.has_project:
137
+ return "", {}
138
+
139
+ if self._status_field_id is None or self._status_options is None:
140
+ pid = self._project_id
141
+ if pid is None:
142
+ return "", {}
143
+ self._status_field_id, self._status_options = self.projects_client.get_status_field_id(pid)
144
+ return self._status_field_id, self._status_options
145
+
146
+ def get_open_issues(self) -> list[Issue]:
147
+ """Get all open issues from the repository.
148
+
149
+ Returns:
150
+ List of open issues
151
+ """
152
+ issues: list[Issue] = []
153
+
154
+ for issue in self.repo.get_issues(state="open"):
155
+ issues.append(
156
+ Issue(
157
+ id=issue.number,
158
+ number=issue.number,
159
+ title=issue.title,
160
+ body=issue.body,
161
+ node_id=issue.node_id,
162
+ url=issue.html_url,
163
+ labels=[label.name for label in issue.labels],
164
+ )
165
+ )
166
+
167
+ return issues
168
+
169
+ def get_project_items(self) -> list[ProjectItem]:
170
+ """Get all items in a project.
171
+
172
+ Returns:
173
+ List of project items
174
+ """
175
+ if self.project_id is None: # New guard
176
+ return [] # Return empty list when no project
177
+
178
+ items = self.projects_client.get_project_items(self.project_id)
179
+ return [
180
+ ProjectItem(
181
+ id=item.project_item_id,
182
+ issue_number=item.issue_number,
183
+ title=item.title,
184
+ body=item.body,
185
+ status=item.status,
186
+ )
187
+ for item in items
188
+ ]
189
+
190
+ def add_issue_to_project(self, issue: Issue) -> str | None:
191
+ """Add an issue to a project.
192
+
193
+ Args:
194
+ issue: Issue to add
195
+
196
+ Returns:
197
+ Project item ID if successful, None otherwise
198
+ """
199
+ if not issue.node_id:
200
+ log.warning(f"Issue #{issue.number} has no node_id")
201
+ return None
202
+
203
+ if self.project_id is None:
204
+ log.warning("add_issue_to_project called without project configuration")
205
+ return None
206
+
207
+ try:
208
+ item_id = self.projects_client.add_issue_to_project(self.project_id, issue.node_id)
209
+ log.info(f"Added issue #{issue.number} to project")
210
+ return item_id
211
+ except Exception as e:
212
+ log.error(f"Failed to add issue #{issue.number} to project: {e}")
213
+ return None
214
+
215
+ def update_issue_status(self, issue_number: int, status: str) -> bool:
216
+ """Update the status of an issue in a project.
217
+
218
+ Args:
219
+ issue_number: Issue number
220
+ status: New status value (provider-specific)
221
+
222
+ Returns:
223
+ True if successful, False otherwise
224
+ """
225
+ if self.project_id is None: # New guard
226
+ log.warning("update_issue_status called without project configuration")
227
+ return False
228
+
229
+ item = self.find_project_item(issue_number)
230
+ if not item:
231
+ log.warning(f"Issue #{issue_number} not found in project")
232
+ return False
233
+
234
+ field_id, options = self.status_field_info
235
+ if status not in options:
236
+ log.warning(f"Status '{status}' not found in project options: {list(options.keys())}")
237
+ return False
238
+
239
+ success = self.projects_client.update_item_status(self.project_id, item.id, field_id, options[status])
240
+ if success:
241
+ log.info(f"Updated issue #{issue_number} to '{status}'")
242
+ return success
243
+
244
+ def get_issue(self, issue_number: int) -> Issue:
245
+ return cast(Issue, self.repo.get_issue(issue_number))
246
+
247
+ def add_comment(self, issue_number: int, comment: str) -> bool:
248
+ """Add a comment to an issue.
249
+
250
+ Args:
251
+ issue_number: Issue number
252
+ comment: Comment text
253
+
254
+ Returns:
255
+ True if successful, False otherwise
256
+ """
257
+ try:
258
+ issue = self.repo.get_issue(issue_number)
259
+ issue.create_comment(comment)
260
+ log.info(f"Added comment to issue #{issue_number}")
261
+ return True
262
+ except Exception as e:
263
+ log.error(f"Failed to add comment to issue #{issue_number}: {e}")
264
+ return False
265
+
266
+ def has_label(self, issue_number: int, label_name: str) -> bool:
267
+ """Check if an issue/pull request has a specific label.
268
+
269
+ Args:
270
+ issue_numer: Issue/Pull request number
271
+ label_name: Name of the label to check for
272
+
273
+ Returns:
274
+ True if the label is present on the PR, False otherwise
275
+ """
276
+ try:
277
+ pr = self.repo.get_issue(issue_number)
278
+
279
+ # Check if any label matches the requested label name
280
+ for label in pr.labels:
281
+ if label.name == label_name:
282
+ log.info(f"Pull request #{issue_number} has label '{label_name}'")
283
+ return True
284
+
285
+ log.info(f"Pull request #{issue_number} does not have label '{label_name}'")
286
+ return False
287
+
288
+ except github.UnknownObjectException:
289
+ log.warning(f"Pull request #{issue_number} not found")
290
+ return False
291
+ except Exception as e:
292
+ log.error(f"Failed to check label on PR #{issue_number}: {e}")
293
+ return False
294
+
295
+ def create_pull_request(
296
+ self,
297
+ title: str,
298
+ body: str,
299
+ head: str,
300
+ base: str = "develop",
301
+ ) -> tuple[int, str] | None:
302
+ """Create a pull request.
303
+
304
+ Args:
305
+ title: PR title
306
+ body: PR body/description
307
+ head: Source branch name
308
+ base: Target branch name (default: "develop")
309
+
310
+ Returns:
311
+ Tuple of (pr_number, pr_url) if successful, None otherwise
312
+ """
313
+ try:
314
+ pr = self.repo.create_pull(
315
+ title=title,
316
+ body=body.strip(),
317
+ head=head,
318
+ base=base,
319
+ )
320
+ log.info(f"Created PR #{pr.number}: {pr.html_url}")
321
+ return pr.number, pr.html_url
322
+ except Exception as e:
323
+ log.error(f"Failed to create PR from {head} to {base}: {e}")
324
+ return None
325
+
326
+ def merge_pull_request(
327
+ self,
328
+ pr_number: int,
329
+ commit_message: str | None = None,
330
+ merge_method: str = "merge",
331
+ ) -> bool:
332
+ """Merge a pull request into its base branch (typically develop).
333
+
334
+ Args:
335
+ pr_number: Pull request number
336
+ commit_message: Optional custom commit message for the merge
337
+ merge_method: Merge method - "merge", "squash", or "rebase" (default: "merge")
338
+
339
+ Returns:
340
+ True if successful, False otherwise
341
+ """
342
+ try:
343
+ pr = self.repo.get_pull(pr_number)
344
+
345
+ if pr.merged:
346
+ log.warning(f"Pull request #{pr_number} is already merged")
347
+ return True
348
+
349
+ if pr.state != "open":
350
+ log.error(f"Pull request #{pr_number} is not open (state: {pr.state})")
351
+ return False
352
+
353
+ if commit_message:
354
+ result = pr.merge(commit_message=commit_message, merge_method=merge_method)
355
+ else:
356
+ result = pr.merge(merge_method=merge_method)
357
+
358
+ if result.merged:
359
+ log.info(f"Successfully merged pull request #{pr_number} into {pr.base.ref} using {merge_method}")
360
+ return True
361
+ else:
362
+ log.error(f"Failed to merge pull request #{pr_number}: {result.message}")
363
+ return False
364
+
365
+ except Exception as e:
366
+ log.error(f"Failed to merge pull request #{pr_number}: {e}")
367
+ return False
@@ -0,0 +1,144 @@
1
+ """Enhanced GitHub manager with reaction support."""
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from .github import GitHubProjectManager
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ class GitHubConversationManager(GitHubProjectManager):
12
+ """GitHub manager with conversation and reaction support."""
13
+
14
+ def get_comment_reactions(self, issue_number: int, comment_id: int) -> list[dict]:
15
+ """Get reactions on a specific comment.
16
+
17
+ Args:
18
+ issue_number: Issue number
19
+ comment_id: Comment ID
20
+
21
+ Returns:
22
+ List of reactions with user and content
23
+ """
24
+ try:
25
+ issue = self.repo.get_issue(issue_number)
26
+ comment = issue.get_comment(comment_id)
27
+ reactions = []
28
+
29
+ for reaction in comment.get_reactions():
30
+ reactions.append(
31
+ {
32
+ "user": reaction.user.login if reaction.user else None,
33
+ "content": reaction.content,
34
+ "created_at": (reaction.created_at.isoformat() if reaction.created_at else None),
35
+ }
36
+ )
37
+
38
+ log.info(f"Found {len(reactions)} reactions on comment {comment_id}")
39
+ return reactions
40
+
41
+ except Exception as e:
42
+ log.error(f"Failed to get reactions on comment {comment_id}: {e}")
43
+ return []
44
+
45
+ def has_thumbs_up_reaction(self, issue_number: int, comment_id: int) -> bool:
46
+ """Check if a comment has a thumbs up reaction from the issue author.
47
+
48
+ Args:
49
+ issue_number: Issue number
50
+ comment_id: Comment ID
51
+
52
+ Returns:
53
+ True if thumbs up from issue author exists
54
+ """
55
+ try:
56
+ issue = self.repo.get_issue(issue_number)
57
+ issue_author = issue.user.login
58
+
59
+ reactions = self.get_comment_reactions(issue_number, comment_id)
60
+
61
+ for reaction in reactions:
62
+ if reaction["content"] == "+1" and reaction["user"] == issue_author:
63
+ log.info(f"Thumbs up from {issue_author} detected")
64
+ return True
65
+
66
+ return False
67
+
68
+ except Exception as e:
69
+ log.error(f"Failed to check thumbs up on comment {comment_id}: {e}")
70
+ return False
71
+
72
+ def get_latest_comments(self, issue_number: int, since_id: Optional[int] = None) -> list[dict]:
73
+ """Get latest comments on an issue.
74
+
75
+ Args:
76
+ issue_number: Issue number
77
+ since_id: Only return comments after this ID
78
+
79
+ Returns:
80
+ List of comment dictionaries
81
+ """
82
+ try:
83
+ issue = self.repo.get_issue(issue_number)
84
+ comments = []
85
+
86
+ for comment in issue.get_comments():
87
+ if since_id and comment.id <= since_id:
88
+ continue
89
+
90
+ # Check for thumbs up reaction
91
+ has_thumbs_up = self.has_thumbs_up_reaction(issue_number, comment.id)
92
+
93
+ comments.append(
94
+ {
95
+ "id": comment.id,
96
+ "user": comment.user.login if comment.user else None,
97
+ "body": comment.body,
98
+ "created_at": (comment.created_at.isoformat() if comment.created_at else None),
99
+ "thumbs_up": has_thumbs_up,
100
+ }
101
+ )
102
+
103
+ log.info(f"Retrieved {len(comments)} new comments")
104
+ return comments
105
+
106
+ except Exception as e:
107
+ log.error(f"Failed to get comments for issue {issue_number}: {e}")
108
+ return []
109
+
110
+ def get_issue_with_reactions(self, issue_number: int) -> dict:
111
+ """Get issue with reaction information.
112
+
113
+ Args:
114
+ issue_number: Issue number
115
+
116
+ Returns:
117
+ Dictionary with issue details and reaction info
118
+ """
119
+ try:
120
+ issue = self.repo.get_issue(issue_number)
121
+
122
+ # Get reactions on the issue itself
123
+ reactions = []
124
+ for reaction in issue.get_reactions():
125
+ reactions.append(
126
+ {
127
+ "user": reaction.user.login if reaction.user else None,
128
+ "content": reaction.content,
129
+ "created_at": (reaction.created_at.isoformat() if reaction.created_at else None),
130
+ }
131
+ )
132
+
133
+ return {
134
+ "id": issue.number,
135
+ "title": issue.title,
136
+ "body": issue.body,
137
+ "author": issue.user.login if issue.user else None,
138
+ "state": issue.state,
139
+ "reactions": reactions,
140
+ }
141
+
142
+ except Exception as e:
143
+ log.error(f"Failed to get issue {issue_number}: {e}")
144
+ return {}