Files
PaperClipAI/ui/src/components/MarkdownExternalLink.test.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

148 lines
5.7 KiB
TypeScript

// @vitest-environment node
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: { get: vi.fn() },
}));
function render(children: string, externalReferences?: MarkdownExternalReferenceMap) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownBody externalReferences={externalReferences}>{children}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
}
describe("MarkdownBody external object decoration", () => {
const successReference: MarkdownExternalReferenceMap = {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
displayKey: "Github Pull Request",
iconKey: "github",
statusCategory: "succeeded",
statusIconKey: "git-merge",
liveness: "fresh",
statusLabel: "Merged",
displayTitle: "Add external refs",
},
};
it("decorates a known URL with the external status icon and metadata attributes", () => {
const html = render("Take a look: https://github.com/acme/web/pull/241", successReference);
expect(html).toContain('class="paperclip-markdown-external-ref"');
expect(html).toContain('data-external-link="resolved"');
expect(html).toContain('data-external-status="succeeded"');
expect(html).toContain('data-external-liveness="fresh"');
expect(html).toContain('aria-label="Github Pull Request Merged: Add external refs"');
expect(html).toContain("github.com/acme/web/pull/241");
});
it("matches a URL even when the user pasted it with different host case or trailing punctuation", () => {
const html = render("see HTTPS://Github.com/acme/web/pull/241#frag.", successReference);
expect(html).toContain('data-external-status="succeeded"');
});
it("renders an unresolved URL as a plain external link with no status affordance", () => {
const html = render("https://random.example.com/path");
expect(html).not.toContain("paperclip-markdown-external-ref");
expect(html).toContain('href="https://random.example.com/path"');
});
it("never decorates a URL that lives inside a fenced or inline code block", () => {
const html = render(
"```\nhttps://github.com/acme/web/pull/241\n```\n\nInline: `https://github.com/acme/web/pull/241`",
successReference,
);
// The fenced/inline literal should still be present as text but the
// decorated anchor should not appear since no `<a>` is created in code.
expect(html).not.toContain('class="paperclip-markdown-external-ref"');
});
it("shows liveness suffix in the aria-label when the object is stale or auth_required", () => {
const html = render("Auth-blocked: https://app.hubspot.com/leads/99", {
"https://app.hubspot.com/leads/99": {
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
statusLabel: "Reconnect",
displayTitle: "Acme deal",
},
});
expect(html).toContain('aria-label="HubSpot Reconnect (Requires auth): Acme deal"');
});
it("labels detected-but-unresolved GitHub objects as not yet refreshed, not not yet resolved", () => {
const html = render("See https://github.com/acme/web/pull/241", {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
displayKey: "Github Pull Request",
iconKey: "github",
statusCategory: "unknown",
liveness: "unknown",
statusLabel: null,
displayTitle: "acme/web#241",
},
});
expect(html).toContain("data-external-link=\"resolved\"");
expect(html).toContain('aria-label="Github Pull Request Not yet refreshed: acme/web#241"');
expect(html).not.toContain("Not yet resolved");
});
it("does not repeat the liveness label when the status label equals it", () => {
const html = render("Auth-blocked: https://github.com/acme/web/pull/241", {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
displayKey: "Github Pull Request",
iconKey: "github",
statusCategory: "unknown",
liveness: "auth_required",
statusLabel: null,
displayTitle: "acme/web#241",
},
});
expect(html).toContain('aria-label="Github Pull Request Requires auth: acme/web#241"');
expect(html).not.toContain("Requires auth (Requires auth)");
expect(html).not.toContain("(Requires auth)");
});
it("does not repeat the liveness label when the status label equals it for unreachable", () => {
const html = render("Down: https://github.com/acme/web/pull/241", {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
displayKey: "Github Pull Request",
iconKey: "github",
statusCategory: "unknown",
liveness: "unreachable",
statusLabel: null,
displayTitle: "acme/web#241",
},
});
expect(html).toContain('aria-label="Github Pull Request Unreachable: acme/web#241"');
expect(html).not.toContain("(Unreachable)");
});
});