Skip to content

Commit 2912415

Browse files
authored
Merge pull request #66 from arnabnandy7/feature/maintainerResponsiveness
feat: add maintainer responsiveness analytics
2 parents bf4bcae + b3c7c77 commit 2912415

21 files changed

Lines changed: 685 additions & 16 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Filters by contributor-friendly label, linked pull requests, and Hacktoberfest readiness
99
- Filters by explicit experience, contribution-type, and small-scope signals
1010
- Scores repository health from maintenance and contributor-activity signals
11+
- Summarizes maintainer responsiveness from bounded recent contribution samples
1112
- Sorts and ranks results using issue quality and repository health
1213
- Keeps successful searches in shareable, browser-navigable URLs
1314
- Identifies when optional GitHub enrichment is incomplete
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "saved_search" ADD COLUMN "responsiveness" text DEFAULT 'any' NOT NULL;

‎doc/architecture.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,20 @@ flowchart LR
7070

7171
Candidate issues are enriched where possible with repository metadata, recent discussion, assignment state, linked pull requests, and Hacktoberfest signals. Repository health uses recent pushes, open issue and pull-request activity, stars, forks, and issue-tracker availability to distinguish active, moderate, and stale projects. The health score contributes a small optional boost to issue quality ranking and is shown directly on issue cards. The application ranks the enriched results for contributor relevance and returns paginated results to the browser.
7272

73+
Maintainer responsiveness remains separate from repository health. For at most 12
74+
candidate repositories per search, a cached six-hour GitHub GraphQL request samples
75+
up to 20 recently updated issues and pull requests from the previous 90 days. The
76+
summary considers the first response from an owner, member, or collaborator (while
77+
excluding comments from the issue author), unanswered contributor-friendly issues,
78+
recent closures, and the external pull-request merge ratio. A responsive status
79+
requires at least half of sampled external pull requests to be merged when any are
80+
present; multiple external pull requests with a merge ratio below 25% indicate a
81+
slow repository. Fewer than four contribution
82+
samples, missing authentication, repositories outside the bounded batch, and partial
83+
GitHub failures produce `Unknown`. Responsive repositories receive a five-point
84+
quality boost and variable repositories receive two points; repository health is
85+
calculated and displayed independently.
86+
7387
Search responses report repository-metadata, discussion-analysis, and linked-PR
7488
availability separately. Optional enrichment failures do not discard usable issue
7589
results; the interface identifies partial data and score explanations omit signals
@@ -85,6 +99,7 @@ Search filters currently include:
8599
- Any experience, first contribution, beginner, or intermediate
86100
- Documentation, tests, bug fix, or feature contribution type
87101
- All scopes or small scope
102+
- Any, responsive, variable, slow, or unknown maintainer responsiveness
88103

89104
Experience, contribution type, and scope classifications use explicit issue
90105
labels and structured issue-template fields. Results show the signals that
@@ -185,7 +200,12 @@ observation is explicitly presented as a baseline.
185200

186201
Digest issue links open GitHub directly. Saved-search links include the existing
187202
filter query parameters; the issue finder reads those parameters and runs the
188-
linked search on load.
203+
linked search on load. Weekly recommendations honor the saved responsiveness
204+
filter and show each repository's status and sample size. Repository-alert
205+
emails also show the cached responsiveness status, sample period, and
206+
contributing signals beside each repository. Weekly recommendations display the
207+
same quality score used to rank results in the portal. Repository alerts remain
208+
newest-issue notifications and do not assign portal search-quality scores.
189209

190210
Authenticated users can also request their own digest immediately from the
191211
saved-search card. The manual route uses the same delivery pipeline and six-day

‎doc/setup.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Run the SQL migrations in filename order against the Turso database:
4949
7. `db/migrations/0007_admin.sql`
5050
8. `db/migrations/0008_opportunity.sql`
5151
9. `db/migrations/0009_experience_scope_filters.sql`
52+
10. `db/migrations/0010_maintainer_responsiveness.sql`
5253

5354
The first migration creates Better Auth's user, session, account, and verification tables. The second creates user-owned saved searches. Migration files intentionally contain structure only—never credentials or production data.
5455

@@ -70,6 +71,8 @@ deduplicated opportunity record for each issue a signed-in user saves or opens;
7071
it does not store the user's GitHub contribution history.
7172
The ninth adds experience, contribution-type, and scope preferences to cloud
7273
saved searches. Existing records default to unrestricted filters.
74+
The tenth adds the optional maintainer-responsiveness preference to cloud saved
75+
searches. Existing records default to unrestricted responsiveness.
7376

7477
## GitHub OAuth
7578

‎src/app/api/saved-searches/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ async function listSavedSearches(userId: string): Promise<SavedSearch[]> {
4242
experience: row.experience,
4343
contributionType: row.contributionType,
4444
scope: row.scope,
45+
responsiveness: row.responsiveness,
4546
createdAt: row.createdAt.toISOString(),
4647
}));
4748
}

‎src/app/api/search/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export async function GET(request: Request) {
3737
experience: searchParams.get("experience"),
3838
contributionType: searchParams.get("contributionType"),
3939
scope: searchParams.get("scope"),
40+
responsiveness: searchParams.get("responsiveness"),
4041
page,
4142
});
4243

‎src/features/issues/components/issue-card.tsx‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ import {
2222
TooltipTrigger,
2323
} from "@/components/ui/tooltip";
2424
import { compactNumber, relativeDate } from "@/features/issues/lib/format";
25+
import { getResponsivenessBoost } from "@/features/issues/lib/repository-responsiveness";
2526
import type {
2627
Issue,
2728
RepositoryHealth,
29+
RepositoryResponsiveness,
2830
} from "@/features/issues/types/search";
2931

3032
function getQualityBadgeClassName(qualityScore: number) {
@@ -39,6 +41,19 @@ function getQualityBadgeClassName(qualityScore: number) {
3941
return "bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/20";
4042
}
4143

44+
function getResponsivenessClassName(status: RepositoryResponsiveness["status"]) {
45+
switch (status) {
46+
case "responsive":
47+
return "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
48+
case "variable":
49+
return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-400";
50+
case "slow":
51+
return "border-rose-500/20 bg-rose-500/10 text-rose-700 dark:text-rose-400";
52+
default:
53+
return "";
54+
}
55+
}
56+
4257
function getRepositoryHealthClassName(label: RepositoryHealth["label"]) {
4358
switch (label) {
4459
case "active":
@@ -87,8 +102,50 @@ function RepositoryHealthTooltip({ issue }: Readonly<{ issue: Issue }>) {
87102
);
88103
}
89104

105+
function ResponsivenessTooltip({ issue }: Readonly<{ issue: Issue }>) {
106+
const responsiveness = issue.repositoryResponsiveness ?? {
107+
status: "unknown" as const,
108+
sampleDays: 90,
109+
sampleSize: 0,
110+
signals: ["Responsiveness sample unavailable"],
111+
};
112+
113+
return (
114+
<Tooltip>
115+
<TooltipTrigger asChild>
116+
<Badge
117+
variant="outline"
118+
className={getResponsivenessClassName(responsiveness.status)}
119+
tabIndex={0}
120+
title={responsiveness.signals.join(" · ")}
121+
>
122+
<MessageCircle className="h-3 w-3" />
123+
{responsiveness.status === "unknown"
124+
? "Response unknown"
125+
: `${responsiveness.status} maintainers`}
126+
</Badge>
127+
</TooltipTrigger>
128+
<TooltipContent className="block max-w-sm space-y-2">
129+
<p className="font-medium">Maintainer responsiveness</p>
130+
<p>
131+
Based on {responsiveness.sampleSize} recent contribution samples from
132+
the last {responsiveness.sampleDays} days.
133+
</p>
134+
<ul className="list-disc space-y-1 pl-4">
135+
{responsiveness.signals.map((signal) => (
136+
<li key={signal}>{signal}</li>
137+
))}
138+
</ul>
139+
</TooltipContent>
140+
</Tooltip>
141+
);
142+
}
143+
90144
function QualityTooltip({ issue }: Readonly<{ issue: Issue }>) {
91145
const healthBoost = Math.round((issue.repositoryHealth.score ?? 0) / 10);
146+
const responsivenessBoost = getResponsivenessBoost(
147+
issue.repositoryResponsiveness?.status ?? "unknown",
148+
);
92149

93150
return (
94151
<Tooltip>
@@ -113,7 +170,8 @@ function QualityTooltip({ issue }: Readonly<{ issue: Issue }>) {
113170
</p>
114171
<p>
115172
Repository health contributes {healthBoost} points. Scores are not
116-
percentages.
173+
percentages. Maintainer responsiveness contributes {responsivenessBoost}
174+
points.
117175
</p>
118176
{issue.enrichment?.repositoryMetadata === false ? (
119177
<p>Repository metadata was unavailable, so its ranking signals were omitted.</p>
@@ -157,6 +215,7 @@ function IssueBadges({ issue }: Readonly<{ issue: Issue }>) {
157215
return (
158216
<div className="flex flex-wrap items-center gap-2">
159217
<RepositoryHealthTooltip issue={issue} />
218+
<ResponsivenessTooltip issue={issue} />
160219
{issue.hacktoberfest ? (
161220
<Badge className="border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-400">
162221
{issue.hacktoberfestSource === "repo-topic"

‎src/features/issues/components/issue-finder.tsx‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
HACKTOBERFEST_OPTIONS,
6060
LABEL_OPTIONS,
6161
LINKED_PR_OPTIONS,
62+
RESPONSIVENESS_OPTIONS,
6263
SORT_OPTIONS,
6364
SCOPE_OPTIONS,
6465
TECH_EXAMPLES,
@@ -88,6 +89,7 @@ type SearchFilters = {
8889
experience: string;
8990
contributionType: string;
9091
scope: string;
92+
responsiveness: string;
9193
};
9294

9395
const DEFAULT_SEARCH_FILTERS: SearchFilters = {
@@ -99,6 +101,7 @@ const DEFAULT_SEARCH_FILTERS: SearchFilters = {
99101
experience: "any",
100102
contributionType: "any",
101103
scope: "any",
104+
responsiveness: "any",
102105
};
103106

104107
function getSupportedValue(
@@ -136,6 +139,11 @@ function getSearchFilters(search: string): SearchFilters | null {
136139
"any",
137140
),
138141
scope: getSupportedValue(params.get("scope"), SCOPE_OPTIONS, "any"),
142+
responsiveness: getSupportedValue(
143+
params.get("responsiveness"),
144+
RESPONSIVENESS_OPTIONS,
145+
"any",
146+
),
139147
};
140148
}
141149

@@ -149,6 +157,7 @@ function createSearchParams(filters: SearchFilters, page?: number) {
149157
experience: filters.experience,
150158
contributionType: filters.contributionType,
151159
scope: filters.scope,
160+
responsiveness: filters.responsiveness,
152161
...(page ? { page: String(page) } : {}),
153162
});
154163
}
@@ -642,6 +651,9 @@ export function IssueFinder() {
642651
DEFAULT_SEARCH_FILTERS.contributionType,
643652
);
644653
const [scope, setScope] = useState(DEFAULT_SEARCH_FILTERS.scope);
654+
const [responsiveness, setResponsiveness] = useState(
655+
DEFAULT_SEARCH_FILTERS.responsiveness,
656+
);
645657
const [data, setData] = useState<SearchResponse | null>(null);
646658
const [issues, setIssues] = useState<Issue[]>([]);
647659
const [page, setPage] = useState(1);
@@ -684,6 +696,7 @@ export function IssueFinder() {
684696
setExperience(DEFAULT_SEARCH_FILTERS.experience);
685697
setContributionType(DEFAULT_SEARCH_FILTERS.contributionType);
686698
setScope(DEFAULT_SEARCH_FILTERS.scope);
699+
setResponsiveness(DEFAULT_SEARCH_FILTERS.responsiveness);
687700
setData(null);
688701
setIssues([]);
689702
setError(null);
@@ -700,6 +713,7 @@ export function IssueFinder() {
700713
setExperience(linkedSearch.experience);
701714
setContributionType(linkedSearch.contributionType);
702715
setScope(linkedSearch.scope);
716+
setResponsiveness(linkedSearch.responsiveness);
703717
void searchIssues(undefined, linkedSearch, false);
704718
}
705719

@@ -847,6 +861,12 @@ export function IssueFinder() {
847861
() => SCOPE_OPTIONS.find((item) => item.value === scope) ?? SCOPE_OPTIONS[0],
848862
[scope],
849863
);
864+
const selectedResponsiveness = useMemo(
865+
() =>
866+
RESPONSIVENESS_OPTIONS.find((item) => item.value === responsiveness) ??
867+
RESPONSIVENESS_OPTIONS[0],
868+
[responsiveness],
869+
);
850870

851871
const hasMore = useMemo(() => {
852872
if (!data) return false;
@@ -876,6 +896,7 @@ export function IssueFinder() {
876896
experience,
877897
contributionType,
878898
scope,
899+
responsiveness,
879900
});
880901

881902
setSavedSearches((current) => [...current, savedSearch]);
@@ -929,6 +950,7 @@ export function IssueFinder() {
929950
setExperience(savedSearch.experience ?? "any");
930951
setContributionType(savedSearch.contributionType ?? "any");
931952
setScope(savedSearch.scope ?? "any");
953+
setResponsiveness(savedSearch.responsiveness ?? "any");
932954

933955
void searchIssues(undefined, {
934956
tech: savedSearch.tech,
@@ -939,6 +961,7 @@ export function IssueFinder() {
939961
experience: savedSearch.experience ?? "any",
940962
contributionType: savedSearch.contributionType ?? "any",
941963
scope: savedSearch.scope ?? "any",
964+
responsiveness: savedSearch.responsiveness ?? "any",
942965
});
943966
}
944967

@@ -1019,6 +1042,8 @@ export function IssueFinder() {
10191042
const searchContributionType =
10201043
searchOverride?.contributionType ?? contributionType;
10211044
const searchScope = searchOverride?.scope ?? scope;
1045+
const searchResponsiveness =
1046+
searchOverride?.responsiveness ?? responsiveness;
10221047

10231048
if (!searchTech.trim()) {
10241049
setError("Enter a technology to search.");
@@ -1041,6 +1066,7 @@ export function IssueFinder() {
10411066
experience: searchExperience,
10421067
contributionType: searchContributionType,
10431068
scope: searchScope,
1069+
responsiveness: searchResponsiveness,
10441070
});
10451071
const requestId = ++searchRequestId.current;
10461072

@@ -1099,6 +1125,7 @@ export function IssueFinder() {
10991125
experience,
11001126
contributionType,
11011127
scope,
1128+
responsiveness,
11021129
},
11031130
nextPage,
11041131
);
@@ -1307,6 +1334,23 @@ export function IssueFinder() {
13071334
</SelectContent>
13081335
</Select>
13091336

1337+
<Select value={responsiveness} onValueChange={setResponsiveness}>
1338+
<SelectTrigger
1339+
className="h-11 w-full"
1340+
size="lg"
1341+
aria-label="Maintainer responsiveness filter"
1342+
>
1343+
<SelectValue>{selectedResponsiveness.label}</SelectValue>
1344+
</SelectTrigger>
1345+
<SelectContent>
1346+
{RESPONSIVENESS_OPTIONS.map((option) => (
1347+
<SelectItem key={option.value} value={option.value}>
1348+
{option.label}
1349+
</SelectItem>
1350+
))}
1351+
</SelectContent>
1352+
</Select>
1353+
13101354
<Button
13111355
type="submit"
13121356
className="h-11 w-full gap-2 sm:col-span-2 lg:col-span-1"
@@ -1342,6 +1386,10 @@ export function IssueFinder() {
13421386
<Metric label="Experience" value={selectedExperience.label} />
13431387
<Metric label="Type" value={selectedContributionType.label} />
13441388
<Metric label="Scope" value={selectedScope.label} />
1389+
<Metric
1390+
label="Responsiveness"
1391+
value={selectedResponsiveness.label}
1392+
/>
13451393
<Metric
13461394
label="Ranked"
13471395
value={data ? compactNumber(data.candidateCount) : "-"}

‎src/features/issues/data/search-options.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,15 @@ export const SCOPE_OPTIONS = [
119119
export const SCOPE_FILTERS = new Set(
120120
SCOPE_OPTIONS.map((option) => option.value),
121121
);
122+
123+
export const RESPONSIVENESS_OPTIONS = [
124+
{ value: "any", label: "Any responsiveness" },
125+
{ value: "responsive", label: "Responsive maintainers" },
126+
{ value: "variable", label: "Variable responsiveness" },
127+
{ value: "slow", label: "Slow responsiveness" },
128+
{ value: "unknown", label: "Unknown responsiveness" },
129+
];
130+
131+
export const RESPONSIVENESS_FILTERS = new Set(
132+
RESPONSIVENESS_OPTIONS.map((option) => option.value),
133+
);

0 commit comments

Comments
 (0)