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,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
+ }
@@ -0,0 +1,5 @@
1
+ """Flow persistence implementations."""
2
+
3
+ from .postgres import PostgresFlowPersistence
4
+
5
+ __all__ = ["PostgresFlowPersistence"]
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