mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## 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>
96 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|