mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-11 03:31:55 +00:00
feat(teams-pipeline): add plugin runtime and operator cli
Third slice of the Microsoft Teams meeting pipeline stack, salvaged onto current main. Adds the standalone teams_pipeline plugin that consumes Graph change notifications from the webhook listener, resolves meeting artifacts (transcript first, recording + STT fallback later), persists job state in a durable store, and exposes an operator CLI for inspection, replay, subscription management, and validation. Design choices follow maintainer review feedback on PR #19815: - Standalone plugin rather than bolted-on core surface (plugins/teams_pipeline/, kind: standalone in plugin.yaml). - Zero new model tools. The agent drives the pipeline by invoking the operator CLI via the terminal tool, guided by the skill that ships with a follow-up PR. - Reuses the existing msgraph_webhook gateway platform for Graph ingress. Pipeline runtime is wired in via bind_gateway_runtime and gated on plugins.enabled so gateways that don't run the plugin boot cleanly. Additions: - plugins/teams_pipeline/: runtime (gateway wiring + config builder), pipeline core, durable SQLite store, subscription maintenance helpers, Graph artifact resolution, operator CLI (list, show, run/replay, fetch dry-run, subscriptions list, subscribe, renew-subscription, delete-subscription, maintain-subscriptions, token-health, validate). - hermes_cli/main.py: second-pass plugin CLI discovery so any standalone plugin registered via ctx.register_cli_command() outside the memory-plugin convention path gets its subcommand wired into argparse without touching core. - gateway/run.py: _teams_pipeline_plugin_enabled() config gate, _wire_teams_pipeline_runtime() binding after adapter setup, and the two runner attributes used by the runtime. Credit to @dlkakbs for the entire plugin implementation.
This commit is contained in:
parent
ea86714cc0
commit
07bbd93337
14 changed files with 3332 additions and 1 deletions
462
plugins/teams_pipeline/cli.py
Normal file
462
plugins/teams_pipeline/cli.py
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
"""CLI commands for the Teams meeting pipeline plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
from gateway.config import Platform, load_gateway_config
|
||||
from plugins.teams_pipeline.meetings import (
|
||||
enrich_meeting_with_call_record,
|
||||
fetch_preferred_transcript_text,
|
||||
list_recording_artifacts,
|
||||
resolve_meeting_reference,
|
||||
)
|
||||
from plugins.teams_pipeline.models import GraphSubscription
|
||||
from plugins.teams_pipeline.pipeline import TeamsMeetingPipeline
|
||||
from plugins.teams_pipeline.store import TeamsPipelineStore, resolve_teams_pipeline_store_path
|
||||
from plugins.teams_pipeline.subscriptions import (
|
||||
build_graph_client,
|
||||
maintain_graph_subscriptions,
|
||||
sync_graph_subscription_record,
|
||||
)
|
||||
from tools.microsoft_graph_auth import MicrosoftGraphConfigError, MicrosoftGraphTokenProvider
|
||||
|
||||
|
||||
def register_cli(subparser: argparse.ArgumentParser) -> None:
|
||||
subs = subparser.add_subparsers(dest="teams_pipeline_action")
|
||||
|
||||
list_p = subs.add_parser("list", aliases=["ls"], help="List recent Teams pipeline jobs")
|
||||
list_p.add_argument("--limit", type=int, default=20)
|
||||
list_p.add_argument("--status", default="")
|
||||
list_p.add_argument("--store-path", default="")
|
||||
|
||||
show_p = subs.add_parser("show", help="Show a stored Teams pipeline job")
|
||||
show_p.add_argument("job_id")
|
||||
show_p.add_argument("--store-path", default="")
|
||||
|
||||
run_p = subs.add_parser("run", aliases=["replay"], help="Replay a stored Teams pipeline job")
|
||||
run_p.add_argument("job_id")
|
||||
run_p.add_argument("--store-path", default="")
|
||||
|
||||
fetch_p = subs.add_parser("fetch", aliases=["test"], help="Dry-run meeting artifact resolution")
|
||||
fetch_p.add_argument("--meeting-id", default="")
|
||||
fetch_p.add_argument("--join-web-url", default="")
|
||||
fetch_p.add_argument("--tenant-id", default="")
|
||||
fetch_p.add_argument("--call-record-id", default="")
|
||||
|
||||
subs_p = subs.add_parser("subscriptions", aliases=["subs"], help="List Graph subscriptions")
|
||||
subs_p.add_argument("--store-path", default="")
|
||||
|
||||
sub_p = subs.add_parser("subscribe", help="Create a Microsoft Graph subscription")
|
||||
sub_p.add_argument("--resource", required=True)
|
||||
sub_p.add_argument("--notification-url", required=True)
|
||||
sub_p.add_argument("--change-type", default="")
|
||||
sub_p.add_argument("--expiration", default="")
|
||||
sub_p.add_argument("--client-state", default="")
|
||||
sub_p.add_argument("--lifecycle-notification-url", default="")
|
||||
sub_p.add_argument("--latest-supported-tls-version", default="v1_2")
|
||||
sub_p.add_argument("--store-path", default="")
|
||||
|
||||
renew_p = subs.add_parser("renew-subscription", help="Renew a Microsoft Graph subscription")
|
||||
renew_p.add_argument("subscription_id")
|
||||
renew_p.add_argument("--expiration", required=True)
|
||||
renew_p.add_argument("--store-path", default="")
|
||||
|
||||
delete_p = subs.add_parser("delete-subscription", help="Delete a Microsoft Graph subscription")
|
||||
delete_p.add_argument("subscription_id")
|
||||
delete_p.add_argument("--store-path", default="")
|
||||
|
||||
maintain_p = subs.add_parser("maintain-subscriptions", help="Renew near-expiry managed subscriptions")
|
||||
maintain_p.add_argument("--renew-within-hours", type=int, default=24)
|
||||
maintain_p.add_argument("--extend-hours", type=int, default=24)
|
||||
maintain_p.add_argument("--dry-run", action="store_true")
|
||||
maintain_p.add_argument("--store-path", default="")
|
||||
maintain_p.add_argument("--client-state", default="")
|
||||
|
||||
token_p = subs.add_parser("token-health", aliases=["token"], help="Inspect Graph token health")
|
||||
token_p.add_argument("--force-refresh", action="store_true")
|
||||
|
||||
validate_p = subs.add_parser("validate", help="Validate Teams pipeline configuration snapshot")
|
||||
validate_p.add_argument("--store-path", default="")
|
||||
|
||||
subparser.set_defaults(func=teams_pipeline_command)
|
||||
|
||||
|
||||
def teams_pipeline_command(args: argparse.Namespace) -> int:
|
||||
action = getattr(args, "teams_pipeline_action", None)
|
||||
if not action:
|
||||
print(
|
||||
"Usage: hermes teams-pipeline "
|
||||
"{list|show|run|fetch|subscriptions|subscribe|renew-subscription|delete-subscription|maintain-subscriptions|token-health|validate}"
|
||||
)
|
||||
return 2
|
||||
|
||||
try:
|
||||
if action in ("list", "ls"):
|
||||
_cmd_list(args)
|
||||
elif action == "show":
|
||||
_cmd_show(args)
|
||||
elif action in ("run", "replay"):
|
||||
_cmd_run(args)
|
||||
elif action in ("fetch", "test"):
|
||||
_cmd_fetch(args)
|
||||
elif action in ("subscriptions", "subs"):
|
||||
_cmd_subscriptions(args)
|
||||
elif action == "subscribe":
|
||||
_cmd_subscribe(args)
|
||||
elif action == "renew-subscription":
|
||||
_cmd_renew_subscription(args)
|
||||
elif action == "delete-subscription":
|
||||
_cmd_delete_subscription(args)
|
||||
elif action == "maintain-subscriptions":
|
||||
_cmd_maintain_subscriptions(args)
|
||||
elif action in ("token-health", "token"):
|
||||
_cmd_token_health(args)
|
||||
elif action == "validate":
|
||||
_cmd_validate(args)
|
||||
else:
|
||||
print(f"Unknown teams-pipeline action: {action}")
|
||||
return 2
|
||||
return 0
|
||||
except MicrosoftGraphConfigError:
|
||||
print(_graph_setup_hint())
|
||||
return 1
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _store_path(path_arg: str | None) -> Path:
|
||||
return resolve_teams_pipeline_store_path(path_arg)
|
||||
|
||||
|
||||
def _graph_setup_hint() -> str:
|
||||
return f"""
|
||||
Microsoft Graph is not configured. Add these to {display_hermes_home()}/.env:
|
||||
|
||||
MSGRAPH_TENANT_ID=...
|
||||
MSGRAPH_CLIENT_ID=...
|
||||
MSGRAPH_CLIENT_SECRET=...
|
||||
|
||||
Then restart the gateway or rerun this command.
|
||||
"""
|
||||
|
||||
|
||||
def _iso_utc_timestamp(hours_from_now: int) -> str:
|
||||
return (datetime.now(timezone.utc) + timedelta(hours=hours_from_now)).replace(
|
||||
microsecond=0
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _default_change_type_for_resource(resource: str) -> str:
|
||||
normalized = str(resource or "").strip().lower()
|
||||
if normalized.startswith("communications/onlinemeetings/getalltranscripts"):
|
||||
return "created"
|
||||
if normalized.startswith("communications/onlinemeetings/getallrecordings"):
|
||||
return "created"
|
||||
if normalized.startswith("communications/callrecords"):
|
||||
return "created"
|
||||
return "updated"
|
||||
|
||||
|
||||
def _compact_job(job: dict) -> dict:
|
||||
payload = dict(job)
|
||||
summary = dict(payload.get("summary_payload") or {})
|
||||
transcript = summary.pop("transcript_text", None)
|
||||
if transcript:
|
||||
summary["transcript_preview"] = str(transcript)[:240]
|
||||
payload["summary_payload"] = summary or None
|
||||
return payload
|
||||
|
||||
|
||||
def _sync_subscription_record(
|
||||
store: TeamsPipelineStore,
|
||||
subscription_payload: dict[str, Any],
|
||||
*,
|
||||
status: str = "active",
|
||||
renewed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
normalized = GraphSubscription.from_dict(subscription_payload).to_dict()
|
||||
normalized["status"] = status
|
||||
if renewed:
|
||||
normalized["latest_renewal_at"] = _iso_utc_timestamp(0)
|
||||
return store.upsert_subscription(normalized["subscription_id"], normalized)
|
||||
|
||||
|
||||
def _validate_configuration_snapshot(store: TeamsPipelineStore) -> dict[str, Any]:
|
||||
env = os.environ
|
||||
issues: list[str] = []
|
||||
warnings: list[str] = []
|
||||
gateway_config = load_gateway_config()
|
||||
webhook_config = gateway_config.platforms.get(Platform.MSGRAPH_WEBHOOK)
|
||||
teams_config = gateway_config.platforms.get(Platform("teams"))
|
||||
|
||||
graph = {
|
||||
"tenant_id": bool(env.get("MSGRAPH_TENANT_ID")),
|
||||
"client_id": bool(env.get("MSGRAPH_CLIENT_ID")),
|
||||
"client_secret": bool(env.get("MSGRAPH_CLIENT_SECRET")),
|
||||
}
|
||||
webhook_enabled = bool(webhook_config and webhook_config.enabled)
|
||||
teams_enabled = bool(teams_config and teams_config.enabled)
|
||||
teams_extra = dict((teams_config.extra or {}) if teams_config else {})
|
||||
teams_mode = str(teams_extra.get("delivery_mode") or "").strip() or None
|
||||
|
||||
if not all(graph.values()):
|
||||
issues.append("Microsoft Graph app-only credentials are incomplete.")
|
||||
if not webhook_enabled:
|
||||
issues.append("MSGRAPH_WEBHOOK_ENABLED is not enabled.")
|
||||
if not teams_enabled:
|
||||
warnings.append("Teams outbound delivery is disabled.")
|
||||
elif teams_mode == "incoming_webhook":
|
||||
if not teams_extra.get("incoming_webhook_url"):
|
||||
issues.append("TEAMS_INCOMING_WEBHOOK_URL is required for incoming_webhook mode.")
|
||||
elif teams_mode == "graph":
|
||||
missing: list[str] = []
|
||||
has_graph_delivery_token = bool(
|
||||
(teams_config.token if teams_config else "") or teams_extra.get("access_token")
|
||||
)
|
||||
has_graph_app_credentials = all(graph.values())
|
||||
if not has_graph_delivery_token and not has_graph_app_credentials:
|
||||
missing.append(
|
||||
"TEAMS_GRAPH_ACCESS_TOKEN or complete MSGRAPH_* app credentials"
|
||||
)
|
||||
if not teams_extra.get("team_id"):
|
||||
missing.append("TEAMS_TEAM_ID")
|
||||
channel_id = teams_extra.get("channel_id") or teams_extra.get("chat_id")
|
||||
if not channel_id and not (teams_config and teams_config.home_channel):
|
||||
missing.append("TEAMS_CHANNEL_ID")
|
||||
for key in missing:
|
||||
issues.append(f"{key} is required for graph delivery mode.")
|
||||
else:
|
||||
warnings.append("TEAMS_DELIVERY_MODE is not set.")
|
||||
|
||||
return {
|
||||
"ok": not issues,
|
||||
"issues": issues,
|
||||
"warnings": warnings,
|
||||
"graph_config": graph,
|
||||
"webhook_enabled": webhook_enabled,
|
||||
"teams_enabled": teams_enabled,
|
||||
"teams_delivery_mode": teams_mode,
|
||||
"store_path": str(store.path),
|
||||
"store_stats": store.stats(),
|
||||
}
|
||||
|
||||
|
||||
def _cmd_list(args) -> None:
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
jobs = list(store.list_jobs().values())
|
||||
status = str(getattr(args, "status", "") or "").strip().lower()
|
||||
if status:
|
||||
jobs = [job for job in jobs if str(job.get("status") or "").lower() == status]
|
||||
jobs.sort(key=lambda item: str((item or {}).get("updated_at") or ""), reverse=True)
|
||||
limit = max(1, min(int(getattr(args, "limit", 20) or 20), 100))
|
||||
jobs = jobs[:limit]
|
||||
|
||||
if not jobs:
|
||||
print("No Teams meeting pipeline jobs found.")
|
||||
return
|
||||
|
||||
print(f"\n{len(jobs)} Teams pipeline job(s):\n")
|
||||
for job in jobs:
|
||||
meeting_id = ((job.get("meeting_ref") or {}).get("meeting_id") or "unknown")
|
||||
print(f" ◆ {job.get('job_id')}")
|
||||
print(f" status: {job.get('status')}")
|
||||
print(f" meeting: {meeting_id}")
|
||||
if job.get("selected_artifact_strategy"):
|
||||
print(f" strategy: {job.get('selected_artifact_strategy')}")
|
||||
if job.get("updated_at"):
|
||||
print(f" updated: {job.get('updated_at')}")
|
||||
if job.get("error_info"):
|
||||
print(f" error: {job.get('error_info')}")
|
||||
print()
|
||||
|
||||
|
||||
def _cmd_show(args) -> None:
|
||||
job_id = str(getattr(args, "job_id", "") or "").strip()
|
||||
if not job_id:
|
||||
print("job_id is required")
|
||||
return
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
job = store.get_job(job_id)
|
||||
if not job:
|
||||
print(f"Unknown job: {job_id}")
|
||||
return
|
||||
print(json.dumps(_compact_job(job), indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _cmd_run(args) -> None:
|
||||
job_id = str(getattr(args, "job_id", "") or "").strip()
|
||||
if not job_id:
|
||||
print("job_id is required")
|
||||
return
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
pipeline = TeamsMeetingPipeline(graph_client=build_graph_client(), store=store, config={})
|
||||
result = _run_async(pipeline.run_job(job_id))
|
||||
print(json.dumps(_compact_job(result.to_dict()), indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _cmd_fetch(args) -> None:
|
||||
meeting_id = str(getattr(args, "meeting_id", "") or "").strip() or None
|
||||
join_web_url = str(getattr(args, "join_web_url", "") or "").strip() or None
|
||||
tenant_id = str(getattr(args, "tenant_id", "") or "").strip() or None
|
||||
call_record_id = str(getattr(args, "call_record_id", "") or "").strip() or None
|
||||
if not meeting_id and not join_web_url:
|
||||
print("meeting_id or join_web_url is required")
|
||||
return
|
||||
|
||||
client = build_graph_client()
|
||||
meeting_ref = _run_async(
|
||||
resolve_meeting_reference(
|
||||
client,
|
||||
meeting_id=meeting_id,
|
||||
join_web_url=join_web_url,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
)
|
||||
transcript_artifact, transcript_text = _run_async(fetch_preferred_transcript_text(client, meeting_ref))
|
||||
recordings = _run_async(list_recording_artifacts(client, meeting_ref))
|
||||
call_record = _run_async(
|
||||
enrich_meeting_with_call_record(client, meeting_ref, call_record_id=call_record_id)
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"meeting_ref": meeting_ref.to_dict(),
|
||||
"transcript_available": bool(transcript_artifact and transcript_text),
|
||||
"transcript_artifact": transcript_artifact.to_dict() if transcript_artifact else None,
|
||||
"transcript_preview": (transcript_text or "")[:240] or None,
|
||||
"recording_count": len(recordings),
|
||||
"recordings": [recording.to_dict() for recording in recordings[:5]],
|
||||
"call_record": call_record.to_dict() if call_record else None,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cmd_subscriptions(args) -> None:
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
client = build_graph_client()
|
||||
subscriptions = _run_async(client.collect_paginated("/subscriptions"))
|
||||
for sub in subscriptions:
|
||||
try:
|
||||
_sync_subscription_record(store, sub, status="active")
|
||||
except Exception:
|
||||
continue
|
||||
if not subscriptions:
|
||||
print("No Microsoft Graph subscriptions found.")
|
||||
return
|
||||
|
||||
print(f"\n{len(subscriptions)} Microsoft Graph subscription(s):\n")
|
||||
for sub in subscriptions:
|
||||
print(f" ◆ {sub.get('id') or 'unknown'}")
|
||||
print(f" resource: {sub.get('resource') or 'unknown'}")
|
||||
print(f" changeType: {sub.get('changeType') or 'unknown'}")
|
||||
if sub.get("expirationDateTime"):
|
||||
print(f" expires: {sub.get('expirationDateTime')}")
|
||||
if sub.get("notificationUrl"):
|
||||
print(f" notify: {sub.get('notificationUrl')}")
|
||||
print()
|
||||
|
||||
|
||||
def _cmd_subscribe(args) -> None:
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
resource = str(getattr(args, "resource", "") or "").strip()
|
||||
notification_url = str(getattr(args, "notification_url", "") or "").strip()
|
||||
change_type = str(getattr(args, "change_type", "") or "").strip() or _default_change_type_for_resource(resource)
|
||||
expiration = str(getattr(args, "expiration", "") or "").strip() or _iso_utc_timestamp(1)
|
||||
client_state = str(getattr(args, "client_state", "") or "").strip()
|
||||
lifecycle_url = str(getattr(args, "lifecycle_notification_url", "") or "").strip()
|
||||
tls_version = str(getattr(args, "latest_supported_tls_version", "") or "").strip() or "v1_2"
|
||||
|
||||
payload = {
|
||||
"changeType": change_type,
|
||||
"notificationUrl": notification_url,
|
||||
"resource": resource,
|
||||
"expirationDateTime": expiration,
|
||||
"latestSupportedTlsVersion": tls_version,
|
||||
}
|
||||
if client_state:
|
||||
payload["clientState"] = client_state
|
||||
if lifecycle_url:
|
||||
payload["lifecycleNotificationUrl"] = lifecycle_url
|
||||
|
||||
result = _run_async(build_graph_client().post_json("/subscriptions", json_body=payload))
|
||||
_sync_subscription_record(store, result, status="active")
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _cmd_renew_subscription(args) -> None:
|
||||
subscription_id = str(getattr(args, "subscription_id", "") or "").strip()
|
||||
expiration = str(getattr(args, "expiration", "") or "").strip()
|
||||
if not subscription_id or not expiration:
|
||||
print("subscription_id and --expiration are required")
|
||||
return
|
||||
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
result = _run_async(
|
||||
build_graph_client().patch_json(
|
||||
f"/subscriptions/{subscription_id}",
|
||||
json_body={"expirationDateTime": expiration},
|
||||
)
|
||||
)
|
||||
merged = {"id": subscription_id, **(result or {}), "expirationDateTime": expiration}
|
||||
_sync_subscription_record(store, merged, status="active", renewed=True)
|
||||
print(json.dumps(merged, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _cmd_delete_subscription(args) -> None:
|
||||
subscription_id = str(getattr(args, "subscription_id", "") or "").strip()
|
||||
if not subscription_id:
|
||||
print("subscription_id is required")
|
||||
return
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
result = _run_async(build_graph_client().delete(f"/subscriptions/{subscription_id}"))
|
||||
store.delete_subscription(subscription_id)
|
||||
print(json.dumps({"subscription_id": subscription_id, "result": result}, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _cmd_maintain_subscriptions(args) -> None:
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
result = _run_async(
|
||||
maintain_graph_subscriptions(
|
||||
client=build_graph_client(),
|
||||
store=store,
|
||||
renew_within_hours=int(getattr(args, "renew_within_hours", 24) or 24),
|
||||
extend_hours=int(getattr(args, "extend_hours", 24) or 24),
|
||||
dry_run=bool(getattr(args, "dry_run", False)),
|
||||
client_state=str(getattr(args, "client_state", "") or "").strip() or None,
|
||||
)
|
||||
)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _cmd_token_health(args) -> None:
|
||||
provider = MicrosoftGraphTokenProvider.from_env()
|
||||
health = provider.inspect_token_health()
|
||||
payload = dict(health)
|
||||
if getattr(args, "force_refresh", False):
|
||||
try:
|
||||
token = _run_async(provider.get_access_token(force_refresh=True))
|
||||
payload["last_refresh_succeeded"] = True
|
||||
payload["access_token_length"] = len(token or "")
|
||||
except Exception as exc:
|
||||
payload["last_refresh_succeeded"] = False
|
||||
payload["refresh_error"] = str(exc)
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _cmd_validate(args) -> None:
|
||||
store = TeamsPipelineStore(_store_path(getattr(args, "store_path", None)))
|
||||
snapshot = _validate_configuration_snapshot(store)
|
||||
print(json.dumps(snapshot, indent=2, sort_keys=True))
|
||||
Loading…
Add table
Add a link
Reference in a new issue