diff --git a/src/container.ts b/src/container.ts index 417042d..31da0b2 100644 --- a/src/container.ts +++ b/src/container.ts @@ -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; diff --git a/src/env/browser/fetch.ts b/src/env/browser/fetch.ts index 63b79b0..ed131b4 100644 --- a/src/env/browser/fetch.ts +++ b/src/env/browser/fetch.ts @@ -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; diff --git a/src/env/node/fetch.ts b/src/env/node/fetch.ts index 0a8e136..bbaa4a6 100644 --- a/src/env/node/fetch.ts +++ b/src/env/node/fetch.ts @@ -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; diff --git a/src/git/remotes/richRemoteProvider.ts b/src/git/remotes/richRemoteProvider.ts index 44662d2..3be8c6d 100644 --- a/src/git/remotes/richRemoteProvider.ts +++ b/src/git/remotes/richRemoteProvider.ts @@ -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); } diff --git a/src/plus/subscription/serverConnection.ts b/src/plus/subscription/serverConnection.ts index 3d35b2c..f8ad6cb 100644 --- a/src/plus/subscription/serverConnection.ts +++ b/src/plus/subscription/serverConnection.ts @@ -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; +} + export class ServerConnection implements Disposable { private _cancellationSource: CancellationTokenSource | undefined; private _deferredCodeExchanges = new Map>(); @@ -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 { + 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; + } + } } diff --git a/src/plus/webviews/workspaces/workspacesWebview.ts b/src/plus/webviews/workspaces/workspacesWebview.ts index 7b9c9cf..0f5b5f6 100644 --- a/src/plus/webviews/workspaces/workspacesWebview.ts +++ b/src/plus/webviews/workspaces/workspacesWebview.ts @@ -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 { + return Promise.resolve({ + workspaces: this.getWorkspaces(), + }); + } + + protected override async includeBootstrap(): Promise { + return this.getState(); + } } diff --git a/src/plus/workspaces/models.ts b/src/plus/workspaces/models.ts new file mode 100644 index 0000000..5b091d2 --- /dev/null +++ b/src/plus/workspaces/models.ts @@ -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; +type ProjectGoalsSettings = Record; + +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; + +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; +type Label = Record; +type IssueType = Record; + +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 { + total_count: number; + page_info: { + start_cursor: string; + end_cursor: string; + has_next_page: boolean; + }; + nodes: i[]; +} + +interface FetchedConnection extends Connection { + is_fetching: boolean; +} + +export interface WorkspacesResponse { + data: { + projects: Connection; + }; +} + +export interface PullRequestsResponse { + data: { + project: { + provider_data: { + pull_requests: FetchedConnection; + }; + }; + }; +} + +export interface WorkspacesWithPullRequestsResponse { + data: { + projects: { + nodes: { + provider_data: { + pull_requests: FetchedConnection; + }; + }[]; + }; + }; + errors?: { + message: string; + path: unknown[]; + statusCode: number; + }[]; +} + +export interface IssuesResponse { + data: { + project: { + provider_data: { + issues: FetchedConnection; + }; + }; + }; +} diff --git a/src/plus/workspaces/workspaces.ts b/src/plus/workspaces/workspaces.ts new file mode 100644 index 0000000..53a12d2 --- /dev/null +++ b/src/plus/workspaces/workspaces.ts @@ -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 | undefined> { + const remotes: GitRemote[] = []; + 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 { + const remote = await this.getRichProvider(); + let session = remote?.provider.session(); + if (session == null) { + return undefined; + } + + if ((session as Promise).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 { + 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 { + 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 { + 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 { + 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 {} + + async ensureWorkspace(): Promise {} +} diff --git a/src/webviews/apps/plus/workspaces/workspaces.ts b/src/webviews/apps/plus/workspaces/workspaces.ts index 53fa39b..7ea8bd7 100644 --- a/src/webviews/apps/plus/workspaces/workspaces.ts +++ b/src/webviews/apps/plus/workspaces/workspaces.ts @@ -14,6 +14,13 @@ export class WorkspacesApp extends App { constructor() { super('WorkspacesApp'); } + + override onInitialize() { + this.log(`${this.appName}.onInitialize`); + this.renderContent(); + } + + renderContent() {} } new WorkspacesApp();