-
Notifications
You must be signed in to change notification settings - Fork 301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add a check for Jenkins results. #13533
Changes from 4 commits
0ab2971
bfcea4a
f1f21c6
b605e31
111f5b1
9f751b9
198d8b4
d851c00
38ab23c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
name: Jenkins status report | ||
|
||
on: | ||
pull_request: | ||
|
||
jobs: | ||
# Check and report Jenkins test results. Should use the check_suite trigger when stable, and | ||
# test the PR that triggered it obviously. | ||
jenkins_check: | ||
name: Check Jenkins results | ||
if: github.repository == 'daos-stack/daos' | ||
runs-on: [self-hosted, light] | ||
steps: | ||
- uses: actions/checkout@v4 | ||
- name: Run check | ||
run: ./ci/jenkins_status.py --pr ${{ github.event.pull_request.number }} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,212 @@ | ||
#!/usr/bin/env python | ||
|
||
"""Parse Jenkins build test results""" | ||
|
||
import argparse | ||
import json | ||
import sys | ||
import urllib | ||
from urllib.request import urlopen | ||
|
||
JENKINS_HOME = "https://build.hpdd.intel.com/job/daos-stack" | ||
|
||
|
||
class TestResult: | ||
"""Represents a single Jenkins test result""" | ||
|
||
def __init__(self, data, blocks): | ||
name = data["name"] | ||
if "-" in name: | ||
try: | ||
(num, full) = name.split("-", 1) | ||
test_num = int(num) | ||
if full[-5] == "-": | ||
full = full[:-5] | ||
name = f"{full} ({test_num})" | ||
Comment on lines
+18
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does the original There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is the weird 8 digits of sha hash at the end of the PR name which is making matching fail. |
||
except ValueError: | ||
pass | ||
self.name = name | ||
self.cname = data["className"] | ||
self.skipped = False | ||
self.passed = False | ||
self.failed = False | ||
assert data["status"] in ("PASSED", "FIXED", "SKIPPED", "FAILED", "REGRESSION") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe better to gracefully handle? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For an internal script I'm OK with this, if there is another status then we want to know about it so we handle it properly. As we're scraping Jenkins then we can stress-test this code against previous builds. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The problem with |
||
if data["status"] in ("PASSED", "FIXED"): | ||
self.passed = True | ||
elif data["status"] in ("FAILED", "REGRESSION"): | ||
self.failed = True | ||
elif data["status"] == "SKIPPED": | ||
self.skipped = True | ||
self.data = data | ||
self.blocks = blocks | ||
|
||
def info(self, prefix=""): | ||
"""Return a string describing the test""" | ||
return f"{prefix}{self.cname}\t\t{self.name}" | ||
|
||
def full_info(self): | ||
"""Return a longer string describing the test""" | ||
|
||
tcl = [] | ||
if self.blocks is not None: | ||
tcl.extend(reversed(self.blocks)) | ||
|
||
tcl.append(f"{self.cname}.{self.name}") | ||
details = self.data["errorDetails"] | ||
if details: | ||
return " / ".join(tcl) + "\n" + details.replace("\\n", "\n") | ||
return self.info() | ||
|
||
# Needed for set operations to compare results across sets. | ||
def __eq__(self, other): | ||
return self.name == other.name and self.cname == other.cname | ||
|
||
# Needed to be able to add results to sets. | ||
def __hash__(self): | ||
return hash((self.name, self.cname)) | ||
|
||
def __str__(self): | ||
return self.name | ||
|
||
def __repr__(self): | ||
return f"Test result of {self.cname}" | ||
|
||
|
||
def je_load(job_name, jid=None, what=None, tree=None): | ||
"""Fetch something from Jenkins and return as native type.""" | ||
|
||
url = f"{JENKINS_HOME}/job/daos/job/{job_name}" | ||
|
||
if jid: | ||
url += f"/{jid}" | ||
if what: | ||
url += f"/{what}" | ||
|
||
url += "/api/json" | ||
|
||
if tree: | ||
url += f"?tree={tree}" | ||
|
||
with urlopen(url) as f: # nosec | ||
return json.load(f) | ||
|
||
|
||
def show_job(job_name, jid): | ||
"""Parse one job | ||
|
||
Return a list of failed test objects""" | ||
|
||
if not job_name.startswith("PR-"): | ||
jdata = je_load(job_name, jid=jid, tree="actions[causes]") | ||
if ( | ||
"causes" not in jdata["actions"][0] | ||
or jdata["actions"][0]["causes"][0]["_class"] | ||
!= "hudson.triggers.TimerTrigger$TimerTriggerCause" | ||
): | ||
return None | ||
|
||
try: | ||
jdata = je_load(job_name, jid=jid, what="testReport") | ||
except urllib.error.HTTPError: | ||
print(f"Job {jid} of {job_name} has no test results") | ||
return None | ||
|
||
print(f"Checking job {jid} of {job_name}") | ||
|
||
failed = [] | ||
|
||
assert not jdata["testActions"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Graceful? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could just remove this, it was me trying to understand the output. |
||
for suite in jdata["suites"]: | ||
for k in suite["cases"]: | ||
tr = TestResult(k, suite["enclosingBlockNames"]) | ||
if not tr.failed: | ||
continue | ||
failed.append(tr) | ||
return failed | ||
|
||
|
||
def test_against_job(all_failed, job_name, count): | ||
"""Check for failures in existing test runs | ||
|
||
Takes set of failed tests, returns set of unexplained tests | ||
""" | ||
data = je_load(job_name) | ||
lcb = data["lastCompletedBuild"]["number"] | ||
main_failed = set() | ||
ccount = 0 | ||
for build in data["builds"]: | ||
jid = build["number"] | ||
if jid > lcb: | ||
print(f"Job {jid} is of {job_name} is still running, skipping") | ||
failed = show_job(job_name, jid) | ||
if not isinstance(failed, list): | ||
continue | ||
for test in failed: | ||
main_failed.add(test) | ||
ccount += 1 | ||
if count == ccount: | ||
break | ||
|
||
unexplained = all_failed.difference(main_failed) | ||
if not unexplained: | ||
print(f"Stopping checking at {ccount} builds, all failures explained") | ||
break | ||
|
||
ignore = all_failed.intersection(main_failed) | ||
if ignore: | ||
print(f"Tests which failed in the PR and have also failed in {job_name} builds.") | ||
for test in ignore: | ||
print(test.full_info()) | ||
|
||
return all_failed.difference(main_failed) | ||
|
||
|
||
def main(): | ||
"""Check the results of a PR""" | ||
|
||
parser = argparse.ArgumentParser(description="Check Jenkins test results") | ||
parser.add_argument("--pr", type=int, required=True) | ||
|
||
args = parser.parse_args() | ||
|
||
job_name = f"PR-{args.pr}" | ||
|
||
data = je_load(job_name) | ||
|
||
lcb = data["lastCompletedBuild"]["number"] | ||
|
||
all_failed = set() | ||
for build in data["builds"]: | ||
jid = build["number"] | ||
if jid > lcb: | ||
print(f"Job {jid} is of {job_name} is still running, skipping") | ||
continue | ||
failed = show_job(job_name, jid) | ||
if not isinstance(failed, list): | ||
continue | ||
for test in failed: | ||
all_failed.add(test) | ||
Comment on lines
+207
to
+208
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JFYI you can add a list to another list like this:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. all_failed is a set, not list. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, then JFYI
|
||
break | ||
if not all_failed: | ||
print("No failed tests in PR, returning") | ||
return | ||
|
||
print(f"PR had failed {len(all_failed)} tests, checking against landings builds") | ||
|
||
all_failed = test_against_job(all_failed, "daily-testing", 14) | ||
|
||
if all_failed: | ||
all_failed = test_against_job(all_failed, "weekly-testing", 4) | ||
|
||
if all_failed: | ||
all_failed = test_against_job(all_failed, "master", 14) | ||
|
||
if all_failed: | ||
print("Tests which only failed in the PR") | ||
for test in all_failed: | ||
print(test.full_info()) | ||
sys.exit(1) | ||
|
||
|
||
if __name__ == "__main__": | ||
main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is probably okay, but some distros point
python -> python2
so personally I tend to usepython3
hereThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hope we're beyond that now!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hopefully, but some older (still supported) distros still use python2 by default last I checked. Also, some distros don't symlink
python
at all. E.g.