blob: 83fa547763ae90dfbcafb204befc830adcddafb5 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import pytest
from github_body_field.parser import (
FieldNotFoundError,
extract_field,
list_fields,
replace_field,
)
CANONICAL_BODY = (
"<!-- a preamble comment from the issue template -->\n"
"\n"
"### CVE tool link\n"
"\n"
"https://cveprocess.apache.org/cve5/CVE-2026-12345\n"
"\n"
"### Reporter credited as\n"
"\n"
"anonymous\n"
"\n"
"### Short public summary for publish\n"
"\n"
"Upgrade to apache-airflow 3.3.0 or later.\n"
"\n"
"### Affected versions\n"
"\n"
"`>= 3.0.0, < 3.3.0`\n"
)
# ---------------------------------------------------------------------------
# list_fields
# ---------------------------------------------------------------------------
def test_list_fields_returns_headings_in_order():
assert list_fields(CANONICAL_BODY) == [
"CVE tool link",
"Reporter credited as",
"Short public summary for publish",
"Affected versions",
]
def test_list_fields_empty_body():
assert list_fields("") == []
def test_list_fields_no_headings():
assert list_fields("just some prose, no headings.\n") == []
def test_list_fields_ignores_headings_in_code_fences():
body = "### Real field\n\n```bash\n### not a heading\necho hi\n```\n\n### Another real\n\nvalue\n"
assert list_fields(body) == ["Real field", "Another real"]
def test_list_fields_ignores_headings_in_tilde_fences():
body = "### Real field\n\n~~~\n### still inside a fence\n~~~\n\n### Next real\n\nvalue\n"
assert list_fields(body) == ["Real field", "Next real"]
def test_list_fields_does_not_match_h2_or_h4():
body = "## Not a field (h2)\n\n### A real field\n\nvalue\n\n#### Subsection (h4)\n\nmore content\n"
assert list_fields(body) == ["A real field"]
def test_list_fields_trims_trailing_whitespace_in_heading_name():
body = "### Field with trailing space \n\nvalue\n"
assert list_fields(body) == ["Field with trailing space"]
def test_list_fields_reports_duplicates_as_separate_entries():
body = "### Same\n\nvalue 1\n\n### Same\n\nvalue 2\n"
assert list_fields(body) == ["Same", "Same"]
# ---------------------------------------------------------------------------
# extract_field
# ---------------------------------------------------------------------------
def test_extract_field_middle_section():
assert extract_field(CANONICAL_BODY, "Reporter credited as") == "anonymous\n"
def test_extract_field_first_section():
assert (
extract_field(CANONICAL_BODY, "CVE tool link")
== "https://cveprocess.apache.org/cve5/CVE-2026-12345\n"
)
def test_extract_field_last_section():
assert extract_field(CANONICAL_BODY, "Affected versions") == "`>= 3.0.0, < 3.3.0`\n"
def test_extract_field_multiline_value():
body = "### Notes\n\nline 1\nline 2\n\nline 4 (internal blank preserved)\n\n### Next\n\nx\n"
assert extract_field(body, "Notes") == ("line 1\nline 2\n\nline 4 (internal blank preserved)\n")
def test_extract_field_value_with_inline_code_fence_containing_hash():
body = (
"### Repro\n"
"\n"
"```python\n"
"### this looks like a heading but is inside a fence\n"
"print(1)\n"
"```\n"
"\n"
"### Next\n"
"\n"
"x\n"
)
assert extract_field(body, "Repro") == (
"```python\n### this looks like a heading but is inside a fence\nprint(1)\n```\n"
)
def test_extract_field_missing_raises():
with pytest.raises(FieldNotFoundError, match="not found"):
extract_field(CANONICAL_BODY, "Nonexistent")
def test_extract_field_duplicate_raises():
body = "### Same\n\nv1\n\n### Same\n\nv2\n"
with pytest.raises(FieldNotFoundError, match="appears 2 times"):
extract_field(body, "Same")
# ---------------------------------------------------------------------------
# replace_field
# ---------------------------------------------------------------------------
def test_replace_field_keeps_other_sections_byte_exact():
new = replace_field(CANONICAL_BODY, "Reporter credited as", "Jane Doe (@jdoe)\n")
# The unchanged sections should round-trip identically.
assert "### CVE tool link\n\nhttps://cveprocess.apache.org/cve5/CVE-2026-12345\n\n" in new
assert "### Short public summary for publish\n\nUpgrade to apache-airflow 3.3.0 or later.\n\n" in new
assert "### Affected versions\n\n`>= 3.0.0, < 3.3.0`\n" in new
# The targeted section now carries the new value.
assert "### Reporter credited as\n\nJane Doe (@jdoe)\n\n" in new
# And the old value is gone.
assert "anonymous" not in new
def test_replace_field_with_value_missing_trailing_newline():
"""A value passed without a trailing \\n should still produce a
well-formed section (no glued-on next heading)."""
new = replace_field(CANONICAL_BODY, "Reporter credited as", "Jane")
assert "### Reporter credited as\n\nJane\n\n### Short public summary" in new
def test_replace_field_preserves_spacer_blank_line():
new = replace_field(CANONICAL_BODY, "Reporter credited as", "x")
# The blank line between "x" and the next heading must survive.
assert "### Reporter credited as\n\nx\n\n### Short public summary" in new
def test_replace_field_idempotent_when_value_unchanged():
same = extract_field(CANONICAL_BODY, "Reporter credited as")
new = replace_field(CANONICAL_BODY, "Reporter credited as", same)
assert new == CANONICAL_BODY
def test_replace_field_last_section_does_not_pad_with_extra_blank():
new = replace_field(CANONICAL_BODY, "Affected versions", "`< 3.3.0`")
# Last section: original body ended exactly one newline after the
# value, so the replacement should too — no extra blank.
assert new.endswith("### Affected versions\n\n`< 3.3.0`\n")
def test_replace_field_preserves_preamble_byte_exact():
new = replace_field(CANONICAL_BODY, "CVE tool link", "https://example.test/x")
assert new.startswith("<!-- a preamble comment from the issue template -->\n\n")
def test_replace_field_does_not_treat_fenced_heading_as_section_break():
"""A `### Foo` line inside a fenced code block must not fool the
parser into ending the current section. We verify by replacing a
*different* section and showing the fenced block in section A
survives byte-exact."""
body = "### A\n\n```\n### Bogus heading inside fence\necho hi\n```\n\n### B\n\nv\n"
new = replace_field(body, "B", "new_v\n")
assert "```\n### Bogus heading inside fence\necho hi\n```" in new
assert new.startswith("### A\n\n```\n### Bogus heading inside fence\necho hi\n```\n\n")
assert new.endswith("### B\n\nnew_v\n")
def test_replace_field_value_can_contain_inline_code_with_pound_signs():
new = replace_field(CANONICAL_BODY, "Affected versions", "Comment: see `### Field` in the docs.\n")
assert "### Affected versions\n\nComment: see `### Field` in the docs.\n" in new
def test_replace_field_missing_raises_without_mutating():
body = CANONICAL_BODY
with pytest.raises(FieldNotFoundError):
replace_field(body, "Nope", "x")
def test_replace_field_duplicate_raises_without_mutating():
body = "### Same\n\nv1\n\n### Same\n\nv2\n"
with pytest.raises(FieldNotFoundError, match="appears 2 times"):
replace_field(body, "Same", "new")
def test_replace_field_value_with_multiple_trailing_blanks_normalised():
"""A caller passing a value padded with extra trailing blanks
should not stack blank lines on top of the section spacer."""
new = replace_field(CANONICAL_BODY, "Reporter credited as", "jane\n\n\n\n")
# Exactly one spacer blank between the new value and the next heading.
assert "### Reporter credited as\n\njane\n\n### Short public summary" in new
def test_replace_field_empty_value():
new = replace_field(CANONICAL_BODY, "Reporter credited as", "")
# Empty value still produces a well-formed section: the heading, a
# single newline (to terminate the heading line), and the spacer.
assert "### Reporter credited as\n\n### Short public summary" in new
def test_replace_field_field_with_no_spacer_before_next_heading():
"""Some legacy bodies pack headings without a blank-line spacer
between sections. Replacement should still not break the next
heading."""
body = "### A\nv1\n### B\nv2\n"
new = replace_field(body, "A", "new1\n")
# No spacer originally → don't invent one.
assert new == "### A\nnew1\n### B\nv2\n"