429 lines
12 KiB
Python
429 lines
12 KiB
Python
# cspell:words viewbox
|
|
|
|
import argparse
|
|
import enum
|
|
import json
|
|
import os
|
|
from typing import Literal, Optional, TypedDict
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from allspice import AllSpice, DesignReview
|
|
|
|
|
|
class Importance(enum.Enum):
|
|
CRITICAL = "Critical"
|
|
MAJOR = "Major"
|
|
MINOR = "Minor"
|
|
TRIVIAL = "Trivial"
|
|
|
|
|
|
CALLOUT_LEVEL_FOR_IMPORTANCE = {
|
|
Importance.CRITICAL.value: "CAUTION",
|
|
Importance.MAJOR.value: "IMPORTANT",
|
|
Importance.MINOR.value: "TIP",
|
|
Importance.TRIVIAL.value: "NOTE",
|
|
}
|
|
"""
|
|
GFM Callout level for each importance level
|
|
"""
|
|
|
|
|
|
def format_importance_for_comment(importance: str) -> str:
|
|
return f">[!{CALLOUT_LEVEL_FOR_IMPORTANCE[importance]}]"
|
|
|
|
|
|
class ReviewComment(TypedDict):
|
|
description: str
|
|
summary: str
|
|
page: Optional[int]
|
|
elements: Optional[str]
|
|
suggestion: Optional[str]
|
|
importance: str
|
|
|
|
|
|
class ReviewJson(TypedDict):
|
|
overview: str
|
|
comments: list
|
|
page_view_bounds: list[list[float]]
|
|
|
|
|
|
Position = dict[Literal["x", "y"], float]
|
|
|
|
|
|
def determine_y_shift(
|
|
svg: ET.ElementTree,
|
|
) -> float:
|
|
"""
|
|
There seems to be two cases in the SVG generation:
|
|
|
|
1. If the pages do not have a viewbox set in the IR, the first page is
|
|
shifted down by the height of the page. This is the case for KiCad,
|
|
OrCAD, SDAX and Altium schematics.
|
|
2. If the pages have a viewbox set in the IR, the first page is not shifted;
|
|
this is the case for HDL schematics.
|
|
|
|
This y shift ends up affecting the viewbox of the page element in the SVG,
|
|
and so we need to account for it when calculating the viewbox of the page.
|
|
"""
|
|
|
|
y_shift = 0.0
|
|
|
|
for child in svg.getroot():
|
|
if child.tag == "{http://www.w3.org/2000/svg}g":
|
|
# This is the first page.
|
|
transform = child.get("transform")
|
|
if not transform:
|
|
return y_shift
|
|
y_transform = transform.split("translate(")[1].split(",")[1].rstrip(")")
|
|
y_transform = float(y_transform)
|
|
|
|
height = float(child.get("data-height", "0mm").rstrip("mm"))
|
|
if abs(height - y_transform) < 1:
|
|
# There is a y shift.
|
|
y_shift = y_transform
|
|
return y_shift
|
|
|
|
return y_shift
|
|
|
|
|
|
def find_page_by_id(
|
|
svg: ET.ElementTree,
|
|
page_id: str,
|
|
) -> Optional[ET.Element]:
|
|
"""
|
|
Find a page element by the given ID.
|
|
"""
|
|
|
|
root = svg.getroot()
|
|
for child in root:
|
|
if (
|
|
child.tag == "{http://www.w3.org/2000/svg}g"
|
|
and child.get("data-document-id") == page_id
|
|
):
|
|
return child
|
|
return None
|
|
|
|
|
|
def find_page_viewbox(
|
|
page: ET.Element,
|
|
y_shift: float,
|
|
) -> list[float]:
|
|
"""
|
|
Find the viewbox of the page element.
|
|
"""
|
|
|
|
view_box = page.get("data-view-box")
|
|
if not view_box:
|
|
raise ValueError(f"No viewbox found in page element {page.get('id')}")
|
|
|
|
[top_left, bottom_right] = view_box.split(" ")
|
|
top_left = [float(coord) for coord in top_left.split(",")]
|
|
bottom_right = [float(coord) for coord in bottom_right.split(",")]
|
|
view_box = [
|
|
top_left[0],
|
|
round(top_left[1] + y_shift, 6),
|
|
bottom_right[0],
|
|
bottom_right[1],
|
|
]
|
|
|
|
return view_box
|
|
|
|
|
|
def find_component_by_id(
|
|
page: ET.Element,
|
|
component_id: str,
|
|
) -> Optional[ET.Element]:
|
|
"""
|
|
Find a component element by the given ID.
|
|
"""
|
|
|
|
for child in page:
|
|
if (
|
|
child.tag == "{http://www.w3.org/2000/svg}g"
|
|
and child.get("data-path") == "components"
|
|
and child.get("data-id") == component_id
|
|
):
|
|
return child
|
|
return None
|
|
|
|
|
|
def x_y_from_transform(
|
|
transform: Optional[str],
|
|
) -> Optional[Position]:
|
|
"""
|
|
Get the x and y coordinates from the transform string.
|
|
"""
|
|
|
|
if not transform:
|
|
return None
|
|
x, y = transform.lstrip("translate(").rstrip(")").split(",")
|
|
x = float(x)
|
|
y = float(y)
|
|
return {"x": x, "y": y}
|
|
|
|
|
|
def find_component_position(
|
|
page: ET.Element,
|
|
component_id: str,
|
|
) -> Optional[Position]:
|
|
"""
|
|
Find the position of a component in the page element.
|
|
"""
|
|
|
|
for child in page:
|
|
if (
|
|
child.tag == "{http://www.w3.org/2000/svg}g"
|
|
and child.get("data-path") == "components"
|
|
and child.get("data-id") == component_id
|
|
):
|
|
transform = child.get("transform")
|
|
return x_y_from_transform(transform)
|
|
return None
|
|
|
|
|
|
def position_of_element(
|
|
element_designator: str,
|
|
ir_page: dict,
|
|
svg_page: ET.Element,
|
|
) -> Optional[Position]:
|
|
split_designator = element_designator.split(".")
|
|
component_designator = split_designator[0]
|
|
if len(split_designator) > 1:
|
|
pin_designator = split_designator[1]
|
|
else:
|
|
pin_designator = None
|
|
|
|
component = None
|
|
|
|
for component in ir_page.get("components", {}).values():
|
|
if component.get("reference") == component_designator:
|
|
component = component
|
|
break
|
|
|
|
if not component:
|
|
return None
|
|
|
|
component_id = component["id"]
|
|
svg_component = find_component_by_id(svg_page, component_id)
|
|
|
|
if svg_component is None:
|
|
return component.get("position")
|
|
component_position = x_y_from_transform(svg_component.get("transform"))
|
|
if not component_position:
|
|
return component.get("position")
|
|
|
|
if pin_designator:
|
|
pin = None
|
|
for ipin in component.get("pins", {}).values():
|
|
if ipin.get("designator") == pin_designator:
|
|
pin = ipin
|
|
break
|
|
if not pin:
|
|
return component_position
|
|
|
|
pin_position = pin.get("position")
|
|
if not pin_position:
|
|
return component_position
|
|
|
|
x = component_position["x"] + pin_position["x"]
|
|
y = component_position["y"] + pin_position["y"]
|
|
return {"x": x, "y": y}
|
|
|
|
return component_position
|
|
|
|
|
|
def percentage_view_box(
|
|
view_box: list[float], view_bounds: list[float], shifted: bool
|
|
) -> list[float]:
|
|
margin = 2.5
|
|
sig_figs = 6
|
|
width = view_bounds[2]
|
|
height = view_bounds[3]
|
|
percentage_view_box = [
|
|
(view_box[0] - view_bounds[0]) / width * 100,
|
|
(view_box[1] - view_bounds[1]) / height * 100,
|
|
(view_box[2] - view_bounds[0]) / width * 100,
|
|
(view_box[3] - view_bounds[1]) / height * 100,
|
|
]
|
|
if shifted:
|
|
view_coords = [
|
|
max(0, round(percentage_view_box[0] - margin, sig_figs)),
|
|
max(0, round((100 - percentage_view_box[3]) - margin, sig_figs)),
|
|
min(100, round(percentage_view_box[2] + margin, sig_figs)),
|
|
min(100, round((100 - percentage_view_box[1]) + margin, sig_figs)),
|
|
]
|
|
else:
|
|
view_coords = [
|
|
max(0, round(percentage_view_box[0] - margin, sig_figs)),
|
|
max(0, round(percentage_view_box[1] - margin, sig_figs)),
|
|
min(100, round(percentage_view_box[2] + margin, sig_figs)),
|
|
min(100, round(percentage_view_box[3] + margin, sig_figs)),
|
|
]
|
|
|
|
return view_coords
|
|
|
|
|
|
def format_snippet(
|
|
view_box: list[float],
|
|
id: str,
|
|
file_path: str,
|
|
base_commit: str,
|
|
head_commit: str,
|
|
repo: str,
|
|
dr_number: str,
|
|
view_bounds: list[float],
|
|
) -> str:
|
|
aspect_ratio = view_bounds[2] / view_bounds[3]
|
|
|
|
snippet = (
|
|
f'!thumbnail[]({file_path}){"{"} diff="{repo}:{base_commit}...{head_commit}" '
|
|
f'pr="{dr_number}" is-added=true doc-id="{id}" diff-visibility="full" variant="default" '
|
|
f'view-coords="{view_box[0]:.2f},{view_box[1]:.2f},{view_box[2]:.2f},{view_box[3]:.2f}" '
|
|
f'aspect-ratio="{aspect_ratio:.2f}" '
|
|
'}'
|
|
)
|
|
|
|
return snippet
|
|
|
|
|
|
def into_review_comment(
|
|
comment: ReviewComment,
|
|
file_path: str,
|
|
ir_json: dict,
|
|
svg: ET.ElementTree,
|
|
base_commit: str,
|
|
head_commit: str,
|
|
repo: str,
|
|
dr_number: str,
|
|
y_shift: float,
|
|
) -> DesignReview.ReviewComment:
|
|
body = f"""
|
|
{format_importance_for_comment(comment["importance"])}
|
|
> {comment["summary"]}
|
|
|
|
{comment["description"]}
|
|
"""
|
|
|
|
page = comment.get("page")
|
|
if page:
|
|
ir_pages = ir_json.get("pages", [])
|
|
if len(ir_pages) >= page:
|
|
ir_page = ir_pages[page - 1]
|
|
else:
|
|
ir_page = None
|
|
|
|
if ir_page:
|
|
page = ir_page.get("name") or page
|
|
svg_page = find_page_by_id(svg, ir_page["id"])
|
|
else:
|
|
svg_page = None
|
|
|
|
elements = comment.get("elements")
|
|
|
|
if elements:
|
|
elements = elements.split(", ")
|
|
|
|
if ir_page and (svg_page is not None):
|
|
page_view_bounds = find_page_viewbox(svg_page, y_shift)
|
|
positions: list[Position] = []
|
|
|
|
for element in elements:
|
|
if pos := position_of_element(element, ir_page, svg_page):
|
|
# 🤷♂️
|
|
if y_shift != 0:
|
|
pos["y"] = -pos["y"]
|
|
positions.append(pos)
|
|
|
|
if positions:
|
|
min_x = min([pos["x"] for pos in positions])
|
|
max_x = max([pos["x"] for pos in positions])
|
|
min_y = min([pos["y"] for pos in positions])
|
|
max_y = max([pos["y"] for pos in positions])
|
|
view_box = [min_x, min_y, max_x, max_y]
|
|
view_box = percentage_view_box(
|
|
view_box,
|
|
page_view_bounds,
|
|
y_shift != 0,
|
|
)
|
|
snippet = format_snippet(
|
|
view_box,
|
|
ir_page["id"],
|
|
file_path,
|
|
base_commit,
|
|
head_commit,
|
|
repo,
|
|
dr_number,
|
|
page_view_bounds,
|
|
)
|
|
body += f"\n\n{snippet}"
|
|
|
|
suggestion = comment.get("suggestion", "") or ""
|
|
if suggestion:
|
|
body += f"\n\n**Suggestion:** {suggestion}"
|
|
|
|
return DesignReview.ReviewComment(body=body, path=file_path)
|
|
|
|
|
|
def main():
|
|
print("Posting design review based on review JSON to the DR")
|
|
|
|
arg_parser = argparse.ArgumentParser(
|
|
description="Get JSON and SVG files for LLM review"
|
|
)
|
|
arg_parser.add_argument("--allspice-hub-url", required=True)
|
|
arg_parser.add_argument("--repository", required=True)
|
|
arg_parser.add_argument("--design-review-number", required=True)
|
|
arg_parser.add_argument("--review-json", required=True)
|
|
arg_parser.add_argument("--ir-json", required=True)
|
|
arg_parser.add_argument("--svg", required=True)
|
|
arg_parser.add_argument("--file-path", required=True)
|
|
arg_parser.add_argument("--base-commit", required=True)
|
|
arg_parser.add_argument("--head-commit", required=True)
|
|
|
|
args = arg_parser.parse_args()
|
|
|
|
token = os.getenv("ALLSPICE_TOKEN")
|
|
if not token or not token.strip():
|
|
raise ValueError("ALLSPICE_TOKEN environment variable is not set")
|
|
|
|
allspice = AllSpice(allspice_hub_url=args.allspice_hub_url, token_text=token)
|
|
|
|
owner, repo_name = args.repository.split("/")
|
|
|
|
dr = DesignReview.request(allspice, owner, repo_name, args.design_review_number)
|
|
|
|
ET.register_namespace("", "http://www.w3.org/2000/svg")
|
|
svg_parser = ET.XMLParser(encoding="utf-8")
|
|
|
|
with open(args.review_json, "r") as f:
|
|
review_json: ReviewJson = json.load(f)
|
|
with open(args.ir_json, "r") as f:
|
|
ir_json = json.load(f)
|
|
with open(args.svg, "r") as f:
|
|
svg = ET.parse(f, parser=svg_parser)
|
|
y_shift = determine_y_shift(svg)
|
|
|
|
body = review_json["overview"]
|
|
comments = [
|
|
into_review_comment(
|
|
comment,
|
|
args.file_path,
|
|
ir_json,
|
|
svg,
|
|
args.base_commit,
|
|
args.head_commit,
|
|
args.repository,
|
|
args.design_review_number,
|
|
y_shift,
|
|
)
|
|
for comment in review_json["comments"]
|
|
]
|
|
|
|
dr.create_review(body, DesignReview.ReviewEvent.COMMENT, comments)
|
|
|
|
print("Done")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|