taskchef 7.14.1 → 7.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.14.1",
3
+ "version": "7.14.2",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.14.1",
3
+ "version": "7.14.2",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -16,7 +16,7 @@ import {
16
16
  turnPresentation,
17
17
  } from "./state.js";
18
18
  import { openTaskFromControl } from "./actions.js";
19
- import { githubReferenceSegments } from "./github-links.js";
19
+ import { referenceSegments } from "./github-links.js";
20
20
  import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
21
21
 
22
22
  const state = {
@@ -61,28 +61,33 @@ const elements = {
61
61
  };
62
62
  let notificationDescriptionSerial = 0;
63
63
 
64
- function githubLink(link, { compact = false } = {}) {
64
+ function referenceLink(link, { compact = false } = {}) {
65
65
  const anchor = document.createElement("a");
66
66
  anchor.className = compact ? "github-link github-link-compact" : "github-link";
67
67
  anchor.href = link.url;
68
68
  anchor.target = "_blank";
69
69
  anchor.rel = "noopener noreferrer";
70
70
  anchor.textContent = link.label ?? link.text;
71
+ const githubKind = link.type === "issue"
72
+ ? ", GitHub issue"
73
+ : link.type === "pull"
74
+ ? ", GitHub pull request"
75
+ : link.provider === "github" || link.owner ? " on GitHub" : "";
71
76
  anchor.setAttribute(
72
77
  "aria-label",
73
- `${link.label ?? link.text} on GitHub (opens in a new tab)`,
78
+ `${link.label ?? link.text}${githubKind} (opens in a new tab)`,
74
79
  );
75
80
  anchor.addEventListener("click", (event) => event.stopPropagation());
76
81
  return anchor;
77
82
  }
78
83
 
79
- function appendGitHubText(container, text, task) {
80
- const children = githubReferenceSegments(text, {
84
+ function appendLinkedText(container, text, task) {
85
+ const children = referenceSegments(text, {
81
86
  projectRepositories: task.project?.githubRepos,
82
87
  taskRepository: task.relatedGitHubRepository,
83
88
  }).map((segment) => {
84
89
  if (segment.kind === "text") return document.createTextNode(segment.text);
85
- if (segment.kind === "link") return githubLink(segment);
90
+ if (segment.kind === "link") return referenceLink(segment);
86
91
  const ambiguous = document.createElement("span");
87
92
  ambiguous.className = "github-reference-ambiguous";
88
93
  ambiguous.textContent = segment.text;
@@ -99,7 +104,7 @@ function relatedGitHubLinks(task, { compact = false } = {}) {
99
104
  const container = document.createElement("nav");
100
105
  container.className = `github-links${compact ? " github-links-compact" : ""}`;
101
106
  container.setAttribute("aria-label", `Related GitHub links for ${task.title}`);
102
- const children = links.map((link) => githubLink(link, { compact }));
107
+ const children = links.map((link) => referenceLink(link, { compact }));
103
108
  if (task.relatedGitHubLinksTruncated) {
104
109
  const more = document.createElement("span");
105
110
  more.className = "github-links-more";
@@ -307,7 +312,7 @@ function turnTimeline(task) {
307
312
  requestLabel.textContent = "Request";
308
313
  const request = document.createElement("p");
309
314
  request.className = "preserve-lines";
310
- appendGitHubText(
315
+ appendLinkedText(
311
316
  request,
312
317
  turn.requestSummary ?? "Request not recorded by this TaskChef version.",
313
318
  task,
@@ -316,7 +321,7 @@ function turnTimeline(task) {
316
321
  resultLabel.textContent = "Result";
317
322
  const result = document.createElement("p");
318
323
  result.className = "preserve-lines";
319
- appendGitHubText(result, presentation.summary, task);
324
+ appendLinkedText(result, presentation.summary, task);
320
325
  const turnMetadata = document.createElement("p");
321
326
  turnMetadata.className = "result-history-turn";
322
327
  turnMetadata.textContent = turn.turnId
@@ -424,12 +429,12 @@ function taskCard(task) {
424
429
  requestLabel.textContent = "Request";
425
430
  const request = document.createElement("span");
426
431
  request.className = "preserve-lines";
427
- request.textContent = latest.requestSummary;
432
+ appendLinkedText(request, latest.requestSummary, task);
428
433
  const resultLabel = document.createElement("strong");
429
434
  resultLabel.textContent = "Result";
430
435
  const result = document.createElement("span");
431
436
  result.className = "preserve-lines";
432
- result.textContent = latest.resultSummary;
437
+ appendLinkedText(result, latest.resultSummary, task);
433
438
  summary.replaceChildren(requestLabel, request, resultLabel, result);
434
439
  const relatedLinks = relatedGitHubLinks(task, { compact: true });
435
440
  const time = timestampControl(
@@ -1,5 +1,6 @@
1
1
  const GITHUB_REPOSITORY = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+$/;
2
- const REFERENCE_PATTERN = /https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)\/(issues|pull)\/([1-9]\d*)(?![A-Za-z0-9_])|([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)#([1-9]\d*)(?![A-Za-z0-9_])|#([1-9]\d*)(?![A-Za-z0-9_])|https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?)\/?(?=$|[\s),.;:!?'"\]}>])/gi;
2
+ const REFERENCE_PATTERN = /https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)\/(issues|pull)\/([1-9]\d*)(?![A-Za-z0-9_])|([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)#([1-9]\d*)(?![A-Za-z0-9_])|#([1-9]\d*)(?![A-Za-z0-9_])|https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?)\/?(?=$|[\s#),.;:!?'"\]}>])/gi;
3
+ const ABSOLUTE_HTTP_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
3
4
  export const MAX_RELATED_GITHUB_LINKS = 20;
4
5
 
5
6
  function canonicalRepository(owner, repository) {
@@ -23,10 +24,35 @@ export function normalizeGitHubRepository(value) {
23
24
 
24
25
  function rawReferences(text) {
25
26
  const value = String(text ?? "");
27
+ const absoluteRanges = absoluteHttpCandidates(value);
26
28
  const references = [];
27
29
  for (const match of value.matchAll(REFERENCE_PATTERN)) {
28
30
  const start = match.index;
29
31
  const preceding = start > 0 ? value[start - 1] : "";
32
+ let explicitAbsoluteUrl = null;
33
+ const containingAbsoluteUrl = absoluteRanges.find((range) => (
34
+ start >= range.start && start < range.end
35
+ ));
36
+ if (containingAbsoluteUrl) {
37
+ const explicitGitHubUrl = Boolean(match[1] || match[9]);
38
+ let absoluteHost = null;
39
+ try {
40
+ absoluteHost = new URL(containingAbsoluteUrl.text).hostname.toLowerCase();
41
+ } catch {
42
+ // Invalid absolute URL tokens still block nested shorthand recognition.
43
+ }
44
+ const completeGitHubUrl = explicitGitHubUrl
45
+ && start === containingAbsoluteUrl.start
46
+ && (absoluteHost === "github.com" || absoluteHost === "www.github.com");
47
+ if (!completeGitHubUrl) continue;
48
+ const safeUrl = safeAbsoluteHttpUrl(containingAbsoluteUrl.text);
49
+ if (!safeUrl) continue;
50
+ explicitAbsoluteUrl = {
51
+ end: containingAbsoluteUrl.end,
52
+ suffix: containingAbsoluteUrl.text.slice(match[0].length),
53
+ text: containingAbsoluteUrl.text,
54
+ };
55
+ }
30
56
  if (match[8] && /[\w&#/]/.test(preceding)) continue;
31
57
  if ((match[5] || match[1] || match[9]) && /[\w/]/.test(preceding)) continue;
32
58
 
@@ -45,14 +71,15 @@ function rawReferences(text) {
45
71
  [owner, repository] = normalized.split("/");
46
72
  }
47
73
  references.push({
48
- end: start + match[0].length,
74
+ end: explicitAbsoluteUrl?.end ?? start + match[0].length,
49
75
  explicit,
50
76
  number,
51
77
  owner,
52
78
  repository,
53
79
  start,
54
- text: match[0],
80
+ text: explicitAbsoluteUrl?.text ?? match[0],
55
81
  type,
82
+ urlSuffix: explicitAbsoluteUrl?.suffix ?? "",
56
83
  });
57
84
  }
58
85
  return references;
@@ -83,26 +110,111 @@ function resolvedReference(reference, repositoryContext) {
83
110
  if (reference.type === "repository") {
84
111
  return {
85
112
  kind: "link",
113
+ label: `${owner}/${repositoryName}`,
86
114
  number: null,
87
115
  owner,
116
+ provider: "github",
88
117
  repository: repositoryName,
89
118
  text: reference.text,
90
119
  type: reference.type,
91
- url: `https://github.com/${owner}/${repositoryName}`,
120
+ url: `https://github.com/${owner}/${repositoryName}${reference.urlSuffix}`,
92
121
  };
93
122
  }
94
123
  const path = reference.type === "pull" ? "pull" : "issues";
95
124
  return {
96
125
  kind: "link",
126
+ label: `${owner}/${repositoryName}#${reference.number}`,
97
127
  number: reference.number,
98
128
  owner,
129
+ provider: "github",
99
130
  repository: repositoryName,
100
131
  text: reference.text,
101
132
  type: reference.type,
102
- url: `https://github.com/${owner}/${repositoryName}/${path}/${reference.number}`,
133
+ url: `https://github.com/${owner}/${repositoryName}/${path}/${reference.number}${reference.urlSuffix}`,
103
134
  };
104
135
  }
105
136
 
137
+ function trimAbsoluteUrl(candidate) {
138
+ let trimmed = candidate;
139
+ let previous;
140
+ do {
141
+ previous = trimmed;
142
+ trimmed = trimmed.replace(/[.,;:!?]+$/u, "");
143
+ for (const [opening, closing] of [["(", ")"], ["[", "]"], ["{", "}"]]) {
144
+ while (trimmed.endsWith(closing)) {
145
+ const openings = [...trimmed].filter((character) => character === opening).length;
146
+ const closings = [...trimmed].filter((character) => character === closing).length;
147
+ if (closings <= openings) break;
148
+ trimmed = trimmed.slice(0, -1);
149
+ }
150
+ }
151
+ } while (trimmed !== previous);
152
+ return trimmed;
153
+ }
154
+
155
+ function absoluteHttpCandidates(text) {
156
+ const candidates = [];
157
+ for (const match of String(text ?? "").matchAll(ABSOLUTE_HTTP_PATTERN)) {
158
+ const candidate = trimAbsoluteUrl(match[0]);
159
+ if (!candidate) continue;
160
+ candidates.push({
161
+ end: match.index + candidate.length,
162
+ start: match.index,
163
+ text: candidate,
164
+ });
165
+ }
166
+ return candidates;
167
+ }
168
+
169
+ function hasDotPathSegment(candidate) {
170
+ const authorityAndPath = candidate.slice(candidate.indexOf("//") + 2).split(/[?#]/u, 1)[0];
171
+ const pathStart = authorityAndPath.indexOf("/");
172
+ if (pathStart < 0) return false;
173
+ return authorityAndPath.slice(pathStart).split("/").some((segment) => {
174
+ try {
175
+ const decoded = decodeURIComponent(segment);
176
+ return decoded === "." || decoded === "..";
177
+ } catch {
178
+ return true;
179
+ }
180
+ });
181
+ }
182
+
183
+ function safeAbsoluteHttpUrl(candidate) {
184
+ try {
185
+ const url = new URL(candidate);
186
+ if (
187
+ !url.hostname
188
+ || url.username
189
+ || url.password
190
+ || candidate.includes("\\")
191
+ || hasDotPathSegment(candidate)
192
+ ) return null;
193
+ return url.href;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ function rawAbsoluteHttpReferences(text) {
200
+ const references = [];
201
+ for (const candidate of absoluteHttpCandidates(text)) {
202
+ const url = safeAbsoluteHttpUrl(candidate.text);
203
+ if (!url) continue;
204
+ references.push({
205
+ end: candidate.end,
206
+ kind: "link",
207
+ label: candidate.text,
208
+ provider: "web",
209
+ start: candidate.start,
210
+ text: candidate.text,
211
+ type: "url",
212
+ url,
213
+ });
214
+ }
215
+ return references;
216
+ }
217
+
106
218
  export function githubReferenceSegments(text, {
107
219
  projectRepositories = [],
108
220
  taskRepository = null,
@@ -134,6 +246,45 @@ export function githubReferenceSegments(text, {
134
246
  return segments;
135
247
  }
136
248
 
249
+ export function referenceSegments(text, options = {}) {
250
+ const value = String(text ?? "");
251
+ const githubSegments = githubReferenceSegments(value, options);
252
+ const references = [];
253
+ let cursor = 0;
254
+ for (const segment of githubSegments) {
255
+ const start = value.indexOf(segment.text, cursor);
256
+ if (start < 0) continue;
257
+ if (segment.kind !== "text") {
258
+ references.push({ end: start + segment.text.length, segment, start });
259
+ }
260
+ cursor = start + segment.text.length;
261
+ }
262
+ const absoluteReferences = rawAbsoluteHttpReferences(value).filter((reference) => (
263
+ !references.some(({ end, start }) => reference.start < end && reference.end > start)
264
+ ));
265
+ if (absoluteReferences.length === 0) return githubSegments;
266
+
267
+ references.push(...absoluteReferences.map((segment) => ({
268
+ end: segment.end,
269
+ segment,
270
+ start: segment.start,
271
+ })));
272
+ references.sort((left, right) => left.start - right.start);
273
+
274
+ const segments = [];
275
+ let offset = 0;
276
+ for (const reference of references) {
277
+ if (reference.start < offset) continue;
278
+ if (reference.start > offset) {
279
+ segments.push({ kind: "text", text: value.slice(offset, reference.start) });
280
+ }
281
+ segments.push(reference.segment);
282
+ offset = reference.end;
283
+ }
284
+ if (offset < value.length) segments.push({ kind: "text", text: value.slice(offset) });
285
+ return segments;
286
+ }
287
+
137
288
  function taskTexts(task) {
138
289
  const texts = [task.instruction];
139
290
  for (const turn of task.turns ?? []) {
@@ -146,16 +297,9 @@ function taskTexts(task) {
146
297
  return texts.filter((text) => typeof text === "string" && text.length > 0);
147
298
  }
148
299
 
149
- function relatedLinkLabel(link, includeRepository, includeOwner) {
300
+ function relatedLinkLabel(link) {
150
301
  if (link.type === "repository") return `${link.owner}/${link.repository}`;
151
- const reference = link.type === "pull"
152
- ? `PR #${link.number}`
153
- : link.type === "issue"
154
- ? `Issue #${link.number}`
155
- : `#${link.number}`;
156
- if (!includeRepository) return reference;
157
- const repository = includeOwner ? `${link.owner}/${link.repository}` : link.repository;
158
- return `${repository} ${reference}`;
302
+ return `${link.owner}/${link.repository}#${link.number}`;
159
303
  }
160
304
 
161
305
  export function taskGitHubProjection(task) {
@@ -179,7 +323,7 @@ export function taskGitHubProjection(task) {
179
323
  taskRepository,
180
324
  })) {
181
325
  if (segment.kind !== "link") continue;
182
- const key = segment.url.toLowerCase();
326
+ const key = segment.url;
183
327
  if (seen.has(key)) continue;
184
328
  if (links.length === MAX_RELATED_GITHUB_LINKS) {
185
329
  truncated = true;
@@ -189,17 +333,9 @@ export function taskGitHubProjection(task) {
189
333
  links.push(segment);
190
334
  }
191
335
  }
192
- const repositories = new Set(links.map(({ owner, repository }) => `${owner}/${repository}`));
193
- const repositoryNames = new Map();
194
- for (const link of links) {
195
- const owners = repositoryNames.get(link.repository) ?? new Set();
196
- owners.add(link.owner);
197
- repositoryNames.set(link.repository, owners);
198
- }
199
- const includeRepository = repositories.size > 1;
200
336
  return {
201
337
  relatedGitHubLinks: links.map((link) => ({
202
- label: relatedLinkLabel(link, includeRepository, repositoryNames.get(link.repository).size > 1),
338
+ label: relatedLinkLabel(link),
203
339
  number: link.number,
204
340
  owner: link.owner,
205
341
  repository: link.repository,