blob: df0359f934671c5e7563d9f02e8c04a5a2278fb9 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2020 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Find missing stable and backported mainline fix patches in chromeos."""
import logging
import os
import subprocess
import sys
import cloudsql_interface
import common
import gerrit_interface
import git_interface
import MySQLdb # pylint: disable=import-error
import requests # pylint: disable=import-error
# Constant representing number CL's we want created on single new missing patch run
NEW_CL_DAILY_LIMIT_PER_STABLE_BRANCH = 2
NEW_CL_DAILY_LIMIT_PER_BRANCH = 2
def get_subsequent_fixes(db, upstream_sha):
"""Finds all fixes that might be needed to fix the incoming commit"""
q = """
WITH RECURSIVE sha_graph (sha, ord) AS (
SELECT %s as sha, 1 as ord
UNION DISTINCT
SELECT fixedby_upstream_sha, ord + 1
FROM sha_graph
JOIN upstream_fixes
ON upstream_sha = sha
)
SELECT sha
FROM sha_graph
GROUP BY sha
ORDER BY MAX(ord);
"""
with db.cursor() as c:
c.execute(q, [upstream_sha])
return list(sum(c.fetchall(), ()))
def upstream_sha_to_kernel_sha(db, chosen_table, branch, upstream_sha):
"""Retrieves chromeos/stable sha by indexing db.
Returns sha or None if upstream sha doesn't exist downstream.
"""
q = f"""SELECT sha
FROM {chosen_table}
WHERE branch = %s
AND (upstream_sha = %s
OR patch_id IN (
SELECT patch_id
FROM linux_upstream
WHERE sha = %s
))"""
with db.cursor() as c:
c.execute(q, [branch, upstream_sha, upstream_sha])
row = c.fetchone()
return row[0] if row else None
def get_change_id(db, target_branch, sha):
"""Get Change-Id associated with provided upstream SHA.
Returns Gerrit Change-Id for a provided SHA if available in either
the chrome_fixes or the stable_fixes table as well as in Gerrit.
None otherwise.
If multiple Change IDs are available, pick one that has not been abandoned.
"""
logging.info("Looking up Change ID for upstream SHA %s", sha)
q = """SELECT c.fix_change_id, c.branch, s.fix_change_id, s.branch
FROM linux_upstream AS l1
JOIN linux_upstream AS l2
ON l1.patch_id = l2.patch_id
LEFT JOIN chrome_fixes AS c
ON c.fixedby_upstream_sha = l2.sha
LEFT JOIN stable_fixes as s
ON s.fixedby_upstream_sha = l2.sha
WHERE l1.sha = %s"""
with db.cursor() as c:
c.execute(q, [sha])
rows = c.fetchall()
change_id_cand, status_cand = None, None
reject_list = []
for (
chrome_change_id,
chrome_branch,
stable_change_id,
stable_branch,
) in rows:
logging.info(
"Database: %s %s %s %s",
chrome_change_id,
chrome_branch,
stable_change_id,
stable_branch,
)
# Some entries in fixes_table do not have a change id attached.
# This will be seen if a patch was identified as already merged
# or as duplicate. Skip those. Also skip empty entries returned
# by the query above.
# Return Change-Ids associated with abandoned CLs only if no other
# Change-Ids are found.
todo_list = []
if chrome_change_id and chrome_branch:
todo_list.append((chrome_change_id, chrome_branch))
if stable_change_id and stable_branch:
todo_list.append((stable_change_id, stable_branch))
for change_id, branch in todo_list:
try:
# Change-IDs stored in in chrome_fixes are not always available
# in Gerrit. This can happen, for example, if a commit was
# created using a git instance with pre-commit hook, and the
# commit was uploaded into Gerrit using a merge. We can not use
# such Change-Ids. To verify, try to get the status from Gerrit
# and skip if the Change-Id is not found.
gerrit_status = gerrit_interface.get_status(change_id, branch)
logging.info(
"Found Change ID %s in branch %s, status %s",
change_id,
branch,
gerrit_status,
)
# We can not use a Change-ID which exists in Gerrit but is marked
# as abandoned for the target branch.
if change_id in reject_list:
continue
if (
branch == target_branch
and gerrit_status == gerrit_interface.GerritStatus.ABANDONED
):
reject_list.append(change_id)
continue
if (
not change_id_cand
or gerrit_status != gerrit_interface.GerritStatus.ABANDONED
):
change_id_cand, status_cand = change_id, gerrit_status
if status_cand != gerrit_interface.GerritStatus.ABANDONED:
break
except requests.exceptions.HTTPError:
# Change-Id was not found in Gerrit
pass
if change_id_cand in reject_list:
change_id_cand, status_cand = None, None
reject = status_cand == gerrit_interface.GerritStatus.ABANDONED and bool(
reject_list
)
logging.info("Returning Change-Id %s, reject=%s", change_id_cand, reject)
return change_id_cand, reject
def update_duplicate_by_patch_id(db, branch, fixed_sha, fixedby_upstream_sha):
"""Update duplicate entry in chrome_fixes table by using patch_id.
Check if the commit is already in the fixes table using a different SHA.
The same commit may be listed upstream under multiple SHAs.
Update the table for duplicate entry if found.
Return True if:
- The patch is already in chrome_fixes table using a different upstream SHA with same patch_id.
- The duplicate entry is updated into chrome_fixes.
Otherwise, False.
"""
q = """SELECT c.kernel_sha, c.fixedby_upstream_sha, c.status
FROM chrome_fixes AS c
JOIN linux_upstream AS l1
ON c.fixedby_upstream_sha = l1.sha
JOIN linux_upstream AS l2
ON l1.patch_id = l2.patch_id
WHERE l1.sha != l2.sha
AND c.branch = %s
AND l2.sha = %s"""
with db.cursor() as c:
c.execute(q, [branch, fixedby_upstream_sha])
row = c.fetchone()
if not row:
return False
# This commit is already queued or known under a different upstream SHA.
# Mark it as abandoned and point to the other entry as reason.
kernel_sha, recorded_upstream_sha, status = row
# kernel_sha is the SHA in the database.
# fixed_sha is the SHA we are trying to fix, which matches kernel_sha
# (meaning both have the same patch ID).
# The entry we need to add to the fixes table is for fixed_sha, not for
# kernel_sha (because kernel_sha is already in the database).
reason = f"Already merged/queued into linux_chrome [upstream sha {recorded_upstream_sha}]"
# Status must be OPEN, MERGED or ABANDONED since we don't have
# entries with CONFLICT in the fixes table.
# Change OPEN to ABANDONED for final status, but keep MERGED.
final_status = status
if final_status == common.Status.OPEN.name:
final_status = common.Status.ABANDONED.name
# ABANDONED is not a valid initial status. Change it to OPEN
# if encountered.
if status == common.Status.ABANDONED.name:
status = common.Status.OPEN.name
entry_time = common.get_current_time()
q = """INSERT INTO chrome_fixes
(kernel_sha, fixedby_upstream_sha, branch, entry_time,
close_time, initial_status, status, reason)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)"""
try:
with db.cursor() as c:
c.execute(
q,
[
fixed_sha,
fixedby_upstream_sha,
branch,
entry_time,
entry_time,
status,
final_status,
reason,
],
)
db.commit()
logging.info(
"SHA %s [%s] fixed by %s: %s",
fixed_sha,
kernel_sha,
fixedby_upstream_sha,
reason,
)
except MySQLdb.Error as e: # pylint: disable=no-member
logging.error(
"Failed to insert an already merged/queued entry into chrome_fixes: error %d (%s)",
e.args[0],
e.args[1],
)
return True
def update_duplicate_by_patch_id2(db, branch, fixedby_upstream_sha):
"""Update duplicate by using patch_id.
Commit sha may have been modified in cherry-pick, backport, etc.
Retrieve SHA in linux_chrome by patch-id by checking for fixedby_upstream_sha.
Remove entries that are already tracked in chrome_fixes.
Return True if:
- The patch is already in chrome_fixes table using a different chrome SHAs with same
upstream SHA and patch_id.
- The duplicate entry is updated into chrome_fixes.
Otherwise, False.
"""
# luf and lcf are joined to restrict to only fixes that have landed
q = """SELECT lc.sha
FROM linux_chrome AS lc
JOIN linux_upstream AS lu
ON lc.patch_id = lu.patch_id
JOIN upstream_fixes as uf
ON lu.sha = uf.upstream_sha
JOIN linux_upstream AS luf
ON luf.sha = uf.fixedby_upstream_sha
JOIN linux_chrome AS lcf
ON lcf.patch_id = luf.patch_id
WHERE uf.fixedby_upstream_sha = %s AND lc.branch = %s AND lcf.branch = %s
AND (lc.sha, uf.fixedby_upstream_sha)
NOT IN (
SELECT kernel_sha, fixedby_upstream_sha
FROM chrome_fixes
WHERE branch = %s
)"""
with db.cursor() as c:
c.execute(q, [fixedby_upstream_sha, branch, branch, branch])
chrome_shas = c.fetchall()
# fixedby_upstream_sha has already been merged into linux_chrome
# chrome shas represent kernel sha for the upstream_sha fixedby_upstream_sha
if chrome_shas:
entry_time = common.get_current_time()
cl_status = common.Status.MERGED.name
reason = f"Already merged into linux_chrome [upstream sha {fixedby_upstream_sha}]"
q = """INSERT INTO chrome_fixes
(kernel_sha, fixedby_upstream_sha, branch, entry_time,
close_time, initial_status, status, reason)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)"""
for chrome_sha in chrome_shas:
try:
with db.cursor() as c:
c.execute(
q,
[
chrome_sha,
fixedby_upstream_sha,
branch,
entry_time,
entry_time,
cl_status,
cl_status,
reason,
],
)
db.commit()
except MySQLdb.Error as e: # pylint: disable=no-member
logging.error(
"Failed to insert an already merged entry into chrome_fixes: error %d(%s)",
e.args[0],
e.args[1],
)
return True
return False
def check_merged_by_patch_id(db, branch, fixed_sha, fixedby_upstream_sha):
"""Handles case where fixedby_upstream_sha may have changed in kernels.
Returns True if successful patch_id insertion and False if patch_id not found.
"""
updated = update_duplicate_by_patch_id(
db, branch, fixed_sha, fixedby_upstream_sha
)
if updated:
return True
return update_duplicate_by_patch_id2(db, branch, fixedby_upstream_sha)
def create_fix(
db,
chosen_table,
chosen_fixes,
branch,
kernel_sha,
fixedby_upstream_sha,
in_recursion=False,
):
"""Inserts fix row by checking status of applying a fix change.
Return True if we create a new Gerrit CL, otherwise return False.
"""
# Check if fix has been merged using it's patch-id since sha's might've changed
merged = check_merged_by_patch_id(
db, branch, kernel_sha, fixedby_upstream_sha
)
if merged:
return False
created_new_change = False
entry_time = common.get_current_time()
close_time = fix_change_id = reason = None
# Try applying patch and get status
handler = git_interface.commitHandler(
common.Kernel.linux_chrome, branch=branch, full_reset=not in_recursion
)
status = handler.cherrypick_status(fixedby_upstream_sha)
initial_status = status.name
current_status = status.name
if status == common.Status.MERGED:
# Create a row for the merged CL (we don't need to track this), but can be stored
# to indicate that the changes of this patch are already merged
# entry_time and close_time are the same since we weren't tracking when it was merged
fixedby_kernel_sha = upstream_sha_to_kernel_sha(
db, chosen_table, branch, fixedby_upstream_sha
)
logging.info(
"%s SHA [%s] already merged bugfix patch [kernel: %s] [upstream: %s]",
chosen_fixes,
kernel_sha,
fixedby_kernel_sha,
fixedby_upstream_sha,
)
reason = "Patch applied to linux_chrome before this robot was run"
close_time = entry_time
# linux_chrome will have change-id's but stable merged fixes will not
# Correctly located fixedby_kernel_sha in linux_chrome
if chosen_table == "linux_chrome" and fixedby_kernel_sha:
fix_change_id = git_interface.get_commit_changeid_linux_chrome(
fixedby_kernel_sha
)
elif status == common.Status.OPEN:
change_id, rejected = get_change_id(db, branch, fixedby_upstream_sha)
if rejected:
# A change-Id for this commit exists in the target branch, but it is marked
# as ABANDONED and we can not use it. Create a database entry and mark the commit
# as abandoned to prevent it from being retried over and over again.
current_status = common.Status.ABANDONED.name
else:
fix_change_id = gerrit_interface.create_change(
kernel_sha,
fixedby_upstream_sha,
branch,
chosen_table == "linux_chrome",
change_id,
)
created_new_change = bool(fix_change_id)
# Checks if change was created successfully
if not created_new_change:
logging.error(
"Failed to create change for kernel_sha %s fixed by %s",
kernel_sha,
fixedby_upstream_sha,
)
return False
cloudsql_interface.insert_journal(
db, kernel_sha, branch, f"Created Gerrit CL {fix_change_id}"
)
if not gerrit_interface.label_cq_plus1(branch, fix_change_id):
logging.info(
"Failed to CQ+1 for branch:%s %s", branch, fix_change_id
)
elif status == common.Status.CONFLICT:
# Register conflict entry_time, do not create gerrit CL
# Requires engineer to manually explore why CL doesn't apply cleanly
cloudsql_interface.insert_journal(
db,
kernel_sha,
branch,
f"Detected patch fixed by {fixedby_upstream_sha} conflicts",
)
q = f"""INSERT INTO {chosen_fixes}
(kernel_sha, fixedby_upstream_sha, branch, entry_time, close_time,
fix_change_id, initial_status, status, reason)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)"""
try:
with db.cursor() as c:
c.execute(
q,
[
kernel_sha,
fixedby_upstream_sha,
branch,
entry_time,
close_time,
fix_change_id,
initial_status,
current_status,
reason,
],
)
logging.info(
"Inserted row into fixes table %s %s %s %s %s %s %s %s %s %s",
chosen_fixes,
kernel_sha,
fixedby_upstream_sha,
branch,
entry_time,
close_time,
fix_change_id,
initial_status,
current_status,
reason,
)
except MySQLdb.Error as e: # pylint: disable=no-member
logging.error(
"Error inserting fix CL into fixes table %s %s %s %s %s %s %s %s %s %s: error %d(%s)",
chosen_fixes,
kernel_sha,
fixedby_upstream_sha,
branch,
entry_time,
close_time,
fix_change_id,
initial_status,
current_status,
reason,
e.args[0],
e.args[1],
)
if created_new_change and not in_recursion:
subsequent_fixes = get_subsequent_fixes(db, fixedby_upstream_sha)
for fix in subsequent_fixes[
1:
]: # 1st returned SHA is fixedby_upstream_sha
logging.info(
"SHA %s recursively fixed by: %s", fixedby_upstream_sha, fix
)
create_fix(
db,
chosen_table,
chosen_fixes,
branch,
kernel_sha,
fix,
in_recursion=True,
)
return created_new_change
def fixup_unmerged_patches(db, branch, kernel_metadata, limit):
"""Fixup script that attempts to reapply unmerged fixes to get latest status.
2 main actions performed by script include:
1) Handle case where a conflicting CL later can be applied cleanly without merge conflicts
2) Detect if the fix has been applied to linux_chrome externally
(i.e not merging through a fix created by this robot)
"""
fixes_table = kernel_metadata.kernel_fixes_table
q = f"""SELECT kernel_sha, fixedby_upstream_sha, status, fix_change_id
FROM {fixes_table}
WHERE status != 'MERGED'
AND branch = %s"""
handler = git_interface.commitHandler(common.Kernel.linux_chrome, branch)
with db.cursor() as c:
c.execute(q, [branch])
rows = c.fetchall()
for kernel_sha, fixedby_upstream_sha, status, fix_change_id in rows:
new_status_enum = handler.cherrypick_status(
fixedby_upstream_sha, apply=status != "ABANDONED"
)
if not new_status_enum:
continue
new_status = new_status_enum.name
if status == "CONFLICT" and new_status == "OPEN":
change_id, rejected = get_change_id(
db, branch, fixedby_upstream_sha
)
if rejected:
# The cherry-pick was successful, but the commit is marked as abandoned
# in gerrit. Mark it accordingly.
reason = "Fix abandoned in Gerrit"
cloudsql_interface.update_change_abandoned(
db, fixes_table, kernel_sha, fixedby_upstream_sha, reason
)
else:
# Treat it unlimited if `limit` is None.
if limit is not None and limit <= 0:
cloudsql_interface.insert_journal(
db,
kernel_sha,
branch,
f"Deferred to create CL for {fixedby_upstream_sha} due to rate limit",
)
continue
fix_change_id = gerrit_interface.create_change(
kernel_sha,
fixedby_upstream_sha,
branch,
kernel_metadata.path == "linux_chrome",
change_id,
)
# Check if we successfully created the fix patch before performing update
if fix_change_id:
cloudsql_interface.update_conflict_to_open(
db,
fixes_table,
kernel_sha,
fixedby_upstream_sha,
fix_change_id,
)
if not gerrit_interface.label_cq_plus1(
branch, fix_change_id
):
logging.info(
"Failed to CQ+1 for branch:%s %s",
branch,
fix_change_id,
)
if limit:
limit -= 1
elif new_status == "MERGED":
# This specifically includes situations where a patch marked ABANDONED
# in the database was merged at some point anyway.
reason = "Fix was merged externally and detected by robot."
if fix_change_id:
gerrit_interface.abandon_change(fix_change_id, branch, reason)
cloudsql_interface.update_change_merged(
db, fixes_table, kernel_sha, fixedby_upstream_sha, reason
)
def update_fixes_in_branch(db, branch, kernel_metadata, limit):
"""Updates fix patch table row by determining if CL merged into linux_chrome."""
chosen_fixes = kernel_metadata.kernel_fixes_table
# Old rows to Update
q = f"""UPDATE {chosen_fixes} AS fixes
JOIN linux_chrome AS lc
ON fixes.fixedby_upstream_sha = lc.upstream_sha
SET status = 'MERGED', close_time = %s, reason = %s
WHERE fixes.branch = %s
AND lc.branch = %s
AND (fixes.status = 'OPEN'
OR fixes.status = 'CONFLICT'
OR fixes.status = 'ABANDONED')"""
close_time = common.get_current_time()
reason = "Patch has been applied to linux_chome"
try:
with db.cursor() as c:
c.execute(q, [close_time, reason, branch, branch])
logging.info(
"Updating rows that have been merged into linux_chrome in table %s / branch %s",
chosen_fixes,
branch,
)
except MySQLdb.Error as e: # pylint: disable=no-member
logging.error(
"Error updating fixes table for merged commits %s %s %s %s: %d(%s)",
chosen_fixes,
close_time,
reason,
branch,
e.args[0],
e.args[1],
)
db.commit()
# Sync status of unmerged patches in a branch
fixup_unmerged_patches(db, branch, kernel_metadata, limit)
def create_new_fixes_in_branch(db, branch, kernel_metadata, limit):
"""Look for missing Fixup commits in provided chromeos or stable release."""
branch_name = kernel_metadata.get_kernel_branch(branch)
logging.info("Checking branch %s", branch_name)
subprocess.run(
["git", "checkout", branch_name],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# chosen_table is either linux_stable or linux_chrome
chosen_table = kernel_metadata.path
chosen_fixes = kernel_metadata.kernel_fixes_table
# New rows to insert
# Note: MySQLdb doesn't support inserting table names as parameters
# due to sql injection
q = f"""SELECT chosen_table.sha, uf.fixedby_upstream_sha
FROM {chosen_table} AS chosen_table
JOIN upstream_fixes AS uf
ON chosen_table.upstream_sha = uf.upstream_sha
WHERE branch = %s
AND (chosen_table.sha, uf.fixedby_upstream_sha)
NOT IN (
SELECT chosen_fixes.kernel_sha, chosen_fixes.fixedby_upstream_sha
FROM {chosen_fixes} AS chosen_fixes
WHERE branch = %s
)"""
try:
with db.cursor() as c:
c.execute(q, [branch, branch])
rows = c.fetchall()
if rows:
logging.info(
"Finding new rows to insert into fixes table %s %s %s",
chosen_table,
chosen_fixes,
branch,
)
except MySQLdb.Error as e: # pylint: disable=no-member
logging.error(
"Error finding new rows to insert %s %s %s: error %d(%s)",
chosen_table,
chosen_fixes,
branch,
e.args[0],
e.args[1],
)
# todo(hirthanan): Create an intermediate state in Status that allows us to
# create all the patches in chrome/stable fixes tables but does not add reviewers
# until quota is available. This should decouple the creation of gerrit CL's
# and adding reviewers to those CL's.
for kernel_sha, fixedby_upstream_sha in rows:
# Treat it unlimited if `limit` is None.
if limit is not None and limit <= 0:
cloudsql_interface.insert_journal(
db,
kernel_sha,
branch,
f"Deferred to create CL for {fixedby_upstream_sha} due to rate limit",
)
continue
created = create_fix(
db,
chosen_table,
chosen_fixes,
branch,
kernel_sha,
fixedby_upstream_sha,
)
if created and limit:
limit -= 1
db.commit()
def missing_patches_sync(db, kernel_metadata, sync_branch_method, limit=None):
"""Helper to create or update fix patches in stable and chromeos releases."""
if len(sys.argv) > 1:
branches = sys.argv[1:]
else:
branches = common.CHROMEOS_BRANCHES
os.chdir(common.get_kernel_absolute_path(kernel_metadata.path))
for b in branches:
sync_branch_method(db, b, kernel_metadata, limit)
os.chdir(common.WORKDIR)
def new_missing_patches():
"""Rate limit calling create_new_fixes_in_branch."""
with common.connect_db() as db:
kernel_metadata = common.get_kernel_metadata(common.Kernel.linux_stable)
missing_patches_sync(
db,
kernel_metadata,
create_new_fixes_in_branch,
NEW_CL_DAILY_LIMIT_PER_STABLE_BRANCH,
)
kernel_metadata = common.get_kernel_metadata(common.Kernel.linux_chrome)
missing_patches_sync(
db,
kernel_metadata,
create_new_fixes_in_branch,
NEW_CL_DAILY_LIMIT_PER_BRANCH,
)
def update_missing_patches():
"""Updates fixes table entries on regular basis."""
with common.connect_db() as db:
kernel_metadata = common.get_kernel_metadata(common.Kernel.linux_stable)
missing_patches_sync(
db,
kernel_metadata,
update_fixes_in_branch,
NEW_CL_DAILY_LIMIT_PER_STABLE_BRANCH,
)
kernel_metadata = common.get_kernel_metadata(common.Kernel.linux_chrome)
missing_patches_sync(
db,
kernel_metadata,
update_fixes_in_branch,
NEW_CL_DAILY_LIMIT_PER_BRANCH,
)