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,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 {}
|