terminalhire 0.20.0 → 0.21.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.
@@ -99,6 +99,7 @@ var require_keytar2 = __commonJS({
99
99
  // src/claims.ts
100
100
  var claims_exports = {};
101
101
  __export(claims_exports, {
102
+ PUSHED_CLAIM_FIELDS: () => PUSHED_CLAIM_FIELDS,
102
103
  acceptedPRRate: () => acceptedPRRate,
103
104
  findClaim: () => findClaim,
104
105
  listClaims: () => listClaims,
@@ -199,12 +200,21 @@ function acceptedPRRate(claims = readClaims()) {
199
200
  const merged = claims.filter((c) => c.state === "merged").length;
200
201
  return { merged, total, rate: total === 0 ? 0 : merged / total };
201
202
  }
202
- var TERMINALHIRE_DIR2, CLAIMS_FILE, TERMINAL_STATES;
203
+ var TERMINALHIRE_DIR2, CLAIMS_FILE, PUSHED_CLAIM_FIELDS, TERMINAL_STATES;
203
204
  var init_claims = __esm({
204
205
  "src/claims.ts"() {
205
206
  "use strict";
206
- TERMINALHIRE_DIR2 = join2(homedir2(), ".terminalhire");
207
+ TERMINALHIRE_DIR2 = process.env.TERMINALHIRE_DIR || join2(homedir2(), ".terminalhire");
207
208
  CLAIMS_FILE = join2(TERMINALHIRE_DIR2, "claims.json");
209
+ PUSHED_CLAIM_FIELDS = [
210
+ "kind",
211
+ "repoFullName",
212
+ "state",
213
+ "prUrl",
214
+ "merged",
215
+ "claimedAt",
216
+ "updatedAt"
217
+ ];
208
218
  TERMINAL_STATES = /* @__PURE__ */ new Set(["merged", "abandoned"]);
209
219
  }
210
220
  });
@@ -278,7 +288,6 @@ var CLAIM_PUSH_TOKEN_FILE = join3(TERMINALHIRE_DIR3, "claim-push-token.enc");
278
288
  var CLAIM_SYNC_BASE = "https://terminalhire.com";
279
289
  var AUTO_CONSENT_VERSION = 2;
280
290
  var AUTO_PUSH_THROTTLE_MS = 24 * 60 * 60 * 1e3;
281
- var CLAIM_PUSH_FIELDS = ["kind", "repoFullName", "state", "prUrl", "merged", "claimedAt", "updatedAt"];
282
291
  async function writePushTokenEnc(rawToken) {
283
292
  mkdirSync3(TERMINALHIRE_DIR3, { recursive: true });
284
293
  const key = await loadKey();
@@ -348,7 +357,7 @@ async function runBackgroundClaimPush({ now = Date.now() } = {}) {
348
357
  if (!existsSync3(CLAIM_PUSH_AUTO_MARKER) || !existsSync3(CLAIM_PUSH_TOKEN_FILE)) return;
349
358
  const marker = readAutoMarker();
350
359
  if (!marker || !marker.autoConsentedAt) return;
351
- const { listClaims: listClaims2, toPushedClaim: toPushedClaim2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
360
+ const { listClaims: listClaims2, toPushedClaim: toPushedClaim2, PUSHED_CLAIM_FIELDS: PUSHED_CLAIM_FIELDS2 } = await Promise.resolve().then(() => (init_claims(), claims_exports));
352
361
  const pushed = listClaims2().map((c) => toPushedClaim2(c));
353
362
  const currentHash = computeSnapshotHash(pushed);
354
363
  const gate = backgroundPushGate({
@@ -363,15 +372,15 @@ async function runBackgroundClaimPush({ now = Date.now() } = {}) {
363
372
  if (!gate.push) return;
364
373
  const token = await readPushTokenEnc();
365
374
  if (!token) return;
366
- const consentToken = {
375
+ const consentReceipt = {
367
376
  consentedAt: marker.autoConsentedAt,
368
377
  version: AUTO_CONSENT_VERSION,
369
- fields: CLAIM_PUSH_FIELDS
378
+ fields: PUSHED_CLAIM_FIELDS2
370
379
  };
371
380
  const res = await fetch(`${CLAIM_SYNC_BASE}/api/claim-sync`, {
372
381
  method: "POST",
373
382
  headers: { "Content-Type": "application/json" },
374
- body: JSON.stringify({ consentToken, claims: pushed, pushToken: token }),
383
+ body: JSON.stringify({ consentToken: consentReceipt, claims: pushed, pushToken: token }),
375
384
  signal: AbortSignal.timeout(1e4)
376
385
  });
377
386
  if (!res.ok) return;
@@ -924,7 +924,7 @@ async function repoContributorCount(owner, name, token) {
924
924
  return void 0;
925
925
  }
926
926
  }
927
- async function fetchRepoMeta(owner, name, token, cache) {
927
+ async function fetchRepoMeta(owner, name, token, cache, stats) {
928
928
  const key = `${owner}/${name}`.toLowerCase();
929
929
  const cached = cache.get(key);
930
930
  if (cached !== void 0) return cached;
@@ -941,8 +941,10 @@ async function fetchRepoMeta(owner, name, token, cache) {
941
941
  topics: r.topics ?? [],
942
942
  contributors
943
943
  };
944
- } catch {
944
+ } catch (err) {
945
945
  meta = null;
946
+ const msg = err instanceof Error ? err.message : String(err);
947
+ if (stats && TRANSIENT_META_ERROR.test(msg)) stats.transient += 1;
946
948
  }
947
949
  cache.set(key, meta);
948
950
  return meta;
@@ -981,8 +983,10 @@ async function computeAcceptanceFromSearch(login, token, ownedOrgs, cache, gates
981
983
  return emptyCredential(/HTTP 403|HTTP 429|rate limit/i.test(msg) ? "rate-limited" : "failed");
982
984
  }
983
985
  const byDomain = {};
986
+ const distinctOrgSet = /* @__PURE__ */ new Set();
984
987
  let qualifyingTotal = 0;
985
988
  const qualifyingPRs = [];
989
+ const metaStats = { transient: 0 };
986
990
  for (const item of items) {
987
991
  const repo = parseRepoUrl(item.repository_url);
988
992
  if (!repo) continue;
@@ -990,13 +994,20 @@ async function computeAcceptanceFromSearch(login, token, ownedOrgs, cache, gates
990
994
  if (ownerLc === loginLc) continue;
991
995
  if (ownedOrgs.has(ownerLc)) continue;
992
996
  if (isTrivialPRTitle(item.title)) continue;
993
- const meta = await fetchRepoMeta(repo.owner, repo.name, token, cache);
997
+ const meta = await fetchRepoMeta(repo.owner, repo.name, token, cache, metaStats);
998
+ if (metaStats.transient > 0) {
999
+ console.warn(
1000
+ `[acceptance] ${login}: per-repo metadata transient failure (${metaStats.transient}) \u2014 degrading to 'rate-limited' rather than a fabricated count`
1001
+ );
1002
+ return emptyCredential("rate-limited");
1003
+ }
994
1004
  if (!meta) continue;
995
1005
  if (meta.private) continue;
996
1006
  if (meta.archived || meta.fork) continue;
997
1007
  if (meta.stars < gates.minStars) continue;
998
1008
  if (meta.contributors !== void 0 && meta.contributors < gates.minContributors) continue;
999
1009
  qualifyingTotal += 1;
1010
+ distinctOrgSet.add(ownerLc);
1000
1011
  const mergedAt = item.pull_request?.merged_at ?? item.closed_at ?? item.created_at;
1001
1012
  const rawDomains = [meta.language ?? "", ...meta.topics].filter(Boolean);
1002
1013
  const domainTags = [...new Set(normalize(rawDomains))];
@@ -1022,7 +1033,14 @@ async function computeAcceptanceFromSearch(login, token, ownedOrgs, cache, gates
1022
1033
  lastMergedAt: b.lastMergedAt
1023
1034
  };
1024
1035
  }
1025
- return { status: "ok", byDomain: finalDomains, qualifyingTotal, qualifyingPRs, computedAt };
1036
+ return {
1037
+ status: "ok",
1038
+ byDomain: finalDomains,
1039
+ qualifyingTotal,
1040
+ qualifyingPRs,
1041
+ distinctOrgs: distinctOrgSet.size,
1042
+ computedAt
1043
+ };
1026
1044
  }
1027
1045
  async function computeAcceptanceCredential(login, token, cache = /* @__PURE__ */ new Map()) {
1028
1046
  if (!token) return emptyCredential("no-token");
@@ -1036,6 +1054,61 @@ async function computeAcceptanceCredentialPublic(login, token, cache = /* @__PUR
1036
1054
  const gates = opts?.relaxGates ? { minStars: 0, minContributors: 0 } : void 0;
1037
1055
  return computeAcceptanceFromSearch(login, token, ownedOrgs, cache, gates);
1038
1056
  }
1057
+ async function fetchOpenExternalPRs(login, token, cache = /* @__PURE__ */ new Map(), gates = {
1058
+ minStars: MIN_STARS,
1059
+ minContributors: MIN_CONTRIBUTORS
1060
+ }) {
1061
+ if (!token) return [];
1062
+ const loginLc = login.toLowerCase();
1063
+ let ownedOrgs;
1064
+ try {
1065
+ ownedOrgs = await fetchPublicOrgs(login, token);
1066
+ } catch {
1067
+ return null;
1068
+ }
1069
+ let items;
1070
+ try {
1071
+ const q = encodeURIComponent(`type:pr is:open is:public author:${login} -user:${login} sort:updated`);
1072
+ const res = await ghFetch(
1073
+ `/search/issues?q=${q}&per_page=${OPEN_PR_PAGE}`,
1074
+ token
1075
+ );
1076
+ items = res.items ?? [];
1077
+ } catch (err) {
1078
+ const msg = err instanceof Error ? err.message : String(err);
1079
+ console.warn("[open-prs] search failed:", msg);
1080
+ return null;
1081
+ }
1082
+ const metaStats = { transient: 0 };
1083
+ const out = [];
1084
+ for (const item of items) {
1085
+ const repo = parseRepoUrl(item.repository_url);
1086
+ if (!repo) continue;
1087
+ const ownerLc = repo.owner.toLowerCase();
1088
+ if (ownerLc === loginLc) continue;
1089
+ if (ownedOrgs.has(ownerLc)) continue;
1090
+ if (isTrivialPRTitle(item.title)) continue;
1091
+ const meta = await fetchRepoMeta(repo.owner, repo.name, token, cache, metaStats);
1092
+ if (metaStats.transient > 0) {
1093
+ console.warn(
1094
+ `[open-prs] ${login}: per-repo metadata transient failure (${metaStats.transient}) \u2014 returning null (keep prior strip)`
1095
+ );
1096
+ return null;
1097
+ }
1098
+ if (!meta) continue;
1099
+ if (meta.private) continue;
1100
+ if (meta.archived || meta.fork) continue;
1101
+ if (meta.stars < gates.minStars) continue;
1102
+ if (meta.contributors !== void 0 && meta.contributors < gates.minContributors) continue;
1103
+ out.push({
1104
+ title: item.title,
1105
+ url: item.html_url,
1106
+ repoFullName: `${repo.owner}/${repo.name}`,
1107
+ openedAt: item.created_at
1108
+ });
1109
+ }
1110
+ return out;
1111
+ }
1039
1112
  function acceptanceCountForDomains(cred, domains) {
1040
1113
  if (cred.status !== "ok") return 0;
1041
1114
  let max = 0;
@@ -1107,7 +1180,7 @@ function deriveResumeTrend(cred, repoRecency, now = Date.now()) {
1107
1180
  }
1108
1181
  return scored.sort((a, b) => b.weight - a.weight).slice(0, 12).map((s) => s.t);
1109
1182
  }
1110
- var TRACTION_TOP_N, CANDIDATE_PR_PAGE, RESUME_DECAY_HALF_LIFE_MS, RESUME_MIN_SCORE;
1183
+ var TRACTION_TOP_N, CANDIDATE_PR_PAGE, OPEN_PR_PAGE, TRANSIENT_META_ERROR, RESUME_DECAY_HALF_LIFE_MS, RESUME_MIN_SCORE;
1111
1184
  var init_github = __esm({
1112
1185
  "../../packages/core/src/github.ts"() {
1113
1186
  "use strict";
@@ -1115,6 +1188,8 @@ var init_github = __esm({
1115
1188
  init_contribution_gate();
1116
1189
  TRACTION_TOP_N = 6;
1117
1190
  CANDIDATE_PR_PAGE = 50;
1191
+ OPEN_PR_PAGE = 20;
1192
+ TRANSIENT_META_ERROR = /HTTP 403|HTTP 429|rate limit|HTTP 5\d\d|timeout|network|fetch failed/i;
1118
1193
  RESUME_DECAY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1e3;
1119
1194
  RESUME_MIN_SCORE = 0.05;
1120
1195
  }
@@ -2860,6 +2935,10 @@ ${pr.body ?? ""}`.matchAll(/#(\d+)\b/g)) {
2860
2935
  }
2861
2936
  return refs;
2862
2937
  }
2938
+ async function fetchRateLimit(client) {
2939
+ const r = await client.json("/rate_limit");
2940
+ return r?.resources ?? null;
2941
+ }
2863
2942
  async function searchContribIssues(client, queries) {
2864
2943
  const byUrl = /* @__PURE__ */ new Map();
2865
2944
  for (const q of queries) {
@@ -2904,6 +2983,9 @@ async function aggregateContributions(opts = {}) {
2904
2983
  const jobs = [];
2905
2984
  const seen = /* @__PURE__ */ new Set();
2906
2985
  const perRepo = /* @__PURE__ */ new Map();
2986
+ let metaNull = 0;
2987
+ let contribUndefined = 0;
2988
+ let prRefsNull = 0;
2907
2989
  for (const issue of issues) {
2908
2990
  if (jobs.length >= MAX_CONTRIB_ITEMS) break;
2909
2991
  const fullName = repoFullNameFromApiUrl2(issue.repository_url);
@@ -2914,9 +2996,13 @@ async function aggregateContributions(opts = {}) {
2914
2996
  if (isAssigned(issue)) continue;
2915
2997
  if ((perRepo.get(fullName) ?? 0) >= MAX_BOUNTIES_PER_DISCOVERED_REPO) continue;
2916
2998
  const repo = await repoMeta(fullName);
2917
- if (!repo) continue;
2999
+ if (!repo) {
3000
+ metaNull++;
3001
+ continue;
3002
+ }
2918
3003
  const title = decodeEntities(issue.title).trim();
2919
3004
  const contributors = await repoContribCount(fullName);
3005
+ if (contributors === void 0) contribUndefined++;
2920
3006
  if (!passesContributionGate({
2921
3007
  fullName,
2922
3008
  stars: repo.stargazers_count,
@@ -2932,6 +3018,7 @@ async function aggregateContributions(opts = {}) {
2932
3018
  const labels = labelNames2(issue.labels);
2933
3019
  if (looksLikeContentTask({ title, body, labels })) continue;
2934
3020
  const prRefs = await repoPRRefs(fullName);
3021
+ if (prRefs === null) prRefsNull++;
2935
3022
  if (prRefs && prRefs.has(issue.number)) continue;
2936
3023
  const openPRsAtDiscovery = prRefs ? 0 : void 0;
2937
3024
  const tags = normalize(
@@ -2971,9 +3058,18 @@ async function aggregateContributions(opts = {}) {
2971
3058
  raw: issue
2972
3059
  });
2973
3060
  }
3061
+ if (!opts.fetchImpl) {
3062
+ const rl = await fetchRateLimit(client);
3063
+ const core = rl?.core ? `${rl.core.remaining}/${rl.core.limit}` : "n/a";
3064
+ const search = rl?.search ? `${rl.search.remaining}/${rl.search.limit}` : "n/a";
3065
+ const noToken = !(process.env["GITHUB_TOKEN"] ?? process.env["GH_TOKEN"]);
3066
+ console.info(
3067
+ `[contribute] build metrics \u2014 scanned=${issues.length} reposDistinct=${repoCache.size} emitted=${jobs.length} metaNull=${metaNull} contribUndefined=${contribUndefined} prRefsNull=${prRefsNull} core=${core} search=${search}` + (noToken ? " (NO TOKEN \u2192 60/hr)" : "")
3068
+ );
3069
+ }
2974
3070
  return jobs;
2975
3071
  }
2976
- var GITHUB_API2, CONTRIB_SEARCH_QUERIES, SEARCH_PER_PAGE2, MAX_CONTRIB_ITEMS, MAX_CONTRIB_ISSUES_SCANNED;
3072
+ var GITHUB_API2, CONTRIB_LABEL_QUERIES, CONTRIB_LANGUAGE_QUERIES, CONTRIB_SEARCH_QUERIES, SEARCH_PER_PAGE2, MAX_CONTRIB_ITEMS, MAX_CONTRIB_ISSUES_SCANNED;
2977
3073
  var init_contributions = __esm({
2978
3074
  "../../packages/core/src/feeds/contributions.ts"() {
2979
3075
  "use strict";
@@ -2985,15 +3081,25 @@ var init_contributions = __esm({
2985
3081
  init_github_bounties();
2986
3082
  init_http();
2987
3083
  GITHUB_API2 = "https://api.github.com";
2988
- CONTRIB_SEARCH_QUERIES = [
3084
+ CONTRIB_LABEL_QUERIES = [
2989
3085
  'label:"good first issue" type:issue state:open',
2990
- 'label:"help wanted" type:issue state:open',
2991
3086
  'label:"good-first-issue" type:issue state:open',
2992
- 'label:"help-wanted" type:issue state:open'
3087
+ 'label:"help wanted" type:issue state:open',
3088
+ 'label:"help-wanted" type:issue state:open',
3089
+ 'label:"up-for-grabs" type:issue state:open'
3090
+ ];
3091
+ CONTRIB_LANGUAGE_QUERIES = [
3092
+ ...["rust", "go", "python", "c++", "ruby"].map(
3093
+ (lang) => `label:"help wanted" language:${lang} type:issue state:open`
3094
+ ),
3095
+ ...["rust", "go"].map(
3096
+ (lang) => `label:"good first issue" language:${lang} type:issue state:open`
3097
+ )
2993
3098
  ];
3099
+ CONTRIB_SEARCH_QUERIES = [...CONTRIB_LABEL_QUERIES, ...CONTRIB_LANGUAGE_QUERIES];
2994
3100
  SEARCH_PER_PAGE2 = 100;
2995
3101
  MAX_CONTRIB_ITEMS = 150;
2996
- MAX_CONTRIB_ISSUES_SCANNED = 300;
3102
+ MAX_CONTRIB_ISSUES_SCANNED = 600;
2997
3103
  }
2998
3104
  });
2999
3105
 
@@ -6436,13 +6542,15 @@ function deriveLegibleProfile(credential, recency, traction, seniorityBand) {
6436
6542
  daysAgo = Math.max(0, Math.round(ageDays2));
6437
6543
  recencyBadge = { lastMergedAt: mostRecent, state: ageDays2 <= thresholdDays ? "live" : "dormant" };
6438
6544
  }
6439
- const orgCount = Object.values(domains).reduce((m, d) => Math.max(m, d.distinctOrgs), 0);
6545
+ const exactOrgCount = typeof credential.distinctOrgs === "number" && credential.distinctOrgs > 0;
6546
+ const orgCount = exactOrgCount ? credential.distinctOrgs : Object.values(domains).reduce((m, d) => Math.max(m, d.distinctOrgs), 0);
6440
6547
  let proofSentence;
6441
6548
  if (!ok) {
6442
6549
  proofSentence = "Contribution credential unavailable \u2014 could not verify.";
6443
6550
  } else {
6444
6551
  const prs = credential.qualifyingTotal;
6445
- let s = `${prs} substantive PR${prs === 1 ? "" : "s"} merged into at least ${orgCount} external org${orgCount === 1 ? "" : "s"} (\u2265${MIN_STARS}\u2605, \u2265${MIN_CONTRIBUTORS} contributors)`;
6552
+ const orgPhrase = exactOrgCount ? `${orgCount}` : `at least ${orgCount}`;
6553
+ let s = `${prs} substantive PR${prs === 1 ? "" : "s"} merged into ${orgPhrase} external org${orgCount === 1 ? "" : "s"} (\u2265${MIN_STARS}\u2605, \u2265${MIN_CONTRIBUTORS} contributors)`;
6446
6554
  if (daysAgo !== void 0) s += ` \u2014 most recent ${daysAgo}d ago`;
6447
6555
  proofSentence = `${s}.`;
6448
6556
  }
@@ -6680,6 +6788,7 @@ __export(src_exports, {
6680
6788
  expandWeighted: () => expandWeighted,
6681
6789
  extractSkillTags: () => extractSkillTags,
6682
6790
  fetchGitHubProfile: () => fetchGitHubProfile,
6791
+ fetchOpenExternalPRs: () => fetchOpenExternalPRs,
6683
6792
  fetchOwnedRepoTraction: () => fetchOwnedRepoTraction,
6684
6793
  fetchRepoRecency: () => fetchRepoRecency,
6685
6794
  flattenTiers: () => flattenTiers,
@@ -751,6 +751,7 @@ var init_contribution_classify = __esm({
751
751
  });
752
752
 
753
753
  // ../../packages/core/src/feeds/contributions.ts
754
+ var CONTRIB_LABEL_QUERIES, CONTRIB_LANGUAGE_QUERIES, CONTRIB_SEARCH_QUERIES;
754
755
  var init_contributions = __esm({
755
756
  "../../packages/core/src/feeds/contributions.ts"() {
756
757
  "use strict";
@@ -761,6 +762,22 @@ var init_contributions = __esm({
761
762
  init_contribution_classify();
762
763
  init_github_bounties();
763
764
  init_http();
765
+ CONTRIB_LABEL_QUERIES = [
766
+ 'label:"good first issue" type:issue state:open',
767
+ 'label:"good-first-issue" type:issue state:open',
768
+ 'label:"help wanted" type:issue state:open',
769
+ 'label:"help-wanted" type:issue state:open',
770
+ 'label:"up-for-grabs" type:issue state:open'
771
+ ];
772
+ CONTRIB_LANGUAGE_QUERIES = [
773
+ ...["rust", "go", "python", "c++", "ruby"].map(
774
+ (lang) => `label:"help wanted" language:${lang} type:issue state:open`
775
+ ),
776
+ ...["rust", "go"].map(
777
+ (lang) => `label:"good first issue" language:${lang} type:issue state:open`
778
+ )
779
+ ];
780
+ CONTRIB_SEARCH_QUERIES = [...CONTRIB_LABEL_QUERIES, ...CONTRIB_LANGUAGE_QUERIES];
764
781
  }
765
782
  });
766
783
 
@@ -768,6 +768,7 @@ var init_contribution_classify = __esm({
768
768
  });
769
769
 
770
770
  // ../../packages/core/src/feeds/contributions.ts
771
+ var CONTRIB_LABEL_QUERIES, CONTRIB_LANGUAGE_QUERIES, CONTRIB_SEARCH_QUERIES;
771
772
  var init_contributions = __esm({
772
773
  "../../packages/core/src/feeds/contributions.ts"() {
773
774
  "use strict";
@@ -778,6 +779,22 @@ var init_contributions = __esm({
778
779
  init_contribution_classify();
779
780
  init_github_bounties();
780
781
  init_http();
782
+ CONTRIB_LABEL_QUERIES = [
783
+ 'label:"good first issue" type:issue state:open',
784
+ 'label:"good-first-issue" type:issue state:open',
785
+ 'label:"help wanted" type:issue state:open',
786
+ 'label:"help-wanted" type:issue state:open',
787
+ 'label:"up-for-grabs" type:issue state:open'
788
+ ];
789
+ CONTRIB_LANGUAGE_QUERIES = [
790
+ ...["rust", "go", "python", "c++", "ruby"].map(
791
+ (lang) => `label:"help wanted" language:${lang} type:issue state:open`
792
+ ),
793
+ ...["rust", "go"].map(
794
+ (lang) => `label:"good first issue" language:${lang} type:issue state:open`
795
+ )
796
+ ];
797
+ CONTRIB_SEARCH_QUERIES = [...CONTRIB_LABEL_QUERIES, ...CONTRIB_LANGUAGE_QUERIES];
781
798
  }
782
799
  });
783
800