Skip to content

Commit 7c13515

Browse files
Hyperkid123claude
andcommitted
feat(memory-server): switch all reads to external_key/source_type
RHCLOUD-48378 Stage 3 of the zero-downtime migration. All WHERE clauses, JOINs, and GROUP BYs now use external_key/source_type instead of jira_key. MCP tools (task_get, task_update, task_remove, slack_notify) accept both external_key+source_type (preferred) and jira_key (fallback). REST API endpoints (delete, unarchive, analytics, slack lookup) use external_key columns. Response helpers include new fields alongside existing jira_key for backward compatibility. Dashboard types updated with optional new fields. 14 new tests verify the read switchover. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f43d08d commit 7c13515

5 files changed

Lines changed: 489 additions & 66 deletions

File tree

dashboard/src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export interface SlackNotification {
77
export interface Task {
88
id: number;
99
jira_key: string;
10+
external_key?: string;
11+
source_type?: string;
12+
source_url?: string;
13+
artifacts?: Array<{ name: string; url: string; type: string }>;
1014
status: 'in_progress' | 'pr_open' | 'pr_changes' | 'paused' | 'done';
1115
repo: string;
1216
branch: string;
@@ -27,6 +31,8 @@ export interface Memory {
2731
category: string;
2832
repo: string;
2933
jira_key: string | null;
34+
external_key?: string;
35+
source_type?: string;
3036
title: string;
3137
content: string;
3238
tags: string[];
@@ -73,6 +79,8 @@ export interface CycleEntry {
7379
is_error: boolean;
7480
no_work: boolean;
7581
jira_key: string | null;
82+
external_key?: string;
83+
source_type?: string;
7684
repo: string | null;
7785
work_type: string | null;
7886
summary: string | null;
@@ -171,6 +179,7 @@ export interface RepoEntry {
171179

172180
export interface TicketEntry {
173181
jira_key: string;
182+
external_key?: string;
174183
title: string | null;
175184
status: string | null;
176185
repo: string | null;

memory-server/bot_memory_server/api.py

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -75,28 +75,28 @@ async def api_tasks(request: Request) -> JSONResponse:
7575
)
7676

7777
# Fetch latest Slack notification per task
78-
jira_keys = [r["jira_key"] for r in rows]
78+
ext_keys = [r["external_key"] or r["jira_key"] for r in rows]
7979
notifications = {}
80-
if jira_keys:
80+
if ext_keys:
8181
notif_rows = await pool.fetch(
8282
"""
83-
SELECT DISTINCT ON (jira_key) jira_key, event_type, message, sent_at
83+
SELECT DISTINCT ON (external_key) external_key, event_type, message, sent_at
8484
FROM slack_notifications
85-
WHERE jira_key = ANY($1)
86-
ORDER BY jira_key, sent_at DESC
85+
WHERE external_key = ANY($1)
86+
ORDER BY external_key, sent_at DESC
8787
""",
88-
jira_keys,
88+
ext_keys,
8989
)
9090
for nr in notif_rows:
91-
notifications[nr["jira_key"]] = {
91+
notifications[nr["external_key"]] = {
9292
"event_type": nr["event_type"],
9393
"message": nr["message"],
9494
"sent_at": nr["sent_at"].isoformat(),
9595
}
9696

9797
return JSONResponse(
9898
{
99-
"items": [_task(r, notifications.get(r["jira_key"])) for r in rows],
99+
"items": [_task(r, notifications.get(r["external_key"] or r["jira_key"])) for r in rows],
100100
"total": total,
101101
"limit": limit,
102102
"offset": offset,
@@ -105,39 +105,39 @@ async def api_tasks(request: Request) -> JSONResponse:
105105

106106

107107
async def api_task_delete(request: Request) -> JSONResponse:
108-
"""Archive a task by jira_key (soft delete — preserves history)."""
108+
"""Archive a task by key (soft delete — preserves history)."""
109109
pool = get_pool()
110-
jira_key = request.path_params.get("jira_key")
111-
if not jira_key:
112-
return JSONResponse({"error": "missing jira_key"}, status_code=400)
110+
key = request.path_params.get("jira_key")
111+
if not key:
112+
return JSONResponse({"error": "missing key"}, status_code=400)
113113
row = await pool.fetchrow(
114-
"UPDATE tasks SET status = 'archived'::task_status WHERE jira_key = $1 RETURNING *",
115-
jira_key,
114+
"UPDATE tasks SET status = 'archived'::task_status WHERE external_key = $1 RETURNING *",
115+
key,
116116
)
117117
if not row:
118-
return JSONResponse({"error": f"Task {jira_key} not found"}, status_code=404)
119-
await bus.publish(Event("task_archived", {"jira_key": jira_key}))
120-
return JSONResponse({"archived": True, "jira_key": jira_key, "task": _task(row)})
118+
return JSONResponse({"error": f"Task {key} not found"}, status_code=404)
119+
await bus.publish(Event("task_archived", {"jira_key": row["jira_key"] or key}))
120+
return JSONResponse({"archived": True, "jira_key": row["jira_key"] or key, "task": _task(row)})
121121

122122

123123
async def api_task_unarchive(request: Request) -> JSONResponse:
124-
"""Restore an archived task back to paused so the bot can pick it up."""
124+
"""Restore an archived task back to in_progress so the bot can pick it up."""
125125
pool = get_pool()
126-
jira_key = request.path_params.get("jira_key")
127-
if not jira_key:
128-
return JSONResponse({"error": "missing jira_key"}, status_code=400)
126+
key = request.path_params.get("jira_key")
127+
if not key:
128+
return JSONResponse({"error": "missing key"}, status_code=400)
129129
row = await pool.fetchrow(
130-
"UPDATE tasks SET status = 'in_progress'::task_status, paused_reason = NULL WHERE jira_key = $1 AND status = 'archived'::task_status RETURNING *",
131-
jira_key,
130+
"UPDATE tasks SET status = 'in_progress'::task_status, paused_reason = NULL WHERE external_key = $1 AND status = 'archived'::task_status RETURNING *",
131+
key,
132132
)
133133
if not row:
134134
return JSONResponse(
135-
{"error": f"Task {jira_key} not found or not archived"}, status_code=404
135+
{"error": f"Task {key} not found or not archived"}, status_code=404
136136
)
137137
await bus.publish(
138-
Event("task_updated", {"jira_key": jira_key, "status": "in_progress"})
138+
Event("task_updated", {"jira_key": row["jira_key"] or key, "status": "in_progress"})
139139
)
140-
return JSONResponse({"unarchived": True, "jira_key": jira_key, "task": _task(row)})
140+
return JSONResponse({"unarchived": True, "jira_key": row["jira_key"] or key, "task": _task(row)})
141141

142142

143143
async def api_memories(request: Request) -> JSONResponse:
@@ -613,9 +613,9 @@ async def api_analytics(request: Request) -> JSONResponse:
613613
SELECT
614614
CASE
615615
WHEN summary ILIKE '%%investigation%%' OR work_type = 'investigation' THEN 'investigation'
616-
WHEN jira_key IS NOT NULL AND (
616+
WHEN external_key IS NOT NULL AND (
617617
summary ILIKE '%%CVE%%' OR summary ILIKE '%%cve%%'
618-
OR jira_key IN (SELECT DISTINCT jira_key FROM tasks WHERE title ILIKE '%%CVE%%')
618+
OR external_key IN (SELECT DISTINCT external_key FROM tasks WHERE title ILIKE '%%CVE%%')
619619
) THEN 'cve'
620620
WHEN work_type = 'pr_review' THEN 'pr_review'
621621
WHEN work_type = 'new_ticket' THEN 'new_ticket'
@@ -640,7 +640,7 @@ async def api_analytics(request: Request) -> JSONResponse:
640640
repo_rows = await pool.fetch(
641641
f"""
642642
SELECT repo,
643-
COUNT(DISTINCT jira_key) AS tickets,
643+
COUNT(DISTINCT external_key) AS tickets,
644644
COUNT(*) AS cycles,
645645
ROUND(SUM(cost_usd)::numeric, 2) AS total_cost,
646646
ROUND(AVG(num_turns)::numeric, 1) AS avg_turns
@@ -656,7 +656,7 @@ async def api_analytics(request: Request) -> JSONResponse:
656656
ticket_rows = await pool.fetch(
657657
f"""
658658
SELECT
659-
c.jira_key,
659+
c.external_key AS jira_key,
660660
t.title,
661661
t.status::text AS task_status,
662662
t.repo,
@@ -666,9 +666,9 @@ async def api_analytics(request: Request) -> JSONResponse:
666666
ROUND(SUM(c.cost_usd)::numeric, 2) AS total_cost,
667667
ROUND(EXTRACT(EPOCH FROM (MAX(c.timestamp) - MIN(c.timestamp)))/3600.0, 1) AS hours_span
668668
FROM cycles c
669-
LEFT JOIN tasks t ON t.jira_key = c.jira_key
670-
WHERE {date_filter} AND c.jira_key IS NOT NULL AND NOT c.no_work
671-
GROUP BY c.jira_key, t.title, t.status, t.repo
669+
LEFT JOIN tasks t ON t.external_key = c.external_key
670+
WHERE {date_filter} AND c.external_key IS NOT NULL AND NOT c.no_work
671+
GROUP BY c.external_key, t.title, t.status, t.repo
672672
ORDER BY total_cycles DESC
673673
LIMIT 30
674674
""",
@@ -680,7 +680,7 @@ async def api_analytics(request: Request) -> JSONResponse:
680680
f"""
681681
SELECT
682682
COUNT(*) AS total_cycles,
683-
COUNT(DISTINCT jira_key) FILTER (WHERE jira_key IS NOT NULL AND NOT no_work) AS unique_tickets,
683+
COUNT(DISTINCT external_key) FILTER (WHERE external_key IS NOT NULL AND NOT no_work) AS unique_tickets,
684684
ROUND(SUM(cost_usd)::numeric, 2) AS total_cost,
685685
ROUND(AVG(cost_usd) FILTER (WHERE NOT no_work)::numeric, 2) AS avg_cost_per_work_cycle,
686686
ROUND(AVG(num_turns) FILTER (WHERE NOT no_work)::numeric, 1) AS avg_turns,
@@ -714,10 +714,10 @@ async def api_analytics(request: Request) -> JSONResponse:
714714
COUNT(*) FILTER (WHERE review_count = 1) AS one_review,
715715
COUNT(*) FILTER (WHERE review_count > 1) AS multi_review
716716
FROM (
717-
SELECT jira_key, COUNT(*) FILTER (WHERE work_type = 'pr_review') AS review_count
717+
SELECT external_key, COUNT(*) FILTER (WHERE work_type = 'pr_review') AS review_count
718718
FROM cycles
719-
WHERE {date_filter} AND jira_key IS NOT NULL AND NOT no_work
720-
GROUP BY jira_key
719+
WHERE {date_filter} AND external_key IS NOT NULL AND NOT no_work
720+
GROUP BY external_key
721721
) sub
722722
""",
723723
*date_params,
@@ -1044,9 +1044,21 @@ async def api_cycle_runs_by_task(request: Request) -> JSONResponse:
10441044

10451045

10461046
def _task(row, slack_notif=None) -> dict:
1047+
raw_artifacts = row.get("artifacts")
1048+
if isinstance(raw_artifacts, str):
1049+
artifacts = json.loads(raw_artifacts)
1050+
elif raw_artifacts is not None:
1051+
artifacts = raw_artifacts
1052+
else:
1053+
artifacts = []
1054+
10471055
result = {
10481056
"id": row["id"],
10491057
"jira_key": row["jira_key"],
1058+
"external_key": row.get("external_key"),
1059+
"source_type": row.get("source_type"),
1060+
"source_url": row.get("source_url"),
1061+
"artifacts": artifacts,
10501062
"status": row["status"],
10511063
"repo": row["repo"],
10521064
"branch": row["branch"],
@@ -1084,6 +1096,8 @@ def _cycle(row) -> dict:
10841096
"is_error": row["is_error"],
10851097
"no_work": row["no_work"],
10861098
"jira_key": row.get("jira_key"),
1099+
"external_key": row.get("external_key"),
1100+
"source_type": row.get("source_type"),
10871101
"repo": row.get("repo"),
10881102
"work_type": row.get("work_type"),
10891103
"summary": row.get("summary"),
@@ -1096,6 +1110,8 @@ def _memory(row) -> dict:
10961110
"category": row["category"],
10971111
"repo": row["repo"],
10981112
"jira_key": row["jira_key"],
1113+
"external_key": row.get("external_key"),
1114+
"source_type": row.get("source_type"),
10991115
"title": row["title"],
11001116
"content": row["content"],
11011117
"tags": list(row["tags"]) if row["tags"] else [],

memory-server/bot_memory_server/tools/slack.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@
1717
def register_slack_tools(mcp: FastMCP):
1818
@mcp.tool()
1919
async def slack_notify(
20-
jira_key: str,
21-
event_type: str,
22-
message: str,
20+
jira_key: Optional[str] = None,
21+
event_type: str = "",
22+
message: str = "",
2323
webhook_url: Optional[str] = os.environ.get("SLACK_WEBHOOK_URL"),
24+
external_key: Optional[str] = None,
25+
source_type: Optional[str] = None,
2426
) -> dict:
25-
"""Send a Slack notification. Deduplicates by jira_key (48h cooldown per ticket, any event type).
27+
"""Send a Slack notification. Deduplicates by external_key (48h cooldown per ticket, any event type).
28+
Lookup by external_key preferred; falls back to jira_key for backward compat.
2629
2730
event_type: 'pr_created', 'release_pending', 'needs_help', 'infra_error', 'review_reminder'.
2831
message: Human-readable message to post. Keep it concise (1-2 sentences + links).
@@ -32,25 +35,31 @@ async def slack_notify(
3235
Skipped silently if cooldown active or webhook not configured."""
3336
pool = get_pool()
3437

38+
lookup_key = external_key or jira_key
39+
if not lookup_key:
40+
raise ValueError("Either jira_key or external_key is required")
41+
lookup_source = source_type or ("jira" if jira_key else None)
42+
effective_jira_key = jira_key or external_key
43+
3544
if not webhook_url:
3645
return {"sent": False, "reason": "SLACK_WEBHOOK_URL not configured"}
3746

38-
# Check cooldown — any notification for this jira_key within 48h
47+
# Check cooldown — any notification for this key within 48h
3948
cutoff = datetime.now(timezone.utc) - timedelta(hours=COOLDOWN_HOURS)
4049
recent = await pool.fetchrow(
4150
"""
4251
SELECT id, event_type, sent_at FROM slack_notifications
43-
WHERE jira_key = $1 AND sent_at > $2
52+
WHERE external_key = $1 AND sent_at > $2
4453
ORDER BY sent_at DESC LIMIT 1
4554
""",
46-
jira_key,
55+
lookup_key,
4756
cutoff,
4857
)
4958

5059
if recent:
5160
return {
5261
"sent": False,
53-
"reason": f"Cooldown active — last {recent['event_type']} for {jira_key} sent {recent['sent_at'].isoformat()}",
62+
"reason": f"Cooldown active — last {recent['event_type']} for {lookup_key} sent {recent['sent_at'].isoformat()}",
5463
}
5564

5665
# Send to Slack
@@ -69,18 +78,18 @@ async def slack_notify(
6978
external_key, source_type)
7079
VALUES ($1, $2, $3, $4, $5)
7180
""",
72-
jira_key,
81+
effective_jira_key,
7382
event_type,
7483
message,
75-
jira_key,
76-
"jira",
84+
lookup_key,
85+
lookup_source or "jira",
7786
)
7887

7988
await bus.publish(
8089
Event(
8190
"slack_notification",
8291
{
83-
"jira_key": jira_key,
92+
"jira_key": effective_jira_key,
8493
"event_type": event_type,
8594
"message": message,
8695
},

0 commit comments

Comments
 (0)