You can learn a lot about a developer tool from its public GitHub issues. Bug reports reveal where users struggle. Feature requests show what they wish the product could do. Pull requests offer clues about the changes contributors are proposing.
The problem is getting those discussions into a form you can actually analyze. Opening issues one by one works for a quick check. It falls apart when you're comparing several repositories, looking for repeated complaints, or building a developer intelligence product.
In this tutorial, you'll use an API to search GitHub issues and pull requests, export a clean dataset to CSV and JSON, and keep the original GitHub links. We'll use Spidra Search for discovery and compare it with GitHub's own API so you can choose the right approach.
Search GitHub issues with Spidra in one API call
Spidra Search supports a developer source for GitHub issues and pull requests. It accepts GitHub-style search qualifiers, rather than requiring you to run a generic web search filtered to github.com.
Install the Spidra Node.js SDK:
npm install spidraThen set your Spidra API key as an environment variable and run this JavaScript in a .mjs file:
import { SpidraClient } from 'spidra';
const client = new SpidraClient({ apiKey: process.env.SPIDRA_API_KEY });
const result = await client.search({
query: '"memory leak" is:issue repo:vercel/next.js',
sources: ['developer'],
limit: 10,
});
for (const item of result.data?.developer ?? []) {
console.log(item.title, item.url);
}That query looks for issues mentioning “memory leak” in the Next.js repository. sources: ['developer'] tells Spidra to search the GitHub-focused source. limit: 10 requests up to ten results from that source.
Spidra returns structured search hits, including fields such as titles, URLs, and descriptions. The presence of additional metadata depends on the result. A search hit is not necessarily a complete GitHub issue record, so we'll keep missing fields explicit in our export.
Filter GitHub issues and pull requests with qualifiers
GitHub's issue search syntax is particularly useful when your question is more specific than “find bugs.” These qualifiers work within the query string:
| Qualifier | Example | Use |
|---|---|---|
repo: | repo:microsoft/playwright | Restrict results to one repository |
is:issue | timeout is:issue | Return issues |
is:pr | timeout is:pr | Return pull requests |
is:open | is:issue is:open | Find open items |
is:merged | is:pr is:merged | Find merged PRs |
label: | label:bug | Restrict by GitHub label |
in:title | timeout in:title | Search titles |
created: | created:>=2026-01-01 | Find records created since a date |
updated: | updated:>=2026-01-01 | Find records updated since a date |
For example:
timeout is:issue is:open repo:microsoft/playwright updated:>=2026-01-01One subtle point: updated: does not mean the issue was opened recently. An old issue with a new comment can qualify. Use created: for new reports and updated: for recent activity. The GitHub search guide covers more qualifiers.
Choose two repositories and publish the exact queries
Let's investigate two common reliability topics: memory leaks in Next.js and timeouts in Playwright. We will search issues and PRs separately so the types stay visible in the dataset.
These are the exact four queries our exporter uses:
"memory leak" is:issue repo:vercel/next.js updated:>=2026-01-01
"memory leak" is:pr repo:vercel/next.js updated:>=2026-01-01
timeout is:issue repo:microsoft/playwright updated:>=2026-01-01
timeout is:pr repo:microsoft/playwright updated:>=2026-01-01Each pair uses the same topic, repository, and time window. The only change is is:issue versus is:pr.
This is a bounded search experiment, not a count of all defects in either project. Matching a phrase doesn't prove a record is a confirmed bug or that a PR resolves it. Search rankings and the number of pages retrieved also affect the sample.
Build a Node.js exporter for GitHub issues and PRs
The final dataset should answer four questions: which repository does a record belong to, is it an issue or PR, what is it about, and where can someone verify it?
We'll also keep available author and date fields without assuming that a generic search date is GitHub's created_at timestamp.
Create a file named github-issues-prs-basic.mjs and paste in the script below. It needs Node.js 18+ and the spidra package. The example fetches at most two pages of 20 hits for each query, retaining raw responses and reporting skipped or unrecognized URLs.
// Node.js 18+ | npm install spidra
// Run: SPIDRA_API_KEY=your_key node github-issues-prs-basic.mjs
import { SpidraClient } from 'spidra';
import { mkdir, writeFile } from 'node:fs/promises';
if (!process.env.SPIDRA_API_KEY) throw new Error('Set SPIDRA_API_KEY first.');
const spidra = new SpidraClient({ apiKey: process.env.SPIDRA_API_KEY });
const targets = [
{ repo: 'vercel/next.js', term: '"memory leak"' },
{ repo: 'microsoft/playwright', term: 'timeout' },
];
const found = new Map();
const raw = [];
const runs = [];
const skipped = [];
function githubIdentity(link) {
try {
const url = new URL(link);
if (url.hostname.toLowerCase() !== 'github.com') return null;
const m = url.pathname.match(/^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)\/?$/);
if (!m) return null;
return {
repository: `${m[1]}/${m[2]}`,
number: Number(m[4]),
kind: m[3] === 'pull' ? 'pr' : 'issue',
};
} catch { return null; }
}
function csvCell(value) {
let text = value == null ? '' : Array.isArray(value) ? value.join('; ') : String(value);
if (/^\s*[=+@-]/.test(text)) text = `'${text}`; // Prevent spreadsheet formula execution.
return `"${text.replaceAll('"', '""')}"`;
}
for (const { repo, term } of targets) {
for (const type of ['issue', 'pr']) {
const query = `${term} is:${type} repo:${repo} updated:>=2026-01-01`;
let nextToken;
let returned = 0;
let pages = 0;
for (let page = 1; page <= 2; page++) {
const response = await spidra.search({
query, sources: ['developer'], limit: 20,
...(nextToken ? { pageTokens: { developer: nextToken } } : {}),
});
raw.push({ query, page, collected_at: new Date().toISOString(), response });
const hits = response.data?.developer ?? [];
pages++;
returned += hits.length;
for (const hit of hits) {
const id = githubIdentity(hit.url);
if (!id || id.repository.toLowerCase() !== repo.toLowerCase()) {
skipped.push({ query, url: hit.url ?? null });
continue;
}
const key = `${id.repository.toLowerCase()}/${id.kind}/${id.number}`;
const previous = found.get(key);
if (previous) {
if (!previous.matched_queries.includes(query)) previous.matched_queries.push(query);
continue;
}
found.set(key, {
...id,
title: hit.title ?? null,
original_url: hit.url,
description: hit.description ?? null,
author: Array.isArray(hit.authors) ? hit.authors.join('; ') : hit.authors ?? null,
source_reported_date: hit.date ?? null,
state: hit.state ?? null,
labels: hit.labels ?? null,
created_at: hit.created_at ?? null,
updated_at: hit.updated_at ?? null,
matched_queries: [query],
});
}
nextToken = response.data?.nextPageTokens?.developer;
if (!nextToken || hits.length === 0) break;
}
runs.push({ query, returned_hits: returned, pages_fetched: pages });
}
}
const records = [...found.values()];
const columns = [
'repository', 'number', 'kind', 'title', 'original_url', 'description',
'author', 'source_reported_date', 'state', 'labels', 'created_at',
'updated_at', 'matched_queries',
];
const report = {
collected_at: new Date().toISOString(),
unique_records: records.length,
issues: records.filter(r => r.kind === 'issue').length,
pull_requests: records.filter(r => r.kind === 'pr').length,
missing_fields: Object.fromEntries(
['author', 'source_reported_date', 'state', 'labels', 'created_at', 'updated_at']
.map(field => [field, records.filter(r => r[field] == null).length])
),
skipped_results: skipped,
queries: runs,
};
const csv = [columns.join(','), ...records.map(r =>
columns.map(c => csvCell(r[c])).join(',')
)].join('\n') + '\n';
await mkdir('github-search-output', { recursive: true });
await Promise.all([
writeFile('github-search-output/raw-spidra.json', JSON.stringify(raw, null, 2)),
writeFile('github-search-output/github-activity.json', JSON.stringify(records, null, 2)),
writeFile('github-search-output/github-activity.csv', csv),
writeFile('github-search-output/report.json', JSON.stringify(report, null, 2)),
]);
console.log(report);Run the script
On macOS or Linux:
export SPIDRA_API_KEY="your_spidra_api_key"
node github-issues-prs-basic.mjsOn Windows PowerShell:
$env:SPIDRA_API_KEY="your_spidra_api_key"
node github-issues-prs-basic.mjsNever commit your API key to version control. Create an API key in the Spidra dashboard.
The script creates four files in github-search-output/:
| File | Contents |
|---|---|
github-activity.csv | Clean rows for Sheets, Excel, or further analysis |
github-activity.json | Structured records with repository, type, source link, and available metadata |
raw-spidra.json | The original responses, saved alongside their exact queries and collection times |
report.json | Retrieved counts, issue/PR mix, missing-field counts, and skipped results |
Three implementation details are worth noting. The script classifies records using the actual URL path (/issues/123 or /pull/123), not just the requested qualifier. It deduplicates records by repository, type, and number. And it escapes CSV content so contributor-controlled titles cannot execute as spreadsheet formulas when the file is opened.
Spidra returns a per-source nextPageTokens value when a continuation is available. The script passes that opaque token back; it never tries to invent one. For larger collections, increase the page cap and add retries and monitoring appropriate to your application. See the Spidra pagination documentation.
What did we find? Separate verified records from API measurements
Here is a small set of public GitHub pages verified independently, useful for understanding why the original links belong in the dataset:
| Type | GitHub record | What it illustrates |
|---|---|---|
| Issue | Next.js #91484 | A Pages Router memory-leak report with reproduction details |
| PR | Next.js #91581 | A proposed LRU cache change that references issue #91484 |
| PR | Next.js #95355 | Another proposed bounded-cache change referring to the same report |
| Issue | Playwright #42693 | A timeout report with a concrete MCP reproduction scenario |
This hand-selected illustration includes two issues and two PRs. It is not the issue-to-PR ratio returned by Spidra, and the listed pages should not be mistaken for the raw Spidra response. That ratio must be calculated from report.json after running the queries with an API key.
When you run the exporter, inspect these three things before drawing conclusions:
- Issue/PR mix. Read
issues,pull_requests, and the per-queryreturned_hitsinreport.json. Those are counts of retrieved matches, not necessarily all GitHub matches. - Missing metadata. Check
missing_fieldsforauthor,source_reported_date,state,labels,created_at, andupdated_at. The script retainsnullwhen the source doesn't provide a field. Missing does not mean false. - Raw results and original links. Open
raw-spidra.jsonto see the complete response objects and follow a feworiginal_urlvalues to the source discussions. If an unknown URL was skipped, investigate it before discarding that result permanently.
This distinction matters. An article should not announce that “Spidra found 50 bugs” without a recorded query, timestamp, page limit, and evidence that those matches really were bug reports. A search result might be a feature request, a duplicate report, or a PR referencing the issue rather than a separate defect.
When is GitHub's official Search API better?
If you only need GitHub issues and PRs, GitHub's own API may be simpler. Its REST Search API provides the /search/issues endpoint, with more GitHub-specific fields.
Here is the equivalent GitHub query. Use the same search terms and qualifiers to make any comparison meaningful:
curl --get 'https://api.github.com/search/issues' \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
--data-urlencode 'q="memory leak" is:issue repo:vercel/next.js updated:>=2026-01-01' \
--data-urlencode 'per_page=20'GitHub returns an items array along with total_count and incomplete_results. Search items can expose state, labels, comment counts, and creation/update timestamps. Pull requests are included in this endpoint and can be distinguished using the presence of the pull_request property.
| Requirement | Spidra developer search | GitHub API |
|---|---|---|
| Find public issues and PRs | Yes | Yes |
| Return source links and descriptions | Yes | Yes |
| Fetch full GitHub-specific lifecycle fields | Not guaranteed in search hits | Use the relevant GitHub endpoints |
| Search other source categories with one platform | Yes; web, news, research, and more | No; focused on GitHub |
| Work with private repositories and GitHub write operations | Not documented for this search flow | Supported with appropriate permissions |
Don't compare unlike numbers. GitHub's total_count describes matches reported for its query. Our report.json measures the records actually retrieved within the configured page cap. Also, GitHub search responses can be incomplete, and the REST API exposes no more than 1,000 search results per query. GitHub applies separate search rate limits, which should shape any high-volume collection plan.
GitHub expanded issue search in April 2026 with semantic and hybrid modes through search_type=semantic and search_type=hybrid. Those are features of GitHub's official API, not documented options of Spidra's developer source. For exact or reproducible phrase matching, stick to a clearly documented query style and record any search mode used.
If your product needs PR review details, lifecycle events, authoritative issue state, or private-repository access, start with GitHub's API. If it needs to discover relevant GitHub discussions alongside web pages, research papers, and news, Spidra can make the discovery step more consistent. A practical combination is to find links with Spidra, then enrich selected records using GitHub's API.
Use the dataset for developer intelligence, not a bug leaderboard
A clean export can support several useful workflows. A developer-tool founder can review feature requests in competing products. A research team can identify recurring complaints and investigate whether work has been proposed to address them. An engineering team can collect links for triage, clustering, or human review.
Keep the interpretation modest: an issue isn't automatically a confirmed defect, a PR isn't necessarily merged, and neither proves a fix has shipped. Larger projects also generate more activity. Use the dataset to locate questions worth investigating, not to rank engineering quality by raw counts.
Conclusion
The useful part of a GitHub issues API isn't just finding matching URLs. It's turning those matches into a traceable dataset: exact search queries, original links, consistent issue/PR classification, raw responses, and visible missing fields.
Spidra Search provides a GitHub-focused developer source and a path to broader multi-source discovery. GitHub's own API remains the straightforward choice when your requirements are entirely GitHub-specific or demand richer repository metadata.
Start with the exporter, inspect its report and source links, and build your analysis on the records you can verify. To explore the developer search source, see the Spidra Search documentation.