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
35 changes: 23 additions & 12 deletions src/github-prs.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,39 @@ const REQUEST_TIMEOUT_MS = 15000;
* 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.
* `opts` carries both test doubles and request options. The doubles (net client
* and timers) exist 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 none and get Electron's `net` and the global timers.
* `partition` + `useSessionCookies` let a caller ride a specific session's
* cookies — the Trac attachment fetch reuses the session that passed the
* proof-of-work challenge, so its `_hcc` cookie authorises the download; net
* does not send session cookies unless asked, hence the explicit flag.
*
* @param {string} url
* @param {Object} [headers]
* @param {Object} [deps]
* @param {string} url
* @param {Object} [headers]
* @param {Object} [opts]
* @param {string} [opts.partition]
* @param {boolean} [opts.useSessionCookies]
* @return {Promise<{status: number, headers: Object, body: string}>}
*/
function httpGet(url, headers = {}, deps = {}) {
function httpGet(url, headers = {}, opts = {}) {
// 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;
const netImpl = opts.net || require('electron').net;
const setTimeoutImpl = opts.setTimeout || setTimeout;
const clearTimeoutImpl = opts.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 });
const requestOptions = { method: 'GET', url };
if (opts.partition) {
requestOptions.partition = opts.partition;
requestOptions.useSessionCookies = opts.useSessionCookies !== false;
}
const request = netImpl.request(requestOptions);
request.setHeader('User-Agent', USER_AGENT);
for (const [key, value] of Object.entries(headers)) request.setHeader(key, value);

Expand Down
25 changes: 25 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const { ensureAutocrlf, readTrunkInfo, collectDirtyFiles, discardChanges, update
const { applyPatchToDir } = require('./patch-apply');
const { parsePatchFiles, planApply } = require('./patch-plan.cjs');
const { fetchLinkedPrs, fetchPrDiff } = require('./github-prs');
const { openAndScrape, fetchAttachment } = require('./trac-view');
const { openExternalUrl, ALLOWED_URL_SCHEMES } = require('./external-url');
const { deleteRegisteredSite } = require('./site-registry');
const { getStore } = require('./settings-store');
Expand Down Expand Up @@ -520,6 +521,30 @@ ipcMain.handle('git:fetch-pr-diff', async (_e, number) => {
}
});

// Trac attachments (#109/#11). Read on demand: opening a real Trac window can
// show the proof-of-work challenge, so it happens when the contributor asks,
// not on every ticket. See src/trac-view.js for the window and scrape.
ipcMain.handle('trac:list-attachments', async (_e, sitePath) => {
try {
const s = await getStore();
const ticketId = ((s.get('siteMeta') || {})[sitePath] || {}).tracTicket;
if (!ticketId) return { ok: true, status: 'no-ticket', items: [] };
const result = await openAndScrape(ticketId);
return { ok: true, ...result };
} catch (e) {
logError('trac:list-attachments', String(e && e.stack ? e.stack : e));
return { ok: false, status: 'error', items: [], error: String(e) };
}
});

ipcMain.handle('trac:fetch-attachment', async (_e, url) => {
try {
return await fetchAttachment(url);
} catch (e) {
return { ok: false, 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
4 changes: 4 additions & 0 deletions src/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ contextBridge.exposeInMainWorld('api', {
listTicketPatches: (sitePath) => ipcRenderer.invoke('git:list-ticket-patches', sitePath)
,
fetchPrDiff: (number) => ipcRenderer.invoke('git:fetch-pr-diff', number)
,
listTracAttachments: (sitePath) => ipcRenderer.invoke('trac:list-attachments', sitePath)
,
fetchTracAttachment: (url) => ipcRenderer.invoke('trac:fetch-attachment', url)
,
applyPatch: async (sitePath, options, onLog, onDone) => {
const { applyId } = await ipcRenderer.invoke('git:apply-patch', sitePath, options);
Expand Down
139 changes: 135 additions & 4 deletions src/renderer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,11 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
const [ticketPatches, setTicketPatches] = useState(null);
const [ticketPatchesLoading, setTicketPatchesLoading] = useState(false);
const [fetchingPr, setFetchingPr] = useState(null);
// Trac attachments (#11): loaded on demand, since opening a real Trac window
// can surface the proof-of-work challenge. null until the user asks.
const [tracAttachments, setTracAttachments] = useState(null);
const [tracAttachmentsLoading, setTracAttachmentsLoading] = useState(false);
const [fetchingAttachment, setFetchingAttachment] = useState(null);
// Trunk update path (#94)
const [trunkDate, setTrunkDate] = useState(null);
const [updateIncomplete, setUpdateIncomplete] = useState(false);
Expand Down Expand Up @@ -1654,17 +1659,33 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
useEffect(() => {
if (!tracTicket) {
setTicketPatches(null);
// Attachments are per-ticket and loaded on demand; a stale list from the
// previous ticket must not linger, and a scrape dropped by the generation
// bump below must not leave a stuck spinner.
setTracAttachments(null);
setTracAttachmentsLoading(false);
loadedTicketRef.current = null;
return;
}
if (!isActive || loadedTicketRef.current === tracTicket) return;
// Marked loaded before the fetch resolves, on purpose: a failed initial
// fetch is not retried on every re-activation (which could keep spending a
// rate-limited quota) — the Refresh button is the retry.
// A new ticket on the active site: drop any attachments the previous one
// loaded (and clear its loading flag, so a scrape dropped by the generation
// bump cannot leave a stuck spinner with no button to recover), then fetch
// its PRs. Marked loaded before the fetch resolves, on purpose: a failed
// initial fetch is not retried on every re-activation (which could keep
// spending a rate-limited quota) — Refresh is the retry.
setTracAttachments(null);
setTracAttachmentsLoading(false);
loadedTicketRef.current = tracTicket;
loadTicketPatches();
}, [tracTicket, isActive, loadTicketPatches]);

// A Trac scrape can run up to 90s. Bump a generation on every ticket change so
// a scrape that resolves after the ticket has moved on is dropped, rather than
// shown under the wrong ticket or clearing a newer request's loading flag.
const scrapeGenRef = useRef(0);
useEffect(() => { scrapeGenRef.current += 1; }, [tracTicket]);

// Fetches a PR's diff and drops into the same preview the file picker uses,
// so applying a PR and applying a downloaded patch are one path from here on.
const previewPr = async (pr) => {
Expand All @@ -1691,6 +1712,48 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
}
};

// Opens the real Trac ticket (the user clears the challenge once if shown),
// scrapes its attachment list, and shows it in-app. On demand, not on link.
const loadTracAttachments = async () => {
const gen = scrapeGenRef.current;
setApplyError('');
setTracAttachmentsLoading(true);
try {
const res = await window.api.listTracAttachments(sitePath);
if (gen !== scrapeGenRef.current) return; // ticket changed mid-scrape; drop the stale result
setTracAttachments(res && res.ok ? res : { status: 'error', items: [] });
} catch {
if (gen !== scrapeGenRef.current) return;
setTracAttachments({ status: 'error', items: [] });
} finally {
if (gen === scrapeGenRef.current) setTracAttachmentsLoading(false);
}
};

// Downloads an attachment through the challenge-passing session and hands it
// to the same preview the PR and file paths use.
const previewAttachment = async (att) => {
setApplyError('');
setFetchingAttachment(att.url);
try {
const res = await window.api.fetchTracAttachment(att.url);
if (!res || !res.ok) {
setApplyError(res?.error || `Could not download ${att.filename}.`);
return;
}
const preview = await window.api.previewPatch(sitePath, res.text);
if (!preview || !preview.ok) {
setApplyError(preview?.error || 'Could not read that patch.');
return;
}
setApplyPreview({ ...preview, label: att.filename, text: res.text });
} catch (e) {
setApplyError(String(e));
} finally {
setFetchingAttachment(null);
}
};

const runApply = ({ reverse = false } = {}) => {
const state = terminalStateRef.current;
if (state.running) {
Expand Down Expand Up @@ -2286,7 +2349,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
</Button>
</div>
<div style={{ marginTop: 4, fontSize: 12, color: '#6c6f72' }}>
See the work that already exists on this ticket before adding your own. Trac attachments are not listed yet — open the ticket for those.
See the work that already exists on this ticket before adding your own.
</div>

{ticketPatchesLoading && !ticketPatches ? (
Expand Down Expand Up @@ -2331,6 +2394,74 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
</div>
) : null}
</div>

<div style={{ marginTop: 16, borderTop: '1px solid #f0f0f1', paddingTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: '#1d2327' }}>Trac attachments</div>
{tracAttachments ? (
<Button variant="link" onClick={loadTracAttachments} disabled={tracAttachmentsLoading} style={{ fontSize: 12 }}>
{tracAttachmentsLoading ? 'Checking…' : 'Refresh'}
</Button>
) : null}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: '#6c6f72' }}>
Patch files are sometimes attached on Trac instead of a PR. Reading them opens the ticket so you can pass its human-check once.
</div>

{!tracAttachments && !tracAttachmentsLoading ? (
<div style={{ marginTop: 10 }}>
<Button variant="secondary" onClick={loadTracAttachments} disabled={isApplying || isUpdating || installing || building} style={{ padding: '8px 14px', borderRadius: 10 }}>
Show Trac attachments
</Button>
</div>
) : null}

{tracAttachmentsLoading ? (
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 8, color: '#3c434a', fontSize: 13 }}><Spinner /> Opening the ticket on Trac…</div>
) : null}

{tracAttachments && tracAttachments.status === 'no-attachments' ? (
<div style={{ marginTop: 10, fontSize: 13, color: '#6c6f72' }}>This ticket has no attachments.</div>
) : null}

{tracAttachments && (tracAttachments.status === 'challenge-timeout' || tracAttachments.status === 'error' || tracAttachments.status === 'closed') ? (
<div style={{ marginTop: 10, padding: '8px 10px', background: '#fcf9e8', border: '1px solid #dba617', borderRadius: 6, fontSize: 12, color: '#6e5406' }}>
{(() => {
if (tracAttachments.status === 'challenge-timeout') return 'Trac’s human-check did not complete in time. Try again, and click “I am human” if it appears.';
if (tracAttachments.status === 'closed') return 'The Trac window was closed before the attachments finished loading. Click “Show Trac attachments” to try again.';
return `Could not read the attachments from Trac.${tracAttachments.error ? ` (${tracAttachments.error})` : ''}`;
})()}
</div>
) : null}

{tracAttachments && tracAttachments.items && tracAttachments.items.length ? (
<div style={{ marginTop: 10, border: '1px solid #ddd', borderRadius: 6, overflow: 'hidden' }}>
{tracAttachments.items.map((att) => (
<div key={att.url} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderBottom: '1px solid #f0f0f1' }}>
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<div style={{ fontSize: 13, color: '#1d2327', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Button variant="link" onClick={() => window.api.openExternal(att.url)} style={{ fontSize: 13 }}>{att.filename}</Button>
</div>
<div style={{ fontSize: 11, color: '#6c6f72' }}>
{[att.author && `by ${att.author}`, att.dateText, att.sizeText].filter(Boolean).join(' · ')}
</div>
</div>
{att.applyable ? (
<Button
variant="secondary"
isBusy={fetchingAttachment === att.url}
disabled={isApplying || isUpdating || installing || building || Boolean(applyPreview) || fetchingAttachment !== null}
onClick={() => previewAttachment(att)}
style={{ flex: '0 0 auto' }}
>Apply…</Button>
) : (
<span style={{ flex: '0 0 auto', fontSize: 11, color: '#6c6f72' }}>not a patch</span>
)}
</div>
))}
</div>
) : null}
</div>
</>
) : (
<>
Expand Down
Loading
Loading