mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
440 lines
13 KiB
TypeScript
440 lines
13 KiB
TypeScript
import {Component, Fragment} from 'react';
|
|
import {browserHistory} from 'react-router';
|
|
import styled from '@emotion/styled';
|
|
import type {Location} from 'history';
|
|
import isEqual from 'lodash/isEqual';
|
|
import * as qs from 'query-string';
|
|
|
|
import type {Client} from 'sentry/api';
|
|
import GuideAnchor from 'sentry/components/assistant/guideAnchor';
|
|
import {Button} from 'sentry/components/button';
|
|
import ButtonBar from 'sentry/components/buttonBar';
|
|
import GroupList from 'sentry/components/issues/groupList';
|
|
import Pagination from 'sentry/components/pagination';
|
|
import QueryCount from 'sentry/components/queryCount';
|
|
import {SegmentedControl} from 'sentry/components/segmentedControl';
|
|
import {DEFAULT_RELATIVE_PERIODS} from 'sentry/constants';
|
|
import {t, tct} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization} from 'sentry/types';
|
|
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
|
|
import withApi from 'sentry/utils/withApi';
|
|
import withOrganization from 'sentry/utils/withOrganization';
|
|
import {IssueSortOptions} from 'sentry/views/issueList/utils';
|
|
|
|
import type {ReleaseBounds} from '../../utils';
|
|
import {getReleaseParams} from '../../utils';
|
|
import EmptyState from '../commitsAndFiles/emptyState';
|
|
|
|
enum IssuesType {
|
|
NEW = 'new',
|
|
UNHANDLED = 'unhandled',
|
|
REGRESSED = 'regressed',
|
|
RESOLVED = 'resolved',
|
|
ALL = 'all',
|
|
}
|
|
|
|
const issuesQuery: Record<IssuesType, string> = {
|
|
[IssuesType.NEW]: 'first-release',
|
|
[IssuesType.UNHANDLED]: 'error.handled:0',
|
|
[IssuesType.REGRESSED]: 'regressed_in_release',
|
|
[IssuesType.RESOLVED]: 'is:resolved',
|
|
[IssuesType.ALL]: 'release',
|
|
};
|
|
|
|
type IssuesQueryParams = {
|
|
limit: number;
|
|
query: string;
|
|
sort: string;
|
|
};
|
|
|
|
const defaultProps = {
|
|
withChart: false,
|
|
};
|
|
|
|
type Props = {
|
|
api: Client;
|
|
location: Location;
|
|
organization: Organization;
|
|
releaseBounds: ReleaseBounds;
|
|
version: string;
|
|
queryFilterDescription?: string;
|
|
} & Partial<typeof defaultProps>;
|
|
|
|
type State = {
|
|
count: {
|
|
all: number | null;
|
|
new: number | null;
|
|
regressed: number | null;
|
|
resolved: number | null;
|
|
unhandled: number | null;
|
|
};
|
|
onCursor?: () => void;
|
|
pageLinks?: string;
|
|
};
|
|
|
|
class ReleaseIssues extends Component<Props, State> {
|
|
static defaultProps = defaultProps;
|
|
state: State = this.getInitialState();
|
|
|
|
getInitialState() {
|
|
return {
|
|
count: {
|
|
new: null,
|
|
all: null,
|
|
resolved: null,
|
|
unhandled: null,
|
|
regressed: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.fetchIssuesCount();
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
if (
|
|
!isEqual(
|
|
getReleaseParams({
|
|
location: this.props.location,
|
|
releaseBounds: this.props.releaseBounds,
|
|
}),
|
|
getReleaseParams({
|
|
location: prevProps.location,
|
|
releaseBounds: prevProps.releaseBounds,
|
|
})
|
|
)
|
|
) {
|
|
this.fetchIssuesCount();
|
|
}
|
|
}
|
|
|
|
getActiveIssuesType(): IssuesType {
|
|
const query = (this.props.location.query?.issuesType as string) ?? '';
|
|
return Object.values<string>(IssuesType).includes(query)
|
|
? (query as IssuesType)
|
|
: IssuesType.NEW;
|
|
}
|
|
|
|
getIssuesUrl() {
|
|
const {version, organization} = this.props;
|
|
const issuesType = this.getActiveIssuesType();
|
|
const {queryParams} = this.getIssuesEndpoint();
|
|
const query = new MutableSearch([]);
|
|
|
|
switch (issuesType) {
|
|
case IssuesType.NEW:
|
|
query.setFilterValues('firstRelease', [version]);
|
|
break;
|
|
case IssuesType.UNHANDLED:
|
|
query.setFilterValues('release', [version]);
|
|
query.setFilterValues('error.handled', ['0']);
|
|
break;
|
|
case IssuesType.REGRESSED:
|
|
query.setFilterValues('regressed_in_release', [version]);
|
|
break;
|
|
case IssuesType.RESOLVED:
|
|
case IssuesType.ALL:
|
|
default:
|
|
query.setFilterValues('release', [version]);
|
|
}
|
|
|
|
return {
|
|
pathname: `/organizations/${organization.slug}/issues/`,
|
|
query: {
|
|
...queryParams,
|
|
limit: undefined,
|
|
cursor: undefined,
|
|
query: query.formatString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
getIssuesEndpoint(): {path: string; queryParams: IssuesQueryParams} {
|
|
const {version, organization, location, releaseBounds} = this.props;
|
|
const issuesType = this.getActiveIssuesType();
|
|
|
|
const queryParams = {
|
|
...getReleaseParams({
|
|
location,
|
|
releaseBounds,
|
|
}),
|
|
limit: 10,
|
|
sort: IssueSortOptions.FREQ,
|
|
groupStatsPeriod: 'auto',
|
|
};
|
|
|
|
switch (issuesType) {
|
|
case IssuesType.ALL:
|
|
return {
|
|
path: `/organizations/${organization.slug}/issues/`,
|
|
queryParams: {
|
|
...queryParams,
|
|
query: new MutableSearch([
|
|
`${issuesQuery.all}:${version}`,
|
|
'is:unresolved',
|
|
]).formatString(),
|
|
},
|
|
};
|
|
case IssuesType.RESOLVED:
|
|
return {
|
|
path: `/organizations/${organization.slug}/releases/${encodeURIComponent(
|
|
version
|
|
)}/resolved/`,
|
|
queryParams: {...queryParams, query: ''},
|
|
};
|
|
case IssuesType.UNHANDLED:
|
|
return {
|
|
path: `/organizations/${organization.slug}/issues/`,
|
|
queryParams: {
|
|
...queryParams,
|
|
query: new MutableSearch([
|
|
`${issuesQuery.all}:${version}`,
|
|
issuesQuery.unhandled,
|
|
'is:unresolved',
|
|
]).formatString(),
|
|
},
|
|
};
|
|
case IssuesType.REGRESSED:
|
|
return {
|
|
path: `/organizations/${organization.slug}/issues/`,
|
|
queryParams: {
|
|
...queryParams,
|
|
query: new MutableSearch([
|
|
`${issuesQuery.regressed}:${version}`,
|
|
]).formatString(),
|
|
},
|
|
};
|
|
case IssuesType.NEW:
|
|
default:
|
|
return {
|
|
path: `/organizations/${organization.slug}/issues/`,
|
|
queryParams: {
|
|
...queryParams,
|
|
query: new MutableSearch([
|
|
`${issuesQuery.new}:${version}`,
|
|
'is:unresolved',
|
|
]).formatString(),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
async fetchIssuesCount() {
|
|
const {api, organization, version} = this.props;
|
|
const issueCountEndpoint = this.getIssueCountEndpoint();
|
|
const resolvedEndpoint = `/organizations/${
|
|
organization.slug
|
|
}/releases/${encodeURIComponent(version)}/resolved/`;
|
|
|
|
try {
|
|
await Promise.all([
|
|
api.requestPromise(issueCountEndpoint),
|
|
api.requestPromise(resolvedEndpoint),
|
|
]).then(([issueResponse, resolvedResponse]) => {
|
|
this.setState({
|
|
count: {
|
|
all: issueResponse[`${issuesQuery.all}:"${version}" is:unresolved`] || 0,
|
|
new: issueResponse[`${issuesQuery.new}:"${version}" is:unresolved`] || 0,
|
|
resolved: resolvedResponse.length,
|
|
unhandled:
|
|
issueResponse[
|
|
`${issuesQuery.unhandled} ${issuesQuery.all}:"${version}" is:unresolved`
|
|
] || 0,
|
|
regressed: issueResponse[`${issuesQuery.regressed}:"${version}"`] || 0,
|
|
},
|
|
});
|
|
});
|
|
} catch {
|
|
// do nothing
|
|
}
|
|
}
|
|
|
|
getIssueCountEndpoint() {
|
|
const {organization, version, location, releaseBounds} = this.props;
|
|
const issuesCountPath = `/organizations/${organization.slug}/issues-count/`;
|
|
|
|
const params = [
|
|
`${issuesQuery.new}:"${version}" is:unresolved`,
|
|
`${issuesQuery.all}:"${version}" is:unresolved`,
|
|
`${issuesQuery.unhandled} ${issuesQuery.all}:"${version}" is:unresolved`,
|
|
`${issuesQuery.regressed}:"${version}"`,
|
|
];
|
|
const queryParams = params.map(param => param);
|
|
const queryParameters = {
|
|
...getReleaseParams({
|
|
location,
|
|
releaseBounds,
|
|
}),
|
|
query: queryParams,
|
|
};
|
|
|
|
return `${issuesCountPath}?${qs.stringify(queryParameters)}`;
|
|
}
|
|
|
|
handleIssuesTypeSelection = (issuesType: IssuesType) => {
|
|
const {location} = this.props;
|
|
|
|
browserHistory.replace({
|
|
...location,
|
|
query: {
|
|
...location.query,
|
|
issuesType,
|
|
},
|
|
});
|
|
};
|
|
|
|
handleFetchSuccess = (groupListState, onCursor) => {
|
|
this.setState({pageLinks: groupListState.pageLinks, onCursor});
|
|
};
|
|
|
|
renderEmptyMessage = () => {
|
|
const {location, releaseBounds} = this.props;
|
|
const issuesType = this.getActiveIssuesType();
|
|
const isEntireReleasePeriod =
|
|
!location.query.pageStatsPeriod && !location.query.pageStart;
|
|
|
|
const {statsPeriod} = getReleaseParams({
|
|
location,
|
|
releaseBounds,
|
|
});
|
|
|
|
const selectedTimePeriod = statsPeriod ? DEFAULT_RELATIVE_PERIODS[statsPeriod] : null;
|
|
const displayedPeriod = selectedTimePeriod
|
|
? selectedTimePeriod.toLowerCase()
|
|
: t('given timeframe');
|
|
|
|
return (
|
|
<EmptyState>
|
|
{issuesType === IssuesType.NEW
|
|
? isEntireReleasePeriod
|
|
? t('No new issues in this release.')
|
|
: tct('No new issues for the [timePeriod].', {
|
|
timePeriod: displayedPeriod,
|
|
})
|
|
: null}
|
|
{issuesType === IssuesType.UNHANDLED
|
|
? isEntireReleasePeriod
|
|
? t('No unhandled issues in this release.')
|
|
: tct('No unhandled issues for the [timePeriod].', {
|
|
timePeriod: displayedPeriod,
|
|
})
|
|
: null}
|
|
{issuesType === IssuesType.REGRESSED
|
|
? isEntireReleasePeriod
|
|
? t('No regressed issues in this release.')
|
|
: tct('No regressed issues for the [timePeriod].', {
|
|
timePeriod: displayedPeriod,
|
|
})
|
|
: null}
|
|
{issuesType === IssuesType.RESOLVED && t('No resolved issues in this release.')}
|
|
{issuesType === IssuesType.ALL
|
|
? isEntireReleasePeriod
|
|
? t('No issues in this release')
|
|
: tct('No issues for the [timePeriod].', {
|
|
timePeriod: displayedPeriod,
|
|
})
|
|
: null}
|
|
</EmptyState>
|
|
);
|
|
};
|
|
|
|
render() {
|
|
const {count, pageLinks, onCursor} = this.state;
|
|
const issuesType = this.getActiveIssuesType();
|
|
const {organization, queryFilterDescription, withChart, version} = this.props;
|
|
const {path, queryParams} = this.getIssuesEndpoint();
|
|
const issuesTypes = [
|
|
{value: IssuesType.ALL, label: t('All Issues'), issueCount: count.all},
|
|
{value: IssuesType.NEW, label: t('New Issues'), issueCount: count.new},
|
|
{
|
|
value: IssuesType.UNHANDLED,
|
|
label: t('Unhandled'),
|
|
issueCount: count.unhandled,
|
|
},
|
|
{
|
|
value: IssuesType.REGRESSED,
|
|
label: t('Regressed'),
|
|
issueCount: count.regressed,
|
|
},
|
|
{
|
|
value: IssuesType.RESOLVED,
|
|
label: t('Resolved'),
|
|
issueCount: count.resolved,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Fragment>
|
|
<ControlsWrapper>
|
|
<GuideAnchor target="release_states">
|
|
<SegmentedControl
|
|
aria-label={t('Issue type')}
|
|
size="xs"
|
|
value={issuesType}
|
|
onChange={key => this.handleIssuesTypeSelection(key)}
|
|
>
|
|
{issuesTypes.map(({value, label, issueCount}) => (
|
|
<SegmentedControl.Item key={value} textValue={label}>
|
|
{label}
|
|
<QueryCount
|
|
count={issueCount}
|
|
max={99}
|
|
hideParens
|
|
hideIfEmpty={false}
|
|
/>
|
|
</SegmentedControl.Item>
|
|
))}
|
|
</SegmentedControl>
|
|
</GuideAnchor>
|
|
|
|
<OpenInButtonBar gap={1}>
|
|
<Button to={this.getIssuesUrl()} size="xs">
|
|
{t('Open in Issues')}
|
|
</Button>
|
|
|
|
<StyledPagination pageLinks={pageLinks} onCursor={onCursor} size="xs" />
|
|
</OpenInButtonBar>
|
|
</ControlsWrapper>
|
|
<div data-test-id="release-wrapper">
|
|
<GroupList
|
|
orgSlug={organization.slug}
|
|
endpointPath={path}
|
|
queryParams={queryParams}
|
|
query={`release:${version}`}
|
|
canSelectGroups={false}
|
|
queryFilterDescription={queryFilterDescription}
|
|
withChart={withChart}
|
|
narrowGroups
|
|
renderEmptyMessage={this.renderEmptyMessage}
|
|
withPagination={false}
|
|
onFetchSuccess={this.handleFetchSuccess}
|
|
source="release"
|
|
/>
|
|
</div>
|
|
</Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
const ControlsWrapper = styled('div')`
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
@media (max-width: ${p => p.theme.breakpoints.small}) {
|
|
display: block;
|
|
}
|
|
`;
|
|
|
|
const OpenInButtonBar = styled(ButtonBar)`
|
|
margin: ${space(1)} 0;
|
|
`;
|
|
|
|
const StyledPagination = styled(Pagination)`
|
|
margin: 0;
|
|
`;
|
|
|
|
export default withApi(withOrganization(ReleaseIssues));
|