Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export const STATUS_FILTERS: Array<{id: StatusFilter; label: string}> = [
];

export const SORTS: Array<{id: BountySort; label: string}> = [
{id: 'status', label: 'By status'},
{id: 'votes', label: 'Most voted'},
{id: 'status', label: 'By status'},
];

export const DIFFICULTY_FILTERS: Array<{id: DifficultyFilter; label: string}> = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function BountyGrid() {
const [focus, setFocus] = useState<FocusFilter>('all');
const [difficulty, setDifficulty] = useState<DifficultyFilter>('all');
const [query, setQuery] = useState('');
const [sort, setSort] = useState<BountySort>('status');
const [sort, setSort] = useState<BountySort>('votes');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [baked] = useState(getActiveBounties);
const live = useLiveBounties(baked, useCallback((slug: string) => setSelectedId(slug), []));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,35 @@
overflow-wrap: anywhere;
}
}

.modes {
display: flex;
width: fit-content;
max-width: 100%;
gap: 0.25rem;
padding: 0.3rem;
border: 0.1rem solid var(--color-border);
border-radius: var(--radius-full);
}

.mode {
padding: 0.4rem 0.875rem;
border-radius: var(--radius-full);
font-size: 0.825rem;
font-weight: 600;
color: var(--color-text-secondary);
transition: all 0.2s ease;

&:hover {
color: var(--color-text-primary);
}
}

.modeActive {
background-color: var(--color-text-primary);
color: var(--color-bg);

&:hover {
color: var(--color-bg);
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
'use client';

import {useState, type FormEvent} from 'react';
import {ArrowUpRightIcon, CopyIcon, GitBranchIcon, KeyRoundIcon} from 'lucide-react';
import {useEffect, useState, type FormEvent} from 'react';
import clsx from 'clsx';
import {ArrowUpRightIcon, CopyIcon, GitBranchIcon, KeyRoundIcon, LinkIcon} from 'lucide-react';
import {Button} from '@/components/ui/Button/Button';
import {CopyButton} from '@/components/ui/CopyButton/CopyButton';
import {trackEvent} from '@/lib/analytics';
import {APP_VERSION} from '@/lib/download/version';
import {
fetchMaintainedRepos,
fetchRepoOwners,
linkRepository,
localScaffoldCommand,
scaffoldRepository,
type LiveRequest,
type ScaffoldedRepo,
type MaintainedRepo,
type RepoOwner,
} from '@/lib/plugins/requests';
import type {BoardBounty} from '../useLiveBounties';
import styles from './ScaffoldForm.module.scss';
Expand All @@ -34,9 +39,44 @@ export function ScaffoldForm({bounty, onScaffolded}: ScaffoldFormProps) {
const [minRuntimeVersion, setMinRuntimeVersion] = useState(APP_VERSION);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<{message: string; reauthUrl?: string} | null>(null);
const [created, setCreated] = useState<ScaffoldedRepo | null>(null);
const [created, setCreated] = useState<{repoUrl: string; fullName: string; submitUrl: string; linked: boolean} | null>(
null,
);
const [mode, setMode] = useState<'create' | 'link'>(canScaffold ? 'create' : 'link');
const [owners, setOwners] = useState<RepoOwner[] | null>(null);
const [linkUrl, setLinkUrl] = useState('');
const [repos, setRepos] = useState<MaintainedRepo[] | null>(null);
const command = localScaffoldCommand(bounty.id, bounty.kind);

// Accounts the repository can be created in; the field stays free text if this fails.
useEffect(() => {
if (!canScaffold) return;
let cancelled = false;
fetchRepoOwners().then((list) => {
if (!cancelled && list && list.length > 0) setOwners(list);
});
return () => {
cancelled = true;
};
}, [canScaffold]);

// Suggestions for the link field, loaded the first time it is shown; retried on the next visit if that fails.
useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: useEffect caches failed fetches permanently

The effect only runs when repos === null. If the first fetch fails, repos becomes [] (line 297 in requests.ts) and the effect never runs again when re-entering 'link' mode. User can still paste a URL manually, but suggestions won't refresh.

Consider using a separate reposLoaded boolean flag or allowing retry on mode change.

if (mode !== 'link' || repos !== null) return;
let cancelled = false;
fetchMaintainedRepos().then((list) => {
if (!cancelled && list) setRepos(list);
});
return () => {
cancelled = true;
};
}, [mode, repos]);

function switchMode(next: 'create' | 'link') {
setMode(next);
setError(null);
}

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!bounty.requestId) return;
Expand All @@ -54,15 +94,44 @@ export function ScaffoldForm({bounty, onScaffolded}: ScaffoldFormProps) {
return;
}
trackEvent('Bounty', 'Scaffold', bounty.id);
setCreated(result.data);
setCreated({...result.data, linked: false});
onScaffolded(result.data.claimers);
}

async function handleLink(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!bounty.requestId) return;
setSubmitting(true);
setError(null);
const result = await linkRepository(bounty.requestId, linkUrl.trim());
setSubmitting(false);
if (!result.ok) {
setError({message: result.error, reauthUrl: result.reauthUrl});
return;
}
trackEvent('Bounty', 'Link repository', bounty.id);
setCreated({...result.data, linked: true});
onScaffolded(result.data.claimers);
}

const errorBox = error && (
<div className={styles.error} role="alert">
<p>{error.message}</p>
{error.reauthUrl && (
<Button href={error.reauthUrl} size="sm">
<KeyRoundIcon />
Grant access on GitHub
</Button>
)}
</div>
);

if (created) {
return (
<div className={styles.done}>
<p>
<strong>{created.fullName}</strong> is ready. Build the plugin, publish a release, then submit the
<strong>{created.fullName}</strong> {created.linked ? 'is linked to your claim' : 'is ready'}. Build
the plugin, publish a release, then submit the
repository to the registry: the bounty ships as soon as <code>{bounty.id}</code> is listed.
</p>
<div className={styles.row}>
Expand All @@ -82,6 +151,59 @@ export function ScaffoldForm({bounty, onScaffolded}: ScaffoldFormProps) {
return (
<div className={styles.wrapper}>
{canScaffold && (
<div className={styles.modes} role="group" aria-label="Repository">
<button
type="button"
aria-pressed={mode === 'create'}
className={clsx(styles.mode, mode === 'create' && styles.modeActive)}
onClick={() => switchMode('create')}
>
New repository
</button>
<button
type="button"
aria-pressed={mode === 'link'}
className={clsx(styles.mode, mode === 'link' && styles.modeActive)}
onClick={() => switchMode('link')}
>
Link an existing one
</button>
</div>
)}
{mode === 'link' && bounty.requestId && (
<form className={styles.form} onSubmit={handleLink}>
<label className={styles.field}>
<span className={styles.label}>Repository URL</span>
<input
className={styles.input}
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
list={`link-repo-${bounty.id}`}
placeholder="https://github.com/owner/repo"
autoComplete="off"
required
/>
<datalist id={`link-repo-${bounty.id}`}>
{repos?.map((repo) => (
<option key={repo.url} value={repo.url}>
{repo.fullName}
</option>
))}
</datalist>
</label>
<span className={styles.hint}>
Pick one of your repositories or paste its URL. You need maintain access, as when submitting a
plugin.
</span>
{errorBox}
<Button type="submit" size="sm" disabled={submitting} className={styles.submit}>
<LinkIcon />
{submitting ? 'Linking the repository…' : 'Link the repository'}
</Button>
</form>
)}
{canScaffold && mode === 'create' && (
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.grid}>
<label className={styles.field}>
Expand All @@ -96,13 +218,24 @@ export function ScaffoldForm({bounty, onScaffolded}: ScaffoldFormProps) {
</label>
<label className={styles.field}>
<span className={styles.label}>Organization</span>
<input
className={styles.input}
value={owner}
onChange={(e) => setOwner(e.target.value)}
placeholder="Your account"
pattern="[A-Za-z0-9\-]{1,39}"
/>
{owners ? (
<select className={styles.input} value={owner} onChange={(e) => setOwner(e.target.value)}>
{owners.map((o, i) => (
<option key={o.login} value={i === 0 ? '' : o.login}>
{i === 0 ? `${o.login} (your account)` : o.login}
</option>
))}
</select>
) : (
<input
className={styles.input}
value={owner}
onChange={(e) => setOwner(e.target.value)}
placeholder="Your account"
pattern="[A-Za-z0-9\-]{1,39}"
autoComplete="off"
/>
)}
</label>
{isTheme ? (
<label className={styles.field}>
Expand Down Expand Up @@ -146,17 +279,7 @@ export function ScaffoldForm({bounty, onScaffolded}: ScaffoldFormProps) {
Private repository
</label>
</div>
{error && (
<div className={styles.error} role="alert">
<p>{error.message}</p>
{error.reauthUrl && (
<Button href={error.reauthUrl} size="sm">
<KeyRoundIcon />
Grant access on GitHub
</Button>
)}
</div>
)}
{errorBox}
<Button type="submit" size="sm" disabled={submitting} className={styles.submit}>
<GitBranchIcon />
{submitting ? 'Creating the repository…' : 'Create the repository'}
Expand Down
48 changes: 48 additions & 0 deletions src/lib/plugins/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,54 @@ export function scaffoldRepository(requestId: string, options: ScaffoldOptions)
});
}

export interface LinkedRepo {
repoUrl: string;
fullName: string;
submitUrl: string;
claimers: LiveRequest['claimers'];
}

/** Records a repository the claimer already maintains as the work for their claim. */
export function linkRepository(requestId: string, repoUrl: string) {
return postAction<LinkedRepo>(`/api/requests/${encodeURIComponent(requestId)}/repo`, {
repoUrl,
returnTo: window.location.href.split('#')[0],
});
}

export interface RepoOwner {
login: string;
type: 'user' | 'org';
}

/** The visitor's GitHub account first, then their organizations, or null when unavailable. */
export async function fetchRepoOwners(): Promise<RepoOwner[] | null> {
try {
const res = await fetch(`${TABULARIUM_URL}/api/submit/owners`, {credentials: 'include'});
if (!res.ok) return null;
return ((await res.json()) as {owners: RepoOwner[]}).owners;
} catch {
return null;
}
}

export interface MaintainedRepo {
fullName: string;
url: string;
}

/** Repositories the visitor can maintain on their linked accounts (first page of each), or null on failure. */
export async function fetchMaintainedRepos(): Promise<MaintainedRepo[] | null> {
try {
const res = await fetch(`${TABULARIUM_URL}/api/submit/repos?page=1`, {credentials: 'include'});
if (!res.ok) return null;
const body = (await res.json()) as {groups: Array<{repos: MaintainedRepo[]}>};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Fragile API response assumption

fetchMaintainedRepos assumes {groups: Array<{repos: MaintainedRepo[]}>} structure. If the API changes, this silently returns [] via the catch block. Consider validating the response structure or logging a warning in development to detect API drift early.

return body.groups.flatMap((group) => group.repos.map(({fullName, url}) => ({fullName, url})));
} catch {
return null;
}
}

/** Local alternative to the hosted scaffold, for any forge. */
export function localScaffoldCommand(slug: string, kind: string | null): string {
return kind === 'theme'
Expand Down