Skip to content

Commit 7af595e

Browse files
committed
pkg_in_pipe: move the plane cards of the builds of a tag to a given state
Add move_cards_to_state.py, which keeps the plane cards of the XCPNG project in sync with the progress of the builds: the cards linked to the builds of a given koji tag are moved to a given state, and never backwards. Signed-off-by: Gaëtan Lehmann <gaetan.lehmann@vates.tech>
1 parent 1d6fe54 commit 7af595e

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

scripts/pkg_in_pipe/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ The json report can be validated against its schema with:
2828
python -m jsonschema -i report.json pkg_in_pipe.schema.json
2929
```
3030

31+
# Move the cards to a state
32+
33+
The `move_cards_to_state.py` script moves the plane cards linked to the builds of a koji tag to a
34+
given state of the XCPNG project. It reads the json report generated by `pkg_in_pipe.py` and
35+
moves the cards by default, unless the `--dry-run` option is used:
36+
37+
```sh
38+
move_cards_to_state.py --report report.json
39+
move_cards_to_state.py --report report.json --tag v8.3-ci --state CI --dry-run
40+
```
41+
42+
A card is only moved when it is not already in the destination state or in a later one
43+
(according to the state order of the project): cards are never moved backwards.
44+
A card not found on plane is skipped, with a warning.
45+
The moves are performed with the plane api, using the token passed through the `PLANE_TOKEN`
46+
environment variable or the `--plane-token` option.
47+
3148
# Run in docker
3249

3350
Before running in docker, the docker image must be built with:
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env python
2+
"""Move the plane cards related to the builds of a koji tag to a given state.
3+
4+
Read the json report generated by pkg_in_pipe.py and, for each plane card linked
5+
to a build of a given koji tag, move the card to a given state of the XCPNG
6+
project, unless the card is already in that state or in a later one.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import os
13+
import sys
14+
15+
import requests
16+
from pydantic import BaseModel
17+
18+
PLANE_BASE_URL = (
19+
'https://project.vates.tech/api/v1/workspaces/vates-global/projects/'
20+
'43438eec-1335-4fc2-8804-5a4c32f4932d/'
21+
)
22+
YELLOW = '\x1b[33m'
23+
RESET = '\x1b[0m'
24+
TERMINAL_STATES = {'cancelled', 'released', 'archived'}
25+
26+
27+
def warn(message: str) -> None:
28+
print(f'{YELLOW}warning: {message}{RESET}', file=sys.stderr)
29+
30+
31+
class Issue(BaseModel):
32+
sequence_id: int
33+
34+
35+
class Build(BaseModel):
36+
issues: list[Issue]
37+
38+
39+
class TagReport(BaseModel):
40+
tag: str
41+
builds: list[Build]
42+
43+
44+
class Report(BaseModel):
45+
tags: list[TagReport]
46+
47+
48+
class PlaneIssue(BaseModel):
49+
id: str
50+
sequence_id: int
51+
state: str | None
52+
53+
54+
class State(BaseModel):
55+
id: str
56+
name: str
57+
sequence: float
58+
59+
60+
def get_plane_paginated(session: requests.Session, url: str) -> list[dict]:
61+
all_results: list[dict] = []
62+
cursor = None
63+
while True:
64+
response = session.get(url, params={'cursor': cursor} if cursor else {})
65+
if response.status_code != 200:
66+
raise RuntimeError(f'got a {response.status_code} response from {url}')
67+
data = response.json()
68+
all_results.extend(data.get('results', []))
69+
cursor = data.get('next_cursor')
70+
if not data.get('next_page_results', False) or not cursor:
71+
break
72+
return all_results
73+
74+
75+
def find_tag_report(report: Report, tag: str) -> TagReport:
76+
for tag_report in report.tags:
77+
if tag_report.tag == tag:
78+
return tag_report
79+
raise SystemExit(f'error: the tag {tag} is not present in the report')
80+
81+
82+
def find_state(states: list[State], name: str) -> State:
83+
for state in states:
84+
if state.name.lower() == name.lower():
85+
return state
86+
raise SystemExit(f'error: no state named {name} in the project')
87+
88+
89+
def card_uri(sequence_id: int) -> str:
90+
return f'https://project.vates.tech/vates-global/browse/XCPNG-{sequence_id}/'
91+
92+
93+
def cards_in_order(tag_report: TagReport) -> list[Issue]:
94+
cards: list[Issue] = []
95+
for build in tag_report.builds:
96+
for issue in build.issues:
97+
if all(card.sequence_id != issue.sequence_id for card in cards):
98+
cards.append(issue)
99+
return cards
100+
101+
102+
def parse_args() -> argparse.Namespace:
103+
parser = argparse.ArgumentParser(
104+
description='Move the plane cards related to the builds of a koji tag to a given state'
105+
)
106+
parser.add_argument('--report', help='The json report generated by pkg_in_pipe.py', default='report.json')
107+
parser.add_argument('--tag', help='The koji tag whose builds are concerned', default='v8.3-ci')
108+
parser.add_argument('--state', help='The plane state to move the cards to', default='CI (ci)')
109+
parser.add_argument(
110+
'--plane-token', help="The token used to access the plane api", default=os.environ.get('PLANE_TOKEN')
111+
)
112+
parser.add_argument('--dry-run', help='Print what would be done without changing anything', action='store_true')
113+
return parser.parse_args()
114+
115+
116+
def main() -> None:
117+
args = parse_args()
118+
if not args.plane_token:
119+
raise SystemExit(
120+
'error: the plane token is required, set the PLANE_TOKEN environment variable '
121+
'or use the --plane-token option'
122+
)
123+
report = Report.model_validate_json(open(args.report).read())
124+
tag_report = find_tag_report(report, args.tag)
125+
session = requests.Session()
126+
session.headers['x-api-key'] = args.plane_token
127+
states = [State.model_validate(s) for s in get_plane_paginated(session, PLANE_BASE_URL + 'states/')]
128+
destination = find_state(states, args.state)
129+
state_by_id = {state.id: state for state in states}
130+
issues = [
131+
PlaneIssue.model_validate(i)
132+
for i in get_plane_paginated(session, PLANE_BASE_URL + 'issues/')
133+
]
134+
issue_by_sequence = {issue.sequence_id: issue for issue in issues}
135+
moved = already = later = missing = 0
136+
for card in cards_in_order(tag_report):
137+
issue = issue_by_sequence.get(int(card.sequence_id))
138+
if issue is None:
139+
print(f'XCPNG-{card.sequence_id} {card_uri(card.sequence_id)}: not found in plane, skipped')
140+
missing += 1
141+
continue
142+
label = f'XCPNG-{issue.sequence_id} {card_uri(issue.sequence_id)}'
143+
current = state_by_id.get(issue.state) if issue.state else None
144+
if current is None or current.sequence < destination.sequence:
145+
if current and current.name.lower() in TERMINAL_STATES:
146+
warn(f'moving {label} from {current.name} to {destination.name} (wrong order)')
147+
if args.dry_run:
148+
print(f'{label} {current.name if current else "none"} -> {destination.name} (would move)')
149+
else:
150+
response = session.patch(
151+
PLANE_BASE_URL + f'issues/{issue.id}/', json={'state': destination.id}
152+
)
153+
if response.status_code != 200:
154+
raise RuntimeError(f'got a {response.status_code} response when moving XCPNG-{card.sequence_id}')
155+
print(f'{label} {current.name if current else "none"} -> {destination.name} (moved)')
156+
moved += 1
157+
elif current.id == destination.id:
158+
print(f'{label} already in {destination.name}')
159+
already += 1
160+
else:
161+
print(f'{label} {current.name} (after {destination.name}, skipped)')
162+
later += 1
163+
action = 'would move' if args.dry_run else 'moved'
164+
print(
165+
f'{moved} {action}, {already} already in {destination.name}, {later} in a later state, '
166+
f'{missing} not found'
167+
)
168+
169+
170+
if __name__ == '__main__':
171+
main()

0 commit comments

Comments
 (0)