├── src ├── assets │ ├── images │ │ ├── logo.png │ │ ├── icon-dark.png │ │ └── icon-light.png │ ├── screenshots │ │ ├── hub_tab.png │ │ ├── widget.png │ │ ├── hub_view.png │ │ ├── settings.png │ │ ├── add_widget.png │ │ ├── repo_filter.png │ │ └── widget_config.png │ └── css │ │ └── styles.css ├── app │ ├── app.ts │ ├── limit.pipe.ts │ ├── repoFilter.pipe.ts │ ├── ng-var.directive.ts │ ├── prSort.pipe.ts │ ├── pullRequestFilter.pipe.ts │ ├── appSettingsService.provider.ts │ ├── localAppSettings.service.ts │ ├── app.module.ts │ ├── tfsAppSettings.service.ts │ ├── tfsService.provider.ts │ ├── pullRequest.component.html │ ├── pullRequestViewModel.ts │ ├── app.component.html │ ├── pullRequest.component.ts │ ├── model.ts │ ├── extensionsApiTfs.service.ts │ ├── app.component.ts │ ├── appConfig.service.ts │ └── restfulTfs.service.ts ├── index.html ├── system.config.js ├── configuration.html ├── manifest.json ├── manifest-dev.json └── overview.md ├── .gitignore ├── typings ├── index.d.ts ├── globals │ ├── node │ │ └── typings.json │ ├── karma │ │ ├── typings.json │ │ └── index.d.ts │ ├── jasmine │ │ ├── typings.json │ │ └── index.d.ts │ └── es6-shim │ │ └── typings.json └── modules │ ├── log4js │ ├── typings.json │ └── index.d.ts │ ├── express │ ├── typings.json │ └── index.d.ts │ └── express-serve-static-core │ └── typings.json ├── tslint.json ├── tsconfig.json ├── typings.json ├── .vscode └── launch.json ├── LICENSE ├── test ├── limit.pipe.spec.ts ├── prSort.pipe.spec.ts ├── repoFilter.pipe.spec.ts ├── app.component.spec.ts ├── testHelpers.ts ├── pullRequestViewModel.spec.ts ├── pullRequestFilter.pipe.spec.ts └── extensionsApiTfs.service.spec.ts ├── readme.md ├── package.json ├── karma-test-shim.js ├── karma.conf.js └── gulpfile.js /src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/images/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-workspace 2 | npm-debug.log 3 | node_modules/ 4 | build/ 5 | dist/ 6 | src/app/multiselect-dropdown.ts 7 | -------------------------------------------------------------------------------- /src/assets/images/icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/images/icon-dark.png -------------------------------------------------------------------------------- /src/assets/images/icon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/images/icon-light.png -------------------------------------------------------------------------------- /src/assets/screenshots/hub_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/screenshots/hub_tab.png -------------------------------------------------------------------------------- /src/assets/screenshots/widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/screenshots/widget.png -------------------------------------------------------------------------------- /src/assets/screenshots/hub_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/screenshots/hub_view.png -------------------------------------------------------------------------------- /src/assets/screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/screenshots/settings.png -------------------------------------------------------------------------------- /src/assets/screenshots/add_widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/screenshots/add_widget.png -------------------------------------------------------------------------------- /src/assets/screenshots/repo_filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/screenshots/repo_filter.png -------------------------------------------------------------------------------- /src/assets/screenshots/widget_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstedman/tfs-pullrequest-dashboard/HEAD/src/assets/screenshots/widget_config.png -------------------------------------------------------------------------------- /src/app/app.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { enableProdMode } from "@angular/core"; 4 | import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; 5 | 6 | import { AppModule } from "./app.module"; 7 | 8 | enableProdMode(); 9 | platformBrowserDynamic().bootstrapModule(AppModule); 10 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | /// 7 | /// 8 | -------------------------------------------------------------------------------- /typings/globals/node/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolution": "main", 3 | "tree": { 4 | "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/a4a912a0cd1849fa7df0e5d909c8625fba04e49d/node/index.d.ts", 5 | "raw": "registry:dt/node#7.0.0+20170322231424", 6 | "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/a4a912a0cd1849fa7df0e5d909c8625fba04e49d/node/index.d.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typings/globals/karma/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolution": "main", 3 | "tree": { 4 | "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/568ec4378d010bb901a61bd9686be49077e54d5f/karma/karma.d.ts", 5 | "raw": "registry:dt/karma#0.13.9+20160811111312", 6 | "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/568ec4378d010bb901a61bd9686be49077e54d5f/karma/karma.d.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typings/modules/log4js/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolution": "main", 3 | "tree": { 4 | "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/ede3f0a99c502f0f752d56122c768473fc2559e8/log4js/index.d.ts", 5 | "raw": "registry:dt/log4js#0.0.0+20160726182927", 6 | "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/ede3f0a99c502f0f752d56122c768473fc2559e8/log4js/index.d.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typings/globals/jasmine/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolution": "main", 3 | "tree": { 4 | "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/db75c9ca79883dd7e5c7058ba5ec07207e5197c2/jasmine/index.d.ts", 5 | "raw": "registry:dt/jasmine#2.5.2+20170317130948", 6 | "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/db75c9ca79883dd7e5c7058ba5ec07207e5197c2/jasmine/index.d.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typings/modules/express/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolution": "main", 3 | "tree": { 4 | "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/fefb1124c3ed05e84878d555dcbcbf16d27f2ec4/express/index.d.ts", 5 | "raw": "registry:dt/express#4.0.0+20170118060322", 6 | "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/fefb1124c3ed05e84878d555dcbcbf16d27f2ec4/express/index.d.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /typings/globals/es6-shim/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolution": "main", 3 | "tree": { 4 | "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/5d004be8ba9a0c34e387c249f6873dc68e20ebfa/es6-shim/index.d.ts", 5 | "raw": "registry:dt/es6-shim#0.31.2+20160726072212", 6 | "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/5d004be8ba9a0c34e387c249f6873dc68e20ebfa/es6-shim/index.d.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/limit.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Pipe, PipeTransform} from "@angular/core"; 2 | 3 | @Pipe({ 4 | name: "limit", 5 | pure: false 6 | }) 7 | @Injectable() 8 | export class LimitPipe implements PipeTransform { 9 | public transform(items: any[], limit: number): any[] { 10 | if (limit <= 0 || items.length < limit) { 11 | return items; 12 | } 13 | return items.slice(0, limit); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "object-literal-sort-keys": false, 5 | "max-line-length": false, 6 | "trailing-comma": false, 7 | "no-reference": false, 8 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], 9 | "interface-name": false, 10 | "max-classes-per-file": false, 11 | "no-console": false, 12 | "import-sources-order": "any" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /typings/modules/express-serve-static-core/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolution": "main", 3 | "tree": { 4 | "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/919eca848c1746d54725f6e59ca3396e88a94a98/express-serve-static-core/index.d.ts", 5 | "raw": "registry:dt/express-serve-static-core#4.0.0+20170324160323", 6 | "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/919eca848c1746d54725f6e59ca3396e88a94a98/express-serve-static-core/index.d.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/repoFilter.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Pipe, PipeTransform} from "@angular/core"; 2 | import {PullRequestViewModel} from "./pullRequestViewModel"; 3 | 4 | @Pipe({ 5 | name: "repoFilter", 6 | pure: false 7 | }) 8 | @Injectable() 9 | export class RepoFilterPipe implements PipeTransform { 10 | public transform(items: PullRequestViewModel[], filteredRepoIds: string[]): PullRequestViewModel[] { 11 | return items.filter((x) => filteredRepoIds.indexOf(x.repository.id) < 0); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "sourceMap": true, 7 | "noImplicitAny": false, 8 | "module": "system", 9 | "moduleResolution": "node", 10 | "noEmitOnError": true, 11 | "pretty": true, 12 | "types": [] 13 | }, 14 | "exclude": [ 15 | "node_modules/", 16 | "build/" 17 | ], 18 | "filesGlob": [ 19 | "src/**/*.ts", 20 | "typings/index.d.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "globalDependencies": { 3 | "es6-shim": "registry:dt/es6-shim#0.31.2+20160726072212", 4 | "jasmine": "registry:dt/jasmine#2.5.2+20170317130948", 5 | "karma": "registry:dt/karma#0.13.9+20160811111312", 6 | "node": "registry:dt/node#7.0.0+20170322231424" 7 | }, 8 | "dependencies": { 9 | "express": "registry:dt/express#4.0.0+20170118060322", 10 | "express-serve-static-core": "registry:dt/express-serve-static-core#4.0.0+20170324160323", 11 | "log4js": "registry:dt/log4js#0.0.0+20160726182927" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:8080", 12 | "webRoot": "${workspaceFolder}/src", 13 | "sourceMaps": true, 14 | "trace": true, 15 | "runtimeArgs": ["--disable-web-security"] 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /src/app/ng-var.directive.ts: -------------------------------------------------------------------------------- 1 | import { Component, Directive, Input, NgModule, TemplateRef, ViewContainerRef } from "@angular/core"; 2 | 3 | // source: https://stackoverflow.com/a/43172992 4 | @Directive({ 5 | selector: "[ngVar]", 6 | }) 7 | export class VarDirective { 8 | @Input() 9 | public set ngVar(context: any) { 10 | this.context.$implicit = this.context.ngVar = context; 11 | this.updateView(); 12 | } 13 | 14 | public context: any = {}; 15 | 16 | constructor(private vcRef: ViewContainerRef, private templateRef: TemplateRef) {} 17 | 18 | public updateView() { 19 | this.vcRef.clear(); 20 | this.vcRef.createEmbeddedView(this.templateRef, this.context); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/prSort.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Pipe, PipeTransform} from "@angular/core"; 2 | import { PullRequestViewModel } from "./pullRequestViewModel"; 3 | 4 | @Pipe({ 5 | name: "prSort", 6 | pure: false 7 | }) 8 | @Injectable() 9 | export class PullRequestSortPipe implements PipeTransform { 10 | public transform(items: PullRequestViewModel[]): PullRequestViewModel[] { 11 | return items.sort((a: PullRequestViewModel, b: PullRequestViewModel) => { 12 | if (a.createdDate > b.createdDate) { 13 | return 1; 14 | } 15 | if (a.createdDate < b.createdDate) { 16 | return -1; 17 | } 18 | return 0; 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/pullRequestFilter.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, Pipe, PipeTransform} from "@angular/core"; 2 | import { PullRequestViewModel } from "./pullRequestViewModel"; 3 | 4 | @Pipe({ 5 | name: "prFilter", 6 | pure: false 7 | }) 8 | @Injectable() 9 | export class PullRequestFilterPipe implements PipeTransform { 10 | public transform(items: PullRequestViewModel[], arg: string): PullRequestViewModel[] { 11 | return items.filter((x) => { 12 | return (arg === "requestedByMe" && x.requestedByMe) || 13 | (arg === "assignedToMe" && x.assignedToMe && !x.requestedByMe) || 14 | (arg === "assignedToMyTeam" && x.assignedToMyTeam && !x.requestedByMe && !x.assignedToMe) || 15 | (arg === "other" && !x.requestedByMe && !x.assignedToMe && !x.assignedToMyTeam) || 16 | (arg === "all"); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ryan Stedman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/app/appSettingsService.provider.ts: -------------------------------------------------------------------------------- 1 | import { FactoryProvider, Injectable, NgZone } from "@angular/core"; 2 | 3 | import { AppConfigService } from "./appConfig.service"; 4 | import { LocalAppSettingsService } from "./localAppSettings.service"; 5 | import { AppSettingsService } from "./model"; 6 | import { TfsAppSettingsService } from "./tfsAppSettings.service"; 7 | 8 | // factory provider for the tfsservice, which switches the backend provider based on if it's using tfs online, 9 | // an on-prem service 10 | @Injectable() 11 | export class AppSettingsServiceProvider implements FactoryProvider { 12 | public provide = AppSettingsService; 13 | 14 | public deps = [NgZone, AppConfigService]; 15 | 16 | public useFactory(zone: NgZone, config: AppConfigService): AppSettingsService { 17 | // If we aren't running as a VSS extension, use the LocalStorageService 18 | if (config.devMode) { 19 | return new LocalAppSettingsService(zone); 20 | } else { 21 | const res = new TfsAppSettingsService(config.extensionDataService, VSS.getWebContext(), zone, config.layout); 22 | config.layoutChanged.on((data) => res.setLayout(data)); 23 | return res; 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/app/localAppSettings.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, NgZone} from "@angular/core"; 2 | 3 | import {AppSettingsService, Layout} from "./model"; 4 | 5 | @Injectable() 6 | export class LocalAppSettingsService extends AppSettingsService { 7 | 8 | private static _layout: Layout = { 9 | categories: [{ 10 | key: "requestedByMe", name: "Requested By Me" 11 | }, 12 | { 13 | key: "assignedToMe", name: "Assigned To Me" 14 | }, 15 | { 16 | key: "assignedToMyTeam", name: "Assigned To My Team" 17 | }], 18 | widgetMode: false 19 | }; 20 | 21 | private static _widgetLayout: Layout = { 22 | categories: [{key: "all", name: "All Pull Requests"}], 23 | widgetMode: true, 24 | size: { 25 | columnSpan: 3, 26 | rowSpan: 2 27 | } 28 | }; 29 | 30 | constructor(zone: NgZone) { 31 | super(LocalAppSettingsService._layout, zone); 32 | } 33 | 34 | protected getValue(key: string): Promise { 35 | const value = localStorage.getItem(key); 36 | return Promise.resolve(value); 37 | } 38 | 39 | protected setValue(key: string, value: string): Promise { 40 | localStorage.setItem(key, value); 41 | return Promise.resolve(value); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TFS Pull Requests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Loading... 25 | 26 | 27 | 28 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test/limit.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import {LimitPipe} from "../src/app/limit.pipe"; 2 | 3 | describe("PullRequestSortPipe", () => { 4 | 5 | let subject: LimitPipe = null; 6 | 7 | beforeEach(() => { 8 | subject = new LimitPipe(); 9 | }); 10 | 11 | it("does nothing with an empty list", () => { 12 | const result = subject.transform([], 10); 13 | expect(result).toBeDefined(); 14 | expect(result.length).toEqual(0); 15 | }); 16 | 17 | it("returns the source list if limit is non-positive", () => { 18 | const source = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]; 19 | const result = subject.transform(source, -1); 20 | 21 | expect(result).toBeDefined(); 22 | expect(result).toEqual(source); 23 | }); 24 | 25 | it("returns the source list if limit is greater than number of elements", () => { 26 | const source = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]; 27 | const result = subject.transform(source, 20); 28 | 29 | expect(result).toBeDefined(); 30 | expect(result).toEqual(source); 31 | }); 32 | 33 | it("returns a the first N elements of a list", () => { 34 | const source = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]; 35 | const result = subject.transform(source, 5); 36 | 37 | expect(result).toBeDefined(); 38 | expect(result).toEqual([1, 2, 3, 4, 5]); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { APP_INITIALIZER, NgModule } from "@angular/core"; 2 | import { FormsModule } from "@angular/forms"; 3 | import { HttpModule } from "@angular/http"; 4 | import { BrowserModule } from "@angular/platform-browser"; 5 | 6 | import { MultiselectDropdownModule } from "angular-2-dropdown-multiselect"; 7 | 8 | import { AppComponent } from "./app.component"; 9 | import { AppConfigService } from "./appConfig.service"; 10 | import { LimitPipe } from "./limit.pipe"; 11 | import { VarDirective } from "./ng-var.directive"; 12 | import { PullRequestSortPipe } from "./prSort.pipe"; 13 | import { PullRequestComponent } from "./pullRequest.component"; 14 | import { PullRequestFilterPipe } from "./pullRequestFilter.pipe"; 15 | import { RepoFilterPipe } from "./repoFilter.pipe"; 16 | 17 | @NgModule({ 18 | imports: [ 19 | HttpModule, 20 | FormsModule, 21 | BrowserModule, 22 | MultiselectDropdownModule 23 | ], 24 | declarations: [ 25 | AppComponent, 26 | PullRequestComponent, 27 | PullRequestFilterPipe, 28 | RepoFilterPipe, 29 | PullRequestSortPipe, 30 | VarDirective, 31 | LimitPipe 32 | ], 33 | providers: [ 34 | AppConfigService, 35 | { 36 | provide: APP_INITIALIZER, 37 | useFactory: (configService: AppConfigService) => (() => configService.initialize()), 38 | deps: [AppConfigService], 39 | multi: true 40 | } 41 | ], 42 | bootstrap: [ AppComponent ] 43 | }) 44 | export class AppModule { 45 | } 46 | -------------------------------------------------------------------------------- /src/app/tfsAppSettings.service.ts: -------------------------------------------------------------------------------- 1 | import {NgZone} from "@angular/core"; 2 | 3 | import {AppSettingsService, Layout} from "./model"; 4 | 5 | export class TfsAppSettingsService extends AppSettingsService { 6 | 7 | constructor(private storageService: IExtensionDataService, 8 | private webContext: WebContext, 9 | zone: NgZone, 10 | layout: Layout) { 11 | super(layout, zone); 12 | } 13 | 14 | public getHubUri(): string { 15 | return `${this.webContext.host.uri}/${this.webContext.project.name}/_apps/hub/ryanstedman.tfs-pullrequest-dashboard.tfs-pullrequest-dashboard`; 16 | } 17 | 18 | protected getValue(key: string): Promise { 19 | return new Promise((resolve, reject) => 20 | this.storageService.getValue(key, {scopeType: "User", defaultValue: null}) 21 | .then((x) => 22 | // the IExtensionData calls run outside of the angular zone. 23 | // Make the result callback run back into the angular zone 24 | this.zone.run(() => resolve(x)))); 25 | } 26 | 27 | protected setValue(key: string, value: string): Promise { 28 | return new Promise((resolve, reject) => 29 | this.storageService.setValue(key, value, {scopeType: "User"}) 30 | .then((x) => 31 | // the IExtensionData calls run outside of the angular zone. 32 | // Make the result callback run back into the angular zone 33 | this.zone.run(() => resolve(x)))); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # TFS PullRequest Dashboard 2 | ## What is it? 3 | An extension for visual studio team services that adds a hub to the code section of tfs for viewing pull requests across all repositories. To see specific details about features, including screenshots, see [overview.md](src/overview.md) 4 | 5 | ## Getting started 6 | 7 | ### Running Locally 8 | 9 | To develop and run this project locally, you need to make changes src/app/appConfig.service.ts. Uncomment the commented out lines near the top that set AppConfigService._devMode & AppConfigService._devApiEndpoint. You will need to change _devApiEndpoint to a tfs collection uri that you wish to run against (ex. 'https://myname.visualstudio.com/' if running against visual studio online, or 'http://mytfsserver:8080/tfs/DefaultCollection' if running against an on-prem server). 10 | 11 | With the local changes made, run: 12 | 13 | `npm install && npm run start` 14 | 15 | This will serve the dashboard page at localhost:8080/. One caveat is that requests to tfs apis have cross-origin resource sharing (CORS) headers in their repsonses, which means that browsers such as chrome and firefox will not automatically handle authentication with the endpoints, and requests will fail. To work around this issue, either use IE (which does not have this CORS restriction), or run chrome without web security. See for instructions. 16 | 17 | ### Packaging 18 | 19 | To package a new vsix installer for the extension, run: 20 | 21 | `npm install && npm run package` 22 | 23 | ## License 24 | [MIT](LICENSE) 25 | -------------------------------------------------------------------------------- /src/app/tfsService.provider.ts: -------------------------------------------------------------------------------- 1 | import { FactoryProvider, Injectable, NgZone } from "@angular/core"; 2 | import { Http } from "@angular/http"; 3 | 4 | import { AppConfigService } from "./appConfig.service"; 5 | import { ExtensionsApiTfsService } from "./extensionsApiTfs.service"; 6 | import { TfsService } from "./model"; 7 | import { RestfulTfsService } from "./restfulTfs.service"; 8 | 9 | // factory provider for the tfsservice, which switches the backend provider based on if it's using tfs online, 10 | // an on-prem service. 11 | @Injectable() 12 | export class TfsServiceProvider implements FactoryProvider { 13 | public provide = TfsService; 14 | 15 | public deps = [Http, AppConfigService, NgZone]; 16 | 17 | public useFactory(http: Http, config: AppConfigService, zone: NgZone): TfsService { 18 | // If we aren't running as a VSS extension, use the restfultfsservice 19 | if (config.devMode) { 20 | return new RestfulTfsService(http, config); 21 | } else { 22 | const gitClient = config.gitClientFactory.getClient(); 23 | const identitiesClient = config.identitiesClientFactory.getClient(); 24 | const coreClient = config.coreClientFactory.getClient(); 25 | const context = config.context; 26 | const isHosted = context.getPageContext().webAccessConfiguration.isHosted; 27 | const user = context.getPageContext().webContext.user; 28 | const projectName = context.getPageContext().webContext.project.name; 29 | return new ExtensionsApiTfsService(gitClient, identitiesClient, coreClient, isHosted, projectName, user, zone); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/system.config.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | 3 | var paths = { 4 | 'npm:': 'node_modules/' 5 | }; 6 | 7 | // map tells the System loader where to look for things 8 | var map = { 9 | 'app': 'app', 10 | // angular bundles 11 | '@angular/core': 'npm:@angular/core/bundles/core.umd.js', 12 | '@angular/common': 'npm:@angular/common/bundles/common.umd.js', 13 | '@angular/compiler': 'npm:@angular/compiler/bundles/compiler.umd.js', 14 | '@angular/platform-browser': 'npm:@angular/platform-browser/bundles/platform-browser.umd.js', 15 | '@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js', 16 | '@angular/http': 'npm:@angular/http/bundles/http.umd.js', 17 | '@angular/router': 'npm:@angular/router/bundles/router.umd.js', 18 | '@angular/forms': 'npm:@angular/forms/bundles/forms.umd.js', 19 | '@angular/upgrade': 'npm:@angular/upgrade/bundles/upgrade.umd.js', 20 | 'rxjs': 'npm:rxjs', 21 | 'typescript': 'npm:typescript', 22 | 'ts': 'npm:plugin-typescript/lib', 23 | 'angular-2-dropdown-multiselect': 'npm:angular-2-dropdown-multiselect/bundles/dropdown.umd.js' 24 | }; 25 | // packages tells the System loader how to load when no filename and/or no extension 26 | var packages = { 27 | 'app': { main: 'app.ts', defaultExtension: 'ts' }, 28 | 'rxjs': { defaultExtension: 'js' }, 29 | 'ts': { 'main': 'plugin.js'}, 30 | "typescript": { 31 | "main": "lib/typescript.js", 32 | "meta": { 33 | "lib/typescript.js": { 34 | "exports": "ts" 35 | } 36 | } 37 | } 38 | }; 39 | 40 | var config = { 41 | transpiler: 'ts', 42 | typescriptOptions: { 43 | tsconfig: true, 44 | }, 45 | paths: paths, 46 | map: map, 47 | packages: packages, 48 | meta: { 49 | 'TFS/*': {build:false} 50 | } 51 | }; 52 | System.config(config); 53 | })(this); 54 | -------------------------------------------------------------------------------- /src/app/pullRequest.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 | 9 |
10 | #{{pullRequest.id}} by {{pullRequest.createdBy}} at {{pullRequest.createdDate | date: dateFormat}} into {{pullRequest.repositoryName}} {{pullRequest.targetRefName}} 11 |
12 |
13 | {{tag.name}} 14 |
15 |
16 | , {{reviewer.isRequired ? reviewer.displayName + '*' : reviewer.displayName}} 17 |
18 |
19 |
20 | into {{pullRequest.repositoryName}} {{pullRequest.targetRefName}} 21 |
22 |
23 | , x{{group.count}} 24 |
25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tfs-pullrequest-dashboard", 3 | "verson": "0.1.0", 4 | "description": "A dashboard for tfs pull requests", 5 | "homepage": "https://github.com/rstedman/tfs-pullrequest-dashboard", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/rstedman/tfs-pullrequest-dashboard" 9 | }, 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "gulp build", 13 | "watch": "gulp watch", 14 | "start": "gulp serve", 15 | "package": "gulp package" 16 | }, 17 | "author": "Ryan Stedman", 18 | "dependencies": { 19 | "@angular/common": "4.3.2", 20 | "@angular/compiler": "4.3.2", 21 | "@angular/core": "4.3.2", 22 | "@angular/forms": "4.3.2", 23 | "@angular/http": "4.3.2", 24 | "@angular/platform-browser": "4.3.2", 25 | "@angular/platform-browser-dynamic": "4.3.2", 26 | "@angular/router": "4.3.2", 27 | "@angular/upgrade": "4.3.2", 28 | "angular-2-dropdown-multiselect": "1.6.0", 29 | "es6-shim": "0.35.3", 30 | "reflect-metadata": "0.1.10", 31 | "rxjs": "5.5.12", 32 | "systemjs": "0.21.4", 33 | "typescript": "2.4.2", 34 | "vss-web-extension-sdk": "5.141.0", 35 | "zone.js": "0.8.16" 36 | }, 37 | "devDependencies": { 38 | "azure-devops-extension-api": "1.157.0", 39 | "azure-devops-extension-sdk": "2.0.11", 40 | "del": "3.0.0", 41 | "gulp": "3.9.1", 42 | "gulp-angular2-embed-templates": "2.2.1", 43 | "gulp-concat": "2.6.1", 44 | "gulp-connect": "5.7.0", 45 | "gulp-html-replace": "1.6.2", 46 | "gulp-live-server": "0.0.31", 47 | "gulp-rename": "1.2.2", 48 | "gulp-run": "1.7.1", 49 | "gulp-sourcemaps": "2.6.0", 50 | "gulp-tslint": "8.1.1", 51 | "gulp-typescript": "3.2.1", 52 | "gulp-uglify": "3.0.0", 53 | "gulp-replace": "0.6.1", 54 | "jasmine": "2.7.0", 55 | "karma": "1.7.0", 56 | "karma-chrome-launcher": "2.2.0", 57 | "karma-cli": "1.0.1", 58 | "karma-htmlfile-reporter": "0.3.5", 59 | "karma-jasmine": "1.1.0", 60 | "karma-jasmine-html-reporter": "0.2.2", 61 | "plugin-typescript": "7.0.6", 62 | "run-sequence": "2.1.0", 63 | "systemjs-builder": "0.16.9", 64 | "tfx-cli": "0.7.8", 65 | "tslint": "5.5.0", 66 | "typings": "2.1.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/prSort.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import {PullRequestAsyncStatus, User} from "../src/app/model"; 2 | import {PullRequestViewModel} from "../src/app/pullRequestViewModel"; 3 | import {PullRequestSortPipe} from "../src/app/prSort.pipe"; 4 | import {TestUtils} from "./testHelpers"; 5 | 6 | describe("PullRequestSortPipe", () => { 7 | 8 | function createPRViewModel(createdDate: Date): PullRequestViewModel { 9 | const user: User = { 10 | id: "123", 11 | displayName: "test user", 12 | uniqueName: "testuser1", 13 | memberOf: [] 14 | }; 15 | const repo: GitRepository = { 16 | _links: { 17 | web: { 18 | href: "http://git/repo" 19 | } 20 | }, 21 | defaultBranch: "master", 22 | url: "http://git/repo", 23 | id: "repo", 24 | name: "repo", 25 | project: null, 26 | remoteUrl: "http://git/repo" 27 | }; 28 | const pr = TestUtils.createPullRequest({ 29 | created: createdDate, 30 | createdById: "user1", 31 | id: 1, 32 | mergeStatus: PullRequestAsyncStatus.Succeeded, 33 | sourceBranch: "testbranch", 34 | targetBranch: "master", 35 | title: "test123", 36 | reviewers: [] 37 | }); 38 | return new PullRequestViewModel(pr, repo, user); 39 | } 40 | 41 | let subject: PullRequestSortPipe = null; 42 | 43 | beforeEach(() => { 44 | subject = new PullRequestSortPipe(); 45 | }); 46 | 47 | it("does nothing with an empty list", () => { 48 | const result = subject.transform([]); 49 | expect(result).toBeDefined(); 50 | expect(result.length).toEqual(0); 51 | }); 52 | 53 | it("sorts prs oldest first", () => { 54 | const pr1 = createPRViewModel(new Date(2016, 5, 12, 8, 30)); 55 | const pr2 = createPRViewModel(new Date(2016, 5, 12, 8, 15)); 56 | const pr3 = createPRViewModel(new Date(2016, 5, 4, 8, 15)); 57 | const result = subject.transform([pr1, pr2, pr3]); 58 | 59 | expect(result).toBeDefined(); 60 | expect(result.length).toEqual(3); 61 | expect(result[0]).toEqual(pr3); 62 | expect(result[1]).toEqual(pr2); 63 | expect(result[2]).toEqual(pr1); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /karma-test-shim.js: -------------------------------------------------------------------------------- 1 | // #docregion 2 | // /*global jasmine, __karma__, window*/ 3 | Error.stackTraceLimit = 0; // "No stacktrace"" is usually best for app testing. 4 | 5 | // Uncomment to get full stacktrace output. Sometimes helpful, usually not. 6 | // Error.stackTraceLimit = Infinity; // 7 | 8 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000; 9 | 10 | __karma__.loaded = function () { }; 11 | 12 | function isSpecFile(path) { 13 | return /\.spec\.(.*\.)?ts$/.test(path); 14 | } 15 | 16 | var allSpecFiles = Object.keys(window.__karma__.files) 17 | .filter(isSpecFile); 18 | 19 | System.config({ 20 | baseURL: 'base', 21 | packages: { 22 | 'src/app': { defaultExtension: 'ts' } , 23 | 'test': { defaultExtension: 'ts' } 24 | }, 25 | 26 | // Assume npm: is set in `paths` in systemjs.config 27 | // Map the angular testing umd bundles 28 | map: { 29 | '@angular/core/testing': 'npm:@angular/core/bundles/core-testing.umd.js', 30 | '@angular/common/testing': 'npm:@angular/common/bundles/common-testing.umd.js', 31 | '@angular/compiler/testing': 'npm:@angular/compiler/bundles/compiler-testing.umd.js', 32 | '@angular/platform-browser/testing': 'npm:@angular/platform-browser/bundles/platform-browser-testing.umd.js', 33 | '@angular/platform-browser-dynamic/testing': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic-testing.umd.js', 34 | '@angular/http/testing': 'npm:@angular/http/bundles/http-testing.umd.js', 35 | '@angular/router/testing': 'npm:@angular/router/bundles/router-testing.umd.js', 36 | '@angular/forms/testing': 'npm:@angular/forms/bundles/forms-testing.umd.js', 37 | }, 38 | }); 39 | 40 | 41 | System.import('src/system.config.js') 42 | .then(initTestBed) 43 | .then(initTesting); 44 | 45 | function initTestBed(){ 46 | return Promise.all([ 47 | System.import('@angular/core/testing'), 48 | System.import('@angular/platform-browser-dynamic/testing') 49 | ]) 50 | .then(function (providers) { 51 | var coreTesting = providers[0]; 52 | var browserTesting = providers[1]; 53 | 54 | coreTesting.TestBed.initTestEnvironment( 55 | browserTesting.BrowserDynamicTestingModule, 56 | browserTesting.platformBrowserDynamicTesting()); 57 | }) 58 | } 59 | 60 | // Import all spec files and start karma 61 | function initTesting () { 62 | return Promise.all( 63 | allSpecFiles.map(function (moduleName) { 64 | return System.import(moduleName); 65 | }) 66 | ) 67 | .then(__karma__.start, __karma__.error); 68 | } 69 | -------------------------------------------------------------------------------- /test/repoFilter.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import {PullRequestAsyncStatus, User} from "../src/app/model"; 2 | import {PullRequestViewModel} from "../src/app/pullRequestViewModel"; 3 | import {RepoFilterPipe} from "../src/app/repoFilter.pipe"; 4 | import {TestUtils} from "./testHelpers"; 5 | 6 | describe("RepoFilterPipe", () => { 7 | 8 | function createPRViewModel(repoId: string): PullRequestViewModel { 9 | const user: User = { 10 | id: "123", 11 | displayName: "test user", 12 | uniqueName: "testuser1", 13 | memberOf: [] 14 | }; 15 | const repo: GitRepository = { 16 | _links: { 17 | web: { 18 | href: `http://git/${repoId}` 19 | } 20 | }, 21 | defaultBranch: "master", 22 | url: `http://git/${repoId}`, 23 | id: repoId, 24 | name: repoId, 25 | project: null, 26 | remoteUrl: `http://git/${repoId}` 27 | }; 28 | const pr = TestUtils.createPullRequest({ 29 | created: new Date(), 30 | createdById: "user1", 31 | id: 1, 32 | mergeStatus: PullRequestAsyncStatus.Succeeded, 33 | sourceBranch: "testbranch", 34 | targetBranch: "master", 35 | title: "test123", 36 | reviewers: [] 37 | }); 38 | return new PullRequestViewModel(pr, repo, user); 39 | } 40 | 41 | let subject: RepoFilterPipe = null; 42 | 43 | beforeEach(() => { 44 | subject = new RepoFilterPipe(); 45 | }); 46 | 47 | it("returns an empty list if an empty list is given", () => { 48 | const result = subject.transform([], ["repo1"]); 49 | expect(result).toBeDefined(); 50 | expect(result.length).toEqual(0); 51 | }); 52 | 53 | it("returns the input if no repos filtered", () => { 54 | const pr1 = createPRViewModel("repo1"); 55 | const pr2 = createPRViewModel("repo2"); 56 | const result = subject.transform([pr1, pr2], []); 57 | expect(result).toBeDefined(); 58 | expect(result.length).toEqual(2); 59 | expect(result[0]).toEqual(pr1); 60 | expect(result[1]).toEqual(pr2); 61 | }); 62 | 63 | it("filters out repos based on repoid", () => { 64 | const pr1 = createPRViewModel("repo1"); 65 | const pr2 = createPRViewModel("repo2"); 66 | const pr3 = createPRViewModel("repo3"); 67 | const pr4 = createPRViewModel("repo4"); 68 | const result = subject.transform([pr1, pr2, pr3, pr4], ["repo2", "repo4"]); 69 | expect(result).toBeDefined(); 70 | expect(result.length).toEqual(2); 71 | expect(result[0]).toEqual(pr1); 72 | expect(result[1]).toEqual(pr3); 73 | }); 74 | 75 | }); 76 | -------------------------------------------------------------------------------- /typings/modules/express/index.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by typings 2 | // Source: https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/fefb1124c3ed05e84878d555dcbcbf16d27f2ec4/express/index.d.ts 3 | declare module 'express' { 4 | // Type definitions for Express 4.x 5 | // Project: http://expressjs.com 6 | // Definitions by: Boris Yankov 7 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 8 | 9 | /* =================== USAGE =================== 10 | 11 | import * as express from "express"; 12 | var app = express(); 13 | 14 | =============================================== */ 15 | 16 | /// 17 | /// 18 | 19 | import * as serveStatic from 'serve-static'; 20 | import * as core from 'express-serve-static-core'; 21 | 22 | /** 23 | * Creates an Express application. The express() function is a top-level function exported by the express module. 24 | */ 25 | function e(): core.Express; 26 | 27 | namespace e { 28 | 29 | /** 30 | * This is the only built-in middleware function in Express. It serves static files and is based on serve-static. 31 | */ 32 | var static: typeof serveStatic; 33 | 34 | export function Router(options?: RouterOptions): core.Router; 35 | 36 | interface RouterOptions { 37 | /** 38 | * Enable case sensitivity. 39 | */ 40 | caseSensitive?: boolean; 41 | 42 | /** 43 | * Preserve the req.params values from the parent router. 44 | * If the parent and the child have conflicting param names, the child’s value take precedence. 45 | * 46 | * @default false 47 | * @since 4.5.0 48 | */ 49 | mergeParams?: boolean; 50 | 51 | /** 52 | * Enable strict routing. 53 | */ 54 | strict?: boolean; 55 | } 56 | 57 | interface Application extends core.Application { } 58 | interface CookieOptions extends core.CookieOptions { } 59 | interface Errback extends core.Errback { } 60 | interface ErrorRequestHandler extends core.ErrorRequestHandler { } 61 | interface Express extends core.Express { } 62 | interface Handler extends core.Handler { } 63 | interface IRoute extends core.IRoute { } 64 | interface IRouter extends core.IRouter { } 65 | interface IRouterHandler extends core.IRouterHandler { } 66 | interface IRouterMatcher extends core.IRouterMatcher { } 67 | interface MediaType extends core.MediaType { } 68 | interface NextFunction extends core.NextFunction { } 69 | interface Request extends core.Request { } 70 | interface RequestHandler extends core.RequestHandler { } 71 | interface RequestParamHandler extends core.RequestParamHandler { } 72 | export interface Response extends core.Response { } 73 | interface Router extends core.Router { } 74 | interface Send extends core.Send { } 75 | } 76 | 77 | export = e; 78 | } 79 | -------------------------------------------------------------------------------- /src/app/pullRequestViewModel.ts: -------------------------------------------------------------------------------- 1 | import { GitPullRequestWithStatuses, PullRequestAsyncStatus, User } from "./model"; 2 | 3 | export class PullRequestViewModel { 4 | 5 | public id: number; 6 | 7 | public remoteUrl: string; 8 | 9 | public requestedByMe: boolean; 10 | 11 | public assignedToMe: boolean; 12 | 13 | public assignedToMyTeam: boolean; 14 | 15 | public title: string; 16 | 17 | public createdByImageUrl: string; 18 | 19 | public createdBy: string; 20 | 21 | public createdDate: Date; 22 | 23 | public repositoryName: string; 24 | 25 | public sourceRefName: string; 26 | 27 | public targetRefName: string; 28 | 29 | public hasMergeConflicts: boolean; 30 | 31 | public isDraft: boolean = false; 32 | 33 | public autoComplete: boolean = false; 34 | 35 | public reviewers: IdentityRefWithVote[]; 36 | 37 | public statuses: GitPullRequestStatus[]; 38 | 39 | constructor(public pullRequest: GitPullRequestWithStatuses, public repository: GitRepository, currentUser: User) { 40 | this.id = pullRequest.pullRequestId; 41 | this.remoteUrl = `${repository._links.web.href}/pullrequest/${pullRequest.pullRequestId}`; 42 | 43 | this.requestedByMe = pullRequest.createdBy.id === currentUser.id; 44 | 45 | const reviewers = pullRequest.reviewers || []; 46 | for (const reviewer of reviewers) { 47 | if (!this.assignedToMe) { 48 | this.assignedToMe = reviewer.id === currentUser.id; 49 | } 50 | for (const team of currentUser.memberOf) { 51 | if (!this.assignedToMyTeam) { 52 | this.assignedToMyTeam = team.id === reviewer.id; 53 | } 54 | } 55 | } 56 | 57 | this.title = pullRequest.title; 58 | this.createdByImageUrl = pullRequest.createdBy.imageUrl; 59 | this.createdBy = pullRequest.createdBy.displayName; 60 | this.createdDate = pullRequest.creationDate; 61 | this.repositoryName = repository.name; 62 | this.sourceRefName = pullRequest.sourceRefName.replace("refs/heads/", ""); 63 | this.targetRefName = pullRequest.targetRefName.replace("refs/heads/", ""); 64 | this.hasMergeConflicts = pullRequest.mergeStatus === PullRequestAsyncStatus.Conflicts; 65 | this.reviewers = reviewers.sort((a: IdentityRefWithVote, b: IdentityRefWithVote) => { 66 | if (a.isRequired && !b.isRequired) { 67 | return -1; 68 | } 69 | if (!a.isRequired && b.isRequired) { 70 | return 1; 71 | } 72 | return 0; 73 | }); 74 | 75 | if (pullRequest.isDraft) { 76 | this.isDraft = true; 77 | } 78 | if (pullRequest.autoCompleteSetBy) { 79 | this.autoComplete = true; 80 | } 81 | 82 | if (pullRequest.statuses) { 83 | this.statuses = pullRequest.statuses; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/configuration.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 43 | 44 | 45 |
46 | 59 |
60 | 61 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | 3 | var appSrcBase = 'src/'; // app source TS files 4 | var testSrcBase= 'test/'; // test source TS files 5 | 6 | 7 | config.set({ 8 | basePath: './', 9 | frameworks: ['jasmine'], 10 | 11 | plugins: [ 12 | require('karma-jasmine'),, 13 | require('karma-chrome-launcher'), 14 | require('karma-jasmine-html-reporter'), 15 | require('karma-htmlfile-reporter') // crashing w/ strange socket error 16 | ], 17 | 18 | files: [ 19 | // System.js for module loading 20 | 'node_modules/systemjs/dist/system.src.js', 21 | 22 | // Polyfills 23 | 'node_modules/es6-shim/es6-shim.js', 24 | 'node_modules/reflect-metadata/Reflect.js', 25 | 26 | // zone.js 27 | 'node_modules/zone.js/dist/zone.js', 28 | 'node_modules/zone.js/dist/long-stack-trace-zone.js', 29 | 'node_modules/zone.js/dist/proxy.js', 30 | 'node_modules/zone.js/dist/sync-test.js', 31 | 'node_modules/zone.js/dist/jasmine-patch.js', 32 | 'node_modules/zone.js/dist/async-test.js', 33 | 'node_modules/zone.js/dist/fake-async-test.js', 34 | 35 | // typescript 36 | 'node_modules/typescript/lib/typescript.js', 37 | 'node_modules/plugin-typescript/lib/plugin.js', 38 | 39 | // RxJs 40 | { pattern: 'node_modules/rxjs/**/*.js', included: false, watched: false }, 41 | { pattern: 'node_modules/rxjs/**/*.js.map', included: false, watched: false }, 42 | 43 | // Paths loaded via module imports: 44 | // Angular itself 45 | { pattern: 'node_modules/@angular/**/*.js', included: false, watched: false }, 46 | { pattern: 'node_modules/@angular/**/*.js.map', included: false, watched: false }, 47 | 48 | { pattern: 'src/system.config.js', included: false, watched: false }, 49 | 'karma-test-shim.js', 50 | 51 | // application & spec code paths loaded via module imports 52 | { pattern: 'tsconfig.json', included: false, watched: true }, 53 | { pattern: appSrcBase + '**/*.ts', included: false, watched: true }, 54 | { pattern: testSrcBase + '**/*.ts', included: false, watched: true }, 55 | 56 | // Asset (HTML & CSS) paths loaded via Angular's component compiler 57 | // (these paths need to be rewritten, see proxies section) 58 | { pattern: appSrcBase + '**/*.html', included: false, watched: true }, 59 | { pattern: appSrcBase + '**/*.css', included: false, watched: true } 60 | ], 61 | 62 | // Proxied base paths for loading assets 63 | proxies: { 64 | // required for component assets fetched by Angular's compiler 65 | '/src/': '/base/src/' 66 | }, 67 | 68 | exclude: [], 69 | // disabled HtmlReporter; suddenly crashing w/ strange socket error 70 | reporters: ['progress', 'kjhtml'],//'html'], 71 | 72 | // HtmlReporter configuration 73 | htmlReporter: { 74 | // Open this file to see results in browser 75 | outputFile: '_test-output/tests.html', 76 | 77 | // Optional 78 | pageTitle: 'Unit Tests', 79 | subPageTitle: __dirname 80 | }, 81 | 82 | port: 9876, 83 | colors: true, 84 | logLevel: config.LOG_INFO, 85 | autoWatch: true, 86 | browsers: ['Chrome'], 87 | singleRun: false 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 8 | 28 |
29 |
30 |
31 |
32 |
33 |
{{category.name + ' (' + filtered.length + ')'}}
34 |
35 | 36 |
37 |
38 | {{filtered.length - rowLimit}} more... 39 |
40 |
41 |
42 |
43 |
44 |
Other Open Pull Requests ({{(pullRequests | prFilter:'other' | repoFilter:filteredRepoIds).length}}) 
45 |
46 | 47 |
48 |
49 |
50 | 53 |
54 | -------------------------------------------------------------------------------- /test/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {AppComponent} from "../src/app/app.component"; 2 | import {AppSettingsService, Layout, TfsService, User} from "../src/app/model"; 3 | import {TestUtils} from "./testHelpers"; 4 | import { NgZone, EventEmitter } from "@angular/core"; 5 | import { Observable } from "rxjs"; 6 | 7 | declare var Rx: any; 8 | 9 | describe("AppComponent", () => { 10 | 11 | class SettingsMock extends AppSettingsService { 12 | protected getValue(key: string): Promise { 13 | return Promise.resolve(""); 14 | } 15 | 16 | protected setValue(key: string, value: string): Promise { 17 | return Promise.resolve(value); 18 | } 19 | } 20 | 21 | const defaultUser: User = { 22 | id: "123", 23 | displayName: "test user", 24 | uniqueName: "testuser1", 25 | memberOf: [ 26 | TestUtils.createIdentity("group1"), 27 | TestUtils.createIdentity("group2") 28 | ] 29 | }; 30 | 31 | const layout: Layout = { 32 | categories: [{key: "test", name: "Test"}], 33 | widgetMode: false 34 | }; 35 | 36 | let tfsMock: TfsService; 37 | let settingsMock: AppSettingsService; 38 | let zoneMock: NgZone; 39 | 40 | const repos = [TestUtils.createRepository("repo1"), 41 | TestUtils.createRepository("repo2"), 42 | TestUtils.createRepository("repo3"), 43 | TestUtils.createRepository("repo4")]; 44 | 45 | let subject: AppComponent; 46 | 47 | beforeEach(() => { 48 | tfsMock = { 49 | getCurrentUser: (): Promise => { 50 | return Promise.resolve(defaultUser); 51 | }, 52 | getPullRequests: (allProjects?: boolean): Observable => { 53 | return Rx.Observable.from([]); 54 | }, 55 | getRepositories: (): Promise => { 56 | return Promise.resolve(repos); 57 | } 58 | }; 59 | spyOn(tfsMock, "getCurrentUser"); 60 | spyOn(tfsMock, "getPullRequests"); 61 | spyOn(tfsMock, "getRepositories"); 62 | 63 | settingsMock = new SettingsMock(layout, null); 64 | spyOn(settingsMock, "getDateFormat"); 65 | spyOn(settingsMock, "getRepoFilter"); 66 | spyOn(settingsMock, "getShowAllProjects"); 67 | 68 | zoneMock = { 69 | run: (action: () => void) => action(), 70 | hasPendingMacrotasks: false, 71 | hasPendingMicrotasks: false, 72 | isStable: true, 73 | onError: null, 74 | onMicrotaskEmpty: null, 75 | onStable: null, 76 | onUnstable: null, 77 | runGuarded: null, 78 | runOutsideAngular: null 79 | } 80 | 81 | subject = new AppComponent(tfsMock, settingsMock, zoneMock); 82 | }); 83 | 84 | it("doesn't make any service calls in the constructor", () => { 85 | expect(tfsMock.getCurrentUser).toHaveBeenCalledTimes(0); 86 | expect(tfsMock.getPullRequests).toHaveBeenCalledTimes(0); 87 | expect(tfsMock.getRepositories).toHaveBeenCalledTimes(0); 88 | expect(settingsMock.getDateFormat).toHaveBeenCalledTimes(0); 89 | expect(settingsMock.getRepoFilter).toHaveBeenCalledTimes(0); 90 | expect(settingsMock.getShowAllProjects).toHaveBeenCalledTimes(0); 91 | }); 92 | 93 | }); 94 | -------------------------------------------------------------------------------- /test/testHelpers.ts: -------------------------------------------------------------------------------- 1 | import {PullRequestAsyncStatus} from "../src/app/model"; 2 | 3 | export interface Voter { 4 | id: string; 5 | required: boolean; 6 | vote: number; 7 | } 8 | 9 | export interface SimplePullRequest { 10 | id: number; 11 | mergeStatus: PullRequestAsyncStatus; 12 | createdById?: string; 13 | reviewers?: Voter[]; 14 | created?: Date; 15 | sourceBranch?: string; 16 | targetBranch?: string; 17 | title?: string; 18 | repo?: string; 19 | project?: string; 20 | } 21 | 22 | export class TestUtils { 23 | public static createIdentity(id: string): Identity { 24 | return { 25 | customDisplayName: id, 26 | descriptor: null, 27 | id, 28 | isActive: true, 29 | isContainer: true, 30 | masterId: id, 31 | memberIds: [], 32 | memberOf: [], 33 | members: [], 34 | metaTypeId: 0, 35 | properties: null, 36 | providerDisplayName: id, 37 | resourceVersion: null, 38 | uniqueUserId: 0 39 | }; 40 | } 41 | 42 | public static voterToIdentityRef(voter: Voter): IdentityRefWithVote { 43 | const result: IdentityRefWithVote = { 44 | displayName: voter.id, 45 | id: voter.id, 46 | imageUrl: null, 47 | isAadIdentity: false, 48 | isContainer: false, 49 | profileUrl: null, 50 | uniqueName: null, 51 | url: null, 52 | isRequired: voter.required, 53 | reviewerUrl: null, 54 | vote: voter.vote, 55 | votedFor: [] 56 | }; 57 | return result; 58 | } 59 | 60 | public static createPullRequest(details: SimplePullRequest): GitPullRequest { 61 | const result: GitPullRequest = { 62 | _links: null, 63 | pullRequestId: details.id, 64 | autoCompleteSetBy: null, 65 | closedBy: null, 66 | closedDate: null, 67 | codeReviewId: 0, 68 | commits: [], 69 | completionOptions: null, 70 | completionQueueTime: null, 71 | createdBy: { 72 | displayName: details.createdById, 73 | id: details.createdById, 74 | url: `http://images/${details.createdById}`, 75 | imageUrl: null, 76 | isAadIdentity: false, 77 | isContainer: false, 78 | profileUrl: null, 79 | uniqueName: details.createdById 80 | }, 81 | creationDate: details.created, 82 | description: details.title, 83 | lastMergeCommit: null, 84 | lastMergeSourceCommit: null, 85 | lastMergeTargetCommit: null, 86 | mergeId: null, 87 | status: 1, 88 | supportsIterations: true, 89 | targetRefName: details.targetBranch, 90 | sourceRefName: details.sourceBranch, 91 | title: details.title, 92 | url: null, 93 | workItemRefs: [], 94 | remoteUrl: null, 95 | repository: { 96 | _links: null, 97 | defaultBranch: null, 98 | id: details.repo, 99 | name: details.repo, 100 | remoteUrl: null, 101 | url: null, 102 | project: { 103 | id: details.project, 104 | name : details.project 105 | } 106 | }, 107 | mergeStatus: details.mergeStatus, 108 | reviewers: details.reviewers.map(TestUtils.voterToIdentityRef) 109 | }; 110 | return result; 111 | } 112 | 113 | public static createRepository(name: string, project = "Test"): GitRepository { 114 | return { 115 | _links: { 116 | web: { 117 | href: `http://git/${name}` 118 | } 119 | }, 120 | defaultBranch: "master", 121 | url: `http://git/${name}`, 122 | id: name, 123 | name, 124 | project: { 125 | name: project, 126 | id: project 127 | }, 128 | remoteUrl: `http://git/${name}` 129 | }; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/assets/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: rgba(var(--palette-neutral-0, 255, 255, 255), 1); 3 | } 4 | 5 | .my-widget { 6 | padding: 0px 8px; 7 | } 8 | 9 | .category-header { 10 | padding-bottom: 6px; 11 | padding-top: 8px; 12 | font-size: 16px; 13 | } 14 | 15 | .category-remainder { 16 | margin-top: 6px; 17 | margin-left: 6px; 18 | font-size: 14px; 19 | } 20 | 21 | .pr-row { 22 | border-top: 1px rgb(var(--palette-neutral-10, 72, 70, 86)) solid; 23 | } 24 | 25 | .pull-request { 26 | display: flex; 27 | align-items: center; 28 | margin: 3px; 29 | } 30 | 31 | .identity-image-container { 32 | margin-right: 5px; 33 | } 34 | 35 | .identity-image { 36 | width: 40px; 37 | height: 40px; 38 | border-radius: 50%; 39 | } 40 | 41 | .my-widget .identity-image { 42 | width: 32px !important; 43 | height: 32px !important; 44 | } 45 | 46 | .pull-request-details { 47 | flex-grow: 1; 48 | color: rgb(var(--palette-neutral-70, 76, 76, 76)); 49 | font-size: 12px; 50 | width: calc(100% - 40px); 51 | } 52 | 53 | .pull-request-title { 54 | font-size: 14px; 55 | color: rgb(var(--palette-primary-shade-10, 0, 90, 158)); 56 | text-decoration: none; 57 | } 58 | 59 | a.pull-request-title:hover { 60 | text-decoration: none; 61 | } 62 | 63 | .title-container { 64 | margin-bottom: -4px; 65 | } 66 | 67 | .container-nowrap { 68 | width: 100%; 69 | overflow: hidden; 70 | white-space: nowrap; 71 | text-overflow: ellipsis; 72 | } 73 | 74 | .git-icon { 75 | margin-right: 1px; 76 | font-size: 14px !important; 77 | } 78 | 79 | .branch-icon { 80 | margin-right: 2px; 81 | font-size: 14px !important; 82 | } 83 | 84 | .vote-container { 85 | display: inline-block; 86 | } 87 | 88 | .tag-container { 89 | display: inline-block; 90 | } 91 | 92 | .compact-details { 93 | display: flex; 94 | } 95 | 96 | .compact-details-bottom-left { 97 | flex-shrink: 1; 98 | flex-grow: 1; 99 | overflow: hidden; 100 | white-space: nowrap; 101 | text-overflow: ellipsis; 102 | } 103 | 104 | .compact-details-bottom-right { 105 | align-self: flex-end; 106 | flex-grow: 0; 107 | flex-shrink: 0; 108 | } 109 | 110 | .vote { 111 | margin-right: 2px; 112 | font-size: 14px !important; 113 | } 114 | 115 | .approved { 116 | color: rgb(var(--palette-accent2-dark, 16, 124, 16)); 117 | } 118 | 119 | .rejected { 120 | color: rgb(var(--palette-accent1, 218, 10, 0)); 121 | } 122 | 123 | .waiting { 124 | color: rgb(var(--palette-accent3, 248, 168, 0)); 125 | } 126 | 127 | .repo-icon { 128 | transform: scale(0.75) 129 | } 130 | 131 | .tag { 132 | border-radius: 3px; 133 | border: 1px solid; 134 | font-size: 12px; 135 | padding-left: 3px; 136 | padding-top: 1px; 137 | padding-right: 3px; 138 | margin-right: 2px; 139 | } 140 | 141 | .failed { 142 | color: rgba( var(--palette-accent1,218, 10, 0) , 1 ); 143 | border-style: dotted; 144 | } 145 | 146 | .draft { 147 | color: rgba( var(--palette-primary-shade-10,16, 110, 190) , 1 ); 148 | } 149 | 150 | .autocomplete { 151 | color: rgb(var(--palette-accent3, 248, 168, 0)); 152 | } 153 | 154 | .pending { 155 | color: rgb(var(--palette-neutral-60, 102, 102, 102)); 156 | } 157 | 158 | .repo { 159 | color: rgb(var(--palette-neutral-80, 51, 51, 51)); 160 | } 161 | 162 | .settings { 163 | position: absolute; 164 | top: 0; 165 | right: 0; 166 | margin-right: 20px; 167 | margin-top: 10px; 168 | } 169 | 170 | .settings-container { 171 | min-width: 250px; 172 | font-size: 12px; 173 | } 174 | 175 | .widget-footer { 176 | position: fixed; 177 | text-align: right; 178 | width: 100%; 179 | bottom: 0; 180 | left: 0; 181 | right: 0; 182 | padding: 5px 10px; 183 | z-index: 1050; 184 | } 185 | 186 | .btn, .dropdown-menu, .input-group-addon, .form-control { 187 | background-color: rgba(var(--palette-neutral-0, 255, 255, 255), 1); 188 | border-color: rgb(var(--palette-neutral-10, 72, 70, 86)); 189 | color: rgba(var(--palette-neutral-100, 0, 0, 0), 1) !important; 190 | } 191 | 192 | .dropdown-menu .divider { 193 | background-color: rgb(var(--palette-neutral-10, 72, 70, 86)); 194 | } 195 | 196 | .btn:hover, .open>.dropdown-toggle.btn-default { 197 | background-color: rgba(var(--palette-neutral-20, 200, 200, 200), 1) !important; 198 | border-color: rgb(var(--palette-neutral-30, 166, 166, 166)) !important; 199 | } 200 | 201 | .dropdown-menu>li>a { 202 | background-color: rgba(var(--palette-neutral-0, 255, 255, 255), 1); 203 | color: rgba(var(--palette-neutral-100, 0, 0, 0), 1); 204 | } 205 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifestVersion": 1, 3 | "id": "tfs-pullrequest-dashboard", 4 | "version": "2.4.1", 5 | "name": "Pull Request Dashboard", 6 | "description": "A pull request dashboard for Visual Studio Team Services/Team Foundation Services.", 7 | "publisher": "ryanstedman", 8 | "links": { 9 | "getstarted": {"uri": "https://github.com/rstedman/tfs-pullrequest-dashboard"}, 10 | "learn": {"uri": "https://github.com/rstedman/tfs-pullrequest-dashboard"}, 11 | "license": {"uri": "https://github.com/rstedman/tfs-pullrequest-dashboard/blob/master/LICENSE"}, 12 | "support": {"uri": "https://github.com/rstedman/tfs-pullrequest-dashboard/issues"} 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "uri": "https://github.com/rstedman/tfs-pullrequest-dashboard" 17 | }, 18 | "content": { 19 | "details":{ "path": "overview.md" } 20 | }, 21 | "tags": ["code", "pull request", "git", "code review", "open source"], 22 | "screenshots": [ 23 | {"path":"assets/screenshots/hub_tab.png"}, 24 | {"path":"assets/screenshots/hub_view.png"}, 25 | {"path":"assets/screenshots/repo_filter.png"}, 26 | {"path":"assets/screenshots/settings.png"}, 27 | {"path":"assets/screenshots/add_widget.png"}, 28 | {"path":"assets/screenshots/widget.png"}, 29 | {"path":"assets/screenshots/widget_config.png"} 30 | ], 31 | "targets": [{ "id": "Microsoft.VisualStudio.Services" }], 32 | "icons": {"default": "assets/images/logo.png"}, 33 | "categories": ["Code"], 34 | "galleryFlags": ["Public"], 35 | "contributions": [ 36 | { 37 | "id": "tfs-pullrequest-dashboard", 38 | "type": "ms.vss-web.hub", 39 | "description": "Adds a dashboard showing pull requests across all repositories.", 40 | "targets": ["ms.vss-code-web.code-hub-group"], 41 | "properties": { 42 | "name": "Pull Request Dashboard", 43 | "order": 99, 44 | "uri": "index.html", 45 | "icon": { 46 | "light": "assets/images/icon-light.png", 47 | "dark": "assets/images/icon-dark.png" 48 | } 49 | } 50 | }, 51 | { 52 | "id": "tfs-pullrequest-dashboard-widget", 53 | "type": "ms.vss-dashboards-web.widget", 54 | "targets": [ 55 | "ms.vss-dashboards-web.widget-catalog", 56 | "ryanstedman.tfs-pullrequest-dashboard.tfs-pullrequest-dashboard-widget.Configuration" 57 | ], 58 | "properties": { 59 | "name": "Pull Request Dashboard Widget", 60 | "description": "Displays pull requests across all repositories", 61 | "previewImageUrl": "assets/images/logo.png", 62 | "uri": "index.html", 63 | "supportedSizes": [ 64 | { "rowSpan": 2, "columnSpan": 3 }, 65 | { "rowSpan": 3, "columnSpan": 3 }, 66 | { "rowSpan": 4, "columnSpan": 3 } 67 | ], 68 | "supportedScopes": ["project_team"] 69 | } 70 | }, 71 | { 72 | "id": "tfs-pullrequest-dashboard-widget.Configuration", 73 | "type": "ms.vss-dashboards-web.widget-configuration", 74 | "targets": [ "ms.vss-dashboards-web.widget-configuration" ], 75 | "properties": { 76 | "name": "Pullrequest Dashboard Widget Configuration", 77 | "description": "Configures Pullrequest Dashboard Widget", 78 | "uri": "configuration.html" 79 | } 80 | } 81 | ], 82 | "scopes": ["vso.code", "vso.identity"], 83 | "files": [ 84 | { 85 | "path": "index.html", "addressable": true 86 | }, 87 | { 88 | "path": "configuration.html", "addressable": true 89 | }, 90 | { 91 | "path": "app/app.bundle.min.js", "addressable": true 92 | }, 93 | { 94 | "path": "app/app.bundle.min.js.map", "addressable": true, "contentType": "application/json" 95 | }, 96 | { 97 | "path": "vendor/vendor.bundle.min.js", "addressable": true 98 | }, 99 | { 100 | "path": "vendor/vendor.bundle.min.js.map", "addressable": true, "contentType": "application/json" 101 | }, 102 | { 103 | "path": "assets", "addressable": true 104 | } 105 | ] 106 | } 107 | -------------------------------------------------------------------------------- /src/manifest-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifestVersion": 1, 3 | "id": "tfs-pullrequest-dashboard-dev", 4 | "public": false, 5 | "baseUri": "https://localhost:5000", 6 | "version": "0.7.2", 7 | "name": "Pull Request Dashboard (dev)", 8 | "description": "A pull request dashboard for Visual Studio Team Services/Team Foundation Services.", 9 | "publisher": "ryanstedman", 10 | "links": { 11 | "getstarted": {"uri": "https://github.com/rstedman/tfs-pullrequest-dashboard"}, 12 | "learn": {"uri": "https://github.com/rstedman/tfs-pullrequest-dashboard"}, 13 | "license": {"uri": "https://github.com/rstedman/tfs-pullrequest-dashboard/blob/master/LICENSE"}, 14 | "support": {"uri": "https://github.com/rstedman/tfs-pullrequest-dashboard/issues"} 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "uri": "https://github.com/rstedman/tfs-pullrequest-dashboard" 19 | }, 20 | "content": { 21 | "details":{ "path": "overview.md" } 22 | }, 23 | "tags": ["code", "pull request", "git", "code review", "open source"], 24 | "screenshots": [ 25 | {"path":"assets/screenshots/hub_tab.png"}, 26 | {"path":"assets/screenshots/hub_view.png"}, 27 | {"path":"assets/screenshots/repo_filter.png"}, 28 | {"path":"assets/screenshots/settings.png"}, 29 | {"path":"assets/screenshots/add_widget.png"}, 30 | {"path":"assets/screenshots/widget.png"}, 31 | {"path":"assets/screenshots/widget_config.png"} 32 | ], 33 | "targets": [{ "id": "Microsoft.VisualStudio.Services" }], 34 | "icons": {"default": "assets/images/logo.png"}, 35 | "categories": ["Code"], 36 | "contributions": [ 37 | { 38 | "id": "tfs-pullrequest-dashboard-dev", 39 | "type": "ms.vss-web.hub", 40 | "description": "Adds a dashboard showing pull requests across all repositories.", 41 | "targets": ["ms.vss-code-web.code-hub-group"], 42 | "properties": { 43 | "name": "Pull Request Dashboard (dev)", 44 | "order": 99, 45 | "uri": "index.html", 46 | "icon": { 47 | "light": "assets/images/icon-light.png", 48 | "dark": "assets/images/icon-dark.png" 49 | } 50 | } 51 | }, 52 | { 53 | "id": "tfs-pullrequest-dashboard-widget-dev", 54 | "type": "ms.vss-dashboards-web.widget", 55 | "targets": [ 56 | "ms.vss-dashboards-web.widget-catalog", 57 | "ryanstedman.tfs-pullrequest-dashboard-dev.tfs-pullrequest-dashboard-widget-dev.Configuration" 58 | ], 59 | "properties": { 60 | "name": "Pull Request Dashboard Widget (dev)", 61 | "description": "Displays pull requests across all repositories", 62 | "previewImageUrl": "assets/images/logo.png", 63 | "uri": "index.html", 64 | "supportedSizes": [ 65 | { "rowSpan": 2, "columnSpan": 3 }, 66 | { "rowSpan": 3, "columnSpan": 3 }, 67 | { "rowSpan": 4, "columnSpan": 3 } 68 | ], 69 | "supportedScopes": ["project_team"] 70 | } 71 | }, 72 | { 73 | "id": "tfs-pullrequest-dashboard-widget-dev.Configuration", 74 | "type": "ms.vss-dashboards-web.widget-configuration", 75 | "targets": [ "ms.vss-dashboards-web.widget-configuration" ], 76 | "properties": { 77 | "name": "Pullrequest Dashboard Widget Configuration", 78 | "description": "Configures Pullrequest Dashboard Widget", 79 | "uri": "configuration.html" 80 | } 81 | } 82 | ], 83 | "scopes": ["vso.code", "vso.identity"], 84 | "files": [ 85 | { 86 | "path": "index.html", "addressable": true 87 | }, 88 | { 89 | "path": "configuration.html", "addressable": true 90 | }, 91 | { 92 | "path": "app/app.bundle.min.js", "addressable": true 93 | }, 94 | { 95 | "path": "app/app.bundle.min.js.map", "addressable": true, "contentType": "application/json" 96 | }, 97 | { 98 | "path": "vendor/vendor.bundle.min.js", "addressable": true 99 | }, 100 | { 101 | "path": "vendor/vendor.bundle.min.js.map", "addressable": true, "contentType": "application/json" 102 | }, 103 | { 104 | "path": "assets", "addressable": true 105 | } 106 | ] 107 | } 108 | -------------------------------------------------------------------------------- /src/app/pullRequest.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from "@angular/core"; 2 | 3 | import { GitStatusState, Vote } from "./model"; 4 | import { PullRequestViewModel } from "./pullRequestViewModel"; 5 | 6 | interface Tag { 7 | name: string; 8 | description: string; 9 | class: string; 10 | } 11 | 12 | @Component({ 13 | selector: "pull-request", 14 | templateUrl: "pullRequest.component.html", 15 | }) 16 | export class PullRequestComponent { 17 | 18 | @Input() 19 | public pullRequest: PullRequestViewModel; 20 | 21 | @Input() 22 | public dateFormat: string; 23 | 24 | @Input() 25 | public compactMode: boolean; 26 | 27 | public getVoteClasses(vote: number): string { 28 | let result = "fa vote"; 29 | if (vote === Vote.NoResponse) { 30 | result += " fa-minus-circle"; 31 | } else if (vote === Vote.Rejected) { 32 | result += " fa-times-circle rejected"; 33 | } else if (vote === Vote.Approved) { 34 | result += " fa-check-circle approved"; 35 | } else if (vote === Vote.ApprovedWithSuggestions) { 36 | result += " fa-check-circle-o approved"; 37 | } else if (vote === Vote.WaitingForAuthor) { 38 | result += " fa-minus-circle waiting"; 39 | } 40 | return result; 41 | } 42 | 43 | public getVoteTooltip(vote: number): string { 44 | if (vote === Vote.NoResponse) { 45 | return "No Response"; 46 | } else if (vote === Vote.Rejected) { 47 | return "Rejected"; 48 | } else if (vote === Vote.Approved) { 49 | return "Approved"; 50 | } else if (vote === Vote.ApprovedWithSuggestions) { 51 | return "Approved With Suggestions"; 52 | } else if (vote === Vote.WaitingForAuthor) { 53 | return "Waiting for Author"; 54 | } 55 | } 56 | 57 | public getVoteGroups() { 58 | const groups = {}; 59 | for (const r of this.pullRequest.reviewers) { 60 | if (groups[r.vote]) { 61 | groups[r.vote].push(r); 62 | } else { 63 | groups[r.vote] = [r]; 64 | } 65 | } 66 | 67 | const result = []; 68 | for (const key of Object.keys(groups)) { 69 | let tooltip = this.getVoteTooltip(Number(key)) + ": "; 70 | let i = 0; 71 | for (const r of groups[key]) { 72 | if (i > 0) { 73 | tooltip += ", "; 74 | } 75 | tooltip += r.displayName; 76 | i++; 77 | } 78 | result.push({ 79 | vote: Number(key), 80 | count: groups[key].length, 81 | tooltip 82 | }); 83 | } 84 | 85 | return result; 86 | } 87 | 88 | public getTags(): Tag[] { 89 | const tags = new Array(); 90 | if (this.pullRequest.isDraft) { 91 | tags.push({ 92 | name: "Draft", 93 | description: "Pull request is in a draft state", 94 | class: "draft" 95 | }); 96 | } 97 | if (this.pullRequest.hasMergeConflicts) { 98 | tags.push({ 99 | name: "Conflicts", 100 | description: "Conflicts exist between the source and target branch", 101 | class: "failed" 102 | }); 103 | } 104 | 105 | if (this.pullRequest.statuses) { 106 | const uniqueStatuses = new Map(); 107 | 108 | for (const status of this.pullRequest.statuses) { 109 | uniqueStatuses.set(status.context.name, status); 110 | } 111 | 112 | uniqueStatuses.forEach((status, key) => { 113 | let cls = "pending"; 114 | let stateDesc = "Pending"; 115 | switch (status.state) { 116 | case GitStatusState.Error: 117 | cls = "rejected"; 118 | stateDesc = "Error"; 119 | break; 120 | case GitStatusState.Failed: 121 | cls = "failed"; 122 | stateDesc = "Failed"; 123 | break; 124 | case GitStatusState.Succeeded: 125 | cls = "approved"; 126 | stateDesc = "Succeeded"; 127 | break; 128 | } 129 | 130 | tags.push({ 131 | name: status.context.name, 132 | description: `${status.description} - ${stateDesc}`, 133 | class: cls 134 | }); 135 | }); 136 | } 137 | if (this.pullRequest.autoComplete) { 138 | tags.push({ 139 | name: "Auto-Complete", 140 | description: "Pull Request is set to auto-complete once all policies have passed", 141 | class: "autocomplete" 142 | }); 143 | } 144 | return tags; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/app/model.ts: -------------------------------------------------------------------------------- 1 | import { NgZone } from "@angular/core"; 2 | import { Observable } from "rxjs"; 3 | 4 | // source: https://stackoverflow.com/a/14657922 5 | export interface ILiteEvent { 6 | on(handler: (data?: T) => void): void; 7 | off(handler: (data?: T) => void): void; 8 | } 9 | 10 | export class LiteEvent implements ILiteEvent { 11 | private handlers: Array<(data?: T) => void> = []; 12 | 13 | public on(handler: (data?: T) => void): void { 14 | this.handlers.push(handler); 15 | } 16 | 17 | public off(handler: (data?: T) => void): void { 18 | this.handlers = this.handlers.filter( (h) => h !== handler); 19 | } 20 | 21 | public trigger(data?: T) { 22 | this.handlers.slice(0).forEach( (h) => h(data)); 23 | } 24 | 25 | public expose(): ILiteEvent { 26 | return this; 27 | } 28 | } 29 | 30 | export interface Category { 31 | key: string; 32 | name: string; 33 | } 34 | 35 | export interface LayoutSize { 36 | columnSpan: number; 37 | rowSpan: number; 38 | } 39 | 40 | export interface Layout { 41 | categories: Category[]; 42 | widgetMode?: boolean; 43 | size?: LayoutSize; 44 | } 45 | 46 | // Represents an user in tfs. Extends the identity model to include the identity 47 | // info for membersOf 48 | export interface User { 49 | // The guid id of the identity 50 | id: string; 51 | 52 | // The displayname of the identity 53 | displayName: string; 54 | 55 | // The unique name of the indenity 56 | uniqueName: string; 57 | 58 | // other identities this identity is a member of 59 | memberOf: Identity[]; 60 | } 61 | 62 | export enum Vote { 63 | Approved = 10, 64 | ApprovedWithSuggestions = 5, 65 | NoResponse = 0, 66 | Rejected = -10, 67 | WaitingForAuthor = -5 68 | } 69 | 70 | export enum PullRequestAsyncStatus { 71 | NotSet = 0, 72 | Queued = 1, 73 | Conflicts = 2, 74 | Succeeded = 3, 75 | RejectedByPolicy = 4, 76 | Failure = 5, 77 | } 78 | 79 | export enum GitStatusState { 80 | NotSet = 0, 81 | Pending = 1, 82 | Succeeded = 2, 83 | Failed = 3, 84 | Error = 4, 85 | } 86 | 87 | export interface GitPullRequestWithStatuses extends GitPullRequest { 88 | statuses?: GitPullRequestStatus[]; 89 | } 90 | 91 | export abstract class TfsService { 92 | public abstract getCurrentUser(): Promise; 93 | public abstract getPullRequests(allProjects?: boolean): Observable; 94 | public abstract getRepositories(allProjects?: boolean): Promise; 95 | } 96 | 97 | export abstract class AppSettingsService { 98 | // settings key for the list of repositories that the user has unselected from the other section 99 | public static repoFilterKey = "repoFilter"; 100 | // settings key for the datetime format the user wants dates to display in 101 | public static dateFormatKey = "dateFormat"; 102 | // settings key for showing PRs across all projects instead of just the current 103 | public static allProjectsKey = "allProjects"; 104 | 105 | public static defaultDateFormat = "dd/MM/yyyy HH:mm"; 106 | 107 | private onCategoryChanged = new LiteEvent(); 108 | 109 | private onLayoutchanged = new LiteEvent(); 110 | 111 | constructor(private layout: Layout, 112 | protected zone: NgZone) { 113 | } 114 | 115 | public async getRepoFilter(): Promise { 116 | const filterStr = await this.getValue(AppSettingsService.repoFilterKey); 117 | if (filterStr) { 118 | return JSON.parse(filterStr); 119 | } 120 | return []; 121 | } 122 | 123 | public async setRepoFilter(value: string[]): Promise { 124 | await this.setValue(AppSettingsService.repoFilterKey, JSON.stringify(value)); 125 | } 126 | 127 | public async getDateFormat(): Promise { 128 | const savedFormat = await this.getValue(AppSettingsService.dateFormatKey); 129 | if (savedFormat && savedFormat !== "") { 130 | return savedFormat; 131 | } 132 | return AppSettingsService.defaultDateFormat; 133 | } 134 | 135 | public async setDateFormat(value: string): Promise { 136 | await this.setValue(AppSettingsService.dateFormatKey, value); 137 | } 138 | 139 | public async getShowAllProjects(): Promise { 140 | const savedAllProjects = await this.getValue(AppSettingsService.allProjectsKey); 141 | if (savedAllProjects === "true") { 142 | return true; 143 | } 144 | return false; 145 | } 146 | 147 | public async setShowAllProjects(value: boolean): Promise { 148 | await this.setValue(AppSettingsService.allProjectsKey, value.toString()); 149 | } 150 | 151 | public getLayout(): Layout { 152 | return this.layout; 153 | } 154 | 155 | public setLayout(layout: Layout) { 156 | this.layout = layout; 157 | this.zone.run(() => this.onLayoutchanged.trigger(layout)); 158 | } 159 | 160 | public layoutChanged(): ILiteEvent { 161 | return this.onLayoutchanged.expose(); 162 | } 163 | 164 | public getHubUri(): string { 165 | return "#"; 166 | } 167 | 168 | protected abstract getValue(key: string): Promise; 169 | protected abstract setValue(key: string, value: string): Promise; 170 | } 171 | -------------------------------------------------------------------------------- /src/app/extensionsApiTfs.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, NgZone} from "@angular/core"; 2 | 3 | import { Observable } from "rxjs"; 4 | import "rxjs/Rx"; 5 | 6 | import {GitPullRequestWithStatuses, TfsService, User} from "./model"; 7 | 8 | // for some reason, Observable.from isn't working, regardless of anything I try. However, Rx.Observable.from works, but 9 | // Rx isn't declared in the typings. This works around that. 10 | declare var Rx: any; 11 | 12 | // TfsService implementation which uses the VSS extension apis for fetching data 13 | @Injectable() 14 | export class ExtensionsApiTfsService extends TfsService { 15 | 16 | constructor(private gitClient: GitClient, 17 | private identitiesClient: IdentitiesClient, 18 | private coreTfsClient: CoreHttpClient, 19 | private isHosted: boolean, 20 | private projectName: string, 21 | private userContext: UserContext, 22 | private zone: NgZone) { 23 | 24 | super(); 25 | } 26 | 27 | public async getCurrentUser(): Promise { 28 | const user: User = { 29 | id: this.userContext.id, 30 | displayName: this.userContext.name, 31 | uniqueName: this.userContext.uniqueName, 32 | memberOf: [] 33 | }; 34 | // The identity apis aren't available in Visual Studio Team Services, only for Team Foundation Services. 35 | // If this is running as an extension in VSTS, we aren't able to return any group membership information for the current user. 36 | if (this.isHosted) { 37 | return user; 38 | } 39 | 40 | const membersOf = await this.getMembersOf(user.id); 41 | const promises: Array> = []; 42 | for (const m of membersOf) { 43 | user.memberOf.push(m); 44 | // now recurse once into the subgroups of each group the member is a member of, to include 45 | // virtual groups made up of several groups 46 | promises.push(this.getMembersOf(m.id)); 47 | } 48 | const subMembersOf = await Promise.all(promises); 49 | for (const members of subMembersOf) { 50 | for (const i of members) { 51 | user.memberOf.push(i); 52 | } 53 | } 54 | return new Promise((resolve, reject) => { 55 | // use ngzone to bring promise callback back into the angular zone 56 | this.zone.run(() => resolve(user)); 57 | }); 58 | } 59 | 60 | public getPullRequests(allProjects?: boolean): Observable { 61 | let projects = Rx.Observable.from([this.projectName]); 62 | 63 | if (allProjects) { 64 | projects = Rx.Observable.fromPromise(this.coreTfsClient.getProjects()) 65 | .flatMap((x) => x) 66 | .map((x) => x.name); 67 | } 68 | 69 | return projects 70 | .map((proj) => Rx.Observable.fromPromise(this.gitClient.getPullRequestsByProject(proj, { 71 | includeLinks: true, 72 | creatorId: null, 73 | repositoryId: null, 74 | reviewerId: null, 75 | sourceRefName: null, 76 | status: 1, 77 | targetRefName: null}, 78 | null, 0, 1000))) 79 | .flatMap((prsObservable: Observable) => prsObservable) 80 | .flatMap((prs: GitPullRequest[]) => prs) 81 | .flatMap((pr: GitPullRequest) => this.getPullRequestComplete(pr)) 82 | .flatMap((pr: GitPullRequest) => this.getPullRequestsWithStatus(pr)); 83 | } 84 | 85 | public async getRepositories(allProjects?: boolean): Promise { 86 | const repos = await this.gitClient.getRepositories(allProjects ? null : this.projectName, true); 87 | return new Promise((resolve, reject) => { 88 | // use ngzone to bring promise callback back into the angular zone 89 | this.zone.run(() => resolve(repos)); 90 | }); 91 | } 92 | 93 | // the search endpoint for pull requests doesn't contain autocomplete info, so we need to requery each individual 94 | // PR just to see if it has autocomplete set 95 | private getPullRequestComplete(pullRequest: GitPullRequest): Observable { 96 | return Rx.Observable.fromPromise(this.gitClient.getPullRequest(pullRequest.repository.id, pullRequest.pullRequestId)); 97 | } 98 | 99 | private getPullRequestsWithStatus(pullRequest: GitPullRequest): Observable { 100 | return Rx.Observable 101 | .fromPromise(this.gitClient.getPullRequestStatuses(pullRequest.repository.id, pullRequest.pullRequestId)) 102 | .map((statuses) => { 103 | const patch: any = {statuses}; 104 | Object.assign(patch, pullRequest); 105 | return patch; 106 | }); 107 | } 108 | 109 | private async getMembersOf(userId: string): Promise { 110 | // get the identities that the current user is a member of 111 | const members = await this.identitiesClient.readMembersOf(userId); 112 | const promises: Array> = []; 113 | for (const memberId of members) { 114 | // ignore any non-tfs identities 115 | if (!memberId.startsWith("Microsoft.TeamFoundation.Identity")) { 116 | continue; 117 | } 118 | 119 | promises.push(this.identitiesClient.readIdentity(memberId)); 120 | } 121 | const identities = await Promise.all(promises); 122 | return identities; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /test/pullRequestViewModel.spec.ts: -------------------------------------------------------------------------------- 1 | import {PullRequestAsyncStatus, User, GitPullRequestWithStatuses} from "../src/app/model"; 2 | import {PullRequestViewModel} from "../src/app/pullRequestViewModel"; 3 | import {TestUtils} from "./testHelpers"; 4 | 5 | describe("PullRequestViewModel", () => { 6 | 7 | const defaultUser: User = { 8 | id: "123", 9 | displayName: "test user", 10 | uniqueName: "testuser1", 11 | memberOf: [ 12 | TestUtils.createIdentity("group1"), 13 | TestUtils.createIdentity("group2") 14 | ] 15 | }; 16 | 17 | const defaultRepository = TestUtils.createRepository("repo1"); 18 | 19 | function getSimplePR(): GitPullRequestWithStatuses { 20 | return TestUtils.createPullRequest({ 21 | created: new Date(), 22 | createdById: "user1", 23 | id: 1, 24 | mergeStatus: PullRequestAsyncStatus.Succeeded, 25 | sourceBranch: "testbranch", 26 | targetBranch: "master", 27 | title: "test123", 28 | reviewers: [ 29 | { 30 | id: "reviewer1", 31 | required: false, 32 | vote: 0 33 | } 34 | ] 35 | }); 36 | } 37 | 38 | it("Initializes Simple Properties", () => { 39 | const pr = getSimplePR(); 40 | const subject = new PullRequestViewModel(pr, defaultRepository, defaultUser); 41 | expect(subject.reviewers.length).toEqual(pr.reviewers.length); 42 | expect(subject.createdBy).toEqual(pr.createdBy.displayName); 43 | expect(subject.createdDate).toEqual(pr.creationDate); 44 | expect(subject.repositoryName).toEqual(defaultRepository.name); 45 | expect(subject.sourceRefName).toEqual(pr.sourceRefName); 46 | expect(subject.targetRefName).toEqual(pr.targetRefName); 47 | expect(subject.createdByImageUrl).toEqual(pr.createdBy.imageUrl); 48 | expect(subject.hasMergeConflicts).toEqual(false); 49 | }); 50 | 51 | it("Removes refs/heads from refNames", () => { 52 | const pr = getSimplePR(); 53 | pr.sourceRefName = "refs/heads/my_branch"; 54 | pr.targetRefName = "refs/heads/master"; 55 | const subject = new PullRequestViewModel(pr, defaultRepository, defaultUser); 56 | expect(subject.sourceRefName).toEqual("my_branch"); 57 | expect(subject.targetRefName).toEqual("master"); 58 | }); 59 | 60 | it("sets reviewers to empty array if reviewers null", () => { 61 | const pr = getSimplePR(); 62 | pr.reviewers = null; 63 | const subject = new PullRequestViewModel(pr, defaultRepository, defaultUser); 64 | expect(subject.reviewers).toBeDefined(); 65 | expect(subject.reviewers.length).toEqual(0); 66 | }); 67 | 68 | it("sets requestedByMe correctly", () => { 69 | const pr = getSimplePR(); 70 | pr.createdBy.id = defaultUser.id; 71 | const subject = new PullRequestViewModel(pr, defaultRepository, defaultUser); 72 | expect(subject.requestedByMe).toEqual(true); 73 | expect(subject.assignedToMe).toEqual(false); 74 | expect(subject.assignedToMyTeam).toEqual(false); 75 | }); 76 | 77 | it("sets assignedToMe correctly", () => { 78 | const pr = getSimplePR(); 79 | pr.reviewers.push( 80 | TestUtils.voterToIdentityRef({ 81 | id: defaultUser.id, 82 | required: false, 83 | vote: 0 84 | })); 85 | const subject = new PullRequestViewModel(pr, defaultRepository, defaultUser); 86 | expect(subject.requestedByMe).toEqual(false); 87 | expect(subject.assignedToMe).toEqual(true); 88 | expect(subject.assignedToMyTeam).toEqual(false); 89 | }); 90 | 91 | it("sets assignedToMyTeam correctly", () => { 92 | const pr = getSimplePR(); 93 | pr.reviewers.push( 94 | TestUtils.voterToIdentityRef({ 95 | id: "group2", 96 | required: false, 97 | vote: 0 98 | })); 99 | const subject = new PullRequestViewModel(pr, defaultRepository, defaultUser); 100 | expect(subject.requestedByMe).toEqual(false); 101 | expect(subject.assignedToMe).toEqual(false); 102 | expect(subject.assignedToMyTeam).toEqual(true); 103 | }); 104 | 105 | it("sets hasMergeConflicts correctly", () => { 106 | const pr = getSimplePR(); 107 | pr.mergeStatus = PullRequestAsyncStatus.Conflicts; 108 | const subject = new PullRequestViewModel(pr, defaultRepository, defaultUser); 109 | expect(subject.hasMergeConflicts).toEqual(true); 110 | }); 111 | 112 | it("sorts required reviewers first", () => { 113 | const pr = getSimplePR(); 114 | pr.reviewers = [ 115 | TestUtils.voterToIdentityRef({ 116 | id: "non_req1", 117 | required: false, 118 | vote: 0 119 | }), 120 | TestUtils.voterToIdentityRef({ 121 | id: "req1", 122 | required: true, 123 | vote: 0 124 | }), 125 | TestUtils.voterToIdentityRef({ 126 | id: "non_req2", 127 | required: false, 128 | vote: 0 129 | }), 130 | TestUtils.voterToIdentityRef({ 131 | id: "req2", 132 | required: true, 133 | vote: 0 134 | })]; 135 | const subject = new PullRequestViewModel(pr, defaultRepository, defaultUser); 136 | expect(subject.reviewers.length).toEqual(4); 137 | expect(subject.reviewers[0].id).toEqual("req1"); 138 | expect(subject.reviewers[1].id).toEqual("req2"); 139 | expect(subject.reviewers[2].id).toEqual("non_req1"); 140 | expect(subject.reviewers[3].id).toEqual("non_req2"); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var del = require('del'); 3 | var sourcemaps = require('gulp-sourcemaps'); 4 | var runSequence = require('run-sequence'); 5 | var connect = require('gulp-connect'); 6 | var systemjsBuilder = require('systemjs-builder'); 7 | var concat = require('gulp-concat'); 8 | var uglify = require('gulp-uglify'); 9 | var rename = require('gulp-rename'); 10 | var run = require('gulp-run'); 11 | var embedTemplates = require('gulp-angular2-embed-templates'); 12 | var path = require('path'); 13 | var htmlreplace = require('gulp-html-replace'); 14 | var tslint = require('gulp-tslint'); 15 | var ts = require('gulp-typescript'); 16 | var karma = require('karma'); 17 | var replace = require('gulp-replace'); 18 | 19 | var paths = { 20 | buildFiles: ['./gulpfile.js', './package.json', './typings.json', './tsconfig.json', './system.config.js'], 21 | compiledFiles: ['src/**/*.ts', 'typings/index.d.ts', 'src/**/*.js'], 22 | vendor_js: ['node_modules/zone.js/dist/zone.js', 23 | 'node_modules/reflect-metadata/Reflect.js', 24 | 'node_modules/systemjs/dist/system.src.js', 25 | 'node_modules/es6-shim/es6-shim.js', 26 | 'node_modules/vss-web-extension-sdk/lib/VSS.SDK.js', 27 | 'node_modules/rxjs/bundles/Rx.js'], 28 | } 29 | 30 | gulp.task('clean', function() { 31 | return del(['build']); 32 | }); 33 | 34 | gulp.task('copy:vendor', function() { 35 | return gulp.src(['node_modules/@angular/**/*', 'node_modules/rxjs/**/*', 'node_modules/typescript/**/*', 'node_modules/plugin-typescript/**/*', 'node_modules/angular-2-dropdown-multiselect/**/*'], {base: './node_modules'}) 36 | .pipe(gulp.dest('./build/node_modules')); 37 | }) 38 | 39 | gulp.task('bundle:app', function() { 40 | var builder = new systemjsBuilder('build/', 'build/system.config.js'); 41 | return builder.buildStatic('./build/app/app.ts', './build/app/app.bundle.min.js', 42 | { 43 | sourceMaps: true, 44 | runtime: false, 45 | sourceMapContents: true, 46 | // don't minify (for now). Seems to mess up the sourcemaps. 47 | //minify: true 48 | }); 49 | }); 50 | 51 | gulp.task('html:replace', function() { 52 | return gulp.src('build/index.html') 53 | .pipe(htmlreplace({ 54 | 'load_bundle': 'app/app.bundle.min.js' 55 | }, 56 | { 57 | keepUnassigned: false //will remove any build sections that we don't have any explicit actions for 58 | })) 59 | .pipe(gulp.dest('build/')); 60 | }) 61 | 62 | gulp.task('bundle:vendor', function() { 63 | return gulp.src(paths.vendor_js) 64 | .pipe(sourcemaps.init()) 65 | .pipe(concat('vendor.bundle.js')) 66 | .pipe(uglify()) 67 | .pipe(rename('vendor.bundle.min.js')) 68 | .pipe(sourcemaps.write('.')) 69 | .pipe(gulp.dest('./build/vendor')); 70 | }); 71 | 72 | gulp.task('tslint', function() { 73 | return gulp.src(['./src/app/*.ts']) 74 | .pipe(tslint({ 75 | configuration: 'tslint.json', 76 | formatter: 'prose' 77 | })) 78 | .pipe(tslint.report({ 79 | summarizeFailureOutput: false, 80 | reportLimit: 20 81 | })); 82 | }); 83 | 84 | // actual transpilation is done in systemjs. This just runs the source through the compiler for type checking, 85 | // so we can get any compiler errors without having to go through the more expensive bundling step 86 | gulp.task('compile:typecheck', function() { 87 | var tsProject = ts.createProject('tsconfig.json'); 88 | return tsProject.src() 89 | .pipe(tsProject()) 90 | }) 91 | 92 | gulp.task('compile:copy', function() { 93 | return gulp.src(['./src/**/*', 'tsconfig.json']) 94 | .pipe(gulp.dest('./build')); 95 | }); 96 | 97 | gulp.task('compile:embed', function () { 98 | return gulp.src('./build/app/*.ts') 99 | .pipe(embedTemplates({sourceType:'ts'})) 100 | .pipe(gulp.dest('./build/app')); 101 | }); 102 | 103 | // compiles just local sources 104 | gulp.task('compile:sources', function(callback) { 105 | runSequence( ['tslint', 'compile:typecheck'], 'compile:copy', 'compile:embed', callback); 106 | }); 107 | 108 | gulp.task('compile', ['copy:vendor', 'bundle:vendor', 'compile:sources']); 109 | 110 | gulp.task('test', function(callback) { 111 | new karma.Server({ 112 | configFile: __dirname + '/karma.conf.js', 113 | singleRun: true 114 | }, callback).start(); 115 | }); 116 | 117 | gulp.task('test:watch', function(callback) { 118 | new karma.Server({ 119 | configFile: __dirname + '/karma.conf.js' 120 | }, callback).start(); 121 | }); 122 | 123 | gulp.task('build', function(callback) { 124 | runSequence('clean', 'compile', ['bundle:app', 'html:replace'], callback); 125 | }); 126 | 127 | gulp.task('package', ['build'], function() { 128 | return run('tfx extension create --root build --manifest-globs manifest.json --output-path dist').exec() 129 | }); 130 | 131 | gulp.task('replace-id-dev-html', function() { 132 | gulp.src(['build/configuration.html']) 133 | .pipe(replace('tfs-pullrequest-dashboard-widget', 'tfs-pullrequest-dashboard-widget-dev')) 134 | .pipe(gulp.dest('build/')); 135 | }); 136 | 137 | gulp.task('replace-id-dev-ts', function() { 138 | gulp.src(['build/app/appConfig.service.ts', 'build/app/tfsAppSettings.service.ts']) 139 | .pipe(replace('tfs-pullrequest-dashboard-widget', 'tfs-pullrequest-dashboard-widget-dev')) 140 | .pipe(replace('tfs-pullrequest-dashboard.tfs-pullrequest-dashboard', 'tfs-pullrequest-dashboard-dev.tfs-pullrequest-dashboard-dev')) 141 | .pipe(gulp.dest('build/app/')); 142 | }); 143 | 144 | gulp.task('build:dev', function(callback) { 145 | runSequence('clean', 'compile', ['replace-id-dev-html', 'replace-id-dev-ts'], ['bundle:app', 'html:replace'], callback); 146 | }); 147 | 148 | // for building dev versions of the extension that can be uploaded without chaning the released version 149 | gulp.task('package:dev', ['build:dev'], function() { 150 | return run('tfx extension create --root build --manifest-globs manifest-dev.json --output-path dist').exec() 151 | }); 152 | 153 | gulp.task('serve', ['compile'], function() { 154 | 155 | gulp.watch(['./src/**/*.*', './test/**/*.*', './tsconfig.json', './tslint.json'], ['compile:sources']); 156 | 157 | connect.server({ 158 | root: './build', 159 | port: 5000, 160 | https: true 161 | }); 162 | }); 163 | 164 | gulp.task('default', ['build']); 165 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, NgZone, OnInit } from "@angular/core"; 2 | import { IMultiSelectOption, IMultiSelectSettings, IMultiSelectTexts } from "angular-2-dropdown-multiselect"; 3 | 4 | import { AppSettingsServiceProvider } from "./appSettingsService.provider"; 5 | import { AppSettingsService, Layout, TfsService, User } from "./model"; 6 | import { PullRequestViewModel } from "./pullRequestViewModel"; 7 | import { TfsServiceProvider } from "./tfsService.provider"; 8 | 9 | @Component({ 10 | selector: "my-app", 11 | templateUrl: "app.component.html", 12 | providers: [new TfsServiceProvider(), new AppSettingsServiceProvider()], 13 | }) 14 | export class AppComponent implements OnInit { 15 | 16 | private static defaultDateFormat = "dd/MM/yyyy HH:mm"; 17 | 18 | public pullRequests: PullRequestViewModel[] = []; 19 | 20 | public repositories: GitRepository[] = []; 21 | 22 | public currentUser: User; 23 | 24 | public filterSettings: IMultiSelectSettings = { 25 | enableSearch: true, 26 | buttonClasses: "btn btn-default btn-sm fa fa-filter", 27 | closeOnSelect: false, 28 | showCheckAll: true, 29 | showUncheckAll: true 30 | }; 31 | 32 | public filterTexts: IMultiSelectTexts = { 33 | checkAll: "Select all", 34 | uncheckAll: "Unselect all", 35 | checked: "repos selected", 36 | checkedPlural: "repos selected", 37 | searchPlaceholder: "Search...", 38 | defaultTitle: "Select" 39 | }; 40 | 41 | public filteredRepoIds: string[] = []; 42 | 43 | public unfilteredRepoSelections: number[] = []; 44 | 45 | public repoOptions: IMultiSelectOption[] = []; 46 | 47 | public dateFormat: string = AppComponent.defaultDateFormat; 48 | 49 | public allProjects: boolean = false; 50 | 51 | public loading: boolean = false; 52 | 53 | public layout: Layout; 54 | 55 | public hubUri: string = "#"; 56 | 57 | public rowLimit: number = 0; 58 | 59 | constructor(private tfsService: TfsService, 60 | private settings: AppSettingsService, 61 | private zone: NgZone) { 62 | 63 | this.settings.layoutChanged().on((data) => this.updateLayout(data)); 64 | this.hubUri = this.settings.getHubUri(); 65 | } 66 | 67 | public ngOnInit() { 68 | this.refresh(); 69 | } 70 | 71 | public async refresh() { 72 | this.loading = true; 73 | try { 74 | this.updateLayout(this.settings.getLayout()); 75 | const filterPromise = this.settings.getRepoFilter(); 76 | const formatPromise = this.settings.getDateFormat(); 77 | const allProjectsPromise = this.settings.getShowAllProjects(); 78 | const currentUserPromise = this.tfsService.getCurrentUser(); 79 | 80 | this.filteredRepoIds = await filterPromise; 81 | this.dateFormat = await formatPromise; 82 | this.allProjects = await allProjectsPromise; 83 | this.currentUser = await currentUserPromise; 84 | await this.reloadPullRequests(); 85 | } finally { 86 | this.loading = false; 87 | } 88 | } 89 | 90 | public async reloadPullRequests(): Promise { 91 | this.loading = true; 92 | try { 93 | const getReposPromise = this.tfsService.getRepositories(this.allProjects); 94 | 95 | const repos = await getReposPromise; 96 | this.repositories = repos.sort((a, b) => { 97 | if (a.name.toLowerCase() > b.name.toLowerCase()) { 98 | return 1; 99 | } 100 | if (a.name.toLowerCase() < b.name.toLowerCase()) { 101 | return -1; 102 | } 103 | return 0; 104 | }); 105 | 106 | this.pullRequests = []; 107 | 108 | this.tfsService.getPullRequests(this.allProjects) 109 | .map((pr) => new PullRequestViewModel(pr, repoById[pr.repository.id], this.currentUser)) 110 | .bufferTime(500) 111 | .subscribe((prs) => this.zone.run(() => this.pullRequests.push(...prs))); 112 | 113 | this.repoOptions = []; 114 | this.unfilteredRepoSelections.length = 0; 115 | const repoById: Map = new Map(); 116 | for (let i = 0; i < this.repositories.length; i++) { 117 | const repo = this.repositories[i]; 118 | repoById[repo.id] = repo; 119 | 120 | this.repoOptions.push({ 121 | id: i, 122 | name: repo.name 123 | }); 124 | 125 | if (this.filteredRepoIds.indexOf(repo.id) < 0) { 126 | this.unfilteredRepoSelections.push(i); 127 | } 128 | } 129 | } finally { 130 | this.loading = false; 131 | } 132 | } 133 | 134 | public onFilteredSelectionsChanged(unfiltered: number[]) { 135 | if (this.loading) { 136 | return; 137 | } 138 | 139 | this.filteredRepoIds = []; 140 | for (const repoOption of this.repoOptions) { 141 | if (unfiltered.indexOf(repoOption.id) < 0) { 142 | const repo = this.getRepoByName(repoOption.name); 143 | this.filteredRepoIds.push(repo.id); 144 | } 145 | } 146 | this.settings.setRepoFilter(this.filteredRepoIds); 147 | } 148 | 149 | public onDateFormatChanged(format: string) { 150 | if (this.loading) { 151 | return; 152 | } 153 | 154 | this.dateFormat = format; 155 | this.settings.setDateFormat(format); 156 | } 157 | 158 | public onAllProjectsChanged(allProjects: boolean) { 159 | if (this.loading) { 160 | return; 161 | } 162 | 163 | this.allProjects = allProjects; 164 | this.settings.setShowAllProjects(allProjects); 165 | this.reloadPullRequests(); 166 | } 167 | 168 | private getRepoByName(name: string): GitRepository { 169 | for (const repo of this.repositories) { 170 | if (repo.name === name) { 171 | return repo; 172 | } 173 | } 174 | } 175 | 176 | private updateLayout(layout: Layout) { 177 | this.layout = layout; 178 | if (layout.widgetMode && layout.size) { 179 | // trial & error - this allows the most PRs to be displayed while fitting nicely in the available 180 | // widget space 181 | this.rowLimit = (layout.size.rowSpan * 3) + (layout.size.rowSpan - 2); 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/app/appConfig.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | 3 | import { Layout, LiteEvent } from "./model"; 4 | 5 | // Used to initialize the application during bootstrap, including resolving VSS services so that they 6 | // can be injected in the constructors of other services. 7 | // This is the only class that should be interacting with the VSS module. 8 | @Injectable() 9 | export class AppConfigService { 10 | 11 | // Set this to true to develop & test locally 12 | private _devMode: boolean = false; 13 | // change this to the endpoint of the tfs service that you wish to develop against 14 | private _devApiEndpoint: string = "https:///tfs/DefaultCollection"; 15 | // change this to the default project to filter to if all projects is not selected 16 | private _devDefaultProject: string = "MyFirstProject"; 17 | 18 | private _gitClientFactory: GitClientFactory; 19 | private _identitiesClientFactory: IdentitiesClientFactory; 20 | private _coreClientFactory: CoreHttpClientFactory; 21 | private _context: Context; 22 | private _extensionDataService: IExtensionDataService; 23 | private _layout: Layout = { 24 | categories: [{ 25 | key: "requestedByMe", name: "Requested By Me" 26 | }, 27 | { 28 | key: "assignedToMe", name: "Assigned To Me" 29 | }, 30 | { 31 | key: "assignedToMyTeam", name: "Assigned To My Team" 32 | }], 33 | widgetMode: false 34 | }; 35 | 36 | private _layoutChanged = new LiteEvent(); 37 | 38 | get devMode(): boolean { return this._devMode; } 39 | get devApiEndpoint(): string { return this._devApiEndpoint; } 40 | get devDefaultProject(): string { return this._devDefaultProject; } 41 | 42 | get gitClientFactory(): GitClientFactory { return this._gitClientFactory; } 43 | get identitiesClientFactory(): IdentitiesClientFactory { return this._identitiesClientFactory; } 44 | get coreClientFactory(): CoreHttpClientFactory { return this._coreClientFactory; } 45 | get context(): Context { return this._context; } 46 | get extensionDataService(): IExtensionDataService { return this._extensionDataService; } 47 | get layout(): Layout { return this._layout; } 48 | get layoutChanged() { return this._layoutChanged.expose(); } 49 | 50 | public initialize(): Promise { 51 | return new Promise((resolve, reject) => { 52 | 53 | // don't do any of the VSS init when in dev mode 54 | if (this._devMode) { 55 | resolve(true); 56 | return; 57 | } 58 | 59 | VSS.init({ 60 | usePlatformScripts: false, 61 | usePlatformStyles: false, 62 | explicitNotifyLoaded: true, 63 | applyTheme: true 64 | }); 65 | 66 | VSS.require(["TFS/VersionControl/GitRestClient", "VSS/Identities/RestClient", "VSS/Context", "TFS/Dashboards/WidgetHelpers", "TFS/Core/RestClient"], 67 | (gitFactory: GitClientFactory, identityFactory: IdentitiesClientFactory, context: Context, widgetHelpers: any, coreClientFactory: CoreHttpClientFactory) => { 68 | this._gitClientFactory = gitFactory; 69 | this._identitiesClientFactory = identityFactory; 70 | this._coreClientFactory = coreClientFactory; 71 | this._context = context; 72 | this._layout.widgetMode = false; 73 | 74 | const pageContext = context.getPageContext(); 75 | if (pageContext.navigation.routeId) { 76 | // In Azure Devops 2019 you can use routeId to determine if you're in the dashboards hub 77 | this._layout.widgetMode = pageContext.navigation.routeId.startsWith("ms.vss-dashboards-web"); 78 | } 79 | if (pageContext.hubsContext.selectedHubId && !this._layout.widgetMode) { 80 | // In VSTS 2017 you can use selectedHubId to determine if you're in the dashboards hub 81 | this._layout.widgetMode = pageContext.hubsContext.selectedHubId.startsWith("ms.vss-dashboards-web"); 82 | } 83 | VSS.getService(VSS.ServiceIds.ExtensionData) 84 | .then((service) => { 85 | this._extensionDataService = service; 86 | if (this._layout.widgetMode) { 87 | widgetHelpers.IncludeWidgetStyles(); 88 | VSS.register("tfs-pullrequest-dashboard-widget", () => { 89 | return { 90 | load: (settings) => { 91 | let loadedCategory = false; 92 | if (settings.customSettings.data) { 93 | const s = JSON.parse(settings.customSettings.data); 94 | if (s && s.prCategory) { 95 | this._layout.categories = [{ 96 | key: s.prCategory, 97 | name: this.getWidgetCatName(s.prCategory) 98 | }]; 99 | loadedCategory = true; 100 | } 101 | } 102 | 103 | if (!loadedCategory) { 104 | this._layout.categories = [{ 105 | key: "assignedToMe", 106 | name: this.getWidgetCatName("assignedToMe") 107 | }]; 108 | } 109 | 110 | this._layout.size = settings.size; 111 | 112 | resolve(true); 113 | return widgetHelpers.WidgetStatusHelper.Success(); 114 | }, 115 | reload: (settings) => { 116 | if (settings.customSettings.data) { 117 | const s = JSON.parse(settings.customSettings.data); 118 | if (s && s.prCategory) { 119 | this._layout.categories = [{ 120 | key: s.prCategory, 121 | name: this.getWidgetCatName(s.prCategory) 122 | }]; 123 | } 124 | } 125 | this._layout.size = settings.size; 126 | this._layoutChanged.trigger(this._layout); 127 | return widgetHelpers.WidgetStatusHelper.Success(); 128 | } 129 | }; 130 | }); 131 | VSS.notifyLoadSucceeded(); 132 | } else { 133 | resolve(true); 134 | VSS.notifyLoadSucceeded(); 135 | } 136 | }); 137 | }); 138 | }); 139 | } 140 | 141 | private getWidgetCatName(cat: string): string { 142 | switch (cat) { 143 | case "requestedByMe": 144 | return "My Pull Requests"; 145 | case "assignedToMe": 146 | return "Pull Requests Assigned To Me"; 147 | case "assignedToMyTeam" : 148 | return "Pull Requests Assigned to My Team"; 149 | default: 150 | return "All Pull Requests"; 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/overview.md: -------------------------------------------------------------------------------- 1 | # Pull Request Dashboard 2 | 3 | When working with a codebase that can span dozens of different code repositories, it can be difficult to keep track of the pull requests that you have created, or ones created by others that you may need to review. This extension solves this issue by providing a place that pull requests across all repositories can be viewed on a single page. 4 | 5 | ## Pull Request Dashboard Code Hub 6 | 7 | This extension adds a new hub to the code section of Visual Studio Team Services/Team Foundation Service for viewing all active pull requests across all repositories. 8 | 9 | ![New Hub](assets/screenshots/hub_tab.png) 10 | 11 | ### Pull Request Categories 12 | 13 | The hub queries for all active pull requests, and groups them into several different categories. Each category sorts pull requests, showing the oldest pull requests first. 14 | 15 | * Requested by me - Pull requests requested by the current user 16 | * Assigned to me - Pull requests created by another user that have the current user assigned as a reviewer 17 | * Assigned to my team - Pull requests created by another user that have a team assigned as a reviewer that the current user is directly or indirectly a member of. 18 | * Indirect team membership means that a team that the current user is not a member of, but one of the teams that the user *is* a member of, is a member of the team (ex. User memberof Team1 memberof Team2). This allows for pull requests assigned to "virtual" teams: a team made up of several other teams (ex. "UX Team" is made up of "UX Team 1" & "UX Team 2"). 19 | * **Note**: Currently only supported for on-premises Team Foundation Server installations, due to APIs used that are currently unavailable in Visual Studio Team Services 20 | * Other Open pull requests - All other active pull requests that don't fall into any of the above categories 21 | 22 | ![Pull Request Categories](assets/screenshots/hub_view.png) 23 | 24 | ### Pull Request Details 25 | Each pull request displayed shows a number of useful pieces of information. 26 | 27 | * PR Title & Author 28 | * Repository name, from branch & to branch 29 | * Tags - Status tags for the pull request 30 | * Tag showing conflicts, if the pull request has conflicts 31 | * Tag showing if the pull request is a draft 32 | * Tag showing if the pull request is set to auto-complete 33 | * Tag for each status set on the pull request 34 | * Reviewers 35 | * Each reviewer on the PR has an icon next to their name indicating their vote 36 | * Solid Green Circle Checkmark = Approved 37 | * Hollow Green Circle Checkmark = Approved With Suggestions 38 | * Yellow Circle Dash = Waiting for Author 39 | * Red Circle X = Rejected 40 | * If a reviewer is marked as required on a PR, an asterisk is added next to their name (ex "User1*" indicates that User1 is a required reviewer on the PR) 41 | 42 | ### Filtering by Repository 43 | 44 | For an organization with dozens of different code repositories, an individual developer may only actively work in a handful of those repos, and may not care about PRs in other repos. Next to the Other Pull Requests group header is a filter button that allows users to select just the repositories that they are interested in, and the Other Open Pull Requests category will dynamically update to show PRs only in repos selected. Filter selections will be saved, allowing subsequent page loads to keep the user's filter selection. 45 | 46 | ![Filtering Repositories](assets/screenshots/repo_filter.png) 47 | 48 | ### Settings 49 | 50 | At the top right of the dashboard plugin is a button which will drop down user-specific settings. The settings supported are: 51 | 52 | * Date Format - specifies the format to display PR creation date timestamp. Default is "dd/MM/yyyy HH:mm". 53 | * All Projects - show pull requests from repositories across all projects. 54 | 55 | ![Settings](assets/screenshots/settings.png) 56 | 57 | ## Pull Request Dashboard Widget 58 | 59 | This extension also adds a new widget that can be added to any VSTS dashboard. 60 | 61 | ![Add Widget](assets/screenshots/add_widget.png) 62 | 63 | ### Widget Display 64 | 65 | The widget shows a more compact version of the pull request details, compared to the code hub dashboard. The differences are: 66 | 67 | * The name of the person that created the pull request, as well as the date created is not displayed. 68 | * That information is still accessible as a tooltip by hovering your cursor over the PR creators image in the widget. 69 | * The individual names of reviewers are not displayed for each PR. Instead, the icons indicating the type of vote is shown, with the number of reviewers that have responded with same vote next to the icon. 70 | * Hovering over the vote icon in the widget will display the list of reviewers that responded with that vote. 71 | 72 | ![Widget](assets/screenshots/widget.png) 73 | 74 | ### Widget Configuration 75 | 76 | The widget allows configuration of the size, as well as the category of pull requests to display. The categories are: 77 | 78 | * Requested By Me - Pull requests created by the current user. 79 | * Assigned To Me - Pull requests where the current user is assigned as a reviewer. 80 | * Assigned To My Team - Pull requests assigned to the team of the current user. 81 | * All - All pull requests, with no filtering applied. 82 | 83 | ## Source Code and Issue Tracking 84 | 85 | Source code for this extension can be found in the [tfs-pullrequest-dashboard](https://github.com/rstedman/tfs-pullrequest-dashboard) GitHub Repository. Please report any issues found with this extension to the issues section of the GitHub repository [here](https://github.com/rstedman/tfs-pullrequest-dashboard/issues). 86 | 87 | ## Change Log 88 | 89 | * (03/29/2020) 2.4.1 - bug fix: only display the most recent status per unique status name. 90 | * (08/12/2019) 2.4.0 - feature: theming support 91 | * (23/07/2019) 2.3.1 - bug fix: dashboard widget does not load in AZDO 92 | * (18/06/2019) 2.3.0 - feature: add pull request id to PR view layout. 93 | * (18/06/2019) 2.2.1 - bug fix: small change to attempt to reduce performance impact of features in 2.2.0 release. 94 | * (02/06/2019) 2.2.0 95 | * feature: Add status tag if the pull request is set to auto complete 96 | * feature: Add status tag for each status set on the pull request 97 | * (23/05/2019) 2.1.0 - feature: add indicator for draft PRs. Also change location of conflict indicator to be consistent. 98 | * (23/04/2019) 2.0.6 - bug fix: the dashboard widget stopped loading in Azure Devops 2019 99 | * (18/11/2018) 2.0.5 - bug fix: navigating to a PR from this extension would result in an error when using dev.azure.com. 100 | * (09/09/2018) 2.0.4 - bug fix: dashboard only shows top 101 active PRs. Update that to top 1000 active PRs. 101 | * (22/02/2018) 2.0.2 - bug fix: dashboard widget breaks with the new dashboards experience VSTS setting enabled. 102 | * (20/12/2017) 2.0.1 - bug fix: when showing pull requests across all projects in a TFS deployment. 103 | * (17/12/2017) 2.0.0 104 | * Added VSTS dashboard widget 105 | * Update pull request display layout - more compact & more in line with how VSTS displays pull requests in other views. 106 | * More efficient querying of pull requests - faster load time. 107 | * (01/10/2017) 1.2.0 - Added user setting to show PRs from repos across all projects. Default behavior still shows PRs from the current project. 108 | * (24/06/2017) 1.1.1 - don't show PRs in "Assigned To My Team" if also "Assigned To Me". 109 | * (17/06/2017) 1.1.0 - added date format user setting 110 | * (03/11/2016) 1.0.1 - bug fix: navigating to a PR would open the PR in the plugin iframe, instead of navigating the parent page 111 | * (02/11/2016) 1.0.0 - initial release 112 | -------------------------------------------------------------------------------- /test/pullRequestFilter.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import {PullRequestAsyncStatus, User} from "../src/app/model"; 2 | import {PullRequestFilterPipe} from "../src/app/pullRequestFilter.pipe"; 3 | import {PullRequestViewModel} from "../src/app/pullRequestViewModel"; 4 | import {TestUtils} from "./testHelpers"; 5 | 6 | describe("PullRequestFilterPipe", () => { 7 | 8 | function createPRViewModel(createdBy: string, reviewers: string[]): PullRequestViewModel { 9 | const user: User = { 10 | id: "user1", 11 | displayName: "test user", 12 | uniqueName: "testuser1", 13 | memberOf: [ 14 | TestUtils.createIdentity("team1"), 15 | TestUtils.createIdentity("team2") 16 | ] 17 | }; 18 | const repo: GitRepository = { 19 | _links: { 20 | web: { 21 | href: "http://git/repo" 22 | } 23 | }, 24 | defaultBranch: "master", 25 | url: `http://git/repo`, 26 | id: "repo", 27 | name: "repo", 28 | project: null, 29 | remoteUrl: `http://git/repo` 30 | }; 31 | const pr = TestUtils.createPullRequest({ 32 | created: new Date(), 33 | createdById: createdBy, 34 | id: 1, 35 | mergeStatus: PullRequestAsyncStatus.Succeeded, 36 | sourceBranch: "testbranch", 37 | targetBranch: "master", 38 | title: "test123", 39 | reviewers: reviewers.map( (x) => { 40 | return { 41 | id: x, 42 | required: false, 43 | vote: 0 44 | }; 45 | }) 46 | }); 47 | return new PullRequestViewModel(pr, repo, user); 48 | } 49 | 50 | let subject: PullRequestFilterPipe = null; 51 | 52 | beforeEach(() => { 53 | subject = new PullRequestFilterPipe(); 54 | }); 55 | 56 | it("returns an emtpy list if given an empty list", () => { 57 | const result = subject.transform([], "requestedByMe"); 58 | expect(result).toBeDefined(); 59 | expect(result.length).toEqual(0); 60 | }); 61 | 62 | it("returns only PRs requested by me when filtered", () => { 63 | const pr1 = createPRViewModel("user2", []); 64 | const pr2 = createPRViewModel("user1", ["user2"]); 65 | const pr3 = createPRViewModel("user3", ["user2", "user1"]); 66 | const pr4 = createPRViewModel("user1", ["user2"]); 67 | 68 | const result = subject.transform([pr1, pr2, pr3, pr4], "requestedByMe"); 69 | expect(result).toBeDefined(); 70 | expect(result.length).toEqual(2); 71 | expect(result[0]).toEqual(pr2); 72 | expect(result[1]).toEqual(pr4); 73 | }); 74 | 75 | it("returns returns an empty list if no prs requested by me when filtered", () => { 76 | const pr1 = createPRViewModel("user2", []); 77 | const pr2 = createPRViewModel("user4", ["user2"]); 78 | const pr3 = createPRViewModel("user3", ["user2", "user1"]); 79 | const pr4 = createPRViewModel("user2", ["user1"]); 80 | 81 | const result = subject.transform([pr1, pr2, pr3, pr4], "requestedByMe"); 82 | expect(result).toBeDefined(); 83 | expect(result.length).toEqual(0); 84 | }); 85 | 86 | it("returns PRs assignedToMe when filtered", () => { 87 | const pr1 = createPRViewModel("user2", ["user3", "user5", "user1", "user10"]); 88 | const pr2 = createPRViewModel("user1", ["user2"]); 89 | const pr3 = createPRViewModel("user3", ["user2", "user1"]); 90 | const pr4 = createPRViewModel("user1", ["user2"]); 91 | 92 | const result = subject.transform([pr1, pr2, pr3, pr4], "assignedToMe"); 93 | expect(result).toBeDefined(); 94 | expect(result.length).toEqual(2); 95 | expect(result[0]).toEqual(pr1); 96 | expect(result[1]).toEqual(pr3); 97 | }); 98 | 99 | it("doesn't return PRs assignedToMe if also createdByMe", () => { 100 | const pr1 = createPRViewModel("user1", ["user3", "user5", "user1", "user10"]); 101 | const pr2 = createPRViewModel("user1", ["user2"]); 102 | const pr3 = createPRViewModel("user2", ["user2", "user1"]); 103 | const pr4 = createPRViewModel("user1", ["user2"]); 104 | 105 | const result = subject.transform([pr1, pr2, pr3, pr4], "assignedToMe"); 106 | expect(result).toBeDefined(); 107 | expect(result.length).toEqual(1); 108 | expect(result[0]).toEqual(pr3); 109 | }); 110 | 111 | it("returns an empty list if no PRs assignedtoMe", () => { 112 | const pr1 = createPRViewModel("user2", ["user3", "user5", "user4", "user10"]); 113 | const pr2 = createPRViewModel("user3", ["user2"]); 114 | const pr3 = createPRViewModel("user2", ["user2", "user5"]); 115 | const pr4 = createPRViewModel("user5", ["user2"]); 116 | 117 | const result = subject.transform([pr1, pr2, pr3, pr4], "assignedToMe"); 118 | expect(result).toBeDefined(); 119 | expect(result.length).toEqual(0); 120 | }); 121 | 122 | it("returns PRs assignedToMyTeam when filtered", () => { 123 | const pr1 = createPRViewModel("user2", ["user3", "team1", "user4", "user10"]); 124 | const pr2 = createPRViewModel("user3", ["user2"]); 125 | const pr3 = createPRViewModel("user2", ["user2", "user5"]); 126 | const pr4 = createPRViewModel("user5", ["team2"]); 127 | 128 | const result = subject.transform([pr1, pr2, pr3, pr4], "assignedToMyTeam"); 129 | expect(result).toBeDefined(); 130 | expect(result.length).toEqual(2); 131 | expect(result[0]).toEqual(pr1); 132 | expect(result[1]).toEqual(pr4); 133 | }); 134 | 135 | it("doesn't return PRs assignedToMyTeam if also requestedByMe", () => { 136 | const pr1 = createPRViewModel("user1", ["user3", "team1", "user4", "user10"]); 137 | const pr2 = createPRViewModel("user3", ["user2"]); 138 | const pr3 = createPRViewModel("user2", ["user2", "user5"]); 139 | const pr4 = createPRViewModel("user5", ["team2"]); 140 | 141 | const result = subject.transform([pr1, pr2, pr3, pr4], "assignedToMyTeam"); 142 | expect(result).toBeDefined(); 143 | expect(result.length).toEqual(1); 144 | expect(result[0]).toEqual(pr4); 145 | }); 146 | 147 | it("returns an empty list if no PRs assignedToMyTeam", () => { 148 | const pr1 = createPRViewModel("user3", ["user3", "team4", "user4", "user10"]); 149 | const pr2 = createPRViewModel("user3", ["user2"]); 150 | const pr3 = createPRViewModel("user2", ["user2", "user5"]); 151 | const pr4 = createPRViewModel("user5", ["team3"]); 152 | 153 | const result = subject.transform([pr1, pr2, pr3, pr4], "assignedToMyTeam"); 154 | expect(result).toBeDefined(); 155 | expect(result.length).toEqual(0); 156 | }); 157 | 158 | it("returns all other PRs if filtering for other", () => { 159 | const pr1 = createPRViewModel("user1", []); // matches requestedByMe 160 | const pr2 = createPRViewModel("user3", ["user2"]); 161 | const pr3 = createPRViewModel("user2", ["user2", "user1"]); // matches assignedToMe 162 | const pr4 = createPRViewModel("user5", ["team3"]); 163 | const pr5 = createPRViewModel("user5", ["team1"]); // matches assignedToMyTeam 164 | 165 | const result = subject.transform([pr1, pr2, pr3, pr4, pr5], "other"); 166 | expect(result).toBeDefined(); 167 | expect(result.length).toEqual(2); 168 | expect(result[0]).toEqual(pr2); 169 | expect(result[1]).toEqual(pr4); 170 | }); 171 | 172 | it("doesn't return PRs assignedToMyTeam if also assignedToMe", () => { 173 | const pr1 = createPRViewModel("user3", ["user1", "team1", "user4", "user10"]); 174 | const pr2 = createPRViewModel("user2", ["team1", "user5"]); 175 | 176 | const result = subject.transform([pr1, pr2], "assignedToMyTeam"); 177 | expect(result).toBeDefined(); 178 | expect(result.length).toEqual(1); 179 | expect(result[0]).toEqual(pr2); 180 | 181 | const result2 = subject.transform([pr1, pr2], "assignedToMe"); 182 | expect(result2).toBeDefined(); 183 | expect(result2.length).toEqual(1); 184 | expect(result2[0]).toEqual(pr1); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /src/app/restfulTfs.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from "@angular/core"; 2 | import {Headers, Http, Response} from "@angular/http"; 3 | 4 | import { Observable } from "rxjs"; 5 | import "rxjs/Rx"; 6 | 7 | import {AppConfigService} from "./appConfig.service"; 8 | import {GitPullRequestWithStatuses, GitStatusState, PullRequestAsyncStatus, TfsService, User} from "./model"; 9 | 10 | @Injectable() 11 | // Interacts with TFS REST APis. Meant for use when not running in the context of a TFS extension (ie. development) 12 | export class RestfulTfsService extends TfsService { 13 | 14 | private USER_HEADER_NAME: string = "x-vss-userdata"; 15 | // need to specify the version to get the response objects to look the same as when requested using the VSS Extension APIs 16 | private IDENTITIES_API_ACCEPT_HEADER: string = "application/json; api-version=2.3-preview.1"; 17 | 18 | private baseUri: string; 19 | private currentProject: string; 20 | 21 | constructor(private http: Http, config: AppConfigService) { 22 | super(); 23 | 24 | this.baseUri = config.devApiEndpoint; 25 | this.currentProject = config.devDefaultProject; 26 | } 27 | 28 | public async getCurrentUser(): Promise { 29 | // just do a basic query to tfs to be able to look at response headers 30 | let r = await this.http.get(`${this.baseUri}/_apis/projects`, {withCredentials: true}).toPromise(); 31 | // aren't actually interested in the projects response body, just the response headers. 32 | // tfs adds a header in the response with the current authenticated users id in the format : 33 | const userIdHeader = r.headers.get(this.USER_HEADER_NAME); 34 | const headerRegex = /([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i; 35 | const match = headerRegex.exec(userIdHeader); 36 | const userId = match[1]; 37 | 38 | r = await this.http.get(`${this.baseUri}/_apis/Identities/${userId}`, { 39 | withCredentials: true, 40 | headers: new Headers({Accept: this.IDENTITIES_API_ACCEPT_HEADER}) 41 | }).toPromise(); 42 | const userIdentity: Identity = r.json(); 43 | const user: User = { 44 | id: userIdentity.id, 45 | displayName: userIdentity.customDisplayName, 46 | uniqueName: userIdentity.providerDisplayName, 47 | memberOf: [] 48 | }; 49 | 50 | const membersOf = await this.getMembersOf(userId); 51 | const promises: Array> = []; 52 | for (const m of membersOf) { 53 | user.memberOf.push(m); 54 | // now recurse once into the subgroups of each group the member is a member of, to include 55 | // virtual groups made up of several groups 56 | promises.push(this.getMembersOf(m.id)); 57 | } 58 | const subMembersOf = await Promise.all(promises); 59 | for (const members of subMembersOf) { 60 | for (const i of members) { 61 | user.memberOf.push(i); 62 | } 63 | } 64 | 65 | return user; 66 | } 67 | 68 | public getPullRequests(allProjects?: boolean): Observable { 69 | let url = `${this.baseUri}/${this.currentProject}/_apis/git/pullRequests?status=active&$top=1000`; 70 | if (allProjects) { 71 | url = `${this.baseUri}/_apis/git/pullRequests?status=active&$top=1000`; 72 | } 73 | 74 | return this.http.get(url, {withCredentials: true}) 75 | .map((r: Response) => this.extractData(r)) 76 | .mergeMap((prs: GitPullRequest[]) => prs) 77 | .flatMap((pr) => this.getPullRequestComplete(pr)) 78 | .flatMap((pr) => this.getPullRequestWithStatuses(pr)) 79 | .map((pr: any) => { 80 | if (pr.mergeStatus) { 81 | // the rest apis return a string for the mergestatus, but the VSS APIs convert that into 82 | // an int. Do the same here, so we can treat PRs the same throughout the app. 83 | // note - we only care about conflicts for now, since we only show something different on merge conflicts. 84 | if (pr.mergeStatus === "conflicts") { 85 | pr.mergeStatus = PullRequestAsyncStatus.Conflicts; 86 | } else { 87 | pr.mergeStatus = PullRequestAsyncStatus.Succeeded; 88 | } 89 | } 90 | return pr; 91 | }); 92 | } 93 | 94 | public getRepositories(allProjects?: boolean): Promise { 95 | let url = `${this.baseUri}/${this.currentProject}/_apis/git/repositories?includeLinks=true`; 96 | if (allProjects) { 97 | url = `${this.baseUri}/_apis/git/repositories?includeLinks=true`; 98 | } 99 | return this.http.get(url, {withCredentials: true}) 100 | .toPromise() 101 | .then(this.extractData) 102 | .catch(this.handleError); 103 | } 104 | 105 | private getPullRequestComplete(pullRequest: GitPullRequest): Observable { 106 | const url = `${this.baseUri}/_apis/git/repositories/${pullRequest.repository.id}/pullRequests/${pullRequest.pullRequestId}`; 107 | return this.http.get(url, {withCredentials: true}) 108 | .map((r: Response) => r.json()); 109 | } 110 | 111 | private getPullRequestWithStatuses(pullRequest: GitPullRequest): Observable { 112 | const url = `${this.baseUri}/_apis/git/repositories/${pullRequest.repository.id}/pullRequests/${pullRequest.pullRequestId}/statuses`; 113 | return this.http.get(url, {withCredentials: true}) 114 | .map((r: Response) => { 115 | const res: any = {statuses: this.extractData(r)}; 116 | // The convert the rest api status to the enum for consistency with the extensions api 117 | for (const status of res.statuses) { 118 | const statusUpdate = {state: GitStatusState.Pending}; 119 | // pending statuses don't have a state set on them 120 | if (status.state) { 121 | if (status.state === "failed") { 122 | statusUpdate.state = GitStatusState.Failed; 123 | } else if (status.state === "succeeded") { 124 | statusUpdate.state = GitStatusState.Succeeded; 125 | } 126 | } 127 | Object.assign(status, statusUpdate); 128 | } 129 | Object.assign(res, pullRequest); 130 | return res; 131 | }); 132 | } 133 | 134 | private async getMembersOf(userId: string): Promise { 135 | const response = await (this.http.get(`${this.baseUri}/_apis/Identities/${userId}/membersOf`, { 136 | withCredentials: true, 137 | headers: new Headers({Accept: this.IDENTITIES_API_ACCEPT_HEADER}) 138 | }).toPromise()); 139 | const promises: Array> = []; 140 | const result: Identity[] = []; 141 | const memberOfIds: string[] = this.extractData(response); 142 | for (const memberId of memberOfIds) { 143 | // ignore any non-tfs identities 144 | if (!memberId.startsWith("Microsoft.TeamFoundation.Identity")) { 145 | continue; 146 | } 147 | 148 | promises.push(this.http.get(`${this.baseUri}/_apis/Identities/${memberId}`, { 149 | withCredentials: true, 150 | headers: new Headers({Accept: this.IDENTITIES_API_ACCEPT_HEADER}) 151 | }).toPromise()); 152 | } 153 | 154 | const responses = await Promise.all(promises); 155 | for (const r of responses) { 156 | result.push(r.json()); 157 | } 158 | return result; 159 | } 160 | 161 | private extractData(res: Response): any { 162 | const body = res.json(); 163 | return body.value || []; 164 | } 165 | 166 | private handleError(error: any) { 167 | const errMsg = (error.message) ? error.message : 168 | error.status ? `${error.status} - ${error.statusText}` : "Server error"; 169 | console.error(errMsg); // log to console instead 170 | return Promise.reject(errMsg); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /test/extensionsApiTfs.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {ExtensionsApiTfsService} from "../src/app/extensionsApiTfs.service"; 2 | import {PullRequestAsyncStatus} from "../src/app/model"; 3 | import {TestUtils} from "./testHelpers"; 4 | 5 | describe("ExtensionsApiTfsService", () => { 6 | 7 | const zoneMock: any = { 8 | run: (fn: () => any): any => { fn(); } 9 | }; 10 | 11 | const userContext: UserContext = { 12 | email: "test@test.com", 13 | id: "Microsoft.TeamFoundation.Identity.testuser", 14 | limitedAccess: false, 15 | name: "test user", 16 | uniqueName: "testuser1" 17 | }; 18 | 19 | const membersMap = { 20 | "Microsoft.TeamFoundation.Identity.testuser": ["Microsoft.TeamFoundation.Identity.group1", "Microsoft.TeamFoundation.Identity.group2", "Microsoft.TeamFoundation.Identity.groupOfGroups"], 21 | "Microsoft.TeamFoundation.Identity.group1": [], 22 | "Microsoft.TeamFoundation.Identity.group2": [], 23 | "Microsoft.TeamFoundation.Identity.groupOfGroups": ["Microsoft.TeamFoundation.Identity.group3", "Microsoft.TeamFoundation.Identity.group4", "SomeOtherGroup"] 24 | }; 25 | 26 | const repos: GitRepository[] = [ 27 | TestUtils.createRepository("repo1", "P1"), 28 | TestUtils.createRepository("repo2", "P1"), 29 | TestUtils.createRepository("repo3", "P1"), 30 | TestUtils.createRepository("repo4", "P1"), 31 | TestUtils.createRepository("repo5", "P1"), 32 | TestUtils.createRepository("repo6", "P2") 33 | ]; 34 | 35 | const prs = [createPR(1, "repo1", "P1"), createPR(2, "repo2", "P1"), createPR(3, "repo3", "P1"), createPR(4, "repo4", "P1"), createPR(5, "repo5", "P1"), createPR(6, "repo6", "P2")]; 36 | 37 | const projectName = "P1"; 38 | let gitClient: GitClient = null; 39 | let identitiesClient: IdentitiesClient = null; 40 | let coreClient: CoreHttpClient = null; 41 | 42 | function createPR(id: number, repo: string, project: string): GitPullRequest { 43 | return TestUtils.createPullRequest({ 44 | id, 45 | mergeStatus: PullRequestAsyncStatus.Succeeded, 46 | reviewers: [], 47 | repo, 48 | project 49 | }); 50 | } 51 | 52 | function createIdentity(memberId: string): Identity { 53 | return { 54 | customDisplayName: `test${memberId}`, 55 | descriptor: null, 56 | id: memberId, 57 | isActive: true, 58 | isContainer: false, 59 | masterId: "123", 60 | memberIds: [], 61 | memberOf: [], 62 | members: [], 63 | metaTypeId: null, 64 | properties: null, 65 | providerDisplayName: "test", 66 | resourceVersion: 1, 67 | uniqueUserId: 1 68 | }; 69 | } 70 | 71 | function createSubject(isHosted: boolean): ExtensionsApiTfsService { 72 | return new ExtensionsApiTfsService(gitClient, identitiesClient, coreClient, isHosted, projectName, userContext, zoneMock); 73 | } 74 | 75 | beforeEach(() => { 76 | identitiesClient = { 77 | readIdentity: (identityId: string, queryMembership?: QueryMembership, properties?: string): Promise => { 78 | return Promise.resolve(createIdentity(identityId)); 79 | }, 80 | readMembersOf: (memberId: string, queryMembership?: QueryMembership): Promise => { 81 | let members = membersMap[memberId]; 82 | if (members == null) { 83 | members = []; 84 | } 85 | return Promise.resolve(members); 86 | } 87 | }; 88 | 89 | gitClient = { 90 | getPullRequests: (repositoryId: string, searchCriteria: GitPullRequestSearchCriteria, project?: string, maxCommentLength?: number, skip?: number, top?: number): Promise => { 91 | const filtered = prs.filter((p) => p.repository.id === repositoryId); 92 | return Promise.resolve(filtered); 93 | }, 94 | getRepositories: (project?: string, includeLinks?: boolean): Promise => { 95 | if (project) { 96 | const filtered = repos.filter((r) => r.project.name === project); 97 | return Promise.resolve(filtered); 98 | } 99 | return Promise.resolve(repos); 100 | }, 101 | getPullRequestsByProject: (project: string, searchCriteria: GitPullRequestSearchCriteria, maxCommentLength?: number, skip?: number, top?: number): Promise => { 102 | if (project) { 103 | const filtered = prs.filter((p) => p.repository.project.name === project); 104 | return Promise.resolve(filtered); 105 | } 106 | return Promise.resolve(prs); 107 | }, 108 | getPullRequest: (repositoryId: string, pullRequestId: number, project?: string, maxCommentLength?: number, skip?: number, top?: number, includeCommits?: boolean, includeWorkItemRefs?: boolean): Promise => null, 109 | 110 | getPullRequestStatuses: (repositoryId: string, pullRequestId: number, project?: string): Promise => null, 111 | }; 112 | 113 | coreClient = { 114 | getProjects: (stateFilter?: any, top?: number, skip?: number, continuationToken?: string): Promise => { 115 | const projects = [ 116 | { 117 | id: "1", 118 | name: "P1" 119 | }, 120 | { 121 | id: "2", 122 | name: "P2" 123 | }]; 124 | 125 | return Promise.resolve(projects); 126 | } 127 | }; 128 | }); 129 | 130 | it("Doesn't call API if hosted when getCurrentUser is called", async (done) => { 131 | const subject = createSubject(true); 132 | spyOn(identitiesClient, "readIdentity"); 133 | spyOn(identitiesClient, "readMembersOf"); 134 | 135 | await subject.getCurrentUser(); 136 | expect(identitiesClient.readIdentity).toHaveBeenCalledTimes(0); 137 | expect(identitiesClient.readMembersOf).toHaveBeenCalledTimes(0); 138 | done(); 139 | }); 140 | 141 | it("Returns user details from UserContext if hosted with getCurrentUser", async (done) => { 142 | const subject = createSubject(true); 143 | const user = await subject.getCurrentUser(); 144 | expect(user).not.toBeNull(); 145 | expect(user.id).toEqual(userContext.id); 146 | expect(user.displayName).toEqual(userContext.name); 147 | expect(user.uniqueName).toEqual(userContext.uniqueName); 148 | expect(user.memberOf.length).toEqual(0); 149 | done(); 150 | }); 151 | 152 | it("Resolves a user's membersOf if not hosted", async (done) => { 153 | const subject = createSubject(false); 154 | const user = await subject.getCurrentUser(); 155 | expect(user).not.toBeNull(); 156 | expect(user.memberOf).toBeDefined(); 157 | expect(user.memberOf.length).toEqual(5); 158 | expect(user.memberOf.filter((x) => x.id === "Microsoft.TeamFoundation.Identity.group1").length).toEqual(1); 159 | expect(user.memberOf.filter((x) => x.id === "Microsoft.TeamFoundation.Identity.group2").length).toEqual(1); 160 | expect(user.memberOf.filter((x) => x.id === "Microsoft.TeamFoundation.Identity.groupOfGroups").length).toEqual(1); 161 | expect(user.memberOf.filter((x) => x.id === "Microsoft.TeamFoundation.Identity.group3").length).toEqual(1); 162 | expect(user.memberOf.filter((x) => x.id === "Microsoft.TeamFoundation.Identity.group4").length).toEqual(1); 163 | expect(user.memberOf.filter((x) => x.id === "SomeOtherGroup").length).toEqual(0); 164 | done(); 165 | }); 166 | 167 | it("Returns all repositories if allProjects true", async (done) => { 168 | const subject = createSubject(true); 169 | 170 | const repositories = await subject.getRepositories(true); 171 | 172 | expect(repositories).toEqual(repos); 173 | 174 | done(); 175 | }); 176 | 177 | it("Only returns repositories from current project if allProjects false", async (done) => { 178 | const subject = createSubject(true); 179 | 180 | const repositories = await subject.getRepositories(false); 181 | 182 | const expected = repos.filter((r) => r.project.name === projectName); 183 | 184 | expect(repositories).toEqual(expected); 185 | 186 | done(); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /typings/modules/log4js/index.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by typings 2 | // Source: https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/ede3f0a99c502f0f752d56122c768473fc2559e8/log4js/index.d.ts 3 | declare module 'log4js' { 4 | // Type definitions for log4js 5 | // Project: https://github.com/nomiddlename/log4js-node 6 | // Definitions by: Kentaro Okuno 7 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 8 | 9 | import express = require('express'); 10 | 11 | /** 12 | * Replaces the console 13 | * @param logger 14 | * @returns void 15 | */ 16 | export function replaceConsole(logger?: Logger): void; 17 | 18 | /** 19 | * Restores the console 20 | * @returns void 21 | */ 22 | export function restoreConsole(): void; 23 | 24 | /** 25 | * Get a logger instance. Instance is cached on categoryName level. 26 | * 27 | * @param {String} [categoryName] name of category to log to. 28 | * @returns {Logger} instance of logger for the category 29 | * @static 30 | */ 31 | export function getLogger(categoryName?: string): Logger; 32 | export function getBufferedLogger(categoryName?: string): Logger; 33 | 34 | /** 35 | * Has a logger instance cached on categoryName. 36 | * 37 | * @param {String} [categoryName] name of category to log to. 38 | * @returns {boolean} contains logger for the category 39 | * @static 40 | */ 41 | export function hasLogger(categoryName: string): boolean; 42 | 43 | /** 44 | * Get the default logger instance. 45 | * 46 | * @returns {Logger} instance of default logger 47 | * @static 48 | */ 49 | export function getDefaultLogger(): Logger; 50 | 51 | /** 52 | * args are appender, then zero or more categories 53 | * 54 | * @param {*[]} appenders 55 | * @returns {void} 56 | * @static 57 | */ 58 | export function addAppender(...appenders: any[]): void; 59 | 60 | /** 61 | * Load appender 62 | * 63 | * @param {string} appender type 64 | * @param {AppenderModule} the appender module. by default, require('./appenders/' + appender) 65 | * @returns {void} 66 | * @static 67 | */ 68 | export function loadAppender(appenderType: string, appenderModule?: AppenderModule): void; 69 | 70 | /** 71 | * Claer configured appenders 72 | * 73 | * @returns {void} 74 | * @static 75 | */ 76 | export function clearAppenders(): void; 77 | 78 | /** 79 | * Shutdown all log appenders. This will first disable all writing to appenders 80 | * and then call the shutdown function each appender. 81 | * 82 | * @params {Function} cb - The callback to be invoked once all appenders have 83 | * shutdown. If an error occurs, the callback will be given the error object 84 | * as the first argument. 85 | * @returns {void} 86 | */ 87 | export function shutdown(cb: Function): void; 88 | 89 | export function configure(filename: string, options?: any): void; 90 | export function configure(config: IConfig, options?: any): void; 91 | 92 | export function setGlobalLogLevel(level: string): void; 93 | export function setGlobalLogLevel(level: Level): void; 94 | 95 | 96 | /** 97 | * Create logger for connect middleware. 98 | * 99 | * 100 | * @returns {express.Handler} Instance of middleware. 101 | * @static 102 | */ 103 | export function connectLogger(logger: Logger, options: { format?: string; level?: string; nolog?: any; }): express.Handler; 104 | export function connectLogger(logger: Logger, options: { format?: string; level?: Level; nolog?: any; }): express.Handler; 105 | 106 | export var layouts: { 107 | basicLayout: Layout, 108 | messagePassThroughLayout: Layout, 109 | patternLayout: Layout, 110 | colouredLayout: Layout, 111 | coloredLayout: Layout, 112 | dummyLayout: Layout, 113 | 114 | /** 115 | * Register your custom layout generator 116 | */ 117 | addLayout: (name: string, serializerGenerator: (config?: LayoutConfig) => Layout) => void, 118 | 119 | /** 120 | * Get layout. Available predified layout names: 121 | * messagePassThrough, basic, colored, coloured, pattern, dummy 122 | * 123 | */ 124 | layout: (name: string, config: LayoutConfig) => Layout 125 | } 126 | 127 | export var appenders: any; 128 | export var levels: { 129 | ALL: Level; 130 | TRACE: Level; 131 | DEBUG: Level; 132 | INFO: Level; 133 | WARN: Level; 134 | ERROR: Level; 135 | FATAL: Level; 136 | OFF: Level; 137 | 138 | toLevel(level: string, defaultLevel?: Level): Level; 139 | toLevel(level: Level, defaultLevel?: Level): Level; 140 | }; 141 | 142 | export interface Logger { 143 | setLevel(level: string): void; 144 | setLevel(level: Level): void; 145 | 146 | isLevelEnabled(level: Level): boolean; 147 | isTraceEnabled(): boolean; 148 | isDebugEnabled(): boolean; 149 | isInfoEnabled(): boolean; 150 | isWarnEnabled(): boolean; 151 | isErrorEnabled(): boolean; 152 | isFatalEnabled(): boolean; 153 | 154 | trace(message: string, ...args: any[]): void; 155 | debug(message: string, ...args: any[]): void; 156 | info(message: string, ...args: any[]): void; 157 | warn(message: string, ...args: any[]): void; 158 | error(message: string, ...args: any[]): void; 159 | fatal(message: string, ...args: any[]): void; 160 | } 161 | 162 | export interface Level { 163 | isEqualTo(other: string): boolean; 164 | isEqualTo(otherLevel: Level): boolean; 165 | isLessThanOrEqualTo(other: string): boolean; 166 | isLessThanOrEqualTo(otherLevel: Level): boolean; 167 | isGreaterThanOrEqualTo(other: string): boolean; 168 | isGreaterThanOrEqualTo(otherLevel: Level): boolean; 169 | } 170 | 171 | export interface IConfig { 172 | appenders: AppenderConfig[]; 173 | levels?: { [category: string]: string }; 174 | replaceConsole?: boolean; 175 | } 176 | 177 | export interface AppenderConfigBase { 178 | type: string; 179 | category?: string; 180 | layout?: { type: string;[key: string]: any } 181 | } 182 | 183 | export interface ConsoleAppenderConfig extends AppenderConfigBase { } 184 | 185 | export interface FileAppenderConfig extends AppenderConfigBase { 186 | filename: string; 187 | } 188 | export interface DateFileAppenderConfig extends FileAppenderConfig { 189 | /** 190 | * The following strings are recognised in the pattern: 191 | * - yyyy : the full year, use yy for just the last two digits 192 | * - MM : the month 193 | * - dd : the day of the month 194 | * - hh : the hour of the day (24-hour clock) 195 | * - mm : the minute of the hour 196 | * - ss : seconds 197 | * - SSS : milliseconds (although I'm not sure you'd want to roll your logs every millisecond) 198 | * - O : timezone (capital letter o) 199 | */ 200 | pattern: string; 201 | alwaysIncludePattern: boolean; 202 | } 203 | 204 | export interface SmtpAppenderConfig extends AppenderConfigBase { 205 | /** Comma separated list of email recipients */ 206 | recipients: string; 207 | 208 | /** Sender of all emails (defaults to transport user) */ 209 | sender: string; 210 | 211 | /** Subject of all email messages (defaults to first event's message)*/ 212 | subject: string; 213 | 214 | /** 215 | * The time in seconds between sending attempts (defaults to 0). 216 | * All events are buffered and sent in one email during this time. 217 | * If 0 then every event sends an email 218 | */ 219 | sendInterval: number; 220 | 221 | SMTP: { 222 | host: string; 223 | secure: boolean; 224 | port: number; 225 | auth: { 226 | user: string; 227 | pass: string; 228 | } 229 | } 230 | } 231 | 232 | export interface HookIoAppenderConfig extends FileAppenderConfig { 233 | maxLogSize: number; 234 | backup: number; 235 | pollInterval: number; 236 | } 237 | 238 | export interface GelfAppenderConfig extends AppenderConfigBase { 239 | host: string; 240 | hostname: string; 241 | port: string; 242 | facility: string; 243 | } 244 | 245 | export interface MultiprocessAppenderConfig extends AppenderConfigBase { 246 | mode: string; 247 | loggerPort: number; 248 | loggerHost: string; 249 | facility: string; 250 | appender?: AppenderConfig; 251 | } 252 | 253 | export interface LogglyAppenderConfig extends AppenderConfigBase { 254 | /** Loggly customer token - https://www.loggly.com/docs/api-sending-data/ */ 255 | token: string; 256 | 257 | /** Loggly customer subdomain (use 'abc' for abc.loggly.com) */ 258 | subdomain: string; 259 | 260 | /** an array of strings to help segment your data & narrow down search results in Loggly */ 261 | tags: string[]; 262 | 263 | /** Enable JSON logging by setting to 'true' */ 264 | json: boolean; 265 | } 266 | 267 | export interface ClusteredAppenderConfig extends AppenderConfigBase { 268 | appenders?: AppenderConfig[]; 269 | } 270 | 271 | type CoreAppenderConfig = ConsoleAppenderConfig 272 | | FileAppenderConfig 273 | | DateFileAppenderConfig 274 | | SmtpAppenderConfig 275 | | HookIoAppenderConfig 276 | | GelfAppenderConfig 277 | | MultiprocessAppenderConfig 278 | | LogglyAppenderConfig 279 | | ClusteredAppenderConfig 280 | 281 | interface CustomAppenderConfig extends AppenderConfigBase { 282 | [prop: string]: any; 283 | } 284 | 285 | type AppenderConfig = CoreAppenderConfig | CustomAppenderConfig; 286 | 287 | export interface LogEvent { 288 | /** 289 | * new Date() 290 | */ 291 | startTime: number; 292 | categoryName: string; 293 | data: any[]; 294 | level: Level; 295 | logger: Logger; 296 | } 297 | 298 | export interface Appender { 299 | (event: LogEvent): void; 300 | } 301 | 302 | export interface AppenderModule { 303 | appender: (...args: any[]) => Appender; 304 | shutdown?: (cb: (error: Error) => void) => void; 305 | configure: (config: CustomAppenderConfig, options?: { [key: string]: any }) => Appender; 306 | } 307 | 308 | export interface LayoutConfig { 309 | [key: string]: any; 310 | } 311 | export interface LayoutGenerator { 312 | (config?: LayoutConfig): Layout 313 | } 314 | 315 | export interface Layout { 316 | (event: LogEvent): string; 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /typings/globals/karma/index.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by typings 2 | // Source: https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/568ec4378d010bb901a61bd9686be49077e54d5f/karma/karma.d.ts 3 | declare module 'karma' { 4 | // See Karma public API https://karma-runner.github.io/0.13/dev/public-api.html 5 | import Promise = require('bluebird'); 6 | import https = require('https'); 7 | import log4js = require('log4js'); 8 | 9 | namespace karma { 10 | interface Karma { 11 | /** 12 | * `start` method is deprecated since 0.13. It will be removed in 0.14. 13 | * Please use 14 | * 15 | * server = new Server(config, [done]) 16 | * server.start() 17 | * 18 | * instead. 19 | */ 20 | server: DeprecatedServer; 21 | Server: Server; 22 | runner: Runner; 23 | stopper: Stopper; 24 | launcher: Launcher; 25 | VERSION: string; 26 | } 27 | 28 | interface LauncherStatic { 29 | generateId(): string; 30 | //TODO: injector should be of type `di.Injector` 31 | new (emitter: NodeJS.EventEmitter, injector: any): Launcher; 32 | } 33 | 34 | interface Launcher { 35 | Launcher: LauncherStatic; 36 | //TODO: Can this return value ever be typified? 37 | launch(names: string[], protocol: string, hostname: string, port: number, urlRoot: string): any[]; 38 | kill(id: string, callback: Function): boolean; 39 | restart(id: string): boolean; 40 | killAll(callback: Function): void; 41 | areAllCaptured(): boolean; 42 | markCaptured(id: string): void; 43 | } 44 | 45 | interface DeprecatedServer { 46 | start(options?: any, callback?: ServerCallback): void; 47 | } 48 | 49 | interface Runner { 50 | run(options?: ConfigOptions | ConfigFile, callback?: ServerCallback): void; 51 | } 52 | 53 | 54 | interface Stopper { 55 | /** 56 | * This function will signal a running server to stop. The equivalent of karma stop. 57 | */ 58 | stop(options?: ConfigOptions, callback?: ServerCallback): void; 59 | } 60 | 61 | 62 | interface TestResults { 63 | disconnected: boolean; 64 | error: boolean; 65 | exitCode: number; 66 | failed: number; 67 | success: number; 68 | } 69 | 70 | interface Server extends NodeJS.EventEmitter { 71 | new (options?: ConfigOptions | ConfigFile, callback?: ServerCallback): Server; 72 | /** 73 | * Start the server 74 | */ 75 | start(): void; 76 | /** 77 | * Get properties from the injector 78 | * @param token 79 | */ 80 | get(token: string): any; 81 | /** 82 | * Force a refresh of the file list 83 | */ 84 | refreshFiles(): Promise; 85 | 86 | on(event: string, listener: Function): this; 87 | 88 | /** 89 | * Listen to the 'run_complete' event. 90 | */ 91 | on(event: 'run_complete', listener: (browsers: any, results: TestResults ) => void): this; 92 | 93 | ///** 94 | // * Backward-compatibility with karma-intellij bundled with WebStorm. 95 | // * Deprecated since version 0.13, to be removed in 0.14 96 | // */ 97 | //static start(): void; 98 | } 99 | 100 | interface ServerCallback { 101 | (exitCode: number): void; 102 | } 103 | 104 | interface Config { 105 | set: (config: ConfigOptions) => void; 106 | LOG_DISABLE: string; 107 | LOG_ERROR: string; 108 | LOG_WARN: string; 109 | LOG_INFO: string; 110 | LOG_DEBUG: string; 111 | } 112 | 113 | interface ConfigFile { 114 | configFile: string; 115 | } 116 | 117 | interface ConfigOptions { 118 | /** 119 | * @description Enable or disable watching files and executing the tests whenever one of these files changes. 120 | * @default true 121 | */ 122 | autoWatch?: boolean; 123 | /** 124 | * @description When Karma is watching the files for changes, it tries to batch multiple changes into a single run 125 | * so that the test runner doesn't try to start and restart running tests more than it should. 126 | * The configuration setting tells Karma how long to wait (in milliseconds) after any changes have occurred 127 | * before starting the test process again. 128 | * @default 250 129 | */ 130 | autoWatchBatchDelay?: number; 131 | /** 132 | * @default '' 133 | * @description The root path location that will be used to resolve all relative paths defined in files and exclude. 134 | * If the basePath configuration is a relative path then it will be resolved to 135 | * the __dirname of the configuration file. 136 | */ 137 | basePath?: string; 138 | /** 139 | * @default 2000 140 | * @description How long does Karma wait for a browser to reconnect (in ms). 141 | *

142 | * With a flaky connection it is pretty common that the browser disconnects, 143 | * but the actual test execution is still running without any problems. Karma does not treat a disconnection 144 | * as immediate failure and will wait browserDisconnectTimeout (ms). 145 | * If the browser reconnects during that time, everything is fine. 146 | *

147 | */ 148 | browserDisconnectTimeout?: number; 149 | /** 150 | * @default 0 151 | * @description The number of disconnections tolerated. 152 | *

153 | * The disconnectTolerance value represents the maximum number of tries a browser will attempt 154 | * in the case of a disconnection. Usually any disconnection is considered a failure, 155 | * but this option allows you to define a tolerance level when there is a flaky network link between 156 | * the Karma server and the browsers. 157 | *

158 | */ 159 | browserDisconnectTolerance?: number; 160 | /** 161 | * @default 10000 162 | * @description How long will Karma wait for a message from a browser before disconnecting from it (in ms). 163 | *

164 | * If, during test execution, Karma does not receive any message from a browser within 165 | * browserNoActivityTimeout (ms), it will disconnect from the browser 166 | *

167 | */ 168 | browserNoActivityTimeout?: number; 169 | /** 170 | * @default [] 171 | * Possible Values: 172 | *
    173 | *
  • Chrome (launcher comes installed with Karma)
  • 174 | *
  • ChromeCanary (launcher comes installed with Karma)
  • 175 | *
  • PhantomJS (launcher comes installed with Karma)
  • 176 | *
  • Firefox (launcher requires karma-firefox-launcher plugin)
  • 177 | *
  • Opera (launcher requires karma-opera-launcher plugin)
  • 178 | *
  • Internet Explorer (launcher requires karma-ie-launcher plugin)
  • 179 | *
  • Safari (launcher requires karma-safari-launcher plugin)
  • 180 | *
181 | * @description A list of browsers to launch and capture. When Karma starts up, it will also start up each browser 182 | * which is placed within this setting. Once Karma is shut down, it will shut down these browsers as well. 183 | * You can capture any browser manually by opening the browser and visiting the URL where 184 | * the Karma web server is listening (by default it is http://localhost:9876/). 185 | */ 186 | browsers?: string[]; 187 | /** 188 | * @default 60000 189 | * @description Timeout for capturing a browser (in ms). 190 | *

191 | * The captureTimeout value represents the maximum boot-up time allowed for a 192 | * browser to start and connect to Karma. If any browser does not get captured within the timeout, Karma 193 | * will kill it and try to launch it again and, after three attempts to capture it, Karma will give up. 194 | *

195 | */ 196 | captureTimeout?: number; 197 | client?: ClientOptions; 198 | /** 199 | * @default true 200 | * @description Enable or disable colors in the output (reporters and logs). 201 | */ 202 | colors?: boolean; 203 | /** 204 | * @default [] 205 | * @description List of files/patterns to exclude from loaded files. 206 | */ 207 | exclude?: string[]; 208 | /** 209 | * @default [] 210 | * @description List of files/patterns to load in the browser. 211 | */ 212 | files?: (FilePattern | string)[]; 213 | /** 214 | * @default [] 215 | * @description List of test frameworks you want to use. Typically, you will set this to ['jasmine'], ['mocha'] or ['qunit']... 216 | * Please note just about all frameworks in Karma require an additional plugin/framework library to be installed (via NPM). 217 | */ 218 | frameworks?: string[]; 219 | /** 220 | * @default 'localhost' 221 | * @description Hostname to be used when capturing browsers. 222 | */ 223 | hostname?: string; 224 | /** 225 | * @default {} 226 | * @description Options object to be used by Node's https class. 227 | * Object description can be found in the 228 | * [NodeJS.org API docs](https://nodejs.org/api/tls.html#tls_tls_createserver_options_secureconnectionlistener) 229 | */ 230 | httpsServerOptions?: https.ServerOptions; 231 | /** 232 | * @default config.LOG_INFO 233 | * Possible values: 234 | *
    235 | *
  • config.LOG_DISABLE
  • 236 | *
  • config.LOG_ERROR
  • 237 | *
  • config.LOG_WARN
  • 238 | *
  • config.LOG_INFO
  • 239 | *
  • config.LOG_DEBUG
  • 240 | *
241 | * @description Level of logging. 242 | */ 243 | logLevel?: string; 244 | /** 245 | * @default [{type: 'console'}] 246 | * @description A list of log appenders to be used. See the documentation for [log4js] for more information. 247 | */ 248 | loggers?: log4js.AppenderConfigBase[]; 249 | /** 250 | * @default ['karma-*'] 251 | * @description List of plugins to load. A plugin can be a string (in which case it will be required 252 | * by Karma) or an inlined plugin - Object. 253 | * By default, Karma loads all sibling NPM modules which have a name starting with karma-*. 254 | * Note: Just about all plugins in Karma require an additional library to be installed (via NPM). 255 | */ 256 | plugins?: any[]; 257 | /** 258 | * @default 9876 259 | * @description The port where the web server will be listening. 260 | */ 261 | port?: number; 262 | /** 263 | * @default {'**\/*.coffee': 'coffee'} 264 | * @description A map of preprocessors to use. 265 | * 266 | * Preprocessors can be loaded through [plugins]. 267 | * 268 | * Note: Just about all preprocessors in Karma (other than CoffeeScript and some other defaults) 269 | * require an additional library to be installed (via NPM). 270 | * 271 | * Be aware that preprocessors may be transforming the files and file types that are available at run time. For instance, 272 | * if you are using the "coverage" preprocessor on your source files, if you then attempt to interactively debug 273 | * your tests, you'll discover that your expected source code is completely changed from what you expected. Because 274 | * of that, you'll want to engineer this so that your automated builds use the coverage entry in the "reporters" list, 275 | * but your interactive debugging does not. 276 | * 277 | */ 278 | preprocessors?: { [name: string]: string | string[] } 279 | /** 280 | * @default 'http:' 281 | * Possible Values: 282 | *
    283 | *
  • http:
  • 284 | *
  • https:
  • 285 | *
286 | * @description Protocol used for running the Karma webserver. 287 | * Determines the use of the Node http or https class. 288 | * Note: Using 'https:' requires you to specify httpsServerOptions. 289 | */ 290 | protocol?: string; 291 | /** 292 | * @default {} 293 | * @description A map of path-proxy pairs. 294 | */ 295 | proxies?: { [path: string]: string } 296 | /** 297 | * @default true 298 | * @description Whether or not Karma or any browsers should raise an error when an inavlid SSL certificate is found. 299 | */ 300 | proxyValidateSSL?: boolean; 301 | /** 302 | * @default 0 303 | * @description Karma will report all the tests that are slower than given time limit (in ms). 304 | * This is disabled by default (since the default value is 0). 305 | */ 306 | reportSlowerThan?: number; 307 | /** 308 | * @default ['progress'] 309 | * Possible Values: 310 | *
    311 | *
  • dots
  • 312 | *
  • progress
  • 313 | *
314 | * @description A list of reporters to use. 315 | * Additional reporters, such as growl, junit, teamcity or coverage can be loaded through plugins. 316 | * Note: Just about all additional reporters in Karma (other than progress) require an additional library to be installed (via NPM). 317 | */ 318 | reporters?: string[]; 319 | /** 320 | * @default false 321 | * @description Continuous Integration mode. 322 | * If true, Karma will start and capture all configured browsers, run tests and then exit with an exit code of 0 or 1 depending 323 | * on whether all tests passed or any tests failed. 324 | */ 325 | singleRun?: boolean; 326 | /** 327 | * @default ['polling', 'websocket'] 328 | * @description An array of allowed transport methods between the browser and testing server. This configuration setting 329 | * is handed off to [socket.io](http://socket.io/) (which manages the communication 330 | * between browsers and the testing server). 331 | */ 332 | transports?: string[]; 333 | /** 334 | * @default '/' 335 | * @description The base url, where Karma runs. 336 | * All of Karma's urls get prefixed with the urlRoot. This is helpful when using proxies, as 337 | * sometimes you might want to proxy a url that is already taken by Karma. 338 | */ 339 | urlRoot?: string; 340 | } 341 | 342 | interface ClientOptions { 343 | /** 344 | * @default undefined 345 | * @description When karma run is passed additional arguments on the command-line, they 346 | * are passed through to the test adapter as karma.config.args (an array of strings). 347 | * The client.args option allows you to set this value for actions other than run. 348 | * How this value is used is up to your test adapter - you should check your adapter's 349 | * documentation to see how (and if) it uses this value. 350 | */ 351 | args?: string[]; 352 | /** 353 | * @default true 354 | * @description Run the tests inside an iFrame or a new window 355 | * If true, Karma runs the tests inside an iFrame. If false, Karma runs the tests in a new window. Some tests may not run in an 356 | * iFrame and may need a new window to run. 357 | */ 358 | useIframe?: boolean; 359 | /** 360 | * @default true 361 | * @description Capture all console output and pipe it to the terminal. 362 | */ 363 | captureConsole?: boolean; 364 | } 365 | 366 | interface FilePattern { 367 | /** 368 | * The pattern to use for matching. This property is mandatory. 369 | */ 370 | pattern: string; 371 | /** 372 | * @default true 373 | * @description If autoWatch is true all files that have set watched to true will be watched 374 | * for changes. 375 | */ 376 | watched?: boolean; 377 | /** 378 | * @default true 379 | * @description Should the files be included in the browser using