beadhub 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.
- beadhub/__init__.py +12 -0
- beadhub/api.py +260 -0
- beadhub/auth.py +101 -0
- beadhub/aweb_context.py +65 -0
- beadhub/aweb_introspection.py +70 -0
- beadhub/beads_sync.py +514 -0
- beadhub/cli.py +330 -0
- beadhub/config.py +65 -0
- beadhub/db.py +129 -0
- beadhub/defaults/invariants/01-tracking-bdh-only.md +11 -0
- beadhub/defaults/invariants/02-communication-mail-first.md +36 -0
- beadhub/defaults/invariants/03-communication-chat.md +60 -0
- beadhub/defaults/invariants/04-identity-no-impersonation.md +17 -0
- beadhub/defaults/invariants/05-collaborate.md +12 -0
- beadhub/defaults/roles/backend.md +55 -0
- beadhub/defaults/roles/coordinator.md +44 -0
- beadhub/defaults/roles/frontend.md +77 -0
- beadhub/defaults/roles/implementer.md +73 -0
- beadhub/defaults/roles/reviewer.md +56 -0
- beadhub/defaults/roles/startup-expert.md +93 -0
- beadhub/defaults.py +262 -0
- beadhub/events.py +704 -0
- beadhub/internal_auth.py +121 -0
- beadhub/jsonl.py +68 -0
- beadhub/logging.py +62 -0
- beadhub/migrations/beads/001_initial.sql +70 -0
- beadhub/migrations/beads/002_search_indexes.sql +20 -0
- beadhub/migrations/server/001_initial.sql +279 -0
- beadhub/names.py +33 -0
- beadhub/notifications.py +275 -0
- beadhub/pagination.py +125 -0
- beadhub/presence.py +495 -0
- beadhub/rate_limit.py +152 -0
- beadhub/redis_client.py +11 -0
- beadhub/roles.py +35 -0
- beadhub/routes/__init__.py +1 -0
- beadhub/routes/agents.py +303 -0
- beadhub/routes/bdh.py +655 -0
- beadhub/routes/beads.py +778 -0
- beadhub/routes/claims.py +141 -0
- beadhub/routes/escalations.py +471 -0
- beadhub/routes/init.py +348 -0
- beadhub/routes/mcp.py +338 -0
- beadhub/routes/policies.py +833 -0
- beadhub/routes/repos.py +538 -0
- beadhub/routes/status.py +568 -0
- beadhub/routes/subscriptions.py +362 -0
- beadhub/routes/workspaces.py +1642 -0
- beadhub/workspace_config.py +202 -0
- beadhub-0.1.0.dist-info/METADATA +254 -0
- beadhub-0.1.0.dist-info/RECORD +54 -0
- beadhub-0.1.0.dist-info/WHEEL +4 -0
- beadhub-0.1.0.dist-info/entry_points.txt +2 -0
- beadhub-0.1.0.dist-info/licenses/LICENSE +21 -0
beadhub/beads_sync.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from pgdbm import AsyncDatabaseManager
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Bead ID format: alphanumeric with common separators, 1-100 chars
|
|
15
|
+
# Examples: bd-abc123, myproject-xyz, issue-42, pgdbm-4uv.16
|
|
16
|
+
BEAD_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,99}$")
|
|
17
|
+
|
|
18
|
+
# Git branch name pattern: alphanumeric with common separators, 1-255 chars
|
|
19
|
+
# Examples: main, feature/new-ui, release/v1.0.0, bugfix/issue-123
|
|
20
|
+
BRANCH_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9/_.-]{0,254}$")
|
|
21
|
+
|
|
22
|
+
# Canonical origin pattern: domain/path format like "github.com/org/repo"
|
|
23
|
+
# Allows alphanumeric, dots, hyphens, underscores, and forward slashes.
|
|
24
|
+
# Each path segment must start with alphanumeric (prevents ".." traversal).
|
|
25
|
+
# Max length 255 (checked in validator function).
|
|
26
|
+
# Examples: github.com/org/repo, gitlab.example.com/team/project
|
|
27
|
+
CANONICAL_ORIGIN_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*(/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$")
|
|
28
|
+
|
|
29
|
+
# Alias pattern: alphanumeric with hyphens/underscores, 1-64 chars
|
|
30
|
+
# Examples: frontend-bot, backend_agent, claude-code-1
|
|
31
|
+
ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
|
|
32
|
+
|
|
33
|
+
# Human name pattern: letters with spaces, hyphens, apostrophes, 1-64 chars
|
|
34
|
+
# Examples: Juan, O'Brien, Mary Jane, Jean-Pierre
|
|
35
|
+
HUMAN_NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9 '\-]{0,63}$")
|
|
36
|
+
|
|
37
|
+
# Default values for repo and branch when not specified
|
|
38
|
+
DEFAULT_REPO = "default"
|
|
39
|
+
DEFAULT_BRANCH = "main"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_valid_bead_id(bead_id: str) -> bool:
|
|
43
|
+
"""Check if bead ID matches expected format."""
|
|
44
|
+
if not bead_id or not isinstance(bead_id, str):
|
|
45
|
+
return False
|
|
46
|
+
return BEAD_ID_PATTERN.match(bead_id) is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_valid_branch_name(branch: str) -> bool:
|
|
50
|
+
"""Check if branch name matches expected Git branch format."""
|
|
51
|
+
if not branch or not isinstance(branch, str):
|
|
52
|
+
return False
|
|
53
|
+
return BRANCH_NAME_PATTERN.match(branch) is not None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_valid_canonical_origin(origin: str) -> bool:
|
|
57
|
+
"""Check if canonical origin matches expected format (e.g., github.com/org/repo)."""
|
|
58
|
+
if not origin or not isinstance(origin, str):
|
|
59
|
+
return False
|
|
60
|
+
if len(origin) > 255:
|
|
61
|
+
return False
|
|
62
|
+
return CANONICAL_ORIGIN_PATTERN.match(origin) is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_valid_alias(alias: str) -> bool:
|
|
66
|
+
"""Check if alias matches expected format."""
|
|
67
|
+
if not alias or not isinstance(alias, str):
|
|
68
|
+
return False
|
|
69
|
+
return ALIAS_PATTERN.match(alias) is not None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_valid_human_name(name: str) -> bool:
|
|
73
|
+
"""Check if human name matches expected format."""
|
|
74
|
+
if not name or not isinstance(name, str):
|
|
75
|
+
return False
|
|
76
|
+
return HUMAN_NAME_PATTERN.match(name) is not None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class BeadStatusChange:
|
|
81
|
+
"""Represents a status change for notification purposes."""
|
|
82
|
+
|
|
83
|
+
bead_id: str
|
|
84
|
+
repo: Optional[str]
|
|
85
|
+
branch: Optional[str]
|
|
86
|
+
old_status: Optional[str]
|
|
87
|
+
new_status: str
|
|
88
|
+
title: Optional[str] = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class BeadsSyncResult:
|
|
93
|
+
issues_synced: int
|
|
94
|
+
issues_added: int
|
|
95
|
+
issues_updated: int
|
|
96
|
+
synced_at: str
|
|
97
|
+
repo: Optional[str] = None
|
|
98
|
+
branch: Optional[str] = None
|
|
99
|
+
status_changes: List["BeadStatusChange"] = field(default_factory=list)
|
|
100
|
+
conflicts: List[str] = field(default_factory=list) # bead IDs that had stale updates
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def conflicts_count(self) -> int:
|
|
104
|
+
return len(self.conflicts)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_timestamp(value: Optional[str]) -> Optional[datetime]:
|
|
108
|
+
if not value:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return datetime.fromisoformat(value)
|
|
112
|
+
except ValueError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_issues_from_list(issues_list: List[Any]) -> Dict[str, dict[str, Any]]:
|
|
117
|
+
"""
|
|
118
|
+
Validate issues from a list (e.g., from an upload payload).
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
issues_list: List of issue dictionaries
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Dictionary mapping issue IDs to validated issue records.
|
|
125
|
+
Invalid issues are skipped with a warning.
|
|
126
|
+
"""
|
|
127
|
+
issues: Dict[str, dict[str, Any]] = {}
|
|
128
|
+
|
|
129
|
+
for idx, record in enumerate(issues_list):
|
|
130
|
+
if not isinstance(record, dict):
|
|
131
|
+
logger.warning("Skipping non-dict issue at index %d", idx)
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
issue_id = record.get("id")
|
|
135
|
+
if not issue_id:
|
|
136
|
+
logger.warning("Skipping record without 'id' at index %d", idx)
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if not is_valid_bead_id(issue_id):
|
|
140
|
+
logger.warning(
|
|
141
|
+
"Skipping record with invalid bead ID '%s' at index %d",
|
|
142
|
+
issue_id[:50] if isinstance(issue_id, str) else str(issue_id)[:50],
|
|
143
|
+
idx,
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
issues[issue_id] = record
|
|
148
|
+
|
|
149
|
+
return issues
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_dependency_ref(
|
|
153
|
+
depends_on: str, default_repo: str, default_branch: str
|
|
154
|
+
) -> Optional[dict]:
|
|
155
|
+
"""Parse a dependency reference into {repo, branch, bead_id}.
|
|
156
|
+
|
|
157
|
+
If depends_on contains ':', treat as cross-repo ref (repo:bead_id).
|
|
158
|
+
Otherwise, use the default repo/branch from current sync context.
|
|
159
|
+
|
|
160
|
+
Returns None if the reference is malformed (empty repo, invalid bead_id).
|
|
161
|
+
"""
|
|
162
|
+
depends_on = depends_on.strip()
|
|
163
|
+
if not depends_on:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
if ":" in depends_on:
|
|
167
|
+
# Cross-repo reference like "other-repo:bd-123"
|
|
168
|
+
# Use default branch since cross-repo refs don't specify branch
|
|
169
|
+
ref_repo, ref_bead_id = depends_on.split(":", 1)
|
|
170
|
+
ref_repo = ref_repo.strip()
|
|
171
|
+
ref_bead_id = ref_bead_id.strip()
|
|
172
|
+
if not ref_repo or not is_valid_bead_id(ref_bead_id):
|
|
173
|
+
logger.warning("Malformed cross-repo dependency ref: %r", depends_on)
|
|
174
|
+
return None
|
|
175
|
+
if not is_valid_canonical_origin(ref_repo):
|
|
176
|
+
logger.warning("Invalid repo in cross-repo dependency ref: %r", ref_repo)
|
|
177
|
+
return None
|
|
178
|
+
return {"repo": ref_repo, "branch": default_branch, "bead_id": ref_bead_id}
|
|
179
|
+
else:
|
|
180
|
+
# Same-repo reference
|
|
181
|
+
if not is_valid_bead_id(depends_on):
|
|
182
|
+
logger.warning("Invalid bead ID in dependency ref: %r", depends_on)
|
|
183
|
+
return None
|
|
184
|
+
return {"repo": default_repo, "branch": default_branch, "bead_id": depends_on}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_structured_ref(item: dict, default_repo: str, default_branch: str) -> Optional[dict]:
|
|
188
|
+
"""Parse a structured blocked_by dict into {repo, branch, bead_id}.
|
|
189
|
+
|
|
190
|
+
Accepts: {"repo": "...", "branch": "...", "bead_id": "..."}
|
|
191
|
+
repo and branch are optional and default to the sync context values.
|
|
192
|
+
bead_id is required and must be valid.
|
|
193
|
+
|
|
194
|
+
Returns None if bead_id is missing/invalid, or if repo/branch are invalid.
|
|
195
|
+
"""
|
|
196
|
+
bead_id = item.get("bead_id")
|
|
197
|
+
if not bead_id or not is_valid_bead_id(bead_id):
|
|
198
|
+
if bead_id:
|
|
199
|
+
logger.warning("Invalid bead_id in structured blocked_by: %r", bead_id)
|
|
200
|
+
else:
|
|
201
|
+
logger.warning("Missing bead_id in structured blocked_by: %r", item)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
# Validate repo if provided
|
|
205
|
+
repo = item.get("repo")
|
|
206
|
+
if repo and not is_valid_canonical_origin(repo):
|
|
207
|
+
logger.warning("Invalid repo in structured blocked_by: %r", repo)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
# Validate branch if provided
|
|
211
|
+
branch = item.get("branch")
|
|
212
|
+
if branch and not is_valid_branch_name(branch):
|
|
213
|
+
logger.warning("Invalid branch name in structured blocked_by: %r", branch)
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"repo": repo or default_repo,
|
|
218
|
+
"branch": branch or default_branch,
|
|
219
|
+
"bead_id": bead_id,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_blocked_by_array(
|
|
224
|
+
blocked_by: Optional[list], default_repo: str, default_branch: str
|
|
225
|
+
) -> List[dict]:
|
|
226
|
+
"""Parse a blocked_by array into structured refs.
|
|
227
|
+
|
|
228
|
+
Accepts two formats:
|
|
229
|
+
1. Structured dicts: [{"repo": "...", "branch": "...", "bead_id": "..."}]
|
|
230
|
+
- repo and branch are optional, default to sync context
|
|
231
|
+
- bead_id is required
|
|
232
|
+
2. Simple strings: ["bd-001", "other-repo:bd-002"]
|
|
233
|
+
- Same-repo refs use defaults
|
|
234
|
+
- Cross-repo refs use "repo:bead_id" format
|
|
235
|
+
|
|
236
|
+
Returns list of {repo, branch, bead_id} dicts.
|
|
237
|
+
Invalid entries are skipped with a warning.
|
|
238
|
+
"""
|
|
239
|
+
if not blocked_by:
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
refs = []
|
|
243
|
+
for item in blocked_by:
|
|
244
|
+
if isinstance(item, dict):
|
|
245
|
+
ref = _parse_structured_ref(item, default_repo, default_branch)
|
|
246
|
+
elif isinstance(item, str):
|
|
247
|
+
ref = _parse_dependency_ref(item, default_repo, default_branch)
|
|
248
|
+
else:
|
|
249
|
+
logger.warning("Unexpected type in blocked_by array: %r", type(item).__name__)
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
if ref is not None:
|
|
253
|
+
refs.append(ref)
|
|
254
|
+
return refs
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def _sync_issues_to_db(
|
|
258
|
+
issues: Dict[str, dict],
|
|
259
|
+
db: AsyncDatabaseManager,
|
|
260
|
+
project_id: str,
|
|
261
|
+
repo: str = DEFAULT_REPO,
|
|
262
|
+
branch: str = DEFAULT_BRANCH,
|
|
263
|
+
) -> BeadsSyncResult:
|
|
264
|
+
"""
|
|
265
|
+
Sync parsed issues to the database.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
issues: Dictionary mapping issue IDs to issue records
|
|
269
|
+
db: Database manager for beads schema
|
|
270
|
+
project_id: UUID of the project (tenant isolation)
|
|
271
|
+
repo: Canonical origin for this sync (e.g., 'github.com/org/repo')
|
|
272
|
+
branch: Git branch name for this sync (default: 'main')
|
|
273
|
+
"""
|
|
274
|
+
now = datetime.now(timezone.utc)
|
|
275
|
+
issues_added = 0
|
|
276
|
+
issues_updated = 0
|
|
277
|
+
status_changes: List[BeadStatusChange] = []
|
|
278
|
+
conflicts: List[str] = [] # bead IDs with stale updates
|
|
279
|
+
|
|
280
|
+
async with db.transaction() as tx:
|
|
281
|
+
for bead_id, issue in issues.items():
|
|
282
|
+
status = issue.get("status")
|
|
283
|
+
title = issue.get("title")
|
|
284
|
+
description = issue.get("description")
|
|
285
|
+
priority = issue.get("priority")
|
|
286
|
+
issue_type = issue.get("issue_type")
|
|
287
|
+
assignee = issue.get("assignee")
|
|
288
|
+
created_by = issue.get("created_by")
|
|
289
|
+
if created_by is not None and not isinstance(created_by, str):
|
|
290
|
+
created_by = str(created_by)
|
|
291
|
+
if isinstance(created_by, str):
|
|
292
|
+
created_by = created_by.strip() or None
|
|
293
|
+
if created_by and len(created_by) > 255:
|
|
294
|
+
logger.warning(
|
|
295
|
+
"Truncating created_by for %s (len=%d)", bead_id, len(created_by)
|
|
296
|
+
)
|
|
297
|
+
created_by = created_by[:255]
|
|
298
|
+
labels = issue.get("labels") or None
|
|
299
|
+
|
|
300
|
+
created_at = _parse_timestamp(issue.get("created_at"))
|
|
301
|
+
updated_at = _parse_timestamp(issue.get("updated_at"))
|
|
302
|
+
|
|
303
|
+
deps = issue.get("dependencies") or []
|
|
304
|
+
parent_id: Optional[dict] = None
|
|
305
|
+
|
|
306
|
+
# Support simple blocked_by array from spec: ["bd-001", "bd-002"]
|
|
307
|
+
simple_blocked_by = issue.get("blocked_by")
|
|
308
|
+
blocked_by: List[dict] = parse_blocked_by_array(simple_blocked_by, repo, branch)
|
|
309
|
+
|
|
310
|
+
# Also process structured dependencies format
|
|
311
|
+
for dep in deps:
|
|
312
|
+
dep_type = dep.get("type")
|
|
313
|
+
depends_on = dep.get("depends_on_id")
|
|
314
|
+
if not depends_on:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
ref = _parse_dependency_ref(depends_on, repo, branch)
|
|
318
|
+
if ref is None:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
if dep_type == "parent-child":
|
|
322
|
+
if parent_id is None:
|
|
323
|
+
parent_id = ref
|
|
324
|
+
continue # parent-child is not a blocking relationship
|
|
325
|
+
|
|
326
|
+
if dep_type != "blocks":
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
target = issues.get(depends_on)
|
|
330
|
+
target_status = target.get("status") if target else None
|
|
331
|
+
if target_status != "closed":
|
|
332
|
+
blocked_by.append(ref)
|
|
333
|
+
|
|
334
|
+
existing = await tx.fetch_one(
|
|
335
|
+
"""
|
|
336
|
+
SELECT bead_id, status, updated_at FROM {{tables.beads_issues}}
|
|
337
|
+
WHERE project_id = $1 AND bead_id = $2 AND repo = $3 AND branch = $4
|
|
338
|
+
FOR UPDATE
|
|
339
|
+
""",
|
|
340
|
+
project_id,
|
|
341
|
+
bead_id,
|
|
342
|
+
repo,
|
|
343
|
+
branch,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if existing is None:
|
|
347
|
+
issues_added += 1
|
|
348
|
+
# New issue - track as status change from None to current status
|
|
349
|
+
if status:
|
|
350
|
+
status_changes.append(
|
|
351
|
+
BeadStatusChange(
|
|
352
|
+
bead_id=bead_id,
|
|
353
|
+
repo=repo,
|
|
354
|
+
branch=branch,
|
|
355
|
+
old_status=None,
|
|
356
|
+
new_status=status,
|
|
357
|
+
title=title,
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
# Optimistic locking: check if incoming update is stale
|
|
362
|
+
db_updated_at = existing.get("updated_at")
|
|
363
|
+
if updated_at is not None and db_updated_at is not None:
|
|
364
|
+
# Both have timestamps - compare them
|
|
365
|
+
if updated_at < db_updated_at:
|
|
366
|
+
# Stale update - skip and record conflict
|
|
367
|
+
logger.info(
|
|
368
|
+
"Stale update detected for %s: incoming %s < DB %s",
|
|
369
|
+
bead_id,
|
|
370
|
+
updated_at.isoformat(),
|
|
371
|
+
db_updated_at.isoformat(),
|
|
372
|
+
)
|
|
373
|
+
conflicts.append(bead_id)
|
|
374
|
+
continue # Skip this issue, don't update
|
|
375
|
+
|
|
376
|
+
issues_updated += 1
|
|
377
|
+
# Check if status changed
|
|
378
|
+
old_status = existing.get("status")
|
|
379
|
+
if old_status != status and status:
|
|
380
|
+
status_changes.append(
|
|
381
|
+
BeadStatusChange(
|
|
382
|
+
bead_id=bead_id,
|
|
383
|
+
repo=repo,
|
|
384
|
+
branch=branch,
|
|
385
|
+
old_status=old_status,
|
|
386
|
+
new_status=status,
|
|
387
|
+
title=title,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
await tx.execute(
|
|
392
|
+
"""
|
|
393
|
+
INSERT INTO {{tables.beads_issues}} (
|
|
394
|
+
project_id,
|
|
395
|
+
bead_id,
|
|
396
|
+
repo,
|
|
397
|
+
branch,
|
|
398
|
+
title,
|
|
399
|
+
description,
|
|
400
|
+
status,
|
|
401
|
+
priority,
|
|
402
|
+
issue_type,
|
|
403
|
+
assignee,
|
|
404
|
+
created_by,
|
|
405
|
+
labels,
|
|
406
|
+
blocked_by,
|
|
407
|
+
parent_id,
|
|
408
|
+
created_at,
|
|
409
|
+
updated_at,
|
|
410
|
+
synced_at
|
|
411
|
+
)
|
|
412
|
+
VALUES (
|
|
413
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
|
414
|
+
$11, $12, $13, $14, $15, $16, $17
|
|
415
|
+
)
|
|
416
|
+
ON CONFLICT (project_id, repo, branch, bead_id) DO UPDATE SET
|
|
417
|
+
title = EXCLUDED.title,
|
|
418
|
+
description = EXCLUDED.description,
|
|
419
|
+
status = EXCLUDED.status,
|
|
420
|
+
priority = EXCLUDED.priority,
|
|
421
|
+
issue_type = EXCLUDED.issue_type,
|
|
422
|
+
assignee = EXCLUDED.assignee,
|
|
423
|
+
created_by = COALESCE(EXCLUDED.created_by, {{tables.beads_issues}}.created_by),
|
|
424
|
+
labels = EXCLUDED.labels,
|
|
425
|
+
blocked_by = EXCLUDED.blocked_by,
|
|
426
|
+
parent_id = EXCLUDED.parent_id,
|
|
427
|
+
created_at = EXCLUDED.created_at,
|
|
428
|
+
updated_at = EXCLUDED.updated_at,
|
|
429
|
+
synced_at = EXCLUDED.synced_at
|
|
430
|
+
""",
|
|
431
|
+
project_id,
|
|
432
|
+
bead_id,
|
|
433
|
+
repo,
|
|
434
|
+
branch,
|
|
435
|
+
title,
|
|
436
|
+
description,
|
|
437
|
+
status,
|
|
438
|
+
priority,
|
|
439
|
+
issue_type,
|
|
440
|
+
assignee,
|
|
441
|
+
created_by,
|
|
442
|
+
labels,
|
|
443
|
+
# asyncpg requires JSON strings for JSONB columns (no auto-serialization)
|
|
444
|
+
json.dumps(blocked_by),
|
|
445
|
+
json.dumps(parent_id) if parent_id else None,
|
|
446
|
+
created_at,
|
|
447
|
+
updated_at,
|
|
448
|
+
now,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return BeadsSyncResult(
|
|
452
|
+
issues_synced=len(issues),
|
|
453
|
+
issues_added=issues_added,
|
|
454
|
+
issues_updated=issues_updated,
|
|
455
|
+
synced_at=now.isoformat(),
|
|
456
|
+
repo=repo,
|
|
457
|
+
branch=branch,
|
|
458
|
+
status_changes=status_changes,
|
|
459
|
+
conflicts=conflicts,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def delete_issues_by_id(
|
|
464
|
+
db: AsyncDatabaseManager,
|
|
465
|
+
project_id: str,
|
|
466
|
+
bead_ids: List[str],
|
|
467
|
+
repo: str = DEFAULT_REPO,
|
|
468
|
+
branch: str = DEFAULT_BRANCH,
|
|
469
|
+
) -> int:
|
|
470
|
+
"""
|
|
471
|
+
Delete issues by their IDs.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
db: Database manager for beads schema
|
|
475
|
+
project_id: UUID of the project (tenant isolation)
|
|
476
|
+
bead_ids: List of bead IDs to delete
|
|
477
|
+
repo: Canonical origin for this sync (e.g., 'github.com/org/repo')
|
|
478
|
+
branch: Git branch name for this sync
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Number of issues deleted
|
|
482
|
+
"""
|
|
483
|
+
if not bead_ids:
|
|
484
|
+
return 0
|
|
485
|
+
|
|
486
|
+
# Validate all bead IDs first
|
|
487
|
+
valid_ids = [bid for bid in bead_ids if is_valid_bead_id(bid)]
|
|
488
|
+
if len(valid_ids) != len(bead_ids):
|
|
489
|
+
invalid_count = len(bead_ids) - len(valid_ids)
|
|
490
|
+
logger.warning("Skipping %d invalid bead IDs in delete request", invalid_count)
|
|
491
|
+
|
|
492
|
+
if not valid_ids:
|
|
493
|
+
return 0
|
|
494
|
+
|
|
495
|
+
async with db.transaction() as tx:
|
|
496
|
+
# Delete issues matching the IDs
|
|
497
|
+
result = await tx.execute(
|
|
498
|
+
"""
|
|
499
|
+
DELETE FROM {{tables.beads_issues}}
|
|
500
|
+
WHERE project_id = $1
|
|
501
|
+
AND repo = $2
|
|
502
|
+
AND branch = $3
|
|
503
|
+
AND bead_id = ANY($4::text[])
|
|
504
|
+
""",
|
|
505
|
+
project_id,
|
|
506
|
+
repo,
|
|
507
|
+
branch,
|
|
508
|
+
valid_ids,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Parse the result to get count (asyncpg returns "DELETE N")
|
|
512
|
+
if result and result.startswith("DELETE "):
|
|
513
|
+
return int(result.split()[1])
|
|
514
|
+
return 0
|