fix(notion-sync): re-key a row matched by URL when its Key has drifted (#761)

* fix(notion-sync): re-key a row matched by URL when its Key has drifted

`tools/job_key.py --audit --migrate` (#637) renames keys in
seen_jobs.json, but Step 4 upserted on Key alone, so the next sync
created a duplicate row for every migrated key. On a Key miss, look up
keyed rows whose URL equals the job's URL exactly: one match gets the
new Key and is updated, several are reported and left untouched, none
creates as before. Rows without a Key stay invisible, and the report
counts re-keyed rows separately.

Shape from Mads Lorentzen's review on #637.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop the CHANGELOG hunk, frozen by #800 (release notes come from the PR title)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(notion-sync): pin the key-collision guard and name the skip counter for both ambiguous cases

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Christian VangandClaude Opus 5.5 authored and GitHub committed 2026-11-03 20:20:19 +01:00
1 parent a3ba937be8
commit a813602972
2 files changed
+34 -6

No files matched your search

+24
View File
@@ -80,6 +80,30 @@ class NotionSyncCommandSpec(unittest.TestCase):
self.assertIn("Match, expired-only entry** → update **Status only** to `expired`", step4)
self.assertIn("Never write or rebuild a body for an expired-only entry, including when `--rebuild` is set.", step5)
def test_key_miss_falls_back_to_an_exact_url_match_and_rekeys_the_row(self):
# `job_key.py --audit --migrate` (#637) renames keys in seen_jobs.json, but
# Step 4 upserted on Key alone, so the next sync created a second row for
# every migrated job. On a Key miss, one keyed row with the same URL gets
# the new Key; several are reported and left alone.
text = COMMAND.read_text(encoding="utf-8")
step4 = text.split("## Step 4:", 1)[1].split("## Step 5:", 1)[0]
step6 = text.split("## Step 6:", 1)[1].split("## Important Rules", 1)[0]
rules = text.split("## Important Rules", 1)[1].split("## Adapting", 1)[0]
self.assertIn("**No match on `Key`** → fall back to the posting URL before creating anything", step4)
self.assertIn("whose `URL` equals the job's URL exactly", step4)
self.assertIn("**Exactly one URL match** → re-key it: set its `Key` to the job's key", step4)
self.assertIn("If the row's current `Key` is the key of another job in this sync set, it is not drifted; treat it like a multiple match instead.", step4)
self.assertIn("**More than one URL match** → create nothing and update nothing for this job", step4)
self.assertIn("**No URL match** → if the job is an expired-only entry, skip it", step4)
# Hand-added rows (no Key) stay invisible, fallback included.
self.assertIn("**and** whose `Key` is non-empty", step4)
self.assertIn("invisible to this command, including to the URL fallback", step4)
self.assertIn("<R> re-keyed by URL", step6)
self.assertIn("<A> skipped (ambiguous URL match)", step6)
self.assertIn("never on fuzzy title comparison", rules)
self.assertIn("exact match on the `URL` property", rules)
@unittest.skipUnless(
_HAVE_YAML,
"PyYAML not installed (the CI Python-test job omits it; the lint job runs lint_skills.py directly)",