Show github pull requests in the GitHub tab (#9148)

* WIP: prototype wiring up the API with the angular frontend

* little refactorings in namespace and loading logic

* frontend styling and polishing

* pairing with aleix

* Avoid empty pullRequest.githubUser to be displayed

* added feature specs, a workaround for a pr-reload-bug in angular, and some readme improvements

* Title for partial PRs added

* GitActionsMenuComponent tests

* Test improvements

* GitHubTabComponent tests

* TabHeaderComponent tests

* TabPrsComponent tests

* revert change to karma runner

* start styling

* i18n in component

* avoid container element

* git actions menu tests

* Github interfaces

* PullRequestComponent tests

* Comment removed

* Typings improvements

* Merge fixes

* Tests fixes

* fix: wp list id links working with tabs

* remove partial github pr state

A PR might still be incomplete but enough data can be pulled from a github issuee notification to fill in most of the information

* avoid duplicate spec

* Fix import paths of op-icon

Co-authored-by: Aleix Suau <info@macrofonoestudio.es>
Co-authored-by: ulferts <jens.ulferts@googlemail.com>
Co-authored-by: Oliver Günther <mail@oliverguenther.de>
pull/9240/head
Philipp Tessenow 4 years ago committed by GitHub
parent 34d2753743
commit 2f9d252456
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      docs/system-admin-guide/github-integration/README.md
  2. 11
      frontend/src/app/components/wp-fast-table/builders/ui-state-link-builder.ts
  3. 3
      frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-package-paths.ts
  4. 3
      frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts
  5. 8
      frontend/src/app/modules/work_packages/routing/wp-list-view/wp-list-view.component.ts
  6. 22
      modules/github_integration/app/models/github_pull_request.rb
  7. 2
      modules/github_integration/config/locales/js-en.yml
  8. 88
      modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.spec.ts
  9. 20
      modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.ts
  10. 60
      modules/github_integration/frontend/module/github-tab/github-tab.component.spec.ts
  11. 10
      modules/github_integration/frontend/module/github-tab/github-tab.component.ts
  12. 4
      modules/github_integration/frontend/module/github-tab/github-tab.template.html
  13. 42
      modules/github_integration/frontend/module/hal/resources/github-check-run-resource.ts
  14. 42
      modules/github_integration/frontend/module/hal/resources/github-pull-request-resource.ts
  15. 42
      modules/github_integration/frontend/module/hal/resources/github-user-resource.ts
  16. 4
      modules/github_integration/frontend/module/main.ts
  17. 109
      modules/github_integration/frontend/module/pull-request/pull-request.component.sass
  18. 109
      modules/github_integration/frontend/module/pull-request/pull-request.component.spec.ts
  19. 67
      modules/github_integration/frontend/module/pull-request/pull-request.component.ts
  20. 42
      modules/github_integration/frontend/module/pull-request/pull-request.template.html
  21. 65
      modules/github_integration/frontend/module/tab-header/tab-header.component.spec.ts
  22. 4
      modules/github_integration/frontend/module/tab-header/tab-header.component.ts
  23. 142
      modules/github_integration/frontend/module/tab-prs/tab-prs.component.spec.ts
  24. 32
      modules/github_integration/frontend/module/tab-prs/tab-prs.component.ts
  25. 4
      modules/github_integration/frontend/module/tab-prs/tab-prs.template.html
  26. 51
      modules/github_integration/frontend/module/tab-prs/wp-github-prs.service.ts
  27. 57
      modules/github_integration/frontend/module/typings.d.ts
  28. 7
      modules/github_integration/lib/open_project/github_integration/notification_handler/helper.rb
  29. 4
      modules/github_integration/lib/open_project/github_integration/notification_handler/issue_comment.rb
  30. 5
      modules/github_integration/lib/open_project/github_integration/notification_handler/pull_request.rb
  31. 42
      modules/github_integration/lib/open_project/github_integration/services/upsert_partial_pull_request.rb
  32. 2
      modules/github_integration/spec/factories/github_pull_request_factory.rb
  33. 27
      modules/github_integration/spec/features/work_package_github_tab_spec.rb
  34. 338
      modules/github_integration/spec/lib/open_project/github_integration/hook_handler_integration_spec.rb
  35. 52
      modules/github_integration/spec/lib/open_project/github_integration/notification_handler/issue_comment_spec.rb
  36. 71
      modules/github_integration/spec/lib/open_project/github_integration/services/upsert_partial_pull_request_spec.rb
  37. 24
      modules/github_integration/spec/lib/open_project/github_integration/services/upsert_pull_request_spec.rb
  38. 91
      modules/github_integration/spec/models/github_pull_request_spec.rb

@ -8,7 +8,7 @@ keywords: github integration
---
# GitHub integration
OpenProject offers are very basic GitHub integration for pull requests.
OpenProject offers an integration for GitHub pull requests.
You create a pull request in GitHub and link to an OpenProject work package.
![New pull request linking to an OpenProject work package](github-pr-workpackage-reference.png)
@ -24,11 +24,7 @@ the pull request is
![Github comments on work package](workpackage-github-comments.png)
Mind that editing an existing pull request's description to add a work package link will
not add a comment in OpenProject. GitHub does not send webhook events for that.
If you still want a comment in OpenProject you will have to reference the
work package in a comment on the pull request in GitHub.
Given the right permissions on the project, a "GitHub" tab is shown for work packages. All previously linked pull requests can be seen there including the PR's status and checks (e.g. tests and linter runs).
## Configuration

@ -23,8 +23,15 @@ export class UiStateLinkBuilder {
private build(workPackageId:string, state:string, title:string, content:string) {
const a = document.createElement('a');
a.href = this.$state.href((this.keepTab as any)[state], { workPackageId: workPackageId });
const selectedTabIdentifier = this.$state.params?.tabIdentifier;
a.href = this.$state.href(
(this.keepTab as any)[state],
{
workPackageId: workPackageId,
...selectedTabIdentifier && {tabIdentifier: selectedTabIdentifier},
}
);
a.classList.add(uiStateLinkClass);
a.dataset['workPackageId'] = workPackageId;
a.dataset['wpState'] = state;

@ -53,6 +53,9 @@ export class APIV3WorkPackagePaths extends CachableAPIV3Resource<WorkPackageReso
// /api/v3/(?:projectPath)/work_packages/(:workPackageId)/available_projects
public readonly available_projects = this.subResource('available_projects');
// /api/v3/(?:projectPath)/work_packages/(:workPackageId)/github_pull_requests
public readonly github_pull_requests = this.subResource('github_pull_requests');
protected createCache():StateCacheService<WorkPackageResource> {
return (this.parent as APIV3WorkPackagesPaths).cache;
}

@ -168,4 +168,7 @@ export class APIV3WorkPackagesPaths extends CachableAPIV3Collection<WorkPackageR
protected createCache():WorkPackageCache {
return new WorkPackageCache(this.injector, this.states.workPackages);
}
// /api/v3/(?:projectPath)/work_packages/(:workPackageId)/available_projects
public readonly available_projects = this.subResource('available_projects');
}

@ -173,9 +173,15 @@ export class WorkPackageListViewComponent extends UntilDestroyedMixin implements
}
openStateLink(event:{ workPackageId:string; requestedState:string }) {
const selectedTabIdentifier = this.$state?.params?.tabIdentifier;
this.$state.go(
(this.keepTab as any)[event.requestedState] || event.requestedState,
{ workPackageId: event.workPackageId, focus: true }
{
workPackageId: event.workPackageId,
focus: true,
...selectedTabIdentifier && {tabIdentifier: selectedTabIdentifier},
}
);
}

@ -38,17 +38,16 @@ class GithubPullRequest < ApplicationRecord
enum state: {
open: 'open',
closed: 'closed',
partial: 'partial'
closed: 'closed'
}
validates_presence_of :github_html_url,
:number,
:repository,
:state
validates_presence_of :github_updated_at,
:state,
:title,
:body,
:github_updated_at
validates_presence_of :body,
:comments_count,
:review_comments_count,
:additions_count,
@ -57,8 +56,15 @@ class GithubPullRequest < ApplicationRecord
unless: :partial?
validate :validate_labels_schema
scope :complete, -> { where(state: ['open', 'closed']) }
scope :without_work_package, -> { left_outer_joins(:work_packages).where(work_packages: { id: nil }) }
scope :partial, -> {
where(body: nil,
comments_count: nil,
review_comments_count: nil,
additions_count: nil,
deletions_count: nil,
changed_files_count: nil)
}
##
# When a PR lives long enough and receives many pushes, the same check (say, a CI test run) can be run multiple times.
@ -68,6 +74,10 @@ class GithubPullRequest < ApplicationRecord
.order(app_id: :asc, name: :asc, started_at: :desc)
end
def partial?
[body, comments_count, review_comments_count, additions_count, deletions_count, changed_files_count].all?(&:nil?)
end
private
def validate_labels_schema

@ -49,3 +49,5 @@ en:
copy_error: ❌ Copy failed!
tab_prs:
empty: There are no pull requests linked yet. Link an existing PR by using the code <code>OP#%{wp_id}</code> in the PR description or create a new PR.
github_actions: Actions

@ -0,0 +1,88 @@
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { GitActionsMenuComponent } from "./git-actions-menu.component";
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { OpContextMenuLocalsToken } from "core-app/components/op-context-menu/op-context-menu.types";
import { GitActionsService } from "../git-actions/git-actions.service";
import { By } from "@angular/platform-browser";
import { OpIconComponent } from "core-app/modules/icon/icon.component";
describe('GitActionsMenuComponent', () => {
let component:GitActionsMenuComponent;
let fixture:ComponentFixture<GitActionsMenuComponent>;
let element:DebugElement;
let gitActionsService:jasmine.SpyObj<GitActionsService>;
const I18nServiceStub = {
t: function(key:string) {
return 'test translation';
}
}
const localsStub = {
workPackage: 1,
items: [
{
hidden: false,
disabled: false,
href: 'http://www.google.com',
linkText: 'linkText',
}
]
}
beforeEach(async () => {
const gitActionsServiceSpy = jasmine.createSpyObj('GitActionsService', ['gitCommand', 'commitMessage', 'branchName']);
await TestBed
.configureTestingModule({
declarations: [
GitActionsMenuComponent,
OpIconComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: OpContextMenuLocalsToken, useValue: localsStub },
{ provide: GitActionsService, useValue: gitActionsServiceSpy },
],
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(GitActionsMenuComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
gitActionsService = fixture.debugElement.injector.get(GitActionsService) as jasmine.SpyObj<GitActionsService>;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should select tab', () => {
const tabToSelect = component.tabs[0];
component.selectTab(tabToSelect);
fixture.detectChanges();
expect(component.selectedTab()).toBe(tabToSelect);
});
it('should select tab', () => {
const tabToSelect = component.tabs[0];
const copyButton = fixture.debugElement.query(By.css('button')).nativeElement;
gitActionsService.branchName.and.returnValue('test branch');
component.selectTab(tabToSelect);
copyButton.click();
fixture.detectChanges();
expect(gitActionsService.branchName).toHaveBeenCalled();
});
});

@ -33,14 +33,8 @@ import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { GitActionsService} from '../git-actions/git-actions.service';
import { OPContextMenuComponent } from 'core-app/components/op-context-menu/op-context-menu.component';
import { OpContextMenuLocalsMap, OpContextMenuLocalsToken } from 'core-app/components/op-context-menu/op-context-menu.types';
import { ITab } from "core-app/modules/plugins/linked/openproject-github_integration/typings";
interface Tab {
label:string,
help:string,
selected:boolean,
lines:number,
textToCopy: ()=>string
}
@Component({
selector: 'git-actions-menu',
@ -64,7 +58,7 @@ export class GitActionsMenuComponent extends OPContextMenuComponent {
public lastCopyResult:string = this.text.copyResult.success;
public showCopyResult:boolean = false;
public tabs:Tab[] = [
public tabs:ITab[] = [
{
label: this.I18n.t('js.github_integration.tab_header.git_actions.branch'),
help: this.I18n.t('js.github_integration.tab_header.git_actions.branch_help'),
@ -96,18 +90,18 @@ export class GitActionsMenuComponent extends OPContextMenuComponent {
this.workPackage = this.locals.workPackage;
}
public selectedTab():Tab {
public selectedTab():ITab {
const selectedTabs = this.tabs.filter((tab)=>tab.selected);
return(selectedTabs[0] || this.tabs[0]);
}
public selectTab(tab:Tab) {
public selectTab(tab:ITab) {
this.tabs.forEach(tab => tab.selected = false);
tab.selected = true;
}
public onCopyButtonClick() {
const success = copy(this.selectedTab().textToCopy())
const success = this.copySelectedTabText();
if (success) {
this.lastCopyResult = this.text.copyResult.success;
@ -117,4 +111,8 @@ export class GitActionsMenuComponent extends OPContextMenuComponent {
this.showCopyResult = true;
window.setTimeout(() => { this.showCopyResult = false;}, 2000);
}
public copySelectedTabText() {
return copy(this.selectedTab().textToCopy());
}
}

@ -0,0 +1,60 @@
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { GitHubTabComponent } from "core-app/modules/plugins/linked/openproject-github_integration/github-tab/github-tab.component";
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { TabPrsComponent } from "core-app/modules/plugins/linked/openproject-github_integration/tab-prs/tab-prs.component";
import { TabHeaderComponent } from "core-app/modules/plugins/linked/openproject-github_integration/tab-header/tab-header.component";
import { By } from "@angular/platform-browser";
describe('GitHubTabComponent.', () => {
let component:GitHubTabComponent;
let fixture:ComponentFixture<GitHubTabComponent>;
let element:DebugElement;
const apiV3Base = 'http://www.openproject.com/api/v3/';
const IPathHelperServiceStub = { api:{ v3: { apiV3Base }}};
const I18nServiceStub = {
t: function(key:string) {
return 'test translation';
}
}
beforeEach(async () => {
await TestBed
.configureTestingModule({
declarations: [
TabPrsComponent,
TabHeaderComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: PathHelperService, useValue: IPathHelperServiceStub },
],
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(GitHubTabComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render header and pull requests components', () => {
const tabHeader = fixture.debugElement.query(By.css('tab-header'));
const tabPrs = fixture.debugElement.query(By.css('tab-prs'));
expect(tabHeader).toBeTruthy();
expect(tabPrs).toBeTruthy();
});
});

@ -26,7 +26,7 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import {Component, Input, OnInit} from '@angular/core';
import {Component, Input} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
@ -36,16 +36,10 @@ import { TabComponent } from 'core-app/components/wp-tabs/components/wp-tab-wrap
selector: 'github-tab',
templateUrl: './github-tab.template.html'
})
export class GitHubTabComponent implements OnInit, TabComponent {
export class GitHubTabComponent implements TabComponent {
@Input() public workPackage:WorkPackageResource;
public pullRequests = [];
constructor(readonly PathHelper:PathHelperService,
readonly I18n:I18nService) {
}
ngOnInit() {
this.pullRequests = [];
}
}

@ -1,4 +1,2 @@
<tab-header [workPackage]="workPackage"></tab-header>
<tab-prs [workPackage]="workPackage"
[pullRequests]="pullRequests"
></tab-prs>
<tab-prs [workPackage]="workPackage"></tab-prs>

@ -0,0 +1,42 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { HalResource } from 'core-app/modules/hal/resources/hal-resource';
export class GithubCheckRunResource extends HalResource {
public get state() {
return this.states.projects.get(this.id!) as any;
}
/**
* Exclude the schema _link from the linkable Resources.
*/
public $linkableKeys():string[] {
return _.without(super.$linkableKeys(), 'schema');
}
}

@ -0,0 +1,42 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { HalResource } from 'core-app/modules/hal/resources/hal-resource';
export class GithubPullRequestResource extends HalResource {
public get state() {
return this.states.projects.get(this.id!) as any;
}
/**
* Exclude the schema _link from the linkable Resources.
*/
public $linkableKeys():string[] {
return _.without(super.$linkableKeys(), 'schema');
}
}

@ -0,0 +1,42 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { HalResource } from 'core-app/modules/hal/resources/hal-resource';
export class GithubUserResource extends HalResource {
public get state() {
return this.states.projects.get(this.id!) as any;
}
/**
* Exclude the schema _link from the linkable Resources.
*/
public $linkableKeys():string[] {
return _.without(super.$linkableKeys(), 'schema');
}
}

@ -36,6 +36,8 @@ import {TabHeaderComponent} from './tab-header/tab-header.component';
import {TabPrsComponent} from './tab-prs/tab-prs.component';
import {GitActionsMenuDirective} from './git-actions-menu/git-actions-menu.directive';
import {GitActionsMenuComponent} from './git-actions-menu/git-actions-menu.component';
import { WorkPackagesGithubPrsService } from './tab-prs/wp-github-prs.service';
import { PullRequestComponent } from './pull-request/pull-request.component';
function displayable(work_package: WorkPackageResource): boolean {
return(!!work_package.github);
@ -59,6 +61,7 @@ export function initializeGithubIntegrationPlugin(injector:Injector) {
OpenprojectCommonModule
],
providers: [
WorkPackagesGithubPrsService
],
declarations: [
GitHubTabComponent,
@ -66,6 +69,7 @@ export function initializeGithubIntegrationPlugin(injector:Injector) {
TabPrsComponent,
GitActionsMenuDirective,
GitActionsMenuComponent,
PullRequestComponent
],
exports: [
GitHubTabComponent,

@ -0,0 +1,109 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
@import "helpers"
.op-pr-pull-request
display: grid
grid-template-columns: auto 1fr auto auto
grid-template-areas: "title title title state" "avatar user link link" "avatar date link link" "avatar checks checks checks"
margin-bottom: 24px
.op-pr-title
@include text-shortener
font-weight: bold
grid-area: title
// have the same line height as the status "button" next to it
line-height: 34px
margin-right: 20px
.op-avatar
grid-area: avatar
.op-pr-user
display: block
font-weight: bold
line-height: 16px
margin-bottom: 3px
grid-area: user
.op-pr-date
display: block
font-size: 0.8rem
color: var(--gray-dark)
grid-area: date
.op-pr-link
grid-area: link
margin-top: 6px
.op-pr-state
grid-area: state
display: inline-block
padding: 8px
border-radius: 6px
border: 1px solid #fff
color: #fff
.op-pr-state_draft
background-color: #6a737d
.op-pr-state_open
background-color: #28a745
.op-pr-state_merged
background-color: #6f42c1
.op-pr-state_closed
background-color: #d73a49
.op-pr-checks
margin-top: 12px
grid-area: checks
margin-left: 0
&:before
content: attr(aria-label)
.op-pr-check
list-style-type: none
padding-top: 8px
margin-left: 5px
.op-pr-check-avatar img
width: 1.4em
height: 1.4em
margin-right: 5px
border-radius: var(--user-avatar-border-radius)
.op-pr-check-state
margin-left: 1em
color: var(--gray-dark)
font-style: italic
.op-pr-check-details
float: right
top: 10px
line-height: 16px
text-align: right

@ -0,0 +1,109 @@
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { Component, DebugElement, Input } from '@angular/core';
import { By } from "@angular/platform-browser";
import { PullRequestComponent } from "./pull-request.component";
@Component({
selector: 'op-date-time',
template: ``,
})
class OpDateTimeComponent {
@Input('dateTimeValue') dateTimeValue:any;
}
describe('PullRequestComponent', () => {
let component:PullRequestComponent;
let fixture:ComponentFixture<PullRequestComponent>;
let element:DebugElement;
const githubUser = {
avatarUrl: 'testavatarurl',
htmlUrl: 'test htmlUrl',
login: 'test login',
};
const checkRun = {
appOwnerAvatarUrl: 'test appOwnerAvatarUrl',
completedAt: 'test completedAt',
conclusion: 'test conclusion',
detailsUrl: 'testdetailsurl',
htmlUrl: 'test htmlUrl',
name: 'test name',
outputSummary: 'test outputSummary',
outputTitle: 'test outputTitle',
startedAt: 'test startedAt',
status: 'test status',
};
const pullRequestStub = {
additionsCount: 3,
body:{
format: '',
raw: 'test raw',
html:'<p>test</p>',
},
changedFilesCount: 3,
commentsCount: 3,
createdAt: 'test createdAt',
deletionsCount: 3,
draft: false,
githubUpdatedAt: 'test githubUpdatedAt',
htmlUrl: 'test htmlUrl',
id: 3,
labels: ['test'],
merged: false,
mergedAt: false,
mergedBy: githubUser,
number: 3,
repository: 'test repository',
reviewCommentsCount: 3,
state: 'open',
title: 'test title',
updatedAt: 'test updatedAt',
githubUser,
checkRuns:[checkRun],
}
beforeEach(async () => {
await TestBed
.configureTestingModule({
declarations: [
PullRequestComponent,
OpDateTimeComponent,
],
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PullRequestComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
// @ts-ignore
component.pullRequest = pullRequestStub;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render pull request data', () => {
const titleElement = fixture.debugElement.query(By.css('.op-pr-title')).nativeElement;
const avatarElement = fixture.debugElement.query(By.css('.op-avatar')).nativeElement;
const userElement = fixture.debugElement.query(By.css('.op-pr-user')).nativeElement;
const detailsElement = fixture.debugElement.query(By.css('.op-pr-link')).nativeElement;
const checkRuns = fixture.debugElement.queryAll(By.css('.op-pr-check'));
const checkRunElement = checkRuns[0].nativeElement;
const checkRunLinkElement = checkRuns[0].query(By.css('a')).nativeElement;
expect(titleElement.textContent).toContain(pullRequestStub.title);
expect(avatarElement.src).toContain(pullRequestStub.githubUser.avatarUrl);
expect(userElement.textContent).toContain(pullRequestStub.githubUser.login);
expect(detailsElement.textContent).toContain(`${pullRequestStub.repository}#${pullRequestStub.number}`);
expect(checkRuns.length).toBe(1);
expect(checkRunElement.textContent).toContain(pullRequestStub.checkRuns[0].name);
expect(checkRunLinkElement.href).toContain(pullRequestStub.checkRuns[0].detailsUrl);
});
});

@ -0,0 +1,67 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { Component, Input } from '@angular/core';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { GithubCheckRunResource } from '../hal/resources/github-check-run-resource';
import { IGithubPullRequestResource } from "../../../../../../../../modules/github_integration/frontend/module/typings";
@Component({
selector: 'github-pull-request',
templateUrl: './pull-request.template.html',
styleUrls: ['./pull-request.component.sass']
})
export class PullRequestComponent {
@Input() public pullRequest:IGithubPullRequestResource;
public text = {
label_updated_on: this.I18n.t('js.label_updated_on'),
label_details: this.I18n.t('js.label_details'),
label_actions: this.I18n.t('js.github_integration.github_actions'),
};
constructor(readonly PathHelper:PathHelperService,
readonly I18n:I18nService) {
}
get state() {
if (this.pullRequest.state === 'open') {
return (this.pullRequest.draft ? 'draft' : 'open');
} else {
return(this.pullRequest.merged ? 'merged' : 'closed');
}
}
public checkRunState(checkRun:GithubCheckRunResource) {
/* Github apps can *optionally* add an output object (and a title) which is the most relevant information to display.
If that is not present, we can display the conclusion (which is present only on finished runs).
If that is not present, we can always fall back to the status. */
return(checkRun.outputTitle || checkRun.conclusion || checkRun.status);
}
}

@ -0,0 +1,42 @@
<div class='op-pr-pull-request'>
<div class='op-pr-title'
[textContent]="pullRequest.title">
</div>
<img alt='PR author avatar'
class='op-avatar'
[src]="pullRequest.githubUser.avatarUrl"
*ngIf="pullRequest.githubUser"
/>
<span class='op-pr-user'>
<a [href]="pullRequest.githubUser.htmlUrl"
[textContent]="pullRequest.githubUser.login"
*ngIf="pullRequest.githubUser"
>
</a>
</span>
<span class='op-pr-date'>
{{ text.label_updated_on }}
<op-date-time [dateTimeValue]="pullRequest.githubUpdatedAt"></op-date-time>
</span>
<span class='op-pr-state' [ngClass]="'op-pr-state_' + state">{{state}}</span>
<a class='op-pr-link' [href]="pullRequest.htmlUrl" [textContent]="pullRequest.repository + '#' + pullRequest.number"></a>
<ul [attr.aria-label]="text.label_actions" class='op-pr-checks' *ngIf="pullRequest.checkRuns?.length">
<li class='op-pr-check' *ngFor="let checkRun of pullRequest.checkRuns">
<span class='op-pr-check-avatar'><img alt='app owner avatar' [src]="checkRun.appOwnerAvatarUrl" /></span>
<span class='op-pr-check-name' [textContent]="checkRun.name"></span>
<span class='op-pr-check-state' [textContent]="checkRunState(checkRun)"></span>
<div class='op-pr-check-details'>
<a [href]="checkRun.detailsUrl">
{{ text.label_details }}
</a>
</div>
</li>
</ul>
</div>

@ -0,0 +1,65 @@
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { TabHeaderComponent } from "core-app/modules/plugins/linked/openproject-github_integration/tab-header/tab-header.component";
import { By } from "@angular/platform-browser";
import { OpIconComponent } from "core-app/modules/icon/icon.component";
import { GitActionsMenuDirective } from "core-app/modules/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive";
import { OPContextMenuService } from "core-components/op-context-menu/op-context-menu.service";
describe('TabHeaderComponent', () => {
let component:TabHeaderComponent;
let fixture:ComponentFixture<TabHeaderComponent>;
let element:DebugElement;
const I18nServiceStub = {
t: function(key:string) {
return 'test translation';
}
}
let oPContextMenuService:jasmine.SpyObj<OPContextMenuService>;
// @ts-ignore
window.Mousetrap = () => () => {};
beforeEach(async () => {
const oPContextMenuServiceSpy = jasmine.createSpyObj('OPContextMenuService', ['show']);
await TestBed
.configureTestingModule({
declarations: [
TabHeaderComponent,
OpIconComponent,
GitActionsMenuDirective,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: OPContextMenuService, useValue: oPContextMenuServiceSpy },
],
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TabHeaderComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
oPContextMenuService = fixture.debugElement.injector.get(OPContextMenuService) as jasmine.SpyObj<OPContextMenuService>;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render title and copy button', () => {
const headerTitle = fixture.debugElement.query(By.css('h3')).nativeElement;
const headerCopyButton = fixture.debugElement.query(By.css('button.github-git-copy[gitActionsCopyDropdown]')).nativeElement;
expect(headerTitle.textContent.trim()).toBe('test translation');
expect(headerCopyButton).toBeTruthy();
});
});

@ -28,7 +28,6 @@
import {Component, Input} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
@Component({
@ -49,7 +48,6 @@ export class TabHeaderComponent {
gitMenuDescription: this.I18n.t('js.github_integration.tab_header.copy_menu.description'),
};
constructor(readonly PathHelper:PathHelperService,
readonly I18n:I18nService) {
constructor(readonly I18n:I18nService) {
}
}

@ -0,0 +1,142 @@
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { ChangeDetectorRef, DebugElement, Component, Input } from '@angular/core';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { OpIconComponent } from "core-app/modules/icon/icon.component";
import { GitActionsMenuDirective } from "core-app/modules/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive";
import { TabPrsComponent } from "core-app/modules/plugins/linked/openproject-github_integration/tab-prs/tab-prs.component";
import { HalResourceService } from "core-app/modules/hal/services/hal-resource.service";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { of } from "rxjs";
import { PullRequestComponent } from "core-app/modules/plugins/linked/openproject-github_integration/pull-request/pull-request.component";
import { By } from "@angular/platform-browser";
@Component({
selector: 'op-date-time',
template: '<p>OpDateTimeComponent </p>'
})
class OpDateTimeComponent {
@Input()
dateTimeValue:any;
}
describe('TabPrsComponent', () => {
let component:TabPrsComponent;
let fixture:ComponentFixture<TabPrsComponent>;
let element:DebugElement;
let halResourceService: jasmine.SpyObj<HalResourceService>;
let changeDetectorRef: jasmine.SpyObj<ChangeDetectorRef>;
const I18nServiceStub = {
t: function(key:string) {
return 'test translation';
}
}
const APIV3Stub = {
work_packages: {
id: () => ({github_pull_requests: 'prpath'})
}
}
const pullRequests = {
elements: [
{
title: 'title 1',
githubUser: {
avatarUrl: 'githubUser 1 avatarUrl',
htmlUrl: 'githubUser 1 htmlUrl',
login: 'githubUser 1 login',
},
githubUpdatedAt: 'githubUser 1 githubUpdatedAt',
repository: 'githubUser 1 repository',
number: 'githubUser 1 number',
checkRuns: [
{
name: 'githubUser 1 checkRun',
outputTitle: 'githubUser 1 outputTitle',
conclusion: 'githubUser 1 conclusion',
status: 'githubUser 1 status',
detailsUrl: 'githubUser 1 detailsUrl',
}
],
},
{
title: 'title 2',
githubUser: {
avatarUrl: 'githubUser 2 avatarUrl',
htmlUrl: 'githubUser 2 htmlUrl',
login: 'githubUser 2 login',
},
githubUpdatedAt: 'githubUser 2 githubUpdatedAt',
repository: 'githubUser 2 repository',
number: 'githubUser 2 number',
checkRuns: [
{
name: 'githubUser 2 checkRun',
outputTitle: 'githubUser 2 outputTitle',
conclusion: 'githubUser 2 conclusion',
status: 'githubUser 2 status',
detailsUrl: 'githubUser 2 detailsUrl',
}
],
}
]
}
beforeEach(async () => {
const halResourceServiceSpy = jasmine.createSpyObj('HalResourceService', ['get']);
const changeDetectorSpy = jasmine.createSpyObj('ChangeDetectorRef', ['detectChanges']);
// @ts-ignore
halResourceServiceSpy.get.and.returnValue(of(pullRequests));
await TestBed
.configureTestingModule({
declarations: [
TabPrsComponent,
OpIconComponent,
GitActionsMenuDirective,
PullRequestComponent,
OpDateTimeComponent,
],
providers: [
{ provide: I18nService, useValue: I18nServiceStub },
{ provide: HalResourceService, useValue: halResourceServiceSpy },
{ provide: APIV3Service, useValue: APIV3Stub },
{ provide: ChangeDetectorRef, useValue: changeDetectorSpy },
],
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TabPrsComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
halResourceService = fixture.debugElement.injector.get(HalResourceService) as jasmine.SpyObj<HalResourceService>;
changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef) as jasmine.SpyObj<ChangeDetectorRef>;
// @ts-ignore
component.workPackage = { id: 'testId' };
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display a PullRequestComponent per pull request', () => {
const pullRequests = fixture.debugElement.queryAll(By.css('github-pull-request'));
expect(pullRequests.length).toBe(2);
});
it('should display a message when there are no pull requests', () => {
component.pullRequests = [];
fixture.detectChanges();
const pullRequests = fixture.debugElement.queryAll(By.css('github-pull-request'));
const textMessage = fixture.debugElement.queryAll(By.css('p'));
expect(pullRequests.length).toBe(0);
expect(textMessage).toBeTruthy();
});
});

@ -26,22 +26,40 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import {Component, Input} from '@angular/core';
import {Component, Input, OnInit} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import { APIV3Service } from 'core-app/modules/apiv3/api-v3.service';
import { HalResourceService } from 'core-app/modules/hal/services/hal-resource.service';
import { CollectionResource } from 'core-app/modules/hal/resources/collection-resource';
import { ChangeDetectorRef } from '@angular/core';
import { IGithubPullRequestResource } from "../../../../../../../../modules/github_integration/frontend/module/typings";
@Component({
selector: 'tab-prs',
templateUrl: './tab-prs.template.html'
})
export class TabPrsComponent {
export class TabPrsComponent implements OnInit {
@Input() public workPackage:WorkPackageResource;
@Input() public pullRequests:WorkPackageResource[];
constructor(readonly PathHelper:PathHelperService,
readonly I18n:I18nService) {
public pullRequests:IGithubPullRequestResource[] = [];
constructor(
readonly I18n:I18nService,
readonly apiV3Service:APIV3Service,
readonly halResourceService:HalResourceService,
readonly changeDetector:ChangeDetectorRef,
) {}
ngOnInit(): void {
const pullRequestsPath = this.apiV3Service.work_packages.id({id: this.workPackage.id })?.github_pull_requests.path;
this.halResourceService
.get<CollectionResource<IGithubPullRequestResource>>(pullRequestsPath)
.subscribe((value) => {
this.pullRequests = value.elements;
this.changeDetector.detectChanges();
});
}
public getEmptyText() {

@ -1,3 +1,7 @@
<ng-container *ngIf="pullRequests.length === 0">
<p [innerHTML]="getEmptyText()"></p>
</ng-container>
<div *ngFor="let pullRequest of pullRequests">
<github-pull-request [pullRequest]="pullRequest"></github-pull-request>
</div>

@ -0,0 +1,51 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { WorkPackageResource } from 'core-app/modules/hal/resources/work-package-resource';
import { HalResource } from 'core-app/modules/hal/resources/hal-resource';
import { Injectable } from '@angular/core';
import { ConfigurationService } from 'core-app/modules/common/config/configuration.service';
import { WorkPackageLinkedResourceCache } from 'core-components/wp-single-view-tabs/wp-linked-resource-cache.service';
@Injectable()
export class WorkPackagesGithubPrsService extends WorkPackageLinkedResourceCache<HalResource[]> {
constructor(public ConfigurationService:ConfigurationService) {
super();
}
protected load(workPackage:WorkPackageResource):Promise<HalResource[]> {
return workPackage.github_pull_requests.$update().then((data:any) => {
return this.sortList(data.elements);
});
}
protected sortList(pullRequests:HalResource[], attr = 'createdAt'):HalResource[] {
return _.sortBy(_.flatten(pullRequests), attr);
}
}

@ -0,0 +1,57 @@
import { HalResource } from "../../../../frontend/src/app/modules/hal/resources/hal-resource";
export interface ITab {
label:string,
help:string,
selected:boolean,
lines:number,
textToCopy: ()=>string
}
export interface IGithubPullRequestResource extends HalResource {
additionsCount?:number;
body?:{
format?:string;
raw?:string;
html?:string;
},
changedFilesCount?:number;
commentsCount?:number;
createdAt?:string;
deletionsCount?:number;
draft?:boolean;
githubUpdatedAt?:string;
htmlUrl?:string;
id?:number;
labels?:string[];
merged?:boolean;
mergedAt?:string;
mergedBy?:IGithubUserResource;
number?:number;
repository?:string;
reviewCommentsCount?:number;
state?:string;
title?:string;
updatedAt?:string;
githubUser?:IGithubUserResource;
checkRuns?:IGithubCheckRunResource[];
}
export interface IGithubUserResource {
avatarUrl:string;
htmlUrl:string;
login:string;
}
export interface IGithubCheckRunResource {
appOwnerAvatarUrl:string;
completedAt:string;
conclusion:string;
detailsUrl:string;
htmlUrl:string;
name:string;
outputSummary:string;
outputTitle:string;
startedAt:string;
status:string;
}

@ -59,9 +59,10 @@ module OpenProject::GithubIntegration
# Returns:
# - Array<WorkPackage>
def find_visible_work_packages(ids, user)
WorkPackage.includes(:project)
.where(id: ids)
.select { |wp| user.allowed_to?(:add_work_package_notes, wp.project) }
WorkPackage
.includes(:project)
.where(id: ids)
.select { |wp| user.allowed_to?(:add_work_package_notes, wp.project) }
end
# Returns a list of `WorkPackage`s that were referenced in the `text` and are visible to the given `user`.

@ -69,9 +69,7 @@ module OpenProject::GithubIntegration
# We still want to create a PR record (even if it just has partial data), to remember that it was referenced
# and avoid adding reference-comments twice.
OpenProject::GithubIntegration::Services::UpsertPartialPullRequest.new.call(
github_html_url: payload.issue.pull_request.html_url,
number: payload.issue.number,
repository: payload.repository.full_name,
payload,
work_packages: work_packages
)
end

@ -66,7 +66,10 @@ module OpenProject::GithubIntegration
end
def pull_request
@pull_request ||= GithubPullRequest.find_by(github_id: payload.pull_request.id)
@pull_request ||= GithubPullRequest
.where(github_id: payload.pull_request.id)
.or(GithubPullRequest.where(github_html_url: payload.pull_request.html_url))
.take
end
def upsert_pull_request(work_packages)

@ -31,36 +31,42 @@ module OpenProject::GithubIntegration::Services
##
# Takes pull request data coming from GitHub webhook data and stores
# them as a `GithubPullRequest`.
# issue_comments webhooks don't give us the full PR data, but just a html_url.
# issue_comments webhooks don't give us the full PR data, but just a a subset, e.g. html_url, state and title
# As described in [the docs](https://docs.github.com/en/rest/reference/issues#list-organization-issues-assigned-to-the-authenticated-user),
# pull request are considered to also be issues
# pull request are considered to also be issues.
#
# Returns the upserted partial `GithubPullRequest`.
class UpsertPartialPullRequest
def call(github_html_url:, number:, repository:, work_packages:)
pull_request = find_full(github_html_url)
def call(payload, work_packages:)
params = extract_params(payload)
if pull_request.present?
pull_request.update!(work_packages: pull_request.work_packages | work_packages)
else
find_or_initialize_partial(github_html_url).tap do |pr|
pr.update!(
number: number,
repository: repository,
work_packages: pr.work_packages | work_packages
)
end
find_or_initialize(params[:github_html_url]).tap do |pr|
pr.update!(work_packages: pr.work_packages | work_packages, **extract_params(payload))
end
end
private
def find_full(github_html_url)
GithubPullRequest.complete.find_by(github_html_url: github_html_url)
def extract_params(payload)
{
github_html_url: payload.issue.pull_request.html_url,
github_updated_at: payload.issue.updated_at,
github_user: github_user_id(payload.issue.user.to_h),
number: payload.issue.number,
state: payload.issue.state,
repository: payload.repository.full_name,
title: payload.issue.title
}
end
def find_or_initialize_partial(github_html_url)
GithubPullRequest.partial.find_or_initialize_by(github_html_url: github_html_url)
def find_or_initialize(github_html_url)
GithubPullRequest.find_or_initialize_by(github_html_url: github_html_url)
end
def github_user_id(payload)
return if payload.blank?
::OpenProject::GithubIntegration::Services::UpsertGithubUser.new.call(payload)
end
end
end

@ -53,8 +53,6 @@ FactoryBot.define do
changed_files_count { 5 }
trait :partial do
state { 'partial' }
github_user { nil }
github_id { nil }
labels { nil }

@ -44,11 +44,13 @@ describe 'Open the GitHub tab', type: :feature, js: true do
let(:project) { FactoryBot.create :project }
let(:work_package) { FactoryBot.create(:work_package, project: project, subject: 'A test work_package') }
let(:github_tab) { Pages::GitHubTab.new(work_package.id) }
let(:pull_request) { FactoryBot.create :github_pull_request, :open, work_packages: [work_package], title: 'A Test PR title' }
let(:check_run) { FactoryBot.create :github_check_run, github_pull_request: pull_request, name: 'a check run name' }
shared_examples_for "a github tab" do
before do
check_run
login_as(user)
work_package
end
# compares the clipboard content by drafting a new comment, pressing ctrl+v and
@ -64,19 +66,32 @@ describe 'Open the GitHub tab', type: :feature, js: true do
work_package_page.switch_to_tab(tab: 'github')
end
it 'show the github tab when the user is allowed to see it' do
it 'shows the github tab when the user is allowed to see it' do
work_package_page.visit!
work_package_page.switch_to_tab(tab: 'github')
expect(page).to have_content('There are no pull requests')
expect(page).to have_content("Link an existing PR by using the code OP##{work_package.id}")
github_tab.git_actions_menu_button.click
github_tab.git_actions_copy_button.click
expect(page).to have_text('Copied!')
expect_clipboard_content("#{work_package.type.name.downcase}/#{work_package.id}-a-test-work_package")
expect(page).to have_text('A Test PR title')
expect(page).to have_text('a check run name')
end
context 'when there are no pull requests' do
let(:check_run) {}
let(:pull_request) {}
it 'shows the github tab with an empty-pull-requests message' do
work_package_page.visit!
work_package_page.switch_to_tab(tab: 'github')
expect(page).to have_content('There are no pull requests')
expect(page).to have_content("Link an existing PR by using the code OP##{work_package.id}")
end
end
describe 'when the user does not have the permissions to see the github tab' do
context 'when the user does not have the permissions to see the github tab' do
let(:role) do
FactoryBot.create(:role,
permissions: %i(view_work_packages
@ -90,7 +105,7 @@ describe 'Open the GitHub tab', type: :feature, js: true do
end
end
describe 'when the github integration is not enabled for the project' do
context 'when the github integration is not enabled for the project' do
let(:project) { FactoryBot.create(:project, disable_modules: 'github') }
it 'does not show the github tab' do

@ -32,7 +32,7 @@ describe OpenProject::GithubIntegration::HookHandler do
subject(:process_webhook) do
described_class.new
.tap { journal_counts_before }
.process('github', OpenStruct.new(env: environment), params, user)
.process('github', OpenStruct.new(env: environment), ActionController::Parameters.new(payload), user)
.tap { [work_packages[0], work_packages[1], work_packages[2], work_packages[3]].map(&:reload) }
end
@ -103,6 +103,7 @@ describe OpenProject::GithubIntegration::HookHandler do
expect { process_webhook }.to(change(GithubPullRequest, :count).by(1).and(change(GithubUser, :count).by(1)))
pull_request = GithubPullRequest.last
github_user = GithubUser.last
expect(pull_request).to have_attributes(
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}",
@ -116,11 +117,11 @@ describe OpenProject::GithubIntegration::HookHandler do
comments_count: 22,
review_comments_count: 33,
changed_files_count: 12,
repository: 'test_user/webhooks_playground'
repository: 'test_user/webhooks_playground',
github_user_id: github_user.id
)
expect(pull_request.work_packages).to eq [work_packages[0]]
github_user = GithubUser.last
expect(github_user).to have_attributes(
github_login: 'test_user',
github_html_url: 'https://github.com/test_user',
@ -133,15 +134,17 @@ describe OpenProject::GithubIntegration::HookHandler do
it 'creates a partial GithubPullRequest' do
expect { process_webhook }.to(
change(GithubPullRequest, :count).by(1).and(
change(GithubUser, :count).by(0)
change(GithubUser, :count).by(1)
)
)
pull_request = GithubPullRequest.last
github_user = GithubUser.last
expect(pull_request).to have_attributes(
number: 1,
state: 'partial',
title: nil,
state: issue_state,
title: issue_title,
body: nil,
merged: nil,
draft: nil,
@ -150,9 +153,16 @@ describe OpenProject::GithubIntegration::HookHandler do
deletions_count: nil,
comments_count: nil,
review_comments_count: nil,
changed_files_count: nil
changed_files_count: nil,
github_user_id: github_user.id
)
expect(pull_request.work_packages).to eq [work_packages[0]]
expect(github_user).to have_attributes(
github_login: 'test_user',
github_html_url: 'https://github.com/test_user',
github_avatar_url: 'https://avatars.githubusercontent.com/u/206108?v=4'
)
end
end
@ -160,14 +170,12 @@ describe OpenProject::GithubIntegration::HookHandler do
let(:event) { 'pull_request' }
context 'when opened without mentioning any work package' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'opened',
title: 'A PR title',
body: 'A PR body'
)
let(:payload) do
webhook_payload(
'pull_request',
'opened',
title: 'A PR title',
body: 'A PR body'
)
end
@ -176,14 +184,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when opened and mentioning a work package in the PR title' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'opened',
title: "A PR title mentioning OP##{work_packages[0].id}",
body: 'A PR body'
)
let(:payload) do
webhook_payload(
'pull_request',
'opened',
title: "A PR title mentioning OP##{work_packages[0].id}",
body: 'A PR body'
)
end
let(:created_journals) { journal_counts_after.sum - journal_counts_before.sum }
@ -193,14 +199,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when opened and mentioning a work package using its code in the PR body' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'opened',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'opened',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'PR Opened' }
@ -210,20 +214,18 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when opened and mentioning many work packages using URLs in the PR body' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'opened',
title: 'A PR title',
body: "A PR body mentioning
* http://#{host_name}/wp/#{work_packages[0].id}
* http://#{host_name}/wp/#{work_packages[0].id} (second mention should not create a second comment)
* https://#{host_name}/work_packages/#{work_packages[1].id}
* http://#{host_name}/subdir/wp/#{work_packages[2].id}
* https://#{host_name}/subdir/work_packages/#{work_packages[3].id}
"
)
let(:payload) do
webhook_payload(
'pull_request',
'opened',
title: 'A PR title',
body: "A PR body mentioning
* http://#{host_name}/wp/#{work_packages[0].id}
* http://#{host_name}/wp/#{work_packages[0].id} (second mention should not create a second comment)
* https://#{host_name}/work_packages/#{work_packages[1].id}
* http://#{host_name}/subdir/wp/#{work_packages[2].id}
* https://#{host_name}/subdir/work_packages/#{work_packages[3].id}
"
)
end
@ -273,19 +275,17 @@ describe OpenProject::GithubIntegration::HookHandler do
]
end
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'opened',
title: 'A PR title',
body: "A PR body mentioning
* OP##{work_packages[0].id}
* OP##{work_packages[1].id}
* OP##{work_packages[2].id}
* OP##{work_packages[3].id}
"
)
let(:payload) do
webhook_payload(
'pull_request',
'opened',
title: 'A PR title',
body: "A PR body mentioning
* OP##{work_packages[0].id}
* OP##{work_packages[1].id}
* OP##{work_packages[2].id}
* OP##{work_packages[3].id}
"
)
end
@ -325,14 +325,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when opened as draft' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'opened_draft',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'opened_draft',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'PR Opened' }
@ -343,14 +341,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when synchronized' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'synchronize',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'synchronize',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
@ -359,14 +355,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when marked as ready_for_review' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'ready_for_review',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'ready_for_review',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'PR Ready for Review' }
@ -376,14 +370,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when the PR was re-labeled' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'labeled',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'labeled',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:pr_labels) do
@ -398,15 +390,13 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when the PR title was edited' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'edited_title',
old_title: 'The old PR title',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'edited_title',
old_title: 'The old PR title',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'Referenced in PR' }
@ -416,15 +406,13 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when the PR body was edited' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'edited_body',
title: 'A PR title',
old_body: 'The old PR body',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'edited_body',
title: 'A PR title',
old_body: 'The old PR body',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'Referenced in PR' }
@ -434,14 +422,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when the PR was converted to draft' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'converted_to_draft',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'converted_to_draft',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:pr_draft) { true }
@ -451,14 +437,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when the PR was closed without merging' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'closed_no_merge',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'closed_no_merge',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'PR Closed' }
@ -469,14 +453,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when the PR was reopened' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'reopened',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'reopened',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'PR Opened' }
@ -486,14 +468,12 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when the PR was merged' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'pull_request',
'closed_merged',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'pull_request',
'closed_merged',
title: 'A PR title',
body: "A PR body mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'PR Merged' }
@ -515,13 +495,11 @@ describe OpenProject::GithubIntegration::HookHandler do
let(:event) { 'issue_comment' }
context 'when an issue comment was created' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'issue_comment',
'create_not_associated_to_pr',
body: "A comment in an issue mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'issue_comment',
'create_not_associated_to_pr',
body: "A comment in an issue mentioning OP##{work_packages[0].id}"
)
end
@ -530,29 +508,28 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when a PR comment was created' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'issue_comment',
'create',
body: "A PR comment mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'issue_comment',
'create',
body: "A PR comment mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'Referenced' }
it_behaves_like 'it comments on the first work_package'
it_behaves_like 'it creates a partial pull request'
it_behaves_like 'it creates a partial pull request' do
let(:issue_state) { payload['issue']['state'] }
let(:issue_title) { payload['issue']['title'] }
end
end
context 'when an issue comment was edited' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'issue_comment',
'edited_not_associated_to_pr',
body: "A comment in an issue mentioning OP##{work_packages[0].id}"
)
let(:payload) do
webhook_payload(
'issue_comment',
'edited_not_associated_to_pr',
body: "A comment in an issue mentioning OP##{work_packages[0].id}"
)
end
@ -560,26 +537,27 @@ describe OpenProject::GithubIntegration::HookHandler do
it_behaves_like 'it does not create a pull request'
end
context 'when an PR comment was edited' do
let(:params) do
ActionController::Parameters.new(
webhook_payload(
'issue_comment',
'edited',
body: "A PR comment mentioning OP##{work_packages[0].id}"
)
context 'when a PR comment was edited' do
let(:payload) do
webhook_payload(
'issue_comment',
'edited',
body: "A PR comment mentioning OP##{work_packages[0].id}"
)
end
let(:journal_entry) { 'Referenced' }
it_behaves_like 'it comments on the first work_package'
it_behaves_like 'it creates a partial pull request'
it_behaves_like 'it creates a partial pull request' do
let(:issue_state) { payload['issue']['state'] }
let(:issue_title) { payload['issue']['title'] }
end
end
end
context 'when receiving a webhook for a ping event' do
let(:event) { 'ping' }
let(:params) { ActionController::Parameters.new(webhook_payload('ping', 'ping')) }
let(:payload) { webhook_payload('ping', 'ping') }
it_behaves_like 'it does not comment on any work package'
it_behaves_like 'it does not create a pull request'
@ -593,8 +571,8 @@ describe OpenProject::GithubIntegration::HookHandler do
before { github_pull_request }
context 'when a run was queued but is not associated to a PR' do
let(:params) do
ActionController::Parameters.new(webhook_payload('check_run', 'queued_no_associated_pr'))
let(:payload) do
webhook_payload('check_run', 'queued_no_associated_pr')
end
it_behaves_like 'it does not comment on any work package'
@ -606,8 +584,8 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when a run was queued' do
let(:params) do
ActionController::Parameters.new(webhook_payload('check_run', 'queued'))
let(:payload) do
webhook_payload('check_run', 'queued')
end
it_behaves_like 'it does not comment on any work package'
@ -627,8 +605,8 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when a run was completed successfully' do
let(:params) do
ActionController::Parameters.new(webhook_payload('check_run', 'completed_success'))
let(:payload) do
webhook_payload('check_run', 'completed_success')
end
it_behaves_like 'it does not comment on any work package'
@ -648,8 +626,8 @@ describe OpenProject::GithubIntegration::HookHandler do
end
context 'when a run was completed with a failure' do
let(:params) do
ActionController::Parameters.new(webhook_payload('check_run', 'completed_failure'))
let(:payload) do
webhook_payload('check_run', 'completed_failure')
end
it_behaves_like 'it does not comment on any work package'

@ -67,21 +67,27 @@ describe OpenProject::GithubIntegration::NotificationHandler::IssueComment do
let(:work_package) { FactoryBot.create :work_package }
before do
allow(handler_instance).to receive(:comment_on_referenced_work_packages).and_return(nil)
allow(OpenProject::GithubIntegration::Services::UpsertPartialPullRequest).to receive(:new)
.and_return(upsert_partial_pull_request_service)
allow(upsert_partial_pull_request_service).to receive(:call).and_return(nil)
allow(handler_instance)
.to receive(:comment_on_referenced_work_packages)
.and_return(nil)
allow(OpenProject::GithubIntegration::Services::UpsertPartialPullRequest)
.to receive(:new)
.and_return(upsert_partial_pull_request_service)
allow(upsert_partial_pull_request_service)
.to receive(:call)
.and_return(nil)
end
shared_examples_for 'upserting a GithubPullRequest' do
it 'calls the UpsertPartialPullRequest service' do
process
expect(upsert_partial_pull_request_service).to have_received(:call).with(
github_html_url: pr_html_url,
number: pr_number,
repository: repo_full_name,
work_packages: [work_package]
)
expect(upsert_partial_pull_request_service)
.to have_received(:call) do |received_payload, work_packages:|
expect(received_payload.to_h)
.to eql payload
expect(work_packages)
.to match_array [work_package]
end
end
end
@ -155,12 +161,13 @@ describe OpenProject::GithubIntegration::NotificationHandler::IssueComment do
it 'calls the UpsertPartialPullRequest service without adding already known work_packages' do
process
expect(upsert_partial_pull_request_service).to have_received(:call).with(
github_html_url: pr_html_url,
number: pr_number,
repository: repo_full_name,
work_packages: []
)
expect(upsert_partial_pull_request_service)
.to have_received(:call) do |received_payload, work_packages:|
expect(received_payload.to_h)
.to eql payload
expect(work_packages)
.to match_array []
end
end
end
end
@ -213,12 +220,13 @@ describe OpenProject::GithubIntegration::NotificationHandler::IssueComment do
it 'calls the UpsertPartialPullRequest service without adding already known work_packages' do
process
expect(upsert_partial_pull_request_service).to have_received(:call).with(
github_html_url: pr_html_url,
number: pr_number,
repository: repo_full_name,
work_packages: []
)
expect(upsert_partial_pull_request_service)
.to have_received(:call) do |received_payload, work_packages:|
expect(received_payload.to_h)
.to eql payload
expect(work_packages)
.to match_array []
end
end
end
end

@ -29,14 +29,41 @@
require File.expand_path('../../../../spec_helper', __dir__)
describe OpenProject::GithubIntegration::Services::UpsertPartialPullRequest do
subject(:upsert) { described_class.new.call(**params) }
subject(:upsert) do
described_class.new.call(OpenProject::GithubIntegration::NotificationHandler::Helper::Payload.new(payload),
work_packages: work_packages)
end
let!(:upsert_user_service) do
upsert_user_service = instance_double(::OpenProject::GithubIntegration::Services::UpsertGithubUser)
allow(::OpenProject::GithubIntegration::Services::UpsertGithubUser)
.to receive(:new)
.and_return(upsert_user_service)
let(:params) do
allow(upsert_user_service)
.to receive(:call)
.and_return(GithubUser.new(id: 12345))
end
let(:payload) do
{
github_html_url: 'https://github.com/pulls/1',
number: 23,
repository: 'test_user/repo',
work_packages: work_packages
"issue" => {
"number" => 23,
"title" => 'Some title',
"updated_at" => "2021-04-06T15:16:03Z",
"state" => 'closed',
"pull_request" => {
"html_url" => 'https://github.com/pulls/1'
},
"user" => {
"login" => "test_user",
"id" => 206108,
"html_url" => "https://github.com/test_user"
}
},
"repository" => {
"full_name" => 'test_user/repo'
}
}
end
let(:work_packages) { FactoryBot.create_list(:work_package, 1) }
@ -46,9 +73,12 @@ describe OpenProject::GithubIntegration::Services::UpsertPartialPullRequest do
expect(GithubPullRequest.last).to have_attributes(
github_id: nil,
state: 'partial',
state: 'closed',
number: 23,
title: 'Some title',
github_html_url: 'https://github.com/pulls/1',
github_updated_at: DateTime.parse("2021-04-06T15:16:03Z"),
github_user_id: 12345,
repository: 'test_user/repo',
work_packages: work_packages
)
@ -57,7 +87,6 @@ describe OpenProject::GithubIntegration::Services::UpsertPartialPullRequest do
context 'when a github pull request with that html_url already exists' do
let(:github_pull_request) do
FactoryBot.create(:github_pull_request,
state: 'partial',
github_html_url: 'https://github.com/pulls/1')
end
@ -69,7 +98,6 @@ describe OpenProject::GithubIntegration::Services::UpsertPartialPullRequest do
context 'when a github pull request with that html_url and work_package exists' do
let(:github_pull_request) do
FactoryBot.create(:github_pull_request,
state: 'partial',
github_html_url: 'https://github.com/pulls/1',
work_packages: work_packages)
end
@ -82,7 +110,6 @@ describe OpenProject::GithubIntegration::Services::UpsertPartialPullRequest do
context 'when a github pull request with that html_url and work_package exists and a new work_package is referenced' do
let(:github_pull_request) do
FactoryBot.create(:github_pull_request,
state: 'partial',
github_html_url: 'https://github.com/pulls/1',
work_packages: already_known_work_packages)
end
@ -90,28 +117,40 @@ describe OpenProject::GithubIntegration::Services::UpsertPartialPullRequest do
let(:already_known_work_packages) { [work_packages[0]] }
it 'adds the new work package' do
expect { upsert }.to change { github_pull_request.reload.work_packages }.from(already_known_work_packages).to(work_packages)
expect { upsert }
.to change { github_pull_request.reload.work_packages }
.from(already_known_work_packages)
.to(work_packages)
end
end
context 'when an open github pull request with that html_url and work_package exists and a new work_package is referenced' do
let(:github_pull_request) do
FactoryBot.create(:github_pull_request,
state: 'open',
github_html_url: 'https://github.com/pulls/1',
repository: 'some_user/a_repository',
state: 'open',
github_id: 1,
work_packages: already_known_work_packages)
end
let(:work_packages) { FactoryBot.create_list(:work_package, 2) }
let(:already_known_work_packages) { [work_packages[0]] }
it 'only adds the new work package and leaves the state untouched' do
expect { upsert }.to change { github_pull_request.reload.work_packages }.from(already_known_work_packages).to(work_packages)
it 'adds the new work package and updates attributes' do
expect { upsert }
.to change { github_pull_request.reload.work_packages }
.from(already_known_work_packages)
.to(work_packages)
expect(github_pull_request).to have_attributes(
state: 'open',
github_id: 1,
state: 'closed',
number: 23,
title: 'Some title',
github_user_id: 12345,
github_html_url: 'https://github.com/pulls/1',
repository: 'some_user/a_repository'
github_updated_at: DateTime.parse("2021-04-06T15:16:03Z"),
repository: 'test_user/repo'
)
end
end

@ -123,11 +123,31 @@ describe OpenProject::GithubIntegration::Services::UpsertPullRequest do
context 'when a partial github pull request with that html_url already exists' do
let(:github_pull_request) do
FactoryBot.create(:github_pull_request, github_html_url: 'https://github.com/test_user/repo', state: 'partial')
FactoryBot.create(:github_pull_request,
github_id: nil,
changed_files_count: nil,
body: nil,
comments_count: nil,
review_comments_count: nil,
additions_count: nil,
deletions_count: nil,
github_html_url: 'https://github.com/test_user/repo',
state: 'closed')
end
it 'updates the github pull request' do
expect { upsert }.to change { github_pull_request.reload.state }.from('partial').to('open')
expect { upsert }.to change { github_pull_request.reload.state }.from('closed').to('open')
expect(github_pull_request).to have_attributes(
github_id: 123,
state: 'open',
number: 5,
title: 'The PR title',
body: 'The PR body',
github_html_url: 'https://github.com/test_user/repo',
github_updated_at: DateTime.parse('20210409T12:13:14Z'),
repository: 'test_user/repo'
)
end
end

@ -33,12 +33,19 @@ describe GithubPullRequest do
it { is_expected.to validate_presence_of :number }
it { is_expected.to validate_presence_of :repository }
it { is_expected.to validate_presence_of :state }
it { is_expected.to validate_presence_of :title }
it { is_expected.to validate_presence_of :github_updated_at }
context 'when it is not a partial pull request' do
subject { described_class.new(state: 'open') }
subject do
described_class.new(changed_files_count: 5,
body: 'something',
comments_count: 4,
review_comments_count: 3,
additions_count: 2,
deletions_count: 1)
end
it { is_expected.to validate_presence_of :github_updated_at }
it { is_expected.to validate_presence_of :title }
it { is_expected.to validate_presence_of :body }
it { is_expected.to validate_presence_of :comments_count }
it { is_expected.to validate_presence_of :review_comments_count }
@ -56,20 +63,6 @@ describe GithubPullRequest do
end
end
describe '.complete' do
subject { described_class.complete }
let(:open) { FactoryBot.create(:github_pull_request, :open) }
let(:merged) { FactoryBot.create(:github_pull_request, :closed_merged) }
let(:closed) { FactoryBot.create(:github_pull_request, :closed_unmerged) }
let(:partial) { FactoryBot.create(:github_pull_request, :partial) }
let(:all_pull_requests) { [open, merged, closed, partial] }
before { all_pull_requests }
it { is_expected.to match_array [open, merged, closed] }
end
describe '.without_work_package' do
subject { described_class.without_work_package }
@ -88,22 +81,68 @@ describe GithubPullRequest do
end
describe '#partial?' do
context 'when the state is partial' do
subject { described_class.new(state: 'partial').partial? }
context 'when the body is set' do
subject { described_class.new(body: 'something').partial? }
it { is_expected.to be_falsey }
end
context 'when the comments_count is set' do
subject { described_class.new(comments_count: 5).partial? }
it { is_expected.to be_falsey }
end
context 'when the review_comments_count is set' do
subject { described_class.new(review_comments_count: 5).partial? }
it { is_expected.to be true }
it { is_expected.to be_falsey }
end
context 'when the state is open' do
subject { described_class.new(state: 'open').partial? }
context 'when the additions_count is set' do
subject { described_class.new(additions_count: 5).partial? }
it { is_expected.to be false }
it { is_expected.to be_falsey }
end
context 'when the state is closed' do
subject { described_class.new(state: 'closed').partial? }
context 'when the deletions_count is set' do
subject { described_class.new(deletions_count: 5).partial? }
it { is_expected.to be_falsey }
end
context 'when the changed_files_count is set' do
subject { described_class.new(changed_files_count: 5).partial? }
it { is_expected.to be_falsey }
end
context 'when the all of the above are nil set and the state is open' do
subject do
described_class.new(changed_files_count: nil,
body: nil,
comments_count: nil,
review_comments_count: nil,
additions_count: nil,
deletions_count: nil,
state: 'open').partial?
end
it { is_expected.to be_truthy }
end
context 'when the all of the above are nil set and the state is closed' do
subject do
described_class.new(changed_files_count: nil,
body: nil,
comments_count: nil,
review_comments_count: nil,
additions_count: nil,
deletions_count: nil,
state: 'closed').partial?
end
it { is_expected.to be false }
it { is_expected.to be_truthy }
end
end

Loading…
Cancel
Save