Skip to content
Open
Changes from all commits
Commits
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
340 changes: 340 additions & 0 deletions tools/bin/mbedtls-archive-epic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make the file executable.

""" Assist with the transition of a projectv1 to a projectv2.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or between projectv2, surely? AFAICT this does work FROM v2 as well, and it's what we want going forward.

This is currently used for management purposes and archiving EPICS.

It will only move public issues/pr's that have been closed/merged.

Requires a fully woking gh cli https://cli.github.com/ set-up
and an access token with the appropriate permissions.
"""
# Copyright The Mbed TLS Contributors
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License") you may
# not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os
import sys
import json
import requests
from shlex import split
from string import Template
from subprocess import Popen, check_output, PIPE
from pprint import pprint

token = os.environ['GITHUB_TOKEN']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please support gh auth token if the environment variable is unset.

Also please arrange for mbedtls-archive-epic.py --help to work even when no token is available.



######################### User Config ###########################
# Instuctions
# - Please ensure that your github cli token is present in your enviroment
# - Set the epic name to tranfer here.
# - Create the epic column to the arhive board, matching the src name.
# - If needed adjust the closed_after date to be in a resonable window
# - Fine tune max entries selection if some entries are missing.
# - Private issues and drafts are not picked up by the script.

epic = "3.6.1 patch release" # Name of epic that needs to be moved
# (It needs to match an empty epic on v2 board)
closed_after_date = "2024-01-01" # Only include entries closed after that date

max_entries = 500 # How many maximum entries the backend will populate.
debug = True # Set to True to print out the github cli commands.
Comment on lines +46 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these please be command line items? I don't want to have to modify the script to make it do something useful. Also, because there are prefilled default, I'm afraid that I'd forget to change something and I'd end up modifying something I didn't intend.


############################# Constants #############################
"""
The following values should remain the same across the project.
If need to modify them, they can querried as follows:

gh project list --owner Mbed-TLS number 13
NUMBER TITLE STATE ID
13 Past EPICs open PVT_kwDOBcuPHc4AgPo7

gh project --owner Mbed-TLS field-list 13
NAME DATA TYPE ID
Status ProjectV2SingleSelectField PVTSSF_lADOBcuPHc4AgPo7zgVai1o

"""
project_epics_for_mbedtls = "PVT_kwDOBcuPHc4AnF0W"
project_past_epics = "PVT_kwDOBcuPHc4AgPo7"
project_field_status = "PVTSSF_lADOBcuPHc4AgPo7zgVai1o"
project_field_owner = "Mbed-TLS"

projects_repo = "Mbed-TLS/mbedtls"
active_project = "EPICs for Mbed TLS"
archive_project = "Past EPICs"
archive_project_board_no = "13"

############################ Github Cli gap #########################
""" This snippet is covering functionality missing for the current version of github cli.
We need to querry the node-id for the Status SingleSelectField, since that is used as
epic name columns. """

gql_get_ids_querry = Template(
"""
query{
node(id: "$project_uid") {
... on ProjectV2 {
fields(first: 100) {
nodes {
... on ProjectV2SingleSelectField {
name options {
id
name
}
}
}
}
}
}
}"""
)

########################### Helpers #################################

class ShellExecResult:

"""
Class to encapsulate the returns from a shell execution.
"""

def __init__(self, success, stdout, stderr):

self.success = success
self.stdout = stdout
self.stderr = stderr


def do_shell_exec(exec_string, expected_result=0):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this just be subprocess.check_output?

  • I don't see this being used for the error cases, except to print additional error messages that aren't particularly helpful.
  • Sometimes the caller forgets to check the return code, it would be better to keep using an exception on failure.
  • Callers don't actually rely on having a shell, they're just passing arguments to one program, sometimes with dodgy quoting. So it would be easier to not use a shell.

"""
Helper function to do shell execution
exec_string - String to execute (as is - function will split)
expected_result - Expected return code.
"""

if debug:
print("CMD", exec_string)
shell_process = Popen(
split(exec_string),
stdin=PIPE,
stdout=PIPE,
stderr=PIPE)

(shell_stdout, shell_stderr) = shell_process.communicate()

return ShellExecResult(shell_process.returncode == expected_result,
shell_stdout.decode("utf-8"),
shell_stderr.decode("utf-8"))


def github_querry(query, token=token):
""" Create an authenticated querry to github"""

request = requests.post('https://api.github.com/graphql',
json={'query': query},
headers={"Authorization": "token " + token})
if request.status_code == 200:
return request.json()
else:
raise Exception(
"Query failed with code {}. Query: {}".format(
request.status_code, query))


def project_get_label_ids(project_id=project_past_epics):
"""Use a gql querry to retrieve the node-ids for the column names of projectV2 """

labels = []
querry = gql_get_ids_querry.substitute(project_uid=project_id)
result = github_querry(querry)
# clean up the result
nodes = [n for n in result["data"]["node"]["fields"]["nodes"] if n]
return nodes.pop()["options"]


####################### Github Cli Wrappers #########################
def get_issues_prs_for_project(issues=True,
is_closed=True,
from_date=closed_after_date,
count=max_entries,
repo=projects_repo):
""" Retrieve a maximum n=count number issues or pull requests closed after a date """

cmd = ("gh {0} --repo {5} list -L {3} -s all --search \"is:{0} is:{1} "
"{2}:>{4}\" --json 'number,projectItems'").format("issue" if issues else "pr",
"closed" if is_closed else "open",
"closed" if is_closed else "created",
count, from_date,
repo)
ret = do_shell_exec(cmd)
data = json.loads(ret.stdout)
data = [n for n in data if n["projectItems"]]

data = [{n["number"]: n["projectItems"]} for n in data]
return data

def link_issues_pr_linked_to_epic(raw_data, epic_name, projectboard = ""):
""" Parse a raw data output and return a list of items linked to a selected epic."""

linked = []
for i in raw_data:
for k, v in i.items():
for e in v:
if epic_name and projectboard:
if e["status"]["name"] == epic_name and e['title'] == projectboard:
linked.append(k)
else:
if e["status"]["name"] == epic_name:
linked.append(k)
return linked

def get_epic_items(column_name, status="both", projectboard=active_project, repo=projects_repo):
""" Return a structured data-set containing an epic name and all the open/closed issues in it."""

output = {"epic": column_name,
"issues": [], "prs": []}

if status not in ["open", "closed", "both"]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be an enum?

Or just not bother, since this function is always called with status="both"?

print("Error: Invalid entry for status:", status)
return
is_closed = "closed" == status
issues = get_issues_prs_for_project(issues=True, is_closed=is_closed, repo=repo)
output["issues"] = link_issues_pr_linked_to_epic(issues, column_name, projectboard)

prs = get_issues_prs_for_project(issues=False, is_closed=is_closed, repo=repo)
output["prs"] = link_issues_pr_linked_to_epic(prs, column_name, projectboard)

# If both is selected just toggle the is_closed state and append the results
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems a bit convoluted. Why not just omit both is:open and is:closed when status=="both"?

if status == "both":
is_closed ^=True
issues = get_issues_prs_for_project(issues=True, is_closed=is_closed, repo=repo)
output["issues"] += link_issues_pr_linked_to_epic(issues, column_name, projectboard)

prs = get_issues_prs_for_project(issues=False, is_closed=is_closed, repo=repo)
output["prs"] += link_issues_pr_linked_to_epic(prs, column_name, projectboard)
return output

def get_no_status_issues_prs_for_project(count=100):
""" Get the contents for the No Status Column in project v2 """

cmd = "gh project item-list --owner {} {} -L {} --format json".format(
project_field_owner, archive_project_board_no, count)

output = {}
ret = do_shell_exec(cmd)
data = json.loads(ret.stdout)

for entry in data["items"]:
if "status" not in entry.keys():
if entry["content"]["type"] == "Issue":
output[entry["content"]["number"]] = entry["id"]
else:
output[entry["content"]["number"]] = entry["id"]
return output


def move_issue_to_proj(issue, old_proj, new_proj, is_pr=False, repo=projects_repo):
""" Moves an issue across projects. It works across legacy and v2 projects """

# Remove the issue from old project
cmd = "gh {} edit {} --repo {} --{}-project \"{}\"".format(
"pr" if is_pr else "issue", issue, repo, "remove", old_proj)
ret = do_shell_exec(cmd)
if ret.success:
# #Add it to the new project
cmd = "gh {} edit {} --repo {} --{}-project \"{}\"".format(
"pr" if is_pr else "issue", issue, repo, "add", new_proj)
ret = do_shell_exec(cmd)
if ret.success:
print(
"Successuflly moved issue {} from project: {} -> {}".format(issue, old_proj, new_proj))
else:
print(cmd)
print(
"Failed to remove issue {} from project: {} sterr: {}".format(
issue, old_proj, ret.stderr))
else:
print(cmd)
print(
"Failed to remove issue {} from project: {} sterr: {}".format(
issue,
old_proj,
ret.stderr))


def move_to_column_project(item_node_id,
column_node_id,
field_node_id=project_field_status,
project_node_id=project_past_epics):
""" Move an issue/pr by its' node-id on a project v2 project """
cmd = ("gh project item-edit --id {} --field-id {} --project-id {} "
"--single-select-option-id {}").format(item_node_id,
field_node_id,
project_node_id,
column_node_id)
ret = do_shell_exec(cmd)
if not ret.success:
print(
"Failed to move issue {} sterr: {}".format(
item_node_id, ret.stderr))

def migrate_epic_to_archive(label_matches):
""""Contains logic required to move a project to the archives."""
column_node_id = label_matches[0]["id"]
print(label_matches)
epic_items = get_epic_items(epic)
from pprint import pprint
epic_name = epic_items["epic"]
epic_issues = epic_items["issues"]
epic_prs = epic_items["prs"]

# Ask for user confirmatwion
print("Found the following items:")
print("\nEpic Name:", epic_name)
print("\nIssues")
pprint(epic_issues)
print("\nPRs")
pprint(epic_prs)

usr = input("Procceed with move? Y/N?")
if usr.lower() != "y":
print("Error, aborting on user request")
sys.exit(1)

# Move items to new project
for issue in epic_issues:
move_issue_to_proj(issue, active_project, archive_project)

for pr in epic_prs:
move_issue_to_proj(pr, active_project, archive_project, is_pr=True)

# Get the new items by the No Status collumn
no_stat_isues = get_no_status_issues_prs_for_project()

# Move them to the matching epic
for pr_no, node_id in no_stat_isues.items():
move_to_column_project(node_id, column_node_id)
print("Moving {} to {}".format(pr_no, epic))

if __name__ == "__main__":

# Do not proceed if an empty epic has not been created
label_matches = [n for n in project_get_label_ids() if n["name"] == epic]

if not label_matches:
print(
"Error, please create an epic with name \"{}\" and re-run. ".format(epic))
sys.exit(1)

migrate_epic_to_archive(label_matches)