Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/extension-duplicate-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Extension Duplicate Check CI

on:
push:
branches: [master]
paths:
- "extension-duplicate-check/**"
- ".github/workflows/extension-duplicate-check.yml"
pull_request:
paths:
- "extension-duplicate-check/**"
- ".github/workflows/extension-duplicate-check.yml"

permissions:
contents: read

concurrency:
group: extension-duplicate-check-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: extension-duplicate-check
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: extension-duplicate-check/package-lock.json
- run: npm ci --ignore-scripts --no-audit --no-fund
- run: npm run typecheck
- run: npm test
- run: npm run build
- name: Check that dist is up to date
run: |
if [ -n "$(git status --porcelain -- dist)" ]; then
git status --porcelain -- dist
git diff -- dist | head -n 200
echo "::error::dist is out of date. Run 'npm run build' and commit the result."
exit 1
fi
5 changes: 5 additions & 0 deletions extension-duplicate-check/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
coverage/
*.log
.DS_Store
.env
245 changes: 245 additions & 0 deletions extension-duplicate-check/README.md

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions extension-duplicate-check/__tests__/comment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { COMMENT_MARKER, isDuplicate, renderComment, upsertComment, type ExtensionReport } from "../src/comment";
import { LexicalScorer } from "../src/scorers/lexical";
import { manifestFixture, storeFixture } from "./helpers";

async function report(name: string): Promise<ExtensionReport> {
const extension = manifestFixture(name);
const other = {
...manifestFixture("pomodoro-focus"),
url: "https://github.com/raycast/extensions/pull/42",
pullRequest: { number: 42, title: "Add | Pomodoro" },
};
const [matches] = await new LexicalScorer().score([extension], [...storeFixture(), other]);
const relevant = matches!.filter((match) => match.score >= 0.2);
return {
extension,
store: relevant.filter((match) => match.candidate.source === "store").slice(0, 3),
pullRequests: relevant.filter((match) => match.candidate.source === "pull-request").slice(0, 3),
};
}

const options = { threshold: 0.6, scorer: "lexical", unavailable: [] };

describe("renderComment", () => {
it("greets the author and lists flagged matches in the reviewers' voice", async () => {
const body = renderComment([await report("spotify-controls")], { ...options, author: "octocat" });
expect(body.startsWith(COMMENT_MARKER)).toBe(true);
expect(body).toContain("Hey @octocat 👋\n\nThanks for your contribution 🔥");
expect(body).toContain("We found an extension that looks similar to **Spotify Controls**:");
expect(body).toContain("If it already covers what yours does");
expect(body).toContain(
"* [Spotify Player](https://www.raycast.com/mattisssa/spotify-player) by " +
"[mattisssa](https://www.raycast.com/mattisssa) in the Store",
);
expect(body).toContain("[extension guidelines](https://manual.raycast.com/extensions-guidelines#reasons-to-be-rejected)");
expect(body).toContain("<summary>Match details</summary>");
expect(body).toContain("⚠️");
});

it("links open pull requests by number without their titles", async () => {
const body = renderComment([await report("pomodoro-focus")], { ...options, threshold: 0.2 });
expect(body).toContain("in #42 (open pull request)");
expect(body).toContain("| #42 |");
expect(body).not.toContain("Add \\| Pomodoro");
});

it("escapes manifest text so it can't mention people or break the markdown", async () => {
const spotify = await report("spotify-controls");
spotify.extension = { ...spotify.extension, title: "Hi @raycast <b>|*" };
const body = renderComment([spotify], options);
expect(body).toContain("**Hi @\u200braycast \\<b\\>\\|\\***");
expect(body).not.toContain("@raycast");
});

it("words the comment from the relation a model judged", async () => {
const spotify = await report("spotify-controls");
spotify.store = spotify.store.map((match, index) => ({ ...match, relation: index === 0 ? "enhancement" : "different" }));
const body = renderComment([spotify], { ...options, scorer: "jev" });
expect(body).toContain("We found an extension with the same main purpose as **Spotify Controls**:");
expect(body).toContain("Your extension seems to add features on top of it. These look like natural enhancements");
expect(body).toContain("| Extension | Where | Relation | Score |");
expect(body).toContain("Same purpose, adds features");
expect(body).toContain("TypeSafe's Jev model");

spotify.store[0] = { ...spotify.store[0]!, relation: "same" };
expect(renderComment([spotify], { ...options, scorer: "jev" })).toContain(
"We found an extension that covers the same core functionality as **Spotify Controls**:",
);
});

it("reports unavailable sources, no matches, and no new extension", async () => {
const body = renderComment([await report("svg-optimizer")], { ...options, unavailable: ["Store not checked."] });
expect(body).toContain("nothing to do here");
expect(body).not.toContain("Thanks for your contribution");
expect(body).toContain("> Store not checked.");
expect(renderComment([], options)).toContain("no longer adds a new extension");
});
});

describe("isDuplicate", () => {
it("compares the best score with the threshold", async () => {
const spotify = await report("spotify-controls");
expect(isDuplicate(spotify, 0.6)).toBe(true);
expect(isDuplicate(spotify, 0.99)).toBe(false);
});
});

describe("upsertComment", () => {
function fakeOctokit(comments: Array<{ id: number; body: string }>) {
const calls: string[] = [];
const octokit = {
paginate: async () => comments,
rest: {
issues: {
listComments: () => undefined,
createComment: async () => {
calls.push("create");
return { data: { html_url: "created" } };
},
updateComment: async ({ comment_id }: { comment_id: number }) => {
calls.push(`update ${comment_id}`);
return { data: { html_url: "updated" } };
},
},
},
};
return { octokit: octokit as unknown as Parameters<typeof upsertComment>[0], calls };
}

const repository = { owner: "raycast", repo: "extensions" };

it("updates the comment that carries the marker", async () => {
const { octokit, calls } = fakeOctokit([
{ id: 1, body: "LGTM" },
{ id: 2, body: `${COMMENT_MARKER}\nold` },
]);
expect(await upsertComment(octokit, repository, 7, "body")).toBe("updated");
expect(calls).toEqual(["update 2"]);
});

it("creates a comment when none carries the marker", async () => {
const { octokit, calls } = fakeOctokit([{ id: 1, body: "LGTM" }]);
expect(await upsertComment(octokit, repository, 7, "body")).toBe("created");
expect(calls).toEqual(["create"]);
});

it("does not create a comment when create is off", async () => {
const { octokit, calls } = fakeOctokit([{ id: 1, body: "LGTM" }]);
expect(await upsertComment(octokit, repository, 7, "body", { create: false })).toBeUndefined();
expect(calls).toEqual([]);
});

it("still updates an earlier comment when create is off", async () => {
const { octokit, calls } = fakeOctokit([{ id: 2, body: `${COMMENT_MARKER}\nold` }]);
expect(await upsertComment(octokit, repository, 7, "body", { create: false })).toBe("updated");
expect(calls).toEqual(["update 2"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "pomodoro-focus",
"title": "Pomodoro Focus",
"description": "A simple pomodoro timer in your menu bar to stay focused with work intervals and breaks.",
"icon": "extension-icon.png",
"author": "someone",
"categories": ["Productivity"],
"license": "MIT",
"commands": [
{ "name": "start-pomodoro", "title": "Start Pomodoro", "description": "Start a pomodoro focus interval", "mode": "no-view" },
{ "name": "menu-bar", "title": "Pomodoro Menu Bar", "description": "Show the timer in the menu bar", "mode": "menu-bar" }
],
"keywords": ["focus", "timer"],
"dependencies": { "@raycast/api": "^1.100.0" }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "spotify-controls",
"title": "Spotify Controls",
"description": "Control Spotify playback: play, pause, skip tracks and see what's playing.",
"icon": "icon.png",
"author": "another",
"categories": ["Media"],
"license": "MIT",
"commands": [
{ "name": "toggle", "title": "Toggle Play Pause", "description": "Toggle Spotify playback", "mode": "no-view" },
{ "name": "next", "title": "Next Track", "description": "Skip to the next track", "mode": "no-view" },
{ "name": "now-playing", "title": "Now Playing", "description": "Show the current track", "mode": "view" }
],
"tools": [{ "name": "play-song", "title": "Play Song", "description": "Play a song on Spotify" }]
}
112 changes: 112 additions & 0 deletions extension-duplicate-check/__tests__/fixtures/store-results.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
{
"query_result": {
"id": 1,
"query": "select ...",
"data": {
"columns": [
{ "name": "name", "type": "string" },
{ "name": "title", "type": "string" },
{ "name": "description", "type": "string" },
{ "name": "author_handle", "type": "string" },
{ "name": "commands", "type": "string" },
{ "name": "keywords", "type": "string" },
{ "name": "categories", "type": "string" }
],
"rows": [
{
"name": "spotify-player",
"title": "Spotify Player",
"description": "Spotify's most powerful extension: control playback, search music, browse your library and manage devices.",
"author_handle": "mattisssa",
"commands": "[{\"name\":\"search\",\"title\":\"Search\",\"description\":\"Search for artists, albums, tracks, podcasts and playlists\"},{\"name\":\"nowPlaying\",\"title\":\"Now Playing\",\"description\":\"Show the currently playing track\"},{\"name\":\"togglePlayPause\",\"title\":\"Toggle Play/Pause\",\"description\":\"Toggle playback\"},{\"name\":\"next\",\"title\":\"Next Track\",\"description\":\"Skip to the next track\"}]",
"keywords": "[\"music\",\"spotify\",\"player\"]",
"categories": "[\"Media\"]"
},
{
"name": "pomodoro",
"title": "Pomodoro",
"description": "Pomodoro extension with menu bar timer, focus intervals and short breaks.",
"author_handle": "asubbotin",
"commands": "[{\"name\":\"pomodoro-control-timer\",\"title\":\"Pomodoro Control Timer\",\"description\":\"Start, pause and stop pomodoro intervals\"},{\"name\":\"pomodoro-menu-bar\",\"title\":\"Pomodoro Menu Bar\",\"description\":\"Show the pomodoro timer in the menu bar\"}]",
"keywords": "[\"focus\",\"timer\",\"productivity\"]",
"categories": "[\"Productivity\"]"
},
{
"name": "todoist",
"title": "Todoist",
"description": "Check your Todoist tasks and quickly create new ones.",
"author_handle": "thomaslombart",
"commands": "[{\"name\":\"home\",\"title\":\"Home\",\"description\":\"See your tasks, projects and labels\"},{\"name\":\"create-task\",\"title\":\"Create Task\",\"description\":\"Create a new task in Todoist\"}]",
"keywords": "[\"tasks\",\"todo\"]",
"categories": "[\"Productivity\"]"
},
{
"name": "linear",
"title": "Linear",
"description": "Create, search and modify your Linear issues.",
"author_handle": "thomaslombart",
"commands": "[{\"name\":\"create-issue\",\"title\":\"Create Issue\",\"description\":\"Create a new Linear issue\"},{\"name\":\"search-issues\",\"title\":\"Search Issues\",\"description\":\"Search issues in your workspace\"}]",
"keywords": "[\"issue tracker\",\"project management\"]",
"categories": "[\"Developer Tools\", \"Productivity\"]"
},
{
"name": "color-picker",
"title": "Color Picker",
"description": "Pick and organize colors from anywhere on your screen.",
"author_handle": "thomas",
"commands": "[{\"name\":\"pick-color\",\"title\":\"Pick Color\",\"description\":\"Pick a color from the screen\"},{\"name\":\"organize-colors\",\"title\":\"Organize Colors\",\"description\":\"Manage saved colors\"}]",
"keywords": "[\"hex\",\"rgb\",\"eyedropper\"]",
"categories": "[\"Design Tools\"]"
},
{
"name": "brew",
"title": "Brew",
"description": "Search and install Homebrew formulae and casks.",
"author_handle": "nhojb",
"commands": "[{\"name\":\"search\",\"title\":\"Search\",\"description\":\"Search Homebrew packages\"},{\"name\":\"installed\",\"title\":\"Installed\",\"description\":\"List installed formulae and casks\"}]",
"keywords": "[\"homebrew\",\"package manager\"]",
"categories": "[\"Developer Tools\"]"
},
{
"name": "kill-process",
"title": "Kill Process",
"description": "Terminate processes sorted by CPU or memory usage.",
"author_handle": "rolandleth",
"commands": "[{\"name\":\"index\",\"title\":\"Kill Process\",\"description\":\"Kill processes by name\"}]",
"keywords": "[\"terminate\",\"cpu\",\"memory\"]",
"categories": "[\"System\"]"
},
{
"name": "google-translate",
"title": "Google Translate",
"description": "Translate text between languages with Google Translate.",
"author_handle": "gebeto",
"commands": "[{\"name\":\"translate\",\"title\":\"Translate\",\"description\":\"Translate text\"},{\"name\":\"translate-form\",\"title\":\"Translate Form\",\"description\":\"Translate long text in a form\"}]",
"keywords": "[\"language\",\"translation\"]",
"categories": "[\"Productivity\"]"
},
{
"name": "notion",
"title": "Notion",
"description": "Search, create and update Notion pages and databases.",
"author_handle": "notion",
"commands": "[{\"name\":\"search-page\",\"title\":\"Search Notion\",\"description\":\"Search pages\"},{\"name\":\"create-database-page\",\"title\":\"Create Database Page\",\"description\":\"Add a page to a database\"}]",
"keywords": "[\"notes\",\"wiki\"]",
"categories": "[\"Productivity\"]"
},
{
"name": "timers",
"title": "Timers",
"description": "Start, stop and track multiple countdown timers and stopwatches.",
"author_handle": "ThatNerd",
"commands": "[{\"name\":\"startTimer\",\"title\":\"Start Timer\",\"description\":\"Start a custom countdown timer\"},{\"name\":\"manageTimers\",\"title\":\"Manage Timers\",\"description\":\"List running timers\"}]",
"keywords": "[\"countdown\",\"stopwatch\",\"alarm\"]",
"categories": "[\"Productivity\"]"
}
]
},
"data_scanned": "N/A",
"runtime": 0.12,
"retrieved_at": "2026-10-29T12:00:00.000Z"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "svg-optimizer",
"title": "SVG Optimizer",
"description": "Minify SVG files from Finder with SVGO presets.",
"icon": "icon.png",
"author": "third",
"categories": ["Design Tools"],
"license": "MIT",
"commands": [
{ "name": "optimize-selected", "title": "Optimize Selected SVGs", "description": "Run SVGO on the files selected in Finder", "mode": "no-view" }
],
"keywords": ["svgo", "minify"]
}
27 changes: 27 additions & 0 deletions extension-duplicate-check/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parseManifest } from "../src/manifest";
import { parseRedashResult } from "../src/sources/store";
import type { Extension } from "../src/types";

const FIXTURES = join(import.meta.dirname, "fixtures");

export function fixture(name: string): string {
return readFileSync(join(FIXTURES, name), "utf8");
}

export function storeFixture(): Extension[] {
return parseRedashResult(JSON.parse(fixture("store-results.json")));
}

export function manifestFixture(name: string, meta: Partial<Extension> = {}): Extension {
const extension = parseManifest(fixture(`${name}.package.json`), {
source: "pull-request",
path: `extensions/${name}/package.json`,
...meta,
});
if (!extension) {
throw new Error(`Invalid fixture ${name}`);
}
return extension;
}
Loading
Loading