Refresh GitHub issues after creation (#3018)
Implements the no-cache GitHub issue list refresh path described in docs/refresh-github-issues-after-create.md. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
bf51593706
commit
153fd863c6
|
|
@ -0,0 +1,138 @@
|
|||
# Refresh GitHub Issues After Create
|
||||
|
||||
## Problem
|
||||
|
||||
Issue https://github.com/stablyai/orca-internal/issues/101 reports that the GitHub issues list can stay stale after creating a new issue.
|
||||
|
||||
- `src/renderer/src/components/TaskPage.tsx:3891` bumps `taskRefreshNonce` after successful issue creation, intending to refetch the list.
|
||||
- `src/renderer/src/store/slices/github.ts:1644` honors `force` only for the renderer cache and in-flight dedupe.
|
||||
- `src/renderer/src/store/slices/github.ts:1666` calls `window.api.gh.listWorkItems` without telling main to bypass the GitHub CLI cache.
|
||||
- `src/main/github/client.ts:821` and `src/main/github/client.ts:846` use `gh api --cache 120s` for the recent issues and PR REST paths, so a forced renderer refresh can still receive a pre-create response for up to two minutes.
|
||||
- `src/main/runtime/rpc/methods/github.ts:10` and `src/main/runtime/rpc/methods/github.ts:283` do not accept or forward a cache-bypass flag for SSH/runtime clients.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The post-create flow forces only Orca's renderer-side work-item cache. It does not bypass the GitHub CLI REST cache used by the main-process recent work-item fetch, so the refreshed request can reuse stale `gh api --cache 120s` data.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replace normal list caching or reduce default cache TTLs.
|
||||
- Change query/search filtering behavior.
|
||||
- Change GitLab, Linear, project view, or PR detail caches.
|
||||
- Add polling after issue creation.
|
||||
- Add new UI controls or visible copy.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add an optional `noCache` flag to the renderer work-item fetch options and the GitHub work-items list contract:
|
||||
- `FetchOptions` in `src/renderer/src/store/slices/github.ts`;
|
||||
- preload API type and implementation for `gh.listWorkItems`;
|
||||
- IPC handler args for `gh:listWorkItems`;
|
||||
- web preload routing, which forwards `gh.listWorkItems` to `github.listWorkItems` for web/remote clients;
|
||||
- runtime RPC schema and handler for `github.listWorkItems`;
|
||||
- `OrcaRuntime.listRepoWorkItems`;
|
||||
- `listWorkItems` and the internal recent-list helper in `src/main/github/client.ts`.
|
||||
|
||||
2. Keep `force` and `noCache` separate. `force` means "bypass renderer cache and in-flight dedupe"; `noCache` means "bypass `gh api --cache`". In TaskPage, pass `{ force: forcedFetch || shouldProbeOnLanding, noCache: forcedFetch }` so nonce-triggered refreshes and preference invalidation bypass the GitHub CLI cache, while the one-time landing probe still behaves like today's background revalidation. Today `taskRefreshNonce` is shared by create, manual refresh, retry, filtering, preset changes, and PR merge refresh, so the implementation should either accept that whole nonce-triggered set as the no-cache scope or split create/manual refresh intent into a separate signal before narrowing it.
|
||||
|
||||
3. When `fetchWorkItems(..., { noCache: true })` calls `window.api.gh.listWorkItems`, pass `noCache: true`; otherwise omit it or pass `false`.
|
||||
|
||||
4. Track `noCache` alongside `force` in `inflightWorkItemsRequests`. A request with `noCache: true` must not dedupe onto an existing request with `noCache: false`, even if that existing request is forced; wait for the existing request to settle and issue a fresh no-cache request. This preserves the create path when it races a landing probe, which is `force: true` but intentionally cacheable.
|
||||
|
||||
5. In `listRecentWorkItems`, build REST args with `[]` when `noCache` is true and `['--cache', '120s']` otherwise. Apply this only to the REST `gh api` issue/PR list calls that currently use the cache. Keep fallback `gh issue list` / `gh pr list` and queried paths unchanged because they do not use this REST cache.
|
||||
|
||||
6. Preserve current force semantics:
|
||||
- non-forced loads keep using the 120-second CLI cache;
|
||||
- forced loads still wait out non-forced in-flight requests before issuing a fresh request;
|
||||
- no-cache loads also wait out cacheable in-flight requests before issuing a fresh request;
|
||||
- force continues to refresh all selected repos through the existing TaskPage effect.
|
||||
|
||||
7. Add regression coverage:
|
||||
- renderer store: `force + noCache` sends `noCache: true`; `force` without `noCache` and non-force calls omit it;
|
||||
- renderer store: `force + noCache` does not dedupe onto an in-flight `force` request that lacks `noCache`;
|
||||
- TaskPage or a focused equivalent: post-create/manual nonce path sets `noCache`, but landing probe does not;
|
||||
- desktop IPC: `gh:listWorkItems` forwards `noCache`;
|
||||
- web preload: `gh.listWorkItems` forwards `noCache` through the runtime route;
|
||||
- main GitHub client: `listWorkItems(..., { noCache: true })` omits `--cache 120s` on recent REST issue/PR calls;
|
||||
- runtime RPC: `github.listWorkItems` accepts and forwards `noCache`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- User creates GitHub issue in Tasks.
|
||||
- `handleCreateNewIssue` bumps `taskRefreshNonce`.
|
||||
- TaskPage effect computes `forcedFetch=true`.
|
||||
- `fetchWorkItemsAcrossRepos` calls `fetchWorkItems` with `{ force: true, noCache: true }` for the nonce-triggered refresh.
|
||||
- `fetchWorkItems` bypasses renderer cache and, when `noCache` is set, calls `gh.listWorkItems({ noCache: true })`.
|
||||
- Desktop IPC or runtime RPC forwards `noCache`.
|
||||
- `listRecentWorkItems` omits `gh api --cache 120s` for that fetch.
|
||||
- GitHub returns a fresh recent issue list; cache is repopulated with the new issue.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Multiple selected repos: all selected repos refresh through the existing fan-out; `noCache` applies only to `forcedFetch`, not the landing probe.
|
||||
- Fork/upstream issue source: source resolution stays unchanged, and the cache bypass applies to whichever source is selected.
|
||||
- SSH/runtime repo: runtime RPC accepts and forwards `noCache`, so remote clients do not retain the stale-cache bug.
|
||||
- In-flight non-forced request: existing force logic waits for the stale request to settle, then issues a fresh no-cache request.
|
||||
- In-flight forced landing probe: a nonce-triggered no-cache request must not dedupe onto the cacheable landing probe; otherwise create can still repaint from `gh api --cache 120s`.
|
||||
- One-time landing probe: it still uses `force` to bypass renderer freshness, but must not set `noCache`; otherwise merely opening Tasks with cached rows would spend uncached GitHub API requests.
|
||||
- Search query active: queried paths already use `gh issue list` / `gh pr list` rather than cached REST calls, so no behavior change is required.
|
||||
- Pagination: next-page fetches use queried/cursor paths and do not populate the renderer work-items cache, so `noCache` is page-0-only.
|
||||
- Concurrent windows: the creating window refreshes immediately; other renderer windows keep their own cache until their next refresh, landing probe, or TTL expiry. This change should not introduce cross-window invalidation.
|
||||
- External GitHub mutations: external issue changes still rely on existing TTL/manual refresh behavior; this fix only guarantees freshness for Orca-originated create flows.
|
||||
- Network/auth errors: existing partial-failure handling and banners remain unchanged.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: extend `src/renderer/src/store/slices/github.test.ts` or add focused coverage for `fetchWorkItems` `force`/`noCache` IPC args and the no-cache-vs-cacheable-in-flight dedupe case.
|
||||
- Unit: cover the TaskPage nonce path or isolate the option computation so nonce-triggered refreshes set `noCache` and the landing probe does not. If implementation narrows no-cache to a new create/manual-refresh signal, cover that narrower signal explicitly.
|
||||
- Unit: extend `src/main/ipc/github.test.ts` to assert `gh:listWorkItems` forwards `noCache` to the client.
|
||||
- Unit: extend `src/renderer/src/web/web-preload-api.test.ts` to assert web/remote `gh.listWorkItems` preserves `noCache`.
|
||||
- Unit: extend `src/main/github/client-issue-source.test.ts` or `src/main/github/client-work-items.test.ts` to assert recent no-cache requests omit `--cache 120s` while normal recent requests keep it.
|
||||
- Unit: extend `src/main/runtime/rpc/methods/github.test.ts` and/or `src/main/runtime/orca-runtime.test.ts` for `noCache` schema/forwarding.
|
||||
- Typecheck: `pnpm typecheck`.
|
||||
- Lint: `pnpm lint`.
|
||||
- Electron validation: create an issue only in a throwaway/test repo if available; otherwise validate the refresh behavior with mocked/local unit tests and capture the Tasks issue list state without mutating live data.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
Not UI-visible. The existing issue list UI and create-issue dialog should look unchanged; only freshness after a forced refresh changes.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. GitHub Tasks issue list after refresh/create path is reachable.
|
||||
2. Create issue dialog before submission, if validation can use a throwaway repo.
|
||||
3. Post-create issue detail/list state, only if validation can use a throwaway repo without mutating live user data.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add `noCache` to renderer fetch options plus shared/preload/web/runtime IPC contracts.
|
||||
2. Thread `noCache` through web routing, runtime, and desktop main-process handlers.
|
||||
3. Apply `noCache` to the recent REST work-item list args.
|
||||
4. Pass `noCache` from nonce-triggered renderer work-item fetches, but not landing probes.
|
||||
5. Add regression tests.
|
||||
6. Run typecheck, lint, and focused tests.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: reduced to force-refresh cache bypass for the existing work-item list path; no polling, UI changes, or TTL changes.
|
||||
- Architecture/data flow: the renderer already owns refresh intent, while main owns GitHub CLI args. Thread `noCache` as explicit request metadata across preload, web preload, desktop IPC, runtime RPC, and SSH-aware runtime methods; do not infer it from every `force` call because landing probes also use `force`.
|
||||
- Failure modes covered:
|
||||
- stale `gh api --cache 120s` response after create;
|
||||
- forced fetch deduping onto a non-forced in-flight request;
|
||||
- runtime/SSH clients lacking the cache-bypass argument;
|
||||
- upstream/origin issue-source selection still resolving before fetch;
|
||||
- queried path accidentally changing despite not using REST cache.
|
||||
- Test coverage required:
|
||||
- renderer store IPC args for `force`/`noCache` combinations;
|
||||
- TaskPage option computation for nonce-triggered refresh vs landing probe;
|
||||
- desktop IPC and web preload forwarding;
|
||||
- main GitHub client recent-list REST args with and without `noCache`;
|
||||
- runtime RPC schema/handler forwarding `noCache`;
|
||||
- focused typecheck/lint.
|
||||
- Performance/blast radius: low when `noCache` is limited to nonce-triggered refreshes and preference invalidation. Normal loads and landing probes keep the CLI cache; the no-cache path doubles the fresh REST calls for repos that have both issue and PR sources, so avoid broadening it to every renderer `force`.
|
||||
- UI quality bar: not UI-visible; UI should remain unchanged apart from fresher rows.
|
||||
- Required review screenshots:
|
||||
1. Tasks GitHub issues list reachable after implementation.
|
||||
2. Create issue dialog reachable, if a throwaway repo is available.
|
||||
3. Post-create or post-refresh list state, if validation can avoid mutating live data.
|
||||
- Residual risks: validating the actual create flow may be skipped unless a safe throwaway GitHub repo is available; cross-window freshness and external GitHub mutations remain bounded by existing refresh/TTL behavior.
|
||||
|
|
@ -140,6 +140,27 @@ describe('GitHub issue source split', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('omits gh api cache args for no-cache recent work-item requests', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
||||
stdout: '[]'
|
||||
})
|
||||
|
||||
await listWorkItems('/repo-root', 10, undefined, undefined, undefined, undefined, true)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
['api', 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('lists SSH repo work items with explicit owner/repo and no local cwd', async () => {
|
||||
resolveIssueSourceMock.mockResolvedValueOnce({
|
||||
source: { owner: 'stablyai', repo: 'orca' },
|
||||
|
|
|
|||
|
|
@ -805,10 +805,12 @@ async function listRecentWorkItems(
|
|||
issueOwnerRepo: OwnerRepo | null,
|
||||
prOwnerRepo: OwnerRepo | null,
|
||||
limit: number,
|
||||
connectionId?: string | null
|
||||
connectionId?: string | null,
|
||||
noCache?: boolean
|
||||
): Promise<PartialWorkItemsResult> {
|
||||
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
|
||||
const requiresExplicitRepo = Boolean(connectionId)
|
||||
const restCacheArgs = noCache ? [] : ['--cache', '120s']
|
||||
assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
|
||||
if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) {
|
||||
// Why: allSettled so a 403 on upstream issues doesn't zero out the origin
|
||||
|
|
@ -819,8 +821,7 @@ async function listRecentWorkItems(
|
|||
? ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'120s',
|
||||
...restCacheArgs,
|
||||
`repos/${issueOwnerRepo.owner}/${issueOwnerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
|
||||
],
|
||||
ghOptions
|
||||
|
|
@ -844,8 +845,7 @@ async function listRecentWorkItems(
|
|||
? ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'120s',
|
||||
...restCacheArgs,
|
||||
`repos/${prOwnerRepo.owner}/${prOwnerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc`
|
||||
],
|
||||
ghOptions
|
||||
|
|
@ -1053,7 +1053,8 @@ export async function listWorkItems(
|
|||
query?: string,
|
||||
before?: string,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null
|
||||
connectionId?: string | null,
|
||||
noCache?: boolean
|
||||
): Promise<ListWorkItemsResult<MainWorkItem>> {
|
||||
// Why: resolve the raw upstream candidate alongside the preference-aware
|
||||
// issue source. The selector needs to know whether an upstream remote
|
||||
|
|
@ -1074,7 +1075,14 @@ export async function listWorkItems(
|
|||
// catch-all here would make an auth/network failure indistinguishable from
|
||||
// an empty result and silently under-report per-repo failures.
|
||||
const partial = !trimmedQuery
|
||||
? await listRecentWorkItems(repoPath, issueOwnerRepo, prOwnerRepo, limit, connectionId)
|
||||
? await listRecentWorkItems(
|
||||
repoPath,
|
||||
issueOwnerRepo,
|
||||
prOwnerRepo,
|
||||
limit,
|
||||
connectionId,
|
||||
noCache
|
||||
)
|
||||
: await listQueriedWorkItems(
|
||||
repoPath,
|
||||
issueOwnerRepo,
|
||||
|
|
|
|||
|
|
@ -199,7 +199,8 @@ describe('registerGitHubHandlers', () => {
|
|||
repoPath: '/workspace/repo',
|
||||
limit: 10,
|
||||
query: 'is:open',
|
||||
before: 'cursor-1'
|
||||
before: 'cursor-1',
|
||||
noCache: true
|
||||
})
|
||||
|
||||
expect(listWorkItemsMock).toHaveBeenCalledWith(
|
||||
|
|
@ -208,7 +209,8 @@ describe('registerGitHubHandlers', () => {
|
|||
'is:open',
|
||||
'cursor-1',
|
||||
'origin',
|
||||
null
|
||||
null,
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -230,7 +232,8 @@ describe('registerGitHubHandlers', () => {
|
|||
'',
|
||||
undefined,
|
||||
undefined,
|
||||
'openclaw-2'
|
||||
'openclaw-2',
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -294,7 +294,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
|||
'gh:listWorkItems',
|
||||
(
|
||||
_event,
|
||||
args: { repoPath: string; repoId?: string; limit?: number; query?: string; before?: string }
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
limit?: number
|
||||
query?: string
|
||||
before?: string
|
||||
noCache?: boolean
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return listWorkItems(
|
||||
|
|
@ -303,7 +310,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
|||
args.query,
|
||||
args.before,
|
||||
repo.issueSourcePreference,
|
||||
repoConnectionId(repo)
|
||||
repoConnectionId(repo),
|
||||
args.noCache
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5843,7 +5843,8 @@ export class OrcaRuntimeService {
|
|||
repoSelector: string,
|
||||
limit?: number,
|
||||
query?: string,
|
||||
before?: string
|
||||
before?: string,
|
||||
noCache?: boolean
|
||||
): Promise<Awaited<ReturnType<typeof listWorkItems>>> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
return listWorkItems(
|
||||
|
|
@ -5852,7 +5853,8 @@ export class OrcaRuntimeService {
|
|||
query,
|
||||
before,
|
||||
repo.issueSourcePreference,
|
||||
repo.connectionId ?? null
|
||||
repo.connectionId ?? null,
|
||||
noCache
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,10 +47,15 @@ describe('github RPC methods', () => {
|
|||
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('github.listWorkItems', { repo: 'repo-1', limit: 10, query: 'is:pr' })
|
||||
makeRequest('github.listWorkItems', {
|
||||
repo: 'repo-1',
|
||||
limit: 10,
|
||||
query: 'is:pr',
|
||||
noCache: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.listRepoWorkItems).toHaveBeenCalledWith('repo-1', 10, 'is:pr', undefined)
|
||||
expect(runtime.listRepoWorkItems).toHaveBeenCalledWith('repo-1', 10, 'is:pr', undefined, true)
|
||||
expect(response).toMatchObject({ ok: true, result: { items: [] } })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ const RepoSelector = z.object({
|
|||
const WorkItemsList = RepoSelector.extend({
|
||||
limit: OptionalFiniteNumber,
|
||||
query: OptionalString,
|
||||
before: OptionalString
|
||||
before: OptionalString,
|
||||
noCache: z.boolean().optional()
|
||||
})
|
||||
|
||||
const IssuesList = RepoSelector.extend({
|
||||
|
|
@ -283,7 +284,13 @@ export const GITHUB_METHODS: RpcMethod[] = [
|
|||
name: 'github.listWorkItems',
|
||||
params: WorkItemsList,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.listRepoWorkItems(params.repo, params.limit, params.query, params.before)
|
||||
runtime.listRepoWorkItems(
|
||||
params.repo,
|
||||
params.limit,
|
||||
params.query,
|
||||
params.before,
|
||||
params.noCache
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.listIssues',
|
||||
|
|
|
|||
|
|
@ -936,6 +936,7 @@ export type PreloadApi = {
|
|||
limit?: number
|
||||
query?: string
|
||||
before?: string
|
||||
noCache?: boolean
|
||||
}) => Promise<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>>
|
||||
prChecks: (args: {
|
||||
repoPath: string
|
||||
|
|
|
|||
|
|
@ -989,6 +989,7 @@ const api = {
|
|||
limit?: number
|
||||
query?: string
|
||||
before?: string
|
||||
noCache?: boolean
|
||||
}): Promise<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>> =>
|
||||
ipcRenderer.invoke('gh:listWorkItems', args),
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
|||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
import {
|
||||
buildTaskPageRepoSourceState,
|
||||
deriveTaskPageGitHubWorkItemsFetchOptions,
|
||||
findTaskPageDialogWorkItem,
|
||||
findTaskPageLinearIssue,
|
||||
reconcileTaskPageLinearIssuesAfterLandingRefresh,
|
||||
|
|
@ -3510,7 +3511,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
// when this effect dispatched preserves later additions.
|
||||
const dispatchedRetryPaths = retryingRepoPaths
|
||||
void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, {
|
||||
force: forcedFetch || shouldProbeOnLanding
|
||||
...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding)
|
||||
})
|
||||
.then(({ items, failedCount: failed }) => {
|
||||
// Why: clear only the repos this effect was responsible for
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { workItemsCacheKey, type CacheEntry } from '@/store/slices/github'
|
|||
import type { GitHubWorkItem, LinearIssue } from '../../../shared/types'
|
||||
import {
|
||||
buildTaskPageRepoSourceState,
|
||||
deriveTaskPageGitHubWorkItemsFetchOptions,
|
||||
findTaskPageDialogWorkItem,
|
||||
findTaskPageLinearDrawerIssue,
|
||||
reconcileTaskPageItemsAfterLandingRefresh,
|
||||
|
|
@ -29,6 +30,21 @@ function linearIssue(id: string): LinearIssue {
|
|||
}
|
||||
|
||||
describe('task page cache selectors', () => {
|
||||
it('uses noCache only for nonce or preference forced GitHub work-item refreshes', () => {
|
||||
expect(deriveTaskPageGitHubWorkItemsFetchOptions(true, false)).toEqual({
|
||||
force: true,
|
||||
noCache: true
|
||||
})
|
||||
expect(deriveTaskPageGitHubWorkItemsFetchOptions(false, true)).toEqual({
|
||||
force: true,
|
||||
noCache: false
|
||||
})
|
||||
expect(deriveTaskPageGitHubWorkItemsFetchOptions(false, false)).toEqual({
|
||||
force: false,
|
||||
noCache: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the selected work-item cache slice shallow-equal across unrelated cache writes', () => {
|
||||
const repo = { id: 'repo-1', path: '/repo/one' }
|
||||
const selectedEntry = entry<GitHubWorkItem[]>([workItem('issue-1', 'repo-1')])
|
||||
|
|
|
|||
|
|
@ -23,10 +23,25 @@ export type TaskPageRepoSourceState = {
|
|||
error: WorkItemsCacheError | null
|
||||
}
|
||||
|
||||
export type TaskPageWorkItemsFetchOptions = {
|
||||
force: boolean
|
||||
noCache: boolean
|
||||
}
|
||||
|
||||
type WorkItemsCache = Record<string, CacheEntry<GitHubWorkItem[]>>
|
||||
type LinearIssueCache = Record<string, CacheEntry<LinearIssue>>
|
||||
type LinearSearchCache = Record<string, CacheEntry<LinearIssue[]>>
|
||||
|
||||
export function deriveTaskPageGitHubWorkItemsFetchOptions(
|
||||
forcedFetch: boolean,
|
||||
shouldProbeOnLanding: boolean
|
||||
): TaskPageWorkItemsFetchOptions {
|
||||
return {
|
||||
force: forcedFetch || shouldProbeOnLanding,
|
||||
noCache: forcedFetch
|
||||
}
|
||||
}
|
||||
|
||||
export function selectTaskPageWorkItemsCacheEntries(
|
||||
workItemsCache: WorkItemsCache,
|
||||
repos: readonly TaskPageRepoCacheInput[],
|
||||
|
|
|
|||
|
|
@ -2685,6 +2685,91 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
|||
expect(after.error).toBeNull()
|
||||
})
|
||||
|
||||
it('threads noCache only when explicitly requested for work-item fetches', async () => {
|
||||
const store = createTestStore()
|
||||
mockApi.gh.listWorkItems
|
||||
.mockResolvedValueOnce({
|
||||
items: [],
|
||||
sources: { issues: null, prs: null, upstreamCandidate: null }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [],
|
||||
sources: { issues: null, prs: null, upstreamCandidate: null }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [],
|
||||
sources: { issues: null, prs: null, upstreamCandidate: null }
|
||||
})
|
||||
|
||||
await store.getState().fetchWorkItems('repo-normal', '/repo/normal', 24, '')
|
||||
await store.getState().fetchWorkItems('repo-force', '/repo/force', 24, '', { force: true })
|
||||
await store.getState().fetchWorkItems('repo-fresh', '/repo/fresh', 24, '', {
|
||||
force: true,
|
||||
noCache: true
|
||||
})
|
||||
|
||||
expect(mockApi.gh.listWorkItems).toHaveBeenNthCalledWith(1, {
|
||||
repoPath: '/repo/normal',
|
||||
repoId: 'repo-normal',
|
||||
limit: 24,
|
||||
query: undefined
|
||||
})
|
||||
expect(mockApi.gh.listWorkItems).toHaveBeenNthCalledWith(2, {
|
||||
repoPath: '/repo/force',
|
||||
repoId: 'repo-force',
|
||||
limit: 24,
|
||||
query: undefined
|
||||
})
|
||||
expect(mockApi.gh.listWorkItems).toHaveBeenNthCalledWith(3, {
|
||||
repoPath: '/repo/fresh',
|
||||
repoId: 'repo-fresh',
|
||||
limit: 24,
|
||||
query: undefined,
|
||||
noCache: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not dedupe a no-cache forced fetch onto a cacheable forced request', async () => {
|
||||
const store = createTestStore()
|
||||
type WorkItemsEnvelope = {
|
||||
items: []
|
||||
sources: { issues: null; prs: null; upstreamCandidate: null }
|
||||
}
|
||||
let resolveCacheable: (value: WorkItemsEnvelope) => void = () => {}
|
||||
const cacheableRequest = new Promise<WorkItemsEnvelope>((resolve) => {
|
||||
resolveCacheable = resolve
|
||||
})
|
||||
mockApi.gh.listWorkItems.mockReturnValueOnce(cacheableRequest).mockResolvedValueOnce({
|
||||
items: [],
|
||||
sources: { issues: null, prs: null, upstreamCandidate: null }
|
||||
})
|
||||
|
||||
const landingProbe = store
|
||||
.getState()
|
||||
.fetchWorkItems('repo-id', '/repo', 24, '', { force: true })
|
||||
await Promise.resolve()
|
||||
const noCacheRefresh = store
|
||||
.getState()
|
||||
.fetchWorkItems('repo-id', '/repo', 24, '', { force: true, noCache: true })
|
||||
|
||||
expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(1)
|
||||
resolveCacheable({
|
||||
items: [],
|
||||
sources: { issues: null, prs: null, upstreamCandidate: null }
|
||||
})
|
||||
await landingProbe
|
||||
await noCacheRefresh
|
||||
|
||||
expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(2)
|
||||
expect(mockApi.gh.listWorkItems).toHaveBeenNthCalledWith(2, {
|
||||
repoPath: '/repo',
|
||||
repoId: 'repo-id',
|
||||
limit: 24,
|
||||
query: undefined,
|
||||
noCache: true
|
||||
})
|
||||
})
|
||||
|
||||
it('routes work item fetches through repo-scoped IPC even when a runtime is active', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ export type CacheEntry<T> = {
|
|||
|
||||
type FetchOptions = {
|
||||
force?: boolean
|
||||
noCache?: boolean
|
||||
}
|
||||
|
||||
type RepoScopedFetchOptions = FetchOptions & {
|
||||
|
|
@ -354,6 +355,7 @@ const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>()
|
|||
type InflightWorkItems = {
|
||||
promise: Promise<GitHubWorkItem[]>
|
||||
force: boolean
|
||||
noCache: boolean
|
||||
}
|
||||
const inflightWorkItemsRequests = new Map<string, InflightWorkItems>()
|
||||
const prRequestGenerations = new Map<string, number>()
|
||||
|
|
@ -1648,12 +1650,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
const existing = inflightWorkItemsRequests.get(key)
|
||||
if (existing) {
|
||||
// Why: a user-initiated refresh (force=true) must not silently dedupe to
|
||||
// a non-forcing fetch already in flight — the result would be no fresher
|
||||
// than what the user just asked to invalidate. Wait for the non-forcing
|
||||
// request to settle (success or failure — we discard the result either
|
||||
// way), then fall through to issue a new forced request. Non-forcing
|
||||
// callers continue to dedupe onto any in-flight request as before.
|
||||
if (options?.force && !existing.force) {
|
||||
// a less-fresh fetch already in flight. noCache=true is stricter than a
|
||||
// cacheable forced landing probe because it must bypass gh api's cache too.
|
||||
if ((options?.force && !existing.force) || (options?.noCache && !existing.noCache)) {
|
||||
await existing.promise.catch(() => {})
|
||||
} else {
|
||||
return existing.promise
|
||||
|
|
@ -1667,7 +1666,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
repoPath,
|
||||
repoId,
|
||||
limit,
|
||||
query: query || undefined
|
||||
query: query || undefined,
|
||||
...(options?.noCache ? { noCache: true } : {})
|
||||
})
|
||||
// Why: stamp repoId at the renderer fetch boundary so every downstream
|
||||
// consumer (cross-repo merge, row rendering, drawer) can rely on the
|
||||
|
|
@ -1720,7 +1720,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
|
||||
inflightWorkItemsRequests.set(key, {
|
||||
promise: request,
|
||||
force: Boolean(options?.force)
|
||||
force: Boolean(options?.force),
|
||||
noCache: Boolean(options?.noCache)
|
||||
})
|
||||
return request
|
||||
},
|
||||
|
|
|
|||
|
|
@ -595,9 +595,15 @@ describe('web GitHub preload API', () => {
|
|||
},
|
||||
{
|
||||
key: 'listWorkItems',
|
||||
args: { repoPath, limit: 20, query: 'is:pr', before: 'cursor' },
|
||||
args: { repoPath, limit: 20, query: 'is:pr', before: 'cursor', noCache: true },
|
||||
expectedMethod: 'github.listWorkItems',
|
||||
expectedParams: withRepo({ repoPath, limit: 20, query: 'is:pr', before: 'cursor' })
|
||||
expectedParams: withRepo({
|
||||
repoPath,
|
||||
limit: 20,
|
||||
query: 'is:pr',
|
||||
before: 'cursor',
|
||||
noCache: true
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'prChecks',
|
||||
|
|
|
|||
Loading…
Reference in New Issue