mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +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":
|
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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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",
|
||||||
|
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