Skip to content

Commit 3eac306

Browse files
committed
bugfix-309-setup-crash: Refactor token handling in CLI setup to prioritize command line input and improve validation logic. Enhance error logging for project item retrieval and update tests to cover pagination scenarios and invalid project IDs.
1 parent b7c59bb commit 3eac306

8 files changed

Lines changed: 268 additions & 57 deletions

File tree

build/cli/index.js

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47651,8 +47651,8 @@ program
4765147651
process.exit(1);
4765247652
}
4765347653
(0, logger_1.logInfo)(`📦 Repository: ${gitInfo.owner}/${gitInfo.repo}`);
47654-
const hasTokenFromCli = Boolean(options.token && String(options.token).trim());
47655-
if (!hasTokenFromCli && !(0, setup_files_1.hasValidSetupToken)(cwd)) {
47654+
const token = (0, setup_files_1.getSetupToken)(cwd, options.token);
47655+
if (!token) {
4765647656
(0, logger_1.logError)('🛑 Setup requires PERSONAL_ACCESS_TOKEN with a valid token.');
4765747657
(0, logger_1.logInfo)(' You can:');
4765847658
(0, logger_1.logInfo)(' • Pass it on the command line: copilot setup --token <your_github_token>');
@@ -47670,7 +47670,7 @@ program
4767047670
[constants_1.INPUT_KEYS.DEBUG]: options.debug.toString(),
4767147671
[constants_1.INPUT_KEYS.SINGLE_ACTION]: constants_1.ACTIONS.INITIAL_SETUP,
4767247672
[constants_1.INPUT_KEYS.SINGLE_ACTION_ISSUE]: 1,
47673-
[constants_1.INPUT_KEYS.TOKEN]: options.token || process.env.PERSONAL_ACCESS_TOKEN || (0, setup_files_1.getSetupToken)(cwd),
47673+
[constants_1.INPUT_KEYS.TOKEN]: token,
4767447674
repo: {
4767547675
owner: gitInfo.owner,
4767647676
repo: gitInfo.repo,
@@ -48730,11 +48730,15 @@ class ProjectDetail {
4873048730
/**
4873148731
* Returns the full public URL to the project (board).
4873248732
* Uses the URL from the API when present and valid; otherwise builds it from owner, type and number.
48733+
* Returns empty string when project number is invalid (e.g. missing from API).
4873348734
*/
4873448735
get publicUrl() {
4873548736
if (this.url && typeof this.url === 'string' && this.url.startsWith('https://')) {
4873648737
return this.url;
4873748738
}
48739+
if (typeof this.number !== 'number' || this.number <= 0) {
48740+
return '';
48741+
}
4873848742
const path = this.type === 'organization' ? 'orgs' : 'users';
4873948743
return `https://github.com/${path}/${this.owner}/projects/${this.number}`;
4874048744
}
@@ -51582,14 +51586,22 @@ class ProjectRepository {
5158251586
number: issueOrPullRequestNumber
5158351587
});
5158451588
if (!issueOrPrResult.repository.issueOrPullRequest) {
51585-
console.error(`Issue or PR #${issueOrPullRequestNumber} not found.`);
51589+
(0, logger_1.logError)(`Issue or PR #${issueOrPullRequestNumber} not found in repository.`);
5158651590
return undefined;
5158751591
}
5158851592
const contentId = issueOrPrResult.repository.issueOrPullRequest.id;
5158951593
// Search for the item ID in the project with pagination
5159051594
let cursor = null;
5159151595
let projectItemId = undefined;
51596+
let totalItemsChecked = 0;
51597+
const maxPages = 100; // 100 * 100 = 10_000 items max to avoid runaway loops
51598+
let pageCount = 0;
5159251599
do {
51600+
if (pageCount >= maxPages) {
51601+
(0, logger_1.logError)(`Stopped after ${maxPages} pages (${totalItemsChecked} items). Issue or PR #${issueOrPullRequestNumber} not found in project.`);
51602+
break;
51603+
}
51604+
pageCount += 1;
5159351605
const projectQuery = `
5159451606
query($projectId: ID!, $cursor: String) {
5159551607
node(id: $projectId) {
@@ -51618,16 +51630,36 @@ class ProjectRepository {
5161851630
projectId: project.id,
5161951631
cursor
5162051632
});
51621-
const items = projectResult.node.items.nodes;
51633+
if (projectResult.node === null) {
51634+
(0, logger_1.logError)(`Project not found for ID "${project.id}". Ensure the project is loaded via getProjectDetail (GraphQL node ID), not the project number.`);
51635+
throw new Error(`Project not found or invalid project ID. The project ID must be the GraphQL node ID from the API (e.g. PVT_...), not the project number.`);
51636+
}
51637+
const items = projectResult.node.items?.nodes ?? [];
51638+
totalItemsChecked += items.length;
51639+
const pageInfo = projectResult.node.items?.pageInfo;
5162251640
const foundItem = items.find((item) => item.content?.id === contentId);
5162351641
if (foundItem) {
5162451642
projectItemId = foundItem.id;
5162551643
break;
5162651644
}
51627-
cursor = projectResult.node.items.pageInfo.hasNextPage
51628-
? projectResult.node.items.pageInfo.endCursor
51629-
: null;
51645+
// Advance cursor only when there is a next page AND a non-null cursor (avoid missing pages)
51646+
const hasNextPage = pageInfo?.hasNextPage === true;
51647+
const endCursor = pageInfo?.endCursor ?? null;
51648+
if (hasNextPage && endCursor) {
51649+
cursor = endCursor;
51650+
}
51651+
else {
51652+
if (hasNextPage && !endCursor) {
51653+
(0, logger_1.logError)(`Project items pagination: hasNextPage is true but endCursor is null (page ${pageCount}, ${totalItemsChecked} items so far). Cannot fetch more.`);
51654+
}
51655+
cursor = null;
51656+
}
5163051657
} while (cursor);
51658+
if (projectItemId === undefined) {
51659+
(0, logger_1.logError)(`Issue or PR #${issueOrPullRequestNumber} not found in project after checking ${totalItemsChecked} items (${pageCount} page(s)). ` +
51660+
`Link it to the project first, or wait for the board to sync.`);
51661+
throw new Error(`Issue or pull request #${issueOrPullRequestNumber} is not in the project yet (checked ${totalItemsChecked} items). Link it to the project first, or wait for the board to sync.`);
51662+
}
5163151663
return projectItemId;
5163251664
};
5163351665
this.isContentLinked = async (project, contentId, token) => {
@@ -60515,15 +60547,19 @@ function ensureEnvWithToken(cwd) {
6051560547
}
6051660548
function isTokenValueValid(token) {
6051760549
const t = token.trim();
60518-
return (t.length >= MIN_VALID_TOKEN_LENGTH &&
60519-
t !== ENV_PLACEHOLDER_VALUE &&
60520-
!t.startsWith('github_pat_11..'));
60550+
return t.length >= MIN_VALID_TOKEN_LENGTH && t !== ENV_PLACEHOLDER_VALUE;
6052160551
}
6052260552
/**
60523-
* Returns the PERSONAL_ACCESS_TOKEN to use for setup (from environment or .env in cwd).
60524-
* Same resolution order as hasValidSetupToken; returns undefined if no valid token is found.
60553+
* Resolves the PERSONAL_ACCESS_TOKEN for setup from a single priority order:
60554+
* 1. override (e.g. CLI --token) if provided and valid,
60555+
* 2. process.env.PERSONAL_ACCESS_TOKEN,
60556+
* 3. .env file in cwd.
60557+
* Returns undefined if no valid token is found.
6052560558
*/
60526-
function getSetupToken(cwd) {
60559+
function getSetupToken(cwd, override) {
60560+
const overrideTrimmed = override?.trim();
60561+
if (overrideTrimmed && isTokenValueValid(overrideTrimmed))
60562+
return overrideTrimmed;
6052760563
const fromEnv = process.env[ENV_TOKEN_KEY]?.trim();
6052860564
if (fromEnv && isTokenValueValid(fromEnv))
6052960565
return fromEnv;
@@ -60534,11 +60570,11 @@ function getSetupToken(cwd) {
6053460570
return undefined;
6053560571
}
6053660572
/**
60537-
* Returns true if PERSONAL_ACCESS_TOKEN is available and looks like a real token
60538-
* (from environment or .env), not the placeholder. Setup should only continue when this is true.
60573+
* Returns true if a valid setup token is available (same resolution order as getSetupToken).
60574+
* Pass an optional override (e.g. CLI --token) so validation considers all sources consistently.
6053960575
*/
60540-
function hasValidSetupToken(cwd) {
60541-
return getSetupToken(cwd) !== undefined;
60576+
function hasValidSetupToken(cwd, override) {
60577+
return getSetupToken(cwd, override) !== undefined;
6054260578
}
6054360579
/** Returns true if a .env file exists in the given directory. */
6054460580
function setupEnvFileExists(cwd) {

build/cli/src/data/model/project_detail.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export declare class ProjectDetail {
99
/**
1010
* Returns the full public URL to the project (board).
1111
* Uses the URL from the API when present and valid; otherwise builds it from owner, type and number.
12+
* Returns empty string when project number is invalid (e.g. missing from API).
1213
*/
1314
get publicUrl(): string;
1415
}

build/cli/src/utils/setup_files.d.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@ export declare function copySetupFiles(cwd: string, setupDirOverride?: string):
2121
*/
2222
export declare function ensureEnvWithToken(cwd: string): void;
2323
/**
24-
* Returns the PERSONAL_ACCESS_TOKEN to use for setup (from environment or .env in cwd).
25-
* Same resolution order as hasValidSetupToken; returns undefined if no valid token is found.
24+
* Resolves the PERSONAL_ACCESS_TOKEN for setup from a single priority order:
25+
* 1. override (e.g. CLI --token) if provided and valid,
26+
* 2. process.env.PERSONAL_ACCESS_TOKEN,
27+
* 3. .env file in cwd.
28+
* Returns undefined if no valid token is found.
2629
*/
27-
export declare function getSetupToken(cwd: string): string | undefined;
30+
export declare function getSetupToken(cwd: string, override?: string): string | undefined;
2831
/**
29-
* Returns true if PERSONAL_ACCESS_TOKEN is available and looks like a real token
30-
* (from environment or .env), not the placeholder. Setup should only continue when this is true.
32+
* Returns true if a valid setup token is available (same resolution order as getSetupToken).
33+
* Pass an optional override (e.g. CLI --token) so validation considers all sources consistently.
3134
*/
32-
export declare function hasValidSetupToken(cwd: string): boolean;
35+
export declare function hasValidSetupToken(cwd: string, override?: string): boolean;
3336
/** Returns true if a .env file exists in the given directory. */
3437
export declare function setupEnvFileExists(cwd: string): boolean;

build/github_action/index.js

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43817,11 +43817,15 @@ class ProjectDetail {
4381743817
/**
4381843818
* Returns the full public URL to the project (board).
4381943819
* Uses the URL from the API when present and valid; otherwise builds it from owner, type and number.
43820+
* Returns empty string when project number is invalid (e.g. missing from API).
4382043821
*/
4382143822
get publicUrl() {
4382243823
if (this.url && typeof this.url === 'string' && this.url.startsWith('https://')) {
4382343824
return this.url;
4382443825
}
43826+
if (typeof this.number !== 'number' || this.number <= 0) {
43827+
return '';
43828+
}
4382543829
const path = this.type === 'organization' ? 'orgs' : 'users';
4382643830
return `https://github.com/${path}/${this.owner}/projects/${this.number}`;
4382743831
}
@@ -46651,14 +46655,22 @@ class ProjectRepository {
4665146655
number: issueOrPullRequestNumber
4665246656
});
4665346657
if (!issueOrPrResult.repository.issueOrPullRequest) {
46654-
console.error(`Issue or PR #${issueOrPullRequestNumber} not found.`);
46658+
(0, logger_1.logError)(`Issue or PR #${issueOrPullRequestNumber} not found in repository.`);
4665546659
return undefined;
4665646660
}
4665746661
const contentId = issueOrPrResult.repository.issueOrPullRequest.id;
4665846662
// Search for the item ID in the project with pagination
4665946663
let cursor = null;
4666046664
let projectItemId = undefined;
46665+
let totalItemsChecked = 0;
46666+
const maxPages = 100; // 100 * 100 = 10_000 items max to avoid runaway loops
46667+
let pageCount = 0;
4666146668
do {
46669+
if (pageCount >= maxPages) {
46670+
(0, logger_1.logError)(`Stopped after ${maxPages} pages (${totalItemsChecked} items). Issue or PR #${issueOrPullRequestNumber} not found in project.`);
46671+
break;
46672+
}
46673+
pageCount += 1;
4666246674
const projectQuery = `
4666346675
query($projectId: ID!, $cursor: String) {
4666446676
node(id: $projectId) {
@@ -46687,16 +46699,36 @@ class ProjectRepository {
4668746699
projectId: project.id,
4668846700
cursor
4668946701
});
46690-
const items = projectResult.node.items.nodes;
46702+
if (projectResult.node === null) {
46703+
(0, logger_1.logError)(`Project not found for ID "${project.id}". Ensure the project is loaded via getProjectDetail (GraphQL node ID), not the project number.`);
46704+
throw new Error(`Project not found or invalid project ID. The project ID must be the GraphQL node ID from the API (e.g. PVT_...), not the project number.`);
46705+
}
46706+
const items = projectResult.node.items?.nodes ?? [];
46707+
totalItemsChecked += items.length;
46708+
const pageInfo = projectResult.node.items?.pageInfo;
4669146709
const foundItem = items.find((item) => item.content?.id === contentId);
4669246710
if (foundItem) {
4669346711
projectItemId = foundItem.id;
4669446712
break;
4669546713
}
46696-
cursor = projectResult.node.items.pageInfo.hasNextPage
46697-
? projectResult.node.items.pageInfo.endCursor
46698-
: null;
46714+
// Advance cursor only when there is a next page AND a non-null cursor (avoid missing pages)
46715+
const hasNextPage = pageInfo?.hasNextPage === true;
46716+
const endCursor = pageInfo?.endCursor ?? null;
46717+
if (hasNextPage && endCursor) {
46718+
cursor = endCursor;
46719+
}
46720+
else {
46721+
if (hasNextPage && !endCursor) {
46722+
(0, logger_1.logError)(`Project items pagination: hasNextPage is true but endCursor is null (page ${pageCount}, ${totalItemsChecked} items so far). Cannot fetch more.`);
46723+
}
46724+
cursor = null;
46725+
}
4669946726
} while (cursor);
46727+
if (projectItemId === undefined) {
46728+
(0, logger_1.logError)(`Issue or PR #${issueOrPullRequestNumber} not found in project after checking ${totalItemsChecked} items (${pageCount} page(s)). ` +
46729+
`Link it to the project first, or wait for the board to sync.`);
46730+
throw new Error(`Issue or pull request #${issueOrPullRequestNumber} is not in the project yet (checked ${totalItemsChecked} items). Link it to the project first, or wait for the board to sync.`);
46731+
}
4670046732
return projectItemId;
4670146733
};
4670246734
this.isContentLinked = async (project, contentId, token) => {
@@ -55982,15 +56014,19 @@ function ensureEnvWithToken(cwd) {
5598256014
}
5598356015
function isTokenValueValid(token) {
5598456016
const t = token.trim();
55985-
return (t.length >= MIN_VALID_TOKEN_LENGTH &&
55986-
t !== ENV_PLACEHOLDER_VALUE &&
55987-
!t.startsWith('github_pat_11..'));
56017+
return t.length >= MIN_VALID_TOKEN_LENGTH && t !== ENV_PLACEHOLDER_VALUE;
5598856018
}
5598956019
/**
55990-
* Returns the PERSONAL_ACCESS_TOKEN to use for setup (from environment or .env in cwd).
55991-
* Same resolution order as hasValidSetupToken; returns undefined if no valid token is found.
56020+
* Resolves the PERSONAL_ACCESS_TOKEN for setup from a single priority order:
56021+
* 1. override (e.g. CLI --token) if provided and valid,
56022+
* 2. process.env.PERSONAL_ACCESS_TOKEN,
56023+
* 3. .env file in cwd.
56024+
* Returns undefined if no valid token is found.
5599256025
*/
55993-
function getSetupToken(cwd) {
56026+
function getSetupToken(cwd, override) {
56027+
const overrideTrimmed = override?.trim();
56028+
if (overrideTrimmed && isTokenValueValid(overrideTrimmed))
56029+
return overrideTrimmed;
5599456030
const fromEnv = process.env[ENV_TOKEN_KEY]?.trim();
5599556031
if (fromEnv && isTokenValueValid(fromEnv))
5599656032
return fromEnv;
@@ -56001,11 +56037,11 @@ function getSetupToken(cwd) {
5600156037
return undefined;
5600256038
}
5600356039
/**
56004-
* Returns true if PERSONAL_ACCESS_TOKEN is available and looks like a real token
56005-
* (from environment or .env), not the placeholder. Setup should only continue when this is true.
56040+
* Returns true if a valid setup token is available (same resolution order as getSetupToken).
56041+
* Pass an optional override (e.g. CLI --token) so validation considers all sources consistently.
5600656042
*/
56007-
function hasValidSetupToken(cwd) {
56008-
return getSetupToken(cwd) !== undefined;
56043+
function hasValidSetupToken(cwd, override) {
56044+
return getSetupToken(cwd, override) !== undefined;
5600956045
}
5601056046
/** Returns true if a .env file exists in the given directory. */
5601156047
function setupEnvFileExists(cwd) {

build/github_action/src/data/model/project_detail.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export declare class ProjectDetail {
99
/**
1010
* Returns the full public URL to the project (board).
1111
* Uses the URL from the API when present and valid; otherwise builds it from owner, type and number.
12+
* Returns empty string when project number is invalid (e.g. missing from API).
1213
*/
1314
get publicUrl(): string;
1415
}

build/github_action/src/utils/setup_files.d.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@ export declare function copySetupFiles(cwd: string, setupDirOverride?: string):
2121
*/
2222
export declare function ensureEnvWithToken(cwd: string): void;
2323
/**
24-
* Returns the PERSONAL_ACCESS_TOKEN to use for setup (from environment or .env in cwd).
25-
* Same resolution order as hasValidSetupToken; returns undefined if no valid token is found.
24+
* Resolves the PERSONAL_ACCESS_TOKEN for setup from a single priority order:
25+
* 1. override (e.g. CLI --token) if provided and valid,
26+
* 2. process.env.PERSONAL_ACCESS_TOKEN,
27+
* 3. .env file in cwd.
28+
* Returns undefined if no valid token is found.
2629
*/
27-
export declare function getSetupToken(cwd: string): string | undefined;
30+
export declare function getSetupToken(cwd: string, override?: string): string | undefined;
2831
/**
29-
* Returns true if PERSONAL_ACCESS_TOKEN is available and looks like a real token
30-
* (from environment or .env), not the placeholder. Setup should only continue when this is true.
32+
* Returns true if a valid setup token is available (same resolution order as getSetupToken).
33+
* Pass an optional override (e.g. CLI --token) so validation considers all sources consistently.
3134
*/
32-
export declare function hasValidSetupToken(cwd: string): boolean;
35+
export declare function hasValidSetupToken(cwd: string, override?: string): boolean;
3336
/** Returns true if a .env file exists in the given directory. */
3437
export declare function setupEnvFileExists(cwd: string): boolean;

0 commit comments

Comments
 (0)