Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
692f41d
feature: update CI config files for Launchable CLI v1 branch
ninjinkun Aug 19, 2025
c5a8ce4
Merge pull request #1113 from cloudbees-oss/v1-ci
ninjinkun Aug 19, 2025
d33f1f8
[AIENG-230] defined a switch to control the test selection behavior
kohsuke Sep 5, 2025
4c867ec
[chore] report an error in subset call
kohsuke Sep 5, 2025
4e88a66
Extend --link flag to support explicit kinds
gayanW Sep 8, 2025
d6314f1
Refactor tests.commands.record.test_tests.TestsTest.test_with_links
gayanW Sep 8, 2025
364ff73
[chore] to display with pager
Konboi Sep 9, 2025
31b5f27
[AIENG-196] Add the new command for the early flake detection
ono-max Sep 10, 2025
b55e8ed
Prefix _ to private method names in utils/link.py
gayanW Sep 10, 2025
56ee7f5
Strip values pass to --link option
gayanW Sep 10, 2025
072ca6c
Add tests
ono-max Sep 10, 2025
d04c2f3
Merge pull request #1130 from cloudbees-oss/flake-detection
ono-max Sep 10, 2025
b7cc590
[tagpr] prepare for the next release
github-actions[bot] Sep 10, 2025
3c5bbca
Merge pull request #1115 from cloudbees-oss/tagpr-from-v1.111.1
ono-max Sep 10, 2025
2c5254b
Merge pull request #1129 from cloudbees-oss/display-with-pager
Konboi Sep 11, 2025
e986505
Merge pull request #1128 from cloudbees-oss/LCHUX-181
gayanW Sep 11, 2025
16afcef
[AIENG-230] better error diagnostics
kohsuke Sep 15, 2025
e93eed7
Fix CI on the v1 branch
ono-max Sep 16, 2025
adfdc37
Merge pull request #1135 from cloudbees-oss/fix-ci-4
ono-max Sep 16, 2025
68eb8e1
Remove a useless test
ono-max Sep 16, 2025
fcba93c
Merge pull request #1137 from cloudbees-oss/remove-test
ono-max Sep 16, 2025
135c3ad
[tagpr] prepare for the next release
github-actions[bot] Sep 16, 2025
c1305fd
Merge pull request #1136 from cloudbees-oss/tagpr-from-v1.111.3
ono-max Sep 16, 2025
07b7c57
Merge pull request #1127 from cloudbees-oss/subset-error-handling
kohsuke Sep 16, 2025
5604f12
Merge pull request #1126 from cloudbees-oss/AIENG-230
kohsuke Sep 16, 2025
5a1c287
Rename `retry flake-detection` to the `detect-flake` command
ono-max Sep 16, 2025
55c99e9
Add detailed output for retrying tests in flake detection
ono-max Sep 16, 2025
3c1eb41
Merge pull request #1134 from cloudbees-oss/rename-flake
ono-max Sep 16, 2025
cd08b21
[tagpr] prepare for the next release
github-actions[bot] Sep 16, 2025
ada5c05
fix: an error message in a test
ninjinkun Jun 24, 2025
cd4cf90
fix: no color on tests
ninjinkun Jun 24, 2025
627f365
fix: test
ninjinkun Jun 25, 2025
fa8cabd
test: revive test_click.py as test_typer.py
ninjinkun Jun 27, 2025
28e98f1
Groovy lang works on JVM but we haven't supported it as jvm_test_pattern
Konboi Jun 19, 2025
051dde7
support groovy file in the maven profile
Konboi Jun 19, 2025
6c60db2
install latest version
Konboi Jun 23, 2025
21f783a
from junitparser v4.0.0 returns JUnitXml instead of testsuite
Konboi Jun 23, 2025
23849dd
[LCHIB-612] Add a workaround for handling timezone abbreviations in d…
ono-max Jun 13, 2025
b95ee76
Use assertEqual instead of assertTrue and assertIn
ono-max Jun 25, 2025
1e9521e
Add comment
ono-max Jun 25, 2025
0d0311a
test: revive test_click.py as test_typer.py
ninjinkun Jun 27, 2025
6e68f8e
feature: replace LAUNCHABLE_ env in Java code
ninjinkun Jul 4, 2025
6f5d168
test: fix tests
ninjinkun Jul 4, 2025
aa5d166
Enhance PytestJSONReportParser to handle user properties as JSON
ono-max Jul 25, 2025
637659d
Follow up fix to f85f624d3816716a220bcd4123edad30ce88babc
kohsuke Aug 5, 2025
1703ea9
Merge pull request #1044 from launchableinc/renovate/actions-attest-b…
kohsuke Aug 5, 2025
355c794
refactor: remove intermidiate documents
ninjinkun Aug 19, 2025
8b51f21
fix: import paths
ninjinkun Aug 20, 2025
3b31a0f
test: add test_typer_types.py
ninjinkun Aug 20, 2025
ca94e12
fix: test
ninjinkun Aug 20, 2025
2f30de0
fix: convert value
ninjinkun Aug 20, 2025
3dccc35
fix: a test file
ninjinkun Aug 21, 2025
60edb4c
fix: a test file
ninjinkun Aug 21, 2025
326ce0e
fix: tracking event count for reducing traking event in SmartTests CLI
ninjinkun Aug 21, 2025
8c32dac
add util method to print error message and exit as 1
Konboi Sep 4, 2025
a4aaa7b
[chore] report an error in subset call
kohsuke Sep 5, 2025
f36cb29
Add tests
ono-max Sep 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: e2e

on:
push:
branches: [main]
branches: [v1]
workflow_dispatch:


Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ name: Python package
on:
workflow_dispatch:
push:
branches: [ main ]
branches: [ v1 ]
paths-ignore:
- 'WORKSPACE'
- 'src/**'
pull_request:
branches: [ main ]
branches: [ v1 ]
paths-ignore:
- 'WORKSPACE'
- 'src/**'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
workflow_dispatch:
push:
branches:
- main
- v1

env:
IMAGE_NAME: cloudbees/launchable
Expand Down
2 changes: 1 addition & 1 deletion .tagpr
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@
#
[tagpr]
vPrefix = true
releaseBranch = main
releaseBranch = v1
versionFile = -
changelog = false
2 changes: 2 additions & 0 deletions launchable/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from launchable.app import Application

from .commands.compare import compare
from .commands.detect_flakes import detect_flakes
from .commands.inspect import inspect
from .commands.record import record
from .commands.split_subset import split_subset
Expand Down Expand Up @@ -91,6 +92,7 @@ def main(ctx, log_level, plugin_dir, dry_run, skip_cert_verification):
main.add_command(inspect)
main.add_command(stats)
main.add_command(compare)
main.add_command(detect_flakes, "detect-flakes")

if __name__ == '__main__':
main()
2 changes: 1 addition & 1 deletion launchable/commands/compare/subsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ def subsets(file_before, file_after):
(before, after, f"{diff:+}" if isinstance(diff, int) else diff, test)
for before, after, diff, test in rows
]
click.echo(tabulate(tabular_data, headers=headers, tablefmt="github"))
click.echo_via_pager(tabulate(tabular_data, headers=headers, tablefmt="github"))
92 changes: 92 additions & 0 deletions launchable/commands/detect_flakes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import os
import sys

import click

from launchable.app import Application
from launchable.commands.helper import find_or_create_session
from launchable.commands.test_path_writer import TestPathWriter
from launchable.testpath import unparse_test_path
from launchable.utils.click import ignorable_error
from launchable.utils.env_keys import REPORT_ERROR_KEY
from launchable.utils.launchable_client import LaunchableClient
from launchable.utils.tracking import Tracking, TrackingClient

from ..utils.commands import Command


@click.group(help="Early flake detection")
@click.option(
'--session',
'session',
help='In the format builds/<build-name>/test_sessions/<test-session-id>',
type=str,
required=True
)
@click.option(
'--retry-threshold',
'retry_threshold',
help='Throughness of how "flake" is detected',
type=click.Choice(['low', 'medium', 'high'], case_sensitive=False),
default='medium',
required=True,
)
@click.pass_context
def detect_flakes(ctx, retry_threshold, session):
tracking_client = TrackingClient(Command.DETECT_FLAKE, app=ctx.obj)
client = LaunchableClient(app=ctx.obj, tracking_client=tracking_client, test_runner=ctx.invoked_subcommand)
session_id = None
try:
session_id = find_or_create_session(
context=ctx,
session=session,
build_name=None,
tracking_client=tracking_client
)
except click.UsageError as e:
click.echo(click.style(str(e), fg="red"), err=True)
sys.exit(1)
except Exception as e:
tracking_client.send_error_event(
event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
stack_trace=str(e),
)
if os.getenv(REPORT_ERROR_KEY):
raise e
else:
click.echo(ignorable_error(e), err=True)
if session_id is None:
return

class FlakeDetection(TestPathWriter):
def __init__(self, app: Application):
super(FlakeDetection, self).__init__(app=app)

def run(self):
test_paths = []
try:
res = client.request(
"get",
"detect-flake",
params={
"confidence": retry_threshold.upper(),
"session-id": os.path.basename(session_id),
"test-runner": ctx.invoked_subcommand})
res.raise_for_status()
test_paths = res.json().get("testPaths", [])
if test_paths:
self.print(test_paths)
click.echo("Trying to retry the following tests:", err=True)
for detail in res.json().get("testDetails", []):
click.echo(f"{detail.get('reason')}: {unparse_test_path(detail.get('fullTestPath'))}", err=True)
except Exception as e:
tracking_client.send_error_event(
event_name=Tracking.ErrorEvent.INTERNAL_CLI_ERROR,
stack_trace=str(e),
)
if os.getenv(REPORT_ERROR_KEY):
raise e
else:
click.echo(ignorable_error(e), err=True)

ctx.obj = FlakeDetection(app=ctx.obj)
2 changes: 2 additions & 0 deletions launchable/commands/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ def find_or_create_session(
session_id = read_session(saved_build_name)
if session_id:
_check_observation_mode_status(session_id, is_observation, tracking_client=tracking_client, app=context.obj)
if links:
click.echo(click.style("WARNING: --link option is ignored since session already exists."), err=True)
return session_id

context.invoke(
Expand Down
2 changes: 1 addition & 1 deletion launchable/commands/inspect/subset.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def display(self):
result._estimated_duration_sec,
]
)
click.echo(tabulate(rows, header, tablefmt="github", floatfmt=".2f"))
click.echo_via_pager(tabulate(rows, header, tablefmt="github", floatfmt=".2f"))


class SubsetResultJSONDisplay(SubsetResultAbstractDisplay):
Expand Down
11 changes: 2 additions & 9 deletions launchable/commands/record/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import click
from tabulate import tabulate

from launchable.utils.link import CIRCLECI_KEY, GITHUB_ACTIONS_KEY, JENKINS_URL_KEY, LinkKind, capture_link
from launchable.utils.link import CIRCLECI_KEY, GITHUB_ACTIONS_KEY, JENKINS_URL_KEY, capture_links
from launchable.utils.tracking import Tracking, TrackingClient

from ...utils import subprocess
Expand Down Expand Up @@ -316,14 +316,7 @@ def synthesize_workspaces() -> List[Workspace]:
def send(ws: List[Workspace]) -> Optional[str]:
# figure out all the CI links to capture
def compute_links():
_links = capture_link(os.environ)
for k, v in links:
_links.append({
"title": k,
"url": v,
"kind": LinkKind.CUSTOM_LINK.name,
})
return _links
return capture_links(links, os.environ)

try:
payload = {
Expand Down
30 changes: 11 additions & 19 deletions launchable/commands/record/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import click

from launchable.utils.click import DATETIME_WITH_TZ, validate_past_datetime
from launchable.utils.link import LinkKind, capture_link
from launchable.utils.link import capture_links
from launchable.utils.tracking import Tracking, TrackingClient

from ...utils.click import KEY_VALUE
Expand Down Expand Up @@ -181,25 +181,17 @@ def session(

flavor_dict = dict(flavor)

payload = {
"flavors": flavor_dict,
"isObservation": is_observation,
"noBuild": is_no_build,
"lineage": lineage,
"testSuite": test_suite,
"timestamp": timestamp.isoformat() if timestamp else None,
}

_links = capture_link(os.environ)
for link in links:
_links.append({
"title": link[0],
"url": link[1],
"kind": LinkKind.CUSTOM_LINK.name,
})
payload["links"] = _links

try:
payload = {
"flavors": flavor_dict,
"isObservation": is_observation,
"noBuild": is_no_build,
"lineage": lineage,
"testSuite": test_suite,
"timestamp": timestamp.isoformat() if timestamp else None,
"links": capture_links(links, os.environ),
}

sub_path = "builds/{}/test_sessions".format(build_name)
res = client.request("post", sub_path, payload=payload)

Expand Down
5 changes: 5 additions & 0 deletions launchable/commands/record/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ def tests(

is_no_build = False

if session and links:
warn_and_exit_if_fail_fast_mode(
"WARNING: `--link` and `--session` are set together.\n--link option can't be used with existing sessions."
)

try:
if is_no_build:
session_id = "builds/{}/test_sessions/{}".format(NO_BUILD_BUILD_NAME, NO_BUILD_TEST_SESSION_ID)
Expand Down
17 changes: 14 additions & 3 deletions launchable/commands/subset.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@
help="get subset list from git managed files",
is_flag=True,
)
@click.option(
"--use-case",
"use_case",
type=click.Choice(["one-commit", "feature-branch", "recurring"]),
hidden=True, # control PTS v2 test selection behavior. Non-committed, so hidden for now.
)
@click.pass_context
def subset(
context: click.core.Context,
Expand Down Expand Up @@ -235,6 +241,7 @@ def subset(
prioritized_tests_mapping_file: Optional[TextIO] = None,
test_suite: Optional[str] = None,
is_get_tests_from_guess: bool = False,
use_case: Optional[str] = None,
):
app = context.obj
tracking_client = TrackingClient(Command.SUBSET, app=app)
Expand Down Expand Up @@ -484,17 +491,17 @@ def get_payload(
if target is not None:
payload["goal"] = {
"type": "subset-by-percentage",
"percentage": target,
"percentage": float(target),
}
elif duration is not None:
payload["goal"] = {
"type": "subset-by-absolute-time",
"duration": duration,
"duration": float(duration),
}
elif confidence is not None:
payload["goal"] = {
"type": "subset-by-confidence",
"percentage": confidence
"percentage": float(confidence)
}
elif goal_spec is not None:
payload["goal"] = {
Expand All @@ -513,6 +520,9 @@ def get_payload(
if prioritized_tests_mapping_file:
payload['prioritizedTestsMapping'] = json.load(prioritized_tests_mapping_file)

if use_case:
payload["changesUnderTest"] = use_case

return payload

def _collect_potential_test_files(self):
Expand Down Expand Up @@ -560,6 +570,7 @@ def request_subset(self) -> SubsetResult:
# The status code 422 is returned when validation error of the test mapping file occurs.
if res.status_code == 422:
print_error_and_die("Error: {}".format(res.reason), Tracking.ErrorEvent.USER_ERROR)
res.raise_for_status()

return SubsetResult.from_response(res.json())
except Exception as e:
Expand Down
2 changes: 2 additions & 0 deletions launchable/test_runners/bazel.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ def subset(client):
split_subset = launchable.CommonSplitSubsetImpls(__name__,
formatter=lambda x: x[0]['name'] + ":" + x[1]['name']).split_subset()

launchable.CommonFlakeDetectionImpls(__name__, formatter=lambda x: x[0]['name'] + ":" + x[1]['name']).detect_flakes()


@click.argument('workspace', required=True)
@click.option('--build-event-json', 'build_event_json_files', help="set file path generated by --build_event_json_file",
Expand Down
2 changes: 2 additions & 0 deletions launchable/test_runners/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ def find_filename():


split_subset = launchable.CommonSplitSubsetImpls(__name__).split_subset()

launchable.CommonFlakeDetectionImpls(__name__).detect_flakes()
29 changes: 29 additions & 0 deletions launchable/test_runners/launchable.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import click

from launchable.commands.detect_flakes import detect_flakes as detect_flakes_cmd
from launchable.commands.record.tests import tests as record_tests_cmd
from launchable.commands.split_subset import split_subset as split_subset_cmd
from launchable.commands.subset import subset as subset_cmd
Expand Down Expand Up @@ -43,6 +44,10 @@ def subset(f):
record.tests = lambda f: wrap(f, record_tests_cmd)


def flake_detection(f):
return wrap(f, detect_flakes_cmd)


def split_subset(f):
return wrap(f, split_subset_cmd)

Expand Down Expand Up @@ -165,3 +170,27 @@ def split_subset(client):
client.run()

return wrap(split_subset, split_subset_cmd, self.cmdname)


class CommonFlakeDetectionImpls:
def __init__(
self,
module_name,
formatter=None,
seperator="\n",
):
self.cmdname = cmdname(module_name)
self._formatter = formatter
self._separator = seperator

def detect_flakes(self):
def detect_flakes(client):
if self._formatter:
client.formatter = self._formatter

if self._separator:
client.separator = self._separator

client.run()

return wrap(detect_flakes, detect_flakes_cmd, self.cmdname)
2 changes: 2 additions & 0 deletions launchable/test_runners/raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def subset(client, test_path_file):

split_subset = launchable.CommonSplitSubsetImpls(__name__, formatter=unparse_test_path, seperator='\n').split_subset()

launchable.CommonFlakeDetectionImpls(__name__).detect_flakes()


@click.argument('test_result_files', required=True, type=click.Path(exists=True), nargs=-1)
@launchable.record.tests
Expand Down
1 change: 1 addition & 0 deletions launchable/test_runners/rspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
subset = launchable.CommonSubsetImpls(__name__).scan_files('*_spec.rb')
split_subset = launchable.CommonSplitSubsetImpls(__name__).split_subset()
record_tests = launchable.CommonRecordTestImpls(__name__).report_files()
launchable.CommonFlakeDetectionImpls(__name__).detect_flakes()
1 change: 1 addition & 0 deletions launchable/utils/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Command(Enum):
RECORD_SESSION = 'RECORD_SESSION'
SUBSET = 'SUBSET'
COMMIT = 'COMMIT'
DETECT_FLAKE = 'DETECT_FLAKE'

def display_name(self):
return self.value.lower().replace('_', ' ')
Loading