mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The issue properties panel can show external objects such as GitHub pull requests. > - Those objects are resolved by external-object providers and then displayed as compact status labels. > - A GitHub pull request could remain in the fallback `unknown` state and appear as `Not yet resolved`. > - That label is confusing when the object is known but has not been refreshed yet. > - This pull request refreshes due external objects from the heartbeat scheduler and improves the unknown-status copy. > - The benefit is a properties panel that moves from pending refresh to the real pull request state without a manual refresh. ## Linked Issues or Issue Description No public GitHub issue exists for this bug. I searched for related public issues and pull requests using the terms `Not yet refreshed`, `external objects refresh`, and `external PR status`, and did not find a duplicate implementation. **What happened?** The issue properties panel could show a GitHub pull request as `Not yet resolved` even when the referenced pull request was valid. The object stayed stale unless a manual refresh path ran. **Expected behavior** A known external object should show pending-refresh copy while it waits for provider data. When the scheduler refreshes it, the properties panel should show the provider status such as open, merged, or closed. **Steps to reproduce** 1. Create or view an issue that references a GitHub pull request. 2. Open the issue properties panel. 3. Observe the external object row before a manual refresh has run. **Paperclip version or commit** Current `master` before this pull request. **Deployment mode** Local dev and self-hosted server. **Installation method** Built from source. **Agent adapter(s) involved** Not adapter-specific. **Database mode** Not database-related. **Access context** Board view. **Privacy checklist** I reviewed this description and did not include logs, credentials, private URLs, internal issue IDs, or PII. ## What Changed - Added a heartbeat scheduler tick that refreshes due external objects for active companies. - Kept manual external-object refresh behavior on the same service path. - Changed display copy so known provider objects use liveness labels such as `Not yet refreshed`, while fresh unknown provider statuses show `Status unavailable`. - Added server and UI tests for scheduled refresh and label behavior. ## Verification - `corepack pnpm install --frozen-lockfile` - `pnpm check:token-gates` - `pnpm exec vitest run server/src/__tests__/external-objects-service.test.ts server/src/__tests__/server-startup-feedback-export.test.ts ui/src/components/ExternalObjectPill.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/lib/external-objects.test.ts` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server build` - `pnpm --filter @paperclipai/ui build` - `pnpm run typecheck:build-gaps` - GitHub PR checks passed on head `0e7fcd30` - Greptile reported 5/5 on head `0e7fcd30` with no unresolved review threads Notes: - I ran recursive typecheck and build first. Both hit container resource limits with exit 137 during concurrent package work, so I reran the affected server and UI targets separately. - An unrelated workspace-runtime auto-port test fails in this container with a PID ownership mismatch. It is outside the files changed here. ## Risks Low to medium risk. The scheduler does more periodic external-object work, so the main risk is extra provider refresh load. The implementation bounds the work to active companies, due non-terminal objects, and 50 objects per company per tick. The path also stays behind the external-objects experimental setting. ## Model Used OpenAI GPT-5 Codex in the Codex execution environment, with shell and GitHub CLI tool use. The runtime did not expose a more specific internal model ID or context window. ## 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) - [x] 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 - [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 - [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: Paperclip <noreply@paperclip.ing>
240 lines
8.1 KiB
TypeScript
240 lines
8.1 KiB
TypeScript
import { useEffect, useState, type ReactNode } from "react";
|
|
import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects";
|
|
import {
|
|
externalObjectDisplayStatusLabel,
|
|
externalObjectDisplayLabel,
|
|
externalObjectIconForKey,
|
|
externalObjectProviderLabel,
|
|
externalObjectToneSeverity,
|
|
externalObjectTypeLabel,
|
|
} from "../../lib/external-objects";
|
|
import {
|
|
externalObjectStatusIcon,
|
|
externalObjectStatusIconDefault,
|
|
} from "../../lib/status-colors";
|
|
import { cn } from "../../lib/utils";
|
|
import { ExternalObjectStatusIcon } from "../ExternalObjectStatusIcon";
|
|
import { PropertyRow } from "./primitives";
|
|
import { ExpandRelationListButton } from "./relation-controls";
|
|
|
|
const EXTERNAL_OBJECT_PROPERTY_PREVIEW_COUNT = 5;
|
|
|
|
function sortExternalObjectGroups(groups: IssueExternalObjectGroup[]) {
|
|
return [...groups].sort((a, b) => {
|
|
const aTone = externalObjectToneSeverity(a.group.object?.statusTone);
|
|
const bTone = externalObjectToneSeverity(b.group.object?.statusTone);
|
|
return bTone - aTone;
|
|
});
|
|
}
|
|
|
|
function externalObjectRowDisplayKey(group: IssueExternalObjectGroup): string {
|
|
const { pill } = group;
|
|
const displayKey = pill.displayKey?.trim();
|
|
if (displayKey) return displayKey;
|
|
if (pill.providerKey === "github") {
|
|
if (pill.objectType === "pull_request") return "Github PR";
|
|
if (pill.objectType === "issue") return "Github Issue";
|
|
}
|
|
return externalObjectDisplayLabel(pill.providerKey, pill.objectType);
|
|
}
|
|
|
|
function externalObjectRowLabel(group: IssueExternalObjectGroup): ReactNode {
|
|
const { pill } = group;
|
|
const displayKey = externalObjectRowDisplayKey(group);
|
|
const Icon = externalObjectIconForKey(pill.iconKey);
|
|
return (
|
|
<span className="inline-flex min-w-0 items-start gap-1" title={displayKey}>
|
|
{Icon ? <Icon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 mt-0.5" /> : null}
|
|
<span className="truncate">{displayKey}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function githubObjectPropertyValue(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 externalObjectPropertyStatusLabel(group: IssueExternalObjectGroup): string {
|
|
return externalObjectDisplayStatusLabel(group.pill);
|
|
}
|
|
|
|
function externalObjectPropertyValue(group: IssueExternalObjectGroup): string {
|
|
const { pill } = group;
|
|
const statusLabel = externalObjectPropertyStatusLabel(group);
|
|
const githubLabel = pill.providerKey === "github" ? githubObjectPropertyValue(pill.url) : null;
|
|
const base = githubLabel ?? pill.displayTitle?.trim() ?? externalObjectRowDisplayKey(group);
|
|
return statusLabel ? `${base} - ${statusLabel}` : base;
|
|
}
|
|
|
|
function isMergedExternalObject(group: IssueExternalObjectGroup): boolean {
|
|
const statusLabel = externalObjectPropertyStatusLabel(group);
|
|
return group.pill.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged";
|
|
}
|
|
|
|
function externalObjectPropertyTone(group: IssueExternalObjectGroup): string {
|
|
const tone = isMergedExternalObject(group)
|
|
? externalObjectStatusIcon.merged
|
|
: externalObjectStatusIcon[group.pill.statusCategory] ?? externalObjectStatusIconDefault;
|
|
return tone.split(" ").filter((c) => c.startsWith("text-")).join(" ");
|
|
}
|
|
|
|
function externalObjectPropertyStatusIconKey(group: IssueExternalObjectGroup): string | null | undefined {
|
|
if (isMergedExternalObject(group)) return group.pill.statusIconKey ?? "git-merge";
|
|
return group.pill.statusIconKey;
|
|
}
|
|
|
|
function externalObjectPropertyTitle(group: IssueExternalObjectGroup): string {
|
|
const { pill, sourceLabels } = group;
|
|
const base = pill.displayTitle ?? externalObjectPropertyValue(group);
|
|
return sourceLabels.length > 0 ? `${base} - ${sourceLabels.join(", ")}` : base;
|
|
}
|
|
|
|
function ExternalObjectPropertyValue({ group }: { group: IssueExternalObjectGroup }) {
|
|
const { pill } = group;
|
|
const statusLabel = externalObjectPropertyStatusLabel(group);
|
|
const providerLabel = externalObjectProviderLabel(pill.providerKey);
|
|
const typeLabel = externalObjectTypeLabel(pill.objectType);
|
|
const value = externalObjectPropertyValue(group);
|
|
const content = (
|
|
<>
|
|
<ExternalObjectStatusIcon
|
|
category={pill.statusCategory}
|
|
liveness={pill.liveness}
|
|
statusIconKey={externalObjectPropertyStatusIconKey(group)}
|
|
sizeClassName="h-3.5 w-3.5"
|
|
label={`${providerLabel}: ${statusLabel}`}
|
|
/>
|
|
<span className="min-w-0 truncate">{value}</span>
|
|
</>
|
|
);
|
|
const className = cn(
|
|
"inline-flex min-w-0 max-w-full items-center gap-1.5 text-sm no-underline",
|
|
externalObjectPropertyTone(group),
|
|
pill.url ? "hover:underline focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring" : "",
|
|
);
|
|
|
|
if (pill.url) {
|
|
return (
|
|
<a
|
|
href={pill.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
data-mention-kind="external-object"
|
|
data-external-status={pill.statusCategory}
|
|
data-external-liveness={pill.liveness}
|
|
className={className}
|
|
title={externalObjectPropertyTitle(group)}
|
|
aria-label={`${providerLabel} ${typeLabel} - ${statusLabel}: ${pill.displayTitle ?? value}`}
|
|
>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span
|
|
data-mention-kind="external-object"
|
|
data-external-status={pill.statusCategory}
|
|
data-external-liveness={pill.liveness}
|
|
className={className}
|
|
title={externalObjectPropertyTitle(group)}
|
|
aria-label={`${providerLabel} ${typeLabel} - ${statusLabel}: ${pill.displayTitle ?? value}`}
|
|
>
|
|
{content}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ExternalObjectRows({
|
|
externalObjects,
|
|
externalObjectsLoading,
|
|
externalObjectsError,
|
|
onRetryExternalObjects,
|
|
}: {
|
|
externalObjects?: IssueExternalObjectGroup[];
|
|
externalObjectsLoading?: boolean;
|
|
externalObjectsError?: boolean;
|
|
onRetryExternalObjects?: () => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setExpanded(false);
|
|
}, [externalObjects]);
|
|
|
|
if (externalObjectsError) {
|
|
return (
|
|
<PropertyRow label="External objects">
|
|
<span className="text-xs text-muted-foreground">
|
|
Couldn't load external objects.
|
|
{onRetryExternalObjects ? (
|
|
<>
|
|
{" "}
|
|
<button
|
|
type="button"
|
|
className="text-primary underline-offset-2 hover:underline"
|
|
onClick={onRetryExternalObjects}
|
|
>
|
|
Retry
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</span>
|
|
</PropertyRow>
|
|
);
|
|
}
|
|
|
|
if (externalObjectsLoading) {
|
|
return (
|
|
<PropertyRow label="External objects">
|
|
<span className="h-4 w-24 animate-pulse rounded bg-muted/40" />
|
|
</PropertyRow>
|
|
);
|
|
}
|
|
|
|
if (!externalObjects || externalObjects.length === 0) return null;
|
|
|
|
const sortedExternalObjects = sortExternalObjectGroups(externalObjects);
|
|
const visibleExternalObjects = expanded
|
|
? sortedExternalObjects
|
|
: sortedExternalObjects.slice(0, EXTERNAL_OBJECT_PROPERTY_PREVIEW_COUNT);
|
|
const hiddenExternalObjectCount = sortedExternalObjects.length - visibleExternalObjects.length;
|
|
|
|
return (
|
|
<>
|
|
{visibleExternalObjects
|
|
.map((externalObject) => {
|
|
const { pill, group } = externalObject;
|
|
return (
|
|
<PropertyRow
|
|
key={group.object?.id ?? `${pill.providerKey}:${pill.objectType}:${pill.url ?? "anon"}`}
|
|
label={externalObjectRowLabel(externalObject)}
|
|
>
|
|
<ExternalObjectPropertyValue group={externalObject} />
|
|
</PropertyRow>
|
|
);
|
|
})}
|
|
{expanded || hiddenExternalObjectCount > 0 ? (
|
|
<PropertyRow label="References">
|
|
<ExpandRelationListButton
|
|
hiddenCount={hiddenExternalObjectCount}
|
|
expanded={expanded}
|
|
onClick={() => setExpanded((next) => !next)}
|
|
/>
|
|
</PropertyRow>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|