This commit is contained in:
Shrikanth Upadhayaya 2025-04-02 16:06:02 -04:00
commit ff2d6595fb
No known key found for this signature in database
5 changed files with 430 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.venv

9
Dockerfile Normal file
View 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
View 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
View 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
View File

@ -0,0 +1 @@
py-allspice @ git+https://github.com/AllSpiceIO/py-allspice@su/design-review-reviews