mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-26 20:08:38 +02:00
Linear connector (#312)
This commit is contained in:
@@ -32,6 +32,7 @@ class DocumentSource(str, Enum):
|
|||||||
FILE = "file"
|
FILE = "file"
|
||||||
NOTION = "notion"
|
NOTION = "notion"
|
||||||
ZULIP = "zulip"
|
ZULIP = "zulip"
|
||||||
|
LINEAR = "linear"
|
||||||
|
|
||||||
|
|
||||||
class DanswerGenAIModel(str, Enum):
|
class DanswerGenAIModel(str, Enum):
|
||||||
|
@@ -13,6 +13,7 @@ from danswer.connectors.interfaces import BaseConnector
|
|||||||
from danswer.connectors.interfaces import EventConnector
|
from danswer.connectors.interfaces import EventConnector
|
||||||
from danswer.connectors.interfaces import LoadConnector
|
from danswer.connectors.interfaces import LoadConnector
|
||||||
from danswer.connectors.interfaces import PollConnector
|
from danswer.connectors.interfaces import PollConnector
|
||||||
|
from danswer.connectors.linear.connector import LinearConnector
|
||||||
from danswer.connectors.models import InputType
|
from danswer.connectors.models import InputType
|
||||||
from danswer.connectors.notion.connector import NotionConnector
|
from danswer.connectors.notion.connector import NotionConnector
|
||||||
from danswer.connectors.productboard.connector import ProductboardConnector
|
from danswer.connectors.productboard.connector import ProductboardConnector
|
||||||
@@ -22,8 +23,6 @@ from danswer.connectors.slack.connector import SlackPollConnector
|
|||||||
from danswer.connectors.web.connector import WebConnector
|
from danswer.connectors.web.connector import WebConnector
|
||||||
from danswer.connectors.zulip.connector import ZulipConnector
|
from danswer.connectors.zulip.connector import ZulipConnector
|
||||||
|
|
||||||
_NUM_SECONDS_IN_DAY = 86400
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectorMissingException(Exception):
|
class ConnectorMissingException(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -50,6 +49,7 @@ def identify_connector_class(
|
|||||||
DocumentSource.NOTION: NotionConnector,
|
DocumentSource.NOTION: NotionConnector,
|
||||||
DocumentSource.ZULIP: ZulipConnector,
|
DocumentSource.ZULIP: ZulipConnector,
|
||||||
DocumentSource.GURU: GuruConnector,
|
DocumentSource.GURU: GuruConnector,
|
||||||
|
DocumentSource.LINEAR: LinearConnector,
|
||||||
}
|
}
|
||||||
connector_by_source = connector_map.get(source, {})
|
connector_by_source = connector_map.get(source, {})
|
||||||
|
|
||||||
|
217
backend/danswer/connectors/linear/connector.py
Normal file
217
backend/danswer/connectors/linear/connector.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timezone
|
||||||
|
from typing import Any
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
||||||
|
from danswer.configs.constants import DocumentSource
|
||||||
|
from danswer.connectors.interfaces import GenerateDocumentsOutput
|
||||||
|
from danswer.connectors.interfaces import LoadConnector
|
||||||
|
from danswer.connectors.interfaces import PollConnector
|
||||||
|
from danswer.connectors.interfaces import SecondsSinceUnixEpoch
|
||||||
|
from danswer.connectors.models import ConnectorMissingCredentialError
|
||||||
|
from danswer.connectors.models import Document
|
||||||
|
from danswer.connectors.models import Section
|
||||||
|
from danswer.utils.logger import setup_logger
|
||||||
|
|
||||||
|
logger = setup_logger()
|
||||||
|
|
||||||
|
_NUM_RETRIES = 5
|
||||||
|
_TIMEOUT = 60
|
||||||
|
_LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_query(request_body: dict[str, Any], api_key: str) -> requests.Response:
|
||||||
|
headers = {
|
||||||
|
"Authorization": api_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
response: requests.Response | None = None
|
||||||
|
for i in range(_NUM_RETRIES):
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
_LINEAR_GRAPHQL_URL,
|
||||||
|
headers=headers,
|
||||||
|
json=request_body,
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
if not response.ok:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Error fetching issues from Linear: {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
if i == _NUM_RETRIES - 1:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
logger.warning(f"A Linear GraphQL error occurred: {e}. Retrying...")
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unexpected execution when querying Linear. This should never happen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LinearConnector(LoadConnector, PollConnector):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
batch_size: int = INDEX_BATCH_SIZE,
|
||||||
|
) -> None:
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.linear_api_key: str | None = None
|
||||||
|
|
||||||
|
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
self.linear_api_key = cast(str, credentials["linear_api_key"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _process_issues(
|
||||||
|
self, start_str: datetime | None = None, end_str: datetime | None = None
|
||||||
|
) -> GenerateDocumentsOutput:
|
||||||
|
if self.linear_api_key is None:
|
||||||
|
raise ConnectorMissingCredentialError("Linear")
|
||||||
|
|
||||||
|
lte_filter = f'lte: "{end_str}"' if end_str else ""
|
||||||
|
gte_filter = f'gte: "{start_str}"' if start_str else ""
|
||||||
|
updatedAtFilter = f"""
|
||||||
|
{lte_filter}
|
||||||
|
{gte_filter}
|
||||||
|
"""
|
||||||
|
|
||||||
|
query = (
|
||||||
|
"""
|
||||||
|
query IterateIssueBatches($first: Int, $after: String) {
|
||||||
|
issues(
|
||||||
|
orderBy: updatedAt,
|
||||||
|
first: $first,
|
||||||
|
after: $after,
|
||||||
|
filter: {
|
||||||
|
updatedAt: {
|
||||||
|
"""
|
||||||
|
+ updatedAtFilter
|
||||||
|
+ """
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
archivedAt
|
||||||
|
number
|
||||||
|
title
|
||||||
|
priority
|
||||||
|
estimate
|
||||||
|
sortOrder
|
||||||
|
startedAt
|
||||||
|
completedAt
|
||||||
|
startedTriageAt
|
||||||
|
triagedAt
|
||||||
|
canceledAt
|
||||||
|
autoClosedAt
|
||||||
|
autoArchivedAt
|
||||||
|
dueDate
|
||||||
|
slaStartedAt
|
||||||
|
slaBreachesAt
|
||||||
|
trashed
|
||||||
|
snoozedUntilAt
|
||||||
|
team {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
previousIdentifiers
|
||||||
|
subIssueSortOrder
|
||||||
|
priorityLabel
|
||||||
|
identifier
|
||||||
|
url
|
||||||
|
branchName
|
||||||
|
customerTicketCount
|
||||||
|
description
|
||||||
|
descriptionData
|
||||||
|
comments {
|
||||||
|
nodes {
|
||||||
|
url
|
||||||
|
body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
has_more = True
|
||||||
|
endCursor = None
|
||||||
|
while has_more:
|
||||||
|
graphql_query = {
|
||||||
|
"query": query,
|
||||||
|
"variables": {
|
||||||
|
"first": self.batch_size,
|
||||||
|
"after": endCursor,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
logger.debug(f"Requesting issues from Linear with query: {graphql_query}")
|
||||||
|
|
||||||
|
response = _make_query(graphql_query, self.linear_api_key)
|
||||||
|
response_json = response.json()
|
||||||
|
logger.debug(f"Raw response from Linear: {response_json}")
|
||||||
|
edges = response_json["data"]["issues"]["edges"]
|
||||||
|
|
||||||
|
documents: list[Document] = []
|
||||||
|
for edge in edges:
|
||||||
|
node = edge["node"]
|
||||||
|
documents.append(
|
||||||
|
Document(
|
||||||
|
id=node["id"],
|
||||||
|
sections=[
|
||||||
|
Section(
|
||||||
|
link=node["url"],
|
||||||
|
text=node["description"],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
Section(
|
||||||
|
link=node["url"],
|
||||||
|
text=comment["body"],
|
||||||
|
)
|
||||||
|
for comment in node["comments"]["nodes"]
|
||||||
|
],
|
||||||
|
source=DocumentSource.LINEAR,
|
||||||
|
semantic_identifier=node["identifier"],
|
||||||
|
metadata={
|
||||||
|
"updated_at": node["updatedAt"],
|
||||||
|
"team": node["team"]["name"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
yield documents
|
||||||
|
|
||||||
|
endCursor = response_json["data"]["issues"]["pageInfo"]["endCursor"]
|
||||||
|
has_more = response_json["data"]["issues"]["pageInfo"]["hasNextPage"]
|
||||||
|
|
||||||
|
def load_from_state(self) -> GenerateDocumentsOutput:
|
||||||
|
yield from self._process_issues()
|
||||||
|
|
||||||
|
def poll_source(
|
||||||
|
self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
|
||||||
|
) -> GenerateDocumentsOutput:
|
||||||
|
start_time = datetime.fromtimestamp(start, tz=timezone.utc)
|
||||||
|
end_time = datetime.fromtimestamp(end, tz=timezone.utc)
|
||||||
|
|
||||||
|
yield from self._process_issues(start_str=start_time, end_str=end_time)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
connector = LinearConnector()
|
||||||
|
connector.load_credentials({"linear_api_key": os.environ["LINEAR_API_KEY"]})
|
||||||
|
document_batches = connector.load_from_state()
|
||||||
|
print(next(document_batches))
|
BIN
web/public/Linear.png
Normal file
BIN
web/public/Linear.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 456 KiB |
227
web/src/app/admin/connectors/linear/page.tsx
Normal file
227
web/src/app/admin/connectors/linear/page.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import { LinearIcon, TrashIcon } from "@/components/icons/icons";
|
||||||
|
import { TextFormField } from "@/components/admin/connectors/Field";
|
||||||
|
import { HealthCheckBanner } from "@/components/health/healthcheck";
|
||||||
|
import { CredentialForm } from "@/components/admin/connectors/CredentialForm";
|
||||||
|
import {
|
||||||
|
Credential,
|
||||||
|
ConnectorIndexingStatus,
|
||||||
|
LinearCredentialJson,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
|
import { fetcher } from "@/lib/fetcher";
|
||||||
|
import { LoadingAnimation } from "@/components/Loading";
|
||||||
|
import { deleteCredential, linkCredential } from "@/lib/credential";
|
||||||
|
import { ConnectorForm } from "@/components/admin/connectors/ConnectorForm";
|
||||||
|
import { ConnectorsTable } from "@/components/admin/connectors/table/ConnectorsTable";
|
||||||
|
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||||
|
|
||||||
|
const Main = () => {
|
||||||
|
const { popup, setPopup } = usePopup();
|
||||||
|
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const {
|
||||||
|
data: connectorIndexingStatuses,
|
||||||
|
isLoading: isConnectorIndexingStatusesLoading,
|
||||||
|
error: isConnectorIndexingStatusesError,
|
||||||
|
} = useSWR<ConnectorIndexingStatus<any, any>[]>(
|
||||||
|
"/api/manage/admin/connector/indexing-status",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: credentialsData,
|
||||||
|
isLoading: isCredentialsLoading,
|
||||||
|
isValidating: isCredentialsValidating,
|
||||||
|
error: isCredentialsError,
|
||||||
|
} = useSWR<Credential<any>[]>("/api/manage/credential", fetcher);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isConnectorIndexingStatusesLoading ||
|
||||||
|
isCredentialsLoading ||
|
||||||
|
isCredentialsValidating
|
||||||
|
) {
|
||||||
|
return <LoadingAnimation text="Loading" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnectorIndexingStatusesError || !connectorIndexingStatuses) {
|
||||||
|
return <div>Failed to load connectors</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCredentialsError || !credentialsData) {
|
||||||
|
return <div>Failed to load credentials</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linearConnectorIndexingStatuses: ConnectorIndexingStatus<
|
||||||
|
{},
|
||||||
|
LinearCredentialJson
|
||||||
|
>[] = connectorIndexingStatuses.filter(
|
||||||
|
(connectorIndexingStatus) =>
|
||||||
|
connectorIndexingStatus.connector.source === "linear"
|
||||||
|
);
|
||||||
|
const linearCredential: Credential<LinearCredentialJson> =
|
||||||
|
credentialsData.filter(
|
||||||
|
(credential) => credential.credential_json?.linear_api_key
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{popup}
|
||||||
|
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">
|
||||||
|
Step 1: Provide your Credentials
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{linearCredential ? (
|
||||||
|
<>
|
||||||
|
<div className="flex mb-1 text-sm">
|
||||||
|
<p className="my-auto">Existing API Key: </p>
|
||||||
|
<p className="ml-1 italic my-auto max-w-md truncate">
|
||||||
|
{linearCredential.credential_json?.linear_api_key}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="ml-1 hover:bg-gray-700 rounded-full p-1"
|
||||||
|
onClick={async () => {
|
||||||
|
if (linearConnectorIndexingStatuses.length > 0) {
|
||||||
|
setPopup({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Must delete all connectors before deleting credentials",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteCredential(linearCredential.id);
|
||||||
|
mutate("/api/manage/credential");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">
|
||||||
|
To use the Linear connector, first follow the guide{" "}
|
||||||
|
<a
|
||||||
|
className="text-blue-500"
|
||||||
|
href="https://docs.danswer.dev/connectors/jira#setting-up"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>{" "}
|
||||||
|
to generate an API Key.
|
||||||
|
</p>
|
||||||
|
<div className="border-solid border-gray-600 border rounded-md p-6 mt-2">
|
||||||
|
<CredentialForm<LinearCredentialJson>
|
||||||
|
formBody={
|
||||||
|
<>
|
||||||
|
<TextFormField
|
||||||
|
name="linear_api_key"
|
||||||
|
label="Linear API Key:"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
validationSchema={Yup.object().shape({
|
||||||
|
linear_api_key: Yup.string().required(
|
||||||
|
"Please enter your Linear API Key!"
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
initialValues={{
|
||||||
|
linear_api_key: "",
|
||||||
|
}}
|
||||||
|
onSubmit={(isSuccess) => {
|
||||||
|
if (isSuccess) {
|
||||||
|
mutate("/api/manage/credential");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">
|
||||||
|
Step 2: Start indexing
|
||||||
|
</h2>
|
||||||
|
{linearCredential ? (
|
||||||
|
<>
|
||||||
|
{linearConnectorIndexingStatuses.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mb-2">
|
||||||
|
We pull the latest <i>issues</i> and <i>comments</i> every{" "}
|
||||||
|
<b>10</b> minutes.
|
||||||
|
</p>
|
||||||
|
<div className="mb-2">
|
||||||
|
<ConnectorsTable<{}, LinearCredentialJson>
|
||||||
|
connectorIndexingStatuses={linearConnectorIndexingStatuses}
|
||||||
|
liveCredential={linearCredential}
|
||||||
|
getCredential={(credential) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>{credential.credential_json.linear_api_key}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onCredentialLink={async (connectorId) => {
|
||||||
|
if (linearCredential) {
|
||||||
|
await linkCredential(connectorId, linearCredential.id);
|
||||||
|
mutate("/api/manage/admin/connector/indexing-status");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onUpdate={() =>
|
||||||
|
mutate("/api/manage/admin/connector/indexing-status")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="border-solid border-gray-600 border rounded-md p-6 mt-4">
|
||||||
|
<h2 className="font-bold mb-3">Create Connector</h2>
|
||||||
|
<p className="text-sm mb-4">
|
||||||
|
Press connect below to start the connection Linear. We pull the
|
||||||
|
latest <i>issues</i> and <i>comments</i> every <b>10</b>{" "}
|
||||||
|
minutes.
|
||||||
|
</p>
|
||||||
|
<ConnectorForm<{}>
|
||||||
|
nameBuilder={() => "LinearConnector"}
|
||||||
|
source="linear"
|
||||||
|
inputType="poll"
|
||||||
|
formBody={<></>}
|
||||||
|
validationSchema={Yup.object().shape({})}
|
||||||
|
initialValues={{}}
|
||||||
|
refreshFreq={10 * 60} // 10 minutes
|
||||||
|
onSubmit={async (isSuccess, responseJson) => {
|
||||||
|
if (isSuccess && responseJson) {
|
||||||
|
await linkCredential(responseJson.id, linearCredential.id);
|
||||||
|
mutate("/api/manage/admin/connector/indexing-status");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">
|
||||||
|
Please provide your access token in Step 1 first! Once done with
|
||||||
|
that, you can then start indexing Linear.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto container">
|
||||||
|
<div className="mb-4">
|
||||||
|
<HealthCheckBanner />
|
||||||
|
</div>
|
||||||
|
<div className="border-solid border-gray-600 border-b mb-4 pb-2 flex">
|
||||||
|
<LinearIcon size={32} />
|
||||||
|
<h1 className="text-3xl font-bold pl-2">Linear</h1>
|
||||||
|
</div>
|
||||||
|
<Main />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -16,6 +16,7 @@ import {
|
|||||||
NotionIcon,
|
NotionIcon,
|
||||||
ZulipIcon,
|
ZulipIcon,
|
||||||
ProductboardIcon,
|
ProductboardIcon,
|
||||||
|
LinearIcon,
|
||||||
} from "@/components/icons/icons";
|
} from "@/components/icons/icons";
|
||||||
import { DISABLE_AUTH } from "@/lib/constants";
|
import { DISABLE_AUTH } from "@/lib/constants";
|
||||||
import { getCurrentUserSS } from "@/lib/userSS";
|
import { getCurrentUserSS } from "@/lib/userSS";
|
||||||
@@ -106,6 +107,15 @@ export default async function AdminLayout({
|
|||||||
),
|
),
|
||||||
link: "/admin/connectors/jira",
|
link: "/admin/connectors/jira",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: (
|
||||||
|
<div className="flex">
|
||||||
|
<LinearIcon size={16} />
|
||||||
|
<div className="ml-1">Linear</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
link: "/admin/connectors/linear",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: (
|
name: (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
@@ -21,6 +21,7 @@ import jiraSVG from "../../../public/Jira.svg";
|
|||||||
import confluenceSVG from "../../../public/Confluence.svg";
|
import confluenceSVG from "../../../public/Confluence.svg";
|
||||||
import guruIcon from "../../../public/Guru.svg";
|
import guruIcon from "../../../public/Guru.svg";
|
||||||
import zulipIcon from "../../../public/Zulip.png";
|
import zulipIcon from "../../../public/Zulip.png";
|
||||||
|
import linearIcon from "../../../public/Linear.png";
|
||||||
|
|
||||||
interface IconProps {
|
interface IconProps {
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -237,6 +238,21 @@ export const ProductboardIcon = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const LinearIcon = ({
|
||||||
|
size = 16,
|
||||||
|
className = defaultTailwindCSS,
|
||||||
|
}: IconProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
// Linear Icon has a bit more surrounding whitespace than other icons, which is why we need to adjust it here
|
||||||
|
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
|
||||||
|
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
|
||||||
|
>
|
||||||
|
<Image src={linearIcon} alt="Logo" width="96" height="96" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const SlabIcon = ({
|
export const SlabIcon = ({
|
||||||
size = 16,
|
size = 16,
|
||||||
className = defaultTailwindCSS,
|
className = defaultTailwindCSS,
|
||||||
|
@@ -18,6 +18,7 @@ const sources: Source[] = [
|
|||||||
{ displayName: "File", internalName: "file" },
|
{ displayName: "File", internalName: "file" },
|
||||||
{ displayName: "Notion", internalName: "notion" },
|
{ displayName: "Notion", internalName: "notion" },
|
||||||
{ displayName: "Zulip", internalName: "zulip" },
|
{ displayName: "Zulip", internalName: "zulip" },
|
||||||
|
{ displayName: "Linear", internalName: "linear" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SourceSelectorProps {
|
interface SourceSelectorProps {
|
||||||
|
@@ -8,6 +8,7 @@ import {
|
|||||||
GoogleDriveIcon,
|
GoogleDriveIcon,
|
||||||
GuruIcon,
|
GuruIcon,
|
||||||
JiraIcon,
|
JiraIcon,
|
||||||
|
LinearIcon,
|
||||||
NotionIcon,
|
NotionIcon,
|
||||||
ProductboardIcon,
|
ProductboardIcon,
|
||||||
SlabIcon,
|
SlabIcon,
|
||||||
@@ -101,6 +102,12 @@ export const getSourceMetadata = (sourceType: ValidSources): SourceMetadata => {
|
|||||||
displayName: "Guru",
|
displayName: "Guru",
|
||||||
adminPageLink: "/admin/connectors/guru",
|
adminPageLink: "/admin/connectors/guru",
|
||||||
};
|
};
|
||||||
|
case "linear":
|
||||||
|
return {
|
||||||
|
icon: LinearIcon,
|
||||||
|
displayName: "Linear",
|
||||||
|
adminPageLink: "/admin/connectors/linear",
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
throw new Error("Invalid source type");
|
throw new Error("Invalid source type");
|
||||||
}
|
}
|
||||||
|
@@ -20,6 +20,7 @@ export type ValidSources =
|
|||||||
| "notion"
|
| "notion"
|
||||||
| "guru"
|
| "guru"
|
||||||
| "zulip"
|
| "zulip"
|
||||||
|
| "linear"
|
||||||
| "file";
|
| "file";
|
||||||
export type ValidInputTypes = "load_state" | "poll" | "event";
|
export type ValidInputTypes = "load_state" | "poll" | "event";
|
||||||
export type ValidStatuses =
|
export type ValidStatuses =
|
||||||
@@ -181,6 +182,10 @@ export interface GuruCredentialJson {
|
|||||||
guru_user_token: string;
|
guru_user_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinearCredentialJson {
|
||||||
|
linear_api_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
// DELETION
|
// DELETION
|
||||||
|
|
||||||
export interface DeletionAttemptSnapshot {
|
export interface DeletionAttemptSnapshot {
|
||||||
|
Reference in New Issue
Block a user