Compare commits

..

2 Commits

Author SHA1 Message Date
Gauvino
d2a0fd875a fix(detect-duplicate): sanitize reposted issue titles
Security audit: the bot echoes other issues' titles back into a comment, so a
maliciously-named issue could ping (@everyone) or inject markdown/HTML. Break
@-mentions with a zero-width space and strip markdown/HTML control chars before
posting.
2026-06-01 20:32:39 +02:00
Gauvino
d2c48de60d ci(issues): flag likely-duplicate issues on open
Adds .github/workflows/detect-duplicate.yml + scripts/detect-duplicate-issue.mjs
(Bun, dep-free, no API key): on a new issue, compares its title/body to open
issues via Jaccard similarity (with light stemming and stop-words), and if the
top matches pass a threshold, posts one comment listing them and adds a
'possible duplicate' label. Inspired by seerr's detect-duplicate, minus the
embedding/Groq dependency.
2026-06-01 17:38:19 +02:00
16 changed files with 331 additions and 105 deletions

38
.github/workflows/detect-duplicate.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: 🔁 Detect Duplicate Issues
on:
issues:
types: [opened]
permissions:
contents: read
concurrency:
group: detect-duplicate-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-24.04
permissions:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}

View File

@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen}
onOpenChange={setDropdownOpen}
trigger={
<View>
<View className='pl-1.5'>
<Ionicons
name='ellipsis-horizontal-outline'
size={24}

View File

@@ -11,7 +11,6 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import {
type ChapterEntry,
chapterStartsMs,
@@ -39,7 +38,6 @@ function ChapterListComponent({
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -81,17 +79,7 @@ function ChapterListComponent({
supportedOrientations={["portrait", "landscape"]}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable
onPress={(e) => e.stopPropagation()}
style={[
styles.sheet,
{
marginLeft: safeArea.left,
marginRight: safeArea.right,
paddingBottom: safeArea.bottom,
},
]}
>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable
@@ -172,12 +160,14 @@ const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
},
sheet: {
backgroundColor: Colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 24,
},
header: {
flexDirection: "row",

View File

@@ -133,6 +133,7 @@ const HomeMobile = () => {
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather

View File

@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
}) => {
const { t } = useTranslation();
const hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
if (loading) {
return null;
}
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
return (
<View>
{/* No section requests `hasTVPreferredFocus`: the native search field
keeps focus while typing, otherwise the first result would re-grab
focus on every keystroke as results re-render. The user navigates
down to the grid manually. */}
<TVJellyseerrMovieSection
title={t("search.request_movies")}
items={movieResults}
isFirstSection={false}
isFirstSection={hasMovies}
onItemPress={onMoviePress}
/>
<TVJellyseerrTvSection
title={t("search.request_series")}
items={tvResults}
isFirstSection={false}
isFirstSection={!hasMovies && hasTv}
onItemPress={onTvPress}
/>
<TVJellyseerrPersonSection
title={t("search.actors")}
items={personResults}
isFirstSection={false}
isFirstSection={!hasMovies && !hasTv && hasPersons}
onItemPress={onPersonPress}
/>
</View>

View File

@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View
style={{
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT,
}}
>
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section) => (
{sections.map((section, index) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
// Never auto-focus a result. The native search field owns focus
// while typing; `hasTVPreferredFocus` here would re-grab focus on
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
isFirstSection={index === 0}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={

View File

@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
// contentOffset only applies on initial mount; since this FlatList is
// reused across searches (stable key), a second search left the inset
// without the offset and the grid snapped flush to the left edge.
contentInset={{
left: edgePadding,
right: edgePadding,
}}
contentOffset={{ x: -edgePadding, y: 0 }}
contentContainerStyle={{
paddingHorizontal: edgePadding,
paddingVertical: SCALE_PADDING,
}}
/>

View File

@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
@@ -75,6 +75,9 @@ interface BottomControlsProps {
minutes: number;
seconds: number;
};
// Chapter props
chapterPositions?: number[];
}
export const BottomControls: FC<BottomControlsProps> = ({
@@ -108,10 +111,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl,
trickplayInfo,
time,
chapterPositions = [],
}) => {
const { settings } = useSettings();
const { t } = useTranslation();
const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers.
@@ -142,9 +146,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[
{
position: "absolute",
right: insets.right,
left: insets.left,
bottom: Math.max(insets.bottom - 17, 0),
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
},
]}
className={"flex flex-col px-2"}
@@ -180,6 +188,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null}
</View>
<View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
@@ -211,17 +230,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={handleNextEpisodeManual}
/>
)}
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center ml-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
</View>
</View>
<View

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter,
}) => {
const { settings } = useSettings();
const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
return (
<View
style={{
position: "absolute",
top: "50%",
left: insets.left,
right: insets.right,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",

View File

@@ -219,6 +219,7 @@ export const Controls: FC<Props> = ({
hasNextChapter,
goToPreviousChapter,
goToNextChapter,
chapterPositions,
} = useChapterNavigation({
chapters: item.Chapters,
progress,
@@ -584,6 +585,7 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/>
</Animated.View>
</>

View File

@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -16,10 +17,10 @@ import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers,
@@ -45,7 +46,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
const isOffline = useOfflineMode();
const insets = useControlsSafeAreaInsets();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
// Set the initial season index
useEffect(() => {
@@ -180,9 +182,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black",
height: "100%",
width: "100%",
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingTop:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
paddingLeft:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
}}
>
<View

View File

@@ -5,11 +5,12 @@ import type {
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -57,8 +58,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false,
onToggleTechnicalInfo,
}) => {
const { settings } = useSettings();
const router = useRouter();
const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
const lightHapticFeedback = useHaptic("light");
const { orientation, lockOrientation } = useOrientation();
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
@@ -97,9 +99,10 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[
{
position: "absolute",
top: insets.top,
left: insets.left,
right: insets.right,
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
padding: HEADER_LAYOUT.CONTAINER_PADDING,
},
]}

View File

@@ -16,8 +16,8 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex,
}) => {
const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0);
@@ -268,8 +268,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
left: Math.max(insets.left, 48) + 20,
}
: {
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
top:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
left:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
};
const textStyle = Platform.isTV

View File

@@ -1,18 +0,0 @@
import {
type EdgeInsets,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useSettings } from "@/utils/atoms/settings";
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
/**
* Returns safe-area insets to apply to in-player controls, honoring the
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
* returns zero insets so controls can sit flush against the screen edges.
*/
export const useControlsSafeAreaInsets = (): EdgeInsets => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
};

View File

@@ -81,6 +81,7 @@ class MpvPlayerView: ExpoView {
private func setupView() {
clipsToBounds = true
backgroundColor = .black
configureAudioSession()
videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -140,26 +141,21 @@ class MpvPlayerView: ExpoView {
CATransaction.commit()
}
// MARK: - Audio Session & Notifications
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
let audioSession = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
try session.setActive(true)
try audioSession.setCategory(
.playback,
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
}
}
/// Deactivate the session AND reset the category `setActive(false)` alone
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
/// reactivation (foreground, route change, other modules) re-steals audio.
private func tearDownAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setActive(false, options: .notifyOthersOnDeactivation)
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
}
// MARK: - Audio Session & Notifications
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -274,7 +270,6 @@ class MpvPlayerView: ExpoView {
func play() {
intendedPlayState = true
configureAudioSession()
setupRemoteCommands()
renderer?.play()
pipController?.setPlaybackRate(1.0)
@@ -445,7 +440,6 @@ class MpvPlayerView: ExpoView {
renderer?.stop()
displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self)
}
}
@@ -525,7 +519,9 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
}
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
// Audio output is now active - this is the right time to activate audio session and set Now Playing
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused())
}
}

View File

@@ -0,0 +1,202 @@
#!/usr/bin/env bun
/**
* Flags likely-duplicate issues when a new issue is opened, using lexical similarity
* (Jaccard over word sets of the title and body) — no API key, no embeddings.
*
* On a match it posts ONE comment listing the closest open issues and adds the
* "possible duplicate" label. If nothing is similar enough, it does nothing.
*
* Env:
* GITHUB_REPOSITORY owner/repo
* ISSUE_NUMBER the new issue number
* ISSUE_TITLE the new issue title
* ISSUE_BODY the new issue body
* GH_TOKEN/GITHUB_TOKEN for gh (provided in CI)
* DUP_THRESHOLD similarity threshold 0..1 (default 0.3)
* DUP_MAX max matches to report (default 5)
* DUP_FIXTURE optional path to a JSON array of {number,title,body} (local testing)
* DRY_RUN if set, print results instead of commenting/labelling
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
const NUMBER = Number(process.env.ISSUE_NUMBER);
const TITLE = process.env.ISSUE_TITLE || "";
const BODY = process.env.ISSUE_BODY || "";
const THRESHOLD = Number(process.env.DUP_THRESHOLD) || 0.3;
const MAX = Number(process.env.DUP_MAX) || 5;
const DRY = !!process.env.DRY_RUN;
const LABEL = "possible duplicate";
// Generic stop words only — keep domain/feature/platform words (android, downloads,
// subtitles…) since those are exactly what makes two reports the same or different.
const STOP = new Set(
(
"a an the and or but if then of to in on at by for with from as is are was were be been being do does did " +
"it its this that these those i you we they me my your our their he she him her " +
"when while where what which who how why so just then than too very can could would should will " +
"not no nor only own same s t don dont im ive please thanks hi hello also still get got use used using " +
"app application streamyfin issue bug"
).split(/\s+/),
);
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s) =>
(s || "")
.toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
.replace(/<!--[\s\S]*?-->/g, " ") // drop html comments
.replace(/https?:\/\/\S+/g, " ") // drop urls
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP.has(w))
.map(stem)
.filter((w) => w.length > 2);
const jaccard = (a, b) => {
const A = new Set(a);
const B = new Set(b);
if (!A.size || !B.size) return 0;
let inter = 0;
for (const x of A) if (B.has(x)) inter++;
return inter / (A.size + B.size - inter);
};
const newTitle = tokens(TITLE);
const newBody = tokens(BODY);
const score = (o) =>
0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself)
let issues;
if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
} else {
const raw = execFileSync(
"gh",
[
"api",
`repos/${REPO}/issues`,
"--paginate",
"-X",
"GET",
"-f",
"state=open",
"-f",
"per_page=100",
"--jq",
".[] | select(.pull_request | not) | {number, title, body}",
],
{ encoding: "utf8", maxBuffer: 1e8 },
);
issues = raw
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
}
const matches = issues
.filter((o) => o.number !== NUMBER)
.map((o) => ({ ...o, s: score(o) }))
.filter((o) => o.s >= THRESHOLD)
.sort((a, b) => b.s - a.s)
.slice(0, MAX);
if (!matches.length) {
console.log("No likely duplicates found.");
process.exit(0);
}
// Neutralise other issues' titles before echoing them back: break @mentions and
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) =>
(t || "")
.replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 140);
const list = matches
.map(
(m) =>
`- #${m.number}${safeTitle(m.title)} (≈ ${Math.round(m.s * 100)}% similar)`,
)
.join("\n");
const comment = [
"<!-- duplicate-detector -->",
"🔍 **This looks like it might be a duplicate.** Possibly related open issues:",
"",
list,
"",
"If yours is different, ignore this — a maintainer will confirm. Otherwise, please 👍 the existing issue and add any extra details there.",
].join("\n");
console.log(`Found ${matches.length} possible duplicate(s):\n${list}`);
if (DRY) {
console.log("\nDRY_RUN: not commenting/labelling.");
process.exit(0);
}
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/comments`,
"-f",
`body=${comment}`,
],
{ stdio: "ignore" },
);
try {
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/labels`,
"-f",
`labels[]=${LABEL}`,
],
{ stdio: "ignore" },
);
} catch {
// label may not exist yet — create then add
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/labels`,
"-f",
`name=${LABEL}`,
"-f",
"color=fbca04",
"-f",
"description=Automatically flagged as a possible duplicate",
],
{ stdio: "ignore" },
);
execFileSync(
"gh",
[
"api",
"-X",
"POST",
`repos/${REPO}/issues/${NUMBER}/labels`,
"-f",
`labels[]=${LABEL}`,
],
{ stdio: "ignore" },
);
}
console.log("Commented and labelled.");