Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(WEB-7151): Typescript check: Allow typed JS #169

Merged
merged 2 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ documentation](https://github.com/actions/toolkit/blob/master/README.md#packages
for the various packages.

We have a wrapper for `@actions/github` in
`.src/infrasctructure/github` that's meant to hold handy helper
methods for accesing data from GitHub that the action might care about
`.src/infrastructure/github` that's meant to hold handy helper
methods for accessing data from GitHub that the action might care about
(e.g. info about the PR that triggered the action). They also provide
an extra abstraction layer that is easier to mock in your tests than
pure Octokit. If you need more info from GitHub in your check,
Expand Down
51 changes: 39 additions & 12 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions src/checks/requiredTypeScript.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mocked } from 'ts-jest/utils'

import {
Content,
github,
PullRequestFiles,
PullsGetResponse,
Expand Down Expand Up @@ -44,6 +45,10 @@ describe('Required TypeScript check', () => {
mockGithub.getPullRequest.mockResolvedValue(
pullRequestResponse as PullsGetResponse
)

mockGithub.downloadContent.mockResolvedValue({
content: 'some js/ts content\nsome more js/ts content',
} as Content)
})

it('returns true for PR without JS changes and good tsconfig', async () => {
Expand Down Expand Up @@ -80,6 +85,28 @@ describe('Required TypeScript check', () => {
await expect(requiredTypeScript.run()).resolves.toBeTruthy()
})

it('returns true PR with JS+TS changes and good tsconfig', async () => {
pullRequestResponse.user.login = 'regular-user'
mockGithub.getPullRequestFiles.mockResolvedValue([
{ filename: 'filename.js', additions: 100, deletions: 20 },
] as PullRequestFiles)
mockGithub.downloadContent.mockResolvedValue({
content: '// @ts-check\nsome js/ts content\nsome more js/ts content',
} as Content)

mockGlob.mockResolvedValue(['tsconfig.json'])
mockReadFile.mockReturnValue(
JSON.stringify({
compilerOptions: {
allowUnreachableCode: false,
noImplicitAny: true,
},
})
)

await expect(requiredTypeScript.run()).resolves.toBeTruthy()
})

it('throws error for PR with JS changes but good tsconfig', async () => {
pullRequestResponse.user.login = 'regular-user'
mockGithub.getPullRequestFiles.mockResolvedValue([
Expand Down Expand Up @@ -366,6 +393,15 @@ describe('isForbiddenJSFile', () => {
expect(isForbiddenJSFile(filename)).toBeFalsy()
})

it.each(['filename.js', 'component.jsx'])(
'it returns false with JS/JSX file with TS checks enabled [%s]',
(filename) => {
expect(
isForbiddenJSFile(filename, '// @ts-check\nsome-js-content')
).toBeFalsy()
}
)

it.each([
'config.toml',
'manifest.yaml',
Expand Down Expand Up @@ -402,6 +438,32 @@ describe('measureTsAdoption', () => {
measureTsAdoption().then((x) => formatAdoptionPercentage(x))
).resolves.toBe('66.7%')
})

it('correctly calculates and formats adoption % with some typed JS files', async () => {
mockGlob.mockImplementation(async ({ patterns }) => {
if (patterns[0].includes('js')) {
return ['file.js', 'file-2.js']
} else if (patterns[0].includes('ts')) {
return ['file.ts']
}
return []
})
mockReadFile.mockImplementation((path) => {
switch (path) {
case 'file.js':
return 'this\nis a\njs\nfile'
case 'file-2.js':
return '// @ts-check\nthis\nis a\njs\nfile'
case 'file.ts':
return 'this\nis a\nlonger\nts\nfile\nso it has\nmore\nadoption'
}
return ''
})

await expect(
measureTsAdoption().then((x) => formatAdoptionPercentage(x))
).resolves.toBe('76.5%')
})
})

describe('missingTsConfigSettings', () => {
Expand Down
62 changes: 50 additions & 12 deletions src/checks/requiredTypeScript.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import * as core from '@actions/core'
import * as CommentedJSON from 'comment-json'

import { WebhookEventMap } from '@octokit/webhooks-types'
import ignore, { Ignore as IgnoredFileFilter } from 'ignore'

import { github } from '../infrastructure/github'
import * as fs from '../infrastructure/fs'
import { isBot } from '../conditions/triggeredByBot'

import Check from './check'
import ignore, { Ignore as IgnoredFileFilter } from 'ignore'

const JS_TS_CHECK_COMMENT = '// @ts-check'
trapped marked this conversation as resolved.
Show resolved Hide resolved

type TsConfig = {
compilerOptions?: {
Expand Down Expand Up @@ -39,6 +40,21 @@ const requiredTypeScript: Check = {
}
export default requiredTypeScript

const getFileContent = async (filename: string) => {
try {
const response = await github.downloadContent(filename, github.context.ref)
if (!('content' in response)) {
throw new Error('No content in response')
}

return response.encoding === 'base64'
? Buffer.from(response.content, 'base64').toString()
: response.content
} catch (_) {
return ''
}
}

async function checkPullRequest(): Promise<boolean> {
const pullPayload = github.context.payload as WebhookEventMap['pull_request']
const pr = await github.getPullRequest(pullPayload.pull_request.number)
Expand All @@ -59,12 +75,23 @@ async function checkPullRequest(): Promise<boolean> {
const filter = getIgnoreFilter()

const files = await github.getPullRequestFiles(pr.number)
const jsFiles = files.filter((f) => isForbiddenJSFile(f.filename, filter))
const renamedJsFiles = files.filter(
const filesWithContent = await Promise.all(
files.map((f) =>
(async () => {
const fileContent = await getFileContent(f.filename)
return { ...f, fileContent }
})()
)
)
trapped marked this conversation as resolved.
Show resolved Hide resolved
const forbiddenJsFiles = filesWithContent.filter((f, index) =>
isForbiddenJSFile(f.filename, f.fileContent, filter)
)

const renamedJsFiles = filesWithContent.filter(
(f) =>
f.previous_filename &&
isForbiddenJSFile(f.previous_filename) &&
!jsFiles.includes(f)
!forbiddenJsFiles.includes(f)
)
const tsconfigFiles = await fs.glob({
patterns: ['**/tsconfig.json'],
Expand All @@ -73,14 +100,14 @@ async function checkPullRequest(): Promise<boolean> {

const errors: Error[] = []

if (jsFiles.length || tsconfigFiles.length) {
if (forbiddenJsFiles.length || tsconfigFiles.length) {
errors.push(
...(await checkJsUsage(jsFiles)),
...(await checkJsUsage(forbiddenJsFiles)),
...(await checkTsConfig(tsconfigFiles))
)
}

if (jsFiles.length || renamedJsFiles.length || errors.length > 0) {
if (forbiddenJsFiles.length || renamedJsFiles.length || errors.length > 0) {
const adoption = await measureTsAdoption(filter)
const commentId = await github.pinComment(
pr.number,
Expand Down Expand Up @@ -151,19 +178,26 @@ export async function measureTsAdoption(
patterns: ['**/*.js', '**/*.jsx'],
exclude: filter,
})
const typedJsFiles = jsFiles.filter((file) => {
const fileContent = fs.readFile(file)
return fileContent.startsWith(JS_TS_CHECK_COMMENT)
})
const tsFiles = await fs.glob({
patterns: ['**/*.ts', '**/*.tsx'],
exclude: filter,
})

const jsLines = jsFiles
const untypedFiles = jsFiles.filter((f) => !typedJsFiles.includes(f))
const typedFiles = [...tsFiles, ...typedJsFiles]

const untypedLines = untypedFiles
.map((f) => fs.readFile(f).split('\n').length)
.reduce((total, lines) => total + lines, 0)
const tsLines = tsFiles
const typedLines = typedFiles
.map((f) => fs.readFile(f).split('\n').length)
.reduce((total, lines) => total + lines, 0)

return tsLines / (jsLines + tsLines)
return typedLines / (untypedLines + typedLines)
}

async function checkTsConfig(files: string[]): Promise<Error[]> {
Expand Down Expand Up @@ -201,11 +235,15 @@ async function checkTsConfig(files: string[]): Promise<Error[]> {

export function isForbiddenJSFile(
filename: string,
fileContent = '',
filter: IgnoredFileFilter = getIgnoreFilter()
): boolean {
const jsPattern = /\.jsx?$/i
const hasJsExtension = jsPattern.test(filename) && !filter.ignores(filename)
const appliesTypescriptViaComment =
fileContent.startsWith(JS_TS_CHECK_COMMENT)

return jsPattern.test(filename) && !filter.ignores(filename)
return hasJsExtension && !appliesTypescriptViaComment
}

export function missingTsConfigSettings(tsconfig: TsConfig): string[] {
Expand Down
Loading