Skip to content

Commit 32c3b98

Browse files
Merge pull request #324 from bg-playground/copilot/add-requirement-external-ids
Accept reporter-supplied requirement external IDs on external case result creation
2 parents ac5dd0b + 1182626 commit 32c3b98

5 files changed

Lines changed: 514 additions & 23 deletions

File tree

backend/app/api/external_results.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -276,23 +276,34 @@ async def create_external_case_result(
276276
raise
277277

278278
if created:
279+
details = {
280+
"session_id": str(case_result.session_id),
281+
"outcome": case_result.outcome.value,
282+
"external_id": case_result.external_id,
283+
"test_case_id": str(case_result.test_case_id) if case_result.test_case_id is not None else None,
284+
"auto_registered": case_result.auto_registered,
285+
"unresolved_requirement_ids": [
286+
str(requirement_id) for requirement_id in getattr(case_result, "unresolved_requirement_ids", [])
287+
],
288+
}
289+
if payload.requirement_external_ids:
290+
details.update(
291+
{
292+
"requirement_external_ids_submitted": payload.requirement_external_ids,
293+
"unresolved_requirement_external_ids": getattr(
294+
case_result, "unresolved_requirement_external_ids", []
295+
),
296+
"auto_register_requirements": payload.auto_register_requirements,
297+
}
298+
)
279299
await write_audit(
280300
db,
281301
actor_kind="runner_token",
282302
actor_id=token.id,
283303
action="external_results.case.create",
284304
resource_type="external_case_result",
285305
resource_id=case_result.id,
286-
details={
287-
"session_id": str(case_result.session_id),
288-
"outcome": case_result.outcome.value,
289-
"external_id": case_result.external_id,
290-
"test_case_id": str(case_result.test_case_id) if case_result.test_case_id is not None else None,
291-
"auto_registered": case_result.auto_registered,
292-
"unresolved_requirement_ids": [
293-
str(requirement_id) for requirement_id in getattr(case_result, "unresolved_requirement_ids", [])
294-
],
295-
},
306+
details=details,
296307
)
297308
else:
298309
response.status_code = status.HTTP_200_OK

backend/app/crud/external_case_results.py

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from app.models.external_case_result import CaseStatus, ExternalCaseResult
1313
from app.models.external_results import ExternalRunSession
1414
from app.models.link import LinkSource, LinkType, RequirementTestCaseLink
15-
from app.models.requirement import PriorityLevel, Requirement
15+
from app.models.requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType
1616
from app.models.test_case import TestCase, TestCaseStatus, TestCaseType
1717
from app.schemas.external_results import CaseResultCreate, CaseResultUpdate
1818

@@ -21,6 +21,32 @@ def _outcome_value(outcome: Any) -> str:
2121
return getattr(outcome, "value", str(outcome))
2222

2323

24+
def _dedupe_requirement_ids(requirement_ids: list[UUID]) -> list[UUID]:
25+
seen_requirement_ids: set[UUID] = set()
26+
deduped_requirement_ids: list[UUID] = []
27+
28+
for requirement_id in requirement_ids:
29+
if requirement_id in seen_requirement_ids:
30+
continue
31+
seen_requirement_ids.add(requirement_id)
32+
deduped_requirement_ids.append(requirement_id)
33+
34+
return deduped_requirement_ids
35+
36+
37+
def _dedupe_requirement_external_ids(requirement_external_ids: list[str]) -> list[str]:
38+
seen_external_ids: set[str] = set()
39+
deduped_external_ids: list[str] = []
40+
41+
for external_id in requirement_external_ids:
42+
if external_id in seen_external_ids:
43+
continue
44+
seen_external_ids.add(external_id)
45+
deduped_external_ids.append(external_id)
46+
47+
return deduped_external_ids
48+
49+
2450
async def _get_requirement_ids_for_test_case(
2551
db: AsyncSession,
2652
*,
@@ -90,7 +116,7 @@ async def _link_requirements(
90116
if not requirement_ids:
91117
return [], []
92118

93-
deduped_ids = list(dict.fromkeys(requirement_ids))
119+
deduped_ids = _dedupe_requirement_ids(requirement_ids)
94120
resolvable_ids, unresolved_ids = await _resolve_requirement_ids(db, requirement_ids=deduped_ids)
95121

96122
values = [
@@ -113,12 +139,7 @@ async def _link_requirements(
113139
stmt = insert_stmt.on_conflict_do_nothing(index_elements=["requirement_id", "test_case_id"])
114140
await db.execute(stmt)
115141

116-
linked_rows = await db.execute(
117-
select(RequirementTestCaseLink.requirement_id)
118-
.where(RequirementTestCaseLink.test_case_id == test_case_id)
119-
.where(RequirementTestCaseLink.requirement_id.in_(deduped_ids))
120-
)
121-
return list(linked_rows.scalars().all()), unresolved_ids
142+
return await _get_requirement_ids_for_test_case(db, test_case_id=test_case_id), unresolved_ids
122143

123144

124145
async def _resolve_requirement_ids(
@@ -138,6 +159,52 @@ async def _resolve_requirement_ids(
138159
return resolvable_ids, unresolved_ids
139160

140161

162+
async def _resolve_requirement_external_ids(
163+
db: AsyncSession,
164+
*,
165+
requirement_external_ids: list[str] | None,
166+
auto_register_requirements: bool,
167+
) -> tuple[list[UUID], list[str]]:
168+
if not requirement_external_ids:
169+
return [], []
170+
171+
deduped_external_ids = _dedupe_requirement_external_ids(requirement_external_ids)
172+
requirement_rows = await db.execute(select(Requirement).where(Requirement.external_id.in_(deduped_external_ids)))
173+
requirements_by_external_id: dict[str, Requirement] = {}
174+
for requirement in requirement_rows.scalars().all():
175+
external_id = requirement.external_id
176+
if isinstance(external_id, str):
177+
requirements_by_external_id[external_id] = requirement
178+
179+
resolved_ids: list[UUID] = []
180+
unresolved_ids: list[str] = []
181+
182+
for submitted_external_id in deduped_external_ids:
183+
requirement = requirements_by_external_id.get(submitted_external_id)
184+
if requirement is not None:
185+
resolved_ids.append(requirement.id)
186+
continue
187+
188+
if not auto_register_requirements:
189+
unresolved_ids.append(submitted_external_id)
190+
continue
191+
192+
requirement = Requirement(
193+
external_id=submitted_external_id,
194+
title=submitted_external_id,
195+
description=f"Auto-registered from external ID {submitted_external_id}",
196+
type=RequirementType.FUNCTIONAL,
197+
priority=PriorityLevel.MEDIUM,
198+
status=RequirementStatus.DRAFT,
199+
)
200+
db.add(requirement)
201+
await db.flush()
202+
requirements_by_external_id[submitted_external_id] = requirement
203+
resolved_ids.append(requirement.id)
204+
205+
return resolved_ids, unresolved_ids
206+
207+
141208
async def create_case_result(
142209
db: AsyncSession,
143210
*,
@@ -158,7 +225,13 @@ async def create_case_result(
158225
db,
159226
requirement_ids=payload.requirement_ids,
160227
)
228+
_resolved_external_ids, unresolved_external_ids = await _resolve_requirement_external_ids(
229+
db,
230+
requirement_external_ids=payload.requirement_external_ids,
231+
auto_register_requirements=False,
232+
)
161233
existing.unresolved_requirement_ids = unresolved_ids
234+
existing.unresolved_requirement_external_ids = unresolved_external_ids
162235
return existing, False
163236

164237
session_result = await db.execute(select(ExternalRunSession).where(ExternalRunSession.id == session_id))
@@ -190,15 +263,25 @@ async def create_case_result(
190263
)
191264
db.add(case_result)
192265
await db.flush()
193-
linked_ids, unresolved_ids = await _link_requirements(
266+
resolvable_ids, unresolved_ids = await _resolve_requirement_ids(
194267
db,
195-
test_case_id=test_case.id,
196268
requirement_ids=payload.requirement_ids,
197269
)
270+
resolved_external_ids, unresolved_external_ids = await _resolve_requirement_external_ids(
271+
db,
272+
requirement_external_ids=payload.requirement_external_ids,
273+
auto_register_requirements=payload.auto_register_requirements,
274+
)
275+
linked_ids, _ = await _link_requirements(
276+
db,
277+
test_case_id=test_case.id,
278+
requirement_ids=_dedupe_requirement_ids(resolvable_ids + resolved_external_ids),
279+
)
198280
await db.commit()
199281
await db.refresh(case_result)
200282
case_result.requirement_ids = linked_ids
201283
case_result.unresolved_requirement_ids = unresolved_ids
284+
case_result.unresolved_requirement_external_ids = unresolved_external_ids
202285
return case_result, True
203286

204287

backend/app/schemas/external_results.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from typing import Any
1616
from uuid import UUID
1717

18-
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
18+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator, model_validator
1919

2020
# ---------------------------------------------------------------------------
2121
# Enumerations
@@ -143,6 +143,31 @@ class CaseResultCreate(BaseModel):
143143
default_factory=list,
144144
description="Requirement UUIDs to link; duplicate insertion is a no-op.",
145145
)
146+
requirement_external_ids: list[str] | None = Field(
147+
default=None,
148+
description="Reporter-supplied external IDs. Each is resolved against requirements.external_id; "
149+
"unresolved IDs are dropped (or auto-registered if auto_register_requirements=True) and "
150+
"recorded in the audit-log details.",
151+
)
152+
auto_register_requirements: bool = Field(
153+
default=False,
154+
description="If true, unknown requirement_external_ids cause stub Requirement rows to be created "
155+
"and linked. Default false — unknown IDs are dropped silently (with audit-log diagnostics).",
156+
)
157+
158+
@field_validator("requirement_external_ids")
159+
@classmethod
160+
def _normalize_requirement_external_ids(cls, value: list[str] | None) -> list[str] | None:
161+
if value is None:
162+
return None
163+
164+
normalized_ids: list[str] = []
165+
for external_id in value:
166+
stripped_external_id = external_id.strip()
167+
if not stripped_external_id:
168+
raise ValueError("requirement_external_ids entries must not be empty.")
169+
normalized_ids.append(stripped_external_id)
170+
return normalized_ids
146171

147172
@model_validator(mode="after")
148173
def _require_at_least_one_id(self) -> CaseResultCreate:

0 commit comments

Comments
 (0)