@gitkraken/core-gitlens 0.4.101 → 0.5.101

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +29 -6
  2. package/dist/git/models/graph.d.ts +63 -33
  3. package/dist/git/models/graph.d.ts.map +1 -1
  4. package/dist/git/models/graph.js.map +1 -1
  5. package/dist/git/models/graphSession.d.ts +3 -84
  6. package/dist/git/models/graphSession.d.ts.map +1 -1
  7. package/dist/git/models/graphSession.js +1 -6
  8. package/dist/git/models/graphSession.js.map +1 -1
  9. package/dist/git/models/issue.d.ts +4 -3
  10. package/dist/git/models/issue.d.ts.map +1 -1
  11. package/dist/git/models/issue.js.map +1 -1
  12. package/dist/git/providers/graph.d.ts +1 -15
  13. package/dist/git/providers/graph.d.ts.map +1 -1
  14. package/dist/git/providers/pausedOperations.d.ts +2 -0
  15. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  16. package/dist/git/utils/issue.utils.d.ts.map +1 -1
  17. package/dist/git/utils/issue.utils.js +8 -6
  18. package/dist/git/utils/issue.utils.js.map +1 -1
  19. package/dist/git/utils/reachability.utils.d.ts +1 -1
  20. package/dist/git/utils/reachability.utils.js +1 -1
  21. package/dist/git/utils/remote.utils.d.ts +3 -0
  22. package/dist/git/utils/remote.utils.d.ts.map +1 -1
  23. package/dist/git/utils/remote.utils.js +5 -0
  24. package/dist/git/utils/remote.utils.js.map +1 -1
  25. package/dist/git-cli/providers/graph.d.ts +1 -3
  26. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  27. package/dist/git-cli/providers/graph.js +37 -214
  28. package/dist/git-cli/providers/graph.js.map +1 -1
  29. package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
  30. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  31. package/dist/git-cli/providers/pausedOperations.js +21 -1
  32. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  33. package/dist/git-cli/providers/staging.d.ts.map +1 -1
  34. package/dist/git-cli/providers/staging.js +10 -1
  35. package/dist/git-cli/providers/staging.js.map +1 -1
  36. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  37. package/dist/plus/git-github/api/github.js +18 -5
  38. package/dist/plus/git-github/api/github.js.map +1 -1
  39. package/dist/plus/git-github/models.d.ts +6 -4
  40. package/dist/plus/git-github/models.d.ts.map +1 -1
  41. package/dist/plus/git-github/models.js +19 -26
  42. package/dist/plus/git-github/models.js.map +1 -1
  43. package/dist/plus/git-github/providers/github/graph.d.ts +1 -3
  44. package/dist/plus/git-github/providers/github/graph.d.ts.map +1 -1
  45. package/dist/plus/git-github/providers/github/graph.js +8 -10
  46. package/dist/plus/git-github/providers/github/graph.js.map +1 -1
  47. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts +38 -48
  48. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts.map +1 -1
  49. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +22 -5
  50. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js.map +1 -1
  51. package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts +39 -0
  52. package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts.map +1 -0
  53. package/dist/plus/integrations/authentication/rejectedTokenTracker.js +53 -0
  54. package/dist/plus/integrations/authentication/rejectedTokenTracker.js.map +1 -0
  55. package/dist/plus/integrations/collectionMetadata.d.ts +21 -1
  56. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  57. package/dist/plus/integrations/collectionMetadata.js +82 -6
  58. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  59. package/dist/plus/integrations/integrationService.d.ts +3 -3
  60. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  61. package/dist/plus/integrations/integrationService.js +19 -3
  62. package/dist/plus/integrations/integrationService.js.map +1 -1
  63. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  64. package/dist/plus/integrations/models/gitHostIntegration.js +19 -6
  65. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  66. package/dist/plus/integrations/models/integration.d.ts +20 -0
  67. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  68. package/dist/plus/integrations/models/integration.js +64 -6
  69. package/dist/plus/integrations/models/integration.js.map +1 -1
  70. package/dist/plus/integrations/models/issuesIntegration.js +5 -5
  71. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  72. package/dist/plus/integrations/providers/models.d.ts +1 -0
  73. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  74. package/dist/plus/integrations/providers/models.js.map +1 -1
  75. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  76. package/dist/plus/integrations/providers/providersApi.js +4 -2
  77. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  78. package/dist/plus/integrations/providers/trello.d.ts +2 -0
  79. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  80. package/dist/plus/integrations/providers/trello.js +1 -1
  81. package/dist/plus/integrations/providers/trello.js.map +1 -1
  82. package/dist/plus/integrations/providers/utils/providerPaging.d.ts +4 -2
  83. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  84. package/dist/plus/integrations/providers/utils/providerPaging.js +34 -22
  85. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  86. package/dist/plus/integrations/reads/warnings.d.ts +4 -2
  87. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  88. package/dist/plus/integrations/reads/warnings.js +4 -2
  89. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  90. package/dist/utils/date.d.ts +1 -1
  91. package/dist/utils/date.d.ts.map +1 -1
  92. package/dist/utils/date.js +5 -3
  93. package/dist/utils/date.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/git/models/graph.ts +59 -41
  96. package/src/git/models/graphSession.ts +3 -106
  97. package/src/git/models/issue.ts +3 -2
  98. package/src/git/providers/graph.ts +1 -15
  99. package/src/git/providers/pausedOperations.ts +8 -1
  100. package/src/git/utils/issue.utils.ts +9 -6
  101. package/src/git/utils/reachability.utils.ts +1 -1
  102. package/src/git/utils/remote.utils.ts +6 -0
  103. package/src/git-cli/providers/graph.ts +37 -250
  104. package/src/git-cli/providers/pausedOperations.ts +29 -2
  105. package/src/git-cli/providers/staging.ts +8 -1
  106. package/src/plus/git-github/api/github.ts +19 -9
  107. package/src/plus/git-github/models.ts +27 -31
  108. package/src/plus/git-github/providers/github/graph.ts +8 -15
  109. package/src/plus/integrations/authentication/integrationAuthenticationProvider.ts +62 -40
  110. package/src/plus/integrations/authentication/rejectedTokenTracker.ts +56 -0
  111. package/src/plus/integrations/collectionMetadata.ts +97 -7
  112. package/src/plus/integrations/integrationService.ts +37 -9
  113. package/src/plus/integrations/models/gitHostIntegration.ts +19 -6
  114. package/src/plus/integrations/models/integration.ts +71 -7
  115. package/src/plus/integrations/models/issuesIntegration.ts +5 -5
  116. package/src/plus/integrations/providers/models.ts +4 -1
  117. package/src/plus/integrations/providers/providersApi.ts +6 -10
  118. package/src/plus/integrations/providers/trello.ts +3 -2
  119. package/src/plus/integrations/providers/utils/providerPaging.ts +46 -24
  120. package/src/plus/integrations/reads/warnings.ts +4 -2
  121. package/src/utils/date.ts +6 -3
@@ -509,7 +509,10 @@ export class PausedOperationsGitSubProvider implements GitPausedOperationsSubPro
509
509
  }
510
510
 
511
511
  @debug()
512
- async continuePausedOperation(repoPath: string, options?: { skip?: boolean }): Promise<void> {
512
+ async continuePausedOperation(
513
+ repoPath: string,
514
+ options?: { skip?: boolean; messageEditor?: string },
515
+ ): Promise<void> {
513
516
  const scope = getScopedLogger();
514
517
 
515
518
  const status = await this.getPausedOperationStatus(repoPath);
@@ -521,8 +524,32 @@ export class PausedOperationsGitSubProvider implements GitPausedOperationsSubPro
521
524
 
522
525
  const args = [status.type, options?.skip ? '--skip' : '--continue'];
523
526
 
527
+ // Continuing after a conflicted step can open the commit-message editor (merge-backend rebases,
528
+ // cherry-picks, and reverts). `GIT_EDITOR` is what git honors for that step, so set it on the
529
+ // environment; also set `core.editor` config as a fallback.
530
+ const configs: string[] = [];
531
+ let env: Record<string, string | undefined> | undefined;
532
+ if (options?.messageEditor) {
533
+ configs.push('-c', `core.editor=${options.messageEditor}`);
534
+ env = { GIT_EDITOR: options.messageEditor };
535
+ }
536
+
524
537
  try {
525
- await this.git.run({ cwd: repoPath, errors: 'throw' }, ...args);
538
+ await this.git.run(
539
+ {
540
+ cwd: repoPath,
541
+ errors: 'throw',
542
+ configs: configs,
543
+ env: env,
544
+ // The runner dedups in-flight commands by args only — it ignores per-call `configs`/`env`.
545
+ // A headless continue (with `GIT_EDITOR`) and a plain `--continue` would therefore collapse
546
+ // into one execution, so the plain one could silently run headless (auto-accepting a commit
547
+ // message) or the headless one could ride a plain continue and open an editor that hangs the
548
+ // loop. Tag the headless variant so it can't share a pending command with the plain one.
549
+ correlationKey: options?.messageEditor ? 'paused-op-continue-headless' : undefined,
550
+ },
551
+ ...args,
552
+ );
526
553
  this.context.hooks?.cache?.onReset?.(repoPath, 'branches', 'status');
527
554
  this.context.hooks?.repository?.onChanged?.(repoPath, ['head', 'heads', 'index']);
528
555
  } catch (ex) {
@@ -58,7 +58,14 @@ export class StagingGitSubProvider implements GitStagingSubProvider {
58
58
  if (gitDir == null) throw new Error(`Unable to determine git directory for ${repoPath}`);
59
59
 
60
60
  const currentIndex = joinPaths(gitDir.uri.fsPath, 'index');
61
- await fs.copyFile(currentIndex, tempIndex);
61
+ try {
62
+ await fs.copyFile(currentIndex, tempIndex);
63
+ } catch (ex) {
64
+ // A repo that has never staged anything has no index file yet; git reads a missing
65
+ // `GIT_INDEX_FILE` as an empty index, which is what 'current' means there. Any other
66
+ // failure is real — don't silently hand back an index claiming everything is deleted.
67
+ if ((ex as NodeJS.ErrnoException)?.code !== 'ENOENT') throw ex;
68
+ }
62
69
  break;
63
70
  }
64
71
  case 'ref': {
@@ -3638,7 +3638,7 @@ export class GitHubApi {
3638
3638
  endCursor?: string | null;
3639
3639
  hasNextPage: boolean;
3640
3640
  };
3641
- nodes: GitHubIssue[];
3641
+ nodes: (GitHubIssue | null)[] | null;
3642
3642
  };
3643
3643
  interface SearchResult {
3644
3644
  authored?: SearchCategory;
@@ -3777,16 +3777,26 @@ export class GitHubApi {
3777
3777
  cancellation,
3778
3778
  );
3779
3779
 
3780
- function toQueryResult(issue: GitHubIssue): IssueShape {
3781
- return fromGitHubIssue(issue, provider);
3782
- }
3783
-
3784
3780
  if (rsp == null) return { values: [], hasMore: false, page: page, truncated: false };
3785
3781
 
3782
+ // Map node-by-node so one unmappable issue can't discard the whole result set
3783
+ const issues: IssueShape[] = [];
3784
+ for (const node of [
3785
+ ...(rsp.assigned?.nodes ?? []),
3786
+ ...(rsp.mentioned?.nodes ?? []),
3787
+ ...(rsp.authored?.nodes ?? []),
3788
+ ]) {
3789
+ if (node?.id == null) continue;
3790
+
3791
+ try {
3792
+ issues.push(fromGitHubIssue(node, provider));
3793
+ } catch (ex) {
3794
+ scope?.warn(`skipped unmappable issue; id=${node.id}, url=${node.url}, ex=${ex}`);
3795
+ }
3796
+ }
3797
+
3786
3798
  const results: IterableIterator<IssueShape> = uniqueBy(
3787
- [...(rsp.assigned?.nodes ?? []), ...(rsp.mentioned?.nodes ?? []), ...(rsp.authored?.nodes ?? [])].map(
3788
- toQueryResult,
3789
- ),
3799
+ issues,
3790
3800
  r => r.url,
3791
3801
  (original, _current) => original,
3792
3802
  );
@@ -3910,7 +3920,7 @@ export class GitHubApi {
3910
3920
  scope,
3911
3921
  cancellation,
3912
3922
  );
3913
- if (rsp == null) return [];
3923
+ if (rsp == null) return results;
3914
3924
 
3915
3925
  const pageResults = filterPullRequestsBySearchState(
3916
3926
  rsp.search.nodes.map(pr => fromGitHubPullRequest(pr, provider)),
@@ -2,7 +2,7 @@ import type { Endpoints } from '@octokit/types';
2
2
  import { GitFileIndexStatus } from '../../git/models/fileStatus.js';
3
3
  import type { IssueLabel } from '../../git/models/issue.js';
4
4
  import { Issue, RepositoryAccessLevel } from '../../git/models/issue.js';
5
- import type { PullRequestState } from '../../git/models/pullRequest.js';
5
+ import type { PullRequestMember, PullRequestState } from '../../git/models/pullRequest.js';
6
6
  import {
7
7
  PullRequest,
8
8
  PullRequestMergeableState,
@@ -131,8 +131,9 @@ export type GitHubPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED';
131
131
  export type GitHubIssueOrPullRequestState = GitHubIssueState | GitHubPullRequestState;
132
132
 
133
133
  export interface GitHubPullRequestLite extends Omit<GitHubIssueOrPullRequest, '__typename'> {
134
- author: GitHubMember;
135
- body: string;
134
+ /** `Actor` is nullable in GitHub's schema — `null` once the author's account is deleted */
135
+ author: GitHubMember | null;
136
+ body: string | null;
136
137
 
137
138
  baseRefName: string;
138
139
  baseRefOid: string;
@@ -167,7 +168,8 @@ export interface GitHubPullRequestLite extends Omit<GitHubIssueOrPullRequest, '_
167
168
  }
168
169
 
169
170
  export interface GitHubIssue extends Omit<GitHubIssueOrPullRequest, '__typename'> {
170
- author: GitHubMember;
171
+ /** `Actor` is nullable in GitHub's schema — `null` once the author's account is deleted */
172
+ author: GitHubMember | null;
171
173
  assignees: { nodes: GitHubMember[] };
172
174
  comments?: {
173
175
  totalCount: number;
@@ -203,7 +205,7 @@ export interface GitHubPullRequest extends GitHubPullRequestLite {
203
205
  reviewDecision: GitHubPullRequestReviewDecision;
204
206
  latestReviews: {
205
207
  nodes: {
206
- author: GitHubMember;
208
+ author: GitHubMember | null;
207
209
  state: GitHubPullRequestReviewState;
208
210
  }[];
209
211
  };
@@ -234,15 +236,17 @@ export type GitHubViewerPermission =
234
236
  | 'READ' // Can read and clone this repository. Can also open and comment on issues and pull requests
235
237
  | 'NONE';
236
238
 
239
+ /** `ghost` is how github.com renders an actor whose account was deleted */
240
+ function fromGitHubMemberOrGhost(member: GitHubMember | null | undefined): PullRequestMember {
241
+ if (member == null) return { id: 'ghost', name: 'ghost' };
242
+
243
+ return { id: member.login, name: member.login, avatarUrl: member.avatarUrl, url: member.url };
244
+ }
245
+
237
246
  export function fromGitHubPullRequestLite(pr: GitHubPullRequestLite, provider: Provider): PullRequest {
238
247
  return new PullRequest(
239
248
  provider,
240
- {
241
- id: pr.author.login,
242
- name: pr.author.login,
243
- avatarUrl: pr.author.avatarUrl,
244
- url: pr.author.url,
245
- },
249
+ fromGitHubMemberOrGhost(pr.author),
246
250
  String(pr.number),
247
251
  pr.id,
248
252
  pr.title,
@@ -296,7 +300,7 @@ export function fromGitHubPullRequestLite(pr: GitHubPullRequestLite, provider: P
296
300
  undefined, // statusCheckRollupState
297
301
  undefined, // project
298
302
  undefined, // version
299
- pr.body,
303
+ pr.body ?? undefined,
300
304
  );
301
305
  }
302
306
 
@@ -399,12 +403,7 @@ export function fromGitHubPullRequestStatusCheckRollupState(
399
403
  export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider): PullRequest {
400
404
  return new PullRequest(
401
405
  provider,
402
- {
403
- id: pr.author.login,
404
- name: pr.author.login,
405
- avatarUrl: pr.author.avatarUrl,
406
- url: pr.author.url,
407
- },
406
+ fromGitHubMemberOrGhost(pr.author),
408
407
  String(pr.number),
409
408
  pr.id,
410
409
  pr.title,
@@ -469,12 +468,7 @@ export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider)
469
468
  )
470
469
  .filter(<T>(r?: T): r is T => Boolean(r)),
471
470
  pr.latestReviews.nodes.map(r => ({
472
- reviewer: {
473
- id: r.author.login,
474
- name: r.author.login,
475
- avatarUrl: r.author.avatarUrl,
476
- url: r.author.url,
477
- },
471
+ reviewer: fromGitHubMemberOrGhost(r.author),
478
472
  state: fromGitHubPullRequestReviewState(r.state),
479
473
  })),
480
474
  pr.assignees.nodes.map(r => ({
@@ -486,7 +480,7 @@ export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider)
486
480
  fromGitHubPullRequestStatusCheckRollupState(pr.commits.nodes?.[0]?.commit.statusCheckRollup?.state),
487
481
  undefined, // project
488
482
  undefined, // version
489
- pr.body,
483
+ pr.body ?? undefined,
490
484
  );
491
485
  }
492
486
 
@@ -506,12 +500,14 @@ export function fromGitHubIssue(value: GitHubIssue, provider: Provider): Issue {
506
500
  new Date(value.updatedAt),
507
501
  value.closed,
508
502
  fromGitHubIssueOrPullRequestState(value.state),
509
- {
510
- id: value.author.login,
511
- name: value.author.login,
512
- avatarUrl: value.author.avatarUrl,
513
- url: value.author.url,
514
- },
503
+ value.author == null
504
+ ? undefined
505
+ : {
506
+ id: value.author.login,
507
+ name: value.author.login,
508
+ avatarUrl: value.author.avatarUrl,
509
+ url: value.author.url,
510
+ },
515
511
  value.assignees.nodes.map(assignee => ({
516
512
  id: assignee.login,
517
513
  name: assignee.login,
@@ -21,8 +21,6 @@ import type {
21
21
  GitGraphSessionChangedChannels,
22
22
  GitGraphSessionRefreshOptions,
23
23
  GitGraphSessionRefreshResult,
24
- GitGraphSessionSnapshot,
25
- GraphSessionRestoreResult,
26
24
  } from '../../../../git/models/graphSession.js';
27
25
  import type { GitLog } from '../../../../git/models/log.js';
28
26
  import type { GitRemote } from '../../../../git/models/remote.js';
@@ -52,14 +50,12 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
52
50
  rev?: string;
53
51
  limit?: number;
54
52
  include?: { stats?: boolean };
55
- restore?: GitGraphSessionSnapshot;
56
- onRestore?: (result: GraphSessionRestoreResult) => void;
57
53
  },
58
54
  cancellation?: AbortSignal,
59
55
  ): Promise<GitGraphSession> {
60
56
  // GitHub-backed graphs have no incremental machinery (and `getGraph` takes no row processor); the
61
- // session is a thin window accumulator over full fetches. `rowProcessor` and `restore`/`onRestore`
62
- // (there's no incremental restore path) are accepted for interface parity and ignored — always a full walk.
57
+ // session is a thin window accumulator over full fetches. `rowProcessor` is accepted for interface
58
+ // parity and ignored — always a full walk.
63
59
  const session = new GraphSession(this, repoPath);
64
60
  await session.initialize(options, cancellation);
65
61
  return session;
@@ -295,10 +291,12 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
295
291
  date: commit.committer.date.getTime(),
296
292
  commitDate: commit.committer.date.getTime(),
297
293
  message: commit.message?.length ? commit.message : commit.summary,
298
- type: commit.parents.length > 1 ? 'merge-node' : 'commit-node',
299
- heads: refHeads,
300
- remotes: refRemoteHeads,
301
- tags: refTags,
294
+ kind: commit.parents.length > 1 ? 'merge' : 'commit',
295
+ // Omitted rather than empty: an empty array still counts as "present" to the splice
296
+ // fingerprint, which defeats its bare-row skip and makes every ref-less row look changeable.
297
+ heads: refHeads.length ? refHeads : undefined,
298
+ remotes: refRemoteHeads.length ? refRemoteHeads : undefined,
299
+ tags: refTags.length ? refTags : undefined,
302
300
  });
303
301
 
304
302
  if (commit.stats != null) {
@@ -623,11 +621,6 @@ class GraphSession implements GitGraphSession {
623
621
  return true;
624
622
  }
625
623
 
626
- serialize(): GitGraphSessionSnapshot | undefined {
627
- // No incremental restore path (no ref-tip gate / reachability table), so nothing worth persisting.
628
- return undefined;
629
- }
630
-
631
624
  dispose(): void {
632
625
  // No-op — the window lives entirely in memory.
633
626
  }
@@ -32,6 +32,38 @@ export interface IntegrationAuthenticationSessionDescriptor {
32
32
  [key: string]: unknown;
33
33
  }
34
34
 
35
+ /**
36
+ * Options for {@link IntegrationAuthenticationProvider.getSession}. The two variants keep `sync` exclusive
37
+ * with the interactive flags: a sync resolves whatever the cloud already has, so it must never open a connect
38
+ * window.
39
+ *
40
+ * `refreshRejectedToken` marks the stored token as known-refused by the provider (an `AuthenticationError` on
41
+ * a previous read), which forces a cloud `/refresh` even when the token's expiry still claims it is valid —
42
+ * the case a purely time-based refresh cannot see. The refresh token itself never reaches the client: the GK
43
+ * cloud performs the exchange server-side.
44
+ */
45
+ export type GetSessionOptions =
46
+ | {
47
+ createIfNeeded?: boolean;
48
+ forceNewSession?: boolean;
49
+ sync?: never;
50
+ refreshRejectedToken?: boolean;
51
+ source?: Sources;
52
+ }
53
+ | {
54
+ createIfNeeded?: never;
55
+ forceNewSession?: never;
56
+ sync: boolean;
57
+ refreshRejectedToken?: boolean;
58
+ source?: Sources;
59
+ };
60
+
61
+ /**
62
+ * What {@link IntegrationAuthenticationProviderBase.getSession} passes down once it has decided the stored
63
+ * session can't be served as-is. `refreshIfExpired` is its own conclusion, not a caller's option.
64
+ */
65
+ type GetNewSessionOptions = GetSessionOptions & { refreshIfExpired?: boolean };
66
+
35
67
  export interface IntegrationAuthenticationProvider extends Disposable {
36
68
  /**
37
69
  * Clears the stored secret for one connection only (never the whole provider). Unlike
@@ -46,11 +78,13 @@ export interface IntegrationAuthenticationProvider extends Disposable {
46
78
  options?: { preserveConfigured?: boolean },
47
79
  ): Promise<void>;
48
80
  deleteAllSessions(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<void>;
81
+ /**
82
+ * Resolves the session for `descriptor`, from storage when it is still usable and from the cloud
83
+ * otherwise. See {@link GetSessionOptions}.
84
+ */
49
85
  getSession(
50
86
  descriptor: IntegrationAuthenticationSessionDescriptor,
51
- options?:
52
- | { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources }
53
- | { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources },
87
+ options?: GetSessionOptions,
54
88
  ): Promise<ProviderAuthenticationSession | undefined>;
55
89
  get onDidChange(): Event<void>;
56
90
  }
@@ -120,9 +154,7 @@ abstract class IntegrationAuthenticationProviderBase<
120
154
  @trace()
121
155
  async getSession(
122
156
  descriptor: IntegrationAuthenticationSessionDescriptor,
123
- options?:
124
- | { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources }
125
- | { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources },
157
+ options?: GetSessionOptions,
126
158
  ): Promise<ProviderAuthenticationSession | undefined> {
127
159
  let session;
128
160
  let previousToken;
@@ -143,10 +175,15 @@ abstract class IntegrationAuthenticationProviderBase<
143
175
  }
144
176
 
145
177
  const isExpiredSession = session?.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now();
146
- if (session == null || isExpiredSession) {
178
+ // A rejected token has to re-resolve even when the stored session looks perfectly healthy: that is
179
+ // the whole point — the provider refused it while its expiry still says valid. Without this the
180
+ // stored session short-circuits here and the refresh below is never reached.
181
+ const refreshRejectedToken = options?.refreshRejectedToken === true && session != null;
182
+ if (session == null || isExpiredSession || refreshRejectedToken) {
147
183
  session = await this.getNewSession(descriptor, {
148
184
  ...options,
149
185
  refreshIfExpired: isExpiredSession,
186
+ refreshRejectedToken: refreshRejectedToken,
150
187
  });
151
188
 
152
189
  if (session != null) {
@@ -163,21 +200,7 @@ abstract class IntegrationAuthenticationProviderBase<
163
200
 
164
201
  protected abstract getNewSession(
165
202
  descriptor: IntegrationAuthenticationSessionDescriptor,
166
- options?:
167
- | {
168
- createIfNeeded?: boolean;
169
- forceNewSession?: boolean;
170
- sync?: never;
171
- refreshIfExpired?: boolean;
172
- source?: Sources;
173
- }
174
- | {
175
- createIfNeeded?: never;
176
- forceNewSession?: never;
177
- sync: boolean;
178
- refreshIfExpired?: boolean;
179
- source?: Sources;
180
- },
203
+ options?: GetNewSessionOptions,
181
204
  ): Promise<ProviderAuthenticationSession | undefined>;
182
205
 
183
206
  protected fireChange(): void {
@@ -202,21 +225,7 @@ export class CloudIntegrationAuthenticationProvider<
202
225
 
203
226
  protected override async getNewSession(
204
227
  descriptor: IntegrationAuthenticationSessionDescriptor,
205
- options?:
206
- | {
207
- createIfNeeded?: boolean;
208
- forceNewSession?: boolean;
209
- sync?: never;
210
- refreshIfExpired?: boolean;
211
- source?: Sources;
212
- }
213
- | {
214
- createIfNeeded?: never;
215
- forceNewSession?: never;
216
- sync: boolean;
217
- refreshIfExpired?: boolean;
218
- source?: Sources;
219
- },
228
+ options?: GetNewSessionOptions,
220
229
  ): Promise<ProviderAuthenticationSession | undefined> {
221
230
  if (options?.forceNewSession) {
222
231
  if ((await this.disconnectCloudSession()) === 'failure') {
@@ -230,9 +239,15 @@ export class CloudIntegrationAuthenticationProvider<
230
239
  // TODO: This is a stopgap to make sure we're not hammering the api on automatic calls to get the session.
231
240
  // Ultimately we want to timestamp calls to syncCloudIntegrations and use that to determine whether we should
232
241
  // make the call or not.
242
+ // `refreshRejectedToken` joins the list for the same reason `refreshIfExpired` is on it: both mean the
243
+ // session in hand is known-unusable, so skipping the cloud round trip would just re-serve it.
233
244
  let session =
234
- options?.refreshIfExpired || options?.createIfNeeded || options?.forceNewSession || options?.sync
235
- ? await this.getCloudSession(descriptor)
245
+ options?.refreshIfExpired ||
246
+ options?.refreshRejectedToken ||
247
+ options?.createIfNeeded ||
248
+ options?.forceNewSession ||
249
+ options?.sync
250
+ ? await this.getCloudSession(descriptor, { refreshRejectedToken: options?.refreshRejectedToken })
236
251
  : undefined;
237
252
 
238
253
  const shouldCreateSession = options?.createIfNeeded && session == null;
@@ -269,6 +284,7 @@ export class CloudIntegrationAuthenticationProvider<
269
284
 
270
285
  private async getCloudSession(
271
286
  descriptor: IntegrationAuthenticationSessionDescriptor,
287
+ options?: { refreshRejectedToken?: boolean },
272
288
  ): Promise<ProviderAuthenticationSession | undefined> {
273
289
  const loggedIn = (await this.authenticationService.ctx.account.getAccount()) != null;
274
290
  if (!loggedIn) return undefined;
@@ -304,7 +320,13 @@ export class CloudIntegrationAuthenticationProvider<
304
320
  session.expiresIn = maxSmallIntegerV8; // maximum expiration length
305
321
  }
306
322
 
307
- if (session != null && session.expiresIn < 60) {
323
+ // `expiresIn < 60` is a PREDICTION that the token is about to lapse, and it is the only trigger the
324
+ // refresh used to have. It cannot see a token the provider has already refused while the backend
325
+ // still believes it is valid — a revoked grant, retired scopes, an app removed from the org — where
326
+ // `expiresIn` stays high and the doomed token is re-sent on every read. `refreshRejectedToken` is
327
+ // the observed counterpart: the caller saw the provider reject this exact token, so refresh it
328
+ // regardless of what its expiry claims.
329
+ if (session != null && (session.expiresIn < 60 || options?.refreshRejectedToken)) {
308
330
  session = await cloudIntegrations.getConnectionSession(
309
331
  this.authProviderId,
310
332
  session.accessToken,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Per-connection recovery state for a cloud token the provider REFUSED — a revoked grant, retired scopes, an
3
+ * app removed from the org. A purely predictive refresh (`expiresIn < 60`) cannot see that case, because the
4
+ * backend still believes the token is valid, so without this the refused token is re-sent on every read and
5
+ * the failure only clears when the user reconnects by hand.
6
+ *
7
+ * One recovery refresh per connection, then the connection is the failure budget's problem:
8
+ * - {@link recordRejection} on an `AuthenticationError` — arms the refresh, or reports that the rejection is
9
+ * not recoverable so the caller counts it as a real failure instead.
10
+ * - {@link claimRefresh} before the next read resolves its session — spends the rejection, so the refresh
11
+ * fires once and no further read of that connection asks the cloud again.
12
+ *
13
+ * Deliberately NOT re-armed when the refresh hands back a different token. The GK cloud mints a new access
14
+ * token on every `/refresh`, so a credential the provider refuses for a reason a refresh cannot fix (retired
15
+ * scopes being the plain case) would look "rotated" on each attempt: re-arming on that would refresh on every
16
+ * read forever and the failure budget — the thing that disconnects and prompts a reconnect — would never
17
+ * advance. What resets this instead is {@link clear}, called by every path that replaces or removes the stored
18
+ * tokens; a connection that is simply working never reaches here at all.
19
+ */
20
+ export class RejectedTokenTracker {
21
+ private readonly _rejections = new Map<string, 'armed' | 'spent'>();
22
+
23
+ /**
24
+ * Whether a read of `connectionId` must force the cloud refresh of a token known to be refused, spending
25
+ * the rejection so the refresh runs at most once. Spent here rather than after the read because a resolve
26
+ * that throws must not leave the refresh armed and ask the cloud again on the next read.
27
+ */
28
+ claimRefresh(connectionId: string): boolean {
29
+ if (this._rejections.get(connectionId) !== 'armed') return false;
30
+
31
+ this._rejections.set(connectionId, 'spent');
32
+ return true;
33
+ }
34
+
35
+ /**
36
+ * Arms the recovery refresh for `connectionId`. Returns `false` once this connection has had its refresh
37
+ * (or already has one pending), so the caller falls back to its own failure handling: a token still
38
+ * refused after the refresh is not self-healing, and only the failure budget ends in a reconnect prompt.
39
+ */
40
+ recordRejection(connectionId: string): boolean {
41
+ if (this._rejections.has(connectionId)) return false;
42
+
43
+ this._rejections.set(connectionId, 'armed');
44
+ return true;
45
+ }
46
+
47
+ /** Drops all state, for when the stored tokens a rejection could name are replaced or gone. */
48
+ clear(): void {
49
+ this._rejections.clear();
50
+ }
51
+
52
+ /** Whether any connection is holding a rejection. For assertions; the read path uses {@link claimRefresh}. */
53
+ get empty(): boolean {
54
+ return this._rejections.size === 0;
55
+ }
56
+ }
@@ -1,4 +1,9 @@
1
- import type { CollectionMetadata, CollectionScopeFailure } from '@gitkraken/provider-apis';
1
+ import type {
2
+ CollectionMetadata,
3
+ CollectionOmission,
4
+ CollectionScope,
5
+ CollectionScopeFailure,
6
+ } from '@gitkraken/provider-apis';
2
7
  import { AuthenticationError, RequestNotFoundError, RequestRateLimitError } from '../../git/errors.js';
3
8
  import type { IntegrationIds } from './constants.js';
4
9
  import { isRateLimitResponse } from './errors.js';
@@ -68,8 +73,21 @@ function toCollectionFailureWarningKind(failure: CollectionScopeFailure): Provid
68
73
  }
69
74
  }
70
75
 
71
- function collectionFailureMessage(failure: CollectionScopeFailure): string {
72
- const scope = failure.scope;
76
+ /**
77
+ * A scope's identity, as the stable string that keys it.
78
+ *
79
+ * The dedup keys in `providerPaging.ts` build on this, so failures and omissions can never disagree about
80
+ * what "the same scope" means. `providerId` is included: the same repository ID under two providers is two
81
+ * scopes.
82
+ */
83
+ export function collectionScopeKey(scope: CollectionScope | undefined): string {
84
+ return [scope?.providerId ?? '', scope?.resourceId ?? '', scope?.projectId ?? '', scope?.repositoryId ?? ''].join(
85
+ ' ',
86
+ );
87
+ }
88
+
89
+ /** ` (resource r, project p, repository o/n)` for the scope IDs present; empty when the scope names none. */
90
+ function collectionScopeText(scope: CollectionScope | undefined): string {
73
91
  const parts: string[] = [];
74
92
  if (scope?.resourceId != null) {
75
93
  parts.push(`resource ${scope.resourceId}`);
@@ -81,9 +99,64 @@ function collectionFailureMessage(failure: CollectionScopeFailure): string {
81
99
  parts.push(`repository ${scope.repositoryId}`);
82
100
  }
83
101
 
84
- const scopeText = parts.length ? ` (${parts.join(', ')})` : '';
102
+ return parts.length ? ` (${parts.join(', ')})` : '';
103
+ }
104
+
105
+ function collectionFailureMessage(failure: CollectionScopeFailure): string {
85
106
  const detail = failure.message != null ? `: ${failure.message}` : '';
86
- return `Failed to read ${failure.kind} scope${scopeText}${detail}`;
107
+ return `Failed to read ${failure.kind} scope${collectionScopeText(failure.scope)}${detail}`;
108
+ }
109
+
110
+ /**
111
+ * Explains one omission in the consumer's terms — what was left out and, where the SDK reports it, how much.
112
+ *
113
+ * An omission is a completeness fact, never a failure: the read succeeded and the provider (or the SDK's own
114
+ * recovery budget) is what withheld results, so retrying the same request cannot recover them. That is why
115
+ * these never contribute to `fetchFailed`.
116
+ */
117
+ function collectionOmissionMessage(omission: CollectionOmission): string {
118
+ const scopeText = collectionScopeText(omission.scope);
119
+ switch (omission.kind) {
120
+ case 'provider-limit':
121
+ // `totalCount` is the only figure saying how much was withheld; both fields are optional, so degrade
122
+ // through the shapes the SDK can actually emit rather than printing `undefined`.
123
+ if (omission.totalCount != null && omission.limit != null) {
124
+ return `Search${scopeText} matched ${omission.totalCount} results, but the provider exposes at most ${omission.limit}`;
125
+ }
126
+ if (omission.limit != null) {
127
+ return `Search${scopeText} exceeded the provider limit of ${omission.limit} results`;
128
+ }
129
+ return `Search${scopeText} exceeded the provider's result limit`;
130
+ case 'recovery-budget':
131
+ return `Stopped recovering omitted results${scopeText} after reaching the request budget${
132
+ omission.limit != null ? ` of ${omission.limit} requests` : ''
133
+ }`;
134
+ case 'pagination-incomplete':
135
+ return `More results are available${scopeText} than this read returned`;
136
+ default:
137
+ // `CollectionOmissionKind` is a closed union, so this is unreachable today. Kept as a compile-time
138
+ // guard rather than a silent fallback: an SDK bump that adds a kind breaks the build here instead
139
+ // of quietly degrading it to a vague message.
140
+ omission.kind satisfies never;
141
+ return `Some results were omitted${scopeText}; the read is incomplete`;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Whether SDK metadata describes a read that may be missing results.
147
+ *
148
+ * A reported omission counts on its own rather than deferring to `completeness`. The SDK does couple the
149
+ * two — every site that emits an omission degrades completeness in the same call — so today the omission
150
+ * clause changes no outcome. It is defensive: the two facts arrive as independent fields, and a read that
151
+ * names what it left out must not be publishable as whole if a future producer sets only one of them.
152
+ *
153
+ * This is the single definition of "may be missing results", shared with `getPagedResult`, so one metadata
154
+ * object cannot be truncated at one layer and complete at another.
155
+ */
156
+ export function isIncompleteCollection(metadata: CollectionMetadata | undefined): boolean {
157
+ if (metadata == null) return false;
158
+
159
+ return metadata.completeness !== 'complete' || (metadata.omissions?.length ?? 0) > 0;
87
160
  }
88
161
 
89
162
  /** Converts internal SDK collection metadata into neutral provider facade signals. */
@@ -109,8 +182,25 @@ export function assessCollectionMetadata(
109
182
  });
110
183
  }
111
184
 
112
- const incomplete = metadata.completeness !== 'complete';
113
- if (incomplete && failures.length === 0) {
185
+ // Omissions explain WHY a read is incomplete when nothing failed — a provider cap, an exhausted recovery
186
+ // budget, an undrained scope. They classify as `other`, never `auth`, and are deliberately excluded from
187
+ // `fetchFailed` below: the request succeeded, so a retry would return the same truncated set.
188
+ const omissions = metadata.omissions ?? [];
189
+ for (const omission of omissions) {
190
+ appendDedupedWarning(warnings, {
191
+ providerId: providerId,
192
+ domain: domain,
193
+ connectionId: connectionId,
194
+ message: collectionOmissionMessage(omission),
195
+ kind: 'other',
196
+ isAuth: false,
197
+ });
198
+ }
199
+
200
+ const incomplete = isIncompleteCollection(metadata);
201
+ // Only fall back to the generic message when nothing more specific was reported; an omission already
202
+ // explains the incompleteness in the consumer's terms, so adding this on top would be noise.
203
+ if (incomplete && failures.length === 0 && omissions.length === 0) {
114
204
  appendDedupedWarning(warnings, {
115
205
  providerId: providerId,
116
206
  domain: domain,