From 077a07b233ac8141ef47fdc897ea1d4f1b93bb7d Mon Sep 17 00:00:00 2001 From: null Date: Tue, 26 May 2026 15:41:24 -0500 Subject: [PATCH] bot --- backend/app/api/bot.py | 173 +++++++++++++ backend/app/main.py | 2 + backend/app/models/bot.py | 38 +++ backend/app/schemas/bot.py | 43 ++++ ...e6f7a8b9c0_add_bot_api_keys_and_reports.py | 63 +++++ frontend/src/app/dashboard/page.tsx | 5 + .../src/app/settings/bot-api-keys/page.tsx | 243 ++++++++++++++++++ frontend/src/app/settings/page.tsx | 7 + .../components/dashboard/BotStatusSection.tsx | 80 ++++++ frontend/src/lib/api/bot.ts | 76 ++++++ scripts/report-project.py | 89 +++++++ 11 files changed, 819 insertions(+) create mode 100644 backend/app/api/bot.py create mode 100644 backend/app/models/bot.py create mode 100644 backend/app/schemas/bot.py create mode 100644 backend/migrations/versions/d5e6f7a8b9c0_add_bot_api_keys_and_reports.py create mode 100644 frontend/src/app/settings/bot-api-keys/page.tsx create mode 100644 frontend/src/components/dashboard/BotStatusSection.tsx create mode 100644 frontend/src/lib/api/bot.ts create mode 100644 scripts/report-project.py diff --git a/backend/app/api/bot.py b/backend/app/api/bot.py new file mode 100644 index 0000000..89ec1d0 --- /dev/null +++ b/backend/app/api/bot.py @@ -0,0 +1,173 @@ +"""Bot API key management and project report endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from sqlmodel import col, desc, select + +from app.api.deps import require_org_admin, require_org_member +from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token +from app.core.time import utcnow +from app.db.session import get_session +from app.models.bot import BotApiKey, BotReport +from app.schemas.bot import ( + BotApiKeyCreate, + BotApiKeyCreated, + BotApiKeyRead, + BotReportCreate, + BotReportRead, +) +from app.services.organizations import OrganizationContext + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + +router = APIRouter(prefix="/bot", tags=["bot"]) + +SESSION_DEP = Depends(get_session) +ORG_MEMBER_DEP = Depends(require_org_member) +ORG_ADMIN_DEP = Depends(require_org_admin) + + +async def _get_bot_key_auth( + x_bot_key: str | None = Header(default=None, alias="X-Bot-Key"), + session: AsyncSession = SESSION_DEP, +) -> BotApiKey: + """Validate X-Bot-Key header against stored hashes. Returns the matching key row.""" + if not x_bot_key: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="X-Bot-Key header required.") + keys = list(await session.exec(select(BotApiKey))) + for key in keys: + if verify_agent_token(x_bot_key, key.key_hash): + key.last_used_at = utcnow() + session.add(key) + await session.commit() + return key + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bot key.") + + +BOT_KEY_DEP = Depends(_get_bot_key_auth) + + +# --------------------------------------------------------------------------- +# Key management (user auth) +# --------------------------------------------------------------------------- + +@router.post("/keys", response_model=BotApiKeyCreated, status_code=status.HTTP_201_CREATED) +async def create_bot_key( + data: BotApiKeyCreate, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> BotApiKeyCreated: + """Generate a new bot API key. The plaintext key is returned once — store it immediately.""" + plaintext = generate_agent_token() + key = BotApiKey( + organization_id=ctx.organization.id, + name=data.name, + key_hash=hash_agent_token(plaintext), + key_last_four=plaintext[-4:], + ) + session.add(key) + await session.commit() + await session.refresh(key) + return BotApiKeyCreated( + id=key.id, + name=key.name, + key_last_four=key.key_last_four, + created_at=key.created_at, + last_used_at=key.last_used_at, + key=plaintext, + ) + + +@router.get("/keys", response_model=list[BotApiKeyRead]) +async def list_bot_keys( + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> list[BotApiKeyRead]: + """List all bot API keys for this organization.""" + keys = list(await session.exec( + select(BotApiKey) + .where(col(BotApiKey.organization_id) == ctx.organization.id) + .order_by(desc(col(BotApiKey.created_at))) + )) + return [BotApiKeyRead.model_validate(k, from_attributes=True) for k in keys] + + +@router.delete("/keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_bot_key( + key_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> None: + """Revoke a bot API key.""" + key = await session.get(BotApiKey, key_id) + if key is None or key.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await session.delete(key) + await session.commit() + + +# --------------------------------------------------------------------------- +# Reporting (bot key auth) +# --------------------------------------------------------------------------- + +@router.post("/report", response_model=BotReportRead, status_code=status.HTTP_201_CREATED) +async def create_bot_report( + data: BotReportCreate, + session: AsyncSession = SESSION_DEP, + key: BotApiKey = BOT_KEY_DEP, +) -> BotReportRead: + """Record what the bot is currently working on.""" + report = BotReport( + organization_id=key.organization_id, + key_id=key.id, + project=data.project, + task=data.task, + status=data.status, + detail=data.detail, + ) + session.add(report) + await session.commit() + await session.refresh(report) + return BotReportRead.model_validate(report, from_attributes=True) + + +# --------------------------------------------------------------------------- +# Report reading (user auth) +# --------------------------------------------------------------------------- + +@router.get("/reports/latest", response_model=BotReportRead) +async def get_latest_bot_report( + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> BotReportRead: + """Return the most recent bot report for this organization.""" + report = (await session.exec( + select(BotReport) + .where(col(BotReport.organization_id) == ctx.organization.id) + .order_by(desc(col(BotReport.reported_at))) + .limit(1) + )).first() + if report is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return BotReportRead.model_validate(report, from_attributes=True) + + +@router.get("/reports", response_model=list[BotReportRead]) +async def list_bot_reports( + limit: int = Query(default=50, ge=1, le=200), + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> list[BotReportRead]: + """List bot reports newest-first.""" + reports = list(await session.exec( + select(BotReport) + .where(col(BotReport.organization_id) == ctx.organization.id) + .order_by(desc(col(BotReport.reported_at))) + .limit(limit) + )) + return [BotReportRead.model_validate(r, from_attributes=True) for r in reports] diff --git a/backend/app/main.py b/backend/app/main.py index 7de42a6..88c38f1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from fastapi.openapi.utils import get_openapi from fastapi_pagination import add_pagination from app.api.activity import router as activity_router +from app.api.bot import router as bot_router from app.api.agent import router as agent_router from app.api.agent_forgejo import router as agent_forgejo_router from app.api.agent_sessions import router as agent_sessions_router @@ -607,6 +608,7 @@ def readyz() -> HealthStatusResponse: api_v1 = APIRouter(prefix="/api/v1") api_v1.include_router(auth_router) +api_v1.include_router(bot_router) api_v1.include_router(agent_router) api_v1.include_router(agents_router) api_v1.include_router(activity_router) diff --git a/backend/app/models/bot.py b/backend/app/models/bot.py new file mode 100644 index 0000000..a60171a --- /dev/null +++ b/backend/app/models/bot.py @@ -0,0 +1,38 @@ +"""Models for bot API keys and project reports.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, Column +from sqlmodel import Field + +from app.core.time import utcnow +from app.models.base import QueryModel + + +class BotApiKey(QueryModel, table=True): + __tablename__ = "bot_api_keys" # pyright: ignore[reportAssignmentType] + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + name: str = Field(index=True) + key_hash: str = Field(index=True) + key_last_four: str + created_at: datetime = Field(default_factory=utcnow) + last_used_at: datetime | None = Field(default=None) + + +class BotReport(QueryModel, table=True): + __tablename__ = "bot_reports" # pyright: ignore[reportAssignmentType] + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + key_id: UUID = Field(foreign_key="bot_api_keys.id", index=True) + project: str | None = Field(default=None) + task: str | None = Field(default=None) + status: str | None = Field(default=None) + detail: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + reported_at: datetime = Field(default_factory=utcnow, index=True) diff --git a/backend/app/schemas/bot.py b/backend/app/schemas/bot.py new file mode 100644 index 0000000..d6d917d --- /dev/null +++ b/backend/app/schemas/bot.py @@ -0,0 +1,43 @@ +"""Schemas for bot API keys and project reports.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from sqlmodel import Field, SQLModel + + +class BotApiKeyCreate(SQLModel): + name: str + + +class BotApiKeyRead(SQLModel): + id: UUID + name: str + key_last_four: str + created_at: datetime + last_used_at: datetime | None + + +class BotApiKeyCreated(BotApiKeyRead): + """Returned once on creation — includes the plaintext key, never stored.""" + key: str + + +class BotReportCreate(SQLModel): + project: str | None = None + task: str | None = None + status: str | None = "busy" + detail: dict[str, Any] | None = None + + +class BotReportRead(SQLModel): + id: UUID + key_id: UUID + project: str | None + task: str | None + status: str | None + detail: dict[str, Any] | None + reported_at: datetime diff --git a/backend/migrations/versions/d5e6f7a8b9c0_add_bot_api_keys_and_reports.py b/backend/migrations/versions/d5e6f7a8b9c0_add_bot_api_keys_and_reports.py new file mode 100644 index 0000000..2b84f4b --- /dev/null +++ b/backend/migrations/versions/d5e6f7a8b9c0_add_bot_api_keys_and_reports.py @@ -0,0 +1,63 @@ +"""add bot api keys and reports + +Revision ID: d5e6f7a8b9c0 +Revises: c6d7e8f9a0b1 +Create Date: 2026-05-26 00:00:00.000000 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "d5e6f7a8b9c0" +down_revision = "c6d7e8f9a0b1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = inspect(op.get_bind()) + + if not inspector.has_table("bot_api_keys"): + op.create_table( + "bot_api_keys", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("key_hash", sa.String(), nullable=False), + sa.Column("key_last_four", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_bot_api_keys_organization_id", "bot_api_keys", ["organization_id"]) + op.create_index("ix_bot_api_keys_key_hash", "bot_api_keys", ["key_hash"]) + op.create_index("ix_bot_api_keys_name", "bot_api_keys", ["name"]) + + if not inspector.has_table("bot_reports"): + op.create_table( + "bot_reports", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("key_id", sa.Uuid(), nullable=False), + sa.Column("project", sa.String(), nullable=True), + sa.Column("task", sa.String(), nullable=True), + sa.Column("status", sa.String(), nullable=True), + sa.Column("detail", sa.JSON(), nullable=True), + sa.Column("reported_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), + sa.ForeignKeyConstraint(["key_id"], ["bot_api_keys.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_bot_reports_organization_id", "bot_reports", ["organization_id"]) + op.create_index("ix_bot_reports_key_id", "bot_reports", ["key_id"]) + op.create_index("ix_bot_reports_reported_at", "bot_reports", ["reported_at"]) + + +def downgrade() -> None: + op.drop_table("bot_reports") + op.drop_table("bot_api_keys") diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index e3f254b..8d00e12 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -63,6 +63,7 @@ import { } from "@/components/dashboard/RuntimeUsageSection"; import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel"; import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel"; +import { BotStatusSection } from "@/components/dashboard/BotStatusSection"; import { type listAgentsApiV1AgentsGetResponse, useListAgentsApiV1AgentsGet, @@ -1462,6 +1463,10 @@ export default function DashboardPage() { )} +
+ +
+
void; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + if (!created) return; + void navigator.clipboard.writeText(created.key); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [created]); + + return ( + { if (!open) onClose(); }}> + + + API Key Generated + + Copy this key now — it will not be shown again. + + +
+
+ + {created?.key} + + +
+

+ Add this to Ripley's TOOLS.md as{" "} + PIPELINE_BOT_KEY={created?.key} +

+
+
+ +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Key row +// --------------------------------------------------------------------------- + +function KeyRow({ + apiKey, + onDelete, +}: { + apiKey: BotApiKeyRead; + onDelete: (id: string) => void; +}) { + const [confirmOpen, setConfirmOpen] = useState(false); + const queryClient = useQueryClient(); + + const deleteMutation = useMutation({ + mutationFn: () => deleteBotKey(apiKey.id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["bot-keys"] }); + setConfirmOpen(false); + }, + }); + + const createdAt = new Date(apiKey.created_at).toLocaleDateString(); + const lastUsed = apiKey.last_used_at + ? new Date(apiKey.last_used_at).toLocaleString() + : "Never"; + + return ( + <> +
+
+ +
+

{apiKey.name}

+

+ ending ••••{apiKey.key_last_four} · created {createdAt} · last used {lastUsed} +

+
+
+ +
+ + This will permanently revoke {apiKey.name}{" "} + (ending ••••{apiKey.key_last_four}). Any scripts using this key will stop working immediately. + + } + onConfirm={() => deleteMutation.mutate()} + isConfirming={deleteMutation.isPending} + confirmLabel="Revoke" + confirmVariant="primary" + /> + + ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function BotApiKeysPage() { + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + const [createdKey, setCreatedKey] = useState(null); + + const keysQuery = useQuery({ + queryKey: ["bot-keys"], + queryFn: listBotKeys, + }); + + const createMutation = useMutation({ + mutationFn: () => createBotKey(name.trim()), + onSuccess: (data) => { + setCreatedKey(data); + setName(""); + void queryClient.invalidateQueries({ queryKey: ["bot-keys"] }); + }, + }); + + const handleGenerate = useCallback(() => { + if (!name.trim()) return; + createMutation.mutate(); + }, [name, createMutation]); + + const keys = keysQuery.data ?? []; + + return ( + +
+ + {/* Generate section */} +
+
+ +

Generate a new key

+
+

+ Give the key a name (e.g. "Ripley"), then click Generate. The plaintext key + is shown once — copy it immediately. +

+
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleGenerate(); }} + className="max-w-xs" + /> + +
+ {createMutation.isError && ( +

Failed to generate key. Try again.

+ )} +
+ + {/* Key list */} +
+

Active keys

+ {keysQuery.isLoading && ( +
+ Loading… +
+ )} + {!keysQuery.isLoading && keys.length === 0 && ( +

No keys yet. Generate one above.

+ )} + {keys.map((k) => ( + void queryClient.invalidateQueries({ queryKey: ["bot-keys"] })} /> + ))} +
+
+ + setCreatedKey(null)} /> +
+ ); +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 3d9c59b..5609550 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -510,6 +510,13 @@ export default function SettingsPage() { icon: GitBranch, tone: "warning", }, + { + href: "/settings/bot-api-keys", + label: "Bot API keys", + description: "Generate and manage API keys for the OpenClaw agent (Ripley).", + icon: Bot, + tone: "neutral", + }, ]; const operationItems: SettingsLink[] = isAdmin diff --git a/frontend/src/components/dashboard/BotStatusSection.tsx b/frontend/src/components/dashboard/BotStatusSection.tsx new file mode 100644 index 0000000..7e0c89e --- /dev/null +++ b/frontend/src/components/dashboard/BotStatusSection.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Bot, Clock } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { getLatestBotReport, type BotReportRead } from "@/lib/api/bot"; +import { DashboardSection } from "./DashboardSection"; + +function timeAgo(iso: string): string { + const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function StatusBadge({ status }: { status: string | null }) { + if (!status) return null; + const styles: Record = { + busy: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]", + idle: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]", + error: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]", + }; + const style = styles[status.toLowerCase()] ?? "bg-[color:var(--surface-strong)] text-muted"; + return ( + + {status} + + ); +} + +function ReportContent({ report }: { report: BotReportRead }) { + return ( +
+
+
+

+ {report.project ?? "—"} +

+ {report.task && ( +

{report.task}

+ )} +
+ +
+
+ + {timeAgo(report.reported_at)} +
+
+ ); +} + +function EmptyState() { + return ( +
+ + Ripley hasn't reported in yet. +
+ ); +} + +export function BotStatusSection() { + const { data: report } = useQuery({ + queryKey: ["bot-report-latest"], + queryFn: getLatestBotReport, + refetchInterval: 30_000, + }); + + return ( + + {report ? : } + + ); +} diff --git a/frontend/src/lib/api/bot.ts b/frontend/src/lib/api/bot.ts new file mode 100644 index 0000000..55668e8 --- /dev/null +++ b/frontend/src/lib/api/bot.ts @@ -0,0 +1,76 @@ +"use client"; + +import { customFetch } from "@/api/mutator"; + +type ApiResponse = { + data: T; + status: number; + headers: Headers; +}; + +export type BotApiKeyRead = { + id: string; + name: string; + key_last_four: string; + created_at: string; + last_used_at: string | null; +}; + +export type BotApiKeyCreated = BotApiKeyRead & { + key: string; +}; + +export type BotReportRead = { + id: string; + key_id: string; + project: string | null; + task: string | null; + status: string | null; + detail: Record | null; + reported_at: string; +}; + +export async function listBotKeys(): Promise { + const response = await customFetch>( + "/api/v1/bot/keys", + { method: "GET" }, + ); + return response.data; +} + +export async function createBotKey(name: string): Promise { + const response = await customFetch>( + "/api/v1/bot/keys", + { + method: "POST", + body: JSON.stringify({ name }), + }, + ); + return response.data; +} + +export async function deleteBotKey(id: string): Promise { + await customFetch>(`/api/v1/bot/keys/${id}`, { + method: "DELETE", + }); +} + +export async function listBotReports(limit = 50): Promise { + const response = await customFetch>( + `/api/v1/bot/reports?limit=${limit}`, + { method: "GET" }, + ); + return response.data; +} + +export async function getLatestBotReport(): Promise { + try { + const response = await customFetch>( + "/api/v1/bot/reports/latest", + { method: "GET" }, + ); + return response.data; + } catch { + return null; + } +} diff --git a/scripts/report-project.py b/scripts/report-project.py new file mode 100644 index 0000000..5e8d723 --- /dev/null +++ b/scripts/report-project.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Report the current project/task to the Pipeline dashboard. + +Usage: + python report-project.py --project Pipeline --task "fixing bug" --status busy + +Required environment variables: + PIPELINE_BOT_KEY API key generated from Settings → Bot API keys + PIPELINE_URL Pipeline base URL (default: http://localhost:8001) + +Optional environment variables: + PIPELINE_REPORT_TIMEOUT Request timeout in seconds (default: 3) + +Exit codes: + 0 success or env vars not configured (non-disruptive) + 1 HTTP error or unexpected failure +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Report current project/task to Pipeline dashboard." + ) + parser.add_argument("--project", default=None, help="Project name, e.g. Pipeline") + parser.add_argument("--task", default=None, help="Current task description") + parser.add_argument( + "--status", + default="busy", + choices=["busy", "idle", "error"], + help="Agent status (default: busy)", + ) + parser.add_argument("--detail", default=None, help="Extra JSON detail string") + args = parser.parse_args() + + bot_key = os.environ.get("PIPELINE_BOT_KEY", "").strip() + base_url = os.environ.get("PIPELINE_URL", "http://localhost:8001").strip().rstrip("/") + timeout = float(os.environ.get("PIPELINE_REPORT_TIMEOUT", "3")) + + if not bot_key: + # Not configured — exit silently so the agent isn't disrupted. + return 0 + + payload: dict[str, object] = { + "project": args.project, + "task": args.task, + "status": args.status, + } + if args.detail: + try: + payload["detail"] = json.loads(args.detail) + except json.JSONDecodeError: + payload["detail"] = {"raw": args.detail} + + body = json.dumps(payload).encode("utf-8") + url = f"{base_url}/api/v1/bot/report" + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "X-Bot-Key": bot_key, + "Content-Type": "application/json", + "User-Agent": "PipelineBotReport/1.0", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout): + pass + except urllib.error.HTTPError as exc: + print(f"report-project: HTTP {exc.code} from {url}", file=sys.stderr) + return 1 + except (urllib.error.URLError, OSError) as exc: + print(f"report-project: connection error: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())