Skip to content

List and apply a ticket's Trac attachments - #139

Merged
juanmaguitar merged 3 commits into
juanmaguitar/list-ticket-patchesfrom
juanmaguitar/trac-attachments
Aug 7, 2026
Merged

List and apply a ticket's Trac attachments#139
juanmaguitar merged 3 commits into
juanmaguitar/list-ticket-patchesfrom
juanmaguitar/trac-attachments

Conversation

@juanmaguitar

Copy link
Copy Markdown
Collaborator

Part of #110. Refs #109, #11. Stacked on #136 (base is that branch), which stacks on #135#123. Review order: #123#135#136 → this. The whole flow merges once the stack is complete.

Why

PRs are only half of "the work already on a ticket". On many tickets — good-first-bugs especially — the patch a contributor wants to try is a .diff attached to the ticket, not a PR (verified live: ticket #37578 has three .diff attachments and one PR; before this, the app showed only the PR). This adds the attachment half, under the PR list, loaded on demand.

What this does

A "Show Trac attachments" button under the linked-PR list. Because Trac serves the attachment list only to a real browser (everything else hits the proof-of-work interstitial), clicking it opens the real ticket in an embedded window where the contributor clears the challenge once, the app scrapes the #attachments block, and the window closes — it is a means, not the UI. The attachments appear as a native in-app list (filename · author · date · size), each .diff/.patch with Apply…; non-patches (e.g. a .txt) are shown but marked "not a patch". Applying downloads the file through that same challenge-passing session and hands it to the existing preview → apply engine, so an attachment, a PR and a chosen file are one path from the preview onward.

On demand, not on link: opening a Trac window can surface the challenge, so it happens when the contributor asks, not for every ticket. The persistent session means the challenge is passed once, not per open.

Verified end to end against live Trac (#37578)

A deterministic Electron harness exercised the real trac-view.js:

  • the window passes the challenge unattended;
  • the parser reads the real markup — 4 attachments with authors and absolute timestamps, the .txt correctly marked not-a-patch;
  • the raw-attachment download is authorised by the session cookie — a real wp-admin/includes/dashboard.php diff (1701 bytes) comes back. This was the one load-bearing runtime assumption, now confirmed.

Also confirmed in the running app: the panel renders, the attachment list populates from the live scrape, and the PR apply→preview wiring works.

Security (the app's first remote, untrusted content — AGENTS.md)

  • The window: contextIsolation, no nodeIntegration, sandbox, a dedicated persist:trac partition, and no preload — the page cannot reach the app or Node; only the #attachments HTML crosses back, read from the main process via executeJavaScript.
  • Navigation pinned to core.trac.wordpress.org against both will-navigate and will-redirect (the latter catches 3xx / <meta refresh>).
  • The parser never emits an off-host URL, and fetchAttachment re-checks the host before sending the session cookie — so a poisoned ticket page cannot get an attacker link in front of the user or leak the cookie off-host.
  • The downloaded diff is untrusted → flows through Apply a patch from a file to the checkout, then rebuild #135's apply engine, which defends against path traversal.

Testing

  • test/trac-attachments.test.cjs — the pure parser: dedup, encoded names, off-host and cross-ticket rejection, absolute-date extraction, missing-metadata rows, empty input. The fixture matches the live markup (confirmed by the harness above).
  • npm test / npm run test:electron: 229/229 both runtimes. npm run lint: clean.
  • src/trac-view.js's window/net glue is untested, consistent with the other net client (github-prs.js) — no repo precedent for mocking BrowserWindow; the logic lives in the covered pure parser.

Self-review (per AGENTS.md)

Ran the review with the judgement pass on fresh context. It returned 2 [fix here] (both 🔵), both fixed before this PR:

  • Navigation lock covered only will-navigate → added will-redirect (3xx / meta-refresh could otherwise move the pinned window off-origin).
  • The link regex accepted an absolute off-host href (Apply was safe, but the filename rendered as an openExternal link) → the parser now rejects any non-Trac-host URL, with a test.

Reviewer verified clean: window config, setWindowOpenHandler, host re-check before the cookie fetch, poll-loop lifecycle with isDestroyed guards + destroy() in finally, additive electron-store (no migration), no new dependency.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds on-demand Trac attachment discovery and integrates patch attachments with the existing preview/apply workflow.

Changes:

  • Adds a secured embedded Trac scraper and session-backed attachment downloads.
  • Parses and displays attachment metadata with patch applicability controls.
  • Adds IPC wiring and parser/wiring tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/trac-view.js Implements the Trac window and downloads.
src/trac-attachments.cjs Parses attachment markup.
src/renderer/index.jsx Adds attachment listing and apply UI.
src/preload.js Exposes attachment IPC methods.
src/main.js Registers attachment IPC handlers.
src/github-prs.js Adds session-cookie request options.
test/trac-attachments.test.cjs Tests attachment parsing.
test/ipc-wiring.test.cjs Tests attachment IPC delegation.
Suppressed comments (1)

src/renderer/index.jsx:2401

  • [Architecture · 🔵 low · fix here] openAndScrape returns closed when the contributor closes the Trac window and includes details for error, but this branch neither handles closed nor shows those details. Closing the window therefore leaves a blank panel, while real failures become undiagnosable. Render cancellation explicitly and surface the returned error fallback.
              {tracAttachments && (tracAttachments.status === 'challenge-timeout' || tracAttachments.status === 'error') ? (
                <div style={{ marginTop: 10, padding: '8px 10px', background: '#fcf9e8', border: '1px solid #dba617', borderRadius: 6, fontSize: 12, color: '#6e5406' }}>
                  {tracAttachments.status === 'challenge-timeout'
                    ? 'Trac’s human-check did not complete in time. Try again, and click “I am human” if it appears.'
                    : 'Could not read the attachments from Trac.'}

Comment thread src/trac-view.js Outdated
Comment thread src/trac-attachments.cjs Outdated
Comment thread src/trac-view.js Outdated
Comment thread src/renderer/index.jsx Outdated
Comment thread src/trac-view.js
Comment on lines +83 to +85
pinToTrac(win.webContents);
// A persistent User-Agent that identifies the app, on this session only.
tracSession.setUserAgent(USER_AGENT);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed in f0f13d3, and deliberately left partial.

The security-critical part of this module was extracted into a pure helper, secureTracUrl(url) in src/trac-attachments.cjs, which is exported and directly unit-tested — including the cases that matter here: core.trac.wordpress.org.evil.com, userinfo (https://core.trac.wordpress.org@evil.com), same-host http:, and non-URL garbage. pinToTrac, parseOne and fetchAttachment all delegate to it, so the origin decision is no longer untested code.

What remains untested is the BrowserWindow lifecycle and network glue around it. Standing up an injected-Electron harness for that is a disproportionate amount of scaffolding for this PR, so it is going to a follow-up issue rather than blocking here. Leaving this thread open until that issue is filed.

@juanmaguitar
juanmaguitar force-pushed the juanmaguitar/trac-attachments branch from 844b2d5 to 8788e41 Compare August 7, 2026 07:58
Pull requests are only half of "the work already on a ticket". On many
tickets — good-first-bugs especially — the patch a contributor wants to try is
a .diff attached to the ticket, not a PR. This adds that half, under the PR
list, loaded on demand.

Trac serves the attachment list only to a real browser: everything else meets
the proof-of-work interstitial. So "Show Trac attachments" opens the real
ticket in an embedded window where the contributor clears the challenge once,
scrapes the #attachments block, and closes — the window is a means, not the
UI. Applying an attachment downloads it through that same challenge-passing
session and hands it to the existing preview → apply engine, so an attachment,
a PR and a chosen file are one path from the preview onward.

Verified end to end against the live ticket #37578: the window passes the
challenge unattended, the parser reads the real markup (four attachments, with
authors and absolute timestamps, the .txt correctly marked not-a-patch), and
the raw-attachment download is authorised by the session cookie (a real
dashboard.php diff comes back).

Shape and safeguards:

- The parser is a pure, dependency-free module (src/trac-attachments.cjs),
  regex over the #attachments HTML like core's grunt-patch-wordpress, unit
  tested with a fixture that matches the live markup. It never emits an
  off-host URL, so a poisoned ticket page cannot get an attacker link in front
  of the user.

- The window (src/trac-view.js) is the app's first remote, untrusted content,
  so it is locked down: contextIsolation, no nodeIntegration, sandbox, a
  dedicated persist:trac partition, and NO preload — the page cannot reach the
  app; only the #attachments HTML crosses back, read by the main process via
  executeJavaScript. Navigation is pinned to core.trac.wordpress.org against
  both will-navigate and will-redirect. The attachment fetch re-checks the host
  before sending the session cookie.

- Reading is on demand, not on link: opening a Trac window can surface the
  challenge, so it happens when the contributor asks, not for every ticket.
  The persistent session means the challenge is passed once, not per open.

httpGet (src/github-prs.js) gains partition/useSessionCookies so the attachment
fetch reuses the challenge session rather than duplicating the net helper.

Refs #109, #11, part of #110.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juanmaguitar
juanmaguitar force-pushed the juanmaguitar/trac-attachments branch from 8788e41 to 8d67477 Compare August 7, 2026 09:15
juanmaguitar and others added 2 commits August 7, 2026 11:25
 review)

The embedded Trac view carries a session cookie earned by clearing the
proof-of-work, so a same-host http downgrade could leak it on an untrusted
network. A new pure secureTracUrl(url) helper accepts only
https://core.trac.wordpress.org/… and returns the normalized href:

- parseOne drops any attachment link that is off-host OR plaintext (#2).
- pinToTrac blocks navigation/redirects that are not the secure origin (#1).
- fetchAttachment validates and sends the normalized href — the address that
  passed the check is the one fetched with the cookie (#3).

The helper is unit-tested, which also covers the security core of the
window-glue testing gap (#5); the rest of that glue is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- A scrape can run up to 90s; a generation bumped on every ticket change lets
  loadTracAttachments ignore a result (and its loading-flag cleanup) that
  resolves after the ticket moved on, so the old ticket's attachments can no
  longer appear under a new one (#4).
- The attachments panel now renders the 'closed' outcome (the user shut the
  Trac window) instead of a blank panel, and surfaces the error detail when
  present (suppressed finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@juanmaguitar
juanmaguitar merged commit 1182ab9 into trunk Aug 7, 2026
3 checks passed
juanmaguitar added a commit that referenced this pull request Aug 7, 2026
Part of #110. Refs #109, #11. **Stacked on #139** (base is that branch),
which stacks on #136#135#123. Review order: #123#135#136#139 → this.

## Why

A contributor arriving at a ticket wants to try the latest fix. The
panel listed PRs and Trac attachments as two separate sections, but
nothing said which one — across both — was the most recent. Usually it's
a PR (visible immediately); but sometimes the newest fix is a `.diff`
uploaded to Trac, and that was buried under the attachments button with
no signal.

## What this does

- The newest known patch carries a **"Latest" pill**, whether it's a PR
row or a Trac-attachment row.
- When the newest is an attachment, a note says so explicitly: *"The
most recent patch on this ticket is a file attachment, not a pull
request."*
- Because attachments load on demand, "latest" is judged across what's
loaded: **PRs alone until the contributor opens attachments** (the
normal case), then both. Opening attachments isn't forced on every
ticket — the verdict simply completes once they're looked at.

## How

The comparison is a pure module (`src/latest-patch.cjs`), unit tested. A
PR is dated by `updatedAt`, an attachment by its scraped upload time. A
`.txt` never competes; a relative-only date ("15 months ago") can't win.
The attachment timestamp is **anchored to UTC by hand** rather than
parsed in the machine's local zone, so two contributors in different
timezones see the same patch marked latest — a consistent answer is the
whole point.

## Testing

- `test/latest-patch.test.cjs` — PR-wins (normal),
attachment-wins-once-loaded (the case the feature exists for), `.txt`
excluded, relative-date can't win, attachments-only, empty→null, and the
UTC-determinism of the date parse (PM/AM and the 12-o'clock edge).
- `npm test` / `npm run test:electron`: **238/238** both runtimes. `npm
run lint`: clean.

## Self-review (per AGENTS.md)

Ran the review with the judgement pass on fresh context. It returned **0
`[fix here]` · 1 `[follow-up]`** — the follow-up was the timezone skew
(PR date is UTC ISO, attachment date was parsed in local time), which
two contributors could see differently. **Fixed before this PR** by
anchoring the attachment parse to UTC, with a determinism test. The
reviewer verified the key/equality logic (exactly one row marked, right
row), NaN/`.txt` exclusion, null-safety, and stale-state reset on ticket
switch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
juanmaguitar added a commit that referenced this pull request Aug 7, 2026
Part of #110. Refs #109, #11. **Stacked on #140** (base is that branch),
atop #139#136#135#123. From hands-on testing of the stack.

## Three fixes reported from testing

1. **The apply error persisted after Cancel.** Applying a PR that no
longer fits ("…has moved on since the patch was written") showed an
error; clicking Cancel dismissed the preview but left the error on
screen. Cancel now clears it too.
2. **The "Try someone else's patch" sub-copy contradicted itself during
a PR preview** — it said "Apply a `.diff`/`.patch` file…" while showing
"PR #4496 changes 1 file". The sub-copy is now hidden whenever a preview
or the apply chain is showing (the preview card speaks for itself), and
the idle copy names pull requests as well as files.
3. **The failure message ran two sentences together** ("…no longer
applies The checkout was not changed."). A period is inserted when the
reason doesn't already end in punctuation.

## One requested addition

**Apply a PR straight from a pasted URL or number**, without it having
to be linked to the ticket. A "Paste a pull request URL or number" input
+ "Apply PR" button in the panel's idle state.

`parsePrRef` (pure, `src/patch-sources.cjs`) accepts a wordpress-develop
PR URL or a bare number and rejects other repos, other hosts, issue URLs
and junk — including crafted `..` paths (URL normalisation collapses
them, then the repo/anchor check rejects). The number rides the
**existing** `previewPr → fetchPrDiff → preview → apply` flow, so a
pasted PR shares the same trust boundary as the linked-PR list:
`fetchPrDiff` hardcodes the repo and strips the number to digits, and
the diff flows through the apply engine's path-traversal defence.

## Testing

- `test/patch-sources.test.cjs` — `parsePrRef`: bare/`#`number, URL with
trailing `/files` and `#…`, scheme-less, wrong-repo rejection, and
issue/foreign-host/junk rejects.
- `npm test` / `npm run test:electron`: **242/242** both runtimes. `npm
run lint`: clean.

## Self-review (per AGENTS.md)

Judgement pass on fresh context: **0 findings** across the five
dimensions. It independently confirmed the `..`-traversal and
foreign-repo/host rejections, that the pasted-number path introduces no
new trust boundary (same `fetchPrDiff` + apply engine), that the
punctuation ternary can't throw (error is truthy-guarded), and that
hiding the sub-copy never strips the idle call-to-action.

Pending your visual confirm in the running app for the three UI fixes
and the paste-a-PR flow — these are your reported issues, verified here
by tests + review.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@juanmaguitar
juanmaguitar deleted the juanmaguitar/trac-attachments branch August 11, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants