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
26 changes: 21 additions & 5 deletions cdk/src/handlers/shared/orchestration-comment-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,28 @@ export function buildIntegrationIterationInstruction(trigger: CommentTrigger): s
*/
const MAX_COMMAND_WORDS = 6;

/** Word/phrase boundary match: the phrase appears as whole words in ``text``. */
/** True when ``c`` is one of the [a-z0-9] chars that count as "inside a word". */
function isAlnum(c: string | undefined): boolean {
return c !== undefined && ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'));
}

/** Word/phrase boundary match: the phrase appears as whole words in ``text``.
*
* Plain substring scan rather than a phrase-interpolated ``new RegExp`` — a
* dynamically built pattern would need metachar escaping to stay correct and
* would make the phrase list a regex-injection / ReDoS surface if it ever grew
* a non-literal member. Comparing chars directly cannot misparse its input.
*/
function hasPhrase(text: string, phrase: string): boolean {
// Escape regex metachars (e.g. "re-run"); match on non-word boundaries so
// "retry" doesn't fire inside a longer word.
const esc = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[^a-z0-9])${esc}([^a-z0-9]|$)`, 'i').test(text);
const hay = text.toLowerCase();
const needle = phrase.toLowerCase();
if (!needle) return false;
// Accept only occurrences flanked by a non-[a-z0-9] char (or a string edge),
// so "retry" doesn't fire inside a longer word like "retryx" / "abcretry".
for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + 1)) {
if (!isAlnum(hay[i - 1]) && !isAlnum(hay[i + needle.length])) return true;
}
return false;
}

/**
Expand Down
20 changes: 20 additions & 0 deletions cdk/test/handlers/shared/orchestration-comment-trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,26 @@ describe('parseRetryIntent — recognise a plain "retry" command', () => {
expect(parseRetryIntent('**retry**')).toBe(true);
});

test('a retry phrase EMBEDDED in a longer word does not fire', () => {
// The phrase match is whole-word: only a non-[a-z0-9] char (or a string
// edge) may flank it. Guards the boundary contract that the substring scan
// in hasPhrase upholds (it replaced a phrase-interpolated `new RegExp`).
for (const s of ['retryx', 'abcretry', 'xrerun', 'rerunner', 'retry9', '9retry']) {
expect(parseRetryIntent(s)).toBe(false);
}
});

test('phrases containing regex metacharacters match literally, not as patterns', () => {
// "re-run" is a RETRY_PHRASES member containing '-'. A phrase must never be
// interpreted as a pattern: "re.run"/"reXrun" must NOT match "re-run", and a
// user typing regex syntax must not have it evaluated.
expect(parseRetryIntent('re-run')).toBe(true);
expect(parseRetryIntent('re.run')).toBe(false);
expect(parseRetryIntent('reXrun')).toBe(false);
expect(parseRetryIntent('.*')).toBe(false);
expect(parseRetryIntent('(retr)y')).toBe(false);
});

test('KNOWN_EPIC_COMMANDS lists retry (kept in sync with the parser + panel copy)', () => {
expect(KNOWN_EPIC_COMMANDS).toContain('retry');
});
Expand Down
23 changes: 21 additions & 2 deletions scripts/linear_epic.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
import json
import os
import sys
import urllib.request
import urllib.error
import urllib.request

LINEAR_URL = "https://api.linear.app/graphql"

Expand Down Expand Up @@ -79,14 +79,33 @@ def pat():
return p


def https_opener():
"""An opener that can speak ONLY https.

``urllib.request.urlopen`` (and ``build_opener``) install the default
handler set, which includes ``file://``, ``ftp://`` and ``data://``. That
turns any future mistake which lets a URL be configured — an env var, a CLI
flag, an API-returned redirect target — into an arbitrary-file read, since
this function carries the Linear PAT. Building the director by hand keeps
HTTPS as the only reachable scheme, so no such mistake can escalate.
``HTTPErrorProcessor``/``HTTPDefaultErrorHandler`` preserve the usual
raise-on-4xx/5xx behaviour the caller below relies on.
"""
opener = urllib.request.OpenerDirector()
opener.add_handler(urllib.request.HTTPSHandler())
opener.add_handler(urllib.request.HTTPErrorProcessor())
opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
return opener


def gql(query, variables=None):
body = json.dumps({"query": query, "variables": variables or {}}).encode()
req = urllib.request.Request(
LINEAR_URL, data=body,
headers={"Authorization": pat(), "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
with https_opener().open(req, timeout=30) as r:
out = json.load(r)
except urllib.error.HTTPError as e:
sys.exit(f"HTTP {e.code}: {e.read().decode()[:400]}")
Expand Down
Loading