├── .gitignore ├── README.md └── metadata ├── classes ├── GithubCallbackController.cls ├── GithubCallbackController.cls-meta.xml ├── GithubCallbackControllerTest.cls ├── GithubCallbackControllerTest.cls-meta.xml ├── GithubCallbackMockGenerator.cls ├── GithubCallbackMockGenerator.cls-meta.xml ├── GithubController.cls ├── GithubController.cls-meta.xml ├── GithubControllerTest.cls ├── GithubControllerTest.cls-meta.xml ├── GithubLoginController.cls ├── GithubLoginController.cls-meta.xml ├── GithubLoginControllerTest.cls └── GithubLoginControllerTest.cls-meta.xml ├── objectTranslations └── GitHub_App_Settings__c-en_US.objectTranslation ├── objects └── GitHub_App_Settings__c.object ├── package.xml ├── pages ├── github_app_html.page ├── github_app_html.page-meta.xml ├── github_callback_html.page ├── github_callback_html.page-meta.xml ├── github_link_html.page ├── github_link_html.page-meta.xml ├── github_login_html.page └── github_login_html.page-meta.xml ├── quickActions └── Case.Link_to_GitHub_Issue.quickAction ├── remoteSiteSettings └── GitHub.remoteSite ├── staticresources ├── github.resource ├── github.resource-meta.xml ├── github_app_js.resource ├── github_app_js.resource-meta.xml ├── github_app_resources.resource ├── github_app_resources.resource-meta.xml ├── github_callback_js.resource ├── github_callback_js.resource-meta.xml ├── github_controllers_js.resource ├── github_controllers_js.resource-meta.xml ├── github_issue_detail_html.resource ├── github_issue_detail_html.resource-meta.xml ├── github_issues_html.resource ├── github_issues_html.resource-meta.xml ├── github_link_html.resource ├── github_link_html.resource-meta.xml ├── github_link_png.resource ├── github_link_png.resource-meta.xml ├── github_login_js.resource ├── github_login_js.resource-meta.xml ├── github_s1_js.resource ├── github_s1_js.resource-meta.xml ├── github_services_js.resource ├── github_services_js.resource-meta.xml ├── github_style_css.resource ├── github_style_css.resource-meta.xml ├── octocat_jpg.resource ├── octocat_jpg.resource-meta.xml ├── underscore.resource └── underscore.resource-meta.xml └── tabs └── Issues_in_GitHub.tab /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IssuesInGitHub 2 | 3 | Link GitHub issues to Cases in Salesforce1 4 | 5 | [Install the Unmanaged Package](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tE00000001YvM) - read the installation instructions below! 6 | 7 | Imagine you're working on a product that has some or all of its code in a GitHub repository. This app integrates GitHub Issues with Cases in Salesforce. As a user, I can browse open issues assigned to me, link them to cases, and see the link on the Case record. The app does OAuth against GitHub, obtaining an access token to call the GitHub REST API and retrieve issues and comments. 8 | 9 | ## Preparation 10 | 11 | You will need a GitHub account. If you do not already have one, you can sign up [here](https://github.com). You will also need to create a repository on GitHub. If you already have a repository with issues, then great - use that; otherwise, [create a new repository](https://github.com/new). Create some issues in the new repository and assign them to yourself (select them in the issues screen for your repository, click 'assignee' and select yourself). 12 | 13 | You will also need to create a GitHub app specific to your org. [Register a new application on GitHub](https://github.com/settings/applications/new), and enter: 14 | 15 | * **Application Name:** Issues in Github 16 | * **Homepage URL:** `https://github.com/metadaddy-sfdc/IssuesInGitHub` 17 | * **Application description:** Link GitHub issues to Cases in Salesforce1 18 | * **Authorization callback URL:** `https://instance.salesforce.com/apex/github_callback_html` 19 | 20 | **IMPORTANT:** Replace `instance` as appropriate for your DE org - e.g. na15. 21 | 22 | Keep the GitHub app window around - you'll need to copy Client ID and Client Secret. 23 | 24 | To install the app into a DE org, click the 'Install Unmanaged Packed' at the top of this README. 25 | 26 | You will need to add the app's tab to the left nav menu - go to **Setup | Mobile Administration | Mobile Navigation**, move 'Issues in GitHub' to the 'Selected' list, click 'Up' to move it just after 'Groups', and click 'Save'. 27 | 28 | Now add the GitHub link and publisher action to the Case Page Layout. Go to **Setup | Customize | Cases | Page Layouts**, and click 'Edit' next to 'Case Layout'. Drag the 'GitHub Link' field and drop it under 'Case Number' in the 'Case Information' section. Click 'Actions' in the palette, drag 'Link to GitHub Issue' and drop it in the 'Publisher Actions' between 'Post' and 'Log a Call'. 29 | 30 | Save the page layout. 31 | 32 | You will also need to add the GitHub Token to the User Page Layout. Go to **Setup | Customize | Users | Page Layouts**, and click 'Edit' next to 'User Layout'. Drag the 'GitHubAccessToken' field and drop it in the 'Additional Information' section. Save the page layout. 33 | 34 | You will also need to create a Custom Setting record with the app's Github credentials. Go to **Setup | Develop | Custom Settings**, click 'Manage' next to 'GitHub App Settings' and create a new record with: 35 | 36 | * **Name:** Github App 37 | * **Client Id:** copy from GitHub app window 38 | * **Client Secret:** copy from GitHub app window 39 | 40 | In your DE org, pin 'Cases' to the top of the Search results - in the regular browser interface to your DE org, search for any text you like, hover over the 'Cases' entry in the 'Records' list on the left, and click the pin that appears. Cases will move to the top of the Records list. This just makes it easier to find cases during the demo. 41 | 42 | The app writes a GitHub access token to your user record. Between demos, you will need to delete the access token to be able to show authorization with GitHub. Go to your User record in your DE org (**Setup | Manage Users | Users |** click your user's name), click 'Edit', then scroll down to the 'Additional Information' section and delete the GitHubAccessToken value. Hit 'Save'. 43 | 44 | Show the app on a real phone if you can (using [AirServer](http://www.airserver.com/) or [Reflector](http://www.airsquirrels.com/reflector/) to show your phone's screen on your laptop). Next best is to use the iOS simulator. If you have to use your laptop browser, use Chrome and [enable mobile emulation](https://developers.google.com/chrome-developer-tools/docs/mobile-emulation) - this will correctly generate the touch events that Salesforce1 is expecting. 45 | 46 | Run through the demo at least a couple of times, and leave some issues linked to cases. 47 | 48 | ## Running through the app 49 | 50 | In the Salesforce1 Mobile App, open the left nav menu, and select 'Issues in GitHub'. If you deleted your GitHub access token (see 'Preparation', above), you should see a login page with the GitHub logo. 51 | 52 | ![Login to GitHub](http://metadaddy.github.io/IssuesInGitHub/LoginToGithub.PNG) 53 | 54 | Touch the logo, and you will be prompted to log in to GitHub, and authorize the app to access your data. 55 | 56 | ![Authorize App](http://metadaddy.github.io/IssuesInGitHub/AuthorizeApp.PNG) 57 | 58 | Don't worry if it skips the login page and goes straight to authorization - if you've been round this loop, the browser has your GitHub cookie - it's not important for the demo flow. Also, if you don't see the GitHub authorization screen within a few seconds, just close the window and touch the GitHub logo again - occasionally this page seems to glitch. 59 | 60 | Once you've authorized the app, you should see a list of issues from GitHub. 61 | 62 | ![Issue List](http://metadaddy.github.io/IssuesInGitHub/IssueList.PNG) 63 | 64 | This JavaScript single-page app, running on a Visualforce page in Salesforce1, is retrieving this data directly from GitHub, without hitting the Apex controller. 65 | 66 | Note that it will only show open issues assigned to you, so if you see an empty list of issues, go create some in GitHub and assign them to yourself (see 'Preparation', above). You can touch an issue to drill down and see more detail, including any comments posted to the issue, and any cases that the issue is linked to. 67 | 68 | ![Issue Detail](http://metadaddy.github.io/IssuesInGitHub/IssueDetail.PNG) 69 | 70 | You can touch a linked case to go to its record detail page - seamless integration between the app and Salesforce1. 71 | 72 | ![Case](http://metadaddy.github.io/IssuesInGitHub/Case.PNG) 73 | 74 | Now let's link an issue to a case. Open the left nav menu, and select 'Cases' (it should be visible at the top of the 'Recent' sub menu - if not, you'll need to pin it in the Search results - see 'Preparation', above). Select a Case, and open the publisher (plus sign on bottom right of screen). 75 | 76 | ![Publisher Actions](http://metadaddy.github.io/IssuesInGitHub/PublisherActions.PNG) 77 | 78 | Select 'Link to GitHub Issue' and you should see a list of issues. 79 | 80 | ![Link Issue](http://metadaddy.github.io/IssuesInGitHub/LinkIssue.PNG) 81 | 82 | This time, touching an issue will select it for linking to the case. A link icon indicates the selected issue. You can play around in this screen a little - the icon will move to the last touched issue, and touching the linked issue will deselect it. 83 | 84 | When you've selected an issue, touch 'Submit' at the top of the screen. You'll be taken back to the Case record, which will refresh. Swipe left to see the Case detail, and scroll down to the 'GitHub Link' field. 85 | 86 | ![Case Detail](http://metadaddy.github.io/IssuesInGitHub/CaseDetail.PNG) 87 | 88 | Touch 'View GitHub Issue' and the detail page for the linked issue should appear. 89 | 90 | ![Linked Issue](http://metadaddy.github.io/IssuesInGitHub/LinkedIssue.PNG) 91 | 92 | Notice that the linked case is listed on the issue detail. Note that, currently, the 'spinner' stays active, even though the detail page has loaded. I'm investigating why this is the case - it might be fixed by the time you run through this. 93 | 94 | ## Exploring the code 95 | 96 | I'll highlight the important integration points, examine the code if you want to to dig deeper. 97 | 98 | Start on the [github_app_html](https://github.com/metadaddy-sfdc/IssuesInGitHub/blob/master/metadata/pages/github_app_html.page) Visualforce Page. Check out the `` attributes - we're using `showHeader="false"`, `sidebar="false"`, `standardStylesheets="false"` and `applyHtmlTag="false"` to get complete control over the page. 99 | 100 | The app uses [AngularJS](https://angularjs.org/) and [Ionic](http://ionicframework.com/) - the CSS and JavaScript for this is loaded from static resources. AngularJS is a client-side MVC framework - it allows you to divide up your JavaScript app into modules. You can see the includes for the modules in github_app_html under the `""` comment. You can also see where the app gets the GitHub API access token from the Visualforce Page's Apex controller, in the `{!accessToken}` merge field. 101 | 102 | Open that Apex controller - the [GithubController](https://github.com/metadaddy-sfdc/IssuesInGitHub/blob/master/metadata/classes/GithubController.cls) class. The Apex controller reads the access token from the User record in its constructor. Notice in the 'onLoad' action method, if there is no access token, the onLoad method returns a redirect to the login page. The Visualforce Page runs this action method before it renders the page - this is what activates the GitHub OAuth login. We won't go down the OAuth rabbit hole here, but the code is all there if anyone wants to take a look. 103 | 104 | Back on the github_app_html Visualforce Page, scroll down to the bottom - the `` element is where the app content will be rendered. This app comprises a number of views that can be dynamically loaded from templates. 105 | 106 | Open the [github_app_js](https://github.com/metadaddy-sfdc/IssuesInGitHub/blob/master/metadata/staticresources/github_app_js.resource) static resource - this is where the app is configured. Scroll down and you'll see a set of 'states' that associate a url with a template and controller, all broken down into their own static resource files. You can see the states for the issue list, issue detail, and link views. 107 | 108 | Open the [github_issues_html](https://github.com/metadaddy-sfdc/IssuesInGitHub/blob/master/metadata/staticresources/github_issues_html.resource) static resource. Notice the ``, containing an ``, with an `ng-repeat` attribute. This is very similar to a Visualforce `` - we're iterating through a list of issues, showing some fields from each one. Notice the link - it has a # prefix - we don't want to go to a different page to see issue detail; we want to show a different view, and the # is followed by a path, containing an encoded issue url. When the user touches the list item, that link will be followed, loading the issue detail view. 109 | 110 | Open the [github_controllers_js](https://github.com/metadaddy-sfdc/IssuesInGitHub/blob/master/metadata/staticresources/github_controllers_js.resource) static resource. The first controller, IssuesCtrl, simply loads all issues from the Issues service. In AngularJS, controllers simply marshall data into the template - services retrieve data. 111 | 112 | Open the [github_services_js](https://github.com/metadaddy-sfdc/IssuesInGitHub/blob/master/metadata/staticresources/github_services_js.resource) static resource. The 'all' function retrieves a list of issues from GitHub. Skip past the error handling to see where it caches the issue list, and builds a map so that issues are easily accessible to the app from their URL without going back to the GitHub API. This function uses [promises](https://docs.angularjs.org/api/ng/service/$q) to simplify asynchronous programming. The function returns a promise that the caller can use to get the data later, without building a stack of callbacks. 113 | 114 | Open the [github_link_html](https://github.com/metadaddy-sfdc/IssuesInGitHub/blob/master/metadata/pages/github_link_html.page) Visualforce Page. This is the publisher action for linking an issue to a case. Since it runs in the context of a Case record, it uses the Case standard controller, but we define GithubController as an extension. Scroll down and you'll see that the HTML is almost identical to github_app_html, except that we pull data from the Apex controller (the `{!case.Id}`, `{!case.CaseNumber}` and `{!case.GitHub_Issue__c}` merge fields) and add it to the AngularJS root scope so that it is accessible to the AngularJS controllers. 115 | 116 | A little lower down, you can see the integration with Salesforce1. We use the publisher library, activating the 'Submit' button in the `publisher.showPanel` handler, and, when the user hits submit, we call the attachIssue method on GithubController to attach that issue to the case. Go to the GithubController class and scroll down to the attachIssue method - it's really very simple. 117 | 118 | Now look at the 'Buttons, Links & Actions' page for Case. You'll see the publisher action there. Click its 'Edit' link and you'll see the Visualforce page there. Now go to the Case 'Page Layout' and point out the action in the list of publisher actions. 119 | 120 | There are quite a few moving parts here, but the end result is a very seamless user experience. With this app in Salesforce1, the user can move between issues from GitHub and Cases from Salesforce in a very natural way. -------------------------------------------------------------------------------- /metadata/classes/GithubCallbackController.cls: -------------------------------------------------------------------------------- 1 | public class GithubCallbackController{ 2 | public String body { get; set; } 3 | public String accessToken { get; set; } 4 | public String oauthError { get; set; } 5 | public String state { get; set; } 6 | public Boolean closeWindow { get; set; } 7 | public GithubCallbackController(){ 8 | GitHub_App_Settings__c settings = GitHub_App_Settings__c.getValues('Github App'); 9 | state = ApexPages.currentPage().getParameters().get('state'); 10 | String code = ApexPages.currentPage().getParameters().get('code'); 11 | HttpRequest req = new HttpRequest(); 12 | req.setEndpoint('https://github.com/login/oauth/access_token'); 13 | req.setBody('&client_id=' + EncodingUtil.urlEncode(settings.client_Id__c, 'UTF-8') + 14 | '&client_secret=' + EncodingUtil.urlEncode(settings.client_Secret__c, 'UTF-8') + 15 | '&code=' + EncodingUtil.urlEncode(code, 'UTF-8')); 16 | req.setMethod('POST'); 17 | req.setHeader('Accept', 'application/json'); 18 | Http h = new Http(); 19 | HttpResponse res = h.send(req); 20 | body = res.getBody(); 21 | Map oauth = (Map)JSON.deserializeUntyped(body); 22 | accessToken = (String)oauth.get('access_token'); 23 | oauthError = (String)oauth.get('error'); 24 | // Sometimes the parent window wins the race, and loads the iframe before we 25 | // get the code here! 26 | if (accessToken != null || 27 | (oauthError != null && oauthError.equals('bad_verification_code'))) { 28 | closeWindow = true; 29 | } 30 | } 31 | 32 | public PageReference onLoad() { 33 | if (accessToken != null) { 34 | User u = [SELECT Id, GitHubAccessToken__c FROM User WHERE Id = :UserInfo.getUserId()]; 35 | if (u.GitHubAccessToken__c != accessToken) { 36 | u.GitHubAccessToken__c = accessToken; 37 | update u; 38 | } 39 | } 40 | return null; 41 | } 42 | } -------------------------------------------------------------------------------- /metadata/classes/GithubCallbackController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /metadata/classes/GithubCallbackControllerTest.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public class GithubCallbackControllerTest{ 3 | public static String accessTokenValue = 'ABC123'; 4 | static String clientId = 'ABC123'; 5 | static String clientSecret = '123ABC'; 6 | static String code = 'XYZ789'; 7 | static String state = '/apex/another_page'; 8 | 9 | static testMethod void testGithubCallbackController(){ 10 | Test.setMock(HttpCalloutMock.class, new GithubCallbackMockGenerator()); 11 | 12 | GitHub_App_Settings__c setting = 13 | new GitHub_App_Settings__c(Name = 'Github App', 14 | Client_Id__c = clientId, 15 | Client_Secret__c = clientSecret); 16 | insert setting; 17 | 18 | User u = GithubControllerTest.createUser(null); 19 | System.runAs(u) { 20 | PageReference pageRef = Page.github_login_html; 21 | pageRef.getParameters().put('state', state); 22 | pageRef.getParameters().put('code', code); 23 | Test.setCurrentPage(pageRef); 24 | 25 | Test.startTest(); 26 | 27 | GithubCallbackController controller = new GithubCallbackController(); 28 | System.assertEquals(state, controller.state); 29 | System.assertEquals(accessTokenValue, controller.accessToken); 30 | System.assertEquals(null, controller.oauthError); 31 | System.assertEquals(true, controller.closeWindow); 32 | 33 | PageReference next = controller.onLoad(); 34 | 35 | Test.stopTest(); 36 | 37 | User u2 = [SELECT GitHubAccessToken__c FROM User WHERE Id = :u.Id]; 38 | System.assertEquals(accessTokenValue, u2.GitHubAccessToken__c); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /metadata/classes/GithubCallbackControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /metadata/classes/GithubCallbackMockGenerator.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | global class GithubCallbackMockGenerator implements HttpCalloutMock { 3 | // Implement this interface method 4 | global HTTPResponse respond(HTTPRequest req) { 5 | System.assertEquals('https://github.com/login/oauth/access_token', req.getEndpoint()); 6 | System.assertEquals('POST', req.getMethod()); 7 | 8 | // Create a fake response 9 | HttpResponse res = new HttpResponse(); 10 | res.setHeader('Content-Type', 'application/json'); 11 | res.setBody('{"access_token":"'+GithubCallbackControllerTest.accessTokenValue+'"}'); 12 | res.setStatusCode(200); 13 | return res; 14 | } 15 | } -------------------------------------------------------------------------------- /metadata/classes/GithubCallbackMockGenerator.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /metadata/classes/GithubController.cls: -------------------------------------------------------------------------------- 1 | global class GithubController { 2 | public String accessToken {get; set;} 3 | public String issue {get; set;} 4 | 5 | public GithubController() { 6 | User u = [SELECT Id, GithubAccessToken__c FROM User WHERE Id = :UserInfo.GetUserId()]; 7 | accessToken = u.GithubAccessToken__c; 8 | issue = ApexPages.currentPage().getParameters().get('issue'); 9 | } 10 | 11 | public GithubController(ApexPages.StandardController controller) { 12 | this(); 13 | } 14 | 15 | public PageReference onLoad() { 16 | // Check that we have an access token for this user 17 | if (accessToken == null) { 18 | // If not, we need to log the user in 19 | PageReference page = new PageReference('/apex/github_login_html'); 20 | page.getParameters().put('state', ApexPages.currentPage().getUrl()); 21 | return page; 22 | } 23 | return null; 24 | } 25 | 26 | @RemoteAction 27 | global static void deleteAccessToken() { 28 | User u = [SELECT Id, GithubAccessToken__c FROM User WHERE Id = :UserInfo.GetUserId()]; 29 | u.GithubAccessToken__c = null; 30 | update u; 31 | } 32 | 33 | @RemoteAction 34 | global static void attachIssue(Id caseId, String issueUrl) { 35 | Case c = [SELECT Id FROM Case WHERE Id = :caseId]; 36 | 37 | c.GitHub_Issue__c = issueUrl; 38 | 39 | update c; 40 | } 41 | 42 | @RemoteAction 43 | global static Id createCase(String title, String body, String issueUrl) { 44 | Case c = new Case( 45 | Subject = title.left(255), // max length of Case.Subject = 255 46 | Description = body.left(32767), // max length of Case.Description = 32k 47 | GitHub_Issue__c = issueUrl 48 | ); 49 | 50 | insert c; 51 | 52 | return c.Id; 53 | } 54 | 55 | @RemoteAction 56 | global static String getJsonIssueCaseMapping() { 57 | Map> issueCaseMapping = new Map>(); 58 | 59 | for (Case c : [select GitHub_Issue__c, CaseNumber, Id 60 | from Case 61 | where GitHub_Issue__c != null 62 | order by CaseNumber]) { 63 | List cases = issueCaseMapping.get(c.GitHub_Issue__c); 64 | if (cases == null) { 65 | cases = new List(); 66 | issueCaseMapping.put(c.GitHub_Issue__c, cases); 67 | } 68 | cases.add(c); 69 | } 70 | 71 | return JSON.serialize(issueCaseMapping); 72 | } 73 | } -------------------------------------------------------------------------------- /metadata/classes/GithubController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /metadata/classes/GithubControllerTest.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public class GithubControllerTest{ 3 | static String accessTokenValue = 'ABC123'; 4 | static String issueUrl = 'https://api.github.com/repos/testuser/testrepo/issues/1'; 5 | 6 | public static User createUser(String accessToken) { 7 | Profile standardUser = [SELECT Id FROM Profile WHERE Name = 'Standard User']; 8 | User u = new User(Alias = 'test', 9 | Email = 'githubtester@devorg.pat', 10 | LastName = 'Tester', 11 | ProfileId = standardUser.Id, 12 | Username = 'githubtester@devorg.pat', 13 | TimeZoneSidKey = 'America/Los_Angeles', 14 | LocaleSidKey = 'en_US', 15 | EmailEncodingKey = 'ISO-8859-1', 16 | LanguageLocaleKey = 'en_US', 17 | GitHubAccessToken__c = accessToken); 18 | 19 | insert u; 20 | 21 | return u; 22 | } 23 | 24 | static testMethod void testGithubControllerAccessToken(){ 25 | User u = createUser(accessTokenValue); 26 | System.runAs(u) { 27 | PageReference pageRef = Page.github_app_html; 28 | Test.setCurrentPage(pageRef); 29 | 30 | GithubController controller = new GithubController(); 31 | 32 | // Does the controller have the access token? 33 | System.assertEquals(accessTokenValue, controller.accessToken); 34 | 35 | // We shouldn't redirect on load 36 | PageReference next = controller.onLoad(); 37 | System.assertEquals(null, next); 38 | } 39 | } 40 | 41 | static testMethod void testGithubControllerNoAccessToken(){ 42 | User u = createUser(null); 43 | System.runAs(u) { 44 | PageReference pageRef = Page.github_app_html; 45 | Test.setCurrentPage(pageRef); 46 | 47 | // Check for no access token 48 | GithubController controller = new GithubController(); 49 | System.assertEquals(null, controller.accessToken); 50 | 51 | // We should redirect on load 52 | PageReference next = controller.onLoad(); 53 | System.assertEquals(next.getUrl(), 54 | '/apex/github_login_html?state='+EncodingUtil.urlEncode(pageRef.getUrl(), 'UTF-8')); 55 | 56 | // State should be set to bring us back here 57 | System.assertEquals(pageRef.getUrl(), next.getParameters().get('state')); 58 | } 59 | } 60 | 61 | static testMethod void testGithubControllerExtension(){ 62 | User u = createUser(accessTokenValue); 63 | System.runAs(u) { 64 | Case c = new Case(); 65 | insert c; 66 | 67 | ApexPages.StandardController sc = new ApexPages.standardController(c); 68 | GithubController controller = new GithubController(sc); 69 | 70 | // Does the controller have the access token? 71 | System.assertEquals(accessTokenValue, controller.accessToken); 72 | } 73 | } 74 | 75 | static testMethod void testDeleteAccessToken(){ 76 | User u = createUser(accessTokenValue); 77 | System.runAs(u) { 78 | GithubController.deleteAccessToken(); 79 | 80 | User u2 = [SELECT GitHubAccessToken__c FROM User WHERE Id = :u.Id]; 81 | System.assertEquals(null, u2.GitHubAccessToken__c); 82 | } 83 | } 84 | 85 | static testMethod void testAttachIssue(){ 86 | User u = createUser(accessTokenValue); 87 | System.runAs(u) { 88 | Case c = new Case(); 89 | 90 | insert c; 91 | 92 | GithubController.attachIssue(c.Id, issueUrl); 93 | 94 | Case c2 = [SELECT GitHub_Issue__c FROM Case WHERE Id = :c.Id]; 95 | System.assertEquals(issueUrl, c2.GitHub_Issue__c); 96 | } 97 | } 98 | 99 | static testMethod void testGetJsonIssueCaseMapping(){ 100 | User u = createUser(accessTokenValue); 101 | System.runAs(u) { 102 | Case c = new Case(); 103 | 104 | insert c; 105 | 106 | GithubController.attachIssue(c.Id, issueUrl); 107 | 108 | String jsonData = GithubController.getJsonIssueCaseMapping(); 109 | 110 | Map mapping = (Map)JSON.deserializeUntyped(jsonData); 111 | 112 | List cases = (List)mapping.get(issueUrl); 113 | System.assertNotEquals(null, cases); 114 | 115 | Map myCase = (Map)cases[0]; 116 | System.assertEquals(c.Id, myCase.get('Id')); 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /metadata/classes/GithubControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /metadata/classes/GithubLoginController.cls: -------------------------------------------------------------------------------- 1 | public class GithubLoginController{ 2 | public String state {get; set;} 3 | public String clientId {get; set;} 4 | 5 | public GithubLoginController() { 6 | GitHub_App_Settings__c settings = GitHub_App_Settings__c.getValues('Github App'); 7 | state = ApexPages.currentPage().getParameters().get('state'); 8 | clientId = settings.client_Id__c; 9 | } 10 | } -------------------------------------------------------------------------------- /metadata/classes/GithubLoginController.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /metadata/classes/GithubLoginControllerTest.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public class GithubLoginControllerTest{ 3 | static String accessTokenValue = 'ABC123'; 4 | static String clientId = 'ABC123'; 5 | static String clientSecret = '123ABC'; 6 | static String state = '/apex/another_page'; 7 | 8 | static testMethod void testGithubLoginController(){ 9 | 10 | GitHub_App_Settings__c setting = 11 | new GitHub_App_Settings__c(Name = 'Github App', 12 | Client_Id__c = clientId, 13 | Client_Secret__c = clientSecret); 14 | insert setting; 15 | 16 | User u = GithubControllerTest.createUser(accessTokenValue); 17 | System.runAs(u) { 18 | PageReference pageRef = Page.github_login_html; 19 | pageRef.getParameters().put('state', state); 20 | Test.setCurrentPage(pageRef); 21 | 22 | GithubLoginController controller = new GithubLoginController(); 23 | System.assertEquals(clientId, controller.clientId); 24 | System.assertEquals(state, controller.state); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /metadata/classes/GithubLoginControllerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /metadata/objectTranslations/GitHub_App_Settings__c-en_US.objectTranslation: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | GitHub App Settings 6 | 7 | 8 | true 9 | GitHub App Settings 10 | 11 | 12 | 13 | Client_Id__c 14 | 15 | 16 | 17 | Client_Secret__c 18 | 19 | Consonant 20 | 21 | -------------------------------------------------------------------------------- /metadata/objects/GitHub_App_Settings__c.object: -------------------------------------------------------------------------------- 1 | 2 | 3 | List 4 | Protected 5 | false 6 | 7 | Client_Id__c 8 | false 9 | 10 | 100 11 | true 12 | false 13 | Text 14 | false 15 | 16 | 17 | Client_Secret__c 18 | false 19 | 20 | 100 21 | true 22 | false 23 | Text 24 | false 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /metadata/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | * 5 | AnalyticSnapshot 6 | 7 | 8 | * 9 | ApexClass 10 | 11 | 12 | * 13 | ApexComponent 14 | 15 | 16 | * 17 | ApexPage 18 | 19 | 20 | * 21 | ApexTrigger 22 | 23 | 24 | * 25 | ApprovalProcess 26 | 27 | 28 | * 29 | AssignmentRules 30 | 31 | 32 | * 33 | AuthProvider 34 | 35 | 36 | * 37 | AutoResponseRules 38 | 39 | 40 | * 41 | BusinessProcess 42 | 43 | 44 | * 45 | CallCenter 46 | 47 | 48 | * 49 | Community 50 | 51 | 52 | * 53 | CompactLayout 54 | 55 | 56 | * 57 | ConnectedApp 58 | 59 | 60 | * 61 | CustomApplication 62 | 63 | 64 | * 65 | CustomApplication 66 | 67 | 68 | * 69 | CustomApplicationComponent 70 | 71 | 72 | * 73 | CustomField 74 | 75 | 76 | * 77 | CustomLabels 78 | 79 | 80 | * 81 | CustomObject 82 | 83 | 84 | * 85 | CustomObjectTranslation 86 | 87 | 88 | * 89 | CustomPageWebLink 90 | 91 | 92 | * 93 | CustomSite 94 | 95 | 96 | * 97 | CustomTab 98 | 99 | 100 | * 101 | Dashboard 102 | 103 | 104 | * 105 | DataCategoryGroup 106 | 107 | 108 | * 109 | Document 110 | 111 | 112 | * 113 | EmailTemplate 114 | 115 | 116 | * 117 | EntitlementProcess 118 | 119 | 120 | * 121 | EntitlementTemplate 122 | 123 | 124 | * 125 | ExternalDataSource 126 | 127 | 128 | * 129 | FieldSet 130 | 131 | 132 | * 133 | Flow 134 | 135 | 136 | * 137 | Group 138 | 139 | 140 | * 141 | HomePageComponent 142 | 143 | 144 | * 145 | HomePageLayout 146 | 147 | 148 | * 149 | Letterhead 150 | 151 | 152 | * 153 | ListView 154 | 155 | 156 | * 157 | LiveChatAgentConfig 158 | 159 | 160 | * 161 | LiveChatButton 162 | 163 | 164 | * 165 | LiveChatDeployment 166 | 167 | 168 | * 169 | MilestoneType 170 | 171 | 172 | * 173 | NamedFilter 174 | 175 | 176 | * 177 | Network 178 | 179 | 180 | * 181 | PermissionSet 182 | 183 | 184 | * 185 | Portal 186 | 187 | 188 | * 189 | PostTemplate 190 | 191 | 192 | * 193 | Queue 194 | 195 | 196 | * 197 | QuickAction 198 | 199 | 200 | * 201 | RecordType 202 | 203 | 204 | * 205 | RemoteSiteSetting 206 | 207 | 208 | * 209 | Report 210 | 211 | 212 | * 213 | ReportType 214 | 215 | 216 | * 217 | Role 218 | 219 | 220 | * 221 | SamlSsoConfig 222 | 223 | 224 | * 225 | Scontrol 226 | 227 | 228 | * 229 | SharingReason 230 | 231 | 232 | * 233 | Skill 234 | 235 | 236 | * 237 | StaticResource 238 | 239 | 240 | * 241 | Territory 242 | 243 | 244 | * 245 | Translations 246 | 247 | 248 | * 249 | ValidationRule 250 | 251 | 29.0 252 | 253 | -------------------------------------------------------------------------------- /metadata/pages/github_app_html.page: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | Issues 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Back 18 | 19 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /metadata/pages/github_app_html.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | true 5 | 6 | 7 | -------------------------------------------------------------------------------- /metadata/pages/github_callback_html.page: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | Issues 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Back 18 | 19 | 20 | 23 | 24 | 25 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /metadata/pages/github_callback_html.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | 5 | 6 | -------------------------------------------------------------------------------- /metadata/pages/github_link_html.page: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | Link to Issue 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Back 19 | 20 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /metadata/pages/github_link_html.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | 5 | 6 | -------------------------------------------------------------------------------- /metadata/pages/github_login_html.page: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | Issues 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Back 17 | 18 | 19 | 20 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /metadata/pages/github_login_html.page-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30.0 4 | 5 | 6 | -------------------------------------------------------------------------------- /metadata/quickActions/Case.Link_to_GitHub_Issue.quickAction: -------------------------------------------------------------------------------- 1 | 2 | 3 | 250 4 | github_link_png 5 | 6 | github_link_html 7 | VisualforcePage 8 | -100 9 | 10 | -------------------------------------------------------------------------------- /metadata/remoteSiteSettings/GitHub.remoteSite: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | true 5 | https://github.com 6 | 7 | -------------------------------------------------------------------------------- /metadata/staticresources/github.resource: -------------------------------------------------------------------------------- 1 | // Github.js 0.9.0 2 | // (c) 2013 Michael Aufreiter, Development Seed 3 | // Github.js is freely distributable under the MIT license. 4 | // For all details and documentation: 5 | // http://substance.io/michael/github 6 | 7 | (function() { 8 | 9 | // Initial Setup 10 | // ------------- 11 | 12 | var XMLHttpRequest, Base64, _; 13 | if (typeof exports !== 'undefined') { 14 | XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest; 15 | _ = require('underscore'); 16 | Base64 = require('./lib/base64.js'); 17 | }else{ 18 | _ = window._; 19 | Base64 = window.Base64; 20 | } 21 | //prefer native XMLHttpRequest always 22 | if (typeof window !== 'undefined' && typeof window.XMLHttpRequest !== 'undefined'){ 23 | XMLHttpRequest = window.XMLHttpRequest; 24 | } 25 | 26 | 27 | var API_URL = 'https://api.github.com'; 28 | 29 | var Github = function(options) { 30 | 31 | // HTTP Request Abstraction 32 | // ======= 33 | // 34 | // I'm not proud of this and neither should you be if you were responsible for the XMLHttpRequest spec. 35 | 36 | function _request(method, path, data, cb, raw, sync) { 37 | function getURL() { 38 | var url = path.indexOf('//') >= 0 ? path : API_URL + path; 39 | return url + ((/\?/).test(url) ? "&" : "?") + (new Date()).getTime(); 40 | } 41 | 42 | var xhr = new XMLHttpRequest(); 43 | if (!raw) {xhr.dataType = "json";} 44 | 45 | xhr.open(method, getURL(), !sync); 46 | if (!sync) { 47 | xhr.onreadystatechange = function () { 48 | if (this.readyState == 4) { 49 | if (this.status >= 200 && this.status < 300 || this.status === 304) { 50 | cb(null, raw ? this.responseText : this.responseText ? JSON.parse(this.responseText) : true, this); 51 | } else { 52 | cb({path: path, request: this, error: this.status}); 53 | } 54 | } 55 | } 56 | }; 57 | xhr.setRequestHeader('Accept','application/vnd.github.raw+json'); 58 | xhr.setRequestHeader('Content-Type','application/json;charset=UTF-8'); 59 | if ((options.token) || (options.username && options.password)) { 60 | xhr.setRequestHeader('Authorization', options.token 61 | ? 'token '+ options.token 62 | : 'Basic ' + Base64.encode(options.username + ':' + options.password) 63 | ); 64 | } 65 | data ? xhr.send(JSON.stringify(data)) : xhr.send(); 66 | if (sync) return xhr.response; 67 | } 68 | 69 | function _requestAllPages(path, cb) { 70 | var results = []; 71 | (function iterate() { 72 | _request("GET", path, null, function(err, res, xhr) { 73 | if (err) { 74 | return cb(err); 75 | } 76 | 77 | results.push.apply(results, res); 78 | 79 | var links = (xhr.getResponseHeader('link') || '').split(/\s*,\s*/g), 80 | next = _.find(links, function(link) { return /rel="next"/.test(link); }); 81 | 82 | if (next) { 83 | next = (/<(.*)>/.exec(next) || [])[1]; 84 | } 85 | 86 | if (!next) { 87 | cb(err, results); 88 | } else { 89 | path = next; 90 | iterate(); 91 | } 92 | }); 93 | })(); 94 | } 95 | 96 | 97 | 98 | // User API 99 | // ======= 100 | 101 | Github.User = function() { 102 | this.repos = function(cb) { 103 | // Github does not always honor the 1000 limit so we want to iterate over the data set. 104 | _requestAllPages("/user/repos?type=all&per_page=1000&sort=updated", function(err, res) { 105 | cb(err, res); 106 | }); 107 | }; 108 | 109 | // List user organizations 110 | // ------- 111 | 112 | this.orgs = function(cb) { 113 | _request("GET", "/user/orgs", null, function(err, res) { 114 | cb(err, res); 115 | }); 116 | }; 117 | 118 | // List authenticated user's gists 119 | // ------- 120 | 121 | this.gists = function(cb) { 122 | _request("GET", "/gists", null, function(err, res) { 123 | cb(err,res); 124 | }); 125 | }; 126 | 127 | // List authenticated user's unread notifications 128 | // ------- 129 | 130 | this.notifications = function(cb) { 131 | _request("GET", "/notifications", null, function(err, res) { 132 | cb(err,res); 133 | }); 134 | }; 135 | 136 | // Show user information 137 | // ------- 138 | 139 | this.show = function(username, cb) { 140 | var command = username ? "/users/"+username : "/user"; 141 | 142 | _request("GET", command, null, function(err, res) { 143 | cb(err, res); 144 | }); 145 | }; 146 | 147 | // List user repositories 148 | // ------- 149 | 150 | this.userRepos = function(username, cb) { 151 | // Github does not always honor the 1000 limit so we want to iterate over the data set. 152 | _requestAllPages("/users/"+username+"/repos?type=all&per_page=1000&sort=updated", function(err, res) { 153 | cb(err, res); 154 | }); 155 | }; 156 | 157 | // List a user's gists 158 | // ------- 159 | 160 | this.userGists = function(username, cb) { 161 | _request("GET", "/users/"+username+"/gists", null, function(err, res) { 162 | cb(err,res); 163 | }); 164 | }; 165 | 166 | // List organization repositories 167 | // ------- 168 | 169 | this.orgRepos = function(orgname, cb) { 170 | // Github does not always honor the 1000 limit so we want to iterate over the data set. 171 | _requestAllPages("/orgs/"+orgname+"/repos?type=all&&page_num=1000&sort=updated&direction=desc", function(err, res) { 172 | cb(err, res); 173 | }); 174 | }; 175 | 176 | // Follow user 177 | // ------- 178 | 179 | this.follow = function(username, cb) { 180 | _request("PUT", "/user/following/"+username, null, function(err, res) { 181 | cb(err, res); 182 | }); 183 | }; 184 | 185 | // Unfollow user 186 | // ------- 187 | 188 | this.unfollow = function(username, cb) { 189 | _request("DELETE", "/user/following/"+username, null, function(err, res) { 190 | cb(err, res); 191 | }); 192 | }; 193 | }; 194 | 195 | 196 | // Repository API 197 | // ======= 198 | 199 | Github.Repository = function(options) { 200 | var repo = options.name; 201 | var user = options.user; 202 | 203 | var that = this; 204 | var repoPath = "/repos/" + user + "/" + repo; 205 | 206 | var currentTree = { 207 | "branch": null, 208 | "sha": null 209 | }; 210 | 211 | // Uses the cache if branch has not been changed 212 | // ------- 213 | 214 | function updateTree(branch, cb) { 215 | if (branch === currentTree.branch && currentTree.sha) return cb(null, currentTree.sha); 216 | that.getRef("heads/"+branch, function(err, sha) { 217 | currentTree.branch = branch; 218 | currentTree.sha = sha; 219 | cb(err, sha); 220 | }); 221 | } 222 | 223 | // Get a particular reference 224 | // ------- 225 | 226 | this.getRef = function(ref, cb) { 227 | _request("GET", repoPath + "/git/refs/" + ref, null, function(err, res) { 228 | if (err) return cb(err); 229 | cb(null, res.object.sha); 230 | }); 231 | }; 232 | 233 | // Create a new reference 234 | // -------- 235 | // 236 | // { 237 | // "ref": "refs/heads/my-new-branch-name", 238 | // "sha": "827efc6d56897b048c772eb4087f854f46256132" 239 | // } 240 | 241 | this.createRef = function(options, cb) { 242 | _request("POST", repoPath + "/git/refs", options, cb); 243 | }; 244 | 245 | // Delete a reference 246 | // -------- 247 | // 248 | // repo.deleteRef('heads/gh-pages') 249 | // repo.deleteRef('tags/v1.0') 250 | 251 | this.deleteRef = function(ref, cb) { 252 | _request("DELETE", repoPath + "/git/refs/"+ref, options, cb); 253 | }; 254 | 255 | // Create a repo 256 | // ------- 257 | 258 | this.createRepo = function(options, cb) { 259 | _request("POST", "/user/repos", options, cb); 260 | }; 261 | 262 | // Delete a repo 263 | // -------- 264 | 265 | this.deleteRepo = function(cb) { 266 | _request("DELETE", repoPath, options, cb); 267 | }; 268 | 269 | // List all tags of a repository 270 | // ------- 271 | 272 | this.listTags = function(cb) { 273 | _request("GET", repoPath + "/tags", null, function(err, tags) { 274 | if (err) return cb(err); 275 | cb(null, tags); 276 | }); 277 | }; 278 | 279 | // List all pull requests of a respository 280 | // ------- 281 | 282 | this.listPulls = function(state, cb) { 283 | _request("GET", repoPath + "/pulls" + (state ? '?state=' + state : ''), null, function(err, pulls) { 284 | if (err) return cb(err); 285 | cb(null, pulls); 286 | }); 287 | }; 288 | 289 | // Gets details for a specific pull request 290 | // ------- 291 | 292 | this.getPull = function(number, cb) { 293 | _request("GET", repoPath + "/pulls/" + number, null, function(err, pull) { 294 | if (err) return cb(err); 295 | cb(null, pull); 296 | }); 297 | }; 298 | 299 | // Retrieve the changes made between base and head 300 | // ------- 301 | 302 | this.compare = function(base, head, cb) { 303 | _request("GET", repoPath + "/compare/" + base + "..." + head, null, function(err, diff) { 304 | if (err) return cb(err); 305 | cb(null, diff); 306 | }); 307 | }; 308 | 309 | // List all branches of a repository 310 | // ------- 311 | 312 | this.listBranches = function(cb) { 313 | _request("GET", repoPath + "/git/refs/heads", null, function(err, heads) { 314 | if (err) return cb(err); 315 | cb(null, _.map(heads, function(head) { return _.last(head.ref.split('/')); })); 316 | }); 317 | }; 318 | 319 | // Retrieve the contents of a blob 320 | // ------- 321 | 322 | this.getBlob = function(sha, cb) { 323 | _request("GET", repoPath + "/git/blobs/" + sha, null, cb, 'raw'); 324 | }; 325 | 326 | // For a given file path, get the corresponding sha (blob for files, tree for dirs) 327 | // ------- 328 | 329 | this.getSha = function(branch, path, cb) { 330 | // Just use head if path is empty 331 | if (path === "") return that.getRef("heads/"+branch, cb); 332 | that.getTree(branch+"?recursive=true", function(err, tree) { 333 | if (err) return cb(err); 334 | var file = _.select(tree, function(file) { 335 | return file.path === path; 336 | })[0]; 337 | cb(null, file ? file.sha : null); 338 | }); 339 | }; 340 | 341 | // Retrieve the tree a commit points to 342 | // ------- 343 | 344 | this.getTree = function(tree, cb) { 345 | _request("GET", repoPath + "/git/trees/"+tree, null, function(err, res) { 346 | if (err) return cb(err); 347 | cb(null, res.tree); 348 | }); 349 | }; 350 | 351 | // Post a new blob object, getting a blob SHA back 352 | // ------- 353 | 354 | this.postBlob = function(content, cb) { 355 | if (typeof(content) === "string") { 356 | content = { 357 | "content": content, 358 | "encoding": "utf-8" 359 | }; 360 | } else { 361 | content = { 362 | "content": btoa(String.fromCharCode.apply(null, new Uint8Array(content))), 363 | "encoding": "base64" 364 | }; 365 | } 366 | 367 | _request("POST", repoPath + "/git/blobs", content, function(err, res) { 368 | if (err) return cb(err); 369 | cb(null, res.sha); 370 | }); 371 | }; 372 | 373 | // Update an existing tree adding a new blob object getting a tree SHA back 374 | // ------- 375 | 376 | this.updateTree = function(baseTree, path, blob, cb) { 377 | var data = { 378 | "base_tree": baseTree, 379 | "tree": [ 380 | { 381 | "path": path, 382 | "mode": "100644", 383 | "type": "blob", 384 | "sha": blob 385 | } 386 | ] 387 | }; 388 | _request("POST", repoPath + "/git/trees", data, function(err, res) { 389 | if (err) return cb(err); 390 | cb(null, res.sha); 391 | }); 392 | }; 393 | 394 | // Post a new tree object having a file path pointer replaced 395 | // with a new blob SHA getting a tree SHA back 396 | // ------- 397 | 398 | this.postTree = function(tree, cb) { 399 | _request("POST", repoPath + "/git/trees", { "tree": tree }, function(err, res) { 400 | if (err) return cb(err); 401 | cb(null, res.sha); 402 | }); 403 | }; 404 | 405 | // Create a new commit object with the current commit SHA as the parent 406 | // and the new tree SHA, getting a commit SHA back 407 | // ------- 408 | 409 | this.commit = function(parent, tree, message, cb) { 410 | var data = { 411 | "message": message, 412 | "author": { 413 | "name": options.username 414 | }, 415 | "parents": [ 416 | parent 417 | ], 418 | "tree": tree 419 | }; 420 | 421 | _request("POST", repoPath + "/git/commits", data, function(err, res) { 422 | currentTree.sha = res.sha; // update latest commit 423 | if (err) return cb(err); 424 | cb(null, res.sha); 425 | }); 426 | }; 427 | 428 | // Update the reference of your head to point to the new commit SHA 429 | // ------- 430 | 431 | this.updateHead = function(head, commit, cb) { 432 | _request("PATCH", repoPath + "/git/refs/heads/" + head, { "sha": commit }, function(err, res) { 433 | cb(err); 434 | }); 435 | }; 436 | 437 | // Show repository information 438 | // ------- 439 | 440 | this.show = function(cb) { 441 | _request("GET", repoPath, null, cb); 442 | }; 443 | 444 | // Get contents 445 | // -------- 446 | 447 | this.contents = function(branch, path, cb, sync) { 448 | return _request("GET", repoPath + "/contents?ref=" + branch + (path ? "&path=" + path : ""), null, cb, 'raw', sync); 449 | }; 450 | 451 | // Fork repository 452 | // ------- 453 | 454 | this.fork = function(cb) { 455 | _request("POST", repoPath + "/forks", null, cb); 456 | }; 457 | 458 | // Branch repository 459 | // -------- 460 | 461 | this.branch = function(oldBranch,newBranch,cb) { 462 | if(arguments.length === 2 && typeof arguments[1] === "function") { 463 | cb = newBranch; 464 | newBranch = oldBranch; 465 | oldBranch = "master"; 466 | } 467 | this.getRef("heads/" + oldBranch, function(err,ref) { 468 | if(err && cb) return cb(err); 469 | that.createRef({ 470 | ref: "refs/heads/" + newBranch, 471 | sha: ref 472 | },cb); 473 | }); 474 | } 475 | 476 | // Create pull request 477 | // -------- 478 | 479 | this.createPullRequest = function(options, cb) { 480 | _request("POST", repoPath + "/pulls", options, cb); 481 | }; 482 | 483 | // List hooks 484 | // -------- 485 | 486 | this.listHooks = function(cb) { 487 | _request("GET", repoPath + "/hooks", null, cb); 488 | }; 489 | 490 | // Get a hook 491 | // -------- 492 | 493 | this.getHook = function(id, cb) { 494 | _request("GET", repoPath + "/hooks/" + id, null, cb); 495 | }; 496 | 497 | // Create a hook 498 | // -------- 499 | 500 | this.createHook = function(options, cb) { 501 | _request("POST", repoPath + "/hooks", options, cb); 502 | }; 503 | 504 | // Edit a hook 505 | // -------- 506 | 507 | this.editHook = function(id, options, cb) { 508 | _request("PATCH", repoPath + "/hooks/" + id, options, cb); 509 | }; 510 | 511 | // Delete a hook 512 | // -------- 513 | 514 | this.deleteHook = function(id, cb) { 515 | _request("DELETE", repoPath + "/hooks/" + id, null, cb); 516 | }; 517 | 518 | // Read file at given path 519 | // ------- 520 | 521 | this.read = function(branch, path, cb) { 522 | that.getSha(branch, path, function(err, sha) { 523 | if (!sha) return cb("not found", null); 524 | that.getBlob(sha, function(err, content) { 525 | cb(err, content, sha); 526 | }); 527 | }); 528 | }; 529 | 530 | // Remove a file from the tree 531 | // ------- 532 | 533 | this.remove = function(branch, path, cb) { 534 | updateTree(branch, function(err, latestCommit) { 535 | that.getTree(latestCommit+"?recursive=true", function(err, tree) { 536 | // Update Tree 537 | var newTree = _.reject(tree, function(ref) { return ref.path === path; }); 538 | _.each(newTree, function(ref) { 539 | if (ref.type === "tree") delete ref.sha; 540 | }); 541 | 542 | that.postTree(newTree, function(err, rootTree) { 543 | that.commit(latestCommit, rootTree, 'Deleted '+path , function(err, commit) { 544 | that.updateHead(branch, commit, function(err) { 545 | cb(err); 546 | }); 547 | }); 548 | }); 549 | }); 550 | }); 551 | }; 552 | 553 | // Delete a file from the tree 554 | // ------- 555 | 556 | this.delete = function(branch, path, cb) { 557 | that.getSha(branch, path, function(err, sha) { 558 | if (!sha) return cb("not found", null); 559 | var delPath = repoPath + "/contents/" + path; 560 | var params = { 561 | "message": "Deleted " + path, 562 | "sha": sha 563 | }; 564 | delPath += "?message=" + encodeURIComponent(params.message); 565 | delPath += "&sha=" + encodeURIComponent(params.sha); 566 | _request("DELETE", delPath, null, cb); 567 | }) 568 | } 569 | 570 | // Move a file to a new location 571 | // ------- 572 | 573 | this.move = function(branch, path, newPath, cb) { 574 | updateTree(branch, function(err, latestCommit) { 575 | that.getTree(latestCommit+"?recursive=true", function(err, tree) { 576 | // Update Tree 577 | _.each(tree, function(ref) { 578 | if (ref.path === path) ref.path = newPath; 579 | if (ref.type === "tree") delete ref.sha; 580 | }); 581 | 582 | that.postTree(tree, function(err, rootTree) { 583 | that.commit(latestCommit, rootTree, 'Deleted '+path , function(err, commit) { 584 | that.updateHead(branch, commit, function(err) { 585 | cb(err); 586 | }); 587 | }); 588 | }); 589 | }); 590 | }); 591 | }; 592 | 593 | // Write file contents to a given branch and path 594 | // ------- 595 | 596 | this.write = function(branch, path, content, message, cb) { 597 | updateTree(branch, function(err, latestCommit) { 598 | if (err) return cb(err); 599 | that.postBlob(content, function(err, blob) { 600 | if (err) return cb(err); 601 | that.updateTree(latestCommit, path, blob, function(err, tree) { 602 | if (err) return cb(err); 603 | that.commit(latestCommit, tree, message, function(err, commit) { 604 | if (err) return cb(err); 605 | that.updateHead(branch, commit, cb); 606 | }); 607 | }); 608 | }); 609 | }); 610 | }; 611 | 612 | // List commits on a repository. Takes an object of optional paramaters: 613 | // sha: SHA or branch to start listing commits from 614 | // path: Only commits containing this file path will be returned 615 | // since: ISO 8601 date - only commits after this date will be returned 616 | // until: ISO 8601 date - only commits before this date will be returned 617 | // ------- 618 | 619 | this.getCommits = function(options, cb) { 620 | options = options || {}; 621 | var url = repoPath + "/commits"; 622 | var params = []; 623 | if (options.sha) { 624 | params.push("sha=" + encodeURIComponent(options.sha)); 625 | } 626 | if (options.path) { 627 | params.push("path=" + encodeURIComponent(options.path)); 628 | } 629 | if (options.since) { 630 | var since = options.since; 631 | if (since.constructor === Date) { 632 | since = since.toISOString(); 633 | } 634 | params.push("since=" + encodeURIComponent(since)); 635 | } 636 | if (options.until) { 637 | var until = options.until; 638 | if (until.constructor === Date) { 639 | until = until.toISOString(); 640 | } 641 | params.push("until=" + encodeURIComponent(until)); 642 | } 643 | if (params.length > 0) { 644 | url += "?" + params.join("&"); 645 | } 646 | _request("GET", url, null, cb); 647 | }; 648 | }; 649 | 650 | // Gists API 651 | // ======= 652 | 653 | Github.Gist = function(options) { 654 | var id = options.id; 655 | var gistPath = "/gists/"+id; 656 | 657 | // Read the gist 658 | // -------- 659 | 660 | this.read = function(cb) { 661 | _request("GET", gistPath, null, function(err, gist) { 662 | cb(err, gist); 663 | }); 664 | }; 665 | 666 | // Create the gist 667 | // -------- 668 | // { 669 | // "description": "the description for this gist", 670 | // "public": true, 671 | // "files": { 672 | // "file1.txt": { 673 | // "content": "String file contents" 674 | // } 675 | // } 676 | // } 677 | 678 | this.create = function(options, cb){ 679 | _request("POST","/gists", options, cb); 680 | }; 681 | 682 | // Delete the gist 683 | // -------- 684 | 685 | this.delete = function(cb) { 686 | _request("DELETE", gistPath, null, function(err,res) { 687 | cb(err,res); 688 | }); 689 | }; 690 | 691 | // Fork a gist 692 | // -------- 693 | 694 | this.fork = function(cb) { 695 | _request("POST", gistPath+"/fork", null, function(err,res) { 696 | cb(err,res); 697 | }); 698 | }; 699 | 700 | // Update a gist with the new stuff 701 | // -------- 702 | 703 | this.update = function(options, cb) { 704 | _request("PATCH", gistPath, options, function(err,res) { 705 | cb(err,res); 706 | }); 707 | }; 708 | 709 | // Star a gist 710 | // -------- 711 | 712 | this.star = function(cb) { 713 | _request("PUT", gistPath+"/star", null, function(err,res) { 714 | cb(err,res); 715 | }); 716 | }; 717 | 718 | // Untar a gist 719 | // -------- 720 | 721 | this.unstar = function(cb) { 722 | _request("DELETE", gistPath+"/star", null, function(err,res) { 723 | cb(err,res); 724 | }); 725 | }; 726 | 727 | // Check if a gist is starred 728 | // -------- 729 | 730 | this.isStarred = function(cb) { 731 | _request("GET", gistPath+"/star", null, function(err,res) { 732 | cb(err,res); 733 | }); 734 | }; 735 | }; 736 | 737 | // Issues API 738 | // ========== 739 | 740 | Github.Issue = function(options) { 741 | this.list = function(options, cb) { 742 | var path; 743 | if (options.repo) { 744 | path = "/repos/" + options.user + "/" + options.repo + "/issues"; 745 | } else if (options.org) { 746 | path = "/orgs/" + options.org + "/issues"; 747 | } else if (options.all) { 748 | path = "/issues"; 749 | } else { 750 | path = "/user/issues"; 751 | } 752 | 753 | _request("GET", path, null, function(err, res) { 754 | cb(err,res) 755 | }); 756 | }; 757 | 758 | this.get = function(url, cb) { 759 | _request("GET", url, null, function(err, res) { 760 | cb(err,res) 761 | }); 762 | }; 763 | }; 764 | 765 | // Top Level API 766 | // ------- 767 | 768 | this.getIssues = function(user, repo) { 769 | return new Github.Issue({user: user, repo: repo}); 770 | }; 771 | 772 | this.getRepo = function(user, repo) { 773 | return new Github.Repository({user: user, name: repo}); 774 | }; 775 | 776 | this.getUser = function() { 777 | return new Github.User(); 778 | }; 779 | 780 | this.getGist = function(id) { 781 | return new Github.Gist({id: id}); 782 | }; 783 | }; 784 | 785 | 786 | if (typeof exports !== 'undefined') { 787 | // Github = exports; 788 | module.exports = Github; 789 | } else { 790 | window.Github = Github; 791 | } 792 | }).call(this); 793 | -------------------------------------------------------------------------------- /metadata/staticresources/github.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Public 4 | text/javascript 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_app_js.resource: -------------------------------------------------------------------------------- 1 | // Ionic Issues App 2 | 3 | // angular.module is a global place for creating, registering and retrieving Angular modules 4 | // 'issues' is the name of this angular module (also set in a attribute in github_app_html) 5 | // the 2nd parameter is an array of 'requires' 6 | // 'issues.services' is found in github_services_js 7 | // 'issues.controllers' is found in github_controllers_js 8 | // 'angularMoment' is found in github_app_resources 9 | angular.module('issues', ['ionic', 'issues.controllers', 'issues.services', 'angularMoment']) 10 | 11 | .run(function($ionicPlatform) { 12 | $ionicPlatform.ready(function() { 13 | if(window.StatusBar) { 14 | // org.apache.cordova.statusbar required 15 | StatusBar.styleDefault(); 16 | } 17 | }); 18 | }) 19 | 20 | .config(function($stateProvider, $urlRouterProvider) { 21 | 22 | // Ionic uses AngularUI Router which uses the concept of states 23 | // Learn more here: https://github.com/angular-ui/ui-router 24 | // Set up the various states which the app can be in. 25 | // Each state's controller can be found in github_controllers_js 26 | $stateProvider 27 | 28 | // Date.now() in path to bust browser cache 29 | .state('issues', { 30 | url: '/issues', 31 | views: { 32 | 'issues': { 33 | templateUrl: '/resource/'+Date.now()+'/github_issues_html', 34 | controller: 'IssuesCtrl' 35 | } 36 | }, 37 | onEnter: function(){ 38 | console.log("enter issues"); 39 | } 40 | }) 41 | .state('issue-detail', { 42 | url: '/issue?issueId&viewIssue', 43 | views: { 44 | 'issues': { 45 | templateUrl: '/resource/'+Date.now()+'/github_issue_detail_html', 46 | controller: 'IssueDetailCtrl' 47 | } 48 | }, 49 | onEnter: function(){ 50 | console.log("enter issue-detail"); 51 | } 52 | }) 53 | .state('link', { 54 | url: '/link', 55 | views: { 56 | 'issues': { 57 | templateUrl: '/resource/'+Date.now()+'/github_link_html', 58 | controller: 'LinkCtrl' 59 | } 60 | }, 61 | onEnter: function(){ 62 | console.log("enter link"); 63 | } 64 | }) 65 | 66 | // if we have a case, we want to link it to an issue 67 | // otherwise show the list of issues 68 | $urlRouterProvider.otherwise((typeof github_caseNumber !== 'undefined') ? '/link' : '/issues'); 69 | 70 | }) 71 | 72 | .filter('encodeURIComponent', function() { 73 | return window.encodeURIComponent; 74 | }) 75 | 76 | // So we can follow javascript links in S1 77 | .config(['$compileProvider', function($compileProvider) { 78 | $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|javascript):/); 79 | }]);; 80 | 81 | -------------------------------------------------------------------------------- /metadata/staticresources/github_app_js.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/javascript 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_app_resources.resource: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metadaddy/IssuesInGitHub/73efbb780383b2a411f3b9be302ca4c3e4bbff41/metadata/staticresources/github_app_resources.resource -------------------------------------------------------------------------------- /metadata/staticresources/github_app_resources.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Public 4 | application/zip 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_callback_js.resource: -------------------------------------------------------------------------------- 1 | // Ionic Callback App 2 | 3 | // angular.module is a global place for creating, registering and retrieving Angular modules 4 | // 'callback' is the name of this angular module (also set in a attribute in index.html) 5 | // the 2nd parameter is an array of 'requires' 6 | angular.module('callback', ['ionic']) 7 | 8 | .run(function($ionicPlatform) { 9 | $ionicPlatform.ready(function() { 10 | if(window.StatusBar) { 11 | StatusBar.styleDefault(); 12 | } 13 | }); 14 | }) 15 | 16 | .config(function($stateProvider, $urlRouterProvider) { 17 | 18 | // Ionic uses AngularUI Router which uses the concept of states 19 | // Learn more here: https://github.com/angular-ui/ui-router 20 | // Set up the various states which the app can be in. 21 | $stateProvider 22 | 23 | .state('callback', { 24 | url: '/callback', 25 | views: { 26 | 'callback': { 27 | templateUrl: 'callback.html' 28 | } 29 | }, 30 | onEnter: function(){ 31 | console.log("enter callback"); 32 | } 33 | }) 34 | 35 | // Default route 36 | $urlRouterProvider.otherwise('/callback'); 37 | 38 | }) 39 | 40 | .filter('encodeURIComponent', function() { 41 | return window.encodeURIComponent; 42 | }) 43 | 44 | // So we can follow javascript links in S1 45 | .config(['$compileProvider', function($compileProvider) { 46 | $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|javascript):/); 47 | }]); 48 | 49 | -------------------------------------------------------------------------------- /metadata/staticresources/github_callback_js.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/javascript 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_controllers_js.resource: -------------------------------------------------------------------------------- 1 | angular.module('issues.controllers', []) 2 | 3 | .controller('IssuesCtrl', function($scope, Issues) { 4 | console.log('IssuesCtrl'); 5 | Issues.all().then(function(issues) { 6 | $scope.issues = issues; 7 | }); 8 | }) 9 | 10 | .controller('IssueDetailCtrl', function($scope, $stateParams, $ionicPopup, 11 | Issues, CaseIssueMapping) { 12 | console.log('IssueDetailCtrl'); 13 | $scope.linkToCases = ($stateParams.viewIssue === null); 14 | Issues.get($stateParams.issueId).then(function(issue){ 15 | $scope.issue = issue; 16 | if (issue.comments > 0) { 17 | Issues.comments($stateParams.issueId).then(function(comments) { 18 | $scope.comments = comments; 19 | }); 20 | } 21 | }); 22 | $scope.createCase = function() { 23 | $ionicPopup.confirm({ 24 | title: 'Create Case', 25 | content: 'Create Case from Issue \''+$scope.issue.title+'\'?' 26 | }).then(function(res) { 27 | if(res) { 28 | GithubController.createCase($scope.issue.title, $scope.issue.body, 29 | $stateParams.issueId, function(result, event){ 30 | CaseIssueMapping.clear(); 31 | CaseIssueMapping.get($stateParams.issueId).then(function(cases){ 32 | console.log('cases', cases); 33 | $scope.issue.cases = cases; 34 | }); 35 | if (event.status) { 36 | // Go to case 37 | navigateToSObject(result); 38 | } 39 | }); 40 | } 41 | }); 42 | }; 43 | }) 44 | 45 | .controller('LinkCtrl', function($scope, $rootScope, Issues, CaseIssueMapping, $q) { 46 | console.log('LinkCtrl'); 47 | $scope.caseNumber = $rootScope.caseNumber; 48 | 49 | $scope.toggleLink = function(issueUrl) { 50 | console.log('toggleLink', issueUrl); 51 | $rootScope.linkedIssue = null; 52 | for (var i = 0; i < $scope.issues.length; i++) { 53 | $scope.issues[i].linked = (!$scope.issues[i].linked) && ($scope.issues[i].url === issueUrl); 54 | if ($scope.issues[i].linked) { 55 | $rootScope.linkedIssue = issueUrl; 56 | } 57 | } 58 | } 59 | 60 | // Get issues and mapping in parallel 61 | $q.all({ 62 | issues: Issues.all(), 63 | mapping: CaseIssueMapping.all() 64 | }).then(function(results){ 65 | $scope.issues = results.issues; 66 | for (var i = 0; i < $scope.issues.length; i++) { 67 | var cases = results.mapping[$scope.issues[i].url]; 68 | if (cases) { 69 | for (var j = 0; j < cases.length; j++) { 70 | if (cases[j].Id === $rootScope.caseId) { 71 | $scope.issues[i].linked = true; 72 | break; 73 | } 74 | } 75 | } 76 | } 77 | }); 78 | }) 79 | -------------------------------------------------------------------------------- /metadata/staticresources/github_controllers_js.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/javascript 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_issue_detail_html.resource: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 |

{{issue.title}}

12 | Opened by {{issue.user.login}} 13 |
14 | 15 |

Status

16 | {{issue.state}} 17 |
18 | 19 | {{issue.body}} 20 | 21 |

Linked to cases

22 | 23 | {{case.CaseNumber}} 24 | 25 | 26 | {{case.CaseNumber}} 27 | 28 |

Comments

29 | 30 | commented 31 |
32 | {{comment.body}} 33 |
34 |
35 |
36 | 39 |
40 |
41 | -------------------------------------------------------------------------------- /metadata/staticresources/github_issue_detail_html.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/plain 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_issues_html.resource: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

{{issue.title}}

6 | 17 |
18 |
19 |
20 |
-------------------------------------------------------------------------------- /metadata/staticresources/github_issues_html.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/plain 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_link_html.resource: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Tap an Issue to Link or Unlink to Case {{caseNumber}}

4 | 5 | 6 |

{{issue.title}}

7 | 14 |
15 |
16 |
17 |
-------------------------------------------------------------------------------- /metadata/staticresources/github_link_html.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/plain 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_link_png.resource: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metadaddy/IssuesInGitHub/73efbb780383b2a411f3b9be302ca4c3e4bbff41/metadata/staticresources/github_link_png.resource -------------------------------------------------------------------------------- /metadata/staticresources/github_link_png.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Public 4 | image/png 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_login_js.resource: -------------------------------------------------------------------------------- 1 | // Ionic Login App 2 | 3 | // angular.module is a global place for creating, registering and retrieving Angular modules 4 | // 'login' is the name of this angular module (also set in a attribute in index.html) 5 | // the 2nd parameter is an array of 'requires' 6 | angular.module('login', ['ionic']) 7 | 8 | .run(function($ionicPlatform) { 9 | $ionicPlatform.ready(function() { 10 | if(window.StatusBar) { 11 | // org.apache.cordova.statusbar required 12 | StatusBar.styleDefault(); 13 | } 14 | }); 15 | }) 16 | 17 | .config(function($stateProvider, $urlRouterProvider) { 18 | 19 | // Ionic uses AngularUI Router which uses the concept of states 20 | // Learn more here: https://github.com/angular-ui/ui-router 21 | // Set up the various states which the app can be in. 22 | $stateProvider 23 | 24 | .state('login', { 25 | url: '/login', 26 | views: { 27 | 'login': { 28 | templateUrl: 'login.html', 29 | } 30 | }, 31 | onEnter: function(){ 32 | console.log("enter login"); 33 | } 34 | }) 35 | 36 | // Default route 37 | $urlRouterProvider.otherwise('/login'); 38 | 39 | }) 40 | 41 | .filter('encodeURIComponent', function() { 42 | return window.encodeURIComponent; 43 | }) 44 | 45 | // So we can follow javascript links in S1 46 | .config(['$compileProvider', function($compileProvider) { 47 | $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|javascript):/); 48 | }]); 49 | 50 | -------------------------------------------------------------------------------- /metadata/staticresources/github_login_js.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/javascript 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_s1_js.resource: -------------------------------------------------------------------------------- 1 | // Utility functions to navigate to URLs and SObjects 2 | 3 | function navigateToURL(url) { 4 | if( (typeof sforce != 'undefined') && (sforce != null) ) { 5 | console.log('navigateToURL',url); 6 | sforce.one.navigateToURL(url); 7 | } else { 8 | console.log('document.location',url); 9 | document.location = url; 10 | } 11 | } 12 | 13 | function navigateToSObject(id) { 14 | if( (typeof sforce != 'undefined') && (sforce != null) ) { 15 | console.log('navigateToSObject',id); 16 | sforce.one.navigateToSObject(id); 17 | } else { 18 | console.log('document.location',id); 19 | document.location = '/'+id; 20 | } 21 | } -------------------------------------------------------------------------------- /metadata/staticresources/github_s1_js.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/javascript 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_services_js.resource: -------------------------------------------------------------------------------- 1 | angular.module('issues.services', []) 2 | 3 | .factory('Issues', function($q, $rootScope, CaseIssueMapping) { 4 | console.log('issues.services factory'); 5 | 6 | // Github API accessors 7 | var github = getGithubAPI(); 8 | var user = github.getUser(); 9 | var issues = github.getIssues(); 10 | 11 | // Cached array of issues from GitHub 12 | var issueList; 13 | // Map from URL to issue 14 | var issueMap; 15 | 16 | return { 17 | all: function() { 18 | var deferred = $q.defer(); 19 | 20 | if (issueList) { 21 | // Use cached array 22 | deferred.resolve(issueList); 23 | } 24 | 25 | // Get issue list from GitHub 26 | issues.list({ 27 | all: true 28 | }, function(err, resp){ 29 | if (err) { 30 | // Token is probably revoked/expired - trigger login 31 | deferred.reject(err); 32 | GithubController.deleteAccessToken(function(result, event){ 33 | if (event.status) { 34 | navigateToURL('/apex/github_login_html?state='+encodeURIComponent('/apex/github_app_html')); 35 | } 36 | }); 37 | } else { 38 | // We have the issues! 39 | issueList = resp; 40 | CaseIssueMapping.all().then(function(issueCasesMap){ 41 | // Build a map from issue URL to issue object 42 | // and set cases property 43 | issueMap = {} 44 | for (var i = 0; i < issueList.length; i++) { 45 | issueMap[issueList[i].url] = issueList[i]; 46 | issueList[i].cases = issueCasesMap[issueList[i].url] || []; 47 | } 48 | console.log('issues', issueList); 49 | deferred.resolve(issueList); 50 | }); 51 | } 52 | }); 53 | 54 | return deferred.promise; 55 | }, 56 | get: function(issueUrl) { 57 | var deferred = $q.defer(); 58 | if (issueMap) { 59 | deferred.resolve(issueMap[issueUrl]); 60 | } else { 61 | return this.all().then(function(){ 62 | return issueMap[issueUrl]; 63 | }); 64 | } 65 | 66 | return deferred.promise; 67 | }, 68 | comments: function(issueUrl) { 69 | var deferred = $q.defer(); 70 | this.get(issueUrl).then(function(issue){ 71 | // Get comments from GitHub 72 | issues.get(issue.comments_url, function(err, comments){ 73 | if (err) { 74 | deferred.fail(err); 75 | } else { 76 | console.log('comments', comments); 77 | deferred.resolve(comments); 78 | } 79 | }); 80 | }); 81 | return deferred.promise; 82 | } 83 | } 84 | }) 85 | 86 | .factory('CaseIssueMapping', function($q) { 87 | var mapping; 88 | 89 | return { 90 | all: function() { 91 | var deferred = $q.defer(); 92 | 93 | if (mapping) { 94 | deferred.resolve(mapping); 95 | } 96 | 97 | // Get mapping of issues to cases from Apex controller 98 | GithubController.getJsonIssueCaseMapping(function(result, event){ 99 | if (event.status) { 100 | mapping = JSON.parse(result); 101 | deferred.resolve(mapping); 102 | } else { 103 | deferred.reject(event); 104 | } 105 | }, { 106 | escape: false 107 | }); 108 | 109 | return deferred.promise; 110 | }, 111 | get: function(issueUrl) { 112 | var deferred = $q.defer(); 113 | 114 | if (mapping) { 115 | deferred.resolve(mapping[issueUrl]); 116 | } else { 117 | return this.all().then(function(){ 118 | return mapping[issueUrl]; 119 | }); 120 | } 121 | 122 | return deferred.promise; 123 | }, 124 | clear: function() { 125 | mapping = null; 126 | } 127 | } 128 | }); 129 | -------------------------------------------------------------------------------- /metadata/staticresources/github_services_js.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/javascript 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/github_style_css.resource: -------------------------------------------------------------------------------- 1 | .issue-login { 2 | font-weight: bold; 3 | } 4 | 5 | .issue-metadata { 6 | font-size: small; 7 | } 8 | 9 | .issue-comment { 10 | padding-top: 10px; 11 | } 12 | 13 | .issue-state-closed { 14 | font-weight: bold; 15 | color: red; 16 | } 17 | 18 | .issue-state-open { 19 | font-weight: bold; 20 | color: green; 21 | } 22 | 23 | .no-arrow[ng-click] .item-content:after { 24 | content: ""; 25 | } 26 | 27 | .linked-true[ng-click] .item-content:after { 28 | font-size: 24px; 29 | content: "\f1fe"; 30 | color: #4a87ee; 31 | } 32 | 33 | .content { 34 | margin: 75px auto; 35 | text-align: center; 36 | font-size: x-large; 37 | line-height: 150%; 38 | } 39 | 40 | .content a, .content a:link, .content a:visited, .content a:hover, .content a:active { 41 | text-decoration: none !important; 42 | color: black; 43 | } 44 | 45 | [ng\:cloak], [ng-cloak], .ng-cloak { 46 | display: none; 47 | } -------------------------------------------------------------------------------- /metadata/staticresources/github_style_css.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/css 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/octocat_jpg.resource: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metadaddy/IssuesInGitHub/73efbb780383b2a411f3b9be302ca4c3e4bbff41/metadata/staticresources/octocat_jpg.resource -------------------------------------------------------------------------------- /metadata/staticresources/octocat_jpg.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Public 4 | image/jpeg 5 | 6 | -------------------------------------------------------------------------------- /metadata/staticresources/underscore.resource: -------------------------------------------------------------------------------- 1 | // Underscore.js 1.6.0 2 | // http://underscorejs.org 3 | // (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 4 | // Underscore may be freely distributed under the MIT license. 5 | (function(){var n=this,t=n._,r={},e=Array.prototype,u=Object.prototype,i=Function.prototype,a=e.push,o=e.slice,c=e.concat,l=u.toString,f=u.hasOwnProperty,s=e.forEach,p=e.map,h=e.reduce,v=e.reduceRight,g=e.filter,d=e.every,m=e.some,y=e.indexOf,b=e.lastIndexOf,x=Array.isArray,w=Object.keys,_=i.bind,j=function(n){return n instanceof j?n:this instanceof j?void(this._wrapped=n):new j(n)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=j),exports._=j):n._=j,j.VERSION="1.6.0";var A=j.each=j.forEach=function(n,t,e){if(null==n)return n;if(s&&n.forEach===s)n.forEach(t,e);else if(n.length===+n.length){for(var u=0,i=n.length;i>u;u++)if(t.call(e,n[u],u,n)===r)return}else for(var a=j.keys(n),u=0,i=a.length;i>u;u++)if(t.call(e,n[a[u]],a[u],n)===r)return;return n};j.map=j.collect=function(n,t,r){var e=[];return null==n?e:p&&n.map===p?n.map(t,r):(A(n,function(n,u,i){e.push(t.call(r,n,u,i))}),e)};var O="Reduce of empty array with no initial value";j.reduce=j.foldl=j.inject=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),h&&n.reduce===h)return e&&(t=j.bind(t,e)),u?n.reduce(t,r):n.reduce(t);if(A(n,function(n,i,a){u?r=t.call(e,r,n,i,a):(r=n,u=!0)}),!u)throw new TypeError(O);return r},j.reduceRight=j.foldr=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),v&&n.reduceRight===v)return e&&(t=j.bind(t,e)),u?n.reduceRight(t,r):n.reduceRight(t);var i=n.length;if(i!==+i){var a=j.keys(n);i=a.length}if(A(n,function(o,c,l){c=a?a[--i]:--i,u?r=t.call(e,r,n[c],c,l):(r=n[c],u=!0)}),!u)throw new TypeError(O);return r},j.find=j.detect=function(n,t,r){var e;return k(n,function(n,u,i){return t.call(r,n,u,i)?(e=n,!0):void 0}),e},j.filter=j.select=function(n,t,r){var e=[];return null==n?e:g&&n.filter===g?n.filter(t,r):(A(n,function(n,u,i){t.call(r,n,u,i)&&e.push(n)}),e)},j.reject=function(n,t,r){return j.filter(n,function(n,e,u){return!t.call(r,n,e,u)},r)},j.every=j.all=function(n,t,e){t||(t=j.identity);var u=!0;return null==n?u:d&&n.every===d?n.every(t,e):(A(n,function(n,i,a){return(u=u&&t.call(e,n,i,a))?void 0:r}),!!u)};var k=j.some=j.any=function(n,t,e){t||(t=j.identity);var u=!1;return null==n?u:m&&n.some===m?n.some(t,e):(A(n,function(n,i,a){return u||(u=t.call(e,n,i,a))?r:void 0}),!!u)};j.contains=j.include=function(n,t){return null==n?!1:y&&n.indexOf===y?n.indexOf(t)!=-1:k(n,function(n){return n===t})},j.invoke=function(n,t){var r=o.call(arguments,2),e=j.isFunction(t);return j.map(n,function(n){return(e?t:n[t]).apply(n,r)})},j.pluck=function(n,t){return j.map(n,j.property(t))},j.where=function(n,t){return j.filter(n,j.matches(t))},j.findWhere=function(n,t){return j.find(n,j.matches(t))},j.max=function(n,t,r){if(!t&&j.isArray(n)&&n[0]===+n[0]&&n.length<65535)return Math.max.apply(Math,n);var e=-1/0,u=-1/0;return A(n,function(n,i,a){var o=t?t.call(r,n,i,a):n;o>u&&(e=n,u=o)}),e},j.min=function(n,t,r){if(!t&&j.isArray(n)&&n[0]===+n[0]&&n.length<65535)return Math.min.apply(Math,n);var e=1/0,u=1/0;return A(n,function(n,i,a){var o=t?t.call(r,n,i,a):n;u>o&&(e=n,u=o)}),e},j.shuffle=function(n){var t,r=0,e=[];return A(n,function(n){t=j.random(r++),e[r-1]=e[t],e[t]=n}),e},j.sample=function(n,t,r){return null==t||r?(n.length!==+n.length&&(n=j.values(n)),n[j.random(n.length-1)]):j.shuffle(n).slice(0,Math.max(0,t))};var E=function(n){return null==n?j.identity:j.isFunction(n)?n:j.property(n)};j.sortBy=function(n,t,r){return t=E(t),j.pluck(j.map(n,function(n,e,u){return{value:n,index:e,criteria:t.call(r,n,e,u)}}).sort(function(n,t){var r=n.criteria,e=t.criteria;if(r!==e){if(r>e||r===void 0)return 1;if(e>r||e===void 0)return-1}return n.index-t.index}),"value")};var F=function(n){return function(t,r,e){var u={};return r=E(r),A(t,function(i,a){var o=r.call(e,i,a,t);n(u,o,i)}),u}};j.groupBy=F(function(n,t,r){j.has(n,t)?n[t].push(r):n[t]=[r]}),j.indexBy=F(function(n,t,r){n[t]=r}),j.countBy=F(function(n,t){j.has(n,t)?n[t]++:n[t]=1}),j.sortedIndex=function(n,t,r,e){r=E(r);for(var u=r.call(e,t),i=0,a=n.length;a>i;){var o=i+a>>>1;r.call(e,n[o])t?[]:o.call(n,0,t)},j.initial=function(n,t,r){return o.call(n,0,n.length-(null==t||r?1:t))},j.last=function(n,t,r){return null==n?void 0:null==t||r?n[n.length-1]:o.call(n,Math.max(n.length-t,0))},j.rest=j.tail=j.drop=function(n,t,r){return o.call(n,null==t||r?1:t)},j.compact=function(n){return j.filter(n,j.identity)};var M=function(n,t,r){return t&&j.every(n,j.isArray)?c.apply(r,n):(A(n,function(n){j.isArray(n)||j.isArguments(n)?t?a.apply(r,n):M(n,t,r):r.push(n)}),r)};j.flatten=function(n,t){return M(n,t,[])},j.without=function(n){return j.difference(n,o.call(arguments,1))},j.partition=function(n,t){var r=[],e=[];return A(n,function(n){(t(n)?r:e).push(n)}),[r,e]},j.uniq=j.unique=function(n,t,r,e){j.isFunction(t)&&(e=r,r=t,t=!1);var u=r?j.map(n,r,e):n,i=[],a=[];return A(u,function(r,e){(t?e&&a[a.length-1]===r:j.contains(a,r))||(a.push(r),i.push(n[e]))}),i},j.union=function(){return j.uniq(j.flatten(arguments,!0))},j.intersection=function(n){var t=o.call(arguments,1);return j.filter(j.uniq(n),function(n){return j.every(t,function(t){return j.contains(t,n)})})},j.difference=function(n){var t=c.apply(e,o.call(arguments,1));return j.filter(n,function(n){return!j.contains(t,n)})},j.zip=function(){for(var n=j.max(j.pluck(arguments,"length").concat(0)),t=new Array(n),r=0;n>r;r++)t[r]=j.pluck(arguments,""+r);return t},j.object=function(n,t){if(null==n)return{};for(var r={},e=0,u=n.length;u>e;e++)t?r[n[e]]=t[e]:r[n[e][0]]=n[e][1];return r},j.indexOf=function(n,t,r){if(null==n)return-1;var e=0,u=n.length;if(r){if("number"!=typeof r)return e=j.sortedIndex(n,t),n[e]===t?e:-1;e=0>r?Math.max(0,u+r):r}if(y&&n.indexOf===y)return n.indexOf(t,r);for(;u>e;e++)if(n[e]===t)return e;return-1},j.lastIndexOf=function(n,t,r){if(null==n)return-1;var e=null!=r;if(b&&n.lastIndexOf===b)return e?n.lastIndexOf(t,r):n.lastIndexOf(t);for(var u=e?r:n.length;u--;)if(n[u]===t)return u;return-1},j.range=function(n,t,r){arguments.length<=1&&(t=n||0,n=0),r=arguments[2]||1;for(var e=Math.max(Math.ceil((t-n)/r),0),u=0,i=new Array(e);e>u;)i[u++]=n,n+=r;return i};var R=function(){};j.bind=function(n,t){var r,e;if(_&&n.bind===_)return _.apply(n,o.call(arguments,1));if(!j.isFunction(n))throw new TypeError;return r=o.call(arguments,2),e=function(){if(!(this instanceof e))return n.apply(t,r.concat(o.call(arguments)));R.prototype=n.prototype;var u=new R;R.prototype=null;var i=n.apply(u,r.concat(o.call(arguments)));return Object(i)===i?i:u}},j.partial=function(n){var t=o.call(arguments,1);return function(){for(var r=0,e=t.slice(),u=0,i=e.length;i>u;u++)e[u]===j&&(e[u]=arguments[r++]);for(;r=f?(clearTimeout(a),a=null,o=l,i=n.apply(e,u),e=u=null):a||r.trailing===!1||(a=setTimeout(c,f)),i}},j.debounce=function(n,t,r){var e,u,i,a,o,c=function(){var l=j.now()-a;t>l?e=setTimeout(c,t-l):(e=null,r||(o=n.apply(i,u),i=u=null))};return function(){i=this,u=arguments,a=j.now();var l=r&&!e;return e||(e=setTimeout(c,t)),l&&(o=n.apply(i,u),i=u=null),o}},j.once=function(n){var t,r=!1;return function(){return r?t:(r=!0,t=n.apply(this,arguments),n=null,t)}},j.wrap=function(n,t){return j.partial(t,n)},j.compose=function(){var n=arguments;return function(){for(var t=arguments,r=n.length-1;r>=0;r--)t=[n[r].apply(this,t)];return t[0]}},j.after=function(n,t){return function(){return--n<1?t.apply(this,arguments):void 0}},j.keys=function(n){if(!j.isObject(n))return[];if(w)return w(n);var t=[];for(var r in n)j.has(n,r)&&t.push(r);return t},j.values=function(n){for(var t=j.keys(n),r=t.length,e=new Array(r),u=0;r>u;u++)e[u]=n[t[u]];return e},j.pairs=function(n){for(var t=j.keys(n),r=t.length,e=new Array(r),u=0;r>u;u++)e[u]=[t[u],n[t[u]]];return e},j.invert=function(n){for(var t={},r=j.keys(n),e=0,u=r.length;u>e;e++)t[n[r[e]]]=r[e];return t},j.functions=j.methods=function(n){var t=[];for(var r in n)j.isFunction(n[r])&&t.push(r);return t.sort()},j.extend=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]=t[r]}),n},j.pick=function(n){var t={},r=c.apply(e,o.call(arguments,1));return A(r,function(r){r in n&&(t[r]=n[r])}),t},j.omit=function(n){var t={},r=c.apply(e,o.call(arguments,1));for(var u in n)j.contains(r,u)||(t[u]=n[u]);return t},j.defaults=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]===void 0&&(n[r]=t[r])}),n},j.clone=function(n){return j.isObject(n)?j.isArray(n)?n.slice():j.extend({},n):n},j.tap=function(n,t){return t(n),n};var S=function(n,t,r,e){if(n===t)return 0!==n||1/n==1/t;if(null==n||null==t)return n===t;n instanceof j&&(n=n._wrapped),t instanceof j&&(t=t._wrapped);var u=l.call(n);if(u!=l.call(t))return!1;switch(u){case"[object String]":return n==String(t);case"[object Number]":return n!=+n?t!=+t:0==n?1/n==1/t:n==+t;case"[object Date]":case"[object Boolean]":return+n==+t;case"[object RegExp]":return n.source==t.source&&n.global==t.global&&n.multiline==t.multiline&&n.ignoreCase==t.ignoreCase}if("object"!=typeof n||"object"!=typeof t)return!1;for(var i=r.length;i--;)if(r[i]==n)return e[i]==t;var a=n.constructor,o=t.constructor;if(a!==o&&!(j.isFunction(a)&&a instanceof a&&j.isFunction(o)&&o instanceof o)&&"constructor"in n&&"constructor"in t)return!1;r.push(n),e.push(t);var c=0,f=!0;if("[object Array]"==u){if(c=n.length,f=c==t.length)for(;c--&&(f=S(n[c],t[c],r,e)););}else{for(var s in n)if(j.has(n,s)&&(c++,!(f=j.has(t,s)&&S(n[s],t[s],r,e))))break;if(f){for(s in t)if(j.has(t,s)&&!c--)break;f=!c}}return r.pop(),e.pop(),f};j.isEqual=function(n,t){return S(n,t,[],[])},j.isEmpty=function(n){if(null==n)return!0;if(j.isArray(n)||j.isString(n))return 0===n.length;for(var t in n)if(j.has(n,t))return!1;return!0},j.isElement=function(n){return!(!n||1!==n.nodeType)},j.isArray=x||function(n){return"[object Array]"==l.call(n)},j.isObject=function(n){return n===Object(n)},A(["Arguments","Function","String","Number","Date","RegExp"],function(n){j["is"+n]=function(t){return l.call(t)=="[object "+n+"]"}}),j.isArguments(arguments)||(j.isArguments=function(n){return!(!n||!j.has(n,"callee"))}),"function"!=typeof/./&&(j.isFunction=function(n){return"function"==typeof n}),j.isFinite=function(n){return isFinite(n)&&!isNaN(parseFloat(n))},j.isNaN=function(n){return j.isNumber(n)&&n!=+n},j.isBoolean=function(n){return n===!0||n===!1||"[object Boolean]"==l.call(n)},j.isNull=function(n){return null===n},j.isUndefined=function(n){return n===void 0},j.has=function(n,t){return f.call(n,t)},j.noConflict=function(){return n._=t,this},j.identity=function(n){return n},j.constant=function(n){return function(){return n}},j.property=function(n){return function(t){return t[n]}},j.matches=function(n){return function(t){if(t===n)return!0;for(var r in n)if(n[r]!==t[r])return!1;return!0}},j.times=function(n,t,r){for(var e=Array(Math.max(0,n)),u=0;n>u;u++)e[u]=t.call(r,u);return e},j.random=function(n,t){return null==t&&(t=n,n=0),n+Math.floor(Math.random()*(t-n+1))},j.now=Date.now||function(){return(new Date).getTime()};var T={escape:{"&":"&","<":"<",">":">",'"':""","'":"'"}};T.unescape=j.invert(T.escape);var I={escape:new RegExp("["+j.keys(T.escape).join("")+"]","g"),unescape:new RegExp("("+j.keys(T.unescape).join("|")+")","g")};j.each(["escape","unescape"],function(n){j[n]=function(t){return null==t?"":(""+t).replace(I[n],function(t){return T[n][t]})}}),j.result=function(n,t){if(null==n)return void 0;var r=n[t];return j.isFunction(r)?r.call(n):r},j.mixin=function(n){A(j.functions(n),function(t){var r=j[t]=n[t];j.prototype[t]=function(){var n=[this._wrapped];return a.apply(n,arguments),z.call(this,r.apply(j,n))}})};var N=0;j.uniqueId=function(n){var t=++N+"";return n?n+t:t},j.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var q=/(.)^/,B={"'":"'","\\":"\\","\r":"r","\n":"n"," ":"t","\u2028":"u2028","\u2029":"u2029"},D=/\\|'|\r|\n|\t|\u2028|\u2029/g;j.template=function(n,t,r){var e;r=j.defaults({},r,j.templateSettings);var u=new RegExp([(r.escape||q).source,(r.interpolate||q).source,(r.evaluate||q).source].join("|")+"|$","g"),i=0,a="__p+='";n.replace(u,function(t,r,e,u,o){return a+=n.slice(i,o).replace(D,function(n){return"\\"+B[n]}),r&&(a+="'+\n((__t=("+r+"))==null?'':_.escape(__t))+\n'"),e&&(a+="'+\n((__t=("+e+"))==null?'':__t)+\n'"),u&&(a+="';\n"+u+"\n__p+='"),i=o+t.length,t}),a+="';\n",r.variable||(a="with(obj||{}){\n"+a+"}\n"),a="var __t,__p='',__j=Array.prototype.join,"+"print=function(){__p+=__j.call(arguments,'');};\n"+a+"return __p;\n";try{e=new Function(r.variable||"obj","_",a)}catch(o){throw o.source=a,o}if(t)return e(t,j);var c=function(n){return e.call(this,n,j)};return c.source="function("+(r.variable||"obj")+"){\n"+a+"}",c},j.chain=function(n){return j(n).chain()};var z=function(n){return this._chain?j(n).chain():n};j.mixin(j),A(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=e[n];j.prototype[n]=function(){var r=this._wrapped;return t.apply(r,arguments),"shift"!=n&&"splice"!=n||0!==r.length||delete r[0],z.call(this,r)}}),A(["concat","join","slice"],function(n){var t=e[n];j.prototype[n]=function(){return z.call(this,t.apply(this._wrapped,arguments))}}),j.extend(j.prototype,{chain:function(){return this._chain=!0,this},value:function(){return this._wrapped}}),"function"==typeof define&&define.amd&&define("underscore",[],function(){return j})}).call(this); 6 | //# sourceMappingURL=underscore-min.map -------------------------------------------------------------------------------- /metadata/staticresources/underscore.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Public 4 | text/javascript 5 | 6 | -------------------------------------------------------------------------------- /metadata/tabs/Issues_in_GitHub.tab: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | Custom44: Hammer 6 | github_app_html 7 | 8 | --------------------------------------------------------------------------------