sync: stale skill detection, sentry filtering, claim removal, review improvements

Stale Detection:
- Detect skills removed/moved from GitHub (3 consecutive 404s threshold)
- Hide stale skills from browse/search, show warning banner on detail page
- Serve cached files with isStale flag when GitHub returns 404
- Add stale-check crawler command for batch verification
- CLI shows warning when installing stale skills from cache

Sentry & Error Handling:
- Filter browser extension errors and add denyUrls
- Anti-inflation measures and sentinel recalibration for curation

Claim & Removal:
- Enhanced ClaimForm with repo-level removal support
- Add repo-removal-request API endpoint with tests
- Improved owner page with bilingual content

Review Pipeline:
- Review version and reviewer tracking in submit API
- Source format filter for pending reviews
- Updated review tests

Other:
- Updated i18n strings (en/fa)
- BrowseFilters improvements
- Dockerfile updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-03-12 05:59:26 +03:30
parent 447f9eff59
commit 4212b5ba47
31 changed files with 1590 additions and 58 deletions

View File

@@ -470,7 +470,7 @@ export function EmptyState({ query, hasFilters, locale = 'en', translations }: E
<Sparkles className="w-10 h-10 text-text-muted" />
</div>
<h3 className="text-xl font-semibold text-text-primary mb-2">
{query ? translations.noResultsWithQuery.replace('{query}', query) : translations.noResults}
{query ? translations.noResultsWithQuery : translations.noResults}
</h3>
<p className="text-text-secondary mb-6 max-w-md mx-auto">
{translations.tryDifferent}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useSession, signIn } from 'next-auth/react';
import { Loader2, CheckCircle, AlertCircle, Github, Clock, Plus, Minus, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
import { Loader2, CheckCircle, AlertCircle, Github, Clock, Plus, Minus, ChevronDown, ChevronUp, ExternalLink, Trash2 } from 'lucide-react';
import { fetchWithCsrf } from '@/lib/csrf-client';
const isMirror = process.env.NEXT_PUBLIC_IS_PRIMARY === 'false';
@@ -33,6 +33,26 @@ interface ClaimFormProps {
tabs: {
remove: string;
add: string;
removeRepo: string;
};
removeRepoForm: {
repoUrl: string;
repoUrlPlaceholder: string;
repoUrlHelp: string;
reason: string;
reasonPlaceholder: string;
submit: string;
submitting: string;
};
removeRepoSuccess: {
title: string;
description: string;
descriptionZero: string;
};
removeRepoError: {
notOwner: string;
invalidRepo: string;
parseError: string;
};
form: {
skillId: string;
@@ -69,6 +89,8 @@ interface ClaimFormProps {
foundSkillsIn: string;
root: string;
andMore: string;
reEnabledTitle: string;
reEnabledDescription: string;
};
error: {
notOwner: string;
@@ -81,6 +103,7 @@ interface ClaimFormProps {
rateLimitExceeded: string;
networkTimeout: string;
generic: string;
repoBlockedByOwner: string;
};
myRequests: {
title: string;
@@ -122,15 +145,15 @@ interface AddRequest {
export function ClaimForm({ translations }: ClaimFormProps) {
const { data: session, status } = useSession();
const [activeTab, setActiveTab] = useState<'remove' | 'add'>('add'); // Default to 'add' tab
const [activeTab, setActiveTab] = useState<'remove' | 'add' | 'remove-repo'>('add'); // Default to 'add' tab
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
// Read tab from URL hash on mount and update on hash change
useEffect(() => {
const readHashTab = () => {
const hash = window.location.hash.slice(1);
if (hash === 'remove' || hash === 'add') {
setActiveTab(hash);
if (hash === 'remove' || hash === 'add' || hash === 'remove-repo') {
setActiveTab(hash as 'remove' | 'add' | 'remove-repo');
}
};
readHashTab();
@@ -139,7 +162,7 @@ export function ClaimForm({ translations }: ClaimFormProps) {
}, []);
// Update URL hash when tab changes
const handleTabChange = useCallback((tab: 'remove' | 'add') => {
const handleTabChange = useCallback((tab: 'remove' | 'add' | 'remove-repo') => {
setActiveTab(tab);
window.history.replaceState(null, '', `#${tab}`);
}, []);
@@ -177,9 +200,19 @@ export function ClaimForm({ translations }: ClaimFormProps) {
const [addHasSkillMd, setAddHasSkillMd] = useState(true);
const [addSkillCount, setAddSkillCount] = useState(0);
const [addSkillPaths, setAddSkillPaths] = useState<string[]>([]);
const [addReEnabled, setAddReEnabled] = useState(false);
const [addRequests, setAddRequests] = useState<AddRequest[]>([]);
const [loadingAddRequests, setLoadingAddRequests] = useState(false);
// Remove-repo form state
const [repoUrl, setRepoUrl] = useState('');
const [repoReason, setRepoReason] = useState('');
const [isSubmittingRepo, setIsSubmittingRepo] = useState(false);
const [repoError, setRepoError] = useState('');
const [repoSuccess, setRepoSuccess] = useState(false);
const [repoBlockedCount, setRepoBlockedCount] = useState(0);
const [repoName, setRepoName] = useState('');
// Fetch user's existing requests
useEffect(() => {
if (session) {
@@ -331,6 +364,9 @@ export function ClaimForm({ translations }: ClaimFormProps) {
case 'ALREADY_PENDING':
setAddError(translations.error.alreadyPending);
break;
case 'REPO_BLOCKED_BY_OWNER':
setAddError(translations.error.repoBlockedByOwner);
break;
case 'AUTH_REQUIRED':
signIn('github');
return;
@@ -342,6 +378,7 @@ export function ClaimForm({ translations }: ClaimFormProps) {
}
setAddSuccess(true);
setAddReEnabled(data.reEnabled ?? false);
setAddHasSkillMd(data.hasSkillMd ?? true);
setAddSkillCount(data.skillCount ?? 0);
setAddSkillPaths(data.skillPaths ?? []);
@@ -355,6 +392,61 @@ export function ClaimForm({ translations }: ClaimFormProps) {
}
};
const handleRepoRemovalSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!session) {
signIn('github');
return;
}
if (!repoUrl.trim()) return;
setIsSubmittingRepo(true);
setRepoError('');
setRepoSuccess(false);
try {
const res = await fetchWithCsrf('/api/skills/repo-removal-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repoUrl: repoUrl.trim(), reason: repoReason.trim() }),
});
const data = await res.json();
if (!res.ok) {
switch (data.code) {
case 'NOT_OWNER':
setRepoError(translations.removeRepoError.notOwner);
break;
case 'INVALID_REPO':
setRepoError(translations.removeRepoError.invalidRepo);
break;
case 'PARSE_ERROR':
setRepoError(translations.removeRepoError.parseError);
break;
case 'AUTH_REQUIRED':
signIn('github');
return;
default:
console.error('Repo removal error:', data);
setRepoError(translations.error.generic);
}
return;
}
setRepoSuccess(true);
setRepoBlockedCount(data.blockedCount ?? 0);
setRepoName(data.repo ?? repoUrl.trim());
setRepoUrl('');
setRepoReason('');
} catch {
setRepoError(translations.error.generic);
} finally {
setIsSubmittingRepo(false);
}
};
const getStatusBadge = (requestStatus: string) => {
switch (requestStatus) {
case 'pending':
@@ -457,11 +549,40 @@ export function ClaimForm({ translations }: ClaimFormProps) {
);
}
// Repo removal success state
if (repoSuccess) {
return (
<div className="card p-8 text-center">
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" />
<h2 className="text-xl font-semibold text-text-primary mb-2">
{translations.removeRepoSuccess.title}
</h2>
<p className="text-text-secondary mb-6">
{repoBlockedCount > 0
? translations.removeRepoSuccess.description
.replace('{count}', String(repoBlockedCount))
.replace('{repo}', repoName)
: translations.removeRepoSuccess.descriptionZero}
</p>
<button
onClick={() => { setRepoSuccess(false); setRepoBlockedCount(0); setRepoName(''); }}
className="btn-secondary"
>
{translations.success.viewRequests}
</button>
</div>
);
}
// Add success state
if (addSuccess) {
// Determine which message to show
let successTitle = translations.addSuccess.title;
let successDescription: string | React.ReactNode;
if (addSkillCount > 1) {
if (addReEnabled) {
successTitle = translations.addSuccess.reEnabledTitle;
successDescription = translations.addSuccess.reEnabledDescription;
} else if (addSkillCount > 1) {
successDescription = (
<>
{translations.addSuccess.descriptionMultiplePrefix}
@@ -479,7 +600,7 @@ export function ClaimForm({ translations }: ClaimFormProps) {
<div className="card p-8 text-center">
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-success" />
<h2 className="text-xl font-semibold text-text-primary mb-2">
{translations.addSuccess.title}
{successTitle}
</h2>
<p className="text-text-secondary mb-4">
{successDescription}
@@ -500,7 +621,7 @@ export function ClaimForm({ translations }: ClaimFormProps) {
</div>
)}
<button
onClick={() => setAddSuccess(false)}
onClick={() => { setAddSuccess(false); setAddReEnabled(false); }}
className="btn-secondary"
>
{translations.addSuccess.viewRequests}
@@ -535,6 +656,17 @@ export function ClaimForm({ translations }: ClaimFormProps) {
<Plus className="w-4 h-4" />
{translations.tabs.add}
</button>
<button
onClick={() => handleTabChange('remove-repo')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'remove-repo'
? 'border-error text-error'
: 'border-transparent text-text-muted hover:text-text-primary'
}`}
>
<Trash2 className="w-4 h-4" />
{translations.tabs.removeRepo}
</button>
</div>
{/* Remove Form */}
@@ -652,6 +784,79 @@ export function ClaimForm({ translations }: ClaimFormProps) {
</>
)}
{/* Remove Repository Form */}
{activeTab === 'remove-repo' && (
<div className="card p-6">
<form onSubmit={handleRepoRemovalSubmit} className="space-y-4">
<div>
<label
htmlFor="repoUrl"
className="block text-sm font-medium text-text-primary mb-2"
>
{translations.removeRepoForm.repoUrl}
</label>
<input
type="text"
id="repoUrl"
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder={translations.removeRepoForm.repoUrlPlaceholder}
className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-error"
dir="ltr"
required
/>
<p className="text-xs text-text-muted mt-1">
{translations.removeRepoForm.repoUrlHelp}
</p>
</div>
<div>
<label
htmlFor="repoReason"
className="block text-sm font-medium text-text-primary mb-2"
>
{translations.removeRepoForm.reason} <span className="text-text-muted">{translations.optional}</span>
</label>
<textarea
id="repoReason"
value={repoReason}
onChange={(e) => setRepoReason(e.target.value)}
placeholder={translations.removeRepoForm.reasonPlaceholder}
rows={3}
className="w-full px-4 py-2 border border-border rounded-lg bg-surface text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
/>
</div>
{repoError && (
<div className="p-3 bg-error-bg border border-error/30 rounded-lg">
<div className="flex items-center gap-2 text-error">
<AlertCircle className="w-4 h-4" />
<p className="text-sm">{repoError}</p>
</div>
</div>
)}
<button
type="submit"
disabled={isSubmittingRepo || !repoUrl.trim()}
className="w-full justify-center inline-flex items-center gap-2 px-4 py-2 bg-error text-white rounded-lg font-medium hover:bg-error/90 transition-colors disabled:opacity-50"
>
{isSubmittingRepo ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{translations.removeRepoForm.submitting}
</>
) : (
<>
<Trash2 className="w-4 h-4" />
{translations.removeRepoForm.submit}
</>
)}
</button>
</form>
</div>
)}
{/* Add Form */}
{activeTab === 'add' && (
<>