Skip to content

Commit

Permalink
Add service and handers for getting PR-based pages
Browse files Browse the repository at this point in the history
This provides access to the pages that are built automatically by the
GitHub Check run handlers for a GitHub pull request.

- New PageService.get_github_pr_page
- New /github-pr/ endpoints, similar to the existing /github/ endpoints,
  but have the PR's head commit SHA in the path to show pages for a
  specific PR check run
- Revised GitHubTree domain model.

The new domain model separates path segments (which are used to add
nodes relative to other nodes) from the Squareone URL path. This
dramatically simplifies the tree domain construction and makes it
possible to use the same domain object for PR preview trees as well.

The new GitHubNode is a dataclass; in the REST API we transform that
dataclass into a Pydantic model for the response.
  • Loading branch information
jonathansick committed Aug 15, 2022
1 parent 6b14f9b commit d5e1759
Show file tree
Hide file tree
Showing 6 changed files with 640 additions and 99 deletions.
284 changes: 201 additions & 83 deletions src/timessquare/domain/githubtree.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum
from typing import List

from pydantic import BaseModel, Field
from typing import List, Optional


class GitHubNodeType(str, Enum):
Expand All @@ -25,103 +23,223 @@ class GitHubNodeType(str, Enum):
"""Page inside a GitHub repository."""


class GitHubNode(BaseModel):
@dataclass
class GitHubNode:
"""A node in the GitHub contents tree."""

node_type: GitHubNodeType = Field(
title="Node type",
description=(
"Indicates whether this a GitHub owner (user or organization), "
"repository, directory in a repositiory, or a page itself."
),
)

title: str = Field(title="Display title")

path: str = Field(
title="Hierarchical path",
description=(
"The page is POSIX-path formatted without a preceeding or "
"trailing slash. The first path element is always the owner, "
"followed by the repository name, directory, and page name as "
"necessary."
),
example="lsst-sqre/times-square-demo/matplotlib/gaussian2d",
)

contents: List[GitHubNode] = Field(
title="Nodes contained within this node.",
description="For 'page' nodes, this is an empty list.",
default_factory=list,
)
node_type: GitHubNodeType
"""The type of path object in the node."""

path_segments: List[str]
"""The segments in the path (i.e. the name of this page, or
this directory) up to this point.
Path segments are always ordered:
1. owner
2. repo
3. directory or directories, as necessary
4. page file stem (i.e. filename without extension)
"""

title: str
"""Presentational title for this node."""

github_commit: Optional[str] = None
"""The commit SHA if this tree is for a specific commit (a PR preview)
instead of corresponding to the default branch of the repository.
"""

contents: List[GitHubNode] = field(default_factory=list)

@property
def path_segments(self) -> List[str]:
return self.path.split("/")
def squareone_path(self) -> str:
"""Path to the node in Squareone.
- If `github_commit` is None, the URL path is relative to
``/times-square/github/`` (not included.
- If the node contains a non-None `github_commit`,
the path is relative to ``/times-square/github-pr/`` (not included).
"""
if self.github_commit is None:
# a path corresponding to the default branch (i.e. the "live view
# github-backed pages)
return "/".join(self.path_segments)
else:
print(f"commit is not none: {self.github_commit}")
# a path corresponding to the commit view of a repository,
# for PR previews.
# formatted as owner/repo/commit/dirname/filestem
return (
f"{self.path_segments[0]}/{self.path_segments[1]}/"
f"{self.github_commit}/{'/'.join(self.path_segments[2:])}"
)

@classmethod
def create_with_repo_root(
cls, results: List[GitHubTreeQueryResult]
) -> GitHubNode:
"""Create a tree with this root-node being the first repository in
the results.
This is appropriate for creating trees for GitHub PR previews.
"""
root_path_segment = [results[0].github_owner, results[0].github_repo]
root = cls(
node_type=GitHubNodeType.repo,
path_segments=root_path_segment,
github_commit=results[0].github_commit,
title=results[0].github_repo,
contents=[],
)
for result in results:
root.insert_node(result)
return root

def insert_input(self, tree_input: GitHubTreeInput) -> None:
n = len(self.path_segments)
if n == len(tree_input.path_segments):
# input is a direct child of this node
self.contents.append(tree_input.to_node())
@classmethod
def create_with_owner_root(
cls, results: List[GitHubTreeQueryResult]
) -> GitHubNode:
"""Create a tree with the root-node being the first GitHub owner in
the results.
This is appropriate for creating trees for the default branch views
of GitHub-backed pages.
"""
root_path_segment = [results[0].github_owner]
root = cls(
node_type=GitHubNodeType.owner,
path_segments=root_path_segment,
title=results[0].github_owner,
github_commit=results[0].github_commit,
contents=[],
)
for result in results:
root.insert_node(result)
return root

def insert_node(self, result: GitHubTreeQueryResult) -> None:
"""Insert an SQL page result as a child (direct, or not) of the
current node.
"""
if self.node_type == GitHubNodeType.owner:
self._insert_node_from_owner(result)
elif self.node_type == GitHubNodeType.repo:
self._insert_node_from_repo(result)
elif self.node_type == GitHubNodeType.directory:
self._insert_node_from_directory(result)
else:
raise ValueError("Cannot insert a node into a page")

def _insert_node_from_owner(self, result: GitHubTreeQueryResult) -> None:
# Try to insert node into an existing repository node
for repo_node in self.contents:
if repo_node.path_segments[1] == result.github_repo:
repo_node.insert_node(result)
return

# Create a repo node since one doesn't already exist
repo_node = GitHubNode(
node_type=GitHubNodeType.repo,
path_segments=result.path_segments[:2],
title=result.path_segments[1],
github_commit=result.github_commit,
contents=[],
)
self.contents.append(repo_node)
repo_node.insert_node(result)

def _insert_node_from_repo(self, result: GitHubTreeQueryResult) -> None:
if len(result.path_segments) == 3:
# direct child of this node
self.contents.append(
GitHubNode(
node_type=GitHubNodeType.page,
path_segments=result.path_segments,
title=result.title,
github_commit=result.github_commit,
contents=[],
)
)
return
else:
# find child that contains input
for child in self.contents:
if (len(child.path_segments) >= n + 1) and (
child.path_segments[n] == tree_input.path_segments[n]
# Find existing directory containing this page
for child_node in self.contents:
n = len(child_node.path_segments)
if (
child_node.node_type == GitHubNodeType.directory
and child_node.path_segments == result.path_segments[:n]
):
child.insert_input(tree_input)
child_node.insert_node(result)
return
# Create a new child node because the necessary one doesn't
# exist
if n == 1:
node_type = GitHubNodeType.repo
else:
node_type = GitHubNodeType.directory
child = GitHubNode(
node_type=node_type,
title=tree_input.path_segments[n],
path="/".join(tree_input.path_segments[: n + 1]),
# Create a directory node
dir_node = GitHubNode(
node_type=GitHubNodeType.directory,
path_segments=result.path_segments[
: len(self.path_segments) + 1
],
title=result.path_segments[len(self.path_segments)],
github_commit=result.github_commit,
contents=[],
)
child.insert_input(tree_input)
self.contents.append(child)
self.contents.append(dir_node)
dir_node.insert_node(result)

def _insert_node_from_directory(
self, result: GitHubTreeQueryResult
) -> None:
self_segment_count = len(self.path_segments)
input_segment_count = len(result.path_segments)

if input_segment_count == self_segment_count + 1:
# a direct child of this directory
self.contents.append(
GitHubNode(
node_type=GitHubNodeType.page,
path_segments=result.path_segments,
title=result.title,
github_commit=result.github_commit,
contents=[],
)
)
else:
# Create a directory node
dir_node = GitHubNode(
node_type=GitHubNodeType.directory,
path_segments=result.path_segments[: self_segment_count + 1],
title=result.path_segments[self_segment_count],
github_commit=result.github_commit,
contents=[],
)
self.contents.append(dir_node)
dir_node.insert_node(result)


@dataclass
class GitHubTreeInput:
class GitHubTreeQueryResult:
"""A domain class used to aid construction of the GitHub contents tree
from the original SQL storage of pages.
This class is used by `PageStore.get_github_tree`; `GitHubNode` is the
public product.
"""

path_segments: List[str]
# The order of these attributes matches the order of the sql query
# in timessquare.storage.page.

github_owner: str

github_repo: str

stem: str
github_commit: Optional[str]

path_prefix: str

title: str

@classmethod
def from_sql_row(
cls,
github_owner: str,
github_repo: str,
path_prefix: str,
title: str,
path_stem: str,
) -> GitHubTreeInput:
path_segments = [github_owner, github_repo]
if path_prefix:
path_segments.extend(path_prefix.split("/"))

return cls(path_segments=path_segments, stem=path_stem, title=title)

def to_node(self) -> GitHubNode:
return GitHubNode(
node_type=GitHubNodeType.page,
title=self.title,
path="/".join(self.path_segments + [self.stem]),
contents=[],
)
path_stem: str

@property
def path_segments(self) -> List[str]:
segments: List[str] = [self.github_owner, self.github_repo]
if len(self.path_prefix) > 0:
segments.extend(self.path_prefix.split("/"))
segments.append(self.path_stem)
return segments
Loading

0 comments on commit d5e1759

Please sign in to comment.