Skip to content
Open
Show file tree
Hide file tree
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
17 changes: 17 additions & 0 deletions scripts/pkg_in_pipe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
171 changes: 171 additions & 0 deletions scripts/pkg_in_pipe/move_cards_to_state.py
Original file line number Diff line number Diff line change
@@ -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()