create-interview-cockpit 0.28.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,526 @@
1
+ import type { InfraLabWorkspace } from "./types";
2
+
3
+ // Mirrors the structure of a real `governance-iam` Terraform module
4
+ // (roles, policies, attachments, users, OIDC providers, multi-account
5
+ // `for_each` expansion) but trimmed down so it can run end-to-end
6
+ // against LocalStack inside the Infra Lab runner.
7
+ //
8
+ // What works on LocalStack (free):
9
+ // - aws_iam_policy / aws_iam_role / *_policy_attachment / aws_iam_user
10
+ // - aws_iam_openid_connect_provider (object exists, trust is not exercised)
11
+ //
12
+ // What does NOT work realistically on LocalStack (left as plan-only / read):
13
+ // - real cross-account assume-role via Organizations
14
+ // - real OIDC federation (Azure DevOps, EKS IRSA, etc.)
15
+ export const AWS_GOVERNANCE_IAM_FILES: Record<string, string> = {
16
+ "README.md": `# AWS Governance IAM Lab
17
+
18
+ Practice the *shape* of an enterprise \`governance-iam\` Terraform module
19
+ against LocalStack. You get the same patterns the real module uses:
20
+
21
+ 1. **OIDC providers** (\`aws_iam_openid_connect_provider\`)
22
+ 2. **Custom IAM policies** loaded from JSON files in \`policy/\`
23
+ 3. **IAM roles** with trust documents from \`assume_role/\`
24
+ 4. **Policy attachments** (custom + AWS-managed)
25
+ 5. **IAM users** with attachments
26
+ 6. **Multi-account expansion** via \`for_each\` keyed as \`account/name\`
27
+
28
+ ## How to run it
29
+
30
+ In the Infra Lab console, one command at a time:
31
+
32
+ 1. \`terraform init\`
33
+ 2. \`terraform validate\`
34
+ 3. \`terraform plan -out=tfplan\`
35
+ 4. \`terraform apply -auto-approve\`
36
+ 5. Inspect with \`terraform state list\` and \`terraform show\`
37
+ 6. \`terraform destroy -auto-approve\` when done
38
+
39
+ ## What's real vs simulated
40
+
41
+ LocalStack accepts the IAM CRUD calls, so all roles/policies/users/attachments
42
+ are actually created. The OIDC providers are stored as objects, but no real
43
+ external identity (Azure DevOps, EKS, GKE) can federate into them here.
44
+
45
+ For the real federation handshake, you'd point this at a free-tier AWS
46
+ account by replacing the \`endpoints\` block in \`provider.tf\` and giving
47
+ real credentials.
48
+
49
+ ## Multi-account note
50
+
51
+ LocalStack free tier is single-account, so the two "accounts"
52
+ (\`access\`, \`workload\`) are simulated by provider aliases pointing at the
53
+ same LocalStack endpoint. The \`for_each\` and \`account/name\` key shape is
54
+ the same as in a real Organizations setup — flip the endpoints and you can
55
+ reuse it as-is.
56
+
57
+ ## Suggested experiments
58
+
59
+ - Add a new JSON file in \`policy/\` and reference it in \`terraform.tfvars\`.
60
+ - Add a new role in \`terraform.tfvars\` and watch the keyspace expand.
61
+ - Change a trust policy in \`assume_role/\` and run \`terraform plan\` to see
62
+ the in-place update.
63
+ - Try \`terraform plan\` after removing an attachment to learn how detach
64
+ works without destroying the role.
65
+ `,
66
+ "provider.tf": `terraform {
67
+ required_version = ">= 1.5.0"
68
+
69
+ required_providers {
70
+ aws = {
71
+ source = "hashicorp/aws"
72
+ version = "~> 5.0"
73
+ }
74
+ }
75
+ }
76
+
77
+ # Two provider aliases simulate the multi-account fan-out used by the real
78
+ # governance-iam module. Both point at the same LocalStack endpoint here;
79
+ # in production they would target different AWS accounts via assume-role.
80
+ provider "aws" {
81
+ alias = "access"
82
+ region = "us-east-1"
83
+ access_key = "test"
84
+ secret_key = "test"
85
+ skip_credentials_validation = true
86
+ skip_metadata_api_check = true
87
+ skip_requesting_account_id = true
88
+
89
+ endpoints {
90
+ iam = "http://localhost:4566"
91
+ sts = "http://localhost:4566"
92
+ }
93
+ }
94
+
95
+ provider "aws" {
96
+ alias = "workload"
97
+ region = "us-east-1"
98
+ access_key = "test"
99
+ secret_key = "test"
100
+ skip_credentials_validation = true
101
+ skip_metadata_api_check = true
102
+ skip_requesting_account_id = true
103
+
104
+ endpoints {
105
+ iam = "http://localhost:4566"
106
+ sts = "http://localhost:4566"
107
+ }
108
+ }
109
+ `,
110
+ "variables.tf": `# Logical inputs that mirror the real module. Swap values in
111
+ # terraform.tfvars to re-shape the deployment without touching main.tf.
112
+
113
+ variable "accounts" {
114
+ description = "Logical AWS account name -> provider alias key."
115
+ type = map(string)
116
+ default = {
117
+ access = "access"
118
+ workload = "workload"
119
+ }
120
+ }
121
+
122
+ variable "policies" {
123
+ description = "Custom policies to create. Keyed as <account>/<name>."
124
+ type = map(object({
125
+ path = string
126
+ description = string
127
+ }))
128
+ default = {}
129
+ }
130
+
131
+ variable "roles" {
132
+ description = "Roles to create. Keyed as <account>/<name>."
133
+ type = map(object({
134
+ name = string
135
+ assume_role_policy_file = string
136
+ assume_role_policy_variable = map(string)
137
+ custom_policies = list(string) # tf_policy_keys to attach
138
+ managed_policy_arns = list(string) # AWS-managed ARNs to attach
139
+ }))
140
+ default = {}
141
+ }
142
+
143
+ variable "users" {
144
+ description = "IAM users to create. Keyed as <account>/<name>."
145
+ type = map(object({
146
+ name = string
147
+ custom_policies = list(string)
148
+ managed_policy_arns = list(string)
149
+ }))
150
+ default = {}
151
+ }
152
+
153
+ variable "oidc_identity" {
154
+ description = "OIDC providers to register. Keyed as <account>/<label>."
155
+ type = map(object({
156
+ account = string
157
+ oidc_url = string
158
+ audiences = list(string)
159
+ thumbprint = list(string)
160
+ }))
161
+ default = {}
162
+ }
163
+ `,
164
+ "local.tf": `# Real governance-iam splits a single declaration into per-account
165
+ # resources. We do the same here; the keys are "<account>/<name>" so a
166
+ # role declared in two accounts becomes two distinct resources.
167
+ locals {
168
+ account_policies = var.policies
169
+ account_roles = var.roles
170
+ account_users = var.users
171
+
172
+ # Flatten role -> custom policy attachments.
173
+ custom_role_attachments = merge([
174
+ for role_key, role in var.roles : {
175
+ for pol_key in role.custom_policies :
176
+ "\${role_key}::\${pol_key}" => {
177
+ tf_role_key = role_key
178
+ tf_policy_key = pol_key
179
+ }
180
+ }
181
+ ]...)
182
+
183
+ # Flatten role -> AWS-managed policy attachments.
184
+ managed_role_attachments = merge([
185
+ for role_key, role in var.roles : {
186
+ for arn in role.managed_policy_arns :
187
+ "\${role_key}::\${arn}" => {
188
+ tf_role_key = role_key
189
+ policy_arn = arn
190
+ }
191
+ }
192
+ ]...)
193
+
194
+ custom_user_attachments = merge([
195
+ for user_key, user in var.users : {
196
+ for pol_key in user.custom_policies :
197
+ "\${user_key}::\${pol_key}" => {
198
+ tf_user_key = user_key
199
+ tf_policy_key = pol_key
200
+ }
201
+ }
202
+ ]...)
203
+
204
+ managed_user_attachments = merge([
205
+ for user_key, user in var.users : {
206
+ for arn in user.managed_policy_arns :
207
+ "\${user_key}::\${arn}" => {
208
+ tf_user_key = user_key
209
+ policy_arn = arn
210
+ }
211
+ }
212
+ ]...)
213
+ }
214
+ `,
215
+ "main.tf": `# @ref: aws-oidc-provider
216
+ # Trust links so external systems (ADO, EKS, GKE) can federate without
217
+ # storing AWS keys. On LocalStack the object is created but no real JWT
218
+ # is verified against it.
219
+ resource "aws_iam_openid_connect_provider" "oidc" {
220
+ for_each = var.oidc_identity
221
+ provider = aws.access
222
+
223
+ url = each.value.oidc_url
224
+ client_id_list = each.value.audiences
225
+ thumbprint_list = each.value.thumbprint
226
+ }
227
+
228
+ # @ref: aws-custom-policy
229
+ # Custom permission documents loaded from JSON files in ./policy.
230
+ # The split() trick is what the real module uses to derive a name from
231
+ # the "<account>/<group>/<file>.json" key shape.
232
+ resource "aws_iam_policy" "policies" {
233
+ for_each = local.account_policies
234
+ provider = aws.access
235
+
236
+ name = split("/", each.key)[1]
237
+ description = each.value.description
238
+ policy = file("\${path.module}/policy/\${each.value.path}")
239
+ }
240
+
241
+ # @ref: aws-iam-role
242
+ # Trust policy != permission policy. The trust policy answers
243
+ # "who is allowed to become this role?". Permissions come from attachments.
244
+ resource "aws_iam_role" "roles" {
245
+ for_each = local.account_roles
246
+ provider = aws.access
247
+
248
+ name = each.value.name
249
+ assume_role_policy = templatefile(
250
+ "\${path.module}/assume_role/\${each.value.assume_role_policy_file}",
251
+ each.value.assume_role_policy_variable,
252
+ )
253
+ }
254
+
255
+ # @ref: aws-role-policy-attachment (custom)
256
+ resource "aws_iam_role_policy_attachment" "custom_role_policy" {
257
+ for_each = local.custom_role_attachments
258
+ provider = aws.access
259
+
260
+ role = aws_iam_role.roles[each.value.tf_role_key].name
261
+ policy_arn = aws_iam_policy.policies[each.value.tf_policy_key].arn
262
+ }
263
+
264
+ # @ref: aws-role-policy-attachment (managed)
265
+ resource "aws_iam_role_policy_attachment" "managed_role_policy" {
266
+ for_each = local.managed_role_attachments
267
+ provider = aws.access
268
+
269
+ role = aws_iam_role.roles[each.value.tf_role_key].name
270
+ policy_arn = each.value.policy_arn
271
+ }
272
+
273
+ # @ref: aws-iam-user
274
+ # Long-lived identity. Kept as an exercise; modern setups prefer roles
275
+ # + federation, but the real module still supports both.
276
+ resource "aws_iam_user" "users" {
277
+ for_each = local.account_users
278
+ provider = aws.access
279
+
280
+ name = each.value.name
281
+ path = "/"
282
+ force_destroy = true
283
+ }
284
+
285
+ resource "aws_iam_user_policy_attachment" "custom_user_policy" {
286
+ for_each = local.custom_user_attachments
287
+ provider = aws.access
288
+
289
+ user = aws_iam_user.users[each.value.tf_user_key].name
290
+ policy_arn = aws_iam_policy.policies[each.value.tf_policy_key].arn
291
+ }
292
+
293
+ resource "aws_iam_user_policy_attachment" "managed_user_policy" {
294
+ for_each = local.managed_user_attachments
295
+ provider = aws.access
296
+
297
+ user = aws_iam_user.users[each.value.tf_user_key].name
298
+ policy_arn = each.value.policy_arn
299
+ }
300
+ `,
301
+ "ado.tf": `# @ref: aws-ado-role
302
+ # Mirrors the real ado.tf: a per-account role assumed by an Azure DevOps
303
+ # pipeline via OIDC, with AdministratorAccess attached. On LocalStack the
304
+ # role exists but the actual federation handshake is not exercised.
305
+ locals {
306
+ ado_accounts = ["access", "workload"]
307
+ }
308
+
309
+ resource "aws_iam_role" "ado_role" {
310
+ for_each = toset(local.ado_accounts)
311
+ provider = aws.access
312
+
313
+ name = "ado-role-\${each.key}"
314
+ assume_role_policy = templatefile(
315
+ "\${path.module}/assume_role/ado-assume-role.json",
316
+ {
317
+ account_id = "000000000000"
318
+ environment = "lab"
319
+ },
320
+ )
321
+ }
322
+
323
+ resource "aws_iam_role_policy_attachment" "ado_admin" {
324
+ for_each = toset(local.ado_accounts)
325
+ provider = aws.access
326
+
327
+ role = aws_iam_role.ado_role[each.key].name
328
+ policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
329
+ }
330
+
331
+ resource "aws_iam_role" "ado_readonly" {
332
+ for_each = toset(local.ado_accounts)
333
+ provider = aws.access
334
+
335
+ name = "ado-role-readonly-\${each.key}"
336
+ assume_role_policy = templatefile(
337
+ "\${path.module}/assume_role/ado-assume-role.json",
338
+ {
339
+ account_id = "000000000000"
340
+ environment = "lab"
341
+ },
342
+ )
343
+ }
344
+
345
+ resource "aws_iam_role_policy_attachment" "ado_readonly" {
346
+ for_each = toset(local.ado_accounts)
347
+ provider = aws.access
348
+
349
+ role = aws_iam_role.ado_readonly[each.key].name
350
+ policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
351
+ }
352
+ `,
353
+ "outputs.tf": `output "policy_arns" {
354
+ description = "ARNs of all custom policies that were created."
355
+ value = { for k, p in aws_iam_policy.policies : k => p.arn }
356
+ }
357
+
358
+ output "role_arns" {
359
+ description = "ARNs of all roles that were created."
360
+ value = { for k, r in aws_iam_role.roles : k => r.arn }
361
+ }
362
+
363
+ output "ado_role_arns" {
364
+ description = "ARNs of the Azure DevOps deployment roles."
365
+ value = { for k, r in aws_iam_role.ado_role : k => r.arn }
366
+ }
367
+ `,
368
+ "terraform.tfvars": `# Sample data that drives the module. Everything below is what would
369
+ # normally live in variables.<env>.tfvars in the real repo.
370
+
371
+ policies = {
372
+ "access/external-dns-policy-v1" = {
373
+ path = "external-dns-policy-v1.json"
374
+ description = "Allows external-dns to manage Route53 records."
375
+ }
376
+ "access/cloudwatch-exporter-policy-v1" = {
377
+ path = "cloudwatch-exporter-policy-v1.json"
378
+ description = "Allows the cloudwatch exporter to read metrics."
379
+ }
380
+ }
381
+
382
+ roles = {
383
+ "access/external-dns" = {
384
+ name = "external-dns-role"
385
+ assume_role_policy_file = "eks-irsa-assume-role.json"
386
+ assume_role_policy_variable = {
387
+ account_id = "000000000000"
388
+ oidc_provider = "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633"
389
+ namespace = "kube-system"
390
+ service_account = "external-dns"
391
+ }
392
+ custom_policies = [
393
+ "access/external-dns-policy-v1",
394
+ ]
395
+ managed_policy_arns = []
396
+ }
397
+ "access/cw-exporter" = {
398
+ name = "cloudwatch-exporter-role"
399
+ assume_role_policy_file = "eks-irsa-assume-role.json"
400
+ assume_role_policy_variable = {
401
+ account_id = "000000000000"
402
+ oidc_provider = "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633"
403
+ namespace = "monitoring"
404
+ service_account = "cloudwatch-exporter"
405
+ }
406
+ custom_policies = [
407
+ "access/cloudwatch-exporter-policy-v1",
408
+ ]
409
+ managed_policy_arns = [
410
+ "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess",
411
+ ]
412
+ }
413
+ }
414
+
415
+ users = {
416
+ "access/break-glass" = {
417
+ name = "break-glass-admin"
418
+ custom_policies = []
419
+ managed_policy_arns = [
420
+ "arn:aws:iam::aws:policy/ReadOnlyAccess",
421
+ ]
422
+ }
423
+ }
424
+
425
+ oidc_identity = {
426
+ "access/eks-main" = {
427
+ account = "access"
428
+ oidc_url = "https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633"
429
+ audiences = ["sts.amazonaws.com"]
430
+ thumbprint = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]
431
+ }
432
+ "access/azure-devops" = {
433
+ account = "access"
434
+ oidc_url = "https://vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000"
435
+ audiences = ["api://AzureADTokenExchange"]
436
+ thumbprint = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]
437
+ }
438
+ }
439
+ `,
440
+ "policy/external-dns-policy-v1.json": `{
441
+ "Version": "2012-10-17",
442
+ "Statement": [
443
+ {
444
+ "Effect": "Allow",
445
+ "Action": [
446
+ "route53:ChangeResourceRecordSets"
447
+ ],
448
+ "Resource": "arn:aws:route53:::hostedzone/*"
449
+ },
450
+ {
451
+ "Effect": "Allow",
452
+ "Action": [
453
+ "route53:ListHostedZones",
454
+ "route53:ListResourceRecordSets"
455
+ ],
456
+ "Resource": "*"
457
+ }
458
+ ]
459
+ }
460
+ `,
461
+ "policy/cloudwatch-exporter-policy-v1.json": `{
462
+ "Version": "2012-10-17",
463
+ "Statement": [
464
+ {
465
+ "Effect": "Allow",
466
+ "Action": [
467
+ "cloudwatch:GetMetricData",
468
+ "cloudwatch:GetMetricStatistics",
469
+ "cloudwatch:ListMetrics",
470
+ "tag:GetResources"
471
+ ],
472
+ "Resource": "*"
473
+ }
474
+ ]
475
+ }
476
+ `,
477
+ "assume_role/eks-irsa-assume-role.json": `{
478
+ "Version": "2012-10-17",
479
+ "Statement": [
480
+ {
481
+ "Effect": "Allow",
482
+ "Principal": {
483
+ "Federated": "arn:aws:iam::\${account_id}:oidc-provider/\${oidc_provider}"
484
+ },
485
+ "Action": "sts:AssumeRoleWithWebIdentity",
486
+ "Condition": {
487
+ "StringEquals": {
488
+ "\${oidc_provider}:sub": "system:serviceaccount:\${namespace}:\${service_account}",
489
+ "\${oidc_provider}:aud": "sts.amazonaws.com"
490
+ }
491
+ }
492
+ }
493
+ ]
494
+ }
495
+ `,
496
+ "assume_role/ado-assume-role.json": `{
497
+ "Version": "2012-10-17",
498
+ "Statement": [
499
+ {
500
+ "Effect": "Allow",
501
+ "Principal": {
502
+ "Federated": "arn:aws:iam::\${account_id}:oidc-provider/vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000"
503
+ },
504
+ "Action": "sts:AssumeRoleWithWebIdentity",
505
+ "Condition": {
506
+ "StringEquals": {
507
+ "vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000:aud": "api://AzureADTokenExchange"
508
+ },
509
+ "StringLike": {
510
+ "vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000:sub": "sc://my-org/my-project/\${environment}-*"
511
+ }
512
+ }
513
+ }
514
+ ]
515
+ }
516
+ `,
517
+ };
518
+
519
+ export const AWS_GOVERNANCE_IAM_LAB: InfraLabWorkspace = {
520
+ version: 1,
521
+ label: "AWS Governance IAM Lab",
522
+ provider: "aws",
523
+ executionMode: "localstack",
524
+ activeFile: "README.md",
525
+ files: AWS_GOVERNANCE_IAM_FILES,
526
+ };
@@ -790,3 +790,123 @@ export function findCodeOwnersPath(
790
790
  export function isCodeOwnersPath(path: string): boolean {
791
791
  return (CODEOWNERS_LOCATIONS as readonly string[]).includes(path);
792
792
  }
793
+
794
+ // ─── Pull request templates ───────────────────────────────────────────
795
+ //
796
+ // GitHub doesn't have a settings UI for PR templates — they're picked
797
+ // up purely from files in the repo. The lookup rules we mirror:
798
+ //
799
+ // 1. Single template: the first match wins, in priority order:
800
+ // .github/pull_request_template.md
801
+ // .github/PULL_REQUEST_TEMPLATE.md
802
+ // pull_request_template.md (repo root)
803
+ // PULL_REQUEST_TEMPLATE.md (repo root)
804
+ // docs/pull_request_template.md
805
+ // docs/PULL_REQUEST_TEMPLATE.md
806
+ // Lookup is also case-insensitive on the filename.
807
+ //
808
+ // 2. Multiple templates: any *.md (or *.txt) file inside
809
+ // .github/PULL_REQUEST_TEMPLATE/
810
+ // PULL_REQUEST_TEMPLATE/
811
+ // docs/PULL_REQUEST_TEMPLATE/
812
+ // Each becomes a pick-able template; on github.com you'd append
813
+ // ?template=foo.md to the compare URL. We just show a picker.
814
+ //
815
+ // If both kinds are present, github.com shows the directory picker but
816
+ // also still lists the single template; we follow the same behaviour.
817
+
818
+ export interface PullRequestTemplate {
819
+ /** Workspace path of the file. */
820
+ path: string;
821
+ /** Display name (file basename minus extension, prettified). */
822
+ name: string;
823
+ /** Raw markdown body of the template. */
824
+ body: string;
825
+ /** True if this is the repo's default single template. */
826
+ isDefault: boolean;
827
+ }
828
+
829
+ const SINGLE_PR_TEMPLATE_DIRS: readonly string[] = [".github/", "", "docs/"];
830
+ const MULTI_PR_TEMPLATE_DIRS: readonly string[] = [
831
+ ".github/PULL_REQUEST_TEMPLATE/",
832
+ "PULL_REQUEST_TEMPLATE/",
833
+ "docs/PULL_REQUEST_TEMPLATE/",
834
+ ];
835
+
836
+ function isPullRequestTemplateFilename(filename: string): boolean {
837
+ // GitHub matches pull_request_template.md case-insensitively, with .md
838
+ // or .markdown extensions. We accept both.
839
+ return /^pull_request_template\.(md|markdown)$/i.test(filename);
840
+ }
841
+
842
+ function prettifyTemplateName(filename: string): string {
843
+ const base = filename.replace(/\.(md|markdown|txt)$/i, "");
844
+ return base.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
845
+ }
846
+
847
+ /**
848
+ * Discover all PR templates in the workspace, in github.com priority
849
+ * order. Returns an empty array if no templates exist.
850
+ */
851
+ export function findPullRequestTemplates(
852
+ files: Record<string, string>,
853
+ ): PullRequestTemplate[] {
854
+ const out: PullRequestTemplate[] = [];
855
+ const seen = new Set<string>();
856
+
857
+ // 1. Single-template lookup
858
+ for (const dir of SINGLE_PR_TEMPLATE_DIRS) {
859
+ for (const path of Object.keys(files)) {
860
+ if (seen.has(path)) continue;
861
+ if (!path.startsWith(dir)) continue;
862
+ const rest = path.slice(dir.length);
863
+ if (rest.includes("/")) continue; // not directly in this dir
864
+ if (!isPullRequestTemplateFilename(rest)) continue;
865
+ out.push({
866
+ path,
867
+ name: "Default",
868
+ body: files[path] ?? "",
869
+ isDefault: true,
870
+ });
871
+ seen.add(path);
872
+ break; // first match per dir; priority order handles the rest
873
+ }
874
+ }
875
+
876
+ // 2. Multi-template directory lookup
877
+ for (const dir of MULTI_PR_TEMPLATE_DIRS) {
878
+ for (const path of Object.keys(files)) {
879
+ if (seen.has(path)) continue;
880
+ if (!path.startsWith(dir)) continue;
881
+ const rest = path.slice(dir.length);
882
+ if (!rest || rest.includes("/")) continue;
883
+ if (!/\.(md|markdown|txt)$/i.test(rest)) continue;
884
+ out.push({
885
+ path,
886
+ name: prettifyTemplateName(rest),
887
+ body: files[path] ?? "",
888
+ isDefault: false,
889
+ });
890
+ seen.add(path);
891
+ }
892
+ }
893
+
894
+ return out;
895
+ }
896
+
897
+ /** True if the path is a recognised PR-template location. */
898
+ export function isPullRequestTemplatePath(path: string): boolean {
899
+ for (const dir of SINGLE_PR_TEMPLATE_DIRS) {
900
+ if (!path.startsWith(dir)) continue;
901
+ const rest = path.slice(dir.length);
902
+ if (!rest.includes("/") && isPullRequestTemplateFilename(rest)) return true;
903
+ }
904
+ for (const dir of MULTI_PR_TEMPLATE_DIRS) {
905
+ if (!path.startsWith(dir)) continue;
906
+ const rest = path.slice(dir.length);
907
+ if (rest && !rest.includes("/") && /\.(md|markdown|txt)$/i.test(rest)) {
908
+ return true;
909
+ }
910
+ }
911
+ return false;
912
+ }
@@ -482,13 +482,24 @@ export default function ChatView({ question }: Props) {
482
482
  // and triggering an update loop.
483
483
  const initialMessages = useMemo(
484
484
  () =>
485
- (question.messages ?? []).map((m) => ({
486
- id: m.id,
487
- role: m.role as "user" | "assistant",
488
- parts: (m.parts ?? [
489
- { type: "text" as const, text: m.content },
490
- ]) as UIMessage["parts"],
491
- })),
485
+ (question.messages ?? []).map((m) => {
486
+ // Older saved messages persist as `{ content, parts: [] }` — the empty
487
+ // array is truthy so a plain `m.parts ?? fallback` keeps `parts`
488
+ // empty, which makes `getTextContent` return "" and breaks any feature
489
+ // that reads message text (annotations, copy-to-clipboard, etc.).
490
+ // Treat empty `parts` the same as missing and rebuild from `content`.
491
+ const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
492
+ const parts = hasParts
493
+ ? (m.parts as UIMessage["parts"])
494
+ : ([
495
+ { type: "text" as const, text: m.content ?? "" },
496
+ ] as UIMessage["parts"]);
497
+ return {
498
+ id: m.id,
499
+ role: m.role as "user" | "assistant",
500
+ parts,
501
+ };
502
+ }),
492
503
  // eslint-disable-next-line react-hooks/exhaustive-deps
493
504
  [question.id],
494
505
  );