Skip to content

Commit 33bab2c

Browse files
committed
feat: add contribution readiness assistant
Signed-off-by: Arnab Nandy <arnab_nandy7@yahoo.com>
1 parent a1faae8 commit 33bab2c

23 files changed

Lines changed: 838 additions & 57 deletions

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
- Filters by explicit experience, contribution-type, and small-scope signals
1010
- Scores repository health from maintenance and contributor-activity signals
1111
- Summarizes maintainer responsiveness from bounded recent contribution samples
12+
- Classifies contribution readiness from cached community documentation and activity signals
1213
- Sorts and ranks results using issue quality and repository health
1314
- Keeps successful searches in shareable, browser-navigable URLs
1415
- Identifies when optional GitHub enrichment is incomplete
16+
- Links repository contribution documentation from expandable issue checklists
1517
- Surfaces trending opportunities by recent activity, discussion, stars, and repository health
1618
- Supports reusable saved searches without requiring an account
1719
- Adds GitHub sign-in for cloud-backed saved searches that survive cleared browser storage
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "saved_search" ADD COLUMN "readiness" text DEFAULT 'any' NOT NULL;

‎doc/architecture.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ 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+
Contribution readiness combines the existing repository health, maintainer
74+
responsiveness, assignment, and discussion signals with GitHub's community
75+
profile. Community-profile requests are limited to 12 repositories per search
76+
and cached for six hours. The profile supplies README, contributing guide,
77+
license, code-of-conduct, issue-template, and pull-request-template links.
78+
Issue cards show an explainable readiness status and expandable checklist.
79+
Unavailable profiles produce `Unknown` rather than a zero score; inactive,
80+
claimed, and poorly documented opportunities remain distinguishable from issues
81+
that are ready to start or should be discussed with maintainers first.
82+
7383
Maintainer responsiveness remains separate from repository health. For at most 12
7484
candidate repositories per search, a cached six-hour GitHub GraphQL request samples
7585
up to 20 recently updated issues and pull requests from the previous 90 days. The
@@ -100,6 +110,7 @@ Search filters currently include:
100110
- Documentation, tests, bug fix, or feature contribution type
101111
- All scopes or small scope
102112
- Any, responsive, variable, slow, or unknown maintainer responsiveness
113+
- Any readiness or Ready to start
103114

104115
Experience, contribution type, and scope classifications use explicit issue
105116
labels and structured issue-template fields. Results show the signals that
@@ -249,6 +260,7 @@ erDiagram
249260
text contribution_type
250261
text scope
251262
text responsiveness
263+
text readiness
252264
}
253265
254266
OPPORTUNITY {
@@ -370,6 +382,8 @@ contributing signals beside each repository. Weekly recommendations display the
370382
same quality and repository-health scores used to rank results in the portal.
371383
Repository alerts enrich their newest issues with repository health and the same
372384
quality formula before adding the repository responsiveness boost.
385+
Saved-search recommendations and weekly digest searches retain the selected
386+
contribution-readiness filter, and weekly issue summaries include readiness.
373387
Saved-search recommendations, repository alerts, repository metadata, and
374388
post-delivery persistence run concurrently where independent so SMTP delivery is
375389
not delayed by avoidable serial network or database round trips.

‎doc/contributing.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ The test suite uses Vitest. Coverage thresholds are configured in `vitest.config
3535

3636
## Database changes
3737

38-
Database changes must update both the Drizzle schema and a new ordered SQL migration. Migrations may be public because they describe structure, not credentials. Never include tokens, secrets, or production records in them.
38+
Database changes must update the Drizzle schema, architecture documentation,
39+
and a new ordered SQL migration. Migrations may be public because they describe
40+
structure, not credentials. Never include tokens, secrets, or production records
41+
in them.
3942

4043
## Testing guidance
4144

‎doc/setup.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Run the SQL migrations in filename order against the Turso database:
5252
10. `db/migrations/0010_maintainer_responsiveness.sql`
5353
11. `db/migrations/0011_issue_feedback.sql`
5454
12. `db/migrations/0012_opportunity_workflow.sql`
55+
13. `db/migrations/0013_contribution_readiness.sql`
5556

5657
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.
5758

@@ -78,6 +79,8 @@ searches. Existing records default to unrestricted responsiveness.
7879
The eleventh stores dismissed recommendation feedback and repositories hidden
7980
by a user. The twelfth adds private state, note, follow-up date, and workflow
8081
activity fields to saved opportunities; existing opportunities begin in Saved.
82+
The thirteenth adds the contribution-readiness preference to cloud saved searches;
83+
existing records continue to include every readiness status.
8184

8285
## GitHub OAuth
8386

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

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

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

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

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,43 @@ import {
3030
import { compactNumber, relativeDate } from "@/features/issues/lib/format";
3131
import { getResponsivenessBoost } from "@/features/issues/lib/repository-responsiveness";
3232
import type {
33+
ContributionReadinessStatus,
3334
Issue,
3435
RepositoryHealth,
3536
RepositoryResponsiveness,
3637
} from "@/features/issues/types/search";
3738

39+
const READINESS_LABELS: Record<ContributionReadinessStatus, string> = {
40+
ready: "Ready to start",
41+
ask: "Ask before starting",
42+
claimed: "Possibly claimed",
43+
poorlyDocumented: "Poorly documented",
44+
inactive: "Repository inactive",
45+
unknown: "Readiness unknown",
46+
};
47+
48+
const DOCUMENTATION_LABELS = {
49+
readme: "README",
50+
contributing: "Contributing guide",
51+
license: "License",
52+
codeOfConduct: "Code of conduct",
53+
issueTemplate: "Issue template",
54+
pullRequestTemplate: "Pull-request template",
55+
} as const;
56+
57+
function getReadinessClassName(status: ContributionReadinessStatus) {
58+
if (status === "ready") {
59+
return "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
60+
}
61+
if (status === "ask" || status === "poorlyDocumented" || status === "claimed") {
62+
return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-400";
63+
}
64+
if (status === "inactive") {
65+
return "border-rose-500/20 bg-rose-500/10 text-rose-700 dark:text-rose-400";
66+
}
67+
return "";
68+
}
69+
3870
function getQualityBadgeClassName(qualityScore: number) {
3971
if (qualityScore >= 70) {
4072
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20";
@@ -220,6 +252,14 @@ function IssueBadges({ issue }: Readonly<{ issue: Issue }>) {
220252

221253
return (
222254
<div className="flex flex-wrap items-center gap-2">
255+
{issue.contributionReadiness ? (
256+
<Badge
257+
variant="outline"
258+
className={getReadinessClassName(issue.contributionReadiness.status)}
259+
>
260+
{READINESS_LABELS[issue.contributionReadiness.status]}
261+
</Badge>
262+
) : null}
223263
<RepositoryHealthTooltip issue={issue} />
224264
<ResponsivenessTooltip issue={issue} />
225265
{issue.hacktoberfest ? (
@@ -291,6 +331,37 @@ export function IssueCard({
291331
</CardTitle>
292332
</CardHeader>
293333
<CardContent className="space-y-4">
334+
{issue.contributionReadiness ? (
335+
<details className="rounded-lg border px-3 py-2 text-sm">
336+
<summary className="cursor-pointer font-medium">
337+
Contribution readiness checklist
338+
{issue.contributionReadiness.score === null
339+
? ""
340+
: ` · ${issue.contributionReadiness.score}/100`}
341+
</summary>
342+
<ul className="mt-2 list-disc space-y-1 pl-5 text-muted-foreground">
343+
{issue.contributionReadiness.signals.map((signal) => (
344+
<li key={signal}>{signal}</li>
345+
))}
346+
</ul>
347+
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1">
348+
{Object.entries(issue.contributionReadiness.documentation).map(
349+
([name, url]) =>
350+
url ? (
351+
<a
352+
key={name}
353+
href={url}
354+
target="_blank"
355+
rel="noreferrer"
356+
className="text-primary underline-offset-4 hover:underline"
357+
>
358+
{DOCUMENTATION_LABELS[name as keyof typeof DOCUMENTATION_LABELS]}
359+
</a>
360+
) : null,
361+
)}
362+
</div>
363+
</details>
364+
) : null}
294365
{issue.classification?.signals.length ? (
295366
<div className="flex flex-wrap gap-2" aria-label="Classification signals">
296367
{issue.classification.signals.map((signal) => (

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

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
HACKTOBERFEST_OPTIONS,
6262
LABEL_OPTIONS,
6363
LINKED_PR_OPTIONS,
64+
READINESS_OPTIONS,
6465
RESPONSIVENESS_OPTIONS,
6566
SORT_OPTIONS,
6667
SCOPE_OPTIONS,
@@ -92,6 +93,7 @@ type SearchFilters = {
9293
contributionType: string;
9394
scope: string;
9495
responsiveness: string;
96+
readiness: string;
9597
};
9698

9799
const DEFAULT_SEARCH_FILTERS: SearchFilters = {
@@ -104,6 +106,7 @@ const DEFAULT_SEARCH_FILTERS: SearchFilters = {
104106
contributionType: "any",
105107
scope: "any",
106108
responsiveness: "any",
109+
readiness: "any",
107110
};
108111

109112
function getSupportedValue(
@@ -146,6 +149,11 @@ function getSearchFilters(search: string): SearchFilters | null {
146149
RESPONSIVENESS_OPTIONS,
147150
"any",
148151
),
152+
readiness: getSupportedValue(
153+
params.get("readiness"),
154+
READINESS_OPTIONS,
155+
"any",
156+
),
149157
};
150158
}
151159

@@ -160,6 +168,7 @@ function createSearchParams(filters: SearchFilters, page?: number) {
160168
contributionType: filters.contributionType,
161169
scope: filters.scope,
162170
responsiveness: filters.responsiveness,
171+
readiness: filters.readiness,
163172
...(page ? { page: String(page) } : {}),
164173
});
165174
}
@@ -173,6 +182,14 @@ function leastAvailable(
173182
: second;
174183
}
175184

185+
function leastAvailableOptional(
186+
first?: EnrichmentAvailability,
187+
second?: EnrichmentAvailability,
188+
) {
189+
if (!first || !second) return first ?? second;
190+
return leastAvailable(first, second);
191+
}
192+
176193
const ENRICHMENT_PRIORITY: Record<EnrichmentAvailability, number> = {
177194
complete: 0,
178195
partial: 1,
@@ -198,6 +215,10 @@ function mergeEnrichment(
198215
current.linkedPullRequests,
199216
next.linkedPullRequests,
200217
),
218+
communityProfile: leastAvailableOptional(
219+
current.communityProfile,
220+
next.communityProfile,
221+
),
201222
};
202223
}
203224

@@ -328,7 +349,11 @@ function EnrichmentNotice({ data }: Readonly<{ data: SearchResponse | null }>) {
328349
["repository metadata", data.enrichment.repositoryMetadata],
329350
["discussion analysis", data.enrichment.discussionAnalysis],
330351
["linked pull requests", data.enrichment.linkedPullRequests],
331-
].filter(([, availability]) => availability !== "complete");
352+
["community profiles", data.enrichment.communityProfile],
353+
].filter(
354+
([, availability]) =>
355+
availability !== undefined && availability !== "complete",
356+
);
332357

333358
if (unavailableSignals.length === 0) return null;
334359

@@ -732,6 +757,7 @@ export function IssueFinder() {
732757
const [responsiveness, setResponsiveness] = useState(
733758
DEFAULT_SEARCH_FILTERS.responsiveness,
734759
);
760+
const [readiness, setReadiness] = useState(DEFAULT_SEARCH_FILTERS.readiness);
735761
const [data, setData] = useState<SearchResponse | null>(null);
736762
const [issues, setIssues] = useState<Issue[]>([]);
737763
const [page, setPage] = useState(1);
@@ -775,6 +801,7 @@ export function IssueFinder() {
775801
setContributionType(DEFAULT_SEARCH_FILTERS.contributionType);
776802
setScope(DEFAULT_SEARCH_FILTERS.scope);
777803
setResponsiveness(DEFAULT_SEARCH_FILTERS.responsiveness);
804+
setReadiness(DEFAULT_SEARCH_FILTERS.readiness);
778805
setData(null);
779806
setIssues([]);
780807
setError(null);
@@ -792,6 +819,7 @@ export function IssueFinder() {
792819
setContributionType(linkedSearch.contributionType);
793820
setScope(linkedSearch.scope);
794821
setResponsiveness(linkedSearch.responsiveness);
822+
setReadiness(linkedSearch.readiness);
795823
void searchIssues(undefined, linkedSearch, false);
796824
}
797825

@@ -945,6 +973,10 @@ export function IssueFinder() {
945973
RESPONSIVENESS_OPTIONS[0],
946974
[responsiveness],
947975
);
976+
const selectedReadiness = useMemo(
977+
() => READINESS_OPTIONS.find((item) => item.value === readiness) ?? READINESS_OPTIONS[0],
978+
[readiness],
979+
);
948980

949981
const hasMore = useMemo(() => {
950982
if (!data) return false;
@@ -975,6 +1007,7 @@ export function IssueFinder() {
9751007
contributionType,
9761008
scope,
9771009
responsiveness,
1010+
readiness,
9781011
});
9791012

9801013
setSavedSearches((current) => [...current, savedSearch]);
@@ -1029,6 +1062,7 @@ export function IssueFinder() {
10291062
setContributionType(savedSearch.contributionType ?? "any");
10301063
setScope(savedSearch.scope ?? "any");
10311064
setResponsiveness(savedSearch.responsiveness ?? "any");
1065+
setReadiness(savedSearch.readiness ?? "any");
10321066

10331067
void searchIssues(undefined, {
10341068
tech: savedSearch.tech,
@@ -1040,6 +1074,7 @@ export function IssueFinder() {
10401074
contributionType: savedSearch.contributionType ?? "any",
10411075
scope: savedSearch.scope ?? "any",
10421076
responsiveness: savedSearch.responsiveness ?? "any",
1077+
readiness: savedSearch.readiness ?? "any",
10431078
});
10441079
}
10451080

@@ -1122,6 +1157,7 @@ export function IssueFinder() {
11221157
const searchScope = searchOverride?.scope ?? scope;
11231158
const searchResponsiveness =
11241159
searchOverride?.responsiveness ?? responsiveness;
1160+
const searchReadiness = searchOverride?.readiness ?? readiness;
11251161

11261162
if (!searchTech.trim()) {
11271163
setError("Enter a technology to search.");
@@ -1145,6 +1181,7 @@ export function IssueFinder() {
11451181
contributionType: searchContributionType,
11461182
scope: searchScope,
11471183
responsiveness: searchResponsiveness,
1184+
readiness: searchReadiness,
11481185
});
11491186
const requestId = ++searchRequestId.current;
11501187

@@ -1204,6 +1241,7 @@ export function IssueFinder() {
12041241
contributionType,
12051242
scope,
12061243
responsiveness,
1244+
readiness,
12071245
},
12081246
nextPage,
12091247
);
@@ -1429,6 +1467,23 @@ export function IssueFinder() {
14291467
</SelectContent>
14301468
</Select>
14311469

1470+
<Select value={readiness} onValueChange={setReadiness}>
1471+
<SelectTrigger
1472+
className="h-11 w-full"
1473+
size="lg"
1474+
aria-label="Contribution readiness filter"
1475+
>
1476+
<SelectValue>{selectedReadiness.label}</SelectValue>
1477+
</SelectTrigger>
1478+
<SelectContent>
1479+
{READINESS_OPTIONS.map((option) => (
1480+
<SelectItem key={option.value} value={option.value}>
1481+
{option.label}
1482+
</SelectItem>
1483+
))}
1484+
</SelectContent>
1485+
</Select>
1486+
14321487
<Button
14331488
type="submit"
14341489
className="h-11 w-full gap-2 sm:col-span-2 lg:col-span-1"

0 commit comments

Comments
 (0)