Skip to content

Commit 07e7e31

Browse files
authored
Merge branch 'main' into feat/bud-branch-overrides
2 parents c98e65e + cd3efc5 commit 07e7e31

20 files changed

Lines changed: 1266 additions & 140 deletions

README.md

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,11 @@ Self-hosted on your hardware. Your data stays local. Inference engine is your ch
2828

2929
### 📺 Watch the demo
3030

31-
<a href="https://youtu.be/ot-BmKxRgRA"><img src="https://img.youtube.com/vi/ot-BmKxRgRA/maxresdefault.jpg" width="48%" alt="Setup walkthrough"></a>
32-
&nbsp;
33-
<a href="https://youtu.be/i8kZdcL1bME"><img src="https://img.youtube.com/vi/i8kZdcL1bME/maxresdefault.jpg" width="48%" alt="Slack triage & MCP tools"></a>
31+
<a href="https://youtu.be/OxoqBI7BNxU"><img src="https://img.youtube.com/vi/OxoqBI7BNxU/maxresdefault.jpg" width="80%" alt="Inside the virtual world"></a>
3432

35-
<a href="https://youtu.be/YBwdTes0Fno"><img src="https://img.youtube.com/vi/YBwdTes0Fno/maxresdefault.jpg" width="48%" alt="Requirements & estimation"></a>
36-
&nbsp;
37-
<a href="https://youtu.be/lV71qhmfzzw"><img src="https://img.youtube.com/vi/lV71qhmfzzw/maxresdefault.jpg" width="48%" alt="Design phase & agent prompts"></a>
33+
**[▶ Inside the virtual world](https://youtu.be/OxoqBI7BNxU)** — the Living Tree, your org as a tended orchard.
3834

39-
<a href="https://youtu.be/YjRihN_SKaw"><img src="https://img.youtube.com/vi/YjRihN_SKaw/maxresdefault.jpg" width="48%" alt="Development & retrospective"></a>
40-
&nbsp;
41-
<a href="https://youtu.be/OxoqBI7BNxU"><img src="https://img.youtube.com/vi/OxoqBI7BNxU/maxresdefault.jpg" width="48%" alt="Inside the virtual world"></a>
42-
43-
**[Setup walkthrough](https://youtu.be/ot-BmKxRgRA)** &nbsp;·&nbsp; **[Slack triage & MCP tools](https://youtu.be/i8kZdcL1bME)** &nbsp;·&nbsp; **[Requirements & estimation](https://youtu.be/YBwdTes0Fno)** &nbsp;·&nbsp; **[Design phase & agent prompts](https://youtu.be/lV71qhmfzzw)** &nbsp;·&nbsp; **[Development & retrospective](https://youtu.be/YjRihN_SKaw)** &nbsp;·&nbsp; **[Inside the virtual world](https://youtu.be/OxoqBI7BNxU)**
35+
**More walkthroughs:** [Setup](https://youtu.be/ot-BmKxRgRA) &nbsp;·&nbsp; [Slack triage & MCP tools](https://youtu.be/i8kZdcL1bME) &nbsp;·&nbsp; [Requirements & estimation](https://youtu.be/YBwdTes0Fno) &nbsp;·&nbsp; [Design phase & agent prompts](https://youtu.be/lV71qhmfzzw) &nbsp;·&nbsp; [Development & retrospective](https://youtu.be/YjRihN_SKaw)
4436

4537
![Bodhiorchard Living Tree Dashboard](docs/images/livingtree.png)
4638

backend/app/api/v1/bud.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
from app.services.bud_assignment_actions import assign_bud, unassign_bud
9797
from app.services.bud_edit_policy import assert_section_editable
9898
from app.services.bud_estimation import estimate_bud_dates
99+
from app.services.bud_learning_alias_resolver import resolve_aliased_contributors
99100
from app.services.bud_timeline import record_event
100101
from app.services.job_queue import JOB_BUD_AGENT, create_job
101102

@@ -544,7 +545,18 @@ async def get_bud_learning(
544545
status_code=status.HTTP_404_NOT_FOUND,
545546
detail="No learning recorded for this BUD yet",
546547
)
547-
return BUDLearningRead.model_validate(learning)
548+
read = BUDLearningRead.model_validate(learning)
549+
# Apply the Settings → Members merge backlink to the stored
550+
# contributor snapshot so deactivated / "(external)" rows fold into
551+
# their currently-active target. The stored JSONB is left alone —
552+
# the resolver runs on every fetch so later alias changes apply
553+
# immediately without a backfill.
554+
if read.metrics and read.metrics.get("contributors"):
555+
contribs = await resolve_aliased_contributors(
556+
db, current_user.org_id, read.metrics["contributors"]
557+
)
558+
read = read.model_copy(update={"metrics": {**read.metrics, "contributors": contribs}})
559+
return read
548560

549561

550562
# Status transitions QA owns directly via PATCH. Matches the manual-testing

backend/app/api/v1/bud_prs.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
ReleaseTimelineEvent,
3636
)
3737
from app.schemas.pull_request import PRChecklistItem, PullRequestRead
38+
from app.services.pr_auto_transition import pr_references_bud
3839
from app.utils.branch_matching import branch_matches
3940

4041
router = APIRouter()
@@ -238,18 +239,31 @@ async def get_bud_release_stage(
238239
target_branch = bud_override or (
239240
(repo.uat_branch if typed_stage == "uat" else repo.main_branch) if repo else None
240241
)
241-
if target_branch and branch_matches(pr.base_branch, target_branch):
242-
seen_pr_ids.add(pr.github_pr_id)
243-
open_prs.append(
244-
ReleasePR(
245-
pr_number=pr.github_pr_number,
246-
repo_name=repo.name if repo else "",
247-
html_url=pr.html_url,
248-
title=pr.title,
249-
author_login=pr.author_github_login,
250-
merged_at=None,
251-
),
252-
)
242+
if not (target_branch and branch_matches(pr.base_branch, target_branch)):
243+
continue
244+
# Content guard for unlinked PRs (``bud_id IS NULL``): the
245+
# release-PR fallback in the repository surfaces every PR
246+
# targeting the stage branch on an impacted repo, but the
247+
# release-stage tab must only show PRs that reference THIS BUD.
248+
# ``pr_references_bud`` looks for ``bud-NNN`` in the head ref
249+
# or title, so a release branch like
250+
# ``release/bud-001-bud-004`` correctly appears on both BUD-001
251+
# and BUD-004 tabs, while an unrelated PR to ``main`` does not
252+
# leak through. Directly-linked PRs (``bud_id == bud_id``)
253+
# short-circuit so the user-edited link wins over text matching.
254+
if pr.bud_id != bud_id and not pr_references_bud(bud.bud_number, pr.head_branch, pr.title):
255+
continue
256+
seen_pr_ids.add(pr.github_pr_id)
257+
open_prs.append(
258+
ReleasePR(
259+
pr_number=pr.github_pr_number,
260+
repo_name=repo.name if repo else "",
261+
html_url=pr.html_url,
262+
title=pr.title,
263+
author_login=pr.author_github_login,
264+
merged_at=None,
265+
),
266+
)
253267

254268
return BUDReleaseStage(
255269
bud_id=str(bud_id),

backend/app/api/v1/learnings.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from fastapi import APIRouter, Depends
3030
from sqlalchemy.ext.asyncio import AsyncSession
3131

32-
from app.core.deps import get_current_user, get_db, require_permissions
32+
from app.core.deps import get_current_user, get_db
3333
from app.models.bud import BUDDocument
3434
from app.models.user import User
3535
from app.repositories.learnings_overview import LearningsOverviewRepository
@@ -188,10 +188,14 @@ def _build_top_contributors(
188188
]
189189

190190

191+
# Intentionally no permission gate: every org member sees the org-wide
192+
# retrospective. The response is aggregate-only (velocity buckets, phase
193+
# drift, contributor leaderboard) and the repo is scoped to
194+
# current_user.org_id. If you ever add admin-only fields to
195+
# LearningsOverviewRead, re-introduce a require_permissions dependency.
191196
@router.get(
192197
"/overview",
193198
response_model=LearningsOverviewRead,
194-
dependencies=[Depends(require_permissions("org:view_settings"))],
195199
)
196200
async def get_learnings_overview(
197201
current_user: User = Depends(get_current_user),

backend/app/mcp/handlers_hooks.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121

2222
import asyncio
2323
import os
24-
import re
2524
import uuid
2625

2726
import structlog
@@ -35,12 +34,15 @@
3534
from app.schemas.dev_activity import DevActivityHookRequest, DevActivityHookResponse
3635
from app.services.colyseus_bridge import publish_to_colyseus
3736
from app.services.event_bus import publish
37+
from app.services.pr_auto_transition import extract_bud_number
3838
from app.services.user_resolution import resolve_user_by_email
3939
from app.services.xp_service import check_and_award_streak
4040

4141
logger = structlog.get_logger(__name__)
4242

43-
_BUD_BRANCH_RE = re.compile(r"^bud-(\d+)/")
43+
# BUD number extraction is owned by ``pr_auto_transition.extract_bud_number``
44+
# so a single matcher governs PR linking, the dev-activity hook below, and any
45+
# future reuse — keeping branch heuristics from drifting between call sites.
4446

4547
# Strong references to in-flight Colyseus publish tasks. ``asyncio.create_task``
4648
# only holds a weak reference to the task it returns, so without an external
@@ -297,13 +299,11 @@ async def _resolve_bud(
297299
return bud.id, bud.bud_number
298300

299301
# Auto-detect from branch name: bud-001/feature → 1
300-
if branch:
301-
match = _BUD_BRANCH_RE.match(branch)
302-
if match:
303-
detected_num = int(match.group(1))
304-
bud = await bud_repo.get_by_number(detected_num)
305-
if bud:
306-
return bud.id, bud.bud_number
302+
detected_num = extract_bud_number(branch)
303+
if detected_num is not None:
304+
bud = await bud_repo.get_by_number(detected_num)
305+
if bud:
306+
return bud.id, bud.bud_number
307307

308308
return None, None
309309

backend/app/repositories/pull_request.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import uuid
1818
from datetime import datetime
1919

20-
from sqlalchemy import func, or_, select
20+
from sqlalchemy import and_, func, or_, select
2121
from sqlalchemy.ext.asyncio import AsyncSession
2222

2323
from app.models.pull_request import PRState, PullRequest
@@ -172,22 +172,40 @@ async def list_open_for_bud_with_repo(
172172
) -> list[tuple[PullRequest, TrackedRepository | None]]:
173173
"""List open PRs for a BUD joined with their tracked repository.
174174
175-
When ``impacted_repo_ids`` is provided, the result includes any open
176-
PR that is either linked to ``bud_id`` directly OR targets one of
177-
the impacted repos. Used by release-stage views that need to surface
178-
open PRs in repos affected by the BUD even if the PR forgot to set
179-
a ``bud_id``.
175+
Three-way predicate so the release-stage views (UAT / PROD tabs)
176+
only surface PRs that genuinely relate to ``bud_id``:
177+
178+
* ``bud_id == X`` — the PR is directly linked to this BUD.
179+
* ``bud_id IS NULL AND repo_id IN impacted_repo_ids`` — aggregate
180+
release PRs like ``develop → main`` legitimately carry no single
181+
owning BUD; we keep them visible on the impacted repo's stage tab
182+
because the SHA-walk in the release detector uses them to attribute
183+
merges back to multiple BUDs.
184+
185+
A plain ``OR(bud_id == X, repo_id IN impacted)`` would also let
186+
through PRs linked to a **different** BUD that happens to touch the
187+
same impacted repo — which is the over-matching bug this method now
188+
prevents.
180189
181190
Args:
182191
bud_id: The BUD UUID to filter on.
183-
impacted_repo_ids: Additional repo UUIDs to include open PRs for.
192+
impacted_repo_ids: Repo UUIDs whose unlinked release PRs should
193+
stay visible. When ``None`` / empty, only directly-linked
194+
PRs are returned.
184195
185196
Returns:
186197
List of ``(PullRequest, TrackedRepository | None)`` tuples.
187198
"""
188-
filters = [PullRequest.bud_id == bud_id]
189199
if impacted_repo_ids:
190-
filters.append(PullRequest.repo_id.in_(impacted_repo_ids))
200+
bud_predicate = or_(
201+
PullRequest.bud_id == bud_id,
202+
and_(
203+
PullRequest.bud_id.is_(None),
204+
PullRequest.repo_id.in_(impacted_repo_ids),
205+
),
206+
)
207+
else:
208+
bud_predicate = PullRequest.bud_id == bud_id
191209

192210
stmt = self._scoped(
193211
select(PullRequest, TrackedRepository)
@@ -198,7 +216,7 @@ async def list_open_for_bud_with_repo(
198216
)
199217
.where(
200218
PullRequest.state == PRState.OPEN,
201-
or_(*filters),
219+
bud_predicate,
202220
)
203221
)
204222
result = await self._db.execute(stmt)

backend/app/repositories/user.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing import Any
1919

2020
from sqlalchemy import Select, and_, case, func, or_, select, true
21+
from sqlalchemy.exc import IntegrityError
2122
from sqlalchemy.ext.asyncio import AsyncSession
2223
from sqlalchemy.orm import aliased
2324

@@ -711,6 +712,72 @@ async def add_email_alias(
711712
self._db.add(alias)
712713
return alias
713714

715+
async def create_stub_member(
716+
self,
717+
org_id: uuid.UUID,
718+
*,
719+
email: str,
720+
name: str,
721+
github_username: str,
722+
password_hash: str,
723+
) -> User:
724+
"""Insert a stub User + ``OrgToUser`` membership in one shot.
725+
726+
Used to surface GitHub PR authors who don't yet have an account
727+
so admins can merge them into a real member via Settings →
728+
Members. Caller chooses ``password_hash`` — typically a random
729+
bcrypt-hashed string so the account cannot authenticate.
730+
731+
Concurrent BUD closures may race on the same unknown
732+
``email`` — the insert is wrapped in a SAVEPOINT and, on the
733+
``uq_users_email`` violation, the loser re-fetches the winning
734+
row instead of poisoning the outer transaction.
735+
"""
736+
try:
737+
async with self._db.begin_nested():
738+
user = User(
739+
email=email,
740+
name=name,
741+
password_hash=password_hash,
742+
github_username=github_username,
743+
is_active=True,
744+
)
745+
self._db.add(user)
746+
await self._db.flush()
747+
self._db.add(OrgToUser(user_id=user.id, org_id=org_id))
748+
await self._db.flush()
749+
return user
750+
except IntegrityError:
751+
existing = await self.get_by_email_in_org(org_id, email)
752+
if existing is None:
753+
raise
754+
return existing
755+
756+
async def find_user_by_alias_email(self, org_id: uuid.UUID, email: str) -> User | None:
757+
"""Return the user who has ``email`` listed as a UserEmailAlias.
758+
759+
Walks one hop of the Settings → Members merge backlink: when
760+
member B is merged into A, B's primary email is recorded as an
761+
alias on A. Given B's email, this returns A.
762+
763+
Returns the immediate target without filtering on ``is_active``
764+
so multi-hop chains (A → B → C) can be traversed externally;
765+
callers that need a guaranteed-active user must loop until
766+
``user.is_active`` is true.
767+
"""
768+
if not email:
769+
return None
770+
result = await self._db.execute(
771+
select(User)
772+
.join(UserEmailAlias, UserEmailAlias.user_id == User.id)
773+
.where(
774+
UserEmailAlias.org_id == org_id,
775+
UserEmailAlias.email == email,
776+
)
777+
.limit(1)
778+
)
779+
return result.scalar_one_or_none()
780+
714781
async def list_aliases(self, user_id: uuid.UUID) -> list[UserEmailAlias]:
715782
"""List all email aliases for a user.
716783

backend/app/services/bud_closure.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,14 @@ async def on_bud_closed(
8181
await _award_bud_shipped_sp(db, org_id, bud)
8282

8383
if bud.status == BUDStatus.CLOSED:
84+
# SAVEPOINT so a metrics-compute failure rolls back any side
85+
# effects the compute introduced (stub members materialised by
86+
# the contributor breakdown's PR-author provisioner,
87+
# author_user_id backfills, the FeatureLearning upsert itself)
88+
# without poisoning the outer close-handler transaction.
8489
try:
85-
await compute_bud_metrics(db, org_id, bud)
90+
async with db.begin_nested():
91+
await compute_bud_metrics(db, org_id, bud)
8692
except Exception:
8793
logger.warning(
8894
"bud_metrics_compute_failed",

0 commit comments

Comments
 (0)