44 |
45 |
48 |
49 |
A Random GitLab Issue
50 |
51 |
52 |
63 | `;
64 |
65 | const NEWBUGPAGE = `
66 |
67 |
68 |
test
69 |
70 |
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 |
191 | );
192 | }
193 |
194 | export default Form;
195 |
--------------------------------------------------------------------------------