Skip to content

Commit 3f396a3

Browse files
committed
## [0.3.20] - 2026-01-14
- GitLab sync: incremental refresh now includes state transitions (open/closed) by fetching updated issues with `state=all`; always updates issues already in cache/graph regardless of `gitlabClosedDays` (which only affects adding new closed issues).
1 parent a120d43 commit 3f396a3

4 files changed

Lines changed: 184 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## [0.3.20] - 2026-01-14
4+
- GitLab sync: incremental refresh now includes state transitions (open/closed) by fetching updated issues with `state=all`; always updates issues already in cache/graph regardless of `gitlabClosedDays` (which only affects adding new closed issues).
5+
36
## [0.3.19] - 2026-01-09
47
- Graph perf: speed up physics overlap resolution on large graphs; faster zoomed-out drawing (LOD) + cached group smudges; faster group label toggling.
58
- improved frame fit

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
],
1414
"author": "Thomas Fischer <tfischer@beamng.gmbh>",
1515
"private": true,
16-
"version": "0.3.19",
16+
"version": "0.3.20",
1717
"license": "MIT",
1818
"engines": {
1919
"node": ">=20"

src/composables/useDataLoader.js

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -279,54 +279,88 @@ export function useDataLoader ({
279279
settings.meta.gitlabCanWrite = false
280280
}
281281

282-
// Fetch opened issues (REST: fast + includes epic_iid)
283-
issues = await fetchProjectIssuesRest(
284-
restClient,
285-
settings.config.projectId,
286-
(msg) => {
287-
loadingMessage.value = msg
288-
updateStatus.value = { loading: true, source: 'gitlab', message: msg }
289-
},
290-
updatedAfter ? { params: { updated_after: updatedAfter } } : {}
291-
)
292-
const partialOpened = !!issues?.__glvPartial
293-
294-
// Fetch closed issues if requested
295-
if (settings.config.gitlabClosedDays > 0) {
296-
const closedAfter = new Date()
297-
closedAfter.setDate(closedAfter.getDate() - settings.config.gitlabClosedDays)
298-
299-
const closedAfterMs = closedAfter.getTime()
300-
const closedUpdatedAfter = (() => {
301-
if (!updatedAfter) return closedAfter.toISOString()
302-
const updatedAfterMs = parseCursorMs(updatedAfter)
303-
if (updatedAfterMs == null) return closedAfter.toISOString()
304-
return new Date(Math.max(closedAfterMs, updatedAfterMs)).toISOString()
305-
})()
306-
307-
const closedIssues = await fetchProjectIssuesRest(
282+
if (updatedAfter) {
283+
// Incremental sync must include state transitions (opened <-> closed), not just new opened issues.
284+
const updated = await fetchProjectIssuesRest(
308285
restClient,
309286
settings.config.projectId,
310287
(msg) => {
311288
loadingMessage.value = msg
312289
updateStatus.value = { loading: true, source: 'gitlab', message: msg }
313290
},
314-
{
315-
state: 'closed',
316-
params: {
317-
updated_after: closedUpdatedAfter
318-
}
319-
}
291+
{ state: 'all', params: { updated_after: updatedAfter } }
320292
)
321-
const partialClosed = !!closedIssues?.__glvPartial
293+
const partialUpdated = !!updated?.__glvPartial
294+
295+
// NOTE: In incremental mode, always apply updates for issues that are already in cache/graph,
296+
// regardless of gitlabClosedDays. gitlabClosedDays only affects adding *new* closed issues.
297+
const closedAfter = (() => {
298+
if (!(settings.config.gitlabClosedDays > 0)) return null
299+
const d = new Date()
300+
d.setDate(d.getDate() - settings.config.gitlabClosedDays)
301+
return d
302+
})()
303+
issues = updated.filter(i => {
304+
if (!i || i.iid == null) return false
305+
const id = String(i.iid)
306+
const exists = !!nodes[id]
307+
if (i.state !== 'closed') return true
308+
if (exists) return true
309+
if (!closedAfter) return false
310+
return !!(i.closed_at && new Date(i.closed_at) >= closedAfter)
311+
})
322312

323-
// Filter to ensure they were actually closed after the date (updated_after is broader)
324-
const actuallyClosed = closedIssues.filter(i => i.closed_at && new Date(i.closed_at) >= closedAfter)
325-
issues = [...issues, ...actuallyClosed]
326-
// propagate partial marker
327-
if (partialOpened || partialClosed) {
313+
if (partialUpdated) {
328314
try { Object.defineProperty(issues, '__glvPartial', { value: true, enumerable: false }) } catch {}
329315
}
316+
} else {
317+
// Full fetch: opened issues (REST: fast + includes epic_iid)
318+
issues = await fetchProjectIssuesRest(
319+
restClient,
320+
settings.config.projectId,
321+
(msg) => {
322+
loadingMessage.value = msg
323+
updateStatus.value = { loading: true, source: 'gitlab', message: msg }
324+
}
325+
)
326+
const partialOpened = !!issues?.__glvPartial
327+
328+
// Fetch closed issues if requested
329+
if (settings.config.gitlabClosedDays > 0) {
330+
const closedAfter = new Date()
331+
closedAfter.setDate(closedAfter.getDate() - settings.config.gitlabClosedDays)
332+
333+
const closedAfterMs = closedAfter.getTime()
334+
const closedUpdatedAfter = (() => {
335+
const updatedAfterMs = parseCursorMs(updatedAfter)
336+
if (updatedAfterMs == null) return closedAfter.toISOString()
337+
return new Date(Math.max(closedAfterMs, updatedAfterMs)).toISOString()
338+
})()
339+
340+
const closedIssues = await fetchProjectIssuesRest(
341+
restClient,
342+
settings.config.projectId,
343+
(msg) => {
344+
loadingMessage.value = msg
345+
updateStatus.value = { loading: true, source: 'gitlab', message: msg }
346+
},
347+
{
348+
state: 'closed',
349+
params: {
350+
updated_after: closedUpdatedAfter
351+
}
352+
}
353+
)
354+
const partialClosed = !!closedIssues?.__glvPartial
355+
356+
// Filter to ensure they were actually closed after the date (updated_after is broader)
357+
const actuallyClosed = closedIssues.filter(i => i.closed_at && new Date(i.closed_at) >= closedAfter)
358+
issues = [...issues, ...actuallyClosed]
359+
// propagate partial marker
360+
if (partialOpened || partialClosed) {
361+
try { Object.defineProperty(issues, '__glvPartial', { value: true, enumerable: false }) } catch {}
362+
}
363+
}
330364
}
331365

332366
// Optional GraphQL enrichment pass for fields REST doesn't provide (kept minimal).

src/composables/useDataLoader.test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,114 @@ describe('useDataLoader', () => {
294294
confirmSpy.mockRestore()
295295
})
296296

297+
it('loadData (gitlab incremental) syncs closed transitions (keeps and updates cached issues even when gitlabClosedDays=0)', async () => {
298+
const gitlab = await import('../services/gitlab')
299+
300+
const closedIssue = {
301+
iid: 1,
302+
title: 'A',
303+
state: 'closed',
304+
labels: [],
305+
author: { name: 'Alice' },
306+
assignee: null,
307+
assignees: [],
308+
milestone: null,
309+
created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
310+
updated_at: new Date().toISOString(),
311+
closed_at: new Date().toISOString(),
312+
due_date: null,
313+
web_url: '',
314+
confidential: false,
315+
time_stats: { time_estimate: 0, total_time_spent: 0 },
316+
user_notes_count: 0,
317+
merge_requests_count: 0,
318+
upvotes: 0,
319+
downvotes: 0,
320+
has_tasks: false,
321+
task_status: null
322+
}
323+
324+
gitlab.fetchProjectIssuesRest.mockResolvedValueOnce([closedIssue])
325+
326+
const settings = reactive({
327+
meta: {},
328+
config: {
329+
enableGitLab: true,
330+
enableSvn: false,
331+
token: 't',
332+
projectId: 'p',
333+
gitlabApiBaseUrl: 'https://gitlab.example.com',
334+
gitlabClosedDays: 0
335+
}
336+
})
337+
const nodes = reactive({ '1': { id: '1', type: 'gitlab_issue', _raw: { state: 'opened' } } })
338+
const edges = reactive({ '1-2': { source: '1', target: '2' }, 'svn-1-1': { source: 'svn-1', target: '1' } })
339+
const issueGraphSnapshot = reactive({ nodes: {}, edges: {} })
340+
const svnUrl = ref('')
341+
const svnVizLimit = ref(2000)
342+
const svnRecentCommits = ref([])
343+
const svnCommitCount = ref(0)
344+
const gitlabCacheMeta = ref({
345+
projectId: 'p',
346+
apiBaseUrl: 'https://gitlab.example.com/api/v4',
347+
syncCursor: new Date(Date.now() - 60 * 60 * 1000).toISOString()
348+
})
349+
const mattermostMeta = ref({})
350+
const lastUpdated = ref(null)
351+
const loading = ref(false)
352+
const loadingMessage = ref('')
353+
const updateStatus = ref({})
354+
const error = ref('')
355+
const isElectron = ref(false)
356+
const canUseSvn = ref(false)
357+
const vizMode = ref('issues')
358+
359+
const { loadData } = useDataLoader({
360+
settings,
361+
nodes,
362+
edges,
363+
issueGraphSnapshot,
364+
svnUrl,
365+
svnVizLimit,
366+
svnRecentCommits,
367+
svnCommitCount,
368+
gitlabCacheMeta,
369+
mattermostMeta,
370+
lastUpdated,
371+
loading,
372+
loadingMessage,
373+
updateStatus,
374+
error,
375+
isElectron,
376+
canUseSvn,
377+
vizMode,
378+
buildSvnVizGraph: () => {},
379+
resetFilters: () => {},
380+
createMockIssuesGraph: () => ({ nodes: {}, edges: {} })
381+
})
382+
383+
await loadData()
384+
385+
// cached issues are kept and updated (gitlabClosedDays does not block updates)
386+
expect(nodes['1']).toBeTruthy()
387+
expect(nodes['1']?.closedAt).toBeTruthy()
388+
// issue-link edges touching updated issues are cleared for re-linking
389+
expect(Object.keys(edges).some(k => k.includes('1-2'))).toBe(false)
390+
// but non-issue edges (e.g. SVN) remain
391+
expect(Object.keys(edges).some(k => k.includes('svn'))).toBe(true)
392+
393+
// incremental fetch uses state=all + updated_after
394+
expect(gitlab.fetchProjectIssuesRest).toHaveBeenCalledWith(
395+
expect.anything(),
396+
'p',
397+
expect.any(Function),
398+
expect.objectContaining({
399+
state: 'all',
400+
params: expect.objectContaining({ updated_after: expect.any(String) })
401+
})
402+
)
403+
})
404+
297405
it('initCachedData uses createMockIssuesGraph when cache empty', async () => {
298406
await localforage.clear()
299407

0 commit comments

Comments
 (0)