Skip to content
Open
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
22 changes: 20 additions & 2 deletions src/app/api/repos/[owner]/[name]/issues-meta/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?')
Expand All @@ -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;

Expand Down Expand Up @@ -118,8 +119,25 @@ export async function GET(
// reading author_stats.
const author_stats: Record<string, { open: number; completed: number; not_planned: number; closed: number }> = {};

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) },
);
}
18 changes: 17 additions & 1 deletion src/app/api/repos/[owner]/[name]/issues/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'),
Expand All @@ -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');
Expand Down Expand Up @@ -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');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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`
Expand Down Expand Up @@ -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
Expand Down
69 changes: 65 additions & 4 deletions src/components/RepoExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ export default function RepoExplorer() {
debouncedQuery: debouncedIssueQuery,
state: issueState,
setState: setIssueState,
mineOnly: issueMineOnly,
setMineOnly: setIssueMineOnly,
author: issueAuthor,
setAuthor: setIssueAuthor,
authorsRequested: issueAuthorsRequested,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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()}`;
};

Expand All @@ -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 });
Expand All @@ -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<IssuesMetaResponse>({
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}`);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1719,6 +1727,59 @@ export default function RepoExplorer() {
},
]}
/>
<Box
as="label"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 2,
px: '12px',
py: '5px',
height: 32,
border: '1px solid',
borderColor: issueMineOnly ? 'var(--attention-emphasis)' : 'var(--border-default)',
bg: issueMineOnly ? 'var(--attention-subtle, rgba(242, 201, 76, 0.16))' : 'var(--bg-canvas)',
color: issueMineOnly ? 'var(--attention-emphasis)' : 'var(--fg-default)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
userSelect: 'none',
transition: 'border-color 80ms, background 80ms, color 80ms',
'&:hover': { borderColor: issueMineOnly ? 'var(--attention-emphasis)' : 'var(--border-strong)' },
}}
>
<input
type="checkbox"
checked={issueMineOnly}
onChange={(e) => setIssueMineOnly(e.target.checked)}
style={{
margin: 0,
width: 14,
height: 14,
accentColor: 'var(--attention-emphasis)',
cursor: 'pointer',
}}
/>
<PersonIcon size={14} />
My Issues only
{myIssueCount > 0 && (
<Box
sx={{
px: '6px',
py: 0,
bg: issueMineOnly ? 'var(--attention-emphasis)' : 'var(--bg-emphasis)',
color: issueMineOnly ? '#ffffff' : 'var(--fg-default)',
fontSize: '11px',
fontWeight: 700,
borderRadius: 999,
lineHeight: '18px',
}}
>
{myIssueCount}
</Box>
)}
</Box>
<Box
sx={{
ml: ['0', null, 'auto'],
Expand Down
8 changes: 7 additions & 1 deletion src/components/repo-explorer/useIssueFilters.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { SortDir } from './styles';

export type IssueState = 'all' | 'open' | 'completed' | 'not_planned' | 'duplicate' | 'closed';
export type IssueState = 'all' | 'open' | 'completed' | 'not_planned' | 'duplicate' | 'closed' | 'mine';
export type IssueSortKey =
| 'opened'
| 'updated'
Expand All @@ -20,6 +20,8 @@ export interface IssueFilters {
debouncedQuery: string;
state: IssueState;
setState: (v: IssueState) => void;
mineOnly: boolean;
setMineOnly: (v: boolean) => void;
author: string;
setAuthor: (v: string) => void;
authorsRequested: boolean;
Expand All @@ -33,6 +35,7 @@ export interface IssueFilters {
export function useIssueFilters(): IssueFilters {
const [query, setQuery] = useState('');
const [state, setState] = useState<IssueState>('all');
const [mineOnly, setMineOnly] = useState(false);
const [author, setAuthor] = useState<string>('all');
const [authorsRequested, setAuthorsRequested] = useState(false);
const [sortKey, setSortKey] = useState<IssueSortKey>('opened');
Expand Down Expand Up @@ -60,6 +63,7 @@ export function useIssueFilters(): IssueFilters {
setState('all');
setAuthor('all');
setAuthorsRequested(false);
setMineOnly(false);
}, []);

return {
Expand All @@ -68,6 +72,8 @@ export function useIssueFilters(): IssueFilters {
debouncedQuery,
state,
setState,
mineOnly,
setMineOnly,
author,
setAuthor,
authorsRequested,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/types/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────
Expand Down