Skip to content
This repository was archived by the owner on Jun 21, 2023. It is now read-only.

Commit 70d9d90

Browse files
committed
Find GitHub context from Chrome window title
Add code to parse window titles and find the GitHub context from the topmost Chrome browser.
1 parent 0aade59 commit 70d9d90

File tree

6 files changed

+326
-0
lines changed

6 files changed

+326
-0
lines changed

src/GitHub.App/GitHub.App.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@
196196
<HintPath>..\..\packages\Rx-XAML.2.2.5-custom\lib\net45\System.Reactive.Windows.Threading.dll</HintPath>
197197
<Private>True</Private>
198198
</Reference>
199+
<Reference Include="System.ValueTuple, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
200+
<HintPath>..\..\packages\System.ValueTuple.4.5.0\lib\net461\System.ValueTuple.dll</HintPath>
201+
</Reference>
199202
<Reference Include="System.Web" />
200203
<Reference Include="System.Xaml" />
201204
<Reference Include="System.Xml.Linq" />
@@ -221,6 +224,8 @@
221224
<Compile Include="SampleData\PullRequestReviewViewModelDesigner.cs" />
222225
<Compile Include="SampleData\PullRequestUserReviewsViewModelDesigner.cs" />
223226
<Compile Include="Services\EnterpriseCapabilitiesService.cs" />
227+
<Compile Include="Services\GitHubContext.cs" />
228+
<Compile Include="Services\GitHubContextService.cs" />
224229
<Compile Include="Services\GlobalConnection.cs" />
225230
<Compile Include="Services\RepositoryForkService.cs" />
226231
<Compile Include="ViewModels\ActorViewModel.cs" />
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace GitHub.App.Services
2+
{
3+
public class GitHubContext
4+
{
5+
public string Owner { get; set; }
6+
public string RepositoryName { get; set; }
7+
public string Host { get; set; }
8+
public string Branch { get; set; }
9+
public int? PullRequest { get; set; }
10+
public int? Issue { get; set; }
11+
}
12+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using System;
2+
using System.Text;
3+
using System.Linq;
4+
using System.Collections.Generic;
5+
using System.Text.RegularExpressions;
6+
using System.Runtime.InteropServices;
7+
using GitHub.Primitives;
8+
9+
namespace GitHub.App.Services
10+
{
11+
public class GitHubContextService
12+
{
13+
// USERID_REGEX = /[a-z0-9][a-z0-9\-\_]*/i
14+
const string owner = "(?<owner>[a-zA-Z0-9][a-zA-Z0-9-_]*)";
15+
16+
// REPO_REGEX = /(?:\w|\.|\-)+/i
17+
// This supports "_" for legacy superfans with logins that still contain "_".
18+
const string repo = @"(?<repo>(?:\w|\.|\-)+)";
19+
20+
//BRANCH_REGEX = /[^\/]+(\/[^\/]+)?/
21+
const string branch = @"(?<branch>[^./ ~^:?*\[\\][^/ ~^:?*\[\\]*(/[^./ ~^:?*\[\\][^/ ~^:?*\[\\]*)*)";
22+
23+
const string pull = "(?<pull>[0-9]+)";
24+
25+
const string issue = "(?<issue>[0-9]+)";
26+
27+
static readonly Regex windowTitleRepositoryRegex = new Regex($"^{owner}/{repo}: ", RegexOptions.Compiled);
28+
static readonly Regex windowTitleBranchRegex = new Regex($"^{owner}/{repo} at {branch} ", RegexOptions.Compiled);
29+
static readonly Regex windowTitlePullRequestRegex = new Regex($" · Pull Request #{pull} · {owner}/{repo} - ", RegexOptions.Compiled);
30+
static readonly Regex windowTitleIssueRegex = new Regex($" · Issue #{issue} · {owner}/{repo} - ", RegexOptions.Compiled);
31+
static readonly Regex windowTitlePathRegex = new Regex($" at {branch} · {owner}/{repo} - ", RegexOptions.Compiled);
32+
static readonly Regex windowTitleBranchesRegex = new Regex($"Branches · {owner}/{repo} - ", RegexOptions.Compiled);
33+
34+
public GitHubContext FindContextFromUrl(UriString url)
35+
{
36+
return new GitHubContext
37+
{
38+
Host = url.Host,
39+
Owner = url.Owner,
40+
RepositoryName = url.RepositoryName
41+
};
42+
}
43+
44+
public GitHubContext FindContextFromBrowser()
45+
{
46+
return FindWindowTitlesForClass("Chrome_WidgetWin_1").Select(FindContextFromWindowTitle).Where(x => x != null).FirstOrDefault();
47+
}
48+
49+
public IEnumerable<string> FindWindowTitlesForClass(string className = "Chrome_WidgetWin_1")
50+
{
51+
IntPtr handleWin = IntPtr.Zero;
52+
while (IntPtr.Zero != (handleWin = User32.FindWindowEx(IntPtr.Zero, handleWin, className, IntPtr.Zero)))
53+
{
54+
// Allocate correct string length first
55+
int length = User32.GetWindowTextLength(handleWin);
56+
if (length == 0)
57+
{
58+
continue;
59+
}
60+
61+
var titleBuilder = new StringBuilder(length + 1);
62+
User32.GetWindowText(handleWin, titleBuilder, titleBuilder.Capacity);
63+
yield return titleBuilder.ToString();
64+
}
65+
}
66+
67+
public GitHubContext FindContextFromWindowTitle(string windowTitle)
68+
{
69+
var (success, owner, repo, branch, pullRequest, issue) = MatchWindowTitle(windowTitle);
70+
if (!success)
71+
{
72+
return null;
73+
}
74+
75+
return new GitHubContext
76+
{
77+
Owner = owner,
78+
RepositoryName = repo,
79+
Branch = branch,
80+
PullRequest = pullRequest,
81+
Issue = issue
82+
};
83+
}
84+
85+
static (bool success, string owner, string repo, string branch, int? pullRequest, int? issue) MatchWindowTitle(string windowTitle)
86+
{
87+
var match = windowTitlePathRegex.Match(windowTitle);
88+
if (match.Success)
89+
{
90+
return (match.Success, match.Groups["owner"].Value, match.Groups["repo"].Value, match.Groups["branch"].Value, null, null);
91+
}
92+
93+
match = windowTitleRepositoryRegex.Match(windowTitle);
94+
if (match.Success)
95+
{
96+
return (match.Success, match.Groups["owner"].Value, match.Groups["repo"].Value, null, null, null);
97+
}
98+
99+
match = windowTitleBranchRegex.Match(windowTitle);
100+
if (match.Success)
101+
{
102+
return (match.Success, match.Groups["owner"].Value, match.Groups["repo"].Value, match.Groups["branch"].Value, null, null);
103+
}
104+
105+
match = windowTitleBranchesRegex.Match(windowTitle);
106+
if (match.Success)
107+
{
108+
return (match.Success, match.Groups["owner"].Value, match.Groups["repo"].Value, null, null, null);
109+
}
110+
111+
match = windowTitlePullRequestRegex.Match(windowTitle);
112+
if (match.Success)
113+
{
114+
int.TryParse(match.Groups["pull"].Value, out int pullRequest);
115+
return (match.Success, match.Groups["owner"].Value, match.Groups["repo"].Value, null, pullRequest, null);
116+
}
117+
118+
match = windowTitleIssueRegex.Match(windowTitle);
119+
if (match.Success)
120+
{
121+
int.TryParse(match.Groups["issue"].Value, out int issue);
122+
return (match.Success, match.Groups["owner"].Value, match.Groups["repo"].Value, null, null, issue);
123+
}
124+
125+
return (match.Success, null, null, null, null, null);
126+
}
127+
128+
static class User32
129+
{
130+
[DllImport("user32.dll", SetLastError = true)]
131+
internal static extern IntPtr FindWindowEx(IntPtr parentHandle, IntPtr childAfter, string className, IntPtr windowTitle);
132+
133+
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
134+
internal static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
135+
136+
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
137+
internal static extern int GetWindowTextLength(IntPtr hWnd);
138+
}
139+
}
140+
}

src/GitHub.App/packages.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@
3333
<package id="SerilogAnalyzer" version="0.12.0.0" targetFramework="net461" />
3434
<package id="SQLitePCL.raw_basic" version="0.7.3.0-vs2012" targetFramework="net45" />
3535
<package id="Stateless" version="2.5.56.0" targetFramework="net45" />
36+
<package id="System.ValueTuple" version="4.5.0" targetFramework="net461" />
3637
</packages>

test/GitHub.App.UnitTests/GitHub.App.UnitTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@
162162
<Compile Include="Properties\AssemblyInfo.cs" />
163163
<Compile Include="Services\AvatarProviderTests.cs" />
164164
<Compile Include="Services\GitClientTests.cs" />
165+
<Compile Include="Services\GitHubContextServiceTests.cs" />
165166
<Compile Include="Services\ImageDownloaderTests.cs" />
166167
<Compile Include="Services\OAuthCallbackListenerTests.cs" />
167168
<Compile Include="Services\PullRequestServiceTests.cs" />
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
using GitHub.App.Services;
2+
using NUnit.Framework;
3+
4+
public class GitHubContextServiceTests
5+
{
6+
public class TheFindContextFromUrlMethod
7+
{
8+
[TestCase("https://github.com", null)]
9+
[TestCase("https://github.com/github", "github")]
10+
[TestCase("https://github.com/github/VisualStudio", "github")]
11+
[TestCase("https://github.com/github/VisualStudio/blob/master/README.md", "github")]
12+
public void Owner(string url, string expectOwner)
13+
{
14+
var target = new GitHubContextService();
15+
16+
var context = target.FindContextFromUrl(url);
17+
18+
Assert.That(context.Owner, Is.EqualTo(expectOwner));
19+
}
20+
21+
[TestCase("https://github.com", null)]
22+
[TestCase("https://github.com/github", null)]
23+
[TestCase("https://github.com/github/VisualStudio", "VisualStudio")]
24+
[TestCase("https://github.com/github/VisualStudio/blob/master/README.md", "VisualStudio")]
25+
public void RepositoryName(string url, string expectRepositoryName)
26+
{
27+
var target = new GitHubContextService();
28+
29+
var context = target.FindContextFromUrl(url);
30+
31+
Assert.That(context.RepositoryName, Is.EqualTo(expectRepositoryName));
32+
}
33+
34+
[TestCase("https://github.com", "github.com")]
35+
[TestCase("https://github.com/github", "github.com")]
36+
[TestCase("https://github.com/github/VisualStudio", "github.com")]
37+
[TestCase("https://github.com/github/VisualStudio/blob/master/README.md", "github.com")]
38+
public void Host(string url, string expectHost)
39+
{
40+
var target = new GitHubContextService();
41+
42+
var context = target.FindContextFromUrl(url);
43+
44+
Assert.That(context.Host, Is.EqualTo(expectHost));
45+
}
46+
}
47+
48+
public class TheFindContextFromWindowTitleMethod
49+
{
50+
[TestCase("github/0123456789: Description - Google Chrome", "0123456789")]
51+
[TestCase("github/abcdefghijklmnopqrstuvwxyz: Description - Google Chrome", "abcdefghijklmnopqrstuvwxyz")]
52+
[TestCase("github/ABCDEFGHIJKLMNOPQRSTUVWXYZ: Description - Google Chrome", "ABCDEFGHIJKLMNOPQRSTUVWXYZ")]
53+
[TestCase("github/_: Description - Google Chrome", "_")]
54+
[TestCase("github/.: Description - Google Chrome", ".")]
55+
[TestCase("github/-: Description - Google Chrome", "-")]
56+
[TestCase("github/$: Description - Google Chrome", null, Description = "Must contain only letters, numbers, `_`, `.` or `-`")]
57+
public void RepositoryName(string windowTitle, string expectRepositoryName)
58+
{
59+
var target = new GitHubContextService();
60+
61+
var context = target.FindContextFromWindowTitle(windowTitle);
62+
63+
Assert.That(context?.RepositoryName, Is.EqualTo(expectRepositoryName));
64+
}
65+
66+
[TestCase("0123456789/Repository: Description - Google Chrome", "0123456789")]
67+
[TestCase("abcdefghijklmnopqrstuvwxyz/Repository: Description - Google Chrome", "abcdefghijklmnopqrstuvwxyz")]
68+
[TestCase("ABCDEFGHIJKLMNOPQRSTUVWXYZ/Repository: Description - Google Chrome", "ABCDEFGHIJKLMNOPQRSTUVWXYZ")]
69+
[TestCase("a_/Repository: Description - Google Chrome", "a_")]
70+
[TestCase("a-/Repository: Description - Google Chrome", "a-")]
71+
[TestCase("_/Repository: Description - Google Chrome", null, Description = "Must start with letter or number")]
72+
[TestCase("-/Repository: Description - Google Chrome", null, Description = "Must start with letter or number")]
73+
public void Owner(string windowTitle, string expectOwner)
74+
{
75+
var target = new GitHubContextService();
76+
77+
var context = target.FindContextFromWindowTitle(windowTitle);
78+
79+
Assert.That(context?.Owner, Is.EqualTo(expectOwner));
80+
}
81+
82+
// They can include slash / for hierarchical (directory) grouping
83+
[TestCase("a/b", "a/b", Description = "")]
84+
[TestCase("aaa/bbb", "aaa/bbb", Description = "")]
85+
86+
// They cannot have space, tilde ~, caret ^, or colon : anywhere.
87+
[TestCase("a b", null)]
88+
[TestCase("a~b", null)]
89+
[TestCase("a^b", null)]
90+
[TestCase("a:b", null)]
91+
92+
// They cannot have question-mark ?, asterisk *, or open bracket [ anywhere.
93+
[TestCase("a?b", null)]
94+
[TestCase("a*b", null)]
95+
[TestCase("a[b", null)]
96+
97+
[TestCase(@"a\b", null, Description = @"They cannot contain a \")]
98+
99+
// Simple case
100+
[TestCase("master", "master")]
101+
102+
// There are many symbols they can contain
103+
[TestCase("!@#$%&()_+-=", "!@#$%&()_+-=")]
104+
105+
[TestCase("/a", null, Description = "They cannot begin a slash")]
106+
[TestCase("a/", null, Description = "They cannot end with a slash")]
107+
[TestCase("../b", null, Description = "no slash-separated component can begin with a dot")]
108+
[TestCase(".a/b", null, Description = "no slash-separated component can begin with a dot")]
109+
[TestCase("a/.b", null, Description = "no slash-separated component can begin with a dot")]
110+
111+
// There are some checks we aren't doing, see https://git-scm.com/docs/git-check-ref-format
112+
// They cannot have ASCII control characters(i.e.bytes whose values are lower than \040, or \177 DEL)
113+
// [TestCase("a/b.lock", null, Description = "or end with the sequence.lock")]
114+
// [TestCase("a..b", null, Description = "They cannot have two consecutive dots..anywhere")]
115+
// [TestCase("a.", null, Description = "They cannot end with a dot")]
116+
// [TestCase("@{a", null, Description = "They cannot contain a sequence @{")]
117+
// [TestCase("@", null, Description = "They cannot be the single character @")]
118+
public void Branch(string branch, string expectBranch)
119+
{
120+
var windowTitle = $"VisualStudio/src/GitHub.VisualStudio/Resources/icons at {branch} · github/VisualStudio - Google Chrome";
121+
var target = new GitHubContextService();
122+
123+
var context = target.FindContextFromWindowTitle(windowTitle);
124+
125+
Assert.That(context?.Branch, Is.EqualTo(expectBranch));
126+
}
127+
128+
[TestCase("github/VisualStudio: GitHub Extension for Visual Studio - Google Chrome", "github", "VisualStudio", null)]
129+
[TestCase("Branches · github/VisualStudio - Google Chrome", "github", "VisualStudio", null)]
130+
[TestCase("github/VisualStudio at build/appveyor-fixes - Google Chrome", "github", "VisualStudio", "build/appveyor-fixes")]
131+
[TestCase("[spike] Open from GitHub URL by jcansdale · Pull Request #1763 · github/VisualStudio - Google Chrome", "github", "VisualStudio", null)]
132+
[TestCase("Consider adding C# code style preferences to editorconfig · Issue #1750 · github/VisualStudio - Google Chrome", "github", "VisualStudio", null)]
133+
[TestCase("VisualStudio/mark_github.xaml at master · github/VisualStudio - Google Chrome", "github", "VisualStudio", "master")]
134+
[TestCase("VisualStudio/src/GitHub.VisualStudio/Resources/icons at master · github/VisualStudio - Google Chrome", "github", "VisualStudio", "master")]
135+
[TestCase("VisualStudio/GitHub.Exports.csproj at 89484dc25a3a475d3253afdc3bd3ddd6c6999c3b · github/VisualStudio - Google Chrome", "github", "VisualStudio", "89484dc25a3a475d3253afdc3bd3ddd6c6999c3b")]
136+
public void OwnerRepositoryBranch(string windowTitle, string expectOwner, string expectRepositoryName, string expectBranch)
137+
{
138+
var target = new GitHubContextService();
139+
140+
var context = target.FindContextFromWindowTitle(windowTitle);
141+
142+
Assert.That(context.Owner, Is.EqualTo(expectOwner));
143+
Assert.That(context.RepositoryName, Is.EqualTo(expectRepositoryName));
144+
Assert.That(context.Branch, Is.EqualTo(expectBranch));
145+
}
146+
147+
[TestCase("[spike] Open from GitHub URL by jcansdale · Pull Request #1763 · github/VisualStudio - Google Chrome", 1763)]
148+
public void PullRequest(string windowTitle, int expectPullRequest)
149+
{
150+
var target = new GitHubContextService();
151+
152+
var context = target.FindContextFromWindowTitle(windowTitle);
153+
154+
Assert.That(context.PullRequest, Is.EqualTo(expectPullRequest));
155+
}
156+
157+
[TestCase("Consider adding C# code style preferences to editorconfig · Issue #1750 · github/VisualStudio - Google Chrome", 1750)]
158+
public void Issue(string windowTitle, int expectIssue)
159+
{
160+
var target = new GitHubContextService();
161+
162+
var context = target.FindContextFromWindowTitle(windowTitle);
163+
164+
Assert.That(context.Issue, Is.EqualTo(expectIssue));
165+
}
166+
}
167+
}

0 commit comments

Comments
 (0)