Git Project selecto

This commit is contained in:
null 2026-05-26 16:58:30 -05:00
parent 9431816c35
commit b92726df66
4 changed files with 193 additions and 71 deletions

View File

@ -22,7 +22,13 @@ from app.models.forgejo_commit_activity import ForgejoCommitDay
from app.models.forgejo_connections import ForgejoConnection from app.models.forgejo_connections import ForgejoConnection
from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.metrics import CommitActivityDay, HeatmapDay, HeatmapResponse, LastPushRead, MetricsResponse from app.schemas.metrics import (
CommitActivityDay,
HeatmapDay,
HeatmapResponse,
LastPushRead,
MetricsResponse,
)
from app.services.forgejo_client import ForgejoAPIClient from app.services.forgejo_client import ForgejoAPIClient
if TYPE_CHECKING: if TYPE_CHECKING:
@ -50,7 +56,9 @@ async def _bg_fetch_line_stats(
) -> None: ) -> None:
"""Background task: sum commit line stats, persist per-repo day counts to DB.""" """Background task: sum commit line stats, persist per-repo day counts to DB."""
async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[int, int, dict[str, int]]: async def _one(
owner: str, repo: str, base_url: str, token: str | None
) -> tuple[int, int, dict[str, int]]:
try: try:
async with ForgejoAPIClient(base_url=base_url, token=token) as client: async with ForgejoAPIClient(base_url=base_url, token=token) as client:
return await client.get_commit_line_stats_since(owner, repo, since_iso) return await client.get_commit_line_stats_since(owner, repo, since_iso)
@ -58,7 +66,9 @@ async def _bg_fetch_line_stats(
return 0, 0, {} return 0, 0, {}
try: try:
results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok, _, _ in repos]) results = await asyncio.gather(
*[_one(o, r, bu, tok) for o, r, bu, tok, _, _ in repos]
)
total_adds = sum(a for a, _, _ in results) total_adds = sum(a for a, _, _ in results)
total_dels = sum(d for _, d, _ in results) total_dels = sum(d for _, d, _ in results)
merged_days: dict[str, int] = {} merged_days: dict[str, int] = {}
@ -66,8 +76,11 @@ async def _bg_fetch_line_stats(
# Persist per-repo per-day counts to DB (upsert). # Persist per-repo per-day counts to DB (upsert).
try: try:
from datetime import date as _date from datetime import date as _date
async with async_session_maker() as session: async with async_session_maker() as session:
for (_, _, _, _, repo_id, org_id), (_, _, day_counts) in zip(repos, results): for (_, _, _, _, repo_id, org_id), (_, _, day_counts) in zip(
repos, results
):
for day_str, cnt in day_counts.items(): for day_str, cnt in day_counts.items():
try: try:
day_obj = _date.fromisoformat(day_str) day_obj = _date.fromisoformat(day_str)
@ -96,6 +109,7 @@ async def _bg_fetch_line_stats(
await session.commit() await session.commit()
except Exception as db_exc: except Exception as db_exc:
from app.core.logging import get_logger as _get_logger from app.core.logging import get_logger as _get_logger
_get_logger(__name__).warning( _get_logger(__name__).warning(
"commit_activity_db_write_failed", extra={"error": str(db_exc)} "commit_activity_db_write_failed", extra={"error": str(db_exc)}
) )
@ -104,10 +118,17 @@ async def _bg_fetch_line_stats(
for day, cnt in day_counts.items(): for day, cnt in day_counts.items():
merged_days[day] = merged_days.get(day, 0) + cnt merged_days[day] = merged_days.get(day, 0) + cnt
_line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, True, merged_days) _line_stats_cache[cache_key] = (
_time.monotonic(),
total_adds,
total_dels,
True,
merged_days,
)
finally: finally:
_line_stats_fetching.discard(cache_key) _line_stats_fetching.discard(cache_key)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Last-push background cache # Last-push background cache
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -122,7 +143,9 @@ async def _bg_fetch_last_push(
) -> None: ) -> None:
"""Background task: find the most-recent commit across all tracked repos.""" """Background task: find the most-recent commit across all tracked repos."""
async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[str, str, dict] | None: async def _one(
owner: str, repo: str, base_url: str, token: str | None
) -> tuple[str, str, dict] | None:
try: try:
async with ForgejoAPIClient(base_url=base_url, token=token) as client: async with ForgejoAPIClient(base_url=base_url, token=token) as client:
commit = await client.get_last_commit(owner, repo) commit = await client.get_last_commit(owner, repo)
@ -133,7 +156,9 @@ async def _bg_fetch_last_push(
return None return None
try: try:
results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok in repos]) results = await asyncio.gather(
*[_one(o, r, bu, tok) for o, r, bu, tok in repos]
)
best: tuple[str, str, dict] | None = None best: tuple[str, str, dict] | None = None
best_ts: str = "" best_ts: str = ""
for item in results: for item in results:
@ -160,7 +185,7 @@ async def _bg_fetch_last_push(
or (commit_obj.get("author") or {}).get("name") or (commit_obj.get("author") or {}).get("name")
or "unknown" or "unknown"
) )
author_obj = (commit_obj.get("author") or {}) author_obj = commit_obj.get("author") or {}
date_str = author_obj.get("date") or commit.get("created") or "" date_str = author_obj.get("date") or commit.get("created") or ""
# Infer branch from commit refs if available, otherwise "—" # Infer branch from commit refs if available, otherwise "—"
branch = (commit.get("branch") or "").strip() or "" branch = (commit.get("branch") or "").strip() or ""
@ -388,9 +413,7 @@ async def get_forgejo_metrics(
if repo: if repo:
repo_key = str(repo_id) repo_key = str(repo_id)
last_sync_timestamps[repo_key] = ( last_sync_timestamps[repo_key] = (
repo.last_sync_at.isoformat() repo.last_sync_at.isoformat() if repo.last_sync_at else ""
if repo.last_sync_at
else ""
) )
sync_error_counts[repo_key] = 1 if repo.last_sync_error else 0 sync_error_counts[repo_key] = 1 if repo.last_sync_error else 0
@ -420,6 +443,9 @@ async def get_forgejo_metrics(
) )
async def get_forgejo_heatmap( async def get_forgejo_heatmap(
organization_id: UUID | None = Query(None, description="Filter by organisation ID"), organization_id: UUID | None = Query(None, description="Filter by organisation ID"),
repository_id: UUID | None = Query(
None, description="Filter by a single repository ID"
),
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> HeatmapResponse: ) -> HeatmapResponse:
@ -433,14 +459,18 @@ async def get_forgejo_heatmap(
# repos. 90 days is enough context for the dashboard summary numbers. # repos. 90 days is enough context for the dashboard summary numbers.
line_stats_since = utcnow() - timedelta(days=90) line_stats_since = utcnow() - timedelta(days=90)
# Fetch repos with their connections in one query # Fetch scoped repos with their connections in one query
repos_with_conns = ( repos_statement = (
await session.exec(
select(ForgejoRepository, ForgejoConnection) select(ForgejoRepository, ForgejoConnection)
.join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id) .join(
ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id
)
.where(ForgejoRepository.organization_id == ctx.organization.id) .where(ForgejoRepository.organization_id == ctx.organization.id)
) )
).all() if repository_id:
repos_statement = repos_statement.where(ForgejoRepository.id == repository_id)
repos_with_conns = (await session.exec(repos_statement)).all()
if not repos_with_conns: if not repos_with_conns:
return HeatmapResponse(days=[], max_count=0) return HeatmapResponse(days=[], max_count=0)
@ -448,7 +478,9 @@ async def get_forgejo_heatmap(
# Line stats — served from background cache; fire refresh if stale. # Line stats — served from background cache; fire refresh if stale.
since_iso = line_stats_since.strftime("%Y-%m-%dT%H:%M:%SZ") since_iso = line_stats_since.strftime("%Y-%m-%dT%H:%M:%SZ")
cache_key = str(ctx.organization.id) cache_key = (
f"repo:{repository_id}" if repository_id else f"org:{ctx.organization.id}"
)
cached = _line_stats_cache.get(cache_key) cached = _line_stats_cache.get(cache_key)
now = _time.monotonic() now = _time.monotonic()
@ -459,13 +491,23 @@ async def get_forgejo_heatmap(
# Normalise base_url the same way get_forgejo_client() does, eagerly, # Normalise base_url the same way get_forgejo_client() does, eagerly,
# so the background task never touches a potentially-closed session. # so the background task never touches a potentially-closed session.
import re as _re import re as _re
repo_tuples: list[tuple[str, str, str, str | None, UUID, UUID]] = [] repo_tuples: list[tuple[str, str, str, str | None, UUID, UUID]] = []
for repo, conn in repos_with_conns: for repo, conn in repos_with_conns:
bu = (conn.base_url or "").rstrip("/") bu = (conn.base_url or "").rstrip("/")
if "/api/v1" in bu: if "/api/v1" in bu:
m = _re.match(r"(https?://[^/]+)", bu) m = _re.match(r"(https?://[^/]+)", bu)
bu = m.group(1).rstrip("/") if m else bu bu = m.group(1).rstrip("/") if m else bu
repo_tuples.append((repo.owner, repo.repo, bu, getattr(conn, "token", None), repo.id, repo.organization_id)) repo_tuples.append(
(
repo.owner,
repo.repo,
bu,
getattr(conn, "token", None),
repo.id,
repo.organization_id,
)
)
_line_stats_fetching.add(cache_key) _line_stats_fetching.add(cache_key)
asyncio.create_task( asyncio.create_task(
@ -485,7 +527,10 @@ async def get_forgejo_heatmap(
# background fetch is in progress. # background fetch is in progress.
commit_db_rows = ( commit_db_rows = (
await session.exec( await session.exec(
select(ForgejoCommitDay.date, func.sum(ForgejoCommitDay.commit_count).label("cnt")) select(
ForgejoCommitDay.date,
func.sum(ForgejoCommitDay.commit_count).label("cnt"),
)
.where( .where(
ForgejoCommitDay.repository_id.in_(repo_ids), ForgejoCommitDay.repository_id.in_(repo_ids),
ForgejoCommitDay.date >= line_stats_since.date(), ForgejoCommitDay.date >= line_stats_since.date(),
@ -496,11 +541,15 @@ async def get_forgejo_heatmap(
).all() ).all()
if commit_db_rows: if commit_db_rows:
days_list = [HeatmapDay(date=str(day), count=int(cnt)) for day, cnt in commit_db_rows] days_list = [
HeatmapDay(date=str(day), count=int(cnt)) for day, cnt in commit_db_rows
]
max_count = max((d.count for d in days_list), default=0) max_count = max((d.count for d in days_list), default=0)
elif has_line_stats and commit_day_counts: elif has_line_stats and commit_day_counts:
# In-memory cache hit but DB write hasn't landed yet # In-memory cache hit but DB write hasn't landed yet
days_list = [HeatmapDay(date=k, count=v) for k, v in sorted(commit_day_counts.items())] days_list = [
HeatmapDay(date=k, count=v) for k, v in sorted(commit_day_counts.items())
]
max_count = max((d.count for d in days_list), default=0) max_count = max((d.count for d in days_list), default=0)
else: else:
# First-ever load — fall back to issue-event counts from DB while background fetch runs # First-ever load — fall back to issue-event counts from DB while background fetch runs
@ -511,12 +560,14 @@ async def get_forgejo_heatmap(
select( select(
sa_cast(ForgejoIssue.forgejo_created_at, SADate).label("day"), sa_cast(ForgejoIssue.forgejo_created_at, SADate).label("day"),
func.count().label("cnt"), func.count().label("cnt"),
).where( )
.where(
ForgejoIssue.repository_id.in_(repo_ids), ForgejoIssue.repository_id.in_(repo_ids),
ForgejoIssue.is_pull_request.is_(False), ForgejoIssue.is_pull_request.is_(False),
ForgejoIssue.forgejo_created_at.is_not(None), ForgejoIssue.forgejo_created_at.is_not(None),
ForgejoIssue.forgejo_created_at >= since, ForgejoIssue.forgejo_created_at >= since,
).group_by(sa_cast(ForgejoIssue.forgejo_created_at, SADate)) )
.group_by(sa_cast(ForgejoIssue.forgejo_created_at, SADate))
) )
).all() ).all()
for day, cnt in created_rows: for day, cnt in created_rows:
@ -529,12 +580,14 @@ async def get_forgejo_heatmap(
select( select(
sa_cast(ForgejoIssue.forgejo_closed_at, SADate).label("day"), sa_cast(ForgejoIssue.forgejo_closed_at, SADate).label("day"),
func.count().label("cnt"), func.count().label("cnt"),
).where( )
.where(
ForgejoIssue.repository_id.in_(repo_ids), ForgejoIssue.repository_id.in_(repo_ids),
ForgejoIssue.is_pull_request.is_(False), ForgejoIssue.is_pull_request.is_(False),
ForgejoIssue.forgejo_closed_at.is_not(None), ForgejoIssue.forgejo_closed_at.is_not(None),
ForgejoIssue.forgejo_closed_at >= since, ForgejoIssue.forgejo_closed_at >= since,
).group_by(sa_cast(ForgejoIssue.forgejo_closed_at, SADate)) )
.group_by(sa_cast(ForgejoIssue.forgejo_closed_at, SADate))
) )
).all() ).all()
for day, cnt in closed_rows: for day, cnt in closed_rows:
@ -561,6 +614,7 @@ async def get_forgejo_heatmap(
) )
async def get_last_push( async def get_last_push(
organization_id: UUID | None = Query(None), organization_id: UUID | None = Query(None),
repository_id: UUID | None = Query(None),
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> LastPushRead | None: ) -> LastPushRead | None:
@ -568,18 +622,27 @@ async def get_last_push(
if organization_id and organization_id != ctx.organization.id: if organization_id and organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
repos_with_conns = ( repos_statement = (
await session.exec(
select(ForgejoRepository, ForgejoConnection) select(ForgejoRepository, ForgejoConnection)
.join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id) .join(
ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id
)
.where(ForgejoRepository.organization_id == ctx.organization.id) .where(ForgejoRepository.organization_id == ctx.organization.id)
) )
).all() if repository_id:
repos_statement = repos_statement.where(ForgejoRepository.id == repository_id)
repos_with_conns = (await session.exec(repos_statement)).all()
if not repos_with_conns: if not repos_with_conns:
return None return None
import re as _re import re as _re
cache_key = f"last-push:{ctx.organization.id}"
cache_key = (
f"last-push:repo:{repository_id}"
if repository_id
else f"last-push:org:{ctx.organization.id}"
)
cached = _last_push_cache.get(cache_key) cached = _last_push_cache.get(cache_key)
now = _time.monotonic() now = _time.monotonic()
@ -592,7 +655,9 @@ async def get_last_push(
if "/api/v1" in bu: if "/api/v1" in bu:
m = _re.match(r"(https?://[^/]+)", bu) m = _re.match(r"(https?://[^/]+)", bu)
bu = m.group(1).rstrip("/") if m else bu bu = m.group(1).rstrip("/") if m else bu
repo_tuples.append((repo.owner, repo.repo, bu, getattr(conn, "token", None))) repo_tuples.append(
(repo.owner, repo.repo, bu, getattr(conn, "token", None))
)
_last_push_fetching.add(cache_key) _last_push_fetching.add(cache_key)
asyncio.create_task( asyncio.create_task(
@ -615,8 +680,12 @@ async def get_last_push(
) )
async def get_commit_activity( async def get_commit_activity(
organization_id: UUID | None = Query(None, description="Filter by organisation ID"), organization_id: UUID | None = Query(None, description="Filter by organisation ID"),
repository_id: UUID | None = Query(None, description="Filter by a single repository ID"), repository_id: UUID | None = Query(
days: int = Query(default=90, ge=1, le=365, description="How many days back to return"), None, description="Filter by a single repository ID"
),
days: int = Query(
default=90, ge=1, le=365, description="How many days back to return"
),
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[CommitActivityDay]: ) -> list[CommitActivityDay]:
@ -675,7 +744,9 @@ async def get_commit_activity(
) )
).all() ).all()
return [CommitActivityDay(date=str(day), commit_count=int(cnt)) for day, cnt in rows] return [
CommitActivityDay(date=str(day), commit_count=int(cnt)) for day, cnt in rows
]
def _zeroed_metrics() -> MetricsResponse: def _zeroed_metrics() -> MetricsResponse:

View File

@ -441,7 +441,6 @@ export default function DashboardPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const [selectedForgejoRepositoryId, setSelectedForgejoRepositoryId] = const [selectedForgejoRepositoryId, setSelectedForgejoRepositoryId] =
useState(ALL_FORGEJO_REPOSITORIES); useState(ALL_FORGEJO_REPOSITORIES);
const [ripleyAutoSelected, setRipleyAutoSelected] = useState(false);
const [isRefreshingForgejoSync, setIsRefreshingForgejoSync] = useState(false); const [isRefreshingForgejoSync, setIsRefreshingForgejoSync] = useState(false);
const [createForgejoIssueOpen, setCreateForgejoIssueOpen] = useState(false); const [createForgejoIssueOpen, setCreateForgejoIssueOpen] = useState(false);
@ -552,6 +551,25 @@ export default function DashboardPage() {
?.organization_id ?? null, ?.organization_id ?? null,
[forgejoRepositories], [forgejoRepositories],
); );
const ripleyForgejoRepository = useMemo(() => {
const project = botReportQuery.data?.project;
if (!project || forgejoRepositories.length === 0) return null;
const needle = project.toLowerCase().trim();
return (
forgejoRepositories.find((repo) => {
const repoName = repo.repo.toLowerCase().trim();
const displayName = repo.display_name.toLowerCase().trim();
return (
repoName === needle ||
displayName === needle ||
repoName.includes(needle) ||
needle.includes(repoName)
);
}) ?? null
);
}, [botReportQuery.data?.project, forgejoRepositories]);
const ripleyForgejoRepositoryId = ripleyForgejoRepository?.id ?? null;
useEffect(() => { useEffect(() => {
if ( if (
@ -565,25 +583,17 @@ export default function DashboardPage() {
} }
}, [forgejoRepositories, selectedForgejoRepositoryId]); }, [forgejoRepositories, selectedForgejoRepositoryId]);
// Auto-select the Forgejo repo that matches Ripley's reported project. // Keep the Git Project selector aligned to Ripley's current project.
// Only fires once on initial load while the user hasn't manually chosen. // A user's manual mismatch stays put until Ripley reports a different project.
useEffect(() => { useEffect(() => {
if (ripleyAutoSelected) return; if (!ripleyForgejoRepositoryId) return;
const project = botReportQuery.data?.project; setSelectedForgejoRepositoryId(ripleyForgejoRepositoryId);
if (!project || forgejoRepositories.length === 0) return; }, [ripleyForgejoRepositoryId]);
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) return;
const needle = project.toLowerCase().trim(); const isForgejoSelectionMismatched = Boolean(
const match = forgejoRepositories.find((repo) => { ripleyForgejoRepositoryId &&
const repoName = repo.repo.toLowerCase().trim(); selectedForgejoRepositoryId !== ripleyForgejoRepositoryId,
const displayName = repo.display_name.toLowerCase().trim(); );
return repoName === needle || displayName === needle ||
repoName.includes(needle) || needle.includes(repoName);
});
if (match) {
setSelectedForgejoRepositoryId(match.id);
setRipleyAutoSelected(true);
}
}, [botReportQuery.data, forgejoRepositories, ripleyAutoSelected, selectedForgejoRepositoryId]);
const forgejoMetricsQuery = useQuery<ForgejoIssueMetrics | null, Error>({ const forgejoMetricsQuery = useQuery<ForgejoIssueMetrics | null, Error>({
queryKey: [ queryKey: [
@ -624,12 +634,20 @@ export default function DashboardPage() {
} | null, } | null,
Error Error
>({ >({
queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId], queryKey: [
"dashboard",
"forgejo",
"heatmap",
forgejoOrganizationId,
selectedForgejoRepositoryId,
],
enabled: Boolean( enabled: Boolean(
isSignedIn && isSignedIn &&
forgejoOrganizationId &&
!forgejoRepositoriesQuery.isLoading && !forgejoRepositoriesQuery.isLoading &&
!forgejoRepositoriesQuery.error, !forgejoRepositoriesQuery.error &&
(selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? forgejoOrganizationId
: selectedForgejoRepository),
), ),
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.has_line_stats === false query.state.data?.has_line_stats === false
@ -637,17 +655,38 @@ export default function DashboardPage() {
: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, : FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always", refetchOnMount: "always",
queryFn: () => { queryFn: () => {
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) {
return getForgejoHeatmap({
repository_id: selectedForgejoRepositoryId,
});
}
if (!forgejoOrganizationId) return Promise.resolve(null); if (!forgejoOrganizationId) return Promise.resolve(null);
return getForgejoHeatmap({ organization_id: forgejoOrganizationId }); return getForgejoHeatmap({ organization_id: forgejoOrganizationId });
}, },
}); });
const forgejoLastPushQuery = useQuery({ const forgejoLastPushQuery = useQuery({
queryKey: ["dashboard", "forgejo", "last-push", forgejoOrganizationId], queryKey: [
enabled: Boolean(isSignedIn && forgejoOrganizationId), "dashboard",
"forgejo",
"last-push",
forgejoOrganizationId,
selectedForgejoRepositoryId,
],
enabled: Boolean(
isSignedIn &&
(selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES
? forgejoOrganizationId
: selectedForgejoRepository),
),
refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS,
refetchOnMount: "always", refetchOnMount: "always",
queryFn: () => { queryFn: () => {
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) {
return getForgejoLastPush({
repository_id: selectedForgejoRepositoryId,
});
}
if (!forgejoOrganizationId) return Promise.resolve(null); if (!forgejoOrganizationId) return Promise.resolve(null);
return getForgejoLastPush({ organization_id: forgejoOrganizationId }); return getForgejoLastPush({ organization_id: forgejoOrganizationId });
}, },
@ -1405,10 +1444,8 @@ export default function DashboardPage() {
repositories={forgejoRepositories} repositories={forgejoRepositories}
metricRepositories={scopedForgejoRepositories} metricRepositories={scopedForgejoRepositories}
selectedRepositoryId={selectedForgejoRepositoryId} selectedRepositoryId={selectedForgejoRepositoryId}
onSelectedRepositoryChange={(id) => { isSelectedRepositoryMismatched={isForgejoSelectionMismatched}
if (id === ALL_FORGEJO_REPOSITORIES) setRipleyAutoSelected(false); onSelectedRepositoryChange={setSelectedForgejoRepositoryId}
setSelectedForgejoRepositoryId(id);
}}
onRefreshLastSync={handleRefreshForgejoLastSync} onRefreshLastSync={handleRefreshForgejoLastSync}
onCreateIssue={() => setCreateForgejoIssueOpen(true)} onCreateIssue={() => setCreateForgejoIssueOpen(true)}
isRefreshingLastSync={isRefreshingForgejoSync} isRefreshingLastSync={isRefreshingForgejoSync}

View File

@ -32,6 +32,7 @@ type ForgejoIssueMetricCardsProps = {
onSelectedRepositoryChange: (repositoryId: string) => void; onSelectedRepositoryChange: (repositoryId: string) => void;
onRefreshLastSync: () => void; onRefreshLastSync: () => void;
onCreateIssue: () => void; onCreateIssue: () => void;
isSelectedRepositoryMismatched?: boolean;
isRefreshingLastSync?: boolean; isRefreshingLastSync?: boolean;
isLoading?: boolean; isLoading?: boolean;
error?: string | null; error?: string | null;
@ -328,6 +329,7 @@ export function ForgejoIssueMetricCards({
onSelectedRepositoryChange, onSelectedRepositoryChange,
onRefreshLastSync, onRefreshLastSync,
onCreateIssue, onCreateIssue,
isSelectedRepositoryMismatched = false,
isRefreshingLastSync = false, isRefreshingLastSync = false,
isLoading = false, isLoading = false,
error, error,
@ -419,7 +421,13 @@ export function ForgejoIssueMetricCards({
onValueChange={onSelectedRepositoryChange} onValueChange={onSelectedRepositoryChange}
disabled={repositories.length === 0} disabled={repositories.length === 0}
> >
<SelectTrigger className="h-9 w-full min-w-[190px] rounded-lg border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 text-sm shadow-none focus:ring-offset-0 sm:w-[230px]"> <SelectTrigger
className={cn(
"h-9 w-full min-w-[190px] rounded-lg border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 text-sm shadow-none focus:ring-offset-0 sm:w-[230px]",
isSelectedRepositoryMismatched &&
"border-[color:rgba(248,113,113,0.65)] bg-[color:rgba(248,113,113,0.10)] text-[color:var(--danger)] focus:ring-[color:rgba(248,113,113,0.35)]",
)}
>
<SelectValue placeholder="All projects" /> <SelectValue placeholder="All projects" />
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent align="end">

View File

@ -647,10 +647,13 @@ export interface ForgejoLastPush {
export async function getForgejoLastPush(params?: { export async function getForgejoLastPush(params?: {
organization_id?: string; organization_id?: string;
repository_id?: string;
}): Promise<ForgejoLastPush | null> { }): Promise<ForgejoLastPush | null> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.organization_id) if (params?.organization_id)
searchParams.set("organization_id", params.organization_id); searchParams.set("organization_id", params.organization_id);
if (params?.repository_id)
searchParams.set("repository_id", params.repository_id);
const qs = searchParams.toString(); const qs = searchParams.toString();
return fetchJson<ForgejoLastPush | null>( return fetchJson<ForgejoLastPush | null>(
`/api/v1/forgejo/last-push${qs ? `?${qs}` : ""}`, `/api/v1/forgejo/last-push${qs ? `?${qs}` : ""}`,
@ -667,10 +670,13 @@ export interface ForgejoHeatmapResponse {
export async function getForgejoHeatmap(params?: { export async function getForgejoHeatmap(params?: {
organization_id?: string; organization_id?: string;
repository_id?: string;
}): Promise<ForgejoHeatmapResponse> { }): Promise<ForgejoHeatmapResponse> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.organization_id) if (params?.organization_id)
searchParams.set("organization_id", params.organization_id); searchParams.set("organization_id", params.organization_id);
if (params?.repository_id)
searchParams.set("repository_id", params.repository_id);
const qs = searchParams.toString(); const qs = searchParams.toString();
return fetchJson<ForgejoHeatmapResponse>( return fetchJson<ForgejoHeatmapResponse>(
`/api/v1/forgejo/heatmap${qs ? `?${qs}` : ""}`, `/api/v1/forgejo/heatmap${qs ? `?${qs}` : ""}`,