Add Jira widget (#502)

* Add Jira widget

* Jira widget UI for no login vs no instances

* Update changelog wording
This commit is contained in:
Andrew Ferrazzutti 2022-09-30 11:52:31 -04:00 committed by GitHub
parent 4c33c60d7a
commit 0111f4bfa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 299 additions and 1 deletions

1
changelog.d/502.feature Normal file
View File

@ -0,0 +1 @@
Add room configuration widget for Jira.

View File

@ -637,6 +637,9 @@ export class BridgeConfig {
case "gitlab": case "gitlab":
config = this.gitlab?.publicConfig; config = this.gitlab?.publicConfig;
break; break;
case "jira":
config = {};
break;
default: default:
throw new ApiError("Not a known service, or service doesn't expose a config", ErrCode.NotFound); throw new ApiError("Not a known service, or service doesn't expose a config", ErrCode.NotFound);
} }

View File

@ -302,6 +302,10 @@ export class ConnectionManager extends EventEmitter {
const configObject = this.validateConnectionTarget(userId, this.config.github, "GitHub", "github"); const configObject = this.validateConnectionTarget(userId, this.config.github, "GitHub", "github");
return await GitHubRepoConnection.getConnectionTargets(userId, this.tokenStore, configObject); return await GitHubRepoConnection.getConnectionTargets(userId, this.tokenStore, configObject);
} }
case JiraProjectConnection.CanonicalEventType: {
const configObject = this.validateConnectionTarget(userId, this.config.jira, "JIRA", "jira");
return await JiraProjectConnection.getConnectionTargets(userId, this.tokenStore, configObject, filters);
}
default: default:
throw new ApiError(`Connection type doesn't support getting targets or is not known`, ErrCode.NotFound); throw new ApiError(`Connection type doesn't support getting targets or is not known`, ErrCode.NotFound);
} }

View File

@ -13,6 +13,9 @@ import { UserTokenStore } from "../UserTokenStore";
import { CommandError, NotLoggedInError } from "../errors"; import { CommandError, NotLoggedInError } from "../errors";
import { ApiError, ErrCode } from "../api"; import { ApiError, ErrCode } from "../api";
import JiraApi from "jira-client"; import JiraApi from "jira-client";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { BridgeConfigJira } from "../Config/Config";
import { HookshotJiraApi } from "../Jira/Client";
type JiraAllowedEventsNames = "issue.created"; type JiraAllowedEventsNames = "issue.created";
const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"]; const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"];
@ -23,6 +26,27 @@ export interface JiraProjectConnectionState extends IConnectionState {
events?: JiraAllowedEventsNames[], events?: JiraAllowedEventsNames[],
} }
export interface JiraProjectConnectionInstanceTarget {
url: string;
name: string;
}
export interface JiraProjectConnectionProjectTarget {
state: JiraProjectConnectionState;
key: string;
name: string;
}
export type JiraProjectConnectionTarget = JiraProjectConnectionInstanceTarget|JiraProjectConnectionProjectTarget;
export interface JiraTargetFilter {
instanceName?: string;
}
export type JiraProjectResponseItem = GetConnectionsResponseItem<JiraProjectConnectionState>;
function validateJiraConnectionState(state: unknown): JiraProjectConnectionState { function validateJiraConnectionState(state: unknown): JiraProjectConnectionState {
const {url, commandPrefix, events, priority} = state as Partial<JiraProjectConnectionState>; const {url, commandPrefix, events, priority} = state as Partial<JiraProjectConnectionState>;
if (url === undefined) { if (url === undefined) {
@ -219,7 +243,60 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
}, },
} }
} }
public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, config: BridgeConfigJira, filters: JiraTargetFilter = {}): Promise<JiraProjectConnectionTarget[]> {
// Search for all projects under the user's control.
const jiraUser = await tokenStore.getJiraForUser(userId, config.url);
if (!jiraUser) {
throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser);
}
if (!filters.instanceName) {
const results: JiraProjectConnectionInstanceTarget[] = [];
try {
for (const resource of await jiraUser.getAccessibleResources()) {
results.push({
url: resource.url,
name: resource.name,
});
}
} catch (ex) {
log.warn(`Failed to fetch accessible resources for ${userId}`, ex);
throw new ApiError("Could not fetch accessible resources for JIRA user.", ErrCode.Unknown);
}
return results;
}
// If we have an instance, search under it.
let resClient: HookshotJiraApi|null;
try {
resClient = await jiraUser.getClientForName(filters.instanceName);
} catch (ex) {
log.warn(`Failed to fetch client for ${filters.instanceName} for ${userId}`, ex);
throw new ApiError("Could not fetch accessible resources for JIRA user.", ErrCode.Unknown);
}
if (!resClient) {
throw new ApiError("Instance not known or not accessible to this user.", ErrCode.ForbiddenUser);
}
const allProjects: JiraProjectConnectionProjectTarget[] = [];
try {
for await (const project of resClient.getAllProjects()) {
allProjects.push({
state: {
// Technically not the real URL, but good enough for hookshot!
url: `${resClient.resource.url}/projects/${project.key}`,
},
key: project.key,
name: project.name,
});
}
} catch (ex) {
log.warn(`Failed to fetch accessible projects for ${config.instanceName} / ${userId}`, ex);
throw new ApiError("Could not fetch accessible projects for JIRA user.", ErrCode.Unknown);
}
return allProjects;
}
public async onJiraIssueUpdated(data: JiraIssueUpdatedEvent) { public async onJiraIssueUpdated(data: JiraIssueUpdatedEvent) {
log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectId} ${data.issue.id}`); log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectId} ${data.issue.id}`);
const url = generateJiraWebLinkFromIssue(data.issue); const url = generateJiraWebLinkFromIssue(data.issue);

View File

@ -7,10 +7,12 @@ import { FeedsConfig } from "./roomConfig/FeedsConfig";
import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig"; import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig";
import { GithubRepoConfig } from "./roomConfig/GithubRepoConfig"; import { GithubRepoConfig } from "./roomConfig/GithubRepoConfig";
import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig"; import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig";
import { JiraProjectConfig } from "./roomConfig/JiraProjectConfig";
import FeedsIcon from "../icons/feeds.png"; import FeedsIcon from "../icons/feeds.png";
import GitHubIcon from "../icons/github.png"; import GitHubIcon from "../icons/github.png";
import GitLabIcon from "../icons/gitlab.png"; import GitLabIcon from "../icons/gitlab.png";
import JiraIcon from "../icons/jira.png";
import WebhookIcon from "../icons/webhook.png"; import WebhookIcon from "../icons/webhook.png";
@ -27,6 +29,7 @@ enum ConnectionType {
Generic = "generic", Generic = "generic",
Github = "github", Github = "github",
Gitlab = "gitlab", Gitlab = "gitlab",
Jira = "jira",
} }
interface IConnectionProps { interface IConnectionProps {
@ -55,6 +58,12 @@ const connections: Record<ConnectionType, IConnectionProps> = {
icon: GitLabIcon, icon: GitLabIcon,
component: GitlabRepoConfig, component: GitlabRepoConfig,
}, },
[ConnectionType.Jira]: {
displayName: 'JIRA',
description: "Connect the room to a JIRA project",
icon: JiraIcon,
component: JiraProjectConfig,
},
[ConnectionType.Generic]: { [ConnectionType.Generic]: {
displayName: 'Generic Webhook', displayName: 'Generic Webhook',
description: "Create a webhook which can be used to connect any service to Matrix", description: "Create a webhook which can be used to connect any service to Matrix",

View File

@ -0,0 +1,204 @@
import { h, FunctionComponent, createRef } from "preact";
import { useState, useCallback, useEffect, useMemo } from "preact/hooks";
import { BridgeAPI, BridgeConfig } from "../../BridgeAPI";
import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig";
import { ErrCode } from "../../../src/api";
import { JiraProjectConnectionState, JiraProjectResponseItem, JiraProjectConnectionProjectTarget, JiraTargetFilter, JiraProjectConnectionInstanceTarget, JiraProjectConnectionTarget } from "../../../src/Connections/JiraProject";
import { InputField, ButtonSet, Button, ErrorPane } from "../elements";
import JiraIcon from "../../icons/jira.png";
const EventType = "uk.half-shot.matrix-hookshot.jira.project";
function getInstancePrettyName(instance: JiraProjectConnectionInstanceTarget) {
return `${instance.name} (${instance.url})`;
}
function getProjectPrettyName(project: JiraProjectConnectionProjectTarget) {
return `${project.key} (${project.name})`;
}
const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: JiraProjectConnectionState) => void}> = ({api, onPicked}) => {
const [filter, setFilter] = useState<JiraTargetFilter>({});
const [results, setResults] = useState<JiraProjectConnectionProjectTarget[]|null>(null);
const [instances, setInstances] = useState<JiraProjectConnectionInstanceTarget[]|null>(null);
const [isConnected, setIsConnected] = useState<boolean|null>(null);
const [debounceTimer, setDebounceTimer] = useState<number|undefined>(undefined);
const [currentProject, setCurrentProject] = useState<{url: string, name: string}|null>(null);
const [searchError, setSearchError] = useState<string|null>(null);
const searchFn = useCallback(async() => {
try {
const res = await api.getConnectionTargets<JiraProjectConnectionTarget>(EventType, filter);
setIsConnected(true);
if (!filter.instanceName) {
setInstances(res as JiraProjectConnectionInstanceTarget[]);
if (res[0]) {
setFilter({instanceName: res[0].name});
}
} else {
setResults(res as JiraProjectConnectionProjectTarget[]);
}
} catch (ex: any) {
if (ex?.errcode === ErrCode.ForbiddenUser) {
setIsConnected(false);
setInstances([]);
} else {
setSearchError("There was an error fetching search results.");
// Rather than raising an error, let's just log and let the user retry a query.
console.warn(`Failed to get connection targets from query:`, ex);
}
}
}, [api, filter]);
const updateSearchFn = useCallback((evt: InputEvent) => {
const project = (evt.target as HTMLOptionElement).value;
const hasResult = results?.find(n =>
project === n.state.url ||
project === getProjectPrettyName(n)
);
if (hasResult) {
onPicked(hasResult.state);
setCurrentProject({
url: hasResult.state.url,
name: getProjectPrettyName(hasResult),
});
}
}, [onPicked, results]);
useEffect(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Browser types
setDebounceTimer(setTimeout(searchFn, 500) as unknown as number);
return () => {
clearTimeout(debounceTimer);
}
// Things break if we depend on the thing we are clearing.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchFn, filter, isConnected, instances]);
const onInstancePicked = useCallback((evt: InputEvent) => {
// Reset the search string.
setFilter({
instanceName: (evt.target as HTMLSelectElement).selectedOptions[0].value,
});
}, []);
const instanceListResults = useMemo(
() => instances?.map(i => <option key={i.url} value={i.url}>{getInstancePrettyName(i)}</option>),
[instances]
);
const projectListResults = useMemo(
() => results?.map(i => <option key={i.key} value={i.state.url}>{getProjectPrettyName(i)}</option>),
[results]
);
return <div>
{instances === null && <p> Loading JIRA connection. </p>}
{isConnected === false && <p> You are not logged into JIRA. </p>}
{isConnected === true && instances?.length === 0 && <p> You are not connected to any JIRA instances. </p>}
{searchError && <ErrorPane> {searchError} </ErrorPane> }
<InputField visible={!!instances?.length} label="JIRA Instance" noPadding={true}>
<select onChange={onInstancePicked}>
{instanceListResults}
</select>
</InputField>
<InputField visible={!!instances?.length} label="Project" noPadding={true}>
<small>{currentProject?.url}</small>
<input onChange={updateSearchFn} value={currentProject?.name} list="jira-projects" type="text" />
<datalist id="jira-projects">
{projectListResults}
</datalist>
</InputField>
</div>;
}
const EventCheckbox: FunctionComponent<{
allowedEvents: string[],
onChange: (evt: HTMLInputElement) => void,
eventName: string,
}> = ({allowedEvents, onChange, eventName, children}) => {
return <li>
<label>
<input
type="checkbox"
x-event-name={eventName}
checked={allowedEvents.includes(eventName)}
onChange={onChange} />
{ children }
</label>
</li>;
};
const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<never, JiraProjectResponseItem, JiraProjectConnectionState>> = ({api, existingConnection, onSave, onRemove }) => {
const [allowedEvents, setAllowedEvents] = useState<string[]>(existingConnection?.config.events || []);
const toggleEvent = useCallback((evt: Event) => {
const key = (evt.target as HTMLElement).getAttribute('x-event-name');
if (key) {
setAllowedEvents(allowedEvents => (
allowedEvents.includes(key) ? allowedEvents.filter(k => k !== key) : [...allowedEvents, key]
));
}
}, []);
const [newConnectionState, setNewConnectionState] = useState<JiraProjectConnectionState|null>(null);
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
const commandPrefixRef = createRef<HTMLInputElement>();
const handleSave = useCallback((evt: Event) => {
evt.preventDefault();
if (!canEdit || !existingConnection && !newConnectionState) {
return;
}
const state = existingConnection?.config || newConnectionState;
if (state) {
onSave({
...(state),
events: allowedEvents as any[],
commandPrefix: commandPrefixRef.current?.value,
});
}
}, [canEdit, existingConnection, newConnectionState, allowedEvents, commandPrefixRef, onSave]);
return <form onSubmit={handleSave}>
{!existingConnection && <ConnectionSearch api={api} onPicked={setNewConnectionState} />}
<InputField visible={!!existingConnection || !!newConnectionState} ref={commandPrefixRef} label="Command Prefix" noPadding={true}>
<input type="text" value={existingConnection?.config.commandPrefix} placeholder="!jira" />
</InputField>
<InputField visible={!!existingConnection || !!newConnectionState} label="Events" noPadding={true}>
<p>Choose which event should send a notification to the room</p>
<ul>
<EventCheckbox allowedEvents={allowedEvents} eventName="issue.created" onChange={toggleEvent}>Issue created</EventCheckbox>
</ul>
</InputField>
<ButtonSet>
{ canEdit && <Button type="submit" disabled={!existingConnection && !newConnectionState}>{ existingConnection ? "Save" : "Add project" }</Button>}
{ canEdit && existingConnection && <Button intent="remove" onClick={onRemove}>Remove project</Button>}
</ButtonSet>
</form>;
};
const RoomConfigText = {
header: 'JIRA Projects',
createNew: 'Add new JIRA Project',
listCanEdit: 'Your connected projects',
listCantEdit: 'Connected projects',
};
const RoomConfigListItemFunc = (c: JiraProjectResponseItem) => c.config.url;
export const JiraProjectConfig: BridgeConfig = ({ api, roomId }) => {
return <RoomConfig<never, JiraProjectResponseItem, JiraProjectConnectionState>
headerImg={JiraIcon}
api={api}
roomId={roomId}
type="jira"
text={RoomConfigText}
listItemName={RoomConfigListItemFunc}
connectionEventType={EventType}
connectionConfigComponent={ConnectionConfiguration}
/>;
};

BIN
web/icons/jira.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB