Files
PaperClipAI/ui/src/components/ExternalObjectPill.tsx
T
Devin Foleyanddevinfoley cfed739f81 fix(external-objects): stop reporting GitHub rate limits as unreachable (#17792)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/11/changes/paperclip/11
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app that people use to manage AI agents
for work.
> - Paperclip shows the status of external objects, such as GitHub pull
requests and issues.
> - A background sweep refreshes these objects and resolves a GitHub
credential.
> - The sweep read a credential only from a company secret with a
well-known name.
> - An instance without that secret made anonymous GitHub API calls.
Anonymous calls are limited to 60 per hour.
> - When the limit was reached, the sweep showed "unreachable" for
objects that it could not refresh.
> - A server environment token is instance-wide, so it must not expose a
private repository that a company cannot see.
> - This pull request reads a server token for public repositories,
reports a rate limit as its own state, and shows a warning on the pill.
> - The benefit is an accurate status, fewer misleading failures, and no
cross-company private data.

## Linked Issues or Issue Description

No public GitHub issue exists. The sections below describe the bug.

**What happened?**

The board showed "unreachable" for many GitHub links, even after a
related fix was deployed.

**Expected behavior**

The board should show "unreachable" only for real network failures. It
should show a rate limit as its own state and retry later.

**Steps to reproduce**

1. Use an instance with no GitHub token company secret and no server
token.
2. Let the external-object sweep make more than 60 anonymous GitHub
requests in one hour.
3. Open a task with GitHub links.

**Paperclip version or commit**

master (commit a67a18c at report time).

**Deployment mode**

Self-hosted server.

## What Changed

- Add `resolveGitHubApiCredential` in
`server/src/services/git-credentials.ts`. It reads a company secret with
a well-known name, then falls back to the server environment token
(`GITHUB_TOKEN`/`GH_TOKEN`), and it reports the credential `source`.
- Use the source in the GitHub external-object provider and in
`github-commit-details.ts`. A `server_env` token is limited to public
repositories. A private repository resolves only with a company
credential; otherwise the object reports `auth_required` and does not
fetch the private object.
- Add a `rate_limited` liveness state in
`packages/shared/src/constants.ts`.
- Classify a 403 with `x-ratelimit-remaining: 0`, a 429 response, and a
rate-limited repo probe as `rate_limited` instead of `unreachable`.
- Show `rate_limited` in the UI filter, icon, label, tone, overlay, and
badge maps.
- Make `ExternalObjectPill` and `ExternalObjectStatusIcon` prefer a
liveness warning icon and tone, so a rate-limited object shows a warning
even when the last status was healthy.
- Add tests for the credential source, the public-only server token, the
company-scoped private repository, and the rate-limited pill tone.

## Verification

- `vitest run server/src/__tests__/external-objects-service.test.ts
server/src/__tests__/git-credentials.test.ts
server/src/__tests__/github-commit-details.test.ts
server/src/__tests__/work-products.test.ts` — pass, with the new scoping
and credential-source tests.
- `vitest run ui/src/lib/external-objects.test.ts
ui/src/lib/issue-filters.test.ts
ui/src/components/ExternalObjectPill.test.tsx
ui/src/components/MarkdownExternalLink.test.tsx` — pass, with the new
rate-limited pill test.
- `pnpm --filter @paperclipai/shared typecheck` — pass. The full
monorepo `tsc` (server and ui) is killed by the 4GB memory cap of this
sandbox; the authoritative CI Typecheck gate runs it instead.
- One test in `git-credentials.test.ts` fails in this sandbox only. The
test "credential helper execution (real git, no network) ... env-carried
token" reads a global GitHub credential helper in the sandbox. The test
fails the same way on the base without this change.

## Risks

- Low risk. The change adds a liveness state and a credential source. It
does not change existing terminal verdicts.
- A server-environment token cannot resolve a private repository. This
is intentional. An operator who needs private repositories must add a
GitHub token as a company secret. The object then reports
`auth_required` with that instruction.
- `rate_limited` is a new value in `EXTERNAL_OBJECT_LIVENESS_STATES`. An
old client ignores an unknown value.

## Model Used

- Provider: DeepSeek (OpenRouter).
- Exact model ID: `openrouter/deepseek/deepseek-v4.1-flash`.
- Capabilities: tool use and code execution in the Paperclip runner.

## 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 similar open and closed PRs and
confirmed this is not a duplicate
- [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
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
2026-10-26 16:13:04 -07:00

205 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from "react";
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
} from "@paperclipai/shared";
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
import {
externalObjectStatusIcon,
externalObjectStatusIconDefault,
externalObjectLivenessOverlay,
} from "../lib/status-colors";
import {
externalObjectDisplayStatusLabel,
externalObjectDisplayLabel,
externalObjectLivenessLabel,
externalObjectIconForKey,
externalObjectProviderLabel,
externalObjectTypeLabel,
} from "../lib/external-objects";
import { cn } from "../lib/utils";
export interface ExternalObjectPillData {
providerKey: string | null;
objectType: string | null;
displayKey?: string | null;
iconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
displayTitle?: string | null;
statusLabel?: string | null;
statusIconKey?: string | null;
url?: string | null;
}
function githubObjectLabel(url: string | null | undefined): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const [, owner, repo, kind, number] = parsed.pathname.split("/");
if (!owner || !repo || !number) return null;
if (kind === "pull") return `PR ${number}`;
if (kind === "issues") return `Issue ${number}`;
return null;
} catch {
return null;
}
}
function externalObjectValueLabel(
object: ExternalObjectPillData,
fallback: string,
statusLabel: string,
): string {
const githubLabel = object.providerKey === "github" ? githubObjectLabel(object.url) : null;
const base = githubLabel ?? object.displayTitle?.trim() ?? fallback;
return statusLabel ? `${base} - ${statusLabel}` : base;
}
function isMergedExternalObject(object: ExternalObjectPillData, statusLabel: string): boolean {
return object.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged";
}
function externalObjectPillTone(object: ExternalObjectPillData, statusLabel: string): string {
if (isMergedExternalObject(object, statusLabel)) {
return "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400";
}
// A liveness problem (auth required, rate limited, unreachable) overrides the last
// known status tone so a warning is visible even when the status looks healthy.
return externalObjectStatusIcon[object.liveness]
?? externalObjectStatusIcon[object.statusCategory]
?? externalObjectStatusIconDefault;
}
function externalObjectStatusIconKey(
object: ExternalObjectPillData,
statusLabel: string,
): string | null | undefined {
if (isMergedExternalObject(object, statusLabel)) return object.statusIconKey ?? "git-merge";
return object.statusIconKey;
}
interface ExternalObjectPillProps {
object: ExternalObjectPillData;
/** Optional external mention count (renders as `×N` superscript when > 1). */
sourceCount?: number;
/** Optional source-mention summary used as the pill's `title` attribute. */
sourceSummary?: string | null;
className?: string;
/** Optional rendered label override. Defaults to `provider object-type`. */
children?: ReactNode;
/**
* If true the pill renders without a hover treatment (used inside
* non-interactive contexts like the property panel). Defaults to false.
*/
inert?: boolean;
/**
* Hide the provider icon when the surrounding UI already names the provider.
* The status icon still renders as the single state glyph inside the pill.
*/
showProviderIcon?: boolean;
}
/**
* External-object equivalent of `IssueReferencePill`. Same `paperclip-mention-chip`
* base so external references feel native to readers (Jakob's Law).
*/
export function ExternalObjectPill({
object,
sourceCount,
sourceSummary,
className,
children,
inert,
showProviderIcon = true,
}: ExternalObjectPillProps) {
const overlay = externalObjectLivenessOverlay[object.liveness] ?? "";
const providerLabel = externalObjectProviderLabel(object.providerKey);
const typeLabel = externalObjectTypeLabel(object.objectType);
const displayKey = externalObjectDisplayLabel(object.providerKey, object.objectType, object.displayKey);
const statusLabel = externalObjectDisplayStatusLabel(object);
const tone = externalObjectPillTone(object, statusLabel);
const valueLabel = externalObjectValueLabel(object, displayKey, statusLabel);
const statusIconKey = externalObjectStatusIconKey(object, statusLabel);
const livenessLabel = externalObjectLivenessLabel(object.liveness);
const ProviderIcon = externalObjectIconForKey(object.iconKey);
const ariaKey = displayKey;
const ariaLabel = `${ariaKey} — ${statusLabel}${
object.liveness === "fresh" || object.liveness === "unknown" ? "" : ` (${livenessLabel})`
}${object.displayTitle ? `: ${object.displayTitle}` : ""}`;
const interactive = !inert && Boolean(object.url);
const classNames = cn(
"paperclip-mention-chip paperclip-mention-chip--external-object",
"inline-flex max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
// Tone is applied as text classes only — the border style comes from the
// overlay (dashed for stale/auth/unreachable).
tone.split(" ").filter((c) => c.startsWith("text-")).join(" "),
overlay,
interactive
&& "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring",
className,
);
const titleAttr = sourceSummary
? `${object.displayTitle ?? displayKey} — ${sourceSummary}`
: object.displayTitle ?? displayKey;
const labelText = children ?? (
<>
<ExternalObjectStatusIcon
category={object.statusCategory}
liveness={object.liveness}
statusIconKey={statusIconKey}
sizeClassName="h-3 w-3"
label={`${providerLabel}: ${statusLabel}`}
/>
<span className="max-w-(--sz-16rem) truncate font-medium">{valueLabel}</span>
</>
);
const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? (
<span className="tabular-nums text-(length:--text-nano) font-medium opacity-80">×{sourceCount}</span>
) : null;
const innerContent = (
<>
{showProviderIcon && ProviderIcon ? (
<ProviderIcon aria-hidden="true" className="h-3 w-3 shrink-0" />
) : null}
<span className="inline-flex min-w-0 items-center gap-1">
{labelText}
</span>
{countSuffix}
</>
);
if (interactive && object.url) {
return (
<a
href={object.url}
target="_blank"
rel="noopener noreferrer"
data-mention-kind="external-object"
data-external-status={object.statusCategory}
data-external-liveness={object.liveness}
className={classNames}
title={titleAttr}
aria-label={ariaLabel}
>
{innerContent}
</a>
);
}
return (
<span
data-mention-kind="external-object"
data-external-status={object.statusCategory}
data-external-liveness={object.liveness}
className={classNames}
title={titleAttr}
aria-label={ariaLabel}
>
{innerContent}
</span>
);
}