@popcorn245/cornfield 0.6.0 → 0.8.0
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/README.md +1 -0
- package/core/agent-contract.js +728 -0
- package/core/agent-contract.schema.json +259 -0
- package/core/config.js +36 -7
- package/core/defuse.js +17 -0
- package/core/mentions.js +239 -0
- package/explorer/explorer.css +9 -0
- package/explorer/forge-panel-model.js +88 -21
- package/explorer/forge-panel.js +310 -15
- package/explorer/index.html +18 -0
- package/explorer/panels.js +24 -0
- package/explorer/state.js +7 -0
- package/package.json +2 -1
- package/server/db.js +15 -0
- package/server/defuse.js +3 -12
- package/server/forge-actions.js +106 -5
- package/server/forge-forgejo.js +26 -5
- package/server/forge-github.js +5 -4
- package/server/forge-routes.js +33 -1
- package/server/forge.js +19 -6
- package/server/hooks.js +81 -1
- package/server/proposal-pulls.js +3 -3
- package/server/server.js +4 -2
- package/server/spec-poll.js +34 -3
- package/server/threads.js +275 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/popcorn245/CornField/blob/main/core/agent-contract.schema.json",
|
|
4
|
+
"title": "cornfield agent contract, version 1",
|
|
5
|
+
"description": "The JSON a cornfield-run fenced block holds: a request, a report or a stop request. docs/agent-contract.md says what each means. Checks the shape; a few rules are the validator's alone (the configured profile list, that a date is a real one), and are listed in the docs.",
|
|
6
|
+
"oneOf": [
|
|
7
|
+
{
|
|
8
|
+
"$ref": "#/$defs/request"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"$ref": "#/$defs/report"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"$ref": "#/$defs/stopRequest"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"$defs": {
|
|
18
|
+
"runId": {
|
|
19
|
+
"description": "run- and a 26 character ULID in Crockford base32",
|
|
20
|
+
"type": "string",
|
|
21
|
+
"pattern": "^run-[0-7][0-9A-HJKMNP-TV-Z]{25}$"
|
|
22
|
+
},
|
|
23
|
+
"request": {
|
|
24
|
+
"description": "A request for a run. It carries no credential: a property named like a token, a secret or a password is refused.",
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"required": [
|
|
28
|
+
"version",
|
|
29
|
+
"runId",
|
|
30
|
+
"kind",
|
|
31
|
+
"refs",
|
|
32
|
+
"brief",
|
|
33
|
+
"requestedBy",
|
|
34
|
+
"requestedAt"
|
|
35
|
+
],
|
|
36
|
+
"propertyNames": {
|
|
37
|
+
"not": {
|
|
38
|
+
"pattern": "(?:[tT][oO][kK][eE][nN]|[sS][eE][cC][rR][eE][tT]|[pP][aA][sS][sS][wW]|[cC][rR][eE][dD][eE][nN][tT][iI][aA][lL]|[aA][pP][iI][_-]?[kK][eE][yY]|[aA][cC][cC][eE][sS][sS][_-]?[kK][eE][yY]|[pP][rR][iI][vV][aA][tT][eE][_-]?[kK][eE][yY]|[sS][sS][hH][_-]?[kK][eE][yY]|[aA][uU][tT][hH][oO][rR][iI][zZ][aA][tT][iI][oO][nN]|[bB][eE][aA][rR][eE][rR]|[cC][oO][oO][kK][iI][eE])"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"properties": {
|
|
42
|
+
"version": {
|
|
43
|
+
"const": 1
|
|
44
|
+
},
|
|
45
|
+
"runId": {
|
|
46
|
+
"$ref": "#/$defs/runId"
|
|
47
|
+
},
|
|
48
|
+
"kind": {
|
|
49
|
+
"enum": [
|
|
50
|
+
"code",
|
|
51
|
+
"summary",
|
|
52
|
+
"brief",
|
|
53
|
+
"proposal"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"profile": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"maxLength": 64,
|
|
59
|
+
"not": {
|
|
60
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
61
|
+
},
|
|
62
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
|
|
63
|
+
"description": "A name the runner maps to a model, a machine and a budget. When the developer configured a list, only those names."
|
|
64
|
+
},
|
|
65
|
+
"refs": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"additionalProperties": false,
|
|
68
|
+
"properties": {
|
|
69
|
+
"issue": {
|
|
70
|
+
"type": "integer",
|
|
71
|
+
"minimum": 1,
|
|
72
|
+
"maximum": 2147483647
|
|
73
|
+
},
|
|
74
|
+
"proposal": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"maxLength": 120,
|
|
77
|
+
"not": {
|
|
78
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
79
|
+
},
|
|
80
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$"
|
|
81
|
+
},
|
|
82
|
+
"pull": {
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"minimum": 1,
|
|
85
|
+
"maximum": 2147483647,
|
|
86
|
+
"description": "The spec pull request that holds the proposal; only with proposal."
|
|
87
|
+
},
|
|
88
|
+
"thread": {
|
|
89
|
+
"type": "integer",
|
|
90
|
+
"minimum": 1,
|
|
91
|
+
"maximum": 2147483647
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"anyOf": [
|
|
95
|
+
{
|
|
96
|
+
"required": [
|
|
97
|
+
"issue"
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"required": [
|
|
102
|
+
"proposal"
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"required": [
|
|
107
|
+
"thread"
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"dependentRequired": {
|
|
112
|
+
"pull": [
|
|
113
|
+
"proposal"
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"branch": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"maxLength": 200,
|
|
120
|
+
"not": {
|
|
121
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
122
|
+
},
|
|
123
|
+
"minLength": 1,
|
|
124
|
+
"description": "A hint: a safe git ref name.",
|
|
125
|
+
"allOf": [
|
|
126
|
+
{
|
|
127
|
+
"pattern": "^[A-Za-z0-9._/-]+$"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"not": {
|
|
131
|
+
"pattern": "^-|^/|/$|//|\\.\\.|\\.$|(^|/)\\.|\\.lock(/|$)"
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
},
|
|
136
|
+
"brief": {
|
|
137
|
+
"type": "string",
|
|
138
|
+
"maxLength": 20000,
|
|
139
|
+
"not": {
|
|
140
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"limits": {
|
|
144
|
+
"type": "object",
|
|
145
|
+
"additionalProperties": false,
|
|
146
|
+
"properties": {
|
|
147
|
+
"minutes": {
|
|
148
|
+
"type": "number",
|
|
149
|
+
"exclusiveMinimum": 0
|
|
150
|
+
},
|
|
151
|
+
"turns": {
|
|
152
|
+
"type": "integer",
|
|
153
|
+
"exclusiveMinimum": 0
|
|
154
|
+
},
|
|
155
|
+
"cost": {
|
|
156
|
+
"type": "number",
|
|
157
|
+
"exclusiveMinimum": 0
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"nodes": {
|
|
162
|
+
"type": "array",
|
|
163
|
+
"maxItems": 100,
|
|
164
|
+
"items": {
|
|
165
|
+
"type": "string",
|
|
166
|
+
"maxLength": 200,
|
|
167
|
+
"not": {
|
|
168
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
169
|
+
},
|
|
170
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
"requestedBy": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"maxLength": 40,
|
|
176
|
+
"not": {
|
|
177
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
178
|
+
},
|
|
179
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$"
|
|
180
|
+
},
|
|
181
|
+
"requestedAt": {
|
|
182
|
+
"type": "string",
|
|
183
|
+
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,3})?Z$"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"report": {
|
|
188
|
+
"description": "What a runner may say about a run it is doing.",
|
|
189
|
+
"type": "object",
|
|
190
|
+
"additionalProperties": false,
|
|
191
|
+
"required": [
|
|
192
|
+
"state"
|
|
193
|
+
],
|
|
194
|
+
"properties": {
|
|
195
|
+
"state": {
|
|
196
|
+
"enum": [
|
|
197
|
+
"claimed",
|
|
198
|
+
"working",
|
|
199
|
+
"done",
|
|
200
|
+
"failed",
|
|
201
|
+
"stopped"
|
|
202
|
+
]
|
|
203
|
+
},
|
|
204
|
+
"note": {
|
|
205
|
+
"type": "string",
|
|
206
|
+
"maxLength": 4000,
|
|
207
|
+
"not": {
|
|
208
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
"url": {
|
|
212
|
+
"type": "string",
|
|
213
|
+
"maxLength": 2000,
|
|
214
|
+
"not": {
|
|
215
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
216
|
+
},
|
|
217
|
+
"pattern": "^https?://[^\\s/@?#]+([/?#]\\S*)?$",
|
|
218
|
+
"description": "Where the runner keeps its logs.",
|
|
219
|
+
"allOf": [
|
|
220
|
+
{
|
|
221
|
+
"not": {
|
|
222
|
+
"pattern": "[?&#;](?:[^\\s=&#;]*(?:[tT][oO][kK][eE][nN]|[sS][eE][cC][rR][eE][tT]|[pP][aA][sS][sS][wW]|[kK][eE][yY]|[sS][iI][gG][nN][aA][tT][uU][rR][eE])[^\\s=&#;]*|[sS][iI][gG])="
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
},
|
|
227
|
+
"tokens": {
|
|
228
|
+
"type": "integer",
|
|
229
|
+
"minimum": 0,
|
|
230
|
+
"maximum": 9007199254740991
|
|
231
|
+
},
|
|
232
|
+
"cost": {
|
|
233
|
+
"type": "number",
|
|
234
|
+
"minimum": 0
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
"stopRequest": {
|
|
239
|
+
"description": "cornfield asking a runner to stop. The runner decides what stopping means.",
|
|
240
|
+
"type": "object",
|
|
241
|
+
"additionalProperties": false,
|
|
242
|
+
"required": [
|
|
243
|
+
"state"
|
|
244
|
+
],
|
|
245
|
+
"properties": {
|
|
246
|
+
"state": {
|
|
247
|
+
"const": "stop-requested"
|
|
248
|
+
},
|
|
249
|
+
"note": {
|
|
250
|
+
"type": "string",
|
|
251
|
+
"maxLength": 4000,
|
|
252
|
+
"not": {
|
|
253
|
+
"pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
package/core/config.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* personas: { roles: null }, // the roles a persona may have (null: any)
|
|
24
24
|
* releaseNotes: { breakingLabel: 'breaking' }, // the pull request label that lists a change under "Breaking" in the release notes
|
|
25
25
|
* hooks: [], // [{ url, events }]: signed JSON events posted to outside code (docs/config.md#hooks); the secret is env only
|
|
26
|
-
* hookPoll: { specs: '5m' }, // how often cornfield looks for ready specs on its own, for the spec.ready
|
|
26
|
+
* hookPoll: { specs: '5m' }, // how often cornfield looks for ready specs and thread activity on its own, for the spec.ready and thread.* hooks; false turns it off (docs/config.md#hook-poll)
|
|
27
|
+
* threads: { label: 'idea' }, // an idea is an issue with this label, its thread the issue's comments; false turns threads off (docs/threads.md)
|
|
27
28
|
* links: [], // [{ label, href }] shown in the explorer's header
|
|
28
29
|
* proxy: {}, // { '/api/': 'http://127.0.0.1:3245' }: same-origin paths for a live source
|
|
29
30
|
* importMap: {}, // bare imports a repo-local browser module needs: { name: '/repo/path.js' }
|
|
@@ -66,7 +67,12 @@ export const FORGE_TYPES = ['github', 'forgejo'];
|
|
|
66
67
|
/** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
|
|
67
68
|
export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
|
|
68
69
|
/** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
|
|
69
|
-
export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring']);
|
|
70
|
+
export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked']);
|
|
71
|
+
/** The events that come from looking at threads (server/threads.js): a poll that wants any of them looks at threads too */
|
|
72
|
+
export const THREAD_EVENTS = Object.freeze(['thread.opened', 'thread.message', 'thread.linked']);
|
|
73
|
+
/** The keys of `threads` when it is an object, and the label's longest length */
|
|
74
|
+
export const THREADS_KEYS = Object.freeze(['label']);
|
|
75
|
+
export const MAX_LABEL = 50;
|
|
70
76
|
/** The most hooks a config lists */
|
|
71
77
|
export const MAX_HOOKS = 20;
|
|
72
78
|
/** The bounds of `hookPoll.specs` in milliseconds: not under a minute (the forge is not hammered), not over an hour */
|
|
@@ -85,14 +91,26 @@ export function parseDuration(v) {
|
|
|
85
91
|
return m ? Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2]] : null;
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
/** Whether some hook subscribes to any of `events` (by name, or with '*') */
|
|
95
|
+
export function hookWants(config, events) {
|
|
96
|
+
return Array.isArray(config.hooks) && config.hooks.some((h) => h.events === '*' || (Array.isArray(h.events) && h.events.some((e) => events.includes(e))));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether threads are on: `threads: false` turns them off, anything else (the default included) leaves them on */
|
|
100
|
+
export const threadsOn = (config) => config.threads !== false;
|
|
101
|
+
|
|
102
|
+
/** The label that makes an issue an idea, or null when threads are off */
|
|
103
|
+
export const ideaLabel = (config) => (threadsOn(config) ? (config.threads && config.threads.label) || DEFAULTS.threads.label : null);
|
|
104
|
+
|
|
88
105
|
/**
|
|
89
|
-
* How often cornfield looks
|
|
90
|
-
*
|
|
91
|
-
*
|
|
106
|
+
* How often cornfield looks by itself, in milliseconds, or null when it does not: when `hookPoll.specs` is false, or no
|
|
107
|
+
* hook subscribes to something the look finds (`spec.ready`, or a `thread.*` event while threads are on), by name or with '*'.
|
|
108
|
+
* One look covers both: the open spec PRs and the threads (server/spec-poll.js).
|
|
109
|
+
* @param {{ hooks?: { events: string[] | '*' }[], hookPoll?: { specs?: string | false } | false, threads?: false | { label?: string } }} config a loaded config
|
|
92
110
|
* @returns {number | null}
|
|
93
111
|
*/
|
|
94
112
|
export function specPollMs(config) {
|
|
95
|
-
const wanted =
|
|
113
|
+
const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && hookWants(config, THREAD_EVENTS));
|
|
96
114
|
const every = config.hookPoll === false ? false : config.hookPoll ? config.hookPoll.specs : DEFAULTS.hookPoll.specs;
|
|
97
115
|
return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
|
|
98
116
|
}
|
|
@@ -115,6 +133,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
115
133
|
agents: { authors: [], labels: [], branchPrefixes: [], bots: true },
|
|
116
134
|
hooks: [],
|
|
117
135
|
hookPoll: { specs: '5m' },
|
|
136
|
+
threads: { label: 'idea' },
|
|
118
137
|
links: [],
|
|
119
138
|
proxy: {},
|
|
120
139
|
importMap: {},
|
|
@@ -241,6 +260,14 @@ export function configProblems(c) {
|
|
|
241
260
|
}
|
|
242
261
|
}
|
|
243
262
|
}
|
|
263
|
+
if (c.threads !== undefined && c.threads !== false) {
|
|
264
|
+
if (!isObject(c.threads)) out.push('"threads" is { label: "idea" }, or false to turn threads off.');
|
|
265
|
+
else {
|
|
266
|
+
for (const k of Object.keys(c.threads)) if (!THREADS_KEYS.includes(k)) out.push(`threads.${k}: the key is ${THREADS_KEYS.join(', ')}.`);
|
|
267
|
+
const l = c.threads.label;
|
|
268
|
+
if (l !== undefined && (!isText(l) || l.includes(',') || l.trim() !== l || l.length > MAX_LABEL)) out.push(`threads.label is the name of the label that makes an issue an idea, like "idea": text of at most ${MAX_LABEL} characters, no comma, no space at either end.`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
244
271
|
if (c.releaseNotes !== undefined) {
|
|
245
272
|
if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
|
|
246
273
|
else if (c.releaseNotes.breakingLabel !== undefined && (!isText(c.releaseNotes.breakingLabel) || c.releaseNotes.breakingLabel.includes(','))) out.push('releaseNotes.breakingLabel is a label\'s name, like "breaking".');
|
|
@@ -323,7 +350,7 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
|
|
|
323
350
|
if (problems.length) throw new Error(`${path.relative(absRoot, configFile) || CONFIG_FILE}:\n ${problems.join('\n ')}`);
|
|
324
351
|
}
|
|
325
352
|
const config = {
|
|
326
|
-
...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes } }),
|
|
353
|
+
...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes }, threads: { ...DEFAULTS.threads } }),
|
|
327
354
|
...raw,
|
|
328
355
|
sources: { ...DEFAULTS.sources, ...(raw.sources || {}) },
|
|
329
356
|
personas: { ...DEFAULTS.personas, ...(raw.personas || {}) },
|
|
@@ -332,6 +359,8 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
|
|
|
332
359
|
deploys: { ...DEFAULTS.deploys, ...(raw.deploys || {}) },
|
|
333
360
|
agents: { ...DEFAULTS.agents, ...(raw.agents || {}) },
|
|
334
361
|
};
|
|
362
|
+
// `threads: false` stays false; an object is read over the default
|
|
363
|
+
config.threads = raw.threads === false ? false : { ...DEFAULTS.threads, ...(raw.threads || {}) };
|
|
335
364
|
// `hookPoll: false` is `{ specs: false }`; an object is read over the default
|
|
336
365
|
config.hookPoll = raw.hookPoll === false ? { specs: false } : { ...DEFAULTS.hookPoll, ...(raw.hookPoll || {}) };
|
|
337
366
|
// `proposals` is the folder to everything downstream, whichever way the file wrote it; the options are read from `approveLands`
|
package/core/defuse.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/defuse
|
|
3
|
+
*
|
|
4
|
+
* Breaking the mentions and references in text cornfield posts on the forge for someone else: `@user`, `@org/team`,
|
|
5
|
+
* `#1`, `owner/repo#1`, `GH-1`, and the links the forge would make of `https://…` or `www.…`. A zero-width space after
|
|
6
|
+
* the character keeps the text as it reads and stops it notifying anyone or linking anything. The pipeline's issues
|
|
7
|
+
* (server/pipeline.js), a review that came through the review API (server/reviews.js) and the agent contract
|
|
8
|
+
* (core/agent-contract.js) use it. It imports nothing, so Node and the browser can both load it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A zero-width space: it ends a mention or a reference without showing */
|
|
12
|
+
export const ZW = '';
|
|
13
|
+
|
|
14
|
+
/** Text with every mention, reference and auto-link broken (see the module's head) */
|
|
15
|
+
export function defuse(text) {
|
|
16
|
+
return String(text).replace(/[@#]/g, (c) => `${c}${ZW}`).replace(/\b(GH)-(?=\d)/gi, `$1${ZW}-`).replace(/:\/\//g, `:${ZW}//`).replace(/\b(www)\./gi, `$1${ZW}.`);
|
|
17
|
+
}
|
package/core/mentions.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/mentions
|
|
3
|
+
*
|
|
4
|
+
* How a thread points at the map (spec 0005, D7). Pure: no I/O, no DOM, no Node built-ins, so the server (a hook's
|
|
5
|
+
* payload) and the explorer (the lights, a node's "Mentioned in") load the same file.
|
|
6
|
+
*
|
|
7
|
+
* - **Mentions.** People write `[[core-server-auth]]` (a node's id) or `[[server/auth.js]]` (a repo path) in prose.
|
|
8
|
+
* `parseMentions` reads them outside code: a fenced block and an inline code span are text about code, not links.
|
|
9
|
+
* - **A block.** Tools and agents may write a fenced block for precise links:
|
|
10
|
+
*
|
|
11
|
+
* ```cornfield-thread
|
|
12
|
+
* { "nodes": ["core-server-auth", "server/hooks.js"], "proposal": "0013-meals" }
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `nodes` are read like mentions (an id or a path); `proposal` is a proposal's slug. A block that is not that shape
|
|
16
|
+
* is ignored, and prose is read as before.
|
|
17
|
+
* - **Resolving.** `resolveMention` answers a reference in this order: a node's id; a node whose `file` is exactly
|
|
18
|
+
* that path; the node(s) that own the path (the deepest `file` that is the path or a folder above it, as a file
|
|
19
|
+
* lights its node for a pull request, `nodesForFile`). A reference that is none of these resolves to nothing and
|
|
20
|
+
* stays plain text, which reads sensibly on the forge without cornfield.
|
|
21
|
+
* - **Caps.** At most `MAX_MENTIONS` distinct references are read from one text, and a reference is at most
|
|
22
|
+
* `MAX_REF` characters, so a comment cannot make the map light up everything or cost a lot to read.
|
|
23
|
+
* - **Links.** `threadOfPull` reads the thread a spec PR names: `Thread: #n` or `Closes #n` (also Fixes and Resolves).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The most distinct references read from one text, and the longest one */
|
|
27
|
+
export const MAX_MENTIONS = 50;
|
|
28
|
+
export const MAX_REF = 200;
|
|
29
|
+
/** The most a `cornfield-thread` block may name */
|
|
30
|
+
export const MAX_BLOCK_NODES = 50;
|
|
31
|
+
/** The fence's info string that marks a block of links */
|
|
32
|
+
export const THREAD_BLOCK = 'cornfield-thread';
|
|
33
|
+
|
|
34
|
+
const SLUG = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
|
|
35
|
+
|
|
36
|
+
const clean = (p) => String(p).replace(/^\.\//, '').replace(/\/+$/, '');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The node(s) whose `file` holds a path: the node's `file` is the path itself or a folder above it, and the deepest
|
|
40
|
+
* (longest) `file` wins. A file that holds several nodes (a page and what renders it) lights all of them. (The explorer's
|
|
41
|
+
* forge panel lights a pull request's files with this same rule.)
|
|
42
|
+
* @param {string} path a repo-relative path
|
|
43
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
44
|
+
* @returns {object[]} the nodes, [] when none holds it
|
|
45
|
+
*/
|
|
46
|
+
export function nodesForFile(path, nodes) {
|
|
47
|
+
const p = clean(path);
|
|
48
|
+
let best = -1;
|
|
49
|
+
let out = [];
|
|
50
|
+
for (const node of nodes) {
|
|
51
|
+
if (typeof node.file !== 'string' || node.file === '') continue;
|
|
52
|
+
const file = clean(node.file);
|
|
53
|
+
if (p !== file && !p.startsWith(`${file}/`)) continue;
|
|
54
|
+
if (file.length > best) { best = file.length; out = [node]; } else if (file.length === best) out.push(node);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Splits text into prose and code: `prose` is the text with every fenced block and inline code span blanked (same length,
|
|
61
|
+
* so offsets hold), `fences` are the fenced blocks as `{ info, body }`.
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {{ prose: string, fences: { info: string, body: string }[] }}
|
|
64
|
+
*/
|
|
65
|
+
export function scan(text) {
|
|
66
|
+
const src = String(text ?? '');
|
|
67
|
+
const lines = src.split('\n');
|
|
68
|
+
const fences = [];
|
|
69
|
+
const out = [];
|
|
70
|
+
let open = null; // { char, len, info, body: [] }
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (open) {
|
|
73
|
+
const close = new RegExp(`^ {0,3}${open.char}{${open.len},}[ \\t]*\\r?$`).test(line);
|
|
74
|
+
if (close) { fences.push({ info: open.info, body: open.body.join('\n') }); open = null; } else open.body.push(line.replace(/\r$/, ''));
|
|
75
|
+
out.push(' '.repeat(line.length));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const m = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\r]*)$/.exec(line);
|
|
79
|
+
// An info string of a backtick fence cannot hold a backtick (then it is an inline span, not a fence)
|
|
80
|
+
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
|
|
81
|
+
open = { char: m[1][0], len: m[1].length, info: m[2].trim(), body: [] };
|
|
82
|
+
out.push(' '.repeat(line.length));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
out.push(line);
|
|
86
|
+
}
|
|
87
|
+
// A fence left open runs to the end of the text, as the forge's Markdown reads it
|
|
88
|
+
if (open) fences.push({ info: open.info, body: open.body.join('\n') });
|
|
89
|
+
return { prose: blankSpans(out.join('\n')), fences };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Inline code spans blanked: a run of backticks closes at the next run of the same length */
|
|
93
|
+
function blankSpans(text) {
|
|
94
|
+
let out = '';
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < text.length) {
|
|
97
|
+
if (text[i] !== '`') { out += text[i++]; continue; }
|
|
98
|
+
let n = 0;
|
|
99
|
+
while (text[i + n] === '`') n++;
|
|
100
|
+
let j = i + n;
|
|
101
|
+
let end = -1;
|
|
102
|
+
while (j < text.length) {
|
|
103
|
+
if (text[j] !== '`') { j++; continue; }
|
|
104
|
+
let m = 0;
|
|
105
|
+
while (text[j + m] === '`') m++;
|
|
106
|
+
if (m === n) { end = j + m; break; }
|
|
107
|
+
j += m;
|
|
108
|
+
}
|
|
109
|
+
if (end === -1) { out += text.slice(i, i + n); i += n; continue; }
|
|
110
|
+
out += text.slice(i, end).replace(/[^\n]/g, ' ');
|
|
111
|
+
i = end;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A reference as written, made comparable: no `./`, no trailing slash, no `#L10` or `:10` line suffix; or null when it is not one */
|
|
117
|
+
export function normalizeRef(raw) {
|
|
118
|
+
let ref = String(raw ?? '').trim();
|
|
119
|
+
if (ref === '' || ref.length > MAX_REF || /[\0\n\r]/.test(ref)) return null;
|
|
120
|
+
ref = ref.replace(/#.*$/, '').replace(/:\d+(?:-\d+)?$/, '');
|
|
121
|
+
ref = clean(ref).trim();
|
|
122
|
+
if (ref === '' || ref.startsWith('/') || ref.split('/').some((p) => p === '..' || p === '.')) return null;
|
|
123
|
+
return ref;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The `cornfield-thread` block of a text, or null: the first well-formed one wins.
|
|
128
|
+
* @param {{ info: string, body: string }[]} fences
|
|
129
|
+
* @returns {{ nodes: string[], proposal: string | null } | null}
|
|
130
|
+
*/
|
|
131
|
+
function blockOf(fences) {
|
|
132
|
+
for (const f of fences) {
|
|
133
|
+
if (f.info.split(/\s+/)[0] !== THREAD_BLOCK) continue;
|
|
134
|
+
let json;
|
|
135
|
+
try { json = JSON.parse(f.body); } catch { continue; }
|
|
136
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) continue;
|
|
137
|
+
const nodes = Array.isArray(json.nodes) ? json.nodes.map((n) => (typeof n === 'string' ? normalizeRef(n) : null)).filter((r) => r !== null).slice(0, MAX_BLOCK_NODES) : [];
|
|
138
|
+
const proposal = typeof json.proposal === 'string' && SLUG.test(json.proposal) ? json.proposal : null;
|
|
139
|
+
if (!nodes.length && !proposal) continue;
|
|
140
|
+
return { nodes: [...new Set(nodes)], proposal };
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* What a text points at, before the map is asked: the `[[…]]` references outside code (each once, in order, at most
|
|
147
|
+
* `MAX_MENTIONS`), and the `cornfield-thread` block when there is one.
|
|
148
|
+
* @param {string} text
|
|
149
|
+
* @returns {{ refs: string[], block: { nodes: string[], proposal: string | null } | null, truncated: boolean }}
|
|
150
|
+
*/
|
|
151
|
+
export function parseMentions(text) {
|
|
152
|
+
const { prose, fences } = scan(text);
|
|
153
|
+
const refs = [];
|
|
154
|
+
let truncated = false;
|
|
155
|
+
for (const m of prose.matchAll(/(?<!\\)\[\[([^[\]\n]{1,400})\]\]/g)) {
|
|
156
|
+
const ref = normalizeRef(m[1]);
|
|
157
|
+
if (ref === null || refs.includes(ref)) continue;
|
|
158
|
+
if (refs.length >= MAX_MENTIONS) { truncated = true; break; }
|
|
159
|
+
refs.push(ref);
|
|
160
|
+
}
|
|
161
|
+
return { refs, block: blockOf(fences), truncated };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The nodes one reference stands for, in order of how sure it is: the id; else nodes whose `file` is exactly the path;
|
|
166
|
+
* else the nodes that own the path.
|
|
167
|
+
* @param {string} ref
|
|
168
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
169
|
+
* @returns {object[]}
|
|
170
|
+
*/
|
|
171
|
+
export function resolveMention(ref, nodes) {
|
|
172
|
+
const r = normalizeRef(ref);
|
|
173
|
+
if (r === null) return [];
|
|
174
|
+
const byId = nodes.filter((n) => n.id === r);
|
|
175
|
+
if (byId.length) return byId;
|
|
176
|
+
const exact = nodes.filter((n) => typeof n.file === 'string' && n.file !== '' && clean(n.file) === r);
|
|
177
|
+
if (exact.length) return exact;
|
|
178
|
+
return nodesForFile(r, nodes);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* References resolved against a map.
|
|
183
|
+
* @param {string[]} refs references as `parseMentions` gives them (or a block's `nodes`)
|
|
184
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
185
|
+
* @returns {{ resolved: { ref: string, ids: string[] }[], ids: string[], unresolved: string[] }} `ids`: every node once, in
|
|
186
|
+
* order; `unresolved`: the references that are on no node (they stay text)
|
|
187
|
+
*/
|
|
188
|
+
export function resolveMentions(refs, nodes) {
|
|
189
|
+
const resolved = [];
|
|
190
|
+
const unresolved = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const list = Array.isArray(refs) ? refs.slice(0, MAX_MENTIONS + MAX_BLOCK_NODES) : [];
|
|
193
|
+
for (const ref of list) {
|
|
194
|
+
const found = resolveMention(ref, nodes);
|
|
195
|
+
if (!found.length) { unresolved.push(ref); continue; }
|
|
196
|
+
const ids = found.map((n) => n.id);
|
|
197
|
+
resolved.push({ ref, ids });
|
|
198
|
+
for (const id of ids) seen.add(id);
|
|
199
|
+
}
|
|
200
|
+
return { resolved, ids: [...seen], unresolved };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Everything a set of texts (a thread's messages) points at, before the map is asked: the references of every text and of
|
|
205
|
+
* its blocks, each once, and the proposal a block names (the first).
|
|
206
|
+
* @param {string[]} texts
|
|
207
|
+
* @returns {{ refs: string[], proposal: string | null, truncated: boolean }}
|
|
208
|
+
*/
|
|
209
|
+
export function linksOf(texts) {
|
|
210
|
+
const refs = [];
|
|
211
|
+
let proposal = null;
|
|
212
|
+
let truncated = false;
|
|
213
|
+
const cap = MAX_MENTIONS * 2;
|
|
214
|
+
for (const text of texts) {
|
|
215
|
+
const got = parseMentions(text);
|
|
216
|
+
truncated ||= got.truncated;
|
|
217
|
+
for (const ref of [...got.refs, ...(got.block ? got.block.nodes : [])]) {
|
|
218
|
+
if (refs.includes(ref)) continue;
|
|
219
|
+
if (refs.length >= cap) { truncated = true; break; }
|
|
220
|
+
refs.push(ref);
|
|
221
|
+
}
|
|
222
|
+
if (proposal === null && got.block && got.block.proposal) proposal = got.block.proposal;
|
|
223
|
+
}
|
|
224
|
+
return { refs, proposal, truncated };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The thread a spec PR's text names: `Thread: #n`, or `Closes #n` (also Fixes and Resolves, and their other tenses), outside
|
|
229
|
+
* code. The first one named. An issue in another repo (`owner/name#n`) is not this repo's thread.
|
|
230
|
+
* @param {string} body
|
|
231
|
+
* @returns {number | null}
|
|
232
|
+
*/
|
|
233
|
+
export function threadOfPull(body) {
|
|
234
|
+
const { prose } = scan(body);
|
|
235
|
+
// `(?:\s*:)?\s+` and not `\s*:?\s+`: two whitespace runs side by side backtrack against each other, which made a long
|
|
236
|
+
// run of spaces after "Closes" (or a code block, blanked to spaces) quadratic; this way each run is read once
|
|
237
|
+
const m = /(?:^|[\s(])(?:thread\s*:|(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?:\s*:)?)\s+#([1-9]\d{0,8})\b/i.exec(prose);
|
|
238
|
+
return m ? Number(m[1]) : null;
|
|
239
|
+
}
|
package/explorer/explorer.css
CHANGED
|
@@ -913,3 +913,12 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
|
|
|
913
913
|
:root[data-theme="light"] .hx-forge-tab[aria-pressed="true"] { box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25); }
|
|
914
914
|
:root[data-theme="light"] .hx-forge { box-shadow: 10px 0 30px -12px rgba(15, 23, 42, 0.18); }
|
|
915
915
|
:root[data-theme="light"] .hx-forge:not(.forge-open) { box-shadow: none; }
|
|
916
|
+
|
|
917
|
+
/* Threads (forge-panel.js, docs/threads.md): messages as written, read-only; a node's "Mentioned in" opens one */
|
|
918
|
+
.hx-thread-msgs { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
|
919
|
+
.hx-thread-msg { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--hx-line); border-radius: 10px; background: var(--hx-control); }
|
|
920
|
+
.hx-thread-body { margin: 0; color: var(--hx-ink); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
|
|
921
|
+
.hx-thread-nodes { padding-left: 0; }
|
|
922
|
+
.hx-thread-compose { font-family: inherit; min-height: 8em; }
|
|
923
|
+
.hx-thread-open { display: block; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
|
|
924
|
+
#forge-idea[hidden], #idea-form[hidden], #idea-message[hidden] { display: none; }
|