# Research: Narrative Report Shows Raw GitHub Usernames Instead of Canonical Names

**Date**: 2026-02-27
**Topic**: Developer name display in narrative reports — why `chungk-duetto` shows instead of `Chung Kim`

---

## Summary of Findings

There are **two distinct but related gaps** that together explain the problem. The narrative report
uses developer names from two different code paths, and each has its own failure mode.

---

## 1. The Full Code Path

```
pipeline_report.py::run_report()
    ↓ loads commits from DB, sets commit["canonical_id"] = cached_commit.author_email
    ↓ identity_resolver.update_commit_stats(all_commits)
        → resolves each commit's author_name/author_email → canonical_id
        → writes commit["canonical_id"] = resolved canonical_id   (UUID)
        → writes commit["canonical_name"] = identity_resolver.get_canonical_name(canonical_id)
    ↓ identity_resolver.get_developer_stats() → list[dict] with primary_name (canonical)
    ↓
    ├─ analytics_gen.generate_activity_distribution_report(all_commits, developer_stats, ...)
    │    → writes activity_distribution_*.csv
    │    → developer column = _get_canonical_display_name(dev_id, dev["primary_name"])
    │       which calls identity_resolver.get_canonical_name(canonical_id) → correct name
    │
    ├─ analytics_gen.generate_developer_focus_report(all_commits, developer_stats, ...)
    │    → writes developer_focus_*.csv
    │    → developer column = _get_canonical_display_name(...) → correct name
    │
    └─ NarrativeReportGenerator.generate_narrative_report(
            all_commits,          ← each commit has canonical_id (UUID) + canonical_name
            developer_stats,      ← each entry has primary_name (canonical)
            activity_data,        ← read from activity_distribution CSV → "developer" column
            focus_data,           ← read from developer_focus CSV → "developer" column
            ...
       )
           ↓
           _write_team_composition(developer_stats, focus_data, commits, ...)
               → iterates developer_stats keyed by canonical_id
               → name = dev.get("primary_name", dev.get("name", "Unknown Developer"))
               → lookup: focus_lookup = {d["developer"]: d for d in focus_data}
                         if name in focus_lookup:  ← uses primary_name as key
```

---

## 2. Gap A — The Timing Race Condition (Primary Bug)

**Location**: `pipeline_report.py`, lines 91–113, and `identity_stats.py::update_commit_stats()`

```python
# pipeline_report.py lines 91-102
for cached_commit in cached_commits_rows:
    commit_dict = cache._commit_to_dict(cached_commit)
    commit_dict["canonical_id"] = cached_commit.author_email or "unknown"  # ← WRONG: email, not UUID
    all_commits.append(commit_dict)

# Then:
identity_resolver = DeveloperIdentityResolver(...)
identity_resolver.update_commit_stats(all_commits)  # ← fixes canonical_id to UUID + sets canonical_name
```

`update_commit_stats()` in `identity_stats.py` lines 126–130:
```python
canonical_id = self.resolve_developer(commit["author_name"], commit["author_email"])
commit["canonical_id"] = canonical_id          # → UUID (correct)
commit["canonical_name"] = self.get_canonical_name(canonical_id)  # → primary_name from DB
```

**BUT** — `update_commit_stats()` also calls `apply_manual_mappings()` at the END (line 171):
```python
# After all identities are created
if self.manual_mappings:
    self.apply_manual_mappings()  # ← runs AFTER all commits are processed
```

And `_apply_manual_mappings()` updates `primary_name` in the DB and then calls
`self._cache.clear(); self._load_cache()`. BUT commit dicts that were already updated with
`canonical_name` in the loop above are **not re-updated**. So `commit["canonical_name"]`
holds the pre-mapping name (e.g., `"chungk-duetto"`) not the post-mapping name (`"Chung Kim"`).

### Why developer_stats IS correct

`get_developer_stats()` is called after `update_commit_stats()` completes. By then,
`apply_manual_mappings()` has run and the DB has `primary_name = "Chung Kim"`. The session
query in `get_developer_stats()` reads fresh data from DB → returns correct `primary_name`.

---

## 3. Gap B — The Chung Kim Noreply Email Is Missing as an Alias

**Config entry** (`gitflow-all-engineers.yaml`):
```yaml
"Chung Kim":
  - "chungk-duetto"
  - "chung.kim@duettoresearch.com"
```

The legacy format is converted in `loader.py` lines 268–275:
```python
for canonical_name, emails in data["developer_aliases"].items():
    if isinstance(emails, list) and emails:
        primary_email = emails[0]   # → "chungk-duetto"  ← NOT a valid email
        data["analysis"]["identity"]["manual_mappings"].append(
            {"name": canonical_name, "primary_email": primary_email, "aliases": emails}
        )
```

`"chungk-duetto"` is a **GitHub username**, not an email address. It becomes the
`primary_email` key in the identity DB. When commits arrive with:
```
author_email = "61434073+chungk-duetto@users.noreply.github.com"
```
the resolver calls `resolve_developer("chungk-duetto", "61434073+chungk-duetto@users.noreply.github.com")`.
It checks `DeveloperAlias.email == alias_email` for the alias email. The stored aliases are
`["chungk-duetto", "chung.kim@duettoresearch.com"]` — neither matches the noreply address.
So the resolver creates a **new, separate identity** with:
- `primary_email = "61434073+chungk-duetto@users.noreply.github.com"`
- `primary_name = "chungk-duetto"` (the git author name from the commit)

This new identity has **no name mapping** applied to it, so it remains `"chungk-duetto"` in the
report.

---

## 4. Which Developer Name Does the Narrative Report Use?

### Team Composition section (`_write_team_composition`)
```python
name = dev.get("primary_name", dev.get("name", "Unknown Developer"))
report.write(f"**{name}**\n")
```
Source: `developer_stats` list → `primary_name` field → from `DeveloperIdentity.primary_name` DB column.

For developers where identity resolution worked correctly (matched and merged), this shows
the canonical name. For `chungk-duetto`'s noreply commits — they create a SEPARATE identity
with `primary_name = "chungk-duetto"`, so the report shows `**chungk-duetto**`.

### Focus data lookup
```python
focus_lookup = {d["developer"]: d for d in focus_data}
if name in focus_lookup:  # uses primary_name as key
```
The `developer` column in `developer_focus_*.csv` comes from `_get_canonical_display_name()`
which calls `identity_resolver.get_canonical_name()`. If the identity was created with
`primary_name = "chungk-duetto"`, this also returns `"chungk-duetto"`. So focus data
is retrieved with the wrong name but at least consistently wrong.

### Project Activity section (`_write_project_activity`)
```python
developer = row.get("developer", "Unknown Developer")
# row["developer"] comes from activity_distribution CSV
```
The `activity_distribution_*.csv` is produced by `generate_activity_distribution_report()` using:
```python
dev_name = _get_canonical_display_name(dev_id, developer.get("primary_name", "Unknown"))
```
Same problem: if dev_id maps to an identity with `primary_name = "chungk-duetto"`, the CSV
has `"chungk-duetto"` in the `developer` column, and the narrative report copies it verbatim.

### Executive Summary top contributor
```python
dev_name = top_dev.get("primary_name", top_dev.get("name", "Unknown Developer"))
```
Same source: developer_stats primary_name. Shows `"sthapa-duetto"` for Suraj Thapa's noreply
commits or anyone whose noreply email created a separate identity.

---

## 5. Is This Narrative-Report-Only or All Report Types?

**All report types are affected**, not just narrative. The root cause is in the identity
database — a separate, un-canonicalized identity is created. All report generators read
from the same identity DB:

| Report | Field Used | Affected? |
|--------|-----------|-----------|
| Narrative (team composition) | `developer_stats["primary_name"]` | Yes |
| Narrative (project activity) | `activity_distribution["developer"]` | Yes |
| Narrative (top contributor) | `developer_stats["primary_name"]` | Yes |
| CSV developer_activity_summary | `_get_canonical_display_name()` | Yes |
| CSV activity_distribution | `_get_canonical_display_name()` | Yes |
| CSV developer_focus | `_get_canonical_display_name()` | Yes |
| CSV weekly_trends top_developer | `_get_canonical_display_name()` | Yes |

---

## 6. Required Fixes

### Fix 1 — Add noreply email as an alias in the config (Quick fix, immediate)

Update the `gitflow-all-engineers.yaml` entry for Chung Kim to include the noreply address:

```yaml
"Chung Kim":
  - "chungk-duetto"
  - "chung.kim@duettoresearch.com"
  - "61434073+chungk-duetto@users.noreply.github.com"
```

This is the only fix needed if the issue is just the missing noreply email. The same pattern
applies to any developer whose commits arrive with GitHub's `{id}+{username}@users.noreply.github.com`
address.

### Fix 2 — First alias entry should be a real email, not a GitHub username (Config hygiene)

In the loader's legacy conversion, `emails[0]` becomes `primary_email`. If `emails[0]` is a
GitHub username like `"chungk-duetto"` rather than an email address, the identity DB stores a
non-email as the primary key. This causes confusing behavior.

**Option A**: Update config so the real corporate email is first:
```yaml
"Chung Kim":
  - "chung.kim@duettoresearch.com"      # ← real email first
  - "chungk-duetto"                      # ← username alias
  - "61434073+chungk-duetto@users.noreply.github.com"
```

**Option B**: Fix the loader to detect and skip non-email first entries, picking the
first actual email as `primary_email`.

### Fix 3 — Re-apply canonical_name to commits after manual mappings run (Code fix)

In `identity_stats.py::update_commit_stats()`, after `apply_manual_mappings()`, the canonical
names that were written to commit dicts earlier in the loop are stale. Fix: do a second pass
to update `canonical_name` after mappings are applied:

```python
# In update_commit_stats(), after the main loop:
if self.manual_mappings:
    self.apply_manual_mappings()
    # Re-apply canonical names now that mappings have been applied
    for commit in commits:
        cid = commit.get("canonical_id")
        if cid:
            commit["canonical_name"] = self.get_canonical_name(cid)
```

Note: For `developer_stats`, this is not needed because `get_developer_stats()` runs after
`update_commit_stats()` completes and reads fresh DB data. But commit-level `canonical_name`
fields are stale.

### Fix 4 — Clear the identities.db and re-run (Remediation for existing bad data)

Because the wrong identity was already created and persisted in the identities DB, adding
the noreply alias to the config may not immediately fix the problem. The resolver may find
the old separate identity and keep it. Two options:

1. Delete the `identities.db` file from the cache directory to force a fresh rebuild on next run.
2. Run `gitflow identity merge` (if available) to manually merge the two identities.

---

## 7. Where canonical_name vs primary_name Is Used

| Location | Field | Source |
|----------|-------|--------|
| `commit["canonical_id"]` (after update_commit_stats) | UUID | identity DB |
| `commit["canonical_name"]` (after update_commit_stats) | primary_name at time of resolution | identity DB |
| `developer_stats[]["primary_name"]` | Authoritative canonical name | identity DB (post-mapping) |
| `activity_distribution["developer"]` | `_get_canonical_display_name()` at report time | identity DB (post-mapping) |
| narrative `**{name}**` | `dev.get("primary_name")` from developer_stats | identity DB (post-mapping) |

The narrative report's developer profiles use `developer_stats["primary_name"]`, which IS the
correct post-mapping name — but only if the identity was successfully merged. If a separate
identity was created (due to missing alias), `primary_name` is the raw commit author name.

---

## 8. Conclusion

The root cause is **Gap B** (missing noreply email in alias config) creating a separate
un-mapped identity. **Gap A** (stale canonical_name in commit dicts) is a secondary issue
that only matters for code using `commit["canonical_name"]` directly rather than
`developer_stats["primary_name"]`.

The immediate fix is to add `"61434073+chungk-duetto@users.noreply.github.com"` to Chung Kim's
alias list, reorder so a real email is `primary_email`, and delete identities.db to reset the
persisted wrong state.
