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