diff --git a/.github/actions/dataconnect-send-notifications/action.yml b/.github/actions/dataconnect-send-notifications/action.yml new file mode 100644 index 00000000000..27133b5031c --- /dev/null +++ b/.github/actions/dataconnect-send-notifications/action.yml @@ -0,0 +1,71 @@ +name: Data Connect Workflow Notifications +description: Notify a GitHub Issue with the results of a workflow. + +inputs: + python-version: + required: true + default: "3.13" + github-issue-for-scheduled-runs: + required: true + job-results-file: + required: true + +runs: + using: "composite" + steps: + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ inputs.python-version }} + + - run: pip install -r requirements.txt + shell: bash + working-directory: firebase-dataconnect/ci + + - id: issue-id + name: Determine GitHub Issue For Commenting + working-directory: firebase-dataconnect/ci + shell: bash + run: | + args=( + python + calculate_github_issue_for_commenting.py + --issue-output-file=github_issue_number.txt + --github-repository='${{ github.repository }}' + --github-ref='${{ github.ref }}' + --github-event-name='${{ github.event_name }}' + --pr-body-github-issue-key=trksmnkncd_notification_issue + --github-issue-for-scheduled-run='${{ inputs.github-issue-for-scheduled-runs }}' + ) + echo "${args[*]}" + "${args[@]}" + + set -xv + issue="$(cat github_issue_number.txt)" + echo "issue=$issue" >> "$GITHUB_OUTPUT" + + - name: Post Comment on GitHub Issue + if: steps.issue-id.outputs.issue != '' + working-directory: firebase-dataconnect/ci + shell: bash + run: | + args=( + python + post_comment_for_job_results.py + --github-issue='${{ steps.issue-id.outputs.issue }}' + --github-workflow='${{ github.workflow }}' + --github-repository='${{ github.repository }}' + --github-ref='${{ github.ref }}' + --github-event-name='${{ github.event_name }}' + --github-sha='${{ github.sha }}' + --github-repository-html-url='${{ github.event.repository.html_url }}' + --github-run-id='${{ github.run_id }}' + --github-run-number='${{ github.run_number }}' + --github-run-attempt='${{ github.run_attempt }}' + ) + + while read -r line; do + args=("${args[@]}" "$line") + done <'${{ inputs.job-results-file }}' + + echo "${args[*]}" + exec "${args[@]}" diff --git a/.github/workflows/dataconnect.yml b/.github/workflows/dataconnect.yml index f5a58ef6896..038145c897e 100644 --- a/.github/workflows/dataconnect.yml +++ b/.github/workflows/dataconnect.yml @@ -9,6 +9,7 @@ on: firebaseToolsVersion: gradleInfoLog: type: boolean + pythonVersion: pull_request: paths: - .github/workflows/dataconnect.yml @@ -27,6 +28,7 @@ env: FDC_FIREBASE_TOOLS_VERSION: ${{ inputs.firebaseToolsVersion || '13.29.1' }} FDC_FIREBASE_TOOLS_DIR: /tmp/firebase-tools FDC_FIREBASE_COMMAND: /tmp/firebase-tools/node_modules/.bin/firebase + FDC_PYTHON_VERSION: ${{ inputs.pythonVersion || '3.13' }} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -256,30 +258,122 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: show-progress: false + sparse-checkout: '.github/' - uses: docker://rhysd/actionlint:1.7.7 with: args: -color /github/workspace/.github/workflows/dataconnect.yml + python-ci-unit-tests: + continue-on-error: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: 'firebase-dataconnect/ci/' + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + - run: pip install -r firebase-dataconnect/ci/requirements.txt + - name: pytest + working-directory: firebase-dataconnect/ci + run: pytest --verbose --full-trace --color=no --strict-config + + python-ci-lint: + continue-on-error: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: 'firebase-dataconnect/ci/' + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + - run: pip install -r firebase-dataconnect/ci/requirements.txt + - name: ruff check + working-directory: firebase-dataconnect/ci + run: ruff check --diff --verbose --no-cache --output-format=github --exit-non-zero-on-fix + + python-ci-format: + continue-on-error: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: 'firebase-dataconnect/ci/' + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + - run: pip install -r firebase-dataconnect/ci/requirements.txt + - name: ruff format + working-directory: firebase-dataconnect/ci + run: ruff format --diff --verbose --no-cache + + python-ci-type-check: + continue-on-error: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: 'firebase-dataconnect/ci/' + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ env.FDC_PYTHON_VERSION }} + - run: pip install -r firebase-dataconnect/ci/requirements.txt + - name: pyright + working-directory: firebase-dataconnect/ci + run: pyright --warnings --stats + + # The "send-notifications" job adds a comment to GitHub Issue + # https://github.com/firebase/firebase-android-sdk/issues/6857 with the results of the scheduled + # nightly runs. Interested parties can then subscribe to that issue to be aprised of the outcome + # of the nightly runs. + # + # When testing the comment-adding logic itself, you can add the line + # trksmnkncd_notification_issue=6863 + # into the PR's description to instead post a comment to issue #6863, an issue specifically + # created for testing, avoiding spamming the main issue to which others are subscribed. send-notifications: - needs: [integration-test, actionlint-dataconnect-yml] + needs: + - 'integration-test' + - 'actionlint-dataconnect-yml' + - 'python-ci-unit-tests' + - 'python-ci-lint' + - 'python-ci-format' + - 'python-ci-type-check' if: always() permissions: issues: write runs-on: ubuntu-latest steps: - - name: Post Comment on Issue #6857 - if: github.event_name == 'schedule' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + show-progress: false + sparse-checkout: | + firebase-dataconnect/ci/ + .github/ + + - name: gh auth login + run: echo '${{ secrets.GITHUB_TOKEN }}' | gh auth login --with-token - cat >message.txt <'${{ runner.temp }}/job_results.txt' <'${{ runner.temp }}/job_results.txt' < None: + args = parse_args() + logging.basicConfig(format="%(message)s", level=logging.INFO) + + github_issue = calculate_github_issue( + github_event_name=args.github_event_name, + github_issue_for_scheduled_run=args.github_issue_for_scheduled_run, + github_ref=args.github_ref, + github_repository=args.github_repository, + pr_body_github_issue_key=args.pr_body_github_issue_key, + ) + + issue_file_text = "" if github_issue is None else str(github_issue) + logging.info("Writing '%s' to %s", issue_file_text, args.issue_output_file) + args.issue_output_file.write_text(issue_file_text, encoding="utf8", errors="replace") + + +def calculate_github_issue( + github_event_name: str, + github_issue_for_scheduled_run: int, + github_ref: str, + github_repository: str, + pr_body_github_issue_key: str, +) -> int | None: + if github_event_name == "schedule": + logging.info( + "GitHub Event name is: %s; using GitHub Issue: %s", + github_event_name, + github_issue_for_scheduled_run, + ) + return github_issue_for_scheduled_run + + logging.info("Extracting PR number from string: %s", github_ref) + pr_number = pr_number_from_github_ref(github_ref) + if pr_number is None: + logging.info("No PR number extracted") + return None + typing.assert_type(pr_number, int) + + logging.info("PR number extracted: %s", pr_number) + logging.info("Loading body text of PR: %s", pr_number) + pr_info = fetch_pr_info( + pr_number=pr_number, + github_repository=github_repository, + ) + + logging.info("Looking for GitHub Issue key in PR body text: %s=NNNN", pr_body_github_issue_key) + github_issue = github_issue_from_pr_body( + pr_body=pr_info.body, + issue_key=pr_body_github_issue_key, + ) + + if github_issue is None: + logging.info("No GitHub Issue key found in PR body") + return None + typing.assert_type(github_issue, int) + + logging.info("Found GitHub Issue key in PR body: %s", github_issue) + return github_issue + + +def github_issue_from_pr_body(pr_body: str, issue_key: str) -> int | None: + expr = re.compile(r"\s*" + re.escape(issue_key) + r"\s*=\s*(\d+)\s*") + for line in pr_body.splitlines(): + match = expr.fullmatch(line.strip()) + if match: + return int(match.group(1)) + return None + + +class ParsedArgs(typing.Protocol): + issue_output_file: pathlib.Path + github_ref: str + github_repository: str + github_event_name: str + pr_body_github_issue_key: str + github_issue_for_scheduled_run: int + + +def parse_args() -> ParsedArgs: + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument( + "--issue-output-file", + required=True, + help="The file to which to write the calculated issue number" + "if no issue number was found, then an empty file will be written", + ) + arg_parser.add_argument( + "--github-ref", + required=True, + help="The value of ${{ github.ref }} in the workflow", + ) + arg_parser.add_argument( + "--github-repository", + required=True, + help="The value of ${{ github.repository }} in the workflow", + ) + arg_parser.add_argument( + "--github-event-name", + required=True, + help="The value of ${{ github.event_name }} in the workflow", + ) + arg_parser.add_argument( + "--pr-body-github-issue-key", + required=True, + help="The string to search for in a Pull Request body to determine the GitHub Issue number " + "for commenting. For example, if the value is 'foobar' then this script searched a PR " + "body for a line of the form 'foobar=NNNN' where 'NNNN' is the GitHub issue number", + ) + arg_parser.add_argument( + "--github-issue-for-scheduled-run", + type=int, + required=True, + help="The GitHub Issue number to use for commenting when --github-event-name is 'schedule'", + ) + + parse_result = arg_parser.parse_args() + parse_result.issue_output_file = pathlib.Path(parse_result.issue_output_file) + return typing.cast("ParsedArgs", parse_result) + + +if __name__ == "__main__": + main() diff --git a/firebase-dataconnect/ci/calculate_github_issue_for_commenting_test.py b/firebase-dataconnect/ci/calculate_github_issue_for_commenting_test.py new file mode 100644 index 00000000000..a7e1df7da03 --- /dev/null +++ b/firebase-dataconnect/ci/calculate_github_issue_for_commenting_test.py @@ -0,0 +1,162 @@ +# Copyright 2025 Google LLC +# +# Licensed 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 hypothesis +import hypothesis.strategies as st +import pytest + +import calculate_github_issue_for_commenting as sut + + +class Test_pr_number_from_github_ref: + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_number_from_valid_github_ref(self, number: int) -> None: + github_ref = f"refs/pull/{number}/merge" + assert sut.pr_number_from_github_ref(github_ref) == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_ignores_leading_zeroes(self, number: int) -> None: + github_ref = f"refs/pull/0{number}/merge" + assert sut.pr_number_from_github_ref(github_ref) == number + + @hypothesis.given(invalid_github_ref=st.text()) + def test_returns_none_on_random_input(self, invalid_github_ref: str) -> None: + assert sut.pr_number_from_github_ref(invalid_github_ref) is None + + @pytest.mark.parametrize( + "invalid_number", + [ + "", + "-1", + "123a", + "a123", + "12a34", + "1.2", + pytest.param( + "1234", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on valid int values", + strict=True, + ), + ), + ], + ) + def test_returns_none_on_invalid_number(self, invalid_number: str) -> None: + invalid_github_ref = f"refs/pull/{invalid_number}/merge" + assert sut.pr_number_from_github_ref(invalid_github_ref) is None + + @pytest.mark.parametrize( + "malformed_ref", + [ + "", + "refs", + "refs/", + "refs/pull", + "refs/pull/", + "refs/pull/1234", + "refs/pull/1234/", + "Refs/pull/1234/merge", + "refs/Pull/1234/merge", + "refs/pull/1234/Merge", + "Arefs/pull/1234/merge", + "refs/pull/1234/mergeZ", + " refs/pull/1234/merge", + "refs/pull/1234/merge ", + pytest.param( + "refs/pull/1234/merge", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on valid ref", + strict=True, + ), + ), + ], + ) + def test_returns_none_on_malformed_ref(self, malformed_ref: str) -> None: + assert sut.pr_number_from_github_ref(malformed_ref) is None + + +class Test_github_issue_from_pr_body: + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_number(self, number: int) -> None: + text = f"zzyzx={number}" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_ignores_leading_zeroes(self, number: int) -> None: + text = f"zzyzx=0{number}" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_ignores_whitespace(self, number: int) -> None: + text = f" zzyzx = {number} " + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given( + number1=st.integers(min_value=0, max_value=10000), + number2=st.integers(min_value=0, max_value=10000), + ) + def test_does_not_ignore_whitespace_in_key(self, number1: int, number2: int) -> None: + text = f"zzyzx={number1}\n z z y z x = {number2} " + assert sut.github_issue_from_pr_body(text, "z z y z x") == number2 + + @hypothesis.given( + number1=st.integers(min_value=0, max_value=10000), + number2=st.integers(min_value=0, max_value=10000), + ) + def test_returns_first_number_ignoring_second(self, number1: int, number2: int) -> None: + text = f"zzyzx={number1}\nzzyzx={number2}" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number1 + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_first_valid_number_ignoring_invalid(self, number: int) -> None: + text = f"zzyzx=12X34\nzzyzx={number}" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_number_amidst_other_lines(self, number: int) -> None: + text = f"line 1\nline 2\nzzyzx={number}\nline 3" + assert sut.github_issue_from_pr_body(text, "zzyzx") == number + + @hypothesis.given(number=st.integers(min_value=0, max_value=10000)) + def test_returns_escapes_regex_special_chars_in_key(self, number: int) -> None: + text = f"*+={number}" + assert sut.github_issue_from_pr_body(text, "*+") == number + + @pytest.mark.parametrize( + "text", + [ + "", + "asdf", + "zzyzx=", + "=zzyzx", + "zzyzx=a", + "zzyzx=-1", + "zzyzx=a123", + "zzyzx=123a", + "zzyzx=1.2", + "a zzyzx=1234", + "zzyzx=1234 a", + pytest.param( + "zzyzx=1234", + marks=pytest.mark.xfail( + reason="make sure that the test would otherwise pass on valid text", + strict=True, + ), + ), + ], + ) + def test_returns_none_when_key_not_found_or_cannot_parse_int(self, text: str) -> None: + assert sut.github_issue_from_pr_body(text, "zzyzx") is None diff --git a/firebase-dataconnect/ci/post_comment_for_job_results.py b/firebase-dataconnect/ci/post_comment_for_job_results.py new file mode 100644 index 00000000000..738ac2c9b9d --- /dev/null +++ b/firebase-dataconnect/ci/post_comment_for_job_results.py @@ -0,0 +1,229 @@ +# Copyright 2025 Google LLC +# +# Licensed 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 argparse +import dataclasses +import logging +import pathlib +import subprocess +import tempfile +import typing + +from util import fetch_pr_info, pr_number_from_github_ref + +if typing.TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +def main() -> None: + args = parse_args() + logging.basicConfig(format="%(message)s", level=logging.INFO) + + message_lines = tuple(generate_message_lines(args)) + + issue_url = f"{args.github_repository_html_url}/issues/{args.github_issue}" + logging.info("Posting the following comment to GitHub Issue %s", issue_url) + for line in message_lines: + logging.info(line) + + message_bytes = "\n".join(message_lines).encode("utf8", errors="replace") + with tempfile.TemporaryDirectory() as tempdir_path: + message_file = pathlib.Path(tempdir_path) / "message_text.txt" + message_file.write_bytes(message_bytes) + post_github_issue_comment( + issue_number=args.github_issue, + body_file=message_file, + github_repository=args.github_repository, + ) + + +def generate_message_lines(data: ParsedArgs) -> Iterable[str]: + logging.info("Extracting PR number from string: %s", data.github_ref) + pr_number = pr_number_from_github_ref(data.github_ref) + pr_title: str | None + if pr_number is None: + logging.info("No PR number extracted") + pr_title = None + else: + pr_info = fetch_pr_info( + pr_number=pr_number, + github_repository=data.github_repository, + ) + pr_title = pr_info.title + + if pr_number is not None: + yield ( + f"Posting from Pull Request {data.github_repository_html_url}/pull/{pr_number} ({pr_title})" + ) + + yield f"Result of workflow '{data.github_workflow}' at {data.github_sha}:" + + for job_result in data.job_results: + result_symbol = "✅" if job_result.result == "success" else "❌" + yield f" - {job_result.job_id}: {result_symbol} {job_result.result}" + + yield "" + yield f"{data.github_repository_html_url}/actions/runs/{data.github_run_id}" + + yield "" + yield ( + f"event_name=`{data.github_event_name}` " + f"run_id=`{data.github_run_id}` " + f"run_number=`{data.github_run_number}` " + f"run_attempt=`{data.github_run_attempt}`" + ) + + +def post_github_issue_comment( + issue_number: int, body_file: pathlib.Path, github_repository: str +) -> None: + gh_args = post_issue_comment_gh_args( + issue_number=issue_number, body_file=body_file, github_repository=github_repository + ) + gh_args = tuple(gh_args) + logging.info("Running command: %s", subprocess.list2cmdline(gh_args)) + subprocess.check_call(gh_args) # noqa: S603 + + +def post_issue_comment_gh_args( + issue_number: int, body_file: pathlib.Path, github_repository: str +) -> Iterable[str]: + yield "gh" + yield "issue" + + yield "comment" + yield str(issue_number) + yield "--body-file" + yield str(body_file) + yield "-R" + yield github_repository + + +@dataclasses.dataclass(frozen=True) +class JobResult: + job_id: str + result: str + + @classmethod + def parse(cls, s: str) -> JobResult: + colon_index = s.find(":") + if colon_index < 0: + raise ParseError( + "no colon (:) character found in job result specification, " + "which is required to delimit the job ID from the job result" + ) + job_id = s[:colon_index] + job_result = s[colon_index + 1 :] + return cls(job_id=job_id, result=job_result) + + +class ParsedArgs(typing.Protocol): + job_results: Sequence[JobResult] + github_issue: int + github_repository: str + github_event_name: str + github_ref: str + github_workflow: str + github_sha: str + github_repository_html_url: str + github_run_id: str + github_run_number: str + github_run_attempt: str + + +class ParseError(Exception): + pass + + +def parse_args() -> ParsedArgs: + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument( + "job_results", + nargs="+", + help="The results of the jobs in question, of the form " + "'job-id:${{ needs.job-id.result }}' where 'job-id' is the id of the corresponding job " + "in the 'needs' section of the job.", + ) + arg_parser.add_argument( + "--github-issue", + type=int, + required=True, + help="The GitHub Issue number to which to post a comment", + ) + arg_parser.add_argument( + "--github-repository", + required=True, + help="The value of ${{ github.repository }} in the workflow", + ) + arg_parser.add_argument( + "--github-event-name", + required=True, + help="The value of ${{ github.event_name }} in the workflow", + ) + arg_parser.add_argument( + "--github-ref", + required=True, + help="The value of ${{ github.ref }} in the workflow", + ) + arg_parser.add_argument( + "--github-workflow", + required=True, + help="The value of ${{ github.workflow }} in the workflow", + ) + arg_parser.add_argument( + "--github-sha", + required=True, + help="The value of ${{ github.sha }} in the workflow", + ) + arg_parser.add_argument( + "--github-repository-html-url", + required=True, + help="The value of ${{ github.event.repository.html_url }} in the workflow", + ) + arg_parser.add_argument( + "--github-run-id", + required=True, + help="The value of ${{ github.run_id }} in the workflow", + ) + arg_parser.add_argument( + "--github-run-number", + required=True, + help="The value of ${{ github.run_number }} in the workflow", + ) + arg_parser.add_argument( + "--github-run-attempt", + required=True, + help="The value of ${{ github.run_attempt }} in the workflow", + ) + + parse_result = arg_parser.parse_args() + + job_results: list[JobResult] = [] + for job_result_str in parse_result.job_results: + try: + job_result = JobResult.parse(job_result_str) + except ParseError as e: + arg_parser.error(f"invalid job result specification: {job_result_str} ({e})") + typing.assert_never("the line above should have raised an exception") + else: + job_results.append(job_result) + parse_result.job_results = tuple(job_results) + + return typing.cast("ParsedArgs", parse_result) + + +if __name__ == "__main__": + main() diff --git a/firebase-dataconnect/ci/pyproject.toml b/firebase-dataconnect/ci/pyproject.toml new file mode 100644 index 00000000000..3afddaa1c6c --- /dev/null +++ b/firebase-dataconnect/ci/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "Firebase Data Connect Android SDK Continuous Integration Tools" +requires-python = ">= 3.13" + +[tool.pytest.ini_options] +addopts = "--strict-markers" + +[tool.pyright] +include = ["**/*.py"] +typeCheckingMode = "strict" + +[tool.ruff] +line-length = 100 +indent-width = 2 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "COM812", # missing-trailing-comma + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D106", # Missing docstring in public nested class + "D107", # Missing docstring in `__init__` + "D203", # incorrect-blank-line-before-class + "D211", # no-blank-line-before-class + "D212", # multi-line-summary-second-line + "E501", # Line too long (will be fixed by the formatter) + "EM101", # Exception must not use a string literal, assign to variable first + "LOG015", # root-logger-call + "TRY003", # Avoid specifying long messages outside the exception class +] + +[tool.ruff.lint.per-file-ignores] +"*_test.py" = [ + "N801", # invalid-class-name + "S101", # Use of `assert` detected +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +docstring-code-format = true diff --git a/firebase-dataconnect/ci/requirements.txt b/firebase-dataconnect/ci/requirements.txt new file mode 100644 index 00000000000..3440d3b19f8 --- /dev/null +++ b/firebase-dataconnect/ci/requirements.txt @@ -0,0 +1,11 @@ +attrs==25.3.0 +hypothesis==6.131.0 +iniconfig==2.1.0 +nodeenv==1.9.1 +packaging==24.2 +pluggy==1.5.0 +pyright==1.1.399 +pytest==8.3.5 +ruff==0.11.5 +sortedcontainers==2.4.0 +typing_extensions==4.13.2 diff --git a/firebase-dataconnect/ci/util.py b/firebase-dataconnect/ci/util.py new file mode 100644 index 00000000000..9f71bdcb226 --- /dev/null +++ b/firebase-dataconnect/ci/util.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed 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 dataclasses +import json +import logging +import re +import subprocess +import typing + +if typing.TYPE_CHECKING: + from collections.abc import Iterable + + +@dataclasses.dataclass(frozen=True) +class GitHubPrInfo: + title: str + body: str + + +def fetch_pr_info(pr_number: int, github_repository: str) -> GitHubPrInfo: + gh_args = _fetch_pr_gh_args(pr_number=pr_number, github_repository=github_repository) + gh_args = tuple(gh_args) + logging.info("Running command: %s", subprocess.list2cmdline(gh_args)) + output_str = subprocess.check_output(gh_args, encoding="utf8", errors="replace") # noqa: S603 + logging.info("%s", output_str.strip()) + output = json.loads(output_str) + return GitHubPrInfo( + title=output["title"], + body=output["body"], + ) + + +def _fetch_pr_gh_args(pr_number: int, github_repository: str) -> Iterable[str]: + yield "gh" + yield "issue" + yield "view" + yield str(pr_number) + yield "--json" + yield "title,body" + yield "-R" + yield github_repository + + +def pr_number_from_github_ref(github_ref: str) -> int | None: + match = re.fullmatch("refs/pull/([0-9]+)/merge", github_ref) + return int(match.group(1)) if match else None