crewly 1.20.156 → 1.20.157
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.
- package/dist/backend/backend/src/constants.d.ts +95 -4
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +97 -6
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.js +5 -0
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/tickets/tickets.controller.d.ts +18 -0
- package/dist/backend/backend/src/controllers/tickets/tickets.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/tickets/tickets.controller.js +42 -0
- package/dist/backend/backend/src/controllers/tickets/tickets.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/tickets/tickets.routes.d.ts +1 -0
- package/dist/backend/backend/src/controllers/tickets/tickets.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/tickets/tickets.routes.js +3 -1
- package/dist/backend/backend/src/controllers/tickets/tickets.routes.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +8 -2
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.d.ts +84 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.js +301 -6
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.boot.d.ts +8 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.boot.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.boot.js +16 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.boot.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.d.ts +28 -10
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.js +46 -193
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.service.d.ts +15 -2
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.service.js +26 -4
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.d.ts +35 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.js.map +1 -1
- package/dist/backend/backend/src/services/v3/request.service.d.ts +2 -0
- package/dist/backend/backend/src/services/v3/request.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/request.service.js +16 -4
- package/dist/backend/backend/src/services/v3/request.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-ask-classifier.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-ask-classifier.js +2 -0
- package/dist/backend/backend/src/services/v3/ticket-ask-classifier.js.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-channel-hooks.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-channel-hooks.js +2 -1
- package/dist/backend/backend/src/services/v3/ticket-channel-hooks.js.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-hygiene.d.ts +162 -0
- package/dist/backend/backend/src/services/v3/ticket-hygiene.d.ts.map +1 -0
- package/dist/backend/backend/src/services/v3/ticket-hygiene.js +221 -0
- package/dist/backend/backend/src/services/v3/ticket-hygiene.js.map +1 -0
- package/dist/backend/backend/src/services/v3/ticket-intake.service.d.ts +71 -6
- package/dist/backend/backend/src/services/v3/ticket-intake.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-intake.service.js +151 -25
- package/dist/backend/backend/src/services/v3/ticket-intake.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-review.service.d.ts +22 -3
- package/dist/backend/backend/src/services/v3/ticket-review.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/ticket-review.service.js +99 -40
- package/dist/backend/backend/src/services/v3/ticket-review.service.js.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.d.ts +13 -0
- package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +95 -4
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +97 -6
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/v3/request.service.d.ts +2 -0
- package/dist/cli/backend/src/services/v3/request.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/request.service.js +16 -4
- package/dist/cli/backend/src/services/v3/request.service.js.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-ask-classifier.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-ask-classifier.js +2 -0
- package/dist/cli/backend/src/services/v3/ticket-ask-classifier.js.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-channel-hooks.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-channel-hooks.js +2 -1
- package/dist/cli/backend/src/services/v3/ticket-channel-hooks.js.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-hygiene.d.ts +162 -0
- package/dist/cli/backend/src/services/v3/ticket-hygiene.d.ts.map +1 -0
- package/dist/cli/backend/src/services/v3/ticket-hygiene.js +221 -0
- package/dist/cli/backend/src/services/v3/ticket-hygiene.js.map +1 -0
- package/dist/cli/backend/src/services/v3/ticket-intake.service.d.ts +71 -6
- package/dist/cli/backend/src/services/v3/ticket-intake.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-intake.service.js +151 -25
- package/dist/cli/backend/src/services/v3/ticket-intake.service.js.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-review.service.d.ts +22 -3
- package/dist/cli/backend/src/services/v3/ticket-review.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/v3/ticket-review.service.js +99 -40
- package/dist/cli/backend/src/services/v3/ticket-review.service.js.map +1 -1
- package/dist/cli/backend/src/types/v2/request.types.d.ts +13 -0
- package/dist/cli/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/v2/request.types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ticket hygiene (specs/ticket-calm.md) — the rules that keep the ticket pile
|
|
3
|
+
* from growing on the owner:
|
|
4
|
+
*
|
|
5
|
+
* - {@link answerNeedsOwner}: an answered ticket waits in 待验收 only when it
|
|
6
|
+
* has a deliverable someone must look at (a document, an email, a draft, a
|
|
7
|
+
* form, code, a deploy, money, anything sent out) or when the answer asks
|
|
8
|
+
* the owner something. A plain answer closes on the spot.
|
|
9
|
+
* - {@link isStaleTicket}: open / running with no activity for
|
|
10
|
+
* {@link TICKET_CONSTANTS.STALE.AFTER_MS} → closed as stale.
|
|
11
|
+
* - {@link planTicketCleanup} / {@link runTicketCleanup}: the one-time
|
|
12
|
+
* cleanup of the pile that built up before these rules (dry run by
|
|
13
|
+
* default, idempotent).
|
|
14
|
+
*
|
|
15
|
+
* Pure except for {@link runTicketCleanup}, which writes through the
|
|
16
|
+
* request store it is given. No LLM.
|
|
17
|
+
*
|
|
18
|
+
* @module services/v3/ticket-hygiene
|
|
19
|
+
*/
|
|
20
|
+
import { TICKET_CONSTANTS } from '../../constants.js';
|
|
21
|
+
import { formatTicketNumber, ticketNeedsReview } from '../../types/v2/ticket.types.js';
|
|
22
|
+
import { askText } from './ticket-ask-classifier.js';
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Rules
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Whether an answered ticket has to wait for the owner (待验收) or can close
|
|
28
|
+
* as soon as it is answered.
|
|
29
|
+
*
|
|
30
|
+
* Waits when the ask names a deliverable ({@link TICKET_CONSTANTS.REVIEW.DELIVERABLE_ASK}),
|
|
31
|
+
* the intent is a change ({@link TICKET_CONSTANTS.REVIEW.DELIVERABLE_CATEGORIES}),
|
|
32
|
+
* or the agent's answer asks the owner something
|
|
33
|
+
* ({@link TICKET_CONSTANTS.REVIEW.OWNER_QUESTION}). Everything else — "看看这个",
|
|
34
|
+
* "研究一下", "这个团队都有几个人" — is a conversation, and a conversation
|
|
35
|
+
* that got its answer is done.
|
|
36
|
+
*
|
|
37
|
+
* @param ticket - The ticket (its ask, intent and latest answer)
|
|
38
|
+
* @returns True when the owner has to look at the result
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* answerNeedsOwner({ description: '帮我写一封回信', reply: {...} }); // true
|
|
43
|
+
* answerNeedsOwner({ description: '看看这个 https://x.com/…', reply: {...} }); // false
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function answerNeedsOwner(ticket) {
|
|
47
|
+
if (ticket.kind === 'question')
|
|
48
|
+
return false;
|
|
49
|
+
const R = TICKET_CONSTANTS.REVIEW;
|
|
50
|
+
if (ticket.intentCategory && R.DELIVERABLE_CATEGORIES.includes(ticket.intentCategory))
|
|
51
|
+
return true;
|
|
52
|
+
if (R.DELIVERABLE_ASK.test(askText(ticket.description ?? '')))
|
|
53
|
+
return true;
|
|
54
|
+
const answer = (ticket.reply?.excerpt ?? '').trim();
|
|
55
|
+
// Ends on a question to him, or asks for an OK / a choice anywhere.
|
|
56
|
+
return /[??]$/.test(answer) || R.OWNER_QUESTION.test(answer);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The last time anything happened on a ticket: created, updated, answered,
|
|
60
|
+
* submitted or discussed.
|
|
61
|
+
*
|
|
62
|
+
* @param ticket - The ticket
|
|
63
|
+
* @returns Epoch ms (NaN-safe: unparsable dates are skipped)
|
|
64
|
+
*/
|
|
65
|
+
export function ticketLastActivity(ticket) {
|
|
66
|
+
const times = [ticket.createdAt, ticket.updatedAt, ticket.submittedAt, ticket.reply?.at, ...(ticket.discussion ?? []).map((d) => d.at)]
|
|
67
|
+
.map((t) => (t ? Date.parse(t) : NaN))
|
|
68
|
+
.filter((n) => Number.isFinite(n));
|
|
69
|
+
return times.length > 0 ? Math.max(...times) : 0;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether a ticket has gone stale: open / ready / running and untouched for
|
|
73
|
+
* {@link TICKET_CONSTANTS.STALE.AFTER_MS}.
|
|
74
|
+
*
|
|
75
|
+
* @param ticket - The ticket
|
|
76
|
+
* @param now - Current time (ms)
|
|
77
|
+
* @returns True when it should be closed as stale
|
|
78
|
+
*/
|
|
79
|
+
export function isStaleTicket(ticket, now) {
|
|
80
|
+
if (!TICKET_CONSTANTS.STALE.STATUSES.includes(ticket.status))
|
|
81
|
+
return false;
|
|
82
|
+
return now - ticketLastActivity(ticket) >= TICKET_CONSTANTS.STALE.AFTER_MS;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The update that closes a ticket as stale: cancelled, tagged, with a note in
|
|
86
|
+
* its discussion so whoever opens it sees why.
|
|
87
|
+
*
|
|
88
|
+
* @param ticket - The ticket
|
|
89
|
+
* @param nowIso - Current time
|
|
90
|
+
* @returns The update
|
|
91
|
+
*/
|
|
92
|
+
export function staleCloseUpdate(ticket, nowIso) {
|
|
93
|
+
const note = {
|
|
94
|
+
at: nowIso,
|
|
95
|
+
author: TICKET_CONSTANTS.STALE.NOTE_AUTHOR,
|
|
96
|
+
text: TICKET_CONSTANTS.STALE.NOTE,
|
|
97
|
+
ref: `stale-${nowIso}`,
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
status: 'cancelled',
|
|
101
|
+
tags: [...new Set([...ticket.tags, TICKET_CONSTANTS.STALE.TAG])],
|
|
102
|
+
discussion: [...(ticket.discussion ?? []), note],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Plan the one-time cleanup — the 2026-09-28 rules applied to the pile that
|
|
107
|
+
* built up before them:
|
|
108
|
+
*
|
|
109
|
+
* - a 待验收 ticket whose answer needs nothing from the owner
|
|
110
|
+
* ({@link answerNeedsOwner}) is closed as answered;
|
|
111
|
+
* - any other 待验收 ticket answered more than
|
|
112
|
+
* {@link TICKET_CONSTANTS.REVIEW.AUTO_ACCEPT_MS} ago is accepted (silence);
|
|
113
|
+
* - an open / running one idle for {@link TICKET_CONSTANTS.STALE.AFTER_MS} is
|
|
114
|
+
* closed as stale.
|
|
115
|
+
*
|
|
116
|
+
* Idempotent: a second plan over the result is empty.
|
|
117
|
+
*
|
|
118
|
+
* @param requests - Every Request in the store
|
|
119
|
+
* @param opts - Clock and scope
|
|
120
|
+
* @returns The report (not applied)
|
|
121
|
+
*/
|
|
122
|
+
export function planTicketCleanup(requests, opts) {
|
|
123
|
+
const actions = [];
|
|
124
|
+
for (const r of requests) {
|
|
125
|
+
const numbered = typeof r.ticketNumber === 'number';
|
|
126
|
+
if (!numbered && opts.includeLegacy === false)
|
|
127
|
+
continue;
|
|
128
|
+
const base = { id: r.id, tkt: numbered ? formatTicketNumber(r.ticketNumber) : null, title: r.title, from: r.status };
|
|
129
|
+
if (r.status === 'waiting_confirmation') {
|
|
130
|
+
const since = Date.parse(r.submittedAt ?? r.updatedAt);
|
|
131
|
+
if (numbered && ticketNeedsReview(r) && r.reply && !answerNeedsOwner(r))
|
|
132
|
+
actions.push({ ...base, action: 'answered' });
|
|
133
|
+
else if (Number.isFinite(since) && opts.now - since >= TICKET_CONSTANTS.REVIEW.AUTO_ACCEPT_MS)
|
|
134
|
+
actions.push({ ...base, action: 'accept' });
|
|
135
|
+
}
|
|
136
|
+
else if (isStaleTicket(r, opts.now)) {
|
|
137
|
+
actions.push({ ...base, action: 'stale' });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
applied: false,
|
|
142
|
+
scanned: requests.length,
|
|
143
|
+
answered: actions.filter((a) => a.action === 'answered').length,
|
|
144
|
+
accept: actions.filter((a) => a.action === 'accept').length,
|
|
145
|
+
stale: actions.filter((a) => a.action === 'stale').length,
|
|
146
|
+
failed: [],
|
|
147
|
+
actions,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Run the cleanup. Dry run unless `apply`; the plan is made from the store's
|
|
152
|
+
* current contents, so running it twice changes nothing the second time.
|
|
153
|
+
*
|
|
154
|
+
* Accepting goes through the store's `done` gate with `accepted` (silence,
|
|
155
|
+
* tagged `auto_accepted`) and `ignoreDeadChildren`, so a ticket with a live
|
|
156
|
+
* WorkItem still stays open (reported under `failed`). A `waiting_confirmation`
|
|
157
|
+
* ticket that does not need review (a question, a cron ticket) is closed the
|
|
158
|
+
* same way without the tag.
|
|
159
|
+
*
|
|
160
|
+
* @param store - Request store
|
|
161
|
+
* @param opts - Clock, scope and `apply`
|
|
162
|
+
* @returns What was (or would be) done
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```typescript
|
|
166
|
+
* const dry = await runTicketCleanup(RequestService.getInstance(), { now: Date.now() });
|
|
167
|
+
* const done = await runTicketCleanup(RequestService.getInstance(), { now: Date.now(), apply: true });
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export async function runTicketCleanup(store, opts) {
|
|
171
|
+
const all = await store.listAll();
|
|
172
|
+
const plan = planTicketCleanup(all, opts);
|
|
173
|
+
if (!opts.apply)
|
|
174
|
+
return plan;
|
|
175
|
+
const byId = new Map(all.map((r) => [r.id, r]));
|
|
176
|
+
const nowIso = new Date(opts.now).toISOString();
|
|
177
|
+
const failed = [];
|
|
178
|
+
for (const a of plan.actions) {
|
|
179
|
+
const r = byId.get(a.id);
|
|
180
|
+
if (!r)
|
|
181
|
+
continue;
|
|
182
|
+
try {
|
|
183
|
+
if (a.action === 'answered') {
|
|
184
|
+
await store.update(r.id, {
|
|
185
|
+
status: 'done',
|
|
186
|
+
accepted: true,
|
|
187
|
+
ignoreDeadChildren: true,
|
|
188
|
+
tags: [...new Set([...r.tags, TICKET_CONSTANTS.REVIEW.ANSWERED_TAG])],
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
else if (a.action === 'accept') {
|
|
192
|
+
const silence = ticketNeedsReview(r);
|
|
193
|
+
await store.update(r.id, {
|
|
194
|
+
status: 'done',
|
|
195
|
+
accepted: true,
|
|
196
|
+
ignoreDeadChildren: true,
|
|
197
|
+
...(silence
|
|
198
|
+
? { acceptedBy: 'silence', tags: [...new Set([...r.tags, TICKET_CONSTANTS.REVIEW.AUTO_ACCEPTED_TAG])] }
|
|
199
|
+
: {}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
await store.update(r.id, staleCloseUpdate(r, nowIso));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
failed.push({ id: r.id, reason: err instanceof Error ? err.message : String(err) });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const failedIds = new Set(failed.map((f) => f.id));
|
|
211
|
+
const ok = plan.actions.filter((a) => !failedIds.has(a.id));
|
|
212
|
+
return {
|
|
213
|
+
...plan,
|
|
214
|
+
applied: true,
|
|
215
|
+
answered: ok.filter((a) => a.action === 'answered').length,
|
|
216
|
+
accept: ok.filter((a) => a.action === 'accept').length,
|
|
217
|
+
stale: ok.filter((a) => a.action === 'stale').length,
|
|
218
|
+
failed,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=ticket-hygiene.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ticket-hygiene.js","sourceRoot":"","sources":["../../../../../../backend/src/services/v3/ticket-hygiene.ts"],"names":[],"mappings":"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"}
|
|
@@ -10,9 +10,18 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - opens a new ticket (a `Request` with a TKT number, `kind`, `origin`,
|
|
12
12
|
* `assignee`) and posts ONE receipt where it was said;
|
|
13
|
-
* - is a reply in a thread that already has a ticket
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - is a reply in a thread that already has a ticket — open, waiting for the
|
|
14
|
+
* owner, or finished within {@link TICKET_CONSTANTS.FOLLOW_UP.RECENT_TICKET_MS}:
|
|
15
|
+
* a follow-up, an answer or a decision is appended to its discussion (and
|
|
16
|
+
* on a 待验收 ticket counts as the owner's OK); only a genuinely new ask
|
|
17
|
+
* opens its own ticket in the same thread, linked to the parent (#827,
|
|
18
|
+
* {@link isGenuinelyNewAsk});
|
|
19
|
+
* - is a DM reply soon after the conversation's latest ticket
|
|
20
|
+
* ({@link TICKET_CONSTANTS.FOLLOW_UP.DM_WINDOW_MS}): the same, for
|
|
21
|
+
* conversations where every message is top level;
|
|
22
|
+
* - is a reply in a thread an agent started (no ticket): an answer to the
|
|
23
|
+
* agent, ignored unless it is a genuinely new ask (2026-09-28: 「发了」
|
|
24
|
+
* 「A 论文那个 开个Issue吧」 had become tickets of their own);
|
|
16
25
|
* - is "不用记" (don't track) and dismisses the ticket it answers;
|
|
17
26
|
* - is noise (trivial ack, file only, a status ping, agent-authored) and is
|
|
18
27
|
* ignored. Request-phrased questions ("可以去研究一下 X 吗") are asks, and a
|
|
@@ -23,7 +32,7 @@
|
|
|
23
32
|
*
|
|
24
33
|
* @module services/v3/ticket-intake.service
|
|
25
34
|
*/
|
|
26
|
-
import { weightedTextLength } from './ticket-ask-classifier.js';
|
|
35
|
+
import { weightedTextLength, type AskClassification } from './ticket-ask-classifier.js';
|
|
27
36
|
import type { IntakeOutcomeRecorder } from './ticket-intake-log.js';
|
|
28
37
|
import { type Request, type RequestStatus, type TicketAcceptedBy } from '../../types/v2/request.types.js';
|
|
29
38
|
import { type TicketOrigin, type TicketReceipt, type TicketKind, type TicketBoardColumn, type TicketPriorityLabel, type BoardWorkItemView, type TicketAcceptance, type TicketReply } from '../../types/v2/ticket.types.js';
|
|
@@ -60,6 +69,26 @@ export declare function titleText(text: string): string;
|
|
|
60
69
|
* @returns True for "不用记" and its variants
|
|
61
70
|
*/
|
|
62
71
|
export declare function isDismissText(text: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Whether a reply in an existing conversation (a ticket's thread, an agent's
|
|
74
|
+
* thread, a DM right after a ticket) is a genuinely new ask that deserves its
|
|
75
|
+
* own ticket (2026-09-28). Stricter than the classifier's `new_ask` alone:
|
|
76
|
+
*
|
|
77
|
+
* - a question stays in the conversation (the agent answers it there);
|
|
78
|
+
* - a short reply (≤ {@link TICKET_CONSTANTS.FOLLOW_UP.SHORT_REPLY_WEIGHTED_LENGTH}
|
|
79
|
+
* weighted) is an answer — 「可以改到10:30吗」 — unless it says 「帮我…」
|
|
80
|
+
* outright or opens a new topic.
|
|
81
|
+
*
|
|
82
|
+
* @param text - The owner's message
|
|
83
|
+
* @param ask - Its in-thread classification
|
|
84
|
+
* @returns True when it opens a ticket of its own
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* isGenuinelyNewAsk('A 论文那个 开个Issue吧 放到backlog', classifyOwnerMessage(t, { inThread: true })); // false
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export declare function isGenuinelyNewAsk(text: string, ask: AskClassification): boolean;
|
|
63
92
|
/** Where a receipt should be posted. */
|
|
64
93
|
export type ReceiptTarget = {
|
|
65
94
|
kind: 'slack';
|
|
@@ -109,6 +138,11 @@ export interface IntakeMessage {
|
|
|
109
138
|
legacyThreadParentRef?: string;
|
|
110
139
|
/** Extra tags (e.g. `slack` / `chat-v2` for the SLA subscriber) */
|
|
111
140
|
tags?: readonly string[];
|
|
141
|
+
/**
|
|
142
|
+
* The message is a reply inside a thread (not the thread's root). A reply
|
|
143
|
+
* in a thread with no ticket answers whoever started it — an agent.
|
|
144
|
+
*/
|
|
145
|
+
isThreadReply?: boolean;
|
|
112
146
|
/** Where to post the receipt; omitted = no receipt */
|
|
113
147
|
receipt?: ReceiptTarget;
|
|
114
148
|
}
|
|
@@ -445,11 +479,42 @@ export declare class TicketIntakeService {
|
|
|
445
479
|
* @returns True when it was
|
|
446
480
|
*/
|
|
447
481
|
private conversationMatches;
|
|
482
|
+
/**
|
|
483
|
+
* Whether a finished ticket finished recently enough that its thread is
|
|
484
|
+
* still its conversation ({@link TICKET_CONSTANTS.FOLLOW_UP.RECENT_TICKET_MS}).
|
|
485
|
+
*
|
|
486
|
+
* @param ticket - A done or cancelled ticket
|
|
487
|
+
* @returns True when follow-ups in its thread still belong to it
|
|
488
|
+
*/
|
|
489
|
+
private finishedRecently;
|
|
490
|
+
/**
|
|
491
|
+
* In a DM-like conversation (every message top level), the ticket a reply
|
|
492
|
+
* most likely follows up: the conversation's ticket with the latest
|
|
493
|
+
* activity, if that was within {@link TICKET_CONSTANTS.FOLLOW_UP.DM_WINDOW_MS}.
|
|
494
|
+
* Dismissed tickets never take follow-ups.
|
|
495
|
+
*
|
|
496
|
+
* @param all - Every Request
|
|
497
|
+
* @param message - The message
|
|
498
|
+
* @returns The ticket, or null
|
|
499
|
+
*/
|
|
500
|
+
private recentConversationTicket;
|
|
501
|
+
/**
|
|
502
|
+
* An owner follow-up on a 待验收 ticket that is not 打回 (2026-09-28): it is
|
|
503
|
+
* his OK. The ticket is accepted and the message kept in its discussion;
|
|
504
|
+
* when it cannot be accepted yet (live work), the agent is back on it.
|
|
505
|
+
*
|
|
506
|
+
* @param ticket - The 待验收 ticket
|
|
507
|
+
* @param message - The follow-up
|
|
508
|
+
* @param text - Trimmed text
|
|
509
|
+
* @returns `verified`, or `appended` when it could not be accepted
|
|
510
|
+
*/
|
|
511
|
+
private acceptOnFollowUp;
|
|
448
512
|
/**
|
|
449
513
|
* Append a follow-up to a ticket's discussion.
|
|
450
514
|
*
|
|
451
|
-
*
|
|
452
|
-
* to the ticket (so the delivered copy carries its marker).
|
|
515
|
+
* Pure acknowledgements ("好的", "thanks") are not written, but the message
|
|
516
|
+
* still belongs to the ticket (so the delivered copy carries its marker).
|
|
517
|
+
* Short answers are written: 「是绿卡」 is what the agent asked for.
|
|
453
518
|
*
|
|
454
519
|
* @param ticket - The open ticket
|
|
455
520
|
* @param message - The follow-up
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ticket-intake.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/v3/ticket-intake.service.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"ticket-intake.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/v3/ticket-intake.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -10,9 +10,18 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - opens a new ticket (a `Request` with a TKT number, `kind`, `origin`,
|
|
12
12
|
* `assignee`) and posts ONE receipt where it was said;
|
|
13
|
-
* - is a reply in a thread that already has a ticket
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - is a reply in a thread that already has a ticket — open, waiting for the
|
|
14
|
+
* owner, or finished within {@link TICKET_CONSTANTS.FOLLOW_UP.RECENT_TICKET_MS}:
|
|
15
|
+
* a follow-up, an answer or a decision is appended to its discussion (and
|
|
16
|
+
* on a 待验收 ticket counts as the owner's OK); only a genuinely new ask
|
|
17
|
+
* opens its own ticket in the same thread, linked to the parent (#827,
|
|
18
|
+
* {@link isGenuinelyNewAsk});
|
|
19
|
+
* - is a DM reply soon after the conversation's latest ticket
|
|
20
|
+
* ({@link TICKET_CONSTANTS.FOLLOW_UP.DM_WINDOW_MS}): the same, for
|
|
21
|
+
* conversations where every message is top level;
|
|
22
|
+
* - is a reply in a thread an agent started (no ticket): an answer to the
|
|
23
|
+
* agent, ignored unless it is a genuinely new ask (2026-09-28: 「发了」
|
|
24
|
+
* 「A 论文那个 开个Issue吧」 had become tickets of their own);
|
|
16
25
|
* - is "不用记" (don't track) and dismisses the ticket it answers;
|
|
17
26
|
* - is noise (trivial ack, file only, a status ping, agent-authored) and is
|
|
18
27
|
* ignored. Request-phrased questions ("可以去研究一下 X 吗") are asks, and a
|
|
@@ -27,7 +36,8 @@ import * as path from 'path';
|
|
|
27
36
|
import { LoggerService } from '../core/logger.service.js';
|
|
28
37
|
import { modifyJsonFile } from '../../utils/file-io.utils.js';
|
|
29
38
|
import { TICKET_CONSTANTS } from '../../constants.js';
|
|
30
|
-
import { classifyOwnerMessage, weightedTextLength } from './ticket-ask-classifier.js';
|
|
39
|
+
import { askText, classifyOwnerMessage, weightedTextLength } from './ticket-ask-classifier.js';
|
|
40
|
+
import { ticketLastActivity } from './ticket-hygiene.js';
|
|
31
41
|
import { redactSensitive } from '../wiki/wiki-redaction.js';
|
|
32
42
|
import { TERMINAL_REQUEST_STATUSES, isValidRequestTransition, } from '../../types/v2/request.types.js';
|
|
33
43
|
import { formatTicketNumber, parseTicketNumber, deriveBoardColumn, ticketPriorityLabel, inferTicketKind, uniqueTicketIdFromTexts, parseReviewReply, activeAcceptance, ticketNeedsReview, } from '../../types/v2/ticket.types.js';
|
|
@@ -37,10 +47,13 @@ import { formatTicketNumber, parseTicketNumber, deriveBoardColumn, ticketPriorit
|
|
|
37
47
|
// ---------------------------------------------------------------------------
|
|
38
48
|
/**
|
|
39
49
|
* Trimmed-text regex for trivial acknowledgement messages — anchored,
|
|
40
|
-
* case-insensitive, allows trailing punctuation. Matches
|
|
41
|
-
* "thx", "👍", etc.
|
|
50
|
+
* case-insensitive, allows trailing punctuation, spaces and emoji. Matches
|
|
51
|
+
* "ok", "好的", "thx", "👍", etc.
|
|
52
|
+
*
|
|
53
|
+
* The tail used to be `\W*`, and CJK characters are all `\W`: 「是绿卡」
|
|
54
|
+
* 「对 就按第二种来 不用再问了」 read as a bare 「是」/「对」 and were dropped.
|
|
42
55
|
*/
|
|
43
|
-
const TRIVIAL_ACK_PATTERN = /^(ok|okay|好的|好|收到|thx|thanks|thank you|谢谢|多谢|👍|✅|got it|sure|yes|是|对|对的|嗯|嗯嗯|行)\
|
|
56
|
+
const TRIVIAL_ACK_PATTERN = /^(ok|okay|好的|好|收到|thx|thanks|thank you|谢谢|多谢|👍|✅|got it|sure|yes|是|对|对的|嗯|嗯嗯|行)[\s\p{P}\p{S}]*$/iu;
|
|
44
57
|
/**
|
|
45
58
|
* Trimmed-text regex matching the synthetic `[Slack File: …]` lines the
|
|
46
59
|
* Slack bridge appends for uploaded files.
|
|
@@ -105,6 +118,32 @@ export function titleText(text) {
|
|
|
105
118
|
export function isDismissText(text) {
|
|
106
119
|
return TICKET_CONSTANTS.DISMISS_PATTERN.test(text);
|
|
107
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Whether a reply in an existing conversation (a ticket's thread, an agent's
|
|
123
|
+
* thread, a DM right after a ticket) is a genuinely new ask that deserves its
|
|
124
|
+
* own ticket (2026-09-28). Stricter than the classifier's `new_ask` alone:
|
|
125
|
+
*
|
|
126
|
+
* - a question stays in the conversation (the agent answers it there);
|
|
127
|
+
* - a short reply (≤ {@link TICKET_CONSTANTS.FOLLOW_UP.SHORT_REPLY_WEIGHTED_LENGTH}
|
|
128
|
+
* weighted) is an answer — 「可以改到10:30吗」 — unless it says 「帮我…」
|
|
129
|
+
* outright or opens a new topic.
|
|
130
|
+
*
|
|
131
|
+
* @param text - The owner's message
|
|
132
|
+
* @param ask - Its in-thread classification
|
|
133
|
+
* @returns True when it opens a ticket of its own
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* isGenuinelyNewAsk('A 论文那个 开个Issue吧 放到backlog', classifyOwnerMessage(t, { inThread: true })); // false
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export function isGenuinelyNewAsk(text, ask) {
|
|
141
|
+
if (ask.verdict !== 'new_ask')
|
|
142
|
+
return false;
|
|
143
|
+
if (ask.signals.includes('strong_request') || ask.signals.includes('new_topic'))
|
|
144
|
+
return true;
|
|
145
|
+
return weightedTextLength(askText(text)) > TICKET_CONSTANTS.FOLLOW_UP.SHORT_REPLY_WEIGHTED_LENGTH;
|
|
146
|
+
}
|
|
108
147
|
// ---------------------------------------------------------------------------
|
|
109
148
|
// Service
|
|
110
149
|
// ---------------------------------------------------------------------------
|
|
@@ -259,8 +298,9 @@ export class TicketIntakeService {
|
|
|
259
298
|
return this.ignored(message, `dismiss_${result.reason}`);
|
|
260
299
|
return { action: 'dismissed', ticket: result.ticket };
|
|
261
300
|
}
|
|
262
|
-
// Same message twice (Slack redelivery, two event types)
|
|
263
|
-
|
|
301
|
+
// Same message twice (Slack redelivery, two event types) — whether it
|
|
302
|
+
// opened a ticket or was appended to one.
|
|
303
|
+
const duplicate = all.find((r) => r.sourceConversationItemId === message.origin.ref || (r.discussion ?? []).some((d) => d.ref === message.origin.ref));
|
|
264
304
|
if (duplicate)
|
|
265
305
|
return { action: 'duplicate', ticket: duplicate };
|
|
266
306
|
// A follow-up in a thread that already has a ticket.
|
|
@@ -271,10 +311,19 @@ export class TicketIntakeService {
|
|
|
271
311
|
const ack = TICKET_CONSTANTS.REVIEW.ACK_PATTERN.test(text);
|
|
272
312
|
if (threadTicket) {
|
|
273
313
|
if (TERMINAL_REQUEST_STATUSES.has(threadTicket.status)) {
|
|
274
|
-
//
|
|
314
|
+
// A dismissed thread stays quiet.
|
|
275
315
|
if (threadTicket.tags.includes(TICKET_CONSTANTS.DISMISSED_TAG)) {
|
|
276
316
|
return this.ignored(message, 'thread_dismissed');
|
|
277
317
|
}
|
|
318
|
+
// A recently finished ticket's thread is still its conversation: the
|
|
319
|
+
// owner answering the agent, choosing, parking it — not a new ticket.
|
|
320
|
+
if (this.finishedRecently(threadTicket)) {
|
|
321
|
+
const ask = classifyOwnerMessage(text, { inThread: true });
|
|
322
|
+
if (isGenuinelyNewAsk(text, ask))
|
|
323
|
+
return this.createInThread(all, message, text, threadTicket, ask);
|
|
324
|
+
return this.appendToTicket(threadTicket, message, text);
|
|
325
|
+
}
|
|
326
|
+
// An old thread: a new conversation (it may open a ticket below).
|
|
278
327
|
}
|
|
279
328
|
else if (threadTicket.status === 'waiting_confirmation' && this.review) {
|
|
280
329
|
if (review)
|
|
@@ -283,20 +332,17 @@ export class TicketIntakeService {
|
|
|
283
332
|
return this.applyReview(threadTicket, { action: 'verify' });
|
|
284
333
|
// A new ask does not reopen the answered one: it gets its own ticket.
|
|
285
334
|
const ask = classifyOwnerMessage(text, { inThread: true });
|
|
286
|
-
if (
|
|
335
|
+
if (isGenuinelyNewAsk(text, ask))
|
|
287
336
|
return this.createInThread(all, message, text, threadTicket, ask);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
await this.review.reopenOnFollowUp(threadTicket.id);
|
|
291
|
-
return this.appendToTicket(threadTicket, message, text);
|
|
337
|
+
// Anything else from the owner that is not 打回 is his OK (2026-09-28).
|
|
338
|
+
return this.acceptOnFollowUp(threadTicket, message, text);
|
|
292
339
|
}
|
|
293
340
|
else {
|
|
294
|
-
// #827: a thread is not one ticket
|
|
295
|
-
// own;
|
|
341
|
+
// #827: a thread is not one ticket — but only a genuinely new ask
|
|
342
|
+
// opens its own; answers, choices and questions stay in this one.
|
|
296
343
|
const ask = classifyOwnerMessage(text, { inThread: true });
|
|
297
|
-
if (
|
|
344
|
+
if (isGenuinelyNewAsk(text, ask))
|
|
298
345
|
return this.createInThread(all, message, text, threadTicket, ask);
|
|
299
|
-
}
|
|
300
346
|
return this.appendToTicket(threadTicket, message, text);
|
|
301
347
|
}
|
|
302
348
|
}
|
|
@@ -313,12 +359,29 @@ export class TicketIntakeService {
|
|
|
313
359
|
if (inReview && (review || recent))
|
|
314
360
|
return this.applyReview(inReview, review ?? { action: 'verify' });
|
|
315
361
|
}
|
|
362
|
+
// A DM reply right after the conversation's latest ticket is its
|
|
363
|
+
// follow-up — short answers too (「是绿卡」 answers the agent's question).
|
|
364
|
+
if (!threadTicket && text && !TRIVIAL_ACK_PATTERN.test(text)) {
|
|
365
|
+
const recent = this.recentConversationTicket(all, message);
|
|
366
|
+
if (recent && !isGenuinelyNewAsk(text, classifyOwnerMessage(text, { inThread: true }))) {
|
|
367
|
+
if (recent.status === 'waiting_confirmation' && this.review)
|
|
368
|
+
return this.acceptOnFollowUp(recent, message, text);
|
|
369
|
+
return this.appendToTicket(recent, message, text);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
316
372
|
const trivial = suppressTrivialOrShort(text);
|
|
317
373
|
if (trivial)
|
|
318
374
|
return this.ignored(message, trivial);
|
|
319
375
|
const fileOnly = suppressFileOnly(text, (message.attachments?.length ?? 0) > 0);
|
|
320
376
|
if (fileOnly)
|
|
321
377
|
return this.ignored(message, fileOnly);
|
|
378
|
+
if (!threadTicket) {
|
|
379
|
+
// A reply in a thread an agent started (a brief, a question): the owner
|
|
380
|
+
// is answering it. Only a genuinely new ask becomes a ticket.
|
|
381
|
+
if (message.isThreadReply && !isGenuinelyNewAsk(text, classifyOwnerMessage(text, { inThread: true }))) {
|
|
382
|
+
return this.ignored(message, 'thread_reply');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
322
385
|
// Lazy: v3-data pulls in the pool and storage singletons, which the Slack
|
|
323
386
|
// modules that import this service must not load at module time.
|
|
324
387
|
const { classifyIntent } = await import('./v3-data.service.js');
|
|
@@ -522,11 +585,75 @@ export class TicketIntakeService {
|
|
|
522
585
|
conversationMatches(r, conversation) {
|
|
523
586
|
return !!r.origin?.threadRef && r.origin.threadRef.startsWith(`${conversation}:`);
|
|
524
587
|
}
|
|
588
|
+
/**
|
|
589
|
+
* Whether a finished ticket finished recently enough that its thread is
|
|
590
|
+
* still its conversation ({@link TICKET_CONSTANTS.FOLLOW_UP.RECENT_TICKET_MS}).
|
|
591
|
+
*
|
|
592
|
+
* @param ticket - A done or cancelled ticket
|
|
593
|
+
* @returns True when follow-ups in its thread still belong to it
|
|
594
|
+
*/
|
|
595
|
+
finishedRecently(ticket) {
|
|
596
|
+
const at = Date.parse(ticket.completedAt ?? ticket.updatedAt);
|
|
597
|
+
return Number.isFinite(at) && this.now().getTime() - at < TICKET_CONSTANTS.FOLLOW_UP.RECENT_TICKET_MS;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* In a DM-like conversation (every message top level), the ticket a reply
|
|
601
|
+
* most likely follows up: the conversation's ticket with the latest
|
|
602
|
+
* activity, if that was within {@link TICKET_CONSTANTS.FOLLOW_UP.DM_WINDOW_MS}.
|
|
603
|
+
* Dismissed tickets never take follow-ups.
|
|
604
|
+
*
|
|
605
|
+
* @param all - Every Request
|
|
606
|
+
* @param message - The message
|
|
607
|
+
* @returns The ticket, or null
|
|
608
|
+
*/
|
|
609
|
+
recentConversationTicket(all, message) {
|
|
610
|
+
const conversation = message.conversationRef;
|
|
611
|
+
if (!conversation || !TICKET_CONSTANTS.FOLLOW_UP.DM_ORIGINS.includes(message.origin.channel))
|
|
612
|
+
return null;
|
|
613
|
+
const cutoff = this.now().getTime() - TICKET_CONSTANTS.FOLLOW_UP.DM_WINDOW_MS;
|
|
614
|
+
let best = null;
|
|
615
|
+
let bestAt = -Infinity;
|
|
616
|
+
for (const r of all) {
|
|
617
|
+
if (typeof r.ticketNumber !== 'number' || !this.conversationMatches(r, conversation))
|
|
618
|
+
continue;
|
|
619
|
+
if (r.tags.includes(TICKET_CONSTANTS.DISMISSED_TAG) || r.tags.includes(TICKET_CONSTANTS.STALE.TAG))
|
|
620
|
+
continue;
|
|
621
|
+
const at = ticketLastActivity(r);
|
|
622
|
+
if (at >= cutoff && at > bestAt) {
|
|
623
|
+
best = r;
|
|
624
|
+
bestAt = at;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return best;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* An owner follow-up on a 待验收 ticket that is not 打回 (2026-09-28): it is
|
|
631
|
+
* his OK. The ticket is accepted and the message kept in its discussion;
|
|
632
|
+
* when it cannot be accepted yet (live work), the agent is back on it.
|
|
633
|
+
*
|
|
634
|
+
* @param ticket - The 待验收 ticket
|
|
635
|
+
* @param message - The follow-up
|
|
636
|
+
* @param text - Trimmed text
|
|
637
|
+
* @returns `verified`, or `appended` when it could not be accepted
|
|
638
|
+
*/
|
|
639
|
+
async acceptOnFollowUp(ticket, message, text) {
|
|
640
|
+
const review = this.review;
|
|
641
|
+
if (!review)
|
|
642
|
+
return this.appendToTicket(ticket, message, text);
|
|
643
|
+
const result = await review.verify(ticket.id);
|
|
644
|
+
if (!result.ok) {
|
|
645
|
+
const reopened = (await review.reopenOnFollowUp(ticket.id)) ?? ticket;
|
|
646
|
+
return this.appendToTicket(reopened, message, text);
|
|
647
|
+
}
|
|
648
|
+
const appended = await this.appendToTicket(result.ticket ?? ticket, message, text);
|
|
649
|
+
return { action: 'verified', ticket: appended.action === 'appended' ? appended.ticket : ticket };
|
|
650
|
+
}
|
|
525
651
|
/**
|
|
526
652
|
* Append a follow-up to a ticket's discussion.
|
|
527
653
|
*
|
|
528
|
-
*
|
|
529
|
-
* to the ticket (so the delivered copy carries its marker).
|
|
654
|
+
* Pure acknowledgements ("好的", "thanks") are not written, but the message
|
|
655
|
+
* still belongs to the ticket (so the delivered copy carries its marker).
|
|
656
|
+
* Short answers are written: 「是绿卡」 is what the agent asked for.
|
|
530
657
|
*
|
|
531
658
|
* @param ticket - The open ticket
|
|
532
659
|
* @param message - The follow-up
|
|
@@ -534,8 +661,9 @@ export class TicketIntakeService {
|
|
|
534
661
|
* @returns `appended` outcome
|
|
535
662
|
*/
|
|
536
663
|
async appendToTicket(ticket, message, text) {
|
|
537
|
-
if (
|
|
664
|
+
if (text.length === 0 || TRIVIAL_ACK_PATTERN.test(text) || suppressFileOnly(text, (message.attachments?.length ?? 0) > 0)) {
|
|
538
665
|
return { action: 'appended', ticket };
|
|
666
|
+
}
|
|
539
667
|
const discussion = [
|
|
540
668
|
...(ticket.discussion ?? []),
|
|
541
669
|
{
|
|
@@ -831,9 +959,7 @@ export class TicketIntakeService {
|
|
|
831
959
|
? null
|
|
832
960
|
: r.acceptedBy ?? (r.tags.includes(TICKET_CONSTANTS.REVIEW.AUTO_ACCEPTED_TAG) ? 'silence' : null),
|
|
833
961
|
autoAcceptAt: r.status === 'waiting_confirmation' && r.submittedAt && ticketNeedsReview(r)
|
|
834
|
-
? new Date(Date.parse(r.
|
|
835
|
-
TICKET_CONSTANTS.REVIEW.NUDGE_AFTER_MS *
|
|
836
|
-
(TICKET_CONSTANTS.REVIEW.MAX_NUDGES - (r.nudgeCount ?? 0) + 1)).toISOString()
|
|
962
|
+
? new Date(Date.parse(r.submittedAt) + TICKET_CONSTANTS.REVIEW.AUTO_ACCEPT_MS).toISOString()
|
|
837
963
|
: null,
|
|
838
964
|
};
|
|
839
965
|
}
|