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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ Chromium Manifest V3 **browser extension** for **FOC TPMs and team members** wor

## Features

1. **Make FOC's project board "global" (i.e., support cross-org issues and PRs)** β€” Manage the board (add items, edit fields where the API allows) from GitHub issues and pull requests on configured repos, with a native-style sidebar and **autosave** for supported field types (single select, number, text, iteration).
1. **Make FOC’s project board "global" (i.e., support cross-org issues and PRs)** β€” Manage the board (add items, edit fields where the API allows) from GitHub issues and pull requests on configured repos, with a native-style sidebar and **autosave** for supported field types (single select, number, text, iteration).
2. **Global auto-expand for project panels** β€” Optional setting so GitHub’s right-hand **Project** panel **auto-expands** for issues and PRs (works across projects, not only FOC).
3. **OR filter for project boards** β€” Enter OR queries in the project board filter bar (e.g. `cycle:202605-2 biglep (-status:"πŸŽ‰ Done") OR (-last-updated:1days)`) to see merged results from multiple filter branches in a single view. The extension splits the query, fetches each branch separately, deduplicates, and renders the combined results through GitHub’s native UI. This is particularly useful during standups where you want to see all your active and recently completed work in a single view. Configure which boards support this in **Options β†’ OR filter for project boards** (defaults to all FilOzone projects).

### πŸŽ₯ 2026-03-29 Demo

Expand Down
21 changes: 21 additions & 0 deletions docs/canonical-test-urls.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,27 @@ Use this when validating **Options β†’ expand Project panel** (and related conte

**Expectation**: With auto-expand on, native **Projects** sections **expand** as implemented for this feature ([spec 003](../specs/003-auto-expand-panels/spec.md)); the **inline FOC** card from scenarios 3–6 still follows global-board / target-repo rules and is unrelated to this check.

## Project Board OR Filter (spec 007)

Use these URLs when verifying the **OR query filter** on project board views ([spec 007](../specs/007-project-board-or-filter/spec.md)).

| View | URL | Notes |
|------|-----|-------|
| Current by Status | [FilOzone/projects/14/views/20](https://github.com/orgs/FilOzone/projects/14/views/20) | Grouped by Status, filtered by cycle. Primary test view. |
| All | [FilOzone/projects/14/views/2](https://github.com/orgs/FilOzone/projects/14/views/2) | Large item set (~360 items), exercises pagination. |
| Recently Updated | [FilOzone/projects/14/views/33](https://github.com/orgs/FilOzone/projects/14/views/33) | Different sort/filter configuration. |

### Test queries

| Query | Expected |
|-------|----------|
| `cycle:202605-2 biglep (-status:"πŸŽ‰ Done") OR (-last-updated:1days)` | Merged results from both branches, no duplicates |
| `(-status:"πŸŽ‰ Done") OR (-last-updated:1days)` | Two branches with no shared prefix |
| `cycle:202605-2 -status:"πŸŽ‰ Done"` | Non-OR query β€” native pass-through, unchanged behavior |
| `((nested))` | Invalid OR β€” native pass-through, console warning |
| `(a) OR (b) trailing` | Invalid OR β€” native pass-through, console warning |
| `(a OR b)` | Invalid OR β€” native pass-through, console warning |

## Related docs

- [Global boards picker status](global-boards-picker-status.md) β€” picker vs sidebar scope.
Expand Down
11 changes: 9 additions & 2 deletions extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "FOC GH",
"version": "0.2.0",
"version": "0.3.0",
"description": "Utilities for FOC team members working in GitHub.",
"icons": {
"16": "icons/icon16.png",
Expand Down Expand Up @@ -36,11 +36,18 @@
],
"js": ["content.js"],
"run_at": "document_idle"
},
{
"matches": [
"https://github.com/orgs/*/projects/*/views/*"
],
"js": ["board-filter.js"],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": ["sidebar.css", "pr-expand-main-world.js"],
"resources": ["sidebar.css", "pr-expand-main-world.js", "board-data-injector.js"],
"matches": ["https://github.com/*"]
}
]
Expand Down
267 changes: 267 additions & 0 deletions extension/src/content/board-filter/board-data-injector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/**
* Main-world fetch interceptor for project board OR queries.
*
* Runs in the PAGE's main world (injected via <script src> from board-filter-main.ts).
* Monkey-patches window.fetch to intercept calls to /memexes/{id}/paginated_items.
*
* On every paginated_items fetch, reads the filter bar's current value and checks
* for OR syntax. If found, intercepts the call, runs multi-branch fetch + merge,
* and returns the merged response. If not, passes through unchanged.
*
* Strategy: clone the original intercepted URL and only replace the `q` parameter
* for each branch. This preserves all native params (sortedBy, groupedBy, sliceBy,
* fieldIds, layout, sumFields, hierarchy) exactly as the React app sends them.
*/

import type { GroupedItemsResponse, GroupItems } from './memex-api.js'
import { mergeResponses } from './result-merger.js'
import { parseORQuery } from './or-query-parser.js'

// Guard against double-injection (Turbo SPA navigation can re-run content scripts)
if ((window as unknown as Record<string, unknown>).__filozFetchInterceptorInstalled) {
// Already installed β€” skip
} else {
(window as unknown as Record<string, unknown>).__filozFetchInterceptorInstalled = true

const originalFetch = window.fetch.bind(window)

/** Track in-flight OR query so we can abort on supersession. */
let activeController: AbortController | null = null

/**
* Fetch a single page and return the parsed JSON.
*/
async function fetchJson(url: string, signal?: AbortSignal): Promise<GroupedItemsResponse> {
const resp = await originalFetch(url, {
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
signal,
})
if (!resp.ok) {
throw new Error(`paginated_items fetch failed: ${resp.status} ${resp.statusText}`)
}
return resp.json()
}

/**
* Clone a URL and replace the `q` parameter while removing pagination cursors.
*/
function cloneUrlWithQuery(originalUrl: URL, query: string): string {
const cloned = new URL(originalUrl.href)
cloned.searchParams.set('q', query)
// Remove pagination params β€” we want the first page for each branch
cloned.searchParams.delete('after')
// Remove group-scoped pagination (groupedBy[value]) β€” we want all groups
cloned.searchParams.delete('groupedBy[value]')
return cloned.href
}

/**
* Build a pagination URL for a specific group within a branch response.
*/
function buildPaginationUrl(originalUrl: URL, query: string, group: GroupItems, endCursor: string): string {
const cloned = new URL(originalUrl.href)
cloned.searchParams.set('q', query)
cloned.searchParams.set('after', endCursor)
// Scope pagination to the specific group
cloned.searchParams.set('groupedBy[value]', group.groupId)
return cloned.href
}

/**
* Fetch all items for a single branch query, following pagination cursors per group.
*/
async function fetchBranch(
originalUrl: URL,
query: string,
signal: AbortSignal,
): Promise<GroupedItemsResponse> {
const firstUrl = cloneUrlWithQuery(originalUrl, query)
console.log('[FilOz] Branch fetch:', query, 'β†’', firstUrl)
const first = await fetchJson(firstUrl, signal)

// Follow pagination within each group
const groupedItems: GroupItems[] = first.groupedItems.map((g) => ({
...g,
nodes: [...g.nodes],
}))

for (let idx = 0; idx < groupedItems.length; idx++) {
let current = groupedItems[idx]
while (current.pageInfo?.hasNextPage && current.pageInfo.endCursor) {
const nextUrl = buildPaginationUrl(originalUrl, query, current, current.pageInfo.endCursor)
const nextPage = await fetchJson(nextUrl, signal)
// When paginating with groupedBy[value], the API may return only the
// requested group (at index 0), not at the original group index.
// Find the matching group by groupId rather than assuming index alignment.
const nextGroup = nextPage.groupedItems?.find(
(g) => g.groupId === current.groupId,
) ?? nextPage.groupedItems?.[0]
if (!nextGroup) break
groupedItems[idx].nodes.push(...nextGroup.nodes)
current = nextGroup
}
groupedItems[idx].pageInfo = {
...groupedItems[idx].pageInfo,
hasNextPage: false,
}
}
Comment thread
BigLep marked this conversation as resolved.

return { ...first, groupedItems }
}

/**
* Execute multi-branch fetch and merge for an OR query.
*/
async function executeBranchFetches(
originalUrl: URL,
prefix: string,
branches: string[],
): Promise<GroupedItemsResponse> {
// Abort any previous in-flight OR query
activeController?.abort()
const controller = new AbortController()
activeController = controller

const branchQueries = branches.map((branch) =>
prefix ? `${prefix} ${branch}` : branch,
)

console.log('[FilOz] Fetching', branchQueries.length, 'branches:', branchQueries)

const results = await Promise.all(
branchQueries.map((query) => fetchBranch(originalUrl, query, controller.signal)),
)

activeController = null
const merged = mergeResponses(results)
console.log('[FilOz] Merged results:', merged.totalCount.value, 'items')
return merged
}

// Monkey-patch fetch
window.fetch = async function patchedFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
// Parse the URL from the request
let url: URL
try {
const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
url = new URL(rawUrl, window.location.origin)
} catch {
return originalFetch(input, init)
}

// Only intercept initial paginated_items requests (not pagination follow-ups)
const match = url.pathname.match(/\/memexes\/(\d+)\/paginated_items/)
if (!match) return originalFetch(input, init)

// Skip pagination requests (these are follow-ups we handle internally)
if (url.searchParams.has('after')) return originalFetch(input, init)

// Read the filter bar's current value to check for OR syntax
const filterInput = document.querySelector<HTMLInputElement>('input#filter-bar-component-input')
if (!filterInput) return originalFetch(input, init)

const filterText = filterInput.value
const parseResult = parseORQuery(filterText)

if (parseResult.kind !== 'or_query') {
if (parseResult.kind === 'invalid_or') {
console.warn('[FilOz] Invalid OR syntax, passing through:', parseResult.error.message)
}
return originalFetch(input, init)
}

const { prefix, branches } = parseResult.query

console.log('[FilOz] Intercepted paginated_items for OR query, memex', match[1])

try {
const merged = await executeBranchFetches(url, prefix, branches)

return new Response(JSON.stringify(merged), {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json' },
})
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
console.log('[FilOz] OR query fetch aborted (superseded)')
throw err
}
console.error('[FilOz] OR query fetch failed, falling through to native:', err)
return originalFetch(input, init)
}
} as typeof fetch

console.log('[FilOz] Fetch interceptor installed')

/**
* Handle initial page load with an OR query in the URL.
*
* On initial load, GitHub server-renders 0 results (it doesn't understand OR).
* Fix: wait for the filter bar, then append a space via execCommand. This
* creates a trusted InputEvent that React sees as a filter change. The new
* query (with trailing space) isn't in React's cache, so it fires a fresh
* paginated_items fetch β€” which our interceptor catches and returns merged
* data. The trailing space doesn't affect OR parsing (we trim).
*
* Important: do NOT remove the space afterward. React caches query results,
* and restoring the original text would return the cached 0-result response.
*/
function handleInitialOrQuery(): void {
const urlParams = new URLSearchParams(window.location.search)
const filterQuery = urlParams.get('filterQuery')
if (!filterQuery) return

const result = parseORQuery(filterQuery)
if (result.kind !== 'or_query') return

console.log('[FilOz] OR query in URL on initial load, will nudge filter bar')

function waitAndNudge(): void {
const input = document.querySelector<HTMLInputElement>('input#filter-bar-component-input')
if (input) {
nudge(input)
return
}
const observer = new MutationObserver(() => {
const el = document.querySelector<HTMLInputElement>('input#filter-bar-component-input')
if (!el) return
observer.disconnect()
nudge(el)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
setTimeout(() => observer.disconnect(), 15_000)
}

function nudge(input: HTMLInputElement): void {
// Wait for React to finish mounting and the filter to be interactive
setTimeout(() => {
input.focus()
const val = input.value
if (val.endsWith(' ')) {
// Trailing space exists β€” remove it by selecting it and deleting
console.log('[FilOz] Nudging filter: removing trailing space to trigger re-fetch')
input.setSelectionRange(val.length - 1, val.length)
document.execCommand('delete')
} else {
// No trailing space β€” add one
console.log('[FilOz] Nudging filter: appending space to trigger re-fetch')
input.setSelectionRange(val.length, val.length)
document.execCommand('insertText', false, ' ')
}
}, 500)
}

waitAndNudge()
}

handleInitialOrQuery()

} // end guard
52 changes: 52 additions & 0 deletions extension/src/content/board-filter/board-filter-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Board Filter content script entry point.
*
* Architecture: content script (this file) β†’ main-world script (board-data-injector.ts)
* β†’ fetch interception β†’ merge
*
* Runs at document_start to inject the main-world fetch interceptor BEFORE
* GitHub's React app initializes. This is critical so the interceptor can
* patch window.fetch before the React app makes its first API call.
*
* The config check (which boards are enabled) runs async but the injection
* must happen synchronously at startup to avoid missing the initial fetch.
* If the board doesn't match the config, the injector is a no-op (it only
* activates when OR syntax is detected in the filter bar).
*/

import { loadConfig, isOrFilterBoard } from '../../lib/project-config.js'

const LOG_PREFIX = '[FilOz:board-filter]'

/** Inject the main-world fetch interceptor immediately. */
function injectMainWorldScript(): void {
const id = 'filoz-board-data-injector'
if (document.getElementById(id)) return

const script = document.createElement('script')
script.id = id
script.src = chrome.runtime.getURL('board-data-injector.js')
;(document.head ?? document.documentElement).appendChild(script)
console.log(LOG_PREFIX, 'Main-world interceptor injected')
}

// Check the URL β€” if it looks like a project board, inject immediately.
// The config check runs async afterward; the injector is harmless on
// non-configured boards (only activates when OR syntax is in the filter bar).
const url = window.location.href
if (/github\.com\/orgs\/[^/]+\/projects\/\d+\/views\/\d+/.test(url)) {
console.log(LOG_PREFIX, 'Content script loaded (document_start)')
injectMainWorldScript()

// Async config check β€” if the board doesn't match, remove the injector
void loadConfig().then((cfg) => {
const baseUrl = url.replace(/\/views\/\d+.*$/, '')
if (!isOrFilterBoard(cfg, baseUrl)) {
console.log(LOG_PREFIX, 'OR filter not enabled for this board, disabling')
// Signal the injector to deactivate
document.dispatchEvent(new CustomEvent('filoz-or-filter-disabled'))
} else {
console.log(LOG_PREFIX, 'OR filter enabled for this board')
}
})
}
Loading
Loading