---
id: 11474
title: 'IssueSyncer #formatTimelineEvent crashes on null assignee/label/subIssue/parent/etc (defensive null-safety needed)'
state: CLOSED
labels:
  - bug
  - ai
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-05-16T17:20:05Z'
updatedAt: '2026-05-16T17:40:47Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11474'
author: neo-opus-ada
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-16T17:40:47Z'
---
# IssueSyncer #formatTimelineEvent crashes on null assignee/label/subIssue/parent/etc (defensive null-safety needed)

## 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:

```javascript
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")`

## Timeline

- 2026-05-16T17:20:06Z @neo-opus-ada added the `bug` label
- 2026-05-16T17:20:06Z @neo-opus-ada added the `ai` label
- 2026-05-16T17:20:06Z @neo-opus-ada added the `architecture` label
- 2026-05-16T17:20:14Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-16T17:23:08Z @neo-opus-ada cross-referenced by PR #11476
- 2026-05-16T17:25:50Z @neo-opus-ada cross-referenced by #11477
- 2026-05-16T17:40:48Z @tobiu closed this issue
- 2026-05-16T17:53:52Z @neo-opus-ada cross-referenced by #11481
- 2026-05-16T17:58:43Z @neo-opus-ada cross-referenced by PR #11482
- 2026-05-16T18:28:58Z @tobiu referenced in commit `74f7a00` - "fix(github-workflow/sync): comprehensive null-safety sweep across IssueSyncer + ghost-issue regression test (#11481) (#11482)

Follow-up to #11474 / PR #11476's whack-a-mole gap. PR #11476 fixed
`#formatTimelineEvent` but did NOT sweep `#formatIssueMarkdown` (frontmatter
assembly) or `#getIssuePath` / `#planBuckets` (labels-derived dropped-label
matching). Operator re-ran `npm run ai:sync-github-workflow` post-merge of
#11476; sync processed past v8.1.0/chunk-28 then crashed:

    ❌ Sync failed: TypeError: Cannot read properties of null (reading 'login')
        at #formatIssueMarkdown (IssueSyncer.mjs:145:46)

Line 145: `issue.author.login` — issue with deleted GitHub author (Ghost user).
SAME null-deref class as #11474 but in the per-issue frontmatter assembly,
not the per-event timeline rendering.

Operator framing 2026-05-16: "we need unit testing to ensure it works."

Comprehensive sweep:

- `#formatIssueMarkdown` (lines 135-177): optional-chaining + fallback markers
  across all nullable sites (title / labels / assignees / author / parent /
  subIssues / blockedBy / blocking). Fallback conventions match #11476's:
  'Ghost' for users; '(no title)' for missing entity titles; filter-out for
  null list entries.

- `#getIssuePath` (line 421): labels-mapping for droppedLabels check now uses
  `l?.name?.toLowerCase()` + `.filter(Boolean)` instead of `l.name.toLowerCase()`.
  Null label nodes / null label names no longer crash.

- `#planBuckets` (line 321): same labels-mapping pattern as #getIssuePath; same
  null-safety applied.

Comprehensive unit test: new "ghost issue" regression test exercises ALL
nullable frontmatter sites simultaneously (null author, null label nodes,
null label names, null assignee logins, null subIssue titles, null blockedBy
entries). Drives through refetchIssuesByNumber → verifies no crash + correct
fallback markers + no literal undefined/null leaks. Test asserts:

- author: 'Ghost' (the empirical crash, now safe)
- title: '(no title)' (null title fallback)
- labels: filtered to valid 'bug' only (null entries + null names dropped)
- assignees: filtered to valid 'tobiu' only (same)
- subIssues: null nodes filtered; null titles get '(no title)' marker
- blockedBy: same pattern
- No literal undefined/null artifacts anywhere

V-B-A: 7/7 IssueSyncer specs pass. Test ALSO exercises #getIssuePath's
labels-deref via the refetchIssuesByNumber pipeline (mock issue has null
label nodes which would have crashed #getIssuePath before the fix).

Sibling syncers already correct:
- PullRequestSyncer.mjs:250 — `pr.author?.login || 'unknown'` ✓
- DiscussionSyncer.mjs:244+256 — `?.login || 'unknown'` ✓

This PR brings IssueSyncer to the same defensive standard.

Avoided trap (the trap THIS ticket existed to break): whack-a-mole — the
prior PR fixed one method while leaving sibling methods in same file with
identical pattern. This PR sweeps the file end-to-end via fully-null mock
exercising the entire pipeline.

Resolves #11481

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"

