quoll-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quoll/__init__.py +0 -0
- quoll/cd/__init__.py +1 -0
- quoll/cd/config.py +75 -0
- quoll/ci/__init__.py +0 -0
- quoll/ci/config.py +213 -0
- quoll/ci/custom.py +32 -0
- quoll/ci/detect.py +139 -0
- quoll/ci/steps.py +85 -0
- quoll/ci/tools.py +104 -0
- quoll/commands/__init__.py +0 -0
- quoll/commands/_common.py +145 -0
- quoll/commands/_workspace.py +83 -0
- quoll/commands/add.py +104 -0
- quoll/commands/add_secret.py +75 -0
- quoll/commands/cd.py +214 -0
- quoll/commands/ci.py +521 -0
- quoll/commands/docker.py +244 -0
- quoll/commands/k8s.py +211 -0
- quoll/commands/new.py +337 -0
- quoll/commands/remove.py +79 -0
- quoll/config/__init__.py +0 -0
- quoll/config/quoll_toml.py +196 -0
- quoll/config/settings.py +22 -0
- quoll/config/store.py +39 -0
- quoll/console.py +3 -0
- quoll/docker/__init__.py +1 -0
- quoll/docker/config.py +64 -0
- quoll/docker/detect.py +41 -0
- quoll/docker/render.py +162 -0
- quoll/files.py +64 -0
- quoll/k8s/__init__.py +0 -0
- quoll/k8s/chart/templates/_helpers.tpl +38 -0
- quoll/k8s/chart/templates/deployment.yaml +93 -0
- quoll/k8s/chart/templates/hpa.yaml +24 -0
- quoll/k8s/chart/templates/ingress.yaml +35 -0
- quoll/k8s/chart/templates/pdb.yaml +16 -0
- quoll/k8s/chart/templates/service.yaml +18 -0
- quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
- quoll/k8s/ci.py +39 -0
- quoll/k8s/config.py +97 -0
- quoll/k8s/deploy.py +22 -0
- quoll/k8s/detect.py +32 -0
- quoll/k8s/render.py +94 -0
- quoll/languages/__init__.py +121 -0
- quoll/languages/base.py +350 -0
- quoll/languages/cpp/Dockerfile.j2 +37 -0
- quoll/languages/cpp/__init__.py +17 -0
- quoll/languages/cpp/ci.py +192 -0
- quoll/languages/cpp/detect.py +84 -0
- quoll/languages/cpp/docker.py +47 -0
- quoll/languages/dotnet/Dockerfile.j2 +21 -0
- quoll/languages/dotnet/__init__.py +17 -0
- quoll/languages/dotnet/ci.py +88 -0
- quoll/languages/dotnet/detect.py +53 -0
- quoll/languages/dotnet/docker.py +47 -0
- quoll/languages/go/Dockerfile.j2 +25 -0
- quoll/languages/go/__init__.py +17 -0
- quoll/languages/go/ci.py +126 -0
- quoll/languages/go/detect.py +30 -0
- quoll/languages/go/docker.py +40 -0
- quoll/languages/java/Dockerfile.j2 +28 -0
- quoll/languages/java/__init__.py +20 -0
- quoll/languages/java/ci.py +163 -0
- quoll/languages/java/detect.py +77 -0
- quoll/languages/java/docker.py +45 -0
- quoll/languages/java/frameworks/__init__.py +1 -0
- quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
- quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
- quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
- quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
- quoll/languages/java/frameworks/spring_boot/services.py +24 -0
- quoll/languages/node/Dockerfile.j2 +98 -0
- quoll/languages/node/__init__.py +29 -0
- quoll/languages/node/ci.py +200 -0
- quoll/languages/node/detect.py +164 -0
- quoll/languages/node/docker.py +141 -0
- quoll/languages/node/frameworks/__init__.py +1 -0
- quoll/languages/node/frameworks/angular/__init__.py +18 -0
- quoll/languages/node/frameworks/angular/ci.py +34 -0
- quoll/languages/node/frameworks/angular/detect.py +20 -0
- quoll/languages/node/frameworks/angular/docker.py +31 -0
- quoll/languages/node/frameworks/angular/starter.py +74 -0
- quoll/languages/node/frameworks/astro/__init__.py +10 -0
- quoll/languages/node/frameworks/astro/ci.py +17 -0
- quoll/languages/node/frameworks/astro/detect.py +14 -0
- quoll/languages/node/frameworks/astro/docker.py +17 -0
- quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
- quoll/languages/node/frameworks/nestjs/detect.py +9 -0
- quoll/languages/node/frameworks/nestjs/docker.py +10 -0
- quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
- quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
- quoll/languages/node/frameworks/nextjs/ci.py +29 -0
- quoll/languages/node/frameworks/nextjs/detect.py +13 -0
- quoll/languages/node/frameworks/nextjs/docker.py +41 -0
- quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
- quoll/languages/node/frameworks/nuxt/ci.py +17 -0
- quoll/languages/node/frameworks/nuxt/detect.py +17 -0
- quoll/languages/node/frameworks/nuxt/docker.py +24 -0
- quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
- quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
- quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
- quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
- quoll/languages/node/starter.py +42 -0
- quoll/languages/node/templates/express/README.md +11 -0
- quoll/languages/node/templates/express/env.example +2 -0
- quoll/languages/node/templates/express/eslint.config.js +15 -0
- quoll/languages/node/templates/express/gitignore +4 -0
- quoll/languages/node/templates/express/nvmrc +1 -0
- quoll/languages/node/templates/express/package.json +16 -0
- quoll/languages/node/templates/express/prettierignore +6 -0
- quoll/languages/node/templates/express/src/app.ts +27 -0
- quoll/languages/node/templates/express/src/server.ts +11 -0
- quoll/languages/node/templates/express/test/app.test.ts +29 -0
- quoll/languages/node/templates/express/tsconfig.build.json +8 -0
- quoll/languages/node/templates/express/tsconfig.json +14 -0
- quoll/languages/node/templates/express/vitest.config.ts +12 -0
- quoll/languages/php/Dockerfile.j2 +40 -0
- quoll/languages/php/__init__.py +19 -0
- quoll/languages/php/ci.py +147 -0
- quoll/languages/php/detect.py +75 -0
- quoll/languages/php/docker.py +59 -0
- quoll/languages/php/frameworks/__init__.py +1 -0
- quoll/languages/php/frameworks/laravel/__init__.py +17 -0
- quoll/languages/php/frameworks/laravel/ci.py +24 -0
- quoll/languages/php/frameworks/laravel/detect.py +13 -0
- quoll/languages/php/frameworks/laravel/docker.py +29 -0
- quoll/languages/php/frameworks/laravel/services.py +25 -0
- quoll/languages/php/frameworks/symfony/__init__.py +15 -0
- quoll/languages/php/frameworks/symfony/ci.py +22 -0
- quoll/languages/php/frameworks/symfony/detect.py +17 -0
- quoll/languages/php/frameworks/symfony/docker.py +25 -0
- quoll/languages/python/Dockerfile.j2 +61 -0
- quoll/languages/python/__init__.py +19 -0
- quoll/languages/python/ci.py +157 -0
- quoll/languages/python/detect.py +175 -0
- quoll/languages/python/docker.py +80 -0
- quoll/languages/python/frameworks/__init__.py +1 -0
- quoll/languages/python/frameworks/django/__init__.py +16 -0
- quoll/languages/python/frameworks/django/ci.py +43 -0
- quoll/languages/python/frameworks/django/detect.py +17 -0
- quoll/languages/python/frameworks/django/docker.py +20 -0
- quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
- quoll/languages/python/frameworks/fastapi/detect.py +9 -0
- quoll/languages/python/frameworks/fastapi/docker.py +21 -0
- quoll/languages/python/frameworks/flask/__init__.py +7 -0
- quoll/languages/python/frameworks/flask/detect.py +9 -0
- quoll/languages/python/frameworks/flask/docker.py +22 -0
- quoll/languages/ruby/Dockerfile.j2 +45 -0
- quoll/languages/ruby/__init__.py +19 -0
- quoll/languages/ruby/ci.py +110 -0
- quoll/languages/ruby/detect.py +72 -0
- quoll/languages/ruby/docker.py +63 -0
- quoll/languages/ruby/frameworks/__init__.py +1 -0
- quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
- quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
- quoll/languages/ruby/frameworks/rails/ci.py +26 -0
- quoll/languages/ruby/frameworks/rails/detect.py +15 -0
- quoll/languages/ruby/frameworks/rails/docker.py +62 -0
- quoll/languages/ruby/frameworks/rails/services.py +10 -0
- quoll/languages/rust/Dockerfile.j2 +23 -0
- quoll/languages/rust/__init__.py +17 -0
- quoll/languages/rust/ci.py +125 -0
- quoll/languages/rust/detect.py +46 -0
- quoll/languages/rust/docker.py +38 -0
- quoll/main.py +50 -0
- quoll/platforms/__init__.py +62 -0
- quoll/platforms/base.py +103 -0
- quoll/platforms/github/__init__.py +4 -0
- quoll/platforms/github/actions.py +78 -0
- quoll/platforms/github/api.py +124 -0
- quoll/platforms/github/cd.py +265 -0
- quoll/platforms/github/ci.py +497 -0
- quoll/platforms/github/dependabot.yml.j2 +49 -0
- quoll/platforms/github/platform.py +52 -0
- quoll/platforms/github/workflow.py +74 -0
- quoll/platforms/gitlab/__init__.py +4 -0
- quoll/platforms/gitlab/api.py +158 -0
- quoll/platforms/gitlab/cd.py +226 -0
- quoll/platforms/gitlab/ci.py +400 -0
- quoll/platforms/gitlab/pipeline.py +136 -0
- quoll/platforms/gitlab/platform.py +54 -0
- quoll/project.py +60 -0
- quoll/services.py +117 -0
- quoll/starters.py +117 -0
- quoll/workspace.py +232 -0
- quoll/yaml_dump.py +137 -0
- quoll_cli-0.2.0.dist-info/METADATA +641 -0
- quoll_cli-0.2.0.dist-info/RECORD +191 -0
- quoll_cli-0.2.0.dist-info/WHEEL +4 -0
- quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
- quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Turn a CIConfig into .gitlab-ci.yml (and a Renovate config)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from quoll.ci import custom
|
|
9
|
+
from quoll.ci.config import SECURITY_JOBS, CIConfig
|
|
10
|
+
from quoll.ci.steps import Item, Step, step
|
|
11
|
+
from quoll.ci.tools import (
|
|
12
|
+
GITLEAKS_IMAGE,
|
|
13
|
+
SBOM_FILE,
|
|
14
|
+
SEMGREP_IMAGE,
|
|
15
|
+
dependency_checks,
|
|
16
|
+
image_checks,
|
|
17
|
+
sast,
|
|
18
|
+
secret_scan,
|
|
19
|
+
)
|
|
20
|
+
from quoll.files import PlannedFile, header, planned
|
|
21
|
+
from quoll.k8s.ci import HELM_IMAGE, chart_checks
|
|
22
|
+
from quoll.languages.base import FrameworkCI
|
|
23
|
+
from quoll.platforms.gitlab.pipeline import branch_rule, docker_job, dump, job, registry_cache, variable_name
|
|
24
|
+
from quoll.services import SERVICES
|
|
25
|
+
from quoll.workspace import Member, Repo
|
|
26
|
+
|
|
27
|
+
PIPELINE_PATH = Path(".gitlab-ci.yml")
|
|
28
|
+
# quoll's jobs, included by .gitlab-ci.yml.
|
|
29
|
+
JOBS_PATH = ".gitlab/quoll-ci.yml"
|
|
30
|
+
# Template of the project's environment for your own jobs.
|
|
31
|
+
SETUP_TEMPLATE = ".quoll-setup"
|
|
32
|
+
SCHEDULED = '$CI_PIPELINE_SOURCE == "schedule"'
|
|
33
|
+
# Your own jobs and overrides of quoll's, included last when present: quoll never touches it.
|
|
34
|
+
CUSTOM_PATH = ".gitlab/custom-ci.yml"
|
|
35
|
+
# Written by `quoll cd`, included when present.
|
|
36
|
+
CD_PATH = ".gitlab/cd.yml"
|
|
37
|
+
RENOVATE_PATH = Path("renovate.json")
|
|
38
|
+
# Any of these means Renovate is already configured.
|
|
39
|
+
RENOVATE_CONFIGS = ("renovate.json", "renovate.json5", ".renovaterc", ".renovaterc.json", ".gitlab/renovate.json")
|
|
40
|
+
SONAR_IMAGE = "sonarsource/sonar-scanner-cli:12"
|
|
41
|
+
SAST_REPORT = "gl-sast-report.json"
|
|
42
|
+
_INFRASTRUCTURE_FAILURES = (
|
|
43
|
+
"runner_system_failure", "stuck_or_timeout_failure", "api_failure", "scheduler_failure", "data_integrity_failure"
|
|
44
|
+
)
|
|
45
|
+
CODECOV_IMAGE = "python:3"
|
|
46
|
+
# CI jobs run in `test`; the delivery publishes once they all passed.
|
|
47
|
+
STAGES = ["test", "publish", "deploy"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_pipeline(target: Union[CIConfig, Repo]) -> str:
|
|
51
|
+
""".gitlab/quoll-ci.yml: the jobs of a project, or of every member of a workspace (run when it changed)."""
|
|
52
|
+
repo = target if isinstance(target, Repo) else Repo.single(target)
|
|
53
|
+
jobs: Dict[str, Any] = {}
|
|
54
|
+
templates: Dict[str, Any] = {}
|
|
55
|
+
for member in repo.members:
|
|
56
|
+
member_jobs, template = _project_jobs(member, repo)
|
|
57
|
+
if repo.workspace:
|
|
58
|
+
member_jobs = {name: _on_changes(j, member) for name, j in member_jobs.items()}
|
|
59
|
+
jobs.update(member_jobs)
|
|
60
|
+
templates[_template_name(member)] = template
|
|
61
|
+
if repo.workspace:
|
|
62
|
+
# Repository-wide scans, once.
|
|
63
|
+
jobs.update(_repo_jobs(repo))
|
|
64
|
+
if not jobs:
|
|
65
|
+
raise ValueError("every CI job is disabled — enable at least one in quoll.toml")
|
|
66
|
+
|
|
67
|
+
if repo.schedule.enabled:
|
|
68
|
+
# Scheduled pipelines (a GitLab pipeline schedule, see `quoll ci schedule`) only re-check security.
|
|
69
|
+
jobs = {name: j if _kind(name, repo) in SECURITY_JOBS else _skip_on_schedule(j) for name, j in jobs.items()}
|
|
70
|
+
|
|
71
|
+
doc: Dict[str, Any] = {}
|
|
72
|
+
if not repo.workspace:
|
|
73
|
+
env = _framework_env(repo.members[0].cfg)
|
|
74
|
+
if env:
|
|
75
|
+
doc["variables"] = env
|
|
76
|
+
doc["default"] = {"interruptible": True}
|
|
77
|
+
doc.update(templates)
|
|
78
|
+
doc.update(jobs)
|
|
79
|
+
return header("ci") + dump(doc)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _project_jobs(member: Member, repo: Repo) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
83
|
+
"""The jobs of one project and its setup template. Alone in the repository, it also carries the
|
|
84
|
+
repository-wide scans, in their historical place."""
|
|
85
|
+
cfg = member.cfg
|
|
86
|
+
ci = cfg.language.ci
|
|
87
|
+
framework = cfg.get_framework()
|
|
88
|
+
framework_ci = framework.ci if framework else FrameworkCI()
|
|
89
|
+
version = cfg.versions[-1]
|
|
90
|
+
image = ci.image(cfg, version)
|
|
91
|
+
setup = ci.image_setup(cfg)
|
|
92
|
+
cache = ci.cache(cfg)
|
|
93
|
+
caches = [cache] if cache else []
|
|
94
|
+
directory = cfg.directory
|
|
95
|
+
# In a workspace, each project's framework settings go to its own jobs.
|
|
96
|
+
env = _framework_env(cfg) if repo.workspace else {}
|
|
97
|
+
jobs: Dict[str, Any] = {}
|
|
98
|
+
|
|
99
|
+
def project_job(items: List[Item], **options: Any) -> Dict[str, Any]:
|
|
100
|
+
variables = {**env, **options.pop("variables", {})}
|
|
101
|
+
return job(
|
|
102
|
+
"test", options.pop("image", image), items, directory=directory, variables=variables or None, **options
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if cfg.lint.enabled and cfg.lint.tools:
|
|
106
|
+
own = [t for t in cfg.lint.tools if cfg.language.is_lint_tool(t)]
|
|
107
|
+
items: List[Item] = list(ci.lint(cfg, own))
|
|
108
|
+
for tool in cfg.lint.tools:
|
|
109
|
+
if tool not in own:
|
|
110
|
+
items.extend(framework_ci.lint(cfg, tool))
|
|
111
|
+
items = custom.extend(cfg, "lint", items)
|
|
112
|
+
jobs[member.job("lint")] = project_job(items, setup=setup, caches=caches, timeout=10)
|
|
113
|
+
|
|
114
|
+
if cfg.test.enabled:
|
|
115
|
+
tests = custom.extend(cfg, "test", framework_ci.test(cfg) if cfg.is_framework_runner else ci.test(cfg))
|
|
116
|
+
test_setup = list(setup)
|
|
117
|
+
options: Dict[str, Any] = {}
|
|
118
|
+
if cfg.matrix:
|
|
119
|
+
# Each entry carries its resolved image: versions may be aliases (stable, lts...) of other tags.
|
|
120
|
+
variable = variable_name(ci.version_key)
|
|
121
|
+
options["image"] = "$IMAGE"
|
|
122
|
+
options["parallel"] = {"matrix": [{variable: v, "IMAGE": ci.image(cfg, v)} for v in cfg.versions]}
|
|
123
|
+
if cfg.services:
|
|
124
|
+
options["services"] = _services(cfg)
|
|
125
|
+
# Exported by the script rather than set as job variables, which project CI/CD variables of the same
|
|
126
|
+
# name would override: tests must never hit a real database.
|
|
127
|
+
test_setup.insert(0, _export_step(cfg.service_environment(lambda name: name)))
|
|
128
|
+
artifacts = _test_artifacts(cfg)
|
|
129
|
+
if artifacts:
|
|
130
|
+
options["artifacts"] = artifacts
|
|
131
|
+
test = project_job(tests, setup=test_setup, caches=caches, timeout=20, **options)
|
|
132
|
+
regex = ci.reports(cfg).coverage_regex if cfg.test.reports else ""
|
|
133
|
+
if regex:
|
|
134
|
+
# Total coverage parsed from the log: merge request widget and badge.
|
|
135
|
+
test["coverage"] = regex
|
|
136
|
+
jobs[member.job("test")] = test
|
|
137
|
+
if cfg.test.coverage and cfg.test.codecov:
|
|
138
|
+
jobs[member.job("codecov")] = job(
|
|
139
|
+
"test",
|
|
140
|
+
CODECOV_IMAGE,
|
|
141
|
+
[_codecov_step(cfg.path(ci.coverage_file(cfg)))],
|
|
142
|
+
needs=[{"job": member.job("test"), "artifacts": True}],
|
|
143
|
+
rules=[{"if": "$CODECOV_TOKEN", "when": "always"}],
|
|
144
|
+
timeout=5,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if cfg.security.audit:
|
|
148
|
+
audit = custom.extend(cfg, "audit", ci.audit(cfg))
|
|
149
|
+
jobs[member.job("audit")] = project_job(audit, setup=setup, caches=caches, timeout=10)
|
|
150
|
+
|
|
151
|
+
if not repo.workspace and repo.secrets:
|
|
152
|
+
jobs["secrets"] = _secrets_job()
|
|
153
|
+
|
|
154
|
+
if cfg.security.sbom or cfg.security.licenses:
|
|
155
|
+
sec = cfg.security
|
|
156
|
+
items = [*ci.install(cfg), *dependency_checks(sec.sbom, sec.licenses, sec.license_severity)]
|
|
157
|
+
items = custom.extend(cfg, "sbom", items)
|
|
158
|
+
sbom_job = project_job(items, setup=setup, caches=caches, timeout=15)
|
|
159
|
+
if sec.sbom:
|
|
160
|
+
# The dependency list of the project (GitLab Ultimate), and the file to download on every tier.
|
|
161
|
+
sbom = cfg.path(SBOM_FILE)
|
|
162
|
+
sbom_job["artifacts"] = {
|
|
163
|
+
"when": "always",
|
|
164
|
+
"paths": [sbom],
|
|
165
|
+
"reports": {"cyclonedx": [sbom]},
|
|
166
|
+
"expire_in": "1 week",
|
|
167
|
+
}
|
|
168
|
+
jobs[member.job("sbom")] = sbom_job
|
|
169
|
+
|
|
170
|
+
if not repo.workspace and repo.sast:
|
|
171
|
+
jobs["sast"] = _sast_job()
|
|
172
|
+
|
|
173
|
+
if cfg.security.sonar:
|
|
174
|
+
sonar = _tool_job(
|
|
175
|
+
SONAR_IMAGE,
|
|
176
|
+
[step("SonarCloud scan", run=_sonar_command(cfg))],
|
|
177
|
+
# Masked variables are not available to merge requests from forks.
|
|
178
|
+
rules=[{"if": "$SONAR_TOKEN"}],
|
|
179
|
+
variables={
|
|
180
|
+
"SONAR_HOST_URL": "https://sonarcloud.io",
|
|
181
|
+
"SONAR_USER_HOME": "$CI_PROJECT_DIR/.sonar",
|
|
182
|
+
"GIT_DEPTH": "0",
|
|
183
|
+
},
|
|
184
|
+
timeout=15,
|
|
185
|
+
)
|
|
186
|
+
sonar["cache"] = [{"key": "sonar", "paths": [".sonar/cache"]}]
|
|
187
|
+
jobs[member.job("sonar")] = sonar
|
|
188
|
+
|
|
189
|
+
if cfg.build.enabled:
|
|
190
|
+
items = custom.extend(cfg, "build", [*framework_ci.before_build(cfg), *ci.build(cfg)])
|
|
191
|
+
kept = {"paths": [cfg.path(p) for p in cfg.build.artifacts], "expire_in": "1 week"}
|
|
192
|
+
jobs[member.job("build")] = project_job(
|
|
193
|
+
items, setup=setup, caches=caches, timeout=15, artifacts=kept if cfg.build.artifacts else None
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if cfg.build.docker:
|
|
197
|
+
project = member.name or "ci"
|
|
198
|
+
tag = f"{project}:$CI_COMMIT_SHA"
|
|
199
|
+
lint_image, _, scan = image_checks(tag)
|
|
200
|
+
build = registry_cache(project) + f'\ndocker buildx build --load $cache --tag "{tag}" .'
|
|
201
|
+
checks = [lint_image.run, build, scan.run]
|
|
202
|
+
jobs[member.job("docker")] = docker_job("test", checks, timeout=20, directory=directory)
|
|
203
|
+
|
|
204
|
+
if cfg.build.chart:
|
|
205
|
+
jobs[member.job("chart")] = _tool_job(HELM_IMAGE, list(chart_checks(cfg.build.chart)), timeout=10,
|
|
206
|
+
directory=directory)
|
|
207
|
+
|
|
208
|
+
return jobs, _setup_template(cfg, image, setup, caches, env)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _repo_jobs(repo: Repo) -> Dict[str, Any]:
|
|
212
|
+
jobs: Dict[str, Any] = {}
|
|
213
|
+
if repo.secrets:
|
|
214
|
+
jobs["secrets"] = _secrets_job()
|
|
215
|
+
if repo.sast:
|
|
216
|
+
jobs["sast"] = _sast_job()
|
|
217
|
+
return jobs
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _secrets_job() -> Dict[str, Any]:
|
|
221
|
+
return _tool_job(
|
|
222
|
+
GITLEAKS_IMAGE,
|
|
223
|
+
# The runner's clone belongs to another user than the image's: let git read it.
|
|
224
|
+
[step("Trust the clone", run='git config --global --add safe.directory "$CI_PROJECT_DIR"'), secret_scan()],
|
|
225
|
+
variables={"GIT_DEPTH": "0"},
|
|
226
|
+
timeout=10,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _sast_job() -> Dict[str, Any]:
|
|
231
|
+
sast_job = _tool_job(SEMGREP_IMAGE, [sast(report=SAST_REPORT)], timeout=15)
|
|
232
|
+
# Findings in the merge request's security widget (GitLab Ultimate); the job fails on them on every tier.
|
|
233
|
+
sast_job["artifacts"] = {"when": "always", "reports": {"sast": SAST_REPORT}}
|
|
234
|
+
return sast_job
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _on_changes(j: Dict[str, Any], member: Member) -> Dict[str, Any]:
|
|
238
|
+
"""`j` only when the member's files (or what every member shares) changed. Tags, schedules and manual runs
|
|
239
|
+
count everything as changed."""
|
|
240
|
+
paths = {"paths": [f"{member.directory}/**/*", *_SHARED_PATHS]} if member.directory != "." else None
|
|
241
|
+
if paths is None:
|
|
242
|
+
return j
|
|
243
|
+
rules = [{**rule, "changes": paths} for rule in j.get("rules", [{"when": "on_success"}])]
|
|
244
|
+
rest = {k: v for k, v in j.items() if k not in ("stage", "image", "rules")}
|
|
245
|
+
return {"stage": j["stage"], "image": j["image"], "rules": rules, **rest}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Whatever touches every member: the workspace's quoll.toml and the CI itself.
|
|
249
|
+
_SHARED_PATHS = ["quoll.toml", ".gitlab-ci.yml", ".gitlab/**/*"]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _template_name(member: Member) -> str:
|
|
253
|
+
return f"{SETUP_TEMPLATE}-{member.name}" if member.name else SETUP_TEMPLATE
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _kind(job_name: str, repo: Repo) -> str:
|
|
257
|
+
"""The generated job a (possibly prefixed) job name stands for: backend-test -> test."""
|
|
258
|
+
for member in repo.members:
|
|
259
|
+
if member.name and job_name.startswith(f"{member.name}-"):
|
|
260
|
+
return job_name[len(member.name) + 1 :]
|
|
261
|
+
return job_name
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _framework_env(cfg: CIConfig) -> Dict[str, str]:
|
|
265
|
+
framework = cfg.get_framework()
|
|
266
|
+
return (framework.ci if framework else FrameworkCI()).env(cfg)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def render_root(target: Union[CIConfig, Repo]) -> str:
|
|
270
|
+
""".gitlab-ci.yml: when pipelines run, and what they include. quoll's jobs come first, so that
|
|
271
|
+
.gitlab/custom-ci.yml, included last, can add jobs and override any key of quoll's."""
|
|
272
|
+
repo = target if isinstance(target, Repo) else Repo.single(target)
|
|
273
|
+
doc: Dict[str, Any] = {
|
|
274
|
+
"workflow": {
|
|
275
|
+
"rules": [
|
|
276
|
+
{"if": '$CI_PIPELINE_SOURCE == "merge_request_event"'},
|
|
277
|
+
*(branch_rule(b) for b in repo.branches),
|
|
278
|
+
{"if": "$CI_COMMIT_TAG"},
|
|
279
|
+
{"if": '$CI_PIPELINE_SOURCE == "web"'},
|
|
280
|
+
*([{"if": SCHEDULED}] if repo.schedule.enabled else []),
|
|
281
|
+
],
|
|
282
|
+
# A new commit cancels the running pipeline of the same branch (the delivery is not interruptible).
|
|
283
|
+
"auto_cancel": {"on_new_commit": "interruptible"},
|
|
284
|
+
},
|
|
285
|
+
"stages": STAGES,
|
|
286
|
+
# A job lost to the infrastructure (runner, timeout, API) runs again; a failing test doesn't (a retry would
|
|
287
|
+
# hide a flaky one).
|
|
288
|
+
"default": {"retry": {"max": 2, "when": list(_INFRASTRUCTURE_FAILURES)}},
|
|
289
|
+
"include": [
|
|
290
|
+
{"local": JOBS_PATH},
|
|
291
|
+
*({"local": path, "rules": [{"exists": [path]}]} for path in (CD_PATH, CUSTOM_PATH)),
|
|
292
|
+
],
|
|
293
|
+
}
|
|
294
|
+
return header("ci") + dump(doc)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _setup_template(cfg: CIConfig, image: str, setup: List[Step], caches: list, env: Dict[str, str]) -> Dict[str, Any]:
|
|
298
|
+
"""For your own jobs (`extends: .quoll-setup`): the project's image, caches and installed dependencies,
|
|
299
|
+
commands run in its directory."""
|
|
300
|
+
template = job(
|
|
301
|
+
"test",
|
|
302
|
+
image,
|
|
303
|
+
cfg.language.ci.install(cfg),
|
|
304
|
+
setup=setup,
|
|
305
|
+
caches=caches,
|
|
306
|
+
timeout=15,
|
|
307
|
+
variables=env or None,
|
|
308
|
+
directory=cfg.directory,
|
|
309
|
+
)
|
|
310
|
+
template["before_script"] = template.pop("script")
|
|
311
|
+
template.pop("timeout")
|
|
312
|
+
return template
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def render_renovate() -> str:
|
|
316
|
+
config = {
|
|
317
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
318
|
+
"extends": ["config:recommended"],
|
|
319
|
+
# quoll regenerates .gitlab-ci.yml from quoll.toml: bump the versions there.
|
|
320
|
+
"gitlabci": {"enabled": False},
|
|
321
|
+
}
|
|
322
|
+
return json.dumps(config, indent=2) + "\n"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def plan(project_dir: Path, target: Union[CIConfig, Repo]) -> List[PlannedFile]:
|
|
326
|
+
repo = target if isinstance(target, Repo) else Repo.single(target)
|
|
327
|
+
files = [
|
|
328
|
+
planned(project_dir, PIPELINE_PATH, render_root(repo)),
|
|
329
|
+
planned(project_dir, Path(JOBS_PATH), render_pipeline(repo)),
|
|
330
|
+
]
|
|
331
|
+
# Renovate config is often customised by hand: only create it, never overwrite it.
|
|
332
|
+
if repo.dependency_updates and not any((project_dir / name).exists() for name in RENOVATE_CONFIGS):
|
|
333
|
+
files.append(planned(project_dir, RENOVATE_PATH, render_renovate(), create_only=True))
|
|
334
|
+
return files
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _test_artifacts(cfg: CIConfig) -> Dict[str, Any]:
|
|
338
|
+
"""Test results and coverage for the merge request widgets; the report Codecov uploads."""
|
|
339
|
+
ci = cfg.language.ci
|
|
340
|
+
artifacts: Dict[str, Any] = {}
|
|
341
|
+
reports: Dict[str, Any] = {}
|
|
342
|
+
if cfg.test.reports:
|
|
343
|
+
found = ci.reports(cfg)
|
|
344
|
+
if found.junit:
|
|
345
|
+
reports["junit"] = cfg.path(found.junit)
|
|
346
|
+
if found.cobertura:
|
|
347
|
+
reports["coverage_report"] = {"coverage_format": "cobertura", "path": cfg.path(found.cobertura)}
|
|
348
|
+
if cfg.test.coverage and cfg.test.codecov:
|
|
349
|
+
artifacts["paths"] = [cfg.path(ci.coverage_file(cfg))]
|
|
350
|
+
if reports:
|
|
351
|
+
artifacts["reports"] = reports
|
|
352
|
+
if artifacts:
|
|
353
|
+
artifacts = {"when": "always", **artifacts, "expire_in": "1 week"}
|
|
354
|
+
return artifacts
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _skip_on_schedule(j: Dict[str, Any]) -> Dict[str, Any]:
|
|
358
|
+
rules = [{"if": SCHEDULED, "when": "never"}, *j.get("rules", [{"when": "on_success"}])]
|
|
359
|
+
rest = {k: v for k, v in j.items() if k not in ("stage", "image", "rules")}
|
|
360
|
+
return {"stage": j["stage"], "image": j["image"], "rules": rules, **rest}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _tool_job(image: str, items: List[Item], **options: Any) -> Dict[str, Any]:
|
|
364
|
+
"""A job running in a tool's image, whose entrypoint is the tool itself."""
|
|
365
|
+
j = job("test", image, items, **options)
|
|
366
|
+
j["image"] = {"name": image, "entrypoint": [""]}
|
|
367
|
+
return j
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _services(cfg: CIConfig) -> List[Dict[str, Any]]:
|
|
371
|
+
services = []
|
|
372
|
+
for name in cfg.services:
|
|
373
|
+
service = SERVICES[name]
|
|
374
|
+
entry: Dict[str, Any] = {"name": service.image, "alias": name}
|
|
375
|
+
if service.env:
|
|
376
|
+
entry["variables"] = service.env
|
|
377
|
+
services.append(entry)
|
|
378
|
+
return services
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _export_step(env: Dict[str, str]) -> Step:
|
|
382
|
+
# Services are reachable under their alias.
|
|
383
|
+
return step("Point the app at the services", run="\n".join(f'export {k}="{v}"' for k, v in env.items()))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _codecov_step(coverage_file: str) -> Item:
|
|
387
|
+
command = 'codecovcli upload-process --fail-on-error --token "$CODECOV_TOKEN"'
|
|
388
|
+
# The CLI takes files, not globs: let it search for the report instead.
|
|
389
|
+
if "*" not in coverage_file:
|
|
390
|
+
command += f" --file {coverage_file}"
|
|
391
|
+
return step("Upload coverage to Codecov", run=f"pip install --quiet codecov-cli\n{command}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _sonar_command(cfg: CIConfig) -> str:
|
|
395
|
+
command = f"sonar-scanner -Dsonar.organization={cfg.security.sonar_organization}"
|
|
396
|
+
if cfg.security.sonar_project_key:
|
|
397
|
+
command += f" -Dsonar.projectKey={cfg.security.sonar_project_key}"
|
|
398
|
+
if cfg.directory != ".":
|
|
399
|
+
command += f" -Dsonar.projectBaseDir={cfg.directory}"
|
|
400
|
+
return command
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Building blocks of GitLab CI pipelines: neutral steps rendered as container jobs, emitted by quoll.yaml_dump."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from quoll import yaml_dump
|
|
8
|
+
from quoll.ci.steps import ROOT, Cache, Item, Step
|
|
9
|
+
|
|
10
|
+
PROJECT_DIR = "$CI_PROJECT_DIR"
|
|
11
|
+
# Docker-in-Docker, for the jobs building images.
|
|
12
|
+
DOCKER_IMAGE = "docker:29"
|
|
13
|
+
DOCKER_SERVICE = "docker:29-dind"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def job(
|
|
17
|
+
stage: str,
|
|
18
|
+
image: str,
|
|
19
|
+
items: Sequence[Item],
|
|
20
|
+
*,
|
|
21
|
+
setup: Sequence[Step] = (),
|
|
22
|
+
caches: Sequence[Cache] = (),
|
|
23
|
+
timeout: int,
|
|
24
|
+
variables: Optional[Dict[str, str]] = None,
|
|
25
|
+
services: Optional[List[Any]] = None,
|
|
26
|
+
parallel: Optional[Dict[str, Any]] = None,
|
|
27
|
+
needs: Optional[List[Any]] = None,
|
|
28
|
+
rules: Optional[List[Dict[str, Any]]] = None,
|
|
29
|
+
artifacts: Optional[Dict[str, Any]] = None,
|
|
30
|
+
directory: str = ".",
|
|
31
|
+
) -> Dict[str, Any]:
|
|
32
|
+
"""A job running `setup` then `items` in `image`, in the project's `directory`. Steps marked `always` go to
|
|
33
|
+
after_script."""
|
|
34
|
+
# after_script runs in a shell of its own: it moves to the directory too.
|
|
35
|
+
script: List[str] = [] if directory == "." else [f'cd "{directory}"']
|
|
36
|
+
after: List[str] = []
|
|
37
|
+
env: Dict[str, str] = dict(variables or {})
|
|
38
|
+
caches = list(caches)
|
|
39
|
+
for item in [*setup, *items]:
|
|
40
|
+
if isinstance(item, Cache):
|
|
41
|
+
caches.append(item)
|
|
42
|
+
continue
|
|
43
|
+
if item.summary:
|
|
44
|
+
continue
|
|
45
|
+
if not item.run:
|
|
46
|
+
raise ValueError(f"step '{item.name}' has no command to run on GitLab CI")
|
|
47
|
+
env.update(item.env)
|
|
48
|
+
command = item.run
|
|
49
|
+
if item.optional and not item.always:
|
|
50
|
+
# after_script failures never fail the job.
|
|
51
|
+
command = f"{{\n{command}\n}} || echo '{item.name} failed (ignored)'"
|
|
52
|
+
(after if item.always else script).append(command)
|
|
53
|
+
|
|
54
|
+
j: Dict[str, Any] = {"stage": stage, "image": image}
|
|
55
|
+
if rules:
|
|
56
|
+
j["rules"] = rules
|
|
57
|
+
if needs is not None:
|
|
58
|
+
j["needs"] = needs
|
|
59
|
+
j["timeout"] = f"{timeout} minutes"
|
|
60
|
+
if parallel:
|
|
61
|
+
j["parallel"] = parallel
|
|
62
|
+
if services:
|
|
63
|
+
j["services"] = services
|
|
64
|
+
root = PROJECT_DIR if directory == "." else f"{PROJECT_DIR}/{directory}"
|
|
65
|
+
for cache in caches:
|
|
66
|
+
env.update({k: v.replace(ROOT, root) for k, v in cache.env.items()})
|
|
67
|
+
if env:
|
|
68
|
+
j["variables"] = env
|
|
69
|
+
if caches:
|
|
70
|
+
j["cache"] = [_cache(c, directory) for c in caches]
|
|
71
|
+
j["script"] = script
|
|
72
|
+
if after:
|
|
73
|
+
j["after_script"] = ([f'cd "{directory}"'] if directory != "." else []) + after
|
|
74
|
+
if artifacts:
|
|
75
|
+
j["artifacts"] = artifacts
|
|
76
|
+
return j
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cache(c: Cache, directory: str = ".") -> Dict[str, Any]:
|
|
80
|
+
def within(path: str) -> str:
|
|
81
|
+
return path if directory == "." else f"{directory}/{path}"
|
|
82
|
+
|
|
83
|
+
# Per job (and matrix entry): a virtualenv built for one interpreter must not leak into another.
|
|
84
|
+
prefix = f"{c.key}-$CI_JOB_NAME_SLUG"
|
|
85
|
+
if c.key_files:
|
|
86
|
+
if len(c.key_files) > 2:
|
|
87
|
+
raise ValueError(f"cache '{c.key}' has {len(c.key_files)} key files; GitLab allows two")
|
|
88
|
+
key: Any = {"files": [within(f) for f in c.key_files], "prefix": prefix}
|
|
89
|
+
else:
|
|
90
|
+
key = prefix
|
|
91
|
+
return {"key": key, "paths": [within(p) for p in c.paths]}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def docker_job(stage: str, script: List[str], *, timeout: int, directory: str = ".", **options: Any) -> Dict[str, Any]:
|
|
95
|
+
"""A job with a Docker daemon (Docker-in-Docker, over TLS), building from `directory`."""
|
|
96
|
+
if directory != ".":
|
|
97
|
+
script = [f'cd "{directory}"', *script]
|
|
98
|
+
variables = {"DOCKER_TLS_CERTDIR": "/certs", **options.pop("variables", {})}
|
|
99
|
+
j: Dict[str, Any] = {"stage": stage, "image": DOCKER_IMAGE, "services": [DOCKER_SERVICE]}
|
|
100
|
+
j.update({k: v for k, v in options.items() if k in ("rules", "needs", "interruptible", "resource_group")})
|
|
101
|
+
j["timeout"] = f"{timeout} minutes"
|
|
102
|
+
j["variables"] = variables
|
|
103
|
+
j["script"] = script
|
|
104
|
+
return j
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def branch_rule(branch: str) -> Dict[str, str]:
|
|
108
|
+
"""Rule matching pushes to `branch`, a name or a GitHub-style pattern (release/*, feature/**)."""
|
|
109
|
+
if not any(c in branch for c in "*?["):
|
|
110
|
+
return {"if": f'$CI_COMMIT_BRANCH == "{branch}"'}
|
|
111
|
+
regex = re.escape(branch).replace(r"\*\*", ".*").replace(r"\*", "[^/]*").replace(r"\?", "[^/]")
|
|
112
|
+
regex = regex.replace("/", r"\/")
|
|
113
|
+
return {"if": f"$CI_COMMIT_BRANCH =~ /^{regex}$/"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def variable_name(version_key: str) -> str:
|
|
117
|
+
"""Matrix variable for a language's version key: python-version -> PYTHON_VERSION."""
|
|
118
|
+
return version_key.upper().replace("-", "_")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def dump(doc: Dict[str, Any]) -> str:
|
|
122
|
+
return yaml_dump.dump(doc, scripts=("script", "before_script", "after_script", "if"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def registry_cache(project: str) -> str:
|
|
126
|
+
"""Shell setting $cache to buildx's options caching layers in the project's registry, under cache:<project>,
|
|
127
|
+
between pipelines (and with the delivery). Without access to the registry (a fork, the registry turned off),
|
|
128
|
+
$cache is empty: the image builds from scratch."""
|
|
129
|
+
return f"""cache=""
|
|
130
|
+
if [ -n "${{CI_REGISTRY:-}}" ] && echo "$CI_REGISTRY_PASSWORD" \\
|
|
131
|
+
| docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" >/dev/null 2>&1; then
|
|
132
|
+
ref="$CI_REGISTRY_IMAGE/cache:{project}"
|
|
133
|
+
cache="--cache-from type=registry,ref=$ref"
|
|
134
|
+
cache="$cache --cache-to type=registry,ref=$ref,mode=max,image-manifest=true,ignore-error=true"
|
|
135
|
+
fi
|
|
136
|
+
docker buildx create --use --driver docker-container >/dev/null"""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
from quoll.platforms.base import Platform, PlatformAPI, Remote
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from quoll.cd.config import CDConfig
|
|
10
|
+
from quoll.ci.config import CIConfig
|
|
11
|
+
from quoll.files import PlannedFile
|
|
12
|
+
from quoll.workspace import Delivery, Repo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitLab(Platform):
|
|
16
|
+
name = "gitlab"
|
|
17
|
+
label = "GitLab"
|
|
18
|
+
secrets_label = "CI/CD variables"
|
|
19
|
+
registries = ("gitlab", "dockerhub")
|
|
20
|
+
schedules_via_api = True
|
|
21
|
+
|
|
22
|
+
def ci_files(self, project_dir: Path, cfg: Union[CIConfig, Repo]) -> List[PlannedFile]:
|
|
23
|
+
from quoll.platforms.gitlab import ci
|
|
24
|
+
|
|
25
|
+
return ci.plan(project_dir, cfg)
|
|
26
|
+
|
|
27
|
+
def cd_files(self, project_dir: Path, cfg: Union[CDConfig, List[Delivery]]) -> List[PlannedFile]:
|
|
28
|
+
from quoll.platforms.gitlab import cd
|
|
29
|
+
|
|
30
|
+
return cd.plan(project_dir, cfg)
|
|
31
|
+
|
|
32
|
+
def default_image(self, remote: Optional[Remote], registry: str, fallback_name: str, member: str = "") -> str:
|
|
33
|
+
path = (remote.path if remote else f"your-group/{fallback_name}").lower()
|
|
34
|
+
if registry == "dockerhub":
|
|
35
|
+
# Docker Hub repositories have a single namespace level.
|
|
36
|
+
parts = path.split("/")
|
|
37
|
+
return f"{parts[0]}/{parts[-1]}" + (f"-{member}" if member else "")
|
|
38
|
+
if member:
|
|
39
|
+
# The project's registry holds one image per member: registry.gitlab.com/group/project/<member>.
|
|
40
|
+
path += f"/{member}"
|
|
41
|
+
# gitlab.com serves its registry on registry.gitlab.com; self-managed instances usually on registry.<host>.
|
|
42
|
+
host = remote.host if remote else "gitlab.com"
|
|
43
|
+
return f"registry.{host}/{path}"
|
|
44
|
+
|
|
45
|
+
def registry_secrets(self, registry: str) -> List[str]:
|
|
46
|
+
return ["DOCKERHUB_USERNAME", "DOCKERHUB_TOKEN"] if registry == "dockerhub" else []
|
|
47
|
+
|
|
48
|
+
def merge_gate(self) -> str:
|
|
49
|
+
return "Protect the branch with 'Pipelines must succeed' (Settings > Merge requests)"
|
|
50
|
+
|
|
51
|
+
def api(self, host: Optional[str] = None) -> PlatformAPI:
|
|
52
|
+
from quoll.platforms.gitlab.api import GitLabAPI
|
|
53
|
+
|
|
54
|
+
return GitLabAPI(f"https://{host}" if host else None)
|
quoll/project.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Read-only view of a project directory, shared by every language plugin."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, List
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ImportError:
|
|
11
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Project:
|
|
15
|
+
"""File helpers that never raise: missing files read as empty, unparsable ones add a warning."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, root: Path) -> None:
|
|
18
|
+
self.root = root
|
|
19
|
+
self.warnings: List[str] = []
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
return self.root.name
|
|
24
|
+
|
|
25
|
+
def exists(self, *names: str) -> bool:
|
|
26
|
+
return any((self.root / n).exists() for n in names)
|
|
27
|
+
|
|
28
|
+
def is_dir(self, name: str) -> bool:
|
|
29
|
+
return (self.root / name).is_dir()
|
|
30
|
+
|
|
31
|
+
def glob(self, *patterns: str) -> List[Path]:
|
|
32
|
+
return sorted(p for pattern in patterns for p in self.root.glob(pattern))
|
|
33
|
+
|
|
34
|
+
def glob_any(self, *patterns: str) -> bool:
|
|
35
|
+
return any(next(self.root.glob(p), None) is not None for p in patterns)
|
|
36
|
+
|
|
37
|
+
def read_text(self, name: str) -> str:
|
|
38
|
+
try:
|
|
39
|
+
return (self.root / name).read_text()
|
|
40
|
+
except OSError:
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
def read_toml(self, name: str) -> dict:
|
|
44
|
+
if not self.exists(name):
|
|
45
|
+
return {}
|
|
46
|
+
try:
|
|
47
|
+
with open(self.root / name, "rb") as f:
|
|
48
|
+
return tomllib.load(f)
|
|
49
|
+
except tomllib.TOMLDecodeError as e:
|
|
50
|
+
self.warnings.append(f"{name} is not valid TOML ({e}) — detection is partial.")
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
def read_json(self, name: str) -> Any:
|
|
54
|
+
if not self.exists(name):
|
|
55
|
+
return {}
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(self.read_text(name))
|
|
58
|
+
except ValueError as e:
|
|
59
|
+
self.warnings.append(f"{name} could not be parsed ({e}) — detection is partial.")
|
|
60
|
+
return {}
|