mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-06-30 02:28:44 +01:00
Remove Phoenix & Notebooks - Phase 4: Remove GitHub integration (#2528)
Removes the GitHub notebook-repo integration that only existed to pin/browse notebook repositories. Deletes src/GitHub/, the GitHub controls/panes, GitHubUtils, JunoUtils, and the connectToGitHub webpack entry. Decouples NotebookManager, useNotebook, the resource tree, and Explorer from GitHub wiring. Trims JunoClient's GitHub-only methods while keeping the Schema and gallery methods. Removes GitHub config fields from ConfigContext and strips the dead github:// branches from NotebookUtil. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -199,7 +199,34 @@ Schema Analyzer (see decision below), and remove all UI entry points that open n
|
|||||||
Schema Analyzer removal was pulled forward into Phase 2 because it is rendered by the nteract
|
Schema Analyzer removal was pulled forward into Phase 2 because it is rendered by the nteract
|
||||||
engine deleted there. No separate work remains for this phase.
|
engine deleted there. No separate work remains for this phase.
|
||||||
|
|
||||||
### Phase 4 — Remove GitHub integration
|
### Phase 4 — Remove GitHub integration ✅ COMPLETED
|
||||||
|
> **Status:** Implemented on branch `users/jawelton/removenotebooks-phase3-062226`. Full
|
||||||
|
> verification sweep is green: `compile`, `compile:strict`, `lint` (0 errors), `format:check`,
|
||||||
|
> `test` (1900 passing), and `build:ci` (webpack) all pass.
|
||||||
|
>
|
||||||
|
> **Implementation notes / deviations:**
|
||||||
|
> - **Localization was a no-op:** there are **zero** GitHub keys in any locale
|
||||||
|
> `Resources.json`. The GitHub UI strings were hardcoded literals that were deleted with
|
||||||
|
> their components, so no resource-file edits were needed.
|
||||||
|
> - **`JunoClient` was trimmed, not deleted** (deletion stays in Phase 6). Removed only the
|
||||||
|
> GitHub-only members (`cachedPinnedRepos`, `subscribeToPinnedRepos`, `getPinnedRepos`,
|
||||||
|
> `updatePinnedRepos`, `deleteGitHubInfo`, `getGitHubToken`, `deleteAppAuthorization`,
|
||||||
|
> `getGitHubClientParams`, `IPinnedRepo`/`IPinnedBranch`) plus the now-orphaned private
|
||||||
|
> helpers `getNotebooksSubscriptionIdAccountUrl`/`getAccount`/`getSubscriptionId` and the
|
||||||
|
> `knockout` import. Kept the Schema (`requestSchema`/`getSchema`, used by `Database.tsx`)
|
||||||
|
> and gallery methods. Deleted `JunoClient.test.ts` (it only covered removed GitHub methods).
|
||||||
|
> - **`src/Utils/JunoUtils.ts` (+ test) was also deleted** — its `toPinnedRepo`/`toGitHubRepo`
|
||||||
|
> helpers only converted GitHub pinned-repo shapes and had no other consumers.
|
||||||
|
> - **`NotebookUtil.ts` (deferred to Phase 5) was edited here** to drop its `GitHubUtils`
|
||||||
|
> dependency: the dead `github://` content-URI branches in `getFilePath`/`getParentPath`/
|
||||||
|
> `getName`/`replaceName` were removed (its `isNotebookFile` consumer in `ResourceTreeAdapter`
|
||||||
|
> is unaffected). `NotebookUtil.test.ts` lost its github-uri cases.
|
||||||
|
> - **`ConfigContext.ts`:** removed the GitHub-only config fields `GITHUB_CLIENT_ID`,
|
||||||
|
> `GITHUB_TEST_ENV_CLIENT_ID`, `GITHUB_CLIENT_SECRET` (their only consumers were deleted).
|
||||||
|
> - **Telemetry deferred (as planned):** the `Action.NotebooksGitHub*` enum members were left
|
||||||
|
> in `TelemetryConstants.ts` for the Phase 6 cleanup (enum-numbering-safe); only their
|
||||||
|
> now-deleted usages were removed.
|
||||||
|
|
||||||
- Delete `src/GitHub/`, `src/Explorer/Controls/GitHub/`,
|
- Delete `src/GitHub/`, `src/Explorer/Controls/GitHub/`,
|
||||||
`src/Explorer/Panes/GitHubReposPanel/`, `src/Utils/GitHubUtils.ts`,
|
`src/Explorer/Panes/GitHubReposPanel/`, `src/Utils/GitHubUtils.ts`,
|
||||||
`src/connectToGitHub.html`, and the `connectToGitHub` webpack entry & HTML plugin.
|
`src/connectToGitHub.html`, and the `connectToGitHub` webpack entry & HTML plugin.
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ export interface ConfigContext {
|
|||||||
CASSANDRA_PROXY_ENDPOINT: string;
|
CASSANDRA_PROXY_ENDPOINT: string;
|
||||||
PROXY_PATH?: string;
|
PROXY_PATH?: string;
|
||||||
JUNO_ENDPOINT: string;
|
JUNO_ENDPOINT: string;
|
||||||
GITHUB_CLIENT_ID: string;
|
|
||||||
GITHUB_TEST_ENV_CLIENT_ID: string;
|
|
||||||
GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it.
|
|
||||||
isPhoenixEnabled: boolean;
|
isPhoenixEnabled: boolean;
|
||||||
hostedExplorerURL: string;
|
hostedExplorerURL: string;
|
||||||
armAPIVersion?: string;
|
armAPIVersion?: string;
|
||||||
@@ -95,8 +92,6 @@ let configContext: Readonly<ConfigContext> = {
|
|||||||
CATALOG_API_KEY: "",
|
CATALOG_API_KEY: "",
|
||||||
ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net",
|
ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net",
|
||||||
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net",
|
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net",
|
||||||
GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306
|
|
||||||
GITHUB_TEST_ENV_CLIENT_ID: "b63fc8cbf87fd3c6e2eb", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1777772
|
|
||||||
JUNO_ENDPOINT: JunoEndpoints.Prod,
|
JUNO_ENDPOINT: JunoEndpoints.Prod,
|
||||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import { DefaultButton, IButtonProps, ITextFieldProps, TextField } from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
import * as Constants from "../../../Common/Constants";
|
|
||||||
import * as UrlUtility from "../../../Common/UrlUtility";
|
|
||||||
import { IGitHubRepo } from "../../../GitHub/GitHubClient";
|
|
||||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
|
||||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
|
||||||
import * as GitHubUtils from "../../../Utils/GitHubUtils";
|
|
||||||
import Explorer from "../../Explorer";
|
|
||||||
import { RepoListItem } from "./GitHubReposComponent";
|
|
||||||
import { ChildrenMargin } from "./GitHubStyleConstants";
|
|
||||||
|
|
||||||
export interface AddRepoComponentProps {
|
|
||||||
container: Explorer;
|
|
||||||
getRepo: (owner: string, repo: string) => Promise<IGitHubRepo>;
|
|
||||||
pinRepo: (item: RepoListItem) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddRepoComponentState {
|
|
||||||
textFieldValue: string;
|
|
||||||
textFieldErrorMessage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AddRepoComponent extends React.Component<AddRepoComponentProps, AddRepoComponentState> {
|
|
||||||
private static readonly DescriptionText =
|
|
||||||
"Don't see what you're looking for? Add your repo/branch, or any public repo (read-access only) by entering the URL: ";
|
|
||||||
private static readonly ButtonText = "Add";
|
|
||||||
private static readonly TextFieldPlaceholder = "https://github.com/owner/repo/tree/branch";
|
|
||||||
private static readonly TextFieldErrorMessage = "Invalid url";
|
|
||||||
|
|
||||||
constructor(props: AddRepoComponentProps) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
textFieldValue: "",
|
|
||||||
textFieldErrorMessage: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
const textFieldProps: ITextFieldProps = {
|
|
||||||
placeholder: AddRepoComponent.TextFieldPlaceholder,
|
|
||||||
autoFocus: true,
|
|
||||||
value: this.state.textFieldValue,
|
|
||||||
errorMessage: this.state.textFieldErrorMessage,
|
|
||||||
onChange: this.onTextFieldChange,
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonProps: IButtonProps = {
|
|
||||||
text: AddRepoComponent.ButtonText,
|
|
||||||
ariaLabel: AddRepoComponent.ButtonText,
|
|
||||||
onClick: this.onAddRepoButtonClick,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p style={{ marginBottom: ChildrenMargin }}>{AddRepoComponent.DescriptionText}</p>
|
|
||||||
<TextField {...textFieldProps} />
|
|
||||||
<DefaultButton style={{ marginTop: ChildrenMargin }} {...buttonProps} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onTextFieldChange = (
|
|
||||||
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
||||||
newValue?: string,
|
|
||||||
): void => {
|
|
||||||
this.setState({
|
|
||||||
textFieldValue: newValue || "",
|
|
||||||
textFieldErrorMessage: undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private onAddRepoButtonClick = async (): Promise<void> => {
|
|
||||||
const startKey: number = TelemetryProcessor.traceStart(Action.NotebooksGitHubManualRepoAdd, {
|
|
||||||
dataExplorerArea: Constants.Areas.Notebook,
|
|
||||||
});
|
|
||||||
let enteredUrl = this.state.textFieldValue;
|
|
||||||
if (enteredUrl.indexOf("/tree/") === -1) {
|
|
||||||
enteredUrl = UrlUtility.createUri(enteredUrl, `tree/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoInfo = GitHubUtils.fromRepoUri(enteredUrl);
|
|
||||||
if (repoInfo) {
|
|
||||||
this.setState({
|
|
||||||
textFieldValue: "",
|
|
||||||
textFieldErrorMessage: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const repo = await this.props.getRepo(repoInfo.owner, repoInfo.repo);
|
|
||||||
if (repo) {
|
|
||||||
const item: RepoListItem = {
|
|
||||||
key: GitHubUtils.toRepoFullName(repo.owner, repo.name),
|
|
||||||
repo,
|
|
||||||
branches: repoInfo.branch ? [{ name: repoInfo.branch }] : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
TelemetryProcessor.traceSuccess(
|
|
||||||
Action.NotebooksGitHubManualRepoAdd,
|
|
||||||
{
|
|
||||||
dataExplorerArea: Constants.Areas.Notebook,
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
return this.props.pinRepo(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
textFieldErrorMessage: AddRepoComponent.TextFieldErrorMessage,
|
|
||||||
});
|
|
||||||
TelemetryProcessor.traceFailure(
|
|
||||||
Action.NotebooksGitHubManualRepoAdd,
|
|
||||||
{
|
|
||||||
dataExplorerArea: Constants.Areas.Notebook,
|
|
||||||
error: AddRepoComponent.TextFieldErrorMessage,
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { ChoiceGroup, IButtonProps, IChoiceGroupProps, PrimaryButton, IChoiceGroupOption } from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { ChildrenMargin } from "./GitHubStyleConstants";
|
|
||||||
|
|
||||||
export interface AuthorizeAccessComponentProps {
|
|
||||||
scope: string;
|
|
||||||
authorizeAccess: (scope: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorizeAccessComponentState {
|
|
||||||
scope: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthorizeAccessComponent extends React.Component<
|
|
||||||
AuthorizeAccessComponentProps,
|
|
||||||
AuthorizeAccessComponentState
|
|
||||||
> {
|
|
||||||
// Scopes supported by GitHub OAuth. We're only interested in ones which allow us access to repos.
|
|
||||||
// https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
|
|
||||||
public static readonly Scopes = {
|
|
||||||
Public: {
|
|
||||||
key: "public_repo",
|
|
||||||
text: "Public repos only",
|
|
||||||
},
|
|
||||||
PublicAndPrivate: {
|
|
||||||
key: "repo",
|
|
||||||
text: "Public and private repos",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly DescriptionPara1 =
|
|
||||||
"Connect your notebooks workspace to GitHub. You'll be able to view, edit, and run notebooks stored in your GitHub repositories in Data Explorer.";
|
|
||||||
private static readonly DescriptionPara2 =
|
|
||||||
"Complete setup by authorizing Azure Cosmos DB to access the repositories in your GitHub account: ";
|
|
||||||
private static readonly AuthorizeButtonText = "Authorize access";
|
|
||||||
|
|
||||||
private onChoiceGroupChange = (event: React.SyntheticEvent<HTMLElement>, option: IChoiceGroupOption): void =>
|
|
||||||
this.setState({
|
|
||||||
scope: option.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
private onButtonClick = (): void => this.props.authorizeAccess(this.state.scope);
|
|
||||||
|
|
||||||
constructor(props: AuthorizeAccessComponentProps) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
scope: this.props.scope,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
const choiceGroupProps: IChoiceGroupProps = {
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
key: AuthorizeAccessComponent.Scopes.Public.key,
|
|
||||||
text: AuthorizeAccessComponent.Scopes.Public.text,
|
|
||||||
ariaLabel: AuthorizeAccessComponent.Scopes.Public.text,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: AuthorizeAccessComponent.Scopes.PublicAndPrivate.key,
|
|
||||||
text: AuthorizeAccessComponent.Scopes.PublicAndPrivate.text,
|
|
||||||
ariaLabel: AuthorizeAccessComponent.Scopes.PublicAndPrivate.text,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
selectedKey: this.state.scope,
|
|
||||||
onChange: this.onChoiceGroupChange,
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonProps: IButtonProps = {
|
|
||||||
text: AuthorizeAccessComponent.AuthorizeButtonText,
|
|
||||||
ariaLabel: AuthorizeAccessComponent.AuthorizeButtonText,
|
|
||||||
onClick: this.onButtonClick,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>{AuthorizeAccessComponent.DescriptionPara1}</p>
|
|
||||||
<p style={{ marginTop: ChildrenMargin }}>{AuthorizeAccessComponent.DescriptionPara2}</p>
|
|
||||||
<ChoiceGroup style={{ marginTop: ChildrenMargin }} {...choiceGroupProps} />
|
|
||||||
<PrimaryButton style={{ marginTop: ChildrenMargin }} {...buttonProps} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { DefaultButton, IButtonProps, Link, PrimaryButton } from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { IGitHubBranch, IGitHubRepo } from "../../../GitHub/GitHubClient";
|
|
||||||
import { AddRepoComponent, AddRepoComponentProps } from "./AddRepoComponent";
|
|
||||||
import { AuthorizeAccessComponent, AuthorizeAccessComponentProps } from "./AuthorizeAccessComponent";
|
|
||||||
import { ButtonsFooterStyle, ChildrenMargin, ContentFooterStyle } from "./GitHubStyleConstants";
|
|
||||||
import { ReposListComponent, ReposListComponentProps } from "./ReposListComponent";
|
|
||||||
|
|
||||||
export interface GitHubReposComponentProps {
|
|
||||||
showAuthorizeAccess: boolean;
|
|
||||||
authorizeAccessProps: AuthorizeAccessComponentProps;
|
|
||||||
reposListProps: ReposListComponentProps;
|
|
||||||
addRepoProps: AddRepoComponentProps;
|
|
||||||
resetConnection: () => void;
|
|
||||||
onOkClick: () => void;
|
|
||||||
onCancelClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoListItem {
|
|
||||||
key: string;
|
|
||||||
repo: IGitHubRepo;
|
|
||||||
branches: IGitHubBranch[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GitHubReposComponent extends React.Component<GitHubReposComponentProps> {
|
|
||||||
private static readonly ManageGitHubRepoDescription =
|
|
||||||
"Select your GitHub repos and branch(es) to pin to your notebooks workspace.";
|
|
||||||
private static readonly ManageGitHubRepoResetConnection = "View or change your GitHub authorization settings.";
|
|
||||||
private static readonly OKButtonText = "OK";
|
|
||||||
private static readonly CancelButtonText = "Cancel";
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
const content: JSX.Element = this.props.showAuthorizeAccess ? (
|
|
||||||
<AuthorizeAccessComponent {...this.props.authorizeAccessProps} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p>{GitHubReposComponent.ManageGitHubRepoDescription}</p>
|
|
||||||
<Link style={{ marginTop: ChildrenMargin }} onClick={this.props.resetConnection}>
|
|
||||||
{GitHubReposComponent.ManageGitHubRepoResetConnection}
|
|
||||||
</Link>
|
|
||||||
<ReposListComponent {...this.props.reposListProps} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const okProps: IButtonProps = {
|
|
||||||
text: GitHubReposComponent.OKButtonText,
|
|
||||||
ariaLabel: GitHubReposComponent.OKButtonText,
|
|
||||||
onClick: this.props.onOkClick,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelProps: IButtonProps = {
|
|
||||||
text: GitHubReposComponent.CancelButtonText,
|
|
||||||
ariaLabel: GitHubReposComponent.CancelButtonText,
|
|
||||||
onClick: this.props.onCancelClick,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>{content}</div>
|
|
||||||
{!this.props.showAuthorizeAccess && (
|
|
||||||
<>
|
|
||||||
<div className={"paneFooter"} style={ContentFooterStyle}>
|
|
||||||
<AddRepoComponent {...this.props.addRepoProps} />
|
|
||||||
</div>
|
|
||||||
<div className={"paneFooter"} style={ButtonsFooterStyle}>
|
|
||||||
<PrimaryButton {...okProps} />
|
|
||||||
<DefaultButton style={{ marginLeft: ChildrenMargin }} {...cancelProps} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import {
|
|
||||||
ICheckboxStyleProps,
|
|
||||||
ICheckboxStyles,
|
|
||||||
IDropdownStyleProps,
|
|
||||||
IDropdownStyles,
|
|
||||||
IStyleFunctionOrObject,
|
|
||||||
} from "@fluentui/react";
|
|
||||||
|
|
||||||
export const ButtonsFooterStyle: React.CSSProperties = {
|
|
||||||
paddingTop: 14,
|
|
||||||
height: "auto",
|
|
||||||
borderTop: "2px solid lightGray",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ContentFooterStyle: React.CSSProperties = {
|
|
||||||
paddingTop: "10px",
|
|
||||||
height: "auto",
|
|
||||||
borderTop: "2px solid lightGray",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChildrenMargin = 10;
|
|
||||||
export const FontSize = 12;
|
|
||||||
|
|
||||||
export const ReposListCheckboxStyles: IStyleFunctionOrObject<ICheckboxStyleProps, ICheckboxStyles> = {
|
|
||||||
label: {
|
|
||||||
margin: 0,
|
|
||||||
padding: "2 0 2 0",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: FontSize,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BranchesDropdownCheckboxStyles: IStyleFunctionOrObject<ICheckboxStyleProps, ICheckboxStyles> = {
|
|
||||||
label: {
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
fontSize: FontSize,
|
|
||||||
},
|
|
||||||
root: {
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: FontSize,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BranchesDropdownStyles: IStyleFunctionOrObject<IDropdownStyleProps, IDropdownStyles> = {
|
|
||||||
title: {
|
|
||||||
fontSize: FontSize,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BranchesDropdownOptionContainerStyle: React.CSSProperties = {
|
|
||||||
padding: 8,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ContentMainStyle: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReposListRepoColumnMinWidth = 192;
|
|
||||||
export const ReposListBranchesColumnWidth = 116;
|
|
||||||
export const BranchesDropdownWidth = 200;
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
import {
|
|
||||||
Checkbox,
|
|
||||||
DetailsList,
|
|
||||||
DetailsRow,
|
|
||||||
Dropdown,
|
|
||||||
ICheckboxProps,
|
|
||||||
IDetailsFooterProps,
|
|
||||||
IDetailsListProps,
|
|
||||||
IDetailsRowBaseProps,
|
|
||||||
IDropdown,
|
|
||||||
IDropdownOption,
|
|
||||||
IDropdownProps,
|
|
||||||
ILinkProps,
|
|
||||||
ISelectableDroppableTextProps,
|
|
||||||
Link,
|
|
||||||
ResponsiveMode,
|
|
||||||
SelectionMode,
|
|
||||||
Text,
|
|
||||||
} from "@fluentui/react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { IGitHubBranch, IGitHubPageInfo } from "../../../GitHub/GitHubClient";
|
|
||||||
import * as GitHubUtils from "../../../Utils/GitHubUtils";
|
|
||||||
import { RepoListItem } from "./GitHubReposComponent";
|
|
||||||
import {
|
|
||||||
BranchesDropdownCheckboxStyles,
|
|
||||||
BranchesDropdownOptionContainerStyle,
|
|
||||||
BranchesDropdownStyles,
|
|
||||||
BranchesDropdownWidth,
|
|
||||||
ReposListBranchesColumnWidth,
|
|
||||||
ReposListCheckboxStyles,
|
|
||||||
ReposListRepoColumnMinWidth,
|
|
||||||
} from "./GitHubStyleConstants";
|
|
||||||
|
|
||||||
export interface ReposListComponentProps {
|
|
||||||
branchesProps: Record<string, BranchesProps>; // key'd by repo key
|
|
||||||
pinnedReposProps: PinnedReposProps;
|
|
||||||
unpinnedReposProps: UnpinnedReposProps;
|
|
||||||
pinRepo: (repo: RepoListItem) => void;
|
|
||||||
unpinRepo: (repo: RepoListItem) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BranchesProps {
|
|
||||||
branches: IGitHubBranch[];
|
|
||||||
lastPageInfo?: IGitHubPageInfo;
|
|
||||||
hasMore: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
defaultBranchName: string;
|
|
||||||
loadMore: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PinnedReposProps {
|
|
||||||
repos: RepoListItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnpinnedReposProps {
|
|
||||||
repos: RepoListItem[];
|
|
||||||
hasMore: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
loadMore: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ReposListComponent extends React.Component<ReposListComponentProps> {
|
|
||||||
private static readonly PinnedReposColumnName = "Pinned repos";
|
|
||||||
private static readonly UnpinnedReposColumnName = "Unpinned repos";
|
|
||||||
private static readonly BranchesColumnName = "Branches";
|
|
||||||
private static readonly LoadingText = "Loading...";
|
|
||||||
private static readonly LoadMoreText = "Load more";
|
|
||||||
private static readonly DefaultBranchNames = "master/main";
|
|
||||||
private static readonly FooterIndex = -1;
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
|
||||||
const pinnedReposListProps: IDetailsListProps = {
|
|
||||||
styles: {
|
|
||||||
contentWrapper: {
|
|
||||||
height: this.props.pinnedReposProps.repos.length ? undefined : 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
items: this.props.pinnedReposProps.repos,
|
|
||||||
getKey: ReposListComponent.getKey,
|
|
||||||
selectionMode: SelectionMode.none,
|
|
||||||
compact: true,
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
key: ReposListComponent.PinnedReposColumnName,
|
|
||||||
name: ReposListComponent.PinnedReposColumnName,
|
|
||||||
ariaLabel: ReposListComponent.PinnedReposColumnName,
|
|
||||||
minWidth: ReposListRepoColumnMinWidth,
|
|
||||||
onRender: this.onRenderPinnedReposColumnItem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: ReposListComponent.BranchesColumnName,
|
|
||||||
name: ReposListComponent.BranchesColumnName,
|
|
||||||
ariaLabel: ReposListComponent.BranchesColumnName,
|
|
||||||
minWidth: ReposListBranchesColumnWidth,
|
|
||||||
maxWidth: ReposListBranchesColumnWidth,
|
|
||||||
onRender: this.onRenderPinnedReposBranchesColumnItem,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
onRenderDetailsFooter: this.props.pinnedReposProps.repos.length ? undefined : this.onRenderReposFooter,
|
|
||||||
};
|
|
||||||
|
|
||||||
const unpinnedReposListProps: IDetailsListProps = {
|
|
||||||
items: this.props.unpinnedReposProps.repos,
|
|
||||||
getKey: ReposListComponent.getKey,
|
|
||||||
selectionMode: SelectionMode.none,
|
|
||||||
compact: true,
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
key: ReposListComponent.UnpinnedReposColumnName,
|
|
||||||
name: ReposListComponent.UnpinnedReposColumnName,
|
|
||||||
ariaLabel: ReposListComponent.UnpinnedReposColumnName,
|
|
||||||
minWidth: ReposListRepoColumnMinWidth,
|
|
||||||
onRender: this.onRenderUnpinnedReposColumnItem,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: ReposListComponent.BranchesColumnName,
|
|
||||||
name: ReposListComponent.BranchesColumnName,
|
|
||||||
ariaLabel: ReposListComponent.BranchesColumnName,
|
|
||||||
minWidth: ReposListBranchesColumnWidth,
|
|
||||||
maxWidth: ReposListBranchesColumnWidth,
|
|
||||||
onRender: this.onRenderUnpinnedReposBranchesColumnItem,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
onRenderDetailsFooter:
|
|
||||||
this.props.unpinnedReposProps.isLoading || this.props.unpinnedReposProps.hasMore
|
|
||||||
? this.onRenderReposFooter
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DetailsList {...pinnedReposListProps} />
|
|
||||||
<DetailsList {...unpinnedReposListProps} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onRenderPinnedReposColumnItem = (item: RepoListItem, index: number): JSX.Element => {
|
|
||||||
if (index === ReposListComponent.FooterIndex) {
|
|
||||||
return <Text>None</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkboxProps: ICheckboxProps = {
|
|
||||||
...ReposListComponent.getCheckboxPropsForLabel(GitHubUtils.toRepoFullName(item.repo.owner, item.repo.name)),
|
|
||||||
styles: ReposListCheckboxStyles,
|
|
||||||
defaultChecked: true,
|
|
||||||
onChange: () => this.props.unpinRepo(item),
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Checkbox {...checkboxProps} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
private onRenderPinnedReposBranchesColumnItem = (item: RepoListItem, index: number): JSX.Element => {
|
|
||||||
if (index === ReposListComponent.FooterIndex) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const branchesProps = this.props.branchesProps[GitHubUtils.toRepoFullName(item.repo.owner, item.repo.name)];
|
|
||||||
if (item.branches.length === 0 && branchesProps.defaultBranchName) {
|
|
||||||
item.branches = [{ name: branchesProps.defaultBranchName }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: IDropdownOption[] = branchesProps.branches.map((branch) => ({
|
|
||||||
key: branch.name,
|
|
||||||
text: branch.name,
|
|
||||||
data: item,
|
|
||||||
disabled: item.branches.length === 1 && branch.name === item.branches[0].name,
|
|
||||||
selected: item.branches.findIndex((element) => element.name === branch.name) !== -1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (branchesProps.hasMore || branchesProps.isLoading) {
|
|
||||||
const text = branchesProps.isLoading ? ReposListComponent.LoadingText : ReposListComponent.LoadMoreText;
|
|
||||||
options.push({
|
|
||||||
key: text,
|
|
||||||
text,
|
|
||||||
data: item,
|
|
||||||
index: ReposListComponent.FooterIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownProps: IDropdownProps = {
|
|
||||||
styles: BranchesDropdownStyles,
|
|
||||||
dropdownWidth: BranchesDropdownWidth,
|
|
||||||
responsiveMode: ResponsiveMode.large,
|
|
||||||
options,
|
|
||||||
onRenderList: this.onRenderBranchesDropdownList,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (item.branches.length === 1) {
|
|
||||||
dropdownProps.placeholder = item.branches[0].name;
|
|
||||||
} else if (item.branches.length > 1) {
|
|
||||||
dropdownProps.placeholder = `${item.branches.length} branches`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Dropdown {...dropdownProps} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
private onRenderUnpinnedReposBranchesColumnItem = (item: RepoListItem, index: number): JSX.Element => {
|
|
||||||
if (index === ReposListComponent.FooterIndex) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownProps: IDropdownProps = {
|
|
||||||
styles: BranchesDropdownStyles,
|
|
||||||
options: [],
|
|
||||||
placeholder: ReposListComponent.DefaultBranchNames,
|
|
||||||
disabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Dropdown {...dropdownProps} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
private onRenderBranchesDropdownList = (
|
|
||||||
props: ISelectableDroppableTextProps<IDropdown, HTMLDivElement>,
|
|
||||||
): JSX.Element => {
|
|
||||||
const renderedList: JSX.Element[] = [];
|
|
||||||
props.options.forEach((option: IDropdownOption) => {
|
|
||||||
const item = (
|
|
||||||
<div key={option.key} style={BranchesDropdownOptionContainerStyle}>
|
|
||||||
{this.onRenderPinnedReposBranchesDropdownOption(option)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
renderedList.push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return <>{renderedList}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
private onRenderPinnedReposBranchesDropdownOption(option: IDropdownOption): JSX.Element {
|
|
||||||
const item: RepoListItem = option.data;
|
|
||||||
const branchesProps = this.props.branchesProps[GitHubUtils.toRepoFullName(item.repo.owner, item.repo.name)];
|
|
||||||
|
|
||||||
if (option.index === ReposListComponent.FooterIndex) {
|
|
||||||
const linkProps: ILinkProps = {
|
|
||||||
disabled: branchesProps.isLoading,
|
|
||||||
onClick: branchesProps.loadMore,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Link {...linkProps}>{option.text}</Link>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkboxProps: ICheckboxProps = {
|
|
||||||
...ReposListComponent.getCheckboxPropsForLabel(option.text),
|
|
||||||
styles: BranchesDropdownCheckboxStyles,
|
|
||||||
defaultChecked: option.selected,
|
|
||||||
disabled: option.disabled,
|
|
||||||
onChange: (event, checked) => {
|
|
||||||
const repoListItem = { ...item };
|
|
||||||
const branch: IGitHubBranch = { name: option.text };
|
|
||||||
repoListItem.branches = repoListItem.branches.filter((element) => element.name !== branch.name);
|
|
||||||
if (checked) {
|
|
||||||
repoListItem.branches.push(branch);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.pinRepo(repoListItem);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Checkbox {...checkboxProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
private onRenderUnpinnedReposColumnItem = (item: RepoListItem, index: number): JSX.Element => {
|
|
||||||
if (index === ReposListComponent.FooterIndex) {
|
|
||||||
const linkProps: ILinkProps = {
|
|
||||||
disabled: this.props.unpinnedReposProps.isLoading,
|
|
||||||
onClick: this.props.unpinnedReposProps.loadMore,
|
|
||||||
};
|
|
||||||
|
|
||||||
const linkText = this.props.unpinnedReposProps.isLoading
|
|
||||||
? ReposListComponent.LoadingText
|
|
||||||
: ReposListComponent.LoadMoreText;
|
|
||||||
return <Link {...linkProps}>{linkText}</Link>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkboxProps: ICheckboxProps = {
|
|
||||||
...ReposListComponent.getCheckboxPropsForLabel(GitHubUtils.toRepoFullName(item.repo.owner, item.repo.name)),
|
|
||||||
styles: ReposListCheckboxStyles,
|
|
||||||
onChange: () => {
|
|
||||||
const repoListItem = { ...item };
|
|
||||||
repoListItem.branches = [];
|
|
||||||
this.props.pinRepo(repoListItem);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Checkbox {...checkboxProps} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
private onRenderReposFooter = (detailsFooterProps: IDetailsFooterProps): JSX.Element => {
|
|
||||||
const props: IDetailsRowBaseProps = {
|
|
||||||
...detailsFooterProps,
|
|
||||||
item: {},
|
|
||||||
itemIndex: ReposListComponent.FooterIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <DetailsRow {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
private static getCheckboxPropsForLabel(label: string): ICheckboxProps {
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
title: label,
|
|
||||||
ariaLabel: label,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getKey(item: RepoListItem): string {
|
|
||||||
return item.key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,6 @@ import * as DataModels from "../Contracts/DataModels";
|
|||||||
import { ContainerConnectionInfo, IPhoenixServiceInfo, IProvisionData, IResponse } from "../Contracts/DataModels";
|
import { ContainerConnectionInfo, IPhoenixServiceInfo, IProvisionData, IResponse } from "../Contracts/DataModels";
|
||||||
import * as ViewModels from "../Contracts/ViewModels";
|
import * as ViewModels from "../Contracts/ViewModels";
|
||||||
import { UploadDetailsRecord } from "../Contracts/ViewModels";
|
import { UploadDetailsRecord } from "../Contracts/ViewModels";
|
||||||
import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
|
|
||||||
import MetricScenario from "../Metrics/MetricEvents";
|
import MetricScenario from "../Metrics/MetricEvents";
|
||||||
import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig";
|
import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig";
|
||||||
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
|
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
|
||||||
@@ -79,8 +78,6 @@ export default class Explorer {
|
|||||||
// Tabs
|
// Tabs
|
||||||
public isTabsContentExpanded: ko.Observable<boolean>;
|
public isTabsContentExpanded: ko.Observable<boolean>;
|
||||||
|
|
||||||
public gitHubOAuthService: GitHubOAuthService;
|
|
||||||
|
|
||||||
// Notebooks
|
// Notebooks
|
||||||
public notebookManager?: NotebookManager;
|
public notebookManager?: NotebookManager;
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,11 @@
|
|||||||
* Contains all notebook related stuff meant to be dynamically loaded by explorer
|
* Contains all notebook related stuff meant to be dynamically loaded by explorer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { HttpStatusCodes } from "../../Common/Constants";
|
|
||||||
import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
|
|
||||||
import * as Logger from "../../Common/Logger";
|
|
||||||
import { GitHubClient } from "../../GitHub/GitHubClient";
|
|
||||||
import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService";
|
|
||||||
import { useSidePanel } from "../../hooks/useSidePanel";
|
|
||||||
import { JunoClient } from "../../Juno/JunoClient";
|
import { JunoClient } from "../../Juno/JunoClient";
|
||||||
import { userContext } from "../../UserContext";
|
import { userContext } from "../../UserContext";
|
||||||
import { useDialog } from "../Controls/Dialog";
|
|
||||||
import Explorer from "../Explorer";
|
import Explorer from "../Explorer";
|
||||||
import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel";
|
|
||||||
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
|
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
|
||||||
import { NotebookContainerClient } from "./NotebookContainerClient";
|
import { NotebookContainerClient } from "./NotebookContainerClient";
|
||||||
import { useNotebook } from "./useNotebook";
|
|
||||||
|
|
||||||
export interface NotebookManagerOptions {
|
export interface NotebookManagerOptions {
|
||||||
container: Explorer;
|
container: Explorer;
|
||||||
@@ -31,85 +21,12 @@ export default class NotebookManager {
|
|||||||
|
|
||||||
public notebookClient: NotebookContainerClient;
|
public notebookClient: NotebookContainerClient;
|
||||||
|
|
||||||
public gitHubOAuthService: GitHubOAuthService;
|
|
||||||
public gitHubClient: GitHubClient;
|
|
||||||
|
|
||||||
public initialize(params: NotebookManagerOptions): void {
|
public initialize(params: NotebookManagerOptions): void {
|
||||||
this.params = params;
|
this.params = params;
|
||||||
this.junoClient = new JunoClient();
|
this.junoClient = new JunoClient();
|
||||||
|
|
||||||
this.gitHubOAuthService = new GitHubOAuthService(this.junoClient);
|
|
||||||
this.gitHubClient = new GitHubClient(this.onGitHubClientError);
|
|
||||||
|
|
||||||
this.notebookClient = new NotebookContainerClient(() =>
|
this.notebookClient = new NotebookContainerClient(() =>
|
||||||
this.params.container.initNotebooks(userContext?.databaseAccount),
|
this.params.container.initNotebooks(userContext?.databaseAccount),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.gitHubOAuthService.getTokenObservable().subscribe((token) => {
|
|
||||||
this.gitHubClient.setToken(token?.access_token);
|
|
||||||
if (this?.gitHubOAuthService.isLoggedIn()) {
|
|
||||||
useSidePanel.getState().closeSidePanel();
|
|
||||||
setTimeout(() => {
|
|
||||||
useSidePanel
|
|
||||||
.getState()
|
|
||||||
.openSidePanel(
|
|
||||||
"Manage GitHub settings",
|
|
||||||
<GitHubReposPanel
|
|
||||||
explorer={this.params.container}
|
|
||||||
gitHubClientProp={this.params.container.notebookManager.gitHubClient}
|
|
||||||
junoClientProp={this.junoClient}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}, 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.params.refreshCommandBarButtons();
|
|
||||||
this.params.refreshNotebookList();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.junoClient.subscribeToPinnedRepos((pinnedRepos) => {
|
|
||||||
this.params.resourceTree.initializeGitHubRepos(pinnedRepos);
|
|
||||||
this.params.resourceTree.triggerRender();
|
|
||||||
useNotebook.getState().initializeGitHubRepos(pinnedRepos);
|
|
||||||
});
|
|
||||||
this.refreshPinnedRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
public refreshPinnedRepos(): void {
|
|
||||||
const token = this.gitHubOAuthService.getTokenObservable()();
|
|
||||||
if (token) {
|
|
||||||
this.junoClient.getPinnedRepos(token.scope);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Octokit's error handler uses any
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
private onGitHubClientError = (error: any): void => {
|
|
||||||
Logger.logError(getErrorMessage(error), "NotebookManager/onGitHubClientError");
|
|
||||||
|
|
||||||
if (error.status === HttpStatusCodes.Unauthorized) {
|
|
||||||
this.gitHubOAuthService.resetToken();
|
|
||||||
|
|
||||||
useDialog
|
|
||||||
.getState()
|
|
||||||
.showOkCancelModalDialog(
|
|
||||||
undefined,
|
|
||||||
"Cosmos DB cannot access your Github account anymore. Please connect to GitHub again.",
|
|
||||||
"Connect to GitHub",
|
|
||||||
() =>
|
|
||||||
useSidePanel
|
|
||||||
.getState()
|
|
||||||
.openSidePanel(
|
|
||||||
"Connect to GitHub",
|
|
||||||
<GitHubReposPanel
|
|
||||||
explorer={this.params.container}
|
|
||||||
gitHubClientProp={this.params.container.notebookManager.gitHubClient}
|
|
||||||
junoClientProp={this.junoClient}
|
|
||||||
/>,
|
|
||||||
),
|
|
||||||
"Cancel",
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
|
||||||
import { NotebookUtil } from "./NotebookUtil";
|
import { NotebookUtil } from "./NotebookUtil";
|
||||||
|
|
||||||
const fileName = "file";
|
const fileName = "file";
|
||||||
@@ -6,9 +5,6 @@ const notebookName = "file.ipynb";
|
|||||||
const folderPath = "folder";
|
const folderPath = "folder";
|
||||||
const filePath = `${folderPath}/${fileName}`;
|
const filePath = `${folderPath}/${fileName}`;
|
||||||
const notebookPath = `${folderPath}/${notebookName}`;
|
const notebookPath = `${folderPath}/${notebookName}`;
|
||||||
const gitHubFolderUri = GitHubUtils.toContentUri("owner", "repo", "branch", folderPath);
|
|
||||||
const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath);
|
|
||||||
const gitHubNotebookUri = GitHubUtils.toContentUri("owner", "repo", "branch", notebookPath);
|
|
||||||
|
|
||||||
describe("NotebookUtil", () => {
|
describe("NotebookUtil", () => {
|
||||||
describe("isNotebookFile", () => {
|
describe("isNotebookFile", () => {
|
||||||
@@ -16,31 +12,18 @@ describe("NotebookUtil", () => {
|
|||||||
expect(NotebookUtil.isNotebookFile(filePath)).toBeFalsy();
|
expect(NotebookUtil.isNotebookFile(filePath)).toBeFalsy();
|
||||||
expect(NotebookUtil.isNotebookFile(notebookPath)).toBeTruthy();
|
expect(NotebookUtil.isNotebookFile(notebookPath)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("works for github file uris", () => {
|
|
||||||
expect(NotebookUtil.isNotebookFile(gitHubFileUri)).toBeFalsy();
|
|
||||||
expect(NotebookUtil.isNotebookFile(gitHubNotebookUri)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getFilePath", () => {
|
describe("getFilePath", () => {
|
||||||
it("works for jupyter file paths", () => {
|
it("works for jupyter file paths", () => {
|
||||||
expect(NotebookUtil.getFilePath(folderPath, fileName)).toEqual(filePath);
|
expect(NotebookUtil.getFilePath(folderPath, fileName)).toEqual(filePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("works for github file uris", () => {
|
|
||||||
expect(NotebookUtil.getFilePath(gitHubFolderUri, fileName)).toEqual(gitHubFileUri);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getParentPath", () => {
|
describe("getParentPath", () => {
|
||||||
it("works for jupyter file paths", () => {
|
it("works for jupyter file paths", () => {
|
||||||
expect(NotebookUtil.getParentPath(filePath)).toEqual(folderPath);
|
expect(NotebookUtil.getParentPath(filePath)).toEqual(folderPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("works for github file uris", () => {
|
|
||||||
expect(NotebookUtil.getParentPath(gitHubFileUri)).toEqual(gitHubFolderUri);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getName", () => {
|
describe("getName", () => {
|
||||||
@@ -48,11 +31,6 @@ describe("NotebookUtil", () => {
|
|||||||
expect(NotebookUtil.getName(filePath)).toEqual(fileName);
|
expect(NotebookUtil.getName(filePath)).toEqual(fileName);
|
||||||
expect(NotebookUtil.getName(notebookPath)).toEqual(notebookName);
|
expect(NotebookUtil.getName(notebookPath)).toEqual(notebookName);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("works for github file uris", () => {
|
|
||||||
expect(NotebookUtil.getName(gitHubFileUri)).toEqual(fileName);
|
|
||||||
expect(NotebookUtil.getName(gitHubNotebookUri)).toEqual(notebookName);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("replaceName", () => {
|
describe("replaceName", () => {
|
||||||
@@ -60,12 +38,5 @@ describe("NotebookUtil", () => {
|
|||||||
expect(NotebookUtil.replaceName(filePath, "newName")).toEqual(filePath.replace(fileName, "newName"));
|
expect(NotebookUtil.replaceName(filePath, "newName")).toEqual(filePath.replace(fileName, "newName"));
|
||||||
expect(NotebookUtil.replaceName(notebookPath, "newName")).toEqual(notebookPath.replace(notebookName, "newName"));
|
expect(NotebookUtil.replaceName(notebookPath, "newName")).toEqual(notebookPath.replace(notebookName, "newName"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("works for github file uris", () => {
|
|
||||||
expect(NotebookUtil.replaceName(gitHubFileUri, "newName")).toEqual(gitHubFileUri.replace(fileName, "newName"));
|
|
||||||
expect(NotebookUtil.replaceName(gitHubNotebookUri, "newName")).toEqual(
|
|
||||||
gitHubNotebookUri.replace(notebookName, "newName"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
|
||||||
import * as StringUtils from "../../Utils/StringUtils";
|
import * as StringUtils from "../../Utils/StringUtils";
|
||||||
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
||||||
|
|
||||||
@@ -51,36 +50,12 @@ export class NotebookUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static getFilePath(path: string, fileName: string): string {
|
public static getFilePath(path: string, fileName: string): string {
|
||||||
const contentInfo = GitHubUtils.fromContentUri(path);
|
|
||||||
if (contentInfo) {
|
|
||||||
let path = fileName;
|
|
||||||
if (contentInfo.path) {
|
|
||||||
path = `${contentInfo.path}/${path}`;
|
|
||||||
}
|
|
||||||
return GitHubUtils.toContentUri(contentInfo.owner, contentInfo.repo, contentInfo.branch, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${path}/${fileName}`;
|
return `${path}/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getParentPath(filepath: string): undefined | string {
|
public static getParentPath(filepath: string): undefined | string {
|
||||||
const basename = NotebookUtil.getName(filepath);
|
const basename = NotebookUtil.getName(filepath);
|
||||||
if (basename) {
|
if (basename) {
|
||||||
const contentInfo = GitHubUtils.fromContentUri(filepath);
|
|
||||||
if (contentInfo) {
|
|
||||||
const parentPath = contentInfo.path.split(basename).shift();
|
|
||||||
if (parentPath === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GitHubUtils.toContentUri(
|
|
||||||
contentInfo.owner,
|
|
||||||
contentInfo.repo,
|
|
||||||
contentInfo.branch,
|
|
||||||
parentPath.replace(/\/$/, ""), // no trailling slash
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPath = filepath.split(basename).shift();
|
const parentPath = filepath.split(basename).shift();
|
||||||
if (parentPath) {
|
if (parentPath) {
|
||||||
return parentPath.replace(/\/$/, ""); // no trailling slash
|
return parentPath.replace(/\/$/, ""); // no trailling slash
|
||||||
@@ -91,27 +66,10 @@ export class NotebookUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static getName(path: string): undefined | string {
|
public static getName(path: string): undefined | string {
|
||||||
let relativePath: string = path;
|
return path.split("/").pop();
|
||||||
const contentInfo = GitHubUtils.fromContentUri(path);
|
|
||||||
if (contentInfo) {
|
|
||||||
relativePath = contentInfo.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return relativePath.split("/").pop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static replaceName(path: string, newName: string): string {
|
public static replaceName(path: string, newName: string): string {
|
||||||
const contentInfo = GitHubUtils.fromContentUri(path);
|
|
||||||
if (contentInfo) {
|
|
||||||
const contentName = contentInfo.path.split("/").pop();
|
|
||||||
if (!contentName) {
|
|
||||||
throw new Error(`Failed to extract name from github path ${contentInfo.path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const basePath = contentInfo.path.split(contentName).shift();
|
|
||||||
return GitHubUtils.toContentUri(contentInfo.owner, contentInfo.repo, contentInfo.branch, `${basePath}${newName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentName = path.split("/").pop();
|
const contentName = path.split("/").pop();
|
||||||
if (!contentName) {
|
if (!contentName) {
|
||||||
throw new Error(`Failed to extract name from path ${path}`);
|
throw new Error(`Failed to extract name from path ${path}`);
|
||||||
|
|||||||
@@ -8,15 +8,12 @@ import * as Logger from "../../Common/Logger";
|
|||||||
import { configContext } from "../../ConfigContext";
|
import { configContext } from "../../ConfigContext";
|
||||||
import * as DataModels from "../../Contracts/DataModels";
|
import * as DataModels from "../../Contracts/DataModels";
|
||||||
import { ContainerConnectionInfo, ContainerInfo } from "../../Contracts/DataModels";
|
import { ContainerConnectionInfo, ContainerInfo } from "../../Contracts/DataModels";
|
||||||
import { IPinnedRepo } from "../../Juno/JunoClient";
|
|
||||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||||
import { userContext } from "../../UserContext";
|
import { userContext } from "../../UserContext";
|
||||||
import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils";
|
import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils";
|
||||||
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
|
||||||
import { useTabs } from "../../hooks/useTabs";
|
import { useTabs } from "../../hooks/useTabs";
|
||||||
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
||||||
import NotebookManager from "./NotebookManager";
|
|
||||||
|
|
||||||
interface NotebookState {
|
interface NotebookState {
|
||||||
isNotebookEnabled: boolean;
|
isNotebookEnabled: boolean;
|
||||||
@@ -29,7 +26,6 @@ interface NotebookState {
|
|||||||
notebookBasePath: string;
|
notebookBasePath: string;
|
||||||
isInitializingNotebooks: boolean;
|
isInitializingNotebooks: boolean;
|
||||||
myNotebooksContentRoot: NotebookContentItem;
|
myNotebooksContentRoot: NotebookContentItem;
|
||||||
gitHubNotebooksContentRoot: NotebookContentItem;
|
|
||||||
galleryContentRoot: NotebookContentItem;
|
galleryContentRoot: NotebookContentItem;
|
||||||
connectionInfo: ContainerConnectionInfo;
|
connectionInfo: ContainerConnectionInfo;
|
||||||
notebookFolderName: string;
|
notebookFolderName: string;
|
||||||
@@ -49,11 +45,10 @@ interface NotebookState {
|
|||||||
setNotebookFolderName: (notebookFolderName: string) => void;
|
setNotebookFolderName: (notebookFolderName: string) => void;
|
||||||
refreshNotebooksEnabledStateForAccount: () => Promise<void>;
|
refreshNotebooksEnabledStateForAccount: () => Promise<void>;
|
||||||
findItem: (root: NotebookContentItem, item: NotebookContentItem) => NotebookContentItem;
|
findItem: (root: NotebookContentItem, item: NotebookContentItem) => NotebookContentItem;
|
||||||
insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem, isGithubTree?: boolean) => void;
|
insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem) => void;
|
||||||
updateNotebookItem: (item: NotebookContentItem, isGithubTree?: boolean) => void;
|
updateNotebookItem: (item: NotebookContentItem) => void;
|
||||||
deleteNotebookItem: (item: NotebookContentItem, isGithubTree?: boolean) => void;
|
deleteNotebookItem: (item: NotebookContentItem) => void;
|
||||||
initializeNotebooksTree: (notebookManager: NotebookManager) => Promise<void>;
|
initializeNotebooksTree: () => Promise<void>;
|
||||||
initializeGitHubRepos: (pinnedRepos: IPinnedRepo[]) => void;
|
|
||||||
setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => void;
|
setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => void;
|
||||||
setIsAllocating: (isAllocating: boolean) => void;
|
setIsAllocating: (isAllocating: boolean) => void;
|
||||||
resetContainerConnection: (connectionStatus: ContainerConnectionInfo) => void;
|
resetContainerConnection: (connectionStatus: ContainerConnectionInfo) => void;
|
||||||
@@ -83,7 +78,6 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
|
|||||||
notebookBasePath: Constants.Notebook.defaultBasePath,
|
notebookBasePath: Constants.Notebook.defaultBasePath,
|
||||||
isInitializingNotebooks: false,
|
isInitializingNotebooks: false,
|
||||||
myNotebooksContentRoot: undefined,
|
myNotebooksContentRoot: undefined,
|
||||||
gitHubNotebooksContentRoot: undefined,
|
|
||||||
galleryContentRoot: undefined,
|
galleryContentRoot: undefined,
|
||||||
connectionInfo: {
|
connectionInfo: {
|
||||||
status: ConnectionStatusType.Connect,
|
status: ConnectionStatusType.Connect,
|
||||||
@@ -183,8 +177,8 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem, isGithubTree?: boolean): void => {
|
insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem): void => {
|
||||||
const root = isGithubTree ? cloneDeep(get().gitHubNotebooksContentRoot) : cloneDeep(get().myNotebooksContentRoot);
|
const root = cloneDeep(get().myNotebooksContentRoot);
|
||||||
const parentItem = get().findItem(root, parent);
|
const parentItem = get().findItem(root, parent);
|
||||||
item.parent = parentItem;
|
item.parent = parentItem;
|
||||||
if (parentItem.children) {
|
if (parentItem.children) {
|
||||||
@@ -192,23 +186,23 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
|
|||||||
} else {
|
} else {
|
||||||
parentItem.children = [item];
|
parentItem.children = [item];
|
||||||
}
|
}
|
||||||
isGithubTree ? set({ gitHubNotebooksContentRoot: root }) : set({ myNotebooksContentRoot: root });
|
set({ myNotebooksContentRoot: root });
|
||||||
},
|
},
|
||||||
updateNotebookItem: (item: NotebookContentItem, isGithubTree?: boolean): void => {
|
updateNotebookItem: (item: NotebookContentItem): void => {
|
||||||
const root = isGithubTree ? cloneDeep(get().gitHubNotebooksContentRoot) : cloneDeep(get().myNotebooksContentRoot);
|
const root = cloneDeep(get().myNotebooksContentRoot);
|
||||||
const parentItem = get().findItem(root, item.parent);
|
const parentItem = get().findItem(root, item.parent);
|
||||||
parentItem.children = parentItem.children.filter((child) => child.path !== item.path);
|
parentItem.children = parentItem.children.filter((child) => child.path !== item.path);
|
||||||
parentItem.children.push(item);
|
parentItem.children.push(item);
|
||||||
item.parent = parentItem;
|
item.parent = parentItem;
|
||||||
isGithubTree ? set({ gitHubNotebooksContentRoot: root }) : set({ myNotebooksContentRoot: root });
|
set({ myNotebooksContentRoot: root });
|
||||||
},
|
},
|
||||||
deleteNotebookItem: (item: NotebookContentItem, isGithubTree?: boolean): void => {
|
deleteNotebookItem: (item: NotebookContentItem): void => {
|
||||||
const root = isGithubTree ? cloneDeep(get().gitHubNotebooksContentRoot) : cloneDeep(get().myNotebooksContentRoot);
|
const root = cloneDeep(get().myNotebooksContentRoot);
|
||||||
const parentItem = get().findItem(root, item.parent);
|
const parentItem = get().findItem(root, item.parent);
|
||||||
parentItem.children = parentItem.children.filter((child) => child.path !== item.path);
|
parentItem.children = parentItem.children.filter((child) => child.path !== item.path);
|
||||||
isGithubTree ? set({ gitHubNotebooksContentRoot: root }) : set({ myNotebooksContentRoot: root });
|
set({ myNotebooksContentRoot: root });
|
||||||
},
|
},
|
||||||
initializeNotebooksTree: async (notebookManager: NotebookManager): Promise<void> => {
|
initializeNotebooksTree: async (): Promise<void> => {
|
||||||
const notebookFolderName = get().isPhoenixNotebooks ? "Temporary Notebooks" : "My Notebooks";
|
const notebookFolderName = get().isPhoenixNotebooks ? "Temporary Notebooks" : "My Notebooks";
|
||||||
set({ notebookFolderName });
|
set({ notebookFolderName });
|
||||||
const myNotebooksContentRoot = {
|
const myNotebooksContentRoot = {
|
||||||
@@ -221,49 +215,12 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
|
|||||||
path: "Gallery",
|
path: "Gallery",
|
||||||
type: NotebookContentItemType.File,
|
type: NotebookContentItemType.File,
|
||||||
};
|
};
|
||||||
const gitHubNotebooksContentRoot = notebookManager?.gitHubOAuthService?.isLoggedIn()
|
|
||||||
? {
|
|
||||||
name: "GitHub repos",
|
|
||||||
path: "PsuedoDir",
|
|
||||||
type: NotebookContentItemType.Directory,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
myNotebooksContentRoot,
|
myNotebooksContentRoot,
|
||||||
galleryContentRoot,
|
galleryContentRoot,
|
||||||
gitHubNotebooksContentRoot,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
initializeGitHubRepos: (pinnedRepos: IPinnedRepo[]): void => {
|
|
||||||
const gitHubNotebooksContentRoot = cloneDeep(get().gitHubNotebooksContentRoot);
|
|
||||||
if (gitHubNotebooksContentRoot) {
|
|
||||||
gitHubNotebooksContentRoot.children = [];
|
|
||||||
pinnedRepos?.forEach((pinnedRepo) => {
|
|
||||||
const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name);
|
|
||||||
const repoTreeItem: NotebookContentItem = {
|
|
||||||
name: repoFullName,
|
|
||||||
path: "PsuedoDir",
|
|
||||||
type: NotebookContentItemType.Directory,
|
|
||||||
children: [],
|
|
||||||
parent: gitHubNotebooksContentRoot,
|
|
||||||
};
|
|
||||||
|
|
||||||
pinnedRepo.branches.forEach((branch) => {
|
|
||||||
repoTreeItem.children.push({
|
|
||||||
name: branch.name,
|
|
||||||
path: GitHubUtils.toContentUri(pinnedRepo.owner, pinnedRepo.name, branch.name, ""),
|
|
||||||
type: NotebookContentItemType.Directory,
|
|
||||||
parent: repoTreeItem,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
gitHubNotebooksContentRoot.children.push(repoTreeItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
set({ gitHubNotebooksContentRoot });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => set({ connectionInfo }),
|
setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => set({ connectionInfo }),
|
||||||
setIsAllocating: (isAllocating: boolean) => set({ isAllocating }),
|
setIsAllocating: (isAllocating: boolean) => set({ isAllocating }),
|
||||||
resetContainerConnection: (connectionStatus: ContainerConnectionInfo): void => {
|
resetContainerConnection: (connectionStatus: ContainerConnectionInfo): void => {
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { shallow } from "enzyme";
|
|
||||||
import React from "react";
|
|
||||||
import { GitHubClient } from "../../../GitHub/GitHubClient";
|
|
||||||
import { JunoClient } from "../../../Juno/JunoClient";
|
|
||||||
import Explorer from "../../Explorer";
|
|
||||||
import { GitHubReposPanel } from "./GitHubReposPanel";
|
|
||||||
const props = {
|
|
||||||
explorer: new Explorer(),
|
|
||||||
closePanel: (): void => undefined,
|
|
||||||
gitHubClientProp: new GitHubClient((): void => undefined),
|
|
||||||
junoClientProp: new JunoClient(),
|
|
||||||
openNotificationConsole: (): void => undefined,
|
|
||||||
};
|
|
||||||
describe("GitHub Repos Panel", () => {
|
|
||||||
it("should render Default properly", () => {
|
|
||||||
const wrapper = shallow(<GitHubReposPanel {...props} />);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Areas, HttpStatusCodes } from "../../../Common/Constants";
|
|
||||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
|
||||||
import { GitHubClient, IGitHubPageInfo, IGitHubRepo } from "../../../GitHub/GitHubClient";
|
|
||||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
|
||||||
import { IPinnedRepo, JunoClient } from "../../../Juno/JunoClient";
|
|
||||||
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
|
|
||||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
|
||||||
import * as GitHubUtils from "../../../Utils/GitHubUtils";
|
|
||||||
import * as JunoUtils from "../../../Utils/JunoUtils";
|
|
||||||
import { AuthorizeAccessComponent } from "../../Controls/GitHub/AuthorizeAccessComponent";
|
|
||||||
import {
|
|
||||||
GitHubReposComponent,
|
|
||||||
GitHubReposComponentProps,
|
|
||||||
RepoListItem,
|
|
||||||
} from "../../Controls/GitHub/GitHubReposComponent";
|
|
||||||
import { ContentMainStyle } from "../../Controls/GitHub/GitHubStyleConstants";
|
|
||||||
import { BranchesProps, PinnedReposProps, UnpinnedReposProps } from "../../Controls/GitHub/ReposListComponent";
|
|
||||||
import Explorer from "../../Explorer";
|
|
||||||
import { PanelInfoErrorComponent } from "../PanelInfoErrorComponent";
|
|
||||||
import { PanelLoadingScreen } from "../PanelLoadingScreen";
|
|
||||||
|
|
||||||
interface IGitHubReposPanelProps {
|
|
||||||
explorer: Explorer;
|
|
||||||
gitHubClientProp: GitHubClient;
|
|
||||||
junoClientProp: JunoClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IGitHubReposPanelState {
|
|
||||||
showAuthorizationAcessState: boolean;
|
|
||||||
isExecuting: boolean;
|
|
||||||
errorMessage: string;
|
|
||||||
showErrorDetails: boolean;
|
|
||||||
gitHubReposState: GitHubReposComponentProps;
|
|
||||||
}
|
|
||||||
export class GitHubReposPanel extends React.Component<IGitHubReposPanelProps, IGitHubReposPanelState> {
|
|
||||||
private static readonly PageSize = 30;
|
|
||||||
private static readonly MasterBranchName = "master";
|
|
||||||
private static readonly MainBranchName = "main";
|
|
||||||
|
|
||||||
private isAddedRepo = false;
|
|
||||||
private gitHubClient: GitHubClient;
|
|
||||||
private junoClient: JunoClient;
|
|
||||||
|
|
||||||
private branchesProps: Record<string, BranchesProps>;
|
|
||||||
private pinnedReposProps: PinnedReposProps;
|
|
||||||
private unpinnedReposProps: UnpinnedReposProps;
|
|
||||||
|
|
||||||
private allGitHubRepos: IGitHubRepo[];
|
|
||||||
private allGitHubReposLastPageInfo?: IGitHubPageInfo;
|
|
||||||
private pinnedReposUpdated: boolean;
|
|
||||||
|
|
||||||
constructor(props: IGitHubReposPanelProps) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.unpinnedReposProps = {
|
|
||||||
repos: [],
|
|
||||||
hasMore: true,
|
|
||||||
isLoading: true,
|
|
||||||
loadMore: (): Promise<void> => this.loadMoreUnpinnedRepos(),
|
|
||||||
};
|
|
||||||
this.branchesProps = {};
|
|
||||||
this.pinnedReposProps = {
|
|
||||||
repos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.allGitHubRepos = [];
|
|
||||||
this.allGitHubReposLastPageInfo = undefined;
|
|
||||||
this.pinnedReposUpdated = false;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
showAuthorizationAcessState: true,
|
|
||||||
isExecuting: false,
|
|
||||||
errorMessage: "",
|
|
||||||
showErrorDetails: false,
|
|
||||||
gitHubReposState: {
|
|
||||||
showAuthorizeAccess: !this.props.explorer.notebookManager?.gitHubOAuthService.isLoggedIn(),
|
|
||||||
authorizeAccessProps: {
|
|
||||||
scope: this.getOAuthScope(),
|
|
||||||
authorizeAccess: (scope): void => this.connectToGitHub(scope),
|
|
||||||
},
|
|
||||||
reposListProps: {
|
|
||||||
branchesProps: this.branchesProps,
|
|
||||||
pinnedReposProps: this.pinnedReposProps,
|
|
||||||
unpinnedReposProps: this.unpinnedReposProps,
|
|
||||||
pinRepo: (item): Promise<void> => this.pinRepo(item),
|
|
||||||
unpinRepo: (item): Promise<void> => this.unpinRepo(item),
|
|
||||||
},
|
|
||||||
addRepoProps: {
|
|
||||||
container: this.props.explorer,
|
|
||||||
getRepo: (owner, repo): Promise<IGitHubRepo> => this.getRepo(owner, repo),
|
|
||||||
pinRepo: (item): Promise<void> => this.pinRepo(item),
|
|
||||||
},
|
|
||||||
resetConnection: (): void => this.setup(true),
|
|
||||||
onOkClick: (): Promise<void> => this.submit(),
|
|
||||||
onCancelClick: (): void => useSidePanel.getState().closeSidePanel(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.gitHubClient = this.props.gitHubClientProp;
|
|
||||||
this.junoClient = this.props.junoClientProp;
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount(): void {
|
|
||||||
this.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
public open(): void {
|
|
||||||
this.resetData();
|
|
||||||
this.setup();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async submit(): Promise<void> {
|
|
||||||
const pinnedReposUpdated = this.pinnedReposUpdated;
|
|
||||||
const reposToPin: IPinnedRepo[] = this.pinnedReposProps.repos.map((repo) => JunoUtils.toPinnedRepo(repo));
|
|
||||||
|
|
||||||
if (pinnedReposUpdated) {
|
|
||||||
try {
|
|
||||||
const response = await this.junoClient.updatePinnedRepos(reposToPin);
|
|
||||||
if (response.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when saving pinned repos`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.explorer.notebookManager?.refreshPinnedRepos();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GitHubReposPane/submit", "Failed to save pinned repos");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useSidePanel.getState().closeSidePanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
public resetData(): void {
|
|
||||||
this.branchesProps = {};
|
|
||||||
|
|
||||||
this.pinnedReposProps.repos = [];
|
|
||||||
this.unpinnedReposProps.repos = [];
|
|
||||||
this.allGitHubRepos = [];
|
|
||||||
this.allGitHubReposLastPageInfo = undefined;
|
|
||||||
|
|
||||||
this.pinnedReposUpdated = false;
|
|
||||||
this.unpinnedReposProps.hasMore = true;
|
|
||||||
this.unpinnedReposProps.isLoading = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getOAuthScope(): string {
|
|
||||||
return (
|
|
||||||
this.props.explorer.notebookManager?.gitHubOAuthService.getTokenObservable()()?.scope ||
|
|
||||||
AuthorizeAccessComponent.Scopes.Public.key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setup(forceShowConnectToGitHub = false): void {
|
|
||||||
forceShowConnectToGitHub || !this.props.explorer.notebookManager?.gitHubOAuthService.isLoggedIn()
|
|
||||||
? this.setupForConnectToGitHub(forceShowConnectToGitHub)
|
|
||||||
: this.setupForManageRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupForConnectToGitHub(forceShowConnectToGitHub: boolean): void {
|
|
||||||
if (forceShowConnectToGitHub) {
|
|
||||||
const newState = { ...this.state.gitHubReposState };
|
|
||||||
newState.showAuthorizeAccess = forceShowConnectToGitHub;
|
|
||||||
this.setState({
|
|
||||||
gitHubReposState: newState,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
isExecuting: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setupForManageRepos(): Promise<void> {
|
|
||||||
this.setState({
|
|
||||||
isExecuting: false,
|
|
||||||
});
|
|
||||||
TelemetryProcessor.trace(Action.NotebooksGitHubManageRepo, ActionModifiers.Mark, {
|
|
||||||
dataExplorerArea: Areas.Notebook,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.refreshManageReposComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateUnpinnedRepos(): RepoListItem[] {
|
|
||||||
const unpinnedGitHubRepos = this.allGitHubRepos.filter(
|
|
||||||
(gitHubRepo) =>
|
|
||||||
this.pinnedReposProps.repos.findIndex(
|
|
||||||
(pinnedRepo) => pinnedRepo.key === GitHubUtils.toRepoFullName(gitHubRepo.owner, gitHubRepo.name),
|
|
||||||
) === -1,
|
|
||||||
);
|
|
||||||
return unpinnedGitHubRepos.map((gitHubRepo) => ({
|
|
||||||
key: GitHubUtils.toRepoFullName(gitHubRepo.owner, gitHubRepo.name),
|
|
||||||
repo: gitHubRepo,
|
|
||||||
branches: [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadMoreBranches(repo: IGitHubRepo): Promise<void> {
|
|
||||||
const branchesProps = this.branchesProps[GitHubUtils.toRepoFullName(repo.owner, repo.name)];
|
|
||||||
branchesProps.hasMore = true;
|
|
||||||
branchesProps.isLoading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.gitHubClient.getBranchesAsync(
|
|
||||||
repo.owner,
|
|
||||||
repo.name,
|
|
||||||
GitHubReposPanel.PageSize,
|
|
||||||
branchesProps.lastPageInfo?.endCursor,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when fetching branches`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
branchesProps.branches = branchesProps.branches.concat(response.data);
|
|
||||||
branchesProps.lastPageInfo = response.pageInfo;
|
|
||||||
branchesProps.defaultBranchName = branchesProps.branches[0].name;
|
|
||||||
const defaultbranchName = branchesProps.branches.find(
|
|
||||||
(branch) =>
|
|
||||||
branch.name === GitHubReposPanel.MasterBranchName || branch.name === GitHubReposPanel.MainBranchName,
|
|
||||||
)?.name;
|
|
||||||
if (defaultbranchName) {
|
|
||||||
branchesProps.defaultBranchName = defaultbranchName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GitHubReposPane/loadMoreBranches", "Failed to fetch branches");
|
|
||||||
}
|
|
||||||
|
|
||||||
branchesProps.isLoading = false;
|
|
||||||
branchesProps.hasMore = branchesProps.lastPageInfo?.hasNextPage;
|
|
||||||
this.setState({
|
|
||||||
gitHubReposState: {
|
|
||||||
...this.state.gitHubReposState,
|
|
||||||
reposListProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps,
|
|
||||||
branchesProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps.branchesProps,
|
|
||||||
[GitHubUtils.toRepoFullName(repo.owner, repo.name)]: branchesProps,
|
|
||||||
},
|
|
||||||
pinnedReposProps: {
|
|
||||||
repos: this.pinnedReposProps.repos,
|
|
||||||
},
|
|
||||||
unpinnedReposProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps.unpinnedReposProps,
|
|
||||||
repos: this.unpinnedReposProps.repos,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadMoreUnpinnedRepos(): Promise<void> {
|
|
||||||
this.unpinnedReposProps.isLoading = true;
|
|
||||||
this.unpinnedReposProps.hasMore = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.gitHubClient.getReposAsync(
|
|
||||||
GitHubReposPanel.PageSize,
|
|
||||||
this.allGitHubReposLastPageInfo?.endCursor,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when fetching unpinned repos`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
this.allGitHubRepos = this.allGitHubRepos.concat(response.data);
|
|
||||||
this.allGitHubReposLastPageInfo = response.pageInfo;
|
|
||||||
this.unpinnedReposProps.repos = this.calculateUnpinnedRepos();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GitHubReposPane/loadMoreUnpinnedRepos", "Failed to fetch unpinned repos");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.unpinnedReposProps.isLoading = false;
|
|
||||||
this.unpinnedReposProps.hasMore = this.allGitHubReposLastPageInfo?.hasNextPage;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
gitHubReposState: {
|
|
||||||
...this.state.gitHubReposState,
|
|
||||||
reposListProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps,
|
|
||||||
unpinnedReposProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps.unpinnedReposProps,
|
|
||||||
isLoading: this.unpinnedReposProps.isLoading,
|
|
||||||
hasMore: this.unpinnedReposProps.hasMore,
|
|
||||||
repos: this.unpinnedReposProps.repos,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getRepo(owner: string, repo: string): Promise<IGitHubRepo> {
|
|
||||||
try {
|
|
||||||
const response = await this.gitHubClient.getRepoAsync(owner, repo);
|
|
||||||
if (response.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when fetching repo`);
|
|
||||||
}
|
|
||||||
this.isAddedRepo = true;
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GitHubReposPane/getRepo", "Failed to fetch repo");
|
|
||||||
return Promise.resolve(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async pinRepo(item: RepoListItem): Promise<void> {
|
|
||||||
this.pinnedReposUpdated = true;
|
|
||||||
const initialReposLength = this.pinnedReposProps.repos.length;
|
|
||||||
|
|
||||||
const existingRepo = this.pinnedReposProps.repos.find((repo) => repo.key === item.key);
|
|
||||||
if (existingRepo) {
|
|
||||||
existingRepo.branches = item.branches;
|
|
||||||
this.setState({
|
|
||||||
gitHubReposState: {
|
|
||||||
...this.state.gitHubReposState,
|
|
||||||
reposListProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps,
|
|
||||||
pinnedReposProps: {
|
|
||||||
repos: this.pinnedReposProps.repos,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.pinnedReposProps.repos = [...this.pinnedReposProps.repos, item];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.unpinnedReposProps.repos = this.calculateUnpinnedRepos();
|
|
||||||
|
|
||||||
if (this.pinnedReposProps.repos.length > initialReposLength) {
|
|
||||||
this.refreshBranchesForPinnedRepos();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async unpinRepo(item: RepoListItem): Promise<void> {
|
|
||||||
this.pinnedReposUpdated = true;
|
|
||||||
this.pinnedReposProps.repos = this.pinnedReposProps.repos.filter((pinnedRepo) => pinnedRepo.key !== item.key);
|
|
||||||
this.unpinnedReposProps.repos = this.calculateUnpinnedRepos();
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
gitHubReposState: {
|
|
||||||
...this.state.gitHubReposState,
|
|
||||||
reposListProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps,
|
|
||||||
pinnedReposProps: {
|
|
||||||
repos: this.pinnedReposProps.repos,
|
|
||||||
},
|
|
||||||
unpinnedReposProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps.unpinnedReposProps,
|
|
||||||
repos: this.unpinnedReposProps.repos,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshManageReposComponent(): Promise<void> {
|
|
||||||
await this.refreshPinnedRepoListItems();
|
|
||||||
this.refreshBranchesForPinnedRepos();
|
|
||||||
this.refreshUnpinnedRepoListItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshPinnedRepoListItems(): Promise<void> {
|
|
||||||
this.pinnedReposProps.repos = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.junoClient.getPinnedRepos(
|
|
||||||
this.props.explorer.notebookManager?.gitHubOAuthService.getTokenObservable()()?.scope,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
|
||||||
throw new Error(`Received HTTP ${response.status} when fetching pinned repos`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
const pinnedRepos = response.data.map(
|
|
||||||
(pinnedRepo) =>
|
|
||||||
({
|
|
||||||
key: GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name),
|
|
||||||
branches: pinnedRepo.branches,
|
|
||||||
repo: JunoUtils.toGitHubRepo(pinnedRepo),
|
|
||||||
}) as RepoListItem,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.pinnedReposProps.repos = pinnedRepos;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GitHubReposPane/refreshPinnedReposListItems", "Failed to fetch pinned repos");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private refreshBranchesForPinnedRepos(): void {
|
|
||||||
this.pinnedReposProps.repos.map((item) => {
|
|
||||||
if (!this.branchesProps[item.key]) {
|
|
||||||
this.branchesProps[item.key] = {
|
|
||||||
branches: [],
|
|
||||||
lastPageInfo: undefined,
|
|
||||||
hasMore: true,
|
|
||||||
isLoading: true,
|
|
||||||
defaultBranchName: undefined,
|
|
||||||
loadMore: (): Promise<void> => this.loadMoreBranches(item.repo),
|
|
||||||
};
|
|
||||||
this.loadMoreBranches(item.repo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
gitHubReposState: {
|
|
||||||
...this.state.gitHubReposState,
|
|
||||||
reposListProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps,
|
|
||||||
branchesProps: {
|
|
||||||
...this.branchesProps,
|
|
||||||
},
|
|
||||||
pinnedReposProps: {
|
|
||||||
repos: this.pinnedReposProps.repos,
|
|
||||||
},
|
|
||||||
unpinnedReposProps: {
|
|
||||||
...this.state.gitHubReposState.reposListProps.unpinnedReposProps,
|
|
||||||
repos: this.unpinnedReposProps.repos,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.isAddedRepo = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshUnpinnedRepoListItems(): Promise<void> {
|
|
||||||
this.allGitHubRepos = [];
|
|
||||||
this.allGitHubReposLastPageInfo = undefined;
|
|
||||||
this.unpinnedReposProps.repos = [];
|
|
||||||
|
|
||||||
this.loadMoreUnpinnedRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
private connectToGitHub(scope: string): void {
|
|
||||||
this.setState({
|
|
||||||
isExecuting: true,
|
|
||||||
});
|
|
||||||
TelemetryProcessor.trace(Action.NotebooksGitHubAuthorize, ActionModifiers.Mark, {
|
|
||||||
dataExplorerArea: Areas.Notebook,
|
|
||||||
scopesSelected: scope,
|
|
||||||
});
|
|
||||||
this.props.explorer.notebookManager?.gitHubOAuthService.startOAuth(scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<form className="panelFormWrapper">
|
|
||||||
{this.state.errorMessage && (
|
|
||||||
<PanelInfoErrorComponent
|
|
||||||
message={this.state.errorMessage}
|
|
||||||
messageType="error"
|
|
||||||
showErrorDetails={this.state.showErrorDetails}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="panelMainContent" style={ContentMainStyle}>
|
|
||||||
<GitHubReposComponent {...this.state.gitHubReposState} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.state.isExecuting && <PanelLoadingScreen />}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`GitHub Repos Panel should render Default properly 1`] = `
|
|
||||||
<form
|
|
||||||
className="panelFormWrapper"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="panelMainContent"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"display": "flex",
|
|
||||||
"flexDirection": "column",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<GitHubReposComponent
|
|
||||||
addRepoProps={
|
|
||||||
{
|
|
||||||
"container": Explorer {
|
|
||||||
"_isInitializingNotebooks": false,
|
|
||||||
"databasesRefreshed": Promise {},
|
|
||||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
|
||||||
"isTabsContentExpanded": [Function],
|
|
||||||
"onRefreshDatabasesKeyPress": [Function],
|
|
||||||
"onRefreshResourcesClick": [Function],
|
|
||||||
"phoenixClient": PhoenixClient {
|
|
||||||
"armResourceId": undefined,
|
|
||||||
"retryOptions": {
|
|
||||||
"maxTimeout": 5000,
|
|
||||||
"minTimeout": 5000,
|
|
||||||
"retries": 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"provideFeedbackEmail": [Function],
|
|
||||||
"queriesClient": QueriesClient {
|
|
||||||
"container": [Circular],
|
|
||||||
},
|
|
||||||
"refreshNotebookList": [Function],
|
|
||||||
"resourceTree": ResourceTreeAdapter {
|
|
||||||
"container": [Circular],
|
|
||||||
"parameters": [Function],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"getRepo": [Function],
|
|
||||||
"pinRepo": [Function],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
authorizeAccessProps={
|
|
||||||
{
|
|
||||||
"authorizeAccess": [Function],
|
|
||||||
"scope": "public_repo",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onCancelClick={[Function]}
|
|
||||||
onOkClick={[Function]}
|
|
||||||
reposListProps={
|
|
||||||
{
|
|
||||||
"branchesProps": {},
|
|
||||||
"pinRepo": [Function],
|
|
||||||
"pinnedReposProps": {
|
|
||||||
"repos": [],
|
|
||||||
},
|
|
||||||
"unpinRepo": [Function],
|
|
||||||
"unpinnedReposProps": {
|
|
||||||
"hasMore": true,
|
|
||||||
"isLoading": true,
|
|
||||||
"loadMore": [Function],
|
|
||||||
"repos": [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resetConnection={[Function]}
|
|
||||||
showAuthorizeAccess={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
@@ -24,7 +24,6 @@ import Explorer from "../Explorer";
|
|||||||
import { useNotebook } from "../Notebook/useNotebook";
|
import { useNotebook } from "../Notebook/useNotebook";
|
||||||
|
|
||||||
export const MyNotebooksTitle = "My Notebooks";
|
export const MyNotebooksTitle = "My Notebooks";
|
||||||
export const GitHubReposTitle = "GitHub repos";
|
|
||||||
|
|
||||||
interface ResourceTreeProps {
|
interface ResourceTreeProps {
|
||||||
explorer: Explorer;
|
explorer: Explorer;
|
||||||
|
|||||||
@@ -16,12 +16,10 @@ import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
|
|||||||
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
|
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
|
||||||
import * as DataModels from "../../Contracts/DataModels";
|
import * as DataModels from "../../Contracts/DataModels";
|
||||||
import * as ViewModels from "../../Contracts/ViewModels";
|
import * as ViewModels from "../../Contracts/ViewModels";
|
||||||
import { IPinnedRepo } from "../../Juno/JunoClient";
|
|
||||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||||
import { userContext } from "../../UserContext";
|
import { userContext } from "../../UserContext";
|
||||||
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
|
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
|
||||||
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
|
||||||
import { useTabs } from "../../hooks/useTabs";
|
import { useTabs } from "../../hooks/useTabs";
|
||||||
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
|
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
|
||||||
import { useDialog } from "../Controls/Dialog";
|
import { useDialog } from "../Controls/Dialog";
|
||||||
@@ -40,7 +38,6 @@ import UserDefinedFunction from "./UserDefinedFunction";
|
|||||||
|
|
||||||
export class ResourceTreeAdapter implements ReactAdapter {
|
export class ResourceTreeAdapter implements ReactAdapter {
|
||||||
public static readonly MyNotebooksTitle = "My Notebooks";
|
public static readonly MyNotebooksTitle = "My Notebooks";
|
||||||
public static readonly GitHubReposTitle = "GitHub repos";
|
|
||||||
|
|
||||||
private static readonly DataTitle = "DATA";
|
private static readonly DataTitle = "DATA";
|
||||||
private static readonly NotebooksTitle = "NOTEBOOKS";
|
private static readonly NotebooksTitle = "NOTEBOOKS";
|
||||||
@@ -49,7 +46,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
|
|||||||
public parameters: ko.Observable<number>;
|
public parameters: ko.Observable<number>;
|
||||||
|
|
||||||
public myNotebooksContentRoot: NotebookContentItem;
|
public myNotebooksContentRoot: NotebookContentItem;
|
||||||
public gitHubNotebooksContentRoot: NotebookContentItem;
|
|
||||||
|
|
||||||
public constructor(private container: Explorer) {
|
public constructor(private container: Explorer) {
|
||||||
this.parameters = ko.observable(Date.now());
|
this.parameters = ko.observable(Date.now());
|
||||||
@@ -106,42 +102,9 @@ export class ResourceTreeAdapter implements ReactAdapter {
|
|||||||
type: NotebookContentItemType.Directory,
|
type: NotebookContentItemType.Directory,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.gitHubNotebooksContentRoot = {
|
|
||||||
name: ResourceTreeAdapter.GitHubReposTitle,
|
|
||||||
path: ResourceTreeAdapter.PseudoDirPath,
|
|
||||||
type: NotebookContentItemType.Directory,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Promise.all(refreshTasks);
|
return Promise.all(refreshTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
public initializeGitHubRepos(pinnedRepos: IPinnedRepo[]): void {
|
|
||||||
if (this.gitHubNotebooksContentRoot) {
|
|
||||||
this.gitHubNotebooksContentRoot.children = [];
|
|
||||||
pinnedRepos?.forEach((pinnedRepo) => {
|
|
||||||
const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name);
|
|
||||||
const repoTreeItem: NotebookContentItem = {
|
|
||||||
name: repoFullName,
|
|
||||||
path: ResourceTreeAdapter.PseudoDirPath,
|
|
||||||
type: NotebookContentItemType.Directory,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
pinnedRepo.branches.forEach((branch) => {
|
|
||||||
repoTreeItem.children.push({
|
|
||||||
name: branch.name,
|
|
||||||
path: GitHubUtils.toContentUri(pinnedRepo.owner, pinnedRepo.name, branch.name, ""),
|
|
||||||
type: NotebookContentItemType.Directory,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.gitHubNotebooksContentRoot.children.push(repoTreeItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.triggerRender();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildDataTree(): LegacyTreeNode {
|
private buildDataTree(): LegacyTreeNode {
|
||||||
const databaseTreeNodes: LegacyTreeNode[] = useDatabases
|
const databaseTreeNodes: LegacyTreeNode[] = useDatabases
|
||||||
.getState()
|
.getState()
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
import { Octokit } from "@octokit/rest";
|
|
||||||
import { HttpStatusCodes } from "../Common/Constants";
|
|
||||||
import * as Logger from "../Common/Logger";
|
|
||||||
import * as UrlUtility from "../Common/UrlUtility";
|
|
||||||
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
|
|
||||||
import { getErrorMessage } from "../Common/ErrorHandlingUtils";
|
|
||||||
|
|
||||||
export interface IGitHubPageInfo {
|
|
||||||
endCursor: string;
|
|
||||||
hasNextPage: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IGitHubResponse<T> {
|
|
||||||
status: number;
|
|
||||||
data: T;
|
|
||||||
pageInfo?: IGitHubPageInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IGitHubRepo {
|
|
||||||
name: string;
|
|
||||||
owner: string;
|
|
||||||
private: boolean;
|
|
||||||
children?: IGitHubFile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IGitHubFile {
|
|
||||||
type: "blob" | "tree";
|
|
||||||
size?: number;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
content?: string;
|
|
||||||
sha?: string;
|
|
||||||
children?: IGitHubFile[];
|
|
||||||
repo: IGitHubRepo;
|
|
||||||
branch: IGitHubBranch;
|
|
||||||
commit: IGitHubCommit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IGitHubCommit {
|
|
||||||
sha: string;
|
|
||||||
message: string;
|
|
||||||
commitDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IGitHubBranch {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// graphql schema
|
|
||||||
interface Collection<T> {
|
|
||||||
pageInfo?: PageInfo;
|
|
||||||
nodes: T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Repository {
|
|
||||||
isPrivate: boolean;
|
|
||||||
name: string;
|
|
||||||
owner: {
|
|
||||||
login: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Ref {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface History {
|
|
||||||
history: Collection<Commit>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Commit {
|
|
||||||
committer: {
|
|
||||||
date: string;
|
|
||||||
};
|
|
||||||
message: string;
|
|
||||||
oid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Tree extends Blob {
|
|
||||||
entries: TreeEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeEntry {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
object: Blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Blob {
|
|
||||||
byteSize?: number;
|
|
||||||
oid?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageInfo {
|
|
||||||
endCursor: string;
|
|
||||||
hasNextPage: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// graphql queries and types
|
|
||||||
const repositoryQuery = `query($owner: String!, $repo: String!) {
|
|
||||||
repository(owner: $owner, name: $repo) {
|
|
||||||
owner {
|
|
||||||
login
|
|
||||||
}
|
|
||||||
name
|
|
||||||
isPrivate
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
type RepositoryQueryParams = {
|
|
||||||
owner: string;
|
|
||||||
repo: string;
|
|
||||||
};
|
|
||||||
type RepositoryQueryResponse = {
|
|
||||||
repository: Repository;
|
|
||||||
};
|
|
||||||
|
|
||||||
const repositoriesQuery = `query($pageSize: Int!, $endCursor: String) {
|
|
||||||
viewer {
|
|
||||||
repositories(first: $pageSize, after: $endCursor) {
|
|
||||||
pageInfo {
|
|
||||||
endCursor,
|
|
||||||
hasNextPage
|
|
||||||
}
|
|
||||||
nodes {
|
|
||||||
owner {
|
|
||||||
login
|
|
||||||
}
|
|
||||||
name
|
|
||||||
isPrivate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
type RepositoriesQueryParams = {
|
|
||||||
pageSize: number;
|
|
||||||
endCursor?: string;
|
|
||||||
};
|
|
||||||
type RepositoriesQueryResponse = {
|
|
||||||
viewer: {
|
|
||||||
repositories: Collection<Repository>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const branchesQuery = `query($owner: String!, $repo: String!, $refPrefix: String!, $pageSize: Int!, $endCursor: String) {
|
|
||||||
repository(owner: $owner, name: $repo) {
|
|
||||||
refs(refPrefix: $refPrefix, first: $pageSize, after: $endCursor) {
|
|
||||||
pageInfo {
|
|
||||||
endCursor,
|
|
||||||
hasNextPage
|
|
||||||
}
|
|
||||||
nodes {
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
type BranchesQueryParams = {
|
|
||||||
owner: string;
|
|
||||||
repo: string;
|
|
||||||
refPrefix: string;
|
|
||||||
pageSize: number;
|
|
||||||
endCursor?: string;
|
|
||||||
};
|
|
||||||
type BranchesQueryResponse = {
|
|
||||||
repository: {
|
|
||||||
refs: Collection<Ref>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const contentsQuery = `query($owner: String!, $repo: String!, $ref: String!, $path: String, $objectExpression: String!) {
|
|
||||||
repository(owner: $owner, name: $repo) {
|
|
||||||
owner {
|
|
||||||
login
|
|
||||||
}
|
|
||||||
name
|
|
||||||
isPrivate
|
|
||||||
ref(qualifiedName: $ref) {
|
|
||||||
name
|
|
||||||
target {
|
|
||||||
... on Commit {
|
|
||||||
history(first: 1, path: $path) {
|
|
||||||
nodes {
|
|
||||||
oid
|
|
||||||
message
|
|
||||||
committer {
|
|
||||||
date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
object(expression: $objectExpression) {
|
|
||||||
... on Blob {
|
|
||||||
oid
|
|
||||||
byteSize
|
|
||||||
}
|
|
||||||
... on Tree {
|
|
||||||
entries {
|
|
||||||
name
|
|
||||||
type
|
|
||||||
object {
|
|
||||||
... on Blob {
|
|
||||||
oid
|
|
||||||
byteSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
type ContentsQueryParams = {
|
|
||||||
owner: string;
|
|
||||||
repo: string;
|
|
||||||
ref: string;
|
|
||||||
path?: string;
|
|
||||||
objectExpression: string;
|
|
||||||
};
|
|
||||||
type ContentsQueryResponse = {
|
|
||||||
repository: Repository & { ref: Ref & { target: History } } & { object: Tree };
|
|
||||||
};
|
|
||||||
|
|
||||||
export class GitHubClient {
|
|
||||||
private static readonly SelfErrorCode = 599;
|
|
||||||
private ocktokit: Octokit;
|
|
||||||
|
|
||||||
constructor(private errorCallback: (error: any) => void) {
|
|
||||||
this.initOctokit();
|
|
||||||
}
|
|
||||||
|
|
||||||
public setToken(token: string): void {
|
|
||||||
this.initOctokit(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getRepoAsync(owner: string, repo: string): Promise<IGitHubResponse<IGitHubRepo>> {
|
|
||||||
try {
|
|
||||||
const response = (await this.ocktokit.graphql(repositoryQuery, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
} as RepositoryQueryParams)) as RepositoryQueryResponse;
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
data: GitHubClient.toGitHubRepo(response.repository),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubClient.Octokit", "GitHubClient.getRepoAsync failed");
|
|
||||||
return {
|
|
||||||
status: GitHubClient.SelfErrorCode,
|
|
||||||
data: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getReposAsync(pageSize: number, endCursor?: string): Promise<IGitHubResponse<IGitHubRepo[]>> {
|
|
||||||
try {
|
|
||||||
const response = (await this.ocktokit.graphql(repositoriesQuery, {
|
|
||||||
pageSize,
|
|
||||||
endCursor,
|
|
||||||
} as RepositoriesQueryParams)) as RepositoriesQueryResponse;
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
data: response.viewer.repositories.nodes.map((repo) => GitHubClient.toGitHubRepo(repo)),
|
|
||||||
pageInfo: GitHubClient.toGitHubPageInfo(response.viewer.repositories.pageInfo),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubClient.Octokit", "GitHubClient.getRepoAsync failed");
|
|
||||||
return {
|
|
||||||
status: GitHubClient.SelfErrorCode,
|
|
||||||
data: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getBranchesAsync(
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
pageSize: number,
|
|
||||||
endCursor?: string,
|
|
||||||
): Promise<IGitHubResponse<IGitHubBranch[]>> {
|
|
||||||
try {
|
|
||||||
const response = (await this.ocktokit.graphql(branchesQuery, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
refPrefix: "refs/heads/",
|
|
||||||
pageSize,
|
|
||||||
endCursor,
|
|
||||||
} as BranchesQueryParams)) as BranchesQueryResponse;
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
data: response.repository.refs.nodes.map((ref) => GitHubClient.toGitHubBranch(ref)),
|
|
||||||
pageInfo: GitHubClient.toGitHubPageInfo(response.repository.refs.pageInfo),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubClient.Octokit", "GitHubClient.getBranchesAsync failed");
|
|
||||||
return {
|
|
||||||
status: GitHubClient.SelfErrorCode,
|
|
||||||
data: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getContentsAsync(
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
branch: string,
|
|
||||||
path?: string,
|
|
||||||
): Promise<IGitHubResponse<IGitHubFile | IGitHubFile[]>> {
|
|
||||||
try {
|
|
||||||
const response = (await this.ocktokit.graphql(contentsQuery, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
ref: `refs/heads/${branch}`,
|
|
||||||
path: path || undefined,
|
|
||||||
objectExpression: `refs/heads/${branch}:${path || ""}`,
|
|
||||||
} as ContentsQueryParams)) as ContentsQueryResponse;
|
|
||||||
|
|
||||||
if (!response.repository.object) {
|
|
||||||
return {
|
|
||||||
status: HttpStatusCodes.NotFound,
|
|
||||||
data: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: IGitHubFile | IGitHubFile[];
|
|
||||||
const entries = response.repository.object.entries;
|
|
||||||
const gitHubRepo = GitHubClient.toGitHubRepo(response.repository);
|
|
||||||
const gitHubBranch = GitHubClient.toGitHubBranch(response.repository.ref);
|
|
||||||
const gitHubCommit = GitHubClient.toGitHubCommit(response.repository.ref.target.history.nodes[0]);
|
|
||||||
|
|
||||||
if (Array.isArray(entries)) {
|
|
||||||
data = entries.map((entry) =>
|
|
||||||
GitHubClient.toGitHubFile(
|
|
||||||
entry,
|
|
||||||
(path && UrlUtility.createUri(path, entry.name)) || entry.name,
|
|
||||||
gitHubRepo,
|
|
||||||
gitHubBranch,
|
|
||||||
gitHubCommit,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
data = GitHubClient.toGitHubFile(
|
|
||||||
{
|
|
||||||
name: NotebookUtil.getName(path),
|
|
||||||
type: "blob",
|
|
||||||
object: response.repository.object,
|
|
||||||
},
|
|
||||||
path,
|
|
||||||
gitHubRepo,
|
|
||||||
gitHubBranch,
|
|
||||||
gitHubCommit,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubClient.Octokit", "GitHubClient.getContentsAsync failed");
|
|
||||||
return {
|
|
||||||
status: GitHubClient.SelfErrorCode,
|
|
||||||
data: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createOrUpdateFileAsync(
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
branch: string,
|
|
||||||
path: string,
|
|
||||||
message: string,
|
|
||||||
content: string,
|
|
||||||
sha?: string,
|
|
||||||
): Promise<IGitHubResponse<IGitHubCommit>> {
|
|
||||||
const response = await this.ocktokit.repos.createOrUpdateFile({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
branch,
|
|
||||||
path,
|
|
||||||
message,
|
|
||||||
content,
|
|
||||||
sha,
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: IGitHubCommit;
|
|
||||||
if (response.data) {
|
|
||||||
data = GitHubClient.toGitHubCommit(response.data.commit);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: response.status, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async renameFileAsync(
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
branch: string,
|
|
||||||
message: string,
|
|
||||||
oldPath: string,
|
|
||||||
newPath: string,
|
|
||||||
): Promise<IGitHubResponse<IGitHubCommit>> {
|
|
||||||
const ref = `heads/${branch}`;
|
|
||||||
const currentRef = await this.ocktokit.git.getRef({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
ref,
|
|
||||||
headers: {
|
|
||||||
"If-None-Match": "", // disable 60s cache
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentTree = await this.ocktokit.git.getTree({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
tree_sha: currentRef.data.object.sha,
|
|
||||||
recursive: "1",
|
|
||||||
headers: {
|
|
||||||
"If-None-Match": "", // disable 60s cache
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// API infers tree from paths so we need to filter them out
|
|
||||||
const currentTreeItems = currentTree.data.tree.filter((item) => item.type !== "tree");
|
|
||||||
currentTreeItems.forEach((item) => {
|
|
||||||
if (item.path === newPath) {
|
|
||||||
throw new Error("File with the path already exists");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedTree = await this.ocktokit.git.createTree({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
tree: currentTreeItems.map((item) => ({
|
|
||||||
path: item.path === oldPath ? newPath : item.path,
|
|
||||||
mode: item.mode as "100644" | "100755" | "040000" | "160000" | "120000",
|
|
||||||
type: item.type as "blob" | "tree" | "commit",
|
|
||||||
sha: item.sha,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
const newCommit = await this.ocktokit.git.createCommit({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
message,
|
|
||||||
parents: [currentRef.data.object.sha],
|
|
||||||
tree: updatedTree.data.sha,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedRef = await this.ocktokit.git.updateRef({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
ref,
|
|
||||||
sha: newCommit.data.sha,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: updatedRef.status,
|
|
||||||
data: GitHubClient.toGitHubCommit(newCommit.data),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async deleteFileAsync(file: IGitHubFile, message: string): Promise<IGitHubResponse<IGitHubCommit>> {
|
|
||||||
const response = await this.ocktokit.repos.deleteFile({
|
|
||||||
owner: file.repo.owner,
|
|
||||||
repo: file.repo.name,
|
|
||||||
path: file.path,
|
|
||||||
message,
|
|
||||||
sha: file.sha,
|
|
||||||
branch: file.branch.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: IGitHubCommit;
|
|
||||||
if (response.data) {
|
|
||||||
data = GitHubClient.toGitHubCommit(response.data.commit);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: response.status, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getBlobAsync(owner: string, repo: string, sha: string): Promise<IGitHubResponse<string>> {
|
|
||||||
const response = await this.ocktokit.git.getBlob({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
file_sha: sha,
|
|
||||||
mediaType: {
|
|
||||||
format: "raw",
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
"If-None-Match": "", // disable 60s cache
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { status: response.status, data: <string>(<unknown>response.data) };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initOctokit(token?: string) {
|
|
||||||
this.ocktokit = new Octokit({
|
|
||||||
auth: token,
|
|
||||||
log: {
|
|
||||||
debug: () => {},
|
|
||||||
info: (message?: any) => GitHubClient.log(Logger.logInfo, message),
|
|
||||||
warn: (message?: any) => GitHubClient.log(Logger.logWarning, message),
|
|
||||||
error: (error?: any) => Logger.logError(getErrorMessage(error), "GitHubClient.Octokit"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ocktokit.hook.error("request", (error) => {
|
|
||||||
this.errorCallback(error);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static log(logger: (message: string, area: string) => void, message?: any) {
|
|
||||||
if (message) {
|
|
||||||
message = typeof message === "string" ? message : JSON.stringify(message);
|
|
||||||
logger(message, "GitHubClient.Octokit");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toGitHubRepo(object: Repository): IGitHubRepo {
|
|
||||||
return {
|
|
||||||
owner: object.owner.login,
|
|
||||||
name: object.name,
|
|
||||||
private: object.isPrivate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toGitHubBranch(object: Ref): IGitHubBranch {
|
|
||||||
return {
|
|
||||||
name: object.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toGitHubCommit(object: {
|
|
||||||
message: string;
|
|
||||||
committer: {
|
|
||||||
date: string;
|
|
||||||
};
|
|
||||||
sha?: string;
|
|
||||||
oid?: string;
|
|
||||||
}): IGitHubCommit {
|
|
||||||
return {
|
|
||||||
sha: object.sha || object.oid,
|
|
||||||
message: object.message,
|
|
||||||
commitDate: object.committer.date,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toGitHubPageInfo(object: PageInfo): IGitHubPageInfo {
|
|
||||||
return {
|
|
||||||
endCursor: object.endCursor,
|
|
||||||
hasNextPage: object.hasNextPage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toGitHubFile(
|
|
||||||
entry: TreeEntry,
|
|
||||||
path: string,
|
|
||||||
repo: IGitHubRepo,
|
|
||||||
branch: IGitHubBranch,
|
|
||||||
commit: IGitHubCommit,
|
|
||||||
): IGitHubFile {
|
|
||||||
if (entry.type !== "blob" && entry.type !== "tree") {
|
|
||||||
throw new Error(`Unsupported file type: ${entry.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: entry.type,
|
|
||||||
name: entry.name,
|
|
||||||
path,
|
|
||||||
repo,
|
|
||||||
branch,
|
|
||||||
commit,
|
|
||||||
size: entry.object?.byteSize,
|
|
||||||
sha: entry.object?.oid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import postRobot from "post-robot";
|
|
||||||
|
|
||||||
export interface IGitHubConnectorParams {
|
|
||||||
state: string;
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GitHubConnectorMsgType = "GitHubConnectorMsgType";
|
|
||||||
|
|
||||||
window.addEventListener("load", async () => {
|
|
||||||
const openerWindow = window.opener;
|
|
||||||
if (openerWindow) {
|
|
||||||
const params = new URLSearchParams(document.location.search);
|
|
||||||
await postRobot.send(
|
|
||||||
openerWindow,
|
|
||||||
GitHubConnectorMsgType,
|
|
||||||
{
|
|
||||||
state: params.get("state"),
|
|
||||||
code: params.get("code"),
|
|
||||||
} as IGitHubConnectorParams,
|
|
||||||
{
|
|
||||||
domain: window.location.origin,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import { IContent } from "@nteract/core";
|
|
||||||
import { fixture } from "@nteract/fixtures";
|
|
||||||
import { HttpStatusCodes } from "../Common/Constants";
|
|
||||||
import * as GitHubUtils from "../Utils/GitHubUtils";
|
|
||||||
import { GitHubClient, IGitHubCommit, IGitHubFile } from "./GitHubClient";
|
|
||||||
import { GitHubContentProvider } from "./GitHubContentProvider";
|
|
||||||
|
|
||||||
const gitHubClient = new GitHubClient(() => {
|
|
||||||
/**/
|
|
||||||
});
|
|
||||||
const gitHubContentProvider = new GitHubContentProvider({
|
|
||||||
gitHubClient,
|
|
||||||
promptForCommitMsg: () => Promise.resolve("commit msg"),
|
|
||||||
});
|
|
||||||
const gitHubCommit: IGitHubCommit = {
|
|
||||||
sha: "sha",
|
|
||||||
message: "message",
|
|
||||||
commitDate: "date",
|
|
||||||
};
|
|
||||||
const sampleFile: IGitHubFile = {
|
|
||||||
type: "blob",
|
|
||||||
size: 0,
|
|
||||||
name: "name.ipynb",
|
|
||||||
path: "dir/name.ipynb",
|
|
||||||
content: fixture,
|
|
||||||
sha: "sha",
|
|
||||||
repo: {
|
|
||||||
owner: "owner",
|
|
||||||
name: "repo",
|
|
||||||
private: false,
|
|
||||||
},
|
|
||||||
branch: {
|
|
||||||
name: "branch",
|
|
||||||
},
|
|
||||||
commit: gitHubCommit,
|
|
||||||
};
|
|
||||||
const sampleGitHubUri = GitHubUtils.toContentUri(
|
|
||||||
sampleFile.repo.owner,
|
|
||||||
sampleFile.repo.name,
|
|
||||||
sampleFile.branch.name,
|
|
||||||
sampleFile.path,
|
|
||||||
);
|
|
||||||
const sampleNotebookModel: IContent<"notebook"> = {
|
|
||||||
name: sampleFile.name,
|
|
||||||
path: sampleGitHubUri,
|
|
||||||
type: "notebook",
|
|
||||||
writable: true,
|
|
||||||
created: "",
|
|
||||||
last_modified: "date",
|
|
||||||
mimetype: "application/x-ipynb+json",
|
|
||||||
content: sampleFile.content ? JSON.parse(sampleFile.content) : undefined,
|
|
||||||
format: "json",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("GitHubContentProvider remove", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on invalid path", async () => {
|
|
||||||
jest.spyOn(GitHubClient.prototype, "getContentsAsync");
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.remove(undefined, "invalid path").toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
expect(gitHubClient.getContentsAsync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on failed read", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: 888, data: undefined }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.remove(undefined, sampleGitHubUri).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(888);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on failed delete", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile }));
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "deleteFileAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: 888, data: undefined }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.remove(undefined, sampleGitHubUri).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(888);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
expect(gitHubClient.deleteFileAsync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes notebook", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile }));
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "deleteFileAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: gitHubCommit }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.remove(undefined, sampleGitHubUri).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.NoContent);
|
|
||||||
expect(gitHubClient.deleteFileAsync).toHaveBeenCalled();
|
|
||||||
expect(response.response).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHubContentProvider get", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on invalid path", async () => {
|
|
||||||
jest.spyOn(GitHubClient.prototype, "getContentsAsync");
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.get(undefined, "invalid path", undefined).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
expect(gitHubClient.getContentsAsync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on failed read", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: 888, data: undefined }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.get(undefined, sampleGitHubUri, undefined).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(888);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads notebook", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.get(undefined, sampleGitHubUri, {}).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
expect(response.response).toEqual(sampleNotebookModel);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHubContentProvider update", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on invalid path", async () => {
|
|
||||||
jest.spyOn(GitHubClient.prototype, "getContentsAsync");
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.update(undefined, "invalid path", undefined).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
expect(gitHubClient.getContentsAsync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on failed read", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: 888, data: undefined }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.update(undefined, sampleGitHubUri, undefined).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(888);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on failed rename", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile }));
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "renameFileAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: 888, data: undefined }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.update(undefined, sampleGitHubUri, sampleNotebookModel).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(888);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
expect(gitHubClient.renameFileAsync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates notebook", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile }));
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "renameFileAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: gitHubCommit }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.update(undefined, sampleGitHubUri, sampleNotebookModel).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
expect(gitHubClient.renameFileAsync).toHaveBeenCalled();
|
|
||||||
expect(response.response.type).toEqual(sampleNotebookModel.type);
|
|
||||||
expect(response.response.name).toEqual(sampleNotebookModel.name);
|
|
||||||
expect(response.response.path).toEqual(sampleNotebookModel.path);
|
|
||||||
expect(response.response.content).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHubContentProvider create", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on invalid path", async () => {
|
|
||||||
jest.spyOn(GitHubClient.prototype, "createOrUpdateFileAsync");
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.create(undefined, "invalid path", sampleNotebookModel).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
expect(gitHubClient.createOrUpdateFileAsync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on failed create", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "createOrUpdateFileAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: 888, data: undefined }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.create(undefined, sampleGitHubUri, sampleNotebookModel).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(888);
|
|
||||||
expect(gitHubClient.createOrUpdateFileAsync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates notebook", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "createOrUpdateFileAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.Created, data: gitHubCommit }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.create(undefined, sampleGitHubUri, sampleNotebookModel).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.Created);
|
|
||||||
expect(gitHubClient.createOrUpdateFileAsync).toHaveBeenCalled();
|
|
||||||
expect(response.response.type).toEqual(sampleNotebookModel.type);
|
|
||||||
expect(response.response.name).toBeDefined();
|
|
||||||
expect(response.response.path).toBeDefined();
|
|
||||||
expect(response.response.content).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHubContentProvider save", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on invalid path", async () => {
|
|
||||||
jest.spyOn(GitHubClient.prototype, "getContentsAsync");
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.save(undefined, "invalid path", undefined).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
expect(gitHubClient.getContentsAsync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on failed read", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: 888, data: undefined }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.save(undefined, sampleGitHubUri, undefined).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(888);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors on failed update", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile }));
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "createOrUpdateFileAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: 888, data: undefined }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.save(undefined, sampleGitHubUri, sampleNotebookModel).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(888);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
expect(gitHubClient.createOrUpdateFileAsync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves notebook", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "getContentsAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile }));
|
|
||||||
jest
|
|
||||||
.spyOn(GitHubClient.prototype, "createOrUpdateFileAsync")
|
|
||||||
.mockReturnValue(Promise.resolve({ status: HttpStatusCodes.OK, data: gitHubCommit }));
|
|
||||||
|
|
||||||
const response = await gitHubContentProvider.save(undefined, sampleGitHubUri, sampleNotebookModel).toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(gitHubClient.getContentsAsync).toHaveBeenCalled();
|
|
||||||
expect(gitHubClient.createOrUpdateFileAsync).toHaveBeenCalled();
|
|
||||||
expect(response.response.type).toEqual(sampleNotebookModel.type);
|
|
||||||
expect(response.response.name).toEqual(sampleNotebookModel.name);
|
|
||||||
expect(response.response.path).toEqual(sampleNotebookModel.path);
|
|
||||||
expect(response.response.content).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHubContentProvider listCheckpoints", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors for everything", async () => {
|
|
||||||
const response = await gitHubContentProvider.listCheckpoints().toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHubContentProvider createCheckpoint", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors for everything", async () => {
|
|
||||||
const response = await gitHubContentProvider.createCheckpoint().toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHubContentProvider deleteCheckpoint", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors for everything", async () => {
|
|
||||||
const response = await gitHubContentProvider.deleteCheckpoint().toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHubContentProvider restoreFromCheckpoint", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors for everything", async () => {
|
|
||||||
const response = await gitHubContentProvider.restoreFromCheckpoint().toPromise();
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
import { makeNotebookRecord, Notebook, stringifyNotebook, toJS } from "@nteract/commutable";
|
|
||||||
import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, ServerConfig } from "@nteract/core";
|
|
||||||
import { from, Observable, of } from "rxjs";
|
|
||||||
import { AjaxResponse } from "rxjs/ajax";
|
|
||||||
import { HttpStatusCodes } from "../Common/Constants";
|
|
||||||
import { getErrorMessage } from "../Common/ErrorHandlingUtils";
|
|
||||||
import * as Logger from "../Common/Logger";
|
|
||||||
import * as UrlUtility from "../Common/UrlUtility";
|
|
||||||
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
|
|
||||||
import * as Base64Utils from "../Utils/Base64Utils";
|
|
||||||
import * as GitHubUtils from "../Utils/GitHubUtils";
|
|
||||||
import { GitHubClient, IGitHubFile, IGitHubResponse } from "./GitHubClient";
|
|
||||||
|
|
||||||
export interface GitHubContentProviderParams {
|
|
||||||
gitHubClient: GitHubClient;
|
|
||||||
promptForCommitMsg: (title: string, primaryButtonLabel: string) => Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitHubContentProviderError extends Error {
|
|
||||||
constructor(
|
|
||||||
error: string,
|
|
||||||
public errno: number = GitHubContentProvider.SelfErrorCode,
|
|
||||||
) {
|
|
||||||
super(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provides 'contents' API for GitHub
|
|
||||||
// http://jupyter-api.surge.sh/#!/contents
|
|
||||||
export class GitHubContentProvider implements IContentProvider {
|
|
||||||
public static readonly SelfErrorCode = 555;
|
|
||||||
|
|
||||||
constructor(private params: GitHubContentProviderParams) {}
|
|
||||||
|
|
||||||
public remove(_: ServerConfig, uri: string): Observable<AjaxResponse> {
|
|
||||||
return from(
|
|
||||||
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
|
|
||||||
try {
|
|
||||||
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Delete", "Delete");
|
|
||||||
const response = await this.params.gitHubClient.deleteFileAsync(content.data as IGitHubFile, commitMsg);
|
|
||||||
if (response.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new GitHubContentProviderError("Failed to delete", response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.createSuccessAjaxResponse(HttpStatusCodes.NoContent, undefined);
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubContentProvider/remove", error.errno);
|
|
||||||
return this.createErrorAjaxResponse(error);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get(_: ServerConfig, uri: string, params: Partial<IGetParams>): Observable<AjaxResponse> {
|
|
||||||
return from(
|
|
||||||
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
|
|
||||||
try {
|
|
||||||
if (content.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new GitHubContentProviderError("Failed to get content", content.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(content.data) && !content.data.content && params.content !== 0) {
|
|
||||||
const file = content.data;
|
|
||||||
file.content = (
|
|
||||||
await this.params.gitHubClient.getBlobAsync(file.repo.owner, file.repo.name, file.sha)
|
|
||||||
).data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.createSuccessAjaxResponse(HttpStatusCodes.OK, this.createContentModel(uri, content.data, params));
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubContentProvider/get", error.errno);
|
|
||||||
return this.createErrorAjaxResponse(error);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public update<FT extends FileType>(
|
|
||||||
_: ServerConfig,
|
|
||||||
uri: string,
|
|
||||||
model: Partial<IContent<FT>>,
|
|
||||||
): Observable<AjaxResponse> {
|
|
||||||
return from(
|
|
||||||
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
|
|
||||||
try {
|
|
||||||
const gitHubFile = content.data as IGitHubFile;
|
|
||||||
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Rename", "Rename");
|
|
||||||
const newUri = model.path;
|
|
||||||
const newPath = GitHubUtils.fromContentUri(newUri).path;
|
|
||||||
const response = await this.params.gitHubClient.renameFileAsync(
|
|
||||||
gitHubFile.repo.owner,
|
|
||||||
gitHubFile.repo.name,
|
|
||||||
gitHubFile.branch.name,
|
|
||||||
commitMsg,
|
|
||||||
gitHubFile.path,
|
|
||||||
newPath,
|
|
||||||
);
|
|
||||||
if (response.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new GitHubContentProviderError("Failed to rename", response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
gitHubFile.commit = response.data;
|
|
||||||
gitHubFile.path = newPath;
|
|
||||||
gitHubFile.name = NotebookUtil.getName(gitHubFile.path);
|
|
||||||
|
|
||||||
return this.createSuccessAjaxResponse(
|
|
||||||
HttpStatusCodes.OK,
|
|
||||||
this.createContentModel(newUri, gitHubFile, { content: 0 }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubContentProvider/update", error.errno);
|
|
||||||
return this.createErrorAjaxResponse(error);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public create<FT extends FileType>(
|
|
||||||
_: ServerConfig,
|
|
||||||
uri: string,
|
|
||||||
model: Partial<IContent<FT>> & { type: FT },
|
|
||||||
): Observable<AjaxResponse> {
|
|
||||||
return from(
|
|
||||||
this.params.promptForCommitMsg("Create New Notebook", "Create").then(async (commitMsg: string) => {
|
|
||||||
try {
|
|
||||||
if (!commitMsg) {
|
|
||||||
throw new GitHubContentProviderError("Couldn't get a commit message");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.type !== "notebook") {
|
|
||||||
throw new GitHubContentProviderError("Unsupported content type");
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentInfo = GitHubUtils.fromContentUri(uri);
|
|
||||||
if (!contentInfo) {
|
|
||||||
throw new GitHubContentProviderError(`Failed to parse ${uri}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = Base64Utils.utf8ToB64(stringifyNotebook(toJS(makeNotebookRecord())));
|
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
second: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
};
|
|
||||||
const name = `Untitled-${new Date().toLocaleString("default", options)}.ipynb`;
|
|
||||||
let path = name;
|
|
||||||
if (contentInfo.path) {
|
|
||||||
path = UrlUtility.createUri(contentInfo.path, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
|
|
||||||
contentInfo.owner,
|
|
||||||
contentInfo.repo,
|
|
||||||
contentInfo.branch,
|
|
||||||
path,
|
|
||||||
commitMsg,
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
if (response.status !== HttpStatusCodes.Created) {
|
|
||||||
throw new GitHubContentProviderError("Failed to create", response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUri = GitHubUtils.toContentUri(contentInfo.owner, contentInfo.repo, contentInfo.branch, path);
|
|
||||||
const newGitHubFile: IGitHubFile = {
|
|
||||||
type: "blob",
|
|
||||||
name: NotebookUtil.getName(newUri),
|
|
||||||
path,
|
|
||||||
repo: {
|
|
||||||
owner: contentInfo.owner,
|
|
||||||
name: contentInfo.repo,
|
|
||||||
private: undefined,
|
|
||||||
},
|
|
||||||
branch: {
|
|
||||||
name: contentInfo.branch,
|
|
||||||
},
|
|
||||||
commit: response.data,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.createSuccessAjaxResponse(
|
|
||||||
HttpStatusCodes.Created,
|
|
||||||
this.createContentModel(newUri, newGitHubFile, { content: 0 }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubContentProvider/create", error.errno);
|
|
||||||
return this.createErrorAjaxResponse(error);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public save<FT extends FileType>(
|
|
||||||
_: ServerConfig,
|
|
||||||
uri: string,
|
|
||||||
model: Partial<IContent<FT>>,
|
|
||||||
): Observable<AjaxResponse> {
|
|
||||||
return from(
|
|
||||||
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
|
|
||||||
try {
|
|
||||||
let commitMsg: string;
|
|
||||||
if (content.status === HttpStatusCodes.NotFound) {
|
|
||||||
// We'll create a new file since it doesn't exist
|
|
||||||
commitMsg = await this.params.promptForCommitMsg("Save", "Save");
|
|
||||||
if (!commitMsg) {
|
|
||||||
throw new GitHubContentProviderError("Couldn't get a commit message");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save");
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedContent: string;
|
|
||||||
if (model.type === "notebook") {
|
|
||||||
updatedContent = Base64Utils.utf8ToB64(stringifyNotebook(model.content as Notebook));
|
|
||||||
} else if (model.type === "file") {
|
|
||||||
updatedContent = model.content as string;
|
|
||||||
if (model.format !== "base64") {
|
|
||||||
updatedContent = Base64Utils.utf8ToB64(updatedContent);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new GitHubContentProviderError("Unsupported content type");
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentInfo = GitHubUtils.fromContentUri(uri);
|
|
||||||
let gitHubFile: IGitHubFile;
|
|
||||||
if (content.data) {
|
|
||||||
gitHubFile = content.data as IGitHubFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
|
|
||||||
contentInfo.owner,
|
|
||||||
contentInfo.repo,
|
|
||||||
contentInfo.branch,
|
|
||||||
contentInfo.path,
|
|
||||||
commitMsg,
|
|
||||||
updatedContent,
|
|
||||||
gitHubFile?.sha,
|
|
||||||
);
|
|
||||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.Created) {
|
|
||||||
throw new GitHubContentProviderError("Failed to create or update", response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gitHubFile) {
|
|
||||||
gitHubFile.commit = response.data;
|
|
||||||
} else {
|
|
||||||
const contentResponse = await this.params.gitHubClient.getContentsAsync(
|
|
||||||
contentInfo.owner,
|
|
||||||
contentInfo.repo,
|
|
||||||
contentInfo.branch,
|
|
||||||
contentInfo.path,
|
|
||||||
);
|
|
||||||
if (contentResponse.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new GitHubContentProviderError("Failed to get content", response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
gitHubFile = contentResponse.data as IGitHubFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.createSuccessAjaxResponse(
|
|
||||||
HttpStatusCodes.OK,
|
|
||||||
this.createContentModel(uri, gitHubFile, { content: 0 }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
Logger.logError(getErrorMessage(error), "GitHubContentProvider/update", error.errno);
|
|
||||||
return this.createErrorAjaxResponse(error);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public listCheckpoints(): Observable<AjaxResponse> {
|
|
||||||
const error = new GitHubContentProviderError("Not implemented");
|
|
||||||
Logger.logError(error.message, "GitHubContentProvider/listCheckpoints", error.errno);
|
|
||||||
return of(this.createErrorAjaxResponse(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
public createCheckpoint(): Observable<AjaxResponse> {
|
|
||||||
const error = new GitHubContentProviderError("Not implemented");
|
|
||||||
Logger.logError(error.message, "GitHubContentProvider/createCheckpoint", error.errno);
|
|
||||||
return of(this.createErrorAjaxResponse(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
public deleteCheckpoint(): Observable<AjaxResponse> {
|
|
||||||
const error = new GitHubContentProviderError("Not implemented");
|
|
||||||
Logger.logError(error.message, "GitHubContentProvider/deleteCheckpoint", error.errno);
|
|
||||||
return of(this.createErrorAjaxResponse(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
public restoreFromCheckpoint(): Observable<AjaxResponse> {
|
|
||||||
const error = new GitHubContentProviderError("Not implemented");
|
|
||||||
Logger.logError(error.message, "GitHubContentProvider/restoreFromCheckpoint", error.errno);
|
|
||||||
return of(this.createErrorAjaxResponse(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateContentAndGetCommitMsg(
|
|
||||||
content: IGitHubResponse<IGitHubFile | IGitHubFile[]>,
|
|
||||||
promptTitle: string,
|
|
||||||
promptPrimaryButtonLabel: string,
|
|
||||||
): Promise<string> {
|
|
||||||
if (content.status !== HttpStatusCodes.OK) {
|
|
||||||
throw new GitHubContentProviderError("Failed to get content", content.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(content.data)) {
|
|
||||||
throw new GitHubContentProviderError("Operation not supported for collections");
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitMsg = await this.params.promptForCommitMsg(promptTitle, promptPrimaryButtonLabel);
|
|
||||||
if (!commitMsg) {
|
|
||||||
throw new GitHubContentProviderError("Couldn't get a commit message");
|
|
||||||
}
|
|
||||||
|
|
||||||
return commitMsg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getContent(uri: string): Promise<IGitHubResponse<IGitHubFile | IGitHubFile[]>> {
|
|
||||||
const contentInfo = GitHubUtils.fromContentUri(uri);
|
|
||||||
if (contentInfo) {
|
|
||||||
const { owner, repo, branch, path } = contentInfo;
|
|
||||||
return this.params.gitHubClient.getContentsAsync(owner, repo, branch, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve({ status: GitHubContentProvider.SelfErrorCode, data: undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
private createContentModel(
|
|
||||||
uri: string,
|
|
||||||
content: IGitHubFile | IGitHubFile[],
|
|
||||||
params: Partial<IGetParams>,
|
|
||||||
): IContent<FileType> {
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
return this.createDirectoryModel(uri, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.type === "tree") {
|
|
||||||
return this.createDirectoryModel(uri, undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (NotebookUtil.isNotebookFile(uri)) {
|
|
||||||
return this.createNotebookModel(content, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.createFileModel(content, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createDirectoryModel(uri: string, gitHubFiles: IGitHubFile[] | undefined): IContent<"directory"> {
|
|
||||||
return {
|
|
||||||
name: NotebookUtil.getName(uri),
|
|
||||||
path: uri,
|
|
||||||
type: "directory",
|
|
||||||
writable: true, // TODO: tamitta: we don't know this info here
|
|
||||||
created: "", // TODO: tamitta: we don't know this info here
|
|
||||||
last_modified: "", // TODO: tamitta: we don't know this info here
|
|
||||||
mimetype: undefined,
|
|
||||||
content: gitHubFiles?.map(
|
|
||||||
(file: IGitHubFile) =>
|
|
||||||
this.createContentModel(
|
|
||||||
GitHubUtils.toContentUri(file.repo.owner, file.repo.name, file.branch.name, file.path),
|
|
||||||
file,
|
|
||||||
{
|
|
||||||
content: 0,
|
|
||||||
},
|
|
||||||
) as IEmptyContent<FileType>,
|
|
||||||
),
|
|
||||||
format: "json",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private createNotebookModel(gitHubFile: IGitHubFile, params: Partial<IGetParams>): IContent<"notebook"> {
|
|
||||||
const content: Notebook = gitHubFile.content && params.content !== 0 ? JSON.parse(gitHubFile.content) : undefined;
|
|
||||||
return {
|
|
||||||
name: gitHubFile.name,
|
|
||||||
path: GitHubUtils.toContentUri(
|
|
||||||
gitHubFile.repo.owner,
|
|
||||||
gitHubFile.repo.name,
|
|
||||||
gitHubFile.branch.name,
|
|
||||||
gitHubFile.path,
|
|
||||||
),
|
|
||||||
type: "notebook",
|
|
||||||
writable: true, // TODO: tamitta: we don't know this info here
|
|
||||||
created: "", // TODO: tamitta: we don't know this info here
|
|
||||||
last_modified: gitHubFile.commit.commitDate,
|
|
||||||
mimetype: content ? "application/x-ipynb+json" : undefined,
|
|
||||||
content,
|
|
||||||
format: content ? "json" : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private createFileModel(gitHubFile: IGitHubFile, params: Partial<IGetParams>): IContent<"file"> {
|
|
||||||
const content: string = gitHubFile.content && params.content !== 0 ? gitHubFile.content : undefined;
|
|
||||||
return {
|
|
||||||
name: gitHubFile.name,
|
|
||||||
path: GitHubUtils.toContentUri(
|
|
||||||
gitHubFile.repo.owner,
|
|
||||||
gitHubFile.repo.name,
|
|
||||||
gitHubFile.branch.name,
|
|
||||||
gitHubFile.path,
|
|
||||||
),
|
|
||||||
type: "file",
|
|
||||||
writable: true, // TODO: tamitta: we don't know this info here
|
|
||||||
created: "", // TODO: tamitta: we don't know this info here
|
|
||||||
last_modified: gitHubFile.commit.commitDate,
|
|
||||||
mimetype: content ? "text/plain" : undefined,
|
|
||||||
content,
|
|
||||||
format: content ? "text" : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private createSuccessAjaxResponse(status: number, content: IContent<FileType>): AjaxResponse {
|
|
||||||
return {
|
|
||||||
originalEvent: new Event("no-op"),
|
|
||||||
xhr: new XMLHttpRequest(),
|
|
||||||
request: {},
|
|
||||||
status,
|
|
||||||
response: content ? content : undefined,
|
|
||||||
responseText: content ? JSON.stringify(content) : undefined,
|
|
||||||
responseType: "json",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private createErrorAjaxResponse(error: GitHubContentProviderError): AjaxResponse {
|
|
||||||
return {
|
|
||||||
originalEvent: new Event("no-op"),
|
|
||||||
xhr: new XMLHttpRequest(),
|
|
||||||
request: {},
|
|
||||||
status: error.errno,
|
|
||||||
response: error,
|
|
||||||
responseText: getErrorMessage(error),
|
|
||||||
responseType: "json",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { HttpStatusCodes } from "../Common/Constants";
|
|
||||||
import Explorer from "../Explorer/Explorer";
|
|
||||||
import NotebookManager from "../Explorer/Notebook/NotebookManager";
|
|
||||||
import { JunoClient } from "../Juno/JunoClient";
|
|
||||||
import { IGitHubConnectorParams } from "./GitHubConnector";
|
|
||||||
import { GitHubOAuthService } from "./GitHubOAuthService";
|
|
||||||
|
|
||||||
describe("GitHubOAuthService", () => {
|
|
||||||
let junoClient: JunoClient;
|
|
||||||
let gitHubOAuthService: GitHubOAuthService;
|
|
||||||
let originalDataExplorer: Explorer;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
junoClient = new JunoClient();
|
|
||||||
gitHubOAuthService = new GitHubOAuthService(junoClient);
|
|
||||||
originalDataExplorer = window.dataExplorer;
|
|
||||||
window.dataExplorer = {
|
|
||||||
...originalDataExplorer,
|
|
||||||
} as Explorer;
|
|
||||||
window.dataExplorer.notebookManager = new NotebookManager();
|
|
||||||
window.dataExplorer.notebookManager.junoClient = junoClient;
|
|
||||||
window.dataExplorer.notebookManager.gitHubOAuthService = gitHubOAuthService;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
window.dataExplorer = originalDataExplorer;
|
|
||||||
originalDataExplorer = undefined;
|
|
||||||
gitHubOAuthService = undefined;
|
|
||||||
junoClient = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logout deletes app authorization and resets token", async () => {
|
|
||||||
const deleteAppAuthorizationCallback = jest.fn().mockReturnValue({ status: HttpStatusCodes.NoContent });
|
|
||||||
junoClient.deleteAppAuthorization = deleteAppAuthorizationCallback;
|
|
||||||
|
|
||||||
await gitHubOAuthService.logout();
|
|
||||||
expect(deleteAppAuthorizationCallback).toHaveBeenCalled();
|
|
||||||
expect(gitHubOAuthService.getTokenObservable()()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resetToken resets token", () => {
|
|
||||||
gitHubOAuthService.resetToken();
|
|
||||||
expect(gitHubOAuthService.getTokenObservable()()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("startOAuth resets OAuth state", async () => {
|
|
||||||
let url: string;
|
|
||||||
const windowOpenCallback = jest.fn().mockImplementation((value: string) => {
|
|
||||||
url = value;
|
|
||||||
});
|
|
||||||
window.open = windowOpenCallback;
|
|
||||||
|
|
||||||
await gitHubOAuthService.startOAuth("scope");
|
|
||||||
expect(windowOpenCallback).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const initialParams = new URLSearchParams(new URL(url).search);
|
|
||||||
expect(initialParams.get("state")).toBeDefined();
|
|
||||||
|
|
||||||
await gitHubOAuthService.startOAuth("another scope");
|
|
||||||
expect(windowOpenCallback).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const newParams = new URLSearchParams(new URL(url).search);
|
|
||||||
expect(newParams.get("state")).toBeDefined();
|
|
||||||
expect(newParams.get("state")).not.toEqual(initialParams.get("state"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finishOAuth updates token", async () => {
|
|
||||||
const data = { key: "value" };
|
|
||||||
const getGitHubTokenCallback = jest.fn().mockReturnValue({ status: HttpStatusCodes.OK, data });
|
|
||||||
junoClient.getGitHubToken = getGitHubTokenCallback;
|
|
||||||
|
|
||||||
const initialToken = gitHubOAuthService.getTokenObservable()();
|
|
||||||
const state = await gitHubOAuthService.startOAuth("scope");
|
|
||||||
|
|
||||||
const params: IGitHubConnectorParams = {
|
|
||||||
state,
|
|
||||||
code: "code",
|
|
||||||
};
|
|
||||||
await gitHubOAuthService.finishOAuth(params);
|
|
||||||
const updatedToken = gitHubOAuthService.getTokenObservable()();
|
|
||||||
|
|
||||||
expect(getGitHubTokenCallback).toHaveBeenCalledWith("code");
|
|
||||||
expect(initialToken).not.toEqual(updatedToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finishOAuth updates token to error if state doesn't match", async () => {
|
|
||||||
await gitHubOAuthService.startOAuth("scope");
|
|
||||||
|
|
||||||
const params: IGitHubConnectorParams = {
|
|
||||||
state: "state",
|
|
||||||
code: "code",
|
|
||||||
};
|
|
||||||
await gitHubOAuthService.finishOAuth(params);
|
|
||||||
|
|
||||||
expect(gitHubOAuthService.getTokenObservable()().error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finishOAuth updates token to error if unable to fetch token", async () => {
|
|
||||||
const getGitHubTokenCallback = jest.fn().mockReturnValue({ status: HttpStatusCodes.NotFound });
|
|
||||||
junoClient.getGitHubToken = getGitHubTokenCallback;
|
|
||||||
|
|
||||||
const state = await gitHubOAuthService.startOAuth("scope");
|
|
||||||
|
|
||||||
const params: IGitHubConnectorParams = {
|
|
||||||
state,
|
|
||||||
code: "code",
|
|
||||||
};
|
|
||||||
await gitHubOAuthService.finishOAuth(params);
|
|
||||||
|
|
||||||
expect(getGitHubTokenCallback).toHaveBeenCalledWith("code");
|
|
||||||
expect(gitHubOAuthService.getTokenObservable()().error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isLoggedIn returns false if resetToken is called", () => {
|
|
||||||
gitHubOAuthService.resetToken();
|
|
||||||
expect(gitHubOAuthService.isLoggedIn()).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isLoggedIn returns false if logout is called", async () => {
|
|
||||||
const deleteAppAuthorizationCallback = jest.fn().mockReturnValue({ status: HttpStatusCodes.NoContent });
|
|
||||||
junoClient.deleteAppAuthorization = deleteAppAuthorizationCallback;
|
|
||||||
|
|
||||||
await gitHubOAuthService.logout();
|
|
||||||
expect(gitHubOAuthService.isLoggedIn()).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import ko from "knockout";
|
|
||||||
import postRobot from "post-robot";
|
|
||||||
import { GetGithubClientId } from "Utils/GitHubUtils";
|
|
||||||
import { HttpStatusCodes } from "../Common/Constants";
|
|
||||||
import { handleError } from "../Common/ErrorHandlingUtils";
|
|
||||||
import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent";
|
|
||||||
import { JunoClient } from "../Juno/JunoClient";
|
|
||||||
import { logConsoleInfo } from "../Utils/NotificationConsoleUtils";
|
|
||||||
import { GitHubConnectorMsgType, IGitHubConnectorParams } from "./GitHubConnector";
|
|
||||||
|
|
||||||
postRobot.on(
|
|
||||||
GitHubConnectorMsgType,
|
|
||||||
{
|
|
||||||
domain: window.location.origin,
|
|
||||||
},
|
|
||||||
(event) => {
|
|
||||||
// Typescript definition for event is wrong. So read params by casting to <any>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const params = (event as any).data as IGitHubConnectorParams;
|
|
||||||
window.dataExplorer.notebookManager?.gitHubOAuthService.finishOAuth(params);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface IGitHubOAuthToken {
|
|
||||||
// API properties
|
|
||||||
access_token?: string;
|
|
||||||
scope?: string;
|
|
||||||
token_type?: string;
|
|
||||||
error?: string;
|
|
||||||
error_description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GitHubOAuthService {
|
|
||||||
private static readonly OAuthEndpoint = "https://github.com/login/oauth/authorize";
|
|
||||||
|
|
||||||
private state: string;
|
|
||||||
private token: ko.Observable<IGitHubOAuthToken>;
|
|
||||||
|
|
||||||
constructor(private junoClient: JunoClient) {
|
|
||||||
this.token = ko.observable<IGitHubOAuthToken>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async startOAuth(scope: string): Promise<string> {
|
|
||||||
// If attempting to change scope from "Public & private repos" to "Public only" we need to delete app authorization.
|
|
||||||
// Otherwise OAuth app still retains the "public & private repos" permissions.
|
|
||||||
if (
|
|
||||||
this.token()?.scope === AuthorizeAccessComponent.Scopes.PublicAndPrivate.key &&
|
|
||||||
scope === AuthorizeAccessComponent.Scopes.Public.key
|
|
||||||
) {
|
|
||||||
const logoutSuccessful = await this.logout();
|
|
||||||
if (!logoutSuccessful) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
scope,
|
|
||||||
client_id: GetGithubClientId(),
|
|
||||||
redirect_uri: new URL("./connectToGitHub.html", window.location.href).href,
|
|
||||||
state: this.resetState(),
|
|
||||||
};
|
|
||||||
|
|
||||||
window.open(`${GitHubOAuthService.OAuthEndpoint}?${new URLSearchParams(params).toString()}`);
|
|
||||||
return params.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async finishOAuth(params: IGitHubConnectorParams): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.validateState(params.state);
|
|
||||||
const response = await this.junoClient.getGitHubToken(params.code);
|
|
||||||
|
|
||||||
if (response.status === HttpStatusCodes.OK && !response.data.error) {
|
|
||||||
logConsoleInfo("Successfully connected to GitHub");
|
|
||||||
this.token(response.data);
|
|
||||||
} else {
|
|
||||||
let errorMsg = response.data.error;
|
|
||||||
if (response.data.error_description) {
|
|
||||||
errorMsg = `${errorMsg}: ${response.data.error_description}`;
|
|
||||||
}
|
|
||||||
throw new Error(errorMsg);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logConsoleInfo(`Failed to connect to GitHub: ${error}`);
|
|
||||||
this.token({ error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTokenObservable(): ko.Observable<IGitHubOAuthToken> {
|
|
||||||
return this.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async logout(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await this.junoClient.deleteAppAuthorization(this.token()?.access_token);
|
|
||||||
if (response.status !== HttpStatusCodes.NoContent) {
|
|
||||||
throw new Error(`Received HTTP ${response.status}: ${response.data} when deleting app authorization`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resetToken();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, "GitHubOAuthService/logout", "Failed to delete app authorization");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public isLoggedIn(): boolean {
|
|
||||||
return !!this.token()?.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetState(): string {
|
|
||||||
this.state = Math.floor(Math.random() * Math.floor(Number.MAX_SAFE_INTEGER)).toString();
|
|
||||||
return this.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
public resetToken(): void {
|
|
||||||
this.token(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateState(state: string) {
|
|
||||||
if (state !== this.state) {
|
|
||||||
throw new Error("State didn't match. Possibility of cross-site request forgery attack.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { HttpStatusCodes } from "../Common/Constants";
|
|
||||||
import { IPinnedRepo, JunoClient } from "./JunoClient";
|
|
||||||
|
|
||||||
const samplePinnedRepos: IPinnedRepo[] = [
|
|
||||||
{
|
|
||||||
owner: "owner",
|
|
||||||
name: "name",
|
|
||||||
private: false,
|
|
||||||
branches: [
|
|
||||||
{
|
|
||||||
name: "name",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("Pinned repos", () => {
|
|
||||||
const junoClient = new JunoClient();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
window.fetch = jest.fn().mockImplementation(() => {
|
|
||||||
return {
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
text: () => JSON.stringify(samplePinnedRepos),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updatePinnedRepos invokes pinned repos subscribers", async () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
const callback = jest.fn().mockImplementation(() => {});
|
|
||||||
|
|
||||||
junoClient.subscribeToPinnedRepos(callback);
|
|
||||||
const response = await junoClient.updatePinnedRepos(samplePinnedRepos);
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(callback).toHaveBeenCalledWith(samplePinnedRepos);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getPinnedRepos invokes pinned repos subscribers", async () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
const callback = jest.fn().mockImplementation(() => {});
|
|
||||||
|
|
||||||
junoClient.subscribeToPinnedRepos(callback);
|
|
||||||
const response = await junoClient.getPinnedRepos("scope");
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(callback).toHaveBeenCalledWith(samplePinnedRepos);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GitHub", () => {
|
|
||||||
const junoClient = new JunoClient();
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getGitHubToken", async () => {
|
|
||||||
let fetchUrl: string;
|
|
||||||
window.fetch = jest.fn().mockImplementation((url: string) => {
|
|
||||||
fetchUrl = url;
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: HttpStatusCodes.OK,
|
|
||||||
text: () => JSON.stringify({ access_token: "token" }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.getGitHubToken("code");
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
|
||||||
expect(response.data.access_token).toBeDefined();
|
|
||||||
expect(window.fetch).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const fetchUrlParams = new URLSearchParams(new URL(fetchUrl).search);
|
|
||||||
let fetchUrlParamsCount = 0;
|
|
||||||
fetchUrlParams.forEach(() => fetchUrlParamsCount++);
|
|
||||||
|
|
||||||
expect(fetchUrlParamsCount).toBe(2);
|
|
||||||
expect(fetchUrlParams.get("code")).toBeDefined();
|
|
||||||
expect(fetchUrlParams.get("client_id")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteAppauthorization", async () => {
|
|
||||||
let fetchUrl: string;
|
|
||||||
window.fetch = jest.fn().mockImplementation((url: string) => {
|
|
||||||
fetchUrl = url;
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: HttpStatusCodes.NoContent,
|
|
||||||
text: () => undefined as string,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await junoClient.deleteAppAuthorization("token");
|
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatusCodes.NoContent);
|
|
||||||
expect(window.fetch).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const fetchUrlParams = new URLSearchParams(new URL(fetchUrl).search);
|
|
||||||
let fetchUrlParamsCount = 0;
|
|
||||||
fetchUrlParams.forEach(() => fetchUrlParamsCount++);
|
|
||||||
|
|
||||||
expect(fetchUrlParamsCount).toBe(2);
|
|
||||||
expect(fetchUrlParams.get("access_token")).toBeDefined();
|
|
||||||
expect(fetchUrlParams.get("client_id")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointUtils";
|
import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointUtils";
|
||||||
import { GetGithubClientId } from "Utils/GitHubUtils";
|
|
||||||
import ko from "knockout";
|
|
||||||
import { HttpHeaders, HttpStatusCodes } from "../Common/Constants";
|
import { HttpHeaders, HttpStatusCodes } from "../Common/Constants";
|
||||||
import { configContext } from "../ConfigContext";
|
import { configContext } from "../ConfigContext";
|
||||||
import * as DataModels from "../Contracts/DataModels";
|
import * as DataModels from "../Contracts/DataModels";
|
||||||
import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent";
|
|
||||||
import { IGitHubResponse } from "../GitHub/GitHubClient";
|
|
||||||
import { IGitHubOAuthToken } from "../GitHub/GitHubOAuthService";
|
|
||||||
import { userContext } from "../UserContext";
|
import { userContext } from "../UserContext";
|
||||||
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
|
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
|
||||||
|
|
||||||
@@ -15,17 +10,6 @@ export interface IJunoResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPinnedRepo {
|
|
||||||
owner: string;
|
|
||||||
name: string;
|
|
||||||
private: boolean;
|
|
||||||
branches: IPinnedBranch[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IPinnedBranch {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IGalleryItem {
|
export interface IGalleryItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -45,113 +29,6 @@ export interface IGalleryItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class JunoClient {
|
export class JunoClient {
|
||||||
private cachedPinnedRepos: ko.Observable<IPinnedRepo[]>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.cachedPinnedRepos = ko.observable<IPinnedRepo[]>([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public subscribeToPinnedRepos(callback: ko.SubscriptionCallback<IPinnedRepo[], void>): ko.Subscription {
|
|
||||||
return this.cachedPinnedRepos.subscribe(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getPinnedRepos(scope: string): Promise<IJunoResponse<IPinnedRepo[]>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github/pinnedrepos`, {
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let pinnedRepos: IPinnedRepo[];
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
pinnedRepos = JSON.parse(await response.text());
|
|
||||||
|
|
||||||
// In case we're restricted to public only scope, we return only public repos
|
|
||||||
if (scope === AuthorizeAccessComponent.Scopes.Public.key) {
|
|
||||||
pinnedRepos = pinnedRepos.filter((repo) => !repo.private);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cachedPinnedRepos(pinnedRepos);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data: pinnedRepos,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updatePinnedRepos(repos: IPinnedRepo[]): Promise<IJunoResponse<undefined>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github/pinnedrepos`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(repos),
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === HttpStatusCodes.OK) {
|
|
||||||
this.cachedPinnedRepos(repos);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async deleteGitHubInfo(): Promise<IJunoResponse<undefined>> {
|
|
||||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getGitHubToken(code: string): Promise<IGitHubResponse<IGitHubOAuthToken>> {
|
|
||||||
const githubParams = JunoClient.getGitHubClientParams();
|
|
||||||
githubParams.append("code", code);
|
|
||||||
|
|
||||||
const response = await window.fetch(
|
|
||||||
`${this.getNotebooksSubscriptionIdAccountUrl()}/github/token?${githubParams.toString()}`,
|
|
||||||
{
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let data: IGitHubOAuthToken;
|
|
||||||
const body = await response.text();
|
|
||||||
if (body) {
|
|
||||||
data = JSON.parse(body);
|
|
||||||
} else {
|
|
||||||
data = {
|
|
||||||
error: response.statusText,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async deleteAppAuthorization(token: string): Promise<IJunoResponse<string>> {
|
|
||||||
const githubParams = JunoClient.getGitHubClientParams();
|
|
||||||
githubParams.append("access_token", token);
|
|
||||||
|
|
||||||
const response = await window.fetch(
|
|
||||||
`${this.getNotebooksSubscriptionIdAccountUrl()}/github/token?${githubParams.toString()}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: JunoClient.getHeaders(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data: await response.text(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async increaseNotebookViews(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
public async increaseNotebookViews(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
||||||
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/views`, {
|
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/views`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -245,18 +122,6 @@ export class JunoClient {
|
|||||||
return `${JunoClient.getJunoEndpoint()}/api/notebooks`;
|
return `${JunoClient.getJunoEndpoint()}/api/notebooks`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAccount(): string {
|
|
||||||
return userContext?.databaseAccount?.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSubscriptionId(): string {
|
|
||||||
return userContext.subscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNotebooksSubscriptionIdAccountUrl(): string {
|
|
||||||
return `${this.getNotebooksUrl()}/subscriptions/${this.getSubscriptionId()}/databaseAccounts/${this.getAccount()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAnalyticsUrl(): string {
|
private getAnalyticsUrl(): string {
|
||||||
return `${JunoClient.getJunoEndpoint()}/api/analytics`;
|
return `${JunoClient.getJunoEndpoint()}/api/analytics`;
|
||||||
}
|
}
|
||||||
@@ -268,16 +133,4 @@ export class JunoClient {
|
|||||||
[HttpHeaders.contentType]: "application/json",
|
[HttpHeaders.contentType]: "application/json",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getGitHubClientParams(): URLSearchParams {
|
|
||||||
const githubParams = new URLSearchParams({
|
|
||||||
client_id: GetGithubClientId(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (configContext.GITHUB_CLIENT_SECRET) {
|
|
||||||
githubParams.append("client_secret", configContext.GITHUB_CLIENT_SECRET);
|
|
||||||
}
|
|
||||||
|
|
||||||
return githubParams;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import * as GitHubUtils from "./GitHubUtils";
|
|
||||||
|
|
||||||
const owner = "owner-1";
|
|
||||||
const repo = "repo-1";
|
|
||||||
const branch = "branch/name.1-2";
|
|
||||||
const path = "folder name/file name1:2.ipynb";
|
|
||||||
|
|
||||||
describe("GitHubUtils", () => {
|
|
||||||
it("fromRepoUri parses github repo url correctly", () => {
|
|
||||||
const repoInfo = GitHubUtils.fromRepoUri(`https://github.com/${owner}/${repo}/tree/${branch}`);
|
|
||||||
expect(repoInfo).toEqual({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
branch,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toContentUri generates github uris correctly", () => {
|
|
||||||
const uri = GitHubUtils.toContentUri(owner, repo, branch, path);
|
|
||||||
expect(uri).toBe(`github://${owner}/${repo}/${path}?ref=${branch}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fromContentUri parses the github uris correctly", () => {
|
|
||||||
const contentInfo = GitHubUtils.fromContentUri(`github://${owner}/${repo}/${path}?ref=${branch}`);
|
|
||||||
expect(contentInfo).toEqual({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
branch,
|
|
||||||
path,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// https://github.com/<owner>/<repo>/tree/<branch>
|
|
||||||
|
|
||||||
import { JunoEndpoints } from "Common/Constants";
|
|
||||||
import { configContext } from "ConfigContext";
|
|
||||||
import { userContext } from "UserContext";
|
|
||||||
|
|
||||||
// The url when users visit a repo/branch on github.com
|
|
||||||
export const RepoUriPattern = /https:\/\/github.com\/([^/]*)\/([^/]*)\/tree\/([^?]*)/;
|
|
||||||
|
|
||||||
// github://<owner>/<repo>/<path>?ref=<branch>
|
|
||||||
// Custom scheme for github content
|
|
||||||
export const ContentUriPattern = /github:\/\/([^/]*)\/([^/]*)\/([^?]*)\?ref=(.*)/;
|
|
||||||
|
|
||||||
// https://github.com/<owner>/<repo>/blob/<branch>/<path>
|
|
||||||
// We need to support this until we move to newer scheme for quickstarts
|
|
||||||
export const LegacyContentUriPattern = /https:\/\/github.com\/([^/]*)\/([^/]*)\/blob\/([^/]*)\/([^?]*)/;
|
|
||||||
|
|
||||||
export function toRepoFullName(owner: string, repo: string): string {
|
|
||||||
return `${owner}/${repo}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromRepoUri(repoUri: string): undefined | { owner: string; repo: string; branch: string } {
|
|
||||||
const matches = repoUri.match(RepoUriPattern);
|
|
||||||
if (matches && matches.length > 3) {
|
|
||||||
return {
|
|
||||||
owner: matches[1],
|
|
||||||
repo: matches[2],
|
|
||||||
branch: matches[3],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromContentUri(
|
|
||||||
contentUri: string,
|
|
||||||
): undefined | { owner: string; repo: string; branch: string; path: string } {
|
|
||||||
let matches = contentUri.match(ContentUriPattern);
|
|
||||||
if (matches && matches.length > 4) {
|
|
||||||
return {
|
|
||||||
owner: matches[1],
|
|
||||||
repo: matches[2],
|
|
||||||
branch: matches[4],
|
|
||||||
path: matches[3],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
matches = contentUri.match(LegacyContentUriPattern);
|
|
||||||
if (matches && matches.length > 4) {
|
|
||||||
return {
|
|
||||||
owner: matches[1],
|
|
||||||
repo: matches[2],
|
|
||||||
branch: matches[3],
|
|
||||||
path: matches[4],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toContentUri(owner: string, repo: string, branch: string, path: string): string {
|
|
||||||
return `github://${owner}/${repo}/${path}?ref=${branch}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toRawContentUri(owner: string, repo: string, branch: string, path: string): string {
|
|
||||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetGithubClientId(): string {
|
|
||||||
const junoEndpoint = userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT;
|
|
||||||
if (
|
|
||||||
junoEndpoint === JunoEndpoints.Test ||
|
|
||||||
junoEndpoint === JunoEndpoints.Test2 ||
|
|
||||||
junoEndpoint === JunoEndpoints.Test3
|
|
||||||
) {
|
|
||||||
return configContext.GITHUB_TEST_ENV_CLIENT_ID;
|
|
||||||
}
|
|
||||||
return configContext.GITHUB_CLIENT_ID;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { RepoListItem } from "../Explorer/Controls/GitHub/GitHubReposComponent";
|
|
||||||
import { IPinnedRepo } from "../Juno/JunoClient";
|
|
||||||
import * as JunoUtils from "./JunoUtils";
|
|
||||||
import { IGitHubRepo } from "../GitHub/GitHubClient";
|
|
||||||
|
|
||||||
const gitHubRepo: IGitHubRepo = {
|
|
||||||
name: "repo-name",
|
|
||||||
owner: "owner",
|
|
||||||
private: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const repoListItem: RepoListItem = {
|
|
||||||
key: "key",
|
|
||||||
repo: {
|
|
||||||
name: "repo-name",
|
|
||||||
owner: "owner",
|
|
||||||
private: false,
|
|
||||||
},
|
|
||||||
branches: [
|
|
||||||
{
|
|
||||||
name: "branch-name",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pinnedRepo: IPinnedRepo = {
|
|
||||||
name: "repo-name",
|
|
||||||
owner: "owner",
|
|
||||||
private: false,
|
|
||||||
branches: [
|
|
||||||
{
|
|
||||||
name: "branch-name",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("JunoUtils", () => {
|
|
||||||
it("toPinnedRepo converts RepoListItem to IPinnedRepo", () => {
|
|
||||||
expect(JunoUtils.toPinnedRepo(repoListItem)).toEqual(pinnedRepo);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toGitHubRepo converts IPinnedRepo to IGitHubRepo", () => {
|
|
||||||
expect(JunoUtils.toGitHubRepo(pinnedRepo)).toEqual(gitHubRepo);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { RepoListItem } from "../Explorer/Controls/GitHub/GitHubReposComponent";
|
|
||||||
import { IGitHubRepo } from "../GitHub/GitHubClient";
|
|
||||||
import { IPinnedRepo } from "../Juno/JunoClient";
|
|
||||||
|
|
||||||
export function toPinnedRepo(item: RepoListItem): IPinnedRepo {
|
|
||||||
return {
|
|
||||||
owner: item.repo.owner,
|
|
||||||
name: item.repo.name,
|
|
||||||
private: item.repo.private,
|
|
||||||
branches: item.branches.map((element) => ({ name: element.name })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toGitHubRepo(pinnedRepo: IPinnedRepo): IGitHubRepo {
|
|
||||||
return {
|
|
||||||
owner: pinnedRepo.owner,
|
|
||||||
name: pinnedRepo.name,
|
|
||||||
private: pinnedRepo.private,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="height=device-height, width=device-width, initial-scale=1.0" />
|
|
||||||
|
|
||||||
<!-- TODO: tamitta: This is just a placeholder. -->
|
|
||||||
<title>Connect to GitHub</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
Connecting to GitHub...
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"./src/Contracts/SelfServeContracts.ts",
|
"./src/Contracts/SelfServeContracts.ts",
|
||||||
"./src/Contracts/SubscriptionType.ts",
|
"./src/Contracts/SubscriptionType.ts",
|
||||||
"./src/Contracts/Versions.ts",
|
"./src/Contracts/Versions.ts",
|
||||||
"./src/Explorer/Controls/GitHub/GitHubStyleConstants.ts",
|
|
||||||
"./src/Explorer/Controls/SmartUi/InputUtils.ts",
|
"./src/Explorer/Controls/SmartUi/InputUtils.ts",
|
||||||
"./src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.test.ts",
|
"./src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.test.ts",
|
||||||
"./src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts",
|
"./src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts",
|
||||||
@@ -63,7 +62,6 @@
|
|||||||
"./src/Explorer/Tables/QueryBuilder/DateTimeUtilities.test.ts",
|
"./src/Explorer/Tables/QueryBuilder/DateTimeUtilities.test.ts",
|
||||||
"./src/Explorer/Tables/QueryBuilder/DateTimeUtilities.ts",
|
"./src/Explorer/Tables/QueryBuilder/DateTimeUtilities.ts",
|
||||||
"./src/Explorer/Tree/AccessibleVerticalList.ts",
|
"./src/Explorer/Tree/AccessibleVerticalList.ts",
|
||||||
"./src/GitHub/GitHubConnector.ts",
|
|
||||||
"./src/HostedExplorerChildFrame.ts",
|
"./src/HostedExplorerChildFrame.ts",
|
||||||
"./src/Platform/Hosted/Components/MeControl.test.tsx",
|
"./src/Platform/Hosted/Components/MeControl.test.tsx",
|
||||||
"./src/Platform/Hosted/Components/MeControl.tsx",
|
"./src/Platform/Hosted/Components/MeControl.tsx",
|
||||||
@@ -96,8 +94,6 @@
|
|||||||
"./src/Utils/CapabilityUtils.ts",
|
"./src/Utils/CapabilityUtils.ts",
|
||||||
"./src/Utils/CloudUtils.ts",
|
"./src/Utils/CloudUtils.ts",
|
||||||
"./src/Utils/EndpointUtils.ts",
|
"./src/Utils/EndpointUtils.ts",
|
||||||
"./src/Utils/GitHubUtils.test.ts",
|
|
||||||
"./src/Utils/GitHubUtils.ts",
|
|
||||||
"./src/Utils/MessageValidation.test.ts",
|
"./src/Utils/MessageValidation.test.ts",
|
||||||
"./src/Utils/MessageValidation.ts",
|
"./src/Utils/MessageValidation.ts",
|
||||||
"./src/Utils/NotificationConsoleUtils.ts",
|
"./src/Utils/NotificationConsoleUtils.ts",
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ module.exports = function (_env = {}, argv = {}) {
|
|||||||
quickstart: "./src/quickstart.ts",
|
quickstart: "./src/quickstart.ts",
|
||||||
hostedExplorer: "./src/HostedExplorer.tsx",
|
hostedExplorer: "./src/HostedExplorer.tsx",
|
||||||
selfServe: "./src/SelfServe/SelfServe.tsx",
|
selfServe: "./src/SelfServe/SelfServe.tsx",
|
||||||
connectToGitHub: "./src/GitHub/GitHubConnector.ts",
|
|
||||||
redirectBridge: "./src/redirectBridge.ts",
|
redirectBridge: "./src/redirectBridge.ts",
|
||||||
...(mode !== "production" && { testExplorer: "./test/testExplorer/TestExplorer.ts" }),
|
...(mode !== "production" && { testExplorer: "./test/testExplorer/TestExplorer.ts" }),
|
||||||
...(mode !== "production" && {
|
...(mode !== "production" && {
|
||||||
@@ -139,11 +138,6 @@ module.exports = function (_env = {}, argv = {}) {
|
|||||||
template: "src/hostedExplorer.html",
|
template: "src/hostedExplorer.html",
|
||||||
chunks: ["hostedExplorer"],
|
chunks: ["hostedExplorer"],
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
filename: "connectToGitHub.html",
|
|
||||||
template: "src/connectToGitHub.html",
|
|
||||||
chunks: ["connectToGitHub"],
|
|
||||||
}),
|
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
filename: "selfServe.html",
|
filename: "selfServe.html",
|
||||||
template: "src/SelfServe/selfServe.html",
|
template: "src/SelfServe/selfServe.html",
|
||||||
|
|||||||
Reference in New Issue
Block a user