mirror of
https://github.com/desktop/desktop
synced 2024-08-24 18:57:33 +00:00
Merge branch 'development' into private-alive-subscriptions
This commit is contained in:
commit
d0da30aa89
|
@ -15,6 +15,7 @@ import username from 'username'
|
|||
import { GitProtocol } from './remote-parsing'
|
||||
import { Emitter } from 'event-kit'
|
||||
import JSZip from 'jszip'
|
||||
import { updateEndpointVersion } from './endpoint-capabilities'
|
||||
|
||||
const envEndpoint = process.env['DESKTOP_GITHUB_DOTCOM_API_ENDPOINT']
|
||||
const envHTMLURL = process.env['DESKTOP_GITHUB_DOTCOM_HTML_URL']
|
||||
|
@ -80,14 +81,8 @@ if (!ClientID || !ClientID.length || !ClientSecret || !ClientSecret.length) {
|
|||
|
||||
type GitHubAccountType = 'User' | 'Organization'
|
||||
|
||||
/** The OAuth scopes we want to request from GitHub.com. */
|
||||
const DotComOAuthScopes = ['repo', 'user', 'workflow']
|
||||
|
||||
/**
|
||||
* The OAuth scopes we want to request from GitHub
|
||||
* Enterprise.
|
||||
*/
|
||||
const EnterpriseOAuthScopes = ['repo', 'user']
|
||||
/** The OAuth scopes we want to request */
|
||||
const oauthScopes = ['repo', 'user', 'workflow']
|
||||
|
||||
enum HttpStatusCode {
|
||||
NotModified = 304,
|
||||
|
@ -1312,6 +1307,8 @@ export class API {
|
|||
API.emitTokenInvalidated(this.endpoint)
|
||||
}
|
||||
|
||||
tryUpdateEndpointVersionFromResponse(this.endpoint, response)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
|
@ -1451,7 +1448,7 @@ export async function createAuthorization(
|
|||
'POST',
|
||||
'authorizations',
|
||||
{
|
||||
scopes: getOAuthScopesForEndpoint(endpoint),
|
||||
scopes: oauthScopes,
|
||||
client_id: ClientID,
|
||||
client_secret: ClientSecret,
|
||||
note: note,
|
||||
|
@ -1464,6 +1461,8 @@ export async function createAuthorization(
|
|||
}
|
||||
)
|
||||
|
||||
tryUpdateEndpointVersionFromResponse(endpoint, response)
|
||||
|
||||
try {
|
||||
const result = await parsedResponse<IAPIAuthorization>(response)
|
||||
if (result) {
|
||||
|
@ -1570,6 +1569,8 @@ export async function fetchMetadata(
|
|||
'Content-Type': 'application/json',
|
||||
})
|
||||
|
||||
tryUpdateEndpointVersionFromResponse(endpoint, response)
|
||||
|
||||
const result = await parsedResponse<IServerMetadata>(response)
|
||||
if (!result || result.verifiable_password_authentication === undefined) {
|
||||
return null
|
||||
|
@ -1679,7 +1680,7 @@ export function getOAuthAuthorizationURL(
|
|||
state: string
|
||||
): string {
|
||||
const urlBase = getHTMLURL(endpoint)
|
||||
const scopes = getOAuthScopesForEndpoint(endpoint)
|
||||
const scopes = oauthScopes
|
||||
const scope = encodeURIComponent(scopes.join(' '))
|
||||
return `${urlBase}/login/oauth/authorize?client_id=${ClientID}&scope=${scope}&state=${state}`
|
||||
}
|
||||
|
@ -1701,6 +1702,8 @@ export async function requestOAuthToken(
|
|||
code: code,
|
||||
}
|
||||
)
|
||||
tryUpdateEndpointVersionFromResponse(endpoint, response)
|
||||
|
||||
const result = await parsedResponse<IAPIAccessToken>(response)
|
||||
return result.access_token
|
||||
} catch (e) {
|
||||
|
@ -1709,8 +1712,12 @@ export async function requestOAuthToken(
|
|||
}
|
||||
}
|
||||
|
||||
function getOAuthScopesForEndpoint(endpoint: string) {
|
||||
return endpoint === getDotComAPIEndpoint()
|
||||
? DotComOAuthScopes
|
||||
: EnterpriseOAuthScopes
|
||||
function tryUpdateEndpointVersionFromResponse(
|
||||
endpoint: string,
|
||||
response: Response
|
||||
) {
|
||||
const gheVersion = response.headers.get('x-github-enterprise-version')
|
||||
if (gheVersion !== null) {
|
||||
updateEndpointVersion(endpoint, gheVersion)
|
||||
}
|
||||
}
|
||||
|
|
155
app/src/lib/endpoint-capabilities.ts
Normal file
155
app/src/lib/endpoint-capabilities.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import * as semver from 'semver'
|
||||
import { getDotComAPIEndpoint } from './api'
|
||||
import { assertNonNullable } from './fatal-error'
|
||||
|
||||
export type VersionConstraint = {
|
||||
/** Whether this constrain will be satisfied when using GitHub.com */
|
||||
dotcom: boolean
|
||||
/**
|
||||
* Whether this constrain will be satisfied when using GitHub AE
|
||||
* Supports specifying a version constraint as a SemVer Range (ex: >= 3.1.0)
|
||||
*/
|
||||
ae: boolean | string
|
||||
/**
|
||||
* Whether this constrain will be satisfied when using GitHub Enterprise
|
||||
* Server. Supports specifying a version constraint as a SemVer Range (ex: >=
|
||||
* 3.1.0)
|
||||
*/
|
||||
es: boolean | string
|
||||
}
|
||||
|
||||
/**
|
||||
* If we're connected to a GHES instance but it doesn't report a version
|
||||
* number (either because of corporate proxies that strip the version
|
||||
* header or because GHES stops sending the version header in the future)
|
||||
* we'll assume it's this version.
|
||||
*
|
||||
* This should correspond loosely with the oldest supported GHES series and
|
||||
* needs to be updated manually.
|
||||
*/
|
||||
const assumedGHESVersion = new semver.SemVer('3.1.0')
|
||||
|
||||
/**
|
||||
* If we're connected to a GHAE instance we won't know its version number
|
||||
* since it doesn't report that so we'll use this substitute GHES equivalent
|
||||
* version number.
|
||||
*
|
||||
* This should correspond loosely with the most recent GHES series and
|
||||
* needs to be updated manually.
|
||||
*/
|
||||
const assumedGHAEVersion = new semver.SemVer('3.2.0')
|
||||
|
||||
/** Stores raw x-github-enterprise-version headers keyed on endpoint */
|
||||
const rawVersionCache = new Map<string, string>()
|
||||
|
||||
/** Stores parsed x-github-enterprise-version headers keyed on endpoint */
|
||||
const versionCache = new Map<string, semver.SemVer | null>()
|
||||
|
||||
/** Get the cache key for a given endpoint address */
|
||||
const endpointVersionKey = (ep: string) => `endpoint-version:${ep}`
|
||||
|
||||
/**
|
||||
* Whether or not the given endpoint URI matches GitHub.com's
|
||||
*
|
||||
* I.e. https://api.github.com/
|
||||
*
|
||||
* Most often used to check if an endpoint _isn't_ GitHub.com meaning it's
|
||||
* either GitHub Enterprise Server or GitHub AE
|
||||
*/
|
||||
export const isDotCom = (ep: string) => ep === getDotComAPIEndpoint()
|
||||
|
||||
/**
|
||||
* Whether or not the given endpoint URI appears to point to a GitHub AE
|
||||
* instance
|
||||
*/
|
||||
export const isGHAE = (ep: string) =>
|
||||
/^https:\/\/[a-z0-9-]+\.ghe\.com$/i.test(ep)
|
||||
|
||||
/**
|
||||
* Whether or not the given endpoint URI appears to point to a GitHub Enterprise
|
||||
* Server instance
|
||||
*/
|
||||
export const isGHES = (ep: string) => !isDotCom(ep) && !isGHAE(ep)
|
||||
|
||||
function getEndpointVersion(endpoint: string) {
|
||||
const key = endpointVersionKey(endpoint)
|
||||
const cached = versionCache.get(key)
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(key)
|
||||
const parsed = raw === null ? null : semver.parse(raw)
|
||||
|
||||
if (parsed !== null) {
|
||||
versionCache.set(key, parsed)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the known version number for a given endpoint
|
||||
*/
|
||||
export function updateEndpointVersion(endpoint: string, version: string) {
|
||||
const key = endpointVersionKey(endpoint)
|
||||
|
||||
if (rawVersionCache.get(key) !== version) {
|
||||
const parsed = semver.parse(version)
|
||||
localStorage.setItem(key, version)
|
||||
rawVersionCache.set(key, version)
|
||||
versionCache.set(key, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
function checkConstraint(
|
||||
epConstraint: string | boolean,
|
||||
epMatchesType: boolean,
|
||||
epVersion?: semver.SemVer
|
||||
) {
|
||||
// Denial of endpoint type regardless of version
|
||||
if (epConstraint === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Approval of endpoint type regardless of version
|
||||
if (epConstraint === true) {
|
||||
return epMatchesType
|
||||
}
|
||||
|
||||
// Version number constraint
|
||||
assertNonNullable(epVersion, `Need to provide a version to compare against`)
|
||||
return epMatchesType && semver.satisfies(epVersion, epConstraint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a predicate which verifies whether a given endpoint matches the
|
||||
* provided constraints.
|
||||
*
|
||||
* Note: NOT meant for direct consumption, only exported for testability reasons.
|
||||
* Consumers should use the various `supports*` methods instead.
|
||||
*/
|
||||
export const endpointSatisfies = (
|
||||
{ dotcom, ae, es }: VersionConstraint,
|
||||
getVersion = getEndpointVersion
|
||||
) => (ep: string) =>
|
||||
checkConstraint(dotcom, isDotCom(ep)) ||
|
||||
checkConstraint(ae, isGHAE(ep), assumedGHAEVersion) ||
|
||||
checkConstraint(es, isGHES(ep), getVersion(ep) ?? assumedGHESVersion)
|
||||
|
||||
/**
|
||||
* Whether or not the endpoint supports the internal GitHub Enterprise Server
|
||||
* avatars API
|
||||
*/
|
||||
export const supportsAvatarsAPI = endpointSatisfies({
|
||||
dotcom: false,
|
||||
ae: '>= 3.0.0',
|
||||
es: '>= 3.0.0',
|
||||
})
|
||||
|
||||
export const supportsRerunningChecks = endpointSatisfies({
|
||||
dotcom: true,
|
||||
ae: '>= 3.4.0',
|
||||
es: '>= 3.4.0',
|
||||
})
|
|
@ -4,9 +4,5 @@
|
|||
* considered a hard limit, i.e. older versions of GitHub Enterprise
|
||||
* might (and probably do) work just fine but this should be a fairly
|
||||
* recent version that we can safely say that we'll work well with.
|
||||
*
|
||||
* I picked the current minimum (2.8) because it was the version
|
||||
* running on our internal GitHub Enterprise instance at the time
|
||||
* we implemented Enterprise sign in (desktop/desktop#664)
|
||||
*/
|
||||
export const minimumSupportedEnterpriseVersion = '2.8.0'
|
||||
export const minimumSupportedEnterpriseVersion = '3.0.0'
|
||||
|
|
145
app/src/lib/markdown-filters/emoji-filter.ts
Normal file
145
app/src/lib/markdown-filters/emoji-filter.ts
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { INodeFilter } from './node-filter'
|
||||
import * as FSE from 'fs-extra'
|
||||
import { escapeRegExp } from '../helpers/regex'
|
||||
import uri2path from 'file-uri-to-path'
|
||||
|
||||
/**
|
||||
* The Emoji Markdown filter will take a text node and create multiple text and
|
||||
* image nodes by inserting emoji images using base64 data uri where emoji
|
||||
* references are in the text node.
|
||||
*
|
||||
* Example: A text node of "That is great! :+1: Good Job!"
|
||||
* Becomes three nodes: "That is great! ",<img src="data uri for :+1:>, " Good Job!"
|
||||
*
|
||||
* Notes: We are taking the emoji file paths and creating the base 64 data URI
|
||||
* because this is to be injected into a sandboxed markdown parser were we will
|
||||
* no longer have access to the local file paths.
|
||||
*/
|
||||
export class EmojiFilter implements INodeFilter {
|
||||
private readonly emojiRegex: RegExp
|
||||
private readonly emojiFilePath: Map<string, string>
|
||||
private readonly emojiBase64URICache: Map<string, string> = new Map()
|
||||
|
||||
/**
|
||||
* @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path.
|
||||
*/
|
||||
public constructor(emojiFilePath: Map<string, string>) {
|
||||
this.emojiFilePath = emojiFilePath
|
||||
this.emojiRegex = this.buildEmojiRegExp(emojiFilePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji filter iterates on all text nodes that are not inside a pre or code tag.
|
||||
*/
|
||||
public createFilterTreeWalker(doc: Document): TreeWalker {
|
||||
return doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (node) {
|
||||
return node.parentNode !== null &&
|
||||
['CODE', 'PRE'].includes(node.parentNode.nodeName)
|
||||
? NodeFilter.FILTER_SKIP
|
||||
: NodeFilter.FILTER_ACCEPT
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a text node and creates multiple text and image nodes by inserting
|
||||
* emoji image nodes using base64 data uri where emoji references are.
|
||||
*
|
||||
* Example: A text node of "That is great! :+1: Good Job!" Becomes three
|
||||
* nodes: ["That is great! ",<img src="data uri for :+1:>, " Good Job!"]
|
||||
*
|
||||
* Note: Emoji filter requires text nodes; otherwise we may inadvertently replace non text elements.
|
||||
*/
|
||||
public async filter(node: Node): Promise<ReadonlyArray<Node> | null> {
|
||||
let text = node.textContent
|
||||
if (
|
||||
node.nodeType !== node.TEXT_NODE ||
|
||||
text === null ||
|
||||
!text.includes(':')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const emojiMatches = text.match(this.emojiRegex)
|
||||
if (emojiMatches === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nodes = new Array<Text | HTMLImageElement>()
|
||||
for (let i = 0; i < emojiMatches.length; i++) {
|
||||
const emojiKey = emojiMatches[i]
|
||||
const emojiPath = this.emojiFilePath.get(emojiKey)
|
||||
if (emojiPath === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const emojiImg = await this.createEmojiNode(emojiPath)
|
||||
if (emojiImg === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
const emojiPosition = text.indexOf(emojiKey)
|
||||
const textBeforeEmoji = text.slice(0, emojiPosition)
|
||||
const textNodeBeforeEmoji = document.createTextNode(textBeforeEmoji)
|
||||
nodes.push(textNodeBeforeEmoji)
|
||||
nodes.push(emojiImg)
|
||||
|
||||
text = text.slice(emojiPosition + emojiKey.length)
|
||||
}
|
||||
|
||||
if (text !== '') {
|
||||
const trailingTextNode = document.createTextNode(text)
|
||||
nodes.push(trailingTextNode)
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to build an emoji image node to insert in place of the emoji ref.
|
||||
* If we fail to create the image element, returns null.
|
||||
*/
|
||||
private async createEmojiNode(
|
||||
emojiPath: string
|
||||
): Promise<HTMLImageElement | null> {
|
||||
try {
|
||||
const dataURI = await this.getBase64FromImageUrl(emojiPath)
|
||||
const emojiImg = new Image()
|
||||
emojiImg.classList.add('emoji')
|
||||
emojiImg.src = dataURI
|
||||
return emojiImg
|
||||
} catch (e) {}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to obtain an images base 64 data uri from it's file path.
|
||||
* - It checks cache, if not, reads from file, then stores in cache.
|
||||
*/
|
||||
private async getBase64FromImageUrl(filePath: string): Promise<string> {
|
||||
const cached = this.emojiBase64URICache.get(filePath)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const imageBuffer = await FSE.readFile(uri2path(filePath))
|
||||
const b64src = imageBuffer.toString('base64')
|
||||
const uri = `data:image/png;base64,${b64src}`
|
||||
this.emojiBase64URICache.set(filePath, uri)
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a regular expression that is looking for all group of characters
|
||||
* that represents any emoji ref (or map key) in the provided map.
|
||||
*
|
||||
* @param emoji Map from the emoji ref (e.g., :+1:) to the image's local path.
|
||||
*/
|
||||
private buildEmojiRegExp(emoji: Map<string, string>): RegExp {
|
||||
const emojiGroups = [...emoji.keys()]
|
||||
.map(emoji => escapeRegExp(emoji))
|
||||
.join('|')
|
||||
return new RegExp('(' + emojiGroups + ')', 'g')
|
||||
}
|
||||
}
|
89
app/src/lib/markdown-filters/node-filter.ts
Normal file
89
app/src/lib/markdown-filters/node-filter.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import memoizeOne from 'memoize-one'
|
||||
import { EmojiFilter } from './emoji-filter'
|
||||
|
||||
export interface INodeFilter {
|
||||
/**
|
||||
* Creates a document tree walker filtered to the nodes relevant to the node filter.
|
||||
*
|
||||
* Examples:
|
||||
* 1) An Emoji filter operates on all text nodes, but not inside pre or code tags.
|
||||
* 2) The issue mention filter operates on all text nodes, but not inside pre, code, or anchor tags
|
||||
*/
|
||||
createFilterTreeWalker(doc: Document): TreeWalker
|
||||
|
||||
/**
|
||||
* This filter accepts a document node and searches for it's pattern within it.
|
||||
*
|
||||
* If found, returns an array of nodes to replace the node with.
|
||||
* Example: [Node(contents before match), Node(match replacement), Node(contents after match)]
|
||||
* If not found, returns null
|
||||
*
|
||||
* This is asynchronous as some filters have data must be fetched or, like in
|
||||
* emoji, the conversion to base 64 data uri is asynchronous
|
||||
* */
|
||||
filter(node: Node): Promise<ReadonlyArray<Node> | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an array of node filters to apply to markdown html. Referring to it as pipe
|
||||
* because they will be applied in the order they are entered in the returned
|
||||
* array. This is important as some filters impact others.
|
||||
*
|
||||
* @param emoji Map from the emoji shortcut (e.g., :+1:) to the image's local path.
|
||||
*/
|
||||
export const buildCustomMarkDownNodeFilterPipe = memoizeOne(
|
||||
(emoji: Map<string, string>): ReadonlyArray<INodeFilter> => [
|
||||
new EmojiFilter(emoji),
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Method takes an array of node filters and applies them to a markdown string.
|
||||
*
|
||||
* It converts the markdown string into a DOM Document. Then, iterates over each
|
||||
* provided filter. Each filter will have method to create a tree walker to
|
||||
* limit the document nodes relative to the filter's purpose. Then, it will
|
||||
* replace any affected node with the node(s) generated by the node filter. If a
|
||||
* node is not impacted, it is not replace.
|
||||
*/
|
||||
export async function applyNodeFilters(
|
||||
nodeFilters: ReadonlyArray<INodeFilter>,
|
||||
parsedMarkdown: string
|
||||
): Promise<string> {
|
||||
const mdDoc = new DOMParser().parseFromString(parsedMarkdown, 'text/html')
|
||||
|
||||
for (const nodeFilter of nodeFilters) {
|
||||
await applyNodeFilter(nodeFilter, mdDoc)
|
||||
}
|
||||
|
||||
return mdDoc.documentElement.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Method uses a NodeFilter to replace any nodes that match the filters tree
|
||||
* walker and filter change criteria.
|
||||
*
|
||||
* Note: This mutates; it does not return a changed copy of the DOM Document
|
||||
* provided.
|
||||
*/
|
||||
async function applyNodeFilter(
|
||||
nodeFilter: INodeFilter,
|
||||
mdDoc: Document
|
||||
): Promise<void> {
|
||||
const walker = nodeFilter.createFilterTreeWalker(mdDoc)
|
||||
|
||||
let textNode = walker.nextNode()
|
||||
while (textNode !== null) {
|
||||
const replacementNodes = await nodeFilter.filter(textNode)
|
||||
const currentNode = textNode
|
||||
textNode = walker.nextNode()
|
||||
if (replacementNodes === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const replacementNode of replacementNodes) {
|
||||
currentNode.parentNode?.insertBefore(replacementNode, currentNode)
|
||||
}
|
||||
currentNode.parentNode?.removeChild(currentNode)
|
||||
}
|
||||
}
|
|
@ -225,7 +225,7 @@ export type Popup =
|
|||
| {
|
||||
type: PopupType.PushRejectedDueToMissingWorkflowScope
|
||||
rejectedPath: string
|
||||
repository: Repository
|
||||
repository: RepositoryWithGitHubRepository
|
||||
}
|
||||
| {
|
||||
type: PopupType.SAMLReauthRequired
|
||||
|
|
|
@ -1148,13 +1148,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
private viewRepositoryOnGitHub() {
|
||||
const repository = this.getRepository()
|
||||
|
||||
if (repository instanceof Repository) {
|
||||
const url = getGitHubHtmlUrl(repository)
|
||||
|
||||
if (url) {
|
||||
this.props.dispatcher.openInBrowser(url)
|
||||
}
|
||||
}
|
||||
this.viewOnGitHub(repository)
|
||||
}
|
||||
|
||||
/** Returns the URL to the current repository if hosted on GitHub */
|
||||
|
@ -2324,6 +2318,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
this.state.askForConfirmationOnRepositoryRemoval
|
||||
}
|
||||
onRemoveRepository={this.removeRepository}
|
||||
onViewOnGitHub={this.viewOnGitHub}
|
||||
onOpenInShell={this.openInShell}
|
||||
onShowRepository={this.showRepository}
|
||||
onOpenInExternalEditor={this.openInExternalEditor}
|
||||
|
@ -2334,6 +2329,20 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
)
|
||||
}
|
||||
|
||||
private viewOnGitHub = (
|
||||
repository: Repository | CloningRepository | null
|
||||
) => {
|
||||
if (!(repository instanceof Repository)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = getGitHubHtmlUrl(repository)
|
||||
|
||||
if (url) {
|
||||
this.props.dispatcher.openInBrowser(url)
|
||||
}
|
||||
}
|
||||
|
||||
private openInShell = (repository: Repository | CloningRepository) => {
|
||||
if (!(repository instanceof Repository)) {
|
||||
return
|
||||
|
@ -2577,6 +2586,7 @@ export class App extends React.Component<IAppProps, IAppState> {
|
|||
this.state.currentOnboardingTutorialStep === TutorialStep.CreateBranch
|
||||
}
|
||||
showCIStatusPopover={this.state.showCIStatusPopover}
|
||||
emoji={this.state.emoji}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -46,6 +46,9 @@ interface IBranchesContainerProps {
|
|||
|
||||
/** Are we currently loading pull requests? */
|
||||
readonly isLoadingPullRequests: boolean
|
||||
|
||||
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
|
||||
readonly emoji: Map<string, string>
|
||||
}
|
||||
|
||||
interface IBranchesContainerState {
|
||||
|
@ -125,6 +128,7 @@ export class BranchesContainer extends React.Component<
|
|||
return (
|
||||
<PullRequestQuickView
|
||||
dispatcher={this.props.dispatcher}
|
||||
emoji={this.props.emoji}
|
||||
pullRequest={pr}
|
||||
pullRequestItemTop={prListItemTop}
|
||||
onMouseEnter={this.onMouseEnterPullRequestQuickView}
|
||||
|
|
|
@ -767,7 +767,7 @@ export class CommitMessage extends React.Component<
|
|||
}
|
||||
|
||||
public render() {
|
||||
const className = classNames({
|
||||
const className = classNames('commit-message-component', {
|
||||
'with-action-bar': this.isActionBarEnabled,
|
||||
'with-co-authors': this.isCoAuthorInputVisible,
|
||||
})
|
||||
|
@ -786,7 +786,6 @@ export class CommitMessage extends React.Component<
|
|||
|
||||
return (
|
||||
<div
|
||||
id="commit-message"
|
||||
role="group"
|
||||
aria-label="Create commit"
|
||||
className={className}
|
||||
|
|
|
@ -34,7 +34,7 @@ export const CommitWarning: React.FunctionComponent<{
|
|||
readonly icon: CommitWarningIcon
|
||||
}> = props => {
|
||||
return (
|
||||
<div id="commit-warning" onContextMenu={ignoreContextMenu}>
|
||||
<div className="commit-warning-component" onContextMenu={ignoreContextMenu}>
|
||||
<div className="warning-icon-container">{renderIcon(props.icon)}</div>
|
||||
<div className="warning-message">{props.children}</div>
|
||||
</div>
|
||||
|
|
|
@ -12,17 +12,14 @@ import {
|
|||
} from '../../lib/ci-checks/ci-checks'
|
||||
import { Octicon, syncClockwise } from '../octicons'
|
||||
import { Button } from '../lib/button'
|
||||
import {
|
||||
APICheckConclusion,
|
||||
getDotComAPIEndpoint,
|
||||
IAPIWorkflowJobStep,
|
||||
} from '../../lib/api'
|
||||
import { APICheckConclusion, IAPIWorkflowJobStep } from '../../lib/api'
|
||||
import { Popover, PopoverCaretPosition } from '../lib/popover'
|
||||
import { CICheckRunList } from './ci-check-run-list'
|
||||
import { encodePathAsUrl } from '../../lib/path'
|
||||
import { PopupType } from '../../models/popup'
|
||||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import { Donut } from '../donut'
|
||||
import { supportsRerunningChecks } from '../../lib/endpoint-capabilities'
|
||||
|
||||
const BlankSlateImage = encodePathAsUrl(
|
||||
__dirname,
|
||||
|
@ -222,7 +219,7 @@ export class CICheckRunPopover extends React.PureComponent<
|
|||
|
||||
private renderRerunButton = () => {
|
||||
const { checkRuns } = this.state
|
||||
if (this.props.repository.endpoint !== getDotComAPIEndpoint()) {
|
||||
if (!supportsRerunningChecks(this.props.repository.endpoint)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@ import {
|
|||
Repository,
|
||||
isRepositoryWithGitHubRepository,
|
||||
} from '../../models/repository'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
import { hasWritePermission } from '../../models/github-repository'
|
||||
import { RetryActionType } from '../../models/retry-actions'
|
||||
import { parseFilesToBeOverwritten } from '../lib/parse-files-to-be-overwritten'
|
||||
|
@ -448,12 +447,7 @@ export async function refusedWorkflowUpdate(
|
|||
return error
|
||||
}
|
||||
|
||||
if (repository.gitHubRepository === null) {
|
||||
return error
|
||||
}
|
||||
|
||||
// DotCom only for now.
|
||||
if (repository.gitHubRepository.endpoint !== getDotComAPIEndpoint()) {
|
||||
if (!isRepositoryWithGitHubRepository(repository)) {
|
||||
return error
|
||||
}
|
||||
|
||||
|
|
|
@ -3,9 +3,13 @@ import * as FSE from 'fs-extra'
|
|||
import * as Path from 'path'
|
||||
import marked from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
applyNodeFilters,
|
||||
buildCustomMarkDownNodeFilterPipe,
|
||||
} from '../../lib/markdown-filters/node-filter'
|
||||
|
||||
interface ISandboxedMarkdownProps {
|
||||
/** A string of unparsed markdownm to display */
|
||||
/** A string of unparsed markdown to display */
|
||||
readonly markdown: string
|
||||
|
||||
/** The baseHref of the markdown content for when the markdown has relative links */
|
||||
|
@ -19,6 +23,12 @@ interface ISandboxedMarkdownProps {
|
|||
* this will not fire.
|
||||
*/
|
||||
readonly onMarkdownLinkClicked?: (url: string) => void
|
||||
|
||||
/** A callback for after the markdown has been parsed and the contents have
|
||||
* been mounted to the iframe */
|
||||
readonly onMarkdownParsed?: () => void
|
||||
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
|
||||
readonly emoji: Map<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -142,6 +152,7 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
// to prevent scrollbar/content cut off.
|
||||
const divHeight = docEl.clientHeight + 50
|
||||
this.frameContainingDivRef.style.height = `${divHeight}px`
|
||||
this.props.onMarkdownParsed?.()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -189,11 +200,19 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
const styleSheet = await this.getInlineStyleSheet()
|
||||
|
||||
const parsedMarkdown = marked(this.props.markdown ?? '', {
|
||||
// https://marked.js.org/using_advanced If true, use approved GitHub
|
||||
// Flavored Markdown (GFM) specification.
|
||||
gfm: true,
|
||||
// https://marked.js.org/using_advanced, If true, add <br> on a single
|
||||
// line break (copies GitHub behavior on comments, but not on rendered
|
||||
// markdown files). Requires gfm be true.
|
||||
breaks: true,
|
||||
})
|
||||
|
||||
const sanitizedHTML = DOMPurify.sanitize(parsedMarkdown)
|
||||
|
||||
const filteredHTML = await this.applyCustomMarkdownFilters(sanitizedHTML)
|
||||
|
||||
const src = `
|
||||
<html>
|
||||
<head>
|
||||
|
@ -202,8 +221,8 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
</head>
|
||||
<body class="markdown-body">
|
||||
<div id="content">
|
||||
${sanitizedHTML}
|
||||
</div
|
||||
${filteredHTML}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
@ -224,6 +243,17 @@ export class SandboxedMarkdown extends React.PureComponent<
|
|||
this.frameRef.src = `data:text/html;charset=utf-8;base64,${b64src}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies custom markdown filters to parsed markdown html. This is done
|
||||
* through converting the markdown html into a DOM document and then
|
||||
* traversing the nodes to apply custom filters such as emoji, issue, username
|
||||
* mentions, etc.
|
||||
*/
|
||||
private applyCustomMarkdownFilters(parsedMarkdown: string): Promise<string> {
|
||||
const nodeFilters = buildCustomMarkDownNodeFilterPipe(this.props.emoji)
|
||||
return applyNodeFilters(nodeFilters, parsedMarkdown)
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div
|
||||
|
|
|
@ -14,6 +14,11 @@ import classNames from 'classnames'
|
|||
* body and 56 for header)
|
||||
*/
|
||||
const maxQuickViewHeight = 556
|
||||
/**
|
||||
* This is currently statically defined so not bothering to attain it from dom
|
||||
* searching.
|
||||
*/
|
||||
const heightPRListItem = 47
|
||||
|
||||
interface IPullRequestQuickViewProps {
|
||||
readonly dispatcher: Dispatcher
|
||||
|
@ -26,25 +31,32 @@ interface IPullRequestQuickViewProps {
|
|||
|
||||
/** When mouse leaves the PR quick view */
|
||||
readonly onMouseLeave: () => void
|
||||
|
||||
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
|
||||
readonly emoji: Map<string, string>
|
||||
}
|
||||
|
||||
interface IPullRequestQuickViewState {
|
||||
readonly position: React.CSSProperties | undefined
|
||||
readonly top: number
|
||||
}
|
||||
|
||||
export class PullRequestQuickView extends React.Component<
|
||||
IPullRequestQuickViewProps,
|
||||
IPullRequestQuickViewState
|
||||
> {
|
||||
private quickViewRef: HTMLDivElement | null = null
|
||||
private quickViewRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
private get quickViewHeight(): number {
|
||||
return this.quickViewRef.current?.clientHeight ?? maxQuickViewHeight
|
||||
}
|
||||
|
||||
public constructor(props: IPullRequestQuickViewProps) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
position: this.calculatePosition(
|
||||
top: this.calculatePosition(
|
||||
props.pullRequestItemTop,
|
||||
maxQuickViewHeight
|
||||
this.quickViewHeight
|
||||
),
|
||||
}
|
||||
}
|
||||
|
@ -52,24 +64,35 @@ export class PullRequestQuickView extends React.Component<
|
|||
public componentDidUpdate = (prevProps: IPullRequestQuickViewProps) => {
|
||||
if (
|
||||
prevProps.pullRequest.pullRequestNumber ===
|
||||
this.props.pullRequest.pullRequestNumber ||
|
||||
this.quickViewRef === null
|
||||
this.props.pullRequest.pullRequestNumber
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.updateQuickViewPosition()
|
||||
}
|
||||
|
||||
private updateQuickViewPosition = () => {
|
||||
this.setState({
|
||||
position: this.calculatePosition(
|
||||
top: this.calculatePosition(
|
||||
this.props.pullRequestItemTop,
|
||||
this.quickViewRef.clientHeight
|
||||
this.quickViewHeight
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
private viewOnGitHub = () => {
|
||||
private onMarkdownParsed = () => {
|
||||
this.updateQuickViewPosition()
|
||||
}
|
||||
|
||||
private onViewOnGitHub = () => {
|
||||
this.props.dispatcher.showPullRequestByPR(this.props.pullRequest)
|
||||
}
|
||||
|
||||
private onMouseLeave = () => {
|
||||
this.props.onMouseLeave()
|
||||
}
|
||||
|
||||
/**
|
||||
* Important to retrieve as it changes for maximization on macOS and quick
|
||||
* view is relative to the top of branch container = foldout-container. But if
|
||||
|
@ -86,11 +109,8 @@ export class PullRequestQuickView extends React.Component<
|
|||
private calculatePosition(
|
||||
prListItemTop: number,
|
||||
quickViewHeight: number
|
||||
): React.CSSProperties | undefined {
|
||||
): number {
|
||||
const topOfPRList = this.getTopPRList()
|
||||
// This is currently staticly defined so not bothering to attain it from
|
||||
// dom searching.
|
||||
const heightPRListItem = 47
|
||||
|
||||
// We want to make sure that the quick view is always visible and highest
|
||||
// being aligned to top of branch/pr dropdown (which is 0 since this is a
|
||||
|
@ -101,14 +121,14 @@ export class PullRequestQuickView extends React.Component<
|
|||
// Check if it has room to display aligned to top (likely top half of list)
|
||||
if (window.innerHeight - prListItemTop > quickViewHeight) {
|
||||
const alignedTop = prListItemTop - topOfPRList
|
||||
return { top: clamp(alignedTop, minTop, maxTop) }
|
||||
return clamp(alignedTop, minTop, maxTop)
|
||||
}
|
||||
|
||||
// Can't align to top -> likely bottom half of list check if has room to display aligned to bottom.
|
||||
if (prListItemTop - quickViewHeight > 0) {
|
||||
const alignedTop = prListItemTop - topOfPRList
|
||||
const alignedBottom = alignedTop - quickViewHeight + heightPRListItem
|
||||
return { top: clamp(alignedBottom, minTop, maxTop) }
|
||||
return clamp(alignedBottom, minTop, maxTop)
|
||||
}
|
||||
|
||||
// If not enough room to display aligned top or bottom, attempt to center on
|
||||
|
@ -118,11 +138,17 @@ export class PullRequestQuickView extends React.Component<
|
|||
const middlePrListItem = prListItemTop + heightPRListItem / 2
|
||||
const middleQuickView = quickViewHeight / 2
|
||||
const alignedMiddle = middlePrListItem - middleQuickView
|
||||
return { top: clamp(alignedMiddle, minTop, maxTop) }
|
||||
return clamp(alignedMiddle, minTop, maxTop)
|
||||
}
|
||||
|
||||
private onQuickViewRef = (quickViewRef: HTMLDivElement) => {
|
||||
this.quickViewRef = quickViewRef
|
||||
private getPointerPosition(top: number): React.CSSProperties {
|
||||
const prListItemTopWRTQuickViewTopZero =
|
||||
this.props.pullRequestItemTop - this.getTopPRList()
|
||||
const prListItemPositionWRToQuickViewTop =
|
||||
prListItemTopWRTQuickViewTopZero - top
|
||||
const centerPointOnListItem =
|
||||
prListItemPositionWRToQuickViewTop + heightPRListItem / 2
|
||||
return { top: centerPointOnListItem }
|
||||
}
|
||||
|
||||
private renderHeader = (): JSX.Element => {
|
||||
|
@ -130,7 +156,7 @@ export class PullRequestQuickView extends React.Component<
|
|||
<header className="header">
|
||||
<Octicon symbol={OcticonSymbol.listUnordered} />
|
||||
<div className="action-needed">Review requested</div>
|
||||
<Button className="button-with-icon" onClick={this.viewOnGitHub}>
|
||||
<Button className="button-with-icon" onClick={this.onViewOnGitHub}>
|
||||
View on GitHub
|
||||
<Octicon symbol={OcticonSymbol.linkExternal} />
|
||||
</Button>
|
||||
|
@ -180,29 +206,32 @@ export class PullRequestQuickView extends React.Component<
|
|||
</div>
|
||||
<SandboxedMarkdown
|
||||
markdown={displayBody}
|
||||
emoji={this.props.emoji}
|
||||
baseHref={base.gitHubRepository.htmlURL}
|
||||
onMarkdownParsed={this.onMarkdownParsed}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onMouseLeave = () => {
|
||||
this.props.onMouseLeave()
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { top } = this.state
|
||||
return (
|
||||
<div
|
||||
className="pull-request-quick-view"
|
||||
onMouseEnter={this.props.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
style={this.state.position}
|
||||
ref={this.onQuickViewRef}
|
||||
style={{ top }}
|
||||
ref={this.quickViewRef}
|
||||
>
|
||||
<div className="pull-request-quick-view-contents">
|
||||
{this.renderHeader()}
|
||||
{this.renderPR()}
|
||||
</div>
|
||||
<div
|
||||
className="pull-request-pointer"
|
||||
style={this.getPointerPosition(top)}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -49,6 +49,9 @@ interface IRepositoriesListProps {
|
|||
/** Called when the repository should be shown in Finder/Explorer/File Manager. */
|
||||
readonly onShowRepository: (repository: Repositoryish) => void
|
||||
|
||||
/** Called when the repository should be opened on GitHub in the default web browser. */
|
||||
readonly onViewOnGitHub: (repository: Repositoryish) => void
|
||||
|
||||
/** Called when the repository should be shown in the shell. */
|
||||
readonly onOpenInShell: (repository: Repositoryish) => void
|
||||
|
||||
|
@ -137,6 +140,7 @@ export class RepositoriesList extends React.Component<
|
|||
}
|
||||
onRemoveRepository={this.props.onRemoveRepository}
|
||||
onShowRepository={this.props.onShowRepository}
|
||||
onViewOnGitHub={this.props.onViewOnGitHub}
|
||||
onOpenInShell={this.props.onOpenInShell}
|
||||
onOpenInExternalEditor={this.props.onOpenInExternalEditor}
|
||||
onChangeRepositoryAlias={this.onChangeRepositoryAlias}
|
||||
|
|
|
@ -32,6 +32,9 @@ interface IRepositoryListItemProps {
|
|||
/** Called when the repository should be shown in Finder/Explorer/File Manager. */
|
||||
readonly onShowRepository: (repository: Repositoryish) => void
|
||||
|
||||
/** Called when the repository should be opened on GitHub in the default web browser. */
|
||||
readonly onViewOnGitHub: (repository: Repositoryish) => void
|
||||
|
||||
/** Called when the repository should be shown in the shell. */
|
||||
readonly onOpenInShell: (repository: Repositoryish) => void
|
||||
|
||||
|
@ -153,6 +156,8 @@ export class RepositoryListItem extends React.Component<
|
|||
|
||||
const repository = this.props.repository
|
||||
const missing = repository instanceof Repository && repository.missing
|
||||
const github =
|
||||
repository instanceof Repository && repository.gitHubRepository != null
|
||||
const openInExternalEditor = this.props.externalEditorLabel
|
||||
? `Open in ${this.props.externalEditorLabel}`
|
||||
: DefaultEditorLabel
|
||||
|
@ -164,6 +169,11 @@ export class RepositoryListItem extends React.Component<
|
|||
action: this.copyToClipboard,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'View on GitHub',
|
||||
action: this.viewOnGitHub,
|
||||
enabled: github,
|
||||
},
|
||||
{
|
||||
label: `Open in ${this.props.shellLabel}`,
|
||||
action: this.openInShell,
|
||||
|
@ -224,6 +234,10 @@ export class RepositoryListItem extends React.Component<
|
|||
this.props.onShowRepository(this.props.repository)
|
||||
}
|
||||
|
||||
private viewOnGitHub = () => {
|
||||
this.props.onViewOnGitHub(this.props.repository)
|
||||
}
|
||||
|
||||
private openInShell = () => {
|
||||
this.props.onOpenInShell(this.props.repository)
|
||||
}
|
||||
|
|
|
@ -56,6 +56,9 @@ interface IBranchDropdownProps {
|
|||
readonly shouldNudge: boolean
|
||||
|
||||
readonly showCIStatusPopover: boolean
|
||||
|
||||
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
|
||||
readonly emoji: Map<string, string>
|
||||
}
|
||||
interface IBranchDropdownState {
|
||||
readonly badgeBottom: number
|
||||
|
@ -94,6 +97,7 @@ export class BranchDropdown extends React.Component<
|
|||
pullRequests={this.props.pullRequests}
|
||||
currentPullRequest={this.props.currentPullRequest}
|
||||
isLoadingPullRequests={this.props.isLoadingPullRequests}
|
||||
emoji={this.props.emoji}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,16 +2,16 @@ import * as React from 'react'
|
|||
import { Dialog, DialogContent, DialogFooter } from '../dialog'
|
||||
import { Dispatcher } from '../dispatcher'
|
||||
import { Ref } from '../lib/ref'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { RepositoryWithGitHubRepository } from '../../models/repository'
|
||||
import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group'
|
||||
import { getDotComAPIEndpoint } from '../../lib/api'
|
||||
|
||||
const okButtonText = __DARWIN__ ? 'Continue in Browser' : 'Continue in browser'
|
||||
|
||||
interface IWorkflowPushRejectedDialogProps {
|
||||
readonly rejectedPath: string
|
||||
readonly repository: Repository
|
||||
readonly repository: RepositoryWithGitHubRepository
|
||||
readonly dispatcher: Dispatcher
|
||||
|
||||
readonly onDismissed: () => void
|
||||
}
|
||||
interface IWorkflowPushRejectedDialogState {
|
||||
|
@ -62,9 +62,19 @@ export class WorkflowPushRejectedDialog extends React.Component<
|
|||
private onSignIn = async () => {
|
||||
this.setState({ loading: true })
|
||||
|
||||
await this.props.dispatcher.requestBrowserAuthenticationToDotcom()
|
||||
const { repository, dispatcher } = this.props
|
||||
const { endpoint } = repository.gitHubRepository
|
||||
|
||||
this.props.dispatcher.push(this.props.repository)
|
||||
if (endpoint === getDotComAPIEndpoint()) {
|
||||
await dispatcher.beginDotComSignIn()
|
||||
} else {
|
||||
await dispatcher.beginEnterpriseSignIn()
|
||||
await dispatcher.setSignInEndpoint(endpoint)
|
||||
}
|
||||
|
||||
await dispatcher.requestBrowserAuthentication()
|
||||
|
||||
dispatcher.push(repository)
|
||||
this.props.onDismissed()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -295,7 +295,9 @@ changes from the original, just a note that this will not match 1-1)
|
|||
.markdown-body .emoji {
|
||||
max-width: none;
|
||||
vertical-align: text-top;
|
||||
background-color: transparent
|
||||
background-color: transparent;
|
||||
height: 1em;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.markdown-body span.frame {
|
||||
|
|
|
@ -4,10 +4,9 @@
|
|||
|
||||
.pull-request-quick-view-contents {
|
||||
background-color: var(--background-color);
|
||||
margin: 0 var(--spacing-double);
|
||||
margin: 0 var(--spacing);
|
||||
min-width: 400px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
|
@ -54,4 +53,30 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pull-request-pointer {
|
||||
position: absolute;
|
||||
top: 23px;
|
||||
left: -6px;
|
||||
pointer-events: none;
|
||||
margin-top: -7px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&::before {
|
||||
border: 8px solid transparent;
|
||||
border-right-color: var(--box-border-color);
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: 7px solid transparent;
|
||||
border-right-color: var(--background-color);
|
||||
left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@import '../../mixins';
|
||||
|
||||
/** A React component holding the commit message entry */
|
||||
#commit-message {
|
||||
.commit-message-component {
|
||||
border-top: 1px solid var(--box-border-color);
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@import '../../mixins';
|
||||
|
||||
#commit-warning {
|
||||
.commit-warning-component {
|
||||
border-top: 1px solid var(--box-border-color);
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
|
|
@ -2,7 +2,18 @@ dialog#commit-message-dialog {
|
|||
.dialog-content {
|
||||
padding: 0;
|
||||
}
|
||||
#commit-message {
|
||||
|
||||
.commit-message-component,
|
||||
.commit-warning-component {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.commit-warning-component {
|
||||
.warning-icon-container {
|
||||
.warning-icon,
|
||||
.information-icon {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
116
app/test/unit/endpoint-capabilities-test.ts
Normal file
116
app/test/unit/endpoint-capabilities-test.ts
Normal file
|
@ -0,0 +1,116 @@
|
|||
import {
|
||||
endpointSatisfies,
|
||||
VersionConstraint,
|
||||
} from '../../src/lib/endpoint-capabilities'
|
||||
import { SemVer, parse } from 'semver'
|
||||
import { getDotComAPIEndpoint } from '../../src/lib/api'
|
||||
import { forceUnwrap } from '../../src/lib/fatal-error'
|
||||
|
||||
describe('endpoint-capabilities', () => {
|
||||
describe('endpointSatisfies', () => {
|
||||
it('recognizes github.com', () => {
|
||||
expect(testDotCom(true)).toBeTrue()
|
||||
expect(testDotCom(false)).toBeFalse()
|
||||
})
|
||||
|
||||
it('recognizes GHES', () => {
|
||||
expect(testGHES(false)).toBeFalse()
|
||||
expect(testGHES(true)).toBeTrue()
|
||||
})
|
||||
|
||||
it('recognizes GHAE', () => {
|
||||
expect(testGHAE(false)).toBeFalse()
|
||||
expect(testGHAE(true)).toBeTrue()
|
||||
})
|
||||
|
||||
// GHAE doesn't advertise the installed version so we'll assume its
|
||||
// capabilities match that of a recent supported version of GHES. This is
|
||||
// defined in the `assumedGHAEVersion` constant in endpoint-capabilities.ts
|
||||
// and needs to be updated periodically.
|
||||
it('assumes GHAE versions', () => {
|
||||
expect(testGHAE('>= 3.2.1')).toBeFalse()
|
||||
expect(testGHAE('>= 3.2.0')).toBeTrue()
|
||||
})
|
||||
|
||||
// If we can't determine the actual version of a GitHub Enterprise Server
|
||||
// instance we'll assume it's running the oldest still supported version
|
||||
// of GHES. This is defined in the `assumedGHESVersion` constant in
|
||||
// endpoint-capabilities.ts and needs to be updated periodically.
|
||||
it('assumes GHES versions', () => {
|
||||
expect(testGHES('>= 3.1.1')).toBeFalse()
|
||||
expect(testGHES('>= 3.1.0')).toBeTrue()
|
||||
})
|
||||
|
||||
it('parses semver ranges', () => {
|
||||
expect(testGHES('>= 1', '1.0.0')).toBeTrue()
|
||||
expect(testGHES('> 1.0.0', '1.0.0')).toBeFalse()
|
||||
expect(testGHES('> 0.9.9', '1.0.0')).toBeTrue()
|
||||
})
|
||||
|
||||
it('deals with common cases (smoketest)', () => {
|
||||
expect(
|
||||
testEndpoint('https://api.github.com', {
|
||||
dotcom: true,
|
||||
ae: '>= 3.0.0',
|
||||
es: '>= 3.0.0',
|
||||
})
|
||||
).toBeTrue()
|
||||
|
||||
expect(
|
||||
testEndpoint(
|
||||
'https://ghe.io',
|
||||
{
|
||||
dotcom: false,
|
||||
ae: '>= 4.0.0',
|
||||
es: '>= 3.1.0',
|
||||
},
|
||||
'3.1.0'
|
||||
)
|
||||
).toBeTrue()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function testDotCom(
|
||||
constraint: boolean,
|
||||
endpointVersion: string | SemVer | null = null
|
||||
) {
|
||||
return testEndpoint(
|
||||
getDotComAPIEndpoint(),
|
||||
{ dotcom: constraint, ae: false, es: false },
|
||||
endpointVersion
|
||||
)
|
||||
}
|
||||
|
||||
function testGHES(
|
||||
constraint: boolean | string,
|
||||
endpointVersion: string | SemVer | null = null
|
||||
) {
|
||||
return testEndpoint(
|
||||
'https://ghe.io',
|
||||
{ dotcom: false, ae: false, es: constraint },
|
||||
endpointVersion
|
||||
)
|
||||
}
|
||||
|
||||
function testGHAE(
|
||||
constraint: boolean | string,
|
||||
endpointVersion: string | SemVer | null = null
|
||||
) {
|
||||
return testEndpoint(
|
||||
'https://corp.ghe.com',
|
||||
{ dotcom: false, ae: constraint, es: false },
|
||||
endpointVersion
|
||||
)
|
||||
}
|
||||
|
||||
function testEndpoint(
|
||||
endpoint: string,
|
||||
constraint: VersionConstraint,
|
||||
endpointVersion: string | SemVer | null = null
|
||||
) {
|
||||
const version = endpointVersion
|
||||
? forceUnwrap(`Couldn't parse endpoint version`, parse(endpointVersion))
|
||||
: null
|
||||
return endpointSatisfies(constraint, () => version)(endpoint)
|
||||
}
|
Loading…
Reference in a new issue