Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/components/issues/GitHubIssueState.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@
.github-issue-not-planned {
color: var(--fgColor-muted);
}
.github-pull-request-closed {
color: var(--fgColor-closed);
}
43 changes: 43 additions & 0 deletions frontend/src/components/issues/GitHubPullRequestState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestIcon } from "lucide-preact";
import type { StateEnum } from "@/api/generated/models";
import styles from "./GitHubIssueState.module.css";

type Props = {
state: readonly StateEnum[];
};

function gitHubPullRequestStateLabel(state: readonly StateEnum[]): string {
if (!state.includes("CLOSED")) {
return "Open";
}
if (state.includes("MERGED")) {
return "Merged";
}
return "Closed";
}

function GitHubPullRequestStateIcon({ state }: Props) {
if (!state.includes("CLOSED")) {
return <GitPullRequestIcon size="1em" className={styles["github-issue-open"]} />;
}
if (state.includes("MERGED")) {
return <GitMergeIcon size="1em" className={styles["github-issue-done"]} />;
}
return <GitPullRequestClosedIcon size="1em" className={styles["github-pull-request-closed"]} />;
}

export function GitHubPullRequestState({ state }: Props) {
const label = gitHubPullRequestStateLabel(state);
return (
<span
className="row centered"
role="img"
title={label}
aria-label={label}
data-color-mode="light"
data-light-theme="light"
>
<GitHubPullRequestStateIcon state={state} />
</span>
);
}
94 changes: 65 additions & 29 deletions frontend/src/components/issues/Issue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ import {
import { ExternalLink } from "../ui/ExternalLink";
import { LegendCard } from "../ui/LegendCard";
import { GitHubIssueState } from "./GitHubIssueState";
import { GitHubPullRequestState } from "./GitHubPullRequestState";
import { IssueViewToggle } from "./IssueViewToggle";

function gitHubNumber(url: string): string {
return `#${url.split("/").pop()}`;
}

type Props = {
issue: IssueType;
inheritedViewMode?: IssueViewMode;
Expand All @@ -27,7 +32,7 @@ export function Issue({
allowViewModeClear = false,
inheritedSuggestionViewMode = DEFAULT_SUGGESTION_VIEW_MODE,
}: Props) {
const { code, title, github_issue_url, github_issue_state } = issue;
const { code, title, github_issue_url, github_issue_state, github_closing_pull_requests } = issue;

const [ownViewMode, setOwnViewMode] = useState<IssueViewMode | undefined>(undefined);
const [ownSuggestionViewMode, setOwnSuggestionViewMode] = useState<
Expand All @@ -45,15 +50,6 @@ export function Issue({
<GlobeCheckIcon size="1em" />
<Link href={`/issues/${code}`}>{code}</Link>
</div>
{github_issue_url && (
/* FIXME(@fricklerhandwerk): Move the GitHub issue state into the card once the issue activity log lands, so the legend carries no variable data. */
<div className="row centered gap-small">
{github_issue_state && (
<GitHubIssueState state={github_issue_state} testId={`issue-${code}-github-state`} />
)}
<ExternalLink href={github_issue_url}>GitHub</ExternalLink>
</div>
)}
</>
);

Expand All @@ -71,32 +67,72 @@ export function Issue({

return (
<LegendCard legend={legend} viewToggle={viewToggle} testId={`issue-${code}`}>
<div className="column gap-small">
{viewMode === "collapsed" && title && (
<span data-testid={`issue-${code}-title`}>{title}</span>
<div className="column gap">
{viewMode !== "collapsed" && github_issue_url && (
<div className="row centered gap-small wrap">
<span>GitHub:</span>
<span className="row centered gap-tiny">
{github_issue_state && (
<GitHubIssueState
state={github_issue_state}
testId={`issue-${code}-github-state`}
/>
)}
<ExternalLink href={github_issue_url}>{gitHubNumber(github_issue_url)}</ExternalLink>
</span>
{github_closing_pull_requests.length > 0 && <span>closed by</span>}
{github_closing_pull_requests.map((pullRequest) => (
<span key={pullRequest.url} className="row centered gap-tiny">
<GitHubPullRequestState state={pullRequest.state} />
<ExternalLink href={pullRequest.url}>{gitHubNumber(pullRequest.url)}</ExternalLink>
</span>
))}
</div>
)}
{viewMode !== "collapsed" && title && (
<div className="bold text-l" data-testid={`issue-${code}-title`}>
{title}
{viewMode === "collapsed" && (
<div className="row gap align-start">
<div className="row gap-small">
{github_issue_state && (
<GitHubIssueState
state={github_issue_state}
testId={`issue-${code}-github-state`}
/>
)}
{github_issue_url && (
<ExternalLink href={github_issue_url}>
{gitHubNumber(github_issue_url)}
</ExternalLink>
)}
</div>
{title && <span data-testid={`issue-${code}-title`}>{title}</span>}
</div>
)}
{viewMode === "expanded" && (
<div className="row gap centered spread">
{title && (
<div className="bold text-l" data-testid={`issue-${code}-title`}>
{title}
</div>
)}
{issue.suggestions.length > 1 && (
<div className="row gap centered">
<EyeIcon size="1em" />
<SuggestionViewToggle
value={suggestionToggleValue}
onChange={setOwnSuggestionViewMode}
allowClear={allowViewModeClear}
iconOnly
currentValue={inheritedSuggestionViewMode}
testId={`issue-${code}-suggestions-view-toggle`}
/>
</div>
)}
</div>
)}
</div>

{viewMode === "expanded" && issue.suggestions.length > 0 && (
<div className="column gap-big stretched">
{issue.suggestions.length > 1 && (
<div className="row gap centered justify-right">
<EyeIcon size="1em" />
<SuggestionViewToggle
value={suggestionToggleValue}
onChange={setOwnSuggestionViewMode}
allowClear={allowViewModeClear}
iconOnly
currentValue={inheritedSuggestionViewMode}
testId={`issue-${code}-suggestions-view-toggle`}
/>
</div>
)}
{issue.suggestions.map((suggestion) =>
typeof suggestion === "number" ? null : (
<Suggestion
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/suggestions/Suggestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ export function Suggestion({
dashed={dimmed}
testId={`suggestion-${id}-collapsed`}
>
<div className="row gap centered">
<div data-testid={`suggestion-${id}-status`} className="contents">
<div className="row gap align-start">
<div data-testid={`suggestion-${id}-status`} className="row centered" style="height: 1lh;">
<SuggestionStatus status={status} rejectionReason={rejection_reason} iconOnly />
</div>
{displayedTitle && <div>{displayedTitle}</div>}
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/ui/ExternalLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export function ExternalLink({
return (
<a href={href} target={target} rel={rel} className={className} title={title}>
{children}
<ExternalLinkIcon size=".9em" style="margin-left: .2em; vertical-align: -0.05em;" />
<span className="no-line-breaks">
{"⁠"}
<ExternalLinkIcon size=".9em" style="margin-left: .2em; vertical-align: -0.05em;" />
</span>
</a>
);
}
4 changes: 4 additions & 0 deletions frontend/src/styles/utility.css
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,10 @@
gap: 0.5em;
}

.gap-tiny {
gap: 0.25em;
}

.row-gap {
column-gap: 1em;
}
Expand Down
39 changes: 38 additions & 1 deletion src/api/issues/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,34 @@

from api.suggestions.serializers import SuggestionSerializer
from shared.models import NixpkgsIssue
from shared.models.issue import EventType
from shared.models.issue import EventType, GitHubItem


class GitHubPullRequestSerializer(serializers.ModelSerializer):
"""A GitHub pull request and its current state."""

url = serializers.URLField(read_only=True)
state = serializers.SerializerMethodField()

class Meta:
model = GitHubItem
fields = ["url", "state"]

@extend_schema_field(
serializers.ListField(
child=serializers.ChoiceField(choices=[flag.name for flag in EventType])
)
)
def get_state(self, obj: GitHubItem) -> list[str]:
return [cast(str, flag.name) for flag in obj.state]


class IssueSerializer(serializers.ModelSerializer):
"""A Nixpkgs security issue (a group of published suggestions)."""

github_issue_url = serializers.SerializerMethodField()
github_issue_state = serializers.SerializerMethodField()
github_closing_pull_requests = serializers.SerializerMethodField()
suggestions = serializers.SerializerMethodField()

class Meta:
Expand All @@ -27,6 +47,7 @@ class Meta:
"updated_at",
"github_issue_url",
"github_issue_state",
"github_closing_pull_requests",
"suggestions",
]
# `title`/`status` have model defaults, but always present on read.
Expand All @@ -50,6 +71,22 @@ def get_github_issue_state(self, obj: NixpkgsIssue) -> list[str] | None:
item = obj.github_issue
return [cast(str, flag.name) for flag in item.state] if item else None

@extend_schema_field(GitHubPullRequestSerializer(many=True))
def get_github_closing_pull_requests(self, obj: NixpkgsIssue) -> ReturnList:
item = obj.github_issue
pull_requests = (
sorted(
(reference.pull_request for reference in item.closed_by.all()),
key=lambda pull_request: pull_request.number,
)
if item
else []
)
return cast(
ReturnList,
GitHubPullRequestSerializer(pull_requests, many=True).data,
)

@extend_schema_field(
{
"type": "array",
Expand Down
1 change: 1 addition & 0 deletions src/api/issues/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def get_queryset(self) -> QuerySet[NixpkgsIssue]: # pyright: ignore[reportIncom
"suggestions__cached",
"suggestions__cve__container__references__tags",
"github_items__events",
"github_items__closed_by__pull_request__events",
)
.order_by("-created_at")
.distinct()
Expand Down
37 changes: 37 additions & 0 deletions src/api/tests/test_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,41 @@ def test_issue_detail_github_issue_state(
assert response.data["github_issue_state"] == ["ISSUE", "CLOSED", "COMPLETED"]


def test_issue_detail_github_closing_pull_requests(
make_issue: Callable[..., NixpkgsIssue],
make_github_item: Callable[..., GitHubItem],
) -> None:
client = APIClient()
issue = make_issue()
github_issue = make_github_item(
1,
GitHubItem.Kind.ISSUE,
EventType.ISSUE | EventType.OPENED,
EventType.ISSUE | EventType.CLOSED | EventType.COMPLETED,
)
issue.github_items.add(github_issue)
pull_request = make_github_item(
2,
GitHubItem.Kind.PULL_REQUEST,
EventType.PULL_REQUEST | EventType.OPENED,
EventType.PULL_REQUEST | EventType.CLOSED | EventType.MERGED,
)
pull_request.closes.create(issue=github_issue)

response = client.get(detail_url(issue.code))
assert response.status_code == 200
assert response.data["github_closing_pull_requests"] == [
{
"url": "https://github.com/NixOS/nixpkgs/pull/2",
"state": ["PULL_REQUEST", "CLOSED", "MERGED"],
}
]


def test_list_issues_query_count_independent_of_issue_count(
make_container: Callable[..., Container],
make_issue: Callable[..., NixpkgsIssue],
make_github_item: Callable[..., GitHubItem],
) -> None:
client = APIClient()

Expand All @@ -192,6 +224,11 @@ def add_closed_issue(number: int) -> None:
event_type=EventType.ISSUE | EventType.CLOSED | EventType.COMPLETED,
occurred_at=timezone.now(),
)
make_github_item(
100 + number,
GitHubItem.Kind.PULL_REQUEST,
EventType.PULL_REQUEST | EventType.OPENED,
).closes.create(issue=item)

def count_queries() -> int:
with CaptureQueriesContext(connection) as queries:
Expand Down
Loading
Loading