Skip to content

Commit aaf2d7a

Browse files
committed
Revamp QA check into a battery included package
1 parent 0929d5d commit aaf2d7a

31 files changed

+5203
-482
lines changed

airbyte-ci/connectors/connector_ops/poetry.lock

+399-480
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

airbyte-ci/connectors/connector_ops/pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ GitPython = "^3.1.29"
1717
pydantic = "^1.9"
1818
PyGithub = "^1.58.0"
1919
rich = "^13.0.0"
20-
pydash = "^7.0.4"
20+
pydash = "^6.0.2"
2121
google-cloud-storage = "^2.8.0"
2222
ci-credentials = {path = "../ci_credentials"}
2323
pandas = "^2.0.3"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Connectors QA
2+
3+
This package has two main purposes:
4+
* Running QA checks on connectors.
5+
* Generating the QA checks documentation that are run on connectors.
6+
7+
8+
9+
## Usage
10+
11+
### Install
12+
13+
```bash
14+
pipx install .
15+
```
16+
17+
This will make `connectors-qa` available in your `PATH`.
18+
19+
20+
Feel free to run `connectors-qa --help` to see the available commands and options.
21+
22+
23+
### Examples
24+
25+
#### Running QA checks on one or more connectors:
26+
27+
```bash
28+
# This command must run from the root of the Airbyte repo
29+
connectors-qa run --name=source-faker --name=source-google-sheets
30+
```
31+
#### Running QA checks on all connectors:
32+
33+
```bash
34+
# This command must run from the root of the Airbyte repo
35+
connectors-qa run --connector-directory=airbyte-integrations/connectors
36+
```
37+
38+
#### Running QA checks on all connectors and generating a JSON report:
39+
40+
```bash
41+
### Generating documentation for QA checks:
42+
connectors-qa run --connector-directory=airbyte-integrations/connectors --report-path=qa_report.json
43+
```
44+
45+
#### Running only specific QA checks on one or more connectors:
46+
47+
```bash
48+
connectors-qa run --name=source-faker --name=source-google-sheets --check=CheckConnectorIconIsAvailable --check=CheckConnectorUsesPythonBaseImage
49+
```
50+
51+
#### Running only specific QA checks on all connectors:
52+
53+
```bash
54+
connectors-qa run --connector-directory=airbyte-integrations/connectors --check=CheckConnectorIconIsAvailable --check=CheckConnectorUsesPythonBaseImage
55+
```
56+
57+
#### Generating documentation for QA checks:
58+
59+
```bash
60+
connectors-qa generate-documentation qa_checks.md
61+
```
62+
63+
## Development
64+
65+
```bash
66+
poetry install
67+
```
68+
69+
### Dependencies
70+
This package uses two local dependencies:
71+
* [`connector_ops`](https://github.com/airbytehq/airbyte/blob/master/airbyte-ci/connectors/connector_ops): To interact with the `Connector` object.
72+
* [`metadata_service/lib`]((https://github.com/airbytehq/airbyte/blob/master/airbyte-ci/connectors/metadata_service/lib)): To validate the metadata of the connectors.
73+
74+
### Adding a new QA check
75+
76+
To add a new QA check, you have to create add new class in one of the `checks` module. This class must inherit from `models.Check` and implement the `_run` method. Then, you need to add an instance of this class to the `ENABLED_CHECKS` list of the module.
77+
78+
**Please run the `generate-doumentation` command to update the documentation with the new check and commit it in your PR.**
79+
80+
### Running tests
81+
82+
```bash
83+
poe test
84+
```
85+
86+
### Running type checks
87+
88+
```bash
89+
poe type_check
90+
```
91+
92+
### Running the linter
93+
94+
```bash
95+
poe lint
96+
```

airbyte-ci/connectors/connectors_qa/connectors_qa/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2+
from .assets import ENABLED_CHECKS as ASSETS_CHECKS
3+
from .metadata import ENABLED_CHECKS as METADATA_CORRECTNESS_CHECKS
4+
from .security import ENABLED_CHECKS as SECURITY_CHECKS
5+
from .packaging import ENABLED_CHECKS as PACKAGING_CHECKS
6+
from .documentation import ENABLED_CHECKS as DOCUMENTATION_CHECKS
7+
8+
ENABLED_CHECKS = (
9+
DOCUMENTATION_CHECKS
10+
+ METADATA_CORRECTNESS_CHECKS
11+
+ PACKAGING_CHECKS
12+
+ ASSETS_CHECKS
13+
+ SECURITY_CHECKS
14+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2+
3+
4+
from connector_ops.utils import Connector # type: ignore
5+
from connectors_qa.models import Check, CheckCategory, CheckResult
6+
7+
8+
class AssetsCheck(Check):
9+
category = CheckCategory.ASSETS
10+
11+
12+
class CheckConnectorIconIsAvailable(AssetsCheck):
13+
name = "Connectors must have an icon"
14+
description = "Each connector must have an icon available in at the root of the connector code directory. It must be an SVG file named `icon.svg`."
15+
requires_metadata = False
16+
17+
def _run(self, connector: Connector) -> CheckResult:
18+
if not connector.icon_path or not connector.icon_path.exists():
19+
return self.create_check_result(
20+
connector=connector,
21+
passed=False,
22+
message="Icon file is missing. Please create an icon file at the root of the connector code directory.",
23+
)
24+
if not connector.icon_path.name == "icon.svg":
25+
return self.create_check_result(
26+
connector=connector,
27+
passed=False,
28+
message="Icon file is not named 'icon.svg'",
29+
)
30+
return self.create_check_result(connector=connector, passed=True, message="Icon file exists")
31+
32+
33+
ENABLED_CHECKS = [CheckConnectorIconIsAvailable()]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2+
3+
import textwrap
4+
from typing import List
5+
6+
from connector_ops.utils import Connector # type: ignore
7+
from connectors_qa import consts
8+
from connectors_qa.models import Check, CheckCategory, CheckResult
9+
from pydash.objects import get # type: ignore
10+
11+
12+
class DocumentationCheck(Check):
13+
category = CheckCategory.DOCUMENTATION
14+
15+
16+
class CheckMigrationGuide(DocumentationCheck):
17+
name = "Breaking changes must be accompanied by a migration guide"
18+
description = "When a breaking change is introduced we check that a migration guide is available. It should be stored under `./docs/integrations/<connector-type>s/<connector-name>-migrations.md`.\nThis document should contain a section for each breaking change, in order of the version descending. It must explain users which action to take to migrate to the new version."
19+
20+
def _run(self, connector: Connector) -> CheckResult:
21+
breaking_changes = get(connector.metadata, "releases.breakingChanges")
22+
if not breaking_changes:
23+
return self.create_check_result(
24+
connector=connector,
25+
passed=True,
26+
message="No breaking changes found. A migration guide is not required",
27+
)
28+
migration_guide_file_path = connector.migration_guide_file_path
29+
migration_guide_exists = migration_guide_file_path is not None and migration_guide_file_path.exists()
30+
if not migration_guide_exists:
31+
return self.create_check_result(
32+
connector=connector,
33+
passed=False,
34+
message=f"Migration guide file is missing for {connector.technical_name}. Please create a migration guide in ./docs/integrations/<connector-type>s/<connector-name>-migrations.md`",
35+
)
36+
37+
expected_title = f"# {connector.name_from_metadata} Migration Guide"
38+
expected_version_header_start = "## Upgrading to "
39+
migration_guide_content = migration_guide_file_path.read_text()
40+
try:
41+
first_line = migration_guide_content.splitlines()[0]
42+
except IndexError:
43+
first_line = migration_guide_content
44+
if not first_line == expected_title:
45+
return self.create_check_result(
46+
connector=connector,
47+
passed=False,
48+
message=f"Migration guide file for {connector.technical_name} does not start with the correct header. Expected '{expected_title}', got '{first_line}'",
49+
)
50+
51+
# Check that the migration guide contains a section for each breaking change key ## Upgrading to {version}
52+
# Note that breaking change is a dict where the version is the key
53+
# Note that the migration guide must have the sections in order of the version descending
54+
# 3.0.0, 2.0.0, 1.0.0, etc
55+
# This means we have to record the headings in the migration guide and then check that they are in order
56+
# We also have to check that the headings are in the breaking changes dict
57+
ordered_breaking_changes = sorted(breaking_changes.keys(), reverse=True)
58+
ordered_expected_headings = [f"{expected_version_header_start}{version}" for version in ordered_breaking_changes]
59+
60+
ordered_heading_versions = []
61+
for line in migration_guide_content.splitlines():
62+
stripped_line = line.strip()
63+
if stripped_line.startswith(expected_version_header_start):
64+
version = stripped_line.replace(expected_version_header_start, "")
65+
ordered_heading_versions.append(version)
66+
67+
if ordered_breaking_changes != ordered_heading_versions:
68+
return self.create_check_result(
69+
connector=connector,
70+
passed=False,
71+
message=textwrap.dedent(
72+
f"""
73+
Migration guide file for {connector.name_from_metadata} has incorrect version headings.
74+
Check for missing, extra, or misordered headings, or headers with typos.
75+
Expected headings: {ordered_expected_headings}
76+
"""
77+
),
78+
)
79+
return self.create_check_result(
80+
connector=connector,
81+
passed=True,
82+
message="The migration guide is correctly templated",
83+
)
84+
85+
86+
class CheckDocumentationExists(DocumentationCheck):
87+
name = "Connectors must have user facing documentation"
88+
description = (
89+
"The user facing connector documentation should be stored under `./docs/integrations/<connector-type>s/<connector-name>.md`."
90+
)
91+
92+
def _run(self, connector: Connector) -> CheckResult:
93+
if not connector.documentation_file_path or not connector.documentation_file_path.exists():
94+
return self.fail(
95+
connector=connector,
96+
message="User facing documentation file is missing. Please create it under ./docs/integrations/<connector-type>s/<connector-name>.md",
97+
)
98+
return self.pass_(
99+
connector=connector,
100+
message=f"User facing documentation file {connector.documentation_file_path} exists",
101+
)
102+
103+
104+
class CheckDocumentationStructure(DocumentationCheck):
105+
name = "Connectors documentation follows our guidelines"
106+
description = f"The user facing connector documentation should follow the guidelines defined in the [documentation standards]({consts.DOCUMENTATION_STANDARDS_URL})."
107+
108+
expected_sections = [
109+
"## Prerequisites",
110+
"## Setup guide",
111+
"## Supported sync modes",
112+
"## Supported streams",
113+
"## Changelog",
114+
]
115+
116+
def check_main_header(self, connector: Connector, doc_lines: List[str]) -> List[str]:
117+
errors = []
118+
if not doc_lines[0].lower().startswith(f"# {connector.metadata['name']}".lower()):
119+
errors.append(
120+
f"The connector name is not used as the main header in the documentation. Expected: '# {connector.metadata['name']}'"
121+
)
122+
return errors
123+
124+
def check_sections(self, doc_lines: List[str]) -> List[str]:
125+
errors = []
126+
for expected_section in self.expected_sections:
127+
if expected_section.lower() not in doc_lines:
128+
errors.append(f"Connector documentation is missing a '{expected_section.replace('#', '').strip()}' section")
129+
return errors
130+
131+
def _run(self, connector: Connector) -> CheckResult:
132+
if not connector.documentation_file_path or not connector.documentation_file_path.exists():
133+
return self.fail(
134+
connector=connector,
135+
message="Could not check documentation structure as the documentation file is missing.",
136+
)
137+
138+
doc_lines = [line.lower() for line in connector.documentation_file_path.read_text().splitlines()]
139+
140+
if not doc_lines:
141+
return self.fail(
142+
connector=connector,
143+
message="Documentation file is empty",
144+
)
145+
146+
errors = []
147+
errors.extend(self.check_main_header(connector, doc_lines))
148+
errors.extend(self.check_sections(doc_lines))
149+
150+
if errors:
151+
return self.fail(
152+
connector=connector,
153+
message=f"Connector documentation does not follow the guidelines: {'. '.join(errors)}",
154+
)
155+
return self.pass_(
156+
connector=connector,
157+
message="Documentation guidelines are followed",
158+
)
159+
160+
161+
class CheckChangelogEntry(DocumentationCheck):
162+
name = "Connectors must have a changelog entry for each version"
163+
description = "Each new version of a connector must have a changelog entry defined in the user facing documentation in `./docs/integrations/<connector-type>s/<connector-name>.md`."
164+
165+
def _run(self, connector: Connector) -> CheckResult:
166+
if connector.documentation_file_path is None or not connector.documentation_file_path.exists():
167+
return self.fail(
168+
connector=connector,
169+
message="Could not check changelog entry as the documentation file is missing. Please create it.",
170+
)
171+
172+
doc_lines = connector.documentation_file_path.read_text().splitlines()
173+
if not doc_lines:
174+
return self.fail(
175+
connector=connector,
176+
message="Documentation file is empty",
177+
)
178+
179+
after_changelog = False
180+
entry_found = False
181+
for line in doc_lines:
182+
if "# changelog" in line.lower():
183+
after_changelog = True
184+
if after_changelog and connector.version in line:
185+
entry_found = True
186+
187+
if not after_changelog:
188+
return self.fail(
189+
connector=connector,
190+
message="Connector documentation is missing a 'Changelog' section",
191+
)
192+
if not entry_found:
193+
return self.fail(
194+
connector=connector,
195+
message=f"Connectors must have a changelog entry for each version: changelog entry for version {connector.version} is missing in the documentation",
196+
)
197+
198+
return self.pass_(connector=connector, message=f"Changelog entry found for version {connector.version}")
199+
200+
201+
ENABLED_CHECKS = [
202+
CheckMigrationGuide(),
203+
CheckDocumentationExists(),
204+
CheckDocumentationStructure(),
205+
CheckChangelogEntry(),
206+
]

0 commit comments

Comments
 (0)