Init
This commit is contained in:
commit
ff2d6595fb
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.venv
|
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@ -0,0 +1,9 @@
|
||||
FROM python:3.13-bookworm
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . /app
|
||||
|
||||
ENTRYPOINT [ "python3", "/app/post_design_review.py" ]
|
46
action.yml
Normal file
46
action.yml
Normal file
@ -0,0 +1,46 @@
|
||||
name: "Post an LLM review to a Design Review on AllSpice Hub"
|
||||
description: >
|
||||
Post on review created using LLM Review as a review on a Design Review on
|
||||
AllSpice Hub.
|
||||
|
||||
inputs:
|
||||
review_json_path:
|
||||
description: "Path to the JSON file containing the review."
|
||||
required: true
|
||||
|
||||
ir_json_path:
|
||||
description: "Path to the JSON file of the schematic."
|
||||
required: true
|
||||
|
||||
svg_path:
|
||||
description: "Path to the SVG file containing the schematic design."
|
||||
required: true
|
||||
|
||||
file_path:
|
||||
description: The path to the file that has been reviewed.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "docker"
|
||||
image: "Dockerfile"
|
||||
args:
|
||||
- "--allspice-hub-url"
|
||||
- ${{ github.server_url }}
|
||||
- "--repository"
|
||||
- ${{ github.repository }}
|
||||
- "--design-review-number"
|
||||
- ${{ github.event.number }}
|
||||
- "--review-json"
|
||||
- "${{ github.workspace }}/${{ inputs.review_json_path }}"
|
||||
- "--ir-json"
|
||||
- "${{ github.workspace }}/${{ inputs.ir_json_path }}"
|
||||
- "--svg"
|
||||
- "${{ github.workspace }}/${{ inputs.svg_path }}"
|
||||
- "--file-path"
|
||||
- "${{ inputs.file_path }}"
|
||||
- "--base-commit"
|
||||
- "${{ github.event.pull_request.base.sha }}"
|
||||
- "--head-commit"
|
||||
- "${{ github.event.pull_request.head.sha }}"
|
||||
env:
|
||||
ALLSPICE_TOKEN: ${{ github.token }}
|
373
post_design_review.py
Normal file
373
post_design_review.py
Normal file
@ -0,0 +1,373 @@
|
||||
# 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_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")
|
||||
if not transform:
|
||||
return None
|
||||
x, y = transform.lstrip("translate(").rstrip(")").split(",")
|
||||
x = float(x)
|
||||
y = float(y)
|
||||
return {"x": x, "y": y}
|
||||
return None
|
||||
|
||||
|
||||
def position_of_element(
|
||||
element_designator: str,
|
||||
ir_page: dict,
|
||||
) -> 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_position = component.get("position")
|
||||
if not component_position:
|
||||
return None
|
||||
|
||||
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 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:
|
||||
margin = 2.5
|
||||
sig_figs = 6
|
||||
width = view_bounds[2] - view_bounds[0]
|
||||
height = view_bounds[3] - view_bounds[1]
|
||||
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,
|
||||
]
|
||||
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)),
|
||||
]
|
||||
aspect_ratio = round(
|
||||
(view_bounds[2] - view_bounds[0]) / (view_bounds[3] - view_bounds[1]), sig_figs
|
||||
)
|
||||
|
||||
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_coords[0]:.2f},{view_coords[1]:.2f},{view_coords[2]:.2f},{view_coords[3]:.2f}" '
|
||||
f'aspect-ratio="{aspect_ratio}" '
|
||||
'}'
|
||||
)
|
||||
|
||||
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:
|
||||
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):
|
||||
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]
|
||||
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()
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
py-allspice @ git+https://github.com/AllSpiceIO/py-allspice@su/design-review-reviews
|
Loading…
Reference in New Issue
Block a user