Files
PaperClipAI/ui/src/components/ExternalObjectStatusIcon.tsx
T
3d3b8d4371 fix(external-objects): distinguish hidden GitHub repos from genuinely-missing objects (#15906)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Issues reference external objects such as GitHub pull requests. The
instance resolves their status and shows it in the UI.
> - GitHub returns 404 both when a pull request or issue does not exist
and when the repository exists but the configured credentials cannot see
it. GitHub hides private repos behind a 404 on purpose.
> - The resolver treated every 404 as a terminal not-found verdict. It
froze references to inaccessible repos forever, and marked them "Not
found (Stale)".
> - This pull request probes the repository metadata to tell the two
cases apart and keep frozen objects re-checkable.
> - The benefit is accurate statuses instead of permanent "Not found"
labels for repos the instance cannot see.

## Linked Issues or Issue Description

No public GitHub issue exists for this report.

**What happened?**
- The issue properties panel and markdown decoration showed many GitHub
pull request and issue references as "Not found (Stale)" even though the
objects exist.
- GitHub returns 404 for objects the configured credentials cannot
access, not only for objects that are truly gone.
- The resolver treated every 404 as a terminal not-found verdict, so
those references were frozen and showed a misleading "stale" liveness.
- Detected-but-unresolved GitHub objects were labeled "Not yet resolved"
in inline markdown, which did not match the pill label "Not yet
refreshed".

**Expected behavior**
- A reference to a repo the credentials cannot see should surface as
authorization-required, not as a permanent "Not found".
- A truly missing object should still be "Not found".
- A terminal object (merged, closed, or confirmed not-found) should not
be labeled "Stale", because it is never refreshed again.
- Inline markdown statuses should match the pill statuses.

**Steps to reproduce**
1. Configure GitHub credentials with no access to a private repo.
2. Add a comment or issue description that links to a pull request in
that repo.
3. Wait for the external object resolver to run.
4. Observe the reference labeled "Not found (Stale)".

**Paperclip version or commit**
- `master` at `e733bdd` (before this change).

**Deployment mode**
- Self-hosted server.

## What Changed

- Added a repository visibility probe to the GitHub external object
provider.
- A 404 for an object now probes the repository metadata with the same
credentials:
- Repository visible: the object genuinely is gone. Keep the not-found
snapshot.
- Repository hidden: return an authorization-required result instead of
a terminal not-found verdict.
- Probe failed (network, rate limit, 5xx): return an unreachable result
and retry later.
- A rate-limited repository probe (403 with `x-ratelimit-remaining: 0`)
is treated as uncertain, not as missing access, so it retries instead of
showing a misleading "Requires auth".
- Re-check previously frozen GitHub not-found objects on their normal
schedule so they self-heal once credentials or repo visibility improve.
A frozen object is only re-checked until a probe confirms the repo is
visible; once confirmed deleted, it is terminal and no longer polled.
- Cleared any terminal verdict when a refresh fails, so a failed refresh
does not freeze an object.
- Kept terminal objects from displaying the "stale" liveness label.
- Matched the inline markdown status label to the pill status label, so
detected-but-unresolved GitHub objects say "Not yet refreshed" instead
of "Not yet resolved".
- Skipped the liveness suffix in markdown links and status icons when it
duplicates the status label (e.g. no more "Requires auth (Requires
auth)").
- Cached repository visibility probes per company+repo + credential
fingerprint, so many objects in the same repo share one probe per
refresh cycle instead of each issuing its own — and a verdict obtained
under one token is never reused by a later refresh running under a
different credential.

## Verification

- Added provider tests for visible, hidden, failed, and rate-limited
repository probes, per-company/per-repo probe caching, and
probe-verified not-found snapshots.
- Added service tests for terminal-object liveness, for re-checking
frozen GitHub not-found objects, and for ensuring probe-verified
not-found objects are never re-queued.
- Added UI tests asserting unresolved GitHub objects render as "Not yet
refreshed" (not "Not yet resolved") and that failed-first-refresh labels
do not repeat the liveness text.
- Ran `npx vitest run src/__tests__/external-objects-service.test.ts` in
`server/`: 34 passed (incl. provider + service suites).
- Ran the external-object markdown/pill/icon suites in `ui/`: 97 passed.
- `ci / Typecheck + Release Registry` and all other CI gates pass on
this head. Local `tsc` could not run to completion in the execution
sandbox (the sandbox memory cap killed it), so CI typecheck is the
authoritative gate.

## Risks

- Low risk. The change is confined to the GitHub external object
provider and the external object service.
- A repo that genuinely does not exist and a private repo both return
404 for the probe. Both now surface authorization-required or a retry
instead of an immediate not-found freeze, which stays re-checkable and
self-heals.
- Re-checking frozen not-found objects adds at most one repository probe
per affected object until the probe produces a definitive verdict, after
which the object is terminal and no longer polled. Probe results are
cached per company+repo+credential, so a credential rotation always
re-probes instead of inheriting a stale verdict.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Provider: DeepSeek via OpenRouter, model ID
`openrouter/~deepseek/deepseek-v4-flash-latest`, running inside the
Paperclip opencode adapter (`opencode_local`).
- The model produced and assisted with the reasoning and code changes;
the execution workspace, test suite, and git/PR steps above were run by
the same agent run.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
— the branch name is assigned by the managed execution workspace and the
harness requires keeping it
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green on the current head (all `ci /
*`, Greptile, Socket, and review checks pass)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-11 11:53:21 -07:00

96 lines
3.4 KiB
TypeScript

import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
} from "@paperclipai/shared";
import { Clock } from "lucide-react";
import {
externalObjectStatusIcon,
externalObjectStatusIconDefault,
} from "../lib/status-colors";
import {
externalObjectCategoryLabel,
externalObjectIconForCategory,
externalObjectIconForKey,
externalObjectLivenessLabel,
} from "../lib/external-objects";
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
import { cn } from "../lib/utils";
interface ExternalObjectStatusIconProps {
category: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
statusIconKey?: string | null;
/** Optional override label used in `aria-label` (e.g. provider-specific copy). */
label?: string | null;
className?: string;
/** Tailwind size class — defaults to `h-3.5 w-3.5` (compact pill use). */
sizeClassName?: string;
/**
* When true the icon renders inline at 12 px with the `mr-1`/`align`
* adjustments used by `MarkdownIssueLink`. Used by the markdown decorator.
*/
inline?: boolean;
}
/**
* Pure presentational icon for an external object's status. Combines:
*
* - A category icon (lucide) from `externalObjectIconForCategory`.
* - Tone classes (text + border) from `externalObjectStatusIcon`.
* - An overlaid clock micro-mark when liveness is `stale`.
* - Reduced-motion-aware spinner for the `running` category.
*
* Never mounts plugin React; the host is the sole renderer of identity glyphs.
*/
export function ExternalObjectStatusIcon({
category,
liveness,
statusIconKey,
label,
className,
sizeClassName = "h-3.5 w-3.5",
inline = false,
}: ExternalObjectStatusIconProps) {
const reducedMotion = usePrefersReducedMotion();
const Icon = externalObjectIconForKey(statusIconKey) ?? externalObjectIconForCategory(category);
const tone = statusIconKey === "git-merge"
? "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400"
: externalObjectStatusIcon[category] ?? externalObjectStatusIconDefault;
const livenessSuffix = liveness === "fresh" || liveness === "unknown"
? ""
: ` (${externalObjectLivenessLabel(liveness)})`;
const baseLabel = label ?? externalObjectCategoryLabel(category);
const ariaLabel = `${baseLabel}${baseLabel.includes(externalObjectLivenessLabel(liveness)) ? "" : livenessSuffix}`;
// The clock overlay needs a positioned wrapper. Inline mode keeps the icon
// tight to the surrounding text; pill mode expects to size by sizeClassName.
const wrapperBase = inline
? "relative mr-1 inline-flex shrink-0 align-(--va-0_125em)"
: "relative inline-flex shrink-0";
const iconSize = inline ? "h-3 w-3" : sizeClassName;
const isSpinner = category === "running";
const animateClass = isSpinner && !reducedMotion ? "animate-spin" : "";
return (
<span
role="img"
aria-label={ariaLabel}
className={cn(wrapperBase, className)}
>
<Icon
aria-hidden="true"
className={cn(iconSize, tone.split(" ").filter((c) => c.startsWith("text-")).join(" "), animateClass)}
/>
{liveness === "stale" ? (
<Clock
aria-hidden="true"
className={cn(
"absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-background text-muted-foreground",
inline ? "" : "p-px",
)}
/>
) : null}
</span>
);
}