diff --git a/scripts/test_yunxiao_github_sync.py b/scripts/test_yunxiao_github_sync.py index 3cda86526..a5aac37d9 100644 --- a/scripts/test_yunxiao_github_sync.py +++ b/scripts/test_yunxiao_github_sync.py @@ -356,6 +356,45 @@ def test_description_source_requires_the_leading_source_line(self): self.assertIsNone(MODULE.description_source_url(f"Body mentions\nGitHub: {url}")) self.assertIsNone(MODULE.description_source_url(f"GitHub: {url}#issuecomment-1")) + def test_legacy_source_sections_accept_bom_and_paragraph_whitespace(self): + issue_url = MODULE.source_url("issue", 394, REPOSITORY) + pr_url = MODULE.source_url("pr", 395, REPOSITORY) + cases = ( + (f"来源:GitHub Issue #394 \ufeff{issue_url}\ufeff \ufeff 问题现象", issue_url), + (f"来源:GitHub PR #395\n\n{pr_url}\n\n问题\nDetails", pr_url), + (f'
来源:GitHub PR #395
{pr_url}
Details
', pr_url), + ) + for description, expected in cases: + with self.subTest(description=description): + self.assertEqual(MODULE.description_source_url(description), expected.casefold()) + + def test_legacy_source_sections_reject_quoted_or_mismatched_sources(self): + for description in ( + "Body first\n来源:GitHub Issue #394\nhttps://github.com/MemTensor/memmy-agent/issues/394", + "来源:GitHub Issue #394\nhttps://github.com/MemTensor/memmy-agent/issues/395", + "来源:GitHub PR #395\nhttps://github.com/MemTensor/memmy-agent/issues/395", + "来源:GitHub Issue #394\nhttps://example.com/MemTensor/memmy-agent/issues/394", + "来源:GitHub Issue #394\nhttps://github.com/MemTensor/memmy-agent/issues/394#issuecomment-1", + "来源:GitHub Issue #394\nUnrelated text\nhttps://github.com/MemTensor/memmy-agent/issues/394", + ): + with self.subTest(description=description): + self.assertIsNone(MODULE.description_source_url(description)) + + def test_title_cleanup_accepts_verified_legacy_source_sections(self): + for kind, number in (("issue", 394), ("pr", 395)): + with self.subTest(kind=kind): + card = source_card(number=number, kind=kind, form="long") + card["description"] = ( + f"来源:GitHub {MODULE.SOURCE_LABELS[kind]} #{number}\n\n" + f"{MODULE.source_url(kind, number, REPOSITORY)}\n\nDetails" + ) + transport = MemoryTransport([card]) + result, stats = self.rename(transport, apply=True) + self.assertEqual((result, stats["renamed"], stats["failed"]), (0, 1, 0)) + self.assertEqual(transport.cards["card"]["description"], card["description"]) + writes = [body for method, _, body in transport.calls if method == "PUT"] + self.assertEqual(writes, [{"subject": f"[{MODULE.SOURCE_LABELS[kind]} #{number}] Fix scheduler"}]) + def test_sync_after_create_and_after_rename_reuses_the_same_card(self): transport = MemoryTransport() self.assertEqual(self.sync(transport), "created") diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py index 4f8a00314..8bee1a1d5 100644 --- a/scripts/yunxiao_github_sync.py +++ b/scripts/yunxiao_github_sync.py @@ -170,7 +170,20 @@ def description_source_url(description: Any) -> str | None: r"GitHub:\s+(https://github\.com/[^/\s]+/[^/\s]+/(?:issues|pull)/[1-9][0-9]*/?)", first_line, ) - return match.group(1).rstrip("/").casefold() if match else None + if match: + return match.group(1).rstrip("/").casefold() + # Early manually imported cards used a source section before the URL. + legacy = re.match( + r"\A来源[::]\s*GitHub\s+(Issue|PR)\s+#([1-9][0-9]*)\s+" + r"(https://github\.com/[^/\s]+/[^/\s]+/(issues|pull)/([1-9][0-9]*)/?)(?=\s|$)", + "".join(parser.parts).replace("\ufeff", " ").lstrip(), + ) + if not legacy: + return None + label, number, url, path, url_number = legacy.groups() + if number != url_number or {"Issue": "issues", "PR": "pull"}[label] != path: + return None + return url.rstrip("/").casefold() def iso_after_days(value: str, days: int) -> str: