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,194 @@
|
|
|
1
|
+
"""GitHub App installation manager - manages installations and tokens."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from github_app.auth import GitHubAppAuth
|
|
9
|
+
from github_app.models import GitHubAppInstallation, GitHubWebhookRegistration
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InstallationManager:
|
|
15
|
+
"""Manages GitHub App installations and their lifecycle."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, db_session: Session, github_auth: GitHubAppAuth):
|
|
18
|
+
self.db_session = db_session
|
|
19
|
+
self.github_auth = github_auth
|
|
20
|
+
|
|
21
|
+
def register_installation(self, installation_data: Dict[str, Any]) -> GitHubAppInstallation:
|
|
22
|
+
"""Register or update a GitHub App installation."""
|
|
23
|
+
installation_id = installation_data["id"]
|
|
24
|
+
|
|
25
|
+
existing = (
|
|
26
|
+
self.db_session.query(GitHubAppInstallation)
|
|
27
|
+
.filter(GitHubAppInstallation.installation_id == installation_id)
|
|
28
|
+
.first()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if existing:
|
|
32
|
+
result = self.update_installation(installation_id, installation_data)
|
|
33
|
+
assert result is not None, "Update should succeed for existing installation"
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
installation = GitHubAppInstallation(
|
|
37
|
+
installation_id=installation_id,
|
|
38
|
+
account_id=installation_data["account"]["id"],
|
|
39
|
+
account_login=installation_data["account"]["login"],
|
|
40
|
+
account_type=installation_data["account"]["type"],
|
|
41
|
+
app_id=installation_data["app_id"],
|
|
42
|
+
permissions=installation_data.get("permissions", {}),
|
|
43
|
+
events=installation_data.get("events", []),
|
|
44
|
+
repositories={},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
print("=" * 80)
|
|
48
|
+
print(
|
|
49
|
+
dict(
|
|
50
|
+
installation_id=installation_id,
|
|
51
|
+
account_id=installation_data["account"]["id"],
|
|
52
|
+
account_login=installation_data["account"]["login"],
|
|
53
|
+
account_type=installation_data["account"]["type"],
|
|
54
|
+
app_id=installation_data["app_id"],
|
|
55
|
+
permissions=installation_data.get("permissions", {}),
|
|
56
|
+
events=installation_data.get("events", []),
|
|
57
|
+
reposiutories={},
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
print("=" * 80)
|
|
61
|
+
self.db_session.add(installation)
|
|
62
|
+
self.db_session.commit()
|
|
63
|
+
|
|
64
|
+
logger.info(f"Registered installation {installation_id} for {installation_data['account']['login']}")
|
|
65
|
+
|
|
66
|
+
return installation
|
|
67
|
+
|
|
68
|
+
def update_installation(
|
|
69
|
+
self,
|
|
70
|
+
installation_id: int,
|
|
71
|
+
installation_data: Optional[Dict[str, Any]] = None,
|
|
72
|
+
) -> Optional[GitHubAppInstallation]:
|
|
73
|
+
"""Update an existing installation."""
|
|
74
|
+
installation = (
|
|
75
|
+
self.db_session.query(GitHubAppInstallation)
|
|
76
|
+
.filter(GitHubAppInstallation.installation_id == installation_id)
|
|
77
|
+
.first()
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not installation:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
if installation_data:
|
|
84
|
+
installation.account_id = installation_data["account"]["id"]
|
|
85
|
+
installation.account_login = installation_data["account"]["login"]
|
|
86
|
+
installation.account_type = installation_data["account"]["type"]
|
|
87
|
+
installation.permissions = installation_data.get("permissions", {})
|
|
88
|
+
installation.events = installation_data.get("events", [])
|
|
89
|
+
|
|
90
|
+
self.db_session.commit()
|
|
91
|
+
logger.info(f"Updated installation {installation_id}")
|
|
92
|
+
|
|
93
|
+
return installation
|
|
94
|
+
|
|
95
|
+
def deactivate_installation(self, installation_id: int) -> bool:
|
|
96
|
+
"""Deactivate an installation (e.g., when uninstalled from GitHub)."""
|
|
97
|
+
installation = (
|
|
98
|
+
self.db_session.query(GitHubAppInstallation)
|
|
99
|
+
.filter(GitHubAppInstallation.installation_id == installation_id)
|
|
100
|
+
.first()
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not installation:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
installation.is_active = False
|
|
107
|
+
self.db_session.commit()
|
|
108
|
+
|
|
109
|
+
logger.info(f"Deactivated installation {installation_id}")
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
def get_installation(self, installation_id: int) -> Optional[GitHubAppInstallation]:
|
|
113
|
+
"""Get an active installation by ID."""
|
|
114
|
+
return (
|
|
115
|
+
self.db_session.query(GitHubAppInstallation)
|
|
116
|
+
.filter(
|
|
117
|
+
GitHubAppInstallation.installation_id == installation_id,
|
|
118
|
+
GitHubAppInstallation.is_active.is_(True),
|
|
119
|
+
)
|
|
120
|
+
.first()
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def list_installations(self, active_only: bool = True) -> List[GitHubAppInstallation]:
|
|
124
|
+
"""List all installations."""
|
|
125
|
+
query = self.db_session.query(GitHubAppInstallation)
|
|
126
|
+
|
|
127
|
+
if active_only:
|
|
128
|
+
query = query.filter(GitHubAppInstallation.is_active.is_(True))
|
|
129
|
+
|
|
130
|
+
return query.all()
|
|
131
|
+
|
|
132
|
+
def get_installation_token(self, installation_id: int) -> Optional[str]:
|
|
133
|
+
"""Get an installation access token."""
|
|
134
|
+
return self.github_auth.get_installation_token(installation_id)
|
|
135
|
+
|
|
136
|
+
def register_webhook(
|
|
137
|
+
self,
|
|
138
|
+
installation_id: int,
|
|
139
|
+
target_repo: str,
|
|
140
|
+
events: List[str],
|
|
141
|
+
secret: Optional[str] = None,
|
|
142
|
+
webhook_url: Optional[str] = None,
|
|
143
|
+
) -> GitHubWebhookRegistration:
|
|
144
|
+
"""Register a webhook for a specific repository."""
|
|
145
|
+
webhook = GitHubWebhookRegistration(
|
|
146
|
+
installation_id=installation_id,
|
|
147
|
+
target_repo=target_repo,
|
|
148
|
+
events=events,
|
|
149
|
+
secret=secret,
|
|
150
|
+
webhook_url=webhook_url,
|
|
151
|
+
is_active=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self.db_session.add(webhook)
|
|
155
|
+
self.db_session.commit()
|
|
156
|
+
|
|
157
|
+
logger.info(f"Registered webhook for {target_repo} (installation: {installation_id})")
|
|
158
|
+
|
|
159
|
+
return webhook
|
|
160
|
+
|
|
161
|
+
def list_webhooks(
|
|
162
|
+
self,
|
|
163
|
+
installation_id: Optional[int] = None,
|
|
164
|
+
target_repo: Optional[str] = None,
|
|
165
|
+
active_only: bool = True,
|
|
166
|
+
) -> List[GitHubWebhookRegistration]:
|
|
167
|
+
"""List webhooks, optionally filtered."""
|
|
168
|
+
query = self.db_session.query(GitHubWebhookRegistration)
|
|
169
|
+
|
|
170
|
+
if installation_id:
|
|
171
|
+
query = query.filter(GitHubWebhookRegistration.installation_id == installation_id)
|
|
172
|
+
|
|
173
|
+
if target_repo:
|
|
174
|
+
query = query.filter(GitHubWebhookRegistration.target_repo == target_repo)
|
|
175
|
+
|
|
176
|
+
if active_only:
|
|
177
|
+
query = query.filter(GitHubWebhookRegistration.is_active.is_(True))
|
|
178
|
+
|
|
179
|
+
return query.all()
|
|
180
|
+
|
|
181
|
+
def deactivate_webhook(self, webhook_id: int) -> bool:
|
|
182
|
+
"""Deactivate a webhook."""
|
|
183
|
+
webhook = (
|
|
184
|
+
self.db_session.query(GitHubWebhookRegistration).filter(GitHubWebhookRegistration.id == webhook_id).first()
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not webhook:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
webhook.is_active = False
|
|
191
|
+
self.db_session.commit()
|
|
192
|
+
|
|
193
|
+
logger.info(f"Deactivated webhook {webhook_id}")
|
|
194
|
+
return True
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from github_app.models import LabelFlowMapping
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LabelFlowMapper:
|
|
13
|
+
def __init__(self, db_session: Session):
|
|
14
|
+
self.db_session = db_session
|
|
15
|
+
|
|
16
|
+
def get_flow_for_label(self, installation_id: int, label_name: str, repo_slug: str) -> Optional[LabelFlowMapping]:
|
|
17
|
+
mappings = (
|
|
18
|
+
self.db_session.query(LabelFlowMapping)
|
|
19
|
+
.filter(
|
|
20
|
+
LabelFlowMapping.installation_id == installation_id,
|
|
21
|
+
LabelFlowMapping.label_name == label_name,
|
|
22
|
+
LabelFlowMapping.is_active.is_(True),
|
|
23
|
+
)
|
|
24
|
+
.order_by(LabelFlowMapping.priority.desc())
|
|
25
|
+
.all()
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
for mapping in mappings:
|
|
29
|
+
pattern: Optional[str] = mapping.repo_pattern # type: ignore
|
|
30
|
+
if self._matches_repo_pattern(pattern, repo_slug):
|
|
31
|
+
logger.info(
|
|
32
|
+
f"Matched label '{label_name}' to flow '{mapping.flow_name}' "
|
|
33
|
+
f"for repo '{repo_slug}' (installation: {installation_id})"
|
|
34
|
+
)
|
|
35
|
+
return mapping
|
|
36
|
+
|
|
37
|
+
logger.debug(
|
|
38
|
+
f"No flow mapping found for label '{label_name}' in repo '{repo_slug}' (installation: {installation_id})"
|
|
39
|
+
)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def _matches_repo_pattern(self, pattern: Optional[str], repo_slug: str) -> bool:
|
|
43
|
+
if not pattern:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
return fnmatch.fnmatch(repo_slug, pattern)
|
|
47
|
+
|
|
48
|
+
def create_mapping(
|
|
49
|
+
self,
|
|
50
|
+
installation_id: int,
|
|
51
|
+
label_name: str,
|
|
52
|
+
flow_name: str,
|
|
53
|
+
repo_pattern: Optional[str] = None,
|
|
54
|
+
priority: int = 0,
|
|
55
|
+
config: Optional[Dict[str, Any]] = None,
|
|
56
|
+
) -> LabelFlowMapping:
|
|
57
|
+
"""Create a new label-to-flow mapping."""
|
|
58
|
+
mapping = LabelFlowMapping(
|
|
59
|
+
installation_id=installation_id,
|
|
60
|
+
label_name=label_name,
|
|
61
|
+
flow_name=flow_name,
|
|
62
|
+
repo_pattern=repo_pattern,
|
|
63
|
+
priority=priority,
|
|
64
|
+
config=config or {},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.db_session.add(mapping)
|
|
68
|
+
self.db_session.commit()
|
|
69
|
+
|
|
70
|
+
logger.info(
|
|
71
|
+
f"Created label-flow mapping: '{label_name}' -> '{flow_name}' "
|
|
72
|
+
f"(installation: {installation_id}, pattern: {repo_pattern})"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return mapping
|
|
76
|
+
|
|
77
|
+
def update_mapping(self, mapping_id: int, **kwargs) -> Optional[LabelFlowMapping]:
|
|
78
|
+
"""Update an existing mapping."""
|
|
79
|
+
mapping = self.db_session.query(LabelFlowMapping).filter(LabelFlowMapping.id == mapping_id).first()
|
|
80
|
+
|
|
81
|
+
if not mapping:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
for key, value in kwargs.items():
|
|
85
|
+
if hasattr(mapping, key):
|
|
86
|
+
setattr(mapping, key, value)
|
|
87
|
+
|
|
88
|
+
self.db_session.commit()
|
|
89
|
+
logger.info(f"Updated label-flow mapping {mapping_id}")
|
|
90
|
+
return mapping
|
|
91
|
+
|
|
92
|
+
def delete_mapping(self, mapping_id: int) -> bool:
|
|
93
|
+
"""Delete a label-to-flow mapping."""
|
|
94
|
+
mapping = self.db_session.query(LabelFlowMapping).filter(LabelFlowMapping.id == mapping_id).first()
|
|
95
|
+
|
|
96
|
+
if not mapping:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
self.db_session.delete(mapping)
|
|
100
|
+
self.db_session.commit()
|
|
101
|
+
|
|
102
|
+
logger.info(f"Deleted label-flow mapping {mapping_id}")
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
def list_mappings(self, installation_id: Optional[int] = None) -> list[LabelFlowMapping]:
|
|
106
|
+
"""List all label-to-flow mappings, optionally filtered by installation."""
|
|
107
|
+
query = self.db_session.query(LabelFlowMapping).filter(LabelFlowMapping.is_active.is_(True))
|
|
108
|
+
|
|
109
|
+
if installation_id:
|
|
110
|
+
query = query.filter(LabelFlowMapping.installation_id == installation_id)
|
|
111
|
+
|
|
112
|
+
return query.order_by(LabelFlowMapping.priority.desc()).all()
|
github_app/models.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""GitHub App database models using shared PostgreSQL base."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Index
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
8
|
+
from sqlalchemy.types import Boolean, DateTime, Integer, String, Text
|
|
9
|
+
|
|
10
|
+
from persistence.postgres import Base, JSONType
|
|
11
|
+
|
|
12
|
+
now = lambda: datetime.now(UTC)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitHubAppInstallation(Base):
|
|
16
|
+
"""GitHub App installation tracking."""
|
|
17
|
+
|
|
18
|
+
__tablename__ = "github_app_installations"
|
|
19
|
+
|
|
20
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
21
|
+
installation_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False, index=True)
|
|
22
|
+
account_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
23
|
+
account_login: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
|
24
|
+
account_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
25
|
+
app_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
26
|
+
installation_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
27
|
+
token_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
28
|
+
repositories: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONType, nullable=True)
|
|
29
|
+
permissions: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONType, nullable=True)
|
|
30
|
+
events: Mapped[Optional[list[str]]] = mapped_column(JSONType, nullable=True)
|
|
31
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
32
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
|
|
33
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
34
|
+
DateTime,
|
|
35
|
+
default=now,
|
|
36
|
+
onupdate=now,
|
|
37
|
+
nullable=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GitHubWebhookRegistration(Base):
|
|
42
|
+
"""Webhook registration tracking per installation."""
|
|
43
|
+
|
|
44
|
+
__tablename__ = "github_webhook_registrations"
|
|
45
|
+
|
|
46
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
47
|
+
webhook_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
48
|
+
installation_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
49
|
+
target_repo: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
|
|
50
|
+
events: Mapped[Optional[list[str]]] = mapped_column(JSONType, nullable=True)
|
|
51
|
+
secret: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
52
|
+
webhook_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
53
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
54
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
|
|
55
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
56
|
+
DateTime,
|
|
57
|
+
default=now,
|
|
58
|
+
onupdate=now,
|
|
59
|
+
nullable=False,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LabelFlowMapping(Base):
|
|
64
|
+
"""Maps GitHub labels to CrewAI flow names."""
|
|
65
|
+
|
|
66
|
+
__tablename__ = "label_flow_mappings"
|
|
67
|
+
|
|
68
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
69
|
+
installation_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
70
|
+
label_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
|
71
|
+
flow_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
72
|
+
repo_pattern: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
73
|
+
priority: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
74
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
75
|
+
config: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONType, nullable=True)
|
|
76
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
|
|
77
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
78
|
+
DateTime,
|
|
79
|
+
default=now,
|
|
80
|
+
onupdate=now,
|
|
81
|
+
nullable=False,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class FlowExecution(Base):
|
|
86
|
+
"""Flow execution tracking per installation."""
|
|
87
|
+
|
|
88
|
+
__tablename__ = "flow_executions"
|
|
89
|
+
|
|
90
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
91
|
+
installation_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
92
|
+
repo_slug: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
|
|
93
|
+
issue_number: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
94
|
+
flow_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
|
95
|
+
trigger_label: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
96
|
+
status: Mapped[str] = mapped_column(String(50), default="pending", nullable=False, index=True)
|
|
97
|
+
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
98
|
+
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
99
|
+
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
100
|
+
flow_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONType, nullable=True)
|
|
101
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
|
|
102
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
103
|
+
DateTime,
|
|
104
|
+
default=now,
|
|
105
|
+
onupdate=now,
|
|
106
|
+
nullable=False,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
__table_args__ = (
|
|
110
|
+
Index("idx_flow_executions_installation", "installation_id"),
|
|
111
|
+
Index("idx_flow_executions_status", "status"),
|
|
112
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""GitHub App webhook handler - integrates with FlowRunner and existing system."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException, Request
|
|
10
|
+
|
|
11
|
+
from github_app.auth import GitHubAppAuth
|
|
12
|
+
from github_app.installation_manager import InstallationManager
|
|
13
|
+
from github_app.label_mapper import LabelFlowMapper
|
|
14
|
+
from project_manager.flow_runner import FlowRunner
|
|
15
|
+
from project_manager.github import GitHubProjectManager
|
|
16
|
+
from project_manager.types import ProjectConfig, StatusMapping
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitHubAppWebhookHandler:
|
|
22
|
+
"""Handles GitHub webhooks for GitHub App installations.
|
|
23
|
+
|
|
24
|
+
Integrates with existing system:
|
|
25
|
+
- Uses FlowRunner for concurrent flow management
|
|
26
|
+
- Delegates to existing Celery tasks (process_github_webhook_task, kickoff_task)
|
|
27
|
+
- Triggers CrewAI flows (ralph, etc.)
|
|
28
|
+
- Works with existing GitHubProjectManager
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
github_auth: GitHubAppAuth,
|
|
34
|
+
installation_manager: InstallationManager,
|
|
35
|
+
label_mapper: LabelFlowMapper,
|
|
36
|
+
webhook_secret: Optional[str] = None,
|
|
37
|
+
):
|
|
38
|
+
self.github_auth = github_auth
|
|
39
|
+
self.installation_manager = installation_manager
|
|
40
|
+
self.label_mapper = label_mapper
|
|
41
|
+
self.webhook_secret = webhook_secret
|
|
42
|
+
|
|
43
|
+
def validate_signature(self, payload: bytes, signature: str) -> bool:
|
|
44
|
+
"""Validate GitHub webhook signature (from project_manager/webhook.py)."""
|
|
45
|
+
if not self.webhook_secret:
|
|
46
|
+
logger.warning("No webhook secret configured, skipping signature validation")
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
if not signature.startswith("sha256="):
|
|
50
|
+
logger.error("Invalid signature format")
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
expected_signature = signature[7:]
|
|
54
|
+
|
|
55
|
+
mac = hmac.new(self.webhook_secret.encode(), msg=payload, digestmod=hashlib.sha256)
|
|
56
|
+
computed_signature = mac.hexdigest()
|
|
57
|
+
|
|
58
|
+
return hmac.compare_digest(computed_signature, expected_signature)
|
|
59
|
+
|
|
60
|
+
async def handle_webhook(self, request: Request) -> Dict[str, Any]:
|
|
61
|
+
"""Main webhook entry point."""
|
|
62
|
+
payload_bytes = await request.body()
|
|
63
|
+
payload_str = payload_bytes.decode("utf-8")
|
|
64
|
+
payload = json.loads(payload_str)
|
|
65
|
+
|
|
66
|
+
event_type = request.headers.get("X-GitHub-Event")
|
|
67
|
+
signature = request.headers.get("X-Hub-Signature-256", "")
|
|
68
|
+
delivery_id = request.headers.get("X-GitHub-Delivery", "")
|
|
69
|
+
|
|
70
|
+
logger.info(f"Received webhook: {event_type} (delivery: {delivery_id})")
|
|
71
|
+
|
|
72
|
+
if event_type == "ping":
|
|
73
|
+
return self._handle_ping(payload)
|
|
74
|
+
|
|
75
|
+
installation_id = self._extract_installation_id(payload)
|
|
76
|
+
if not installation_id:
|
|
77
|
+
raise HTTPException(status_code=400, detail="Missing installation ID")
|
|
78
|
+
|
|
79
|
+
if self.webhook_secret and signature:
|
|
80
|
+
if not self.validate_signature(payload_bytes, signature):
|
|
81
|
+
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
|
82
|
+
|
|
83
|
+
if event_type == "installation":
|
|
84
|
+
return await self._handle_installation_event(payload)
|
|
85
|
+
elif event_type == "issues":
|
|
86
|
+
return await self._handle_issue_event(installation_id, payload)
|
|
87
|
+
else:
|
|
88
|
+
logger.info(f"Unhandled event type: {event_type}")
|
|
89
|
+
return {"status": "ignored", "event": event_type}
|
|
90
|
+
|
|
91
|
+
def _extract_installation_id(self, payload: Dict[str, Any]) -> Optional[int]:
|
|
92
|
+
"""Extract installation ID from payload."""
|
|
93
|
+
if "installation" in payload:
|
|
94
|
+
return payload["installation"].get("id")
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def _handle_ping(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
98
|
+
"""Handle ping event (from project_manager/webhook.py)."""
|
|
99
|
+
logger.info("Received ping from GitHub")
|
|
100
|
+
logger.info(f" Zen: {payload.get('zen')}")
|
|
101
|
+
|
|
102
|
+
hook = payload.get("hook", {})
|
|
103
|
+
if hook:
|
|
104
|
+
events = hook.get("events", [])
|
|
105
|
+
logger.info(f" Events: {events}")
|
|
106
|
+
logger.info(f" URL: {hook.get('config', {}).get('url')}")
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"status": "pong",
|
|
110
|
+
"zen": payload.get("zen"),
|
|
111
|
+
"hook_id": payload.get("hook_id"),
|
|
112
|
+
"events": hook.get("events", []) if hook else [],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async def _handle_installation_event(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
116
|
+
"""Handle installation lifecycle events."""
|
|
117
|
+
action = payload.get("action")
|
|
118
|
+
installation = payload.get("installation", {})
|
|
119
|
+
|
|
120
|
+
if action == "created":
|
|
121
|
+
self.installation_manager.register_installation(installation)
|
|
122
|
+
logger.info(f"Installation created: {installation.get('id')}")
|
|
123
|
+
elif action == "deleted":
|
|
124
|
+
installation_id = installation.get("id")
|
|
125
|
+
if installation_id:
|
|
126
|
+
self.installation_manager.deactivate_installation(installation_id)
|
|
127
|
+
logger.info(f"Installation deleted: {installation_id}")
|
|
128
|
+
|
|
129
|
+
return {"status": "processed", "action": action}
|
|
130
|
+
|
|
131
|
+
async def _handle_issue_event(self, installation_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
132
|
+
"""Handle issue events - uses FlowRunner for concurrent flow management.
|
|
133
|
+
|
|
134
|
+
This method:
|
|
135
|
+
1. Gets installation token and creates per-repo GitHubProjectManager
|
|
136
|
+
2. Creates FlowRunner to check if flow is already running
|
|
137
|
+
3. Delegates to existing process_github_webhook_task if flow can start
|
|
138
|
+
"""
|
|
139
|
+
action = payload.get("action")
|
|
140
|
+
issue = payload.get("issue", {})
|
|
141
|
+
repo = payload.get("repository", {})
|
|
142
|
+
repo_slug = repo.get("full_name")
|
|
143
|
+
issue_number = issue.get("number")
|
|
144
|
+
|
|
145
|
+
logger.info(f"Processing issue event: {action} on {repo_slug}#{issue_number} (installation: {installation_id})")
|
|
146
|
+
|
|
147
|
+
if action not in ["opened", "reopened", "labeled"]:
|
|
148
|
+
return {
|
|
149
|
+
"status": "ignored",
|
|
150
|
+
"reason": f"action '{action}' not handled",
|
|
151
|
+
"issue": issue_number,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Get installation token
|
|
155
|
+
token = self.installation_manager.get_installation_token(installation_id)
|
|
156
|
+
if not token:
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=500,
|
|
159
|
+
detail=f"Failed to get installation token for {installation_id}",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Create per-repo project manager and flow runner
|
|
163
|
+
try:
|
|
164
|
+
owner, name = repo_slug.split("/", 1)
|
|
165
|
+
|
|
166
|
+
config = ProjectConfig(
|
|
167
|
+
provider="github",
|
|
168
|
+
repo_owner=owner,
|
|
169
|
+
repo_name=name,
|
|
170
|
+
project_identifier=None,
|
|
171
|
+
token=token,
|
|
172
|
+
status_mapping=StatusMapping(),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
manager = GitHubProjectManager(config)
|
|
176
|
+
flow_runner = FlowRunner(manager=manager)
|
|
177
|
+
|
|
178
|
+
# Check if flow is already running for this repo
|
|
179
|
+
if flow_runner.is_flow_running():
|
|
180
|
+
current = flow_runner.get_running_flow()
|
|
181
|
+
return {
|
|
182
|
+
"status": "already_running",
|
|
183
|
+
"message": (
|
|
184
|
+
f"Flow already running for issue #{current.issue_number}" if current else "Flow already running"
|
|
185
|
+
),
|
|
186
|
+
"repo": repo_slug,
|
|
187
|
+
"issue": issue_number,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
import traceback
|
|
192
|
+
|
|
193
|
+
traceback.print_exc()
|
|
194
|
+
logger.error(f"Failed to create project manager for {repo_slug}: {e}")
|
|
195
|
+
# Fall back to delegating without flow runner check
|
|
196
|
+
return await self._delegate_to_celery(payload, installation_id)
|
|
197
|
+
|
|
198
|
+
# Delegate to existing Celery task which handles all the logic
|
|
199
|
+
return await self._delegate_to_celery(payload, installation_id)
|
|
200
|
+
|
|
201
|
+
async def _delegate_to_celery(self, payload: Dict[str, Any], installation_id: int) -> Dict[str, Any]:
|
|
202
|
+
"""Delegate to existing process_github_webhook_task."""
|
|
203
|
+
from tasks.tasks import process_github_webhook_task
|
|
204
|
+
|
|
205
|
+
# Add installation context to payload
|
|
206
|
+
payload["installation_id"] = installation_id
|
|
207
|
+
|
|
208
|
+
# Use existing webhook processing task
|
|
209
|
+
result = process_github_webhook_task.delay(payload) # type: ignore
|
|
210
|
+
|
|
211
|
+
logger.info(f"Delegated to process_github_webhook_task: {result.id}")
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"status": "queued",
|
|
215
|
+
"task_id": result.id,
|
|
216
|
+
"installation_id": installation_id,
|
|
217
|
+
}
|
persistence/__init__.py
ADDED
persistence/config.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PersistenceSettings(BaseSettings):
|
|
5
|
+
|
|
6
|
+
# Database Configuration
|
|
7
|
+
DATABASE_URL: str = "sqlite:///polycode.db"
|
|
8
|
+
|
|
9
|
+
model_config = SettingsConfigDict(extra="ignore", env_file=".env", case_sensitive=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
settings = PersistenceSettings() # pyright:ignore
|