Browse Source

Adds initial workspace API

main
Keith Daulton 2 years ago
parent
commit
b479590171
9 changed files with 725 additions and 6 deletions
  1. +7
    -0
      src/container.ts
  2. +2
    -1
      src/env/browser/fetch.ts
  3. +1
    -1
      src/env/node/fetch.ts
  4. +2
    -1
      src/git/remotes/richRemoteProvider.ts
  5. +39
    -3
      src/plus/subscription/serverConnection.ts
  6. +21
    -0
      src/plus/webviews/workspaces/workspacesWebview.ts
  7. +267
    -0
      src/plus/workspaces/models.ts
  8. +379
    -0
      src/plus/workspaces/workspaces.ts
  9. +7
    -0
      src/webviews/apps/plus/workspaces/workspaces.ts

+ 7
- 0
src/container.ts View File

@ -27,6 +27,7 @@ import { GraphWebview } from './plus/webviews/graph/graphWebview';
import { TimelineWebview } from './plus/webviews/timeline/timelineWebview';
import { TimelineWebviewView } from './plus/webviews/timeline/timelineWebviewView';
import { WorkspacesWebview } from './plus/webviews/workspaces/workspacesWebview';
import { WorkspacesApi } from './plus/workspaces/workspaces';
import { StatusBarController } from './statusbar/statusBarController';
import type { Storage } from './storage';
import { executeCommand } from './system/command';
@ -175,6 +176,7 @@ export class Container {
(this._subscriptionAuthentication = new SubscriptionAuthenticationProvider(this, server)),
);
context.subscriptions.push((this._subscription = new SubscriptionService(this, previousVersion)));
context.subscriptions.push((this._workspaces = new WorkspacesApi(this, server)));
context.subscriptions.push((this._git = new GitProviderService(this)));
context.subscriptions.push(new GitFileSystemProvider(this));
@ -530,6 +532,11 @@ export class Container {
return this._searchAndCompareView;
}
private _workspaces: WorkspacesApi;
get workspaces() {
return this._workspaces;
}
private _subscription: SubscriptionService;
get subscription() {
return this._subscription;

+ 2
- 1
src/env/browser/fetch.ts View File

@ -11,7 +11,8 @@ declare global {
declare type _BodyInit = BodyInit;
declare type _RequestInit = RequestInit;
declare type _Response = Response;
export type { _BodyInit as BodyInit, _RequestInit as RequestInit, _Response as Response };
declare type _RequestInfo = RequestInfo;
export type { _BodyInit as BodyInit, _RequestInit as RequestInit, _Response as Response, _RequestInfo as RequestInfo };
export function getProxyAgent(_strictSSL?: boolean): HttpsProxyAgent | undefined {
return undefined;

+ 1
- 1
src/env/node/fetch.ts View File

@ -6,7 +6,7 @@ import { configuration } from '../../configuration';
import { Logger } from '../../logger';
export { fetch };
export type { BodyInit, RequestInit, Response } from 'node-fetch';
export type { BodyInit, RequestInfo, RequestInit, Response } from 'node-fetch';
export function getProxyAgent(strictSSL?: boolean): HttpsProxyAgent | undefined {
let proxyUrl: string | undefined;

+ 2
- 1
src/git/remotes/richRemoteProvider.ts View File

@ -79,7 +79,8 @@ export abstract class RichRemoteProvider extends RemoteProvider {
}
protected _session: AuthenticationSession | null | undefined;
protected session() {
// TODO: exposing this is rough approach for workspaces
session() {
if (this._session === undefined) {
return this.ensureSession(false);
}

+ 39
- 3
src/plus/subscription/serverConnection.ts View File

@ -1,7 +1,7 @@
import type { CancellationToken, Disposable, StatusBarItem } from 'vscode';
import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode';
import { uuid } from '@env/crypto';
import type { Response } from '@env/fetch';
import type { RequestInfo, RequestInit, Response } from '@env/fetch';
import { fetch, getProxyAgent } from '@env/fetch';
import type { Container } from '../../container';
import { Logger } from '../../logger';
@ -12,12 +12,20 @@ import type { DeferredEvent, DeferredEventExecutor } from '../../system/event';
import { promisifyDeferred } from '../../system/event';
export const AuthenticationUriPathPrefix = 'did-authenticate';
// TODO: What user-agent should we use?
const userAgent = 'Visual-Studio-Code-GitLens';
interface AccountInfo {
id: string;
accountName: string;
}
interface GraphQLRequest {
query: string;
operationName?: string;
variables?: Record<string, unknown>;
}
export class ServerConnection implements Disposable {
private _cancellationSource: CancellationTokenSource | undefined;
private _deferredCodeExchanges = new Map<string, DeferredEvent<string>>();
@ -72,8 +80,7 @@ export class ServerConnection implements Disposable {
agent: getProxyAgent(),
headers: {
Authorization: `Bearer ${token}`,
// TODO: What user-agent should we use?
'User-Agent': 'Visual-Studio-Code-GitLens',
'User-Agent': userAgent,
},
});
} catch (ex) {
@ -243,4 +250,33 @@ export class ServerConnection implements Disposable {
this._statusBarItem = undefined;
}
}
async fetchGraphql(data: GraphQLRequest, token: string, init?: RequestInit) {
return this.fetchCore(Uri.joinPath(this.baseAccountUri, 'api/projects/graphql').toString(), token, {
method: 'POST',
body: JSON.stringify(data),
...init,
});
}
private async fetchCore(url: RequestInfo, token: string, init?: RequestInit): Promise<Response> {
const scope = getLogScope();
try {
const options = {
agent: getProxyAgent(),
...init,
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': userAgent,
'Content-Type': 'application/json',
...init?.headers,
},
};
return await fetch(url, options);
} catch (ex) {
Logger.error(ex, scope);
throw ex;
}
}
}

+ 21
- 0
src/plus/webviews/workspaces/workspacesWebview.ts View File

@ -34,4 +34,25 @@ export class WorkspacesWebview extends WebviewBase {
void setContext(ContextKeys.WorkspacesFocused, focused);
}
private async getWorkspaces() {
try {
const rsp = await this.container.workspaces.getWorkspacesWithPullRequests();
console.log(rsp);
} catch (ex) {
console.log(ex);
}
return {};
}
private async getState(): Promise<State> {
return Promise.resolve({
workspaces: this.getWorkspaces(),
});
}
protected override async includeBootstrap(): Promise<State> {
return this.getState();
}
}

+ 267
- 0
src/plus/workspaces/models.ts View File

@ -0,0 +1,267 @@
export type WorkspaceProvider =
| 'GITHUB'
| 'GITHUB_ENTERPRISE'
| 'GITLAB'
| 'GITLAB_SELF_HOSTED'
| 'BITBUCKET'
| 'AZURE';
export interface Workspace {
id: string;
name: string;
description: string;
type: WorkspaceType;
icon_url: string;
host_url: string;
status: string;
provider: string;
azure_organization_id: string;
azure_project: string;
created_date: Date;
updated_date: Date;
created_by: string;
updated_by: string;
members: WorkspaceMember[];
organization: WorkspaceOrganization;
issue_tracker: WorkspaceIssueTracker;
settings: WorkspaceSettings;
current_user: UserWorkspaceSettings;
errors: string[];
provider_data: ProviderWorkspaceData;
}
export type WorkspaceType = 'GK_PROJECT' | 'GK_ORG_VELOCITY' | 'GK_CLI';
export interface WorkspaceMember {
id: string;
role: string;
name: string;
username: string;
avatar_url: string;
}
interface WorkspaceOrganization {
id: string;
team_ids: string[];
}
interface WorkspaceIssueTracker {
provider: string;
settings: WorkspaceIssueTrackerSettings;
}
interface WorkspaceIssueTrackerSettings {
resource_id: string;
}
interface WorkspaceSettings {
gkOrgVelocity: GKOrgVelocitySettings;
goals: ProjectGoalsSettings;
}
type GKOrgVelocitySettings = Record<string, unknown>;
type ProjectGoalsSettings = Record<string, unknown>;
interface UserWorkspaceSettings {
project_id: string;
user_id: string;
tab_settings: UserWorkspaceTabSettings;
}
interface UserWorkspaceTabSettings {
issue_tracker: WorkspaceIssueTracker;
}
export interface ProviderWorkspaceData {
id: string;
provider_organization_id: string;
repository: Repository;
repositories: Repository[];
pull_requests: PullRequest[];
issues: Issue[];
repository_members: RepositoryMember[];
milestones: Milestone[];
labels: Label[];
issue_types: IssueType[];
provider_identity: ProviderIdentity;
metrics: Metrics;
}
type Metrics = Record<string, unknown>;
interface ProviderIdentity {
avatar_url: string;
id: string;
name: string;
username: string;
pat_organization: string;
is_using_pat: boolean;
scopes: string;
}
export interface Branch {
id: string;
node_id: string;
name: string;
commit: BranchCommit;
}
interface BranchCommit {
id: string;
url: string;
build_status: {
context: string;
state: string;
description: string;
};
}
export interface Repository {
id: string;
name: string;
description: string;
repository_id: string;
provider: string;
provider_organization_id: string;
provider_organization_name: string;
url: string;
default_branch: string;
branches: Branch[];
pull_requests: PullRequest[];
issues: Issue[];
members: RepositoryMember[];
milestones: Milestone[];
labels: Label[];
issue_types: IssueType[];
possibly_deleted: boolean;
has_webhook: boolean;
}
interface RepositoryMember {
avatar_url: string;
name: string;
node_id: string;
username: string;
}
type Milestone = Record<string, unknown>;
type Label = Record<string, unknown>;
type IssueType = Record<string, unknown>;
export interface PullRequest {
id: string;
node_id: string;
number: string;
title: string;
description: string;
url: string;
milestone_id: string;
labels: Label[];
author_id: string;
author_username: string;
created_date: Date;
updated_date: Date;
closed_date: Date;
merged_date: Date;
first_commit_date: Date;
first_response_date: Date;
comment_count: number;
repository: Repository;
head_commit: {
id: string;
url: string;
build_status: {
context: string;
state: string;
description: string;
};
};
lifecycle_stages: {
stage: string;
start_date: Date;
end_date: Date;
}[];
reviews: PullRequestReviews[];
head: {
name: string;
};
}
interface PullRequestReviews {
user_id: string;
avatar_url: string;
state: string;
}
export interface Issue {
id: string;
node_id: string;
title: string;
author_id: string;
assignee_ids: string[];
milestone_id: string;
label_ids: string[];
issue_type: string;
url: string;
created_date: Date;
updated_date: Date;
comment_count: number;
repository: Repository;
}
interface Connection<i> {
total_count: number;
page_info: {
start_cursor: string;
end_cursor: string;
has_next_page: boolean;
};
nodes: i[];
}
interface FetchedConnection<i> extends Connection<i> {
is_fetching: boolean;
}
export interface WorkspacesResponse {
data: {
projects: Connection<Workspace>;
};
}
export interface PullRequestsResponse {
data: {
project: {
provider_data: {
pull_requests: FetchedConnection<PullRequest>;
};
};
};
}
export interface WorkspacesWithPullRequestsResponse {
data: {
projects: {
nodes: {
provider_data: {
pull_requests: FetchedConnection<PullRequest>;
};
}[];
};
};
errors?: {
message: string;
path: unknown[];
statusCode: number;
}[];
}
export interface IssuesResponse {
data: {
project: {
provider_data: {
issues: FetchedConnection<Issue>;
};
};
};
}

+ 379
- 0
src/plus/workspaces/workspaces.ts View File

@ -0,0 +1,379 @@
import type { AuthenticationSession, Disposable } from 'vscode';
import type { RequestInit } from '@env/fetch';
import type { Container } from '../../container';
import type { GitRemote } from '../../git/models/remote';
import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider';
import { Logger } from '../../logger';
import type { ServerConnection } from '../subscription/serverConnection';
import type {
IssuesResponse,
PullRequestsResponse,
Workspace,
WorkspaceProvider,
WorkspacesResponse,
WorkspacesWithPullRequestsResponse,
} from './models';
export class WorkspacesApi implements Disposable {
// private _disposable: Disposable;
constructor(private readonly container: Container, private readonly server: ServerConnection) {}
dispose(): void {
// this._disposable?.dispose();
}
private async getAccessToken() {
// TODO: should probably get scopes from somewhere
const sessions = await this.container.subscriptionAuthentication.getSessions(['gitlens']);
if (!sessions.length) {
return;
}
const session = sessions[0];
return session.accessToken;
}
private async getRichProvider(): Promise<GitRemote<RichRemoteProvider> | undefined> {
const remotes: GitRemote<RichRemoteProvider>[] = [];
for (const repo of this.container.git.openRepositories) {
const richRemote = await repo.getRichRemote(true);
if (richRemote == null || remotes.includes(richRemote)) {
continue;
}
remotes.push(richRemote);
}
if (remotes.length === 0) {
return undefined;
}
return remotes[0];
}
private async getRichProviderSession(): Promise<AuthenticationSession | undefined> {
const remote = await this.getRichProvider();
let session = remote?.provider.session();
if (session == null) {
return undefined;
}
if ((session as Promise<AuthenticationSession | undefined>).then != null) {
session = await session;
}
return session;
}
private async getProviderCredentials(_type: WorkspaceProvider) {
const session = await this.getRichProviderSession();
if (session == null) return undefined;
// TODO: get tokens from Providers
// let token;
// switch (type) {
// case 'GITHUB':
// token = { access_token: session.accessToken, is_pat: false };
// break;
// }
const token = { github: { access_token: session.accessToken, is_pat: false } };
return Promise.resolve(token);
}
async getWorkspaces(): Promise<WorkspacesResponse | undefined> {
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
const rsp = await this.server.fetchGraphql(
{
query: `
query getWorkspaces {
projects(first: 100) {
total_count
page_info {
start_cursor
has_next_page
end_cursor
}
nodes {
id
name
provider
}
}
}
`,
},
accessToken,
);
if (!rsp.ok) {
Logger.error(undefined, `Getting workspaces failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const json: WorkspacesResponse | undefined = await rsp.json();
return json;
}
async getPullRequests(workspace: Workspace): Promise<PullRequestsResponse | undefined> {
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
const query = `
query getPullRequestsForWorkspace(
$workspaceId: String
) {
project(id: $workspaceId) {
provider
provider_data {
pull_requests(first: 100) {
nodes {
id
title
number
author_username
comment_count
created_date
repository {
id
name
provider_organization_id
}
head_commit {
build_status {
context
state
description
}
}
head {
name
}
url
}
is_fetching
page_info {
end_cursor
has_next_page
}
}
}
}
}
`;
const init: RequestInit = {};
const externalTokens = await this.getProviderCredentials(workspace.provider.toUpperCase() as WorkspaceProvider);
if (externalTokens != null) {
init.headers = {
'External-Tokens': JSON.stringify(externalTokens),
};
}
const rsp = await this.server.fetchGraphql(
{
query: query,
variables: {
workspaceId: workspace.id,
},
},
accessToken,
init,
);
if (!rsp.ok) {
Logger.error(undefined, `Getting pull requests failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
let json: PullRequestsResponse | undefined = await rsp.json();
if (json?.data.project.provider_data.pull_requests.is_fetching === true) {
await new Promise(resolve => setTimeout(resolve, 200));
json = await this.getPullRequests(workspace);
}
return json;
}
async getIssues(workspace: Workspace): Promise<IssuesResponse | undefined> {
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
const query = `
query getIssuesForWorkspace($projectId: String) {
project(id: $projectId) {
provider
provider_data {
issues(first: 100) {
nodes {
id
title
assignee_ids
author_id
comment_count
created_date
issue_type
label_ids
node_id
repository {
id
name
provider_organization_id
}
updated_date
milestone_id
url
}
is_fetching
page_info {
end_cursor
has_next_page
}
}
}
}
}
`;
const init: RequestInit = {};
const externalTokens = await this.getProviderCredentials(workspace.provider.toUpperCase() as WorkspaceProvider);
if (externalTokens != null) {
init.headers = {
'External-Tokens': JSON.stringify(externalTokens),
};
}
const rsp = await this.server.fetchGraphql(
{
query: query,
variables: {
workspaceId: workspace.id,
},
},
accessToken,
init,
);
if (!rsp.ok) {
Logger.error(undefined, `Getting pull requests failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
let json: IssuesResponse | undefined = await rsp.json();
if (json?.data.project.provider_data.issues.is_fetching === true) {
await new Promise(resolve => setTimeout(resolve, 200));
json = await this.getIssues(workspace);
}
return json;
}
async getWorkspacesWithPullRequests(): Promise<WorkspacesWithPullRequestsResponse | undefined> {
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
const query = `
query getPullRequestsForAllWorkspaces {
projects(first: 100) {
total_count
page_info {
start_cursor
has_next_page
end_cursor
}
nodes {
id
name
provider
provider_data {
pull_requests(first: 100) {
nodes {
id
title
number
author_username
comment_count
created_date
repository {
id
name
provider_organization_id
}
head_commit {
build_status {
context
state
description
}
}
head {
name
}
url
}
is_fetching
page_info {
end_cursor
has_next_page
}
total_count
}
}
}
}
}
`;
const init: RequestInit = {};
const externalTokens = await this.getProviderCredentials('github' as WorkspaceProvider);
if (externalTokens != null) {
init.headers = {
'External-Tokens': JSON.stringify(externalTokens),
};
}
const rsp = await this.server.fetchGraphql(
{
query: query,
},
accessToken,
init,
);
if (!rsp.ok) {
Logger.error(undefined, `Getting pull requests failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
let json: WorkspacesWithPullRequestsResponse | undefined = await rsp.json();
if (json?.errors != null && json.errors.length > 0) {
const error = json.errors[0];
Logger.error(undefined, `Getting pull requests failed: (${error.statusCode}) ${error.message}`);
throw new Error(error.message);
}
if (json?.data.projects.nodes[0].provider_data.pull_requests.is_fetching === true) {
await new Promise(resolve => setTimeout(resolve, 200));
json = await this.getWorkspacesWithPullRequests();
}
return json;
}
async createWorkspace(): Promise<void> {}
async ensureWorkspace(): Promise<void> {}
}

+ 7
- 0
src/webviews/apps/plus/workspaces/workspaces.ts View File

@ -14,6 +14,13 @@ export class WorkspacesApp extends App {
constructor() {
super('WorkspacesApp');
}
override onInitialize() {
this.log(`${this.appName}.onInitialize`);
this.renderContent();
}
renderContent() {}
}
new WorkspacesApp();

Loading…
Cancel
Save