diff --git a/src/app/api/repos/[owner]/[name]/issues-meta/route.ts b/src/app/api/repos/[owner]/[name]/issues-meta/route.ts index 7b5b958..5fc9b42 100644 --- a/src/app/api/repos/[owner]/[name]/issues-meta/route.ts +++ b/src/app/api/repos/[owner]/[name]/issues-meta/route.ts @@ -18,6 +18,7 @@ export async function GET( const url = new URL(req.url); const q = (url.searchParams.get('q') ?? '').trim(); const summaryOnly = url.searchParams.get('summary') === '1'; + const mineLogin = (url.searchParams.get('mine_login') ?? '').trim(); const lastFetch = (db .prepare('SELECT last_issues_fetch FROM repo_meta WHERE full_name = ?') @@ -27,7 +28,7 @@ export async function GET( const linkCount = (db .prepare('SELECT COUNT(*) AS c FROM pr_issue_links WHERE repo_full_name = ?') .get(full) as { c: number }).c; - const etag = buildEtag(['issues-meta-v5', full, lastFetch, linkCount, q, summaryOnly ? 'summary' : 'full']); + const etag = buildEtag(['issues-meta-v6', full, lastFetch, linkCount, q, mineLogin, summaryOnly ? 'summary' : 'full']); const notModified = etagNotModified(req, etag); if (notModified) return notModified; @@ -118,8 +119,25 @@ export async function GET( // reading author_stats. const author_stats: Record = {}; + let mine_count: number | undefined; + if (mineLogin) { + mine_count = (db + .prepare( + `SELECT COUNT(*) AS c FROM issues + WHERE repo_full_name = ? AND LOWER(author_login) = ?` + ) + .get(full, mineLogin.toLowerCase()) as { c: number }).c; + } + return NextResponse.json( - { repo: full, author_options: authorRows, author_stats, total_authors, assoc_counts }, + { + repo: full, + author_options: authorRows, + author_stats, + total_authors, + assoc_counts, + ...(mine_count !== undefined ? { mine_count } : {}), + }, { headers: withEtagHeaders(etag) }, ); } diff --git a/src/app/api/repos/[owner]/[name]/issues/route.ts b/src/app/api/repos/[owner]/[name]/issues/route.ts index 799e791..1d0f712 100644 --- a/src/app/api/repos/[owner]/[name]/issues/route.ts +++ b/src/app/api/repos/[owner]/[name]/issues/route.ts @@ -69,7 +69,7 @@ export async function GET( .get(etagSession.uid, full) as { c: number }).c : 0; const etag = buildEtag([ - 'issues-v4', + 'issues-v5', full, meta0?.last_issues_fetch, linkCount0, @@ -79,6 +79,7 @@ export async function GET( url.searchParams.get('state'), url.searchParams.get('author'), url.searchParams.get('assoc'), + url.searchParams.get('mine_login'), url.searchParams.get('sort'), url.searchParams.get('dir'), url.searchParams.get('since'), @@ -95,6 +96,7 @@ export async function GET( // options at the top of the author dropdown). Mutually exclusive with the // login-level `author` filter — if both are set, `author` wins. const assoc = (url.searchParams.get('assoc') ?? '').toLowerCase(); + const mineLogin = (url.searchParams.get('mine_login') ?? '').trim(); const sort = (url.searchParams.get('sort') ?? 'opened') as SortKey; const dir = (url.searchParams.get('dir') ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; const since = url.searchParams.get('since'); @@ -127,6 +129,14 @@ export async function GET( } else if (assoc === 'contributor') { where.push("UPPER(COALESCE(i.author_association,'')) IN ('CONTRIBUTOR','FIRST_TIME_CONTRIBUTOR','FIRST_TIMER')"); } + if (state === 'mine' && mineLogin) { + where.push('LOWER(i.author_login) = ?'); + args.push(mineLogin.toLowerCase()); + } else if (state === 'mine') { + // state=mine without a mine_login (e.g. signed-out user toggled the + // filter) would otherwise fall through to "all issues" — force empty. + where.push('1 = 0'); + } // State buckets mirror the client-side effectiveIssueState rule (which // mirrors Gittensor's solved-issue definition). The EXISTS subquery checks // pr_issue_links — populated authoritatively from `closingIssuesReferences` @@ -253,6 +263,12 @@ export async function GET( } else if (assoc === 'contributor') { stateOnlyWhere.push("UPPER(COALESCE(i.author_association,'')) IN ('CONTRIBUTOR','FIRST_TIME_CONTRIBUTOR','FIRST_TIMER')"); } + if (state === 'mine' && mineLogin) { + stateOnlyWhere.push('LOWER(i.author_login) = ?'); + stateOnlyArgs.push(mineLogin.toLowerCase()); + } else if (state === 'mine') { + stateOnlyWhere.push('1 = 0'); + } const stateCountsRow = db .prepare( `SELECT diff --git a/src/components/RepoExplorer.tsx b/src/components/RepoExplorer.tsx index 5ebf2c3..cddd61b 100644 --- a/src/components/RepoExplorer.tsx +++ b/src/components/RepoExplorer.tsx @@ -269,6 +269,8 @@ export default function RepoExplorer() { debouncedQuery: debouncedIssueQuery, state: issueState, setState: setIssueState, + mineOnly: issueMineOnly, + setMineOnly: setIssueMineOnly, author: issueAuthor, setAuthor: setIssueAuthor, authorsRequested: issueAuthorsRequested, @@ -520,7 +522,7 @@ export default function RepoExplorer() { // of the new view rather than e.g. page 5 of an empty filter result. useEffect(() => { setIssuesPage(1); - }, [issueQuery, issueState, issueAuthor, issueSortKey, issueSortDir]); + }, [issueQuery, issueState, issueMineOnly, issueAuthor, issueSortKey, issueSortDir]); useEffect(() => { setPullsPage(1); @@ -718,12 +720,14 @@ export default function RepoExplorer() { const queriesReady = settingsReady && routeReady && selected.fullName !== ''; const shouldLoadIssues = tab === 'issues'; + const issuesState = issueMineOnly ? 'mine' : issueState; + const buildIssuesUrl = (page: number, size: number) => { const sp = new URLSearchParams(); sp.set('page', String(page)); sp.set('pageSize', String(size)); if (debouncedIssueQuery) sp.set('q', debouncedIssueQuery); - if (issueState !== 'all') sp.set('state', issueState); + if (issuesState !== 'all') sp.set('state', issuesState); // `__assoc:` prefix is the front-end sentinel for the "Collaborators" / // "Contributors" pseudo-options at the top of the author dropdown — they // map to GitHub's author_association field, not a specific login. @@ -734,6 +738,7 @@ export default function RepoExplorer() { } sp.set('sort', issueSortKey); sp.set('dir', issueSortDir); + if (me) sp.set('mine_login', me); return `/api/repos/${selected.owner}/${selected.name}/issues?${sp.toString()}`; }; @@ -745,10 +750,11 @@ export default function RepoExplorer() { issuesPage, issuesPageSize, debouncedIssueQuery, - issueState, + issuesState, issueAuthor, issueSortKey, issueSortDir, + me, ], queryFn: async ({ signal }) => { const r = await fetch(buildIssuesUrl(issuesPage, issuesPageSize), { signal }); @@ -768,9 +774,10 @@ export default function RepoExplorer() { // Repo-wide author list + per-author counts. Refresh slowly because these // change much less often than the listing itself. const { data: issuesMeta, isFetching: issuesMetaFetching } = useQuery({ - queryKey: ['issues-meta', selected.owner, selected.name, issueAuthorsRequested], + queryKey: ['issues-meta', selected.owner, selected.name, me, issueAuthorsRequested], queryFn: async ({ signal }) => { const sp = new URLSearchParams(); + if (me) sp.set('mine_login', me); if (!issueAuthorsRequested) sp.set('summary', '1'); const r = await fetch(`/api/repos/${selected.owner}/${selected.name}/issues-meta?${sp.toString()}`, { signal }); if (!r.ok) throw new Error(`HTTP ${r.status}`); @@ -1250,6 +1257,7 @@ export default function RepoExplorer() { const filteredPulls = pullsData?.pulls ?? []; const myPullCount = pullsMeta?.mine_count ?? 0; + const myIssueCount = issuesMeta?.mine_count ?? 0; // Server returns `new_count` based on the per-tab baseline we sent. const newIssuesCount = issuesData?.new_count ?? 0; @@ -1719,6 +1727,59 @@ export default function RepoExplorer() { }, ]} /> + + setIssueMineOnly(e.target.checked)} + style={{ + margin: 0, + width: 14, + height: 14, + accentColor: 'var(--attention-emphasis)', + cursor: 'pointer', + }} + /> + + My Issues only + {myIssueCount > 0 && ( + + {myIssueCount} + + )} + void; + mineOnly: boolean; + setMineOnly: (v: boolean) => void; author: string; setAuthor: (v: string) => void; authorsRequested: boolean; @@ -33,6 +35,7 @@ export interface IssueFilters { export function useIssueFilters(): IssueFilters { const [query, setQuery] = useState(''); const [state, setState] = useState('all'); + const [mineOnly, setMineOnly] = useState(false); const [author, setAuthor] = useState('all'); const [authorsRequested, setAuthorsRequested] = useState(false); const [sortKey, setSortKey] = useState('opened'); @@ -60,6 +63,7 @@ export function useIssueFilters(): IssueFilters { setState('all'); setAuthor('all'); setAuthorsRequested(false); + setMineOnly(false); }, []); return { @@ -68,6 +72,8 @@ export function useIssueFilters(): IssueFilters { debouncedQuery, state, setState, + mineOnly, + setMineOnly, author, setAuthor, authorsRequested, diff --git a/src/lib/api-types.ts b/src/lib/api-types.ts index b665e62..45eb9f4 100644 --- a/src/lib/api-types.ts +++ b/src/lib/api-types.ts @@ -138,6 +138,8 @@ export interface IssuesMetaResponse { * "Collaborators" / "Contributors" pseudo-filters at the top of the * author dropdown. */ assoc_counts?: { collaborator: number; contributor: number }; + /** Count of issues authored by the signed-in user. Powers "My Issues only". */ + mine_count?: number; } export interface PullsMetaResponse { diff --git a/src/types/entities.ts b/src/types/entities.ts index 8fafd9e..92e8cd4 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -345,6 +345,8 @@ export interface IssuesMetaResponse { * author dropdown. */ assoc_counts?: { collaborator: number; contributor: number }; + /** Count of issues authored by the signed-in user. Powers "My Issues only". */ + mine_count?: number; } // ─── Pull ────────────────────────────────────────────────────────────────────