Skip to content
Merged
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
149 changes: 149 additions & 0 deletions src/github-prs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
'use strict';

/**
* Finding the pull requests linked to a Trac ticket, and fetching one's diff
* (issue #11 / #109).
*
* All GitHub access goes through the documented REST API, not the web `.diff`
* route: `github.com/…/pull/N.diff` and `patch-diff.githubusercontent.com`
* both return 422 to unauthenticated clients now (verified 2026-08-06), so the
* only reliable unauthenticated path is `repos/…/pulls/N` with the diff media
* type. The cost is the shared 60-requests-per-hour limit, which is why the
* caller caches and why classifyHttpFailure separates a spent limit from an
* empty ticket.
*
* Requests use Electron's `net` rather than a new HTTP dependency: it rides the
* Chromium network stack, so it honours the same proxy and TLS configuration
* the rest of the app already relies on, and adds nothing to install.
*/

const { parseLinkedPrs, classifyHttpFailure } = require('./patch-sources.cjs');

const REPO = 'WordPress/wordpress-develop';
// GitHub rejects API requests with no User-Agent; an identifying one is also
// the honest thing to send.
const USER_AGENT = 'WordPress-Contributor-Toolkit (+https://github.com/WordPress/experimental-wp-dev-env)';
const REQUEST_TIMEOUT_MS = 15000;

/**
* A single GET over Electron net. Never rejects on an HTTP status — the status
* is data the caller classifies — only on a transport failure or timeout.
* Modelled on the never-reject readiness probe in main.js.
*
* The `deps` seam (net client and timers) exists only so the response,
* transport-error, timeout, and settle-once paths can be exercised without the
* network or a real 15s wait; production callers pass nothing and get Electron's
* `net` and the global timers.
*
* @param {string} url
* @param {Object} [headers]
* @param {Object} [deps]
* @return {Promise<{status: number, headers: Object, body: string}>}
*/
function httpGet(url, headers = {}, deps = {}) {
// Required lazily, not at module load: requiring `electron` outside Electron
// resolves the binary and can spawn its installer on a cold checkout, and the
// standalone tests inject their own client and must never reach it.
const netImpl = deps.net || require('electron').net;
const setTimeoutImpl = deps.setTimeout || setTimeout;
const clearTimeoutImpl = deps.clearTimeout || clearTimeout;
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn, arg) => { if (!settled) { settled = true; fn(arg); } };

const request = netImpl.request({ method: 'GET', url });
request.setHeader('User-Agent', USER_AGENT);
for (const [key, value] of Object.entries(headers)) request.setHeader(key, value);

const timer = setTimeoutImpl(() => {
try { request.abort(); } catch {}
finish(reject, new Error(`Timed out after ${REQUEST_TIMEOUT_MS}ms`));
}, REQUEST_TIMEOUT_MS);

request.on('response', (response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
clearTimeoutImpl(timer);
const lowerHeaders = {};
for (const [key, value] of Object.entries(response.headers || {})) {
lowerHeaders[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
}
finish(resolve, { status: response.statusCode, headers: lowerHeaders, body: Buffer.concat(chunks).toString('utf8') });
});
response.on('error', (e) => { clearTimeoutImpl(timer); finish(reject, e); });
});
request.on('error', (e) => { clearTimeoutImpl(timer); finish(reject, e); });
request.end();
});
}

/**
* The pull requests that cite a ticket, newest first.
*
* @param {number|string} ticketId
* @param {Object} [deps]
* @return {Promise<{status: 'ok'|'rate-limited'|'error'|'offline', items: Array, error?: string}>}
*/
async function fetchLinkedPrs(ticketId, deps = {}) {
const get = deps.httpGet || httpGet;
const id = String(ticketId).replace(/[^0-9]/g, '');
if (!id) return { status: 'error', items: [], error: 'No ticket number' };

// 100 is GitHub's per-page maximum. One request covers any realistic ticket;
// paginating would multiply requests against the shared unauthenticated quota
// this whole feature is careful with, so instead a result that does not fit in
// one page is treated as incomplete below.
const query = encodeURIComponent(`repo:${REPO} is:pr ${id}`);
const url = `https://api.github.com/search/issues?q=${query}&per_page=100`;

let res;
try {
res = await get(url, { Accept: 'application/vnd.github+json' });
} catch (e) {
// A transport failure is offline, not empty: the contributor may simply
// have no network, which the panel should say rather than "no patches".
return { status: 'offline', items: [], error: String(e && e.message ? e.message : e) };
}

if (res.status !== 200) {
return { status: classifyHttpFailure(res.status, res.headers), items: [], error: `GitHub returned ${res.status}` };
}

let json;
try { json = JSON.parse(res.body); } catch { return { status: 'error', items: [], error: 'Unreadable response from GitHub' }; }

// A truncated search must not be cached as the complete list: the linked PR
// could be one we did not receive, and "no patches" shown as final is the
// exact failure this feature guards against. Fall back to the cache instead.
const returned = Array.isArray(json.items) ? json.items.length : 0;
if (json.incomplete_results === true || (typeof json.total_count === 'number' && json.total_count > returned)) {
return { status: 'error', items: [], error: 'Too many results to list reliably' };
}

return { status: 'ok', items: parseLinkedPrs(json, id) };
}

/**
* The unified diff for one pull request.
*
* @param {number} number
* @return {Promise<{ok: true, text: string}|{ok: false, status: string, error: string}>}
*/
async function fetchPrDiff(number) {
const n = String(number).replace(/[^0-9]/g, '');
if (!n) return { ok: false, status: 'error', error: 'No pull request number' };

let res;
try {
res = await httpGet(`https://api.github.com/repos/${REPO}/pulls/${n}`, { Accept: 'application/vnd.github.v3.diff' });
} catch (e) {
return { ok: false, status: 'offline', error: String(e && e.message ? e.message : e) };
}
if (res.status !== 200) {
return { ok: false, status: classifyHttpFailure(res.status, res.headers), error: `GitHub returned ${res.status}` };
}
return { ok: true, text: res.body };
}

module.exports = { fetchLinkedPrs, fetchPrDiff, httpGet };
42 changes: 42 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const { normalizeEol } = require('./git-update.cjs');
const { ensureAutocrlf, readTrunkInfo, collectDirtyFiles, discardChanges, updateToLatestTrunk } = require('./trunk-update');
const { applyPatchToDir } = require('./patch-apply');
const { parsePatchFiles, planApply } = require('./patch-plan.cjs');
const { fetchLinkedPrs, fetchPrDiff } = require('./github-prs');
const { openExternalUrl, ALLOWED_URL_SCHEMES } = require('./external-url');
const { deleteRegisteredSite } = require('./site-registry');
const { getStore } = require('./settings-store');
Expand Down Expand Up @@ -478,6 +479,47 @@ ipcMain.handle('git:update-trunk', async (event, sitePath) => {
return { updateId };
});

// --- Discovering the patches on a ticket (#109/#11) --- linked PRs come from
// GitHub; the network code is in src/github-prs.js, these handlers add the
// cache and IPC. A last-known-good copy per ticket, in electron-store, is what
// lets a rate-limited or offline lookup still show the work that exists.
const patchCacheKey = (ticketId) => `ticketPatches:${ticketId}`;

ipcMain.handle('git:list-ticket-patches', async (_e, sitePath) => {
try {
const s = await getStore();
const meta = (s.get('siteMeta') || {})[sitePath] || {};
const ticketId = meta.tracTicket;
if (!ticketId) return { ok: true, ticket: null, prs: { status: 'no-ticket', items: [] } };

const result = await fetchLinkedPrs(ticketId);
if (result.status === 'ok') {
s.set(patchCacheKey(ticketId), { checkedAt: new Date().toISOString(), items: result.items });
return { ok: true, ticket: ticketId, prs: { status: 'ok', items: result.items } };
}

// Could not read GitHub. Fall back to whatever was last seen for this
// ticket, labelled with when — a stale-but-shown list beats a short one
// presented as complete.
const cached = s.get(patchCacheKey(ticketId)) || null;
return {
ok: true,
ticket: ticketId,
prs: { status: result.status, items: cached ? cached.items : [], cachedAt: cached ? cached.checkedAt : null, error: result.error }
};
} catch (e) {
return { ok: false, error: String(e) };
}
});

ipcMain.handle('git:fetch-pr-diff', async (_e, number) => {
try {
return await fetchPrDiff(number);
} catch (e) {
return { ok: false, status: 'error', error: String(e) };
}
});

// --- Applying someone else's patch (#11) --- the diff mechanics live in
// src/patch-apply.js; these handlers add IPC plumbing and electron-store writes.

Expand Down
99 changes: 99 additions & 0 deletions src/patch-sources.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use strict';

/**
* Turning a GitHub search response into the pull requests that actually belong
* to a Trac ticket (issue #109 / #11).
*
* On the busiest tickets the real work is a wordpress-develop PR, not a Trac
* attachment — the attachment list is empty precisely where activity is
* highest. Core's Trac↔GitHub convention is that a PR cites its ticket in the
* body ("Trac ticket: https://core.trac.wordpress.org/ticket/NNNNN"), so the
* search is: ask GitHub broadly for PRs mentioning the number, then verify
* narrowly, here, that each one cites this ticket's URL. GitHub's search
* tokeniser matches the bare number in comments and unrelated text, so the
* verification is what makes the list trustworthy rather than merely plausible.
*
* Kept pure and dependency-free so the verification and the failure
* classification — the parts that decide whether the UI shows work that exists
* — are unit tested without a network: the main process requires it, and so
* does `node --test` (same convention as git-update.cjs / patch-plan.cjs).
*/

const TICKET_HOST = 'core.trac.wordpress.org';

/**
* True when a PR body cites this exact ticket. The negative lookahead stops
* `/ticket/6582` from matching inside `/ticket/65820`, and the host is required
* so a bare "#65822" in prose does not count.
*
* @param {string} body
* @param {number|string} ticketId
* @return {boolean}
*/
function bodyCitesTicket(body, ticketId) {
if (typeof body !== 'string') return false;
const id = String(ticketId).replace(/[^0-9]/g, '');
if (!id) return false;
const re = new RegExp(`${TICKET_HOST.replace(/\./g, '\\.')}/ticket/${id}(?![0-9])`);
return re.test(body);
}

/**
* Reduces a GitHub `search/issues` response to the PRs that cite the ticket.
* Returns newest-first — for a moving target like a PR the freshest is the one
* a contributor most likely wants.
*
* @param {Object} searchJson
* @param {number|string} ticketId
* @return {Array<{number: number, title: string, state: string, updatedAt: string, url: string}>}
*/
function parseLinkedPrs(searchJson, ticketId) {
const items = searchJson && Array.isArray(searchJson.items) ? searchJson.items : [];
const seen = new Set();
const prs = [];
for (const item of items) {
// `search/issues` returns issues and PRs together; only PRs carry
// `pull_request`.
if (!item || !item.pull_request) continue;
if (!bodyCitesTicket(item.body, ticketId)) continue;
if (seen.has(item.number)) continue;
seen.add(item.number);
prs.push({
number: item.number,
title: typeof item.title === 'string' ? item.title : '',
state: item.state === 'closed' ? 'closed' : 'open',
updatedAt: item.updated_at || item.created_at || '',
url: item.html_url || `https://github.com/WordPress/wordpress-develop/pull/${item.number}`
});
}
prs.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
return prs;
}

/**
* Classifies a non-2xx GitHub response so the UI can tell "nothing on this
* ticket" apart from "we could not read it". A rate-limited answer is not an
* empty ticket: on a shared Contributor-Day IP the unauthenticated 60/hour is
* spent quickly, and a short list shown as complete is the exact failure this
* feature exists to prevent.
*
* @param {number} status
* @param {Object} [headers] Lower-cased header map.
* @return {'rate-limited'|'error'}
*/
function classifyHttpFailure(status, headers = {}) {
const remaining = headers['x-ratelimit-remaining'];
if (status === 429) return 'rate-limited';
if ((status === 403 || status === 401) && String(remaining) === '0') return 'rate-limited';
// GitHub's secondary (abuse) limit is a 403 with a Retry-After header while
// the primary quota is not yet spent — the burst case on a shared IP.
if (status === 403 && headers['retry-after'] !== undefined && headers['retry-after'] !== null) return 'rate-limited';
return 'error';
}

module.exports = {
TICKET_HOST,
bodyCitesTicket,
parseLinkedPrs,
classifyHttpFailure
};
4 changes: 4 additions & 0 deletions src/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ contextBridge.exposeInMainWorld('api', {
choosePatchFile: () => ipcRenderer.invoke('dialog:choose-patch-file')
,
previewPatch: (sitePath, patchText) => ipcRenderer.invoke('git:preview-patch', sitePath, patchText)
,
listTicketPatches: (sitePath) => ipcRenderer.invoke('git:list-ticket-patches', sitePath)
,
fetchPrDiff: (number) => ipcRenderer.invoke('git:fetch-pr-diff', number)
,
applyPatch: async (sitePath, options, onLog, onDone) => {
const { applyId } = await ipcRenderer.invoke('git:apply-patch', sitePath, options);
Expand Down
Loading
Loading