├── .nvmrc ├── .prettierrc.json ├── src ├── core │ ├── defaults.ts │ ├── types.ts │ ├── format │ │ ├── defaults.ts │ │ ├── types.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── __snapshots__ │ │ │ └── pretty-print.test.ts.snap │ │ ├── pretty-print.ts │ │ ├── pretty-print.test.ts │ │ ├── template.ts │ │ ├── index.test.ts │ │ ├── template.test.ts │ │ └── helpers.test.ts │ ├── adapters │ │ ├── utils.ts │ │ ├── youtrack.ts │ │ ├── clickup.ts │ │ ├── dom-helpers.ts │ │ ├── linear.ts │ │ ├── linear.test.ts │ │ ├── plane.ts │ │ ├── plane.test.ts │ │ ├── polarion.ts │ │ ├── clickup.test.ts │ │ ├── trello.ts │ │ ├── gitlab.ts │ │ ├── youtrack.test.ts │ │ ├── tara.ts │ │ ├── trello.test.ts │ │ ├── github.ts │ │ ├── jira-cloud.ts │ │ ├── notion.ts │ │ ├── jira-server.ts │ │ ├── polarion.test.ts │ │ ├── github.test.ts │ │ ├── tara.test.ts │ │ ├── gitlab.test.ts │ │ ├── notion.test.ts │ │ ├── jira-cloud.test.ts │ │ └── jira-server.test.ts │ ├── client.ts │ ├── enhance.ts │ ├── __snapshots__ │ │ └── client.test.ts.snap │ ├── enhance.test.ts │ ├── client.test.ts │ ├── search.test.ts │ └── search.ts ├── options │ ├── styles │ │ ├── _settings.scss │ │ └── _additions.scss │ ├── index.scss │ ├── index.html │ ├── index.tsx │ └── components │ │ ├── checkbox-input.tsx │ │ ├── example.ts │ │ ├── checkbox-input.test.tsx │ │ ├── template-input.tsx │ │ ├── form.test.tsx │ │ └── form.tsx ├── icons │ ├── icon-128.png │ ├── icon-16.png │ ├── icon-32.png │ ├── icon-48.png │ ├── icon-64.png │ ├── icon-light-128.png │ ├── icon-light-16.png │ ├── icon-light-32.png │ ├── icon-light-48.png │ ├── icon-light-64.png │ ├── icon.svg │ └── icon-light.svg ├── popup │ ├── observe-media.ts │ ├── components │ │ ├── content.tsx │ │ ├── __snapshots__ │ │ │ ├── error-details.test.tsx.snap │ │ │ ├── no-tickets.test.tsx.snap │ │ │ └── about.test.tsx.snap │ │ ├── navbar.tsx │ │ ├── external-link.tsx │ │ ├── navbar.test.tsx │ │ ├── content.test.tsx │ │ ├── error-details.tsx │ │ ├── about.test.tsx │ │ ├── no-tickets.test.tsx │ │ ├── error-details.test.tsx │ │ ├── external-link.test.tsx │ │ ├── copy-button.tsx │ │ ├── tool.tsx │ │ ├── no-tickets.tsx │ │ ├── about.tsx │ │ ├── copy-button.test.tsx │ │ ├── ticket-controls.test.tsx │ │ ├── ticket-controls.tsx │ │ └── tool.test.tsx │ ├── styles │ │ ├── _settings.scss │ │ ├── _spinner.scss │ │ ├── _overrides.scss │ │ └── _additions.scss │ ├── types.ts │ ├── index.html │ ├── hooks │ │ ├── use-input.ts │ │ └── use-input.test.ts │ ├── render.tsx │ ├── index.scss │ ├── index.ts │ └── index.test.ts ├── polyfills.ts ├── store.ts ├── types.ts ├── content │ └── index.ts ├── errors.ts ├── additional.d.ts ├── background │ └── index.ts └── manifest.json ├── __mocks__ └── webextension-polyfill.ts ├── safari ├── tickety-tick │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── icon-128.png │ │ │ ├── icon-16.png │ │ │ ├── icon-32.png │ │ │ ├── icon-64.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── tickety_tick.entitlements │ ├── AppDelegate.swift │ ├── Info.plist │ └── ViewController.swift ├── tickety-tick.xcodeproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── IDETemplateMacros.plist ├── .swiftformat ├── tickety-tick Extension │ ├── tickety_tick_Extension.entitlements │ ├── SafariWebExtensionHandler.swift │ └── Info.plist └── .gitignore ├── screenshots ├── interface.png ├── opera │ ├── step1.png │ ├── step2.png │ └── step3.png └── firefox-preferences.png ├── .browserslistrc ├── script ├── version ├── lib │ └── utils ├── check-release-dependencies ├── extract-cert-ou ├── prepare-release ├── xcodebuild ├── build-icons ├── release └── open-in-chrome.mjs ├── test ├── setup.js ├── router.ts ├── factories.ts └── transforms │ └── file.js ├── babel.config.js ├── .stylelintrc.json ├── .gitignore ├── tsconfig.json ├── .editorconfig ├── postcss.config.js ├── jest.config.js ├── LICENSE ├── SAFARI.md ├── .eslintrc.json ├── .circleci └── config.yml ├── webpack.config.ts └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 24.10.0 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/core/defaults.ts: -------------------------------------------------------------------------------- 1 | export default { type: "feature" }; 2 | -------------------------------------------------------------------------------- /src/options/styles/_settings.scss: -------------------------------------------------------------------------------- 1 | // Typography 2 | 3 | $font-size-base: 0.85rem; 4 | -------------------------------------------------------------------------------- /__mocks__/webextension-polyfill.ts: -------------------------------------------------------------------------------- 1 | export default { runtime: { openOptionsPage: jest.fn() } }; 2 | -------------------------------------------------------------------------------- /safari/tickety-tick/Assets.xcassets/AppIcon.appiconset/icon-128.png: -------------------------------------------------------------------------------- 1 | ../../../../src/icons/icon-128.png -------------------------------------------------------------------------------- /safari/tickety-tick/Assets.xcassets/AppIcon.appiconset/icon-16.png: -------------------------------------------------------------------------------- 1 | ../../../../src/icons/icon-16.png -------------------------------------------------------------------------------- /safari/tickety-tick/Assets.xcassets/AppIcon.appiconset/icon-32.png: -------------------------------------------------------------------------------- 1 | ../../../../src/icons/icon-32.png -------------------------------------------------------------------------------- /safari/tickety-tick/Assets.xcassets/AppIcon.appiconset/icon-64.png: -------------------------------------------------------------------------------- 1 | ../../../../src/icons/icon-64.png -------------------------------------------------------------------------------- /src/icons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-128.png -------------------------------------------------------------------------------- /src/icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-16.png -------------------------------------------------------------------------------- /src/icons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-32.png -------------------------------------------------------------------------------- /src/icons/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-48.png -------------------------------------------------------------------------------- /src/icons/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-64.png -------------------------------------------------------------------------------- /screenshots/interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/screenshots/interface.png -------------------------------------------------------------------------------- /screenshots/opera/step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/screenshots/opera/step1.png -------------------------------------------------------------------------------- /screenshots/opera/step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/screenshots/opera/step2.png -------------------------------------------------------------------------------- /screenshots/opera/step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/screenshots/opera/step3.png -------------------------------------------------------------------------------- /src/icons/icon-light-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-light-128.png -------------------------------------------------------------------------------- /src/icons/icon-light-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-light-16.png -------------------------------------------------------------------------------- /src/icons/icon-light-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-light-32.png -------------------------------------------------------------------------------- /src/icons/icon-light-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-light-48.png -------------------------------------------------------------------------------- /src/icons/icon-light-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/src/icons/icon-light-64.png -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # browsers we support 2 | 3 | last 2 chrome versions 4 | last 2 firefox versions 5 | last 2 opera versions 6 | -------------------------------------------------------------------------------- /screenshots/firefox-preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrowd/tickety-tick/main/screenshots/firefox-preferences.png -------------------------------------------------------------------------------- /script/version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { version } = require("../package.json"); 4 | 5 | console.log(version); 6 | -------------------------------------------------------------------------------- /safari/tickety-tick/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | 3 | import { TextEncoder } from "util"; 4 | 5 | Object.assign(global, { TextEncoder }); 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript", 5 | "@babel/preset-react", 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /src/options/index.scss: -------------------------------------------------------------------------------- 1 | // Customization 2 | @import "styles/settings"; 3 | 4 | // Bootstrap 5 | @import "~bootstrap"; 6 | 7 | // Options style 8 | @import "styles/additions"; 9 | -------------------------------------------------------------------------------- /test/router.ts: -------------------------------------------------------------------------------- 1 | const { MemoryRouter } = jest.requireActual("react-router"); 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const wrapper = MemoryRouter; 5 | -------------------------------------------------------------------------------- /safari/tickety-tick.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /script/lib/utils: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # A collection of helpers for our scripts. 4 | 5 | function has() { 6 | type "$1" >/dev/null 2>&1 7 | } 8 | 9 | function abort() { 10 | printf "${1}\n">&2 11 | exit 1 12 | } 13 | -------------------------------------------------------------------------------- /safari/tickety-tick/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /safari/.swiftformat: -------------------------------------------------------------------------------- 1 | # File options 2 | --exclude .build,.git,fastlane,node_modules,xcuserdata 3 | 4 | # Language options 5 | --swiftversion 5.4 6 | 7 | # Rules 8 | --enable isEmpty 9 | 10 | # Format options 11 | --indent 2 12 | --maxwidth 100 13 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { Ticket } from "../types"; 2 | import type defaults from "./defaults"; 3 | 4 | export type TicketData = Omit & Partial; 5 | 6 | export type Adapter = (url: URL, document: Document) => Promise; 7 | -------------------------------------------------------------------------------- /src/popup/observe-media.ts: -------------------------------------------------------------------------------- 1 | export default function observe( 2 | specifier: string, 3 | fn: (_: boolean) => void, 4 | ): void { 5 | const query = window.matchMedia(specifier); 6 | query.addEventListener("change", ({ matches }) => fn(matches)); 7 | fn(query.matches); 8 | } 9 | -------------------------------------------------------------------------------- /src/popup/components/content.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type Props = { children: React.ReactNode }; 4 | 5 | function Content({ children }: Props) { 6 | return
{children}
; 7 | } 8 | 9 | export default Content; 10 | -------------------------------------------------------------------------------- /src/popup/styles/_settings.scss: -------------------------------------------------------------------------------- 1 | // Typography 2 | 3 | $font-size-base: 0.75rem; 4 | 5 | // Color 6 | 7 | $primary: #c32f34; 8 | 9 | // Nav & Navbar 10 | 11 | $navbar-brand-font-size: $font-size-base; 12 | $nav-link-padding-y: 0; 13 | $navbar-nav-link-padding-x: 0.25rem; 14 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Options 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/core/format/defaults.ts: -------------------------------------------------------------------------------- 1 | export const branch = "{type | slugify}/{id | slugify}-{title | slugify}"; 2 | 3 | export const commit = "[#{id}] {title}\n\n{description}\n\n{url}"; 4 | 5 | export const command = 6 | "git checkout -b {branch | shellquote} && git commit --allow-empty -m {commit | shellquote}"; 7 | -------------------------------------------------------------------------------- /src/options/styles/_additions.scss: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0.5rem; 3 | } 4 | 5 | // Input checkbox on the options page 6 | // 7 | // Firefox glitches if we don't set the margin manually. 8 | .input-checkbox { 9 | margin: 0 2px !important; 10 | } 11 | 12 | .mw-100ch { 13 | max-width: 100ch; 14 | } 15 | -------------------------------------------------------------------------------- /src/core/adapters/utils.ts: -------------------------------------------------------------------------------- 1 | import type { TicketData } from "../types"; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export function hasRequiredDetails( 5 | obj: Record, 6 | ): obj is TicketData { 7 | return typeof obj.id === "string" && typeof obj.title === "string"; 8 | } 9 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import { Headers as HeadersPolyfill } from "headers-polyfill"; 2 | 3 | // NOTE: With manifest v3, Firefox does not have an up to date implementation of 4 | // the `Headers` class. This breaks our HTTP client based on `ky`. 5 | // Therefore we use a polyfill for this: 6 | globalThis.Headers = HeadersPolyfill; 7 | -------------------------------------------------------------------------------- /safari/tickety-tick.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard-scss", 3 | "plugins": ["stylelint-prettier"], 4 | "rules": { 5 | "prettier/prettier": true, 6 | "scss/comment-no-empty": null, 7 | "value-keyword-case": [ 8 | "lower", 9 | { 10 | "camelCaseSvgKeywords": true 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /script/check-release-dependencies: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | SCRIPTDIR=$(cd "$(dirname "$0")"; pwd) 8 | # shellcheck source=SCRIPTDIR/lib/utils 9 | source "$SCRIPTDIR/lib/utils" 10 | 11 | if ! has gh; then 12 | abort "Please install GitHub CLI first (https://cli.github.com)." 13 | fi 14 | -------------------------------------------------------------------------------- /src/popup/types.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorObject } from "../errors"; 2 | import type { Ticket } from "../types"; 3 | 4 | export type BackgroundPage = Window & { 5 | getTickets: () => { tickets: Ticket[]; errors: ErrorObject[] }; 6 | }; 7 | 8 | export type BackgroundWorker = { 9 | getTickets: () => { tickets: Ticket[]; errors: ErrorObject[] }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/popup/components/__snapshots__/error-details.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`error-details renders a button to copy the error details 1`] = ` 4 | "Tickety-Tick revision: test-commit-hash 5 | 6 | \`\`\` 7 | TestError: Boom! 8 | @test it code:21:3 9 | @test anonymous code:22:19 10 | \`\`\`" 11 | `; 12 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | // Store preferences in synced storage if available, use local as a fallback: 4 | // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/sync 5 | // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/local 6 | export default browser.storage.sync || browser.storage.local; 7 | -------------------------------------------------------------------------------- /safari/tickety-tick.xcodeproj/project.xcworkspace/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // ___FILENAME___ 8 | // 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | npm-debug.log* 3 | yarn-debug.log* 4 | yarn-error.log* 5 | 6 | # Dependency directories 7 | node_modules/ 8 | 9 | # Optional npm cache directory 10 | .npm/ 11 | 12 | # Optional eslint cache 13 | .eslintcache/ 14 | 15 | # Build output 16 | dist/ 17 | 18 | # Test coverage reports 19 | coverage/ 20 | 21 | # release artifacts 22 | release-artifacts/ 23 | -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import "./index.scss"; 2 | 3 | import React from "react"; 4 | import { createRoot } from "react-dom/client"; 5 | 6 | import store from "../store"; 7 | import Form from "./components/form"; 8 | 9 | const domNode = document.getElementById("options-root"); 10 | const root = createRoot(domNode!); 11 | const element =
; 12 | root.render(element); 13 | -------------------------------------------------------------------------------- /src/popup/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type Props = { children: React.ReactNode }; 4 | 5 | function Navbar({ children }: Props) { 6 | return ( 7 |
8 |
{children}
9 |
10 | ); 11 | } 12 | 13 | export default Navbar; 14 | -------------------------------------------------------------------------------- /safari/tickety-tick/tickety_tick.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /safari/tickety-tick Extension/tickety_tick_Extension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/popup/components/external-link.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type Props = React.AnchorHTMLAttributes & { 4 | href: string; 5 | }; 6 | 7 | function ExternalLink({ children, href, ...other }: Props) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | 15 | export default ExternalLink; 16 | -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tickety-Tick 6 | 7 | 8 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "jsx": "react", 7 | "module": "es6", 8 | "moduleResolution": "node", 9 | "noImplicitAny": true, 10 | "outDir": "./dist/", 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "target": "es5" 14 | }, 15 | "include": ["./src/**/*", "./test/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /src/popup/hooks/use-input.ts: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useState } from "react"; 3 | 4 | type Event = React.ChangeEvent; 5 | 6 | export default function useInput(initialValue?: string) { 7 | const [value, setValue] = useState(initialValue); 8 | 9 | function onChange(event: Event) { 10 | return setValue(event.target.value); 11 | } 12 | 13 | return { value, onChange }; 14 | } 15 | -------------------------------------------------------------------------------- /script/extract-cert-ou: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | if [ $# -lt 1 ]; then 8 | echo "usage: $(basename "$0") " 9 | exit 1 10 | fi 11 | 12 | certname="$1" 13 | 14 | exec security find-certificate -a -c "$certname" -p \ 15 | | keytool -printcert -J-Duser.language=en \ 16 | | grep 'Owner: ' \ 17 | | head -n 1 \ 18 | | sed -n -e 's/^.*OU=\([^:space:,]*\).*$/\1/p' 19 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Ticket = { 2 | id: string; 3 | type: string; 4 | title: string; 5 | description?: string; 6 | url?: string; 7 | }; 8 | 9 | export type Fmt = { 10 | commit: string; 11 | branch: string; 12 | command: string; 13 | }; 14 | 15 | export type TicketWithFmt = Ticket & { fmt: Fmt }; 16 | 17 | export type BackgroundMessage = { 18 | getTickets?: boolean; 19 | }; 20 | 21 | export type ContentMessage = { 22 | tickets?: boolean; 23 | }; 24 | -------------------------------------------------------------------------------- /script/prepare-release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | ./script/check-release-dependencies 10 | 11 | branch="chore/prepare-release-$(git rev-parse HEAD)" 12 | git checkout -b "$branch" 13 | 14 | yarn version 15 | 16 | tag=$(git describe --abbrev=0 --tags) 17 | 18 | git tag --delete "$tag" 19 | 20 | gh pr create \ 21 | --title "Prepare $tag" \ 22 | --body "Bump version to $tag." 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs. 3 | # 4 | # Some of these options are also respected by Prettier. 5 | # 6 | # https://editorconfig.org 7 | 8 | root = true 9 | 10 | [*] 11 | charset = utf-8 12 | end_of_line = lf 13 | indent_size = 2 14 | indent_style = space 15 | insert_final_newline = true 16 | trim_trailing_whitespace = true 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /src/content/index.ts: -------------------------------------------------------------------------------- 1 | import "../polyfills"; 2 | 3 | import browser from "webextension-polyfill"; 4 | 5 | import stdsearch from "../core/search"; 6 | import type { ContentMessage } from "../types"; 7 | 8 | if (window === window.top) { 9 | browser.runtime.onMessage.addListener((message: unknown) => { 10 | if ((message as ContentMessage).tickets) { 11 | const url = new URL(window.location.toString()); 12 | return stdsearch(url, document); 13 | } 14 | 15 | return undefined; 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/core/format/types.ts: -------------------------------------------------------------------------------- 1 | import type { Ticket } from "../../types"; 2 | 3 | type AsyncFormatFunction = (ticket: Ticket) => Promise; 4 | type FormatFunction = (ticket: Ticket) => string; 5 | 6 | export interface Formatter { 7 | branch: FormatFunction; 8 | commit: AsyncFormatFunction; 9 | command: AsyncFormatFunction; 10 | } 11 | 12 | export type Templates = { 13 | branch: string; 14 | commit: string; 15 | command: string; 16 | }; 17 | 18 | export type Options = { 19 | autofmt: boolean; 20 | }; 21 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorObject } from "serialize-error"; 2 | import { deserializeError, serializeError } from "serialize-error"; 3 | 4 | // Make error objects JSON-serializable/deserializable so error details do not 5 | // get lost between the content script and the extension popup. 6 | 7 | export type { ErrorObject }; 8 | 9 | export const serialize = (error: Error): ErrorObject => serializeError(error); 10 | 11 | export const deserialize = (error: ErrorObject): Error => 12 | deserializeError(error); 13 | -------------------------------------------------------------------------------- /src/core/client.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from "ky"; 2 | import ky from "ky"; 3 | 4 | const credentials = "include"; 5 | 6 | const headers = { 7 | "content-type": "application/json; charset=utf-8", 8 | accept: "application/json", 9 | }; 10 | 11 | const timeout = 1000; 12 | 13 | function client(base: string, options: Partial = {}) { 14 | return ky.extend({ 15 | prefixUrl: base, 16 | credentials, 17 | headers, 18 | timeout, 19 | ...options, 20 | }); 21 | } 22 | 23 | export default client; 24 | -------------------------------------------------------------------------------- /safari/.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | 3 | ## User settings 4 | xcuserdata/ 5 | 6 | ## Compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 7 | *.xcscmblueprint 8 | *.xccheckout 9 | 10 | ## Compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 11 | build/ 12 | DerivedData/ 13 | *.moved-aside 14 | *.pbxuser 15 | !default.pbxuser 16 | *.mode1v3 17 | !default.mode1v3 18 | *.mode2v3 19 | !default.mode2v3 20 | *.perspectivev3 21 | !default.perspectivev3 22 | 23 | ## Gcc Patch 24 | /*.gcno 25 | -------------------------------------------------------------------------------- /safari/tickety-tick/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // 4 | 5 | import Cocoa 6 | 7 | @main 8 | class AppDelegate: NSObject, NSApplicationDelegate { 9 | func applicationDidFinishLaunching(_: Notification) { 10 | // Insert code here to initialize your application 11 | } 12 | 13 | func applicationWillTerminate(_: Notification) { 14 | // Insert code here to tear down your application 15 | } 16 | 17 | func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { 18 | true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/popup/components/navbar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import type { Props } from "./navbar"; 5 | import Navbar from "./navbar"; 6 | 7 | describe("navbar", () => { 8 | function subject(props: Props) { 9 | return render(); 10 | } 11 | 12 | it("renders its children", () => { 13 | const children = "navbar content"; 14 | const screen = subject({ children }); 15 | expect(screen.container).toHaveTextContent(children); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/popup/components/content.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import type { Props } from "./content"; 5 | import Content from "./content"; 6 | 7 | describe("Content", () => { 8 | function subject(props: Props) { 9 | return render(); 10 | } 11 | 12 | it("renders its children", () => { 13 | const children = "nested content"; 14 | const screen = subject({ children }); 15 | expect(screen.container).toHaveTextContent(children); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/additional.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Define type for ".svg" imports handled by "@svgr/webpack" 4 | declare module "*.svg" { 5 | export const ReactComponent: React.FunctionComponent< 6 | React.SVGProps & { title?: string } 7 | >; 8 | 9 | const src: string; 10 | export default src; 11 | } 12 | 13 | // Unfortunately there are no type definitions for "micro-match" at the moment 14 | declare module "micro-match"; 15 | 16 | // This global const is defined by the Webpack DefinePlugin 17 | declare const COMMITHASH: string; 18 | -------------------------------------------------------------------------------- /test/factories.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | 3 | import type { Ticket, TicketWithFmt } from "../src/types"; 4 | 5 | export function ticket(overrides: Partial = {}): TicketWithFmt { 6 | const defaults = { id: "1", title: "ticket title", type: "feature" }; 7 | 8 | const base = { ...defaults, ...overrides }; 9 | 10 | const branch = `branch-${base.id}`; 11 | const commit = `commit-${base.id}`; 12 | const command = `command-${base.id}`; 13 | 14 | const fmt = { branch, commit, command }; 15 | 16 | return { ...base, fmt }; 17 | } 18 | -------------------------------------------------------------------------------- /src/core/enhance.ts: -------------------------------------------------------------------------------- 1 | import type { Ticket, TicketWithFmt } from "../types"; 2 | import format from "./format"; 3 | import type { Templates } from "./format/types"; 4 | 5 | async function enhancer(templates: Partial, autofmt: boolean) { 6 | const fmt = await format(templates, autofmt); 7 | 8 | const enhance = async (ticket: Ticket): Promise => 9 | Object.assign(ticket, { 10 | fmt: { 11 | branch: await fmt.branch(ticket), 12 | commit: await fmt.commit(ticket), 13 | command: await fmt.command(ticket), 14 | }, 15 | }); 16 | 17 | return enhance; 18 | } 19 | 20 | export default enhancer; 21 | -------------------------------------------------------------------------------- /src/popup/styles/_spinner.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | margin: 2rem auto; 3 | width: 4rem; 4 | text-align: center; 5 | } 6 | 7 | .spinner > .bounce { 8 | width: 1rem; 9 | height: 1rem; 10 | margin: 0.125rem; 11 | background-color: $gray-500; 12 | border-radius: 100%; 13 | display: inline-block; 14 | animation: spinner-bouncedelay 1.4s infinite ease-in-out both; 15 | } 16 | 17 | .spinner .bounce-1 { 18 | animation-delay: -0.32s; 19 | } 20 | 21 | .spinner .bounce-2 { 22 | animation-delay: -0.16s; 23 | } 24 | 25 | @keyframes spinner-bouncedelay { 26 | 0%, 27 | 80%, 28 | 100% { 29 | transform: scale(0); 30 | } 31 | 32 | 40% { 33 | transform: scale(1); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/popup/styles/_overrides.scss: -------------------------------------------------------------------------------- 1 | .border-bottom { 2 | border-color: var(--bs-gray-400) !important; 3 | } 4 | 5 | .navbar-light .navbar-brand { 6 | color: var(--bs-body-color); 7 | 8 | &:hover, 9 | &:focus { 10 | color: var(--bs-body-color); 11 | } 12 | } 13 | 14 | .navbar-light .navbar-nav .nav-link { 15 | color: var(--bs-gray-600); 16 | 17 | &:hover, 18 | &:focus { 19 | color: var(--bs-gray-900); 20 | } 21 | } 22 | 23 | .form-control { 24 | background-color: var(--bs-body-bg); 25 | border-color: var(--bs-gray-400); 26 | color: var(--bs-gray-800); 27 | 28 | &:disabled { 29 | background-color: rgb(var(--bs-light-rgb)); 30 | color: var(--bs-gray-500); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | import { purgeCSSPlugin } from "@fullhuman/postcss-purgecss"; 2 | 3 | export default { 4 | plugins: [ 5 | [ 6 | purgeCSSPlugin({ content: ["src/**/*.{html,tsx}"] }), 7 | { 8 | contentFunction: (src) => { 9 | if (src.endsWith("src/popup/index.scss")) { 10 | return ["src/popup/**/*.{html,ts,tsx}"]; 11 | } 12 | if (src.endsWith("src/options/index.scss")) { 13 | return ["src/options/**/*.{html,ts,tsx}"]; 14 | } 15 | throw new Error(`Unexpected CSS entrypoint: ${src}`); 16 | }, 17 | }, 18 | ], 19 | ["postcss-preset-env", {}], 20 | ["cssnano", { preset: "default" }], 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | import type { BackgroundMessage, ContentMessage } from "../types"; 4 | 5 | async function getTickets() { 6 | const message: ContentMessage = { tickets: true }; 7 | const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 8 | const results = await browser.tabs.sendMessage(tab.id!, message); // eslint-disable-line @typescript-eslint/no-non-null-assertion 9 | return results; 10 | } 11 | 12 | async function handleMessage(message: unknown) { 13 | if ((message as BackgroundMessage).getTickets) { 14 | return getTickets(); 15 | } 16 | return null; 17 | } 18 | 19 | browser.runtime.onMessage.addListener(handleMessage); 20 | -------------------------------------------------------------------------------- /src/popup/components/error-details.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import CopyButton from "./copy-button"; 4 | 5 | export type Props = { errors: Error[] }; 6 | 7 | function ErrorDetails({ errors }: Props) { 8 | const preamble = `Tickety-Tick revision: ${COMMITHASH}`; 9 | 10 | const logs = errors.map((err) => 11 | ["```", err, err.stack?.trim() ?? "", "```"].join("\n"), 12 | ); 13 | 14 | const info = [preamble, ...logs].join("\n\n"); 15 | 16 | return ( 17 | 22 | Copy error details 23 | 24 | ); 25 | } 26 | 27 | export default ErrorDetails; 28 | -------------------------------------------------------------------------------- /src/popup/render.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { MemoryRouter as Router, Route, Routes } from "react-router"; 4 | 5 | import type { TicketWithFmt } from "../types"; 6 | import About from "./components/about"; 7 | import Tool from "./components/tool"; 8 | 9 | function render(tickets: TicketWithFmt[], errors: Error[]) { 10 | const container = document.getElementById("popup-root"); 11 | const root = createRoot(container!); 12 | 13 | const element = ( 14 | 15 | 16 | } /> 17 | } /> 18 | 19 | 20 | ); 21 | 22 | root.render(element); 23 | } 24 | 25 | export default render; 26 | -------------------------------------------------------------------------------- /src/popup/components/about.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import * as router from "../../../test/router"; 5 | import type { Props } from "./about"; 6 | import About from "./about"; 7 | 8 | // NOTE: This is a temporary workaround for the tests not being 9 | // fully compatible with svgr while updating to React 19. 10 | jest.mock("../../icons/icon.svg", () => ({ 11 | ReactComponent: (props: React.SVGProps) => ( 12 | icon.svg 13 | ), 14 | })); 15 | 16 | describe("about", () => { 17 | function subject(props: Props) { 18 | return render(, { wrapper: router.wrapper }); 19 | } 20 | 21 | it("renders", () => { 22 | expect(subject({}).asFragment()).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/popup/hooks/use-input.test.ts: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useState } from "react"; 3 | 4 | import useInput from "./use-input"; 5 | 6 | jest.mock("react"); 7 | 8 | describe("use-input", () => { 9 | beforeEach(() => { 10 | (useState as jest.Mock).mockReset(); 11 | }); 12 | 13 | it("receives an initial value and tracks updates", () => { 14 | const setValue = jest.fn(); 15 | (useState as jest.Mock).mockImplementation((value) => [value, setValue]); 16 | 17 | const { value, onChange } = useInput("initial"); 18 | 19 | expect(value).toBe("initial"); 20 | 21 | const event = { 22 | target: { value: "updated" }, 23 | } as React.ChangeEvent; 24 | onChange(event); 25 | 26 | expect(setValue).toHaveBeenCalledWith("updated"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/core/adapters/youtrack.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * YouTrack adapter 3 | * 4 | * The adapter uses the DOM to extract ticket information from issues. 5 | */ 6 | 7 | import type { TicketData } from "../types"; 8 | import { $has, $text } from "./dom-helpers"; 9 | import { hasRequiredDetails } from "./utils"; 10 | 11 | async function scan(url: URL, document: Document): Promise { 12 | if (!$has(".yt-issue-view", document)) return []; 13 | 14 | const id = $text(".js-issue-id", document)?.trim(); 15 | const title = $text("[data-test='issueSummary']", document); 16 | const type = 17 | $text("[data-test='Type']", document)?.toLowerCase().trim() || "feature"; 18 | 19 | const tickets = [{ id, title, type, url: url.toString() }]; 20 | 21 | return tickets.filter(hasRequiredDetails) as TicketData[]; 22 | } 23 | 24 | export default scan; 25 | -------------------------------------------------------------------------------- /safari/tickety-tick Extension/SafariWebExtensionHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariWebExtensionHandler.swift 3 | // 4 | 5 | import os.log 6 | import SafariServices 7 | 8 | let SFExtensionMessageKey = "message" 9 | 10 | class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { 11 | func beginRequest(with context: NSExtensionContext) { 12 | let item = context.inputItems[0] as! NSExtensionItem 13 | let message = item.userInfo?[SFExtensionMessageKey] 14 | 15 | os_log( 16 | .default, 17 | "Received message from browser.runtime.sendNativeMessage: %@", 18 | message as! CVarArg 19 | ) 20 | 21 | let response = NSExtensionItem() 22 | response.userInfo = [SFExtensionMessageKey: ["Response to": message]] 23 | 24 | context.completeRequest(returningItems: [response], completionHandler: nil) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/core/__snapshots__/client.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`client creates a new client with default values 1`] = ` 4 | [ 5 | [ 6 | { 7 | "credentials": "include", 8 | "headers": { 9 | "accept": "application/json", 10 | "content-type": "application/json; charset=utf-8", 11 | }, 12 | "prefixUrl": "https://example.io/api", 13 | "timeout": 1000, 14 | }, 15 | ], 16 | ] 17 | `; 18 | 19 | exports[`client sets custom options 1`] = ` 20 | [ 21 | [ 22 | { 23 | "credentials": "same-origin", 24 | "headers": { 25 | "accept": "application/json", 26 | "content-type": "application/json; charset=utf-8", 27 | }, 28 | "prefixUrl": "https://example.io/api", 29 | "timeout": 3000, 30 | }, 31 | ], 32 | ] 33 | `; 34 | -------------------------------------------------------------------------------- /script/xcodebuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | # Utility functions 10 | 11 | function usage { 12 | echo "usage: $(basename "$0") [code-sign-identity]" 13 | exit 0 14 | } 15 | 16 | # Set defaults 17 | 18 | identity="Apple Development" 19 | 20 | # Parse arguments 21 | 22 | while [ $# -gt 0 ]; do 23 | case $1 in 24 | --help|-h) 25 | usage 26 | ;; 27 | *) 28 | identity=$1 29 | break 30 | ;; 31 | esac 32 | done 33 | 34 | # Run build 35 | 36 | team=$(script/extract-cert-ou "$identity") 37 | 38 | exec xcodebuild \ 39 | -project safari/tickety-tick.xcodeproj \ 40 | -scheme tickety-tick \ 41 | build \ 42 | CONFIGURATION_BUILD_DIR="$(pwd)/dist/safari" \ 43 | CODE_SIGN_IDENTITY="$identity" \ 44 | DEVELOPMENT_TEAM="$team" 45 | -------------------------------------------------------------------------------- /src/core/enhance.test.ts: -------------------------------------------------------------------------------- 1 | import type { Ticket } from "../types"; 2 | import enhance from "./enhance"; 3 | import format, { defaults } from "./format"; 4 | 5 | describe("ticket enhancer", () => { 6 | const ticket: Ticket = { 7 | id: "BTC-042", 8 | title: "Add more tests for src/common/format.js", 9 | type: "enhancement", 10 | }; 11 | 12 | const templates = defaults; 13 | 14 | it('attaches format output to tickets as "fmt" property', async () => { 15 | const formatter = await format(templates); 16 | const enhancer = await enhance(templates, false); 17 | 18 | expect(await enhancer(ticket)).toEqual({ 19 | ...ticket, 20 | fmt: { 21 | branch: await formatter.branch(ticket), 22 | commit: await formatter.commit(ticket), 23 | command: await formatter.command(ticket), 24 | }, 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/core/client.test.ts: -------------------------------------------------------------------------------- 1 | import ky from "ky"; 2 | 3 | import client from "./client"; 4 | 5 | jest.mock("ky"); 6 | 7 | describe("client", () => { 8 | const mock = { ky: true }; 9 | 10 | beforeEach(() => { 11 | (ky.extend as jest.Mock).mockReturnValue(mock); 12 | }); 13 | 14 | afterEach(() => { 15 | (ky.extend as jest.Mock).mockReset(); 16 | }); 17 | 18 | it("creates a new client with default values", () => { 19 | const instance = client("https://example.io/api"); 20 | expect((ky.extend as jest.Mock).mock.calls).toMatchSnapshot(); 21 | expect(instance).toBe(mock); 22 | }); 23 | 24 | it("sets custom options", () => { 25 | const instance = client("https://example.io/api", { 26 | credentials: "same-origin", 27 | timeout: 3000, 28 | }); 29 | expect((ky.extend as jest.Mock).mock.calls).toMatchSnapshot(); 30 | expect(instance).toBe(mock); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/icon-light.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | coverageDirectory: "coverage", 6 | collectCoverageFrom: ["/src/**/*.{js,jsx,ts,tsx}"], 7 | globals: { 8 | COMMITHASH: "test-commit-hash", 9 | }, 10 | setupFilesAfterEnv: ["/test/setup.js"], 11 | testEnvironment: "jsdom", 12 | transform: { 13 | "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", 14 | "(?!^.+\\.(js|jsx|ts|tsx)$)": "/test/transforms/file.js", 15 | }, 16 | transformIgnorePatterns: [ 17 | "node_modules/(?!ccount|character-entities|copy-text-to-clipboard|escape-string-regexp|ky|longest-streak|markdown-table|mdast-util-|micromark-|parse-entities|serialize-error|strip-indent|unist-|zwitch)", 18 | ], 19 | watchPlugins: [ 20 | "jest-watch-typeahead/filename", 21 | "jest-watch-typeahead/testname", 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /src/core/format/helpers.ts: -------------------------------------------------------------------------------- 1 | import { createSlug } from "speakingurl"; 2 | 3 | type StringMappingFn = (input: string) => string; 4 | 5 | export const lowercase = (): StringMappingFn => (s) => s.toLowerCase(); 6 | 7 | export const shellquote = (): StringMappingFn => (s) => 8 | `'${s.replace(/'/g, "'\\''")}'`; 9 | 10 | export const slugify = (separator = "-"): StringMappingFn => 11 | createSlug({ separator }); 12 | 13 | export const substring = 14 | (start: number, end?: number): StringMappingFn => 15 | (s) => 16 | s.substring(start, end); 17 | substring.description = "substring(start-index[, end-index])"; 18 | 19 | export const trim = (): StringMappingFn => (s) => s.trim(); 20 | 21 | export const truncate = 22 | (limit: number): StringMappingFn => 23 | (s) => 24 | s.length > limit ? `${s.substring(0, limit - 1)}…` : s; 25 | truncate.description = "truncate(max-length)"; 26 | 27 | export const uppercase = (): StringMappingFn => (s) => s.toUpperCase(); 28 | -------------------------------------------------------------------------------- /src/core/adapters/clickup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Clickup adapter 3 | * 4 | * The adapter extracts ticket information from the page title. 5 | * Not that clickup doesn't seem to have proper ticket types, 6 | * so for not it will always return task. 7 | */ 8 | 9 | import { match } from "micro-match"; 10 | 11 | import type { TicketData } from "../types"; 12 | import { $text } from "./dom-helpers"; 13 | 14 | async function scan(url: URL, document: Document): Promise { 15 | if (url.hostname !== "app.clickup.com") return []; 16 | 17 | const { id } = match("/t/:id", url.pathname); 18 | 19 | if (!id) return []; 20 | 21 | const pageTitle = $text("title", document); 22 | 23 | if (!pageTitle) return []; 24 | 25 | const title = pageTitle.split("|")[0].trim(); 26 | 27 | const ticket = { 28 | id, 29 | url: url.toString(), 30 | title, 31 | type: "task", 32 | } as TicketData; 33 | 34 | return [ticket]; 35 | } 36 | 37 | export default scan; 38 | -------------------------------------------------------------------------------- /script/build-icons: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # script/build-icons: Build optimized png icons in all required sizes and 4 | # colors from the svg icons. 5 | # 6 | # Running this script requires `inkscape` and `pngcrush` 7 | # executables in your PATH. 8 | 9 | set -o errexit 10 | set -o nounset 11 | 12 | dir="$(realpath "$(dirname "$0")/../src/icons")" 13 | dark_src="${dir}/icon.svg" 14 | light_src="${dir}/icon-light.svg" 15 | 16 | export_png_icon() { 17 | src=$1 dest=$2 size=$3 18 | 19 | inkscape -z -o "$dest" -D -w "$size" "$src" # export 20 | convert "$dest" -background none -gravity center -extent "${size}x${size}" "$dest" # pad 21 | pngcrush -brute -ow "$dest" # reduce 22 | } 23 | 24 | for s in 16 32 48 64 128; do 25 | dark_dest="${dir}/icon-${s}.png" 26 | light_dest="${dir}/icon-light-${s}.png" 27 | export_png_icon "$dark_src" "$dark_dest" "$s" 28 | export_png_icon "$light_src" "$light_dest" "$s" 29 | done 30 | -------------------------------------------------------------------------------- /src/popup/index.scss: -------------------------------------------------------------------------------- 1 | @import "styles/settings"; 2 | 3 | // Bootstrap 4 | @import "~bootstrap"; 5 | 6 | // Bootstrap overrides 7 | @import "styles/overrides"; 8 | 9 | // Popup style 10 | @import "styles/additions"; 11 | 12 | // Spinner 13 | @import "styles/spinner"; 14 | 15 | .dark { 16 | // Invert gray-scale colors 17 | --bs-black-rgb: 255, 255, 255; 18 | --bs-white-rgb: 0, 0, 0; 19 | --bs-white: #000; 20 | --bs-gray-100: #212529; 21 | --bs-gray-200: #343a40; 22 | --bs-gray-300: #495057; 23 | --bs-gray-400: #6c757d; 24 | --bs-gray-500: #adb5bd; 25 | --bs-gray-600: #ced4da; 26 | --bs-gray-700: #dee2e6; 27 | --bs-gray-800: #e9ecef; 28 | --bs-gray-900: #f8f9fa; 29 | 30 | // Set custom dark foreground & background 31 | --bs-body-bg: #292a2d; 32 | --bs-body-bg-rgb: 41, 42, 45; 33 | --bs-body-color: #f8f9fa; 34 | --bs-body-color-rgb: 248, 249, 250; 35 | 36 | // Invert dark & light colors 37 | --bs-light-rgb: 33, 37, 41; 38 | --bs-dark-rgb: 248, 249, 250; 39 | } 40 | -------------------------------------------------------------------------------- /test/transforms/file.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); // eslint-disable-line @typescript-eslint/no-require-imports 2 | 3 | function process(_src, filename) { 4 | const name = path.basename(filename); 5 | const namestr = JSON.stringify(name); 6 | 7 | if (filename.match(/\.svg$/)) { 8 | return { 9 | code: ` 10 | const React = require("react"); 11 | module.exports = { 12 | __esModule: true, 13 | default: ${namestr}, 14 | ReactComponent: React.forwardRef(function Svg(props, ref) { 15 | return { 16 | $$typeof: Symbol.for("react.element"), 17 | type: "svg", 18 | ref: ref, 19 | key: null, 20 | props: Object.assign({}, props, { 21 | children: ${namestr} 22 | }) 23 | }; 24 | }), 25 | }; 26 | `, 27 | }; 28 | } 29 | 30 | return { code: `module.exports = ${namestr};` }; 31 | } 32 | 33 | module.exports = { process }; 34 | -------------------------------------------------------------------------------- /src/core/adapters/dom-helpers.ts: -------------------------------------------------------------------------------- 1 | export function trim(string: string | null) { 2 | return string ? string.replace(/^\s+|\s+$/g, "") : null; 3 | } 4 | 5 | // Finders 6 | 7 | export function $find(selector: string, context: Document | HTMLElement) { 8 | return context.querySelector(selector); 9 | } 10 | 11 | export function $all(selector: string, context: Document | HTMLElement) { 12 | return Array.from(context.querySelectorAll(selector)); 13 | } 14 | 15 | export function $has(selector: string, context: Document | HTMLElement) { 16 | return $find(selector, context) !== null; 17 | } 18 | 19 | // Properties 20 | 21 | export function $text(selector: string, context: Document | HTMLElement) { 22 | const node = $find(selector, context); 23 | return node ? trim(node.textContent) : null; 24 | } 25 | 26 | export function $value(selector: string, context: Document | HTMLElement) { 27 | const node = $find(selector, context); 28 | return node ? trim(node.getAttribute("value")) : null; 29 | } 30 | -------------------------------------------------------------------------------- /src/core/adapters/linear.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Linear adapter 3 | * 4 | * The adapter extracts ticket information from the markup. Note that linear 5 | * obfuscates classes, so the DOM selectors are probably not reliable (for one, 6 | * they are locale dependent, but at this time Linear is only available in 7 | * English). 8 | * 9 | */ 10 | 11 | import { match } from "micro-match"; 12 | 13 | import type { TicketData } from "../types"; 14 | import { $text } from "./dom-helpers"; 15 | 16 | async function scan(url: URL, document: Document): Promise { 17 | if (url.hostname !== "linear.app") return []; 18 | 19 | const { id, issueType } = match("/:team/:issueType/:id/:slug", url.pathname); 20 | const pageTitle = $text("title", document); 21 | const title = pageTitle && pageTitle.substring(id.length + 1); 22 | 23 | if (!id) return []; 24 | 25 | const ticket = { 26 | id, 27 | url: url.toString(), 28 | title, 29 | type: issueType, 30 | } as TicketData; 31 | 32 | return [ticket]; 33 | } 34 | 35 | export default scan; 36 | -------------------------------------------------------------------------------- /src/options/components/checkbox-input.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type Props = { 4 | checked: boolean; 5 | disabled: boolean; 6 | id: string; 7 | label: React.ReactNode; 8 | name: string; 9 | onChange: React.ChangeEventHandler; 10 | }; 11 | 12 | function CheckboxInput({ 13 | checked, 14 | disabled, 15 | id, 16 | label, 17 | name, 18 | onChange, 19 | }: Props) { 20 | return ( 21 |
22 |
23 |
24 | 33 |
34 |
35 | 36 |
37 |
38 |
39 | ); 40 | } 41 | 42 | export default CheckboxInput; 43 | -------------------------------------------------------------------------------- /src/options/components/example.ts: -------------------------------------------------------------------------------- 1 | export const id = "9"; 2 | 3 | export const type = "story"; 4 | 5 | export const title = "Capitalized, short (50 chars or less) summary"; 6 | 7 | export const url = "https://github.com/bitcrowd/tickety-tick/issues/93"; 8 | 9 | export const description = `More detailed explanatory text, if necessary. Wrap it to about 72 characters or so. In some contexts, the first line is treated as the subject of an email and the rest of the text as the body. The blank line separating the summary from the body is critical (unless you omit the body entirely); tools like rebase can get confused if you run the two together. 10 | 11 | Write your commit message in the imperative: "Fix bug" and not "Fixed bug" or "Fixes bug." This convention matches up with commit messages generated by commands like git merge and git revert. 12 | 13 | Further paragraphs come after blank lines. 14 | 15 | - Bullet points are okay, too 16 | - Typically a hyphen or asterisk is used for the bullet, followed by a single space, with blank lines in between, but conventions vary here 17 | - Use a hanging indent`; 18 | -------------------------------------------------------------------------------- /safari/tickety-tick/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSMainStoryboardFile 26 | Main 27 | NSPrincipalClass 28 | NSApplication 29 | 30 | 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-present bitcrowd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/popup/components/no-tickets.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import type { Props as ErrorDetailsProps } from "./error-details"; 5 | import type { Props } from "./no-tickets"; 6 | import NoTickets from "./no-tickets"; 7 | 8 | jest.mock( 9 | "./error-details", 10 | () => 11 | function Component({ errors }: ErrorDetailsProps) { 12 | return <>{errors.map((error) => `- ${error}`).join("\n")}; 13 | }, 14 | ); 15 | 16 | describe("no-tickets", () => { 17 | function subject(overrides: Partial) { 18 | const defaults: Props = { errors: [] }; 19 | const props = { ...defaults, ...overrides }; 20 | return render(); 21 | } 22 | 23 | describe("with no errors", () => { 24 | it("renders a hint", () => { 25 | expect(subject({ errors: [] }).asFragment()).toMatchSnapshot(); 26 | }); 27 | }); 28 | 29 | describe("with errors", () => { 30 | it("renders an error report", () => { 31 | const errors = [new Error("An error to test error reporting")]; 32 | expect(subject({ errors }).asFragment()).toMatchSnapshot(); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/core/adapters/linear.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import scan from "./linear"; 8 | 9 | const pages = { 10 | issuePage: { 11 | url: new URL( 12 | "https://linear.app/my_team/issue/FOO-1/do-something-interesting", 13 | ), 14 | document: "FOO-1 Do something interesting", 15 | }, 16 | }; 17 | 18 | describe("linear adapter", () => { 19 | function doc(body = "") { 20 | const { window } = new JSDOM(`${body}`); 21 | return window.document; 22 | } 23 | 24 | it("returns an empty array if it is on a different page", async () => { 25 | const result = await scan(new URL("https://example.com"), doc()); 26 | expect(result).toEqual([]); 27 | }); 28 | 29 | it("extracts the ticket from the issue page", async () => { 30 | const result = await scan( 31 | pages.issuePage.url, 32 | doc(pages.issuePage.document), 33 | ); 34 | expect(result).toEqual([ 35 | { 36 | id: "FOO-1", 37 | title: "Do something interesting", 38 | type: "issue", 39 | url: pages.issuePage.url.toString(), 40 | }, 41 | ]); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/popup/styles/_additions.scss: -------------------------------------------------------------------------------- 1 | // Popup 2 | 3 | .popup { 4 | width: 290px; 5 | } 6 | 7 | // Buttons 8 | 9 | .btn { 10 | .btn-label { 11 | font-size: 0.8em; 12 | } 13 | 14 | .btn-label-conceal { 15 | display: none; 16 | } 17 | 18 | &:active .btn-label-conceal, 19 | &:focus .btn-label-conceal, 20 | &:hover .btn-label-conceal { 21 | display: initial; 22 | } 23 | } 24 | 25 | // Logo 26 | 27 | .logo { 28 | height: 1em; 29 | 30 | path { 31 | fill: currentColor !important; 32 | fill-opacity: 1 !important; 33 | } 34 | } 35 | 36 | // Octicons 37 | // 38 | // These styles are normally set directly on the SVG element by the official 39 | // React wrapper for Octicons. However, inline style attributes are blocked by 40 | // the extension's Content Security Policy — in Firefox at least. Therefore we 41 | // simply (re-)define them here so they are applied correctly. 42 | // 43 | // Source code reference: https://git.io/fxK7X 44 | // CSP: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src 45 | 46 | .octicon { 47 | display: inline-block; 48 | fill: currentColor; 49 | user-select: none; 50 | vertical-align: text-top; 51 | } 52 | 53 | .octicon-nav { 54 | margin-top: -0.0625em; 55 | } 56 | -------------------------------------------------------------------------------- /SAFARI.md: -------------------------------------------------------------------------------- 1 | # Safari 2 | 3 | You can build Tickety-Tick for Safari. 4 | 5 | ## Prerequisites 6 | 7 | Install [Xcode](https://apps.apple.com/de/app/xcode/id497799835?l=en&mt=12) in addition to the install steps described in [README.md](./README.md#building). 8 | 9 | You’ll also need a signing certificate. 10 | 11 | The easiest way to create one might be this: 12 | 13 | 1. Open Xcode 14 | 2. Go to Xcode -> Settings > Accounts 15 | 3. Add an account of type “Apple ID” 16 | 17 | — https://help.apple.com/xcode/mac/current/#/dev154b28f09 18 | 19 | The certificate should show up in Keychain Access (app), usually as "Apple Development". 20 | 21 | ## Building & Installing 22 | 23 | To build the extension for Safari, run: 24 | 25 | ```shell 26 | yarn build:safari 27 | ``` 28 | 29 | If your signing certificate has a name that is not "Apple Development", use: 30 | 31 | ```shell 32 | yarn build:chrome 33 | yarn build:safari:xcodebuild 34 | ``` 35 | 36 | You may be prompted to grant access to your signing identity during build. 37 | This is not the build scripts themselves, but the Xcode tools invoked as a result. 38 | 39 | Once the build succeeds, open `dist/safari/tickety-tick.app`. 40 | 41 | This should install the extension. You just have to enable it. 42 | -------------------------------------------------------------------------------- /safari/tickety-tick Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Tickety-Tick 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSExtension 26 | 27 | NSExtensionPointIdentifier 28 | com.apple.Safari.web-extension 29 | NSExtensionPrincipalClass 30 | $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/core/adapters/plane.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Plane adapter 3 | * 4 | * The adapter extracts ticket information from the markup. 5 | */ 6 | 7 | import { match } from "micro-match"; 8 | 9 | import type { TicketData } from "../types"; 10 | import { $find, $text } from "./dom-helpers"; 11 | import { hasRequiredDetails } from "./utils"; 12 | 13 | async function scan(url: URL, document: Document): Promise { 14 | const ogUrl = ( 15 | $find("[property~='og:url'][content]", document) 16 | ); 17 | if (!ogUrl || ogUrl.content !== "https://app.plane.so/") return []; 18 | 19 | let { issueType } = match( 20 | "/:workspace/projects/:projectUuid/:issueType/:issueUuid/", 21 | url.pathname, 22 | ); 23 | if (issueType === "issues") { 24 | issueType = "feature"; 25 | } 26 | 27 | const pageTitle = $text("title", document); 28 | const extract = pageTitle && pageTitle.match(/([A-Z]+-\d+)\s(.+)/); 29 | const id = extract && extract[1]; 30 | const title = extract && extract[2]; 31 | 32 | const tickets = [ 33 | { 34 | id, 35 | url: url.toString(), 36 | title, 37 | type: issueType, 38 | }, 39 | ]; 40 | 41 | return tickets.filter(hasRequiredDetails) as TicketData[]; 42 | } 43 | 44 | export default scan; 45 | -------------------------------------------------------------------------------- /src/popup/components/error-details.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import type { Props as CopyButtonProps } from "./copy-button"; 5 | import type { Props } from "./error-details"; 6 | import CopyErrorDetails from "./error-details"; 7 | 8 | jest.mock( 9 | "./copy-button", 10 | () => 11 | function Component({ value }: CopyButtonProps) { 12 | return ( 13 | 16 | ); 17 | }, 18 | ); 19 | 20 | describe("error-details", () => { 21 | function subject(overrides: Partial) { 22 | const defaults: Props = { errors: [new Error("WAT?")] }; 23 | const props = { ...defaults, ...overrides }; 24 | return render(); 25 | } 26 | 27 | it("renders a button to copy the error details", () => { 28 | const error = new Error("Boom!"); 29 | error.name = "TestError"; 30 | error.stack = "@test it code:21:3\n@test anonymous code:22:19\n"; 31 | 32 | const screen = subject({ errors: [error] }); 33 | 34 | const button = screen.getByRole("button", { 35 | name: "Copy error details", 36 | }) as HTMLButtonElement; 37 | 38 | expect(button.value).toMatchSnapshot(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/core/adapters/plane.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import scan from "./plane"; 8 | 9 | const pages = { 10 | issuePage: { 11 | url: new URL( 12 | "https://app.plane.so/my_team/projects/e9460bd9-2967-4c5e-8d95-24b053e6965d/issues/9210a612-338b-4620-a834-07a89a26bf65/", 13 | ), 14 | document: 15 | "FOO-1 Do something interesting", 16 | }, 17 | }; 18 | 19 | describe("plane adapter", () => { 20 | function doc(head = "") { 21 | const { window } = new JSDOM( 22 | `${head}`, 23 | ); 24 | return window.document; 25 | } 26 | 27 | it("returns an empty array if it is on a different page", async () => { 28 | const result = await scan(new URL("https://example.com"), doc()); 29 | expect(result).toEqual([]); 30 | }); 31 | 32 | it("extracts the ticket info from the issue page", async () => { 33 | const result = await scan( 34 | pages.issuePage.url, 35 | doc(pages.issuePage.document), 36 | ); 37 | expect(result).toEqual([ 38 | { 39 | id: "FOO-1", 40 | title: "Do something interesting", 41 | type: "feature", 42 | url: pages.issuePage.url.toString(), 43 | }, 44 | ]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /safari/tickety-tick/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "icon-16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "icon-32.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "icon-32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "icon-64.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "icon-128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "version" : 1, 61 | "author" : "xcode" 62 | } 63 | } -------------------------------------------------------------------------------- /src/popup/index.ts: -------------------------------------------------------------------------------- 1 | import "./index.scss"; 2 | 3 | import type { ErrorObject } from "serialize-error"; 4 | import browser from "webextension-polyfill"; 5 | 6 | import enhance from "../core/enhance"; 7 | import type { Options, Templates } from "../core/format/types"; 8 | import { deserialize } from "../errors"; 9 | import store from "../store"; 10 | import type { Ticket } from "../types"; 11 | import onmedia from "./observe-media"; 12 | import render from "./render"; 13 | 14 | async function getTickets(): Promise<{ 15 | tickets: Ticket[]; 16 | errors: ErrorObject[]; 17 | }> { 18 | return browser.runtime.sendMessage({ 19 | getTickets: true, 20 | }) as Promise<{ tickets: Ticket[]; errors: ErrorObject[] }>; 21 | } 22 | 23 | async function getConfig() { 24 | return store.get(null) as Promise<{ templates: Templates; options: Options }>; 25 | } 26 | 27 | async function load() { 28 | const { tickets, errors } = await getTickets(); 29 | const { options, templates } = await getConfig(); 30 | 31 | const enhancer = await enhance(templates, options?.autofmt); 32 | 33 | render(await Promise.all(tickets.map(enhancer)), errors.map(deserialize)); 34 | } 35 | 36 | window.onload = load; 37 | 38 | onmedia("(prefers-color-scheme: dark)", (matches) => { 39 | const { classList } = document.documentElement; 40 | 41 | if (matches) { 42 | classList.add("dark"); 43 | } else { 44 | classList.remove("dark"); 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /src/core/adapters/polarion.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Polarion adapter 3 | * 4 | * The adapter extracts ticket information from Polarion workitem using DOM 5 | * selectors. 6 | */ 7 | 8 | import type { TicketData } from "../types"; 9 | import { $text } from "./dom-helpers"; 10 | 11 | function isPolarionPage(document: Document) { 12 | if (document.body.classList.contains("polarion-WorkItem-Title")) return true; 13 | return false; 14 | } 15 | async function scan(url: URL, document: Document): Promise { 16 | if (isPolarionPage(document)) return []; 17 | 18 | const polarionTitle = $text( 19 | ".polarion-WorkItem-Title a.polarion-Hyperlink", 20 | document, 21 | ); 22 | const type = 23 | $text("#FIELD_type .polarion-JSEnumOption", document) 24 | ?.toLowerCase() 25 | .trim() === "bug" 26 | ? "bug" 27 | : "feature"; 28 | const description = $text("#FIELD_description .polarion-TextField", document); 29 | 30 | if (!polarionTitle || !type || !description) return []; 31 | 32 | // polarionTitle follows the pattern "RC-654 - Title" 33 | const match = polarionTitle.match(/^([A-Z0-9]+-[0-9]+)\s*[-:]\s*(.+)$/); 34 | 35 | if (!match || match.length !== 3) return []; 36 | 37 | return [ 38 | { 39 | id: match[1], 40 | title: match[2].trim(), 41 | description, 42 | type: type.toLowerCase(), 43 | url: url.toString(), 44 | }, 45 | ]; 46 | } 47 | 48 | export default scan; 49 | -------------------------------------------------------------------------------- /src/core/adapters/clickup.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import scan from "./clickup"; 8 | 9 | const pages = { 10 | issuePage: { 11 | url: new URL("https://app.clickup.com/t/8790abcde"), 12 | document: "A nice ticket | #8790abcde", 13 | }, 14 | boardPage: { 15 | url: new URL("https://app.clickup.com/4637546/v/b/6-123456789-2"), 16 | }, 17 | }; 18 | 19 | describe("clickup adapter", () => { 20 | function doc(body = "") { 21 | const { window } = new JSDOM(`${body}`); 22 | return window.document; 23 | } 24 | 25 | it("returns an empty array if it is on the board", async () => { 26 | const result = await scan(pages.issuePage.url, doc()); 27 | expect(result).toEqual([]); 28 | }); 29 | 30 | it("returns an empty array if it is on a different page", async () => { 31 | const result = await scan(new URL("https://example.com"), doc()); 32 | expect(result).toEqual([]); 33 | }); 34 | 35 | it("extracts the ticket from the issue page", async () => { 36 | const result = await scan( 37 | pages.issuePage.url, 38 | doc(pages.issuePage.document), 39 | ); 40 | expect(result).toEqual([ 41 | { 42 | id: "8790abcde", 43 | title: "A nice ticket", 44 | type: "task", 45 | url: "https://app.clickup.com/t/8790abcde", 46 | }, 47 | ]); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/core/format/index.ts: -------------------------------------------------------------------------------- 1 | import type { Ticket } from "../../types"; 2 | import * as defaults from "./defaults"; 3 | import * as helpers from "./helpers"; 4 | import pprint from "./pretty-print"; 5 | import compile from "./template"; 6 | import type { Formatter, Templates } from "./types"; 7 | 8 | export { defaults, helpers }; 9 | 10 | function format( 11 | templates: Partial, 12 | name: keyof Templates, 13 | ) { 14 | const render = compile(templates[name] || defaults[name], helpers); 15 | return (ticket: T) => render(ticket).trim(); 16 | } 17 | 18 | function formatCommit(templates: Partial, prettify: boolean) { 19 | return async (ticket: Ticket) => { 20 | let formattedCommit = format(templates, "commit")(ticket); 21 | if (prettify) { 22 | formattedCommit = await pprint(formattedCommit); 23 | } 24 | return formattedCommit; 25 | }; 26 | } 27 | 28 | export default ( 29 | templates: Partial = {}, 30 | prettify = true, 31 | ): Formatter => { 32 | const branch = format(templates, "branch"); 33 | 34 | const commit = formatCommit(templates, prettify); 35 | 36 | const command = async (ticket: Ticket) => 37 | format( 38 | templates, 39 | "command", 40 | )({ 41 | branch: branch(ticket), 42 | commit: await commit(ticket), 43 | ...ticket, 44 | }); 45 | 46 | return { branch, command, commit }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/popup/components/external-link.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import type { Props } from "./external-link"; 5 | import ExternalLink from "./external-link"; 6 | 7 | describe("external-link", () => { 8 | function subject(overrides: Partial) { 9 | const defaults: Props = { 10 | children: "link text", 11 | href: "https://github.com/bitcrowd/tickety-tick", 12 | }; 13 | const props = { ...defaults, ...overrides }; 14 | return render(); 15 | } 16 | 17 | it("sets the link href attribute", () => { 18 | const href = "https://github.com/"; 19 | const screen = subject({ href }); 20 | const link = screen.getByRole("link"); 21 | expect(link).toHaveAttribute("href", href); 22 | }); 23 | 24 | it('sets the target to "_blank"', () => { 25 | const screen = subject({}); 26 | const link = screen.getByRole("link"); 27 | expect(link).toHaveAttribute("target", "_blank"); 28 | }); 29 | 30 | it("renders the link children", () => { 31 | const screen = subject({ children: "neat" }); 32 | const link = screen.getByRole("link"); 33 | expect(link).toHaveTextContent("neat"); 34 | }); 35 | 36 | it("passes on any other properties to the rendered link", () => { 37 | const screen = subject({ className: "fancy-link" }); 38 | const link = screen.getByRole("link"); 39 | expect(link).toHaveClass("fancy-link"); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/core/adapters/trello.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Trello adapter 3 | * 4 | * The adapter extracts the identifier of the selected card (short link) from 5 | * the page URL and uses the Trello API to retrieve the corresponding card 6 | * information. 7 | * 8 | * https://developers.trello.com/v1.0/reference#introduction 9 | * 10 | * Card view: https://trello.com/c//1-ticket-title 11 | */ 12 | 13 | import { match } from "micro-match"; 14 | 15 | import client from "../client"; 16 | import type { TicketData } from "../types"; 17 | 18 | type TrelloTicketInfo = { 19 | name: string; 20 | desc: string; 21 | shortLink: string; 22 | shortUrl: string; 23 | }; 24 | 25 | function extractTicketInfo(response: TrelloTicketInfo) { 26 | const { 27 | desc: description, 28 | name: title, 29 | shortLink: id, 30 | shortUrl: url, 31 | } = response; 32 | 33 | return { 34 | id, 35 | title, 36 | description, 37 | url, 38 | }; 39 | } 40 | 41 | const requestOptions = { 42 | searchParams: { fields: "name,desc,shortLink,shortUrl" }, 43 | }; 44 | 45 | async function scan(url: URL): Promise { 46 | if (url.hostname !== "trello.com") return []; 47 | 48 | const { key } = match("/c/:key/:slug", url.pathname); 49 | 50 | if (!key) return []; 51 | 52 | const trello = client("https://trello.com/1"); 53 | const response = await trello 54 | .get(`cards/${key}`, requestOptions) 55 | .json(); 56 | const ticket = extractTicketInfo(response); 57 | 58 | return [ticket]; 59 | } 60 | 61 | export default scan; 62 | -------------------------------------------------------------------------------- /src/core/adapters/gitlab.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Gitlab adapter 3 | * 4 | * The adapter uses the DOM to extract information about tickets. 5 | */ 6 | 7 | import type { TicketData } from "../types"; 8 | import { $find, $has, $text } from "./dom-helpers"; 9 | import { hasRequiredDetails } from "./utils"; 10 | 11 | function findTicketId(document: Document) { 12 | const id = document.body.dataset.pageTypeId; 13 | if (id) return id; 14 | 15 | // Legacy approach of extracting the ticket id, left in place to support 16 | // older self-hosted GitLab installations 17 | const element = $find("#js-issuable-app-initial-data", document); 18 | if (element === null) return undefined; 19 | 20 | const data = JSON.parse(element.innerHTML.replace(/"/g, '"')); 21 | return data?.issuableRef.match(/#(\d+)/)[1]; 22 | } 23 | 24 | async function scan(url: URL, document: Document): Promise { 25 | if (document.body.dataset.page === "projects:issues:show") { 26 | const id = findTicketId(document); 27 | const title = 28 | $text(".issue-details .title", document) || 29 | $text('h1[data-testid="work-item-title"]', document); 30 | const type = 31 | $has('.labels [data-original-title="bug"]', document) || 32 | $has( 33 | 'section[data-testid="work-item-labels"] [data-testid="bug"]', 34 | document, 35 | ) 36 | ? "bug" 37 | : "feature"; 38 | 39 | const ticket = { id, title, type }; 40 | 41 | if (hasRequiredDetails(ticket)) return [ticket]; 42 | 43 | return []; 44 | } 45 | 46 | return []; 47 | } 48 | 49 | export default scan; 50 | -------------------------------------------------------------------------------- /src/core/format/__snapshots__/pretty-print.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`pretty-print formats lists 1`] = ` 4 | "Format lists in body 5 | 6 | - Bullet points are okay too 7 | - Typically a hyphen or asterisk is used for the bullet, followed by a 8 | single space, with blank lines in between, but conventions vary here 9 | - Use a hanging indent 10 | " 11 | `; 12 | 13 | exports[`pretty-print strips body indentation 1`] = ` 14 | "Strip body indentation 15 | 16 | Unindent this line. Move this one up. 17 | 18 | function leave(me) { 19 | return 'indented'; 20 | } 21 | 22 | And continue. 23 | " 24 | `; 25 | 26 | exports[`pretty-print strips leading and trailing blank lines and whitespace 1`] = ` 27 | "Remove whitespace around subject line 28 | 29 | Also, remove additional blank lines before and after body. 30 | 31 | Preserve blank lines between paragraphs of the body. 32 | 33 | Strip whitespace on empty lines (see line above). 34 | 35 | Strip trailing whitespace (see end of this line). 36 | " 37 | `; 38 | 39 | exports[`pretty-print wraps overlong body lines 1`] = ` 40 | "Wrap body lines 41 | 42 | More detailed explanatory text is wrapped to 72 characters. The blank 43 | line separating the subject from the body is critical unless you omit 44 | the body entirely. 45 | " 46 | `; 47 | 48 | exports[`pretty-print wraps overlong subject lines 1`] = ` 49 | "Wrap commit subject lines with more than 50… 50 | 51 | …characters and insert one blank line before the remaining subject text. 52 | Wrap the remaining subject text to 72 characters. 53 | " 54 | `; 55 | -------------------------------------------------------------------------------- /src/popup/components/copy-button.tsx: -------------------------------------------------------------------------------- 1 | import pbcopy from "copy-text-to-clipboard"; 2 | import React, { useCallback, useEffect, useRef, useState } from "react"; 3 | 4 | function useDelayed(fn: () => void, ms: number) { 5 | const timeout = useRef>(null); 6 | const callback = useRef(fn); 7 | 8 | const clear = useCallback(() => { 9 | if (timeout.current) clearTimeout(timeout.current); 10 | timeout.current = null; 11 | }, []); 12 | 13 | const run = useCallback(() => { 14 | if (timeout.current) clearTimeout(timeout.current); 15 | timeout.current = setTimeout(() => callback.current(), ms); 16 | }, [ms]); 17 | 18 | useEffect(() => { 19 | callback.current = fn; 20 | }, [fn]); 21 | 22 | useEffect(() => clear, [clear]); 23 | 24 | return run; 25 | } 26 | 27 | function close() { 28 | window.close(); 29 | } 30 | 31 | export type Props = { 32 | value: string; 33 | children?: 34 | | undefined 35 | | React.ReactNode 36 | | ((copied: boolean) => React.ReactNode); 37 | } & Omit, "children">; 38 | 39 | function CopyButton({ children = null, value, ...rest }: Props) { 40 | const [copied, setCopied] = useState(false); 41 | const closeWithDelay = useDelayed(close, 600); 42 | 43 | const handler = useCallback(() => { 44 | pbcopy(value); 45 | setCopied(true); 46 | closeWithDelay(); 47 | }, [closeWithDelay, value]); 48 | 49 | return ( 50 | 53 | ); 54 | } 55 | 56 | export default CopyButton; 57 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "", 4 | "version": "", 5 | "description": "", 6 | "icons": { 7 | "16": "icon-16.png", 8 | "32": "icon-32.png", 9 | "48": "icon-48.png", 10 | "64": "icon-64.png", 11 | "128": "icon-128.png" 12 | }, 13 | "background": { 14 | "scripts": ["background.js"], 15 | "service_worker": "background.js" 16 | }, 17 | "content_scripts": [ 18 | { 19 | "matches": ["http://*/*", "https://*/*"], 20 | "js": ["content.js"] 21 | } 22 | ], 23 | "web_accessible_resources": [], 24 | "permissions": ["activeTab", "clipboardWrite", "storage"], 25 | "action": { 26 | "default_title": "Git Branch/Message", 27 | "default_popup": "popup.html", 28 | "default_icon": { 29 | "64": "icon-64.png", 30 | "32": "icon-32.png", 31 | "16": "icon-16.png" 32 | }, 33 | "theme_icons": [ 34 | { 35 | "light": "icon-light-64.png", 36 | "dark": "icon-64.png", 37 | "size": 64 38 | }, 39 | { 40 | "light": "icon-light-32.png", 41 | "dark": "icon-32.png", 42 | "size": 32 43 | }, 44 | { 45 | "light": "icon-light-16.png", 46 | "dark": "icon-16.png", 47 | "size": 16 48 | } 49 | ] 50 | }, 51 | "options_ui": { 52 | "page": "options.html" 53 | }, 54 | "commands": { 55 | "_execute_action": { 56 | "suggested_key": { 57 | "default": "Ctrl+T", 58 | "mac": "MacCtrl+T" 59 | } 60 | } 61 | }, 62 | "content_security_policy": { 63 | "extension_pages": "default-src 'none'; img-src 'self' data:; style-src 'self'; script-src 'self';" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /safari/tickety-tick/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // 4 | 5 | import Cocoa 6 | import SafariServices.SFSafariApplication 7 | import SafariServices.SFSafariExtensionManager 8 | 9 | let appName = "tickety-tick" 10 | let extensionBundleIdentifier = "net.bitcrowd.tickety-tick.Extension" 11 | 12 | class ViewController: NSViewController { 13 | @IBOutlet var appNameLabel: NSTextField! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | appNameLabel.stringValue = appName 18 | SFSafariExtensionManager 19 | .getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { state, error in 20 | guard let state = state, error == nil else { 21 | // Insert code to inform the user that something went wrong. 22 | return 23 | } 24 | 25 | DispatchQueue.main.async { 26 | if state.isEnabled { 27 | self.appNameLabel.stringValue = "\(appName)'s extension is currently on." 28 | } else { 29 | self.appNameLabel 30 | .stringValue = 31 | "\(appName)'s extension is currently off. You can turn it on in Safari Extensions preferences." 32 | } 33 | } 34 | } 35 | } 36 | 37 | @IBAction func openSafariExtensionPreferences(_: AnyObject?) { 38 | SFSafariApplication 39 | .showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in 40 | guard error == nil else { 41 | // Insert code to inform the user that something went wrong. 42 | return 43 | } 44 | 45 | DispatchQueue.main.async { 46 | NSApplication.shared.terminate(nil) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/popup/components/tool.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import browser from "webextension-polyfill"; 4 | 5 | import type { TicketWithFmt } from "../../types"; 6 | import Content from "./content"; 7 | import Navbar from "./navbar"; 8 | import NoTickets from "./no-tickets"; 9 | import TicketControls from "./ticket-controls"; 10 | 11 | function close() { 12 | window.close(); 13 | } 14 | 15 | function openOptions() { 16 | return browser.runtime.openOptionsPage(); 17 | } 18 | 19 | export type Props = { 20 | tickets: TicketWithFmt[]; 21 | errors: Error[]; 22 | }; 23 | 24 | function Tool({ tickets, errors }: Props): React.ReactElement { 25 | async function onClickSettingsLink(event: React.MouseEvent) { 26 | event.preventDefault(); 27 | await openOptions(); 28 | close(); 29 | } 30 | 31 | return ( 32 | <> 33 | 34 | Tickety-Tick 35 | 51 | 52 | 53 | {tickets.length > 0 ? ( 54 | 55 | ) : ( 56 | 57 | )} 58 | 59 | 60 | ); 61 | } 62 | 63 | export default Tool; 64 | -------------------------------------------------------------------------------- /src/core/adapters/youtrack.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import scan from "./youtrack"; 8 | 9 | const html = { 10 | issuepage: (type?: string) => ` 11 |
12 | TT-123 13 | 14 |

15 | Support YouTrack 16 |

17 | 18 | 21 |
22 | `, 23 | }; 24 | 25 | const url = new URL( 26 | "https://bitcrowd.youtrack.cloud/issue/TT-0/Support-YouTrack", 27 | ); 28 | 29 | describe("youtrack adapter", () => { 30 | function doc(body = "") { 31 | const { window } = new JSDOM(`${body}`); 32 | return window.document; 33 | } 34 | 35 | it("returns an empty array if it is on a different page", async () => { 36 | const result = await scan(url, doc("other")); 37 | expect(result).toEqual([]); 38 | }); 39 | 40 | it("extracts tickets from issue pages", async () => { 41 | const result = await scan(url, doc(html.issuepage("Feature"))); 42 | expect(result).toEqual([ 43 | { 44 | id: "TT-123", 45 | title: "Support YouTrack", 46 | type: "feature", 47 | url: url.toString(), 48 | }, 49 | ]); 50 | }); 51 | 52 | it("recognizes issues types", async () => { 53 | const result = await scan(url, doc(html.issuepage("Question"))); 54 | expect(result).toEqual([ 55 | { 56 | id: "TT-123", 57 | title: "Support YouTrack", 58 | type: "question", 59 | url: url.toString(), 60 | }, 61 | ]); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/popup/components/__snapshots__/no-tickets.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`no-tickets with errors renders an error report 1`] = ` 4 | 5 |
8 |
11 | Houston, we have a problem. 12 |
13 |

14 | Tickety-Tick encountered 15 | 16 | an error 17 | 18 |
19 | while scanning for tickets. 20 |

21 |

22 | - Error: An error to test error reporting 23 |

24 |

25 | 30 | Report an issue 31 | 32 |

33 |
34 |
35 | `; 36 | 37 | exports[`no-tickets with no errors renders a hint 1`] = ` 38 | 39 |
42 |
45 | No tickets found on this page. 46 |
47 |
48 | Did you select or open any tickets? 49 |
50 |

51 | Tickety-Tick currently supports 52 |
53 | GitHub, GitLab, Jira, Trello, Notion, Tara, Linear, Plane and Polarion. 54 |

55 |
56 | Missing anything or found a bug? 57 |
58 |

61 | 66 | Report an issue 67 | 68 |

69 |
70 |
71 | `; 72 | -------------------------------------------------------------------------------- /src/popup/components/no-tickets.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import ErrorDetails from "./error-details"; 4 | import ExternalLink from "./external-link"; 5 | 6 | function IssueLink() { 7 | return ( 8 | 9 | Report an issue 10 | 11 | ); 12 | } 13 | 14 | function Hint() { 15 | return ( 16 | <> 17 |
No tickets found on this page.
18 |
Did you select or open any tickets?
19 |

20 | Tickety-Tick currently supports 21 |
22 | GitHub, GitLab, Jira, Trello, Notion, Tara, Linear, Plane and Polarion. 23 |

24 |
Missing anything or found a bug?
25 |

26 | 27 |

28 | 29 | ); 30 | } 31 | 32 | export type ReportProps = { errors: Error[] }; 33 | 34 | function Report({ errors }: ReportProps) { 35 | return ( 36 | <> 37 |
Houston, we have a problem.
38 |

39 | Tickety-Tick encountered{" "} 40 | 41 | {errors.length === 1 ? "an error" : `${errors.length} errors`} 42 | 43 |
44 | while scanning for tickets. 45 |

46 |

47 | 48 |

49 |

50 | 51 |

52 | 53 | ); 54 | } 55 | 56 | export type Props = { errors: Error[] }; 57 | 58 | function NoTickets({ errors }: Props) { 59 | return ( 60 |
61 | {errors.length > 0 ? : } 62 |
63 | ); 64 | } 65 | 66 | export { Hint, Report }; 67 | export default NoTickets; 68 | -------------------------------------------------------------------------------- /src/options/components/checkbox-input.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import type { Props } from "./checkbox-input"; 5 | import CheckboxInput from "./checkbox-input"; 6 | 7 | describe("checkbox-input", () => { 8 | function subject(overrides: Partial) { 9 | const defaults: Props = { 10 | id: "checkbox-input-id", 11 | name: "checkbox-input-name", 12 | label: label, 13 | checked: true, 14 | disabled: false, 15 | onChange: jest.fn(), 16 | }; 17 | 18 | const props = { ...defaults, ...overrides }; 19 | return render(); 20 | } 21 | 22 | it("renders an input label", () => { 23 | const label = "Awesome Label"; 24 | const screen = subject({ id: "id-1", label }); 25 | const input = screen.getByRole("checkbox", { name: label }); 26 | 27 | expect(input).toBeInTheDocument(); 28 | }); 29 | 30 | it("renders an input field", () => { 31 | const screen = subject({ id: "id-2", name: "name-2", checked: true }); 32 | const input = screen.getByRole("checkbox"); 33 | 34 | expect(input).toHaveAttribute("id", "id-2"); 35 | expect(input).toHaveAttribute("name", "name-2"); 36 | expect(input).toBeChecked(); 37 | }); 38 | 39 | it("notifies about input changes", () => { 40 | const onChange = jest.fn(); 41 | const screen = subject({ onChange }); 42 | const input = screen.getByRole("checkbox"); 43 | 44 | fireEvent.click(input); 45 | 46 | expect(onChange).toHaveBeenCalled(); 47 | }); 48 | 49 | it("disables the input if requested", () => { 50 | const screen = subject({ disabled: true }); 51 | const input = screen.getByRole("checkbox"); 52 | 53 | expect(input).toBeDisabled(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/core/search.test.ts: -------------------------------------------------------------------------------- 1 | import { serialize } from "../errors"; 2 | import JiraCloud from "./adapters/jira-cloud"; 3 | import JiraServer from "./adapters/jira-server"; 4 | import { search, stdadapters } from "./search"; 5 | import type { Adapter, TicketData } from "./types"; 6 | 7 | describe("ticket search", () => { 8 | function mock(result: Promise, index: number): Adapter { 9 | return jest.fn().mockName(`adapters[${index}]`).mockReturnValue(result); 10 | } 11 | 12 | function mocks(results: Promise[]) { 13 | return results.map(mock); 14 | } 15 | 16 | const url = new URL("https://x.yz"); 17 | const doc = {} as Document; 18 | 19 | it("feeds the location and document to every adapter", async () => { 20 | const adapters = mocks([[], []].map((v) => Promise.resolve(v))); 21 | 22 | await search(adapters, url, doc); 23 | 24 | adapters.forEach((scan) => { 25 | expect(scan).toHaveBeenCalledWith(url, doc); 26 | }); 27 | }); 28 | 29 | it("resolves with the aggregated tickets and serializable errors", async () => { 30 | const tickets0 = [{ id: "0", title: "true story", type: "test" }]; 31 | const tickets1 = [{ id: "1", title: "yep", type: "test" }]; 32 | 33 | const error0 = new Error("test error 0"); 34 | const error1 = new Error("test error 1"); 35 | 36 | const adapters = mocks([ 37 | Promise.resolve(tickets0), 38 | Promise.resolve(tickets1), 39 | Promise.reject(error0), 40 | Promise.reject(error1), 41 | ]); 42 | 43 | const results = await search(adapters, url, doc); 44 | 45 | expect(results).toEqual({ 46 | tickets: [...tickets0, ...tickets1], 47 | errors: [error0, error1].map(serialize), 48 | }); 49 | }); 50 | 51 | it("searches jira cloud before jira server", () => { 52 | expect(stdadapters.indexOf(JiraCloud)).toBeLessThan( 53 | stdadapters.indexOf(JiraServer), 54 | ); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/core/search.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorObject } from "../errors"; 2 | import { serialize } from "../errors"; 3 | import type { Ticket } from "../types"; 4 | import Clickup from "./adapters/clickup"; 5 | import GitHub from "./adapters/github"; 6 | import GitLab from "./adapters/gitlab"; 7 | import JiraCloud from "./adapters/jira-cloud"; 8 | import JiraServer from "./adapters/jira-server"; 9 | import Linear from "./adapters/linear"; 10 | import Notion from "./adapters/notion"; 11 | import Plane from "./adapters/plane"; 12 | import Polarion from "./adapters/polarion"; 13 | import Tara from "./adapters/tara"; 14 | import Trello from "./adapters/trello"; 15 | import defaults from "./defaults"; 16 | import type { Adapter } from "./types"; 17 | 18 | async function attempt(scan: Adapter, url: URL, document: Document) { 19 | try { 20 | const result = await scan(url, document); 21 | const tickets = result.map((data) => ({ ...defaults, ...data })); 22 | return tickets; 23 | } catch (error) { 24 | return error as Error; 25 | } 26 | } 27 | 28 | function aggregate(results: (Ticket[] | Error)[]) { 29 | return results.reduce<{ tickets: Ticket[]; errors: ErrorObject[] }>( 30 | ({ tickets, errors }, result) => 31 | result instanceof Error 32 | ? { tickets, errors: errors.concat(serialize(result)) } 33 | : { errors, tickets: tickets.concat(result) }, 34 | { tickets: [], errors: [] }, 35 | ); 36 | } 37 | 38 | async function search(adapters: Adapter[], url: URL, document: Document) { 39 | const scans = adapters.map((scan) => attempt(scan, url, document)); 40 | const results = await Promise.all(scans); 41 | return aggregate(results); 42 | } 43 | 44 | const stdadapters: Adapter[] = [ 45 | GitHub, 46 | GitLab, 47 | JiraCloud, 48 | JiraServer, 49 | Linear, 50 | Notion, 51 | Tara, 52 | Trello, 53 | Clickup, 54 | Plane, 55 | Polarion, 56 | ]; 57 | 58 | export { search, stdadapters }; 59 | export default search.bind(null, stdadapters); 60 | -------------------------------------------------------------------------------- /src/core/adapters/tara.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * tara.ai adapter 3 | * 4 | * The adapter extracts the current task's identifier from the page URL 5 | * and uses the DOM to retrieve information for the corresponding ticket. 6 | * Page URLs follow the pattern: 7 | * 8 | * https://app.tara.ai//tasks/ 9 | * 10 | * Each workspace has multiple projects (called "requirements"), task IDs 11 | * always start with `TASK-` followed by a 1-based index unique within the 12 | * given workspace. 13 | */ 14 | 15 | import { match } from "micro-match"; 16 | 17 | import type { TicketData } from "../types"; 18 | import { $all, $has, $text } from "./dom-helpers"; 19 | import { hasRequiredDetails } from "./utils"; 20 | 21 | /** 22 | * Tara has two ticket views, one in a modal-like overlay, one in full-page view. 23 | */ 24 | function isModalView(document: Document) { 25 | const modalViewSelector = 'input[data-cy="requirement-detail-name-input"]'; 26 | return $has(modalViewSelector, document); 27 | } 28 | 29 | function extractDescription(document: Document) { 30 | const editorRootNodes = $all(".DraftEditor-root", document); 31 | 32 | if (isModalView(document)) editorRootNodes.shift(); // Skip project information editor node 33 | 34 | const textNodes = $all('span[data-text="true"]', editorRootNodes[0]); 35 | 36 | return textNodes.map((node) => node.textContent).join("\n"); 37 | } 38 | 39 | async function scan(url: URL, document: Document): Promise { 40 | if (url.host !== "app.tara.ai") return []; 41 | 42 | const { index } = match("/:workspace/tasks/:index", url.pathname); 43 | 44 | if (!index) return []; 45 | 46 | const id = $text('span[data-cy="task-modal-task-id-text"]', document); 47 | const title = $text( 48 | 'textarea[data-cy="task-modal-task-name-text"]', 49 | document, 50 | ); 51 | const description = extractDescription(document); 52 | 53 | const ticket = { id, title, description, url: url.toString() }; 54 | 55 | if (hasRequiredDetails(ticket)) return [ticket]; 56 | 57 | return []; 58 | } 59 | 60 | export default scan; 61 | -------------------------------------------------------------------------------- /src/core/format/pretty-print.ts: -------------------------------------------------------------------------------- 1 | import * as markdown from "prettier/plugins/markdown"; 2 | import * as prettier from "prettier/standalone"; 3 | import unindent from "strip-indent"; 4 | 5 | const widths = { subject: 50, body: 72 }; 6 | 7 | async function format(text: string, width: number): Promise { 8 | return prettier.format(text, { 9 | parser: "markdown", 10 | plugins: [markdown], 11 | printWidth: width, 12 | proseWrap: "always", 13 | }); 14 | } 15 | 16 | function split(text: string, separator: string): [string, string | null] { 17 | const position = text.indexOf(separator); 18 | 19 | if (position < 0) return [text, null]; 20 | 21 | const head = text.substring(0, position); 22 | const tail = text.substring(position + separator.length); 23 | 24 | return [head, tail]; 25 | } 26 | 27 | function capitalize(text: string): string { 28 | return text.replace(/^[a-zA-Z]|\s[a-zA-Z]/, (w) => w.toUpperCase()); 29 | } 30 | 31 | async function gitsubject(text: string): Promise { 32 | const subject = capitalize(text.trim()); 33 | 34 | if (subject.length > widths.subject) { 35 | const [start, rest] = split( 36 | await format(subject, widths.subject - 1), 37 | "\n", 38 | ); 39 | return [`${start}…`, await format(`…${rest}`, widths.body)].join("\n\n"); 40 | } 41 | 42 | return subject; 43 | } 44 | 45 | async function gitbody(text: string): Promise { 46 | const body = unindent(text.replace(/^(\s*\n)*|\s*$/, "")); 47 | return format(body, widths.body); 48 | } 49 | 50 | async function maybe( 51 | value: string | null | undefined, 52 | fn: (text: string) => Promise, 53 | ): Promise { 54 | if (typeof value !== "string") return value; 55 | return fn(value); 56 | } 57 | 58 | async function print(text: string): Promise { 59 | const [line0, rest] = split(text.trim(), "\n"); 60 | const parts = [maybe(line0, gitsubject), maybe(rest, gitbody)]; 61 | return (await Promise.all(parts)) 62 | .filter((v: string | null | undefined) => v !== null) 63 | .join("\n\n"); 64 | } 65 | 66 | export default print; 67 | -------------------------------------------------------------------------------- /src/core/adapters/trello.test.ts: -------------------------------------------------------------------------------- 1 | import client from "../client"; 2 | import scan from "./trello"; 3 | 4 | jest.mock("../client"); 5 | 6 | const key = "haKn65Sy"; 7 | 8 | const shortLink = key; 9 | const name = "A quick summary of the card"; 10 | const slug = "4-a-quick-summary-of-the-card"; 11 | const desc = "A detailed description of the card"; 12 | const shortUrl = `https://trello.com/c/${shortLink}`; 13 | 14 | const response = { 15 | desc, 16 | name, 17 | shortLink, 18 | shortUrl, 19 | }; 20 | 21 | describe("trello adapter", () => { 22 | const url = (str: string) => new URL(str); 23 | const api = { get: jest.fn() }; 24 | 25 | beforeEach(() => { 26 | api.get.mockReturnValue({ json: () => response }); 27 | (client as jest.Mock).mockReturnValue(api); 28 | }); 29 | 30 | afterEach(() => { 31 | (client as jest.Mock).mockReset(); 32 | api.get.mockReset(); 33 | }); 34 | 35 | it("returns an empty array if it is on a different domain", async () => { 36 | const result = await scan(url(`https://other.com/c/${key}/${slug}`)); 37 | expect(result).toEqual([]); 38 | }); 39 | 40 | it("returns an empty array if it is on a different path", async () => { 41 | const result = await scan(url("https://trello.com/another/url")); 42 | expect(api.get).not.toHaveBeenCalled(); 43 | expect(result).toEqual([]); 44 | }); 45 | 46 | it("uses the correct endpoint", async () => { 47 | await scan(url("https://trello.com/c/aghy1jdk/1-ticket-title")); 48 | expect(client).toHaveBeenCalledWith("https://trello.com/1"); 49 | expect(api.get).toHaveBeenCalled(); 50 | }); 51 | 52 | it("extracts tickets from the current card", async () => { 53 | const result = await scan(url(`https://trello.com/c/${key}/${slug}`)); 54 | expect(client).toHaveBeenCalledWith("https://trello.com/1"); 55 | expect(api.get).toHaveBeenCalledWith(`cards/${key}`, { 56 | searchParams: { fields: "name,desc,shortLink,shortUrl" }, 57 | }); 58 | expect(result).toEqual([ 59 | { 60 | id: shortLink, 61 | title: name, 62 | description: desc, 63 | url: shortUrl, 64 | }, 65 | ]); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "airbnb", 9 | "airbnb/hooks", 10 | "plugin:import/typescript", 11 | "plugin:jest/recommended", 12 | "plugin:prettier/recommended", 13 | "plugin:@typescript-eslint/recommended" 14 | ], 15 | "globals": { 16 | "COMMITHASH": "readonly" 17 | }, 18 | "plugins": [ 19 | "simple-import-sort" 20 | ], 21 | "rules": { 22 | "@typescript-eslint/consistent-type-imports": [ 23 | "error", 24 | { 25 | "prefer": "type-imports" 26 | } 27 | ], 28 | "@typescript-eslint/explicit-module-boundary-types": "off", 29 | "@typescript-eslint/no-unused-vars": [ 30 | "error", 31 | { "argsIgnorePattern": "^_" } 32 | ], 33 | "@typescript-eslint/no-use-before-define": "error", 34 | "import/extensions": [ 35 | "error", 36 | { 37 | "js": "never", 38 | "json": "always", 39 | "ts": "never" 40 | } 41 | ], 42 | "import/no-extraneous-dependencies": [ 43 | "error", 44 | { 45 | "devDependencies": [ 46 | "webpack.config.ts", 47 | "postcss.config.js", 48 | "src/**/*.test.jsx", 49 | "src/**/*.test.tsx", 50 | "src/**/*.test.js", 51 | "src/**/*.test.ts", 52 | "test/**/*.js", 53 | "test/**/*.ts", 54 | "script/*" 55 | ] 56 | } 57 | ], 58 | "jsx-a11y/anchor-is-valid": [ 59 | "error", 60 | { 61 | "specialLink": ["to"] 62 | } 63 | ], 64 | "no-unused-vars": [ 65 | "error", 66 | { 67 | "argsIgnorePattern": "^_" 68 | } 69 | ], 70 | "no-use-before-define": "off", 71 | "prettier/prettier": "error", 72 | "react/jsx-filename-extension": [ 73 | "error", 74 | { 75 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 76 | } 77 | ], 78 | "react/jsx-props-no-spreading": "off", 79 | "react/require-default-props": "off", 80 | "react/prop-types": "off", 81 | "simple-import-sort/exports": "error", 82 | "simple-import-sort/imports": "error" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/core/adapters/github.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub and GitHub Enterprise adapter 3 | * 4 | * The adapter uses the DOM to extract ticket information from issues. 5 | */ 6 | 7 | import type { TicketData } from "../types"; 8 | import { $has, $text } from "./dom-helpers"; 9 | import { hasRequiredDetails } from "./utils"; 10 | 11 | /** 12 | * DOM selectors 13 | * 14 | * Element classes on GitHub have slightly changed over time. To maintain 15 | * support for some older GitHub Enterprise instances, the adapter will attempt 16 | * different versions of selectors. 17 | */ 18 | export const selectors = { 19 | default: { 20 | issuePage: 'div[data-testid="issue-viewer-container"]', 21 | issueId: 'div[data-component="TitleArea"] span', 22 | issueTitle: 'div[data-component="TitleArea"] bdi.markdown-title', 23 | }, 24 | legacy: { 25 | issuePage: ".js-issues-results .gh-header-number", 26 | issueId: ".gh-header-number", 27 | issueTitle: ".js-issue-title", 28 | }, 29 | }; 30 | 31 | async function attempt( 32 | doc: Document, 33 | select: (typeof selectors)[keyof typeof selectors], 34 | ) { 35 | // single issue page 36 | if ($has(select.issuePage, doc)) { 37 | const id = $text(select.issueId, doc)?.replace(/^#/, ""); 38 | const title = $text(select.issueTitle, doc); 39 | 40 | if (!id || !title) return []; 41 | 42 | const type = 43 | $text('div[data-testid="issue-type-container"]', doc) 44 | ?.toLowerCase() 45 | .trim() || $has('.js-issue-labels .IssueLabel[data-name="bug" i]', doc) 46 | ? "bug" 47 | : "feature"; 48 | 49 | const tickets = [{ id, title, type }]; 50 | return tickets; 51 | } 52 | 53 | return []; 54 | } 55 | 56 | async function tryScanDefaultThenLegacy(doc: Document) { 57 | const tickets = await attempt(doc, selectors.default); 58 | if (tickets.length > 0) return tickets; 59 | return attempt(doc, selectors.legacy); 60 | } 61 | 62 | async function scan(url: URL, document: Document): Promise { 63 | const project = url.pathname.split("/").slice(1, 3).join("/"); 64 | const tickets = await tryScanDefaultThenLegacy(document); 65 | return tickets.filter(hasRequiredDetails).map((ticket) => ({ 66 | url: `https://github.com/${project}/issues/${ticket.id}`, 67 | ...ticket, 68 | })) as TicketData[]; 69 | } 70 | 71 | export default scan; 72 | -------------------------------------------------------------------------------- /src/core/format/pretty-print.test.ts: -------------------------------------------------------------------------------- 1 | import print from "./pretty-print"; 2 | 3 | describe("pretty-print", () => { 4 | it("capitalizes subject lines", async () => { 5 | expect(await print("apply proper casing")).toBe("Apply proper casing"); 6 | expect(await print("[#42] capitalize subject")).toBe( 7 | "[#42] Capitalize subject", 8 | ); 9 | expect(await print("[#lowercase-id] capitalize subject")).toBe( 10 | "[#lowercase-id] Capitalize subject", 11 | ); 12 | }); 13 | 14 | it("wraps overlong subject lines", async () => { 15 | const input = 16 | "Wrap commit subject lines with more than 50 characters and insert one blank line before the remaining subject text. Wrap the remaining subject text to 72 characters."; 17 | expect(await print(input)).toMatchSnapshot(); 18 | }); 19 | 20 | it("wraps overlong body lines", async () => { 21 | const input = `Wrap body lines 22 | 23 | More detailed explanatory text is wrapped to 72 characters. The blank line separating the subject from the body is critical unless you omit the body entirely. 24 | `; 25 | 26 | expect(await print(input)).toMatchSnapshot(); 27 | }); 28 | 29 | it("formats lists", async () => { 30 | const input = `Format lists in body 31 | 32 | * Bullet points are okay too 33 | * Typically a hyphen or asterisk is used for the bullet, followed by a 34 | single space, with blank lines in between, but conventions vary here 35 | * Use a hanging indent 36 | `; 37 | 38 | expect(await print(input)).toMatchSnapshot(); 39 | }); 40 | 41 | it("strips leading and trailing blank lines and whitespace", async () => { 42 | const input = ` 43 | 44 | Remove whitespace around subject line 45 | 46 | 47 | Also, remove additional blank lines before and after body. 48 | 49 | Preserve blank lines between paragraphs of the body. 50 | 51 | Strip whitespace on empty lines (see line above). 52 | 53 | Strip trailing whitespace (see end of this line). 54 | 55 | `; 56 | 57 | expect(await print(input)).toMatchSnapshot(); 58 | }); 59 | 60 | it("strips body indentation", async () => { 61 | const input = `Strip body indentation 62 | 63 | Unindent this line. 64 | Move this one up. 65 | 66 | function leave(me) { 67 | return 'indented'; 68 | } 69 | 70 | And continue. 71 | `; 72 | 73 | expect(await print(input)).toMatchSnapshot(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/core/format/template.ts: -------------------------------------------------------------------------------- 1 | type Binding = Record; // eslint-disable-line @typescript-eslint/no-explicit-any 2 | type Helpers = Record any>; // eslint-disable-line @typescript-eslint/no-explicit-any 3 | 4 | const trim = (s: string): string => s.trim(); 5 | 6 | /* eslint-disable @typescript-eslint/no-explicit-any */ 7 | function safe(fn: (...a: any[]) => any) { 8 | return function wrapped(...args: any[]) { 9 | try { 10 | return fn(...args); 11 | } catch (err) { 12 | return `!!(${(err as Error).message})`; 13 | } 14 | }; 15 | } 16 | /* eslint-enable @typescript-eslint/no-explicit-any */ 17 | 18 | function raise(message: string) { 19 | return function raises() { 20 | throw new Error(message); 21 | }; 22 | } 23 | 24 | // Turn an expression into a pipeline function. 25 | // 26 | // Example expressions: 27 | // 28 | // 'lowercase' 29 | // 'lowercase()' 30 | // 'substring(3)' 31 | // 'substring(0, 10)' 32 | // 33 | function make(expr: string, transforms: Helpers = {}) { 34 | const [, name, , argstr = ""] = expr.match(/^([^()]+)(\((.+)\))?$/) ?? []; 35 | 36 | const fn = transforms[name]; 37 | 38 | if (typeof fn !== "function") return raise(`no helper named "${name}"`); 39 | 40 | try { 41 | const args = JSON.parse(`[${argstr}]`); 42 | return fn(...args); 43 | } catch { 44 | return raise(`invalid parameters provided to "${name}": ${argstr}`); 45 | } 46 | } 47 | 48 | // Compile a template string into a render function. 49 | // 50 | // Example strings: 51 | // 52 | // 'a = {v}' 53 | // 'b = {v | lowercase}' 54 | // 'c = {v | lowercase | substring(0, 3)}' 55 | // 56 | function compile(template: string, transforms = {}): (v?: Binding) => string { 57 | const parts = template.match(/\{[^}]*\}|[^{]+/g); 58 | 59 | if (parts === null) return () => template; 60 | 61 | const fns = parts.map((part) => { 62 | if (part[0] === "{" && part[part.length - 1] === "}") { 63 | const [key, ...procs] = part 64 | .replace(/^\{|\}$/g, "") 65 | .split("|") 66 | .map(trim); 67 | 68 | const pipeline = procs.map((expr) => safe(make(expr, transforms))); 69 | 70 | return (values: Binding) => 71 | pipeline.reduce((v, fn) => fn(v), values[key] ?? ""); 72 | } 73 | 74 | return () => part; 75 | }); 76 | 77 | return (values = {}) => fns.map((fn) => fn(values)).join(""); 78 | } 79 | 80 | export default compile; 81 | -------------------------------------------------------------------------------- /src/popup/components/about.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeftIcon } from "@primer/octicons-react"; 2 | import React from "react"; 3 | import { Link } from "react-router"; 4 | 5 | import { ReactComponent as Logo } from "../../icons/icon.svg"; 6 | import Content from "./content"; 7 | import ExternalLink from "./external-link"; 8 | import Navbar from "./navbar"; 9 | 10 | const repo = "https://github.com/bitcrowd/tickety-tick"; 11 | const tree = [repo, "tree", COMMITHASH].join("/"); 12 | 13 | export type Props = Record; 14 | 15 | function About(): React.ReactNode { 16 | return ( 17 | <> 18 | 19 |
    20 |
  • 21 | 22 | 23 | Back 24 | 25 |
  • 26 |
27 |
28 | 29 |

30 | Tickety-Tick 31 |

32 |
Usage:
33 |
    34 |
  1. Open a ticket in your favourite issue tracking system.
  2. 35 |
  3. Click the extension icon.
  4. 36 |
  5. 37 | Use the buttons to create a commit message, branch name or CLI 38 | command. 39 |
  6. 40 |
41 |

42 | This extension is open-source software by the fellows at{" "} 43 | bitcrowd. 44 |

45 |

46 | The source code is available on{" "} 47 | GitHub. 48 |

49 |

50 | 51 | {COMMITHASH} 52 | 53 |

54 |

55 | Logo by 56 | 57 | Ramón G. 58 | 59 | under CC-BY 3.0 60 |
61 | Other icons from 62 | 63 | GitHub Octicons 64 | 65 |

66 |
67 | 68 | ); 69 | } 70 | 71 | export default About; 72 | -------------------------------------------------------------------------------- /src/popup/components/copy-button.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from "@testing-library/react"; 2 | import pbcopy from "copy-text-to-clipboard"; 3 | import React from "react"; 4 | 5 | import type { Props } from "./copy-button"; 6 | import CopyButton from "./copy-button"; 7 | 8 | jest.mock("copy-text-to-clipboard"); 9 | jest.useFakeTimers(); 10 | 11 | describe("copy-button", () => { 12 | function subject(overrides: Partial) { 13 | const defaults: Props = { value: "copy text" }; 14 | const props = { ...defaults, ...overrides }; 15 | return render(); 16 | } 17 | 18 | let close: jest.SpyInstance; 19 | 20 | beforeEach(() => { 21 | close = jest.spyOn(window, "close").mockReturnValue(); 22 | }); 23 | 24 | afterEach(() => { 25 | jest.clearAllTimers(); 26 | close.mockRestore(); 27 | }); 28 | 29 | it("renders its children", () => { 30 | const children = "button content"; 31 | const screen = subject({ children }); 32 | const button = screen.getByRole("button"); 33 | expect(button).toHaveTextContent(children); 34 | }); 35 | 36 | it('passes the "copied" state to its children if passed a function', () => { 37 | const children = jest.fn(); 38 | const screen = subject({ children }); 39 | const button = screen.getByRole("button"); 40 | 41 | expect(children).toHaveBeenNthCalledWith(1, false); 42 | 43 | fireEvent.click(button); 44 | 45 | expect(children).toHaveBeenNthCalledWith(2, true); 46 | }); 47 | 48 | it("calls the context pbcopy function with the provided value on click", () => { 49 | const value = "a lot of value for such a small button"; 50 | const screen = subject({ value }); 51 | const button = screen.getByRole("button"); 52 | 53 | fireEvent.click(button); 54 | 55 | expect(pbcopy).toHaveBeenCalledWith(value); 56 | }); 57 | 58 | it("calls the context close function delayed after the value was copied", () => { 59 | const value = "a lot of value for such a small button"; 60 | const screen = subject({ value }); 61 | const button = screen.getByRole("button"); 62 | 63 | fireEvent.click(button); 64 | 65 | expect(pbcopy).toHaveBeenCalledWith(value); 66 | expect(close).not.toHaveBeenCalled(); 67 | 68 | jest.runOnlyPendingTimers(); 69 | expect(close).toHaveBeenCalled(); 70 | }); 71 | 72 | it("passes on any other properties to the rendered button", () => { 73 | const screen = subject({ value: "0x2a", className: "fancy-button" }); 74 | const button = screen.getByRole("button"); 75 | 76 | expect(button).toHaveClass("fancy-button"); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/core/adapters/jira-cloud.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Jira Cloud adapter 3 | * 4 | * This adapter is for cloud-hosted Jira and uses the v3 rest API for fetching tickets. 5 | * 6 | * The adapter extracts the identifier of the selected issue from the page URL 7 | * and uses the Jira API to retrieve the corresponding ticket information. 8 | * 9 | * https://developer.atlassian.com/server/jira/platform/rest-apis/ 10 | * 11 | * Supported page URLs: 12 | * - Backlog and Active Sprints: https://.atlassian.net/secure/RapidBoard.jspa?…&selectedIssue= 13 | * - Issues and filters: https://.atlassian.net/projects//issues/ 14 | * - Issue view: https://.atlassian.net/browse/ 15 | */ 16 | 17 | import { fromADF } from "mdast-util-from-adf"; 18 | import { gfmToMarkdown } from "mdast-util-gfm"; 19 | import { toMarkdown } from "mdast-util-to-markdown"; 20 | import { match } from "micro-match"; 21 | 22 | import client from "../client"; 23 | import type { TicketData } from "../types"; 24 | 25 | type JiraTicketInfo = { 26 | key: string; 27 | fields: { 28 | issuetype: { name: string }; 29 | summary: string; 30 | description: any; // eslint-disable-line @typescript-eslint/no-explicit-any 31 | }; 32 | }; 33 | 34 | function isJiraPage(url: URL) { 35 | if (url.host.endsWith(".atlassian.net")) return true; 36 | return false; 37 | } 38 | 39 | function getSelectedIssueId(url: URL) { 40 | const { searchParams: params } = new URL(url.href); 41 | 42 | if (params.has("selectedIssue")) return params.get("selectedIssue"); 43 | 44 | const path = url.pathname.replace(/^\/jira\/software/, ""); 45 | 46 | return ["/projects/:project/issues/:id", "/browse/:id"] 47 | .map((pattern) => match(pattern, path).id) 48 | .find(Boolean); 49 | } 50 | 51 | function extractTicketInfo(info: JiraTicketInfo, host: string) { 52 | const { key: id, fields } = info; 53 | const { issuetype, summary: title } = fields; 54 | 55 | const type = issuetype.name.toLowerCase(); 56 | const url = `https://${host}/browse/${id}`; 57 | const description = fields.description 58 | ? toMarkdown(fromADF(fields.description), { extensions: [gfmToMarkdown()] }) 59 | : undefined; 60 | 61 | return { 62 | type, 63 | id, 64 | title, 65 | description, 66 | url, 67 | }; 68 | } 69 | 70 | async function scan(url: URL): Promise { 71 | if (!isJiraPage(url)) return []; 72 | 73 | const id = getSelectedIssueId(url); 74 | 75 | if (!id) return []; 76 | 77 | const jira = client(`https://${url.host}/rest/api/3`); 78 | const response = await jira.get(`issue/${id}`).json(); 79 | const ticket = extractTicketInfo(response, url.host); 80 | 81 | return [ticket]; 82 | } 83 | 84 | export default scan; 85 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | SCRIPTDIR=$(cd "$(dirname "$0")"; pwd) 8 | # shellcheck source=SCRIPTDIR/lib/utils 9 | source "$SCRIPTDIR/lib/utils" 10 | 11 | cd "$(dirname "$0")/.." 12 | 13 | ./script/check-release-dependencies 14 | 15 | branch=$(git branch --show-current) 16 | 17 | if [[ "$branch" != "main" ]]; then 18 | abort "Please only release from the main branch." 19 | fi 20 | 21 | yarn checks 22 | 23 | version=$(script/version) 24 | tag="v$version" 25 | 26 | git tag "$tag" 27 | git push origin "$tag" 28 | 29 | # Build artifacts. 30 | yarn bundle:chrome 31 | yarn bundle:firefox 32 | 33 | # Create a GitHub release, upload build artifacts. 34 | gh release create "$tag" \ 35 | --generate-notes \ 36 | --verify-tag \ 37 | ./dist/*.zip 38 | 39 | gh release download "$tag"\ 40 | --archive=zip \ 41 | --clobber \ 42 | --output="release-artifacts/tickety_tick-${tag##v}.zip" 43 | 44 | if has op; then 45 | apikey=$(op item get "bgi26drb3naqhipfmt5wth6634" --field key) 46 | apisecret=$(op item get "bgi26drb3naqhipfmt5wth6634" --reveal --field secret) 47 | client_id=$(op item get "yjtvyfa4wcygxlps6smdeybdu4" --field "Client ID") 48 | client_secret=$(op item get "yjtvyfa4wcygxlps6smdeybdu4" --reveal --field "Client Secret") 49 | refresh_token=$(op item get "yjtvyfa4wcygxlps6smdeybdu4" --reveal --field "OAuth Refresh Token") 50 | else 51 | # Mozilla Add-Ons 52 | read -rp "Please provide your Mozilla API Key: " apikey 53 | read -srp "Please provide the corresponding API Secret: " apisecret 54 | printf "\n\n" 55 | 56 | # Chrome Web Store 57 | read -rp "Please provide your Chrome Web Store Publish API client ID: " client_id 58 | read -srp "Please provide your Chrome Web Store Publish API client secret: " client_secret 59 | printf "\n" 60 | read -srp "Please provide your Chrome Web Store Publish API refresh token: " refresh_token 61 | printf "\n\n" 62 | fi 63 | 64 | yarn web-ext sign \ 65 | --api-key "$apikey" \ 66 | --api-secret "$apisecret" \ 67 | --source-dir ./dist/firefox \ 68 | --channel=listed \ 69 | --upload-source-code="release-artifacts/tickety_tick-${tag##v}.zip" \ 70 | --artifacts-dir="./release-artifacts" 71 | 72 | printf "\n\n" 73 | 74 | gh release upload \ 75 | "$tag" \ 76 | "release-artifacts/tickety_tick-${tag##v}.xpi" 77 | 78 | printf "\n\n" 79 | 80 | yarn chrome-webstore-upload upload \ 81 | --client-id "$client_id" \ 82 | --client-secret "$client_secret" \ 83 | --refresh-token "$refresh_token" \ 84 | --extension-id "ciakolhgmfijpjbpcofoalfjiladihbg" \ 85 | --auto-publish \ 86 | --source ./dist/chrome.zip 87 | 88 | cat <; 22 | }; 23 | }; 24 | 25 | type NotionTicketResponse = { 26 | results: NotionTicketInfo[]; 27 | }; 28 | 29 | /** 30 | * Turns a slugged page ID without dashes into a dasherized RFC 4122 UUID. 31 | * UUID-Format: 96ec637d-e4b0-4a5e-acf3-8d4d9a1b2e4b 32 | */ 33 | function uuid(slugId: string) { 34 | return [ 35 | slugId.substring(0, 8), 36 | slugId.substring(8, 12), 37 | slugId.substring(12, 16), 38 | slugId.substring(16, 20), 39 | slugId.substring(20), 40 | ].join("-"); 41 | } 42 | 43 | function getPageFromPath(path: string) { 44 | const { slug } = match(":organization?/:slug", path); 45 | if (!slug) return null; 46 | 47 | return slug.replace(/.*-/, ""); // strip title from slug 48 | } 49 | 50 | function getSelectedPageId(url: URL) { 51 | const { pathname: path, searchParams: params } = new URL(url.href); 52 | const isPageModal = params.has("p"); 53 | const slugId = isPageModal ? params.get("p") : getPageFromPath(path); 54 | 55 | if (!slugId) return null; 56 | return slugId; 57 | } 58 | 59 | function extractTicketInfo(result: NotionTicketInfo) { 60 | const { value } = result; 61 | if (!value) return undefined; 62 | 63 | const { id, type } = value; 64 | if (type !== "page") return undefined; 65 | 66 | const title = value.properties.title[0][0]; 67 | return { id, title, type }; 68 | } 69 | 70 | function getTickets(response: NotionTicketResponse, id: string) { 71 | return (response.results || []) 72 | .map(extractTicketInfo) 73 | .filter((t) => t && t.id === id) as TicketData[]; 74 | } 75 | 76 | async function scan(url: URL): Promise { 77 | if (url.host !== "www.notion.so") return []; 78 | 79 | const slugId = getSelectedPageId(url); 80 | if (!slugId) return []; 81 | 82 | const id = uuid(slugId); 83 | const api = client(`https://${url.host}`); 84 | const request = { json: { requests: [{ table: "block", id }] } }; 85 | const response = await api 86 | .post("api/v3/getRecordValues", request) 87 | .json(); 88 | 89 | return getTickets(response, id).map((ticket) => ({ 90 | url: `https://www.notion.so/${slugId}`, 91 | ...ticket, 92 | })) as TicketData[]; 93 | } 94 | 95 | export default scan; 96 | -------------------------------------------------------------------------------- /src/core/adapters/jira-server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Jira Server adapter 3 | * 4 | * This adapter is for self-hosted jira instances and uses the old v2 rest API for fetching tickets. 5 | * 6 | * The adapter extracts the identifier of the selected issue from the page URL 7 | * and uses the Jira API to retrieve the corresponding ticket information. 8 | * 9 | * https://my-jira.org/server/jira/platform/rest-apis/ 10 | * 11 | * Supported page URLs: 12 | * - Backlog and Active Sprints: https:///secure/RapidBoard.jspa?…&selectedIssue= 13 | * - Issues and filters: https:///projects//issues/ 14 | * - Issue view: https:///browse/ 15 | */ 16 | 17 | import { match } from "micro-match"; 18 | 19 | import client from "../client"; 20 | import type { TicketData } from "../types"; 21 | 22 | type JiraTicketInfo = { 23 | key: string; 24 | fields: { 25 | issuetype: { name: string }; 26 | summary: string; 27 | description: string; 28 | }; 29 | }; 30 | 31 | function isJiraPage(_url: URL, document: Document) { 32 | if (document.body.id === "jira") return true; 33 | return false; 34 | } 35 | 36 | const pathSuffixes = 37 | /\/(browse\/[^/]+|projects\/[^/]+\/issues\/[^/]+|secure\/RapidBoard\.jspa|jira\/software\/([^/]\/)*projects\/[^/]+\/boards\/.*)$/g; 38 | 39 | function getPathPrefix(url: URL) { 40 | return url.pathname.replace(pathSuffixes, ""); 41 | } 42 | 43 | function getSelectedIssueId(url: URL, prefix = "") { 44 | const { searchParams: params } = new URL(url.href); 45 | 46 | if (params.has("selectedIssue")) return params.get("selectedIssue"); 47 | 48 | const path = url.pathname.substring(prefix.length); // strip path prefix 49 | 50 | return ["/projects/:project/issues/:id", "/browse/:id"] 51 | .map((pattern) => match(pattern, path).id) 52 | .find(Boolean); 53 | } 54 | 55 | function extractTicketInfo(info: JiraTicketInfo, host: string) { 56 | const { 57 | key: id, 58 | fields: { issuetype, summary: title, description }, 59 | } = info; 60 | const type = issuetype.name.toLowerCase(); 61 | const url = `https://${host}/browse/${id}`; 62 | 63 | return { 64 | type, 65 | id, 66 | title, 67 | description, 68 | url, 69 | }; 70 | } 71 | 72 | async function scan(url: URL, document: Document): Promise { 73 | if (!isJiraPage(url, document)) return []; 74 | 75 | const prefix = getPathPrefix(url); // self-managed instances may host on a subpath 76 | 77 | const id = getSelectedIssueId(url, prefix); 78 | 79 | if (!id) return []; 80 | 81 | const jira = client(`https://${url.host}${prefix}/rest/api/latest`); 82 | const response = await jira.get(`issue/${id}`).json(); 83 | const ticket = extractTicketInfo(response, url.host); 84 | 85 | return [ticket]; 86 | } 87 | 88 | export default scan; 89 | -------------------------------------------------------------------------------- /script/open-in-chrome.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // usage: open-in-chrome [extension-dir] [starting-url] 4 | 5 | import * as path from "path"; 6 | import * as launcher from "chrome-launcher"; 7 | 8 | const dir = process.argv[2] || path.join(__dirname, "..", "dist", "chrome"); 9 | const url = process.argv[3] || "https://github.com/bitcrowd/tickety-tick"; 10 | 11 | async function launchChrome() { 12 | const chromeFlags = launcher.Launcher.defaultFlags() 13 | .filter((flag) => flag !== "--disable-extensions") 14 | .concat([ 15 | "--remote-debugging-pipe", 16 | "--enable-unsafe-extension-debugging", 17 | "--no-first-run", 18 | "--no-default-browser-check", 19 | ]); 20 | 21 | const options = { 22 | chromeFlags, 23 | ignoreDefaultFlags: true, 24 | startingUrl: url, 25 | }; 26 | 27 | const chrome = await launcher.launch(options); 28 | 29 | if (chrome.port !== 0) { 30 | console.warn( 31 | "⚠️ Expected remote-debugging-pipe mode on port 0, but got a debug port.", 32 | ); 33 | } 34 | 35 | const pipes = chrome.remoteDebuggingPipes; 36 | if (!pipes) { 37 | throw new Error("Chrome did not expose remoteDebuggingPipes"); 38 | } 39 | 40 | console.log("🚀 Chrome launched with remote-debugging-pipe."); 41 | console.log(`📂 Loading extension from: ${dir}`); 42 | 43 | const requestId = Math.floor(Math.random() * 1e6); 44 | const request = { 45 | id: requestId, 46 | method: "Extensions.loadUnpacked", 47 | params: { path: dir }, 48 | }; 49 | 50 | // --- Send command and wait for response 51 | const firstResponse = new Promise((resolve, reject) => { 52 | let buffer = ""; 53 | 54 | pipes.incoming.on("error", reject); 55 | pipes.incoming.on("close", () => 56 | reject(new Error("Pipe closed before response")), 57 | ); 58 | 59 | pipes.incoming.on("data", (chunk) => { 60 | buffer += chunk; 61 | let end; 62 | while ((end = buffer.indexOf("\x00")) !== -1) { 63 | const message = buffer.slice(0, end); 64 | buffer = buffer.slice(end + 1); 65 | try { 66 | const parsed = JSON.parse(message); 67 | if (parsed.id === requestId) { 68 | resolve(parsed); 69 | } 70 | } catch { 71 | // ignore non-JSON noise 72 | } 73 | } 74 | }); 75 | }); 76 | 77 | pipes.outgoing.write(JSON.stringify(request) + "\x00"); 78 | 79 | const response = await firstResponse; 80 | if (response.error) { 81 | throw new Error(`Failed to load extension: ${response.error.message}`); 82 | } 83 | 84 | console.log(`✅ Extension loaded (id: ${response.result.id})`); 85 | console.log(`🌐 Opening: ${url}`); 86 | 87 | chrome.process.on("exit", () => { 88 | console.log("💨 Chrome closed."); 89 | process.exit(0); 90 | }); 91 | } 92 | 93 | launchChrome().catch((err) => { 94 | console.error("❌ Error:", err); 95 | process.exit(1); 96 | }); 97 | -------------------------------------------------------------------------------- /src/popup/components/ticket-controls.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import { ticket as make } from "../../../test/factories"; 5 | import type { Props } from "./ticket-controls"; 6 | import TicketControls from "./ticket-controls"; 7 | 8 | describe("ticket-controls", () => { 9 | function subject(overrides: Partial) { 10 | const defaults: Props = { 11 | tickets: ["raz", "dva", "tri"].map((title, i) => 12 | make({ id: `${i + 1}`, title }), 13 | ), 14 | }; 15 | const props = { ...defaults, ...overrides }; 16 | return render(); 17 | } 18 | 19 | it("renders for multiple tickets", () => { 20 | expect(subject({}).asFragment()).toMatchSnapshot(); 21 | }); 22 | 23 | it("renders for a single ticket", () => { 24 | const tickets = [make({ id: "1", title: "raz" })]; 25 | expect(subject({ tickets })).toMatchSnapshot(); 26 | }); 27 | 28 | it("renders a dropdown if there is more than one ticket", () => { 29 | const tickets = ["raz", "dva", "tri"].map((title, i) => 30 | make({ id: `${i + 1}`, title }), 31 | ); 32 | const screen = subject({ tickets }); 33 | const select = screen.getByRole("combobox"); 34 | expect(select).not.toBeDisabled(); 35 | }); 36 | 37 | it("renders a disabled dropdown if there is only one ticket", () => { 38 | const tickets = [make({ id: "2", title: "dva" })]; 39 | const screen = subject({ tickets }); 40 | const select = screen.getByRole("combobox"); 41 | expect(select).toBeDisabled(); 42 | }); 43 | 44 | it("renders copy buttons for branch, commit and command of the selected ticket", () => { 45 | const ticket1 = make({ id: "1", title: "raz" }); 46 | const ticket2 = make({ id: "2", title: "dva" }); 47 | const screen = subject({ tickets: [ticket1, ticket2] }); 48 | const select = screen.getByRole("combobox"); 49 | let buttons = screen.getAllByRole("button") as HTMLButtonElement[]; 50 | 51 | expect(buttons).toHaveLength(3); 52 | expect(buttons.map((button) => button.value)).toEqual([ 53 | ticket1.fmt.branch, 54 | ticket1.fmt.commit, 55 | ticket1.fmt.command, 56 | ]); 57 | 58 | // select the second ticket (index = 1) 59 | fireEvent.change(select, { target: { value: 1 } }); 60 | 61 | buttons = screen.getAllByRole("button") as HTMLButtonElement[]; 62 | expect(buttons).toHaveLength(3); 63 | expect(buttons.map((button) => button.value)).toEqual([ 64 | ticket2.fmt.branch, 65 | ticket2.fmt.commit, 66 | ticket2.fmt.command, 67 | ]); 68 | }); 69 | 70 | it("allows to cycle through the buttons and the select via the tab key", () => { 71 | const screen = subject({}); 72 | const buttons = screen.getAllByRole("button"); 73 | const select = screen.getByRole("combobox"); 74 | 75 | buttons.forEach((button) => { 76 | expect(button).toHaveAttribute("tabIndex", "1"); 77 | }); 78 | expect(select).toHaveAttribute("tabIndex", "1"); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/popup/components/ticket-controls.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CheckIcon, 3 | CommentIcon, 4 | GitBranchIcon, 5 | TerminalIcon, 6 | } from "@primer/octicons-react"; 7 | import React from "react"; 8 | 9 | import type { TicketWithFmt } from "../../types"; 10 | import useInput from "../hooks/use-input"; 11 | import type { Props as CopyButtonProps } from "./copy-button"; 12 | import CopyButton from "./copy-button"; 13 | 14 | function TicketCopyButton({ 15 | icon, 16 | label, 17 | title, 18 | value, 19 | ...rest 20 | }: CopyButtonProps & { 21 | icon: React.ElementType; 22 | label: string; 23 | title: string; 24 | value: string; 25 | }) { 26 | return ( 27 | 33 | {(copied: boolean) => { 34 | const IconComponent = copied ? CheckIcon : icon; 35 | 36 | return ( 37 | <> 38 | 39 | 40 | {copied ? "Copied" : label} 41 | 42 | 43 | ); 44 | }} 45 | 46 | ); 47 | } 48 | 49 | export type Props = { 50 | tickets: TicketWithFmt[]; 51 | }; 52 | 53 | /* eslint-disable jsx-a11y/tabindex-no-positive */ 54 | 55 | function TicketControls({ tickets }: Props) { 56 | const select = useInput("0"); 57 | 58 | const ticket = tickets[Number.parseInt(select.value ?? "0", 10)]; 59 | 60 | return ( 61 | 62 |
63 | 70 | 77 | 84 |
85 |
86 | 99 |
100 | 101 | ); 102 | } 103 | 104 | /* eslint-enable jsx-a11y/tabindex-no-positive */ 105 | 106 | export default TicketControls; 107 | -------------------------------------------------------------------------------- /src/core/format/index.test.ts: -------------------------------------------------------------------------------- 1 | import type { Ticket } from "../../types"; 2 | import format, { helpers } from "."; 3 | import pprint from "./pretty-print"; 4 | import type { Formatter } from "./types"; 5 | 6 | jest.mock("./pretty-print", () => jest.fn()); 7 | 8 | describe("ticket formatting", () => { 9 | const ticket: Ticket = { 10 | id: "BTC-042", 11 | title: "Add more tests for src/common/format/index.js", 12 | type: "new enhancement", 13 | }; 14 | 15 | beforeEach(() => { 16 | (pprint as jest.Mock).mockClear(); 17 | }); 18 | 19 | describe("default format", () => { 20 | let fmt: Formatter; 21 | beforeEach(async () => { 22 | fmt = await format({ commit: "", branch: "", command: "" }, false); 23 | }); 24 | 25 | describe("commit", () => { 26 | it("includes ticket id and title", async () => { 27 | const formatted = await fmt.commit(ticket); 28 | expect(formatted).toBe(`[#${ticket.id}] ${ticket.title}`); 29 | }); 30 | }); 31 | 32 | describe("branch", () => { 33 | const slugify = helpers.slugify(); 34 | 35 | it("includes ticket type, id and title", async () => { 36 | const formatted = await fmt.branch(ticket); 37 | expect(formatted).toBe( 38 | `${slugify(ticket.type)}/${slugify(ticket.id)}-${slugify( 39 | ticket.title, 40 | )}`, 41 | ); 42 | }); 43 | }); 44 | 45 | describe("command", () => { 46 | const shellquote = helpers.shellquote(); 47 | 48 | it("includes the quoted branch name and commit message", async () => { 49 | const branch = await fmt.branch(ticket); 50 | const commit = await fmt.commit(ticket); 51 | 52 | const formatted = await fmt.command(ticket); 53 | 54 | expect(formatted).toBe( 55 | `git checkout -b ${shellquote(branch)}` + 56 | ` && git commit --allow-empty -m ${shellquote(commit)}`, 57 | ); 58 | }); 59 | }); 60 | }); 61 | 62 | describe("with template overrides", () => { 63 | (["branch", "commit", "command"] as const).forEach((key) => { 64 | describe(`${key}`, () => { 65 | it("renders the custom template", async () => { 66 | const template = `${key}-formatted`; 67 | const fmt = await format({ [key]: template }, false); 68 | const formatted = await fmt[key](ticket); 69 | expect(formatted).toBe(template); 70 | }); 71 | }); 72 | }); 73 | }); 74 | 75 | describe("with pretty-printing enabled", () => { 76 | let stdfmt: Formatter; 77 | let fmt: Formatter; 78 | beforeEach(async () => { 79 | stdfmt = await format({}, false); 80 | fmt = await format({}, true); 81 | }); 82 | 83 | describe("commit", () => { 84 | it("is pretty-printed", async () => { 85 | (pprint as jest.Mock).mockReturnValue("pretty-printed commit"); 86 | const original = await stdfmt.commit(ticket); 87 | const formatted = await fmt.commit(ticket); 88 | expect(pprint).toHaveBeenCalledWith(original); 89 | expect(formatted).toBe("pretty-printed commit"); 90 | }); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/core/format/template.test.ts: -------------------------------------------------------------------------------- 1 | import compile from "./template"; 2 | 3 | describe("template", () => { 4 | it("replaces any value occurrences", () => { 5 | const render = compile('{number} => "{word}"'); 6 | const output = render({ number: 12, word: "dodici" }); 7 | expect(output).toBe('12 => "dodici"'); 8 | }); 9 | 10 | it("handles missing values", () => { 11 | const transforms = { sparkle: () => (s: string) => `*${s}*` }; 12 | const render = compile("--{nope | sparkle}", transforms); 13 | expect(render({})).toBe("--**"); 14 | expect(render()).toBe("--**"); 15 | }); 16 | 17 | it("applies value transformations", () => { 18 | const lowercase = jest.fn((s) => s.toLowerCase()); 19 | const dasherize = jest.fn((s) => s.replace(/\s+/g, "-")); 20 | 21 | const transforms = { 22 | lowercase: () => lowercase, 23 | dasherize: () => dasherize, 24 | }; 25 | 26 | const render = compile("= {title | lowercase | dasherize}", transforms); 27 | const output = render({ title: "A B C" }); 28 | 29 | expect(lowercase).toHaveBeenCalledWith("A B C"); 30 | expect(dasherize).toHaveBeenCalledWith("a b c"); 31 | expect(output).toBe("= a-b-c"); 32 | }); 33 | 34 | it("supports parameterized transformations", () => { 35 | const long = "abcdefghijklmnopqrstuvwxyz"; 36 | const substring = (start: number, end: number) => (s: string) => 37 | s.substring(start, end); 38 | const transforms = { substring }; 39 | 40 | const render = compile("pre {long | substring(15, 18)} post", transforms); 41 | const output = render({ long }); 42 | 43 | expect(output).toBe("pre pqr post"); 44 | }); 45 | 46 | it("handles missing transformations", () => { 47 | const render = compile("a{a | ??}", {}); 48 | const output = render({ a: "++" }); 49 | expect(output).toBe('a!!(no helper named "??")'); 50 | }); 51 | 52 | it("handles invalid transformation parameters", () => { 53 | const int = (s: string): number => Number.parseInt(s, 10); 54 | const pow = (exp: string) => (s: string) => int(s) ** int(exp); 55 | 56 | const render = compile("{a | pow(break)}", { pow }); 57 | const output = render({ a: 12 }); 58 | 59 | expect(output).toBe('!!(invalid parameters provided to "pow": break)'); 60 | }); 61 | 62 | it("ignores whitespace within template expressions", () => { 63 | const transforms = { 64 | triple: () => (a: number) => a * 3, 65 | square: () => (a: number) => a * a, 66 | }; 67 | 68 | const render = compile( 69 | "({ a } * 3)**2 = { a | triple | square }", 70 | transforms, 71 | ); 72 | const output = render({ a: 2 }); 73 | 74 | expect(output).toBe("(2 * 3)**2 = 36"); 75 | }); 76 | 77 | it("handles incomplete template expressions (no closing brace)", () => { 78 | const render = compile("{", {}); 79 | const output = render({}); 80 | expect(output).toBe("{"); 81 | }); 82 | 83 | it("handles incomplete template expressions (incomplete filter pipeline)", () => { 84 | const trim = () => (s: string) => s.trim(); 85 | const render = compile("{a | trim |}", { trim }); 86 | const output = render({}); 87 | expect(output).toBe('!!(no helper named "undefined")'); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/core/adapters/polarion.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import scan from "./polarion"; 8 | 9 | const url = new URL( 10 | "https://polarion.company-url.com/polarion/#/project/test_project/workitem?id=RC-654", 11 | ); 12 | 13 | const htmlSnippet = ` 14 |
15 |
16 | 17 | 18 | 19 | 37 | 38 | 39 |
20 | 21 | 22 | 23 | 33 | 34 | 35 |
24 | 25 | 26 | 27 | 28 | 29 | RC-654 - Update Sha.js 30 | 31 | 32 |
36 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 49 | 50 | 51 |
Type: 47 | Task 48 |
52 | 53 | 54 |
55 |
56 | 57 | Use new versions of sha.js to address security issues. 58 | 59 |
60 |
61 | 62 |
63 | `; 64 | 65 | describe("polarion adapter", () => { 66 | function doc(body = "") { 67 | const { window } = new JSDOM( 68 | `Title${body}`, 69 | ); 70 | return window.document; 71 | } 72 | 73 | it("returns an empty array when page does not contain a valid workitem", async () => { 74 | const result = await scan(url, doc("
Invalid content
")); 75 | 76 | expect(result).toEqual([]); 77 | }); 78 | 79 | it("extracts ticket from Polarion workitem page", async () => { 80 | const result = await scan(url, doc(htmlSnippet)); 81 | 82 | expect(result).toEqual([ 83 | { 84 | id: "RC-654", 85 | title: "Update Sha.js", 86 | type: "feature", 87 | url: url.toString(), 88 | description: "Use new versions of sha.js to address security issues.", 89 | }, 90 | ]); 91 | }); 92 | 93 | it("extracts ticket with type 'bug' when type field is 'Bug'", async () => { 94 | const bugHtml = htmlSnippet.replace('title="Task">Task', 'title="Bug">Bug'); 95 | const result = await scan(url, doc(bugHtml)); 96 | 97 | expect(result[0].type).toBe("bug"); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/options/components/template-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import type { TextareaAutosizeProps } from "react-textarea-autosize"; 3 | import TextareaAutosize from "react-textarea-autosize"; 4 | 5 | function shouldRenderAsTextarea( 6 | props: React.InputHTMLAttributes | TextareaAutosizeProps, 7 | multiline: boolean, 8 | ): props is TextareaAutosizeProps { 9 | return multiline; 10 | } 11 | 12 | export type TemplateInputElementProps = { multiline: boolean } & ( 13 | | React.InputHTMLAttributes 14 | | TextareaAutosizeProps 15 | ); 16 | 17 | function TemplateInputElement({ 18 | multiline, 19 | ...props 20 | }: TemplateInputElementProps) { 21 | if (shouldRenderAsTextarea(props, multiline)) { 22 | return ; 23 | } 24 | 25 | return ; 26 | } 27 | 28 | const noop = () => undefined; 29 | 30 | const isPromise = (input: Promise | string) => input instanceof Promise; 31 | 32 | export type Props = { 33 | disabled: boolean; 34 | fallback: string; 35 | id: string; 36 | label: string; 37 | icon: React.ReactElement; 38 | name: string; 39 | multiline?: boolean; 40 | onChange: React.ChangeEventHandler; 41 | preview: string | Promise; 42 | value: string; 43 | }; 44 | 45 | function TemplateInput(props: Props) { 46 | const { 47 | id, 48 | name, 49 | label, 50 | icon, 51 | value, 52 | multiline = false, 53 | fallback, 54 | disabled, 55 | onChange, 56 | preview, 57 | } = props; 58 | 59 | const [previewString, setPreviewString] = useState( 60 | isPromise(preview) ? "" : (preview as string), 61 | ); 62 | 63 | useEffect(() => { 64 | if (isPromise(preview)) { 65 | const setPreview = async () => setPreviewString(await preview); 66 | setPreview(); 67 | } else { 68 | setPreviewString(preview); 69 | } 70 | }, [preview]); 71 | 72 | const setValue = (newValue: string) => 73 | onChange({ 74 | target: { name, value: newValue }, 75 | } as React.ChangeEvent); 76 | 77 | const onFocus = value ? noop : () => setValue(fallback); 78 | const onBlur = value === fallback ? () => setValue("") : noop; 79 | 80 | return ( 81 |
82 |
83 | 89 | 101 |
102 |
103 |
104 |
108 |             {previewString}
109 |           
110 |
111 |
112 |
113 | ); 114 | } 115 | 116 | export { TemplateInputElement }; 117 | export default TemplateInput; 118 | -------------------------------------------------------------------------------- /src/core/format/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import * as helpers from "./helpers"; 2 | 3 | describe("format helpers", () => { 4 | describe("lowercase", () => { 5 | const lowercase = helpers.lowercase(); 6 | 7 | it("lowercases strings", () => { 8 | expect(lowercase("QUIET")).toBe("quiet"); 9 | }); 10 | }); 11 | 12 | describe("shellquote", () => { 13 | const shellquote = helpers.shellquote(); 14 | 15 | it("wraps the input in single-quotes", () => { 16 | expect(shellquote('echo "pwned"')).toBe("'echo \"pwned\"'"); 17 | }); 18 | 19 | it("escapes any single-quotes in the input", () => { 20 | const input = "you'; echo aren't \"pwned\""; 21 | const quoted = "'you'\\''; echo aren'\\''t \"pwned\"'"; 22 | expect(shellquote(input)).toBe(quoted); 23 | }); 24 | }); 25 | 26 | describe("slugify", () => { 27 | const slugify = helpers.slugify(); 28 | 29 | it("formats normal strings", () => { 30 | const formatted = slugify("hello"); 31 | expect(formatted).toBe("hello"); 32 | }); 33 | 34 | it("lowercases strings", () => { 35 | const formatted = slugify("Bitcrowd"); 36 | expect(formatted).toBe("bitcrowd"); 37 | }); 38 | 39 | it("formats spaces to dashes", () => { 40 | const formatted = slugify("hello bitcrowd"); 41 | expect(formatted).toBe("hello-bitcrowd"); 42 | }); 43 | 44 | it("formats special characters", () => { 45 | const formatted = slugify("Señor Dévèloper"); 46 | expect(formatted).toBe("senor-developer"); 47 | }); 48 | 49 | it("formats umlauts", () => { 50 | const formatted = slugify("äöüß"); 51 | expect(formatted).toBe("aeoeuess"); 52 | }); 53 | 54 | it("strips brackets", () => { 55 | const formatted = slugify("[#23] Add (more)"); 56 | expect(formatted).toBe("23-add-more"); 57 | }); 58 | 59 | it("formats slashes to dashes", () => { 60 | const formatted = slugify("src/js/format"); 61 | expect(formatted).toBe("src-js-format"); 62 | }); 63 | 64 | it("formats dots to dashes", () => { 65 | const formatted = slugify("format.js"); 66 | expect(formatted).toBe("format-js"); 67 | }); 68 | 69 | it("strips hashes", () => { 70 | const formatted = slugify("##23 #hashtag"); 71 | expect(formatted).toBe("23-hashtag"); 72 | }); 73 | 74 | it("accepts a custom separator", () => { 75 | const formatted = helpers.slugify("_")("##23 #hashtag"); 76 | expect(formatted).toBe("23_hashtag"); 77 | }); 78 | }); 79 | 80 | describe("substring", () => { 81 | const substring = helpers.substring(3, 6); 82 | 83 | it("returns the specified slice of a string", () => { 84 | expect(substring("abcdefghi")).toBe("def"); 85 | }); 86 | }); 87 | 88 | describe("trim", () => { 89 | const trim = helpers.trim(); 90 | 91 | it("removes leading and trailing whitespace", () => { 92 | expect(trim("\t black\t\t ")).toBe("black"); 93 | }); 94 | }); 95 | 96 | describe("truncate", () => { 97 | const truncate = helpers.truncate(3); 98 | 99 | it("truncates strings longer than the limit", () => { 100 | expect(truncate("abcd")).toBe("ab…"); 101 | }); 102 | 103 | it("returns short strings unchanged", () => { 104 | expect(truncate("abc")).toBe("abc"); 105 | }); 106 | }); 107 | 108 | describe("uppercase", () => { 109 | const uppercase = helpers.uppercase(); 110 | 111 | it("uppercases strings", () => { 112 | expect(uppercase("loud")).toBe("LOUD"); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/popup/components/tool.test.tsx: -------------------------------------------------------------------------------- 1 | import type { RenderResult } from "@testing-library/react"; 2 | import { fireEvent, render, waitFor } from "@testing-library/react"; 3 | import React from "react"; 4 | import browser from "webextension-polyfill"; 5 | 6 | import { ticket as make } from "../../../test/factories"; 7 | import * as router from "../../../test/router"; 8 | import type { Props as ErrorDetailsProps } from "./error-details"; 9 | import type { Props } from "./tool"; 10 | import Tool from "./tool"; 11 | 12 | jest.mock( 13 | "./error-details", 14 | () => 15 | function Component({ errors }: ErrorDetailsProps) { 16 | const info = errors.map((e) => e.message).join("\n"); 17 | return ( 18 | 21 | ); 22 | }, 23 | ); 24 | 25 | describe("tool", () => { 26 | const tickets = ["un", "deux", "trois"].map((title, i) => 27 | make({ id: `${i + 1}`, title }), 28 | ); 29 | const errors = [new Error("ouch")]; 30 | 31 | let screen: RenderResult; 32 | 33 | let openOptions: jest.SpyInstance, []>; 34 | let close: jest.SpyInstance; 35 | 36 | function subject(overrides: Partial) { 37 | const defaults: Props = { tickets: [], errors: [] }; 38 | const props = { ...defaults, ...overrides }; 39 | return render(, { wrapper: router.wrapper }); 40 | } 41 | 42 | beforeEach(() => { 43 | openOptions = jest.spyOn(browser.runtime, "openOptionsPage"); 44 | close = jest.spyOn(window, "close"); 45 | }); 46 | 47 | afterEach(() => { 48 | openOptions.mockReset(); 49 | close.mockRestore(); 50 | }); 51 | 52 | describe("always", () => { 53 | beforeEach(() => { 54 | screen = subject({ tickets: [], errors: [] }); 55 | openOptions.mockReset().mockResolvedValue(); 56 | close.mockReset(); 57 | }); 58 | 59 | it("renders a settings link", async () => { 60 | const link = screen.getByRole("link", { name: "Settings" }); 61 | 62 | fireEvent.click(link); 63 | 64 | await waitFor(() => { 65 | expect(openOptions).toHaveBeenCalled(); 66 | expect(close).toHaveBeenCalled(); 67 | }); 68 | }); 69 | 70 | it("renders a help link", () => { 71 | const link = screen.getByRole("link", { name: "Help" }); 72 | expect(link).toHaveAttribute("href", "/about"); 73 | }); 74 | }); 75 | 76 | describe("with an array of tickets", () => { 77 | beforeEach(() => { 78 | screen = subject({ tickets, errors }); 79 | }); 80 | 81 | it("renders a ticket list", () => { 82 | const options = screen.getAllByRole("option"); 83 | const titles = tickets.map((t) => t.title); 84 | expect(options.map((o) => o.textContent)).toEqual(titles); 85 | }); 86 | }); 87 | 88 | describe("with no tickets found and no errors", () => { 89 | beforeEach(() => { 90 | screen = subject({ tickets: [], errors: [] }); 91 | }); 92 | 93 | it('renders the "no tickets" notification', () => { 94 | expect(screen.container).toHaveTextContent(/no tickets found/i); 95 | }); 96 | }); 97 | 98 | describe("with no tickets found and errors", () => { 99 | beforeEach(() => { 100 | screen = subject({ tickets: [], errors }); 101 | }); 102 | 103 | it("renders an error notification", () => { 104 | const notification = screen.getByRole("button", { name: "ErrorDetails" }); // because of the mock 105 | const info = errors.map((e) => e.message).join("\n"); 106 | expect(notification).toHaveValue(info); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/popup/components/__snapshots__/about.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`about renders 1`] = ` 4 | 5 | 44 |
47 |

50 | 55 | Tickety-Tick 56 |

57 |
58 | Usage: 59 |
60 |
    63 |
  1. 64 | Open a ticket in your favourite issue tracking system. 65 |
  2. 66 |
  3. 67 | Click the extension icon. 68 |
  4. 69 |
  5. 70 | Use the buttons to create a commit message, branch name or CLI command. 71 |
  6. 72 |
73 |

74 | This extension is open-source software by the fellows at 75 | 80 | bitcrowd 81 | 82 | . 83 |

84 |

85 | The source code is available on 86 | 91 | GitHub 92 | 93 | . 94 |

95 |

98 | 104 | test-commit-hash 105 | 106 |

107 |

110 | 111 | Logo by 112 | 113 | 118 | Ramón G. 119 | 120 | 121 | under CC-BY 3.0 122 | 123 |
124 | 125 | Other icons from 126 | 127 | 132 | GitHub Octicons 133 | 134 |

135 |
136 |
137 | `; 138 | -------------------------------------------------------------------------------- /src/popup/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import "."; 5 | 6 | import browser from "webextension-polyfill"; 7 | 8 | import { ticket as make } from "../../test/factories"; 9 | import enhance from "../core/enhance"; 10 | import { deserialize } from "../errors"; 11 | import store from "../store"; 12 | import render from "./render"; 13 | import type { BackgroundWorker } from "./types"; 14 | 15 | const mockBackgroundWorker: BackgroundWorker = { getTickets: jest.fn() }; 16 | 17 | jest 18 | .mock("webextension-polyfill", () => { 19 | const runtime = { 20 | sendMessage: jest 21 | .fn() 22 | .mockImplementation((_msg, _sender) => 23 | mockBackgroundWorker.getTickets(), 24 | ), 25 | }; 26 | return { runtime }; 27 | }) 28 | .mock("../core/enhance", () => jest.fn(() => jest.fn())) 29 | .mock("../store", () => ({ get: jest.fn().mockResolvedValue({}) })) 30 | .mock("./observe-media") 31 | .mock("./render"); 32 | 33 | describe("popup", () => { 34 | const initialize = window.onload as () => Promise; 35 | 36 | beforeEach(() => { 37 | (mockBackgroundWorker.getTickets as jest.Mock).mockResolvedValue({ 38 | tickets: [], 39 | errors: [], 40 | }); 41 | (store.get as jest.Mock).mockResolvedValue({}); 42 | (enhance as jest.Mock).mockReturnValue((x: any) => x); // eslint-disable-line @typescript-eslint/no-explicit-any 43 | }); 44 | 45 | afterEach(() => { 46 | (mockBackgroundWorker.getTickets as jest.Mock).mockReset(); 47 | (store.get as jest.Mock).mockReset(); 48 | (enhance as jest.Mock).mockReset(); 49 | (render as jest.Mock).mockReset(); 50 | }); 51 | 52 | it("sets up an initialization handler", () => { 53 | expect(initialize).toEqual(expect.any(Function)); 54 | }); 55 | 56 | it("fetches ticket information through the background page", async () => { 57 | await initialize(); 58 | 59 | expect(browser.runtime.sendMessage).toHaveBeenCalledWith({ 60 | getTickets: true, 61 | }); 62 | expect(mockBackgroundWorker.getTickets).toHaveBeenCalled(); 63 | }); 64 | 65 | it("loads settings from storage", async () => { 66 | await initialize(); 67 | 68 | expect(store.get).toHaveBeenCalledWith(null); 69 | }); 70 | 71 | it("configures ticket formatting", async () => { 72 | const templates = { branch: "test/branch" }; 73 | const options = { autofmt: Math.random() < 0.5 }; 74 | (store.get as jest.Mock).mockResolvedValue({ templates, options }); 75 | 76 | await initialize(); 77 | 78 | expect(enhance).toHaveBeenCalledWith(templates, options.autofmt); 79 | }); 80 | 81 | it("renders the popup content with enhanced tickets", async () => { 82 | const tickets = ["uno", "dos"].map((title, id) => 83 | make({ id: id.toString(), title }), 84 | ); 85 | (mockBackgroundWorker.getTickets as jest.Mock).mockResolvedValue({ 86 | tickets, 87 | errors: [], 88 | }); 89 | 90 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 91 | const format = (ticket: any) => ({ ...ticket, fmt: true }); 92 | const enhancer = jest.fn(format); 93 | (enhance as jest.Mock).mockReturnValue(enhancer); 94 | 95 | await initialize(); 96 | 97 | expect(enhance).toHaveBeenCalled(); 98 | 99 | expect(render).toHaveBeenCalledWith( 100 | tickets.map(format), 101 | expect.any(Object), 102 | ); 103 | }); 104 | 105 | it("renders the popup content with errors", async () => { 106 | const errors = [{ message: "Test Error" }]; 107 | (mockBackgroundWorker.getTickets as jest.Mock).mockResolvedValue({ 108 | tickets: [], 109 | errors, 110 | }); 111 | 112 | await initialize(); 113 | 114 | expect(render).toHaveBeenCalledWith( 115 | expect.any(Object), 116 | errors.map(deserialize), 117 | ); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/core/adapters/github.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import scan, { selectors } from "./github"; 8 | 9 | const pages = { 10 | default: { 11 | issuepage: ` 12 |
13 |
14 |

15 | A Random GitHub Issue 16 | #12 17 |

18 |
19 |
`, 20 | bugpage: ` 21 |
22 |
23 |
24 |

25 | A Random GitHub Issue 26 | #12 27 |

28 |
29 |
30 |
31 |
32 | 43 |
44 |
45 |
`, 46 | }, 47 | legacy: { 48 | issuepage: ` 49 |
50 |

51 | A Random GitHub Issue 52 | #12 53 |

54 |
`, 55 | bugpage: ` 56 |
57 |

58 | A Random GitHub Issue 59 | #12 60 |

61 |
62 | bug 63 |
64 |
`, 65 | }, 66 | }; 67 | 68 | const url = (path: string) => 69 | new URL(`https://github.com/test-org/test-project/${path}`); 70 | 71 | Object.keys(selectors).forEach((variant) => { 72 | const html = pages[variant as keyof typeof selectors]; 73 | 74 | describe(`github adapter (${variant})`, () => { 75 | function doc(body = "") { 76 | const { window } = new JSDOM(`${body}`); 77 | return window.document; 78 | } 79 | 80 | it("returns an empty array if it is on a different page", async () => { 81 | const result = await scan(new URL("https://example.net"), doc()); 82 | expect(result).toEqual([]); 83 | }); 84 | 85 | it("extracts tickets from issue pages", async () => { 86 | const result = await scan(url("issues/12"), doc(html.issuepage)); 87 | expect(result).toEqual([ 88 | { 89 | id: "12", 90 | title: "A Random GitHub Issue", 91 | type: "feature", 92 | url: "https://github.com/test-org/test-project/issues/12", 93 | }, 94 | ]); 95 | }); 96 | 97 | it("recognizes issue types", async () => { 98 | const result = await scan(url("issues/12"), doc(html.bugpage)); 99 | expect(result).toEqual([ 100 | { 101 | id: "12", 102 | title: "A Random GitHub Issue", 103 | type: "bug", 104 | url: "https://github.com/test-org/test-project/issues/12", 105 | }, 106 | ]); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/core/adapters/tara.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import scan from "./tara"; 8 | 9 | const FULL_PAGE_VIEW = ` 10 |
11 |
12 | TASK-17 13 |
14 |
15 | 16 |
17 |
18 |
19 |
20 | Some description 21 | That gets longer. 22 |
23 |
24 |
25 |
26 | `; 27 | 28 | const MODAL_VIEW = ` 29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 | There is some board description here. 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | TASK-123 48 |
49 |
50 | 51 |
52 |
53 |
54 |
55 | Here goes some fancy description. 56 | With multiple lines. 57 |
58 |
59 |
60 |
61 |
62 | `; 63 | 64 | describe("tara adapter", () => { 65 | const url = (str: string) => new URL(str); 66 | 67 | function doc(body = "") { 68 | const { window } = new JSDOM(`${body}`); 69 | return window.document; 70 | } 71 | 72 | it("returns an empty array if it is on a different page", async () => { 73 | const result = await scan(url("https://some-other-website.com"), doc()); 74 | expect(result).toEqual([]); 75 | }); 76 | 77 | it("extracts tickets from full-page task view", async () => { 78 | const result = await scan( 79 | url("https://app.tara.ai/my-workspace/tasks/17"), 80 | doc(FULL_PAGE_VIEW), 81 | ); 82 | 83 | expect(result).toEqual([ 84 | { 85 | id: "TASK-17", 86 | title: "This is a Tara task", 87 | description: "Some description\nThat gets longer.", 88 | url: "https://app.tara.ai/my-workspace/tasks/17", 89 | }, 90 | ]); 91 | }); 92 | 93 | it("extracts tickets from modal-overlay task view", async () => { 94 | const result = await scan( 95 | url("https://app.tara.ai/my-workspace/tasks/123"), 96 | doc(MODAL_VIEW), 97 | ); 98 | 99 | expect(result).toEqual([ 100 | { 101 | id: "TASK-123", 102 | title: "This is a Tara task in modal view", 103 | description: "Here goes some fancy description.\nWith multiple lines.", 104 | url: "https://app.tara.ai/my-workspace/tasks/123", 105 | }, 106 | ]); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/core/adapters/gitlab.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import scan from "./gitlab"; 8 | 9 | const ISSUEPAGE = ` 10 | 11 |
12 |
13 |
14 |

A Random GitLab Issue

15 |
16 |
17 |
18 | 19 | `; 20 | 21 | const NEWISSUEPAGE = ` 22 | 23 |
24 |

A Random GitLab Issue

25 |
26 | 27 | `; 28 | 29 | const LEGACYISSUE = ` 30 |
31 |
32 | 35 |
36 |

A Random GitLab Issue

37 |
38 |
39 |
40 | `; 41 | 42 | const BUGPAGE = ` 43 |
44 |
45 | 48 |
49 |

A Random GitLab Issue

50 |
51 |
52 | 63 | `; 64 | 65 | const NEWBUGPAGE = ` 66 | 67 |
68 |

test

69 |
70 |
71 | bug 72 |
73 | 74 | `; 75 | 76 | describe("gitlab adapter", () => { 77 | const url = new URL("https://x.yz"); 78 | 79 | function doc(body = "", dataPage = "") { 80 | const { window } = new JSDOM( 81 | `${body}`, 82 | ); 83 | return window.document; 84 | } 85 | 86 | it("returns an empty array if it is on a different page", async () => { 87 | const result = await scan(url, doc()); 88 | expect(result).toEqual([]); 89 | }); 90 | 91 | it("extracts tickets from issue pages", async () => { 92 | const result = await scan(url, doc(ISSUEPAGE, "projects:issues:show")); 93 | expect(result).toEqual([ 94 | { id: "22578", title: "A Random GitLab Issue", type: "feature" }, 95 | ]); 96 | }); 97 | 98 | it("extracts tickets from new issue pages", async () => { 99 | const result = await scan(url, doc(NEWISSUEPAGE, "projects:issues:show")); 100 | expect(result).toEqual([ 101 | { id: "18", title: "A Random GitLab Issue", type: "feature" }, 102 | ]); 103 | }); 104 | 105 | it("extracts tickets from legacy issue pages", async () => { 106 | const result = await scan(url, doc(LEGACYISSUE, "projects:issues:show")); 107 | expect(result).toEqual([ 108 | { id: "22578", title: "A Random GitLab Issue", type: "feature" }, 109 | ]); 110 | }); 111 | 112 | it("recognizes issues labelled as bugs", async () => { 113 | const result = await scan(url, doc(BUGPAGE, "projects:issues:show")); 114 | expect(result).toEqual([ 115 | { id: "22578", title: "A Random GitLab Issue", type: "bug" }, 116 | ]); 117 | }); 118 | 119 | it("recognizes issues labelled as bugs on the new page", async () => { 120 | const result = await scan(url, doc(NEWBUGPAGE, "projects:issues:show")); 121 | expect(result).toEqual([{ id: "1", title: "test", type: "bug" }]); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | 3 | definitions: 4 | pwd: &pwd ~/tickety-tick 5 | 6 | executors: 7 | base: 8 | docker: 9 | - image: cimg/node:24.10.0 10 | working_directory: *pwd 11 | swift: 12 | docker: 13 | - image: swift:5.4 14 | working_directory: *pwd 15 | 16 | commands: 17 | restore_pwd: 18 | description: Restore working directory 19 | steps: 20 | - restore_cache: 21 | key: pwd-{{ .Environment.CIRCLE_SHA1 }} 22 | 23 | save_pwd: 24 | description: Save working directory 25 | steps: 26 | - save_cache: 27 | key: pwd-{{ .Environment.CIRCLE_SHA1 }} 28 | paths: 29 | - *pwd 30 | 31 | restore_deps: 32 | description: Restore dependencies 33 | steps: 34 | - restore_cache: 35 | key: dependency-cache-{{ arch }}-{{ checksum "yarn.lock" }} 36 | 37 | save_deps: 38 | description: Save dependencies 39 | steps: 40 | - save_cache: 41 | key: dependency-cache-{{ arch }}-{{ checksum "yarn.lock" }} 42 | paths: 43 | - node_modules 44 | 45 | jobs: 46 | setup: 47 | executor: base 48 | steps: 49 | - checkout 50 | - restore_deps 51 | - run: 52 | name: Install dependencies 53 | command: yarn install --frozen-lockfile 54 | - save_deps 55 | - save_pwd 56 | 57 | lint: 58 | executor: base 59 | steps: 60 | - restore_pwd 61 | - run: 62 | name: Run Linter 63 | command: yarn lint --format junit -o reports/eslint/results.xml 64 | - store_test_results: 65 | path: reports 66 | 67 | stylelint: 68 | executor: base 69 | steps: 70 | - restore_pwd 71 | - run: 72 | name: Run SCSS Linter 73 | command: mkdir -p reports/stylelint && yarn stylelint --custom-formatter node_modules/stylelint-junit-formatter/index.js -o reports/stylelint/results.xml 74 | - store_test_results: 75 | path: reports 76 | 77 | typecheck: 78 | executor: base 79 | steps: 80 | - restore_pwd 81 | - run: 82 | name: Run tsc 83 | command: yarn typecheck 84 | 85 | test: 86 | executor: base 87 | steps: 88 | - restore_pwd 89 | - run: 90 | name: Run Tests 91 | command: yarn test --maxWorkers=2 --ci --reporters=default --reporters=jest-junit 92 | environment: 93 | JEST_JUNIT_OUTPUT: reports/jest/results.xml 94 | - store_test_results: 95 | path: reports 96 | 97 | swiftformat: 98 | executor: swift 99 | steps: 100 | - restore_cache: 101 | keys: 102 | - swiftformat-cache-{{ arch }} 103 | - run: 104 | name: Install SwiftFormat 105 | command: | 106 | VERSION="0.48.11" 107 | 108 | type "swiftformat" >/dev/null 2>&1 && [[ "$(swiftformat --version)" == "$VERSION" ]] || { 109 | cd /tmp 110 | git clone --branch "$VERSION" --depth 1 https://github.com/nicklockwood/SwiftFormat 111 | cd SwiftFormat 112 | swift build --configuration release 113 | BIN="$(swift build --configuration release --show-bin-path)" 114 | mv $BIN/swiftformat "/usr/local/bin" 115 | } 116 | - save_cache: 117 | key: swiftformat-cache-{{ arch }} 118 | paths: 119 | - /usr/local/bin 120 | - checkout 121 | - run: 122 | name: Run SwiftFormat 123 | command: cd safari && swiftformat --lint . 124 | 125 | build: 126 | executor: base 127 | steps: 128 | - restore_pwd 129 | - run: 130 | name: Build 131 | command: yarn build:firefox 132 | 133 | workflows: 134 | version: 2.1 135 | checks: 136 | jobs: 137 | - setup 138 | - lint: 139 | requires: 140 | - setup 141 | - stylelint: 142 | requires: 143 | - setup 144 | - typecheck: 145 | requires: 146 | - setup 147 | - test: 148 | requires: 149 | - setup 150 | - swiftformat 151 | - build: 152 | requires: 153 | - setup 154 | -------------------------------------------------------------------------------- /src/core/adapters/notion.test.ts: -------------------------------------------------------------------------------- 1 | import client from "../client"; 2 | import scan from "./notion"; 3 | 4 | jest.mock("../client"); 5 | 6 | describe("notion adapter", () => { 7 | const id = "5b1d7dd7-9107-4890-b2ec-83175b8eda83"; 8 | const title = "Add notion.so support"; 9 | const slugId = "5b1d7dd791074890b2ec83175b8eda83"; 10 | const slug = `Add-notion-so-support-${slugId}`; 11 | const ticketUrl = `https://www.notion.so/${slugId}`; 12 | 13 | const response = { 14 | results: [ 15 | { 16 | role: "editor", 17 | value: { 18 | id, 19 | type: "page", 20 | properties: { title: [[title]] }, 21 | }, 22 | }, 23 | ], 24 | }; 25 | 26 | const ticket = { id, title, type: "page", url: ticketUrl }; 27 | const url = (str: string) => new URL(str); 28 | const api = { post: jest.fn() }; 29 | 30 | beforeEach(() => { 31 | api.post.mockReturnValue({ json: () => response }); 32 | (client as jest.Mock).mockReturnValue(api); 33 | }); 34 | 35 | afterEach(() => { 36 | api.post.mockReset(); 37 | (client as jest.Mock).mockReset(); 38 | }); 39 | 40 | it("returns an empty array when not on a www.notion.so page", async () => { 41 | const result = await scan(url("https://another-domain.com")); 42 | expect(api.post).not.toHaveBeenCalled(); 43 | expect(result).toEqual([]); 44 | }); 45 | 46 | it("uses the notion.so api", async () => { 47 | await scan(url(`https://www.notion.so/notionuser/${slug}`)); 48 | expect(client).toHaveBeenCalledWith("https://www.notion.so"); 49 | expect(api.post).toHaveBeenCalled(); 50 | }); 51 | 52 | it("returns an empty array when the current page is a board view", async () => { 53 | api.post.mockReturnValueOnce({ 54 | json: () => ({ 55 | results: [ 56 | { 57 | role: "editor", 58 | value: { id, type: "collection_view_page" }, 59 | }, 60 | ], 61 | }), 62 | }); 63 | const result = await scan( 64 | url( 65 | `https://www.notion.so/notionuser/${slugId}?v=77ff97cab6ff4beab7fa6e27f992dd5e`, 66 | ), 67 | ); 68 | expect(api.post).toHaveBeenCalledWith("api/v3/getRecordValues", { 69 | json: { requests: [{ table: "block", id }] }, 70 | }); 71 | expect(result).toEqual([]); 72 | }); 73 | 74 | it("returns an emtpy array when the page does not exist", async () => { 75 | api.post.mockReturnValueOnce({ 76 | json: () => ({ results: [{ role: "editor" }] }), 77 | }); 78 | const result = await scan(url(`https://www.notion.so/notionuser/${slug}`)); 79 | expect(api.post).toHaveBeenCalledWith("api/v3/getRecordValues", { 80 | json: { requests: [{ table: "block", id }] }, 81 | }); 82 | expect(result).toEqual([]); 83 | }); 84 | 85 | it("returns an empty array if the page id does not match the requested one", async () => { 86 | const otherId = "7c1e7ee7-9107-4890-b2ec-83175b8edv99"; 87 | const otherSlugId = otherId.replace(/-/g, ""); 88 | const result = await scan( 89 | url(`https://www.notion.so/notionuser/Some-ticket-${otherSlugId}`), 90 | ); 91 | expect(api.post).toHaveBeenCalledWith("api/v3/getRecordValues", { 92 | json: { requests: [{ table: "block", id: otherId }] }, 93 | }); 94 | expect(result).toEqual([]); 95 | }); 96 | 97 | it("extracts tickets from page modals (board view)", async () => { 98 | const result = await scan( 99 | url( 100 | `https://www.notion.so/notionuser/0e8608aa770a4d36a246d7a3c64f51af?v=77ff97cab6ff4beab7fa6e27f992dd5e&p=${slugId}`, 101 | ), 102 | ); 103 | expect(api.post).toHaveBeenCalledWith("api/v3/getRecordValues", { 104 | json: { requests: [{ table: "block", id }] }, 105 | }); 106 | expect(result).toEqual([ticket]); 107 | }); 108 | 109 | it("extracts tickets from the page view", async () => { 110 | const result = await scan(url(`https://www.notion.so/notionuser/${slug}`)); 111 | expect(api.post).toHaveBeenCalledWith("api/v3/getRecordValues", { 112 | json: { requests: [{ table: "block", id }] }, 113 | }); 114 | expect(result).toEqual([ticket]); 115 | }); 116 | 117 | it("extracts tickets from the page view without organization", async () => { 118 | const result = await scan(url(`https://www.notion.so/${slug}`)); 119 | expect(api.post).toHaveBeenCalledWith("api/v3/getRecordValues", { 120 | json: { requests: [{ table: "block", id }] }, 121 | }); 122 | expect(result).toEqual([ticket]); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import CopyWebpackPlugin from "copy-webpack-plugin"; 2 | import { GitRevisionPlugin } from "git-revision-webpack-plugin"; 3 | import HtmlWebpackPlugin from "html-webpack-plugin"; 4 | import MiniCssExtractPlugin from "mini-css-extract-plugin"; 5 | import path from "path"; 6 | import type { Configuration } from "webpack"; 7 | import { DefinePlugin } from "webpack"; 8 | import ZipWebpackPlugin from "zip-webpack-plugin"; 9 | 10 | import pkg from "./package.json"; 11 | 12 | // Small variations between browsers supporting the WebExtensions API 13 | const variant = process.env.VARIANT as string | undefined; 14 | 15 | // Helper functions for paths 16 | function src(...p: string[]): string { 17 | return path.join(__dirname, "src", ...p); 18 | } 19 | 20 | function dist(...p: string[]): string { 21 | return path.join(__dirname, "dist", ...p); 22 | } 23 | 24 | // Initialize GitRevisionPlugin 25 | const revision = new GitRevisionPlugin(); 26 | 27 | // Define the Webpack configuration as a TypeScript object 28 | const config: Configuration = { 29 | mode: (process.env.NODE_ENV as "development" | "production") ?? "production", 30 | context: __dirname, 31 | 32 | entry: { 33 | background: src("background", "index.ts"), 34 | content: src("content", "index.ts"), 35 | options: src("options", "index.tsx"), 36 | popup: src("popup", "index.ts"), 37 | }, 38 | 39 | output: { 40 | path: dist(variant!), 41 | filename: "[name].js", 42 | }, 43 | 44 | resolve: { 45 | extensions: [".js", ".json", ".ts", ".tsx"], 46 | }, 47 | 48 | module: { 49 | rules: [ 50 | { 51 | test: /\.tsx?$/, 52 | exclude: /node_modules/, 53 | use: { 54 | loader: "babel-loader", 55 | }, 56 | }, 57 | { 58 | test: /\.scss$/, 59 | use: [ 60 | MiniCssExtractPlugin.loader, 61 | { 62 | loader: "css-loader", 63 | options: { sourceMap: true }, 64 | }, 65 | { 66 | loader: "postcss-loader", 67 | options: { sourceMap: true }, 68 | }, 69 | { 70 | loader: "sass-loader", 71 | options: { sourceMap: true }, 72 | }, 73 | ], 74 | }, 75 | { 76 | test: /\.svg$/, 77 | exclude: /node_modules/, 78 | use: [ 79 | { 80 | loader: "@svgr/webpack", 81 | options: { prettier: false, svgo: false, ref: true }, 82 | }, 83 | { 84 | loader: "file-loader", 85 | options: { name: "[name].[ext]" }, 86 | }, 87 | ], 88 | }, 89 | ], 90 | }, 91 | 92 | plugins: [ 93 | new HtmlWebpackPlugin({ 94 | template: src("popup", "index.html"), 95 | filename: "popup.html", 96 | chunks: ["popup"], 97 | inject: true, 98 | minify: { 99 | collapseWhitespace: true, 100 | removeScriptTypeAttributes: true, 101 | }, 102 | cache: false, 103 | }), 104 | 105 | new HtmlWebpackPlugin({ 106 | template: src("options", "index.html"), 107 | filename: "options.html", 108 | chunks: ["options"], 109 | inject: true, 110 | minify: { 111 | collapseWhitespace: true, 112 | removeScriptTypeAttributes: true, 113 | }, 114 | cache: false, 115 | }), 116 | 117 | new MiniCssExtractPlugin({ 118 | filename: "[name].css", 119 | }), 120 | 121 | new CopyWebpackPlugin({ 122 | patterns: [ 123 | { 124 | from: src("icons", "*.png"), 125 | to: "[name][ext]", 126 | }, 127 | { 128 | from: src("manifest.json"), 129 | transform: (content) => { 130 | const mf = JSON.parse(content.toString()); 131 | mf.name = pkg.name; 132 | mf.version = pkg.version; 133 | mf.description = pkg.description; 134 | 135 | if (variant === "firefox") { 136 | mf.browser_specific_settings = { 137 | gecko: { 138 | id: "jid1-ynkvezs8Qn2TJA@jetpack", 139 | }, 140 | }; 141 | } 142 | return JSON.stringify(mf); 143 | }, 144 | }, 145 | ], 146 | }), 147 | 148 | revision, 149 | 150 | new DefinePlugin({ 151 | COMMITHASH: JSON.stringify(revision.commithash()), 152 | }), 153 | 154 | ...(process.env.BUNDLE === "true" 155 | ? [ 156 | new ZipWebpackPlugin({ 157 | path: dist(), 158 | filename: variant, 159 | }), 160 | ] 161 | : []), 162 | ], 163 | 164 | devtool: "source-map", 165 | }; 166 | 167 | export default config; 168 | -------------------------------------------------------------------------------- /src/core/adapters/jira-cloud.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import client from "../client"; 6 | import scan from "./jira-cloud"; 7 | 8 | jest.mock("../client"); 9 | 10 | const key = "RC-654"; 11 | 12 | const description = "A long description of the ticket"; 13 | 14 | const response = { 15 | id: "10959", 16 | fields: { 17 | issuetype: { name: "Story" }, 18 | summary: "A quick summary of the ticket", 19 | description: { 20 | version: 1, 21 | type: "doc", 22 | content: [ 23 | { 24 | type: "paragraph", 25 | content: [{ type: "text", text: description }], 26 | }, 27 | ], 28 | }, 29 | }, 30 | key, 31 | }; 32 | 33 | const ticket = { 34 | id: response.key, 35 | title: response.fields.summary, 36 | description: `${description}\n`, 37 | type: response.fields.issuetype.name.toLowerCase(), 38 | url: `https://my-subdomain.atlassian.net/browse/${key}`, 39 | }; 40 | 41 | describe("jira cloud adapter", () => { 42 | const url = (str: string) => new URL(str); 43 | 44 | const api = { get: jest.fn() }; 45 | 46 | beforeEach(() => { 47 | api.get.mockReturnValue({ json: () => response }); 48 | (client as jest.Mock).mockReturnValue(api); 49 | }); 50 | 51 | afterEach(() => { 52 | (client as jest.Mock).mockReset(); 53 | api.get.mockReset(); 54 | }); 55 | 56 | it("returns an empty array when on a different host", async () => { 57 | const result = await scan(url("https://another-domain.com")); 58 | expect(api.get).not.toHaveBeenCalled(); 59 | expect(result).toEqual([]); 60 | }); 61 | 62 | it("returns null when no issue is selected", async () => { 63 | const result = await scan(url("https://my-subdomain.atlassian.com")); 64 | expect(api.get).not.toHaveBeenCalled(); 65 | expect(result).toEqual([]); 66 | }); 67 | 68 | it("uses the endpoints for the current host", async () => { 69 | await scan(url(`https://my-subdomain.atlassian.net/browse/${key}`)); 70 | expect(client).toHaveBeenCalledWith( 71 | "https://my-subdomain.atlassian.net/rest/api/3", 72 | ); 73 | expect(api.get).toHaveBeenCalled(); 74 | }); 75 | 76 | it("extracts tickets from the active sprints tab", async () => { 77 | const result = await scan( 78 | url(`https://my-subdomain.atlassian.net/?selectedIssue=${key}`), 79 | ); 80 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 81 | expect(result).toEqual([ticket]); 82 | }); 83 | 84 | it("extracts tickets from the issues tab", async () => { 85 | const result = await scan( 86 | url( 87 | `https://my-subdomain.atlassian.net/projects/TT/issues/${key}?filter=something`, 88 | ), 89 | ); 90 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 91 | expect(result).toEqual([ticket]); 92 | }); 93 | 94 | it("extracts tickets when browsing an issue", async () => { 95 | const result = await scan( 96 | url(`https://my-subdomain.atlassian.net/browse/${key}`), 97 | ); 98 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 99 | expect(result).toEqual([ticket]); 100 | }); 101 | 102 | it("extracts tickets from new generation software projects", async () => { 103 | const result = await scan( 104 | url( 105 | `https://my-subdomain.atlassian.net/jira/software/projects/TT/boards/8?selectedIssue=${key}`, 106 | ), 107 | ); 108 | expect(client).toHaveBeenCalledWith( 109 | "https://my-subdomain.atlassian.net/rest/api/3", 110 | ); 111 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 112 | expect(result).toEqual([ticket]); 113 | }); 114 | 115 | it("extracts tickets from new generation software projects from the board-URL", async () => { 116 | const result = await scan( 117 | url( 118 | `https://my-subdomain.atlassian.net/jira/software/projects/TT/boards/7/backlog?selectedIssue=${key}`, 119 | ), 120 | ); 121 | expect(client).toHaveBeenCalledWith( 122 | "https://my-subdomain.atlassian.net/rest/api/3", 123 | ); 124 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 125 | expect(result).toEqual([ticket]); 126 | }); 127 | 128 | it("extracts tickets from classic software projects from the board-URL", async () => { 129 | const result = await scan( 130 | url( 131 | `https://my-subdomain.atlassian.net/jira/software/c/projects/TT/boards/7?selectedIssue=${key}`, 132 | ), 133 | ); 134 | expect(client).toHaveBeenCalledWith( 135 | "https://my-subdomain.atlassian.net/rest/api/3", 136 | ); 137 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 138 | expect(result).toEqual([ticket]); 139 | }); 140 | 141 | it("extracts tickets from classic software projects from the backlog-URL", async () => { 142 | const result = await scan( 143 | url( 144 | `https://my-subdomain.atlassian.net/jira/software/c/projects/TT/boards/7/backlog?selectedIssue=${key}`, 145 | ), 146 | ); 147 | expect(client).toHaveBeenCalledWith( 148 | "https://my-subdomain.atlassian.net/rest/api/3", 149 | ); 150 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 151 | expect(result).toEqual([ticket]); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "5.6.0", 4 | "name": "tickety-tick", 5 | "description": "A browser extension that helps you to create commit messages and branch names from story trackers.", 6 | "author": "bitcrowd ", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "run-s -n 'build:* {*}' --", 10 | "build:chrome": "cross-env VARIANT=chrome webpack --config webpack.config.ts", 11 | "build:firefox": "cross-env VARIANT=firefox webpack --config webpack.config.ts", 12 | "build:safari": "run-s 'build:chrome' 'build:safari:xcodebuild'", 13 | "build:safari:xcodebuild": "./script/xcodebuild", 14 | "watch:chrome": "cross-env NODE_ENV=development run-s 'build:chrome --watch'", 15 | "watch:firefox": "cross-env NODE_ENV=development run-s 'build:firefox --watch'", 16 | "open:chrome": "./script/open-in-chrome.mjs ./dist/chrome https://github.com/bitcrowd/tickety-tick", 17 | "open:firefox": "web-ext run --source-dir ./dist/firefox --url https://github.com/bitcrowd/tickety-tick/issues --url https://issues.apache.org/jira/browse/HIVE-29271?filter=-4 --pref browser.rights.shown=true", 18 | "bundle:chrome": "cross-env BUNDLE=true run-s build:chrome", 19 | "bundle:firefox": "cross-env BUNDLE=true run-s build:firefox", 20 | "lint": "eslint --ignore-path .gitignore --ext .js,.ts,.tsx .", 21 | "lint:md": "prettier --ignore-path .gitignore --check '**/*.{md,mdx}'", 22 | "lint:ext": "run-s --continue-on-error lint:ext:firefox lint:ext:chrome", 23 | "lint:ext:firefox": "web-ext lint --source-dir ./dist/firefox", 24 | "lint:ext:chrome": "web-ext lint --source-dir ./dist/chrome", 25 | "format": "run-s --continue-on-error format:js format:md", 26 | "format:js": "eslint --ignore-path .gitignore --fix --ext .js,.ts,.tsx .", 27 | "format:md": "prettier --ignore-path .gitignore --write '**/*.{md,mdx}'", 28 | "stylelint": "stylelint --ignore-path .gitignore 'src/**/*.scss'", 29 | "test": "jest", 30 | "typecheck": "tsc --noEmit", 31 | "checks": "run-s stylelint lint typecheck test", 32 | "prepare-release": "./script/prepare-release", 33 | "release": "./script/release" 34 | }, 35 | "devDependencies": { 36 | "@babel/core": "^7.28.5", 37 | "@babel/preset-env": "^7.28.3", 38 | "@babel/preset-react": "^7.27.1", 39 | "@babel/preset-typescript": "^7.27.1", 40 | "@babel/register": "^7.28.3", 41 | "@fullhuman/postcss-purgecss": "^7.0.2", 42 | "@svgr/webpack": "^6.5.0", 43 | "@testing-library/dom": "^10.4.1", 44 | "@testing-library/jest-dom": "^6.9.1", 45 | "@testing-library/react": "^16.3.0", 46 | "@types/jest": "30.0.0", 47 | "@types/jsdom": "^16.2.13", 48 | "@typescript-eslint/eslint-plugin": "^8.46.2", 49 | "@typescript-eslint/parser": "^8.46.2", 50 | "babel-jest": "^30.2.0", 51 | "babel-loader": "^9.2.1", 52 | "chrome-launcher": "^1.2.1", 53 | "chrome-webstore-upload-cli": "^3.5.0", 54 | "copy-webpack-plugin": "11.0.0", 55 | "cross-env": "7.0.3", 56 | "css-loader": "7.1.2", 57 | "cssnano": "^7.1.1", 58 | "eslint": "^8.56.0", 59 | "eslint-config-airbnb": "19.0.4", 60 | "eslint-config-prettier": "^10.1.8", 61 | "eslint-plugin-import": "^2.32.0", 62 | "eslint-plugin-jest": "^28.8.3", 63 | "eslint-plugin-jsx-a11y": "^6.10.2", 64 | "eslint-plugin-prettier": "^5.5.4", 65 | "eslint-plugin-react": "^7.37.5", 66 | "eslint-plugin-react-hooks": "^4.6.2", 67 | "eslint-plugin-simple-import-sort": "^12.1.1", 68 | "file-loader": "^6.2.0", 69 | "git-revision-webpack-plugin": "^5.0.0", 70 | "html-webpack-plugin": "5.6.4", 71 | "jest": "30.2.0", 72 | "jest-environment-jsdom": "30.2.0", 73 | "jest-junit": "^16.0.0", 74 | "jest-watch-typeahead": "^3.0.1", 75 | "jsdom": "17.0.0", 76 | "mini-css-extract-plugin": "^2.9.4", 77 | "npm-run-all": "4.1.5", 78 | "postcss": "^8.5.6", 79 | "postcss-loader": "8.2.0", 80 | "postcss-preset-env": "^10.4.0", 81 | "sass": "^1.93.2", 82 | "sass-loader": "16.0.6", 83 | "stylelint": "^16.25.0", 84 | "stylelint-config-standard-scss": "^16.0.0", 85 | "stylelint-junit-formatter": "^0.2.2", 86 | "stylelint-prettier": "^5.0.3", 87 | "typescript": "^5.9.3", 88 | "web-ext": "9.0.0", 89 | "webpack": "5.102.1", 90 | "webpack-chain": "6.5.1", 91 | "webpack-cli": "^6.0.1", 92 | "zip-webpack-plugin": "4.0.3" 93 | }, 94 | "dependencies": { 95 | "@primer/octicons-react": "^19.19.0", 96 | "@types/react": "^19.2.2", 97 | "@types/react-dom": "^19.2.2", 98 | "@types/react-router-dom": "^5.3.3", 99 | "@types/speakingurl": "^13.0.6", 100 | "@types/webextension-polyfill": "^0.12.4", 101 | "bootstrap": "5.3.8", 102 | "copy-text-to-clipboard": "^3.2.2", 103 | "headers-polyfill": "^4.0.3", 104 | "ky": "^1.13.0", 105 | "mdast-util-from-adf": "^2.1.1", 106 | "mdast-util-gfm": "^2.0.0", 107 | "mdast-util-to-markdown": "^1.2.3", 108 | "micro-match": "^1.0.3", 109 | "prettier": "^3.6.2", 110 | "react": "19.2.0", 111 | "react-dom": "19.2.0", 112 | "react-router": "7.9.4", 113 | "react-textarea-autosize": "^8.5.9", 114 | "serialize-error": "^12.0.0", 115 | "speakingurl": "14.0.1", 116 | "strip-indent": "^4.1.1", 117 | "webextension-polyfill": "^0.12.0" 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/options/components/form.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import type { RenderResult } from "@testing-library/react"; 5 | import { 6 | fireEvent, 7 | render as renderComponent, 8 | waitFor, 9 | } from "@testing-library/react"; 10 | import React from "react"; 11 | 12 | import format, { helpers } from "../../core/format"; 13 | import type { Props } from "./form"; 14 | import Form from "./form"; 15 | 16 | jest.mock("../../core/format", () => 17 | Object.assign(jest.fn(), jest.requireActual("../../core/format")), 18 | ); 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | function createMockStore(data: any) { 22 | return { 23 | get: jest.fn().mockResolvedValue(data), 24 | set: jest.fn().mockResolvedValue(undefined), 25 | }; 26 | } 27 | 28 | describe("form", () => { 29 | function render(overrides: Partial) { 30 | const defaults: Props = { store: createMockStore({}) }; 31 | const props = { ...defaults, ...overrides }; 32 | return renderComponent(
); 33 | } 34 | 35 | function waitForLoadingToFinish(screen: RenderResult) { 36 | return waitFor(() => screen.getByRole("button", { name: "Save" })); 37 | } 38 | 39 | beforeEach(() => { 40 | (format as jest.Mock).mockImplementation((_templates, autofmt) => ({ 41 | commit: () => 42 | new Promise((resolve) => { 43 | resolve(`formatted-commit (${autofmt})`); 44 | }), 45 | branch: () => `formatted-branch (${autofmt})`, 46 | command: () => 47 | new Promise((resolve) => { 48 | resolve(`formatted-command (${autofmt})`); 49 | }), 50 | })); 51 | }); 52 | 53 | afterEach(() => { 54 | (format as jest.Mock).mockReset(); 55 | }); 56 | 57 | [ 58 | ["branch", "Branch Name Format"], 59 | ["commit", "Commit Message Format"], 60 | ["command", "Command Format"], 61 | ].forEach(([key, name]) => { 62 | it(`renders an input for the ${key} format`, async () => { 63 | const value = `${key}-template`; 64 | const store = createMockStore({ templates: { [key]: value } }); 65 | const screen = render({ store }); 66 | 67 | await waitForLoadingToFinish(screen); 68 | 69 | const input = screen.getByRole("textbox", { name }); 70 | await waitFor(() => expect(input).toHaveValue(value)); 71 | 72 | fireEvent.change(input, { target: { value: `${key}++` } }); 73 | await waitFor(() => expect(input).toHaveValue(`${key}++`)); 74 | }); 75 | }); 76 | 77 | it("renders a checkbox to toggle commit message auto-formatting", async () => { 78 | const store = createMockStore({ options: { autofmt: true } }); 79 | const screen = render({ store }); 80 | 81 | await waitForLoadingToFinish(screen); 82 | 83 | const checkbox = screen.getByRole("checkbox", { 84 | name: /Auto-format commit message/, 85 | }); 86 | 87 | expect(checkbox).toBeChecked(); 88 | expect(format).toHaveBeenCalledWith(expect.any(Object), true); 89 | expect(screen.getByText("formatted-commit (true)")).toBeInTheDocument(); 90 | 91 | fireEvent.click(checkbox); 92 | 93 | expect(checkbox).not.toBeChecked(); 94 | expect(format).toHaveBeenLastCalledWith(expect.any(Object), false); 95 | await waitFor(() => { 96 | expect(screen.getByText("formatted-commit (false)")).toBeInTheDocument(); 97 | }); 98 | }); 99 | 100 | it("renders the names & descriptions of available template helpers", async () => { 101 | const screen = render({}); 102 | await waitForLoadingToFinish(screen); 103 | 104 | Object.values(helpers).forEach((fn) => { 105 | expect(screen.container).toHaveTextContent( 106 | "description" in fn ? fn.description : fn.name, 107 | ); 108 | }); 109 | }); 110 | 111 | it("stores templates and options on submit and disables form elements while saving", async () => { 112 | const store = createMockStore({ 113 | templates: { 114 | branch: "branch", 115 | commit: "commit", 116 | command: "command", 117 | }, 118 | options: { autofmt: true }, 119 | }); 120 | 121 | const screen = render({ store }); 122 | 123 | await waitForLoadingToFinish(screen); 124 | 125 | const checkbox = screen.getByRole("checkbox", { 126 | name: /Auto-format commit message/, 127 | }); 128 | const inputs = [ 129 | ["branch", "Branch Name Format"], 130 | ["commit", "Commit Message Format"], 131 | ["command", "Command Format"], 132 | ].map<[string, HTMLInputElement]>(([key, name]) => [ 133 | key, 134 | screen.getByRole("textbox", { name }) as HTMLInputElement, 135 | ]); 136 | 137 | fireEvent.click(checkbox); 138 | 139 | inputs.forEach(([key, input]) => { 140 | fireEvent.change(input, { target: { value: `${key}++` } }); 141 | }); 142 | 143 | const saveButton = screen.getByRole("button", { name: "Save" }); 144 | fireEvent.click(saveButton); 145 | 146 | const changed = { 147 | templates: { 148 | branch: "branch++", 149 | commit: "commit++", 150 | command: "command++", 151 | }, 152 | options: { autofmt: false }, 153 | }; 154 | 155 | expect(store.set).toHaveBeenCalledWith(changed); 156 | 157 | expect(saveButton).toBeDisabled(); 158 | expect(checkbox).toBeDisabled(); 159 | inputs.forEach(([, input]) => expect(input).toBeDisabled()); 160 | 161 | await waitFor(() => { 162 | expect(saveButton).not.toBeDisabled(); 163 | expect(checkbox).not.toBeDisabled(); 164 | inputs.forEach(([, input]) => expect(input).not.toBeDisabled()); 165 | }); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /src/core/adapters/jira-server.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { JSDOM } from "jsdom"; 6 | 7 | import client from "../client"; 8 | import scan from "./jira-server"; 9 | 10 | jest.mock("../client"); 11 | 12 | const key = "RC-654"; 13 | 14 | const response = { 15 | id: "10959", 16 | fields: { 17 | issuetype: { name: "Story" }, 18 | summary: "A quick summary of the ticket", 19 | description: "A long description of the ticket", 20 | }, 21 | key, 22 | }; 23 | 24 | const ticket = { 25 | id: response.key, 26 | title: response.fields.summary, 27 | description: response.fields.description, 28 | type: response.fields.issuetype.name.toLowerCase(), 29 | url: `https://my-domain.com/browse/${key}`, 30 | }; 31 | 32 | describe("jira server adapter", () => { 33 | const dom = new JSDOM('…'); 34 | const url = (str: string) => new URL(str); 35 | const doc = dom.window.document; 36 | 37 | const api = { get: jest.fn() }; 38 | 39 | beforeEach(() => { 40 | api.get.mockReturnValue({ json: () => response }); 41 | (client as jest.Mock).mockReturnValue(api); 42 | }); 43 | 44 | afterEach(() => { 45 | (client as jest.Mock).mockReset(); 46 | api.get.mockReset(); 47 | }); 48 | 49 | it("returns an empty array when on a different host", async () => { 50 | const result = await scan(url("https://my-domain.com"), doc); 51 | expect(api.get).not.toHaveBeenCalled(); 52 | expect(result).toEqual([]); 53 | }); 54 | 55 | it("returns null when no issue is selected", async () => { 56 | const result = await scan(url("https://my-domain.com"), doc); 57 | expect(api.get).not.toHaveBeenCalled(); 58 | expect(result).toEqual([]); 59 | }); 60 | 61 | it("uses the endpoints for the current host", async () => { 62 | await scan(url(`https://my-domain.com/browse/${key}`), doc); 63 | expect(client).toHaveBeenCalledWith( 64 | "https://my-domain.com/rest/api/latest", 65 | ); 66 | expect(api.get).toHaveBeenCalled(); 67 | }); 68 | 69 | it("extracts tickets from the active sprints tab", async () => { 70 | const result = await scan( 71 | url(`https://my-domain.com/?selectedIssue=${key}`), 72 | doc, 73 | ); 74 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 75 | expect(result).toEqual([ticket]); 76 | }); 77 | 78 | it("extracts tickets from the issues tab", async () => { 79 | const result = await scan( 80 | url(`https://my-domain.com/projects/TT/issues/${key}?filter=something`), 81 | doc, 82 | ); 83 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 84 | expect(result).toEqual([ticket]); 85 | }); 86 | 87 | it("extracts tickets when browsing an issue", async () => { 88 | const result = await scan(url(`https://my-domain.com/browse/${key}`), doc); 89 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 90 | expect(result).toEqual([ticket]); 91 | }); 92 | 93 | it("extracts tickets from new generation software projects", async () => { 94 | const result = await scan( 95 | url( 96 | `https://my-domain.com/jira/software/projects/TT/boards/8?selectedIssue=${key}`, 97 | ), 98 | doc, 99 | ); 100 | expect(client).toHaveBeenCalledWith( 101 | "https://my-domain.com/rest/api/latest", 102 | ); 103 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 104 | expect(result).toEqual([ticket]); 105 | }); 106 | 107 | it("extracts tickets from new generation software projects from the board-URL", async () => { 108 | const result = await scan( 109 | url( 110 | `https://my-domain.com/jira/software/projects/TT/boards/7/backlog?selectedIssue=${key}`, 111 | ), 112 | doc, 113 | ); 114 | expect(client).toHaveBeenCalledWith( 115 | "https://my-domain.com/rest/api/latest", 116 | ); 117 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 118 | expect(result).toEqual([ticket]); 119 | }); 120 | 121 | it("extracts tickets from classic software projects from the board-URL", async () => { 122 | const result = await scan( 123 | url( 124 | `https://my-domain.com/jira/software/c/projects/TT/boards/7?selectedIssue=${key}`, 125 | ), 126 | doc, 127 | ); 128 | expect(client).toHaveBeenCalledWith( 129 | "https://my-domain.com/rest/api/latest", 130 | ); 131 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 132 | expect(result).toEqual([ticket]); 133 | }); 134 | 135 | it("extracts tickets from classic software projects from the backlog-URL", async () => { 136 | const result = await scan( 137 | url( 138 | `https://my-domain.com/jira/software/c/projects/TT/boards/7/backlog?selectedIssue=${key}`, 139 | ), 140 | doc, 141 | ); 142 | expect(client).toHaveBeenCalledWith( 143 | "https://my-domain.com/rest/api/latest", 144 | ); 145 | expect(api.get).toHaveBeenCalledWith(`issue/${key}`); 146 | expect(result).toEqual([ticket]); 147 | }); 148 | 149 | it("extracts tickets on self-managed instances (with path prefix)", async () => { 150 | const results = await Promise.all([ 151 | scan( 152 | url( 153 | `https://jira.local/prefix/secure/RapidBoard.jspa?selectedIssue=${key}`, 154 | ), 155 | doc, 156 | ), 157 | scan(url(`https://jira.local/prefix/projects/TT/issues/${key}`), doc), 158 | scan(url(`https://jira.local/prefix/browse/${key}`), doc), 159 | ]); 160 | 161 | const endpoint = "https://jira.local/prefix/rest/api/latest"; 162 | const expectedTicket = { 163 | ...ticket, 164 | url: `https://jira.local/browse/${key}`, 165 | }; 166 | 167 | expect(client).toHaveBeenNthCalledWith(1, endpoint); 168 | expect(client).toHaveBeenNthCalledWith(2, endpoint); 169 | expect(client).toHaveBeenNthCalledWith(3, endpoint); 170 | 171 | expect(results).toEqual([ 172 | [expectedTicket], 173 | [expectedTicket], 174 | [expectedTicket], 175 | ]); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /src/options/components/form.tsx: -------------------------------------------------------------------------------- 1 | import type { Icon } from "@primer/octicons-react"; 2 | import { 3 | CommentIcon, 4 | GitBranchIcon, 5 | TerminalIcon, 6 | } from "@primer/octicons-react"; 7 | import React, { useEffect, useState } from "react"; 8 | 9 | import format, { defaults, helpers } from "../../core/format"; 10 | import CheckboxInput from "./checkbox-input"; 11 | import * as example from "./example"; 12 | import type { Props as TemplateInputProps } from "./template-input"; 13 | import TemplateInput from "./template-input"; 14 | 15 | const recommendation = 16 | "https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html"; 17 | 18 | function InputIcon({ icon: IconComponent }: { icon: Icon }) { 19 | return ; 20 | } 21 | 22 | export type Props = { 23 | store: { 24 | get: (_: null) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any 25 | set: (_: Record) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any 26 | }; 27 | }; 28 | 29 | type State = { 30 | loading: boolean; 31 | autofmt: boolean; 32 | branch: string; 33 | commit: string; 34 | command: string; 35 | }; 36 | 37 | const initialState: State = { 38 | loading: true, 39 | autofmt: true, 40 | branch: "", 41 | commit: "", 42 | command: "", 43 | }; 44 | 45 | function Form({ store }: Props) { 46 | const [state, setState] = useState(initialState); 47 | 48 | useEffect(() => { 49 | store.get(null).then((data) => { 50 | const { options, templates } = data ?? {}; 51 | setState({ ...initialState, loading: false, ...options, ...templates }); 52 | }); 53 | }, [store]); 54 | 55 | const handleChanged = (event: React.ChangeEvent) => { 56 | const { name, type, value, checked } = event.target; 57 | 58 | setState({ 59 | ...state, 60 | [name]: type === "checkbox" ? checked : value, 61 | }); 62 | }; 63 | 64 | const handleSaved = () => { 65 | setState({ ...state, loading: false }); 66 | }; 67 | 68 | const { loading, autofmt, ...templates } = state; 69 | 70 | const handleSubmit = (event: React.FormEvent) => { 71 | event.preventDefault(); 72 | 73 | const options = { autofmt }; 74 | 75 | setState({ ...state, loading: true }); 76 | store.set({ templates, options }).then(handleSaved); 77 | }; 78 | 79 | // Create a formatter for rendering previews 80 | const fmt = format(templates, autofmt); 81 | 82 | const fields = [ 83 | { 84 | icon: , 85 | label: "Commit Message Format", 86 | id: "commit-message-format", 87 | name: "commit", 88 | value: templates.commit, 89 | fallback: defaults.commit, 90 | preview: fmt.commit(example), 91 | multiline: true, 92 | }, 93 | { 94 | icon: , 95 | label: "Branch Name Format", 96 | id: "branch-name-format", 97 | name: "branch", 98 | value: templates.branch, 99 | fallback: defaults.branch, 100 | preview: fmt.branch(example), 101 | }, 102 | { 103 | icon: , 104 | label: "Command Format", 105 | id: "command-format", 106 | name: "command", 107 | value: templates.command, 108 | fallback: defaults.command, 109 | preview: fmt.command(example), 110 | }, 111 | ]; 112 | 113 | const input = (props: Omit) => ( 114 | 115 |

{props.label}

116 | 117 |
118 | ); 119 | 120 | return ( 121 | 122 |
123 | 130 | Auto-format commit message – as per{" "} 131 | 136 | recommendation 137 | 138 | 139 | } 140 | onChange={handleChanged} 141 | /> 142 |
143 | 144 | {fields.map(input)} 145 | 146 |
147 | 148 |
149 |
150 |
151 | Template variables: 152 |
    153 | {Object.keys(example) 154 | .sort() 155 | .map((name) => ( 156 |
  • {name}
  • 157 | ))} 158 |
  • branch (only in command)
  • 159 |
  • commit (only in command)
  • 160 |
161 |
162 | 163 |
164 | Available Helpers: 165 |
    166 | {Object.keys(helpers) 167 | .sort() 168 | .map((name) => { 169 | const helper = helpers[name as keyof typeof helpers]; 170 | return ( 171 |
  • 172 | {"description" in helper ? helper.description : name} 173 |
  • 174 | ); 175 | })} 176 |
177 |
178 |
179 |
180 | 181 |
182 | 189 |
190 | 191 | ); 192 | } 193 | 194 | export default Form; 195 | --------------------------------------------------------------------------------