diff --git a/scripts/pkg_in_pipe/README.md b/scripts/pkg_in_pipe/README.md index 66b4d31d..8373bf78 100644 --- a/scripts/pkg_in_pipe/README.md +++ b/scripts/pkg_in_pipe/README.md @@ -28,6 +28,23 @@ The json report can be validated against its schema with: python -m jsonschema -i report.json pkg_in_pipe.schema.json ``` +# Move the cards to a state + +The `move_cards_to_state.py` script moves the plane cards linked to the builds of a koji tag to a +given state of the XCPNG project. It reads the json report generated by `pkg_in_pipe.py` and +moves the cards by default, unless the `--dry-run` option is used: + +```sh +move_cards_to_state.py --report report.json +move_cards_to_state.py --report report.json --tag v8.3-ci --state CI --dry-run +``` + +A card is only moved when it is not already in the destination state or in a later one +(according to the state order of the project): cards are never moved backwards. +A card not found on plane is skipped, with a warning. +The moves are performed with the plane api, using the token passed through the `PLANE_TOKEN` +environment variable or the `--plane-token` option. + # Run in docker Before running in docker, the docker image must be built with: diff --git a/scripts/pkg_in_pipe/move_cards_to_state.py b/scripts/pkg_in_pipe/move_cards_to_state.py new file mode 100755 index 00000000..819387ba --- /dev/null +++ b/scripts/pkg_in_pipe/move_cards_to_state.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python +"""Move the plane cards related to the builds of a koji tag to a given state. + +Read the json report generated by pkg_in_pipe.py and, for each plane card linked +to a build of a given koji tag, move the card to a given state of the XCPNG +project, unless the card is already in that state or in a later one. +""" + +from __future__ import annotations + +import argparse +import os +import sys + +import requests +from pydantic import BaseModel + +PLANE_BASE_URL = ( + 'https://project.vates.tech/api/v1/workspaces/vates-global/projects/' + '43438eec-1335-4fc2-8804-5a4c32f4932d/' +) +YELLOW = '\x1b[33m' +RESET = '\x1b[0m' +TERMINAL_STATES = {'cancelled', 'released', 'archived'} + + +def warn(message: str) -> None: + print(f'{YELLOW}warning: {message}{RESET}', file=sys.stderr) + + +class Issue(BaseModel): + sequence_id: int + + +class Build(BaseModel): + issues: list[Issue] + + +class TagReport(BaseModel): + tag: str + builds: list[Build] + + +class Report(BaseModel): + tags: list[TagReport] + + +class PlaneIssue(BaseModel): + id: str + sequence_id: int + state: str | None + + +class State(BaseModel): + id: str + name: str + sequence: float + + +def get_plane_paginated(session: requests.Session, url: str) -> list[dict]: + all_results: list[dict] = [] + cursor = None + while True: + response = session.get(url, params={'cursor': cursor} if cursor else {}) + if response.status_code != 200: + raise RuntimeError(f'got a {response.status_code} response from {url}') + data = response.json() + all_results.extend(data.get('results', [])) + cursor = data.get('next_cursor') + if not data.get('next_page_results', False) or not cursor: + break + return all_results + + +def find_tag_report(report: Report, tag: str) -> TagReport: + for tag_report in report.tags: + if tag_report.tag == tag: + return tag_report + raise SystemExit(f'error: the tag {tag} is not present in the report') + + +def find_state(states: list[State], name: str) -> State: + for state in states: + if state.name.lower() == name.lower(): + return state + raise SystemExit(f'error: no state named {name} in the project') + + +def card_uri(sequence_id: int) -> str: + return f'https://project.vates.tech/vates-global/browse/XCPNG-{sequence_id}/' + + +def cards_in_order(tag_report: TagReport) -> list[Issue]: + cards: list[Issue] = [] + for build in tag_report.builds: + for issue in build.issues: + if all(card.sequence_id != issue.sequence_id for card in cards): + cards.append(issue) + return cards + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description='Move the plane cards related to the builds of a koji tag to a given state' + ) + parser.add_argument('--report', help='The json report generated by pkg_in_pipe.py', default='report.json') + parser.add_argument('--tag', help='The koji tag whose builds are concerned', default='v8.3-ci') + parser.add_argument('--state', help='The plane state to move the cards to', default='CI (ci)') + parser.add_argument( + '--plane-token', help="The token used to access the plane api", default=os.environ.get('PLANE_TOKEN') + ) + parser.add_argument('--dry-run', help='Print what would be done without changing anything', action='store_true') + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if not args.plane_token: + raise SystemExit( + 'error: the plane token is required, set the PLANE_TOKEN environment variable ' + 'or use the --plane-token option' + ) + report = Report.model_validate_json(open(args.report).read()) + tag_report = find_tag_report(report, args.tag) + session = requests.Session() + session.headers['x-api-key'] = args.plane_token + states = [State.model_validate(s) for s in get_plane_paginated(session, PLANE_BASE_URL + 'states/')] + destination = find_state(states, args.state) + state_by_id = {state.id: state for state in states} + issues = [ + PlaneIssue.model_validate(i) + for i in get_plane_paginated(session, PLANE_BASE_URL + 'issues/') + ] + issue_by_sequence = {issue.sequence_id: issue for issue in issues} + moved = already = later = missing = 0 + for card in cards_in_order(tag_report): + issue = issue_by_sequence.get(int(card.sequence_id)) + if issue is None: + print(f'XCPNG-{card.sequence_id} {card_uri(card.sequence_id)}: not found in plane, skipped') + missing += 1 + continue + label = f'XCPNG-{issue.sequence_id} {card_uri(issue.sequence_id)}' + current = state_by_id.get(issue.state) if issue.state else None + if current is None or current.sequence < destination.sequence: + if current and current.name.lower() in TERMINAL_STATES: + warn(f'moving {label} from {current.name} to {destination.name} (wrong order)') + if args.dry_run: + print(f'{label} {current.name if current else "none"} -> {destination.name} (would move)') + else: + response = session.patch( + PLANE_BASE_URL + f'issues/{issue.id}/', json={'state': destination.id} + ) + if response.status_code != 200: + raise RuntimeError(f'got a {response.status_code} response when moving XCPNG-{card.sequence_id}') + print(f'{label} {current.name if current else "none"} -> {destination.name} (moved)') + moved += 1 + elif current.id == destination.id: + print(f'{label} already in {destination.name}') + already += 1 + else: + print(f'{label} {current.name} (after {destination.name}, skipped)') + later += 1 + action = 'would move' if args.dry_run else 'moved' + print( + f'{moved} {action}, {already} already in {destination.name}, {later} in a later state, ' + f'{missing} not found' + ) + + +if __name__ == '__main__': + main()