Operator ran npm run ai:sync-github-workflow from main checkout post-#11470 merge. After progressing through 8506 issues (created chunks up to v8.1.0/chunk-40), sync crashed with:
❌ Sync failed: TypeError: Cannot read properties of null (reading 'login')
at #formatTimelineEvent (ai/services/github-workflow/sync/IssueSyncer.mjs:202:58)
at #formatIssueMarkdown (IssueSyncer.mjs:171:50)
at IssueSyncer.pullFromGitHub (IssueSyncer.mjs:522:48)
Problem
IssueSyncer.mjs:202 reads event.assignee.login directly with an inline comment: // Assuming assignee is always a User. That assumption fails when the user has been deleted on GitHub — the timeline event's assignee field comes back as null from the GraphQL API, and .login throws.
The same null-deref pattern exists across the entire #formatTimelineEvent switch:
Line
Case
Field accessed
Failure mode
196
LabeledEvent
event.label.name
deleted label
199
UnlabeledEvent
event.label.name
deleted label
202
AssignedEvent
event.assignee.login
deleted user (this crash)
205
UnassignedEvent
event.assignee.login
deleted user
223
ReferencedEvent
event.commit.message / .oid
force-pushed-away commit
227
CrossReferencedEvent
event.source.__typename / .number
deleted source
231
SubIssueAddedEvent
event.subIssue.number
deleted sub-issue
234
SubIssueRemovedEvent
event.subIssue.number
deleted sub-issue
237
ParentIssueAddedEvent
event.parent.number
deleted parent
240
ParentIssueRemovedEvent
event.parent.number
deleted parent
243
BlockedByAddedEvent
event.blockingIssue.number
deleted blocking issue
246
BlockingAddedEvent
event.blockedIssue.number
deleted blocked issue
249
BlockedByRemovedEvent
event.blockingIssue.number
deleted blocking issue
252
BlockingRemovedEvent
event.blockedIssue.number
deleted blocked issue
Note line 186 already null-safes event.actor and event.author (event.actor?.login || event.author?.login || 'Ghost'). The pattern was known but coverage is incomplete.
Each unhandled null aborts the entire sync_all run — wasting all the GraphQL cost spent (~516 cost units in this trace).
Architectural Reality
#formatTimelineEvent formats GitHub GraphQL timelineItems.nodes entries into Markdown timeline lines. GitHub may return null for any referenced entity that has been:
The function runs per-event for thousands of issues during clean-slate sync. ONE null entity ANYWHERE in ANY issue's timeline crashes the whole sync.
The Fix
Add uniform null-safety to #formatTimelineEvent switch cases using optional chaining + fallback markers:
case'AssignedEvent':
details = `assigned to @${event.assignee?.login || 'Ghost'}`;
break;
case'UnassignedEvent':
details = `unassigned from @${event.assignee?.login || 'Ghost'}`;
break;
case'LabeledEvent':
details = `added the \`${event.label?.name || '(deleted label)'}\` label`;
break;
// ... etc. for all switch cases that deref fields
Fallback markers ('Ghost' for users matches existing event.actor?.login || event.author?.login || 'Ghost' pattern at line 186; '(deleted X)' or '?' for other entities).
Acceptance Criteria
All switch cases in #formatTimelineEvent use optional chaining (?.) when dereferencing any nested field that GitHub can return as null (assignee, label, subIssue, parent, blockingIssue, blockedIssue, commit, source).
Each null-deref site has a meaningful fallback marker that preserves Timeline section readability (e.g., 'Ghost' for users matches existing convention; '(deleted label)', '(deleted)' for other entities; '?' for numbers).
Unit test verifies #formatTimelineEvent produces a graceful string output (no exception, no 'undefined'/'null' artifacts in output) for at least: AssignedEvent-with-null-assignee, LabeledEvent-with-null-label, SubIssueAddedEvent-with-null-subIssue. Other null paths covered by inspection.
No regression in normal-path Timeline rendering (existing test of #formatTimelineEvent on populated events continues to pass).
sync_all can run to completion past this crash point without aborting on null-entity events (post-merge validation).
Out of Scope
Log-volume reduction during sync (separate concern — operator surfaced "very intense" per-issue log line volume; deserves its own enhancement ticket).
Skipping/filtering nullable timeline events entirely (defensive null-safety preserves the event surface in markdown for archive integrity; filtering loses information).
Adding GraphQL-side null-filter on the query (returns less data; same architectural concern).
Migrating #formatTimelineEvent to a more declarative renderer pattern (out-of-scope refactor).
Avoided Traps
Filtering null events at fetch time: loses information in archived issue Timeline sections; deleted-user/deleted-label history is valuable archive context even when the entity is gone.
Returning early on null: same loss as filtering.
Wrapping each case in try/catch: works but obscures the systematic pattern; uniform optional-chaining is the architecturally cleaner expression.
Only fixing AssignedEvent (the empirical crash): whack-a-mole — next sync_all run hits a null subIssue or label and crashes again. Defensive coverage is the right scope.
Related
Parent context: post-merge of #11470 (ADR 0004 Phase 1 close-out CLI enabler) — operator's first attempt to run the full clean-slate sync surfaced this bug.
Authority artifact: GitHub GraphQL API spec — timelineItems entities may have null fields when referenced entities are deleted.
Adjacent: log-volume enhancement (separate ticket TBD per operator's 2026-05-16 observation).
tobiu referenced in commit 74f7a00 - "fix(github-workflow/sync): comprehensive null-safety sweep across IssueSyncer + ghost-issue regression test (#11481) (#11482) on May 16, 2026, 8:28 PM
Context
Operator ran
npm run ai:sync-github-workflowfrom main checkout post-#11470 merge. After progressing through 8506 issues (created chunks up tov8.1.0/chunk-40), sync crashed with:❌ Sync failed: TypeError: Cannot read properties of null (reading 'login') at #formatTimelineEvent (ai/services/github-workflow/sync/IssueSyncer.mjs:202:58) at #formatIssueMarkdown (IssueSyncer.mjs:171:50) at IssueSyncer.pullFromGitHub (IssueSyncer.mjs:522:48)Problem
IssueSyncer.mjs:202readsevent.assignee.logindirectly with an inline comment:// Assuming assignee is always a User. That assumption fails when the user has been deleted on GitHub — the timeline event'sassigneefield comes back asnullfrom the GraphQL API, and.loginthrows.The same null-deref pattern exists across the entire
#formatTimelineEventswitch:LabeledEventevent.label.nameUnlabeledEventevent.label.nameAssignedEventevent.assignee.loginUnassignedEventevent.assignee.loginReferencedEventevent.commit.message/.oidCrossReferencedEventevent.source.__typename/.numberSubIssueAddedEventevent.subIssue.numberSubIssueRemovedEventevent.subIssue.numberParentIssueAddedEventevent.parent.numberParentIssueRemovedEventevent.parent.numberBlockedByAddedEventevent.blockingIssue.numberBlockingAddedEventevent.blockedIssue.numberBlockedByRemovedEventevent.blockingIssue.numberBlockingRemovedEventevent.blockedIssue.numberNote line 186 already null-safes
event.actorandevent.author(event.actor?.login || event.author?.login || 'Ghost'). The pattern was known but coverage is incomplete.Each unhandled null aborts the entire sync_all run — wasting all the GraphQL cost spent (~516 cost units in this trace).
Architectural Reality
#formatTimelineEventformats GitHub GraphQLtimelineItems.nodesentries into Markdown timeline lines. GitHub may returnnullfor any referenced entity that has been:The function runs per-event for thousands of issues during clean-slate sync. ONE null entity ANYWHERE in ANY issue's timeline crashes the whole sync.
The Fix
Add uniform null-safety to
#formatTimelineEventswitch cases using optional chaining + fallback markers:case 'AssignedEvent': details = `assigned to @${event.assignee?.login || 'Ghost'}`; break; case 'UnassignedEvent': details = `unassigned from @${event.assignee?.login || 'Ghost'}`; break; case 'LabeledEvent': details = `added the \`${event.label?.name || '(deleted label)'}\` label`; break; // ... etc. for all switch cases that deref fieldsFallback markers (
'Ghost'for users matches existingevent.actor?.login || event.author?.login || 'Ghost'pattern at line 186;'(deleted X)'or'?'for other entities).Acceptance Criteria
#formatTimelineEventuse optional chaining (?.) when dereferencing any nested field that GitHub can return as null (assignee, label, subIssue, parent, blockingIssue, blockedIssue, commit, source).'Ghost'for users matches existing convention;'(deleted label)','(deleted)'for other entities;'?'for numbers).#formatTimelineEventproduces a graceful string output (no exception, no'undefined'/'null' artifacts in output) for at least: AssignedEvent-with-null-assignee, LabeledEvent-with-null-label, SubIssueAddedEvent-with-null-subIssue. Other null paths covered by inspection.#formatTimelineEventon populated events continues to pass).Out of Scope
#formatTimelineEventto a more declarative renderer pattern (out-of-scope refactor).Avoided Traps
subIssueorlabeland crashes again. Defensive coverage is the right scope.Related
timelineItemsentities may have null fields when referenced entities are deleted.Origin Session ID: 0064efde-455e-4ecd-a26f-574381b3766a
Retrieval Hint:
query_raw_memories(query="IssueSyncer formatTimelineEvent null assignee login deleted user timeline event GraphQL")