Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Commit ab5fa69

Browse files
committed
Add a new transplant-report command
This new command can be used to copy over the coverage report from one commit to another, without having to trigger new uploads.
1 parent e9bcffd commit ab5fa69

File tree

15 files changed

+231
-76
lines changed

15 files changed

+231
-76
lines changed

codecov_cli/commands/commit.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from codecov_cli.fallbacks import CodecovOption, FallbackFieldEnum
88
from codecov_cli.helpers.args import get_cli_args
9-
from codecov_cli.helpers.git import GitService
109
from codecov_cli.helpers.options import global_options
1110
from codecov_cli.services.commit import create_commit_logic
1211
from codecov_cli.types import CommandContext

codecov_cli/commands/create_report_result.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,12 @@ def create_report_results(
3737
),
3838
)
3939
create_report_results_logic(
40-
commit_sha, code, slug, git_service, token, enterprise_url, fail_on_error, args
40+
commit_sha,
41+
code,
42+
slug,
43+
git_service,
44+
token,
45+
enterprise_url,
46+
fail_on_error,
47+
args,
4148
)

codecov_cli/commands/empty_upload.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,12 @@ def empty_upload(
7676
),
7777
)
7878
return empty_upload_logic(
79-
commit_sha, slug, token, git_service, enterprise_url, fail_on_error, force, args
79+
commit_sha,
80+
slug,
81+
token,
82+
git_service,
83+
enterprise_url,
84+
fail_on_error,
85+
force,
86+
args,
8087
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import logging
2+
import typing
3+
4+
import click
5+
import sentry_sdk
6+
7+
from codecov_cli.helpers.args import get_cli_args
8+
from codecov_cli.helpers.options import global_options
9+
from codecov_cli.services.report import transplant_report_logic
10+
from codecov_cli.types import CommandContext
11+
12+
logger = logging.getLogger("codecovcli")
13+
14+
15+
@click.command(hidden=True)
16+
@click.option(
17+
"--from-sha",
18+
help="SHA (with 40 chars) of the commit from which to forward coverage reports",
19+
required=True,
20+
)
21+
@global_options
22+
@click.pass_context
23+
def transplant_report(
24+
ctx: CommandContext,
25+
from_sha: str,
26+
commit_sha: str,
27+
slug: typing.Optional[str],
28+
token: typing.Optional[str],
29+
git_service: typing.Optional[str],
30+
fail_on_error: bool,
31+
):
32+
with sentry_sdk.start_transaction(op="task", name="Transplant Report"):
33+
with sentry_sdk.start_span(name="transplant_report"):
34+
enterprise_url = ctx.obj.get("enterprise_url")
35+
args = get_cli_args(ctx)
36+
logger.debug(
37+
"Starting transplant report process",
38+
extra=dict(extra_log_attributes=args),
39+
)
40+
transplant_report_logic(
41+
from_sha,
42+
commit_sha,
43+
slug,
44+
token,
45+
git_service,
46+
enterprise_url,
47+
fail_on_error,
48+
args,
49+
)

codecov_cli/helpers/glob.py

Lines changed: 57 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -31,44 +31,46 @@ def translate(pat, *, recursive=False, include_hidden=False, seps=None):
3131
seps = (os.path.sep, os.path.altsep)
3232
else:
3333
seps = os.path.sep
34-
escaped_seps = ''.join(map(re.escape, seps))
35-
any_sep = f'[{escaped_seps}]' if len(seps) > 1 else escaped_seps
36-
not_sep = f'[^{escaped_seps}]'
34+
escaped_seps = "".join(map(re.escape, seps))
35+
any_sep = f"[{escaped_seps}]" if len(seps) > 1 else escaped_seps
36+
not_sep = f"[^{escaped_seps}]"
3737
if include_hidden:
38-
one_last_segment = f'{not_sep}+'
39-
one_segment = f'{one_last_segment}{any_sep}'
40-
any_segments = f'(?:.+{any_sep})?'
41-
any_last_segments = '.*'
38+
one_last_segment = f"{not_sep}+"
39+
one_segment = f"{one_last_segment}{any_sep}"
40+
any_segments = f"(?:.+{any_sep})?"
41+
any_last_segments = ".*"
4242
else:
43-
one_last_segment = f'[^{escaped_seps}.]{not_sep}*'
44-
one_segment = f'{one_last_segment}{any_sep}'
45-
any_segments = f'(?:{one_segment})*'
46-
any_last_segments = f'{any_segments}(?:{one_last_segment})?'
43+
one_last_segment = f"[^{escaped_seps}.]{not_sep}*"
44+
one_segment = f"{one_last_segment}{any_sep}"
45+
any_segments = f"(?:{one_segment})*"
46+
any_last_segments = f"{any_segments}(?:{one_last_segment})?"
4747

4848
results = []
4949
parts = re.split(any_sep, pat)
5050
last_part_idx = len(parts) - 1
5151
for idx, part in enumerate(parts):
52-
if part == '*':
52+
if part == "*":
5353
results.append(one_segment if idx < last_part_idx else one_last_segment)
54-
elif recursive and part == '**':
54+
elif recursive and part == "**":
5555
if idx < last_part_idx:
56-
if parts[idx + 1] != '**':
56+
if parts[idx + 1] != "**":
5757
results.append(any_segments)
5858
else:
5959
results.append(any_last_segments)
6060
else:
6161
if part:
62-
if not include_hidden and part[0] in '*?':
63-
results.append(r'(?!\.)')
64-
results.extend(_translate(part, f'{not_sep}*', not_sep)[0])
62+
if not include_hidden and part[0] in "*?":
63+
results.append(r"(?!\.)")
64+
results.extend(_translate(part, f"{not_sep}*", not_sep)[0])
6565
if idx < last_part_idx:
6666
results.append(any_sep)
67-
res = ''.join(results)
68-
return fr'(?s:{res})\Z'
67+
res = "".join(results)
68+
return rf"(?s:{res})\Z"
69+
70+
71+
_re_setops_sub = re.compile(r"([&~|])").sub
6972

7073

71-
_re_setops_sub = re.compile(r'([&~|])').sub
7274
def _translate(pat, star, question_mark):
7375
res = []
7476
add = res.append
@@ -77,69 +79,70 @@ def _translate(pat, star, question_mark):
7779
i, n = 0, len(pat)
7880
while i < n:
7981
c = pat[i]
80-
i = i+1
81-
if c == '*':
82+
i = i + 1
83+
if c == "*":
8284
# store the position of the wildcard
8385
star_indices.append(len(res))
8486
add(star)
8587
# compress consecutive `*` into one
86-
while i < n and pat[i] == '*':
88+
while i < n and pat[i] == "*":
8789
i += 1
88-
elif c == '?':
90+
elif c == "?":
8991
add(question_mark)
90-
elif c == '[':
92+
elif c == "[":
9193
j = i
92-
if j < n and pat[j] == '!':
93-
j = j+1
94-
if j < n and pat[j] == ']':
95-
j = j+1
96-
while j < n and pat[j] != ']':
97-
j = j+1
94+
if j < n and pat[j] == "!":
95+
j = j + 1
96+
if j < n and pat[j] == "]":
97+
j = j + 1
98+
while j < n and pat[j] != "]":
99+
j = j + 1
98100
if j >= n:
99-
add('\\[')
101+
add("\\[")
100102
else:
101103
stuff = pat[i:j]
102-
if '-' not in stuff:
103-
stuff = stuff.replace('\\', r'\\')
104+
if "-" not in stuff:
105+
stuff = stuff.replace("\\", r"\\")
104106
else:
105107
chunks = []
106-
k = i+2 if pat[i] == '!' else i+1
108+
k = i + 2 if pat[i] == "!" else i + 1
107109
while True:
108-
k = pat.find('-', k, j)
110+
k = pat.find("-", k, j)
109111
if k < 0:
110112
break
111113
chunks.append(pat[i:k])
112-
i = k+1
113-
k = k+3
114+
i = k + 1
115+
k = k + 3
114116
chunk = pat[i:j]
115117
if chunk:
116118
chunks.append(chunk)
117119
else:
118-
chunks[-1] += '-'
120+
chunks[-1] += "-"
119121
# Remove empty ranges -- invalid in RE.
120-
for k in range(len(chunks)-1, 0, -1):
121-
if chunks[k-1][-1] > chunks[k][0]:
122-
chunks[k-1] = chunks[k-1][:-1] + chunks[k][1:]
122+
for k in range(len(chunks) - 1, 0, -1):
123+
if chunks[k - 1][-1] > chunks[k][0]:
124+
chunks[k - 1] = chunks[k - 1][:-1] + chunks[k][1:]
123125
del chunks[k]
124126
# Escape backslashes and hyphens for set difference (--).
125127
# Hyphens that create ranges shouldn't be escaped.
126-
stuff = '-'.join(s.replace('\\', r'\\').replace('-', r'\-')
127-
for s in chunks)
128-
i = j+1
128+
stuff = "-".join(
129+
s.replace("\\", r"\\").replace("-", r"\-") for s in chunks
130+
)
131+
i = j + 1
129132
if not stuff:
130133
# Empty range: never match.
131-
add('(?!)')
132-
elif stuff == '!':
134+
add("(?!)")
135+
elif stuff == "!":
133136
# Negated empty range: match any character.
134-
add('.')
137+
add(".")
135138
else:
136139
# Escape set operations (&&, ~~ and ||).
137-
stuff = _re_setops_sub(r'\\\1', stuff)
138-
if stuff[0] == '!':
139-
stuff = '^' + stuff[1:]
140-
elif stuff[0] in ('^', '['):
141-
stuff = '\\' + stuff
142-
add(f'[{stuff}]')
140+
stuff = _re_setops_sub(r"\\\1", stuff)
141+
if stuff[0] == "!":
142+
stuff = "^" + stuff[1:]
143+
elif stuff[0] in ("^", "["):
144+
stuff = "\\" + stuff
145+
add(f"[{stuff}]")
143146
else:
144147
add(re.escape(c))
145148
assert i == n

codecov_cli/helpers/request.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ def wrapper(*args, **kwargs):
7979
)
8080
sleep(backoff_time(retry))
8181
retry += 1
82-
raise Exception(f"Request failed after too many retries. URL: {kwargs.get('url', args[0] if args else 'Unknown')}")
82+
raise Exception(
83+
f"Request failed after too many retries. URL: {kwargs.get('url', args[0] if args else 'Unknown')}"
84+
)
8385

8486
return wrapper
8587

codecov_cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from codecov_cli.commands.upload import do_upload
2020
from codecov_cli.commands.upload_coverage import upload_coverage
2121
from codecov_cli.commands.upload_process import upload_process
22+
from codecov_cli.commands.transplant_report import transplant_report
2223
from codecov_cli.helpers.ci_adapters import get_ci_adapter, get_ci_providers_list
2324
from codecov_cli.helpers.config import load_cli_config
2425
from codecov_cli.helpers.logging_utils import configure_logger
@@ -76,6 +77,7 @@ def cli(
7677

7778
cli.add_command(do_upload)
7879
cli.add_command(create_commit)
80+
cli.add_command(transplant_report)
7981
cli.add_command(create_report)
8082
cli.add_command(create_report_results)
8183
cli.add_command(get_report_results)

codecov_cli/plugins/compress_pycoverage_contexts.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ def run_preparation(self, collector) -> PreparationPluginReturn:
6464
)
6565
return PreparationPluginReturn(
6666
success=False,
67-
messages=[f"File to compress {self.file_to_compress} is not a file."],
67+
messages=[
68+
f"File to compress {self.file_to_compress} is not a file."
69+
],
6870
)
6971
# Create in and out streams
7072
fd_in = open(self.file_to_compress, "rb")

codecov_cli/plugins/gcov.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def run_preparation(self, collector) -> PreparationPluginReturn:
4040
logger.warning(f"{self.executable} is not installed or can't be found.")
4141
return
4242

43-
filename_include_regex = globs_to_regex(["*.gcno", *self.patterns_to_include])
43+
filename_include_regex = globs_to_regex(
44+
["*.gcno", *self.patterns_to_include]
45+
)
4446
filename_exclude_regex = globs_to_regex(self.patterns_to_ignore)
4547

4648
matched_paths = [

codecov_cli/services/report/__init__.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,7 @@ def send_reports_result_request(
101101
enterprise_url,
102102
args,
103103
):
104-
data = {
105-
"cli_args": args,
106-
}
104+
data = {"cli_args": args}
107105
headers = get_token_header(token)
108106
upload_url = enterprise_url or CODECOV_API_URL
109107
url = f"{upload_url}/upload/{service}/{encoded_slug}/commits/{commit_sha}/reports/{report_code}/results"
@@ -167,3 +165,28 @@ def send_reports_result_get_request(
167165
time.sleep(5)
168166
number_tries += 1
169167
return response_obj
168+
169+
170+
def transplant_report_logic(
171+
from_sha: str,
172+
to_sha: str,
173+
slug: typing.Optional[str],
174+
token: typing.Optional[str],
175+
service: typing.Optional[str],
176+
enterprise_url: typing.Optional[str] = None,
177+
fail_on_error: bool = False,
178+
args: typing.Union[dict, None] = None,
179+
):
180+
slug = encode_slug(slug)
181+
headers = get_token_header(token)
182+
183+
data = {"cli_args": args, "from_sha": from_sha, "to_sha": to_sha}
184+
185+
base_url = enterprise_url or CODECOV_INGEST_URL
186+
url = f"{base_url}/upload/{service}/{slug}/commits/transplant"
187+
sending_result = send_post_request(url=url, data=data, headers=headers)
188+
189+
log_warnings_and_errors_if_any(
190+
sending_result, "Transplanting report", fail_on_error
191+
)
192+
return sending_result

0 commit comments

Comments
 (0)