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.
Files changed (54) hide show
  1. beadhub/__init__.py +12 -0
  2. beadhub/api.py +260 -0
  3. beadhub/auth.py +101 -0
  4. beadhub/aweb_context.py +65 -0
  5. beadhub/aweb_introspection.py +70 -0
  6. beadhub/beads_sync.py +514 -0
  7. beadhub/cli.py +330 -0
  8. beadhub/config.py +65 -0
  9. beadhub/db.py +129 -0
  10. beadhub/defaults/invariants/01-tracking-bdh-only.md +11 -0
  11. beadhub/defaults/invariants/02-communication-mail-first.md +36 -0
  12. beadhub/defaults/invariants/03-communication-chat.md +60 -0
  13. beadhub/defaults/invariants/04-identity-no-impersonation.md +17 -0
  14. beadhub/defaults/invariants/05-collaborate.md +12 -0
  15. beadhub/defaults/roles/backend.md +55 -0
  16. beadhub/defaults/roles/coordinator.md +44 -0
  17. beadhub/defaults/roles/frontend.md +77 -0
  18. beadhub/defaults/roles/implementer.md +73 -0
  19. beadhub/defaults/roles/reviewer.md +56 -0
  20. beadhub/defaults/roles/startup-expert.md +93 -0
  21. beadhub/defaults.py +262 -0
  22. beadhub/events.py +704 -0
  23. beadhub/internal_auth.py +121 -0
  24. beadhub/jsonl.py +68 -0
  25. beadhub/logging.py +62 -0
  26. beadhub/migrations/beads/001_initial.sql +70 -0
  27. beadhub/migrations/beads/002_search_indexes.sql +20 -0
  28. beadhub/migrations/server/001_initial.sql +279 -0
  29. beadhub/names.py +33 -0
  30. beadhub/notifications.py +275 -0
  31. beadhub/pagination.py +125 -0
  32. beadhub/presence.py +495 -0
  33. beadhub/rate_limit.py +152 -0
  34. beadhub/redis_client.py +11 -0
  35. beadhub/roles.py +35 -0
  36. beadhub/routes/__init__.py +1 -0
  37. beadhub/routes/agents.py +303 -0
  38. beadhub/routes/bdh.py +655 -0
  39. beadhub/routes/beads.py +778 -0
  40. beadhub/routes/claims.py +141 -0
  41. beadhub/routes/escalations.py +471 -0
  42. beadhub/routes/init.py +348 -0
  43. beadhub/routes/mcp.py +338 -0
  44. beadhub/routes/policies.py +833 -0
  45. beadhub/routes/repos.py +538 -0
  46. beadhub/routes/status.py +568 -0
  47. beadhub/routes/subscriptions.py +362 -0
  48. beadhub/routes/workspaces.py +1642 -0
  49. beadhub/workspace_config.py +202 -0
  50. beadhub-0.1.0.dist-info/METADATA +254 -0
  51. beadhub-0.1.0.dist-info/RECORD +54 -0
  52. beadhub-0.1.0.dist-info/WHEEL +4 -0
  53. beadhub-0.1.0.dist-info/entry_points.txt +2 -0
  54. 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