mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 13:17:08 +00:00
Add Jira widget (#502)
* Add Jira widget * Jira widget UI for no login vs no instances * Update changelog wording
This commit is contained in:
parent
4c33c60d7a
commit
0111f4bfa3
1
changelog.d/502.feature
Normal file
1
changelog.d/502.feature
Normal file
@ -0,0 +1 @@
|
||||
Add room configuration widget for Jira.
|
@ -637,6 +637,9 @@ export class BridgeConfig {
|
||||
case "gitlab":
|
||||
config = this.gitlab?.publicConfig;
|
||||
break;
|
||||
case "jira":
|
||||
config = {};
|
||||
break;
|
||||
default:
|
||||
throw new ApiError("Not a known service, or service doesn't expose a config", ErrCode.NotFound);
|
||||
}
|
||||
|
@ -302,6 +302,10 @@ export class ConnectionManager extends EventEmitter {
|
||||
const configObject = this.validateConnectionTarget(userId, this.config.github, "GitHub", "github");
|
||||
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:
|
||||
throw new ApiError(`Connection type doesn't support getting targets or is not known`, ErrCode.NotFound);
|
||||
}
|
||||
|
@ -13,6 +13,9 @@ import { UserTokenStore } from "../UserTokenStore";
|
||||
import { CommandError, NotLoggedInError } from "../errors";
|
||||
import { ApiError, ErrCode } from "../api";
|
||||
import JiraApi from "jira-client";
|
||||
import { GetConnectionsResponseItem } from "../provisioning/api";
|
||||
import { BridgeConfigJira } from "../Config/Config";
|
||||
import { HookshotJiraApi } from "../Jira/Client";
|
||||
|
||||
type JiraAllowedEventsNames = "issue.created";
|
||||
const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"];
|
||||
@ -23,6 +26,27 @@ export interface JiraProjectConnectionState extends IConnectionState {
|
||||
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 {
|
||||
const {url, commandPrefix, events, priority} = state as Partial<JiraProjectConnectionState>;
|
||||
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) {
|
||||
log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectId} ${data.issue.id}`);
|
||||
const url = generateJiraWebLinkFromIssue(data.issue);
|
||||
|
@ -7,10 +7,12 @@ import { FeedsConfig } from "./roomConfig/FeedsConfig";
|
||||
import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig";
|
||||
import { GithubRepoConfig } from "./roomConfig/GithubRepoConfig";
|
||||
import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig";
|
||||
import { JiraProjectConfig } from "./roomConfig/JiraProjectConfig";
|
||||
|
||||
import FeedsIcon from "../icons/feeds.png";
|
||||
import GitHubIcon from "../icons/github.png";
|
||||
import GitLabIcon from "../icons/gitlab.png";
|
||||
import JiraIcon from "../icons/jira.png";
|
||||
import WebhookIcon from "../icons/webhook.png";
|
||||
|
||||
|
||||
@ -27,6 +29,7 @@ enum ConnectionType {
|
||||
Generic = "generic",
|
||||
Github = "github",
|
||||
Gitlab = "gitlab",
|
||||
Jira = "jira",
|
||||
}
|
||||
|
||||
interface IConnectionProps {
|
||||
@ -55,6 +58,12 @@ const connections: Record<ConnectionType, IConnectionProps> = {
|
||||
icon: GitLabIcon,
|
||||
component: GitlabRepoConfig,
|
||||
},
|
||||
[ConnectionType.Jira]: {
|
||||
displayName: 'JIRA',
|
||||
description: "Connect the room to a JIRA project",
|
||||
icon: JiraIcon,
|
||||
component: JiraProjectConfig,
|
||||
},
|
||||
[ConnectionType.Generic]: {
|
||||
displayName: 'Generic Webhook',
|
||||
description: "Create a webhook which can be used to connect any service to Matrix",
|
||||
|
204
web/components/roomConfig/JiraProjectConfig.tsx
Normal file
204
web/components/roomConfig/JiraProjectConfig.tsx
Normal 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
BIN
web/icons/jira.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
Loading…
x
Reference in New Issue
Block a user