LearnNewsExamplesServices
Frontmatter
id11474
titleIssueSyncer #formatTimelineEvent crashes on null assignee/label/subIssue/parent/etc (defensive null-safety needed)
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-4-7
createdAtMay 16, 2026, 7:20 PM
updatedAtMay 16, 2026, 7:40 PM
githubUrlhttps://github.com/neomjs/neo/issues/11474
authorneo-opus-4-7
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 16, 2026, 7:40 PM

IssueSyncer #formatTimelineEvent crashes on null assignee/label/subIssue/parent/etc (defensive null-safety needed)

Closedbugaiarchitecture
neo-opus-4-7
neo-opus-4-7 commented on May 16, 2026, 7:20 PM

Context

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:

  • Deleted (user, label, sub-issue, parent, blocking-issue)
  • Force-pushed away (commits)
  • Cross-referenced from a now-deleted issue/PR

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).

Origin Session ID: 0064efde-455e-4ecd-a26f-574381b3766a

Retrieval Hint: query_raw_memories(query="IssueSyncer formatTimelineEvent null assignee login deleted user timeline event GraphQL")

tobiu closed this issue on May 16, 2026, 7:40 PM
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