Add 8-hour default window for legacy-meter deploy monitoring
Expose hours query param on /legacy-meter/summary and weekly-review; capture_legacy_meter_evidence.py defaults to --hours 8 (--days 7 for weekly retirement gate). Re-capture post-deploy evidence with tighter window.
This commit is contained in:
parent
674479dcc7
commit
e302ca9f59
7 changed files with 252 additions and 90 deletions
|
|
@ -28,6 +28,21 @@ from api.services.legacy_meter import (
|
|||
router = APIRouter(prefix="/legacy-meter", tags=["legacy-meter"])
|
||||
|
||||
|
||||
def _review_window(
|
||||
*,
|
||||
days: int | None,
|
||||
hours: int | None,
|
||||
window_start: datetime | None,
|
||||
window_end: datetime | None,
|
||||
) -> tuple[datetime, datetime]:
|
||||
end = window_end or datetime.now(tz=timezone.utc)
|
||||
if window_start is not None:
|
||||
return window_start, end
|
||||
if hours is not None:
|
||||
return end - timedelta(hours=hours), end
|
||||
return end - timedelta(days=days or 7), end
|
||||
|
||||
|
||||
@router.post(
|
||||
"/interfaces",
|
||||
response_model=LegacyInterfaceRead,
|
||||
|
|
@ -107,23 +122,43 @@ async def record_usage(
|
|||
|
||||
@router.get("/summary", response_model=LegacyUsageSummary)
|
||||
async def usage_summary(
|
||||
days: int = Query(7, ge=1, le=366),
|
||||
days: int | None = Query(None, ge=1, le=366),
|
||||
hours: int | None = Query(None, ge=1, le=8784),
|
||||
window_start: datetime | None = None,
|
||||
window_end: datetime | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LegacyUsageSummary:
|
||||
end = window_end or datetime.now(tz=timezone.utc)
|
||||
start = window_start or (end - timedelta(days=days))
|
||||
if days is not None and hours is not None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Pass either days or hours, not both",
|
||||
)
|
||||
start, end = _review_window(
|
||||
days=days,
|
||||
hours=hours,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
)
|
||||
return await legacy_usage_summary(session, window_start=start, window_end=end)
|
||||
|
||||
|
||||
@router.get("/weekly-review", response_model=LegacyWeeklyReview)
|
||||
async def weekly_review(
|
||||
days: int = Query(7, ge=1, le=366),
|
||||
days: int | None = Query(None, ge=1, le=366),
|
||||
hours: int | None = Query(None, ge=1, le=8784),
|
||||
window_start: datetime | None = None,
|
||||
window_end: datetime | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LegacyWeeklyReview:
|
||||
end = window_end or datetime.now(tz=timezone.utc)
|
||||
start = window_start or (end - timedelta(days=days))
|
||||
if days is not None and hours is not None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Pass either days or hours, not both",
|
||||
)
|
||||
start, end = _review_window(
|
||||
days=days,
|
||||
hours=hours,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
)
|
||||
return await legacy_weekly_review(session, window_start=start, window_end=end)
|
||||
|
|
|
|||
|
|
@ -1,30 +1,12 @@
|
|||
{
|
||||
"captured_at": "2026-07-08T23:08:23.293570+00:00",
|
||||
"captured_at": "2026-07-08T23:35:18.486286+00:00",
|
||||
"api_base": "http://127.0.0.1:8000",
|
||||
"days": 7,
|
||||
"workplan": "STATE-WP-0069",
|
||||
"retired_interfaces": [
|
||||
{
|
||||
"id": "8051fb40-66c9-4dc4-a413-13e8573e04a3",
|
||||
"interface_key": "event_subject:org.statehub.workstream.completed",
|
||||
"interface_kind": "event_subject",
|
||||
"legacy_since": "2026-06-04T06:09:42.198193Z",
|
||||
"replacement_ref": "org.statehub.workplan.completed",
|
||||
"owner_component": "state-hub.events",
|
||||
"status": "retired",
|
||||
"replacement_verified": true,
|
||||
"manual_hold": false,
|
||||
"hold_reason": null,
|
||||
"notes": "Retired by STATE-WP-0069 closeout",
|
||||
"retired_at": "2026-07-08T21:38:52.773601Z",
|
||||
"created_at": "2026-06-04T06:09:42.198193Z",
|
||||
"updated_at": "2026-07-08T21:38:52.761243Z"
|
||||
}
|
||||
],
|
||||
"workplan": "STATE-WP-0070",
|
||||
"retired_interfaces": [],
|
||||
"weekly_review": {
|
||||
"generated_at": "2026-07-08T23:08:23.276946Z",
|
||||
"window_start": "2026-07-01T23:08:22.803913Z",
|
||||
"window_end": "2026-07-08T23:08:22.803913Z",
|
||||
"generated_at": "2026-07-08T23:35:18.459774Z",
|
||||
"window_start": "2026-07-08T15:35:18.219148Z",
|
||||
"window_end": "2026-07-08T23:35:18.219148Z",
|
||||
"cadence": "weekly",
|
||||
"activity_core_handoff": {
|
||||
"activity_id": "statehub-legacy-interface-review",
|
||||
|
|
@ -67,18 +49,18 @@
|
|||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 54,
|
||||
"calls": 17,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 54
|
||||
"unknown": 17
|
||||
},
|
||||
"users": {
|
||||
"unknown": 54
|
||||
"unknown": 17
|
||||
},
|
||||
"components": {
|
||||
"state-hub.events": 54
|
||||
"state-hub.events": 17
|
||||
}
|
||||
},
|
||||
"last_seen_at": "2026-07-08T20:49:12.839383Z",
|
||||
|
|
@ -214,23 +196,23 @@
|
|||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 24897,
|
||||
"calls": 1752,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 24897
|
||||
"unknown": 1752
|
||||
},
|
||||
"users": {
|
||||
"unknown": 24897
|
||||
"unknown": 1752
|
||||
},
|
||||
"components": {
|
||||
"unknown": 24897
|
||||
"unknown": 1752
|
||||
}
|
||||
},
|
||||
"last_seen_at": "2026-07-08T21:14:39.652776Z",
|
||||
"retirement_candidate": false,
|
||||
"retirement_reason": "24897 call(s) in review window"
|
||||
"retirement_reason": "1752 call(s) in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
|
|
@ -265,23 +247,17 @@
|
|||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 4,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 4
|
||||
},
|
||||
"users": {
|
||||
"unknown": 4
|
||||
},
|
||||
"components": {
|
||||
"unknown": 4
|
||||
}
|
||||
"calls": 0,
|
||||
"tenant_count": 0,
|
||||
"user_count": 0,
|
||||
"component_count": 0,
|
||||
"tenants": {},
|
||||
"users": {},
|
||||
"components": {}
|
||||
},
|
||||
"last_seen_at": "2026-07-03T05:20:34.534439Z",
|
||||
"retirement_candidate": false,
|
||||
"retirement_reason": "4 call(s) in review window"
|
||||
"retirement_candidate": true,
|
||||
"retirement_reason": "no measured usage in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
|
|
@ -316,23 +292,23 @@
|
|||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 82774,
|
||||
"calls": 7653,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 82774
|
||||
"unknown": 7653
|
||||
},
|
||||
"users": {
|
||||
"unknown": 82774
|
||||
"unknown": 7653
|
||||
},
|
||||
"components": {
|
||||
"unknown": 82774
|
||||
"unknown": 7653
|
||||
}
|
||||
},
|
||||
"last_seen_at": "2026-07-08T21:14:20.781103Z",
|
||||
"retirement_candidate": false,
|
||||
"retirement_reason": "82774 call(s) in review window"
|
||||
"retirement_reason": "7653 call(s) in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
|
|
@ -367,23 +343,23 @@
|
|||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 41448,
|
||||
"calls": 3892,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 41448
|
||||
"unknown": 3892
|
||||
},
|
||||
"users": {
|
||||
"unknown": 41448
|
||||
"unknown": 3892
|
||||
},
|
||||
"components": {
|
||||
"unknown": 41448
|
||||
"unknown": 3892
|
||||
}
|
||||
},
|
||||
"last_seen_at": "2026-07-08T21:14:12.747005Z",
|
||||
"retirement_candidate": false,
|
||||
"retirement_reason": "41448 call(s) in review window"
|
||||
"retirement_reason": "3892 call(s) in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
|
|
@ -418,23 +394,74 @@
|
|||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 128,
|
||||
"calls": 46,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 128
|
||||
"unknown": 46
|
||||
},
|
||||
"users": {
|
||||
"unknown": 128
|
||||
"unknown": 46
|
||||
},
|
||||
"components": {
|
||||
"unknown": 128
|
||||
"unknown": 46
|
||||
}
|
||||
},
|
||||
"last_seen_at": "2026-07-08T20:49:12.335710Z",
|
||||
"retirement_candidate": false,
|
||||
"retirement_reason": "128 call(s) in review window"
|
||||
"retirement_reason": "46 call(s) in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
"id": "4a70583c-1e41-4ec1-99a9-728856a7d79c",
|
||||
"interface_key": "rest_api:POST /progress/ workstream_id",
|
||||
"interface_kind": "rest_api",
|
||||
"legacy_since": "2026-07-08T23:35:09.148333Z",
|
||||
"replacement_ref": "POST /progress/ with workplan_id",
|
||||
"owner_component": "state-hub.api",
|
||||
"status": "legacy",
|
||||
"replacement_verified": true,
|
||||
"manual_hold": false,
|
||||
"hold_reason": null,
|
||||
"notes": null,
|
||||
"retired_at": null,
|
||||
"created_at": "2026-07-08T23:35:09.148333Z",
|
||||
"updated_at": "2026-07-08T23:35:09.148333Z"
|
||||
},
|
||||
"all_time": {
|
||||
"calls": 1,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 1
|
||||
},
|
||||
"users": {
|
||||
"unknown": 1
|
||||
},
|
||||
"components": {
|
||||
"unknown": 1
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 1,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 1
|
||||
},
|
||||
"users": {
|
||||
"unknown": 1
|
||||
},
|
||||
"components": {
|
||||
"unknown": 1
|
||||
}
|
||||
},
|
||||
"last_seen_at": "2026-07-08T23:35:09.125218Z",
|
||||
"retirement_candidate": false,
|
||||
"retirement_reason": "1 call(s) in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
|
|
@ -520,23 +547,23 @@
|
|||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 40,
|
||||
"calls": 18,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 40
|
||||
"unknown": 18
|
||||
},
|
||||
"users": {
|
||||
"unknown": 40
|
||||
"unknown": 18
|
||||
},
|
||||
"components": {
|
||||
"unknown": 40
|
||||
"unknown": 18
|
||||
}
|
||||
},
|
||||
"last_seen_at": "2026-07-08T20:38:05.903144Z",
|
||||
"retirement_candidate": false,
|
||||
"retirement_reason": "40 call(s) in review window"
|
||||
"retirement_reason": "18 call(s) in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
|
|
@ -630,6 +657,51 @@
|
|||
"retirement_candidate": true,
|
||||
"retirement_reason": "no measured usage in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
"id": "e4c140ec-7479-46f9-b185-4b533f19d639",
|
||||
"interface_key": "rest_api:GET /workstreams/workplan-index",
|
||||
"interface_kind": "rest_api",
|
||||
"legacy_since": "2026-06-04T05:20:58.321869Z",
|
||||
"replacement_ref": "/workplans/index",
|
||||
"owner_component": "state-hub.api",
|
||||
"status": "legacy",
|
||||
"replacement_verified": true,
|
||||
"manual_hold": false,
|
||||
"hold_reason": null,
|
||||
"notes": null,
|
||||
"retired_at": null,
|
||||
"created_at": "2026-06-04T05:20:58.321869Z",
|
||||
"updated_at": "2026-06-04T05:20:58.321869Z"
|
||||
},
|
||||
"all_time": {
|
||||
"calls": 30,
|
||||
"tenant_count": 1,
|
||||
"user_count": 1,
|
||||
"component_count": 1,
|
||||
"tenants": {
|
||||
"unknown": 30
|
||||
},
|
||||
"users": {
|
||||
"unknown": 30
|
||||
},
|
||||
"components": {
|
||||
"unknown": 30
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"calls": 0,
|
||||
"tenant_count": 0,
|
||||
"user_count": 0,
|
||||
"component_count": 0,
|
||||
"tenants": {},
|
||||
"users": {},
|
||||
"components": {}
|
||||
},
|
||||
"last_seen_at": "2026-07-03T05:20:34.534439Z",
|
||||
"retirement_candidate": true,
|
||||
"retirement_reason": "no measured usage in review window"
|
||||
},
|
||||
{
|
||||
"interface": {
|
||||
"id": "281fd706-b192-4194-8055-2d8733c115f9",
|
||||
|
|
@ -676,5 +748,6 @@
|
|||
"retirement_reason": "no measured usage in review window"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"hours": 8
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ git log -1 --oneline # expect 5a92923 or newer
|
|||
statehub fix-consistency --all --remote --api-base http://127.0.0.1:8000
|
||||
|
||||
# 4. Verify legacy-meter attribution (from any host with hub API access)
|
||||
python ~/state-hub/scripts/capture_legacy_meter_evidence.py --days 7
|
||||
python ~/state-hub/scripts/capture_legacy_meter_evidence.py
|
||||
# Default 8-hour window; use --days 7 for weekly cadence
|
||||
# Expect component bucket state-hub.fix-consistency; declining GET /workstreams/*
|
||||
```
|
||||
|
||||
|
|
@ -47,8 +48,8 @@ python ~/state-hub/scripts/capture_legacy_meter_evidence.py --days 7
|
|||
(`POST /consistency/sweep/remote-all` exit_code 0, 2026-07-08T23:08Z)
|
||||
- [x] Weekly legacy-meter review shows `state-hub.fix-consistency` (not only `unknown`)
|
||||
(`docs/evidence/legacy-meter-weekly-review-20260709.json`, 5 attributed calls)
|
||||
- [ ] `GET /workstreams/*` 7-day window counts trend down week-over-week
|
||||
(deploy complete 2026-07-09; re-capture after one full week for ≥50% drop gate)
|
||||
- [ ] `GET /workstreams/*` counts trend down in 8-hour capture windows post-deploy
|
||||
(default `capture_legacy_meter_evidence.py`; weekly `--days 7` for retirement gate)
|
||||
|
||||
## Rollback
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ on legacy REST keys; most lacked `X-StateHub-Component` identity (`unknown` buck
|
|||
## Next verification
|
||||
|
||||
```bash
|
||||
python scripts/capture_legacy_meter_evidence.py --days 7
|
||||
python scripts/capture_legacy_meter_evidence.py # default 8h; --days 7 for weekly
|
||||
```
|
||||
|
||||
Expect `GET /workstreams/*` window counts to fall after deploy + one full
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ Deferred until seven consecutive zero-usage review windows per key:
|
|||
Evidence capture:
|
||||
|
||||
```bash
|
||||
python scripts/capture_legacy_meter_evidence.py --days 7
|
||||
python scripts/capture_legacy_meter_evidence.py # default 8h post-deploy monitoring
|
||||
python scripts/capture_legacy_meter_evidence.py --days 7 # weekly retirement gate
|
||||
# writes docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Capture legacy-meter weekly-review payload as dated evidence JSON.
|
||||
"""Capture legacy-meter review payload as dated evidence JSON.
|
||||
|
||||
Usage:
|
||||
python scripts/capture_legacy_meter_evidence.py [--days 7] [--api-base URL] [--dry-run]
|
||||
python scripts/capture_legacy_meter_evidence.py [--hours 8] [--days N] [--api-base URL] [--dry-run]
|
||||
|
||||
Default review window is 8 hours (post-deploy monitoring). Pass --days for the
|
||||
weekly activity-core cadence (typically 7).
|
||||
|
||||
Writes:
|
||||
docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json
|
||||
|
|
@ -68,23 +71,40 @@ def _retire_interfaces(base: str, keys: list[str]) -> list[dict]:
|
|||
return retired
|
||||
|
||||
|
||||
def capture(*, api_base: str, days: int, retire_keys: list[str], dry_run: bool) -> Path:
|
||||
review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}")
|
||||
def _review_query(*, days: int | None, hours: int | None) -> str:
|
||||
if days is not None:
|
||||
return f"days={days}"
|
||||
return f"hours={hours or 8}"
|
||||
|
||||
|
||||
def capture(
|
||||
*,
|
||||
api_base: str,
|
||||
days: int | None,
|
||||
hours: int | None,
|
||||
retire_keys: list[str],
|
||||
dry_run: bool,
|
||||
) -> Path:
|
||||
query = _review_query(days=days, hours=hours)
|
||||
review = _api_get(api_base, f"/legacy-meter/weekly-review?{query}")
|
||||
retired: list[dict] = []
|
||||
if retire_keys and not dry_run:
|
||||
retired = _retire_interfaces(api_base, retire_keys)
|
||||
review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}")
|
||||
review = _api_get(api_base, f"/legacy-meter/weekly-review?{query}")
|
||||
|
||||
today = datetime.date.today().strftime("%Y%m%d")
|
||||
out_path = EVIDENCE_DIR / f"legacy-meter-weekly-review-{today}.json"
|
||||
payload = {
|
||||
payload: dict = {
|
||||
"captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"api_base": api_base,
|
||||
"days": days,
|
||||
"workplan": "STATE-WP-0069",
|
||||
"workplan": "STATE-WP-0070",
|
||||
"retired_interfaces": retired,
|
||||
"weekly_review": review,
|
||||
}
|
||||
if days is not None:
|
||||
payload["days"] = days
|
||||
else:
|
||||
payload["hours"] = hours or 8
|
||||
|
||||
if dry_run:
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
|
@ -94,13 +114,25 @@ def capture(*, api_base: str, days: int, retire_keys: list[str], dry_run: bool)
|
|||
out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
candidates = len(review.get("retirement_candidates", []))
|
||||
interfaces = len(review.get("interfaces", []))
|
||||
print(f"wrote {out_path} ({interfaces} interfaces, {candidates} retirement candidates)")
|
||||
window = query.replace("=", " ")
|
||||
print(f"wrote {out_path} ({interfaces} interfaces, {candidates} retirement candidates, {window})")
|
||||
return out_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--days", type=int, default=7)
|
||||
parser.add_argument(
|
||||
"--hours",
|
||||
type=int,
|
||||
default=8,
|
||||
help="Review window in hours (default: 8; used when --days is omitted)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--days",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Review window in days (weekly cadence; overrides --hours)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-base",
|
||||
default=os.environ.get("API_BASE", "http://127.0.0.1:8000"),
|
||||
|
|
@ -118,6 +150,7 @@ def main() -> None:
|
|||
capture(
|
||||
api_base=args.api_base,
|
||||
days=args.days,
|
||||
hours=args.hours,
|
||||
retire_keys=retire_keys,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,25 @@ class TestWorkplanAliasesAndLegacyMeter:
|
|||
assert item["window"]["components"] == {"old-client": 1}
|
||||
assert item["retirement_candidate"] is False
|
||||
|
||||
async def test_weekly_review_accepts_hours_window(self, client):
|
||||
r = await client.post("/legacy-meter/interfaces", json={
|
||||
"interface_key": "rest_api:GET /hour-window",
|
||||
"interface_kind": "rest_api",
|
||||
"replacement_ref": "/workplans/",
|
||||
"replacement_verified": True,
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
review = (await client.get("/legacy-meter/weekly-review?hours=8")).json()
|
||||
window_seconds = (
|
||||
datetime.fromisoformat(review["window_end"].replace("Z", "+00:00"))
|
||||
- datetime.fromisoformat(review["window_start"].replace("Z", "+00:00"))
|
||||
).total_seconds()
|
||||
assert 8 * 3600 - 5 <= window_seconds <= 8 * 3600 + 5
|
||||
|
||||
bad = await client.get("/legacy-meter/weekly-review?days=7&hours=8")
|
||||
assert bad.status_code == 422
|
||||
|
||||
async def test_weekly_review_reports_unused_verified_legacy_interface(self, client):
|
||||
r = await client.post("/legacy-meter/interfaces", json={
|
||||
"interface_key": "rest_api:GET /obsolete",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue